akm-cli 0.9.0-beta.4 → 0.9.0-beta.41
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 +646 -0
- package/dist/assets/prompts/consolidate-system.md +23 -0
- package/dist/assets/prompts/contradiction-judge.md +33 -0
- package/dist/assets/prompts/distill-knowledge-system.md +22 -0
- package/dist/assets/prompts/distill-lesson-system.md +36 -0
- package/dist/assets/prompts/extract-session.md +6 -2
- package/dist/assets/prompts/graph-extract-system.md +1 -0
- package/dist/assets/prompts/memory-infer-system.md +1 -0
- package/dist/assets/prompts/memory-infer-user.md +5 -0
- package/dist/assets/prompts/metadata-enhance-system.md +1 -0
- package/dist/assets/prompts/procedural-system.md +44 -0
- package/dist/assets/prompts/recombine-system.md +40 -0
- package/dist/assets/prompts/staleness-detect-system.md +6 -0
- package/dist/assets/prompts/validate-summary-judge.md +1 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +22 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +22 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +24 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +22 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +25 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +21 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +21 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +23 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +22 -0
- package/dist/assets/templates/html/health.html +281 -111
- package/dist/cli.js +14 -3
- package/dist/commands/agent/contribute-cli.js +16 -3
- package/dist/commands/feedback-cli.js +15 -6
- package/dist/commands/graph/graph.js +75 -71
- package/dist/commands/health/checks.js +48 -0
- package/dist/commands/health/html-report.js +422 -80
- package/dist/commands/health.js +381 -9
- package/dist/commands/improve/calibration.js +161 -0
- package/dist/commands/improve/consolidate.js +631 -111
- package/dist/commands/improve/dedup.js +482 -0
- package/dist/commands/improve/distill.js +163 -69
- package/dist/commands/improve/encoding-salience.js +205 -0
- package/dist/commands/improve/extract-cli.js +115 -1
- package/dist/commands/improve/extract-prompt.js +39 -2
- package/dist/commands/improve/extract-watch.js +140 -0
- package/dist/commands/improve/extract.js +403 -40
- package/dist/commands/improve/feedback-valence.js +54 -0
- package/dist/commands/improve/homeostatic.js +467 -0
- package/dist/commands/improve/improve-auto-accept.js +113 -6
- package/dist/commands/improve/improve-profiles.js +12 -0
- package/dist/commands/improve/improve.js +2042 -612
- package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
- package/dist/commands/improve/outcome-loop.js +256 -0
- package/dist/commands/improve/proactive-maintenance.js +115 -0
- package/dist/commands/improve/procedural.js +418 -0
- package/dist/commands/improve/recombine.js +602 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +46 -4
- package/dist/commands/improve/related-sessions.js +120 -0
- package/dist/commands/improve/salience.js +438 -0
- package/dist/commands/improve/triage.js +93 -0
- package/dist/commands/lint/agent-linter.js +19 -24
- package/dist/commands/lint/base-linter.js +173 -60
- package/dist/commands/lint/command-linter.js +19 -24
- package/dist/commands/lint/env-key-rules.js +34 -1
- package/dist/commands/lint/fact-linter.js +39 -0
- package/dist/commands/lint/index.js +31 -13
- package/dist/commands/lint/memory-linter.js +1 -1
- package/dist/commands/lint/registry.js +7 -2
- package/dist/commands/lint/task-linter.js +3 -3
- package/dist/commands/lint/workflow-linter.js +26 -1
- package/dist/commands/proposal/drain-policies.js +5 -0
- package/dist/commands/proposal/drain.js +17 -1
- package/dist/commands/proposal/proposal.js +5 -0
- package/dist/commands/proposal/propose.js +5 -0
- package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
- package/dist/commands/proposal/validators/proposals.js +187 -57
- package/dist/commands/read/curate.js +344 -80
- package/dist/commands/read/search-cli.js +7 -0
- package/dist/commands/read/search.js +1 -0
- package/dist/commands/read/show.js +67 -2
- package/dist/commands/sources/init.js +36 -9
- package/dist/commands/sources/installed-stashes.js +5 -1
- package/dist/commands/sources/schema-repair.js +13 -1
- package/dist/commands/sources/stash-cli.js +19 -3
- package/dist/commands/sources/stash-skeleton.js +23 -8
- package/dist/core/asset/asset-registry.js +2 -0
- package/dist/core/asset/asset-spec.js +14 -0
- package/dist/core/asset/frontmatter.js +166 -167
- package/dist/core/asset/markdown.js +8 -0
- package/dist/core/authoring-rules.js +83 -0
- package/dist/core/config/config-schema.js +274 -2
- package/dist/core/config/config.js +2 -2
- package/dist/core/logs-db.js +4 -3
- package/dist/core/paths.js +3 -0
- package/dist/core/standards/resolve-standards-context.js +87 -0
- package/dist/core/standards/resolve-stash-standards.js +99 -0
- package/dist/core/standards/resolve-type-conventions.js +66 -0
- package/dist/core/state-db.js +691 -30
- package/dist/indexer/db/db.js +364 -38
- package/dist/indexer/db/graph-db.js +129 -86
- package/dist/indexer/ensure-index.js +152 -17
- package/dist/indexer/graph/graph-boost.js +51 -41
- package/dist/indexer/graph/graph-extraction.js +203 -3
- package/dist/indexer/index-writer-lock.js +99 -0
- package/dist/indexer/indexer.js +114 -111
- package/dist/indexer/passes/memory-inference.js +10 -3
- package/dist/indexer/passes/staleness-detect.js +2 -5
- package/dist/indexer/search/db-search.js +15 -4
- package/dist/indexer/search/ranking-contributors.js +22 -0
- package/dist/indexer/search/ranking.js +4 -0
- package/dist/indexer/walk/matchers.js +9 -0
- package/dist/integrations/agent/prompts.js +33 -0
- package/dist/integrations/harnesses/claude/session-log.js +11 -1
- package/dist/integrations/harnesses/opencode/session-log.js +173 -3
- package/dist/integrations/session-logs/index.js +16 -0
- package/dist/llm/client.js +23 -4
- package/dist/llm/embedder.js +27 -3
- package/dist/llm/embedders/local.js +66 -2
- package/dist/llm/feature-gate.js +8 -4
- package/dist/llm/graph-extract.js +2 -1
- package/dist/llm/memory-infer.js +4 -8
- package/dist/llm/metadata-enhance.js +9 -1
- package/dist/output/renderers.js +73 -1
- package/dist/output/shapes/curate.js +14 -2
- package/dist/output/text/helpers.js +16 -1
- package/dist/runtime.js +25 -1
- package/dist/scripts/migrate-storage.js +1378 -599
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +479 -270
- package/dist/setup/setup.js +3 -3
- package/dist/sources/providers/git.js +71 -61
- package/dist/sources/providers/tar-utils.js +16 -8
- package/dist/storage/sqlite-pragmas.js +146 -0
- package/dist/wiki/wiki.js +37 -0
- package/dist/workflows/db.js +3 -4
- package/dist/workflows/validate-summary.js +2 -7
- package/docs/data-and-telemetry.md +1 -0
- package/package.json +8 -6
|
@@ -6,51 +6,34 @@ import fs from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import readline from "node:readline";
|
|
8
8
|
import { parse as yamlParse } from "yaml";
|
|
9
|
+
import consolidateSystemPrompt from "../../assets/prompts/consolidate-system.md" with { type: "text" };
|
|
9
10
|
import { parseAssetRef } from "../../core/asset/asset-ref.js";
|
|
10
11
|
import { assembleAssetFromString, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
|
|
11
12
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
12
13
|
import { resolveStashDir, timestampForFilename } from "../../core/common.js";
|
|
13
14
|
import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
|
|
14
15
|
import { ConfigError } from "../../core/errors.js";
|
|
15
|
-
|
|
16
|
+
// Note: appendEvent import removed (WS-3a: archive TTL machinery retired)
|
|
16
17
|
import { parseEmbeddedJsonResponse } from "../../core/parse.js";
|
|
18
|
+
import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
|
|
17
19
|
import { detectTruncatedDescription } from "../../core/text-truncation.js";
|
|
18
20
|
import { hasHotCaptureMode, hasSupersededStatus, MERGE_ABSOLUTE_FLOOR_CHARS, MERGE_SHRINK_RATIO_MIN, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
|
|
19
21
|
import { createProposal, isProposalSkipped, listProposals } from "../proposal/validators/proposals.js";
|
|
22
|
+
import { cacheHash, runDeterministicDedup, stripFrontmatterBody } from "./dedup.js";
|
|
23
|
+
import { checkGenerationGuard, checkLexicalDiversity, computeMergedGeneration, readAssetGeneration, runHomeostaticDemotion, shouldSkipHotProbationInLlm, } from "./homeostatic.js";
|
|
20
24
|
import { writeContradictEdge } from "./memory/memory-belief.js";
|
|
21
25
|
// Re-export the moved helpers so existing test imports continue to resolve.
|
|
22
26
|
export { hasSupersededStatus, validateProposalFrontmatter };
|
|
27
|
+
import { getBodyEmbeddings, getConsolidationJudgedMap, openStateDatabase, upsertBodyEmbeddings, upsertConsolidationJudged, } from "../../core/state-db.js";
|
|
23
28
|
import { warn } from "../../core/warn.js";
|
|
24
29
|
import { commitWriteTargetBoundary, deleteAssetFromSource, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
|
|
25
30
|
import { closeDatabase, findEntryIdByRef, getAllEntries, getEntryById, getNeighborsByEntryId, openExistingDatabase, } from "../../indexer/db/db.js";
|
|
26
31
|
import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
|
|
27
32
|
import { chatCompletion } from "../../llm/client.js";
|
|
28
|
-
import { cosineSimilarity, embedBatch } from "../../llm/embedder.js";
|
|
33
|
+
import { cosineSimilarity, embedBatch, resolveEmbeddingModelId } from "../../llm/embedder.js";
|
|
29
34
|
import { isLlmFeatureEnabled, tryLlmFeature } from "../../llm/feature-gate.js";
|
|
30
35
|
// ── Prompts ─────────────────────────────────────────────────────────────────
|
|
31
|
-
const CONSOLIDATE_SYSTEM_PROMPT =
|
|
32
|
-
|
|
33
|
-
Rules:
|
|
34
|
-
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.
|
|
35
|
-
2. DELETE: Memory is clearly outdated, contradicted, or redundant → propose deletion. NEVER propose delete for memories annotated \`(captureMode: hot)\` — they are user-explicit and only the user can retire them. The downstream guard will refuse these regardless, so proposing them just wastes tokens.
|
|
36
|
-
3. PROMOTE: Memory expresses a stable, reusable fact suitable as a \`knowledge:\` asset → propose promotion. Do NOT delete the source memory. NEVER propose promote / merge / contradict for memories annotated \`(already queued)\` — they have a pending proposal whose body matches; a duplicate will be deterministically dropped, so proposing them just wastes tokens.
|
|
37
|
-
4. CONTRADICT: Two memories make mutually exclusive factual claims about the same subject (e.g. "always use VPN" vs "VPN is optional") → mark the older or less authoritative one as contradicted. This writes a contradictedBy edge so the belief-resolution SCC algorithm can resolve the conflict. Do NOT delete contradicted memories — let the belief resolver decide.
|
|
38
|
-
5. KEEP: Memory is unique and current → omit from output.
|
|
39
|
-
|
|
40
|
-
Return ONLY JSON (no prose, no code fences):
|
|
41
|
-
{
|
|
42
|
-
"operations": [
|
|
43
|
-
{ "op": "merge", "primary": "memory:<name>", "secondaries": ["memory:<name>", ...], "mergeStrategy": "synthesize", "confidence": 0.95 },
|
|
44
|
-
{ "op": "delete", "ref": "memory:<name>", "reason": "<brief reason>", "confidence": 0.90 },
|
|
45
|
-
{ "op": "promote", "ref": "memory:<name>", "knowledgeRef": "knowledge:<suggested-slug>", "reason": "<brief reason>", "description": "<one sentence describing the new knowledge asset>", "confidence": 0.92 },
|
|
46
|
-
{ "op": "contradict", "ref": "memory:<name>", "contradictedByRef": "memory:<name>", "reason": "<brief reason>", "confidence": 0.88 }
|
|
47
|
-
],
|
|
48
|
-
"warnings": ["<optional concerns>"]
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
For every operation, emit a \`confidence\` field in [0, 1] expressing your certainty that the operation is correct and safe. Use 0.95+ only when evidence is unambiguous. Omit the field rather than guessing if you are uncertain.
|
|
52
|
-
|
|
53
|
-
When the merged content includes an \`updated\` frontmatter field, the value MUST be a real ISO date string (e.g. \`updated: 2026-05-20\`). NEVER emit \`updated: today\`, \`updated: {today}\`, \`updated: {today: null}\`, \`updated: now\`, or any other literal placeholder/template-variable. If you do not have a real source-of-truth date, OMIT the \`updated\` field entirely — the post-processor will not invent one for you.`;
|
|
36
|
+
const CONSOLIDATE_SYSTEM_PROMPT = consolidateSystemPrompt;
|
|
54
37
|
/**
|
|
55
38
|
* JSON Schema for structured consolidate plans (PR 1 of the asset-writers
|
|
56
39
|
* decision — see knowledge:projects/akm/asset-writers-investigation/00-synthesis).
|
|
@@ -247,29 +230,17 @@ export function computeSafeChunkSize(contextLength, bodyTruncation, maxChunkSize
|
|
|
247
230
|
const raw = Math.floor(usableTokens / tokensPerMemory);
|
|
248
231
|
return Math.max(1, Math.min(maxChunkSize ?? 50, raw));
|
|
249
232
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
* Re-order memories so that similar ones are placed adjacent to each other
|
|
253
|
-
* before the memories are sliced into chunks. This ensures high-similarity
|
|
254
|
-
* memories land in the same LLM context window, allowing the consolidate
|
|
255
|
-
* model to detect and merge duplicates that would otherwise be split across
|
|
256
|
-
* chunks and survive indefinitely.
|
|
257
|
-
*
|
|
258
|
-
* Algorithm: greedy nearest-neighbour chain starting from the first memory.
|
|
259
|
-
* Each step selects the unused memory with the highest cosine similarity to
|
|
260
|
-
* the last-placed memory. O(n²) — acceptable for the expected N < 200.
|
|
261
|
-
*
|
|
262
|
-
* mem0 arXiv:2504.19413 — every candidate compared against whole store.
|
|
263
|
-
* A-MEM arXiv:2502.12110 — atomic notes linked by similarity.
|
|
264
|
-
*
|
|
265
|
-
* Returns the original order unchanged when:
|
|
266
|
-
* - The embedding config is not present.
|
|
267
|
-
* - Embedding requests fail (fail-open).
|
|
268
|
-
* - There are fewer than 3 memories (no benefit to reordering).
|
|
269
|
-
*/
|
|
270
|
-
async function clusterMemoriesBySimilarity(memories, config) {
|
|
233
|
+
async function clusterMemoriesBySimilarity(memories, config, stateDb) {
|
|
234
|
+
const noTelemetry = { embedMs: 0, cacheHits: 0, cacheMisses: 0 };
|
|
271
235
|
if (memories.length < 3 || !config.embedding)
|
|
272
|
-
return memories;
|
|
236
|
+
return { ordered: memories, embedTelemetry: noTelemetry };
|
|
237
|
+
// WS-3a: cluster uses description+tags as the embedding input (NOT the raw
|
|
238
|
+
// body) — this is intentionally different from the dedup/body cache because
|
|
239
|
+
// the clustering goal is semantic grouping, not dedup twin detection.
|
|
240
|
+
// The body_embeddings cache is keyed by cacheHash(body); clustering inputs
|
|
241
|
+
// are keyed by cacheHash(description+tags text). Re-use the same table with
|
|
242
|
+
// a distinct hash so the two lookup sets never collide.
|
|
243
|
+
const modelId = resolveEmbeddingModelId(config.embedding);
|
|
273
244
|
const texts = memories.map((m) => {
|
|
274
245
|
const parts = [];
|
|
275
246
|
if (m.description)
|
|
@@ -278,16 +249,91 @@ async function clusterMemoriesBySimilarity(memories, config) {
|
|
|
278
249
|
parts.push(m.tags.join(" "));
|
|
279
250
|
return parts.join(". ") || m.name;
|
|
280
251
|
});
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
252
|
+
// Compute content hashes for the cluster texts (not bodies — different input).
|
|
253
|
+
const contentHashes = texts.map((t) => createHash("sha256").update(t, "utf8").digest("hex"));
|
|
254
|
+
// WS-5: track embed cache hits/misses for perf telemetry.
|
|
255
|
+
let embedMs = 0;
|
|
256
|
+
let cacheHits = 0;
|
|
257
|
+
let cacheMisses = 0;
|
|
258
|
+
let cachedVecs = new Map();
|
|
259
|
+
if (stateDb) {
|
|
260
|
+
try {
|
|
261
|
+
cachedVecs = getBodyEmbeddings(stateDb, contentHashes, modelId);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Fail open.
|
|
265
|
+
cachedVecs = new Map();
|
|
266
|
+
}
|
|
284
267
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
268
|
+
const missIndices = [];
|
|
269
|
+
const missTexts = [];
|
|
270
|
+
for (let i = 0; i < texts.length; i++) {
|
|
271
|
+
if (!cachedVecs.has(contentHashes[i])) {
|
|
272
|
+
missIndices.push(i);
|
|
273
|
+
missTexts.push(texts[i]);
|
|
274
|
+
cacheMisses++;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
cacheHits++;
|
|
278
|
+
}
|
|
288
279
|
}
|
|
280
|
+
let missVecs = [];
|
|
281
|
+
if (missTexts.length > 0) {
|
|
282
|
+
const embedStart = Date.now();
|
|
283
|
+
try {
|
|
284
|
+
missVecs = await embedBatch(missTexts, config.embedding);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// Fail open: embedding failures degrade gracefully to original order.
|
|
288
|
+
return { ordered: memories, embedTelemetry: { embedMs, cacheHits, cacheMisses } };
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
embedMs += Date.now() - embedStart;
|
|
292
|
+
}
|
|
293
|
+
// Upsert newly computed vectors into the cache.
|
|
294
|
+
if (stateDb && missVecs.length === missTexts.length) {
|
|
295
|
+
try {
|
|
296
|
+
const toUpsert = missIndices.map((idx, pos) => ({
|
|
297
|
+
contentHash: contentHashes[idx],
|
|
298
|
+
embedding: missVecs[pos],
|
|
299
|
+
modelId,
|
|
300
|
+
}));
|
|
301
|
+
upsertBodyEmbeddings(stateDb, toUpsert);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Fail open: cache write errors are non-fatal.
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Assemble the full embedding array in memories order.
|
|
309
|
+
let embeddings = null;
|
|
310
|
+
{
|
|
311
|
+
const assembled = [];
|
|
312
|
+
let ok = true;
|
|
313
|
+
for (let i = 0; i < memories.length; i++) {
|
|
314
|
+
const hash = contentHashes[i];
|
|
315
|
+
const cached = cachedVecs.get(hash);
|
|
316
|
+
if (cached) {
|
|
317
|
+
assembled.push(cached);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
const missPos = missIndices.indexOf(i);
|
|
321
|
+
const vec = missPos >= 0 ? missVecs[missPos] : undefined;
|
|
322
|
+
if (vec) {
|
|
323
|
+
assembled.push(vec);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
ok = false;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (ok && assembled.length === memories.length) {
|
|
331
|
+
embeddings = assembled;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const embedTelemetry = { embedMs, cacheHits, cacheMisses };
|
|
289
335
|
if (!embeddings || embeddings.length !== memories.length)
|
|
290
|
-
return memories;
|
|
336
|
+
return { ordered: memories, embedTelemetry };
|
|
291
337
|
// Greedy nearest-neighbour chain.
|
|
292
338
|
const used = new Array(memories.length).fill(false);
|
|
293
339
|
const ordered = [];
|
|
@@ -313,7 +359,7 @@ async function clusterMemoriesBySimilarity(memories, config) {
|
|
|
313
359
|
used[bestIdx] = true;
|
|
314
360
|
current = bestIdx;
|
|
315
361
|
}
|
|
316
|
-
return ordered;
|
|
362
|
+
return { ordered, embedTelemetry };
|
|
317
363
|
}
|
|
318
364
|
// ── Chunk helpers ────────────────────────────────────────────────────────────
|
|
319
365
|
/**
|
|
@@ -332,7 +378,7 @@ async function clusterMemoriesBySimilarity(memories, config) {
|
|
|
332
378
|
* is precomputed once per run by `loadPendingConsolidateProposalHashes`
|
|
333
379
|
* so the cost stays O(memories) inside the chunk loop.
|
|
334
380
|
*/
|
|
335
|
-
export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks, bodyTruncation, pendingProposalBodyHashes = new Set()) {
|
|
381
|
+
export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks, bodyTruncation, pendingProposalBodyHashes = new Set(), standardsContext = "") {
|
|
336
382
|
const start = memories[0] ? `memory:${memories[0].name}` : "";
|
|
337
383
|
const end = memories[memories.length - 1] ? `memory:${memories[memories.length - 1].name}` : "";
|
|
338
384
|
const annotationsByIndex = [];
|
|
@@ -347,7 +393,9 @@ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks,
|
|
|
347
393
|
}
|
|
348
394
|
const parsed = parseFrontmatter(body);
|
|
349
395
|
const isHot = parsed.data.captureMode === "hot";
|
|
350
|
-
|
|
396
|
+
// Use cacheHash (case-preserving stripped body) to match the domain used
|
|
397
|
+
// by loadPendingConsolidateProposalHashes and the body-embedding cache.
|
|
398
|
+
const bodyHash = cacheHash(body);
|
|
351
399
|
const isAlreadyQueued = pendingProposalBodyHashes.has(bodyHash);
|
|
352
400
|
annotationsByIndex.push({ isHot, isAlreadyQueued, body });
|
|
353
401
|
if (isHot)
|
|
@@ -358,6 +406,11 @@ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks,
|
|
|
358
406
|
`Chunk ${chunkIndex + 1} of ${totalChunks}, memories ${start}–${end}:`,
|
|
359
407
|
"",
|
|
360
408
|
];
|
|
409
|
+
if (standardsContext.trim()) {
|
|
410
|
+
lines.push("Standards to follow (the rulebook for this target):");
|
|
411
|
+
lines.push(standardsContext.trim());
|
|
412
|
+
lines.push("");
|
|
413
|
+
}
|
|
361
414
|
// Top-of-prompt protection block for hot refs. Neutral phrasing — avoid
|
|
362
415
|
// op-words like "promote", "merge", "contradict" so the model doesn't
|
|
363
416
|
// accidentally treat the warning as a hint to use that op elsewhere
|
|
@@ -390,10 +443,10 @@ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks,
|
|
|
390
443
|
/**
|
|
391
444
|
* Precompute body-hashes of all currently-pending consolidate proposals so
|
|
392
445
|
* the per-chunk prompt can annotate memories whose body would just produce
|
|
393
|
-
* a deterministic `dedup_pending_proposal` skip.
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* nothing" so the LLM still proposes
|
|
446
|
+
* a deterministic `dedup_pending_proposal` skip. Uses `cacheHash` (case-
|
|
447
|
+
* preserving stripped body) — the same domain used by the body-embedding
|
|
448
|
+
* cache and `computeMemoryContentHash`. Empty set on any read/parse error
|
|
449
|
+
* — fail-safe to "annotate nothing" so the LLM still proposes.
|
|
397
450
|
*/
|
|
398
451
|
function loadPendingConsolidateProposalHashes(stashDir) {
|
|
399
452
|
const hashes = new Set();
|
|
@@ -401,8 +454,7 @@ function loadPendingConsolidateProposalHashes(stashDir) {
|
|
|
401
454
|
const pending = listProposals(stashDir, { status: "pending" }).filter((p) => p.source === "consolidate");
|
|
402
455
|
for (const p of pending) {
|
|
403
456
|
try {
|
|
404
|
-
|
|
405
|
-
hashes.add(createHash("sha256").update(body, "utf8").digest("hex"));
|
|
457
|
+
hashes.add(cacheHash(p.payload.content));
|
|
406
458
|
}
|
|
407
459
|
catch {
|
|
408
460
|
// skip malformed payloads — they can't dedup anyway
|
|
@@ -672,6 +724,28 @@ function backupFile(filePath, backupDir, name) {
|
|
|
672
724
|
// best-effort
|
|
673
725
|
}
|
|
674
726
|
}
|
|
727
|
+
// ── WS-3b: Generation frontmatter injection ───────────────────────────────────
|
|
728
|
+
/**
|
|
729
|
+
* Inject `generation` (and optionally `source_refs`) into merged content.
|
|
730
|
+
* generation = max(sourceGenerations) + 1.
|
|
731
|
+
* Fails open — returns original content if frontmatter can't be parsed.
|
|
732
|
+
*/
|
|
733
|
+
function injectGenerationFrontmatter(mergedContent, sourceGenerations, allParticipants) {
|
|
734
|
+
try {
|
|
735
|
+
const parsed = parseFrontmatter(mergedContent);
|
|
736
|
+
const updatedFm = {
|
|
737
|
+
...parsed.data,
|
|
738
|
+
generation: computeMergedGeneration(sourceGenerations),
|
|
739
|
+
};
|
|
740
|
+
if (!updatedFm.source_refs) {
|
|
741
|
+
updatedFm.source_refs = allParticipants;
|
|
742
|
+
}
|
|
743
|
+
return assembleAssetFromString(serializeFrontmatter(updatedFm), parsed.content);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return mergedContent; // fail open
|
|
747
|
+
}
|
|
748
|
+
}
|
|
675
749
|
// ── Archive helper (P1-B: soft-invalidation) ─────────────────────────────────
|
|
676
750
|
/**
|
|
677
751
|
* Move a memory asset to `.akm/archive/` with `status: superseded` frontmatter
|
|
@@ -752,6 +826,25 @@ function resolveConsolidateLlmConfig(config) {
|
|
|
752
826
|
// fall back to the default LLM profile rather than disabling the pass.
|
|
753
827
|
return getDefaultLlmConfig(config);
|
|
754
828
|
}
|
|
829
|
+
// ── Judged-state cache (#581) ────────────────────────────────────────────────
|
|
830
|
+
/**
|
|
831
|
+
* Stable content hash for a memory file used by the judged-state cache (#581)
|
|
832
|
+
* and the body-embedding cache (WS-3a). Uses `cacheHash` from dedup.ts:
|
|
833
|
+
* sha256 of the case-preserving stripped body. Two memories that differ only
|
|
834
|
+
* in frontmatter (`updated:`, `inferenceProcessed:`) hash identically, so a
|
|
835
|
+
* cosmetic frontmatter touch never forces a needless re-judge — only a body
|
|
836
|
+
* change does. Returns `undefined` on any read/parse error so callers fail
|
|
837
|
+
* open (treat the memory as un-cached → it stays in the LLM pool).
|
|
838
|
+
*/
|
|
839
|
+
function computeMemoryContentHash(filePath) {
|
|
840
|
+
try {
|
|
841
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
842
|
+
return cacheHash(raw);
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
return undefined;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
755
848
|
// ── Main entry point ─────────────────────────────────────────────────────────
|
|
756
849
|
export async function akmConsolidate(opts = {}) {
|
|
757
850
|
const startMs = Date.now();
|
|
@@ -780,6 +873,27 @@ export async function akmConsolidate(opts = {}) {
|
|
|
780
873
|
}
|
|
781
874
|
const warnings = [];
|
|
782
875
|
checkForIncompleteJournal(stashDir, opts.recoveryMode ?? "abort", warnings);
|
|
876
|
+
// WS-3a: open one state.db handle shared by the body-embedding cache (dedup
|
|
877
|
+
// + cluster) and the judged-state cache. All callers in the function body
|
|
878
|
+
// receive this handle; it is closed in the `finally` block below.
|
|
879
|
+
// Fail-open: any open error leaves it `undefined` and all cache paths skip.
|
|
880
|
+
let sharedStateDb;
|
|
881
|
+
try {
|
|
882
|
+
sharedStateDb = openStateDatabase();
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
// State DB unavailable → skip the embedding cache for this run.
|
|
886
|
+
}
|
|
887
|
+
try {
|
|
888
|
+
return await akmConsolidateInner(opts, config, stashDir, startMs, sourceRun, warnings, sharedStateDb);
|
|
889
|
+
}
|
|
890
|
+
finally {
|
|
891
|
+
sharedStateDb?.close();
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// Inner implementation — all early-return paths are here; sharedStateDb is
|
|
895
|
+
// closed by the outer finally in `akmConsolidate`.
|
|
896
|
+
async function akmConsolidateInner(opts, config, stashDir, startMs, sourceRun, warnings, sharedStateDb) {
|
|
783
897
|
let memories = loadMemoriesForSource(opts.target, stashDir, warnings);
|
|
784
898
|
// Pre-flight: filter out stale DB entries whose files no longer exist on
|
|
785
899
|
// disk. Without this, memories deleted by a prior run (but not yet
|
|
@@ -791,6 +905,80 @@ export async function akmConsolidate(opts = {}) {
|
|
|
791
905
|
warnings.push(`Pre-flight: filtered ${staleCount} stale DB entr${staleCount === 1 ? "y" : "ies"} (file absent on disk) from memory pool before chunking.`);
|
|
792
906
|
}
|
|
793
907
|
memories = memories.filter((m) => fs.existsSync(m.filePath));
|
|
908
|
+
// ── WS-3b Step 0a: Homeostatic demotion ────────────────────────────────────
|
|
909
|
+
// DEFAULT OFF. Before any LLM merge, demote retrievalSalience in state.db
|
|
910
|
+
// for stale/low-value assets so the merge pool is bounded and high-SNR.
|
|
911
|
+
// Demotion is state.db-only (file content untouched); re-promotable on
|
|
912
|
+
// re-retrieval. Only fires when `homeostaticDemotion.enabled === true`.
|
|
913
|
+
const homeostaticConfig = config.profiles?.improve?.default?.processes?.consolidate?.homeostaticDemotion ?? {};
|
|
914
|
+
if (homeostaticConfig.enabled && sharedStateDb) {
|
|
915
|
+
const demotionResult = runHomeostaticDemotion(sharedStateDb, homeostaticConfig);
|
|
916
|
+
if (demotionResult.demoted > 0) {
|
|
917
|
+
warnings.push(`Homeostatic demotion: demoted retrievalSalience for ${demotionResult.demoted} stale asset(s) before merge pool assembly.`);
|
|
918
|
+
}
|
|
919
|
+
warnings.push(...demotionResult.warnings);
|
|
920
|
+
}
|
|
921
|
+
// ── WS-3b Step 0c: Filter hot-probation assets from LLM merge pool ─────────
|
|
922
|
+
// Hot-probation assets (system-generated, not yet graduated from intake pass)
|
|
923
|
+
// are processed by the dedup pre-pass but excluded from the LLM clustering.
|
|
924
|
+
// This prevents noisy extractions from polluting LLM context. The dedup pass
|
|
925
|
+
// below still runs against them so they're cleaned up deterministically.
|
|
926
|
+
// DEFAULT OFF — only active when `processes.extract.hotProbation.enabled === true`
|
|
927
|
+
// (the flag that causes extract to tag new extractions as hot-probation).
|
|
928
|
+
// Without that flag no assets will ever carry the hot-probation marker, so
|
|
929
|
+
// running the filter loop would be pure unnecessary I/O over the full corpus.
|
|
930
|
+
const hotProbationEnabled = config.profiles?.improve?.default?.processes?.extract?.hotProbation
|
|
931
|
+
?.enabled === true;
|
|
932
|
+
let hotProbationCount = 0;
|
|
933
|
+
if (hotProbationEnabled) {
|
|
934
|
+
const hotProbationMemories = [];
|
|
935
|
+
const nonProbationMemories = [];
|
|
936
|
+
for (const m of memories) {
|
|
937
|
+
try {
|
|
938
|
+
const raw = fs.readFileSync(m.filePath, "utf8");
|
|
939
|
+
const parsed = parseFrontmatter(raw);
|
|
940
|
+
if (shouldSkipHotProbationInLlm(parsed.data)) {
|
|
941
|
+
hotProbationMemories.push(m);
|
|
942
|
+
hotProbationCount++;
|
|
943
|
+
}
|
|
944
|
+
else {
|
|
945
|
+
nonProbationMemories.push(m);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
nonProbationMemories.push(m); // fail open
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
if (hotProbationCount > 0) {
|
|
953
|
+
warnings.push(`Hot-probation: ${hotProbationCount} hot-probation asset(s) routed to dedup-only pass (excluded from LLM merge pool).`);
|
|
954
|
+
memories = nonProbationMemories;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
// ── Deterministic dedup pre-pass (#617) ─────────────────────────────────────
|
|
958
|
+
// Cheap, no-LLM fast path that collapses the obvious near-duplicates
|
|
959
|
+
// (`.derived` ↔ origin pairs + content twins) BEFORE the embedding-clustered
|
|
960
|
+
// LLM consolidation. DEFAULT OFF — when `dedup.enabled !== true` this is a
|
|
961
|
+
// no-op and the pass behaves byte-identically to today. Collapsed variants
|
|
962
|
+
// are pruned from the LLM pool so the model only ever sees genuinely
|
|
963
|
+
// distinct-but-related memories. Each dropped variant is archived (soft
|
|
964
|
+
// invalidation) before deletion, matching the LLM merge path.
|
|
965
|
+
// Dry-run never mutates the filesystem, so the dedup pre-pass is skipped
|
|
966
|
+
// entirely under `--dry-run` (the LLM plan preview below is unaffected).
|
|
967
|
+
let dedupCollapsed = 0;
|
|
968
|
+
if (opts.dedup?.enabled && !opts.dryRun) {
|
|
969
|
+
const dedupTimestamp = timestampForFilename();
|
|
970
|
+
const dedupResult = await runDeterministicDedup(stashDir, opts.dedup, config, (variantFilePath, variantName) => {
|
|
971
|
+
archiveMemory(variantFilePath, stashDir, `memory:${variantName}`, "collapsed by deterministic dedup pre-pass", -1, undefined, warnings);
|
|
972
|
+
backupFile(variantFilePath, getBackupDir(stashDir, dedupTimestamp), variantName);
|
|
973
|
+
}, opts.signal, sharedStateDb);
|
|
974
|
+
dedupCollapsed = dedupResult.collapsed;
|
|
975
|
+
warnings.push(...dedupResult.warnings);
|
|
976
|
+
if (dedupResult.consumedRefs.length > 0) {
|
|
977
|
+
const consumed = new Set(dedupResult.consumedRefs);
|
|
978
|
+
memories = memories.filter((m) => !consumed.has(`memory:${m.name}`));
|
|
979
|
+
warnings.push(`Deterministic dedup: collapsed ${dedupResult.collapsed} near-duplicate memor${dedupResult.collapsed === 1 ? "y" : "ies"} (no LLM) before chunking.`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
794
982
|
if (memories.length === 0) {
|
|
795
983
|
return {
|
|
796
984
|
schemaVersion: 1,
|
|
@@ -801,7 +989,10 @@ export async function akmConsolidate(opts = {}) {
|
|
|
801
989
|
target: opts.target ?? stashDir,
|
|
802
990
|
processed: 0,
|
|
803
991
|
merged: 0,
|
|
804
|
-
|
|
992
|
+
// #617: the deterministic dedup pre-pass may have emptied the pool by
|
|
993
|
+
// collapsing every remaining memory into a canonical. Surface those
|
|
994
|
+
// collapses in `deleted` so the run reports the work it actually did.
|
|
995
|
+
deleted: dedupCollapsed,
|
|
805
996
|
promoted: [],
|
|
806
997
|
contradicted: 0,
|
|
807
998
|
warnings,
|
|
@@ -809,7 +1000,7 @@ export async function akmConsolidate(opts = {}) {
|
|
|
809
1000
|
};
|
|
810
1001
|
}
|
|
811
1002
|
if (opts.incrementalSince) {
|
|
812
|
-
memories = narrowToIncrementalCandidates(memories, opts.incrementalSince, warnings);
|
|
1003
|
+
memories = narrowToIncrementalCandidates(memories, opts.incrementalSince, warnings, opts.neighborsPerChanged);
|
|
813
1004
|
if (memories.length === 0) {
|
|
814
1005
|
return {
|
|
815
1006
|
schemaVersion: 1,
|
|
@@ -828,6 +1019,114 @@ export async function akmConsolidate(opts = {}) {
|
|
|
828
1019
|
};
|
|
829
1020
|
}
|
|
830
1021
|
}
|
|
1022
|
+
// WS-5 perf telemetry accumulators. These are collected throughout the run and
|
|
1023
|
+
// merged into `perfTelemetry` on the final ConsolidateResult.
|
|
1024
|
+
// `dedupPoolSize` = memories entering judgedCache narrowing (after dedup+incremental+limit).
|
|
1025
|
+
// `judgedCacheSkipped` = memories skipped by the cache.
|
|
1026
|
+
// `llmPoolSize` = memories actually sent to the LLM.
|
|
1027
|
+
// `embedMs/cacheHits/cacheMisses` = accumulated from clusterMemoriesBySimilarity.
|
|
1028
|
+
const perfMs = { dedupPoolSize: memories.length, judgedCacheSkipped: 0 };
|
|
1029
|
+
// ── Judged-state cache narrowing (#581) ─────────────────────────────────────
|
|
1030
|
+
// DEFAULT OFF. When enabled, skip every memory whose current content hash
|
|
1031
|
+
// equals the hash recorded the last time the consolidate LLM judged it
|
|
1032
|
+
// (judged-unchanged → no re-judge). This converts coverage from O(window) to
|
|
1033
|
+
// O(changed/new) so one run can sweep the whole corpus while the LLM only
|
|
1034
|
+
// sees genuinely new/changed memories. `currentHashByName` is populated for
|
|
1035
|
+
// EVERY surviving memory (whether or not the cache is on) so the post-LLM
|
|
1036
|
+
// recording step can upsert judged state without re-reading the files; when
|
|
1037
|
+
// the cache is off it stays empty and the recording step is a no-op.
|
|
1038
|
+
const judgedCacheEnabled = opts.judgedCache?.enabled !== false;
|
|
1039
|
+
const currentHashByName = new Map();
|
|
1040
|
+
if (judgedCacheEnabled) {
|
|
1041
|
+
for (const m of memories) {
|
|
1042
|
+
const h = computeMemoryContentHash(m.filePath);
|
|
1043
|
+
if (h !== undefined)
|
|
1044
|
+
currentHashByName.set(m.name, h);
|
|
1045
|
+
}
|
|
1046
|
+
let cachedMap = new Map();
|
|
1047
|
+
{
|
|
1048
|
+
// Use the shared state.db handle if available; open a local one otherwise.
|
|
1049
|
+
const dbForJudged = sharedStateDb;
|
|
1050
|
+
if (dbForJudged) {
|
|
1051
|
+
try {
|
|
1052
|
+
cachedMap = getConsolidationJudgedMap(dbForJudged, memories.map((m) => `memory:${m.name}`));
|
|
1053
|
+
}
|
|
1054
|
+
catch {
|
|
1055
|
+
cachedMap = new Map();
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
let localDb;
|
|
1060
|
+
try {
|
|
1061
|
+
localDb = openStateDatabase();
|
|
1062
|
+
cachedMap = getConsolidationJudgedMap(localDb, memories.map((m) => `memory:${m.name}`));
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
// State DB unavailable → fail open: judge the full pool this run.
|
|
1066
|
+
cachedMap = new Map();
|
|
1067
|
+
}
|
|
1068
|
+
finally {
|
|
1069
|
+
localDb?.close();
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
const beforeCount = memories.length;
|
|
1074
|
+
memories = memories.filter((m) => {
|
|
1075
|
+
const cur = currentHashByName.get(m.name);
|
|
1076
|
+
// No readable hash → keep (fail open; let the LLM judge it).
|
|
1077
|
+
if (cur === undefined)
|
|
1078
|
+
return true;
|
|
1079
|
+
const cached = cachedMap.get(`memory:${m.name}`);
|
|
1080
|
+
// Skip only when previously judged AND content is byte-identical since.
|
|
1081
|
+
return !(cached !== undefined && cached.content_hash === cur);
|
|
1082
|
+
});
|
|
1083
|
+
const skipped = beforeCount - memories.length;
|
|
1084
|
+
perfMs.judgedCacheSkipped = skipped; // WS-5 perf telemetry
|
|
1085
|
+
if (skipped > 0) {
|
|
1086
|
+
warnings.push(`Judged-state cache: skipped ${skipped} memor${skipped === 1 ? "y" : "ies"} judged-unchanged (no LLM); ${memories.length} remain for judging.`);
|
|
1087
|
+
}
|
|
1088
|
+
if (memories.length === 0) {
|
|
1089
|
+
return {
|
|
1090
|
+
schemaVersion: 1,
|
|
1091
|
+
ok: true,
|
|
1092
|
+
shape: "consolidate-result",
|
|
1093
|
+
dryRun: opts.dryRun ?? false,
|
|
1094
|
+
previewOnly: false,
|
|
1095
|
+
target: opts.target ?? stashDir,
|
|
1096
|
+
processed: 0,
|
|
1097
|
+
merged: 0,
|
|
1098
|
+
deleted: dedupCollapsed,
|
|
1099
|
+
promoted: [],
|
|
1100
|
+
contradicted: 0,
|
|
1101
|
+
warnings,
|
|
1102
|
+
durationMs: Date.now() - startMs,
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
if (opts.limit === undefined && memories.length > 150) {
|
|
1107
|
+
warnings.push(`Consolidation: pool has ${memories.length} memories and no limit is set. Consider adding a limit to your consolidate config to prevent timeouts on slow LLM endpoints.`);
|
|
1108
|
+
}
|
|
1109
|
+
if (opts.limit !== undefined && memories.length > opts.limit) {
|
|
1110
|
+
// Order oldest-modified-first before capping so the limit selects the
|
|
1111
|
+
// stalest memories rather than a fixed head of the (rowid-ordered) DB
|
|
1112
|
+
// query. Consolidation rewrites surviving files, bumping their mtime, so
|
|
1113
|
+
// processed memories drift to the back of the queue and the cap rotates
|
|
1114
|
+
// across the whole corpus over successive runs instead of revisiting the
|
|
1115
|
+
// same slice every time. Fail-open to 0 (front of queue) when a file can
|
|
1116
|
+
// no longer be stat'd.
|
|
1117
|
+
const mtimeOf = (m) => {
|
|
1118
|
+
try {
|
|
1119
|
+
return fs.statSync(m.filePath).mtimeMs;
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
return 0;
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
const mtimeCache = new Map(memories.map((m) => [m.filePath, mtimeOf(m)]));
|
|
1126
|
+
memories = [...memories].sort((a, b) => (mtimeCache.get(a.filePath) ?? 0) - (mtimeCache.get(b.filePath) ?? 0));
|
|
1127
|
+
warnings.push(`Consolidation: pool capped at ${opts.limit} of ${memories.length} memories (limit option, oldest-modified first).`);
|
|
1128
|
+
memories = memories.slice(0, opts.limit);
|
|
1129
|
+
}
|
|
831
1130
|
// Consolidation always uses the HTTP LLM client directly — never the agent
|
|
832
1131
|
// CLI. The agent CLI is for interactive agent sessions (reflect, propose);
|
|
833
1132
|
// structured JSON generation works better and faster via HTTP.
|
|
@@ -851,16 +1150,64 @@ export async function akmConsolidate(opts = {}) {
|
|
|
851
1150
|
const chunkSize = computeSafeChunkSize(modelContextLength, bodyTruncation, opts.maxChunkSize);
|
|
852
1151
|
// -- Phase A: plan generation -----------------------------------------------
|
|
853
1152
|
const sourceName = opts.target ?? stashDir;
|
|
1153
|
+
// WS-5: capture llmPoolSize = memories entering the LLM (after all filtering).
|
|
1154
|
+
const llmPoolSize = memories.length;
|
|
854
1155
|
// C-1 / #380: Pre-cluster memories by embedding similarity before chunking.
|
|
855
1156
|
// This ensures that semantically similar memories land in the same LLM
|
|
856
1157
|
// context window, allowing the model to detect and merge duplicates that
|
|
857
1158
|
// would otherwise be split across chunks and survive indefinitely.
|
|
858
1159
|
// mem0 arXiv:2504.19413, A-MEM arXiv:2502.12110.
|
|
859
1160
|
// Fails open: if embeddings are unavailable or fail, original order is used.
|
|
860
|
-
const clusteredMemories = await clusterMemoriesBySimilarity(memories, config);
|
|
1161
|
+
const { ordered: clusteredMemories, embedTelemetry } = await clusterMemoriesBySimilarity(memories, config, sharedStateDb);
|
|
1162
|
+
// WS-3b Anti-collapse step 8c: inject random (non-similar) clusters.
|
|
1163
|
+
// A small fraction (default 5%) of the pool is shuffled into random positions
|
|
1164
|
+
// so the pipeline isn't PURELY similarity-driven. This prevents rich-get-richer
|
|
1165
|
+
// entrenchment where only the most-retrieved assets ever get consolidated.
|
|
1166
|
+
// DEFAULT OFF — gated on antiCollapse.enabled.
|
|
1167
|
+
let finalClusteredMemories = clusteredMemories;
|
|
1168
|
+
{
|
|
1169
|
+
const antiCollapseForCluster = config.profiles?.improve?.default?.processes?.consolidate?.antiCollapse ?? {};
|
|
1170
|
+
if (antiCollapseForCluster.enabled && clusteredMemories.length > 2) {
|
|
1171
|
+
const fraction = antiCollapseForCluster.randomClusterFraction ?? 0.05;
|
|
1172
|
+
const randomCount = Math.max(1, Math.floor(clusteredMemories.length * fraction));
|
|
1173
|
+
// Pick `randomCount` positions to inject random (un-clustered) members.
|
|
1174
|
+
// Use a seeded-ish shuffle: sort by hash of the name so it's deterministic
|
|
1175
|
+
// per run but not strictly similarity-driven.
|
|
1176
|
+
const shuffled = [...clusteredMemories].sort((a, b) => {
|
|
1177
|
+
// Deterministic shuffle: compare sha256-ish (use name hash as proxy).
|
|
1178
|
+
const ha = a.name.split("").reduce((acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0, 0);
|
|
1179
|
+
const hb = b.name.split("").reduce((acc, c) => ((acc << 5) - acc + c.charCodeAt(0)) | 0, 0);
|
|
1180
|
+
return ha - hb;
|
|
1181
|
+
});
|
|
1182
|
+
const randomSlice = shuffled.slice(0, randomCount);
|
|
1183
|
+
const randomSet = new Set(randomSlice.map((m) => m.name));
|
|
1184
|
+
// Insert random members at intervals through the clustered sequence.
|
|
1185
|
+
const withRandom = [];
|
|
1186
|
+
const interval = Math.max(2, Math.floor(clusteredMemories.length / randomCount));
|
|
1187
|
+
let randomIdx = 0;
|
|
1188
|
+
for (let i = 0; i < clusteredMemories.length; i++) {
|
|
1189
|
+
const m = clusteredMemories[i];
|
|
1190
|
+
if (m && !randomSet.has(m.name))
|
|
1191
|
+
withRandom.push(m);
|
|
1192
|
+
if (i > 0 && i % interval === 0 && randomIdx < randomSlice.length) {
|
|
1193
|
+
const r = randomSlice[randomIdx++];
|
|
1194
|
+
if (r)
|
|
1195
|
+
withRandom.push(r);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
// Append any remaining random members not yet inserted.
|
|
1199
|
+
while (randomIdx < randomSlice.length) {
|
|
1200
|
+
const r = randomSlice[randomIdx++];
|
|
1201
|
+
if (r)
|
|
1202
|
+
withRandom.push(r);
|
|
1203
|
+
}
|
|
1204
|
+
finalClusteredMemories = withRandom;
|
|
1205
|
+
warnings.push(`Anti-collapse: injected ${randomCount} random (non-similarity-driven) cluster member(s) into consolidation pool (fraction=${fraction}).`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
861
1208
|
const chunks = [];
|
|
862
|
-
for (let i = 0; i <
|
|
863
|
-
chunks.push(
|
|
1209
|
+
for (let i = 0; i < finalClusteredMemories.length; i += chunkSize) {
|
|
1210
|
+
chunks.push(finalClusteredMemories.slice(i, i + chunkSize));
|
|
864
1211
|
}
|
|
865
1212
|
// 2026-05-27 prompt-context fix: precompute body-hashes of pending
|
|
866
1213
|
// consolidate proposals once, so the per-chunk prompt can annotate
|
|
@@ -869,8 +1216,46 @@ export async function akmConsolidate(opts = {}) {
|
|
|
869
1216
|
// 4h on this user's stack. See
|
|
870
1217
|
// /tmp/akm-health-investigations/tuning-reasons-investigation.md §Q3.
|
|
871
1218
|
const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir);
|
|
1219
|
+
// ── Cold-start budget estimation ─────────────────────────────────────────────
|
|
1220
|
+
// Estimate wall-clock cost BEFORE issuing any LLM calls. When a signal is
|
|
1221
|
+
// provided and the estimated cost exceeds ~60% of the remaining budget we
|
|
1222
|
+
// auto-reduce the pool and log the reduction so the run never starts work
|
|
1223
|
+
// it cannot finish (avoiding SIGTERM mid-LLM-call).
|
|
1224
|
+
//
|
|
1225
|
+
// Formula: chunks.length × p90_chunk_seconds. The p90 comes from
|
|
1226
|
+
// `opts.p90ChunkSecondsDefault` (caller-supplied, typically from the profile
|
|
1227
|
+
// config); absent = 30 s (conservative default matching a medium local LLM).
|
|
1228
|
+
//
|
|
1229
|
+
// "Remaining budget" is read from a custom property on the AbortSignal if
|
|
1230
|
+
// the caller (improve.ts) has attached one. Without it no auto-reduction
|
|
1231
|
+
// fires but the check is still cheap to run.
|
|
1232
|
+
if (chunks.length > 10 && opts.signal) {
|
|
1233
|
+
const p90Chunk = opts.p90ChunkSecondsDefault ?? 30;
|
|
1234
|
+
const estimatedSeconds = chunks.length * p90Chunk;
|
|
1235
|
+
// remainingBudgetMs is a non-standard extension set by improve.ts when it
|
|
1236
|
+
// creates the budget AbortController. Undefined = no budget information.
|
|
1237
|
+
const budgetMs = opts.signal.remainingBudgetMs;
|
|
1238
|
+
if (budgetMs !== undefined && budgetMs > 0) {
|
|
1239
|
+
const remainingSeconds = budgetMs / 1000;
|
|
1240
|
+
if (estimatedSeconds > remainingSeconds * 0.6) {
|
|
1241
|
+
const safeCaps = Math.max(1, Math.floor((remainingSeconds * 0.6) / p90Chunk));
|
|
1242
|
+
const removedChunks = chunks.length - safeCaps;
|
|
1243
|
+
if (removedChunks > 0) {
|
|
1244
|
+
const msg = `[consolidate] cold-start budget: estimated ${estimatedSeconds.toFixed(0)}s > 60% of remaining ${remainingSeconds.toFixed(0)}s; ` +
|
|
1245
|
+
`reducing pool from ${chunks.length} to ${safeCaps} chunks (${removedChunks} deferred to next run).`;
|
|
1246
|
+
warn(msg);
|
|
1247
|
+
warnings.push(msg);
|
|
1248
|
+
chunks.splice(safeCaps);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
872
1253
|
warn(`[consolidate] ${memories.length} memories / ${chunks.length} chunk(s) / chunk_size=${chunkSize}` +
|
|
873
1254
|
` / pending-proposal hashes: ${pendingProposalBodyHashes.size}`);
|
|
1255
|
+
// Consolidate output merges memories (non-wiki) → stash authoring standards.
|
|
1256
|
+
// Resolved ONCE per run and passed to each chunk prompt (facts not re-read
|
|
1257
|
+
// per chunk).
|
|
1258
|
+
const standardsContext = resolveStashStandards(stashDir);
|
|
874
1259
|
const chunkOpsArrays = [];
|
|
875
1260
|
// Structured skip-reason histogram (2026-05-26): every deterministic
|
|
876
1261
|
// post-LLM op rejection site below also calls `pushSkipReason` so the
|
|
@@ -907,6 +1292,13 @@ export async function akmConsolidate(opts = {}) {
|
|
|
907
1292
|
// judgedNoAction tracks memories the LLM saw inside a chunk but proposed
|
|
908
1293
|
// no op for. Computed per chunk as `chunk.length − unique(targetRefs in ops)`.
|
|
909
1294
|
let judgedNoAction = 0;
|
|
1295
|
+
// Judged-state cache (#581): coarse outcome per memory NAME the LLM actually
|
|
1296
|
+
// judged in a successfully-parsed chunk this run. "actioned" = an op targeted
|
|
1297
|
+
// it; "no_action" = the LLM saw it and proposed nothing. Populated only when
|
|
1298
|
+
// the cache is enabled (otherwise it stays empty and the post-loop recording
|
|
1299
|
+
// step is a no-op). Memories in failed/aborted chunks are NOT recorded, so a
|
|
1300
|
+
// transient LLM failure never poisons the cache into skipping them next run.
|
|
1301
|
+
const judgedOutcomeByName = new Map();
|
|
910
1302
|
// 2026-05-27 cross-chunk double-count fix: refs that contributed to
|
|
911
1303
|
// judgedNoAction in their own chunk. When a different chunk's op references
|
|
912
1304
|
// one of these as a secondary and that op later fails, the ref would land
|
|
@@ -931,6 +1323,19 @@ export async function akmConsolidate(opts = {}) {
|
|
|
931
1323
|
const ABORT_MIN_CHUNKS = 4;
|
|
932
1324
|
const ABORT_FAILURE_RATE = 0.5;
|
|
933
1325
|
for (let chunkIdx = 0; chunkIdx < chunks.length; chunkIdx++) {
|
|
1326
|
+
// Budget-signal check: break cleanly before the next LLM call if the
|
|
1327
|
+
// caller's budget has been exhausted. Commits work done so far.
|
|
1328
|
+
if (opts.signal?.aborted) {
|
|
1329
|
+
const skipped = chunks.length - chunkIdx;
|
|
1330
|
+
const msg = `[consolidate] budget signal aborted before chunk ${chunkIdx + 1}/${chunks.length}; ${skipped} chunk(s) not processed (partial_timeout — work done so far committed).`;
|
|
1331
|
+
warn(msg);
|
|
1332
|
+
warnings.push(msg);
|
|
1333
|
+
// Account for memories in unprocessed chunks.
|
|
1334
|
+
for (let i = chunkIdx; i < chunks.length; i++) {
|
|
1335
|
+
failedChunkMemories += chunks[i].length;
|
|
1336
|
+
}
|
|
1337
|
+
break;
|
|
1338
|
+
}
|
|
934
1339
|
// Abort if failure rate >= 50% over at least 4 processed chunks.
|
|
935
1340
|
if (totalChunksProcessed >= ABORT_MIN_CHUNKS) {
|
|
936
1341
|
const failureRate = totalChunksFailed / totalChunksProcessed;
|
|
@@ -968,7 +1373,7 @@ export async function akmConsolidate(opts = {}) {
|
|
|
968
1373
|
continue;
|
|
969
1374
|
}
|
|
970
1375
|
warn(`[consolidate] chunk ${chunkIdx + 1}/${chunks.length} (${chunk.length} memories) …`);
|
|
971
|
-
const userPrompt = buildChunkPrompt(sourceName, chunk, chunkIdx, chunks.length, bodyTruncation, pendingProposalBodyHashes);
|
|
1376
|
+
const userPrompt = buildChunkPrompt(sourceName, chunk, chunkIdx, chunks.length, bodyTruncation, pendingProposalBodyHashes, standardsContext);
|
|
972
1377
|
let raw = await tryLlmFeature("memory_consolidation", config, async () => {
|
|
973
1378
|
if (!llmConfig)
|
|
974
1379
|
return { ok: false, error: "No LLM configured for consolidation" };
|
|
@@ -1075,11 +1480,64 @@ export async function akmConsolidate(opts = {}) {
|
|
|
1075
1480
|
if (!targetRefs.has(memRef)) {
|
|
1076
1481
|
chunkNoAction++;
|
|
1077
1482
|
judgedNoActionRefs.add(memRef);
|
|
1483
|
+
// Judged-state cache (#581): the LLM saw this memory and proposed
|
|
1484
|
+
// nothing → record judged-unchanged so the next run can skip it.
|
|
1485
|
+
if (judgedCacheEnabled)
|
|
1486
|
+
judgedOutcomeByName.set(m.name, "no_action");
|
|
1487
|
+
}
|
|
1488
|
+
else if (judgedCacheEnabled) {
|
|
1489
|
+
// An op targeted this memory → it was judged + actioned.
|
|
1490
|
+
judgedOutcomeByName.set(m.name, "actioned");
|
|
1078
1491
|
}
|
|
1079
1492
|
}
|
|
1080
1493
|
judgedNoAction += chunkNoAction;
|
|
1081
1494
|
chunkOpsArrays.push(ops);
|
|
1082
1495
|
}
|
|
1496
|
+
// ── Judged-state cache recording (#581) ─────────────────────────────────────
|
|
1497
|
+
// Persist judged state for every memory the LLM actually judged this run so
|
|
1498
|
+
// the next run can skip the unchanged ones. Keyed by current content hash so
|
|
1499
|
+
// a later body edit (different hash) re-enters the LLM pool. DEFAULT OFF and
|
|
1500
|
+
// skipped under --dry-run (dry-run mutates nothing). Failed/aborted chunks
|
|
1501
|
+
// contributed no entries to `judgedOutcomeByName`, so a transient LLM outage
|
|
1502
|
+
// never caches a memory as judged.
|
|
1503
|
+
if (judgedCacheEnabled && !opts.dryRun && judgedOutcomeByName.size > 0) {
|
|
1504
|
+
// Use the shared state.db handle; open a local one as fallback.
|
|
1505
|
+
const doRecord = (db) => {
|
|
1506
|
+
const judgedAt = new Date(startMs).toISOString();
|
|
1507
|
+
for (const [name, outcome] of judgedOutcomeByName) {
|
|
1508
|
+
const hash = currentHashByName.get(name);
|
|
1509
|
+
if (hash === undefined)
|
|
1510
|
+
continue;
|
|
1511
|
+
upsertConsolidationJudged(db, {
|
|
1512
|
+
entryKey: `memory:${name}`,
|
|
1513
|
+
contentHash: hash,
|
|
1514
|
+
judgedAt,
|
|
1515
|
+
outcome,
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
};
|
|
1519
|
+
if (sharedStateDb) {
|
|
1520
|
+
try {
|
|
1521
|
+
doRecord(sharedStateDb);
|
|
1522
|
+
}
|
|
1523
|
+
catch (e) {
|
|
1524
|
+
warnings.push(`Judged-state cache: failed to record judged state: ${String(e)}`);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
let localDb;
|
|
1529
|
+
try {
|
|
1530
|
+
localDb = openStateDatabase();
|
|
1531
|
+
doRecord(localDb);
|
|
1532
|
+
}
|
|
1533
|
+
catch (e) {
|
|
1534
|
+
warnings.push(`Judged-state cache: failed to record judged state: ${String(e)}`);
|
|
1535
|
+
}
|
|
1536
|
+
finally {
|
|
1537
|
+
localDb?.close();
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1083
1541
|
// Build the known-refs set from the already-filtered memory pool so
|
|
1084
1542
|
// mergePlans() can reject LLM-hallucinated primary refs before execution.
|
|
1085
1543
|
const knownRefs = new Set(memories.map((m) => `memory:${m.name}`));
|
|
@@ -1266,7 +1724,7 @@ export async function akmConsolidate(opts = {}) {
|
|
|
1266
1724
|
emitMergeFailureSkips(mergeResult.error);
|
|
1267
1725
|
continue;
|
|
1268
1726
|
}
|
|
1269
|
-
|
|
1727
|
+
let mergedContent = mergeResult.content;
|
|
1270
1728
|
// Validate frontmatter of merged content — must have a `---` block
|
|
1271
1729
|
// with at minimum a `description` field. We parse via the hand-rolled
|
|
1272
1730
|
// parser (cheap) AND require non-empty description. This guards against
|
|
@@ -1320,6 +1778,62 @@ export async function akmConsolidate(opts = {}) {
|
|
|
1320
1778
|
emitMergeFailureSkips("merge_participant_blocked");
|
|
1321
1779
|
continue;
|
|
1322
1780
|
}
|
|
1781
|
+
// WS-3b: Anti-collapse generation guard (step 8a).
|
|
1782
|
+
// DEFAULT OFF. When antiCollapse.enabled, refuse to merge two assets both
|
|
1783
|
+
// above generation N (default 2). This prevents the pipeline from
|
|
1784
|
+
// building ever-deeper LLM-merged trees that lose the source fidelity
|
|
1785
|
+
// of the original episodes.
|
|
1786
|
+
const antiCollapseConfig = config.profiles?.improve?.default?.processes?.consolidate?.antiCollapse ??
|
|
1787
|
+
{};
|
|
1788
|
+
if (antiCollapseConfig.enabled) {
|
|
1789
|
+
const allParticipants = [op.primary, ...op.secondaries];
|
|
1790
|
+
const sourceGenerations = allParticipants.map((ref) => {
|
|
1791
|
+
const e = memoryByRef.get(ref);
|
|
1792
|
+
if (!e)
|
|
1793
|
+
return 0;
|
|
1794
|
+
try {
|
|
1795
|
+
const raw = fs.readFileSync(e.filePath, "utf8");
|
|
1796
|
+
const parsed = parseFrontmatter(raw);
|
|
1797
|
+
return readAssetGeneration(parsed.data);
|
|
1798
|
+
}
|
|
1799
|
+
catch {
|
|
1800
|
+
return 0;
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1803
|
+
const generationCheck = checkGenerationGuard(sourceGenerations, antiCollapseConfig);
|
|
1804
|
+
if (generationCheck.refused) {
|
|
1805
|
+
warnings.push(`Merge: ${generationCheck.reason}`);
|
|
1806
|
+
emitMergeFailureSkips("merge_generation_guard");
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
// WS-3b: Lexical diversity check (step 8b).
|
|
1810
|
+
// Low n-gram diversity ⇒ likely correlated-extraction artifact; raise merge threshold.
|
|
1811
|
+
if (antiCollapseConfig.lexicalDiversityCheck !== false) {
|
|
1812
|
+
const bodies = allParticipants
|
|
1813
|
+
.map((ref) => {
|
|
1814
|
+
const e = memoryByRef.get(ref);
|
|
1815
|
+
if (!e)
|
|
1816
|
+
return "";
|
|
1817
|
+
try {
|
|
1818
|
+
const raw = fs.readFileSync(e.filePath, "utf8");
|
|
1819
|
+
return stripFrontmatterBody(raw);
|
|
1820
|
+
}
|
|
1821
|
+
catch {
|
|
1822
|
+
return "";
|
|
1823
|
+
}
|
|
1824
|
+
})
|
|
1825
|
+
.filter((b) => b.length > 0);
|
|
1826
|
+
const diversityCheck = checkLexicalDiversity(bodies, antiCollapseConfig);
|
|
1827
|
+
if (diversityCheck.lowDiversity) {
|
|
1828
|
+
// Low-diversity cluster: just warn (don't refuse merge since the dedup
|
|
1829
|
+
// path handles exact twins). The warning surfaces in health telemetry.
|
|
1830
|
+
warnings.push(`Merge: cluster around ${op.primary} has low lexical diversity (${diversityCheck.diversity?.toFixed(2) ?? "?"} < 0.30) — likely correlated extraction; merge proceeds but review is recommended.`);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
// Inject generation counter into merged content frontmatter (step 8a).
|
|
1834
|
+
// merged.generation = max(sourceGenerations) + 1.
|
|
1835
|
+
mergedContent = injectGenerationFrontmatter(mergedContent, sourceGenerations, allParticipants);
|
|
1836
|
+
}
|
|
1323
1837
|
// Backup secondaries before deleting
|
|
1324
1838
|
for (const secRef of op.secondaries) {
|
|
1325
1839
|
const secEntry = memoryByRef.get(secRef);
|
|
@@ -1522,11 +2036,12 @@ export async function akmConsolidate(opts = {}) {
|
|
|
1522
2036
|
// is the load-bearing content, so dedup must hash on body only.
|
|
1523
2037
|
// (b) A prior run created a proposal for the same body under a
|
|
1524
2038
|
// different knowledgeRef slug.
|
|
1525
|
-
|
|
2039
|
+
// Use cacheHash (case-preserving stripped body) to match the canonical
|
|
2040
|
+
// hash domain used by the body-embedding cache and pending-proposal set.
|
|
2041
|
+
const bodyHash = cacheHash(sourceBody);
|
|
1526
2042
|
const allPendingConsolidateProposals = listProposals(stashDir, { status: "pending" }).filter((p) => p.source === "consolidate");
|
|
1527
2043
|
const contentDupProposal = allPendingConsolidateProposals.find((p) => {
|
|
1528
|
-
|
|
1529
|
-
return createHash("sha256").update(otherBody, "utf8").digest("hex") === bodyHash;
|
|
2044
|
+
return cacheHash(p.payload.content) === bodyHash;
|
|
1530
2045
|
});
|
|
1531
2046
|
if (contentDupProposal) {
|
|
1532
2047
|
warnings.push(`Skipping promote: identical body already pending as proposal ${contentDupProposal.id} (ref: ${contentDupProposal.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
|
|
@@ -1608,6 +2123,18 @@ export async function akmConsolidate(opts = {}) {
|
|
|
1608
2123
|
}
|
|
1609
2124
|
}
|
|
1610
2125
|
else if (op.op === "contradict") {
|
|
2126
|
+
// Confidence gate: surface-level topic overlap causes false positives
|
|
2127
|
+
// (investigation 2026-06-18). Require ≥0.92 confidence before writing
|
|
2128
|
+
// contradiction edges. Missing confidence field defaults to 1.0 for
|
|
2129
|
+
// backward compatibility with responses that predate this field.
|
|
2130
|
+
const opConfidence = typeof op.confidence === "number"
|
|
2131
|
+
? op.confidence
|
|
2132
|
+
: 1.0;
|
|
2133
|
+
if (opConfidence < 0.92) {
|
|
2134
|
+
warnings.push(`Contradict: confidence ${opConfidence.toFixed(2)} below 0.92 threshold for ${op.ref} <-> ${op.contradictedByRef} — skipping.`);
|
|
2135
|
+
pushSkipReason("contradict", op.ref, "contradict_low_confidence");
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
1611
2138
|
// C-3 / #382: Write contradictedBy edges so resolveFamilyContradictions
|
|
1612
2139
|
// (the SCC resolver in memory-improve.ts) has edges to work on.
|
|
1613
2140
|
// Zep arXiv:2501.13956 §3 — unified belief-revision with contradiction edges.
|
|
@@ -1647,37 +2174,19 @@ export async function akmConsolidate(opts = {}) {
|
|
|
1647
2174
|
commitWriteTargetBoundary(target, `Consolidate: ${merged} merged, ${deleted} removed`);
|
|
1648
2175
|
}
|
|
1649
2176
|
cleanupJournal(stashDir, timestamp);
|
|
1650
|
-
//
|
|
1651
|
-
//
|
|
1652
|
-
//
|
|
1653
|
-
//
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
// Emit event before deletion so the record survives the purge.
|
|
1664
|
-
appendEvent({
|
|
1665
|
-
eventType: "archive_cleanup",
|
|
1666
|
-
metadata: {
|
|
1667
|
-
file: fname,
|
|
1668
|
-
filePath: fp,
|
|
1669
|
-
ageMs: Date.now() - stat.mtimeMs,
|
|
1670
|
-
retentionMs,
|
|
1671
|
-
},
|
|
1672
|
-
});
|
|
1673
|
-
fs.unlinkSync(fp);
|
|
1674
|
-
}
|
|
1675
|
-
}
|
|
1676
|
-
catch {
|
|
1677
|
-
/* ignore race conditions */
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
2177
|
+
// [signoff 2026-06-15] TTL archive cleanup machinery RETIRED (WS-3a).
|
|
2178
|
+
// The elaborate archiveRetentionDays / archive-dir scan existed only to satisfy
|
|
2179
|
+
// the old irrecoverability constraint. Stashes are now git-backed, so git
|
|
2180
|
+
// history is the recovery path — no bespoke archive TTL needed. Any files in
|
|
2181
|
+
// .akm/archive/ will stay there harmlessly until the operator prunes them with
|
|
2182
|
+
// `git rm` or `find .akm/archive -mtime +90 -delete`. Changed N files this
|
|
2183
|
+
// run; recover any via `git show <sha>:<path>` or `git restore <path>`.
|
|
2184
|
+
if (merged > 0 || deleted > 0 || dedupCollapsed > 0) {
|
|
2185
|
+
const totalChanged = merged + deleted + dedupCollapsed;
|
|
2186
|
+
warnings.push(`Changed ${totalChanged} file(s) this run. Recover any via git if needed (git history is the backstop).`);
|
|
2187
|
+
}
|
|
2188
|
+
const runDurationMs = Date.now() - startMs;
|
|
2189
|
+
const budgetFraction = opts.runBudgetMs !== undefined && opts.runBudgetMs > 0 ? runDurationMs / opts.runBudgetMs : undefined;
|
|
1681
2190
|
return {
|
|
1682
2191
|
schemaVersion: 1,
|
|
1683
2192
|
ok: true,
|
|
@@ -1687,7 +2196,10 @@ export async function akmConsolidate(opts = {}) {
|
|
|
1687
2196
|
target: sourceName,
|
|
1688
2197
|
processed: memories.length,
|
|
1689
2198
|
merged,
|
|
1690
|
-
|
|
2199
|
+
// #617: fold the deterministic dedup pre-pass collapses into the reported
|
|
2200
|
+
// deleted count. Each collapse removed exactly one variant file with NO
|
|
2201
|
+
// LLM call before the LLM pass ran on the pruned pool.
|
|
2202
|
+
deleted: deleted + dedupCollapsed,
|
|
1691
2203
|
promoted,
|
|
1692
2204
|
contradicted,
|
|
1693
2205
|
failedChunks: totalChunksFailed,
|
|
@@ -1697,7 +2209,16 @@ export async function akmConsolidate(opts = {}) {
|
|
|
1697
2209
|
mergedSecondaries,
|
|
1698
2210
|
failedChunkMemories,
|
|
1699
2211
|
warnings,
|
|
1700
|
-
durationMs:
|
|
2212
|
+
durationMs: runDurationMs,
|
|
2213
|
+
perfTelemetry: {
|
|
2214
|
+
dedupPoolSize: perfMs.dedupPoolSize,
|
|
2215
|
+
llmPoolSize,
|
|
2216
|
+
judgedCacheSkipped: perfMs.judgedCacheSkipped,
|
|
2217
|
+
embedMs: embedTelemetry.embedMs,
|
|
2218
|
+
embedCacheHits: embedTelemetry.cacheHits,
|
|
2219
|
+
embedCacheMisses: embedTelemetry.cacheMisses,
|
|
2220
|
+
...(budgetFraction !== undefined ? { estimatedBudgetFractionUsed: budgetFraction } : {}),
|
|
2221
|
+
},
|
|
1701
2222
|
};
|
|
1702
2223
|
}
|
|
1703
2224
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -2004,7 +2525,7 @@ function parseSinceToIso(since) {
|
|
|
2004
2525
|
const multiplier = { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
|
|
2005
2526
|
return new Date(Date.now() - parseInt(m[1], 10) * multiplier).toISOString();
|
|
2006
2527
|
}
|
|
2007
|
-
export function narrowToIncrementalCandidates(memories, since, warnings) {
|
|
2528
|
+
export function narrowToIncrementalCandidates(memories, since, warnings, neighborsPerChanged = 5) {
|
|
2008
2529
|
const sinceIso = parseSinceToIso(since);
|
|
2009
2530
|
const isChanged = (m) => {
|
|
2010
2531
|
try {
|
|
@@ -2019,7 +2540,6 @@ export function narrowToIncrementalCandidates(memories, since, warnings) {
|
|
|
2019
2540
|
return [];
|
|
2020
2541
|
if (changed.length === memories.length)
|
|
2021
2542
|
return memories;
|
|
2022
|
-
const NEIGHBORS_PER_CHANGED = 5;
|
|
2023
2543
|
const byName = new Map(memories.map((m) => [m.name, m]));
|
|
2024
2544
|
const keep = new Set(changed.map((m) => m.name));
|
|
2025
2545
|
let db;
|
|
@@ -2029,7 +2549,7 @@ export function narrowToIncrementalCandidates(memories, since, warnings) {
|
|
|
2029
2549
|
const id = findEntryIdByRef(db, `memory:${m.name}`);
|
|
2030
2550
|
if (id === undefined)
|
|
2031
2551
|
continue;
|
|
2032
|
-
for (const hit of getNeighborsByEntryId(db, id,
|
|
2552
|
+
for (const hit of getNeighborsByEntryId(db, id, neighborsPerChanged + 1)) {
|
|
2033
2553
|
if (hit.id === id)
|
|
2034
2554
|
continue;
|
|
2035
2555
|
const entry = getEntryById(db, hit.id);
|
|
@@ -2231,8 +2751,8 @@ async function generateMergedContent(config, primaryRef, primaryBody, secondaryR
|
|
|
2231
2751
|
}
|
|
2232
2752
|
normalizeUpdatedField(repairedFmData);
|
|
2233
2753
|
const repairedYaml = serializeFrontmatter(repairedFmData);
|
|
2234
|
-
const bodyPart = mergedFm.content
|
|
2235
|
-
return { content:
|
|
2754
|
+
const bodyPart = typeof mergedFm.content === "string" ? mergedFm.content : "";
|
|
2755
|
+
return { content: assembleAssetFromString(repairedYaml, bodyPart) };
|
|
2236
2756
|
}
|
|
2237
2757
|
}
|
|
2238
2758
|
catch {
|