muonroi-cli 1.6.0 → 1.6.1

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 (46) hide show
  1. package/dist/src/cli/cost-forensics.d.ts +3 -0
  2. package/dist/src/cli/cost-forensics.js +11 -0
  3. package/dist/src/cli/cost-forensics.test.js +1 -0
  4. package/dist/src/cli/experience-report.d.ts +20 -0
  5. package/dist/src/cli/experience-report.js +76 -0
  6. package/dist/src/cli/experience-report.test.d.ts +5 -0
  7. package/dist/src/cli/experience-report.test.js +63 -0
  8. package/dist/src/generated/version.d.ts +1 -1
  9. package/dist/src/generated/version.js +1 -1
  10. package/dist/src/gsd/__tests__/directives.test.js +24 -1
  11. package/dist/src/gsd/directives.d.ts +22 -0
  12. package/dist/src/gsd/directives.js +34 -10
  13. package/dist/src/index.js +9 -0
  14. package/dist/src/mcp/__tests__/client-pool.spec.js +54 -4
  15. package/dist/src/mcp/__tests__/forensics-tools.test.js +1 -0
  16. package/dist/src/mcp/client-pool.d.ts +9 -2
  17. package/dist/src/mcp/client-pool.js +60 -21
  18. package/dist/src/orchestrator/message-processor.js +34 -2
  19. package/dist/src/orchestrator/session-experience.d.ts +89 -0
  20. package/dist/src/orchestrator/session-experience.js +169 -0
  21. package/dist/src/orchestrator/session-experience.test.d.ts +6 -0
  22. package/dist/src/orchestrator/session-experience.test.js +72 -0
  23. package/dist/src/orchestrator/stream-runner.js +4 -0
  24. package/dist/src/pil/__tests__/layer3-ee-injection.test.js +5 -3
  25. package/dist/src/pil/__tests__/layer3-injected-chunk.test.js +31 -0
  26. package/dist/src/pil/__tests__/pipeline.test.js +17 -0
  27. package/dist/src/pil/layer3-ee-injection.d.ts +9 -0
  28. package/dist/src/pil/layer3-ee-injection.js +29 -0
  29. package/dist/src/pil/layer4-gsd.js +3 -2
  30. package/dist/src/pil/pipeline.js +11 -0
  31. package/dist/src/pil/session-experience-injection.d.ts +34 -0
  32. package/dist/src/pil/session-experience-injection.js +54 -0
  33. package/dist/src/pil/session-experience-injection.test.d.ts +6 -0
  34. package/dist/src/pil/session-experience-injection.test.js +79 -0
  35. package/dist/src/storage/interaction-log.d.ts +1 -1
  36. package/dist/src/storage/interaction-log.js +17 -4
  37. package/dist/src/storage/session-experience-store.d.ts +63 -0
  38. package/dist/src/storage/session-experience-store.js +164 -0
  39. package/dist/src/storage/session-experience-store.test.d.ts +5 -0
  40. package/dist/src/storage/session-experience-store.test.js +86 -0
  41. package/dist/src/storage/ui-interaction-log.js +4 -2
  42. package/dist/src/tools/registry-ee-query.test.js +7 -1
  43. package/dist/src/tools/registry.js +7 -0
  44. package/dist/src/types/index.d.ts +6 -0
  45. package/dist/src/ui/app.js +0 -0
  46. package/package.json +1 -1
@@ -66,6 +66,7 @@ import { getModelInfo } from "../models/registry.js";
66
66
  import { cheapModelShellLine, injectCheapModelPlaybook, injectCheapModelShellDirective, shouldInjectCheapModelPlaybook, } from "../pil/cheap-model-playbook.js";
67
67
  import { injectCheapModelWorkbook, shouldInjectCheapModelWorkbook } from "../pil/cheap-model-workbooks.js";
68
68
  import { applyPilSuffix, getResponseTaskType, getResponseToolSet, isResponseTool, runPipeline, shouldHaltOnResponseTool, } from "../pil/index.js";
69
+ import { isMetaAnalysisPrompt } from "../pil/layer6-output.js";
69
70
  import { taskTypeToMaxTokens, taskTypeToReasoningEffort, taskTypeToTier } from "../pil/task-tier-map.js";
70
71
  import { getProviderCapabilities } from "../providers/capabilities.js";
71
72
  import { loadKeyForProvider } from "../providers/keychain.js";
@@ -78,6 +79,7 @@ import { reportRouteOutcome } from "../router/decide.js";
78
79
  import { decideStepRouting, getStepRouterConfig } from "../router/step-router.js";
79
80
  import { routerStore } from "../router/store.js";
80
81
  import { getNextMessageSequence, logInteraction, markMessageErrored, markToolCallErrored, persistMessageWriteAhead, persistToolCallWriteAhead, } from "../storage/index.js";
82
+ import { persistSessionExperience } from "../storage/session-experience-store.js";
81
83
  import { createBuiltinTools } from "../tools/registry.js";
82
84
  import { snapshotFromTodoWriteArgs } from "../tools/todo-write-snapshot.js";
83
85
  import { visionToolsNeeded } from "../tools/vision-gate.js";
@@ -103,6 +105,7 @@ import { buildRepetitionReminder, recordAssistantBurst, shouldInjectRepetitionRe
103
105
  import { classifyStreamError } from "./retry-classifier.js";
104
106
  import { forcedFinalize, getSessionLastTask, incSessionStep, parseBudgetOverride, recordSessionLastTask, resetSessionStep, resolveCeiling, } from "./scope-ceiling.js";
105
107
  import { attachReminderToMessages, buildCheckpointReminder, buildScopeReminder, cadenceForSize, shouldInjectCeilingCrossing, shouldInjectReminder, shouldInjectSoftWarn, shouldPreWarnCompaction, } from "./scope-reminder.js";
108
+ import { formatElisionManifest, getSessionExperienceCounts, recordCompaction, recordElision, } from "./session-experience.js";
106
109
  import { attemptStallRescue, pushStallToolResult } from "./stall-rescue.js";
107
110
  import { createStallWatchdog, STALL_ERROR_MESSAGE } from "./stall-watchdog.js";
108
111
  import { wrapToolSetWithCap } from "./sub-agent-cap.js";
@@ -1505,6 +1508,10 @@ export class MessageProcessor {
1505
1508
  // rehydrate it even if EE is down (the EE extract below caps at 8k
1506
1509
  // and needs the network; the cache keeps up to 200k, no network).
1507
1510
  recordArtifact(toolCallId, toolName, fullContent);
1511
+ // Lived-experience telemetry: count this elision so a later
1512
+ // "cảm nhận trong CLI" question answers from data, and so the
1513
+ // post-compaction note can list what it just stubbed.
1514
+ recordElision(toolCallId, toolName, fullContent.length, sn);
1508
1515
  try {
1509
1516
  getDefaultEEClient()
1510
1517
  .extract({
@@ -1527,13 +1534,20 @@ export class MessageProcessor {
1527
1534
  };
1528
1535
  const compacted = compactSubAgentMessages(stripped, {
1529
1536
  thresholdChars: topLevelCompactThreshold,
1530
- keepLastTurns: topLevelCompactKeepLast,
1537
+ // Rec #1 (cheap part): on meta/self-eval turns keep a couple more
1538
+ // trailing tool turns verbatim — those carry the reasoning the
1539
+ // agent is being asked to reflect on, and over-eliding them is
1540
+ // exactly what starves a self-evaluation. One boolean, no new
1541
+ // detection logic (isMetaAnalysisPrompt already gates layer3/5).
1542
+ keepLastTurns: topLevelCompactKeepLast + (isMetaAnalysisPrompt(userMessage) ? 2 : 0),
1531
1543
  label: "top-level",
1532
1544
  envelopeChars,
1533
1545
  contextWindowTokens,
1534
1546
  keepToolIds: keepToolIds.length ? keepToolIds : undefined,
1535
1547
  persistArtifact,
1536
1548
  });
1549
+ if (compacted !== stripped)
1550
+ recordCompaction(sn);
1537
1551
  // Pre-compaction visibility: give the agent one step of notice
1538
1552
  // before B4 actually rewrites history into stubs. This is the
1539
1553
  // advance warning that was missing — agent can now decide to
@@ -1619,7 +1633,15 @@ export class MessageProcessor {
1619
1633
  // "task finished?", "compacted yet?", "EE checkpoint" so agent can self-assess and avoid mù
1620
1634
  // even when the top-level summary is not in its immediate focus (sub-agents, long loops).
1621
1635
  const _compactNote = compacted !== stripped
1622
- ? `[context compacted at step ${sn} — older or low-value tool results rewritten to stubs to fit budget. High-value evidence (file reads, bash, your previous responses) is kept verbatim. ${buildCheckpointReminder(sn, true)}]`
1636
+ ? (() => {
1637
+ // Rec #2: turn the generic "high-value elided? use ee_query"
1638
+ // prose into a concrete, actionable manifest of what was just
1639
+ // stubbed (id/tool/size) — sourced from the elisions recorded
1640
+ // by persistArtifact above — so the rehydrate round-trip is
1641
+ // informed, not blind.
1642
+ const _m = formatElisionManifest();
1643
+ return `[context compacted at step ${sn} — older or low-value tool results rewritten to stubs to fit budget. High-value evidence (file reads, bash, your previous responses) is kept verbatim. ${buildCheckpointReminder(sn, true)}${_m ? ` ${_m}` : ""}]`;
1644
+ })()
1623
1645
  : null;
1624
1646
  if (_compactNote) {
1625
1647
  return { messages: attachReminderToMessages(compacted, _compactNote) };
@@ -1680,6 +1702,16 @@ export class MessageProcessor {
1680
1702
  console.error("[Agent:onFinish] failed to emit llm-done", err);
1681
1703
  }
1682
1704
  deps.setCurrentCallId("");
1705
+ // Rec #1 persisted forensics: onFinish fires once per top-level turn,
1706
+ // so flush this session's cumulative experience counts here. Readers
1707
+ // take the latest row per session, so the last turn's row is the
1708
+ // session total. No-ops on missing id / all-zero. Fail-open.
1709
+ try {
1710
+ persistSessionExperience(deps.session?.id ?? null, getSessionExperienceCounts());
1711
+ }
1712
+ catch (err) {
1713
+ console.error("[Agent:onFinish] persistSessionExperience failed", err);
1714
+ }
1683
1715
  },
1684
1716
  });
1685
1717
  let _topTokenIndex = 0;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * src/orchestrator/session-experience.ts
3
+ *
4
+ * In-process record of what actually happened to the agent in THIS CLI session:
5
+ * how often compaction fired, which tool outputs were elided, how many of those
6
+ * the agent rehydrated via ee_query (and from where), and whether the Experience
7
+ * Engine misbehaved. It is the single source of truth for the agent's *lived*
8
+ * session experience.
9
+ *
10
+ * Why this exists: when a user asks "cảm nhận trong CLI" / "do you feel blind in
11
+ * this session", the agent used to answer by READING the anti-mù source code and
12
+ * theorizing about mechanisms (session ce816796a57d) — friction it never actually
13
+ * observed. That is backwards. With this tracker the agent answers from data —
14
+ * "compaction fired 3x, I rehydrated 2 artifacts, never lost context" — instead of
15
+ * inferring from code. The same counters double as the "measure before you
16
+ * re-architect" instrumentation: how often a real session actually elides a stub
17
+ * the agent then needs.
18
+ *
19
+ * Process-scoped singleton == session-scoped: one CLI invocation is one session.
20
+ * Pure module, no I/O, fully unit-testable; reset hook for tests.
21
+ */
22
+ export type RehydrateSource = "cache" | "disk" | "ee" | "unavailable";
23
+ export interface ElisionRecord {
24
+ toolCallId: string;
25
+ toolName: string;
26
+ /** Full length of the elided output, in chars. */
27
+ chars: number;
28
+ /** prepareStep step number at which it was elided. */
29
+ step: number;
30
+ }
31
+ export interface SessionExperience {
32
+ compactions: number;
33
+ lastCompactionStep: number | null;
34
+ elisions: ReadonlyArray<ElisionRecord>;
35
+ totalElidedChars: number;
36
+ rehydrations: Readonly<Record<RehydrateSource, number>>;
37
+ eeTimeouts: number;
38
+ eeErrors: number;
39
+ }
40
+ /** Record that B3/B4 compaction actually elided something at `step`. */
41
+ export declare function recordCompaction(step: number): void;
42
+ /** Record a single tool output the compactor rewrote into a stub. */
43
+ export declare function recordElision(toolCallId: string, toolName: string, chars: number, step: number): void;
44
+ /**
45
+ * Record an ee_query rehydrate of an elided artifact, tagged by where it came
46
+ * from. `unavailable` means the agent asked for an artifact that was neither in
47
+ * the local cache nor recoverable from EE — the "needed-but-couldn't-get" signal.
48
+ */
49
+ export declare function recordRehydration(source: RehydrateSource): void;
50
+ /** Record an Experience Engine timeout or non-timeout error felt this session. */
51
+ export declare function recordEeEvent(kind: "timeout" | "error"): void;
52
+ /**
53
+ * Flat scalar counts — the shape persisted per session and aggregated
54
+ * cross-session by `usage experience` to decide whether compaction friction is
55
+ * real at a painful rate (no nested arrays, JSON-stable).
56
+ */
57
+ export interface SessionExperienceCounts {
58
+ compactions: number;
59
+ elided: number;
60
+ totalElidedChars: number;
61
+ rehydratedCache: number;
62
+ rehydratedDisk: number;
63
+ rehydratedEe: number;
64
+ unavailable: number;
65
+ eeTimeouts: number;
66
+ eeErrors: number;
67
+ }
68
+ /** Scalar counts for persistence/aggregation (drops the per-elision array). */
69
+ export declare function getSessionExperienceCounts(): SessionExperienceCounts;
70
+ /** Immutable snapshot of the session so far. */
71
+ export declare function getSessionExperience(): SessionExperience;
72
+ /** Most-recent elisions, newest first — feeds the checkpoint manifest. */
73
+ export declare function recentElisions(n?: number): ElisionRecord[];
74
+ /** True when literally nothing notable has happened yet (context intact). */
75
+ export declare function isSessionExperienceEmpty(): boolean;
76
+ /**
77
+ * A compact manifest of the most-recently elided tool outputs, for the
78
+ * post-compaction checkpoint note: turns the generic "high-value elided? use
79
+ * ee_query" prose into a concrete, actionable list so the agent's rehydrate
80
+ * round-trip is informed rather than blind.
81
+ */
82
+ export declare function formatElisionManifest(n?: number): string;
83
+ /**
84
+ * The agent-facing felt summary. Injected when the user asks how the agent is
85
+ * doing IN this session, so the answer is grounded in what actually happened —
86
+ * not in a fresh reading of the compaction/PIL source.
87
+ */
88
+ export declare function formatSessionExperience(): string;
89
+ export declare function __resetSessionExperienceForTests(): void;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * src/orchestrator/session-experience.ts
3
+ *
4
+ * In-process record of what actually happened to the agent in THIS CLI session:
5
+ * how often compaction fired, which tool outputs were elided, how many of those
6
+ * the agent rehydrated via ee_query (and from where), and whether the Experience
7
+ * Engine misbehaved. It is the single source of truth for the agent's *lived*
8
+ * session experience.
9
+ *
10
+ * Why this exists: when a user asks "cảm nhận trong CLI" / "do you feel blind in
11
+ * this session", the agent used to answer by READING the anti-mù source code and
12
+ * theorizing about mechanisms (session ce816796a57d) — friction it never actually
13
+ * observed. That is backwards. With this tracker the agent answers from data —
14
+ * "compaction fired 3x, I rehydrated 2 artifacts, never lost context" — instead of
15
+ * inferring from code. The same counters double as the "measure before you
16
+ * re-architect" instrumentation: how often a real session actually elides a stub
17
+ * the agent then needs.
18
+ *
19
+ * Process-scoped singleton == session-scoped: one CLI invocation is one session.
20
+ * Pure module, no I/O, fully unit-testable; reset hook for tests.
21
+ */
22
+ /** Bound the elision log so a pathological session can't grow it unbounded. */
23
+ const MAX_ELISIONS = 200;
24
+ function freshState() {
25
+ return {
26
+ compactions: 0,
27
+ lastCompactionStep: null,
28
+ elisions: [],
29
+ rehydrations: { cache: 0, disk: 0, ee: 0, unavailable: 0 },
30
+ eeTimeouts: 0,
31
+ eeErrors: 0,
32
+ };
33
+ }
34
+ let state = freshState();
35
+ /** Record that B3/B4 compaction actually elided something at `step`. */
36
+ export function recordCompaction(step) {
37
+ state.compactions += 1;
38
+ state.lastCompactionStep = Number.isFinite(step) ? step : state.lastCompactionStep;
39
+ }
40
+ /** Record a single tool output the compactor rewrote into a stub. */
41
+ export function recordElision(toolCallId, toolName, chars, step) {
42
+ if (!toolCallId)
43
+ return;
44
+ state.elisions.push({
45
+ toolCallId,
46
+ toolName: toolName || "",
47
+ chars: Number.isFinite(chars) && chars > 0 ? Math.floor(chars) : 0,
48
+ step: Number.isFinite(step) ? step : 0,
49
+ });
50
+ // FIFO trim — keep the most recent MAX_ELISIONS.
51
+ if (state.elisions.length > MAX_ELISIONS) {
52
+ state.elisions.splice(0, state.elisions.length - MAX_ELISIONS);
53
+ }
54
+ }
55
+ /**
56
+ * Record an ee_query rehydrate of an elided artifact, tagged by where it came
57
+ * from. `unavailable` means the agent asked for an artifact that was neither in
58
+ * the local cache nor recoverable from EE — the "needed-but-couldn't-get" signal.
59
+ */
60
+ export function recordRehydration(source) {
61
+ if (source in state.rehydrations)
62
+ state.rehydrations[source] += 1;
63
+ }
64
+ /** Record an Experience Engine timeout or non-timeout error felt this session. */
65
+ export function recordEeEvent(kind) {
66
+ if (kind === "timeout")
67
+ state.eeTimeouts += 1;
68
+ else
69
+ state.eeErrors += 1;
70
+ }
71
+ /** Scalar counts for persistence/aggregation (drops the per-elision array). */
72
+ export function getSessionExperienceCounts() {
73
+ const s = getSessionExperience();
74
+ return {
75
+ compactions: s.compactions,
76
+ elided: s.elisions.length,
77
+ totalElidedChars: s.totalElidedChars,
78
+ rehydratedCache: s.rehydrations.cache,
79
+ rehydratedDisk: s.rehydrations.disk,
80
+ rehydratedEe: s.rehydrations.ee,
81
+ unavailable: s.rehydrations.unavailable,
82
+ eeTimeouts: s.eeTimeouts,
83
+ eeErrors: s.eeErrors,
84
+ };
85
+ }
86
+ /** Immutable snapshot of the session so far. */
87
+ export function getSessionExperience() {
88
+ return {
89
+ compactions: state.compactions,
90
+ lastCompactionStep: state.lastCompactionStep,
91
+ elisions: state.elisions.slice(),
92
+ totalElidedChars: state.elisions.reduce((sum, e) => sum + e.chars, 0),
93
+ rehydrations: { ...state.rehydrations },
94
+ eeTimeouts: state.eeTimeouts,
95
+ eeErrors: state.eeErrors,
96
+ };
97
+ }
98
+ /** Most-recent elisions, newest first — feeds the checkpoint manifest. */
99
+ export function recentElisions(n = 5) {
100
+ const take = Math.max(0, Math.floor(n));
101
+ return state.elisions.slice(-take).reverse();
102
+ }
103
+ /** True when literally nothing notable has happened yet (context intact). */
104
+ export function isSessionExperienceEmpty() {
105
+ return (state.compactions === 0 &&
106
+ state.elisions.length === 0 &&
107
+ state.eeTimeouts === 0 &&
108
+ state.eeErrors === 0 &&
109
+ state.rehydrations.cache === 0 &&
110
+ state.rehydrations.disk === 0 &&
111
+ state.rehydrations.ee === 0 &&
112
+ state.rehydrations.unavailable === 0);
113
+ }
114
+ function shortId(id) {
115
+ return id.length > 12 ? id.slice(0, 12) : id;
116
+ }
117
+ /**
118
+ * A compact manifest of the most-recently elided tool outputs, for the
119
+ * post-compaction checkpoint note: turns the generic "high-value elided? use
120
+ * ee_query" prose into a concrete, actionable list so the agent's rehydrate
121
+ * round-trip is informed rather than blind.
122
+ */
123
+ export function formatElisionManifest(n = 5) {
124
+ const recent = recentElisions(n);
125
+ if (recent.length === 0)
126
+ return "";
127
+ const items = recent.map((e) => `id=${shortId(e.toolCallId)} ${e.toolName || "tool"} (${e.chars}c)`).join(" · ");
128
+ return `Elided this turn: ${items}. ee_query "tool-artifact id=XXX" to rehydrate the one you need.`;
129
+ }
130
+ /**
131
+ * The agent-facing felt summary. Injected when the user asks how the agent is
132
+ * doing IN this session, so the answer is grounded in what actually happened —
133
+ * not in a fresh reading of the compaction/PIL source.
134
+ */
135
+ export function formatSessionExperience() {
136
+ const s = getSessionExperience();
137
+ const lines = [];
138
+ lines.push("[session experience — what ACTUALLY happened to you in THIS CLI session so far]");
139
+ if (isSessionExperienceEmpty()) {
140
+ lines.push("- Nothing notable: no compaction, no elision, no EE failures. Your context is intact this session.");
141
+ }
142
+ else {
143
+ lines.push(s.compactions === 0
144
+ ? "- Compaction: not fired yet — full context retained."
145
+ : `- Compaction: fired ${s.compactions}x${s.lastCompactionStep !== null ? ` (last at step ${s.lastCompactionStep})` : ""}.`);
146
+ if (s.elisions.length === 0) {
147
+ lines.push("- Tool outputs elided: none — nothing was rewritten to a stub.");
148
+ }
149
+ else {
150
+ const tools = [...new Set(s.elisions.map((e) => e.toolName || "tool"))].join(", ");
151
+ lines.push(`- Tool outputs elided: ${s.elisions.length} (${s.totalElidedChars} chars; via ${tools}).`);
152
+ }
153
+ const r = s.rehydrations;
154
+ const rehydratedTotal = r.cache + r.disk + r.ee;
155
+ lines.push(rehydratedTotal === 0 && r.unavailable === 0
156
+ ? "- Rehydrated via ee_query: none requested."
157
+ : `- Rehydrated via ee_query: cache=${r.cache} disk=${r.disk} ee=${r.ee}; needed-but-unavailable=${r.unavailable}.`);
158
+ if (s.eeTimeouts > 0 || s.eeErrors > 0) {
159
+ lines.push(`- Experience Engine: timeouts=${s.eeTimeouts} errors=${s.eeErrors}.`);
160
+ }
161
+ }
162
+ lines.push("Answer the user's how-does-it-feel / are-you-blind question FROM THIS lived data — not by reading the CLI source. If everything is zero, say so plainly: nothing degraded your context this session.");
163
+ return lines.join("\n");
164
+ }
165
+ // ─── Test hook ─────────────────────────────────────────────────────────────
166
+ export function __resetSessionExperienceForTests() {
167
+ state = freshState();
168
+ }
169
+ //# sourceMappingURL=session-experience.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * session-experience — in-process record of the agent's lived session, so a
3
+ * "cảm nhận trong CLI" / "are you blind?" question is answered from data, not by
4
+ * re-reading source. Also the "measure before re-architecting" instrumentation.
5
+ */
6
+ export {};
@@ -0,0 +1,72 @@
1
+ /**
2
+ * session-experience — in-process record of the agent's lived session, so a
3
+ * "cảm nhận trong CLI" / "are you blind?" question is answered from data, not by
4
+ * re-reading source. Also the "measure before re-architecting" instrumentation.
5
+ */
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+ import { __resetSessionExperienceForTests, formatElisionManifest, formatSessionExperience, getSessionExperience, isSessionExperienceEmpty, recentElisions, recordCompaction, recordEeEvent, recordElision, recordRehydration, } from "./session-experience.js";
8
+ describe("session-experience tracker", () => {
9
+ afterEach(() => __resetSessionExperienceForTests());
10
+ it("starts empty and reports an intact-context felt summary", () => {
11
+ expect(isSessionExperienceEmpty()).toBe(true);
12
+ const text = formatSessionExperience();
13
+ expect(text).toContain("Nothing notable");
14
+ expect(text).toContain("context is intact this session");
15
+ // Steering line must always tell the agent to use lived data, not source.
16
+ expect(text).toMatch(/not by reading the CLI source/i);
17
+ });
18
+ it("accumulates compaction, elision, rehydration and EE counters", () => {
19
+ recordCompaction(4);
20
+ recordCompaction(9);
21
+ recordElision("call_a", "read_file", 4100, 4);
22
+ recordElision("call_b", "grep", 2300, 9);
23
+ recordRehydration("cache");
24
+ recordRehydration("unavailable");
25
+ recordEeEvent("timeout");
26
+ const s = getSessionExperience();
27
+ expect(s.compactions).toBe(2);
28
+ expect(s.lastCompactionStep).toBe(9);
29
+ expect(s.elisions).toHaveLength(2);
30
+ expect(s.totalElidedChars).toBe(6400);
31
+ expect(s.rehydrations.cache).toBe(1);
32
+ expect(s.rehydrations.unavailable).toBe(1);
33
+ expect(s.eeTimeouts).toBe(1);
34
+ expect(isSessionExperienceEmpty()).toBe(false);
35
+ });
36
+ it("felt summary reflects real counters when non-empty", () => {
37
+ recordCompaction(3);
38
+ recordElision("call_x", "read_file", 5000, 3);
39
+ recordRehydration("ee");
40
+ const text = formatSessionExperience();
41
+ expect(text).toContain("fired 1x");
42
+ expect(text).toContain("last at step 3");
43
+ expect(text).toContain("Tool outputs elided: 1");
44
+ expect(text).toContain("ee=1");
45
+ expect(text).not.toContain("Nothing notable");
46
+ });
47
+ it("recentElisions returns newest first and respects the cap arg", () => {
48
+ for (let i = 0; i < 6; i++)
49
+ recordElision(`call_${i}`, "read_file", 1000 + i, i);
50
+ const recent = recentElisions(3);
51
+ expect(recent.map((e) => e.toolCallId)).toEqual(["call_5", "call_4", "call_3"]);
52
+ });
53
+ it("formatElisionManifest is empty with no elisions, actionable otherwise", () => {
54
+ expect(formatElisionManifest()).toBe("");
55
+ recordElision("0123456789abcdefXYZ", "read_file", 4096, 7);
56
+ const m = formatElisionManifest();
57
+ // id is shortened, tool + char count present, and points at ee_query.
58
+ expect(m).toContain("id=0123456789ab");
59
+ expect(m).toContain("read_file (4096c)");
60
+ expect(m).toMatch(/ee_query "tool-artifact id=XXX"/);
61
+ });
62
+ it("caps the elision log at 200 (FIFO) without unbounded growth", () => {
63
+ for (let i = 0; i < 250; i++)
64
+ recordElision(`c_${i}`, "bash", 500, i);
65
+ const s = getSessionExperience();
66
+ expect(s.elisions).toHaveLength(200);
67
+ // Oldest 50 dropped; newest retained.
68
+ expect(s.elisions[0].toolCallId).toBe("c_50");
69
+ expect(s.elisions.at(-1).toolCallId).toBe("c_249");
70
+ });
71
+ });
72
+ //# sourceMappingURL=session-experience.test.js.map
@@ -53,6 +53,7 @@ import { repairToolCallHook } from "./repair-tool-call.js";
53
53
  import { classifyStreamError } from "./retry-classifier.js";
54
54
  import { incSessionStep, resolveCeiling } from "./scope-ceiling.js";
55
55
  import { attachReminderToMessages, buildScopeReminder, cadenceForSize, shouldInjectReminder, shouldInjectSoftWarn, } from "./scope-reminder.js";
56
+ import { recordCompaction, recordElision } from "./session-experience.js";
56
57
  import { createStallWatchdog, STALL_ERROR_MESSAGE } from "./stall-watchdog.js";
57
58
  import { wrapToolSetWithCap } from "./sub-agent-cap.js";
58
59
  import { compactSubAgentMessages } from "./subagent-compactor.js";
@@ -415,6 +416,7 @@ export class StreamRunner {
415
416
  const persistSubArtifact = (toolCallId, toolName, fullContent, reason) => {
416
417
  // Local-first durable cache so ee_query rehydrates even when EE is down.
417
418
  recordArtifact(toolCallId, toolName, fullContent);
419
+ recordElision(toolCallId, toolName, fullContent.length, stepNumber);
418
420
  try {
419
421
  getDefaultEEClient()
420
422
  .extract({
@@ -435,6 +437,8 @@ export class StreamRunner {
435
437
  keepToolIds: subKeepToolIds.length ? subKeepToolIds : undefined,
436
438
  persistArtifact: persistSubArtifact,
437
439
  });
440
+ if (compacted !== stripped)
441
+ recordCompaction(stepNumber);
438
442
  // Phase 4A — scope reminder injection for the sub-agent loop.
439
443
  // Mirror of the top-level wiring in message-processor.ts:
440
444
  // K = cadenceForSize(size) where size defaults to "medium" because
@@ -1,5 +1,5 @@
1
1
  import { beforeEach, describe, expect, test, vi } from "vitest";
2
- import { layer3EeInjection } from "../layer3-ee-injection.js";
2
+ import { layer3EeInjection, RECALL_FEEDBACK_NUDGE } from "../layer3-ee-injection.js";
3
3
  vi.mock("../../ee/bridge.js", () => ({
4
4
  searchByText: vi.fn().mockResolvedValue([]),
5
5
  }));
@@ -93,8 +93,10 @@ describe("layer3EeInjection (bridge-based)", () => {
93
93
  const chars = parseInt(charsMatch[1], 10);
94
94
  // Two parallel collections, each at 15% of budget: 15% of 100 tokens * 4 chars/token
95
95
  // = 60 chars per block + 3 for "..." suffix, joined with newline. Allow generous
96
- // ceiling for header text + 2 blocks.
97
- expect(chars).toBeLessThanOrEqual(260);
96
+ // ceiling for header text + 2 blocks, PLUS the fixed ee_feedback nudge appended
97
+ // when rateable experience is present. The 2000-char input means a truncation
98
+ // regression would blow well past this bound regardless.
99
+ expect(chars).toBeLessThanOrEqual(260 + RECALL_FEEDBACK_NUDGE.length + 1);
98
100
  }
99
101
  }
100
102
  });
@@ -75,6 +75,37 @@ describe("layer3 experience_injected chunk emission (CQ-16b)", () => {
75
75
  expect(chunk?.experienceInjected?.scoreFloor).toBeDefined();
76
76
  expect(typeof chunk?.experienceInjected?.scoreFloor).toBe("number");
77
77
  });
78
+ it("experience_injected chunk carries per-point {id, title, tier} so the TUI can show WHAT was injected", async () => {
79
+ mockSearchByText.mockResolvedValue([
80
+ {
81
+ id: "point-1",
82
+ score: 0.9,
83
+ payload: { text: "Use dependency injection for testability" },
84
+ collection: "experience-behavioral",
85
+ },
86
+ ]);
87
+ await layer3EeInjection(BASE_CTX);
88
+ const chunk = capturedSinkCalls.find((c) => typeof c !== "string" && c.type === "experience_injected");
89
+ const points = chunk?.experienceInjected?.points;
90
+ expect(Array.isArray(points)).toBe(true);
91
+ expect(points.length).toBeGreaterThan(0);
92
+ const p = points[0];
93
+ expect(p.id).toBe("point-1");
94
+ expect(p.title).toContain("dependency injection");
95
+ expect(["principle", "behavioral", "checkpoint"]).toContain(p.tier);
96
+ });
97
+ it("appends an ee_feedback nudge to the injected text when rateable experience is present", async () => {
98
+ mockSearchByText.mockResolvedValue([
99
+ {
100
+ id: "p1",
101
+ score: 0.9,
102
+ payload: { text: "Prefer composition over inheritance" },
103
+ collection: "experience-behavioral",
104
+ },
105
+ ]);
106
+ const result = await layer3EeInjection(BASE_CTX);
107
+ expect(result.enriched).toContain("ee_feedback(id, followed|ignored|noise)");
108
+ });
78
109
  it("does NOT emit experience_injected when searchByText returns empty array", async () => {
79
110
  mockSearchByText.mockResolvedValue([]);
80
111
  await layer3EeInjection(BASE_CTX);
@@ -65,6 +65,23 @@ describe("runPipeline()", () => {
65
65
  expect(ctx.layers[i].delta).not.toBe("skipped:null-taskType");
66
66
  }
67
67
  });
68
+ it("felt-experience prompt injects the session snapshot even when taskType is null", async () => {
69
+ // Regression: the felt-experience injection was first placed INSIDE the
70
+ // `taskType !== null` branch, so a "cảm nhận trong CLI" question that
71
+ // classifies to null (not a coding task) silently skipped it. It must run
72
+ // regardless of taskType.
73
+ mockClassify.mockReturnValue({ tier: "abstain", confidence: 0.2, reason: "low-confidence" });
74
+ const ctx = await runPipeline("bạn có bị mù context không trong session này, cảm nhận thế nào");
75
+ expect(ctx.taskType).toBeNull();
76
+ expect(ctx.layers.find((l) => l.name === "session-experience")?.applied).toBe(true);
77
+ expect(ctx.enriched).toContain("[session experience —");
78
+ expect(ctx.enriched).toMatch(/not by reading the CLI source/i);
79
+ });
80
+ it("plain evaluate-the-CLI prompt does NOT inject the session snapshot", async () => {
81
+ const ctx = await runPipeline("đánh giá agent bên trong cli và đề xuất cải thiện");
82
+ expect(ctx.layers.find((l) => l.name === "session-experience")).toBeUndefined();
83
+ expect(ctx.enriched).not.toContain("[session experience —");
84
+ });
68
85
  it("metrics.totalMs is a non-negative number", async () => {
69
86
  const ctx = await runPipeline("refactor this");
70
87
  expect(ctx.metrics).not.toBeNull();
@@ -15,6 +15,15 @@
15
15
  * and PIL Layer 3 are active on the same pipeline run.
16
16
  */
17
17
  import type { PipelineContext } from "./types.js";
18
+ /**
19
+ * Inline reminder appended to the injected experience block (when rateable
20
+ * principles/behavioral are present) so passively-injected recalls carry a
21
+ * feedback prompt next to their [id:..] handles — the front-loaded
22
+ * native-capabilities instruction can be compacted away on long sessions, and
23
+ * unrated recalls degrade future recall (the recall arm of the EE loop is
24
+ * explicit-feedback-only by design).
25
+ */
26
+ export declare const RECALL_FEEDBACK_NUDGE = "\u21B3 Acted on one of the above [id:..]? Rate it: ee_feedback(id, followed|ignored|noise). Unrated recalls degrade future recall.";
18
27
  export declare function layer3EeInjection(ctx: PipelineContext): Promise<PipelineContext>;
19
28
  /**
20
29
  * Issue #4 — meta-turn TARGETED complement to Layer 3's checkpoint arm.
@@ -45,6 +45,15 @@ const PIL_PRINCIPLES_FLOOR = Math.max(0, PIL_SCORE_FLOOR - 0.15);
45
45
  // hitCount threshold for promoting a behavioral point to T1 "proven" reflex.
46
46
  // Mirrors the EE evolution promotion rule (3 confirmed hits → T1).
47
47
  const T1_HIT_THRESHOLD = 3;
48
+ /**
49
+ * Inline reminder appended to the injected experience block (when rateable
50
+ * principles/behavioral are present) so passively-injected recalls carry a
51
+ * feedback prompt next to their [id:..] handles — the front-loaded
52
+ * native-capabilities instruction can be compacted away on long sessions, and
53
+ * unrated recalls degrade future recall (the recall arm of the EE loop is
54
+ * explicit-feedback-only by design).
55
+ */
56
+ export const RECALL_FEEDBACK_NUDGE = "↳ Acted on one of the above [id:..]? Rate it: ee_feedback(id, followed|ignored|noise). Unrated recalls degrade future recall.";
48
57
  /**
49
58
  * Extract all sha16 values from `<!-- bb-context-injected:<sha16> -->` markers
50
59
  * already present in the enriched context string.
@@ -289,12 +298,22 @@ export async function layer3EeInjection(ctx) {
289
298
  // STALE-01: Register injected point IDs for prompt-stale reconciliation.
290
299
  updateLastSurfacedState(allPoints.map((p) => String(p.id)));
291
300
  // CQ-16b: Emit experience_injected StreamChunk so TUI can show collapsible block.
301
+ // Carry per-point {id, title, tier} so the TUI can show WHAT was injected, not
302
+ // just how many (the data already exists here; previously only the count + ids
303
+ // reached the client and the title was never serialized).
304
+ const pointTitle = (p) => (extractPointText(p).split("\n")[0] ?? "").replace(/\s+/g, " ").trim().slice(0, 100);
305
+ const injectedPoints = [
306
+ ...deduplicatedPrinciples.map((p) => ({ id: String(p.id), title: pointTitle(p), tier: "principle" })),
307
+ ...deduplicatedBehavioral.map((p) => ({ id: String(p.id), title: pointTitle(p), tier: "behavioral" })),
308
+ ...deduplicatedCheckpoints.map((p) => ({ id: String(p.id), title: pointTitle(p), tier: "checkpoint" })),
309
+ ];
292
310
  try {
293
311
  const injectedChunk = {
294
312
  type: "experience_injected",
295
313
  experienceInjected: {
296
314
  pointCount: totalPoints + deduplicatedCheckpoints.length,
297
315
  pointIds: allPoints.map((p) => String(p.id)),
316
+ points: injectedPoints,
298
317
  scoreFloor: PIL_SCORE_FLOOR,
299
318
  taskType: ctx.taskType ?? undefined,
300
319
  domain: ctx.domain ?? undefined,
@@ -324,6 +343,16 @@ export async function layer3EeInjection(ctx) {
324
343
  // Idea 5: raised from 0.08 to 0.12 for higher fidelity on critical progress + artifact refs.
325
344
  parts.push(truncateToBudget(cpText + "\n" + marker, Math.floor(ctx.tokenBudget * 0.12)));
326
345
  }
346
+ // Close the recall feedback loop at the injection site: passively-injected
347
+ // experience (the agent did not ee_query for it) otherwise carries no feedback
348
+ // prompt, so it goes unrated and EE cannot learn if the injection was gold or
349
+ // noise. The front-loaded native-capabilities instruction can be compacted away
350
+ // on long sessions; this nudge rides next to the [id:..] handles it refers to.
351
+ // Gated on rateable experience (principles/behavioral) — checkpoints are task
352
+ // artifacts, not recall verdicts.
353
+ if (deduplicatedPrinciples.length + deduplicatedBehavioral.length > 0) {
354
+ parts.push(RECALL_FEEDBACK_NUDGE);
355
+ }
327
356
  const injected = parts.join("\n");
328
357
  try {
329
358
  if (ctx.sessionId) {
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { routeTask } from "../ee/bridge.js";
20
20
  import { scoreComplexity } from "../gsd/complexity.js";
21
- import { buildDirective } from "../gsd/directives.js";
21
+ import { buildDirective, mentionsEcosystemScope } from "../gsd/directives.js";
22
22
  import { detectGrayAreas } from "../gsd/gray-areas.js";
23
23
  import { detectGsdPhase } from "../gsd/types.js";
24
24
  import { classifyEeError, logEeFailure } from "../utils/ee-logger.js";
@@ -96,7 +96,8 @@ export async function layer4Gsd(ctx) {
96
96
  : isMetaAnalysisPrompt(ctx.raw) ||
97
97
  (ctx.taskType === "general" && ctx.intentKind === "task") ||
98
98
  (isQuestionLike(ctx.raw) && !isImplementationIntent(ctx.raw));
99
- const directive = buildDirective({ complexity, phase, grayAreas, informational });
99
+ const ecosystem = mentionsEcosystemScope(ctx.raw);
100
+ const directive = buildDirective({ complexity, phase, grayAreas, informational, ecosystem });
100
101
  const budgetChars = Math.floor(ctx.tokenBudget * DIRECTIVE_BUDGET_FRACTION);
101
102
  const trimmed = truncateToBudget(directive.text, budgetChars);
102
103
  return {