akm-cli 0.7.5 → 0.8.0-rc2

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 (152) hide show
  1. package/.github/CHANGELOG.md +1 -1
  2. package/dist/cli/parse-args.js +43 -0
  3. package/dist/cli.js +853 -479
  4. package/dist/commands/agent-dispatch.js +102 -0
  5. package/dist/commands/agent-support.js +62 -0
  6. package/dist/commands/config-cli.js +68 -84
  7. package/dist/commands/consolidate.js +823 -0
  8. package/dist/commands/distill-promotion-policy.js +658 -0
  9. package/dist/commands/distill.js +244 -52
  10. package/dist/commands/eval-cases.js +40 -0
  11. package/dist/commands/events.js +2 -23
  12. package/dist/commands/graph.js +222 -0
  13. package/dist/commands/health.js +376 -0
  14. package/dist/commands/help/help-accept.md +9 -0
  15. package/dist/commands/help/help-improve.md +53 -0
  16. package/dist/commands/help/help-proposals.md +15 -0
  17. package/dist/commands/help/help-propose.md +17 -0
  18. package/dist/commands/help/help-reject.md +8 -0
  19. package/dist/commands/history.js +3 -30
  20. package/dist/commands/improve.js +1170 -0
  21. package/dist/commands/info.js +2 -2
  22. package/dist/commands/init.js +2 -2
  23. package/dist/commands/install-audit.js +5 -1
  24. package/dist/commands/installed-stashes.js +118 -138
  25. package/dist/commands/knowledge.js +133 -0
  26. package/dist/commands/lint/agent-linter.js +46 -0
  27. package/dist/commands/lint/base-linter.js +285 -0
  28. package/dist/commands/lint/command-linter.js +46 -0
  29. package/dist/commands/lint/default-linter.js +13 -0
  30. package/dist/commands/lint/index.js +107 -0
  31. package/dist/commands/lint/knowledge-linter.js +13 -0
  32. package/dist/commands/lint/memory-linter.js +58 -0
  33. package/dist/commands/lint/registry.js +33 -0
  34. package/dist/commands/lint/skill-linter.js +42 -0
  35. package/dist/commands/lint/task-linter.js +47 -0
  36. package/dist/commands/lint/types.js +1 -0
  37. package/dist/commands/lint/workflow-linter.js +53 -0
  38. package/dist/commands/lint.js +1 -0
  39. package/dist/commands/proposal.js +8 -7
  40. package/dist/commands/propose.js +78 -28
  41. package/dist/commands/reflect.js +143 -35
  42. package/dist/commands/registry-search.js +2 -2
  43. package/dist/commands/remember.js +54 -0
  44. package/dist/commands/schema-repair.js +130 -0
  45. package/dist/commands/search.js +21 -5
  46. package/dist/commands/show.js +121 -17
  47. package/dist/commands/source-add.js +10 -10
  48. package/dist/commands/source-manage.js +11 -19
  49. package/dist/commands/tasks.js +385 -0
  50. package/dist/commands/url-checker.js +39 -0
  51. package/dist/commands/vault.js +8 -26
  52. package/dist/core/action-contributors.js +25 -0
  53. package/dist/core/asset-ref.js +4 -0
  54. package/dist/core/asset-registry.js +4 -16
  55. package/dist/core/asset-spec.js +10 -0
  56. package/dist/core/common.js +94 -0
  57. package/dist/core/concurrent.js +22 -0
  58. package/dist/core/config.js +222 -128
  59. package/dist/core/events.js +73 -126
  60. package/dist/core/frontmatter.js +3 -1
  61. package/dist/core/markdown.js +17 -0
  62. package/dist/core/memory-improve.js +678 -0
  63. package/dist/core/parse.js +155 -0
  64. package/dist/core/paths.js +101 -3
  65. package/dist/core/proposal-validators.js +61 -0
  66. package/dist/core/proposals.js +49 -38
  67. package/dist/core/state-db.js +775 -0
  68. package/dist/core/time.js +51 -0
  69. package/dist/core/warn.js +59 -1
  70. package/dist/indexer/db-search.js +52 -238
  71. package/dist/indexer/db.js +378 -1
  72. package/dist/indexer/ensure-index.js +61 -0
  73. package/dist/indexer/graph-boost.js +247 -94
  74. package/dist/indexer/graph-db.js +201 -0
  75. package/dist/indexer/graph-dedup.js +99 -0
  76. package/dist/indexer/graph-extraction.js +409 -76
  77. package/dist/indexer/index-context.js +10 -0
  78. package/dist/indexer/indexer.js +442 -290
  79. package/dist/indexer/llm-cache.js +47 -0
  80. package/dist/indexer/match-contributors.js +141 -0
  81. package/dist/indexer/matchers.js +24 -190
  82. package/dist/indexer/memory-inference.js +63 -29
  83. package/dist/indexer/metadata-contributors.js +26 -0
  84. package/dist/indexer/metadata.js +194 -175
  85. package/dist/indexer/path-resolver.js +89 -0
  86. package/dist/indexer/ranking-contributors.js +204 -0
  87. package/dist/indexer/ranking.js +74 -0
  88. package/dist/indexer/search-hit-enrichers.js +22 -0
  89. package/dist/indexer/search-source.js +24 -9
  90. package/dist/indexer/semantic-status.js +2 -16
  91. package/dist/indexer/walker.js +25 -0
  92. package/dist/integrations/agent/config.js +175 -3
  93. package/dist/integrations/agent/index.js +3 -1
  94. package/dist/integrations/agent/pipeline.js +39 -0
  95. package/dist/integrations/agent/profiles.js +67 -5
  96. package/dist/integrations/agent/prompts.js +77 -72
  97. package/dist/integrations/agent/runners.js +31 -0
  98. package/dist/integrations/agent/sdk-runner.js +120 -0
  99. package/dist/integrations/agent/spawn.js +71 -16
  100. package/dist/integrations/lockfile.js +10 -18
  101. package/dist/integrations/session-logs/index.js +65 -0
  102. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  103. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  104. package/dist/integrations/session-logs/types.js +1 -0
  105. package/dist/llm/call-ai.js +74 -0
  106. package/dist/llm/client.js +61 -122
  107. package/dist/llm/feature-gate.js +27 -16
  108. package/dist/llm/graph-extract.js +297 -62
  109. package/dist/llm/memory-infer.js +49 -71
  110. package/dist/llm/metadata-enhance.js +39 -22
  111. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  112. package/dist/output/cli-hints-full.md +277 -0
  113. package/dist/output/cli-hints-short.md +65 -0
  114. package/dist/output/cli-hints.js +2 -318
  115. package/dist/output/renderers.js +190 -123
  116. package/dist/output/shapes.js +33 -0
  117. package/dist/output/text.js +239 -2
  118. package/dist/registry/providers/skills-sh.js +61 -49
  119. package/dist/registry/providers/static-index.js +44 -48
  120. package/dist/setup/setup.js +510 -11
  121. package/dist/sources/provider-factory.js +2 -1
  122. package/dist/sources/providers/git.js +2 -2
  123. package/dist/sources/website-ingest.js +4 -0
  124. package/dist/tasks/backends/cron.js +200 -0
  125. package/dist/tasks/backends/exec-utils.js +25 -0
  126. package/dist/tasks/backends/index.js +32 -0
  127. package/dist/tasks/backends/launchd-template.xml +19 -0
  128. package/dist/tasks/backends/launchd.js +184 -0
  129. package/dist/tasks/backends/schtasks-template.xml +29 -0
  130. package/dist/tasks/backends/schtasks.js +212 -0
  131. package/dist/tasks/parser.js +198 -0
  132. package/dist/tasks/resolveAkmBin.js +84 -0
  133. package/dist/tasks/runner.js +432 -0
  134. package/dist/tasks/schedule.js +208 -0
  135. package/dist/tasks/schema.js +13 -0
  136. package/dist/tasks/validator.js +59 -0
  137. package/dist/wiki/index-template.md +12 -0
  138. package/dist/wiki/ingest-workflow-template.md +54 -0
  139. package/dist/wiki/log-template.md +8 -0
  140. package/dist/wiki/schema-template.md +61 -0
  141. package/dist/wiki/wiki-templates.js +12 -0
  142. package/dist/wiki/wiki.js +10 -61
  143. package/dist/workflows/authoring.js +5 -25
  144. package/dist/workflows/renderer.js +8 -3
  145. package/dist/workflows/runs.js +59 -91
  146. package/dist/workflows/validator.js +1 -1
  147. package/dist/workflows/workflow-template.md +24 -0
  148. package/docs/README.md +3 -0
  149. package/docs/migration/release-notes/0.7.0.md +1 -1
  150. package/docs/migration/release-notes/0.8.0.md +43 -0
  151. package/package.json +3 -2
  152. package/dist/templates/wiki-templates.js +0 -100
@@ -1,16 +1,18 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { getConfigDir } from "../core/config";
3
+ import { writeFileAtomic } from "../core/common";
4
+ import { getDataDir } from "../core/paths";
4
5
  // ── Paths ───────────────────────────────────────────────────────────────────
5
6
  const LOCKFILE_NAME = "akm.lock";
6
7
  function getLockfilePath() {
7
- return path.join(getConfigDir(), LOCKFILE_NAME);
8
+ return path.join(getDataDir(), LOCKFILE_NAME);
8
9
  }
9
10
  // ── Lock sentinel ────────────────────────────────────────────────────────────
10
11
  const LOCK_MAX_RETRIES = 3;
11
12
  const LOCK_RETRY_DELAY_MS = 100;
12
13
  function getLockSentinelPath() {
13
- return `${getLockfilePath()}.lck`;
14
+ // The sentinel always lives next to the lock file it guards.
15
+ return `${path.join(getDataDir(), LOCKFILE_NAME)}.lck`;
14
16
  }
15
17
  async function acquireLockSentinel() {
16
18
  const sentinelPath = getLockSentinelPath();
@@ -87,23 +89,11 @@ export function readLockfile() {
87
89
  }
88
90
  }
89
91
  export function writeLockfile(entries) {
90
- const lockfilePath = getLockfilePath();
92
+ // Always write to $DATA — never to the legacy $CONFIG location.
93
+ const lockfilePath = path.join(getDataDir(), LOCKFILE_NAME);
91
94
  const dir = path.dirname(lockfilePath);
92
95
  fs.mkdirSync(dir, { recursive: true });
93
- const tmpPath = `${lockfilePath}.tmp.${process.pid}.${Math.random().toString(36).slice(2, 8)}`;
94
- try {
95
- fs.writeFileSync(tmpPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
96
- fs.renameSync(tmpPath, lockfilePath);
97
- }
98
- catch (err) {
99
- try {
100
- fs.unlinkSync(tmpPath);
101
- }
102
- catch {
103
- /* ignore cleanup failure */
104
- }
105
- throw err;
106
- }
96
+ writeFileAtomic(lockfilePath, `${JSON.stringify(entries, null, 2)}\n`);
107
97
  }
108
98
  export async function upsertLockEntry(entry) {
109
99
  const acquired = await acquireLockSentinel();
@@ -118,6 +108,8 @@ export async function upsertLockEntry(entry) {
118
108
  }
119
109
  }
120
110
  export async function removeLockEntry(id) {
111
+ if (!fs.existsSync(getDataDir()))
112
+ return;
121
113
  const acquired = await acquireLockSentinel();
122
114
  try {
123
115
  const entries = readLockfile();
@@ -0,0 +1,65 @@
1
+ import { ClaudeCodeProvider } from "./providers/claude-code";
2
+ import { OpenCodeProvider } from "./providers/opencode";
3
+ const HARNESSES = [new ClaudeCodeProvider(), new OpenCodeProvider()];
4
+ const ERROR_PATTERNS = /error|failed|exception|cannot|undefined|null pointer|ENOENT|timeout/i;
5
+ /**
6
+ * Returns all available session log harnesses for the current machine.
7
+ * Add new harnesses to HARNESSES to support additional agent runtimes.
8
+ */
9
+ export function getAvailableHarnesses() {
10
+ return HARNESSES.filter((harness) => harness.isAvailable());
11
+ }
12
+ export function normalizeSessionTopic(text) {
13
+ const normalized = text.replace(/\s+/g, " ").trim().toLowerCase();
14
+ if (normalized.length < 10)
15
+ return undefined;
16
+ return normalized.slice(0, 60);
17
+ }
18
+ export function aggregateSessionEvents(events) {
19
+ const counts = new Map();
20
+ for (const event of events) {
21
+ const topic = normalizeSessionTopic(event.text);
22
+ if (!topic)
23
+ continue;
24
+ const isFailurePattern = ERROR_PATTERNS.test(topic);
25
+ if (!isFailurePattern)
26
+ continue;
27
+ const existing = counts.get(topic) ?? {
28
+ count: 0,
29
+ isFailurePattern,
30
+ sources: new Set(),
31
+ topic,
32
+ };
33
+ existing.count += 1;
34
+ existing.isFailurePattern = existing.isFailurePattern || isFailurePattern;
35
+ existing.sources.add(event.harness);
36
+ counts.set(topic, existing);
37
+ }
38
+ return [...counts.values()]
39
+ .filter((entry) => entry.count >= 2)
40
+ .sort((a, b) => b.count - a.count || a.topic.localeCompare(b.topic))
41
+ .slice(0, 15)
42
+ .map((entry) => ({
43
+ topic: entry.topic,
44
+ frequency: entry.count,
45
+ source: [...entry.sources].sort().join(","),
46
+ isFailurePattern: entry.isFailurePattern,
47
+ }));
48
+ }
49
+ /**
50
+ * Scan recent session logs from all available harnesses and return
51
+ * repeated failure patterns that might warrant new AKM assets.
52
+ */
53
+ export function getExecutionLogCandidates(sinceDays = 7) {
54
+ const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
55
+ const events = [];
56
+ for (const harness of getAvailableHarnesses()) {
57
+ try {
58
+ events.push(...harness.readEvents({ sinceMs }));
59
+ }
60
+ catch {
61
+ // individual harness failures are non-fatal
62
+ }
63
+ }
64
+ return aggregateSessionEvents(events);
65
+ }
@@ -0,0 +1,56 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
5
+ export class ClaudeCodeProvider {
6
+ name = "claude-code";
7
+ isAvailable() {
8
+ return fs.existsSync(CLAUDE_PROJECTS_DIR);
9
+ }
10
+ *readEvents(input) {
11
+ try {
12
+ for (const jsonlPath of this.#walkJsonl(CLAUDE_PROJECTS_DIR)) {
13
+ const stat = fs.statSync(jsonlPath);
14
+ if (stat.mtimeMs < input.sinceMs)
15
+ continue;
16
+ const lines = fs.readFileSync(jsonlPath, "utf8").split("\n").filter(Boolean);
17
+ for (const line of lines) {
18
+ try {
19
+ const entry = JSON.parse(line);
20
+ const text = entry?.message?.content ?? entry?.content ?? "";
21
+ if (typeof text !== "string" || text.length < 10)
22
+ continue;
23
+ yield {
24
+ harness: this.name,
25
+ text,
26
+ ts: typeof entry?.timestamp === "number" ? entry.timestamp : stat.mtimeMs,
27
+ sessionId: typeof entry?.session_id === "string" ? entry.session_id : undefined,
28
+ role: typeof entry?.role === "string" ? entry.role : "unknown",
29
+ filePath: jsonlPath,
30
+ };
31
+ }
32
+ catch {
33
+ // skip malformed lines
34
+ }
35
+ }
36
+ }
37
+ }
38
+ catch {
39
+ return;
40
+ }
41
+ }
42
+ *#walkJsonl(dir) {
43
+ try {
44
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
45
+ const full = path.join(dir, entry.name);
46
+ if (entry.isDirectory())
47
+ yield* this.#walkJsonl(full);
48
+ else if (entry.name.endsWith(".jsonl"))
49
+ yield full;
50
+ }
51
+ }
52
+ catch {
53
+ // permission errors etc.
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,52 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ function getOpenCodeLogDir() {
5
+ if (process.platform === "darwin") {
6
+ return path.join(os.homedir(), "Library", "Application Support", "opencode");
7
+ }
8
+ return path.join(os.homedir(), ".local", "share", "opencode");
9
+ }
10
+ export class OpenCodeProvider {
11
+ name = "opencode";
12
+ #logDir = getOpenCodeLogDir();
13
+ isAvailable() {
14
+ return fs.existsSync(this.#logDir);
15
+ }
16
+ *readEvents(input) {
17
+ try {
18
+ for (const file of fs.readdirSync(this.#logDir)) {
19
+ const full = path.join(this.#logDir, file);
20
+ const stat = fs.statSync(full);
21
+ if (stat.mtimeMs < input.sinceMs)
22
+ continue;
23
+ if (!file.endsWith(".json") && !file.endsWith(".jsonl") && !file.endsWith(".log"))
24
+ continue;
25
+ const content = fs.readFileSync(full, "utf8");
26
+ const lines = content.includes("\n") ? content.split("\n") : [content];
27
+ for (const line of lines) {
28
+ try {
29
+ const entry = JSON.parse(line);
30
+ const text = entry?.content ?? entry?.message ?? entry?.text ?? "";
31
+ if (typeof text !== "string" || text.length < 10)
32
+ continue;
33
+ yield {
34
+ harness: this.name,
35
+ text,
36
+ ts: typeof entry?.timestamp === "number" ? entry.timestamp : stat.mtimeMs,
37
+ sessionId: typeof entry?.sessionId === "string" ? entry.sessionId : undefined,
38
+ role: typeof entry?.role === "string" ? entry.role : "unknown",
39
+ filePath: full,
40
+ };
41
+ }
42
+ catch {
43
+ // skip malformed
44
+ }
45
+ }
46
+ }
47
+ }
48
+ catch {
49
+ return;
50
+ }
51
+ }
52
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Unified AI call adapter: prefers `config.agent` (agent CLI shell-out),
3
+ * falls back to `config.llm` (HTTP chat-completions).
4
+ *
5
+ * NOT for use by background indexer passes — those call `chatCompletion`
6
+ * directly to avoid the agent-CLI overhead and to stay on the HTTP path that
7
+ * the indexer was designed around.
8
+ */
9
+ import { warn } from "../core/warn";
10
+ import { resolveAgentProfile, runAgent } from "../integrations/agent";
11
+ import { chatCompletion } from "./client";
12
+ /**
13
+ * Unified AI call: prefers `config.agent` (agent CLI), falls back to
14
+ * `config.llm` (HTTP). When neither is configured, returns a structured
15
+ * error pointing the user at `akm setup`.
16
+ *
17
+ * NOT for use by background indexer passes — those call `chatCompletion`
18
+ * directly.
19
+ */
20
+ export async function callAi(config, prompt, opts = {}) {
21
+ if (config.agent) {
22
+ try {
23
+ const defaultName = config.agent.default;
24
+ if (!defaultName) {
25
+ return {
26
+ ok: false,
27
+ error: "No default agent profile configured. Set `agent.default` in config.json or run `akm setup`.",
28
+ };
29
+ }
30
+ const profile = resolveAgentProfile(defaultName, config.agent.profiles?.[defaultName]);
31
+ if (!profile) {
32
+ return {
33
+ ok: false,
34
+ error: `Agent profile "${defaultName}" is not built-in and has no \`bin\` override.`,
35
+ };
36
+ }
37
+ const result = await runAgent(profile, prompt, {
38
+ stdio: opts.draftFilePath ? "interactive" : "captured",
39
+ parseOutput: "text",
40
+ timeoutMs: opts.timeoutMs,
41
+ });
42
+ if (!result.ok)
43
+ return { ok: false, error: result.error ?? result.reason ?? "agent failed" };
44
+ return { ok: true, content: result.stdout ?? "", path: "agent-cli" };
45
+ }
46
+ catch (e) {
47
+ return { ok: false, error: String(e) };
48
+ }
49
+ }
50
+ if (config.llm) {
51
+ if (opts.draftFilePath) {
52
+ warn("[akm] No agent CLI configured — falling back to LLM API. " +
53
+ "File-write contract unavailable; expecting JSON in stdout. " +
54
+ "Install an agent CLI and run `akm setup` for full functionality.");
55
+ }
56
+ const messages = [];
57
+ if (opts.systemPrompt)
58
+ messages.push({ role: "system", content: opts.systemPrompt });
59
+ messages.push({ role: "user", content: prompt });
60
+ try {
61
+ const content = await chatCompletion(config.llm, messages, {
62
+ ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}),
63
+ });
64
+ return { ok: true, content, path: "llm-http" };
65
+ }
66
+ catch (e) {
67
+ return { ok: false, error: String(e) };
68
+ }
69
+ }
70
+ return {
71
+ ok: false,
72
+ error: "No AI connection configured. Run `akm setup` or set `agent` or `llm` in your config.",
73
+ };
74
+ }
@@ -8,6 +8,10 @@
8
8
  * `llm.ts` re-exports everything from this module for backward compatibility.
9
9
  */
10
10
  import { fetchWithTimeout } from "../core/common";
11
+ import { escapeJsonStringControls, parseJsonResponse, stripCodeFences, stripThinkBlocks } from "../core/parse";
12
+ // Re-export shared parse utilities so existing importers of `client.ts` continue
13
+ // to resolve `parseJsonResponse` and `parseEmbeddedJsonResponse` from this module.
14
+ export { escapeJsonStringControls, parseEmbeddedJsonResponse, parseJsonResponse, stripCodeFences, stripThinkBlocks, } from "../core/parse";
11
15
  /** Maximum length of an LLM error response body included in thrown errors. */
12
16
  const ERROR_BODY_MAX_LEN = 200;
13
17
  /**
@@ -38,143 +42,78 @@ export function redactErrorBody(input) {
38
42
  }
39
43
  return out;
40
44
  }
45
+ export class LlmCallError extends Error {
46
+ code;
47
+ statusCode;
48
+ constructor(message, code, statusCode) {
49
+ super(message);
50
+ this.code = code;
51
+ this.statusCode = statusCode;
52
+ this.name = "LlmCallError";
53
+ }
54
+ }
41
55
  export async function chatCompletion(config, messages, options) {
42
56
  const timeoutMs = options?.timeoutMs ?? config.timeoutMs ?? 120_000;
43
57
  const headers = { "Content-Type": "application/json" };
44
58
  if (config.apiKey) {
45
59
  headers.Authorization = `Bearer ${config.apiKey}`;
46
60
  }
47
- const response = await fetchWithTimeout(config.endpoint, {
48
- method: "POST",
49
- headers,
50
- body: JSON.stringify({
51
- model: config.model,
52
- messages,
53
- temperature: options?.temperature ?? config.temperature ?? 0.3,
54
- max_tokens: options?.maxTokens ?? config.maxTokens ?? 512,
55
- ...config.extraParams,
56
- }),
57
- }, timeoutMs, options?.signal);
58
- if (!response.ok) {
59
- const rawBody = await response.text().catch(() => "");
60
- const safeBody = redactErrorBody(rawBody);
61
- throw new Error(`LLM request failed (${response.status}) ${config.endpoint}: ${safeBody}`);
61
+ // Only include max_tokens when explicitly set. The model/API knows its own
62
+ // limits; a hardcoded default creates silent truncation failures when the
63
+ // guess is wrong. Users who need a cap can set llm.maxTokens in config.
64
+ const resolvedMaxTokens = options?.maxTokens ?? config.maxTokens;
65
+ let response;
66
+ try {
67
+ response = await fetchWithTimeout(config.endpoint, {
68
+ method: "POST",
69
+ headers,
70
+ body: JSON.stringify({
71
+ model: config.model,
72
+ messages,
73
+ temperature: options?.temperature ?? config.temperature ?? 0.3,
74
+ ...(resolvedMaxTokens !== undefined ? { max_tokens: resolvedMaxTokens } : {}),
75
+ ...config.extraParams,
76
+ }),
77
+ }, timeoutMs, options?.signal);
62
78
  }
63
- const json = (await response.json());
64
- return json.choices?.[0]?.message?.content?.trim() ?? "";
65
- }
66
- /** Strip leading/trailing markdown code fences from an LLM response. */
67
- export function stripJsonFences(raw) {
68
- const repaired = raw
69
- .trim()
70
- .replace(/<think>[\s\S]*?<\/think>/gi, "")
71
- .replace(/^```(?:json)?\s*\n?/i, "")
72
- .replace(/\n?```\s*$/i, "")
73
- .trim();
74
- let out = "";
75
- let inString = false;
76
- let escaped = false;
77
- for (let i = 0; i < repaired.length; i++) {
78
- const ch = repaired[i];
79
- if (escaped) {
80
- out += ch;
81
- escaped = false;
82
- continue;
79
+ catch (err) {
80
+ // fetchWithTimeout throws a plain Error with a message containing
81
+ // "timed out" for AbortController-driven timeouts, or "aborted" for
82
+ // caller-driven cancellations. Map both to typed LlmCallError.
83
+ const msg = err instanceof Error ? err.message : String(err);
84
+ if (err instanceof DOMException && err.name === "AbortError") {
85
+ throw new LlmCallError(`Request timed out after ${timeoutMs}ms`, "timeout");
83
86
  }
84
- if (ch === "\\" && inString) {
85
- out += ch;
86
- escaped = true;
87
- continue;
87
+ if (msg.includes("timed out")) {
88
+ throw new LlmCallError(`Request timed out after ${timeoutMs}ms`, "timeout");
88
89
  }
89
- if (ch === '"') {
90
- inString = !inString;
91
- out += ch;
92
- continue;
90
+ throw new LlmCallError(`Network error: ${msg}`, "network_error");
91
+ }
92
+ if (!response.ok) {
93
+ const rawBody = await response.text().catch(() => "");
94
+ const safeBody = redactErrorBody(rawBody);
95
+ const status = response.status;
96
+ if (status === 429) {
97
+ throw new LlmCallError(`LLM request rate limited (429) ${config.endpoint}: ${safeBody}`, "rate_limited", status);
93
98
  }
94
- if (inString) {
95
- if (ch === "\n") {
96
- out += "\\n";
97
- continue;
98
- }
99
- if (ch === "\r") {
100
- out += "\\r";
101
- continue;
102
- }
103
- if (ch === "\t") {
104
- out += "\\t";
105
- continue;
106
- }
99
+ if (status >= 500) {
100
+ throw new LlmCallError(`LLM provider error (${status}) ${config.endpoint}: ${safeBody}`, "provider_error", status);
107
101
  }
108
- out += ch;
109
- }
110
- return out;
111
- }
112
- /** Parse a possibly-fenced JSON response. Returns undefined if invalid. */
113
- export function parseJsonResponse(raw) {
114
- try {
115
- return JSON.parse(stripJsonFences(raw));
116
- }
117
- catch {
118
- return undefined;
102
+ throw new LlmCallError(`LLM request failed (${status}) ${config.endpoint}: ${safeBody}`, "provider_error", status);
119
103
  }
104
+ const json = (await response.json());
105
+ const content = (json.choices?.[0]?.message?.content ?? "").trim();
106
+ const reasoning = (json.choices?.[0]?.message?.reasoning_content ?? "").trim();
107
+ return content || reasoning;
120
108
  }
121
109
  /**
122
- * Best-effort recovery for providers that wrap JSON in extra prose or fenced
123
- * blocks. Extracts the first balanced top-level object/array and parses it.
110
+ * Strip `<think>` blocks, code fences, and escape control characters in JSON
111
+ * strings. Thin wrapper kept for backward compatibility with call sites that
112
+ * import `stripJsonFences` from this module. New code should prefer the
113
+ * granular helpers from `../core/parse`.
124
114
  */
125
- export function parseEmbeddedJsonResponse(raw) {
126
- const direct = parseJsonResponse(raw);
127
- if (direct !== undefined)
128
- return direct;
129
- const text = stripJsonFences(raw);
130
- let arrayFallback;
131
- for (let start = 0; start < text.length; start++) {
132
- const opener = text[start];
133
- if (opener !== "{" && opener !== "[")
134
- continue;
135
- const closer = opener === "{" ? "}" : "]";
136
- let depth = 0;
137
- let inString = false;
138
- let escaped = false;
139
- for (let i = start; i < text.length; i++) {
140
- const ch = text[i];
141
- if (inString) {
142
- if (escaped) {
143
- escaped = false;
144
- }
145
- else if (ch === "\\") {
146
- escaped = true;
147
- }
148
- else if (ch === '"') {
149
- inString = false;
150
- }
151
- continue;
152
- }
153
- if (ch === '"') {
154
- inString = true;
155
- continue;
156
- }
157
- if (ch === opener)
158
- depth += 1;
159
- if (ch === closer) {
160
- depth -= 1;
161
- if (depth === 0) {
162
- try {
163
- const parsed = JSON.parse(text.slice(start, i + 1));
164
- if (!Array.isArray(parsed)) {
165
- return parsed;
166
- }
167
- arrayFallback ??= parsed;
168
- break;
169
- }
170
- catch {
171
- break;
172
- }
173
- }
174
- }
175
- }
176
- }
177
- return arrayFallback;
115
+ export function stripJsonFences(raw) {
116
+ return escapeJsonStringControls(stripCodeFences(stripThinkBlocks(raw)));
178
117
  }
179
118
  // ── Availability check ──────────────────────────────────────────────────────
180
119
  /**
@@ -8,16 +8,15 @@
8
8
  * The seam is intentionally tiny:
9
9
  *
10
10
  * - `isLlmFeatureEnabled(config, feature)` — pure predicate, no side
11
- * effects, no I/O. Returns `true` only when the feature flag is the
12
- * literal boolean `true` in config. Defaults are `false` per v1
13
- * spec §14 — adding a flag to the schema is a non-event until the user
14
- * opts in.
11
+ * effects, no I/O. Returns `true` when the feature flag is explicitly
12
+ * `true`, or when the feature has a non-false default (currently
13
+ * `graph_extraction`).
15
14
  * - `tryLlmFeature(feature, config, fn, fallback, opts?)` — single-call
16
15
  * wrapper that runs `fn()` only when the gate is open, enforces a hard
17
- * timeout (default 30s — overridable per call), and returns `fallback`
18
- * on disablement, throw, or timeout. The wrapper is referentially
19
- * transparent for any given (gate-state, fn-result) pair: no module
20
- * state is mutated.
16
+ * timeout (default 600s — overridable per call via `opts.timeoutMs`),
17
+ * and returns `fallback` on disablement, throw, or timeout. The wrapper
18
+ * is referentially transparent for any given (gate-state, fn-result)
19
+ * pair: no module state is mutated.
21
20
  *
22
21
  * Statelessness invariant (v1 spec §14.4): nothing in this module holds
23
22
  * state across calls. There are no caches, no module-level singletons, no
@@ -29,18 +28,30 @@
29
28
  /**
30
29
  * Pure predicate: is the named feature gate explicitly enabled in `config`?
31
30
  *
32
- * Returns `false` when:
33
- * - the LLM block is missing,
34
- * - the `features` block is missing,
35
- * - the key is absent (defaults are `false`),
36
- * - the key is set to `false`.
31
+ * Returns `false` only when the key is explicitly set to `false`, or when
32
+ * the key is absent and its default is `false`.
37
33
  */
34
+ const FEATURE_DEFAULTS = {
35
+ memory_inference: true,
36
+ graph_extraction: true,
37
+ };
38
38
  export function isLlmFeatureEnabled(config, feature) {
39
- if (!config?.llm?.features)
39
+ const configured = config?.llm?.features?.[feature];
40
+ if (configured === true)
41
+ return true;
42
+ if (configured === false)
40
43
  return false;
41
- return config.llm.features[feature] === true;
44
+ return FEATURE_DEFAULTS[feature] === true;
42
45
  }
43
- const DEFAULT_TIMEOUT_MS = 30_000;
46
+ /**
47
+ * Default hard timeout for every bounded in-tree LLM call. Set to 10 minutes
48
+ * (600 000 ms) — generous enough for a slow local model on a single-threaded
49
+ * server. Override per-call via `TryLlmFeatureOptions.timeoutMs`.
50
+ *
51
+ * Do NOT reduce this default without a documented user-facing reason — local
52
+ * model users need the headroom.
53
+ */
54
+ const DEFAULT_TIMEOUT_MS = 600_000;
44
55
  /**
45
56
  * Run `fn()` only if `isLlmFeatureEnabled(config, feature)` is `true`. On
46
57
  * disablement, throw, or timeout, return `fallback` (or — if it is a