@gmickel/gno 2.8.0 → 2.8.1
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/README.md +1 -1
- package/assets/skill/SKILL.md +5 -2
- package/assets/skill/recipes/memory-scoped-recall.md +10 -5
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.8.0.zip → gno-browser-clipper-v2.8.1.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.1.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +41 -6
- package/spec/mcp.md +8 -4
- package/spec/output-schemas/memory-recall.schema.json +1 -1
- package/spec/output-schemas/status.schema.json +3 -3
- package/src/cli/commands/graph.ts +3 -1
- package/src/cli/commands/links.ts +27 -49
- package/src/cli/commands/status.ts +1 -0
- package/src/core/audit-links.ts +56 -4
- package/src/core/audit-outside-index.ts +215 -0
- package/src/core/audit-workspace.ts +13 -7
- package/src/core/audit.ts +9 -1
- package/src/core/link-inventory-markdown.ts +2 -3
- package/src/core/links.ts +40 -17
- package/src/core/memory-recall.ts +254 -15
- package/src/core/memory-types.ts +12 -0
- package/src/core/memory.ts +2 -0
- package/src/ingestion/sync.ts +5 -3
- package/src/mcp/tools/links.ts +71 -93
- package/src/mcp/tools/status.ts +1 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +4 -0
- package/src/sdk/client.ts +1 -0
- package/src/serve/public/components/editor/MarkdownPreview.tsx +5 -3
- package/src/serve/routes/graph.ts +3 -1
- package/src/serve/routes/links.ts +32 -50
- package/src/serve/server.ts +2 -1
- package/src/serve/status.ts +1 -0
- package/src/store/sqlite/adapter.ts +87 -106
- package/src/store/sqlite/graph-link-resolver.ts +7 -0
- package/src/store/sqlite/graph-similarity.ts +96 -0
- package/src/store/sqlite/workspace-link-resolver.ts +119 -31
- package/src/store/types.ts +15 -2
- package/src/store/vector/status.ts +27 -0
- package/src/store/vector/stored-vectors.ts +158 -0
- package/src/store/vector/types.ts +6 -0
- package/src/store/vector/variant-search.ts +30 -14
- package/browser-extension/artifacts/gno-browser-clipper-v2.8.0.zip.sha256 +0 -1
package/src/core/memory-types.ts
CHANGED
|
@@ -33,11 +33,23 @@ export const MEMORY_RECALL_MAX_TOKENS = 512;
|
|
|
33
33
|
/** Retrieval depth per leg before fusion and budgeting. */
|
|
34
34
|
export const MEMORY_RECALL_RETRIEVAL_LIMIT = 32;
|
|
35
35
|
export const MEMORY_RRF_K = 60;
|
|
36
|
+
/**
|
|
37
|
+
* Any-term fallback floor: a fact must score at least this fraction of the
|
|
38
|
+
* best fact's raw BM25, so a term shared by most facts cannot pull them in.
|
|
39
|
+
*/
|
|
40
|
+
export const MEMORY_RECALL_ANY_TERM_MIN_RELATIVE_SCORE = 0.1;
|
|
36
41
|
export const MEMORY_DEFAULT_LOCK_WAIT_MS = 120_000;
|
|
37
42
|
export const MEMORY_TOKEN_BYTES_ESTIMATE = 4;
|
|
38
43
|
|
|
44
|
+
/** Recall hint when the scope holds no current fact at all. */
|
|
39
45
|
export const MEMORY_EMPTY_RECALL_HINT =
|
|
40
46
|
'No memories in scope yet. Store one with: gno remember "<fact>" --scope <scope> --decision add';
|
|
47
|
+
/** Recall hint when the scope holds facts but none matched the query. */
|
|
48
|
+
export const MEMORY_NO_MATCH_RECALL_HINT =
|
|
49
|
+
'No memories in scope matched this query. Rephrase with words the fact uses, or store one with: gno remember "<fact>" --scope <scope> --decision add';
|
|
50
|
+
/** Recall hint when facts matched but none fit the token budget. */
|
|
51
|
+
export const MEMORY_OVER_BUDGET_RECALL_HINT =
|
|
52
|
+
"Matching memories did not fit the token budget. Raise --max-tokens (maxTokens) to return them.";
|
|
41
53
|
|
|
42
54
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
55
|
// Errors
|
package/src/core/memory.ts
CHANGED
|
@@ -34,6 +34,8 @@ export {
|
|
|
34
34
|
MEMORY_CANDIDATE_POOL,
|
|
35
35
|
MEMORY_EMPTY_RECALL_HINT,
|
|
36
36
|
MEMORY_LEXICAL_LIKELY_THRESHOLD,
|
|
37
|
+
MEMORY_NO_MATCH_RECALL_HINT,
|
|
38
|
+
MEMORY_OVER_BUDGET_RECALL_HINT,
|
|
37
39
|
MEMORY_RECALL_MAX_FACTS,
|
|
38
40
|
MEMORY_RECALL_MAX_TOKENS,
|
|
39
41
|
MEMORY_SEMANTIC_LIKELY_THRESHOLD,
|
package/src/ingestion/sync.ts
CHANGED
|
@@ -57,7 +57,6 @@ import {
|
|
|
57
57
|
} from "../core/links";
|
|
58
58
|
import { extractMemoryScopes } from "../core/memory-record";
|
|
59
59
|
import { normalizeTag, validateTag } from "../core/tags";
|
|
60
|
-
import { TYPED_METADATA_INGEST_VERSION } from "../core/typed-metadata";
|
|
61
60
|
import { defaultChunker } from "./chunker";
|
|
62
61
|
import { persistChunkLayout, prepareChunking } from "./chunking";
|
|
63
62
|
import {
|
|
@@ -102,9 +101,12 @@ const MAX_CONCURRENCY = 16;
|
|
|
102
101
|
/**
|
|
103
102
|
* Current ingest schema version.
|
|
104
103
|
* Increment when ingestion adds new derived data (tags, metadata, etc.)
|
|
105
|
-
* Documents with ingestVersion < INGEST_VERSION
|
|
104
|
+
* or changes how it is parsed. Documents with ingestVersion < INGEST_VERSION
|
|
105
|
+
* will be re-processed. Must stay >= TYPED_METADATA_INGEST_VERSION (tested).
|
|
106
|
+
* 8: wiki links with a table-escaped alias (`[[Note\|Alias]]`) and Markdown
|
|
107
|
+
* link text with square brackets parse the way Obsidian renders them.
|
|
106
108
|
*/
|
|
107
|
-
export const INGEST_VERSION =
|
|
109
|
+
export const INGEST_VERSION = 8;
|
|
108
110
|
const EMPTY_CONTENT_TYPE_RULES_FINGERPRINT =
|
|
109
111
|
fingerprintContentTypeMetadataRules([]);
|
|
110
112
|
const NON_RETRYABLE_CONVERSION_ERROR_CODES = new Set([
|
package/src/mcp/tools/links.ts
CHANGED
|
@@ -25,6 +25,12 @@ import { parseRef } from "../../core/ref-parser";
|
|
|
25
25
|
import { normalizeCollectionName } from "../../core/validation";
|
|
26
26
|
import { getActivePreset } from "../../llm/registry";
|
|
27
27
|
import { createVectorIndexPort } from "../../store/vector";
|
|
28
|
+
import {
|
|
29
|
+
readStoredDocumentVectors,
|
|
30
|
+
resolveStoredVectorSource,
|
|
31
|
+
similarityHitDocuments,
|
|
32
|
+
storedVectorSearchOptions,
|
|
33
|
+
} from "../../store/vector/stored-vectors";
|
|
28
34
|
import { runTool, type ToolResult } from "./index";
|
|
29
35
|
|
|
30
36
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -409,62 +415,41 @@ export function handleSimilar(
|
|
|
409
415
|
const preset = getActivePreset(ctx.config);
|
|
410
416
|
const modelUri = preset.embed;
|
|
411
417
|
|
|
412
|
-
//
|
|
418
|
+
// Stored chunk vectors from the active partition (NO model loading required)
|
|
413
419
|
const db = ctx.store.getRawDb();
|
|
420
|
+
const source = resolveStoredVectorSource(db, modelUri);
|
|
414
421
|
|
|
415
|
-
|
|
416
|
-
|
|
422
|
+
let vectors: Float32Array[];
|
|
423
|
+
try {
|
|
424
|
+
vectors =
|
|
425
|
+
readStoredDocumentVectors(db, source, [
|
|
426
|
+
{ id: doc.id, mirrorHash: doc.mirrorHash },
|
|
427
|
+
]).get(doc.id) ?? [];
|
|
428
|
+
} catch (e) {
|
|
429
|
+
throw new Error(
|
|
430
|
+
`Invalid stored embedding data: ${e instanceof Error ? e.message : String(e)}`
|
|
431
|
+
);
|
|
417
432
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
.query<VectorRow, [string, string]>(
|
|
421
|
-
`SELECT embedding FROM content_vectors
|
|
422
|
-
WHERE mirror_hash = ? AND model = ?
|
|
423
|
-
ORDER BY seq`
|
|
424
|
-
)
|
|
425
|
-
.all(doc.mirrorHash, modelUri);
|
|
426
|
-
|
|
427
|
-
if (vectorRows.length === 0) {
|
|
433
|
+
const first = vectors[0];
|
|
434
|
+
if (!first) {
|
|
428
435
|
throw new Error(
|
|
429
436
|
`${MCP_ERRORS.NOT_FOUND.code}: Document has no embeddings. Run: gno embed`
|
|
430
437
|
);
|
|
431
438
|
}
|
|
432
439
|
|
|
433
440
|
// Compute average embedding from all chunks
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
let avgEmbedding: Float32Array;
|
|
441
|
-
|
|
442
|
-
try {
|
|
443
|
-
dimensions = firstBlob.byteLength / 4;
|
|
444
|
-
avgEmbedding = new Float32Array(dimensions);
|
|
445
|
-
|
|
446
|
-
for (const row of vectorRows) {
|
|
447
|
-
const blob = new Uint8Array(row.embedding);
|
|
448
|
-
const embeddingDims = blob.byteLength / 4;
|
|
449
|
-
if (embeddingDims !== dimensions) {
|
|
450
|
-
throw new Error(
|
|
451
|
-
`Inconsistent embedding dimensions: expected ${dimensions}, got ${embeddingDims}`
|
|
452
|
-
);
|
|
453
|
-
}
|
|
454
|
-
const embedding = new Float32Array(
|
|
455
|
-
blob.buffer,
|
|
456
|
-
blob.byteOffset,
|
|
457
|
-
embeddingDims
|
|
441
|
+
const dimensions = first.length;
|
|
442
|
+
const avgEmbedding = new Float32Array(dimensions);
|
|
443
|
+
for (const embedding of vectors) {
|
|
444
|
+
if (embedding.length !== dimensions) {
|
|
445
|
+
throw new Error(
|
|
446
|
+
`Invalid stored embedding data: Inconsistent embedding dimensions: expected ${dimensions}, got ${embedding.length}`
|
|
458
447
|
);
|
|
459
|
-
for (let i = 0; i < dimensions; i++) {
|
|
460
|
-
const current = avgEmbedding[i] ?? 0;
|
|
461
|
-
avgEmbedding[i] = current + (embedding[i] ?? 0) / vectorRows.length;
|
|
462
|
-
}
|
|
463
448
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
449
|
+
for (let i = 0; i < dimensions; i++) {
|
|
450
|
+
const current = avgEmbedding[i] ?? 0;
|
|
451
|
+
avgEmbedding[i] = current + (embedding[i] ?? 0) / vectors.length;
|
|
452
|
+
}
|
|
468
453
|
}
|
|
469
454
|
|
|
470
455
|
// Normalize the average embedding for cosine similarity
|
|
@@ -513,73 +498,62 @@ export function handleSimilar(
|
|
|
513
498
|
const searchResult = await vectorIndex.searchNearest(
|
|
514
499
|
avgEmbedding,
|
|
515
500
|
candidateLimit,
|
|
516
|
-
|
|
501
|
+
storedVectorSearchOptions(source)
|
|
517
502
|
);
|
|
518
503
|
|
|
519
504
|
if (!searchResult.ok) {
|
|
520
505
|
throw new Error(searchResult.error.message);
|
|
521
506
|
}
|
|
522
507
|
|
|
523
|
-
//
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
.filter((r) => r.mirrorHash !== doc.mirrorHash)
|
|
528
|
-
.map((r) => r.mirrorHash)
|
|
529
|
-
),
|
|
530
|
-
];
|
|
508
|
+
// Legacy hits (no owners) exclude the source's content, as before
|
|
509
|
+
const hits = searchResult.value.filter(
|
|
510
|
+
(r) => r.documentIds !== undefined || r.mirrorHash !== doc.mirrorHash
|
|
511
|
+
);
|
|
531
512
|
|
|
532
|
-
// Batch query documents
|
|
513
|
+
// Batch query the hits' documents: exact owners for partition hits,
|
|
514
|
+
// documents sharing the content for legacy hits (avoid N+1)
|
|
533
515
|
interface DocRow {
|
|
516
|
+
id: number;
|
|
534
517
|
docid: string;
|
|
535
518
|
uri: string;
|
|
536
519
|
title: string | null;
|
|
537
520
|
collection: string;
|
|
538
521
|
rel_path: string;
|
|
539
|
-
|
|
522
|
+
mirrorHash: string;
|
|
540
523
|
}
|
|
541
524
|
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
// Build best score per mirrorHash from search results
|
|
562
|
-
const scoresByHash = new Map<string, number>();
|
|
563
|
-
for (const r of searchResult.value) {
|
|
564
|
-
if (r.mirrorHash === doc.mirrorHash) continue;
|
|
565
|
-
// Compute similarity score from cosine distance
|
|
566
|
-
// sqlite-vec with cosine metric returns distance where similarity = 1 - distance
|
|
567
|
-
const score = Math.max(0, Math.min(1, 1 - r.distance));
|
|
568
|
-
const existing = scoresByHash.get(r.mirrorHash) ?? 0;
|
|
569
|
-
if (score > existing) {
|
|
570
|
-
scoresByHash.set(r.mirrorHash, score);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
525
|
+
const docRows = db
|
|
526
|
+
.query<DocRow, [string, string]>(
|
|
527
|
+
`SELECT id, docid, uri, title, collection, rel_path, mirror_hash AS mirrorHash
|
|
528
|
+
FROM documents WHERE active = 1
|
|
529
|
+
AND (id IN (SELECT value FROM json_each(?))
|
|
530
|
+
OR mirror_hash IN (SELECT value FROM json_each(?)))
|
|
531
|
+
ORDER BY id`
|
|
532
|
+
)
|
|
533
|
+
.all(
|
|
534
|
+
JSON.stringify([
|
|
535
|
+
...new Set(hits.flatMap((r) => r.documentIds ?? [])),
|
|
536
|
+
]),
|
|
537
|
+
JSON.stringify([
|
|
538
|
+
...new Set(
|
|
539
|
+
hits.filter((r) => !r.documentIds).map((r) => r.mirrorHash)
|
|
540
|
+
),
|
|
541
|
+
])
|
|
542
|
+
);
|
|
573
543
|
|
|
574
|
-
// Build similar docs list
|
|
544
|
+
// Build similar docs list; hits are nearest first, so a document's
|
|
545
|
+
// first hit carries its best score
|
|
575
546
|
const similar: SimilarDocOutput[] = [];
|
|
547
|
+
const seenIds = new Set<number>();
|
|
576
548
|
const docCollection = doc.collection.toLowerCase();
|
|
577
549
|
|
|
578
|
-
for (const
|
|
550
|
+
for (const { document: docRow, distance } of similarityHitDocuments(
|
|
551
|
+
hits,
|
|
552
|
+
docRows
|
|
553
|
+
)) {
|
|
579
554
|
if (similar.length >= limit) break;
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (!docRow) continue;
|
|
555
|
+
if (docRow.id === doc.id || seenIds.has(docRow.id)) continue;
|
|
556
|
+
seenIds.add(docRow.id);
|
|
583
557
|
|
|
584
558
|
// Filter by collection if not crossCollection (case-insensitive)
|
|
585
559
|
if (
|
|
@@ -589,7 +563,9 @@ export function handleSimilar(
|
|
|
589
563
|
continue;
|
|
590
564
|
}
|
|
591
565
|
|
|
592
|
-
|
|
566
|
+
// Compute similarity score from cosine distance
|
|
567
|
+
// sqlite-vec with cosine metric returns distance where similarity = 1 - distance
|
|
568
|
+
const score = Math.max(0, Math.min(1, 1 - distance));
|
|
593
569
|
if (score < threshold) continue;
|
|
594
570
|
|
|
595
571
|
// Get absPath (case-insensitive collection lookup)
|
|
@@ -799,6 +775,7 @@ export function handleGraph(
|
|
|
799
775
|
threshold: args.threshold ?? 0.7,
|
|
800
776
|
linkedOnly: args.linkedOnly ?? true,
|
|
801
777
|
similarTopK: args.similarTopK ?? 5,
|
|
778
|
+
embedModel: getActivePreset(ctx.config).embed,
|
|
802
779
|
});
|
|
803
780
|
|
|
804
781
|
if (!result.ok) {
|
|
@@ -836,6 +813,7 @@ async function getValidatedGraph(
|
|
|
836
813
|
threshold: args.threshold ?? 0.7,
|
|
837
814
|
linkedOnly: args.linkedOnly ?? true,
|
|
838
815
|
similarTopK: args.similarTopK ?? 5,
|
|
816
|
+
embedModel: getActivePreset(ctx.config).embed,
|
|
839
817
|
});
|
|
840
818
|
|
|
841
819
|
if (!result.ok) {
|
package/src/mcp/tools/status.ts
CHANGED
|
@@ -105,6 +105,7 @@ export function handleStatus(
|
|
|
105
105
|
const result = await ctx.store.getStatus({
|
|
106
106
|
embedModel: resolveModelUri(ctx.config, "embed"),
|
|
107
107
|
chunking: ctx.config.chunking ?? {},
|
|
108
|
+
configuredCollections: ctx.config.collections.map(({ name }) => name),
|
|
108
109
|
});
|
|
109
110
|
if (!result.ok) {
|
|
110
111
|
throw new Error(result.error.message);
|
package/src/pipeline/search.ts
CHANGED
|
@@ -223,6 +223,8 @@ export async function searchBm25(
|
|
|
223
223
|
filter: options.filter,
|
|
224
224
|
memoryScopesAny: options.memoryFilter?.scopes,
|
|
225
225
|
excludeSuperseded: options.memoryFilter?.excludeSuperseded,
|
|
226
|
+
anyTerm: options.anyTerm,
|
|
227
|
+
minRelativeScore: options.minRelativeScore,
|
|
226
228
|
});
|
|
227
229
|
|
|
228
230
|
if (!ftsResult.ok) {
|
package/src/pipeline/types.ts
CHANGED
|
@@ -232,6 +232,10 @@ export interface SearchOptions extends InferenceOptions {
|
|
|
232
232
|
scopes: string[];
|
|
233
233
|
excludeSuperseded: boolean;
|
|
234
234
|
};
|
|
235
|
+
/** Internal: match any positive lexical term (OR) instead of all (AND). */
|
|
236
|
+
anyTerm?: boolean;
|
|
237
|
+
/** Internal: drop hits below this fraction of the best raw BM25 score. */
|
|
238
|
+
minRelativeScore?: number;
|
|
235
239
|
}
|
|
236
240
|
|
|
237
241
|
/** Structured query mode identifier */
|
package/src/sdk/client.ts
CHANGED
|
@@ -1509,6 +1509,7 @@ class GnoClientImpl implements GnoClient {
|
|
|
1509
1509
|
await this.store.getStatus({
|
|
1510
1510
|
embedModel: resolveModelUri(this.config, "embed"),
|
|
1511
1511
|
chunking: this.config.chunking ?? {},
|
|
1512
|
+
configuredCollections: this.config.collections.map(({ name }) => name),
|
|
1512
1513
|
})
|
|
1513
1514
|
);
|
|
1514
1515
|
return {
|
|
@@ -16,6 +16,7 @@ import remarkGfm from "remark-gfm";
|
|
|
16
16
|
import {
|
|
17
17
|
normalizeWikiName,
|
|
18
18
|
parseTargetParts,
|
|
19
|
+
splitWikiLinkContent,
|
|
19
20
|
stripWikiMdExt,
|
|
20
21
|
} from "../../../../core/links";
|
|
21
22
|
import { slugifySectionTitle } from "../../../../core/sections";
|
|
@@ -103,9 +104,10 @@ function renderMarkdownWithWikiLinks(
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
return content.replace(WIKI_LINK_REGEX, (match, rawContent: string) => {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
const
|
|
107
|
+
const { target: rawTarget, alias: rawAlias } =
|
|
108
|
+
splitWikiLinkContent(rawContent);
|
|
109
|
+
const displayText = rawAlias?.trim() || rawTarget.trim() || match;
|
|
110
|
+
const parsed = parseTargetParts(rawTarget);
|
|
109
111
|
const targetCollection = parsed.collection || collection || "";
|
|
110
112
|
const targetRefKey = normalizeWikiName(stripWikiMdExt(parsed.ref));
|
|
111
113
|
const targetAnchorKey = (parsed.anchor ?? "").trim().toLowerCase();
|
|
@@ -141,7 +141,8 @@ function parseBoolean(value: string | null, defaultValue: boolean): boolean {
|
|
|
141
141
|
*/
|
|
142
142
|
export async function handleGraph(
|
|
143
143
|
store: SqliteAdapter,
|
|
144
|
-
url: URL
|
|
144
|
+
url: URL,
|
|
145
|
+
embedModel?: string
|
|
145
146
|
): Promise<Response> {
|
|
146
147
|
// Parse query params
|
|
147
148
|
const collection = url.searchParams.get("collection") || undefined;
|
|
@@ -197,6 +198,7 @@ export async function handleGraph(
|
|
|
197
198
|
threshold: thresholdResult.value,
|
|
198
199
|
linkedOnly,
|
|
199
200
|
similarTopK: similarTopKResult.value,
|
|
201
|
+
embedModel,
|
|
200
202
|
};
|
|
201
203
|
|
|
202
204
|
const result = await store.getGraph(options);
|
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
import type { SqliteAdapter } from "../../store/sqlite/adapter";
|
|
8
8
|
import type { ServerContext } from "../context";
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
readStoredDocumentVectors,
|
|
12
|
+
resolveStoredVectorSource,
|
|
13
|
+
similarityHitDocuments,
|
|
14
|
+
storedVectorSearchOptions,
|
|
15
|
+
} from "../../store/vector/stored-vectors";
|
|
11
16
|
|
|
12
17
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
18
|
// Types
|
|
@@ -373,31 +378,27 @@ export async function handleDocSimilar(
|
|
|
373
378
|
} satisfies SimilarDocResponse);
|
|
374
379
|
}
|
|
375
380
|
|
|
376
|
-
//
|
|
377
|
-
const embedModel = ctx.vectorIndex.model;
|
|
378
|
-
|
|
379
|
-
// Get document embedding from content_vectors (prefer seq=0)
|
|
381
|
+
// Stored vector of the document's first chunk, from the active partition
|
|
380
382
|
const db = store.getRawDb();
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
383
|
+
const source = resolveStoredVectorSource(db, ctx.vectorIndex.model);
|
|
384
|
+
let embedding: Float32Array | undefined;
|
|
385
|
+
try {
|
|
386
|
+
[embedding] =
|
|
387
|
+
readStoredDocumentVectors(
|
|
388
|
+
db,
|
|
389
|
+
source,
|
|
390
|
+
[{ id: doc.id, mirrorHash: doc.mirrorHash }],
|
|
391
|
+
{ firstChunkOnly: true }
|
|
392
|
+
).get(doc.id) ?? [];
|
|
393
|
+
} catch (e) {
|
|
394
|
+
return errorResponse(
|
|
395
|
+
"RUNTIME",
|
|
396
|
+
`Invalid stored embedding data: ${e instanceof Error ? e.message : String(e)}`,
|
|
397
|
+
500
|
|
398
|
+
);
|
|
384
399
|
}
|
|
385
400
|
|
|
386
|
-
|
|
387
|
-
.query<VectorRow, [string, string]>(
|
|
388
|
-
"SELECT embedding FROM content_vectors WHERE mirror_hash = ? AND model = ? AND seq = 0 LIMIT 1"
|
|
389
|
-
)
|
|
390
|
-
.get(doc.mirrorHash, embedModel);
|
|
391
|
-
|
|
392
|
-
const fallbackRow =
|
|
393
|
-
vectorRow ??
|
|
394
|
-
db
|
|
395
|
-
.query<VectorRow, [string, string]>(
|
|
396
|
-
"SELECT embedding FROM content_vectors WHERE mirror_hash = ? AND model = ? ORDER BY seq LIMIT 1"
|
|
397
|
-
)
|
|
398
|
-
.get(doc.mirrorHash, embedModel);
|
|
399
|
-
|
|
400
|
-
if (!fallbackRow) {
|
|
401
|
+
if (!embedding) {
|
|
401
402
|
return jsonResponse({
|
|
402
403
|
similar: [],
|
|
403
404
|
meta: {
|
|
@@ -409,20 +410,7 @@ export async function handleDocSimilar(
|
|
|
409
410
|
},
|
|
410
411
|
} satisfies SimilarDocResponse);
|
|
411
412
|
}
|
|
412
|
-
|
|
413
|
-
let dimensions: number;
|
|
414
|
-
let embedding: Float32Array;
|
|
415
|
-
|
|
416
|
-
try {
|
|
417
|
-
embedding = decodeEmbedding(fallbackRow.embedding);
|
|
418
|
-
dimensions = embedding.length;
|
|
419
|
-
} catch (e) {
|
|
420
|
-
return errorResponse(
|
|
421
|
-
"RUNTIME",
|
|
422
|
-
`Invalid stored embedding data: ${e instanceof Error ? e.message : String(e)}`,
|
|
423
|
-
500
|
|
424
|
-
);
|
|
425
|
-
}
|
|
413
|
+
const dimensions = embedding.length;
|
|
426
414
|
|
|
427
415
|
// Normalize embedding for cosine similarity
|
|
428
416
|
let norm = 0;
|
|
@@ -442,7 +430,7 @@ export async function handleDocSimilar(
|
|
|
442
430
|
const searchResult = await ctx.vectorIndex.searchNearest(
|
|
443
431
|
embedding,
|
|
444
432
|
candidateLimit,
|
|
445
|
-
|
|
433
|
+
storedVectorSearchOptions(source)
|
|
446
434
|
);
|
|
447
435
|
|
|
448
436
|
if (!searchResult.ok) {
|
|
@@ -457,22 +445,16 @@ export async function handleDocSimilar(
|
|
|
457
445
|
return errorResponse("RUNTIME", docsResult.error.message, 500);
|
|
458
446
|
}
|
|
459
447
|
|
|
460
|
-
|
|
461
|
-
docsResult.value
|
|
462
|
-
.filter((d) => d.mirrorHash && d.active)
|
|
463
|
-
.map((d) => [d.mirrorHash!, d])
|
|
464
|
-
);
|
|
465
|
-
|
|
466
|
-
// Build similar docs list, excluding self
|
|
448
|
+
// Build similar docs list from each hit's owning documents, excluding self
|
|
467
449
|
const similar: SimilarDocResponse["similar"] = [];
|
|
468
450
|
const seenDocids = new Set<string>();
|
|
469
451
|
|
|
470
|
-
for (const
|
|
452
|
+
for (const { document: similarDoc, distance } of similarityHitDocuments(
|
|
453
|
+
searchResult.value,
|
|
454
|
+
docsResult.value.filter((d) => d.mirrorHash && d.active)
|
|
455
|
+
)) {
|
|
471
456
|
if (similar.length >= limit) break;
|
|
472
457
|
|
|
473
|
-
const similarDoc = docsByHash.get(vec.mirrorHash);
|
|
474
|
-
if (!similarDoc) continue;
|
|
475
|
-
|
|
476
458
|
// Exclude self
|
|
477
459
|
if (similarDoc.docid === doc.docid) continue;
|
|
478
460
|
|
|
@@ -481,7 +463,7 @@ export async function handleDocSimilar(
|
|
|
481
463
|
|
|
482
464
|
// Compute similarity score from cosine distance
|
|
483
465
|
// sqlite-vec with cosine metric returns distance where similarity = 1 - distance
|
|
484
|
-
const score = Math.max(0, Math.min(1, 1 -
|
|
466
|
+
const score = Math.max(0, Math.min(1, 1 - distance));
|
|
485
467
|
if (score < threshold) continue;
|
|
486
468
|
|
|
487
469
|
similar.push({
|
package/src/serve/server.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { RequestPeerServer } from "./request-locality";
|
|
|
3
3
|
import type { ResidentRuntime } from "./resident-runtime";
|
|
4
4
|
import type { ContextHolder } from "./routes/api";
|
|
5
5
|
|
|
6
|
+
import { getActivePreset } from "../llm/registry";
|
|
6
7
|
import {
|
|
7
8
|
isHttpGatewayLoopbackBind,
|
|
8
9
|
resolveHttpGatewayConfig,
|
|
@@ -1694,7 +1695,7 @@ export async function startServer(
|
|
|
1694
1695
|
const url = new URL(req.url);
|
|
1695
1696
|
return withSecurityHeaders(
|
|
1696
1697
|
await handleResidentRead(runtime as ResidentRuntime, req, () =>
|
|
1697
|
-
handleGraph(store, url)
|
|
1698
|
+
handleGraph(store, url, getActivePreset(ctxHolder.config).embed)
|
|
1698
1699
|
),
|
|
1699
1700
|
isDev
|
|
1700
1701
|
);
|
package/src/serve/status.ts
CHANGED
|
@@ -650,6 +650,7 @@ export async function buildAppStatus(
|
|
|
650
650
|
const result = await ctx.store.getStatus({
|
|
651
651
|
embedModel: resolveModelUri(ctx.config, "embed"),
|
|
652
652
|
chunking: ctx.config.chunking ?? {},
|
|
653
|
+
configuredCollections: ctx.config.collections.map(({ name }) => name),
|
|
653
654
|
});
|
|
654
655
|
if (!result.ok) {
|
|
655
656
|
throw result.error;
|