akm-cli 0.7.5 → 0.8.0-rc1
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/.github/CHANGELOG.md +1 -1
- package/dist/cli/parse-args.js +43 -0
- package/dist/cli.js +804 -461
- package/dist/commands/agent-dispatch.js +102 -0
- package/dist/commands/agent-support.js +62 -0
- package/dist/commands/config-cli.js +68 -84
- package/dist/commands/consolidate.js +823 -0
- package/dist/commands/distill-promotion-policy.js +658 -0
- package/dist/commands/distill.js +244 -52
- package/dist/commands/eval-cases.js +40 -0
- package/dist/commands/events.js +2 -23
- package/dist/commands/graph.js +222 -0
- package/dist/commands/health.js +376 -0
- package/dist/commands/help/help-accept.md +9 -0
- package/dist/commands/help/help-improve.md +53 -0
- package/dist/commands/help/help-proposals.md +15 -0
- package/dist/commands/help/help-propose.md +17 -0
- package/dist/commands/help/help-reject.md +8 -0
- package/dist/commands/history.js +3 -30
- package/dist/commands/improve.js +1170 -0
- package/dist/commands/info.js +2 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/install-audit.js +5 -1
- package/dist/commands/installed-stashes.js +118 -138
- package/dist/commands/knowledge.js +133 -0
- package/dist/commands/lint/agent-linter.js +46 -0
- package/dist/commands/lint/base-linter.js +251 -0
- package/dist/commands/lint/command-linter.js +46 -0
- package/dist/commands/lint/default-linter.js +13 -0
- package/dist/commands/lint/index.js +107 -0
- package/dist/commands/lint/knowledge-linter.js +13 -0
- package/dist/commands/lint/memory-linter.js +58 -0
- package/dist/commands/lint/registry.js +33 -0
- package/dist/commands/lint/skill-linter.js +42 -0
- package/dist/commands/lint/task-linter.js +47 -0
- package/dist/commands/lint/types.js +1 -0
- package/dist/commands/lint/workflow-linter.js +53 -0
- package/dist/commands/lint.js +1 -0
- package/dist/commands/proposal.js +8 -7
- package/dist/commands/propose.js +78 -28
- package/dist/commands/reflect.js +143 -35
- package/dist/commands/registry-search.js +2 -2
- package/dist/commands/remember.js +54 -0
- package/dist/commands/schema-repair.js +130 -0
- package/dist/commands/search.js +21 -5
- package/dist/commands/show.js +121 -17
- package/dist/commands/source-add.js +10 -10
- package/dist/commands/source-manage.js +11 -19
- package/dist/commands/tasks.js +385 -0
- package/dist/commands/url-checker.js +39 -0
- package/dist/commands/vault.js +2 -23
- package/dist/core/action-contributors.js +25 -0
- package/dist/core/asset-registry.js +4 -16
- package/dist/core/asset-spec.js +10 -0
- package/dist/core/common.js +94 -0
- package/dist/core/concurrent.js +22 -0
- package/dist/core/config.js +222 -128
- package/dist/core/events.js +73 -126
- package/dist/core/frontmatter.js +3 -1
- package/dist/core/markdown.js +17 -0
- package/dist/core/memory-improve.js +678 -0
- package/dist/core/parse.js +155 -0
- package/dist/core/paths.js +101 -3
- package/dist/core/proposal-validators.js +61 -0
- package/dist/core/proposals.js +49 -38
- package/dist/core/state-db.js +775 -0
- package/dist/core/time.js +51 -0
- package/dist/core/warn.js +59 -1
- package/dist/indexer/db-search.js +52 -238
- package/dist/indexer/db.js +377 -1
- package/dist/indexer/ensure-index.js +61 -0
- package/dist/indexer/graph-boost.js +247 -94
- package/dist/indexer/graph-db.js +201 -0
- package/dist/indexer/graph-dedup.js +99 -0
- package/dist/indexer/graph-extraction.js +409 -76
- package/dist/indexer/index-context.js +10 -0
- package/dist/indexer/indexer.js +442 -290
- package/dist/indexer/llm-cache.js +47 -0
- package/dist/indexer/match-contributors.js +141 -0
- package/dist/indexer/matchers.js +24 -190
- package/dist/indexer/memory-inference.js +63 -29
- package/dist/indexer/metadata-contributors.js +26 -0
- package/dist/indexer/metadata.js +188 -175
- package/dist/indexer/path-resolver.js +89 -0
- package/dist/indexer/ranking-contributors.js +204 -0
- package/dist/indexer/ranking.js +74 -0
- package/dist/indexer/search-hit-enrichers.js +22 -0
- package/dist/indexer/search-source.js +24 -9
- package/dist/indexer/semantic-status.js +2 -16
- package/dist/indexer/walker.js +25 -0
- package/dist/integrations/agent/config.js +175 -3
- package/dist/integrations/agent/index.js +3 -1
- package/dist/integrations/agent/pipeline.js +39 -0
- package/dist/integrations/agent/profiles.js +67 -5
- package/dist/integrations/agent/prompts.js +77 -72
- package/dist/integrations/agent/runners.js +31 -0
- package/dist/integrations/agent/sdk-runner.js +120 -0
- package/dist/integrations/agent/spawn.js +71 -16
- package/dist/integrations/lockfile.js +10 -18
- package/dist/integrations/session-logs/index.js +65 -0
- package/dist/integrations/session-logs/providers/claude-code.js +56 -0
- package/dist/integrations/session-logs/providers/opencode.js +52 -0
- package/dist/integrations/session-logs/types.js +1 -0
- package/dist/llm/call-ai.js +74 -0
- package/dist/llm/client.js +61 -122
- package/dist/llm/feature-gate.js +27 -16
- package/dist/llm/graph-extract.js +297 -62
- package/dist/llm/memory-infer.js +49 -71
- package/dist/llm/metadata-enhance.js +39 -22
- package/dist/llm/prompts/graph-extract-user-prompt.md +12 -0
- package/dist/output/cli-hints-full.md +277 -0
- package/dist/output/cli-hints-short.md +65 -0
- package/dist/output/cli-hints.js +2 -318
- package/dist/output/renderers.js +190 -123
- package/dist/output/shapes.js +33 -0
- package/dist/output/text.js +239 -2
- package/dist/registry/providers/skills-sh.js +61 -49
- package/dist/registry/providers/static-index.js +44 -48
- package/dist/setup/setup.js +510 -11
- package/dist/sources/provider-factory.js +2 -1
- package/dist/sources/providers/git.js +2 -2
- package/dist/sources/website-ingest.js +4 -0
- package/dist/tasks/backends/cron.js +200 -0
- package/dist/tasks/backends/exec-utils.js +25 -0
- package/dist/tasks/backends/index.js +32 -0
- package/dist/tasks/backends/launchd-template.xml +19 -0
- package/dist/tasks/backends/launchd.js +184 -0
- package/dist/tasks/backends/schtasks-template.xml +29 -0
- package/dist/tasks/backends/schtasks.js +212 -0
- package/dist/tasks/parser.js +198 -0
- package/dist/tasks/resolveAkmBin.js +84 -0
- package/dist/tasks/runner.js +432 -0
- package/dist/tasks/schedule.js +208 -0
- package/dist/tasks/schema.js +13 -0
- package/dist/tasks/validator.js +59 -0
- package/dist/wiki/index-template.md +12 -0
- package/dist/wiki/ingest-workflow-template.md +54 -0
- package/dist/wiki/log-template.md +8 -0
- package/dist/wiki/schema-template.md +61 -0
- package/dist/wiki/wiki-templates.js +12 -0
- package/dist/wiki/wiki.js +10 -61
- package/dist/workflows/authoring.js +5 -25
- package/dist/workflows/renderer.js +8 -3
- package/dist/workflows/runs.js +59 -91
- package/dist/workflows/validator.js +1 -1
- package/dist/workflows/workflow-template.md +24 -0
- package/docs/README.md +3 -0
- package/docs/migration/release-notes/0.7.0.md +1 -1
- package/docs/migration/release-notes/0.8.0.md +43 -0
- package/package.json +3 -2
- package/dist/templates/wiki-templates.js +0 -100
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared time and date utilities.
|
|
3
|
+
*
|
|
4
|
+
* Centralises parsing of user-facing `--since` values so that all consumers
|
|
5
|
+
* interpret the same set of formats (ISO-8601, epoch ms, plain date strings)
|
|
6
|
+
* consistently without private re-implementations drifting apart.
|
|
7
|
+
*/
|
|
8
|
+
import { UsageError } from "./errors";
|
|
9
|
+
// ── Since-flag parsing ───────────────────────────────────────────────────────
|
|
10
|
+
/**
|
|
11
|
+
* Parse a user-supplied `--since` value and return an ISO-8601 timestamp
|
|
12
|
+
* string (e.g. `"2026-01-15T10:30:00.000Z"`).
|
|
13
|
+
*
|
|
14
|
+
* Accepted input formats:
|
|
15
|
+
* - ISO-8601 timestamp (preferred): `"2026-04-01T00:00:00Z"`
|
|
16
|
+
* - Plain date: `"2026-04-01"` (interpreted as start-of-day UTC)
|
|
17
|
+
* - Epoch milliseconds (pure digit string): `"1744329600000"`
|
|
18
|
+
* - Any other value parseable by `new Date()`
|
|
19
|
+
*
|
|
20
|
+
* Callers that need a different wire format (e.g. SQLite `"YYYY-MM-DD HH:MM:SS"`)
|
|
21
|
+
* should convert the returned ISO string themselves.
|
|
22
|
+
*
|
|
23
|
+
* @throws {UsageError} when `since` is empty or cannot be parsed as a date.
|
|
24
|
+
*/
|
|
25
|
+
export function parseSinceToIso(since) {
|
|
26
|
+
const trimmed = since.trim();
|
|
27
|
+
if (!trimmed) {
|
|
28
|
+
throw new UsageError("--since cannot be empty.", "INVALID_FLAG_VALUE");
|
|
29
|
+
}
|
|
30
|
+
// Pure-digit input → epoch milliseconds
|
|
31
|
+
if (/^\d+$/.test(trimmed)) {
|
|
32
|
+
const ms = Number.parseInt(trimmed, 10);
|
|
33
|
+
const d = new Date(ms);
|
|
34
|
+
if (Number.isNaN(d.getTime())) {
|
|
35
|
+
throw new UsageError(`Invalid --since value: ${since}`, "INVALID_FLAG_VALUE");
|
|
36
|
+
}
|
|
37
|
+
return d.toISOString();
|
|
38
|
+
}
|
|
39
|
+
const parsed = new Date(trimmed);
|
|
40
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
41
|
+
throw new UsageError(`Invalid --since value: ${since}. Expected ISO timestamp (e.g. 2026-04-01T00:00:00Z) or epoch ms.`, "INVALID_FLAG_VALUE");
|
|
42
|
+
}
|
|
43
|
+
return parsed.toISOString();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Convert an ISO-8601 timestamp string to the SQLite datetime format
|
|
47
|
+
* `"YYYY-MM-DD HH:MM:SS"` used by `datetime('now')`.
|
|
48
|
+
*/
|
|
49
|
+
export function isoToSqlite(iso) {
|
|
50
|
+
return iso.replace("T", " ").replace(/\.\d+Z$/, "");
|
|
51
|
+
}
|
package/dist/core/warn.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Module-level quiet/verbose flags for stderr
|
|
2
|
+
* Module-level quiet/verbose flags and optional file sink for stderr output.
|
|
3
3
|
*
|
|
4
4
|
* `quiet` is controlled by the CLI `--quiet`/`-q` flag.
|
|
5
5
|
* `verbose` is controlled by the CLI `--verbose` flag, with `AKM_VERBOSE`
|
|
6
6
|
* (env var) winning regardless: env > flag > default (false).
|
|
7
|
+
*
|
|
8
|
+
* Call `setLogFile(path)` to tee all warn/error/info output to a file in
|
|
9
|
+
* addition to stderr. The file sink is written even when `--quiet` suppresses
|
|
10
|
+
* console output, so logs remain available for post-run inspection.
|
|
7
11
|
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
8
14
|
let quiet = false;
|
|
9
15
|
let verbose = false;
|
|
16
|
+
let logFilePath;
|
|
10
17
|
export function setQuiet(value) {
|
|
11
18
|
quiet = value;
|
|
12
19
|
}
|
|
@@ -51,15 +58,66 @@ export function isVerbose() {
|
|
|
51
58
|
return false;
|
|
52
59
|
return verbose;
|
|
53
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Direct all warn/error/info output to `filePath` in addition to stderr.
|
|
63
|
+
* The directory is created if it does not exist. Pass `undefined` to disable.
|
|
64
|
+
* The file is written even when `--quiet` suppresses console output.
|
|
65
|
+
*/
|
|
66
|
+
export function setLogFile(filePath) {
|
|
67
|
+
logFilePath = filePath;
|
|
68
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
export function clearLogFile() {
|
|
71
|
+
logFilePath = undefined;
|
|
72
|
+
}
|
|
73
|
+
export function getLogFile() {
|
|
74
|
+
return logFilePath;
|
|
75
|
+
}
|
|
76
|
+
function appendToLogFile(level, args) {
|
|
77
|
+
if (!logFilePath)
|
|
78
|
+
return;
|
|
79
|
+
const ts = new Date().toISOString();
|
|
80
|
+
const msg = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
|
|
81
|
+
try {
|
|
82
|
+
fs.appendFileSync(logFilePath, `[${ts}] [${level}] ${msg}\n`);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Never throw from a logging function — log failures are silent.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Emit an info/progress line to stderr unless --quiet is active.
|
|
90
|
+
* Always written to the log file if one is active.
|
|
91
|
+
* Use for progress counters and status lines (replaces console.error used for progress).
|
|
92
|
+
*/
|
|
93
|
+
export function info(...args) {
|
|
94
|
+
appendToLogFile("INFO", args);
|
|
95
|
+
if (!quiet) {
|
|
96
|
+
console.warn(...args);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
54
99
|
/**
|
|
55
100
|
* Emit a warning to stderr unless --quiet is active.
|
|
101
|
+
* Always written to the log file if one is active.
|
|
56
102
|
* Drop-in replacement for console.warn() across the codebase.
|
|
57
103
|
*/
|
|
58
104
|
export function warn(...args) {
|
|
105
|
+
appendToLogFile("WARN", args);
|
|
59
106
|
if (!quiet) {
|
|
60
107
|
console.warn(...args);
|
|
61
108
|
}
|
|
62
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Emit an error to stderr unless --quiet is active.
|
|
112
|
+
* Always written to the log file if one is active.
|
|
113
|
+
* Drop-in replacement for console.error() used for diagnostic failures.
|
|
114
|
+
*/
|
|
115
|
+
export function error(...args) {
|
|
116
|
+
appendToLogFile("ERROR", args);
|
|
117
|
+
if (!quiet) {
|
|
118
|
+
console.error(...args);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
63
121
|
/**
|
|
64
122
|
* Emit a warning only when verbose output is requested. Use for noisy
|
|
65
123
|
* per-item diagnostics that should be replaced by a one-line summary at
|
|
@@ -11,24 +11,21 @@
|
|
|
11
11
|
* implementation, not a "local vs. remote" distinction.
|
|
12
12
|
*/
|
|
13
13
|
import fs from "node:fs";
|
|
14
|
+
import { buildActionFromContributors, defaultActionContributors } from "../core/action-contributors";
|
|
14
15
|
import { makeAssetRef } from "../core/asset-ref";
|
|
15
16
|
import { defaultRendererRegistry } from "../core/asset-registry";
|
|
16
17
|
import { getDbPath } from "../core/paths";
|
|
17
18
|
import { warn } from "../core/warn";
|
|
18
|
-
import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta,
|
|
19
|
+
import { closeDatabase, getAllEntries, getEntryById, getEntryCount, getMeta, openExistingDatabase, sanitizeFtsQuery, searchFts, searchVec, } from "./db";
|
|
19
20
|
import { ensureIndex } from "./ensure-index";
|
|
20
|
-
import {
|
|
21
|
-
import { computeGraphBoost, loadGraphBoostContext } from "./graph-boost";
|
|
21
|
+
import { collectGraphRelatedHit, loadGraphBoostContext } from "./graph-boost";
|
|
22
22
|
import { isProposedQuality } from "./metadata";
|
|
23
|
+
import { applyRankingRules, combineSearchScores, normalizeFtsScores } from "./ranking";
|
|
24
|
+
import { enrichSearchHit } from "./search-hit-enrichers";
|
|
23
25
|
import { buildEditHint, findSourceForPath, isEditable } from "./search-source";
|
|
24
26
|
import { deriveSemanticProviderFingerprint, getEffectiveSemanticStatus, isSemanticRuntimeReady, readSemanticStatus, } from "./semantic-status";
|
|
25
|
-
export async function rendererForType(type, registry = defaultRendererRegistry) {
|
|
26
|
-
const name = registry.rendererNameFor(type);
|
|
27
|
-
return name ? getRenderer(name) : undefined;
|
|
28
|
-
}
|
|
29
27
|
export function buildLocalAction(type, ref, registry = defaultRendererRegistry) {
|
|
30
|
-
|
|
31
|
-
return builder ? builder(ref) : `akm show ${ref}`;
|
|
28
|
+
return buildActionFromContributors({ type, ref }, defaultActionContributors(registry)) ?? `akm show ${ref}`;
|
|
32
29
|
}
|
|
33
30
|
function resolveSearchHitRef(entry, refName, source) {
|
|
34
31
|
if (source?.wikiName) {
|
|
@@ -44,6 +41,7 @@ export async function searchLocal(input) {
|
|
|
44
41
|
const { query, searchType, limit, stashDir, sources, config } = input;
|
|
45
42
|
const filters = input.filters;
|
|
46
43
|
const includeProposed = input.includeProposed === true;
|
|
44
|
+
const beliefFilter = input.beliefFilter ?? "all";
|
|
47
45
|
const rendererRegistry = input.rendererRegistry ?? defaultRendererRegistry;
|
|
48
46
|
const allSourceDirs = sources.map((s) => s.path);
|
|
49
47
|
const rawStatus = readSemanticStatus();
|
|
@@ -69,6 +67,7 @@ export async function searchLocal(input) {
|
|
|
69
67
|
hits: [],
|
|
70
68
|
tip: "No search index available. Run 'akm index' to build one.",
|
|
71
69
|
warnings: warnings.length > 0 ? warnings : undefined,
|
|
70
|
+
mode: "keyword",
|
|
72
71
|
};
|
|
73
72
|
}
|
|
74
73
|
const db = openExistingDatabase(dbPath);
|
|
@@ -79,9 +78,10 @@ export async function searchLocal(input) {
|
|
|
79
78
|
hits: [],
|
|
80
79
|
tip: "Index is empty. Run 'akm index' to populate it.",
|
|
81
80
|
warnings: warnings.length > 0 ? warnings : undefined,
|
|
81
|
+
mode: "keyword",
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
|
-
const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed);
|
|
84
|
+
const { hits, embedMs, rankMs } = await searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry, filters, includeProposed, beliefFilter);
|
|
85
85
|
return {
|
|
86
86
|
hits,
|
|
87
87
|
tip: hits.length === 0
|
|
@@ -90,6 +90,7 @@ export async function searchLocal(input) {
|
|
|
90
90
|
warnings: warnings.length > 0 ? warnings : undefined,
|
|
91
91
|
embedMs,
|
|
92
92
|
rankMs,
|
|
93
|
+
mode: embedMs !== undefined && embedMs > 0 ? "semantic" : "keyword",
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
finally {
|
|
@@ -97,7 +98,7 @@ export async function searchLocal(input) {
|
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
// ── Database search ─────────────────────────────────────────────────────────
|
|
100
|
-
async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false) {
|
|
101
|
+
async function searchDatabase(db, query, searchType, limit, stashDir, allSourceDirs, config, sources, rendererRegistry = defaultRendererRegistry, filters, includeProposed = false, beliefFilter = "all") {
|
|
101
102
|
const hasSearchableTokens = query.length > 0 && sanitizeFtsQuery(query).length > 0;
|
|
102
103
|
// Empty queries — including ones that sanitize down to no searchable FTS
|
|
103
104
|
// tokens such as "." — should enumerate matching entries instead of
|
|
@@ -124,7 +125,8 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
124
125
|
const qualityFiltered = includeProposed
|
|
125
126
|
? scopeFiltered
|
|
126
127
|
: scopeFiltered.filter((ie) => !isProposedQuality(ie.entry.quality));
|
|
127
|
-
const
|
|
128
|
+
const beliefFiltered = qualityFiltered.filter((ie) => matchBeliefFilter(ie.entry.type, ie.entry.beliefState, beliefFilter));
|
|
129
|
+
const selected = beliefFiltered.slice(0, limit);
|
|
128
130
|
const hits = await Promise.all(selected.map((ie) => buildDbHit({
|
|
129
131
|
entry: ie.entry,
|
|
130
132
|
path: ie.filePath,
|
|
@@ -151,22 +153,7 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
151
153
|
// ── Score normalization ──────────────────────────────────────────────
|
|
152
154
|
// Normalized BM25 + cosine similarity with weighted addition
|
|
153
155
|
// (FTS 0.7, vector 0.3) for well-differentiated combined scores.
|
|
154
|
-
|
|
155
|
-
const ftsScoreMap = new Map();
|
|
156
|
-
if (ftsResults.length > 0) {
|
|
157
|
-
// BM25 scores are negative; most negative = best match
|
|
158
|
-
const bestBm25 = ftsResults[0].bm25Score; // most negative (best)
|
|
159
|
-
const worstBm25 = ftsResults[ftsResults.length - 1].bm25Score; // least negative (worst)
|
|
160
|
-
const range = bestBm25 - worstBm25; // negative range
|
|
161
|
-
for (const r of ftsResults) {
|
|
162
|
-
// Normalize: best match = 1.0, worst match approaches 0
|
|
163
|
-
// When range is 0 (all same score), all get 1.0
|
|
164
|
-
const normalized = range !== 0 ? (r.bm25Score - worstBm25) / range : 1.0;
|
|
165
|
-
// Scale to 0.3-1.0 range so even the worst FTS hit has a meaningful base score
|
|
166
|
-
const ftsScore = 0.3 + normalized * 0.7;
|
|
167
|
-
ftsScoreMap.set(r.id, { score: ftsScore, result: r });
|
|
168
|
-
}
|
|
169
|
-
}
|
|
156
|
+
const ftsScoreMap = normalizeFtsScores(ftsResults);
|
|
170
157
|
// Build embedding score map (cosine similarities already 0-1)
|
|
171
158
|
const embedScoreMap = new Map();
|
|
172
159
|
if (embeddingScores) {
|
|
@@ -175,46 +162,12 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
175
162
|
}
|
|
176
163
|
}
|
|
177
164
|
// ── Combine FTS + vector scores ──────────────────────────────────────
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
for (const [id, { score: ftsScore, result }] of ftsScoreMap) {
|
|
185
|
-
seenIds.add(id);
|
|
186
|
-
const embedScore = embedScoreMap.get(id);
|
|
187
|
-
let combinedScore;
|
|
188
|
-
let rankingMode;
|
|
189
|
-
if (embedScore !== undefined) {
|
|
190
|
-
combinedScore = ftsScore * FTS_WEIGHT + embedScore * VEC_WEIGHT;
|
|
191
|
-
rankingMode = "hybrid";
|
|
192
|
-
}
|
|
193
|
-
else {
|
|
194
|
-
combinedScore = ftsScore;
|
|
195
|
-
rankingMode = "fts";
|
|
196
|
-
}
|
|
197
|
-
scored.push({ id, entry: result.entry, filePath: result.filePath, score: combinedScore, rankingMode });
|
|
198
|
-
}
|
|
199
|
-
// Add vec-only results not already in FTS results
|
|
200
|
-
if (embeddingScores) {
|
|
201
|
-
for (const [id, cosine] of embeddingScores) {
|
|
202
|
-
if (seenIds.has(id))
|
|
203
|
-
continue;
|
|
204
|
-
const found = getEntryById(db, id);
|
|
205
|
-
if (found) {
|
|
206
|
-
if (typeFilter && found.entry.type !== typeFilter)
|
|
207
|
-
continue;
|
|
208
|
-
scored.push({
|
|
209
|
-
id,
|
|
210
|
-
entry: found.entry,
|
|
211
|
-
filePath: found.filePath,
|
|
212
|
-
score: cosine * VEC_WEIGHT, // Only vector score, no FTS
|
|
213
|
-
rankingMode: "semantic",
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
165
|
+
const scored = combineSearchScores({
|
|
166
|
+
ftsScoreMap,
|
|
167
|
+
embedScoreMap,
|
|
168
|
+
getEntryById: (id) => getEntryById(db, id) ?? undefined,
|
|
169
|
+
typeFilter,
|
|
170
|
+
});
|
|
218
171
|
// ── Scoring Phase ──────────────────────────────────────────────────────
|
|
219
172
|
// Apply boosts as multiplicative factors (all boosts in a single phase
|
|
220
173
|
// so that sort order and displayed scores are always consistent).
|
|
@@ -223,8 +176,6 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
223
176
|
// user's intent. An exact name match is the strongest signal. Actionable
|
|
224
177
|
// asset types (skills, commands, agents) are more useful than passive
|
|
225
178
|
// reference docs. Curated metadata is more reliable than auto-generated.
|
|
226
|
-
const queryTokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
227
|
-
const queryLower = query.toLowerCase().trim();
|
|
228
179
|
// Graph boost context (#207). Built once per query and reused across
|
|
229
180
|
// every scored entry so the disk read + JSON parse only happens once
|
|
230
181
|
// per search invocation. `null` when no graph file is present, when
|
|
@@ -237,170 +188,11 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
237
188
|
// Search across all source dirs; the graph file lives next to the
|
|
238
189
|
// primary source root. Cache misses are silent — the helper handles
|
|
239
190
|
// missing files internally and returns `null` instead of throwing.
|
|
240
|
-
|
|
241
|
-
if (!primaryDir)
|
|
191
|
+
if (allSourceDirs.length === 0)
|
|
242
192
|
return null;
|
|
243
|
-
return loadGraphBoostContext(
|
|
193
|
+
return loadGraphBoostContext(allSourceDirs, query, config, db);
|
|
244
194
|
})();
|
|
245
|
-
|
|
246
|
-
const entry = item.entry;
|
|
247
|
-
let boostSum = 0;
|
|
248
|
-
// ── 1. Exact / near-exact name match (strongest signal) ──
|
|
249
|
-
// If the query IS the asset name (or very close), this is almost certainly
|
|
250
|
-
// what the user wants. This is the single most important ranking signal.
|
|
251
|
-
const nameLower = entry.name.toLowerCase();
|
|
252
|
-
const rawNameBase = nameLower.split("/").pop() ?? nameLower; // last segment for path-based names
|
|
253
|
-
const nameBase = entry.type === "memory" && rawNameBase.endsWith(".derived")
|
|
254
|
-
? rawNameBase.slice(0, -".derived".length)
|
|
255
|
-
: rawNameBase;
|
|
256
|
-
if (nameBase === queryLower || nameLower === queryLower) {
|
|
257
|
-
// Exact match: massive boost
|
|
258
|
-
boostSum += 2.0;
|
|
259
|
-
}
|
|
260
|
-
else if (nameBase.includes(queryLower) || queryLower.includes(nameBase)) {
|
|
261
|
-
// Near-exact: query is substring of name or vice versa
|
|
262
|
-
boostSum += 1.0;
|
|
263
|
-
}
|
|
264
|
-
else {
|
|
265
|
-
// Token overlap: how many query tokens appear in the base name?
|
|
266
|
-
const nameTokens = nameBase.split(/[-_\s]+/).filter(Boolean);
|
|
267
|
-
const matchCount = queryTokens.filter((qt) => nameTokens.some((nt) => nt === qt || nt.includes(qt))).length;
|
|
268
|
-
if (matchCount > 0) {
|
|
269
|
-
// Proportional to how many query tokens match (0.3 per token, max 0.9)
|
|
270
|
-
boostSum += Math.min(0.9, matchCount * 0.3);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
// ── 2. Type relevance boost ──
|
|
274
|
-
// Actionable assets (skills, commands, agents) are generally more useful
|
|
275
|
-
// than passive reference material when the user is searching for something
|
|
276
|
-
// to use. Knowledge docs are reference — valuable but secondary.
|
|
277
|
-
const TYPE_BOOST = {
|
|
278
|
-
skill: 0.4,
|
|
279
|
-
command: 0.35,
|
|
280
|
-
workflow: 0.35,
|
|
281
|
-
agent: 0.3,
|
|
282
|
-
script: 0.2,
|
|
283
|
-
memory: 0.1,
|
|
284
|
-
knowledge: 0,
|
|
285
|
-
};
|
|
286
|
-
boostSum += TYPE_BOOST[entry.type] ?? 0;
|
|
287
|
-
// ── 2.5. Derived-vs-raw memory preference ──
|
|
288
|
-
// Raw memories are user notes and may be incomplete or unvetted. Compressed
|
|
289
|
-
// `.derived` memories are the higher-signal retrieval target, but the
|
|
290
|
-
// preference should stay modest so stronger relevance signals still dominate.
|
|
291
|
-
if (entry.type === "memory") {
|
|
292
|
-
if (entry.name.toLowerCase().endsWith(".derived")) {
|
|
293
|
-
boostSum += 0.18;
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
boostSum -= 0.08;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
// ── 3. Tag exact match ──
|
|
300
|
-
// Exact tag equality is a strong signal — the author explicitly tagged
|
|
301
|
-
// this asset with the user's search term.
|
|
302
|
-
if (entry.tags) {
|
|
303
|
-
let tagBoost = 0;
|
|
304
|
-
for (const tag of entry.tags) {
|
|
305
|
-
if (queryTokens.some((t) => tag.toLowerCase() === t)) {
|
|
306
|
-
tagBoost += 0.15;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
boostSum += Math.min(0.3, tagBoost);
|
|
310
|
-
}
|
|
311
|
-
// ── 4. Search hint match ──
|
|
312
|
-
// Hints are author-curated retrieval cues (e.g. "use when deploying to k8s").
|
|
313
|
-
if (entry.searchHints) {
|
|
314
|
-
let hintBoost = 0;
|
|
315
|
-
for (const hint of entry.searchHints) {
|
|
316
|
-
const hintLower = hint.toLowerCase();
|
|
317
|
-
for (const token of queryTokens) {
|
|
318
|
-
if (hintLower.includes(token)) {
|
|
319
|
-
hintBoost += 0.12;
|
|
320
|
-
break;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
boostSum += Math.min(0.24, hintBoost);
|
|
325
|
-
}
|
|
326
|
-
// ── 5. Alias match ──
|
|
327
|
-
// Aliases are alternate names the author defined for discovery.
|
|
328
|
-
if (entry.aliases) {
|
|
329
|
-
for (const alias of entry.aliases) {
|
|
330
|
-
const aliasLower = alias.toLowerCase();
|
|
331
|
-
if (aliasLower === queryLower) {
|
|
332
|
-
boostSum += 1.5; // Nearly as strong as exact name match
|
|
333
|
-
break;
|
|
334
|
-
}
|
|
335
|
-
if (queryTokens.some((t) => aliasLower.includes(t))) {
|
|
336
|
-
boostSum += 0.3;
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
// ── 6. Description relevance ──
|
|
341
|
-
// All query tokens appearing in description suggests strong relevance.
|
|
342
|
-
if (entry.description) {
|
|
343
|
-
const descLower = entry.description.toLowerCase();
|
|
344
|
-
const descMatchCount = queryTokens.filter((t) => descLower.includes(t)).length;
|
|
345
|
-
if (descMatchCount === queryTokens.length && queryTokens.length > 1) {
|
|
346
|
-
// All query tokens found in description — high relevance
|
|
347
|
-
boostSum += 0.25;
|
|
348
|
-
}
|
|
349
|
-
else if (descMatchCount > 0) {
|
|
350
|
-
boostSum += 0.1;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
// ── 7. Metadata quality signals ──
|
|
354
|
-
// Curated metadata is the only boost-bearing quality marker. `generated`
|
|
355
|
-
// and `proposed` (and unknown values) get no boost. `proposed` is also
|
|
356
|
-
// filtered out by default downstream (v1 spec §4.2).
|
|
357
|
-
const qualityBoost = entry.quality === "curated" ? 0.05 : 0;
|
|
358
|
-
boostSum += qualityBoost;
|
|
359
|
-
const confidenceBoost = typeof entry.confidence === "number" ? Math.min(0.05, Math.max(0, entry.confidence) * 0.05) : 0;
|
|
360
|
-
boostSum += confidenceBoost;
|
|
361
|
-
// ── 8. Graph signal (opt-in, #207) ──
|
|
362
|
-
// When the graph-extraction pass has produced a `graph.json`,
|
|
363
|
-
// contribute an additive boost based on how many of this entry's
|
|
364
|
-
// extracted entities match the query (or are one hop away from a
|
|
365
|
-
// match). Computed inside the same loop so all boosts are in one
|
|
366
|
-
// place and the per-call cost is one map lookup when the graph is
|
|
367
|
-
// absent. There is no parallel scoring track — `boostSum` is the
|
|
368
|
-
// single accumulator and the existing `MAX_BOOST_SUM` cap below
|
|
369
|
-
// applies to graph contributions exactly as it does to every other
|
|
370
|
-
// boost.
|
|
371
|
-
if (graphContext) {
|
|
372
|
-
boostSum += computeGraphBoost(graphContext, item.filePath);
|
|
373
|
-
}
|
|
374
|
-
const cappedBoost = Math.min(boostSum, MAX_BOOST_SUM);
|
|
375
|
-
item.score = item.score * (1 + cappedBoost);
|
|
376
|
-
}
|
|
377
|
-
// Utility-based re-ranking (MemRL pattern).
|
|
378
|
-
// After the FTS+boost scoring pass, apply a multiplicative
|
|
379
|
-
// utility factor based on aggregated usage telemetry.
|
|
380
|
-
// Batch-load all utility scores in one query to avoid N+1.
|
|
381
|
-
const UTILITY_WEIGHT = 0.5;
|
|
382
|
-
const UTILITY_MAX_BOOST = 1.5; // Cap at 1.5x multiplier
|
|
383
|
-
const RECENCY_DECAY_DAYS = 30;
|
|
384
|
-
const utilScoresMap = getUtilityScoresByIds(db, scored.map((s) => s.id));
|
|
385
|
-
for (const item of scored) {
|
|
386
|
-
const utilScore = utilScoresMap.get(item.id);
|
|
387
|
-
if (utilScore && utilScore.utility > 0) {
|
|
388
|
-
// Compute recency factor: exponential decay based on days since last use
|
|
389
|
-
let recencyFactor = 1;
|
|
390
|
-
if (utilScore.lastUsedAt) {
|
|
391
|
-
const lastUsedMs = new Date(utilScore.lastUsedAt).getTime();
|
|
392
|
-
const daysSinceLastUse = Number.isNaN(lastUsedMs)
|
|
393
|
-
? Infinity
|
|
394
|
-
: Math.max(0, (Date.now() - lastUsedMs) / (1000 * 60 * 60 * 24));
|
|
395
|
-
recencyFactor = Math.exp(-daysSinceLastUse / RECENCY_DECAY_DAYS);
|
|
396
|
-
}
|
|
397
|
-
// Compute raw utility boost and cap it
|
|
398
|
-
const rawBoost = 1 + utilScore.utility * recencyFactor * UTILITY_WEIGHT;
|
|
399
|
-
const cappedBoost = Math.min(rawBoost, UTILITY_MAX_BOOST);
|
|
400
|
-
item.score = item.score * cappedBoost;
|
|
401
|
-
item.utilityBoosted = true;
|
|
402
|
-
}
|
|
403
|
-
}
|
|
195
|
+
applyRankingRules({ db, query, items: scored, graphContext });
|
|
404
196
|
// ── minScore floor ──────────────────────────────────────────────────────
|
|
405
197
|
// Drop semantic-only hits (cosine-only, no FTS match) whose score falls
|
|
406
198
|
// below the configured floor. FTS hits and hybrid hits are always kept.
|
|
@@ -424,8 +216,9 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
424
216
|
const qualityFiltered = includeProposed
|
|
425
217
|
? scopeFiltered
|
|
426
218
|
: scopeFiltered.filter((item) => !isProposedQuality(item.entry.quality));
|
|
219
|
+
const beliefFiltered = qualityFiltered.filter((item) => matchBeliefFilter(item.entry.type, item.entry.beliefState, beliefFilter));
|
|
427
220
|
const rankMs = Date.now() - tRank0;
|
|
428
|
-
const selected =
|
|
221
|
+
const selected = beliefFiltered.slice(0, limit);
|
|
429
222
|
const hits = await Promise.all(selected.map(({ entry, filePath, score, rankingMode, utilityBoosted }) => {
|
|
430
223
|
// CLAUDE.md locks SearchHit.score in [0,1]. The boost loop above can
|
|
431
224
|
// exceed 1.0 (this was a pre-existing breach that #207's graph boost
|
|
@@ -444,11 +237,21 @@ async function searchDatabase(db, query, searchType, limit, stashDir, allSourceD
|
|
|
444
237
|
sources,
|
|
445
238
|
config,
|
|
446
239
|
utilityBoosted,
|
|
240
|
+
graphContext,
|
|
447
241
|
rendererRegistry,
|
|
448
242
|
});
|
|
449
243
|
}));
|
|
450
244
|
return { embedMs, rankMs, hits };
|
|
451
245
|
}
|
|
246
|
+
function matchBeliefFilter(type, beliefState, filter) {
|
|
247
|
+
if (filter === "all")
|
|
248
|
+
return true;
|
|
249
|
+
if (type !== "memory")
|
|
250
|
+
return true;
|
|
251
|
+
if (filter === "current")
|
|
252
|
+
return beliefState === undefined || beliefState === "active";
|
|
253
|
+
return beliefState === "contradicted" || beliefState === "superseded" || beliefState === "archived";
|
|
254
|
+
}
|
|
452
255
|
// ── Vector scorer ───────────────────────────────────────────────────────────
|
|
453
256
|
async function tryVecScores(db, query, k, config) {
|
|
454
257
|
const semanticStatus = getEffectiveSemanticStatus(config, readSemanticStatus());
|
|
@@ -490,6 +293,7 @@ export async function buildDbHit(input) {
|
|
|
490
293
|
// Round to 4 decimal places, no boost multiplication
|
|
491
294
|
const score = Math.round(input.score * 10000) / 10000;
|
|
492
295
|
const whyMatched = buildWhyMatched(input.entry, input.query, input.rankingMode, qualityBoost, confidenceBoost, input.utilityBoosted);
|
|
296
|
+
const graphHit = input.graphContext ? collectGraphRelatedHit(input.graphContext, input.path) : null;
|
|
493
297
|
const source = findSourceForPath(input.path, input.sources);
|
|
494
298
|
const ref = resolveSearchHitRef(input.entry, input.entry.name, source);
|
|
495
299
|
const editable = isEditable(input.path, input.config);
|
|
@@ -514,11 +318,15 @@ export async function buildDbHit(input) {
|
|
|
514
318
|
// Surface optional quality (v1 spec §4.2). Omitted when entry has
|
|
515
319
|
// no `quality` field so payloads stay compact for the common case.
|
|
516
320
|
...(input.entry.quality ? { quality: input.entry.quality } : {}),
|
|
321
|
+
...(input.entry.beliefState ? { beliefState: input.entry.beliefState } : {}),
|
|
322
|
+
...(input.entry.currentBeliefRefs ? { currentBeliefRefs: input.entry.currentBeliefRefs } : {}),
|
|
323
|
+
...(graphHit ? { graph: { entities: graphHit.entities, relations: graphHit.relations } } : {}),
|
|
517
324
|
};
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
325
|
+
await enrichSearchHit(hit, {
|
|
326
|
+
type: input.entry.type,
|
|
327
|
+
stashDir: entryStashDir,
|
|
328
|
+
rendererRegistry,
|
|
329
|
+
});
|
|
522
330
|
return hit;
|
|
523
331
|
}
|
|
524
332
|
export function buildWhyMatched(entry, query,
|
|
@@ -565,6 +373,12 @@ rankingMode, qualityBoost, confidenceBoost, utilityBoosted) {
|
|
|
565
373
|
reasons.push("curated metadata boost");
|
|
566
374
|
if (confidenceBoost > 0)
|
|
567
375
|
reasons.push("metadata confidence boost");
|
|
376
|
+
if (entry.beliefState === "active")
|
|
377
|
+
reasons.push("active belief state");
|
|
378
|
+
if (entry.beliefState === "contradicted")
|
|
379
|
+
reasons.push("contradicted belief state");
|
|
380
|
+
if (entry.beliefState === "superseded")
|
|
381
|
+
reasons.push("superseded belief state");
|
|
568
382
|
if (utilityBoosted)
|
|
569
383
|
reasons.push("usage history boost");
|
|
570
384
|
return reasons;
|
|
@@ -585,7 +399,7 @@ export function deriveSize(bytes) {
|
|
|
585
399
|
* precondition is always met regardless of caller.
|
|
586
400
|
*/
|
|
587
401
|
function deduplicateByPath(items) {
|
|
588
|
-
const sorted = [...items].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
|
402
|
+
const sorted = [...items].sort((a, b) => (b.score ?? 0) - (a.score ?? 0) || a.filePath.localeCompare(b.filePath));
|
|
589
403
|
const seen = new Set();
|
|
590
404
|
return sorted.filter((item) => {
|
|
591
405
|
if (seen.has(item.filePath))
|