@@ -3,7 +3,7 @@ import fs from "fs/promises"
33import path from "path"
44import { Global } from "@/global"
55import { Instance } from "@/project/instance"
6- import { MEMORY_MAX_BLOCK_SIZE , MEMORY_MAX_BLOCKS_PER_SCOPE , type MemoryBlock } from "./types"
6+ import { MEMORY_MAX_BLOCK_SIZE , MEMORY_MAX_BLOCKS_PER_SCOPE , type MemoryBlock , type Citation } from "./types"
77
88const FRONTMATTER_REGEX = / ^ - - - \n ( [ \s \S ] * ?) \n - - - \n ( [ \s \S ] * ) $ /
99
@@ -20,7 +20,11 @@ function dirForScope(scope: "global" | "project"): string {
2020}
2121
2222function blockPath ( scope : "global" | "project" , id : string ) : string {
23- return path . join ( dirForScope ( scope ) , `${ id } .md` )
23+ return path . join ( dirForScope ( scope ) , ...id . split ( "/" ) . slice ( 0 , - 1 ) , `${ id . split ( "/" ) . pop ( ) } .md` )
24+ }
25+
26+ function auditLogPath ( scope : "global" | "project" ) : string {
27+ return path . join ( dirForScope ( scope ) , ".log" )
2428}
2529
2630function parseFrontmatter ( raw : string ) : { meta : Record < string , unknown > ; content : string } | undefined {
@@ -50,19 +54,43 @@ function parseFrontmatter(raw: string): { meta: Record<string, unknown>; content
5054
5155function serializeBlock ( block : MemoryBlock ) : string {
5256 const tags = block . tags . length > 0 ? `\ntags: ${ JSON . stringify ( block . tags ) } ` : ""
57+ const expires = block . expires ? `\nexpires: ${ block . expires } ` : ""
58+ const citations = block . citations && block . citations . length > 0 ? `\ncitations: ${ JSON . stringify ( block . citations ) } ` : ""
5359 return [
5460 "---" ,
5561 `id: ${ block . id } ` ,
5662 `scope: ${ block . scope } ` ,
5763 `created: ${ block . created } ` ,
58- `updated: ${ block . updated } ${ tags } ` ,
64+ `updated: ${ block . updated } ${ tags } ${ expires } ${ citations } ` ,
5965 "---" ,
6066 "" ,
6167 block . content ,
6268 "" ,
6369 ] . join ( "\n" )
6470}
6571
72+ export function isExpired ( block : MemoryBlock ) : boolean {
73+ if ( ! block . expires ) return false
74+ return new Date ( block . expires ) <= new Date ( )
75+ }
76+
77+ async function appendAuditLog ( scope : "global" | "project" , entry : string ) : Promise < void > {
78+ const logPath = auditLogPath ( scope )
79+ const dir = path . dirname ( logPath )
80+ try {
81+ await fs . mkdir ( dir , { recursive : true } )
82+ await fs . appendFile ( logPath , entry + "\n" , "utf-8" )
83+ } catch {
84+ // Audit logging is best-effort — never fail the operation
85+ }
86+ }
87+
88+ function auditEntry ( action : string , id : string , scope : string , extra ?: string ) : string {
89+ const ts = new Date ( ) . toISOString ( )
90+ const suffix = extra ? ` ${ extra } ` : ""
91+ return `[${ ts } ] ${ action } ${ scope } /${ id } ${ suffix } `
92+ }
93+
6694export namespace MemoryStore {
6795 export async function read ( scope : "global" | "project" , id : string ) : Promise < MemoryBlock | undefined > {
6896 const filepath = blockPath ( scope , id )
@@ -77,79 +105,132 @@ export namespace MemoryStore {
77105 const parsed = parseFrontmatter ( raw )
78106 if ( ! parsed ) return undefined
79107
108+ const citations = ( ( ) => {
109+ if ( ! parsed . meta . citations ) return undefined
110+ if ( Array . isArray ( parsed . meta . citations ) ) return parsed . meta . citations as Citation [ ]
111+ return undefined
112+ } ) ( )
113+
80114 return {
81115 id : String ( parsed . meta . id ?? id ) ,
82116 scope : ( parsed . meta . scope as "global" | "project" ) ?? scope ,
83117 tags : Array . isArray ( parsed . meta . tags ) ? ( parsed . meta . tags as string [ ] ) : [ ] ,
84118 created : String ( parsed . meta . created ?? new Date ( ) . toISOString ( ) ) ,
85119 updated : String ( parsed . meta . updated ?? new Date ( ) . toISOString ( ) ) ,
120+ expires : parsed . meta . expires ? String ( parsed . meta . expires ) : undefined ,
121+ citations,
86122 content : parsed . content ,
87123 }
88124 }
89125
90- export async function list ( scope : "global" | "project" ) : Promise < MemoryBlock [ ] > {
126+ export async function list ( scope : "global" | "project" , opts ?: { includeExpired ?: boolean } ) : Promise < MemoryBlock [ ] > {
91127 const dir = dirForScope ( scope )
92- let entries : string [ ]
93- try {
94- entries = await fs . readdir ( dir )
95- } catch ( e : any ) {
96- if ( e . code === "ENOENT" ) return [ ]
97- throw e
98- }
99-
100128 const blocks : MemoryBlock [ ] = [ ]
101- for ( const entry of entries ) {
102- if ( ! entry . endsWith ( ".md" ) ) continue
103- const id = entry . slice ( 0 , - 3 )
104- const block = await read ( scope , id )
105- if ( block ) blocks . push ( block )
129+
130+ async function scanDir ( currentDir : string , prefix : string ) {
131+ let entries : { name : string ; isDirectory : ( ) => boolean } [ ]
132+ try {
133+ entries = await fs . readdir ( currentDir , { withFileTypes : true } )
134+ } catch ( e : any ) {
135+ if ( e . code === "ENOENT" ) return
136+ throw e
137+ }
138+
139+ for ( const entry of entries ) {
140+ if ( entry . name . startsWith ( "." ) ) continue
141+ if ( entry . isDirectory ( ) ) {
142+ await scanDir ( path . join ( currentDir , entry . name ) , prefix ? `${ prefix } /${ entry . name } ` : entry . name )
143+ } else if ( entry . name . endsWith ( ".md" ) ) {
144+ const baseName = entry . name . slice ( 0 , - 3 )
145+ const id = prefix ? `${ prefix } /${ baseName } ` : baseName
146+ const block = await read ( scope , id )
147+ if ( block ) {
148+ if ( ! opts ?. includeExpired && isExpired ( block ) ) continue
149+ blocks . push ( block )
150+ }
151+ }
152+ }
106153 }
107154
155+ await scanDir ( dir , "" )
108156 blocks . sort ( ( a , b ) => b . updated . localeCompare ( a . updated ) )
109157 return blocks
110158 }
111159
112- export async function listAll ( ) : Promise < MemoryBlock [ ] > {
113- const [ global , project ] = await Promise . all ( [ list ( "global" ) , list ( "project" ) ] )
160+ export async function listAll ( opts ?: { includeExpired ?: boolean } ) : Promise < MemoryBlock [ ] > {
161+ const [ global , project ] = await Promise . all ( [ list ( "global" , opts ) , list ( "project" , opts ) ] )
114162 const all = [ ...project , ...global ]
115163 all . sort ( ( a , b ) => b . updated . localeCompare ( a . updated ) )
116164 return all
117165 }
118166
119- export async function write ( block : MemoryBlock ) : Promise < void > {
167+ export async function findDuplicates (
168+ scope : "global" | "project" ,
169+ block : { id : string ; tags : string [ ] } ,
170+ ) : Promise < MemoryBlock [ ] > {
171+ const existing = await list ( scope )
172+ return existing . filter ( ( b ) => {
173+ if ( b . id === block . id ) return false // same block = update, not duplicate
174+ if ( block . tags . length === 0 ) return false
175+ const overlap = block . tags . filter ( ( t ) => b . tags . includes ( t ) )
176+ return overlap . length >= Math . ceil ( block . tags . length / 2 )
177+ } )
178+ }
179+
180+ export async function write ( block : MemoryBlock ) : Promise < { duplicates : MemoryBlock [ ] } > {
120181 if ( block . content . length > MEMORY_MAX_BLOCK_SIZE ) {
121182 throw new Error (
122183 `Memory block "${ block . id } " content exceeds maximum size of ${ MEMORY_MAX_BLOCK_SIZE } characters (got ${ block . content . length } )` ,
123184 )
124185 }
125186
126- const existing = await list ( block . scope )
187+ const existing = await list ( block . scope , { includeExpired : true } )
127188 const isUpdate = existing . some ( ( b ) => b . id === block . id )
128189 if ( ! isUpdate && existing . length >= MEMORY_MAX_BLOCKS_PER_SCOPE ) {
129190 throw new Error (
130191 `Cannot create memory block "${ block . id } ": scope "${ block . scope } " already has ${ MEMORY_MAX_BLOCKS_PER_SCOPE } blocks (maximum). Delete an existing block first.` ,
131192 )
132193 }
133194
134- const dir = dirForScope ( block . scope )
135- await fs . mkdir ( dir , { recursive : true } )
195+ const duplicates = await findDuplicates ( block . scope , block )
136196
137197 const filepath = blockPath ( block . scope , block . id )
198+ const dir = path . dirname ( filepath )
199+ await fs . mkdir ( dir , { recursive : true } )
200+
138201 const tmpPath = filepath + ".tmp"
139202 const serialized = serializeBlock ( block )
140203
141204 await fs . writeFile ( tmpPath , serialized , "utf-8" )
142205 await fs . rename ( tmpPath , filepath )
206+
207+ const action = isUpdate ? "UPDATE" : "CREATE"
208+ await appendAuditLog ( block . scope , auditEntry ( action , block . id , block . scope ) )
209+
210+ return { duplicates }
143211 }
144212
145213 export async function remove ( scope : "global" | "project" , id : string ) : Promise < boolean > {
146214 const filepath = blockPath ( scope , id )
147215 try {
148216 await fs . unlink ( filepath )
217+ await appendAuditLog ( scope , auditEntry ( "DELETE" , id , scope ) )
149218 return true
150219 } catch ( e : any ) {
151220 if ( e . code === "ENOENT" ) return false
152221 throw e
153222 }
154223 }
224+
225+ export async function readAuditLog ( scope : "global" | "project" , limit : number = 50 ) : Promise < string [ ] > {
226+ const logPath = auditLogPath ( scope )
227+ try {
228+ const raw = await fs . readFile ( logPath , "utf-8" )
229+ const lines = raw . trim ( ) . split ( "\n" ) . filter ( Boolean )
230+ return lines . slice ( - limit )
231+ } catch ( e : any ) {
232+ if ( e . code === "ENOENT" ) return [ ]
233+ throw e
234+ }
235+ }
155236}
0 commit comments