@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.
- package/README.md +16 -1
- package/lib/composition/index.js +3 -0
- package/lib/composition-D8Y3-ROd.js +604 -0
- package/lib/ensure-MEYMj9jn.js +222 -0
- package/lib/errors-Br2-uUSK.js +57 -0
- package/lib/http-routes-BP-S3KV-.js +149 -0
- package/lib/http-routes.js +2 -0
- package/lib/index.js +66 -850
- package/lib/restrictor.js +37 -0
- package/lib/subagent/index.js +186 -0
- package/lib/types/citations.js +192 -0
- package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
- package/lib/types/composition/composer.js +681 -0
- package/lib/types/composition/ensure.d.ts +10 -0
- package/lib/types/composition/ensure.js +95 -0
- package/lib/types/composition/index.d.ts +5 -0
- package/lib/types/composition/index.js +7 -0
- package/lib/types/composition/preset-ids.d.ts +17 -0
- package/lib/types/composition/preset-ids.js +21 -0
- package/lib/types/composition/restricted-preset.d.ts +36 -0
- package/lib/types/composition/restricted-preset.js +42 -0
- package/lib/types/constants.js +223 -0
- package/lib/types/cost.js +67 -0
- package/lib/types/curator.js +346 -0
- package/lib/types/diagnostics.d.ts +26 -0
- package/lib/types/diagnostics.js +23 -0
- package/lib/types/document-body.js +91 -0
- package/lib/types/drift-signals.js +319 -0
- package/lib/types/env.js +25 -0
- package/lib/types/errors.d.ts +4 -1
- package/lib/types/errors.js +59 -0
- package/lib/types/events.js +16 -0
- package/lib/types/evidence.js +65 -0
- package/lib/types/evolution-events.js +479 -0
- package/lib/types/fold-untrusted.js +44 -0
- package/lib/types/frontmatter.js +623 -0
- package/lib/types/fuzzy-match.js +135 -0
- package/lib/types/gates.js +51 -0
- package/lib/types/guards.js +27 -0
- package/lib/types/health-baseline.js +63 -0
- package/lib/types/http-routes.d.ts +67 -35
- package/lib/types/http-routes.js +135 -0
- package/lib/types/index.d.ts +7 -2
- package/lib/types/index.js +101 -0
- package/lib/types/instance-scope.js +69 -0
- package/lib/types/io.js +675 -0
- package/lib/types/learn-prompt.js +42 -0
- package/lib/types/limits.js +63 -0
- package/lib/types/live.js +107 -0
- package/lib/types/long-runs.js +96 -0
- package/lib/types/memory-store.d.ts +0 -16
- package/lib/types/memory-store.js +606 -0
- package/lib/types/mutations.js +95 -0
- package/lib/types/namespace-migration.js +156 -0
- package/lib/types/numeric.js +47 -0
- package/lib/types/opt-in.js +101 -0
- package/lib/types/package-identity.js +47 -0
- package/lib/types/params.js +328 -0
- package/lib/types/payload.js +36 -0
- package/lib/types/platform-line.generated.d.ts +10 -0
- package/lib/types/platform-line.generated.js +12 -0
- package/lib/types/platform-services.d.ts +2 -2
- package/lib/types/platform-services.js +52 -0
- package/lib/types/probe.js +122 -0
- package/lib/types/prompts.js +409 -0
- package/lib/types/quality.js +308 -0
- package/lib/types/reachability.js +122 -0
- package/lib/types/redact.js +257 -0
- package/lib/types/reference-rewrite.js +144 -0
- package/lib/types/reports.js +110 -0
- package/lib/types/restrictor.d.ts +11 -0
- package/lib/types/restrictor.js +48 -0
- package/lib/types/review-channel.js +59 -0
- package/lib/types/run-registry.js +345 -0
- package/lib/types/scope.js +7 -0
- package/lib/types/serial.js +17 -0
- package/lib/types/session-fold.d.ts +15 -0
- package/lib/types/session-fold.js +28 -0
- package/lib/types/session-projection.js +295 -0
- package/lib/types/signals.js +209 -0
- package/lib/types/skill-discovery.js +66 -0
- package/lib/types/skill-health.js +134 -0
- package/lib/types/skill-history.js +745 -0
- package/lib/types/skill-lint.js +112 -0
- package/lib/types/skill-reads.js +67 -0
- package/lib/types/skill-row-cells.js +123 -0
- package/lib/types/skill-store.js +3528 -0
- package/lib/types/state-store.js +60 -0
- package/lib/types/store-ledger.js +117 -0
- package/lib/types/subagent/channel.d.ts +4 -0
- package/lib/types/subagent/channel.js +131 -0
- package/lib/types/subagent/index.d.ts +4 -0
- package/lib/types/subagent/index.js +5 -0
- package/lib/types/subagent/readiness.d.ts +6 -0
- package/lib/types/subagent/readiness.js +35 -0
- package/lib/types/subagent/types.d.ts +58 -0
- package/lib/types/subagent/types.js +2 -0
- package/lib/types/threats.js +338 -0
- package/lib/types/tool-dispatch.d.ts +5 -0
- package/lib/types/tool-dispatch.js +535 -0
- package/lib/types/tree-write.js +40 -0
- package/lib/types/unit-members.js +121 -0
- package/lib/types/usage.js +474 -0
- package/lib/types/write-authority.js +23 -0
- package/lib/types/write-gates.js +344 -0
- package/lib/types/write-inventory.js +158 -0
- 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
|