mover-os 4.7.7 → 4.7.8

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.
Files changed (107) hide show
  1. package/install.js +688 -68
  2. package/package.json +15 -3
  3. package/src/dashboard/build.js +1503 -0
  4. package/src/dashboard/dashboard.js +276 -0
  5. package/src/dashboard/index.js +319 -0
  6. package/src/dashboard/lib/activation-log.js +297 -0
  7. package/src/dashboard/lib/active-context-parser.js +177 -0
  8. package/src/dashboard/lib/agent-command.js +93 -0
  9. package/src/dashboard/lib/agent-detect.js +255 -0
  10. package/src/dashboard/lib/agent-detector.js +92 -0
  11. package/src/dashboard/lib/agent-session.js +462 -0
  12. package/src/dashboard/lib/anti-identity-detector.js +116 -0
  13. package/src/dashboard/lib/auto-learnings-parser.js +183 -0
  14. package/src/dashboard/lib/cli-usage-parser.js +92 -0
  15. package/src/dashboard/lib/config-parser.js +109 -0
  16. package/src/dashboard/lib/connect-recommender.js +131 -0
  17. package/src/dashboard/lib/correlations-parser.js +231 -0
  18. package/src/dashboard/lib/daily-note-resolver.js +211 -0
  19. package/src/dashboard/lib/date-utils.js +35 -0
  20. package/src/dashboard/lib/distribution-parser.js +78 -0
  21. package/src/dashboard/lib/dossier-parser.js +64 -0
  22. package/src/dashboard/lib/drift-history.js +83 -0
  23. package/src/dashboard/lib/drift-score.js +119 -0
  24. package/src/dashboard/lib/engine-health.js +170 -0
  25. package/src/dashboard/lib/engine-writer.js +1685 -0
  26. package/src/dashboard/lib/execution-plan.js +125 -0
  27. package/src/dashboard/lib/experiments-parser.js +429 -0
  28. package/src/dashboard/lib/feed-parser.js +294 -0
  29. package/src/dashboard/lib/forked-future.js +60 -0
  30. package/src/dashboard/lib/goal-forecast.js +304 -0
  31. package/src/dashboard/lib/goals-parser.js +67 -0
  32. package/src/dashboard/lib/health-protocols-parser.js +133 -0
  33. package/src/dashboard/lib/hook-activity.js +48 -0
  34. package/src/dashboard/lib/hook-indexer.js +169 -0
  35. package/src/dashboard/lib/hourly-activity-parser.js +143 -0
  36. package/src/dashboard/lib/identity-parser.js +85 -0
  37. package/src/dashboard/lib/ingestion.js +418 -0
  38. package/src/dashboard/lib/library-indexer-v2.js +212 -0
  39. package/src/dashboard/lib/library-indexer.js +105 -0
  40. package/src/dashboard/lib/log-activation.sh +61 -0
  41. package/src/dashboard/lib/memory-curator.js +97 -0
  42. package/src/dashboard/lib/memory-gardener.js +177 -0
  43. package/src/dashboard/lib/memory-gepa.js +102 -0
  44. package/src/dashboard/lib/memory-index.js +470 -0
  45. package/src/dashboard/lib/memory-rerank.js +72 -0
  46. package/src/dashboard/lib/memory-text.js +136 -0
  47. package/src/dashboard/lib/metrics-log-parser.js +76 -0
  48. package/src/dashboard/lib/moves-usage-parser.js +184 -0
  49. package/src/dashboard/lib/onboarding-forge.js +70 -0
  50. package/src/dashboard/lib/override-outcome-parser.js +68 -0
  51. package/src/dashboard/lib/override-summary.js +73 -0
  52. package/src/dashboard/lib/paths.js +192 -0
  53. package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
  54. package/src/dashboard/lib/phantom-strategy.js +129 -0
  55. package/src/dashboard/lib/project-scanner.js +121 -0
  56. package/src/dashboard/lib/promise-wall.js +88 -0
  57. package/src/dashboard/lib/record-score.js +173 -0
  58. package/src/dashboard/lib/redaction.js +140 -0
  59. package/src/dashboard/lib/refusal-parser.js +44 -0
  60. package/src/dashboard/lib/regenerate-manifest.js +206 -0
  61. package/src/dashboard/lib/rewind-snapshots.js +689 -0
  62. package/src/dashboard/lib/roast-wall-parser.js +200 -0
  63. package/src/dashboard/lib/run-registry.js +226 -0
  64. package/src/dashboard/lib/safe-write.js +63 -0
  65. package/src/dashboard/lib/session-log-parser.js +145 -0
  66. package/src/dashboard/lib/session-time-parser.js +158 -0
  67. package/src/dashboard/lib/skill-index.js +171 -0
  68. package/src/dashboard/lib/skill-indexer.js +118 -0
  69. package/src/dashboard/lib/skill-recommender.js +689 -0
  70. package/src/dashboard/lib/strategy-parser.js +245 -0
  71. package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
  72. package/src/dashboard/lib/streak-parser.js +95 -0
  73. package/src/dashboard/lib/suggested-now.js +254 -0
  74. package/src/dashboard/lib/tool-awareness.js +125 -0
  75. package/src/dashboard/lib/transcript-parser.js +331 -0
  76. package/src/dashboard/lib/vault-graph-parser.js +205 -0
  77. package/src/dashboard/lib/view-generator.js +163 -0
  78. package/src/dashboard/lib/voice-dna-parser.js +57 -0
  79. package/src/dashboard/lib/walkthrough-script.js +140 -0
  80. package/src/dashboard/lib/workflow-graph-parser.js +170 -0
  81. package/src/dashboard/lib/workflow-library-parser.js +146 -0
  82. package/src/dashboard/server.js +2024 -0
  83. package/src/dashboard/shortcut.js +0 -0
  84. package/src/dashboard/static/setup-poc.html +306 -0
  85. package/src/dashboard/static/walkthrough-poc.html +580 -0
  86. package/src/dashboard/styles.css +1201 -0
  87. package/src/dashboard/templates/index.html +278 -0
  88. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  89. package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  90. package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  91. package/src/dashboard/ui/dist/assets/hero.png +0 -0
  92. package/src/dashboard/ui/dist/assets/index-598CSGOZ.js +157 -0
  93. package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
  94. package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -0
  95. package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  96. package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  97. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  98. package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  99. package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  100. package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
  101. package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
  102. package/src/dashboard/ui/dist/icon.svg +4 -0
  103. package/src/dashboard/ui/dist/index.html +18 -0
  104. package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
  105. package/src/dashboard/ui/dist/registerSW.js +1 -0
  106. package/src/dashboard/ui/dist/sw.js +1 -0
  107. package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
@@ -0,0 +1,331 @@
1
+ "use strict";
2
+
3
+ // transcript-parser.js — Mover memory engine, Layer 0 (ingestion).
4
+ // STREAMS Claude Code jsonl session transcripts line-by-line (readline) and distils
5
+ // each session into ONE high-signal digest doc: a capped searchable text sample plus
6
+ // runtime artifacts (commands, files, tools, errors, commits) that exist in NO Daily Note.
7
+ //
8
+ // Why streaming, not readFileSync: real sessions reach >512MB → readFileSync throws
9
+ // ERR_STRING_TOO_LONG (Node's max string length). Streaming handles any size with
10
+ // bounded memory — we never hold the whole file, only a capped digest.
11
+ //
12
+ // Reuses the role/ts/content + tool_use extraction shape from src/hooks/session-stub-extractor.js.
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const os = require("os");
17
+ const readline = require("readline");
18
+ const { ngramSets, matchLinks } = require("./memory-text");
19
+
20
+ const DECISION = /\b(decided|let'?s go with|the plan is|we'?ll |going with|instead of|the move is|we should|chose to|verdict|the answer is|conclusion)\b/i;
21
+ // Matches `git [flags] commit [flags] -m|-am "<subject>"`. Flags between git/commit/-m are
22
+ // allowed (e.g. `git -C /repo commit -m`), bounded by no-quote so it can't span across a
23
+ // quoted arg into an unrelated command. The captured subject is validated by extractCommitSubject.
24
+ const COMMIT_RE = /git\b[^"']*\bcommit\b[^"']*-a?m\s+["']([^"']{0,90})/i;
25
+
26
+ // A git-commit subject from a Bash command, or null. COMMIT_RE matches the `-m "<subject>"`
27
+ // shape, but on the real corpus a commit whose message is a heredoc / command-substitution
28
+ // (`-m "$(cat <<EOF`) captures the shell syntax, not the subject — noise in the commit list.
29
+ // Reject those, bare escapes, and empties; keep only a plausible one-line subject.
30
+ function extractCommitSubject(command) {
31
+ const m = String(command || "").match(COMMIT_RE);
32
+ if (!m) return null;
33
+ const s = (m[1] || "").trim();
34
+ if (s.length < 3) return null; // empty or too short to be a subject
35
+ if (/^\$\(/.test(s) || /<</.test(s)) return null; // command substitution / heredoc body
36
+ if (/^[\\\s]+$/.test(s)) return null; // only backslashes / whitespace
37
+ return s;
38
+ }
39
+
40
+ // Slash-command (workflow) detection for error attribution (C1). The transcript form is
41
+ // authoritative: a real invocation is wrapped as <command-name>/log</command-name>. A bare
42
+ // leading "/cmd" is a weaker fallback, constrained so an absolute home path can't
43
+ // masquerade as a command — the command word must end the token (whitespace or EOL after it).
44
+ const CMDTAG_RE = /<command-name>\s*\/?([a-z][\w-]*)/i;
45
+ const SLASH_RE = /^\s*\/([a-z][\w-]*)(?:\s|$)/i;
46
+ function detectCommand(text) {
47
+ if (typeof text !== "string" || !text) return null;
48
+ const m = text.match(CMDTAG_RE) || text.match(SLASH_RE);
49
+ return m ? "/" + m[1].toLowerCase() : null;
50
+ }
51
+
52
+ const DEFAULT_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
53
+
54
+ // ── session enumeration (walk + dedup + subagent handling) ──
55
+
56
+ function deriveProject(dirName) {
57
+ const i = dirName.indexOf("-01-Projects-");
58
+ if (i >= 0) return dirName.slice(i + "-01-Projects-".length).replace(/-/g, " ").trim();
59
+ if (/Architect-OS$/.test(dirName)) return "(vault root)";
60
+ return dirName.replace(/^-+/, "").replace(/-/g, " ").trim();
61
+ }
62
+
63
+ function detectAgent(dirName) {
64
+ const d = dirName.toLowerCase();
65
+ if (d.includes("gemini") || d.includes("antigravity")) return "gemini";
66
+ if (d.includes("codex")) return "codex";
67
+ if (d.includes("cursor")) return "cursor";
68
+ return "claude-code";
69
+ }
70
+
71
+ function walkJsonl(dir, projectDir, acc) {
72
+ let ents;
73
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
74
+ for (const e of ents) {
75
+ const p = path.join(dir, e.name);
76
+ if (e.isDirectory()) {
77
+ walkJsonl(p, projectDir, acc);
78
+ } else if (e.name.endsWith(".jsonl")) {
79
+ const st = (() => { try { return fs.statSync(p); } catch { return null; } })();
80
+ if (!st) continue;
81
+ const isSubagent = /(^|\/)subagents(\/|$)/.test(p) || e.name.startsWith("agent-");
82
+ acc.push({
83
+ sessionId: e.name.replace(/\.jsonl$/, ""),
84
+ file: p,
85
+ projectDir,
86
+ project: deriveProject(projectDir),
87
+ agent: detectAgent(projectDir),
88
+ size: st.size,
89
+ mtime: st.mtimeMs,
90
+ isSubagent,
91
+ });
92
+ }
93
+ }
94
+ return acc;
95
+ }
96
+
97
+ // Enumerate every session under ~/.claude/projects, deduped by session UUID.
98
+ // The Windows-mirror dir (C--Users-<user>-…) repeats the SAME UUIDs → we keep the
99
+ // largest copy (most complete) so a session is never indexed twice.
100
+ function enumerateSessions(projectsDir = DEFAULT_PROJECTS_DIR, opts = {}) {
101
+ const includeSubagents = !!opts.includeSubagents;
102
+ const top = (() => { try { return fs.readdirSync(projectsDir, { withFileTypes: true }); } catch { return []; } })();
103
+ const all = [];
104
+ for (const e of top) {
105
+ if (!e.isDirectory()) continue;
106
+ walkJsonl(path.join(projectsDir, e.name), e.name, all);
107
+ }
108
+ const byId = new Map();
109
+ const subagents = [];
110
+ for (const rec of all) {
111
+ if (rec.isSubagent) { subagents.push(rec); continue; }
112
+ const prev = byId.get(rec.sessionId);
113
+ if (!prev || rec.size > prev.size) byId.set(rec.sessionId, rec);
114
+ }
115
+ const sessions = [...byId.values()].sort((a, b) => b.mtime - a.mtime);
116
+ return includeSubagents ? sessions.concat(subagents) : sessions;
117
+ }
118
+
119
+ // ── single-session parse (streaming) ──
120
+
121
+ function clampDuration(firstTs, lastTs) {
122
+ if (!firstTs || !lastTs) return null;
123
+ const d = (Date.parse(lastTs) - Date.parse(firstTs)) / 60000;
124
+ // Guard the cross-machine / resumed-session artifact (the proof saw 155,757 min ≈ 108 days).
125
+ if (!Number.isFinite(d) || d < 0 || d > 72 * 60) return null;
126
+ return Math.round(d);
127
+ }
128
+
129
+ // Pick a readable snippet: prefer a high-signal decision, else the first prose-like sentence in
130
+ // the sample, skipping shell/heredoc/code noise (e.g. "$(cat <<…") that makes recall unreadable.
131
+ function pickSnippet(decisions, sampleText) {
132
+ const readable = (s) => {
133
+ const t = String(s || "").trim();
134
+ if (t.length < 16) return false;
135
+ if (/^[$<`{[(]/.test(t)) return false;
136
+ if (/\$\(|<<|```|^\s*git\b/.test(t.slice(0, 28))) return false;
137
+ const letters = (t.match(/[a-z]/gi) || []).length;
138
+ return letters / t.length > 0.45;
139
+ };
140
+ for (const d of decisions || []) if (readable(d)) return d.trim().replace(/\s+/g, " ").slice(0, 220);
141
+ for (const s of String(sampleText || "").split(/(?<=[.!?])\s+|\n+/)) {
142
+ if (readable(s)) return s.trim().replace(/\s+/g, " ").slice(0, 220);
143
+ }
144
+ return String(sampleText || "").replace(/\s+/g, " ").trim().slice(0, 220);
145
+ }
146
+
147
+ // Parse one transcript file → a single digest doc. Resolves (never rejects) so a bad
148
+ // file degrades to {error:true} instead of killing a backfill over thousands of files.
149
+ function parseTranscript(file, opts = {}) {
150
+ const vocab = opts.vocab || null;
151
+ const MAX = opts.maxSampleChars || 1_200_000;
152
+ const ASSISTANT_BUDGET = opts.assistantBudget || 300_000; // assistant prose is low-signal; cap hard.
153
+
154
+ return new Promise((resolve) => {
155
+ const turns = { user: 0, assistant: 0 };
156
+ const tools = Object.create(null);
157
+ const files = new Set();
158
+ const commands = [];
159
+ const commits = [];
160
+ const errors = [];
161
+ const decisions = [];
162
+ let firstTs = null, lastTs = null;
163
+ // C1 — workflow attribution. currentCommand = the most recent slash-command seen; on each
164
+ // error we snapshot it into lastCommand so a digest carries WHICH workflow was running when
165
+ // things broke. Self-improvement (GEPA/§5.5) reads this to name the workflow to fix.
166
+ let currentCommand = null, lastCommand = null;
167
+
168
+ // sampleParts = the high-signal digest (user turns + decisions + artifacts first,
169
+ // assistant snippets only while budget remains). chars tracks the running total.
170
+ const sampleParts = [];
171
+ let chars = 0;
172
+ let assistantChars = 0;
173
+ let truncated = false;
174
+ const push = (t) => {
175
+ if (!t) return;
176
+ if (chars >= MAX) { truncated = true; return; }
177
+ sampleParts.push(t);
178
+ chars += t.length;
179
+ };
180
+
181
+ let stream;
182
+ try { stream = fs.createReadStream(file, "utf8"); } catch { return resolve({ file, error: true }); }
183
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
184
+
185
+ rl.on("line", (line) => {
186
+ if (!line || !line.trim()) return;
187
+ let obj; try { obj = JSON.parse(line); } catch { return; }
188
+ // A line can be VALID JSON yet not an object — `null`, a scalar, or an array (corruption /
189
+ // partial writes). `null.message` below would throw UNCAUGHT in this readline handler and kill
190
+ // an entire backfill (rl.on("error") only catches stream errors, not handler throws). Skip it.
191
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return;
192
+ const msg = obj.message || obj;
193
+ const role = msg.role || obj.type || "";
194
+ const ts = obj.timestamp || obj.created_at || msg.timestamp || "";
195
+ if (ts) { if (!firstTs) firstTs = ts; lastTs = ts; }
196
+ const content = msg.content;
197
+
198
+ if (role === "user") {
199
+ turns.user++;
200
+ if (typeof content === "string") {
201
+ const cmd = detectCommand(content); // scan even <command-name> turns (not pushed as prose)
202
+ if (cmd) currentCommand = cmd;
203
+ if (!content.startsWith("<")) push(content);
204
+ } else if (Array.isArray(content)) {
205
+ for (const c of content) {
206
+ if (!c) continue;
207
+ if (c.type === "text" && c.text) {
208
+ const cmd = detectCommand(c.text);
209
+ if (cmd) currentCommand = cmd;
210
+ if (!c.text.startsWith("<")) push(c.text);
211
+ }
212
+ if (c.type === "tool_result" && c.is_error) {
213
+ if (currentCommand) lastCommand = currentCommand; // attribute this error to its workflow
214
+ // tool_result content is a string OR the Messages-API array of blocks. Extract the text
215
+ // block(s) so the error reads as plain text — NOT a `[{"type":"text",...}]` JSON blob
216
+ // that would make GEPA signatures/samples unreadable (L1).
217
+ const t = typeof c.content === "string"
218
+ ? c.content
219
+ : Array.isArray(c.content)
220
+ ? (c.content.filter((x) => x && x.type === "text" && x.text).map((x) => x.text).join(" ") || JSON.stringify(c.content))
221
+ : JSON.stringify(c.content || "");
222
+ const e = t.replace(/\s+/g, " ").slice(0, 100);
223
+ if (e) { errors.push(e); push(e); }
224
+ }
225
+ }
226
+ }
227
+ } else if (role === "assistant") {
228
+ turns.assistant++;
229
+ if (Array.isArray(content)) {
230
+ for (const c of content) {
231
+ if (!c) continue;
232
+ if (c.type === "text" && c.text) {
233
+ // decisions are high-signal — always captured; full prose is budget-capped.
234
+ for (const s of c.text.split(/(?<=[.!?])\s+/)) {
235
+ if (DECISION.test(s) && s.length < 180 && decisions.length < 40) {
236
+ const d = s.trim();
237
+ decisions.push(d);
238
+ push(d);
239
+ }
240
+ }
241
+ if (assistantChars < ASSISTANT_BUDGET) {
242
+ const snip = c.text.slice(0, 240);
243
+ assistantChars += snip.length;
244
+ push(snip);
245
+ }
246
+ }
247
+ if (c.type === "tool_use" && c.name) {
248
+ tools[c.name] = (tools[c.name] || 0) + 1;
249
+ const inp = c.input || {};
250
+ if (typeof inp.file_path === "string") files.add(inp.file_path);
251
+ if (c.name === "Bash" && typeof inp.command === "string") {
252
+ const cmd = inp.command.replace(/\s+/g, " ").trim().slice(0, 100);
253
+ if (commands.length < 200) commands.push(cmd);
254
+ const subj = extractCommitSubject(inp.command);
255
+ if (subj) commits.push(subj);
256
+ }
257
+ }
258
+ }
259
+ }
260
+ }
261
+ });
262
+
263
+ rl.on("close", () => {
264
+ // Fold compact artifact text into the digest so commits/files/errors are searchable.
265
+ const fileBases = [...files].map((f) => path.basename(f));
266
+ const artifactText =
267
+ "tools " + Object.keys(tools).join(" ") + "\n" +
268
+ "files " + fileBases.slice(0, 400).join(" ") + "\n" +
269
+ "commits " + commits.join(" | ") + "\n" +
270
+ (errors.length ? "errors " + errors.slice(0, 30).join(" | ") : "");
271
+ sampleParts.push(artifactText);
272
+
273
+ const sampleText = sampleParts.join("\n");
274
+ let candidateLinks = [];
275
+ if (vocab) {
276
+ try { candidateLinks = matchLinks(ngramSets(sampleText), vocab); } catch {}
277
+ }
278
+ resolve({
279
+ file,
280
+ sessionId: opts.sessionId || path.basename(file).replace(/\.jsonl$/, ""),
281
+ project: opts.project || null,
282
+ agent: opts.agent || null,
283
+ firstTs, lastTs,
284
+ durationMin: clampDuration(firstTs, lastTs),
285
+ turns,
286
+ sampleText,
287
+ snippet: pickSnippet(decisions, sampleText),
288
+ sampleChars: sampleText.length,
289
+ truncated,
290
+ artifacts: {
291
+ tools,
292
+ files: fileBases.slice(0, 60),
293
+ fileCount: files.size,
294
+ commands: dedupe(commands).slice(0, 12),
295
+ commits: dedupe(commits).slice(0, 12),
296
+ errors: dedupe(errors).slice(0, 6),
297
+ },
298
+ decisions: dedupe(decisions).slice(0, 12),
299
+ candidateLinks,
300
+ meta: { lastCommand }, // C1: workflow active at the last error (null if none) — for attribution
301
+ });
302
+ });
303
+
304
+ rl.on("error", () => resolve({ file, error: true }));
305
+ });
306
+ }
307
+
308
+ function dedupe(a) { return [...new Set(a)]; }
309
+
310
+ module.exports = { enumerateSessions, parseTranscript, deriveProject, extractCommitSubject, DEFAULT_PROJECTS_DIR };
311
+
312
+ // ── CLI (standalone testing) ──
313
+ if (require.main === module) {
314
+ (async () => {
315
+ const arg = process.argv[2];
316
+ if (arg === "--enumerate") {
317
+ const dir = process.argv[3] || DEFAULT_PROJECTS_DIR;
318
+ const s = enumerateSessions(dir);
319
+ const totalMB = s.reduce((a, x) => a + x.size, 0) / 1e6;
320
+ console.log(`${s.length} unique sessions, ${totalMB.toFixed(0)}MB (deduped, subagents excluded)`);
321
+ for (const x of s.slice(0, 15)) {
322
+ console.log(` ${(x.size / 1e6).toFixed(0).padStart(4)}MB ${x.project.slice(0, 28).padEnd(28)} ${x.sessionId}`);
323
+ }
324
+ } else if (arg) {
325
+ const r = await parseTranscript(arg, {});
326
+ console.log(JSON.stringify({ ...r, sampleText: r.sampleText ? r.sampleText.slice(0, 400) + "…" : "" }, null, 2));
327
+ } else {
328
+ console.log("usage: node transcript-parser.js <file.jsonl> | --enumerate [projectsDir]");
329
+ }
330
+ })();
331
+ }
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+
3
+ // F39 — Vault Wiki-Link Graph
4
+ //
5
+ // Walks the entire vault, parses every markdown file for [[wiki-links]],
6
+ // builds a node-edge graph. Distinct from PublicProofGraph (which shows
7
+ // BEHAVIORAL patterns); this is the actual wiki-link topology of the
8
+ // user's Obsidian vault.
9
+ //
10
+ // Performance: skips node_modules, .git, .obsidian, 04_Archives.
11
+ // Caps total nodes at 600 by default to keep payload reasonable. Wraps
12
+ // links to non-existent files as orphan nodes (visualizes broken refs).
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+
17
+ const DEFAULT_LIMIT = 600;
18
+ const SKIP_DIRS = new Set([
19
+ "node_modules", ".git", ".github", ".obsidian", ".trash",
20
+ "04_Archives", ".claude", ".gemini", ".cursor", ".windsurf",
21
+ "dist", "build", ".vite", ".next", ".cache", ".turbo",
22
+ ".vscode", ".idea", "coverage",
23
+ // Source / dev directories at any depth — these contain code, internal
24
+ // research, and documentation that uses literal `[[Wiki Links]]` as
25
+ // examples. Not vault knowledge; they pollute the graph with phantom
26
+ // broken refs to placeholders like `[[Project Name]]`.
27
+ "src", "dev", "ui", "ui-v2", "ui-legacy",
28
+ ]);
29
+
30
+ // File NAME pattern to skip walking entirely. `_TEMPLATE.md` files exist
31
+ // throughout the structure (Entity templates, project scaffolds) and only
32
+ // contain placeholder wiki-links by design.
33
+ const SKIP_FILE_RE = /^_TEMPLATE/i;
34
+
35
+ // Directories whose files contain documentation/placeholder wiki-links
36
+ // like [[Project Name]], [[Daily - YYYY-MM-DD]], [[Entity Name]]. Those
37
+ // are template variables, not real broken refs — we still WALK these
38
+ // dirs (so the files appear in the file count) but we don't parse their
39
+ // links into the graph, which used to produce dozens of false-positive
40
+ // "broken" entries.
41
+ const TEMPLATE_DIRS_RE = /(?:^|\/)(?:Templates|_Template Project|template|templates)(?:\/|$)/i;
42
+
43
+ // Files to skip when they appear by name — these are framework / agent
44
+ // scaffolding, not user knowledge.
45
+ const SKIP_FILES = new Set([
46
+ "SKILL.md", "AGENTS.md", "CLAUDE.md", "GEMINI.md", "CURSOR.md",
47
+ ]);
48
+
49
+ function walkMarkdown(dir, out, depth = 0) {
50
+ if (depth > 6) return; // cap recursion
51
+ let entries;
52
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
53
+ for (const e of entries) {
54
+ // Skip ALL dotfiles/dotdirs by default (they're config, not vault content)
55
+ if (e.name.startsWith(".")) continue;
56
+ if (e.isDirectory()) {
57
+ if (SKIP_DIRS.has(e.name)) continue;
58
+ walkMarkdown(path.join(dir, e.name), out, depth + 1);
59
+ } else if (e.isFile() && e.name.endsWith(".md")) {
60
+ if (SKIP_FILES.has(e.name)) continue;
61
+ if (SKIP_FILE_RE.test(e.name)) continue;
62
+ out.push(path.join(dir, e.name));
63
+ }
64
+ }
65
+ }
66
+
67
+ // Parse [[wiki-links]] from a markdown blob. Handles aliases:
68
+ // [[File Name]]
69
+ // [[File Name|Display Text]]
70
+ // [[Folder/File]]
71
+ // Returns array of target stems (filename without .md extension).
72
+ function parseWikiLinks(text) {
73
+ const out = [];
74
+ const re = /\[\[([^\]|#]+?)(?:[#|][^\]]*)?\]\]/g;
75
+ let m;
76
+ while ((m = re.exec(text)) !== null) {
77
+ const raw = m[1].trim();
78
+ if (!raw) continue;
79
+ // Strip optional path prefix; we match by filename stem
80
+ const stem = raw.split("/").pop()?.replace(/\.md$/i, "") || "";
81
+ if (stem) out.push(stem);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ function fileStem(filePath) {
87
+ return path.basename(filePath, ".md");
88
+ }
89
+
90
+ function topFolder(vault, filePath) {
91
+ const rel = path.relative(vault, filePath);
92
+ const parts = rel.split(path.sep);
93
+ return parts[0] || "(root)";
94
+ }
95
+
96
+ function buildVaultGraph(vault, { limit = DEFAULT_LIMIT } = {}) {
97
+ if (!vault || !fs.existsSync(vault)) {
98
+ return { available: false, error: "vault path not found" };
99
+ }
100
+
101
+ const files = [];
102
+ walkMarkdown(vault, files);
103
+
104
+ // Build stem → filePath index for resolving links
105
+ const stemToFile = new Map();
106
+ for (const f of files) {
107
+ const stem = fileStem(f);
108
+ if (!stemToFile.has(stem)) stemToFile.set(stem, f);
109
+ }
110
+
111
+ const nodes = new Map(); // id → { id, label, folder, fileCount?, brokenLink?, kind }
112
+ const edges = []; // { source, target }
113
+ const inDeg = new Map(); // for centrality sizing
114
+ const outDeg = new Map();
115
+
116
+ function ensureNode(stem, fromFilePath) {
117
+ if (nodes.has(stem)) return;
118
+ const filePath = stemToFile.get(stem);
119
+ const folder = filePath ? topFolder(vault, filePath) : (fromFilePath ? topFolder(vault, fromFilePath) : "(orphan)");
120
+ nodes.set(stem, {
121
+ id: stem,
122
+ label: stem,
123
+ folder,
124
+ brokenLink: !filePath, // true if this stem is referenced but no file exists
125
+ kind: filePath ? "file" : "orphan",
126
+ });
127
+ }
128
+
129
+ for (const f of files) {
130
+ const stem = fileStem(f);
131
+ ensureNode(stem, f);
132
+ let text;
133
+ try { text = fs.readFileSync(f, "utf8"); } catch { continue; }
134
+ // Skip wiki-link parsing for template files — they contain
135
+ // documentation placeholders like [[Project Name]] / [[Daily - YYYY-MM-DD]]
136
+ // that are not real references and would otherwise inflate brokenLinks.
137
+ const rel = path.relative(vault, f);
138
+ if (TEMPLATE_DIRS_RE.test(rel)) continue;
139
+ const links = parseWikiLinks(text);
140
+ for (const target of links) {
141
+ if (target === stem) continue; // skip self-links
142
+ ensureNode(target, f);
143
+ edges.push({ source: stem, target });
144
+ outDeg.set(stem, (outDeg.get(stem) || 0) + 1);
145
+ inDeg.set(target, (inDeg.get(target) || 0) + 1);
146
+ }
147
+ }
148
+
149
+ // Compute full-vault stats ACROSS ALL NODES, not just the top-N visible.
150
+ // Prior bug: folderCounts / brokenLinks / orphans were derived from the
151
+ // capped `ranked` slice → "orphans = 0" because the top-N by definition
152
+ // have non-zero degree, and folder bars summed to ~200 not 1,800+.
153
+ const fullFolderCounts = {};
154
+ let totalOrphans = 0;
155
+ let totalBroken = 0;
156
+ for (const n of nodes.values()) {
157
+ const deg = (inDeg.get(n.id) || 0) + (outDeg.get(n.id) || 0);
158
+ if (n.brokenLink) {
159
+ totalBroken++;
160
+ // Don't count broken-link stems in folder distribution — they
161
+ // aren't real files, they're dangling references.
162
+ continue;
163
+ }
164
+ if (deg === 0) totalOrphans++;
165
+ fullFolderCounts[n.folder] = (fullFolderCounts[n.folder] || 0) + 1;
166
+ }
167
+
168
+ // Rank nodes by total degree, keep top N (for any UI that wants the
169
+ // most-connected slice; the totals above are over ALL nodes).
170
+ const ranked = Array.from(nodes.values())
171
+ .map(n => ({ ...n, degree: (inDeg.get(n.id) || 0) + (outDeg.get(n.id) || 0), inDegree: inDeg.get(n.id) || 0, outDegree: outDeg.get(n.id) || 0 }))
172
+ .sort((a, b) => b.degree - a.degree)
173
+ .slice(0, limit);
174
+
175
+ // Surface a separate slice of ALL real orphans + ALL broken so the UI
176
+ // can show concrete examples without consumers re-walking the graph.
177
+ const allOrphans = Array.from(nodes.values())
178
+ .filter(n => !n.brokenLink && ((inDeg.get(n.id) || 0) + (outDeg.get(n.id) || 0)) === 0)
179
+ .map(n => ({ ...n, degree: 0, inDegree: 0, outDegree: 0 }));
180
+ const allBroken = Array.from(nodes.values())
181
+ .filter(n => n.brokenLink)
182
+ .map(n => ({ ...n, degree: (inDeg.get(n.id) || 0) + (outDeg.get(n.id) || 0), inDegree: inDeg.get(n.id) || 0, outDegree: outDeg.get(n.id) || 0 }))
183
+ .sort((a, b) => b.inDegree - a.inDegree);
184
+
185
+ const keepIds = new Set(ranked.map(n => n.id));
186
+ const filteredEdges = edges.filter(e => keepIds.has(e.source) && keepIds.has(e.target));
187
+
188
+ return {
189
+ available: true,
190
+ nodes: ranked,
191
+ edges: filteredEdges,
192
+ totalFiles: files.length,
193
+ totalNodes: nodes.size,
194
+ totalEdges: edges.length,
195
+ visibleNodes: ranked.length,
196
+ visibleEdges: filteredEdges.length,
197
+ folderCounts: fullFolderCounts,
198
+ brokenLinks: totalBroken,
199
+ orphanCount: totalOrphans,
200
+ orphans: allOrphans.slice(0, 24),
201
+ brokenSamples: allBroken.slice(0, 24),
202
+ };
203
+ }
204
+
205
+ module.exports = { buildVaultGraph, parseWikiLinks };