@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
|
@@ -167,6 +167,7 @@ import {
|
|
|
167
167
|
listVectorPartitions,
|
|
168
168
|
vectorRuntimeStatus,
|
|
169
169
|
} from "../vector/status";
|
|
170
|
+
import { loadSqliteVec } from "../vector/variants";
|
|
170
171
|
import {
|
|
171
172
|
deleteSavedCapsuleRegistration as deleteStoredSavedCapsuleRegistration,
|
|
172
173
|
getSavedCapsuleRegistration as getStoredSavedCapsuleRegistration,
|
|
@@ -225,6 +226,7 @@ import {
|
|
|
225
226
|
} from "./graph-link-resolver";
|
|
226
227
|
import { queryGraphNeighborsForSeeds } from "./graph-neighbors";
|
|
227
228
|
import { createGraphReferenceStore } from "./graph-reference-state";
|
|
229
|
+
import { hasSqliteVec, storedSimilarityEdges } from "./graph-similarity";
|
|
228
230
|
import {
|
|
229
231
|
snapshotLegacyTitles,
|
|
230
232
|
reconcileLegacyTitles,
|
|
@@ -3218,9 +3220,18 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3218
3220
|
...(options.chunkLanguage ? [options.chunkLanguage] : []),
|
|
3219
3221
|
limit,
|
|
3220
3222
|
];
|
|
3221
|
-
const
|
|
3223
|
+
const allRows = db
|
|
3222
3224
|
.query<FtsRow, (string | number)[]>(sql)
|
|
3223
3225
|
.all(...queryParams);
|
|
3226
|
+
// Raw bm25() is negative and rows are best-first.
|
|
3227
|
+
const floor =
|
|
3228
|
+
options.minRelativeScore !== undefined && allRows[0]
|
|
3229
|
+
? allRows[0].score * options.minRelativeScore
|
|
3230
|
+
: undefined;
|
|
3231
|
+
const rows =
|
|
3232
|
+
floor === undefined
|
|
3233
|
+
? allRows
|
|
3234
|
+
: allRows.filter((row) => row.score <= floor);
|
|
3224
3235
|
|
|
3225
3236
|
return ok(
|
|
3226
3237
|
rows.map((r) => ({
|
|
@@ -5452,13 +5463,11 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5452
5463
|
|
|
5453
5464
|
const warnings: string[] = [];
|
|
5454
5465
|
|
|
5455
|
-
// Always
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
similarAvailable =
|
|
5460
|
-
} catch {
|
|
5461
|
-
// sqlite-vec not loaded
|
|
5466
|
+
// Always report sqlite-vec availability (not just when similarity
|
|
5467
|
+
// requested); this connection loads it on first use.
|
|
5468
|
+
let similarAvailable = hasSqliteVec(db);
|
|
5469
|
+
if (!similarAvailable && (await loadSqliteVec(db))) {
|
|
5470
|
+
similarAvailable = hasSqliteVec(db);
|
|
5462
5471
|
}
|
|
5463
5472
|
|
|
5464
5473
|
interface ResolvedEdgeRow {
|
|
@@ -5767,110 +5776,45 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
5767
5776
|
);
|
|
5768
5777
|
}
|
|
5769
5778
|
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
)
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
}
|
|
5788
|
-
}
|
|
5789
|
-
}
|
|
5790
|
-
const allowedMirrorHashes = [...mirrorByDocid.values()];
|
|
5791
|
-
if (allowedMirrorHashes.length === 0) {
|
|
5792
|
-
warnings.push("Similarity unavailable: no embedded nodes in graph");
|
|
5793
|
-
}
|
|
5794
|
-
const allowedPlaceholders = allowedMirrorHashes
|
|
5795
|
-
.map(() => "?")
|
|
5796
|
-
.join(",");
|
|
5797
|
-
|
|
5798
|
-
// Get kNN for each node
|
|
5799
|
-
// Query content_vectors for embedded chunks, find similar
|
|
5800
|
-
for (const docid of nodesForSimilarity) {
|
|
5801
|
-
if (allowedMirrorHashes.length === 0) break;
|
|
5802
|
-
const mirrorHash = mirrorByDocid.get(docid);
|
|
5803
|
-
if (!mirrorHash) continue;
|
|
5804
|
-
|
|
5805
|
-
// Find similar docs using vec_distance, aggregate by doc to get max score
|
|
5806
|
-
interface SimilarRow {
|
|
5807
|
-
target_docid: string;
|
|
5808
|
-
score: number;
|
|
5809
|
-
}
|
|
5810
|
-
|
|
5811
|
-
// Use GROUP BY to get one best score per doc (avoids duplicate rows from multi-chunk docs)
|
|
5812
|
-
const similarQuery = `
|
|
5813
|
-
SELECT
|
|
5814
|
-
d.docid as target_docid,
|
|
5815
|
-
MAX(1 - vec_distance_cosine(v1.embedding, v2.embedding)) as score
|
|
5816
|
-
FROM content_vectors v1
|
|
5817
|
-
JOIN content_vectors v2 ON v2.model = v1.model
|
|
5818
|
-
AND v2.mirror_hash != v1.mirror_hash
|
|
5819
|
-
AND v2.seq = 0
|
|
5820
|
-
JOIN documents d ON d.mirror_hash = v2.mirror_hash AND d.active = 1
|
|
5821
|
-
WHERE v1.mirror_hash = ? AND v1.seq = 0
|
|
5822
|
-
AND d.docid != ?
|
|
5823
|
-
AND v2.mirror_hash IN (${allowedPlaceholders})
|
|
5824
|
-
GROUP BY d.docid
|
|
5825
|
-
HAVING score >= ?
|
|
5826
|
-
ORDER BY score DESC
|
|
5827
|
-
LIMIT ?
|
|
5828
|
-
`;
|
|
5829
|
-
|
|
5830
|
-
try {
|
|
5831
|
-
const similarRows = db
|
|
5832
|
-
.query<SimilarRow, (string | number)[]>(similarQuery)
|
|
5833
|
-
.all(
|
|
5834
|
-
mirrorHash,
|
|
5835
|
-
docid,
|
|
5836
|
-
...allowedMirrorHashes,
|
|
5837
|
-
threshold,
|
|
5838
|
-
similarTopK
|
|
5839
|
-
);
|
|
5840
|
-
|
|
5841
|
-
for (const sim of similarRows) {
|
|
5842
|
-
if (!nodeDocids.has(sim.target_docid)) continue;
|
|
5843
|
-
|
|
5844
|
-
// Clamp score to [0, 1] for schema compliance
|
|
5845
|
-
const clampedScore = Math.max(0, Math.min(1, sim.score));
|
|
5846
|
-
|
|
5779
|
+
const embedModel = options?.embedModel;
|
|
5780
|
+
if (embedModel) {
|
|
5781
|
+
const similarityEdges = storedSimilarityEdges(
|
|
5782
|
+
db,
|
|
5783
|
+
embedModel,
|
|
5784
|
+
nodesForSimilarity,
|
|
5785
|
+
threshold,
|
|
5786
|
+
similarTopK
|
|
5787
|
+
);
|
|
5788
|
+
if (similarityEdges === null) {
|
|
5789
|
+
warnings.push(
|
|
5790
|
+
"Similarity query failed; similarity edges are unavailable"
|
|
5791
|
+
);
|
|
5792
|
+
} else if (similarityEdges.embeddedNodes === 0) {
|
|
5793
|
+
warnings.push("Similarity unavailable: no embedded nodes in graph");
|
|
5794
|
+
} else {
|
|
5795
|
+
for (const edge of similarityEdges.edges) {
|
|
5847
5796
|
// Canonicalize by lexicographic order (undirected edge)
|
|
5848
5797
|
const [a, b] =
|
|
5849
|
-
|
|
5850
|
-
? [
|
|
5851
|
-
: [
|
|
5798
|
+
edge.source < edge.target
|
|
5799
|
+
? [edge.source, edge.target]
|
|
5800
|
+
: [edge.target, edge.source];
|
|
5852
5801
|
const key = `${a}:${b}:similar`;
|
|
5853
5802
|
|
|
5854
5803
|
// Keep max score
|
|
5855
5804
|
const existing = edgeMap.get(key);
|
|
5856
|
-
if (!existing ||
|
|
5805
|
+
if (!existing || edge.score > existing.weight) {
|
|
5857
5806
|
edgeMap.set(key, {
|
|
5858
5807
|
type: "similar",
|
|
5859
|
-
weight:
|
|
5808
|
+
weight: edge.score,
|
|
5860
5809
|
confidence: "similarity",
|
|
5861
|
-
audit: { resolution: "similarity", score:
|
|
5810
|
+
audit: { resolution: "similarity", score: edge.score },
|
|
5862
5811
|
});
|
|
5863
5812
|
}
|
|
5864
5813
|
}
|
|
5865
|
-
} catch {
|
|
5866
|
-
similarityFailures++;
|
|
5867
5814
|
}
|
|
5868
|
-
}
|
|
5869
|
-
|
|
5870
|
-
// Report partial failures
|
|
5871
|
-
if (similarityFailures > 0) {
|
|
5815
|
+
} else {
|
|
5872
5816
|
warnings.push(
|
|
5873
|
-
|
|
5817
|
+
"Similarity unavailable: no embedding model configured"
|
|
5874
5818
|
);
|
|
5875
5819
|
}
|
|
5876
5820
|
} else if (includeSimilar && !similarAvailable) {
|
|
@@ -6010,9 +5954,15 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
6010
5954
|
embedModel?: string;
|
|
6011
5955
|
embedFingerprint?: string;
|
|
6012
5956
|
chunking?: Partial<ChunkingParams>;
|
|
5957
|
+
configuredCollections?: readonly string[];
|
|
6013
5958
|
}): Promise<StoreResult<IndexStatus>> {
|
|
6014
5959
|
try {
|
|
6015
5960
|
const db = this.ensureOpen();
|
|
5961
|
+
// JSON array of configured names, or null when the caller has no config
|
|
5962
|
+
// (every indexed collection is reported).
|
|
5963
|
+
const configuredJson = options?.configuredCollections
|
|
5964
|
+
? JSON.stringify(options.configuredCollections)
|
|
5965
|
+
: null;
|
|
6016
5966
|
const embedModel = options?.embedModel ?? null;
|
|
6017
5967
|
const embedFingerprint =
|
|
6018
5968
|
options?.embedFingerprint ??
|
|
@@ -6055,7 +6005,16 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
6055
6005
|
}
|
|
6056
6006
|
|
|
6057
6007
|
const collectionStats = db
|
|
6058
|
-
.query<
|
|
6008
|
+
.query<
|
|
6009
|
+
CollectionStat,
|
|
6010
|
+
[
|
|
6011
|
+
string | null,
|
|
6012
|
+
string | null,
|
|
6013
|
+
string | null,
|
|
6014
|
+
string | null,
|
|
6015
|
+
string | null,
|
|
6016
|
+
]
|
|
6017
|
+
>(
|
|
6059
6018
|
`
|
|
6060
6019
|
WITH document_stats AS (
|
|
6061
6020
|
SELECT
|
|
@@ -6110,29 +6069,51 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
6110
6069
|
FROM collections c
|
|
6111
6070
|
LEFT JOIN document_stats ds ON ds.collection = c.name
|
|
6112
6071
|
LEFT JOIN collection_chunks ch ON ch.collection = c.name
|
|
6072
|
+
WHERE ? IS NULL OR c.name IN (SELECT value FROM json_each(?))
|
|
6113
6073
|
ORDER BY c.name
|
|
6114
6074
|
`
|
|
6115
6075
|
)
|
|
6116
|
-
.all(
|
|
6076
|
+
.all(
|
|
6077
|
+
embedModel,
|
|
6078
|
+
embedModel,
|
|
6079
|
+
embedFingerprint,
|
|
6080
|
+
configuredJson,
|
|
6081
|
+
configuredJson
|
|
6082
|
+
);
|
|
6117
6083
|
|
|
6118
|
-
//
|
|
6084
|
+
// Totals cover the configured collections only, so a collection removed
|
|
6085
|
+
// from config stops counting before the next update prunes its rows.
|
|
6119
6086
|
const totalsRow = db
|
|
6120
|
-
.query<
|
|
6087
|
+
.query<
|
|
6088
|
+
{ total: number; active: number },
|
|
6089
|
+
[string | null, string | null]
|
|
6090
|
+
>(
|
|
6121
6091
|
`
|
|
6122
6092
|
SELECT
|
|
6123
6093
|
COUNT(*) as total,
|
|
6124
6094
|
SUM(CASE WHEN active = 1 THEN 1 ELSE 0 END) as active
|
|
6125
6095
|
FROM documents
|
|
6096
|
+
WHERE ? IS NULL OR collection IN (SELECT value FROM json_each(?))
|
|
6126
6097
|
`
|
|
6127
6098
|
)
|
|
6128
|
-
.get();
|
|
6099
|
+
.get(configuredJson, configuredJson);
|
|
6129
6100
|
|
|
6101
|
+
// Chunks of active documents only (deduplicated by canonical chunk);
|
|
6102
|
+
// content_chunks keeps rows of deleted documents until cleanup.
|
|
6130
6103
|
const chunkCount =
|
|
6131
6104
|
db
|
|
6132
|
-
.query<{ count: number }, []>(
|
|
6133
|
-
|
|
6105
|
+
.query<{ count: number }, [string | null, string | null]>(
|
|
6106
|
+
`
|
|
6107
|
+
SELECT COUNT(*) as count
|
|
6108
|
+
FROM content_chunks
|
|
6109
|
+
WHERE mirror_hash IN (
|
|
6110
|
+
SELECT mirror_hash FROM documents
|
|
6111
|
+
WHERE active = 1 AND mirror_hash IS NOT NULL
|
|
6112
|
+
AND (? IS NULL OR collection IN (SELECT value FROM json_each(?)))
|
|
6113
|
+
)
|
|
6114
|
+
`
|
|
6134
6115
|
)
|
|
6135
|
-
.get()?.count ?? 0;
|
|
6116
|
+
.get(configuredJson, configuredJson)?.count ?? 0;
|
|
6136
6117
|
|
|
6137
6118
|
// Embedding backlog: chunks from active docs without vectors
|
|
6138
6119
|
// Uses EXISTS to avoid duplicates when multiple docs share mirror_hash
|
|
@@ -116,6 +116,11 @@ export interface AuditLinkSnapshotLink {
|
|
|
116
116
|
endLine: number;
|
|
117
117
|
endCol: number;
|
|
118
118
|
resolved: ResolvedGraphLinkTarget | null;
|
|
119
|
+
/**
|
|
120
|
+
* Unresolved workspace wiki link whose target exists as a file in the
|
|
121
|
+
* workspace but is not an indexed document. Existence only: no edge.
|
|
122
|
+
*/
|
|
123
|
+
outsideIndex?: boolean;
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
export interface AuditLinkSnapshot {
|
|
@@ -128,6 +133,8 @@ export interface AuditLinkSnapshot {
|
|
|
128
133
|
*/
|
|
129
134
|
scopeCollections?: string[];
|
|
130
135
|
links: AuditLinkSnapshotLink[];
|
|
136
|
+
/** Why some workspace file listings were incomplete, when they were. */
|
|
137
|
+
outsideIndexDiagnostic?: string;
|
|
131
138
|
totals: { documents: number; links: number };
|
|
132
139
|
truncated: { documents: boolean; links: boolean };
|
|
133
140
|
metrics: {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph similarity edges scored from stored document vectors.
|
|
3
|
+
*
|
|
4
|
+
* @module src/store/sqlite/graph-similarity
|
|
5
|
+
*/
|
|
6
|
+
import type { Database } from "bun:sqlite";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
readStoredDocumentVectors,
|
|
10
|
+
resolveStoredVectorSource,
|
|
11
|
+
} from "../vector/stored-vectors";
|
|
12
|
+
|
|
13
|
+
/** True when sqlite-vec is loaded on this connection. */
|
|
14
|
+
export function hasSqliteVec(db: Database): boolean {
|
|
15
|
+
try {
|
|
16
|
+
db.query("SELECT vec_version()").get();
|
|
17
|
+
return true;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface StoredSimilarityEdge {
|
|
24
|
+
source: string;
|
|
25
|
+
target: string;
|
|
26
|
+
/** Cosine similarity clamped to [0, 1] */
|
|
27
|
+
score: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function unit(vector: Float32Array): Float32Array {
|
|
31
|
+
let norm = 0;
|
|
32
|
+
for (const value of vector) norm += value * value;
|
|
33
|
+
norm = Math.sqrt(norm);
|
|
34
|
+
return norm > 0 ? vector.map((value) => value / norm) : vector;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function dot(a: Float32Array, b: Float32Array): number {
|
|
38
|
+
let sum = 0;
|
|
39
|
+
for (let i = 0; i < a.length; i++) sum += (a[i] ?? 0) * (b[i] ?? 0);
|
|
40
|
+
return sum;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Top-K most similar other nodes per node, by the first-chunk vector each
|
|
45
|
+
* document stores for `model`. Documents sharing content are not similarity
|
|
46
|
+
* edges. Null when the stored vectors cannot be read.
|
|
47
|
+
*/
|
|
48
|
+
export function storedSimilarityEdges(
|
|
49
|
+
db: Database,
|
|
50
|
+
model: string,
|
|
51
|
+
docids: string[],
|
|
52
|
+
threshold: number,
|
|
53
|
+
topK: number
|
|
54
|
+
): { embeddedNodes: number; edges: StoredSimilarityEdge[] } | null {
|
|
55
|
+
try {
|
|
56
|
+
const documents = db
|
|
57
|
+
.query<{ id: number; docid: string; mirrorHash: string }, [string]>(`
|
|
58
|
+
SELECT id, docid, mirror_hash AS mirrorHash FROM documents
|
|
59
|
+
WHERE active = 1 AND mirror_hash IS NOT NULL
|
|
60
|
+
AND docid IN (SELECT value FROM json_each(?))
|
|
61
|
+
ORDER BY docid
|
|
62
|
+
`)
|
|
63
|
+
.all(JSON.stringify(docids));
|
|
64
|
+
const vectors = readStoredDocumentVectors(
|
|
65
|
+
db,
|
|
66
|
+
resolveStoredVectorSource(db, model),
|
|
67
|
+
documents,
|
|
68
|
+
{ firstChunkOnly: true }
|
|
69
|
+
);
|
|
70
|
+
const nodes = documents.flatMap((document) => {
|
|
71
|
+
const vector = vectors.get(document.id)?.[0];
|
|
72
|
+
return vector ? [{ ...document, vector: unit(vector) }] : [];
|
|
73
|
+
});
|
|
74
|
+
const edges: StoredSimilarityEdge[] = [];
|
|
75
|
+
for (const node of nodes) {
|
|
76
|
+
const scored: StoredSimilarityEdge[] = [];
|
|
77
|
+
for (const other of nodes) {
|
|
78
|
+
if (
|
|
79
|
+
other.mirrorHash === node.mirrorHash ||
|
|
80
|
+
other.vector.length !== node.vector.length
|
|
81
|
+
)
|
|
82
|
+
continue;
|
|
83
|
+
const score = Math.max(0, Math.min(1, dot(node.vector, other.vector)));
|
|
84
|
+
if (score >= threshold)
|
|
85
|
+
scored.push({ source: node.docid, target: other.docid, score });
|
|
86
|
+
}
|
|
87
|
+
scored.sort(
|
|
88
|
+
(a, b) => b.score - a.score || a.target.localeCompare(b.target)
|
|
89
|
+
);
|
|
90
|
+
edges.push(...scored.slice(0, topK));
|
|
91
|
+
}
|
|
92
|
+
return { embeddedNodes: nodes.length, edges };
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -293,6 +293,73 @@ class WorkspaceCatalog {
|
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/** A normalized wiki target, ready to be matched against workspace paths. */
|
|
297
|
+
interface WorkspaceTargetShape {
|
|
298
|
+
normalized: string;
|
|
299
|
+
base: string;
|
|
300
|
+
exact: Set<string>;
|
|
301
|
+
relative: boolean;
|
|
302
|
+
hasPath: boolean;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const workspaceTargetShape = (
|
|
306
|
+
targetRefNorm: string,
|
|
307
|
+
sourceWsFolderNorm: string
|
|
308
|
+
): WorkspaceTargetShape | null => {
|
|
309
|
+
const normalized = normalizeWorkspaceTarget(
|
|
310
|
+
targetRefNorm,
|
|
311
|
+
sourceWsFolderNorm
|
|
312
|
+
);
|
|
313
|
+
if (normalized === null || normalized.length === 0) return null;
|
|
314
|
+
const base = stripWikiMdExt(normalized);
|
|
315
|
+
// A relative target stays a path even when it normalizes to a root name.
|
|
316
|
+
const relative = RELATIVE_TARGET.test(targetRefNorm.trim());
|
|
317
|
+
return {
|
|
318
|
+
normalized,
|
|
319
|
+
base,
|
|
320
|
+
exact: new Set([base, `${base}.md`]),
|
|
321
|
+
relative,
|
|
322
|
+
hasPath: relative || normalized.includes("/"),
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Match class of one same-basename file for a target, or null when it does
|
|
328
|
+
* not match: 0 exact workspace path, 1 exact path relative to the source's
|
|
329
|
+
* collection, 2 same folder as the source, 3 any other name or path-suffix
|
|
330
|
+
* match. Exact path classes apply to path targets only; a plain name ranks
|
|
331
|
+
* by folder and depth like any other same-named file. A relative target is
|
|
332
|
+
* already a full workspace path: only the exact workspace path matches.
|
|
333
|
+
*/
|
|
334
|
+
const workspaceMatchClass = (
|
|
335
|
+
shape: WorkspaceTargetShape,
|
|
336
|
+
file: {
|
|
337
|
+
wsNorm: string;
|
|
338
|
+
wsFolderNorm: string;
|
|
339
|
+
/** Path relative to the source's collection root, when it is in it. */
|
|
340
|
+
sourceCollectionRelNorm: string | null;
|
|
341
|
+
sourceWsFolderNorm: string;
|
|
342
|
+
}
|
|
343
|
+
): number | null => {
|
|
344
|
+
if (shape.hasPath && shape.exact.has(file.wsNorm)) return 0;
|
|
345
|
+
if (shape.relative) return null;
|
|
346
|
+
if (
|
|
347
|
+
shape.hasPath &&
|
|
348
|
+
file.sourceCollectionRelNorm !== null &&
|
|
349
|
+
shape.exact.has(file.sourceCollectionRelNorm)
|
|
350
|
+
) {
|
|
351
|
+
return 1;
|
|
352
|
+
}
|
|
353
|
+
if (
|
|
354
|
+
!shape.hasPath ||
|
|
355
|
+
file.wsNorm.endsWith(`/${shape.base}`) ||
|
|
356
|
+
file.wsNorm.endsWith(`/${shape.base}.md`)
|
|
357
|
+
) {
|
|
358
|
+
return file.wsFolderNorm === file.sourceWsFolderNorm ? 2 : 3;
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
};
|
|
362
|
+
|
|
296
363
|
interface RankedCandidate {
|
|
297
364
|
doc: CatalogDoc;
|
|
298
365
|
klass: number;
|
|
@@ -345,37 +412,17 @@ const rankTarget = (
|
|
|
345
412
|
source: { collection: string; wsFolderNorm: string },
|
|
346
413
|
targetRefNorm: string
|
|
347
414
|
): WorkspaceTargetResolution | null => {
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
source.wsFolderNorm
|
|
351
|
-
);
|
|
352
|
-
if (normalized === null || normalized.length === 0) return null;
|
|
353
|
-
const base = stripWikiMdExt(normalized);
|
|
354
|
-
const exact = new Set([base, `${base}.md`]);
|
|
355
|
-
// A relative target stays a path even when it normalizes to a root name.
|
|
356
|
-
const relative = RELATIVE_TARGET.test(targetRefNorm.trim());
|
|
357
|
-
const hasPath = relative || normalized.includes("/");
|
|
415
|
+
const shape = workspaceTargetShape(targetRefNorm, source.wsFolderNorm);
|
|
416
|
+
if (shape === null) return null;
|
|
358
417
|
const matches: RankedCandidate[] = [];
|
|
359
|
-
for (const doc of catalog.byBasename(wsKey, basenameKeys(normalized))) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
hasPath &&
|
|
368
|
-
doc.collection === source.collection &&
|
|
369
|
-
exact.has(doc.relNorm)
|
|
370
|
-
) {
|
|
371
|
-
klass = 1;
|
|
372
|
-
} else if (
|
|
373
|
-
!hasPath ||
|
|
374
|
-
doc.wsNorm.endsWith(`/${base}`) ||
|
|
375
|
-
doc.wsNorm.endsWith(`/${base}.md`)
|
|
376
|
-
) {
|
|
377
|
-
klass = doc.wsFolderNorm === source.wsFolderNorm ? 2 : 3;
|
|
378
|
-
}
|
|
418
|
+
for (const doc of catalog.byBasename(wsKey, basenameKeys(shape.normalized))) {
|
|
419
|
+
const klass = workspaceMatchClass(shape, {
|
|
420
|
+
wsNorm: doc.wsNorm,
|
|
421
|
+
wsFolderNorm: doc.wsFolderNorm,
|
|
422
|
+
sourceCollectionRelNorm:
|
|
423
|
+
doc.collection === source.collection ? doc.relNorm : null,
|
|
424
|
+
sourceWsFolderNorm: source.wsFolderNorm,
|
|
425
|
+
});
|
|
379
426
|
if (klass !== null) matches.push({ doc, klass });
|
|
380
427
|
}
|
|
381
428
|
const distinct = distinctSources(matches, source.collection);
|
|
@@ -411,7 +458,7 @@ const rankTarget = (
|
|
|
411
458
|
}
|
|
412
459
|
// Fallback: frontmatter title inside the source collection only; relative
|
|
413
460
|
// targets are paths and never fall back to titles.
|
|
414
|
-
if (relative) return null;
|
|
461
|
+
if (shape.relative) return null;
|
|
415
462
|
for (const [rank, keys] of titleLookups(targetRefNorm)) {
|
|
416
463
|
const docs = catalog
|
|
417
464
|
.byTitleIn(source.collection, wsKey, keys)
|
|
@@ -513,6 +560,47 @@ export const createInMemoryWorkspaceResolver = (
|
|
|
513
560
|
};
|
|
514
561
|
};
|
|
515
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Existence matcher over workspace files that are not indexed documents
|
|
565
|
+
* (attachments, notes in unindexed or excluded folders). A target matches a
|
|
566
|
+
* file under the same rules as an indexed note: basename with `.md` optional
|
|
567
|
+
* (so a non-Markdown target needs its extension), exact or suffix path for
|
|
568
|
+
* path targets, exact workspace path for `./` and `../` targets. The path
|
|
569
|
+
* relative to the source's collection needs no separate check here: that
|
|
570
|
+
* file's workspace path equals the target or ends with `/target`. File names
|
|
571
|
+
* fold case fully, as Obsidian matches link targets case-insensitively.
|
|
572
|
+
*/
|
|
573
|
+
export const createWorkspaceFileMatcher = (
|
|
574
|
+
workspacePaths: Iterable<string>
|
|
575
|
+
): ((targetRefNorm: string, sourceWsPath: string) => boolean) => {
|
|
576
|
+
const byBase = new Map<string, Array<{ wsNorm: string; folder: string }>>();
|
|
577
|
+
for (const path of workspacePaths) {
|
|
578
|
+
const wsNorm = path.normalize("NFC").toLowerCase();
|
|
579
|
+
const base = lastSegment(wsNorm);
|
|
580
|
+
const files = byBase.get(base) ?? [];
|
|
581
|
+
files.push({ wsNorm, folder: folderOf(wsNorm) });
|
|
582
|
+
byBase.set(base, files);
|
|
583
|
+
}
|
|
584
|
+
return (targetRefNorm, sourceWsPath) => {
|
|
585
|
+
const sourceWsFolderNorm = folderOf(
|
|
586
|
+
sourceWsPath.normalize("NFC").toLowerCase()
|
|
587
|
+
);
|
|
588
|
+
const shape = workspaceTargetShape(targetRefNorm, sourceWsFolderNorm);
|
|
589
|
+
if (shape === null) return false;
|
|
590
|
+
return basenameKeys(shape.normalized).some((key) =>
|
|
591
|
+
(byBase.get(key) ?? []).some(
|
|
592
|
+
(file) =>
|
|
593
|
+
workspaceMatchClass(shape, {
|
|
594
|
+
wsNorm: file.wsNorm,
|
|
595
|
+
wsFolderNorm: file.folder,
|
|
596
|
+
sourceCollectionRelNorm: null,
|
|
597
|
+
sourceWsFolderNorm,
|
|
598
|
+
}) !== null
|
|
599
|
+
)
|
|
600
|
+
);
|
|
601
|
+
};
|
|
602
|
+
};
|
|
603
|
+
|
|
516
604
|
export interface WorkspaceTargetInput {
|
|
517
605
|
targetRefNorm: string;
|
|
518
606
|
source: WorkspaceLinkSource;
|
package/src/store/types.ts
CHANGED
|
@@ -742,6 +742,11 @@ export interface FtsSearchOptions extends DocumentEligibilityOptions {
|
|
|
742
742
|
snippet?: boolean;
|
|
743
743
|
/** Match documents containing ANY positive term instead of ALL of them. */
|
|
744
744
|
anyTerm?: boolean;
|
|
745
|
+
/**
|
|
746
|
+
* Drop rows whose raw BM25 score is below this fraction (0-1) of the best
|
|
747
|
+
* row's score, so matches carried only by near-zero-IDF terms fall away.
|
|
748
|
+
*/
|
|
749
|
+
minRelativeScore?: number;
|
|
745
750
|
}
|
|
746
751
|
|
|
747
752
|
/** Managed-memory eligibility query (unbounded, executed in one SQL query). */
|
|
@@ -833,11 +838,11 @@ export interface IndexStatus {
|
|
|
833
838
|
ftsTokenizer: FtsTokenizer;
|
|
834
839
|
/** Per-collection status */
|
|
835
840
|
collections: CollectionStatus[];
|
|
836
|
-
/** Total documents across
|
|
841
|
+
/** Total documents across the reported collections */
|
|
837
842
|
totalDocuments: number;
|
|
838
843
|
/** Active (non-deleted) documents */
|
|
839
844
|
activeDocuments: number;
|
|
840
|
-
/**
|
|
845
|
+
/** Distinct chunks of active documents across the reported collections */
|
|
841
846
|
totalChunks: number;
|
|
842
847
|
/** Chunks without embeddings */
|
|
843
848
|
embeddingBacklog: number;
|
|
@@ -1077,6 +1082,8 @@ export interface GetGraphOptions {
|
|
|
1077
1082
|
linkedOnly?: boolean;
|
|
1078
1083
|
/** Top-K similar docs per node (default 5, clamped 1-20) */
|
|
1079
1084
|
similarTopK?: number;
|
|
1085
|
+
/** Embedding model whose stored vectors score similarity edges */
|
|
1086
|
+
embedModel?: string;
|
|
1080
1087
|
}
|
|
1081
1088
|
|
|
1082
1089
|
/** Options for seed-scoped one-hop graph neighbor lookup (query-time expansion). */
|
|
@@ -2386,6 +2393,12 @@ export interface StorePort {
|
|
|
2386
2393
|
embedModel?: string;
|
|
2387
2394
|
embedFingerprint?: string;
|
|
2388
2395
|
chunking?: Partial<ChunkingParams>;
|
|
2396
|
+
/**
|
|
2397
|
+
* Configured collection names. When given, the collection list and the
|
|
2398
|
+
* document/chunk totals cover only these collections, so a collection
|
|
2399
|
+
* removed from config stops being reported before its rows are pruned.
|
|
2400
|
+
*/
|
|
2401
|
+
configuredCollections?: readonly string[];
|
|
2389
2402
|
}): Promise<StoreResult<IndexStatus>>;
|
|
2390
2403
|
|
|
2391
2404
|
// ─────────────────────────────────────────────────────────────────────────
|
|
@@ -148,6 +148,33 @@ function retrievalPartition(
|
|
|
148
148
|
: candidates.find((p) => p.partition_id === selection);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The activated partition that stored-vector readers (document similarity)
|
|
153
|
+
* use for `model`, chosen as status counts it. Undefined leaves legacy
|
|
154
|
+
* `content_vectors` authority, exactly as vector search does before any
|
|
155
|
+
* partition activates.
|
|
156
|
+
*/
|
|
157
|
+
export function storedVectorPartition(
|
|
158
|
+
db: Database,
|
|
159
|
+
model: string
|
|
160
|
+
): { partitionId: string; dimensions: number } | undefined {
|
|
161
|
+
return db.transaction(() => {
|
|
162
|
+
if (!hasPartitionTable(db)) return undefined;
|
|
163
|
+
const partition = countedPartition(
|
|
164
|
+
db,
|
|
165
|
+
model,
|
|
166
|
+
readPartitions(db, model),
|
|
167
|
+
readSelections(db).get(model)
|
|
168
|
+
);
|
|
169
|
+
return partition && activated(partition)
|
|
170
|
+
? {
|
|
171
|
+
partitionId: partition.partition_id,
|
|
172
|
+
dimensions: partition.dimensions,
|
|
173
|
+
}
|
|
174
|
+
: undefined;
|
|
175
|
+
})();
|
|
176
|
+
}
|
|
177
|
+
|
|
151
178
|
function readPartitions(db: Database, model: string | null): Partition[] {
|
|
152
179
|
return db
|
|
153
180
|
.query<Partition, [string | null, string | null]>(`
|