sweet-search 2.5.2 → 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 (155) hide show
  1. package/core/cli.js +24 -3
  2. package/core/graph/graph-expansion.js +215 -36
  3. package/core/graph/graph-extractor.js +196 -11
  4. package/core/graph/graph-search.js +395 -92
  5. package/core/graph/hcgs-generator.js +2 -1
  6. package/core/graph/index.js +2 -0
  7. package/core/graph/repo-map.js +28 -6
  8. package/core/graph/structural-answer-cues.js +168 -0
  9. package/core/graph/structural-callsite-hints.js +40 -0
  10. package/core/graph/structural-context-format.js +40 -0
  11. package/core/graph/structural-context.js +450 -0
  12. package/core/graph/structural-forward-push.js +156 -0
  13. package/core/graph/structural-header-context.js +19 -0
  14. package/core/graph/structural-importance.js +148 -0
  15. package/core/graph/structural-pagerank.js +197 -0
  16. package/core/graph/summary-manager.js +13 -9
  17. package/core/incremental-indexing/application/dirty-scan.mjs +236 -0
  18. package/core/incremental-indexing/application/file-watcher.mjs +197 -0
  19. package/core/incremental-indexing/application/maintenance-handlers.mjs +519 -0
  20. package/core/incremental-indexing/application/maintenance-worker.mjs +380 -0
  21. package/core/incremental-indexing/application/operator-cli.mjs +554 -0
  22. package/core/incremental-indexing/application/production-li-delta.mjs +192 -0
  23. package/core/incremental-indexing/application/production-reconciler-helpers.mjs +107 -0
  24. package/core/incremental-indexing/application/production-reconciler.mjs +583 -0
  25. package/core/incremental-indexing/application/reconciler.mjs +477 -0
  26. package/core/incremental-indexing/application/tombstone-injector.mjs +148 -0
  27. package/core/incremental-indexing/domain/chunk-identity.mjs +260 -0
  28. package/core/incremental-indexing/domain/encoder-deps.mjs +193 -0
  29. package/core/incremental-indexing/domain/encoder-input.mjs +225 -0
  30. package/core/incremental-indexing/domain/interval-autotune.mjs +255 -0
  31. package/core/incremental-indexing/domain/reconcile-counters.mjs +149 -0
  32. package/core/incremental-indexing/domain/watermark-scheduler.mjs +239 -0
  33. package/core/incremental-indexing/infrastructure/artifact-temp-sweep.mjs +163 -0
  34. package/core/incremental-indexing/infrastructure/baseline-readiness.mjs +121 -0
  35. package/core/incremental-indexing/infrastructure/dirty-set.mjs +233 -0
  36. package/core/incremental-indexing/infrastructure/graph-gc.mjs +314 -0
  37. package/core/incremental-indexing/infrastructure/hashing.mjs +298 -0
  38. package/core/incremental-indexing/infrastructure/hcgs-invalidation.mjs +182 -0
  39. package/core/incremental-indexing/infrastructure/li-segment-merge.mjs +278 -0
  40. package/core/incremental-indexing/infrastructure/li-segment-state.mjs +173 -0
  41. package/core/incremental-indexing/infrastructure/lockfile.mjs +119 -0
  42. package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +283 -0
  43. package/core/incremental-indexing/infrastructure/manifest.mjs +194 -0
  44. package/core/incremental-indexing/infrastructure/path-filter.mjs +190 -0
  45. package/core/incremental-indexing/infrastructure/reader-heartbeat.mjs +201 -0
  46. package/core/incremental-indexing/infrastructure/schema-migrations.mjs +257 -0
  47. package/core/incremental-indexing/infrastructure/sparse-gram-delta.mjs +335 -0
  48. package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +176 -0
  49. package/core/incremental-indexing/infrastructure/staleness-display.mjs +105 -0
  50. package/core/incremental-indexing/infrastructure/tombstone-bitmap.mjs +234 -0
  51. package/core/incremental-indexing/infrastructure/vector-delta-writer.mjs +359 -0
  52. package/core/incremental-indexing/infrastructure/vector-gc.mjs +133 -0
  53. package/core/incremental-indexing/infrastructure/worktree-stamp.mjs +155 -0
  54. package/core/incremental-indexing/infrastructure/wsl2-detect.mjs +115 -0
  55. package/core/indexing/admission-policy.js +139 -0
  56. package/core/indexing/artifact-builder.js +29 -12
  57. package/core/indexing/ast-chunker.js +107 -30
  58. package/core/indexing/dedup/exemplar-selector.js +19 -1
  59. package/core/indexing/gitignore-filter.js +223 -0
  60. package/core/indexing/incremental-tracker.js +99 -30
  61. package/core/indexing/index-codebase-v21.js +6 -5
  62. package/core/indexing/index-maintainer.mjs +698 -6
  63. package/core/indexing/indexer-ann.js +99 -15
  64. package/core/indexing/indexer-build.js +158 -45
  65. package/core/indexing/indexer-empty-baseline.js +80 -0
  66. package/core/indexing/indexer-manifest.js +66 -0
  67. package/core/indexing/indexer-phases.js +56 -23
  68. package/core/indexing/indexer-sparse-gram.js +54 -13
  69. package/core/indexing/indexer-utils.js +26 -208
  70. package/core/indexing/indexing-file-policy.js +32 -7
  71. package/core/indexing/maintainer-launcher.mjs +137 -0
  72. package/core/indexing/merkle-tracker.js +251 -244
  73. package/core/indexing/model-pool.js +46 -5
  74. package/core/infrastructure/code-graph-repository.js +758 -6
  75. package/core/infrastructure/code-graph-visibility.js +157 -0
  76. package/core/infrastructure/codebase-repository.js +100 -13
  77. package/core/infrastructure/config/search.js +1 -1
  78. package/core/infrastructure/db-utils.js +118 -0
  79. package/core/infrastructure/dedup-hashing.js +10 -13
  80. package/core/infrastructure/hardware-capability.js +17 -7
  81. package/core/infrastructure/index.js +8 -2
  82. package/core/infrastructure/language-patterns/maps.js +4 -1
  83. package/core/infrastructure/language-patterns/registry-core.js +56 -17
  84. package/core/infrastructure/language-patterns/registry-object-oriented.js +12 -5
  85. package/core/infrastructure/language-patterns.js +69 -0
  86. package/core/infrastructure/model-registry.js +20 -0
  87. package/core/infrastructure/native-inference.js +7 -12
  88. package/core/infrastructure/native-resolver.js +52 -37
  89. package/core/infrastructure/native-sparse-gram.js +261 -20
  90. package/core/infrastructure/native-tokenizer.js +6 -15
  91. package/core/infrastructure/simd-distance.js +10 -16
  92. package/core/infrastructure/sparse-gram-delta-reader.js +76 -0
  93. package/core/infrastructure/structural-alias-resolver.js +122 -0
  94. package/core/infrastructure/structural-candidate-ranker.js +34 -0
  95. package/core/infrastructure/structural-context-repository.js +472 -0
  96. package/core/infrastructure/structural-context-utils.js +51 -0
  97. package/core/infrastructure/structural-graph-signals.js +121 -0
  98. package/core/infrastructure/structural-qualified-resolution.js +15 -0
  99. package/core/infrastructure/structural-source-definitions.js +100 -0
  100. package/core/infrastructure/tombstone-bitmap-reader.js +139 -0
  101. package/core/infrastructure/tree-sitter-provider.js +811 -37
  102. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md +50 -0
  103. package/core/query/query-router.js +55 -5
  104. package/core/ranking/file-kind-ranking.js +2192 -15
  105. package/core/ranking/late-interaction-index.js +87 -12
  106. package/core/search/cli-decoration.js +290 -0
  107. package/core/search/context-expander.js +988 -78
  108. package/core/search/index.js +1 -0
  109. package/core/search/output-policy.js +275 -0
  110. package/core/search/search-anchor.js +499 -0
  111. package/core/search/search-boost.js +93 -1
  112. package/core/search/search-cli.js +61 -204
  113. package/core/search/search-hybrid.js +250 -10
  114. package/core/search/search-pattern-chunks.js +57 -8
  115. package/core/search/search-pattern-planner.js +68 -9
  116. package/core/search/search-pattern-prefilter.js +30 -10
  117. package/core/search/search-pattern-ripgrep.js +40 -4
  118. package/core/search/search-pattern-sparse-overlay.js +256 -0
  119. package/core/search/search-pattern.js +117 -29
  120. package/core/search/search-postprocess.js +479 -5
  121. package/core/search/search-read-semantic.js +260 -23
  122. package/core/search/search-read.js +82 -64
  123. package/core/search/search-reader-pin.js +71 -0
  124. package/core/search/search-rrf.js +279 -0
  125. package/core/search/search-semantic.js +110 -5
  126. package/core/search/search-server.js +130 -57
  127. package/core/search/search-trace.js +107 -0
  128. package/core/search/server-identity.js +93 -0
  129. package/core/search/session-daemon-prewarm.mjs +33 -10
  130. package/core/search/sweet-search.js +399 -7
  131. package/core/skills/sweet-index/SKILL.md +8 -6
  132. package/core/vector-store/binary-hnsw-index.js +194 -30
  133. package/core/vector-store/float-vector-store.js +96 -6
  134. package/core/vector-store/hnsw-index.js +220 -49
  135. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +471 -0
  136. package/eval/agent-read-workflows/bin/ss-find +15 -0
  137. package/eval/agent-read-workflows/bin/ss-grep +12 -0
  138. package/eval/agent-read-workflows/bin/ss-read +14 -0
  139. package/eval/agent-read-workflows/bin/ss-search +18 -0
  140. package/eval/agent-read-workflows/bin/ss-semantic +12 -0
  141. package/eval/agent-read-workflows/bin/ss-trace +11 -0
  142. package/mcp/read-tool.js +109 -0
  143. package/mcp/server.js +55 -15
  144. package/mcp/tool-handlers.js +14 -124
  145. package/mcp/trace-tool.js +81 -0
  146. package/package.json +25 -10
  147. package/scripts/hooks/intercept-read.mjs +55 -0
  148. package/scripts/hooks/remind-tools.mjs +40 -0
  149. package/scripts/init.js +698 -54
  150. package/scripts/inject-agent-instructions.js +431 -0
  151. package/scripts/install-prompt-reminders.js +188 -0
  152. package/scripts/install-tool-enforcement.js +220 -0
  153. package/scripts/smoke-test.js +12 -9
  154. package/scripts/uninstall.js +276 -18
  155. package/scripts/write-claude-rules.js +110 -0
@@ -11,12 +11,13 @@
11
11
  */
12
12
 
13
13
  import fs from 'fs/promises';
14
- import { existsSync, createWriteStream, createReadStream } from 'fs';
14
+ import { existsSync, createWriteStream, createReadStream, statSync } from 'fs';
15
15
  import path from 'path';
16
16
  import { DB_PATHS, LATE_INTERACTION_CONFIG } from '../infrastructure/config/index.js';
17
17
  import { wasmMaxSimF32, wasmMaxSimDequantPerToken, wasmMaxSimDequant4Bit, nativeMaxSimBatch, nativeMaxSimBatchPerToken, nativeMaxSimBatch4Bit, initWasm, isNativeMaxSimAvailable, isNativePerTokenAvailable, isNative4BitAvailable } from '../infrastructure/simd-distance.js';
18
18
  import { fastRotate, generateSignVector, calibrateWUSH, wushRotate } from '../infrastructure/quantization.js';
19
19
  import { poolTokens } from './late-interaction-model.js';
20
+ import { loadBitmap, isSet } from '../infrastructure/tombstone-bitmap-reader.js';
20
21
 
21
22
  // =============================================================================
22
23
  // CRC32 (IEEE 802.3 polynomial, used for SSLX segment footer checksum)
@@ -373,6 +374,8 @@ export class LateInteractionIndex {
373
374
  this._segments = []; // { path, count } of flushed segments
374
375
  this._segmentDir = null;
375
376
  this._segmentSize = options.segmentSize || LI_SEGMENT_SIZE;
377
+ this._docSegmentPositions = new Map(); // doc id -> { segmentPath, docIndex }
378
+ this._staleBitmapCache = new Map(); // segment path -> { mtimeMs, size, bitmap }
376
379
  }
377
380
 
378
381
  /**
@@ -899,7 +902,12 @@ export class LateInteractionIndex {
899
902
  }
900
903
  const entries = [];
901
904
  for (const [aliasId, ptr] of this.aliasPointers) {
902
- entries.push({ aliasId, exemplarId: ptr.exemplarId, clusterId: ptr.clusterId });
905
+ entries.push({
906
+ aliasId,
907
+ exemplarId: ptr.exemplarId,
908
+ clusterId: ptr.clusterId,
909
+ metadata: ptr.metadata || {},
910
+ });
903
911
  }
904
912
  const payload = { version: 1, count: entries.length, aliases: entries };
905
913
  await fs.writeFile(this._aliasSidecarPath(indexPath), JSON.stringify(payload));
@@ -913,13 +921,13 @@ export class LateInteractionIndex {
913
921
  const payload = JSON.parse(raw);
914
922
  if (!payload || !Array.isArray(payload.aliases)) return;
915
923
  this.aliasPointers.clear();
916
- for (const { aliasId, exemplarId, clusterId } of payload.aliases) {
924
+ for (const { aliasId, exemplarId, clusterId, metadata } of payload.aliases) {
917
925
  // Orphan guard: drop aliases whose exemplar is no longer in documents.
918
926
  // Happens if the file containing the exemplar was removed between
919
927
  // save and load (incremental re-index removed the exemplar file
920
928
  // but did not re-run dedup over the alias files).
921
929
  if (!this.documents.has(exemplarId)) continue;
922
- this.aliasPointers.set(aliasId, { exemplarId, clusterId, metadata: {} });
930
+ this.aliasPointers.set(aliasId, { exemplarId, clusterId, metadata: metadata || {} });
923
931
  }
924
932
  } catch (_e) {
925
933
  // Malformed sidecar — treat as absent; aliases will be skipped at query time.
@@ -928,6 +936,7 @@ export class LateInteractionIndex {
928
936
 
929
937
  getTokens(id) {
930
938
  const resolved = this._resolveForRead(id);
939
+ if (this.isDocumentTombstoned(resolved)) return null;
931
940
  const doc = this.documents.get(resolved);
932
941
  if (!doc) return null;
933
942
 
@@ -964,6 +973,7 @@ export class LateInteractionIndex {
964
973
  */
965
974
  getTokensFlat(id) {
966
975
  const resolved = this._resolveForRead(id);
976
+ if (this.isDocumentTombstoned(resolved)) return null;
967
977
  const doc = this.documents.get(resolved);
968
978
  if (!doc) return null;
969
979
 
@@ -1156,6 +1166,49 @@ export class LateInteractionIndex {
1156
1166
  return totalScore / effectiveQuery.length;
1157
1167
  }
1158
1168
 
1169
+ _loadSegmentStaleBitmap(segmentPath) {
1170
+ const sidecarPath = segmentPath + '.stale.bin';
1171
+ let stat;
1172
+ try {
1173
+ stat = statSync(sidecarPath, { bigint: true });
1174
+ } catch {
1175
+ this._staleBitmapCache.delete(segmentPath);
1176
+ return null;
1177
+ }
1178
+ const statKey = `${stat.mtimeNs}:${stat.ctimeNs}:${stat.size}`;
1179
+
1180
+ const cached = this._staleBitmapCache.get(segmentPath);
1181
+ if (cached && cached.statKey === statKey) {
1182
+ return cached.bitmap;
1183
+ }
1184
+
1185
+ try {
1186
+ const bitmap = loadBitmap(sidecarPath);
1187
+ this._staleBitmapCache.set(segmentPath, {
1188
+ statKey,
1189
+ bitmap,
1190
+ });
1191
+ return bitmap;
1192
+ } catch (err) {
1193
+ if (process.env.SWEET_DEBUG) {
1194
+ console.debug(`[LateInteraction] ignoring unreadable stale bitmap ${sidecarPath}: ${err.message}`);
1195
+ }
1196
+ this._staleBitmapCache.set(segmentPath, {
1197
+ statKey,
1198
+ bitmap: null,
1199
+ });
1200
+ return null;
1201
+ }
1202
+ }
1203
+
1204
+ isDocumentTombstoned(docId) {
1205
+ if (!docId) return false;
1206
+ const position = this._docSegmentPositions.get(docId);
1207
+ if (!position) return false;
1208
+ const bitmap = this._loadSegmentStaleBitmap(position.segmentPath);
1209
+ return bitmap ? isSet(bitmap, position.docIndex) : false;
1210
+ }
1211
+
1159
1212
  /**
1160
1213
  * MaxSim scoring from flat buffers (avoids reshape/sub-array creation).
1161
1214
  *
@@ -1419,11 +1472,14 @@ export class LateInteractionIndex {
1419
1472
  // public `id` is the entity id from the code graph. Honouring _liChunkId
1420
1473
  // lets expanded candidates participate in MaxSim rerank.
1421
1474
  const docIdOf = (c) => c._liChunkId || c.id;
1475
+ const lookupDocIdOf = (c) => this._resolveForRead(docIdOf(c));
1422
1476
 
1423
1477
  if (useFlatPath && !this.useTokenWeights) {
1424
1478
  const groups = { bit4: [], perToken: [], perDoc: [] };
1425
1479
  for (const candidate of toScore) {
1426
- const doc = this.documents.get(docIdOf(candidate));
1480
+ const lookupDocId = lookupDocIdOf(candidate);
1481
+ if (this.isDocumentTombstoned(lookupDocId)) continue;
1482
+ const doc = this.documents.get(lookupDocId);
1427
1483
  if (!doc) continue;
1428
1484
  if (doc.quantBits === 4 && doc.minArray && doc.tokenNorms) {
1429
1485
  groups.bit4.push({ candidate, doc });
@@ -1444,6 +1500,11 @@ export class LateInteractionIndex {
1444
1500
  const scores = scoreFn(queryFlat, effectiveQueryTokens.length, scoringDim, nativeCands);
1445
1501
  if (scores) {
1446
1502
  for (let i = 0; i < group.length; i++) {
1503
+ if (this.isDocumentTombstoned(lookupDocIdOf(group[i].candidate))) {
1504
+ pushFallback(group[i].candidate, { _liTombstoned: true });
1505
+ nativeScored.add(group[i].candidate.id);
1506
+ continue;
1507
+ }
1447
1508
  pushScored(group[i].candidate, scores[i]);
1448
1509
  nativeScored.add(group[i].candidate.id);
1449
1510
  }
@@ -1459,7 +1520,12 @@ export class LateInteractionIndex {
1459
1520
  // Try WASM fused kernels first (avoids JS-side dequant), fall back to JS dequant + wasmMaxSimF32.
1460
1521
  for (const candidate of toScore) {
1461
1522
  if (nativeScored.has(candidate.id)) continue;
1462
- const doc = this.documents.get(docIdOf(candidate));
1523
+ const docId = lookupDocIdOf(candidate);
1524
+ if (this.isDocumentTombstoned(docId)) {
1525
+ pushFallback(candidate, { _liTombstoned: true });
1526
+ continue;
1527
+ }
1528
+ const doc = this.documents.get(docId);
1463
1529
  if (!doc) { pushFallback(candidate); continue; }
1464
1530
 
1465
1531
  if (useFlatPath) {
@@ -1494,7 +1560,7 @@ export class LateInteractionIndex {
1494
1560
  }
1495
1561
 
1496
1562
  // JS dequant → WASM f32 or JS fallback
1497
- const flatData = this.getTokensFlat(docIdOf(candidate));
1563
+ const flatData = this.getTokensFlat(docId);
1498
1564
  if (flatData) {
1499
1565
  pushScored(candidate, this.maxSimScoreFlat(
1500
1566
  effectiveQueryTokens, flatData.flat, flatData.numTokens, flatData.dim,
@@ -1504,7 +1570,7 @@ export class LateInteractionIndex {
1504
1570
  pushFallback(candidate);
1505
1571
  }
1506
1572
  } else {
1507
- const docTokens = this.getTokens(docIdOf(candidate));
1573
+ const docTokens = this.getTokens(docId);
1508
1574
  if (docTokens) {
1509
1575
  pushScored(candidate, this.maxSimScore(effectiveQueryTokens, docTokens, pruneOpts));
1510
1576
  } else {
@@ -1997,14 +2063,21 @@ export class LateInteractionIndex {
1997
2063
  if (manifest.modelId) this.modelId = manifest.modelId;
1998
2064
 
1999
2065
  this.documents.clear();
2066
+ this._docSegmentPositions.clear();
2067
+ this._staleBitmapCache.clear();
2000
2068
 
2001
2069
  const isSSLX = manifest.format === 'sslx-v3';
2002
2070
 
2003
2071
  for (const seg of manifest.segments) {
2004
2072
  const segPath = path.join(segmentDir, seg.path);
2005
2073
  const docs = await this._readSegmentFile(segPath);
2074
+ const staleBitmap = this._loadSegmentStaleBitmap(segPath);
2006
2075
 
2007
- for (const doc of docs) {
2076
+ for (let docIndex = 0; docIndex < docs.length; docIndex++) {
2077
+ const doc = docs[docIndex];
2078
+ if (staleBitmap && isSet(staleBitmap, docIndex)) {
2079
+ continue;
2080
+ }
2008
2081
  // SSLX reader returns typed arrays directly; legacy LISE returns plain arrays
2009
2082
  const tokens = (doc.tokens instanceof Int8Array || doc.tokens instanceof Float32Array || doc.tokens instanceof Uint8Array)
2010
2083
  ? doc.tokens
@@ -2033,6 +2106,7 @@ export class LateInteractionIndex {
2033
2106
  if (doc.preNorms) entry.preNorms = doc.preNorms;
2034
2107
 
2035
2108
  this.documents.set(doc.id, entry);
2109
+ this._docSegmentPositions.set(doc.id, { segmentPath: segPath, docIndex });
2036
2110
  }
2037
2111
  }
2038
2112
 
@@ -2273,9 +2347,10 @@ export class LateInteractionIndex {
2273
2347
  hasTokens(chunkIds) {
2274
2348
  const available = new Set();
2275
2349
  for (const id of chunkIds) {
2276
- if (this.documents.has(id)) { available.add(id); continue; }
2277
- const ptr = this.aliasPointers.get(id);
2278
- if (ptr && this.documents.has(ptr.exemplarId)) available.add(id);
2350
+ const resolved = this._resolveForRead(id);
2351
+ if (!this.documents.has(resolved)) continue;
2352
+ if (this.isDocumentTombstoned(resolved)) continue;
2353
+ available.add(id);
2279
2354
  }
2280
2355
  return available;
2281
2356
  }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * CLI decoration rendering for sweet-search tools.
3
+ *
4
+ * Pure presentation: ANSI styling, the pixel-art banner, the stats line, and a
5
+ * compact per-tool identity line. WHERE these may be emitted is decided by
6
+ * output-policy.js — this module only renders to the channel the policy chose.
7
+ *
8
+ * Kept dependency-light (only output-policy + node:fs) so the read/trace/
9
+ * semantic tools can import it without pulling the search engine.
10
+ */
11
+
12
+ import { createDecorationWriter, detectOutputPolicy } from './output-policy.js';
13
+
14
+ // =============================================================================
15
+ // CLI STYLING (ANSI truecolor w/ fallback)
16
+ // =============================================================================
17
+
18
+ export const STYLE = (() => {
19
+ // 5 shades of dark blue (edge -> center)
20
+ const colors = {
21
+ darkest: { r: 6, g: 10, b: 31 },
22
+ darker: { r: 10, g: 17, b: 52 },
23
+ dark: { r: 14, g: 24, b: 73 },
24
+ lightDark: { r: 18, g: 32, b: 95 },
25
+ lightestDark: { r: 22, g: 40, b: 116 },
26
+ border: { r: 90, g: 115, b: 220 },
27
+ white: { r: 255, g: 255, b: 255 },
28
+ };
29
+
30
+ const reset = '\x1b[0m';
31
+ const bold = '\x1b[1m';
32
+
33
+ const lerp = (c1, c2, t) => ({
34
+ r: Math.round(c1.r + (c2.r - c1.r) * t),
35
+ g: Math.round(c1.g + (c2.g - c1.g) * t),
36
+ b: Math.round(c1.b + (c2.b - c1.b) * t),
37
+ });
38
+
39
+ // Convert RGB to xterm-256 color code (fallback for terminals without truecolor)
40
+ const rgbToAnsi256 = (r, g, b) => {
41
+ // Grayscale range
42
+ if (r === g && g === b) {
43
+ if (r < 8) return 16;
44
+ if (r > 248) return 231;
45
+ return Math.round(((r - 8) / 247) * 24) + 232;
46
+ }
47
+
48
+ const to6 = (v) => Math.round((v / 255) * 5);
49
+ const rr = to6(r);
50
+ const gg = to6(g);
51
+ const bb = to6(b);
52
+ return 16 + (36 * rr) + (6 * gg) + bb;
53
+ };
54
+
55
+ const fg24 = (c) => `\x1b[38;2;${c.r};${c.g};${c.b}m`;
56
+ const bg24 = (c) => `\x1b[48;2;${c.r};${c.g};${c.b}m`;
57
+
58
+ const fg256 = (c) => `\x1b[38;5;${rgbToAnsi256(c.r, c.g, c.b)}m`;
59
+ const bg256 = (c) => `\x1b[48;5;${rgbToAnsi256(c.r, c.g, c.b)}m`;
60
+
61
+ const detectColorMode = () => {
62
+ const forced = (process.env.SWEET_SEARCH_COLOR_MODE || process.env.SMART_SEARCH_COLOR_MODE || '').trim().toLowerCase();
63
+ if (forced === 'none' || forced === '0' || forced === 'off') return 'none';
64
+ if (forced === '256' || forced === 'ansi256' || forced === 'xterm256') return 'ansi256';
65
+ if (forced === 'truecolor' || forced === '24bit' || forced === 'rgb') return 'truecolor';
66
+
67
+ if (process.env.NO_COLOR) return 'none';
68
+
69
+ const colorterm = process.env.COLORTERM || '';
70
+ if (/truecolor|24bit/i.test(colorterm)) return 'truecolor';
71
+
72
+ // Windows Terminal + VS Code terminals are typically truecolor-capable.
73
+ if (process.env.WT_SESSION || process.env.TERM_PROGRAM === 'vscode') return 'truecolor';
74
+
75
+ const term = process.env.TERM || '';
76
+ if (/256color/i.test(term)) return 'ansi256';
77
+
78
+ return 'none';
79
+ };
80
+
81
+ const colorMode = detectColorMode(); // 'truecolor' | 'ansi256' | 'none'
82
+ const fg = colorMode === 'truecolor' ? fg24 : colorMode === 'ansi256' ? fg256 : () => '';
83
+ const bg = colorMode === 'truecolor' ? bg24 : colorMode === 'ansi256' ? bg256 : () => '';
84
+
85
+ const headerStyleEnv = (process.env.SWEET_SEARCH_HEADER_STYLE || process.env.SMART_SEARCH_HEADER_STYLE || '').trim().toLowerCase();
86
+ const headerStyle =
87
+ headerStyleEnv === 'zones' || headerStyleEnv === 'gradient'
88
+ ? headerStyleEnv
89
+ : (colorMode === 'truecolor' ? 'gradient' : 'zones');
90
+
91
+ return {
92
+ colors,
93
+ fg,
94
+ bg,
95
+ reset: colorMode === 'none' ? '' : reset,
96
+ bold: colorMode === 'none' ? '' : bold,
97
+ lerp,
98
+ colorMode,
99
+ headerStyle,
100
+ };
101
+ })();
102
+
103
+ // Colorless style — same geometry as STYLE but emits no ANSI escapes. Used when
104
+ // the output policy keeps the banner but disables color (e.g. NO_COLOR set, or a
105
+ // captured /dev/tty side-channel where we still want plain pixel art).
106
+ export const PLAIN_STYLE = {
107
+ colors: STYLE.colors,
108
+ fg: () => '',
109
+ bg: () => '',
110
+ reset: '',
111
+ bold: '',
112
+ lerp: STYLE.lerp,
113
+ colorMode: 'none',
114
+ headerStyle: STYLE.headerStyle,
115
+ };
116
+
117
+ // Default line writer for decoration when no policy writer is supplied: stdout.
118
+ const _stdoutLine = (line = '') => process.stdout.write(`${line}\n`);
119
+
120
+ // 2-line pixel art using half-blocks - SWEET SEARCH
121
+ const SWEET_SEARCH_L1 = '█▀▀ █ █ █ █▀▀ █▀▀ ▀█▀ █▀▀ █▀▀ ▄▀▄ █▀▄ █▀▀ █▄█';
122
+ const SWEET_SEARCH_L2 = '▄▄█ ▀▄█▄▀ ██▄ ██▄ █ ▄▄█ ██▄ █▀█ ██▄ █▄▄ █▀█';
123
+
124
+ // Per-tool glyphs so each of the six tools is recognizable at a glance.
125
+ const TOOL_ICONS = {
126
+ search: '✨',
127
+ lexical: '⚡',
128
+ semantic: '🧠',
129
+ hybrid: '⚗️',
130
+ pattern: '⌗',
131
+ grep: '#',
132
+ structural: '🔗',
133
+ trace: '🔗',
134
+ read: '📄',
135
+ 'read-semantic': '🧠',
136
+ };
137
+
138
+ /**
139
+ * Print styled header - 2-line pixel art with query on right = 2 content lines.
140
+ *
141
+ * @param {string} query
142
+ * @param {Object} [opts]
143
+ * @param {(line?: string) => void} [opts.write] line writer (default: stdout)
144
+ * @param {Object} [opts.style] STYLE or PLAIN_STYLE
145
+ */
146
+ export function printStyledHeader(query, opts = {}) {
147
+ const write = opts.write || _stdoutLine;
148
+ const width = Math.min(process.stdout.columns || 80, 80);
149
+ const { colors, fg, bg, reset, bold } = opts.style || STYLE;
150
+
151
+ const artLen = SWEET_SEARCH_L2.length;
152
+ const maxQueryLen = width - artLen - 8;
153
+ const displayQuery = query.length > maxQueryLen
154
+ ? query.slice(0, maxQueryLen - 3) + '...'
155
+ : query;
156
+
157
+ const palette = [
158
+ colors.darkest,
159
+ colors.darker,
160
+ colors.dark,
161
+ colors.lightDark,
162
+ colors.lightestDark,
163
+ ];
164
+
165
+ const getBgColor = (i, w) => {
166
+ const pos = i / Math.max(1, w - 1);
167
+ const t = 1 - Math.abs(0.5 - pos) * 2;
168
+ const zone = Math.min(Math.floor(t * palette.length), palette.length - 1);
169
+ return palette[zone];
170
+ };
171
+
172
+ const buildLine = (leftContent, rightContent = null, isArt = false) => {
173
+ let result = '';
174
+ const leftPad = 2;
175
+ const rightPad = 2;
176
+ const rightStart = rightContent ? width - rightPad - rightContent.length : width;
177
+
178
+ for (let i = 0; i < width; i++) {
179
+ const bgColor = getBgColor(i, width);
180
+ const leftCharIdx = i - leftPad;
181
+ const rightCharIdx = i - rightStart;
182
+
183
+ if (rightContent && rightCharIdx >= 0 && rightCharIdx < rightContent.length) {
184
+ result += bold + fg(colors.white) + bg(bgColor) + rightContent[rightCharIdx];
185
+ } else if (leftCharIdx >= 0 && leftCharIdx < leftContent.length) {
186
+ const fgColor = isArt ? colors.border : colors.white;
187
+ result += bold + fg(fgColor) + bg(bgColor) + leftContent[leftCharIdx];
188
+ } else {
189
+ result += bg(bgColor) + ' ';
190
+ }
191
+ }
192
+ return result + reset;
193
+ };
194
+
195
+ const queryStr = `"${displayQuery}"`;
196
+
197
+ write('');
198
+ write(buildLine(SWEET_SEARCH_L1, null, true));
199
+ write(buildLine(SWEET_SEARCH_L2, queryStr, true));
200
+ }
201
+
202
+ /**
203
+ * Print styled stats line. The mode glyph distinguishes search-family variants
204
+ * (lexical/semantic/hybrid/pattern/grep), giving each its own identity.
205
+ *
206
+ * @param {Object} stats
207
+ * @param {boolean} [isWarm=false]
208
+ * @param {Object} [opts]
209
+ * @param {(line?: string) => void} [opts.write] line writer (default: stdout)
210
+ * @param {Object} [opts.style] STYLE or PLAIN_STYLE
211
+ */
212
+ export function printStyledStats(stats, isWarm = false, opts = {}) {
213
+ const write = opts.write || _stdoutLine;
214
+ const { colors, fg, reset } = opts.style || STYLE;
215
+ const mode = stats.routing?.mode || stats.mode || 'auto';
216
+ const pathType = stats.path || 'hybrid';
217
+ const timeMs = stats.server_ms || stats.total_ms || 0;
218
+
219
+ const modeIcon = TOOL_ICONS[mode] || '◆';
220
+ const warmIcon = isWarm ? `${fg(colors.border)}●${reset}` : `${fg(colors.darker)}○${reset}`;
221
+
222
+ write(
223
+ ` ${modeIcon} ${fg(colors.white)}${mode}${reset} ` +
224
+ `${fg(colors.dark)}│${reset} ${fg(colors.border)}${pathType}${reset} ` +
225
+ `${fg(colors.dark)}│${reset} ${fg(colors.white)}${timeMs}ms${reset} ${warmIcon}`
226
+ );
227
+ write('');
228
+ }
229
+
230
+ /**
231
+ * Emit the search banner (header + stats) on the channel the policy chose.
232
+ * Results always go to stdout separately; this only handles decoration. When
233
+ * the policy disables the banner, nothing is written.
234
+ */
235
+ export function emitDecoration(policy, query, stats, isWarm) {
236
+ if (!policy.bannerEnabled) return;
237
+ const style = policy.colorEnabled ? STYLE : PLAIN_STYLE;
238
+ const deco = createDecorationWriter(policy);
239
+ try {
240
+ printStyledHeader(query, { write: deco.write, style });
241
+ printStyledStats(stats, isWarm, { write: deco.write, style });
242
+ } finally {
243
+ deco.close();
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Convenience wrapper: resolve the output policy from the current environment +
249
+ * stdout, then emit the tool identity. Used by the read/trace/semantic CLIs.
250
+ *
251
+ * @param {string} tool
252
+ * @param {string} [detail]
253
+ * @param {{plain?: boolean, noBanner?: boolean}} [flags]
254
+ */
255
+ export function emitToolIdentityAuto(tool, detail = '', flags = {}) {
256
+ const policy = detectOutputPolicy({
257
+ format: flags.plain ? 'plain' : null,
258
+ noBanner: !!flags.noBanner,
259
+ env: process.env,
260
+ stream: process.stdout,
261
+ });
262
+ emitToolIdentity(policy, tool, detail);
263
+ }
264
+
265
+ /**
266
+ * Emit a compact, branded one-line identity for a tool (read/trace/semantic and
267
+ * any other tool that should not carry the full banner). Routed to the policy's
268
+ * decoration channel; a no-op when the policy disables the banner.
269
+ *
270
+ * @param {import('./output-policy.js').OutputPolicy} policy
271
+ * @param {string} tool e.g. 'read', 'trace', 'read-semantic'
272
+ * @param {string} [detail] short context shown after the tool name
273
+ */
274
+ export function emitToolIdentity(policy, tool, detail = '') {
275
+ if (!policy || !policy.bannerEnabled) return;
276
+ const style = policy.colorEnabled ? STYLE : PLAIN_STYLE;
277
+ const { colors, fg, reset, bold } = style;
278
+ const icon = TOOL_ICONS[tool] || '✦';
279
+ const deco = createDecorationWriter(policy);
280
+ try {
281
+ const sep = `${fg(colors.dark)}·${reset}`;
282
+ const detailStr = detail ? ` ${sep} ${fg(colors.border)}${detail}${reset}` : '';
283
+ deco.write('');
284
+ deco.write(
285
+ ` ${icon} ${bold}${fg(colors.white)}sweet-search${reset} ${sep} ${fg(colors.white)}${tool}${reset}${detailStr}`,
286
+ );
287
+ } finally {
288
+ deco.close();
289
+ }
290
+ }