@jaimevalasek/aioson 1.28.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.
Files changed (155) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +7 -5
  3. package/docs/en/5-reference/cli-reference.md +40 -10
  4. package/docs/pt/4-agentes/briefing.md +2 -0
  5. package/docs/pt/4-agentes/copywriter.md +2 -0
  6. package/docs/pt/4-agentes/genome.md +1 -0
  7. package/docs/pt/4-agentes/pm.md +1 -1
  8. package/docs/pt/4-agentes/profiler-enricher.md +2 -0
  9. package/docs/pt/4-agentes/profiler-forge.md +2 -0
  10. package/docs/pt/4-agentes/sheldon.md +2 -0
  11. package/docs/pt/4-agentes/squad.md +12 -10
  12. package/docs/pt/5-referencia/autopilot-handoff.md +4 -4
  13. package/docs/pt/5-referencia/comandos-cli.md +7 -3
  14. package/docs/pt/5-referencia/fluxo-artefatos.md +1 -1
  15. package/docs/pt/5-referencia/memoria-e-contexto.md +62 -2
  16. package/docs/pt/_arquivo/monitor-de-contexto.md +2 -2
  17. package/package.json +4 -2
  18. package/src/cli.js +72 -24
  19. package/src/commands/ac-test-audit.js +45 -0
  20. package/src/commands/artifact-validate.js +62 -50
  21. package/src/commands/classify.js +73 -2
  22. package/src/commands/context-brief.js +59 -0
  23. package/src/commands/context-guard.js +88 -0
  24. package/src/commands/context-monitor.js +1 -1
  25. package/src/commands/context-search.js +101 -52
  26. package/src/commands/context-select.js +11 -2
  27. package/src/commands/feature-archive.js +21 -12
  28. package/src/commands/feature-current.js +82 -0
  29. package/src/commands/gate-check.js +32 -15
  30. package/src/commands/harness-check.js +17 -1
  31. package/src/commands/hooks-install.js +169 -26
  32. package/src/commands/hygiene-scan.js +423 -0
  33. package/src/commands/rules-lint.js +124 -0
  34. package/src/commands/sdd-benchmark.js +134 -0
  35. package/src/commands/spec-analyze.js +6 -4
  36. package/src/commands/store-system.js +329 -49
  37. package/src/constants.js +8 -3
  38. package/src/context-brief.js +585 -0
  39. package/src/context-guard.js +209 -0
  40. package/src/context-search.js +796 -96
  41. package/src/context-selector.js +802 -420
  42. package/src/handoff-contract.js +14 -6
  43. package/src/harness/contract-schema.js +1 -1
  44. package/src/i18n/messages/en.js +12 -5
  45. package/src/i18n/messages/es.js +11 -4
  46. package/src/i18n/messages/fr.js +11 -4
  47. package/src/i18n/messages/pt-BR.js +12 -5
  48. package/src/lib/ac-test-audit.js +194 -0
  49. package/src/preflight-engine.js +10 -6
  50. package/src/squad/state-manager.js +1 -1
  51. package/template/.aioson/agents/analyst.md +93 -53
  52. package/template/.aioson/agents/architect.md +41 -32
  53. package/template/.aioson/agents/briefing-refiner.md +15 -2
  54. package/template/.aioson/agents/briefing.md +105 -86
  55. package/template/.aioson/agents/committer.md +1 -1
  56. package/template/.aioson/agents/copywriter.md +53 -10
  57. package/template/.aioson/agents/design-hybrid-forge.md +9 -5
  58. package/template/.aioson/agents/dev.md +22 -25
  59. package/template/.aioson/agents/deyvin.md +126 -124
  60. package/template/.aioson/agents/discover.md +8 -9
  61. package/template/.aioson/agents/discovery-design-doc.md +52 -36
  62. package/template/.aioson/agents/forge-run.md +3 -0
  63. package/template/.aioson/agents/genome.md +12 -6
  64. package/template/.aioson/agents/neo.md +30 -24
  65. package/template/.aioson/agents/orache.md +16 -21
  66. package/template/.aioson/agents/orchestrator.md +40 -31
  67. package/template/.aioson/agents/pentester.md +22 -12
  68. package/template/.aioson/agents/pm.md +11 -2
  69. package/template/.aioson/agents/product.md +162 -183
  70. package/template/.aioson/agents/profiler-enricher.md +29 -6
  71. package/template/.aioson/agents/profiler-forge.md +16 -6
  72. package/template/.aioson/agents/profiler-researcher.md +10 -6
  73. package/template/.aioson/agents/qa.md +29 -19
  74. package/template/.aioson/agents/scope-check.md +14 -2
  75. package/template/.aioson/agents/sheldon.md +51 -21
  76. package/template/.aioson/agents/site-forge.md +4 -6
  77. package/template/.aioson/agents/squad.md +7 -12
  78. package/template/.aioson/agents/tester.md +40 -30
  79. package/template/.aioson/agents/ux-ui.md +56 -41
  80. package/template/.aioson/agents/validator.md +2 -2
  81. package/template/.aioson/config.md +4 -3
  82. package/template/.aioson/design-docs/agent-loading-contract.md +3 -3
  83. package/template/.aioson/docs/LAYERS.md +2 -0
  84. package/template/.aioson/docs/autonomy-protocol.md +7 -5
  85. package/template/.aioson/docs/autopilot-handoff.md +5 -3
  86. package/template/.aioson/docs/dev/execution-discipline.md +3 -0
  87. package/template/.aioson/docs/dev/simple-plan-lane.md +126 -77
  88. package/template/.aioson/docs/dev/stack-conventions.md +4 -1
  89. package/template/.aioson/docs/deyvin/continuity-recovery.md +21 -18
  90. package/template/.aioson/docs/deyvin/debugging-escalation.md +3 -0
  91. package/template/.aioson/docs/deyvin/pair-execution.md +3 -0
  92. package/template/.aioson/docs/deyvin/runtime-handoffs.md +6 -3
  93. package/template/.aioson/docs/dossier/agent-templates.md +3 -0
  94. package/template/.aioson/docs/dossier/schema.md +3 -0
  95. package/template/.aioson/docs/example-external-api-context.md +2 -0
  96. package/template/.aioson/docs/feature-expansion-taxonomy.md +53 -0
  97. package/template/.aioson/docs/handoff-persistence.md +95 -91
  98. package/template/.aioson/docs/pentester/app-playbooks.md +3 -0
  99. package/template/.aioson/docs/pentester/browser-dast-playbook.md +401 -398
  100. package/template/.aioson/docs/pentester/llm-supplychain.md +3 -0
  101. package/template/.aioson/docs/product/conversation-playbook.md +1 -1
  102. package/template/.aioson/docs/quality/code-health-analysis.md +2 -0
  103. package/template/.aioson/docs/sheldon/enrichment-paths.md +47 -1
  104. package/template/.aioson/docs/sheldon/harness-contract.md +26 -21
  105. package/template/.aioson/docs/sheldon/quality-lens.md +3 -0
  106. package/template/.aioson/docs/sheldon/research-loop.md +3 -0
  107. package/template/.aioson/docs/sheldon/web-intelligence.md +3 -0
  108. package/template/.aioson/docs/site-forge-build.md +4 -2
  109. package/template/.aioson/docs/site-forge-extraction.md +2 -0
  110. package/template/.aioson/docs/site-forge-qa.md +2 -0
  111. package/template/.aioson/docs/site-forge-recon.md +7 -5
  112. package/template/.aioson/docs/site-forge-transform.md +2 -0
  113. package/template/.aioson/docs/squad/content-output.md +3 -0
  114. package/template/.aioson/docs/squad/creation-flow.md +22 -1
  115. package/template/.aioson/docs/squad/domain-breadth.md +3 -0
  116. package/template/.aioson/docs/squad/domain-classification.md +3 -0
  117. package/template/.aioson/docs/squad/eval-gate.md +3 -0
  118. package/template/.aioson/docs/squad/genome-bindings.md +14 -0
  119. package/template/.aioson/docs/squad/package-contract.md +5 -0
  120. package/template/.aioson/docs/squad/persona-grounding.md +65 -62
  121. package/template/.aioson/docs/squad/quality-lens.md +3 -0
  122. package/template/.aioson/docs/squad/research-loop.md +3 -0
  123. package/template/.aioson/docs/squad/session-operations.md +3 -0
  124. package/template/.aioson/docs/squad/workflow-quality.md +3 -0
  125. package/template/.aioson/docs/tester/coverage-quality.md +4 -1
  126. package/template/.aioson/docs/ux-ui/design-execution.md +9 -7
  127. package/template/.aioson/rules/README.md +48 -2
  128. package/template/.aioson/rules/agent-language-policy.md +26 -21
  129. package/template/.aioson/rules/agent-structural-contract.md +168 -158
  130. package/template/.aioson/rules/aioson-context-boundary.md +7 -1
  131. package/template/.aioson/rules/canonical-path-contract.md +16 -10
  132. package/template/.aioson/rules/data-format-convention.md +17 -11
  133. package/template/.aioson/rules/disk-first-artifacts.md +12 -8
  134. package/template/.aioson/rules/example-monetary-values.md +4 -0
  135. package/template/.aioson/rules/implementation-structure-and-data-access.md +50 -0
  136. package/template/.aioson/rules/output-brevity.md +2 -0
  137. package/template/.aioson/rules/prd-section-ownership.md +17 -12
  138. package/template/.aioson/rules/security-baseline.md +8 -3
  139. package/template/.aioson/rules/simple-plan-lane.md +22 -5
  140. package/template/.aioson/rules/source-code-language-convention.md +34 -0
  141. package/template/.aioson/rules/spec-level-ownership.md +10 -5
  142. package/template/.aioson/rules/squad-driver-pattern.md +5 -0
  143. package/template/.aioson/skills/process/aioson-spec-driven/references/artifact-map.md +24 -23
  144. package/template/.aioson/skills/process/aioson-spec-driven/references/classification-map.md +4 -0
  145. package/template/.aioson/skills/process/aioson-spec-driven/references/dev.md +2 -2
  146. package/template/.aioson/skills/process/aioson-spec-driven/references/qa.md +1 -1
  147. package/template/.aioson/skills/process/briefing-expansion-scout/SKILL.md +72 -0
  148. package/template/.aioson/skills/process/product-scope-expansion/SKILL.md +74 -0
  149. package/template/.aioson/skills/process/sheldon-expansion-audit/SKILL.md +67 -0
  150. package/template/.aioson/skills/static/context-budget-guide.md +1 -1
  151. package/template/.aioson/skills/static/multi-agent-patterns.md +5 -4
  152. package/template/.aioson/tasks/squad-create.md +11 -0
  153. package/template/.aioson/tasks/squad-design.md +3 -3
  154. package/template/AGENTS.md +36 -19
  155. package/template/CLAUDE.md +9 -5
@@ -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 = 1;
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
- const db = new Database(dbPath);
19
- db.pragma('journal_mode = WAL');
20
- db.pragma('synchronous = NORMAL');
21
- // Wait up to 5s for a transient lock (e.g. WAL checkpoint, AV file-lock on
22
- // Windows) instead of throwing SQLITE_BUSY immediately.
23
- db.pragma('busy_timeout = 5000');
24
- db.exec(`
25
- CREATE TABLE IF NOT EXISTS schema_version (
26
- version INTEGER NOT NULL
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
- // Insert schema version if not present
46
- const ver = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
47
- if (!ver) {
48
- db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
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
- return db;
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
- const extensions = opts.extensions || ['.md', '.txt', '.json'];
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(dir, extensions);
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 (rel_path, project_dir, indexed_at, file_mtime, size)
95
- VALUES (?, ?, ?, ?, ?)
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
- "DELETE FROM docs WHERE rel_path = ?"
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
- // Check staleness
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
- const stat = require('node:fs').statSync(absPath);
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 title = extractTitle(relPath, content);
300
+ const metadata = extractMetadata(relPath, content);
132
301
 
133
- // Remove old entry if force
134
- if (force) {
135
- deletePrev.run(relPath);
136
- }
302
+ deletePrev.run(baseDir, relPath);
137
303
 
138
- insertDoc.run(relPath, title, content);
139
- insertMeta.run(relPath, dir, new Date().toISOString(), mtime, size);
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
- // Sanitize FTS5 query: escape special characters
161
- const safeQuery = sanitizeFtsQuery(query);
162
-
163
- let sql = `
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 (meta && meta.file_mtime) {
205
- const ageMs = now - Number(meta.file_mtime);
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("DELETE FROM docs WHERE rel_path = ?");
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 = { IndexManager, withIndex, sanitizeFtsQuery };
1022
+ module.exports = {
1023
+ IndexManager,
1024
+ withIndex,
1025
+ sanitizeFtsQuery,
1026
+ buildFtsQuery,
1027
+ extractMetadata,
1028
+ inferSourceType
1029
+ };