muonroi-cli 1.5.0 → 1.6.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 (30) hide show
  1. package/dist/src/ee/artifact-cache.d.ts +56 -0
  2. package/dist/src/ee/artifact-cache.js +155 -0
  3. package/dist/src/ee/artifact-cache.test.d.ts +1 -0
  4. package/dist/src/ee/artifact-cache.test.js +69 -0
  5. package/dist/src/ee/search.js +7 -5
  6. package/dist/src/ee/search.test.d.ts +1 -0
  7. package/dist/src/ee/search.test.js +23 -0
  8. package/dist/src/generated/version.d.ts +1 -1
  9. package/dist/src/generated/version.js +1 -1
  10. package/dist/src/orchestrator/compaction.d.ts +2 -0
  11. package/dist/src/orchestrator/compaction.js +14 -1
  12. package/dist/src/orchestrator/compaction.test.js +25 -1
  13. package/dist/src/orchestrator/message-processor.js +15 -5
  14. package/dist/src/orchestrator/scope-reminder.d.ts +12 -0
  15. package/dist/src/orchestrator/scope-reminder.js +16 -0
  16. package/dist/src/orchestrator/scope-reminder.test.js +22 -1
  17. package/dist/src/orchestrator/stream-runner.js +3 -0
  18. package/dist/src/orchestrator/subagent-compactor.d.ts +14 -5
  19. package/dist/src/orchestrator/subagent-compactor.js +30 -8
  20. package/dist/src/orchestrator/subagent-compactor.spec.js +18 -0
  21. package/dist/src/pil/__tests__/layer6-output.test.js +21 -0
  22. package/dist/src/pil/__tests__/surface-compaction-artifacts.test.d.ts +1 -0
  23. package/dist/src/pil/__tests__/surface-compaction-artifacts.test.js +112 -0
  24. package/dist/src/pil/layer3-ee-injection.d.ts +19 -0
  25. package/dist/src/pil/layer3-ee-injection.js +96 -4
  26. package/dist/src/pil/layer6-output.js +18 -7
  27. package/dist/src/pil/pipeline.js +15 -9
  28. package/dist/src/tools/registry-ee-query.test.js +18 -1
  29. package/dist/src/tools/registry.js +13 -2
  30. package/package.json +1 -1
@@ -0,0 +1,56 @@
1
+ /**
2
+ * src/ee/artifact-cache.ts
3
+ *
4
+ * Durable fallback for compaction-elided tool outputs (issue #3 increment 2 /
5
+ * anti-mù durability).
6
+ *
7
+ * When B3/B4 compaction rewrites a low-value tool result into a ~200-char stub,
8
+ * the full content is shipped to the Experience Engine (source="tool-artifact")
9
+ * so a later `ee_query("tool-artifact id=X")` can rehydrate it. But that recovery
10
+ * depends on EE (Qdrant/HTTP) being reachable. This module is the EE-independent
11
+ * recovery path, in two tiers:
12
+ * - in-process LRU (keyed by toolCallId): authoritative full content for THIS
13
+ * session, instant, survives an EE outage mid-session;
14
+ * - append-only disk spill (~/.muonroi-cli/artifact-cache.jsonl): survives a
15
+ * PROCESS RESTART too, so a restart + EE-down double-failure can still
16
+ * rehydrate. Disable with MUONROI_ARTIFACT_CACHE_DISK=0.
17
+ *
18
+ * ee_query reads in-memory first, then disk, then falls back to EE /api/search
19
+ * (the cross-session source). Both tiers are bounded; both are best-effort and
20
+ * fail-open (a disk error never breaks recall).
21
+ */
22
+ export interface ArtifactEntry {
23
+ toolName: string;
24
+ content: string;
25
+ }
26
+ /**
27
+ * Record an elided tool output by toolCallId. In-memory set is synchronous;
28
+ * the disk append is fire-and-forget (tracked so tests can flush it). No-ops on
29
+ * empty id/content.
30
+ */
31
+ export declare function recordArtifact(toolCallId: string, toolName: string, content: string): void;
32
+ /** The actual disk append (awaitable). Resets the file when it exceeds the size cap. */
33
+ export declare function appendArtifactToDisk(toolCallId: string, toolName: string, content: string): Promise<void>;
34
+ /** Exact in-memory lookup by toolCallId. */
35
+ export declare function getArtifact(toolCallId: string): ArtifactEntry | null;
36
+ /**
37
+ * Synchronous in-memory lookup from a contract query string. Returns null when
38
+ * the query has no id= or the id is not in the in-process LRU.
39
+ */
40
+ export declare function findArtifactByQuery(query: string): (ArtifactEntry & {
41
+ toolCallId: string;
42
+ }) | null;
43
+ /**
44
+ * Disk-tier lookup (survives restart). Scans the spill file newest-first so the
45
+ * most recent record for an id wins. Fail-open: a missing/corrupt file yields
46
+ * null, never throws.
47
+ */
48
+ export declare function findArtifactOnDisk(query: string): Promise<(ArtifactEntry & {
49
+ toolCallId: string;
50
+ }) | null>;
51
+ export declare function __resetArtifactCacheForTests(): void;
52
+ export declare function __setArtifactCacheMaxForTests(n: number): void;
53
+ export declare function __setArtifactCacheDiskPathForTests(p: string | null): void;
54
+ export declare function __artifactCacheSize(): number;
55
+ /** Await all in-flight fire-and-forget disk writes (deterministic tests). */
56
+ export declare function flushArtifactDiskWrites(): Promise<void>;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * src/ee/artifact-cache.ts
3
+ *
4
+ * Durable fallback for compaction-elided tool outputs (issue #3 increment 2 /
5
+ * anti-mù durability).
6
+ *
7
+ * When B3/B4 compaction rewrites a low-value tool result into a ~200-char stub,
8
+ * the full content is shipped to the Experience Engine (source="tool-artifact")
9
+ * so a later `ee_query("tool-artifact id=X")` can rehydrate it. But that recovery
10
+ * depends on EE (Qdrant/HTTP) being reachable. This module is the EE-independent
11
+ * recovery path, in two tiers:
12
+ * - in-process LRU (keyed by toolCallId): authoritative full content for THIS
13
+ * session, instant, survives an EE outage mid-session;
14
+ * - append-only disk spill (~/.muonroi-cli/artifact-cache.jsonl): survives a
15
+ * PROCESS RESTART too, so a restart + EE-down double-failure can still
16
+ * rehydrate. Disable with MUONROI_ARTIFACT_CACHE_DISK=0.
17
+ *
18
+ * ee_query reads in-memory first, then disk, then falls back to EE /api/search
19
+ * (the cross-session source). Both tiers are bounded; both are best-effort and
20
+ * fail-open (a disk error never breaks recall).
21
+ */
22
+ import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ const DEFAULT_MAX_ENTRIES = 100;
26
+ /** Per-entry cap so one giant output can't dominate the footprint. */
27
+ const MAX_CONTENT_CHARS = 200_000;
28
+ /** Disk-file size cap; on overflow the file is reset (EE retains older artifacts). */
29
+ const DISK_MAX_BYTES = 8 * 1024 * 1024;
30
+ const store = new Map();
31
+ let maxEntries = DEFAULT_MAX_ENTRIES;
32
+ let diskPathOverride = null;
33
+ const pendingWrites = new Set();
34
+ function diskEnabled() {
35
+ return process.env.MUONROI_ARTIFACT_CACHE_DISK !== "0";
36
+ }
37
+ function diskPath() {
38
+ return diskPathOverride ?? path.join(os.homedir(), ".muonroi-cli", "artifact-cache.jsonl");
39
+ }
40
+ /** Extract the id from a "tool-artifact id=<id>" / "full tool result id=<id>" query. */
41
+ function extractArtifactId(query) {
42
+ const m = /\bid\s*=\s*["']?([A-Za-z0-9_\-:.]+)/i.exec(query || "");
43
+ return m ? m[1] : null;
44
+ }
45
+ /**
46
+ * Record an elided tool output by toolCallId. In-memory set is synchronous;
47
+ * the disk append is fire-and-forget (tracked so tests can flush it). No-ops on
48
+ * empty id/content.
49
+ */
50
+ export function recordArtifact(toolCallId, toolName, content) {
51
+ if (!toolCallId || typeof content !== "string" || content.length === 0)
52
+ return;
53
+ const capped = content.slice(0, MAX_CONTENT_CHARS);
54
+ if (store.has(toolCallId))
55
+ store.delete(toolCallId); // refresh recency
56
+ store.set(toolCallId, { toolName: toolName || "", content: capped });
57
+ while (store.size > maxEntries) {
58
+ const oldest = store.keys().next().value;
59
+ if (oldest === undefined)
60
+ break;
61
+ store.delete(oldest);
62
+ }
63
+ if (diskEnabled()) {
64
+ const w = appendArtifactToDisk(toolCallId, toolName || "", capped).catch((err) => {
65
+ console.error(`[artifact-cache] disk append failed: ${err?.message}`);
66
+ });
67
+ pendingWrites.add(w);
68
+ void w.finally(() => pendingWrites.delete(w));
69
+ }
70
+ }
71
+ /** The actual disk append (awaitable). Resets the file when it exceeds the size cap. */
72
+ export async function appendArtifactToDisk(toolCallId, toolName, content) {
73
+ const p = diskPath();
74
+ await mkdir(path.dirname(p), { recursive: true });
75
+ try {
76
+ const s = await stat(p);
77
+ if (s.size > DISK_MAX_BYTES)
78
+ await writeFile(p, "");
79
+ }
80
+ catch {
81
+ /* file does not exist yet — nothing to cap */
82
+ }
83
+ await appendFile(p, `${JSON.stringify({ id: toolCallId, toolName, content })}\n`);
84
+ }
85
+ /** Exact in-memory lookup by toolCallId. */
86
+ export function getArtifact(toolCallId) {
87
+ if (!toolCallId)
88
+ return null;
89
+ return store.get(toolCallId) ?? null;
90
+ }
91
+ /**
92
+ * Synchronous in-memory lookup from a contract query string. Returns null when
93
+ * the query has no id= or the id is not in the in-process LRU.
94
+ */
95
+ export function findArtifactByQuery(query) {
96
+ const id = extractArtifactId(query);
97
+ if (!id)
98
+ return null;
99
+ const hit = store.get(id);
100
+ return hit ? { toolCallId: id, toolName: hit.toolName, content: hit.content } : null;
101
+ }
102
+ /**
103
+ * Disk-tier lookup (survives restart). Scans the spill file newest-first so the
104
+ * most recent record for an id wins. Fail-open: a missing/corrupt file yields
105
+ * null, never throws.
106
+ */
107
+ export async function findArtifactOnDisk(query) {
108
+ if (!diskEnabled())
109
+ return null;
110
+ const id = extractArtifactId(query);
111
+ if (!id)
112
+ return null;
113
+ let text;
114
+ try {
115
+ text = await readFile(diskPath(), "utf8");
116
+ }
117
+ catch {
118
+ return null; // no spill file yet
119
+ }
120
+ const lines = text.split("\n");
121
+ for (let i = lines.length - 1; i >= 0; i--) {
122
+ const line = lines[i];
123
+ if (!line)
124
+ continue;
125
+ try {
126
+ const row = JSON.parse(line);
127
+ if (row.id === id)
128
+ return { toolCallId: id, toolName: row.toolName ?? "", content: row.content ?? "" };
129
+ }
130
+ catch {
131
+ /* skip a torn/partial append line */
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+ // ─── Test hooks ──────────────────────────────────────────────────────────────
137
+ export function __resetArtifactCacheForTests() {
138
+ store.clear();
139
+ maxEntries = DEFAULT_MAX_ENTRIES;
140
+ diskPathOverride = null;
141
+ }
142
+ export function __setArtifactCacheMaxForTests(n) {
143
+ maxEntries = Math.max(1, n);
144
+ }
145
+ export function __setArtifactCacheDiskPathForTests(p) {
146
+ diskPathOverride = p;
147
+ }
148
+ export function __artifactCacheSize() {
149
+ return store.size;
150
+ }
151
+ /** Await all in-flight fire-and-forget disk writes (deterministic tests). */
152
+ export async function flushArtifactDiskWrites() {
153
+ await Promise.allSettled([...pendingWrites]);
154
+ }
155
+ //# sourceMappingURL=artifact-cache.js.map
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import { rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { __artifactCacheSize, __resetArtifactCacheForTests, __setArtifactCacheDiskPathForTests, __setArtifactCacheMaxForTests, appendArtifactToDisk, findArtifactByQuery, findArtifactOnDisk, flushArtifactDiskWrites, getArtifact, recordArtifact, } from "./artifact-cache.js";
6
+ // Redirect the disk spill to a temp file for EVERY test so recordArtifact never
7
+ // writes the real ~/.muonroi-cli/artifact-cache.jsonl.
8
+ const diskFile = path.join(os.tmpdir(), `muonroi-artifact-cache-test-${process.pid}.jsonl`);
9
+ beforeEach(() => __setArtifactCacheDiskPathForTests(diskFile));
10
+ afterEach(async () => {
11
+ __resetArtifactCacheForTests();
12
+ delete process.env.MUONROI_ARTIFACT_CACHE_DISK;
13
+ await rm(diskFile, { force: true });
14
+ });
15
+ describe("artifact-cache (in-memory tier — durable rehydrate when EE is down)", () => {
16
+ it("records and retrieves an elided output by toolCallId", () => {
17
+ recordArtifact("call_7", "read_file", "FULL CONTENT of src/auth.ts");
18
+ expect(getArtifact("call_7")).toEqual({ toolName: "read_file", content: "FULL CONTENT of src/auth.ts" });
19
+ expect(getArtifact("missing")).toBeNull();
20
+ });
21
+ it("no-ops on empty id or empty content", () => {
22
+ recordArtifact("", "read_file", "x");
23
+ recordArtifact("call_x", "read_file", "");
24
+ expect(__artifactCacheSize()).toBe(0);
25
+ });
26
+ it("findArtifactByQuery extracts the id from the contract query strings", () => {
27
+ recordArtifact("abc123", "grep", "GREP HITS");
28
+ expect(findArtifactByQuery("tool-artifact id=abc123")?.content).toBe("GREP HITS");
29
+ expect(findArtifactByQuery("full tool result id=abc123")?.toolCallId).toBe("abc123");
30
+ expect(findArtifactByQuery("tool-artifact ID = abc123")?.content).toBe("GREP HITS"); // spacing/case
31
+ expect(findArtifactByQuery("tool-artifact id=nope")).toBeNull(); // not cached
32
+ expect(findArtifactByQuery("no id here")).toBeNull(); // no id=
33
+ });
34
+ it("evicts the oldest entries past the LRU cap; re-recording refreshes recency", () => {
35
+ __setArtifactCacheMaxForTests(2);
36
+ recordArtifact("a", "t", "A");
37
+ recordArtifact("b", "t", "B");
38
+ recordArtifact("a", "t", "A2"); // touch 'a' → now 'b' is oldest
39
+ recordArtifact("c", "t", "C"); // evicts 'b'
40
+ expect(getArtifact("a")?.content).toBe("A2");
41
+ expect(getArtifact("c")?.content).toBe("C");
42
+ expect(getArtifact("b")).toBeNull();
43
+ expect(__artifactCacheSize()).toBe(2);
44
+ });
45
+ });
46
+ describe("artifact-cache (disk spill — survives a process restart)", () => {
47
+ it("rehydrates from disk after the in-memory tier is gone (simulated restart)", async () => {
48
+ recordArtifact("call_disk", "read_file", "PERSISTED CONTENT");
49
+ await flushArtifactDiskWrites();
50
+ // Simulate a restart: in-memory tier cleared, but the disk file persists.
51
+ __resetArtifactCacheForTests();
52
+ __setArtifactCacheDiskPathForTests(diskFile);
53
+ expect(findArtifactByQuery("tool-artifact id=call_disk")).toBeNull(); // memory gone
54
+ const onDisk = await findArtifactOnDisk("tool-artifact id=call_disk");
55
+ expect(onDisk?.content).toBe("PERSISTED CONTENT");
56
+ expect(onDisk?.toolName).toBe("read_file");
57
+ });
58
+ it("newest record for an id wins on disk", async () => {
59
+ await appendArtifactToDisk("dup", "t", "OLD");
60
+ await appendArtifactToDisk("dup", "t", "NEW");
61
+ expect((await findArtifactOnDisk("tool-artifact id=dup"))?.content).toBe("NEW");
62
+ });
63
+ it("respects MUONROI_ARTIFACT_CACHE_DISK=0 (no disk read)", async () => {
64
+ await appendArtifactToDisk("x", "t", "C");
65
+ process.env.MUONROI_ARTIFACT_CACHE_DISK = "0";
66
+ expect(await findArtifactOnDisk("tool-artifact id=x")).toBeNull();
67
+ });
68
+ });
69
+ //# sourceMappingURL=artifact-cache.test.js.map
@@ -97,11 +97,13 @@ export async function mirrorRecallLocally(query, meta, logPath) {
97
97
  * unavailability/timeout — never throws for transport errors.
98
98
  */
99
99
  export async function searchEE(query, opts = {}) {
100
- const { createEEClient } = await import("./client.js");
101
- const { loadEEAuthToken, getCachedServerBaseUrl } = await import("./auth.js");
102
- const authToken = (await loadEEAuthToken()) ?? undefined;
103
- const baseUrl = getCachedServerBaseUrl() ?? undefined;
104
- return createEEClient({ baseUrl, authToken }).search(query, opts);
100
+ // Route through the shared injectable default client (same one the WRITE leg
101
+ // persistArtifact → getDefaultEEClient().extract uses), NOT a fresh per-call
102
+ // client. This unifies the anti-mù seam: setDefaultEEClient now intercepts BOTH
103
+ // the artifact write and the artifact READ leg, and the default client carries
104
+ // the boot-loaded token + 401 refresh maintained by intercept.ts.
105
+ const { getDefaultEEClient } = await import("./intercept.js");
106
+ return getDefaultEEClient().search(query, opts);
105
107
  }
106
108
  /**
107
109
  * Active recall over the EE brain via /api/recall (recallMode) — the fixed
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { setDefaultEEClient } from "./intercept.js";
3
+ import { searchEE } from "./search.js";
4
+ // Issue #3 seam: searchEE used to build a FRESH createEEClient, so the artifact
5
+ // READ leg (ee_query "tool-artifact id=X") could not be intercepted by
6
+ // setDefaultEEClient — while the WRITE leg (persistArtifact → getDefaultEEClient
7
+ // .extract) could. Routing searchEE through getDefaultEEClient unifies the seam:
8
+ // one injected client now intercepts both legs (testable end-to-end + the spot a
9
+ // durability fallback can hook).
10
+ describe("searchEE — routes through the injectable default EE client", () => {
11
+ afterEach(() => {
12
+ setDefaultEEClient(null); // teardown → next getDefaultEEClient lazy-inits a real one
13
+ });
14
+ it("uses getDefaultEEClient().search so the artifact READ leg is interceptable", async () => {
15
+ const fakeResp = { results: [{ id: "x", text: "REHYDRATED" }] };
16
+ const search = vi.fn().mockResolvedValue(fakeResp);
17
+ setDefaultEEClient({ search });
18
+ const out = await searchEE("tool-artifact id=x", { collections: ["experience-behavioral"], limit: 1 });
19
+ expect(search).toHaveBeenCalledWith("tool-artifact id=x", { collections: ["experience-behavioral"], limit: 1 });
20
+ expect(out).toBe(fakeResp);
21
+ });
22
+ });
23
+ //# sourceMappingURL=search.test.js.map
@@ -1,2 +1,2 @@
1
- export declare const PACKAGE_VERSION = "1.5.0";
1
+ export declare const PACKAGE_VERSION = "1.6.0";
2
2
  export declare const PACKAGE_DESCRIPTION = "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.";
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by scripts/sync-version.cjs. DO NOT EDIT BY HAND.
2
2
  // Sourced from package.json at build time so it survives bun --compile bundling.
3
- export const PACKAGE_VERSION = "1.5.0";
3
+ export const PACKAGE_VERSION = "1.6.0";
4
4
  export const PACKAGE_DESCRIPTION = "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.";
5
5
  //# sourceMappingURL=version.js.map
@@ -23,6 +23,8 @@ export declare const DEFAULT_RESERVE_TOKENS = 16384;
23
23
  export declare const DEFAULT_KEEP_RECENT_TOKENS = 20000;
24
24
  export declare const POST_TURN_MIN_TOKENS = 2000;
25
25
  export declare const COMPACTION_MAX_OUTPUT_TOKENS = 4096;
26
+ export declare const COMPACTION_META_MAX_OUTPUT_TOKENS = 1536;
27
+ export declare function metaCompactionMaxTokens(): number;
26
28
  export declare const TOOL_RESULT_MAX_CHARS_CONFIGURABLE = 8000;
27
29
  export declare const COMPACTION_SUMMARY_HEADER = "[Context checkpoint summary]";
28
30
  export declare function extractUserContent(content: unknown): string;
@@ -10,6 +10,19 @@ export const DEFAULT_RESERVE_TOKENS = 16_384;
10
10
  export const DEFAULT_KEEP_RECENT_TOKENS = 20_000;
11
11
  export const POST_TURN_MIN_TOKENS = 2_000;
12
12
  export const COMPACTION_MAX_OUTPUT_TOKENS = 4_096;
13
+ // Meta-analysis (agent/PIL self-eval) summaries are capped tighter than normal
14
+ // to prevent runaway summaries (session df2dbb878984: 73k input → 14k-char
15
+ // summary). Default 1536 (was a hard 1024) — modestly more fidelity now that
16
+ // anti-mù recovery (layer3 surfacing + the in-process/disk artifact cache)
17
+ // backstops detail loss, still ~2.3x below the 14k-char problem. Tune per machine
18
+ // with MUONROI_META_COMPACT_MAX_TOKENS (clamped 512..COMPACTION_MAX_OUTPUT_TOKENS).
19
+ export const COMPACTION_META_MAX_OUTPUT_TOKENS = 1_536;
20
+ export function metaCompactionMaxTokens() {
21
+ const raw = Number(process.env.MUONROI_META_COMPACT_MAX_TOKENS);
22
+ if (Number.isFinite(raw) && raw >= 512 && raw <= COMPACTION_MAX_OUTPUT_TOKENS)
23
+ return Math.floor(raw);
24
+ return COMPACTION_META_MAX_OUTPUT_TOKENS;
25
+ }
13
26
  export const TOOL_RESULT_MAX_CHARS_CONFIGURABLE = 8000;
14
27
  export const COMPACTION_SUMMARY_HEADER = "[Context checkpoint summary]";
15
28
  const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant.
@@ -450,7 +463,7 @@ async function summarizeConversation(provider, modelId, messages, reserveTokens,
450
463
  const userText = messages.map((m) => extractUserContent(m.content)).join("\n");
451
464
  const isMeta = isMetaAnalysisPrompt(userText);
452
465
  const effectiveMax = isMeta
453
- ? Math.min(1024, Math.max(512, Math.floor(reserveTokens * 0.5)))
466
+ ? Math.min(metaCompactionMaxTokens(), Math.max(512, Math.floor(reserveTokens * 0.5)))
454
467
  : Math.min(COMPACTION_MAX_OUTPUT_TOKENS, Math.max(512, Math.floor(reserveTokens * 0.8)));
455
468
  if (previousSummary) {
456
469
  promptParts.push(`Existing summary:\n${previousSummary}`);
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
2
  import { buildEffectiveTranscript } from "../storage/transcript-view.js";
3
- import { COMPACTION_SUMMARY_HEADER, createCompactionSummaryMessage, findCutPoint, prepareCompaction, serializeConversation, shouldCompactContext, } from "./compaction.js";
3
+ import { COMPACTION_META_MAX_OUTPUT_TOKENS, COMPACTION_SUMMARY_HEADER, createCompactionSummaryMessage, findCutPoint, metaCompactionMaxTokens, prepareCompaction, serializeConversation, shouldCompactContext, } from "./compaction.js";
4
4
  import { buildCheckpointReminder } from "./scope-reminder.js";
5
5
  import { __forceFallbackForTests } from "./token-counter.js";
6
6
  // Pin token counts to the chars/4 fallback so cut-point assertions remain stable.
@@ -160,4 +160,28 @@ describe("compaction helpers", () => {
160
160
  expect(r).toContain("tool-artifact");
161
161
  });
162
162
  });
163
+ describe("metaCompactionMaxTokens — meta summary cap (tunable, session 2b7a10219499)", () => {
164
+ it("defaults to 1536 — looser than the old hard 1024, still well below the 14k-char problem", () => {
165
+ delete process.env.MUONROI_META_COMPACT_MAX_TOKENS;
166
+ expect(metaCompactionMaxTokens()).toBe(COMPACTION_META_MAX_OUTPUT_TOKENS);
167
+ expect(COMPACTION_META_MAX_OUTPUT_TOKENS).toBe(1536);
168
+ expect(COMPACTION_META_MAX_OUTPUT_TOKENS).toBeGreaterThan(1024);
169
+ });
170
+ it("honors a valid MUONROI_META_COMPACT_MAX_TOKENS override", () => {
171
+ process.env.MUONROI_META_COMPACT_MAX_TOKENS = "2048";
172
+ try {
173
+ expect(metaCompactionMaxTokens()).toBe(2048);
174
+ }
175
+ finally {
176
+ delete process.env.MUONROI_META_COMPACT_MAX_TOKENS;
177
+ }
178
+ });
179
+ it("clamps out-of-range / garbage overrides to the default", () => {
180
+ for (const bad of ["999999", "100", "-5", "abc", ""]) {
181
+ process.env.MUONROI_META_COMPACT_MAX_TOKENS = bad;
182
+ expect(metaCompactionMaxTokens(), bad).toBe(COMPACTION_META_MAX_OUTPUT_TOKENS);
183
+ }
184
+ delete process.env.MUONROI_META_COMPACT_MAX_TOKENS;
185
+ });
186
+ });
163
187
  //# sourceMappingURL=compaction.test.js.map
@@ -50,6 +50,7 @@
50
50
  // - O1 (providerOptions shape forensics) — extractProviderOptionsShape
51
51
  // - siliconflow reasoning-strip — turnCaps.sanitizeHistory
52
52
  import { stepCountIs, streamText } from "ai";
53
+ import { recordArtifact } from "../ee/artifact-cache.js";
53
54
  import { getCachedAuthToken, getCachedServerBaseUrl } from "../ee/auth.js";
54
55
  import { routeFeedback, routeModel } from "../ee/bridge.js";
55
56
  import { getDefaultEEClient } from "../ee/intercept.js";
@@ -101,11 +102,11 @@ import { repairToolCallHook } from "./repair-tool-call.js";
101
102
  import { buildRepetitionReminder, recordAssistantBurst, shouldInjectRepetitionReminder, } from "./repetition-detector.js";
102
103
  import { classifyStreamError } from "./retry-classifier.js";
103
104
  import { forcedFinalize, getSessionLastTask, incSessionStep, parseBudgetOverride, recordSessionLastTask, resetSessionStep, resolveCeiling, } from "./scope-ceiling.js";
104
- import { attachReminderToMessages, buildCheckpointReminder, buildScopeReminder, cadenceForSize, shouldInjectCeilingCrossing, shouldInjectReminder, shouldInjectSoftWarn, } from "./scope-reminder.js";
105
+ import { attachReminderToMessages, buildCheckpointReminder, buildScopeReminder, cadenceForSize, shouldInjectCeilingCrossing, shouldInjectReminder, shouldInjectSoftWarn, shouldPreWarnCompaction, } from "./scope-reminder.js";
105
106
  import { attemptStallRescue, pushStallToolResult } from "./stall-rescue.js";
106
107
  import { createStallWatchdog, STALL_ERROR_MESSAGE } from "./stall-watchdog.js";
107
108
  import { wrapToolSetWithCap } from "./sub-agent-cap.js";
108
- import { compactSubAgentMessages } from "./subagent-compactor.js";
109
+ import { compactSubAgentMessages, cumulativeMessageChars } from "./subagent-compactor.js";
109
110
  import { detectTextEmittedToolCall, parseDsmlToolCalls } from "./text-tool-call-detector.js";
110
111
  import { createToolLoopCapPredicate } from "./tool-loop-cap.js";
111
112
  import { buildToolRepetitionAbortMessage, recordToolError as recordToolRepetitionError, recordToolSuccess as recordToolRepetitionSuccess, } from "./tool-repetition-detector.js";
@@ -1500,6 +1501,10 @@ export class MessageProcessor {
1500
1501
  const _cwd = process.cwd();
1501
1502
  const _sess = undefined; // best-effort; EE artifact still indexable by content + meta.toolCallId
1502
1503
  const persistArtifact = (toolCallId, toolName, fullContent, reason) => {
1504
+ // Local-first: record the FULL output in-process so ee_query can
1505
+ // rehydrate it even if EE is down (the EE extract below caps at 8k
1506
+ // and needs the network; the cache keeps up to 200k, no network).
1507
+ recordArtifact(toolCallId, toolName, fullContent);
1503
1508
  try {
1504
1509
  getDefaultEEClient()
1505
1510
  .extract({
@@ -1532,9 +1537,14 @@ export class MessageProcessor {
1532
1537
  // Pre-compaction visibility: give the agent one step of notice
1533
1538
  // before B4 actually rewrites history into stubs. This is the
1534
1539
  // advance warning that was missing — agent can now decide to
1535
- // summarize, finish, or request preservation.
1536
- const _preCompactWarnAt = Math.floor(topLevelCompactThreshold * 0.78);
1537
- if (stripped.length > _preCompactWarnAt && compacted === stripped) {
1540
+ // summarize, finish, or request preservation. Fires when we did
1541
+ // NOT compact this step (compacted === stripped, restored by the
1542
+ // compactSubAgentMessages no-op ref contract) AND the prompt is
1543
+ // approaching the threshold. Must compare CHARS (messages +
1544
+ // envelope), not stripped.length (a message count that never
1545
+ // exceeds a char-scaled threshold) — session 2b7a10219499.
1546
+ const _preWarnChars = cumulativeMessageChars(stripped) + envelopeChars;
1547
+ if (compacted === stripped && shouldPreWarnCompaction(_preWarnChars, topLevelCompactThreshold)) {
1538
1548
  const _cp = buildCheckpointReminder(sn, true);
1539
1549
  const _pre = `[pre-compaction warning at step ${sn} — next step(s) will likely rewrite older tool results to stubs (threshold ${topLevelCompactThreshold}, keepLast=${topLevelCompactKeepLast}). ${_cp} Summarize or finish if possible.]`;
1540
1550
  return { messages: attachReminderToMessages(stripped, _pre) };
@@ -100,3 +100,15 @@ export declare function attachReminderToMessages<T>(messages: ReadonlyArray<T>,
100
100
  * Used by prepareStep / sub-agent paths after compaction.
101
101
  */
102
102
  export declare function buildCheckpointReminder(iteration: number, hasEECheckpoint: boolean): string;
103
+ /**
104
+ * Pre-compaction "advance warning" gate. Fires when the prompt is approaching
105
+ * (default ≥78% of) the compaction threshold but compaction has NOT yet run this
106
+ * step — giving the agent one step to PRESERVE / finish before B3/B4 rewrites
107
+ * older tool results into stubs.
108
+ *
109
+ * `promptChars` MUST be the same quantity the compactor thresholds on (cumulative
110
+ * message chars + envelope chars), NOT the message COUNT. The original B4 wiring
111
+ * compared `stripped.length` (a message count, ~tens) against a char-scaled
112
+ * threshold (~156000), so the warning could never fire — session 2b7a10219499.
113
+ */
114
+ export declare function shouldPreWarnCompaction(promptChars: number, thresholdChars: number, ratio?: number): boolean;
@@ -218,4 +218,20 @@ export function buildCheckpointReminder(iteration, hasEECheckpoint) {
218
218
  return base;
219
219
  return base.slice(0, 220);
220
220
  }
221
+ /**
222
+ * Pre-compaction "advance warning" gate. Fires when the prompt is approaching
223
+ * (default ≥78% of) the compaction threshold but compaction has NOT yet run this
224
+ * step — giving the agent one step to PRESERVE / finish before B3/B4 rewrites
225
+ * older tool results into stubs.
226
+ *
227
+ * `promptChars` MUST be the same quantity the compactor thresholds on (cumulative
228
+ * message chars + envelope chars), NOT the message COUNT. The original B4 wiring
229
+ * compared `stripped.length` (a message count, ~tens) against a char-scaled
230
+ * threshold (~156000), so the warning could never fire — session 2b7a10219499.
231
+ */
232
+ export function shouldPreWarnCompaction(promptChars, thresholdChars, ratio = 0.78) {
233
+ if (thresholdChars <= 0 || promptChars <= 0)
234
+ return false;
235
+ return promptChars >= Math.floor(thresholdChars * ratio);
236
+ }
221
237
  //# sourceMappingURL=scope-reminder.js.map
@@ -13,7 +13,7 @@
13
13
  * - Reminder lives in tool_result/system message — never in system prompt
14
14
  */
15
15
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
16
- import { attachReminderToMessages, buildScopeReminder, cadenceForSize, shouldInjectCeilingCrossing, shouldInjectReminder, shouldInjectSoftWarn, } from "./scope-reminder.js";
16
+ import { attachReminderToMessages, buildScopeReminder, cadenceForSize, shouldInjectCeilingCrossing, shouldInjectReminder, shouldInjectSoftWarn, shouldPreWarnCompaction, } from "./scope-reminder.js";
17
17
  describe("cadenceForSize", () => {
18
18
  it("locks 3/5/8 for small/medium/large with hard floor >= 3", () => {
19
19
  expect(cadenceForSize("small")).toBe(3);
@@ -201,4 +201,25 @@ describe("attachReminderToMessages", () => {
201
201
  expect(out).toEqual(messages);
202
202
  });
203
203
  });
204
+ describe("shouldPreWarnCompaction (regression: session 2b7a10219499 dead pre-warning)", () => {
205
+ const THRESHOLD = 200_000; // MUONROI_TOP_LEVEL_COMPACT_THRESHOLD_CHARS default
206
+ it("fires when prompt chars reach >=78% of the threshold (approaching compaction)", () => {
207
+ expect(shouldPreWarnCompaction(Math.floor(THRESHOLD * 0.78), THRESHOLD)).toBe(true);
208
+ expect(shouldPreWarnCompaction(190_000, THRESHOLD)).toBe(true);
209
+ });
210
+ it("does NOT fire while comfortably below the threshold", () => {
211
+ expect(shouldPreWarnCompaction(100_000, THRESHOLD)).toBe(false);
212
+ expect(shouldPreWarnCompaction(0, THRESHOLD)).toBe(false);
213
+ });
214
+ it("guards against the original bug: a message COUNT can never trip a char threshold", () => {
215
+ // The dead wiring compared stripped.length (a message count, ~tens) to the
216
+ // char-scaled threshold. With chars it crosses; with a count it never does.
217
+ const messageCount = 60; // plausible long-session message count
218
+ expect(shouldPreWarnCompaction(messageCount, THRESHOLD)).toBe(false);
219
+ expect(shouldPreWarnCompaction(170_000, THRESHOLD)).toBe(true);
220
+ });
221
+ it("is inert for a zero/negative threshold (no compaction configured)", () => {
222
+ expect(shouldPreWarnCompaction(999_999, 0)).toBe(false);
223
+ });
224
+ });
204
225
  //# sourceMappingURL=scope-reminder.test.js.map
@@ -27,6 +27,7 @@
27
27
  // - F1 (sub-agent cumulative cap) — wrapToolSetWithCap
28
28
  // - siliconflow reasoning-strip — taskCaps.sanitizeHistory
29
29
  import { stepCountIs, streamText } from "ai";
30
+ import { recordArtifact } from "../ee/artifact-cache.js";
30
31
  import { getDefaultEEClient } from "../ee/intercept.js";
31
32
  import { acquireMcpTools } from "../mcp/client-pool.js";
32
33
  import { normalizeModelId } from "../models/registry.js";
@@ -412,6 +413,8 @@ export class StreamRunner {
412
413
  }
413
414
  // Idea 4 persist for sub-agent elisions (best-effort; may lack full session but EE can still index the artifact content).
414
415
  const persistSubArtifact = (toolCallId, toolName, fullContent, reason) => {
416
+ // Local-first durable cache so ee_query rehydrates even when EE is down.
417
+ recordArtifact(toolCallId, toolName, fullContent);
415
418
  try {
416
419
  getDefaultEEClient()
417
420
  .extract({
@@ -106,8 +106,11 @@ export interface SubAgentCompactorOptions {
106
106
  export declare const CHARS_PER_TOKEN = 4;
107
107
  export declare const SUBAGENT_COMPACT_DEFAULT_THRESHOLD = 80000;
108
108
  export declare const SUBAGENT_COMPACT_DEFAULT_KEEP_LAST = 3;
109
- /** Tools whose full outputs are high-value for anti-mù (idea 1). Keep verbatim even if older than keepLast. */
110
- export declare const IMPORTANT_TOOL_NAMES: readonly ["read_file", "grep", "lsp", "bash"];
109
+ /** Tools whose full outputs are high-value for anti-mù (idea 1). Keep verbatim even if older than keepLast.
110
+ * Extended for meta self-eval: ee_query / usage_forensics / selfverify_* are the exact artifacts
111
+ * the native contract + native-capabilities tell the agent to rely on for "task finished?" and
112
+ * rehydrate during long meta conversations about CLI/PIL/compaction/EE. */
113
+ export declare const IMPORTANT_TOOL_NAMES: readonly ["read_file", "grep", "lsp", "bash", "ee_query", "usage_forensics", "selfverify_start", "selfverify_result", "selfverify_status"];
111
114
  /**
112
115
  * Heuristic: keep full (no stub) for high-signal tool results.
113
116
  * Signals: allowlist tool + (error/todo/plan/keyfile/large output or explicit keep list).
@@ -116,8 +119,14 @@ export declare const IMPORTANT_TOOL_NAMES: readonly ["read_file", "grep", "lsp",
116
119
  export declare function isHighValueToolResult(toolName: string, preview: string, explicitKeepIds?: Set<string>, toolCallId?: string): boolean;
117
120
  export declare function cumulativeMessageChars(messages: ReadonlyArray<ModelMessage>): number;
118
121
  /**
119
- * Compact a sub-agent message array in place-like fashion. Returns a NEW
120
- * array; the input is not mutated. Below the threshold the original array
121
- * reference is returned for cheap identity comparison in tests.
122
+ * Compact a sub-agent message array in place-like fashion. The input is never
123
+ * mutated. When compaction actually elides something a NEW array is returned.
124
+ * On a no-op (below threshold, or too few tool turns to skip) the ORIGINAL input
125
+ * array is returned BY REFERENCE so callers can detect "did not compact this
126
+ * step" via identity (`compacted === input`). The B4 wiring in
127
+ * message-processor.ts (pre-compaction warning + compaction note gating) and the
128
+ * sub-agent wiring in stream-runner.ts both rely on this contract — returning a
129
+ * fresh slice on a no-op silently made the warning dead and the note fire every
130
+ * step.
122
131
  */
123
132
  export declare function compactSubAgentMessages(messages: ReadonlyArray<ModelMessage>, opts?: SubAgentCompactorOptions): ModelMessage[];
@@ -58,8 +58,21 @@ export const SUBAGENT_COMPACT_DEFAULT_THRESHOLD = 80_000;
58
58
  export const SUBAGENT_COMPACT_DEFAULT_KEEP_LAST = 3;
59
59
  const DEFAULT_OUTPUT_PREVIEW_CHARS = 200;
60
60
  const DEFAULT_LABEL = "sub-agent";
61
- /** Tools whose full outputs are high-value for anti-mù (idea 1). Keep verbatim even if older than keepLast. */
62
- export const IMPORTANT_TOOL_NAMES = ["read_file", "grep", "lsp", "bash"];
61
+ /** Tools whose full outputs are high-value for anti-mù (idea 1). Keep verbatim even if older than keepLast.
62
+ * Extended for meta self-eval: ee_query / usage_forensics / selfverify_* are the exact artifacts
63
+ * the native contract + native-capabilities tell the agent to rely on for "task finished?" and
64
+ * rehydrate during long meta conversations about CLI/PIL/compaction/EE. */
65
+ export const IMPORTANT_TOOL_NAMES = [
66
+ "read_file",
67
+ "grep",
68
+ "lsp",
69
+ "bash",
70
+ "ee_query",
71
+ "usage_forensics",
72
+ "selfverify_start",
73
+ "selfverify_result",
74
+ "selfverify_status",
75
+ ];
63
76
  /**
64
77
  * Heuristic: keep full (no stub) for high-signal tool results.
65
78
  * Signals: allowlist tool + (error/todo/plan/keyfile/large output or explicit keep list).
@@ -268,7 +281,9 @@ function rewriteOlderToolMessage(msg, previewChars, label, keepToolIds, persistA
268
281
  try {
269
282
  persistArtifact(toolCallId, tr.toolName, rawPreview, "elided-by-compactor");
270
283
  }
271
- catch { /* fail-open */ }
284
+ catch {
285
+ /* fail-open */
286
+ }
272
287
  }
273
288
  return {
274
289
  type: "tool-result",
@@ -282,9 +297,15 @@ function rewriteOlderToolMessage(msg, previewChars, label, keepToolIds, persistA
282
297
  return { ...msg, content: rewritten };
283
298
  }
284
299
  /**
285
- * Compact a sub-agent message array in place-like fashion. Returns a NEW
286
- * array; the input is not mutated. Below the threshold the original array
287
- * reference is returned for cheap identity comparison in tests.
300
+ * Compact a sub-agent message array in place-like fashion. The input is never
301
+ * mutated. When compaction actually elides something a NEW array is returned.
302
+ * On a no-op (below threshold, or too few tool turns to skip) the ORIGINAL input
303
+ * array is returned BY REFERENCE so callers can detect "did not compact this
304
+ * step" via identity (`compacted === input`). The B4 wiring in
305
+ * message-processor.ts (pre-compaction warning + compaction note gating) and the
306
+ * sub-agent wiring in stream-runner.ts both rely on this contract — returning a
307
+ * fresh slice on a no-op silently made the warning dead and the note fire every
308
+ * step.
288
309
  */
289
310
  export function compactSubAgentMessages(messages, opts = {}) {
290
311
  const resolved = resolveOpts(opts);
@@ -299,11 +320,12 @@ export function compactSubAgentMessages(messages, opts = {}) {
299
320
  // window utilization. Falls back to static char threshold + keepLast
300
321
  // when no contextWindowTokens supplied (preserves old behaviour).
301
322
  const { effectiveThresholdChars, effectiveKeepLastTurns } = computeDynamicParams(total, resolved);
323
+ // No-op: return the input BY REFERENCE (contract above) so `compacted === input`.
302
324
  if (total < effectiveThresholdChars)
303
- return messages.slice();
325
+ return messages;
304
326
  const keepFrom = findKeepFromIndex(messages, effectiveKeepLastTurns);
305
327
  if (keepFrom <= 0)
306
- return messages.slice();
328
+ return messages;
307
329
  // Walk older messages; rewrite fresh tool results into stubs, super-shrink
308
330
  // already-stubbed results (F1), and strip args off older assistant
309
331
  // tool-call shells (F1). The 1:1 assistant↔tool pairing required by the AI
@@ -64,6 +64,24 @@ describe("subagent-compactor: compactSubAgentMessages", () => {
64
64
  // No tool-result rewrite happened — output object identity per part preserved.
65
65
  expect(out[3]).toBe(msgs[3]);
66
66
  });
67
+ it("returns the SAME array reference on a no-op below threshold (compacted===input contract)", () => {
68
+ // Callers (message-processor B4 prepareStep:1840/1908/1914) detect "did NOT
69
+ // compact this step" via `compacted === stripped`. The docstring promises the
70
+ // original ref on a no-op; returning a fresh slice silently broke that —
71
+ // making the pre-compaction warning dead and the compaction note fire every
72
+ // step. Lock the identity contract.
73
+ const msgs = buildHistory(2, 5); // below threshold
74
+ expect(compactSubAgentMessages(msgs)).toBe(msgs);
75
+ });
76
+ it("returns a NEW array when compaction actually elides (compacted!==input)", () => {
77
+ const msgs = buildHistory(10, 10); // ~100kb > threshold
78
+ for (const m of msgs) {
79
+ if (m.role === "tool" && Array.isArray(m.content)) {
80
+ m.content[0].toolName = "other_tool"; // force low-value so it elides
81
+ }
82
+ }
83
+ expect(compactSubAgentMessages(msgs)).not.toBe(msgs);
84
+ });
67
85
  it("compacts when cumulative chars exceed threshold", () => {
68
86
  const msgs = buildHistory(10, 10); // ~100kb of tool output
69
87
  // Neutralize to test pure size-based elision (high-value keep would reduce savings).
@@ -222,6 +222,15 @@ describe("getResponseToolSet — Phase 2b deliverableKind consume (model overrid
222
222
  // …and an explicit report request keeps it.
223
223
  expect(Object.keys(getResponseToolSet({ ...makeCtx("analyze", null), raw: "list all cost leaks" }))).toContain("respond_analyze");
224
224
  });
225
+ it("DROPS respond_* on an implement turn even when mis-classified as report (session 2b7a10219499)", () => {
226
+ // "lên plan rồi improvement … cải thiện X" is an implement turn the model
227
+ // tagged deliverable=report; the report-exception used to KEEP respond_plan,
228
+ // so the model stated a plan and ended the turn with edits done but
229
+ // uncommitted/unreported. Implementation intent must suppress the terminal
230
+ // tool BEFORE the deliverable branch is consulted.
231
+ expect(getResponseToolSet(ctxD("lên plan rồi improvement nhé, focus cải thiện Compaction", "plan", "report"))).toEqual({});
232
+ expect(getResponseToolSet(ctxD("improve the compactor and implement the fix", "plan", "report"))).toEqual({});
233
+ });
225
234
  });
226
235
  describe("applyPilSuffix — outputStyle variants", () => {
227
236
  const styles = ["concise", "detailed", "balanced"];
@@ -413,4 +422,16 @@ describe("isQuestionLike — Vietnamese yes/no question frames (regression: sess
413
422
  expect(isQuestionLike("explain the pipeline")).toBe(true);
414
423
  });
415
424
  });
425
+ describe("isImplementationIntent — improve / cải thiện (regression: session 2b7a10219499)", () => {
426
+ it("recognises improve/improvement + VI cải thiện as implement turns", () => {
427
+ expect(isImplementationIntent("improve the compactor")).toBe(true);
428
+ expect(isImplementationIntent("lên plan rồi improvement nhé")).toBe(true);
429
+ expect(isImplementationIntent("focus cải thiện Compaction")).toBe(true);
430
+ expect(isImplementationIntent("cai thien phan compaction")).toBe(true);
431
+ });
432
+ it("does not over-match analysis questions that merely describe behaviour", () => {
433
+ expect(isImplementationIntent("what does the enrichment layer do?")).toBe(false);
434
+ expect(isImplementationIntent("why does the suite fail — break it down")).toBe(false);
435
+ });
436
+ });
416
437
  //# sourceMappingURL=layer6-output.test.js.map
@@ -0,0 +1,112 @@
1
+ import { beforeEach, describe, expect, test, vi } from "vitest";
2
+ import { surfaceCompactionArtifacts } from "../layer3-ee-injection.js";
3
+ // Issue #4 — targeted complement to layer3's checkpoint arm on meta turns.
4
+ // layer3 (now run on meta after issue #2) surfaces checkpoints via a FIXED
5
+ // recency query; this arm searches by the meta question (ctx.raw) to surface the
6
+ // elided tool-artifacts relevant to it, and DEFERS when layer3 already injected a
7
+ // checkpoint block. Mock the EE search + the audit log so the test stays offline.
8
+ vi.mock("../../ee/bridge.js", () => ({
9
+ searchByText: vi.fn().mockResolvedValue([]),
10
+ }));
11
+ vi.mock("../../storage/interaction-log.js", () => ({
12
+ logInteraction: vi.fn(),
13
+ }));
14
+ import { searchByText } from "../../ee/bridge.js";
15
+ function makeCtx(overrides = {}) {
16
+ return {
17
+ raw: "compaction cần cải thiện gì trong CLI",
18
+ enriched: "compaction cần cải thiện gì trong CLI",
19
+ taskType: "general",
20
+ domain: null,
21
+ confidence: 0.85,
22
+ outputStyle: "balanced",
23
+ tokenBudget: 2000,
24
+ metrics: null,
25
+ layers: [],
26
+ sessionId: "sess-meta-1",
27
+ ...overrides,
28
+ };
29
+ }
30
+ const artifactPoint = {
31
+ id: "art1",
32
+ score: 0.9,
33
+ payload: {
34
+ text: "tool-artifact id=call_7 toolName=read_file elided 4200 chars: src/orchestrator/compaction.ts createCompactionSummaryMessage ...",
35
+ },
36
+ collection: "experience-behavioral",
37
+ };
38
+ const checkpointPoint = {
39
+ id: "cp1",
40
+ score: 0.8,
41
+ payload: { text: "Context checkpoint summary ✔ DONE: extended IMPORTANT_TOOL_NAMES; tests 16/16" },
42
+ collection: "experience-behavioral",
43
+ };
44
+ const genericPoint = {
45
+ id: "gen1",
46
+ score: 0.97,
47
+ payload: { text: "Always run the full test suite before pushing" },
48
+ collection: "experience-behavioral",
49
+ };
50
+ describe("surfaceCompactionArtifacts (issue #4 — meta-turn auto-surface)", () => {
51
+ beforeEach(() => {
52
+ vi.mocked(searchByText).mockReset();
53
+ vi.mocked(searchByText).mockResolvedValue([]);
54
+ });
55
+ test("auto-surfaces [artifact] + checkpoint refs (and the rehydrate instruction) into enriched", async () => {
56
+ // biome-ignore lint/suspicious/noExplicitAny: test fixture shape mirrors EEPoint
57
+ vi.mocked(searchByText).mockResolvedValue([artifactPoint, checkpointPoint]);
58
+ const ctx = makeCtx();
59
+ const out = await surfaceCompactionArtifacts(ctx);
60
+ expect(out.enriched).toContain("[artifact]"); // artifact-typed line
61
+ expect(out.enriched).toContain("ee.query tool"); // how to rehydrate the full output
62
+ expect(out.enriched).toContain("call_7"); // the concrete tool-artifact id the agent can fetch
63
+ const layer = out.layers.find((l) => l.name === "ee-meta-artifacts");
64
+ expect(layer?.applied).toBe(true);
65
+ expect(layer?.delta).toContain("artifacts=2");
66
+ // Searches only the behavioral collection (where tool-artifacts are persisted).
67
+ expect(vi.mocked(searchByText)).toHaveBeenCalledWith(expect.stringContaining("tool-artifact"), ["experience-behavioral"], expect.any(Number), expect.any(Object));
68
+ });
69
+ test("no sessionId → unchanged, no EE call (no prior compaction to rehydrate)", async () => {
70
+ const ctx = makeCtx({ sessionId: undefined });
71
+ const out = await surfaceCompactionArtifacts(ctx);
72
+ expect(out.enriched).toBe(ctx.enriched);
73
+ expect(out.layers.find((l) => l.name === "ee-meta-artifacts")?.delta).toBe("no-session");
74
+ expect(vi.mocked(searchByText)).not.toHaveBeenCalled();
75
+ });
76
+ test("search failure is fail-open + recorded (delta=error=…, enriched unchanged)", async () => {
77
+ vi.mocked(searchByText).mockRejectedValue(new Error("EE down"));
78
+ const ctx = makeCtx();
79
+ const out = await surfaceCompactionArtifacts(ctx);
80
+ expect(out.enriched).toBe(ctx.enriched);
81
+ expect(out.layers.find((l) => l.name === "ee-meta-artifacts")?.delta).toMatch(/^error=/);
82
+ });
83
+ test("generic behavioral hits are filtered out (not mislabelled as artifacts)", async () => {
84
+ // biome-ignore lint/suspicious/noExplicitAny: test fixture shape mirrors EEPoint
85
+ vi.mocked(searchByText).mockResolvedValue([genericPoint]);
86
+ const ctx = makeCtx();
87
+ const out = await surfaceCompactionArtifacts(ctx);
88
+ expect(out.enriched).toBe(ctx.enriched);
89
+ expect(out.layers.find((l) => l.name === "ee-meta-artifacts")?.delta).toBe("no-artifacts");
90
+ });
91
+ test("defers to layer3 — skips with NO EE call when a checkpoint block is already present", async () => {
92
+ // layer3 ran first this turn and injected a checkpoint block (its marker is
93
+ // in enriched). The complement must not duplicate it or pay a 2nd round-trip.
94
+ const enriched = `${makeCtx().raw}\n[task checkpoints …]\n<!-- ee-checkpoint-injected:0123456789abcdef -->`;
95
+ const out = await surfaceCompactionArtifacts(makeCtx({ enriched }));
96
+ expect(out.layers.find((l) => l.name === "ee-meta-artifacts")?.delta).toBe("already-surfaced");
97
+ expect(out.enriched).toBe(enriched); // unchanged
98
+ expect(vi.mocked(searchByText)).not.toHaveBeenCalled();
99
+ });
100
+ test("idempotent — a second pass on its own output defers (marker it wrote is seen)", async () => {
101
+ // biome-ignore lint/suspicious/noExplicitAny: test fixture shape mirrors EEPoint
102
+ vi.mocked(searchByText).mockResolvedValue([artifactPoint]);
103
+ const first = await surfaceCompactionArtifacts(makeCtx());
104
+ expect(first.enriched).toContain("[artifact]");
105
+ expect(vi.mocked(searchByText)).toHaveBeenCalledTimes(1);
106
+ const second = await surfaceCompactionArtifacts(makeCtx({ enriched: first.enriched }));
107
+ expect(second.layers.find((l) => l.name === "ee-meta-artifacts")?.delta).toBe("already-surfaced");
108
+ expect(second.enriched).toBe(first.enriched); // not grown a second time
109
+ expect(vi.mocked(searchByText)).toHaveBeenCalledTimes(1); // no second round-trip
110
+ });
111
+ });
112
+ //# sourceMappingURL=surface-compaction-artifacts.test.js.map
@@ -16,3 +16,22 @@
16
16
  */
17
17
  import type { PipelineContext } from "./types.js";
18
18
  export declare function layer3EeInjection(ctx: PipelineContext): Promise<PipelineContext>;
19
+ /**
20
+ * Issue #4 — meta-turn TARGETED complement to Layer 3's checkpoint arm.
21
+ *
22
+ * Since issue #2, Layer 3 now runs on the meta-analysis path too, so its
23
+ * checkpoint arm already surfaces recent checkpoints/artifacts for the agent.
24
+ * That arm uses a FIXED recency query, though — it isn't biased toward the
25
+ * current meta question. This arm fills that gap: it searches by `ctx.raw` so a
26
+ * self-evaluating agent sees the elided tool-artifacts RELEVANT to what it's
27
+ * analyzing, rendered via the same `formatTaskCheckpoints` so the `[artifact]
28
+ * … id=X` refs appear automatically instead of waiting on a manual `ee_query`.
29
+ *
30
+ * Defers to Layer 3: if a checkpoint block was already injected this turn (any
31
+ * `ee-checkpoint-injected` marker present) it skips entirely — no duplicate
32
+ * block and no second EE round-trip. Gated on `sessionId` (no session ⇒ no prior
33
+ * compaction to rehydrate). Strictly additive and fail-open: any error /
34
+ * no-session / no-match / already-surfaced returns ctx with the original
35
+ * `enriched` plus an `ee-meta-artifacts` layer marker for forensics.
36
+ */
37
+ export declare function surfaceCompactionArtifacts(ctx: PipelineContext): Promise<PipelineContext>;
@@ -119,7 +119,7 @@ async function queryEeBridge(raw) {
119
119
  const [principleRaw, behavioralRaw, checkpointRaw] = await Promise.all([
120
120
  searchByText(raw, ["experience-principles"], 3, signal),
121
121
  searchByText(raw, ["experience-behavioral"], 4, signal),
122
- searchByText("Context checkpoint summary OR \"compaction checkpoint\" recent Progress DONE elided OR tool-artifact OR \"tool result id=\"", ["experience-behavioral"], 3, signal).catch(() => []),
122
+ searchByText('Context checkpoint summary OR "compaction checkpoint" recent Progress DONE elided OR tool-artifact OR "tool result id="', ["experience-behavioral"], 3, signal).catch(() => []),
123
123
  ]);
124
124
  const principlePoints = principleRaw.filter((p) => (p.score ?? 0) >= PIL_PRINCIPLES_FLOOR);
125
125
  const behavioralPoints = behavioralRaw.filter((p) => (p.score ?? 0) >= PIL_SCORE_FLOOR);
@@ -161,14 +161,16 @@ function formatExperienceHints(points) {
161
161
  function formatTaskCheckpoints(points) {
162
162
  if (points.length === 0)
163
163
  return "";
164
- const lines = points.map((p) => {
164
+ const lines = points
165
+ .map((p) => {
165
166
  const t = extractPointText(p);
166
167
  // Idea 4: surface tool-artifact refs so agent sees "elided high-value, query for full"
167
168
  if (/tool-artifact|tool result id=|elided.*id=/.test(t.toLowerCase())) {
168
169
  return `- [artifact] ${t.slice(0, 160)} [id:${p.id}]`;
169
170
  }
170
171
  return `- ${t.slice(0, 180)} [id:${p.id}]`;
171
- }).filter((l) => l !== "- ");
172
+ })
173
+ .filter((l) => l !== "- ");
172
174
  if (lines.length === 0)
173
175
  return "";
174
176
  return `[task checkpoints — prior compactions: use to answer "task finished?", "compacted yet?". Artifacts: use ee.query tool with "tool-artifact id=XXX" for full elided tool output.] \n${lines.join("\n")}`;
@@ -282,7 +284,7 @@ export async function layer3EeInjection(ctx) {
282
284
  const text = extractPointText(p);
283
285
  return text.length === 0 || !checkpointMarkerShas.has(payloadSha16(text));
284
286
  })
285
- : (result.checkpointPoints || []);
287
+ : result.checkpointPoints || [];
286
288
  const allPoints = [...deduplicatedPrinciples, ...deduplicatedBehavioral, ...deduplicatedCheckpoints];
287
289
  // STALE-01: Register injected point IDs for prompt-stale reconciliation.
288
290
  updateLastSurfacedState(allPoints.map((p) => String(p.id)));
@@ -359,4 +361,94 @@ export async function layer3EeInjection(ctx) {
359
361
  ],
360
362
  };
361
363
  }
364
+ /**
365
+ * Records whose text actually reads like a compaction checkpoint or an elided
366
+ * tool-artifact. Used to keep generic behavioral hits from being mislabelled as
367
+ * `[artifact]`/checkpoint lines when we search by the meta question (ctx.raw)
368
+ * rather than the fixed checkpoint-arm query.
369
+ */
370
+ const CHECKPOINT_LIKE_RE = /context checkpoint summary|compaction checkpoint|tool-artifact|tool result id=|elided|progress[^a-z]*done|✔/i;
371
+ /**
372
+ * Issue #4 — meta-turn TARGETED complement to Layer 3's checkpoint arm.
373
+ *
374
+ * Since issue #2, Layer 3 now runs on the meta-analysis path too, so its
375
+ * checkpoint arm already surfaces recent checkpoints/artifacts for the agent.
376
+ * That arm uses a FIXED recency query, though — it isn't biased toward the
377
+ * current meta question. This arm fills that gap: it searches by `ctx.raw` so a
378
+ * self-evaluating agent sees the elided tool-artifacts RELEVANT to what it's
379
+ * analyzing, rendered via the same `formatTaskCheckpoints` so the `[artifact]
380
+ * … id=X` refs appear automatically instead of waiting on a manual `ee_query`.
381
+ *
382
+ * Defers to Layer 3: if a checkpoint block was already injected this turn (any
383
+ * `ee-checkpoint-injected` marker present) it skips entirely — no duplicate
384
+ * block and no second EE round-trip. Gated on `sessionId` (no session ⇒ no prior
385
+ * compaction to rehydrate). Strictly additive and fail-open: any error /
386
+ * no-session / no-match / already-surfaced returns ctx with the original
387
+ * `enriched` plus an `ee-meta-artifacts` layer marker for forensics.
388
+ */
389
+ export async function surfaceCompactionArtifacts(ctx) {
390
+ const markLayer = (applied, delta) => ({
391
+ ...ctx,
392
+ layers: [...ctx.layers, { name: "ee-meta-artifacts", applied, delta }],
393
+ });
394
+ if (!ctx.sessionId)
395
+ return markLayer(false, "no-session");
396
+ // Defer to Layer 3: a checkpoint/artifact block is already present this turn,
397
+ // so don't duplicate it or pay a second EE round-trip. This arm only fills the
398
+ // gap when Layer 3's fixed-query checkpoint arm surfaced nothing.
399
+ if (extractCheckpointMarkerShas(ctx.enriched).size > 0)
400
+ return markLayer(false, "already-surfaced");
401
+ let points = [];
402
+ try {
403
+ const signal = AbortSignal.timeout(PIL_SEARCH_TIMEOUT_MS);
404
+ // Bias toward records relevant to THIS meta question (ctx.raw) while pulling
405
+ // in checkpoint/artifact vocabulary so the single cheap arm lands on the
406
+ // compaction records rather than generic behavioral patterns.
407
+ const query = `${ctx.raw}\nContext checkpoint summary tool-artifact "tool result id=" elided Progress DONE`;
408
+ const raw = await searchByText(query, ["experience-behavioral"], 5, signal);
409
+ points = raw
410
+ .filter((p) => (p.score ?? 0) >= PIL_SCORE_FLOOR * 0.7)
411
+ .filter((p) => CHECKPOINT_LIKE_RE.test(extractPointText(p)));
412
+ }
413
+ catch (err) {
414
+ logEeFailure("pil.meta.surfaceCompactionArtifacts", classifyEeError(err), err, { budgetMs: PIL_SEARCH_TIMEOUT_MS });
415
+ return markLayer(false, `error=${String(err)}`);
416
+ }
417
+ if (points.length === 0)
418
+ return markLayer(false, "no-artifacts");
419
+ const cpText = formatTaskCheckpoints(points);
420
+ if (!cpText)
421
+ return markLayer(false, "no-artifacts");
422
+ // Append the marker AFTER truncation so it always survives into `enriched`
423
+ // — that marker is what makes the defer-check above fire on any later pass.
424
+ const blockSha = payloadSha16(cpText);
425
+ const body = truncateToBudget(cpText, Math.floor(ctx.tokenBudget * 0.12));
426
+ const block = `${body}\n<!-- ee-checkpoint-injected:${blockSha} -->`;
427
+ try {
428
+ if (ctx.sessionId) {
429
+ logInteraction(ctx.sessionId, "ee_injection", {
430
+ eventSubtype: "injected",
431
+ data: {
432
+ phase: "pil_meta_artifacts",
433
+ role: "knowledge_retriever",
434
+ checkpointCount: points.length,
435
+ pointIds: points.map((p) => String(p.id)),
436
+ injectedChars: block.length,
437
+ },
438
+ });
439
+ }
440
+ }
441
+ catch (err) {
442
+ // No silent catch: surfacing succeeded; only the audit write failed.
443
+ console.error(`[pil.meta.surfaceCompactionArtifacts] interaction log failed: ${err?.message}`);
444
+ }
445
+ return {
446
+ ...ctx,
447
+ enriched: `${ctx.enriched}\n${block}`,
448
+ layers: [
449
+ ...ctx.layers,
450
+ { name: "ee-meta-artifacts", applied: true, delta: `artifacts=${points.length} chars=${block.length}` },
451
+ ],
452
+ };
453
+ }
362
454
  //# sourceMappingURL=layer3-ee-injection.js.map
@@ -258,12 +258,16 @@ export function applyPilSuffix(systemPrompt, ctx, responseToolsActive = false) {
258
258
  * OUTPUT RULES (graceful — exactly what code-heavy tasks already do), so a false
259
259
  * positive on an analysis turn only forgoes structured JSON, never breaks output.
260
260
  *
261
- * High-signal verbs only (implement/edit/wire/rewrite/rename/scaffold/refactor,
262
- * "make the change", "apply the fix/patch", VI equivalents). Bare "fix"/"replace"
263
- * are excluded — too common in analysis ("explain the fix") — so pure
264
- * analyze/plan/debug-investigation turns keep their structured output.
261
+ * High-signal verbs only (implement/edit/wire/rewrite/rename/scaffold/refactor/
262
+ * improve, "make the change", "apply the fix/patch", VI equivalents incl. "cải
263
+ * thiện"). Bare "fix"/"replace" are excluded — too common in analysis ("explain
264
+ * the fix") — so pure analyze/plan/debug-investigation turns keep their
265
+ * structured output. "improve(ment)" / "cải thiện" added after session
266
+ * 2b7a10219499: "lên plan rồi improvement … cải thiện Compaction" was an
267
+ * implement turn the model mis-classified as a `report`, so a terminal
268
+ * respond_plan ended it on a plan (edits done but uncommitted/unreported).
265
269
  */
266
- const IMPLEMENTATION_INTENT_RE = /\b(implement|edit|wire(?:\s+up)?|rewrite|rename|scaffold|refactor)\b|\bmake\s+(the\s+)?(change|edit|modification)s?\b|\bapply\s+(the\s+)?(fix|change|patch|edit|diff)\b|(?:^|\s)(triển\s*khai|trien\s*khai|chỉnh\s*sửa|chinh\s*sua|viết\s*lại|viet\s*lai|đổi\s*tên|doi\s*ten)\b/i;
270
+ const IMPLEMENTATION_INTENT_RE = /\b(implement|edit|wire(?:\s+up)?|rewrite|rename|scaffold|refactor)\b|\bimprove(?:ment)?\b|\bmake\s+(the\s+)?(change|edit|modification)s?\b|\bapply\s+(the\s+)?(fix|change|patch|edit|diff)\b|(?:^|\s)(triển\s*khai|trien\s*khai|chỉnh\s*sửa|chinh\s*sua|viết\s*lại|viet\s*lai|đổi\s*tên|doi\s*ten|cải\s*thiện|cai\s*thien)\b/i;
267
271
  export function isImplementationIntent(raw) {
268
272
  return !!raw && IMPLEMENTATION_INTENT_RE.test(raw);
269
273
  }
@@ -340,6 +344,15 @@ export function getResponseToolSet(ctx, providerId) {
340
344
  // - report → keep the structured tool (its value IS the structure).
341
345
  // Only when the model didn't emit a deliverable (null → legacy cascade / model
342
346
  // omitted the word) do we fall back to the legacy regex predicates.
347
+ // Implementation intent ALWAYS suppresses a terminal respond_* — checked
348
+ // BEFORE the deliverable branch so a mis-classified `report` can't bypass it
349
+ // (session 2b7a10219499: a "plan rồi improvement" implement turn got
350
+ // deliverable=report → the report-exception below kept respond_plan → the
351
+ // model stated a plan and ended the turn with edits done but uncommitted).
352
+ // A respond_* tool lets the model "answer" and stop before edits land, so any
353
+ // implement turn must fall through to the markdown OUTPUT RULES instead.
354
+ if (isImplementationIntent(ctx.raw))
355
+ return {};
343
356
  if (ctx.deliverableKind) {
344
357
  if (ctx.deliverableKind === "code")
345
358
  return {};
@@ -347,8 +360,6 @@ export function getResponseToolSet(ctx, providerId) {
347
360
  return {};
348
361
  }
349
362
  else {
350
- if (isImplementationIntent(ctx.raw))
351
- return {};
352
363
  if (ctx.taskType !== "general" && !prefersStructuredReport(ctx.raw))
353
364
  return {};
354
365
  }
@@ -22,7 +22,7 @@ import { isDiscoveryEnabled } from "./config.js";
22
22
  import { scoreComplexitySize } from "./layer1_5-complexity-size.js";
23
23
  import { layer1Intent } from "./layer1-intent.js";
24
24
  import { layer2Personality } from "./layer2-personality.js";
25
- import { layer3EeInjection } from "./layer3-ee-injection.js";
25
+ import { layer3EeInjection, surfaceCompactionArtifacts } from "./layer3-ee-injection.js";
26
26
  import { layer4Gsd } from "./layer4-gsd.js";
27
27
  import { layer5Context } from "./layer5-context.js";
28
28
  import { isMetaAnalysisPrompt, layer6Output } from "./layer6-output.js";
@@ -144,15 +144,21 @@ async function runLayers(ctx, options) {
144
144
  }
145
145
  if (ctx.taskType !== null) {
146
146
  await timed("layer2-personality", layer2Personality);
147
+ // Issue #2: meta-analysis turns used to skip layer3 (EE recall) + layer5
148
+ // (context) to cut overhead — but that starved exactly the self-evaluation
149
+ // turns where behavioral/principle recall matters most. Run the full
150
+ // sequence for every taskType-bearing turn now. In the live (interactive)
151
+ // path there is no pipeline timeout (see runPipeline), and each EE layer is
152
+ // internally timeout-bounded, so meta turns just carry the same EE budget as
153
+ // a normal turn.
154
+ await timed("layer3-ee-injection", layer3EeInjection);
155
+ await timed("layer4-gsd-structuring", layer4Gsd);
156
+ await timed("layer5-context-enrichment", layer5Context);
147
157
  if (isMetaAnalysisPrompt(ctx.raw)) {
148
- // FIX: skip heavy EE (layer3) + context (layer5) for meta-analysis turns
149
- // to reduce PIL overhead on evaluation/improvement questions (as intended).
150
- await timed("layer4-gsd-structuring", layer4Gsd);
151
- }
152
- else {
153
- await timed("layer3-ee-injection", layer3EeInjection);
154
- await timed("layer4-gsd-structuring", layer4Gsd);
155
- await timed("layer5-context-enrichment", layer5Context);
158
+ // Issue #4 (targeted complement): surface the elided tool-artifacts
159
+ // RELEVANT to this meta question. Defers to layer3 it only fires when
160
+ // layer3's fixed-query checkpoint arm surfaced no checkpoint block.
161
+ await timed("ee-meta-artifacts", surfaceCompactionArtifacts);
156
162
  }
157
163
  }
158
164
  else {
@@ -9,7 +9,8 @@
9
9
  * (no network).
10
10
  */
11
11
  import os from "node:os";
12
- import { describe, expect, it } from "vitest";
12
+ import { afterEach, describe, expect, it } from "vitest";
13
+ import { __resetArtifactCacheForTests, recordArtifact } from "../ee/artifact-cache.js";
13
14
  import { BashTool } from "./bash.js";
14
15
  import { createBuiltinTools, isToolArtifactQuery } from "./registry.js";
15
16
  describe("ee_query builtin tool", () => {
@@ -45,4 +46,20 @@ describe("isToolArtifactQuery — ee_query intent routing", () => {
45
46
  expect(isToolArtifactQuery("tool-artifact storage design")).toBe(false);
46
47
  });
47
48
  });
49
+ describe("ee_query — anti-mù rehydrate (local-first, durable when EE is down)", () => {
50
+ afterEach(() => __resetArtifactCacheForTests());
51
+ it("rehydrates a tool-artifact from the in-session cache with NO EE/network call", async () => {
52
+ // Simulates: the compactor elided this output earlier (recordArtifact), EE is
53
+ // now down. The agent's ee_query("tool-artifact id=X") must still return the
54
+ // full content from the local cache rather than an [ee_unavailable] note.
55
+ recordArtifact("call_42", "read_file", "FULL ELIDED CONTENT — line A\nline B\nline C");
56
+ const tools = createBuiltinTools(new BashTool(os.tmpdir()), "agent");
57
+ const t = tools.ee_query;
58
+ const out = String(await t.execute?.({ query: "tool-artifact id=call_42" }));
59
+ expect(out).toContain("rehydrated from in-session cache");
60
+ expect(out).toContain("tool=read_file");
61
+ expect(out).toContain("FULL ELIDED CONTENT");
62
+ expect(out).not.toMatch(/ee_unavailable/);
63
+ });
64
+ });
48
65
  //# sourceMappingURL=registry-ee-query.test.js.map
@@ -466,14 +466,25 @@ export function createBuiltinTools(bash, mode, opts) {
466
466
  }
467
467
  try {
468
468
  if (isToolArtifactQuery(query)) {
469
- // Artifact rehydration raw /api/search (exact-collection lookup).
469
+ // Local-first (anti-mù durability): the compactor records each elided
470
+ // output in-process by toolCallId. For an exact "tool-artifact id=X"
471
+ // lookup this is the authoritative full content for THIS session and
472
+ // works even when EE is down — the failure window long sessions hit.
473
+ const { findArtifactByQuery, findArtifactOnDisk } = await import("../ee/artifact-cache.js");
474
+ const mem = findArtifactByQuery(query);
475
+ const local = mem ?? (await findArtifactOnDisk(query));
476
+ if (local) {
477
+ const src = mem ? "in-session cache" : "local disk cache";
478
+ return truncateOutput(`[tool-artifact id=${local.toolCallId} tool=${local.toolName} — rehydrated from ${src}]\n${local.content}`);
479
+ }
480
+ // EE fallback (cross-session / post-restart) → raw /api/search exact lookup.
470
481
  const { searchEE } = await import("../ee/search.js");
471
482
  const resp = await searchEE(query, {
472
483
  ...(Array.isArray(input?.collections) ? { collections: input.collections } : {}),
473
484
  ...(typeof input?.limit === "number" ? { limit: input.limit } : {}),
474
485
  });
475
486
  if (resp === null) {
476
- return "[ee_unavailable] Experience Engine returned no response (server down, timeout, circuit open, or unconfigured). Proceed without EE recall — re-read the source directly if you need the elided content.";
487
+ return "[ee_unavailable] Experience Engine returned no response (server down, timeout, circuit open, or unconfigured) and the artifact is not in this session's local cache. Proceed without EE recall — re-read the source directly if you need the elided content.";
477
488
  }
478
489
  return truncateOutput(JSON.stringify(resp));
479
490
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "1.5.0",
6
+ "version": "1.6.0",
7
7
  "description": "BYOK AI coding agent with multi-model council debate, role-based routing, and auto-compact.",
8
8
  "repository": {
9
9
  "type": "git",