@hachej/boring-agent 0.1.111 → 0.2.1

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 (156) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +262 -51
  3. package/dist/agents.d.ts +75 -0
  4. package/dist/agents.js +91 -0
  5. package/dist/approval.d.ts +71 -0
  6. package/dist/approval.js +126 -0
  7. package/dist/artifacts.d.ts +53 -0
  8. package/dist/artifacts.js +129 -0
  9. package/dist/ask-user.d.ts +40 -0
  10. package/dist/ask-user.js +134 -0
  11. package/dist/attachment.d.ts +5 -0
  12. package/dist/attachment.js +4 -0
  13. package/dist/browser-task.d.ts +32 -0
  14. package/dist/browser-task.js +119 -0
  15. package/dist/chat-transport.d.ts +80 -0
  16. package/dist/chat-transport.js +306 -0
  17. package/dist/contracts.d.ts +40 -0
  18. package/dist/contracts.js +1 -0
  19. package/dist/conversations.d.ts +136 -0
  20. package/dist/conversations.js +366 -0
  21. package/dist/definitions.d.ts +37 -0
  22. package/dist/definitions.js +99 -0
  23. package/dist/delivery-input.d.ts +35 -0
  24. package/dist/delivery-input.js +56 -0
  25. package/dist/delivery.d.ts +33 -0
  26. package/dist/delivery.js +154 -0
  27. package/dist/durable-wait.d.ts +23 -0
  28. package/dist/durable-wait.js +79 -0
  29. package/dist/file-convert.d.ts +46 -0
  30. package/dist/file-convert.js +159 -0
  31. package/dist/file-guard.d.ts +50 -0
  32. package/dist/file-guard.js +177 -0
  33. package/dist/file-types.d.ts +17 -0
  34. package/dist/file-types.js +95 -0
  35. package/dist/gateway-provider.d.ts +24 -0
  36. package/dist/gateway-provider.js +42 -0
  37. package/dist/git.d.ts +34 -0
  38. package/dist/git.js +79 -0
  39. package/dist/harness-pool.d.ts +38 -0
  40. package/dist/harness-pool.js +134 -0
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.js +1 -0
  43. package/dist/json-schema-tool.d.ts +25 -0
  44. package/dist/json-schema-tool.js +19 -0
  45. package/dist/memory/optchat-core.d.ts +118 -0
  46. package/dist/memory/optchat-core.js +317 -0
  47. package/dist/memory/optchat.d.ts +156 -0
  48. package/dist/memory/optchat.js +432 -0
  49. package/dist/mentions.d.ts +56 -0
  50. package/dist/mentions.js +140 -0
  51. package/dist/metering.d.ts +183 -0
  52. package/dist/metering.js +443 -0
  53. package/dist/model-gateway.d.ts +86 -0
  54. package/dist/model-gateway.js +241 -0
  55. package/dist/native-version.d.ts +9 -0
  56. package/dist/native-version.js +7 -0
  57. package/dist/native.d.ts +4 -0
  58. package/dist/native.js +1 -0
  59. package/dist/presentation.d.ts +17 -0
  60. package/dist/presentation.js +39 -0
  61. package/dist/projection-format.d.ts +68 -0
  62. package/dist/projection-format.js +118 -0
  63. package/dist/projection-window.d.ts +9 -0
  64. package/dist/projection-window.js +93 -0
  65. package/dist/projection.d.ts +22 -0
  66. package/dist/projection.js +153 -0
  67. package/dist/question-response.d.ts +20 -0
  68. package/dist/question-response.js +75 -0
  69. package/dist/questions.d.ts +74 -0
  70. package/dist/questions.js +199 -0
  71. package/dist/self-evolving.d.ts +55 -0
  72. package/dist/self-evolving.js +239 -0
  73. package/dist/skills.d.ts +15 -0
  74. package/dist/skills.js +51 -0
  75. package/dist/tool-evidence.d.ts +14 -0
  76. package/dist/tool-evidence.js +54 -0
  77. package/dist/validation.d.ts +38 -0
  78. package/dist/validation.js +68 -0
  79. package/dist/workspaces.d.ts +107 -0
  80. package/dist/workspaces.js +212 -0
  81. package/package.json +145 -150
  82. package/dist/DebugDrawer-JNA43SVR.js +0 -8
  83. package/dist/admin-DORVQfLn.d.ts +0 -340
  84. package/dist/chunk-2BGLZZ7T.js +0 -10
  85. package/dist/chunk-42D6KAQV.js +0 -2882
  86. package/dist/chunk-4XOW57KJ.js +0 -343
  87. package/dist/chunk-72KT6VWV.js +0 -1338
  88. package/dist/chunk-7XAOVFSN.js +0 -28
  89. package/dist/chunk-KJIEMSQP.js +0 -198
  90. package/dist/chunk-KN72XWP7.js +0 -394
  91. package/dist/chunk-N6THTH7P.js +0 -110
  92. package/dist/chunk-V3H5A3BR.js +0 -22501
  93. package/dist/chunk-YGRQ4Q5P.js +0 -20
  94. package/dist/chunk-YTUZFKB3.js +0 -78
  95. package/dist/core/index.d.ts +0 -28
  96. package/dist/core/index.js +0 -617
  97. package/dist/createHarness-DOLOHA62.js +0 -18
  98. package/dist/eval/index.d.ts +0 -247
  99. package/dist/eval/index.js +0 -541
  100. package/dist/file-search-BUgMSKZE.d.ts +0 -289
  101. package/dist/front/artifacts.d.ts +0 -34
  102. package/dist/front/artifacts.js +0 -21
  103. package/dist/front/index.d.ts +0 -1082
  104. package/dist/front/index.js +0 -12912
  105. package/dist/front/styles.css +0 -5380
  106. package/dist/harness-qHlMJhZo.d.ts +0 -276
  107. package/dist/middleware-CaAEXpi5.d.ts +0 -115
  108. package/dist/piChatEvent-DpE0lIb9.d.ts +0 -450
  109. package/dist/piChatSessionService-CBct-hmz.d.ts +0 -86
  110. package/dist/sandbox-BMhut3kh.d.ts +0 -317
  111. package/dist/server/agent-host/testing/compositionRouteProof.d.ts +0 -8
  112. package/dist/server/agent-host/testing/compositionRouteProof.js +0 -78
  113. package/dist/server/agent-host/testing/gatewayConformance.d.ts +0 -43
  114. package/dist/server/agent-host/testing/gatewayConformance.js +0 -605
  115. package/dist/server/index.d.ts +0 -2278
  116. package/dist/server/index.js +0 -274
  117. package/dist/server/pi-session-readability.d.ts +0 -12
  118. package/dist/server/pi-session-readability.js +0 -23
  119. package/dist/server/worker/index.d.ts +0 -68
  120. package/dist/server/worker/index.js +0 -377
  121. package/dist/share-entry-CxS6UJ_L.d.ts +0 -330
  122. package/dist/shared/index.d.ts +0 -2111
  123. package/dist/shared/index.js +0 -262
  124. package/dist/types-DcWoQMn8.d.ts +0 -1377
  125. package/docs/ACCESSIBILITY.md +0 -52
  126. package/docs/AGENT_GATEWAY_V0.md +0 -212
  127. package/docs/AGENT_PACKAGES.md +0 -90
  128. package/docs/API.md +0 -123
  129. package/docs/AUTHORING.md +0 -68
  130. package/docs/CSP.md +0 -41
  131. package/docs/ERROR_CODES.md +0 -229
  132. package/docs/KNOWN_LIMITATIONS.md +0 -100
  133. package/docs/MIGRATION.md +0 -50
  134. package/docs/PERFORMANCE.md +0 -68
  135. package/docs/PLUGINS.md +0 -138
  136. package/docs/README.md +0 -98
  137. package/docs/RISKS-MULTI-TAB.md +0 -46
  138. package/docs/SESSION_IDENTITY.md +0 -91
  139. package/docs/STYLING.md +0 -73
  140. package/docs/UI-SHADCN.md +0 -56
  141. package/docs/VERCEL_COSTS.md +0 -103
  142. package/docs/plans/archive/AGENT_EVAL_FRAMEWORK.md +0 -466
  143. package/docs/plans/archive/agent-package-spec.md +0 -1777
  144. package/docs/plans/archive/harness-followup-capabilities.md +0 -440
  145. package/docs/plans/archive/harness-tool-ui-capabilities.md +0 -144
  146. package/docs/plans/archive/pi-followup-history-projection.md +0 -229
  147. package/docs/plans/archive/pi-tools-migration.md +0 -1061
  148. package/docs/plans/archive/reviews/pi-followup-history-codex-review.md +0 -11
  149. package/docs/plans/archive/reviews/pi-followup-history-gpt-review.md +0 -64
  150. package/docs/plans/archive/reviews/pi-followup-history-opus-review.md +0 -43
  151. package/docs/plans/archive/reviews/pi-followup-history-xai-review.md +0 -43
  152. package/docs/plans/archive/vercel-base-snapshot-template-plan.md +0 -527
  153. package/docs/plans/archive/vercel-persistent-sandbox-adapter.md +0 -553
  154. package/docs/runtime-provisioning.md +0 -157
  155. package/docs/runtime.md +0 -296
  156. package/docs/tools.md +0 -102
@@ -0,0 +1,432 @@
1
+ // OptChat memory as one native pi-durable extension, opt-in per conversation.
2
+ // The Pi transcript is the append-only log and is never rewritten. While the extension is selected, `beforeRequest` shows
3
+ // the model one view of everything before the current turn: a tiling of the log by summary-tree nodes, one `id+n|text`
4
+ // line each. The current turn (the latest user message and the steps after it) stays verbatim, and every positional
5
+ // system message is kept. Summaries are made by native pi-durable tasks (one background task per node, shipped in the
6
+ // extension's `tasks`, so Pi schedules, runs and recovers them) and stored in a conversation document family.
7
+ // Idea, algorithm and prompts: Victor Taelin's OptChat, https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449
8
+ import { CompactionTask, GenerationTask, defineDoc, defineDocFamily, defineExtension, defineTask, defineTool, hook, section } from '@earendil-works/pi-durable';
9
+ import { withAbortSignal, withCancel } from '@earendil-works/chord/context';
10
+ import { Type } from '@earendil-works/pi-ai';
11
+ import { DEFAULTS, OptChatLog, OptChatTree, advanceFold, bareLines, byteLength, compactPrompt, cutBytes, dateText, emptyFold, leavesOf, nodeKey, openParts, partStart, renderView, scaleLine, span, viewDoc, zoomText, } from './optchat-core.js';
12
+ export { DEFAULTS as OPTCHAT_DEFAULTS, PLACEHOLDER as OPTCHAT_PLACEHOLDER, compactPrompt, viewDoc } from './optchat-core.js';
13
+ export { OptChatLog, OptChatTree, advanceFold, emptyFold, leavesOf, renderView, zoomText, dateText } from './optchat-core.js';
14
+ /** Summaries of one conversation, in shards of 64 nodes per level (`<level>.<shard>`). A fork starts with the current ones; fingerprints reject any that no longer match. */
15
+ export const OptChatNodes = defineDocFamily({
16
+ kind: 'boring.memory.optchat.nodes', version: 1, family: true, scope: 'conversation', history: 'latest', fork: 'current', initial: () => ({ nodes: {} }),
17
+ });
18
+ /** Compactor bookkeeping of one conversation: the summarizer's calls and spend, and the last failure of each node (the retry spacing reads it; no timer does). */
19
+ export const OptChatState = defineDoc({
20
+ kind: 'boring.memory.optchat.state', version: 1, scope: 'conversation', history: 'latest', fork: 'initial',
21
+ initial: () => ({ calls: 0, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, failures: {} }),
22
+ });
23
+ const SHARD = 64;
24
+ const shardKey = (level, index) => `${level}.${Math.floor(index / SHARD)}`;
25
+ class Session {
26
+ id;
27
+ log = new OptChatLog();
28
+ stored = new Map();
29
+ tree;
30
+ fold = emptyFold();
31
+ /** Summary tasks may be created for leaves below this; the current turn's steps are held back while a request runs. */
32
+ upTo = 0;
33
+ lastRequest;
34
+ constructor(id, nodeBytes) {
35
+ this.id = id;
36
+ this.tree = new OptChatTree(this.log, this.stored, nodeBytes);
37
+ }
38
+ /** Take the log of a request or a context read; a changed history drops the fold (summaries stay, checked by fingerprint). */
39
+ sync(leaves) {
40
+ const before = this.log.leaves.length;
41
+ if (this.log.set(leaves) < before) {
42
+ this.tree.reset();
43
+ this.fold = emptyFold();
44
+ }
45
+ }
46
+ }
47
+ const message = (text) => ({ role: 'user', content: text.map(part => ({ type: 'text', text: part })), timestamp: Date.now() });
48
+ const LIVE = ['pending', 'running', 'waiting'];
49
+ const addUsage = (sum, usage) => {
50
+ if (!sum)
51
+ return structuredClone(usage);
52
+ sum.input += usage.input;
53
+ sum.output += usage.output;
54
+ sum.cacheRead += usage.cacheRead;
55
+ sum.cacheWrite += usage.cacheWrite;
56
+ sum.totalTokens += usage.totalTokens;
57
+ if (usage.cacheWrite1h !== undefined)
58
+ sum.cacheWrite1h = (sum.cacheWrite1h ?? 0) + usage.cacheWrite1h;
59
+ if (usage.reasoning !== undefined)
60
+ sum.reasoning = (sum.reasoning ?? 0) + usage.reasoning;
61
+ sum.cost.input += usage.cost.input;
62
+ sum.cost.output += usage.cost.output;
63
+ sum.cost.cacheRead += usage.cost.cacheRead;
64
+ sum.cost.cacheWrite += usage.cost.cacheWrite;
65
+ sum.cost.total += usage.cost.total;
66
+ return sum;
67
+ };
68
+ export function createOptChatMemory(options) {
69
+ const nodeBytes = options.nodeBytes ?? DEFAULTS.nodeBytes, viewBytes = options.viewBytes ?? DEFAULTS.viewBytes, jobs = options.jobs ?? DEFAULTS.jobs;
70
+ const tries = options.tries ?? DEFAULTS.tries, cap = options.cap ?? DEFAULTS.cap, retryMs = options.retryMs ?? DEFAULTS.retryMs, settleMs = options.settleTimeoutMs ?? DEFAULTS.settleTimeoutMs;
71
+ const agentName = options.agentName ?? 'OptChat';
72
+ const extensionName = options.name ?? 'boring.memory.optchat';
73
+ const taskKind = `${extensionName}.node`;
74
+ const system = compactPrompt(agentName);
75
+ const scale = scaleLine(nodeBytes);
76
+ const sessions = new Map();
77
+ const aborter = new AbortController();
78
+ const hostContext = withAbortSignal(aborter.signal, options.context);
79
+ const session = (id) => {
80
+ let found = sessions.get(String(id));
81
+ if (!found)
82
+ sessions.set(String(id), found = new Session(id, nodeBytes));
83
+ return found;
84
+ };
85
+ /** Read the stored summaries. Tasks write them (in this process or another, before or after a restart), so every read goes to the documents. */
86
+ async function loadStored(s, reader, context) {
87
+ const total = s.log.leaves.length;
88
+ for (let level = 0; span(level) <= total; level++) {
89
+ const nodes = Math.floor(total / span(level));
90
+ for (let shard = 0; shard * SHARD < nodes; shard++) {
91
+ const doc = await reader.snapshot(OptChatNodes, s.id, `${level}.${shard}`, context);
92
+ for (const [index, node] of Object.entries(doc?.nodes ?? {}))
93
+ s.stored.set(nodeKey(level, Number(index)), node);
94
+ }
95
+ }
96
+ }
97
+ async function conversationOf(id) {
98
+ const found = await options.harness().conversation(id, options.context);
99
+ if (!found)
100
+ throw new Error(`No conversation ${String(id)}`);
101
+ return found;
102
+ }
103
+ /** Bring a session up to date with the committed context and its stored summaries. */
104
+ async function refresh(id) {
105
+ const s = session(id);
106
+ const view = await (await conversationOf(id)).context(options.context);
107
+ s.sync(leavesOf(view.messages, cap).leaves);
108
+ await loadStored(s, options.harness(), options.context);
109
+ return s;
110
+ }
111
+ // ---- The summarizer (runs inside the native task) ----------------------------------------------------------------------
112
+ /** One node by the reference protocol (§4.2, §4.3): context first, then the step; over-long lines are resubmitted cut at the limit; the shortest wins. */
113
+ async function askModel(job, runtimeModels, signal) {
114
+ const summarizer = options.summarizer;
115
+ if ('summarize' in summarizer) {
116
+ try {
117
+ return { text: cutBytes((await summarizer.summarize(job, signal)).trim(), nodeBytes), calls: 1 };
118
+ }
119
+ catch (error) {
120
+ if (signal.aborted)
121
+ throw error;
122
+ return { error, calls: 1 };
123
+ }
124
+ }
125
+ const models = summarizer.models ?? runtimeModels;
126
+ const ref = typeof summarizer.model === 'function' ? await summarizer.model(job.conversationId) : summarizer.model;
127
+ const model = models.getModel(ref.provider, ref.modelId);
128
+ if (!model)
129
+ throw new Error(`Summarizer model ${ref.provider}/${ref.modelId} is not available`);
130
+ const lines = job.source.map(line => line.replace(/\r?\n/g, ' '));
131
+ const step = `For scale, this line is exactly ${scale.bytes} bytes:\n${scale.text}\n\n` + (job.level === 0
132
+ ? `Compress this message into one line, in at most ${nodeBytes} bytes:\n${job.source[0]}`
133
+ : `Merge these two lines into one, in at most ${nodeBytes} bytes:\n${lines.join('\n')}`);
134
+ const conversation = [message([`<chat>\n${job.context}\n</chat>`, step])];
135
+ const attempts = [];
136
+ let usage, calls = 0, spender;
137
+ try {
138
+ for (;;) {
139
+ const reply = await models.complete(model, { systemPrompt: system, messages: conversation }, { signal });
140
+ calls++;
141
+ spender = `${reply.provider}/${reply.model}`;
142
+ usage = addUsage(usage, reply.usage);
143
+ if (reply.stopReason === 'error' || reply.stopReason === 'aborted')
144
+ throw new Error(reply.errorMessage ?? `Summarizer stopped: ${reply.stopReason}`);
145
+ const line = reply.content.filter(part => part.type === 'text').map(part => part.text).join('\n').trim();
146
+ if (!line)
147
+ throw new Error('The summarizer returned nothing');
148
+ attempts.push(line);
149
+ if (byteLength(line) <= nodeBytes || attempts.length >= tries)
150
+ break;
151
+ conversation.push(reply, message([`That line is ${byteLength(line)} bytes; the limit is ${nodeBytes}. It must end where it is cut here:\n${cutBytes(line, nodeBytes)}| ← LIMIT`]));
152
+ }
153
+ }
154
+ catch (error) {
155
+ if (signal.aborted)
156
+ throw error;
157
+ return { error, ...(usage ? { usage } : {}), calls, ...(spender ? { spender } : {}) };
158
+ }
159
+ return { text: attempts.reduce((best, line) => byteLength(line) < byteLength(best) ? line : best), ...(usage ? { usage } : {}), calls, ...(spender ? { spender } : {}) };
160
+ }
161
+ /** What one node task adds to the conversation's documents in its single commit: calls, spend and the node or its failure. */
162
+ async function bookkeeping(tx, id, summary) {
163
+ const state = await tx.doc(OptChatState, id);
164
+ state.calls += summary.calls;
165
+ if (summary.usage && summary.spender) {
166
+ const u = summary.usage;
167
+ state.usage.input += u.input;
168
+ state.usage.output += u.output;
169
+ state.usage.cacheRead += u.cacheRead;
170
+ state.usage.cacheWrite += u.cacheWrite;
171
+ state.usage.cost += u.cost.total;
172
+ }
173
+ return state;
174
+ }
175
+ const NodeTask = defineTask({
176
+ name: taskKind, version: 1, initial: () => ({ phase: 'summarize' }),
177
+ phases: {
178
+ summarize: async (running, runtime, context) => {
179
+ const { level, index, of, upTo } = running.input, id = runtime.conversationId;
180
+ // Nothing to write; a model call that already ran still books its spend.
181
+ const stale = (summary) => runtime.commit(async (tx) => {
182
+ if (summary)
183
+ await bookkeeping(tx, id, summary);
184
+ return { status: 'terminal', outcome: { status: 'completed', result: { built: false } } };
185
+ }, context);
186
+ // A private view of this conversation: a request may be folding and rendering the shared session right now.
187
+ const s = new Session(id, nodeBytes);
188
+ const load = async () => {
189
+ s.sync(leavesOf((await runtime.context(id, context)).messages, cap).leaves);
190
+ await loadStored(s, runtime, context);
191
+ };
192
+ await load();
193
+ const ready = () => s.log.fp(level, index) === of && (level === 0 || (s.tree.text(level - 1, 2 * index) !== undefined && s.tree.text(level - 1, 2 * index + 1) !== undefined));
194
+ if (!ready() || s.tree.text(level, index) !== undefined)
195
+ return stale(); // the history changed, a child is missing, or the node needs no summary
196
+ const to = Math.min(upTo, s.log.leaves.length);
197
+ if (s.fold.T > to)
198
+ s.fold = emptyFold();
199
+ advanceFold(s.fold, s.tree, to, viewBytes);
200
+ const source = level === 0 ? [s.log.leaves[index].line] : [s.tree.text(level - 1, 2 * index), s.tree.text(level - 1, 2 * index + 1)];
201
+ const job = { conversationId: id, level, index, range: [index * span(level), (index + 1) * span(level) - 1], source, context: bareLines(s.fold, s.tree, level === 0 ? index : (index + 1) * span(level)), nodeBytes };
202
+ const summary = await askModel(job, runtime.models, runtime.signal);
203
+ await load();
204
+ if (!ready())
205
+ return stale(summary); // the history changed while the model ran: its line is dropped, its spend is kept
206
+ let firstFailure = false;
207
+ await runtime.commit(async (tx) => {
208
+ const state = await bookkeeping(tx, id, summary);
209
+ if (summary.text === undefined) {
210
+ firstFailure = state.failures[`${level}:${index}`]?.of !== of;
211
+ state.failures[`${level}:${index}`] = { at: Date.now(), of, error: String(summary.error?.message ?? summary.error) };
212
+ return { status: 'terminal', outcome: { status: 'failed', error: { message: state.failures[`${level}:${index}`].error } } };
213
+ }
214
+ const doc = await tx.doc(OptChatNodes, id, shardKey(level, index), 0);
215
+ doc.nodes[String(index)] = { text: summary.text, of };
216
+ delete state.failures[`${level}:${index}`];
217
+ return { status: 'terminal', outcome: { status: 'completed', result: { built: true } } };
218
+ }, context);
219
+ if (firstFailure)
220
+ try {
221
+ options.onError?.(summary.error, { conversationId: id, level, index });
222
+ }
223
+ catch { /* a reporter must not stop a task */ }
224
+ },
225
+ },
226
+ abort: async (_running, runtime, context) => { await runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), context); },
227
+ });
228
+ // ---- Admission and waiting ---------------------------------------------------------------------------------------------
229
+ /** Nodes that could be built now: sources exist and the whole preceding view is summarized (§4.1), oldest first. */
230
+ function candidates(s) {
231
+ const upTo = Math.min(s.upTo, s.log.leaves.length);
232
+ advanceFold(s.fold, s.tree, upTo, viewBytes);
233
+ const open = openParts(s.fold)[0];
234
+ const first = open ? partStart(open) : s.fold.T;
235
+ const found = [];
236
+ for (let level = 0; span(level) <= upTo; level++) {
237
+ for (let index = 0; (index + 1) * span(level) <= upTo; index++) {
238
+ const end = level === 0 ? index : (index + 1) * span(level);
239
+ if (end > first)
240
+ break;
241
+ if (s.tree.text(level, index) !== undefined)
242
+ continue;
243
+ if (level > 0 && (s.tree.text(level - 1, 2 * index) === undefined || s.tree.text(level - 1, 2 * index + 1) === undefined))
244
+ continue;
245
+ found.push({ level, index });
246
+ }
247
+ }
248
+ return found;
249
+ }
250
+ /**
251
+ * Make sure every node that can be built has one live background task, up to `jobs` live tasks of the conversation. Host
252
+ * admission only: Pi schedules and runs the tasks. One live task per node key (found by scanning native tasks in the same
253
+ * commit that creates them); a node whose last failure is inside `retryMs` is left for the next trigger.
254
+ */
255
+ async function admit(s, skip) {
256
+ const wanted = candidates(s).map(({ level, index }) => ({ level, index, of: s.log.fp(level, index) }));
257
+ const failures = (await options.harness().snapshot(OptChatState, s.id, options.context))?.failures ?? {};
258
+ const now = Date.now(), upTo = Math.min(s.upTo, s.log.leaves.length);
259
+ const conversation = await conversationOf(s.id);
260
+ let waiting = 0, created = 0;
261
+ const live = await conversation.commit(async (tx) => {
262
+ const found = new Map();
263
+ for (const status of LIVE) {
264
+ let cursor;
265
+ do {
266
+ const page = await tx.scanTasks({ conversationId: s.id, kind: taskKind, status }, 200, cursor);
267
+ for (const task of page.items)
268
+ found.set(task.input.key, task.id);
269
+ cursor = page.next;
270
+ } while (cursor);
271
+ }
272
+ for (const node of wanted) {
273
+ if (found.size >= jobs)
274
+ break;
275
+ const key = `${nodeKey(node.level, node.index)}:${node.of}`;
276
+ if (found.has(key) || skip.has(key))
277
+ continue;
278
+ const failure = failures[nodeKey(node.level, node.index)];
279
+ if (failure && failure.of === node.of && now - failure.at < retryMs) {
280
+ waiting++;
281
+ continue;
282
+ }
283
+ found.set(key, await tx.createTask(NodeTask, { key, level: node.level, index: node.index, of: node.of, upTo }, { ownership: { kind: 'conversation' }, background: true }));
284
+ created++;
285
+ }
286
+ return found;
287
+ }, options.context);
288
+ return { live, created, waiting };
289
+ }
290
+ /** Wait (natively, `waitForTask`) until one of the tasks settles, or `ms` pass or the context ends. Returns what settled, if anything. */
291
+ async function settleAny(ids, ms, context) {
292
+ const { context: waiter, cancel } = withCancel(withAbortSignal(aborter.signal, context));
293
+ const timer = ms === undefined ? undefined : setTimeout(cancel, Math.max(0, ms));
294
+ try {
295
+ const done = await Promise.race(ids.map(id => options.harness().waitForTask(id, waiter).then(task => ({ key: task.input.key, built: task.state.outcome.status === 'completed' && task.state.outcome.result?.built === true }), () => undefined)));
296
+ return done;
297
+ }
298
+ finally {
299
+ if (timer)
300
+ clearTimeout(timer);
301
+ cancel();
302
+ }
303
+ }
304
+ async function nap(id) {
305
+ const s = await refresh(id);
306
+ s.upTo = s.log.leaves.length;
307
+ const skip = new Set();
308
+ for (;;) {
309
+ const { live } = await admit(s, skip);
310
+ if (live.size === 0)
311
+ return;
312
+ const done = await settleAny([...live.values()], undefined, hostContext);
313
+ if (!done)
314
+ return; // disposed
315
+ if (!done.built)
316
+ skip.add(done.key);
317
+ await loadStored(s, options.harness(), options.context);
318
+ }
319
+ }
320
+ /** Wait until every line of the view is a summary (§6). Never shows cut text: on timeout the rest shows the placeholder. */
321
+ async function settle(s, covered, context) {
322
+ const deadline = Date.now() + settleMs, skip = new Set();
323
+ for (;;) {
324
+ await loadStored(s, options.harness(), options.context);
325
+ advanceFold(s.fold, s.tree, covered, viewBytes);
326
+ const open = openParts(s.fold).length;
327
+ if (open === 0 && s.fold.size <= viewBytes)
328
+ return true;
329
+ const { live } = await admit(s, skip);
330
+ if (open === 0 && live.size === 0)
331
+ return true;
332
+ if (live.size === 0)
333
+ return false; // nothing to wait for: every missing node is inside its retry window
334
+ const left = deadline - Date.now();
335
+ if (left <= 0 || context.abortSignal?.aborted)
336
+ return false;
337
+ const done = await settleAny([...live.values()], left, context);
338
+ if (done && !done.built)
339
+ skip.add(done.key);
340
+ }
341
+ }
342
+ // ---- The extension -----------------------------------------------------------------------------------------------------
343
+ const text = (value) => ({ content: [{ type: 'text', text: value }] });
344
+ const zoomTool = defineTool({
345
+ name: 'zoom',
346
+ description: 'Open the line id+n of the view into the two lines of n/2 under it; n = 1 gives the message whole.',
347
+ parameters: Type.Object({ id: Type.Integer({ minimum: 0 }), n: Type.Integer({ minimum: 1 }) }, { additionalProperties: false }),
348
+ replay: 'safe',
349
+ execute: async ({ id, n }, api) => text(zoomText((await refresh(api.conversationId)).tree, id, n)),
350
+ });
351
+ const dateTool = defineTool({
352
+ name: 'date',
353
+ description: 'The date and time of message id.',
354
+ parameters: Type.Object({ id: Type.Integer({ minimum: 0 }) }, { additionalProperties: false }),
355
+ replay: 'safe',
356
+ execute: async ({ id }, api) => text(dateText((await refresh(api.conversationId)).log, id)),
357
+ });
358
+ const extension = defineExtension({
359
+ name: extensionName,
360
+ tools: [zoomTool, dateTool],
361
+ sections: [section('optchat-view', () => viewDoc(agentName), { tag: false })],
362
+ tasks: [NodeTask],
363
+ hooks: [
364
+ hook(GenerationTask, {
365
+ beforeRequest: async (request, api, context) => {
366
+ const { leaves, before } = leavesOf(request.messages, cap);
367
+ const last = request.messages.findLastIndex(item => item.role === 'user');
368
+ const covered = last < 0 ? 0 : before[last];
369
+ if (covered === 0)
370
+ return undefined; // nothing came before this turn
371
+ const s = session(api.conversationId);
372
+ s.sync(leaves);
373
+ s.upTo = covered;
374
+ if (s.fold.T > covered)
375
+ s.fold = emptyFold();
376
+ const started = Date.now();
377
+ const settled = await settle(s, covered, context);
378
+ const view = renderView(s.fold, s.tree);
379
+ const messages = request.messages.flatMap((item, index) => {
380
+ if (index < last)
381
+ return item.role === 'system' ? [item] : [];
382
+ if (index > last)
383
+ return [item];
384
+ const user = item;
385
+ const body = typeof user.content === 'string' ? [{ type: 'text', text: user.content }] : user.content;
386
+ return [{ ...user, content: [{ type: 'text', text: view }, ...body] }];
387
+ });
388
+ s.lastRequest = { at: new Date().toISOString(), covered, requestMessages: messages.length, logMessages: request.messages.length, viewBytes: byteLength(view), waitedMs: Date.now() - started, settled };
389
+ return { messages };
390
+ },
391
+ // A finished answer: once the run has committed it, create its summary tasks (background, so they never hold the run) so the next request finds its view ready.
392
+ onYield: (_answer, api) => {
393
+ void (async () => { await (await conversationOf(api.conversationId)).waitForIdle(hostContext); await nap(api.conversationId); })().catch(() => { });
394
+ return undefined;
395
+ },
396
+ }),
397
+ // A native compaction summary would cut the transcript this view is built from; the view replaces it while this extension is selected.
398
+ hook(CompactionTask, { beforeCompact: () => ({ decline: true }) }),
399
+ ],
400
+ });
401
+ return {
402
+ extension,
403
+ nap: id => nap(id),
404
+ async stats(id) {
405
+ const s = await refresh(id);
406
+ const fold = { T: s.fold.T, size: s.fold.size, parts: s.fold.parts.map(part => ({ ...part })) };
407
+ advanceFold(fold, s.tree, s.log.leaves.length, viewBytes);
408
+ let summaries = 0;
409
+ for (const [key, node] of s.stored) {
410
+ const [level, index] = key.split(':').map(Number);
411
+ if (node.of === s.log.fp(level, index))
412
+ summaries++;
413
+ }
414
+ const state = await options.harness().snapshot(OptChatState, id, options.context);
415
+ const failures = Object.entries(state?.failures ?? {}).filter(([key, failure]) => { const [level, index] = key.split(':').map(Number); return failure.of === s.log.fp(level, index); }).map(([, failure]) => failure);
416
+ const busy = (await options.harness().inspect(options.context)).tasks.filter(task => task.record.kind === taskKind && task.record.conversationId === id).length;
417
+ const retrying = failures.filter(failure => Date.now() - failure.at < retryMs).length;
418
+ const latest = failures.reduce((best, failure) => !best || failure.at > best.at ? failure : best, undefined);
419
+ return {
420
+ leaves: s.log.leaves.length, summaries,
421
+ view: { bytes: fold.size, budget: viewBytes, open: openParts(fold).length,
422
+ parts: fold.parts.map(part => ({ id: partStart(part), n: span(part.level), level: part.level, text: s.tree.text(part.level, part.index) ?? '', open: part.open })) },
423
+ compactor: { busy, retrying, failed: failures.length, idle: busy === 0 && retrying === 0, ...(latest ? { error: latest.error } : {}), calls: state?.calls ?? 0, usage: { ...(state?.usage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }) } },
424
+ ...(s.lastRequest ? { lastRequest: s.lastRequest } : {}),
425
+ };
426
+ },
427
+ async zoom(id, nth, n) { return zoomText((await refresh(id)).tree, nth, n); },
428
+ async date(id, nth) { return dateText((await refresh(id)).log, nth); },
429
+ forget(id) { sessions.delete(String(id)); },
430
+ async dispose() { aborter.abort(); },
431
+ };
432
+ }
@@ -0,0 +1,56 @@
1
+ import type { InputSubmissionDraft } from '@earendil-works/pi-durable';
2
+ import type { ResourceAccess } from '@hachej/boring-files';
3
+ import type { WorkspaceBinding } from './workspaces.js';
4
+ /** The native input content a submission carries: a string, or text and image parts. */
5
+ export type MentionInput = InputSubmissionDraft['content'];
6
+ /** What a host reader returns for one workspace path. `bytes` is absent when the file is too large to read. */
7
+ export interface MentionFile {
8
+ readonly size: number;
9
+ readonly bytes?: Uint8Array;
10
+ }
11
+ /** Read one workspace-relative path. Return undefined when it is missing, outside the workspace or unreadable. Never throw to refuse. */
12
+ export type MentionReader = (path: string, options?: {
13
+ readonly bytes?: boolean;
14
+ }) => Promise<MentionFile | undefined>;
15
+ export interface MentionLimits {
16
+ /** Largest part of one file given to the model; more is cut and the cut is stated. Default 100 KB. */
17
+ readonly fileBytes?: number;
18
+ /** Most bytes of file content in one message. Later files become a note. Default 300 KB. */
19
+ readonly totalBytes?: number;
20
+ /** Most distinct mentions resolved in one message. Default 20. */
21
+ readonly files?: number;
22
+ }
23
+ export interface MentionResolverOptions {
24
+ readonly read: MentionReader;
25
+ readonly limits?: MentionLimits;
26
+ /** `inline` (default) puts the content of each mentioned file in the message. `reference` adds one short `<file path type size />` part per file and never its content: the agent reads the file with its tools when it needs it. */
27
+ readonly mode?: 'inline' | 'reference';
28
+ }
29
+ export declare const MENTION_LIMITS: Readonly<{
30
+ fileBytes: 100000;
31
+ totalBytes: 300000;
32
+ files: 20;
33
+ }>;
34
+ /** Every part this module adds starts with this prefix, so a viewer can tell it from what the person typed. */
35
+ export declare const MENTION_FILE_PREFIX = "<file path=\"";
36
+ /** The `@path` tokens of a message: at the start or after whitespace; a bare path drops trailing punctuation, a quoted `@"a b.md"` (escapes `\"`, `\\`) is taken as written. The syntax pi-chat produces. The registry item `pi-chat` cannot import this, so it copies the
37
+ * parser (`MENTION_TOKEN` and `pieces` in `registry/pi-chat/config.ts`); `test/contracts/pi-chat-source.test.mjs` keeps the two equal. */
38
+ export declare function mentionedPaths(text: string): string[];
39
+ /** A workspace-relative path with no absolute prefix, no `..`, no backslash and no control characters. */
40
+ export declare function safeMentionPath(path: string): boolean;
41
+ /**
42
+ * Turn `@workspace/path` mentions of a submitted input into content the model can read, on the host and at submit
43
+ * time, so an agent with no file tool still sees what the person attached (in `reference` mode it gets a reference only). The `@path` text stays in the message. A
44
+ * text file becomes a `<file path="...">` text part, an image a native image part, and a binary, oversized, missing,
45
+ * unreadable or over-budget file one short note. It never throws: an input it cannot improve is returned unchanged.
46
+ */
47
+ export declare function createMentionResolver(options: MentionResolverOptions): (content: MentionInput) => Promise<MentionInput>;
48
+ /**
49
+ * A `MentionReader` over the workspace provider: the person's own view of the workspace, through the same access check and
50
+ * published view the file viewers use. Paths are workspace-relative and the provider decides what exists, so there is no
51
+ * containment check of its own. Files above `maxReadBytes` (default 5 MB) are reported by size and not given.
52
+ */
53
+ export declare function workspaceMentionReader(files: WorkspaceBinding['files'], access: ResourceAccess, options?: {
54
+ readonly maxReadBytes?: number;
55
+ }): MentionReader;
56
+ //# sourceMappingURL=mentions.d.ts.map
@@ -0,0 +1,140 @@
1
+ import { SNIFF_BYTES, extensionOf, fileKind, nativeImageType } from './file-types.js';
2
+ export const MENTION_LIMITS = Object.freeze({ fileBytes: 100_000, totalBytes: 300_000, files: 20 });
3
+ /** Every part this module adds starts with this prefix, so a viewer can tell it from what the person typed. */
4
+ export const MENTION_FILE_PREFIX = '<file path="';
5
+ /** The `@path` tokens of a message: at the start or after whitespace; a bare path drops trailing punctuation, a quoted `@"a b.md"` (escapes `\"`, `\\`) is taken as written. The syntax pi-chat produces. The registry item `pi-chat` cannot import this, so it copies the
6
+ * parser (`MENTION_TOKEN` and `pieces` in `registry/pi-chat/config.ts`); `test/contracts/pi-chat-source.test.mjs` keeps the two equal. */
7
+ export function mentionedPaths(text) {
8
+ const found = new Set();
9
+ for (const match of text.matchAll(/(^|\s)@(?:"((?:[^"\\]|\\.)*)"|([^\s]+))/g)) {
10
+ const path = match[2] !== undefined ? match[2].replace(/\\(.)/g, '$1') : match[3].replace(/[.,;:!?)\]}'"]+$/, '');
11
+ if (path)
12
+ found.add(path);
13
+ }
14
+ return [...found];
15
+ }
16
+ /** A workspace-relative path with no absolute prefix, no `..`, no backslash and no control characters. */
17
+ export function safeMentionPath(path) {
18
+ if (!path || path.startsWith('/') || path.includes('\\') || /[\u0000-\u001f]/.test(path) || /^[A-Za-z]:/.test(path))
19
+ return false;
20
+ return !path.split('/').some(segment => segment === '..' || segment === '');
21
+ }
22
+ const attribute = (value) => value.replace(/"/g, '%22').replace(/</g, '%3C');
23
+ const note = (path, reason) => ({ type: 'text', text: `${MENTION_FILE_PREFIX}${attribute(path)}" unavailable="${attribute(reason)}" />` });
24
+ function utf8(bytes) {
25
+ if (bytes.includes(0))
26
+ return undefined;
27
+ try {
28
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ /** Cut at a character boundary: a split multi-byte tail is dropped rather than shown as a replacement character. */
35
+ function utf8Prefix(bytes, limit) {
36
+ for (let end = Math.min(limit, bytes.length), tries = 0; tries < 4 && end > 0; tries++, end--) {
37
+ const text = utf8(bytes.subarray(0, end));
38
+ if (text !== undefined)
39
+ return text;
40
+ }
41
+ return undefined;
42
+ }
43
+ const base64 = (bytes) => { let binary = ''; for (let at = 0; at < bytes.length; at += 0x8000)
44
+ binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000)); return btoa(binary); };
45
+ /**
46
+ * Turn `@workspace/path` mentions of a submitted input into content the model can read, on the host and at submit
47
+ * time, so an agent with no file tool still sees what the person attached (in `reference` mode it gets a reference only). The `@path` text stays in the message. A
48
+ * text file becomes a `<file path="...">` text part, an image a native image part, and a binary, oversized, missing,
49
+ * unreadable or over-budget file one short note. It never throws: an input it cannot improve is returned unchanged.
50
+ */
51
+ export function createMentionResolver(options) {
52
+ const limits = { ...MENTION_LIMITS, ...options.limits };
53
+ return async (content) => {
54
+ try {
55
+ const parts = typeof content === 'string' ? [{ type: 'text', text: content }] : content;
56
+ const paths = mentionedPaths(parts.map(part => part.type === 'text' ? part.text : '').join('\n')).slice(0, limits.files);
57
+ if (!paths.length)
58
+ return content;
59
+ const added = [];
60
+ const reference = options.mode === 'reference';
61
+ let used = 0;
62
+ for (const path of paths) {
63
+ if (!safeMentionPath(path)) {
64
+ added.push(note(path, 'not a path inside the workspace'));
65
+ continue;
66
+ }
67
+ let file;
68
+ try {
69
+ file = await (reference ? options.read(path, { bytes: false }) : options.read(path));
70
+ }
71
+ catch {
72
+ file = undefined;
73
+ }
74
+ if (!file) {
75
+ added.push(note(path, 'not found or not readable'));
76
+ continue;
77
+ }
78
+ const bytes = file.bytes;
79
+ const image = nativeImageType(path);
80
+ if (reference) {
81
+ const type = image ?? (extensionOf(path) || undefined);
82
+ added.push({ type: 'text', text: `${MENTION_FILE_PREFIX}${attribute(path)}"${type ? ` type="${attribute(type)}"` : ''} size="${file.size}" />` });
83
+ continue;
84
+ }
85
+ if (!bytes) {
86
+ added.push(note(path, `too large to attach (${file.size} bytes)`));
87
+ continue;
88
+ }
89
+ if (used >= limits.totalBytes) {
90
+ added.push(note(path, `skipped: the ${limits.totalBytes} byte limit for one message was reached`));
91
+ continue;
92
+ }
93
+ if (image) {
94
+ if (bytes.length > limits.fileBytes || used + bytes.length > limits.totalBytes) {
95
+ added.push(note(path, `image too large to attach (${bytes.length} bytes)`));
96
+ continue;
97
+ }
98
+ used += bytes.length;
99
+ added.push({ type: 'text', text: `${MENTION_FILE_PREFIX}${attribute(path)}" type="${image}" size="${bytes.length}" />` }, { type: 'image', data: base64(bytes), mimeType: image });
100
+ continue;
101
+ }
102
+ // Documents are never decoded as text, even when their bytes happen to be valid UTF-8 (an uncompressed PDF is).
103
+ if (fileKind(path, bytes.subarray(0, SNIFF_BYTES)) !== 'text') {
104
+ added.push(note(path, `binary file, ${bytes.length} bytes; content not included`));
105
+ continue;
106
+ }
107
+ const room = Math.min(limits.fileBytes, limits.totalBytes - used);
108
+ const text = bytes.length <= room ? utf8(bytes) : utf8Prefix(bytes, room);
109
+ if (text === undefined) {
110
+ added.push(note(path, `binary file, ${bytes.length} bytes; content not included`));
111
+ continue;
112
+ }
113
+ const cut = bytes.length > room;
114
+ used += Math.min(bytes.length, room);
115
+ added.push({ type: 'text', text: `${MENTION_FILE_PREFIX}${attribute(path)}"${cut ? ` truncated="showing the first ${new TextEncoder().encode(text).length} of ${bytes.length} bytes"` : ''}>\n${text.replace(/<\/file>/g, '<\\/file>')}\n</file>` });
116
+ }
117
+ return [...parts, ...added];
118
+ }
119
+ catch {
120
+ return content;
121
+ }
122
+ };
123
+ }
124
+ /**
125
+ * A `MentionReader` over the workspace provider: the person's own view of the workspace, through the same access check and
126
+ * published view the file viewers use. Paths are workspace-relative and the provider decides what exists, so there is no
127
+ * containment check of its own. Files above `maxReadBytes` (default 5 MB) are reported by size and not given.
128
+ */
129
+ export function workspaceMentionReader(files, access, options = {}) {
130
+ const maxRead = options.maxReadBytes ?? 5_000_000;
131
+ return async (path, readOptions) => {
132
+ if (!safeMentionPath(path))
133
+ return undefined;
134
+ const read = await files.read({ target: { resource: { providerId: files.providerId, path }, view: { kind: 'published' } }, revision: { kind: 'latest' } }, { ...access });
135
+ if (read.kind !== 'available')
136
+ return undefined;
137
+ const bytes = read.snapshot.bytes;
138
+ return readOptions?.bytes === false || bytes.byteLength > maxRead ? { size: bytes.byteLength } : { size: bytes.byteLength, bytes };
139
+ };
140
+ }