@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.
- package/README.md +24 -3
- package/package.json +4 -2
- package/src/core/accounts/oauth-pool.ts +35 -0
- package/src/core/accounts/registry.ts +4 -0
- package/src/core/accounts/routing.ts +22 -0
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +227 -28
- package/src/core/anthropic/store.ts +4 -1
- package/src/core/codex/quota.ts +43 -11
- package/src/core/codex/store.ts +21 -11
- package/src/core/config.ts +12 -0
- package/src/domains/agents/format.ts +97 -0
- package/src/domains/agents/index.ts +58 -47
- package/src/domains/compact/archive.ts +140 -0
- package/src/domains/compact/chunking.ts +166 -0
- package/src/domains/compact/index.ts +452 -0
- package/src/domains/compact/jev.ts +242 -0
- package/src/domains/compact/policy.ts +255 -0
- package/src/domains/compact/types.ts +79 -0
- package/src/domains/subscriptions/accounts-picker.ts +44 -4
- package/src/domains/subscriptions/accounts.ts +31 -8
- package/src/domains/subscriptions/index.ts +2 -2
- package/src/domains/subscriptions/provider.ts +46 -5
- package/src/domains/subscriptions/providers/anthropic.ts +37 -5
- package/src/domains/subscriptions/providers/codex.ts +120 -254
- package/src/domains/subscriptions/providers/hosted.ts +2 -2
- package/src/domains/subscriptions/providers/oauth-pool.ts +124 -72
- package/src/domains/workflows/index.ts +1 -1
- package/src/domains/workflows/runtime/agent-attempt.ts +41 -11
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +24 -1
- package/src/domains/workflows/runtime/agent-runner.ts +42 -20
- package/src/domains/workflows/runtime/agent-workspace.ts +0 -24
- package/src/domains/workflows/runtime/engine.ts +48 -12
- package/src/domains/workflows/runtime/progress.ts +2 -2
- package/src/domains/workflows/runtime/types.ts +4 -4
- package/src/domains/workflows/runtime/ui/display-text.ts +15 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +51 -14
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +6 -6
- package/src/domains/workflows/runtime/workflow-run-history.ts +3 -1
- package/src/domains/workflows/runtime/workflow-run-record.ts +1 -0
- package/src/domains/workflows/runtime/worktree.ts +35 -1
- package/src/ui/usage-bars.ts +1 -2
- package/src/core/codex/oauth.ts +0 -129
package/src/core/config.ts
CHANGED
|
@@ -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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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"
|
|
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
|
-
|
|
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("
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
|
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
|
+
}
|