dsh-memoir 0.4.3

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.
package/lib/store.js ADDED
@@ -0,0 +1,553 @@
1
+ /**
2
+ * Structured memory store for dsh-memoir — the single source of truth is the
3
+ * global index JSON (~/.dsh/dsh-memoir.json); the per-project PROJECT_MEMORY.md
4
+ * is a regenerated human-readable rendering of the same entries (git-friendly,
5
+ * auto-injected into future sessions). Pure node:fs, no cordis dependency —
6
+ * unit-testable with an injected path.
7
+ *
8
+ * v0.3.1: revision-based in-memory snapshot cache — cold start reads the file
9
+ * once, warm reads return the snapshot without touching disk, writes bump the
10
+ * revision and refresh the snapshot; external file changes are picked up by a
11
+ * low-frequency mtime probe. Corrupt JSON is backed up (never silently
12
+ * overwritten), atomic writes use unique temp names, and project keys are
13
+ * normalized (drive-letter case + separators) so C:\A / c:\a\ / C:/A share
14
+ * one bucket.
15
+ */
16
+ import { createHash, randomBytes } from 'node:crypto';
17
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ /** Global index format version. */
21
+ export const FORMAT_VERSION = 2;
22
+ /** Project memory file name (workspace root, git-committable). */
23
+ export const PROJECT_FILE = 'PROJECT_MEMORY.md';
24
+ /** Section keys, human labels, and markdown headers (fixed order for rendering). */
25
+ export const SECTIONS = {
26
+ work: { label: '工作记录', header: '## 工作记录 Work Log' },
27
+ lessons: { label: '经验教训', header: '## 经验教训 Lessons Learned' },
28
+ actions: { label: '行动指南', header: '## 行动指南 Action Guide' },
29
+ note: { label: '备注', header: '## 备注 Notes' },
30
+ };
31
+ /** Section keys in canonical render order. */
32
+ export const SECTION_KEYS = Object.keys(SECTIONS);
33
+ /**
34
+ * Legacy cap on how much project memory was auto-injected into the prompt,
35
+ * in JS string length (not bytes, not tokens). Kept for compatibility with
36
+ * existing imports; v0.4+ replaces this with the selector's token budget
37
+ * (targetTokens / hardMaxTokens).
38
+ */
39
+ export const INJECT_LIMIT = 16000;
40
+ /** How often (ms) warm load() calls re-probe the file mtime; 0 = every call. */
41
+ export const DEFAULT_MTIME_CHECK_MS = 2000;
42
+ /** Default store location: <home>/.dsh/dsh-memoir.json. */
43
+ export function defaultStorePath() {
44
+ return join(homedir(), '.dsh', 'dsh-memoir.json');
45
+ }
46
+ /** Cross-process mutation lock defaults (roadmap §2.2). */
47
+ export const DEFAULT_LOCK_RETRY_MS = 25;
48
+ export const DEFAULT_LOCK_TIMEOUT_MS = 5000;
49
+ /** Blocking sleep for short lock retries. */
50
+ function sleepSync(ms) {
51
+ const buffer = new Int32Array(new SharedArrayBuffer(4));
52
+ Atomics.wait(buffer, 0, 0, ms);
53
+ }
54
+ /**
55
+ * Run fn while holding an exclusive lock file created with openSync('wx')
56
+ * (atomic O_EXCL create, no race window). Retries every retryMs until
57
+ * timeoutMs, then throws. The lock is always released in finally — even
58
+ * when fn throws. Used to serialize read-modify-write store mutations
59
+ * across processes sharing one ~/.dsh/dsh-memoir.json.
60
+ */
61
+ export function withFileLock(lockPath, fn, options = {}) {
62
+ const retryMs = options.retryMs ?? DEFAULT_LOCK_RETRY_MS;
63
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
64
+ const started = Date.now();
65
+ let fd = null;
66
+ for (;;) {
67
+ try {
68
+ fd = openSync(lockPath, 'wx');
69
+ break;
70
+ }
71
+ catch (error) {
72
+ if (error.code !== 'EEXIST')
73
+ throw error;
74
+ if (Date.now() - started >= timeoutMs) {
75
+ throw new Error('dsh-memoir: store lock timeout after ' + timeoutMs + 'ms (' + lockPath + ') — another process may hold a stale lock');
76
+ }
77
+ sleepSync(retryMs);
78
+ }
79
+ }
80
+ try {
81
+ return fn();
82
+ }
83
+ finally {
84
+ try {
85
+ closeSync(fd);
86
+ }
87
+ catch {
88
+ // already closed
89
+ }
90
+ try {
91
+ unlinkSync(lockPath);
92
+ }
93
+ catch {
94
+ // already released
95
+ }
96
+ }
97
+ }
98
+ /** `YYYY-MM-DD HH:mm` in local time. */
99
+ export function formatTime(ms) {
100
+ const d = new Date(ms);
101
+ const p = (n) => String(n).padStart(2, '0');
102
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
103
+ }
104
+ /**
105
+ * Normalize one workspace path into a stable key:
106
+ * strip trailing separators, unify separators to '/'. Windows drive paths
107
+ * are FULLY lowercased (v0.4.2) — the canonical bucket key of C:\A /
108
+ * c:\a\ / C:/A is 'c:/a', so all case variants share one bucket. The
109
+ * display path stored on the project keeps its original case. POSIX paths
110
+ * are unchanged apart from trailing separators.
111
+ */
112
+ export function projectKey(cwd) {
113
+ const raw = String(cwd).replace(/[\\/]+$/, '');
114
+ const drive = /^([A-Za-z]):/.exec(raw);
115
+ if (drive === null)
116
+ return raw;
117
+ return (drive[1] + ':' + raw.slice(2)).toLowerCase().replace(/\\/g, '/');
118
+ }
119
+ /** Project display title: the last path segment. */
120
+ export function projectTitle(cwd) {
121
+ return projectKey(cwd).split('/').filter(Boolean).pop() || projectKey(cwd);
122
+ }
123
+ /** Mint one entry id (opaque, locally unique). */
124
+ function mintId() {
125
+ return randomBytes(6).toString('hex');
126
+ }
127
+ /** Mint a unique temp-file suffix (avoids concurrent-write collisions). */
128
+ function tmpSuffix() {
129
+ return `${process.pid}.${randomBytes(4).toString('hex')}`;
130
+ }
131
+ /** Atomic write (unique tmp name + rename), creating the parent dir. */
132
+ export function writeFileAtomic(path, content, mode = 0o644) {
133
+ const dir = dirname(path);
134
+ if (!existsSync(dir))
135
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
136
+ const tmp = `${path}.tmp.${tmpSuffix()}`;
137
+ try {
138
+ writeFileSync(tmp, content, { encoding: 'utf8', mode });
139
+ renameSync(tmp, path);
140
+ }
141
+ catch (error) {
142
+ try {
143
+ if (existsSync(tmp))
144
+ renameSync(tmp, tmp + '.failed');
145
+ }
146
+ catch {
147
+ // best effort: never mask the original write error
148
+ }
149
+ throw error;
150
+ }
151
+ }
152
+ /** Trim a long text to a bounded tail for prompt injection. */
153
+ export function bounded(value, limit) {
154
+ if (value.length <= limit)
155
+ return value;
156
+ return `…(内容较长,仅显示最近 ${limit} 字节)…\n` + value.slice(-limit);
157
+ }
158
+ /** Validate one record payload; returns an error message or undefined. */
159
+ export function validateEntryPayload(payload) {
160
+ if (typeof payload !== 'object' || payload === null)
161
+ return 'payload must be a JSON object';
162
+ const record = payload;
163
+ if (typeof record.section !== 'string' || !Object.prototype.hasOwnProperty.call(SECTIONS, record.section)) {
164
+ return `section must be one of ${SECTION_KEYS.join('/')}`;
165
+ }
166
+ if (typeof record.content !== 'string' || record.content.trim() === '')
167
+ return 'content is required';
168
+ if (record.title !== undefined && (typeof record.title !== 'string' || record.title.length > 200)) {
169
+ return 'title must be a string of at most 200 chars';
170
+ }
171
+ return undefined;
172
+ }
173
+ /**
174
+ * The structured memory store.
175
+ */
176
+ export class MemoirStore {
177
+ /** The store file path. */
178
+ path;
179
+ /** How often warm load() calls re-probe the file mtime (0 = every call). */
180
+ mtimeCheckIntervalMs;
181
+ /** Cross-process mutation lock retry interval (withFileLock). */
182
+ lockRetryMs;
183
+ /** Cross-process mutation lock acquisition timeout (withFileLock). */
184
+ lockTimeoutMs;
185
+ /** The in-memory snapshot backing warm reads. */
186
+ snapshot = null;
187
+ /** Write counter; bumped on every save() (record/remove). */
188
+ revision = 0;
189
+ /** Snapshot-rebuild counter; bumped on every snapshot (re)build. */
190
+ epoch = 0;
191
+ /** Timestamp of the last mtime probe (throttles external-change checks). */
192
+ lastMtimeCheck = 0;
193
+ // IO / cache counters (diagnostics + tests).
194
+ loadCount = 0;
195
+ hitCount = 0;
196
+ fileReadCount = 0;
197
+ statProbeCount = 0;
198
+ corruptBackupCount = 0;
199
+ lastLoadMs;
200
+ /** renderMarkdown cache: project key → { signature, markdown }. */
201
+ renderCache = new Map();
202
+ renderCount = 0;
203
+ renderComputeCount = 0;
204
+ /**
205
+ * @param path - store file path (defaults to the standard location).
206
+ * @param options.mtimeCheckIntervalMs - mtime probe throttle; 0 probes on
207
+ * every load (tests), defaults to a low-frequency 2000ms.
208
+ * @param options.lockRetryMs / lockTimeoutMs - cross-process mutation lock
209
+ * tuning (tests shrink these; defaults 25ms / 5000ms).
210
+ */
211
+ constructor(path, options) {
212
+ this.path = path ?? defaultStorePath();
213
+ this.mtimeCheckIntervalMs = options?.mtimeCheckIntervalMs ?? DEFAULT_MTIME_CHECK_MS;
214
+ this.lockRetryMs = options?.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;
215
+ this.lockTimeoutMs = options?.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
216
+ }
217
+ /** The cross-process lock file guarding mutations of this store. */
218
+ lockFilePath() {
219
+ return this.path.replace(/\.json$/, '') + '.lock';
220
+ }
221
+ /**
222
+ * Run one read-modify-write mutation inside the cross-process lock.
223
+ * Inside the critical section the in-memory snapshot is dropped and the
224
+ * store is re-read from disk, so a process whose snapshot went stale
225
+ * mutates the latest on-disk state (no lost update between processes).
226
+ */
227
+ mutateLocked(mutate) {
228
+ const lockPath = this.lockFilePath();
229
+ const dir = dirname(lockPath);
230
+ if (!existsSync(dir))
231
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
232
+ return withFileLock(lockPath, () => {
233
+ this.invalidate();
234
+ return mutate();
235
+ }, { retryMs: this.lockRetryMs, timeoutMs: this.lockTimeoutMs });
236
+ }
237
+ /** Current store revision (0 before the first load/save). */
238
+ currentRevision() {
239
+ return this.revision;
240
+ }
241
+ /** Stat the store file into the snapshot signature (null when absent). */
242
+ statNow() {
243
+ try {
244
+ const s = statSync(this.path);
245
+ return { mtimeMs: s.mtimeMs, size: s.size };
246
+ }
247
+ catch {
248
+ return null;
249
+ }
250
+ }
251
+ /**
252
+ * Load and normalize the store.
253
+ *
254
+ * Revision-based snapshot cache: the first call of a process reads and
255
+ * parses the file; every later call returns the in-memory snapshot without
256
+ * touching disk. External file changes (another dsh process) are picked up
257
+ * by a low-frequency mtime probe (mtimeCheckIntervalMs). Absence is
258
+ * negatively cached the same way. Corrupt JSON is renamed to a
259
+ * `.corrupt.<timestamp>` backup before the store starts fresh.
260
+ */
261
+ load() {
262
+ this.loadCount++;
263
+ const started = Date.now();
264
+ // Warm path: serve the snapshot directly; probe mtime only at the
265
+ // configured frequency (or every call when the interval is 0).
266
+ if (this.snapshot !== null) {
267
+ const now = Date.now();
268
+ if (this.mtimeCheckIntervalMs === 0 || now - this.lastMtimeCheck >= this.mtimeCheckIntervalMs) {
269
+ this.lastMtimeCheck = now;
270
+ this.statProbeCount++;
271
+ const stat = this.statNow();
272
+ const same = this.snapshot.stat === null
273
+ ? stat === null
274
+ : stat !== null && stat.mtimeMs === this.snapshot.stat.mtimeMs && stat.size === this.snapshot.stat.size;
275
+ if (same) {
276
+ this.hitCount++;
277
+ return this.snapshot.file;
278
+ }
279
+ // File changed underneath us: fall through to a rebuild.
280
+ }
281
+ else {
282
+ this.hitCount++;
283
+ return this.snapshot.file;
284
+ }
285
+ }
286
+ // Cold path: stat + read + parse (+ corrupt backup) + normalize.
287
+ let stat = this.statNow();
288
+ let parsed = { version: FORMAT_VERSION, projects: {} };
289
+ if (stat !== null) {
290
+ try {
291
+ const raw = JSON.parse(readFileSync(this.path, 'utf8'));
292
+ if (typeof raw === 'object' && raw !== null && typeof raw.projects === 'object' && raw.projects !== null) {
293
+ parsed = raw;
294
+ }
295
+ else {
296
+ throw new Error('store shape invalid');
297
+ }
298
+ }
299
+ catch {
300
+ // Corrupt store: preserve it as a backup, then start fresh (the
301
+ // project markdown files remain as human-readable history).
302
+ this.corruptBackupCount++;
303
+ try {
304
+ renameSync(this.path, `${this.path}.corrupt.${Date.now()}`);
305
+ stat = null;
306
+ }
307
+ catch {
308
+ // Backup failed (permissions?): keep serving fresh in-memory; the
309
+ // next save will still overwrite the corrupt file.
310
+ }
311
+ }
312
+ this.fileReadCount++;
313
+ }
314
+ const file = this.normalize(parsed);
315
+ this.epoch++;
316
+ this.snapshot = { revision: this.revision, epoch: this.epoch, file, stat };
317
+ this.lastMtimeCheck = Date.now();
318
+ this.lastLoadMs = Date.now() - started;
319
+ // Entry shapes may have changed underneath us: drop the render cache.
320
+ this.renderCache.clear();
321
+ return file;
322
+ }
323
+ /** Normalize a parsed store file: mint ids, coerce shapes, merge duplicate
324
+ * buckets that normalize to the same project key (legacy Windows variants). */
325
+ normalize(parsed) {
326
+ const projects = {};
327
+ for (const [rawKey, project] of Object.entries(parsed.projects ?? {})) {
328
+ if (typeof project !== 'object' || project === null)
329
+ continue;
330
+ const key = projectKey(rawKey);
331
+ const rawEntries = Array.isArray(project.entries) ? project.entries : [];
332
+ const entries = rawEntries
333
+ .filter((e) => typeof e === 'object' && e !== null)
334
+ .map((e) => ({
335
+ id: typeof e.id === 'string' && e.id !== '' ? e.id : mintId(),
336
+ section: typeof e.section === 'string' && Object.prototype.hasOwnProperty.call(SECTIONS, e.section) ? e.section : 'note',
337
+ // Conditional spread (not "title: undefined"): the in-memory
338
+ // shape must round-trip the serialized JSON exactly, so
339
+ // snapshot.file stays deep-equal to the on-disk file.
340
+ ...(typeof e.title === 'string' && e.title !== '' ? { title: e.title } : {}),
341
+ content: typeof e.content === 'string' ? e.content : '',
342
+ time: typeof e.time === 'number' && Number.isFinite(e.time) ? e.time : Date.now(),
343
+ ...(typeof e.sessionId === 'string' && e.sessionId !== '' ? { sessionId: e.sessionId } : {}),
344
+ }));
345
+ const normalized = {
346
+ path: typeof project.path === 'string' && project.path !== '' ? project.path : rawKey,
347
+ title: typeof project.title === 'string' && project.title !== '' ? project.title : projectTitle(rawKey),
348
+ updatedAt: typeof project.updatedAt === 'number' ? project.updatedAt : (entries[entries.length - 1]?.time ?? Date.now()),
349
+ entries,
350
+ };
351
+ const existing = projects[key];
352
+ if (existing === undefined) {
353
+ projects[key] = normalized;
354
+ }
355
+ else {
356
+ // Same workspace stored under legacy key variants: merge by id.
357
+ const seen = new Set(existing.entries.map((e) => e.id));
358
+ for (const entry of normalized.entries) {
359
+ if (!seen.has(entry.id))
360
+ existing.entries.push(entry);
361
+ }
362
+ existing.updatedAt = Math.max(existing.updatedAt, normalized.updatedAt);
363
+ }
364
+ }
365
+ return { version: FORMAT_VERSION, projects };
366
+ }
367
+ /** Persist the store atomically (0600 — may contain user's notes). */
368
+ save(file) {
369
+ try {
370
+ writeFileAtomic(this.path, JSON.stringify(file, null, 2) + '\n', 0o600);
371
+ }
372
+ catch (error) {
373
+ // The in-memory snapshot may already carry the mutation; drop it so the
374
+ // next load() re-reads the on-disk truth instead of serving a stale hit.
375
+ this.snapshot = null;
376
+ throw error;
377
+ }
378
+ // Bump the revision and refresh the snapshot signature from the file we
379
+ // just wrote: subsequent reads hit the cache without re-parsing.
380
+ this.revision++;
381
+ this.epoch++;
382
+ this.snapshot = { revision: this.revision, epoch: this.epoch, file, stat: this.statNow() };
383
+ this.lastMtimeCheck = Date.now();
384
+ // Entry shapes changed under a mutation: drop all render-cache entries.
385
+ this.renderCache.clear();
386
+ }
387
+ /** Drop the snapshot so the next load() re-reads and re-parses the file. */
388
+ invalidate() {
389
+ this.snapshot = null;
390
+ this.renderCache.clear();
391
+ }
392
+ /** Cache/IO counters (diagnostics + tests). */
393
+ stats() {
394
+ return {
395
+ revision: this.revision,
396
+ epoch: this.epoch,
397
+ loads: this.loadCount,
398
+ hits: this.hitCount,
399
+ misses: this.loadCount - this.hitCount,
400
+ hitRate: this.loadCount === 0 ? 0 : this.hitCount / this.loadCount,
401
+ fileReads: this.fileReadCount,
402
+ statProbes: this.statProbeCount,
403
+ corruptBackups: this.corruptBackupCount,
404
+ renders: this.renderCount,
405
+ renderComputes: this.renderComputeCount,
406
+ renderHitRate: this.renderCount === 0 ? 0 : (this.renderCount - this.renderComputeCount) / this.renderCount,
407
+ lastLoadMs: this.lastLoadMs,
408
+ };
409
+ }
410
+ /** One project record, or undefined. */
411
+ project(cwd) {
412
+ return this.load().projects[projectKey(cwd)];
413
+ }
414
+ /** Entries of one project in insertion order. */
415
+ entries(cwd) {
416
+ return this.project(cwd)?.entries ?? [];
417
+ }
418
+ /** Compact per-project summaries (path, title, entry count, updatedAt). */
419
+ listProjects() {
420
+ const store = this.load();
421
+ return Object.entries(store.projects).map(([key, project]) => ({
422
+ key,
423
+ path: project.path,
424
+ title: project.title,
425
+ count: project.entries.length,
426
+ updatedAt: project.updatedAt,
427
+ }));
428
+ }
429
+ /** Append one entry and regenerate the project markdown. Returns the entry. */
430
+ record(cwd, payload, sessionId) {
431
+ const error = validateEntryPayload(payload);
432
+ if (error !== undefined)
433
+ throw new Error(error);
434
+ return this.mutateLocked(() => {
435
+ const store = this.load();
436
+ const key = projectKey(cwd);
437
+ const project = (store.projects[key] ??= {
438
+ // Display path keeps the caller's original case; the bucket key is
439
+ // the canonical (lowercased for Windows) projectKey(cwd).
440
+ path: cwd,
441
+ title: projectTitle(key),
442
+ updatedAt: Date.now(),
443
+ entries: [],
444
+ });
445
+ const entry = {
446
+ id: mintId(),
447
+ section: payload.section,
448
+ ...(typeof payload.title === 'string' && payload.title.trim() !== '' ? { title: payload.title.trim() } : {}),
449
+ content: payload.content.trim(),
450
+ time: Date.now(),
451
+ ...(typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : {}),
452
+ };
453
+ project.entries.push(entry);
454
+ project.updatedAt = entry.time;
455
+ this.save(store);
456
+ this.writeProjectFile(cwd);
457
+ return entry;
458
+ });
459
+ }
460
+ /** Remove one entry by id; regenerates the project markdown. */
461
+ remove(cwd, id) {
462
+ return this.mutateLocked(() => {
463
+ const store = this.load();
464
+ const key = projectKey(cwd);
465
+ const project = store.projects[key];
466
+ if (project === undefined)
467
+ return false;
468
+ const index = project.entries.findIndex((e) => e.id === id);
469
+ if (index < 0)
470
+ return false;
471
+ project.entries.splice(index, 1);
472
+ project.updatedAt = Date.now();
473
+ this.save(store);
474
+ this.writeProjectFile(cwd);
475
+ return true;
476
+ });
477
+ }
478
+ /** Render one entry as a markdown bullet line. */
479
+ renderEntryLine(entry) {
480
+ const label = SECTIONS[entry.section]?.label ?? entry.section;
481
+ const when = formatTime(entry.time);
482
+ const head = entry.title !== undefined ? `${entry.title} — ` : '';
483
+ return `- [${when}] [${label}] ${head}${entry.content}`;
484
+ }
485
+ /** Cheap O(1) signature of one project's entries (count + tail id/time). */
486
+ renderSignature(project) {
487
+ const entries = project?.entries ?? [];
488
+ const last = entries[entries.length - 1];
489
+ return `${entries.length}|${project?.updatedAt ?? 0}|${last?.id ?? ''}|${last?.time ?? ''}`;
490
+ }
491
+ /** Regenerate the full PROJECT_MEMORY.md content for one project. */
492
+ renderMarkdown(cwd) {
493
+ this.renderCount++;
494
+ const key = projectKey(cwd);
495
+ const project = this.load().projects[key];
496
+ const signature = this.renderSignature(project);
497
+ const cached = this.renderCache.get(key);
498
+ if (cached !== undefined && cached.signature === signature)
499
+ return cached.markdown;
500
+ const markdown = this.renderMarkdownNow(project?.entries ?? []);
501
+ this.renderComputeCount++;
502
+ this.renderCache.set(key, { signature, markdown });
503
+ return markdown;
504
+ }
505
+ /** Pure markdown assembly for one project's entries (no cache access). */
506
+ renderMarkdownNow(entries) {
507
+ const header = [
508
+ '# 项目持久记忆 Project Memory',
509
+ '',
510
+ '> 本文件由 dsh-memoir 插件维护:记录本项目历次会话的工作归纳、经验教训与行动指南,',
511
+ '> 作为未来 AGENTS 接手本项目时的行动指南。会话开始时自动注入 system prompt。',
512
+ '',
513
+ ];
514
+ const body = [];
515
+ for (const key of SECTION_KEYS) {
516
+ const group = entries.filter((e) => e.section === key);
517
+ if (group.length === 0)
518
+ continue;
519
+ body.push(SECTIONS[key].header, '');
520
+ for (const entry of group)
521
+ body.push(this.renderEntryLine(entry));
522
+ body.push('');
523
+ }
524
+ if (body.length === 0) {
525
+ body.push('> 暂无条目。让 agent 用 memoir_record 沉淀,或在「记忆」面板中手动记录。', '');
526
+ }
527
+ return [...header, ...body].join('\n');
528
+ }
529
+ /** Absolute path of one project's memory file (no write). */
530
+ projectFilePath(cwd) {
531
+ return join(cwd, PROJECT_FILE);
532
+ }
533
+ /** Regenerate and write the project memory file; returns its path. */
534
+ writeProjectFile(cwd) {
535
+ const path = this.projectFilePath(cwd);
536
+ const markdown = this.renderMarkdown(cwd);
537
+ // Skip the write when the file already holds this exact content — keeps
538
+ // mtimes stable (no git churn) and avoids pointless disk writes.
539
+ try {
540
+ if (existsSync(path) && readFileSync(path, 'utf8') === markdown)
541
+ return path;
542
+ }
543
+ catch {
544
+ // Unreadable file: fall through to a fresh atomic write.
545
+ }
546
+ writeFileAtomic(path, markdown);
547
+ return path;
548
+ }
549
+ }
550
+ /** SHA-256 hex digest of a string, truncated for prompt-stability hashing. */
551
+ export function sha256(text, length = 16) {
552
+ return createHash('sha256').update(text).digest('hex').slice(0, length);
553
+ }
package/lib/tools.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Agent tools for dsh-memoir: memoir_record (persist one work / lesson /
3
+ * action / note entry) and memoir_read (read project / global memory). Both
4
+ * tools resolve the caller's workspace from the executing agent's session cwd
5
+ * and delegate all persistence to the structured MemoirStore.
6
+ *
7
+ * v0.3.1: section headers no longer duplicate "##"; project/global reads are
8
+ * bounded by internal hard caps; descriptions and renders are trimmed.
9
+ * v0.4.0: memoir_read gains limit (default 8, max 30) and detail
10
+ * (compact default / full) so reads are cheap by default.
11
+ */
12
+ import type { ContentBlock } from '@deepseek-ai/dsh-llm';
13
+ import type { ToolRunContext } from '@deepseek-ai/dsh-tools';
14
+ import type { MemoirEntry, MemoirStore } from './store.js';
15
+ import type { RetrievalEngine } from './retrieval.js';
16
+ /** One text content block (the only render shape these tools emit). */
17
+ export declare function text(value: string): ContentBlock[];
18
+ /** Resolve the caller session's workspace cwd (absolute), or undefined. */
19
+ export declare function resolveWorkspace(exec: ToolRunContext | undefined): string | undefined;
20
+ /** Internal hard caps: bound read output regardless of stored volume. */
21
+ export declare const READ_GLOBAL_MAX_ENTRIES_PER_PROJECT = 50;
22
+ export declare const READ_OUTPUT_MAX_CHARS = 16000;
23
+ /** memoir_read output-shaping options (from config readDefaultLimit/readMaxLimit). */
24
+ export interface ReadToolOptions {
25
+ defaultLimit: number;
26
+ maxLimit: number;
27
+ }
28
+ /** Full-detail entry line (time + label + title + content). */
29
+ export declare function renderEntryFull(entry: MemoirEntry): string;
30
+ /** Compact one-line entry (id + title + collapsed single-line content). */
31
+ export declare function renderEntryCompact(entry: MemoirEntry, maxContent?: number): string;
32
+ /** The record tool: persist one memory entry. */
33
+ export declare function memoirRecordTool(store: MemoirStore): import("@deepseek-ai/dsh-tools").ToolDefinition;
34
+ /** The read tool: project / global / all memory with optional filters. */
35
+ export declare function memoirReadTool(store: MemoirStore, options?: ReadToolOptions, retrieval?: RetrievalEngine): import("@deepseek-ai/dsh-tools").ToolDefinition;