sweet-search 2.5.1 → 2.5.3

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 (159) hide show
  1. package/core/cli.js +45 -0
  2. package/core/embedding/embedding-cache.js +90 -4
  3. package/core/embedding/embedding-service.js +27 -5
  4. package/core/graph/graph-expansion.js +215 -36
  5. package/core/graph/graph-extractor.js +196 -11
  6. package/core/graph/graph-search.js +395 -92
  7. package/core/graph/hcgs-generator.js +2 -1
  8. package/core/graph/index.js +2 -0
  9. package/core/graph/repo-map.js +28 -6
  10. package/core/graph/structural-answer-cues.js +168 -0
  11. package/core/graph/structural-callsite-hints.js +40 -0
  12. package/core/graph/structural-context-format.js +40 -0
  13. package/core/graph/structural-context.js +450 -0
  14. package/core/graph/structural-forward-push.js +156 -0
  15. package/core/graph/structural-header-context.js +19 -0
  16. package/core/graph/structural-importance.js +148 -0
  17. package/core/graph/structural-pagerank.js +197 -0
  18. package/core/graph/summary-manager.js +13 -9
  19. package/core/incremental-indexing/application/dirty-scan.mjs +236 -0
  20. package/core/incremental-indexing/application/file-watcher.mjs +197 -0
  21. package/core/incremental-indexing/application/maintenance-handlers.mjs +519 -0
  22. package/core/incremental-indexing/application/maintenance-worker.mjs +380 -0
  23. package/core/incremental-indexing/application/operator-cli.mjs +554 -0
  24. package/core/incremental-indexing/application/production-li-delta.mjs +192 -0
  25. package/core/incremental-indexing/application/production-reconciler-helpers.mjs +107 -0
  26. package/core/incremental-indexing/application/production-reconciler.mjs +583 -0
  27. package/core/incremental-indexing/application/reconciler.mjs +477 -0
  28. package/core/incremental-indexing/application/tombstone-injector.mjs +148 -0
  29. package/core/incremental-indexing/domain/chunk-identity.mjs +260 -0
  30. package/core/incremental-indexing/domain/encoder-deps.mjs +193 -0
  31. package/core/incremental-indexing/domain/encoder-input.mjs +225 -0
  32. package/core/incremental-indexing/domain/interval-autotune.mjs +255 -0
  33. package/core/incremental-indexing/domain/reconcile-counters.mjs +149 -0
  34. package/core/incremental-indexing/domain/watermark-scheduler.mjs +239 -0
  35. package/core/incremental-indexing/infrastructure/artifact-temp-sweep.mjs +163 -0
  36. package/core/incremental-indexing/infrastructure/baseline-readiness.mjs +121 -0
  37. package/core/incremental-indexing/infrastructure/dirty-set.mjs +233 -0
  38. package/core/incremental-indexing/infrastructure/graph-gc.mjs +314 -0
  39. package/core/incremental-indexing/infrastructure/hashing.mjs +298 -0
  40. package/core/incremental-indexing/infrastructure/hcgs-invalidation.mjs +182 -0
  41. package/core/incremental-indexing/infrastructure/li-segment-merge.mjs +278 -0
  42. package/core/incremental-indexing/infrastructure/li-segment-state.mjs +173 -0
  43. package/core/incremental-indexing/infrastructure/lockfile.mjs +119 -0
  44. package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +283 -0
  45. package/core/incremental-indexing/infrastructure/manifest.mjs +194 -0
  46. package/core/incremental-indexing/infrastructure/path-filter.mjs +190 -0
  47. package/core/incremental-indexing/infrastructure/reader-heartbeat.mjs +201 -0
  48. package/core/incremental-indexing/infrastructure/schema-migrations.mjs +257 -0
  49. package/core/incremental-indexing/infrastructure/sparse-gram-delta.mjs +335 -0
  50. package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +176 -0
  51. package/core/incremental-indexing/infrastructure/staleness-display.mjs +105 -0
  52. package/core/incremental-indexing/infrastructure/tombstone-bitmap.mjs +234 -0
  53. package/core/incremental-indexing/infrastructure/vector-delta-writer.mjs +359 -0
  54. package/core/incremental-indexing/infrastructure/vector-gc.mjs +133 -0
  55. package/core/incremental-indexing/infrastructure/worktree-stamp.mjs +155 -0
  56. package/core/incremental-indexing/infrastructure/wsl2-detect.mjs +115 -0
  57. package/core/indexing/admission-policy.js +139 -0
  58. package/core/indexing/artifact-builder.js +29 -12
  59. package/core/indexing/ast-chunker.js +107 -30
  60. package/core/indexing/dedup/exemplar-selector.js +19 -1
  61. package/core/indexing/gitignore-filter.js +223 -0
  62. package/core/indexing/incremental-tracker.js +99 -30
  63. package/core/indexing/index-codebase-v21.js +37 -7
  64. package/core/indexing/index-maintainer.mjs +698 -6
  65. package/core/indexing/indexer-ann.js +99 -15
  66. package/core/indexing/indexer-build.js +158 -45
  67. package/core/indexing/indexer-empty-baseline.js +80 -0
  68. package/core/indexing/indexer-manifest.js +66 -0
  69. package/core/indexing/indexer-phases.js +56 -23
  70. package/core/indexing/indexer-sparse-gram.js +54 -13
  71. package/core/indexing/indexer-utils.js +26 -208
  72. package/core/indexing/indexing-file-policy.js +32 -7
  73. package/core/indexing/maintainer-launcher.mjs +137 -0
  74. package/core/indexing/merkle-tracker.js +251 -244
  75. package/core/indexing/model-pool.js +46 -5
  76. package/core/infrastructure/code-graph-repository.js +758 -6
  77. package/core/infrastructure/code-graph-visibility.js +157 -0
  78. package/core/infrastructure/codebase-repository.js +100 -13
  79. package/core/infrastructure/config/search.js +1 -1
  80. package/core/infrastructure/db-utils.js +118 -0
  81. package/core/infrastructure/dedup-hashing.js +10 -13
  82. package/core/infrastructure/hardware-capability.js +17 -7
  83. package/core/infrastructure/index.js +10 -2
  84. package/core/infrastructure/init-config.js +138 -0
  85. package/core/infrastructure/language-patterns/maps.js +4 -1
  86. package/core/infrastructure/language-patterns/registry-core.js +56 -17
  87. package/core/infrastructure/language-patterns/registry-object-oriented.js +12 -5
  88. package/core/infrastructure/language-patterns.js +69 -0
  89. package/core/infrastructure/model-registry.js +20 -0
  90. package/core/infrastructure/native-inference.js +7 -12
  91. package/core/infrastructure/native-resolver.js +52 -37
  92. package/core/infrastructure/native-sparse-gram.js +261 -20
  93. package/core/infrastructure/native-tokenizer.js +6 -15
  94. package/core/infrastructure/simd-distance.js +10 -16
  95. package/core/infrastructure/sparse-gram-delta-reader.js +76 -0
  96. package/core/infrastructure/structural-alias-resolver.js +122 -0
  97. package/core/infrastructure/structural-candidate-ranker.js +34 -0
  98. package/core/infrastructure/structural-context-repository.js +472 -0
  99. package/core/infrastructure/structural-context-utils.js +51 -0
  100. package/core/infrastructure/structural-graph-signals.js +121 -0
  101. package/core/infrastructure/structural-qualified-resolution.js +15 -0
  102. package/core/infrastructure/structural-source-definitions.js +100 -0
  103. package/core/infrastructure/tombstone-bitmap-reader.js +139 -0
  104. package/core/infrastructure/tree-sitter-provider.js +811 -37
  105. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md +50 -0
  106. package/core/query/query-router.js +55 -5
  107. package/core/ranking/file-kind-ranking.js +2192 -15
  108. package/core/ranking/late-interaction-index.js +87 -12
  109. package/core/search/cli-decoration.js +290 -0
  110. package/core/search/context-expander.js +988 -78
  111. package/core/search/index.js +1 -0
  112. package/core/search/output-policy.js +275 -0
  113. package/core/search/search-anchor.js +499 -0
  114. package/core/search/search-boost.js +93 -1
  115. package/core/search/search-cli.js +61 -204
  116. package/core/search/search-hybrid.js +250 -10
  117. package/core/search/search-pattern-chunks.js +57 -8
  118. package/core/search/search-pattern-planner.js +68 -9
  119. package/core/search/search-pattern-prefilter.js +30 -10
  120. package/core/search/search-pattern-ripgrep.js +40 -4
  121. package/core/search/search-pattern-sparse-overlay.js +256 -0
  122. package/core/search/search-pattern.js +117 -29
  123. package/core/search/search-postprocess.js +479 -5
  124. package/core/search/search-read-semantic.js +277 -23
  125. package/core/search/search-read.js +82 -64
  126. package/core/search/search-reader-pin.js +71 -0
  127. package/core/search/search-rrf.js +279 -0
  128. package/core/search/search-semantic.js +110 -5
  129. package/core/search/search-server.js +273 -54
  130. package/core/search/search-trace.js +107 -0
  131. package/core/search/server-identity.js +93 -0
  132. package/core/search/session-daemon-prewarm.mjs +33 -10
  133. package/core/search/sweet-search.js +414 -9
  134. package/core/skills/sweet-index/SKILL.md +8 -6
  135. package/core/start-server.js +13 -2
  136. package/core/vector-store/binary-hnsw-index.js +194 -30
  137. package/core/vector-store/float-vector-store.js +96 -6
  138. package/core/vector-store/hnsw-index.js +220 -49
  139. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +471 -0
  140. package/eval/agent-read-workflows/bin/ss-find +15 -0
  141. package/eval/agent-read-workflows/bin/ss-grep +12 -0
  142. package/eval/agent-read-workflows/bin/ss-read +14 -0
  143. package/eval/agent-read-workflows/bin/ss-search +18 -0
  144. package/eval/agent-read-workflows/bin/ss-semantic +12 -0
  145. package/eval/agent-read-workflows/bin/ss-trace +11 -0
  146. package/mcp/read-tool.js +109 -0
  147. package/mcp/server.js +55 -15
  148. package/mcp/tool-handlers.js +14 -124
  149. package/mcp/trace-tool.js +81 -0
  150. package/package.json +25 -10
  151. package/scripts/hooks/intercept-read.mjs +55 -0
  152. package/scripts/hooks/remind-tools.mjs +40 -0
  153. package/scripts/init.js +698 -54
  154. package/scripts/inject-agent-instructions.js +431 -0
  155. package/scripts/install-prompt-reminders.js +188 -0
  156. package/scripts/install-tool-enforcement.js +220 -0
  157. package/scripts/smoke-test.js +12 -9
  158. package/scripts/uninstall.js +427 -23
  159. package/scripts/write-claude-rules.js +110 -0
@@ -30,9 +30,27 @@
30
30
  */
31
31
 
32
32
  import path from 'node:path';
33
+ import fs from 'node:fs';
33
34
  import { CodebaseRepository } from '../infrastructure/codebase-repository.js';
34
- import { DB_PATHS, LATE_INTERACTION_CONFIG } from '../infrastructure/config/index.js';
35
+ import { DB_PATHS, LATE_INTERACTION_CONFIG, PROJECT_ROOT } from '../infrastructure/config/index.js';
36
+ import { applyPersistedLiModel } from '../infrastructure/init-config.js';
35
37
  import { readFile as readFileExact } from './search-read.js';
38
+ import { withPinnedRead } from './search-reader-pin.js';
39
+ import { emitToolIdentityAuto } from './cli-decoration.js';
40
+
41
+ // Applies the user's persisted LI model exactly once per (projectRoot, env)
42
+ // pair so encodeQuery/_getLateInteractionIndex below see the right variant.
43
+ // Without this an edge-only init silently uses the standard 768d model for
44
+ // query encoding while the on-disk LI index was built with the 256d edge
45
+ // model — every score becomes nonsense (the dim mismatch trips the
46
+ // modelMismatch guard but query encoding has already paid the wrong-cost).
47
+ const _appliedLiPerRoot = new Map(); // projectRoot -> appliedModel
48
+ function _ensurePersistedLiModelApplied(projectRoot) {
49
+ const key = projectRoot || process.cwd();
50
+ if (_appliedLiPerRoot.has(key)) return;
51
+ const r = applyPersistedLiModel(key);
52
+ _appliedLiPerRoot.set(key, r.applied);
53
+ }
36
54
 
37
55
  // ---------------------------------------------------------------------------
38
56
  // Defaults — keep modest so a one-file call stays under ~100ms after warmup.
@@ -47,6 +65,21 @@ const DEFAULTS = {
47
65
  lexicalWeight: 1.0,
48
66
  symbolWeight: 1.5, // symbol-name hits are stronger evidence per-file
49
67
  maxsimWeight: 1.6, // late interaction wins ties
68
+ // Demotion factors applied to the final re-rank score (after MaxSim re-rank).
69
+ // Stage 3 diagnosis (2026-05-13, PHASE6_REDO ss-semantic) found:
70
+ // - chunks with null/unknown symbol metadata frequently win top-1 when
71
+ // they're really file-header fragments or unnamed code blocks
72
+ // (CPP-002, RB-001, C-005, PY-004 dev failures)
73
+ // - tiny chunks (≤ 5 lines) inflate MaxSim by concentrating literal
74
+ // token presence in a small window (RB-001 `module Sinatra`,
75
+ // C-005 single-line `redisContext *redisConnectWithOptions(...)`).
76
+ // Multiplicative demotion at the final-rank stage is conservative: the
77
+ // chunk is still returned, just less likely to be top-1. Tunable; 0.85
78
+ // was chosen by inspecting per-failure score margins (typical wrong-vs-
79
+ // gold gap is 0.01-0.04, so 0.85 reliably flips the cases identified).
80
+ unsymboledDemote: 0.85,
81
+ smallChunkDemote: 0.85,
82
+ smallChunkMaxLines: 5,
50
83
  };
51
84
 
52
85
  const APPROX_CHARS_PER_TOKEN = 4;
@@ -55,36 +88,155 @@ const APPROX_CHARS_PER_TOKEN = 4;
55
88
  // Module-level lazy singletons
56
89
  // ---------------------------------------------------------------------------
57
90
 
58
- let _repo = null;
59
- function _getRepo() {
60
- if (_repo === null) {
61
- try { _repo = new CodebaseRepository(DB_PATHS.codebase); }
62
- catch { _repo = false; }
91
+ const RECONCILE_MANIFEST_FILENAME = 'reconcile-manifest.json';
92
+
93
+ function _projectKey(projectRoot) {
94
+ return path.resolve(projectRoot || PROJECT_ROOT || process.cwd());
95
+ }
96
+
97
+ function _dataDirName() {
98
+ const dir = path.basename(path.dirname(DB_PATHS.codebase || ''));
99
+ return dir && dir !== '.' ? dir : '.sweet-search';
100
+ }
101
+
102
+ function _stateDirForProject(projectRoot) {
103
+ const root = _projectKey(projectRoot);
104
+ if (root === path.resolve(PROJECT_ROOT)) return path.dirname(DB_PATHS.codebase);
105
+ return path.join(root, _dataDirName());
106
+ }
107
+
108
+ function _codebasePathForProject(projectRoot, manifest = null) {
109
+ const descriptor = manifest?.vectors?.path || manifest?.vectors?.dbPath;
110
+ if (descriptor) {
111
+ return _resolveStatePath(projectRoot, descriptor);
112
+ }
113
+ return _defaultCodebasePathForProject(projectRoot);
114
+ }
115
+
116
+ function _defaultCodebasePathForProject(projectRoot) {
117
+ const root = _projectKey(projectRoot);
118
+ if (root === path.resolve(PROJECT_ROOT)) return DB_PATHS.codebase;
119
+ return path.join(_stateDirForProject(root), 'codebase.db');
120
+ }
121
+
122
+ function _readReconcileManifest(projectRoot) {
123
+ try {
124
+ const manifest = JSON.parse(
125
+ fs.readFileSync(path.join(_stateDirForProject(projectRoot), RECONCILE_MANIFEST_FILENAME), 'utf-8'),
126
+ );
127
+ return Number.isInteger(manifest?.epoch) ? manifest : null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function _resolveStatePath(projectRoot, filePath) {
134
+ if (!filePath) return null;
135
+ if (path.isAbsolute(filePath)) return filePath;
136
+ return path.join(_stateDirForProject(projectRoot), filePath);
137
+ }
138
+
139
+ function _lateInteractionIndexPath(projectRoot, manifest) {
140
+ const descriptor = manifest?.lateInteraction?.path
141
+ || manifest?.lateInteraction?.indexPath
142
+ || manifest?.lateInteraction?.manifest;
143
+ if (descriptor) {
144
+ const resolved = _resolveStatePath(projectRoot, descriptor);
145
+ const segmentDir = path.dirname(resolved);
146
+ return segmentDir.endsWith('.segments')
147
+ ? segmentDir.slice(0, -'.segments'.length)
148
+ : resolved;
149
+ }
150
+ const root = _projectKey(projectRoot);
151
+ if (root === path.resolve(PROJECT_ROOT)) return DB_PATHS.lateInteraction;
152
+ if (DB_PATHS.lateInteraction && fs.existsSync(DB_PATHS.lateInteraction)) {
153
+ return DB_PATHS.lateInteraction;
154
+ }
155
+ return path.join(_stateDirForProject(root), path.basename(DB_PATHS.lateInteraction));
156
+ }
157
+
158
+ function _sourceStaleness(projectRoot, filePathRel) {
159
+ const manifest = _readReconcileManifest(projectRoot);
160
+ const publishedMs = Date.parse(manifest?.publishedAt || '');
161
+ if (!Number.isFinite(publishedMs)) return null;
162
+ try {
163
+ const abs = path.isAbsolute(filePathRel)
164
+ ? filePathRel
165
+ : path.resolve(projectRoot, filePathRel);
166
+ const stat = fs.statSync(abs);
167
+ if (stat.mtimeMs <= publishedMs) return null;
168
+ return {
169
+ stale: true,
170
+ indexEpoch: manifest.epoch,
171
+ indexPublishedAt: manifest.publishedAt,
172
+ sourceMtime: stat.mtime.toISOString(),
173
+ warning: 'source file is newer than the semantic index; spans were selected from stale index metadata and text was reread from disk',
174
+ };
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ const _repos = new Map();
181
+ function _getRepo(projectRoot) {
182
+ const key = _projectKey(projectRoot);
183
+ const manifest = _readReconcileManifest(projectRoot);
184
+ const dbPath = _codebasePathForProject(projectRoot, manifest);
185
+ const baseDbPath = _defaultCodebasePathForProject(projectRoot);
186
+ let entry = _repos.get(key);
187
+ if (!entry || entry.dbPath !== dbPath || entry.baseDbPath !== baseDbPath) {
188
+ entry?.repo?.close?.();
189
+ try {
190
+ entry = { dbPath, baseDbPath, repo: new CodebaseRepository(baseDbPath) };
191
+ _repos.set(key, entry);
192
+ } catch {
193
+ return null;
194
+ }
63
195
  }
64
- return _repo || null;
196
+ const repo = entry.repo;
197
+ repo.refreshManifestEpoch?.();
198
+ return repo;
65
199
  }
66
200
 
67
201
  let _liIndex = null;
68
202
  let _liInitPromise = null;
69
- async function _getLateInteractionIndex() {
70
- if (_liIndex) return _liIndex;
203
+ let _liProjectKey = null;
204
+ let _liManifestEpoch = null;
205
+ async function _getLateInteractionIndex(projectRoot) {
206
+ const projectKey = _projectKey(projectRoot);
207
+ const manifest = _readReconcileManifest(projectRoot);
208
+ const manifestEpoch = Number.isInteger(manifest?.epoch) ? manifest.epoch : null;
209
+ const samePin = _liProjectKey === projectKey && _liManifestEpoch === manifestEpoch;
210
+ if (_liIndex !== null && samePin) return _liIndex || null;
211
+ if (_liIndex !== null && !samePin) {
212
+ _liIndex = null;
213
+ _liInitPromise = null;
214
+ }
71
215
  if (_liInitPromise) return _liInitPromise;
72
216
  if (!LATE_INTERACTION_CONFIG?.enabled) return null;
73
217
  _liInitPromise = (async () => {
74
218
  try {
75
219
  const { LateInteractionIndex } = await import('../ranking/late-interaction-index.js');
76
- const idx = new LateInteractionIndex({});
220
+ const idx = new LateInteractionIndex({
221
+ indexPath: _lateInteractionIndexPath(projectRoot, manifest),
222
+ });
77
223
  await idx.init();
78
224
  // If the index is empty (no segments, no docs), treat as unavailable —
79
225
  // saves a noisy warning later when scoreWithLateInteraction runs.
80
226
  if (!idx.documents || idx.documents.size === 0) {
81
227
  _liIndex = false;
228
+ _liProjectKey = projectKey;
229
+ _liManifestEpoch = manifestEpoch;
82
230
  return null;
83
231
  }
84
232
  _liIndex = idx;
233
+ _liProjectKey = projectKey;
234
+ _liManifestEpoch = manifestEpoch;
85
235
  return idx;
86
236
  } catch {
87
237
  _liIndex = false;
238
+ _liProjectKey = projectKey;
239
+ _liManifestEpoch = manifestEpoch;
88
240
  return null;
89
241
  } finally {
90
242
  _liInitPromise = null;
@@ -115,7 +267,26 @@ function _projectRelative(absOrRelPath, projectRoot) {
115
267
  ? absOrRelPath
116
268
  : path.resolve(root, absOrRelPath);
117
269
  const rel = path.relative(root, abs);
118
- return rel.startsWith('..') || path.isAbsolute(rel) ? abs : rel;
270
+ const normalized = _normalizeRelativePath(rel);
271
+ if (normalized) return normalized;
272
+ try {
273
+ const realRel = path.relative(
274
+ fs.realpathSync.native(root),
275
+ fs.realpathSync.native(abs),
276
+ );
277
+ return _normalizeRelativePath(realRel) || abs;
278
+ } catch {
279
+ return abs;
280
+ }
281
+ }
282
+
283
+ function _normalizeRelativePath(rel) {
284
+ const normalized = rel.replace(/\\/g, '/').replace(/^\.\//, '');
285
+ if (!normalized || normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) {
286
+ return null;
287
+ }
288
+ if (path.isAbsolute(normalized)) return null;
289
+ return normalized;
119
290
  }
120
291
 
121
292
  function _parseMeta(rawMeta) {
@@ -161,7 +332,7 @@ function _escapeRegex(s) {
161
332
  // ---------------------------------------------------------------------------
162
333
 
163
334
  async function _loadFileChunks(filePathRel, projectRoot) {
164
- const repo = _getRepo();
335
+ const repo = _getRepo(projectRoot);
165
336
  if (!repo) return { chunks: [], language: null };
166
337
  const rows = repo.getChunksByFilePath(filePathRel);
167
338
  if (rows.length === 0) return { chunks: [], language: null };
@@ -253,24 +424,38 @@ function _scoreSymbol(chunks, queryTerms, queryRaw) {
253
424
  const sym = (c.symbol || '').toLowerCase();
254
425
  if (!sym) continue;
255
426
  let s = 0;
256
- if (sym && lowerRaw.includes(sym)) s += 2; // raw query mentions the symbol
427
+ // Word-boundary match prevents short symbols from collecting +2 just for
428
+ // being a substring of an unrelated longer token in the query. Stage 3
429
+ // PHASE6_REDO diagnosis (2026-05-13) found two ss-semantic dev FAILs
430
+ // (JV-004 `Show getType` → `get` chunk got +2 from "get" ⊂ "gettype";
431
+ // LU-001 `trace _class metatable` → `class` chunk got +2 from "class"
432
+ // ⊂ "_class") where this substring-rule over-credited the wrong chunk.
433
+ // The word-boundary form still credits genuine mentions (e.g., "query"
434
+ // as a real query token still matches `query`-symbol chunks — ZG-001
435
+ // ambiguity is preserved). Structural rule, no per-language signal,
436
+ // no stopword growth.
437
+ const reBoundary = new RegExp(`(?:^|[^a-zA-Z0-9_])${_escapeRegex(sym)}(?=[^a-zA-Z0-9_]|$)`, 'i');
438
+ if (sym && reBoundary.test(lowerRaw)) s += 2; // query mentions symbol as a word
257
439
  for (const t of queryTerms) {
258
440
  if (sym === t) s += 3; // exact name match
259
- else if (sym.includes(t)) s += 1; // substring
441
+ else if (sym.includes(t)) s += 1; // substring (chunk symbol contains query token)
260
442
  }
261
443
  if (s > 0) scores.set(c.id, s);
262
444
  }
263
445
  return scores;
264
446
  }
265
447
 
266
- async function _scoreLateInteraction(chunks, query) {
448
+ async function _scoreLateInteraction(chunks, query, projectRoot) {
267
449
  if (chunks.length === 0) return { scores: new Map(), ran: false };
268
- const liIndex = await _getLateInteractionIndex();
450
+ const liIndex = await _getLateInteractionIndex(projectRoot);
269
451
  if (!liIndex) return { scores: new Map(), ran: false };
270
452
 
271
- // Only score chunks whose IDs actually appear in the LI index.
453
+ // Only score chunks whose IDs actually appear in the LI index. Use the
454
+ // public availability API so alias pointers and live tombstone sidecars
455
+ // share the same visibility contract as normal search.
456
+ const available = liIndex.hasTokens(chunks.map(c => c.id));
272
457
  const candidates = chunks
273
- .filter(c => liIndex.documents.has(c.id))
458
+ .filter(c => available.has(c.id))
274
459
  .map(c => ({ id: c.id, score: 0 }));
275
460
  if (candidates.length === 0) return { scores: new Map(), ran: false };
276
461
 
@@ -442,13 +627,15 @@ function _fallbackSpanFromText(fileText, totalLines, maxChars) {
442
627
  * @param {boolean} [req.verbose=false] - include timings + signal contributions
443
628
  * @returns {Promise<Object>}
444
629
  */
445
- export async function readSemantic(req) {
630
+ async function _readSemanticUnpinned(req) {
446
631
  const t0 = performance.now();
447
632
  if (!req || !req.path) throw new Error('path is required');
448
633
  if (!req.query || !String(req.query).trim()) throw new Error('query is required');
449
634
 
450
635
  const projectRoot = req.projectRoot || process.cwd();
636
+ _ensurePersistedLiModelApplied(projectRoot);
451
637
  const filePathRel = _projectRelative(req.path, projectRoot);
638
+ const staleness = _sourceStaleness(projectRoot, filePathRel);
452
639
 
453
640
  const topK = req.topK ?? DEFAULTS.topK;
454
641
  const threshold = req.threshold ?? DEFAULTS.threshold;
@@ -477,6 +664,7 @@ export async function readSemantic(req) {
477
664
  spans: fallback.ok ? [_fallbackSpanFromRead(fallback, maxChars)] : [],
478
665
  charsReturned: fallback.ok ? Math.min((fallback.text || '').length, maxChars) : 0,
479
666
  approxTokensReturned: fallback.ok ? Math.ceil(Math.min((fallback.text || '').length, maxChars) / APPROX_CHARS_PER_TOKEN) : 0,
667
+ ...(staleness ? { staleness, warnings: [staleness.warning] } : {}),
480
668
  timings: { totalMs: +(performance.now() - t0).toFixed(2) },
481
669
  };
482
670
  }
@@ -498,7 +686,7 @@ export async function readSemantic(req) {
498
686
  const tLex1 = performance.now();
499
687
 
500
688
  const tLi0 = performance.now();
501
- const { scores: maxsimScores, ran: liRan } = await _scoreLateInteraction(chunks, req.query);
689
+ const { scores: maxsimScores, ran: liRan } = await _scoreLateInteraction(chunks, req.query, projectRoot);
502
690
  const tLi1 = performance.now();
503
691
 
504
692
  // Threshold gate on MaxSim — drop chunks whose LI score is too low. This
@@ -534,6 +722,7 @@ export async function readSemantic(req) {
534
722
  charsReturned: Math.min(fileText.length, maxChars),
535
723
  approxTokensReturned: Math.ceil(Math.min(fileText.length, maxChars) / APPROX_CHARS_PER_TOKEN),
536
724
  signals: verbose ? { liRan, lexicalHits: 0, symbolHits: 0, maxsimHits: 0 } : undefined,
725
+ ...(staleness ? { staleness, warnings: [staleness.warning] } : {}),
537
726
  timings: verbose ? {
538
727
  loadMs: +(tLoad1 - tLoad0).toFixed(2),
539
728
  lexicalMs: +(tLex1 - tLex0).toFixed(2),
@@ -552,12 +741,39 @@ export async function readSemantic(req) {
552
741
  // Final re-rank: prefer late-interaction score when LI ran; otherwise the
553
742
  // RRF score is the authority. This mirrors the SOTA pattern (cheap candidate
554
743
  // pool → expensive LI re-rank on the survivors).
744
+ //
745
+ // Multiplicative score demotions on null/unknown-symbol chunks and on tiny
746
+ // chunks are applied here so the re-rank below sees the corrected score
747
+ // (Stage 3 PHASE6_REDO ss-semantic, 2026-05-13). Demotion is intentionally
748
+ // applied AFTER the MaxSim re-rank threshold gate above — chunks still
749
+ // survive into the result, they're just less likely to win top-1.
750
+ const unsymDemote = req.unsymboledDemote ?? DEFAULTS.unsymboledDemote;
751
+ const smallDemote = req.smallChunkDemote ?? DEFAULTS.smallChunkDemote;
752
+ const smallChunkMaxLines = req.smallChunkMaxLines ?? DEFAULTS.smallChunkMaxLines;
753
+
555
754
  const ranked = fusedTop
556
755
  .map(([id, fusedScore]) => {
557
756
  const c = idToChunk.get(id);
558
757
  if (!c) return null;
559
758
  const li = maxsimScores.get(id);
560
- const finalScore = liRan && li != null ? li : fusedScore;
759
+ const baseScore = liRan && li != null ? li : fusedScore;
760
+ // Stage 3 PHASE6_REDO ss-semantic (2026-05-13): demote only the
761
+ // INTERSECTION of (null-or-unknown symbol) AND (≤ smallChunkMaxLines).
762
+ // Earlier OR-form regressed typescript-lib (interface declarations
763
+ // are legitimately small AND symboled; OR-rule demoted them too).
764
+ // The intersection targets exactly the RB-001 pattern — short
765
+ // unnamed code fragments that win MaxSim by concentrated literal
766
+ // tokens (e.g., 3-line `module Sinatra` decl beating the 24-line
767
+ // Base class body). Multiplicative composition gives 0.85*0.85=0.7225x
768
+ // when both conditions fire.
769
+ const symMeta = c.symbol;
770
+ const isUnsymboled = !symMeta || symMeta === 'unknown';
771
+ const chunkLines = c.endLine - c.startLine + 1;
772
+ const isSmall = chunkLines <= smallChunkMaxLines;
773
+ const demoteFactor = (isUnsymboled && isSmall)
774
+ ? unsymDemote * smallDemote
775
+ : 1;
776
+ const finalScore = baseScore * demoteFactor;
561
777
  return {
562
778
  id,
563
779
  symbol: c.symbol,
@@ -570,6 +786,8 @@ export async function readSemantic(req) {
570
786
  symbol: symbolScores.get(id) || 0,
571
787
  maxsim: liRan ? (maxsimScores.get(id) ?? null) : null,
572
788
  fused: fusedScore,
789
+ baseScore,
790
+ demoteFactor,
573
791
  },
574
792
  };
575
793
  })
@@ -591,6 +809,7 @@ export async function readSemantic(req) {
591
809
  spans,
592
810
  charsReturned: charsUsed,
593
811
  approxTokensReturned: Math.ceil(charsUsed / APPROX_CHARS_PER_TOKEN),
812
+ ...(staleness ? { staleness, warnings: [staleness.warning] } : {}),
594
813
  signals: verbose ? {
595
814
  liRan,
596
815
  lexicalHits: lexicalScores.size,
@@ -608,6 +827,21 @@ export async function readSemantic(req) {
608
827
  };
609
828
  }
610
829
 
830
+ export async function readSemantic(req) {
831
+ const projectRoot = req?.projectRoot || process.cwd();
832
+ return withPinnedRead(
833
+ {
834
+ projectRoot,
835
+ meta: {
836
+ tool: 'read-semantic',
837
+ path: req?.path ?? null,
838
+ query: req?.query ? String(req.query).slice(0, 200) : null,
839
+ },
840
+ },
841
+ () => _readSemanticUnpinned({ ...req, projectRoot }),
842
+ );
843
+ }
844
+
611
845
  // ---------------------------------------------------------------------------
612
846
  // Formatting
613
847
  // ---------------------------------------------------------------------------
@@ -624,6 +858,9 @@ export function formatReadSemanticResult(result, format = 'agent') {
624
858
  lines.push(`[error]`);
625
859
  return lines.join('\n');
626
860
  }
861
+ for (const warning of result.warnings || []) {
862
+ lines.push(`[warning] ${warning}`);
863
+ }
627
864
  for (const span of result.spans) {
628
865
  const label = span.symbols && span.symbols.length
629
866
  ? `${span.symbols.join(', ')} (lines ${span.startLine}-${span.endLine})`
@@ -647,10 +884,18 @@ function _parseArgs(args) {
647
884
  const positional = [];
648
885
  let format = 'agent';
649
886
  let topK; let threshold; let contextLines; let maxChars; let maxTokens; let verbose = false;
887
+ let plain = false; let noBanner = false;
650
888
  for (let i = 0; i < args.length; i++) {
651
889
  const a = args[i];
652
890
  if (a === '--json') format = 'json';
653
891
  else if (a === '--agent') format = 'agent';
892
+ else if (a === '--no-banner') noBanner = true;
893
+ else if (a === '--format' || a.startsWith('--format=')) {
894
+ const v = a === '--format' ? args[++i] : a.slice('--format='.length);
895
+ if (v === 'json' || v === 'agent') format = v;
896
+ else if (v === 'plain') plain = true;
897
+ else throw new Error(`unknown --format value: ${v}`);
898
+ }
654
899
  else if (a === '--verbose') verbose = true;
655
900
  else if (a === '--top' || a === '--top-k' || a === '-k') topK = +args[++i];
656
901
  else if (a === '--threshold') threshold = +args[++i];
@@ -661,7 +906,7 @@ function _parseArgs(args) {
661
906
  else if (a.startsWith('--')) throw new Error(`unknown flag: ${a}`);
662
907
  else positional.push(a);
663
908
  }
664
- return { positional, format, topK, threshold, contextLines, maxChars, maxTokens, verbose };
909
+ return { positional, format, topK, threshold, contextLines, maxChars, maxTokens, verbose, plain, noBanner };
665
910
  }
666
911
 
667
912
  function _printHelp() {
@@ -678,6 +923,8 @@ function _printHelp() {
678
923
  ' --max-chars <n> Hard cap on returned text (default: 8000)',
679
924
  ' --max-tokens <n> Convenience cap (~chars/4)',
680
925
  ' --json Emit JSON',
926
+ ' --format <fmt> json | agent | plain (plain = no identity line)',
927
+ ' --no-banner Suppress the identity line',
681
928
  ' --verbose Include timings + per-signal scores',
682
929
  '',
683
930
  ].join('\n'));
@@ -703,6 +950,9 @@ export async function handleReadSemanticCli(args) {
703
950
  maxTokens: parsed.maxTokens,
704
951
  verbose: parsed.verbose,
705
952
  });
953
+ if (parsed.format !== 'json') {
954
+ emitToolIdentityAuto('read-semantic', `${file} · "${query}"`, { plain: parsed.plain, noBanner: parsed.noBanner });
955
+ }
706
956
  process.stdout.write(formatReadSemanticResult(result, parsed.format));
707
957
  if (parsed.format !== 'json') process.stdout.write('\n');
708
958
  process.exit(result.ok ? 0 : 1);
@@ -710,8 +960,12 @@ export async function handleReadSemanticCli(args) {
710
960
 
711
961
  // Test-only export — clears caches between unit tests.
712
962
  export function __resetReadSemanticCachesForTests() {
713
- _repo = null;
963
+ for (const entry of _repos.values()) entry?.repo?.close?.();
964
+ _repos.clear();
714
965
  _liIndex = null;
715
966
  _liInitPromise = null;
967
+ _liProjectKey = null;
968
+ _liManifestEpoch = null;
716
969
  _encodeQueryFn = null;
970
+ _appliedLiPerRoot.clear();
717
971
  }