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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/_read-thresholds.ts
|
|
3
|
+
*
|
|
4
|
+
* Report-only threshold recommendations for scripts/bench-read-real.ts.
|
|
5
|
+
* Pure functions: no I/O, no defaults changed anywhere. The bench prints the
|
|
6
|
+
* result next to the current defaults so a human can decide.
|
|
7
|
+
*
|
|
8
|
+
* Derivation (break-even by size bin):
|
|
9
|
+
* 1. Put each file in a fixed-width byte bin (default 256 bytes).
|
|
10
|
+
* 2. Ignore bins with fewer than `minFiles` files (default 3) as too sparse.
|
|
11
|
+
* 3. A bin "wins" when its aggregate candidate tokens are no more than its
|
|
12
|
+
* aggregate baseline tokens.
|
|
13
|
+
* 4. The recommendation is the lower edge of the earliest qualified bin from
|
|
14
|
+
* which every qualified bin upward wins. If the largest qualified bin
|
|
15
|
+
* loses, there is no recommendation.
|
|
16
|
+
* 5. If the very first qualified bin already wins, the data cannot see the
|
|
17
|
+
* true break-even, so the result is marked as bounded by the smallest
|
|
18
|
+
* sampled size ("at or below").
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface ReadSample {
|
|
22
|
+
/** File size in bytes. */
|
|
23
|
+
bytes: number;
|
|
24
|
+
/** Baseline tokens (raw file, or the snip view for the AST comparison). */
|
|
25
|
+
baseline: number;
|
|
26
|
+
/** Candidate tokens (ashlr__read output, or the AST view). */
|
|
27
|
+
candidate: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BinStat {
|
|
31
|
+
/** Inclusive lower edge in bytes. */
|
|
32
|
+
from: number;
|
|
33
|
+
files: number;
|
|
34
|
+
baseline: number;
|
|
35
|
+
candidate: number;
|
|
36
|
+
wins: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BreakEven {
|
|
40
|
+
/** Recommended threshold in bytes, or null when no size range reliably wins. */
|
|
41
|
+
bytes: number | null;
|
|
42
|
+
/** True when the first qualified bin already wins (true break-even may be lower). */
|
|
43
|
+
atOrBelow: boolean;
|
|
44
|
+
samples: number;
|
|
45
|
+
qualifiedBins: number;
|
|
46
|
+
bins: BinStat[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface BreakEvenOptions {
|
|
50
|
+
binBytes?: number;
|
|
51
|
+
minFiles?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function deriveBreakEven(samples: ReadSample[], opts: BreakEvenOptions = {}): BreakEven {
|
|
55
|
+
const binBytes = opts.binBytes ?? 256;
|
|
56
|
+
const minFiles = opts.minFiles ?? 3;
|
|
57
|
+
const byBin = new Map<number, BinStat>();
|
|
58
|
+
for (const s of samples) {
|
|
59
|
+
if (!Number.isFinite(s.bytes) || s.bytes < 0) continue;
|
|
60
|
+
const from = Math.floor(s.bytes / binBytes) * binBytes;
|
|
61
|
+
const bin = byBin.get(from) ?? { from, files: 0, baseline: 0, candidate: 0, wins: false };
|
|
62
|
+
bin.files++;
|
|
63
|
+
bin.baseline += s.baseline;
|
|
64
|
+
bin.candidate += s.candidate;
|
|
65
|
+
byBin.set(from, bin);
|
|
66
|
+
}
|
|
67
|
+
const bins = [...byBin.values()].sort((a, b) => a.from - b.from);
|
|
68
|
+
for (const b of bins) b.wins = b.candidate <= b.baseline;
|
|
69
|
+
const qualified = bins.filter((b) => b.files >= minFiles);
|
|
70
|
+
let start = qualified.length;
|
|
71
|
+
while (start > 0 && qualified[start - 1]!.wins) start--;
|
|
72
|
+
const found = start < qualified.length;
|
|
73
|
+
return {
|
|
74
|
+
bytes: found ? qualified[start]!.from : null,
|
|
75
|
+
atOrBelow: found && start === 0,
|
|
76
|
+
samples: samples.length,
|
|
77
|
+
qualifiedBins: qualified.length,
|
|
78
|
+
bins,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ThresholdReport {
|
|
83
|
+
name: string;
|
|
84
|
+
current: number;
|
|
85
|
+
derivation: string;
|
|
86
|
+
result: BreakEven;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function describe(r: BreakEven): string {
|
|
90
|
+
if (r.bytes === null) return "none (no size range where every bin breaks even)";
|
|
91
|
+
return r.atOrBelow ? `<= ${r.bytes} B (wins from the smallest sampled bin)` : `${r.bytes} B`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Lines the bench prints. Always states that defaults are unchanged. */
|
|
95
|
+
export function formatThresholdReport(
|
|
96
|
+
reports: ThresholdReport[],
|
|
97
|
+
ctx: { every: number; binBytes: number; minFiles: number },
|
|
98
|
+
): string[] {
|
|
99
|
+
const out: string[] = [];
|
|
100
|
+
out.push("");
|
|
101
|
+
out.push("Recommended read thresholds (report only, no default is changed)");
|
|
102
|
+
for (const r of reports) {
|
|
103
|
+
out.push(
|
|
104
|
+
` ${r.name.padEnd(22)} current ${String(r.current).padStart(5)} B recommended ${describe(r.result)}`,
|
|
105
|
+
);
|
|
106
|
+
out.push(
|
|
107
|
+
` from ${r.result.samples} files, ${r.result.qualifiedBins} bins with >= ${ctx.minFiles} files; ${r.derivation}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
out.push(
|
|
111
|
+
` How: ${ctx.binBytes} B size bins; the threshold is the lower edge of the first bin from which every bin`,
|
|
112
|
+
);
|
|
113
|
+
out.push(" with enough files has candidate tokens <= baseline tokens.");
|
|
114
|
+
out.push(
|
|
115
|
+
` Caveat: derived from this repo's own tracked files (every Nth file, N=${ctx.every}) with estimated tokens,`,
|
|
116
|
+
);
|
|
117
|
+
out.push(" not a real tokenizer. Synthetic; other codebases will differ. Review before changing any default.");
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ashlr-mcp package bin — node launcher for the stdio MCP router.
|
|
4
|
+
*
|
|
5
|
+
* npm installs this as `ashlr-mcp`. It runs under node so a missing bun gets
|
|
6
|
+
* the same first-run handling as the plugin manifests (scripts/bootstrap.mjs:
|
|
7
|
+
* find ~/.bun/bin, else install bun unless ASHLR_NO_AUTO_INSTALL=1) instead of
|
|
8
|
+
* `/usr/bin/env: 'bun': No such file or directory`. scripts/ashlr-mcp.ts stays
|
|
9
|
+
* the source-checkout entry (`bun run scripts/ashlr-mcp.ts`).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
process.env.ASHLR_MCP_HOST ||= "generic";
|
|
13
|
+
process.argv.splice(2, 0, "servers/_router.ts");
|
|
14
|
+
|
|
15
|
+
await import("./bootstrap.mjs");
|
package/scripts/ashlr-mcp.ts
CHANGED
|
@@ -15,6 +15,9 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
15
15
|
const PLUGIN_ROOT = resolve(dirname(__filename), "..");
|
|
16
16
|
|
|
17
17
|
process.env.ASHLR_MCP_HOST ||= "generic";
|
|
18
|
+
// The router runs from PLUGIN_ROOT; keep the launch folder (the user's project)
|
|
19
|
+
// so the cwd clamp allows it.
|
|
20
|
+
process.env.ASHLR_LAUNCH_CWD ||= process.cwd();
|
|
18
21
|
|
|
19
22
|
const entrypoint = join(PLUGIN_ROOT, "scripts", "mcp-entrypoint.ts");
|
|
20
23
|
const child = Bun.spawn(
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ashlr-plugin package bin — node launcher for scripts/cli.ts.
|
|
4
|
+
*
|
|
5
|
+
* npm installs this as `ashlr-plugin`. The CLI itself runs under bun; this
|
|
6
|
+
* shim finds bun (PATH, then ~/.bun/bin) and otherwise explains how to get it,
|
|
7
|
+
* instead of failing with `/usr/bin/env: 'bun': No such file or directory`.
|
|
8
|
+
* It never installs anything.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { hasBun, prependBunToPath } from "./bun-resolve.mjs";
|
|
16
|
+
import { buildHandshake, wantsHandshake } from "./handshake.mjs";
|
|
17
|
+
|
|
18
|
+
if (!hasBun()) prependBunToPath();
|
|
19
|
+
|
|
20
|
+
// Companion handshake (docs/ashlr-handshake.md): answered here, before the
|
|
21
|
+
// bun check, so a machine without bun still gets an answer saying so.
|
|
22
|
+
if (wantsHandshake(process.argv.slice(2))) {
|
|
23
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
25
|
+
const phantom = spawnSync("phantom", ["--version"], { stdio: "ignore" }).status === 0;
|
|
26
|
+
process.stdout.write(`${JSON.stringify(buildHandshake(version, { bun: hasBun(), phantom }))}\n`);
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
if (!hasBun()) {
|
|
30
|
+
process.stderr.write(
|
|
31
|
+
"ashlr-plugin needs Bun 1.3 or newer, and bun was not found on PATH or in ~/.bun/bin.\n" +
|
|
32
|
+
"Install it: curl -fsSL https://bun.sh/install | bash (Windows: irm bun.sh/install.ps1 | iex)\n" +
|
|
33
|
+
"Then open a new terminal and run ashlr-plugin again.\n",
|
|
34
|
+
);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const cli = join(dirname(fileURLToPath(import.meta.url)), "cli.ts");
|
|
39
|
+
const child = spawn("bun", ["run", cli, ...process.argv.slice(2)], { stdio: "inherit", env: process.env });
|
|
40
|
+
|
|
41
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
42
|
+
process.on(signal, () => {
|
|
43
|
+
try {
|
|
44
|
+
child.kill(signal);
|
|
45
|
+
} catch {
|
|
46
|
+
// Child may already be gone.
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
child.on("exit", (code, signal) => {
|
|
52
|
+
if (signal) {
|
|
53
|
+
// Our own SIGINT/SIGTERM listeners above replace node's default action,
|
|
54
|
+
// so re-raising with them attached would just exit 0. Drop them first so
|
|
55
|
+
// the signal terminates this process the way it terminated the child.
|
|
56
|
+
process.removeAllListeners(signal);
|
|
57
|
+
process.kill(process.pid, signal);
|
|
58
|
+
} else process.exit(code ?? 0);
|
|
59
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* scripts/bench-read-real.ts
|
|
4
|
+
*
|
|
5
|
+
* Measures the real ashlrRead handler (line numbering, snip, badge included)
|
|
6
|
+
* against raw file tokens, bucketed by file size. run-benchmark.ts mirrors only
|
|
7
|
+
* snipCompact, so it cannot see per-line overhead on small files.
|
|
8
|
+
*
|
|
9
|
+
* bun run scripts/bench-read-real.ts [--every N] # every Nth tracked text file (default 3)
|
|
10
|
+
*
|
|
11
|
+
* Synthetic: token counts come from estimateTokensFromString, not a real tokenizer.
|
|
12
|
+
* Same-commit comparison: run it before and after a change on the same file list.
|
|
13
|
+
*
|
|
14
|
+
* It also prints report-only recommended thresholds (see _read-thresholds.ts):
|
|
15
|
+
* - read redirect: where ashlr__read output stops being larger than the raw file
|
|
16
|
+
* - AST skeleton: where the AST view beats the snip view for TS/JS files
|
|
17
|
+
* No default is changed. Pass --no-thresholds to skip the extra AST reads.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from "fs";
|
|
20
|
+
import { execSync } from "child_process";
|
|
21
|
+
import { resolve } from "path";
|
|
22
|
+
import { AST_THRESHOLD, ashlrRead } from "../servers/read-server.ts";
|
|
23
|
+
import { invalidateCached } from "../servers/_read-cache.ts";
|
|
24
|
+
import { NUDGE_READ_THRESHOLD } from "../hooks/pretooluse-common.ts";
|
|
25
|
+
import { deriveBreakEven, formatThresholdReport, type ReadSample } from "./_read-thresholds.ts";
|
|
26
|
+
import { estimateTokensFromString } from "@ashlr/core-efficiency";
|
|
27
|
+
|
|
28
|
+
const every = Number(process.argv[process.argv.indexOf("--every") + 1]) || 3;
|
|
29
|
+
const files = execSync(
|
|
30
|
+
"git ls-files -- '*.ts' '*.md' '*.json' '*.sh' '*.tsx' | grep -v -E 'bun.lock|^bench/refs|^video|^site|^scripts/bench-read-real'",
|
|
31
|
+
{ encoding: "utf-8", maxBuffer: 1e8 },
|
|
32
|
+
).trim().split("\n");
|
|
33
|
+
const bucket = (n: number) => n < 1000 ? "a <1K" : n < 1700 ? "b 1-1.7K" : n < 2500 ? "c 1.7-2.5K" : n < 5000 ? "d 2.5-5K" : n < 15000 ? "e 5-15K" : "f 15K+";
|
|
34
|
+
const b: Record<string, { n: number; r: number; a: number; worse: number }> = {};
|
|
35
|
+
const thresholds = !process.argv.includes("--no-thresholds");
|
|
36
|
+
const readSamples: ReadSample[] = [];
|
|
37
|
+
const astSamples: ReadSample[] = [];
|
|
38
|
+
const astLangs = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
39
|
+
let tr = 0, ta = 0, k = 0;
|
|
40
|
+
for (const f of files) {
|
|
41
|
+
let raw: string;
|
|
42
|
+
try { raw = readFileSync(f, "utf-8"); } catch { continue; }
|
|
43
|
+
if (!raw.length || raw.length > 400000) continue;
|
|
44
|
+
if (k++ % every) continue;
|
|
45
|
+
let out: string;
|
|
46
|
+
try { out = await ashlrRead({ path: f }); } catch { continue; }
|
|
47
|
+
const r = estimateTokensFromString(raw), a = estimateTokensFromString(out);
|
|
48
|
+
tr += r; ta += a;
|
|
49
|
+
const x = (b[bucket(raw.length)] ??= { n: 0, r: 0, a: 0, worse: 0 });
|
|
50
|
+
x.n++; x.r += r; x.a += a; if (a > r) x.worse++;
|
|
51
|
+
readSamples.push({ bytes: raw.length, baseline: r, candidate: a });
|
|
52
|
+
if (thresholds && astLangs.test(f)) {
|
|
53
|
+
// Compare the two views directly; drop the cache entry between reads.
|
|
54
|
+
const abs = resolve(f);
|
|
55
|
+
try {
|
|
56
|
+
invalidateCached(abs);
|
|
57
|
+
const snip = await ashlrRead({ path: f, mode: "snip" });
|
|
58
|
+
invalidateCached(abs);
|
|
59
|
+
const ast = await ashlrRead({ path: f, mode: "ast" });
|
|
60
|
+
invalidateCached(abs);
|
|
61
|
+
astSamples.push({ bytes: raw.length, baseline: estimateTokensFromString(snip), candidate: estimateTokensFromString(ast) });
|
|
62
|
+
} catch { /* skip files either view cannot read */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const key of Object.keys(b).sort()) {
|
|
66
|
+
const v = b[key]!;
|
|
67
|
+
console.log(key.slice(2).padEnd(10), "files", String(v.n).padStart(4), "raw", String(v.r).padStart(7), "ashlr", String(v.a).padStart(7), ((v.a / v.r - 1) * 100).toFixed(1).padStart(6) + "%", "worse-than-raw", v.worse);
|
|
68
|
+
}
|
|
69
|
+
console.log("TOTAL".padEnd(10), "raw", tr, "ashlr", ta, ((ta / tr - 1) * 100).toFixed(1) + "%");
|
|
70
|
+
if (thresholds) {
|
|
71
|
+
const binBytes = 256, minFiles = 3;
|
|
72
|
+
const lines = formatThresholdReport(
|
|
73
|
+
[
|
|
74
|
+
{
|
|
75
|
+
name: "read redirect",
|
|
76
|
+
current: NUDGE_READ_THRESHOLD,
|
|
77
|
+
derivation: "candidate = ashlr__read output, baseline = raw file",
|
|
78
|
+
result: deriveBreakEven(readSamples, { binBytes, minFiles }),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "AST skeleton (TS/JS)",
|
|
82
|
+
current: AST_THRESHOLD,
|
|
83
|
+
derivation: "candidate = AST view, baseline = snip view",
|
|
84
|
+
result: deriveBreakEven(astSamples, { binBytes, minFiles }),
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
{ every, binBytes, minFiles },
|
|
88
|
+
);
|
|
89
|
+
for (const l of lines) console.log(l);
|
|
90
|
+
}
|
package/scripts/bootstrap.mjs
CHANGED
|
@@ -49,12 +49,14 @@ function autoInstallBun() {
|
|
|
49
49
|
"-Command",
|
|
50
50
|
"irm bun.sh/install.ps1 | iex",
|
|
51
51
|
],
|
|
52
|
-
|
|
52
|
+
// The installer's stdout goes to stderr: our stdout is the MCP
|
|
53
|
+
// channel, and an installer banner there corrupts the first frame.
|
|
54
|
+
{ stdio: ["ignore", 2, "inherit"] },
|
|
53
55
|
)
|
|
54
56
|
: spawnSync(
|
|
55
57
|
"bash",
|
|
56
58
|
["-lc", "curl -fsSL https://bun.sh/install | bash"],
|
|
57
|
-
{ stdio: ["ignore",
|
|
59
|
+
{ stdio: ["ignore", 2, "inherit"] },
|
|
58
60
|
);
|
|
59
61
|
if (result.status !== 0) {
|
|
60
62
|
log("bun installer failed. Install manually: https://bun.sh/docs/installation");
|
|
@@ -78,6 +80,9 @@ if (!hasBun()) {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
const entrypoint = join(PLUGIN_ROOT, "scripts", "mcp-entrypoint.ts");
|
|
83
|
+
// The router runs from PLUGIN_ROOT, so record the folder the host launched us
|
|
84
|
+
// in first: that is the user's project, and the cwd clamp allows it.
|
|
85
|
+
process.env.ASHLR_LAUNCH_CWD ||= process.cwd();
|
|
81
86
|
const child = spawn("bun", ["run", entrypoint, ...process.argv.slice(2)], {
|
|
82
87
|
cwd: PLUGIN_ROOT,
|
|
83
88
|
stdio: ["inherit", "inherit", "inherit"],
|
|
@@ -95,6 +100,11 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
child.on("exit", (code, signal) => {
|
|
98
|
-
if (signal)
|
|
99
|
-
|
|
103
|
+
if (signal) {
|
|
104
|
+
// Our own SIGINT/SIGTERM listeners above replace node's default action,
|
|
105
|
+
// so re-raising with them attached would just exit 0. Drop them first so
|
|
106
|
+
// the signal terminates this process the way it terminated the child.
|
|
107
|
+
process.removeAllListeners(signal);
|
|
108
|
+
process.kill(process.pid, signal);
|
|
109
|
+
} else process.exit(code ?? 0);
|
|
100
110
|
});
|
package/scripts/cli.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* ashlr-plugin tools [--json] # list registered MCP tools
|
|
11
11
|
* ashlr-plugin version # print ashlr-plugin version
|
|
12
12
|
* ashlr-plugin mcp # start the stdio MCP router
|
|
13
|
-
* ashlr-plugin codex-doctor --json # Codex plugin packaging health
|
|
13
|
+
* ashlr-plugin codex-doctor --json # Codex plugin packaging health (alias: doctor)
|
|
14
14
|
* ashlr-plugin codex-install --dry-run --json # planned Codex config changes
|
|
15
15
|
*
|
|
16
16
|
* Output is stable JSON on stdout. Errors go to stderr with a human-readable
|
|
@@ -24,21 +24,29 @@ import { fileURLToPath } from "url";
|
|
|
24
24
|
|
|
25
25
|
const STATS_PATH = join(homedir(), ".ashlr", "stats.json");
|
|
26
26
|
|
|
27
|
-
function usage(): never {
|
|
27
|
+
function usage(exitCode = 1): never {
|
|
28
28
|
process.stderr.write(
|
|
29
29
|
`usage:\n` +
|
|
30
30
|
` ashlr-plugin stats --json [--session <id>] [--since <YYYY-MM-DD>] [--tool <name>]\n` +
|
|
31
|
+
` print the local savings ledger (or one slice of it) as JSON\n` +
|
|
31
32
|
` ashlr-plugin tools [--json]\n` +
|
|
33
|
+
` list the MCP tools the router registers\n` +
|
|
32
34
|
` ashlr-plugin mcp\n` +
|
|
35
|
+
` start the stdio MCP router\n` +
|
|
33
36
|
` ashlr-plugin codex-start [--json]\n` +
|
|
34
37
|
` ashlr-plugin codex-resume [--json]\n` +
|
|
35
38
|
` ashlr-plugin codex-end [--json]\n` +
|
|
39
|
+
` Codex session lifecycle hooks\n` +
|
|
36
40
|
` ashlr-plugin codex-doctor [--json]\n` +
|
|
41
|
+
` check the plugin's install and Codex packaging (alias: doctor)\n` +
|
|
37
42
|
` ashlr-plugin codex-install --dry-run [--json]\n` +
|
|
43
|
+
` show the Codex config changes an install would make\n` +
|
|
38
44
|
` ashlr-plugin genome-refresh [--json]\n` +
|
|
39
|
-
`
|
|
45
|
+
` refresh genome sections for changed files\n` +
|
|
46
|
+
` ashlr-plugin version\n` +
|
|
47
|
+
` print the plugin version\n`,
|
|
40
48
|
);
|
|
41
|
-
process.exit(
|
|
49
|
+
process.exit(exitCode);
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
function repoRootFromCli(): string {
|
|
@@ -48,8 +56,13 @@ function repoRootFromCli(): string {
|
|
|
48
56
|
|
|
49
57
|
function loadStats(): Record<string, unknown> {
|
|
50
58
|
if (!existsSync(STATS_PATH)) {
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
// No savings recorded yet: answer with an empty ledger so `--json`
|
|
60
|
+
// callers always get JSON.
|
|
61
|
+
return {
|
|
62
|
+
schemaVersion: 2,
|
|
63
|
+
sessions: {},
|
|
64
|
+
lifetime: { calls: 0, tokensSaved: 0, rawTotal: 0, byTool: {}, byDay: {} },
|
|
65
|
+
};
|
|
53
66
|
}
|
|
54
67
|
try {
|
|
55
68
|
return JSON.parse(readFileSync(STATS_PATH, "utf-8"));
|
|
@@ -144,8 +157,10 @@ function runStats(flags: Record<string, string | boolean>): void {
|
|
|
144
157
|
process.stderr.write(`ashlr-plugin: --since must be YYYY-MM-DD (got ${since})\n`);
|
|
145
158
|
process.exit(1);
|
|
146
159
|
}
|
|
147
|
-
|
|
148
|
-
|
|
160
|
+
// The ledger keeps per-day totals in lifetime.byDay (older builds of this
|
|
161
|
+
// command read a `daily` key that the ledger never had).
|
|
162
|
+
const lifetime = (stats.lifetime ?? {}) as { byDay?: Record<string, unknown> };
|
|
163
|
+
const daily = lifetime.byDay ?? {};
|
|
149
164
|
const filtered: Record<string, unknown> = {};
|
|
150
165
|
for (const [date, entry] of Object.entries(daily)) {
|
|
151
166
|
if (date >= since) filtered[date] = entry;
|
|
@@ -163,6 +178,9 @@ function runVersion(): void {
|
|
|
163
178
|
async function runMcp(): Promise<void> {
|
|
164
179
|
const root = repoRootFromCli();
|
|
165
180
|
process.env.ASHLR_MCP_HOST ||= "generic";
|
|
181
|
+
// The router runs from the plugin root; keep the caller's folder (the
|
|
182
|
+
// user's project) so the router can move back into it.
|
|
183
|
+
process.env.ASHLR_LAUNCH_CWD ||= process.cwd();
|
|
166
184
|
const proc = Bun.spawn(
|
|
167
185
|
["bun", "run", join(root, "scripts", "mcp-entrypoint.ts"), "servers/_router.ts"],
|
|
168
186
|
{
|
|
@@ -233,7 +251,7 @@ function runCodexDoctor(flags: JsonFlagMap): void {
|
|
|
233
251
|
const server = mcp?.mcpServers?.ashlr;
|
|
234
252
|
const instructions = typeof server?.instructions === "string" ? server.instructions : "";
|
|
235
253
|
const allowlist = process.env.ASHLR_ALLOW_PROJECT_PATHS ?? "";
|
|
236
|
-
const mcpBinDeclared = pkg?.bin?.["ashlr-mcp"] === "./scripts/ashlr-mcp.
|
|
254
|
+
const mcpBinDeclared = pkg?.bin?.["ashlr-mcp"] === "./scripts/ashlr-mcp.mjs";
|
|
237
255
|
const mcpBinResolvable = commandOnPath("ashlr-mcp");
|
|
238
256
|
const sourceCheckoutCommand = `bun run ${join(root, "scripts", "ashlr-mcp.ts")}`;
|
|
239
257
|
const mcpHealthy = Boolean(
|
|
@@ -358,6 +376,12 @@ function runCodexDoctor(flags: JsonFlagMap): void {
|
|
|
358
376
|
`ashlr Codex doctor: ${report.ok ? "ok" : "issues found"}\n` +
|
|
359
377
|
`- plugin manifest: ${report.pluginManifest.present ? "present" : "missing"}\n` +
|
|
360
378
|
`- MCP server: ${report.mcp.server ? "present" : "missing"} (${report.mcp.bin})\n` +
|
|
379
|
+
// Report-only: `ok` does not depend on this. The plugin's .mcp.json runs
|
|
380
|
+
// `ashlr-mcp`, so a host cannot start the server when it is not on PATH.
|
|
381
|
+
(report.mcp.binResolvable
|
|
382
|
+
? "- MCP launch: ashlr-mcp is on PATH\n"
|
|
383
|
+
: "- MCP launch: ashlr-mcp is not on PATH, so a host that runs .mcp.json cannot start the server. " +
|
|
384
|
+
`Install the package bin (npm install -g ashlr-plugin) or point the host at: ${report.mcp.sourceCheckoutCommand}\n`) +
|
|
361
385
|
`- hooks: ${report.hooks.present ? "present" : "missing"} (${report.hooks.defaultMode})\n` +
|
|
362
386
|
`- skills: ${report.skills.present ? "present" : "missing"}\n` +
|
|
363
387
|
`- agents: ${report.agents.present ? "present" : "missing"}\n`,
|
|
@@ -451,6 +475,7 @@ async function main(): Promise<void> {
|
|
|
451
475
|
runCodexLifecycle(subcommand, flags);
|
|
452
476
|
break;
|
|
453
477
|
case "codex-doctor":
|
|
478
|
+
case "doctor":
|
|
454
479
|
runCodexDoctor(flags);
|
|
455
480
|
break;
|
|
456
481
|
case "codex-install":
|
|
@@ -467,7 +492,7 @@ async function main(): Promise<void> {
|
|
|
467
492
|
case "--help":
|
|
468
493
|
case "-h":
|
|
469
494
|
case "help":
|
|
470
|
-
usage();
|
|
495
|
+
usage(0);
|
|
471
496
|
break;
|
|
472
497
|
default:
|
|
473
498
|
process.stderr.write(`ashlr-plugin: unknown subcommand "${subcommand}"\n`);
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { openContextDb } from "../servers/_embedding-cache";
|
|
20
|
-
import { EMBED_DIM } from "../servers/_embedding-model";
|
|
20
|
+
import { EMBED_DIM, embedFormat } from "../servers/_embedding-model";
|
|
21
|
+
import { safeTargetLabel } from "../servers/_gateway-target.ts";
|
|
21
22
|
|
|
22
23
|
if (process.env.ASHLR_CONTEXT_DB_DISABLE === "1") {
|
|
23
24
|
console.log("ashlr context-db: disabled (ASHLR_CONTEXT_DB_DISABLE=1)");
|
|
@@ -31,8 +32,12 @@ db.close();
|
|
|
31
32
|
const mbSize = (s.dbBytes / (1024 * 1024)).toFixed(2);
|
|
32
33
|
const hitPct = (s.hitRateLast1000 * 100).toFixed(1);
|
|
33
34
|
|
|
35
|
+
// The OpenAI-compatible mode may point at a Phantom proxy URL carrying a
|
|
36
|
+
// session token, so it is shown as scheme://host:port only.
|
|
34
37
|
const embedderLine = process.env.ASHLR_EMBED_URL
|
|
35
|
-
?
|
|
38
|
+
? embedFormat() === "openai"
|
|
39
|
+
? `bm25 (dim=${EMBED_DIM}) + remote (openai format): ${safeTargetLabel(process.env.ASHLR_EMBED_URL)}`
|
|
40
|
+
: `bm25 (dim=${EMBED_DIM}) + remote: ${process.env.ASHLR_EMBED_URL}`
|
|
36
41
|
: `bm25 (dim=${EMBED_DIM})`;
|
|
37
42
|
|
|
38
43
|
console.log(`ashlr context-db`);
|
package/scripts/deploy-smoke.sh
CHANGED
|
@@ -33,6 +33,12 @@ pass() { echo -e "${GRN}PASS${RST} $1"; PASS=$((PASS + 1)); }
|
|
|
33
33
|
fail() { echo -e "${RED}FAIL${RST} $1"; FAIL=$((FAIL + 1)); }
|
|
34
34
|
info() { echo -e "${YLW}----${RST} $1"; }
|
|
35
35
|
|
|
36
|
+
# curl with the bearer header read from a config on stdin, so the token is
|
|
37
|
+
# never a curl argument (argv is visible to other local users in `ps`).
|
|
38
|
+
auth_curl() {
|
|
39
|
+
printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" | curl --config - "$@"
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
# ---------------------------------------------------------------------------
|
|
37
43
|
# 1. Site health
|
|
38
44
|
# ---------------------------------------------------------------------------
|
|
@@ -83,9 +89,8 @@ if [ -z "$TOKEN" ]; then
|
|
|
83
89
|
info "ASHLR_TOKEN not set — skipping authenticated badge + stats checks"
|
|
84
90
|
else
|
|
85
91
|
# Upload a minimal stats payload to create a user record
|
|
86
|
-
SYNC_STATUS=$(
|
|
92
|
+
SYNC_STATUS=$(auth_curl -sf -o /dev/null -w "%{http_code}" \
|
|
87
93
|
-X POST "${API_URL}/stats/sync" \
|
|
88
|
-
-H "Authorization: Bearer ${TOKEN}" \
|
|
89
94
|
-H "Content-Type: application/json" \
|
|
90
95
|
-d '{"stats":{"lifetime":{"calls":42,"tokensSaved":99000,"byTool":{},"byDay":{}}}}' \
|
|
91
96
|
|| echo "000")
|
|
@@ -98,8 +103,7 @@ else
|
|
|
98
103
|
|
|
99
104
|
# Derive userId from token (first 8 chars as a smoke proxy — real impl uses DB lookup)
|
|
100
105
|
# Instead, hit the aggregate endpoint to get the userId
|
|
101
|
-
AGG=$(
|
|
102
|
-
-H "Authorization: Bearer ${TOKEN}" \
|
|
106
|
+
AGG=$(auth_curl -sf \
|
|
103
107
|
"${API_URL}/stats/aggregate" || echo "{}")
|
|
104
108
|
USER_ID=$(echo "$AGG" | grep -o '"user_id":"[^"]*"' | cut -d'"' -f4 || echo "")
|
|
105
109
|
|