akm-cli 0.9.0-beta.1 → 0.9.0-beta.11
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 +492 -0
- package/dist/assets/templates/html/default.html +78 -0
- package/dist/assets/templates/html/health.html +732 -0
- package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
- package/dist/cli/shared.js +21 -5
- package/dist/cli.js +47 -5
- package/dist/commands/config-cli.js +0 -10
- package/dist/commands/feedback-cli.js +42 -37
- package/dist/commands/graph/graph.js +75 -71
- package/dist/commands/health/checks.js +48 -0
- package/dist/commands/health/html-report.js +666 -0
- package/dist/commands/health.js +201 -14
- package/dist/commands/improve/consolidate.js +39 -6
- package/dist/commands/improve/distill.js +26 -5
- package/dist/commands/improve/extract-prompt.js +1 -1
- package/dist/commands/improve/extract.js +73 -8
- package/dist/commands/improve/improve-auto-accept.js +63 -3
- package/dist/commands/improve/improve-cli.js +7 -0
- package/dist/commands/improve/improve-profiles.js +4 -0
- package/dist/commands/improve/improve.js +874 -447
- package/dist/commands/improve/proactive-maintenance.js +113 -0
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +31 -0
- package/dist/commands/proposal/drain.js +73 -6
- package/dist/commands/proposal/proposal-cli.js +22 -10
- package/dist/commands/proposal/proposal.js +17 -1
- package/dist/commands/proposal/validators/proposals.js +365 -329
- package/dist/commands/read/curate.js +17 -0
- package/dist/commands/remember.js +6 -2
- package/dist/commands/sources/stash-cli.js +10 -2
- package/dist/commands/tasks/tasks.js +32 -8
- package/dist/core/config/config-schema.js +33 -0
- package/dist/core/logs-db.js +304 -0
- package/dist/core/paths.js +3 -0
- package/dist/core/state-db.js +152 -14
- package/dist/indexer/db/db.js +99 -13
- package/dist/indexer/ensure-index.js +152 -17
- package/dist/indexer/index-writer-lock.js +99 -0
- package/dist/indexer/indexer.js +114 -111
- package/dist/indexer/passes/memory-inference.js +61 -22
- package/dist/integrations/harnesses/claude/session-log.js +17 -5
- package/dist/llm/client.js +38 -4
- package/dist/llm/usage-persist.js +77 -0
- package/dist/llm/usage-telemetry.js +103 -0
- package/dist/output/context.js +3 -2
- package/dist/output/html-render.js +73 -0
- package/dist/output/shapes/helpers.js +17 -1
- package/dist/output/text/helpers.js +69 -1
- package/dist/scripts/migrate-storage.js +154 -25
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +21 -2
- package/dist/sources/providers/tar-utils.js +16 -8
- package/dist/tasks/backends/cron.js +46 -9
- package/dist/tasks/runner.js +99 -16
- package/dist/workflows/db.js +4 -0
- package/package.json +3 -2
- package/dist/commands/config-edit.js +0 -344
package/dist/cli/shared.js
CHANGED
|
@@ -12,6 +12,7 @@ import { stringify as yamlStringify } from "yaml";
|
|
|
12
12
|
import { assertNever } from "../core/assert.js";
|
|
13
13
|
import { AkmError } from "../core/errors.js";
|
|
14
14
|
import { getOutputMode } from "../output/context.js";
|
|
15
|
+
import { DEFAULT_TEMPLATE, deliverRendered, escapeHtml, renderHtml, resolveTemplatePath } from "../output/html-render.js";
|
|
15
16
|
import { shapeForCommand } from "../output/shapes.js";
|
|
16
17
|
import { formatPlain, outputJsonl } from "../output/text.js";
|
|
17
18
|
// ── Exit codes ───────────────────────────────────────────────────────────────
|
|
@@ -111,7 +112,10 @@ export function defineJsonCommand(def) {
|
|
|
111
112
|
});
|
|
112
113
|
}
|
|
113
114
|
/**
|
|
114
|
-
* Render a command result according to the active output mode
|
|
115
|
+
* Render a command result according to the active output mode
|
|
116
|
+
* (json/jsonl/yaml/text/md/html). When `--output <path>` is set, the rendered
|
|
117
|
+
* document is written to that file instead of stdout (jsonl excepted — it is
|
|
118
|
+
* a line-streaming protocol and always goes to stdout).
|
|
115
119
|
*/
|
|
116
120
|
export function output(command, result) {
|
|
117
121
|
const mode = getOutputMode();
|
|
@@ -122,14 +126,14 @@ export function output(command, result) {
|
|
|
122
126
|
}
|
|
123
127
|
switch (mode.format) {
|
|
124
128
|
case "json":
|
|
125
|
-
|
|
129
|
+
deliverRendered(JSON.stringify(shaped, null, 2), mode.outputPath);
|
|
126
130
|
return;
|
|
127
131
|
case "yaml":
|
|
128
|
-
|
|
132
|
+
deliverRendered(yamlStringify(shaped), mode.outputPath);
|
|
129
133
|
return;
|
|
130
134
|
case "text": {
|
|
131
135
|
const plain = formatPlain(command, shaped, mode.detail);
|
|
132
|
-
|
|
136
|
+
deliverRendered(plain ?? JSON.stringify(shaped, null, 2), mode.outputPath);
|
|
133
137
|
return;
|
|
134
138
|
}
|
|
135
139
|
case "md":
|
|
@@ -137,8 +141,20 @@ export function output(command, result) {
|
|
|
137
141
|
// per-run / window-compare table renderings. Commands that don't
|
|
138
142
|
// implement an md renderer fall back to the JSON envelope so
|
|
139
143
|
// pipelines never get an empty stdout.
|
|
140
|
-
|
|
144
|
+
deliverRendered(JSON.stringify(shaped, null, 2), mode.outputPath);
|
|
141
145
|
return;
|
|
146
|
+
case "html": {
|
|
147
|
+
// Generic fallback: render the JSON envelope inside the dark-mode
|
|
148
|
+
// default template. Commands with a bespoke HTML template (`akm health`)
|
|
149
|
+
// intercept before reaching output(), same as the `md` intercept.
|
|
150
|
+
const html = renderHtml(resolveTemplatePath(DEFAULT_TEMPLATE), {
|
|
151
|
+
"%%COMMAND%%": escapeHtml(command),
|
|
152
|
+
"%%CONTENT_JSON%%": escapeHtml(JSON.stringify(shaped, null, 2)),
|
|
153
|
+
"%%GENERATED_AT%%": new Date().toISOString(),
|
|
154
|
+
});
|
|
155
|
+
deliverRendered(html, mode.outputPath);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
142
158
|
}
|
|
143
159
|
}
|
|
144
160
|
/**
|
package/dist/cli.js
CHANGED
|
@@ -88,6 +88,7 @@ import { getCacheDir, getConfigPath, getDbPath } from "./core/paths.js";
|
|
|
88
88
|
import { plainize } from "./core/tty.js";
|
|
89
89
|
import { info, isQuiet, setQuiet, setVerbose, warn } from "./core/warn.js";
|
|
90
90
|
import { getHyphenatedBoolean, getOutputMode, initOutputMode, parseFlagValue } from "./output/context.js";
|
|
91
|
+
import { deliverRendered, renderHtml, resolveTemplatePath } from "./output/html-render.js";
|
|
91
92
|
import { pkgVersion } from "./version.js";
|
|
92
93
|
function applyEarlyStderrFlags(argv) {
|
|
93
94
|
if (argv.includes("--quiet") || argv.includes("-q")) {
|
|
@@ -297,16 +298,47 @@ const healthCommand = defineCommand({
|
|
|
297
298
|
type: "string",
|
|
298
299
|
description: "Explicit comparison window 'name=...,since=ISO,until=ISO' (repeatable, up to 4; mutually exclusive with --window-compare)",
|
|
299
300
|
},
|
|
301
|
+
compare: {
|
|
302
|
+
type: "string",
|
|
303
|
+
description: "Comparison window for the --format html report's trend deltas (default: 24h)",
|
|
304
|
+
},
|
|
300
305
|
},
|
|
301
306
|
async run({ args }) {
|
|
302
307
|
let resultStatus;
|
|
303
|
-
await runWithJsonErrors(() => {
|
|
308
|
+
await runWithJsonErrors(async () => {
|
|
304
309
|
// citty only surfaces the last value of a repeated flag, so read --windows
|
|
305
310
|
// directly from argv to support multi-window comparison.
|
|
306
311
|
const rawWindows = parseAllFlagValues("--windows");
|
|
307
312
|
const windows = rawWindows.length > 0 ? rawWindows.map((raw) => parseWindowSpec(raw)) : undefined;
|
|
308
313
|
const groupBy = args["group-by"];
|
|
309
314
|
const windowCompareRaw = args["window-compare"];
|
|
315
|
+
const mode = getOutputMode();
|
|
316
|
+
// `--format html` is health-specific: render the full HTML health
|
|
317
|
+
// report (charts, KPI cards, advisories) from the bespoke template.
|
|
318
|
+
// Mirrors the `md` intercept below. Two reads, exactly like the
|
|
319
|
+
// retired akm-health-report skill: the canonical per-run window plus a
|
|
320
|
+
// window-compare read for the trend deltas (defaults to 24h,
|
|
321
|
+
// overridable via --compare).
|
|
322
|
+
if (mode.format === "html") {
|
|
323
|
+
// Default the compare window to the report's own `--since` window so the
|
|
324
|
+
// trend deltas are like-for-like (e.g. last 7d vs the prior 7d). A fixed
|
|
325
|
+
// 24h default made a `--since 7d` report compare its 7-day totals against
|
|
326
|
+
// a 24-hour prior window, producing meaningless deltas.
|
|
327
|
+
const compare = args.compare ?? windowCompareRaw ?? args.since ?? "24h";
|
|
328
|
+
const result = akmHealth({ since: args.since, groupBy: "run" });
|
|
329
|
+
resultStatus = result.status;
|
|
330
|
+
const deltas = akmHealth({ since: args.since, windowCompare: compare }).deltas;
|
|
331
|
+
const { buildHealthHtmlReplacements } = await import("./commands/health/html-report.js");
|
|
332
|
+
const { listPendingProposals } = await import("./commands/proposal/proposal.js");
|
|
333
|
+
const replacements = buildHealthHtmlReplacements(result, {
|
|
334
|
+
window: args.since ?? "24h",
|
|
335
|
+
compare,
|
|
336
|
+
proposals: listPendingProposals(),
|
|
337
|
+
deltas,
|
|
338
|
+
});
|
|
339
|
+
deliverRendered(renderHtml(resolveTemplatePath("health"), replacements), mode.outputPath);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
310
342
|
const result = akmHealth({
|
|
311
343
|
since: args.since,
|
|
312
344
|
groupBy: groupBy,
|
|
@@ -317,13 +349,12 @@ const healthCommand = defineCommand({
|
|
|
317
349
|
// `--format md` is health-specific: render a TSV-shaped per-run or
|
|
318
350
|
// window-compare table to stdout instead of going through the JSON
|
|
319
351
|
// envelope. Other modes fall through to the standard output() path.
|
|
320
|
-
const mode = getOutputMode();
|
|
321
352
|
if (mode.format === "md") {
|
|
322
353
|
if (result.windows && result.windows.length > 0) {
|
|
323
|
-
|
|
354
|
+
deliverRendered(renderWindowCompareMd(result.windows, result.deltas), mode.outputPath);
|
|
324
355
|
}
|
|
325
356
|
else if (result.runs) {
|
|
326
|
-
|
|
357
|
+
deliverRendered(renderRunsDetailMd(result.runs), mode.outputPath);
|
|
327
358
|
}
|
|
328
359
|
else {
|
|
329
360
|
output("health", result);
|
|
@@ -419,7 +450,11 @@ export const main = defineCommand({
|
|
|
419
450
|
" 78 config error",
|
|
420
451
|
},
|
|
421
452
|
args: {
|
|
422
|
-
format: { type: "string", description: "Output format (json|jsonl|text|yaml)", default: "json" },
|
|
453
|
+
format: { type: "string", description: "Output format (json|jsonl|text|yaml|md|html)", default: "json" },
|
|
454
|
+
output: {
|
|
455
|
+
type: "string",
|
|
456
|
+
description: "Write rendered output to a file instead of stdout (all formats except jsonl)",
|
|
457
|
+
},
|
|
423
458
|
detail: {
|
|
424
459
|
type: "string",
|
|
425
460
|
description: "Detail level (verbosity): brief|normal|full. Default: brief.",
|
|
@@ -505,6 +540,13 @@ const EXIT_HEALTH_WARN = EXIT_CODES.HEALTH_WARN;
|
|
|
505
540
|
// The wrapper sets `AKM_NODE_ENTRY=1` to opt into the startup block. The test
|
|
506
541
|
// harness never sets it, so importing cli.ts under Bun stays inert as before.
|
|
507
542
|
if (import.meta.main || process.env.AKM_NODE_ENTRY === "1") {
|
|
543
|
+
// Mark that this process is the real akm CLI: its `process.argv[1]` is the
|
|
544
|
+
// akm entrypoint, so the background auto-reindex may safely re-invoke it as a
|
|
545
|
+
// detached child. Hosts that merely import this module (the in-process test
|
|
546
|
+
// harness, library embeddings) never reach this block, so they fall back to
|
|
547
|
+
// an inline reindex instead of spawning the wrong program. See
|
|
548
|
+
// `ensureIndex` in src/indexer/ensure-index.ts.
|
|
549
|
+
process.env.AKM_CLI_ENTRY = "1";
|
|
508
550
|
// citty reads process.argv directly and does not accept a custom argv array,
|
|
509
551
|
// so we must replace process.argv with the normalized version before runMain.
|
|
510
552
|
process.argv = normalizeShowArgv(process.argv);
|
|
@@ -320,16 +320,6 @@ export const configCommand = defineJsonCommand({
|
|
|
320
320
|
}
|
|
321
321
|
},
|
|
322
322
|
}),
|
|
323
|
-
edit: defineJsonCommand({
|
|
324
|
-
meta: {
|
|
325
|
-
name: "edit",
|
|
326
|
-
description: "Interactively edit configuration via a schema-driven menu (TTY only).",
|
|
327
|
-
},
|
|
328
|
-
async run() {
|
|
329
|
-
const { runConfigEdit } = await import("./config-edit.js");
|
|
330
|
-
await runConfigEdit();
|
|
331
|
-
},
|
|
332
|
-
}),
|
|
333
323
|
validate: defineJsonCommand({
|
|
334
324
|
meta: {
|
|
335
325
|
name: "validate",
|
|
@@ -14,6 +14,7 @@ import { appendEvent } from "../core/events.js";
|
|
|
14
14
|
import { warn } from "../core/warn.js";
|
|
15
15
|
import { applyFeedbackToUtilityScore, closeDatabase, findEntryIdByRef, getEntryFilePathById, openExistingDatabase, } from "../indexer/db/db.js";
|
|
16
16
|
import { ensureIndex } from "../indexer/ensure-index.js";
|
|
17
|
+
import { withIndexWriterLease } from "../indexer/index-writer-lock.js";
|
|
17
18
|
import { resolveSourceEntries } from "../indexer/search/search-source.js";
|
|
18
19
|
import { countFeedbackSignals, insertUsageEvent } from "../indexer/usage/usage-events.js";
|
|
19
20
|
// ── Tag validation ────────────────────────────────────────────────────────────
|
|
@@ -203,47 +204,51 @@ export const feedbackCommand = defineCommand({
|
|
|
203
204
|
...(validatedTags.length > 0 ? { tags: validatedTags } : {}),
|
|
204
205
|
};
|
|
205
206
|
const metadataStr = Object.keys(metadataObj).length > 1 ? JSON.stringify(metadataObj) : undefined;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const db = openExistingDatabase();
|
|
213
|
-
try {
|
|
214
|
-
const entryId = findEntryIdByRef(db, ref);
|
|
215
|
-
if (entryId === undefined) {
|
|
216
|
-
throw new UsageError(`Ref "${ref}" is not in the index. ` +
|
|
217
|
-
"Run 'akm search' to verify the asset exists, then 'akm index' if it was recently added.");
|
|
207
|
+
const utilityResult = await withIndexWriterLease({ purpose: "feedback-write" }, async () => {
|
|
208
|
+
// Feedback is itself an index.db writer, so it must not spawn a detached
|
|
209
|
+
// reindex and then compete with it for the same database file.
|
|
210
|
+
const sources = resolveSourceEntries();
|
|
211
|
+
if (sources.length > 0) {
|
|
212
|
+
await ensureIndex(sources[0].path, { mode: "blocking" });
|
|
218
213
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
// For negative signals, the score is adjusted the next time `akm index`
|
|
222
|
-
// runs — the signal is durable in the DB but does NOT suppress ranking
|
|
223
|
-
// in search results until after reindexing.
|
|
224
|
-
insertUsageEvent(db, {
|
|
225
|
-
event_type: "feedback",
|
|
226
|
-
entry_ref: ref,
|
|
227
|
-
entry_id: entryId,
|
|
228
|
-
signal,
|
|
229
|
-
metadata: metadataStr,
|
|
230
|
-
});
|
|
231
|
-
// Apply feedback-derived utility score adjustment immediately so that
|
|
232
|
-
// positive/negative signals influence search ranking without requiring
|
|
233
|
-
// a full reindex. We query the total accumulated feedback counts from
|
|
234
|
-
// usage_events so the delta reflects the entire signal history.
|
|
235
|
-
// Uses MemRL bounded-step EMA (F-5 / #386, arXiv:2601.03192).
|
|
214
|
+
let scopedUtilityResult;
|
|
215
|
+
const db = openExistingDatabase();
|
|
236
216
|
try {
|
|
237
|
-
const
|
|
238
|
-
|
|
217
|
+
const entryId = findEntryIdByRef(db, ref);
|
|
218
|
+
if (entryId === undefined) {
|
|
219
|
+
throw new UsageError(`Ref "${ref}" is not in the index. ` +
|
|
220
|
+
"Run 'akm search' to verify the asset exists, then 'akm index' if it was recently added.");
|
|
221
|
+
}
|
|
222
|
+
// Persist the feedback signal into usage_events. For positive signals,
|
|
223
|
+
// the EMA utility score is updated immediately on the next read path.
|
|
224
|
+
// For negative signals, the score is adjusted the next time `akm index`
|
|
225
|
+
// runs — the signal is durable in the DB but does NOT suppress ranking
|
|
226
|
+
// in search results until after reindexing.
|
|
227
|
+
insertUsageEvent(db, {
|
|
228
|
+
event_type: "feedback",
|
|
229
|
+
entry_ref: ref,
|
|
230
|
+
entry_id: entryId,
|
|
231
|
+
signal,
|
|
232
|
+
metadata: metadataStr,
|
|
233
|
+
});
|
|
234
|
+
// Apply feedback-derived utility score adjustment immediately so that
|
|
235
|
+
// positive/negative signals influence search ranking without requiring
|
|
236
|
+
// a full reindex. We query the total accumulated feedback counts from
|
|
237
|
+
// usage_events so the delta reflects the entire signal history.
|
|
238
|
+
// Uses MemRL bounded-step EMA (F-5 / #386, arXiv:2601.03192).
|
|
239
|
+
try {
|
|
240
|
+
const { pos, neg } = countFeedbackSignals(db, entryId);
|
|
241
|
+
scopedUtilityResult = applyFeedbackToUtilityScore(db, entryId, pos, neg);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// best-effort — feedback recording succeeds even if utility update fails
|
|
245
|
+
}
|
|
239
246
|
}
|
|
240
|
-
|
|
241
|
-
|
|
247
|
+
finally {
|
|
248
|
+
closeDatabase(db);
|
|
242
249
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
closeDatabase(db);
|
|
246
|
-
}
|
|
250
|
+
return scopedUtilityResult;
|
|
251
|
+
});
|
|
247
252
|
appendEvent({
|
|
248
253
|
eventType: "feedback",
|
|
249
254
|
ref,
|
|
@@ -12,6 +12,7 @@ import { closeDatabase, findEntryIdByRef, getEntryById, getEntryRefRowsForStashR
|
|
|
12
12
|
import { loadStoredGraphSnapshot } from "../../indexer/db/graph-db.js";
|
|
13
13
|
import { listRelatedPathsForFile } from "../../indexer/graph/graph-boost.js";
|
|
14
14
|
import { runGraphExtractionPass } from "../../indexer/graph/graph-extraction.js";
|
|
15
|
+
import { withIndexWriterLease } from "../../indexer/index-writer-lock.js";
|
|
15
16
|
import { lookup } from "../../indexer/indexer.js";
|
|
16
17
|
import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js";
|
|
17
18
|
import { resolveAssetPath } from "../../indexer/walk/path-resolver.js";
|
|
@@ -375,85 +376,88 @@ export async function akmGraphUpdate(options) {
|
|
|
375
376
|
}
|
|
376
377
|
}
|
|
377
378
|
const scoped = Array.isArray(options.refs) && options.refs.length > 0;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
379
|
+
return withIndexWriterLease({ purpose: "graph-update" }, async () => {
|
|
380
|
+
let candidatePaths;
|
|
381
|
+
if (scoped && options.refs) {
|
|
382
|
+
// Resolve each ref to an absolute file path while the writer lease is held
|
|
383
|
+
// so the scoped graph write sees the same index snapshot it resolved from.
|
|
384
|
+
const dbPath = getDbPath();
|
|
385
|
+
let db;
|
|
386
|
+
const resolvedPaths = new Set();
|
|
387
|
+
try {
|
|
388
|
+
db = openDatabase(dbPath);
|
|
389
|
+
for (const ref of options.refs) {
|
|
390
|
+
const trimmed = ref.trim();
|
|
391
|
+
if (!trimmed)
|
|
392
|
+
continue;
|
|
393
|
+
const entryId = findEntryIdByRef(db, trimmed);
|
|
394
|
+
if (entryId === undefined) {
|
|
395
|
+
warn(`[graph] ref not found in index, skipping: ${trimmed}`);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const row = getEntryById(db, entryId);
|
|
399
|
+
if (!row?.filePath) {
|
|
400
|
+
warn(`[graph] could not resolve path for ref, skipping: ${trimmed}`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
resolvedPaths.add(row.filePath);
|
|
394
404
|
}
|
|
395
|
-
const row = getEntryById(db, entryId);
|
|
396
|
-
if (!row?.filePath) {
|
|
397
|
-
warn(`[graph] could not resolve path for ref, skipping: ${trimmed}`);
|
|
398
|
-
continue;
|
|
399
|
-
}
|
|
400
|
-
resolvedPaths.add(row.filePath);
|
|
401
405
|
}
|
|
406
|
+
finally {
|
|
407
|
+
if (db)
|
|
408
|
+
closeDatabase(db);
|
|
409
|
+
}
|
|
410
|
+
if (resolvedPaths.size === 0) {
|
|
411
|
+
warn("[graph] none of the provided refs resolved to indexed paths — no extraction performed.");
|
|
412
|
+
return {
|
|
413
|
+
shape: "graph-update",
|
|
414
|
+
ok: true,
|
|
415
|
+
filesExtracted: 0,
|
|
416
|
+
entitiesUpserted: 0,
|
|
417
|
+
relationsUpserted: 0,
|
|
418
|
+
durationMs: 0,
|
|
419
|
+
scoped: true,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
candidatePaths = resolvedPaths;
|
|
402
423
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
424
|
+
const extractionFn = options.graphExtractionFn ?? runGraphExtractionPass;
|
|
425
|
+
const passOptions = candidatePaths ? { candidatePaths } : {};
|
|
426
|
+
let db;
|
|
427
|
+
const startMs = Date.now();
|
|
428
|
+
try {
|
|
429
|
+
db = openDatabase(getDbPath());
|
|
430
|
+
const onProgress = (event) => {
|
|
431
|
+
if (!event.currentPath)
|
|
432
|
+
return;
|
|
433
|
+
const file = path.basename(event.currentPath);
|
|
434
|
+
warn(`[graph] extracting ${event.processed}/${event.total} ${file}`);
|
|
435
|
+
};
|
|
436
|
+
const result = await extractionFn({
|
|
437
|
+
config,
|
|
438
|
+
sources,
|
|
439
|
+
signal: undefined,
|
|
440
|
+
db,
|
|
441
|
+
reEnrich: false,
|
|
442
|
+
onProgress,
|
|
443
|
+
options: passOptions,
|
|
444
|
+
});
|
|
445
|
+
const durationMs = Date.now() - startMs;
|
|
409
446
|
return {
|
|
410
447
|
shape: "graph-update",
|
|
411
448
|
ok: true,
|
|
412
|
-
filesExtracted:
|
|
413
|
-
entitiesUpserted:
|
|
414
|
-
relationsUpserted:
|
|
415
|
-
durationMs
|
|
416
|
-
scoped
|
|
449
|
+
filesExtracted: result.quality.extractedFiles,
|
|
450
|
+
entitiesUpserted: result.quality.entityCount,
|
|
451
|
+
relationsUpserted: result.quality.relationCount,
|
|
452
|
+
durationMs,
|
|
453
|
+
scoped,
|
|
417
454
|
};
|
|
418
455
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const startMs = Date.now();
|
|
425
|
-
try {
|
|
426
|
-
db = openDatabase(getDbPath());
|
|
427
|
-
const onProgress = (event) => {
|
|
428
|
-
if (!event.currentPath)
|
|
429
|
-
return;
|
|
430
|
-
const file = path.basename(event.currentPath);
|
|
431
|
-
warn(`[graph] extracting ${event.processed}/${event.total} ${file}`);
|
|
432
|
-
};
|
|
433
|
-
const result = await extractionFn({
|
|
434
|
-
config,
|
|
435
|
-
sources,
|
|
436
|
-
signal: undefined,
|
|
437
|
-
db,
|
|
438
|
-
reEnrich: false,
|
|
439
|
-
onProgress,
|
|
440
|
-
options: passOptions,
|
|
441
|
-
});
|
|
442
|
-
const durationMs = Date.now() - startMs;
|
|
443
|
-
return {
|
|
444
|
-
shape: "graph-update",
|
|
445
|
-
ok: true,
|
|
446
|
-
filesExtracted: result.quality.extractedFiles,
|
|
447
|
-
entitiesUpserted: result.quality.entityCount,
|
|
448
|
-
relationsUpserted: result.quality.relationCount,
|
|
449
|
-
durationMs,
|
|
450
|
-
scoped,
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
finally {
|
|
454
|
-
if (db)
|
|
455
|
-
closeDatabase(db);
|
|
456
|
-
}
|
|
456
|
+
finally {
|
|
457
|
+
if (db)
|
|
458
|
+
closeDatabase(db);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
457
461
|
}
|
|
458
462
|
async function resolveGraphTarget(ref, source) {
|
|
459
463
|
const parsedRef = parseAssetRef(ref);
|
|
@@ -257,6 +257,54 @@ export const HEALTH_CHECKS = [
|
|
|
257
257
|
};
|
|
258
258
|
},
|
|
259
259
|
},
|
|
260
|
+
{
|
|
261
|
+
// #603: pool-saturation advisory. The raw `sessionsScanned` count fired on
|
|
262
|
+
// normal cadence changes (the Jun 12 false alarm). Instead track the ratio
|
|
263
|
+
// of NEW (unseen) sessions to the total session pool extract evaluated in
|
|
264
|
+
// the window: a low ratio is the *expected* steady state, only a near-zero
|
|
265
|
+
// ratio signals a possible discovery/dedup bug.
|
|
266
|
+
//
|
|
267
|
+
// unseen ≈ `sessionsScanned` (extract only processes new sessions; already-
|
|
268
|
+
// seen ones are deduped into `sessionsSkipped`). total = scanned + skipped.
|
|
269
|
+
// This is a heuristic approximation — `sessionsSkipped` also folds in
|
|
270
|
+
// too-short skips — so the check is informational and never gates status.
|
|
271
|
+
name: "pool-saturation",
|
|
272
|
+
channel: "advisory",
|
|
273
|
+
run: (ctx) => {
|
|
274
|
+
const sx = ctx.sessionExtraction;
|
|
275
|
+
const total = sx.sessionsScanned + sx.sessionsSkipped;
|
|
276
|
+
const unseen = sx.sessionsScanned;
|
|
277
|
+
const ratio = total > 0 ? unseen / total : null;
|
|
278
|
+
const pct = ratio === null ? null : Math.round(ratio * 1000) / 10;
|
|
279
|
+
let status = "pass";
|
|
280
|
+
let confidence = "low";
|
|
281
|
+
let message;
|
|
282
|
+
if (!sx.ran || ratio === null) {
|
|
283
|
+
message = "Pool saturation: no extract activity in the window — no signal.";
|
|
284
|
+
}
|
|
285
|
+
else if (ratio < 0.02) {
|
|
286
|
+
status = "warn";
|
|
287
|
+
confidence = "medium";
|
|
288
|
+
message = `Session pool near-exhausted: only ${pct}% of the ${total}-session pool was new (<2%). Possible discovery/dedup bug — verify extract is still finding new sessions.`;
|
|
289
|
+
}
|
|
290
|
+
else if (ratio < 0.1) {
|
|
291
|
+
confidence = "medium";
|
|
292
|
+
message = `Session pool saturation: ${pct}% of ${total} sessions were new (<10%, steady-state expected — informational).`;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
confidence = "medium";
|
|
296
|
+
message = `Session pool healthy: ${pct}% of ${total} sessions were new.`;
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
name: "pool-saturation",
|
|
300
|
+
kind: "heuristic",
|
|
301
|
+
status,
|
|
302
|
+
confidence,
|
|
303
|
+
message,
|
|
304
|
+
evidence: { totalSessions: total, unseenSessions: unseen, saturationRatio: ratio },
|
|
305
|
+
};
|
|
306
|
+
},
|
|
307
|
+
},
|
|
260
308
|
{
|
|
261
309
|
name: "auto-accept-validation",
|
|
262
310
|
channel: "advisory",
|