@sema-agent/core 5.26.0 → 5.28.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/CHANGELOG.md +113 -0
- package/dist/agents/agent-transcript-tool.d.ts +5 -2
- package/dist/agents/agent-transcript-tool.js +2 -1
- package/dist/agents/send-message-tool.d.ts +4 -1
- package/dist/agents/subagent.d.ts +5 -2
- package/dist/core/checkpoint-store.d.ts +7 -2
- package/dist/core/hooks.d.ts +61 -4
- package/dist/core/hooks.js +37 -15
- package/dist/core/memory-engine/engine.d.ts +8 -5
- package/dist/core/memory-engine/engine.js +18 -6
- package/dist/core/memory-engine/file-backend.d.ts +144 -4
- package/dist/core/memory-engine/file-backend.js +304 -36
- package/dist/core/memory-engine/layout.d.ts +31 -2
- package/dist/core/memory-engine/layout.js +132 -8
- package/dist/core/memory-engine/types.d.ts +9 -1
- package/dist/core/memory-vector.d.ts +6 -1
- package/dist/core/memory-vector.js +14 -4
- package/dist/core/memory.js +1 -6
- package/dist/core/permission-rule-consent.d.ts +82 -8
- package/dist/core/permission-rule-consent.js +92 -1
- package/dist/core/permission-rule-model.d.ts +87 -6
- package/dist/core/permission-rule-model.js +79 -0
- package/dist/core/permission-rule-org.d.ts +22 -3
- package/dist/core/permission-rule-org.js +67 -20
- package/dist/core/permission-rule-store.js +2 -2
- package/dist/core/permission-rule-sync.d.ts +15 -1
- package/dist/core/permission-rule-sync.js +89 -47
- package/dist/core/runner/prepare-memory.js +14 -9
- package/dist/core/runner/prepare-task.d.ts +9 -3
- package/dist/core/runner/prepare-task.js +37 -11
- package/dist/core/runner/runtask.d.ts +8 -1
- package/dist/core/runner/runtask.js +8 -1
- package/dist/core/task-registry-agent.d.ts +13 -3
- package/dist/core/task-registry-agent.js +51 -21
- package/dist/core/task-registry-monitor.js +1 -1
- package/dist/core/task-registry-shared.d.ts +9 -0
- package/dist/core/task-registry.d.ts +6 -3
- package/dist/core/tool-policy.d.ts +44 -4
- package/dist/core/tool-policy.js +37 -3
- package/dist/core/tool-result-store.d.ts +108 -7
- package/dist/core/tool-result-store.js +95 -15
- package/dist/core/types.d.ts +115 -17
- package/dist/core/types.js +30 -1
- package/dist/engine/loop/types.d.ts +10 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/orchestration/run-workflow-tool.d.ts +5 -3
- package/dist/orchestration/workflow.d.ts +9 -6
- package/dist/stores/file/checkpoint-store.d.ts +2 -1
- package/dist/stores/file/index.d.ts +1 -1
- package/dist/stores/file/tool-result-store.d.ts +41 -1
- package/dist/stores/file/tool-result-store.js +107 -19
- package/dist/tools/fs/fs-bash.d.ts +7 -0
- package/dist/tools/fs/fs-shared.d.ts +5 -0
- package/dist/tools/fs/fs-shared.js +11 -7
- package/dist/tools/fs/index.d.ts +6 -0
- package/dist/tools/fs/index.js +2 -0
- package/package.json +1 -1
|
@@ -128,6 +128,15 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
128
128
|
/** The names of sibling scope subdirs to exclude when scanning the ROOT scope's own nested content. */
|
|
129
129
|
private excludedSubdirNames;
|
|
130
130
|
private scopeDir;
|
|
131
|
+
/** B2/C-F9 — contain one inbound-gate REJECT, shared by the adopting read (`readScope` sync) and
|
|
132
|
+
* the adoption-restricted committed view (`readScopeCommitted`) so the two doors can never drift:
|
|
133
|
+
* report the finding, capture the SCANNED bytes into control-plane quarantine (never a path
|
|
134
|
+
* re-read — TOCTOU), restore the committed shadow over the suspect where one exists (the write
|
|
135
|
+
* lands by `rename`, which REPLACES a link at the final component instead of traversing it —
|
|
136
|
+
* pinned by test), and escalate any deviation as a report-visible `quarantine_failed`. Returns
|
|
137
|
+
* the shadow text that now backs the entry (undefined ⇔ never committed / shadow-less legacy —
|
|
138
|
+
* each caller decides what leaving the set means on its channel). */
|
|
139
|
+
private containInboundReject;
|
|
131
140
|
/** B2 inbound gate for content that did NOT come through applyPatches (git pull / hand edits /
|
|
132
141
|
* a hand-seeded file): secret + cap + structure. Returns the rejection, or undefined (clean). */
|
|
133
142
|
private inboundGate;
|
|
@@ -138,12 +147,49 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
138
147
|
* `sync` (read-side callers) adopts a committed file's CURRENT disk rev into the committed-rev
|
|
139
148
|
* ledger ONLY through the inbound gate (B2: out-of-session edits, e.g. git-pull, are scanned;
|
|
140
149
|
* a reject is moved to control-plane quarantine and the committed shadow content is restored to
|
|
141
|
-
* disk). The applyPatches lookup passes false (the ledger must keep the baseline for CAS).
|
|
150
|
+
* disk). The applyPatches lookup passes false (the ledger must keep the baseline for CAS).
|
|
151
|
+
*
|
|
152
|
+
* Ledger-write discipline: the sync=true adoption path WRITES `revs.json`, and that write is
|
|
153
|
+
* wholesale (atomic replace) — unserialized against applyPatches' transaction it is a
|
|
154
|
+
* lost update (the later writer drops every row the earlier one added). sync=true is therefore
|
|
155
|
+
* reachable ONLY through {@link readScopeAdopting}: `probeOnly` runs the scan with NO adoption
|
|
156
|
+
* side effects and returns undefined at the FIRST divergent entry (adoption required — the
|
|
157
|
+
* caller re-runs the scan under the txn mutex against a freshly reloaded ledger); a completed
|
|
158
|
+
* probe pass returns the entries (nothing to adopt, no lock ever taken). */
|
|
142
159
|
private readScope;
|
|
160
|
+
/**
|
|
161
|
+
* The adopting read: {@link readScope} sync=true with its ledger write serialized behind the
|
|
162
|
+
* SAME cross-process txn mutex applyPatches holds (acquisition/steal/timeout semantics shared,
|
|
163
|
+
* never duplicated). Without it, an adoption landing beside a sibling process's commit is a
|
|
164
|
+
* lost update: both read the ledger, both write it wholesale, the later atomic write drops the
|
|
165
|
+
* earlier writer's rows (the entry file stays on disk but the ledger no longer knows it).
|
|
166
|
+
* - PROBE first (no lock): disk agreeing with the ledger is the common case, and that pass writes
|
|
167
|
+
* no LEDGER row — the read stays lock-free and never contends with a writer over the ledger.
|
|
168
|
+
* (Precision, merged-code scan: the non-divergent branch may still BACKFILL a missing shadow
|
|
169
|
+
* file for a committed entry, an unserialized control-plane write with a narrow pre-existing
|
|
170
|
+
* last-writer window against a sibling's commit — see the shadow-backfill site below; that
|
|
171
|
+
* window predates this probe and is the same one the v2 single-lock design closes.)
|
|
172
|
+
* - Divergence ⇒ acquire the mutex, finish any interrupted transaction (the steal-safety
|
|
173
|
+
* contract: a stolen stale lock is safe BECAUSE recovery runs inside the lock), RELOAD the
|
|
174
|
+
* ledger from disk (the probe's cached view is exactly the stale baseline that loses
|
|
175
|
+
* updates), then re-run the scan with adoption and save inside the lock. The save re-verifies
|
|
176
|
+
* lock OWNERSHIP first (a holder stalled past the stale deadline was stolen from — its
|
|
177
|
+
* snapshot is behind the stealer's commit and must abort, exactly like the applyPatches
|
|
178
|
+
* journal write).
|
|
179
|
+
* - A lock that cannot be acquired throws (same loud arm as the write path): an adoption that
|
|
180
|
+
* cannot be serialized must surface, never silently skip the gate or serve stale state as
|
|
181
|
+
* adopted.
|
|
182
|
+
* No self-deadlock: nothing under the lock re-enters acquireTxnLock — the scan's quarantine /
|
|
183
|
+
* shadow / announcement writes use their own per-file sidecar locks (whose holders never take
|
|
184
|
+
* the txn mutex), and applyPatchesLocked only issues sync=false reads. The two UNLOCKED
|
|
185
|
+
* recovery observers (constructor / checkControlPlane) are unaffected: they already stand down
|
|
186
|
+
* while a live holder exists ({@link txnInFlight}), which now covers adoption holds too.
|
|
187
|
+
*/
|
|
188
|
+
private readScopeAdopting;
|
|
143
189
|
listHeaders(scopes: readonly string[]): Promise<MemoryEntryHeader[]>;
|
|
144
|
-
private
|
|
190
|
+
private listHeadersFrom;
|
|
145
191
|
getByIds(ids: readonly string[]): Promise<MemoryEntry[]>;
|
|
146
|
-
private
|
|
192
|
+
private getByIdsFrom;
|
|
147
193
|
/**
|
|
148
194
|
* design/178 §3 (ruled 2026-08-08) — a SIDE-EFFECT-FREE projection of this backend for the session's
|
|
149
195
|
* RETRIEVAL face (`memory_search` / `memory_get`).
|
|
@@ -162,11 +208,105 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
162
208
|
* WRITES ARE REFUSED, loudly — a view handed to a write path is a defect, not a fallback.
|
|
163
209
|
*/
|
|
164
210
|
retrievalView(): MemoryBackend;
|
|
211
|
+
/** Dedup keys for divergence findings the restricted view already recorded on THIS instance: one
|
|
212
|
+
* materialize reads a scope several times (listHeaders, then getByIds per scope), and a
|
|
213
|
+
* checkpoint-cadence harvest re-audits the same still-pending state — without the key, one
|
|
214
|
+
* untouched divergence would mint a finding per read. Per-process on purpose (no durable state to
|
|
215
|
+
* lifecycle: a cross-process resume re-discloses once, which is the desired "still pending"
|
|
216
|
+
* notice); a divergence whose bytes CHANGE mints a new key and is reported again. */
|
|
217
|
+
private restrictedFindingKeys;
|
|
218
|
+
private recordRestrictedFinding;
|
|
219
|
+
/** Id-less audit state for an adoption-restricted session's WRITE scope. Armed only by the
|
|
220
|
+
* audited mount, which carries the caller's explicit session verdict together with the session's
|
|
221
|
+
* write scope — never inferred from plane structure (a writeScope-null layering has no write
|
|
222
|
+
* scope and is never given this surface: id-less files there are ordinary working files or
|
|
223
|
+
* external drops). The baseline is the set of id-less rel paths already under the scope at the
|
|
224
|
+
* first audited read after the mount (= materialize time): a pre-existing id-less file cannot be
|
|
225
|
+
* attributed to this session and stays unflagged, while a session under the restriction verdict
|
|
226
|
+
* has no sanctioned write channel into the store — so an id-less file that APPEARS after the
|
|
227
|
+
* baseline arrived out of band by construction and is worth naming. Per-process like the finding
|
|
228
|
+
* keys; when restricted sessions interleave on one instance the later mount re-baselines, which
|
|
229
|
+
* degrades toward silence, never toward a false alarm. */
|
|
230
|
+
private restrictedIdlessScope?;
|
|
231
|
+
private restrictedIdlessBaseline?;
|
|
232
|
+
/**
|
|
233
|
+
* The ADOPTION-RESTRICTED read of one scope: serve the COMMITTED account (ledger + control-plane
|
|
234
|
+
* shadow) and never advance it. This is the tamper boundary for a session with no write intent:
|
|
235
|
+
* the ordinary `readScope(sync=true)` ADOPTS disk divergence as the new committed baseline
|
|
236
|
+
* regardless of session intent, so a byte landed by a side channel (e.g. a shell write under a
|
|
237
|
+
* session whose persistence is declared unavailable, or into a read-only layering) would be
|
|
238
|
+
* committed by the very session that was told it cannot persist — its own checkpoint-resume
|
|
239
|
+
* materialize included. Here instead:
|
|
240
|
+
* - disk rev == committed rev ⇒ served as-is (it IS the committed content);
|
|
241
|
+
* - divergence whose bytes equal the committed SHADOW ⇒ served as-is: the shadow is written at
|
|
242
|
+
* the applyPatches commit point, so matching it is transactional proof that a concurrent
|
|
243
|
+
* writable session committed this state and only this call's ledger snapshot raced it —
|
|
244
|
+
* never treated as tampering;
|
|
245
|
+
* - other divergence of a shadow-backed entry ⇒ the SHADOW (committed content) is served; the
|
|
246
|
+
* divergent disk bytes stay in place UNLESS they fail the inbound gate (the existing
|
|
247
|
+
* quarantine door, unchanged under restriction). Scan-clean divergence is never quarantined,
|
|
248
|
+
* overwritten, or adopted — it stays on disk as deferred external state a later unrestricted
|
|
249
|
+
* session adjudicates normally;
|
|
250
|
+
* - a divergent entry with NO shadow (shadow-less legacy), and a never-committed id-bearing
|
|
251
|
+
* file, have no committed content to serve ⇒ WITHHELD with a loud finding, never served from
|
|
252
|
+
* disk, never adopted.
|
|
253
|
+
* The ledger is reloaded from disk on every call (a stale cache would misread a concurrent commit
|
|
254
|
+
* as divergence) and NEVER written here; the only writes this path may perform are the
|
|
255
|
+
* gate-failing containment above and a shadow backfill for a rev-matching entry (recording
|
|
256
|
+
* committed content — the account itself does not move).
|
|
257
|
+
*
|
|
258
|
+
* `audit` picks the mount: true = the engine's materialize/harvest boundary (findings recorded,
|
|
259
|
+
* gate-failing divergence contained); false = the retrieval-tool face (a PURE committed read —
|
|
260
|
+
* no findings, no containment, no writes: a model lookup must be side-effect-free).
|
|
261
|
+
*/
|
|
262
|
+
private readScopeCommitted;
|
|
263
|
+
/** Does `id` have a projection file anywhere in the store OTHER than `excludeAbsPath`? Returns
|
|
264
|
+
* that projection's rel path, or undefined. Early-exits on the first hit; only consulted on the
|
|
265
|
+
* rare divergent-with-shadow path (the scope-binding guard above), never on clean reads. */
|
|
266
|
+
private idProjectionElsewhere;
|
|
267
|
+
/**
|
|
268
|
+
* The ADOPTION-RESTRICTED projection of this backend (see {@link readScopeCommitted}) — what a
|
|
269
|
+
* session with no write intent materializes and retrieves through. The CALLER decides restriction
|
|
270
|
+
* (an explicit verdict: a read-only layering, or persistence declared unavailable) and mounts this
|
|
271
|
+
* view; the backend never guesses a session's intent from its own state.
|
|
272
|
+
* - `audit: true` (the engine's materialize/harvest mount): divergence findings are recorded for
|
|
273
|
+
* the harvest report + announcement queue, and gate-FAILING divergence is contained by the
|
|
274
|
+
* existing quarantine door; `writeScope` (when the session has one) additionally arms the
|
|
275
|
+
* id-less baseline for that scope — see {@link auditRestrictedDivergence};
|
|
276
|
+
* - `audit: false` (the retrieval-tool mount): a pure committed-view read — no findings, no
|
|
277
|
+
* containment, no writes, no baseline.
|
|
278
|
+
* WRITES ARE REFUSED loudly — same posture as {@link retrievalView}.
|
|
279
|
+
*/
|
|
280
|
+
restrictedAdoptionView(opts?: {
|
|
281
|
+
audit?: boolean;
|
|
282
|
+
writeScope?: string | null;
|
|
283
|
+
}): MemoryBackend;
|
|
284
|
+
/**
|
|
285
|
+
* The harvest-boundary divergence sweep for an adoption-restricted session (the engine's
|
|
286
|
+
* zero-admission harvest arm calls this BEFORE draining findings): re-run the committed-view audit
|
|
287
|
+
* over the session's scopes — bytes landed AFTER materialize (a mid-session shell write) are
|
|
288
|
+
* detected here, at the very boundary where an unrestricted harvest would have admitted them —
|
|
289
|
+
* then check the committed account for entries MISSING from disk entirely (an unattributed delete
|
|
290
|
+
* is divergence too). Ledger rows carry no scope, so presence is judged across every registered
|
|
291
|
+
* scope, the same resolution `getByIds` uses; a rename or cross-scope move therefore never reads
|
|
292
|
+
* as missing. Non-destructive throughout: nothing is restored, forgotten, or adopted here.
|
|
293
|
+
*
|
|
294
|
+
* `opts.idlessWriteScope` is the CALLER's per-call session verdict (same posture as the mount:
|
|
295
|
+
* the backend never guesses): when the audited session HAS a write scope, id-less files under it
|
|
296
|
+
* that were not in the mount-time baseline are named too — the session has no sanctioned write
|
|
297
|
+
* channel into the store, so a new arrival is out of band by construction. Left out (a
|
|
298
|
+
* writeScope-null layering, or a caller that never mounted the audit), the sweep is exactly the
|
|
299
|
+
* id-bearing audit above; a scope mismatch with the armed mount likewise degrades to silence
|
|
300
|
+
* rather than judging against a foreign baseline.
|
|
301
|
+
*/
|
|
302
|
+
auditRestrictedDivergence(scopes: readonly string[], opts?: {
|
|
303
|
+
idlessWriteScope?: string;
|
|
304
|
+
}): void;
|
|
165
305
|
/** Lexical floor (design/138 §2.1): `1 - Jaccard` distance over name+description+body, ascending. */
|
|
166
306
|
search(query: string, scopes: readonly string[], opts?: {
|
|
167
307
|
limit?: number;
|
|
168
308
|
}): Promise<ScoredMemoryEntry[]>;
|
|
169
|
-
private
|
|
309
|
+
private searchFrom;
|
|
170
310
|
/**
|
|
171
311
|
* H4 — the entry transaction, staged and journaled:
|
|
172
312
|
* 1. RELOAD the ledger from disk (验收 M2: a concurrently-committed transaction from another
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync } from "node:fs";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { dirname, join, relative } from "node:path";
|
|
4
4
|
import { jaccardDistance, termSet } from "../memory-vector.js";
|
|
5
5
|
import { MAX_MEMORY_BYTES } from "../memory.js";
|
|
@@ -229,6 +229,29 @@ export class FileMemoryEngineBackend {
|
|
|
229
229
|
scopeDir(scope) {
|
|
230
230
|
return registerScope(this.directoryRoot, this.controlPlaneRoot, scope);
|
|
231
231
|
}
|
|
232
|
+
containInboundReject(finding, absPath, text, entryId, committed) {
|
|
233
|
+
this.inboundFindings.push(finding);
|
|
234
|
+
const q = quarantineAndTombstone(absPath, text, join(this.controlPlaneRoot, QUARANTINE_DIR), this.now);
|
|
235
|
+
const shadowText = committed !== undefined ? this.readCommittedShadow(entryId) : undefined;
|
|
236
|
+
let restoredShadow = false;
|
|
237
|
+
if (shadowText !== undefined) {
|
|
238
|
+
try {
|
|
239
|
+
atomicWriteFileSync(absPath, shadowText);
|
|
240
|
+
restoredShadow = true;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (q.detail !== undefined || (!q.removed && !restoredShadow)) {
|
|
246
|
+
const contained = q.removed || restoredShadow;
|
|
247
|
+
this.inboundFindings.push({
|
|
248
|
+
path: finding.path,
|
|
249
|
+
code: "quarantine_failed",
|
|
250
|
+
reason: `quarantine escalation: ${q.detail ?? "suspect copy not captured"}${contained ? "" : " — rejected content may still be on the model-visible plane"}`,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return shadowText;
|
|
254
|
+
}
|
|
232
255
|
inboundGate(relPath, text) {
|
|
233
256
|
const nameFinding = scanMemoryFileName(relPath);
|
|
234
257
|
if (nameFinding !== undefined)
|
|
@@ -240,7 +263,7 @@ export class FileMemoryEngineBackend {
|
|
|
240
263
|
}
|
|
241
264
|
return undefined;
|
|
242
265
|
}
|
|
243
|
-
readScope(scope, sync
|
|
266
|
+
readScope(scope, sync, probeOnly, lockToken) {
|
|
244
267
|
if (!sync && this.batchScan !== undefined) {
|
|
245
268
|
const memo = this.batchScan.get(scope);
|
|
246
269
|
if (memo !== undefined)
|
|
@@ -270,6 +293,10 @@ export class FileMemoryEngineBackend {
|
|
|
270
293
|
if (ledger) {
|
|
271
294
|
const committed = ledger[entry.id];
|
|
272
295
|
if (committed !== entry.rev) {
|
|
296
|
+
if (probeOnly)
|
|
297
|
+
return undefined;
|
|
298
|
+
if (lockToken !== undefined)
|
|
299
|
+
this.assertTxnLockOwnership(lockToken);
|
|
273
300
|
const relPath = relative(this.directoryRoot, f.path);
|
|
274
301
|
let finding = this.inboundGate(relPath, text);
|
|
275
302
|
if (finding === undefined && committed !== undefined) {
|
|
@@ -288,27 +315,7 @@ export class FileMemoryEngineBackend {
|
|
|
288
315
|
}
|
|
289
316
|
}
|
|
290
317
|
if (finding) {
|
|
291
|
-
this.
|
|
292
|
-
const q = quarantineAndTombstone(f.path, text, join(this.controlPlaneRoot, QUARANTINE_DIR), this.now);
|
|
293
|
-
const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
|
|
294
|
-
let restoredShadow = false;
|
|
295
|
-
if (shadowText !== undefined) {
|
|
296
|
-
try {
|
|
297
|
-
atomicWriteFileSync(f.path, shadowText);
|
|
298
|
-
restoredShadow = true;
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
}
|
|
302
|
-
entry = entryFromFile(shadowText, entry.id, f.slug, scope);
|
|
303
|
-
}
|
|
304
|
-
if (q.detail !== undefined || (!q.removed && !restoredShadow)) {
|
|
305
|
-
const contained = q.removed || restoredShadow;
|
|
306
|
-
this.inboundFindings.push({
|
|
307
|
-
path: finding.path,
|
|
308
|
-
code: "quarantine_failed",
|
|
309
|
-
reason: `quarantine escalation: ${q.detail ?? "suspect copy not captured"}${contained ? "" : " — rejected content may still be on the model-visible plane"}`,
|
|
310
|
-
});
|
|
311
|
-
}
|
|
318
|
+
const shadowText = this.containInboundReject(finding, f.path, text, entry.id, committed);
|
|
312
319
|
if (shadowText === undefined) {
|
|
313
320
|
if (committed !== undefined) {
|
|
314
321
|
delete ledger[entry.id];
|
|
@@ -316,6 +323,7 @@ export class FileMemoryEngineBackend {
|
|
|
316
323
|
}
|
|
317
324
|
continue;
|
|
318
325
|
}
|
|
326
|
+
entry = entryFromFile(shadowText, entry.id, f.slug, scope);
|
|
319
327
|
}
|
|
320
328
|
else {
|
|
321
329
|
if (committed !== undefined)
|
|
@@ -339,8 +347,11 @@ export class FileMemoryEngineBackend {
|
|
|
339
347
|
}
|
|
340
348
|
entries.push(entry);
|
|
341
349
|
}
|
|
342
|
-
if (ledgerChanged)
|
|
350
|
+
if (ledgerChanged) {
|
|
351
|
+
if (lockToken !== undefined)
|
|
352
|
+
this.assertTxnLockOwnership(lockToken);
|
|
343
353
|
this.saveLedger();
|
|
354
|
+
}
|
|
344
355
|
if (adoptedExternal.length > 0) {
|
|
345
356
|
try {
|
|
346
357
|
enqueueMemoryAnnouncement(this.controlPlaneRoot, {
|
|
@@ -373,27 +384,46 @@ export class FileMemoryEngineBackend {
|
|
|
373
384
|
this.batchScan.set(scope, entries);
|
|
374
385
|
return entries;
|
|
375
386
|
}
|
|
387
|
+
async readScopeAdopting(scope, timings) {
|
|
388
|
+
const probed = this.readScope(scope, true, true);
|
|
389
|
+
if (probed !== undefined)
|
|
390
|
+
return probed;
|
|
391
|
+
const lock = await this.acquireTxnLock(timings);
|
|
392
|
+
try {
|
|
393
|
+
this.assertTxnLockOwnership(lock.token);
|
|
394
|
+
this.ledger = undefined;
|
|
395
|
+
this.recoverJournal();
|
|
396
|
+
return this.readScope(scope, true, false, lock.token);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
this.ledger = undefined;
|
|
400
|
+
throw err;
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
lock.release();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
376
406
|
async listHeaders(scopes) {
|
|
377
|
-
return this.
|
|
407
|
+
return this.listHeadersFrom(scopes, (s) => this.readScopeAdopting(s));
|
|
378
408
|
}
|
|
379
|
-
|
|
409
|
+
async listHeadersFrom(scopes, read) {
|
|
380
410
|
const out = [];
|
|
381
411
|
for (const scope of scopes) {
|
|
382
412
|
const dir = this.scopeDir(scope);
|
|
383
|
-
for (const e of
|
|
413
|
+
for (const e of await read(scope)) {
|
|
384
414
|
out.push(headerOf(e, join(dir, `${e.slug}.md`)));
|
|
385
415
|
}
|
|
386
416
|
}
|
|
387
417
|
return out;
|
|
388
418
|
}
|
|
389
419
|
async getByIds(ids) {
|
|
390
|
-
return this.
|
|
420
|
+
return this.getByIdsFrom(ids, (s) => this.readScopeAdopting(s));
|
|
391
421
|
}
|
|
392
|
-
|
|
422
|
+
async getByIdsFrom(ids, read) {
|
|
393
423
|
const want = new Set(ids);
|
|
394
424
|
const out = [];
|
|
395
425
|
for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
|
|
396
|
-
for (const e of
|
|
426
|
+
for (const e of await read(scope)) {
|
|
397
427
|
if (want.has(e.id))
|
|
398
428
|
out.push(e);
|
|
399
429
|
}
|
|
@@ -405,18 +435,256 @@ export class FileMemoryEngineBackend {
|
|
|
405
435
|
throw new Error(`memory retrieval view is read-only — ${op} must go through the backend itself`);
|
|
406
436
|
};
|
|
407
437
|
return {
|
|
408
|
-
listHeaders: async (scopes) => this.
|
|
409
|
-
getByIds: async (ids) => this.
|
|
410
|
-
search: async (query, scopes, opts) => this.
|
|
438
|
+
listHeaders: async (scopes) => this.listHeadersFrom(scopes, (s) => this.readScope(s, false)),
|
|
439
|
+
getByIds: async (ids) => this.getByIdsFrom(ids, (s) => this.readScope(s, false)),
|
|
440
|
+
search: async (query, scopes, opts) => this.searchFrom(query, scopes, opts, (s) => this.readScope(s, false)),
|
|
411
441
|
applyPatches: async () => refuse("applyPatches"),
|
|
412
442
|
getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
|
|
413
443
|
setConsolidationCursor: async () => refuse("setConsolidationCursor"),
|
|
414
444
|
};
|
|
415
445
|
}
|
|
446
|
+
restrictedFindingKeys = new Set();
|
|
447
|
+
recordRestrictedFinding(key, finding) {
|
|
448
|
+
if (this.restrictedFindingKeys.has(key))
|
|
449
|
+
return;
|
|
450
|
+
this.restrictedFindingKeys.add(key);
|
|
451
|
+
this.inboundFindings.push(finding);
|
|
452
|
+
}
|
|
453
|
+
restrictedIdlessScope;
|
|
454
|
+
restrictedIdlessBaseline;
|
|
455
|
+
readScopeCommitted(scope, audit) {
|
|
456
|
+
const dir = this.scopeDir(scope);
|
|
457
|
+
const isRoot = dir === this.directoryRoot;
|
|
458
|
+
const files = scanEntryFiles(dir, {
|
|
459
|
+
exclude: isRoot ? this.excludedSubdirNames(scope) : undefined,
|
|
460
|
+
onSkip: audit
|
|
461
|
+
? (p, kind) => {
|
|
462
|
+
if (kind !== "unreadable")
|
|
463
|
+
return;
|
|
464
|
+
const rel = relative(this.directoryRoot, p);
|
|
465
|
+
this.recordRestrictedFinding(`unreadable|${rel}`, {
|
|
466
|
+
path: rel,
|
|
467
|
+
code: "unreadable",
|
|
468
|
+
reason: "the path could not be read (lstat/readdir failed) — the restricted-session divergence audit is incomplete under it",
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
: undefined,
|
|
472
|
+
});
|
|
473
|
+
this.ledger = undefined;
|
|
474
|
+
const ledger = this.loadLedger();
|
|
475
|
+
const idlessSeen = audit && scope === this.restrictedIdlessScope ? new Set() : undefined;
|
|
476
|
+
const entries = [];
|
|
477
|
+
for (const f of files) {
|
|
478
|
+
const rel = relative(this.directoryRoot, f.path);
|
|
479
|
+
let text;
|
|
480
|
+
try {
|
|
481
|
+
text = readFileSync(f.path, "utf8");
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
if (audit) {
|
|
485
|
+
this.recordRestrictedFinding(`unreadable|${rel}`, {
|
|
486
|
+
path: rel,
|
|
487
|
+
code: "unreadable",
|
|
488
|
+
reason: "memory entry file could not be read — it is missing from this restricted session's view",
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const parsed = parseEntryFile(text);
|
|
494
|
+
if (parsed.id === undefined) {
|
|
495
|
+
idlessSeen?.add(rel);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const entry = entryFromFile(text, parsed.id, f.slug, scope);
|
|
499
|
+
const committed = ledger[entry.id];
|
|
500
|
+
if (committed === entry.rev) {
|
|
501
|
+
if (audit && this.readCommittedShadow(entry.id) === undefined) {
|
|
502
|
+
try {
|
|
503
|
+
atomicWriteFileSync(this.shadowPath(entry.id), text);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
entries.push(entry);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
|
|
512
|
+
if (shadowText !== undefined) {
|
|
513
|
+
const shadowEntry = entryFromFile(shadowText, entry.id, f.slug, scope);
|
|
514
|
+
if (shadowEntry.rev === entry.rev) {
|
|
515
|
+
entries.push(entry);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
const otherProjection = this.idProjectionElsewhere(entry.id, f.path);
|
|
519
|
+
if (otherProjection !== undefined) {
|
|
520
|
+
if (audit) {
|
|
521
|
+
const finding = this.inboundGate(rel, text);
|
|
522
|
+
if (finding !== undefined) {
|
|
523
|
+
this.containInboundReject(finding, f.path, text, entry.id, undefined);
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
this.recordRestrictedFinding(`dupserve|${rel}|${entry.rev}`, {
|
|
527
|
+
path: rel,
|
|
528
|
+
code: "restricted_divergence",
|
|
529
|
+
reason: `this file carries the id of a committed entry that has another projection (${otherProjection}) — the committed copy may only stand in for an entry's unique projection, so this copy is withheld; an unrestricted session's harvest adjudicates it`,
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (audit) {
|
|
536
|
+
const finding = this.inboundGate(rel, text);
|
|
537
|
+
if (finding !== undefined) {
|
|
538
|
+
this.containInboundReject(finding, f.path, text, entry.id, committed);
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
this.recordRestrictedFinding(`divergence|${rel}|${entry.rev}`, {
|
|
542
|
+
path: rel,
|
|
543
|
+
code: "restricted_divergence",
|
|
544
|
+
reason: "on-disk content diverges from the committed entry with no backend transaction backing it; this adoption-restricted session serves the committed content and leaves the disk state pending — an unrestricted session can adopt it normally",
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
entries.push(shadowEntry);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (committed !== undefined) {
|
|
552
|
+
if (audit) {
|
|
553
|
+
this.recordRestrictedFinding(`legacy|${rel}|${entry.rev}`, {
|
|
554
|
+
path: rel,
|
|
555
|
+
code: "restricted_divergence",
|
|
556
|
+
reason: "on-disk content diverges from a committed entry that has no committed shadow copy; this adoption-restricted session withholds the entry (nothing trustworthy to serve) — an unrestricted session can adjudicate it",
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (audit) {
|
|
562
|
+
const finding = this.inboundGate(rel, text);
|
|
563
|
+
if (finding !== undefined) {
|
|
564
|
+
this.containInboundReject(finding, f.path, text, entry.id, undefined);
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
this.recordRestrictedFinding(`uncommitted|${rel}|${entry.rev}`, {
|
|
568
|
+
path: rel,
|
|
569
|
+
code: "restricted_divergence",
|
|
570
|
+
reason: "an id-bearing memory file exists on disk that the committed account has never seen; this adoption-restricted session neither adopts nor serves it — it stays on disk for an unrestricted session to adopt normally",
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (idlessSeen !== undefined && this.restrictedIdlessBaseline === undefined)
|
|
576
|
+
this.restrictedIdlessBaseline = idlessSeen;
|
|
577
|
+
return entries;
|
|
578
|
+
}
|
|
579
|
+
idProjectionElsewhere(id, excludeAbsPath) {
|
|
580
|
+
for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
|
|
581
|
+
const dir = this.scopeDir(scope);
|
|
582
|
+
const isRoot = dir === this.directoryRoot;
|
|
583
|
+
for (const f of scanEntryFiles(dir, { exclude: isRoot ? this.excludedSubdirNames(scope) : undefined })) {
|
|
584
|
+
if (f.path === excludeAbsPath)
|
|
585
|
+
continue;
|
|
586
|
+
const text = readSafe(f.path);
|
|
587
|
+
if (text === undefined)
|
|
588
|
+
continue;
|
|
589
|
+
if (parseEntryFile(text).id === id)
|
|
590
|
+
return relative(this.directoryRoot, f.path);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
restrictedAdoptionView(opts) {
|
|
596
|
+
const audit = opts?.audit === true;
|
|
597
|
+
if (audit) {
|
|
598
|
+
this.restrictedIdlessScope = typeof opts?.writeScope === "string" ? opts.writeScope : undefined;
|
|
599
|
+
this.restrictedIdlessBaseline = undefined;
|
|
600
|
+
}
|
|
601
|
+
const refuse = (op) => {
|
|
602
|
+
throw new Error(`memory adoption-restricted view is read-only — ${op} must go through the backend itself`);
|
|
603
|
+
};
|
|
604
|
+
return {
|
|
605
|
+
listHeaders: async (scopes) => this.listHeadersFrom(scopes, (s) => this.readScopeCommitted(s, audit)),
|
|
606
|
+
getByIds: async (ids) => this.getByIdsFrom(ids, (s) => this.readScopeCommitted(s, audit)),
|
|
607
|
+
search: async (query, scopes, o) => this.searchFrom(query, scopes, o, (s) => this.readScopeCommitted(s, audit)),
|
|
608
|
+
applyPatches: async () => refuse("applyPatches"),
|
|
609
|
+
getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
|
|
610
|
+
setConsolidationCursor: async () => refuse("setConsolidationCursor"),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
auditRestrictedDivergence(scopes, opts) {
|
|
614
|
+
for (const scope of scopes)
|
|
615
|
+
this.readScopeCommitted(scope, true);
|
|
616
|
+
const idlessBaseline = opts?.idlessWriteScope !== undefined && opts.idlessWriteScope === this.restrictedIdlessScope ? this.restrictedIdlessBaseline : undefined;
|
|
617
|
+
const idlessScope = idlessBaseline !== undefined ? opts?.idlessWriteScope : undefined;
|
|
618
|
+
const ledger = { ...this.loadLedger() };
|
|
619
|
+
const present = new Map();
|
|
620
|
+
let complete = true;
|
|
621
|
+
for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
|
|
622
|
+
const dir = this.scopeDir(scope);
|
|
623
|
+
const isRoot = dir === this.directoryRoot;
|
|
624
|
+
const files = scanEntryFiles(dir, {
|
|
625
|
+
exclude: isRoot ? this.excludedSubdirNames(scope) : undefined,
|
|
626
|
+
onSkip: (_p, kind) => {
|
|
627
|
+
if (kind === "unreadable")
|
|
628
|
+
complete = false;
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
for (const f of files) {
|
|
632
|
+
const text = readSafe(f.path);
|
|
633
|
+
if (text === undefined) {
|
|
634
|
+
complete = false;
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
const id = parseEntryFile(text).id;
|
|
638
|
+
if (id !== undefined) {
|
|
639
|
+
present.set(id, [...(present.get(id) ?? []), relative(this.directoryRoot, f.path)]);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (scope === idlessScope && idlessBaseline !== undefined) {
|
|
643
|
+
const rel = relative(this.directoryRoot, f.path);
|
|
644
|
+
if (!idlessBaseline.has(rel)) {
|
|
645
|
+
this.recordRestrictedFinding(`idless|${rel}|${createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16)}`, {
|
|
646
|
+
path: rel,
|
|
647
|
+
code: "restricted_divergence",
|
|
648
|
+
reason: "an id-less file appeared under the session's write scope after materialize, through none of this session's channels: a concurrent session's in-flight material, or an out-of-band drop — left on disk for an unrestricted harvest to adjudicate",
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
for (const [id, paths] of present) {
|
|
655
|
+
if (paths.length < 2 || ledger[id] === undefined)
|
|
656
|
+
continue;
|
|
657
|
+
this.recordRestrictedFinding(`dup|${id}|${paths.sort().join(",")}`, {
|
|
658
|
+
path: paths[0] ?? id,
|
|
659
|
+
code: "restricted_divergence",
|
|
660
|
+
reason: `committed memory entry ${id} appears at ${paths.length} paths (${paths.join(", ")}) — a filesystem copy is not a rename; this adoption-restricted session flags it and an unrestricted session's harvest adjudicates which projection is real`,
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
if (!complete) {
|
|
664
|
+
this.recordRestrictedFinding(`missing-audit-incomplete`, {
|
|
665
|
+
path: ".",
|
|
666
|
+
code: "restricted_divergence",
|
|
667
|
+
reason: "the divergence audit could not scan every path (unreadable subtree) — missing-entry detection was skipped this pass rather than judged from an incomplete scan",
|
|
668
|
+
});
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
for (const [id, rev] of Object.entries(ledger)) {
|
|
672
|
+
if (present.has(id))
|
|
673
|
+
continue;
|
|
674
|
+
this.ledger = undefined;
|
|
675
|
+
if (this.loadLedger()[id] === undefined)
|
|
676
|
+
continue;
|
|
677
|
+
this.recordRestrictedFinding(`missing|${id}|${rev}`, {
|
|
678
|
+
path: `id:${id}`,
|
|
679
|
+
code: "restricted_divergence",
|
|
680
|
+
reason: `committed memory entry ${id} is missing from disk with no transaction backing the removal; this adoption-restricted session neither restores nor forgets it — an unrestricted session can restore it from the committed copy`,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
416
684
|
async search(query, scopes, opts) {
|
|
417
|
-
return this.
|
|
685
|
+
return this.searchFrom(query, scopes, opts, (s) => this.readScopeAdopting(s));
|
|
418
686
|
}
|
|
419
|
-
|
|
687
|
+
async searchFrom(query, scopes, opts, read) {
|
|
420
688
|
const limit = opts?.limit ?? 20;
|
|
421
689
|
const q = termSet(query);
|
|
422
690
|
if (q.size === 0)
|
|
@@ -424,7 +692,7 @@ export class FileMemoryEngineBackend {
|
|
|
424
692
|
const scored = [];
|
|
425
693
|
for (const scope of scopes) {
|
|
426
694
|
const dir = this.scopeDir(scope);
|
|
427
|
-
for (const e of
|
|
695
|
+
for (const e of await read(scope)) {
|
|
428
696
|
const haystack = `${e.frontmatter.name ?? e.slug} ${e.frontmatter.description ?? ""} ${e.body}`;
|
|
429
697
|
const d = jaccardDistance(q, haystack);
|
|
430
698
|
if (d === null)
|
|
@@ -111,6 +111,17 @@ export declare function resolveMemoryEngineRoot(explicit?: string): string;
|
|
|
111
111
|
* collision (two distinct scopes → one dir name), so even a hash collision cannot silently merge scopes.
|
|
112
112
|
*/
|
|
113
113
|
export declare function scopeDirName(scope: string): string;
|
|
114
|
+
/**
|
|
115
|
+
* Whether `dir`'s volume FOLDS letter case — two names differing only in case address ONE
|
|
116
|
+
* directory entry (the macOS/Windows default). Probed on the actual directory, never guessed from
|
|
117
|
+
* the platform: case-sensitive APFS volumes and case-insensitive Linux mounts both exist, and this
|
|
118
|
+
* answer gates a fail-closed isolation refusal, so it must describe the volume the scope dirs
|
|
119
|
+
* really land on — a platform guess would fail OPEN exactly on the mismatched deployments.
|
|
120
|
+
* `undefined` = the volume answered on neither leg (unwritable AND no letter-bearing entry to read
|
|
121
|
+
* against); the caller must treat that as unresolved, never as either determined answer.
|
|
122
|
+
* Exported for the layout pins; not part of the package surface.
|
|
123
|
+
*/
|
|
124
|
+
export declare function dirCaseFolds(dir: string): boolean | undefined;
|
|
114
125
|
/** Which scope owns the memory dir ROOT (undefined = unclaimed). */
|
|
115
126
|
export declare function rootScopeOf(controlDir: string): string | undefined;
|
|
116
127
|
/**
|
|
@@ -121,8 +132,26 @@ export declare function rootScopeOf(controlDir: string): string | undefined;
|
|
|
121
132
|
export declare function claimRootScope(controlDir: string, scope: string): string;
|
|
122
133
|
/** Register `scope` in the durable registry (idempotent) and return its home dir under `memoryDir`.
|
|
123
134
|
* H5 fail-closed: a dir-name collision with a DIFFERENT registered scope throws — two scopes must
|
|
124
|
-
* never share one directory (silent cross-scope merge).
|
|
125
|
-
|
|
135
|
+
* never share one directory (silent cross-scope merge). The collision test has TWO arms:
|
|
136
|
+
* - exact: two scopes whose labels are the SAME string (hash collision / forged registry);
|
|
137
|
+
* - case-fold (2026-08-12): two labels differing ONLY in letter case are distinct strings to this
|
|
138
|
+
* registry yet ONE physical directory on a case-folding volume — cross-scope bleed the exact arm
|
|
139
|
+
* cannot see. The fold arm compares the FINAL dir labels (ASCII by construction, so toLowerCase
|
|
140
|
+
* is an exact fold) and only fires when the volume actually folds — probed lazily on a fold-equal
|
|
141
|
+
* candidate ({@link dirCaseFolds}), so the no-collision path never pays the probe and a
|
|
142
|
+
* case-SENSITIVE volume keeps hosting case-differing scope pairs as the genuinely distinct
|
|
143
|
+
* directories they are there. An UNPROBEABLE volume (neither probe leg answers) refuses the
|
|
144
|
+
* fold-equal pair too — an unresolved semantics guess would fail open exactly where the guard
|
|
145
|
+
* is needed. `opts.caseFoldingFs` overrides the probe for callers (and pins) that already
|
|
146
|
+
* measured the volume's semantics. The scope→dir MAPPING itself is unchanged on purpose:
|
|
147
|
+
* folding scopeDirName would re-home every existing mixed-case scope's directory.
|
|
148
|
+
* Both arms compare the scope's EFFECTIVE label — the registered one when present (a registered
|
|
149
|
+
* scope's home NEVER moves; entries live there), the formula only for a never-registered scope —
|
|
150
|
+
* so a registry whose stored labels drifted from the current formula is judged by the directories
|
|
151
|
+
* it actually serves, not by labels nobody uses. Lock-serialized (独立轨 F5). */
|
|
152
|
+
export declare function registerScope(memoryDir: string, controlDir: string, scope: string, opts?: {
|
|
153
|
+
caseFoldingFs?: boolean;
|
|
154
|
+
}): string;
|
|
126
155
|
/** All registered scopes → home dir name (`""` = the root). */
|
|
127
156
|
export declare function registeredScopes(controlDir: string): Record<string, string>;
|
|
128
157
|
/** The stable home directory of `scope`'s entry files: the root when it owns the root, else the
|