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.
Files changed (81) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +49 -8
  5. package/commands/ashlr-doctor.md +29 -0
  6. package/commands/ashlr-status.md +1 -1
  7. package/docs/PHANTOM-USAGE.md +58 -0
  8. package/docs/architecture.md +5 -5
  9. package/docs/ashlr-handshake.md +36 -0
  10. package/docs/assets/og.svg +1 -1
  11. package/docs/benchmarks-v2.json +226 -177
  12. package/docs/benchmarks.md +20 -10
  13. package/docs/competitive-wozcode.md +2 -2
  14. package/docs/embeddings-gateway.md +39 -0
  15. package/docs/index.html +3 -3
  16. package/docs/legal.md +1 -1
  17. package/docs/llm-providers.md +15 -1
  18. package/docs/multi-host-mcp.md +25 -3
  19. package/docs/phantom-preflight.md +23 -0
  20. package/docs/team-genome.md +2 -1
  21. package/hooks/_hook-timing-ledger.ts +2 -9
  22. package/hooks/hooks.json +3 -106
  23. package/hooks/posttooluse-stale-result.ts +3 -0
  24. package/hooks/pretooluse-common.ts +1 -1
  25. package/hooks/pretooluse-notebookedit.ts +1 -1
  26. package/hooks/pretooluse-write.ts +1 -1
  27. package/hooks/session-start.ts +48 -26
  28. package/package.json +9 -4
  29. package/ports/cursor/mcp.json +1 -1
  30. package/scripts/_read-thresholds.ts +119 -0
  31. package/scripts/ashlr-mcp.mjs +15 -0
  32. package/scripts/ashlr-mcp.ts +3 -0
  33. package/scripts/ashlr-plugin.mjs +59 -0
  34. package/scripts/bench-read-real.ts +90 -0
  35. package/scripts/bootstrap.mjs +14 -4
  36. package/scripts/cli.ts +35 -10
  37. package/scripts/context-status.ts +7 -2
  38. package/scripts/deploy-smoke.sh +8 -4
  39. package/scripts/doctor.ts +171 -42
  40. package/scripts/genome-auto-consolidate.ts +4 -2
  41. package/scripts/genome-init.ts +3 -3
  42. package/scripts/genome-key.ts +64 -7
  43. package/scripts/handshake.mjs +51 -0
  44. package/scripts/hook-bootstrap.mjs +170 -17
  45. package/scripts/mcp-entrypoint.ts +3 -0
  46. package/scripts/ollama-setup.ts +5 -4
  47. package/scripts/run-benchmark.ts +28 -18
  48. package/scripts/upgrade-flow.ts +9 -14
  49. package/servers/_base-urls.ts +48 -0
  50. package/servers/_companions.ts +258 -0
  51. package/servers/_cwd-clamp.ts +38 -1
  52. package/servers/_edit-match.ts +12 -2
  53. package/servers/_embedding-model.ts +77 -0
  54. package/servers/_gateway-target.ts +179 -0
  55. package/servers/_llm-providers/anthropic.ts +10 -4
  56. package/servers/_llm-providers/cloud.ts +9 -7
  57. package/servers/_llm-providers/index.ts +8 -6
  58. package/servers/_llm-providers/local.ts +9 -5
  59. package/servers/_mcp-host.ts +6 -0
  60. package/servers/_miss-tracker.ts +121 -0
  61. package/servers/_phantom-exec.ts +135 -0
  62. package/servers/_phantom-preflight.ts +130 -0
  63. package/servers/_pid-alive.ts +40 -0
  64. package/servers/_pro-token-keychain.ts +149 -0
  65. package/servers/_redact-keys.ts +75 -0
  66. package/servers/_rg-text.ts +111 -0
  67. package/servers/_router.ts +20 -2
  68. package/servers/_run-with-timeout.ts +56 -12
  69. package/servers/_test-watch.ts +1 -10
  70. package/servers/_token-measure.ts +3 -1
  71. package/servers/_tool-base.ts +6 -0
  72. package/servers/bash-server.ts +1 -10
  73. package/servers/edit-server.ts +24 -1
  74. package/servers/efficiency-server.ts +12 -2
  75. package/servers/genome-server-handlers.ts +2 -2
  76. package/servers/genome-server.ts +17 -17
  77. package/servers/grep-server.ts +6 -2
  78. package/servers/orient-server-handlers.ts +3 -2
  79. package/servers/orient-server.ts +16 -9
  80. package/servers/read-server.ts +1 -1
  81. package/servers/write-server-handlers.ts +10 -2
@@ -67,7 +67,7 @@ registerTool({
67
67
  description:
68
68
  "Merge pending proposals into the genome. Without `model` (and without " +
69
69
  "$ASHLR_LLM_URL) runs a deterministic sequential-apply; with an " +
70
- "OpenAI-compatible endpoint it uses the LLM to merge conflicting proposals " +
70
+ "LLM configured by $ASHLR_LLM_URL it merges conflicting proposals " +
71
71
  "for the same section.",
72
72
  inputSchema: {
73
73
  type: "object",
@@ -75,7 +75,7 @@ registerTool({
75
75
  model: {
76
76
  type: "string",
77
77
  description:
78
- "Optional OpenAI-compatible base URL (e.g. http://localhost:1234/v1). Pass 'none' to force offline sequential-apply. If omitted, falls back to $ASHLR_LLM_URL or sequential-apply.",
78
+ "Pass 'none' to force offline sequential-apply, or any other value (for example 'llm') to merge with the LLM configured by $ASHLR_LLM_URL (default local LM Studio at 127.0.0.1:1234). The value is never used as an endpoint. If omitted, uses the LLM only when $ASHLR_LLM_URL or $ASHLR_LLM_KEY is set.",
79
79
  },
80
80
  cwd: {
81
81
  type: "string",
@@ -38,27 +38,26 @@ import type {
38
38
  ProviderRequest,
39
39
  StreamEvent,
40
40
  } from "@ashlr/core-efficiency";
41
+ import { safeKeyedFetch } from "./_gateway-target.ts";
41
42
 
42
43
  // ---------------------------------------------------------------------------
43
44
  // LLM shim — OpenAI-compatible chat.completions streaming
44
45
  // ---------------------------------------------------------------------------
45
46
 
47
+ /** Local LLM on loopback by IP literal; `localhost` by name is not accepted for a keyed call. */
48
+ export const DEFAULT_GENOME_LLM_URL = "http://127.0.0.1:1234/v1";
49
+
46
50
  /**
47
51
  * Build a minimal LLMSummarizer that hits an OpenAI-compatible endpoint.
48
52
  *
49
- * Precedence:
50
- * 1. explicit `modelUrl` arg (per-call override)
51
- * 2. $ASHLR_LLM_URL / $ASHLR_LLM_KEY
52
- * 3. local LM Studio at http://localhost:1234/v1 (no key required)
53
- *
54
- * Returns null if no endpoint is configured AND no override given — the
55
- * scribe falls back to sequential-apply merging in that case.
53
+ * The endpoint comes only from user config ($ASHLR_LLM_URL, else local LM
54
+ * Studio at http://127.0.0.1:1234/v1), never from a tool argument, so the
55
+ * model cannot choose where $ASHLR_LLM_KEY or genome content goes.
56
+ * safeKeyedFetch() applies the gateway rules (https, or http only to
57
+ * 127.0.0.1 / [::1]; no credentials in the URL; no redirects).
56
58
  */
57
- export function buildLLMShim(modelUrl?: string): LLMSummarizer | undefined {
58
- const base =
59
- modelUrl ??
60
- process.env.ASHLR_LLM_URL ??
61
- "http://localhost:1234/v1";
59
+ export function buildLLMShim(): LLMSummarizer | undefined {
60
+ const base = (process.env.ASHLR_LLM_URL ?? "").trim() || DEFAULT_GENOME_LLM_URL;
62
61
  const key = process.env.ASHLR_LLM_KEY ?? "";
63
62
  const model = process.env.ASHLR_LLM_MODEL ?? "local";
64
63
 
@@ -88,7 +87,7 @@ export function buildLLMShim(modelUrl?: string): LLMSummarizer | undefined {
88
87
 
89
88
  let res: Response;
90
89
  try {
91
- res = await fetch(endpoint, {
90
+ res = await safeKeyedFetch(endpoint, {
92
91
  method: "POST",
93
92
  headers: {
94
93
  "Content-Type": "application/json",
@@ -226,12 +225,13 @@ export async function handleConsolidate(args: ConsolidateArgs): Promise<string>
226
225
  }
227
226
  // Only construct the LLM shim if caller explicitly requested one OR env is
228
227
  // configured. When args.model is the literal string "none", skip the shim
229
- // and fall through to sequential-apply.
228
+ // and fall through to sequential-apply. Any other `model` value only opts
229
+ // in to LLM merging; it is never used as an endpoint.
230
230
  let router: LLMSummarizer | undefined;
231
231
  if (args.model === "none") {
232
232
  router = undefined;
233
233
  } else if (args.model || process.env.ASHLR_LLM_URL || process.env.ASHLR_LLM_KEY) {
234
- router = buildLLMShim(args.model);
234
+ router = buildLLMShim();
235
235
  } else {
236
236
  // No configured endpoint — sequential-apply keeps this deterministic and
237
237
  // offline. Users opt into LLM merging by passing `model` or setting env.
@@ -330,14 +330,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
330
330
  {
331
331
  name: "ashlr__genome_consolidate",
332
332
  description:
333
- "Merge pending proposals into the genome. Without `model` (and without $ASHLR_LLM_URL) runs a deterministic sequential-apply; with an OpenAI-compatible endpoint it uses the LLM to merge conflicting proposals for the same section.",
333
+ "Merge pending proposals into the genome. Without `model` (and without $ASHLR_LLM_URL) runs a deterministic sequential-apply; with the LLM configured by $ASHLR_LLM_URL it merges conflicting proposals for the same section.",
334
334
  inputSchema: {
335
335
  type: "object",
336
336
  properties: {
337
337
  model: {
338
338
  type: "string",
339
339
  description:
340
- "Optional OpenAI-compatible base URL (e.g. http://localhost:1234/v1). Pass 'none' to force offline sequential-apply. If omitted, falls back to $ASHLR_LLM_URL or sequential-apply.",
340
+ "Pass 'none' to force offline sequential-apply, or any other value (for example 'llm') to merge with the LLM configured by $ASHLR_LLM_URL (default local LM Studio at 127.0.0.1:1234). The value is never used as an endpoint. If omitted, uses the LLM only when $ASHLR_LLM_URL or $ASHLR_LLM_KEY is set.",
341
341
  },
342
342
  cwd: {
343
343
  type: "string",
@@ -26,6 +26,7 @@ import { getCalibrationMultiplier } from "../scripts/read-calibration";
26
26
  import { recordSaving } from "./_stats";
27
27
  import { logGenomeCompressionRatioEvent } from "./_telemetry";
28
28
  import { clampToCwd, primaryProjectRoot } from "./_cwd-clamp";
29
+ import { rgStopReason, rgTextArgs, truncateRgText } from "./_rg-text";
29
30
  import { getEmbeddingCache } from "./_tool-base";
30
31
  import { embed, upsertCorpus } from "./_embedding-model";
31
32
  import { populateGenomeEmbeddings } from "./_genome-embed-populator";
@@ -504,12 +505,15 @@ export async function ashlrGrep(input: {
504
505
 
505
506
  const rgBin = resolveRg();
506
507
 
507
- const res = spawnSync(rgBin, ["--json", "-n", "--", input.pattern, cwd], {
508
+ const res = spawnSync(rgBin, rgTextArgs(input.pattern, cwd), {
508
509
  encoding: "utf-8",
509
510
  timeout: 15_000,
511
+ maxBuffer: 64 * 1024 * 1024,
510
512
  });
513
+ // `raw` is what a plain grep would have returned, so savings are counted
514
+ // against that, not against a verbose wire format.
511
515
  const raw = res.stdout ?? "";
512
- const truncated = raw.length > 4000 ? raw.slice(0, 2000) + "\n\n[... truncated ...]\n\n" + raw.slice(-1000) : raw;
516
+ const truncated = truncateRgText(raw, rgStopReason(res));
513
517
  const summarized = await summarizeIfLarge(truncated, {
514
518
  toolName: "ashlr__grep",
515
519
  systemPrompt: PROMPTS.grep,
@@ -40,12 +40,13 @@ registerTool({
40
40
  required: ["query"],
41
41
  },
42
42
  handler: async (args: Record<string, unknown>, _ctx: ToolCallContext): Promise<ToolResult> => {
43
- const a = args as Partial<OrientArgs> & { endpointOverride?: string };
43
+ // Only the documented arguments are read. An extra `endpointOverride` (or
44
+ // any other key) is ignored: the LLM endpoint comes from user config only.
45
+ const a = args as Partial<OrientArgs>;
44
46
  const out = await orient({
45
47
  query: typeof a.query === "string" ? a.query : "",
46
48
  dir: typeof a.dir === "string" ? a.dir : undefined,
47
49
  depth: a.depth === "quick" || a.depth === "thorough" ? a.depth : undefined,
48
- endpointOverride: typeof a.endpointOverride === "string" ? a.endpointOverride : undefined,
49
50
  });
50
51
  return { content: [{ type: "text", text: out.text }] };
51
52
  },
@@ -37,6 +37,7 @@ import { scan, formatBaseline, listFiles } from "../scripts/baseline-scan";
37
37
  import { readFileSync } from "fs";
38
38
  import { recordSaving as recordSavingCore } from "./_stats";
39
39
  import { STOPWORDS, extractKeywords } from "./_text-helpers";
40
+ import { safeKeyedFetch } from "./_gateway-target.ts";
40
41
 
41
42
  // Re-export for callers that historically imported `extractKeywords` from orient-server.
42
43
  export { extractKeywords, STOPWORDS };
@@ -206,11 +207,23 @@ interface CallLLMOpts {
206
207
  systemPrompt: string;
207
208
  userContent: string;
208
209
  timeoutMs?: number;
209
- endpointOverride?: string;
210
+ }
211
+
212
+ /** LM Studio on loopback by IP literal; `localhost` by name is not accepted for a keyed call. */
213
+ export const DEFAULT_ORIENT_LLM_URL = "http://127.0.0.1:1234/v1";
214
+
215
+ /**
216
+ * The orient LLM endpoint comes only from user config (ASHLR_LLM_URL), never
217
+ * from a tool argument, so the model cannot choose where ASHLR_LLM_KEY or
218
+ * project excerpts go. safeKeyedFetch() then applies the gateway rules.
219
+ */
220
+ export function orientLlmBaseUrl(env: Record<string, string | undefined> = process.env): string {
221
+ const configured = (env.ASHLR_LLM_URL ?? "").trim().replace(/\/+$/, "");
222
+ return configured || DEFAULT_ORIENT_LLM_URL;
210
223
  }
211
224
 
212
225
  export async function callLocalLLM(opts: CallLLMOpts): Promise<string | null> {
213
- const base = opts.endpointOverride ?? process.env.ASHLR_LLM_URL ?? "http://localhost:1234/v1";
226
+ const base = orientLlmBaseUrl();
214
227
  const apiKey = process.env.ASHLR_LLM_KEY ?? "local-llm";
215
228
  const model = process.env.ASHLR_LLM_MODEL ?? "qwen/qwen3-coder-30b@8bit";
216
229
  const timeoutMs = opts.timeoutMs ?? LLM_TIMEOUT_MS;
@@ -218,7 +231,7 @@ export async function callLocalLLM(opts: CallLLMOpts): Promise<string | null> {
218
231
  const ctl = new AbortController();
219
232
  const t = setTimeout(() => ctl.abort(), timeoutMs);
220
233
  try {
221
- const res = await fetch(`${base}/chat/completions`, {
234
+ const res = await safeKeyedFetch(`${base}/chat/completions`, {
222
235
  method: "POST",
223
236
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
224
237
  signal: ctl.signal,
@@ -252,8 +265,6 @@ export interface OrientArgs {
252
265
  query: string;
253
266
  dir?: string;
254
267
  depth?: "quick" | "thorough";
255
- /** Test hook: override LLM endpoint. */
256
- endpointOverride?: string;
257
268
  }
258
269
 
259
270
  export interface OrientResult {
@@ -329,7 +340,6 @@ export async function orient(args: OrientArgs): Promise<OrientResult> {
329
340
  systemPrompt: ORIENT_SYSTEM_PROMPT,
330
341
  userContent,
331
342
  timeoutMs: LLM_TIMEOUT_MS,
332
- endpointOverride: args.endpointOverride,
333
343
  });
334
344
 
335
345
  let synthesis: string;
@@ -419,9 +429,6 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
419
429
  query: typeof a.query === "string" ? a.query : "",
420
430
  dir: typeof a.dir === "string" ? a.dir : undefined,
421
431
  depth: a.depth === "quick" || a.depth === "thorough" ? a.depth : undefined,
422
- endpointOverride: typeof (a as { endpointOverride?: string }).endpointOverride === "string"
423
- ? (a as { endpointOverride?: string }).endpointOverride
424
- : undefined,
425
432
  });
426
433
  return { content: [{ type: "text", text: out.text }] };
427
434
  } catch (err) {
@@ -26,7 +26,7 @@ import { isProSync } from "./_pro";
26
26
  * Minimum content byte-length before AST skeleton rendering is attempted.
27
27
  * Below this threshold the full snipCompact path is cheaper.
28
28
  */
29
- const AST_THRESHOLD = 6144;
29
+ export const AST_THRESHOLD = 6144;
30
30
 
31
31
  /**
32
32
  * File extensions treated as code for the line-number-preservation path.
@@ -15,13 +15,21 @@ registerTool({
15
15
  inputSchema: {
16
16
  type: "object",
17
17
  properties: {
18
- filePath: { type: "string", description: "Absolute or cwd-relative path to write" },
18
+ filePath: { type: "string", description: "Absolute or cwd-relative path to write (`path` is accepted too)" },
19
19
  content: { type: "string", description: "File content to write" },
20
20
  },
21
21
  required: ["filePath", "content"],
22
22
  },
23
23
  handler: async (args: Record<string, unknown>, _ctx: ToolCallContext): Promise<ToolResult> => {
24
- const result = await ashlrWrite(args as unknown as WriteArgs);
24
+ // ashlr__read and ashlr__edit take `path`, so callers often pass it here too.
25
+ const filePath = args.filePath ?? args.path;
26
+ if (typeof filePath !== "string" || filePath === "") {
27
+ return { content: [{ type: "text", text: "ashlr__write: filePath must be a non-empty string" }], isError: true };
28
+ }
29
+ if (typeof args.content !== "string") {
30
+ return { content: [{ type: "text", text: "ashlr__write: content must be a string" }], isError: true };
31
+ }
32
+ const result = await ashlrWrite({ filePath, content: args.content } satisfies WriteArgs);
25
33
  return { content: [{ type: "text", text: formatWriteResult(result) }] };
26
34
  },
27
35
  });