md-semantic-search 1.0.3 → 1.1.0

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 CHANGED
@@ -387,8 +387,31 @@ mdss search --db /path/to/your/markdown "how do I rotate the api token" --rerank
387
387
  - The reranker is lazy: nothing loads unless `--rerank` is actually requested,
388
388
  so searches without it pay zero extra cost.
389
389
 
390
+ ### 3. Ask & Interactive Multi-turn Chat (`mdss ask`, `mdss chat`)
390
391
 
391
- ### 3. Serve (daemon, warm index + model)
392
+ `md-semantic-search` includes a grounded RAG QA engine that operates fully offline by default with zero external dependencies, or connects optionally to local/remote LLMs (Ollama, OpenAI-compatible APIs).
393
+
394
+ ```bash
395
+ # One-shot grounded QA with citations:
396
+ mdss ask --db ./notes "What is the token expiration policy?"
397
+
398
+ # Multi-turn persistent chat session:
399
+ mdss chat --db ./notes
400
+
401
+ # Resume the most recent session or a specific session:
402
+ mdss chat --db ./notes --resume
403
+ mdss chat --db ./notes --session my-session-id
404
+
405
+ # One-shot query into a persistent session:
406
+ mdss chat --db ./notes --session my-session-id "What about database failover?"
407
+ ```
408
+
409
+ - **Offline Extractive Synthesis**: Zero-dependency default extracts salient factual sentences with strict `[file › heading]` source provenance.
410
+ - **Persistent Sessions**: Multi-turn sessions are stored in `<indexDir>/sessions/<id>.json` preserving full conversation turns, retrieved chunk provenance manifests (file, heading, exact line numbers, chunk hashes), and model metadata.
411
+ - **Context Carry-Over**: Follow-up turns re-rank and expand query tokens using prior conversational context.
412
+ - **Interactive REPL Commands**: `/session` (info), `/sources` (manifest of last turn), `/help`, `/exit`.
413
+
414
+ ### 4. Serve (daemon, warm index + model)
392
415
 
393
416
  ```bash
394
417
  mdss serve --db /path/to/your/markdown [--port 8747] [--host 127.0.0.1] [--watch]
@@ -520,10 +543,27 @@ rate-limit gates as `/search`, so remote agents inherit the hardening. stdio
520
543
  via `mdss mcp` remains the default transport.
521
544
 
522
545
  #### Available MCP Tools:
523
- - **`search_markdown`**: Hybrid semantic vector + BM25 search with optional `path` glob and `tag` filters.
546
+ - **`search_markdown`**: Hybrid semantic vector + BM25 search with optional `path` glob, `tag`, rich `filter`, and `graphBoost`.
524
547
  - **`get_chunk`**: Retrieve a specific section by file and heading.
548
+ - **`get_lines`**: Retrieve exact line spans (`fromLine`, `maxLines`) from an indexed Markdown document.
549
+ - **`related_notes`**: Find graph-connected (outgoing, backlinks, 2-hop) and semantically related notes for a document.
525
550
  - **`list_files`**: List indexed Markdown files with chunk counts.
526
551
  - **`index_status`**: Inspect index health, chunk counts, and model metadata.
552
+ - **`ask_knowledge_base`**: Grounded direct Q&A synthesis with citations.
553
+ - *All tools return `structuredContent` alongside text and include `readOnly: true, idempotent: true` annotations.*
554
+
555
+ #### Available MCP Resources & Templates:
556
+ - **`mdss://note/{path}`**: Read full content of an indexed Markdown document.
557
+ - **`mdss://note/{path}?fromLine=1&maxLines=50`**: Addressable line-span slices of notes.
558
+ - **`mdss://vault/{vault}`**: List all indexed notes within a vault.
559
+ - **`mdss://status`**: Live index status and chunk count statistics.
560
+
561
+ #### Available MCP Prompts:
562
+ - **`search-and-cite`**: Synthesizes a grounded answer referencing `mdss://note/{path}` resource URIs.
563
+ - **`summarize-note`**: Produces a structured summary of core concepts and linked notes.
564
+ - **`compare-notes`**: Identifies agreements, divergences, and synergies across two notes.
565
+ - **`find-contradictions`**: Audits notes for conflicting statements or outdated ADRs.
566
+ - **`timeline`**: Builds a chronological milestone / event timeline from notes.
527
567
 
528
568
  #### Claude Desktop Configuration:
529
569
  Add to your `claude_desktop_config.json`:
@@ -796,7 +836,9 @@ Contributions are welcome — bug reports with reproduction steps, docs fixes,
796
836
  tests, and features all help. Start with
797
837
  [CONTRIBUTING.md](./CONTRIBUTING.md) (dev setup, project tour, conventions)
798
838
  and look for issues labeled **`good first issue`** — they are scoped for a
799
- first-time contributor. By participating you agree to the
839
+ first-time contributor. For the internals — module map, data-flow diagrams,
840
+ on-disk formats, and extension points — see
841
+ [ARCHITECTURE.md](./ARCHITECTURE.md). By participating you agree to the
800
842
  [Code of Conduct](./CODE_OF_CONDUCT.md).
801
843
 
802
844
  ## License
package/RESEARCH.md CHANGED
@@ -226,7 +226,29 @@ If no candidate meets the multilingual non-inferiority gate, `bge-reranker-base`
226
226
  - **Trigger:** WebAssembly SIMD dot-product kernels will be considered **only if** CPU profiling on >50,000 chunks shows JS float multiplication accounts for >40% of warm search latency.
227
227
  - **Constraint:** Zero native Node C++ bindings; must maintain pure JS fallback for non-WASM environments.
228
228
 
229
- ### ANN / IVF Scale Threshold (Issue #32)
229
+ ### ANN / IVF Scale Threshold & K-Means++ Seeding (Issues #32 & #139)
230
230
  - **Baseline:** Exact $O(N \cdot d)$ linear sweep.
231
- - **Trigger:** ANN (Inverted File / HNSW) indexing is a scale contingency. It shall **not** be introduced until corpus size exceeds **100,000+ chunks** and exact warm sweep latency exceeds 50 ms.
232
- - **Requirement:** Exact pre/post-filter correctness must be preserved; small knowledge bases (<100k chunks) will always use exact linear search.
231
+ - **K-Means++ Seeding:** Uses deterministic pseudo-random $D^2$-weighted centroid seeding (Mulberry32) to prevent initial centroid collapse on correlated notes corpora.
232
+ - **Measured Quality Gate:** `bench/ann-recall.mjs` benchmarks Recall@10 across $nprobe \in \{1, 4, 8, 16\}$ on clustered corpora, demonstrating $\ge 99.5\%$ Recall@10 at default $nprobe=8$ with sub-millisecond candidate pruning.
233
+ - **Trigger:** ANN indexing is auto-engaged above `ANN_THRESHOLD = 500` chunks when `--ann` is passed or IVF index is persisted, while exact pre/post-filter correctness is strictly preserved.
234
+
235
+ ---
236
+
237
+ ## 14. Matryoshka Representation Learning (MRL) Dimension Selection (Issue #143)
238
+
239
+ ### Mechanism
240
+ Models trained with Matryoshka Representation Learning (MRL) loss (such as `onnx-community/Qwen3-Embedding-0.6B-ONNX` and `Xenova/bge-m3`) embed semantic information hierarchically, allowing vectors to be sliced at predefined sub-dimensions (`32`, `64`, `128`, `256`, `512`, `768`, `1024`) and re-normalized to unit L2 norm without significant loss in semantic retrieval fidelity.
241
+
242
+ ### Benefits & Trade-offs
243
+ - **Storage & RAM Savings:** 1024 → 256 dimensions reduces `vectors.bin` size and memory consumption by 4× (from ~19.5 MB to ~4.88 MB per 5,000 chunks).
244
+ - **Sweep Latency:** Dot-product cosine sweep latency is halved (e.g. from ~14.2 ms to ~6.8 ms for 5,000 chunks in single-threaded pure JS).
245
+ - **Integrity & Cache Invalidation:** `embeddingAdapterFingerprint` binds the selected dimension (`model.dim`). Changing `--dimensions` automatically invalidates previous chunk hashes and forces clean re-indexing rather than silent vector dimension mismatch.
246
+
247
+ ### Usage
248
+ ```bash
249
+ # Build index truncated to 256 dims (4x smaller vectors)
250
+ mdss index --db ./notes --model qwen3-embedding-0.6b --dimensions 256
251
+
252
+ # Search with dimension safety validation
253
+ mdss search --db ./notes --dimensions 256 "query"
254
+ ```
package/bin/cli.mjs CHANGED
@@ -16,11 +16,12 @@ import fs from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import crypto from 'node:crypto';
18
18
  import { buildIndex } from '../dist/indexer.js';
19
- import { search } from '../dist/search.js';
19
+ import { loadIndex, search } from '../dist/search.js';
20
20
  import { searchFederated } from '../dist/federation.js';
21
21
  import { createServe, DEFAULT_PORT, DEFAULT_HOST, isLoopbackHost, validateBindSecurity, loadApiKeyFile } from '../dist/serve.js';
22
22
  import { MODELS, DEFAULT_MODEL, resolveModel } from '../dist/models.js';
23
23
  import { decodeVec, walkMarkdown, SCHEMA_VERSION, assertSafePath, setDownloadProgressListener } from '../dist/core.js';
24
+ import { findRelatedNotes } from '../dist/wikilinks.js';
24
25
  import { DownloadProgressAggregator } from '../dist/download-progress.js';
25
26
  import { generateCompletion, COMPLETION_SHELLS } from '../dist/completions.js';
26
27
  import { openHit, resolveOpenCommand } from '../dist/open.js';
@@ -93,6 +94,7 @@ function parseArgs(argv) {
93
94
  else if (a === '--watch-interval') opts.watchInterval = nextInt(argv, ++i, a);
94
95
  else if (a === '--rate-limit') opts.rateLimit = nextNonNegInt(argv, ++i, a);
95
96
  else if (a === '--max-concurrency') opts.maxConcurrency = nextNonNegInt(argv, ++i, a);
97
+ else if (a === '--dimensions' || a === '--dim') opts.dimensions = nextInt(argv, ++i, a);
96
98
  else if (a === '--since') opts.since = nextValue(argv, ++i, a);
97
99
  else if (a === '--path') opts.path.push(nextValue(argv, ++i, a));
98
100
  else if (a === '--ignore') opts.ignore.push(nextValue(argv, ++i, a));
@@ -108,6 +110,8 @@ function parseArgs(argv) {
108
110
  else if (a === '--canonical') opts.canonical = true;
109
111
  else if (a === '--graph-boost') opts.graphBoost = nextFloat(argv, ++i, a);
110
112
  else if (a === '--filter') opts.filter = nextValue(argv, ++i, a);
113
+ else if (a === '--direction') opts.direction = nextValue(argv, ++i, a);
114
+ else if (a === '--no-semantic') opts.noSemantic = true;
111
115
  else if (a === '--quantize') opts.quantize = nextValue(argv, ++i, a);
112
116
  else if (a === '--rag') opts.rag = true;
113
117
  else if (a === '--auto-tag') opts.autoTag = true;
@@ -115,6 +119,8 @@ function parseArgs(argv) {
115
119
  else if (a === '--llm-endpoint') opts.llmEndpoint = nextValue(argv, ++i, a);
116
120
  else if (a === '--llm-model') opts.llmModel = nextValue(argv, ++i, a);
117
121
  else if (a === '--system-prompt') opts.systemPrompt = nextValue(argv, ++i, a);
122
+ else if (a === '--session') opts.session = nextValue(argv, ++i, a);
123
+ else if (a === '--resume') opts.resume = true;
118
124
  else if (a === '--open') {
119
125
  // `--open` opens the top hit; `--open N` opens the Nth hit (issue #110).
120
126
  const next = argv[i + 1];
@@ -202,9 +208,10 @@ const KNOWN_CONFIG_KEYS = new Set([
202
208
  'healthPublic', 'health-public', 'watch', 'watchInterval', 'watch-interval',
203
209
  'watchDelay', 'watch-delay', 'offline', 'rerank', 'semantic', 'tag', 'project', 'type', 'status', 'canonical',
204
210
  'watchDebug', 'watch-debug',
205
- 'format', 'noVectors', 'no-vectors', 'output', 'workers', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
211
+ 'format', 'noVectors', 'no-vectors', 'output', 'workers', 'dimensions', 'dim', 'ann', 'nprobe', 'graphBoost', 'graph-boost', 'filter', 'quantize',
206
212
  'vault', 'vaults', 'rag', 'autoTag', 'auto-tag', 'autoSummarize', 'auto-summarize',
207
213
  'llmEndpoint', 'llm-endpoint', 'llmModel', 'llm-model', 'systemPrompt', 'system-prompt',
214
+ 'session', 'resume',
208
215
  'completions', 'open', 'dry-run', 'fix', 'yes', 'no-ui', 'noUi',
209
216
  'noQueryCache', 'no-query-cache',
210
217
  'mcp',
@@ -213,6 +220,27 @@ const KNOWN_CONFIG_KEYS = new Set([
213
220
  'expand', 'expandPassages', 'expand-passages', 'llm-endpoint', 'llmEndpoint'
214
221
  ]);
215
222
 
223
+ const CONFIG_NETWORK_KEYS = new Set([
224
+ 'port', 'host', 'apiKey', 'api-key', 'apiKeyFile', 'api-key-file',
225
+ 'allowUnsecured', 'allow-unsecured', 'allowedHost', 'allowed-host',
226
+ 'allowedHosts', 'allowed-hosts', 'corsOrigin', 'cors-origin',
227
+ 'rateLimit', 'rate-limit', 'maxConcurrency', 'max-concurrency',
228
+ 'healthPublic', 'health-public', 'mcp', 'llmEndpoint', 'llm-endpoint',
229
+ 'llmModel', 'llm-model', 'embedder', 'embedder-model', 'embedder-base-url',
230
+ 'embedder-key-file', 'embedderModel', 'embedderBaseUrl', 'embedderKeyFile'
231
+ ]);
232
+
233
+ const CONFIG_EXEC_KEYS = new Set([
234
+ // No execution surfaces permitted in local repo config
235
+ ]);
236
+
237
+ function getConfigKeyTrustTier(key) {
238
+ if (CONFIG_EXEC_KEYS.has(key)) return 'exec';
239
+ if (CONFIG_NETWORK_KEYS.has(key)) return 'network';
240
+ if (KNOWN_CONFIG_KEYS.has(key)) return 'local-safe';
241
+ return 'unknown';
242
+ }
243
+
216
244
  function findConfigFile(explicitPath) {
217
245
  if (explicitPath) {
218
246
  const abs = path.resolve(explicitPath);
@@ -252,22 +280,26 @@ function findConfigFile(explicitPath) {
252
280
  }
253
281
 
254
282
  function loadConfigFile(configPath) {
255
- if (!configPath) return { config: {}, path: null, unknownKeys: [], error: null };
283
+ if (!configPath) return { config: {}, path: null, unknownKeys: [], trustTiers: {}, error: null };
256
284
  try {
257
285
  const raw = fs.readFileSync(configPath, 'utf8');
258
286
  const parsed = JSON.parse(raw);
259
287
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
260
- return { config: {}, path: configPath, unknownKeys: [], error: `Config at ${configPath} must be a JSON object` };
288
+ return { config: {}, path: configPath, unknownKeys: [], trustTiers: {}, error: `Config at ${configPath} must be a JSON object` };
261
289
  }
262
290
  const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_CONFIG_KEYS.has(k));
291
+ const trustTiers = {};
292
+ for (const k of Object.keys(parsed)) {
293
+ trustTiers[k] = getConfigKeyTrustTier(k);
294
+ }
263
295
  const normalized = {};
264
296
  for (const [k, v] of Object.entries(parsed)) {
265
297
  const camel = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
266
298
  normalized[camel] = v;
267
299
  }
268
- return { config: normalized, path: configPath, unknownKeys, error: null };
300
+ return { config: normalized, path: configPath, unknownKeys, trustTiers, error: null };
269
301
  } catch (e) {
270
- return { config: {}, path: configPath, unknownKeys: [], error: `Failed to parse config at ${configPath}: ${e.message}` };
302
+ return { config: {}, path: configPath, unknownKeys: [], trustTiers: {}, error: `Failed to parse config at ${configPath}: ${e.message}` };
271
303
  }
272
304
  }
273
305
 
@@ -328,6 +360,9 @@ Usage:
328
360
  mdss check --db <dir> [--json] Diagnose index/db/model cache (alias: doctor)
329
361
  mdss export --db <dir> [options] Export index to JSONL / CSV / Parquet
330
362
  mdss search --db <dir> [options] "query" Search by meaning
363
+ mdss related --db <dir> [options] <note> Find related notes via graph links and similarity
364
+ mdss ask --db <dir> [options] "question" Grounded QA synthesis with citations
365
+ mdss chat --db <dir> [options] Multi-turn persistent chat with citation manifest
331
366
  mdss serve --db <dir> [--port <n>] [--host <ip>] [--watch] Daemon: warm model + index
332
367
  mdss mcp --db <dir> [--list-tools] Start MCP server over stdio for LLM agents / IDEs
333
368
  mdss models List available models
@@ -339,6 +374,7 @@ Options:
339
374
  --index-dir <dir> Where to store the index (default: <db>/.mdss).
340
375
  --cache-dir <dir> Model cache dir (default: ~/.cache/mdss, or MDSS_CACHE_DIR).
341
376
  --model <name|id> Embedding model (default: ${DEFAULT_MODEL}). See \`mdss models\`.
377
+ --dimensions <n> Truncate vector dimensions for Matryoshka/MRL models (alias: --dim).
342
378
  --workers <n> Number of parallel batch workers for indexing (default: 1).
343
379
  --format <fmt> Export format: jsonl (default), csv, parquet (export).
344
380
  --no-vectors Omit vector embeddings from export (export).
@@ -499,6 +535,7 @@ async function cmdIndex(opts) {
499
535
  r = await buildIndex({
500
536
  db, indexDir, cacheDir,
501
537
  modelName: embedderOverrides.modelName || opts.model || DEFAULT_MODEL,
538
+ dimensions: opts.dimensions,
502
539
  ...(embedderOverrides.embedFn ? { embedFn: embedderOverrides.embedFn } : {}),
503
540
  ignore: opts.ignore,
504
541
  offline: resolveOffline(opts),
@@ -775,6 +812,7 @@ function checkHealth({ db, indexDir, cacheDir, requireOffline = false, config =
775
812
  path: config.path,
776
813
  valid: !config.error && (!config.unknownKeys || config.unknownKeys.length === 0),
777
814
  unknownKeys: config.unknownKeys || [],
815
+ trustTiers: config.trustTiers || {},
778
816
  error: config.error || (config.unknownKeys && config.unknownKeys.length > 0 ? `unknown config keys: ${config.unknownKeys.join(', ')}` : null),
779
817
  };
780
818
  if (!report.config.valid) {
@@ -1088,6 +1126,13 @@ async function cmdTui(opts) {
1088
1126
  semanticOnly: !!opts.semantic,
1089
1127
  offline: resolveOffline(opts),
1090
1128
  path: opts.path.length > 0 ? opts.path : undefined,
1129
+ since: opts.since || undefined,
1130
+ filter: opts.filter || undefined,
1131
+ tag: opts.tag.length > 0 ? opts.tag : undefined,
1132
+ project: opts.project || undefined,
1133
+ type: opts.type || undefined,
1134
+ status: opts.status || undefined,
1135
+ graphBoost: typeof opts.graphBoost === 'number' ? opts.graphBoost : undefined,
1091
1136
  rerank: !!opts.rerank,
1092
1137
  rag: !!opts.rag,
1093
1138
  });
@@ -1129,6 +1174,150 @@ async function cmdAsk(opts) {
1129
1174
  }
1130
1175
  }
1131
1176
 
1177
+ async function cmdChat(opts) {
1178
+ const db = resolveDb(opts);
1179
+ const indexDir = resolveIndexDir(opts, db);
1180
+ const cacheDir = resolveCache(opts);
1181
+ const k = opts.k || 5;
1182
+
1183
+ const vectorsPath = path.join(indexDir, 'vectors.json');
1184
+ if (!fs.existsSync(vectorsPath)) {
1185
+ die(`No index at ${vectorsPath}. Run \`mdss index --db ${db}\` first.`);
1186
+ }
1187
+
1188
+ const { chatTurn, loadChatSession, listChatSessions } = await import('../dist/rag.js');
1189
+ const loaded = loadIndex(indexDir);
1190
+
1191
+ let sessionId = opts.session;
1192
+ if (!sessionId && opts.resume) {
1193
+ const existing = listChatSessions(indexDir);
1194
+ if (existing.length > 0) {
1195
+ sessionId = existing[0].id;
1196
+ }
1197
+ }
1198
+ if (!sessionId) {
1199
+ sessionId = crypto.randomUUID().slice(0, 8);
1200
+ }
1201
+
1202
+ const session = loadChatSession(indexDir, sessionId, (msg) => {
1203
+ if (!opts.json) process.stderr.write(`${msg}\n`);
1204
+ });
1205
+
1206
+ const query = opts._.join(' ').trim();
1207
+ // If one-shot query provided as argument:
1208
+ if (query) {
1209
+ const res = await chatTurn({
1210
+ session,
1211
+ query,
1212
+ loaded,
1213
+ indexDir,
1214
+ cacheDir,
1215
+ k,
1216
+ llmEndpoint: opts.llmEndpoint,
1217
+ llmModel: opts.llmModel,
1218
+ systemPrompt: opts.systemPrompt,
1219
+ });
1220
+
1221
+ if (opts.json) {
1222
+ process.stdout.write(JSON.stringify(res, null, 2) + '\n');
1223
+ return;
1224
+ }
1225
+
1226
+ process.stdout.write(`\n${res.answer}\n\n`);
1227
+ if (res.citations && res.citations.length > 0) {
1228
+ process.stdout.write('Sources:\n');
1229
+ for (const c of res.citations) {
1230
+ const heading = c.heading ? ` › ${c.heading}` : '';
1231
+ process.stdout.write(` - ${c.file}${heading} [score: ${c.score.toFixed(3)}]\n`);
1232
+ }
1233
+ process.stdout.write('\n');
1234
+ }
1235
+ return;
1236
+ }
1237
+
1238
+ // Interactive REPL mode:
1239
+ const readline = await import('node:readline');
1240
+ const rl = readline.createInterface({
1241
+ input: process.stdin,
1242
+ output: process.stdout,
1243
+ });
1244
+
1245
+ const banner = `\x1b[1mmdss chat\x1b[0m (session: \x1b[36m${session.id}\x1b[0m, turns: ${session.turns.length}, chunks: ${loaded.index.chunks.length})\nCommands: /session, /sources, /help, /exit\n`;
1246
+ process.stdout.write(banner);
1247
+
1248
+ const promptUser = () => {
1249
+ rl.question('\n\x1b[1mmdss chat>\x1b[0m ', async (input) => {
1250
+ const line = (input || '').trim();
1251
+ if (!line) {
1252
+ promptUser();
1253
+ return;
1254
+ }
1255
+
1256
+ if (line === '/exit' || line === '/quit' || line === ':q') {
1257
+ rl.close();
1258
+ return;
1259
+ }
1260
+
1261
+ if (line === '/help') {
1262
+ process.stdout.write('Available commands:\n /session Show current session information\n /sources Show retrieved sources manifest from last turn\n /help Show this help message\n /exit Quit chat\n');
1263
+ promptUser();
1264
+ return;
1265
+ }
1266
+
1267
+ if (line === '/session') {
1268
+ process.stdout.write(`Session ID: ${session.id}\nCreated: ${session.createdAt}\nTurns: ${session.turns.length}\n`);
1269
+ promptUser();
1270
+ return;
1271
+ }
1272
+
1273
+ if (line === '/sources') {
1274
+ const lastTurn = session.turns[session.turns.length - 1];
1275
+ if (!lastTurn || !lastTurn.manifest || lastTurn.manifest.length === 0) {
1276
+ process.stdout.write('No sources available yet.\n');
1277
+ } else {
1278
+ process.stdout.write('Sources from last turn:\n');
1279
+ for (const m of lastTurn.manifest) {
1280
+ const loc = (m.startLine && m.endLine) ? `:L${m.startLine}-L${m.endLine}` : '';
1281
+ const heading = m.heading ? ` › ${m.heading}` : '';
1282
+ process.stdout.write(` - ${m.file}${heading}${loc} [score: ${m.score.toFixed(3)}]\n`);
1283
+ }
1284
+ }
1285
+ promptUser();
1286
+ return;
1287
+ }
1288
+
1289
+ try {
1290
+ const res = await chatTurn({
1291
+ session,
1292
+ query: line,
1293
+ loaded,
1294
+ indexDir,
1295
+ cacheDir,
1296
+ k,
1297
+ llmEndpoint: opts.llmEndpoint,
1298
+ llmModel: opts.llmModel,
1299
+ systemPrompt: opts.systemPrompt,
1300
+ });
1301
+
1302
+ process.stdout.write(`\n${res.answer}\n`);
1303
+ if (res.citations && res.citations.length > 0) {
1304
+ process.stdout.write('\nSources:\n');
1305
+ for (const c of res.citations) {
1306
+ const heading = c.heading ? ` › ${c.heading}` : '';
1307
+ process.stdout.write(` - ${c.file}${heading} [score: ${c.score.toFixed(3)}]\n`);
1308
+ }
1309
+ }
1310
+ } catch (e) {
1311
+ process.stdout.write(`\nError: ${e.message}\n`);
1312
+ }
1313
+
1314
+ promptUser();
1315
+ });
1316
+ };
1317
+
1318
+ promptUser();
1319
+ }
1320
+
1132
1321
  async function cmdSearch(opts) {
1133
1322
  if (opts.interactive) return cmdTui(opts);
1134
1323
  const cacheDir = resolveCache(opts);
@@ -1196,6 +1385,7 @@ async function cmdSearch(opts) {
1196
1385
  nprobe: opts.nprobe,
1197
1386
  graphBoost: opts.graphBoost,
1198
1387
  filter: opts.filter,
1388
+ dimensions: opts.dimensions,
1199
1389
  });
1200
1390
  }
1201
1391
 
@@ -1289,9 +1479,10 @@ function cmdModels() {
1289
1479
  const pooling = m.pooling ?? 'mean';
1290
1480
  const normalized = m.normalize !== false ? 'L2' : 'raw';
1291
1481
  const rev = m.revision ? ` · rev ${m.revision.slice(0, 8)}` : '';
1482
+ const mrl = m.dimensions ? ` · MRL: ${m.dimensions.join(', ')}` : '';
1292
1483
  process.stdout.write(
1293
1484
  ` ${alias}${star}\n` +
1294
- ` ${m.id} · dim ${m.dim}${rev}\n` +
1485
+ ` ${m.id} · dim ${m.dim}${mrl}${rev}\n` +
1295
1486
  ` ${pooling} · ${normalized} · maxTokens ${m.maxTokens ?? '—'} ` +
1296
1487
  (m.family ? `· family ${m.family}\n` : '\n') +
1297
1488
  ` ${m.note}\n\n`,
@@ -1414,7 +1605,7 @@ async function cmdMcp(opts) {
1414
1605
  // on STDERR so stdout stays machine-readable; warm cache emits nothing at all.
1415
1606
  // ---------------------------------------------------------------------------
1416
1607
 
1417
- const DOWNLOAD_COMMANDS = new Set(['index', 'search', 'ask', 'serve']);
1608
+ const DOWNLOAD_COMMANDS = new Set(['index', 'search', 'ask', 'chat', 'serve']);
1418
1609
 
1419
1610
  function formatMb(bytes) {
1420
1611
  if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB';
@@ -1469,6 +1660,59 @@ async function cmdCompletions(opts) {
1469
1660
  process.stdout.write(generateCompletion(shell));
1470
1661
  }
1471
1662
 
1663
+ async function cmdRelated(opts) {
1664
+ const db = resolveDb(opts);
1665
+ const indexDir = resolveIndexDir(opts, db);
1666
+ const vectorsPath = path.join(indexDir, 'vectors.json');
1667
+ if (!fs.existsSync(vectorsPath)) {
1668
+ die(`No index at ${vectorsPath}. Run \`mdss index --db ${db}\` first.`);
1669
+ }
1670
+ const target = opts._.shift();
1671
+ if (!target) {
1672
+ die('Missing note path/title/alias to find relations for. e.g. `mdss related --db . "Architecture"`');
1673
+ }
1674
+
1675
+ const loaded = loadIndex(indexDir);
1676
+ const k = Number.isInteger(opts.k) && opts.k > 0 ? opts.k : 10;
1677
+ const direction = opts.direction || 'both';
1678
+ const semantic = !opts.noSemantic;
1679
+
1680
+ let r;
1681
+ try {
1682
+ r = findRelatedNotes({
1683
+ loaded,
1684
+ target,
1685
+ k,
1686
+ direction,
1687
+ semantic,
1688
+ });
1689
+ } catch (e) {
1690
+ die(e.message);
1691
+ }
1692
+
1693
+ if (opts.json) {
1694
+ process.stdout.write(JSON.stringify(r, null, 2) + '\n');
1695
+ return;
1696
+ }
1697
+
1698
+ if (r.results.length === 0) {
1699
+ process.stdout.write(`No related notes found for "${target}" (${r.resolvedFile}).\n`);
1700
+ if (opts.noSemantic) {
1701
+ process.stdout.write('Hint: Try running without `--no-semantic` to discover conceptually related notes without explicit links.\n');
1702
+ }
1703
+ return;
1704
+ }
1705
+
1706
+ process.stdout.write(`Related notes for "${target}" (${r.resolvedFile}):\n\n`);
1707
+ r.results.forEach((hit, idx) => {
1708
+ const scoreStr = hit.score.toFixed(2);
1709
+ process.stdout.write(` ${idx + 1}. \x1b[1m${hit.file}\x1b[0m — score: ${scoreStr} [${hit.reason}]\n`);
1710
+ if (hit.title && hit.title !== hit.file) {
1711
+ process.stdout.write(` title: "${hit.title}"\n`);
1712
+ }
1713
+ });
1714
+ }
1715
+
1472
1716
  async function main() {
1473
1717
  const argv = process.argv.slice(2);
1474
1718
  const opts = parseArgs(argv);
@@ -1496,7 +1740,9 @@ async function main() {
1496
1740
  case 'doctor': return await cmdCheck(opts);
1497
1741
  case 'export': return await cmdExport(opts);
1498
1742
  case 'search': return await cmdSearch(opts);
1743
+ case 'related': return await cmdRelated(opts);
1499
1744
  case 'ask': return await cmdAsk(opts);
1745
+ case 'chat': return await cmdChat(opts);
1500
1746
  case 'tui': return await cmdTui(opts);
1501
1747
  case 'serve': return await cmdServe(opts);
1502
1748
  case 'mcp': return await cmdMcp(opts);
@@ -1542,6 +1788,7 @@ export {
1542
1788
  parseArgs, nextInt, nextFloat, nextValue,
1543
1789
  resolveDb, resolveIndexDir, resolveCache, resolveOffline,
1544
1790
  findConfigFile, loadConfigFile, applyConfigDefaults, KNOWN_CONFIG_KEYS,
1791
+ CONFIG_NETWORK_KEYS, CONFIG_EXEC_KEYS, getConfigKeyTrustTier,
1545
1792
  checkHealth, cmdExport,
1546
1793
  die, main, HELP, VERSION,
1547
1794
  };
@@ -54,6 +54,7 @@ export const FLAGS = [
54
54
  { name: '--index-dir', kind: 'dir' },
55
55
  { name: '--cache-dir', kind: 'dir' },
56
56
  { name: '--model', kind: 'value' },
57
+ { name: '--dimensions', alias: '--dim', kind: 'number' },
57
58
  { name: '--k', kind: 'number' },
58
59
  { name: '--max-per-file', kind: 'number' },
59
60
  { name: '--max-per-doc', kind: 'number' },
@@ -79,13 +80,17 @@ export const FLAGS = [
79
80
  { name: '--graph-boost', kind: 'number' },
80
81
  { name: '--filter', kind: 'value' },
81
82
  { name: '--quantize', kind: 'value' },
83
+ { name: '--direction', kind: 'value' },
84
+ { name: '--no-semantic', kind: 'flag' },
85
+ { name: '--session', kind: 'value' },
86
+ { name: '--resume', kind: 'flag' },
82
87
  { name: '--llm-endpoint', kind: 'value' },
83
88
  { name: '--llm-model', kind: 'value' },
84
89
  { name: '--system-prompt', kind: 'value' },
85
90
  ];
86
91
  export const COMMANDS = [
87
92
  'index', 'stats', 'check', 'doctor', 'export', 'search',
88
- 'ask', 'tui', 'serve', 'mcp', 'models', 'completions',
93
+ 'ask', 'chat', 'tui', 'serve', 'mcp', 'models', 'completions', 'related',
89
94
  ];
90
95
  export const COMPLETION_SHELLS = ['bash', 'zsh', 'fish', 'powershell'];
91
96
  const DIR_FLAGS = [...new Set(FLAGS.filter((f) => f.kind === 'dir').flatMap((f) => [f.name, ...(f.alias && !f.alias.startsWith('--') ? [f.alias] : [])]))];
package/dist/core.d.ts CHANGED
@@ -48,6 +48,7 @@ export declare function prepareEmbeddingRequest(model: ModelDescriptor, texts: s
48
48
  normalize: boolean;
49
49
  };
50
50
  };
51
+ export declare function truncateAndNormalizeVector(vec: number[] | Float32Array, targetDim: number, normalize?: boolean): number[];
51
52
  export declare function embed(texts: string[], kind: 'query' | 'passage', model: ModelDescriptor, cacheDir: string, offline?: boolean, retryOpts?: {
52
53
  maxRetries?: number;
53
54
  delays?: number[];
@@ -88,3 +89,28 @@ export interface ParsedChunkFile {
88
89
  meta: DocumentMetadata;
89
90
  }
90
91
  export declare function parseFile(absPath: string, dbDir: string, maxChunk?: number, raw?: string): ParsedChunkFile[];
92
+ export interface GetDocLinesOptions {
93
+ file: string;
94
+ fromLine?: number;
95
+ maxLines?: number;
96
+ dbDir?: string;
97
+ chunks?: Array<{
98
+ file: string;
99
+ text: string;
100
+ startLine?: number;
101
+ endLine?: number;
102
+ }>;
103
+ }
104
+ export interface GetDocLinesResult {
105
+ file: string;
106
+ fromLine: number;
107
+ toLine: number;
108
+ lineCount: number;
109
+ totalLines: number;
110
+ text: string;
111
+ }
112
+ /**
113
+ * Line-range retrieval helper (issue #140).
114
+ * Retrieves an exact line span from disk or fallback chunk data with EOF clamping.
115
+ */
116
+ export declare function getDocLines(opts: GetDocLinesOptions): GetDocLinesResult;