@sema-agent/core 5.25.0 → 5.27.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist/agents/agent-definition.js +5 -0
  3. package/dist/agents/agent-transcript-tool.d.ts +5 -2
  4. package/dist/agents/agent-transcript-tool.js +2 -1
  5. package/dist/agents/send-message-tool.d.ts +4 -1
  6. package/dist/agents/send-message-tool.js +1 -0
  7. package/dist/agents/subagent.d.ts +6 -2
  8. package/dist/agents/subagent.js +5 -0
  9. package/dist/core/checkpoint-store.d.ts +7 -2
  10. package/dist/core/hooks.d.ts +39 -4
  11. package/dist/core/hooks.js +18 -14
  12. package/dist/core/memory-engine/dual-root.js +3 -1
  13. package/dist/core/memory-engine/engine.d.ts +53 -6
  14. package/dist/core/memory-engine/engine.js +43 -11
  15. package/dist/core/memory-engine/file-backend.d.ts +81 -0
  16. package/dist/core/memory-engine/file-backend.js +250 -24
  17. package/dist/core/memory-engine/index.d.ts +1 -1
  18. package/dist/core/memory-engine/index.js +1 -1
  19. package/dist/core/memory-engine/types.d.ts +8 -1
  20. package/dist/core/memory-vector.d.ts +6 -1
  21. package/dist/core/memory-vector.js +14 -4
  22. package/dist/core/memory.js +1 -6
  23. package/dist/core/permission-rule-consent.js +8 -1
  24. package/dist/core/permission-rule-model.d.ts +70 -5
  25. package/dist/core/permission-rule-model.js +58 -0
  26. package/dist/core/runner/compaction-call-options.d.ts +4 -4
  27. package/dist/core/runner/compaction-call-options.js +3 -4
  28. package/dist/core/runner/prepare-memory.d.ts +34 -15
  29. package/dist/core/runner/prepare-memory.js +99 -26
  30. package/dist/core/runner/prepare-task.d.ts +9 -3
  31. package/dist/core/runner/prepare-task.js +60 -15
  32. package/dist/core/runner/runtask.d.ts +8 -1
  33. package/dist/core/store-contracts/tool-result-store-contract.d.ts +6 -0
  34. package/dist/core/store-contracts/tool-result-store-contract.js +24 -0
  35. package/dist/core/task-registry-agent.d.ts +4 -3
  36. package/dist/core/task-registry-agent.js +3 -3
  37. package/dist/core/task-registry-monitor.js +6 -5
  38. package/dist/core/task-registry.d.ts +6 -3
  39. package/dist/core/tool-policy.d.ts +9 -2
  40. package/dist/core/tool-result-budget.d.ts +1 -1
  41. package/dist/core/tool-result-budget.js +3 -3
  42. package/dist/core/tool-result-store.d.ts +164 -9
  43. package/dist/core/tool-result-store.js +82 -23
  44. package/dist/core/types.d.ts +103 -7
  45. package/dist/core/untrusted-text.d.ts +6 -2
  46. package/dist/core/untrusted-text.js +1 -1
  47. package/dist/engine/loop/types.d.ts +10 -3
  48. package/dist/engine/session/import-validate.js +2 -1
  49. package/dist/index.d.ts +3 -3
  50. package/dist/index.js +3 -3
  51. package/dist/orchestration/run-workflow-tool.d.ts +5 -3
  52. package/dist/orchestration/workflow.d.ts +9 -6
  53. package/dist/orchestration/workflow.js +2 -0
  54. package/dist/prompts/default.d.ts +11 -0
  55. package/dist/prompts/default.js +3 -0
  56. package/dist/stores/file/checkpoint-store.d.ts +2 -1
  57. package/dist/stores/file/fs-atomic.d.ts +1 -1
  58. package/dist/stores/file/index.d.ts +1 -1
  59. package/dist/stores/file/tool-result-store.d.ts +45 -9
  60. package/dist/stores/file/tool-result-store.js +76 -9
  61. package/dist/tools/fs/fs-shared.js +5 -4
  62. package/package.json +1 -1
@@ -34,6 +34,11 @@ export function buildMemoryInstruction(memoryDir, instructionFileName) {
34
34
  }
35
35
  export const MEMORY_RECALL_DISCIPLINE = "Before answering questions about earlier work, decisions, dates, people, or the user's preferences, look them up: `memory_search` finds entries by keyword and `memory_get` reads a full entry — the injected memory index only lists what exists. When a lookup comes up empty, say that you checked memory and found nothing instead of guessing.";
36
36
  export const MEMORY_PREFERENCE_DISCIPLINE = "When the user confirms a stored preference or fact still holds, refresh that entry's `last-confirmed: <YYYY-MM-DD>` frontmatter line (add it when absent). When you save a preference, add an `applies-when: <context>` frontmatter line naming when it applies. Both are plain frontmatter lines — write them yourself; nothing fills them in for you.";
37
+ export const MEMORY_ANNOUNCEMENT_READONLY_PLANE_CODA = "The notices immediately above concern a READ-ONLY memory store: any guidance in them to record, update, or tombstone an entry cannot be applied to that store this session — surface it to the user instead of claiming it done.";
38
+ export const MEMORY_ANNOUNCEMENT_READONLY_CODA = "The memory store itself is not writable this session, so any guidance above to record, update, or tombstone a memory entry cannot be applied here — surface it to the user instead of claiming it done.";
39
+ export const MEMORY_READONLY_NOTICE = `# Memory
40
+
41
+ You have READ-ONLY access to persistent memory in this session: stored notes are available below, but this session has no memory write channel — the engine will not accept writes into the memory store. If the user asks you to remember something for later, say plainly that you cannot save it in this session — never claim to have noted or remembered it.`;
37
42
  export const MEMORY_INDEX_MAX_LINES = 200;
38
43
  export const MEMORY_INDEX_MAX_BYTES = 25 * 1024;
39
44
  export const STUB_ARCHIVED_LINE = "[body archived — request hydration by listing the slug in memory/.hydrate]";
@@ -41,6 +46,7 @@ export const DEFAULT_MAX_MEMORY_FILES = 500;
41
46
  export const DEFAULT_HARVEST_DEADLINE_MS = 5_000;
42
47
  export const DEFAULT_HARVEST_FILE_BUDGET = 2_000;
43
48
  export const MASS_DELETION_FUSE_RATIO = 0.5;
49
+ let indexCaptureSeq = 0;
44
50
  export class MemoryEngine {
45
51
  backend;
46
52
  memoryDir;
@@ -317,9 +323,13 @@ export class MemoryEngine {
317
323
  catch {
318
324
  }
319
325
  }
320
- async materialize(scopes, writeScope) {
326
+ async materialize(scopes, writeScope, opts) {
321
327
  ensureDirExists(this.memoryDir);
322
328
  ensureDirExists(this.controlDir);
329
+ const restricted = opts?.adoptionRestricted === true;
330
+ const readBackend = restricted
331
+ ? (this.backend.restrictedAdoptionView?.({ audit: true }) ?? this.backend)
332
+ : this.backend;
323
333
  if (writeScope !== null)
324
334
  claimRootScope(this.controlDir, writeScope);
325
335
  const scopeDirs = new Map();
@@ -330,8 +340,8 @@ export class MemoryEngine {
330
340
  for (const [scope, dir] of scopeDirs)
331
341
  this.chmodScopeTree(dir, 0o755, 0o644, { excludeTopDirs: this.siblingScopeDirNames(dir, scope) });
332
342
  const allScopes = [...scopeDirs.keys()];
333
- const headers = await this.backend.listHeaders(allScopes);
334
- const entries = await this.backend.getByIds(headers.map((h) => h.id));
343
+ const headers = await readBackend.listHeaders(allScopes);
344
+ const entries = await readBackend.getByIds(headers.map((h) => h.id));
335
345
  const byScope = new Map();
336
346
  for (const e of entries) {
337
347
  const arr = byScope.get(e.scope) ?? [];
@@ -351,6 +361,7 @@ export class MemoryEngine {
351
361
  repoIndexTargets: new Map(),
352
362
  indexBaselineLines: 0,
353
363
  indexText: "",
364
+ ...(restricted ? { adoptionRestricted: true } : {}),
354
365
  };
355
366
  if (writeScope !== null)
356
367
  ensureDirExists(handle.writableRoot);
@@ -376,7 +387,8 @@ export class MemoryEngine {
376
387
  const { deleted: _stubDrop, ...stubFm } = entry.frontmatter;
377
388
  const text = stub ? serializeEntryFile({ id: entry.id, frontmatter: stubFm, body: STUB_ARCHIVED_LINE }) : fullText;
378
389
  const target = join(dir, `${entry.slug}.md`);
379
- this.writeIfChanged(target, text);
390
+ if (!(zeroCopy && restricted))
391
+ this.writeIfChanged(target, text);
380
392
  const onDiskRev = stub ? revOfText(text, entry.id) : entry.rev;
381
393
  const path = canonicalize(target);
382
394
  handle.materialized.push({
@@ -409,7 +421,7 @@ export class MemoryEngine {
409
421
  this.discloseAnnounceFailure("index-gate enqueue", err);
410
422
  }
411
423
  }
412
- const indexText = this.rebuildIndex(handle, headers, { write: writeScope !== null, ignoreOnDisk: indexGate !== undefined });
424
+ const indexText = this.rebuildIndex(handle, headers, { write: writeScope !== null && !restricted, ignoreOnDisk: indexGate !== undefined || restricted });
413
425
  handle.indexBaselineLines = countIndexLines(indexText);
414
426
  handle.indexText = indexText;
415
427
  for (const [scope, dir] of scopeDirs) {
@@ -425,8 +437,9 @@ export class MemoryEngine {
425
437
  inject(handle, opts) {
426
438
  const writeChannel = handle.writeScope !== null && opts?.writeToolMounted !== false;
427
439
  const instruction = writeChannel ? buildMemoryInstruction(handle.writableRoot) : "";
440
+ const readOnlyNotice = handle.writeScope === null ? MEMORY_READONLY_NOTICE : undefined;
428
441
  const indexPath = join(handle.writableRoot, MEMORY_INDEX_FILENAME);
429
- const onDisk = handle.indexOnDiskUntrusted === true ? undefined : readSafe(indexPath);
442
+ const onDisk = handle.indexOnDiskUntrusted === true || handle.adoptionRestricted === true ? undefined : readSafe(indexPath);
430
443
  const indexText = onDisk !== undefined && onDisk.trim() !== "" ? onDisk : handle.indexText;
431
444
  const truncated = truncateIndex(indexText);
432
445
  const index = composeMemoryBlock(truncated, handle.writeScope ?? handle.scopes[0] ?? "memory");
@@ -440,14 +453,17 @@ export class MemoryEngine {
440
453
  if (drained.queue.length > 0 || drained.folded > 0) {
441
454
  announcements = drained.queue;
442
455
  announceBlock = renderAnnouncements(drained.queue, drained.folded);
456
+ if (handle.writeScope === null)
457
+ announceBlock = `${announceBlock}\n\n${MEMORY_ANNOUNCEMENT_READONLY_PLANE_CODA}`;
443
458
  }
444
459
  }
445
460
  catch (err) {
446
461
  this.discloseAnnounceFailure("inject drain", err);
447
462
  }
448
- const block = [instruction, index, announceBlock].filter((s) => Boolean(s && s.trim())).join("\n\n");
463
+ const block = [instruction || readOnlyNotice, index, announceBlock].filter((s) => Boolean(s && s.trim())).join("\n\n");
449
464
  return {
450
465
  instruction,
466
+ ...(readOnlyNotice !== undefined ? { readOnlyNotice } : {}),
451
467
  ...(index !== undefined ? { index } : {}),
452
468
  ...(announcements !== undefined ? { announcements } : {}),
453
469
  ...(announceBlock !== undefined ? { announceBlock } : {}),
@@ -459,8 +475,14 @@ export class MemoryEngine {
459
475
  const root = handle.writableRoot;
460
476
  if (canonicalPath !== root && !canonicalPath.startsWith(`${root}${sep}`))
461
477
  return { ok: true };
462
- if (handle.writeScope === null)
463
- return { ok: true };
478
+ if (handle.writeScope === null) {
479
+ return {
480
+ ok: false,
481
+ code: "read_only_layering",
482
+ reason: "this session's memory is read-only (no write scope) — the engine does not accept writes into the memory domain. Nothing was written.",
483
+ muted: false,
484
+ };
485
+ }
464
486
  const findings = [];
465
487
  const nameFinding = scanMemoryFileName(relative(root, canonicalPath));
466
488
  if (nameFinding !== undefined)
@@ -512,10 +534,20 @@ export class MemoryEngine {
512
534
  warnings: [],
513
535
  };
514
536
  const writeScope = handle.writeScope;
515
- if (writeScope === null) {
537
+ if (writeScope === null || opts?.admitNothing !== undefined) {
538
+ try {
539
+ this.backend.auditRestrictedDivergence?.([
540
+ ...new Set([...handle.scopes, ...(writeScope !== null ? [writeScope] : [])]),
541
+ ]);
542
+ }
543
+ catch (err) {
544
+ report.warnings.push(`restricted-session divergence audit failed: ${err instanceof Error ? err.message : String(err)}`);
545
+ }
516
546
  const roFindings = this.backend.drainInboundFindings?.();
517
547
  if (roFindings !== undefined && roFindings.length > 0)
518
548
  report.inboundFindings = roFindings;
549
+ if (opts?.admitNothing !== undefined)
550
+ report.warnings.push(opts.admitNothing.reason);
519
551
  return report;
520
552
  }
521
553
  try {
@@ -1240,7 +1272,7 @@ export class MemoryEngine {
1240
1272
  }
1241
1273
  }
1242
1274
  if (!landed)
1243
- writeFileSync(`${dest}.${process.pid}`, text, { encoding: "utf8", flag: "wx" });
1275
+ writeFileSync(`${dest}.${process.pid}.${indexCaptureSeq++}`, text, { encoding: "utf8", flag: "wx" });
1244
1276
  captured = true;
1245
1277
  }
1246
1278
  catch (err) {
@@ -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;
@@ -142,8 +151,10 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
142
151
  private readScope;
143
152
  listHeaders(scopes: readonly string[]): Promise<MemoryEntryHeader[]>;
144
153
  private listHeadersWith;
154
+ private listHeadersFrom;
145
155
  getByIds(ids: readonly string[]): Promise<MemoryEntry[]>;
146
156
  private getByIdsWith;
157
+ private getByIdsFrom;
147
158
  /**
148
159
  * design/178 §3 (ruled 2026-08-08) — a SIDE-EFFECT-FREE projection of this backend for the session's
149
160
  * RETRIEVAL face (`memory_search` / `memory_get`).
@@ -162,11 +173,81 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
162
173
  * WRITES ARE REFUSED, loudly — a view handed to a write path is a defect, not a fallback.
163
174
  */
164
175
  retrievalView(): MemoryBackend;
176
+ /** Dedup keys for divergence findings the restricted view already recorded on THIS instance: one
177
+ * materialize reads a scope several times (listHeaders, then getByIds per scope), and a
178
+ * checkpoint-cadence harvest re-audits the same still-pending state — without the key, one
179
+ * untouched divergence would mint a finding per read. Per-process on purpose (no durable state to
180
+ * lifecycle: a cross-process resume re-discloses once, which is the desired "still pending"
181
+ * notice); a divergence whose bytes CHANGE mints a new key and is reported again. */
182
+ private restrictedFindingKeys;
183
+ private recordRestrictedFinding;
184
+ /**
185
+ * The ADOPTION-RESTRICTED read of one scope: serve the COMMITTED account (ledger + control-plane
186
+ * shadow) and never advance it. This is the tamper boundary for a session with no write intent:
187
+ * the ordinary `readScope(sync=true)` ADOPTS disk divergence as the new committed baseline
188
+ * regardless of session intent, so a byte landed by a side channel (e.g. a shell write under a
189
+ * session whose persistence is declared unavailable, or into a read-only layering) would be
190
+ * committed by the very session that was told it cannot persist — its own checkpoint-resume
191
+ * materialize included. Here instead:
192
+ * - disk rev == committed rev ⇒ served as-is (it IS the committed content);
193
+ * - divergence whose bytes equal the committed SHADOW ⇒ served as-is: the shadow is written at
194
+ * the applyPatches commit point, so matching it is transactional proof that a concurrent
195
+ * writable session committed this state and only this call's ledger snapshot raced it —
196
+ * never treated as tampering;
197
+ * - other divergence of a shadow-backed entry ⇒ the SHADOW (committed content) is served; the
198
+ * divergent disk bytes stay in place UNLESS they fail the inbound gate (the existing
199
+ * quarantine door, unchanged under restriction). Scan-clean divergence is never quarantined,
200
+ * overwritten, or adopted — it stays on disk as deferred external state a later unrestricted
201
+ * session adjudicates normally;
202
+ * - a divergent entry with NO shadow (shadow-less legacy), and a never-committed id-bearing
203
+ * file, have no committed content to serve ⇒ WITHHELD with a loud finding, never served from
204
+ * disk, never adopted.
205
+ * The ledger is reloaded from disk on every call (a stale cache would misread a concurrent commit
206
+ * as divergence) and NEVER written here; the only writes this path may perform are the
207
+ * gate-failing containment above and a shadow backfill for a rev-matching entry (recording
208
+ * committed content — the account itself does not move).
209
+ *
210
+ * `audit` picks the mount: true = the engine's materialize/harvest boundary (findings recorded,
211
+ * gate-failing divergence contained); false = the retrieval-tool face (a PURE committed read —
212
+ * no findings, no containment, no writes: a model lookup must be side-effect-free).
213
+ */
214
+ private readScopeCommitted;
215
+ /** Does `id` have a projection file anywhere in the store OTHER than `excludeAbsPath`? Returns
216
+ * that projection's rel path, or undefined. Early-exits on the first hit; only consulted on the
217
+ * rare divergent-with-shadow path (the scope-binding guard above), never on clean reads. */
218
+ private idProjectionElsewhere;
219
+ /**
220
+ * The ADOPTION-RESTRICTED projection of this backend (see {@link readScopeCommitted}) — what a
221
+ * session with no write intent materializes and retrieves through. The CALLER decides restriction
222
+ * (an explicit verdict: a read-only layering, or persistence declared unavailable) and mounts this
223
+ * view; the backend never guesses a session's intent from its own state.
224
+ * - `audit: true` (the engine's materialize/harvest mount): divergence findings are recorded for
225
+ * the harvest report + announcement queue, and gate-FAILING divergence is contained by the
226
+ * existing quarantine door;
227
+ * - `audit: false` (the retrieval-tool mount): a pure committed-view read — no findings, no
228
+ * containment, no writes.
229
+ * WRITES ARE REFUSED loudly — same posture as {@link retrievalView}.
230
+ */
231
+ restrictedAdoptionView(opts?: {
232
+ audit?: boolean;
233
+ }): MemoryBackend;
234
+ /**
235
+ * The harvest-boundary divergence sweep for an adoption-restricted session (the engine's
236
+ * zero-admission harvest arm calls this BEFORE draining findings): re-run the committed-view audit
237
+ * over the session's scopes — bytes landed AFTER materialize (a mid-session shell write) are
238
+ * detected here, at the very boundary where an unrestricted harvest would have admitted them —
239
+ * then check the committed account for entries MISSING from disk entirely (an unattributed delete
240
+ * is divergence too). Ledger rows carry no scope, so presence is judged across every registered
241
+ * scope, the same resolution `getByIds` uses; a rename or cross-scope move therefore never reads
242
+ * as missing. Non-destructive throughout: nothing is restored, forgotten, or adopted here.
243
+ */
244
+ auditRestrictedDivergence(scopes: readonly string[]): void;
165
245
  /** Lexical floor (design/138 §2.1): `1 - Jaccard` distance over name+description+body, ascending. */
166
246
  search(query: string, scopes: readonly string[], opts?: {
167
247
  limit?: number;
168
248
  }): Promise<ScoredMemoryEntry[]>;
169
249
  private searchWith;
250
+ private searchFrom;
170
251
  /**
171
252
  * H4 — the entry transaction, staged and journaled:
172
253
  * 1. RELOAD the ledger from disk (验收 M2: a concurrently-committed transaction from another
@@ -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)
@@ -288,27 +311,7 @@ export class FileMemoryEngineBackend {
288
311
  }
289
312
  }
290
313
  if (finding) {
291
- this.inboundFindings.push(finding);
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
- }
314
+ const shadowText = this.containInboundReject(finding, f.path, text, entry.id, committed);
312
315
  if (shadowText === undefined) {
313
316
  if (committed !== undefined) {
314
317
  delete ledger[entry.id];
@@ -316,6 +319,7 @@ export class FileMemoryEngineBackend {
316
319
  }
317
320
  continue;
318
321
  }
322
+ entry = entryFromFile(shadowText, entry.id, f.slug, scope);
319
323
  }
320
324
  else {
321
325
  if (committed !== undefined)
@@ -377,10 +381,13 @@ export class FileMemoryEngineBackend {
377
381
  return this.listHeadersWith(scopes, true);
378
382
  }
379
383
  listHeadersWith(scopes, adopt) {
384
+ return this.listHeadersFrom(scopes, (s) => this.readScope(s, adopt));
385
+ }
386
+ listHeadersFrom(scopes, read) {
380
387
  const out = [];
381
388
  for (const scope of scopes) {
382
389
  const dir = this.scopeDir(scope);
383
- for (const e of this.readScope(scope, adopt)) {
390
+ for (const e of read(scope)) {
384
391
  out.push(headerOf(e, join(dir, `${e.slug}.md`)));
385
392
  }
386
393
  }
@@ -390,10 +397,13 @@ export class FileMemoryEngineBackend {
390
397
  return this.getByIdsWith(ids, true);
391
398
  }
392
399
  getByIdsWith(ids, adopt) {
400
+ return this.getByIdsFrom(ids, (s) => this.readScope(s, adopt));
401
+ }
402
+ getByIdsFrom(ids, read) {
393
403
  const want = new Set(ids);
394
404
  const out = [];
395
405
  for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
396
- for (const e of this.readScope(scope, adopt)) {
406
+ for (const e of read(scope)) {
397
407
  if (want.has(e.id))
398
408
  out.push(e);
399
409
  }
@@ -413,10 +423,226 @@ export class FileMemoryEngineBackend {
413
423
  setConsolidationCursor: async () => refuse("setConsolidationCursor"),
414
424
  };
415
425
  }
426
+ restrictedFindingKeys = new Set();
427
+ recordRestrictedFinding(key, finding) {
428
+ if (this.restrictedFindingKeys.has(key))
429
+ return;
430
+ this.restrictedFindingKeys.add(key);
431
+ this.inboundFindings.push(finding);
432
+ }
433
+ readScopeCommitted(scope, audit) {
434
+ const dir = this.scopeDir(scope);
435
+ const isRoot = dir === this.directoryRoot;
436
+ const files = scanEntryFiles(dir, {
437
+ exclude: isRoot ? this.excludedSubdirNames(scope) : undefined,
438
+ onSkip: audit
439
+ ? (p, kind) => {
440
+ if (kind !== "unreadable")
441
+ return;
442
+ const rel = relative(this.directoryRoot, p);
443
+ this.recordRestrictedFinding(`unreadable|${rel}`, {
444
+ path: rel,
445
+ code: "unreadable",
446
+ reason: "the path could not be read (lstat/readdir failed) — the restricted-session divergence audit is incomplete under it",
447
+ });
448
+ }
449
+ : undefined,
450
+ });
451
+ this.ledger = undefined;
452
+ const ledger = this.loadLedger();
453
+ const entries = [];
454
+ for (const f of files) {
455
+ const rel = relative(this.directoryRoot, f.path);
456
+ let text;
457
+ try {
458
+ text = readFileSync(f.path, "utf8");
459
+ }
460
+ catch {
461
+ if (audit) {
462
+ this.recordRestrictedFinding(`unreadable|${rel}`, {
463
+ path: rel,
464
+ code: "unreadable",
465
+ reason: "memory entry file could not be read — it is missing from this restricted session's view",
466
+ });
467
+ }
468
+ continue;
469
+ }
470
+ const parsed = parseEntryFile(text);
471
+ if (parsed.id === undefined)
472
+ continue;
473
+ const entry = entryFromFile(text, parsed.id, f.slug, scope);
474
+ const committed = ledger[entry.id];
475
+ if (committed === entry.rev) {
476
+ if (audit && this.readCommittedShadow(entry.id) === undefined) {
477
+ try {
478
+ atomicWriteFileSync(this.shadowPath(entry.id), text);
479
+ }
480
+ catch {
481
+ }
482
+ }
483
+ entries.push(entry);
484
+ continue;
485
+ }
486
+ const shadowText = committed !== undefined ? this.readCommittedShadow(entry.id) : undefined;
487
+ if (shadowText !== undefined) {
488
+ const shadowEntry = entryFromFile(shadowText, entry.id, f.slug, scope);
489
+ if (shadowEntry.rev === entry.rev) {
490
+ entries.push(entry);
491
+ continue;
492
+ }
493
+ const otherProjection = this.idProjectionElsewhere(entry.id, f.path);
494
+ if (otherProjection !== undefined) {
495
+ if (audit) {
496
+ const finding = this.inboundGate(rel, text);
497
+ if (finding !== undefined) {
498
+ this.containInboundReject(finding, f.path, text, entry.id, undefined);
499
+ }
500
+ else {
501
+ this.recordRestrictedFinding(`dupserve|${rel}|${entry.rev}`, {
502
+ path: rel,
503
+ code: "restricted_divergence",
504
+ 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`,
505
+ });
506
+ }
507
+ }
508
+ continue;
509
+ }
510
+ if (audit) {
511
+ const finding = this.inboundGate(rel, text);
512
+ if (finding !== undefined) {
513
+ this.containInboundReject(finding, f.path, text, entry.id, committed);
514
+ }
515
+ else {
516
+ this.recordRestrictedFinding(`divergence|${rel}|${entry.rev}`, {
517
+ path: rel,
518
+ code: "restricted_divergence",
519
+ 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",
520
+ });
521
+ }
522
+ }
523
+ entries.push(shadowEntry);
524
+ continue;
525
+ }
526
+ if (committed !== undefined) {
527
+ if (audit) {
528
+ this.recordRestrictedFinding(`legacy|${rel}|${entry.rev}`, {
529
+ path: rel,
530
+ code: "restricted_divergence",
531
+ 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",
532
+ });
533
+ }
534
+ continue;
535
+ }
536
+ if (audit) {
537
+ const finding = this.inboundGate(rel, text);
538
+ if (finding !== undefined) {
539
+ this.containInboundReject(finding, f.path, text, entry.id, undefined);
540
+ }
541
+ else {
542
+ this.recordRestrictedFinding(`uncommitted|${rel}|${entry.rev}`, {
543
+ path: rel,
544
+ code: "restricted_divergence",
545
+ 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",
546
+ });
547
+ }
548
+ }
549
+ }
550
+ return entries;
551
+ }
552
+ idProjectionElsewhere(id, excludeAbsPath) {
553
+ for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
554
+ const dir = this.scopeDir(scope);
555
+ const isRoot = dir === this.directoryRoot;
556
+ for (const f of scanEntryFiles(dir, { exclude: isRoot ? this.excludedSubdirNames(scope) : undefined })) {
557
+ if (f.path === excludeAbsPath)
558
+ continue;
559
+ const text = readSafe(f.path);
560
+ if (text === undefined)
561
+ continue;
562
+ if (parseEntryFile(text).id === id)
563
+ return relative(this.directoryRoot, f.path);
564
+ }
565
+ }
566
+ return undefined;
567
+ }
568
+ restrictedAdoptionView(opts) {
569
+ const audit = opts?.audit === true;
570
+ const refuse = (op) => {
571
+ throw new Error(`memory adoption-restricted view is read-only — ${op} must go through the backend itself`);
572
+ };
573
+ return {
574
+ listHeaders: async (scopes) => this.listHeadersFrom(scopes, (s) => this.readScopeCommitted(s, audit)),
575
+ getByIds: async (ids) => this.getByIdsFrom(ids, (s) => this.readScopeCommitted(s, audit)),
576
+ search: async (query, scopes, o) => this.searchFrom(query, scopes, o, (s) => this.readScopeCommitted(s, audit)),
577
+ applyPatches: async () => refuse("applyPatches"),
578
+ getConsolidationCursor: async (scope) => this.getConsolidationCursor(scope),
579
+ setConsolidationCursor: async () => refuse("setConsolidationCursor"),
580
+ };
581
+ }
582
+ auditRestrictedDivergence(scopes) {
583
+ for (const scope of scopes)
584
+ this.readScopeCommitted(scope, true);
585
+ const ledger = { ...this.loadLedger() };
586
+ const present = new Map();
587
+ let complete = true;
588
+ for (const scope of Object.keys(registeredScopes(this.controlPlaneRoot))) {
589
+ const dir = this.scopeDir(scope);
590
+ const isRoot = dir === this.directoryRoot;
591
+ const files = scanEntryFiles(dir, {
592
+ exclude: isRoot ? this.excludedSubdirNames(scope) : undefined,
593
+ onSkip: (_p, kind) => {
594
+ if (kind === "unreadable")
595
+ complete = false;
596
+ },
597
+ });
598
+ for (const f of files) {
599
+ const text = readSafe(f.path);
600
+ if (text === undefined) {
601
+ complete = false;
602
+ continue;
603
+ }
604
+ const id = parseEntryFile(text).id;
605
+ if (id !== undefined)
606
+ present.set(id, [...(present.get(id) ?? []), relative(this.directoryRoot, f.path)]);
607
+ }
608
+ }
609
+ for (const [id, paths] of present) {
610
+ if (paths.length < 2 || ledger[id] === undefined)
611
+ continue;
612
+ this.recordRestrictedFinding(`dup|${id}|${paths.sort().join(",")}`, {
613
+ path: paths[0] ?? id,
614
+ code: "restricted_divergence",
615
+ 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`,
616
+ });
617
+ }
618
+ if (!complete) {
619
+ this.recordRestrictedFinding(`missing-audit-incomplete`, {
620
+ path: ".",
621
+ code: "restricted_divergence",
622
+ 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",
623
+ });
624
+ return;
625
+ }
626
+ for (const [id, rev] of Object.entries(ledger)) {
627
+ if (present.has(id))
628
+ continue;
629
+ this.ledger = undefined;
630
+ if (this.loadLedger()[id] === undefined)
631
+ continue;
632
+ this.recordRestrictedFinding(`missing|${id}|${rev}`, {
633
+ path: `id:${id}`,
634
+ code: "restricted_divergence",
635
+ 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`,
636
+ });
637
+ }
638
+ }
416
639
  async search(query, scopes, opts) {
417
640
  return this.searchWith(query, scopes, opts, true);
418
641
  }
419
642
  searchWith(query, scopes, opts, adopt) {
643
+ return this.searchFrom(query, scopes, opts, (s) => this.readScope(s, adopt));
644
+ }
645
+ searchFrom(query, scopes, opts, read) {
420
646
  const limit = opts?.limit ?? 20;
421
647
  const q = termSet(query);
422
648
  if (q.size === 0)
@@ -424,7 +650,7 @@ export class FileMemoryEngineBackend {
424
650
  const scored = [];
425
651
  for (const scope of scopes) {
426
652
  const dir = this.scopeDir(scope);
427
- for (const e of this.readScope(scope, adopt)) {
653
+ for (const e of read(scope)) {
428
654
  const haystack = `${e.frontmatter.name ?? e.slug} ${e.frontmatter.description ?? ""} ${e.body}`;
429
655
  const d = jaccardDistance(q, haystack);
430
656
  if (d === null)
@@ -1,4 +1,4 @@
1
- export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, type MemoryEngineOptions, type MemoryInjection, } from "./engine.js";
1
+ export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_READONLY_NOTICE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, type MemoryEngineOptions, type MemoryInjection, } from "./engine.js";
2
2
  export { MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_ENGINE_TOOL_NAMES, type MemorySearchDetails, type MemorySearchHit, type MemoryGetDetails, } from "./tools.js";
3
3
  export { scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_SEGMENT_RE } from "./scan.js";
4
4
  export { FileMemoryEngineBackend, scanEntryFiles, MEMORY_INDEX_FILENAME, DEFAULT_MAX_ENTRY_DEPTH, type ScannedEntryFile } from "./file-backend.js";
@@ -1,4 +1,4 @@
1
- export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, } from "./engine.js";
1
+ export { MemoryEngine, buildMemoryInstruction, truncateIndex, MEMORY_INSTRUCTION_TEMPLATE, MEMORY_RECALL_DISCIPLINE, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_READONLY_NOTICE, MEMORY_INDEX_MAX_LINES, MEMORY_INDEX_MAX_BYTES, STUB_ARCHIVED_LINE, DEFAULT_MAX_MEMORY_FILES, DEFAULT_HARVEST_DEADLINE_MS, DEFAULT_HARVEST_FILE_BUDGET, MASS_DELETION_FUSE_RATIO, renderAnnouncements, } from "./engine.js";
2
2
  export { MEMORY_SEARCH_TOOL_NAME, MEMORY_GET_TOOL_NAME, MEMORY_ENGINE_TOOL_NAMES, } from "./tools.js";
3
3
  export { scanMemoryWrite, scanMemoryFileName, scanRemediation, MEMORY_FILENAME_SEGMENT_RE } from "./scan.js";
4
4
  export { FileMemoryEngineBackend, scanEntryFiles, MEMORY_INDEX_FILENAME, DEFAULT_MAX_ENTRY_DEPTH } from "./file-backend.js";
@@ -223,9 +223,16 @@ export interface MemorySessionHandle {
223
223
  * the gate's whole purpose is keeping those bytes out of the prompt, so a refused clear must not
224
224
  * leave the injection path reading them anyway. Absent ⇒ the normal "live file wins" behavior. */
225
225
  indexOnDiskUntrusted?: boolean;
226
+ /** True ⇔ this session materialized through the ADOPTION-RESTRICTED (committed-view) read face:
227
+ * either the plane is a read-only layering (`writeScope === null`) or the caller declared the
228
+ * session unable to persist (an explicit verdict — never inferred down here). Restricted sessions
229
+ * read committed state only (ledger + control-plane shadow); disk divergence with no transaction
230
+ * backing is neither adopted into the committed account nor served, and `inject` reads the
231
+ * materialize-time index text instead of the live on-disk file. */
232
+ adoptionRestricted?: boolean;
226
233
  }
227
234
  /** Stable rejection codes a harvest gate can produce (model-visible gate events — 镜头 I). */
228
- export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "polluted" | "invalid";
235
+ export type HarvestRejectionCode = "outside_root" | "symlink" | "secret" | "injection" | "filename" | "too_large" | "file_cap" | "readonly_layer" | "stub_modified" | "nested_too_deep" | "quarantine_failed" | "unreadable" | "polluted" | "restricted_divergence" | "invalid";
229
236
  /** One rejected file: path (relative to the memory dir), stable code, and a model-readable reason. */
230
237
  export interface HarvestRejection {
231
238
  path: string;
@@ -13,7 +13,12 @@
13
13
  * MySQL is NOT "can't support vectors" — it is `portable`-capable (JSON column + in-process cosine), just not
14
14
  * `native`. A deployment injects an embedder (config-driven); the store then reports the achieved rung.
15
15
  */
16
- /** Lower-cased alphanumeric term set of a string (for the lexical stand-in distance). */
16
+ /** Lower-cased lexical term set: alphanumeric runs PLUS CJK character bigrams (a single-character
17
+ * run contributes its unigram). CJK scripts have no `a-z0-9` runs at all, so an alphanumeric-only
18
+ * tokenizer made every pure-CJK entry an EMPTY set — never a lexical-rung candidate for any query
19
+ * — and a pure-CJK query returned nothing; character bigrams are the standard analyzer unit there
20
+ * (word boundaries are not written). Bigrams stay within one run: adjacency across an intervening
21
+ * non-CJK character is not real adjacency. */
17
22
  export declare function termSet(s: string): Set<string>;
18
23
  /** Lexical stand-in distance: `1 - Jaccard(terms)` ∈ [0,1] ⊂ [0,2]; `null` = no overlap (not a candidate). */
19
24
  export declare function jaccardDistance(query: Set<string>, text: string): number | null;