akm-cli 0.9.0-beta.11 → 0.9.0-beta.26
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 +163 -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 +5 -1
- 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/templates/html/health.html +25 -27
- package/dist/cli.js +2 -2
- package/dist/commands/agent/contribute-cli.js +16 -3
- package/dist/commands/feedback-cli.js +48 -44
- package/dist/commands/health/html-report.js +140 -16
- package/dist/commands/health.js +277 -1
- package/dist/commands/improve/calibration.js +161 -0
- package/dist/commands/improve/consolidate.js +595 -105
- package/dist/commands/improve/dedup.js +482 -0
- package/dist/commands/improve/distill.js +119 -64
- 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 +32 -1
- package/dist/commands/improve/extract-watch.js +140 -0
- package/dist/commands/improve/extract.js +210 -30
- 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 +80 -7
- package/dist/commands/improve/improve-profiles.js +8 -0
- package/dist/commands/improve/improve.js +991 -61
- 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 +9 -35
- package/dist/commands/improve/procedural.js +409 -0
- package/dist/commands/improve/recombine.js +488 -0
- package/dist/commands/improve/reflect.js +20 -1
- package/dist/commands/improve/related-sessions.js +120 -0
- package/dist/commands/improve/salience.js +386 -0
- package/dist/commands/improve/triage.js +95 -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/index.js +30 -13
- package/dist/commands/lint/memory-linter.js +1 -1
- package/dist/commands/lint/registry.js +5 -2
- package/dist/commands/lint/task-linter.js +3 -3
- package/dist/commands/lint/workflow-linter.js +26 -1
- package/dist/commands/proposal/validators/proposals.js +4 -0
- package/dist/commands/read/curate.js +284 -86
- package/dist/commands/read/search-cli.js +7 -0
- package/dist/commands/read/search.js +1 -0
- package/dist/commands/sources/installed-stashes.js +5 -1
- package/dist/core/asset/frontmatter.js +166 -167
- package/dist/core/asset/markdown.js +8 -0
- package/dist/core/config/config-schema.js +211 -3
- package/dist/core/config/config.js +2 -2
- package/dist/core/logs-db.js +4 -3
- package/dist/core/state-db.js +555 -29
- package/dist/indexer/db/db.js +250 -27
- package/dist/indexer/db/graph-db.js +81 -86
- package/dist/indexer/graph/graph-boost.js +51 -41
- 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.js +4 -0
- package/dist/integrations/harnesses/claude/session-log.js +10 -0
- package/dist/integrations/harnesses/opencode/session-log.js +9 -0
- package/dist/integrations/session-logs/index.js +16 -0
- package/dist/llm/embedder.js +27 -3
- package/dist/llm/embedders/local.js +66 -2
- 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/shapes/curate.js +14 -2
- package/dist/output/text/helpers.js +9 -0
- package/dist/runtime.js +25 -1
- package/dist/scripts/migrate-storage.js +1025 -567
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +435 -269
- package/dist/storage/sqlite-pragmas.js +146 -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 +5 -4
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import { rethrowIfTestIsolationError } from "../../core/errors.js";
|
|
6
6
|
import { getDbPath } from "../../core/paths.js";
|
|
7
|
-
import { warn } from "../../core/warn.js";
|
|
8
7
|
import { closeDatabase, openExistingDatabase } from "./db.js";
|
|
9
8
|
function withReadableGraphDb(db, fn) {
|
|
10
9
|
if (db)
|
|
@@ -26,38 +25,16 @@ function uniqueSorted(values) {
|
|
|
26
25
|
function normalizeEntity(value) {
|
|
27
26
|
return value.trim().toLowerCase();
|
|
28
27
|
}
|
|
29
|
-
/**
|
|
30
|
-
* Resolve a file_path within a stash to its entries.id. Returns null when the
|
|
31
|
-
* path has no indexed entry (orphan graph row).
|
|
32
|
-
*/
|
|
33
|
-
export function resolveEntryIdForPath(db, stashRoot, filePath) {
|
|
34
|
-
try {
|
|
35
|
-
const row = db
|
|
36
|
-
.prepare("SELECT id FROM entries WHERE stash_dir = ? AND file_path = ? LIMIT 1")
|
|
37
|
-
.get(stashRoot, filePath);
|
|
38
|
-
if (row)
|
|
39
|
-
return row.id;
|
|
40
|
-
// Fall back to file_path-only match (legacy callers may pass a stash root
|
|
41
|
-
// that doesn't exactly match entries.stash_dir, e.g. trailing-slash diffs).
|
|
42
|
-
const fallback = db.prepare("SELECT id FROM entries WHERE file_path = ? LIMIT 1").get(filePath);
|
|
43
|
-
return fallback?.id ?? null;
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
28
|
/**
|
|
50
29
|
* Persist (or update) a graph snapshot for a stash root.
|
|
51
30
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* Orphan files (no entries row resolvable) are skipped and counted in a
|
|
60
|
-
* single warn() so the caller sees the magnitude without log spam.
|
|
31
|
+
* #624-P1: keyed on (stash_root, file_path, body_hash) — NOT entries.id. Graph
|
|
32
|
+
* rows are self-keyed by path, so they survive an entries delete + reinsert
|
|
33
|
+
* (a reindex) when body_hash is unchanged. Unchanged files (matching body_hash)
|
|
34
|
+
* only have their file-meta refreshed; files whose body_hash changed have their
|
|
35
|
+
* old row + child rows deleted and the new content inserted; files in DB but
|
|
36
|
+
* absent from the new snapshot are deleted. There is no entry_id resolution and
|
|
37
|
+
* no orphan-skip — a graph file no longer needs a matching entries row.
|
|
61
38
|
*/
|
|
62
39
|
export function replaceStoredGraph(db, graph) {
|
|
63
40
|
const upsertMeta = db.prepare(`INSERT INTO graph_meta (
|
|
@@ -98,87 +75,98 @@ export function replaceStoredGraph(db, graph) {
|
|
|
98
75
|
cache_misses = excluded.cache_misses,
|
|
99
76
|
truncation_count = excluded.truncation_count,
|
|
100
77
|
failure_count = excluded.failure_count`);
|
|
101
|
-
const selectExisting = db.prepare("SELECT
|
|
102
|
-
const deleteFile = db.prepare("DELETE FROM graph_files WHERE
|
|
103
|
-
const deleteEntities = db.prepare("DELETE FROM graph_file_entities WHERE
|
|
104
|
-
const deleteRelations = db.prepare("DELETE FROM graph_file_relations WHERE
|
|
78
|
+
const selectExisting = db.prepare("SELECT file_path, body_hash, file_order FROM graph_files WHERE stash_root = ?");
|
|
79
|
+
const deleteFile = db.prepare("DELETE FROM graph_files WHERE stash_root = ? AND file_path = ? AND body_hash = ?");
|
|
80
|
+
const deleteEntities = db.prepare("DELETE FROM graph_file_entities WHERE stash_root = ? AND file_path = ? AND body_hash = ?");
|
|
81
|
+
const deleteRelations = db.prepare("DELETE FROM graph_file_relations WHERE stash_root = ? AND file_path = ? AND body_hash = ?");
|
|
105
82
|
const insertFile = db.prepare(`INSERT INTO graph_files (
|
|
106
|
-
|
|
107
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
|
83
|
+
stash_root, file_path, file_order, file_type, body_hash, confidence, status, reason, extraction_run_id
|
|
84
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
108
85
|
const updateFileMeta = db.prepare(`UPDATE graph_files
|
|
109
86
|
SET file_order = ?, file_type = ?, confidence = ?, status = ?, reason = ?, extraction_run_id = ?
|
|
110
|
-
WHERE
|
|
111
|
-
const insertEntity = db.prepare(`INSERT INTO graph_file_entities (
|
|
112
|
-
VALUES (?, ?, ?, ?, ?)`);
|
|
87
|
+
WHERE stash_root = ? AND file_path = ? AND body_hash = ?`);
|
|
88
|
+
const insertEntity = db.prepare(`INSERT INTO graph_file_entities (stash_root, file_path, body_hash, entity_order, entity_norm, entity)
|
|
89
|
+
VALUES (?, ?, ?, ?, ?, ?)`);
|
|
113
90
|
const insertRelation = db.prepare(`INSERT INTO graph_file_relations (
|
|
114
|
-
|
|
115
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
91
|
+
stash_root, file_path, body_hash, relation_order, from_entity_norm, from_entity, to_entity_norm, to_entity, relation_type, confidence
|
|
92
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
116
93
|
const quality = graph.quality;
|
|
117
94
|
const telemetry = graph.telemetry;
|
|
118
95
|
db.transaction(() => {
|
|
119
96
|
upsertMeta.run(graph.stashRoot, graph.schemaVersion, graph.generatedAt, quality?.consideredFiles ?? graph.files.length, quality?.extractedFiles ?? graph.files.length, quality?.entityCount ?? graph.entities?.length ?? 0, quality?.relationCount ?? graph.relations?.length ?? 0, quality?.extractionCoverage ?? 0, quality?.density ?? 0, telemetry?.extractorId ?? null, telemetry?.extractionRunId ?? null, telemetry?.model ?? null, telemetry?.promptVersion ?? null, telemetry?.batchSize ?? null, telemetry?.cacheHits ?? 0, telemetry?.cacheMisses ?? 0, telemetry?.truncationCount ?? 0, telemetry?.failureCount ?? 0);
|
|
120
|
-
// Build a snapshot of existing rows for incremental compare.
|
|
97
|
+
// Build a snapshot of existing rows for incremental compare. The unique
|
|
98
|
+
// index idx_graph_files_path guarantees at most one row per file_path.
|
|
121
99
|
const existingRows = selectExisting.all(graph.stashRoot);
|
|
122
100
|
const existingByPath = new Map();
|
|
123
101
|
for (const row of existingRows)
|
|
124
102
|
existingByPath.set(row.file_path, row);
|
|
125
|
-
|
|
126
|
-
const presentEntryIds = new Set();
|
|
103
|
+
const presentPaths = new Set();
|
|
127
104
|
for (const [fileOrder, node] of graph.files.entries()) {
|
|
128
|
-
// body_hash is
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
105
|
+
// body_hash is part of the PK; default to a sentinel for inputs (test
|
|
106
|
+
// fixtures, legacy imports) that don't supply one. The sentinel never
|
|
107
|
+
// equals a real hash so subsequent staleness checks always re-extract —
|
|
108
|
+
// correct behaviour for "unknown" bodies. Distinct files in one stash
|
|
109
|
+
// are still keyed apart by file_path, so the empty sentinel is safe.
|
|
132
110
|
const bodyHash = node.bodyHash && node.bodyHash.length > 0 ? node.bodyHash : "";
|
|
133
|
-
|
|
134
|
-
if (entryId == null) {
|
|
135
|
-
orphanCount += 1;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
presentEntryIds.add(entryId);
|
|
111
|
+
presentPaths.add(node.path);
|
|
139
112
|
const existing = existingByPath.get(node.path);
|
|
140
|
-
if (existing && existing.
|
|
113
|
+
if (existing && existing.body_hash === bodyHash) {
|
|
141
114
|
// Body unchanged — only fix up file_order/confidence in case they drifted.
|
|
142
|
-
updateFileMeta.run(fileOrder, node.type, node.confidence ?? null, node.status ?? (node.entities.length > 0 ? "extracted" : "empty"), node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content"), node.extractionRunId ?? telemetry?.extractionRunId ?? null,
|
|
115
|
+
updateFileMeta.run(fileOrder, node.type, node.confidence ?? null, node.status ?? (node.entities.length > 0 ? "extracted" : "empty"), node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content"), node.extractionRunId ?? telemetry?.extractionRunId ?? null, graph.stashRoot, node.path, bodyHash);
|
|
143
116
|
continue;
|
|
144
117
|
}
|
|
145
118
|
if (existing) {
|
|
146
|
-
// Stale row (different body_hash
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
deleteEntities.run(existing.
|
|
150
|
-
deleteRelations.run(existing.
|
|
151
|
-
deleteFile.run(existing.
|
|
119
|
+
// Stale row (different body_hash for this path). Delete the old row by
|
|
120
|
+
// its OLD body_hash; child rows cascade, but explicit DELETE keeps the
|
|
121
|
+
// order deterministic and is safe regardless of the FK pragma.
|
|
122
|
+
deleteEntities.run(graph.stashRoot, existing.file_path, existing.body_hash);
|
|
123
|
+
deleteRelations.run(graph.stashRoot, existing.file_path, existing.body_hash);
|
|
124
|
+
deleteFile.run(graph.stashRoot, existing.file_path, existing.body_hash);
|
|
152
125
|
}
|
|
153
|
-
insertFile.run(
|
|
126
|
+
insertFile.run(graph.stashRoot, node.path, fileOrder, node.type, bodyHash, node.confidence ?? null, node.status ?? (node.entities.length > 0 ? "extracted" : "empty"), node.reason ?? (node.entities.length > 0 ? "none" : "no_graph_content"), node.extractionRunId ?? telemetry?.extractionRunId ?? null);
|
|
154
127
|
for (const [entityOrder, entity] of node.entities.entries()) {
|
|
155
|
-
insertEntity.run(
|
|
128
|
+
insertEntity.run(graph.stashRoot, node.path, bodyHash, entityOrder, normalizeEntity(entity), entity);
|
|
156
129
|
}
|
|
157
130
|
for (const [relationOrder, relation] of node.relations.entries()) {
|
|
158
|
-
insertRelation.run(
|
|
131
|
+
insertRelation.run(graph.stashRoot, node.path, bodyHash, relationOrder, normalizeEntity(relation.from), relation.from, normalizeEntity(relation.to), relation.to, relation.type ?? null, relation.confidence ?? null);
|
|
159
132
|
}
|
|
160
133
|
}
|
|
161
134
|
// Delete files present in DB but absent from the new snapshot. Child
|
|
162
|
-
// tables CASCADE on
|
|
135
|
+
// tables CASCADE on the composite key; explicit DELETE keeps it determinstic.
|
|
163
136
|
for (const row of existingRows) {
|
|
164
|
-
if (!
|
|
165
|
-
deleteEntities.run(row.
|
|
166
|
-
deleteRelations.run(row.
|
|
167
|
-
deleteFile.run(row.
|
|
137
|
+
if (!presentPaths.has(row.file_path)) {
|
|
138
|
+
deleteEntities.run(graph.stashRoot, row.file_path, row.body_hash);
|
|
139
|
+
deleteRelations.run(graph.stashRoot, row.file_path, row.body_hash);
|
|
140
|
+
deleteFile.run(graph.stashRoot, row.file_path, row.body_hash);
|
|
168
141
|
}
|
|
169
142
|
}
|
|
170
|
-
if (orphanCount > 0) {
|
|
171
|
-
warn(`[graph] replaceStoredGraph: skipped ${orphanCount} file(s) with no resolvable entry under ${graph.stashRoot}.`);
|
|
172
|
-
}
|
|
173
143
|
})();
|
|
174
144
|
}
|
|
175
145
|
export function deleteStoredGraph(db, stashPath) {
|
|
176
146
|
db.transaction(() => {
|
|
177
|
-
// Child rows cascade via
|
|
147
|
+
// Child rows cascade via the composite (stash_root, file_path, body_hash)
|
|
148
|
+
// FK; deleting graph_files clears them. This is the explicit full-clear
|
|
149
|
+
// path for a stash (entries-delete no longer wipes graph data — see #624-P1).
|
|
178
150
|
db.prepare("DELETE FROM graph_files WHERE stash_root = ?").run(stashPath);
|
|
179
151
|
db.prepare("DELETE FROM graph_meta WHERE stash_root = ?").run(stashPath);
|
|
180
152
|
})();
|
|
181
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* #624-P1 — does any graph data exist for a file_path under a stash root?
|
|
156
|
+
* Consumed by show/curate flows (P3) but defined here so the schema and its
|
|
157
|
+
* accessors land together.
|
|
158
|
+
*/
|
|
159
|
+
export function hasGraphData(db, stashRoot, filePath) {
|
|
160
|
+
try {
|
|
161
|
+
const row = db
|
|
162
|
+
.prepare("SELECT 1 AS present FROM graph_files WHERE stash_root = ? AND file_path = ? LIMIT 1")
|
|
163
|
+
.get(stashRoot, filePath);
|
|
164
|
+
return row !== undefined;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
182
170
|
/**
|
|
183
171
|
* Scoped loader — only the graph_meta row for a stash. Used by callers that
|
|
184
172
|
* only need summary numbers (e.g. `akm graph summary`).
|
|
@@ -195,13 +183,12 @@ export function loadGraphFilesOnly(stashPath, db) {
|
|
|
195
183
|
return withReadableGraphDb(db, (readDb) => {
|
|
196
184
|
try {
|
|
197
185
|
const rows = readDb
|
|
198
|
-
.prepare(`SELECT
|
|
186
|
+
.prepare(`SELECT file_path, file_type, body_hash, confidence, status, reason
|
|
199
187
|
FROM graph_files
|
|
200
188
|
WHERE stash_root = ?
|
|
201
189
|
ORDER BY file_order`)
|
|
202
190
|
.all(stashPath);
|
|
203
191
|
return rows.map((row) => ({
|
|
204
|
-
entryId: row.entry_id,
|
|
205
192
|
path: row.file_path,
|
|
206
193
|
type: row.file_type,
|
|
207
194
|
bodyHash: row.body_hash,
|
|
@@ -222,13 +209,16 @@ export function loadGraphFilesOnly(stashPath, db) {
|
|
|
222
209
|
}
|
|
223
210
|
}
|
|
224
211
|
/**
|
|
225
|
-
* Scoped loader — entities for a single
|
|
212
|
+
* Scoped loader — entities for a single file, keyed on the #624-P1 composite
|
|
213
|
+
* (stash_root, file_path, body_hash). Used by per-asset show/curate lookups.
|
|
226
214
|
*/
|
|
227
|
-
export function
|
|
215
|
+
export function loadGraphEntitiesByPath(db, stashRoot, filePath, bodyHash) {
|
|
228
216
|
try {
|
|
229
217
|
const rows = db
|
|
230
|
-
.prepare(
|
|
231
|
-
|
|
218
|
+
.prepare(`SELECT entity FROM graph_file_entities
|
|
219
|
+
WHERE stash_root = ? AND file_path = ? AND body_hash = ?
|
|
220
|
+
ORDER BY entity_order`)
|
|
221
|
+
.all(stashRoot, filePath, bodyHash);
|
|
232
222
|
return rows.map((r) => r.entity);
|
|
233
223
|
}
|
|
234
224
|
catch {
|
|
@@ -314,27 +304,32 @@ export function loadStoredGraphSnapshot(stashPath, db) {
|
|
|
314
304
|
return null;
|
|
315
305
|
try {
|
|
316
306
|
const fileRows = readDb
|
|
317
|
-
.prepare(`SELECT
|
|
307
|
+
.prepare(`SELECT file_path, file_type, body_hash, confidence, status, reason, extraction_run_id
|
|
318
308
|
FROM graph_files
|
|
319
309
|
WHERE stash_root = ?
|
|
320
310
|
ORDER BY file_order`)
|
|
321
311
|
.all(stashPath);
|
|
322
312
|
const entityRows = readDb
|
|
323
|
-
.prepare(`SELECT
|
|
313
|
+
.prepare(`SELECT gf.file_path AS file_path, gfe.entity AS entity
|
|
324
314
|
FROM graph_file_entities gfe
|
|
325
|
-
JOIN graph_files gf
|
|
315
|
+
JOIN graph_files gf
|
|
316
|
+
ON gf.stash_root = gfe.stash_root
|
|
317
|
+
AND gf.file_path = gfe.file_path
|
|
318
|
+
AND gf.body_hash = gfe.body_hash
|
|
326
319
|
WHERE gf.stash_root = ?
|
|
327
320
|
ORDER BY gf.file_order, gfe.entity_order`)
|
|
328
321
|
.all(stashPath);
|
|
329
322
|
const relationRows = readDb
|
|
330
|
-
.prepare(`SELECT
|
|
331
|
-
gf.file_path AS file_path,
|
|
323
|
+
.prepare(`SELECT gf.file_path AS file_path,
|
|
332
324
|
gfr.from_entity AS from_entity,
|
|
333
325
|
gfr.to_entity AS to_entity,
|
|
334
326
|
gfr.relation_type AS relation_type,
|
|
335
327
|
gfr.confidence AS confidence
|
|
336
328
|
FROM graph_file_relations gfr
|
|
337
|
-
JOIN graph_files gf
|
|
329
|
+
JOIN graph_files gf
|
|
330
|
+
ON gf.stash_root = gfr.stash_root
|
|
331
|
+
AND gf.file_path = gfr.file_path
|
|
332
|
+
AND gf.body_hash = gfr.body_hash
|
|
338
333
|
WHERE gf.stash_root = ?
|
|
339
334
|
ORDER BY gf.file_order, gfr.relation_order`)
|
|
340
335
|
.all(stashPath);
|
|
@@ -241,13 +241,18 @@ export function collectGraphRelatedHit(context, filePath) {
|
|
|
241
241
|
* Find graph files that share entities with the given file.
|
|
242
242
|
*
|
|
243
243
|
* Implementation: SQL self-join on graph_file_entities, scoped by stash_root,
|
|
244
|
-
* grouped by
|
|
244
|
+
* grouped by file_path, ordered by shared-entity count desc. Touches ~50-200
|
|
245
245
|
* rows instead of loading the entire snapshot into memory. Cold-call latency
|
|
246
246
|
* drops from ~30-60ms (full snapshot parse) to ~2-5ms on typical stashes.
|
|
247
247
|
*
|
|
248
|
+
* #624-P1: the graph tables are keyed on (stash_root, file_path, body_hash) —
|
|
249
|
+
* NOT entries.id — so candidates are identified by file_path (the unique index
|
|
250
|
+
* idx_graph_files_path guarantees one graph_files row per path).
|
|
251
|
+
*
|
|
248
252
|
* The returned `ref` field carries the canonical asset ref (`type:name`)
|
|
249
|
-
* resolved from entries.entry_key when the
|
|
250
|
-
* fall back to formatting `path` when `ref` is undefined (
|
|
253
|
+
* resolved from entries.entry_key when the file is indexed. Callers should
|
|
254
|
+
* fall back to formatting `path` when `ref` is undefined (graph row with no
|
|
255
|
+
* matching entries row).
|
|
251
256
|
*/
|
|
252
257
|
export function listRelatedPathsForFile(stashRoot, filePath, limit = 5, db) {
|
|
253
258
|
if (!db) {
|
|
@@ -255,113 +260,118 @@ export function listRelatedPathsForFile(stashRoot, filePath, limit = 5, db) {
|
|
|
255
260
|
// callers pass a handle), so degrade to empty rather than reopening.
|
|
256
261
|
return [];
|
|
257
262
|
}
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
let targetEntryId;
|
|
263
|
+
// Confirm the target file has a graph row; without it there is nothing to
|
|
264
|
+
// relate. (Identity is file_path within the stash — one row per path.)
|
|
261
265
|
try {
|
|
262
266
|
const row = db
|
|
263
|
-
.prepare("SELECT
|
|
267
|
+
.prepare("SELECT 1 AS present FROM graph_files WHERE stash_root = ? AND file_path = ? LIMIT 1")
|
|
264
268
|
.get(stashRoot, filePath);
|
|
265
|
-
|
|
269
|
+
if (row === undefined)
|
|
270
|
+
return [];
|
|
266
271
|
}
|
|
267
272
|
catch {
|
|
268
273
|
return [];
|
|
269
274
|
}
|
|
270
|
-
if (targetEntryId == null)
|
|
271
|
-
return [];
|
|
272
275
|
const effectiveLimit = Math.max(1, limit);
|
|
273
|
-
// Shared-entity count per candidate
|
|
276
|
+
// Shared-entity count per candidate file_path. The target's entities are the
|
|
277
|
+
// rows for `filePath`; candidates are any OTHER file_path in the stash that
|
|
278
|
+
// shares a normalized entity.
|
|
274
279
|
let candidateRows;
|
|
275
280
|
try {
|
|
276
281
|
candidateRows = db
|
|
277
|
-
.prepare(`SELECT gf.
|
|
278
|
-
gf.file_path AS file_path,
|
|
282
|
+
.prepare(`SELECT gf.file_path AS file_path,
|
|
279
283
|
gf.file_type AS file_type,
|
|
280
284
|
COUNT(*) AS shared
|
|
281
285
|
FROM graph_file_entities target
|
|
282
286
|
JOIN graph_file_entities e
|
|
283
287
|
ON e.stash_root = target.stash_root
|
|
284
288
|
AND e.entity_norm = target.entity_norm
|
|
285
|
-
AND e.
|
|
289
|
+
AND e.file_path != target.file_path
|
|
286
290
|
JOIN graph_files gf
|
|
287
|
-
ON gf.
|
|
288
|
-
|
|
291
|
+
ON gf.stash_root = e.stash_root
|
|
292
|
+
AND gf.file_path = e.file_path
|
|
293
|
+
AND gf.body_hash = e.body_hash
|
|
294
|
+
WHERE target.file_path = ?
|
|
289
295
|
AND target.stash_root = ?
|
|
290
|
-
GROUP BY gf.
|
|
296
|
+
GROUP BY gf.file_path
|
|
291
297
|
ORDER BY shared DESC, gf.file_path ASC
|
|
292
298
|
LIMIT ?`)
|
|
293
|
-
.all(
|
|
299
|
+
.all(filePath, stashRoot, effectiveLimit);
|
|
294
300
|
}
|
|
295
301
|
catch {
|
|
296
302
|
return [];
|
|
297
303
|
}
|
|
298
304
|
if (candidateRows.length === 0)
|
|
299
305
|
return [];
|
|
300
|
-
const
|
|
301
|
-
const placeholders =
|
|
306
|
+
const candidatePaths = candidateRows.map((r) => r.file_path);
|
|
307
|
+
const placeholders = candidatePaths.map(() => "?").join(",");
|
|
302
308
|
// Pull the shared entity names (joined by normalized casing) for display.
|
|
303
309
|
const sharedRows = db
|
|
304
|
-
.prepare(`SELECT e.
|
|
310
|
+
.prepare(`SELECT e.file_path AS file_path, e.entity AS entity
|
|
305
311
|
FROM graph_file_entities e
|
|
306
312
|
JOIN graph_file_entities target
|
|
307
313
|
ON target.stash_root = e.stash_root
|
|
308
314
|
AND target.entity_norm = e.entity_norm
|
|
309
|
-
WHERE e.
|
|
310
|
-
AND
|
|
315
|
+
WHERE e.file_path IN (${placeholders})
|
|
316
|
+
AND e.stash_root = ?
|
|
317
|
+
AND target.file_path = ?
|
|
311
318
|
AND target.stash_root = ?`)
|
|
312
|
-
.all(...
|
|
313
|
-
const
|
|
319
|
+
.all(...candidatePaths, stashRoot, filePath, stashRoot);
|
|
320
|
+
const sharedByPath = new Map();
|
|
314
321
|
for (const row of sharedRows) {
|
|
315
|
-
let bucket =
|
|
322
|
+
let bucket = sharedByPath.get(row.file_path);
|
|
316
323
|
if (!bucket) {
|
|
317
324
|
bucket = new Set();
|
|
318
|
-
|
|
325
|
+
sharedByPath.set(row.file_path, bucket);
|
|
319
326
|
}
|
|
320
327
|
bucket.add(row.entity);
|
|
321
328
|
}
|
|
322
329
|
// Relation count for each candidate (relations where either endpoint
|
|
323
330
|
// matches one of the shared entities).
|
|
324
|
-
const
|
|
331
|
+
const relationCountByPath = new Map();
|
|
325
332
|
const relationRows = db
|
|
326
|
-
.prepare(`SELECT
|
|
333
|
+
.prepare(`SELECT file_path, from_entity, to_entity
|
|
327
334
|
FROM graph_file_relations
|
|
328
|
-
WHERE
|
|
329
|
-
|
|
335
|
+
WHERE file_path IN (${placeholders})
|
|
336
|
+
AND stash_root = ?`)
|
|
337
|
+
.all(...candidatePaths, stashRoot);
|
|
330
338
|
for (const row of relationRows) {
|
|
331
|
-
const shared =
|
|
339
|
+
const shared = sharedByPath.get(row.file_path);
|
|
332
340
|
if (!shared)
|
|
333
341
|
continue;
|
|
334
342
|
if (shared.has(row.from_entity) || shared.has(row.to_entity)) {
|
|
335
|
-
|
|
343
|
+
relationCountByPath.set(row.file_path, (relationCountByPath.get(row.file_path) ?? 0) + 1);
|
|
336
344
|
}
|
|
337
345
|
}
|
|
338
346
|
// Optional: ref lookup via entries.entry_key. entry_key is stored as
|
|
339
347
|
// `${stash_dir}:${type}:${name}` — strip the stash-dir prefix to get the
|
|
340
|
-
// user-facing `type:name`.
|
|
341
|
-
|
|
348
|
+
// user-facing `type:name`. Resolve by (stash_dir, file_path) now that the
|
|
349
|
+
// graph rows are no longer keyed on entries.id.
|
|
350
|
+
const refByPath = new Map();
|
|
342
351
|
try {
|
|
343
352
|
const entryRows = db
|
|
344
|
-
.prepare(`SELECT
|
|
345
|
-
|
|
353
|
+
.prepare(`SELECT entry_key, stash_dir, file_path FROM entries
|
|
354
|
+
WHERE file_path IN (${placeholders}) AND stash_dir = ?`)
|
|
355
|
+
.all(...candidatePaths, stashRoot);
|
|
346
356
|
for (const row of entryRows) {
|
|
347
357
|
const ref = stripStashPrefix(row.entry_key, row.stash_dir);
|
|
348
358
|
if (ref)
|
|
349
|
-
|
|
359
|
+
refByPath.set(row.file_path, ref);
|
|
350
360
|
}
|
|
351
361
|
}
|
|
352
362
|
catch {
|
|
353
363
|
/* ignore — refs are best-effort */
|
|
354
364
|
}
|
|
355
365
|
return candidateRows.map((row) => {
|
|
356
|
-
const sharedSet =
|
|
366
|
+
const sharedSet = sharedByPath.get(row.file_path) ?? new Set();
|
|
357
367
|
const sharedEntities = [...sharedSet].sort((a, b) => a.localeCompare(b));
|
|
358
|
-
const ref =
|
|
368
|
+
const ref = refByPath.get(row.file_path);
|
|
359
369
|
return {
|
|
360
370
|
...(ref ? { ref } : {}),
|
|
361
371
|
path: row.file_path,
|
|
362
372
|
type: row.file_type,
|
|
363
373
|
sharedEntities,
|
|
364
|
-
relationCount:
|
|
374
|
+
relationCount: relationCountByPath.get(row.file_path) ?? 0,
|
|
365
375
|
};
|
|
366
376
|
});
|
|
367
377
|
}
|
|
@@ -425,10 +425,17 @@ function markParentProcessed(parent) {
|
|
|
425
425
|
warn(`memory inference: failed to re-read parent ${parent.filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
426
426
|
return;
|
|
427
427
|
}
|
|
428
|
-
const updatedFm = { ...parent.data, [FM_INFERENCE_PROCESSED]: true };
|
|
429
428
|
const block = parseFrontmatterBlock(raw);
|
|
430
|
-
|
|
431
|
-
|
|
429
|
+
if (!block) {
|
|
430
|
+
// Cannot safely rewrite malformed frontmatter — skip marking so the memory
|
|
431
|
+
// is retried on the next run once the frontmatter is repaired. Writing with
|
|
432
|
+
// `body = raw` would wrap the entire file (including the bad frontmatter)
|
|
433
|
+
// in a new block, producing a duplicate-frontmatter corruption.
|
|
434
|
+
warn(`memory inference: skipping markParentProcessed for ${parent.filePath} — could not parse frontmatter block`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const updatedFm = { ...parent.data, [FM_INFERENCE_PROCESSED]: true };
|
|
438
|
+
const next = assembleAsset(updatedFm, block.content);
|
|
432
439
|
try {
|
|
433
440
|
fs.writeFileSync(parent.filePath, next, "utf8");
|
|
434
441
|
}
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
import { createHash } from "node:crypto";
|
|
45
45
|
import fs from "node:fs";
|
|
46
46
|
import path from "node:path";
|
|
47
|
+
import stalenessDetectSystemPrompt from "../../assets/prompts/staleness-detect-system.md" with { type: "text" };
|
|
47
48
|
import { assembleAsset } from "../../core/asset/asset-serialize.js";
|
|
48
49
|
import { parseFrontmatter, parseFrontmatterBlock } from "../../core/asset/frontmatter.js";
|
|
49
50
|
import { concurrentMap } from "../../core/concurrent.js";
|
|
@@ -319,11 +320,7 @@ function pickSimilar(candidate, all) {
|
|
|
319
320
|
return scored.slice(0, TOP_K_SIMILAR).map((s) => s.snap);
|
|
320
321
|
}
|
|
321
322
|
// ── LLM dispatch ────────────────────────────────────────────────────────────
|
|
322
|
-
const SYSTEM_PROMPT =
|
|
323
|
-
"Respond on the first line with exactly YES or NO.\n" +
|
|
324
|
-
"If YES, the second line MUST be of the form `SUPERSEDED_BY: <ref>` where <ref> is the exact ref of the superseding memory from the list provided. Do NOT invent refs.\n" +
|
|
325
|
-
"If NO, do not include any additional lines.\n" +
|
|
326
|
-
"No prose, no preamble, no markdown.";
|
|
323
|
+
const SYSTEM_PROMPT = stalenessDetectSystemPrompt;
|
|
327
324
|
async function askValidator(connection, candidate, allMemories, signal, timeoutMs) {
|
|
328
325
|
const similar = pickSimilar(candidate, allMemories);
|
|
329
326
|
if (similar.length === 0) {
|
|
@@ -65,6 +65,7 @@ export async function searchLocal(input) {
|
|
|
65
65
|
const includeProposed = input.includeProposed === true;
|
|
66
66
|
const beliefFilter = input.beliefFilter ?? "all";
|
|
67
67
|
const restrictToSources = input.restrictToSources === true;
|
|
68
|
+
const includeExcludedTypes = input.includeExcludedTypes === true;
|
|
68
69
|
const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
|
|
69
70
|
const allSourceDirs = sources.map((s) => s.path);
|
|
70
71
|
const rawStatus = readSemanticStatus();
|
|
@@ -114,7 +115,7 @@ export async function searchLocal(input) {
|
|
|
114
115
|
mode: "keyword",
|
|
115
116
|
};
|
|
116
117
|
}
|
|
117
|
-
const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed, beliefFilter, restrictToSources);
|
|
118
|
+
const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed, beliefFilter, restrictToSources, includeExcludedTypes);
|
|
118
119
|
return {
|
|
119
120
|
hits,
|
|
120
121
|
tip: hits.length === 0
|
|
@@ -131,14 +132,19 @@ export async function searchLocal(input) {
|
|
|
131
132
|
}
|
|
132
133
|
}
|
|
133
134
|
// ── Database search ─────────────────────────────────────────────────────────
|
|
134
|
-
async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all", restrictToSources = false) {
|
|
135
|
+
async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all", restrictToSources = false, includeExcludedTypes = false) {
|
|
135
136
|
const hasSearchableTokens = query.length > 0 && sanitizeFtsQuery(query).length > 0;
|
|
137
|
+
// #627 — resolve the default type-exclusion policy. It applies ONLY on the
|
|
138
|
+
// untyped ('any') path and only when the caller did not opt back in via
|
|
139
|
+
// `includeExcludedTypes`. When the config key is ABSENT a built-in default of
|
|
140
|
+
// ['session'] is applied; an explicit empty list disables exclusion.
|
|
141
|
+
const defaultExcludes = searchType === "any" && !includeExcludedTypes ? (config.search?.defaultExcludeTypes ?? ["session"]) : [];
|
|
136
142
|
// Empty queries — including ones that sanitize down to no searchable FTS
|
|
137
143
|
// tokens such as "." — should enumerate matching entries instead of
|
|
138
144
|
// returning an empty result set from FTS.
|
|
139
145
|
if (!hasSearchableTokens) {
|
|
140
146
|
const typeFilter = searchType === "any" ? undefined : searchType;
|
|
141
|
-
const allEntries = getAllEntries(db, typeFilter);
|
|
147
|
+
const allEntries = getAllEntries(db, typeFilter, defaultExcludes);
|
|
142
148
|
// Deduplicate by file path — multiple entries can share the same file
|
|
143
149
|
const seenFilePaths = new Set();
|
|
144
150
|
const uniqueEntries = allEntries.filter((ie) => {
|
|
@@ -187,7 +193,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
187
193
|
const typeFilter = searchType === "any" ? undefined : searchType;
|
|
188
194
|
const tEmbed0 = Date.now();
|
|
189
195
|
const embeddingPromise = tryVecScores(db, query, limit * 3, config);
|
|
190
|
-
const ftsResults = searchFts(db, query, limit * 3, typeFilter);
|
|
196
|
+
const ftsResults = searchFts(db, query, limit * 3, typeFilter, defaultExcludes);
|
|
191
197
|
const embeddingScores = await embeddingPromise;
|
|
192
198
|
const embedMs = Date.now() - tEmbed0;
|
|
193
199
|
const tRank0 = Date.now();
|
|
@@ -208,6 +214,11 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
208
214
|
embedScoreMap,
|
|
209
215
|
getEntryById: (id) => getEntryById(db, id) ?? undefined,
|
|
210
216
|
typeFilter,
|
|
217
|
+
// #627 — also exclude default-hidden types from the vector-only branch so a
|
|
218
|
+
// session asset that is a top-k vector neighbor (but not an FTS match) does
|
|
219
|
+
// not leak into default ('any') results. defaultExcludes is already []
|
|
220
|
+
// unless this is the untyped path without includeExcludedTypes.
|
|
221
|
+
excludeTypes: defaultExcludes,
|
|
211
222
|
});
|
|
212
223
|
// ── Scoring Phase ──────────────────────────────────────────────────────
|
|
213
224
|
// Apply boosts as multiplicative factors (all boosts in a single phase
|
|
@@ -20,6 +20,7 @@ export function normalizeFtsScores(results) {
|
|
|
20
20
|
export function combineSearchScores(options) {
|
|
21
21
|
const FTS_WEIGHT = 0.7;
|
|
22
22
|
const VEC_WEIGHT = 0.3;
|
|
23
|
+
const excludeTypeSet = options.excludeTypes && options.excludeTypes.length > 0 ? new Set(options.excludeTypes) : null;
|
|
23
24
|
const scored = [];
|
|
24
25
|
const seenIds = new Set();
|
|
25
26
|
for (const [id, { score: ftsScore, result }] of options.ftsScoreMap) {
|
|
@@ -42,6 +43,9 @@ export function combineSearchScores(options) {
|
|
|
42
43
|
continue;
|
|
43
44
|
if (options.typeFilter && found.entry.type !== options.typeFilter)
|
|
44
45
|
continue;
|
|
46
|
+
// #627 — drop vector-only neighbors whose type is excluded on the default path.
|
|
47
|
+
if (excludeTypeSet?.has(found.entry.type))
|
|
48
|
+
continue;
|
|
45
49
|
scored.push({
|
|
46
50
|
id,
|
|
47
51
|
entry: found.entry,
|
|
@@ -107,6 +107,16 @@ export class ClaudeCodeProvider {
|
|
|
107
107
|
isAvailable() {
|
|
108
108
|
return fs.existsSync(claudeProjectsDir());
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Directory holding Claude Code's per-project session JSONL files
|
|
112
|
+
* (`~/.claude/projects`, honoring `AKM_CLAUDE_PROJECTS_DIR`). Returns `[]`
|
|
113
|
+
* when the directory does not exist on this machine. See {@link
|
|
114
|
+
* SessionLogHarness.watchRoots}.
|
|
115
|
+
*/
|
|
116
|
+
watchRoots() {
|
|
117
|
+
const dir = claudeProjectsDir();
|
|
118
|
+
return fs.existsSync(dir) ? [dir] : [];
|
|
119
|
+
}
|
|
110
120
|
*readEvents(input) {
|
|
111
121
|
try {
|
|
112
122
|
for (const jsonlPath of this.#walkJsonl(claudeProjectsDir())) {
|
|
@@ -26,6 +26,15 @@ export class OpenCodeProvider {
|
|
|
26
26
|
isAvailable() {
|
|
27
27
|
return fs.existsSync(this.#baseDir);
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Directory holding opencode's per-project session metadata files
|
|
31
|
+
* (`<base>/storage/session`). Returns `[]` when it does not exist on this
|
|
32
|
+
* machine. See {@link SessionLogHarness.watchRoots}.
|
|
33
|
+
*/
|
|
34
|
+
watchRoots() {
|
|
35
|
+
const sessionRoot = path.join(this.#baseDir, "storage", "session");
|
|
36
|
+
return fs.existsSync(sessionRoot) ? [sessionRoot] : [];
|
|
37
|
+
}
|
|
29
38
|
*readEvents(input) {
|
|
30
39
|
// Legacy behavior: stream raw log lines from the top-level dir and `log/`
|
|
31
40
|
// subdirectory. Kept to keep `getExecutionLogCandidates` working without
|
|
@@ -28,6 +28,22 @@ const ERROR_PATTERNS = /error|failed|exception|cannot|undefined|null pointer|ENO
|
|
|
28
28
|
export function getAvailableHarnesses() {
|
|
29
29
|
return HARNESSES.filter((harness) => harness.isAvailable());
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Map each available harness to its `{ harnessName, roots }` watch target,
|
|
33
|
+
* skipping harnesses that expose no roots (absent `watchRoots()` or an empty
|
|
34
|
+
* result). This is the one stable entry point the watcher uses so it never
|
|
35
|
+
* reaches into providers directly.
|
|
36
|
+
*/
|
|
37
|
+
export function getWatchTargets() {
|
|
38
|
+
const targets = [];
|
|
39
|
+
for (const harness of getAvailableHarnesses()) {
|
|
40
|
+
const roots = harness.watchRoots?.() ?? [];
|
|
41
|
+
if (roots.length === 0)
|
|
42
|
+
continue;
|
|
43
|
+
targets.push({ harnessName: harness.name, roots });
|
|
44
|
+
}
|
|
45
|
+
return targets;
|
|
46
|
+
}
|
|
31
47
|
export function normalizeSessionTopic(text) {
|
|
32
48
|
const normalized = text.replace(/\s+/g, " ").trim().toLowerCase();
|
|
33
49
|
if (normalized.length < 10)
|