akm-cli 0.7.4 → 0.8.0-rc.3

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