ashlr-plugin 1.36.4 → 1.37.1
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +49 -8
- package/commands/ashlr-doctor.md +29 -0
- package/commands/ashlr-status.md +1 -1
- package/docs/PHANTOM-USAGE.md +58 -0
- package/docs/architecture.md +5 -5
- package/docs/ashlr-handshake.md +36 -0
- package/docs/assets/og.svg +1 -1
- package/docs/benchmarks-v2.json +226 -177
- package/docs/benchmarks.md +20 -10
- package/docs/competitive-wozcode.md +2 -2
- package/docs/embeddings-gateway.md +39 -0
- package/docs/index.html +3 -3
- package/docs/legal.md +1 -1
- package/docs/llm-providers.md +15 -1
- package/docs/multi-host-mcp.md +25 -3
- package/docs/phantom-preflight.md +23 -0
- package/docs/team-genome.md +2 -1
- package/hooks/_hook-timing-ledger.ts +2 -9
- package/hooks/hooks.json +3 -106
- package/hooks/posttooluse-stale-result.ts +3 -0
- package/hooks/pretooluse-common.ts +1 -1
- package/hooks/pretooluse-notebookedit.ts +1 -1
- package/hooks/pretooluse-write.ts +1 -1
- package/hooks/session-start.ts +48 -26
- package/package.json +9 -4
- package/ports/cursor/mcp.json +1 -1
- package/scripts/_read-thresholds.ts +119 -0
- package/scripts/ashlr-mcp.mjs +15 -0
- package/scripts/ashlr-mcp.ts +3 -0
- package/scripts/ashlr-plugin.mjs +59 -0
- package/scripts/bench-read-real.ts +90 -0
- package/scripts/bootstrap.mjs +14 -4
- package/scripts/cli.ts +35 -10
- package/scripts/context-status.ts +7 -2
- package/scripts/deploy-smoke.sh +8 -4
- package/scripts/doctor.ts +171 -42
- package/scripts/genome-auto-consolidate.ts +4 -2
- package/scripts/genome-init.ts +3 -3
- package/scripts/genome-key.ts +64 -7
- package/scripts/handshake.mjs +51 -0
- package/scripts/hook-bootstrap.mjs +170 -17
- package/scripts/mcp-entrypoint.ts +3 -0
- package/scripts/ollama-setup.ts +5 -4
- package/scripts/run-benchmark.ts +28 -18
- package/scripts/upgrade-flow.ts +9 -14
- package/servers/_base-urls.ts +48 -0
- package/servers/_companions.ts +258 -0
- package/servers/_cwd-clamp.ts +38 -1
- package/servers/_edit-match.ts +12 -2
- package/servers/_embedding-model.ts +77 -0
- package/servers/_gateway-target.ts +179 -0
- package/servers/_llm-providers/anthropic.ts +10 -4
- package/servers/_llm-providers/cloud.ts +9 -7
- package/servers/_llm-providers/index.ts +8 -6
- package/servers/_llm-providers/local.ts +9 -5
- package/servers/_mcp-host.ts +6 -0
- package/servers/_miss-tracker.ts +121 -0
- package/servers/_phantom-exec.ts +135 -0
- package/servers/_phantom-preflight.ts +130 -0
- package/servers/_pid-alive.ts +40 -0
- package/servers/_pro-token-keychain.ts +149 -0
- package/servers/_redact-keys.ts +75 -0
- package/servers/_rg-text.ts +111 -0
- package/servers/_router.ts +20 -2
- package/servers/_run-with-timeout.ts +56 -12
- package/servers/_test-watch.ts +1 -10
- package/servers/_token-measure.ts +3 -1
- package/servers/_tool-base.ts +6 -0
- package/servers/bash-server.ts +1 -10
- package/servers/edit-server.ts +24 -1
- package/servers/efficiency-server.ts +12 -2
- package/servers/genome-server-handlers.ts +2 -2
- package/servers/genome-server.ts +17 -17
- package/servers/grep-server.ts +6 -2
- package/servers/orient-server-handlers.ts +3 -2
- package/servers/orient-server.ts +16 -9
- package/servers/read-server.ts +1 -1
- package/servers/write-server-handlers.ts +10 -2
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS keychain storage for ASHLR_PRO_TOKEN, with the old plaintext
|
|
3
|
+
* ~/.ashlr/env line as the fallback.
|
|
4
|
+
*
|
|
5
|
+
* macOS: `security -i` (login keychain, token on stdin). Linux: `secret-tool` (libsecret), which
|
|
6
|
+
* reads the secret from stdin. Anything else, or a backend that fails, falls
|
|
7
|
+
* back to the 0600 `export ASHLR_PRO_TOKEN=` line in ~/.ashlr/env.
|
|
8
|
+
* ASHLR_KEYCHAIN=0 turns the keychain off entirely.
|
|
9
|
+
*
|
|
10
|
+
* Token values are never logged or put in error messages.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from "child_process";
|
|
14
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
15
|
+
import { dirname } from "path";
|
|
16
|
+
|
|
17
|
+
export const KEYCHAIN_SERVICE = "ashlr-plugin";
|
|
18
|
+
export const KEYCHAIN_ACCOUNT = "ASHLR_PRO_TOKEN";
|
|
19
|
+
const ENV_LINE_PREFIX = "export ASHLR_PRO_TOKEN=";
|
|
20
|
+
/** The one-line warning shown when the token had to go to the plaintext file. */
|
|
21
|
+
export const PLAINTEXT_FALLBACK_WARNING =
|
|
22
|
+
"No usable OS keychain; ASHLR_PRO_TOKEN saved in plain text to ~/.ashlr/env (mode 0600).";
|
|
23
|
+
const SAFE_TOKEN = /^[A-Za-z0-9._~+\/=:-]+$/;
|
|
24
|
+
|
|
25
|
+
export interface RunResult {
|
|
26
|
+
status: number | null;
|
|
27
|
+
stdout: string;
|
|
28
|
+
}
|
|
29
|
+
export type Runner = (cmd: string, args: string[], input?: string) => RunResult;
|
|
30
|
+
|
|
31
|
+
export interface KeychainDeps {
|
|
32
|
+
platform?: NodeJS.Platform;
|
|
33
|
+
env?: NodeJS.ProcessEnv;
|
|
34
|
+
run?: Runner;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const defaultRunner: Runner = (cmd, args, input) => {
|
|
38
|
+
try {
|
|
39
|
+
const r = spawnSync(cmd, args, {
|
|
40
|
+
input,
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
timeout: 5000,
|
|
43
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
44
|
+
});
|
|
45
|
+
return { status: r.error ? null : r.status, stdout: r.stdout ?? "" };
|
|
46
|
+
} catch {
|
|
47
|
+
return { status: null, stdout: "" };
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function backend(deps: KeychainDeps): "darwin" | "linux" | null {
|
|
52
|
+
const env = deps.env ?? process.env;
|
|
53
|
+
if (env["ASHLR_KEYCHAIN"] === "0") return null;
|
|
54
|
+
const platform = deps.platform ?? process.platform;
|
|
55
|
+
if (platform === "darwin") return "darwin";
|
|
56
|
+
if (platform === "linux") return "linux";
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Store the token in the OS keychain. True only when the backend succeeded. */
|
|
61
|
+
export function keychainStore(token: string, deps: KeychainDeps = {}): boolean {
|
|
62
|
+
const run = deps.run ?? defaultRunner;
|
|
63
|
+
const b = backend(deps);
|
|
64
|
+
if (!b || !token) return false;
|
|
65
|
+
if (b === "darwin") {
|
|
66
|
+
// `security -i` reads the command from stdin, so the token never sits on
|
|
67
|
+
// argv (visible to `ps`). Its parser splits on whitespace and quotes, so
|
|
68
|
+
// only tokens of plain token characters go this way; anything else is
|
|
69
|
+
// left to the file fallback rather than risk a mangled value.
|
|
70
|
+
if (!SAFE_TOKEN.test(token)) return false;
|
|
71
|
+
return run(
|
|
72
|
+
"security",
|
|
73
|
+
["-i"],
|
|
74
|
+
`add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${KEYCHAIN_ACCOUNT} -w ${token}\n`,
|
|
75
|
+
).status === 0;
|
|
76
|
+
}
|
|
77
|
+
return run(
|
|
78
|
+
"secret-tool",
|
|
79
|
+
["store", "--label=ashlr-plugin pro token", "service", KEYCHAIN_SERVICE, "account", KEYCHAIN_ACCOUNT],
|
|
80
|
+
token,
|
|
81
|
+
).status === 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read the token from the OS keychain, or null. */
|
|
85
|
+
export function keychainLoad(deps: KeychainDeps = {}): string | null {
|
|
86
|
+
const run = deps.run ?? defaultRunner;
|
|
87
|
+
const b = backend(deps);
|
|
88
|
+
if (!b) return null;
|
|
89
|
+
const r = b === "darwin"
|
|
90
|
+
? run("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w"])
|
|
91
|
+
: run("secret-tool", ["lookup", "service", KEYCHAIN_SERVICE, "account", KEYCHAIN_ACCOUNT]);
|
|
92
|
+
if (r.status !== 0) return null;
|
|
93
|
+
const t = r.stdout.trim();
|
|
94
|
+
return t || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readEnv(envFile: string): string {
|
|
98
|
+
try { return readFileSync(envFile, "utf8"); } catch { return ""; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function writeEnv(envFile: string, lines: string[]): void {
|
|
102
|
+
mkdirSync(dirname(envFile), { recursive: true, mode: 0o700 });
|
|
103
|
+
const body = lines.join("\n").trim();
|
|
104
|
+
writeFileSync(envFile, body ? body + "\n" : "", { encoding: "utf8", mode: 0o600 });
|
|
105
|
+
try { chmodSync(envFile, 0o600); } catch { /* best-effort */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The token on the env file's `export ASHLR_PRO_TOKEN=` line, or null. */
|
|
109
|
+
export function envFileToken(envFile: string): string | null {
|
|
110
|
+
for (const line of readEnv(envFile).split("\n")) {
|
|
111
|
+
if (!line.startsWith(ENV_LINE_PREFIX)) continue;
|
|
112
|
+
let v = line.slice(ENV_LINE_PREFIX.length).trim();
|
|
113
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
114
|
+
return v || null;
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function withoutTokenLine(envFile: string): string[] {
|
|
120
|
+
return readEnv(envFile).split("\n").filter((l) => !l.startsWith(ENV_LINE_PREFIX));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Save the token: keychain first (and drop any plaintext line), otherwise the
|
|
125
|
+
* plaintext env line. Returns where it went.
|
|
126
|
+
*/
|
|
127
|
+
export function storeProToken(token: string, envFile: string, deps: KeychainDeps = {}): "keychain" | "file" {
|
|
128
|
+
if (keychainStore(token, deps) && keychainLoad(deps) === token) {
|
|
129
|
+
if (existsSync(envFile)) writeEnv(envFile, withoutTokenLine(envFile));
|
|
130
|
+
return "keychain";
|
|
131
|
+
}
|
|
132
|
+
const lines = withoutTokenLine(envFile);
|
|
133
|
+
lines.push(`${ENV_LINE_PREFIX}${token}`);
|
|
134
|
+
writeEnv(envFile, lines);
|
|
135
|
+
return "file";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* One-time migration: move a plaintext env-file token into the keychain. The
|
|
140
|
+
* line is removed only after the keychain reads the same value back, so a
|
|
141
|
+
* failed or partial store never loses the token.
|
|
142
|
+
*/
|
|
143
|
+
export function migrateEnvFileToken(envFile: string, deps: KeychainDeps = {}): "migrated" | "none" | "kept" {
|
|
144
|
+
const token = envFileToken(envFile);
|
|
145
|
+
if (!token) return "none";
|
|
146
|
+
if (!keychainStore(token, deps) || keychainLoad(deps) !== token) return "kept";
|
|
147
|
+
writeEnv(envFile, withoutTokenLine(envFile));
|
|
148
|
+
return "migrated";
|
|
149
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip key material from text that came back from a model endpoint before it
|
|
3
|
+
* reaches an error message, a log line or stderr.
|
|
4
|
+
*
|
|
5
|
+
* A gateway that rejects a credential may quote it back in its error body,
|
|
6
|
+
* whole or in part. Any run of 8 or more characters that also appears inside
|
|
7
|
+
* a configured key is replaced with "[redacted]".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Environment variables that can hold key material for a model endpoint. */
|
|
11
|
+
export const KEY_ENV_VARS = [
|
|
12
|
+
"ASHLR_LLM_KEY",
|
|
13
|
+
"ANTHROPIC_API_KEY",
|
|
14
|
+
"OPENAI_API_KEY",
|
|
15
|
+
"ASHLR_EMBED_KEY",
|
|
16
|
+
"ASHLR_PRO_TOKEN",
|
|
17
|
+
"PHANTOM_PROXY_TOKEN",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
const WINDOW = 8;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Redact every run of `WINDOW` or more characters of `text` that also occurs
|
|
24
|
+
* inside one of the configured key values or one of `extra`.
|
|
25
|
+
*/
|
|
26
|
+
export function redactKnownKeys(
|
|
27
|
+
text: string,
|
|
28
|
+
extra: Array<string | null | undefined> = [],
|
|
29
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
30
|
+
): string {
|
|
31
|
+
const secrets = [...KEY_ENV_VARS.map((v) => env[v]), ...extra].filter(
|
|
32
|
+
(s): s is string => typeof s === "string" && s.trim().length >= WINDOW,
|
|
33
|
+
);
|
|
34
|
+
if (secrets.length === 0 || text.length < WINDOW) return text;
|
|
35
|
+
|
|
36
|
+
const windows = new Set<string>();
|
|
37
|
+
for (const s of secrets) {
|
|
38
|
+
for (let i = 0; i + WINDOW <= s.length; i++)
|
|
39
|
+
windows.add(s.slice(i, i + WINDOW));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const covered = new Uint8Array(text.length);
|
|
43
|
+
let any = false;
|
|
44
|
+
for (let i = 0; i + WINDOW <= text.length; i++) {
|
|
45
|
+
if (windows.has(text.slice(i, i + WINDOW))) {
|
|
46
|
+
covered.fill(1, i, i + WINDOW);
|
|
47
|
+
any = true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!any) return text;
|
|
51
|
+
|
|
52
|
+
let out = "";
|
|
53
|
+
for (let i = 0; i < text.length; ) {
|
|
54
|
+
if (covered[i]) {
|
|
55
|
+
while (i < text.length && covered[i]) i++;
|
|
56
|
+
out += "[redacted]";
|
|
57
|
+
} else {
|
|
58
|
+
out += text[i++];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read a failed response's body for an error message: bounded, redacted, then
|
|
66
|
+
* cut to `max` characters.
|
|
67
|
+
*/
|
|
68
|
+
export async function redactedBody(
|
|
69
|
+
res: Response,
|
|
70
|
+
extra: Array<string | null | undefined> = [],
|
|
71
|
+
max = 200,
|
|
72
|
+
): Promise<string> {
|
|
73
|
+
const raw = await res.text().catch(() => "");
|
|
74
|
+
return redactKnownKeys(raw.slice(0, 4096), extra).slice(0, max);
|
|
75
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* _rg-text.ts — the ripgrep fallback for ashlr__grep, as plain text.
|
|
3
|
+
*
|
|
4
|
+
* The fallback used to run `rg --json` and cut the JSON stream to its first
|
|
5
|
+
* 2000 and last 1000 characters. JSON event lines are roughly three times the
|
|
6
|
+
* size of `path:line:text`, so the cut kept only a handful of matches (often
|
|
7
|
+
* as a half-open JSON object), and savings were counted against the JSON size
|
|
8
|
+
* rather than what a plain grep would have returned.
|
|
9
|
+
*
|
|
10
|
+
* Here ripgrep prints `path:line:text`; if that exceeds the budget, whole
|
|
11
|
+
* lines are kept from the head and tail and the omitted count is stated.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** ripgrep arguments for compact `path:line:text` output. */
|
|
15
|
+
export function rgTextArgs(pattern: string, searchRoot: string): string[] {
|
|
16
|
+
return [
|
|
17
|
+
"-n",
|
|
18
|
+
"--no-heading",
|
|
19
|
+
"--color",
|
|
20
|
+
"never",
|
|
21
|
+
// Minified bundles and lockfiles can put megabytes on one line.
|
|
22
|
+
"--max-columns",
|
|
23
|
+
"300",
|
|
24
|
+
"--max-columns-preview",
|
|
25
|
+
"--",
|
|
26
|
+
pattern,
|
|
27
|
+
searchRoot,
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const RG_TEXT_BUDGET = 4000;
|
|
32
|
+
const HEAD_BUDGET = 2600;
|
|
33
|
+
const TAIL_BUDGET = 1000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Why ripgrep stopped before finishing, or null when it ran to completion.
|
|
37
|
+
*
|
|
38
|
+
* A killed search still leaves its partial stdout behind: spawnSync keeps what
|
|
39
|
+
* it buffered on ENOBUFS and on its timeout, and runWithTimeout keeps the
|
|
40
|
+
* first 10 MB. Counting that as the whole result made the header state a
|
|
41
|
+
* total that was only how far ripgrep got, and the last line was usually cut
|
|
42
|
+
* mid-path.
|
|
43
|
+
*/
|
|
44
|
+
export function rgStopReason(res: {
|
|
45
|
+
error?: unknown;
|
|
46
|
+
signal?: string | null;
|
|
47
|
+
timedOut?: boolean;
|
|
48
|
+
stderr?: string | null;
|
|
49
|
+
}): string | null {
|
|
50
|
+
const code = (res.error as { code?: unknown } | undefined)?.code;
|
|
51
|
+
if (res.timedOut || code === "ETIMEDOUT") return "it timed out";
|
|
52
|
+
if (code === "ENOBUFS" || /stdout capped at/.test(res.stderr ?? "")) return "its output passed the size cap";
|
|
53
|
+
if (res.signal) return `it was killed (${res.signal})`;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Keep `raw` whole when it fits the budget; otherwise keep whole lines from
|
|
59
|
+
* the head and tail and say how many matching lines were omitted.
|
|
60
|
+
*
|
|
61
|
+
* `stoppedEarly` (from `rgStopReason`) marks `raw` as a prefix of the real
|
|
62
|
+
* result: the cut-off last line is dropped and the counts are stated as lower
|
|
63
|
+
* bounds, even when what is left fits the budget.
|
|
64
|
+
*/
|
|
65
|
+
export function truncateRgText(raw: string, stoppedEarly: string | null = null): string {
|
|
66
|
+
if (stoppedEarly) {
|
|
67
|
+
const complete = raw.endsWith("\n") ? raw : raw.slice(0, raw.lastIndexOf("\n") + 1);
|
|
68
|
+
const lines = complete.replace(/\n$/, "").split("\n").filter((l) => l.length > 0);
|
|
69
|
+
const note = `[ashlr__grep] ripgrep stopped early because ${stoppedEarly}; at least ${lines.length} matching lines in ${countFiles(lines)} files. Narrow the pattern or cwd.`;
|
|
70
|
+
if (complete.length <= RG_TEXT_BUDGET) return lines.length > 0 ? `${note}\n${lines.join("\n")}` : note;
|
|
71
|
+
return cut(lines, note);
|
|
72
|
+
}
|
|
73
|
+
if (raw.length <= RG_TEXT_BUDGET) return raw;
|
|
74
|
+
const lines = raw.replace(/\n$/, "").split("\n");
|
|
75
|
+
return cut(
|
|
76
|
+
lines,
|
|
77
|
+
`[ashlr__grep] ${lines.length} matching lines in ${countFiles(lines)} files; showing {shown}. Narrow the pattern or cwd to see the rest.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function countFiles(lines: readonly string[]): number {
|
|
82
|
+
const files = new Set<string>();
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
const m = /^(.*?):\d+:/.exec(line);
|
|
85
|
+
if (m) files.add(m[1]!);
|
|
86
|
+
}
|
|
87
|
+
return files.size;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cut(lines: readonly string[], header: string): string {
|
|
91
|
+
const head: string[] = [];
|
|
92
|
+
let used = 0;
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (used + line.length + 1 > HEAD_BUDGET && head.length > 0) break;
|
|
95
|
+
head.push(line);
|
|
96
|
+
used += line.length + 1;
|
|
97
|
+
}
|
|
98
|
+
const tail: string[] = [];
|
|
99
|
+
used = 0;
|
|
100
|
+
for (let i = lines.length - 1; i >= head.length; i--) {
|
|
101
|
+
const line = lines[i]!;
|
|
102
|
+
if (used + line.length + 1 > TAIL_BUDGET) break;
|
|
103
|
+
tail.unshift(line);
|
|
104
|
+
used += line.length + 1;
|
|
105
|
+
}
|
|
106
|
+
const omitted = lines.length - head.length - tail.length;
|
|
107
|
+
const parts = [header.replace("{shown}", String(head.length + tail.length)), ...head];
|
|
108
|
+
if (omitted > 0) parts.push(`[... ${omitted} matching lines omitted ...]`);
|
|
109
|
+
parts.push(...tail);
|
|
110
|
+
return parts.join("\n");
|
|
111
|
+
}
|
package/servers/_router.ts
CHANGED
|
@@ -24,7 +24,24 @@
|
|
|
24
24
|
|
|
25
25
|
import { listTools, runStandalone } from "./_tool-base";
|
|
26
26
|
import { detectMcpHost } from "./_mcp-host";
|
|
27
|
+
import { adoptLaunchCwd } from "./_cwd-clamp";
|
|
27
28
|
import "./_router-handlers";
|
|
29
|
+
import { readFileSync } from "fs";
|
|
30
|
+
import { join } from "path";
|
|
31
|
+
|
|
32
|
+
/** Reported in MCP `serverInfo` and the startup banner; ASHLR_VERSION overrides. */
|
|
33
|
+
function pluginVersion(): string {
|
|
34
|
+
if (process.env.ASHLR_VERSION) return process.env.ASHLR_VERSION;
|
|
35
|
+
try {
|
|
36
|
+
const pkg = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")) as { version?: unknown };
|
|
37
|
+
if (typeof pkg.version === "string" && pkg.version) return pkg.version;
|
|
38
|
+
} catch {
|
|
39
|
+
// A partial copy of the plugin without package.json: report "dev".
|
|
40
|
+
}
|
|
41
|
+
return "dev";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const VERSION = pluginVersion();
|
|
28
45
|
|
|
29
46
|
// ---------------------------------------------------------------------------
|
|
30
47
|
// Entry point
|
|
@@ -38,13 +55,14 @@ async function main(): Promise<void> {
|
|
|
38
55
|
process.exit(0);
|
|
39
56
|
}
|
|
40
57
|
|
|
58
|
+
adoptLaunchCwd();
|
|
41
59
|
const toolCount = listTools().length;
|
|
42
60
|
const host = detectMcpHost();
|
|
43
61
|
process.stderr.write(
|
|
44
|
-
`[ashlr-router] starting · ${toolCount} tools registered · version=${
|
|
62
|
+
`[ashlr-router] starting · ${toolCount} tools registered · version=${VERSION} · host=${host}\n`,
|
|
45
63
|
);
|
|
46
64
|
|
|
47
|
-
await runStandalone("ashlr-router",
|
|
65
|
+
await runStandalone("ashlr-router", VERSION);
|
|
48
66
|
}
|
|
49
67
|
|
|
50
68
|
if (import.meta.main) {
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
* - SIGTERM first, 250 ms grace, then SIGKILL.
|
|
13
13
|
* - Never throws on subprocess error — returns `exitCode: -1` with the error
|
|
14
14
|
* surfaced in `stderr`.
|
|
15
|
+
* - Always settles. `close` is the normal path, but it waits for every stdio
|
|
16
|
+
* pipe to end and has been seen not to fire at all (ashlr-plugin main run
|
|
17
|
+
* 39195707592, macOS: a `echo` call outlived its own 5 s timeout and hit the
|
|
18
|
+
* 15 s test timeout). So `exit` starts a short drain window after which we
|
|
19
|
+
* resolve with what was collected, and a hard deadline slightly past the
|
|
20
|
+
* kill grace resolves a run that never reports `exit` or `close`.
|
|
15
21
|
* - Stdout and stderr are each capped at 10 MB to prevent OOM on runaway
|
|
16
22
|
* output; excess bytes are silently dropped (the cap notice appears in
|
|
17
23
|
* stderr so callers can detect truncation).
|
|
@@ -19,6 +25,11 @@
|
|
|
19
25
|
|
|
20
26
|
import { spawn } from "child_process";
|
|
21
27
|
|
|
28
|
+
/** After `exit`, how long to wait for `close` (pipes drained) before settling. */
|
|
29
|
+
const EXIT_DRAIN_MS = 1_000;
|
|
30
|
+
/** Past the timeout and the 250 ms SIGKILL grace, how long before we give up. */
|
|
31
|
+
const HARD_DEADLINE_SLACK_MS = 2_000;
|
|
32
|
+
|
|
22
33
|
/** Maximum bytes buffered per stream before we stop accumulating. */
|
|
23
34
|
const STREAM_CAP = 10 * 1024 * 1024; // 10 MB
|
|
24
35
|
|
|
@@ -30,6 +41,13 @@ export interface RunWithTimeoutOptions {
|
|
|
30
41
|
timeoutMs: number;
|
|
31
42
|
/** Optional stdin to feed the child process (UTF-8 string). */
|
|
32
43
|
input?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Optional abort signal. Aborting kills the process group exactly like a
|
|
46
|
+
* timeout does (and reports `timedOut: true`). Callers that stop waiting
|
|
47
|
+
* early, such as a total cap racing several probes, use this so the child
|
|
48
|
+
* and its descendants do not outlive the caller.
|
|
49
|
+
*/
|
|
50
|
+
signal?: AbortSignal;
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
export interface RunWithTimeoutResult {
|
|
@@ -115,7 +133,10 @@ export function runWithTimeout(opts: RunWithTimeoutOptions): Promise<RunWithTime
|
|
|
115
133
|
}
|
|
116
134
|
|
|
117
135
|
/** Kill the process tree and set timedOut flag. */
|
|
136
|
+
let killed = false;
|
|
118
137
|
function killTree(): void {
|
|
138
|
+
if (killed) return;
|
|
139
|
+
killed = true;
|
|
119
140
|
timedOut = true;
|
|
120
141
|
if (isWin) {
|
|
121
142
|
// taskkill /T cascades through child processes on Windows.
|
|
@@ -148,26 +169,49 @@ export function runWithTimeout(opts: RunWithTimeoutOptions): Promise<RunWithTime
|
|
|
148
169
|
}
|
|
149
170
|
}
|
|
150
171
|
|
|
172
|
+
let settled = false;
|
|
173
|
+
let drainTimer: ReturnType<typeof setTimeout> | undefined;
|
|
174
|
+
let exitCode: number | null = null;
|
|
175
|
+
|
|
151
176
|
const timer = setTimeout(killTree, opts.timeoutMs);
|
|
177
|
+
const onAbort = (): void => killTree();
|
|
178
|
+
if (opts.signal) {
|
|
179
|
+
if (opts.signal.aborted) killTree();
|
|
180
|
+
else opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
181
|
+
}
|
|
182
|
+
const deadline = setTimeout(
|
|
183
|
+
() => settle(exitCode, "hard deadline"),
|
|
184
|
+
opts.timeoutMs + 250 + HARD_DEADLINE_SLACK_MS,
|
|
185
|
+
);
|
|
152
186
|
|
|
153
|
-
|
|
187
|
+
/** Resolve exactly once and clear every timer. */
|
|
188
|
+
function settle(code: number | null, note?: string, spawnErr?: Error): void {
|
|
189
|
+
if (settled) return;
|
|
190
|
+
settled = true;
|
|
154
191
|
clearTimeout(timer);
|
|
192
|
+
clearTimeout(deadline);
|
|
193
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
194
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
195
|
+
let err = stderr;
|
|
196
|
+
if (spawnErr) err += `\n[spawn error: ${spawnErr.message}]`;
|
|
197
|
+
else if (note && code === null) err += `\n[_run-with-timeout: settled by ${note}]`;
|
|
155
198
|
resolveP({
|
|
156
199
|
stdout,
|
|
157
|
-
stderr,
|
|
158
|
-
exitCode:
|
|
200
|
+
stderr: err,
|
|
201
|
+
exitCode: spawnErr ? -1 : (code ?? -1),
|
|
159
202
|
timedOut,
|
|
160
203
|
});
|
|
161
|
-
}
|
|
204
|
+
}
|
|
162
205
|
|
|
163
|
-
child.on("
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
exitCode: -1,
|
|
169
|
-
timedOut,
|
|
170
|
-
});
|
|
206
|
+
child.on("exit", (code) => {
|
|
207
|
+
exitCode = code;
|
|
208
|
+
// `close` normally follows within milliseconds. If it does not (a pipe
|
|
209
|
+
// held open, or a lost event), settle with what we have.
|
|
210
|
+
drainTimer = setTimeout(() => settle(code, "exit drain"), EXIT_DRAIN_MS);
|
|
171
211
|
});
|
|
212
|
+
|
|
213
|
+
child.on("close", (code) => settle(code ?? exitCode));
|
|
214
|
+
|
|
215
|
+
child.on("error", (err) => settle(null, undefined, err));
|
|
172
216
|
});
|
|
173
217
|
}
|
package/servers/_test-watch.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { homedir } from "os";
|
|
|
34
34
|
import { join, sep } from "path";
|
|
35
35
|
import { randomBytes } from "crypto";
|
|
36
36
|
import { registerExternalSessions, type ExternalSession } from "./bash-server";
|
|
37
|
+
import { pidAlive } from "./_pid-alive";
|
|
37
38
|
|
|
38
39
|
// ---------------------------------------------------------------------------
|
|
39
40
|
// Constants
|
|
@@ -180,16 +181,6 @@ export async function reloadWatchSessions(): Promise<void> {
|
|
|
180
181
|
}
|
|
181
182
|
}
|
|
182
183
|
|
|
183
|
-
function pidAlive(pid: number): boolean {
|
|
184
|
-
if (!pid || pid <= 0) return false;
|
|
185
|
-
try {
|
|
186
|
-
process.kill(pid, 0);
|
|
187
|
-
return true;
|
|
188
|
-
} catch {
|
|
189
|
-
return false;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
184
|
// ---------------------------------------------------------------------------
|
|
194
185
|
// File watching
|
|
195
186
|
// ---------------------------------------------------------------------------
|
|
@@ -16,7 +16,9 @@ import { createHash } from "crypto";
|
|
|
16
16
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
17
17
|
import { homedir } from "os";
|
|
18
18
|
import { join } from "path";
|
|
19
|
+
import { anthropicBaseUrl } from "./_base-urls.ts";
|
|
19
20
|
import { estimateTokensFromString } from "@ashlr/core-efficiency";
|
|
21
|
+
import { safeKeyedFetch } from "./_gateway-target.ts";
|
|
20
22
|
|
|
21
23
|
export type MeasurementMode = "estimate" | "measured";
|
|
22
24
|
|
|
@@ -126,7 +128,7 @@ export async function measureTokens(text: string): Promise<number | null> {
|
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
try {
|
|
129
|
-
const res = await
|
|
131
|
+
const res = await safeKeyedFetch(`${anthropicBaseUrl()}/v1/messages`, {
|
|
130
132
|
method: "POST",
|
|
131
133
|
signal: AbortSignal.timeout(5_000),
|
|
132
134
|
headers: {
|
package/servers/_tool-base.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { writeCrashDump } from "./_crash-dump";
|
|
|
27
27
|
import { openContextDb, type ContextDb } from "./_embedding-cache";
|
|
28
28
|
import { recordTelemetryEvent, isTelemetryEnabled } from "./_telemetry";
|
|
29
29
|
import { detectMcpHost, type McpHost } from "./_mcp-host";
|
|
30
|
+
import { isPathRefusal } from "./_cwd-clamp";
|
|
30
31
|
|
|
31
32
|
/** Protocol-native guidance applies to every MCP host without host-specific hooks. */
|
|
32
33
|
export const MCP_EFFICIENCY_INSTRUCTIONS = [
|
|
@@ -218,6 +219,11 @@ export async function runStandalone(
|
|
|
218
219
|
});
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
// Path refusals come back from many handlers as plain text; flag them so
|
|
223
|
+
// hosts and agents see a failed call, not a successful read of a message.
|
|
224
|
+
const first = (result as { content?: Array<{ text?: unknown }> })?.content?.[0];
|
|
225
|
+
if (first && isPathRefusal(first.text)) (result as { isError?: boolean }).isError = true;
|
|
226
|
+
|
|
221
227
|
return result as { content: unknown[] };
|
|
222
228
|
} catch (err) {
|
|
223
229
|
// Per-handler crash isolation: one handler's throw must not take the
|
package/servers/bash-server.ts
CHANGED
|
@@ -60,6 +60,7 @@ import { summarizeIfLarge, PROMPTS, confidenceBadge, confidenceTier } from "./_s
|
|
|
60
60
|
import { recordSaving as recordSavingCore } from "./_stats";
|
|
61
61
|
import { logEvent } from "./_events";
|
|
62
62
|
import { clampToCwd } from "./_cwd-clamp";
|
|
63
|
+
import { pidAlive } from "./_pid-alive";
|
|
63
64
|
|
|
64
65
|
// Bash always records under the "ashlr__bash" tool bucket.
|
|
65
66
|
async function recordSaving(rawBytes: number, compactBytes: number): Promise<number> {
|
|
@@ -587,16 +588,6 @@ interface PersistedSession {
|
|
|
587
588
|
cumulativeBytes: number;
|
|
588
589
|
}
|
|
589
590
|
|
|
590
|
-
function pidAlive(pid: number): boolean {
|
|
591
|
-
if (!pid || pid <= 0) return false;
|
|
592
|
-
try {
|
|
593
|
-
process.kill(pid, 0);
|
|
594
|
-
return true;
|
|
595
|
-
} catch {
|
|
596
|
-
return false;
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
591
|
async function persistSessions(): Promise<void> {
|
|
601
592
|
await mkdir(dirname(SESSIONS_PATH), { recursive: true });
|
|
602
593
|
const payload: PersistedSession[] = [];
|
package/servers/edit-server.ts
CHANGED
|
@@ -37,14 +37,27 @@ export interface EditResult {
|
|
|
37
37
|
|
|
38
38
|
const FUZZY_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB
|
|
39
39
|
|
|
40
|
+
const TINY_EDIT_CHARS = 120;
|
|
41
|
+
|
|
40
42
|
function summarizeEdit(
|
|
41
43
|
relPath: string,
|
|
42
44
|
search: string,
|
|
43
45
|
replace: string,
|
|
44
46
|
matchCount: number,
|
|
45
47
|
strict: boolean,
|
|
48
|
+
/** Echo the first line of what was removed and added. Only worth its tokens when the match was not exact. */
|
|
49
|
+
echo = false,
|
|
46
50
|
): string {
|
|
47
51
|
const first = (s: string) => s.split("\n")[0]?.slice(0, 72) ?? "";
|
|
52
|
+
// Tiny edits: the removed/added detail lines cost more than the edit itself,
|
|
53
|
+
// and the caller already knows what it sent. Return the header only.
|
|
54
|
+
// Same for an exact, unique match: the hunk applied exactly as sent, so the
|
|
55
|
+
// echo is the caller's own text played back. Fuzzy matches and replace-all
|
|
56
|
+
// edits keep the detail lines.
|
|
57
|
+
// `echo` (fuzzy) always keeps them: the caller cannot know what was replaced.
|
|
58
|
+
if (!echo && (search.length + replace.length < TINY_EDIT_CHARS || (strict && matchCount === 1))) {
|
|
59
|
+
return `[ashlr__edit] ${relPath} · ${strict ? "1 of " + matchCount : matchCount + " of " + matchCount} hunks applied`;
|
|
60
|
+
}
|
|
48
61
|
return [
|
|
49
62
|
`[ashlr__edit] ${relPath} · ${strict ? "1 of " + matchCount : matchCount + " of " + matchCount} hunks applied`,
|
|
50
63
|
` - removed (${estimateTokensFromString(search)} tok): ${first(search)}${search.length > 72 ? "…" : ""}`,
|
|
@@ -105,7 +118,9 @@ export async function ashlrEdit(input: EditArgs): Promise<EditResult> {
|
|
|
105
118
|
refreshGenomeAfterEdit(abs, original, updated).catch(() => {});
|
|
106
119
|
|
|
107
120
|
const baseBytes = search.length + replace.length;
|
|
108
|
-
|
|
121
|
+
// Echo the file text the fuzzy match actually replaced, not the caller's search.
|
|
122
|
+
const matched = original.slice(fuzzyMatch.start, fuzzyMatch.end);
|
|
123
|
+
const compactSummary = summarizeEdit(relPath, matched, replace, 1, true, true);
|
|
109
124
|
await recordSaving(baseBytes, compactSummary.length, "ashlr__edit");
|
|
110
125
|
|
|
111
126
|
appendEdit({ relPath, hunksApplied: 1 });
|
|
@@ -135,6 +150,14 @@ export async function ashlrEdit(input: EditArgs): Promise<EditResult> {
|
|
|
135
150
|
throw new Error(`ashlr__edit: search string not found in ${relPath}`);
|
|
136
151
|
}
|
|
137
152
|
|
|
153
|
+
// Strict uniqueness must count overlapping occurrences too: "}\n}" occurs
|
|
154
|
+
// twice in "}\n}\n}" even though a non-overlapping scan finds it once.
|
|
155
|
+
if (strict && count === 1 && original.indexOf(search, original.indexOf(search) + 1) !== -1) {
|
|
156
|
+
let overlapping = 0;
|
|
157
|
+
for (let i = original.indexOf(search); i !== -1; i = original.indexOf(search, i + 1)) overlapping++;
|
|
158
|
+
count = overlapping;
|
|
159
|
+
}
|
|
160
|
+
|
|
138
161
|
if (strict && count > 1) {
|
|
139
162
|
throw new Error(
|
|
140
163
|
`ashlr__edit: search string matched ${count} times in ${relPath}; pass strict:false to replace all, or widen the context to a unique span.`,
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
type SessionBucket,
|
|
47
47
|
} from "./_stats";
|
|
48
48
|
import { clampToCwd } from "./_cwd-clamp";
|
|
49
|
+
import { rgStopReason, rgTextArgs, truncateRgText } from "./_rg-text";
|
|
49
50
|
import { getEmbeddingCache } from "./_tool-base";
|
|
50
51
|
import { embed, upsertCorpus } from "./_embedding-model";
|
|
51
52
|
import { populateGenomeEmbeddings } from "./_genome-embed-populator";
|
|
@@ -809,11 +810,13 @@ export async function ashlrGrep(input: { pattern: string; cwd?: string; bypassSu
|
|
|
809
810
|
|
|
810
811
|
const res = await runWithTimeout({
|
|
811
812
|
command: rgBin,
|
|
812
|
-
args:
|
|
813
|
+
args: rgTextArgs(input.pattern, cwd),
|
|
813
814
|
timeoutMs: 15_000,
|
|
814
815
|
});
|
|
816
|
+
// `raw` is what a plain grep would have returned, so savings are counted
|
|
817
|
+
// against that, not against a verbose wire format.
|
|
815
818
|
const raw = res.stdout ?? "";
|
|
816
|
-
const truncated = raw
|
|
819
|
+
const truncated = truncateRgText(raw, rgStopReason(res));
|
|
817
820
|
const summarized = await summarizeIfLarge(truncated, {
|
|
818
821
|
toolName: "ashlr__grep",
|
|
819
822
|
systemPrompt: PROMPTS.grep,
|
|
@@ -1006,6 +1009,8 @@ export async function ashlrEdit(input: EditArgs): Promise<EditResult> {
|
|
|
1006
1009
|
return { text: compactSummary, hunksApplied };
|
|
1007
1010
|
}
|
|
1008
1011
|
|
|
1012
|
+
const TINY_EDIT_CHARS = 120;
|
|
1013
|
+
|
|
1009
1014
|
function summarizeEdit(
|
|
1010
1015
|
relPath: string,
|
|
1011
1016
|
search: string,
|
|
@@ -1014,6 +1019,11 @@ function summarizeEdit(
|
|
|
1014
1019
|
strict: boolean,
|
|
1015
1020
|
): string {
|
|
1016
1021
|
const first = (s: string) => s.split("\n")[0]?.slice(0, 72) ?? "";
|
|
1022
|
+
// Tiny edits: the removed/added detail lines cost more than the edit itself,
|
|
1023
|
+
// and the caller already knows what it sent. Return the header only.
|
|
1024
|
+
if (search.length + replace.length < TINY_EDIT_CHARS) {
|
|
1025
|
+
return `[ashlr__edit] ${relPath} · ${strict ? "1 of " + matchCount : matchCount + " of " + matchCount} hunks applied`;
|
|
1026
|
+
}
|
|
1017
1027
|
return [
|
|
1018
1028
|
`[ashlr__edit] ${relPath} · ${strict ? "1 of " + matchCount : matchCount + " of " + matchCount} hunks applied`,
|
|
1019
1029
|
` - removed (${estimateTokensFromString(search)} tok): ${first(search)}${search.length > 72 ? "…" : ""}`,
|