@jaimevalasek/aioson 1.29.1 → 1.30.0
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 +19 -0
- package/README.md +7 -5
- package/docs/en/5-reference/cli-reference.md +40 -10
- package/docs/pt/4-agentes/pm.md +1 -1
- package/docs/pt/5-referencia/autopilot-handoff.md +4 -4
- package/docs/pt/5-referencia/comandos-cli.md +5 -3
- package/docs/pt/5-referencia/fluxo-artefatos.md +1 -1
- package/docs/pt/5-referencia/memoria-e-contexto.md +2 -2
- package/docs/pt/_arquivo/monitor-de-contexto.md +2 -2
- package/package.json +4 -2
- package/src/cli.js +67 -24
- package/src/commands/ac-test-audit.js +45 -0
- package/src/commands/artifact-validate.js +62 -50
- package/src/commands/classify.js +73 -2
- package/src/commands/context-brief.js +59 -0
- package/src/commands/context-guard.js +88 -0
- package/src/commands/context-monitor.js +1 -1
- package/src/commands/context-search.js +101 -52
- package/src/commands/context-select.js +11 -2
- package/src/commands/feature-archive.js +21 -12
- package/src/commands/feature-current.js +82 -0
- package/src/commands/gate-check.js +32 -15
- package/src/commands/harness-check.js +17 -1
- package/src/commands/hooks-install.js +169 -26
- package/src/commands/hygiene-scan.js +423 -0
- package/src/commands/rules-lint.js +11 -3
- package/src/commands/sdd-benchmark.js +134 -0
- package/src/commands/spec-analyze.js +6 -4
- package/src/commands/store-system.js +329 -49
- package/src/constants.js +8 -3
- package/src/context-brief.js +585 -0
- package/src/context-guard.js +209 -0
- package/src/context-search.js +796 -96
- package/src/context-selector.js +802 -444
- package/src/handoff-contract.js +14 -6
- package/src/harness/contract-schema.js +1 -1
- package/src/i18n/messages/en.js +12 -5
- package/src/i18n/messages/es.js +11 -4
- package/src/i18n/messages/fr.js +11 -4
- package/src/i18n/messages/pt-BR.js +12 -5
- package/src/lib/ac-test-audit.js +194 -0
- package/src/preflight-engine.js +10 -6
- package/src/squad/state-manager.js +1 -1
- package/template/.aioson/agents/analyst.md +41 -17
- package/template/.aioson/agents/architect.md +4 -2
- package/template/.aioson/agents/briefing-refiner.md +15 -2
- package/template/.aioson/agents/briefing.md +12 -8
- package/template/.aioson/agents/committer.md +1 -1
- package/template/.aioson/agents/copywriter.md +20 -9
- package/template/.aioson/agents/design-hybrid-forge.md +9 -5
- package/template/.aioson/agents/dev.md +22 -25
- package/template/.aioson/agents/deyvin.md +126 -124
- package/template/.aioson/agents/discover.md +3 -1
- package/template/.aioson/agents/discovery-design-doc.md +11 -2
- package/template/.aioson/agents/forge-run.md +3 -0
- package/template/.aioson/agents/genome.md +9 -5
- package/template/.aioson/agents/neo.md +30 -24
- package/template/.aioson/agents/orache.md +10 -6
- package/template/.aioson/agents/orchestrator.md +4 -2
- package/template/.aioson/agents/pentester.md +22 -12
- package/template/.aioson/agents/pm.md +5 -3
- package/template/.aioson/agents/product.md +25 -18
- package/template/.aioson/agents/profiler-enricher.md +10 -6
- package/template/.aioson/agents/profiler-forge.md +10 -6
- package/template/.aioson/agents/profiler-researcher.md +10 -6
- package/template/.aioson/agents/qa.md +21 -19
- package/template/.aioson/agents/scope-check.md +9 -3
- package/template/.aioson/agents/sheldon.md +22 -8
- package/template/.aioson/agents/site-forge.md +2 -0
- package/template/.aioson/agents/squad.md +4 -2
- package/template/.aioson/agents/tester.md +19 -15
- package/template/.aioson/agents/ux-ui.md +16 -8
- package/template/.aioson/config.md +4 -3
- package/template/.aioson/design-docs/agent-loading-contract.md +3 -3
- package/template/.aioson/docs/autopilot-handoff.md +3 -3
- package/template/.aioson/docs/dev/simple-plan-lane.md +73 -27
- package/template/.aioson/docs/dev/stack-conventions.md +1 -1
- package/template/.aioson/docs/deyvin/continuity-recovery.md +1 -1
- package/template/.aioson/docs/deyvin/runtime-handoffs.md +3 -3
- package/template/.aioson/docs/feature-expansion-taxonomy.md +53 -0
- package/template/.aioson/docs/handoff-persistence.md +14 -12
- package/template/.aioson/docs/product/conversation-playbook.md +1 -1
- package/template/.aioson/docs/sheldon/enrichment-paths.md +44 -1
- package/template/.aioson/docs/sheldon/harness-contract.md +23 -21
- package/template/.aioson/docs/tester/coverage-quality.md +1 -1
- package/template/.aioson/docs/ux-ui/design-execution.md +9 -7
- package/template/.aioson/rules/README.md +35 -17
- package/template/.aioson/rules/agent-structural-contract.md +165 -160
- package/template/.aioson/rules/aioson-context-boundary.md +5 -4
- package/template/.aioson/rules/canonical-path-contract.md +5 -4
- package/template/.aioson/rules/data-format-convention.md +5 -4
- package/template/.aioson/rules/disk-first-artifacts.md +2 -2
- package/template/.aioson/rules/implementation-structure-and-data-access.md +50 -0
- package/template/.aioson/rules/security-baseline.md +4 -3
- package/template/.aioson/rules/simple-plan-lane.md +18 -6
- package/template/.aioson/rules/source-code-language-convention.md +34 -0
- package/template/.aioson/skills/process/aioson-spec-driven/references/artifact-map.md +24 -23
- package/template/.aioson/skills/process/aioson-spec-driven/references/classification-map.md +4 -0
- package/template/.aioson/skills/process/aioson-spec-driven/references/dev.md +2 -2
- package/template/.aioson/skills/process/aioson-spec-driven/references/qa.md +1 -1
- package/template/.aioson/skills/process/briefing-expansion-scout/SKILL.md +72 -0
- package/template/.aioson/skills/process/product-scope-expansion/SKILL.md +74 -0
- package/template/.aioson/skills/process/sheldon-expansion-audit/SKILL.md +67 -0
- package/template/.aioson/skills/static/context-budget-guide.md +1 -1
- package/template/.aioson/skills/static/multi-agent-patterns.md +5 -4
- package/template/AGENTS.md +36 -19
- package/template/CLAUDE.md +9 -5
package/src/context-search.js
CHANGED
|
@@ -4,29 +4,127 @@ const fs = require('node:fs/promises');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const os = require('node:os');
|
|
6
6
|
const Database = require('better-sqlite3');
|
|
7
|
+
const { parseFrontmatter, parseAgentList } = require('./preflight-engine');
|
|
8
|
+
const { pathMatchesPattern: selectorPathMatchesPattern } = require('./context-selector');
|
|
7
9
|
|
|
8
10
|
const SEARCH_DIR = path.join(os.homedir(), '.aioson', 'search');
|
|
9
11
|
const DB_FILE = 'context-search.sqlite';
|
|
10
|
-
const SCHEMA_VERSION =
|
|
12
|
+
const SCHEMA_VERSION = 3;
|
|
11
13
|
const MAX_STALE_MS = 24 * 60 * 60 * 1000; // 24h
|
|
14
|
+
const CONTENT_LIMIT = 100_000;
|
|
15
|
+
const SEARCH_RESULT_MULTIPLIER = 6;
|
|
16
|
+
|
|
17
|
+
const SKIP_DIR_NAMES = new Set([
|
|
18
|
+
'.git',
|
|
19
|
+
'.hg',
|
|
20
|
+
'.svn',
|
|
21
|
+
'node_modules',
|
|
22
|
+
'vendor',
|
|
23
|
+
'dist',
|
|
24
|
+
'build',
|
|
25
|
+
'coverage',
|
|
26
|
+
'.next',
|
|
27
|
+
'.nuxt'
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const SKIP_REL_DIRS = [
|
|
31
|
+
'.aioson/agents',
|
|
32
|
+
'.aioson/backups',
|
|
33
|
+
'.aioson/locales',
|
|
34
|
+
'.aioson/mcp',
|
|
35
|
+
'.aioson/runtime',
|
|
36
|
+
'.aioson/tmp',
|
|
37
|
+
'.aioson/.cache'
|
|
38
|
+
];
|
|
12
39
|
|
|
13
40
|
async function ensureDir(dir) {
|
|
14
41
|
await fs.mkdir(dir, { recursive: true });
|
|
15
42
|
}
|
|
16
43
|
|
|
44
|
+
function normalizeProjectDir(dir) {
|
|
45
|
+
const resolved = path.resolve(dir);
|
|
46
|
+
// The recall index is one global DB shared across callers. Windows paths are
|
|
47
|
+
// case-insensitive, so fold case on win32 to keep a single partition per
|
|
48
|
+
// project regardless of drive-letter/segment casing drift between callers
|
|
49
|
+
// (a lowercased drive letter, a symlink, cwd casing differing across tools).
|
|
50
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
51
|
+
}
|
|
52
|
+
|
|
17
53
|
function openDb(dbPath) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
54
|
+
try {
|
|
55
|
+
return initDb(dbPath);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (!isCorruptionError(err)) throw err;
|
|
58
|
+
// A truncated WAL, AV quarantine, or disk-full can leave the sqlite file
|
|
59
|
+
// unopenable (SQLITE_NOTADB/SQLITE_CORRUPT). Recreate it once instead of
|
|
60
|
+
// bricking every recall command until the user manually deletes the file.
|
|
61
|
+
discardDbFiles(dbPath);
|
|
62
|
+
return initDb(dbPath);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
28
65
|
|
|
66
|
+
function initDb(dbPath) {
|
|
67
|
+
let db;
|
|
68
|
+
try {
|
|
69
|
+
db = new Database(dbPath);
|
|
70
|
+
db.pragma('journal_mode = WAL');
|
|
71
|
+
db.pragma('synchronous = NORMAL');
|
|
72
|
+
// Wait up to 5s for a transient lock (e.g. WAL checkpoint, AV file-lock on
|
|
73
|
+
// Windows) instead of throwing SQLITE_BUSY immediately.
|
|
74
|
+
db.pragma('busy_timeout = 5000');
|
|
75
|
+
db.exec(`
|
|
76
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
77
|
+
version INTEGER NOT NULL
|
|
78
|
+
);
|
|
79
|
+
`);
|
|
80
|
+
|
|
81
|
+
ensureSearchTables(db);
|
|
82
|
+
ensureMetaSchema(db);
|
|
83
|
+
|
|
84
|
+
// Insert schema version if not present
|
|
85
|
+
const ver = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
|
|
86
|
+
if (!ver) {
|
|
87
|
+
db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
|
|
88
|
+
} else if (Number(ver.version) < SCHEMA_VERSION) {
|
|
89
|
+
db.prepare('UPDATE schema_version SET version = ?').run(SCHEMA_VERSION);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return db;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// Release the handle before the caller unlinks the file (Windows file lock).
|
|
95
|
+
if (db) { try { db.close(); } catch { /* ignore */ } }
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isCorruptionError(err) {
|
|
101
|
+
const code = err && err.code ? String(err.code) : '';
|
|
102
|
+
const msg = err && err.message ? String(err.message) : '';
|
|
103
|
+
return /SQLITE_NOTADB|SQLITE_CORRUPT/.test(code)
|
|
104
|
+
|| /not a database|file is encrypted|malformed/i.test(msg);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function discardDbFiles(dbPath) {
|
|
108
|
+
const fsSync = require('node:fs');
|
|
109
|
+
for (const suffix of ['', '-wal', '-shm', '-journal']) {
|
|
110
|
+
try { fsSync.unlinkSync(`${dbPath}${suffix}`); } catch { /* best effort */ }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function ensureSearchTables(db) {
|
|
115
|
+
const docsColumns = tableColumns(db, 'docs');
|
|
116
|
+
const metaColumns = tableColumns(db, 'docs_meta');
|
|
117
|
+
const docsNeedRebuild = docsColumns.length > 0 && !docsColumns.some((column) => column.name === 'project_dir');
|
|
118
|
+
const metaNeedRebuild = metaColumns.length > 0 && !hasCompositeProjectPathPrimaryKey(metaColumns);
|
|
119
|
+
|
|
120
|
+
if (docsNeedRebuild || metaNeedRebuild) {
|
|
121
|
+
db.exec('DROP TABLE IF EXISTS docs');
|
|
122
|
+
db.exec('DROP TABLE IF EXISTS docs_meta');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
db.exec(`
|
|
29
126
|
CREATE VIRTUAL TABLE IF NOT EXISTS docs USING fts5(
|
|
127
|
+
project_dir UNINDEXED,
|
|
30
128
|
rel_path,
|
|
31
129
|
title,
|
|
32
130
|
content,
|
|
@@ -34,21 +132,56 @@ function openDb(dbPath) {
|
|
|
34
132
|
);
|
|
35
133
|
|
|
36
134
|
CREATE TABLE IF NOT EXISTS docs_meta (
|
|
37
|
-
rel_path TEXT PRIMARY KEY,
|
|
38
135
|
project_dir TEXT NOT NULL,
|
|
136
|
+
rel_path TEXT NOT NULL,
|
|
39
137
|
indexed_at TEXT NOT NULL,
|
|
40
138
|
file_mtime TEXT,
|
|
41
|
-
size INTEGER DEFAULT 0
|
|
139
|
+
size INTEGER DEFAULT 0,
|
|
140
|
+
PRIMARY KEY (project_dir, rel_path)
|
|
42
141
|
);
|
|
43
142
|
`);
|
|
143
|
+
}
|
|
44
144
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
145
|
+
function tableColumns(db, tableName) {
|
|
146
|
+
try {
|
|
147
|
+
return db.prepare(`PRAGMA table_info(${tableName})`).all();
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
49
150
|
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hasCompositeProjectPathPrimaryKey(columns) {
|
|
154
|
+
const primaryKey = columns
|
|
155
|
+
.filter((column) => Number(column.pk) > 0)
|
|
156
|
+
.sort((a, b) => Number(a.pk) - Number(b.pk))
|
|
157
|
+
.map((column) => column.name);
|
|
158
|
+
return primaryKey.length === 2
|
|
159
|
+
&& primaryKey[0] === 'project_dir'
|
|
160
|
+
&& primaryKey[1] === 'rel_path';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function ensureMetaSchema(db) {
|
|
164
|
+
const existing = new Set(tableColumns(db, 'docs_meta').map((column) => column.name));
|
|
165
|
+
const columns = [
|
|
166
|
+
['source_type', "source_type TEXT DEFAULT ''"],
|
|
167
|
+
['description', "description TEXT DEFAULT ''"],
|
|
168
|
+
['agents', "agents TEXT DEFAULT ''"],
|
|
169
|
+
['modes', "modes TEXT DEFAULT ''"],
|
|
170
|
+
['task_types', "task_types TEXT DEFAULT ''"],
|
|
171
|
+
['triggers', "triggers TEXT DEFAULT ''"],
|
|
172
|
+
['aliases', "aliases TEXT DEFAULT ''"],
|
|
173
|
+
['entities', "entities TEXT DEFAULT ''"],
|
|
174
|
+
['paths', "paths TEXT DEFAULT ''"],
|
|
175
|
+
['retrieval_intents', "retrieval_intents TEXT DEFAULT ''"],
|
|
176
|
+
['load_tier', "load_tier TEXT DEFAULT ''"],
|
|
177
|
+
['priority', 'priority INTEGER DEFAULT 0']
|
|
178
|
+
];
|
|
50
179
|
|
|
51
|
-
|
|
180
|
+
for (const [name, ddl] of columns) {
|
|
181
|
+
if (!existing.has(name)) {
|
|
182
|
+
db.exec(`ALTER TABLE docs_meta ADD COLUMN ${ddl}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
52
185
|
}
|
|
53
186
|
|
|
54
187
|
class IndexManager {
|
|
@@ -68,6 +201,11 @@ class IndexManager {
|
|
|
68
201
|
|
|
69
202
|
close() {
|
|
70
203
|
if (this._db) {
|
|
204
|
+
try {
|
|
205
|
+
this._db.pragma('wal_checkpoint(TRUNCATE)');
|
|
206
|
+
} catch {
|
|
207
|
+
// best effort only
|
|
208
|
+
}
|
|
71
209
|
this._db.close();
|
|
72
210
|
this._db = null;
|
|
73
211
|
}
|
|
@@ -80,63 +218,109 @@ class IndexManager {
|
|
|
80
218
|
* @returns {{ indexed: number, skipped: number }}
|
|
81
219
|
*/
|
|
82
220
|
async indexDirectory(dir, opts = {}) {
|
|
83
|
-
|
|
221
|
+
// Recall indexes markdown only: .json/.txt (package-lock.json, fixtures) are
|
|
222
|
+
// pure noise for context recall and the only consumers (context:brief,
|
|
223
|
+
// context:search, agent-loader shards) are all markdown. A single .md policy
|
|
224
|
+
// also stops the two writers from purge-thrashing the shared global index.
|
|
225
|
+
const extensions = opts.extensions || ['.md'];
|
|
84
226
|
const force = Boolean(opts.force);
|
|
227
|
+
const baseDir = normalizeProjectDir(dir);
|
|
85
228
|
let indexed = 0;
|
|
86
229
|
let skipped = 0;
|
|
87
230
|
|
|
88
|
-
const files = await this._listFiles(
|
|
231
|
+
const files = await this._listFiles(baseDir, extensions);
|
|
232
|
+
this._purgeSkippedEntries(baseDir);
|
|
233
|
+
this._purgeDeletedEntries(baseDir, new Set(files.map((file) => file.relPath)));
|
|
89
234
|
|
|
90
235
|
const insertDoc = this._db.prepare(
|
|
91
|
-
'INSERT INTO docs (rel_path, title, content) VALUES (?, ?, ?)'
|
|
236
|
+
'INSERT INTO docs (project_dir, rel_path, title, content) VALUES (?, ?, ?, ?)'
|
|
92
237
|
);
|
|
93
238
|
const insertMeta = this._db.prepare(`
|
|
94
|
-
INSERT OR REPLACE INTO docs_meta (
|
|
95
|
-
|
|
239
|
+
INSERT OR REPLACE INTO docs_meta (
|
|
240
|
+
project_dir,
|
|
241
|
+
rel_path,
|
|
242
|
+
indexed_at,
|
|
243
|
+
file_mtime,
|
|
244
|
+
size,
|
|
245
|
+
source_type,
|
|
246
|
+
description,
|
|
247
|
+
agents,
|
|
248
|
+
modes,
|
|
249
|
+
task_types,
|
|
250
|
+
triggers,
|
|
251
|
+
aliases,
|
|
252
|
+
entities,
|
|
253
|
+
paths,
|
|
254
|
+
retrieval_intents,
|
|
255
|
+
load_tier,
|
|
256
|
+
priority
|
|
257
|
+
)
|
|
258
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
96
259
|
`);
|
|
97
260
|
const getMeta = this._db.prepare(
|
|
98
|
-
'SELECT indexed_at, file_mtime FROM docs_meta WHERE rel_path = ?'
|
|
261
|
+
'SELECT indexed_at, file_mtime FROM docs_meta WHERE rel_path = ? AND project_dir = ?'
|
|
99
262
|
);
|
|
100
263
|
const deletePrev = this._db.prepare(
|
|
101
|
-
|
|
264
|
+
'DELETE FROM docs WHERE project_dir = ? AND rel_path = ?'
|
|
102
265
|
);
|
|
103
266
|
|
|
104
267
|
const doIndex = this._db.transaction((fileList) => {
|
|
105
268
|
for (const { relPath, absPath } of fileList) {
|
|
106
|
-
|
|
269
|
+
let content = '';
|
|
270
|
+
let size = 0;
|
|
271
|
+
let mtime = '';
|
|
272
|
+
let stat;
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
stat = require('node:fs').statSync(absPath);
|
|
276
|
+
size = stat.size;
|
|
277
|
+
mtime = stat.mtimeMs.toString();
|
|
278
|
+
} catch {
|
|
279
|
+
skipped++;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
107
283
|
if (!force) {
|
|
108
|
-
const meta = getMeta.get(relPath);
|
|
109
|
-
if (meta) {
|
|
284
|
+
const meta = getMeta.get(relPath, baseDir);
|
|
285
|
+
if (meta && meta.file_mtime === mtime) {
|
|
110
286
|
skipped++;
|
|
111
287
|
continue;
|
|
112
288
|
}
|
|
113
289
|
}
|
|
114
290
|
|
|
115
|
-
let content = '';
|
|
116
|
-
let size = 0;
|
|
117
|
-
let mtime = '';
|
|
118
|
-
|
|
119
291
|
try {
|
|
120
292
|
// Synchronous read inside transaction
|
|
121
293
|
const raw = require('node:fs').readFileSync(absPath, 'utf8');
|
|
122
|
-
|
|
123
|
-
content = raw.slice(0, 100_000); // cap at 100KB
|
|
124
|
-
size = stat.size;
|
|
125
|
-
mtime = stat.mtimeMs.toString();
|
|
294
|
+
content = raw.slice(0, CONTENT_LIMIT);
|
|
126
295
|
} catch {
|
|
127
296
|
skipped++;
|
|
128
297
|
continue;
|
|
129
298
|
}
|
|
130
299
|
|
|
131
|
-
const
|
|
300
|
+
const metadata = extractMetadata(relPath, content);
|
|
132
301
|
|
|
133
|
-
|
|
134
|
-
if (force) {
|
|
135
|
-
deletePrev.run(relPath);
|
|
136
|
-
}
|
|
302
|
+
deletePrev.run(baseDir, relPath);
|
|
137
303
|
|
|
138
|
-
insertDoc.run(relPath, title,
|
|
139
|
-
insertMeta.run(
|
|
304
|
+
insertDoc.run(baseDir, relPath, metadata.title, metadata.searchText);
|
|
305
|
+
insertMeta.run(
|
|
306
|
+
baseDir,
|
|
307
|
+
relPath,
|
|
308
|
+
new Date().toISOString(),
|
|
309
|
+
mtime,
|
|
310
|
+
size,
|
|
311
|
+
metadata.source_type,
|
|
312
|
+
metadata.description,
|
|
313
|
+
metadata.agents.join(','),
|
|
314
|
+
metadata.modes.join(','),
|
|
315
|
+
metadata.task_types.join(','),
|
|
316
|
+
metadata.triggers.join(','),
|
|
317
|
+
metadata.aliases.join(','),
|
|
318
|
+
metadata.entities.join(','),
|
|
319
|
+
metadata.paths.join(','),
|
|
320
|
+
metadata.retrieval_intents.join(','),
|
|
321
|
+
metadata.load_tier,
|
|
322
|
+
metadata.priority
|
|
323
|
+
);
|
|
140
324
|
indexed++;
|
|
141
325
|
}
|
|
142
326
|
});
|
|
@@ -145,6 +329,42 @@ class IndexManager {
|
|
|
145
329
|
return { indexed, skipped };
|
|
146
330
|
}
|
|
147
331
|
|
|
332
|
+
_purgeSkippedEntries(projectDir) {
|
|
333
|
+
const rows = this._db.prepare(
|
|
334
|
+
'SELECT rel_path FROM docs_meta WHERE project_dir = ?'
|
|
335
|
+
).all(projectDir);
|
|
336
|
+
const skipped = rows.filter((row) => matchesSkippedRelDir(row.rel_path));
|
|
337
|
+
if (skipped.length === 0) return;
|
|
338
|
+
|
|
339
|
+
const purge = this._db.transaction((items) => {
|
|
340
|
+
const delDoc = this._db.prepare('DELETE FROM docs WHERE project_dir = ? AND rel_path = ?');
|
|
341
|
+
const delMeta = this._db.prepare('DELETE FROM docs_meta WHERE project_dir = ? AND rel_path = ?');
|
|
342
|
+
for (const item of items) {
|
|
343
|
+
delDoc.run(projectDir, item.rel_path);
|
|
344
|
+
delMeta.run(projectDir, item.rel_path);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
purge(skipped);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
_purgeDeletedEntries(projectDir, currentRelPaths) {
|
|
351
|
+
const rows = this._db.prepare(
|
|
352
|
+
'SELECT rel_path FROM docs_meta WHERE project_dir = ?'
|
|
353
|
+
).all(projectDir);
|
|
354
|
+
const deleted = rows.filter((row) => !currentRelPaths.has(row.rel_path));
|
|
355
|
+
if (deleted.length === 0) return;
|
|
356
|
+
|
|
357
|
+
const purge = this._db.transaction((items) => {
|
|
358
|
+
const delDoc = this._db.prepare('DELETE FROM docs WHERE project_dir = ? AND rel_path = ?');
|
|
359
|
+
const delMeta = this._db.prepare('DELETE FROM docs_meta WHERE project_dir = ? AND rel_path = ?');
|
|
360
|
+
for (const item of items) {
|
|
361
|
+
delDoc.run(projectDir, item.rel_path);
|
|
362
|
+
delMeta.run(projectDir, item.rel_path);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
purge(deleted);
|
|
366
|
+
}
|
|
367
|
+
|
|
148
368
|
/**
|
|
149
369
|
* Full-text search with BM25 ranking + recency reranking.
|
|
150
370
|
* @param {string} query
|
|
@@ -157,52 +377,16 @@ class IndexManager {
|
|
|
157
377
|
|
|
158
378
|
if (!query || !query.trim()) return [];
|
|
159
379
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
SELECT rel_path, title,
|
|
165
|
-
snippet(docs, 2, '[', ']', '...', 20) AS snippet,
|
|
166
|
-
rank AS bm25_score
|
|
167
|
-
FROM docs
|
|
168
|
-
WHERE docs MATCH ?
|
|
169
|
-
`;
|
|
170
|
-
const params = [safeQuery];
|
|
171
|
-
|
|
172
|
-
if (projectDir) {
|
|
173
|
-
sql += ` AND rel_path IN (SELECT rel_path FROM docs_meta WHERE project_dir = ?)`;
|
|
174
|
-
params.push(projectDir);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
sql += ` ORDER BY rank LIMIT ?`;
|
|
178
|
-
params.push(limit * 2); // fetch more for reranking
|
|
179
|
-
|
|
180
|
-
let rows;
|
|
181
|
-
try {
|
|
182
|
-
rows = this._db.prepare(sql).all(...params);
|
|
183
|
-
} catch {
|
|
184
|
-
return [];
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Rerank by recency
|
|
188
|
-
const metas = new Map();
|
|
189
|
-
if (rows.length > 0) {
|
|
190
|
-
const relPaths = rows.map(r => r.rel_path);
|
|
191
|
-
const placeholders = relPaths.map(() => '?').join(',');
|
|
192
|
-
const metaRows = this._db.prepare(
|
|
193
|
-
`SELECT rel_path, indexed_at, file_mtime FROM docs_meta WHERE rel_path IN (${placeholders})`
|
|
194
|
-
).all(...relPaths);
|
|
195
|
-
for (const m of metaRows) {
|
|
196
|
-
metas.set(m.rel_path, m);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
380
|
+
const rows = this._searchRows(query, {
|
|
381
|
+
limit: limit * 2,
|
|
382
|
+
projectDir
|
|
383
|
+
});
|
|
199
384
|
|
|
200
385
|
const now = Date.now();
|
|
201
386
|
const results = rows.map(row => {
|
|
202
|
-
const meta = metas.get(row.rel_path);
|
|
203
387
|
let recencyBonus = 0;
|
|
204
|
-
if (
|
|
205
|
-
const ageMs = now - Number(
|
|
388
|
+
if (row.file_mtime) {
|
|
389
|
+
const ageMs = now - Number(row.file_mtime);
|
|
206
390
|
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
207
391
|
recencyBonus = Math.max(0, 1 - ageDays / 30); // fade over 30 days
|
|
208
392
|
}
|
|
@@ -210,10 +394,13 @@ class IndexManager {
|
|
|
210
394
|
const bm25 = -(row.bm25_score || 0);
|
|
211
395
|
const score = bm25 + recencyBonus * 0.5;
|
|
212
396
|
return {
|
|
397
|
+
projectDir: row.project_dir || '',
|
|
213
398
|
relPath: row.rel_path,
|
|
214
399
|
title: row.title,
|
|
215
400
|
snippet: row.snippet || '',
|
|
216
|
-
score
|
|
401
|
+
score,
|
|
402
|
+
source_type: row.source_type || '',
|
|
403
|
+
load_tier: row.load_tier || ''
|
|
217
404
|
};
|
|
218
405
|
});
|
|
219
406
|
|
|
@@ -221,6 +408,93 @@ class IndexManager {
|
|
|
221
408
|
return results.slice(0, limit);
|
|
222
409
|
}
|
|
223
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Search broad project context and return a load-oriented package.
|
|
413
|
+
*
|
|
414
|
+
* context:search is intentionally permissive: agent, mode, source and intent
|
|
415
|
+
* are ranking signals, not hard filters. context:select remains the final
|
|
416
|
+
* strict selector before loading files into an agent prompt.
|
|
417
|
+
*
|
|
418
|
+
* @param {string} query
|
|
419
|
+
* @param {object} opts
|
|
420
|
+
* @returns {{query:string, results:Array, package:{must_read:Array, should_read:Array, maybe:Array}}}
|
|
421
|
+
*/
|
|
422
|
+
searchPackage(query, opts = {}) {
|
|
423
|
+
const limit = Number(opts.limit) || 10;
|
|
424
|
+
const projectDir = opts.projectDir || null;
|
|
425
|
+
const searchInput = buildSearchInput(query, opts);
|
|
426
|
+
|
|
427
|
+
if (!searchInput.trim()) {
|
|
428
|
+
return emptyPackage(query);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const rows = this._searchRows(searchInput, {
|
|
432
|
+
limit: Math.max(limit * SEARCH_RESULT_MULTIPLIER, 30),
|
|
433
|
+
projectDir
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const context = buildRankingContext(query, opts);
|
|
437
|
+
const results = dedupeRankedResults(rows
|
|
438
|
+
.map((row) => rankContextRow(row, context))
|
|
439
|
+
.sort((a, b) => b.score - a.score || a.relPath.localeCompare(b.relPath)))
|
|
440
|
+
.slice(0, limit);
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
query,
|
|
444
|
+
search_input: searchInput,
|
|
445
|
+
results,
|
|
446
|
+
package: bucketResults(results)
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
_searchRows(query, opts = {}) {
|
|
451
|
+
const safeQuery = buildFtsQuery(query);
|
|
452
|
+
if (!safeQuery) return [];
|
|
453
|
+
|
|
454
|
+
let sql = `
|
|
455
|
+
SELECT
|
|
456
|
+
docs.project_dir,
|
|
457
|
+
docs.rel_path,
|
|
458
|
+
docs.title,
|
|
459
|
+
snippet(docs, 2, '[', ']', '...', 20) AS snippet,
|
|
460
|
+
rank AS bm25_score,
|
|
461
|
+
docs_meta.indexed_at,
|
|
462
|
+
docs_meta.file_mtime,
|
|
463
|
+
docs_meta.source_type,
|
|
464
|
+
docs_meta.description,
|
|
465
|
+
docs_meta.agents,
|
|
466
|
+
docs_meta.modes,
|
|
467
|
+
docs_meta.task_types,
|
|
468
|
+
docs_meta.triggers,
|
|
469
|
+
docs_meta.aliases,
|
|
470
|
+
docs_meta.entities,
|
|
471
|
+
docs_meta.paths,
|
|
472
|
+
docs_meta.retrieval_intents,
|
|
473
|
+
docs_meta.load_tier,
|
|
474
|
+
docs_meta.priority
|
|
475
|
+
FROM docs
|
|
476
|
+
LEFT JOIN docs_meta
|
|
477
|
+
ON docs.project_dir = docs_meta.project_dir
|
|
478
|
+
AND docs.rel_path = docs_meta.rel_path
|
|
479
|
+
WHERE docs MATCH ?
|
|
480
|
+
`;
|
|
481
|
+
const params = [safeQuery];
|
|
482
|
+
|
|
483
|
+
if (opts.projectDir) {
|
|
484
|
+
sql += ` AND docs.project_dir = ?`;
|
|
485
|
+
params.push(normalizeProjectDir(opts.projectDir));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
sql += ` ORDER BY rank LIMIT ?`;
|
|
489
|
+
params.push(Number(opts.limit) || 10);
|
|
490
|
+
|
|
491
|
+
try {
|
|
492
|
+
return this._db.prepare(sql).all(...params);
|
|
493
|
+
} catch {
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
224
498
|
/**
|
|
225
499
|
* Remove stale index entries older than maxAge ms.
|
|
226
500
|
* @param {number} maxAge — milliseconds (default 24h)
|
|
@@ -229,17 +503,17 @@ class IndexManager {
|
|
|
229
503
|
invalidateStale(maxAge = MAX_STALE_MS) {
|
|
230
504
|
const cutoff = new Date(Date.now() - maxAge + 1).toISOString();
|
|
231
505
|
const stale = this._db.prepare(
|
|
232
|
-
'SELECT rel_path FROM docs_meta WHERE indexed_at < ?'
|
|
506
|
+
'SELECT project_dir, rel_path FROM docs_meta WHERE indexed_at < ?'
|
|
233
507
|
).all(cutoff);
|
|
234
508
|
|
|
235
509
|
if (stale.length === 0) return { removed: 0 };
|
|
236
510
|
|
|
237
511
|
const doRemove = this._db.transaction((rows) => {
|
|
238
|
-
const delDoc = this._db.prepare(
|
|
239
|
-
const delMeta = this._db.prepare('DELETE FROM docs_meta WHERE rel_path = ?');
|
|
512
|
+
const delDoc = this._db.prepare('DELETE FROM docs WHERE project_dir = ? AND rel_path = ?');
|
|
513
|
+
const delMeta = this._db.prepare('DELETE FROM docs_meta WHERE project_dir = ? AND rel_path = ?');
|
|
240
514
|
for (const row of rows) {
|
|
241
|
-
delDoc.run(row.rel_path);
|
|
242
|
-
delMeta.run(row.rel_path);
|
|
515
|
+
delDoc.run(row.project_dir, row.rel_path);
|
|
516
|
+
delMeta.run(row.project_dir, row.rel_path);
|
|
243
517
|
}
|
|
244
518
|
});
|
|
245
519
|
|
|
@@ -278,23 +552,40 @@ async function walkDir(dir, extensions, results, baseDir) {
|
|
|
278
552
|
}
|
|
279
553
|
|
|
280
554
|
for (const entry of entries) {
|
|
281
|
-
// Skip hidden dirs and node_modules
|
|
282
|
-
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
283
|
-
|
|
284
555
|
const absPath = path.join(dir, entry.name);
|
|
285
556
|
|
|
286
557
|
if (entry.isDirectory()) {
|
|
558
|
+
if (shouldSkipDirectory(absPath, entry.name, baseDir)) continue;
|
|
287
559
|
await walkDir(absPath, extensions, results, baseDir);
|
|
288
560
|
} else if (entry.isFile()) {
|
|
289
561
|
const ext = path.extname(entry.name).toLowerCase();
|
|
290
562
|
if (extensions.includes(ext)) {
|
|
291
|
-
const relPath = path.relative(baseDir, absPath);
|
|
563
|
+
const relPath = normalizeRelPath(path.relative(baseDir, absPath));
|
|
292
564
|
results.push({ relPath, absPath });
|
|
293
565
|
}
|
|
294
566
|
}
|
|
295
567
|
}
|
|
296
568
|
}
|
|
297
569
|
|
|
570
|
+
function shouldSkipDirectory(absPath, dirName, baseDir) {
|
|
571
|
+
if (SKIP_DIR_NAMES.has(dirName)) return true;
|
|
572
|
+
if (dirName.startsWith('.') && dirName !== '.aioson') return true;
|
|
573
|
+
|
|
574
|
+
const relPath = normalizeRelPath(path.relative(baseDir, absPath));
|
|
575
|
+
if (!relPath) return false;
|
|
576
|
+
return matchesSkippedRelDir(relPath);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function matchesSkippedRelDir(relPathValue) {
|
|
580
|
+
const relPath = normalizeRelPath(relPathValue);
|
|
581
|
+
return SKIP_REL_DIRS.some((skip) => (
|
|
582
|
+
relPath === skip
|
|
583
|
+
|| relPath.startsWith(`${skip}/`)
|
|
584
|
+
|| relPath.endsWith(`/${skip}`)
|
|
585
|
+
|| relPath.includes(`/${skip}/`)
|
|
586
|
+
));
|
|
587
|
+
}
|
|
588
|
+
|
|
298
589
|
function extractTitle(relPath, content) {
|
|
299
590
|
// Try first H1 heading
|
|
300
591
|
const match = content.match(/^#\s+(.+)$/m);
|
|
@@ -303,6 +594,326 @@ function extractTitle(relPath, content) {
|
|
|
303
594
|
return path.basename(relPath, path.extname(relPath));
|
|
304
595
|
}
|
|
305
596
|
|
|
597
|
+
function extractMetadata(relPath, content) {
|
|
598
|
+
const fm = parseFrontmatter(content);
|
|
599
|
+
const title = fm.title || fm.name || extractTitle(relPath, content);
|
|
600
|
+
const description = fm.description || fm.summary || '';
|
|
601
|
+
const sourceType = normalizeKey(fm.source_type || fm.sourceType || inferSourceType(relPath));
|
|
602
|
+
const loadTier = normalizeKey(fm.load_tier || fm.loadTier || inferLoadTier(sourceType, relPath));
|
|
603
|
+
const priority = Number.parseInt(fm.priority || '0', 10) || 0;
|
|
604
|
+
|
|
605
|
+
const metadata = {
|
|
606
|
+
title,
|
|
607
|
+
description,
|
|
608
|
+
source_type: sourceType,
|
|
609
|
+
agents: parseAgentList(fm.agents) || [],
|
|
610
|
+
modes: parseListValue(fm.modes),
|
|
611
|
+
task_types: parseListValue(fm.task_types || fm.taskTypes),
|
|
612
|
+
triggers: parseListValue(fm.triggers),
|
|
613
|
+
aliases: parseListValue(fm.aliases || fm.alias),
|
|
614
|
+
entities: parseListValue(fm.entities || fm.entity),
|
|
615
|
+
paths: parseListValue(fm.paths || fm.globs),
|
|
616
|
+
retrieval_intents: parseListValue(fm.retrieval_intents || fm.intents || fm.intent),
|
|
617
|
+
load_tier: loadTier,
|
|
618
|
+
priority
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
metadata.searchText = [
|
|
622
|
+
relPath,
|
|
623
|
+
title,
|
|
624
|
+
description,
|
|
625
|
+
sourceType,
|
|
626
|
+
loadTier,
|
|
627
|
+
metadata.agents.join(' '),
|
|
628
|
+
metadata.modes.join(' '),
|
|
629
|
+
metadata.task_types.join(' '),
|
|
630
|
+
metadata.triggers.join(' '),
|
|
631
|
+
metadata.aliases.join(' '),
|
|
632
|
+
metadata.entities.join(' '),
|
|
633
|
+
metadata.paths.join(' '),
|
|
634
|
+
metadata.retrieval_intents.join(' '),
|
|
635
|
+
stripFrontmatter(content)
|
|
636
|
+
].filter(Boolean).join('\n');
|
|
637
|
+
|
|
638
|
+
return metadata;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function stripFrontmatter(content) {
|
|
642
|
+
return String(content || '').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function inferSourceType(relPath) {
|
|
646
|
+
const normalized = normalizeRelPath(relPath).toLowerCase();
|
|
647
|
+
if (normalized.includes('/.aioson/rules/') || normalized.startsWith('.aioson/rules/')) return 'rule';
|
|
648
|
+
if (normalized.includes('/.aioson/docs/') || normalized.startsWith('.aioson/docs/')) return 'doc';
|
|
649
|
+
if (normalized.includes('/.aioson/design-docs/') || normalized.startsWith('.aioson/design-docs/')) return 'design-governance';
|
|
650
|
+
if (normalized.includes('/.aioson/skills/') || normalized.startsWith('.aioson/skills/')) return normalized.endsWith('/skill.md') ? 'skill' : 'skill-reference';
|
|
651
|
+
if (normalized.includes('/.aioson/installed-skills/') || normalized.startsWith('.aioson/installed-skills/')) return normalized.endsWith('/skill.md') ? 'skill' : 'skill-reference';
|
|
652
|
+
if (normalized.includes('/.aioson/context/bootstrap/') || normalized.startsWith('.aioson/context/bootstrap/')) return 'bootstrap';
|
|
653
|
+
if (normalized.includes('/.aioson/context/features/') || normalized.startsWith('.aioson/context/features/')) return 'feature';
|
|
654
|
+
if (normalized.includes('/.aioson/context/') || normalized.startsWith('.aioson/context/')) return 'context';
|
|
655
|
+
if (normalized.includes('/.aioson/briefings/') || normalized.startsWith('.aioson/briefings/')) return 'briefing';
|
|
656
|
+
if (normalized.startsWith('researchs/')) return 'research';
|
|
657
|
+
if (normalized.startsWith('plans/')) return 'plan';
|
|
658
|
+
if (normalized.startsWith('prds/')) return 'prd';
|
|
659
|
+
return 'file';
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function inferLoadTier(sourceType, relPath) {
|
|
663
|
+
const normalized = normalizeRelPath(relPath).toLowerCase();
|
|
664
|
+
const base = path.basename(normalized);
|
|
665
|
+
if (base === 'project.context.md' || base === 'project-pulse.md') return 'always';
|
|
666
|
+
if (sourceType === 'rule') return 'trigger';
|
|
667
|
+
if (sourceType === 'skill') return 'skill';
|
|
668
|
+
if (sourceType === 'bootstrap') return 'trigger';
|
|
669
|
+
if (sourceType === 'feature' || sourceType === 'prd' || sourceType === 'plan') return 'feature';
|
|
670
|
+
return 'reference';
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function parseListValue(value) {
|
|
674
|
+
if (Array.isArray(value)) {
|
|
675
|
+
return value.map(String).map((item) => item.trim()).filter(Boolean);
|
|
676
|
+
}
|
|
677
|
+
if (value === undefined || value === null) return [];
|
|
678
|
+
const raw = String(value).trim();
|
|
679
|
+
if (!raw || raw === '[]') return [];
|
|
680
|
+
if (raw.startsWith('[') && raw.endsWith(']')) {
|
|
681
|
+
return raw
|
|
682
|
+
.slice(1, -1)
|
|
683
|
+
.split(',')
|
|
684
|
+
.map((item) => item.trim().replace(/^["']|["']$/g, ''))
|
|
685
|
+
.filter(Boolean);
|
|
686
|
+
}
|
|
687
|
+
return raw
|
|
688
|
+
.split(',')
|
|
689
|
+
.map((item) => item.trim().replace(/^["']|["']$/g, ''))
|
|
690
|
+
.filter(Boolean);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function buildSearchInput(query, opts = {}) {
|
|
694
|
+
const values = [
|
|
695
|
+
query,
|
|
696
|
+
opts.task,
|
|
697
|
+
opts.goal,
|
|
698
|
+
opts.paths,
|
|
699
|
+
opts.path,
|
|
700
|
+
opts.intent,
|
|
701
|
+
opts.intents,
|
|
702
|
+
opts.source,
|
|
703
|
+
opts.sourceType,
|
|
704
|
+
opts.source_type
|
|
705
|
+
];
|
|
706
|
+
return values.flatMap(parseListValue).join(' ').trim();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function buildRankingContext(query, opts = {}) {
|
|
710
|
+
const agent = normalizeKey(opts.agent || '');
|
|
711
|
+
const mode = normalizeKey(opts.mode || '');
|
|
712
|
+
const sourceTypes = parseListValue(opts.source || opts.sourceType || opts.source_type).map(normalizeKey);
|
|
713
|
+
const intents = parseListValue(opts.intent || opts.intents || opts.retrieval_intents).map(normalizeToken);
|
|
714
|
+
const paths = parseListValue(opts.paths || opts.path).map(normalizeRelPath);
|
|
715
|
+
const task = [query, opts.task, opts.goal, paths.join(' '), intents.join(' ')].filter(Boolean).join(' ');
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
agent,
|
|
719
|
+
mode,
|
|
720
|
+
sourceTypes,
|
|
721
|
+
intents,
|
|
722
|
+
paths,
|
|
723
|
+
lookup: normalizeToken(task)
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function rankContextRow(row, context) {
|
|
728
|
+
const reasons = [];
|
|
729
|
+
const metadata = rowToMetadata(row);
|
|
730
|
+
const bm25 = Math.max(0, -(Number(row.bm25_score) || 0));
|
|
731
|
+
let score = 20 + bm25 * 10;
|
|
732
|
+
|
|
733
|
+
if (metadata.priority) {
|
|
734
|
+
score += Math.min(20, Math.max(0, metadata.priority));
|
|
735
|
+
reasons.push(`priority:${metadata.priority}`);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (metadata.source_type === 'rule') {
|
|
739
|
+
score += 16;
|
|
740
|
+
reasons.push('source:rule');
|
|
741
|
+
} else if (metadata.source_type === 'skill' || metadata.source_type === 'skill-reference') {
|
|
742
|
+
score += 10;
|
|
743
|
+
reasons.push(`source:${metadata.source_type}`);
|
|
744
|
+
} else if (metadata.source_type === 'bootstrap' || metadata.source_type === 'context') {
|
|
745
|
+
score += 8;
|
|
746
|
+
reasons.push(`source:${metadata.source_type}`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (context.sourceTypes.length > 0 && context.sourceTypes.includes(metadata.source_type)) {
|
|
750
|
+
score += 14;
|
|
751
|
+
reasons.push(`source-filter:${metadata.source_type}`);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (context.agent) {
|
|
755
|
+
if (metadata.agents.length === 0 || metadata.agents.includes('all')) {
|
|
756
|
+
score += 4;
|
|
757
|
+
reasons.push('agent:all');
|
|
758
|
+
} else if (metadata.agents.map(normalizeKey).includes(context.agent)) {
|
|
759
|
+
score += 14;
|
|
760
|
+
reasons.push(`agent:${context.agent}`);
|
|
761
|
+
} else {
|
|
762
|
+
score -= 4;
|
|
763
|
+
reasons.push('agent:mismatch-boost-only');
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (context.mode) {
|
|
768
|
+
if (metadata.modes.length === 0) {
|
|
769
|
+
score += 2;
|
|
770
|
+
} else if (metadata.modes.map(normalizeKey).includes(context.mode)) {
|
|
771
|
+
score += 10;
|
|
772
|
+
reasons.push(`mode:${context.mode}`);
|
|
773
|
+
} else {
|
|
774
|
+
score -= 3;
|
|
775
|
+
reasons.push('mode:mismatch-boost-only');
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const boosts = [
|
|
780
|
+
['task_types', metadata.task_types, 18],
|
|
781
|
+
['triggers', metadata.triggers, 24],
|
|
782
|
+
['aliases', metadata.aliases, 24],
|
|
783
|
+
['entities', metadata.entities, 20],
|
|
784
|
+
['retrieval_intents', metadata.retrieval_intents, 18],
|
|
785
|
+
['paths', metadata.paths, 16]
|
|
786
|
+
];
|
|
787
|
+
for (const [label, values, points] of boosts) {
|
|
788
|
+
const matched = label === 'paths'
|
|
789
|
+
? pathMatches(context.paths, values)
|
|
790
|
+
: keywordMatches(context.lookup, values);
|
|
791
|
+
if (matched.length > 0) {
|
|
792
|
+
score += points;
|
|
793
|
+
reasons.push(`${label}:${matched.slice(0, 3).join(',')}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
if (context.intents.length > 0) {
|
|
798
|
+
const matchedIntents = listIntersection(context.intents, metadata.retrieval_intents.map(normalizeToken));
|
|
799
|
+
if (matchedIntents.length > 0) {
|
|
800
|
+
score += 16;
|
|
801
|
+
reasons.push(`intent:${matchedIntents.slice(0, 3).join(',')}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
if (metadata.description && phraseMatches(context.lookup, metadata.description)) {
|
|
806
|
+
score += 10;
|
|
807
|
+
reasons.push('description');
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const confidence = Math.max(0.1, Math.min(0.99, score / 100));
|
|
811
|
+
return {
|
|
812
|
+
projectDir: row.project_dir || '',
|
|
813
|
+
relPath: row.rel_path,
|
|
814
|
+
path: row.rel_path,
|
|
815
|
+
title: row.title,
|
|
816
|
+
snippet: row.snippet || '',
|
|
817
|
+
score: Number(score.toFixed(3)),
|
|
818
|
+
confidence: Number(confidence.toFixed(2)),
|
|
819
|
+
source_type: metadata.source_type,
|
|
820
|
+
load_tier: metadata.load_tier,
|
|
821
|
+
reason: reasons.length > 0 ? reasons.join('; ') : 'fts',
|
|
822
|
+
metadata
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function rowToMetadata(row) {
|
|
827
|
+
return {
|
|
828
|
+
source_type: normalizeKey(row.source_type || 'file'),
|
|
829
|
+
description: row.description || '',
|
|
830
|
+
agents: parseListValue(row.agents).map(normalizeKey),
|
|
831
|
+
modes: parseListValue(row.modes).map(normalizeKey),
|
|
832
|
+
task_types: parseListValue(row.task_types),
|
|
833
|
+
triggers: parseListValue(row.triggers),
|
|
834
|
+
aliases: parseListValue(row.aliases),
|
|
835
|
+
entities: parseListValue(row.entities),
|
|
836
|
+
paths: parseListValue(row.paths),
|
|
837
|
+
retrieval_intents: parseListValue(row.retrieval_intents),
|
|
838
|
+
load_tier: normalizeKey(row.load_tier || ''),
|
|
839
|
+
priority: Number.parseInt(row.priority || '0', 10) || 0
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function bucketResults(results) {
|
|
844
|
+
const must = [];
|
|
845
|
+
const should = [];
|
|
846
|
+
const maybe = [];
|
|
847
|
+
|
|
848
|
+
for (const result of results) {
|
|
849
|
+
const hardRuleHit = result.source_type === 'rule'
|
|
850
|
+
&& /(?:task_types|triggers|aliases|entities|paths|retrieval_intents):/.test(result.reason);
|
|
851
|
+
if (result.score >= 70 || hardRuleHit) {
|
|
852
|
+
must.push(result);
|
|
853
|
+
} else if (result.score >= 45) {
|
|
854
|
+
should.push(result);
|
|
855
|
+
} else {
|
|
856
|
+
maybe.push(result);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return {
|
|
861
|
+
must_read: must,
|
|
862
|
+
should_read: should,
|
|
863
|
+
maybe
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function dedupeRankedResults(results) {
|
|
868
|
+
const byKey = new Map();
|
|
869
|
+
|
|
870
|
+
for (const result of results) {
|
|
871
|
+
const key = canonicalResultKey(result);
|
|
872
|
+
const current = byKey.get(key);
|
|
873
|
+
byKey.set(key, preferResult(result, current));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return Array.from(byKey.values())
|
|
877
|
+
.sort((a, b) => b.score - a.score || a.relPath.localeCompare(b.relPath));
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function canonicalResultKey(result) {
|
|
881
|
+
const relPath = normalizeRelPath(result.relPath || result.path).toLowerCase();
|
|
882
|
+
const projectDir = normalizeRelPath(result.projectDir || result.project_dir || '').toLowerCase();
|
|
883
|
+
const pathKey = relPath.startsWith('template/') ? relPath.slice('template/'.length) : relPath;
|
|
884
|
+
return `${projectDir}\0${pathKey}`;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function preferResult(candidate, current) {
|
|
888
|
+
if (!current) return candidate;
|
|
889
|
+
|
|
890
|
+
const candidateTemplate = isTemplateMirror(candidate);
|
|
891
|
+
const currentTemplate = isTemplateMirror(current);
|
|
892
|
+
if (candidateTemplate !== currentTemplate) {
|
|
893
|
+
return candidateTemplate ? current : candidate;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (candidate.score > current.score) return candidate;
|
|
897
|
+
return current;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function isTemplateMirror(result) {
|
|
901
|
+
return normalizeRelPath(result.relPath || result.path).toLowerCase().startsWith('template/');
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function emptyPackage(query) {
|
|
905
|
+
return {
|
|
906
|
+
query,
|
|
907
|
+
search_input: '',
|
|
908
|
+
results: [],
|
|
909
|
+
package: {
|
|
910
|
+
must_read: [],
|
|
911
|
+
should_read: [],
|
|
912
|
+
maybe: []
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
306
917
|
function sanitizeFtsQuery(query) {
|
|
307
918
|
// Remove FTS5 special chars that could cause parse errors
|
|
308
919
|
return query
|
|
@@ -313,6 +924,88 @@ function sanitizeFtsQuery(query) {
|
|
|
313
924
|
.join(' ');
|
|
314
925
|
}
|
|
315
926
|
|
|
927
|
+
function buildFtsQuery(query) {
|
|
928
|
+
const tokens = tokenizeQuery(query).slice(0, 32);
|
|
929
|
+
if (tokens.length === 0) return '';
|
|
930
|
+
return [...new Set(tokens)]
|
|
931
|
+
.map((token) => `"${token.replace(/"/g, '')}"`)
|
|
932
|
+
.join(' OR ');
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function tokenizeQuery(value) {
|
|
936
|
+
return normalizeToken(value)
|
|
937
|
+
.split(/\s+/)
|
|
938
|
+
.map((token) => token.trim())
|
|
939
|
+
.filter((token) => token.length > 1);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function keywordMatches(lookup, values) {
|
|
943
|
+
const matches = [];
|
|
944
|
+
for (const value of values) {
|
|
945
|
+
if (phraseMatches(lookup, value)) matches.push(String(value));
|
|
946
|
+
}
|
|
947
|
+
return matches;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function phraseMatches(lookup, value) {
|
|
951
|
+
const normalized = normalizeToken(value);
|
|
952
|
+
if (!lookup || !normalized) return false;
|
|
953
|
+
const parts = normalized.split(/\s+/).filter(Boolean);
|
|
954
|
+
// Short single-token signals must match whole-word, else "ui"/"api" false-fire
|
|
955
|
+
// inside "build"/"rapid" and add rank noise to recall results.
|
|
956
|
+
if (parts.length === 1 && normalized.length <= 3) {
|
|
957
|
+
return wholeWordInLookup(lookup, normalized);
|
|
958
|
+
}
|
|
959
|
+
if (lookup.includes(normalized)) return true;
|
|
960
|
+
return parts.length > 1 && parts.every((part) => lookup.includes(part));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function wholeWordInLookup(lookup, token) {
|
|
964
|
+
if (!token) return false;
|
|
965
|
+
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
966
|
+
// normalizeToken keeps only [a-z0-9/-]; treat those as word chars.
|
|
967
|
+
return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`).test(lookup);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function pathMatches(requestedPaths, patterns) {
|
|
971
|
+
const matches = [];
|
|
972
|
+
for (const requestedPath of requestedPaths) {
|
|
973
|
+
for (const pattern of patterns) {
|
|
974
|
+
if (pathPatternMatches(requestedPath, pattern)) {
|
|
975
|
+
matches.push(`${requestedPath}~${pattern}`);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return matches;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function pathPatternMatches(filePath, pattern) {
|
|
983
|
+
return selectorPathMatchesPattern(filePath, pattern);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function listIntersection(a, b) {
|
|
987
|
+
const right = new Set(b);
|
|
988
|
+
return a.filter((item) => right.has(item));
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function normalizeRelPath(value) {
|
|
992
|
+
return String(value || '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function normalizeKey(value) {
|
|
996
|
+
return normalizeToken(value).replace(/\s+/g, '-');
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function normalizeToken(value) {
|
|
1000
|
+
return String(value || '')
|
|
1001
|
+
.normalize('NFD')
|
|
1002
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
1003
|
+
.toLowerCase()
|
|
1004
|
+
.replace(/[`*_]/g, '')
|
|
1005
|
+
.replace(/[^a-z0-9/-]+/g, ' ')
|
|
1006
|
+
.trim();
|
|
1007
|
+
}
|
|
1008
|
+
|
|
316
1009
|
/**
|
|
317
1010
|
* Convenience: open a global IndexManager, use it, close it.
|
|
318
1011
|
*/
|
|
@@ -326,4 +1019,11 @@ async function withIndex(fn, searchDir) {
|
|
|
326
1019
|
}
|
|
327
1020
|
}
|
|
328
1021
|
|
|
329
|
-
module.exports = {
|
|
1022
|
+
module.exports = {
|
|
1023
|
+
IndexManager,
|
|
1024
|
+
withIndex,
|
|
1025
|
+
sanitizeFtsQuery,
|
|
1026
|
+
buildFtsQuery,
|
|
1027
|
+
extractMetadata,
|
|
1028
|
+
inferSourceType
|
|
1029
|
+
};
|