flint-agent 1.14.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 (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,443 @@
1
+ // Context swap: tool results past a budget move to the session's disk, with
2
+ // an index, and a one-line stub stays in their place. docs/context-swap.md.
3
+ //
4
+ // Everything a tool returns stayed in the conversation and was sent again on
5
+ // every call: a research turn of 23 calls sent 1,759k input tokens
6
+ // (2026-10-02). The half-window compression that existed cut results to
7
+ // one-liners and lost them; here what leaves the context is kept, with an
8
+ // address, and swap_list / swap_read bring it back.
9
+
10
+ import { getSpendLevel, spendSettings, windowShare } from "../spend.js";
11
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import path from "node:path";
13
+
14
+ /** Swap is on unless FLINT_SWAP=0. */
15
+ export function swapEnabled(env = process.env) {
16
+ return env.FLINT_SWAP !== "0";
17
+ }
18
+
19
+ export function swapSettings(env = process.env, level = getSpendLevel(env)) {
20
+ const int = (v, d) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : d);
21
+ const num = (v, d) => (Number.isFinite(parseFloat(v)) ? parseFloat(v) : d);
22
+ const lv = spendSettings(level).swap;
23
+ return {
24
+ // Set by the live 30-page run (2026-10-02): at 8 KB and 40k tokens the
25
+ // context still grew from 22k to 55k tokens a call and no old page was
26
+ // ever swapped out. Swap is for keeping the context thin.
27
+ resultMax: int(env.FLINT_SWAP_RESULT_MAX, lv.resultMax),
28
+ budgetTokens: int(env.FLINT_SWAP_BUDGET, lv.budgetTokens),
29
+ lowWater: num(env.FLINT_SWAP_LOW_WATER, 0.6),
30
+ minBytes: int(env.FLINT_SWAP_MIN_BYTES, 1024),
31
+ headBytes: int(env.FLINT_SWAP_HEAD, lv.headBytes),
32
+ };
33
+ }
34
+
35
+ // ── Describing a result ──────────────────────────────────────
36
+
37
+ /** page, file, command, search or other, from the tool's name. */
38
+ export function kindOf(tool) {
39
+ const n = String(tool || "").toLowerCase();
40
+ if (/run_command|run_background|peek_process|shell|exec/.test(n)) return "command";
41
+ if (/search|glob|grep/.test(n)) return "search";
42
+ if (/fetch|chrome|browser|page|navigate|url|web/.test(n)) return "page";
43
+ if (/file|image/.test(n)) return "file";
44
+ return "other";
45
+ }
46
+
47
+ /** The URL, path, command or query a call was about. */
48
+ export function sourceOf(tool, args = {}) {
49
+ const a = args && typeof args === "object" ? args : {};
50
+ const v = a.url ?? a.path ?? a.file_path ?? a.command ?? a.query ?? a.pattern ?? "";
51
+ return String(v);
52
+ }
53
+
54
+ /**
55
+ * The readable part of a result. Browser tools answer in JSON ({"url",
56
+ * "title", "text": the page}): the live run put "{" as every title, no source,
57
+ * and a head of escaped JSON. The page text, its URL and title are taken out;
58
+ * anything else is the text as it is.
59
+ */
60
+ export function readableOf(raw) {
61
+ const s = String(raw ?? "");
62
+ const t = s.trimStart();
63
+ if (t.startsWith("{") && t.length < 20_000_000) {
64
+ try {
65
+ const o = JSON.parse(t);
66
+ if (o && typeof o === "object" && !Array.isArray(o)) {
67
+ const text = ["text", "content", "markdown", "body", "output", "result"].map((k) => o[k]).find((v) => typeof v === "string" && v.length);
68
+ if (text) {
69
+ const url = typeof o.url === "string" ? o.url : typeof o.href === "string" ? o.href : "";
70
+ return { text, url, title: typeof o.title === "string" ? o.title : "" };
71
+ }
72
+ }
73
+ } catch {}
74
+ }
75
+ return { text: s, url: "", title: "" };
76
+ }
77
+
78
+ const MD_HEADING = /^#{1,6}\s+\S/;
79
+ const HTML_HEADING = /<h([1-3])[^>]*>([\s\S]*?)<\/h\1>/i;
80
+
81
+ /** First heading, else the first non-empty line; at most 100 characters. */
82
+ export function titleOf(text) {
83
+ const lines = String(text || "").split(/\r?\n/);
84
+ for (const l of lines) {
85
+ if (MD_HEADING.test(l)) return l.replace(/^#{1,6}\s+/, "").trim().slice(0, 100);
86
+ const h = l.match(HTML_HEADING);
87
+ if (h) return h[2].replace(/<[^>]+>/g, "").trim().slice(0, 100);
88
+ }
89
+ const first = lines.find((l) => l.trim());
90
+ return (first || "").trim().slice(0, 100);
91
+ }
92
+
93
+ /** Up to `max` headings: markdown lines as they are, HTML h1-h3 as their text. */
94
+ export function outlineOf(text, max = 20) {
95
+ const out = [];
96
+ for (const l of String(text || "").split(/\r?\n/)) {
97
+ if (out.length >= max) break;
98
+ if (MD_HEADING.test(l)) { out.push(l.trim()); continue; }
99
+ const h = l.match(HTML_HEADING);
100
+ if (h) out.push(h[2].replace(/<[^>]+>/g, "").trim());
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function size(bytes) {
106
+ if (bytes < 1024) return `${bytes} B`;
107
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
108
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
109
+ }
110
+
111
+ const oneLine = (s, max) => String(s || "").replace(/\s+/g, " ").trim().slice(0, max);
112
+
113
+ /**
114
+ * The one-line stub that stands for an entry in the conversation. Built from
115
+ * the entry alone, so the same entry always gives the same bytes (the prompt
116
+ * cache depends on it).
117
+ */
118
+ export function stubFor(e) {
119
+ const source = oneLine(e.source, 160) || "-";
120
+ // A conversation line carries a two-sentence summary; a page, a title.
121
+ const title = oneLine(e.title, e.kind === "conversation" ? 240 : 100).replace(/"/g, "'");
122
+ return `[swap #${e.id} · ${e.kind} · ${source} · ${size(e.bytes)} · "${title}" · turn ${e.turn} · swap_read ${e.id}]`;
123
+ }
124
+
125
+ /** What the model sees of a result swapped as it arrives: stub, head, outline. */
126
+ export function arrivalView(e, text, headBytes = 2048) {
127
+ const s = String(text);
128
+ const head = s.slice(0, headBytes);
129
+ const parts = [stubFor(e), head];
130
+ if (s.length > head.length) parts.push(`[... ${size(Buffer.byteLength(s.slice(head.length), "utf8"))} more: swap_read ${e.id} with offset/limit for a part]`);
131
+ const outline = outlineOf(s, 30);
132
+ if (outline.length) parts.push("Outline:\n" + outline.map((h) => `- ${h}`).join("\n"));
133
+ return parts.join("\n");
134
+ }
135
+
136
+ // ── The store ────────────────────────────────────────────────
137
+
138
+ function slugOf(source, title) {
139
+ const base = String(source || title || "item")
140
+ .replace(/^[a-z]+:\/\//i, "")
141
+ .toLowerCase()
142
+ .replace(/[^a-z0-9]+/g, "-")
143
+ .replace(/^-+|-+$/g, "")
144
+ .slice(0, 48)
145
+ .replace(/-+$/g, "");
146
+ return base || "item";
147
+ }
148
+
149
+ const pad3 = (n) => String(n).padStart(3, "0");
150
+
151
+ function durationMs(since) {
152
+ if (typeof since === "number") return since;
153
+ const m = String(since || "").match(/^(\d+)\s*([smhd])$/);
154
+ if (!m) return null;
155
+ return Number(m[1]) * { s: 1e3, m: 6e4, h: 3.6e6, d: 8.64e7 }[m[2]];
156
+ }
157
+
158
+ /**
159
+ * The swap of one session: files under `dir`, one index line per entry.
160
+ * Nothing is written until the first put.
161
+ */
162
+ export function createSwapStore(dir) {
163
+ const indexFile = path.join(dir, "index.jsonl");
164
+ const entries = [];
165
+ if (existsSync(indexFile)) {
166
+ for (const line of readFileSync(indexFile, "utf8").split("\n")) {
167
+ if (!line.trim()) continue;
168
+ try { entries.push(JSON.parse(line)); } catch {}
169
+ }
170
+ }
171
+ const byId = new Map(entries.map((e) => [e.id, e]));
172
+ let nextId = entries.reduce((m, e) => Math.max(m, e.id), 0) + 1;
173
+
174
+ return {
175
+ dir,
176
+ put({ turn = 0, call = 0, tool = "", kind, source = "", title, text = "" }) {
177
+ const k = kind || kindOf(tool);
178
+ const id = nextId++;
179
+ const file = `t${pad3(turn)}/${pad3(id)}-${k}-${slugOf(source, title)}.${k === "page" ? "md" : "txt"}`;
180
+ const e = {
181
+ id, t: new Date().toISOString(), turn, call, tool, kind: k, source: String(source),
182
+ title: title ?? titleOf(text), bytes: Buffer.byteLength(String(text), "utf8"), file,
183
+ };
184
+ mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
185
+ writeFileSync(path.join(dir, file), String(text), "utf8");
186
+ appendFileSync(indexFile, JSON.stringify(e) + "\n", "utf8");
187
+ entries.push(e);
188
+ byId.set(id, e);
189
+ return e;
190
+ },
191
+ get(id) {
192
+ return byId.get(Number(id)) || null;
193
+ },
194
+ /** The entry's text, whole, or `limit` lines from line `offset` (1-based). */
195
+ read(id, { offset, limit } = {}) {
196
+ const e = byId.get(Number(id));
197
+ if (!e) return null;
198
+ let text;
199
+ try { text = readFileSync(path.join(dir, e.file), "utf8"); } catch { return null; }
200
+ if (!offset && !limit) return text;
201
+ const lines = text.split("\n");
202
+ const from = Math.max(1, Number(offset) || 1) - 1;
203
+ const to = limit ? from + Number(limit) : lines.length;
204
+ return lines.slice(from, to).join("\n");
205
+ },
206
+ /** Entries, newest first, filtered by turn, time, source and title words. */
207
+ list({ turn, since, source, text, limit, now = Date.now() } = {}) {
208
+ let out = [...entries].reverse();
209
+ if (turn != null && turn !== "") out = out.filter((e) => e.turn === Number(turn));
210
+ const ms = since != null ? durationMs(since) : null;
211
+ if (ms != null) out = out.filter((e) => Date.parse(e.t) >= now - ms);
212
+ if (source) out = out.filter((e) => String(e.source).toLowerCase().includes(String(source).toLowerCase()));
213
+ if (text) {
214
+ const words = String(text).toLowerCase().split(/\s+/).filter(Boolean);
215
+ out = out.filter((e) => words.every((w) => String(e.title).toLowerCase().includes(w)));
216
+ }
217
+ if (limit) out = out.slice(0, Number(limit));
218
+ return out;
219
+ },
220
+ };
221
+ }
222
+
223
+ // The store of the session the agent loop is running, for swap_list/swap_read.
224
+ let currentStore = null;
225
+ export function setCurrentSwapStore(store) { currentStore = store || null; }
226
+ export function getCurrentSwapStore() { return currentStore; }
227
+
228
+ // ── Eviction ─────────────────────────────────────────────────
229
+
230
+ const tokensOf = (content) => Math.ceil(String(content ?? "").length / 4);
231
+ const STUB_TOKENS = 45;
232
+
233
+ const WRAP_RE = /^<([\w-]+) name="([^"]*)">\n([\s\S]*)\n<\/\1>$/;
234
+ function unwrap(content) {
235
+ const m = String(content ?? "").match(WRAP_RE);
236
+ return m ? { tool: m[2], text: m[3] } : { tool: "", text: String(content ?? "") };
237
+ }
238
+
239
+ /**
240
+ * Indices of tool results to swap out, oldest first, or none. Only when the
241
+ * tool results take more than `budgetTokens`, and then down to `lowWater` of
242
+ * it, so the history is rewritten rarely and in batches (each rewrite costs
243
+ * the prompt cache from that message on). Never swapped: anything but a tool
244
+ * result, the results of the latest call (not read yet), results under
245
+ * `minBytes`, results swapped already.
246
+ */
247
+ export function planEviction(messages, { budgetTokens = 40000, lowWater = 0.6, minBytes = 1024 } = {}) {
248
+ let total = 0;
249
+ for (const m of messages) if (m.role === "tool") total += tokensOf(m.content);
250
+ if (total <= budgetTokens) return [];
251
+ let lastCall = -1;
252
+ for (let i = messages.length - 1; i >= 0; i--) {
253
+ if (messages[i].role === "assistant" && messages[i].tool_calls?.length) { lastCall = i; break; }
254
+ }
255
+ const target = budgetTokens * lowWater;
256
+ const plan = [];
257
+ for (let i = 0; i < messages.length && total > target; i++) {
258
+ const m = messages[i];
259
+ // A result swapped on arrival keeps its view (stub, head, outline) until
260
+ // it is old enough to go; then it shrinks to the stub. Left out, thirty
261
+ // views kept the live run at 46-50k tokens a call (2026-10-02). A bare
262
+ // stub is under minBytes and stays.
263
+ if (m.role !== "tool" || i > lastCall) continue;
264
+ if (String(m.content ?? "").length < minBytes) continue;
265
+ plan.push(i);
266
+ total -= tokensOf(m.content) - STUB_TOKENS;
267
+ }
268
+ return plan;
269
+ }
270
+
271
+ /** Arguments of the call a tool result answers, from the call itself. */
272
+ function callArgs(messages, i) {
273
+ const m = messages[i];
274
+ if (m._toolArgs) return m._toolArgs;
275
+ for (let j = i - 1; j >= 0; j--) {
276
+ const tc = messages[j].tool_calls?.find((c) => c.id === m.tool_call_id);
277
+ if (tc) {
278
+ try { return JSON.parse(tc.function?.arguments || "{}"); } catch { return {}; }
279
+ }
280
+ }
281
+ return {};
282
+ }
283
+
284
+ /** Turn number (operator messages so far) and call number within the turn, at index i. */
285
+ export function turnAndCall(messages, i) {
286
+ let turn = 0;
287
+ let call = 0;
288
+ for (let j = 0; j <= i && j < messages.length; j++) {
289
+ if (messages[j].role === "user") { turn++; call = 0; } else if (messages[j].role === "tool") call++;
290
+ }
291
+ return { turn, call: Math.max(call, 1) };
292
+ }
293
+
294
+ /** Swap out what planEviction names; returns how many. */
295
+ export function applySwap(messages, store, opts = {}) {
296
+ const plan = planEviction(messages, opts);
297
+ for (const i of plan) {
298
+ const m = messages[i];
299
+ const { tool: wrappedTool, text: raw } = unwrap(m.content);
300
+ const tool = m._toolName || wrappedTool;
301
+ // Already on disk: an arrival view, or a swap_read result. It goes back
302
+ // to its entry's stub instead of becoming a copy (the live run re-read
303
+ // 30 pages and grew the index from 30 entries to 93).
304
+ const readBack = tool === "swap_read" && raw.match(/^\[swap #(\d+) /);
305
+ const original = (m._swap && store.get(m._swap)) || (readBack && store.get(Number(readBack[1])));
306
+ if (original) {
307
+ messages[i] = { ...m, content: stubFor(original), _swap: original.id };
308
+ continue;
309
+ }
310
+ const { turn, call } = turnAndCall(messages, i);
311
+ const r = readableOf(raw);
312
+ const e = store.put({ turn, call, tool, kind: kindOf(tool), source: r.url || sourceOf(tool, callArgs(messages, i)), title: r.title || titleOf(r.text), text: r.text });
313
+ messages[i] = { ...m, content: stubFor(e), _swap: e.id };
314
+ }
315
+ return plan.length;
316
+ }
317
+
318
+ // ── When swap is on ──────────────────────────────────────────
319
+
320
+ /**
321
+ * Swap sleeps until the context reaches this many tokens: 75% of the lossy
322
+ * compression's threshold, so swap acts first and that one is the last
323
+ * resort. Measured 2026-10-02: always on, swap saved nothing on a 30-page run
324
+ * (38k against 36k tokens a call) and made the agent read pages back.
325
+ */
326
+ export function swapFromTokens({ env = process.env, compressThreshold = 64000, level = getSpendLevel(env) } = {}) {
327
+ const v = parseInt(env.FLINT_SWAP_FROM, 10);
328
+ if (Number.isFinite(v)) return v;
329
+ return Math.floor(compressThreshold * spendSettings(level).swapFromShare);
330
+ }
331
+
332
+ export function swapActive(contextTokens, swapFrom) {
333
+ return contextTokens >= swapFrom;
334
+ }
335
+
336
+ /** The context's size the way the budget counts it: characters / 4, tool calls included. */
337
+ export function contextTokensOf(messages) {
338
+ let t = 0;
339
+ for (const m of messages || []) {
340
+ const c = m?.content;
341
+ if (typeof c === "string") t += Math.ceil(c.length / 4);
342
+ else if (c != null) t += Math.ceil(JSON.stringify(c).length / 4);
343
+ if (m?.tool_calls) t += Math.ceil(JSON.stringify(m.tool_calls).length / 4);
344
+ }
345
+ return t;
346
+ }
347
+
348
+ // ── Conversation swap ────────────────────────────────────────
349
+
350
+ /** 60% of the window up to 300k (100k when unknown); a third of that per chunk; 4 turns kept. */
351
+ export function convSettings({ env = process.env, window = null, level = getSpendLevel(env) } = {}) {
352
+ const int = (v) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : null);
353
+ const high = int(env.FLINT_SWAP_CONV_HIGH) ?? windowShare(spendSettings(level).conv, window);
354
+ return {
355
+ high,
356
+ chunk: int(env.FLINT_SWAP_CONV_CHUNK) ?? Math.floor(high / 3),
357
+ keepTurns: int(env.FLINT_SWAP_CONV_KEEP) ?? 4,
358
+ };
359
+ }
360
+
361
+ /** Turns: an operator's message and everything up to the next one, as index ranges. */
362
+ export function turnsOf(messages) {
363
+ const starts = [];
364
+ for (let i = 0; i < messages.length; i++) if (messages[i]?.role === "user") starts.push(i);
365
+ return starts.map((s, k) => ({ start: s, end: k + 1 < starts.length ? starts[k + 1] : messages.length }));
366
+ }
367
+
368
+ /**
369
+ * The oldest whole turns to move, about `chunk` tokens of them, or null when
370
+ * the context is under `high`. Never the latest `keepTurns` turns, never the
371
+ * system message or a stub (they come before the first turn).
372
+ */
373
+ export function planConversationSwap(messages, { high, chunk, keepTurns = 4 } = {}) {
374
+ if (contextTokensOf(messages) < high) return null;
375
+ const turns = turnsOf(messages);
376
+ const movable = turns.slice(0, Math.max(0, turns.length - keepTurns));
377
+ if (!movable.length) return null;
378
+ let tokens = 0;
379
+ let k = 0;
380
+ for (; k < movable.length; k++) {
381
+ tokens += contextTokensOf(messages.slice(movable[k].start, movable[k].end));
382
+ if (tokens >= chunk) break;
383
+ }
384
+ const last = movable[Math.min(k, movable.length - 1)];
385
+ return { start: movable[0].start, end: last.end, count: Math.min(k, movable.length - 1) + 1 };
386
+ }
387
+
388
+ /** A readable transcript of messages; `tools: false` leaves tool results out. */
389
+ export function transcriptOf(messages, { tools = true } = {}) {
390
+ const out = [];
391
+ for (const m of messages) {
392
+ const text = typeof m.content === "string" ? m.content : m.content == null ? "" : JSON.stringify(m.content);
393
+ if (m.role === "user") out.push(`USER: ${text}`);
394
+ else if (m.role === "assistant") {
395
+ const calls = (m.tool_calls || []).map((c) => `${c.function?.name} ${c.function?.arguments || ""}`.trim());
396
+ out.push(`ASSISTANT: ${[text, ...calls.map((c) => `[calls ${c}]`)].filter(Boolean).join("\n")}`);
397
+ } else if (m.role === "tool") {
398
+ if (tools) out.push(`TOOL (${m._toolName || unwrap(m.content).tool || "tool"}): ${unwrap(text).text}`);
399
+ } else if (m.role === "system") out.push(`SYSTEM: ${text}`);
400
+ }
401
+ return out.join("\n\n");
402
+ }
403
+
404
+ const STAMP_TIME = /\[Local time: \w{3} \d{4}-\d\d-\d\d (\d\d:\d\d)/;
405
+ const STAMP_LINE = /^\[Local time: [^\]\n]*\]\n?/;
406
+
407
+ /**
408
+ * Move the oldest whole turns to the swap as one `conversation` entry and put
409
+ * one line in their place, after the system message and the earlier lines.
410
+ * `summarize(text)` writes what the line says; when it fails, the first words
411
+ * of the operator's messages do. Returns the entry, or null when nothing moved.
412
+ */
413
+ export async function applyConversationSwap(messages, store, opts, summarize) {
414
+ const plan = planConversationSwap(messages, opts);
415
+ if (!plan) return null;
416
+ const chunk = messages.slice(plan.start, plan.end);
417
+ let before = 0;
418
+ for (const m of messages.slice(0, plan.start)) if (m._conv && m._turnsTo > before) before = m._turnsTo;
419
+ const from = before + 1;
420
+ const to = before + plan.count;
421
+ const times = chunk.filter((m) => m.role === "user").map((m) => String(m.content).match(STAMP_TIME)?.[1]).filter(Boolean);
422
+ const span = times.length ? `, ${times[0]}-${times.at(-1)}` : "";
423
+
424
+ let summary = "";
425
+ try {
426
+ // The head of every message, not the first 40k characters of the
427
+ // transcript: one long message used to fill that, and the summary said
428
+ // the rest of the chunk was "truncated" (live run, 2026-10-02).
429
+ const heads = chunk.map((m) => (typeof m.content === "string" && m.content.length > 1500 ? { ...m, content: m.content.slice(0, 1500) + " …" } : m));
430
+ summary = String((await summarize?.(transcriptOf(heads, { tools: false }).slice(0, 40000))) || "").replace(/\s+/g, " ").trim();
431
+ } catch {}
432
+ if (!summary) {
433
+ summary = chunk.filter((m) => m.role === "user")
434
+ .map((m) => String(m.content).replace(STAMP_LINE, "").trim().split(/\s+/).slice(0, 12).join(" "))
435
+ .join(" | ")
436
+ .slice(0, 240);
437
+ }
438
+
439
+ const turnNow = before + turnsOf(messages).length;
440
+ const e = store.put({ turn: turnNow, call: 0, tool: "conversation", kind: "conversation", source: `turns ${from}-${to}${span}`, title: summary, text: transcriptOf(chunk) });
441
+ messages.splice(plan.start, plan.end - plan.start, { role: "system", content: stubFor(e), _swap: e.id, _conv: true, _turnsTo: to });
442
+ return e;
443
+ }