mover-os 4.7.7 → 4.7.9
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 +34 -24
- package/install.js +2868 -251
- package/package.json +15 -3
- package/src/dashboard/build.js +1541 -0
- package/src/dashboard/dashboard.js +276 -0
- package/src/dashboard/index.js +319 -0
- package/src/dashboard/lib/activation-log.js +297 -0
- package/src/dashboard/lib/active-context-parser.js +189 -0
- package/src/dashboard/lib/agent-command.js +93 -0
- package/src/dashboard/lib/agent-detect.js +255 -0
- package/src/dashboard/lib/agent-detector.js +92 -0
- package/src/dashboard/lib/agent-session.js +483 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -0
- package/src/dashboard/lib/approval-registry.js +170 -0
- package/src/dashboard/lib/auto-learnings-parser.js +183 -0
- package/src/dashboard/lib/cli-usage-parser.js +92 -0
- package/src/dashboard/lib/config-parser.js +109 -0
- package/src/dashboard/lib/connect-recommender.js +131 -0
- package/src/dashboard/lib/correlations-parser.js +231 -0
- package/src/dashboard/lib/daily-note-resolver.js +228 -0
- package/src/dashboard/lib/date-utils.js +43 -0
- package/src/dashboard/lib/distribution-parser.js +137 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +88 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-default-fingerprints.json +53 -0
- package/src/dashboard/lib/engine-health.js +173 -0
- package/src/dashboard/lib/engine-writer.js +1831 -0
- package/src/dashboard/lib/execution-plan.js +125 -0
- package/src/dashboard/lib/experiments-parser.js +429 -0
- package/src/dashboard/lib/feed-parser.js +294 -0
- package/src/dashboard/lib/forked-future.js +60 -0
- package/src/dashboard/lib/goal-forecast.js +427 -0
- package/src/dashboard/lib/goals-parser.js +67 -0
- package/src/dashboard/lib/health-protocols-parser.js +133 -0
- package/src/dashboard/lib/hook-activity.js +48 -0
- package/src/dashboard/lib/hook-indexer.js +169 -0
- package/src/dashboard/lib/hourly-activity-parser.js +143 -0
- package/src/dashboard/lib/identity-parser.js +85 -0
- package/src/dashboard/lib/ingestion.js +418 -0
- package/src/dashboard/lib/library-indexer-v2.js +226 -0
- package/src/dashboard/lib/library-indexer.js +105 -0
- package/src/dashboard/lib/library-search.js +290 -0
- package/src/dashboard/lib/log-activation.sh +61 -0
- package/src/dashboard/lib/memory-curator.js +97 -0
- package/src/dashboard/lib/memory-gardener.js +177 -0
- package/src/dashboard/lib/memory-gepa.js +102 -0
- package/src/dashboard/lib/memory-index.js +519 -0
- package/src/dashboard/lib/memory-rerank.js +72 -0
- package/src/dashboard/lib/memory-text.js +136 -0
- package/src/dashboard/lib/metrics-log-parser.js +76 -0
- package/src/dashboard/lib/moves-usage-parser.js +184 -0
- package/src/dashboard/lib/onboarding-forge.js +70 -0
- package/src/dashboard/lib/override-outcome-parser.js +68 -0
- package/src/dashboard/lib/override-summary.js +73 -0
- package/src/dashboard/lib/paths.js +192 -0
- package/src/dashboard/lib/pattern-manifest-loader.js +29 -0
- package/src/dashboard/lib/phantom-strategy.js +129 -0
- package/src/dashboard/lib/pid-markers.js +80 -0
- package/src/dashboard/lib/project-scanner.js +121 -0
- package/src/dashboard/lib/promise-wall.js +88 -0
- package/src/dashboard/lib/record-score.js +173 -0
- package/src/dashboard/lib/redaction.js +140 -0
- package/src/dashboard/lib/refusal-parser.js +44 -0
- package/src/dashboard/lib/regenerate-manifest.js +206 -0
- package/src/dashboard/lib/rewind-snapshots.js +689 -0
- package/src/dashboard/lib/roast-wall-parser.js +200 -0
- package/src/dashboard/lib/run-registry.js +286 -0
- package/src/dashboard/lib/safe-write.js +63 -0
- package/src/dashboard/lib/session-log-parser.js +145 -0
- package/src/dashboard/lib/session-time-parser.js +158 -0
- package/src/dashboard/lib/skill-index.js +171 -0
- package/src/dashboard/lib/skill-indexer.js +118 -0
- package/src/dashboard/lib/skill-recommender.js +689 -0
- package/src/dashboard/lib/state-core/backfill.js +298 -0
- package/src/dashboard/lib/state-core/dryrun.js +188 -0
- package/src/dashboard/lib/state-core/event-log.js +615 -0
- package/src/dashboard/lib/state-core/events.js +265 -0
- package/src/dashboard/lib/state-core/projections.js +376 -0
- package/src/dashboard/lib/state-core/start-close.js +162 -0
- package/src/dashboard/lib/state-core/trial.js +96 -0
- package/src/dashboard/lib/strategy-parser.js +248 -0
- package/src/dashboard/lib/strategy-protocol-parser.js +135 -0
- package/src/dashboard/lib/streak-parser.js +95 -0
- package/src/dashboard/lib/suggested-now.js +254 -0
- package/src/dashboard/lib/tool-awareness.js +125 -0
- package/src/dashboard/lib/transcript-parser.js +371 -0
- package/src/dashboard/lib/vault-graph-parser.js +205 -0
- package/src/dashboard/lib/view-generator.js +163 -0
- package/src/dashboard/lib/voice-dna-parser.js +57 -0
- package/src/dashboard/lib/walkthrough-script.js +140 -0
- package/src/dashboard/lib/workflow-graph-parser.js +170 -0
- package/src/dashboard/lib/workflow-library-parser.js +146 -0
- package/src/dashboard/server.js +2402 -0
- package/src/dashboard/shortcut.js +284 -0
- package/src/dashboard/static/setup-poc.html +306 -0
- package/src/dashboard/static/walkthrough-poc.html +580 -0
- package/src/dashboard/styles.css +1201 -0
- package/src/dashboard/templates/index.html +278 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/hero.png +0 -0
- package/src/dashboard/ui/dist/assets/index-ByVKPvLf.js +34 -0
- package/src/dashboard/ui/dist/assets/index-CCoKjUcC.js +161 -0
- package/src/dashboard/ui/dist/assets/index-CZWNQDt5.css +1 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/src/dashboard/ui/dist/assets/mover-system.css +62 -0
- package/src/dashboard/ui/dist/assets/xterm-CqkleIqs.js +1 -0
- package/src/dashboard/ui/dist/icon.svg +4 -0
- package/src/dashboard/ui/dist/index.html +18 -0
- package/src/dashboard/ui/dist/manifest.webmanifest +1 -0
- package/src/dashboard/ui/dist/registerSW.js +1 -0
- package/src/dashboard/ui/dist/sw.js +1 -0
- package/src/dashboard/ui/dist/workbox-39fa566e.js +1 -0
|
@@ -0,0 +1,371 @@
|
|
|
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 projectFromCwd(cwd) {
|
|
64
|
+
const parts = String(cwd || "").split(/[\\/]01_Projects[\\/]/);
|
|
65
|
+
if (parts.length < 2) return null;
|
|
66
|
+
const project = parts[1].split(/[\\/]/)[0];
|
|
67
|
+
return project ? project.trim() : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isBootstrapText(text) {
|
|
71
|
+
const s = String(text || "").trim();
|
|
72
|
+
return /^# AGENTS\.md instructions\b/i.test(s) ||
|
|
73
|
+
/^<environment_context\b/i.test(s) ||
|
|
74
|
+
/^<hook_prompt\b/i.test(s);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function detectAgent(dirName) {
|
|
78
|
+
const d = dirName.toLowerCase();
|
|
79
|
+
if (d.includes("gemini") || d.includes("antigravity")) return "gemini";
|
|
80
|
+
if (d.includes("codex")) return "codex";
|
|
81
|
+
if (d.includes("cursor")) return "cursor";
|
|
82
|
+
return "claude-code";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function walkJsonl(dir, projectDir, acc) {
|
|
86
|
+
let ents;
|
|
87
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
|
|
88
|
+
for (const e of ents) {
|
|
89
|
+
const p = path.join(dir, e.name);
|
|
90
|
+
if (e.isDirectory()) {
|
|
91
|
+
walkJsonl(p, projectDir, acc);
|
|
92
|
+
} else if (e.name.endsWith(".jsonl")) {
|
|
93
|
+
const st = (() => { try { return fs.statSync(p); } catch { return null; } })();
|
|
94
|
+
if (!st) continue;
|
|
95
|
+
const isSubagent = /(^|\/)subagents(\/|$)/.test(p) || e.name.startsWith("agent-");
|
|
96
|
+
acc.push({
|
|
97
|
+
sessionId: e.name.replace(/\.jsonl$/, ""),
|
|
98
|
+
file: p,
|
|
99
|
+
projectDir,
|
|
100
|
+
project: deriveProject(projectDir),
|
|
101
|
+
agent: detectAgent(projectDir),
|
|
102
|
+
size: st.size,
|
|
103
|
+
mtime: st.mtimeMs,
|
|
104
|
+
isSubagent,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return acc;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Enumerate every session under ~/.claude/projects, deduped by session UUID.
|
|
112
|
+
// The Windows-mirror dir (C--Users-<user>-…) repeats the SAME UUIDs → we keep the
|
|
113
|
+
// largest copy (most complete) so a session is never indexed twice.
|
|
114
|
+
function enumerateSessions(projectsDir = DEFAULT_PROJECTS_DIR, opts = {}) {
|
|
115
|
+
const includeSubagents = !!opts.includeSubagents;
|
|
116
|
+
const top = (() => { try { return fs.readdirSync(projectsDir, { withFileTypes: true }); } catch { return []; } })();
|
|
117
|
+
const all = [];
|
|
118
|
+
for (const e of top) {
|
|
119
|
+
if (!e.isDirectory()) continue;
|
|
120
|
+
walkJsonl(path.join(projectsDir, e.name), e.name, all);
|
|
121
|
+
}
|
|
122
|
+
const byId = new Map();
|
|
123
|
+
const subagents = [];
|
|
124
|
+
for (const rec of all) {
|
|
125
|
+
if (rec.isSubagent) { subagents.push(rec); continue; }
|
|
126
|
+
const prev = byId.get(rec.sessionId);
|
|
127
|
+
if (!prev || rec.size > prev.size) byId.set(rec.sessionId, rec);
|
|
128
|
+
}
|
|
129
|
+
const sessions = [...byId.values()].sort((a, b) => b.mtime - a.mtime);
|
|
130
|
+
return includeSubagents ? sessions.concat(subagents) : sessions;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── single-session parse (streaming) ──
|
|
134
|
+
|
|
135
|
+
function clampDuration(firstTs, lastTs) {
|
|
136
|
+
if (!firstTs || !lastTs) return null;
|
|
137
|
+
const d = (Date.parse(lastTs) - Date.parse(firstTs)) / 60000;
|
|
138
|
+
// Guard the cross-machine / resumed-session artifact (the proof saw 155,757 min ≈ 108 days).
|
|
139
|
+
if (!Number.isFinite(d) || d < 0 || d > 72 * 60) return null;
|
|
140
|
+
return Math.round(d);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Pick a readable snippet: prefer a high-signal decision, else the first prose-like sentence in
|
|
144
|
+
// the sample, skipping shell/heredoc/code noise (e.g. "$(cat <<…") that makes recall unreadable.
|
|
145
|
+
function pickSnippet(decisions, sampleText) {
|
|
146
|
+
const readable = (s) => {
|
|
147
|
+
const t = String(s || "").trim();
|
|
148
|
+
if (t.length < 16) return false;
|
|
149
|
+
if (/^[$<`{[(]/.test(t)) return false;
|
|
150
|
+
if (/\$\(|<<|```|^\s*git\b/.test(t.slice(0, 28))) return false;
|
|
151
|
+
const letters = (t.match(/[a-z]/gi) || []).length;
|
|
152
|
+
return letters / t.length > 0.45;
|
|
153
|
+
};
|
|
154
|
+
for (const d of decisions || []) if (readable(d)) return d.trim().replace(/\s+/g, " ").slice(0, 220);
|
|
155
|
+
for (const s of String(sampleText || "").split(/(?<=[.!?])\s+|\n+/)) {
|
|
156
|
+
if (readable(s)) return s.trim().replace(/\s+/g, " ").slice(0, 220);
|
|
157
|
+
}
|
|
158
|
+
return String(sampleText || "").replace(/\s+/g, " ").trim().slice(0, 220);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Parse one transcript file → a single digest doc. Resolves (never rejects) so a bad
|
|
162
|
+
// file degrades to {error:true} instead of killing a backfill over thousands of files.
|
|
163
|
+
function parseTranscript(file, opts = {}) {
|
|
164
|
+
const vocab = opts.vocab || null;
|
|
165
|
+
const MAX = opts.maxSampleChars || 1_200_000;
|
|
166
|
+
const ASSISTANT_BUDGET = opts.assistantBudget || 300_000; // assistant prose is low-signal; cap hard.
|
|
167
|
+
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
const turns = { user: 0, assistant: 0 };
|
|
170
|
+
const tools = Object.create(null);
|
|
171
|
+
const files = new Set();
|
|
172
|
+
const commands = [];
|
|
173
|
+
const commits = [];
|
|
174
|
+
const errors = [];
|
|
175
|
+
const decisions = [];
|
|
176
|
+
let firstTs = null, lastTs = null;
|
|
177
|
+
let detectedProject = opts.project || null;
|
|
178
|
+
let detectedAgent = opts.agent || null;
|
|
179
|
+
let detectedSessionId = opts.sessionId || null;
|
|
180
|
+
// C1 — workflow attribution. currentCommand = the most recent slash-command seen; on each
|
|
181
|
+
// error we snapshot it into lastCommand so a digest carries WHICH workflow was running when
|
|
182
|
+
// things broke. Self-improvement (GEPA/§5.5) reads this to name the workflow to fix.
|
|
183
|
+
let currentCommand = null, lastCommand = null;
|
|
184
|
+
|
|
185
|
+
// sampleParts = the high-signal digest (user turns + decisions + artifacts first,
|
|
186
|
+
// assistant snippets only while budget remains). chars tracks the running total.
|
|
187
|
+
const sampleParts = [];
|
|
188
|
+
let chars = 0;
|
|
189
|
+
let assistantChars = 0;
|
|
190
|
+
let truncated = false;
|
|
191
|
+
const push = (t) => {
|
|
192
|
+
if (!t) return;
|
|
193
|
+
if (chars >= MAX) { truncated = true; return; }
|
|
194
|
+
sampleParts.push(t);
|
|
195
|
+
chars += t.length;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
let stream;
|
|
199
|
+
try { stream = fs.createReadStream(file, "utf8"); } catch { return resolve({ file, error: true }); }
|
|
200
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
201
|
+
|
|
202
|
+
rl.on("line", (line) => {
|
|
203
|
+
if (!line || !line.trim()) return;
|
|
204
|
+
let obj; try { obj = JSON.parse(line); } catch { return; }
|
|
205
|
+
// A line can be VALID JSON yet not an object — `null`, a scalar, or an array (corruption /
|
|
206
|
+
// partial writes). `null.message` below would throw UNCAUGHT in this readline handler and kill
|
|
207
|
+
// an entire backfill (rl.on("error") only catches stream errors, not handler throws). Skip it.
|
|
208
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return;
|
|
209
|
+
|
|
210
|
+
// Codex rollout transcripts begin with session_meta and then wrap messages in
|
|
211
|
+
// {type:"response_item", payload:{type:"message", ...}}. Capture the cwd here because
|
|
212
|
+
// Codex stores rollouts under date folders, so the transcript path cannot identify the project.
|
|
213
|
+
if (obj.type === "session_meta" && obj.payload && typeof obj.payload === "object") {
|
|
214
|
+
detectedSessionId = detectedSessionId || obj.payload.session_id || obj.payload.id || null;
|
|
215
|
+
detectedProject = detectedProject || projectFromCwd(obj.payload.cwd);
|
|
216
|
+
if (/codex/i.test(String(obj.payload.originator || obj.payload.source || ""))) detectedAgent = "codex";
|
|
217
|
+
const metaTs = obj.timestamp || obj.payload.timestamp || "";
|
|
218
|
+
if (metaTs) { if (!firstTs) firstTs = metaTs; lastTs = metaTs; }
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const isCodexMessage = obj.type === "response_item" && obj.payload && obj.payload.type === "message";
|
|
223
|
+
const msg = isCodexMessage ? obj.payload : (obj.message || obj);
|
|
224
|
+
const role = msg.role || obj.type || "";
|
|
225
|
+
const ts = obj.timestamp || obj.created_at || msg.timestamp || "";
|
|
226
|
+
if (ts) { if (!firstTs) firstTs = ts; lastTs = ts; }
|
|
227
|
+
const content = isCodexMessage && Array.isArray(msg.content)
|
|
228
|
+
? msg.content.map((c) => c && (c.type === "input_text" || c.type === "output_text")
|
|
229
|
+
? { ...c, type: "text" }
|
|
230
|
+
: c)
|
|
231
|
+
: msg.content;
|
|
232
|
+
|
|
233
|
+
if (role === "user" && Array.isArray(content)) {
|
|
234
|
+
const textBlocks = content.filter((c) => c && c.type === "text" && c.text);
|
|
235
|
+
if (textBlocks.length && textBlocks.every((c) => isBootstrapText(c.text))) return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (role === "user") {
|
|
239
|
+
turns.user++;
|
|
240
|
+
if (typeof content === "string") {
|
|
241
|
+
const cmd = detectCommand(content); // scan even <command-name> turns (not pushed as prose)
|
|
242
|
+
if (cmd) currentCommand = cmd;
|
|
243
|
+
if (!content.startsWith("<") && !isBootstrapText(content)) push(content);
|
|
244
|
+
} else if (Array.isArray(content)) {
|
|
245
|
+
for (const c of content) {
|
|
246
|
+
if (!c) continue;
|
|
247
|
+
if (c.type === "text" && c.text) {
|
|
248
|
+
const cmd = detectCommand(c.text);
|
|
249
|
+
if (cmd) currentCommand = cmd;
|
|
250
|
+
if (!c.text.startsWith("<") && !isBootstrapText(c.text)) push(c.text);
|
|
251
|
+
}
|
|
252
|
+
if (c.type === "tool_result" && c.is_error) {
|
|
253
|
+
if (currentCommand) lastCommand = currentCommand; // attribute this error to its workflow
|
|
254
|
+
// tool_result content is a string OR the Messages-API array of blocks. Extract the text
|
|
255
|
+
// block(s) so the error reads as plain text — NOT a `[{"type":"text",...}]` JSON blob
|
|
256
|
+
// that would make GEPA signatures/samples unreadable (L1).
|
|
257
|
+
const t = typeof c.content === "string"
|
|
258
|
+
? c.content
|
|
259
|
+
: Array.isArray(c.content)
|
|
260
|
+
? (c.content.filter((x) => x && x.type === "text" && x.text).map((x) => x.text).join(" ") || JSON.stringify(c.content))
|
|
261
|
+
: JSON.stringify(c.content || "");
|
|
262
|
+
const e = t.replace(/\s+/g, " ").slice(0, 100);
|
|
263
|
+
if (e) { errors.push(e); push(e); }
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} else if (role === "assistant") {
|
|
268
|
+
turns.assistant++;
|
|
269
|
+
if (Array.isArray(content)) {
|
|
270
|
+
for (const c of content) {
|
|
271
|
+
if (!c) continue;
|
|
272
|
+
if (c.type === "text" && c.text) {
|
|
273
|
+
// decisions are high-signal — always captured; full prose is budget-capped.
|
|
274
|
+
for (const s of c.text.split(/(?<=[.!?])\s+/)) {
|
|
275
|
+
if (DECISION.test(s) && s.length < 180 && decisions.length < 40) {
|
|
276
|
+
const d = s.trim();
|
|
277
|
+
decisions.push(d);
|
|
278
|
+
push(d);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (assistantChars < ASSISTANT_BUDGET) {
|
|
282
|
+
const snip = c.text.slice(0, 240);
|
|
283
|
+
assistantChars += snip.length;
|
|
284
|
+
push(snip);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (c.type === "tool_use" && c.name) {
|
|
288
|
+
tools[c.name] = (tools[c.name] || 0) + 1;
|
|
289
|
+
const inp = c.input || {};
|
|
290
|
+
if (typeof inp.file_path === "string") files.add(inp.file_path);
|
|
291
|
+
if (c.name === "Bash" && typeof inp.command === "string") {
|
|
292
|
+
const cmd = inp.command.replace(/\s+/g, " ").trim().slice(0, 100);
|
|
293
|
+
if (commands.length < 200) commands.push(cmd);
|
|
294
|
+
const subj = extractCommitSubject(inp.command);
|
|
295
|
+
if (subj) commits.push(subj);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
rl.on("close", () => {
|
|
304
|
+
// Fold compact artifact text into the digest so commits/files/errors are searchable.
|
|
305
|
+
const fileBases = [...files].map((f) => path.basename(f));
|
|
306
|
+
const artifactText =
|
|
307
|
+
"tools " + Object.keys(tools).join(" ") + "\n" +
|
|
308
|
+
"files " + fileBases.slice(0, 400).join(" ") + "\n" +
|
|
309
|
+
"commits " + commits.join(" | ") + "\n" +
|
|
310
|
+
(errors.length ? "errors " + errors.slice(0, 30).join(" | ") : "");
|
|
311
|
+
sampleParts.push(artifactText);
|
|
312
|
+
|
|
313
|
+
const sampleText = sampleParts.join("\n");
|
|
314
|
+
let candidateLinks = [];
|
|
315
|
+
if (vocab) {
|
|
316
|
+
try { candidateLinks = matchLinks(ngramSets(sampleText), vocab); } catch {}
|
|
317
|
+
}
|
|
318
|
+
resolve({
|
|
319
|
+
file,
|
|
320
|
+
sessionId: detectedSessionId || path.basename(file).replace(/\.jsonl$/, ""),
|
|
321
|
+
project: detectedProject,
|
|
322
|
+
agent: detectedAgent || (path.basename(file).startsWith("rollout-") ? "codex" : null),
|
|
323
|
+
firstTs, lastTs,
|
|
324
|
+
durationMin: clampDuration(firstTs, lastTs),
|
|
325
|
+
turns,
|
|
326
|
+
sampleText,
|
|
327
|
+
snippet: pickSnippet(decisions, sampleText),
|
|
328
|
+
sampleChars: sampleText.length,
|
|
329
|
+
truncated,
|
|
330
|
+
artifacts: {
|
|
331
|
+
tools,
|
|
332
|
+
files: fileBases.slice(0, 60),
|
|
333
|
+
fileCount: files.size,
|
|
334
|
+
commands: dedupe(commands).slice(0, 12),
|
|
335
|
+
commits: dedupe(commits).slice(0, 12),
|
|
336
|
+
errors: dedupe(errors).slice(0, 6),
|
|
337
|
+
},
|
|
338
|
+
decisions: dedupe(decisions).slice(0, 12),
|
|
339
|
+
candidateLinks,
|
|
340
|
+
meta: { lastCommand }, // C1: workflow active at the last error (null if none) — for attribution
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
rl.on("error", () => resolve({ file, error: true }));
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function dedupe(a) { return [...new Set(a)]; }
|
|
349
|
+
|
|
350
|
+
module.exports = { enumerateSessions, parseTranscript, deriveProject, projectFromCwd, extractCommitSubject, DEFAULT_PROJECTS_DIR };
|
|
351
|
+
|
|
352
|
+
// ── CLI (standalone testing) ──
|
|
353
|
+
if (require.main === module) {
|
|
354
|
+
(async () => {
|
|
355
|
+
const arg = process.argv[2];
|
|
356
|
+
if (arg === "--enumerate") {
|
|
357
|
+
const dir = process.argv[3] || DEFAULT_PROJECTS_DIR;
|
|
358
|
+
const s = enumerateSessions(dir);
|
|
359
|
+
const totalMB = s.reduce((a, x) => a + x.size, 0) / 1e6;
|
|
360
|
+
console.log(`${s.length} unique sessions, ${totalMB.toFixed(0)}MB (deduped, subagents excluded)`);
|
|
361
|
+
for (const x of s.slice(0, 15)) {
|
|
362
|
+
console.log(` ${(x.size / 1e6).toFixed(0).padStart(4)}MB ${x.project.slice(0, 28).padEnd(28)} ${x.sessionId}`);
|
|
363
|
+
}
|
|
364
|
+
} else if (arg) {
|
|
365
|
+
const r = await parseTranscript(arg, {});
|
|
366
|
+
console.log(JSON.stringify({ ...r, sampleText: r.sampleText ? r.sampleText.slice(0, 400) + "…" : "" }, null, 2));
|
|
367
|
+
} else {
|
|
368
|
+
console.log("usage: node transcript-parser.js <file.jsonl> | --enumerate [projectsDir]");
|
|
369
|
+
}
|
|
370
|
+
})();
|
|
371
|
+
}
|
|
@@ -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 };
|