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.
- package/{CHANGELOG.md → .github/CHANGELOG.md} +34 -1
- package/.github/LICENSE +374 -0
- package/dist/cli/parse-args.js +43 -0
- package/dist/cli.js +1007 -593
- package/dist/commands/agent-dispatch.js +102 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +823 -0
- package/dist/commands/curate.js +1 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +250 -48
- package/dist/commands/eval-cases.js +40 -0
- package/dist/commands/events.js +12 -24
- package/dist/commands/graph.js +222 -0
- package/dist/commands/health.js +376 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +53 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +3 -30
- package/dist/commands/improve.js +1170 -0
- package/dist/commands/info.js +2 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/install-audit.js +5 -1
- package/dist/commands/installed-stashes.js +118 -138
- package/dist/commands/knowledge.js +133 -0
- package/dist/commands/lint/agent-linter.js +46 -0
- package/dist/commands/lint/base-linter.js +251 -0
- package/dist/commands/lint/command-linter.js +46 -0
- package/dist/commands/lint/default-linter.js +13 -0
- package/dist/commands/lint/index.js +107 -0
- package/dist/commands/lint/knowledge-linter.js +13 -0
- package/dist/commands/lint/memory-linter.js +58 -0
- package/dist/commands/lint/registry.js +33 -0
- package/dist/commands/lint/skill-linter.js +42 -0
- package/dist/commands/lint/task-linter.js +47 -0
- package/dist/commands/lint/types.js +1 -0
- package/dist/commands/lint/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/migration-help.js +2 -2
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +113 -43
- package/dist/commands/reflect.js +175 -41
- package/dist/commands/registry-search.js +2 -2
- package/dist/commands/remember.js +55 -1
- package/dist/commands/schema-repair.js +130 -0
- package/dist/commands/search.js +21 -5
- package/dist/commands/show.js +131 -52
- package/dist/commands/source-add.js +10 -10
- package/dist/commands/source-manage.js +11 -19
- package/dist/commands/tasks.js +385 -0
- package/dist/commands/url-checker.js +39 -0
- package/dist/commands/vault.js +7 -33
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-registry.js +5 -17
- package/dist/core/asset-spec.js +11 -1
- package/dist/core/common.js +94 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +229 -122
- package/dist/core/events.js +87 -123
- package/dist/core/frontmatter.js +3 -1
- package/dist/core/markdown.js +17 -0
- package/dist/core/memory-improve.js +678 -0
- package/dist/core/parse.js +155 -0
- package/dist/core/paths.js +101 -3
- package/dist/core/proposal-validators.js +61 -0
- package/dist/core/proposals.js +49 -38
- package/dist/core/state-db.js +775 -0
- package/dist/core/time.js +51 -0
- package/dist/core/warn.js +59 -1
- package/dist/indexer/db-search.js +86 -472
- package/dist/indexer/db.js +392 -6
- package/dist/indexer/ensure-index.js +133 -0
- package/dist/indexer/graph-boost.js +247 -94
- package/dist/indexer/graph-db.js +201 -0
- package/dist/indexer/graph-dedup.js +99 -0
- package/dist/indexer/graph-extraction.js +417 -74
- package/dist/indexer/index-context.js +10 -0
- package/dist/indexer/indexer.js +466 -298
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/match-contributors.js +141 -0
- package/dist/indexer/matchers.js +24 -190
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +188 -175
- package/dist/indexer/path-resolver.js +89 -0
- package/dist/indexer/ranking-contributors.js +204 -0
- package/dist/indexer/ranking.js +74 -0
- package/dist/indexer/search-hit-enrichers.js +22 -0
- package/dist/indexer/search-source.js +24 -9
- package/dist/indexer/semantic-status.js +2 -16
- package/dist/indexer/walker.js +25 -0
- package/dist/integrations/agent/config.js +175 -3
- package/dist/integrations/agent/index.js +3 -1
- package/dist/integrations/agent/pipeline.js +39 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +114 -29
- package/dist/integrations/agent/runners.js +31 -0
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +136 -28
- package/dist/integrations/lockfile.js +10 -18
- package/dist/integrations/session-logs/index.js +65 -0
- package/dist/integrations/session-logs/providers/claude-code.js +56 -0
- package/dist/integrations/session-logs/providers/opencode.js +52 -0
- package/dist/integrations/session-logs/types.js +1 -0
- package/dist/llm/call-ai.js +74 -0
- package/dist/llm/client.js +63 -86
- package/dist/llm/feature-gate.js +27 -16
- package/dist/llm/graph-extract.js +297 -64
- package/dist/llm/memory-infer.js +52 -71
- package/dist/llm/metadata-enhance.js +39 -22
- package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
- package/dist/output/cli-hints-full.md +277 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +2 -309
- package/dist/output/renderers.js +196 -124
- package/dist/output/shapes.js +41 -3
- package/dist/output/text.js +257 -21
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/git.js +44 -2
- package/dist/sources/website-ingest.js +4 -0
- package/dist/tasks/backends/cron.js +200 -0
- package/dist/tasks/backends/exec-utils.js +25 -0
- package/dist/tasks/backends/index.js +32 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +184 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +212 -0
- package/dist/tasks/parser.js +198 -0
- package/dist/tasks/resolveAkmBin.js +84 -0
- package/dist/tasks/runner.js +432 -0
- package/dist/tasks/schedule.js +208 -0
- package/dist/tasks/schema.js +13 -0
- package/dist/tasks/validator.js +59 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +12 -0
- package/dist/wiki/wiki.js +10 -61
- package/dist/workflows/authoring.js +5 -25
- package/dist/workflows/db.js +9 -0
- package/dist/workflows/renderer.js +8 -3
- package/dist/workflows/runs.js +73 -88
- package/dist/workflows/scope-key.js +76 -0
- package/dist/workflows/validator.js +1 -1
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +3 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.7.4.md +1 -1
- package/docs/migration/release-notes/0.7.5.md +20 -0
- package/docs/migration/release-notes/0.8.0.md +43 -0
- package/package.json +4 -3
- 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
|
+
}
|