seekstone 0.14.0 → 0.15.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 +7 -4
- package/dist/index.js +950 -27
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,129 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res, err) => function __init() {
|
|
5
|
+
if (err) throw err[0];
|
|
6
|
+
try {
|
|
7
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
throw err = [e], e;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/semantic/config.ts
|
|
18
|
+
import { join as join7 } from "path";
|
|
19
|
+
function defaultCacheDir(env, homedir2) {
|
|
20
|
+
return env.SEEKSTONE_CACHE_DIR || join7(homedir2, ".cache", "seekstone");
|
|
21
|
+
}
|
|
22
|
+
function resolveSemanticConfig(env, homedir2) {
|
|
23
|
+
if (env.SEEKSTONE_SEMANTIC !== "1") return void 0;
|
|
24
|
+
const cacheDir = defaultCacheDir(env, homedir2);
|
|
25
|
+
const modelDir = env.SEEKSTONE_MODEL_PATH || join7(cacheDir, "models", DEFAULT_MODEL_ID);
|
|
26
|
+
return { modelDir, cacheDir };
|
|
27
|
+
}
|
|
28
|
+
var DEFAULT_MODEL_ID;
|
|
29
|
+
var init_config = __esm({
|
|
30
|
+
"src/semantic/config.ts"() {
|
|
31
|
+
"use strict";
|
|
32
|
+
DEFAULT_MODEL_ID = "potion-base-8M";
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// src/semantic/model-manifest.ts
|
|
37
|
+
var DEFAULT_MODEL;
|
|
38
|
+
var init_model_manifest = __esm({
|
|
39
|
+
"src/semantic/model-manifest.ts"() {
|
|
40
|
+
"use strict";
|
|
41
|
+
DEFAULT_MODEL = {
|
|
42
|
+
id: "potion-base-8M",
|
|
43
|
+
license: "MIT (https://huggingface.co/minishlab/potion-base-8M)",
|
|
44
|
+
files: [
|
|
45
|
+
{
|
|
46
|
+
name: "model.safetensors",
|
|
47
|
+
url: "https://huggingface.co/minishlab/potion-base-8M/resolve/main/model.safetensors",
|
|
48
|
+
sha256: "f65d0f325faadc1e121c319e2faa41170d3fa07d8c89abd48ca5358d9a223de2",
|
|
49
|
+
bytes: 30236760
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "tokenizer.json",
|
|
53
|
+
url: "https://huggingface.co/minishlab/potion-base-8M/resolve/main/tokenizer.json",
|
|
54
|
+
sha256: "e67e803f624fb4d67dea1c730d06e1067e1b14d830e2c2202569e3ef0f70bb50",
|
|
55
|
+
bytes: 683666
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "config.json",
|
|
59
|
+
url: "https://huggingface.co/minishlab/potion-base-8M/resolve/main/config.json",
|
|
60
|
+
sha256: "2a6ac0e9aaa356a68a5688070db78fc3a464fefe85d2f06a1905ce3718687553",
|
|
61
|
+
bytes: 202
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// src/semantic/fetch-model.ts
|
|
69
|
+
var fetch_model_exports = {};
|
|
70
|
+
__export(fetch_model_exports, {
|
|
71
|
+
runFetchModel: () => runFetchModel
|
|
72
|
+
});
|
|
73
|
+
import { createHash as createHash3 } from "crypto";
|
|
74
|
+
import { existsSync } from "fs";
|
|
75
|
+
import { mkdir as mkdir7, readFile as readFile17, writeFile as writeFile4 } from "fs/promises";
|
|
76
|
+
import { join as join11 } from "path";
|
|
77
|
+
async function runFetchModel(deps) {
|
|
78
|
+
const manifest = deps.manifest ?? DEFAULT_MODEL;
|
|
79
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
80
|
+
const destDir = deps.env.SEEKSTONE_MODEL_PATH || join11(defaultCacheDir(deps.env, deps.homedir), "models", manifest.id);
|
|
81
|
+
const output = [];
|
|
82
|
+
output.push(`fetch-model: ${manifest.id} \u2192 ${destDir}`);
|
|
83
|
+
try {
|
|
84
|
+
await mkdir7(destDir, { recursive: true });
|
|
85
|
+
let fetched = 0;
|
|
86
|
+
let skipped = 0;
|
|
87
|
+
for (const file of manifest.files) {
|
|
88
|
+
const dest = join11(destDir, file.name);
|
|
89
|
+
if (existsSync(dest) && sha256(await readFile17(dest)) === file.sha256) {
|
|
90
|
+
skipped++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
output.push(`fetching ${file.name} (${file.bytes} bytes)\u2026`);
|
|
94
|
+
const res = await fetchFn(file.url);
|
|
95
|
+
if (!res.ok) throw new Error(`fetch ${file.url} failed: ${res.status}`);
|
|
96
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
97
|
+
const got = sha256(buf);
|
|
98
|
+
if (got !== file.sha256) {
|
|
99
|
+
throw new Error(`checksum mismatch for ${file.name}: expected ${file.sha256}, got ${got}`);
|
|
100
|
+
}
|
|
101
|
+
await writeFile4(dest, buf);
|
|
102
|
+
fetched++;
|
|
103
|
+
}
|
|
104
|
+
output.push(`\u2713 ${manifest.id}: ${fetched} fetched, ${skipped} already present (verified).`);
|
|
105
|
+
output.push(
|
|
106
|
+
"Enable semantic search with SEEKSTONE_SEMANTIC=1 in your MCP server config, then restart the session."
|
|
107
|
+
);
|
|
108
|
+
return { output, exitCode: 0 };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
output.push(`\u2717 ${err instanceof Error ? err.message : String(err)}`);
|
|
111
|
+
return { output, exitCode: 1 };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function sha256(buf) {
|
|
115
|
+
return createHash3("sha256").update(buf).digest("hex");
|
|
116
|
+
}
|
|
117
|
+
var init_fetch_model = __esm({
|
|
118
|
+
"src/semantic/fetch-model.ts"() {
|
|
119
|
+
"use strict";
|
|
120
|
+
init_config();
|
|
121
|
+
init_model_manifest();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
2
124
|
|
|
3
125
|
// src/index.ts
|
|
126
|
+
import { homedir } from "os";
|
|
4
127
|
import { resolve as resolve2 } from "path";
|
|
5
128
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
6
129
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -17,6 +140,7 @@ function parseCliIntent(argv) {
|
|
|
17
140
|
}
|
|
18
141
|
return "init";
|
|
19
142
|
}
|
|
143
|
+
if (argv[0] === "fetch-model") return "fetch-model";
|
|
20
144
|
for (const arg of argv) {
|
|
21
145
|
if (arg === "--version" || arg === "-v") return "version";
|
|
22
146
|
if (arg === "--help" || arg === "-h") return "help";
|
|
@@ -33,10 +157,11 @@ Seekstone runs as a Model Context Protocol stdio server. It is normally
|
|
|
33
157
|
launched by an MCP client (Claude Desktop, Claude Code, Cursor, \u2026), not run by hand.
|
|
34
158
|
|
|
35
159
|
Usage:
|
|
36
|
-
seekstone
|
|
37
|
-
seekstone init
|
|
38
|
-
seekstone
|
|
39
|
-
seekstone --
|
|
160
|
+
seekstone Start the MCP server (reads from stdin/stdout)
|
|
161
|
+
seekstone init Validate a vault and print/patch the client MCP config
|
|
162
|
+
seekstone fetch-model Download the local embedding model for semantic search
|
|
163
|
+
seekstone --version Print the version and exit
|
|
164
|
+
seekstone --help Print this help and exit
|
|
40
165
|
|
|
41
166
|
"seekstone init" options:
|
|
42
167
|
${initOptionLines}
|
|
@@ -48,6 +173,9 @@ Optional environment:
|
|
|
48
173
|
SEEKSTONE_LOG_LEVEL error | warn | info (default) | debug
|
|
49
174
|
SEEKSTONE_LOG_FILE Absolute path; append JSON-line logs here
|
|
50
175
|
SEEKSTONE_WATCH_POLL Set to 1 to poll for changes (network drives, WSL)
|
|
176
|
+
SEEKSTONE_SEMANTIC Set to 1 to enable semantic search (needs fetch-model first)
|
|
177
|
+
SEEKSTONE_MODEL_PATH Override the embedding-model directory
|
|
178
|
+
SEEKSTONE_CACHE_DIR Override the cache root (default ~/.cache/seekstone)
|
|
51
179
|
|
|
52
180
|
Add to Claude Code:
|
|
53
181
|
claude mcp add seekstone --env SEEKSTONE_VAULT=/path/to/vault -- npx -y seekstone
|
|
@@ -370,8 +498,39 @@ function resolveLink(target, notes2) {
|
|
|
370
498
|
|
|
371
499
|
// src/tools/search.ts
|
|
372
500
|
import { z as z2 } from "zod";
|
|
501
|
+
|
|
502
|
+
// src/semantic/excerpt.ts
|
|
503
|
+
function chunkExcerpt(body, span, terms, maxLen) {
|
|
504
|
+
const normalized = body.includes("\r") ? body.replace(/\r\n/g, "\n") : body;
|
|
505
|
+
const start = Math.min(span.start, normalized.length);
|
|
506
|
+
const end = Math.min(Math.max(span.end, start), normalized.length);
|
|
507
|
+
const text = start === end ? normalized : normalized.slice(start, end);
|
|
508
|
+
return extractExcerpt(text, terms, maxLen);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// src/semantic/route.ts
|
|
512
|
+
var MAX_ROUTE_WORDS = 6;
|
|
513
|
+
function queryWords(query) {
|
|
514
|
+
return query.toLowerCase().split(/\W+/).filter(Boolean);
|
|
515
|
+
}
|
|
516
|
+
function routeToLexical(query, topPaths, topN = 3) {
|
|
517
|
+
const words = new Set(queryWords(query));
|
|
518
|
+
if (words.size === 0) return false;
|
|
519
|
+
return topPaths.slice(0, topN).some((path2) => {
|
|
520
|
+
const base = (path2.split(/[\\/]/).pop() ?? "").replace(/\.md$/, "").toLowerCase();
|
|
521
|
+
const parts = new Set(base.split(/\W+/).filter(Boolean));
|
|
522
|
+
return parts.size === words.size && [...parts].every((p) => words.has(p));
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/tools/search.ts
|
|
373
527
|
var SearchInput = z2.object({
|
|
374
528
|
query: z2.string().min(1).describe("Search query. Supports fuzzy matching and prefix search."),
|
|
529
|
+
// Optional (not .default()) so the inferred type keeps existing direct
|
|
530
|
+
// callers compiling; search() itself defaults it to 'lexical'.
|
|
531
|
+
mode: z2.enum(["lexical", "semantic", "hybrid"]).optional().describe(
|
|
532
|
+
"lexical = keyword search (default). semantic = meaning-based search over local embeddings (requires SEEKSTONE_SEMANTIC=1 and a fetched model). hybrid = exact-title lookups go lexical, everything else semantic."
|
|
533
|
+
),
|
|
375
534
|
limit: z2.number().int().min(1).max(50).default(10).describe("Maximum number of results to return."),
|
|
376
535
|
folder: z2.string().optional().describe("Restrict results to notes under this vault-relative folder prefix."),
|
|
377
536
|
tag: z2.string().optional().describe("Restrict results to notes containing this tag."),
|
|
@@ -379,44 +538,113 @@ var SearchInput = z2.object({
|
|
|
379
538
|
"Max characters of match context per hit (default 120). Lower trims payload; higher gives more context."
|
|
380
539
|
)
|
|
381
540
|
});
|
|
541
|
+
var SEMANTIC_CANDIDATES = 50;
|
|
382
542
|
function basenameNoExt(path2) {
|
|
383
543
|
const base = path2.slice(path2.lastIndexOf("/") + 1);
|
|
384
544
|
const dot = base.lastIndexOf(".");
|
|
385
545
|
return dot > 0 ? base.slice(0, dot) : base;
|
|
386
546
|
}
|
|
387
547
|
function search(ctx2, input) {
|
|
548
|
+
const mode = input.mode ?? "lexical";
|
|
549
|
+
if (mode === "lexical") return lexicalSearch(ctx2, input);
|
|
550
|
+
const semantic = requireSemantic(ctx2);
|
|
551
|
+
if (mode === "semantic") return semanticSearch(ctx2, semantic, input);
|
|
552
|
+
const words = queryWords(input.query);
|
|
553
|
+
if (words.length > 0 && words.length <= MAX_ROUTE_WORDS) {
|
|
554
|
+
const lexHits = lexicalSearch(ctx2, input);
|
|
555
|
+
if (routeToLexical(
|
|
556
|
+
input.query,
|
|
557
|
+
lexHits.map((h) => h.path)
|
|
558
|
+
))
|
|
559
|
+
return lexHits;
|
|
560
|
+
}
|
|
561
|
+
return semanticSearch(ctx2, semantic, input);
|
|
562
|
+
}
|
|
563
|
+
function requireSemantic(ctx2) {
|
|
564
|
+
if (!ctx2.semantic) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
JSON.stringify({
|
|
567
|
+
error: "semantic_unavailable",
|
|
568
|
+
hint: 'Semantic search is not enabled. Set SEEKSTONE_SEMANTIC=1 in the server env and run `npx -y seekstone fetch-model` once to download the local embedding model, then restart the session. Meanwhile, mode: "lexical" works.'
|
|
569
|
+
})
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const progress = ctx2.semantic.progress;
|
|
573
|
+
if (progress.state === "building") {
|
|
574
|
+
throw new Error(
|
|
575
|
+
JSON.stringify({
|
|
576
|
+
error: "semantic_building",
|
|
577
|
+
done: progress.done,
|
|
578
|
+
total: progress.total,
|
|
579
|
+
hint: 'The semantic index is still embedding this vault (first run only \u2014 later runs load a cache). Retry shortly, or use mode: "lexical".'
|
|
580
|
+
})
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
return ctx2.semantic;
|
|
584
|
+
}
|
|
585
|
+
function searchTerms(query) {
|
|
586
|
+
return query.toLowerCase().split(/\s+/).filter((t) => t.length > 1);
|
|
587
|
+
}
|
|
588
|
+
function lexicalSearch(ctx2, input) {
|
|
388
589
|
const results = ctx2.index.search(input.query, {
|
|
389
590
|
boost: { title: 3, tags: 2, body: 1 },
|
|
390
591
|
fuzzy: 0.2,
|
|
391
592
|
prefix: true
|
|
392
593
|
});
|
|
393
|
-
const terms = input.query
|
|
594
|
+
const terms = searchTerms(input.query);
|
|
394
595
|
const hits = [];
|
|
395
596
|
for (const r of results) {
|
|
396
|
-
|
|
397
|
-
if (
|
|
398
|
-
const note2 = ctx2.notes.get(r.id);
|
|
399
|
-
if (!note2) continue;
|
|
400
|
-
const noteTags2 = note2.tags.split(" ");
|
|
401
|
-
if (!noteTags2.some((t) => t === input.tag || t === input.tag?.replace(/^#/, ""))) continue;
|
|
402
|
-
}
|
|
403
|
-
const note = ctx2.notes.get(r.id);
|
|
404
|
-
const body = note?.body ?? "";
|
|
405
|
-
const noteTags = note?.tags ? note.tags.split(" ").filter(Boolean) : [];
|
|
406
|
-
const title = r.title;
|
|
597
|
+
const note = filterNote(ctx2, input, r.id);
|
|
598
|
+
if (!note) continue;
|
|
407
599
|
const hit = {
|
|
408
600
|
path: r.id,
|
|
409
601
|
// Round the raw MiniSearch score: 2 decimals preserve relevance gaps without 17-digit float tax.
|
|
410
602
|
score: Math.round(r.score * 100) / 100,
|
|
411
|
-
excerpt: extractExcerpt(body, terms, input.excerptLength ?? 120)
|
|
603
|
+
excerpt: extractExcerpt(note.body, terms, input.excerptLength ?? 120)
|
|
412
604
|
};
|
|
413
|
-
|
|
414
|
-
if (noteTags.length > 0) hit.tags = noteTags;
|
|
605
|
+
decorate(hit, r.title, note.tags);
|
|
415
606
|
hits.push(hit);
|
|
416
607
|
if (hits.length >= input.limit) break;
|
|
417
608
|
}
|
|
418
609
|
return hits;
|
|
419
610
|
}
|
|
611
|
+
function semanticSearch(ctx2, semantic, input) {
|
|
612
|
+
const queryVec = semantic.embedQuery(input.query);
|
|
613
|
+
const candidates = semantic.store.topNotes(queryVec, SEMANTIC_CANDIDATES);
|
|
614
|
+
const terms = searchTerms(input.query);
|
|
615
|
+
const hits = [];
|
|
616
|
+
for (const c of candidates) {
|
|
617
|
+
const note = filterNote(ctx2, input, c.path);
|
|
618
|
+
if (!note) continue;
|
|
619
|
+
const hit = {
|
|
620
|
+
path: c.path,
|
|
621
|
+
// Cosine similarity ∈ [-1, 1]: 3 decimals keep the ranking legible.
|
|
622
|
+
score: Math.round(c.score * 1e3) / 1e3,
|
|
623
|
+
excerpt: chunkExcerpt(note.body, c, terms, input.excerptLength ?? 120)
|
|
624
|
+
};
|
|
625
|
+
decorate(hit, note.title, note.tags);
|
|
626
|
+
hits.push(hit);
|
|
627
|
+
if (hits.length >= input.limit) break;
|
|
628
|
+
}
|
|
629
|
+
return hits;
|
|
630
|
+
}
|
|
631
|
+
function filterNote(ctx2, input, path2) {
|
|
632
|
+
if (input.folder && !path2.startsWith(input.folder)) return void 0;
|
|
633
|
+
const note = ctx2.notes.get(path2);
|
|
634
|
+
if (!note) return void 0;
|
|
635
|
+
if (input.tag) {
|
|
636
|
+
const noteTags = note.tags.split(" ");
|
|
637
|
+
if (!noteTags.some((t) => t === input.tag || t === input.tag?.replace(/^#/, ""))) {
|
|
638
|
+
return void 0;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return note;
|
|
642
|
+
}
|
|
643
|
+
function decorate(hit, title, tags) {
|
|
644
|
+
if (title && title !== basenameNoExt(hit.path)) hit.title = title;
|
|
645
|
+
const noteTags = tags ? tags.split(" ").filter(Boolean) : [];
|
|
646
|
+
if (noteTags.length > 0) hit.tags = noteTags;
|
|
647
|
+
}
|
|
420
648
|
|
|
421
649
|
// src/tools/context_pack.ts
|
|
422
650
|
var ContextPackInput = z3.object({
|
|
@@ -2028,6 +2256,7 @@ var META_KEYS = [
|
|
|
2028
2256
|
"to",
|
|
2029
2257
|
"folder",
|
|
2030
2258
|
"tag",
|
|
2259
|
+
"mode",
|
|
2031
2260
|
"limit",
|
|
2032
2261
|
"overwrite",
|
|
2033
2262
|
"pattern",
|
|
@@ -2719,7 +2948,7 @@ function createLogger(opts = {}) {
|
|
|
2719
2948
|
);
|
|
2720
2949
|
}
|
|
2721
2950
|
}
|
|
2722
|
-
function
|
|
2951
|
+
function writeFile5(record) {
|
|
2723
2952
|
if (!fileOk || filePath === void 0) return;
|
|
2724
2953
|
const line = `${JSON.stringify(record)}
|
|
2725
2954
|
`;
|
|
@@ -2741,7 +2970,7 @@ function createLogger(opts = {}) {
|
|
|
2741
2970
|
const f = fields ?? {};
|
|
2742
2971
|
stderr(`${lvl.toUpperCase().padEnd(5)} ${ts} ${msg}${fmtFields(f)}
|
|
2743
2972
|
`);
|
|
2744
|
-
|
|
2973
|
+
writeFile5({ t: ts, level: lvl, msg, ...f });
|
|
2745
2974
|
}
|
|
2746
2975
|
return {
|
|
2747
2976
|
level,
|
|
@@ -2772,6 +3001,667 @@ function installProcessGuards(log2) {
|
|
|
2772
3001
|
};
|
|
2773
3002
|
}
|
|
2774
3003
|
|
|
3004
|
+
// src/index.ts
|
|
3005
|
+
init_config();
|
|
3006
|
+
|
|
3007
|
+
// ../core/src/embed/chunk.ts
|
|
3008
|
+
var MIN_WINDOW_WORDS = 250;
|
|
3009
|
+
var MAX_WINDOW_WORDS = 400;
|
|
3010
|
+
function chunkNote(title, body) {
|
|
3011
|
+
const normalized = body.replace(/\r\n/g, "\n");
|
|
3012
|
+
const paragraphs = splitParagraphs(normalized).flatMap(splitOversized);
|
|
3013
|
+
if (paragraphs.length === 0) return [{ text: title, words: 0, start: 0, end: 0 }];
|
|
3014
|
+
const chunks = [];
|
|
3015
|
+
let window = [];
|
|
3016
|
+
let windowWords = 0;
|
|
3017
|
+
const flush = () => {
|
|
3018
|
+
const first = window[0];
|
|
3019
|
+
const last = window[window.length - 1];
|
|
3020
|
+
if (!first || !last) return;
|
|
3021
|
+
chunks.push({
|
|
3022
|
+
text: `${title}
|
|
3023
|
+
|
|
3024
|
+
${window.map((p) => p.text).join("\n\n")}`,
|
|
3025
|
+
words: windowWords,
|
|
3026
|
+
start: first.start,
|
|
3027
|
+
end: last.end
|
|
3028
|
+
});
|
|
3029
|
+
window = [];
|
|
3030
|
+
windowWords = 0;
|
|
3031
|
+
};
|
|
3032
|
+
for (const para of paragraphs) {
|
|
3033
|
+
if (windowWords >= MIN_WINDOW_WORDS || windowWords + para.words > MAX_WINDOW_WORDS) flush();
|
|
3034
|
+
window.push(para);
|
|
3035
|
+
windowWords += para.words;
|
|
3036
|
+
}
|
|
3037
|
+
flush();
|
|
3038
|
+
return chunks;
|
|
3039
|
+
}
|
|
3040
|
+
function splitParagraphs(normalized) {
|
|
3041
|
+
const out = [];
|
|
3042
|
+
const push = (rawStart, raw) => {
|
|
3043
|
+
const text = raw.trim();
|
|
3044
|
+
if (text.length === 0) return;
|
|
3045
|
+
const start = rawStart + (raw.length - raw.trimStart().length);
|
|
3046
|
+
out.push({ text, words: countWords(text), start, end: start + text.length });
|
|
3047
|
+
};
|
|
3048
|
+
const sep4 = /\n[ \t]*\n+/g;
|
|
3049
|
+
let prev = 0;
|
|
3050
|
+
for (const m of normalized.matchAll(sep4)) {
|
|
3051
|
+
push(prev, normalized.slice(prev, m.index));
|
|
3052
|
+
prev = m.index + m[0].length;
|
|
3053
|
+
}
|
|
3054
|
+
push(prev, normalized.slice(prev));
|
|
3055
|
+
return out;
|
|
3056
|
+
}
|
|
3057
|
+
function splitOversized(para) {
|
|
3058
|
+
if (para.words <= MAX_WINDOW_WORDS) return [para];
|
|
3059
|
+
const words = [...para.text.matchAll(/\S+/g)];
|
|
3060
|
+
const pieces = [];
|
|
3061
|
+
for (let i = 0; i < words.length; i += MAX_WINDOW_WORDS) {
|
|
3062
|
+
const slice = words.slice(i, i + MAX_WINDOW_WORDS);
|
|
3063
|
+
const first = slice[0];
|
|
3064
|
+
const last = slice[slice.length - 1];
|
|
3065
|
+
if (!first || !last) continue;
|
|
3066
|
+
pieces.push({
|
|
3067
|
+
text: slice.map((w) => w[0]).join(" "),
|
|
3068
|
+
words: slice.length,
|
|
3069
|
+
start: para.start + first.index,
|
|
3070
|
+
end: para.start + last.index + last[0].length
|
|
3071
|
+
});
|
|
3072
|
+
}
|
|
3073
|
+
return pieces;
|
|
3074
|
+
}
|
|
3075
|
+
function countWords(text) {
|
|
3076
|
+
const m = text.match(/\S+/g);
|
|
3077
|
+
return m ? m.length : 0;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// ../core/src/embed/model2vec.ts
|
|
3081
|
+
import { readFile as readFile14 } from "fs/promises";
|
|
3082
|
+
import { basename as basename4, join as join8 } from "path";
|
|
3083
|
+
|
|
3084
|
+
// ../core/src/embed/safetensors.ts
|
|
3085
|
+
function readSafetensors(buf) {
|
|
3086
|
+
if (buf.byteLength < 8) {
|
|
3087
|
+
throw new Error(`safetensors: buffer too small (${buf.byteLength} bytes) to hold a header`);
|
|
3088
|
+
}
|
|
3089
|
+
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
3090
|
+
const headerLen = Number(view.getBigUint64(0, true));
|
|
3091
|
+
if (8 + headerLen > buf.byteLength) {
|
|
3092
|
+
throw new Error(
|
|
3093
|
+
`safetensors: header length ${headerLen} exceeds buffer (${buf.byteLength} bytes)`
|
|
3094
|
+
);
|
|
3095
|
+
}
|
|
3096
|
+
const header = JSON.parse(new TextDecoder().decode(buf.subarray(8, 8 + headerLen)));
|
|
3097
|
+
const dataStart = 8 + headerLen;
|
|
3098
|
+
const out = /* @__PURE__ */ new Map();
|
|
3099
|
+
const unsupported = [];
|
|
3100
|
+
for (const [name, meta] of Object.entries(header)) {
|
|
3101
|
+
if (name === "__metadata__") continue;
|
|
3102
|
+
if (meta.dtype !== "F32") {
|
|
3103
|
+
unsupported.push(`${name} (${meta.dtype})`);
|
|
3104
|
+
continue;
|
|
3105
|
+
}
|
|
3106
|
+
const [begin, end] = meta.data_offsets;
|
|
3107
|
+
const byteLen = end - begin;
|
|
3108
|
+
const elems = meta.shape.reduce((a, b) => a * b, 1);
|
|
3109
|
+
if (byteLen !== elems * 4) {
|
|
3110
|
+
throw new Error(
|
|
3111
|
+
`safetensors: tensor "${name}" data span ${byteLen} B does not match shape [${meta.shape.join(", ")}]`
|
|
3112
|
+
);
|
|
3113
|
+
}
|
|
3114
|
+
if (dataStart + end > buf.byteLength) {
|
|
3115
|
+
throw new Error(`safetensors: tensor "${name}" extends past the end of the buffer`);
|
|
3116
|
+
}
|
|
3117
|
+
const absStart = buf.byteOffset + dataStart + begin;
|
|
3118
|
+
let data;
|
|
3119
|
+
if (absStart % 4 === 0) {
|
|
3120
|
+
data = new Float32Array(buf.buffer, absStart, elems);
|
|
3121
|
+
} else {
|
|
3122
|
+
const copy = new Uint8Array(byteLen);
|
|
3123
|
+
copy.set(buf.subarray(dataStart + begin, dataStart + end));
|
|
3124
|
+
data = new Float32Array(copy.buffer);
|
|
3125
|
+
}
|
|
3126
|
+
out.set(name, { dtype: meta.dtype, shape: meta.shape, data });
|
|
3127
|
+
}
|
|
3128
|
+
if (unsupported.length > 0) {
|
|
3129
|
+
throw new Error(`safetensors: only F32 tensors are supported; found ${unsupported.join(", ")}`);
|
|
3130
|
+
}
|
|
3131
|
+
return out;
|
|
3132
|
+
}
|
|
3133
|
+
|
|
3134
|
+
// ../core/src/embed/tokenizer.ts
|
|
3135
|
+
function loadWordPieceTokenizer(tokenizerJson) {
|
|
3136
|
+
const tj = tokenizerJson;
|
|
3137
|
+
const model = tj?.model;
|
|
3138
|
+
if (model?.type !== "WordPiece" || !model.vocab) {
|
|
3139
|
+
throw new Error(
|
|
3140
|
+
`tokenizer: unsupported model type "${model?.type ?? "missing"}" (only WordPiece is implemented)`
|
|
3141
|
+
);
|
|
3142
|
+
}
|
|
3143
|
+
const vocab = new Map(Object.entries(model.vocab));
|
|
3144
|
+
const unkToken = model.unk_token ?? "[UNK]";
|
|
3145
|
+
const unkId = vocab.get(unkToken);
|
|
3146
|
+
if (unkId === void 0) {
|
|
3147
|
+
throw new Error(`tokenizer: unk_token "${unkToken}" is not in the vocab`);
|
|
3148
|
+
}
|
|
3149
|
+
const prefix = model.continuing_subword_prefix ?? "##";
|
|
3150
|
+
const maxChars = model.max_input_chars_per_word ?? 100;
|
|
3151
|
+
const norm = tj.normalizer ?? {};
|
|
3152
|
+
const cleanText = norm.clean_text ?? true;
|
|
3153
|
+
const handleCjk = norm.handle_chinese_chars ?? true;
|
|
3154
|
+
const lowercase = norm.lowercase ?? true;
|
|
3155
|
+
const stripAccents = norm.strip_accents ?? lowercase;
|
|
3156
|
+
const specialIds = new Set(
|
|
3157
|
+
(tj.added_tokens ?? []).filter((t) => t.special).map((t) => t.id)
|
|
3158
|
+
);
|
|
3159
|
+
function normalize(text) {
|
|
3160
|
+
let out = cleanText ? doCleanText(text) : text;
|
|
3161
|
+
if (handleCjk) out = padCjkChars(out);
|
|
3162
|
+
if (stripAccents) out = out.normalize("NFD").replace(new RegExp("\\p{Mn}", "gu"), "");
|
|
3163
|
+
if (lowercase) out = out.toLowerCase();
|
|
3164
|
+
return out;
|
|
3165
|
+
}
|
|
3166
|
+
function wordPiece(word) {
|
|
3167
|
+
const chars = [...word];
|
|
3168
|
+
if (chars.length > maxChars) return [unkId];
|
|
3169
|
+
const ids = [];
|
|
3170
|
+
let start = 0;
|
|
3171
|
+
while (start < chars.length) {
|
|
3172
|
+
let end = chars.length;
|
|
3173
|
+
let id;
|
|
3174
|
+
while (start < end) {
|
|
3175
|
+
const piece = (start > 0 ? prefix : "") + chars.slice(start, end).join("");
|
|
3176
|
+
const found = vocab.get(piece);
|
|
3177
|
+
if (found !== void 0) {
|
|
3178
|
+
id = found;
|
|
3179
|
+
break;
|
|
3180
|
+
}
|
|
3181
|
+
end--;
|
|
3182
|
+
}
|
|
3183
|
+
if (id === void 0) return [unkId];
|
|
3184
|
+
ids.push(id);
|
|
3185
|
+
start = end;
|
|
3186
|
+
}
|
|
3187
|
+
return ids;
|
|
3188
|
+
}
|
|
3189
|
+
return {
|
|
3190
|
+
vocabSize: vocab.size,
|
|
3191
|
+
unkId,
|
|
3192
|
+
specialIds,
|
|
3193
|
+
encode(text) {
|
|
3194
|
+
const ids = [];
|
|
3195
|
+
for (const word of preTokenize(normalize(text))) {
|
|
3196
|
+
for (const id of wordPiece(word)) ids.push(id);
|
|
3197
|
+
}
|
|
3198
|
+
return ids;
|
|
3199
|
+
}
|
|
3200
|
+
};
|
|
3201
|
+
}
|
|
3202
|
+
function preTokenize(text) {
|
|
3203
|
+
const words = [];
|
|
3204
|
+
let cur = "";
|
|
3205
|
+
for (const ch of text) {
|
|
3206
|
+
const cp = ch.codePointAt(0);
|
|
3207
|
+
if (isWhitespace(ch, cp)) {
|
|
3208
|
+
if (cur) {
|
|
3209
|
+
words.push(cur);
|
|
3210
|
+
cur = "";
|
|
3211
|
+
}
|
|
3212
|
+
} else if (isPunctuation(ch, cp)) {
|
|
3213
|
+
if (cur) {
|
|
3214
|
+
words.push(cur);
|
|
3215
|
+
cur = "";
|
|
3216
|
+
}
|
|
3217
|
+
words.push(ch);
|
|
3218
|
+
} else {
|
|
3219
|
+
cur += ch;
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
if (cur) words.push(cur);
|
|
3223
|
+
return words;
|
|
3224
|
+
}
|
|
3225
|
+
function doCleanText(text) {
|
|
3226
|
+
let out = "";
|
|
3227
|
+
for (const ch of text) {
|
|
3228
|
+
const cp = ch.codePointAt(0);
|
|
3229
|
+
if (cp === 0 || cp === 65533 || isControl(ch, cp)) continue;
|
|
3230
|
+
out += isWhitespace(ch, cp) ? " " : ch;
|
|
3231
|
+
}
|
|
3232
|
+
return out;
|
|
3233
|
+
}
|
|
3234
|
+
function padCjkChars(text) {
|
|
3235
|
+
let out = "";
|
|
3236
|
+
for (const ch of text) {
|
|
3237
|
+
out += isCjk(ch.codePointAt(0)) ? ` ${ch} ` : ch;
|
|
3238
|
+
}
|
|
3239
|
+
return out;
|
|
3240
|
+
}
|
|
3241
|
+
function isWhitespace(ch, cp) {
|
|
3242
|
+
if (cp === 32 || cp === 9 || cp === 10 || cp === 13) return true;
|
|
3243
|
+
return new RegExp("\\p{Zs}", "u").test(ch);
|
|
3244
|
+
}
|
|
3245
|
+
function isControl(ch, cp) {
|
|
3246
|
+
if (cp === 9 || cp === 10 || cp === 13) return false;
|
|
3247
|
+
return new RegExp("\\p{Cc}|\\p{Cf}", "u").test(ch);
|
|
3248
|
+
}
|
|
3249
|
+
function isPunctuation(ch, cp) {
|
|
3250
|
+
if (cp >= 33 && cp <= 47 || cp >= 58 && cp <= 64 || cp >= 91 && cp <= 96 || cp >= 123 && cp <= 126) {
|
|
3251
|
+
return true;
|
|
3252
|
+
}
|
|
3253
|
+
return new RegExp("\\p{P}", "u").test(ch);
|
|
3254
|
+
}
|
|
3255
|
+
function isCjk(cp) {
|
|
3256
|
+
return cp >= 19968 && cp <= 40959 || cp >= 13312 && cp <= 19903 || cp >= 131072 && cp <= 173791 || cp >= 173824 && cp <= 177983 || cp >= 177984 && cp <= 178207 || cp >= 178208 && cp <= 183983 || cp >= 63744 && cp <= 64255 || cp >= 194560 && cp <= 195103;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
// ../core/src/embed/model2vec.ts
|
|
3260
|
+
var TENSOR_NAME = "embeddings";
|
|
3261
|
+
async function loadModel2Vec(modelDir) {
|
|
3262
|
+
const [configRaw, tokenizerRaw, weights] = await Promise.all([
|
|
3263
|
+
readFile14(join8(modelDir, "config.json"), "utf8"),
|
|
3264
|
+
readFile14(join8(modelDir, "tokenizer.json"), "utf8"),
|
|
3265
|
+
readFile14(join8(modelDir, "model.safetensors"))
|
|
3266
|
+
]);
|
|
3267
|
+
const config = JSON.parse(configRaw);
|
|
3268
|
+
const tokenizer = loadWordPieceTokenizer(JSON.parse(tokenizerRaw));
|
|
3269
|
+
const tensors = readSafetensors(weights);
|
|
3270
|
+
const tensor = tensors.get(TENSOR_NAME) ?? (tensors.size === 1 ? [...tensors.values()][0] : void 0);
|
|
3271
|
+
if (!tensor) {
|
|
3272
|
+
throw new Error(
|
|
3273
|
+
`model2vec: no "${TENSOR_NAME}" tensor in ${modelDir}; found: ${[...tensors.keys()].join(", ")}`
|
|
3274
|
+
);
|
|
3275
|
+
}
|
|
3276
|
+
if (tensor.shape.length !== 2) {
|
|
3277
|
+
throw new Error(`model2vec: expected a 2-D embedding matrix, got [${tensor.shape.join(", ")}]`);
|
|
3278
|
+
}
|
|
3279
|
+
const [rows, dim] = tensor.shape;
|
|
3280
|
+
if (rows !== tokenizer.vocabSize) {
|
|
3281
|
+
throw new Error(
|
|
3282
|
+
`model2vec: embedding rows (${rows}) do not match tokenizer vocab size (${tokenizer.vocabSize})`
|
|
3283
|
+
);
|
|
3284
|
+
}
|
|
3285
|
+
if (config.hidden_dim !== void 0 && config.hidden_dim !== dim) {
|
|
3286
|
+
throw new Error(
|
|
3287
|
+
`model2vec: config hidden_dim ${config.hidden_dim} does not match tensor dim ${dim}`
|
|
3288
|
+
);
|
|
3289
|
+
}
|
|
3290
|
+
const table = tensor.data;
|
|
3291
|
+
return {
|
|
3292
|
+
id: basename4(modelDir),
|
|
3293
|
+
dim,
|
|
3294
|
+
embed(text) {
|
|
3295
|
+
const out = new Float32Array(dim);
|
|
3296
|
+
let pooled = 0;
|
|
3297
|
+
for (const id of tokenizer.encode(text)) {
|
|
3298
|
+
if (tokenizer.specialIds.has(id) && id !== tokenizer.unkId) continue;
|
|
3299
|
+
const base = id * dim;
|
|
3300
|
+
for (let j = 0; j < dim; j++) {
|
|
3301
|
+
out[j] = out[j] + table[base + j];
|
|
3302
|
+
}
|
|
3303
|
+
pooled++;
|
|
3304
|
+
}
|
|
3305
|
+
if (pooled === 0) return out;
|
|
3306
|
+
let sumSquares = 0;
|
|
3307
|
+
for (let j = 0; j < dim; j++) {
|
|
3308
|
+
const mean = out[j] / pooled;
|
|
3309
|
+
out[j] = mean;
|
|
3310
|
+
sumSquares += mean * mean;
|
|
3311
|
+
}
|
|
3312
|
+
const norm = Math.sqrt(sumSquares);
|
|
3313
|
+
if (norm > 0) {
|
|
3314
|
+
for (let j = 0; j < dim; j++) out[j] = out[j] / norm;
|
|
3315
|
+
}
|
|
3316
|
+
return out;
|
|
3317
|
+
}
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
// src/semantic/cache.ts
|
|
3322
|
+
import { createHash as createHash2 } from "crypto";
|
|
3323
|
+
import { mkdir as mkdir6, readFile as readFile15, rename as rename4, writeFile as writeFile3 } from "fs/promises";
|
|
3324
|
+
import { join as join9 } from "path";
|
|
3325
|
+
var CACHE_VERSION = 2;
|
|
3326
|
+
function cachePathsFor(cacheDir, vaultRoot2, modelId) {
|
|
3327
|
+
const vaultKey = createHash2("sha256").update(vaultRoot2).digest("hex").slice(0, 16);
|
|
3328
|
+
const dir = join9(cacheDir, "embeddings", vaultKey);
|
|
3329
|
+
return { dir, bin: join9(dir, `${modelId}.bin`), manifest: join9(dir, `${modelId}.json`) };
|
|
3330
|
+
}
|
|
3331
|
+
async function loadCache(paths, modelId, dim) {
|
|
3332
|
+
try {
|
|
3333
|
+
const manifest = JSON.parse(await readFile15(paths.manifest, "utf8"));
|
|
3334
|
+
if (manifest.version !== CACHE_VERSION || manifest.modelId !== modelId || manifest.dim !== dim) {
|
|
3335
|
+
return void 0;
|
|
3336
|
+
}
|
|
3337
|
+
const buf = await readFile15(paths.bin);
|
|
3338
|
+
const totalChunks = manifest.entries.reduce((a, e) => a + e.chunks, 0);
|
|
3339
|
+
const vectorBytes = totalChunks * dim * 4;
|
|
3340
|
+
const spanBytes = totalChunks * 2 * 4;
|
|
3341
|
+
if (buf.byteLength !== vectorBytes + spanBytes) return void 0;
|
|
3342
|
+
const aligned = new Uint8Array(buf.byteLength);
|
|
3343
|
+
aligned.set(buf);
|
|
3344
|
+
const floats = new Float32Array(aligned.buffer, 0, totalChunks * dim);
|
|
3345
|
+
const spans = new Uint32Array(aligned.buffer, vectorBytes, totalChunks * 2);
|
|
3346
|
+
const vectors = /* @__PURE__ */ new Map();
|
|
3347
|
+
const hashes = /* @__PURE__ */ new Map();
|
|
3348
|
+
let chunkOffset = 0;
|
|
3349
|
+
for (const e of manifest.entries) {
|
|
3350
|
+
if (e.chunks <= 0) return void 0;
|
|
3351
|
+
vectors.set(e.path, {
|
|
3352
|
+
packed: floats.subarray(chunkOffset * dim, (chunkOffset + e.chunks) * dim),
|
|
3353
|
+
spans: spans.subarray(chunkOffset * 2, (chunkOffset + e.chunks) * 2)
|
|
3354
|
+
});
|
|
3355
|
+
hashes.set(e.path, e.contentHash);
|
|
3356
|
+
chunkOffset += e.chunks;
|
|
3357
|
+
}
|
|
3358
|
+
return { vectors, hashes };
|
|
3359
|
+
} catch {
|
|
3360
|
+
return void 0;
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
async function saveCache(paths, modelId, dim, notes2, hashes) {
|
|
3364
|
+
const entries = [];
|
|
3365
|
+
const kept = [];
|
|
3366
|
+
let totalChunks = 0;
|
|
3367
|
+
for (const [path2, note] of notes2) {
|
|
3368
|
+
const contentHash2 = hashes.get(path2);
|
|
3369
|
+
if (!contentHash2) continue;
|
|
3370
|
+
const chunks = note.packed.length / dim;
|
|
3371
|
+
entries.push({ path: path2, contentHash: contentHash2, chunks });
|
|
3372
|
+
kept.push(note);
|
|
3373
|
+
totalChunks += chunks;
|
|
3374
|
+
}
|
|
3375
|
+
const bin = new Uint8Array(totalChunks * dim * 4 + totalChunks * 2 * 4);
|
|
3376
|
+
const floats = new Float32Array(bin.buffer, 0, totalChunks * dim);
|
|
3377
|
+
const spans = new Uint32Array(bin.buffer, totalChunks * dim * 4, totalChunks * 2);
|
|
3378
|
+
let floatOffset = 0;
|
|
3379
|
+
let spanOffset = 0;
|
|
3380
|
+
for (const note of kept) {
|
|
3381
|
+
floats.set(note.packed, floatOffset);
|
|
3382
|
+
spans.set(note.spans, spanOffset);
|
|
3383
|
+
floatOffset += note.packed.length;
|
|
3384
|
+
spanOffset += note.spans.length;
|
|
3385
|
+
}
|
|
3386
|
+
const manifest = { version: CACHE_VERSION, modelId, dim, entries };
|
|
3387
|
+
await mkdir6(paths.dir, { recursive: true });
|
|
3388
|
+
await atomicWriteBytes(paths.bin, bin);
|
|
3389
|
+
await atomicWriteBytes(paths.manifest, new TextEncoder().encode(JSON.stringify(manifest)));
|
|
3390
|
+
}
|
|
3391
|
+
async function atomicWriteBytes(absPath, bytes) {
|
|
3392
|
+
const tmpPath = `${absPath}.seekstone-cache-tmp`;
|
|
3393
|
+
await writeFile3(tmpPath, bytes);
|
|
3394
|
+
await rename4(tmpPath, absPath);
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
// src/semantic/store.ts
|
|
3398
|
+
var SemanticStore = class {
|
|
3399
|
+
dim;
|
|
3400
|
+
notes = /* @__PURE__ */ new Map();
|
|
3401
|
+
chunks = 0;
|
|
3402
|
+
constructor(dim) {
|
|
3403
|
+
if (!Number.isInteger(dim) || dim <= 0) {
|
|
3404
|
+
throw new Error(`semantic store: invalid dim ${dim}`);
|
|
3405
|
+
}
|
|
3406
|
+
this.dim = dim;
|
|
3407
|
+
}
|
|
3408
|
+
/** Total chunk vectors across all notes. */
|
|
3409
|
+
get chunkCount() {
|
|
3410
|
+
return this.chunks;
|
|
3411
|
+
}
|
|
3412
|
+
get noteCount() {
|
|
3413
|
+
return this.notes.size;
|
|
3414
|
+
}
|
|
3415
|
+
/** Replace (or insert) a note's packed chunk vectors and spans. */
|
|
3416
|
+
setNote(path2, packed, spans) {
|
|
3417
|
+
if (packed.length === 0 || packed.length % this.dim !== 0) {
|
|
3418
|
+
throw new Error(
|
|
3419
|
+
`semantic store: packed length ${packed.length} is not a positive multiple of dim ${this.dim} (${path2})`
|
|
3420
|
+
);
|
|
3421
|
+
}
|
|
3422
|
+
const n = packed.length / this.dim;
|
|
3423
|
+
if (spans.length !== n * 2) {
|
|
3424
|
+
throw new Error(
|
|
3425
|
+
`semantic store: spans length ${spans.length} does not match ${n} chunks (${path2})`
|
|
3426
|
+
);
|
|
3427
|
+
}
|
|
3428
|
+
const prev = this.notes.get(path2);
|
|
3429
|
+
if (prev) this.chunks -= prev.packed.length / this.dim;
|
|
3430
|
+
this.notes.set(path2, { packed, spans });
|
|
3431
|
+
this.chunks += n;
|
|
3432
|
+
}
|
|
3433
|
+
removeNote(path2) {
|
|
3434
|
+
const prev = this.notes.get(path2);
|
|
3435
|
+
if (!prev) return;
|
|
3436
|
+
this.chunks -= prev.packed.length / this.dim;
|
|
3437
|
+
this.notes.delete(path2);
|
|
3438
|
+
}
|
|
3439
|
+
getNote(path2) {
|
|
3440
|
+
return this.notes.get(path2);
|
|
3441
|
+
}
|
|
3442
|
+
/** Iterate (path, note vectors) — cache serialization uses this. */
|
|
3443
|
+
entries() {
|
|
3444
|
+
return this.notes.entries();
|
|
3445
|
+
}
|
|
3446
|
+
/** Top `k` notes by max-pooled chunk cosine, ties broken path-ascending. */
|
|
3447
|
+
topNotes(query, k) {
|
|
3448
|
+
if (query.length !== this.dim) {
|
|
3449
|
+
throw new Error(`semantic store: query dim ${query.length} does not match ${this.dim}`);
|
|
3450
|
+
}
|
|
3451
|
+
const dim = this.dim;
|
|
3452
|
+
const hits = [];
|
|
3453
|
+
for (const [path2, { packed, spans }] of this.notes) {
|
|
3454
|
+
let best = Number.NEGATIVE_INFINITY;
|
|
3455
|
+
let bestChunk = 0;
|
|
3456
|
+
const n = packed.length / dim;
|
|
3457
|
+
for (let c = 0; c < n; c++) {
|
|
3458
|
+
const base = c * dim;
|
|
3459
|
+
let score = 0;
|
|
3460
|
+
for (let j = 0; j < dim; j++) {
|
|
3461
|
+
score += packed[base + j] * query[j];
|
|
3462
|
+
}
|
|
3463
|
+
if (score > best) {
|
|
3464
|
+
best = score;
|
|
3465
|
+
bestChunk = c;
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
hits.push({
|
|
3469
|
+
path: path2,
|
|
3470
|
+
score: best,
|
|
3471
|
+
chunkIndex: bestChunk,
|
|
3472
|
+
start: spans[bestChunk * 2],
|
|
3473
|
+
end: spans[bestChunk * 2 + 1]
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
3476
|
+
return hits.sort((a, b) => b.score - a.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)).slice(0, Math.max(0, k));
|
|
3477
|
+
}
|
|
3478
|
+
};
|
|
3479
|
+
|
|
3480
|
+
// src/semantic/state.ts
|
|
3481
|
+
var Semantic = class _Semantic {
|
|
3482
|
+
embedder;
|
|
3483
|
+
store;
|
|
3484
|
+
progress;
|
|
3485
|
+
ctx;
|
|
3486
|
+
paths;
|
|
3487
|
+
log;
|
|
3488
|
+
debounceMs;
|
|
3489
|
+
saveDebounceMs;
|
|
3490
|
+
yieldEvery;
|
|
3491
|
+
hashes = /* @__PURE__ */ new Map();
|
|
3492
|
+
pending = /* @__PURE__ */ new Map();
|
|
3493
|
+
saveTimer;
|
|
3494
|
+
stopped = false;
|
|
3495
|
+
constructor(embedder, ctx2, cfg, deps) {
|
|
3496
|
+
this.embedder = embedder;
|
|
3497
|
+
this.store = new SemanticStore(embedder.dim);
|
|
3498
|
+
this.ctx = ctx2;
|
|
3499
|
+
this.paths = cachePathsFor(cfg.cacheDir, ctx2.vaultRoot, embedder.id);
|
|
3500
|
+
this.log = deps.log;
|
|
3501
|
+
this.debounceMs = deps.debounceMs ?? 300;
|
|
3502
|
+
this.saveDebounceMs = deps.saveDebounceMs ?? 5e3;
|
|
3503
|
+
this.yieldEvery = deps.yieldEvery ?? 8;
|
|
3504
|
+
this.progress = { state: "building", done: 0, total: ctx2.notes.size };
|
|
3505
|
+
}
|
|
3506
|
+
/**
|
|
3507
|
+
* Load the model and kick off the background index build. Rejects with an
|
|
3508
|
+
* actionable message when the model files are missing — the user enabled
|
|
3509
|
+
* the feature explicitly, so a broken setup should fail loudly at boot.
|
|
3510
|
+
*/
|
|
3511
|
+
static async start(ctx2, cfg, deps = {}) {
|
|
3512
|
+
let embedder;
|
|
3513
|
+
try {
|
|
3514
|
+
embedder = await (deps.loadModel ?? loadModel2Vec)(cfg.modelDir);
|
|
3515
|
+
} catch (err) {
|
|
3516
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3517
|
+
throw new Error(
|
|
3518
|
+
`semantic search: could not load the embedding model from ${cfg.modelDir} \u2014 run \`npx -y seekstone fetch-model\` to download it, or point SEEKSTONE_MODEL_PATH at a Model2Vec model directory (${reason})`
|
|
3519
|
+
);
|
|
3520
|
+
}
|
|
3521
|
+
const semantic = new _Semantic(embedder, ctx2, cfg, deps);
|
|
3522
|
+
void semantic.build();
|
|
3523
|
+
return semantic;
|
|
3524
|
+
}
|
|
3525
|
+
/** Resolves when the initial build has finished (tests and progress checks). */
|
|
3526
|
+
buildDone = Promise.resolve();
|
|
3527
|
+
ready() {
|
|
3528
|
+
return this.buildDone;
|
|
3529
|
+
}
|
|
3530
|
+
build() {
|
|
3531
|
+
this.buildDone = this.buildInner().catch((err) => {
|
|
3532
|
+
this.log?.error("semantic build failed", {
|
|
3533
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3534
|
+
});
|
|
3535
|
+
});
|
|
3536
|
+
return this.buildDone;
|
|
3537
|
+
}
|
|
3538
|
+
async buildInner() {
|
|
3539
|
+
const t0 = performance.now();
|
|
3540
|
+
const cached = await loadCache(this.paths, this.embedder.id, this.embedder.dim);
|
|
3541
|
+
const ids = [...this.ctx.notes.keys()];
|
|
3542
|
+
this.progress = { state: "building", done: 0, total: ids.length };
|
|
3543
|
+
let reused = 0;
|
|
3544
|
+
let sinceYield = 0;
|
|
3545
|
+
for (const id of ids) {
|
|
3546
|
+
if (this.stopped) return;
|
|
3547
|
+
if (!this.hashes.has(id)) {
|
|
3548
|
+
const note = this.ctx.notes.get(id);
|
|
3549
|
+
if (note) {
|
|
3550
|
+
const current = contentHash(note.raw);
|
|
3551
|
+
const cachedVecs = cached?.hashes.get(id) === current ? cached.vectors.get(id) : void 0;
|
|
3552
|
+
if (cachedVecs !== void 0 && cachedVecs.packed.length > 0) {
|
|
3553
|
+
this.store.setNote(id, cachedVecs.packed, cachedVecs.spans);
|
|
3554
|
+
reused++;
|
|
3555
|
+
} else {
|
|
3556
|
+
const { packed, spans } = this.embedNote(note);
|
|
3557
|
+
this.store.setNote(id, packed, spans);
|
|
3558
|
+
if (++sinceYield >= this.yieldEvery) {
|
|
3559
|
+
sinceYield = 0;
|
|
3560
|
+
await new Promise((r) => setImmediate(r));
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
this.hashes.set(id, current);
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
if (this.progress.state === "building") this.progress.done++;
|
|
3567
|
+
}
|
|
3568
|
+
this.progress = { state: "ready" };
|
|
3569
|
+
this.log?.info("semantic index ready", {
|
|
3570
|
+
notes: this.store.noteCount,
|
|
3571
|
+
chunks: this.store.chunkCount,
|
|
3572
|
+
reusedFromCache: reused,
|
|
3573
|
+
buildMs: Math.round(performance.now() - t0)
|
|
3574
|
+
});
|
|
3575
|
+
if (reused < this.store.noteCount) await this.save();
|
|
3576
|
+
}
|
|
3577
|
+
embedNote(note) {
|
|
3578
|
+
const chunks = chunkNote(note.title, note.body);
|
|
3579
|
+
const dim = this.embedder.dim;
|
|
3580
|
+
const packed = new Float32Array(chunks.length * dim);
|
|
3581
|
+
const spans = new Uint32Array(chunks.length * 2);
|
|
3582
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
3583
|
+
const chunk = chunks[i];
|
|
3584
|
+
packed.set(this.embedder.embed(chunk.text), i * dim);
|
|
3585
|
+
spans[i * 2] = chunk.start;
|
|
3586
|
+
spans[i * 2 + 1] = chunk.end;
|
|
3587
|
+
}
|
|
3588
|
+
return { packed, spans };
|
|
3589
|
+
}
|
|
3590
|
+
embedQuery(query) {
|
|
3591
|
+
return this.embedder.embed(query);
|
|
3592
|
+
}
|
|
3593
|
+
/** Watcher hook: a note was added or changed (ctx.notes is already fresh). */
|
|
3594
|
+
noteChanged(path2) {
|
|
3595
|
+
if (this.stopped) return;
|
|
3596
|
+
const existing = this.pending.get(path2);
|
|
3597
|
+
if (existing) clearTimeout(existing);
|
|
3598
|
+
const timer = setTimeout(() => {
|
|
3599
|
+
this.pending.delete(path2);
|
|
3600
|
+
this.reembed(path2);
|
|
3601
|
+
}, this.debounceMs);
|
|
3602
|
+
timer.unref?.();
|
|
3603
|
+
this.pending.set(path2, timer);
|
|
3604
|
+
}
|
|
3605
|
+
/** Watcher hook: a note was deleted. */
|
|
3606
|
+
noteRemoved(path2) {
|
|
3607
|
+
const existing = this.pending.get(path2);
|
|
3608
|
+
if (existing) {
|
|
3609
|
+
clearTimeout(existing);
|
|
3610
|
+
this.pending.delete(path2);
|
|
3611
|
+
}
|
|
3612
|
+
if (this.hashes.has(path2) || this.store.getNote(path2)) {
|
|
3613
|
+
this.store.removeNote(path2);
|
|
3614
|
+
this.hashes.delete(path2);
|
|
3615
|
+
this.scheduleSave();
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
reembed(path2) {
|
|
3619
|
+
const note = this.ctx.notes.get(path2);
|
|
3620
|
+
if (!note) return;
|
|
3621
|
+
const current = contentHash(note.raw);
|
|
3622
|
+
const prior = this.hashes.get(path2);
|
|
3623
|
+
if (prior === current) return;
|
|
3624
|
+
const { packed, spans } = this.embedNote(note);
|
|
3625
|
+
this.store.setNote(path2, packed, spans);
|
|
3626
|
+
this.hashes.set(path2, current);
|
|
3627
|
+
this.log?.debug("semantic re-embed", { path: path2 });
|
|
3628
|
+
this.scheduleSave();
|
|
3629
|
+
}
|
|
3630
|
+
scheduleSave() {
|
|
3631
|
+
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
3632
|
+
this.saveTimer = setTimeout(() => {
|
|
3633
|
+
this.saveTimer = void 0;
|
|
3634
|
+
void this.save();
|
|
3635
|
+
}, this.saveDebounceMs);
|
|
3636
|
+
this.saveTimer.unref?.();
|
|
3637
|
+
}
|
|
3638
|
+
async save() {
|
|
3639
|
+
if (this.stopped) return;
|
|
3640
|
+
try {
|
|
3641
|
+
await saveCache(
|
|
3642
|
+
this.paths,
|
|
3643
|
+
this.embedder.id,
|
|
3644
|
+
this.embedder.dim,
|
|
3645
|
+
this.store.entries(),
|
|
3646
|
+
this.hashes
|
|
3647
|
+
);
|
|
3648
|
+
this.log?.debug("semantic cache saved", { notes: this.store.noteCount });
|
|
3649
|
+
} catch (err) {
|
|
3650
|
+
this.log?.warn("semantic cache save failed", {
|
|
3651
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3652
|
+
});
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
/** Tear down timers (tests; the process otherwise runs for the session). */
|
|
3656
|
+
stop() {
|
|
3657
|
+
this.stopped = true;
|
|
3658
|
+
for (const timer of this.pending.values()) clearTimeout(timer);
|
|
3659
|
+
this.pending.clear();
|
|
3660
|
+
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
3661
|
+
this.saveTimer = void 0;
|
|
3662
|
+
}
|
|
3663
|
+
};
|
|
3664
|
+
|
|
2775
3665
|
// src/tool-list.ts
|
|
2776
3666
|
var READ_ONLY_ANNOTATIONS = {
|
|
2777
3667
|
readOnlyHint: true,
|
|
@@ -2799,14 +3689,23 @@ var ALL_TOOLS = [
|
|
|
2799
3689
|
{
|
|
2800
3690
|
name: "search",
|
|
2801
3691
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
2802
|
-
description:
|
|
3692
|
+
description: 'Full-text search across the vault. Returns ranked excerpts (~120 chars, tunable) \u2014 not full notes \u2014 to minimise context usage. Supports fuzzy matching and prefix search; with SEEKSTONE_SEMANTIC=1, mode "semantic"/"hybrid" searches by meaning via a local embedding model.',
|
|
2803
3693
|
inputSchema: {
|
|
2804
3694
|
type: "object",
|
|
2805
3695
|
properties: {
|
|
2806
3696
|
query: { type: "string", description: "Search query." },
|
|
3697
|
+
mode: {
|
|
3698
|
+
type: "string",
|
|
3699
|
+
enum: ["lexical", "semantic", "hybrid"],
|
|
3700
|
+
description: "lexical = keyword search (default). semantic = meaning-based search (requires SEEKSTONE_SEMANTIC=1). hybrid = exact-title lookups go lexical, everything else semantic."
|
|
3701
|
+
},
|
|
2807
3702
|
limit: { type: "number", description: "Max results (1\u201350, default 10)." },
|
|
2808
3703
|
folder: { type: "string", description: "Restrict to a vault-relative folder prefix." },
|
|
2809
|
-
tag: { type: "string", description: "Restrict to notes with this tag." }
|
|
3704
|
+
tag: { type: "string", description: "Restrict to notes with this tag." },
|
|
3705
|
+
excerptLength: {
|
|
3706
|
+
type: "number",
|
|
3707
|
+
description: "Max characters of match context per hit (20\u20132000, default 120)."
|
|
3708
|
+
}
|
|
2810
3709
|
},
|
|
2811
3710
|
required: ["query"]
|
|
2812
3711
|
}
|
|
@@ -3288,8 +4187,8 @@ function visibleTools(policy2) {
|
|
|
3288
4187
|
|
|
3289
4188
|
// src/watcher.ts
|
|
3290
4189
|
import { realpathSync } from "fs";
|
|
3291
|
-
import { readFile as
|
|
3292
|
-
import { join as
|
|
4190
|
+
import { readFile as readFile16 } from "fs/promises";
|
|
4191
|
+
import { join as join10, relative as relative2, sep as sep3 } from "path";
|
|
3293
4192
|
import chokidar from "chokidar";
|
|
3294
4193
|
function startWatcher(ctx2, log2, opts) {
|
|
3295
4194
|
const usePolling = opts?.usePolling ?? process.env.SEEKSTONE_WATCH_POLL === "1";
|
|
@@ -3301,10 +4200,11 @@ function startWatcher(ctx2, log2, opts) {
|
|
|
3301
4200
|
const toRel = (abs) => relative2(vaultRoot2, abs).split(sep3).join("/");
|
|
3302
4201
|
async function upsert(relPath, op) {
|
|
3303
4202
|
try {
|
|
3304
|
-
const raw = await
|
|
4203
|
+
const raw = await readFile16(join10(vaultRoot2, relPath), "utf8");
|
|
3305
4204
|
removeNoteBacklinks(ctx2, relPath);
|
|
3306
4205
|
upsertDoc(ctx2, buildDoc(relPath, raw));
|
|
3307
4206
|
addNoteBacklinks(ctx2, relPath, raw);
|
|
4207
|
+
ctx2.semantic?.noteChanged(relPath);
|
|
3308
4208
|
log2?.debug("index updated", { path: relPath, op });
|
|
3309
4209
|
} catch {
|
|
3310
4210
|
}
|
|
@@ -3314,6 +4214,7 @@ function startWatcher(ctx2, log2, opts) {
|
|
|
3314
4214
|
removeNoteBacklinks(ctx2, relPath);
|
|
3315
4215
|
ctx2.index.discard(relPath);
|
|
3316
4216
|
ctx2.notes.delete(relPath);
|
|
4217
|
+
ctx2.semantic?.noteRemoved(relPath);
|
|
3317
4218
|
log2?.debug("index removed", { path: relPath, op: "delete" });
|
|
3318
4219
|
}
|
|
3319
4220
|
}
|
|
@@ -3353,7 +4254,7 @@ function startWatcher(ctx2, log2, opts) {
|
|
|
3353
4254
|
}
|
|
3354
4255
|
|
|
3355
4256
|
// src/index.ts
|
|
3356
|
-
var VERSION = true ? "0.
|
|
4257
|
+
var VERSION = true ? "0.15.1" : "0.0.0-dev";
|
|
3357
4258
|
var intent = parseCliIntent(process.argv.slice(2));
|
|
3358
4259
|
if (intent === "version") {
|
|
3359
4260
|
process.stdout.write(`${VERSION}
|
|
@@ -3381,6 +4282,14 @@ if (intent === "init") {
|
|
|
3381
4282
|
`);
|
|
3382
4283
|
process.exit(result.exitCode);
|
|
3383
4284
|
}
|
|
4285
|
+
if (intent === "fetch-model") {
|
|
4286
|
+
const { runFetchModel: runFetchModel2 } = await Promise.resolve().then(() => (init_fetch_model(), fetch_model_exports));
|
|
4287
|
+
const { homedir: homedir2 } = await import("os");
|
|
4288
|
+
const result = await runFetchModel2({ env: process.env, homedir: homedir2() });
|
|
4289
|
+
process.stdout.write(`${result.output.join("\n")}
|
|
4290
|
+
`);
|
|
4291
|
+
process.exit(result.exitCode);
|
|
4292
|
+
}
|
|
3384
4293
|
var log = createLogger();
|
|
3385
4294
|
installProcessGuards(log);
|
|
3386
4295
|
var rawVaultRoot = process.env.SEEKSTONE_VAULT;
|
|
@@ -3396,6 +4305,20 @@ log.info("building index", { vault: vaultRoot });
|
|
|
3396
4305
|
var { index, notes, backlinks, buildMs } = await buildIndex(vaultRoot);
|
|
3397
4306
|
log.info("index ready", { notes: notes.size, buildMs });
|
|
3398
4307
|
var ctx = { vaultRoot, index, notes, backlinks, policy };
|
|
4308
|
+
var semanticCfg = resolveSemanticConfig(process.env, homedir());
|
|
4309
|
+
if (semanticCfg) {
|
|
4310
|
+
try {
|
|
4311
|
+
ctx.semantic = await Semantic.start(ctx, semanticCfg, { log });
|
|
4312
|
+
log.info("semantic search enabled", {
|
|
4313
|
+
model: ctx.semantic.embedder.id,
|
|
4314
|
+
dim: ctx.semantic.embedder.dim,
|
|
4315
|
+
notes: notes.size
|
|
4316
|
+
});
|
|
4317
|
+
} catch (err) {
|
|
4318
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
4319
|
+
process.exit(1);
|
|
4320
|
+
}
|
|
4321
|
+
}
|
|
3399
4322
|
var watcher = startWatcher(ctx, log);
|
|
3400
4323
|
process.on("exit", () => void watcher.stop());
|
|
3401
4324
|
var server = new Server({ name: "seekstone", version: VERSION }, { capabilities: { tools: {} } });
|