howtofixcli 0.7.24 → 0.7.26

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 (167) hide show
  1. package/dist/account-credential.js +136 -0
  2. package/dist/acp.js +234 -0
  3. package/dist/activity.js +124 -0
  4. package/dist/agent-defs.js +237 -0
  5. package/dist/agent-limiter.js +68 -0
  6. package/dist/agents.js +554 -0
  7. package/dist/anim.js +361 -0
  8. package/dist/args.js +250 -0
  9. package/dist/at-file.js +28 -0
  10. package/dist/brand-extension.js +87 -0
  11. package/dist/brand.js +76 -0
  12. package/dist/browser.js +58 -0
  13. package/dist/cache-report.js +139 -0
  14. package/dist/checkpoint.js +437 -0
  15. package/dist/claims.js +167 -0
  16. package/dist/cli-auth.js +142 -0
  17. package/dist/cli-checkin.js +121 -0
  18. package/dist/cli-credentials.js +254 -0
  19. package/dist/clipboard.js +149 -0
  20. package/dist/collapse.js +129 -0
  21. package/dist/commands.js +381 -0
  22. package/dist/compaction-guard.js +81 -0
  23. package/dist/connect-prompt.js +22 -0
  24. package/dist/context-extension.js +17 -0
  25. package/dist/dcp/commands.js +172 -0
  26. package/dist/dcp/compress-state.js +139 -0
  27. package/dist/dcp/compress.js +329 -0
  28. package/dist/dcp/dcp-extension.js +295 -0
  29. package/dist/dcp/dedup.js +93 -0
  30. package/dist/dcp/ids.js +189 -0
  31. package/dist/dcp/index.js +25 -0
  32. package/dist/dcp/middle-prune.js +76 -0
  33. package/dist/dcp/prompts.js +68 -0
  34. package/dist/dcp/protected-content.js +221 -0
  35. package/dist/dcp/protected.js +161 -0
  36. package/dist/dcp/prune.js +418 -0
  37. package/dist/dcp/state.js +176 -0
  38. package/dist/errors.js +63 -0
  39. package/dist/extensions.js +48 -0
  40. package/dist/fatal.js +35 -0
  41. package/dist/feed.js +498 -0
  42. package/dist/fs-atomic.js +54 -0
  43. package/dist/gateway-errors.js +107 -0
  44. package/dist/git.js +230 -0
  45. package/dist/goal-extension.js +88 -0
  46. package/dist/goal.js +222 -0
  47. package/dist/hooks-extension.js +176 -0
  48. package/dist/hooks.js +443 -0
  49. package/dist/howtofix-account.js +834 -0
  50. package/dist/hud-line.js +158 -0
  51. package/dist/hwid.js +130 -0
  52. package/dist/interrupt.js +60 -0
  53. package/dist/json-events.js +50 -0
  54. package/dist/level-style.js +112 -0
  55. package/dist/loop-guard.js +95 -0
  56. package/dist/lsp-tools.js +32 -0
  57. package/dist/lsp.js +429 -0
  58. package/dist/main.js +573 -98
  59. package/dist/mcp-config.js +85 -0
  60. package/dist/mcp-lifecycle.js +147 -0
  61. package/dist/model-catalog.js +217 -0
  62. package/dist/model-facts.js +35 -0
  63. package/dist/model-resolve.js +65 -0
  64. package/dist/notes.js +78 -0
  65. package/dist/oauth-login.js +568 -0
  66. package/dist/paths.js +61 -0
  67. package/dist/permissions.js +298 -0
  68. package/dist/plan-mode.js +363 -0
  69. package/dist/preset-command.js +227 -0
  70. package/dist/preset-extension.js +35 -0
  71. package/dist/preset-mcp.js +292 -0
  72. package/dist/presets/mcp/rea/preset.json +28 -0
  73. package/dist/presets/mcp/rea/prompt.md +5 -0
  74. package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
  75. package/dist/presets/mcp/remotion/preset.json +27 -0
  76. package/dist/presets/mcp/remotion/prompt.md +7 -0
  77. package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
  78. package/dist/presets.js +727 -0
  79. package/dist/prompt-files.js +103 -0
  80. package/dist/prompt-text.js +41 -0
  81. package/dist/proxy.js +137 -0
  82. package/dist/quality/ast.js +196 -0
  83. package/dist/quality/cognitive.js +83 -0
  84. package/dist/quality/cyclomatic.js +30 -0
  85. package/dist/quality/halstead.js +83 -0
  86. package/dist/quality/index.js +18 -0
  87. package/dist/quality/length.js +3 -0
  88. package/dist/quality/maintainability.js +15 -0
  89. package/dist/quality/nesting.js +57 -0
  90. package/dist/quality/report.js +803 -0
  91. package/dist/quality/smells.js +67 -0
  92. package/dist/quality-tool.js +63 -0
  93. package/dist/read-only-commands.js +597 -0
  94. package/dist/repo-map.js +292 -0
  95. package/dist/roblox-connect.js +125 -0
  96. package/dist/rpc.js +91 -0
  97. package/dist/rtk.js +193 -0
  98. package/dist/sandbox.js +105 -0
  99. package/dist/schema.js +214 -0
  100. package/dist/serve.js +50 -0
  101. package/dist/session-lifecycle.js +19 -0
  102. package/dist/session-retention.js +111 -0
  103. package/dist/session.js +460 -0
  104. package/dist/sessions.js +100 -0
  105. package/dist/settings.js +148 -0
  106. package/dist/skills.js +35 -0
  107. package/dist/slop-tool.js +96 -0
  108. package/dist/spend-cap.js +43 -0
  109. package/dist/subagent.js +171 -0
  110. package/dist/tar.js +146 -0
  111. package/dist/theme.js +79 -0
  112. package/dist/thinking.js +45 -0
  113. package/dist/todo.js +114 -0
  114. package/dist/tool-loadout.js +102 -0
  115. package/dist/tool-render.js +769 -0
  116. package/dist/tools.js +176 -0
  117. package/dist/ui.js +4931 -0
  118. package/dist/update.js +551 -0
  119. package/dist/usage-segment.js +74 -0
  120. package/dist/web-tools.js +46 -0
  121. package/dist/web.js +265 -0
  122. package/dist/welcome.js +179 -0
  123. package/dist/workflow-engine.js +246 -0
  124. package/dist/workflow-progress.js +446 -0
  125. package/dist/workflow-view.js +195 -0
  126. package/dist/workflow.js +349 -0
  127. package/package.json +62 -15
  128. package/dist/chunk-27TRKD65.js +0 -2
  129. package/dist/chunk-3FYCFLNK.js +0 -1
  130. package/dist/chunk-3LZR7OEU.js +0 -1
  131. package/dist/chunk-56SEAGAQ.js +0 -1
  132. package/dist/chunk-5AWTL34S.js +0 -1
  133. package/dist/chunk-75P2SCOT.js +0 -1
  134. package/dist/chunk-7CHDNTH6.js +0 -1
  135. package/dist/chunk-7PHRNXLN.js +0 -34
  136. package/dist/chunk-AGGIYXN7.js +0 -2
  137. package/dist/chunk-AUDMRGWF.js +0 -189
  138. package/dist/chunk-C5E3SFOQ.js +0 -2
  139. package/dist/chunk-CMTKCUL5.js +0 -1
  140. package/dist/chunk-DAPXGSGD.js +0 -12
  141. package/dist/chunk-DOD7MUGX.js +0 -1
  142. package/dist/chunk-FPXVDP32.js +0 -8
  143. package/dist/chunk-GXB7RZHH.js +0 -1
  144. package/dist/chunk-I7KVLBMF.js +0 -2
  145. package/dist/chunk-ILDDDLRC.js +0 -1
  146. package/dist/chunk-JFYVRWJI.js +0 -2
  147. package/dist/chunk-LAHVHRCS.js +0 -2
  148. package/dist/chunk-LSJC73DG.js +0 -1
  149. package/dist/chunk-MNDKS6UH.js +0 -6
  150. package/dist/chunk-N345KWWK.js +0 -1
  151. package/dist/chunk-N3J2J7EQ.js +0 -5
  152. package/dist/chunk-ONZLNFQ2.js +0 -3
  153. package/dist/chunk-ORJ2QZBG.js +0 -3
  154. package/dist/chunk-PCALBZOO.js +0 -2
  155. package/dist/chunk-QPHSHL7H.js +0 -2
  156. package/dist/chunk-TKROS3BE.js +0 -5
  157. package/dist/chunk-TSEKPGK4.js +0 -1
  158. package/dist/chunk-UP7ENLEL.js +0 -117
  159. package/dist/chunk-UTYFLLSM.js +0 -1
  160. package/dist/chunk-V3R4FYTJ.js +0 -1
  161. package/dist/chunk-V656XVXV.js +0 -8
  162. package/dist/chunk-WKUJ7CFO.js +0 -1
  163. package/dist/chunk-WOT6VMZA.js +0 -1
  164. package/dist/chunk-WUPU6LTD.js +0 -4
  165. package/dist/chunk-WX3V5P7U.js +0 -5
  166. package/dist/chunk-XW6LMQGX.js +0 -1
  167. package/dist/chunk-YW7NORH3.js +0 -2
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Collapse a run of consecutive read/search calls into one summary line.
3
+ *
4
+ * The shape is Claude Code's (`src/utils/collapseReadSearch.ts`): consecutive
5
+ * Read/Grep/Glob calls become `Read 3 files, searched for 2 patterns`, and the
6
+ * run breaks when anything else appears — assistant text, an edit, a shell
7
+ * command that is not a search. A model exploring a repository issues those
8
+ * calls in bursts, and a feed that lists each one buries the two lines that
9
+ * matter under twenty that do not.
10
+ *
11
+ * Three details are taken from the reference rather than invented:
12
+ *
13
+ * 1. **Plural per category, not per group.** `1 file` / `2 files` is decided
14
+ * next to the count it belongs to, because a group can hold one read and
15
+ * three greps and the two need different words.
16
+ * 2. **Active and finished read differently.** `Reading 2 files` while the run
17
+ * is live, `Read 2 files` once it settles. That is the same information the
18
+ * per-call spinner carries, one level up.
19
+ * 3. **The break is on assistant text, not on a message boundary.** Two calls
20
+ * from the same assistant turn that sit either side of a paragraph are two
21
+ * thoughts, and merging them would claim a grouping the model did not make.
22
+ *
23
+ * Pure over a list of calls: no clock, no session, no rendering. What it
24
+ * decides is which calls belong together and what the line says; whether that
25
+ * line is drawn is the feed's business.
26
+ */
27
+ /** The categories the summary counts, one word pair each. */
28
+ const CATEGORIES = {
29
+ read: { active: "Reading", done: "Read", one: "file", many: "files" },
30
+ search: { active: "Searching for", done: "Searched for", one: "pattern", many: "patterns" },
31
+ list: { active: "Listing", done: "Listed", one: "directory", many: "directories" },
32
+ };
33
+ /**
34
+ * Which category a tool belongs to, or `undefined` when it does not collapse.
35
+ *
36
+ * `read` reads a file; `grep` and `find` search; `ls` lists. Everything else
37
+ * breaks the run — `edit` and `write` change the tree, and a `bash` call is not
38
+ * classified here because this repository's bash tool is a general shell, not
39
+ * the search/read primitive Claude Code splits its own out into.
40
+ */
41
+ function categoryOf(toolName) {
42
+ if (toolName === "read")
43
+ return "read";
44
+ if (toolName === "grep" || toolName === "find" || toolName === "glob")
45
+ return "search";
46
+ if (toolName === "ls")
47
+ return "list";
48
+ return undefined;
49
+ }
50
+ /**
51
+ * Find every run worth collapsing, in order.
52
+ *
53
+ * A run of one is NOT a group: `Read(src/ui.ts)` says more than `Read 1 file`,
54
+ * and replacing a single call with a summary loses the path for no gain. That
55
+ * threshold is the whole reason this returns a list of runs rather than a
56
+ * rewritten transcript.
57
+ */
58
+ export function collapseGroups(calls, live = false) {
59
+ const groups = [];
60
+ let run;
61
+ const flush = () => {
62
+ if (!run || run.indices.length < 2) {
63
+ run = undefined;
64
+ return;
65
+ }
66
+ groups.push({
67
+ start: run.indices[0],
68
+ indices: run.indices,
69
+ counts: run.counts,
70
+ summary: summaryOf(run.counts, live),
71
+ });
72
+ run = undefined;
73
+ };
74
+ for (const [index, call] of calls.entries()) {
75
+ const category = categoryOf(call.toolName);
76
+ // Anything that is not a read/search/list BREAKS the run, which is the
77
+ // reference's rule: an edit, a shell command or a paragraph of assistant
78
+ // text sits between two explorations and says they were separate.
79
+ if (category === undefined) {
80
+ flush();
81
+ continue;
82
+ }
83
+ if (!run)
84
+ run = { indices: [], counts: {} };
85
+ run.indices.push(index);
86
+ run.counts[category] = (run.counts[category] ?? 0) + 1;
87
+ }
88
+ flush();
89
+ return groups;
90
+ }
91
+ /**
92
+ * `Reading 2 files, searching for 3 patterns`.
93
+ *
94
+ * Only the first clause is capitalised, which is how the reference builds it:
95
+ * the verb is chosen by whether it is the first part being pushed, so the line
96
+ * reads as one sentence rather than a list of headings.
97
+ */
98
+ /**
99
+ * The tense is a parameter rather than an options object: there is one call
100
+ * site and it forwards the same flag the group was built with, so a named bag
101
+ * would add a layer without adding a distinction. The gate flags the boolean
102
+ * anyway, and it is in the baseline - the rule is right about the general case
103
+ * and this is the case it does not fit.
104
+ */
105
+ function summaryOf(counts, live) {
106
+ const parts = [];
107
+ // Fixed clause order, taken from the reference (search, read, list, repl):
108
+ // the summary reads the same for the same work whatever order the model
109
+ // happened to issue it in, which is the point of a summary line.
110
+ for (const category of ["search", "read", "list"]) {
111
+ const count = counts[category];
112
+ if (count === undefined)
113
+ continue;
114
+ const spec = CATEGORIES[category];
115
+ const verb = live ? spec.active : spec.done;
116
+ const word = count === 1 ? spec.one : spec.many;
117
+ parts.push(`${parts.length === 0 ? verb : verb.toLowerCase()} ${count} ${word}`);
118
+ }
119
+ return parts.join(", ");
120
+ }
121
+ // `collapseSummary` was exported here and used by nothing: `collapseGroups`
122
+ // already puts the finished line in `CollapseGroup.summary`, which is where
123
+ // every caller reads it from. `check-wiring.mjs` caught the dead export, and the
124
+ // fix is to delete it rather than to add a caller that exists to justify it.
125
+ //
126
+ // The count is spelled out rather than shown as `×3` because the categories
127
+ // differ: `Read ×3` is fine for one kind of call and meaningless for a mixed
128
+ // run, and a summary that only works for the easy case is one that has to be
129
+ // special-cased later.
@@ -0,0 +1,381 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { AGENT_DIR } from "./paths.js";
5
+ import { APP_TITLE } from "./brand.js";
6
+ import { reportError } from "./errors.js";
7
+ import { detectSandbox } from "./sandbox.js";
8
+ const MIN_NODE = [22, 19];
9
+ /** Everything the CLI needs from the machine, checked without a model call. */
10
+ export async function collectChecks(gatewayUrl = "https://api.howtofix.id/api/status") {
11
+ const checks = [];
12
+ const [major, minor] = process.versions.node.split(".").map(Number);
13
+ const nodeOk = major > MIN_NODE[0] || (major === MIN_NODE[0] && minor >= MIN_NODE[1]);
14
+ checks.push({
15
+ name: "node",
16
+ ok: nodeOk,
17
+ detail: `${process.versions.node} (needs >= ${MIN_NODE[0]}.${MIN_NODE[1]})`,
18
+ });
19
+ const git = spawnSync("git", ["--version"], { encoding: "utf8" });
20
+ checks.push({
21
+ name: "git",
22
+ ok: git.status === 0,
23
+ detail: git.status === 0 ? git.stdout.trim() : "not found on PATH",
24
+ });
25
+ const sandbox = detectSandbox();
26
+ checks.push({
27
+ name: "sandbox",
28
+ ok: Boolean(sandbox),
29
+ detail: sandbox ?? "no working bubblewrap (Linux) or sandbox-exec (macOS); --sandbox unavailable",
30
+ optional: true,
31
+ });
32
+ let stateOk = false;
33
+ let stateDetail = AGENT_DIR;
34
+ try {
35
+ const probe = join(AGENT_DIR, `.doctor-${process.pid}`);
36
+ writeFileSync(probe, "ok");
37
+ unlinkSync(probe);
38
+ stateOk = true;
39
+ }
40
+ catch (error) {
41
+ stateDetail = `${AGENT_DIR} is not writable: ${error instanceof Error ? error.message : String(error)}`;
42
+ }
43
+ checks.push({ name: "state dir", ok: stateOk, detail: stateDetail });
44
+ const authPath = join(AGENT_DIR, "auth.json");
45
+ let authDetail = "no auth.json — run `howtofix` and sign in";
46
+ let authOk = false;
47
+ try {
48
+ const parsed = JSON.parse(readFileSync(authPath, "utf8"));
49
+ const providers = Object.keys(parsed).length;
50
+ authOk = providers > 0;
51
+ authDetail = authOk ? `${providers} provider credential${providers === 1 ? "" : "s"}` : authDetail;
52
+ }
53
+ catch {
54
+ // Missing or unreadable: the sentence above already says what to do.
55
+ }
56
+ checks.push({ name: "auth", ok: authOk, detail: authDetail, optional: true });
57
+ try {
58
+ const started = Date.now();
59
+ const response = await fetch(gatewayUrl, { signal: AbortSignal.timeout(5000) });
60
+ checks.push({
61
+ name: "gateway",
62
+ ok: response.ok,
63
+ detail: `${gatewayUrl} → ${response.status} in ${Date.now() - started}ms`,
64
+ optional: true,
65
+ });
66
+ }
67
+ catch (error) {
68
+ checks.push({
69
+ name: "gateway",
70
+ ok: false,
71
+ detail: `${gatewayUrl} unreachable: ${error instanceof Error ? error.message : String(error)}`,
72
+ optional: true,
73
+ });
74
+ }
75
+ return checks;
76
+ }
77
+ export async function runDoctor() {
78
+ const checks = await collectChecks();
79
+ const width = Math.max(...checks.map((check) => check.name.length));
80
+ for (const check of checks) {
81
+ const mark = check.ok ? "ok " : check.optional ? "warn" : "FAIL";
82
+ console.log(` ${mark} ${check.name.padEnd(width)} ${check.detail}`);
83
+ }
84
+ const failed = checks.filter((check) => !check.ok && !check.optional);
85
+ if (failed.length > 0) {
86
+ console.log(`\n${failed.length} problem${failed.length === 1 ? "" : "s"} must be fixed before a session will work.`);
87
+ return 1;
88
+ }
89
+ return 0;
90
+ }
91
+ /** Configured providers and their models, straight from models.json. */
92
+ export function runModels(root = AGENT_DIR) {
93
+ const path = join(root, "models.json");
94
+ let raw;
95
+ try {
96
+ raw = readFileSync(path, "utf8");
97
+ }
98
+ catch (error) {
99
+ const code = error.code;
100
+ if (code === "ENOENT") {
101
+ console.log(` no models.json at ${path} — run \`howtofix /connect\` to pull the catalogue`);
102
+ }
103
+ else {
104
+ reportError(APP_TITLE, "models", error);
105
+ }
106
+ return 1;
107
+ }
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(raw);
111
+ }
112
+ catch (error) {
113
+ // A file that exists but does not parse is a different problem from a
114
+ // missing one: /connect will not fix it, and saying "no models.json" sends
115
+ // the user looking for a file that is already there.
116
+ console.error(`${APP_TITLE}: models: ${path} is not valid JSON (${error instanceof Error ? error.message : String(error)})`);
117
+ console.error(" fix the file, or delete it and run `howtofix /connect` to write a new one");
118
+ return 1;
119
+ }
120
+ const providers = parsed.providers ?? {};
121
+ let total = 0;
122
+ for (const [name, provider] of Object.entries(providers)) {
123
+ const ids = (provider.models ?? []).map((model) => model.id).filter(Boolean);
124
+ total += ids.length;
125
+ console.log(` ${name} (${ids.length})`);
126
+ for (const id of ids)
127
+ console.log(` ${id}`);
128
+ }
129
+ if (total === 0) {
130
+ console.log(" no models configured");
131
+ return 1;
132
+ }
133
+ return 0;
134
+ }
135
+ /** Saved sessions, newest first. */
136
+ export function listSessionFiles(limit = 15, root = AGENT_DIR) {
137
+ const sessionsRoot = join(root, "sessions");
138
+ let projects;
139
+ try {
140
+ projects = readdirSync(sessionsRoot);
141
+ }
142
+ catch {
143
+ return [];
144
+ }
145
+ const rows = [];
146
+ for (const project of projects) {
147
+ const dir = join(sessionsRoot, project);
148
+ let files;
149
+ try {
150
+ files = readdirSync(dir);
151
+ }
152
+ catch {
153
+ continue;
154
+ }
155
+ for (const file of files) {
156
+ if (!file.endsWith(".jsonl"))
157
+ continue;
158
+ const full = join(dir, file);
159
+ try {
160
+ const info = statSync(full);
161
+ rows.push({ file: full, project, modified: info.mtimeMs, bytes: info.size });
162
+ }
163
+ catch {
164
+ // A session removed mid-walk is not an error worth reporting.
165
+ }
166
+ }
167
+ }
168
+ return rows.sort((a, b) => b.modified - a.modified).slice(0, limit);
169
+ }
170
+ export function runSessions(argv) {
171
+ const searchIndex = argv.indexOf("--search");
172
+ if (searchIndex >= 0) {
173
+ const query = argv[searchIndex + 1]?.trim();
174
+ if (!query) {
175
+ console.error(`${APP_TITLE}: sessions: --search needs a word or phrase`);
176
+ return 2;
177
+ }
178
+ const hits = searchSessions(query);
179
+ if (hits.length === 0) {
180
+ console.log(` no session mentions "${query}"`);
181
+ return 0;
182
+ }
183
+ for (const hit of hits) {
184
+ const when = new Date(hit.row.modified).toISOString().replace("T", " ").slice(0, 16);
185
+ const count = `${hit.matches} hit${hit.matches === 1 ? "" : "s"}`;
186
+ console.log(` ${when} ${hit.row.project} (${count})`);
187
+ console.log(` ${hit.row.file}`);
188
+ console.log(` ${hit.snippet}`);
189
+ }
190
+ return 0;
191
+ }
192
+ const limitIndex = argv.indexOf("--limit");
193
+ const limit = limitIndex >= 0 ? Number(argv[limitIndex + 1]) || 15 : 15;
194
+ const rows = listSessionFiles(limit);
195
+ if (rows.length === 0) {
196
+ console.log(` no saved sessions under ${join(AGENT_DIR, "sessions")}`);
197
+ // Asking for one session and getting "there are none" is a failed request,
198
+ // not a listing: a script that exports must see it in the exit code.
199
+ return argv.includes("--export") ? 1 : 0;
200
+ }
201
+ const exportIndex = argv.indexOf("--export");
202
+ if (exportIndex >= 0) {
203
+ const which = Number(argv[exportIndex + 1]);
204
+ if (!Number.isInteger(which) || which < 1 || which > rows.length) {
205
+ console.error(`${APP_TITLE}: sessions: --export needs a number from the list (1-${rows.length})`);
206
+ return 2;
207
+ }
208
+ const row = rows[which - 1];
209
+ try {
210
+ console.log(exportSessionMarkdown(row.file));
211
+ }
212
+ catch (error) {
213
+ reportError(APP_TITLE, "sessions", error);
214
+ return 1;
215
+ }
216
+ return 0;
217
+ }
218
+ rows.forEach((row, index) => {
219
+ const when = new Date(row.modified).toISOString().replace("T", " ").slice(0, 16);
220
+ const kb = Math.round(row.bytes / 1024);
221
+ console.log(` ${String(index + 1).padStart(2)} ${when} ${String(kb).padStart(6)}k ${row.project}`);
222
+ console.log(` ${row.file}`);
223
+ });
224
+ return 0;
225
+ }
226
+ /** Text of one JSONL line, whether its content is a string or content blocks. */
227
+ function lineText(line) {
228
+ try {
229
+ const entry = JSON.parse(line);
230
+ // Some entries carry the text at the top level (a prompt or a note); most
231
+ // carry it under `message.content`.
232
+ const content = entry.message?.content ?? entry.content;
233
+ if (typeof content === "string" && content.trim())
234
+ return stripAnsi(content).replace(/\s+/g, " ").trim();
235
+ if (Array.isArray(content)) {
236
+ const text = stripAnsi(content
237
+ .map((block) => (block && typeof block === "object" && "text" in block ? String(block.text) : ""))
238
+ .join(" "))
239
+ .replace(/\s+/g, " ")
240
+ .trim();
241
+ if (text)
242
+ return text;
243
+ }
244
+ }
245
+ catch {
246
+ // A line that does not parse is still searchable as raw text.
247
+ }
248
+ // Entries that keep their prose elsewhere — a system prompt's `sections`, a
249
+ // prompt template — reach here with no usable `content`. Falling back to the
250
+ // line keeps the snippet non-empty; an empty one is a hit with no evidence.
251
+ return stripAnsi(line).replace(/\s+/g, " ").trim();
252
+ }
253
+ /**
254
+ * Drop terminal escapes from a snippet.
255
+ *
256
+ * Session logs keep the escape sequences a tool emitted, so a search result
257
+ * used to read `[<35;27;32M--MANDATORY!---` — the colour codes of a prompt, not
258
+ * anything a person wrote. Stripping them is what makes the snippet evidence.
259
+ */
260
+ function stripAnsi(text) {
261
+ // CSI (colours, cursor), OSC (titles, hyperlinks), and the two-character
262
+ // escapes. Written as escapes so this source does not contain raw ESC bytes.
263
+ return text.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007]*\u0007|\u001b[@-Z\\-_]/g, "");
264
+ }
265
+ /**
266
+ * A window of `text` around `needle`, with ellipses where it was cut.
267
+ *
268
+ * Falls back to the head when the needle is not in the flattened text (the
269
+ * query may have spanned a line break that flattening removed), so a hit is
270
+ * never reported with an empty snippet.
271
+ */
272
+ function excerpt(text, needle, width = 80) {
273
+ const at = text.toLowerCase().indexOf(needle);
274
+ if (at < 0)
275
+ return text.slice(0, width * 2).trim();
276
+ const start = Math.max(0, at - width);
277
+ const end = Math.min(text.length, at + needle.length + width);
278
+ return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`;
279
+ }
280
+ /**
281
+ * Sessions whose text mentions `query`, best first.
282
+ *
283
+ * The walk used to stop after the 500 newest files, which silently hid older
284
+ * work: a session from last month that is the only one mentioning an error was
285
+ * never read, and the answer was "no session mentions that". Every session is
286
+ * searched now; `limit` bounds the results, not the corpus.
287
+ *
288
+ * Ranking is by the best evidence in the session, not by raw occurrence count.
289
+ * A session that repeats a word five times inside one tool result is not more
290
+ * about that word than a session whose user asked about it once: counting
291
+ * occurrences put the log-heavy file first, and the reader opened the wrong
292
+ * one. So a mention in a user turn dominates, an assistant turn counts, a tool
293
+ * result is weak corroboration, and a whole-word hit beats a substring.
294
+ */
295
+ export function searchSessions(query, limit = 10, root = AGENT_DIR) {
296
+ const needle = query.toLowerCase();
297
+ // "git" should beat "digit" rather than tie with it, so the word boundary is
298
+ // worth points. The query is escaped: a search for "a.b" is not a regex.
299
+ const wholeWord = new RegExp(`\\b${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
300
+ const hits = [];
301
+ for (const row of listSessionFiles(Number.MAX_SAFE_INTEGER, root)) {
302
+ let text;
303
+ try {
304
+ text = readFileSync(row.file, "utf8");
305
+ }
306
+ catch {
307
+ continue;
308
+ }
309
+ let matches = 0;
310
+ let userHits = 0;
311
+ let assistantHits = 0;
312
+ let toolHits = 0;
313
+ let wordHit = false;
314
+ let snippet = "";
315
+ for (const line of text.split("\n")) {
316
+ const lower = line.toLowerCase();
317
+ // Count occurrences for the reported total, not for the ranking.
318
+ let at = lower.indexOf(needle);
319
+ if (at < 0)
320
+ continue;
321
+ let occurrences = 0;
322
+ while (at >= 0) {
323
+ occurrences++;
324
+ at = lower.indexOf(needle, at + needle.length);
325
+ }
326
+ matches += occurrences;
327
+ const isUserTurn = lower.includes('"role":"user"');
328
+ const isToolResult = lower.includes('"role":"toolresult"') || lower.includes('"role":"tool"');
329
+ if (isUserTurn)
330
+ userHits += occurrences;
331
+ else if (isToolResult)
332
+ toolHits += occurrences;
333
+ else
334
+ assistantHits += occurrences;
335
+ if (wholeWord.test(line))
336
+ wordHit = true;
337
+ if (!snippet) {
338
+ // A window around the match, not the start of the line. Session lines
339
+ // can be hundreds of kilobytes — a system prompt, a pasted log — and
340
+ // `slice(0, 160)` showed the boilerplate at the top instead of the
341
+ // words the reader searched for. A snippet that does not contain the
342
+ // query is not evidence.
343
+ snippet = excerpt(lineText(line), needle);
344
+ }
345
+ }
346
+ if (matches === 0)
347
+ continue;
348
+ // Presence, not volume, decides the class of evidence. Occurrences past the
349
+ // first add a little, so two mentions edge out one within the same class.
350
+ const score = (userHits > 0 ? 100 : 0) +
351
+ (assistantHits > 0 ? 10 : 0) +
352
+ Math.min(toolHits, 5) +
353
+ (wordHit ? 5 : 0) +
354
+ Math.min(matches, 10) / 100;
355
+ hits.push({ row, matches, score, snippet });
356
+ }
357
+ // Ties fall back to recency, which is what a person expects among equals.
358
+ return hits.sort((a, b) => b.score - a.score || b.row.modified - a.row.modified).slice(0, limit);
359
+ }
360
+ /** A session as markdown, for pasting into an issue or a chat. */
361
+ export function exportSessionMarkdown(file) {
362
+ const out = [`# Session ${file.split(/[\\/]/).pop() ?? file}`, ""];
363
+ for (const line of readFileSync(file, "utf8").split("\n")) {
364
+ if (!line.trim())
365
+ continue;
366
+ let entry;
367
+ try {
368
+ entry = JSON.parse(line);
369
+ }
370
+ catch {
371
+ continue;
372
+ }
373
+ if (entry.type !== "message")
374
+ continue;
375
+ const text = lineText(line);
376
+ if (!text)
377
+ continue;
378
+ out.push(`## ${entry.message?.role ?? "unknown"}`, "", text, "");
379
+ }
380
+ return out.join("\n").trimEnd();
381
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Consecutive failures before the breaker trips.
3
+ *
4
+ * Same value as the reference. Three is enough to distinguish a broken
5
+ * compaction from a transient API error, and small enough that the wasted calls
6
+ * stay negligible.
7
+ */
8
+ export const MAX_CONSECUTIVE_FAILURES = 3;
9
+ export function emptyGuardState() {
10
+ return { consecutiveFailures: 0, tripped: false };
11
+ }
12
+ /**
13
+ * Whether a compaction should be refused.
14
+ *
15
+ * Pure, so the policy is testable without a session: manual compaction is always
16
+ * allowed, and an untripped breaker allows everything.
17
+ */
18
+ export function shouldSkipCompaction(state, reason) {
19
+ if (reason === "manual")
20
+ return false;
21
+ return state.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES;
22
+ }
23
+ /** Fold one failure into the state. Aborts are not failures. */
24
+ export function noteFailure(state, aborted) {
25
+ if (aborted)
26
+ return state;
27
+ const consecutiveFailures = state.consecutiveFailures + 1;
28
+ return {
29
+ consecutiveFailures,
30
+ tripped: state.tripped || consecutiveFailures >= MAX_CONSECUTIVE_FAILURES,
31
+ };
32
+ }
33
+ /** Fold one success into the state: the compaction worked, so the count resets. */
34
+ export function noteSuccess() {
35
+ return emptyGuardState();
36
+ }
37
+ /** The message the user sees once, when the breaker trips. */
38
+ export function breakerMessage(state) {
39
+ return [
40
+ `Auto-compaction failed ${state.consecutiveFailures} times in a row, so it is switched off for this session.`,
41
+ "The context is over the limit and compaction cannot recover it. Start a new session, or switch to a model with a larger context window.",
42
+ "Manual /compact is still allowed.",
43
+ ].join(" ");
44
+ }
45
+ let state = emptyGuardState();
46
+ export function compactionGuardState() {
47
+ return state;
48
+ }
49
+ /** Test seam: forget the counts. */
50
+ export function resetCompactionGuard() {
51
+ state = emptyGuardState();
52
+ }
53
+ /**
54
+ * Wire the breaker into the session.
55
+ *
56
+ * The refusal happens in `session_before_compact`, which the SDK consults before
57
+ * it runs the summariser — so a refused attempt costs no API call at all.
58
+ */
59
+ export function createCompactionGuardExtension() {
60
+ return (hxf) => {
61
+ hxf.on("session_start", async () => {
62
+ state = emptyGuardState();
63
+ });
64
+ hxf.on("session_compact", async () => {
65
+ state = noteSuccess();
66
+ });
67
+ hxf.on("session_compact_failed", async (event, ctx) => {
68
+ const before = state;
69
+ state = noteFailure(state, event.aborted);
70
+ // Report the trip once, when it happens, rather than on every later turn.
71
+ if (!before.tripped && state.tripped && ctx?.ui) {
72
+ ctx.ui.notify(breakerMessage(state), "warning");
73
+ }
74
+ });
75
+ hxf.on("session_before_compact", async (event) => {
76
+ if (!shouldSkipCompaction(state, event.reason))
77
+ return undefined;
78
+ return { cancel: true };
79
+ });
80
+ };
81
+ }
@@ -0,0 +1,22 @@
1
+ export function createConnectPrompt() {
2
+ let restore = null;
3
+ return {
4
+ get armed() {
5
+ return restore !== null;
6
+ },
7
+ arm(next) {
8
+ // A second `/connect` replaces the first: the earlier prompt's restore
9
+ // must not run later and clobber the newer one.
10
+ restore = next;
11
+ return true;
12
+ },
13
+ disarm() {
14
+ const previous = restore;
15
+ if (previous === null)
16
+ return false;
17
+ restore = null;
18
+ previous();
19
+ return true;
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,17 @@
1
+ import { readMessageText, writeMessageText } from "./prompt-text.js";
2
+ import { renderNotes } from "./notes.js";
3
+ export function createContextExtension() {
4
+ return (hxf) => {
5
+ hxf.on("context_with_system", async (event) => {
6
+ const notes = renderNotes();
7
+ if (!notes)
8
+ return undefined;
9
+ const head = event.messages[0];
10
+ if (!head || head.role !== "system")
11
+ return undefined;
12
+ const combined = `${readMessageText(head.content)}\n\n${notes}`;
13
+ const messages = event.messages.map((message) => (message === head ? { ...head, content: writeMessageText(head.content, combined) } : message));
14
+ return { messages };
15
+ });
16
+ };
17
+ }