akm-cli 0.9.0-beta.3 → 0.9.0-beta.31
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 +613 -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 +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 +634 -111
- package/dist/commands/improve/dedup.js +482 -0
- package/dist/commands/improve/distill.js +145 -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 +33 -2
- package/dist/commands/improve/extract-watch.js +140 -0
- package/dist/commands/improve/extract.js +244 -35
- 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 +1974 -614
- 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 +87 -0
- package/dist/commands/improve/procedural.js +409 -0
- package/dist/commands/improve/recombine.js +593 -0
- package/dist/commands/improve/reflect.js +26 -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/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/proposal.js +5 -0
- package/dist/commands/proposal/validators/proposals.js +71 -54
- 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/installed-stashes.js +5 -1
- package/dist/commands/sources/stash-cli.js +10 -2
- 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/config/config-schema.js +259 -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/state-db.js +649 -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 +1 -0
- package/dist/integrations/harnesses/claude/session-log.js +11 -1
- package/dist/integrations/harnesses/opencode/session-log.js +9 -0
- 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/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 +9 -0
- package/dist/runtime.js +25 -1
- package/dist/scripts/migrate-storage.js +1242 -594
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +473 -270
- package/dist/sources/providers/tar-utils.js +16 -8
- 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 +9 -6
|
@@ -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
|
}
|
|
@@ -40,13 +40,14 @@ import path from "node:path";
|
|
|
40
40
|
import { TYPE_DIRS } from "../../core/asset/asset-spec.js";
|
|
41
41
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
42
42
|
import { concurrentMap } from "../../core/concurrent.js";
|
|
43
|
-
import { getIndexPassConfig, resolveBatchSize } from "../../core/config/config.js";
|
|
43
|
+
import { getIndexPassConfig, loadConfig, resolveBatchSize } from "../../core/config/config.js";
|
|
44
|
+
import { rethrowIfTestIsolationError } from "../../core/errors.js";
|
|
44
45
|
import { warn, warnVerbose } from "../../core/warn.js";
|
|
45
46
|
import { isProcessEnabled } from "../../llm/feature-gate.js";
|
|
46
47
|
import * as graphExtract from "../../llm/graph-extract.js";
|
|
47
48
|
import { resolveIndexPassLLM } from "../../llm/index-passes.js";
|
|
48
49
|
import { computeBodyHash, GRAPH_SCHEMA_VERSION, getLlmCacheEntriesByRefs, getLlmCacheEntry, upsertLlmCacheEntry, } from "../db/db.js";
|
|
49
|
-
import { loadStoredGraphSnapshot, replaceStoredGraph } from "../db/graph-db.js";
|
|
50
|
+
import { drainExtractionQueue, loadStoredGraphSnapshot, replaceStoredGraph } from "../db/graph-db.js";
|
|
50
51
|
import { walkMarkdownFiles } from "../walk/walker.js";
|
|
51
52
|
import { deduplicateGraph } from "./graph-dedup.js";
|
|
52
53
|
/** Schema version for the persisted artifact — bumps trigger a full rebuild. */
|
|
@@ -92,6 +93,12 @@ function computeGraphQualityTelemetry(consideredFiles, extractedFiles, entityCou
|
|
|
92
93
|
};
|
|
93
94
|
}
|
|
94
95
|
export const DEFAULT_GRAPH_EXTRACTION_INCLUDE_TYPES = ["memory", "knowledge"];
|
|
96
|
+
/**
|
|
97
|
+
* Max number of lazy-extraction queue rows drained per pass (#624-P3). Bounds
|
|
98
|
+
* per-run work so a large backlog is spread across runs rather than processed
|
|
99
|
+
* all at once. Generous default — the queue is normally near-empty.
|
|
100
|
+
*/
|
|
101
|
+
const GRAPH_EXTRACTION_QUEUE_DRAIN_LIMIT = 100;
|
|
95
102
|
const SUPPORTED_GRAPH_EXTRACTION_INCLUDE_TYPES = new Set([
|
|
96
103
|
"memory",
|
|
97
104
|
"knowledge",
|
|
@@ -291,8 +298,29 @@ export async function runGraphExtractionPass(ctx) {
|
|
|
291
298
|
warnVerbose("graph extraction: skipped because no primary stash source is available.");
|
|
292
299
|
return { ...EMPTY_RESULT };
|
|
293
300
|
}
|
|
301
|
+
// #624-P3: drain the lazy-extraction queue BEFORE the ranked sweep, highest
|
|
302
|
+
// priority first. Queued paths are extracted individually (per-file merge,
|
|
303
|
+
// other files untouched) so they are processed even when they fall outside
|
|
304
|
+
// the normal candidate set. Default (empty queue) is a byte-identical no-op:
|
|
305
|
+
// drainExtractionQueue returns [] and the loop body never runs.
|
|
306
|
+
if (db) {
|
|
307
|
+
const drained = drainExtractionQueue(db, primary.path, GRAPH_EXTRACTION_QUEUE_DRAIN_LIMIT);
|
|
308
|
+
for (const queued of drained) {
|
|
309
|
+
if (signal?.aborted)
|
|
310
|
+
break;
|
|
311
|
+
await extractGraphForSingleFile(db, primary.path, queued.filePath, queued.bodyHash, { config, signal });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
294
314
|
const includeTypes = getGraphExtractionIncludeTypes(config);
|
|
295
|
-
|
|
315
|
+
let eligible = collectEligibleFiles(primary.path, includeTypes).filter((candidate) => !options.candidatePaths || options.candidatePaths.has(candidate.absPath));
|
|
316
|
+
// P2 (#624): when topN is set and a DB is available, rank the (already
|
|
317
|
+
// candidate-filtered) eligible set by utility_scores DESC and keep only the
|
|
318
|
+
// top-N. Default (topN unset) is byte-identical to today — no ranking query
|
|
319
|
+
// is issued and the eligible set is untouched. Ranking composes WITH the
|
|
320
|
+
// candidatePaths filter: scoped-then-ranked-then-sliced.
|
|
321
|
+
if (db && options.topN != null && options.topN >= 0) {
|
|
322
|
+
eligible = rankCandidatesByUtility(db, eligible, primary.path).slice(0, options.topN);
|
|
323
|
+
}
|
|
296
324
|
const considered = eligible.length;
|
|
297
325
|
if (considered === 0) {
|
|
298
326
|
const scoped = options.candidatePaths ? ` matching ${options.candidatePaths.size} candidate path(s)` : "";
|
|
@@ -628,6 +656,178 @@ export async function runGraphExtractionPass(ctx) {
|
|
|
628
656
|
warnings,
|
|
629
657
|
};
|
|
630
658
|
}
|
|
659
|
+
/**
|
|
660
|
+
* Infer the asset type (`memory`, `knowledge`, …) for a path from the stash
|
|
661
|
+
* directory segment it lives under. Returns the matching include-type, or
|
|
662
|
+
* `undefined` when the path is not under a known graph-eligible type dir.
|
|
663
|
+
*/
|
|
664
|
+
function inferGraphTypeForPath(stashRoot, absPath) {
|
|
665
|
+
const rel = path.relative(stashRoot, absPath);
|
|
666
|
+
const firstSeg = rel.split(path.sep)[0];
|
|
667
|
+
if (!firstSeg)
|
|
668
|
+
return undefined;
|
|
669
|
+
for (const type of SUPPORTED_GRAPH_EXTRACTION_INCLUDE_TYPES) {
|
|
670
|
+
if (TYPE_DIRS[type] === firstSeg)
|
|
671
|
+
return type;
|
|
672
|
+
}
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* #624-P3 — extract graph data for a SINGLE file and merge it into the stored
|
|
677
|
+
* graph WITHOUT clobbering other files' rows.
|
|
678
|
+
*
|
|
679
|
+
* Re-reads the body from disk at call time (the queued body_hash is NOT trusted
|
|
680
|
+
* blindly — the file may have been deleted or changed since enqueue) and skips
|
|
681
|
+
* silently (returns `false`) when the file is gone or empty. Resolves the LLM
|
|
682
|
+
* via {@link resolveIndexPassLLM} (model-available guard: returns `false` when
|
|
683
|
+
* no provider is configured) UNLESS `opts.llmOverride` is supplied, in which
|
|
684
|
+
* case the override is the extractor seam (used by tests and by callers that
|
|
685
|
+
* already hold a resolved model).
|
|
686
|
+
*
|
|
687
|
+
* Returns `true` when a graph row was written for the file, `false` on any
|
|
688
|
+
* skip (missing file, empty body, unknown type, no model, or extraction error).
|
|
689
|
+
*/
|
|
690
|
+
export async function extractGraphForSingleFile(db, stashRoot, filePath, bodyHash, opts) {
|
|
691
|
+
try {
|
|
692
|
+
// Re-read from disk — never trust a stale queued body.
|
|
693
|
+
let raw;
|
|
694
|
+
try {
|
|
695
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
return false; // file gone / unreadable → silent skip
|
|
699
|
+
}
|
|
700
|
+
const parsed = parseFrontmatter(raw);
|
|
701
|
+
const body = parsed.content.trim();
|
|
702
|
+
if (!body)
|
|
703
|
+
return false;
|
|
704
|
+
const type = inferGraphTypeForPath(stashRoot, filePath) ?? "memory";
|
|
705
|
+
const effectiveHash = bodyHash ?? computeBodyHash(body);
|
|
706
|
+
// Extract — via the injected seam, or the real per-asset path.
|
|
707
|
+
let extraction;
|
|
708
|
+
if (opts?.llmOverride) {
|
|
709
|
+
const out = await opts.llmOverride(body);
|
|
710
|
+
extraction = {
|
|
711
|
+
entities: out.entities,
|
|
712
|
+
relations: out.relations,
|
|
713
|
+
...(out.confidence !== undefined ? { confidence: out.confidence } : {}),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
const config = opts?.config ?? loadConfig();
|
|
718
|
+
if (!isProcessEnabled("index", "graph_extraction", config))
|
|
719
|
+
return false;
|
|
720
|
+
const llmConfig = resolveIndexPassLLM("graph", config);
|
|
721
|
+
if (!llmConfig)
|
|
722
|
+
return false; // model-available guard
|
|
723
|
+
const result = await graphExtract.extractGraphFromBody(llmConfig, body, opts?.signal, config);
|
|
724
|
+
extraction = {
|
|
725
|
+
entities: result.entities,
|
|
726
|
+
relations: result.relations,
|
|
727
|
+
...(result.confidence !== undefined ? { confidence: result.confidence } : {}),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
const entities = [...new Set(extraction.entities.map((e) => e.trim()).filter(Boolean))];
|
|
731
|
+
const relations = extraction.relations
|
|
732
|
+
.map((r) => ({
|
|
733
|
+
from: r.from.trim(),
|
|
734
|
+
to: r.to.trim(),
|
|
735
|
+
...(r.type ? { type: r.type.trim() } : {}),
|
|
736
|
+
...(normalizeConfidence(r.confidence) !== undefined ? { confidence: normalizeConfidence(r.confidence) } : {}),
|
|
737
|
+
}))
|
|
738
|
+
.filter((r) => r.from && r.to);
|
|
739
|
+
const node = {
|
|
740
|
+
path: filePath,
|
|
741
|
+
type,
|
|
742
|
+
bodyHash: effectiveHash,
|
|
743
|
+
entities,
|
|
744
|
+
relations,
|
|
745
|
+
...(normalizeConfidence(extraction.confidence) !== undefined
|
|
746
|
+
? { confidence: normalizeConfidence(extraction.confidence) }
|
|
747
|
+
: {}),
|
|
748
|
+
status: entities.length > 0 ? "extracted" : "empty",
|
|
749
|
+
reason: entities.length > 0 ? "none" : "no_graph_content",
|
|
750
|
+
extractionRunId: crypto.randomUUID(),
|
|
751
|
+
};
|
|
752
|
+
// Merge with the previously-stored nodes, scoping the refresh to JUST this
|
|
753
|
+
// path so other files' rows are preserved (and graph_meta counts refresh).
|
|
754
|
+
const previousGraph = loadGraphFile(stashRoot, db);
|
|
755
|
+
const candidatePaths = new Set([filePath]);
|
|
756
|
+
const mergedNodes = mergeGraphNodes(previousGraph.files, [node], candidatePaths);
|
|
757
|
+
const assetRefs = mergedNodes.map((n) => n.path);
|
|
758
|
+
const deduped = deduplicateGraph(mergedNodes.map((n) => ({ entities: n.entities, relations: n.relations })), assetRefs);
|
|
759
|
+
const qualityExtracted = mergedNodes.filter((n) => n.status === "extracted" && n.entities.length > 0).length;
|
|
760
|
+
const quality = computeGraphQualityTelemetry(mergedNodes.length, qualityExtracted, deduped.entities.length, deduped.relations.length);
|
|
761
|
+
const graph = {
|
|
762
|
+
schemaVersion: GRAPH_FILE_SCHEMA_VERSION,
|
|
763
|
+
generatedAt: new Date().toISOString(),
|
|
764
|
+
stashRoot,
|
|
765
|
+
files: mergedNodes,
|
|
766
|
+
entities: deduped.entities,
|
|
767
|
+
relations: deduped.relations,
|
|
768
|
+
quality,
|
|
769
|
+
...(previousGraph.telemetry ? { telemetry: previousGraph.telemetry } : {}),
|
|
770
|
+
};
|
|
771
|
+
return writeGraphFile(stashRoot, graph, db);
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
rethrowIfTestIsolationError(err);
|
|
775
|
+
return false;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// ── Eligible-file detection ─────────────────────────────────────────────────
|
|
779
|
+
/**
|
|
780
|
+
* Rank eligible graph-extraction candidates by their entry `utility_scores`,
|
|
781
|
+
* highest first, for the incremental high-signal-first sweep (P2 of #624).
|
|
782
|
+
*
|
|
783
|
+
* The join is READ-ONLY (`entries.file_path = candidate.absPath`, then
|
|
784
|
+
* `entries.id -> utility_scores.entry_id`) and does NOT re-couple the graph
|
|
785
|
+
* rows to `entries`. It reads the GLOBAL `utility_scores` table (not the
|
|
786
|
+
* per-scope `utility_scores_scoped`), so ranking is corpus-wide; `stashRoot`
|
|
787
|
+
* is accepted for call-site symmetry/future scoping but is not used to filter
|
|
788
|
+
* (the global table has no `stash_root` column).
|
|
789
|
+
*
|
|
790
|
+
* Candidates with no matching `entries` row, or an entry with no
|
|
791
|
+
* `utility_scores` row, get an effective utility of 0 (LEFT JOIN + COALESCE)
|
|
792
|
+
* and sort LAST — they are deprioritized, never dropped, so a `topN >= total`
|
|
793
|
+
* slice still includes them and they remain reachable on later runs.
|
|
794
|
+
*
|
|
795
|
+
* Ties (equal utility) break by `file_path` ASC for deterministic output.
|
|
796
|
+
* Returns a NEW array; the input is not mutated. SQLite's ~999 bound-parameter
|
|
797
|
+
* cap is respected by chunking the `IN (...)` lookup at 500.
|
|
798
|
+
*
|
|
799
|
+
* Exported for direct unit testing.
|
|
800
|
+
*/
|
|
801
|
+
export function rankCandidatesByUtility(db, candidates, _stashRoot) {
|
|
802
|
+
// Cannot rank without a DB → return the input unranked rather than throw.
|
|
803
|
+
// Keeps the DB-less code path (reuse-from-memory) working when topN is set.
|
|
804
|
+
if (!db || candidates.length === 0)
|
|
805
|
+
return candidates;
|
|
806
|
+
const utilityByPath = new Map();
|
|
807
|
+
const CHUNK = 500;
|
|
808
|
+
for (let start = 0; start < candidates.length; start += CHUNK) {
|
|
809
|
+
const chunk = candidates.slice(start, start + CHUNK);
|
|
810
|
+
const paths = chunk.map((c) => c.absPath);
|
|
811
|
+
const placeholders = paths.map(() => "?").join(", ");
|
|
812
|
+
const rows = db
|
|
813
|
+
.prepare(`SELECT e.file_path AS file_path, COALESCE(MAX(u.utility), 0) AS utility
|
|
814
|
+
FROM entries e
|
|
815
|
+
LEFT JOIN utility_scores u ON u.entry_id = e.id
|
|
816
|
+
WHERE e.file_path IN (${placeholders})
|
|
817
|
+
GROUP BY e.file_path`)
|
|
818
|
+
.all(...paths);
|
|
819
|
+
for (const row of rows) {
|
|
820
|
+
utilityByPath.set(row.file_path, row.utility ?? 0);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
return [...candidates].sort((a, b) => {
|
|
824
|
+
const ua = utilityByPath.get(a.absPath) ?? 0;
|
|
825
|
+
const ub = utilityByPath.get(b.absPath) ?? 0;
|
|
826
|
+
if (ub !== ua)
|
|
827
|
+
return ub - ua; // utility DESC
|
|
828
|
+
return a.absPath < b.absPath ? -1 : a.absPath > b.absPath ? 1 : 0; // tie-break: path ASC
|
|
829
|
+
});
|
|
830
|
+
}
|
|
631
831
|
/**
|
|
632
832
|
* Scan the primary stash for `memory:` and `knowledge:` markdown files
|
|
633
833
|
* suitable for graph extraction. The directory layout convention is the
|
|
@@ -0,0 +1,99 @@
|
|
|
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
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { probeLock, releaseLock, releaseLockIfOwned, tryAcquireLockSync } from "../core/file-lock.js";
|
|
7
|
+
import { getDbPath, getIndexWriterLockPath } from "../core/paths.js";
|
|
8
|
+
const INDEX_WRITER_LOCK_STALE_AFTER_MS = 12 * 60 * 60 * 1000;
|
|
9
|
+
const INDEX_WRITER_WAIT_MS = 100;
|
|
10
|
+
const heldLocks = new Map();
|
|
11
|
+
function buildPayload(purpose, pid = process.pid) {
|
|
12
|
+
return JSON.stringify({
|
|
13
|
+
pid,
|
|
14
|
+
purpose,
|
|
15
|
+
dbPath: getDbPath(),
|
|
16
|
+
startedAt: new Date().toISOString(),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function delay(ms) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
function throwIfAborted(signal) {
|
|
23
|
+
if (!signal?.aborted)
|
|
24
|
+
return;
|
|
25
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("index writer wait aborted");
|
|
26
|
+
}
|
|
27
|
+
function releaseHeldLock(lockPath) {
|
|
28
|
+
const held = heldLocks.get(lockPath);
|
|
29
|
+
if (!held)
|
|
30
|
+
return;
|
|
31
|
+
held.depth -= 1;
|
|
32
|
+
if (held.depth > 0)
|
|
33
|
+
return;
|
|
34
|
+
heldLocks.delete(lockPath);
|
|
35
|
+
process.off("exit", held.exitHandler);
|
|
36
|
+
releaseLockIfOwned(lockPath, process.pid);
|
|
37
|
+
}
|
|
38
|
+
function retainHeldLock(lockPath) {
|
|
39
|
+
const existing = heldLocks.get(lockPath);
|
|
40
|
+
if (existing) {
|
|
41
|
+
existing.depth += 1;
|
|
42
|
+
return { lockPath, release: () => releaseHeldLock(lockPath) };
|
|
43
|
+
}
|
|
44
|
+
const exitHandler = () => releaseLockIfOwned(lockPath, process.pid);
|
|
45
|
+
process.on("exit", exitHandler);
|
|
46
|
+
heldLocks.set(lockPath, { depth: 1, exitHandler });
|
|
47
|
+
return { lockPath, release: () => releaseHeldLock(lockPath) };
|
|
48
|
+
}
|
|
49
|
+
function detachHeldLock(lockPath) {
|
|
50
|
+
const held = heldLocks.get(lockPath);
|
|
51
|
+
if (!held)
|
|
52
|
+
return;
|
|
53
|
+
heldLocks.delete(lockPath);
|
|
54
|
+
process.off("exit", held.exitHandler);
|
|
55
|
+
}
|
|
56
|
+
export async function acquireIndexWriterLease(options) {
|
|
57
|
+
const mode = options.mode ?? "wait";
|
|
58
|
+
const lockPath = getIndexWriterLockPath();
|
|
59
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
60
|
+
if (heldLocks.has(lockPath)) {
|
|
61
|
+
return retainHeldLock(lockPath);
|
|
62
|
+
}
|
|
63
|
+
while (true) {
|
|
64
|
+
throwIfAborted(options.signal);
|
|
65
|
+
if (tryAcquireLockSync(lockPath, buildPayload(options.purpose))) {
|
|
66
|
+
return retainHeldLock(lockPath);
|
|
67
|
+
}
|
|
68
|
+
const probe = probeLock(lockPath, { staleAfterMs: INDEX_WRITER_LOCK_STALE_AFTER_MS });
|
|
69
|
+
if (probe.state === "held" && probe.holderPid === process.pid) {
|
|
70
|
+
return retainHeldLock(lockPath);
|
|
71
|
+
}
|
|
72
|
+
if (probe.state === "stale") {
|
|
73
|
+
releaseLock(lockPath);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (mode === "try")
|
|
77
|
+
return undefined;
|
|
78
|
+
await delay(INDEX_WRITER_WAIT_MS);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function withIndexWriterLease(options, run) {
|
|
82
|
+
const lease = await acquireIndexWriterLease(options);
|
|
83
|
+
if (!lease) {
|
|
84
|
+
throw new Error(`index writer lease unavailable for ${options.purpose}`);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return await run();
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
lease.release();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function handoffIndexWriterLeaseToPid(lease, pid, purpose) {
|
|
94
|
+
fs.writeFileSync(lease.lockPath, buildPayload(purpose, pid), "utf8");
|
|
95
|
+
detachHeldLock(lease.lockPath);
|
|
96
|
+
}
|
|
97
|
+
export function probeIndexWriterLease() {
|
|
98
|
+
return probeLock(getIndexWriterLockPath(), { staleAfterMs: INDEX_WRITER_LOCK_STALE_AFTER_MS });
|
|
99
|
+
}
|