@sema-agent/core 5.24.0 → 5.26.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 (54) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/dist/agents/agent-definition.js +5 -0
  3. package/dist/agents/send-message-tool.js +1 -0
  4. package/dist/agents/subagent.d.ts +1 -0
  5. package/dist/agents/subagent.js +5 -0
  6. package/dist/core/checkpoint-store.d.ts +47 -8
  7. package/dist/core/checkpoint-store.js +1 -0
  8. package/dist/core/hooks.d.ts +12 -5
  9. package/dist/core/hooks.js +22 -4
  10. package/dist/core/memory-engine/dual-root.js +3 -1
  11. package/dist/core/memory-engine/engine.d.ts +45 -1
  12. package/dist/core/memory-engine/engine.js +40 -7
  13. package/dist/core/memory-engine/index.d.ts +1 -1
  14. package/dist/core/memory-engine/index.js +1 -1
  15. package/dist/core/permission-rule-consent.js +8 -1
  16. package/dist/core/permission-rule-org.d.ts +9 -0
  17. package/dist/core/permission-rule-org.js +12 -5
  18. package/dist/core/runner/compaction-call-options.d.ts +4 -4
  19. package/dist/core/runner/compaction-call-options.js +3 -4
  20. package/dist/core/runner/prepare-memory.d.ts +34 -15
  21. package/dist/core/runner/prepare-memory.js +85 -17
  22. package/dist/core/runner/prepare-task.d.ts +2 -0
  23. package/dist/core/runner/prepare-task.js +63 -11
  24. package/dist/core/runner/runtask.js +25 -8
  25. package/dist/core/store-contracts/tool-result-store-contract.d.ts +6 -0
  26. package/dist/core/store-contracts/tool-result-store-contract.js +24 -0
  27. package/dist/core/task-registry-agent.js +3 -3
  28. package/dist/core/task-registry-monitor.js +6 -5
  29. package/dist/core/tool-policy.d.ts +11 -0
  30. package/dist/core/tool-result-budget.d.ts +1 -1
  31. package/dist/core/tool-result-budget.js +3 -3
  32. package/dist/core/tool-result-store.d.ts +164 -9
  33. package/dist/core/tool-result-store.js +82 -23
  34. package/dist/core/types.d.ts +68 -0
  35. package/dist/core/untrusted-text.d.ts +6 -2
  36. package/dist/core/untrusted-text.js +1 -1
  37. package/dist/engine/session/import-validate.js +2 -1
  38. package/dist/index.d.ts +4 -4
  39. package/dist/index.js +4 -4
  40. package/dist/orchestration/workflow.js +2 -0
  41. package/dist/prompts/default.d.ts +11 -0
  42. package/dist/prompts/default.js +3 -0
  43. package/dist/stores/file/adoption/adopt.d.ts +23 -3
  44. package/dist/stores/file/adoption/adopt.js +1 -0
  45. package/dist/stores/file/adoption/marker.d.ts +26 -11
  46. package/dist/stores/file/fs-atomic.d.ts +1 -1
  47. package/dist/stores/file/permission-rule-store.d.ts +15 -1
  48. package/dist/stores/file/permission-rule-store.js +4 -1
  49. package/dist/stores/file/task-list-store.d.ts +15 -1
  50. package/dist/stores/file/task-list-store.js +2 -2
  51. package/dist/stores/file/tool-result-store.d.ts +45 -9
  52. package/dist/stores/file/tool-result-store.js +76 -9
  53. package/dist/tools/fs/fs-shared.js +26 -9
  54. 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;
@@ -425,6 +431,7 @@ export class MemoryEngine {
425
431
  inject(handle, opts) {
426
432
  const writeChannel = handle.writeScope !== null && opts?.writeToolMounted !== false;
427
433
  const instruction = writeChannel ? buildMemoryInstruction(handle.writableRoot) : "";
434
+ const readOnlyNotice = handle.writeScope === null ? MEMORY_READONLY_NOTICE : undefined;
428
435
  const indexPath = join(handle.writableRoot, MEMORY_INDEX_FILENAME);
429
436
  const onDisk = handle.indexOnDiskUntrusted === true ? undefined : readSafe(indexPath);
430
437
  const indexText = onDisk !== undefined && onDisk.trim() !== "" ? onDisk : handle.indexText;
@@ -440,14 +447,17 @@ export class MemoryEngine {
440
447
  if (drained.queue.length > 0 || drained.folded > 0) {
441
448
  announcements = drained.queue;
442
449
  announceBlock = renderAnnouncements(drained.queue, drained.folded);
450
+ if (handle.writeScope === null)
451
+ announceBlock = `${announceBlock}\n\n${MEMORY_ANNOUNCEMENT_READONLY_PLANE_CODA}`;
443
452
  }
444
453
  }
445
454
  catch (err) {
446
455
  this.discloseAnnounceFailure("inject drain", err);
447
456
  }
448
- const block = [instruction, index, announceBlock].filter((s) => Boolean(s && s.trim())).join("\n\n");
457
+ const block = [instruction || readOnlyNotice, index, announceBlock].filter((s) => Boolean(s && s.trim())).join("\n\n");
449
458
  return {
450
459
  instruction,
460
+ ...(readOnlyNotice !== undefined ? { readOnlyNotice } : {}),
451
461
  ...(index !== undefined ? { index } : {}),
452
462
  ...(announcements !== undefined ? { announcements } : {}),
453
463
  ...(announceBlock !== undefined ? { announceBlock } : {}),
@@ -459,8 +469,14 @@ export class MemoryEngine {
459
469
  const root = handle.writableRoot;
460
470
  if (canonicalPath !== root && !canonicalPath.startsWith(`${root}${sep}`))
461
471
  return { ok: true };
462
- if (handle.writeScope === null)
463
- return { ok: true };
472
+ if (handle.writeScope === null) {
473
+ return {
474
+ ok: false,
475
+ code: "read_only_layering",
476
+ reason: "this session's memory is read-only (no write scope) — the engine does not accept writes into the memory domain. Nothing was written.",
477
+ muted: false,
478
+ };
479
+ }
464
480
  const findings = [];
465
481
  const nameFinding = scanMemoryFileName(relative(root, canonicalPath));
466
482
  if (nameFinding !== undefined)
@@ -512,10 +528,12 @@ export class MemoryEngine {
512
528
  warnings: [],
513
529
  };
514
530
  const writeScope = handle.writeScope;
515
- if (writeScope === null) {
531
+ if (writeScope === null || opts?.admitNothing !== undefined) {
516
532
  const roFindings = this.backend.drainInboundFindings?.();
517
533
  if (roFindings !== undefined && roFindings.length > 0)
518
534
  report.inboundFindings = roFindings;
535
+ if (opts?.admitNothing !== undefined)
536
+ report.warnings.push(opts.admitNothing.reason);
519
537
  return report;
520
538
  }
521
539
  try {
@@ -656,16 +674,18 @@ export class MemoryEngine {
656
674
  const indexNow = readSafe(pollutedIndexPath);
657
675
  if (indexNow === undefined || indexNow === handle.indexText)
658
676
  return;
677
+ let indexCaptureLanded = false;
659
678
  try {
660
679
  const dest = join(this.controlDir, QUARANTINE_DIR, `${this.now()}-polluted-${MEMORY_INDEX_FILENAME}`);
661
680
  ensureDirExists(dirname(dest));
662
- writeFileSync(dest, indexNow, "utf8");
681
+ writeFileSync(dest, indexNow, { encoding: "utf8", flag: "wx" });
682
+ indexCaptureLanded = true;
663
683
  }
664
684
  catch {
665
685
  }
666
686
  try {
667
687
  writeFileNoFollow(pollutedIndexPath, handle.indexText);
668
- report.warnings.push("memory index restored to its pre-session state — this session's index additions were not retained (session polluted; the removed text was captured to quarantine)");
688
+ report.warnings.push(`memory index restored to its pre-session state — this session's index additions were not retained (session polluted; ${indexCaptureLanded ? "the removed text was captured to quarantine" : "the quarantine capture did NOT land — the removed text is gone"})`);
669
689
  }
670
690
  catch (err) {
671
691
  report.warnings.push(`memory index could NOT be restored to its pre-session state: ${err instanceof Error ? err.message : String(err)}`);
@@ -1225,7 +1245,20 @@ export class MemoryEngine {
1225
1245
  try {
1226
1246
  const dest = join(this.controlDir, QUARANTINE_DIR, `${this.now()}-${MEMORY_INDEX_FILENAME}`);
1227
1247
  ensureDirExists(dirname(dest));
1228
- writeFileSync(dest, text, "utf8");
1248
+ let landed = false;
1249
+ for (let attempt = 0; !landed && attempt < 10; attempt++) {
1250
+ const candidate = attempt === 0 ? dest : `${dest}.${attempt}`;
1251
+ try {
1252
+ writeFileSync(candidate, text, { encoding: "utf8", flag: "wx" });
1253
+ landed = true;
1254
+ }
1255
+ catch (err) {
1256
+ if (!(err instanceof Error && "code" in err && err.code === "EEXIST"))
1257
+ throw err;
1258
+ }
1259
+ }
1260
+ if (!landed)
1261
+ writeFileSync(`${dest}.${process.pid}.${indexCaptureSeq++}`, text, { encoding: "utf8", flag: "wx" });
1229
1262
  captured = true;
1230
1263
  }
1231
1264
  catch (err) {
@@ -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";
@@ -281,8 +281,15 @@ export async function prepareCcImport(opts) {
281
281
  skipped.push({ rule: String(entry), reason: "settings entry is not a string" });
282
282
  continue;
283
283
  }
284
- if (!entry.startsWith("Bash("))
284
+ if (!entry.startsWith("Bash(")) {
285
+ const reason = /^[A-Za-z][A-Za-z0-9_]*\(.*\)$/.test(entry)
286
+ ? "unsupported.tool: only Bash(...) command rules import in v1 — this entry stays in the settings file, unimported"
287
+ : /^[A-Za-z][A-Za-z0-9_-]*$/.test(entry)
288
+ ? "unsupported.form: a bare tool-name entry is a name-set item, not a command rule — it stays in the settings file, unimported"
289
+ : "unsupported.form: not a Bash(...) command rule — it stays in the settings file, unimported";
290
+ skipped.push({ rule: entry, reason });
285
291
  continue;
292
+ }
286
293
  const parsed = parseAllowRuleText(entry);
287
294
  if ("reject" in parsed) {
288
295
  skipped.push({ rule: entry, reason: `${parsed.reject.code}: ${parsed.reject.message}` });
@@ -112,6 +112,15 @@ export declare const ORG_ADJUDICATION_TIMEOUT_MS = 15000;
112
112
  export declare function settleOrgVerdictWithin<T>(p: Promise<T>, fallback: T, opts: {
113
113
  signal?: AbortSignal;
114
114
  timeoutMs: number;
115
+ /**
116
+ * backlog #136④ — which arm WON, reported at the moment it won. A consumer that wants to tell a
117
+ * cancelled wait from an unreadable provider cannot get that by sampling `signal.aborted` after
118
+ * the await: a cancellation queued between the settlement and the continuation reads identically
119
+ * to one that actually ended the wait, and the operator-facing account would name the wrong
120
+ * cause. Called at most once, before `p` resolves, and only for the two non-provider arms —
121
+ * absent call ⇒ the awaited promise itself settled.
122
+ */
123
+ onFallback?: (cause: "aborted" | "timeout") => void;
115
124
  }): Promise<T>;
116
125
  /** The `decisionReason` of a decision an ORG RULE produced (a deny, or a non-dismissable ask). Same
117
126
  * single-spelling contract as {@link ORG_UNAVAILABLE_DECISION_REASON}. */
@@ -11,22 +11,29 @@ export const ORG_ADJUDICATION_TIMEOUT_MS = 15_000;
11
11
  export function settleOrgVerdictWithin(p, fallback, opts) {
12
12
  return new Promise((resolve) => {
13
13
  let settled = false;
14
- const finish = (v) => {
14
+ const finish = (v, cause) => {
15
15
  if (settled)
16
16
  return;
17
17
  settled = true;
18
18
  clearTimeout(timer);
19
19
  opts.signal?.removeEventListener("abort", onAbort);
20
+ if (cause !== undefined) {
21
+ try {
22
+ opts.onFallback?.(cause);
23
+ }
24
+ catch {
25
+ }
26
+ }
20
27
  resolve(v);
21
28
  };
22
- const onAbort = () => finish(fallback);
23
- const timer = setTimeout(() => finish(fallback), opts.timeoutMs);
29
+ const onAbort = () => finish(fallback, "aborted");
30
+ const timer = setTimeout(() => finish(fallback, "timeout"), opts.timeoutMs);
24
31
  if (opts.signal?.aborted === true) {
25
- finish(fallback);
32
+ finish(fallback, "aborted");
26
33
  return;
27
34
  }
28
35
  opts.signal?.addEventListener("abort", onAbort);
29
- p.then(finish, () => finish(fallback));
36
+ p.then((v) => finish(v), () => finish(fallback));
30
37
  });
31
38
  }
32
39
  export const ORG_RULE_DECISION_REASON = "org_rule";
@@ -95,10 +95,10 @@ export declare function buildStaleOffloadPointer(toolName: string, ref: string,
95
95
  * The session transcript is NEVER touched — this runs on the outgoing {@link Context} only.
96
96
  * Error results, image/document-bearing blocks' non-text parts, already-offloaded previews, and
97
97
  * replacements that would save < `minSavingsChars` are left verbatim. The full text is persisted
98
- * under a deterministic content-digested ref (design/30's mint helper over
99
- * `<toolCallId>_s<sha256-12 of the text>`write-once, so re-projection on every turn re-puts a
100
- * no-op; the digest exists because tool-call ids carry no cross-turn uniqueness contract),
101
- * readable back via `read_tool_result`.
98
+ * under a deterministic content-digested ref (`buildToolResultRef(sessionId, toolCallId,
99
+ * toolResultContentSegment(text))`a `~`-separated three-segment mint whose digest is its OWN
100
+ * segment; write-once, so re-projection on every turn re-puts a no-op; the digest exists because
101
+ * tool-call ids carry no cross-turn uniqueness contract), readable back via `read_tool_result`.
102
102
  */
103
103
  export declare function projectStaleToolResults(context: Context, cfg: ResolvedStaleToolResultOffload, store: ToolResultStore, sessionId: string,
104
104
  /** Run-scoped cache of refs already persisted by THIS run (独立复审 MED,已修): without it the
@@ -1,6 +1,5 @@
1
- import { createHash } from "node:crypto";
2
1
  import { emitTrace } from "../trace.js";
3
- import { buildToolResultRef, OFFLOAD_TOOL_NAME, PERSISTED_OUTPUT_PREFIX } from "../tool-result-store.js";
2
+ import { buildToolResultRef, OFFLOAD_TOOL_NAME, PERSISTED_OUTPUT_PREFIX, toolResultContentSegment, toolResultProvenanceOf, } from "../tool-result-store.js";
4
3
  export function buildWorkingFileAttachments(spec, prepared) {
5
4
  if (spec.compaction?.attachWorkingFiles === false || !prepared.readTaskFile)
6
5
  return undefined;
@@ -73,13 +72,13 @@ export async function projectStaleToolResults(context, cfg, store, sessionId, wr
73
72
  const text = toolResultText(msg);
74
73
  if (text.startsWith(PERSISTED_OUTPUT_PREFIX))
75
74
  continue;
76
- const ref = buildToolResultRef(sessionId, `${msg.toolCallId}_s${createHash("sha256").update(text, "utf8").digest("hex").slice(0, 32)}`);
75
+ const ref = buildToolResultRef(sessionId, msg.toolCallId, toolResultContentSegment(text));
77
76
  const pointer = buildStaleOffloadPointer(toolName, ref, text.length);
78
77
  if (text.length - pointer.length < cfg.minSavingsChars)
79
78
  continue;
80
79
  if (!writtenRefs.has(ref)) {
81
80
  try {
82
- await store.put(ref, text);
81
+ await store.put(ref, text, toolResultProvenanceOf(sessionId));
83
82
  writtenRefs.add(ref);
84
83
  }
85
84
  catch {
@@ -1,11 +1,3 @@
1
- /**
2
- * design/157 B15 一期 (P6 相位抽取) — prepareTask's long-term-memory phase, verbatim. The ONLY inputs
3
- * are the five fields below (measured, not assumed: spec.memory + deps.* + the three ambient values);
4
- * the ONLY outputs are the memory-engine session and the composed injection block. `deps.onError`
5
- * call order and payloads are part of the contract (event-sequence snapshot pin recorded across the
6
- * move). Throws pass through unchanged: a `config.memory_*`-coded violation is a DELIBERATE refusal
7
- * (design/142 S1 硬门) and must keep failing prepare loudly.
8
- */
9
1
  import type { BeforeWriteHook, RunnerDeps, TaskSpec, ToolSpec } from "../types.js";
10
2
  import type { Prepared } from "./prepare-task.js";
11
3
  export interface PrepareMemoryInput {
@@ -18,15 +10,42 @@ export interface PrepareMemoryInput {
18
10
  };
19
11
  /**
20
12
  * #181-F5 — whether a tool NAMED `Write` (the tool the CC `# Memory` instruction names) is on the
21
- * ASSEMBLED roster this run and not excluded: prepare-task's
22
- * `tools.some(t => t.name === "Write") && !exclude.includes("Write")`. Name occupancy IS the channel
23
- * declaration, so the hands band is only one of the arms that can satisfy it — a hands-less run that
24
- * mounts its own `Write` counts, and an `excludeTools: ["Write"]` run reads as unmounted whatever the
25
- * hands band did. Threaded into `engine.inject` so a run with a writable memory scope but no write
26
- * channel is not instructed to call a tool that is not on its roster; the RB-276 index seed follows
27
- * the same gate (it exists only to satisfy the Write tool's unread-overwrite guard).
13
+ * ASSEMBLED roster this run, not excluded, AND can actually REACH the host-side memory root:
14
+ * prepare-task's `tools.some(t => t.name === "Write") && !exclude.includes("Write")` plus the
15
+ * remote-env conjunct (a hand-band Write on a remote ExecutionEnv writes the sandbox filesystem,
16
+ * not the host memory root it does not count unless the deployment declares the mount shared
17
+ * via `memoryPersistenceCapable: true`). Name occupancy IS the channel declaration for the local
18
+ * arms a hands-less run that mounts its own `Write` counts, and an `excludeTools: ["Write"]`
19
+ * run reads as unmounted whatever the hands band did. Threaded into `engine.inject` so a run with
20
+ * a writable memory scope but no write channel is not instructed to call a tool that is not on
21
+ * its roster (or cannot reach the store); the RB-276 index seed follows the same gate.
28
22
  */
29
23
  writeToolsMounted: boolean;
24
+ /**
25
+ * The session-wide persistence verdict: `TaskSpec.memoryPersistenceCapable` (the deployment's own
26
+ * statement — the only honest channel for a custom memory writer persisting through its closure,
27
+ * which no inference can see) when set, else the KNOWN-store-path inference: a mounted,
28
+ * non-excluded file-write tool (Write/Edit/NotebookEdit) or a write-capable shell. A generic
29
+ * write-effect tool does NOT count — a mail sender's side effect is not a memory store, and
30
+ * counting it re-opened the silent-confabulation hole. `false` ⇒ this phase mounts
31
+ * {@link MEMORY_READONLY_NOTICE} where the engine is silent.
32
+ */
33
+ rosterCanPersist: boolean;
34
+ /**
35
+ * The raw `TaskSpec.memoryPersistenceCapable` DECLARATION, kept separate from the composed
36
+ * {@link rosterCanPersist} verdict because the two drive different arms:
37
+ * - `true` (declared) RETRACTS the engine's own read-only notice — the deployment vouches for a
38
+ * persistence channel the engine cannot see (a custom writer persisting through its closure),
39
+ * so "you cannot save" beside it would be a false claim. An INFERRED-true roster must NOT
40
+ * retract: over a writeScope-null layering the engine will refuse those very file writes
41
+ * (`read_only_layering`), so the roster's write tools prove nothing about THIS store and
42
+ * stripping the notice re-opens the silent-confabulation hole for exactly the state the
43
+ * notice was built for.
44
+ * - `false` (declared) also CLOSES the file-tool write channel into the writable memory root
45
+ * (the write gate refuses), so the mounted notice's "the engine will not accept writes into
46
+ * the memory store" stays a true statement instead of a disclosure the store then contradicts.
47
+ */
48
+ memoryPersistenceDeclared?: boolean;
30
49
  /**
31
50
  * design/178 ②-1 — whether the `memory_search`/`memory_get` pair PASSED its early mount conjuncts
32
51
  * (exclusion + name occupancy, decided in prepare-task BEFORE this phase). True ⇒ this phase builds
@@ -1,8 +1,9 @@
1
+ import { sep } from "node:path";
1
2
  import { admitMemoryScopes } from "../memory-admission.js";
2
- import { adoptLegacyRepoDirs, deriveRepoControlPlaneDir, deriveProjectControlDir, deriveProjectMemoryDir, deriveRepoMemoryDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, lookupProjectIdHint, recordProjectIdHint, resolveMemoryEngineRoot } from "../memory-engine/layout.js";
3
+ import { adoptLegacyRepoDirs, canonicalize, deriveRepoControlPlaneDir, deriveProjectControlDir, deriveProjectMemoryDir, deriveRepoMemoryDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, isContainedIn, lookupProjectIdHint, recordProjectIdHint, resolveMemoryEngineRoot } from "../memory-engine/layout.js";
3
4
  import { classifyScopePlanes, derivePersonalControlDir, derivePersonalMemoryDir, mergeHarvestReports, mergeInjections, needsDualRoots, parsedProjectPlane } from "../memory-engine/dual-root.js";
4
5
  import { normalizeMemorySpec } from "../memory.js";
5
- import { MEMORY_PREFERENCE_DISCIPLINE, MEMORY_RECALL_DISCIPLINE, MemoryEngine } from "../memory-engine/engine.js";
6
+ import { MEMORY_ANNOUNCEMENT_READONLY_CODA, MEMORY_PREFERENCE_DISCIPLINE, MEMORY_READONLY_NOTICE, MEMORY_RECALL_DISCIPLINE, MemoryEngine } from "../memory-engine/engine.js";
6
7
  import { createMemoryEngineTools } from "../memory-engine/tools.js";
7
8
  import { assertScopeContractPlacement, parseScopeKey, resolveProjectId } from "../memory-engine/scope-contract.js";
8
9
  import { FileMemoryEngineBackend } from "../memory-engine/file-backend.js";
@@ -113,10 +114,12 @@ export async function prepareMemory(input) {
113
114
  }
114
115
  const personalMemoryDir = derivePersonalMemoryDir(engineRoot);
115
116
  const personalControlDir = derivePersonalControlDir(engineRoot);
116
- const createPersonalEngine = () => {
117
- const personalBackend = typeof pinned === "string" && pinned
118
- ? new FileMemoryEngineBackend(personalMemoryDir, { controlDir: personalControlDir })
119
- : backend;
117
+ const effectiveControlDir = (b, fallback) => {
118
+ const pinnedCtl = b.controlPlaneRoot;
119
+ return typeof pinnedCtl === "string" && pinnedCtl ? pinnedCtl : fallback;
120
+ };
121
+ const choosePersonalBackend = () => typeof pinned === "string" && pinned ? new FileMemoryEngineBackend(personalMemoryDir, { controlDir: personalControlDir }) : backend;
122
+ const createPersonalEngine = (personalBackend) => {
120
123
  return {
121
124
  engine: new MemoryEngine({ backend: personalBackend, memoryDir: personalMemoryDir, controlDir: personalControlDir, onIncident: onEngineIncident }),
122
125
  backend: personalBackend,
@@ -131,17 +134,43 @@ export async function prepareMemory(input) {
131
134
  };
132
135
  let writeEngine;
133
136
  let writeHandle;
137
+ let readOnlyEngine;
138
+ let readOnlyHandle;
134
139
  let injectFn;
135
140
  let harvestBoth;
136
141
  let toolPlanes;
142
+ const admitNothingOpts = input.memoryPersistenceDeclared === false
143
+ ? { admitNothing: { reason: "harvest admitted nothing: memory persistence is declared unavailable for this session (memoryPersistenceCapable: false)" } }
144
+ : {};
137
145
  if (dual) {
146
+ const personalBackendChosen = choosePersonalBackend();
147
+ {
148
+ const projCtl = effectiveControlDir(backend, identityKey !== undefined ? deriveProjectControlDir(engineRoot, identityKey) : deriveRepoControlPlaneDir(engineRoot, repoRoot));
149
+ const persCtl = effectiveControlDir(personalBackendChosen, personalControlDir);
150
+ const overlap = (a, b) => isContainedIn(a, b) || isContainedIn(b, a);
151
+ const refuse = (what, a, b) => {
152
+ const e = new Error(`memory dual-root configuration error: ${what} overlap (${canonicalize(a)} vs ${canonicalize(b)}) — the planes' data roots and control planes must all be disjoint.`);
153
+ e.code = "config.memory_dual_root_overlap";
154
+ throw e;
155
+ };
156
+ if (overlap(memoryDir, personalMemoryDir))
157
+ refuse("the project and personal memory roots", memoryDir, personalMemoryDir);
158
+ if (overlap(projCtl, persCtl))
159
+ refuse("the project and personal control planes", projCtl, persCtl);
160
+ for (const [ctlName, ctl] of [["project control plane", projCtl], ["personal control plane", persCtl]]) {
161
+ for (const [rootName, dataRoot] of [["project memory root", memoryDir], ["personal memory mount", personalMemoryDir]]) {
162
+ if (overlap(ctl, dataRoot))
163
+ refuse(`the ${ctlName} and the ${rootName}`, ctl, dataRoot);
164
+ }
165
+ }
166
+ }
138
167
  const projectEngine = new MemoryEngine({
139
168
  backend,
140
169
  memoryDir,
141
170
  controlDir: identityKey !== undefined ? deriveProjectControlDir(engineRoot, identityKey) : deriveRepoControlPlaneDir(engineRoot, repoRoot),
142
171
  onIncident: onEngineIncident,
143
172
  });
144
- const personal = createPersonalEngine();
173
+ const personal = createPersonalEngine(personalBackendChosen);
145
174
  const personalEngine = personal.engine;
146
175
  const p = planes;
147
176
  const projectHandle = await projectEngine.materialize(p.project, p.writePlane === "project" ? memorySpec.writeScope : null);
@@ -149,6 +178,8 @@ export async function prepareMemory(input) {
149
178
  const writeIsPersonal = p.writePlane === "personal";
150
179
  writeEngine = writeIsPersonal ? personalEngine : projectEngine;
151
180
  writeHandle = writeIsPersonal ? personalHandle : projectHandle;
181
+ readOnlyEngine = writeIsPersonal ? projectEngine : personalEngine;
182
+ readOnlyHandle = writeIsPersonal ? projectHandle : personalHandle;
152
183
  injectFn = () => mergeInjections(projectEngine.inject(projectHandle, { writeToolMounted: input.writeToolsMounted }), personalEngine.inject(personalHandle, { writeToolMounted: input.writeToolsMounted }));
153
184
  toolPlanes = [
154
185
  {
@@ -167,7 +198,7 @@ export async function prepareMemory(input) {
167
198
  harvestBoth = async () => {
168
199
  const writeFirst = writeIsPersonal ? [personalEngine, personalHandle] : [projectEngine, projectHandle];
169
200
  const readOther = writeIsPersonal ? [projectEngine, projectHandle] : [personalEngine, personalHandle];
170
- const writeReport = await writeFirst[0].harvest(writeFirst[1], { ...pollutedOpts(writeFirst[0]), sessionId });
201
+ const writeReport = await writeFirst[0].harvest(writeFirst[1], { ...pollutedOpts(writeFirst[0]), sessionId, ...admitNothingOpts });
171
202
  let readReport;
172
203
  let readFailure;
173
204
  try {
@@ -183,13 +214,13 @@ export async function prepareMemory(input) {
183
214
  };
184
215
  }
185
216
  else if (personalOnly) {
186
- const personal = createPersonalEngine();
217
+ const personal = createPersonalEngine(choosePersonalBackend());
187
218
  const personalEngine = personal.engine;
188
219
  const handle = await personalEngine.materialize(memorySpec.scopes, memorySpec.writeScope);
189
220
  writeEngine = personalEngine;
190
221
  writeHandle = handle;
191
222
  injectFn = () => personalEngine.inject(handle, { writeToolMounted: input.writeToolsMounted });
192
- harvestBoth = () => personalEngine.harvest(handle, { ...pollutedOpts(personalEngine), sessionId });
223
+ harvestBoth = () => personalEngine.harvest(handle, { ...pollutedOpts(personalEngine), sessionId, ...admitNothingOpts });
193
224
  toolPlanes = [
194
225
  {
195
226
  backend: retrievalBackend(personal.backend),
@@ -210,7 +241,7 @@ export async function prepareMemory(input) {
210
241
  writeEngine = engine;
211
242
  writeHandle = handle;
212
243
  injectFn = () => engine.inject(handle, { writeToolMounted: input.writeToolsMounted });
213
- harvestBoth = () => engine.harvest(handle, { ...pollutedOpts(engine), sessionId });
244
+ harvestBoth = () => engine.harvest(handle, { ...pollutedOpts(engine), sessionId, ...admitNothingOpts });
214
245
  toolPlanes = [
215
246
  {
216
247
  backend: retrievalBackend(backend),
@@ -220,7 +251,25 @@ export async function prepareMemory(input) {
220
251
  },
221
252
  ];
222
253
  }
223
- memoryWriteGateRef.current = (w) => writeEngine.gateWrite(writeHandle, w.key, w.content);
254
+ memoryWriteGateRef.current = (w) => {
255
+ if (readOnlyEngine !== undefined && readOnlyHandle !== undefined) {
256
+ const ro = readOnlyEngine.gateWrite(readOnlyHandle, w.key, w.content);
257
+ if (!ro.ok)
258
+ return ro;
259
+ }
260
+ if (input.memoryPersistenceDeclared === false) {
261
+ const root = writeHandle.writableRoot;
262
+ if (w.key === root || w.key.startsWith(`${root}${sep}`)) {
263
+ return {
264
+ ok: false,
265
+ code: "read_only_layering",
266
+ reason: `memory persistence is declared unavailable for this session (memoryPersistenceCapable: false) — writes into the memory store are refused. Nothing was written.`,
267
+ muted: false,
268
+ };
269
+ }
270
+ }
271
+ return writeEngine.gateWrite(writeHandle, w.key, w.content);
272
+ };
224
273
  const harvestSafe = async (phase = "terminal") => {
225
274
  try {
226
275
  const report = await harvestBoth();
@@ -261,7 +310,10 @@ export async function prepareMemory(input) {
261
310
  memoryTools = createMemoryEngineTools({ planes: toolPlanes });
262
311
  }
263
312
  catch (err) {
264
- if (typeof err.code === "string" && (err.code.startsWith("config.memory_scope") || err.code.startsWith("config.memory_project"))) {
313
+ if (typeof err.code === "string" &&
314
+ (err.code.startsWith("config.memory_scope") ||
315
+ err.code.startsWith("config.memory_project") ||
316
+ err.code === "config.memory_dual_root_overlap")) {
265
317
  throw err;
266
318
  }
267
319
  deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "memory", sessionId });
@@ -275,15 +327,31 @@ export async function prepareMemory(input) {
275
327
  }
276
328
  if (memoryEngineSession) {
277
329
  const injection = memoryEngineSession.inject();
278
- if (injection.block.trim())
279
- memoryBlock = injection.block;
330
+ let blockBody = injection.block;
331
+ if (!input.rosterCanPersist && injection.instruction === "" && injection.readOnlyNotice === undefined) {
332
+ blockBody = blockBody.trim() ? `${MEMORY_READONLY_NOTICE}\n\n${blockBody}` : MEMORY_READONLY_NOTICE;
333
+ }
334
+ else if (!input.rosterCanPersist && injection.instruction !== "") {
335
+ blockBody = [MEMORY_READONLY_NOTICE, injection.index, injection.announceBlock].filter((s) => Boolean(s && s.trim())).join("\n\n");
336
+ }
337
+ else if (input.memoryPersistenceDeclared === true && injection.readOnlyNotice !== undefined) {
338
+ blockBody = [injection.index, injection.announceBlock].filter((s) => Boolean(s && s.trim())).join("\n\n");
339
+ }
340
+ if (!input.rosterCanPersist &&
341
+ (injection.announceBlock?.trim() ?? "") !== "" &&
342
+ blockBody.includes(injection.announceBlock) &&
343
+ !blockBody.trimEnd().endsWith(MEMORY_ANNOUNCEMENT_READONLY_CODA)) {
344
+ blockBody = `${blockBody}\n\n${MEMORY_ANNOUNCEMENT_READONLY_CODA}`;
345
+ }
346
+ if (blockBody.trim())
347
+ memoryBlock = blockBody;
280
348
  if (memoryTools !== undefined) {
281
349
  memoryBlock = memoryBlock !== undefined ? `${memoryBlock}\n\n${MEMORY_RECALL_DISCIPLINE}` : MEMORY_RECALL_DISCIPLINE;
282
350
  }
283
- if (memoryEngineSession.handle.writeScope !== null && input.writeToolsMounted) {
351
+ if (memoryEngineSession.handle.writeScope !== null && input.writeToolsMounted && input.rosterCanPersist) {
284
352
  memoryBlock = memoryBlock !== undefined ? `${memoryBlock}\n\n${MEMORY_PREFERENCE_DISCIPLINE}` : MEMORY_PREFERENCE_DISCIPLINE;
285
353
  }
286
- if (memoryBlock !== undefined && injection.indexSeed !== undefined)
354
+ if (memoryBlock !== undefined && injection.indexSeed !== undefined && input.rosterCanPersist)
287
355
  seedFiles = [injection.indexSeed];
288
356
  }
289
357
  return { memoryEngineSession, memoryBlock, admittedOrgScopes, ownOrgVerdict, ...(memoryTools !== undefined ? { memoryTools } : {}), ...(seedFiles !== undefined ? { seedFiles } : {}) };
@@ -28,6 +28,8 @@ import { type WiringManifest } from "../wiring-manifest.js";
28
28
  import type { ActiveWorktreeSession, AgentMessage, AgentTool, ExecutionEnv } from "../../internal/harness.js";
29
29
  import type { NestedUsageAccum, RunnerDeps, TaskEvent, TaskLimits, TaskResult, TaskSpec, ToolActivity, ToolEffect } from "../types.js";
30
30
  import type { RepairBundle } from "../../agents/repair-loop.js";
31
+ /** Test seam (mirrors `__resetBashTimeoutAnnouncements`): never called by production code. */
32
+ export declare function __resetMaterializeEnvAnnouncements(): void;
31
33
  /**
32
34
  * design/164 — validate `TaskSpec.limits` at the door and return it unchanged.
33
35
  *