opencode-harnmem 0.0.0-stage → 0.1.0

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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/index.js +512 -0
  4. package/lib/engine.mjs +72 -0
  5. package/lib/format.mjs +119 -0
  6. package/lib/settings.mjs +131 -0
  7. package/package.json +23 -3
  8. package/vendor/core/assets.d.ts +52 -0
  9. package/vendor/core/assets.d.ts.map +1 -0
  10. package/vendor/core/assets.js +227 -0
  11. package/vendor/core/assets.js.map +1 -0
  12. package/vendor/core/config.d.ts +36 -0
  13. package/vendor/core/config.d.ts.map +1 -0
  14. package/vendor/core/config.js +134 -0
  15. package/vendor/core/config.js.map +1 -0
  16. package/vendor/core/consolidate.d.ts +14 -0
  17. package/vendor/core/consolidate.d.ts.map +1 -0
  18. package/vendor/core/consolidate.js +237 -0
  19. package/vendor/core/consolidate.js.map +1 -0
  20. package/vendor/core/data/synonyms.en.txt +61 -0
  21. package/vendor/core/data/synonyms.zh.txt +157 -0
  22. package/vendor/core/extract.d.ts +49 -0
  23. package/vendor/core/extract.d.ts.map +1 -0
  24. package/vendor/core/extract.js +179 -0
  25. package/vendor/core/extract.js.map +1 -0
  26. package/vendor/core/frontmatter.d.ts +31 -0
  27. package/vendor/core/frontmatter.d.ts.map +1 -0
  28. package/vendor/core/frontmatter.js +372 -0
  29. package/vendor/core/frontmatter.js.map +1 -0
  30. package/vendor/core/harnmem.d.ts +87 -0
  31. package/vendor/core/harnmem.d.ts.map +1 -0
  32. package/vendor/core/harnmem.js +918 -0
  33. package/vendor/core/harnmem.js.map +1 -0
  34. package/vendor/core/hot-context.d.ts +28 -0
  35. package/vendor/core/hot-context.d.ts.map +1 -0
  36. package/vendor/core/hot-context.js +179 -0
  37. package/vendor/core/hot-context.js.map +1 -0
  38. package/vendor/core/index.d.ts +16 -0
  39. package/vendor/core/index.d.ts.map +1 -0
  40. package/vendor/core/index.js +10 -0
  41. package/vendor/core/index.js.map +1 -0
  42. package/vendor/core/indexer.d.ts +107 -0
  43. package/vendor/core/indexer.d.ts.map +1 -0
  44. package/vendor/core/indexer.js +282 -0
  45. package/vendor/core/indexer.js.map +1 -0
  46. package/vendor/core/inspect.d.ts +278 -0
  47. package/vendor/core/inspect.d.ts.map +1 -0
  48. package/vendor/core/inspect.js +643 -0
  49. package/vendor/core/inspect.js.map +1 -0
  50. package/vendor/core/lifecycle.d.ts +50 -0
  51. package/vendor/core/lifecycle.d.ts.map +1 -0
  52. package/vendor/core/lifecycle.js +249 -0
  53. package/vendor/core/lifecycle.js.map +1 -0
  54. package/vendor/core/project.d.ts +41 -0
  55. package/vendor/core/project.d.ts.map +1 -0
  56. package/vendor/core/project.js +197 -0
  57. package/vendor/core/project.js.map +1 -0
  58. package/vendor/core/prompts/README.md +96 -0
  59. package/vendor/core/prompts/consolidate-digest.md +31 -0
  60. package/vendor/core/prompts/extract.md +80 -0
  61. package/vendor/core/prompts/migrate.md +96 -0
  62. package/vendor/core/prompts/synonyms.md +43 -0
  63. package/vendor/core/prompts/write-policy.md +12 -0
  64. package/vendor/core/retrieval.d.ts +95 -0
  65. package/vendor/core/retrieval.d.ts.map +1 -0
  66. package/vendor/core/retrieval.js +569 -0
  67. package/vendor/core/retrieval.js.map +1 -0
  68. package/vendor/core/store.d.ts +145 -0
  69. package/vendor/core/store.d.ts.map +1 -0
  70. package/vendor/core/store.js +511 -0
  71. package/vendor/core/store.js.map +1 -0
  72. package/vendor/core/suggest.d.ts +128 -0
  73. package/vendor/core/suggest.d.ts.map +1 -0
  74. package/vendor/core/suggest.js +384 -0
  75. package/vendor/core/suggest.js.map +1 -0
  76. package/vendor/core/text.d.ts +80 -0
  77. package/vendor/core/text.d.ts.map +1 -0
  78. package/vendor/core/text.js +253 -0
  79. package/vendor/core/text.js.map +1 -0
  80. package/vendor/core/types.d.ts +453 -0
  81. package/vendor/core/types.d.ts.map +1 -0
  82. package/vendor/core/types.js +14 -0
  83. package/vendor/core/types.js.map +1 -0
  84. package/vendor/core/util.d.ts +38 -0
  85. package/vendor/core/util.d.ts.map +1 -0
  86. package/vendor/core/util.js +248 -0
  87. package/vendor/core/util.js.map +1 -0
@@ -0,0 +1,918 @@
1
+ /**
2
+ * HarnMem facade: the only entry point harness adapters should use.
3
+ * See docs/design/07-adapter-contract.md.
4
+ */
5
+ import { promises as fs } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { HARNMEM_API_VERSION } from "./types.js";
8
+ import { loadConfig } from "./config.js";
9
+ import { detectProject, findProject, registerProject } from "./project.js";
10
+ import { Store, shardKey } from "./store.js";
11
+ import { IndexManager } from "./indexer.js";
12
+ import { Retriever } from "./retrieval.js";
13
+ import { Consolidator } from "./consolidate.js";
14
+ import { decideDedupe, supersedeOld } from "./lifecycle.js";
15
+ import { buildDigest, runExtraction } from "./extract.js";
16
+ import { deployDefaultAssets, loadSynonymGroups } from "./assets.js";
17
+ import { dayKey, newMemoryId, parseTimestamp, readTextIfExists, resolveHome, withTimeout, } from "./util.js";
18
+ const DEFAULT_SYNONYMS = [
19
+ ["部署", "上线", "发布"],
20
+ ["记忆", "内存", "memory"],
21
+ ["插件", "扩展", "extension", "plugin"],
22
+ ["报错", "错误", "异常", "error"],
23
+ ["配置", "设置", "config"],
24
+ ["重构", "重写", "refactor"],
25
+ ];
26
+ export class HarnMem {
27
+ apiVersion = HARNMEM_API_VERSION;
28
+ config;
29
+ store;
30
+ project;
31
+ index;
32
+ retriever;
33
+ consolidator;
34
+ synonyms = null;
35
+ session;
36
+ constructor(config, store, project) {
37
+ this.config = config;
38
+ this.store = store;
39
+ this.project = project;
40
+ this.index = new IndexManager(store, config);
41
+ this.retriever = new Retriever({
42
+ store,
43
+ index: this.index,
44
+ config,
45
+ getProjectId: () => this.project?.id,
46
+ loadSynonyms: () => this.loadSynonyms(),
47
+ });
48
+ this.consolidator = new Consolidator(store, this.index, config);
49
+ this.session = {
50
+ id: config.sessionId ?? newMemoryId(),
51
+ startedAt: (config.now ?? Date.now)(),
52
+ date: dayKey((config.now ?? Date.now)()),
53
+ modelWrites: 0,
54
+ contextDirty: true,
55
+ pendingReason: null,
56
+ snapshot: null,
57
+ lastAuditSignature: null,
58
+ extractFailures: 0,
59
+ extractSkips: {},
60
+ extracted: false,
61
+ };
62
+ }
63
+ static async open(options) {
64
+ if (!options || typeof options !== "object")
65
+ throw new Error("HarnMem.open requires a config object");
66
+ if (!options.harness)
67
+ throw new Error("HarnMem.open requires config.harness");
68
+ const home = resolveHome(options.home);
69
+ const cwd = options.cwd ?? process.cwd();
70
+ const project = options.project
71
+ ? await resolveExplicitProject(home, options.project, cwd)
72
+ : await detectProject(cwd);
73
+ const config = await loadConfig(home, project.root, { ...options, home });
74
+ config.project = project.id;
75
+ const store = new Store(home);
76
+ await store.init();
77
+ // First run: copy shipped prompts/ and data/ into the home so they are editable.
78
+ deployDefaultAssets(home);
79
+ try {
80
+ await registerProject(home, project, config.harness, (config.now ?? Date.now)());
81
+ }
82
+ catch {
83
+ // registry is advisory; never block opening
84
+ }
85
+ const harnmem = new HarnMem(config, store, project);
86
+ // Usage counters are an overlay file, not part of the memory records.
87
+ harnmem.setUsage(await store.loadUsage());
88
+ return harnmem;
89
+ }
90
+ setUsage(usage) {
91
+ this.index.setUsage(usage);
92
+ }
93
+ now() {
94
+ return (this.config.now ?? Date.now)();
95
+ }
96
+ info() {
97
+ return {
98
+ apiVersion: this.apiVersion,
99
+ home: this.config.home,
100
+ project: this.project.id,
101
+ harness: this.config.harness,
102
+ sessionId: this.session.id,
103
+ };
104
+ }
105
+ // ------------------------------------------------------------------ session
106
+ async sessionStart(input = {}) {
107
+ if (input.sessionId)
108
+ this.session.id = input.sessionId;
109
+ this.config.sessionId = this.session.id;
110
+ if (input.date)
111
+ this.session.date = input.date;
112
+ this.session.startedAt = this.now();
113
+ this.session.contextDirty = true;
114
+ this.session.pendingReason = input.reason ?? 'session_start';
115
+ const block = await this.ensureContext(input.reason ?? "session_start");
116
+ await this.auditInjection(block);
117
+ return block;
118
+ }
119
+ /** Cached, byte-stable context; rebuild only when dirty or the day changed. */
120
+ async ensureContext(reason) {
121
+ const today = dayKey(this.now());
122
+ const dirty = this.session.contextDirty || !this.session.snapshot || this.session.date !== today;
123
+ if (!dirty && this.session.snapshot)
124
+ return this.session.snapshot;
125
+ const effective = reason ?? this.session.pendingReason ?? "session_start";
126
+ const block = await this.retriever.buildContext(effective);
127
+ this.session.snapshot = block;
128
+ this.session.contextDirty = false;
129
+ this.session.pendingReason = null;
130
+ this.session.date = today;
131
+ if (effective === "memory_write" || effective === "manual" || effective === "day_rollover" || effective === "compaction") {
132
+ await this.auditInjection(block);
133
+ }
134
+ return block;
135
+ }
136
+ /** Adapter calls this after the model itself wrote a memory this turn. */
137
+ noteModelWrite() {
138
+ this.session.modelWrites++;
139
+ this.session.contextDirty = true;
140
+ this.session.pendingReason = "memory_write";
141
+ }
142
+ invalidateContext() {
143
+ this.session.contextDirty = true;
144
+ }
145
+ async auditInjection(block) {
146
+ const signature = `${block.ids.join(",")}|${block.text.length}`;
147
+ if (signature === this.session.lastAuditSignature)
148
+ return;
149
+ this.session.lastAuditSignature = signature;
150
+ try {
151
+ await this.store.audit({
152
+ ts: this.now(),
153
+ kind: "inject",
154
+ harness: this.config.harness,
155
+ session: this.session.id,
156
+ ids: block.ids,
157
+ bytes: Buffer.byteLength(block.text, "utf8"),
158
+ tokens: block.tokensEstimate,
159
+ reason: block.reason,
160
+ });
161
+ }
162
+ catch {
163
+ // audit is best effort
164
+ }
165
+ }
166
+ // -------------------------------------------------------------------- write
167
+ async write(input) {
168
+ if (this.config.readOnly)
169
+ throw new Error("HarnMem is read-only");
170
+ const warnings = [];
171
+ const now = this.now();
172
+ const title = (input.title ?? "").trim();
173
+ const description = (input.description ?? "").trim();
174
+ let content = (input.content ?? "").trim();
175
+ if (!title)
176
+ throw new Error("memory_write requires a title");
177
+ if (!description)
178
+ throw new Error("memory_write requires a description");
179
+ if (!content)
180
+ throw new Error("memory_write requires content");
181
+ const maxBody = this.config.governance?.maxBodyBytes ?? 65536;
182
+ if (Buffer.byteLength(content, "utf8") > maxBody) {
183
+ content = `${content.slice(0, maxBody)}…`;
184
+ warnings.push(`content truncated to ${maxBody} bytes`);
185
+ }
186
+ const scope = resolveScope(input, this.project.id);
187
+ const projectId = scope === "project" ? (input.project ? await this.resolveProjectId(input.project) : this.project.id) : undefined;
188
+ const shard = this.shardFor(scope, projectId);
189
+ const sensitive = input.sensitive ?? looksSensitive(`${title}\n${description}\n${content}`);
190
+ const normalized = {
191
+ ...input,
192
+ scope,
193
+ project: projectId,
194
+ title,
195
+ description,
196
+ content,
197
+ tags: uniqueStrings(input.tags ?? []),
198
+ entities: uniqueStrings(input.entities ?? []),
199
+ sensitive,
200
+ };
201
+ const { records } = await this.store.readShard(shard);
202
+ const decision = decideDedupe(normalized, records, now);
203
+ const links = uniqueStrings([...(input.links ?? []), ...decision.links]).filter((id) => id !== undefined);
204
+ const existingIds = new Set(records.map((record) => record.id));
205
+ const validLinks = links.filter((id) => existingIds.has(id));
206
+ const superseded = [];
207
+ const source = {
208
+ harness: input.source?.harness ?? this.config.harness,
209
+ session: input.source?.session ?? this.session.id,
210
+ turn: input.source?.turn,
211
+ ts: input.source?.ts ?? now,
212
+ };
213
+ if (decision.action === "skip" && decision.target) {
214
+ await this.store.audit({ ts: now, kind: "write", action: "SKIP", id: decision.target.id, duplicateOf: decision.target.id });
215
+ return { id: decision.target.id, action: "SKIP", superseded: [], warnings, record: decision.target };
216
+ }
217
+ if (decision.action === "merge" && decision.target && decision.merged) {
218
+ await this.store.writeRecord(shard, decision.merged);
219
+ await this.store.audit({ ts: now, kind: "write", action: "MERGE", id: decision.target.id, reason: decision.reason });
220
+ this.session.contextDirty = true;
221
+ this.session.pendingReason = 'memory_write';
222
+ this.index.invalidate();
223
+ return { id: decision.target.id, action: "MERGE", superseded: [], warnings, record: decision.merged };
224
+ }
225
+ const id = newMemoryId(now);
226
+ let record = {
227
+ schema: 1,
228
+ id,
229
+ type: input.type ?? "project",
230
+ category: input.category,
231
+ scope,
232
+ project: projectId,
233
+ projectName: scope === "project" ? this.project.name : undefined,
234
+ title: title.slice(0, 200),
235
+ description: description.slice(0, 400),
236
+ content,
237
+ tags: normalized.tags ?? [],
238
+ entities: normalized.entities ?? [],
239
+ importance: clampNumber(input.importance ?? defaultImportance(input.type), 0, 10),
240
+ confidence: clampNumber(input.confidence ?? defaultConfidence(input.type), 0, 1),
241
+ pinned: input.pinned ?? isConstraintType(input.type),
242
+ status: "active",
243
+ created: now,
244
+ updated: now,
245
+ lastUsed: undefined,
246
+ usedCount: 0,
247
+ validFrom: now,
248
+ validTo: null,
249
+ observedAt: parseObservedAt(content) ?? now,
250
+ evidence: Math.max(1, (input.evidence ?? []).length),
251
+ source,
252
+ provenance: [`${this.config.harness}:${this.session.id}`],
253
+ links: validLinks,
254
+ supersedes: uniqueStrings(input.supersedes ?? []),
255
+ supersededBy: null,
256
+ sensitive,
257
+ };
258
+ if (decision.action === "supersede" && decision.target) {
259
+ const old = supersedeOld(decision.target, id, now);
260
+ await this.store.writeRecord(shard, old);
261
+ record = { ...record, supersedes: uniqueStrings([...record.supersedes, old.id]), links: validLinks.filter((l) => l !== old.id) };
262
+ superseded.push(old.id);
263
+ }
264
+ // Explicit `supersedes` from a caller/extraction: only existing ids count,
265
+ // and every still-active target is moved through the bi-temporal supersede
266
+ // path (never deleted). This preserves the old version for history queries.
267
+ const requestedSupersedes = record.supersedes.filter((targetId) => targetId !== id);
268
+ const acceptedSupersedes = [];
269
+ for (const targetId of requestedSupersedes) {
270
+ if (superseded.includes(targetId)) {
271
+ acceptedSupersedes.push(targetId);
272
+ continue;
273
+ }
274
+ const found = await this.store.findRecord(targetId);
275
+ if (!found)
276
+ continue;
277
+ acceptedSupersedes.push(targetId);
278
+ if (found.record.status === "active") {
279
+ await this.store.writeRecord(found.shard, supersedeOld(found.record, id, now));
280
+ superseded.push(targetId);
281
+ }
282
+ }
283
+ record = { ...record, supersedes: uniqueStrings([...record.supersedes.filter((s) => acceptedSupersedes.includes(s)), ...acceptedSupersedes]) };
284
+ await this.store.writeRecord(shard, record);
285
+ await this.store.audit({
286
+ ts: now,
287
+ kind: "write",
288
+ harness: this.config.harness,
289
+ session: this.session.id,
290
+ action: decision.action === "link" ? "LINK" : decision.action === "supersede" ? "SUPERSEDE" : "NEW",
291
+ id: record.id,
292
+ superseded,
293
+ links: validLinks,
294
+ });
295
+ this.session.contextDirty = true;
296
+ this.session.pendingReason = 'memory_write';
297
+ this.index.invalidate();
298
+ return {
299
+ id: record.id,
300
+ action: decision.action === "link" ? "LINK" : decision.action === "supersede" ? "SUPERSEDE" : "NEW",
301
+ superseded,
302
+ warnings,
303
+ record,
304
+ };
305
+ }
306
+ // ------------------------------------------------------------------ import
307
+ async importRecords(records, opts = {}) {
308
+ const result = { ids: [], skipped: 0, superseded: [], warnings: [] };
309
+ if (this.config.readOnly)
310
+ throw new Error("HarnMem is read-only");
311
+ const now = this.now();
312
+ const byShard = new Map();
313
+ for (const input of records) {
314
+ if (!input || !input.title) {
315
+ result.skipped++;
316
+ result.warnings.push("record missing title");
317
+ continue;
318
+ }
319
+ const scope = input.scope ?? (input.type === "user" ? "user" : "project");
320
+ const projectId = scope === "project" ? (input.project ?? this.project.id) : undefined;
321
+ const shard = this.shardFor(scope, projectId);
322
+ const created = parseTimestamp(input.created, now);
323
+ const record = {
324
+ schema: 1,
325
+ id: input.id ?? newMemoryId(created),
326
+ type: input.type ?? "project",
327
+ category: input.category,
328
+ scope,
329
+ project: projectId,
330
+ projectName: input.projectName ?? (scope === "project" ? this.project.name : undefined),
331
+ title: input.title,
332
+ description: input.description ?? "",
333
+ content: input.content ?? "",
334
+ tags: uniqueStrings(input.tags ?? []),
335
+ entities: uniqueStrings(input.entities ?? []),
336
+ importance: clampNumber(input.importance ?? defaultImportance(input.type), 0, 10),
337
+ confidence: clampNumber(input.confidence ?? 0.8, 0, 1),
338
+ pinned: input.pinned ?? false,
339
+ status: input.status ?? "active",
340
+ created,
341
+ updated: parseTimestamp(input.updated, created),
342
+ lastUsed: input.lastUsed,
343
+ usedCount: input.usedCount ?? 0,
344
+ validFrom: parseTimestamp(input.validFrom, created),
345
+ validTo: input.validTo === undefined || input.validTo === null ? null : input.validTo,
346
+ observedAt: input.observedAt ?? created,
347
+ evidence: input.evidence ?? 1,
348
+ source: {
349
+ harness: input.source?.harness ?? "import",
350
+ session: input.source?.session,
351
+ turn: input.source?.turn,
352
+ ts: parseTimestamp(input.source?.ts, now),
353
+ migratedFrom: input.source?.migratedFrom,
354
+ },
355
+ provenance: input.provenance ?? [`import:${this.config.harness}`],
356
+ links: uniqueStrings(input.links ?? []),
357
+ supersedes: uniqueStrings(input.supersedes ?? []),
358
+ supersededBy: input.supersededBy ?? null,
359
+ sensitive: input.sensitive,
360
+ extra: input.extra,
361
+ };
362
+ const entry = byShard.get(shardKey(shard)) ?? { shard, records: [] };
363
+ entry.records.push(record);
364
+ byShard.set(shardKey(shard), entry);
365
+ result.ids.push(record.id);
366
+ if (record.status === "superseded" || record.supersededBy)
367
+ result.superseded.push(record.id);
368
+ }
369
+ if (opts.dedupe) {
370
+ // Dedupe mode: replay through write() one by one (slow; opt-in only).
371
+ result.ids = [];
372
+ result.superseded = [];
373
+ for (const entry of byShard.values()) {
374
+ for (const record of entry.records) {
375
+ const written = await this.write({
376
+ type: record.type,
377
+ category: record.category,
378
+ scope: record.scope,
379
+ project: record.project,
380
+ title: record.title,
381
+ description: record.description,
382
+ content: record.content,
383
+ tags: record.tags,
384
+ entities: record.entities,
385
+ importance: record.importance,
386
+ confidence: record.confidence,
387
+ pinned: record.pinned,
388
+ sensitive: record.sensitive,
389
+ source: record.source,
390
+ });
391
+ result.ids.push(written.id);
392
+ result.superseded.push(...written.superseded);
393
+ }
394
+ }
395
+ return result;
396
+ }
397
+ if (!opts.dryRun) {
398
+ for (const entry of byShard.values())
399
+ await this.store.writeRecordsBulk(entry.shard, entry.records);
400
+ // Lazy: per-shard indexes are rebuilt on first query via seq validation.
401
+ this.index.invalidate();
402
+ this.session.contextDirty = true;
403
+ this.session.pendingReason = "memory_write";
404
+ await this.store.audit({ ts: now, kind: "import", count: result.ids.length, shards: [...byShard.keys()] });
405
+ }
406
+ return result;
407
+ }
408
+ // ------------------------------------------------------------------ forget
409
+ async forget(id, opts = {}) {
410
+ const found = await this.store.findRecord(id);
411
+ if (!found)
412
+ return { id, ok: false, mode: opts.hard ? "hard" : "trash" };
413
+ if (opts.hard) {
414
+ await fs.unlink(this.store.recordPath(found.shard, id));
415
+ await this.store.bumpSeq();
416
+ await this.store.audit({ ts: this.now(), kind: "forget", id, mode: "hard" });
417
+ }
418
+ else {
419
+ await this.store.moveToTrash(found.shard, found.record, this.now());
420
+ await this.store.audit({ ts: this.now(), kind: "forget", id, mode: "trash" });
421
+ }
422
+ this.session.contextDirty = true;
423
+ this.session.pendingReason = 'manual';
424
+ this.index.invalidate();
425
+ return { id, ok: true, mode: opts.hard ? "hard" : "trash" };
426
+ }
427
+ async restore(id) {
428
+ let files = [];
429
+ try {
430
+ files = await fs.readdir(this.store.trashDir);
431
+ }
432
+ catch {
433
+ return { id, ok: false };
434
+ }
435
+ const match = files.find((file) => file.endsWith(`-${id}.md`));
436
+ if (!match)
437
+ return { id, ok: false };
438
+ const trashFile = join(this.store.trashDir, match);
439
+ const raw = await readTextIfExists(trashFile);
440
+ if (!raw)
441
+ return { id, ok: false };
442
+ const { parseFrontmatter, recordFromFrontmatter } = await import("./frontmatter.js");
443
+ const parsed = parseFrontmatter(raw);
444
+ const record = recordFromFrontmatter(parsed.data, parsed.body, id, this.now());
445
+ record.status = "active";
446
+ record.updated = this.now();
447
+ const shard = this.shardFor(record.scope, record.project ?? undefined);
448
+ await this.store.writeRecord(shard, record);
449
+ await fs.unlink(trashFile);
450
+ await this.store.audit({ ts: this.now(), kind: "restore", id });
451
+ this.session.contextDirty = true;
452
+ this.session.pendingReason = 'manual';
453
+ this.index.invalidate();
454
+ return { id, ok: true };
455
+ }
456
+ // ---------------------------------------------------------------- read side
457
+ async search(opts) {
458
+ const result = await this.retriever.search(opts);
459
+ await this.autoFlushUsage();
460
+ return result;
461
+ }
462
+ async read(id, opts = {}) {
463
+ return this.retriever.read(id, opts);
464
+ }
465
+ async consolidate(opts = {}) {
466
+ const report = await this.consolidator.run(opts);
467
+ this.session.contextDirty = true;
468
+ this.session.pendingReason = 'manual';
469
+ return report;
470
+ }
471
+ async rebuildIndex() {
472
+ await this.index.rebuildAll();
473
+ }
474
+ async stats() {
475
+ const shards = await this.store.listShards();
476
+ const shardStats = [];
477
+ let totalBytes = 0;
478
+ let duplicateCandidates = 0;
479
+ let orphanTotal = 0;
480
+ let userActive = 0;
481
+ let projectActive = 0;
482
+ let indexStale = false;
483
+ for (const shard of shards) {
484
+ const { records } = await this.store.readShard(shard);
485
+ let bytes = 0;
486
+ for (const record of records)
487
+ bytes += Buffer.byteLength(record.content, "utf8");
488
+ totalBytes += bytes;
489
+ const ids = new Set(records.map((r) => r.id));
490
+ let orphans = 0;
491
+ for (const record of records)
492
+ for (const link of record.links)
493
+ if (!ids.has(link))
494
+ orphans++;
495
+ orphanTotal += orphans;
496
+ const active = records.filter((r) => r.status === "active").length;
497
+ if (shard.scope === "user")
498
+ userActive += active;
499
+ else
500
+ projectActive += active;
501
+ const shardSeq = await this.store.getShardSeq(shard.name);
502
+ const stored = (await this.store.loadIndex(shard.name));
503
+ if (!stored || stored.seq !== shardSeq)
504
+ indexStale = true;
505
+ duplicateCandidates += countDuplicateCandidates(records);
506
+ shardStats.push({
507
+ name: shard.name,
508
+ scope: shard.scope,
509
+ total: records.length,
510
+ active,
511
+ superseded: records.filter((r) => r.status === "superseded").length,
512
+ archived: records.filter((r) => r.status === "archived").length,
513
+ pinned: records.filter((r) => r.pinned).length,
514
+ bytes,
515
+ });
516
+ }
517
+ const seq = await this.store.peekSeq();
518
+ const manifest = await this.store.readManifest();
519
+ let indexBuiltAt = 0;
520
+ try {
521
+ for (const file of await fs.readdir(this.store.indexPath)) {
522
+ if (!file.endsWith(".json") || file === "manifest.json")
523
+ continue;
524
+ const stat = await fs.stat(join(this.store.indexPath, file));
525
+ if (stat.mtimeMs > indexBuiltAt)
526
+ indexBuiltAt = stat.mtimeMs;
527
+ }
528
+ }
529
+ catch {
530
+ indexBuiltAt = 0;
531
+ }
532
+ const governance = this.config.governance;
533
+ const overBudget = userActive > (governance?.maxUserRecords ?? 500) ||
534
+ projectActive > (governance?.maxProjectRecords ?? 3000);
535
+ let indexSize = 0;
536
+ try {
537
+ for (const file of await fs.readdir(this.store.indexPath)) {
538
+ const stat = await fs.stat(join(this.store.indexPath, file));
539
+ indexSize += stat.size;
540
+ }
541
+ }
542
+ catch {
543
+ indexSize = 0;
544
+ }
545
+ const lastConsolidate = await this.lastAuditTs("consolidate");
546
+ return {
547
+ version: 1,
548
+ home: this.store.home,
549
+ project: this.project.id,
550
+ shards: shardStats,
551
+ index: {
552
+ seq,
553
+ builtAt: indexBuiltAt || (manifest?.builtAt ?? 0),
554
+ stale: indexStale,
555
+ sizeBytes: indexSize,
556
+ overhead: totalBytes === 0 ? 0 : indexSize / totalBytes,
557
+ },
558
+ health: {
559
+ overBudget,
560
+ duplicateCandidates,
561
+ orphans: orphanTotal,
562
+ lastConsolidate,
563
+ },
564
+ extraction: {
565
+ pending: 0,
566
+ failed: this.session.extractFailures,
567
+ skipReasons: { ...this.session.extractSkips },
568
+ },
569
+ };
570
+ }
571
+ async lastAuditTs(kind) {
572
+ const file = join(this.store.stateDir, "audit.jsonl");
573
+ const raw = await readTextIfExists(file);
574
+ if (!raw)
575
+ return null;
576
+ let last = null;
577
+ for (const line of raw.split(/\r?\n/)) {
578
+ if (!line.includes(`"kind":"${kind}"`))
579
+ continue;
580
+ try {
581
+ const parsed = JSON.parse(line);
582
+ if (typeof parsed.ts === "number")
583
+ last = parsed.ts;
584
+ }
585
+ catch {
586
+ // ignore malformed lines
587
+ }
588
+ }
589
+ return last;
590
+ }
591
+ /**
592
+ * How many messages the newest completed (`status: "done"`) extraction for
593
+ * this session already covered; `null` when none ever completed.
594
+ *
595
+ * This is the durable counterpart of the in-memory `extracted` flag: a
596
+ * restarted process (or a resume catch-up) must not call the model again for
597
+ * a journal prefix that was already extracted, but must still process turns
598
+ * that happened after the last extraction.
599
+ */
600
+ async extractedMessageCount(sessionId) {
601
+ const file = join(this.store.stateDir, "audit.jsonl");
602
+ const raw = await readTextIfExists(file);
603
+ if (!raw)
604
+ return null;
605
+ let covered = null;
606
+ for (const line of raw.split(/\r?\n/)) {
607
+ if (!line)
608
+ continue;
609
+ try {
610
+ const parsed = JSON.parse(line);
611
+ if (parsed.kind !== "extract" || parsed.session !== sessionId)
612
+ continue;
613
+ if (parsed.status !== "done" || typeof parsed.messages !== "number")
614
+ continue;
615
+ covered = covered === null ? parsed.messages : Math.max(covered, parsed.messages);
616
+ }
617
+ catch {
618
+ // ignore malformed lines
619
+ }
620
+ }
621
+ return covered;
622
+ }
623
+ /**
624
+ * Persisted session journal for one session id, oldest first (bounded to the
625
+ * last 200 messages). Adapters use it to rehydrate a process-local transcript
626
+ * after a restart: DSH resumes a session in a new process without replaying
627
+ * earlier events, so the digest would otherwise never reach the extraction
628
+ * gates (N2 quit path).
629
+ */
630
+ async readSessionJournal(sessionId = this.session.id) {
631
+ return this.store.readSessionTranscript(this.config.harness, sessionId);
632
+ }
633
+ // ------------------------------------------------------------------ extract
634
+ /**
635
+ * Public extraction entry point. Every attempt — including every skip — is
636
+ * persisted to `state/audit.jsonl` as `kind: "extract"` (ROADMAP N13), so a
637
+ * silent no-op is impossible to miss when debugging an adapter.
638
+ */
639
+ async sessionEnd(input) {
640
+ let result;
641
+ try {
642
+ result = await this.sessionEndRecorded(input);
643
+ }
644
+ catch (error) {
645
+ this.session.extractFailures++;
646
+ result = { status: "failed", added: [], superseded: [], skipped: 0, error: error.message };
647
+ }
648
+ await this.auditExtract(input, result);
649
+ return result;
650
+ }
651
+ async sessionEndRecorded(input) {
652
+ try {
653
+ const messages = (input.messages ?? []).filter((message) => message.text && message.text.trim());
654
+ if (messages.length > 0) {
655
+ await this.store.appendSession({
656
+ harness: this.config.harness,
657
+ sessionId: input.sessionId ?? this.session.id,
658
+ cwd: input.cwd ?? this.config.cwd,
659
+ messages: messages.slice(-200),
660
+ });
661
+ }
662
+ await this.autoFlushUsage();
663
+ const write = this.config.write;
664
+ if (this.config.readOnly)
665
+ return skip("read_only");
666
+ if (!write?.autoExtract)
667
+ return skip("auto_extract_off");
668
+ if (input.skipExtract)
669
+ return skip("requested");
670
+ if (this.session.extracted)
671
+ return skip("already_extracted");
672
+ // Cross-process idempotency: the in-memory flag dies with the process, so
673
+ // the audit trail is the durable record. Skip only when this attempt would
674
+ // cover no new messages — a session that continued after its last
675
+ // extraction still gets the newer turns (N2 resume catch-up).
676
+ const sessionId = input.sessionId ?? this.session.id;
677
+ const completedThrough = await this.extractedMessageCount(sessionId);
678
+ if (completedThrough !== null && messages.length <= completedThrough)
679
+ return skip("already_extracted");
680
+ if (!this.config.llm)
681
+ return { status: "no_llm", added: [], superseded: [], skipped: 0 };
682
+ if (this.session.modelWrites >= (write.skipExtractIfModelWrote ?? 1))
683
+ return skip("model_wrote");
684
+ const realUserMessages = messages.filter((message) => message.role === "user" && message.text.trim().split(/\s+/).length >= 1);
685
+ if (realUserMessages.length < (write.minUserMessages ?? 3))
686
+ return skip("too_few_messages");
687
+ // A digest below the extraction floor is a normal no-op, not a silent
688
+ // `done/added:0`: name the reason so the audit line is self-explanatory.
689
+ if (buildDigest(messages).length < (write.minDigestChars ?? 500))
690
+ return skip("digest_too_small");
691
+ const existing = await this.existingRefs();
692
+ const extracted = await withTimeout(runExtraction(this.config.llm, this.config, {
693
+ messages,
694
+ existing,
695
+ projectName: this.project.name,
696
+ date: this.session.date,
697
+ }), write.llmTimeoutMs ?? 12000, "extraction");
698
+ const added = [];
699
+ const superseded = [];
700
+ let skipped = 0;
701
+ for (const memory of extracted) {
702
+ if ((memory.confidence ?? 0.7) < (write.minConfidence ?? 0)) {
703
+ skipped++;
704
+ continue;
705
+ }
706
+ const scope = memory.scope === "user" || memory.type === "user" || memory.type === "feedback" ? (memory.scope ?? (memory.type === "user" ? "user" : "project")) : "project";
707
+ try {
708
+ const result = await this.write({
709
+ type: memory.type,
710
+ scope,
711
+ title: memory.title,
712
+ description: memory.description,
713
+ content: memory.content,
714
+ category: memory.category,
715
+ tags: memory.tags,
716
+ entities: memory.entities,
717
+ importance: memory.importance,
718
+ confidence: memory.confidence,
719
+ links: memory.links,
720
+ supersedes: memory.supersedes,
721
+ evidence: memory.evidence,
722
+ source: { harness: this.config.harness, session: input.sessionId ?? this.session.id, ts: this.now() },
723
+ });
724
+ if (result.action === "SKIP")
725
+ skipped++;
726
+ else
727
+ added.push(result.id);
728
+ superseded.push(...result.superseded);
729
+ }
730
+ catch {
731
+ skipped++;
732
+ }
733
+ }
734
+ this.session.extracted = true;
735
+ this.session.contextDirty = true;
736
+ this.session.pendingReason = 'memory_write';
737
+ const status = added.length > 0 ? "done" : "done";
738
+ return { status, added, superseded, skipped };
739
+ }
740
+ catch (error) {
741
+ this.session.extractFailures++;
742
+ return {
743
+ status: "failed",
744
+ added: [],
745
+ superseded: [],
746
+ skipped: 0,
747
+ error: error.message,
748
+ };
749
+ }
750
+ function skip(reason) {
751
+ return { status: "skipped", added: [], superseded: [], skipped: 0, skipReason: reason };
752
+ }
753
+ }
754
+ /**
755
+ * Persist one `extract` audit line per session-end attempt. Audit writes are
756
+ * advisory: a failure here must never surface as an extraction failure.
757
+ */
758
+ async auditExtract(input, result) {
759
+ const messages = (input.messages ?? []).filter((message) => message.text && message.text.trim());
760
+ try {
761
+ await this.store.audit({
762
+ ts: this.now(),
763
+ kind: "extract",
764
+ harness: this.config.harness,
765
+ session: input.sessionId ?? this.session.id,
766
+ status: result.status,
767
+ ...(result.skipReason ? { reason: result.skipReason } : {}),
768
+ ...(result.error ? { error: result.error } : {}),
769
+ added: result.added.length,
770
+ superseded: result.superseded.length,
771
+ skipped: result.skipped,
772
+ messages: messages.length,
773
+ userMessages: messages.filter((message) => message.role === "user").length,
774
+ });
775
+ }
776
+ catch {
777
+ // audit is advisory
778
+ }
779
+ }
780
+ async existingRefs() {
781
+ const shard = this.shardFor("project", this.project.id);
782
+ const { records } = await this.store.readShard(shard);
783
+ return records
784
+ .filter((record) => record.status === "active")
785
+ .slice(0, 60)
786
+ .map((record) => ({ id: record.id, title: record.title, type: record.type, description: record.description }));
787
+ }
788
+ // ------------------------------------------------------------------- close
789
+ async close() {
790
+ await this.autoFlushUsage();
791
+ await this.retriever.pendingUsageUpdates().clear();
792
+ }
793
+ async autoFlushUsage() {
794
+ const updates = this.retriever.pendingUsageUpdates();
795
+ if (updates.size === 0)
796
+ return;
797
+ try {
798
+ await this.store.saveUsage(updates);
799
+ }
800
+ catch {
801
+ // usage counters are advisory
802
+ }
803
+ finally {
804
+ this.retriever.resetUsage();
805
+ }
806
+ }
807
+ // ------------------------------------------------------------------ helpers
808
+ shardFor(scope, projectId) {
809
+ return scope === "user" ? { scope: "user", name: "user" } : { scope: "project", project: projectId ?? this.project.id, name: projectId ?? this.project.id };
810
+ }
811
+ async resolveProjectId(query) {
812
+ if (query === this.project.id)
813
+ return query;
814
+ const known = await findProject(this.store.home, query);
815
+ return known?.id ?? query;
816
+ }
817
+ loadSynonyms() {
818
+ if (this.synonyms)
819
+ return this.synonyms;
820
+ const map = new Map();
821
+ const addGroup = (group) => {
822
+ const cleaned = uniqueStrings(group);
823
+ if (cleaned.length < 2)
824
+ return;
825
+ for (const item of cleaned) {
826
+ const key = item.toLowerCase();
827
+ const list = map.get(key) ?? [];
828
+ for (const other of cleaned)
829
+ if (other.toLowerCase() !== key)
830
+ list.push(other);
831
+ map.set(key, uniqueStrings(list));
832
+ }
833
+ };
834
+ for (const group of DEFAULT_SYNONYMS)
835
+ addGroup(group);
836
+ try {
837
+ // Every data/synonyms.<lang>.txt discovered: the <home> copy of a filename
838
+ // overrides the shipped default of the same name, and all languages merge.
839
+ for (const group of loadSynonymGroups(this.store.home).groups)
840
+ addGroup(group);
841
+ }
842
+ catch {
843
+ // optional data files
844
+ }
845
+ this.synonyms = map;
846
+ return map;
847
+ }
848
+ }
849
+ function resolveScope(input, currentProject) {
850
+ void currentProject;
851
+ if (input.scope === "user" || input.scope === "project")
852
+ return input.scope;
853
+ return input.type === "user" ? "user" : "project";
854
+ }
855
+ const SENSITIVE_PATTERNS = [
856
+ /(api[_-]?key|secret|password|passwd|token|credential)\s*[:=]\s*\S{8,}/i,
857
+ /(身份证|护照|银行卡|信用卡)\D{0,6}\d{6,}/,
858
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
859
+ ];
860
+ function looksSensitive(text) {
861
+ return SENSITIVE_PATTERNS.some((pattern) => pattern.test(text));
862
+ }
863
+ async function resolveExplicitProject(home, query, cwd) {
864
+ const known = await findProject(home, query);
865
+ if (known) {
866
+ return { id: known.id, slug: known.slug, name: known.name, key: `explicit:${known.id}`, root: known.paths[0] ?? cwd };
867
+ }
868
+ return { id: query, slug: query, name: query, key: `explicit:${query}`, root: cwd };
869
+ }
870
+ function defaultImportance(type) {
871
+ switch (type) {
872
+ case "user":
873
+ return 8;
874
+ case "feedback":
875
+ return 7;
876
+ case "reference":
877
+ return 5;
878
+ default:
879
+ return 6;
880
+ }
881
+ }
882
+ function defaultConfidence(type) {
883
+ return type === "user" ? 0.9 : 0.75;
884
+ }
885
+ function isConstraintType(type) {
886
+ return type === "user";
887
+ }
888
+ function clampNumber(value, min, max) {
889
+ if (!Number.isFinite(value))
890
+ return min;
891
+ return Math.min(max, Math.max(min, value));
892
+ }
893
+ function uniqueStrings(values) {
894
+ return [...new Set(values.map((v) => v.trim()).filter(Boolean))];
895
+ }
896
+ function parseObservedAt(content) {
897
+ const match = /(?:Observed|观察日期|日期)[::]\s*(\d{4}-\d{2}-\d{2})/.exec(content);
898
+ if (!match)
899
+ return null;
900
+ const ts = Date.parse(`${match[1]}T12:00:00Z`);
901
+ return Number.isFinite(ts) ? ts : null;
902
+ }
903
+ function countDuplicateCandidates(records) {
904
+ let count = 0;
905
+ const active = records.filter((record) => record.status === "active");
906
+ for (let i = 0; i < active.length && i < 200; i++) {
907
+ for (let j = i + 1; j < active.length && j < i + 20; j++) {
908
+ const a = active[i];
909
+ const b = active[j];
910
+ if (!a.description || !b.description)
911
+ continue;
912
+ if (a.title === b.title || a.description === b.description)
913
+ count++;
914
+ }
915
+ }
916
+ return count;
917
+ }
918
+ //# sourceMappingURL=harnmem.js.map