@sema-agent/core 5.27.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 +64 -0
- package/dist/core/hooks.d.ts +22 -0
- package/dist/core/hooks.js +22 -3
- package/dist/core/memory-engine/engine.js +2 -4
- package/dist/core/memory-engine/file-backend.d.ts +66 -7
- package/dist/core/memory-engine/file-backend.js +69 -27
- 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 +6 -5
- 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 +17 -1
- package/dist/core/permission-rule-model.js +21 -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-task.js +8 -3
- package/dist/core/runner/runtask.js +8 -1
- package/dist/core/task-registry-agent.d.ts +9 -0
- 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/tool-policy.d.ts +35 -2
- 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 +80 -10
- package/dist/core/types.js +30 -1
- package/dist/index.d.ts +2 -2
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.28.0 — 2026-08-12
|
|
4
|
+
|
|
5
|
+
No API-BREAKING changes (exports grow only). Narrowings are tighten-direction and named below.
|
|
6
|
+
|
|
7
|
+
### Narrowed (behavior, ruled)
|
|
8
|
+
|
|
9
|
+
- **A hook-promoted ask's provenance is stamped, never read back.** Every consumption point keying
|
|
10
|
+
on `decisionReason === "hook"` now reads an engine-stamped value: the fold reconstructs the
|
|
11
|
+
promoted ask from an explicit member whitelist and stamps `"hook"` unconditionally, so a hook
|
|
12
|
+
self-declaring any other word (or extra members like `persistedRuleShadowed`) cannot change how
|
|
13
|
+
the persisted-rule lane, the rule-suggestion factory, or the inherited-frame reuse treat the ask.
|
|
14
|
+
- **A `decisionReason` outside the closed set is refused at the consumption screen.** A
|
|
15
|
+
deployment-authored `ToolPolicy.check` returning an unrecognized word (or routing the field
|
|
16
|
+
through a getter/prototype) is denied fail-closed with the closed set named; plain decisions pass
|
|
17
|
+
by reference. The HOOK seam is exempt by design (the field is a documented non-input there — see
|
|
18
|
+
the stamp above).
|
|
19
|
+
- **The served org snapshot meets the same validator as a fetched one.** A persisted last-known-good
|
|
20
|
+
snapshot carrying a rule the current validator refuses now fails closed during a provider outage
|
|
21
|
+
instead of serving partial policy.
|
|
22
|
+
- **Legacy rule text the current validator refuses no longer stalls sync.** A shape-whole tombstone
|
|
23
|
+
whose TEXT is refused gets the new closed-set reason `invalid_rule_text` — disclosed, quarantined,
|
|
24
|
+
and no longer withholding the whole inbound-adds round (safe: tombstones match byte-identical
|
|
25
|
+
text only, and an add with that text is refused by the same predicate).
|
|
26
|
+
- **Case-colliding scope directories are refused loudly on case-insensitive filesystems.** Two
|
|
27
|
+
scopes differing only by case no longer silently share one physical directory (macOS/Windows);
|
|
28
|
+
collision detection compares folded identity (dev:ino-keyed), with lazy real probing instead of
|
|
29
|
+
platform guessing.
|
|
30
|
+
- **Spill provenance is session-only, and a failing spill degrades instead of poisoning.** The agent
|
|
31
|
+
spill write points stamp `{sessionId}` (matching the offload/budget/compaction form); a failed or
|
|
32
|
+
conflicting spill put defers to a sibling-proven ref or reports honestly — a legacy-shape row can
|
|
33
|
+
no longer make an agent result permanently unreadable (guarded put + generation guard + coherent
|
|
34
|
+
poll snapshot).
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `onNotice` structured notice seat (first two families: bash timeout, MATERIALIZE) — absent keeps
|
|
39
|
+
the `console.warn` text verbatim; a present non-function seat is announced once and falls back
|
|
40
|
+
loudly instead of silencing both channels.
|
|
41
|
+
- `tool_end.errorCode` now reads both discriminator spellings — `details.code` first (string), then
|
|
42
|
+
`details.errorKind` — so loop-thrown error frames are classifiable; `code` wins when both exist.
|
|
43
|
+
- `ToolResultStore.deleteBySession?` (optional member; File + in-memory implementations, not on the
|
|
44
|
+
scoped wrapper, not in the published contract kit) — returns `{ deleted, unattributable }`;
|
|
45
|
+
selection keys on recorded provenance, unowned rows are counted and never deleted. A put refused
|
|
46
|
+
over an orphan sidecar un-publishes its own content (pre-call state restored), and a damaged
|
|
47
|
+
re-published survivor is counted unattributable.
|
|
48
|
+
- Adopt-on-read ledger writes are serialized behind the existing cross-process txn mutex (lock-free
|
|
49
|
+
probe fast path; divergence acquires the mutex, reloads the ledger from disk, and re-verifies
|
|
50
|
+
lock ownership at recovery, side-effect and commit points) — cross-process lost-update closed.
|
|
51
|
+
- `runTaskStream` refuses an out-of-shape `resume` synchronously (a resume without `outcome` used to
|
|
52
|
+
hang `stream.result()` forever).
|
|
53
|
+
- `docs/INTEGRATION-CORE.md` — the core→embedder contract document (event-frame per-key obligations,
|
|
54
|
+
code-vocabulary pointers, TaskResult matrix, resume/decide shapes, store optional-member table);
|
|
55
|
+
maintained per L3 release.
|
|
56
|
+
- Rule-suggestion admission honesty (#174 adjacency): the suggestion factory keys on the stamped
|
|
57
|
+
provenance.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- Control-character gate: five bench probe arms now genuinely reach their claimed product paths;
|
|
62
|
+
the published B2 index-truncation curve was corrected to `[0, 0, 0.005, 0.602]` (the old counting
|
|
63
|
+
masked a real N200 loss).
|
|
64
|
+
- `TaskResult.retryAfterMs` and `tool_end.errorCode` JSDoc corrected to match code (two accepted
|
|
65
|
+
codes; the errorKind fallback).
|
|
66
|
+
|
|
3
67
|
## 5.27.0 — 2026-08-11
|
|
4
68
|
|
|
5
69
|
No API-BREAKING changes (exports grow only). Narrowings are tighten-direction and named below.
|
package/dist/core/hooks.d.ts
CHANGED
|
@@ -417,6 +417,28 @@ export interface HookToolOutput {
|
|
|
417
417
|
/**
|
|
418
418
|
* A PreToolUse hook result: a {@link PermissionResult} (so a hook can `deny`/`ask`, or `allow` with an
|
|
419
419
|
* `updatedInput` rewrite) plus optional `additionalContext` injected into the eventual tool result.
|
|
420
|
+
*
|
|
421
|
+
* **The provenance members are not hook-settable inputs** (#174). `decisionReason` is the ENGINE's account
|
|
422
|
+
* of which layer produced a verdict, and `persistedRuleShadowed` is the gate's own #144 disclosure that a
|
|
423
|
+
* stored rule matched without clearing the ask. The gate stamps both itself — a `decisionReason` on a hook
|
|
424
|
+
* result is DISCARDED (a hook ask always reads `"hook"` downstream), and a `persistedRuleShadowed` is
|
|
425
|
+
* dropped rather than rendered on an approval card as a rule no lane matched.
|
|
426
|
+
*
|
|
427
|
+
* Which of a hook's OTHER members reach the surviving decision depends on which fold arm ran, so read
|
|
428
|
+
* the arm, not a single list: an ask promoted from a policy `allow` is rebuilt from the hook's `message`,
|
|
429
|
+
* `requiresRealApproval` and `updatedInput`, while a hook ask folded BESIDE a policy ask contributes its
|
|
430
|
+
* message and its mandate to the policy's decision and leaves the policy's own rewrite in place (the
|
|
431
|
+
* hook's rewrite still reaches execution — through the gate's captured rewrite, not through the decision
|
|
432
|
+
* object). Both arms are at the `hookAsk` fold in {@link runToolGate}.
|
|
433
|
+
*
|
|
434
|
+
* `additionalContext` is on neither list and never was consumed as a decision member: it rides its own
|
|
435
|
+
* channel — collected into the gate's `preToolContext` as soon as the hook answers, whichever verdict it
|
|
436
|
+
* returned, and drained onto the tool result by the runner. DELIVERY is narrower than collection: the
|
|
437
|
+
* runner stashes the context only for a call that will EXECUTE, because a blocked or suspended call never
|
|
438
|
+
* reaches the result step that drains it and carries its own model-facing reason instead. The context
|
|
439
|
+
* formerly APPEARED on the promoted ask object as an undeclared property, because that fold spread the
|
|
440
|
+
* whole hook result; the concurrent arm never carried it, so the two arms disagreed on a member neither
|
|
441
|
+
* of them reads. Nothing in the engine reads it off a decision.
|
|
420
442
|
*/
|
|
421
443
|
export type PreToolUseResult = PermissionResult & {
|
|
422
444
|
additionalContext?: string;
|
package/dist/core/hooks.js
CHANGED
|
@@ -98,7 +98,7 @@ function preToolUseCrashReason(subject, err) {
|
|
|
98
98
|
function screenPreToolUseResult(r) {
|
|
99
99
|
if (r === undefined)
|
|
100
100
|
return undefined;
|
|
101
|
-
return refuseOutOfContractDecision(r);
|
|
101
|
+
return refuseOutOfContractDecision(r, { reasonIsNonInput: true });
|
|
102
102
|
}
|
|
103
103
|
export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
|
|
104
104
|
return brandPolicyAskClass({
|
|
@@ -125,6 +125,7 @@ export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash) {
|
|
|
125
125
|
action: "ask",
|
|
126
126
|
message: decisionText(r) ?? `approval required for "${req.toolName}" (inherited PreToolUse hook)`,
|
|
127
127
|
decisionReason: "hook",
|
|
128
|
+
...(r.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
128
129
|
...(r.updatedInput !== undefined ? { updatedInput: r.updatedInput } : {}),
|
|
129
130
|
};
|
|
130
131
|
}
|
|
@@ -220,13 +221,31 @@ export async function runToolGate(input) {
|
|
|
220
221
|
if ((decision.action === "allow" || decision.action === "ask") && decision.updatedInput !== undefined) {
|
|
221
222
|
policyRewrite = decision.updatedInput;
|
|
222
223
|
}
|
|
223
|
-
else if (hookAsk?.
|
|
224
|
+
else if (hookAsk?.updatedInput !== undefined) {
|
|
224
225
|
policyRewrite = hookAsk.updatedInput;
|
|
225
226
|
}
|
|
226
227
|
if (decision.action === "allow" && hookAsk) {
|
|
227
|
-
decision = {
|
|
228
|
+
decision = {
|
|
229
|
+
action: "ask",
|
|
230
|
+
...(hookAsk.message !== undefined ? { message: hookAsk.message } : {}),
|
|
231
|
+
decisionReason: "hook",
|
|
232
|
+
...(hookAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
233
|
+
...(hookAsk.updatedInput !== undefined ? { updatedInput: hookAsk.updatedInput } : {}),
|
|
234
|
+
};
|
|
228
235
|
denySource = "hook";
|
|
229
236
|
}
|
|
237
|
+
else if (decision.action === "ask" && hookAsk) {
|
|
238
|
+
const hookMessage = hookAsk.message;
|
|
239
|
+
const foldedMessage = hookMessage !== undefined && hookMessage !== decision.message
|
|
240
|
+
? `${decision.message !== undefined ? `${decision.message} ` : ""}(a PreToolUse hook also asks: ${hookMessage})`
|
|
241
|
+
: decision.message;
|
|
242
|
+
decision = {
|
|
243
|
+
...decision,
|
|
244
|
+
decisionReason: "hook",
|
|
245
|
+
...(foldedMessage !== undefined ? { message: foldedMessage } : {}),
|
|
246
|
+
...(hookAsk.requiresRealApproval === true ? { requiresRealApproval: true } : {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
230
249
|
if (input.egress && decision.action === "allow") {
|
|
231
250
|
decision = {
|
|
232
251
|
action: "ask",
|
|
@@ -328,7 +328,7 @@ export class MemoryEngine {
|
|
|
328
328
|
ensureDirExists(this.controlDir);
|
|
329
329
|
const restricted = opts?.adoptionRestricted === true;
|
|
330
330
|
const readBackend = restricted
|
|
331
|
-
? (this.backend.restrictedAdoptionView?.({ audit: true }) ?? this.backend)
|
|
331
|
+
? (this.backend.restrictedAdoptionView?.({ audit: true, writeScope }) ?? this.backend)
|
|
332
332
|
: this.backend;
|
|
333
333
|
if (writeScope !== null)
|
|
334
334
|
claimRootScope(this.controlDir, writeScope);
|
|
@@ -536,9 +536,7 @@ export class MemoryEngine {
|
|
|
536
536
|
const writeScope = handle.writeScope;
|
|
537
537
|
if (writeScope === null || opts?.admitNothing !== undefined) {
|
|
538
538
|
try {
|
|
539
|
-
this.backend.auditRestrictedDivergence?.([
|
|
540
|
-
...new Set([...handle.scopes, ...(writeScope !== null ? [writeScope] : [])]),
|
|
541
|
-
]);
|
|
539
|
+
this.backend.auditRestrictedDivergence?.([...new Set([...handle.scopes, ...(writeScope !== null ? [writeScope] : [])])], handle.adoptionRestricted === true && writeScope !== null ? { idlessWriteScope: writeScope } : undefined);
|
|
542
540
|
}
|
|
543
541
|
catch (err) {
|
|
544
542
|
report.warnings.push(`restricted-session divergence audit failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -147,13 +147,48 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
147
147
|
* `sync` (read-side callers) adopts a committed file's CURRENT disk rev into the committed-rev
|
|
148
148
|
* ledger ONLY through the inbound gate (B2: out-of-session edits, e.g. git-pull, are scanned;
|
|
149
149
|
* a reject is moved to control-plane quarantine and the committed shadow content is restored to
|
|
150
|
-
* 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). */
|
|
151
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;
|
|
152
189
|
listHeaders(scopes: readonly string[]): Promise<MemoryEntryHeader[]>;
|
|
153
|
-
private listHeadersWith;
|
|
154
190
|
private listHeadersFrom;
|
|
155
191
|
getByIds(ids: readonly string[]): Promise<MemoryEntry[]>;
|
|
156
|
-
private getByIdsWith;
|
|
157
192
|
private getByIdsFrom;
|
|
158
193
|
/**
|
|
159
194
|
* design/178 §3 (ruled 2026-08-08) — a SIDE-EFFECT-FREE projection of this backend for the session's
|
|
@@ -181,6 +216,19 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
181
216
|
* notice); a divergence whose bytes CHANGE mints a new key and is reported again. */
|
|
182
217
|
private restrictedFindingKeys;
|
|
183
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?;
|
|
184
232
|
/**
|
|
185
233
|
* The ADOPTION-RESTRICTED read of one scope: serve the COMMITTED account (ledger + control-plane
|
|
186
234
|
* shadow) and never advance it. This is the tamper boundary for a session with no write intent:
|
|
@@ -223,13 +271,15 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
223
271
|
* view; the backend never guesses a session's intent from its own state.
|
|
224
272
|
* - `audit: true` (the engine's materialize/harvest mount): divergence findings are recorded for
|
|
225
273
|
* the harvest report + announcement queue, and gate-FAILING divergence is contained by the
|
|
226
|
-
* existing quarantine door;
|
|
274
|
+
* existing quarantine door; `writeScope` (when the session has one) additionally arms the
|
|
275
|
+
* id-less baseline for that scope — see {@link auditRestrictedDivergence};
|
|
227
276
|
* - `audit: false` (the retrieval-tool mount): a pure committed-view read — no findings, no
|
|
228
|
-
* containment, no writes.
|
|
277
|
+
* containment, no writes, no baseline.
|
|
229
278
|
* WRITES ARE REFUSED loudly — same posture as {@link retrievalView}.
|
|
230
279
|
*/
|
|
231
280
|
restrictedAdoptionView(opts?: {
|
|
232
281
|
audit?: boolean;
|
|
282
|
+
writeScope?: string | null;
|
|
233
283
|
}): MemoryBackend;
|
|
234
284
|
/**
|
|
235
285
|
* The harvest-boundary divergence sweep for an adoption-restricted session (the engine's
|
|
@@ -240,13 +290,22 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
|
|
|
240
290
|
* is divergence too). Ledger rows carry no scope, so presence is judged across every registered
|
|
241
291
|
* scope, the same resolution `getByIds` uses; a rename or cross-scope move therefore never reads
|
|
242
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.
|
|
243
301
|
*/
|
|
244
|
-
auditRestrictedDivergence(scopes: readonly string[]
|
|
302
|
+
auditRestrictedDivergence(scopes: readonly string[], opts?: {
|
|
303
|
+
idlessWriteScope?: string;
|
|
304
|
+
}): void;
|
|
245
305
|
/** Lexical floor (design/138 §2.1): `1 - Jaccard` distance over name+description+body, ascending. */
|
|
246
306
|
search(query: string, scopes: readonly string[], opts?: {
|
|
247
307
|
limit?: number;
|
|
248
308
|
}): Promise<ScoredMemoryEntry[]>;
|
|
249
|
-
private searchWith;
|
|
250
309
|
private searchFrom;
|
|
251
310
|
/**
|
|
252
311
|
* H4 — the entry transaction, staged and journaled:
|
|
@@ -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";
|
|
@@ -263,7 +263,7 @@ export class FileMemoryEngineBackend {
|
|
|
263
263
|
}
|
|
264
264
|
return undefined;
|
|
265
265
|
}
|
|
266
|
-
readScope(scope, sync
|
|
266
|
+
readScope(scope, sync, probeOnly, lockToken) {
|
|
267
267
|
if (!sync && this.batchScan !== undefined) {
|
|
268
268
|
const memo = this.batchScan.get(scope);
|
|
269
269
|
if (memo !== undefined)
|
|
@@ -293,6 +293,10 @@ export class FileMemoryEngineBackend {
|
|
|
293
293
|
if (ledger) {
|
|
294
294
|
const committed = ledger[entry.id];
|
|
295
295
|
if (committed !== entry.rev) {
|
|
296
|
+
if (probeOnly)
|
|
297
|
+
return undefined;
|
|
298
|
+
if (lockToken !== undefined)
|
|
299
|
+
this.assertTxnLockOwnership(lockToken);
|
|
296
300
|
const relPath = relative(this.directoryRoot, f.path);
|
|
297
301
|
let finding = this.inboundGate(relPath, text);
|
|
298
302
|
if (finding === undefined && committed !== undefined) {
|
|
@@ -343,8 +347,11 @@ export class FileMemoryEngineBackend {
|
|
|
343
347
|
}
|
|
344
348
|
entries.push(entry);
|
|
345
349
|
}
|
|
346
|
-
if (ledgerChanged)
|
|
350
|
+
if (ledgerChanged) {
|
|
351
|
+
if (lockToken !== undefined)
|
|
352
|
+
this.assertTxnLockOwnership(lockToken);
|
|
347
353
|
this.saveLedger();
|
|
354
|
+
}
|
|
348
355
|
if (adoptedExternal.length > 0) {
|
|
349
356
|
try {
|
|
350
357
|
enqueueMemoryAnnouncement(this.controlPlaneRoot, {
|
|
@@ -377,33 +384,46 @@ export class FileMemoryEngineBackend {
|
|
|
377
384
|
this.batchScan.set(scope, entries);
|
|
378
385
|
return entries;
|
|
379
386
|
}
|
|
380
|
-
async
|
|
381
|
-
|
|
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
|
+
}
|
|
382
405
|
}
|
|
383
|
-
|
|
384
|
-
return this.listHeadersFrom(scopes, (s) => this.
|
|
406
|
+
async listHeaders(scopes) {
|
|
407
|
+
return this.listHeadersFrom(scopes, (s) => this.readScopeAdopting(s));
|
|
385
408
|
}
|
|
386
|
-
listHeadersFrom(scopes, read) {
|
|
409
|
+
async listHeadersFrom(scopes, read) {
|
|
387
410
|
const out = [];
|
|
388
411
|
for (const scope of scopes) {
|
|
389
412
|
const dir = this.scopeDir(scope);
|
|
390
|
-
for (const e of read(scope)) {
|
|
413
|
+
for (const e of await read(scope)) {
|
|
391
414
|
out.push(headerOf(e, join(dir, `${e.slug}.md`)));
|
|
392
415
|
}
|
|
393
416
|
}
|
|
394
417
|
return out;
|
|
395
418
|
}
|
|
396
419
|
async getByIds(ids) {
|
|
397
|
-
return this.
|
|
398
|
-
}
|
|
399
|
-
getByIdsWith(ids, adopt) {
|
|
400
|
-
return this.getByIdsFrom(ids, (s) => this.readScope(s, adopt));
|
|
420
|
+
return this.getByIdsFrom(ids, (s) => this.readScopeAdopting(s));
|
|
401
421
|
}
|
|
402
|
-
getByIdsFrom(ids, read) {
|
|
422
|
+
async getByIdsFrom(ids, read) {
|
|
403
423
|
const want = new Set(ids);
|
|
404
424
|
const out = [];
|
|
405
425
|
for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
|
|
406
|
-
for (const e of read(scope)) {
|
|
426
|
+
for (const e of await read(scope)) {
|
|
407
427
|
if (want.has(e.id))
|
|
408
428
|
out.push(e);
|
|
409
429
|
}
|
|
@@ -415,9 +435,9 @@ export class FileMemoryEngineBackend {
|
|
|
415
435
|
throw new Error(`memory retrieval view is read-only — ${op} must go through the backend itself`);
|
|
416
436
|
};
|
|
417
437
|
return {
|
|
418
|
-
listHeaders: async (scopes) => this.
|
|
419
|
-
getByIds: async (ids) => this.
|
|
420
|
-
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)),
|
|
421
441
|
applyPatches: async () => refuse("applyPatches"),
|
|
422
442
|
getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
|
|
423
443
|
setConsolidationCursor: async () => refuse("setConsolidationCursor"),
|
|
@@ -430,6 +450,8 @@ export class FileMemoryEngineBackend {
|
|
|
430
450
|
this.restrictedFindingKeys.add(key);
|
|
431
451
|
this.inboundFindings.push(finding);
|
|
432
452
|
}
|
|
453
|
+
restrictedIdlessScope;
|
|
454
|
+
restrictedIdlessBaseline;
|
|
433
455
|
readScopeCommitted(scope, audit) {
|
|
434
456
|
const dir = this.scopeDir(scope);
|
|
435
457
|
const isRoot = dir === this.directoryRoot;
|
|
@@ -450,6 +472,7 @@ export class FileMemoryEngineBackend {
|
|
|
450
472
|
});
|
|
451
473
|
this.ledger = undefined;
|
|
452
474
|
const ledger = this.loadLedger();
|
|
475
|
+
const idlessSeen = audit && scope === this.restrictedIdlessScope ? new Set() : undefined;
|
|
453
476
|
const entries = [];
|
|
454
477
|
for (const f of files) {
|
|
455
478
|
const rel = relative(this.directoryRoot, f.path);
|
|
@@ -468,8 +491,10 @@ export class FileMemoryEngineBackend {
|
|
|
468
491
|
continue;
|
|
469
492
|
}
|
|
470
493
|
const parsed = parseEntryFile(text);
|
|
471
|
-
if (parsed.id === undefined)
|
|
494
|
+
if (parsed.id === undefined) {
|
|
495
|
+
idlessSeen?.add(rel);
|
|
472
496
|
continue;
|
|
497
|
+
}
|
|
473
498
|
const entry = entryFromFile(text, parsed.id, f.slug, scope);
|
|
474
499
|
const committed = ledger[entry.id];
|
|
475
500
|
if (committed === entry.rev) {
|
|
@@ -547,6 +572,8 @@ export class FileMemoryEngineBackend {
|
|
|
547
572
|
}
|
|
548
573
|
}
|
|
549
574
|
}
|
|
575
|
+
if (idlessSeen !== undefined && this.restrictedIdlessBaseline === undefined)
|
|
576
|
+
this.restrictedIdlessBaseline = idlessSeen;
|
|
550
577
|
return entries;
|
|
551
578
|
}
|
|
552
579
|
idProjectionElsewhere(id, excludeAbsPath) {
|
|
@@ -567,6 +594,10 @@ export class FileMemoryEngineBackend {
|
|
|
567
594
|
}
|
|
568
595
|
restrictedAdoptionView(opts) {
|
|
569
596
|
const audit = opts?.audit === true;
|
|
597
|
+
if (audit) {
|
|
598
|
+
this.restrictedIdlessScope = typeof opts?.writeScope === "string" ? opts.writeScope : undefined;
|
|
599
|
+
this.restrictedIdlessBaseline = undefined;
|
|
600
|
+
}
|
|
570
601
|
const refuse = (op) => {
|
|
571
602
|
throw new Error(`memory adoption-restricted view is read-only — ${op} must go through the backend itself`);
|
|
572
603
|
};
|
|
@@ -579,9 +610,11 @@ export class FileMemoryEngineBackend {
|
|
|
579
610
|
setConsolidationCursor: async () => refuse("setConsolidationCursor"),
|
|
580
611
|
};
|
|
581
612
|
}
|
|
582
|
-
auditRestrictedDivergence(scopes) {
|
|
613
|
+
auditRestrictedDivergence(scopes, opts) {
|
|
583
614
|
for (const scope of scopes)
|
|
584
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;
|
|
585
618
|
const ledger = { ...this.loadLedger() };
|
|
586
619
|
const present = new Map();
|
|
587
620
|
let complete = true;
|
|
@@ -602,8 +635,20 @@ export class FileMemoryEngineBackend {
|
|
|
602
635
|
continue;
|
|
603
636
|
}
|
|
604
637
|
const id = parseEntryFile(text).id;
|
|
605
|
-
if (id !== undefined)
|
|
638
|
+
if (id !== undefined) {
|
|
606
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
|
+
}
|
|
607
652
|
}
|
|
608
653
|
}
|
|
609
654
|
for (const [id, paths] of present) {
|
|
@@ -637,12 +682,9 @@ export class FileMemoryEngineBackend {
|
|
|
637
682
|
}
|
|
638
683
|
}
|
|
639
684
|
async search(query, scopes, opts) {
|
|
640
|
-
return this.
|
|
641
|
-
}
|
|
642
|
-
searchWith(query, scopes, opts, adopt) {
|
|
643
|
-
return this.searchFrom(query, scopes, opts, (s) => this.readScope(s, adopt));
|
|
685
|
+
return this.searchFrom(query, scopes, opts, (s) => this.readScopeAdopting(s));
|
|
644
686
|
}
|
|
645
|
-
searchFrom(query, scopes, opts, read) {
|
|
687
|
+
async searchFrom(query, scopes, opts, read) {
|
|
646
688
|
const limit = opts?.limit ?? 20;
|
|
647
689
|
const q = termSet(query);
|
|
648
690
|
if (q.size === 0)
|
|
@@ -650,7 +692,7 @@ export class FileMemoryEngineBackend {
|
|
|
650
692
|
const scored = [];
|
|
651
693
|
for (const scope of scopes) {
|
|
652
694
|
const dir = this.scopeDir(scope);
|
|
653
|
-
for (const e of read(scope)) {
|
|
695
|
+
for (const e of await read(scope)) {
|
|
654
696
|
const haystack = `${e.frontmatter.name ?? e.slug} ${e.frontmatter.description ?? ""} ${e.body}`;
|
|
655
697
|
const d = jaccardDistance(q, haystack);
|
|
656
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
|