@lmzhen/dsh-evolution-core 0.26.26 → 0.27.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +16 -1
  2. package/lib/composition/index.js +3 -0
  3. package/lib/composition-D8Y3-ROd.js +604 -0
  4. package/lib/ensure-MEYMj9jn.js +222 -0
  5. package/lib/errors-Br2-uUSK.js +57 -0
  6. package/lib/http-routes-BP-S3KV-.js +149 -0
  7. package/lib/http-routes.js +2 -0
  8. package/lib/index.js +66 -850
  9. package/lib/restrictor.js +37 -0
  10. package/lib/subagent/index.js +186 -0
  11. package/lib/types/citations.js +192 -0
  12. package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
  13. package/lib/types/composition/composer.js +681 -0
  14. package/lib/types/composition/ensure.d.ts +10 -0
  15. package/lib/types/composition/ensure.js +95 -0
  16. package/lib/types/composition/index.d.ts +5 -0
  17. package/lib/types/composition/index.js +7 -0
  18. package/lib/types/composition/preset-ids.d.ts +17 -0
  19. package/lib/types/composition/preset-ids.js +21 -0
  20. package/lib/types/composition/restricted-preset.d.ts +36 -0
  21. package/lib/types/composition/restricted-preset.js +42 -0
  22. package/lib/types/constants.js +223 -0
  23. package/lib/types/cost.js +67 -0
  24. package/lib/types/curator.js +346 -0
  25. package/lib/types/diagnostics.d.ts +26 -0
  26. package/lib/types/diagnostics.js +23 -0
  27. package/lib/types/document-body.js +91 -0
  28. package/lib/types/drift-signals.js +319 -0
  29. package/lib/types/env.js +25 -0
  30. package/lib/types/errors.d.ts +4 -1
  31. package/lib/types/errors.js +59 -0
  32. package/lib/types/events.js +16 -0
  33. package/lib/types/evidence.js +65 -0
  34. package/lib/types/evolution-events.js +479 -0
  35. package/lib/types/fold-untrusted.js +44 -0
  36. package/lib/types/frontmatter.js +623 -0
  37. package/lib/types/fuzzy-match.js +135 -0
  38. package/lib/types/gates.js +51 -0
  39. package/lib/types/guards.js +27 -0
  40. package/lib/types/health-baseline.js +63 -0
  41. package/lib/types/http-routes.d.ts +67 -35
  42. package/lib/types/http-routes.js +135 -0
  43. package/lib/types/index.d.ts +7 -2
  44. package/lib/types/index.js +101 -0
  45. package/lib/types/instance-scope.js +69 -0
  46. package/lib/types/io.js +675 -0
  47. package/lib/types/learn-prompt.js +42 -0
  48. package/lib/types/limits.js +63 -0
  49. package/lib/types/live.js +107 -0
  50. package/lib/types/long-runs.js +96 -0
  51. package/lib/types/memory-store.d.ts +0 -16
  52. package/lib/types/memory-store.js +606 -0
  53. package/lib/types/mutations.js +95 -0
  54. package/lib/types/namespace-migration.js +156 -0
  55. package/lib/types/numeric.js +47 -0
  56. package/lib/types/opt-in.js +101 -0
  57. package/lib/types/package-identity.js +47 -0
  58. package/lib/types/params.js +328 -0
  59. package/lib/types/payload.js +36 -0
  60. package/lib/types/platform-line.generated.d.ts +10 -0
  61. package/lib/types/platform-line.generated.js +12 -0
  62. package/lib/types/platform-services.d.ts +2 -2
  63. package/lib/types/platform-services.js +52 -0
  64. package/lib/types/probe.js +122 -0
  65. package/lib/types/prompts.js +409 -0
  66. package/lib/types/quality.js +308 -0
  67. package/lib/types/reachability.js +122 -0
  68. package/lib/types/redact.js +257 -0
  69. package/lib/types/reference-rewrite.js +144 -0
  70. package/lib/types/reports.js +110 -0
  71. package/lib/types/restrictor.d.ts +11 -0
  72. package/lib/types/restrictor.js +48 -0
  73. package/lib/types/review-channel.js +59 -0
  74. package/lib/types/run-registry.js +345 -0
  75. package/lib/types/scope.js +7 -0
  76. package/lib/types/serial.js +17 -0
  77. package/lib/types/session-fold.d.ts +15 -0
  78. package/lib/types/session-fold.js +28 -0
  79. package/lib/types/session-projection.js +295 -0
  80. package/lib/types/signals.js +209 -0
  81. package/lib/types/skill-discovery.js +66 -0
  82. package/lib/types/skill-health.js +134 -0
  83. package/lib/types/skill-history.js +745 -0
  84. package/lib/types/skill-lint.js +112 -0
  85. package/lib/types/skill-reads.js +67 -0
  86. package/lib/types/skill-row-cells.js +123 -0
  87. package/lib/types/skill-store.js +3528 -0
  88. package/lib/types/state-store.js +60 -0
  89. package/lib/types/store-ledger.js +117 -0
  90. package/lib/types/subagent/channel.d.ts +4 -0
  91. package/lib/types/subagent/channel.js +131 -0
  92. package/lib/types/subagent/index.d.ts +4 -0
  93. package/lib/types/subagent/index.js +5 -0
  94. package/lib/types/subagent/readiness.d.ts +6 -0
  95. package/lib/types/subagent/readiness.js +35 -0
  96. package/lib/types/subagent/types.d.ts +58 -0
  97. package/lib/types/subagent/types.js +2 -0
  98. package/lib/types/threats.js +338 -0
  99. package/lib/types/tool-dispatch.d.ts +5 -0
  100. package/lib/types/tool-dispatch.js +535 -0
  101. package/lib/types/tree-write.js +40 -0
  102. package/lib/types/unit-members.js +121 -0
  103. package/lib/types/usage.js +474 -0
  104. package/lib/types/write-authority.js +23 -0
  105. package/lib/types/write-gates.js +344 -0
  106. package/lib/types/write-inventory.js +158 -0
  107. package/package.json +39 -11
@@ -0,0 +1,606 @@
1
+ /**
2
+ * File-backed durable memory with Hermes-compatible semantics.
3
+ * Stores are MEMORY.md and USER.md under $DSH_HOME/memories (~/.dsh/memories).
4
+ */
5
+ import { basename, join } from 'node:path';
6
+ import { nodeEvolutionIo, transactIo } from "./io.js";
7
+ import { evolutionRoot } from "./state-store.js";
8
+ import { makeSerialQueue } from "./serial.js";
9
+ import { payloadText } from "./payload.js";
10
+ import { scanMemoryThreats } from "./threats.js";
11
+ import { ENTRY_DELIMITER, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_USER_CHAR_LIMIT, DEFAULT_CONSOLIDATION_FAILURES } from "./constants.js";
12
+ export { ENTRY_DELIMITER } from "./constants.js";
13
+ /**
14
+ * Read-guard factor: a memory file larger than this multiple of its target's
15
+ * char limit is treated as externally corrupted and skipped instead of being
16
+ * read whole (aligned with claw `tools/memory.ts` size guard, which uses the
17
+ * same 10× bound around a file that should never exceed the store limit).
18
+ */
19
+ const READ_GUARD_FACTOR = 10;
20
+ /**
21
+ * Consolidation-failure backoff window (package-private, rc.42 audit P2-1):
22
+ * only failures inside the window count toward `maxConsolidationFailures`.
23
+ * The store cannot observe turn boundaries, so the model-facing "this turn"
24
+ * phrasing is approximated with ten minutes — generous enough to cover one
25
+ * turn's retry loop, short enough that a failure yesterday never makes today's
26
+ * first refusal say "stop retrying".
27
+ */
28
+ const FAILURE_WINDOW_MS = 10 * 60_000;
29
+ /**
30
+ * Recoverable-error preview bounds (B-line G5, Hermes `_previews` parity):
31
+ * failed replace/remove/batch calls echo the current entries so the model can
32
+ * self-recover without re-reading the store. Bounded to five entries of eighty
33
+ * characters each; package-private because it is an error-message shape, not a
34
+ * behavior switch.
35
+ */
36
+ const ERROR_PREVIEW_ENTRIES = 5;
37
+ const ERROR_PREVIEW_WIDTH = 80;
38
+ function previewEntries(entries) {
39
+ if (entries.length === 0)
40
+ return '';
41
+ const shown = entries.slice(0, ERROR_PREVIEW_ENTRIES).map((entry) => {
42
+ const text = entry.length > ERROR_PREVIEW_WIDTH ? `${entry.slice(0, ERROR_PREVIEW_WIDTH)}…` : entry;
43
+ return `- ${text}`;
44
+ });
45
+ const more = entries.length > ERROR_PREVIEW_ENTRIES ? `\n (+${entries.length - ERROR_PREVIEW_ENTRIES} more)` : '';
46
+ return `\n\nCurrent entries (preview):\n${shown.join('\n')}${more}`;
47
+ }
48
+ export function memoryRoot(env = process.env) {
49
+ // V9-05 (0.3.51): single resolver — evolutionRoot() holds the ONLY
50
+ // DSH_HOME empty/whitespace fallback; the old bare `||` here resolved
51
+ // `DSH_HOME=" "` to a CWD-relative " /memories" sidecar.
52
+ return join(evolutionRoot(env), 'memories');
53
+ }
54
+ function fileFor(root, target) {
55
+ return join(root, target === 'memory' ? 'MEMORY.md' : 'USER.md');
56
+ }
57
+ function normalizeEntries(raw) {
58
+ return raw.split(ENTRY_DELIMITER).map(entry => entry.trim()).filter(Boolean);
59
+ }
60
+ function render(entries) {
61
+ return entries.join(ENTRY_DELIMITER) + '\n';
62
+ }
63
+ function stripDatePrefix(entry) {
64
+ return entry.replace(/^## \d{4}-\d{2}-\d{2}\n/, '');
65
+ }
66
+ /** F-201: does `content` carry the on-disk entry delimiter or a trailing
67
+ * `\n§` fragment that would combine with the render terminator into a real
68
+ * delimiter boundary? Both split the fact into multiple entries on read-back
69
+ * (and a delimiter-ending fact is permanent drift — `render(entries)!==raw`
70
+ * bricks every later write). A leading/plain `§` is safe and round-trips. */
71
+ function hasEntryDelimiter(content) {
72
+ return content.includes(ENTRY_DELIMITER) || content.endsWith('\n§');
73
+ }
74
+ export class MemoryStore {
75
+ memoryLimit;
76
+ userLimit;
77
+ addDatePrefix;
78
+ root;
79
+ maxFailures;
80
+ io;
81
+ /** V10-03 (P2-18): see MemoryStoreOptions.threatExemptLabels. */
82
+ threatExemptLabels;
83
+ /** V6-16 (0.3.37): same-process RMW serialization (the SkillLibrary queue) —
84
+ * on a backend WITHOUT a transact lock two concurrent callers compute on the
85
+ * same old content and the last rename wins, silently dropping one op's
86
+ * update. The node backend's cross-process lock already serializes; this
87
+ * chain covers the no-transact custom backends. */
88
+ serial = makeSerialQueue();
89
+ failureCount = 0;
90
+ lastFailureAt = 0;
91
+ constructor(options = {}) {
92
+ this.io = options.io ?? nodeEvolutionIo();
93
+ this.memoryLimit = options.memoryCharLimit ?? DEFAULT_MEMORY_CHAR_LIMIT;
94
+ this.userLimit = options.userCharLimit ?? DEFAULT_USER_CHAR_LIMIT;
95
+ this.addDatePrefix = options.addDatePrefix ?? false;
96
+ this.root = options.root ?? memoryRoot();
97
+ this.maxFailures = options.maxConsolidationFailures ?? DEFAULT_CONSOLIDATION_FAILURES;
98
+ this.threatExemptLabels = options.threatExemptLabels ?? [];
99
+ }
100
+ /** V10-03 (P2-18): ScanOptions shared by every threat check of this store —
101
+ * the constructor's exempt labels, empty by default (behavior unchanged). */
102
+ threatScanOptions() {
103
+ return this.threatExemptLabels.length > 0 ? { excludeLabels: this.threatExemptLabels } : {};
104
+ }
105
+ /** V10-03 (P2-18): the strict-scan write gate. A block message names the hit
106
+ * label (scanMemoryThreats already embeds it) plus the self-heal hint. */
107
+ memoryThreatBlock(text) {
108
+ // A2-16 (v18): scanMemoryThreats already appends its exemption hint;
109
+ // a store-side append duplicated the sentence (the old THREAT_EXEMPT_HINT
110
+ // export was removed in v21 — see threats.ts THREAT_EXEMPTION_HINT).
111
+ return scanMemoryThreats(text, undefined, this.threatScanOptions());
112
+ }
113
+ limitFor(target) {
114
+ return target === 'memory' ? this.memoryLimit : this.userLimit;
115
+ }
116
+ /** P2-1 (v18): the generated date prefix participates in duplicate detection
117
+ * only when THIS store writes it. With addDatePrefix=false a fact's own
118
+ * leading `## YYYY-MM-DD\n` is content, not a generated prefix. */
119
+ dedupeKey(entry) {
120
+ return this.addDatePrefix ? stripDatePrefix(entry) : entry;
121
+ }
122
+ /**
123
+ * Read-guard probe: `{ size, limit }` when the on-disk file exceeds
124
+ * `limit * READ_GUARD_FACTOR` bytes, `null` when it is absent, unknown
125
+ * (backend without a size probe), under the bound, or the target has no
126
+ * limit configured.
127
+ */
128
+ async oversizedFile(target) {
129
+ const size = await this.io.size?.(fileFor(this.root, target));
130
+ if (size === null || size === undefined)
131
+ return null;
132
+ const limit = this.limitFor(target);
133
+ if (limit <= 0)
134
+ return null;
135
+ return size > limit * READ_GUARD_FACTOR ? { size, limit } : null;
136
+ }
137
+ async read(target) {
138
+ // Oversized files are skipped whole (claw alignment); a later write to the
139
+ // same target is refused via `oversizedRefusal` instead of overwriting.
140
+ if (await this.oversizedFile(target))
141
+ return [];
142
+ const raw = await this.io.readText(fileFor(this.root, target));
143
+ return raw === null ? [] : [...new Set(normalizeEntries(raw))];
144
+ }
145
+ // 0.3.16 (S1.10, D-2): `write()` was a bare public write path bypassing
146
+ // transact/threat-scan/drift checks, with ZERO callers in the family —
147
+ // removed. Memory mutations go through `applyBatch` only.
148
+ resetFailures() {
149
+ this.failureCount = 0;
150
+ }
151
+ failure(target, message, entries) {
152
+ // Rolling-window decay (rc.42 audit P2-1): the backoff counter used to be
153
+ // process-lifetime, so three failures EVER — across turns and sessions —
154
+ // made every later failure say "stop retrying" even though the model had
155
+ // moved on. The store cannot see turn boundaries, so "this turn" is
156
+ // approximated with a window: failures older than it stop counting and
157
+ // the counter restarts from one.
158
+ if (Date.now() - this.lastFailureAt > FAILURE_WINDOW_MS)
159
+ this.failureCount = 0;
160
+ this.lastFailureAt = Date.now();
161
+ this.failureCount += 1;
162
+ const chars = entries.join(ENTRY_DELIMITER).length;
163
+ if (this.failureCount > this.maxFailures) {
164
+ return {
165
+ ok: false,
166
+ message: `本轮记忆整理已失败 ${this.failureCount} 次。请停止重试记忆调用,继续处理用户的任务。${previewEntries(entries)}`,
167
+ entries, chars, limit: this.limitFor(target),
168
+ };
169
+ }
170
+ return { ok: false, message: `${message}${previewEntries(entries)}`, entries, chars, limit: this.limitFor(target) };
171
+ }
172
+ /**
173
+ * StorageHint percentage must clamp at 100 like the render header: a drifted
174
+ * entry can push chars past the limit, and "Storage at 125%" contradicts the
175
+ * clamped usage indicator.
176
+ */
177
+ storageHint(target, chars) {
178
+ const limit = this.limitFor(target);
179
+ if (limit <= 0)
180
+ return '';
181
+ const percent = Math.min(100, Math.floor((chars * 100) / limit));
182
+ return percent >= 80 ? ` ⚠️ Storage at ${percent}% (${chars}/${limit} chars).` : '';
183
+ }
184
+ /**
185
+ * Best-effort raw-copy backup of the on-disk file to `<file>.bak` before a
186
+ * refusal, so an externally modified (or oversized) file stays recoverable.
187
+ * Copies bytes instead of reading them so a pathologically large file is
188
+ * never loaded just to back it up. Failure to back up does not change the
189
+ * refusal semantics. V8-23⑫ (0.3.49): ONE fixed backup name per target —
190
+ * a fresh refusal overwrites it (the previous timestamped names accumulated
191
+ * per drift incident with no retention policy). V9-08 (0.3.51) declares the
192
+ * two failure shapes: (1) the pre-copy remove of the previous `.bak` fails —
193
+ * harmless, because the copy contract is overwrite (`cp force`);
194
+ * (2) the copy itself fails (disk/backend) — returns `null` and the refusal
195
+ * message simply carries no backup suffix; the refusal semantics and the
196
+ * on-disk file are untouched either way.
197
+ */
198
+ async backupFile(target) {
199
+ const path = fileFor(this.root, target);
200
+ const backup = `${path}.bak`;
201
+ // P3-13 (v14): stage the copy under a transient name and only then replace
202
+ // the fixed `.bak`, so a FAILED copy leaves the previous backup intact —
203
+ // the old shape removed `.bak` first and lost the last good generation.
204
+ const staging = `${backup}.${process.pid}.${Date.now().toString(36)}.tmp`;
205
+ try {
206
+ await this.io.remove(staging).catch(() => { });
207
+ await this.io.copy(path, staging);
208
+ await this.io.rename(staging, backup);
209
+ return backup;
210
+ }
211
+ catch {
212
+ await this.io.remove(staging).catch(() => { });
213
+ return null;
214
+ }
215
+ }
216
+ /**
217
+ * Read-guard refusal for write paths. Returns the refusal result when the
218
+ * target file is oversized, `null` otherwise. The file is skipped for
219
+ * reading (never loaded), backed up by raw copy, and the model is told to
220
+ * fix it manually — mirroring the drift refusal so corrupted state is never
221
+ * silently overwritten.
222
+ */
223
+ async oversizedRefusal(target) {
224
+ const oversized = await this.oversizedFile(target);
225
+ if (!oversized)
226
+ return null;
227
+ const backup = await this.backupFile(target);
228
+ const suffix = backup ? ` 已把备份保存到 ${basename(backup)}。` : '';
229
+ return {
230
+ ok: false,
231
+ message: `记忆文件为 ${oversized.size} 字节(上限 ${oversized.limit * READ_GUARD_FACTOR})—— 跳过读取。${suffix} 请手动修复该文件后重试。`,
232
+ entries: [], chars: 0, limit: this.limitFor(target),
233
+ };
234
+ }
235
+ /**
236
+ * The ONE memory write skeleton (V27 G2.5): oversized read-guard pre-transact,
237
+ * one transaction over the target path, and the C-01 structured refusal when a
238
+ * backend never invokes the task. `addChained` and `applyBatchChained` supply
239
+ * only their own in-transaction core, so the two write paths cannot drift in
240
+ * their guard order, their missing-file handling or their error text.
241
+ *
242
+ * @param target - memory target being written
243
+ * @param core - the in-transaction read-modify-write for the locked body
244
+ * @param shrinkOnly - v29 MEM-02: every queued operation REMOVES an entry
245
+ * (the recovery path for a limit lowered under existing content). The
246
+ * oversized read-guard is skipped for these batches: a canonical file
247
+ * written under a ≥10× higher limit trips the `limit × 10` byte bound, and
248
+ * the guard's "fix the file manually" refusal would pre-empt exactly the
249
+ * shrink recovery MEM-01 advertises. The load is bounded by what the store
250
+ * itself wrote under the old limit.
251
+ * @returns the core's result, or the oversized / contract-violation refusal
252
+ */
253
+ async chainedWrite(target, core, shrinkOnly = false) {
254
+ // M-7 (v3 audit): the oversized read-guard must run BEFORE the transact —
255
+ // inside it, node transact has already loaded the whole file, so the
256
+ // "skipped for reading (never loaded)" contract only holds pre-lock.
257
+ // v33 R2-1: the shrinkOnly bypass keeps a hard ceiling - a pathological
258
+ // (externally corrupted) file must not be loaded whole just because the
259
+ // batch is remove-only.
260
+ const SHRINK_LOAD_CEILING = 64 * 1024 * 1024;
261
+ if (!shrinkOnly) {
262
+ const refusal = await this.oversizedRefusal(target);
263
+ if (refusal)
264
+ return refusal;
265
+ }
266
+ else {
267
+ const size = await this.io.size?.(fileFor(this.root, target));
268
+ if (typeof size === 'number' && size > SHRINK_LOAD_CEILING) {
269
+ return {
270
+ ok: false,
271
+ message: `记忆文件为 ${size} 字节 —— 即使只做删除批处理也过大、无法加载。请手动修复该文件后重试。`,
272
+ entries: [], chars: 0, limit: this.limitFor(target),
273
+ };
274
+ }
275
+ }
276
+ let outcome;
277
+ await transactIo(this.io, fileFor(this.root, target), async (current) => {
278
+ const step = await core(current ?? '');
279
+ outcome = step.result;
280
+ // M-4 (v3 audit): a failure on a MISSING file must keep it missing —
281
+ // returning '' would fabricate an empty file. `null` (DELETE) is safe
282
+ // here: the file does not exist, so the remove is a no-op.
283
+ return step.write ?? (current ?? null);
284
+ });
285
+ // C-01: a transact backend that violates the contract (never invokes the
286
+ // task) leaves `outcome` undefined — the old `undefined as
287
+ // MemoryApplyResult` cast handed callers an object whose `.ok` dereference
288
+ // raised a raw TypeError. Structured refusal instead, mirroring
289
+ // skill-store's V6-19 guard.
290
+ return outcome ?? {
291
+ ok: false,
292
+ message: '内部错误:记忆写事务没有调用任务;未执行任何写入',
293
+ entries: [], chars: 0, limit: this.limitFor(target),
294
+ };
295
+ }
296
+ async add(target, facts) {
297
+ return await this.serial(() => this.addChained(target, facts));
298
+ }
299
+ async addChained(target, facts) {
300
+ if (!facts.trim())
301
+ return { ok: false, message: '内容不能为空。', entries: [], chars: 0, limit: this.limitFor(target) };
302
+ return await this.chainedWrite(target, async (raw) => await this.addCore(target, facts, raw));
303
+ }
304
+ /**
305
+ * Single-entry add inside the transaction: shared checks (oversized,
306
+ * drift, threat) and the content computation. `raw` is the locked view
307
+ * (`current`) — never a second IO read. `write: null` means "no change".
308
+ */
309
+ async addCore(target, facts, raw) {
310
+ const content = facts.trim();
311
+ // P3 (v15): structured rejection, NOT `this.failure()` — failure() feeds
312
+ // the consolidation-failure backoff counter, and an empty-facts validation
313
+ // error is not a write failure (same discipline as applyBatchCore's
314
+ // inline empty-facts rejection). Unreachable via the public entries
315
+ // (addChained pre-trims), kept as a defence against future callers.
316
+ if (!content)
317
+ return { result: { ok: false, message: '内容不能为空。', entries: [], chars: 0, limit: this.limitFor(target) }, write: null };
318
+ // The oversized guard runs pre-transact in add(); drift is derived from
319
+ // the locked view below.
320
+ const refusal = await this.driftRefusal(target, raw);
321
+ if (refusal)
322
+ return { result: refusal, write: null };
323
+ const threat = this.memoryThreatBlock(content);
324
+ if (threat)
325
+ return { result: { ok: false, message: threat, entries: [], chars: 0, limit: this.limitFor(target) }, write: null };
326
+ // V8-02 (0.3.47): the delimiter guard must inspect the FINAL on-disk entry
327
+ // — with addDatePrefix the prefix+content seam can SYNTHESIZE `\n§\n`
328
+ // (`§\nfoo` passes the pre-prefix check but becomes `## date\n§\nfoo`).
329
+ // C-03: the prefix is computed ONCE — the delimiter check and the
330
+ // appended entry below must see the same string (a midnight rollover
331
+ // between two computations made the checked string ≠ the written string).
332
+ const prefixed = this.addDatePrefix ? `## ${new Date().toISOString().slice(0, 10)}\n${content}` : content;
333
+ if (hasEntryDelimiter(prefixed)) {
334
+ // F-201: a fact carrying the delimiter (or ending in `\n§`) would split
335
+ // into multiple entries on read-back, and a delimiter-ending fact is
336
+ // permanent drift. Refuse up front with the position so the model can
337
+ // rewrite it as separate facts. V4-49: no batch-style `Operation N`
338
+ // prefix here — this is a single add, and addCore's other rejections
339
+ // (drift/threat/limit) are likewise prefix-free.
340
+ return { result: { ok: false, message: '该条目含有分隔符(§),会被拆成多条;请改写为多条独立的事实。', entries: [], chars: 0, limit: this.limitFor(target) }, write: null };
341
+ }
342
+ const entries = [...new Set(normalizeEntries(raw))];
343
+ // P2-1 (v18): only strip the generated date prefix when THIS store writes
344
+ // one (see dedupeKey). With addDatePrefix=false a user fact may
345
+ // legitimately start with `## YYYY-MM-DD\n`; stripping it made
346
+ // `add('alpha')` a false duplicate of a stored `## 2020-01-01\nalpha`.
347
+ if (entries.some(entry => this.dedupeKey(entry) === content)) {
348
+ this.resetFailures();
349
+ return { result: { ok: true, message: `该条目已存在(未重复添加)。${this.storageHint(target, entries.join(ENTRY_DELIMITER).length)}`, entries, chars: entries.join(ENTRY_DELIMITER).length, limit: this.limitFor(target) }, write: null };
350
+ }
351
+ const next = [...entries, prefixed];
352
+ const total = next.join(ENTRY_DELIMITER).length;
353
+ const addLimit = this.limitFor(target);
354
+ if (addLimit > 0 && total > addLimit) {
355
+ return { result: this.failure(target, `添加该条目会超过 ${addLimit} 字符上限。请先合并或删除过期条目,再重试。`, entries), write: null };
356
+ }
357
+ this.resetFailures();
358
+ return { result: { ok: true, message: `已添加条目。${this.storageHint(target, total)}`, entries: next, chars: total, limit: this.limitFor(target) }, write: render(next) };
359
+ }
360
+ /**
361
+ * The single drift evaluation. `raw` is in canonical form when it byte-matches
362
+ * `render(normalizeEntries(raw))`; anything else means it was edited outside
363
+ * MemoryStore (empty/`§`-only entries, stray blank lines, leading or trailing
364
+ * delimiters — structural anomalies the writer would quietly normalize away).
365
+ * Both write paths derive this from their locked view and `detectDrift` from
366
+ * a fresh read, so a write and a later read can never disagree about the same
367
+ * bytes.
368
+ *
369
+ * An absent, empty, or whitespace-only body is the "never written" state
370
+ * (rc.42 audit P1-6): it parses to zero entries, and the canonical form
371
+ * `'\n'` can never byte-match it, so flagging it would permanently refuse
372
+ * every write path — including the repairs the model would need to make.
373
+ * Such files are adopted instead of flagged.
374
+ *
375
+ * Returns one of:
376
+ * - `'external'` — non-canonical body, i.e. real external modification. This
377
+ * includes the Hermes-parity signal #2 (an entry larger than the whole-file
378
+ * limit): that shape is only meaningful as external evidence on a
379
+ * NON-canonical body, because free-form external appends never render
380
+ * canonically.
381
+ * - `'over-limit'` — v28 MEM-01: a CANONICAL body whose entries exceed the
382
+ * CURRENT configured limit. Those bytes were written by this store under a
383
+ * previous (higher) limit, so they are not external drift; treating them as
384
+ * such misattributed a config change to an "external editor" and bricked
385
+ * every write path (the advertised recovery — remove/consolidate — is
386
+ * exactly what the drift gate refused). Callers route this state to a
387
+ * config-naming refusal and let shrink-only batches through.
388
+ * - `null` — no drift: writable as-is.
389
+ *
390
+ * @param target - memory target whose char limit bounds one parsed entry
391
+ * @param raw - on-disk body, or `null` when the file does not exist
392
+ */
393
+ driftKind(target, raw) {
394
+ if (raw === null || raw.trim() === '')
395
+ return null;
396
+ const entries = normalizeEntries(raw);
397
+ if (render(entries) !== raw)
398
+ return 'external';
399
+ const limit = this.limitFor(target);
400
+ // A zero/negative limit means "unbounded".
401
+ if (limit > 0 && entries.some(entry => entry.length > limit))
402
+ return 'over-limit';
403
+ return null;
404
+ }
405
+ /** External-drift predicate: canonical-form violations only (see
406
+ * {@link driftKind}). `detectDrift` and the write paths share it, so a write
407
+ * and a later read never disagree about the same bytes. */
408
+ drifted(target, raw) {
409
+ return this.driftKind(target, raw) === 'external';
410
+ }
411
+ /**
412
+ * Drift refusal for a body already read under the write lock, or `null` when
413
+ * writing may proceed. Both write paths return this unchanged, so their
414
+ * refusals stay byte-identical and each carries the same backup.
415
+ *
416
+ * The `'over-limit'` state never refuses shrink-only batches (`shrinkOnly`):
417
+ * removing entries is the advertised recovery for a limit lowered under
418
+ * existing content, and the batch's own final limit check still gates the
419
+ * result.
420
+ *
421
+ * @param target - memory target that owns the drifted file
422
+ * @param raw - locked file body
423
+ * @param shrinkOnly - every queued operation removes an entry (no growth)
424
+ * @returns the refusal to hand back, or `null` to continue writing
425
+ */
426
+ async driftRefusal(target, raw, shrinkOnly = false) {
427
+ const kind = this.driftKind(target, raw);
428
+ if (kind === null)
429
+ return null;
430
+ if (kind === 'over-limit') {
431
+ if (shrinkOnly)
432
+ return null;
433
+ const limit = this.limitFor(target);
434
+ const entries = normalizeEntries(raw);
435
+ const over = entries.filter(entry => entry.length > limit).length;
436
+ return {
437
+ ok: false,
438
+ message: `${over} 条记忆超过了配置的 ${target}CharLimit(${limit});它们是在更高上限下写入的。请提高上限或删除条目 —— 删除类操作仍然可用。`,
439
+ entries,
440
+ chars: entries.join(ENTRY_DELIMITER).length,
441
+ limit,
442
+ };
443
+ }
444
+ const backup = await this.backupFile(target);
445
+ const suffix = backup ? ` 已把备份保存到 ${basename(backup)}。` : '';
446
+ return { ok: false, message: `检测到记忆文件被外部改动。${suffix} 请先处理该漂移再重试。`, entries: [], chars: 0, limit: this.limitFor(target) };
447
+ }
448
+ async applyBatch(target, operations) {
449
+ return await this.serial(() => this.applyBatchChained(target, operations));
450
+ }
451
+ async applyBatchChained(target, operations) {
452
+ if (operations.length === 0)
453
+ return { ok: false, message: 'operations 列表为空。', entries: [], chars: 0, limit: this.limitFor(target) };
454
+ // v28 MEM-01: remove-only batches are the recovery path for the
455
+ // 'over-limit' state (canonical body under a lowered limit) — the drift
456
+ // gate lets them through; grows still refuse with the config-naming text.
457
+ // v29 MEM-02: they also bypass the oversized read-guard (see chainedWrite).
458
+ const shrinkOnly = operations.every(op => op.action === 'remove');
459
+ return await this.chainedWrite(target, async (raw) => await this.applyBatchCore(target, operations, raw, shrinkOnly), shrinkOnly);
460
+ }
461
+ /** Batch RMW inside the transaction. `write: null` = failure/no-op, disk untouched. */
462
+ async applyBatchCore(target, operations, raw, shrinkOnly = false) {
463
+ // The oversized guard runs pre-transact in applyBatch(); drift is derived
464
+ // from the locked view below.
465
+ const refusal = await this.driftRefusal(target, raw, shrinkOnly);
466
+ if (refusal)
467
+ return { result: refusal, write: null };
468
+ const entries = [...new Set(normalizeEntries(raw))];
469
+ const working = [...entries];
470
+ // C-02: one date prefix per batch — the replace branch gains the
471
+ // same addDatePrefix treatment as add, and every op in the batch shares a
472
+ // single computation (no midnight seam between ops).
473
+ const datePrefix = this.addDatePrefix ? `## ${new Date().toISOString().slice(0, 10)}\n` : '';
474
+ for (const [index, op] of operations.entries()) {
475
+ const position = index + 1;
476
+ if (op.action === 'add') {
477
+ // PLAN-R2 P2-6 (2026-09-16): `content` is the schema alias for the new
478
+ // body. T3-06/A34: precedence goes to the first NON-BLANK of the two
479
+ // (core's `payloadText`), not to a blank `facts` that shadows a body the
480
+ // op does carry.
481
+ const body = payloadText(op);
482
+ if (!body)
483
+ return { result: { ok: false, message: `第 ${position} 个操作(add):必须提供 facts。未应用任何操作。${previewEntries(entries)}`, entries, chars: entries.join(ENTRY_DELIMITER).length, limit: this.limitFor(target) }, write: null };
484
+ const threat = this.memoryThreatBlock(body);
485
+ if (threat)
486
+ return { result: { ok: false, message: `第 ${position} 个操作:${threat}${previewEntries(entries)}`, entries, chars: entries.join(ENTRY_DELIMITER).length, limit: this.limitFor(target) }, write: null };
487
+ // V8-02 (0.3.47): same post-prefix inspection as addCore — the
488
+ // `## date\n${body}` seam must not synthesize the delimiter.
489
+ const entryBody = `${datePrefix}${body}`;
490
+ if (hasEntryDelimiter(entryBody)) {
491
+ return { result: { ok: false, message: `第 ${position} 个操作(add):该条目含有分隔符(§),会被拆成多条;请改写为多条独立的事实。${previewEntries(entries)}`, entries, chars: entries.join(ENTRY_DELIMITER).length, limit: this.limitFor(target) }, write: null };
492
+ }
493
+ if (!working.some(entry => this.dedupeKey(entry) === body)) {
494
+ working.push(entryBody);
495
+ }
496
+ continue;
497
+ }
498
+ // V6-25 (0.3.37): an enum-outside action (e.g. 'Add'/'upsert') used to
499
+ // silently fall into the REPLACE branch — a semantic drift that passed
500
+ // as ok:true. Fail loud on the contract violation instead. The action is
501
+ // inspected through unknown (the caller's type can lie).
502
+ const rawAction = op.action;
503
+ if (rawAction !== 'remove' && rawAction !== 'replace') {
504
+ return { result: { ok: false, message: `第 ${position} 个操作:未知动作「${String(rawAction)}」(应为 add/remove/replace)。未应用任何操作。${previewEntries(entries)}`, entries, chars: entries.join(ENTRY_DELIMITER).length, limit: this.limitFor(target) }, write: null };
505
+ }
506
+ const needle = (op.old_text ?? '').trim();
507
+ if (!needle)
508
+ return { result: { ok: false, message: `第 ${position} 个操作(${op.action}):必须提供 old_text。未应用任何操作。${previewEntries(entries)}`, entries, chars: entries.join(ENTRY_DELIMITER).length, limit: this.limitFor(target) }, write: null };
509
+ const matches = working.map((entry, matchIndex) => ({ entry, matchIndex })).filter(({ entry }) => entry.includes(needle));
510
+ if (matches.length === 0) {
511
+ return { result: this.failure(target, `第 ${position} 个操作:找不到匹配 "${needle}" 的条目。未应用任何操作。`, entries), write: null };
512
+ }
513
+ if (new Set(matches.map(m => m.entry)).size > 1) {
514
+ return { result: { ok: false, message: `第 ${position} 个操作:「${needle}」匹配到多条不同条目。未应用任何操作。${previewEntries(entries)}`, entries, chars: entries.join(ENTRY_DELIMITER).length, limit: this.limitFor(target) }, write: null };
515
+ }
516
+ const matchIndex = matches[0]?.matchIndex ?? -1;
517
+ if (op.action === 'remove') {
518
+ working.splice(matchIndex, 1);
519
+ }
520
+ else {
521
+ // PLAN-R2 P2-6 (2026-09-16): same `content` alias as the add branch —
522
+ // this is the replacement body slot, not a facts-only field.
523
+ const body = payloadText(op);
524
+ if (!body)
525
+ return { result: { ok: false, message: `第 ${position} 个操作(replace):必须提供 facts。${previewEntries(entries)}`, entries, chars: entries.join(ENTRY_DELIMITER).length, limit: this.limitFor(target) }, write: null };
526
+ const threat = this.memoryThreatBlock(body);
527
+ if (threat)
528
+ return { result: { ok: false, message: `第 ${position} 个操作:${threat}${previewEntries(entries)}`, entries, chars: entries.join(ENTRY_DELIMITER).length, limit: this.limitFor(target) }, write: null };
529
+ // C-02: the replaced entry carries the date prefix like an
530
+ // added one, and the delimiter guard inspects the FINAL on-disk entry
531
+ // (the V8-02 seam rule — a leading-§ body would synthesize `\n§\n`).
532
+ const entryBody = `${datePrefix}${body}`;
533
+ if (hasEntryDelimiter(entryBody)) {
534
+ return { result: { ok: false, message: `第 ${position} 个操作(replace):该条目含有分隔符(§),会被拆成多条;请改写为多条独立的事实。${previewEntries(entries)}`, entries, chars: entries.join(ENTRY_DELIMITER).length, limit: this.limitFor(target) }, write: null };
535
+ }
536
+ working[matchIndex] = entryBody;
537
+ }
538
+ }
539
+ const total = working.join(ENTRY_DELIMITER).length;
540
+ const batchLimit = this.limitFor(target);
541
+ if (batchLimit > 0 && total > batchLimit) {
542
+ return { result: this.failure(target, `批处理结果(${total} 字符)超过 ${batchLimit} 上限。请在同一个批里再删除或缩短一些条目。`, entries), write: null };
543
+ }
544
+ this.resetFailures();
545
+ return { result: { ok: true, message: `已应用 ${operations.length} 个操作。${this.storageHint(target, total)}`, entries: working, chars: total, limit: this.limitFor(target) }, write: render(working) };
546
+ }
547
+ async renderContext() {
548
+ const memory = await this.read('memory');
549
+ const user = await this.read('user');
550
+ const parts = [];
551
+ for (const [target, label, entries] of [['memory', 'Memory', memory], ['user', 'User Profile', user]]) {
552
+ // An oversized file read as empty; probe once more here so the injected
553
+ // context states the skip instead of silently dropping the block.
554
+ const oversized = entries.length === 0 ? await this.oversizedFile(target) : null;
555
+ if (oversized) {
556
+ parts.push(`## ${label} — file skipped: ${oversized.size} bytes (limit ${oversized.limit * READ_GUARD_FACTOR}); not read`);
557
+ continue;
558
+ }
559
+ // V10-03 (P2-18): the same exempt labels apply on render — an entry the
560
+ // deployment allowed into the store must not silently vanish from the
561
+ // injected context.
562
+ const safe = entries.filter(entry => !scanMemoryThreats(entry, undefined, this.threatScanOptions()));
563
+ if (safe.length > 0) {
564
+ const body = safe.join(ENTRY_DELIMITER);
565
+ const limit = this.limitFor(target);
566
+ const note = safe.length === entries.length ? '' : ` (${entries.length - safe.length} threat-matched entries filtered)`;
567
+ // Usage indicator aligned with Hermes `_render_block`: floor percentage clamped at 100.
568
+ // C-04: with no limit (limit <= 0) the whole usage segment is
569
+ // omitted — the old form rendered a bogus "[0% — N/0 chars]".
570
+ const usage = limit > 0
571
+ ? ` [${Math.min(100, Math.floor((body.length * 100) / limit))}% — ${body.length}/${limit} chars]`
572
+ : '';
573
+ parts.push(`## ${label} (${safe.length} entries)${usage}${note}\n${body}`);
574
+ }
575
+ else if (entries.length > 0) {
576
+ // P3-14 (v14): every entry was threat-matched. The block used to
577
+ // vanish with no trace, so the model (and an operator reading the
578
+ // transcript) could not tell "no memories" from "all filtered" — the
579
+ // oversized branch above already states its skip explicitly.
580
+ parts.push(`## ${label} — ${entries.length} entries withheld by the security scan; none injected`);
581
+ }
582
+ }
583
+ // G6-S2/W8: the injected docs are registered with `interpolate: false` by their consumer
584
+ // (tool-memory), so stored entries keep literal `{{...}}`: the platform never resolves them and
585
+ // never throws on one. MemoryStore no longer rewrites the text — the escape belonged to the
586
+ // consumer's registration flag, not to the stored knowledge (two homes would drift).
587
+ return parts.join('\n\n');
588
+ }
589
+ /**
590
+ * Detect on-disk drift for a caller that holds no locked view: `true` when the
591
+ * file is not in canonical form, or when its size trips the read guard. The
592
+ * write paths apply the same predicate (`drifted`) to the body they read under
593
+ * the lock, so a write and a follow-up read agree about the same bytes.
594
+ *
595
+ * @param target - memory target to inspect
596
+ * @returns whether the file on disk counts as externally drifted
597
+ */
598
+ async detectDrift(target) {
599
+ // Oversized files are an external-modification signal by the read guard;
600
+ // report drift so a write followed by a read never loads them.
601
+ if (await this.oversizedFile(target))
602
+ return true;
603
+ return this.drifted(target, await this.io.readText(fileFor(this.root, target)));
604
+ }
605
+ }
606
+ //# sourceMappingURL=memory-store.js.map