@@ -10,10 +10,12 @@ import fs from 'fs';
1010import path from 'path' ;
1111import type { Ctx } from '../types.js' ;
1212import { 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' ;
1416import type { ThemeAssociation } from '../theme-manager.js' ;
1517import type { RefinedTurn } from '../refine/types.js' ;
16- import type { TreeNode } from '../dao/index.js' ;
18+ import type { IndexEntry } from '../dao/index.js' ;
1719
1820export 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
254332export 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
259342export 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+
273469export function saveEssence ( ctx : Ctx , content : string ) : { ok : boolean } {
274470 const essenceDir = path . join ( ctx . storeRoot , 'essence' ) ;
275471 fs . mkdirSync ( essenceDir , { recursive : true } ) ;
0 commit comments