@alanzhao/dsh-memory-lite 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/cordis.patch.yml +9 -0
  4. package/lib/catalog.d.ts +35 -0
  5. package/lib/catalog.js +145 -0
  6. package/lib/catalog.js.map +1 -0
  7. package/lib/client.js +631 -0
  8. package/lib/config.d.ts +133 -0
  9. package/lib/config.js +173 -0
  10. package/lib/config.js.map +1 -0
  11. package/lib/extract/checkpoint.d.ts +60 -0
  12. package/lib/extract/checkpoint.js +42 -0
  13. package/lib/extract/checkpoint.js.map +1 -0
  14. package/lib/extract/decision.d.ts +36 -0
  15. package/lib/extract/decision.js +118 -0
  16. package/lib/extract/decision.js.map +1 -0
  17. package/lib/extract/digest.d.ts +12 -0
  18. package/lib/extract/digest.js +25 -0
  19. package/lib/extract/digest.js.map +1 -0
  20. package/lib/extract/index.d.ts +52 -0
  21. package/lib/extract/index.js +489 -0
  22. package/lib/extract/index.js.map +1 -0
  23. package/lib/extract/prompt.d.ts +20 -0
  24. package/lib/extract/prompt.js +63 -0
  25. package/lib/extract/prompt.js.map +1 -0
  26. package/lib/extract/triggers.d.ts +15 -0
  27. package/lib/extract/triggers.js +26 -0
  28. package/lib/extract/triggers.js.map +1 -0
  29. package/lib/extract/window.d.ts +48 -0
  30. package/lib/extract/window.js +110 -0
  31. package/lib/extract/window.js.map +1 -0
  32. package/lib/index.d.ts +29 -0
  33. package/lib/index.js +92 -0
  34. package/lib/index.js.map +1 -0
  35. package/lib/inject.d.ts +45 -0
  36. package/lib/inject.js +102 -0
  37. package/lib/inject.js.map +1 -0
  38. package/lib/memory-store.d.ts +147 -0
  39. package/lib/memory-store.js +494 -0
  40. package/lib/memory-store.js.map +1 -0
  41. package/lib/path.d.ts +24 -0
  42. package/lib/path.js +54 -0
  43. package/lib/path.js.map +1 -0
  44. package/lib/peer.d.ts +16 -0
  45. package/lib/peer.js +38 -0
  46. package/lib/peer.js.map +1 -0
  47. package/lib/status.d.ts +44 -0
  48. package/lib/status.js +42 -0
  49. package/lib/status.js.map +1 -0
  50. package/lib/tool-utils.d.ts +25 -0
  51. package/lib/tool-utils.js +16 -0
  52. package/lib/tool-utils.js.map +1 -0
  53. package/lib/tools/forget-memory.d.ts +9 -0
  54. package/lib/tools/forget-memory.js +39 -0
  55. package/lib/tools/forget-memory.js.map +1 -0
  56. package/lib/tools/read-memory.d.ts +8 -0
  57. package/lib/tools/read-memory.js +41 -0
  58. package/lib/tools/read-memory.js.map +1 -0
  59. package/lib/tools/remember.d.ts +9 -0
  60. package/lib/tools/remember.js +65 -0
  61. package/lib/tools/remember.js.map +1 -0
  62. package/lib/tools/search-memory.d.ts +8 -0
  63. package/lib/tools/search-memory.js +49 -0
  64. package/lib/tools/search-memory.js.map +1 -0
  65. package/lib/tools/update-memory.d.ts +9 -0
  66. package/lib/tools/update-memory.js +46 -0
  67. package/lib/tools/update-memory.js.map +1 -0
  68. package/lib/types.d.ts +35 -0
  69. package/lib/types.js +7 -0
  70. package/lib/types.js.map +1 -0
  71. package/package.json +81 -0
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The on-disk memory tree: containment, atomic writes through a serial queue,
3
+ * L0 index management, and line search. Instances are created per plugin mount
4
+ * and closed over by the tools and the pre-step injector.
5
+ * @module dsh-memory-lite/src/memory-store
6
+ */
7
+ import type { IndexEntry } from './types.js';
8
+ import type { SharingConfig } from './config.js';
9
+ /** Serialize mutations so writers never interleave inside one memory tree. */
10
+ export declare class MutationQueue {
11
+ private tail;
12
+ /** Run `op` after every previously queued operation; errors isolate per call. */
13
+ enqueue<T>(op: () => Promise<T>): Promise<T>;
14
+ }
15
+ /** Cap on retained summary-log lines per peer (bounds the rolling log). */
16
+ export declare const MAX_LOG_LINES = 5000;
17
+ /** One line-level search hit. */
18
+ export interface SearchMatch {
19
+ /** Memory path relative to the peer's memories root. */
20
+ readonly path: string;
21
+ /** The matching line, capped in length. */
22
+ readonly line: string;
23
+ }
24
+ /** The known sections of a memory file; extra content is preserved verbatim. */
25
+ export interface MemoryFileSections {
26
+ /** H1 title text ('' when absent). */
27
+ title: string;
28
+ /** Current-section body ('' when absent). */
29
+ current: string;
30
+ /** History-section body ('' when absent). */
31
+ history: string;
32
+ /** Related-section body ('' when absent). */
33
+ related: string;
34
+ /** Preamble plus any unknown `## section` bodies, preserved. */
35
+ extra: string;
36
+ }
37
+ /** Parse a memory file into its sections; unknown sections survive in {@link MemoryFileSections.extra}. */
38
+ export declare function parseMemoryFile(text: string): MemoryFileSections;
39
+ /**
40
+ * Normalize a tool-supplied "new content" value. Models sometimes pass a full
41
+ * rendered memory file (an H1 title line plus `## Current` etc.) instead of
42
+ * the plain new body; detect that shape and extract the Current section body
43
+ * so it is stored as the section's content rather than nested as a literal
44
+ * bullet. Plain text passes through unchanged.
45
+ */
46
+ export declare function normalizeContent(content: string): string;
47
+ /** Render parsed sections back into a canonical memory file (always ends with one newline). */
48
+ export declare function renderMemoryFile(sections: MemoryFileSections): string;
49
+ /** One-line summary of content for the L0 index. */
50
+ export declare function summaryOf(content: string, maxLength?: number): string;
51
+ /** Render the L0 index document for one peer. */
52
+ export declare function renderIndex(peer: string, entries: readonly IndexEntry[], now: Date): string;
53
+ /** Parse L0 index entries from an index document; malformed lines are ignored (tolerant of human edits). */
54
+ export declare function parseIndex(text: string): IndexEntry[];
55
+ /**
56
+ * A filesystem-safe slug from a title, used as the memory file basename.
57
+ * Keeps Unicode letters and numbers (Chinese titles yield CJK filenames
58
+ * instead of collapsing to the bare 'memory' fallback, which would collide).
59
+ */
60
+ export declare function slugify(title: string): string;
61
+ /**
62
+ * Owns the on-disk memory tree for every peer under one root. All mutations
63
+ * run through {@link MutationQueue}; every read/write goes through
64
+ * {@link MemoryStore.resolve} (the containment boundary).
65
+ */
66
+ export declare class MemoryStore {
67
+ readonly root: string;
68
+ private readonly queue;
69
+ readonly sharing: SharingConfig;
70
+ constructor(root: string, sharing?: SharingConfig);
71
+ peersDir(): string;
72
+ /** Existing peer directory names (plain names, no path), tolerant of a missing/empty root. */
73
+ listPeers(): Promise<string[]>;
74
+ memoriesRoot(peer: string): string;
75
+ sessionsDir(peer: string): string;
76
+ trashDir(): string;
77
+ /**
78
+ * Contain and resolve a tool-supplied relative path inside one peer's memories
79
+ * root. Paths under `shared/<name>/...` are redirected (Phase 3 sharing) to
80
+ * the declared target peer's memories root; the model-facing relative path
81
+ * keeps the `shared/<name>` prefix so tool output stays source-annotated.
82
+ * `opts.write` marks a mutation so read-only mounts are rejected.
83
+ */
84
+ resolve(peer: string, relPath: string, opts?: {
85
+ write?: boolean;
86
+ }): Promise<{
87
+ abs: string;
88
+ rel: string;
89
+ }>;
90
+ /** Resolve a `shared/<name>/...` path against the declared mount, enforcing read-only. */
91
+ private resolveShared;
92
+ fileExists(peer: string, relPath: string): Promise<boolean>;
93
+ readFile(peer: string, relPath: string): Promise<{
94
+ content: string;
95
+ rel: string;
96
+ }>;
97
+ /** Create a new memory file with the canonical skeleton. */
98
+ writeNewMemory(peer: string, relPath: string, title: string, content: string): Promise<void>;
99
+ /**
100
+ * Append content as new Current bullets; creates the section when absent.
101
+ * Identical lines already in Current are skipped (no-op writes are dropped).
102
+ */
103
+ appendCurrent(peer: string, relPath: string, content: string): Promise<void>;
104
+ /**
105
+ * Replace a memory's current content, archiving the previous current into
106
+ * History with a date. The previous content is never destroyed (ADD-only).
107
+ */
108
+ updateCurrent(peer: string, relPath: string, content: string): Promise<void>;
109
+ /**
110
+ * Validate a session id and resolve its checkpoint/audit file under
111
+ * sessionsDir; the id is constrained so it can never escape the directory.
112
+ */
113
+ sessionCheckpointPath(peer: string, sessionId: string): string;
114
+ /** Read a session checkpoint document; a missing file reads as undefined. */
115
+ readSessionCheckpoint(peer: string, sessionId: string): Promise<string | undefined>;
116
+ /** Persist a session checkpoint/audit document through the serial queue. */
117
+ writeSessionCheckpoint(peer: string, sessionId: string, text: string): Promise<void>;
118
+ /** Peer-level rolling run log: peers/{peer}/sessions/extraction.log. */
119
+ summaryLogPath(peer: string): string;
120
+ /** Append one JSON line to the peer's extraction summary log (capped). */
121
+ appendExtractionLog(peer: string, line: string): Promise<void>;
122
+ /**
123
+ * Diagnostic: append one failed extraction answer (the raw model output that
124
+ * parseDecision rejected) to peers/{peer}/sessions/failed-answers.log, capped.
125
+ * Only written when the answer was not salvageable; used to debug parse-error
126
+ * spikes. Not part of the functional path — callers tolerate failures.
127
+ */
128
+ failedAnswerLogPath(peer: string): string;
129
+ appendFailedAnswer(peer: string, entry: object): Promise<void>;
130
+ /** Soft-delete: move the file under root/.trash/<date>/, never overwriting. */
131
+ softDelete(peer: string, relPath: string): Promise<void>;
132
+ /** Parse the L0 index for one peer; a missing index reads as empty. */
133
+ readIndex(peer: string): Promise<IndexEntry[]>;
134
+ /** Rebuild the L0 index from entries, sorted by file mtime (newest first). */
135
+ rebuildIndex(peer: string, entries: readonly IndexEntry[]): Promise<void>;
136
+ /** Add or replace one entry, then rebuild. */
137
+ refreshIndexEntry(peer: string, relPath: string, summary: string): Promise<void>;
138
+ /** Drop one entry, then rebuild. */
139
+ removeIndexEntry(peer: string, relPath: string): Promise<void>;
140
+ /** Line search across a peer's memory files, including enabled shared mounts (index files excluded). */
141
+ search(peer: string, query: string, maxMatches?: number, maxLineLength?: number): Promise<SearchMatch[]>;
142
+ /** Search roots for a peer: its own memories plus each enabled non-self shared mount. */
143
+ private searchRoots;
144
+ private writeNow;
145
+ private writeIndexNow;
146
+ private walkMarkdown;
147
+ }
@@ -0,0 +1,494 @@
1
+ /**
2
+ * The on-disk memory tree: containment, atomic writes through a serial queue,
3
+ * L0 index management, and line search. Instances are created per plugin mount
4
+ * and closed over by the tools and the pre-step injector.
5
+ * @module dsh-memory-lite/src/memory-store
6
+ */
7
+ import { readFile as readFileNode, readdir, rename, stat } from 'node:fs/promises';
8
+ import { basename, dirname, extname, join, relative } from 'node:path';
9
+ import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
10
+ import { containWithin, ensureDir, MemoryPathError, realpathSafe } from './path.js';
11
+ /** Serialize mutations so writers never interleave inside one memory tree. */
12
+ export class MutationQueue {
13
+ tail = Promise.resolve();
14
+ /** Run `op` after every previously queued operation; errors isolate per call. */
15
+ enqueue(op) {
16
+ const run = this.tail.then(op, op);
17
+ this.tail = run.then(() => undefined, () => undefined);
18
+ return run;
19
+ }
20
+ }
21
+ /** Cap on retained summary-log lines per peer (bounds the rolling log). */
22
+ export const MAX_LOG_LINES = 5000;
23
+ /** Parse a memory file into its sections; unknown sections survive in {@link MemoryFileSections.extra}. */
24
+ export function parseMemoryFile(text) {
25
+ const lines = text.split('\n');
26
+ const title = lines[0]?.startsWith('# ') ? lines[0].slice(2).trim() : '';
27
+ const sections = new Map();
28
+ const preamble = [];
29
+ let currentName;
30
+ for (let index = 1; index < lines.length; index += 1) {
31
+ const line = lines[index];
32
+ const match = /^## (.+)$/.exec(line);
33
+ if (match !== null) {
34
+ currentName = match[1].trim();
35
+ sections.set(currentName, []);
36
+ continue;
37
+ }
38
+ if (currentName === undefined)
39
+ preamble.push(line);
40
+ else
41
+ sections.get(currentName).push(line);
42
+ }
43
+ const unknown = [];
44
+ for (const [name, body] of sections) {
45
+ if (name !== 'Current' && name !== 'History' && name !== 'Related') {
46
+ unknown.push(`## ${name}\n${body.join('\n')}`);
47
+ }
48
+ }
49
+ const extra = [preamble.join('\n').trim(), unknown.join('\n\n')].filter(part => part !== '').join('\n\n');
50
+ return {
51
+ title,
52
+ current: (sections.get('Current') ?? []).join('\n').trim(),
53
+ history: (sections.get('History') ?? []).join('\n').trim(),
54
+ related: (sections.get('Related') ?? []).join('\n').trim(),
55
+ extra,
56
+ };
57
+ }
58
+ /**
59
+ * Normalize a tool-supplied "new content" value. Models sometimes pass a full
60
+ * rendered memory file (an H1 title line plus `## Current` etc.) instead of
61
+ * the plain new body; detect that shape and extract the Current section body
62
+ * so it is stored as the section's content rather than nested as a literal
63
+ * bullet. Plain text passes through unchanged.
64
+ */
65
+ export function normalizeContent(content) {
66
+ const trimmed = content.trim();
67
+ const firstLine = trimmed.split('\n')[0] ?? '';
68
+ const looksLikeFullFile = firstLine.startsWith('# ') && /^## /m.test(trimmed);
69
+ if (!looksLikeFullFile)
70
+ return trimmed;
71
+ const parsed = parseMemoryFile(trimmed);
72
+ return parsed.current !== '' ? parsed.current : trimmed;
73
+ }
74
+ /** Render parsed sections back into a canonical memory file (always ends with one newline). */
75
+ export function renderMemoryFile(sections) {
76
+ const parts = [`# ${sections.title === '' ? 'Memory' : sections.title}`, ''];
77
+ if (sections.current !== '')
78
+ parts.push('## Current', sections.current, '');
79
+ if (sections.history !== '')
80
+ parts.push('## History', sections.history, '');
81
+ if (sections.related !== '')
82
+ parts.push('## Related', sections.related, '');
83
+ if (sections.extra !== '')
84
+ parts.push(sections.extra, '');
85
+ return parts.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '\n');
86
+ }
87
+ /** One-line summary of content for the L0 index. */
88
+ export function summaryOf(content, maxLength = 100) {
89
+ const normalized = content.replaceAll(/\s+/g, ' ').trim();
90
+ return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`;
91
+ }
92
+ /** Render the L0 index document for one peer. */
93
+ export function renderIndex(peer, entries, now) {
94
+ const hints = {
95
+ preferences: '(always relevant)',
96
+ entities: '(relevant when mentioned)',
97
+ events: '(relevant for time queries)',
98
+ experiences: '(relevant for similar tasks)',
99
+ };
100
+ const lines = [`# Memory Index — ${peer}`, '', `> Last updated: ${now.toISOString()} | Total: ${entries.length}`, ''];
101
+ const byCategory = new Map();
102
+ for (const entry of entries) {
103
+ const list = byCategory.get(entry.category);
104
+ if (list === undefined)
105
+ byCategory.set(entry.category, [entry]);
106
+ else
107
+ list.push(entry);
108
+ }
109
+ for (const [category, list] of byCategory) {
110
+ lines.push(`## ${category}${hints[category] !== undefined ? ' ' + hints[category] : ''}`);
111
+ for (const entry of list)
112
+ lines.push(`- ${entry.path}: ${entry.summary}`);
113
+ lines.push('');
114
+ }
115
+ return lines.join('\n').replace(/\n+$/, '\n');
116
+ }
117
+ /** Parse L0 index entries from an index document; malformed lines are ignored (tolerant of human edits). */
118
+ export function parseIndex(text) {
119
+ const entries = [];
120
+ const linePattern = /^- (.+\.md): (.*)$/;
121
+ for (const line of text.split('\n')) {
122
+ const match = linePattern.exec(line);
123
+ if (match === null)
124
+ continue;
125
+ const path = match[1].trim();
126
+ if (path === '' || path.startsWith('/') || path.includes('..'))
127
+ continue;
128
+ const category = path.split('/')[0] ?? '';
129
+ if (category === '')
130
+ continue;
131
+ entries.push({ category, path, summary: match[2].trim() });
132
+ }
133
+ return entries;
134
+ }
135
+ /**
136
+ * A filesystem-safe slug from a title, used as the memory file basename.
137
+ * Keeps Unicode letters and numbers (Chinese titles yield CJK filenames
138
+ * instead of collapsing to the bare 'memory' fallback, which would collide).
139
+ */
140
+ export function slugify(title) {
141
+ const slug = title
142
+ .toLowerCase()
143
+ .normalize('NFKD')
144
+ .replace(/[^\p{L}\p{N}._-]+/gu, '-')
145
+ .replace(/^-+|-+$/g, '');
146
+ return slug === '' ? 'memory' : slug.slice(0, 80);
147
+ }
148
+ /**
149
+ * Owns the on-disk memory tree for every peer under one root. All mutations
150
+ * run through {@link MutationQueue}; every read/write goes through
151
+ * {@link MemoryStore.resolve} (the containment boundary).
152
+ */
153
+ export class MemoryStore {
154
+ root;
155
+ queue = new MutationQueue();
156
+ sharing;
157
+ constructor(root, sharing = { enabled: false, mounts: [] }) {
158
+ this.root = root;
159
+ this.sharing = sharing;
160
+ }
161
+ peersDir() {
162
+ return join(this.root, 'peers');
163
+ }
164
+ /** Existing peer directory names (plain names, no path), tolerant of a missing/empty root. */
165
+ async listPeers() {
166
+ try {
167
+ const entries = await readdir(this.peersDir(), { withFileTypes: true });
168
+ return entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
169
+ }
170
+ catch {
171
+ return [];
172
+ }
173
+ }
174
+ memoriesRoot(peer) {
175
+ return join(this.root, 'peers', peer, 'memories');
176
+ }
177
+ sessionsDir(peer) {
178
+ return join(this.root, 'peers', peer, 'sessions');
179
+ }
180
+ trashDir() {
181
+ return join(this.root, '.trash');
182
+ }
183
+ /**
184
+ * Contain and resolve a tool-supplied relative path inside one peer's memories
185
+ * root. Paths under `shared/<name>/...` are redirected (Phase 3 sharing) to
186
+ * the declared target peer's memories root; the model-facing relative path
187
+ * keeps the `shared/<name>` prefix so tool output stays source-annotated.
188
+ * `opts.write` marks a mutation so read-only mounts are rejected.
189
+ */
190
+ async resolve(peer, relPath, opts = {}) {
191
+ if (extname(relPath).toLowerCase() !== '.md') {
192
+ throw new MemoryPathError(`memory path must end in .md: ${JSON.stringify(relPath)}`);
193
+ }
194
+ if (relPath.startsWith('shared/')) {
195
+ return this.resolveShared(peer, relPath, opts);
196
+ }
197
+ const memories = this.memoriesRoot(peer);
198
+ await ensureDir(memories);
199
+ // containWithin returns the canonical (realpath'd) target; compute the
200
+ // relative path against the canonical root so /tmp vs /private/tmp and
201
+ // other symlinked ancestors never leak into tool-facing paths.
202
+ const memoriesReal = await realpathSafe(memories);
203
+ const abs = await containWithin(memories, relPath);
204
+ return { abs, rel: relative(memoriesReal, abs) };
205
+ }
206
+ /** Resolve a `shared/<name>/...` path against the declared mount, enforcing read-only. */
207
+ async resolveShared(peer, relPath, opts) {
208
+ if (!this.sharing.enabled) {
209
+ throw new MemoryPathError('shared/ paths are disabled (sharing.enabled = false)');
210
+ }
211
+ const rest = relPath.slice('shared/'.length);
212
+ const slash = rest.indexOf('/');
213
+ if (slash <= 0) {
214
+ throw new MemoryPathError(`shared path must be shared/<name>/<file>.md: ${JSON.stringify(relPath)}`);
215
+ }
216
+ const name = rest.slice(0, slash);
217
+ const inner = rest.slice(slash + 1);
218
+ const mount = this.sharing.mounts.find(mount => mount.name === name);
219
+ if (mount === undefined) {
220
+ throw new MemoryPathError(`unknown shared mount ${JSON.stringify(name)}`);
221
+ }
222
+ if (mount.peer === peer) {
223
+ throw new MemoryPathError(`a peer cannot access its own memories via shared/`);
224
+ }
225
+ if (opts.write === true && mount.readonly) {
226
+ throw new MemoryPathError(`shared mount ${JSON.stringify(name)} is read-only`);
227
+ }
228
+ const targetMemories = this.memoriesRoot(mount.peer);
229
+ await ensureDir(targetMemories);
230
+ const subpath = mount.subpath === '' || mount.subpath === '.' ? '' : mount.subpath + '/';
231
+ const targetAbs = await containWithin(targetMemories, subpath + inner);
232
+ return { abs: targetAbs, rel: relPath };
233
+ }
234
+ async fileExists(peer, relPath) {
235
+ const { abs } = await this.resolve(peer, relPath);
236
+ try {
237
+ return (await stat(abs)).isFile();
238
+ }
239
+ catch {
240
+ return false;
241
+ }
242
+ }
243
+ async readFile(peer, relPath) {
244
+ const { abs, rel } = await this.resolve(peer, relPath);
245
+ let content;
246
+ try {
247
+ content = await readFileNode(abs, 'utf8');
248
+ }
249
+ catch (error) {
250
+ throw new MemoryPathError(`cannot read memory ${JSON.stringify(relPath)}: ${error instanceof Error ? error.message : String(error)}`);
251
+ }
252
+ return { content, rel };
253
+ }
254
+ /** Create a new memory file with the canonical skeleton. */
255
+ writeNewMemory(peer, relPath, title, content) {
256
+ const bullets = content.split('\n').map(line => `- ${line}`).join('\n');
257
+ return this.queue.enqueue(async () => {
258
+ await this.writeNow(peer, relPath, `# ${title}\n\n## Current\n${bullets}\n\n## History\n\n## Related\n`);
259
+ });
260
+ }
261
+ /**
262
+ * Append content as new Current bullets; creates the section when absent.
263
+ * Identical lines already in Current are skipped (no-op writes are dropped).
264
+ */
265
+ appendCurrent(peer, relPath, content) {
266
+ return this.queue.enqueue(async () => {
267
+ const existing = await this.readFile(peer, relPath);
268
+ const parsed = parseMemoryFile(existing.content);
269
+ const bullets = content.split('\n').map(line => `- ${line}`);
270
+ const seen = new Set(parsed.current.split('\n').filter(line => line !== ''));
271
+ const fresh = bullets.filter(bullet => !seen.has(bullet));
272
+ if (fresh.length === 0)
273
+ return;
274
+ parsed.current = parsed.current === '' ? fresh.join('\n') : `${parsed.current}\n${fresh.join('\n')}`;
275
+ await this.writeNow(peer, existing.rel, renderMemoryFile(parsed));
276
+ });
277
+ }
278
+ /**
279
+ * Replace a memory's current content, archiving the previous current into
280
+ * History with a date. The previous content is never destroyed (ADD-only).
281
+ */
282
+ updateCurrent(peer, relPath, content) {
283
+ return this.queue.enqueue(async () => {
284
+ const existing = await this.readFile(peer, relPath);
285
+ const parsed = parseMemoryFile(existing.content);
286
+ const date = new Date().toISOString().slice(0, 10);
287
+ if (parsed.current !== '') {
288
+ const oldLines = parsed.current.split('\n');
289
+ const entry = `- ${date}: ${oldLines[0]}`;
290
+ const continuation = oldLines.slice(1).map(line => ` ${line}`).join('\n');
291
+ const historyEntry = continuation === '' ? entry : `${entry}\n${continuation}`;
292
+ parsed.history = parsed.history === '' ? historyEntry : `${parsed.history}\n${historyEntry}`;
293
+ }
294
+ parsed.current = content.split('\n').map(line => `- ${line}`).join('\n');
295
+ await this.writeNow(peer, existing.rel, renderMemoryFile(parsed));
296
+ });
297
+ }
298
+ /**
299
+ * Validate a session id and resolve its checkpoint/audit file under
300
+ * sessionsDir; the id is constrained so it can never escape the directory.
301
+ */
302
+ sessionCheckpointPath(peer, sessionId) {
303
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(sessionId)) {
304
+ throw new MemoryPathError('invalid session id ' + JSON.stringify(sessionId));
305
+ }
306
+ return join(this.sessionsDir(peer), sessionId + '.json');
307
+ }
308
+ /** Read a session checkpoint document; a missing file reads as undefined. */
309
+ async readSessionCheckpoint(peer, sessionId) {
310
+ const abs = this.sessionCheckpointPath(peer, sessionId);
311
+ try {
312
+ return await readFileNode(abs, 'utf8');
313
+ }
314
+ catch {
315
+ return undefined;
316
+ }
317
+ }
318
+ /** Persist a session checkpoint/audit document through the serial queue. */
319
+ writeSessionCheckpoint(peer, sessionId, text) {
320
+ const abs = this.sessionCheckpointPath(peer, sessionId);
321
+ return this.queue.enqueue(async () => {
322
+ await ensureDir(dirname(abs));
323
+ await writeFileAtomic(abs, text, { mode: 0o600, dirMode: 0o700 });
324
+ });
325
+ }
326
+ /** Peer-level rolling run log: peers/{peer}/sessions/extraction.log. */
327
+ summaryLogPath(peer) {
328
+ return join(this.sessionsDir(peer), 'extraction.log');
329
+ }
330
+ /** Append one JSON line to the peer's extraction summary log (capped). */
331
+ appendExtractionLog(peer, line) {
332
+ const abs = this.summaryLogPath(peer);
333
+ return this.queue.enqueue(async () => {
334
+ await ensureDir(dirname(abs));
335
+ let existing = '';
336
+ try {
337
+ existing = await readFileNode(abs, 'utf8');
338
+ }
339
+ catch {
340
+ // first entry
341
+ }
342
+ const lines = existing === '' ? [] : existing.split('\n').filter(l => l.trim() !== '');
343
+ lines.push(line.trim());
344
+ if (lines.length > MAX_LOG_LINES)
345
+ lines.splice(0, lines.length - MAX_LOG_LINES);
346
+ await writeFileAtomic(abs, lines.join('\n') + '\n', { mode: 0o600, dirMode: 0o700 });
347
+ });
348
+ }
349
+ /**
350
+ * Diagnostic: append one failed extraction answer (the raw model output that
351
+ * parseDecision rejected) to peers/{peer}/sessions/failed-answers.log, capped.
352
+ * Only written when the answer was not salvageable; used to debug parse-error
353
+ * spikes. Not part of the functional path — callers tolerate failures.
354
+ */
355
+ failedAnswerLogPath(peer) {
356
+ return join(this.sessionsDir(peer), 'failed-answers.log');
357
+ }
358
+ appendFailedAnswer(peer, entry) {
359
+ const abs = this.failedAnswerLogPath(peer);
360
+ return this.queue.enqueue(async () => {
361
+ await ensureDir(dirname(abs));
362
+ let existing = '';
363
+ try {
364
+ existing = await readFileNode(abs, 'utf8');
365
+ }
366
+ catch {
367
+ // first entry
368
+ }
369
+ const lines = existing === '' ? [] : existing.split('\n').filter(l => l.trim() !== '');
370
+ lines.push(JSON.stringify(entry));
371
+ if (lines.length > MAX_LOG_LINES)
372
+ lines.splice(0, lines.length - MAX_LOG_LINES);
373
+ await writeFileAtomic(abs, lines.join('\n') + '\n', { mode: 0o600, dirMode: 0o700 });
374
+ }).catch(() => { });
375
+ }
376
+ /** Soft-delete: move the file under root/.trash/<date>/, never overwriting. */
377
+ softDelete(peer, relPath) {
378
+ return this.queue.enqueue(async () => {
379
+ const { abs, rel } = await this.resolve(peer, relPath, { write: true });
380
+ const day = new Date().toISOString().slice(0, 10);
381
+ const destDir = join(this.trashDir(), day);
382
+ await ensureDir(destDir);
383
+ const dest = join(destDir, `${basename(rel)}.${Date.now().toString(36)}`);
384
+ await rename(abs, dest);
385
+ });
386
+ }
387
+ /** Parse the L0 index for one peer; a missing index reads as empty. */
388
+ async readIndex(peer) {
389
+ const memories = this.memoriesRoot(peer);
390
+ await ensureDir(memories);
391
+ try {
392
+ return parseIndex(await readFileNode(join(memories, '_index.md'), 'utf8'));
393
+ }
394
+ catch {
395
+ return [];
396
+ }
397
+ }
398
+ /** Rebuild the L0 index from entries, sorted by file mtime (newest first). */
399
+ rebuildIndex(peer, entries) {
400
+ return this.queue.enqueue(async () => this.writeIndexNow(peer, entries));
401
+ }
402
+ /** Add or replace one entry, then rebuild. */
403
+ refreshIndexEntry(peer, relPath, summary) {
404
+ return this.queue.enqueue(async () => {
405
+ const entries = await this.readIndex(peer);
406
+ const category = relPath.split('/')[0] ?? '';
407
+ const next = entries.filter(entry => entry.path !== relPath);
408
+ next.push({ category, path: relPath, summary });
409
+ await this.writeIndexNow(peer, next);
410
+ });
411
+ }
412
+ /** Drop one entry, then rebuild. */
413
+ removeIndexEntry(peer, relPath) {
414
+ return this.queue.enqueue(async () => {
415
+ const entries = await this.readIndex(peer);
416
+ await this.writeIndexNow(peer, entries.filter(entry => entry.path !== relPath));
417
+ });
418
+ }
419
+ /** Line search across a peer's memory files, including enabled shared mounts (index files excluded). */
420
+ async search(peer, query, maxMatches = 20, maxLineLength = 300) {
421
+ if (query.trim() === '')
422
+ throw new MemoryPathError('search query must not be empty');
423
+ const needle = query.toLowerCase();
424
+ const matches = [];
425
+ for (const { abs, prefix } of this.searchRoots(peer)) {
426
+ await ensureDir(abs);
427
+ await this.walkMarkdown(abs, abs, async (fileAbs, rel) => {
428
+ if (matches.length >= maxMatches)
429
+ return;
430
+ let content;
431
+ try {
432
+ content = await readFileNode(fileAbs, 'utf8');
433
+ }
434
+ catch {
435
+ return;
436
+ }
437
+ for (const line of content.split('\n')) {
438
+ if (matches.length >= maxMatches)
439
+ return;
440
+ const trimmed = line.slice(0, maxLineLength);
441
+ if (trimmed.toLowerCase().includes(needle))
442
+ matches.push({ path: prefix + rel, line: trimmed });
443
+ }
444
+ });
445
+ }
446
+ return matches;
447
+ }
448
+ /** Search roots for a peer: its own memories plus each enabled non-self shared mount. */
449
+ searchRoots(peer) {
450
+ const roots = [{ abs: this.memoriesRoot(peer), prefix: '' }];
451
+ if (this.sharing.enabled) {
452
+ for (const mount of this.sharing.mounts) {
453
+ if (mount.peer === peer)
454
+ continue;
455
+ const sub = mount.subpath === '' || mount.subpath === '.' ? '' : mount.subpath;
456
+ roots.push({ abs: join(this.memoriesRoot(mount.peer), sub), prefix: 'shared/' + mount.name + '/' });
457
+ }
458
+ }
459
+ return roots;
460
+ }
461
+ async writeNow(peer, relPath, content) {
462
+ const { abs } = await this.resolve(peer, relPath, { write: true });
463
+ await writeFileAtomic(abs, content, { mode: 0o600, dirMode: 0o700 });
464
+ }
465
+ async writeIndexNow(peer, entries) {
466
+ const memories = this.memoriesRoot(peer);
467
+ const byMtime = new Map();
468
+ for (const entry of entries) {
469
+ try {
470
+ byMtime.set(entry.path, (await stat(join(memories, entry.path))).mtimeMs);
471
+ }
472
+ catch {
473
+ byMtime.set(entry.path, 0);
474
+ }
475
+ }
476
+ const sorted = [...entries].sort((a, b) => (byMtime.get(b.path) ?? 0) - (byMtime.get(a.path) ?? 0));
477
+ await writeFileAtomic(join(memories, '_index.md'), renderIndex(peer, sorted, new Date()), { mode: 0o600, dirMode: 0o700 });
478
+ }
479
+ async walkMarkdown(base, dir, visit) {
480
+ const entries = await readdir(dir, { withFileTypes: true });
481
+ for (const entry of entries) {
482
+ const abs = join(dir, entry.name);
483
+ if (entry.isDirectory()) {
484
+ await this.walkMarkdown(base, abs, visit);
485
+ }
486
+ else if (entry.isFile() && extname(entry.name).toLowerCase() === '.md' && entry.name !== '_index.md') {
487
+ // rel is always relative to the top-level memories root, so results
488
+ // stay valid paths for read_memory / update_memory / forget_memory.
489
+ await visit(abs, relative(base, abs));
490
+ }
491
+ }
492
+ }
493
+ }
494
+ //# sourceMappingURL=memory-store.js.map