@aexol/spectral 0.9.199 → 0.9.201

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 (28) hide show
  1. package/dist/commands/serve.d.ts +2 -0
  2. package/dist/commands/serve.d.ts.map +1 -1
  3. package/dist/commands/serve.js +11 -1
  4. package/dist/extensions/image-generation/index.d.ts.map +1 -1
  5. package/dist/extensions/image-generation/index.js +81 -11
  6. package/dist/extensions/session-fanout/index.d.ts +19 -0
  7. package/dist/extensions/session-fanout/index.d.ts.map +1 -0
  8. package/dist/extensions/session-fanout/index.js +139 -0
  9. package/dist/memory/tools/read-project-observations.d.ts.map +1 -1
  10. package/dist/memory/tools/read-project-observations.js +8 -4
  11. package/dist/memory/tools/write-project-observation.d.ts.map +1 -1
  12. package/dist/memory/tools/write-project-observation.js +5 -1
  13. package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
  14. package/dist/sdk/coding-agent/core/extensions/native-extensions.js +10 -0
  15. package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
  16. package/dist/sdk/coding-agent/core/system-prompt.js +2 -1
  17. package/dist/server/fanout/fanout-manager.d.ts +126 -0
  18. package/dist/server/fanout/fanout-manager.d.ts.map +1 -0
  19. package/dist/server/fanout/fanout-manager.js +344 -0
  20. package/dist/server/session-stream.d.ts.map +1 -1
  21. package/dist/server/session-stream.js +4 -0
  22. package/dist/server/storage.d.ts +59 -2
  23. package/dist/server/storage.d.ts.map +1 -1
  24. package/dist/server/storage.js +287 -12
  25. package/dist/server/text/trigrams.d.ts +72 -0
  26. package/dist/server/text/trigrams.d.ts.map +1 -0
  27. package/dist/server/text/trigrams.js +128 -0
  28. package/package.json +1 -1
@@ -28,6 +28,7 @@
28
28
  * later (e.g. if we ever need to colocate with a remote service).
29
29
  */
30
30
  import { createSqliteAdapter } from "./sqlite-adapter.js";
31
+ import { MIN_TRIGRAM_TOKEN_LENGTH, bestTokenTrigramJaccard, buildFtsMatchQuery, trigramJaccard, } from "./text/trigrams.js";
31
32
  import { randomUUID } from "node:crypto";
32
33
  import { mkdirSync, readFileSync } from "node:fs";
33
34
  import { dirname, join, resolve } from "node:path";
@@ -149,6 +150,61 @@ CREATE TABLE IF NOT EXISTS dev_process_definitions (
149
150
  updated_at INTEGER NOT NULL
150
151
  );
151
152
  `;
153
+ /**
154
+ * FTS5 trigram index over project-observation content, synced explicitly in
155
+ * code (same transaction as `insertProjectObservations`) rather than via
156
+ * triggers so the behavior is obvious and testable. `obs_id` stores the
157
+ * observation id so stale index rows can be deleted on INSERT OR REPLACE.
158
+ *
159
+ * Created outside SCHEMA_SQL on purpose: if the bundled SQLite build lacks
160
+ * FTS5, the store still opens and observation search degrades to LIKE
161
+ * filtering instead of failing at startup.
162
+ */
163
+ const PROJECT_OBS_FTS_SQL = `
164
+ CREATE VIRTUAL TABLE IF NOT EXISTS project_observations_fts USING fts5(
165
+ content,
166
+ project_id UNINDEXED,
167
+ obs_id UNINDEXED,
168
+ tokenize='trigram'
169
+ );
170
+ `;
171
+ // ---- observational-memory ranking constants ------------------------------
172
+ /** Weight of the text-match component (bm25-derived or trigram similarity). */
173
+ const WEIGHT_TEXT_MATCH = 0.6;
174
+ /** Weight of the relevance-tag component (critical=1.0 … low=0.25). */
175
+ const WEIGHT_RELEVANCE = 0.25;
176
+ /** Weight of the recency component. */
177
+ const WEIGHT_RECENCY = 0.15;
178
+ /** Recency decay constant: exp(-ageDays / RECENCY_DECAY_DAYS). */
179
+ const RECENCY_DECAY_DAYS = 30;
180
+ const MS_PER_DAY = 86_400_000;
181
+ /** Relevance-tag weights used by the ranking formula. */
182
+ const RELEVANCE_WEIGHTS = {
183
+ critical: 1.0,
184
+ high: 0.75,
185
+ medium: 0.5,
186
+ low: 0.25,
187
+ };
188
+ /** Minimum mean trigram Jaccard similarity for a typo-tolerant fallback match. */
189
+ const TYPO_FALLBACK_MIN_MEAN_SIMILARITY = 0.3;
190
+ /**
191
+ * Trigram Jaccard similarity at or above which a new observation is treated
192
+ * as a near-duplicate rewording of an existing row and replaces it.
193
+ */
194
+ const NEAR_DUPLICATE_SIMILARITY = 0.9;
195
+ /**
196
+ * Final ranking score for a candidate observation: a weighted blend of
197
+ * normalized text-match quality, relevance tag and recency decay. Every
198
+ * component is in [0, 1]; higher is better.
199
+ */
200
+ function scoreObservation(textScore, relevance, createdAt, now) {
201
+ const ageDays = Math.max(0, (now - createdAt) / MS_PER_DAY);
202
+ const recency = Math.exp(-ageDays / RECENCY_DECAY_DAYS);
203
+ const relevanceWeight = RELEVANCE_WEIGHTS[relevance] ?? RELEVANCE_WEIGHTS.low;
204
+ return (textScore * WEIGHT_TEXT_MATCH +
205
+ relevanceWeight * WEIGHT_RELEVANCE +
206
+ recency * WEIGHT_RECENCY);
207
+ }
152
208
  /**
153
209
  * Synchronous binding-file reader for a project at the given filesystem path.
154
210
  * Returns null when no `.aexol/aexol.jsonc` exists at that path.
@@ -269,9 +325,20 @@ export class SessionStore {
269
325
  stmtGetForkCompactSource;
270
326
  // Project observations: cross-session durable memory.
271
327
  stmtInsertProjectObs;
272
- stmtSearchProjectObs;
273
328
  stmtGetProjectObsById;
274
329
  stmtGetProjectByCwd;
330
+ // Shared by the typo-tolerant search fallback and near-duplicate detection.
331
+ stmtListProjectObsByProject;
332
+ stmtDeleteProjectObs;
333
+ stmtCountProjectObs;
334
+ // FTS5 trigram index statements. Null when the bundled SQLite build has no
335
+ // FTS5 — the store then degrades to LIKE-only search (see `ftsEnabled`).
336
+ ftsEnabled = false;
337
+ stmtInsertProjectObsFts = null;
338
+ stmtDeleteProjectObsFts = null;
339
+ stmtDeleteProjectObsFtsByProject = null;
340
+ stmtSearchProjectObsFts = null;
341
+ stmtCountProjectObsFts = null;
275
342
  stmtUpsertProjectRecallSource;
276
343
  stmtGetProjectRecallSource;
277
344
  // Prompt queue statements
@@ -539,14 +606,15 @@ export class SessionStore {
539
606
  this.stmtGetForkCompactSource = this.db.prepare(`SELECT fork_compact_source_id FROM sessions WHERE id = ?`);
540
607
  this.stmtInsertProjectObs = this.db.prepare(`INSERT OR REPLACE INTO project_observations (id, project_id, session_id, content, relevance, created_at)
541
608
  VALUES (?, ?, ?, ?, ?, ?)`);
542
- this.stmtSearchProjectObs = this.db.prepare(`SELECT id, project_id, session_id, content, relevance, created_at
543
- FROM project_observations
544
- WHERE project_id = ? AND content LIKE ?
545
- ORDER BY created_at DESC
546
- LIMIT 20`);
547
609
  this.stmtGetProjectObsById = this.db.prepare(`SELECT id, project_id, session_id, content, relevance, created_at
548
610
  FROM project_observations
549
611
  WHERE project_id = ? AND id = ?`);
612
+ this.stmtListProjectObsByProject = this.db.prepare(`SELECT id, project_id, session_id, content, relevance, created_at
613
+ FROM project_observations
614
+ WHERE project_id = ?
615
+ ORDER BY created_at DESC`);
616
+ this.stmtDeleteProjectObs = this.db.prepare(`DELETE FROM project_observations WHERE id = ? AND project_id = ?`);
617
+ this.stmtCountProjectObs = this.db.prepare(`SELECT COUNT(*) AS count FROM project_observations`);
550
618
  this.stmtGetProjectByCwd = this.db.prepare(`SELECT id FROM projects WHERE path = ? LIMIT 1`);
551
619
  this.stmtUpsertProjectRecallSource = this.db.prepare(`INSERT OR REPLACE INTO project_recall_sources
552
620
  (project_id, memory_id, entries_json, updated_at)
@@ -554,6 +622,35 @@ export class SessionStore {
554
622
  this.stmtGetProjectRecallSource = this.db.prepare(`SELECT project_id, memory_id, entries_json, updated_at
555
623
  FROM project_recall_sources
556
624
  WHERE project_id = ? AND memory_id = ?`);
625
+ // ---- project-observation FTS trigram index ---------------------------
626
+ // The index is a standalone FTS5 virtual table synced explicitly in code
627
+ // (same transaction as insertProjectObservations). If the SQLite build
628
+ // lacks FTS5 we log a warning and degrade to LIKE-only search instead of
629
+ // failing to open the store.
630
+ try {
631
+ this.db.exec(PROJECT_OBS_FTS_SQL);
632
+ this.stmtInsertProjectObsFts = this.db.prepare(`INSERT INTO project_observations_fts (content, project_id, obs_id) VALUES (?, ?, ?)`);
633
+ this.stmtDeleteProjectObsFts = this.db.prepare(`DELETE FROM project_observations_fts WHERE obs_id = ? AND project_id = ?`);
634
+ this.stmtDeleteProjectObsFtsByProject = this.db.prepare(`DELETE FROM project_observations_fts WHERE project_id = ?`);
635
+ this.stmtSearchProjectObsFts = this.db.prepare(`SELECT o.id, o.session_id, o.content, o.relevance, o.created_at,
636
+ bm25(project_observations_fts) AS bm25_score
637
+ FROM project_observations_fts
638
+ JOIN project_observations o
639
+ ON o.id = project_observations_fts.obs_id
640
+ AND o.project_id = project_observations_fts.project_id
641
+ WHERE project_observations_fts MATCH ?
642
+ AND project_observations_fts.project_id = ?`);
643
+ this.stmtCountProjectObsFts = this.db.prepare(`SELECT COUNT(*) AS count FROM project_observations_fts`);
644
+ this.backfillProjectObservationsFts();
645
+ this.ftsEnabled = true;
646
+ }
647
+ catch (err) {
648
+ const msg = err instanceof Error ? err.message : String(err);
649
+ console.warn(`[storage] FTS5 trigram index unavailable (${msg}); ` +
650
+ `project-observation search degrades to a LIKE-conjunction ` +
651
+ `(exact token substring) scan without typo tolerance or bm25 ranking.`);
652
+ this.ftsEnabled = false;
653
+ }
557
654
  // ---- prompt queue statements ----------------------------------------
558
655
  this.stmtEnqueuePrompt = this.db.prepare(`INSERT INTO prompt_queue (id, session_id, content, images_json, position, created_at)
559
656
  VALUES (?, ?, ?, ?, ?, ?)`);
@@ -736,6 +833,11 @@ export class SessionStore {
736
833
  deleteProject(id) {
737
834
  const sessionIds = this.stmtListSessionIdsByProject.all(id).map((r) => r.id);
738
835
  const info = this.stmtDeleteProject.run(id);
836
+ if (info.changes > 0) {
837
+ // project_observations rows cascade via FK, but the standalone FTS
838
+ // index has no FK — clean it explicitly so it never accumulates orphans.
839
+ this.stmtDeleteProjectObsFtsByProject?.run(id);
840
+ }
739
841
  return { deleted: info.changes > 0, sessionIds };
740
842
  }
741
843
  /** Sessions belonging to a single project, newest-first. */
@@ -1358,29 +1460,202 @@ export class SessionStore {
1358
1460
  /**
1359
1461
  * Insert multiple project observations in a single transaction.
1360
1462
  * Uses INSERT OR REPLACE so re-running after the same compaction is idempotent.
1463
+ *
1464
+ * Write-time near-duplicate detection: an observation whose content is a
1465
+ * near-identical rewording (trigram Jaccard >= NEAR_DUPLICATE_SIMILARITY)
1466
+ * of an existing row replaces it — new wording wins — so refinements of
1467
+ * the same fact don't accumulate as duplicates. The FTS5 trigram index is
1468
+ * synced in the same transaction.
1361
1469
  */
1362
1470
  insertProjectObservations(projectId, sessionId, observations, createdAt) {
1363
1471
  if (observations.length === 0)
1364
1472
  return;
1365
1473
  const tx = this.db.transaction(() => {
1366
1474
  for (const obs of observations) {
1475
+ // Near-duplicate detection: a reworded duplicate replaces the older
1476
+ // row. Identical re-inserts also match (similarity 1.0), which keeps
1477
+ // INSERT OR REPLACE idempotency intact.
1478
+ const duplicate = this.findNearDuplicateObservation(projectId, obs.content);
1479
+ if (duplicate) {
1480
+ this.stmtDeleteProjectObs.run(duplicate.id, projectId);
1481
+ this.stmtDeleteProjectObsFts?.run(duplicate.id, projectId);
1482
+ }
1367
1483
  this.stmtInsertProjectObs.run(obs.id, projectId, sessionId, obs.content, obs.relevance, createdAt);
1484
+ if (this.ftsEnabled) {
1485
+ // INSERT OR REPLACE semantics: drop any stale index row for this id
1486
+ // before indexing the new content.
1487
+ this.stmtDeleteProjectObsFts?.run(obs.id, projectId);
1488
+ this.stmtInsertProjectObsFts?.run(obs.content, projectId, obs.id);
1489
+ }
1368
1490
  }
1369
1491
  });
1370
1492
  tx();
1371
1493
  }
1372
1494
  /**
1373
- * Search project observations by substring match. Returns up to 20 most recent.
1495
+ * Find an existing project observation whose content is a near-identical
1496
+ * rewording of `content` (trigram Jaccard >= NEAR_DUPLICATE_SIMILARITY).
1497
+ */
1498
+ findNearDuplicateObservation(projectId, content) {
1499
+ let best = null;
1500
+ let bestSimilarity = 0;
1501
+ for (const row of this.stmtListProjectObsByProject.all(projectId)) {
1502
+ const similarity = trigramJaccard(content, row.content);
1503
+ if (similarity > bestSimilarity) {
1504
+ bestSimilarity = similarity;
1505
+ best = row;
1506
+ }
1507
+ }
1508
+ return bestSimilarity >= NEAR_DUPLICATE_SIMILARITY ? best : null;
1509
+ }
1510
+ /**
1511
+ * Search project observations using n-gram matching.
1512
+ *
1513
+ * Query tokens are matched via the FTS5 trigram index: each token becomes
1514
+ * a quoted phrase joined with AND, so a multi-word query matches
1515
+ * observations containing ALL tokens anywhere (case-insensitive substring
1516
+ * semantics). Tokens shorter than 3 characters cannot be matched by the
1517
+ * trigram tokenizer; they are applied as a complementary LIKE filter over
1518
+ * the FTS candidates (or as a plain LIKE conjunction when every token is
1519
+ * short, or when the index is unavailable). When no candidate survives,
1520
+ * a typo-tolerant fallback keeps observations whose token-level trigram
1521
+ * similarity to the query is >= TYPO_FALLBACK_MIN_MEAN_SIMILARITY.
1522
+ *
1523
+ * Results are ranked by a blend of text-match quality, relevance tag and
1524
+ * recency. The return shape is unchanged from the previous substring
1525
+ * search: { content, relevance, createdAt, sessionId }.
1374
1526
  */
1375
1527
  searchProjectObservations(projectId, query, limit = 20) {
1376
- const pattern = `%${query}%`;
1377
- const rows = this.stmtSearchProjectObs.all(projectId, pattern);
1378
- return rows.slice(0, limit).map((r) => ({
1528
+ const trimmed = query.trim();
1529
+ if (!trimmed)
1530
+ return [];
1531
+ const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
1532
+ if (tokens.length === 0)
1533
+ return [];
1534
+ const now = Date.now();
1535
+ return this.findObservationCandidates(projectId, tokens)
1536
+ .map((candidate) => ({
1537
+ candidate,
1538
+ score: scoreObservation(candidate.textScore, candidate.relevance, candidate.created_at, now),
1539
+ }))
1540
+ .sort((a, b) => b.score - a.score || b.candidate.created_at - a.candidate.created_at)
1541
+ .slice(0, limit)
1542
+ .map(({ candidate }) => ({
1543
+ content: candidate.content,
1544
+ relevance: candidate.relevance,
1545
+ createdAt: candidate.created_at,
1546
+ sessionId: candidate.session_id,
1547
+ }));
1548
+ }
1549
+ /**
1550
+ * Collect candidate observations for a tokenized query, assigning each a
1551
+ * normalized text-match score in (0, 1].
1552
+ */
1553
+ findObservationCandidates(projectId, tokens) {
1554
+ const longTokens = tokens.filter((t) => t.length >= MIN_TRIGRAM_TOKEN_LENGTH);
1555
+ const shortTokens = tokens.filter((t) => t.length < MIN_TRIGRAM_TOKEN_LENGTH);
1556
+ // Primary path: FTS5 trigram MATCH over the trigram-matchable tokens.
1557
+ if (longTokens.length > 0 && this.ftsEnabled) {
1558
+ const candidates = this.searchObservationsFts(projectId, longTokens, shortTokens);
1559
+ if (candidates.length > 0)
1560
+ return candidates;
1561
+ }
1562
+ else if (!this.ftsEnabled && longTokens.length > 0) {
1563
+ // FTS5 unavailable: LIKE-conjunction scan over the project's rows using
1564
+ // ALL tokens as needles, preserving the pre-FTS substring semantics for
1565
+ // non-token-aligned substrings (e.g. "igrate rollb").
1566
+ const needles = tokens.map((t) => t.toLowerCase());
1567
+ return this.stmtListProjectObsByProject
1568
+ .all(projectId)
1569
+ .filter((row) => {
1570
+ const content = row.content.toLowerCase();
1571
+ return needles.every((needle) => content.includes(needle));
1572
+ })
1573
+ .map((row) => ({ ...row, textScore: 1 }));
1574
+ }
1575
+ else if (longTokens.length === 0) {
1576
+ // All tokens shorter than a trigram: plain LIKE conjunction.
1577
+ const needles = shortTokens.map((t) => t.toLowerCase());
1578
+ return this.stmtListProjectObsByProject
1579
+ .all(projectId)
1580
+ .filter((row) => {
1581
+ const content = row.content.toLowerCase();
1582
+ return needles.every((needle) => content.includes(needle));
1583
+ })
1584
+ .map((row) => ({ ...row, textScore: 1 }));
1585
+ }
1586
+ // Typo-tolerant fallback: nothing matched exactly. Scan the project's
1587
+ // observations (small corpus) and keep near matches by trigram
1588
+ // similarity, which substitutes for the bm25 component in the ranking.
1589
+ return this.stmtListProjectObsByProject
1590
+ .all(projectId)
1591
+ .map((row) => ({ row, similarity: bestTokenTrigramJaccard(tokens, row.content) }))
1592
+ .filter(({ similarity }) => similarity >= TYPO_FALLBACK_MIN_MEAN_SIMILARITY)
1593
+ .map(({ row, similarity }) => ({ ...row, textScore: similarity }));
1594
+ }
1595
+ /**
1596
+ * FTS5 trigram MATCH over `longTokens`, then a complementary LIKE filter
1597
+ * for tokens too short for the trigram tokenizer. Returns an empty array
1598
+ * when the index yields nothing usable (the caller then falls back).
1599
+ */
1600
+ searchObservationsFts(projectId, longTokens, shortTokens) {
1601
+ const matchQuery = buildFtsMatchQuery(longTokens);
1602
+ if (!matchQuery)
1603
+ return [];
1604
+ let rows;
1605
+ try {
1606
+ rows = this.stmtSearchProjectObsFts?.all(matchQuery, projectId) ?? [];
1607
+ }
1608
+ catch (err) {
1609
+ // Defensive: a MATCH failure should degrade to the fallback path, not
1610
+ // surface as a tool error.
1611
+ const msg = err instanceof Error ? err.message : String(err);
1612
+ console.warn(`[storage] project-observation FTS query failed: ${msg}`);
1613
+ return [];
1614
+ }
1615
+ let candidates = rows.map((r) => ({
1616
+ id: r.id,
1617
+ project_id: projectId,
1618
+ session_id: r.session_id,
1379
1619
  content: r.content,
1380
1620
  relevance: r.relevance,
1381
- createdAt: r.created_at,
1382
- sessionId: r.session_id,
1621
+ created_at: r.created_at,
1622
+ // bm25() is negative with lower = better in SQLite; 1 - exp(bm25)
1623
+ // maps it to a bounded, higher-is-better text score in (0, 1].
1624
+ textScore: 1 - Math.exp(r.bm25_score),
1383
1625
  }));
1626
+ for (const token of shortTokens) {
1627
+ const needle = token.toLowerCase();
1628
+ candidates = candidates.filter((c) => c.content.toLowerCase().includes(needle));
1629
+ }
1630
+ return candidates;
1631
+ }
1632
+ /**
1633
+ * Rebuild the FTS index from project_observations when it is out of sync
1634
+ * (empty or partial relative to the base table). Handles databases created
1635
+ * before the FTS index existed and any drift (e.g. projects deleted by an
1636
+ * older build without FTS cleanup). No-op when the counts already match.
1637
+ */
1638
+ backfillProjectObservationsFts() {
1639
+ const obsCount = this.stmtCountProjectObs.get()?.count ?? 0;
1640
+ const ftsCount = this.stmtCountProjectObsFts?.get()?.count ?? 0;
1641
+ if (obsCount === ftsCount)
1642
+ return;
1643
+ const tx = this.db.transaction(() => {
1644
+ this.db.exec(`DELETE FROM project_observations_fts`);
1645
+ if (obsCount > 0) {
1646
+ this.db.exec(`INSERT INTO project_observations_fts (content, project_id, obs_id)
1647
+ SELECT content, project_id, id FROM project_observations`);
1648
+ }
1649
+ });
1650
+ tx();
1651
+ }
1652
+ /**
1653
+ * Whether the FTS5 trigram index is active for project observations.
1654
+ * Exposed for diagnostics and tests; false means search is running in
1655
+ * LIKE-only degradation mode.
1656
+ */
1657
+ isProjectObsFtsEnabled() {
1658
+ return this.ftsEnabled;
1384
1659
  }
1385
1660
  /**
1386
1661
  * Get a single project observation by id. Returns null if not found.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Character-trigram text utilities for observational memory.
3
+ *
4
+ * Shared by the storage layer for:
5
+ * - building FTS5 MATCH queries against the trigram-tokenized index
6
+ * (`buildFtsMatchQuery`),
7
+ * - typo-tolerant search fallback (`bestTokenTrigramJaccard`),
8
+ * - write-time near-duplicate detection (`trigramJaccard`).
9
+ *
10
+ * Pure functions with no SQLite dependency so the logic is unit-testable
11
+ * in isolation.
12
+ */
13
+ /**
14
+ * Minimum token length the SQLite trigram tokenizer can match. Tokens
15
+ * shorter than this cannot be satisfied by an FTS5 MATCH query and must be
16
+ * handled with complementary LIKE filtering (or the LIKE-only search path).
17
+ */
18
+ export declare const MIN_TRIGRAM_TOKEN_LENGTH = 3;
19
+ /**
20
+ * Per-token floor for {@link bestTokenTrigramJaccard}: every trigram-matchable
21
+ * query token must reach this Jaccard similarity against some content token,
22
+ * otherwise the content is considered not to match the query at all. This
23
+ * mirrors the AND semantics of the FTS MATCH path — every term must be
24
+ * (approximately) present — while still tolerating typos.
25
+ */
26
+ export declare const TYPO_FALLBACK_MIN_TOKEN_SIMILARITY = 0.2;
27
+ /** Normalize text for trigram comparison: lowercase and collapse whitespace. */
28
+ export declare function normalizeForTrigrams(text: string): string;
29
+ /**
30
+ * Build the set of character trigrams of `text`.
31
+ *
32
+ * Strings shorter than one trigram collapse to a single pseudo-token so
33
+ * identical short strings still compare equal (Jaccard 1.0). Empty input
34
+ * yields an empty set.
35
+ */
36
+ export declare function trigramSet(text: string): Set<string>;
37
+ /**
38
+ * Jaccard similarity between the character-trigram sets of two strings,
39
+ * in [0, 1]. Returns 0 when either side has no trigrams.
40
+ */
41
+ export declare function trigramJaccard(a: string, b: string): number;
42
+ /**
43
+ * Typo-tolerant similarity between a whole query and a piece of content:
44
+ * the mean, over query tokens, of the best Jaccard similarity between that
45
+ * token and any single content token.
46
+ *
47
+ * Comparing token-to-token (instead of query-to-full-content) keeps the
48
+ * score robust to sentence length — a one-word typo in a long observation
49
+ * would otherwise be diluted below any useful threshold.
50
+ *
51
+ * Only `queryTokens` of trigram-matchable length are scored; shorter tokens
52
+ * are ignored so they do not drag the mean down for matches they can never
53
+ * express. Returns 0 — no match — when any such token fails to reach
54
+ * TYPO_FALLBACK_MIN_TOKEN_SIMILARITY against every content token (the token
55
+ * is absent, mirroring the AND semantics of the FTS path), or when no query
56
+ * token qualifies.
57
+ */
58
+ export declare function bestTokenTrigramJaccard(queryTokens: string[], content: string): number;
59
+ /**
60
+ * Build an FTS5 MATCH query from search tokens.
61
+ *
62
+ * Each token becomes a quoted phrase (`"token"`); phrases are joined with
63
+ * AND. With the trigram tokenizer each quoted phrase acts as a
64
+ * case-insensitive substring match, so a multi-token query matches content
65
+ * containing all tokens anywhere. Embedded double quotes are escaped by
66
+ * doubling, which is how FTS5 string literals represent a literal quote.
67
+ *
68
+ * Returns an empty string when no non-empty tokens are supplied — callers
69
+ * must guard against that before running a MATCH query.
70
+ */
71
+ export declare function buildFtsMatchQuery(tokens: string[]): string;
72
+ //# sourceMappingURL=trigrams.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trigrams.d.ts","sourceRoot":"","sources":["../../../src/server/text/trigrams.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAE1C;;;;;;GAMG;AACH,eAAO,MAAM,kCAAkC,MAAM,CAAC;AAEtD,gFAAgF;AAChF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAWpD;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAI3D;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,MAAM,EAAE,EACrB,OAAO,EAAE,MAAM,GACd,MAAM,CAoBR;AAaD;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,CAK3D"}
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Character-trigram text utilities for observational memory.
3
+ *
4
+ * Shared by the storage layer for:
5
+ * - building FTS5 MATCH queries against the trigram-tokenized index
6
+ * (`buildFtsMatchQuery`),
7
+ * - typo-tolerant search fallback (`bestTokenTrigramJaccard`),
8
+ * - write-time near-duplicate detection (`trigramJaccard`).
9
+ *
10
+ * Pure functions with no SQLite dependency so the logic is unit-testable
11
+ * in isolation.
12
+ */
13
+ /**
14
+ * Minimum token length the SQLite trigram tokenizer can match. Tokens
15
+ * shorter than this cannot be satisfied by an FTS5 MATCH query and must be
16
+ * handled with complementary LIKE filtering (or the LIKE-only search path).
17
+ */
18
+ export const MIN_TRIGRAM_TOKEN_LENGTH = 3;
19
+ /**
20
+ * Per-token floor for {@link bestTokenTrigramJaccard}: every trigram-matchable
21
+ * query token must reach this Jaccard similarity against some content token,
22
+ * otherwise the content is considered not to match the query at all. This
23
+ * mirrors the AND semantics of the FTS MATCH path — every term must be
24
+ * (approximately) present — while still tolerating typos.
25
+ */
26
+ export const TYPO_FALLBACK_MIN_TOKEN_SIMILARITY = 0.2;
27
+ /** Normalize text for trigram comparison: lowercase and collapse whitespace. */
28
+ export function normalizeForTrigrams(text) {
29
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
30
+ }
31
+ /**
32
+ * Build the set of character trigrams of `text`.
33
+ *
34
+ * Strings shorter than one trigram collapse to a single pseudo-token so
35
+ * identical short strings still compare equal (Jaccard 1.0). Empty input
36
+ * yields an empty set.
37
+ */
38
+ export function trigramSet(text) {
39
+ const normalized = normalizeForTrigrams(text);
40
+ if (normalized.length === 0)
41
+ return new Set();
42
+ if (normalized.length < MIN_TRIGRAM_TOKEN_LENGTH) {
43
+ return new Set([normalized]);
44
+ }
45
+ const grams = new Set();
46
+ for (let i = 0; i + MIN_TRIGRAM_TOKEN_LENGTH <= normalized.length; i++) {
47
+ grams.add(normalized.slice(i, i + MIN_TRIGRAM_TOKEN_LENGTH));
48
+ }
49
+ return grams;
50
+ }
51
+ /**
52
+ * Jaccard similarity between the character-trigram sets of two strings,
53
+ * in [0, 1]. Returns 0 when either side has no trigrams.
54
+ */
55
+ export function trigramJaccard(a, b) {
56
+ const setA = trigramSet(a);
57
+ const setB = trigramSet(b);
58
+ return jaccard(setA, setB);
59
+ }
60
+ /**
61
+ * Typo-tolerant similarity between a whole query and a piece of content:
62
+ * the mean, over query tokens, of the best Jaccard similarity between that
63
+ * token and any single content token.
64
+ *
65
+ * Comparing token-to-token (instead of query-to-full-content) keeps the
66
+ * score robust to sentence length — a one-word typo in a long observation
67
+ * would otherwise be diluted below any useful threshold.
68
+ *
69
+ * Only `queryTokens` of trigram-matchable length are scored; shorter tokens
70
+ * are ignored so they do not drag the mean down for matches they can never
71
+ * express. Returns 0 — no match — when any such token fails to reach
72
+ * TYPO_FALLBACK_MIN_TOKEN_SIMILARITY against every content token (the token
73
+ * is absent, mirroring the AND semantics of the FTS path), or when no query
74
+ * token qualifies.
75
+ */
76
+ export function bestTokenTrigramJaccard(queryTokens, content) {
77
+ const scorable = queryTokens.filter((t) => t.length >= MIN_TRIGRAM_TOKEN_LENGTH);
78
+ if (scorable.length === 0)
79
+ return 0;
80
+ const contentTokens = content.split(/\s+/).filter((t) => t.length > 0);
81
+ const contentTokenGrams = contentTokens.map((token) => trigramSet(token));
82
+ let total = 0;
83
+ for (const queryToken of scorable) {
84
+ let best = 0;
85
+ const queryGrams = trigramSet(queryToken);
86
+ for (const grams of contentTokenGrams) {
87
+ const similarity = jaccard(queryGrams, grams);
88
+ if (similarity > best)
89
+ best = similarity;
90
+ if (best === 1)
91
+ break;
92
+ }
93
+ if (best < TYPO_FALLBACK_MIN_TOKEN_SIMILARITY)
94
+ return 0;
95
+ total += best;
96
+ }
97
+ return total / scorable.length;
98
+ }
99
+ /** Jaccard similarity between two pre-built sets, in [0, 1]. */
100
+ function jaccard(a, b) {
101
+ if (a.size === 0 || b.size === 0)
102
+ return 0;
103
+ let intersection = 0;
104
+ for (const gram of a) {
105
+ if (b.has(gram))
106
+ intersection++;
107
+ }
108
+ const union = a.size + b.size - intersection;
109
+ return union === 0 ? 0 : intersection / union;
110
+ }
111
+ /**
112
+ * Build an FTS5 MATCH query from search tokens.
113
+ *
114
+ * Each token becomes a quoted phrase (`"token"`); phrases are joined with
115
+ * AND. With the trigram tokenizer each quoted phrase acts as a
116
+ * case-insensitive substring match, so a multi-token query matches content
117
+ * containing all tokens anywhere. Embedded double quotes are escaped by
118
+ * doubling, which is how FTS5 string literals represent a literal quote.
119
+ *
120
+ * Returns an empty string when no non-empty tokens are supplied — callers
121
+ * must guard against that before running a MATCH query.
122
+ */
123
+ export function buildFtsMatchQuery(tokens) {
124
+ return tokens
125
+ .filter((token) => token.length > 0)
126
+ .map((token) => `"${token.replace(/"/g, '""')}"`)
127
+ .join(" AND ");
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.9.199",
3
+ "version": "0.9.201",
4
4
  "description": "AI coding agent for Aexol with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,