ucode-agent 1.62.0 → 1.62.2

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.
@@ -0,0 +1,174 @@
1
+ /**
2
+ * openable.js — a page that works when you double-click it.
3
+ *
4
+ * ucode hands a plain app over as a file:// link, and the plain starter loads
5
+ * its code with <script type="module">. Browsers refuse module scripts from
6
+ * file:// (CORS, origin 'null'), so the page drew its markup and ran none of
7
+ * its JavaScript: every button dead, nothing in the console the user would
8
+ * ever open. The check never saw it, because it serves the folder over http.
9
+ *
10
+ * So before the check, each local module script is rewritten into one
11
+ * ordinary deferred script: the files it imports are inlined into it, and the
12
+ * whole program is wrapped in a function, which keeps a module's private
13
+ * scope and strict mode. Anything this cannot do faithfully — an import from
14
+ * a URL or a package, a cycle, `export ... from`, import.meta — is left as it
15
+ * was.
16
+ */
17
+
18
+ import { promises as fs } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { parse } from '@babel/parser';
21
+ import { writeTracked } from '../tools/shared.js';
22
+
23
+ const SCRIPT = /<script\b([^>]*)>\s*<\/script>/gi;
24
+ const attr = (attrs, name) => new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(attrs);
25
+ const valueOf = (m) => m && (m[1] ?? m[2] ?? m[3]);
26
+ const LOCAL_SRC = (src) => src && !/^(?:[a-z]+:|\/\/)/i.test(src);
27
+
28
+ class Skip extends Error {}
29
+
30
+ /** Rewrite every local module script in dir/<file>. Returns the scripts changed. */
31
+ export async function makeOpenable(dir, file = 'index.html') {
32
+ const html = path.join(dir, file);
33
+ const page = await fs.readFile(html, 'utf8').catch(() => null);
34
+ if (page === null) return [];
35
+
36
+ const changed = [];
37
+ const tags = [];
38
+ for (const m of page.matchAll(SCRIPT)) {
39
+ const src = valueOf(attr(m[1], 'src'));
40
+ if (valueOf(attr(m[1], 'type'))?.toLowerCase() === 'module' && LOCAL_SRC(src)) tags.push({ tag: m[0], attrs: m[1], src });
41
+ }
42
+ let next = page;
43
+ const done = new Set(); // the same script twice on a page is rewritten once
44
+ for (const { tag, attrs, src } of tags) {
45
+ const entry = path.resolve(dir, src.split(/[?#]/)[0]);
46
+ if (!done.has(entry)) {
47
+ let code;
48
+ try {
49
+ code = await bundle(entry);
50
+ } catch (err) {
51
+ if (err instanceof Skip || err?.code === 'ENOENT' || err instanceof SyntaxError) continue;
52
+ throw err;
53
+ }
54
+ await writeTracked(entry, code);
55
+ done.add(entry);
56
+ }
57
+ let classic = attrs.replace(/\s*\btype\s*=\s*(?:"module"|'module'|module)/i, '');
58
+ if (!/\bdefer\b/i.test(classic)) classic += ' defer';
59
+ next = next.replace(tag, () => `<script${classic}></script>`);
60
+ changed.push(src);
61
+ }
62
+ if (next !== page) await writeTracked(html, next);
63
+ return changed;
64
+ }
65
+
66
+ /** The entry and everything it imports, as one classic script. */
67
+ async function bundle(entry) {
68
+ const order = [];
69
+ const seen = new Map(); // file -> 'visiting' | module
70
+ const visit = async (file) => {
71
+ if (seen.get(file) === 'visiting') throw new Skip('import cycle');
72
+ if (seen.has(file)) return seen.get(file);
73
+ seen.set(file, 'visiting');
74
+ const mod = await transform(file, file !== entry, visit);
75
+ seen.set(file, mod);
76
+ order.push(mod);
77
+ return mod;
78
+ };
79
+ const main = await visit(entry);
80
+ const deps = order.filter((m) => m !== main);
81
+
82
+ const body = [
83
+ ...deps.map((m) => `// ${path.relative(path.dirname(entry), m.file).split(path.sep).join('/')}\nconst ${m.name} = (() => {\n${m.code}\n})();\n`),
84
+ main.code,
85
+ ].join('\n');
86
+ return '// Rewritten by ucode as one ordinary script, so the page runs when opened\n' +
87
+ '// straight from disk (browsers block module scripts on file://).\n' +
88
+ `(${main.tla ? 'async ' : ''}() => {\n'use strict';\n${body}\n})();\n`;
89
+ }
90
+
91
+ let ids = 0;
92
+
93
+ /** One module: imports become reads of earlier modules, exports are stripped (and returned, for a dependency). */
94
+ async function transform(file, isDep, visit) {
95
+ const src = await fs.readFile(file, 'utf8');
96
+ const ast = parse(src, { sourceType: 'module', allowAwaitOutsideFunction: true });
97
+ const edits = [];
98
+ const exported = []; // [exportedName, localName]
99
+ const names = (id) => {
100
+ if (id.type !== 'Identifier') throw new Skip('destructured export');
101
+ return id.name;
102
+ };
103
+
104
+ for (const node of ast.program.body) {
105
+ if (node.type === 'ImportDeclaration') {
106
+ const spec = node.source.value;
107
+ if (!/^\.{1,2}\//.test(spec)) throw new Skip(`import from ${spec}`);
108
+ const dep = await visit(path.resolve(path.dirname(file), spec.split(/[?#]/)[0]));
109
+ const parts = [];
110
+ const named = [];
111
+ for (const s of node.specifiers) {
112
+ if (s.type === 'ImportDefaultSpecifier') parts.push(`const ${s.local.name} = ${dep.name}.default;`);
113
+ else if (s.type === 'ImportNamespaceSpecifier') parts.push(`const ${s.local.name} = ${dep.name};`);
114
+ else {
115
+ const imported = s.imported.name ?? s.imported.value;
116
+ named.push(imported === s.local.name ? imported : `${JSON.stringify(imported)}: ${s.local.name}`);
117
+ }
118
+ }
119
+ if (named.length) parts.unshift(`const { ${named.join(', ')} } = ${dep.name};`);
120
+ edits.push([node.start, node.end, parts.join(' ')]);
121
+ } else if (node.type === 'ExportNamedDeclaration') {
122
+ if (node.source) throw new Skip('export from');
123
+ if (node.declaration) {
124
+ const d = node.declaration;
125
+ if (d.type === 'VariableDeclaration') for (const v of d.declarations) exported.push([names(v.id), names(v.id)]);
126
+ else exported.push([d.id.name, d.id.name]);
127
+ edits.push([node.start, d.start, '']);
128
+ } else {
129
+ for (const s of node.specifiers) exported.push([s.exported.name ?? s.exported.value, s.local.name]);
130
+ edits.push([node.start, node.end, '']);
131
+ }
132
+ } else if (node.type === 'ExportDefaultDeclaration') {
133
+ const d = node.declaration;
134
+ if ((d.type === 'FunctionDeclaration' || d.type === 'ClassDeclaration') && d.id) {
135
+ exported.push(['default', d.id.name]);
136
+ edits.push([node.start, d.start, '']);
137
+ } else {
138
+ exported.push(['default', '__default']);
139
+ edits.push([node.start, d.start, 'const __default = ']);
140
+ }
141
+ } else if (node.type === 'ExportAllDeclaration') {
142
+ throw new Skip('export *');
143
+ }
144
+ }
145
+ const moduleOnly = (n) => n.type === 'Import' || n.type === 'ImportExpression' || (n.type === 'MetaProperty' && n.meta.name === 'import');
146
+ if (some(ast.program, moduleOnly, false)) throw new Skip('import.meta or import()');
147
+
148
+ let code = src;
149
+ for (const [start, end, text] of edits.sort((a, b) => b[0] - a[0])) code = code.slice(0, start) + text + code.slice(end);
150
+ if (isDep) {
151
+ code += `\nreturn { ${exported.map(([name, local]) => `${JSON.stringify(name)}: ${local}`).join(', ')} };`;
152
+ }
153
+ const tla = some(ast.program, (n) => n.type === 'AwaitExpression' || (n.type === 'ForOfStatement' && n.await), true);
154
+ if (isDep && tla) throw new Skip('top-level await in a dependency');
155
+ return { file, code, tla, name: `__ucode_${path.basename(file).replace(/\W/g, '_')}_${++ids}` };
156
+ }
157
+
158
+ /** Whether any node matches; with outsideFunctions, only nodes not inside a function. */
159
+ function some(root, match, outsideFunctions) {
160
+ const stack = [root];
161
+ while (stack.length) {
162
+ const node = stack.pop();
163
+ if (!node || typeof node.type !== 'string') continue;
164
+ if (match(node)) return true;
165
+ if (outsideFunctions && /Function|ClassMethod|ObjectMethod/.test(node.type)) continue;
166
+ for (const key of Object.keys(node)) {
167
+ if (key === 'loc' || key === 'start' || key === 'end') continue;
168
+ const v = node[key];
169
+ if (Array.isArray(v)) stack.push(...v);
170
+ else if (v && typeof v === 'object') stack.push(v);
171
+ }
172
+ }
173
+ return false;
174
+ }
@@ -1,225 +1,225 @@
1
- /**
2
- * window.js — keeping a long conversation inside the model's context.
3
- *
4
- * Two rules, both of which exist because the obvious alternative is worse:
5
- *
6
- * Summarize the oldest turns rather than dropping them. Dropping means the
7
- * agent forgets a decision it made an hour ago and quietly contradicts it.
8
- *
9
- * Never cut between a tool call and its result. A result with no call above
10
- * it is unreadable to the model and to anyone debugging the transcript.
11
- *
12
- * Only what gets sent is affected. history.js keeps the full record on disk
13
- * whatever happens here.
14
- */
15
-
16
- import { estimateConversation } from './provider.js';
17
-
18
- /** Start folding once the conversation passes this share of the window. */
19
- export const FOLD_AT = 0.75;
20
-
21
- /**
22
- * ...or once it passes this many tokens, whichever comes first.
23
- *
24
- * A share of the window is a correctness threshold: it stops the request
25
- * being rejected. It is the wrong measure for speed. These endpoints re-read
26
- * the whole conversation on every step and none of them cache it, so a
27
- * hundred thousand tokens is a hundred thousand tokens re-read ten times
28
- * before the app is finished — and on a million-token model, three quarters
29
- * of the window is a build that has been crawling for an hour by the time
30
- * anything is folded.
31
- *
32
- * Folding costs one model call. Past this size, that call has already paid
33
- * for itself in the steps that follow it.
34
- */
35
- export const FOLD_TOKENS = Number(process.env.UCODE_FOLD_TOKENS) || 80_000;
36
-
37
- /** After folding, the verbatim tail may occupy this share of the window. */
38
- export const KEEP = 0.4;
39
-
40
- export function usage(messages, limit) {
41
- const used = estimateConversation(messages);
42
- return {
43
- used,
44
- limit,
45
- percent: limit > 0 ? Math.min(100, (used / limit) * 100) : 0,
46
- left: Math.max(0, limit - used),
47
- };
48
- }
49
-
50
- export function tooBig(messages, limit) {
51
- return usage(messages, limit).used > foldAbove(limit);
52
- }
53
-
54
- /** The size at which folding starts: whichever of the two rules bites first. */
55
- export function foldAbove(limit) {
56
- return Math.min(limit * FOLD_AT, FOLD_TOKENS);
57
- }
58
-
59
- /**
60
- * Where to cut so the kept tail stands on its own.
61
- *
62
- * Walks backwards accumulating messages until the budget runs out, then nudges
63
- * the boundary forward past any tool result whose call would have been folded
64
- * away.
65
- */
66
- function cutPoint(messages, budget) {
67
- let cut = messages.length;
68
- let left = budget;
69
-
70
- for (let i = messages.length - 1; i >= 0; i--) {
71
- const cost = estimateConversation([messages[i]]);
72
- if (left - cost < 0 && cut < messages.length) break;
73
- left -= cost;
74
- cut = i;
75
- }
76
-
77
- while (cut < messages.length && messages[cut].role === 'tool') cut++;
78
-
79
- // Whatever else happens, the most recent exchange survives intact.
80
- if (cut >= messages.length) cut = Math.max(0, messages.length - 1);
81
- return cut;
82
- }
83
-
84
- /**
85
- * Fold the older half of a conversation into a summary, if it needs it.
86
- *
87
- * @param {Array} messages
88
- * @param {object} o
89
- * @param {number} o.limit token budget
90
- * @param {Function} o.summarize async (older, previousSummary) => string
91
- * @param {boolean} [o.force] fold even below the threshold — the provider
92
- * has already said the request is too big
93
- */
94
- export async function fold(messages, { limit, summarize, force = false }) {
95
- if (!force && !tooBig(messages, limit)) return { messages, folded: false };
96
-
97
- // Half the size that triggered the fold, so there is room to work before
98
- // the next one. Sized off the same absolute rule, or a fold on a
99
- // million-token model would keep a tail that is instantly too big again and
100
- // summarize on every single step.
101
- const cut = cutPoint(messages, Math.min(limit * KEEP, foldAbove(limit) / 2));
102
- const older = messages.slice(0, cut);
103
- const recent = messages.slice(cut);
104
-
105
- // Nothing old enough to fold — the tail on its own is already oversized.
106
- if (older.length === 0) return { messages, folded: false };
107
-
108
- // A second fold must not summarize the first summary as if it were chat:
109
- // it is handed over as the prior summary, to be merged rather than retold.
110
- const previous = older.find((m) => m.folded)?.summary ?? null;
111
- const summary = await summarize(older.filter((m) => !m.folded), previous);
112
-
113
- return {
114
- folded: true,
115
- summary,
116
- droppedCount: older.length,
117
- messages: [
118
- {
119
- role: 'system',
120
- content:
121
- `Summary of the earlier part of this conversation. ${older.length} messages ` +
122
- `were folded away to stay inside the context window.\n\n${summary}\n\n` +
123
- 'Treat all of that as settled context. Everything after this point is verbatim.',
124
- folded: true,
125
- summary,
126
- },
127
- ...recent,
128
- ],
129
- };
130
- }
131
-
132
- /**
133
- * What the summarizer is asked to do: opencode's anchored summary (MIT, see
134
- * THIRD_PARTY_NOTICES.md). Fixed sections mean nothing gets dropped because
135
- * the summarizer found it dull — the next step and the files that matter
136
- * always have a place — and a second fold merges into the first instead of
137
- * summarizing a summary.
138
- */
139
- export const SUMMARY_PROMPT = `You summarize a coding session so another coding agent can continue the work with nothing else to go on.
140
-
141
- Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
142
- <template>
143
- ## Objective
144
- - [one or two brief sentences describing what the user is trying to accomplish]
145
-
146
- ## Important Details
147
- - [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
148
-
149
- ## Work State
150
- ### Completed
151
- - [finished work, verified facts, or changes made; otherwise "(none)"]
152
-
153
- ### Active
154
- - [current work, partial changes, or investigation state; otherwise "(none)"]
155
-
156
- ### Blocked
157
- - [blockers, failing commands, or unknowns; otherwise "(none)"]
158
-
159
- ## Next Move
160
- 1. [immediate concrete action, or "(none)"]
161
- 2. [next action if known, or "(none)"]
162
-
163
- ## Relevant Files
164
- - [file or directory path: why it matters, or "(none)"]
165
- </template>
166
-
167
- Rules:
168
- - Keep every section, even when empty.
169
- - Use terse bullets, not prose paragraphs.
170
- - Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
171
- - Do not mention the summary process or that context was compacted.`;
172
-
173
- const MERGE = `The <prior-summary> summarizes everything that happened before the <conversation>. Construct a new summary that combines both. The <prior-summary> is discarded after this: anything you do not carry into the new summary is lost.
174
-
175
- When combining:
176
- - Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary> even when the <conversation> does not mention them. Drop only what is finished and no longer needed.
177
- - The <conversation> is more recent than the <prior-summary>. Where they conflict, the conversation wins: state the corrected fact and drop the old claim.
178
- - Add new progress, decisions, constraints, and context from the conversation.
179
- - Move completed work from "Active" to "Completed".
180
- - If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.
181
- - Update "Objective" and "Next Move" to reflect the current work state.`;
182
-
183
- /** The summarizer's user message: the conversation, and the summary it extends if there is one. */
184
- export function summaryRequest(conversation, previous = null) {
185
- // File contents are in there too. One carrying "</conversation>" must not
186
- // close the section early and speak to the summarizer as if it were us.
187
- const fence = (s) => String(s).replace(/<\/?(?:conversation|prior-summary)>/gi, '');
188
- conversation = fence(conversation);
189
- if (previous) previous = fence(previous);
190
- const parts = [`Here is the conversation so far:
191
-
192
- <conversation>
193
- ${conversation}
194
- </conversation>`];
195
- if (previous) {
196
- parts.push(`Here is the summary of the conversation before the <conversation> above:
197
-
198
- <prior-summary>
199
- ${previous}
200
- </prior-summary>`, MERGE);
201
- } else {
202
- parts.push('Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.');
203
- }
204
- return parts.join('\n\n');
205
- }
206
-
207
- /**
208
- * Flatten the messages being folded into plain text for the summarizer.
209
- * Tool traffic is included but trimmed — that a tool ran and roughly what it
210
- * returned matters; its exact bytes almost never do.
211
- */
212
- export function forSummary(messages) {
213
- return messages
214
- .map((m) => {
215
- if (m.role === 'tool') return `[${m.name} returned]\n${(m.content || '').slice(0, 300)}`;
216
- if (m.role === 'assistant') {
217
- const calls = (m.toolCalls || [])
218
- .map((c) => `[called ${c.name}(${JSON.stringify(c.args).slice(0, 200)})]`)
219
- .join('\n');
220
- return `assistant: ${m.content || ''}\n${calls}`.trim();
221
- }
222
- return `${m.role}: ${m.content || ''}`;
223
- })
224
- .join('\n\n');
225
- }
1
+ /**
2
+ * window.js — keeping a long conversation inside the model's context.
3
+ *
4
+ * Two rules, both of which exist because the obvious alternative is worse:
5
+ *
6
+ * Summarize the oldest turns rather than dropping them. Dropping means the
7
+ * agent forgets a decision it made an hour ago and quietly contradicts it.
8
+ *
9
+ * Never cut between a tool call and its result. A result with no call above
10
+ * it is unreadable to the model and to anyone debugging the transcript.
11
+ *
12
+ * Only what gets sent is affected. history.js keeps the full record on disk
13
+ * whatever happens here.
14
+ */
15
+
16
+ import { estimateConversation } from './provider.js';
17
+
18
+ /** Start folding once the conversation passes this share of the window. */
19
+ export const FOLD_AT = 0.75;
20
+
21
+ /**
22
+ * ...or once it passes this many tokens, whichever comes first.
23
+ *
24
+ * A share of the window is a correctness threshold: it stops the request
25
+ * being rejected. It is the wrong measure for speed. These endpoints re-read
26
+ * the whole conversation on every step and none of them cache it, so a
27
+ * hundred thousand tokens is a hundred thousand tokens re-read ten times
28
+ * before the app is finished — and on a million-token model, three quarters
29
+ * of the window is a build that has been crawling for an hour by the time
30
+ * anything is folded.
31
+ *
32
+ * Folding costs one model call. Past this size, that call has already paid
33
+ * for itself in the steps that follow it.
34
+ */
35
+ export const FOLD_TOKENS = Number(process.env.UCODE_FOLD_TOKENS) || 80_000;
36
+
37
+ /** After folding, the verbatim tail may occupy this share of the window. */
38
+ export const KEEP = 0.4;
39
+
40
+ export function usage(messages, limit) {
41
+ const used = estimateConversation(messages);
42
+ return {
43
+ used,
44
+ limit,
45
+ percent: limit > 0 ? Math.min(100, (used / limit) * 100) : 0,
46
+ left: Math.max(0, limit - used),
47
+ };
48
+ }
49
+
50
+ export function tooBig(messages, limit) {
51
+ return usage(messages, limit).used > foldAbove(limit);
52
+ }
53
+
54
+ /** The size at which folding starts: whichever of the two rules bites first. */
55
+ export function foldAbove(limit) {
56
+ return Math.min(limit * FOLD_AT, FOLD_TOKENS);
57
+ }
58
+
59
+ /**
60
+ * Where to cut so the kept tail stands on its own.
61
+ *
62
+ * Walks backwards accumulating messages until the budget runs out, then nudges
63
+ * the boundary forward past any tool result whose call would have been folded
64
+ * away.
65
+ */
66
+ function cutPoint(messages, budget) {
67
+ let cut = messages.length;
68
+ let left = budget;
69
+
70
+ for (let i = messages.length - 1; i >= 0; i--) {
71
+ const cost = estimateConversation([messages[i]]);
72
+ if (left - cost < 0 && cut < messages.length) break;
73
+ left -= cost;
74
+ cut = i;
75
+ }
76
+
77
+ while (cut < messages.length && messages[cut].role === 'tool') cut++;
78
+
79
+ // Whatever else happens, the most recent exchange survives intact.
80
+ if (cut >= messages.length) cut = Math.max(0, messages.length - 1);
81
+ return cut;
82
+ }
83
+
84
+ /**
85
+ * Fold the older half of a conversation into a summary, if it needs it.
86
+ *
87
+ * @param {Array} messages
88
+ * @param {object} o
89
+ * @param {number} o.limit token budget
90
+ * @param {Function} o.summarize async (older, previousSummary) => string
91
+ * @param {boolean} [o.force] fold even below the threshold — the provider
92
+ * has already said the request is too big
93
+ */
94
+ export async function fold(messages, { limit, summarize, force = false }) {
95
+ if (!force && !tooBig(messages, limit)) return { messages, folded: false };
96
+
97
+ // Half the size that triggered the fold, so there is room to work before
98
+ // the next one. Sized off the same absolute rule, or a fold on a
99
+ // million-token model would keep a tail that is instantly too big again and
100
+ // summarize on every single step.
101
+ const cut = cutPoint(messages, Math.min(limit * KEEP, foldAbove(limit) / 2));
102
+ const older = messages.slice(0, cut);
103
+ const recent = messages.slice(cut);
104
+
105
+ // Nothing old enough to fold — the tail on its own is already oversized.
106
+ if (older.length === 0) return { messages, folded: false };
107
+
108
+ // A second fold must not summarize the first summary as if it were chat:
109
+ // it is handed over as the prior summary, to be merged rather than retold.
110
+ const previous = older.find((m) => m.folded)?.summary ?? null;
111
+ const summary = await summarize(older.filter((m) => !m.folded), previous);
112
+
113
+ return {
114
+ folded: true,
115
+ summary,
116
+ droppedCount: older.length,
117
+ messages: [
118
+ {
119
+ role: 'system',
120
+ content:
121
+ `Summary of the earlier part of this conversation. ${older.length} messages ` +
122
+ `were folded away to stay inside the context window.\n\n${summary}\n\n` +
123
+ 'Treat all of that as settled context. Everything after this point is verbatim.',
124
+ folded: true,
125
+ summary,
126
+ },
127
+ ...recent,
128
+ ],
129
+ };
130
+ }
131
+
132
+ /**
133
+ * What the summarizer is asked to do: opencode's anchored summary (MIT, see
134
+ * THIRD_PARTY_NOTICES.md). Fixed sections mean nothing gets dropped because
135
+ * the summarizer found it dull — the next step and the files that matter
136
+ * always have a place — and a second fold merges into the first instead of
137
+ * summarizing a summary.
138
+ */
139
+ export const SUMMARY_PROMPT = `You summarize a coding session so another coding agent can continue the work with nothing else to go on.
140
+
141
+ Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
142
+ <template>
143
+ ## Objective
144
+ - [one or two brief sentences describing what the user is trying to accomplish]
145
+
146
+ ## Important Details
147
+ - [constraints/preferences, decisions and why, important facts/assumptions, exact context needed to continue, or "(none)"]
148
+
149
+ ## Work State
150
+ ### Completed
151
+ - [finished work, verified facts, or changes made; otherwise "(none)"]
152
+
153
+ ### Active
154
+ - [current work, partial changes, or investigation state; otherwise "(none)"]
155
+
156
+ ### Blocked
157
+ - [blockers, failing commands, or unknowns; otherwise "(none)"]
158
+
159
+ ## Next Move
160
+ 1. [immediate concrete action, or "(none)"]
161
+ 2. [next action if known, or "(none)"]
162
+
163
+ ## Relevant Files
164
+ - [file or directory path: why it matters, or "(none)"]
165
+ </template>
166
+
167
+ Rules:
168
+ - Keep every section, even when empty.
169
+ - Use terse bullets, not prose paragraphs.
170
+ - Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
171
+ - Do not mention the summary process or that context was compacted.`;
172
+
173
+ const MERGE = `The <prior-summary> summarizes everything that happened before the <conversation>. Construct a new summary that combines both. The <prior-summary> is discarded after this: anything you do not carry into the new summary is lost.
174
+
175
+ When combining:
176
+ - Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary> even when the <conversation> does not mention them. Drop only what is finished and no longer needed.
177
+ - The <conversation> is more recent than the <prior-summary>. Where they conflict, the conversation wins: state the corrected fact and drop the old claim.
178
+ - Add new progress, decisions, constraints, and context from the conversation.
179
+ - Move completed work from "Active" to "Completed".
180
+ - If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.
181
+ - Update "Objective" and "Next Move" to reflect the current work state.`;
182
+
183
+ /** The summarizer's user message: the conversation, and the summary it extends if there is one. */
184
+ export function summaryRequest(conversation, previous = null) {
185
+ // File contents are in there too. One carrying "</conversation>" must not
186
+ // close the section early and speak to the summarizer as if it were us.
187
+ const fence = (s) => String(s).replace(/<\/?(?:conversation|prior-summary)>/gi, '');
188
+ conversation = fence(conversation);
189
+ if (previous) previous = fence(previous);
190
+ const parts = [`Here is the conversation so far:
191
+
192
+ <conversation>
193
+ ${conversation}
194
+ </conversation>`];
195
+ if (previous) {
196
+ parts.push(`Here is the summary of the conversation before the <conversation> above:
197
+
198
+ <prior-summary>
199
+ ${previous}
200
+ </prior-summary>`, MERGE);
201
+ } else {
202
+ parts.push('Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.');
203
+ }
204
+ return parts.join('\n\n');
205
+ }
206
+
207
+ /**
208
+ * Flatten the messages being folded into plain text for the summarizer.
209
+ * Tool traffic is included but trimmed — that a tool ran and roughly what it
210
+ * returned matters; its exact bytes almost never do.
211
+ */
212
+ export function forSummary(messages) {
213
+ return messages
214
+ .map((m) => {
215
+ if (m.role === 'tool') return `[${m.name} returned]\n${(m.content || '').slice(0, 300)}`;
216
+ if (m.role === 'assistant') {
217
+ const calls = (m.toolCalls || [])
218
+ .map((c) => `[called ${c.name}(${JSON.stringify(c.args).slice(0, 200)})]`)
219
+ .join('\n');
220
+ return `assistant: ${m.content || ''}\n${calls}`.trim();
221
+ }
222
+ return `${m.role}: ${m.content || ''}`;
223
+ })
224
+ .join('\n\n');
225
+ }