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.
- package/dist/account-credential.js +136 -0
- package/dist/acp.js +234 -0
- package/dist/activity.js +124 -0
- package/dist/agent-defs.js +237 -0
- package/dist/agent-limiter.js +68 -0
- package/dist/agents.js +554 -0
- package/dist/anim.js +361 -0
- package/dist/args.js +250 -0
- package/dist/at-file.js +28 -0
- package/dist/brand-extension.js +87 -0
- package/dist/brand.js +76 -0
- package/dist/browser.js +58 -0
- package/dist/cache-report.js +139 -0
- package/dist/checkpoint.js +437 -0
- package/dist/claims.js +167 -0
- package/dist/cli-auth.js +142 -0
- package/dist/cli-checkin.js +121 -0
- package/dist/cli-credentials.js +254 -0
- package/dist/clipboard.js +149 -0
- package/dist/collapse.js +129 -0
- package/dist/commands.js +381 -0
- package/dist/compaction-guard.js +81 -0
- package/dist/connect-prompt.js +22 -0
- package/dist/context-extension.js +17 -0
- package/dist/dcp/commands.js +172 -0
- package/dist/dcp/compress-state.js +139 -0
- package/dist/dcp/compress.js +329 -0
- package/dist/dcp/dcp-extension.js +295 -0
- package/dist/dcp/dedup.js +93 -0
- package/dist/dcp/ids.js +189 -0
- package/dist/dcp/index.js +25 -0
- package/dist/dcp/middle-prune.js +76 -0
- package/dist/dcp/prompts.js +68 -0
- package/dist/dcp/protected-content.js +221 -0
- package/dist/dcp/protected.js +161 -0
- package/dist/dcp/prune.js +418 -0
- package/dist/dcp/state.js +176 -0
- package/dist/errors.js +63 -0
- package/dist/extensions.js +48 -0
- package/dist/fatal.js +35 -0
- package/dist/feed.js +498 -0
- package/dist/fs-atomic.js +54 -0
- package/dist/gateway-errors.js +107 -0
- package/dist/git.js +230 -0
- package/dist/goal-extension.js +88 -0
- package/dist/goal.js +222 -0
- package/dist/hooks-extension.js +176 -0
- package/dist/hooks.js +443 -0
- package/dist/howtofix-account.js +834 -0
- package/dist/hud-line.js +158 -0
- package/dist/hwid.js +130 -0
- package/dist/interrupt.js +60 -0
- package/dist/json-events.js +50 -0
- package/dist/level-style.js +112 -0
- package/dist/loop-guard.js +95 -0
- package/dist/lsp-tools.js +32 -0
- package/dist/lsp.js +429 -0
- package/dist/main.js +573 -98
- package/dist/mcp-config.js +85 -0
- package/dist/mcp-lifecycle.js +147 -0
- package/dist/model-catalog.js +217 -0
- package/dist/model-facts.js +35 -0
- package/dist/model-resolve.js +65 -0
- package/dist/notes.js +78 -0
- package/dist/oauth-login.js +568 -0
- package/dist/paths.js +61 -0
- package/dist/permissions.js +298 -0
- package/dist/plan-mode.js +363 -0
- package/dist/preset-command.js +227 -0
- package/dist/preset-extension.js +35 -0
- package/dist/preset-mcp.js +292 -0
- package/dist/presets/mcp/rea/preset.json +28 -0
- package/dist/presets/mcp/rea/prompt.md +5 -0
- package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
- package/dist/presets/mcp/remotion/preset.json +27 -0
- package/dist/presets/mcp/remotion/prompt.md +7 -0
- package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
- package/dist/presets.js +727 -0
- package/dist/prompt-files.js +103 -0
- package/dist/prompt-text.js +41 -0
- package/dist/proxy.js +137 -0
- package/dist/quality/ast.js +196 -0
- package/dist/quality/cognitive.js +83 -0
- package/dist/quality/cyclomatic.js +30 -0
- package/dist/quality/halstead.js +83 -0
- package/dist/quality/index.js +18 -0
- package/dist/quality/length.js +3 -0
- package/dist/quality/maintainability.js +15 -0
- package/dist/quality/nesting.js +57 -0
- package/dist/quality/report.js +803 -0
- package/dist/quality/smells.js +67 -0
- package/dist/quality-tool.js +63 -0
- package/dist/read-only-commands.js +597 -0
- package/dist/repo-map.js +292 -0
- package/dist/roblox-connect.js +125 -0
- package/dist/rpc.js +91 -0
- package/dist/rtk.js +193 -0
- package/dist/sandbox.js +105 -0
- package/dist/schema.js +214 -0
- package/dist/serve.js +50 -0
- package/dist/session-lifecycle.js +19 -0
- package/dist/session-retention.js +111 -0
- package/dist/session.js +460 -0
- package/dist/sessions.js +100 -0
- package/dist/settings.js +148 -0
- package/dist/skills.js +35 -0
- package/dist/slop-tool.js +96 -0
- package/dist/spend-cap.js +43 -0
- package/dist/subagent.js +171 -0
- package/dist/tar.js +146 -0
- package/dist/theme.js +79 -0
- package/dist/thinking.js +45 -0
- package/dist/todo.js +114 -0
- package/dist/tool-loadout.js +102 -0
- package/dist/tool-render.js +769 -0
- package/dist/tools.js +176 -0
- package/dist/ui.js +4931 -0
- package/dist/update.js +551 -0
- package/dist/usage-segment.js +74 -0
- package/dist/web-tools.js +46 -0
- package/dist/web.js +265 -0
- package/dist/welcome.js +179 -0
- package/dist/workflow-engine.js +246 -0
- package/dist/workflow-progress.js +446 -0
- package/dist/workflow-view.js +195 -0
- package/dist/workflow.js +349 -0
- package/package.json +62 -15
- package/dist/chunk-27TRKD65.js +0 -2
- package/dist/chunk-3FYCFLNK.js +0 -1
- package/dist/chunk-3LZR7OEU.js +0 -1
- package/dist/chunk-56SEAGAQ.js +0 -1
- package/dist/chunk-5AWTL34S.js +0 -1
- package/dist/chunk-75P2SCOT.js +0 -1
- package/dist/chunk-7CHDNTH6.js +0 -1
- package/dist/chunk-7PHRNXLN.js +0 -34
- package/dist/chunk-AGGIYXN7.js +0 -2
- package/dist/chunk-AUDMRGWF.js +0 -189
- package/dist/chunk-C5E3SFOQ.js +0 -2
- package/dist/chunk-CMTKCUL5.js +0 -1
- package/dist/chunk-DAPXGSGD.js +0 -12
- package/dist/chunk-DOD7MUGX.js +0 -1
- package/dist/chunk-FPXVDP32.js +0 -8
- package/dist/chunk-GXB7RZHH.js +0 -1
- package/dist/chunk-I7KVLBMF.js +0 -2
- package/dist/chunk-ILDDDLRC.js +0 -1
- package/dist/chunk-JFYVRWJI.js +0 -2
- package/dist/chunk-LAHVHRCS.js +0 -2
- package/dist/chunk-LSJC73DG.js +0 -1
- package/dist/chunk-MNDKS6UH.js +0 -6
- package/dist/chunk-N345KWWK.js +0 -1
- package/dist/chunk-N3J2J7EQ.js +0 -5
- package/dist/chunk-ONZLNFQ2.js +0 -3
- package/dist/chunk-ORJ2QZBG.js +0 -3
- package/dist/chunk-PCALBZOO.js +0 -2
- package/dist/chunk-QPHSHL7H.js +0 -2
- package/dist/chunk-TKROS3BE.js +0 -5
- package/dist/chunk-TSEKPGK4.js +0 -1
- package/dist/chunk-UP7ENLEL.js +0 -117
- package/dist/chunk-UTYFLLSM.js +0 -1
- package/dist/chunk-V3R4FYTJ.js +0 -1
- package/dist/chunk-V656XVXV.js +0 -8
- package/dist/chunk-WKUJ7CFO.js +0 -1
- package/dist/chunk-WOT6VMZA.js +0 -1
- package/dist/chunk-WUPU6LTD.js +0 -4
- package/dist/chunk-WX3V5P7U.js +0 -5
- package/dist/chunk-XW6LMQGX.js +0 -1
- package/dist/chunk-YW7NORH3.js +0 -2
package/dist/collapse.js
ADDED
|
@@ -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.
|
package/dist/commands.js
ADDED
|
@@ -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
|
+
}
|