@orquentra/codebuddy 2.14.0 → 2.15.0

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 (91) hide show
  1. package/ARCHITECTURE.md +98 -0
  2. package/README.md +87 -6
  3. package/dist/{chunk-LW75SKZR.js → chunk-4BSO3HST.js} +126 -18
  4. package/dist/chunk-4BSO3HST.js.map +1 -0
  5. package/dist/{chunk-CAEPHLHC.js → chunk-57642NGU.js} +2 -44
  6. package/dist/chunk-57642NGU.js.map +1 -0
  7. package/dist/{chunk-ESZXCZIN.js → chunk-6YI2D5F5.js} +33 -5
  8. package/dist/chunk-6YI2D5F5.js.map +1 -0
  9. package/dist/{chunk-4DKYKDHI.js → chunk-7IPSDDO5.js} +5 -3
  10. package/dist/{chunk-4DKYKDHI.js.map → chunk-7IPSDDO5.js.map} +1 -1
  11. package/dist/chunk-BTTQWSDS.js +372 -0
  12. package/dist/chunk-BTTQWSDS.js.map +1 -0
  13. package/dist/{chunk-YJPCS2GP.js → chunk-D5E7SGI5.js} +57 -18
  14. package/dist/chunk-D5E7SGI5.js.map +1 -0
  15. package/dist/chunk-EMWGLKPR.js +45 -0
  16. package/dist/chunk-EMWGLKPR.js.map +1 -0
  17. package/dist/chunk-ITXE27SZ.js +358 -0
  18. package/dist/chunk-ITXE27SZ.js.map +1 -0
  19. package/dist/{chunk-LRQNPYCK.js → chunk-JJQDVGF7.js} +13 -7
  20. package/dist/chunk-JJQDVGF7.js.map +1 -0
  21. package/dist/{chunk-XSNW6Y3X.js → chunk-KQCP3ZYH.js} +28 -24
  22. package/dist/chunk-KQCP3ZYH.js.map +1 -0
  23. package/dist/{chunk-YWDH6DQ6.js → chunk-KYRM3HPP.js} +21 -19
  24. package/dist/{chunk-YWDH6DQ6.js.map → chunk-KYRM3HPP.js.map} +1 -1
  25. package/dist/{chunk-KNR2P2UY.js → chunk-M75SN4D4.js} +19 -4
  26. package/dist/chunk-M75SN4D4.js.map +1 -0
  27. package/dist/{chunk-L6BRYZ6G.js → chunk-RZ266NHE.js} +7 -7
  28. package/dist/chunk-U7KDM62P.js +43 -0
  29. package/dist/chunk-U7KDM62P.js.map +1 -0
  30. package/dist/chunk-UATJCD2T.js +368 -0
  31. package/dist/chunk-UATJCD2T.js.map +1 -0
  32. package/dist/{chunk-YNSTIMCF.js → chunk-UR3DF74I.js} +352 -157
  33. package/dist/chunk-UR3DF74I.js.map +1 -0
  34. package/dist/chunk-UTPC66MV.js +128 -0
  35. package/dist/chunk-UTPC66MV.js.map +1 -0
  36. package/dist/chunk-WHKXUXHE.js +156 -0
  37. package/dist/chunk-WHKXUXHE.js.map +1 -0
  38. package/dist/{chunk-7QMHIJBA.js → chunk-YMXFSHXT.js} +969 -968
  39. package/dist/chunk-YMXFSHXT.js.map +1 -0
  40. package/dist/cli/index.js +4 -4
  41. package/dist/config-TXQJCCVI.js +15 -0
  42. package/dist/expand-KWYGNCKZ.js +98 -0
  43. package/dist/expand-KWYGNCKZ.js.map +1 -0
  44. package/dist/file-vectors-MKXTSGGH.js +19 -0
  45. package/dist/index-facts-4MTEHKMN.js +38 -0
  46. package/dist/index-pass-TBDUEZCB.js +14 -0
  47. package/dist/index.js +24 -22
  48. package/dist/{install-EU35ERK5.js → install-E3JWINTN.js} +11 -10
  49. package/dist/{lean-43ADYTBW.js → lean-MLTAFS5L.js} +8 -7
  50. package/dist/local-75DJECVR.js +48 -0
  51. package/dist/local-llm-MECVJTVU.js +19 -0
  52. package/dist/local-llm-MECVJTVU.js.map +1 -0
  53. package/dist/{main-SFC5LV4E.js → main-RLMKQZW4.js} +332 -123
  54. package/dist/main-RLMKQZW4.js.map +1 -0
  55. package/dist/{memory-TGHVTBME.js → memory-PFFVMTCH.js} +9 -6
  56. package/dist/{memory-TGHVTBME.js.map → memory-PFFVMTCH.js.map} +1 -1
  57. package/dist/{operations-6Z6ZDVBP.js → operations-STR7S5UO.js} +8 -7
  58. package/dist/operations-STR7S5UO.js.map +1 -0
  59. package/dist/{rerank-IQAEWLQR.js → rerank-XXKWDI5Q.js} +7 -4
  60. package/dist/{rerank-IQAEWLQR.js.map → rerank-XXKWDI5Q.js.map} +1 -1
  61. package/dist/{runtime-W5AW32SD.js → runtime-SWFWGTQ4.js} +9 -8
  62. package/dist/runtime-SWFWGTQ4.js.map +1 -0
  63. package/dist/summaries-QXWFFDUZ.js +31 -0
  64. package/dist/summaries-QXWFFDUZ.js.map +1 -0
  65. package/dist/{zvec-24HLAFGN.js → zvec-F73IBWO5.js} +13 -5
  66. package/dist/zvec-F73IBWO5.js.map +1 -0
  67. package/package.json +3 -2
  68. package/dist/chunk-7QMHIJBA.js.map +0 -1
  69. package/dist/chunk-CAEPHLHC.js.map +0 -1
  70. package/dist/chunk-ESC4Y3L7.js +0 -204
  71. package/dist/chunk-ESC4Y3L7.js.map +0 -1
  72. package/dist/chunk-ESZXCZIN.js.map +0 -1
  73. package/dist/chunk-KNR2P2UY.js.map +0 -1
  74. package/dist/chunk-LRQNPYCK.js.map +0 -1
  75. package/dist/chunk-LW75SKZR.js.map +0 -1
  76. package/dist/chunk-SFMIMLXR.js +0 -63
  77. package/dist/chunk-SFMIMLXR.js.map +0 -1
  78. package/dist/chunk-XSNW6Y3X.js.map +0 -1
  79. package/dist/chunk-YJPCS2GP.js.map +0 -1
  80. package/dist/chunk-YNSTIMCF.js.map +0 -1
  81. package/dist/config-GEZFLXK6.js +0 -12
  82. package/dist/main-SFC5LV4E.js.map +0 -1
  83. package/dist/summaries-32PHOB6L.js +0 -20
  84. /package/dist/{chunk-L6BRYZ6G.js.map → chunk-RZ266NHE.js.map} +0 -0
  85. /package/dist/{config-GEZFLXK6.js.map → config-TXQJCCVI.js.map} +0 -0
  86. /package/dist/{install-EU35ERK5.js.map → file-vectors-MKXTSGGH.js.map} +0 -0
  87. /package/dist/{lean-43ADYTBW.js.map → index-facts-4MTEHKMN.js.map} +0 -0
  88. /package/dist/{operations-6Z6ZDVBP.js.map → index-pass-TBDUEZCB.js.map} +0 -0
  89. /package/dist/{runtime-W5AW32SD.js.map → install-E3JWINTN.js.map} +0 -0
  90. /package/dist/{summaries-32PHOB6L.js.map → lean-MLTAFS5L.js.map} +0 -0
  91. /package/dist/{zvec-24HLAFGN.js.map → local-75DJECVR.js.map} +0 -0
@@ -1,9 +1,12 @@
1
1
  import {
2
2
  redactLlmKeys
3
- } from "./chunk-SFMIMLXR.js";
3
+ } from "./chunk-WHKXUXHE.js";
4
4
  import {
5
5
  writeAtomic
6
6
  } from "./chunk-CG5B3LYG.js";
7
+ import {
8
+ localChatJson
9
+ } from "./chunk-BTTQWSDS.js";
7
10
 
8
11
  // src/llm/budget.ts
9
12
  import { mkdir, readFile } from "fs/promises";
@@ -64,6 +67,7 @@ async function storeResponse(root, key, value) {
64
67
  var DEFAULT_COOLDOWN_SECONDS = 60;
65
68
  async function chatJson(config, request) {
66
69
  const started = Date.now();
70
+ if (config.local) return localCall(config.local, request);
67
71
  try {
68
72
  return await call(config, request, started);
69
73
  } catch (error) {
@@ -121,7 +125,9 @@ async function call(config, request, started) {
121
125
  ],
122
126
  temperature: 0,
123
127
  max_tokens: request.maxTokens,
124
- response_format: { type: "json_object" }
128
+ response_format: { type: "json_object" },
129
+ // Qwen3 reasons by default on some hosts; cards and reranks need none.
130
+ ...config.provider === "groq" && /^qwen\//.test(config.model) ? { reasoning_effort: "none" } : {}
125
131
  }),
126
132
  signal: AbortSignal.timeout(request.deadlineMs)
127
133
  });
@@ -176,7 +182,28 @@ async function call(config, request, started) {
176
182
  cached: false
177
183
  };
178
184
  }
179
- function parseJsonContent(content) {
185
+ async function localCall(spec, request) {
186
+ const started = Date.now();
187
+ const cacheKey = request.cacheKey && `local:${spec.id}:${request.cacheKey}`;
188
+ if (cacheKey) {
189
+ const parsed = request.schema.safeParse(await cachedResponse(request.repositoryRoot, cacheKey));
190
+ if (parsed.success) {
191
+ return {
192
+ ok: true,
193
+ value: parsed.data,
194
+ model: spec.id,
195
+ latencyMs: Date.now() - started,
196
+ tokens: 0,
197
+ cached: true
198
+ };
199
+ }
200
+ }
201
+ const result = await localChatJson(spec, request);
202
+ if (result.ok && cacheKey) await storeResponse(request.repositoryRoot, cacheKey, result.value);
203
+ return result;
204
+ }
205
+ function parseJsonContent(raw) {
206
+ const content = raw.replace(/<think>[\s\S]*?<\/think>/g, "");
180
207
  const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1];
181
208
  try {
182
209
  return JSON.parse((fenced ?? content).trim());
@@ -188,7 +215,8 @@ function estimateTokens(request, content) {
188
215
  return Math.ceil((request.system.length + request.user.length + content.length) / 4);
189
216
  }
190
217
  function redact(config, text) {
191
- return redactLlmKeys(text).split(config.apiKey).join("[REDACTED]");
218
+ const redacted = redactLlmKeys(text);
219
+ return config.apiKey ? redacted.split(config.apiKey).join("[REDACTED]") : redacted;
192
220
  }
193
221
 
194
222
  export {
@@ -196,4 +224,4 @@ export {
196
224
  coolingDown,
197
225
  chatJson
198
226
  };
199
- //# sourceMappingURL=chunk-ESZXCZIN.js.map
227
+ //# sourceMappingURL=chunk-6YI2D5F5.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/llm/budget.ts","../src/llm/client.ts"],"sourcesContent":["import { mkdir, readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { writeAtomic } from \"../core/markdown-store-fs.js\";\n\n/**\n * Free-tier hygiene for the LLM layer, all in `.codebuddy/cache/` (gitignored,\n * rebuildable): a per-day token ledger per provider:model, a cooldown after\n * 429s, and a response cache. Background work stops at 70% of the daily\n * budget so prompt-time calls keep a reserved 30%.\n *\n * ponytail: read-modify-write JSON without a lock; two hook processes can lose\n * one update. Fine for a soft budget; add the staging lock if it ever matters.\n */\nconst BACKGROUND_SHARE = 0.7;\nconst CACHE_LIMIT = 2_000;\n\nconst file = (root: string, name: string) => join(root, \".codebuddy\", \"cache\", name);\n\nasync function readJson<T>(path: string, fallback: T): Promise<T> {\n try {\n return JSON.parse(await readFile(path, \"utf8\")) as T;\n } catch {\n return fallback;\n }\n}\n\nasync function writeJson(path: string, value: unknown): Promise<void> {\n await mkdir(join(path, \"..\"), { recursive: true });\n await writeAtomic(path, `${JSON.stringify(value)}\\n`);\n}\n\ntype Ledger = { day: string; used: Record<string, number> };\nconst utcDay = (now: Date) => now.toISOString().slice(0, 10);\n\nexport async function usageToday(root: string, now = new Date()): Promise<Record<string, number>> {\n const ledger = await readJson<Ledger>(file(root, \"llm-usage.json\"), { day: \"\", used: {} });\n return ledger.day === utcDay(now) ? ledger.used : {};\n}\n\nexport async function recordUsage(\n root: string,\n key: string,\n tokens: number,\n now = new Date(),\n): Promise<void> {\n const used = await usageToday(root, now);\n used[key] = (used[key] ?? 0) + tokens;\n await writeJson(file(root, \"llm-usage.json\"), { day: utcDay(now), used });\n}\n\n/** Whether `estimate` more tokens fit today's share for this purpose. */\nexport async function reserveBudget(\n root: string,\n key: string,\n purpose: \"prompt\" | \"background\",\n estimate: number,\n dailyBudget: number,\n now = new Date(),\n): Promise<boolean> {\n const limit = purpose === \"background\" ? dailyBudget * BACKGROUND_SHARE : dailyBudget;\n return ((await usageToday(root, now))[key] ?? 0) + estimate <= limit;\n}\n\n/** Milliseconds of cooldown left for this provider:model (0 = none). */\nexport async function coolingDown(root: string, key: string, now = new Date()): Promise<number> {\n const until = (await readJson<Record<string, number>>(file(root, \"llm-cooldown.json\"), {}))[key];\n return until ? Math.max(0, until - now.getTime()) : 0;\n}\n\nexport async function startCooldown(\n root: string,\n key: string,\n seconds: number,\n now = new Date(),\n): Promise<void> {\n const path = file(root, \"llm-cooldown.json\");\n const cooldowns = await readJson<Record<string, number>>(path, {});\n cooldowns[key] = now.getTime() + seconds * 1_000;\n await writeJson(path, cooldowns);\n}\n\nexport async function cachedResponse(root: string, key: string): Promise<unknown | undefined> {\n return (await readJson<Record<string, unknown>>(file(root, \"llm-responses.json\"), {}))[key];\n}\n\n/** Insertion-ordered; the oldest entries go once the cache holds CACHE_LIMIT. */\nexport async function storeResponse(root: string, key: string, value: unknown): Promise<void> {\n const path = file(root, \"llm-responses.json\");\n const cache = await readJson<Record<string, unknown>>(path, {});\n delete cache[key];\n cache[key] = value;\n const keys = Object.keys(cache);\n for (const old of keys.slice(0, Math.max(0, keys.length - CACHE_LIMIT))) delete cache[old];\n await writeJson(path, cache);\n}\n","import type { z } from \"zod\";\nimport {\n cachedResponse,\n coolingDown,\n recordUsage,\n reserveBudget,\n startCooldown,\n storeResponse,\n} from \"./budget.js\";\nimport { type EnabledLlmConfig, redactLlmKeys } from \"./config.js\";\nimport { type LocalModelSpec, localChatJson } from \"./local.js\";\n\n/** Seconds to back off after a 429 without a usable retry-after header. */\nconst DEFAULT_COOLDOWN_SECONDS = 60;\n\n/**\n * One OpenAI-compatible chat call that must answer with JSON (Groq and\n * OpenRouter share the API). It never throws: every failure is a reason the\n * caller turns into today's deterministic behaviour plus an explain line.\n */\nexport type LlmFailure =\n | \"disabled\"\n | \"timeout\"\n | \"rate-limited\"\n | \"cooldown\"\n | \"budget\"\n | \"http\"\n | \"invalid\";\n\nexport type LlmResult<T> =\n | { ok: true; value: T; model: string; latencyMs: number; tokens: number; cached: boolean }\n | { ok: false; reason: LlmFailure; detail: string; latencyMs: number };\n\nexport type ChatJsonRequest<T> = {\n system: string;\n user: string;\n schema: z.ZodType<T>;\n maxTokens: number;\n deadlineMs: number;\n /** Prompt-path calls get the reserved budget share; background work stops earlier. */\n purpose: \"prompt\" | \"background\";\n /**\n * Local backend only: a JSON schema for the generation grammar (with\n * `maxItems`/`maxLength` bounds) instead of one derived from `schema`.\n */\n jsonSchema?: object;\n /** Cache identity (inputs + prompt version); omitted = never cached. */\n cacheKey?: string;\n repositoryRoot: string;\n};\n\nexport async function chatJson<T>(\n config: EnabledLlmConfig,\n request: ChatJsonRequest<T>,\n): Promise<LlmResult<T>> {\n const started = Date.now();\n if (config.local) return localCall(config.local, request);\n try {\n return await call(config, request, started);\n } catch (error) {\n // Structural guarantee: an unexpected body shape is an invalid answer, not a crash.\n return {\n ok: false,\n reason: \"invalid\",\n detail: redact(config, String((error as Error)?.message ?? error)).slice(0, 300),\n latencyMs: Date.now() - started,\n };\n }\n}\n\nasync function call<T>(\n config: EnabledLlmConfig,\n request: ChatJsonRequest<T>,\n started: number,\n): Promise<LlmResult<T>> {\n const fail = (reason: LlmFailure, detail: string): LlmResult<T> => ({\n ok: false,\n reason,\n detail: redact(config, detail).slice(0, 300),\n latencyMs: Date.now() - started,\n });\n const root = request.repositoryRoot;\n const ledgerKey = `${config.provider}:${config.model}`;\n const cooling = await coolingDown(root, ledgerKey);\n if (cooling > 0) return fail(\"cooldown\", `rate limited; retry in ${Math.ceil(cooling / 1000)} s`);\n const cacheKey = request.cacheKey && `${ledgerKey}:${request.cacheKey}`;\n if (cacheKey) {\n const parsed = request.schema.safeParse(await cachedResponse(root, cacheKey));\n if (parsed.success) {\n return {\n ok: true,\n value: parsed.data,\n model: config.model,\n latencyMs: Date.now() - started,\n tokens: 0,\n cached: true,\n };\n }\n }\n const estimate = estimateTokens(request, \"\") + request.maxTokens;\n if (!(await reserveBudget(root, ledgerKey, request.purpose, estimate, config.dailyTokenBudget))) {\n return fail(\"budget\", `daily ${request.purpose} budget for ${ledgerKey} is spent`);\n }\n let response: Response;\n try {\n response = await fetch(`${config.baseUrl}/chat/completions`, {\n method: \"POST\",\n headers: {\n authorization: `Bearer ${config.apiKey}`,\n \"content-type\": \"application/json\",\n ...(config.provider === \"openrouter\" ? { \"x-title\": \"CodeBuddy\" } : {}),\n },\n body: JSON.stringify({\n model: config.model,\n messages: [\n { role: \"system\", content: request.system },\n { role: \"user\", content: request.user },\n ],\n temperature: 0,\n max_tokens: request.maxTokens,\n response_format: { type: \"json_object\" },\n // Qwen3 reasons by default on some hosts; cards and reranks need none.\n ...(config.provider === \"groq\" && /^qwen\\//.test(config.model)\n ? { reasoning_effort: \"none\" }\n : {}),\n }),\n signal: AbortSignal.timeout(request.deadlineMs),\n });\n } catch (error) {\n const name = (error as Error)?.name;\n if (name === \"TimeoutError\" || name === \"AbortError\") {\n return fail(\"timeout\", `no answer within ${request.deadlineMs} ms`);\n }\n return fail(\"http\", (error as Error)?.message ?? String(error));\n }\n let body: string;\n try {\n body = await response.text();\n } catch (error) {\n const name = (error as Error)?.name;\n return fail(\n name === \"TimeoutError\" || name === \"AbortError\" ? \"timeout\" : \"http\",\n String(error),\n );\n }\n if (response.status === 429) {\n const retryAfter = Number(response.headers.get(\"retry-after\"));\n await startCooldown(\n root,\n ledgerKey,\n Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : DEFAULT_COOLDOWN_SECONDS,\n );\n return fail(\"rate-limited\", body);\n }\n if (!response.ok) return fail(\"http\", `HTTP ${response.status}: ${body}`);\n\n let completion: {\n model?: unknown;\n choices?: Array<{ message?: { content?: unknown } }>;\n usage?: { total_tokens?: unknown };\n } | null;\n try {\n completion = JSON.parse(body);\n } catch {\n return fail(\"invalid\", \"response is not JSON\");\n }\n const raw = completion?.choices?.[0]?.message?.content;\n if (typeof raw !== \"string\") return fail(\"invalid\", \"answer has no text content\");\n const content = raw;\n const reported = completion?.usage?.total_tokens;\n const tokens =\n typeof reported === \"number\" && Number.isFinite(reported)\n ? reported\n : estimateTokens(request, content);\n await recordUsage(root, ledgerKey, tokens);\n const parsed = request.schema.safeParse(parseJsonContent(content));\n if (!parsed.success) return fail(\"invalid\", `output failed validation: ${content.slice(0, 120)}`);\n if (cacheKey) await storeResponse(root, cacheKey, parsed.data);\n return {\n ok: true,\n value: parsed.data,\n model: typeof completion?.model === \"string\" ? completion.model : config.model,\n latencyMs: Date.now() - started,\n tokens,\n cached: false,\n };\n}\n\n/** Local generation: same response cache, no budget ledger or cooldown (no cost, no quota). */\nasync function localCall<T>(\n spec: LocalModelSpec,\n request: ChatJsonRequest<T>,\n): Promise<LlmResult<T>> {\n const started = Date.now();\n const cacheKey = request.cacheKey && `local:${spec.id}:${request.cacheKey}`;\n if (cacheKey) {\n const parsed = request.schema.safeParse(await cachedResponse(request.repositoryRoot, cacheKey));\n if (parsed.success) {\n return {\n ok: true,\n value: parsed.data,\n model: spec.id,\n latencyMs: Date.now() - started,\n tokens: 0,\n cached: true,\n };\n }\n }\n const result = await localChatJson(spec, request);\n if (result.ok && cacheKey) await storeResponse(request.repositoryRoot, cacheKey, result.value);\n return result;\n}\n\n/** Free models often wrap JSON in a ```json fence despite json_object mode. */\nfunction parseJsonContent(raw: string): unknown {\n // A Qwen3 host may still prepend an (empty) reasoning block.\n const content = raw.replace(/<think>[\\s\\S]*?<\\/think>/g, \"\");\n const fenced = content.match(/```(?:json)?\\s*([\\s\\S]*?)```/i)?.[1];\n try {\n return JSON.parse((fenced ?? content).trim());\n } catch {\n return undefined;\n }\n}\n\nfunction estimateTokens(request: { system: string; user: string }, content: string): number {\n return Math.ceil((request.system.length + request.user.length + content.length) / 4);\n}\n\nfunction redact(config: EnabledLlmConfig, text: string): string {\n const redacted = redactLlmKeys(text);\n return config.apiKey ? redacted.split(config.apiKey).join(\"[REDACTED]\") : redacted;\n}\n"],"mappings":"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","names":["parsed"]}
@@ -1,7 +1,9 @@
1
1
  import {
2
- countTokens,
2
+ countTokens
3
+ } from "./chunk-57642NGU.js";
4
+ import {
3
5
  generateEntityId
4
- } from "./chunk-CAEPHLHC.js";
6
+ } from "./chunk-EMWGLKPR.js";
5
7
  import {
6
8
  writeAtomic
7
9
  } from "./chunk-CG5B3LYG.js";
@@ -331,4 +333,4 @@ export {
331
333
  coveringTestCandidates,
332
334
  detectTestCommand
333
335
  };
334
- //# sourceMappingURL=chunk-4DKYKDHI.js.map
336
+ //# sourceMappingURL=chunk-7IPSDDO5.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/savings/tokens.ts","../src/context/session-ledger.ts","../src/context/tests.ts"],"sourcesContent":["import { countTokens } from \"../planner/budget.js\";\n\n/**\n * The one place token counts are computed. Strings use the same deterministic\n * cl100k tokenizer as planner budgets; objects are measured by their JSON\n * serialization, which matches how they travel over MCP.\n */\n\nexport function estimateTokens(value: unknown): number {\n if (value === undefined || value === null) return 0;\n const text = typeof value === \"string\" ? value : JSON.stringify(value);\n if (!text) return 0;\n return countTokens(text);\n}\n","import { createHash } from \"node:crypto\";\nimport { mkdir, readdir, readFile, rm, stat } from \"node:fs/promises\";\nimport { join, resolve } from \"node:path\";\nimport { generateEntityId } from \"../core/ids.js\";\nimport { writeAtomic } from \"../core/markdown-store-fs.js\";\nimport { estimateTokens } from \"../savings/tokens.js\";\n\nconst VERSION = 2;\nconst MAX_CAPSULES = 128;\nconst MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;\nconst MAX_PENDING = 8;\nconst PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1000;\nconst STALE_LOCK_MS = 60_000;\n/** Acknowledged versions kept per section, so switching back to a task reuses them. */\nconst MAX_ACKNOWLEDGED_PER_KEY = 8;\n\ntype LedgerRecord = { hash: string; lastSentAt: string };\n/** A delivery sent to a client that has not yet confirmed receipt. */\ntype PendingDelivery = { createdAt: string; records: Record<string, string> };\n/** One acknowledged version of an `exchange` section: content hash and when it was sent. */\ntype AcknowledgedVersion = { hash: string; sentAt: string };\ntype LedgerFile = {\n version: 2;\n updatedAt: string;\n /** `deliver`/`markSent` capsules (hooks): one current hash per key. */\n records: Record<string, LedgerRecord>;\n pending?: Record<string, PendingDelivery>;\n /** `exchange` sections (MCP): every recently acknowledged version, newest first. */\n acknowledged?: Record<string, AcknowledgedVersion[]>;\n};\n\nexport type ContextCapsule = { key: string; text: string };\nexport type CapsuleDelivery = {\n /** Full text of new or changed capsules, in input order. */\n lines: string[];\n /** Keys of capsules already sent this session and not resent. */\n unchanged: string[];\n full: number;\n referenced: number;\n sentTokens: number;\n avoidedTokens: number;\n};\n\nexport type SectionHash = { key: string; hash: string };\nexport type AcknowledgedExchange = {\n deliveryId: string;\n /** Keys whose content the client confirmed receiving and that have not changed. */\n unchanged: string[];\n ack: \"none\" | \"accepted\" | \"unknown\";\n};\n\nexport class SessionCapsuleLedger {\n private readonly directory: string;\n\n constructor(repositoryRoot = process.cwd()) {\n this.directory = join(resolve(repositoryRoot), \".codebuddy\", \"cache\", \"session\");\n }\n\n async deliver(\n sessionId: string,\n capsules: ContextCapsule[],\n now = new Date(),\n ): Promise<CapsuleDelivery> {\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n const path = this.path(sessionId);\n const ledger = await this.read(path, now);\n const lines: string[] = [];\n const unchanged: string[] = [];\n let full = 0;\n let referenced = 0;\n let sentTokens = 0;\n let avoidedTokens = 0;\n\n for (const capsule of capsules) {\n const hash = digest(capsule.text);\n const existing = ledger.records[capsule.key];\n if (existing?.hash === hash) {\n unchanged.push(capsule.key);\n referenced++;\n avoidedTokens += estimateTokens(capsule.text);\n } else {\n lines.push(capsule.text);\n full++;\n sentTokens += estimateTokens(capsule.text);\n }\n ledger.records[capsule.key] = { hash, lastSentAt: now.toISOString() };\n }\n\n await this.write(path, ledger, now);\n return { lines, unchanged, full, referenced, sentTokens, avoidedTokens };\n } finally {\n await release();\n }\n }\n\n /** Keys of capsules whose current text was already recorded as sent this session. */\n async seen(\n sessionId: string,\n capsules: ContextCapsule[],\n now = new Date(),\n ): Promise<Set<string>> {\n const ledger = await this.read(this.path(sessionId), now);\n return new Set(\n capsules\n .filter((capsule) => ledger.records[capsule.key]?.hash === digest(capsule.text))\n .map((capsule) => capsule.key),\n );\n }\n\n /** Whether anything was delivered in this session yet (a later prompt is a follow-up). */\n async delivered(sessionId: string, now = new Date()): Promise<boolean> {\n const ledger = await this.read(this.path(sessionId), now);\n return Object.keys(ledger.records).length > 0;\n }\n\n /**\n * Record capsules as delivered. Call only after their text actually reached\n * the client, so capsules that were clipped or failed to write stay unseen\n * and are delivered by a later prompt.\n */\n async markSent(sessionId: string, capsules: ContextCapsule[], now = new Date()): Promise<void> {\n if (capsules.length === 0) return;\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n const path = this.path(sessionId);\n const ledger = await this.read(path, now);\n for (const capsule of capsules) {\n ledger.records[capsule.key] = { hash: digest(capsule.text), lastSentAt: now.toISOString() };\n }\n await this.write(path, ledger, now);\n } finally {\n await release();\n }\n }\n\n /**\n * Acknowledged delivery for clients that can lose context (MCP). Unlike\n * `deliver`, sending marks nothing as seen: the sent hashes are held as a\n * pending delivery until the client echoes its `deliveryId` back as `ack` on\n * a later call. Only acknowledged, unchanged sections sent within `ttlMs` are\n * reported unchanged, so a dropped response or a compacted client context\n * fails safe to a full resend.\n *\n * A section keeps its last few acknowledged versions (by content hash), not\n * just the latest, so returning to an earlier task in the same session does\n * not resend what the client already holds. `full` means the client lost its\n * context: every acknowledged version and unconfirmed delivery is forgotten.\n */\n async exchange(\n sessionId: string,\n sections: SectionHash[],\n options: {\n ack?: string;\n ttlMs: number;\n /** The client lost its context: nothing acknowledged before this call counts. */\n full?: boolean;\n now?: Date;\n /** Finalize the response before recording hashes of the actual sent sections. */\n prepare?: (exchange: AcknowledgedExchange) => SectionHash[];\n },\n ): Promise<AcknowledgedExchange> {\n const now = options.now ?? new Date();\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n const path = this.path(sessionId);\n const ledger = await this.read(path, now);\n let pending = ledger.pending ?? {};\n let acknowledged = ledger.acknowledged ?? {};\n\n let ack: AcknowledgedExchange[\"ack\"] = \"none\";\n if (options.ack !== undefined) {\n const confirmed = pending[options.ack];\n if (confirmed) {\n for (const [key, hash] of Object.entries(confirmed.records)) {\n acknowledged[key] = [\n { hash, sentAt: confirmed.createdAt },\n ...(acknowledged[key] ?? []).filter((version) => version.hash !== hash),\n ]\n .sort((left, right) => right.sentAt.localeCompare(left.sentAt))\n .slice(0, MAX_ACKNOWLEDGED_PER_KEY);\n }\n delete pending[options.ack];\n ack = \"accepted\";\n } else {\n ack = \"unknown\";\n }\n }\n if (options.full) {\n acknowledged = {};\n pending = {};\n }\n\n const unchanged: string[] = [];\n const sent: Record<string, string> = {};\n for (const section of sections) {\n const held = acknowledged[section.key]?.some((version) => {\n const age = now.getTime() - Date.parse(version.sentAt);\n return version.hash === section.hash && age >= 0 && age <= options.ttlMs;\n });\n if (held) {\n unchanged.push(section.key);\n } else {\n sent[section.key] = section.hash;\n }\n }\n\n const deliveryId = generateEntityId(\"dlv\", now.getTime());\n const exchange = { deliveryId, unchanged, ack };\n const delivered = options.prepare?.(exchange);\n pending[deliveryId] = {\n createdAt: now.toISOString(),\n records: delivered\n ? Object.fromEntries(delivered.map(({ key, hash }) => [key, hash]))\n : sent,\n };\n ledger.pending = Object.fromEntries(\n Object.entries(pending)\n .filter(([, item]) => now.getTime() - Date.parse(item.createdAt) <= PENDING_MAX_AGE_MS)\n .sort((left, right) => right[1].createdAt.localeCompare(left[1].createdAt))\n .slice(0, MAX_PENDING),\n );\n ledger.acknowledged = Object.fromEntries(\n Object.entries(acknowledged)\n .map(([key, versions]): [string, AcknowledgedVersion[]] => [\n key,\n versions.filter((version) => now.getTime() - Date.parse(version.sentAt) <= MAX_AGE_MS),\n ])\n .filter(([, versions]) => versions.length > 0)\n .slice(0, MAX_CAPSULES),\n );\n await this.write(path, ledger, now);\n return { deliveryId, unchanged, ack };\n } finally {\n await release();\n }\n }\n\n /** Forget everything sent in a session, e.g. after the client compacted its context. */\n async reset(sessionId: string): Promise<void> {\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n await rm(this.path(sessionId), { force: true });\n } finally {\n await release();\n }\n }\n\n /** Remove expired disposable state; locked sessions are left for the next pass. */\n async pruneExpired(now = new Date()): Promise<number> {\n let removed = 0;\n for (const entry of await readdir(this.directory).catch(() => [])) {\n if (!entry.endsWith(\".json\")) continue;\n const path = join(this.directory, entry);\n try {\n await stat(`${path}.lock`);\n continue;\n } catch {\n // No active lock.\n }\n try {\n const metadata = await stat(path);\n if (now.getTime() - metadata.mtimeMs > MAX_AGE_MS) {\n await rm(path, { force: true });\n removed++;\n }\n } catch {\n // Another cleanup may have removed it already.\n }\n }\n return removed;\n }\n\n private async write(path: string, ledger: LedgerFile, now: Date): Promise<void> {\n ledger.updatedAt = now.toISOString();\n ledger.records = Object.fromEntries(\n Object.entries(ledger.records)\n .sort((left, right) => right[1].lastSentAt.localeCompare(left[1].lastSentAt))\n .slice(0, MAX_CAPSULES),\n );\n await writeAtomic(path, `${JSON.stringify(ledger, null, 2)}\\n`);\n }\n\n private async read(path: string, now: Date): Promise<LedgerFile> {\n try {\n const parsed = JSON.parse(await readFile(path, \"utf8\")) as LedgerFile;\n if (parsed.version !== VERSION || typeof parsed.records !== \"object\") return empty(now);\n const records = Object.fromEntries(\n Object.entries(parsed.records).filter(([, record]) => {\n const age = now.getTime() - Date.parse(record.lastSentAt);\n return Number.isFinite(age) && age <= MAX_AGE_MS;\n }),\n );\n const pending =\n parsed.pending && typeof parsed.pending === \"object\" ? parsed.pending : undefined;\n const acknowledged =\n parsed.acknowledged && typeof parsed.acknowledged === \"object\"\n ? Object.fromEntries(\n Object.entries(parsed.acknowledged).filter(([, versions]) => Array.isArray(versions)),\n )\n : undefined;\n return {\n version: VERSION,\n updatedAt: parsed.updatedAt,\n records,\n ...(pending ? { pending } : {}),\n ...(acknowledged ? { acknowledged } : {}),\n };\n } catch {\n return empty(now);\n }\n }\n\n private path(sessionId: string): string {\n // The full identity participates in the key. Legacy sanitized filenames\n // are intentionally ignored, so a fresh session cannot inherit collisions.\n return join(this.directory, `${digest(sessionId)}.json`);\n }\n\n private async acquire(sessionId: string): Promise<() => Promise<void>> {\n const lock = `${this.path(sessionId)}.lock`;\n for (let attempt = 0; ; attempt++) {\n try {\n await mkdir(lock);\n return async () => rm(lock, { recursive: true, force: true });\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== \"EEXIST\" || attempt >= 200) throw error;\n try {\n const age = Date.now() - (await stat(lock)).mtimeMs;\n if (age > STALE_LOCK_MS) {\n await rm(lock, { recursive: true, force: true });\n continue;\n }\n } catch {\n // The owner may have released the lock between checks.\n }\n await new Promise((resolveWait) => setTimeout(resolveWait, 10));\n }\n }\n }\n}\n\nfunction empty(now: Date): LedgerFile {\n return { version: VERSION, updatedAt: now.toISOString(), records: {} };\n}\n\nfunction digest(text: string): string {\n return createHash(\"sha256\").update(text).digest(\"hex\");\n}\n","import { access, readFile } from \"node:fs/promises\";\nimport { basename, dirname, extname, join } from \"node:path\";\n\n/**\n * Which test covers a file and how this repo runs its tests, from naming\n * conventions and project markers only. Agents otherwise spend turns finding\n * both; a wrong guess costs one failed command, so first match wins.\n */\nexport function findCoveringTest(path: string, paths: ReadonlySet<string>): string | null {\n return coveringTestCandidates(path).find((candidate) => paths.has(candidate)) ?? null;\n}\n\n/** Conventional covering-test paths for `path`, most specific first. */\nexport function coveringTestCandidates(path: string): string[] {\n const ext = extname(path);\n const stem = basename(path, ext);\n const dir = dirname(path);\n const join2 = (...parts: string[]) => parts.filter((part) => part && part !== \".\").join(\"/\");\n // `src/a/x.ts` mirrors to `test/a/x.test.ts`: drop a leading source root.\n const mirrored = dir.replace(/^(src|lib|app)(\\/|$)/, \"\");\n return ext === \".py\"\n ? [\n join2(dir, `test_${stem}.py`),\n join2(dir, `${stem}_test.py`),\n join2(dir, \"tests\", `test_${stem}.py`),\n // `src/pkg/sub/x.py` → `tests/pkg/sub/test_x.py`, `tests/sub/test_x.py`, `tests/test_x.py`.\n join2(\"tests\", mirrored, `test_${stem}.py`),\n join2(\"tests\", mirrored.replace(/^[^/]+\\/?/, \"\"), `test_${stem}.py`),\n join2(\"tests\", `test_${stem}.py`),\n ]\n : ext === \".go\"\n ? [join2(dir, `${stem}_test.go`)]\n : [\n join2(dir, `${stem}.test${ext}`),\n join2(dir, `${stem}.spec${ext}`),\n join2(dir, \"__tests__\", `${stem}.test${ext}`),\n join2(dir, \"__tests__\", `${stem}${ext}`),\n join2(\"test\", mirrored, `${stem}.test${ext}`),\n join2(\"tests\", mirrored, `${stem}.test${ext}`),\n ];\n}\n\nconst NPM_PLACEHOLDER = /no test specified/;\n\nexport async function detectTestCommand(repositoryRoot: string): Promise<string | null> {\n const exists = (file: string) =>\n access(join(repositoryRoot, file)).then(\n () => true,\n () => false,\n );\n try {\n const pkg = JSON.parse(await readFile(join(repositoryRoot, \"package.json\"), \"utf8\")) as {\n scripts?: Record<string, unknown>;\n packageManager?: unknown;\n };\n const script = pkg.scripts?.test;\n if (typeof script === \"string\" && script.trim() && !NPM_PLACEHOLDER.test(script)) {\n const declared =\n typeof pkg.packageManager === \"string\" ? pkg.packageManager.split(/[\\s@]/, 1)[0] : \"\";\n // `bun test` is Bun's own runner, not the package's test script.\n if (declared === \"bun\") return \"bun run test\";\n if (declared && [\"npm\", \"pnpm\", \"yarn\"].includes(declared)) return `${declared} test`;\n if (await exists(\"pnpm-lock.yaml\")) return \"pnpm test\";\n if (await exists(\"yarn.lock\")) return \"yarn test\";\n if ((await exists(\"bun.lockb\")) || (await exists(\"bun.lock\"))) return \"bun run test\";\n return \"npm test\";\n }\n } catch {\n // No or unreadable package.json: try other ecosystems.\n }\n for (const marker of [\"pyproject.toml\", \"pytest.ini\", \"setup.cfg\"]) {\n if (await exists(marker)) return \"pytest\";\n }\n if (await exists(\"go.mod\")) return \"go test ./...\";\n if (await exists(\"Cargo.toml\")) return \"cargo test\";\n return null;\n}\n"],"mappings":"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","names":["readFile","join","join2"]}
1
+ {"version":3,"sources":["../src/savings/tokens.ts","../src/context/session-ledger.ts","../src/context/tests.ts"],"sourcesContent":["import { countTokens } from \"../planner/budget.js\";\n\n/**\n * The one place token counts are computed. Strings use the same deterministic\n * cl100k tokenizer as planner budgets; objects are measured by their JSON\n * serialization, which matches how they travel over MCP.\n */\n\nexport function estimateTokens(value: unknown): number {\n if (value === undefined || value === null) return 0;\n const text = typeof value === \"string\" ? value : JSON.stringify(value);\n if (!text) return 0;\n return countTokens(text);\n}\n","import { createHash } from \"node:crypto\";\nimport { mkdir, readdir, readFile, rm, stat } from \"node:fs/promises\";\nimport { join, resolve } from \"node:path\";\nimport { generateEntityId } from \"../core/ids.js\";\nimport { writeAtomic } from \"../core/markdown-store-fs.js\";\nimport { estimateTokens } from \"../savings/tokens.js\";\n\nconst VERSION = 2;\nconst MAX_CAPSULES = 128;\nconst MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;\nconst MAX_PENDING = 8;\nconst PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1000;\nconst STALE_LOCK_MS = 60_000;\n/** Acknowledged versions kept per section, so switching back to a task reuses them. */\nconst MAX_ACKNOWLEDGED_PER_KEY = 8;\n\ntype LedgerRecord = { hash: string; lastSentAt: string };\n/** A delivery sent to a client that has not yet confirmed receipt. */\ntype PendingDelivery = { createdAt: string; records: Record<string, string> };\n/** One acknowledged version of an `exchange` section: content hash and when it was sent. */\ntype AcknowledgedVersion = { hash: string; sentAt: string };\ntype LedgerFile = {\n version: 2;\n updatedAt: string;\n /** `deliver`/`markSent` capsules (hooks): one current hash per key. */\n records: Record<string, LedgerRecord>;\n pending?: Record<string, PendingDelivery>;\n /** `exchange` sections (MCP): every recently acknowledged version, newest first. */\n acknowledged?: Record<string, AcknowledgedVersion[]>;\n};\n\nexport type ContextCapsule = { key: string; text: string };\nexport type CapsuleDelivery = {\n /** Full text of new or changed capsules, in input order. */\n lines: string[];\n /** Keys of capsules already sent this session and not resent. */\n unchanged: string[];\n full: number;\n referenced: number;\n sentTokens: number;\n avoidedTokens: number;\n};\n\nexport type SectionHash = { key: string; hash: string };\nexport type AcknowledgedExchange = {\n deliveryId: string;\n /** Keys whose content the client confirmed receiving and that have not changed. */\n unchanged: string[];\n ack: \"none\" | \"accepted\" | \"unknown\";\n};\n\nexport class SessionCapsuleLedger {\n private readonly directory: string;\n\n constructor(repositoryRoot = process.cwd()) {\n this.directory = join(resolve(repositoryRoot), \".codebuddy\", \"cache\", \"session\");\n }\n\n async deliver(\n sessionId: string,\n capsules: ContextCapsule[],\n now = new Date(),\n ): Promise<CapsuleDelivery> {\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n const path = this.path(sessionId);\n const ledger = await this.read(path, now);\n const lines: string[] = [];\n const unchanged: string[] = [];\n let full = 0;\n let referenced = 0;\n let sentTokens = 0;\n let avoidedTokens = 0;\n\n for (const capsule of capsules) {\n const hash = digest(capsule.text);\n const existing = ledger.records[capsule.key];\n if (existing?.hash === hash) {\n unchanged.push(capsule.key);\n referenced++;\n avoidedTokens += estimateTokens(capsule.text);\n } else {\n lines.push(capsule.text);\n full++;\n sentTokens += estimateTokens(capsule.text);\n }\n ledger.records[capsule.key] = { hash, lastSentAt: now.toISOString() };\n }\n\n await this.write(path, ledger, now);\n return { lines, unchanged, full, referenced, sentTokens, avoidedTokens };\n } finally {\n await release();\n }\n }\n\n /** Keys of capsules whose current text was already recorded as sent this session. */\n async seen(\n sessionId: string,\n capsules: ContextCapsule[],\n now = new Date(),\n ): Promise<Set<string>> {\n const ledger = await this.read(this.path(sessionId), now);\n return new Set(\n capsules\n .filter((capsule) => ledger.records[capsule.key]?.hash === digest(capsule.text))\n .map((capsule) => capsule.key),\n );\n }\n\n /** Whether anything was delivered in this session yet (a later prompt is a follow-up). */\n async delivered(sessionId: string, now = new Date()): Promise<boolean> {\n const ledger = await this.read(this.path(sessionId), now);\n return Object.keys(ledger.records).length > 0;\n }\n\n /**\n * Record capsules as delivered. Call only after their text actually reached\n * the client, so capsules that were clipped or failed to write stay unseen\n * and are delivered by a later prompt.\n */\n async markSent(sessionId: string, capsules: ContextCapsule[], now = new Date()): Promise<void> {\n if (capsules.length === 0) return;\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n const path = this.path(sessionId);\n const ledger = await this.read(path, now);\n for (const capsule of capsules) {\n ledger.records[capsule.key] = { hash: digest(capsule.text), lastSentAt: now.toISOString() };\n }\n await this.write(path, ledger, now);\n } finally {\n await release();\n }\n }\n\n /**\n * Acknowledged delivery for clients that can lose context (MCP). Unlike\n * `deliver`, sending marks nothing as seen: the sent hashes are held as a\n * pending delivery until the client echoes its `deliveryId` back as `ack` on\n * a later call. Only acknowledged, unchanged sections sent within `ttlMs` are\n * reported unchanged, so a dropped response or a compacted client context\n * fails safe to a full resend.\n *\n * A section keeps its last few acknowledged versions (by content hash), not\n * just the latest, so returning to an earlier task in the same session does\n * not resend what the client already holds. `full` means the client lost its\n * context: every acknowledged version and unconfirmed delivery is forgotten.\n */\n async exchange(\n sessionId: string,\n sections: SectionHash[],\n options: {\n ack?: string;\n ttlMs: number;\n /** The client lost its context: nothing acknowledged before this call counts. */\n full?: boolean;\n now?: Date;\n /** Finalize the response before recording hashes of the actual sent sections. */\n prepare?: (exchange: AcknowledgedExchange) => SectionHash[];\n },\n ): Promise<AcknowledgedExchange> {\n const now = options.now ?? new Date();\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n const path = this.path(sessionId);\n const ledger = await this.read(path, now);\n let pending = ledger.pending ?? {};\n let acknowledged = ledger.acknowledged ?? {};\n\n let ack: AcknowledgedExchange[\"ack\"] = \"none\";\n if (options.ack !== undefined) {\n const confirmed = pending[options.ack];\n if (confirmed) {\n for (const [key, hash] of Object.entries(confirmed.records)) {\n acknowledged[key] = [\n { hash, sentAt: confirmed.createdAt },\n ...(acknowledged[key] ?? []).filter((version) => version.hash !== hash),\n ]\n .sort((left, right) => right.sentAt.localeCompare(left.sentAt))\n .slice(0, MAX_ACKNOWLEDGED_PER_KEY);\n }\n delete pending[options.ack];\n ack = \"accepted\";\n } else {\n ack = \"unknown\";\n }\n }\n if (options.full) {\n acknowledged = {};\n pending = {};\n }\n\n const unchanged: string[] = [];\n const sent: Record<string, string> = {};\n for (const section of sections) {\n const held = acknowledged[section.key]?.some((version) => {\n const age = now.getTime() - Date.parse(version.sentAt);\n return version.hash === section.hash && age >= 0 && age <= options.ttlMs;\n });\n if (held) {\n unchanged.push(section.key);\n } else {\n sent[section.key] = section.hash;\n }\n }\n\n const deliveryId = generateEntityId(\"dlv\", now.getTime());\n const exchange = { deliveryId, unchanged, ack };\n const delivered = options.prepare?.(exchange);\n pending[deliveryId] = {\n createdAt: now.toISOString(),\n records: delivered\n ? Object.fromEntries(delivered.map(({ key, hash }) => [key, hash]))\n : sent,\n };\n ledger.pending = Object.fromEntries(\n Object.entries(pending)\n .filter(([, item]) => now.getTime() - Date.parse(item.createdAt) <= PENDING_MAX_AGE_MS)\n .sort((left, right) => right[1].createdAt.localeCompare(left[1].createdAt))\n .slice(0, MAX_PENDING),\n );\n ledger.acknowledged = Object.fromEntries(\n Object.entries(acknowledged)\n .map(([key, versions]): [string, AcknowledgedVersion[]] => [\n key,\n versions.filter((version) => now.getTime() - Date.parse(version.sentAt) <= MAX_AGE_MS),\n ])\n .filter(([, versions]) => versions.length > 0)\n .slice(0, MAX_CAPSULES),\n );\n await this.write(path, ledger, now);\n return { deliveryId, unchanged, ack };\n } finally {\n await release();\n }\n }\n\n /** Forget everything sent in a session, e.g. after the client compacted its context. */\n async reset(sessionId: string): Promise<void> {\n await mkdir(this.directory, { recursive: true, mode: 0o700 });\n const release = await this.acquire(sessionId);\n try {\n await rm(this.path(sessionId), { force: true });\n } finally {\n await release();\n }\n }\n\n /** Remove expired disposable state; locked sessions are left for the next pass. */\n async pruneExpired(now = new Date()): Promise<number> {\n let removed = 0;\n for (const entry of await readdir(this.directory).catch(() => [])) {\n if (!entry.endsWith(\".json\")) continue;\n const path = join(this.directory, entry);\n try {\n await stat(`${path}.lock`);\n continue;\n } catch {\n // No active lock.\n }\n try {\n const metadata = await stat(path);\n if (now.getTime() - metadata.mtimeMs > MAX_AGE_MS) {\n await rm(path, { force: true });\n removed++;\n }\n } catch {\n // Another cleanup may have removed it already.\n }\n }\n return removed;\n }\n\n private async write(path: string, ledger: LedgerFile, now: Date): Promise<void> {\n ledger.updatedAt = now.toISOString();\n ledger.records = Object.fromEntries(\n Object.entries(ledger.records)\n .sort((left, right) => right[1].lastSentAt.localeCompare(left[1].lastSentAt))\n .slice(0, MAX_CAPSULES),\n );\n await writeAtomic(path, `${JSON.stringify(ledger, null, 2)}\\n`);\n }\n\n private async read(path: string, now: Date): Promise<LedgerFile> {\n try {\n const parsed = JSON.parse(await readFile(path, \"utf8\")) as LedgerFile;\n if (parsed.version !== VERSION || typeof parsed.records !== \"object\") return empty(now);\n const records = Object.fromEntries(\n Object.entries(parsed.records).filter(([, record]) => {\n const age = now.getTime() - Date.parse(record.lastSentAt);\n return Number.isFinite(age) && age <= MAX_AGE_MS;\n }),\n );\n const pending =\n parsed.pending && typeof parsed.pending === \"object\" ? parsed.pending : undefined;\n const acknowledged =\n parsed.acknowledged && typeof parsed.acknowledged === \"object\"\n ? Object.fromEntries(\n Object.entries(parsed.acknowledged).filter(([, versions]) => Array.isArray(versions)),\n )\n : undefined;\n return {\n version: VERSION,\n updatedAt: parsed.updatedAt,\n records,\n ...(pending ? { pending } : {}),\n ...(acknowledged ? { acknowledged } : {}),\n };\n } catch {\n return empty(now);\n }\n }\n\n private path(sessionId: string): string {\n // The full identity participates in the key. Legacy sanitized filenames\n // are intentionally ignored, so a fresh session cannot inherit collisions.\n return join(this.directory, `${digest(sessionId)}.json`);\n }\n\n private async acquire(sessionId: string): Promise<() => Promise<void>> {\n const lock = `${this.path(sessionId)}.lock`;\n for (let attempt = 0; ; attempt++) {\n try {\n await mkdir(lock);\n return async () => rm(lock, { recursive: true, force: true });\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== \"EEXIST\" || attempt >= 200) throw error;\n try {\n const age = Date.now() - (await stat(lock)).mtimeMs;\n if (age > STALE_LOCK_MS) {\n await rm(lock, { recursive: true, force: true });\n continue;\n }\n } catch {\n // The owner may have released the lock between checks.\n }\n await new Promise((resolveWait) => setTimeout(resolveWait, 10));\n }\n }\n }\n}\n\nfunction empty(now: Date): LedgerFile {\n return { version: VERSION, updatedAt: now.toISOString(), records: {} };\n}\n\nfunction digest(text: string): string {\n return createHash(\"sha256\").update(text).digest(\"hex\");\n}\n","import { access, readFile } from \"node:fs/promises\";\nimport { basename, dirname, extname, join } from \"node:path\";\n\n/**\n * Which test covers a file and how this repo runs its tests, from naming\n * conventions and project markers only. Agents otherwise spend turns finding\n * both; a wrong guess costs one failed command, so first match wins.\n */\nexport function findCoveringTest(path: string, paths: ReadonlySet<string>): string | null {\n return coveringTestCandidates(path).find((candidate) => paths.has(candidate)) ?? null;\n}\n\n/** Conventional covering-test paths for `path`, most specific first. */\nexport function coveringTestCandidates(path: string): string[] {\n const ext = extname(path);\n const stem = basename(path, ext);\n const dir = dirname(path);\n const join2 = (...parts: string[]) => parts.filter((part) => part && part !== \".\").join(\"/\");\n // `src/a/x.ts` mirrors to `test/a/x.test.ts`: drop a leading source root.\n const mirrored = dir.replace(/^(src|lib|app)(\\/|$)/, \"\");\n return ext === \".py\"\n ? [\n join2(dir, `test_${stem}.py`),\n join2(dir, `${stem}_test.py`),\n join2(dir, \"tests\", `test_${stem}.py`),\n // `src/pkg/sub/x.py` → `tests/pkg/sub/test_x.py`, `tests/sub/test_x.py`, `tests/test_x.py`.\n join2(\"tests\", mirrored, `test_${stem}.py`),\n join2(\"tests\", mirrored.replace(/^[^/]+\\/?/, \"\"), `test_${stem}.py`),\n join2(\"tests\", `test_${stem}.py`),\n ]\n : ext === \".go\"\n ? [join2(dir, `${stem}_test.go`)]\n : [\n join2(dir, `${stem}.test${ext}`),\n join2(dir, `${stem}.spec${ext}`),\n join2(dir, \"__tests__\", `${stem}.test${ext}`),\n join2(dir, \"__tests__\", `${stem}${ext}`),\n join2(\"test\", mirrored, `${stem}.test${ext}`),\n join2(\"tests\", mirrored, `${stem}.test${ext}`),\n ];\n}\n\nconst NPM_PLACEHOLDER = /no test specified/;\n\nexport async function detectTestCommand(repositoryRoot: string): Promise<string | null> {\n const exists = (file: string) =>\n access(join(repositoryRoot, file)).then(\n () => true,\n () => false,\n );\n try {\n const pkg = JSON.parse(await readFile(join(repositoryRoot, \"package.json\"), \"utf8\")) as {\n scripts?: Record<string, unknown>;\n packageManager?: unknown;\n };\n const script = pkg.scripts?.test;\n if (typeof script === \"string\" && script.trim() && !NPM_PLACEHOLDER.test(script)) {\n const declared =\n typeof pkg.packageManager === \"string\" ? pkg.packageManager.split(/[\\s@]/, 1)[0] : \"\";\n // `bun test` is Bun's own runner, not the package's test script.\n if (declared === \"bun\") return \"bun run test\";\n if (declared && [\"npm\", \"pnpm\", \"yarn\"].includes(declared)) return `${declared} test`;\n if (await exists(\"pnpm-lock.yaml\")) return \"pnpm test\";\n if (await exists(\"yarn.lock\")) return \"yarn test\";\n if ((await exists(\"bun.lockb\")) || (await exists(\"bun.lock\"))) return \"bun run test\";\n return \"npm test\";\n }\n } catch {\n // No or unreadable package.json: try other ecosystems.\n }\n for (const marker of [\"pyproject.toml\", \"pytest.ini\", \"setup.cfg\"]) {\n if (await exists(marker)) return \"pytest\";\n }\n if (await exists(\"go.mod\")) return \"go test ./...\";\n if (await exists(\"Cargo.toml\")) return \"cargo test\";\n return null;\n}\n"],"mappings":"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","names":["readFile","join","join2"]}
@@ -0,0 +1,372 @@
1
+ import {
2
+ modelCacheDir
3
+ } from "./chunk-3QGW53GD.js";
4
+
5
+ // src/llm/local.ts
6
+ import { createHash } from "crypto";
7
+ import { createWriteStream } from "fs";
8
+ import { mkdir, rename, rm, stat, statfs, utimes, writeFile } from "fs/promises";
9
+ import { availableParallelism, freemem, totalmem } from "os";
10
+ import { dirname, join } from "path";
11
+ import { Readable } from "stream";
12
+ import { pipeline } from "stream/promises";
13
+ import { z } from "zod";
14
+ var GB = 1024 ** 3;
15
+ var LOCAL_MODELS = {
16
+ default: {
17
+ size: "default",
18
+ id: "qwen3-0.6b-q4_k_m",
19
+ repo: "unsloth/Qwen3-0.6B-GGUF",
20
+ revision: "50968a4468ef4233ed78cd7c3de230dd1d61a56b",
21
+ file: "Qwen3-0.6B-Q4_K_M.gguf",
22
+ bytes: 396705472,
23
+ sha256: "ac2d97712095a558e31573f62f466a3f9d93990898b0ec79d7c974c1780d524a",
24
+ ramBytes: 1 * GB,
25
+ noThink: true
26
+ },
27
+ better: {
28
+ size: "better",
29
+ id: "qwen3-1.7b-q4_k_m",
30
+ repo: "unsloth/Qwen3-1.7B-GGUF",
31
+ revision: "d7f544eead698dbd1f15126ef60b45a1e1933222",
32
+ file: "Qwen3-1.7B-Q4_K_M.gguf",
33
+ bytes: 1107409472,
34
+ sha256: "b139949c5bd74937ad8ed8c8cf3d9ffb1e99c866c823204dc42c0d91fa181897",
35
+ ramBytes: 2.2 * GB,
36
+ noThink: true
37
+ }
38
+ };
39
+ var LLAMA_RUNTIME = "node-llama-cpp@3.22.3";
40
+ function localModelPath(spec) {
41
+ return join(modelCacheDir(), "gguf", spec.file);
42
+ }
43
+ async function installedLocalModel(preferred) {
44
+ if (process.env.CODEBUDDY_LOCAL_LLM === "0") return null;
45
+ const order = [...preferred ? [preferred] : [], "default", "better"];
46
+ for (const size of new Set(order)) {
47
+ const spec = LOCAL_MODELS[size];
48
+ const info = await stat(localModelPath(spec)).catch(() => null);
49
+ if (info?.isFile() && info.size === spec.bytes) return spec;
50
+ }
51
+ return null;
52
+ }
53
+ async function localRuntimeAvailable() {
54
+ try {
55
+ await importRuntime();
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+ function machineCanCard() {
62
+ if (totalmem() < 4 * GB) {
63
+ return { ok: false, reason: `${formatBytes(totalmem())} RAM (setup installs it from 4 GB)` };
64
+ }
65
+ if (availableParallelism() < 2) return { ok: false, reason: "a single CPU core" };
66
+ return { ok: true };
67
+ }
68
+ async function checkLocalModelResources(spec) {
69
+ const directory = modelCacheDir();
70
+ await mkdir(directory, { recursive: true });
71
+ const fs = await statfs(directory).catch(() => null);
72
+ if (fs && fs.bavail * fs.bsize < spec.bytes * 1.1) {
73
+ return {
74
+ ok: false,
75
+ reason: `needs ${formatBytes(spec.bytes * 1.1)} free in ${directory}, ${formatBytes(fs.bavail * fs.bsize)} available`
76
+ };
77
+ }
78
+ if (totalmem() < spec.ramBytes * 2) {
79
+ return {
80
+ ok: false,
81
+ reason: `needs about ${formatBytes(spec.ramBytes)} of RAM while loaded; this machine has ${formatBytes(totalmem())} in total`
82
+ };
83
+ }
84
+ return { ok: true };
85
+ }
86
+ async function downloadLocalModel(spec, onProgress, fetchImpl = fetch) {
87
+ const target = localModelPath(spec);
88
+ await mkdir(dirname(target), { recursive: true });
89
+ const partial = `${target}.partial`;
90
+ const url = `https://huggingface.co/${spec.repo}/resolve/${spec.revision}/${spec.file}`;
91
+ const response = await fetchImpl(url);
92
+ if (!response.ok || !response.body) {
93
+ throw new Error(`download failed: HTTP ${response.status} for ${url}`);
94
+ }
95
+ const hash = createHash("sha256");
96
+ let received = 0;
97
+ const source = Readable.fromWeb(response.body);
98
+ source.on("data", (chunk) => {
99
+ hash.update(chunk);
100
+ received += chunk.length;
101
+ onProgress?.(received, spec.bytes);
102
+ });
103
+ try {
104
+ await pipeline(source, createWriteStream(partial));
105
+ const digest = hash.digest("hex");
106
+ if (received !== spec.bytes || digest !== spec.sha256) {
107
+ throw new Error(
108
+ `checksum mismatch for ${spec.file} (got ${received} bytes, sha256 ${digest.slice(0, 12)}\u2026); not installed`
109
+ );
110
+ }
111
+ await rename(partial, target);
112
+ } finally {
113
+ await rm(partial, { force: true });
114
+ }
115
+ return target;
116
+ }
117
+ async function removeLocalModels() {
118
+ const removed = [];
119
+ for (const spec of Object.values(LOCAL_MODELS)) {
120
+ const path = localModelPath(spec);
121
+ if (await stat(path).catch(() => null)) {
122
+ await rm(path, { force: true });
123
+ removed.push(spec.id);
124
+ }
125
+ }
126
+ return removed;
127
+ }
128
+ var loading = null;
129
+ var loaded = null;
130
+ var queue = Promise.resolve();
131
+ var grammars = /* @__PURE__ */ new Map();
132
+ async function importRuntime() {
133
+ const specifier = process.env.CODEBUDDY_LLAMA_RUNTIME ?? "node-llama-cpp";
134
+ return await import(specifier);
135
+ }
136
+ var CONTEXT_TOKENS = 4096;
137
+ function generationThreads() {
138
+ const configured = Number(process.env.CODEBUDDY_LLM_THREADS);
139
+ if (Number.isInteger(configured) && configured > 0) return configured;
140
+ return Math.max(1, availableParallelism() - 1);
141
+ }
142
+ async function load(spec) {
143
+ if (loaded?.spec.id === spec.id) return loaded;
144
+ if (!loading) {
145
+ loading = (async () => {
146
+ const runtime = await importRuntime();
147
+ const llama = await runtime.getLlama({ gpu: "auto" });
148
+ const model = await llama.loadModel({ modelPath: localModelPath(spec) });
149
+ const context = await model.createContext({
150
+ contextSize: CONTEXT_TOKENS,
151
+ threads: generationThreads()
152
+ });
153
+ loaded = { spec, llama, model, context, sequence: context.getSequence(), runtime };
154
+ return loaded;
155
+ })().finally(() => {
156
+ loading = null;
157
+ });
158
+ }
159
+ return loading;
160
+ }
161
+ var IDLE_UNLOAD_MS = 6e4;
162
+ var idleTimer = null;
163
+ var running = 0;
164
+ function scheduleUnload() {
165
+ if (idleTimer) clearTimeout(idleTimer);
166
+ idleTimer = setTimeout(() => void unloadLocalModel(), IDLE_UNLOAD_MS);
167
+ idleTimer.unref();
168
+ }
169
+ async function unloadLocalModel() {
170
+ if (idleTimer) clearTimeout(idleTimer);
171
+ idleTimer = null;
172
+ const current = loaded;
173
+ if (!current || running > 0) return;
174
+ loaded = null;
175
+ grammars.clear();
176
+ await current.context.dispose().catch(() => {
177
+ });
178
+ await current.model.dispose().catch(() => {
179
+ });
180
+ }
181
+ function isLocalModelWarm() {
182
+ return loaded !== null;
183
+ }
184
+ async function warmLocalModel(spec) {
185
+ try {
186
+ await load(spec);
187
+ return true;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+ async function localChatJson(spec, request) {
193
+ const started = Date.now();
194
+ const fail = (reason, detail) => ({
195
+ ok: false,
196
+ reason,
197
+ detail: detail.slice(0, 300),
198
+ latencyMs: Date.now() - started
199
+ });
200
+ let model;
201
+ try {
202
+ model = await withDeadline(load(spec), request.deadlineMs);
203
+ } catch (error) {
204
+ return error.name === "TimeoutError" ? fail("timeout", "model still loading") : fail("disabled", `local model unavailable: ${error.message}`);
205
+ }
206
+ const run = async () => {
207
+ running++;
208
+ try {
209
+ return await generate();
210
+ } finally {
211
+ running--;
212
+ scheduleUnload();
213
+ }
214
+ };
215
+ const generate = async () => {
216
+ if (!loaded) model = await load(spec);
217
+ const remaining = request.deadlineMs - (Date.now() - started);
218
+ if (remaining <= 0) return fail("timeout", `no answer within ${request.deadlineMs} ms`);
219
+ const grammar = await grammarFor(model.llama, request.jsonSchema ?? request.schema);
220
+ const session = new model.runtime.LlamaChatSession({
221
+ contextSequence: model.sequence,
222
+ autoDisposeSequence: false,
223
+ systemPrompt: request.system
224
+ });
225
+ const controller = new AbortController();
226
+ const timer = setTimeout(() => controller.abort(), remaining);
227
+ try {
228
+ const text = await session.prompt(`${spec.noThink ? "/no_think\n" : ""}${request.user}`, {
229
+ grammar,
230
+ maxTokens: request.maxTokens,
231
+ temperature: 0,
232
+ ...spec.noThink ? { budgets: { thoughtTokens: 0 } } : {},
233
+ signal: controller.signal,
234
+ stopOnAbortSignal: true
235
+ });
236
+ if (controller.signal.aborted) {
237
+ return fail("timeout", `no answer within ${request.deadlineMs} ms`);
238
+ }
239
+ let value;
240
+ try {
241
+ value = JSON.parse(text);
242
+ } catch {
243
+ return fail("invalid", `output is not JSON: ${text.slice(0, 120)}`);
244
+ }
245
+ const parsed = request.schema.safeParse(value);
246
+ if (!parsed.success)
247
+ return fail("invalid", `output failed validation: ${text.slice(0, 120)}`);
248
+ return {
249
+ ok: true,
250
+ value: parsed.data,
251
+ model: spec.id,
252
+ latencyMs: Date.now() - started,
253
+ tokens: Math.ceil((request.system.length + request.user.length + text.length) / 4),
254
+ cached: false
255
+ };
256
+ } catch (error) {
257
+ return controller.signal.aborted ? fail("timeout", `no answer within ${request.deadlineMs} ms`) : fail("invalid", String(error?.message ?? error));
258
+ } finally {
259
+ clearTimeout(timer);
260
+ session.dispose({ disposeSequence: false });
261
+ }
262
+ };
263
+ const result = queue.then(run, run);
264
+ queue = result.catch(() => void 0);
265
+ return result;
266
+ }
267
+ async function grammarFor(llama, schema) {
268
+ let grammar = grammars.get(schema);
269
+ if (!grammar) {
270
+ grammar = (async () => {
271
+ try {
272
+ const json = schema instanceof z.ZodType ? z.toJSONSchema(schema, { io: "input", unrepresentable: "any" }) : schema;
273
+ return await llama.createGrammarForJsonSchema(stripSchemaMeta(json));
274
+ } catch {
275
+ return llama.getGrammarFor("json");
276
+ }
277
+ })();
278
+ grammars.set(schema, grammar);
279
+ }
280
+ return grammar;
281
+ }
282
+ function stripSchemaMeta(value) {
283
+ if (Array.isArray(value)) return value.map(stripSchemaMeta);
284
+ if (!value || typeof value !== "object") return value;
285
+ const out = {};
286
+ for (const [key, child] of Object.entries(value)) {
287
+ if (key === "$schema" || key === "default" || key === "additionalProperties") continue;
288
+ out[key] = stripSchemaMeta(child);
289
+ }
290
+ return out;
291
+ }
292
+ function withDeadline(promise, ms) {
293
+ return new Promise((resolve, reject) => {
294
+ const timer = setTimeout(
295
+ () => reject(Object.assign(new Error("deadline"), { name: "TimeoutError" })),
296
+ ms
297
+ );
298
+ promise.then(
299
+ (value) => {
300
+ clearTimeout(timer);
301
+ resolve(value);
302
+ },
303
+ (error) => {
304
+ clearTimeout(timer);
305
+ reject(error);
306
+ }
307
+ );
308
+ });
309
+ }
310
+ var FOREGROUND_WINDOW_MS = 2e3;
311
+ var FOREGROUND_MAX_WAIT_MS = 15e3;
312
+ function foregroundPath(root) {
313
+ return join(root, ".codebuddy", "cache", "foreground");
314
+ }
315
+ async function markForeground(root) {
316
+ const path = foregroundPath(root);
317
+ const now = /* @__PURE__ */ new Date();
318
+ try {
319
+ await utimes(path, now, now);
320
+ } catch {
321
+ await mkdir(dirname(path), { recursive: true }).catch(() => {
322
+ });
323
+ await writeFile(path, "").catch(() => {
324
+ });
325
+ }
326
+ }
327
+ async function waitForForeground(root, signal) {
328
+ const until = Date.now() + FOREGROUND_MAX_WAIT_MS;
329
+ while (Date.now() < until && !signal?.aborted) {
330
+ const info = await stat(foregroundPath(root)).catch(() => null);
331
+ if (!info || Date.now() - info.mtimeMs > FOREGROUND_WINDOW_MS) return;
332
+ await new Promise((resolve) => setTimeout(resolve, 250));
333
+ }
334
+ }
335
+ async function localModelStatus(preferred) {
336
+ const installed = await installedLocalModel(preferred);
337
+ return {
338
+ runtime: await localRuntimeAvailable(),
339
+ installed,
340
+ path: installed ? localModelPath(installed) : null
341
+ };
342
+ }
343
+ function formatBytes(bytes) {
344
+ return bytes >= GB ? `${(bytes / GB).toFixed(1)} GB` : `${Math.round(bytes / 1024 ** 2)} MB`;
345
+ }
346
+ function freeMemory() {
347
+ return freemem();
348
+ }
349
+
350
+ export {
351
+ LOCAL_MODELS,
352
+ LLAMA_RUNTIME,
353
+ localModelPath,
354
+ installedLocalModel,
355
+ localRuntimeAvailable,
356
+ machineCanCard,
357
+ checkLocalModelResources,
358
+ downloadLocalModel,
359
+ removeLocalModels,
360
+ generationThreads,
361
+ IDLE_UNLOAD_MS,
362
+ unloadLocalModel,
363
+ isLocalModelWarm,
364
+ warmLocalModel,
365
+ localChatJson,
366
+ markForeground,
367
+ waitForForeground,
368
+ localModelStatus,
369
+ formatBytes,
370
+ freeMemory
371
+ };
372
+ //# sourceMappingURL=chunk-BTTQWSDS.js.map