@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.
- package/dist/commands/serve.d.ts +2 -0
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +11 -1
- package/dist/extensions/image-generation/index.d.ts.map +1 -1
- package/dist/extensions/image-generation/index.js +81 -11
- package/dist/extensions/session-fanout/index.d.ts +19 -0
- package/dist/extensions/session-fanout/index.d.ts.map +1 -0
- package/dist/extensions/session-fanout/index.js +139 -0
- package/dist/memory/tools/read-project-observations.d.ts.map +1 -1
- package/dist/memory/tools/read-project-observations.js +8 -4
- package/dist/memory/tools/write-project-observation.d.ts.map +1 -1
- package/dist/memory/tools/write-project-observation.js +5 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/extensions/native-extensions.js +10 -0
- package/dist/sdk/coding-agent/core/system-prompt.d.ts.map +1 -1
- package/dist/sdk/coding-agent/core/system-prompt.js +2 -1
- package/dist/server/fanout/fanout-manager.d.ts +126 -0
- package/dist/server/fanout/fanout-manager.d.ts.map +1 -0
- package/dist/server/fanout/fanout-manager.js +344 -0
- package/dist/server/session-stream.d.ts.map +1 -1
- package/dist/server/session-stream.js +4 -0
- package/dist/server/storage.d.ts +59 -2
- package/dist/server/storage.d.ts.map +1 -1
- package/dist/server/storage.js +287 -12
- package/dist/server/text/trigrams.d.ts +72 -0
- package/dist/server/text/trigrams.d.ts.map +1 -0
- package/dist/server/text/trigrams.js +128 -0
- package/package.json +1 -1
package/dist/server/storage.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
1377
|
-
|
|
1378
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
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
|
+
}
|