@visual-z/xeno 0.1.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 (180) hide show
  1. package/README.md +498 -0
  2. package/docs/install.md +130 -0
  3. package/package.json +43 -0
  4. package/src/apps/approve.ts +49 -0
  5. package/src/apps/cli.ts +37 -0
  6. package/src/apps/commands.ts +226 -0
  7. package/src/apps/completions.ts +28 -0
  8. package/src/apps/input.ts +297 -0
  9. package/src/apps/render.ts +207 -0
  10. package/src/apps/shell.ts +2353 -0
  11. package/src/apps/startup.ts +1302 -0
  12. package/src/core/benchmark.ts +195 -0
  13. package/src/core/boundary.ts +174 -0
  14. package/src/core/churn.ts +59 -0
  15. package/src/core/completion-review.ts +66 -0
  16. package/src/core/context-policy.ts +60 -0
  17. package/src/core/context-window.ts +54 -0
  18. package/src/core/continuation.ts +114 -0
  19. package/src/core/cycle.ts +17 -0
  20. package/src/core/data-query.ts +201 -0
  21. package/src/core/deadline.ts +39 -0
  22. package/src/core/derive.ts +489 -0
  23. package/src/core/edit-plan.ts +200 -0
  24. package/src/core/environment.ts +29 -0
  25. package/src/core/event-validation.ts +99 -0
  26. package/src/core/events.ts +486 -0
  27. package/src/core/evidence-links.ts +71 -0
  28. package/src/core/evidence.ts +89 -0
  29. package/src/core/execution.ts +12 -0
  30. package/src/core/exhaustive.ts +18 -0
  31. package/src/core/experiment.ts +40 -0
  32. package/src/core/export.ts +169 -0
  33. package/src/core/file-references.ts +35 -0
  34. package/src/core/frontmatter.ts +213 -0
  35. package/src/core/handoff.ts +397 -0
  36. package/src/core/harness-comparison.ts +90 -0
  37. package/src/core/history-query.ts +103 -0
  38. package/src/core/history.ts +100 -0
  39. package/src/core/json-event.ts +135 -0
  40. package/src/core/json-pointer.ts +46 -0
  41. package/src/core/keymap.ts +241 -0
  42. package/src/core/observation-view.ts +87 -0
  43. package/src/core/outcome.ts +45 -0
  44. package/src/core/overflow.ts +59 -0
  45. package/src/core/permissions.ts +182 -0
  46. package/src/core/policy-evaluation.ts +55 -0
  47. package/src/core/progress.ts +101 -0
  48. package/src/core/project.ts +372 -0
  49. package/src/core/prompts.ts +83 -0
  50. package/src/core/queue.ts +60 -0
  51. package/src/core/read-bounds.ts +15 -0
  52. package/src/core/recall-analysis.ts +90 -0
  53. package/src/core/recall-trial.ts +153 -0
  54. package/src/core/response-recovery.ts +46 -0
  55. package/src/core/scheduling.ts +8 -0
  56. package/src/core/settings.ts +174 -0
  57. package/src/core/skills.ts +121 -0
  58. package/src/core/stall.ts +48 -0
  59. package/src/core/text-normalize.ts +81 -0
  60. package/src/core/theme.ts +179 -0
  61. package/src/core/tokens.ts +149 -0
  62. package/src/core/tool-contract.ts +102 -0
  63. package/src/core/tool-input.ts +38 -0
  64. package/src/core/transcript.ts +60 -0
  65. package/src/core/window-trial.ts +65 -0
  66. package/src/core/work-state.ts +103 -0
  67. package/src/core/yaml.ts +282 -0
  68. package/src/index.ts +10 -0
  69. package/src/runtime/auth.ts +255 -0
  70. package/src/runtime/benchmark.ts +187 -0
  71. package/src/runtime/catalog.ts +365 -0
  72. package/src/runtime/evidence-notes.ts +33 -0
  73. package/src/runtime/exec-sessions.ts +214 -0
  74. package/src/runtime/execution-control.ts +39 -0
  75. package/src/runtime/harness-comparison.ts +356 -0
  76. package/src/runtime/history-tools.ts +108 -0
  77. package/src/runtime/loop.ts +1983 -0
  78. package/src/runtime/model.ts +614 -0
  79. package/src/runtime/models-fetch.ts +155 -0
  80. package/src/runtime/oauth-device.ts +86 -0
  81. package/src/runtime/oauth.ts +759 -0
  82. package/src/runtime/openai-responses.ts +286 -0
  83. package/src/runtime/openai.ts +675 -0
  84. package/src/runtime/provider.ts +498 -0
  85. package/src/runtime/recall-trial.ts +226 -0
  86. package/src/runtime/resume.ts +66 -0
  87. package/src/runtime/retry.ts +297 -0
  88. package/src/runtime/tools.ts +1193 -0
  89. package/src/runtime/usage.ts +225 -0
  90. package/src/runtime/window-trial.ts +140 -0
  91. package/src/runtime/working-notes.ts +30 -0
  92. package/src/store/atomic.ts +31 -0
  93. package/src/store/auth-store.ts +50 -0
  94. package/src/store/blobs.ts +142 -0
  95. package/src/store/command.ts +75 -0
  96. package/src/store/config.ts +69 -0
  97. package/src/store/context-files.ts +356 -0
  98. package/src/store/file-index.ts +81 -0
  99. package/src/store/git-branch.ts +35 -0
  100. package/src/store/history-search.ts +19 -0
  101. package/src/store/log.ts +642 -0
  102. package/src/store/models.ts +41 -0
  103. package/src/store/observations.ts +181 -0
  104. package/src/store/query-source.ts +44 -0
  105. package/src/store/recovery.ts +39 -0
  106. package/src/store/replay.ts +63 -0
  107. package/src/store/resources.ts +361 -0
  108. package/src/store/restore.ts +302 -0
  109. package/src/store/search.ts +644 -0
  110. package/src/store/serial.ts +13 -0
  111. package/src/store/sessions.ts +463 -0
  112. package/src/store/trust.ts +69 -0
  113. package/src/store/workspace.ts +256 -0
  114. package/src/store/worktree.ts +233 -0
  115. package/src/tui/ansi.ts +67 -0
  116. package/src/tui/autocomplete.ts +29 -0
  117. package/src/tui/complete.ts +87 -0
  118. package/src/tui/diff.ts +160 -0
  119. package/src/tui/editor.ts +893 -0
  120. package/src/tui/exhaustive.ts +14 -0
  121. package/src/tui/fuzzy.ts +75 -0
  122. package/src/tui/host.ts +223 -0
  123. package/src/tui/input.ts +69 -0
  124. package/src/tui/keys.ts +378 -0
  125. package/src/tui/markdown.ts +17 -0
  126. package/src/tui/render.ts +153 -0
  127. package/src/tui/select.ts +240 -0
  128. package/src/tui/settings.ts +40 -0
  129. package/src/tui/statusline.ts +162 -0
  130. package/src/tui/style.ts +205 -0
  131. package/src/tui/terminal.ts +30 -0
  132. package/src/tui/text.ts +52 -0
  133. package/src/tui/toolview.ts +386 -0
  134. package/src/tui/transcript.ts +71 -0
  135. package/src/tui/tree.ts +405 -0
  136. package/src/tui/width.ts +567 -0
  137. package/src/vendor/pi-tui/LICENSE.upstream +21 -0
  138. package/src/vendor/pi-tui/VENDOR.md +23 -0
  139. package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
  140. package/src/vendor/pi-tui/autocomplete.ts +835 -0
  141. package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
  142. package/src/vendor/pi-tui/components/box.ts +167 -0
  143. package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
  144. package/src/vendor/pi-tui/components/editor.ts +2470 -0
  145. package/src/vendor/pi-tui/components/h-stack.ts +44 -0
  146. package/src/vendor/pi-tui/components/image.ts +127 -0
  147. package/src/vendor/pi-tui/components/input.ts +494 -0
  148. package/src/vendor/pi-tui/components/loader.ts +101 -0
  149. package/src/vendor/pi-tui/components/markdown.ts +1015 -0
  150. package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
  151. package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
  152. package/src/vendor/pi-tui/components/select-list.ts +273 -0
  153. package/src/vendor/pi-tui/components/settings-list.ts +328 -0
  154. package/src/vendor/pi-tui/components/spacer.ts +28 -0
  155. package/src/vendor/pi-tui/components/stack.ts +154 -0
  156. package/src/vendor/pi-tui/components/text.ts +107 -0
  157. package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
  158. package/src/vendor/pi-tui/components/v-stack.ts +33 -0
  159. package/src/vendor/pi-tui/editor-component.ts +74 -0
  160. package/src/vendor/pi-tui/fuzzy.ts +138 -0
  161. package/src/vendor/pi-tui/index.ts +156 -0
  162. package/src/vendor/pi-tui/keybindings.ts +320 -0
  163. package/src/vendor/pi-tui/keys.ts +1401 -0
  164. package/src/vendor/pi-tui/kill-ring.ts +46 -0
  165. package/src/vendor/pi-tui/latex.ts +1506 -0
  166. package/src/vendor/pi-tui/layout-node.ts +51 -0
  167. package/src/vendor/pi-tui/layout.ts +449 -0
  168. package/src/vendor/pi-tui/native-modifiers.ts +13 -0
  169. package/src/vendor/pi-tui/native-module-path.ts +31 -0
  170. package/src/vendor/pi-tui/native-platform.ts +63 -0
  171. package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
  172. package/src/vendor/pi-tui/terminal-colors.ts +73 -0
  173. package/src/vendor/pi-tui/terminal-image.ts +696 -0
  174. package/src/vendor/pi-tui/terminal.ts +547 -0
  175. package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
  176. package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
  177. package/src/vendor/pi-tui/tui.ts +1456 -0
  178. package/src/vendor/pi-tui/undo-stack.ts +28 -0
  179. package/src/vendor/pi-tui/utils.ts +1345 -0
  180. package/src/vendor/pi-tui/word-navigation.ts +117 -0
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Whether a provider error means "the prompt does not fit the window".
3
+ *
4
+ * That failure needs a different remedy from every other one: retrying the
5
+ * same request fails the same way (so it must not be retried even when a
6
+ * relay reports it as a 5xx), and the only thing that makes it succeed is a
7
+ * smaller projection. Providers word it differently and none shares a code,
8
+ * so recognition is by message text.
9
+ *
10
+ * Patterns ported from pi-mono `packages/ai/src/utils/overflow.ts` (MIT,
11
+ * commit 6f75515); the provider each one came from is kept beside it. A
12
+ * provider not listed here surfaces as an ordinary error, which is the safe
13
+ * direction: a missed overflow ends the turn visibly, a false one would cut
14
+ * a healthy context.
15
+ */
16
+ const OVERFLOW_PATTERNS: readonly RegExp[] = [
17
+ /prompt (?:is )?too long/i, // Anthropic, z.ai
18
+ /request_too_large/i, // Anthropic byte-size overflow (413)
19
+ /input is too long for requested model/i, // Amazon Bedrock
20
+ /exceeds the context window/i, // OpenAI (Completions & Responses)
21
+ /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, // OpenAI-compatible proxies
22
+ /input token count.*exceeds the maximum/i, // Google Gemini
23
+ /maximum prompt length is \d+/i, // xAI
24
+ /reduce the length of the messages/i, // Groq
25
+ /maximum context length is \d+ tokens/i, // OpenRouter
26
+ /exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i, // OpenRouter / Poolside
27
+ /input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI
28
+ /exceeds the limit of \d+/i, // GitHub Copilot
29
+ /exceeds the available context size/i, // llama.cpp
30
+ /greater than the context length/i, // LM Studio
31
+ /context window exceeds limit/i, // MiniMax
32
+ /exceeded model token limit/i, // Kimi
33
+ /too large for model with \d+ maximum context length/i, // Mistral
34
+ /prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i, // DS4
35
+ /model_context_window_exceeded/i, // z.ai finish reason surfaced as error text
36
+ /prompt too long; exceeded (?:max )?context length/i, // Ollama
37
+ /range of input length should be/i, // DashScope / Qwen
38
+ /context[_ ]length[_ ]exceeded/i, // generic
39
+ /too many tokens/i, // generic
40
+ /token limit exceeded/i, // generic
41
+ ];
42
+
43
+ /** Throttling that happens to mention tokens; excluded even when a pattern above matches. */
44
+ const NOT_OVERFLOW: readonly RegExp[] = [
45
+ /^(Throttling error|Service unavailable):/i,
46
+ /throttl/i,
47
+ /rate limit/i,
48
+ /too many requests/i,
49
+ /please wait/i,
50
+ ];
51
+
52
+ /** Cerebras answers an oversized request with a bodiless 400 / 413. */
53
+ const BODYLESS = /^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i;
54
+
55
+ export function isContextOverflow(message: string, status?: number): boolean {
56
+ if (status === 429) return false;
57
+ if (NOT_OVERFLOW.some((pattern) => pattern.test(message))) return false;
58
+ return OVERFLOW_PATTERNS.some((pattern) => pattern.test(message)) || BODYLESS.test(message.trim());
59
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Pure permission rules: which tools the model may see, and whether a
3
+ * call is allow / ask / deny.
4
+ *
5
+ * Approval is the last look at what is about to run, not a boundary.
6
+ * Path tools are already confined to the workspace root; bash is not,
7
+ * which is why bash defaults to ask and everything else to allow.
8
+ *
9
+ * This module must stay free of I/O, clocks, and randomness. The CLI
10
+ * compiles flags and session memory into a rule list; this file only
11
+ * matches them. There is no command-danger heuristic: a screen that
12
+ * misses `find . -delete` is worse than none, because it implies
13
+ * someone is checking.
14
+ */
15
+
16
+ export type PermissionAction = "allow" | "ask" | "deny";
17
+
18
+ export interface PermissionRequest {
19
+ readonly tool: string;
20
+ /** Exact command string for bash; omitted for tools that have none. */
21
+ readonly command?: string;
22
+ }
23
+
24
+ export interface PermissionRule {
25
+ readonly action: PermissionAction;
26
+ /** Absent matches every tool. */
27
+ readonly tool?: string;
28
+ /** Absent matches every command. When set, matches only this exact string. */
29
+ readonly command?: string;
30
+ }
31
+
32
+ /**
33
+ * Who contributed the tool. `builtin` is the only producer today.
34
+ * `extension` is in the union so `--no-builtin-tools` can keep a
35
+ * non-builtin tool without a later schema change across layers.
36
+ */
37
+ export type ToolSource = "builtin" | "extension";
38
+
39
+ export interface ToolSelection {
40
+ /** `--tools`: a strict allowlist. */
41
+ readonly only?: readonly string[];
42
+ /** `--exclude-tools`: removed after the allowlist (or the full set) is built. */
43
+ readonly exclude?: readonly string[];
44
+ /** `--no-tools`: start from nothing. Ignored when `only` is set. */
45
+ readonly none?: boolean;
46
+ /**
47
+ * `--no-builtin-tools`. Drops `source === "builtin"` (and untagged tools,
48
+ * which are builtin). Ignored when `only` is set: an explicit `--tools`
49
+ * names a specific tool and is a stronger statement than "not that category".
50
+ */
51
+ readonly noBuiltin?: boolean;
52
+ }
53
+
54
+ /**
55
+ * The flag a no-tty refusal names so a pipeline can choose explicitly.
56
+ *
57
+ * Session memory is not this flag: session memory dies with the process.
58
+ * A persisted "always allow" would fire months later when nobody is watching.
59
+ */
60
+ export const ALLOW_TOOLS_FLAG = "--allow-tools";
61
+
62
+ /**
63
+ * Product default. bash asks because it is not confined to the workspace
64
+ * root; every other built-in is allow because the path tools are.
65
+ */
66
+ export function defaultRules(): readonly PermissionRule[] {
67
+ return [
68
+ { action: "ask", tool: "bash" },
69
+ // Same reach as bash. write_stdin needs no rule of its own: it can only
70
+ // address a session exec_command already started, under this approval.
71
+ { action: "ask", tool: "exec_command" },
72
+ { action: "allow" },
73
+ ];
74
+ }
75
+
76
+ /**
77
+ * `--allow-tools` compiled to rules, in front of the defaults so they win.
78
+ */
79
+ export function rulesWithAllowTools(allowTools: readonly string[]): PermissionRule[] {
80
+ return [
81
+ ...allowTools.map((tool) => ({ action: "allow" as const, tool })),
82
+ ...defaultRules(),
83
+ ];
84
+ }
85
+
86
+ export function ruleMatches(rule: PermissionRule, request: PermissionRequest): boolean {
87
+ if (rule.tool !== undefined && rule.tool !== request.tool) return false;
88
+ if (rule.command !== undefined && rule.command !== request.command) return false;
89
+ return true;
90
+ }
91
+
92
+ /**
93
+ * First matching rule wins. An empty list allows: the tool tests pass no
94
+ * rules because they are testing the tool, not the gate. The CLI always
95
+ * supplies defaultRules().
96
+ */
97
+ export function decide(
98
+ rules: readonly PermissionRule[],
99
+ request: PermissionRequest,
100
+ ): PermissionAction {
101
+ for (const rule of rules) {
102
+ if (ruleMatches(rule, request)) return rule.action;
103
+ }
104
+ return "allow";
105
+ }
106
+
107
+ /**
108
+ * Comma-separated flag values. Empty tokens are dropped so `--tools a,,b`
109
+ * does not invent a blank name.
110
+ */
111
+ export function parseToolList(raw: string): string[] {
112
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
113
+ }
114
+
115
+ /**
116
+ * Names the model will see. An excluded tool has to vanish here -- leaving
117
+ * it in the schema and failing at call time teaches the model to retry
118
+ * the same call and burn tokens.
119
+ *
120
+ * `--tools` is a strict allowlist. `--no-tools` starts from nothing, then
121
+ * `--tools` can add names back (pi's combination). `--exclude-tools` is
122
+ * applied last.
123
+ */
124
+ export function selectToolNames(
125
+ available: readonly string[],
126
+ selection: ToolSelection,
127
+ ): string[] {
128
+ let names: string[];
129
+ if (selection.only !== undefined) {
130
+ const allow = new Set(selection.only);
131
+ names = available.filter((n) => allow.has(n));
132
+ } else if (selection.none === true) {
133
+ names = [];
134
+ } else {
135
+ names = [...available];
136
+ }
137
+ if (selection.exclude !== undefined) {
138
+ const ex = new Set(selection.exclude);
139
+ names = names.filter((n) => !ex.has(n));
140
+ }
141
+ return names;
142
+ }
143
+
144
+ export function selectTools<T extends { name: string; source?: ToolSource }>(
145
+ tools: readonly T[],
146
+ selection: ToolSelection,
147
+ ): T[] {
148
+ const names = new Set(selectToolNames(tools.map((t) => t.name), selection));
149
+ let selected = tools.filter((t) => names.has(t.name));
150
+ // `--tools` already named specific tools, so the category filter does
151
+ // not get a second veto. Untagged tools are builtin: that is today's
152
+ // only producer, and treating absence as "keep" would make the flag a
153
+ // no-op until every schema is recut.
154
+ if (selection.noBuiltin === true && selection.only === undefined) {
155
+ selected = selected.filter((t) => (t.source ?? "builtin") !== "builtin");
156
+ }
157
+ return selected;
158
+ }
159
+
160
+ export function permissionRequest(name: string, input: unknown): PermissionRequest {
161
+ if ((name !== "bash" && name !== "exec_command") || typeof input !== "object" || input === null) {
162
+ return { tool: name };
163
+ }
164
+ const command = (input as { readonly [k: string]: unknown })[name === "bash" ? "command" : "cmd"];
165
+ if (typeof command !== "string") return { tool: name };
166
+ return { tool: name, command };
167
+ }
168
+
169
+ /**
170
+ * Why this ask became a deny: nobody could be asked. Names the flag that
171
+ * would have allowed the call without asking, so a CI author can choose.
172
+ */
173
+ export function askWithoutTtyMessage(request: PermissionRequest): string {
174
+ return (
175
+ `refused: ${request.tool} requires approval and there is no tty to ask. ` +
176
+ `Pass ${ALLOW_TOOLS_FLAG} ${request.tool} to allow it without asking.`
177
+ );
178
+ }
179
+
180
+ export function deniedMessage(request: PermissionRequest): string {
181
+ return `refused: ${request.tool} was denied`;
182
+ }
@@ -0,0 +1,55 @@
1
+ /** N.7: repetitions stay attached to their task; they are not independent questions. */
2
+ import { type ContextPolicy } from "./context-policy.ts";
3
+ import { shuffled } from "./experiment.ts";
4
+
5
+ // N.7 is a frozen three-arm protocol; new production policies cannot change it.
6
+ const EVALUATED_POLICIES: readonly ContextPolicy[] = ["segmented", "continuous", "adaptive"];
7
+
8
+ export interface PolicyJob { id: string; policy: ContextPolicy; repeat: number }
9
+ export function policyJobKey(job: PolicyJob): string { return JSON.stringify([job.id, job.policy, job.repeat]); }
10
+ export function policySchedule(ids: readonly string[], repetitions: number, seed: number): PolicyJob[] {
11
+ if (ids.length === 0 || new Set(ids).size !== ids.length || !Number.isSafeInteger(repetitions) || repetitions < 1) throw new Error("invalid policy trial design");
12
+ const policies = shuffled(EVALUATED_POLICIES, seed);
13
+ const jobs: PolicyJob[] = [];
14
+ let block = 0;
15
+ for (let repeat = 0; repeat < repetitions; repeat++) for (const id of shuffled([...ids].sort(), seed + repeat + 1)) {
16
+ for (let position = 0; position < policies.length; position++) jobs.push({ id, repeat, policy: policies[(block + position) % policies.length]! });
17
+ block++;
18
+ }
19
+ return jobs;
20
+ }
21
+ export interface PolicyObservation extends PolicyJob {
22
+ status: string; correct: boolean; peakRateUsd: number; reservedUnknownUsd: number;
23
+ requests: number; cuts: number; incompleteHandoffs: number; wallMs: number;
24
+ usage: { prompt_tokens: number; cache_read_input_tokens: number; output_tokens: number };
25
+ }
26
+ export function summarizePolicies(plan: readonly PolicyJob[], results: readonly PolicyObservation[]) {
27
+ const planned = new Set(plan.map(policyJobKey)), observed = new Set<string>();
28
+ if (planned.size !== plan.length) throw new Error("duplicate planned policy job");
29
+ for (const result of results) {
30
+ const key = policyJobKey(result);
31
+ if (!planned.has(key) || observed.has(key) || (result.correct && result.status !== "answered")) throw new Error("invalid policy result");
32
+ observed.add(key);
33
+ }
34
+ return [...new Set(plan.map((job) => job.policy))].sort().map((policy) => {
35
+ const schedule = plan.filter((job) => job.policy === policy), group = results.filter((row) => row.policy === policy);
36
+ const sum = (read: (row: PolicyObservation) => number) => group.reduce((n, row) => n + read(row), 0);
37
+ const correct = group.filter((row) => row.correct).length;
38
+ const peakRateUsd = sum((row) => row.peakRateUsd), reservedUnknownUsd = sum((row) => row.reservedUnknownUsd);
39
+ const promptTokens = sum((row) => row.usage.prompt_tokens), cachedTokens = sum((row) => row.usage.cache_read_input_tokens);
40
+ return { policy, planned: schedule.length, attempted: group.length, missing: schedule.length - group.length,
41
+ independentTasks: new Set(schedule.map((job) => job.id)).size, correct,
42
+ accuracy: group.length ? correct / group.length : null, complete: group.length === schedule.length,
43
+ plannedSolvedFraction: schedule.length ? correct / schedule.length : null,
44
+ peakRateUsd, reservedUnknownUsd, allAttemptCostPerSuccessUsd: correct ? (peakRateUsd + reservedUnknownUsd) / correct : null,
45
+ promptTokens, cachedTokens, outputTokens: sum((row) => row.usage.output_tokens),
46
+ cacheHitRate: promptTokens ? cachedTokens / promptTokens : null,
47
+ requests: sum((row) => row.requests), cuts: sum((row) => row.cuts), incompleteHandoffs: sum((row) => row.incompleteHandoffs),
48
+ wallMs: sum((row) => row.wallMs),
49
+ tasks: [...new Set(schedule.map((job) => job.id))].map((id) => ({ id,
50
+ planned: schedule.filter((job) => job.id === id).length,
51
+ attempted: group.filter((row) => row.id === id).length,
52
+ correct: group.filter((row) => row.id === id && row.correct).length })),
53
+ };
54
+ });
55
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * D9 / research M.4: an anti-stall signal, not a judgment of task completion.
3
+ * Novelty belongs to observed content and executed checks, never note wording,
4
+ * event IDs, or timestamps. The full ledger is rebuilt from the active log path;
5
+ * its carried handoff view may be paged like the other structural state (D20).
6
+ */
7
+ import type { LineRange, LogEvent } from "./events.ts";
8
+
9
+ export type ProgressEvidence = (
10
+ | { kind: "read"; sha256: string; ranges?: readonly LineRange[]; observation_sha256?: string }
11
+ | { kind: "observation"; sha256: string }
12
+ | { kind: "check"; command: string; gate?: string; workspace_sha256?: string }
13
+ ) & { source: { node: number; event_id: string } };
14
+
15
+ /** Test coverage without expanding arbitrarily long ranges into line numbers. */
16
+ function hasUnseenLines(requested: readonly LineRange[], observed: readonly LineRange[]): boolean {
17
+ const sorted = [...observed].sort((a, b) => a[0] - b[0]);
18
+ return requested.some(([start, end]) => {
19
+ let next = start;
20
+ for (const [low, high] of sorted) {
21
+ if (high < next) continue;
22
+ if (low > next) return true;
23
+ if (high >= end) return false;
24
+ next = high + 1;
25
+ }
26
+ return next <= end;
27
+ });
28
+ }
29
+
30
+ export function deriveProgress(
31
+ previous: readonly ProgressEvidence[], events: readonly LogEvent[], node: number,
32
+ eligibleChecks: ReadonlySet<string>,
33
+ ): { evidence: ProgressEvidence[]; progressed: boolean } {
34
+ const evidence = [...previous];
35
+ const reads = new Map<string, Extract<ProgressEvidence, { kind: "read" }>[]>();
36
+ const observations = new Set<string>();
37
+ const checks = new Set<string>();
38
+ // A named gate has one status per workspace. Changing its arguments does not
39
+ // make another gate, and cannot keep the loop alive by spelling a pass anew.
40
+ const checkKey = (command: string, gate?: string, workspace?: string): string =>
41
+ JSON.stringify([gate === undefined ? "command" : "gate", gate ?? command, workspace ?? null]);
42
+ for (const item of previous) {
43
+ if (item.kind === "read") {
44
+ const seen = reads.get(item.sha256) ?? [];
45
+ seen.push(item);
46
+ reads.set(item.sha256, seen);
47
+ if (item.observation_sha256 !== undefined) observations.add(item.observation_sha256);
48
+ } else if (item.kind === "observation") observations.add(item.sha256);
49
+ else checks.add(checkKey(item.command, item.gate, item.workspace_sha256));
50
+ }
51
+ let progressed = false;
52
+ const calls = new Map<string, string>();
53
+ for (const event of events) {
54
+ if (event.t === "assistant") {
55
+ for (const block of event.blocks) if (block.type === "tool_use") calls.set(block.call_id, block.name);
56
+ }
57
+ if (event.t !== "tool_result") continue;
58
+ const name = calls.get(event.call_id);
59
+ calls.delete(event.call_id);
60
+ if (!event.ok) continue;
61
+ const source = { node, event_id: event.id };
62
+ const effect = event.effect;
63
+ const observation = event.observation_sha256 ?? event.content_ref?.sha256 ?? event.content_sha256;
64
+ if (effect?.kind === "read" && effect.sha256 !== "") {
65
+ const seen = reads.get(effect.sha256) ?? [];
66
+ const novel = !seen.some((r) => r.ranges === undefined) &&
67
+ (effect.ranges === undefined || hasUnseenLines(effect.ranges, seen.flatMap((r) => r.ranges ?? [])));
68
+ if (novel) {
69
+ const item: Extract<ProgressEvidence, { kind: "read" }> = {
70
+ kind: "read", sha256: effect.sha256, source,
71
+ ...(effect.ranges === undefined ? {} : { ranges: effect.ranges }),
72
+ ...(observation === undefined ? {} : { observation_sha256: observation }),
73
+ };
74
+ evidence.push(item);
75
+ reads.set(effect.sha256, [...seen, item]);
76
+ // Whole-file hashes can change outside the returned range. Such a read
77
+ // extends coverage for that version but unchanged bytes are not novelty.
78
+ if (observation === undefined || !observations.has(observation)) progressed = true;
79
+ if (observation !== undefined) observations.add(observation);
80
+ }
81
+ } else if (effect?.kind === "command" && effect.exit === 0) {
82
+ const key = checkKey(effect.cmd, effect.gate, effect.workspace_sha256);
83
+ if (!checks.has(key)) {
84
+ checks.add(key);
85
+ evidence.push({ kind: "check", command: effect.cmd, source,
86
+ ...(effect.gate === undefined ? {} : { gate: effect.gate }),
87
+ ...(effect.workspace_sha256 === undefined ? {} : { workspace_sha256: effect.workspace_sha256 }) });
88
+ // Remember even unclaimed successes. A later model note cannot turn a
89
+ // rerun of old evidence into a fresh completion by giving it a new name.
90
+ if (eligibleChecks.has(event.id)) progressed = true;
91
+ }
92
+ } else if ((name === "grep" || name === "find" || name === "ls" || name === "query") &&
93
+ observation !== undefined && (event.content_ref?.bytes ?? event.content.length) > 0 &&
94
+ !observations.has(observation)) {
95
+ observations.add(observation);
96
+ evidence.push({ kind: "observation", sha256: observation, source });
97
+ progressed = true;
98
+ }
99
+ }
100
+ return { evidence, progressed };
101
+ }