akm-cli 0.9.0-beta.5 → 0.9.0-beta.51
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 +711 -0
- package/README.md +12 -4
- package/dist/akm +38 -0
- package/dist/akm-migrate-storage +38 -0
- package/dist/assets/profiles/default.json +9 -4
- package/dist/assets/profiles/frequent.json +1 -1
- package/dist/assets/profiles/memory-focus.json +1 -1
- package/dist/assets/profiles/quick.json +1 -1
- package/dist/assets/profiles/synthesize.json +15 -0
- package/dist/assets/profiles/thorough.json +1 -1
- 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/graph-extract-user-prompt.md +1 -1
- 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 +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
- package/dist/assets/templates/html/health.html +281 -111
- package/dist/assets/wiki/ingest-workflow-template.md +38 -10
- package/dist/cli/parse-args.js +46 -1
- package/dist/cli/shared.js +28 -0
- package/dist/cli.js +27 -11
- package/dist/commands/agent/agent-dispatch.js +2 -2
- package/dist/commands/agent/agent-support.js +0 -7
- package/dist/commands/agent/contribute-cli.js +17 -4
- package/dist/commands/config-cli.js +18 -2
- package/dist/commands/env/child-env.js +47 -0
- package/dist/commands/env/env-cli.js +33 -26
- package/dist/commands/env/secret-cli.js +36 -22
- package/dist/commands/feedback-cli.js +15 -6
- package/dist/commands/graph/graph-cli.js +5 -13
- package/dist/commands/graph/graph.js +76 -72
- package/dist/commands/health/checks.js +49 -1
- package/dist/commands/health/html-report.js +422 -80
- package/dist/commands/health.js +386 -9
- package/dist/commands/improve/calibration.js +161 -0
- package/dist/commands/improve/consolidate/chunking.js +141 -0
- package/dist/commands/improve/consolidate/eligibility.js +81 -0
- package/dist/commands/improve/consolidate/merge.js +145 -0
- package/dist/commands/improve/consolidate/sanitize.js +231 -0
- package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
- package/dist/commands/improve/consolidate.js +635 -660
- package/dist/commands/improve/dedup.js +482 -0
- package/dist/commands/improve/distill.js +159 -69
- package/dist/commands/improve/eligibility.js +434 -0
- package/dist/commands/improve/encoding-salience.js +205 -0
- package/dist/commands/improve/extract-cli.js +124 -2
- package/dist/commands/improve/extract-prompt.js +39 -2
- package/dist/commands/improve/extract-watch.js +140 -0
- package/dist/commands/improve/extract.js +389 -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 +138 -7
- package/dist/commands/improve/improve-cli.js +36 -61
- package/dist/commands/improve/improve-profiles.js +14 -0
- package/dist/commands/improve/improve-result-file.js +14 -25
- package/dist/commands/improve/improve-session.js +58 -0
- package/dist/commands/improve/improve.js +485 -2498
- package/dist/commands/improve/locks.js +154 -0
- package/dist/commands/improve/loop-stages.js +1083 -0
- package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
- package/dist/commands/improve/outcome-loop.js +256 -0
- package/dist/commands/improve/preparation.js +1966 -0
- package/dist/commands/improve/proactive-maintenance.js +115 -0
- package/dist/commands/improve/procedural.js +418 -0
- package/dist/commands/improve/recombine.js +850 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +183 -40
- 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 +38 -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 +43 -50
- package/dist/commands/proposal/proposal-cli.js +21 -31
- package/dist/commands/proposal/proposal.js +5 -0
- package/dist/commands/proposal/propose.js +7 -2
- package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
- package/dist/commands/proposal/validators/proposals.js +189 -63
- package/dist/commands/read/curate.js +414 -94
- package/dist/commands/read/knowledge.js +6 -3
- package/dist/commands/read/search-cli.js +9 -4
- package/dist/commands/read/search.js +10 -6
- package/dist/commands/read/show.js +86 -7
- package/dist/commands/sources/init.js +49 -17
- package/dist/commands/sources/installed-stashes.js +11 -3
- package/dist/commands/sources/schema-repair.js +43 -45
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/source-add.js +7 -3
- package/dist/commands/sources/stash-cli.js +28 -40
- package/dist/commands/sources/stash-skeleton.js +23 -8
- package/dist/commands/tasks/tasks-cli.js +19 -27
- package/dist/commands/tasks/tasks.js +39 -11
- package/dist/commands/wiki-cli.js +21 -35
- package/dist/core/asset/asset-registry.js +3 -1
- package/dist/core/asset/asset-spec.js +18 -2
- package/dist/core/asset/frontmatter.js +166 -167
- package/dist/core/asset/markdown.js +8 -0
- package/dist/core/authoring-rules.js +92 -0
- package/dist/core/common.js +0 -5
- package/dist/core/config/config-migration.js +12 -11
- package/dist/core/config/config-schema.js +340 -56
- package/dist/core/config/config-types.js +3 -3
- package/dist/core/config/config.js +28 -7
- package/dist/core/events.js +3 -7
- package/dist/core/improve-types.js +11 -8
- package/dist/core/logs-db.js +10 -66
- package/dist/core/parse.js +36 -16
- 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/migrations.js +714 -0
- package/dist/core/state-db.js +525 -474
- package/dist/indexer/db/db.js +439 -247
- 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 +218 -4
- package/dist/indexer/index-writer-lock.js +99 -0
- package/dist/indexer/indexer.js +123 -221
- package/dist/indexer/passes/dir-staleness.js +114 -0
- package/dist/indexer/passes/memory-inference.js +13 -5
- package/dist/indexer/passes/staleness-detect.js +2 -5
- package/dist/indexer/search/db-search.js +19 -6
- package/dist/indexer/search/ranking-contributors.js +22 -0
- package/dist/indexer/search/ranking.js +4 -0
- package/dist/indexer/search/search-source.js +17 -18
- package/dist/indexer/search/semantic-status.js +4 -0
- package/dist/indexer/walk/matchers.js +9 -0
- package/dist/integrations/agent/config.js +6 -53
- package/dist/integrations/agent/index.js +2 -18
- package/dist/integrations/agent/prompts.js +75 -9
- package/dist/integrations/agent/runner-dispatch.js +59 -0
- package/dist/integrations/harnesses/claude/session-log.js +11 -1
- package/dist/integrations/harnesses/index.js +2 -3
- package/dist/integrations/harnesses/opencode/session-log.js +173 -3
- package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
- package/dist/integrations/session-logs/index.js +16 -0
- package/dist/llm/client.js +45 -15
- package/dist/llm/embedder.js +42 -3
- package/dist/llm/embedders/deterministic.js +66 -0
- package/dist/llm/embedders/local.js +66 -2
- package/dist/llm/feature-gate.js +8 -4
- package/dist/llm/graph-extract.js +67 -44
- package/dist/llm/memory-infer-impl.js +138 -0
- package/dist/llm/memory-infer.js +1 -127
- package/dist/llm/metadata-enhance.js +44 -31
- package/dist/llm/structured-call.js +49 -0
- package/dist/migrate-storage-node.mjs +8 -0
- package/dist/output/context.js +5 -5
- package/dist/output/renderers.js +74 -2
- package/dist/output/shapes/curate.js +14 -2
- package/dist/output/shapes/passthrough.js +0 -1
- package/dist/output/text/helpers.js +16 -1
- package/dist/registry/providers/skills-sh.js +21 -147
- package/dist/registry/providers/static-index.js +15 -157
- package/dist/registry/resolve.js +22 -9
- package/dist/runtime.js +25 -1
- package/dist/scripts/migrate-storage.js +2617 -1961
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
- package/dist/setup/setup.js +29 -8
- package/dist/sources/include.js +6 -2
- package/dist/sources/providers/filesystem.js +0 -1
- package/dist/sources/providers/git-install.js +210 -0
- package/dist/sources/providers/git-provider.js +234 -0
- package/dist/sources/providers/git-stash.js +248 -0
- package/dist/sources/providers/git.js +10 -661
- package/dist/sources/providers/npm.js +2 -6
- package/dist/sources/providers/provider-utils.js +13 -7
- package/dist/sources/providers/sync-from-ref.js +9 -1
- package/dist/sources/providers/tar-utils.js +16 -8
- package/dist/sources/providers/website.js +9 -5
- package/dist/sources/website-ingest.js +187 -29
- package/dist/sources/wiki-fetchers/registry.js +53 -0
- package/dist/sources/wiki-fetchers/youtube.js +239 -0
- package/dist/storage/database.js +45 -10
- package/dist/storage/managed-db.js +82 -0
- package/dist/storage/repositories/registry-cache.js +92 -0
- package/dist/storage/sqlite-pragmas.js +146 -0
- package/dist/tasks/backends/cron.js +1 -1
- package/dist/tasks/backends/launchd.js +1 -1
- package/dist/tasks/backends/schtasks.js +1 -1
- package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
- package/dist/tasks/runner.js +5 -13
- package/dist/text-import-hook.mjs +0 -0
- package/dist/wiki/wiki.js +37 -0
- package/dist/workflows/db.js +3 -4
- package/dist/workflows/runtime/runs.js +1 -117
- package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
- package/dist/workflows/validate-summary.js +2 -7
- package/docs/data-and-telemetry.md +3 -2
- package/docs/migration/release-notes/0.9.0.md +39 -0
- package/package.json +13 -11
- package/dist/commands/db-cli.js +0 -23
- package/dist/indexer/db/db-backup.js +0 -376
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
// ── Deterministic near-duplicate MEMORY dedup (#617) ─────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// A CHEAP, DETERMINISTIC fast path that collapses the obvious duplicates with
|
|
7
|
+
// NO LLM call, run in front of the embedding-clustered LLM consolidation pass
|
|
8
|
+
// (see consolidate.ts). The working stash accumulates near-duplicates faster
|
|
9
|
+
// than the capped/rotating LLM consolidate pass can merge them; this pre-pass
|
|
10
|
+
// clears the unambiguous twins so the (expensive) LLM only ever sees genuinely
|
|
11
|
+
// distinct-but-related memories.
|
|
12
|
+
//
|
|
13
|
+
// Two collapse classes, both safe and reversible (archive + backup at the call
|
|
14
|
+
// site in consolidate.ts; this module only computes the plan and applies the
|
|
15
|
+
// file writes/deletes):
|
|
16
|
+
//
|
|
17
|
+
// 1. `.derived` ↔ origin pairs — a memory-inference `<parent>.derived` child
|
|
18
|
+
// whose normalized body is identical to (or, with embeddings, ≥ the strict
|
|
19
|
+
// cosine threshold of) its origin. Keep the canonical (non-derived) origin;
|
|
20
|
+
// drop the derived variant; preserve the variant's provenance on the
|
|
21
|
+
// canonical (`dedupedFrom`).
|
|
22
|
+
//
|
|
23
|
+
// 2. Content twins — two non-derived memories with identical normalized body
|
|
24
|
+
// hash, or ≥ the strict cosine threshold. Keep the deterministic canonical
|
|
25
|
+
// (lexicographically smallest name); merge the other into it, preserving
|
|
26
|
+
// provenance.
|
|
27
|
+
//
|
|
28
|
+
// Determinism: the plan is a pure function of the on-disk memory set + config.
|
|
29
|
+
// Nothing here reads Date.now()/Math.random(); ordering is by memory name so the
|
|
30
|
+
// canonical choice and op order are stable across runs.
|
|
31
|
+
//
|
|
32
|
+
// Gating: DEFAULT OFF. The pre-pass only runs when `dedup.enabled === true`. The
|
|
33
|
+
// cosine path additionally requires an embedding config; absent embeddings the
|
|
34
|
+
// pass still collapses exact normalized-hash twins. `cosineThreshold` defaults
|
|
35
|
+
// to 0.97 — a strict floor chosen so distinct-but-related memories fall through
|
|
36
|
+
// untouched to the LLM consolidation.
|
|
37
|
+
import { createHash } from "node:crypto";
|
|
38
|
+
import fs from "node:fs";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { parseAssetRef } from "../../core/asset/asset-ref.js";
|
|
41
|
+
import { assembleAssetFromString, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
|
|
42
|
+
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
43
|
+
import { getBodyEmbeddings, upsertBodyEmbeddings } from "../../core/state-db.js";
|
|
44
|
+
import { warn } from "../../core/warn.js";
|
|
45
|
+
import { cosineSimilarity, embedBatch, resolveEmbeddingModelId } from "../../llm/embedder.js";
|
|
46
|
+
/** Default strict cosine floor — high enough to skip distinct-but-related memories. */
|
|
47
|
+
export const DEFAULT_DEDUP_COSINE_THRESHOLD = 0.97;
|
|
48
|
+
/**
|
|
49
|
+
* Strip frontmatter from raw memory content, returning the body text trimmed.
|
|
50
|
+
* Case and whitespace are preserved — this is the shared primitive used by
|
|
51
|
+
* both hash wrappers below. Falls back to `raw.trim()` on unparseable
|
|
52
|
+
* frontmatter (consistent with the pre-existing load-time hot guard).
|
|
53
|
+
*/
|
|
54
|
+
export function stripFrontmatterBody(raw) {
|
|
55
|
+
try {
|
|
56
|
+
return parseFrontmatter(raw).content.trim();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return raw.trim();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Normalize a memory body for content-twin equality. Strips frontmatter,
|
|
64
|
+
* lowercases, trims, and collapses all runs of whitespace to a single space so
|
|
65
|
+
* trivial reformatting (extra blank lines, trailing spaces, case) does not
|
|
66
|
+
* defeat the hash. Deterministic and pure.
|
|
67
|
+
*
|
|
68
|
+
* Use this for the DEDUP path only (exact-twin detection). For the change-
|
|
69
|
+
* detection / embedding-cache path use `cacheHash` instead.
|
|
70
|
+
*/
|
|
71
|
+
export function normalizeMemoryBody(raw) {
|
|
72
|
+
return stripFrontmatterBody(raw).toLowerCase().replace(/\s+/g, " ");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Hash used for content-twin detection: lowercase + whitespace-collapsed body.
|
|
76
|
+
* Two memories that differ only in case or whitespace produce the same hash
|
|
77
|
+
* and are considered identical twins. Use this key for the dedup buckets.
|
|
78
|
+
*/
|
|
79
|
+
export function dedupHash(raw) {
|
|
80
|
+
return createHash("sha256").update(normalizeMemoryBody(raw), "utf8").digest("hex");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Hash used for change-detection and the body-embedding cache: case-/whitespace-
|
|
84
|
+
* preserving stripped body. Two memories with the same wording but different
|
|
85
|
+
* casing produce DIFFERENT hashes here, which is intentional — we embed the
|
|
86
|
+
* exact text and cache by its precise content.
|
|
87
|
+
*
|
|
88
|
+
* This is the `content_hash` stored in `body_embeddings` and
|
|
89
|
+
* `consolidation_judged`. Do NOT reuse the `dedupHash` for those tables.
|
|
90
|
+
*/
|
|
91
|
+
export function cacheHash(raw) {
|
|
92
|
+
return createHash("sha256").update(stripFrontmatterBody(raw), "utf8").digest("hex");
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Load every memory `.md` file (including `.derived` children) from the stash
|
|
96
|
+
* memories directory. Unlike the consolidate loader, this DOES include derived
|
|
97
|
+
* children — the whole point of class 1 is to collapse a derived child into its
|
|
98
|
+
* origin, so both must be visible here.
|
|
99
|
+
*/
|
|
100
|
+
export function loadDedupMemories(stashDir) {
|
|
101
|
+
const memoriesDir = path.join(stashDir, "memories");
|
|
102
|
+
if (!fs.existsSync(memoriesDir))
|
|
103
|
+
return [];
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const fname of fs.readdirSync(memoriesDir).sort()) {
|
|
106
|
+
if (!fname.endsWith(".md"))
|
|
107
|
+
continue;
|
|
108
|
+
const name = fname.replace(/\.md$/, "");
|
|
109
|
+
const filePath = path.join(memoriesDir, fname);
|
|
110
|
+
let raw;
|
|
111
|
+
try {
|
|
112
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
let fm = {};
|
|
118
|
+
try {
|
|
119
|
+
fm = parseFrontmatter(raw).data ?? {};
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
fm = {};
|
|
123
|
+
}
|
|
124
|
+
const normalizedBody = normalizeMemoryBody(raw);
|
|
125
|
+
out.push({
|
|
126
|
+
name,
|
|
127
|
+
filePath,
|
|
128
|
+
derived: name.endsWith(".derived"),
|
|
129
|
+
derivedFrom: typeof fm.derivedFrom === "string" ? fm.derivedFrom : undefined,
|
|
130
|
+
raw,
|
|
131
|
+
normalizedBody,
|
|
132
|
+
bodyHash: dedupHash(raw),
|
|
133
|
+
hot: fm.captureMode === "hot",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
/** Default cap on the O(n²) cosine-compare pool size. */
|
|
139
|
+
export const DEFAULT_COSINE_CANDIDATE_LIMIT = 500;
|
|
140
|
+
/**
|
|
141
|
+
* Build the deterministic collapse plan. Pure over (memories, similarities,
|
|
142
|
+
* threshold) — `embeddings` is optional; when absent only exact normalized-hash
|
|
143
|
+
* twins are matched.
|
|
144
|
+
*
|
|
145
|
+
* Pass invariants:
|
|
146
|
+
* - A memory is consumed (collapsed) at most once.
|
|
147
|
+
* - Hot (captureMode: hot) memories are never collapsed, as canonical OR
|
|
148
|
+
* variant — user-explicit, only the user retires them.
|
|
149
|
+
* - Class 1 (`.derived` ↔ origin) is matched first so a derived child is
|
|
150
|
+
* always folded into its origin (never the reverse, never twin-matched).
|
|
151
|
+
*/
|
|
152
|
+
export function planDedup(memories, opts) {
|
|
153
|
+
const collapses = [];
|
|
154
|
+
const warnings = [];
|
|
155
|
+
const consumed = new Set();
|
|
156
|
+
const threshold = opts.cosineThreshold;
|
|
157
|
+
const byName = new Map();
|
|
158
|
+
for (const m of memories)
|
|
159
|
+
byName.set(m.name, m);
|
|
160
|
+
const sim = (a, b) => {
|
|
161
|
+
if (!opts.embeddings)
|
|
162
|
+
return undefined;
|
|
163
|
+
const ea = opts.embeddings.get(a.name);
|
|
164
|
+
const eb = opts.embeddings.get(b.name);
|
|
165
|
+
if (!ea || !eb)
|
|
166
|
+
return undefined;
|
|
167
|
+
return cosineSimilarity(ea, eb);
|
|
168
|
+
};
|
|
169
|
+
// ── Class 1: `.derived` children ↔ their origin ────────────────────────────
|
|
170
|
+
// Iterate derived children in sorted order (loadDedupMemories sorts), folding
|
|
171
|
+
// each into its origin when near-identical.
|
|
172
|
+
const derived = memories.filter((m) => m.derived).sort((a, b) => a.name.localeCompare(b.name));
|
|
173
|
+
for (const child of derived) {
|
|
174
|
+
if (consumed.has(child.name))
|
|
175
|
+
continue;
|
|
176
|
+
if (child.hot)
|
|
177
|
+
continue;
|
|
178
|
+
const originName = child.derivedFrom ?? child.name.replace(/\.derived$/, "");
|
|
179
|
+
const origin = byName.get(originName);
|
|
180
|
+
if (!origin || consumed.has(origin.name) || origin.derived || origin.hot)
|
|
181
|
+
continue;
|
|
182
|
+
let via;
|
|
183
|
+
let similarity;
|
|
184
|
+
if (child.bodyHash === origin.bodyHash) {
|
|
185
|
+
via = "derived-hash";
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const s = sim(child, origin);
|
|
189
|
+
if (s !== undefined && s >= threshold) {
|
|
190
|
+
via = "derived-cosine";
|
|
191
|
+
similarity = s;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
if (!via)
|
|
195
|
+
continue;
|
|
196
|
+
collapses.push({ canonical: origin.name, variant: child.name, via, similarity });
|
|
197
|
+
consumed.add(child.name);
|
|
198
|
+
}
|
|
199
|
+
// ── Class 2: content twins among non-derived memories ──────────────────────
|
|
200
|
+
// Bucket by exact normalized-hash first (cheap, transitive, deterministic).
|
|
201
|
+
const remaining = memories
|
|
202
|
+
.filter((m) => !m.derived && !m.hot && !consumed.has(m.name))
|
|
203
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
204
|
+
const hashBuckets = new Map();
|
|
205
|
+
for (const m of remaining) {
|
|
206
|
+
const list = hashBuckets.get(m.bodyHash);
|
|
207
|
+
if (list)
|
|
208
|
+
list.push(m);
|
|
209
|
+
else
|
|
210
|
+
hashBuckets.set(m.bodyHash, [m]);
|
|
211
|
+
}
|
|
212
|
+
for (const bucket of hashBuckets.values()) {
|
|
213
|
+
if (bucket.length < 2)
|
|
214
|
+
continue;
|
|
215
|
+
// Canonical = lexicographically smallest name (already sorted).
|
|
216
|
+
const canonical = bucket[0];
|
|
217
|
+
for (let i = 1; i < bucket.length; i++) {
|
|
218
|
+
const variant = bucket[i];
|
|
219
|
+
if (consumed.has(variant.name))
|
|
220
|
+
continue;
|
|
221
|
+
collapses.push({ canonical: canonical.name, variant: variant.name, via: "twin-hash" });
|
|
222
|
+
consumed.add(variant.name);
|
|
223
|
+
}
|
|
224
|
+
consumed.add(canonical.name); // canonical kept but no longer a twin candidate
|
|
225
|
+
}
|
|
226
|
+
// Cosine twins (only when embeddings are available). O(n²) over the still-
|
|
227
|
+
// unconsumed non-derived pool; deterministic greedy: for each canonical in
|
|
228
|
+
// sorted order, claim every unconsumed later memory whose similarity ≥ floor.
|
|
229
|
+
// The pool is capped at `cosineCandidateLimit` (default 500) to bound the
|
|
230
|
+
// O(n²) cost (~0.1 s at 500; ~3 s at 2.6 k; ~85 s at 13 k). Exact-hash
|
|
231
|
+
// matches above always run over the full pool and are unaffected.
|
|
232
|
+
if (opts.embeddings) {
|
|
233
|
+
const limit = opts.cosineCandidateLimit ?? DEFAULT_COSINE_CANDIDATE_LIMIT;
|
|
234
|
+
const fullPool = remaining.filter((m) => !consumed.has(m.name));
|
|
235
|
+
const pool = fullPool.length > limit ? fullPool.slice(0, limit) : fullPool;
|
|
236
|
+
if (fullPool.length > limit) {
|
|
237
|
+
warnings.push(`dedup: cosine compare pool (${fullPool.length}) exceeds cosineCandidateLimit (${limit}); capping to first ${limit} memories (exact-hash matches unaffected).`);
|
|
238
|
+
}
|
|
239
|
+
for (let i = 0; i < pool.length; i++) {
|
|
240
|
+
const canonical = pool[i];
|
|
241
|
+
if (consumed.has(canonical.name))
|
|
242
|
+
continue;
|
|
243
|
+
for (let j = i + 1; j < pool.length; j++) {
|
|
244
|
+
const variant = pool[j];
|
|
245
|
+
if (consumed.has(variant.name))
|
|
246
|
+
continue;
|
|
247
|
+
const s = sim(canonical, variant);
|
|
248
|
+
if (s !== undefined && s >= threshold) {
|
|
249
|
+
collapses.push({ canonical: canonical.name, variant: variant.name, via: "twin-cosine", similarity: s });
|
|
250
|
+
consumed.add(variant.name);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
consumed.add(canonical.name);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { collapses, warnings };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Fold a dropped variant's provenance into the canonical's frontmatter. Appends
|
|
260
|
+
* the variant ref to a `dedupedFrom` list (deduplicated, sorted) and carries
|
|
261
|
+
* any `source`/`sources` references the variant held that the canonical lacks.
|
|
262
|
+
* Pure string→string; never invents timestamps.
|
|
263
|
+
*/
|
|
264
|
+
export function applyProvenance(canonicalRaw, variant) {
|
|
265
|
+
let parsed;
|
|
266
|
+
try {
|
|
267
|
+
parsed = parseFrontmatter(canonicalRaw);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Canonical frontmatter unparseable — leave content untouched (the file is
|
|
271
|
+
// never collapsed AS a canonical when hot/unparseable is the variant, but
|
|
272
|
+
// the canonical itself can still be odd; preserve bytes).
|
|
273
|
+
return canonicalRaw;
|
|
274
|
+
}
|
|
275
|
+
const fm = { ...parsed.data };
|
|
276
|
+
const variantRef = `memory:${variant.name}`;
|
|
277
|
+
const existing = Array.isArray(fm.dedupedFrom)
|
|
278
|
+
? fm.dedupedFrom.filter((v) => typeof v === "string")
|
|
279
|
+
: typeof fm.dedupedFrom === "string"
|
|
280
|
+
? [fm.dedupedFrom]
|
|
281
|
+
: [];
|
|
282
|
+
const next = Array.from(new Set([...existing, variantRef])).sort();
|
|
283
|
+
fm.dedupedFrom = next;
|
|
284
|
+
return assembleAssetFromString(serializeFrontmatter(fm), parsed.content);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Apply a collapse plan to disk: rewrite each canonical with merged provenance,
|
|
288
|
+
* delete each variant. Returns counts + consumed refs so the consolidate pass
|
|
289
|
+
* can prune them from the LLM pool. NO LLM call.
|
|
290
|
+
*
|
|
291
|
+
* `onArchive` (optional) is invoked with the variant file path before deletion
|
|
292
|
+
* so the caller can archive/back up exactly as it does for LLM merges.
|
|
293
|
+
*/
|
|
294
|
+
export function applyDedupPlan(plan, memories, onArchive) {
|
|
295
|
+
const byName = new Map();
|
|
296
|
+
for (const m of memories)
|
|
297
|
+
byName.set(m.name, m);
|
|
298
|
+
const warnings = [...plan.warnings];
|
|
299
|
+
const consumedRefs = [];
|
|
300
|
+
let collapsed = 0;
|
|
301
|
+
// Accumulate provenance per canonical so multiple variants folding into the
|
|
302
|
+
// same canonical produce a single rewrite.
|
|
303
|
+
const provByCanonical = new Map();
|
|
304
|
+
for (const c of plan.collapses) {
|
|
305
|
+
const list = provByCanonical.get(c.canonical);
|
|
306
|
+
if (list)
|
|
307
|
+
list.push(byName.get(c.variant));
|
|
308
|
+
else
|
|
309
|
+
provByCanonical.set(c.canonical, [byName.get(c.variant)]);
|
|
310
|
+
}
|
|
311
|
+
// 1. Rewrite canonicals with merged provenance.
|
|
312
|
+
for (const [canonicalName, variants] of provByCanonical) {
|
|
313
|
+
const canonical = byName.get(canonicalName);
|
|
314
|
+
if (!canonical)
|
|
315
|
+
continue;
|
|
316
|
+
let content = canonical.raw;
|
|
317
|
+
for (const v of variants.filter((x) => x).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
318
|
+
content = applyProvenance(content, v);
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
fs.writeFileSync(canonical.filePath, content.endsWith("\n") ? content : `${content}\n`, "utf8");
|
|
322
|
+
}
|
|
323
|
+
catch (e) {
|
|
324
|
+
warnings.push(`dedup: failed to rewrite canonical ${canonicalName}: ${String(e)}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// 2. Delete variants (archive first if requested).
|
|
328
|
+
for (const c of plan.collapses) {
|
|
329
|
+
const variant = byName.get(c.variant);
|
|
330
|
+
if (!variant)
|
|
331
|
+
continue;
|
|
332
|
+
if (!fs.existsSync(variant.filePath)) {
|
|
333
|
+
warnings.push(`dedup: variant ${c.variant} already absent — skipping delete.`);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (onArchive) {
|
|
337
|
+
try {
|
|
338
|
+
onArchive(variant);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// best-effort archival; deletion proceeds
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
fs.unlinkSync(variant.filePath);
|
|
346
|
+
collapsed++;
|
|
347
|
+
consumedRefs.push(`memory:${variant.name}`);
|
|
348
|
+
}
|
|
349
|
+
catch (e) {
|
|
350
|
+
warnings.push(`dedup: failed to delete variant ${c.variant}: ${String(e)}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { collapsed, consumedRefs, warnings };
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Top-level entry point for the consolidate pre-pass. Loads memories, optionally
|
|
357
|
+
* embeds them (only when a cosine path is reachable), plans, and applies the
|
|
358
|
+
* collapse. DEFAULT OFF — returns a no-op result when `config.enabled !== true`.
|
|
359
|
+
*
|
|
360
|
+
* `onArchive` lets the caller archive/back up each dropped variant before
|
|
361
|
+
* deletion (consolidate.ts wires this to its existing archive helper).
|
|
362
|
+
*
|
|
363
|
+
* `signal` (optional): an AbortSignal forwarded from the caller's budget
|
|
364
|
+
* controller. When aborted before the embedding call the function returns a
|
|
365
|
+
* no-op result immediately; the signal is also forwarded into `embedBatch`
|
|
366
|
+
* so a mid-embedding abort is handled cleanly.
|
|
367
|
+
*/
|
|
368
|
+
export async function runDeterministicDedup(stashDir, dedupConfig, akmConfig, onArchive, signal,
|
|
369
|
+
/** Optional open state.db handle for the body-embedding cache (WS-3a). */
|
|
370
|
+
stateDb) {
|
|
371
|
+
if (!dedupConfig?.enabled) {
|
|
372
|
+
return { collapsed: 0, consumedRefs: [], warnings: [] };
|
|
373
|
+
}
|
|
374
|
+
if (signal?.aborted) {
|
|
375
|
+
return { collapsed: 0, consumedRefs: [], warnings: ["dedup: aborted before start"] };
|
|
376
|
+
}
|
|
377
|
+
const threshold = dedupConfig.cosineThreshold ?? DEFAULT_DEDUP_COSINE_THRESHOLD;
|
|
378
|
+
const candidateLimit = dedupConfig.cosineCandidateLimit ?? DEFAULT_COSINE_CANDIDATE_LIMIT;
|
|
379
|
+
const memories = loadDedupMemories(stashDir);
|
|
380
|
+
if (memories.length === 0) {
|
|
381
|
+
return { collapsed: 0, consumedRefs: [], warnings: [] };
|
|
382
|
+
}
|
|
383
|
+
// Embed only when embeddings are configured — exact-hash collapse still works
|
|
384
|
+
// without them. Fail-open: any embedding error degrades to hash-only matching.
|
|
385
|
+
// NOTE: embedBatch embeds the case-preserving stripped body (cacheHash domain),
|
|
386
|
+
// not the lowercase dedupHash body, so dedup cosine and the body_embeddings
|
|
387
|
+
// cache share the same canonical embedding input.
|
|
388
|
+
let embeddings;
|
|
389
|
+
if (akmConfig.embedding) {
|
|
390
|
+
try {
|
|
391
|
+
const eligible = memories.filter((m) => !m.hot);
|
|
392
|
+
// Use the case-preserving stripped body for embeddings (matching cacheHash
|
|
393
|
+
// canonical input) so the embedding cache can be shared with consolidate.
|
|
394
|
+
const modelId = resolveEmbeddingModelId(akmConfig.embedding);
|
|
395
|
+
// WS-3a: body-embedding cache — look up all content_hashes in one query,
|
|
396
|
+
// embed only the misses, then upsert the new vectors in one transaction.
|
|
397
|
+
const contentHashes = eligible.map((m) => cacheHash(m.raw));
|
|
398
|
+
const hashToName = new Map();
|
|
399
|
+
for (let i = 0; i < eligible.length; i++) {
|
|
400
|
+
hashToName.set(contentHashes[i], eligible[i].name);
|
|
401
|
+
}
|
|
402
|
+
let cachedVecs = new Map();
|
|
403
|
+
if (stateDb) {
|
|
404
|
+
try {
|
|
405
|
+
cachedVecs = getBodyEmbeddings(stateDb, contentHashes, modelId);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
// Fail open: cache read errors degrade to full embed.
|
|
409
|
+
cachedVecs = new Map();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const missIndices = [];
|
|
413
|
+
const missTexts = [];
|
|
414
|
+
for (let i = 0; i < eligible.length; i++) {
|
|
415
|
+
const hash = contentHashes[i];
|
|
416
|
+
if (!cachedVecs.has(hash)) {
|
|
417
|
+
missIndices.push(i);
|
|
418
|
+
missTexts.push(stripFrontmatterBody(eligible[i].raw) || eligible[i].name);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
let missVecs = [];
|
|
422
|
+
if (missTexts.length > 0) {
|
|
423
|
+
missVecs = await embedBatch(missTexts, akmConfig.embedding, signal);
|
|
424
|
+
// Upsert new vectors into cache.
|
|
425
|
+
if (stateDb && missVecs.length === missTexts.length) {
|
|
426
|
+
try {
|
|
427
|
+
const toUpsert = missIndices.map((idx, pos) => ({
|
|
428
|
+
contentHash: contentHashes[idx],
|
|
429
|
+
embedding: missVecs[pos],
|
|
430
|
+
modelId,
|
|
431
|
+
}));
|
|
432
|
+
upsertBodyEmbeddings(stateDb, toUpsert);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// Fail open: cache write errors are non-fatal.
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Assemble the full embeddings map (cache hits + freshly embedded misses).
|
|
440
|
+
if (missVecs.length === missTexts.length || cachedVecs.size > 0) {
|
|
441
|
+
embeddings = new Map();
|
|
442
|
+
// Add cache hits.
|
|
443
|
+
for (let i = 0; i < eligible.length; i++) {
|
|
444
|
+
const hash = contentHashes[i];
|
|
445
|
+
const cached = cachedVecs.get(hash);
|
|
446
|
+
if (cached)
|
|
447
|
+
embeddings.set(eligible[i].name, cached);
|
|
448
|
+
}
|
|
449
|
+
// Add freshly embedded misses.
|
|
450
|
+
for (let pos = 0; pos < missIndices.length; pos++) {
|
|
451
|
+
const idx = missIndices[pos];
|
|
452
|
+
const vec = missVecs[pos];
|
|
453
|
+
if (vec)
|
|
454
|
+
embeddings.set(eligible[idx].name, vec);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
embeddings = undefined;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const plan = planDedup(memories, { cosineThreshold: threshold, embeddings, cosineCandidateLimit: candidateLimit });
|
|
463
|
+
if (plan.collapses.length === 0) {
|
|
464
|
+
return { collapsed: 0, consumedRefs: [], warnings: plan.warnings };
|
|
465
|
+
}
|
|
466
|
+
// Sanity: every variant ref must be a parseable memory ref before we touch
|
|
467
|
+
// disk (defends against a malformed name slipping through).
|
|
468
|
+
for (const c of plan.collapses) {
|
|
469
|
+
try {
|
|
470
|
+
parseAssetRef(`memory:${c.variant}`);
|
|
471
|
+
parseAssetRef(`memory:${c.canonical}`);
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
plan.warnings.push(`dedup: unparseable ref in collapse ${c.canonical} ← ${c.variant} — dropping op.`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const result = applyDedupPlan(plan, memories, (v) => onArchive?.(v.filePath, v.name));
|
|
478
|
+
if (result.collapsed > 0) {
|
|
479
|
+
warn(`[consolidate] deterministic dedup collapsed ${result.collapsed} near-duplicate memor${result.collapsed === 1 ? "y" : "ies"} (no LLM).`);
|
|
480
|
+
}
|
|
481
|
+
return result;
|
|
482
|
+
}
|