@staix/vault-mem 0.2.2 → 0.2.4

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.
@@ -0,0 +1,139 @@
1
+ import { AnalysisYamlScalar, parseAnalysisYaml, yamlTruthy } from "./analysis-yaml.ts";
2
+ import type { AnalysisSnapshot } from "./analysis-types.ts";
3
+ import { buildGraph, finalizeGraph, pythonTitle, pyString, type GraphNode, type GraphEdge, type VaultGraph } from "./vault-graph.ts";
4
+ import { posix } from "node:path";
5
+
6
+ export interface WorkspaceSnapshot {
7
+ files: { path: string; text: string }[];
8
+ buSlugs: string[];
9
+ skipRoots: string[];
10
+ assertUnchanged?: () => void;
11
+ sourceTargets: Record<string, "file" | "directory" | "missing" | "denied">;
12
+ }
13
+ export const WORK_SKIP_DIRS = new Set([".git", "node_modules", ".venv", ".sync-conflicts", "temp", ".maru", ".pnpm-store", ".conductor", ".dotfiles", ".omx", ".omc", ".gstack", ".frontend-slides", ".claude", ".archive", "archive", "vault", "inbox", "sites", "dev", "shared", "_templates", "templates"]);
14
+ const FIELDS = ["projects", "project", "vault_note", "topics", "attendees", "relatedMeetings", "relatedTasks"];
15
+ const FRONTMATTER = /^---\s*\n([\s\S]*?)\n---\s*\n/;
16
+ const WIKI = /\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]/g;
17
+ const PRIORITY = { wiki_link: 0, fm_ref: 1, bu_member: 2, source_ref: 3, related: 4, supersedes: 5 };
18
+ export type WorkCounters = Record<"work_docs" | "yaml_fail" | "template_skip" | "related_unresolved" | "related_ambiguous" | "related_dir_skip" | "related_root_escape" | "related_home_escape" | "source_stub" | "source_dead" | "source_nonmd" | "source_skiproot" | "collisions" | "dup_edges", number>;
19
+ export function workSkipped(path: string, roots: string[]): boolean {
20
+ const normalized = path.normalize("NFC").replace(/^\/+|\/+$/g, "");
21
+ return roots.some(root => normalized === root || normalized.startsWith(root + "/"));
22
+ }
23
+ function mapping(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof AnalysisYamlScalar); }
24
+ export function workspaceFrontmatter(text: string): Record<string, unknown> {
25
+ const match = FRONTMATTER.exec(text);
26
+ if (!match) return {};
27
+ try { const value = parseAnalysisYaml(match[1]!); return mapping(value) ? value : {}; }
28
+ catch { return {}; } // The reference skips rejected YAML and counts work-doc failures.
29
+ }
30
+ function links(value: unknown): string[] { return [...pyString(value).matchAll(WIKI)].map(match => match[1]!.trim().normalize("NFC").toLowerCase().replaceAll(" ", "-")); }
31
+ function coerce(value: unknown): string {
32
+ return value instanceof AnalysisYamlScalar && value.kind === "timestamp" ? value.text.replace(" ", "T") : pyString(value);
33
+ }
34
+ function label(path: string): string {
35
+ let stem = posix.basename(path, ".md").normalize("NFC");
36
+ const parent = posix.basename(posix.dirname(path));
37
+ if (["readme", "index"].includes(stem.toLowerCase()) && parent !== ".") stem = parent + "-" + stem;
38
+ return pythonTitle(stem.replaceAll("-", " "));
39
+ }
40
+ /** Reference source spelling: keep its ID spelling, including path segments, rather than silently remap it. */
41
+ export function workspaceSource(fm: Record<string, unknown>): string | undefined {
42
+ if (typeof fm.source !== "string" || !fm.source.trim()) return undefined;
43
+ let raw = fm.source.trim().replace(/^"+|"+$/g, "").replace(/^'+|'+$/g, "");
44
+ if (!raw || raw.startsWith("http://") || raw.startsWith("https://")) return undefined;
45
+ if (raw.startsWith("work/")) raw = raw.slice(5);
46
+ return raw.normalize("NFC");
47
+ }
48
+ export function buildWorkspaceGraph(snapshot: AnalysisSnapshot, work: WorkspaceSnapshot): { graph: VaultGraph; counters: WorkCounters; relations: Record<string, number> } {
49
+ const graph = buildGraph(snapshot);
50
+ const counters: WorkCounters = { work_docs: 0, yaml_fail: 0, template_skip: 0, related_unresolved: 0, related_ambiguous: 0, related_dir_skip: 0, related_root_escape: 0, related_home_escape: 0, source_stub: 0, source_dead: 0, source_nonmd: 0, source_skiproot: 0, collisions: 0, dup_edges: 0 };
51
+ const vaultIds = new Set(graph.nodes.map(node => node.id));
52
+ const docs = new Map<string, { path: string; fm: Record<string, unknown>; body: string }>(), stems = new Map<string, string[]>();
53
+ for (const file of work.files) {
54
+ const match = FRONTMATTER.exec(file.text);
55
+ if (!match) continue;
56
+ if (match[1]!.includes("{{")) { counters.template_skip++; continue; }
57
+ const fm = workspaceFrontmatter(file.text);
58
+ if (!Object.keys(fm).length) { if (match[1]!.trim()) counters.yaml_fail++; continue; }
59
+ if (!yamlTruthy(fm.document_type) && !FIELDS.some(field => yamlTruthy(fm[field])) && !yamlTruthy(fm.related)) continue;
60
+ const path = file.path.normalize("NFC"), id = "work:" + path.slice(0, -3);
61
+ docs.set(id, { path, fm, body: file.text.slice(match[0].length) });
62
+ const key = posix.basename(path, ".md").toLowerCase().replaceAll(" ", "-");
63
+ stems.set(key, [...stems.get(key) ?? [], id]); counters.work_docs++;
64
+ }
65
+ const validBu = new Set(work.buSlugs), usedBu = new Set<string>();
66
+ const businessUnit = (fm: Record<string, unknown>) => pyString(fm.business_unit ?? "").replaceAll("[[", "").replaceAll("]]", "").trim();
67
+ for (const doc of docs.values()) { const bu = businessUnit(doc.fm); if (validBu.has(bu)) usedBu.add(bu); }
68
+ const node = (value: Omit<GraphNode, "degree" | "community">): GraphNode => ({ ...value, degree: 0, community: null });
69
+ for (const slug of [...usedBu].sort()) graph.nodes.push(node({ id: "bu:" + slug, type: "bu", domain: "bu", label: slug, source_file: "" }));
70
+ for (const [id, doc] of docs) graph.nodes.push(node({ id, label: label(doc.path), type: coerce(yamlTruthy(doc.fm.document_type) ? doc.fm.document_type : "work-doc"), domain: doc.path.split("/")[0]!, description: Array.from(coerce(yamlTruthy(doc.fm.title) ? doc.fm.title : yamlTruthy(doc.fm.description) ? doc.fm.description : "")).slice(0, 200).join(""), source_file: doc.path }));
71
+ if (graph.nodes.length > 20000) throw new Error("workspace graph: exceeds 20000 nodes");
72
+ const candidates: GraphEdge[] = [];
73
+ const add = (relation: GraphEdge["relation"], source: string, target: string, attrs: { rel?: string; field?: string } = {}) => {
74
+ if (candidates.length >= 200000) throw new Error("workspace graph: exceeds 200000 edge candidates");
75
+ candidates.push({ source, target, relation, confidence_tag: "EXTRACTED", confidence: 1, ...attrs });
76
+ };
77
+ for (const [id, doc] of docs) {
78
+ const related = doc.fm.related;
79
+ if (related !== null && related !== undefined) for (const entry of Array.isArray(related) ? related : [related]) {
80
+ const rtype = mapping(entry) ? entry.rel : undefined;
81
+ let value = pyString(mapping(entry) ? entry.doc ?? "" : entry).trim();
82
+ if (!value) continue;
83
+ value = value.replace(/\s*\([^)]*\)\s*$/, "").trim();
84
+ const wiki = links(value);
85
+ let target: string | undefined;
86
+ if (wiki.length) {
87
+ const targets = stems.get(wiki[0]!);
88
+ if (!targets?.length) counters.related_unresolved++;
89
+ else if (targets.length > 1) counters.related_ambiguous++;
90
+ else target = targets[0];
91
+ } else if (value.startsWith("~")) counters.related_home_escape++;
92
+ else {
93
+ const joined = posix.isAbsolute(value) ? posix.normalize(value) : posix.normalize(posix.join(posix.dirname(doc.path), value));
94
+ if (joined.startsWith("..")) counters.related_root_escape++;
95
+ else if (value.endsWith("/") || !joined.endsWith(".md")) counters.related_dir_skip++;
96
+ else if (docs.has("work:" + joined.slice(0, -3))) target = "work:" + joined.slice(0, -3);
97
+ else counters.related_unresolved++;
98
+ }
99
+ if (target) add("related", id, target, yamlTruthy(rtype) ? { rel: coerce(rtype) } : {});
100
+ }
101
+ for (const field of FIELDS) if (field in doc.fm) for (const key of links(doc.fm[field])) if (vaultIds.has(key)) add("fm_ref", id, key, { field });
102
+ for (const key of links(doc.body)) if (vaultIds.has(key)) add("wiki_link", id, key);
103
+ const bu = businessUnit(doc.fm); if (usedBu.has(bu)) add("bu_member", id, "bu:" + bu);
104
+ }
105
+ const stubs = new Set<string>();
106
+ for (const note of [...snapshot.notes].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
107
+ const fm = workspaceFrontmatter(note.source), id = posix.basename(note.path, ".md"), raw = workspaceSource(fm);
108
+ if (raw !== undefined) {
109
+ const ext = posix.extname(raw), norm = raw.endsWith(".md") ? raw.slice(0, -3) : raw, target = "work:" + norm;
110
+ if (raw.endsWith("/") || (ext && ext !== ".md")) counters.source_nonmd++;
111
+ else if (docs.has(target)) add("source_ref", id, target);
112
+ else if (norm.split("/").some(part => WORK_SKIP_DIRS.has(part)) || workSkipped(norm, work.skipRoots) || work.sourceTargets[norm] === "denied") counters.source_skiproot++;
113
+ else if (work.sourceTargets[norm] === "file") {
114
+ if (!stubs.has(target)) { stubs.add(target); counters.source_stub++; graph.nodes.push(node({ id: target, type: "file", label: label(norm + ".md"), domain: norm.split("/")[0]!, source_file: norm + ".md" })); }
115
+ add("source_ref", id, target);
116
+ } else if (work.sourceTargets[norm] === "directory") counters.source_nonmd++;
117
+ else counters.source_dead++;
118
+ }
119
+ for (const field of ["supersedes", "superseded_by"]) if (yamlTruthy(fm[field])) for (const key of links(fm[field])) if (vaultIds.has(key)) add("supersedes", id, key, { field });
120
+ }
121
+ const known = new Set(graph.nodes.map(node => node.id)), best = new Map<string, GraphEdge>();
122
+ const pair = (edge: GraphEdge) => JSON.stringify([edge.source, edge.target].sort());
123
+ for (const edge of candidates) {
124
+ if (edge.source === edge.target || !known.has(edge.source) || !known.has(edge.target)) continue;
125
+ const key = pair(edge), current = best.get(key);
126
+ if (current) {
127
+ if (current.relation !== edge.relation) counters.collisions++; else counters.dup_edges++;
128
+ }
129
+ if (!current || PRIORITY[edge.relation] > PRIORITY[current.relation]) best.set(key, edge);
130
+ }
131
+ const edges = new Map(graph.edges.map(edge => [pair(edge), edge]));
132
+ for (const [key, edge] of best) edges.set(key, { ...edges.get(key), ...edge });
133
+ graph.edges = [...edges.values()];
134
+ if (graph.nodes.length > 20000 || graph.edges.length > 100000) throw new Error("workspace graph: node or edge bound exceeded");
135
+ finalizeGraph(graph);
136
+ const relations: Record<string, number> = {};
137
+ for (const edge of graph.edges) relations[edge.relation] = (relations[edge.relation] ?? 0) + 1;
138
+ return { graph, counters, relations };
139
+ }
@@ -0,0 +1,150 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, opendirSync, openSync, readSync, realpathSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import type { VaultSpec, VmemConfig } from "./config.ts";
5
+ import type { AnalysisSnapshot } from "./analysis-types.ts";
6
+ import { analysisRead } from "./analysis-input.ts";
7
+ import { parseAnalysisYaml } from "./analysis-yaml.ts";
8
+ import { isDenied } from "./platform.ts";
9
+ import { sourceWorkspaceRoot } from "./source.ts";
10
+ import { WORK_SKIP_DIRS, workspaceFrontmatter, workspaceSource, workSkipped, type WorkspaceSnapshot } from "./workspace-graph.ts";
11
+
12
+ const CONFIG_SKIP = new Set([".git", "node_modules", ".venv", ".sync-conflicts", "archive", ".archive", "vault", "temp"]);
13
+ export const WORK_MAX_FILES = 50000;
14
+ /** Per-file bound for work docs that open with frontmatter (the only ones that can become nodes). */
15
+ export const WORK_MAX_FILE_BYTES = 2 * 1024 * 1024;
16
+ export const WORK_MAX_TOTAL_BYTES = 128 * 1024 * 1024;
17
+ /** Bytes probed from every `.md` file to decide whether it opens with frontmatter; plain files are never read further. */
18
+ const PROBE_BYTES = 4;
19
+ /** Directory entries visited per walk (symlinks included), so a runaway tree fails explicitly instead of stalling. */
20
+ export const WORK_MAX_ENTRIES = 2_000_000;
21
+ const DEFAULT_ROOTS = ["scratchpad/memos", "scratchpad/temp"];
22
+ function object(value: unknown): Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
23
+ /** Filesystem-only snapshot. No directory or file symlinks are followed, including source stubs. */
24
+ /** Test-only overrides of the reader bounds; production callers pass nothing and get the exported constants. */
25
+ export interface WorkspaceLimits { files?: number; fileBytes?: number; bytes?: number; entries?: number }
26
+ export function workspaceInput(snapshot: AnalysisSnapshot, vault: VaultSpec, config: VmemConfig, limits: WorkspaceLimits = {}): WorkspaceSnapshot {
27
+ const maxFiles = limits.files ?? WORK_MAX_FILES, maxFileBytes = limits.fileBytes ?? WORK_MAX_FILE_BYTES;
28
+ const maxBytes = limits.bytes ?? WORK_MAX_TOTAL_BYTES, maxEntries = limits.entries ?? WORK_MAX_ENTRIES;
29
+ const found = sourceWorkspaceRoot(vault);
30
+ if (!found) throw new Error("workspace graph: vault requires a registry");
31
+ const root = realpathSync(found);
32
+ if (root !== found || lstatSync(found).isSymbolicLink() || !lstatSync(found).isDirectory()) throw new Error("workspace graph: work root must be a plain directory");
33
+ const denied = (file: string) => isDenied(file, config.denyPaths ?? []);
34
+ if (denied(root)) throw new Error("workspace graph: work root is denied");
35
+ let filesRead = 0, bytesRead = 0, entriesSeen = 0;
36
+ const stamps = new Map<string, string>();
37
+ const stamp = (file: string) => { const stat = lstatSync(file, { bigint: true }); return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode].join(":"); };
38
+ const remember = (file: string) => {
39
+ const current = stamp(file), prior = stamps.get(file);
40
+ if (prior !== undefined && prior !== current) throw new Error("workspace graph: input changed during snapshot");
41
+ stamps.set(file, current);
42
+ };
43
+ /**
44
+ * FRONTMATTER_RE needs `---` then whitespace at offset 0; a file that cannot match is skipped unread, as in the reference.
45
+ * Every probed file counts once toward WORK_MAX_FILES and is stamped, so one that later gains frontmatter fails assertUnchanged.
46
+ */
47
+ const probed = new Set<string>();
48
+ const opensWithFrontmatter = (file: string): boolean => {
49
+ if (++filesRead > maxFiles) throw new Error("workspace graph: file count exceeds bound");
50
+ probed.add(file);
51
+ remember(file);
52
+ let fd: number;
53
+ try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
54
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
55
+ try {
56
+ if (!fstatSync(fd).isFile()) return false;
57
+ const head = Buffer.alloc(PROBE_BYTES), n = readSync(fd, head, 0, PROBE_BYTES, 0);
58
+ return n === PROBE_BYTES && head.subarray(0, 3).toString("latin1") === "---" && /\s/.test(String.fromCharCode(head[3]!));
59
+ } finally { closeSync(fd); }
60
+ };
61
+ const read = (file: string, maximum = maxFileBytes): string => {
62
+ // A probed work doc was already counted; other reads (configs) count here.
63
+ if (!probed.has(file) && ++filesRead > maxFiles) throw new Error("workspace graph: file count exceeds bound");
64
+ remember(file);
65
+ const text = analysisRead(file, maximum);
66
+ if (text === null) throw new Error("workspace graph: input disappeared");
67
+ remember(file);
68
+ bytesRead += Buffer.byteLength(text);
69
+ if (bytesRead > maxBytes) throw new Error("workspace graph: total bytes exceed bound");
70
+ return text;
71
+ };
72
+ let skipRoots = DEFAULT_ROOTS;
73
+ const workspaceConfig = join(root, "workspace.config.yaml");
74
+ try {
75
+ const stat = lstatSync(workspaceConfig);
76
+ if (stat.isFile() && !stat.isSymbolicLink() && !denied(workspaceConfig)) {
77
+ const text = read(workspaceConfig, 1024 * 1024);
78
+ let data: Record<string, unknown> = {};
79
+ try { data = object(parseAnalysisYaml(text)); } catch { /* Reference falls back on malformed config. */ }
80
+ const paths = object(data.paths), scratchpad = object(data.scratchpad), configured = String(paths.scratchpad ?? "").trim();
81
+ let scratchRoot = "scratchpad";
82
+ if (configured) {
83
+ const expanded = configured === "~" ? homedir() : configured.startsWith("~/") ? join(homedir(), configured.slice(2)) : configured;
84
+ const absolute = resolve(expanded), rel = relative(root, absolute);
85
+ if (rel !== ".." && !rel.startsWith(".." + sep) && !isAbsolute(rel)) scratchRoot = rel.normalize("NFC").replace(/^\/+|\/+$/g, "");
86
+ else scratchRoot = "";
87
+ }
88
+ if (scratchRoot) skipRoots = [String(scratchpad.memos_subdir || "memos"), String(scratchpad.temp_subdir || "temp")].map(dir => scratchRoot + "/" + dir.normalize("NFC").replace(/^\/+|\/+$/g, ""));
89
+ }
90
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
91
+ const walk = (dir: string, skip: Set<string>, roots: string[], visit: (file: string, rel: string) => void): void => {
92
+ if (realpathSync(dir) !== dir || denied(dir)) return;
93
+ remember(dir);
94
+ const handle = opendirSync(dir), children: { name: string; directory: boolean; file: boolean }[] = [];
95
+ try {
96
+ for (;;) {
97
+ const entry = handle.readSync(); if (!entry) break;
98
+ if (++entriesSeen > maxEntries) throw new Error("workspace graph: directory entry count exceeds bound");
99
+ if (entry.isSymbolicLink()) continue;
100
+ children.push({ name: entry.name, directory: entry.isDirectory(), file: entry.isFile() });
101
+ }
102
+ } finally { handle.closeSync(); }
103
+ for (const entry of children) {
104
+ const file = join(dir, entry.name);
105
+ if (entry.file && !denied(file)) visit(file, relative(root, file).normalize("NFC"));
106
+ }
107
+ for (const entry of children) {
108
+ const file = join(dir, entry.name), rel = relative(root, file).normalize("NFC");
109
+ if (entry.directory && !skip.has(entry.name) && !workSkipped(rel, roots) && !denied(file)) walk(file, skip, roots, visit);
110
+ }
111
+ remember(dir);
112
+ };
113
+ const files: WorkspaceSnapshot["files"] = [], buSlugs = new Set<string>();
114
+ walk(root, WORK_SKIP_DIRS, skipRoots, (file, path) => { if (path.endsWith(".md") && opensWithFrontmatter(file)) files.push({ path, text: read(file) }); });
115
+ // The bu-config walk prunes fewer trees (it must see `.maru/`), like the reference's rglob, so it gets its own entry budget.
116
+ entriesSeen = 0;
117
+ walk(root, CONFIG_SKIP, [], (file, path) => {
118
+ if (!path.endsWith("/bu-config.yaml") && path !== "bu-config.yaml") return;
119
+ const text = read(file, 1024 * 1024);
120
+ try {
121
+ const data = object(parseAnalysisYaml(text)), slug = String(data.bu_id ?? "").replaceAll("[[", "").replaceAll("]]", "").trim();
122
+ if (/^[a-z0-9][a-z0-9-]*$/.test(slug)) buSlugs.add(slug);
123
+ } catch { /* Invalid bu config does not declare a hub. */ }
124
+ });
125
+ const plain = (candidate: string): "file" | "directory" | "missing" | "denied" => {
126
+ const rel = relative(root, candidate);
127
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith(".." + sep)) return "missing";
128
+ let current = root;
129
+ for (const part of rel.split(sep)) {
130
+ current = join(current, part);
131
+ if (denied(current)) return "denied";
132
+ try { const stat = lstatSync(current); if (stat.isSymbolicLink()) return "missing"; remember(current); }
133
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT" || (error as NodeJS.ErrnoException).code === "ENOTDIR") return "missing"; throw error; }
134
+ }
135
+ const stat = lstatSync(candidate);
136
+ return stat.isFile() ? "file" : stat.isDirectory() ? "directory" : "missing";
137
+ };
138
+ const sourceTargets: WorkspaceSnapshot["sourceTargets"] = {};
139
+ for (const note of snapshot.notes) {
140
+ const raw = workspaceSource(workspaceFrontmatter(note.source));
141
+ if (raw === undefined) continue;
142
+ const norm = raw.endsWith(".md") ? raw.slice(0, -3) : raw;
143
+ if (norm.split("/").some(part => WORK_SKIP_DIRS.has(part)) || workSkipped(norm, skipRoots)) continue;
144
+ const file = plain(resolve(root, norm + ".md"));
145
+ sourceTargets[norm] = file === "file" || file === "denied" ? file : plain(resolve(root, norm)) === "directory" ? "directory" : "missing";
146
+ }
147
+ const assertUnchanged = () => { for (const [file, expected] of stamps) if (stamp(file) !== expected) throw new Error("workspace graph: input changed during build"); };
148
+ assertUnchanged();
149
+ return { files, buSlugs: [...buSlugs].sort(), skipRoots, sourceTargets, assertUnchanged };
150
+ }