@staix/vault-mem 0.3.0 → 0.3.2
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/CHANGELOG.md +17 -1
- package/README.md +23 -5
- package/package.json +1 -1
- package/src/cli.ts +52 -11
- package/src/commands-table.ts +5 -2
- package/src/config.ts +28 -8
- package/src/index.ts +8 -3
- package/src/log-commands.ts +1 -1
- package/src/mcp-vault.ts +6 -2
- package/src/mcp.ts +44 -19
- package/src/org-promote.ts +1 -14
- package/src/registry.ts +63 -2
- package/src/spec-graph.ts +117 -0
- package/src/spec-source.ts +204 -0
- package/src/sync.ts +17 -5
- package/src/tidy.ts +7 -1
- package/src/vault-index.ts +159 -97
- package/src/write-commands.ts +8 -3
- package/src/write-operations.ts +46 -1
- package/src/write-validation.ts +20 -8
package/src/vault-index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { realPath } from "./platform.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync } from "node:fs";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
6
|
import type { AnalysisSnapshot } from "./analysis-types.ts";
|
|
6
|
-
import type { VaultSpec } from "./config.ts";
|
|
7
|
-
import { parseFrontmatter } from "./frontmatter.ts";
|
|
7
|
+
import type { VaultSpec, SpecSourceSpec } from "./config.ts";
|
|
8
|
+
import { parseFrontmatter, type FrontmatterDocument } from "./frontmatter.ts";
|
|
8
9
|
import { normalizeTarget, noteKey, proseLines, resolveLinks, type LinkNote, type ResolvedLink } from "./links.ts";
|
|
9
10
|
|
|
10
11
|
export const MAX_NOTE_BYTES = 2 * 1024 * 1024;
|
|
@@ -41,13 +42,13 @@ function within(root: string, target: string): boolean {
|
|
|
41
42
|
const rel = relative(root, target);
|
|
42
43
|
return rel === "" || (!rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && rel !== ".." && !isAbsolute(rel));
|
|
43
44
|
}
|
|
44
|
-
function canonicalLocation(location: string): string {
|
|
45
|
-
try { return
|
|
45
|
+
function canonicalLocation(location: string, canonicalize: (path: string) => string = realpathSync): string {
|
|
46
|
+
try { return canonicalize(location); }
|
|
46
47
|
catch (error) {
|
|
47
48
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
48
49
|
const parent = dirname(location);
|
|
49
50
|
if (parent === location) throw error;
|
|
50
|
-
return join(canonicalLocation(parent), relative(parent, location));
|
|
51
|
+
return join(canonicalLocation(parent, canonicalize), relative(parent, location));
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
export function resultLimit(value = 50): number {
|
|
@@ -61,6 +62,15 @@ function aliases(value: unknown): string[] {
|
|
|
61
62
|
function metadata(stat: NonNullable<ReturnType<typeof lstatSync>>): string {
|
|
62
63
|
return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
|
|
63
64
|
}
|
|
65
|
+
/** Adapter file I/O gets path-specific guidance without rewriting unrelated Git or binding errors. */
|
|
66
|
+
function specFileIO<T>(path: string, action: string, operation: () => T): T {
|
|
67
|
+
try { return operation(); }
|
|
68
|
+
catch (error) {
|
|
69
|
+
const code = (error as NodeJS.ErrnoException)?.code ?? "I/O failure";
|
|
70
|
+
const advice = action === "close" ? "retry the scan and inspect local file I/O if the failure repeats" : "restore read access and a stable file, then retry";
|
|
71
|
+
throw new Error(`spec discovery ${path}: cannot ${action} Markdown (${code}); ${advice}`, { cause: error });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
64
74
|
function boundedText(value: string, bytes: number): { content: string; truncated: boolean } {
|
|
65
75
|
const buffer = Buffer.from(value);
|
|
66
76
|
if (buffer.length <= bytes) return { content: value, truncated: false };
|
|
@@ -104,69 +114,90 @@ function headingMetadata(sections: { name: string }[]): Pick<NoteReadResult, "he
|
|
|
104
114
|
}
|
|
105
115
|
return { headings, ...(truncated ? { headingsTruncated: true } : {}) };
|
|
106
116
|
}
|
|
117
|
+
/** A source changes discovery and record selection, while sharing scan storage and BM25. */
|
|
118
|
+
export interface IndexSourceAdapter {
|
|
119
|
+
validate(): void;
|
|
120
|
+
paths(): string[];
|
|
121
|
+
validatePath(path: string): void;
|
|
122
|
+
accept(path: string, document: FrontmatterDocument, hash: string): boolean;
|
|
123
|
+
invalid(path: string): void;
|
|
124
|
+
}
|
|
125
|
+
export class IndexCacheError extends Error {}
|
|
126
|
+
export interface IndexOptions { memory?: boolean; adapter?: IndexSourceAdapter; reuseCache?: boolean }
|
|
107
127
|
/** Derived cache only. No vault or application configuration files are written. */
|
|
108
|
-
export class VaultIndex {
|
|
128
|
+
export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
|
|
109
129
|
private readonly db: Database;
|
|
110
130
|
readonly file: string;
|
|
111
131
|
private readonly root: string;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
132
|
+
private readonly adapter?: IndexSourceAdapter;
|
|
133
|
+
constructor(home: string, readonly vault: Source, vaultRoots: string[] = [vault.path], options: IndexOptions = {}) {
|
|
134
|
+
this.adapter = options.adapter;
|
|
135
|
+
this.root = this.adapter ? realPath(vault.path) : realpathSync(vault.path);
|
|
136
|
+
if (this.root !== vault.path) throw new Error(this.adapter ? "spec index: worktree path changed; resolve the source again with resolveSpecWorktree" : `vault ${vault.id}: registered path changed`);
|
|
115
137
|
const notes = join(this.root, "notes");
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
} else this.file = ":memory:";
|
|
133
|
-
this.db = new Database(this.file);
|
|
134
|
-
this.db.run("PRAGMA busy_timeout=5000; PRAGMA journal_mode=DELETE;");
|
|
135
|
-
this.db.run(`CREATE TABLE IF NOT EXISTS notes (
|
|
136
|
-
path TEXT PRIMARY KEY, hash TEXT NOT NULL, metadata TEXT NOT NULL,
|
|
137
|
-
title TEXT NOT NULL, description TEXT NOT NULL, body TEXT NOT NULL,
|
|
138
|
-
type TEXT NOT NULL, domain TEXT NOT NULL, aliases TEXT NOT NULL, source TEXT NOT NULL);
|
|
139
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(path UNINDEXED, title, description, body, tokenize='unicode61');
|
|
140
|
-
CREATE TABLE IF NOT EXISTS analysis_notes (path TEXT PRIMARY KEY, source TEXT NOT NULL, hash TEXT NOT NULL, metadata TEXT NOT NULL);
|
|
141
|
-
CREATE TABLE IF NOT EXISTS links (source TEXT NOT NULL, ordinal INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(source, ordinal));`);
|
|
142
|
-
// Dry runs reuse the derived cache via a readonly connection, then refresh only changed notes in memory.
|
|
143
|
-
if (options.memory) {
|
|
144
|
-
const file = join(cache, indexName(this.root));
|
|
145
|
-
let exists = false;
|
|
146
|
-
try { const stat = lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("index: expected plain database file"); exists = true; }
|
|
147
|
-
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
148
|
-
if (exists) {
|
|
149
|
-
if (realpathSync(cache) !== cache) throw new Error("index: linked cache directory");
|
|
150
|
-
const disk = new Database(file, { readonly: true });
|
|
138
|
+
if (this.adapter) this.adapter.validate();
|
|
139
|
+
else if (realpathSync(notes) !== notes || !lstatSync(notes).isDirectory()) throw new Error(`vault ${vault.id}: notes must be a plain directory`);
|
|
140
|
+
try {
|
|
141
|
+
const cacheName = this.adapter ? `spec-${indexName(this.root)}` : indexName(this.root);
|
|
142
|
+
const cache = resolve(home, "indexes");
|
|
143
|
+
const noDisk = options.memory && options.reuseCache === false;
|
|
144
|
+
const canonicalize = this.adapter ? realPath : realpathSync;
|
|
145
|
+
const canonical = noDisk ? cache : canonicalLocation(cache, canonicalize);
|
|
146
|
+
for (const root of noDisk ? [] : vaultRoots) {
|
|
147
|
+
if (within(canonicalLocation(resolve(root), canonicalize), canonical)) throw new Error(this.adapter ? "spec index: cache is inside a protected source; choose VMEM_HOME outside every repository and vault" : "index: VMEM_HOME cache must be outside every vault");
|
|
148
|
+
}
|
|
149
|
+
// Canonical home resolution is allowed, but a linked cache directory/file is not.
|
|
150
|
+
if (!options.memory) {
|
|
151
|
+
mkdirSync(cache, { recursive: true, mode: 0o700 });
|
|
152
|
+
if (lstatSync(cache).isSymbolicLink() || !lstatSync(cache).isDirectory()) throw new Error("index: expected plain cache directory");
|
|
153
|
+
this.file = join(cache, cacheName);
|
|
151
154
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
155
|
+
const fd = openSync(this.file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
156
|
+
closeSync(fd);
|
|
157
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
158
|
+
if (!lstatSync(this.file).isFile() || lstatSync(this.file).isSymbolicLink() || (this.adapter && lstatSync(this.file).nlink !== 1)) throw new Error("index: expected plain database file");
|
|
159
|
+
} else this.file = ":memory:";
|
|
160
|
+
this.db = new Database(this.file);
|
|
161
|
+
this.db.run("PRAGMA busy_timeout=5000; PRAGMA journal_mode=DELETE;");
|
|
162
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS notes (
|
|
163
|
+
path TEXT PRIMARY KEY, hash TEXT NOT NULL, metadata TEXT NOT NULL,
|
|
164
|
+
title TEXT NOT NULL, description TEXT NOT NULL, body TEXT NOT NULL,
|
|
165
|
+
type TEXT NOT NULL, domain TEXT NOT NULL, aliases TEXT NOT NULL, source TEXT NOT NULL);
|
|
166
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(path UNINDEXED, title, description, body, tokenize='unicode61');
|
|
167
|
+
CREATE TABLE IF NOT EXISTS analysis_notes (path TEXT PRIMARY KEY, source TEXT NOT NULL, hash TEXT NOT NULL, metadata TEXT NOT NULL);
|
|
168
|
+
CREATE TABLE IF NOT EXISTS links (source TEXT NOT NULL, ordinal INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(source, ordinal));`);
|
|
169
|
+
// Dry runs reuse the derived cache via a readonly connection, then refresh only changed notes in memory.
|
|
170
|
+
if (options.memory && options.reuseCache !== false) {
|
|
171
|
+
const file = join(cache, cacheName);
|
|
172
|
+
let exists = false;
|
|
173
|
+
try { const stat = lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink() || (this.adapter && stat.nlink !== 1)) throw new Error("index: expected plain database file"); exists = true; }
|
|
174
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
175
|
+
if (exists) {
|
|
176
|
+
if (canonicalize(cache) !== cache) throw new Error("index: linked cache directory");
|
|
177
|
+
const disk = new Database(file, { readonly: true });
|
|
178
|
+
try {
|
|
179
|
+
disk.transaction(() => {
|
|
180
|
+
const size = disk.query<{count:number;bytes:number}, []>("SELECT count(*) AS count, coalesce(sum(length(CAST(source AS BLOB))),0) AS bytes FROM notes").get()!;
|
|
164
181
|
if (size.count > 10000 || size.bytes > 64 * 1024 * 1024) throw new Error("analysis: cached snapshot exceeds bound");
|
|
165
|
-
for (const row of disk.query<{
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
182
|
+
for (const row of disk.query<Omit<StoredNote,"aliases"> & {aliases:string}, []>("SELECT * FROM notes").all()) {
|
|
183
|
+
this.db.query("INSERT INTO notes VALUES (?,?,?,?,?,?,?,?,?,?)").run(row.path,row.hash,row.metadata,row.title,row.description,row.body,row.type,row.domain,row.aliases,row.source);
|
|
184
|
+
this.db.query("INSERT INTO fts VALUES (?,?,?,?)").run(row.path,row.title,row.description,row.body);
|
|
185
|
+
}
|
|
186
|
+
for (const row of disk.query<{source:string;ordinal:number;data:string}, []>("SELECT * FROM links").all()) this.db.query("INSERT INTO links VALUES (?,?,?)").run(row.source,row.ordinal,row.data);
|
|
187
|
+
this.db.run(`PRAGMA user_version = ${Number(disk.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0)}`);
|
|
188
|
+
const exists = disk.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='analysis_notes'").get();
|
|
189
|
+
if (exists) {
|
|
190
|
+
const size = disk.query<{count:number;bytes:number}, []>("SELECT count(*) AS count, coalesce(sum(length(CAST(source AS BLOB))),0) AS bytes FROM analysis_notes").get()!;
|
|
191
|
+
if (size.count > 10000 || size.bytes > 64 * 1024 * 1024) throw new Error("analysis: cached snapshot exceeds bound");
|
|
192
|
+
for (const row of disk.query<{path:string;source:string;hash:string;metadata:string}, []>("SELECT * FROM analysis_notes").all()) this.db.query("INSERT INTO analysis_notes VALUES (?,?,?,?)").run(row.path,row.source,row.hash,row.metadata);
|
|
193
|
+
}
|
|
194
|
+
})();
|
|
195
|
+
} finally { disk.close(); }
|
|
196
|
+
}
|
|
169
197
|
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (this.adapter) throw new IndexCacheError(error instanceof Error ? error.message : "cache initialization failed", { cause: error });
|
|
200
|
+
throw error;
|
|
170
201
|
}
|
|
171
202
|
}
|
|
172
203
|
close(): void { this.db.close(); }
|
|
@@ -177,62 +208,91 @@ export class VaultIndex {
|
|
|
177
208
|
scan(rebuild = false, options: { skipInvalidFrontmatter?: boolean; captureInvalidFrontmatter?: boolean } = {}): { notes: number; changed: number; removed: number; skipped?: string[] } {
|
|
178
209
|
return this.db.transaction(() => {
|
|
179
210
|
const notesDir = join(this.root, "notes");
|
|
180
|
-
if (
|
|
181
|
-
|
|
211
|
+
if (this.adapter) this.adapter.validate();
|
|
212
|
+
else if (realpathSync(this.root) !== this.root || realpathSync(notesDir) !== notesDir || !lstatSync(notesDir).isDirectory()) throw new Error(`vault ${this.vault.id}: path or notes directory changed`);
|
|
213
|
+
const indexed = this.adapter ? new Set(this.db.query<{path:string}, []>("SELECT path FROM notes").all().map(row => row.path)) : undefined;
|
|
214
|
+
const previous = new Map(this.db.query<{ path: string; hash: string; metadata: string }, []>(this.adapter ? "SELECT path,hash,metadata FROM analysis_notes" : options.captureInvalidFrontmatter ? "SELECT path,hash,metadata FROM notes UNION SELECT path,hash,metadata FROM analysis_notes" : "SELECT path,hash,metadata FROM notes").all().map(note => [note.path, note]));
|
|
182
215
|
if (rebuild) this.db.run("DELETE FROM fts");
|
|
183
216
|
const live = new Set<string>(), present = new Set<string>();
|
|
184
217
|
let changed = 0, analysisBytes = 0;
|
|
185
218
|
const skipped: string[] = [], touched = new Set<string>(), keys = new Set<string>();
|
|
186
219
|
// Links resolve by path only, so other notes re-resolve only when they target a touched path.
|
|
187
220
|
const touch = (path: string) => { touched.add(path); keys.add(noteKey(path)); };
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
221
|
+
const paths = this.adapter ? this.adapter.paths() : readdirSync(notesDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).filter(entry => entry.name.endsWith(".md") && entry.isFile()).map(entry => entry.name);
|
|
222
|
+
for (const path of paths) {
|
|
223
|
+
this.adapter?.validatePath(path);
|
|
224
|
+
const file = join(this.adapter ? this.root : notesDir, path);
|
|
225
|
+
const before = this.adapter ? specFileIO(path, "inspect", () => lstatSync(file)) : lstatSync(file);
|
|
226
|
+
if (!before.isFile() || before.isSymbolicLink()) throw new Error(this.adapter ? `spec discovery ${path}: selected path changed; restore a plain file and retry` : `vault ${this.vault.id}: note path changed`);
|
|
227
|
+
if (before.size > MAX_NOTE_BYTES) throw new Error(this.adapter ? `spec discovery ${path}: Markdown exceeds ${MAX_NOTE_BYTES} bytes; split the file before rebuilding` : `vault ${this.vault.id}, ${path}: note exceeds ${MAX_NOTE_BYTES} bytes`);
|
|
193
228
|
live.add(path); present.add(path);
|
|
194
|
-
if (options.captureInvalidFrontmatter) {
|
|
229
|
+
if (options.captureInvalidFrontmatter || this.adapter) {
|
|
195
230
|
analysisBytes += before.size;
|
|
196
|
-
if (present.size > 10000 || analysisBytes > 64 * 1024 * 1024) throw new Error("analysis: snapshot exceeds bound");
|
|
231
|
+
if (present.size > 10000 || analysisBytes > 64 * 1024 * 1024) throw new Error(this.adapter ? `spec discovery ${path}: more than 10000 Markdown files or 64 MiB of input; ignore large or vendored Markdown via .gitignore and untrack already-tracked excluded files with git rm --cached before rebuilding` : "analysis: snapshot exceeds bound");
|
|
197
232
|
}
|
|
198
233
|
const old = previous.get(path), stamp = metadata(before);
|
|
199
|
-
if (!rebuild && old?.metadata === stamp && this.db.query("SELECT 1 FROM analysis_notes WHERE path=?").get(path)) continue;
|
|
200
|
-
const
|
|
201
|
-
let
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
234
|
+
if (!this.adapter && !rebuild && old?.metadata === stamp && this.db.query("SELECT 1 FROM analysis_notes WHERE path=?").get(path)) continue;
|
|
235
|
+
const cached = this.adapter && !rebuild && old?.metadata === stamp ? this.db.query<{source:string}, [string]>("SELECT source FROM analysis_notes WHERE path=?").get(path) : null;
|
|
236
|
+
let source: string, hash: string;
|
|
237
|
+
if (cached && old) { source = cached.source; hash = old.hash; }
|
|
238
|
+
else {
|
|
239
|
+
const fd = this.adapter ? specFileIO(path, "open", () => openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)) : openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
240
|
+
let buffer: Buffer;
|
|
241
|
+
try {
|
|
242
|
+
const stat = this.adapter ? specFileIO(path, "inspect", () => fstatSync(fd)) : fstatSync(fd);
|
|
243
|
+
if (!stat.isFile() || stat.size > MAX_NOTE_BYTES || metadata(stat) !== stamp) throw new Error(this.adapter ? `spec discovery ${path}: file changed during scan; retry on a stable worktree` : `vault ${this.vault.id}, ${path}: note changed during scan`);
|
|
244
|
+
// Fixed bound even if a concurrent writer grows the file.
|
|
245
|
+
buffer = Buffer.alloc(MAX_NOTE_BYTES + 1);
|
|
246
|
+
let size = 0;
|
|
247
|
+
for (;;) {
|
|
248
|
+
const count = this.adapter ? specFileIO(path, "read", () => readSync(fd, buffer, size, buffer.length - size, null)) : readSync(fd, buffer, size, buffer.length - size, null);
|
|
249
|
+
size += count;
|
|
250
|
+
if (!count || size === buffer.length) break;
|
|
251
|
+
}
|
|
252
|
+
buffer = buffer.subarray(0, size);
|
|
253
|
+
if (size > MAX_NOTE_BYTES || metadata(this.adapter ? specFileIO(path, "inspect", () => fstatSync(fd)) : fstatSync(fd)) !== stamp) throw new Error(this.adapter ? `spec discovery ${path}: file changed during scan; retry on a stable worktree` : `vault ${this.vault.id}, ${path}: note changed during scan`);
|
|
254
|
+
} finally { if (this.adapter) specFileIO(path, "close", () => closeSync(fd)); else closeSync(fd); }
|
|
255
|
+
if (this.adapter) {
|
|
256
|
+
try { source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer); }
|
|
257
|
+
catch { throw new Error(`spec discovery ${path}: Markdown is not valid UTF-8; re-encode the file as UTF-8 before rebuilding`); }
|
|
258
|
+
} else source = buffer.toString("utf8");
|
|
259
|
+
hash = digest(buffer);
|
|
260
|
+
this.adapter?.validatePath(path);
|
|
261
|
+
if (this.adapter && metadata(specFileIO(path, "inspect", () => lstatSync(file))) !== stamp) throw new Error(`spec discovery ${path}: file changed during read; retry on a stable worktree`);
|
|
262
|
+
this.db.query("INSERT OR REPLACE INTO analysis_notes VALUES (?,?,?,?)").run(path, source, hash, stamp);
|
|
263
|
+
}
|
|
264
|
+
if (!this.adapter && !rebuild && old?.hash === hash) { this.db.query("UPDATE notes SET metadata=? WHERE path=?").run(stamp, path); continue; }
|
|
219
265
|
let document: ReturnType<typeof parseFrontmatter>;
|
|
220
|
-
try { document = parseFrontmatter(
|
|
266
|
+
try { document = parseFrontmatter(source); }
|
|
221
267
|
catch {
|
|
222
|
-
if (!options.skipInvalidFrontmatter && !options.captureInvalidFrontmatter) throw new Error(`vault ${this.vault.id}, ${path}: invalid frontmatter`);
|
|
268
|
+
if (!this.adapter && !options.skipInvalidFrontmatter && !options.captureInvalidFrontmatter) throw new Error(`vault ${this.vault.id}, ${path}: invalid frontmatter`);
|
|
269
|
+
this.adapter?.invalid(path);
|
|
223
270
|
live.delete(path); skipped.push(path); continue;
|
|
224
271
|
}
|
|
272
|
+
if (this.adapter) {
|
|
273
|
+
this.adapter.validatePath(path);
|
|
274
|
+
if (metadata(specFileIO(path, "inspect", () => lstatSync(file))) !== stamp) throw new Error(`spec discovery ${path}: file changed during scan; retry on a stable worktree`);
|
|
275
|
+
if (!this.adapter.accept(path, document, hash)) { live.delete(path); continue; }
|
|
276
|
+
const row = this.db.query<{hash:string}, [string]>("SELECT hash FROM notes WHERE path=?").get(path);
|
|
277
|
+
if (!rebuild && row?.hash === hash) { this.db.query("UPDATE notes SET metadata=? WHERE path=?").run(stamp, path); continue; }
|
|
278
|
+
}
|
|
225
279
|
const { data, body } = document;
|
|
226
280
|
touch(path);
|
|
227
281
|
this.db.query("INSERT OR REPLACE INTO notes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(path, hash, stamp,
|
|
228
|
-
text(data.title) || path.slice(0, -3), text(data.description), body, text(data.type), text(data.domain), JSON.stringify(aliases(data.aliases ?? data.alias)),
|
|
282
|
+
text(data.title) || path.slice(0, -3), text(data.description), body, text(data.type), text(data.domain), JSON.stringify(aliases(data.aliases ?? data.alias)), source);
|
|
229
283
|
this.db.query("DELETE FROM fts WHERE path=?").run(path);
|
|
230
284
|
this.db.query("INSERT INTO fts(path,title,description,body) SELECT path,title,description,body FROM notes WHERE path=?").run(path);
|
|
231
285
|
changed++;
|
|
232
286
|
}
|
|
233
287
|
for (const row of this.db.query<{path:string}, []>("SELECT path FROM analysis_notes").all()) if (!present.has(row.path)) this.db.query("DELETE FROM analysis_notes WHERE path=?").run(row.path);
|
|
234
288
|
let removed = 0;
|
|
235
|
-
for (const path of previous.keys()) if (!live.has(path)) { touch(path); this.db.query("DELETE FROM notes WHERE path=?").run(path); this.db.query("DELETE FROM fts WHERE path=?").run(path); removed++; }
|
|
289
|
+
for (const path of indexed ?? previous.keys()) if (!live.has(path)) { touch(path); this.db.query("DELETE FROM notes WHERE path=?").run(path); this.db.query("DELETE FROM fts WHERE path=?").run(path); removed++; }
|
|
290
|
+
if (this.adapter) {
|
|
291
|
+
// Spec links are resolved by frontmatter id in buildSpecGraph, never by vault paths.
|
|
292
|
+
this.db.run("DELETE FROM links");
|
|
293
|
+
this.adapter.validate();
|
|
294
|
+
return { notes: live.size, changed, removed, ...(skipped.length ? { skipped } : {}) };
|
|
295
|
+
}
|
|
236
296
|
const stale = this.db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version !== LINK_RESOLUTION;
|
|
237
297
|
if (rebuild || stale) this.db.run("DELETE FROM links");
|
|
238
298
|
else if (changed || removed) {
|
|
@@ -261,15 +321,17 @@ export class VaultIndex {
|
|
|
261
321
|
const notes = this.db.query<{path: string; source: string}, []>("SELECT path,source FROM analysis_notes ORDER BY path").all();
|
|
262
322
|
return { ...inputs, notes };
|
|
263
323
|
}
|
|
264
|
-
search(query: string, options: SearchOptions = {}): SearchResult[] {
|
|
324
|
+
search(query: string, options: SearchOptions = {}, snapshot?: ReadonlyMap<string, string>): SearchResult[] {
|
|
265
325
|
const limit = resultLimit(options.limit);
|
|
266
326
|
if (!query.trim() || Buffer.byteLength(query) > 1024) throw new Error("search: query must contain 1..1024 bytes");
|
|
267
327
|
// Literal AND terms: users cannot inject FTS operators, columns or SQL.
|
|
268
328
|
const terms = query.trim().split(/\s+/).map(term => `"${term.replaceAll('"', '""')}"`).join(" AND ");
|
|
269
|
-
|
|
270
|
-
|
|
329
|
+
const hashColumn = snapshot ? ",n.hash AS sourceHash" : "";
|
|
330
|
+
return this.db.query<SearchResult & { sourceHash?: string }, [string, string | null, string | null, string | null, string | null, number]>(`SELECT n.path, n.title, substr(n.description,1,512) AS description, n.type, n.domain,
|
|
331
|
+
bm25(fts,0,5,2,1) AS score, snippet(fts,3,'','',' ... ',32) AS snippet${hashColumn} FROM fts JOIN notes n ON n.path=fts.path
|
|
271
332
|
WHERE fts MATCH ? AND (? IS NULL OR n.type=?) AND (? IS NULL OR n.domain=?) ORDER BY score,n.path LIMIT ?`).all(terms, options.type ?? null, options.type ?? null, options.domain ?? null, options.domain ?? null, limit)
|
|
272
|
-
.
|
|
333
|
+
.filter(row => !snapshot || snapshot.get(row.path) === row.sourceHash)
|
|
334
|
+
.map(({ sourceHash: _, ...row }) => ({ ...row, title: boundedText(row.title, 512).content, snippet: boundedText(row.snippet, 1024).content, type: boundedText(row.type, 256).content, domain: boundedText(row.domain, 256).content }));
|
|
273
335
|
}
|
|
274
336
|
private find(target: string): string {
|
|
275
337
|
if (!target || Buffer.byteLength(target) > 1024) throw new Error("note: expected target <=1024 bytes");
|
package/src/write-commands.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { COMMANDS as COMMAND_USAGE } from "./commands-table.ts";
|
|
1
2
|
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
|
|
2
3
|
import { canonicalPath, isDenied } from "./platform.ts";
|
|
3
4
|
import { loadRegistry } from "./registry.ts";
|
|
@@ -44,7 +45,7 @@ export function runWriteCommand(command: string, args: string[], home: string, e
|
|
|
44
45
|
for (let i = 0; i < args.length; i++) {
|
|
45
46
|
const arg = args[i]!;
|
|
46
47
|
if (!arg.startsWith("--")) { positional.push(arg); continue; }
|
|
47
|
-
if (Object.hasOwn(flags, arg) || (!values.includes(arg) && !booleans.includes(arg))) throw new Error(`${command}: unknown or repeated option`);
|
|
48
|
+
if (Object.hasOwn(flags, arg) || (!values.includes(arg) && !booleans.includes(arg))) throw new Error(`${command}: unknown or repeated option: ${arg}`);
|
|
48
49
|
if (booleans.includes(arg)) flags[arg] = true;
|
|
49
50
|
else {
|
|
50
51
|
const value = args[++i];
|
|
@@ -54,8 +55,12 @@ export function runWriteCommand(command: string, args: string[], home: string, e
|
|
|
54
55
|
}
|
|
55
56
|
const required = (name: string): string => { if (typeof flags[name] !== "string") throw new Error(`${name}: required`); return flags[name] as string; };
|
|
56
57
|
const optional = (name: string): string | undefined => typeof flags[name] === "string" ? flags[name] as string : undefined;
|
|
57
|
-
const
|
|
58
|
-
|
|
58
|
+
const usage = COMMAND_USAGE.find(item => item.name === command)!.usage;
|
|
59
|
+
const targets = usage.slice(command.length).split(/\s+(?:--|\[)/, 1)[0]!.match(/<[^>]+>/g) ?? [];
|
|
60
|
+
if (positional.length !== targets.length) {
|
|
61
|
+
const expected = targets.length === 2 ? `${targets[0]} and one ${targets[1]}` : targets.join(" and ");
|
|
62
|
+
throw new Error(`${command}: takes ${expected}; got ${Math.max(0, positional.length - 1)} targets`);
|
|
63
|
+
}
|
|
59
64
|
const vault = positional[0]!, target = positional[1]!;
|
|
60
65
|
if (command === "tidy") {
|
|
61
66
|
const all = flags["--all"] === true, rules = TIDY_RULES.filter(rule => all || flags[`--${rule}`] === true);
|
package/src/write-operations.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
1
2
|
import { normalizeSource } from "./source.ts";
|
|
2
3
|
import { parseFrontmatter, emitFrontmatter } from "./frontmatter.ts";
|
|
3
4
|
import { normalizeTarget, proseLines, rewriteLinkTargets, extractLinks, resolveLinks, type LinkNote } from "./links.ts";
|
|
@@ -45,6 +46,42 @@ export function h2Sections(body: string): { name: string; start: number; end: nu
|
|
|
45
46
|
}
|
|
46
47
|
return sections;
|
|
47
48
|
}
|
|
49
|
+
/** Replace the last prose `## Topics` section (never one inside a fence) with the given topics; append one if absent. */
|
|
50
|
+
export function replaceTopicsSection(body: string, topics: string[]): string {
|
|
51
|
+
const prose = proseLines(body), raw = body.split("\n");
|
|
52
|
+
let start = -1;
|
|
53
|
+
for (let i = prose.length - 1; i >= 0; i--) if (/^ {0,3}##[ \t]+Topics[ \t]*#*[ \t]*$/i.test(prose[i]!)) { start = i; break; }
|
|
54
|
+
const section = ["## Topics", "", ...topics.map(topic => `- ${topic}`), ""];
|
|
55
|
+
if (start < 0) return `${body.replace(/\n*$/, "")}\n\n${section.join("\n")}`;
|
|
56
|
+
// The section ends at the next prose heading of level 1 or 2.
|
|
57
|
+
let end = raw.length;
|
|
58
|
+
for (let i = start + 1; i < prose.length; i++) if (/^ {0,3}#{1,2}[ \t]/.test(prose[i]!)) { end = i; break; }
|
|
59
|
+
const tail = raw.slice(end);
|
|
60
|
+
return [...raw.slice(0, start), ...section, ...(tail.length ? tail : [])].join("\n").replace(/\n*$/, "\n");
|
|
61
|
+
}
|
|
62
|
+
/** In-place refiling replaces only visible wiki-list lines, preserving all other bytes. */
|
|
63
|
+
function replaceTopicsList(body: string, topics: string[], newline: string): string {
|
|
64
|
+
const visible = proseLines(body), raw = body.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
65
|
+
let start = -1;
|
|
66
|
+
for (let i = visible.length - 1; i >= 0; i--) if (/^ {0,3}##[ \t]+Topics(?:[ \t]+#+)?[ \t]*$/i.test(visible[i]!)) { start = i; break; }
|
|
67
|
+
const list = topics.map(topic => `- ${topic}`).join(newline);
|
|
68
|
+
if (start < 0) return body + (body && !body.endsWith("\n") ? newline : "") + newline + `## Topics${newline}${newline}${list}${newline}`;
|
|
69
|
+
const items = new Set<number>();
|
|
70
|
+
// The Topics list is the leading run of list lines as V05 sees them: blank and fenced lines continue it (fences are kept),
|
|
71
|
+
// the first visible heading or prose line ends it.
|
|
72
|
+
for (let i = start + 1; i < raw.length; i++) {
|
|
73
|
+
const line = visible[i] ?? "";
|
|
74
|
+
if (/^[ \t]*(?:[-*+][ \t]+)?(\[\[[^\]|#]+\]\])[ \t]*$/.test(line)) items.add(i);
|
|
75
|
+
else if (line.trim()) break;
|
|
76
|
+
}
|
|
77
|
+
const first = items.values().next().value;
|
|
78
|
+
if (first === undefined) {
|
|
79
|
+
const addition = (raw[start]!.endsWith("\n") ? "" : newline) + list + (list ? newline : "");
|
|
80
|
+
return raw.slice(0, start + 1).join("") + addition + raw.slice(start + 1).join("");
|
|
81
|
+
}
|
|
82
|
+
const ending = /\r?\n$/.exec(raw[first]!)?.[0] ?? "";
|
|
83
|
+
return raw.map((line, i) => i === first ? list + (list ? ending : "") : items.has(i) ? "" : line).join("");
|
|
84
|
+
}
|
|
48
85
|
export function appendSection(source: string, section: string, markdown: string, path: string): string {
|
|
49
86
|
if (typeof section !== "string" || !section.trim() || section.length > 120 || /[\r\n]/.test(section) || section.trim().replace(/[ \t]+#+$/, "").trim().toLowerCase() === "topics") refuse("V05", "append cannot target Topics or an invalid section");
|
|
50
87
|
if (typeof markdown !== "string" || Buffer.byteLength(markdown) > 16384 || proseLines(markdown).some(line => /^ {0,3}##[ \t]+Topics(?:[ \t]+#+)?[ \t]*$/i.test(line))) refuse("V05", "append cannot introduce a Topics section or exceed 16384 bytes");
|
|
@@ -125,7 +162,15 @@ export function planOperation(operation: WriteOp, notes: Map<string, string>, re
|
|
|
125
162
|
if (!operation.set || typeof operation.set !== "object" || Array.isArray(operation.set) || (operation.unset !== undefined && (!Array.isArray(operation.unset) || operation.unset.some(key => typeof key !== "string")))) refuse("V01", "invalid frontmatter update");
|
|
126
163
|
const changes = {...operation.set};
|
|
127
164
|
for (const key of operation.unset ?? []) changes[key] = undefined;
|
|
128
|
-
try {
|
|
165
|
+
try {
|
|
166
|
+
const before = document(source, path);
|
|
167
|
+
let after = emitFrontmatter(before, normalizedSourceChanges(changes));
|
|
168
|
+
if (Object.hasOwn(operation.set, "topics") && !isDeepStrictEqual(changes.topics, before.data.topics) && Array.isArray(changes.topics) && changes.topics.every((value): value is string => typeof value === "string")) {
|
|
169
|
+
const doc = document(after, path);
|
|
170
|
+
after = (doc.frontmatter ?? "") + replaceTopicsList(doc.body, changes.topics, /\r?\n/.exec(source)?.[0] ?? "\n");
|
|
171
|
+
}
|
|
172
|
+
set(path, after);
|
|
173
|
+
} catch (error) { if (error instanceof WriteRefusal) throw error; refuse("V01", "cannot emit frontmatter", path); }
|
|
129
174
|
break;
|
|
130
175
|
}
|
|
131
176
|
case "link": {
|
package/src/write-validation.ts
CHANGED
|
@@ -181,22 +181,34 @@ function topicIssues(note: ParsedNote, index: Map<string, Set<ParsedNote>>, seve
|
|
|
181
181
|
}
|
|
182
182
|
return [];
|
|
183
183
|
}
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
/** V05 has two independent problems: placement (one Topics H2, and it is the last H2) and content (the last Topics section lists exactly the frontmatter topics). */
|
|
185
|
+
function topicProblems(note: ParsedNote): Set<"placement" | "content"> {
|
|
186
|
+
const problems = new Set<"placement" | "content">();
|
|
187
|
+
if (note.data.type === "moc" && note.data.topics === undefined) return problems;
|
|
186
188
|
const lines = proseLines(note.body), h2s = lines.flatMap((line, index) => /^ {0,3}##[ \t]+/.test(line) ? [index] : []);
|
|
187
189
|
const topics = h2s.filter(index => /^ {0,3}##[ \t]+Topics(?:[ \t]+#+)?[ \t]*$/i.test(lines[index]!));
|
|
188
|
-
|
|
190
|
+
if (topics.length !== 1 || h2s[h2s.length - 1] !== topics[0]) problems.add("placement");
|
|
191
|
+
const last = topics[topics.length - 1], end = h2s.find(index => last !== undefined && index > last) ?? lines.length;
|
|
189
192
|
const actual: string[] = [];
|
|
190
|
-
|
|
193
|
+
let content = last !== undefined;
|
|
194
|
+
if (last !== undefined) for (const line of lines.slice(last + 1, end)) {
|
|
191
195
|
if (!line.trim()) continue;
|
|
192
196
|
const wiki = /^[ \t]*(?:[-*+][ \t]+)?(\[\[[^\]|#]+\]\])[ \t]*$/.exec(line);
|
|
193
|
-
if (!wiki) {
|
|
197
|
+
if (!wiki) { content = false; break; }
|
|
194
198
|
actual.push(noteKey(wiki[1]!.slice(2, -2)));
|
|
195
199
|
}
|
|
196
200
|
const expected = Array.isArray(note.data.topics) ? note.data.topics.map(value => typeof value === "string" ? noteKey(value.slice(2, -2)) : "") : [];
|
|
197
201
|
const sortedExpected = [...expected].sort();
|
|
198
|
-
if (actual.length !== expected.length || [...actual].sort().some((value, index) => value !== sortedExpected[index]))
|
|
199
|
-
return
|
|
202
|
+
if (!content || actual.length !== expected.length || [...actual].sort().some((value, index) => value !== sortedExpected[index])) problems.add("content");
|
|
203
|
+
return problems;
|
|
204
|
+
}
|
|
205
|
+
/** An error unless every V05 problem of the new note was already present in the prior note (as V01 retains only unchanged findings). */
|
|
206
|
+
function topicPlacement(note: ParsedNote, old: ParsedNote | undefined, context: ValidationContext): WriteIssue[] {
|
|
207
|
+
const problems = topicProblems(note);
|
|
208
|
+
if (!problems.size) return [];
|
|
209
|
+
const before = context.operation.op !== "create" && old !== undefined ? topicProblems(old) : new Set<string>();
|
|
210
|
+
const retained = [...problems].every(problem => before.has(problem));
|
|
211
|
+
return [issue("V05", "Topics must be the final H2 and list exactly the frontmatter topics", retained ? "warning" : "error", note.path)];
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
/** Validate a complete mutation plan against the final overlaid snapshot, without filesystem writes. */
|
|
@@ -226,7 +238,7 @@ export function validateWrite(changes: PlannedChange[], context: ValidationConte
|
|
|
226
238
|
const note = newNotes.get(change.path);
|
|
227
239
|
if (!note) { issues.push(issue("V01", "invalid frontmatter", "error", change.path)); continue; }
|
|
228
240
|
const prior=context.operation.op==="move"&¬eKey(context.operation.to)===noteKey(note.path)?oldNotes.get(movedFrom(context.operation.from, context)):oldNotes.get(note.path);
|
|
229
|
-
issues.push(...schemaIssues(note,prior,context), ...topicPlacement(note));
|
|
241
|
+
issues.push(...schemaIssues(note,prior,context), ...topicPlacement(note,prior,context));
|
|
230
242
|
const source = note.data.source;
|
|
231
243
|
let sourceDenied=strings(source).some(entry=>isDenied(entry.replaceAll("\\","/"),context.config.denyPaths));
|
|
232
244
|
if(source!==undefined&&!sourceDenied)try{
|