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.
- package/LICENSE +21 -0
- package/README.md +45 -2
- package/index.js +512 -0
- package/lib/engine.mjs +72 -0
- package/lib/format.mjs +119 -0
- package/lib/settings.mjs +131 -0
- package/package.json +23 -3
- package/vendor/core/assets.d.ts +52 -0
- package/vendor/core/assets.d.ts.map +1 -0
- package/vendor/core/assets.js +227 -0
- package/vendor/core/assets.js.map +1 -0
- package/vendor/core/config.d.ts +36 -0
- package/vendor/core/config.d.ts.map +1 -0
- package/vendor/core/config.js +134 -0
- package/vendor/core/config.js.map +1 -0
- package/vendor/core/consolidate.d.ts +14 -0
- package/vendor/core/consolidate.d.ts.map +1 -0
- package/vendor/core/consolidate.js +237 -0
- package/vendor/core/consolidate.js.map +1 -0
- package/vendor/core/data/synonyms.en.txt +61 -0
- package/vendor/core/data/synonyms.zh.txt +157 -0
- package/vendor/core/extract.d.ts +49 -0
- package/vendor/core/extract.d.ts.map +1 -0
- package/vendor/core/extract.js +179 -0
- package/vendor/core/extract.js.map +1 -0
- package/vendor/core/frontmatter.d.ts +31 -0
- package/vendor/core/frontmatter.d.ts.map +1 -0
- package/vendor/core/frontmatter.js +372 -0
- package/vendor/core/frontmatter.js.map +1 -0
- package/vendor/core/harnmem.d.ts +87 -0
- package/vendor/core/harnmem.d.ts.map +1 -0
- package/vendor/core/harnmem.js +918 -0
- package/vendor/core/harnmem.js.map +1 -0
- package/vendor/core/hot-context.d.ts +28 -0
- package/vendor/core/hot-context.d.ts.map +1 -0
- package/vendor/core/hot-context.js +179 -0
- package/vendor/core/hot-context.js.map +1 -0
- package/vendor/core/index.d.ts +16 -0
- package/vendor/core/index.d.ts.map +1 -0
- package/vendor/core/index.js +10 -0
- package/vendor/core/index.js.map +1 -0
- package/vendor/core/indexer.d.ts +107 -0
- package/vendor/core/indexer.d.ts.map +1 -0
- package/vendor/core/indexer.js +282 -0
- package/vendor/core/indexer.js.map +1 -0
- package/vendor/core/inspect.d.ts +278 -0
- package/vendor/core/inspect.d.ts.map +1 -0
- package/vendor/core/inspect.js +643 -0
- package/vendor/core/inspect.js.map +1 -0
- package/vendor/core/lifecycle.d.ts +50 -0
- package/vendor/core/lifecycle.d.ts.map +1 -0
- package/vendor/core/lifecycle.js +249 -0
- package/vendor/core/lifecycle.js.map +1 -0
- package/vendor/core/project.d.ts +41 -0
- package/vendor/core/project.d.ts.map +1 -0
- package/vendor/core/project.js +197 -0
- package/vendor/core/project.js.map +1 -0
- package/vendor/core/prompts/README.md +96 -0
- package/vendor/core/prompts/consolidate-digest.md +31 -0
- package/vendor/core/prompts/extract.md +80 -0
- package/vendor/core/prompts/migrate.md +96 -0
- package/vendor/core/prompts/synonyms.md +43 -0
- package/vendor/core/prompts/write-policy.md +12 -0
- package/vendor/core/retrieval.d.ts +95 -0
- package/vendor/core/retrieval.d.ts.map +1 -0
- package/vendor/core/retrieval.js +569 -0
- package/vendor/core/retrieval.js.map +1 -0
- package/vendor/core/store.d.ts +145 -0
- package/vendor/core/store.d.ts.map +1 -0
- package/vendor/core/store.js +511 -0
- package/vendor/core/store.js.map +1 -0
- package/vendor/core/suggest.d.ts +128 -0
- package/vendor/core/suggest.d.ts.map +1 -0
- package/vendor/core/suggest.js +384 -0
- package/vendor/core/suggest.js.map +1 -0
- package/vendor/core/text.d.ts +80 -0
- package/vendor/core/text.d.ts.map +1 -0
- package/vendor/core/text.js +253 -0
- package/vendor/core/text.js.map +1 -0
- package/vendor/core/types.d.ts +453 -0
- package/vendor/core/types.d.ts.map +1 -0
- package/vendor/core/types.js +14 -0
- package/vendor/core/types.js.map +1 -0
- package/vendor/core/util.d.ts +38 -0
- package/vendor/core/util.d.ts.map +1 -0
- package/vendor/core/util.js +248 -0
- 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
|