@jameslovespancakes/pi-plus 1.0.12 → 1.0.13

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 (45) hide show
  1. package/README.md +24 -3
  2. package/package.json +4 -2
  3. package/src/core/accounts/oauth-pool.ts +35 -0
  4. package/src/core/accounts/registry.ts +4 -0
  5. package/src/core/accounts/routing.ts +22 -0
  6. package/src/core/anthropic/identity.ts +54 -0
  7. package/src/core/anthropic/oauth.ts +3 -0
  8. package/src/core/anthropic/quota.ts +227 -28
  9. package/src/core/anthropic/store.ts +4 -1
  10. package/src/core/codex/quota.ts +43 -11
  11. package/src/core/codex/store.ts +21 -11
  12. package/src/core/config.ts +12 -0
  13. package/src/domains/agents/format.ts +97 -0
  14. package/src/domains/agents/index.ts +58 -47
  15. package/src/domains/compact/archive.ts +140 -0
  16. package/src/domains/compact/chunking.ts +166 -0
  17. package/src/domains/compact/index.ts +452 -0
  18. package/src/domains/compact/jev.ts +242 -0
  19. package/src/domains/compact/policy.ts +255 -0
  20. package/src/domains/compact/types.ts +79 -0
  21. package/src/domains/subscriptions/accounts-picker.ts +44 -4
  22. package/src/domains/subscriptions/accounts.ts +31 -8
  23. package/src/domains/subscriptions/index.ts +2 -2
  24. package/src/domains/subscriptions/provider.ts +46 -5
  25. package/src/domains/subscriptions/providers/anthropic.ts +37 -5
  26. package/src/domains/subscriptions/providers/codex.ts +120 -254
  27. package/src/domains/subscriptions/providers/hosted.ts +2 -2
  28. package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
  29. package/src/domains/workflows/index.ts +1 -1
  30. package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
  31. package/src/domains/workflows/runtime/agent-failure.ts +14 -0
  32. package/src/domains/workflows/runtime/agent-limits.ts +24 -1
  33. package/src/domains/workflows/runtime/agent-runner.ts +42 -20
  34. package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
  35. package/src/domains/workflows/runtime/engine.ts +48 -12
  36. package/src/domains/workflows/runtime/progress.ts +2 -2
  37. package/src/domains/workflows/runtime/types.ts +4 -4
  38. package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
  39. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
  40. package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
  41. package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
  42. package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
  43. package/src/domains/workflows/runtime/worktree.ts +35 -1
  44. package/src/ui/usage-bars.ts +1 -2
  45. package/src/core/codex/oauth.ts +0 -129
@@ -20,10 +20,17 @@ export interface RemoteSection {
20
20
  workers: Record<string, unknown>[];
21
21
  }
22
22
 
23
+ export type BetterCompactMode = "off" | "on" | "jev";
24
+
25
+ export interface CompactSection {
26
+ better: BetterCompactMode;
27
+ }
28
+
23
29
  export interface PiPlusConfig {
24
30
  env: Record<string, string>;
25
31
  policy: PolicySection;
26
32
  remote: RemoteSection;
33
+ compact: CompactSection;
27
34
  }
28
35
 
29
36
  const DEFAULTS: PiPlusConfig = {
@@ -34,6 +41,7 @@ const DEFAULTS: PiPlusConfig = {
34
41
  deny: [],
35
42
  },
36
43
  remote: { injectStatus: true, workers: [] },
44
+ compact: { better: "off" },
37
45
  };
38
46
 
39
47
  /** Legacy file -> section, applied only when that section is still absent. */
@@ -103,6 +111,7 @@ export function configPath(): string {
103
111
  }
104
112
 
105
113
  function normalize(raw: Partial<PiPlusConfig> | undefined): PiPlusConfig {
114
+ const better = raw?.compact?.better;
106
115
  return {
107
116
  env: raw?.env && typeof raw.env === "object" ? { ...raw.env } : {},
108
117
  policy: {
@@ -117,6 +126,9 @@ function normalize(raw: Partial<PiPlusConfig> | undefined): PiPlusConfig {
117
126
  defaults: raw?.remote?.defaults,
118
127
  workers: Array.isArray(raw?.remote?.workers) ? raw.remote.workers : [],
119
128
  },
129
+ compact: {
130
+ better: better === "on" || better === "jev" ? better : "off",
131
+ },
120
132
  };
121
133
  }
122
134
 
@@ -0,0 +1,97 @@
1
+ const DELIVERY_TEXT_MAX = 2_000;
2
+
3
+ export interface SnapshotAgent {
4
+ sessionId: string;
5
+ alias?: string;
6
+ host: string;
7
+ branch?: string;
8
+ repo?: string;
9
+ commit?: string;
10
+ state: "idle" | "thinking" | "tool";
11
+ lastTool?: string;
12
+ coordinator?: string | null;
13
+ reports?: string[];
14
+ }
15
+
16
+ export interface SnapshotSelf {
17
+ branch?: string;
18
+ repo?: string;
19
+ commit?: string;
20
+ }
21
+
22
+ export interface SnapshotCoordination {
23
+ coordinator?: string | null;
24
+ reports?: string[];
25
+ repoThread?: string;
26
+ }
27
+
28
+ export interface DeliveryMessage {
29
+ senderId: string;
30
+ senderAlias?: string;
31
+ text: string;
32
+ priority: "normal" | "urgent";
33
+ }
34
+
35
+ export interface DeliveryThread {
36
+ id: string;
37
+ title?: string;
38
+ }
39
+
40
+ export interface BoardDelivery {
41
+ message: DeliveryMessage;
42
+ thread: DeliveryThread;
43
+ }
44
+
45
+ function cleanLine(value: unknown, max: number): string {
46
+ const text = String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
47
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
48
+ }
49
+
50
+ function compactTool(lastTool?: string): string | undefined {
51
+ if (!lastTool) return undefined;
52
+ const separator = lastTool.indexOf(":");
53
+ return cleanLine(separator < 0 ? lastTool : lastTool.slice(0, separator), 32);
54
+ }
55
+
56
+ export function formatBoardSnapshot(
57
+ agents: readonly SnapshotAgent[],
58
+ self: SnapshotSelf,
59
+ coordination: SnapshotCoordination,
60
+ ): string {
61
+ const commit = self.commit?.slice(0, 12) || "no-commit";
62
+ const meta = [
63
+ `${self.branch || "-"}@${commit}`,
64
+ `${agents.length} peer${agents.length === 1 ? "" : "s"}`,
65
+ coordination.repoThread ? `room=${coordination.repoThread}` : undefined,
66
+ coordination.coordinator ? `reports-to=${coordination.coordinator}` : undefined,
67
+ coordination.reports?.length ? `reports=${coordination.reports.join(",")}` : undefined,
68
+ ].filter(Boolean).join(" ");
69
+ const lines = [`[board ${meta}]`];
70
+ for (const agent of agents.slice(0, 12)) {
71
+ const agentCommit = agent.commit?.slice(0, 12) || "no-commit";
72
+ const sameLine = agent.repo && self.repo === agent.repo && agent.branch === self.branch;
73
+ const drift = sameLine && agent.commit && self.commit && agent.commit !== self.commit ? " !commit" : "";
74
+ const tool = agent.state === "tool" ? compactTool(agent.lastTool) : undefined;
75
+ const activity = tool ? `tool:${tool}` : agent.state;
76
+ const role = agent.reports?.length ? " coord" : agent.coordinator ? ` ->${agent.coordinator}` : "";
77
+ lines.push(`- ${agent.alias || agent.sessionId.slice(0, 8)}@${agent.host} ${activity} ${agent.branch || "-"}@${agentCommit}${drift}${role}`);
78
+ }
79
+ if (agents.length > 12) lines.push(`- +${agents.length - 12} more; use agent_board agents`);
80
+ return lines.join("\n");
81
+ }
82
+
83
+ function compactDeliveryText(text: string): string {
84
+ const clean = text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, " ").trim();
85
+ if (clean.length <= DELIVERY_TEXT_MAX) return clean;
86
+ return `${clean.slice(0, DELIVERY_TEXT_MAX - 1)}…\n[truncated; use agent_board read]`;
87
+ }
88
+
89
+ export function formatBoardDeliveries(deliveries: readonly BoardDelivery[]): string {
90
+ const lines = [`[board${deliveries.some(({ message }) => message.priority === "urgent") ? " urgent" : ""} x${deliveries.length}]`];
91
+ for (const { message, thread } of deliveries) {
92
+ const sender = message.senderAlias || message.senderId.slice(0, 8);
93
+ lines.push(`${message.priority === "urgent" ? "! " : ""}from=${sender} thread=${thread.id}${thread.title ? ` (${cleanLine(thread.title, 60)})` : ""}`);
94
+ lines.push(compactDeliveryText(message.text));
95
+ }
96
+ return lines.join("\n");
97
+ }
@@ -11,11 +11,14 @@ import { Type } from "typebox";
11
11
  import WebSocket from "ws";
12
12
  import { env } from "../../core/env.ts";
13
13
  import { handleBoardAdmin, registerBoardLifecycle } from "./board-setup.ts";
14
+ import { formatBoardDeliveries, formatBoardSnapshot, type BoardDelivery } from "./format.ts";
14
15
 
15
16
  const HEARTBEAT_MS = 8_000;
16
17
  const REQUEST_TIMEOUT_MS = 2_000;
17
18
  const SNAPSHOT_CACHE_MS = 1_500;
18
19
  const MESSAGE_MAX = 8_000;
20
+ const DELIVERY_DEBOUNCE_MS = 250;
21
+ const DELIVERY_BATCH_MAX = 8;
19
22
 
20
23
  type State = "idle" | "thinking" | "tool";
21
24
  type Priority = "normal" | "urgent";
@@ -302,6 +305,9 @@ export default function (pi: ExtensionAPI) {
302
305
  let self: AgentInfo | undefined;
303
306
  let gitInfo: GitInfo = {};
304
307
  let snapshot: { at: number; agents: AgentInfo[] } = { at: 0, agents: [] };
308
+ let lastInjectedSnapshot: string | undefined;
309
+ let pendingDeliveries: BoardDelivery[] = [];
310
+ let deliveryTimer: ReturnType<typeof setTimeout> | undefined;
305
311
 
306
312
  const updateStatus = () => {
307
313
  if (!ctx?.hasUI) return;
@@ -315,43 +321,46 @@ export default function (pi: ExtensionAPI) {
315
321
  self = { ...self, ...gitInfo };
316
322
  client.presence(gitInfo);
317
323
  };
318
- const formatCoordination = (): string[] => {
319
- const { coordinator, reports, repoThread } = client.coordination;
320
- const lines: string[] = [];
321
- if (coordinator) lines.push(`You report to coordinator '${coordinator}'. Use agent_board action 'report' to send them progress, blockers, and completed work.`);
322
- if (reports?.length) lines.push(`You are the coordinator for: ${reports.join(", ")}. Their reports arrive as agent-board messages; keep their work aligned.`);
323
- if (repoThread) lines.push(`Shared repo room: thread '${repoThread}' automatically contains every agent working in this repository. Post repo-wide notices there with agent_board send.`);
324
- return lines;
324
+ const snapshotAgents = async (): Promise<AgentInfo[]> => {
325
+ if (Date.now() - snapshot.at > SNAPSHOT_CACHE_MS) {
326
+ snapshot = { at: Date.now(), agents: await client.request("agents", {}, 500) };
327
+ }
328
+ return snapshot.agents;
325
329
  };
326
- const formatSnapshot = (agents: AgentInfo[]): string => {
327
- const coordinationLines = formatCoordination();
328
- if (!agents.length) return ["[Live agent board]\nNo other Pi agents are currently running.", ...coordinationLines].join("\n");
329
- const lines = agents.slice(0, 20).map((agent) => {
330
- const commit = agent.commit ? agent.commit.slice(0, 12) : "no-commit";
331
- const sameLine = agent.repo && gitInfo.repo === agent.repo && agent.branch === gitInfo.branch;
332
- const divergent = sameLine && agent.commit && gitInfo.commit && agent.commit !== gitInfo.commit ? " ⚠ different commit" : "";
333
- const activity = agent.state === "tool" && agent.lastTool ? agent.lastTool : agent.state;
334
- const role = agent.reports?.length ? " [coordinator]" : agent.coordinator ? ` [reports to ${agent.coordinator}]` : "";
335
- return `- ${agent.alias || agent.sessionId.slice(0, 8)}@${agent.host}${role}: ${activity}; ${agent.branch || "-"}@${commit}${divergent}${agent.lastPrompt ? `; ${agent.lastPrompt}` : ""}`;
336
- });
337
- return [
338
- `[Live agent board, consult before working]`,
339
- `Your commit: ${gitInfo.branch || "-"}@${gitInfo.commit?.slice(0, 12) || "no-commit"}`,
340
- `Running collaborators:`,
341
- lines.join("\n"),
342
- ...coordinationLines,
343
- `Avoid duplicate or outdated work. Use agent_board to coordinate when useful.`,
344
- ].join("\n");
330
+ const flushDeliveries = () => {
331
+ if (deliveryTimer) clearTimeout(deliveryTimer);
332
+ deliveryTimer = undefined;
333
+ const deliveries = pendingDeliveries;
334
+ pendingDeliveries = [];
335
+ if (!ctx || !deliveries.length) return;
336
+ const urgent = deliveries.some(({ message }) => message.priority === "urgent");
337
+ if (urgent && !ctx.isIdle()) ctx.abort();
338
+ const details = deliveries.length === 1 ? deliveries[0] : { deliveries };
339
+ pi.sendMessage(
340
+ { customType: "agent-board", content: formatBoardDeliveries(deliveries), display: true, details },
341
+ { deliverAs: "steer", triggerTurn: true },
342
+ );
343
+ };
344
+ const queueDelivery = (delivery: BoardDelivery) => {
345
+ pendingDeliveries.push(delivery);
346
+ if (delivery.message.priority === "urgent" || pendingDeliveries.length >= DELIVERY_BATCH_MAX) {
347
+ flushDeliveries();
348
+ return;
349
+ }
350
+ if (deliveryTimer) return;
351
+ deliveryTimer = setTimeout(flushDeliveries, DELIVERY_DEBOUNCE_MS);
352
+ deliveryTimer.unref?.();
345
353
  };
346
354
 
347
355
  client.on((event) => {
348
- if (event.t === "connection" || event.t === "coordination") { updateStatus(); return; }
356
+ if (event.t === "connection") {
357
+ if (event.connected) { snapshot.at = 0; lastInjectedSnapshot = undefined; }
358
+ updateStatus();
359
+ return;
360
+ }
361
+ if (event.t === "coordination") { updateStatus(); return; }
349
362
  if (event.t !== "message" || event.adminView || !ctx) return;
350
- const message = event.message as BoardMessage;
351
- const thread = event.thread as ThreadInfo;
352
- const content = `[Live agent-board message${message.priority === "urgent" ? " URGENT" : ""}]\nFrom: ${message.senderAlias || message.senderId}\nThread: ${thread.title || thread.id}\nCommit context: you are on ${gitInfo.branch || "-"}@${gitInfo.commit?.slice(0, 12) || "no-commit"}\n\n${message.text}\n\nEvaluate this at the next decision point. If relevant, adjust your work and reply through agent_board with thread '${thread.id}'. If irrelevant, continue the current task.`;
353
- if (message.priority === "urgent" && !ctx.isIdle()) ctx.abort();
354
- pi.sendMessage({ customType: "agent-board", content, display: true, details: { message, thread } }, { deliverAs: "steer", triggerTurn: true });
363
+ queueDelivery({ message: event.message as BoardMessage, thread: event.thread as ThreadInfo });
355
364
  });
356
365
 
357
366
  pi.on("session_start", async (_event, eventCtx) => {
@@ -368,6 +377,16 @@ export default function (pi: ExtensionAPI) {
368
377
  pi.on("before_agent_start", async (event) => {
369
378
  client.presence({ state: "thinking", lastPrompt: cleanLine(event.prompt) });
370
379
  client.activity("prompt", event.prompt);
380
+ if (!client.connected) return;
381
+ try {
382
+ const content = formatBoardSnapshot(await snapshotAgents(), gitInfo, client.coordination);
383
+ if (content === lastInjectedSnapshot) return;
384
+ lastInjectedSnapshot = content;
385
+ // Persist one compact snapshot at a user-turn boundary. A transient message
386
+ // appended in `context` becomes Anthropic's final cache breakpoint but is
387
+ // absent from the next transcript, forcing a conversation-cache miss.
388
+ return { message: { customType: "agent-board-snapshot", content, display: false, details: {} } };
389
+ } catch { return; }
371
390
  });
372
391
  pi.on("tool_execution_start", async (event) => {
373
392
  const lastTool = cleanLine(`${event.toolName}: ${JSON.stringify(event.args)}`, 100);
@@ -375,20 +394,12 @@ export default function (pi: ExtensionAPI) {
375
394
  });
376
395
  pi.on("tool_execution_end", async () => client.presence({ state: "thinking" }));
377
396
  pi.on("agent_settled", async () => { client.presence({ state: "idle", lastTool: undefined }); refreshGit(); });
378
- pi.on("session_shutdown", async (_event, eventCtx) => { client.stop(); eventCtx.ui.setStatus("agent-board", undefined); ctx = undefined; self = undefined; });
379
-
380
- // A transient, late context message avoids growing the session and keeps the static prompt cache stable.
381
- pi.on("context", async (event) => {
382
- if (!client.connected) return;
383
- try {
384
- if (Date.now() - snapshot.at > SNAPSHOT_CACHE_MS) snapshot = { at: Date.now(), agents: await client.request("agents", {}, 500) };
385
- return {
386
- messages: [...event.messages, {
387
- role: "custom" as const, customType: "agent-board-snapshot", content: formatSnapshot(snapshot.agents),
388
- display: false, details: {}, timestamp: Date.now(),
389
- }],
390
- };
391
- } catch { return; }
397
+ pi.on("session_compact", async () => { lastInjectedSnapshot = undefined; });
398
+ pi.on("session_tree", async () => { lastInjectedSnapshot = undefined; });
399
+ pi.on("session_shutdown", async (_event, eventCtx) => {
400
+ if (deliveryTimer) clearTimeout(deliveryTimer);
401
+ deliveryTimer = undefined; pendingDeliveries = [];
402
+ client.stop(); eventCtx.ui.setStatus("agent-board", undefined); ctx = undefined; self = undefined;
392
403
  });
393
404
 
394
405
  pi.registerMessageRenderer("agent-board", (message, _options, theme) => new Text(theme.fg("accent", message.content as string), 1, 0));
@@ -419,7 +430,7 @@ export default function (pi: ExtensionAPI) {
419
430
  description: "Consult and message all currently running local and remote Pi agents. Every agent record includes host, state, Git branch, and commit hash. Messages are live: idle agents wake immediately and busy agents see them at the next safe turn boundary. Stopped agents are never listed and messages are not queued for them. Agents working in the same repository share an auto-created repo room, and coordinators can be assigned so subordinate agents report their progress upward.",
420
431
  promptSnippet: "Consult live collaborators, report to your coordinator, and exchange direct, repo-room, or group messages",
421
432
  promptGuidelines: [
422
- "Consult the transient Live agent board context before starting work; avoid duplicating work and compare Git commit hashes before relying on another agent's changes.",
433
+ "Consult the latest compact Live agent board snapshot before starting work; avoid duplicating work and compare Git commit hashes before relying on another agent's changes.",
423
434
  "Use agent_board to inform collaborators when their commit or assumptions appear outdated, and reply only when coordination is useful.",
424
435
  "If the board snapshot names a coordinator for you, use agent_board action 'report' to send them meaningful progress, blockers, and completed work instead of staying silent.",
425
436
  "Use agent_board action 'set_coordinator' when the user puts one agent in charge of others, and 'coordinators' to inspect the current reporting structure.",
@@ -0,0 +1,140 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { basename } from "node:path";
4
+ import { agentPath, writeJson } from "../../core/store.ts";
5
+ import type { ArchiveRecord, SemanticChunk, SuperContextArchive } from "./types.ts";
6
+
7
+ function safeSessionId(sessionId: string): string {
8
+ const readable = sessionId.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 80) || "session";
9
+ const suffix = createHash("sha256").update(sessionId).digest("hex").slice(0, 10);
10
+ return `${readable}-${suffix}`;
11
+ }
12
+
13
+ export function archivePath(sessionId: string): string {
14
+ return agentPath("super-context", "archives", `${safeSessionId(sessionId)}.json`);
15
+ }
16
+
17
+ function emptyArchive(sessionId: string): SuperContextArchive {
18
+ return { version: 1, sessionId, records: [], checkpoints: {} };
19
+ }
20
+
21
+ function isArchiveRecord(value: unknown): value is ArchiveRecord {
22
+ if (!value || typeof value !== "object") return false;
23
+ const record = value as Partial<ArchiveRecord>;
24
+ return typeof record.id === "string"
25
+ && typeof record.hash === "string"
26
+ && typeof record.text === "string"
27
+ && typeof record.role === "string"
28
+ && typeof record.ordinal === "number"
29
+ && typeof record.archivedAt === "string";
30
+ }
31
+
32
+ /** Refuse to overwrite a corrupt archive: source recoverability wins over convenience. */
33
+ export function loadArchive(sessionId: string): SuperContextArchive {
34
+ const path = archivePath(sessionId);
35
+ if (!existsSync(path)) return emptyArchive(sessionId);
36
+
37
+ let parsed: unknown;
38
+ try {
39
+ parsed = JSON.parse(readFileSync(path, "utf8"));
40
+ } catch (error) {
41
+ throw new Error(`Super Context archive is unreadable; refusing to overwrite ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
42
+ }
43
+ if (!parsed || typeof parsed !== "object") throw new Error(`Invalid Super Context archive: ${path}`);
44
+ const candidate = parsed as Partial<SuperContextArchive>;
45
+ if (candidate.version !== 1 || candidate.sessionId !== sessionId || !Array.isArray(candidate.records)) {
46
+ throw new Error(`Unsupported Super Context archive format: ${path}`);
47
+ }
48
+ if (!candidate.records.every(isArchiveRecord)) throw new Error(`Invalid Super Context archive records: ${path}`);
49
+ const checkpoints = candidate.checkpoints;
50
+ if (!checkpoints || typeof checkpoints !== "object" || Array.isArray(checkpoints)) {
51
+ throw new Error(`Invalid Super Context archive checkpoints: ${path}`);
52
+ }
53
+ for (const ids of Object.values(checkpoints)) {
54
+ if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) {
55
+ throw new Error(`Invalid Super Context archive checkpoint: ${path}`);
56
+ }
57
+ }
58
+ return candidate as SuperContextArchive;
59
+ }
60
+
61
+ function addChunks(archive: SuperContextArchive, chunks: readonly SemanticChunk[]): void {
62
+ const byId = new Map(archive.records.map((record) => [record.id, record]));
63
+ let ordinal = archive.records.reduce((maximum, record) => Math.max(maximum, record.ordinal), -1) + 1;
64
+ const archivedAt = new Date().toISOString();
65
+ for (const chunk of chunks) {
66
+ const existing = byId.get(chunk.id);
67
+ if (existing) {
68
+ if (existing.hash !== chunk.hash || existing.text !== chunk.text) {
69
+ throw new Error(`Super Context archive ID collision for ${chunk.id}`);
70
+ }
71
+ continue;
72
+ }
73
+ const record: ArchiveRecord = { ...chunk, ordinal, archivedAt };
74
+ ordinal += 1;
75
+ archive.records.push(record);
76
+ byId.set(record.id, record);
77
+ }
78
+ }
79
+
80
+ function checkpointHash(recordIds: readonly string[]): string {
81
+ const digest = createHash("sha256").update(recordIds.join("\0")).digest("hex");
82
+ return `SCC-${digest.slice(0, 20)}`;
83
+ }
84
+
85
+ export interface PersistedCheckpoint {
86
+ archive: SuperContextArchive;
87
+ checkpointId: string;
88
+ records: ArchiveRecord[];
89
+ duplicateChunks: number;
90
+ file: string;
91
+ }
92
+
93
+ /** Add-only persistence for immutable records and content-addressed checkpoints. */
94
+ export function persistCheckpoint(
95
+ sessionId: string,
96
+ chunks: readonly SemanticChunk[],
97
+ priorRecordIds: readonly string[],
98
+ ): PersistedCheckpoint {
99
+ const archive = loadArchive(sessionId);
100
+ const currentIds = chunks.map((chunk) => chunk.id);
101
+ const allRecordIds = [...priorRecordIds, ...currentIds];
102
+ const duplicateChunks = allRecordIds.length - new Set(allRecordIds).size;
103
+ addChunks(archive, chunks);
104
+
105
+ const knownIds = new Set(archive.records.map((record) => record.id));
106
+ const recordIds = [...new Set(allRecordIds)];
107
+ const missing = recordIds.filter((id) => !knownIds.has(id));
108
+ if (missing.length > 0) throw new Error(`Super Context archive is missing ${missing.length} source record(s)`);
109
+
110
+ const checkpointId = checkpointHash(recordIds);
111
+ const existing = archive.checkpoints[checkpointId];
112
+ if (existing && JSON.stringify(existing) !== JSON.stringify(recordIds)) {
113
+ throw new Error(`Super Context checkpoint collision for ${checkpointId}`);
114
+ }
115
+ archive.checkpoints[checkpointId] ??= recordIds;
116
+
117
+ const file = archivePath(sessionId);
118
+ if (!writeJson(file, archive, false, 0o600)) throw new Error(`Failed to persist Super Context archive: ${file}`);
119
+ const byId = new Map(archive.records.map((record) => [record.id, record]));
120
+ return {
121
+ archive,
122
+ checkpointId,
123
+ records: recordIds.map((id) => byId.get(id)!),
124
+ duplicateChunks,
125
+ file,
126
+ };
127
+ }
128
+
129
+ export function checkpointRecords(archive: SuperContextArchive, checkpointId: string | undefined): ArchiveRecord[] {
130
+ if (!checkpointId) return [];
131
+ const ids = archive.checkpoints[checkpointId];
132
+ if (!ids) return [];
133
+ const byId = new Map(archive.records.map((record) => [record.id, record]));
134
+ const records = ids.map((id) => byId.get(id)).filter((record): record is ArchiveRecord => record !== undefined);
135
+ return records.length === ids.length ? records : [];
136
+ }
137
+
138
+ export function archiveDisplayName(file: string): string {
139
+ return basename(file);
140
+ }
@@ -0,0 +1,166 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import type { Message } from "@earendil-works/pi-ai";
4
+ import { convertToLlm } from "@earendil-works/pi-coding-agent";
5
+ import type { ChunkRole, SemanticChunk, SourceItem } from "./types.ts";
6
+
7
+ export const TARGET_CHUNK_TOKENS = 512;
8
+ export const MIN_CHUNK_TOKENS = 128;
9
+ export const MAX_CHUNK_TOKENS = 1024;
10
+ export const CHUNK_OVERLAP_TOKENS = 32;
11
+
12
+ const PROTECTION_PATTERN = /(?:\b(?:must|never|required?|constraint|blocked|blocker|decision|correction|instead|uncommitted|rollback|next steps?|todo|in progress|do not|don't|cannot|can't)\b|\b(?:error|failed?|failure|exception|timeout|timed out|denied|refused)\b|\b(?:modified|created|deleted|renamed|implemented|verified|reproduced)\b)/i;
13
+ const EXACT_PATTERN = /(?:[A-Za-z]:[\\/][^\s`"']+|(?:^|\s)(?:\.\.?[\\/]|~[\\/])[^\s`"']+|\b[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|json|md|py|rs|go|java|cs|cpp|c|h|yaml|yml|toml|lock|sql)\b|`[^`\n]{1,160}`|\b[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\(\)|\b(?:exit code|status code|HTTP)\s*[:=]?\s*\d+\b|\b\d+(?:\.\d+)?%\b)/im;
14
+ const QUARANTINE_PATTERN = /(?:ignore (?:all )?(?:prior|previous|earlier|system|developer) instructions?|system (?:override|message|prompt)|developer (?:override|message)|you are (?:chatgpt|the assistant)|follow these instructions?|assistant must|reveal (?:the )?(?:system|developer) prompt|include (?:the )?exact token|output (?:only )?(?:the )?(?:token|string)|prompt injection|INJECTION_[A-Z0-9_-]+)/i;
15
+
16
+ function estimateTokens(text: string): number {
17
+ return Math.max(1, Math.ceil(text.length / 4));
18
+ }
19
+
20
+ function blockText(content: Message["content"]): string {
21
+ if (typeof content === "string") return content;
22
+ return content
23
+ .map((block) => {
24
+ if (block.type === "text") return block.text;
25
+ if (block.type === "image") return `[image ${block.mimeType}]`;
26
+ return "";
27
+ })
28
+ .filter(Boolean)
29
+ .join("\n");
30
+ }
31
+
32
+ function serializeMessage(message: Message): string {
33
+ if (message.role === "user") return `[User]\n${blockText(message.content)}`;
34
+ if (message.role === "toolResult") {
35
+ const state = message.isError ? " error" : "";
36
+ return `[Tool result: ${message.toolName}${state}]\n${blockText(message.content)}`;
37
+ }
38
+ const parts: string[] = [];
39
+ for (const block of message.content) {
40
+ if (block.type === "text") parts.push(`[Assistant]\n${block.text}`);
41
+ else if (block.type === "thinking") parts.push(`[Assistant thinking]\n${block.thinking}`);
42
+ else if (block.type === "toolCall") {
43
+ parts.push(`[Assistant tool call]\n${block.name}(${JSON.stringify(block.arguments)})`);
44
+ }
45
+ }
46
+ if (message.errorMessage) parts.push(`[Assistant error]\n${message.errorMessage}`);
47
+ return parts.join("\n\n");
48
+ }
49
+
50
+ function roleFor(message: AgentMessage, converted: Message): ChunkRole {
51
+ if (message.role === "user") return "user";
52
+ if (message.role === "assistant") return "assistant";
53
+ if (message.role === "toolResult" || message.role === "bashExecution") return "tool";
54
+ if (message.role === "compactionSummary" || message.role === "branchSummary") return "summary";
55
+ if (message.role === "custom") return "custom";
56
+ if (converted.role === "toolResult") return "tool";
57
+ if (converted.role === "assistant") return "assistant";
58
+ return "custom";
59
+ }
60
+
61
+ /** Convert Pi messages without applying Pi's tool-output truncation. */
62
+ export function sourceItemsFromMessages(messages: readonly AgentMessage[]): SourceItem[] {
63
+ const items: SourceItem[] = [];
64
+ for (const message of messages) {
65
+ for (const converted of convertToLlm([message])) {
66
+ const text = serializeMessage(converted).trim();
67
+ if (!text) continue;
68
+ items.push({ role: roleFor(message, converted), text, source: "conversation" });
69
+ }
70
+ }
71
+ return items;
72
+ }
73
+
74
+ export function legacySummaryItem(summary: string): SourceItem {
75
+ return { role: "summary", text: `[Legacy compaction summary]\n${summary}`, source: "legacy-summary" };
76
+ }
77
+
78
+ export function isQuarantined(role: ChunkRole, text: string): boolean {
79
+ return role === "tool" && QUARANTINE_PATTERN.test(text);
80
+ }
81
+
82
+ export function isProtected(role: ChunkRole, text: string): boolean {
83
+ if (isQuarantined(role, text)) return false;
84
+ if (role === "user" || role === "summary") return true;
85
+ return PROTECTION_PATTERN.test(text);
86
+ }
87
+
88
+ export function isExactHeavy(text: string): boolean {
89
+ return EXACT_PATTERN.test(text) || PROTECTION_PATTERN.test(text);
90
+ }
91
+
92
+ function splitLargeItem(item: SourceItem): SourceItem[] {
93
+ const maxChars = MAX_CHUNK_TOKENS * 4;
94
+ const overlapChars = CHUNK_OVERLAP_TOKENS * 4;
95
+ if (item.text.length <= maxChars) return [item];
96
+
97
+ const pieces: SourceItem[] = [];
98
+ let start = 0;
99
+ while (start < item.text.length) {
100
+ let end = Math.min(item.text.length, start + maxChars);
101
+ if (end < item.text.length) {
102
+ const newline = item.text.lastIndexOf("\n", end);
103
+ const sentence = item.text.lastIndexOf(". ", end);
104
+ const boundary = Math.max(newline, sentence);
105
+ if (boundary > start + Math.floor(maxChars / 2)) end = boundary + 1;
106
+ }
107
+ pieces.push({ ...item, text: item.text.slice(start, end) });
108
+ if (end >= item.text.length) break;
109
+ start = Math.max(start + 1, end - overlapChars);
110
+ }
111
+ return pieces;
112
+ }
113
+
114
+ function safetyKey(item: SourceItem): string {
115
+ return [item.role, item.source, isProtected(item.role, item.text), isQuarantined(item.role, item.text)].join(":");
116
+ }
117
+
118
+ function makeChunk(items: readonly SourceItem[]): SemanticChunk {
119
+ const text = items.map((item) => item.text).join("\n\n");
120
+ const role = items[0]!.role;
121
+ const source = items[0]!.source;
122
+ const hash = createHash("sha256").update(role).update("\0").update(source).update("\0").update(text).digest("hex");
123
+ return {
124
+ id: `SC-${hash.slice(0, 16)}`,
125
+ hash,
126
+ role,
127
+ text,
128
+ tokens: estimateTokens(text),
129
+ source,
130
+ protected: isProtected(role, text),
131
+ exactHeavy: isExactHeavy(text),
132
+ quarantined: isQuarantined(role, text),
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Semantic/trust-aware chunking. Role and safety boundaries are never mixed;
138
+ * undersized chunks are retained when combining them would cross a boundary.
139
+ */
140
+ export function semanticChunks(sourceItems: readonly SourceItem[]): SemanticChunk[] {
141
+ const units = sourceItems.flatMap(splitLargeItem);
142
+ const chunks: SemanticChunk[] = [];
143
+ let current: SourceItem[] = [];
144
+ let currentTokens = 0;
145
+
146
+ const flush = () => {
147
+ if (current.length > 0) chunks.push(makeChunk(current));
148
+ current = [];
149
+ currentTokens = 0;
150
+ };
151
+
152
+ for (const unit of units) {
153
+ const unitTokens = estimateTokens(unit.text);
154
+ const compatible = current.length === 0 || safetyKey(current[0]!) === safetyKey(unit);
155
+ const combinedTokens = currentTokens + unitTokens;
156
+ if (!compatible || (current.length > 0 && currentTokens >= MIN_CHUNK_TOKENS && combinedTokens > TARGET_CHUNK_TOKENS)) {
157
+ flush();
158
+ }
159
+ if (current.length > 0 && currentTokens + unitTokens > MAX_CHUNK_TOKENS) flush();
160
+ current.push(unit);
161
+ currentTokens += unitTokens;
162
+ if (currentTokens >= TARGET_CHUNK_TOKENS) flush();
163
+ }
164
+ flush();
165
+ return chunks;
166
+ }