mover-os 4.7.6 → 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.
- package/install.js +763 -70
- package/package.json +15 -3
- package/src/dashboard/build.js +1503 -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 +177 -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 +462 -0
- package/src/dashboard/lib/anti-identity-detector.js +116 -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 +211 -0
- package/src/dashboard/lib/date-utils.js +35 -0
- package/src/dashboard/lib/distribution-parser.js +78 -0
- package/src/dashboard/lib/dossier-parser.js +64 -0
- package/src/dashboard/lib/drift-history.js +83 -0
- package/src/dashboard/lib/drift-score.js +119 -0
- package/src/dashboard/lib/engine-health.js +170 -0
- package/src/dashboard/lib/engine-writer.js +1685 -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 +304 -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 +212 -0
- package/src/dashboard/lib/library-indexer.js +105 -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 +470 -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/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 +226 -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/strategy-parser.js +245 -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 +331 -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 +2024 -0
- package/src/dashboard/shortcut.js +0 -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-598CSGOZ.js +157 -0
- package/src/dashboard/ui/dist/assets/index-BP--M69H.css +1 -0
- package/src/dashboard/ui/dist/assets/index-CidzmwSW.js +34 -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,200 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { dailiesDir, autoLearningsPath } = require("./paths");
|
|
6
|
+
|
|
7
|
+
// roast-wall-parser.js — Codex's #1 viral pick. Surfaces dated AI verdicts
|
|
8
|
+
// (the brutal honest one-liners /analyse-day produces). Sources:
|
|
9
|
+
// 1. Daily Notes "## Session Log" entries that contain verdict markers
|
|
10
|
+
// 2. Auto_Learnings.md CORRECTION + BELIEF SHIFT entries
|
|
11
|
+
// 3. Roast quotes embedded in Daily Note ## Session Log sections
|
|
12
|
+
//
|
|
13
|
+
// Returns ranked Roast[] with severity, date, quote, pattern attribution.
|
|
14
|
+
|
|
15
|
+
const SEVERITY_KEYWORDS = {
|
|
16
|
+
high: ["override", "broke", "violated", "again", "third time", "structural", "avoidance", "phantom"],
|
|
17
|
+
med: ["drift", "slipping", "deferred", "missed", "skipped", "stale"],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function compute({ vault, autoLearnings = null, daysLookback = 60 } = {}) {
|
|
21
|
+
if (!vault) return { available: false, count: 0, roasts: [], stats: { high: 0, med: 0, low: 0 }, sourcesChecked: 0 };
|
|
22
|
+
const roasts = [];
|
|
23
|
+
|
|
24
|
+
// False-zero discipline (T261): track how many real sources we were able to
|
|
25
|
+
// open. A wall we actually scanned but found empty is a true count:0 and
|
|
26
|
+
// stays available:true. But if NOTHING was scannable — no Auto_Learnings
|
|
27
|
+
// patterns AND no Dailies dir on disk — then count:0 is an artifact of
|
|
28
|
+
// having nothing to read, not a clean record. That flips available:false so
|
|
29
|
+
// the consumer shows "calibrating"/empty rather than "0 roasts = you're good".
|
|
30
|
+
let sourcesChecked = 0;
|
|
31
|
+
|
|
32
|
+
// Collect from Auto_Learnings — pre-categorized.
|
|
33
|
+
// False-zero fix (T261, Codex #1): the build.js fallback for an UNAVAILABLE
|
|
34
|
+
// Auto_Learnings still carries `activePatternsConf3Plus: []`, so a truthiness
|
|
35
|
+
// check on the array alone counted a non-source as "scanned" and forced
|
|
36
|
+
// available:true with count:0. Only count this source when it was genuinely
|
|
37
|
+
// readable (available !== false) AND actually had patterns to read. A real
|
|
38
|
+
// available AL with zero patterns contributes no roasts and does NOT, on its
|
|
39
|
+
// own, make the wall "scanned" — the Dailies walk below governs that case
|
|
40
|
+
// (a present Dailies dir → available:true count:0; nothing on disk →
|
|
41
|
+
// sourcesChecked stays 0 → available:false).
|
|
42
|
+
if (
|
|
43
|
+
autoLearnings &&
|
|
44
|
+
autoLearnings.available !== false &&
|
|
45
|
+
Array.isArray(autoLearnings.activePatternsConf3Plus) &&
|
|
46
|
+
autoLearnings.activePatternsConf3Plus.length > 0
|
|
47
|
+
) {
|
|
48
|
+
sourcesChecked++;
|
|
49
|
+
autoLearnings.activePatternsConf3Plus.forEach((p) => {
|
|
50
|
+
if (!p.description) return;
|
|
51
|
+
roasts.push({
|
|
52
|
+
source: "Auto_Learnings.md",
|
|
53
|
+
date: p.lastSeen || p.firstSeen || "live",
|
|
54
|
+
severity: p.confidence >= 4 ? "high" : "med",
|
|
55
|
+
quote: p.description.slice(0, 280),
|
|
56
|
+
pattern: p.id || "—",
|
|
57
|
+
confidence: p.confidence || 0,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Walk recent Daily Notes for verdict-marker lines.
|
|
63
|
+
// Audit pass 2 (find-bugs #14): existsSync check before readdirSync so
|
|
64
|
+
// fresh-install vaults (no Dailies dir yet) don't silently swallow the
|
|
65
|
+
// ENOENT inside the catch — distinguishes "no dailies dir" from "no
|
|
66
|
+
// verdict markers found in dailies."
|
|
67
|
+
let dailiesAvailable = true;
|
|
68
|
+
try {
|
|
69
|
+
const dDir = dailiesDir(vault);
|
|
70
|
+
if (!fs.existsSync(dDir)) { dailiesAvailable = false; throw Object.assign(new Error("dailies dir missing"), { code: "ENOENT" }); }
|
|
71
|
+
// False-zero fix (T261, Codex #4): only count the Dailies source as
|
|
72
|
+
// "checked" AFTER we successfully READ the directory. existsSync passing
|
|
73
|
+
// doesn't guarantee readability (permissions / race) — incrementing before
|
|
74
|
+
// readdirSync could leave sourcesChecked >= 1 on a dir we never actually
|
|
75
|
+
// scanned, fabricating available:true count:0 from an unscanned source.
|
|
76
|
+
const months = fs.readdirSync(dDir).filter((d) => /^\d{4}-\d{2}$/.test(d)).sort().reverse();
|
|
77
|
+
sourcesChecked++;
|
|
78
|
+
const cutoff = Date.now() - daysLookback * 24 * 3600 * 1000;
|
|
79
|
+
let scanned = 0;
|
|
80
|
+
for (const mDir of months) {
|
|
81
|
+
if (scanned >= 30) break;
|
|
82
|
+
const monthPath = path.join(dDir, mDir);
|
|
83
|
+
try {
|
|
84
|
+
const files = fs.readdirSync(monthPath).filter((f) => /^Daily - \d{4}-\d{2}-\d{2}\.md$/.test(f)).sort().reverse();
|
|
85
|
+
for (const f of files) {
|
|
86
|
+
if (scanned >= 30) break;
|
|
87
|
+
const m = f.match(/(\d{4})-(\d{2})-(\d{2})/);
|
|
88
|
+
if (!m) continue;
|
|
89
|
+
const t = Date.parse(`${m[1]}-${m[2]}-${m[3]}`);
|
|
90
|
+
if (!Number.isFinite(t) || t < cutoff) continue;
|
|
91
|
+
scanned++;
|
|
92
|
+
const text = safeRead(path.join(monthPath, f));
|
|
93
|
+
extractRoastsFromDaily(text, m[0], roasts);
|
|
94
|
+
}
|
|
95
|
+
} catch (_) { /* skip month */ }
|
|
96
|
+
}
|
|
97
|
+
} catch (_) { /* daily walk failed */ }
|
|
98
|
+
|
|
99
|
+
// Rank: severity → date desc → confidence
|
|
100
|
+
const sev = { high: 0, med: 1, low: 2 };
|
|
101
|
+
roasts.sort((a, b) => {
|
|
102
|
+
const s = (sev[a.severity] ?? 3) - (sev[b.severity] ?? 3);
|
|
103
|
+
if (s !== 0) return s;
|
|
104
|
+
if (a.date && b.date && a.date !== "live" && b.date !== "live") {
|
|
105
|
+
return String(b.date).localeCompare(String(a.date));
|
|
106
|
+
}
|
|
107
|
+
return (b.confidence || 0) - (a.confidence || 0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Dedupe by first 80 chars of quote
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const deduped = roasts.filter((r) => {
|
|
113
|
+
const key = (r.quote || "").slice(0, 80).toLowerCase().replace(/\s+/g, " ");
|
|
114
|
+
if (seen.has(key)) return false;
|
|
115
|
+
seen.add(key);
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// If no source was openable, count:0 is meaningless absence, not a clean
|
|
120
|
+
// wall — flag unavailable so the consumer renders an empty/calibrating
|
|
121
|
+
// state instead of "0 roasts" read as "all clear". A scanned-but-empty
|
|
122
|
+
// wall (sourcesChecked >= 1) keeps available:true with a real count:0.
|
|
123
|
+
if (sourcesChecked === 0) {
|
|
124
|
+
return {
|
|
125
|
+
available: false,
|
|
126
|
+
count: 0,
|
|
127
|
+
roasts: [],
|
|
128
|
+
stats: { high: 0, med: 0, low: 0 },
|
|
129
|
+
sourcesChecked: 0,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
available: true,
|
|
135
|
+
count: deduped.length,
|
|
136
|
+
roasts: deduped.slice(0, 24),
|
|
137
|
+
stats: {
|
|
138
|
+
high: deduped.filter((r) => r.severity === "high").length,
|
|
139
|
+
med: deduped.filter((r) => r.severity === "med").length,
|
|
140
|
+
low: deduped.filter((r) => r.severity === "low").length,
|
|
141
|
+
},
|
|
142
|
+
sourcesChecked,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractRoastsFromDaily(text, dateKey, accumulator) {
|
|
147
|
+
if (!text) return;
|
|
148
|
+
// Find roast markers in session logs — look for italicized quotes,
|
|
149
|
+
// [FLAG], [VERDICT], or block-quote lines with sharp prose.
|
|
150
|
+
const lines = text.split(/\n/);
|
|
151
|
+
for (let i = 0; i < lines.length; i++) {
|
|
152
|
+
const line = lines[i];
|
|
153
|
+
// Block quote with sharp prose >40 chars
|
|
154
|
+
const blockMatch = line.match(/^>\s+["“]([^"”]{40,300})["”]/);
|
|
155
|
+
if (blockMatch) {
|
|
156
|
+
accumulator.push({
|
|
157
|
+
source: `Daily - ${dateKey}.md`,
|
|
158
|
+
date: dateKey,
|
|
159
|
+
severity: scoreSeverity(blockMatch[1]),
|
|
160
|
+
quote: blockMatch[1].trim(),
|
|
161
|
+
pattern: "—",
|
|
162
|
+
confidence: 3,
|
|
163
|
+
});
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// FLAG: / VERDICT: / OVERRIDE: lines.
|
|
167
|
+
//
|
|
168
|
+
// T242 bug hunt fix (B#15): anchor to start of line. Previous regex
|
|
169
|
+
// had no `^` so prose like "this was not an OVERRIDE: success"
|
|
170
|
+
// matched and got scored with inverted polarity. Reject lines whose
|
|
171
|
+
// preceding context contains a negation cue.
|
|
172
|
+
const negationCue = /\b(?:not|no|wasn'?t|isn'?t|don'?t|never)\s+[^.]{0,40}$/i;
|
|
173
|
+
const headStart = line.match(/^(.{0,40}?)\s*(?:FLAG|VERDICT|OVERRIDE|ROAST):/i);
|
|
174
|
+
if (headStart && negationCue.test(headStart[1])) continue;
|
|
175
|
+
const flag = line.match(/^\s*[-*>]*\s*(?:FLAG|VERDICT|OVERRIDE|ROAST):\s+(.{40,300})/i);
|
|
176
|
+
if (flag) {
|
|
177
|
+
accumulator.push({
|
|
178
|
+
source: `Daily - ${dateKey}.md`,
|
|
179
|
+
date: dateKey,
|
|
180
|
+
severity: scoreSeverity(flag[1]),
|
|
181
|
+
quote: flag[1].trim(),
|
|
182
|
+
pattern: flag[0].split(":")[0].toUpperCase(),
|
|
183
|
+
confidence: 4,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function scoreSeverity(text) {
|
|
190
|
+
const t = String(text).toLowerCase();
|
|
191
|
+
if (SEVERITY_KEYWORDS.high.some((k) => t.includes(k))) return "high";
|
|
192
|
+
if (SEVERITY_KEYWORDS.med.some((k) => t.includes(k))) return "med";
|
|
193
|
+
return "low";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function safeRead(p) {
|
|
197
|
+
try { return fs.readFileSync(p, "utf8"); } catch (_) { return ""; }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { compute };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* run-registry.js — server-held registry for one-shot agent runs (the Run tab).
|
|
5
|
+
*
|
|
6
|
+
* WHY: the original Run path (server.js runAgentStream) piped the child's
|
|
7
|
+
* stdout STRAIGHT to the HTTP response with no server-side buffer and no
|
|
8
|
+
* handle on the child. So navigating away from the Run tab — or a page
|
|
9
|
+
* refresh — lost the run and its output, and there was no way to re-attach.
|
|
10
|
+
* The Run tab was ephemeral; the Chat tab (AgentSessionManager) was not.
|
|
11
|
+
*
|
|
12
|
+
* This registry makes Run persistent the same way Chat is: the child and its
|
|
13
|
+
* output live on the SERVER, decoupled from the request that started them. A
|
|
14
|
+
* client can disconnect and reconnect; the run keeps going and its output is
|
|
15
|
+
* retained. Unlike the Chat path (Claude-only stream-json), this wraps the
|
|
16
|
+
* existing multi-agent one-shot command (agentCommand), so Run stays
|
|
17
|
+
* claude/codex/gemini — there is no Claude-only regression.
|
|
18
|
+
*
|
|
19
|
+
* Persistence scope == Chat's: in-memory, per running daemon. A daemon restart
|
|
20
|
+
* clears runs (AgentSessionManager behaves identically). Bounds keep memory
|
|
21
|
+
* sane for a long-lived daemon: a per-run output cap (drop the head, keep the
|
|
22
|
+
* meaningful tail incl. the exit line) and a retained-run cap + age eviction.
|
|
23
|
+
*
|
|
24
|
+
* Subscribe semantics mirror AgentSessionManager.subscribe: a new subscriber
|
|
25
|
+
* gets the full buffer replayed first (so a reattaching client sees history),
|
|
26
|
+
* then live chunks. The replay snapshot + subscriber-add happen in one
|
|
27
|
+
* synchronous step, so a stdout 'data' event (only dispatched at an I/O yield)
|
|
28
|
+
* can never interleave and drop or duplicate a chunk.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const { spawn } = require("child_process");
|
|
32
|
+
|
|
33
|
+
const DEFAULTS = {
|
|
34
|
+
maxRuns: 40, // retain at most N runs (live + finished)
|
|
35
|
+
maxBuffer: 1024 * 1024, // 1 MB per-run output cap (head dropped, tail kept)
|
|
36
|
+
finishedTtlMs: 6 * 60 * 60 * 1000, // evict finished runs 6h after they end
|
|
37
|
+
idleSweepMs: 5 * 60 * 1000, // sweep cadence
|
|
38
|
+
timeoutMs: 10 * 60 * 1000, // hard stop a run after 10 min (matches legacy)
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
class RunRegistry {
|
|
42
|
+
constructor(opts = {}) {
|
|
43
|
+
this.opt = { ...DEFAULTS, ...opts };
|
|
44
|
+
this.runs = new Map(); // id -> run record
|
|
45
|
+
this._seq = 0;
|
|
46
|
+
this._sweep = setInterval(() => this._evict(), this.opt.idleSweepMs);
|
|
47
|
+
if (this._sweep.unref) this._sweep.unref();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Spawn a tracked run. The CALLER builds the command (agentCommand) and the
|
|
52
|
+
* final prompt (buildAgentPrompt); this owns the process + buffer lifecycle.
|
|
53
|
+
* spec: { agent, label, cmd, args, stdin, finalPrompt, cwd, env, preamble }
|
|
54
|
+
* Returns the run record (its `.id` is the handle for subscribe/get/stop).
|
|
55
|
+
*/
|
|
56
|
+
start(spec) {
|
|
57
|
+
const id = `run_${process.pid}_${++this._seq}`;
|
|
58
|
+
const run = {
|
|
59
|
+
id,
|
|
60
|
+
agent: spec.agent || "claude-code",
|
|
61
|
+
label: (spec.label || spec.agent || "task").slice(0, 200),
|
|
62
|
+
status: "running",
|
|
63
|
+
output: spec.preamble ? String(spec.preamble) : "",
|
|
64
|
+
started: Date.now(),
|
|
65
|
+
ended: null,
|
|
66
|
+
exit: null,
|
|
67
|
+
truncated: false,
|
|
68
|
+
child: null,
|
|
69
|
+
subscribers: new Set(),
|
|
70
|
+
};
|
|
71
|
+
this.runs.set(id, run);
|
|
72
|
+
this._trimCount();
|
|
73
|
+
|
|
74
|
+
let child;
|
|
75
|
+
try {
|
|
76
|
+
child = spawn(spec.cmd, spec.args, { cwd: spec.cwd, env: spec.env, stdio: ["pipe", "pipe", "pipe"] });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${e && e.message}\n`);
|
|
79
|
+
this._finish(run, "failed", null);
|
|
80
|
+
return run;
|
|
81
|
+
}
|
|
82
|
+
run.child = child;
|
|
83
|
+
|
|
84
|
+
const timeout = setTimeout(() => {
|
|
85
|
+
this._append(run, "\n[mover-studio] timeout after 10 minutes; stopping agent.\n", true);
|
|
86
|
+
try { child.kill("SIGTERM"); } catch (_) {}
|
|
87
|
+
}, this.opt.timeoutMs);
|
|
88
|
+
if (timeout.unref) timeout.unref();
|
|
89
|
+
|
|
90
|
+
// Guard stdin against an async EPIPE if the child dies early (mirrors the
|
|
91
|
+
// legacy one-shot path + the AgentSession stdin guard).
|
|
92
|
+
if (child.stdin) child.stdin.on("error", () => {});
|
|
93
|
+
try {
|
|
94
|
+
if (spec.stdin) child.stdin.end(spec.finalPrompt);
|
|
95
|
+
else child.stdin.end();
|
|
96
|
+
} catch (_) {}
|
|
97
|
+
|
|
98
|
+
child.stdout.on("data", (c) => this._onChunk(run, c));
|
|
99
|
+
child.stderr.on("data", (c) => this._onChunk(run, c));
|
|
100
|
+
child.on("error", (err) => {
|
|
101
|
+
clearTimeout(timeout);
|
|
102
|
+
this._append(run, `\n[mover-studio] failed to start ${run.agent}: ${err && err.message}\n`, true);
|
|
103
|
+
this._finish(run, "failed", null);
|
|
104
|
+
});
|
|
105
|
+
child.on("close", (code) => {
|
|
106
|
+
clearTimeout(timeout);
|
|
107
|
+
this._append(run, `\n[mover-studio] agent exited with code ${code}\n`, true);
|
|
108
|
+
// A run we SIGTERM'd via stop() keeps its "stopped" status; otherwise
|
|
109
|
+
// derive from the exit code (0/null = done, non-zero = exited).
|
|
110
|
+
const status = run.status === "stopping" ? "stopped" : (code === 0 || code == null) ? "done" : "exited";
|
|
111
|
+
this._finish(run, status, code);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return run;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Subscribe to a run: replay the full buffer, then stream live chunks.
|
|
118
|
+
* cb receives {type:"replay"|"chunk"|"end", text?, status?, exit?}.
|
|
119
|
+
* Returns an unsubscribe fn. */
|
|
120
|
+
subscribe(id, cb) {
|
|
121
|
+
const run = this.runs.get(id);
|
|
122
|
+
if (!run) { cb({ type: "end", status: "missing", exit: null }); return () => {}; }
|
|
123
|
+
// Snapshot + register atomically (no I/O between) so no chunk is lost.
|
|
124
|
+
cb({ type: "replay", text: run.output, status: run.status, exit: run.exit });
|
|
125
|
+
if (run.status === "running" || run.status === "stopping") {
|
|
126
|
+
run.subscribers.add(cb);
|
|
127
|
+
return () => run.subscribers.delete(cb);
|
|
128
|
+
}
|
|
129
|
+
cb({ type: "end", status: run.status, exit: run.exit });
|
|
130
|
+
return () => {};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Really stop a run (SIGTERM the child). Distinct from a client merely
|
|
134
|
+
* detaching its stream ("stop watching"). */
|
|
135
|
+
stop(id) {
|
|
136
|
+
const run = this.runs.get(id);
|
|
137
|
+
if (!run || !run.child || (run.status !== "running")) return false;
|
|
138
|
+
run.status = "stopping";
|
|
139
|
+
try { run.child.kill("SIGTERM"); } catch (_) {}
|
|
140
|
+
// Escalate if the child ignores SIGTERM.
|
|
141
|
+
const t = setTimeout(() => { if (run.status === "stopping" && run.child) { try { run.child.kill("SIGKILL"); } catch (_) {} } }, 2500);
|
|
142
|
+
if (t.unref) t.unref();
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get(id) { const r = this.runs.get(id); return r ? this._summary(r, true) : null; }
|
|
147
|
+
has(id) { return this.runs.has(id); }
|
|
148
|
+
list() {
|
|
149
|
+
return [...this.runs.values()].sort((a, b) => b.started - a.started).map((r) => this._summary(r, false));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
shutdownAll() {
|
|
153
|
+
if (this._sweep) { clearInterval(this._sweep); this._sweep = null; }
|
|
154
|
+
for (const r of [...this.runs.values()]) {
|
|
155
|
+
if (r.child && (r.status === "running" || r.status === "stopping")) { try { r.child.kill("SIGTERM"); } catch (_) {} }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
_onChunk(run, chunk) {
|
|
162
|
+
const s = chunk.toString("utf8");
|
|
163
|
+
this._append(run, s);
|
|
164
|
+
this._emit(run, { type: "chunk", text: s });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_append(run, s, isTail) {
|
|
168
|
+
run.output += s;
|
|
169
|
+
if (run.output.length > this.opt.maxBuffer) {
|
|
170
|
+
// Keep the TAIL — it carries the latest work and the exit line that the
|
|
171
|
+
// client derives status from. `isTail` writes (exit/timeout lines) are
|
|
172
|
+
// short and always survive because they were just appended.
|
|
173
|
+
run.output = "[… earlier output truncated …]\n" + run.output.slice(run.output.length - this.opt.maxBuffer);
|
|
174
|
+
run.truncated = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_emit(run, ev) {
|
|
179
|
+
for (const cb of [...run.subscribers]) { try { cb(ev); } catch (_) { /* a dead subscriber must not break the loop */ } }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
_finish(run, status, exit) {
|
|
183
|
+
// Terminal guard: a spawn failure fires 'error' THEN 'close', so both
|
|
184
|
+
// handlers call _finish. Without this, the second call overwrites a real
|
|
185
|
+
// "failed"/"stopped" with the close code (null -> "done") and emits a
|
|
186
|
+
// second "end". First finish wins. (Found by a Codex review, 2026-06-30.)
|
|
187
|
+
if (run.ended) return;
|
|
188
|
+
run.status = status;
|
|
189
|
+
run.exit = exit;
|
|
190
|
+
run.ended = Date.now();
|
|
191
|
+
run.child = null;
|
|
192
|
+
this._emit(run, { type: "end", status, exit });
|
|
193
|
+
run.subscribers.clear();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_summary(run, withOutput) {
|
|
197
|
+
const s = {
|
|
198
|
+
id: run.id, agent: run.agent, label: run.label, status: run.status,
|
|
199
|
+
started: run.started, ended: run.ended, exit: run.exit,
|
|
200
|
+
truncated: run.truncated, bytes: run.output.length,
|
|
201
|
+
};
|
|
202
|
+
if (withOutput) s.output = run.output;
|
|
203
|
+
return s;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Cap the number of retained runs: drop the OLDEST finished runs first; never
|
|
207
|
+
// evict a still-running run to make room.
|
|
208
|
+
_trimCount() {
|
|
209
|
+
if (this.runs.size <= this.opt.maxRuns) return;
|
|
210
|
+
const finished = [...this.runs.values()]
|
|
211
|
+
.filter((r) => r.status !== "running" && r.status !== "stopping")
|
|
212
|
+
.sort((a, b) => a.started - b.started);
|
|
213
|
+
while (this.runs.size > this.opt.maxRuns && finished.length) this.runs.delete(finished.shift().id);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
_evict() {
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
for (const r of [...this.runs.values()]) {
|
|
219
|
+
if (r.status !== "running" && r.status !== "stopping" && r.ended && now - r.ended > this.opt.finishedTtlMs) {
|
|
220
|
+
this.runs.delete(r.id);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { RunRegistry, DEFAULTS };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// safe-write.js — drift-guarded file writes (B5). Global Rules S21 (multi-instance safety) made
|
|
4
|
+
// MECHANICAL instead of prompt-level. Multiple agents/dashboards can touch the same vault; before a
|
|
5
|
+
// programmatic writer overwrites an important file (an Engine file, the Daily Note) it re-reads it
|
|
6
|
+
// and byte-compares against the content it based its edit on (`expected`). If the file changed
|
|
7
|
+
// underneath — another instance, the user in Obsidian, or an agent — the current bytes are preserved
|
|
8
|
+
// to a timestamped sidecar (.bak.<ts>) BEFORE the new content lands, so a concurrent edit is never
|
|
9
|
+
// silently clobbered. The write itself is atomic (tmp + rename). Pure Node stdlib, zero-dep.
|
|
10
|
+
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
|
|
14
|
+
class DriftError extends Error {
|
|
15
|
+
constructor(file, backupPath) {
|
|
16
|
+
super(
|
|
17
|
+
backupPath
|
|
18
|
+
? `drift: ${file} changed since it was read; preserved a backup at ${backupPath}`
|
|
19
|
+
: `drift: ${file} changed since it was read; aborted without writing (re-read and retry)`
|
|
20
|
+
);
|
|
21
|
+
this.name = "DriftError";
|
|
22
|
+
this.code = "EDRIFT";
|
|
23
|
+
this.file = file;
|
|
24
|
+
this.backupPath = backupPath || null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Write `next` to `file`, guarding against a concurrent edit.
|
|
29
|
+
// expected — the bytes the caller last read and based `next` on. Pass null to skip drift
|
|
30
|
+
// detection (a first write / no baseline / a known-fresh file).
|
|
31
|
+
// opts.throwOnDrift — on drift, abort WITHOUT touching the file and throw DriftError, so the
|
|
32
|
+
// caller can re-read + re-merge. Default false: back up the current bytes,
|
|
33
|
+
// then proceed with the overwrite.
|
|
34
|
+
// opts.now — timestamp for the .bak/.tmp suffix (deterministic in tests).
|
|
35
|
+
// Returns { written, existed, drifted, backupPath }.
|
|
36
|
+
function writeWithDriftGuard(file, expected, next, opts = {}) {
|
|
37
|
+
const ts = opts.now != null ? opts.now : Date.now();
|
|
38
|
+
|
|
39
|
+
let current = null, existed = false;
|
|
40
|
+
try { current = fs.readFileSync(file, "utf8"); existed = true; } catch { existed = false; }
|
|
41
|
+
const drifted = existed && expected != null && current !== expected;
|
|
42
|
+
|
|
43
|
+
// Abort path: leave the file exactly as the concurrent writer left it — no data loss, no backup.
|
|
44
|
+
if (drifted && opts.throwOnDrift) throw new DriftError(file, null);
|
|
45
|
+
|
|
46
|
+
let backupPath = null;
|
|
47
|
+
if (drifted) {
|
|
48
|
+
// We are about to overwrite a concurrent edit → preserve it first so nothing is lost silently.
|
|
49
|
+
backupPath = `${file}.bak.${ts}`;
|
|
50
|
+
fs.writeFileSync(backupPath, current);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
54
|
+
// Same-directory tmp so the rename is a same-filesystem (atomic) operation, never a cross-device
|
|
55
|
+
// copy. pid in the suffix so two writers in the same ms don't collide on the scratch file.
|
|
56
|
+
const tmp = `${file}.tmp.${ts}.${process.pid}`;
|
|
57
|
+
fs.writeFileSync(tmp, next);
|
|
58
|
+
fs.renameSync(tmp, file); // atomic on POSIX: a concurrent reader sees old-or-new, never a partial
|
|
59
|
+
|
|
60
|
+
return { written: true, existed, drifted, backupPath };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { writeWithDriftGuard, DriftError };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// session-log-parser.js — Mover memory engine, curated tier.
|
|
4
|
+
// Parses the Daily Note "## Session Log" section that daily-note-resolver.js ignores.
|
|
5
|
+
// This is the HIGHEST-signal curated record: /log already distilled each session into
|
|
6
|
+
// timestamped arcs ("#### ~04:20 - Found PII leak"), commit hashes, and **Flag:** lines.
|
|
7
|
+
// One doc per sub-arc = the natural recall unit ("when did I fix X").
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const { dailiesDir } = require("./paths");
|
|
12
|
+
|
|
13
|
+
const DAILY_RE = /^Daily - (\d{4})-(\d{2})-(\d{2})\.md$/;
|
|
14
|
+
const COMMIT_HASH = /`([0-9a-f]{7,40})`/g;
|
|
15
|
+
const PROJECT_TAG = /\[([^\]]+)\]/g;
|
|
16
|
+
|
|
17
|
+
function listDailyNotes(vault) {
|
|
18
|
+
const root = dailiesDir(vault);
|
|
19
|
+
const out = [];
|
|
20
|
+
let months;
|
|
21
|
+
try { months = fs.readdirSync(root, { withFileTypes: true }); } catch { return out; }
|
|
22
|
+
for (const m of months) {
|
|
23
|
+
if (!m.isDirectory()) continue;
|
|
24
|
+
const mdir = path.join(root, m.name);
|
|
25
|
+
let files;
|
|
26
|
+
try { files = fs.readdirSync(mdir); } catch { continue; }
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
const mm = f.match(DAILY_RE);
|
|
29
|
+
if (mm) out.push({ date: `${mm[1]}-${mm[2]}-${mm[3]}`, file: path.join(mdir, f) });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Also support flat (no month subfolder) layout.
|
|
33
|
+
try {
|
|
34
|
+
for (const f of fs.readdirSync(root)) {
|
|
35
|
+
const mm = f.match(DAILY_RE);
|
|
36
|
+
if (mm) out.push({ date: `${mm[1]}-${mm[2]}-${mm[3]}`, file: path.join(root, f) });
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
out.sort((a, b) => (a.date < b.date ? 1 : -1)); // newest first
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractCommits(text) {
|
|
44
|
+
const out = new Set();
|
|
45
|
+
let m;
|
|
46
|
+
COMMIT_HASH.lastIndex = 0;
|
|
47
|
+
while ((m = COMMIT_HASH.exec(text)) !== null) {
|
|
48
|
+
// commit hashes are 7-40 hex; skip obvious non-hashes (all digits, e.g. line refs)
|
|
49
|
+
if (/[a-f]/.test(m[1]) && m[1].length >= 7) out.add(m[1].slice(0, 10));
|
|
50
|
+
}
|
|
51
|
+
return [...out].slice(0, 12);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Pull the project label out of a "### Session Log [ATTENDED] … [Mover OS Bundle] …" header.
|
|
55
|
+
function projectFromHeader(headerLine) {
|
|
56
|
+
let m, project = null;
|
|
57
|
+
PROJECT_TAG.lastIndex = 0;
|
|
58
|
+
while ((m = PROJECT_TAG.exec(headerLine)) !== null) {
|
|
59
|
+
const tag = m[1].trim();
|
|
60
|
+
if (/^(ATTENDED|UNATTENDED|COMPACTED|Claude Code|Antigravity|Gemini|Cursor|Codex|Background Agent)$/i.test(tag)) continue;
|
|
61
|
+
if (/^~?\d/.test(tag)) continue; // timestamps
|
|
62
|
+
project = tag; // last non-meta tag wins (it's usually the project)
|
|
63
|
+
}
|
|
64
|
+
return project;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Parse one Daily Note's "## Session Log" into arc docs.
|
|
68
|
+
function parseNote(note) {
|
|
69
|
+
let raw;
|
|
70
|
+
try { raw = fs.readFileSync(note.file, "utf8"); } catch { return []; }
|
|
71
|
+
const idx = raw.search(/^##\s+Session Log\s*$/m);
|
|
72
|
+
if (idx < 0) return [];
|
|
73
|
+
// Section = from "## Session Log" to next top-level "## " (or EOF).
|
|
74
|
+
let section = raw.slice(idx);
|
|
75
|
+
const nextTop = section.slice(3).search(/^##\s+(?!#)/m);
|
|
76
|
+
if (nextTop >= 0) section = section.slice(0, nextTop + 3);
|
|
77
|
+
|
|
78
|
+
let curProject = null;
|
|
79
|
+
const docs = [];
|
|
80
|
+
|
|
81
|
+
// Split into blocks on "#### " arc headers; track the nearest "### " session header for project.
|
|
82
|
+
const lines = section.split(/\r?\n/);
|
|
83
|
+
let arc = null;
|
|
84
|
+
const flush = () => {
|
|
85
|
+
if (!arc) return;
|
|
86
|
+
const body = arc.bodyLines.join("\n").trim();
|
|
87
|
+
const text = (arc.title + "\n" + body).trim();
|
|
88
|
+
if (text.length > 8) {
|
|
89
|
+
docs.push({
|
|
90
|
+
id: `slog:${note.date}#${arc.time || docs.length}`,
|
|
91
|
+
type: "sessionlog",
|
|
92
|
+
date: note.date,
|
|
93
|
+
time: arc.time,
|
|
94
|
+
title: arc.title,
|
|
95
|
+
project: curProject,
|
|
96
|
+
commits: extractCommits(text),
|
|
97
|
+
text,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
arc = null;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
const sess = line.match(/^###\s+(.*)$/);
|
|
105
|
+
if (sess && !line.startsWith("####")) {
|
|
106
|
+
const p = projectFromHeader(sess[1]);
|
|
107
|
+
if (p) curProject = p;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const arcHead = line.match(/^####\s+(.*)$/);
|
|
111
|
+
if (arcHead) {
|
|
112
|
+
flush();
|
|
113
|
+
const titleRaw = arcHead[1].trim();
|
|
114
|
+
const tm = titleRaw.match(/~?(\d{1,2}:\d{2})/);
|
|
115
|
+
arc = { time: tm ? tm[1] : null, title: titleRaw.replace(/^~?\d{1,2}:\d{2}\s*-?\s*/, "").trim() || titleRaw, bodyLines: [] };
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (arc) arc.bodyLines.push(line);
|
|
119
|
+
}
|
|
120
|
+
flush();
|
|
121
|
+
return docs;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Parse all Daily Note session logs → curated arc docs.
|
|
125
|
+
function parseSessionLogs(vault, opts = {}) {
|
|
126
|
+
const notes = listDailyNotes(vault);
|
|
127
|
+
const limited = opts.limit ? notes.slice(0, opts.limit) : notes;
|
|
128
|
+
const docs = [];
|
|
129
|
+
for (const note of limited) {
|
|
130
|
+
for (const d of parseNote(note)) docs.push(d);
|
|
131
|
+
}
|
|
132
|
+
return { docs, noteCount: limited.length, arcCount: docs.length };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { parseSessionLogs, listDailyNotes };
|
|
136
|
+
|
|
137
|
+
// ── CLI ──
|
|
138
|
+
if (require.main === module) {
|
|
139
|
+
const vault = process.argv[2] || require("./paths").resolveVault();
|
|
140
|
+
const r = parseSessionLogs(vault, { limit: Number(process.argv[3]) || 0 });
|
|
141
|
+
console.log(`${r.noteCount} daily notes → ${r.arcCount} session-log arcs`);
|
|
142
|
+
for (const d of r.docs.slice(0, 10)) {
|
|
143
|
+
console.log(` ${d.date} ${d.time || " - "} [${d.project || "?"}] ${d.title.slice(0, 60)} ${d.commits.length ? "commits:" + d.commits.join(",") : ""}`);
|
|
144
|
+
}
|
|
145
|
+
}
|