Skip to content

Commit 450d1c0

Browse files
author
Zehee
committed
feat: full memory file and folder CRUD in vis dashboard
- Add API endpoints for folders and memory files - Rebuild Memories view with folder tree, file list, composer editor - Support create/rename/delete folders and create/edit/delete memory files - Add integration tests for CRUD endpoints
1 parent 42f9cff commit 450d1c0

5 files changed

Lines changed: 858 additions & 95 deletions

File tree

‎src/vis/api.ts‎

Lines changed: 216 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import fs from 'fs';
1010
import path from 'path';
1111
import type { Ctx } from '../types.js';
1212
import { getSessionsRoot } from '../config.js';
13-
import { atomicWriteFile } from '../utils/paths.js';
13+
import { atomicWriteFile, safeResolve } from '../utils/paths.js';
14+
import { sanitizeFolder, sanitizeKey, toTitle } from '../utils/validation.js';
15+
import { safeParseFile } from '../utils/file-helpers.js';
1416
import type { ThemeAssociation } from '../theme-manager.js';
1517
import type { RefinedTurn } from '../refine/types.js';
16-
import type { TreeNode } from '../dao/index.js';
18+
import type { IndexEntry } from '../dao/index.js';
1719

1820
export interface WorkspaceInfo {
1921
id: string;
@@ -231,29 +233,110 @@ export function getRecentDecisions(ctx: Ctx, limit = 20): DecisionItem[] {
231233
return decisions;
232234
}
233235

234-
function treeNodeToMemoryNode(node: TreeNode): MemoryNode {
235-
const children: MemoryNode[] = [];
236-
node.children.forEach((child) => {
237-
children.push(treeNodeToMemoryNode(child));
238-
});
236+
function validateFolderPath(folder: unknown, allowRoot = true): string | null {
237+
const sanitized = sanitizeFolder(folder);
238+
if (!sanitized) return null;
239+
if (
240+
sanitized !== 'memory' &&
241+
!sanitized.startsWith('memory/') &&
242+
sanitized !== 'notes' &&
243+
!sanitized.startsWith('notes/')
244+
) {
245+
return null;
246+
}
247+
if (!allowRoot && (sanitized === 'memory' || sanitized === 'notes')) return null;
248+
return sanitized;
249+
}
239250

240-
return {
241-
name: node.name,
242-
comment: node.comment || '',
243-
files: node.files.map((file) => ({
244-
key: file.key,
245-
title: file.title,
246-
tags: file.tags,
247-
createdAt: file.createdAt || '',
248-
updatedAt: file.updatedAt || '',
249-
})),
250-
children,
251+
export function listMemoryFolders(ctx: Ctx): string[] {
252+
const folders: string[] = [];
253+
for (const root of ['memory', 'notes']) {
254+
const rootPath = path.join(ctx.storeRoot, root);
255+
if (!fs.existsSync(rootPath)) continue;
256+
folders.push(root);
257+
const walk = (dir: string, prefix: string) => {
258+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
259+
if (!entry.isDirectory()) continue;
260+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
261+
folders.push(rel);
262+
walk(path.join(dir, entry.name), rel);
263+
}
264+
};
265+
walk(rootPath, root);
266+
}
267+
return folders.sort();
268+
}
269+
270+
function buildRootNode(rootName: string, index: Record<string, IndexEntry>, storeRoot: string): MemoryNode {
271+
const root: MemoryNode = {
272+
name: rootName,
273+
comment: index[`${rootName}/`]?.comment || '',
274+
files: [],
275+
children: [],
251276
};
277+
278+
for (const key of Object.keys(index).sort()) {
279+
if (!key.startsWith(`${rootName}/`)) continue;
280+
281+
if (key.endsWith('/')) {
282+
if (key === `${rootName}/`) continue;
283+
const relativeFolder = key.slice(rootName.length + 1, -1);
284+
const parts = relativeFolder.split('/');
285+
let current = root;
286+
let currentPath = rootName;
287+
for (const part of parts) {
288+
currentPath = `${currentPath}/${part}`;
289+
let child = current.children.find((c) => c.name === part);
290+
if (!child) {
291+
child = {
292+
name: part,
293+
comment: index[`${currentPath}/`]?.comment || '',
294+
files: [],
295+
children: [],
296+
};
297+
current.children.push(child);
298+
}
299+
current = child;
300+
}
301+
current.comment = index[key]?.comment || '';
302+
} else if (key.endsWith('.md')) {
303+
const relativePath = key.slice(rootName.length + 1);
304+
const dirPart = path.dirname(relativePath);
305+
const parts = dirPart === '.' ? [] : dirPart.split('/');
306+
const fileName = path.basename(key, '.md');
307+
const value = index[key];
308+
let current = root;
309+
for (const part of parts) {
310+
let child = current.children.find((c) => c.name === part);
311+
if (!child) {
312+
child = { name: part, comment: '', files: [], children: [] };
313+
current.children.push(child);
314+
}
315+
current = child;
316+
}
317+
const filePath = path.join(storeRoot, key);
318+
const parsed = safeParseFile(filePath);
319+
current.files.push({
320+
key: fileName,
321+
title: value?.title || toTitle(fileName),
322+
tags: Array.isArray(value?.tags) ? value.tags : [],
323+
createdAt: String(parsed?.frontmatter?.createdAt || ''),
324+
updatedAt: String(parsed?.frontmatter?.updatedAt || ''),
325+
});
326+
}
327+
}
328+
329+
return root;
252330
}
253331

254332
export function getMemories(ctx: Ctx): MemoryNode {
255-
const tree = ctx.indexDao.buildMemoryTreeData();
256-
return treeNodeToMemoryNode(tree);
333+
const index = ctx.indexDao.getIndex().index;
334+
return {
335+
name: '',
336+
comment: '',
337+
files: [],
338+
children: [buildRootNode('memory', index, ctx.storeRoot), buildRootNode('notes', index, ctx.storeRoot)],
339+
};
257340
}
258341

259342
export function getMemoryContent(
@@ -270,6 +353,119 @@ export function getMemoryContent(
270353
};
271354
}
272355

356+
export async function writeMemory(
357+
ctx: Ctx,
358+
folder: string,
359+
key: string,
360+
options: { content?: string; title?: string; tags?: string[] },
361+
): Promise<{ ok: boolean; filePath?: string; error?: string }> {
362+
const normalizedFolder = validateFolderPath(folder);
363+
if (!normalizedFolder) {
364+
return { ok: false, error: 'Invalid folder path' };
365+
}
366+
const normalizedKey = sanitizeKey(key);
367+
const content = typeof options.content === 'string' ? options.content : '';
368+
const title = typeof options.title === 'string' ? options.title : undefined;
369+
const tags = Array.isArray(options.tags) ? options.tags.filter((t) => typeof t === 'string') : [];
370+
371+
const filePath = ctx.memoryStore.write(normalizedFolder, normalizedKey, content, tags, { title });
372+
await ctx.indexDao.upsertEntry(filePath);
373+
return { ok: true, filePath: path.relative(ctx.storeRoot, filePath).replace(/\\/g, '/') };
374+
}
375+
376+
export async function deleteMemory(
377+
ctx: Ctx,
378+
folder: string,
379+
key: string,
380+
): Promise<{ ok: boolean; error?: string }> {
381+
const normalizedFolder = validateFolderPath(folder);
382+
if (!normalizedFolder) {
383+
return { ok: false, error: 'Invalid folder path' };
384+
}
385+
const normalizedKey = sanitizeKey(key);
386+
const filePath = ctx.memoryStore.resolveFilePath(normalizedFolder, normalizedKey);
387+
const relativePath = path.relative(ctx.storeRoot, filePath).replace(/\\/g, '/');
388+
const deleted = ctx.memoryStore.delete(normalizedFolder, normalizedKey);
389+
if (!deleted) {
390+
return { ok: false, error: 'Memory not found' };
391+
}
392+
await ctx.indexDao.deleteEntryByPath(relativePath);
393+
return { ok: true };
394+
}
395+
396+
export async function createFolder(
397+
ctx: Ctx,
398+
folderPath: string,
399+
): Promise<{ ok: boolean; error?: string }> {
400+
const normalized = validateFolderPath(folderPath, false);
401+
if (!normalized) {
402+
return { ok: false, error: 'Invalid folder path' };
403+
}
404+
const absPath = safeResolve(ctx.storeRoot, normalized);
405+
if (fs.existsSync(absPath)) {
406+
return { ok: false, error: 'Folder already exists' };
407+
}
408+
fs.mkdirSync(absPath, { recursive: true });
409+
await ctx.indexDao.setFolderComment(normalized, '');
410+
return { ok: true };
411+
}
412+
413+
export async function renameFolder(
414+
ctx: Ctx,
415+
oldFolderPath: string,
416+
newFolderPath: string,
417+
): Promise<{ ok: boolean; error?: string }> {
418+
const oldNormalized = validateFolderPath(oldFolderPath, false);
419+
if (!oldNormalized) {
420+
return { ok: false, error: 'Invalid source folder path' };
421+
}
422+
const newNormalized = validateFolderPath(newFolderPath, false);
423+
if (!newNormalized) {
424+
return { ok: false, error: 'Invalid destination folder path' };
425+
}
426+
if (oldNormalized === newNormalized) {
427+
return { ok: false, error: 'Source and destination are the same' };
428+
}
429+
const oldAbs = safeResolve(ctx.storeRoot, oldNormalized);
430+
const newAbs = safeResolve(ctx.storeRoot, newNormalized);
431+
if (!fs.existsSync(oldAbs)) {
432+
return { ok: false, error: 'Source folder not found' };
433+
}
434+
if (fs.existsSync(newAbs)) {
435+
return { ok: false, error: 'Destination folder already exists' };
436+
}
437+
fs.mkdirSync(path.dirname(newAbs), { recursive: true });
438+
fs.renameSync(oldAbs, newAbs);
439+
await ctx.indexDao.reconcileIndex();
440+
return { ok: true };
441+
}
442+
443+
export async function deleteFolder(
444+
ctx: Ctx,
445+
folderPath: string,
446+
recursive = false,
447+
): Promise<{ ok: boolean; error?: string }> {
448+
const normalized = validateFolderPath(folderPath, false);
449+
if (!normalized) {
450+
return { ok: false, error: 'Invalid folder path' };
451+
}
452+
const absPath = safeResolve(ctx.storeRoot, normalized);
453+
if (!fs.existsSync(absPath)) {
454+
return { ok: false, error: 'Folder not found' };
455+
}
456+
if (recursive) {
457+
fs.rmSync(absPath, { recursive: true, force: true });
458+
} else {
459+
const entries = fs.readdirSync(absPath);
460+
if (entries.length > 0) {
461+
return { ok: false, error: 'Folder is not empty' };
462+
}
463+
fs.rmdirSync(absPath);
464+
}
465+
await ctx.indexDao.reconcileIndex();
466+
return { ok: true };
467+
}
468+
273469
export function saveEssence(ctx: Ctx, content: string): { ok: boolean } {
274470
const essenceDir = path.join(ctx.storeRoot, 'essence');
275471
fs.mkdirSync(essenceDir, { recursive: true });

‎src/vis/server.ts‎

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ import {
1818
getMemoryContent,
1919
saveEssence,
2020
updateTheme,
21+
listMemoryFolders,
22+
writeMemory,
23+
deleteMemory,
24+
createFolder,
25+
renameFolder,
26+
deleteFolder,
2127
} from './api.js';
2228

2329
const KIMI_ORIGIN = 'http://127.0.0.1:58627';
@@ -134,7 +140,7 @@ export function createApp(ctx: Ctx): Hono {
134140

135141
app.use('/api/*', async (c, next) => {
136142
c.header('Access-Control-Allow-Origin', '*');
137-
c.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
143+
c.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
138144
c.header('Access-Control-Allow-Headers', 'Content-Type');
139145
if (c.req.method === 'OPTIONS') return c.body(null, 204);
140146
await next();
@@ -163,6 +169,36 @@ export function createApp(ctx: Ctx): Hono {
163169
return c.json(getMemories(ctx));
164170
});
165171

172+
app.get('/api/folders', (c) => {
173+
return c.json(listMemoryFolders(ctx));
174+
});
175+
176+
app.post('/api/memory/:folder{.+}/:key', async (c) => {
177+
const folder = decodeURIComponent(c.req.param('folder'));
178+
const key = decodeURIComponent(c.req.param('key'));
179+
const body = (await c.req.json().catch(() => ({}))) as {
180+
content?: unknown;
181+
title?: unknown;
182+
tags?: unknown;
183+
};
184+
const tags = Array.isArray(body.tags)
185+
? body.tags.filter((t): t is string => typeof t === 'string')
186+
: [];
187+
const result = await writeMemory(ctx, folder, key, {
188+
content: typeof body.content === 'string' ? body.content : '',
189+
title: typeof body.title === 'string' ? body.title : undefined,
190+
tags,
191+
});
192+
return c.json(result, result.ok ? 200 : 400);
193+
});
194+
195+
app.delete('/api/memory/:folder{.+}/:key', async (c) => {
196+
const folder = decodeURIComponent(c.req.param('folder'));
197+
const key = decodeURIComponent(c.req.param('key'));
198+
const result = await deleteMemory(ctx, folder, key);
199+
return c.json(result, result.ok ? 200 : 404);
200+
});
201+
166202
app.get('/api/memory/:folder{.+}/:key', (c) => {
167203
const folder = decodeURIComponent(c.req.param('folder'));
168204
const key = decodeURIComponent(c.req.param('key'));
@@ -171,6 +207,32 @@ export function createApp(ctx: Ctx): Hono {
171207
return c.json(result);
172208
});
173209

210+
app.post('/api/folders', async (c) => {
211+
const body = (await c.req.json().catch(() => ({}))) as { folder?: unknown };
212+
if (typeof body.folder !== 'string') {
213+
return c.json({ ok: false, error: 'folder is required' }, 400);
214+
}
215+
const result = await createFolder(ctx, body.folder);
216+
return c.json(result, result.ok ? 200 : 400);
217+
});
218+
219+
app.post('/api/folders/:folder{.+}/rename', async (c) => {
220+
const folder = decodeURIComponent(c.req.param('folder'));
221+
const body = (await c.req.json().catch(() => ({}))) as { newFolder?: unknown };
222+
if (typeof body.newFolder !== 'string') {
223+
return c.json({ ok: false, error: 'newFolder is required' }, 400);
224+
}
225+
const result = await renameFolder(ctx, folder, body.newFolder);
226+
return c.json(result, result.ok ? 200 : 400);
227+
});
228+
229+
app.delete('/api/folders/:folder{.+}', async (c) => {
230+
const folder = decodeURIComponent(c.req.param('folder'));
231+
const recursive = c.req.query('recursive') === 'true';
232+
const result = await deleteFolder(ctx, folder, recursive);
233+
return c.json(result, result.ok ? 200 : 400);
234+
});
235+
174236
app.post('/api/essence', async (c) => {
175237
const body = (await c.req.json().catch(() => ({}))) as { content?: unknown };
176238
if (typeof body.content !== 'string') {

0 commit comments

Comments
 (0)