akm-cli 0.7.4 → 0.8.0-rc1

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 (158) hide show
  1. package/{CHANGELOG.md → .github/CHANGELOG.md} +34 -1
  2. package/.github/LICENSE +374 -0
  3. package/dist/cli/parse-args.js +43 -0
  4. package/dist/cli.js +1007 -593
  5. package/dist/commands/agent-dispatch.js +102 -0
  6. package/dist/commands/agent-support.js +62 -0
  7. package/dist/commands/config-cli.js +68 -84
  8. package/dist/commands/consolidate.js +823 -0
  9. package/dist/commands/curate.js +1 -0
  10. package/dist/commands/distill-promotion-policy.js +658 -0
  11. package/dist/commands/distill.js +250 -48
  12. package/dist/commands/eval-cases.js +40 -0
  13. package/dist/commands/events.js +12 -24
  14. package/dist/commands/graph.js +222 -0
  15. package/dist/commands/health.js +376 -0
  16. package/dist/commands/help/help-accept.md +9 -0
  17. package/dist/commands/help/help-improve.md +53 -0
  18. package/dist/commands/help/help-proposals.md +15 -0
  19. package/dist/commands/help/help-propose.md +17 -0
  20. package/dist/commands/help/help-reject.md +8 -0
  21. package/dist/commands/history.js +3 -30
  22. package/dist/commands/improve.js +1170 -0
  23. package/dist/commands/info.js +2 -2
  24. package/dist/commands/init.js +2 -2
  25. package/dist/commands/install-audit.js +5 -1
  26. package/dist/commands/installed-stashes.js +118 -138
  27. package/dist/commands/knowledge.js +133 -0
  28. package/dist/commands/lint/agent-linter.js +46 -0
  29. package/dist/commands/lint/base-linter.js +251 -0
  30. package/dist/commands/lint/command-linter.js +46 -0
  31. package/dist/commands/lint/default-linter.js +13 -0
  32. package/dist/commands/lint/index.js +107 -0
  33. package/dist/commands/lint/knowledge-linter.js +13 -0
  34. package/dist/commands/lint/memory-linter.js +58 -0
  35. package/dist/commands/lint/registry.js +33 -0
  36. package/dist/commands/lint/skill-linter.js +42 -0
  37. package/dist/commands/lint/task-linter.js +47 -0
  38. package/dist/commands/lint/types.js +1 -0
  39. package/dist/commands/lint/workflow-linter.js +53 -0
  40. package/dist/commands/lint.js +1 -0
  41. package/dist/commands/migration-help.js +2 -2
  42. package/dist/commands/proposal.js +8 -7
  43. package/dist/commands/propose.js +113 -43
  44. package/dist/commands/reflect.js +175 -41
  45. package/dist/commands/registry-search.js +2 -2
  46. package/dist/commands/remember.js +55 -1
  47. package/dist/commands/schema-repair.js +130 -0
  48. package/dist/commands/search.js +21 -5
  49. package/dist/commands/show.js +131 -52
  50. package/dist/commands/source-add.js +10 -10
  51. package/dist/commands/source-manage.js +11 -19
  52. package/dist/commands/tasks.js +385 -0
  53. package/dist/commands/url-checker.js +39 -0
  54. package/dist/commands/vault.js +7 -33
  55. package/dist/core/action-contributors.js +25 -0
  56. package/dist/core/asset-registry.js +5 -17
  57. package/dist/core/asset-spec.js +11 -1
  58. package/dist/core/common.js +94 -0
  59. package/dist/core/concurrent.js +22 -0
  60. package/dist/core/config.js +229 -122
  61. package/dist/core/events.js +87 -123
  62. package/dist/core/frontmatter.js +3 -1
  63. package/dist/core/markdown.js +17 -0
  64. package/dist/core/memory-improve.js +678 -0
  65. package/dist/core/parse.js +155 -0
  66. package/dist/core/paths.js +101 -3
  67. package/dist/core/proposal-validators.js +61 -0
  68. package/dist/core/proposals.js +49 -38
  69. package/dist/core/state-db.js +775 -0
  70. package/dist/core/time.js +51 -0
  71. package/dist/core/warn.js +59 -1
  72. package/dist/indexer/db-search.js +86 -472
  73. package/dist/indexer/db.js +392 -6
  74. package/dist/indexer/ensure-index.js +133 -0
  75. package/dist/indexer/graph-boost.js +247 -94
  76. package/dist/indexer/graph-db.js +201 -0
  77. package/dist/indexer/graph-dedup.js +99 -0
  78. package/dist/indexer/graph-extraction.js +417 -74
  79. package/dist/indexer/index-context.js +10 -0
  80. package/dist/indexer/indexer.js +466 -298
  81. package/dist/indexer/llm-cache.js +47 -0
  82. package/dist/indexer/match-contributors.js +141 -0
  83. package/dist/indexer/matchers.js +24 -190
  84. package/dist/indexer/memory-inference.js +63 -29
  85. package/dist/indexer/metadata-contributors.js +26 -0
  86. package/dist/indexer/metadata.js +188 -175
  87. package/dist/indexer/path-resolver.js +89 -0
  88. package/dist/indexer/ranking-contributors.js +204 -0
  89. package/dist/indexer/ranking.js +74 -0
  90. package/dist/indexer/search-hit-enrichers.js +22 -0
  91. package/dist/indexer/search-source.js +24 -9
  92. package/dist/indexer/semantic-status.js +2 -16
  93. package/dist/indexer/walker.js +25 -0
  94. package/dist/integrations/agent/config.js +175 -3
  95. package/dist/integrations/agent/index.js +3 -1
  96. package/dist/integrations/agent/pipeline.js +39 -0
  97. package/dist/integrations/agent/profiles.js +67 -5
  98. package/dist/integrations/agent/prompts.js +114 -29
  99. package/dist/integrations/agent/runners.js +31 -0
  100. package/dist/integrations/agent/sdk-runner.js +120 -0
  101. package/dist/integrations/agent/spawn.js +136 -28
  102. package/dist/integrations/lockfile.js +10 -18
  103. package/dist/integrations/session-logs/index.js +65 -0
  104. package/dist/integrations/session-logs/providers/claude-code.js +56 -0
  105. package/dist/integrations/session-logs/providers/opencode.js +52 -0
  106. package/dist/integrations/session-logs/types.js +1 -0
  107. package/dist/llm/call-ai.js +74 -0
  108. package/dist/llm/client.js +63 -86
  109. package/dist/llm/feature-gate.js +27 -16
  110. package/dist/llm/graph-extract.js +297 -64
  111. package/dist/llm/memory-infer.js +52 -71
  112. package/dist/llm/metadata-enhance.js +39 -22
  113. package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
  114. package/dist/output/cli-hints-full.md +277 -0
  115. package/dist/output/cli-hints-short.md +65 -0
  116. package/dist/output/cli-hints.js +2 -309
  117. package/dist/output/renderers.js +196 -124
  118. package/dist/output/shapes.js +41 -3
  119. package/dist/output/text.js +257 -21
  120. package/dist/registry/providers/skills-sh.js +61 -49
  121. package/dist/registry/providers/static-index.js +44 -48
  122. package/dist/setup/setup.js +510 -11
  123. package/dist/sources/provider-factory.js +2 -1
  124. package/dist/sources/providers/git.js +44 -2
  125. package/dist/sources/website-ingest.js +4 -0
  126. package/dist/tasks/backends/cron.js +200 -0
  127. package/dist/tasks/backends/exec-utils.js +25 -0
  128. package/dist/tasks/backends/index.js +32 -0
  129. package/dist/tasks/backends/launchd-template.xml +19 -0
  130. package/dist/tasks/backends/launchd.js +184 -0
  131. package/dist/tasks/backends/schtasks-template.xml +29 -0
  132. package/dist/tasks/backends/schtasks.js +212 -0
  133. package/dist/tasks/parser.js +198 -0
  134. package/dist/tasks/resolveAkmBin.js +84 -0
  135. package/dist/tasks/runner.js +432 -0
  136. package/dist/tasks/schedule.js +208 -0
  137. package/dist/tasks/schema.js +13 -0
  138. package/dist/tasks/validator.js +59 -0
  139. package/dist/wiki/index-template.md +12 -0
  140. package/dist/wiki/ingest-workflow-template.md +54 -0
  141. package/dist/wiki/log-template.md +8 -0
  142. package/dist/wiki/schema-template.md +61 -0
  143. package/dist/wiki/wiki-templates.js +12 -0
  144. package/dist/wiki/wiki.js +10 -61
  145. package/dist/workflows/authoring.js +5 -25
  146. package/dist/workflows/db.js +9 -0
  147. package/dist/workflows/renderer.js +8 -3
  148. package/dist/workflows/runs.js +73 -88
  149. package/dist/workflows/scope-key.js +76 -0
  150. package/dist/workflows/validator.js +1 -1
  151. package/dist/workflows/workflow-template.md +24 -0
  152. package/docs/README.md +3 -0
  153. package/docs/migration/release-notes/0.7.0.md +1 -1
  154. package/docs/migration/release-notes/0.7.4.md +1 -1
  155. package/docs/migration/release-notes/0.7.5.md +20 -0
  156. package/docs/migration/release-notes/0.8.0.md +43 -0
  157. package/package.json +4 -3
  158. package/dist/templates/wiki-templates.js +0 -100
@@ -0,0 +1,823 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import readline from "node:readline";
4
+ import { stringify as yamlStringify } from "yaml";
5
+ import { parseAssetRef } from "../core/asset-ref";
6
+ import { resolveStashDir, timestampForFilename } from "../core/common";
7
+ import { loadConfig } from "../core/config";
8
+ import { ConfigError } from "../core/errors";
9
+ import { parseFrontmatter } from "../core/frontmatter";
10
+ import { parseEmbeddedJsonResponse } from "../core/parse";
11
+ import { createProposal, listProposals } from "../core/proposals";
12
+ import { warn } from "../core/warn";
13
+ import { deleteAssetFromSource, resolveWriteTarget, writeAssetToSource } from "../core/write-source";
14
+ import { closeDatabase, getAllEntries, openExistingDatabase } from "../indexer/db";
15
+ import { chatCompletion } from "../llm/client";
16
+ import { isLlmFeatureEnabled, tryLlmFeature } from "../llm/feature-gate";
17
+ // ── Prompts ─────────────────────────────────────────────────────────────────
18
+ const CONSOLIDATE_SYSTEM_PROMPT = `You are the akm consolidate assistant analyzing memory assets.
19
+
20
+ Rules:
21
+ 1. MERGE: Two or more memories are substantially duplicated or closely related → propose merging. Return the primary ref to keep and secondary refs to delete. Do NOT include mergedContent — the merge will be executed in a separate step.
22
+ 2. DELETE: Memory is clearly outdated, contradicted, or redundant → propose deletion.
23
+ 3. PROMOTE: Memory expresses a stable, reusable fact suitable as a \`knowledge:\` asset → propose promotion. Do NOT delete the source memory.
24
+ 4. KEEP: Memory is unique and current → omit from output.
25
+
26
+ Return ONLY JSON (no prose, no code fences):
27
+ {
28
+ "operations": [
29
+ { "op": "merge", "primary": "memory:<name>", "secondaries": ["memory:<name>", ...], "mergeStrategy": "synthesize" },
30
+ { "op": "delete", "ref": "memory:<name>", "reason": "<brief reason>" },
31
+ { "op": "promote", "ref": "memory:<name>", "knowledgeRef": "knowledge:<suggested-slug>", "reason": "<brief reason>" }
32
+ ],
33
+ "warnings": ["<optional concerns>"]
34
+ }`;
35
+ export function isConsolidationEligibleMemoryName(name) {
36
+ return !name.endsWith(".derived");
37
+ }
38
+ function loadMemoriesFromDb(sourceFilterPath) {
39
+ let db;
40
+ try {
41
+ db = openExistingDatabase();
42
+ const entries = getAllEntries(db, "memory");
43
+ return entries
44
+ .filter((e) => {
45
+ if (!sourceFilterPath)
46
+ return true;
47
+ return path.resolve(e.stashDir) === path.resolve(sourceFilterPath);
48
+ })
49
+ .filter((e) => isConsolidationEligibleMemoryName(e.entry.name))
50
+ .map((e) => ({
51
+ name: e.entry.name,
52
+ filePath: e.filePath,
53
+ description: e.entry.description ?? "",
54
+ tags: e.entry.tags ?? [],
55
+ stashDir: e.stashDir,
56
+ }));
57
+ }
58
+ catch {
59
+ return [];
60
+ }
61
+ finally {
62
+ if (db)
63
+ closeDatabase(db);
64
+ }
65
+ }
66
+ function loadMemoriesFromFs(memoriesDir, stashDir) {
67
+ if (!fs.existsSync(memoriesDir))
68
+ return [];
69
+ const entries = [];
70
+ for (const fname of fs.readdirSync(memoriesDir)) {
71
+ if (!fname.endsWith(".md"))
72
+ continue;
73
+ const filePath = path.join(memoriesDir, fname);
74
+ const name = fname.replace(/\.md$/, "");
75
+ if (!isConsolidationEligibleMemoryName(name))
76
+ continue;
77
+ entries.push({ name, filePath, description: "", tags: [], stashDir });
78
+ }
79
+ return entries;
80
+ }
81
+ // ── Chunk sizing ─────────────────────────────────────────────────────────────
82
+ /**
83
+ * Conservative chars-per-token estimate used when computing prompt budgets.
84
+ * English text averages roughly 4 chars/token for most LLM tokenizers. We use
85
+ * 3 to stay conservative (shorter tokens = more tokens per char).
86
+ */
87
+ const CHARS_PER_TOKEN = 3;
88
+ /**
89
+ * Overhead budget reserved for the system prompt, chunk header lines, and per-
90
+ * memory metadata lines (name, description, tags, separator). Measured at
91
+ * roughly 600 chars for the system prompt + ~100 chars of header + ~50 chars
92
+ * per memory × chunk size. We round up to 2 000 tokens to leave room for the
93
+ * model's own output.
94
+ */
95
+ const PROMPT_OVERHEAD_TOKENS = 2_000;
96
+ /**
97
+ * Default effective token budget used when `config.llm.contextLength` is not
98
+ * set. This is intentionally conservative (4 096) rather than being set to
99
+ * the model's actual context window, because:
100
+ *
101
+ * - When the agent path is used (config.agent), the agent CLI (e.g. opencode)
102
+ * prepends its own large system prompt + conversation history before
103
+ * forwarding to the model. That overhead easily consumes 30K+ tokens on
104
+ * a model with a 16K context window, leaving very little room for
105
+ * chunk content.
106
+ * - When the HTTP path is used (config.llm), only the akm system prompt and
107
+ * user prompt are sent, so the budget can be set to the model's actual
108
+ * context length via config.llm.contextLength.
109
+ *
110
+ * Set config.llm.contextLength in your config file to the model's actual
111
+ * context window to allow larger chunks on the HTTP path.
112
+ */
113
+ export const DEFAULT_CONTEXT_LENGTH_TOKENS = 4_096;
114
+ /**
115
+ * Given the model's context window and the per-memory body truncation limit,
116
+ * return the maximum number of memories that can safely fit in one chunk
117
+ * without the prompt overflowing the context window.
118
+ *
119
+ * The formula is:
120
+ * usableTokens = contextLength - PROMPT_OVERHEAD_TOKENS
121
+ * tokensPerMemory = ceil(bodyTruncation / CHARS_PER_TOKEN)
122
+ * chunkSize = floor(usableTokens / tokensPerMemory)
123
+ *
124
+ * Result is clamped between 1 and 50 to avoid degenerate values.
125
+ *
126
+ * @param contextLength - Model context window in tokens.
127
+ * @param bodyTruncation - Max chars per memory body included in the prompt.
128
+ */
129
+ export function computeSafeChunkSize(contextLength, bodyTruncation) {
130
+ const usableTokens = Math.max(contextLength - PROMPT_OVERHEAD_TOKENS, 0);
131
+ const tokensPerMemory = Math.max(Math.ceil(bodyTruncation / CHARS_PER_TOKEN), 1);
132
+ const raw = Math.floor(usableTokens / tokensPerMemory);
133
+ return Math.max(1, Math.min(50, raw));
134
+ }
135
+ // ── Chunk helpers ────────────────────────────────────────────────────────────
136
+ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks, bodyTruncation) {
137
+ const start = memories[0] ? `memory:${memories[0].name}` : "";
138
+ const end = memories[memories.length - 1] ? `memory:${memories[memories.length - 1].name}` : "";
139
+ const lines = [
140
+ `Source: ${sourceName}`,
141
+ `Chunk ${chunkIndex + 1} of ${totalChunks}, memories ${start}–${end}:`,
142
+ "",
143
+ ];
144
+ for (let i = 0; i < memories.length; i++) {
145
+ const m = memories[i];
146
+ lines.push(`[${i + 1}] memory:${m.name}`);
147
+ lines.push(`Description: ${m.description || "(none)"}`);
148
+ lines.push(`Tags: ${m.tags.length > 0 ? m.tags.join(", ") : "(none)"}`);
149
+ lines.push("---");
150
+ let body = "";
151
+ try {
152
+ body = fs.readFileSync(m.filePath, "utf8");
153
+ }
154
+ catch {
155
+ body = "(unreadable)";
156
+ }
157
+ lines.push(body.slice(0, bodyTruncation));
158
+ lines.push("");
159
+ }
160
+ return lines.join("\n");
161
+ }
162
+ function isValidOp(op) {
163
+ if (typeof op !== "object" || op === null)
164
+ return false;
165
+ const o = op;
166
+ if (o.op === "merge") {
167
+ return typeof o.primary === "string" && Array.isArray(o.secondaries);
168
+ }
169
+ if (o.op === "delete") {
170
+ return typeof o.ref === "string";
171
+ }
172
+ if (o.op === "promote") {
173
+ return typeof o.ref === "string" && typeof o.knowledgeRef === "string";
174
+ }
175
+ return false;
176
+ }
177
+ function mergePlans(chunks) {
178
+ const mergeOps = new Map();
179
+ const deleteOps = new Map();
180
+ const promoteOps = new Map();
181
+ const warnings = [];
182
+ for (const chunk of chunks) {
183
+ for (const op of chunk) {
184
+ if (op.op === "merge") {
185
+ // merge wins over delete
186
+ if (deleteOps.has(op.primary)) {
187
+ deleteOps.delete(op.primary);
188
+ }
189
+ for (const sec of op.secondaries) {
190
+ if (deleteOps.has(sec))
191
+ deleteOps.delete(sec);
192
+ }
193
+ mergeOps.set(op.primary, op);
194
+ }
195
+ else if (op.op === "delete") {
196
+ if (!mergeOps.has(op.ref)) {
197
+ deleteOps.set(op.ref, op);
198
+ }
199
+ }
200
+ else if (op.op === "promote") {
201
+ const existingMerge = mergeOps.get(op.ref);
202
+ if (existingMerge) {
203
+ warnings.push(`Conflict: promote and merge both target ${op.ref}; preferring merge.`);
204
+ }
205
+ else {
206
+ promoteOps.set(op.ref, op);
207
+ }
208
+ }
209
+ }
210
+ }
211
+ const ops = [...mergeOps.values(), ...deleteOps.values(), ...promoteOps.values()];
212
+ return { ops, warnings };
213
+ }
214
+ function getJournalPath(stashDir) {
215
+ return path.join(stashDir, ".akm", "consolidate-journal.json");
216
+ }
217
+ function getBackupDir(stashDir, timestamp) {
218
+ return path.join(stashDir, ".akm", "consolidate-backup", timestamp);
219
+ }
220
+ function removeStaleJournal(stashDir, journal, warnings) {
221
+ const journalPath = getJournalPath(stashDir);
222
+ try {
223
+ fs.unlinkSync(journalPath);
224
+ }
225
+ catch {
226
+ warnings.push(`Failed to remove stale consolidate journal at ${journalPath}.`);
227
+ }
228
+ const backupTimestamp = typeof journal.backupTimestamp === "string" && journal.backupTimestamp.trim().length > 0
229
+ ? journal.backupTimestamp.trim()
230
+ : typeof journal.startedAt === "string" && journal.startedAt.trim().length > 0
231
+ ? journal.startedAt.replace(/[:.]/g, "-")
232
+ : "";
233
+ if (!backupTimestamp)
234
+ return;
235
+ const backupDir = getBackupDir(stashDir, backupTimestamp);
236
+ if (!fs.existsSync(backupDir))
237
+ return;
238
+ try {
239
+ fs.rmSync(backupDir, { recursive: true, force: true });
240
+ }
241
+ catch {
242
+ warnings.push(`Failed to remove stale consolidate backup at ${backupDir}.`);
243
+ }
244
+ warnings.push(`Cleared stale consolidate backup at ${backupDir}.`);
245
+ }
246
+ function checkForIncompleteJournal(stashDir, recoveryMode, warnings) {
247
+ const journalPath = getJournalPath(stashDir);
248
+ if (!fs.existsSync(journalPath))
249
+ return;
250
+ let journal;
251
+ try {
252
+ journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
253
+ }
254
+ catch {
255
+ if (recoveryMode === "clean") {
256
+ try {
257
+ fs.unlinkSync(journalPath);
258
+ warnings.push(`Removed unreadable consolidate journal at ${journalPath}.`);
259
+ }
260
+ catch {
261
+ warnings.push(`Failed to remove unreadable consolidate journal at ${journalPath}.`);
262
+ }
263
+ return;
264
+ }
265
+ throw new ConfigError(`Incomplete consolidation state detected: unreadable journal at ${journalPath}. Re-run with --consolidate-recovery clean to remove stale journal artifacts, or remove the file manually.`, "INVALID_CONFIG_FILE");
266
+ }
267
+ const operationCount = Array.isArray(journal.operations) ? journal.operations.length : 0;
268
+ const completedCount = Array.isArray(journal.completed) ? journal.completed.length : 0;
269
+ if (completedCount >= operationCount)
270
+ return;
271
+ if (recoveryMode === "clean") {
272
+ removeStaleJournal(stashDir, journal, warnings);
273
+ warnings.push(`Removed stale consolidation journal at ${journalPath} (${completedCount}/${operationCount} operations completed).`);
274
+ return;
275
+ }
276
+ const backupHint = typeof journal.backupTimestamp === "string" && journal.backupTimestamp.trim().length > 0
277
+ ? ` Backup dir: ${getBackupDir(stashDir, journal.backupTimestamp.trim())}.`
278
+ : "";
279
+ throw new ConfigError(`Incomplete consolidation run detected at ${journalPath} (${completedCount}/${operationCount} operations completed). Re-run with --consolidate-recovery clean to remove stale journal artifacts.${backupHint}`, "INVALID_CONFIG_FILE");
280
+ }
281
+ function writeJournal(stashDir, ops, backupTimestamp) {
282
+ const journalPath = getJournalPath(stashDir);
283
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
284
+ const journal = {
285
+ startedAt: new Date().toISOString(),
286
+ operations: ops,
287
+ completed: [],
288
+ backupTimestamp,
289
+ };
290
+ fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2), "utf8");
291
+ }
292
+ function markJournalCompleted(stashDir, opRef) {
293
+ const journalPath = getJournalPath(stashDir);
294
+ if (!fs.existsSync(journalPath))
295
+ return;
296
+ try {
297
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
298
+ journal.completed.push(opRef);
299
+ fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2), "utf8");
300
+ }
301
+ catch {
302
+ // best-effort
303
+ }
304
+ }
305
+ function cleanupJournal(stashDir, timestamp) {
306
+ const journalPath = getJournalPath(stashDir);
307
+ try {
308
+ fs.unlinkSync(journalPath);
309
+ }
310
+ catch {
311
+ // ignore
312
+ }
313
+ const backupDir = getBackupDir(stashDir, timestamp);
314
+ try {
315
+ fs.rmSync(backupDir, { recursive: true, force: true });
316
+ }
317
+ catch {
318
+ // ignore
319
+ }
320
+ }
321
+ function backupFile(filePath, backupDir, name) {
322
+ try {
323
+ fs.mkdirSync(backupDir, { recursive: true });
324
+ fs.copyFileSync(filePath, path.join(backupDir, `${name}.md`));
325
+ }
326
+ catch {
327
+ // best-effort
328
+ }
329
+ }
330
+ // ── Archive helper (P1-B: soft-invalidation) ─────────────────────────────────
331
+ /**
332
+ * Move a memory asset to `.akm/archive/` with `status: superseded` frontmatter
333
+ * instead of deleting it outright. The live stash delete still happens after
334
+ * this call — this is belt-and-suspenders archival that survives the hard delete.
335
+ *
336
+ * Archive filename: `<iso-ts>-<opIndex>-<basename>.md`
337
+ * New frontmatter fields: status, superseded_at, superseded_by (optional),
338
+ * superseded_reason.
339
+ */
340
+ function archiveMemory(filePath, stashDir, ref, reason, opIndex, supersededBy, warnings) {
341
+ const archiveDir = path.join(stashDir, ".akm", "archive");
342
+ fs.mkdirSync(archiveDir, { recursive: true });
343
+ let raw;
344
+ try {
345
+ raw = fs.readFileSync(filePath, "utf8");
346
+ }
347
+ catch {
348
+ if (warnings)
349
+ warnings.push(`archiveMemory: could not read ${ref} for archiving — skipping archive write`);
350
+ return;
351
+ }
352
+ let content = raw;
353
+ try {
354
+ const parsed = parseFrontmatter(raw);
355
+ const newFm = {
356
+ ...parsed.data,
357
+ status: "superseded",
358
+ superseded_at: new Date().toISOString(),
359
+ ...(supersededBy ? { superseded_by: supersededBy } : {}),
360
+ superseded_reason: reason,
361
+ };
362
+ const fmStr = yamlStringify(newFm).trimEnd();
363
+ content = `---\n${fmStr}\n---\n${parsed.content}`;
364
+ }
365
+ catch {
366
+ if (warnings)
367
+ warnings.push(`archiveMemory: could not parse frontmatter for ${ref} — archiving raw`);
368
+ }
369
+ const ts = timestampForFilename();
370
+ const safeName = path.basename(filePath, ".md");
371
+ const archivePath = path.join(archiveDir, `${ts}-${opIndex}-${safeName}.md`);
372
+ try {
373
+ fs.writeFileSync(archivePath, content, "utf8");
374
+ }
375
+ catch (e) {
376
+ if (warnings)
377
+ warnings.push(`archiveMemory: write failed for ${ref}: ${String(e)}`);
378
+ }
379
+ }
380
+ // ── Main entry point ─────────────────────────────────────────────────────────
381
+ export async function akmConsolidate(opts = {}) {
382
+ const startMs = Date.now();
383
+ const config = opts.config ?? loadConfig();
384
+ const stashDir = opts.stashDir ?? resolveStashDir();
385
+ if (!isLlmFeatureEnabled(config, "memory_consolidation")) {
386
+ return {
387
+ schemaVersion: 1,
388
+ ok: true,
389
+ shape: "consolidate-result",
390
+ dryRun: opts.dryRun ?? false,
391
+ previewOnly: false,
392
+ target: opts.target ?? stashDir,
393
+ processed: 0,
394
+ merged: 0,
395
+ deleted: 0,
396
+ promoted: [],
397
+ warnings: [],
398
+ durationMs: Date.now() - startMs,
399
+ };
400
+ }
401
+ const warnings = [];
402
+ checkForIncompleteJournal(stashDir, opts.recoveryMode ?? "abort", warnings);
403
+ const memories = loadMemoriesForSource(opts.target, stashDir, warnings);
404
+ if (memories.length === 0) {
405
+ return {
406
+ schemaVersion: 1,
407
+ ok: true,
408
+ shape: "consolidate-result",
409
+ dryRun: opts.dryRun ?? false,
410
+ previewOnly: false,
411
+ target: opts.target ?? stashDir,
412
+ processed: 0,
413
+ merged: 0,
414
+ deleted: 0,
415
+ promoted: [],
416
+ warnings,
417
+ durationMs: Date.now() - startMs,
418
+ };
419
+ }
420
+ // Consolidation always uses the HTTP LLM client directly — never the agent
421
+ // CLI. The agent CLI is for interactive agent sessions (reflect, propose);
422
+ // structured JSON generation works better and faster via HTTP.
423
+ const isHttpPath = !!config.llm;
424
+ // Chunk sizing: derive a safe chunk size from the configured model context
425
+ // window (config.llm.contextLength) so that the full prompt (system prompt +
426
+ // chunk user prompt) never exceeds the model's n_ctx limit. When no context
427
+ // length is configured we fall back to DEFAULT_CONTEXT_LENGTH_TOKENS (8 000)
428
+ // which is conservative enough for most 8K–16K local models.
429
+ //
430
+ // bodyTruncation caps the body excerpt included per memory in the prompt.
431
+ // Reducing it further than 500 chars degrades consolidation quality, so we
432
+ // keep it fixed and let computeSafeChunkSize vary the number of memories
433
+ // per chunk instead.
434
+ const bodyTruncation = 500;
435
+ const modelContextLength = config.llm?.contextLength ?? DEFAULT_CONTEXT_LENGTH_TOKENS;
436
+ const chunkSize = computeSafeChunkSize(modelContextLength, bodyTruncation);
437
+ // -- Phase A: plan generation -----------------------------------------------
438
+ const sourceName = opts.target ?? stashDir;
439
+ const chunks = [];
440
+ for (let i = 0; i < memories.length; i += chunkSize) {
441
+ chunks.push(memories.slice(i, i + chunkSize));
442
+ }
443
+ warn(`[consolidate] ${memories.length} memories / ${chunks.length} chunk(s) / chunk_size=${chunkSize}`);
444
+ const chunkOpsArrays = [];
445
+ let consecutiveFailures = 0;
446
+ for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) {
447
+ // Abort early if the first chunk failed — the LLM/agent is likely unavailable
448
+ // and continuing would waste minutes processing chunks that will all fail the same way.
449
+ if (chunkIdx > 0 && consecutiveFailures >= 2) {
450
+ const skipped = chunks.length - chunkIdx;
451
+ warnings.push(`Consolidation aborted after ${consecutiveFailures} consecutive chunk failures — LLM may be unavailable. ${skipped} chunk(s) skipped.`);
452
+ break;
453
+ }
454
+ const chunk = chunks[chunkIdx];
455
+ warn(`[consolidate] chunk ${chunkIdx + 1}/${chunks.length} (${chunk.length} memories) …`);
456
+ const userPrompt = buildChunkPrompt(sourceName, chunk, chunkIdx, chunks.length, bodyTruncation);
457
+ const raw = await tryLlmFeature("memory_consolidation", config, async () => {
458
+ if (!config.llm)
459
+ return { ok: false, error: "No LLM configured for consolidation" };
460
+ try {
461
+ const content = await chatCompletion(config.llm, [
462
+ { role: "system", content: CONSOLIDATE_SYSTEM_PROMPT },
463
+ { role: "user", content: userPrompt },
464
+ ]);
465
+ return { ok: true, content };
466
+ }
467
+ catch (e) {
468
+ return { ok: false, error: String(e) };
469
+ }
470
+ }, { ok: false, error: `chunk ${chunkIdx + 1} failed` });
471
+ if (!raw.ok) {
472
+ warnings.push(raw.error ?? `chunk ${chunkIdx + 1} failed`);
473
+ consecutiveFailures++;
474
+ continue;
475
+ }
476
+ if (process.env.AKM_DEBUG_LLM) {
477
+ const preview = (raw.content ?? "").slice(0, 500);
478
+ warn(`[akm:consolidate] chunk ${chunkIdx + 1} raw response (first 500 chars): ${preview}`);
479
+ }
480
+ const parsed = parseEmbeddedJsonResponse(raw.content);
481
+ if (!parsed || !Array.isArray(parsed.operations)) {
482
+ const hint = raw.content !== undefined && raw.content.trim() === ""
483
+ ? " (empty response — if using a thinking model, disable thinking mode)"
484
+ : "";
485
+ warnings.push(`Chunk ${chunkIdx + 1}: invalid plan from AI — skipping.${hint}`);
486
+ consecutiveFailures++;
487
+ continue;
488
+ }
489
+ consecutiveFailures = 0; // reset on success
490
+ const ops = [];
491
+ for (const op of parsed.operations) {
492
+ if (isValidOp(op)) {
493
+ ops.push(op);
494
+ }
495
+ else {
496
+ warnings.push(`Chunk ${chunkIdx + 1}: skipping invalid operation: ${JSON.stringify(op)}`);
497
+ }
498
+ }
499
+ if (Array.isArray(parsed.warnings)) {
500
+ for (const w of parsed.warnings) {
501
+ if (typeof w === "string")
502
+ warnings.push(w);
503
+ }
504
+ }
505
+ chunkOpsArrays.push(ops);
506
+ }
507
+ const { ops: allOps, warnings: mergeWarnings } = mergePlans(chunkOpsArrays);
508
+ warnings.push(...mergeWarnings);
509
+ // -- Dry-run: show AI plan without executing any writes --------------------
510
+ if (opts.dryRun) {
511
+ return {
512
+ schemaVersion: 1,
513
+ ok: true,
514
+ shape: "consolidate-result",
515
+ dryRun: true,
516
+ previewOnly: true,
517
+ target: sourceName,
518
+ processed: memories.length,
519
+ merged: 0,
520
+ deleted: 0,
521
+ promoted: [],
522
+ planned: allOps,
523
+ warnings,
524
+ durationMs: Date.now() - startMs,
525
+ };
526
+ }
527
+ warn(`[consolidate] plan: ${allOps.length} operation(s)`);
528
+ // -- HTTP path: warn about quality and confirm unless auto-accepted --------
529
+ if (isHttpPath) {
530
+ warnings.push("Running on HTTP path — plan generated from truncated memory excerpts; quality may vary.");
531
+ if (!opts.autoAccept) {
532
+ const n = allOps.length;
533
+ const answer = await promptConfirm(`Apply ${n} operations? [y/N] `);
534
+ if (!answer) {
535
+ return {
536
+ schemaVersion: 1,
537
+ ok: true,
538
+ shape: "consolidate-result",
539
+ dryRun: false,
540
+ previewOnly: true,
541
+ target: sourceName,
542
+ processed: memories.length,
543
+ merged: 0,
544
+ deleted: 0,
545
+ promoted: [],
546
+ planned: allOps,
547
+ warnings: [...warnings, "Aborted by user."],
548
+ durationMs: Date.now() - startMs,
549
+ };
550
+ }
551
+ }
552
+ }
553
+ // -- Phase B + writes -------------------------------------------------------
554
+ const target = resolveWriteTarget(config);
555
+ const timestamp = timestampForFilename();
556
+ const backupDir = getBackupDir(stashDir, timestamp);
557
+ // Write journal before any mutations
558
+ writeJournal(stashDir, allOps, timestamp);
559
+ let merged = 0;
560
+ let deleted = 0;
561
+ const promoted = [];
562
+ // Build a lookup map: ref → MemoryEntry
563
+ const memoryByRef = new Map();
564
+ for (const m of memories) {
565
+ memoryByRef.set(`memory:${m.name}`, m);
566
+ }
567
+ for (let opIndex = 0; opIndex < allOps.length; opIndex++) {
568
+ const op = allOps[opIndex];
569
+ warn(`[consolidate] ${opIndex + 1}/${allOps.length} ${op.op} ${op.op === "merge" ? op.primary : op.ref}`);
570
+ if (op.op === "merge") {
571
+ const primaryEntry = memoryByRef.get(op.primary);
572
+ if (!primaryEntry) {
573
+ warnings.push(`Merge: primary ${op.primary} not found in loaded memories — skipping.`);
574
+ continue;
575
+ }
576
+ // Phase B: generate merged content
577
+ const secondaryBodies = [];
578
+ for (const secRef of op.secondaries) {
579
+ const secEntry = memoryByRef.get(secRef);
580
+ if (!secEntry) {
581
+ warnings.push(`Merge: secondary ${secRef} not found — skipping merge op.`);
582
+ continue;
583
+ }
584
+ secondaryBodies.push(secRef);
585
+ }
586
+ if (secondaryBodies.length === 0)
587
+ continue;
588
+ let primaryBody = "";
589
+ try {
590
+ primaryBody = fs.readFileSync(primaryEntry.filePath, "utf8");
591
+ }
592
+ catch {
593
+ warnings.push(`Merge: could not read primary ${op.primary} — skipping.`);
594
+ continue;
595
+ }
596
+ const mergedContent = await generateMergedContent(config, op.primary, primaryBody, op.secondaries, memoryByRef, warnings);
597
+ if (mergedContent === null)
598
+ continue;
599
+ // Validate frontmatter of merged content
600
+ try {
601
+ parseFrontmatter(mergedContent);
602
+ }
603
+ catch {
604
+ warnings.push(`Merge: merged content for ${op.primary} has invalid frontmatter — skipping.`);
605
+ continue;
606
+ }
607
+ // Backup secondaries before deleting
608
+ for (const secRef of op.secondaries) {
609
+ const secEntry = memoryByRef.get(secRef);
610
+ if (secEntry && fs.existsSync(secEntry.filePath)) {
611
+ backupFile(secEntry.filePath, backupDir, secEntry.name);
612
+ }
613
+ }
614
+ // Write merged primary
615
+ try {
616
+ const parsedPrimary = parseAssetRef(op.primary);
617
+ await writeAssetToSource(target.source, target.config, parsedPrimary, mergedContent);
618
+ }
619
+ catch (e) {
620
+ warnings.push(`Merge: write failed for ${op.primary}: ${String(e)}`);
621
+ continue;
622
+ }
623
+ // Archive and delete secondaries (P1-B: soft-invalidation)
624
+ for (const secRef of op.secondaries) {
625
+ const secEntry = memoryByRef.get(secRef);
626
+ if (!secEntry)
627
+ continue;
628
+ if (fs.existsSync(secEntry.filePath)) {
629
+ archiveMemory(secEntry.filePath, stashDir, secRef, "merged into primary", opIndex, op.primary, warnings);
630
+ }
631
+ try {
632
+ const parsedSec = parseAssetRef(secRef);
633
+ await deleteAssetFromSource(target.source, target.config, parsedSec);
634
+ markJournalCompleted(stashDir, secRef);
635
+ }
636
+ catch (e) {
637
+ warnings.push(`Merge: delete failed for ${secRef}: ${String(e)}`);
638
+ }
639
+ }
640
+ markJournalCompleted(stashDir, op.primary);
641
+ merged++;
642
+ }
643
+ else if (op.op === "delete") {
644
+ const entry = memoryByRef.get(op.ref);
645
+ if (!entry) {
646
+ warnings.push(`Delete: ${op.ref} not found in loaded memories — skipping.`);
647
+ continue;
648
+ }
649
+ if (fs.existsSync(entry.filePath)) {
650
+ backupFile(entry.filePath, backupDir, entry.name);
651
+ // P1-B: soft-invalidation archive before hard delete
652
+ archiveMemory(entry.filePath, stashDir, op.ref, op.reason, opIndex, undefined, warnings);
653
+ }
654
+ try {
655
+ const parsedRef = parseAssetRef(op.ref);
656
+ await deleteAssetFromSource(target.source, target.config, parsedRef);
657
+ markJournalCompleted(stashDir, op.ref);
658
+ deleted++;
659
+ }
660
+ catch (e) {
661
+ warnings.push(`Delete: failed for ${op.ref}: ${String(e)}`);
662
+ }
663
+ }
664
+ else if (op.op === "promote") {
665
+ const entry = memoryByRef.get(op.ref);
666
+ if (!entry) {
667
+ warnings.push(`Promote: ${op.ref} not found in loaded memories — skipping.`);
668
+ continue;
669
+ }
670
+ let knowledgeRef = op.knowledgeRef;
671
+ try {
672
+ parseAssetRef(knowledgeRef);
673
+ }
674
+ catch {
675
+ const slug = op.knowledgeRef
676
+ .replace(/^knowledge:/, "")
677
+ .replace(/[^a-z0-9-]/gi, "-")
678
+ .toLowerCase();
679
+ knowledgeRef = `knowledge:${slug}`;
680
+ warnings.push(`Normalized invalid ref "${op.knowledgeRef}" → "${knowledgeRef}"`);
681
+ }
682
+ // Idempotency: check pending proposals
683
+ const existingProposals = listProposals(stashDir, { ref: knowledgeRef });
684
+ if (existingProposals.some((p) => p.status === "pending")) {
685
+ warnings.push(`Skipping promote: pending proposal already exists for ${knowledgeRef}`);
686
+ continue;
687
+ }
688
+ // Idempotency: check if knowledge asset already exists
689
+ const parsedKnowledgeRef = parseAssetRef(knowledgeRef);
690
+ const destPath = path.join(target.source.path, "knowledge", `${parsedKnowledgeRef.name}.md`);
691
+ if (fs.existsSync(destPath)) {
692
+ warnings.push(`Skipping promote: ${knowledgeRef} already exists in source`);
693
+ continue;
694
+ }
695
+ let memoryContent = "";
696
+ try {
697
+ memoryContent = fs.readFileSync(entry.filePath, "utf8");
698
+ }
699
+ catch (e) {
700
+ warnings.push(`Promote: could not read ${op.ref}: ${String(e)}`);
701
+ continue;
702
+ }
703
+ try {
704
+ const proposal = createProposal(stashDir, {
705
+ ref: knowledgeRef,
706
+ source: "consolidate",
707
+ payload: { content: memoryContent },
708
+ });
709
+ promoted.push(proposal.id);
710
+ markJournalCompleted(stashDir, op.ref);
711
+ }
712
+ catch (e) {
713
+ warnings.push(`Promote: createProposal failed for ${op.ref}: ${String(e)}`);
714
+ }
715
+ }
716
+ }
717
+ cleanupJournal(stashDir, timestamp);
718
+ // TTL cleanup: remove archive entries older than archiveRetentionDays (default 90)
719
+ const archiveDir = path.join(stashDir, ".akm", "archive");
720
+ if (fs.existsSync(archiveDir)) {
721
+ const retentionMs = (config.archiveRetentionDays ?? 90) * 86_400_000;
722
+ const cutoff = Date.now() - retentionMs;
723
+ for (const fname of fs.readdirSync(archiveDir)) {
724
+ const fp = path.join(archiveDir, fname);
725
+ try {
726
+ if (fs.statSync(fp).mtimeMs < cutoff)
727
+ fs.unlinkSync(fp);
728
+ }
729
+ catch {
730
+ /* ignore race conditions */
731
+ }
732
+ }
733
+ }
734
+ return {
735
+ schemaVersion: 1,
736
+ ok: true,
737
+ shape: "consolidate-result",
738
+ dryRun: false,
739
+ previewOnly: false,
740
+ target: sourceName,
741
+ processed: memories.length,
742
+ merged,
743
+ deleted,
744
+ promoted,
745
+ warnings,
746
+ durationMs: Date.now() - startMs,
747
+ };
748
+ }
749
+ // ── Helpers ─────────────────────────────────────────────────────────────────
750
+ function loadMemoriesForSource(source, stashDir, warnings) {
751
+ let memories = loadMemoriesFromDb(source ? resolveSourcePath(source) : undefined);
752
+ if (memories.length === 0) {
753
+ // DB fallback: walk filesystem
754
+ const memoriesDir = path.join(source ?? stashDir, "memories");
755
+ memories = loadMemoriesFromFs(memoriesDir, source ?? stashDir);
756
+ if (memories.length > 0) {
757
+ warnings.push("DB not found or empty — loaded memories directly from filesystem.");
758
+ }
759
+ }
760
+ return memories;
761
+ }
762
+ function resolveSourcePath(sourceName) {
763
+ // If it looks like an absolute path, use directly
764
+ if (path.isAbsolute(sourceName))
765
+ return sourceName;
766
+ return sourceName;
767
+ }
768
+ async function generateMergedContent(config, primaryRef, primaryBody, secondaryRefs, memoryByRef, warnings) {
769
+ // Only handle single-secondary merges per design (one call per merge op)
770
+ const secRef = secondaryRefs[0];
771
+ const secEntry = memoryByRef.get(secRef);
772
+ if (!secEntry)
773
+ return null;
774
+ let secBody = "";
775
+ try {
776
+ secBody = fs.readFileSync(secEntry.filePath, "utf8");
777
+ }
778
+ catch {
779
+ warnings.push(`Merge: could not read secondary ${secRef} — skipping.`);
780
+ return null;
781
+ }
782
+ const prompt = [
783
+ "Merge these two memory assets into one. Output ONLY the merged markdown (with YAML frontmatter). Do not explain, do not use code fences.",
784
+ "",
785
+ `=== Primary memory (${primaryRef}) ===`,
786
+ primaryBody,
787
+ "",
788
+ `=== Secondary memory (${secRef}) ===`,
789
+ secBody,
790
+ ].join("\n");
791
+ const result = await tryLlmFeature("memory_consolidation", config, async () => {
792
+ if (!config.llm)
793
+ return { ok: false, error: "No LLM configured for consolidation" };
794
+ try {
795
+ const content = await chatCompletion(config.llm, [{ role: "user", content: prompt }]);
796
+ return { ok: true, content };
797
+ }
798
+ catch (e) {
799
+ return { ok: false, error: String(e) };
800
+ }
801
+ }, { ok: false, error: `merge content generation failed for ${primaryRef}` });
802
+ if (!result.ok) {
803
+ warnings.push(result.error ?? `merge content generation failed for ${primaryRef}`);
804
+ return null;
805
+ }
806
+ return result.content;
807
+ }
808
+ async function promptConfirm(message) {
809
+ process.stdout.write(message);
810
+ return new Promise((resolve) => {
811
+ let settled = false;
812
+ const done = (answer) => {
813
+ if (settled)
814
+ return;
815
+ settled = true;
816
+ rl.close();
817
+ resolve(answer);
818
+ };
819
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
820
+ rl.once("line", (line) => done(line.trim().toLowerCase() === "y"));
821
+ rl.once("close", () => done(false));
822
+ });
823
+ }