@tt-a1i/openpi 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +295 -389
  3. package/SETUP.md +24 -22
  4. package/THIRD_PARTY_NOTICES.md +3 -4
  5. package/assets/readme-hero-mobile.svg +2 -2
  6. package/assets/readme-hero.svg +10 -10
  7. package/extensions/ask-user/handoff.ts +5 -1
  8. package/extensions/ask-user/index.ts +44 -0
  9. package/extensions/background-terminals/index.ts +118 -29
  10. package/extensions/background-terminals/src/domain.ts +5 -1
  11. package/extensions/background-terminals/src/manager.ts +2 -1
  12. package/extensions/background-terminals/src/prompt.ts +35 -0
  13. package/extensions/background-terminals/src/result-delivery.ts +76 -3
  14. package/extensions/background-terminals/src/ui/tool-result.ts +52 -1
  15. package/extensions/capabilities/index.ts +198 -0
  16. package/extensions/context-pivot/index.ts +21 -0
  17. package/extensions/cron/index.ts +42 -15
  18. package/extensions/execution-convergence/active-evidence.ts +129 -0
  19. package/extensions/execution-convergence/index.ts +442 -0
  20. package/extensions/execution-convergence/workspace-provenance.ts +338 -0
  21. package/extensions/file-search/index.ts +8 -1
  22. package/extensions/file-search/src/binaries.ts +2 -1
  23. package/extensions/git-info/src/runtime.ts +1 -1
  24. package/extensions/goal/controller.ts +2 -1
  25. package/extensions/goal/index.ts +20 -1
  26. package/extensions/plan-mode/index.ts +12 -0
  27. package/extensions/setup/index.ts +241 -45
  28. package/extensions/setup/intercom-fs-helper.cjs +130 -0
  29. package/extensions/setup/intercom.ts +603 -0
  30. package/extensions/shared/child-session.ts +42 -5
  31. package/extensions/shared/setup-config.ts +27 -1
  32. package/extensions/shared/setup-episode-state.ts +7 -0
  33. package/extensions/shared/tool-surface.ts +435 -0
  34. package/extensions/subagents/index.ts +16 -1
  35. package/extensions/subagents/src/manager.ts +13 -11
  36. package/extensions/subagents/src/prompt.ts +1 -1
  37. package/extensions/tasks/index.ts +39 -12
  38. package/extensions/ui-customization/footer.ts +6 -1
  39. package/extensions/workflows/artifacts.ts +6 -1
  40. package/extensions/workflows/dashboard.ts +138 -27
  41. package/extensions/workflows/graph-projection.ts +240 -0
  42. package/extensions/workflows/handoff.ts +194 -0
  43. package/extensions/workflows/index.ts +258 -56
  44. package/extensions/workflows/invocation-ledger.ts +368 -0
  45. package/extensions/workflows/model.ts +57 -1
  46. package/extensions/workflows/operator.ts +131 -0
  47. package/extensions/workflows/prompt.ts +10 -38
  48. package/extensions/workflows/replay-safety.ts +9 -8
  49. package/extensions/workflows/runner.ts +10 -2
  50. package/extensions/workflows/sandbox.ts +5 -0
  51. package/package.json +15 -15
  52. package/skills/subagents/SKILL.md +6 -0
  53. package/skills/workflows/EXAMPLES.md +58 -0
  54. package/skills/workflows/REFERENCE.md +44 -0
  55. package/skills/workflows/SKILL.md +39 -0
@@ -11,15 +11,29 @@ export function createDeferredResultDelivery<T extends { id: string }>() {
11
11
  return {
12
12
  defer(result: T) {
13
13
  pending.set(result.id, result);
14
+ return pending.size;
14
15
  },
15
16
  consume(ids: Iterable<string>) {
16
17
  for (const id of ids) pending.delete(id);
17
18
  },
18
- drain() {
19
- const results = [...pending.values()];
20
- pending.clear();
19
+ drain(maxResults = Number.POSITIVE_INFINITY) {
20
+ const results: T[] = [];
21
+ for (const [id, result] of pending) {
22
+ if (results.length >= maxResults) break;
23
+ results.push(result);
24
+ pending.delete(id);
25
+ }
21
26
  return results;
22
27
  },
28
+ restore(results: readonly T[]) {
29
+ const current = [...pending.values()];
30
+ pending.clear();
31
+ for (const result of results) pending.set(result.id, result);
32
+ for (const result of current) pending.set(result.id, result);
33
+ },
34
+ size() {
35
+ return pending.size;
36
+ },
23
37
  clear() {
24
38
  pending.clear();
25
39
  },
@@ -36,6 +50,65 @@ export function createDeferredResultDelivery<T extends { id: string }>() {
36
50
  * result in context — carried alongside the user's next message — without
37
51
  * demanding a reply.
38
52
  */
53
+ export interface IdleResultBatcherOptions<TimerHandle> {
54
+ readonly delayMs: number;
55
+ readonly isIdle: () => boolean;
56
+ readonly flush: (wake: boolean) => void;
57
+ readonly startTimer: (callback: () => void, delayMs: number) => TimerHandle;
58
+ readonly clearTimer: (timer: TimerHandle) => void;
59
+ }
60
+
61
+ /**
62
+ * Coalesce settlements that arrive while the agent is idle without turning
63
+ * the window into a sliding delay. A token rejects stale callbacks after
64
+ * cancellation, including callbacks already queued by the host event loop.
65
+ */
66
+ export function createIdleResultBatcher<TimerHandle>(
67
+ options: IdleResultBatcherOptions<TimerHandle>,
68
+ ) {
69
+ let timer: TimerHandle | undefined;
70
+ let active: symbol | undefined;
71
+
72
+ const cancel = () => {
73
+ active = undefined;
74
+ if (timer !== undefined) options.clearTimer(timer);
75
+ timer = undefined;
76
+ };
77
+
78
+ return {
79
+ schedule() {
80
+ if (active !== undefined) return;
81
+ const token = Symbol("idle-result-batch");
82
+ active = token;
83
+ timer = options.startTimer(() => {
84
+ if (active !== token) return;
85
+ active = undefined;
86
+ timer = undefined;
87
+ if (options.isIdle()) options.flush(true);
88
+ }, options.delayMs);
89
+ },
90
+ flushNow() {
91
+ const wake = options.isIdle();
92
+ cancel();
93
+ options.flush(wake);
94
+ },
95
+ flushWithoutWake() {
96
+ cancel();
97
+ options.flush(false);
98
+ },
99
+ clear: cancel,
100
+ };
101
+ }
102
+
103
+ export function hasTerminalCapacity(options: {
104
+ readonly running: number;
105
+ readonly pending: number;
106
+ readonly reserved: number;
107
+ readonly maximum: number;
108
+ }) {
109
+ return options.running + options.pending + options.reserved < options.maximum;
110
+ }
111
+
39
112
  export function resultDeliveryOptions(wake: boolean) {
40
113
  return wake
41
114
  ? ({ deliverAs: "followUp", triggerTurn: true } as const)
@@ -1,5 +1,5 @@
1
1
  import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
2
- import { Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
2
+ import { type Component, Text, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { sanitizeText } from "./output-view.ts";
4
4
 
5
5
  const STREAM_PREVIEW_LINES = 2;
@@ -102,6 +102,57 @@ function fixedRows(rows: string[]): Component {
102
102
  };
103
103
  }
104
104
 
105
+ interface TerminalBatchResult {
106
+ readonly id: string;
107
+ readonly title: string;
108
+ readonly status: string;
109
+ readonly exitCode?: number;
110
+ readonly signal?: string;
111
+ }
112
+
113
+ /** Render a batch as a truthful per-terminal summary; expand reveals raw logs. */
114
+ export function renderTerminalBatchResult(
115
+ content: string,
116
+ expanded: boolean,
117
+ theme: Theme,
118
+ results: readonly TerminalBatchResult[],
119
+ omitted = 0,
120
+ ): Component {
121
+ if (expanded) return new Text(sanitizeText(content), 0, 0);
122
+ const rows = [
123
+ theme.fg("accent", `${results.length} background terminals completed`),
124
+ ...results.map((result) => {
125
+ const failed =
126
+ result.status === "failed" || result.status === "timed_out";
127
+ const icon = failed
128
+ ? theme.fg("error", "x")
129
+ : result.status === "killed"
130
+ ? theme.fg("muted", "■")
131
+ : theme.fg("success", "■");
132
+ const how =
133
+ result.status === "timed_out"
134
+ ? "timed out"
135
+ : result.status === "killed"
136
+ ? "killed"
137
+ : (result.signal ?? `exit ${result.exitCode ?? "?"}`);
138
+ return `${icon} ${theme.fg("accent", result.id)}${theme.fg("muted", ` · ${result.title} · ${how}`)}`;
139
+ }),
140
+ ...(omitted > 0
141
+ ? [
142
+ theme.fg(
143
+ "dim",
144
+ `… ${omitted} older result${omitted === 1 ? "" : "s"} omitted`,
145
+ ),
146
+ ]
147
+ : []),
148
+ theme.fg(
149
+ "dim",
150
+ `… logs hidden · ${keyHint("app.tools.expand", "to expand")}`,
151
+ ),
152
+ ];
153
+ return fixedRows(rows);
154
+ }
155
+
105
156
  /** Render terminal results compactly by default; expanded mode preserves all text. */
106
157
  export function renderTerminalResult(
107
158
  content: string,
@@ -0,0 +1,198 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { type Static, Type } from "typebox";
4
+ import {
5
+ loadSetupConfig,
6
+ SETUP_CONFIG_CHANGED_CHANNEL,
7
+ type MyPiSetupConfig,
8
+ } from "../shared/setup-config.ts";
9
+ import {
10
+ getLoadedOpenPiCapabilities,
11
+ loadOpenPiCapabilities,
12
+ OPENPI_CAPABILITY_GROUPS,
13
+ OPENPI_CAPABILITY_NAMES,
14
+ OPENPI_TOOL_SURFACE,
15
+ type OpenPiCapability,
16
+ patchOwnedTools,
17
+ resetOpenPiToolSurface,
18
+ } from "../shared/tool-surface.ts";
19
+
20
+ const CapabilitySchema = Type.Enum(OPENPI_CAPABILITY_NAMES);
21
+
22
+ const OpenPiLoadToolsParameters = Type.Object({
23
+ groups: Type.Optional(
24
+ Type.Array(CapabilitySchema, {
25
+ minItems: 1,
26
+ maxItems: OPENPI_CAPABILITY_NAMES.length,
27
+ uniqueItems: true,
28
+ description: "Groups to load; omit to list.",
29
+ }),
30
+ ),
31
+ });
32
+
33
+ type OpenPiLoadToolsInput = Static<typeof OpenPiLoadToolsParameters>;
34
+
35
+ const CAPABILITY_INTENT = {
36
+ search:
37
+ /\b(?:use|run)\s+(?:fd|rg)\b|\buse\s+(?:structured\s+)?(?:(?:file|code|content)\s+)?search\b|\b(?:structured|fast)\s+(?:file|code|content)\s+search\b|(?:使用|运行).{0,8}(?:fd|rg)|结构化(?:文件|代码|内容)搜索/iu,
38
+ delegate:
39
+ /\b(?:use|spawn|run)\s+(?:an?\s+|multiple\s+|several\s+|two\s+)?(?:pi\s+)?subagents?\b|(?:^|[.!?]\s+)(?:please\s+)?(?:delegate|parallelize)\s+(?:this|the)\s+(?:task|work)\b|\b(?:can|could|would)\s+you\s+(?:please\s+)?(?:delegate|parallelize)\s+(?:this|the)\s+(?:task|work)\b|\bparallel\s+agents?\b|(?:使用|启动|调用|来|开).{0,8}子代理|(?:多个?|多路)子代理|并行.{0,8}(?:代理|agent)|委派.{0,6}任务/iu,
40
+ workflow:
41
+ /\b(?:use|run|create|build)\s+(?:(?:an?|the)\s+)?(?:openpi\s+)?workflow\b|(?:使用|运行|创建|构建).{0,8}工作流/iu,
42
+ background:
43
+ /\b(?:run|start|keep)\b.{0,40}\b(?:in the background|background\s+(?:process|terminal|job))\b|后台.{0,8}(?:运行|进程|终端|任务)/iu,
44
+ session:
45
+ /\b(?:create|set|update|track)\s+(?:an?\s+)?(?:session\s+)?(?:goal|task list|tasks)\b|(?:设置|创建|更新|跟踪|追踪).{0,8}(?:目标|任务)/iu,
46
+ } as const satisfies Record<OpenPiCapability, RegExp>;
47
+
48
+ const CAPABILITY_GATEWAY_INTENT =
49
+ /\bopenpi\s+(?:capabilit(?:y|ies)|tools?|features?)\b|openpi.{0,8}(?:能力|工具|功能)/iu;
50
+
51
+ const CONDITIONAL_OR_NEGATED_INTENT =
52
+ /^(?:\s*(?:only\s+)?(?:if|when|unless|before|in case)\b)|\b(?:do not|don't|cannot|can't|not|no|never|avoid)\b|\b(?:if|unless)\b|\bwhen\s+(?:needed|required|necessary)\b|(?:如果|若|假如|除非|仅当|需要时|不要|不能|不用|不必|无需|避免|请勿|禁止)/iu;
53
+
54
+ const CAPABILITY_SKILLS: Partial<Record<OpenPiCapability, string>> = {
55
+ delegate: fileURLToPath(
56
+ new URL("../../skills/subagents/SKILL.md", import.meta.url),
57
+ ),
58
+ workflow: fileURLToPath(
59
+ new URL("../../skills/workflows/SKILL.md", import.meta.url),
60
+ ),
61
+ background: fileURLToPath(
62
+ new URL("../../skills/background-terminals/SKILL.md", import.meta.url),
63
+ ),
64
+ };
65
+
66
+ function capabilitiesRequestedByPrompt(prompt: string) {
67
+ const clauses = prompt.split(/[\n.!?。!?;;]+/u);
68
+ return OPENPI_CAPABILITY_NAMES.filter((capability) =>
69
+ clauses.some(
70
+ (clause) =>
71
+ !CONDITIONAL_OR_NEGATED_INTENT.test(clause) &&
72
+ CAPABILITY_INTENT[capability].test(clause),
73
+ ),
74
+ );
75
+ }
76
+
77
+ function requestsCapabilityGateway(prompt: string) {
78
+ return prompt
79
+ .split(/[\n.!?。!?;;]+/u)
80
+ .some(
81
+ (clause) =>
82
+ !CONDITIONAL_OR_NEGATED_INTENT.test(clause) &&
83
+ CAPABILITY_GATEWAY_INTENT.test(clause),
84
+ );
85
+ }
86
+
87
+ function capabilitySkillPaths(capabilities: readonly OpenPiCapability[]) {
88
+ return capabilities.flatMap((capability) => {
89
+ const skill = CAPABILITY_SKILLS[capability];
90
+ return skill ? [skill] : [];
91
+ });
92
+ }
93
+
94
+ function skillGuidance(capabilities: readonly OpenPiCapability[]) {
95
+ const skills = capabilitySkillPaths(capabilities);
96
+ return skills.length > 0
97
+ ? `Before first use, read the matching OpenPI capability guidance: ${skills.join(", ")}.`
98
+ : "";
99
+ }
100
+
101
+ interface CapabilityExtensionDependencies {
102
+ readonly loadConfig: () => Pick<MyPiSetupConfig, "capabilities">;
103
+ readonly sourcePath?: string;
104
+ }
105
+
106
+ export function createCapabilitiesExtension(
107
+ dependencies: CapabilityExtensionDependencies = {
108
+ loadConfig: loadSetupConfig,
109
+ },
110
+ ) {
111
+ return function capabilities(pi: ExtensionAPI) {
112
+ const reconcileDiscoveryGateway = () => {
113
+ const adaptive =
114
+ dependencies.loadConfig().capabilities.discovery === "adaptive";
115
+ patchOwnedTools(pi, "capabilities", {
116
+ ...(adaptive
117
+ ? { enable: OPENPI_TOOL_SURFACE.capabilities.entry }
118
+ : { disable: OPENPI_TOOL_SURFACE.capabilities.entry }),
119
+ });
120
+ };
121
+
122
+ pi.events.on(SETUP_CONFIG_CHANGED_CHANNEL, reconcileDiscoveryGateway);
123
+
124
+ pi.on("session_start", () => {
125
+ resetOpenPiToolSurface(
126
+ pi,
127
+ dependencies.sourcePath
128
+ ? { capabilities: dependencies.sourcePath }
129
+ : undefined,
130
+ );
131
+ reconcileDiscoveryGateway();
132
+ });
133
+
134
+ pi.on("before_agent_start", (event) => {
135
+ if (requestsCapabilityGateway(event.prompt)) {
136
+ patchOwnedTools(pi, "capabilities", {
137
+ enable: OPENPI_TOOL_SURFACE.capabilities.entry,
138
+ });
139
+ }
140
+ const requested = capabilitiesRequestedByPrompt(event.prompt);
141
+ if (requested.length > 0) loadOpenPiCapabilities(pi, requested);
142
+ const guidance = skillGuidance(requested);
143
+ if (guidance) {
144
+ return {
145
+ message: {
146
+ customType: "openpi-capability-guidance",
147
+ content: guidance,
148
+ display: false,
149
+ details: { capabilities: requested },
150
+ },
151
+ };
152
+ }
153
+ });
154
+
155
+ pi.registerTool({
156
+ name: "openpi_load_tools",
157
+ label: "Load OpenPI Tools",
158
+ description:
159
+ "Load optional OpenPI groups only when useful: search, delegate, workflow, background, or session. Ordinary file and shell work needs none. Loaded groups stay available.",
160
+ parameters: OpenPiLoadToolsParameters,
161
+ async execute(_toolCallId, params: OpenPiLoadToolsInput) {
162
+ const result = params.groups
163
+ ? loadOpenPiCapabilities(pi, params.groups)
164
+ : {
165
+ newlyLoaded: [],
166
+ loaded: getLoadedOpenPiCapabilities(pi),
167
+ activatedTools: [],
168
+ };
169
+ const available = OPENPI_CAPABILITY_NAMES.map((name) => ({
170
+ name,
171
+ summary: OPENPI_CAPABILITY_GROUPS[name].summary,
172
+ loaded: result.loaded.includes(name),
173
+ }));
174
+ const text = params.groups
175
+ ? result.newlyLoaded.length > 0
176
+ ? `Loaded OpenPI capabilities: ${result.newlyLoaded.join(", ")}. Activated tools: ${result.activatedTools.join(", ") || "none yet"}.${skillGuidance(result.newlyLoaded) ? ` ${skillGuidance(result.newlyLoaded)}` : ""}`
177
+ : `Requested OpenPI capabilities were already loaded: ${result.loaded.join(", ") || "none"}.`
178
+ : `Available OpenPI capabilities:\n${available
179
+ .map(
180
+ ({ name, summary, loaded }) =>
181
+ `- ${name}${loaded ? " (loaded)" : ""}: ${summary}`,
182
+ )
183
+ .join("\n")}`;
184
+ return {
185
+ content: [{ type: "text" as const, text }],
186
+ details: {
187
+ available,
188
+ loaded: result.loaded,
189
+ newlyLoaded: result.newlyLoaded,
190
+ activatedTools: result.activatedTools,
191
+ },
192
+ };
193
+ },
194
+ });
195
+ };
196
+ }
197
+
198
+ export default createCapabilitiesExtension();
@@ -4,6 +4,10 @@ import type {
4
4
  SessionEntry,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
+ import {
8
+ OPENPI_TOOL_SURFACE,
9
+ patchOwnedTools,
10
+ } from "../shared/tool-surface.ts";
7
11
 
8
12
  export const MIN_CONTEXT_PIVOT_TOKENS = 30_000;
9
13
  const STATUS_KEY = "context-pivot";
@@ -78,6 +82,22 @@ export default function contextPivot(pi: ExtensionAPI) {
78
82
  let generation = 0;
79
83
  let pending: PendingPivot | undefined;
80
84
  let compacting = false;
85
+ const setVisibleForContext = (ctx?: ExtensionContext) => {
86
+ const tokens = ctx ? estimateContextTokens(ctx.getContextUsage()) : null;
87
+ patchOwnedTools(pi, "context", {
88
+ ...(tokens !== null && tokens >= MIN_CONTEXT_PIVOT_TOKENS
89
+ ? { enable: OPENPI_TOOL_SURFACE.context.deferred }
90
+ : { disable: OPENPI_TOOL_SURFACE.context.deferred }),
91
+ });
92
+ };
93
+
94
+ pi.on("session_start", () => {
95
+ setVisibleForContext();
96
+ });
97
+
98
+ pi.on("agent_start", (_event, ctx) => {
99
+ setVisibleForContext(ctx);
100
+ });
81
101
 
82
102
  pi.on("session_before_compact", (event) => {
83
103
  const pivot = pending;
@@ -99,6 +119,7 @@ export default function contextPivot(pi: ExtensionAPI) {
99
119
  generation += 1;
100
120
  pending = undefined;
101
121
  compacting = false;
122
+ setVisibleForContext();
102
123
  if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
103
124
  });
104
125
 
@@ -17,10 +17,10 @@ import type {
17
17
  } from "@earendil-works/pi-coding-agent";
18
18
  import {
19
19
  advanceDeliveredJobs,
20
+ type CronJob,
20
21
  dueJobs,
21
22
  formatInterval,
22
23
  parseCronCommand,
23
- type CronJob,
24
24
  } from "./schedule.ts";
25
25
 
26
26
  /** How often the scheduler looks for due jobs. */
@@ -55,20 +55,47 @@ export default function cron(
55
55
  stopPolling = undefined;
56
56
  };
57
57
 
58
- const fire = (job: CronJob) => {
58
+ const fire = (due: readonly CronJob[]) => {
59
+ if (due.length === 0) return true;
59
60
  try {
60
- pi.sendMessage(
61
- {
62
- customType: "cron-fire",
63
- content: job.prompt,
64
- display: true,
65
- details: { id: job.id, recurring: job.intervalMs !== undefined },
66
- },
67
- { deliverAs: "followUp", triggerTurn: true },
68
- );
61
+ const jobs = due.map((job) => ({
62
+ id: job.id,
63
+ prompt: job.prompt,
64
+ recurring: job.intervalMs !== undefined,
65
+ }));
66
+ const message =
67
+ due.length === 1
68
+ ? {
69
+ customType: "cron-fire",
70
+ content: `[cron ${jobs[0]!.id} · ${jobs[0]!.recurring ? "recurring" : "once"}]\n${jobs[0]!.prompt}`,
71
+ display: true,
72
+ details: jobs[0],
73
+ }
74
+ : {
75
+ customType: "cron-fire",
76
+ content: `${due.length} scheduled prompts are due:\n\n${jobs
77
+ .map(
78
+ (job) =>
79
+ `[cron ${job.id} · ${job.recurring ? "recurring" : "once"}]\n${job.prompt}`,
80
+ )
81
+ .join("\n\n")}`,
82
+ display: true,
83
+ details: { count: jobs.length, jobs },
84
+ };
85
+ pi.sendMessage<
86
+ | { id: number; prompt: string; recurring: boolean }
87
+ | {
88
+ count: number;
89
+ jobs: Array<{ id: number; prompt: string; recurring: boolean }>;
90
+ }
91
+ >(message, {
92
+ deliverAs: "followUp",
93
+ triggerTurn: true,
94
+ });
69
95
  return true;
70
96
  } catch {
71
- // Session may be shutting down; leave the job due for the next tick.
97
+ // Session may be shutting down; leave the whole batch due for the next
98
+ // tick. A partial advance would silently lose prompts.
72
99
  return false;
73
100
  }
74
101
  };
@@ -83,10 +110,10 @@ export default function cron(
83
110
  const due = dueJobs(jobs, now);
84
111
  if (due.length === 0) return;
85
112
  const deliveredIds = new Set<number>();
86
- for (const job of due) {
87
- if (fire(job)) deliveredIds.add(job.id);
113
+ if (fire(due)) {
114
+ for (const job of due) deliveredIds.add(job.id);
88
115
  }
89
- jobs = advanceDeliveredJobs(jobs, deliveredIds, now);
116
+ jobs = advanceDeliveredJobs(jobs, deliveredIds, runtime.now());
90
117
  if (jobs.length === 0) stopTicker();
91
118
  };
92
119
 
@@ -0,0 +1,129 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ContextEvent } from "@earendil-works/pi-coding-agent";
3
+
4
+ type Message = ContextEvent["messages"][number];
5
+
6
+ export interface ActiveEvidencePolicy {
7
+ minimumTransactionsBeforeProjection: number;
8
+ minActiveTransactions: number;
9
+ epochStride: number;
10
+ }
11
+
12
+ const DEFAULT_ACTIVE_EVIDENCE_POLICY: ActiveEvidencePolicy = {
13
+ minimumTransactionsBeforeProjection: 9,
14
+ minActiveTransactions: 3,
15
+ epochStride: 6,
16
+ };
17
+
18
+ export const DELAYED_ACTIVE_EVIDENCE_POLICY: ActiveEvidencePolicy = {
19
+ minimumTransactionsBeforeProjection: 15,
20
+ minActiveTransactions: 3,
21
+ epochStride: 6,
22
+ };
23
+
24
+ interface Transaction {
25
+ start: number;
26
+ end: number;
27
+ callIds: string[];
28
+ }
29
+
30
+ function serializedChars(messages: readonly Message[]) {
31
+ return messages.reduce(
32
+ (total, message) => total + JSON.stringify(message).length,
33
+ 0,
34
+ );
35
+ }
36
+
37
+ function assistantToolCallIds(message: Message) {
38
+ if (message.role !== "assistant") return [];
39
+ return message.content.flatMap((block) =>
40
+ block.type === "toolCall" ? [block.id] : [],
41
+ );
42
+ }
43
+
44
+ function parseTransactions(messages: readonly Message[], start: number) {
45
+ const transactions: Transaction[] = [];
46
+ let index = start;
47
+ while (index < messages.length) {
48
+ const assistant = messages[index];
49
+ if (!assistant) return undefined;
50
+ const callIds = assistantToolCallIds(assistant);
51
+ if (callIds.length === 0 || new Set(callIds).size !== callIds.length) {
52
+ return undefined;
53
+ }
54
+
55
+ const expected = new Set(callIds);
56
+ const observed = new Set<string>();
57
+ let cursor = index + 1;
58
+ while (cursor < messages.length && observed.size < expected.size) {
59
+ const result = messages[cursor];
60
+ if (result?.role !== "toolResult") return undefined;
61
+ if (!expected.has(result.toolCallId) || observed.has(result.toolCallId)) {
62
+ return undefined;
63
+ }
64
+ observed.add(result.toolCallId);
65
+ cursor += 1;
66
+ }
67
+ if (observed.size !== expected.size) return undefined;
68
+
69
+ transactions.push({ start: index, end: cursor, callIds });
70
+ index = cursor;
71
+ }
72
+ return transactions;
73
+ }
74
+
75
+ function latestUserIndex(messages: readonly Message[]) {
76
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
77
+ if (messages[index]?.role === "user") return index;
78
+ }
79
+ return -1;
80
+ }
81
+
82
+ export function projectActiveEvidence(
83
+ messages: readonly Message[],
84
+ policy = DEFAULT_ACTIVE_EVIDENCE_POLICY,
85
+ ) {
86
+ const userIndex = latestUserIndex(messages);
87
+ if (userIndex < 0 || userIndex === messages.length - 1) return undefined;
88
+
89
+ const transactions = parseTransactions(messages, userIndex + 1);
90
+ if (!transactions) return undefined;
91
+ if (transactions.length < policy.minimumTransactionsBeforeProjection) {
92
+ return undefined;
93
+ }
94
+
95
+ const closable = transactions.length - policy.minActiveTransactions;
96
+ const closedTransactions =
97
+ Math.floor(closable / policy.epochStride) * policy.epochStride;
98
+ if (closedTransactions < policy.epochStride) return undefined;
99
+
100
+ const first = transactions[0];
101
+ const retained = transactions[closedTransactions];
102
+ if (!first || !retained) return undefined;
103
+
104
+ const closedMessages = messages.slice(first.start, retained.start);
105
+ const digest = createHash("sha256")
106
+ .update(JSON.stringify(closedMessages))
107
+ .digest("hex")
108
+ .slice(0, 16);
109
+ const epoch = closedTransactions / policy.epochStride;
110
+ const projectedMessages: Message[] = [
111
+ ...messages.slice(0, first.start),
112
+ ...messages.slice(retained.start),
113
+ ];
114
+ const originalChars = serializedChars(messages);
115
+ const projectedChars = serializedChars(projectedMessages);
116
+ if (projectedChars >= originalChars) return undefined;
117
+
118
+ return {
119
+ messages: projectedMessages,
120
+ receipt: {
121
+ epoch,
122
+ closedTransactions,
123
+ retainedTransactions: transactions.length - closedTransactions,
124
+ originalChars,
125
+ projectedChars,
126
+ digest,
127
+ },
128
+ };
129
+ }