@fiale-plus/pi-rogue 0.2.0 → 0.2.2

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 (39) hide show
  1. package/README.md +2 -1
  2. package/node_modules/@fiale-plus/pi-rogue-advisor/src/extension.ts +75 -31
  3. package/node_modules/@fiale-plus/pi-rogue-advisor/src/loop-convergence.test.ts +2 -2
  4. package/node_modules/@fiale-plus/pi-rogue-advisor/src/state-versioning.test.ts +25 -4
  5. package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +4 -3
  6. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +38 -4
  7. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +52 -6
  8. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/advisor-checkins.test.ts +10 -0
  9. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/advisor-checkins.ts +17 -2
  10. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.ts +2 -2
  11. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/internal.ts +11 -2
  12. package/node_modules/@fiale-plus/pi-rogue-router/README.md +32 -0
  13. package/node_modules/@fiale-plus/pi-rogue-router/package.json +30 -0
  14. package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.test.ts +84 -0
  15. package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.ts +355 -0
  16. package/node_modules/@fiale-plus/pi-rogue-router/src/cli.ts +277 -0
  17. package/node_modules/@fiale-plus/pi-rogue-router/src/completions.ts +34 -0
  18. package/node_modules/@fiale-plus/pi-rogue-router/src/config-extension.test.ts +133 -0
  19. package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +168 -0
  20. package/node_modules/@fiale-plus/pi-rogue-router/src/dataset.ts +154 -0
  21. package/node_modules/@fiale-plus/pi-rogue-router/src/decision-ledger.test.ts +148 -0
  22. package/node_modules/@fiale-plus/pi-rogue-router/src/decision.ts +138 -0
  23. package/node_modules/@fiale-plus/pi-rogue-router/src/extension.ts +139 -0
  24. package/node_modules/@fiale-plus/pi-rogue-router/src/git-features.ts +119 -0
  25. package/node_modules/@fiale-plus/pi-rogue-router/src/hash.ts +19 -0
  26. package/node_modules/@fiale-plus/pi-rogue-router/src/index.ts +15 -0
  27. package/node_modules/@fiale-plus/pi-rogue-router/src/learning.test.ts +241 -0
  28. package/node_modules/@fiale-plus/pi-rogue-router/src/learning.ts +382 -0
  29. package/node_modules/@fiale-plus/pi-rogue-router/src/ledger.ts +94 -0
  30. package/node_modules/@fiale-plus/pi-rogue-router/src/observe.ts +119 -0
  31. package/node_modules/@fiale-plus/pi-rogue-router/src/outcomes.ts +128 -0
  32. package/node_modules/@fiale-plus/pi-rogue-router/src/progress.ts +93 -0
  33. package/node_modules/@fiale-plus/pi-rogue-router/src/session-reader.ts +217 -0
  34. package/node_modules/@fiale-plus/pi-rogue-router/src/subagents.ts +178 -0
  35. package/node_modules/@fiale-plus/pi-rogue-router/src/types.ts +150 -0
  36. package/node_modules/@fiale-plus/pi-rogue-router/src/v1-telemetry.test.ts +293 -0
  37. package/package.json +5 -3
  38. package/src/extension.test.ts +1 -0
  39. package/src/extension.ts +2 -0
package/README.md CHANGED
@@ -8,6 +8,7 @@ It stitches together (and bundles for a true single-package install):
8
8
  - `@fiale-plus/pi-rogue-advisor` (logic; direct releases paused)
9
9
  - `@fiale-plus/pi-rogue-context-broker` (context-broker runtime; registered by default with an env kill switch)
10
10
  - `@fiale-plus/pi-rogue-orchestration` (logic; direct releases paused)
11
+ - `@fiale-plus/pi-rogue-router` (observe-only trajectory-router lab; direct releases paused)
11
12
 
12
13
  Direct installs of the advisor/orchestration packages are paused (marked private). All users and future releases go through the bundle. See `docs/release.md` and root `AGENTS.md` / `README.md` for the release policy.
13
14
 
@@ -37,7 +38,7 @@ npm install
37
38
 
38
39
  ## Command surface
39
40
 
40
- - Default: `/advisor`, `/goal`, `/loop`, `/autoresearch`, `/autoresearch-lab` plus status/config/command paths (all provided via the bundle).
41
+ - Default: `/advisor`, `/goal`, `/loop`, `/autoresearch`, `/autoresearch-lab`, `/router` plus status/config/command paths (all provided via the bundle).
41
42
  - Context broker: enabled by default; `PI_CONTEXT_BROKER_ENABLED=false` disables `/context status`, `/context brief`, `/context lookup <handle|text>`, `/context pin <handle>`, `/context export <handle>`, and `/context prune` with autocomplete.
42
43
 
43
44
  ## Status
@@ -5,7 +5,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import { Box, Text } from "@earendil-works/pi-tui";
6
6
  import { completeSimple, type ThinkingLevel } from "@earendil-works/pi-ai";
7
7
  import { Type } from "typebox";
8
- import { featureFile, readText, truncate, writeText, atomicWriteText } from "./internal.js";
8
+ import { featureDir, featureFile, readText, truncate, writeText, atomicWriteText } from "./internal.js";
9
9
  import { advisorArgumentCompletions, piRogueArgumentCompletions } from "./completions.js";
10
10
  import {
11
11
  appendRouteLog,
@@ -46,10 +46,10 @@ const DEFAULT_CONFIG: AdvisorConfig = {
46
46
  };
47
47
 
48
48
  const CONFIG_PATH = featureFile("advisor", "config.json");
49
- const STATE_PATH = featureFile("advisor", "state.json");
49
+ const LEGACY_STATE_PATH = featureFile("advisor", "state.json");
50
50
  const CACHE_PATH = featureFile("advisor", "cache.json");
51
- const CURRENT_PATH = featureFile("advisor", "current.md");
52
51
  const HISTORY_PATH = featureFile("advisor", "history.jsonl");
52
+ const SESSION_STATE_PROP = "__piRogueAdvisorStatePath";
53
53
  const ORCHESTRATION_DIR = join(homedir(), ".pi", "agent", "fiale-plus", "orchestration");
54
54
 
55
55
  const MAX_CACHE = 64;
@@ -169,8 +169,47 @@ function saveConfig(c: AdvisorConfig) {
169
169
  writeJson(CONFIG_PATH, c);
170
170
  }
171
171
 
172
- function loadState(): SessionState {
173
- const raw = readJson<Partial<SessionState>>(STATE_PATH, {});
172
+ function advisorSessionDir(ctxOrKey?: any): string {
173
+ const key = typeof ctxOrKey === "string" ? ctxOrKey : sessionKey(ctxOrKey);
174
+ return join(featureDir("advisor"), "sessions", safeSessionKey(key));
175
+ }
176
+
177
+ export function advisorSessionStatePath(ctxOrKey?: any): string {
178
+ return join(advisorSessionDir(ctxOrKey), "state.json");
179
+ }
180
+
181
+ function advisorCurrentPath(ctxOrKey?: any): string {
182
+ return join(advisorSessionDir(ctxOrKey), "current.md");
183
+ }
184
+
185
+ function safeSessionKey(key: string): string {
186
+ const safe = String(key || "session").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
187
+ return safe || "session";
188
+ }
189
+
190
+ function statePathFor(state: SessionState): string {
191
+ return String((state as any)[SESSION_STATE_PROP] || LEGACY_STATE_PATH);
192
+ }
193
+
194
+ function attachStatePath<T extends SessionState>(state: T, path: string): T {
195
+ Object.defineProperty(state, SESSION_STATE_PROP, {
196
+ value: path,
197
+ enumerable: false,
198
+ configurable: true,
199
+ writable: true,
200
+ });
201
+ return state;
202
+ }
203
+
204
+ function loadState(ctxOrKey?: any): SessionState {
205
+ // Do not fall back to LEGACY_STATE_PATH here: that file was unscoped and is
206
+ // the source of issue #103 context bleed. New/resumed sessions must only load
207
+ // their own namespaced mutable advisor state.
208
+ return loadStateFromPath(advisorSessionStatePath(ctxOrKey));
209
+ }
210
+
211
+ function loadStateFromPath(path: string): SessionState {
212
+ const raw = readJson<Partial<SessionState>>(path, {});
174
213
  // Handle state versioning: migrate old versions to current
175
214
  const version = raw._v ?? 0;
176
215
  if (version < STATE_VERSION) {
@@ -181,7 +220,7 @@ function loadState(): SessionState {
181
220
  }
182
221
  const control = raw.reviewControl;
183
222
  const pauseUntil = Number(raw.advisorPauseUntilTurn);
184
- return {
223
+ return attachStatePath({
185
224
  _v: STATE_VERSION,
186
225
  turns: raw.turns ?? 0,
187
226
  lastTask: raw.lastTask ?? "",
@@ -217,11 +256,11 @@ function loadState(): SessionState {
217
256
  lastAppliedAt: control?.lastAppliedAt,
218
257
  },
219
258
  advisorPauseUntilTurn: Number.isFinite(pauseUntil) ? pauseUntil : undefined,
220
- };
259
+ }, path);
221
260
  }
222
261
 
223
262
  function saveState(s: SessionState) {
224
- atomicWriteText(STATE_PATH, JSON.stringify(s, null, 2) + "\n");
263
+ atomicWriteText(statePathFor(s), JSON.stringify(s, null, 2) + "\n");
225
264
  }
226
265
 
227
266
  function loadCache(): Record<string, string> {
@@ -489,7 +528,7 @@ function markReviewApplied(state: SessionState, signature: string, trigger: stri
489
528
  }
490
529
 
491
530
  function persistReviewState(state: SessionState, includeReviewRoute: boolean): void {
492
- const persisted = loadState();
531
+ const persisted = loadStateFromPath(statePathFor(state));
493
532
  persisted.reviewControl = state.reviewControl;
494
533
  persisted.followUp = state.followUp;
495
534
  persisted.followUpTask = state.followUpTask;
@@ -771,8 +810,12 @@ function mergeRouteReview(configReview: AdvisorConfig["review"], route?: ReviewP
771
810
 
772
811
  function sessionKey(ctx: any): string {
773
812
  const sessionFile = ctx?.sessionManager?.getSessionFile?.();
774
- if (!sessionFile) return "session";
775
- return basename(String(sessionFile)).replace(/\.[^.]+$/, "");
813
+ if (typeof sessionFile === "string" && sessionFile.length > 0) {
814
+ return safeSessionKey(basename(String(sessionFile)).replace(/\.[^.]+$/, ""));
815
+ }
816
+ const sessionId = ctx?.session?.id || process.env.PI_ROGUE_SESSION_ID;
817
+ if (typeof sessionId === "string" && sessionId.length > 0) return safeSessionKey(sessionId);
818
+ return "session";
776
819
  }
777
820
 
778
821
  type OrchestrationSnapshot = {
@@ -846,12 +889,13 @@ function checkinDescription(config: AdvisorConfig): string {
846
889
  return `checkins ${config.checkinIntervalMinutes}m`;
847
890
  }
848
891
 
849
- function setPiRogueStatus(ctx: any, config = loadConfig(), state = loadState()): void {
892
+ function setPiRogueStatus(ctx: any, config = loadConfig(), state?: SessionState): void {
893
+ const currentState = state ?? loadState(ctx);
850
894
  const normalized = normalizeAdvisorConfig(config);
851
895
  const checkin = checkinDescription(normalized);
852
- const pause = advisorPauseRemaining(state, state.turns);
896
+ const pause = advisorPauseRemaining(currentState, currentState.turns);
853
897
  const pauseText = pause > 0 ? ` · pause ${pause} turn${pause === 1 ? "" : "s"}` : "";
854
- const last = state.checkin.lastAt ? ` · last ${new Date(state.checkin.lastAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "";
898
+ const last = currentState.checkin.lastAt ? ` · last ${new Date(currentState.checkin.lastAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "";
855
899
  ctx.ui.setStatus("pi-rogue", `☠︎ advisor ${normalized.mode}/${normalized.review} · ${checkin}${pauseText}${last}`);
856
900
  }
857
901
 
@@ -896,7 +940,7 @@ async function maybeAdvisorCheckin(pi: ExtensionAPI, ctx: any, source: string):
896
940
  if (checkinLocks.has(key)) return false;
897
941
 
898
942
  const config = loadConfig();
899
- const state = loadState();
943
+ const state = loadState(ctx);
900
944
  const reason = shouldRunCheckin(config, state, Date.now(), Date.now());
901
945
  if (!reason) {
902
946
  if (state.checkin.queued) {
@@ -935,7 +979,7 @@ async function maybeAdvisorCheckin(pi: ExtensionAPI, ctx: any, source: string):
935
979
  );
936
980
  if (!completed) return false;
937
981
 
938
- const next = loadState();
982
+ const next = loadState(ctx);
939
983
  next.checkin = {
940
984
  lastAt: new Date().toISOString(),
941
985
  lastTurn: next.turns,
@@ -1056,7 +1100,7 @@ export async function completeWithHigherAdvisorModel(
1056
1100
 
1057
1101
  async function askAdvisor(pi: ExtensionAPI, ctx: any, question: string, scope: string, includeWork: boolean) {
1058
1102
  const config = loadConfig();
1059
- const state = loadState();
1103
+ const state = loadState(ctx);
1060
1104
  if (!question.trim()) return { text: "Ask a question.", error: "empty" };
1061
1105
 
1062
1106
  const brokerBrief = includeWork ? contextBrokerBrief(pi) : "";
@@ -1085,7 +1129,7 @@ async function askAdvisor(pi: ExtensionAPI, ctx: any, question: string, scope: s
1085
1129
  async function doReview(pi: ExtensionAPI, ctx: any, trigger: string, delta: string, meta: ReviewMaterialMeta) {
1086
1130
  const config = loadConfig();
1087
1131
  if (config.review === "off") return;
1088
- const state = loadState();
1132
+ const state = loadState(ctx);
1089
1133
 
1090
1134
  const signature = reviewMaterialSignature(state, delta, meta);
1091
1135
  if (state.reviewControl.running) {
@@ -1252,7 +1296,7 @@ async function doReview(pi: ExtensionAPI, ctx: any, trigger: string, delta: stri
1252
1296
  finalReason = (parsed.reason || parsed.summary || "review result").slice(0, 120);
1253
1297
 
1254
1298
  const display = formatAdvisorDisplay("advisor:llm", decision, finalReason);
1255
- writeText(CURRENT_PATH, `${display}\n`);
1299
+ writeText(advisorCurrentPath(ctx), `${display}\n`);
1256
1300
 
1257
1301
  const reviewTask = parsed.activeTask || state.lastTask || "";
1258
1302
  const hasTaskActions = parsed.taskActions.length > 0;
@@ -1300,7 +1344,7 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1300
1344
  pi.on("session_start", (_event, ctx) => {
1301
1345
  const key = sessionKey(ctx);
1302
1346
  checkinLocks.delete(key);
1303
- const state = loadState();
1347
+ const state = loadState(ctx);
1304
1348
  recoverReviewControl(state);
1305
1349
  saveState(state);
1306
1350
  setPiRogueStatus(ctx, loadConfig(), state);
@@ -1334,7 +1378,7 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1334
1378
  // ── Preflight (heuristics only — no LLM call, <1ms) ──────────────────
1335
1379
  pi.on("before_agent_start", async (event: any, ctx: any) => {
1336
1380
  const cfg = loadConfig();
1337
- const state = loadState();
1381
+ const state = loadState(ctx);
1338
1382
  const hasFollowUp = Boolean(state.followUp);
1339
1383
  if ((isAdvisorAutoRunSuppressed(state, state.turns) && !hasFollowUp) || cfg.mode === "off" || cfg.mode === "manual") {
1340
1384
  return { systemPrompt: event.systemPrompt };
@@ -1391,7 +1435,7 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1391
1435
  const note = routeNote(route);
1392
1436
  const control = state.reviewControl;
1393
1437
  const controlTag = control.status === "needed" || control.status === "running" ? `Review-control: ${control.status}${control.lastDecision ? ` (${control.lastDecision})` : ""}` : "";
1394
- writeText(CURRENT_PATH, `${note}\n`);
1438
+ writeText(advisorCurrentPath(ctx), `${note}\n`);
1395
1439
  return {
1396
1440
  systemPrompt: [
1397
1441
  event.systemPrompt,
@@ -1409,7 +1453,7 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1409
1453
  pi.on("turn_end", async (event: any, ctx: any) => {
1410
1454
  const cfg = loadConfig();
1411
1455
  if (cfg.mode === "off") return;
1412
- const state = loadState();
1456
+ const state = loadState(ctx);
1413
1457
  const suppressedThisTurn = isAdvisorAutoRunSuppressedForTurnContext(state, state.turns);
1414
1458
  const tools = (event.toolResults || []).map((t: any) => String(t?.toolName || t?.name || "tool"));
1415
1459
  const fileChanged = tools.some((t: string) => /^(edit|write)$/i.test(t));
@@ -1431,7 +1475,7 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1431
1475
  });
1432
1476
  }
1433
1477
 
1434
- const post = loadState();
1478
+ const post = loadState(ctx);
1435
1479
  if (!isAdvisorAutoRunSuppressed(post, post.turns)) {
1436
1480
  void maybeAdvisorCheckin(pi, ctx, "turn_end");
1437
1481
  }
@@ -1441,7 +1485,7 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1441
1485
  pi.on("agent_end", async (event: any, ctx: any) => {
1442
1486
  const cfg = loadConfig();
1443
1487
  if (cfg.mode === "off") return;
1444
- const state = loadState();
1488
+ const state = loadState(ctx);
1445
1489
  const suppressed = isAdvisorAutoRunSuppressedForTurnContext(state, state.turns);
1446
1490
  if (cfg.review === "off" || suppressed) {
1447
1491
  if (!suppressed) {
@@ -1466,7 +1510,7 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1466
1510
  materialSignals: signals,
1467
1511
  });
1468
1512
 
1469
- const post = loadState();
1513
+ const post = loadState(ctx);
1470
1514
  if (!isAdvisorAutoRunSuppressed(post, post.turns)) {
1471
1515
  void maybeAdvisorCheckin(pi, ctx, "agent_end");
1472
1516
  }
@@ -1478,12 +1522,12 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1478
1522
  getArgumentCompletions: (prefix: string) => piRogueArgumentCompletions(prefix),
1479
1523
  handler: async (args, ctx) => {
1480
1524
  const cfg = loadConfig();
1481
- const state = loadState();
1525
+ const state = loadState(ctx);
1482
1526
  const arg = String(args ?? "").trim().toLowerCase();
1483
1527
  setPiRogueStatus(ctx, cfg, state);
1484
1528
 
1485
1529
  if (!arg || arg === "status" || arg === "help") {
1486
- ctx.ui.notify(piRogueCockpitText(cfg, state, readText(CURRENT_PATH).trim(), orchestrationSnapshotText(ctx)), "info");
1530
+ ctx.ui.notify(piRogueCockpitText(cfg, state, readText(advisorCurrentPath(ctx)).trim(), orchestrationSnapshotText(ctx)), "info");
1487
1531
  return;
1488
1532
  }
1489
1533
 
@@ -1519,7 +1563,7 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1519
1563
  return;
1520
1564
  }
1521
1565
 
1522
- ctx.ui.notify(piRogueCockpitText(cfg, state, readText(CURRENT_PATH).trim(), orchestrationSnapshotText(ctx)), "info");
1566
+ ctx.ui.notify(piRogueCockpitText(cfg, state, readText(advisorCurrentPath(ctx)).trim(), orchestrationSnapshotText(ctx)), "info");
1523
1567
  },
1524
1568
  });
1525
1569
 
@@ -1531,10 +1575,10 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1531
1575
  const a = String(args ?? "").trim().toLowerCase();
1532
1576
  const [cmd, ...rest] = a.split(/\s+/);
1533
1577
  const cfg = loadConfig();
1534
- const state = loadState();
1578
+ const state = loadState(ctx);
1535
1579
 
1536
1580
  if (!a || cmd === "status") {
1537
- const note = readText(CURRENT_PATH).trim();
1581
+ const note = readText(advisorCurrentPath(ctx)).trim();
1538
1582
  const resolved = await resolveModel(ctx, cfg);
1539
1583
  const route = state.router.review ?? state.router.preflight;
1540
1584
  const pause = advisorPauseRemaining(state, state.turns);
@@ -4,7 +4,7 @@ import { dirname } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { completeSimple } from "@earendil-works/pi-ai";
7
- import { registerAdvisor } from "./extension.js";
7
+ import { advisorSessionStatePath, registerAdvisor } from "./extension.js";
8
8
 
9
9
  const testHome = vi.hoisted(() => `/tmp/pi-rogue-advisor-loop-convergence-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
10
10
 
@@ -57,7 +57,7 @@ function makeHandlers() {
57
57
  }
58
58
 
59
59
  const ADVISOR_STATE_DIR = join(homedir(), ".pi", "agent", "pi-rogue", "advisor");
60
- const ADVISOR_STATE_PATH = join(ADVISOR_STATE_DIR, "state.json");
60
+ const ADVISOR_STATE_PATH = advisorSessionStatePath("session");
61
61
  const ADVISOR_CONFIG_PATH = join(ADVISOR_STATE_DIR, "config.json");
62
62
  const ADVISOR_CACHE_PATH = join(ADVISOR_STATE_DIR, "cache.json");
63
63
 
@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
4
  import { homedir } from "node:os";
5
- import { registerAdvisor } from "./extension.js";
5
+ import { advisorSessionStatePath, registerAdvisor } from "./extension.js";
6
6
 
7
7
  const testHome = vi.hoisted(() => `/tmp/pi-rogue-advisor-state-versioning-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
8
8
 
@@ -37,17 +37,17 @@ function makeHandlers() {
37
37
  }
38
38
 
39
39
  const ADVISOR_STATE_DIR = join(homedir(), ".pi", "agent", "pi-rogue", "advisor");
40
- const ADVISOR_STATE_PATH = join(ADVISOR_STATE_DIR, "state.json");
41
40
  const ADVISOR_CONFIG_PATH = join(ADVISOR_STATE_DIR, "config.json");
41
+ const ADVISOR_STATE_PATH = advisorSessionStatePath("session");
42
42
 
43
43
  function readAdvisorState(): any {
44
44
  return JSON.parse(readFileSync(ADVISOR_STATE_PATH, "utf8"));
45
45
  }
46
46
 
47
- function mkCtx() {
47
+ function mkCtx(session = "session") {
48
48
  return {
49
49
  sessionManager: {
50
- getSessionFile: () => join(homedir(), ".pi", "agent", "pi-rogue", "advisor", "session.jsonl"),
50
+ getSessionFile: () => join(homedir(), ".pi", "agent", "pi-rogue", "advisor", `${session}.jsonl`),
51
51
  },
52
52
  isIdle: () => true,
53
53
  modelRegistry: {
@@ -224,4 +224,25 @@ describe("state versioning and recovery", () => {
224
224
  expect(recovered.reviewControl.pending).toBe(true);
225
225
  expect(recovered.reviewControl.lastDecision).toBe("review");
226
226
  });
227
+
228
+ it("keeps mutable advisor state isolated by session", async () => {
229
+ const setup = makeHandlers();
230
+ const { handlers: h, pi } = setup;
231
+ registerAdvisor(pi);
232
+
233
+ const ctxA = mkCtx("model-training");
234
+ const ctxB = mkCtx("runpod");
235
+
236
+ void h.session_start?.[0]?.({}, ctxA);
237
+ await h.before_agent_start?.[0]?.({ prompt: "train advisor on regex logs", systemPrompt: "base" }, ctxA);
238
+
239
+ const stateA = JSON.parse(readFileSync(advisorSessionStatePath("model-training"), "utf8"));
240
+ expect(stateA.lastTask).toBe("train advisor on regex logs");
241
+
242
+ void h.session_start?.[0]?.({}, ctxB);
243
+ const stateB = JSON.parse(readFileSync(advisorSessionStatePath("runpod"), "utf8"));
244
+ expect(stateB.lastTask).toBe("");
245
+ expect(stateB.followUp).toBe("");
246
+ expect(stateB.reviewSignals).toEqual([]);
247
+ });
227
248
  });
@@ -22,7 +22,7 @@ PI_CONTEXT_BROKER_ENABLED=false pi
22
22
 
23
23
  When active, the bundle registers:
24
24
 
25
- - `/context status` — enabled state, record/byte counts, pinned counts, and routing telemetry.
25
+ - `/context status` — enabled state, record/byte counts, pinned counts, routing telemetry, and prompt rewrite savings bytes.
26
26
  - `/context brief` — bounded prompt-safe broker brief with handles and summaries.
27
27
  - `/context lookup <handle|text>` — exact handle rehydration or current-session text search.
28
28
  - `/context pin <handle>` — protect an artifact from normal TTL/cap pruning.
@@ -33,9 +33,9 @@ The command includes autocomplete for subcommands and known artifact handles. Ex
33
33
 
34
34
  Optional durability is available with `PI_CONTEXT_BROKER_DURABLE=true` or `PI_CONTEXT_BROKER_STORE_DIR=/path/to/store`. Durable mode now defaults to SQLite (`artifacts.sqlite`) with an FTS index for text lookup, so exact handles, tier, and pin state survive restarts without replay reconstruction. Set `PI_CONTEXT_BROKER_BACKEND=jsonl` to use the legacy JSONL/blob backend.
35
35
 
36
- - `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES` controls when large `toolResult` / `bashExecution` payloads are rewritten in-context. The default is `0` (rewritten), so raw tool evidence is replaced by handles by default.
36
+ - `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES` controls when large `toolResult` / `bashExecution` payloads are rewritten in-context. The default is `8192` bytes, so small tool evidence remains inline while larger outputs are replaced by handles.
37
37
 
38
- For quieter sessions, set `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES` to a higher value to only rewrite larger outputs.
38
+ For more aggressive prompt reduction, set `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES=0`. For quieter sessions, set it to a higher value to only rewrite larger outputs.
39
39
 
40
40
 
41
41
  ## Session behavior and limits
@@ -45,6 +45,7 @@ For quieter sessions, set `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES` to a highe
45
45
  - Without durable mode, restarting Pi loses broker state until the current branch is backfilled again.
46
46
  - Prompt integration injects a bounded, tier-aware broker brief and lookup guidance; the LLM also gets a `context_lookup` tool for exact handle dereferencing. Payloads that hit hostile-binary heuristics are represented in prompt as handles plus short guidance to export the full content.
47
47
  - The `context` hook rewrites prompt-visible `toolResult` and `bashExecution` payloads in the LLM-bound message copy to broker handles and summaries, reducing prompt load while preserving exact `/context lookup` rehydration.
48
+ - Current-turn `context_lookup` results are left visible so the model can consume requested exact evidence once. Historical `context_lookup` results that already have a later assistant response are omitted from later prompt assembly to avoid recursive prompt growth.
48
49
  - Pi `excludeFromContext` bash entries are not backfilled or rewritten into broker prompts.
49
50
  - Basic secret redaction runs before broker storage and display for common token/password/API-key patterns.
50
51
  - Optional global caps can be configured via env vars:
@@ -382,7 +382,7 @@ lookupBytes: 500,
382
382
  await commands.get("context").handler(`export ${handle}`, ctx);
383
383
  const result = await handlers.get("context")?.[0]({
384
384
  type: "context",
385
- messages: [{ role: "toolResult", toolCallId: "tool-result-telemetry", toolName: "bash", content: [{ type: "text", text: "telemetry_payload_" + "y".repeat(150) }], isError: false, timestamp: 1 }],
385
+ messages: [{ role: "toolResult", toolCallId: "tool-result-telemetry", toolName: "bash", content: [{ type: "text", text: "telemetry_payload_" + "y".repeat(1000) }], isError: false, timestamp: 1 }],
386
386
  }, ctx);
387
387
 
388
388
  await commands.get("context").handler("status", ctx);
@@ -392,15 +392,46 @@ lookupBytes: 500,
392
392
  expect(result).toBeDefined();
393
393
  const telemetry = notifications.at(-1)?.message ?? "";
394
394
  expect(telemetry).toContain("Context broker routing telemetry:");
395
+ expect(telemetry).toContain("rewriteSavings rawBytes=");
396
+ expect(telemetry).toContain("replacementBytes=");
397
+ expect(telemetry).toContain("savedBytes=");
398
+ expect(telemetry).toMatch(/savedBytes=[1-9]\d*/);
399
+ expect(telemetry).toContain("contextLookupHistoryOmitted=");
395
400
  expect(telemetry).toContain("lookups tool(calls=");
396
401
  expect(telemetry).toContain("lookups slash(calls=");
397
402
  expect(telemetry).toContain("exports=");
398
403
  expect(telemetry).toContain("pins=");
399
404
  });
400
405
 
401
- it("does not broker context_lookup results recursively", async () => {
406
+ it("keeps current context_lookup results visible before the model consumes them", async () => {
402
407
  const { pi, handlers, commands, tools } = createPiMock();
403
- registerContextBrokerBeta(pi, { lookupBytes: 500, rewriteThresholdBytes: 1 });
408
+ registerContextBrokerBeta(pi, { lookupBytes: 1000, rewriteThresholdBytes: 1 });
409
+ const { ctx } = createCtx();
410
+
411
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
412
+ await runHandlers(handlers, "tool_result", {
413
+ type: "tool_result",
414
+ toolCallId: "call-current-lookup-source",
415
+ toolName: "bash",
416
+ input: { command: "printf current-lookup" },
417
+ content: [{ type: "text", text: "CURRENT_LOOKUP_EVIDENCE_" + "x".repeat(120) }],
418
+ isError: false,
419
+ }, ctx);
420
+ const handle = commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
421
+ const lookupResult = await tools.get("context_lookup").execute("lookup-current", { handle }, undefined, undefined, ctx);
422
+
423
+ const contextResult = await handlers.get("context")?.[0]({
424
+ type: "context",
425
+ messages: [{ role: "toolResult", toolCallId: "lookup-current", toolName: "context_lookup", content: lookupResult.content, isError: false }],
426
+ }, ctx);
427
+
428
+ expect(contextResult).toBeUndefined();
429
+ expect(lookupResult.content[0].text).toContain("CURRENT_LOOKUP_EVIDENCE_");
430
+ });
431
+
432
+ it("does not broker historical context_lookup results recursively", async () => {
433
+ const { pi, handlers, commands, tools } = createPiMock();
434
+ registerContextBrokerBeta(pi, { lookupBytes: 500 });
404
435
  const { ctx, notifications } = createCtx();
405
436
 
406
437
  await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
@@ -425,7 +456,10 @@ lookupBytes: 500,
425
456
  }, ctx);
426
457
  const contextResult = await handlers.get("context")?.[0]({
427
458
  type: "context",
428
- messages: [{ role: "toolResult", toolCallId: "lookup-call", toolName: "context_lookup", content: lookupResult.content, isError: false }],
459
+ messages: [
460
+ { role: "toolResult", toolCallId: "lookup-call", toolName: "context_lookup", content: lookupResult.content, isError: false },
461
+ { role: "assistant", content: [{ type: "text", text: "I consumed the lookup." }] },
462
+ ],
429
463
  }, ctx);
430
464
  await commands.get("context").handler("brief", ctx);
431
465
 
@@ -176,6 +176,16 @@ function compact(value: string, max = 120): string {
176
176
  return truncateUtf8(value.replace(/\s+/g, " ").trim(), max);
177
177
  }
178
178
 
179
+ function utf8Bytes(text: string): number {
180
+ return Buffer.byteLength(text, "utf8");
181
+ }
182
+
183
+ function promptPayloadBytes(message: any): number {
184
+ if (message?.role === "bashExecution") return utf8Bytes(String(message.output ?? ""));
185
+ if (message?.role === "toolResult") return utf8Bytes(contentText(message.content));
186
+ return utf8Bytes(toText(message));
187
+ }
188
+
179
189
  function stableHash(value: string): string {
180
190
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
181
191
  }
@@ -308,6 +318,9 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
308
318
  contextHookBash: 0,
309
319
  contextHookBashRewrites: 0,
310
320
  contextHookBashHostile: 0,
321
+ contextHookRewriteRawBytes: 0,
322
+ contextHookRewriteReplacementBytes: 0,
323
+ contextHookContextLookupHistoryOmissions: 0,
311
324
  toolResultEvents: 0,
312
325
  toolResultArtifacts: 0,
313
326
  backfillScans: 0,
@@ -329,11 +342,21 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
329
342
  pruneCalls: 0,
330
343
  };
331
344
 
345
+ function recordContextRewrite(rawBytes: number, replacementBytes: number): void {
346
+ routingTelemetry.contextHookRewriteRawBytes += Math.max(0, rawBytes);
347
+ routingTelemetry.contextHookRewriteReplacementBytes += Math.max(0, replacementBytes);
348
+ }
349
+
332
350
  function formatRoutingTelemetry(): string {
351
+ const savedBytes = Math.max(0, routingTelemetry.contextHookRewriteRawBytes - routingTelemetry.contextHookRewriteReplacementBytes);
352
+ const savedPct = routingTelemetry.contextHookRewriteRawBytes > 0
353
+ ? ((savedBytes / routingTelemetry.contextHookRewriteRawBytes) * 100).toFixed(1)
354
+ : "0.0";
333
355
  const line = [
334
356
  `contextHook calls=${routingTelemetry.contextHookCalls}`,
335
357
  `toolResults seen=${routingTelemetry.contextHookToolResults} rewritten=${routingTelemetry.contextHookToolResultRewrites} hostile=${routingTelemetry.contextHookToolResultHostile}`,
336
358
  `bash seen=${routingTelemetry.contextHookBash} rewritten=${routingTelemetry.contextHookBashRewrites} hostile=${routingTelemetry.contextHookBashHostile}`,
359
+ `rewriteSavings rawBytes=${routingTelemetry.contextHookRewriteRawBytes} replacementBytes=${routingTelemetry.contextHookRewriteReplacementBytes} savedBytes=${savedBytes} savedPct=${savedPct}% contextLookupHistoryOmitted=${routingTelemetry.contextHookContextLookupHistoryOmissions}`,
337
360
  `lookups tool(calls=${routingTelemetry.toolLookupCalls}, hits=${routingTelemetry.toolLookupHits}, misses=${routingTelemetry.toolLookupMisses})`,
338
361
  `lookups slash(calls=${routingTelemetry.commandLookupCalls}, hits=${routingTelemetry.commandLookupHits}, misses=${routingTelemetry.commandLookupMisses})`,
339
362
  `exports=${routingTelemetry.exportCalls}`,
@@ -572,19 +595,35 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
572
595
  activeSessionId = sessionIdFor(ctx);
573
596
  routingTelemetry.contextHookCalls += 1;
574
597
  const toolInputs = collectToolInputs(event.messages);
575
- const drafts = event.messages.map((message: any): { original: any; replacement?: any; artifact?: ContextArtifact; rewrite?: (artifact: ContextArtifact) => any; safeFallback?: any } => {
598
+ type RewriteDraft = {
599
+ original: any;
600
+ replacement?: any;
601
+ rawBytes?: number;
602
+ artifact?: ContextArtifact;
603
+ rewrite?: (artifact: ContextArtifact) => any;
604
+ safeFallback?: any;
605
+ };
606
+ const drafts = event.messages.map((message: any, index: number): RewriteDraft => {
576
607
  if (message?.role === "toolResult") {
577
608
  routingTelemetry.contextHookToolResults += 1;
578
609
  const raw = contentText(message.content);
610
+ const rawBytes = utf8Bytes(raw);
579
611
  const toolInput = typeof message.toolCallId === "string" ? toolInputs.get(message.toolCallId) : undefined;
580
612
  const toolName = String(message.toolName ?? toolInput?.toolName ?? "tool");
581
613
  const hostile = hasHostileText(raw) || hasHostileValue(message.content);
582
614
  if (hostile) routingTelemetry.contextHookToolResultHostile += 1;
583
- const shouldRewrite = Buffer.byteLength(raw, "utf8") > rewriteThresholdBytes || hostile;
584
- if (!shouldRewrite) return { original: message };
585
615
  if (!shouldBrokerToolName(toolName)) {
586
- return { original: message, replacement: { ...message, content: [{ type: "text", text: contextLookupHistoryPlaceholder() }] } };
616
+ const hasLaterAssistant = event.messages.slice(index + 1).some((candidate: any) => candidate?.role === "assistant");
617
+ if (!hasLaterAssistant) return { original: message };
618
+ routingTelemetry.contextHookContextLookupHistoryOmissions += 1;
619
+ return {
620
+ original: message,
621
+ rawBytes,
622
+ replacement: { ...message, content: [{ type: "text", text: contextLookupHistoryPlaceholder() }] },
623
+ };
587
624
  }
625
+ const shouldRewrite = rawBytes > rewriteThresholdBytes || hostile;
626
+ if (!shouldRewrite) return { original: message };
588
627
  const artifact = publishToolArtifact({
589
628
  toolName,
590
629
  input: message.input ?? toolInput?.input,
@@ -599,6 +638,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
599
638
  routingTelemetry.contextHookToolResultRewrites += 1;
600
639
  return {
601
640
  original: message,
641
+ rawBytes,
602
642
  artifact,
603
643
  rewrite: (live) => ({ ...message, content: [{ type: "text", text: brokerPlaceholder(live) }] }),
604
644
  safeFallback: { ...message, content: [{ type: "text", text: prunedPayloadPlaceholder(hostile) }] },
@@ -608,9 +648,10 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
608
648
  if (message?.role === "bashExecution" && message.excludeFromContext !== true) {
609
649
  routingTelemetry.contextHookBash += 1;
610
650
  const raw = String(message.output ?? "");
651
+ const rawBytes = utf8Bytes(raw);
611
652
  const hostile = hasHostileText(raw) || hasHostileValue(message.output);
612
653
  if (hostile) routingTelemetry.contextHookBashHostile += 1;
613
- const shouldRewrite = Buffer.byteLength(raw, "utf8") > rewriteThresholdBytes || hostile;
654
+ const shouldRewrite = rawBytes > rewriteThresholdBytes || hostile;
614
655
  if (!shouldRewrite) return { original: message };
615
656
  const sourceId = typeof message.timestamp === "number"
616
657
  ? `bash:${message.timestamp}:${stableHash([message.command ?? "", raw, message.exitCode ?? "", message.cancelled ?? ""].join("\n"))}`
@@ -634,6 +675,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
634
675
  routingTelemetry.contextHookBashRewrites += 1;
635
676
  return {
636
677
  original: message,
678
+ rawBytes,
637
679
  artifact,
638
680
  rewrite: (live) => ({ ...message, output: brokerPlaceholder(live), truncated: true }),
639
681
  safeFallback: { ...message, output: prunedPayloadPlaceholder(hostile), truncated: true },
@@ -647,6 +689,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
647
689
  const messages = drafts.map((draft) => {
648
690
  if (draft.replacement) {
649
691
  changed = true;
692
+ recordContextRewrite(draft.rawBytes ?? promptPayloadBytes(draft.original), promptPayloadBytes(draft.replacement));
650
693
  return draft.replacement;
651
694
  }
652
695
  if (!draft.artifact || !draft.rewrite) return draft.original;
@@ -655,12 +698,15 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
655
698
  for (const parentId of draft.artifact.parentIds) sourceHandles.delete(parentId);
656
699
  if (draft.safeFallback) {
657
700
  changed = true;
701
+ recordContextRewrite(draft.rawBytes ?? promptPayloadBytes(draft.original), promptPayloadBytes(draft.safeFallback));
658
702
  return draft.safeFallback;
659
703
  }
660
704
  return draft.original;
661
705
  }
662
706
  changed = true;
663
- return draft.rewrite(live);
707
+ const replacement = draft.rewrite(live);
708
+ recordContextRewrite(draft.rawBytes ?? promptPayloadBytes(draft.original), promptPayloadBytes(replacement));
709
+ return replacement;
664
710
  });
665
711
 
666
712
  return changed ? { messages } : undefined;
@@ -62,6 +62,16 @@ describe("advisor check-in lifecycle bridge", () => {
62
62
  expect(JSON.parse(readFileSync(file, "utf8")).checkins).toBe("off");
63
63
  });
64
64
 
65
+ it("preserves legacy one-argument reset signature", () => {
66
+ const { config } = tempState();
67
+ writeFileSync(config, JSON.stringify({ checkins: "off" }), "utf8");
68
+
69
+ const next = resetAdvisorSessionContext(config);
70
+
71
+ expect(next.config.checkins).toBe("off");
72
+ expect(JSON.parse(readFileSync(config, "utf8")).checkins).toBe("off");
73
+ });
74
+
65
75
  it("resets advisor brief context and check-in timing for a new goal", () => {
66
76
  const { config, state } = tempState();
67
77
  const startedAt = Date.now();