howtofixcli 0.7.10 → 0.7.12
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/account-credential.test.js +38 -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 +228 -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 +120 -0
- package/dist/cli-credentials.js +254 -0
- package/dist/clipboard.js +149 -0
- package/dist/collapse.js +129 -0
- package/dist/commands.js +294 -0
- package/dist/compaction-guard.js +81 -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/device-id.js +90 -0
- package/dist/device-id.test.js +23 -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 +90 -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 +735 -0
- package/dist/hud-line.js +153 -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 +540 -94
- 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.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/rtk.js +193 -0
- package/dist/sandbox.js +105 -0
- package/dist/schema.js +214 -0
- package/dist/session-lifecycle.js +19 -0
- package/dist/session-retention.js +111 -0
- package/dist/session.js +447 -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 +97 -0
- package/dist/tool-render.js +769 -0
- package/dist/tools.js +176 -0
- package/dist/ui.js +4778 -0
- package/dist/update.js +551 -0
- package/dist/usage-segment.js +69 -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-2YYQ3OBQ.js +0 -1
- package/dist/chunk-3FYCFLNK.js +0 -1
- package/dist/chunk-3KT55SQP.js +0 -34
- package/dist/chunk-4BWDGEXH.js +0 -5
- package/dist/chunk-56SEAGAQ.js +0 -1
- package/dist/chunk-6FUWMLWO.js +0 -1
- package/dist/chunk-7CHDNTH6.js +0 -1
- package/dist/chunk-ACOMNDUL.js +0 -185
- package/dist/chunk-AGGIYXN7.js +0 -2
- package/dist/chunk-BHVBO4SC.js +0 -1
- package/dist/chunk-CGHISVXB.js +0 -1
- package/dist/chunk-FPXVDP32.js +0 -8
- package/dist/chunk-FRGMJPNO.js +0 -2
- package/dist/chunk-GXB7RZHH.js +0 -1
- package/dist/chunk-ILDDDLRC.js +0 -1
- package/dist/chunk-JFYVRWJI.js +0 -2
- package/dist/chunk-MTNY7MFG.js +0 -6
- package/dist/chunk-MVLCGU3G.js +0 -1
- package/dist/chunk-N345KWWK.js +0 -1
- package/dist/chunk-ORJ2QZBG.js +0 -3
- package/dist/chunk-PCALBZOO.js +0 -2
- package/dist/chunk-PIEAJJRE.js +0 -5
- package/dist/chunk-RDRF7DIS.js +0 -109
- package/dist/chunk-TEW62VDF.js +0 -1
- package/dist/chunk-TSEKPGK4.js +0 -1
- package/dist/chunk-UTYFLLSM.js +0 -1
- package/dist/chunk-V3R4FYTJ.js +0 -1
- package/dist/chunk-V656XVXV.js +0 -8
- package/dist/chunk-VICEM5Y2.js +0 -2
- 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-WZKWT757.js +0 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP configuration the session should load, read at the moment the SDK asks.
|
|
3
|
+
*
|
|
4
|
+
* The SDK's MCP extension reads `mcp.json` exactly once, on `session_start`. A
|
|
5
|
+
* server removed from the menu afterwards stays configured as far as that
|
|
6
|
+
* extension is concerned, so nothing closes its transport and the process keeps
|
|
7
|
+
* running for the life of the CLI — the leak the preset menu could not see,
|
|
8
|
+
* because the menu only ever edited the file.
|
|
9
|
+
*
|
|
10
|
+
* The extension's teardown is correct and already exists: it closes every server
|
|
11
|
+
* whose config changed when `loadConfig` returns a set that no longer names it.
|
|
12
|
+
* What it needed was a loader that re-reads the file each time it is called, so
|
|
13
|
+
* that a change made mid-session is visible to it. That is all this is.
|
|
14
|
+
*
|
|
15
|
+
* The shape mirrors what the SDK's own loader produces, because the extension
|
|
16
|
+
* consumes it directly: `servers`, `errors`, and the project path. Entries keep
|
|
17
|
+
* whatever keys the file holds, since the SDK reads `command`, `args`, `env`,
|
|
18
|
+
* `url`, `enabled`, and `exposure` from them.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { AGENT_DIR } from "./paths.js";
|
|
22
|
+
import { mcpConfigPath } from "./preset-mcp.js";
|
|
23
|
+
/** The project's own `mcp.json`, which takes precedence when the project is trusted. */
|
|
24
|
+
export function projectMcpConfigPath(cwd) {
|
|
25
|
+
return `${cwd}/.howtofix/mcp.json`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read one `mcp.json`. A missing file is an empty config; an unparseable one is
|
|
29
|
+
* an error, never a crash — a broken file must not stop the session from
|
|
30
|
+
* starting, and the user needs to be told which file is broken.
|
|
31
|
+
*/
|
|
32
|
+
function readOne(path, errors) {
|
|
33
|
+
if (!existsSync(path))
|
|
34
|
+
return {};
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
37
|
+
const servers = parsed?.mcpServers;
|
|
38
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers))
|
|
39
|
+
return {};
|
|
40
|
+
return servers;
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
errors.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Load global and (when trusted) project MCP configuration.
|
|
49
|
+
*
|
|
50
|
+
* Re-read on every call. The extension calls this on each `session_start`, and
|
|
51
|
+
* the session's own reload path re-emits it, so this is what turns "the file
|
|
52
|
+
* changed" into "the running session changed".
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* The global `mcp.json` as the menu needs it: names and specs, no merging.
|
|
56
|
+
*
|
|
57
|
+
* The menu shows what a removal will act on, so it must read the same file the
|
|
58
|
+
* loader reads. A second reader here would be a second opinion about a config
|
|
59
|
+
* file, and the two would disagree the first time either changed.
|
|
60
|
+
*/
|
|
61
|
+
export function readGlobalMcpServers(agentDir) {
|
|
62
|
+
const errors = [];
|
|
63
|
+
return readOne(mcpConfigPath(agentDir ?? AGENT_DIR), errors);
|
|
64
|
+
}
|
|
65
|
+
export function loadMcpConfiguration(options) {
|
|
66
|
+
const errors = [];
|
|
67
|
+
const globalServers = readOne(mcpConfigPath(options.agentDir ?? AGENT_DIR), errors);
|
|
68
|
+
const projectPath = projectMcpConfigPath(options.cwd);
|
|
69
|
+
const projectConfig = options.projectTrusted && existsSync(projectPath) ? projectPath : undefined;
|
|
70
|
+
const projectServers = projectConfig ? readOne(projectConfig, errors) : {};
|
|
71
|
+
// The project overrides the global entry of the same name, which is the same
|
|
72
|
+
// precedence the SDK applies and the reason `/mcp` saves project overrides to
|
|
73
|
+
// this file.
|
|
74
|
+
const globalPath = mcpConfigPath(options.agentDir ?? AGENT_DIR);
|
|
75
|
+
const merged = new Map();
|
|
76
|
+
for (const [name, spec] of Object.entries(globalServers)) {
|
|
77
|
+
merged.set(name, { name, config: spec, source: globalPath, scope: "global" });
|
|
78
|
+
}
|
|
79
|
+
for (const [name, spec] of Object.entries(projectServers)) {
|
|
80
|
+
// A project entry of the same name overrides the global one, and takes over
|
|
81
|
+
// as the file `/mcp` writes to.
|
|
82
|
+
merged.set(name, { name, config: spec, source: projectConfig, scope: "project" });
|
|
83
|
+
}
|
|
84
|
+
return { servers: [...merged.values()], errors, projectConfig };
|
|
85
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One lifecycle seam for MCP configuration changes.
|
|
3
|
+
*
|
|
4
|
+
* Editing `mcp.json` and making the running session match it are two different
|
|
5
|
+
* things, and only the first was ever done. Removing a server from the preset
|
|
6
|
+
* menu wrote the file and stopped: the live transport, its child process, and the
|
|
7
|
+
* `mcp__<server>__<tool>` registrations stayed in the session until the CLI
|
|
8
|
+
* exited, so a server the user had just removed was still reachable by the agent.
|
|
9
|
+
* Installing had the mirror-image problem — the reload was a `void` call, so two
|
|
10
|
+
* quick presses could overlap a config write with a session reload and land in
|
|
11
|
+
* either order.
|
|
12
|
+
*
|
|
13
|
+
* This module owns the second half. Every path that changes MCP configuration
|
|
14
|
+
* funnels through `reconcile`, which:
|
|
15
|
+
*
|
|
16
|
+
* - treats the on-disk configuration as the desired state and the active server
|
|
17
|
+
* set as the actual state, then moves one toward the other;
|
|
18
|
+
* - runs single-flight: a second request joins the one in progress rather than
|
|
19
|
+
* starting another, and the *latest* desired state is what gets applied, so a
|
|
20
|
+
* change made during a run is not lost;
|
|
21
|
+
* - never lets a failure escape as an unhandled rejection, because every caller
|
|
22
|
+
* reaches it from a `void` inside a picker handler.
|
|
23
|
+
*
|
|
24
|
+
* It deliberately does not know how the SDK starts or stops a server. The caller
|
|
25
|
+
* supplies that through `apply`, which keeps this testable without a live
|
|
26
|
+
* transport and keeps the SDK's MCP surface in one place.
|
|
27
|
+
*/
|
|
28
|
+
/** Raised names that could not be released. Reported, never thrown onward. */
|
|
29
|
+
function describeError(error) {
|
|
30
|
+
return error instanceof Error ? error.message : String(error);
|
|
31
|
+
}
|
|
32
|
+
export class McpLifecycle {
|
|
33
|
+
/** Names currently active in the session. */
|
|
34
|
+
active;
|
|
35
|
+
apply;
|
|
36
|
+
report;
|
|
37
|
+
/** The newest desired set seen while a reconciliation was running. */
|
|
38
|
+
pending;
|
|
39
|
+
/** The in-flight reconciliation, if any. Callers join this instead of starting one. */
|
|
40
|
+
running;
|
|
41
|
+
constructor(options) {
|
|
42
|
+
this.active = new Set(options.initial ?? []);
|
|
43
|
+
this.apply = options.apply;
|
|
44
|
+
this.report = options.report ?? ((message) => console.error(message));
|
|
45
|
+
}
|
|
46
|
+
/** Names the session currently holds. A copy, so a caller cannot mutate the state. */
|
|
47
|
+
activeServers() {
|
|
48
|
+
return [...this.active];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Move the active set toward `desired`.
|
|
52
|
+
*
|
|
53
|
+
* Concurrent callers join the run in progress; the desired set is replaced
|
|
54
|
+
* before joining, so the last request wins and no edit is dropped. That is the
|
|
55
|
+
* difference between coalescing and dropping: an install that arrives while a
|
|
56
|
+
* removal is running is applied by the time the shared promise settles, not
|
|
57
|
+
* discarded because the slot was taken.
|
|
58
|
+
*/
|
|
59
|
+
reconcile(desired) {
|
|
60
|
+
this.pending = new Set(desired);
|
|
61
|
+
if (this.running)
|
|
62
|
+
return this.running;
|
|
63
|
+
this.running = this.drain().finally(() => {
|
|
64
|
+
this.running = undefined;
|
|
65
|
+
});
|
|
66
|
+
return this.running;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Apply desired sets until the newest one has been applied.
|
|
70
|
+
*
|
|
71
|
+
* A single pass would leave the last request unapplied whenever a change
|
|
72
|
+
* arrived mid-run, which is exactly the case this exists for.
|
|
73
|
+
*/
|
|
74
|
+
async drain() {
|
|
75
|
+
const total = { added: [], removed: [], unchanged: [], problems: [] };
|
|
76
|
+
while (this.pending !== undefined) {
|
|
77
|
+
const desired = this.pending;
|
|
78
|
+
this.pending = undefined;
|
|
79
|
+
const pass = await this.applyOnce(desired);
|
|
80
|
+
total.added.push(...pass.added);
|
|
81
|
+
total.removed.push(...pass.removed);
|
|
82
|
+
total.unchanged.push(...pass.unchanged);
|
|
83
|
+
total.problems.push(...pass.problems);
|
|
84
|
+
}
|
|
85
|
+
// Names can appear in both an add and a later remove within one reconcile
|
|
86
|
+
// call; report each name once, with the final state deciding which list.
|
|
87
|
+
const finalActive = this.active;
|
|
88
|
+
total.added = total.added.filter((name) => finalActive.has(name));
|
|
89
|
+
total.removed = [...new Set(total.removed.filter((name) => !finalActive.has(name)))];
|
|
90
|
+
total.added = [...new Set(total.added)];
|
|
91
|
+
total.unchanged = [...new Set(total.unchanged.filter((name) => finalActive.has(name)))];
|
|
92
|
+
return total;
|
|
93
|
+
}
|
|
94
|
+
async applyOnce(desired) {
|
|
95
|
+
const added = [...desired].filter((name) => !this.active.has(name));
|
|
96
|
+
const removed = [...this.active].filter((name) => !desired.has(name));
|
|
97
|
+
const unchanged = [...desired].filter((name) => this.active.has(name));
|
|
98
|
+
if (added.length === 0 && removed.length === 0) {
|
|
99
|
+
return { added: [], removed: [], unchanged, problems: [] };
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await this.apply(added, removed);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
// The session may be partly reconciled. Report which half failed rather
|
|
106
|
+
// than claiming the configuration and the session agree.
|
|
107
|
+
const message = `mcp reconciliation failed: ${describeError(error)}`;
|
|
108
|
+
this.report(message);
|
|
109
|
+
return { added: [], removed: [], unchanged: [], problems: [message] };
|
|
110
|
+
}
|
|
111
|
+
// Recorded only after `apply` resolves: a teardown that threw has not
|
|
112
|
+
// released the server, so the active set must still name it or the next
|
|
113
|
+
// reconcile would skip the release it still owes.
|
|
114
|
+
for (const name of added)
|
|
115
|
+
this.active.add(name);
|
|
116
|
+
for (const name of removed)
|
|
117
|
+
this.active.delete(name);
|
|
118
|
+
return { added, removed, unchanged, problems: [] };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Release everything this session owns.
|
|
122
|
+
*
|
|
123
|
+
* Called when the session is replaced or the CLI exits. Without it, repeated
|
|
124
|
+
* `/new` and `/resume` accumulate one set of server processes per session,
|
|
125
|
+
* which is the leak the picker path could not see because it never disposes a
|
|
126
|
+
* session at all.
|
|
127
|
+
*/
|
|
128
|
+
async dispose() {
|
|
129
|
+
// A reconcile still in flight owns the transports; wait for it so we do not
|
|
130
|
+
// dispose a set it is in the middle of mutating.
|
|
131
|
+
if (this.running)
|
|
132
|
+
await this.running.catch(() => undefined);
|
|
133
|
+
const names = [...this.active];
|
|
134
|
+
if (names.length === 0)
|
|
135
|
+
return { added: [], removed: [], unchanged: [], problems: [] };
|
|
136
|
+
try {
|
|
137
|
+
await this.apply([], names);
|
|
138
|
+
this.active.clear();
|
|
139
|
+
return { added: [], removed: names, unchanged: [], problems: [] };
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
const message = `mcp teardown failed: ${describeError(error)}`;
|
|
143
|
+
this.report(message);
|
|
144
|
+
return { added: [], removed: [], unchanged: [], problems: [message] };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real context windows and reasoning support, from models.dev.
|
|
3
|
+
*
|
|
4
|
+
* `GET /v1/models` on the gateway returns ids and nothing else — verified against
|
|
5
|
+
* the live service, whose response carries only `id`, `object`, `created`,
|
|
6
|
+
* `owned_by` and `supported_endpoint_types`. So a synced model has no declared
|
|
7
|
+
* window and no declared capability, and `/connect` filled the gap with two
|
|
8
|
+
* guesses that were both wrong in the same direction:
|
|
9
|
+
*
|
|
10
|
+
* - `contextWindow: 128_000` for every model. The gateway serves models with
|
|
11
|
+
* 1,000,000-token windows (deepseek-v4.1-flash, kimi-k3, mimo-v2.5), so the
|
|
12
|
+
* context meter read ~8x too full and the CLI compacted a conversation that
|
|
13
|
+
* had barely started.
|
|
14
|
+
* - no `reasoning` field at all. The SDK gates thinking on it — the schema is
|
|
15
|
+
* `reasoning: boolean`, and `getSupportedThinkingLevels` returns `["off"]`
|
|
16
|
+
* the moment it is falsy — so every synced model lost its thinking levels
|
|
17
|
+
* even though the model reasons. That is the bug a user reports as "thinking
|
|
18
|
+
* is missing on a model that has it".
|
|
19
|
+
*
|
|
20
|
+
* models.dev publishes exactly these facts per model (`limit.context`,
|
|
21
|
+
* `limit.output`, `reasoning`). It is a public catalogue, so the CLI can read it
|
|
22
|
+
* instead of guessing. Nothing here is bundled: the catalogue is fetched once,
|
|
23
|
+
* slimmed to the three fields that are used, and cached next to the agent state,
|
|
24
|
+
* so a later start can repair models without a network call.
|
|
25
|
+
*
|
|
26
|
+
* The pure half (index, lookup) is separated from the network half so the
|
|
27
|
+
* matching rules are testable without a fetch.
|
|
28
|
+
*/
|
|
29
|
+
/** Where the catalogue lives. One document, every provider. */
|
|
30
|
+
export const MODELS_DEV_URL = "https://models.dev/api.json";
|
|
31
|
+
/** How long to wait for the catalogue. It is ~5 MB, so this is generous. */
|
|
32
|
+
export const CATALOG_TIMEOUT_MS = 20_000;
|
|
33
|
+
/**
|
|
34
|
+
* Providers whose entry wins when the same model is listed by several.
|
|
35
|
+
*
|
|
36
|
+
* The catalogue is community-maintained and mirrors every reseller, so one model
|
|
37
|
+
* id resolves to a dozen near-identical entries. The first-party listing is the
|
|
38
|
+
* one whose numbers describe the model rather than a middleman's limits, so the
|
|
39
|
+
* search prefers it. Order matters only among the preferred set; everything else
|
|
40
|
+
* is ordered alphabetically so the result never depends on object iteration.
|
|
41
|
+
*/
|
|
42
|
+
const PREFERRED_PROVIDERS = ["anthropic", "openai", "google", "deepseek", "zai", "xai", "moonshotai", "minimax", "alibaba", "xiaomi", "meta", "tencent", "nvidia", "venice", "openrouter"];
|
|
43
|
+
/** The comparison form of a model id: case and punctuation are not meaningful. */
|
|
44
|
+
export function normalizeModelId(id) {
|
|
45
|
+
return id.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
46
|
+
}
|
|
47
|
+
/** The last path segment of an id, normalized: `z-ai/glm-5.3-fast` -> `glm53fast`. */
|
|
48
|
+
function lastSegment(id) {
|
|
49
|
+
const parts = id.split("/");
|
|
50
|
+
return normalizeModelId(parts[parts.length - 1] ?? id);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Index every model in the catalogue document.
|
|
54
|
+
*
|
|
55
|
+
* One entry per normalized id; when several providers list the same model, the
|
|
56
|
+
* preferred provider wins (see {@link PREFERRED_PROVIDERS}). A model whose entry
|
|
57
|
+
* carries no `limit.context` is skipped: without a window there is nothing to
|
|
58
|
+
* correct, and indexing it would let a bad entry shadow a good one.
|
|
59
|
+
*/
|
|
60
|
+
export function buildCatalogIndex(raw) {
|
|
61
|
+
const index = new Map();
|
|
62
|
+
const owners = new Map();
|
|
63
|
+
if (!raw || typeof raw !== "object")
|
|
64
|
+
return index;
|
|
65
|
+
const providers = raw;
|
|
66
|
+
const priority = (provider) => {
|
|
67
|
+
const at = PREFERRED_PROVIDERS.indexOf(provider);
|
|
68
|
+
return at === -1 ? PREFERRED_PROVIDERS.length : at;
|
|
69
|
+
};
|
|
70
|
+
// Sorted so the winner among equally-preferred providers is decided by name,
|
|
71
|
+
// not by the insertion order of a parsed JSON object — this number feeds a
|
|
72
|
+
// token budget, so it must not depend on how the document happened to be built.
|
|
73
|
+
for (const provider of Object.keys(providers).sort()) {
|
|
74
|
+
const models = providers[provider]?.models;
|
|
75
|
+
if (!models || typeof models !== "object")
|
|
76
|
+
continue;
|
|
77
|
+
for (const id of Object.keys(models)) {
|
|
78
|
+
const model = models[id];
|
|
79
|
+
const context = Number(model?.limit?.context);
|
|
80
|
+
if (!Number.isFinite(context) || context <= 0)
|
|
81
|
+
continue;
|
|
82
|
+
const key = normalizeModelId(id);
|
|
83
|
+
const rank = priority(provider);
|
|
84
|
+
const held = owners.get(key);
|
|
85
|
+
// A preferred provider always wins; otherwise the first (alphabetical) one
|
|
86
|
+
// keeps the slot. Never let a later, less-preferred listing overwrite it.
|
|
87
|
+
if (held !== undefined && held <= rank)
|
|
88
|
+
continue;
|
|
89
|
+
const output = Number(model?.limit?.output);
|
|
90
|
+
index.set(key, {
|
|
91
|
+
contextWindow: Math.floor(context),
|
|
92
|
+
maxTokens: Number.isFinite(output) && output > 0 ? Math.floor(output) : undefined,
|
|
93
|
+
reasoning: model?.reasoning === true,
|
|
94
|
+
vision: Array.isArray(model?.modalities?.input) && model.modalities.input.includes("image"),
|
|
95
|
+
});
|
|
96
|
+
owners.set(key, rank);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return index;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The catalogue entry for a gateway model id, or undefined when it is unknown.
|
|
103
|
+
*
|
|
104
|
+
* Three passes, in order of how literal the match is: the whole id, the last path
|
|
105
|
+
* segment, then a suffix. The gateway renames models (`deepseek-v4.1-flash` is
|
|
106
|
+
* `DeepSeek-V4.1-Flash` in the catalogue, `glm-5.3-flash` is `zai/glm-5.3-flash`)
|
|
107
|
+
* and drops vendor prefixes (`anthropic/claude-sonnet-5` keeps its own), so a
|
|
108
|
+
* single exact match finds less than half. A miss is not a failure: the caller
|
|
109
|
+
* keeps its conservative default, which is the behaviour before this module.
|
|
110
|
+
*/
|
|
111
|
+
export function lookupModel(index, id) {
|
|
112
|
+
const wanted = normalizeModelId(id);
|
|
113
|
+
if (!wanted)
|
|
114
|
+
return undefined;
|
|
115
|
+
const exact = index.get(wanted);
|
|
116
|
+
if (exact)
|
|
117
|
+
return exact;
|
|
118
|
+
const wantedTail = lastSegment(id);
|
|
119
|
+
for (const [key, entry] of index) {
|
|
120
|
+
if (lastSegment(key) === wantedTail)
|
|
121
|
+
return entry;
|
|
122
|
+
}
|
|
123
|
+
for (const [key, entry] of index) {
|
|
124
|
+
if (key.endsWith(wanted) || wanted.endsWith(key))
|
|
125
|
+
return entry;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
/** Where the slimmed catalogue is cached, so a later start needs no network. */
|
|
130
|
+
export function catalogCachePath(agentDir) {
|
|
131
|
+
return `${agentDir}/models-dev-cache.json`;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The serialized form of an index: `{ id: [context, output, reasoning, vision] }`.
|
|
135
|
+
*
|
|
136
|
+
* An array rather than an object per entry because the catalogue is 8,000+ rows
|
|
137
|
+
* and repeating the keys eight thousand times triples the file for no reader's
|
|
138
|
+
* benefit. The cache is internal — nothing outside this module parses it.
|
|
139
|
+
*
|
|
140
|
+
* `vision` was added after the first release, so a cache written by the older
|
|
141
|
+
* build is a 3-tuple. {@link deserializeCatalog} reads the fourth slot as
|
|
142
|
+
* optional and defaults it to `false`, which is the behaviour that build had;
|
|
143
|
+
* the next fetch fills it in. Bumping a format number would instead throw the
|
|
144
|
+
* whole cache away and cost every user a 5 MB download on the next start.
|
|
145
|
+
*/
|
|
146
|
+
export function serializeCatalog(index) {
|
|
147
|
+
const out = {};
|
|
148
|
+
for (const [key, entry] of index) {
|
|
149
|
+
out[key] = [entry.contextWindow, entry.maxTokens ?? 0, entry.reasoning ? 1 : 0, entry.vision ? 1 : 0];
|
|
150
|
+
}
|
|
151
|
+
return JSON.stringify(out);
|
|
152
|
+
}
|
|
153
|
+
/** Read back {@link serializeCatalog}. Returns an empty index for anything unusable. */
|
|
154
|
+
export function deserializeCatalog(text) {
|
|
155
|
+
const index = new Map();
|
|
156
|
+
let parsed;
|
|
157
|
+
try {
|
|
158
|
+
parsed = JSON.parse(text);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return index;
|
|
162
|
+
}
|
|
163
|
+
if (!parsed || typeof parsed !== "object")
|
|
164
|
+
return index;
|
|
165
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
166
|
+
if (!Array.isArray(value) || value.length < 3)
|
|
167
|
+
continue;
|
|
168
|
+
const context = Number(value[0]);
|
|
169
|
+
const output = Number(value[1]);
|
|
170
|
+
if (!Number.isFinite(context) || context <= 0)
|
|
171
|
+
continue;
|
|
172
|
+
index.set(key, {
|
|
173
|
+
contextWindow: Math.floor(context),
|
|
174
|
+
maxTokens: Number.isFinite(output) && output > 0 ? Math.floor(output) : undefined,
|
|
175
|
+
reasoning: value[2] === 1 || value[2] === true,
|
|
176
|
+
// Absent in a cache written before this field existed: read it as "no
|
|
177
|
+
// vision", which is what that build assumed, and let the next fetch correct it.
|
|
178
|
+
vision: value[3] === 1 || value[3] === true,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return index;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Fetch the catalogue and index it.
|
|
185
|
+
*
|
|
186
|
+
* Never throws: a catalogue that cannot be fetched is a reason to keep the
|
|
187
|
+
* conservative defaults, not a reason for `/connect` to fail. The caller reports
|
|
188
|
+
* which of the two happened.
|
|
189
|
+
*/
|
|
190
|
+
export async function fetchCatalog(options) {
|
|
191
|
+
const url = options?.url ?? MODELS_DEV_URL;
|
|
192
|
+
const doFetch = options?.fetchImpl ?? fetch;
|
|
193
|
+
const timeoutMs = options?.timeoutMs ?? CATALOG_TIMEOUT_MS;
|
|
194
|
+
const controller = new AbortController();
|
|
195
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
196
|
+
try {
|
|
197
|
+
const response = await doFetch(url, { signal: controller.signal, headers: { Accept: "application/json" } });
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
return { ok: false, message: `the model catalogue answered ${response.status}` };
|
|
200
|
+
}
|
|
201
|
+
const index = buildCatalogIndex(await response.json());
|
|
202
|
+
if (index.size === 0) {
|
|
203
|
+
return { ok: false, message: "the model catalogue was empty or in an unexpected shape" };
|
|
204
|
+
}
|
|
205
|
+
return { ok: true, index };
|
|
206
|
+
}
|
|
207
|
+
catch (cause) {
|
|
208
|
+
const aborted = cause instanceof Error && cause.name === "AbortError";
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
message: aborted ? `the model catalogue did not answer within ${Math.round(timeoutMs / 1000)}s` : "could not reach the model catalogue",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two facts a model picker is useless without: how much context it takes and
|
|
3
|
+
* what it costs.
|
|
4
|
+
*
|
|
5
|
+
* Every model entry already carries `contextWindow` and `cost`, but the picker
|
|
6
|
+
* showed only the provider, so a free model and an expensive one looked the
|
|
7
|
+
* same. These helpers turn those numbers into one short line for the list.
|
|
8
|
+
*/
|
|
9
|
+
/** 200000 -> "200K", 1000000 -> "1M", 1500000 -> "1.5M". Empty when unknown. */
|
|
10
|
+
export function formatContextWindow(tokens) {
|
|
11
|
+
if (!tokens || !Number.isFinite(tokens) || tokens <= 0)
|
|
12
|
+
return "";
|
|
13
|
+
if (tokens >= 1_000_000) {
|
|
14
|
+
// Rounded to one decimal, then trimmed: 1_048_576 is "1M", not "1.0M".
|
|
15
|
+
return `${Number((tokens / 1_000_000).toFixed(1))}M`;
|
|
16
|
+
}
|
|
17
|
+
if (tokens >= 1_000)
|
|
18
|
+
return `${Math.round(tokens / 1_000)}K`;
|
|
19
|
+
return String(tokens);
|
|
20
|
+
}
|
|
21
|
+
/** Price per million tokens for input and output, or "free" when both are zero. */
|
|
22
|
+
export function formatModelPrice(cost) {
|
|
23
|
+
if (!cost)
|
|
24
|
+
return "";
|
|
25
|
+
if (cost.input <= 0 && cost.output <= 0)
|
|
26
|
+
return "free";
|
|
27
|
+
const money = (value) => (value === 0 ? "0" : String(Number(value >= 1 ? value.toFixed(2) : value.toFixed(3))));
|
|
28
|
+
return `$${money(cost.input)}/$${money(cost.output)} per M`;
|
|
29
|
+
}
|
|
30
|
+
/** "1M ctx · free" — the summary shown beside a model in the picker. */
|
|
31
|
+
export function describeModelFacts(model) {
|
|
32
|
+
const context = formatContextWindow(model.contextWindow);
|
|
33
|
+
const price = formatModelPrice(model.cost);
|
|
34
|
+
return [context ? `${context} ctx` : "", price].filter(Boolean).join(" · ");
|
|
35
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a model spec the way the CLI's own `--model` flag does.
|
|
3
|
+
*
|
|
4
|
+
* Exact `provider/id` or a bare `id` first, then a case-insensitive substring
|
|
5
|
+
* match, so both the name `/model` prints and a shorthand work. Lifted out of
|
|
6
|
+
* `session.ts` when subagents gained a model of their own: two resolvers would
|
|
7
|
+
* drift, and a subagent picking a different model than the parent asked for
|
|
8
|
+
* would read as the option being broken.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveModel(models, spec) {
|
|
11
|
+
const exact = models.find((model) => `${model.provider}/${model.id}` === spec || model.id === spec);
|
|
12
|
+
if (exact)
|
|
13
|
+
return exact;
|
|
14
|
+
const needle = spec.toLowerCase();
|
|
15
|
+
return models.find((model) => model.id.toLowerCase().includes(needle) || `${model.provider}/${model.id}`.toLowerCase().includes(needle));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Order a catalogue for choosing a default model, best first.
|
|
19
|
+
*
|
|
20
|
+
* A gateway lists its catalogue in no useful order — `anthropic/claude-opus-4.8`
|
|
21
|
+
* can come first while the fast general-purpose models are far down the list — so
|
|
22
|
+
* "use whatever is first" hands a new user the most expensive thing on the
|
|
23
|
+
* account, or an image model that cannot hold a conversation.
|
|
24
|
+
*
|
|
25
|
+
* Image, embedding, audio and video models are pushed to the end: they cannot
|
|
26
|
+
* answer a coding prompt, so selecting one would look like a broken install. The
|
|
27
|
+
* caller then probes the first few and takes the first that answers.
|
|
28
|
+
*/
|
|
29
|
+
const PREFERRED_FAMILIES = ["deepseek", "qwen", "claude", "gpt-5", "gpt-4", "gemini", "glm", "mimo", "kimi", "llama"];
|
|
30
|
+
const NOT_CHAT = /(image|embedding|embed|whisper|tts|audio|video|rerank|moderation|dall-e|stable-diffusion|sora)/i;
|
|
31
|
+
export function orderModelsForDefault(ids) {
|
|
32
|
+
const rank = (id) => {
|
|
33
|
+
if (NOT_CHAT.test(id))
|
|
34
|
+
return 100;
|
|
35
|
+
const family = PREFERRED_FAMILIES.findIndex((name) => id.toLowerCase().includes(name));
|
|
36
|
+
return family === -1 ? 50 : family;
|
|
37
|
+
};
|
|
38
|
+
// Stable within a rank, so the gateway's own order decides ties.
|
|
39
|
+
return ids
|
|
40
|
+
.map((id, index) => ({ id, index }))
|
|
41
|
+
.sort((a, b) => rank(a.id) - rank(b.id) || a.index - b.index)
|
|
42
|
+
.map((entry) => entry.id);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The model a session should start on when nothing was asked for.
|
|
46
|
+
*
|
|
47
|
+
* The model runtime knows every model in the catalog it ships, including
|
|
48
|
+
* providers this account holds no credential for, and its own default is the
|
|
49
|
+
* catalog's — `opencode/kimi-k2.6` — not the account's. Starting there is not a
|
|
50
|
+
* cosmetic problem: the first prompt goes to a provider that cannot answer and
|
|
51
|
+
* comes back as an empty assistant message, with nothing in the transcript to
|
|
52
|
+
* explain why it looks hung.
|
|
53
|
+
*
|
|
54
|
+
* So the default comes from the models that are actually available, and a
|
|
55
|
+
* HowToFix provider wins over any other reachable one: an install that signed in
|
|
56
|
+
* to HowToFix should run HowToFix's models.
|
|
57
|
+
*/
|
|
58
|
+
export function defaultModelFor(available, ownProviders = []) {
|
|
59
|
+
if (available.length === 0)
|
|
60
|
+
return undefined;
|
|
61
|
+
const own = available.filter((model) => ownProviders.includes(model.provider));
|
|
62
|
+
const pool = own.length > 0 ? own : available;
|
|
63
|
+
const first = orderModelsForDefault(pool.map((model) => model.id))[0];
|
|
64
|
+
return pool.find((model) => model.id === first) ?? pool[0];
|
|
65
|
+
}
|
package/dist/notes.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shared scratchpad for facts that must survive context compaction and reach
|
|
3
|
+
* every agent working on the task.
|
|
4
|
+
*
|
|
5
|
+
* A shared notes store for the session.
|
|
6
|
+
* Why it matters: the `task` tool spawns subagents with their own context
|
|
7
|
+
* window, so anything the main agent learned while scouting is invisible to them,
|
|
8
|
+
* and anything they discover dies with their run. A note written once is visible
|
|
9
|
+
* to the parent and to every sibling, and it survives a compaction that would
|
|
10
|
+
* otherwise drop the discovery from the transcript.
|
|
11
|
+
*
|
|
12
|
+
* The store is process-wide on purpose: `agents.ts` opens subagent sessions in
|
|
13
|
+
* this same process, so they share it. It is in-memory only — notes are working
|
|
14
|
+
* state for one session, not project documentation (that is `AGENTS.md`).
|
|
15
|
+
*/
|
|
16
|
+
import { StringEnum } from "@netrodev/hxf-ai";
|
|
17
|
+
import { defineTool } from "@netrodev/hxf-coding-agent";
|
|
18
|
+
import { Type } from "typebox";
|
|
19
|
+
const notes = new Map();
|
|
20
|
+
/** Longest note accepted, so one call cannot flood every later request. */
|
|
21
|
+
const MAX_VALUE = 2000;
|
|
22
|
+
/** Longest key accepted; keys are the lookup handle, so they stay short. */
|
|
23
|
+
const MAX_KEY = 80;
|
|
24
|
+
export function clearNotes() {
|
|
25
|
+
notes.clear();
|
|
26
|
+
}
|
|
27
|
+
export function noteCount() {
|
|
28
|
+
return notes.size;
|
|
29
|
+
}
|
|
30
|
+
/** The block injected into the prompt, or an empty string when there is nothing. */
|
|
31
|
+
export function renderNotes() {
|
|
32
|
+
if (notes.size === 0)
|
|
33
|
+
return "";
|
|
34
|
+
const lines = [...notes].map(([key, value]) => `- ${key}: ${value}`);
|
|
35
|
+
return ["<shared-notes>", "Working notes recorded with the `context` tool. They survive compaction and are visible to every agent on this task.", ...lines, "</shared-notes>"].join("\n");
|
|
36
|
+
}
|
|
37
|
+
export function applyNote(op, key, value) {
|
|
38
|
+
if (op === "list") {
|
|
39
|
+
if (notes.size === 0)
|
|
40
|
+
return { text: "No notes." };
|
|
41
|
+
return { text: [...notes].map(([k, v]) => `${k} = ${v}`).join("\n") };
|
|
42
|
+
}
|
|
43
|
+
if (!key)
|
|
44
|
+
return { text: "Error: key is required", error: "key required" };
|
|
45
|
+
if (key.length > MAX_KEY)
|
|
46
|
+
return { text: `Error: key longer than ${MAX_KEY} characters`, error: "key too long" };
|
|
47
|
+
if (op === "forget") {
|
|
48
|
+
const existed = notes.delete(key);
|
|
49
|
+
return { text: existed ? `Removed ${key}` : `No note named ${key}` };
|
|
50
|
+
}
|
|
51
|
+
if (op === "set") {
|
|
52
|
+
if (!value)
|
|
53
|
+
return { text: "Error: value is required for set", error: "value required" };
|
|
54
|
+
const clipped = value.length > MAX_VALUE ? `${value.slice(0, MAX_VALUE)}…` : value;
|
|
55
|
+
notes.set(key, clipped);
|
|
56
|
+
return { text: `Recorded ${key} (${notes.size} note${notes.size === 1 ? "" : "s"} held)` };
|
|
57
|
+
}
|
|
58
|
+
return { text: `Error: unknown op ${JSON.stringify(op)}`, error: "unknown op" };
|
|
59
|
+
}
|
|
60
|
+
export const contextTool = defineTool({
|
|
61
|
+
name: "context",
|
|
62
|
+
label: "context",
|
|
63
|
+
description: "Record a durable working note (a decision, a file's role, a fact you would otherwise re-derive). Notes survive context compaction and are visible to every agent on this task, including subagents. Use `set` to write, `list` to read, `forget` to remove.",
|
|
64
|
+
promptSnippet: "context: durable notes shared with every agent on this task",
|
|
65
|
+
parameters: Type.Object({
|
|
66
|
+
op: StringEnum(["set", "list", "forget"], { description: "set writes a note, list reads all, forget removes one" }),
|
|
67
|
+
key: Type.Optional(Type.String({ description: "Short lookup key, e.g. 'auth-entrypoint'" })),
|
|
68
|
+
value: Type.Optional(Type.String({ description: "The fact to keep (for set)" })),
|
|
69
|
+
}),
|
|
70
|
+
async execute(_toolCallId, params) {
|
|
71
|
+
const { text, error } = applyNote(params.op, params.key, params.value);
|
|
72
|
+
return {
|
|
73
|
+
content: [{ type: "text", text }],
|
|
74
|
+
details: { op: params.op, notes: Object.fromEntries(notes) },
|
|
75
|
+
...(error ? { isError: true } : {}),
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
});
|