@noesis-brain/mcp-server 2.0.1 → 2.1.0
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 +91 -3
- package/dist/agent/runner.d.ts +25 -0
- package/dist/agent/runner.d.ts.map +1 -0
- package/dist/agent/runner.js +218 -0
- package/dist/agent/runner.js.map +1 -0
- package/dist/api/NoesisClient.d.ts +119 -0
- package/dist/api/NoesisClient.d.ts.map +1 -1
- package/dist/api/NoesisClient.js +130 -6
- package/dist/api/NoesisClient.js.map +1 -1
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +409 -35
- package/dist/cli/setup.js.map +1 -1
- package/dist/index.js +31 -1
- package/dist/index.js.map +1 -1
- package/dist/resolve/noteRef.d.ts +101 -0
- package/dist/resolve/noteRef.d.ts.map +1 -0
- package/dist/resolve/noteRef.js +186 -0
- package/dist/resolve/noteRef.js.map +1 -0
- package/dist/resolve/rootsFormat.d.ts +12 -0
- package/dist/resolve/rootsFormat.d.ts.map +1 -0
- package/dist/resolve/rootsFormat.js +63 -0
- package/dist/resolve/rootsFormat.js.map +1 -0
- package/dist/tools/backlog.d.ts +4 -0
- package/dist/tools/backlog.d.ts.map +1 -0
- package/dist/tools/backlog.js +157 -0
- package/dist/tools/backlog.js.map +1 -0
- package/dist/tools/index.d.ts +52 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +433 -137
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/navis.d.ts.map +1 -1
- package/dist/tools/navis.js +4 -0
- package/dist/tools/navis.js.map +1 -1
- package/package.json +8 -3
- package/scripts/agent-e2e.mjs +69 -0
- package/scripts/noesis-capture-session-end.mjs +94 -0
- package/scripts/noesis-capture-watcher.mjs +907 -0
- package/skill-templates/noesis-capture.md +166 -0
- package/skill-templates/noesis-create-study-note.md +149 -0
- package/skill-templates/noesis-find-related.md +89 -0
- package/skill-templates/noesis-investigate.md +90 -0
- package/skill-templates/noesis-manage-news.md +80 -0
- package/skill-templates/noesis-skim-read.md +72 -0
- package/templates/claude-md-block.md +5 -1
- package/templates/claude-md-restructure-on-sync.md +10 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// noesis-capture-watcher.mjs — Live Session Capture into Noesis
|
|
3
|
+
//
|
|
4
|
+
// Tails a Claude Code session transcript (.jsonl) and renders it to a clean,
|
|
5
|
+
// readable Markdown note (default: ~/Noesis/captures/<session-id>.md, override
|
|
6
|
+
// with --out), then pushes that note to the Noesis cloud directly
|
|
7
|
+
// (POST /api/mcp/notes/upsert) on every change. Both the local render and the
|
|
8
|
+
// cloud push are fully deterministic and run with no Claude session in the loop
|
|
9
|
+
// — the /noesis-capture skill only starts/stops this watcher and reports status.
|
|
10
|
+
//
|
|
11
|
+
// Zero external dependencies (only node: builtins; uses global fetch + node:crypto).
|
|
12
|
+
// Node ESM (requires Node >= 18 for global fetch; tested on Node 22).
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// node noesis-capture-watcher.mjs --session "<id-or-name>" [--out <path>]
|
|
16
|
+
// [--once] [--resolve-only] [--print-self] [--self <id>] [--interval-ms 1500]
|
|
17
|
+
// [--no-cloud] [--push-min-interval-ms 5000] [--max-life-ms <ms>]
|
|
18
|
+
//
|
|
19
|
+
// --session session id (UUID) or a substring of its ai-title / summary
|
|
20
|
+
// --out output .md path (default: ~/Noesis/captures/<id>.md)
|
|
21
|
+
// --once render a single snapshot (+ one cloud push) and exit
|
|
22
|
+
// --resolve-only print resolved {sessionId,transcriptPath,...} JSON and exit
|
|
23
|
+
// --print-self print `SELF=<id>` for the CURRENT session (this cwd) and exit
|
|
24
|
+
// --self <id> refuse if the resolved session equals this id (self-guard)
|
|
25
|
+
// --interval-ms poll interval while tailing (default 1500)
|
|
26
|
+
// --no-cloud local render only — do not push to the Noesis cloud
|
|
27
|
+
// --push-min-interval-ms min ms between cloud pushes (debounce; default 5000)
|
|
28
|
+
// --max-life-ms optional hard lifetime cap (ms); watcher self-exits when reached
|
|
29
|
+
//
|
|
30
|
+
// Cloud auth: NOESIS_API_TOKEN / NOESIS_API_URL are read from the environment,
|
|
31
|
+
// else from ~/.claude.json -> mcpServers.noesis.env. The token is never logged
|
|
32
|
+
// or placed on a command line.
|
|
33
|
+
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import os from 'node:os';
|
|
37
|
+
import crypto from 'node:crypto';
|
|
38
|
+
|
|
39
|
+
const HOME = os.homedir();
|
|
40
|
+
const PROJECTS = path.join(HOME, '.claude', 'projects');
|
|
41
|
+
const SNAPSHOT = path.join(HOME, '.claude', 'session-snapshot.json');
|
|
42
|
+
// Default output dir when --out is not supplied. ~/Noesis is the conventional
|
|
43
|
+
// Noesis root; the /noesis-capture skill overrides this with --out under the
|
|
44
|
+
// user's actual registered root (resolved via mcp__noesis__list_roots) so the
|
|
45
|
+
// cloud push always finds a matching root.
|
|
46
|
+
const DEFAULT_OUT_DIR = path.join(HOME, 'Noesis', 'captures');
|
|
47
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------- arg parsing
|
|
50
|
+
|
|
51
|
+
function parseArgs(argv) {
|
|
52
|
+
const a = { intervalMs: 1500 };
|
|
53
|
+
for (let i = 0; i < argv.length; i++) {
|
|
54
|
+
let k = argv[i];
|
|
55
|
+
let v = null;
|
|
56
|
+
const eq = k.indexOf('=');
|
|
57
|
+
if (k.startsWith('--') && eq > 0) { v = k.slice(eq + 1); k = k.slice(0, eq); }
|
|
58
|
+
switch (k) {
|
|
59
|
+
case '--session': a.session = v ?? argv[++i]; break;
|
|
60
|
+
case '--out': a.out = v ?? argv[++i]; break;
|
|
61
|
+
case '--self': a.self = v ?? argv[++i]; break;
|
|
62
|
+
case '--interval-ms': a.intervalMs = parseInt(v ?? argv[++i], 10) || 1500; break;
|
|
63
|
+
case '--max-life-ms': a.maxLifeMs = parseInt(v ?? argv[++i], 10) || 0; break;
|
|
64
|
+
case '--push-min-interval-ms': a.pushMinIntervalMs = parseInt(v ?? argv[++i], 10) || 0; break;
|
|
65
|
+
case '--no-cloud': a.noCloud = true; break;
|
|
66
|
+
case '--once': a.once = true; break;
|
|
67
|
+
case '--resolve-only': a.resolveOnly = true; break;
|
|
68
|
+
case '--print-self': a.printSelf = true; break;
|
|
69
|
+
case '--help': case '-h': a.help = true; break;
|
|
70
|
+
default: break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return a;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ------------------------------------------------------------------ utilities
|
|
77
|
+
|
|
78
|
+
const oneLine = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
|
|
79
|
+
|
|
80
|
+
// ANSI/CSI escape sequences (e.g. "\x1b[7m", "\x1b[0m") leak in from terminal
|
|
81
|
+
// command output; strip them so result lines read as plain text.
|
|
82
|
+
const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
83
|
+
const stripAnsi = (s) => String(s == null ? '' : s).replace(ANSI_RE, '');
|
|
84
|
+
|
|
85
|
+
// git prints this autocrlf warning to stderr on add/commit when a repo lacks a
|
|
86
|
+
// .gitattributes; it carries no signal in a session capture, so drop it from
|
|
87
|
+
// command output (Bash/PowerShell) before the result is summarized.
|
|
88
|
+
const GIT_CRLF_WARN_RE = /warning: in the working copy of '[^']*', LF will be replaced by CRLF the next time Git touches it\.?\s*/g;
|
|
89
|
+
const stripCmdNoise = (s) => String(s == null ? '' : s).replace(GIT_CRLF_WARN_RE, '');
|
|
90
|
+
|
|
91
|
+
// Drop Claude Code image placeholders ("[Image #1]", "[Image: source: <path>]")
|
|
92
|
+
// that carry no readable signal in a text capture.
|
|
93
|
+
const stripImageRefs = (s) =>
|
|
94
|
+
String(s == null ? '' : s)
|
|
95
|
+
.replace(/\[Image #\d+\]/g, '')
|
|
96
|
+
.replace(/\[Image:[^\]]*\]/g, '')
|
|
97
|
+
.replace(/[ \t]{2,}/g, ' ')
|
|
98
|
+
.trim();
|
|
99
|
+
|
|
100
|
+
// Neutralize bare HTML tags so literal "<details>", "<summary>", "<system-reminder>"
|
|
101
|
+
// etc. in transcript data don't get interpreted as HTML by the Markdown renderer.
|
|
102
|
+
// Only for PLAIN-TEXT data fields (never inside code spans / fenced blocks).
|
|
103
|
+
const esc = (s) => String(s == null ? '' : s).replace(/</g, '<').replace(/>/g, '>');
|
|
104
|
+
|
|
105
|
+
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
106
|
+
function localStamp(d) {
|
|
107
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ` +
|
|
108
|
+
`${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
109
|
+
}
|
|
110
|
+
function localDate(d) {
|
|
111
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
112
|
+
}
|
|
113
|
+
function tsStamp(iso) {
|
|
114
|
+
if (!iso) return '';
|
|
115
|
+
const d = new Date(iso);
|
|
116
|
+
return Number.isNaN(d.getTime()) ? '' : localStamp(d);
|
|
117
|
+
}
|
|
118
|
+
function log(msg) { process.stdout.write(`[noesis-capture] ${localStamp(new Date())} ${msg}\n`); }
|
|
119
|
+
|
|
120
|
+
function statMtime(p) {
|
|
121
|
+
try { return fs.statSync(p).mtimeMs; } catch { return 0; }
|
|
122
|
+
}
|
|
123
|
+
function projectDirs() {
|
|
124
|
+
let ents;
|
|
125
|
+
try { ents = fs.readdirSync(PROJECTS, { withFileTypes: true }); } catch { return []; }
|
|
126
|
+
return ents.filter((e) => e.isDirectory()).map((e) => path.join(PROJECTS, e.name));
|
|
127
|
+
}
|
|
128
|
+
function findById(id) {
|
|
129
|
+
for (const dir of projectDirs()) {
|
|
130
|
+
const p = path.join(dir, `${id}.jsonl`);
|
|
131
|
+
if (fs.existsSync(p)) return p;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
// Map the current working directory to its Claude Code project dir and return
|
|
136
|
+
// the most-recently-modified transcript's session id (i.e. THIS session). Mirrors
|
|
137
|
+
// Claude Code's cwd->project-dir encoding (":" "\" "/" "_" all become "-"). Used
|
|
138
|
+
// by --print-self so the /noesis-capture skill can pass --self and never capture
|
|
139
|
+
// its own controller session.
|
|
140
|
+
function selfSessionId() {
|
|
141
|
+
const name = process.cwd().replace(/[:\\/_]/g, '-');
|
|
142
|
+
let d = path.join(PROJECTS, name);
|
|
143
|
+
if (!fs.existsSync(d)) {
|
|
144
|
+
const fc = name[0] || '';
|
|
145
|
+
const toggled = (fc === fc.toUpperCase() ? fc.toLowerCase() : fc.toUpperCase()) + name.slice(1);
|
|
146
|
+
d = path.join(PROJECTS, toggled);
|
|
147
|
+
}
|
|
148
|
+
let files;
|
|
149
|
+
try { files = fs.readdirSync(d).filter((f) => f.endsWith('.jsonl')).map((f) => path.join(d, f)); }
|
|
150
|
+
catch { return ''; }
|
|
151
|
+
if (!files.length) return '';
|
|
152
|
+
const newest = files.reduce((a, b) => (statMtime(a) >= statMtime(b) ? a : b));
|
|
153
|
+
return path.basename(newest).replace(/\.jsonl$/i, '');
|
|
154
|
+
}
|
|
155
|
+
// All top-level session transcripts (depth 1) — excludes subagent/workflow files.
|
|
156
|
+
function topLevelTranscripts() {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const dir of projectDirs()) {
|
|
159
|
+
let ents;
|
|
160
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
161
|
+
for (const e of ents) {
|
|
162
|
+
if (e.isFile() && e.name.endsWith('.jsonl')) {
|
|
163
|
+
const p = path.join(dir, e.name);
|
|
164
|
+
out.push({ path: p, sessionId: e.name.slice(0, -6), mtime: statMtime(p) });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
// Read the LAST ai-title from a transcript without a full parse pass.
|
|
171
|
+
function lastAiTitle(file) {
|
|
172
|
+
let raw;
|
|
173
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
174
|
+
const idx = raw.lastIndexOf('"type":"ai-title"');
|
|
175
|
+
if (idx < 0) return null;
|
|
176
|
+
const start = raw.lastIndexOf('\n', idx) + 1;
|
|
177
|
+
let end = raw.indexOf('\n', idx);
|
|
178
|
+
if (end < 0) end = raw.length;
|
|
179
|
+
try { return JSON.parse(raw.slice(start, end)).aiTitle || null; } catch { return null; }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function readJson(file) {
|
|
183
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
|
|
184
|
+
}
|
|
185
|
+
function readEntries(file) {
|
|
186
|
+
let raw;
|
|
187
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
188
|
+
const out = [];
|
|
189
|
+
for (const line of raw.split('\n')) {
|
|
190
|
+
const s = line.trim();
|
|
191
|
+
if (!s) continue;
|
|
192
|
+
try { out.push(JSON.parse(s)); } catch { /* skip malformed */ }
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------- resolution
|
|
198
|
+
|
|
199
|
+
function resolveByName(name) {
|
|
200
|
+
const needle = name.toLowerCase();
|
|
201
|
+
const matches = []; // {sessionId, transcriptPath, title, mtime}
|
|
202
|
+
const seen = new Set();
|
|
203
|
+
const add = (sessionId, transcriptPath, title) => {
|
|
204
|
+
if (!sessionId || seen.has(sessionId)) return;
|
|
205
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) return;
|
|
206
|
+
seen.add(sessionId);
|
|
207
|
+
matches.push({ sessionId, transcriptPath, title: title || '', mtime: statMtime(transcriptPath) });
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// 1. Per-project sessions-index.json (summary / firstPrompt)
|
|
211
|
+
for (const dir of projectDirs()) {
|
|
212
|
+
const idx = readJson(path.join(dir, 'sessions-index.json'));
|
|
213
|
+
if (!Array.isArray(idx)) continue;
|
|
214
|
+
for (const it of idx) {
|
|
215
|
+
const hay = `${it.summary || ''} ${it.firstPrompt || ''}`.toLowerCase();
|
|
216
|
+
if (it.sessionId && hay.includes(needle)) {
|
|
217
|
+
add(it.sessionId, path.join(dir, `${it.sessionId}.jsonl`), it.summary);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 2. Global session-snapshot.json (title)
|
|
223
|
+
const snap = readJson(SNAPSHOT);
|
|
224
|
+
if (Array.isArray(snap)) {
|
|
225
|
+
for (const s of snap) {
|
|
226
|
+
if (s.sessionId && (s.title || '').toLowerCase().includes(needle)) {
|
|
227
|
+
add(s.sessionId, findById(s.sessionId), s.title);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 3. Fallback: scan recent transcripts' ai-title (only if nothing found yet).
|
|
233
|
+
if (matches.length === 0) {
|
|
234
|
+
const tops = topLevelTranscripts().sort((a, b) => b.mtime - a.mtime);
|
|
235
|
+
let scanned = 0;
|
|
236
|
+
for (const t of tops) {
|
|
237
|
+
if (seen.has(t.sessionId)) continue;
|
|
238
|
+
if (scanned++ > 200) break;
|
|
239
|
+
const title = lastAiTitle(t.path);
|
|
240
|
+
if (title && title.toLowerCase().includes(needle)) {
|
|
241
|
+
add(t.sessionId, t.path, title);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
matches.sort((a, b) => b.mtime - a.mtime);
|
|
247
|
+
if (!matches.length) return null;
|
|
248
|
+
return { ...matches[0], candidates: matches.slice(0, 5) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function resolve(sessionArg) {
|
|
252
|
+
const arg = sessionArg.trim();
|
|
253
|
+
// Direct transcript path (also handy for tests / subagent transcripts).
|
|
254
|
+
if (/\.jsonl$/i.test(arg) && fs.existsSync(arg)) {
|
|
255
|
+
return { sessionId: path.basename(arg).replace(/\.jsonl$/i, ''), transcriptPath: arg, candidates: [] };
|
|
256
|
+
}
|
|
257
|
+
if (UUID_RE.test(arg)) {
|
|
258
|
+
const p = findById(arg);
|
|
259
|
+
if (p) return { sessionId: arg, transcriptPath: p, candidates: [] };
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
return resolveByName(arg);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------- conversion
|
|
266
|
+
|
|
267
|
+
function buildResultMap(entries) {
|
|
268
|
+
const map = new Map();
|
|
269
|
+
for (const e of entries) {
|
|
270
|
+
const content = e.message?.content;
|
|
271
|
+
if (e.type === 'user' && Array.isArray(content)) {
|
|
272
|
+
for (const b of content) {
|
|
273
|
+
if (b && b.type === 'tool_result' && b.tool_use_id) map.set(b.tool_use_id, b);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return map;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function extractMeta(entries, sessionId) {
|
|
281
|
+
let title = null, cwd = null, gitBranch = null, firstTs = null;
|
|
282
|
+
for (const e of entries) {
|
|
283
|
+
if (e.type === 'ai-title' && e.aiTitle) title = e.aiTitle; // last wins
|
|
284
|
+
if (cwd == null && e.cwd) cwd = e.cwd;
|
|
285
|
+
if (gitBranch == null && e.gitBranch != null) gitBranch = e.gitBranch;
|
|
286
|
+
if (firstTs == null && e.timestamp) firstTs = e.timestamp;
|
|
287
|
+
}
|
|
288
|
+
const shortId = sessionId.slice(0, 8);
|
|
289
|
+
const display = (title ? title : `Session ${shortId}`) + ' — Capture';
|
|
290
|
+
const startDate = firstTs ? localDate(new Date(firstTs)) : localDate(new Date());
|
|
291
|
+
return { sessionId, title: display, cwd, gitBranch, date: startDate, updated: localDate(new Date()) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function yq(s) { return `'${String(s == null ? '' : s).replace(/'/g, "''")}'`; }
|
|
295
|
+
|
|
296
|
+
function frontmatter(meta) {
|
|
297
|
+
return [
|
|
298
|
+
'---',
|
|
299
|
+
`title: ${yq(meta.title)}`,
|
|
300
|
+
`description: 'Live capture of a Claude Code session, auto-synced to Noesis.'`,
|
|
301
|
+
// `source: claude` drives the Noesis "Captured from Claude Code" badge
|
|
302
|
+
// (SourceProviderBadge resolves note.source === 'claude'; must be exactly 'claude').
|
|
303
|
+
`source: claude`,
|
|
304
|
+
`keywords: [session-capture, claude-code, noesis]`,
|
|
305
|
+
`date: ${meta.date}`,
|
|
306
|
+
`updated: ${meta.updated}`,
|
|
307
|
+
`status: active`,
|
|
308
|
+
`source_session_id: ${yq(meta.sessionId)}`,
|
|
309
|
+
`source_cwd: ${yq(meta.cwd)}`,
|
|
310
|
+
`source_git_branch: ${yq(meta.gitBranch)}`,
|
|
311
|
+
'---',
|
|
312
|
+
'',
|
|
313
|
+
].join('\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function resultText(res) {
|
|
317
|
+
const c = res?.content;
|
|
318
|
+
if (typeof c === 'string') return stripCmdNoise(stripAnsi(c));
|
|
319
|
+
if (Array.isArray(c)) return stripCmdNoise(stripAnsi(c.filter((b) => b && b.type === 'text').map((b) => b.text).join(' ')));
|
|
320
|
+
return '';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function firstWords(s, n) {
|
|
324
|
+
const words = oneLine(s).replace(/[#>*`_~|]/g, '').split(' ').filter(Boolean);
|
|
325
|
+
const head = words.slice(0, n).join(' ');
|
|
326
|
+
return words.length > n ? `${head}…` : head;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function blockquote(text) {
|
|
330
|
+
return text.split('\n').map((l) => (l.length ? `> ${l}` : '>')).join('\n');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function isSystemInjection(text) {
|
|
334
|
+
const t = text.trimStart();
|
|
335
|
+
return t.startsWith('<system-reminder') || t.startsWith('[SYSTEM NOTIFICATION') ||
|
|
336
|
+
t.startsWith('<task-notification') || t.startsWith('<command-') ||
|
|
337
|
+
t.startsWith('<local-command-stdout') || t.startsWith('Caveat:');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function summarizeToolUse(name, input) {
|
|
341
|
+
input = input || {};
|
|
342
|
+
const t = (s, n = 160) => oneLine(s).slice(0, n);
|
|
343
|
+
switch (name) {
|
|
344
|
+
case 'Read': return `Read \`${input.file_path || ''}\``;
|
|
345
|
+
case 'Edit': case 'Write': case 'MultiEdit':
|
|
346
|
+
return `${name} \`${input.file_path || ''}\``;
|
|
347
|
+
case 'NotebookEdit': return `NotebookEdit \`${input.notebook_path || ''}\``;
|
|
348
|
+
case 'Bash': return `Bash: \`${t(input.command || '', 160)}\``;
|
|
349
|
+
case 'PowerShell': return `PowerShell: \`${t(input.command || '', 160)}\``;
|
|
350
|
+
case 'Glob': return `Glob \`${input.pattern || ''}\``;
|
|
351
|
+
case 'Grep': return `Grep \`${input.pattern || ''}\``;
|
|
352
|
+
case 'Task': case 'Agent':
|
|
353
|
+
return `Agent (${input.subagent_type || 'agent'}): ${t(input.description || '')}`;
|
|
354
|
+
case 'WebFetch': return `WebFetch ${input.url || ''}`;
|
|
355
|
+
case 'WebSearch': return `WebSearch: ${t(input.query || '')}`;
|
|
356
|
+
case 'TodoWrite': case 'TaskCreate': case 'TaskUpdate': return name;
|
|
357
|
+
default: {
|
|
358
|
+
const keys = Object.keys(input);
|
|
359
|
+
if (!keys.length) return name;
|
|
360
|
+
// Wrap raw JSON in a code span so any `<` inside is HTML-safe and reads as code.
|
|
361
|
+
return `${name}: \`${t(JSON.stringify(input), 120)}\``;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function summarizeToolResult(res) {
|
|
367
|
+
if (!res) return '';
|
|
368
|
+
if (res.is_error) return `(error) ${esc(oneLine(resultText(res)).slice(0, 200))}`;
|
|
369
|
+
const txt = oneLine(resultText(res));
|
|
370
|
+
return txt ? esc(txt.slice(0, 200)) : '(done)';
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function parseAnswers(res) {
|
|
374
|
+
const map = new Map();
|
|
375
|
+
if (!res) return map;
|
|
376
|
+
const txt = resultText(res);
|
|
377
|
+
const re = /"([^"]*)"\s*=\s*"([^"]*)"/g;
|
|
378
|
+
let m;
|
|
379
|
+
while ((m = re.exec(txt)) !== null) map.set(m[1], m[2]);
|
|
380
|
+
return map;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderDecisionBlock(toolUse, resultMap) {
|
|
384
|
+
const qs = Array.isArray(toolUse.input?.questions) ? toolUse.input.questions : [];
|
|
385
|
+
const answers = parseAnswers(resultMap.get(toolUse.id));
|
|
386
|
+
const lines = [];
|
|
387
|
+
for (const q of qs) {
|
|
388
|
+
lines.push(`#### Decision — ${esc(oneLine(q.header)) || 'Question'}`);
|
|
389
|
+
lines.push(`> ${esc(oneLine(q.question))}`);
|
|
390
|
+
const ans = answers.get(q.question);
|
|
391
|
+
const opts = Array.isArray(q.options) ? q.options : [];
|
|
392
|
+
const isChosen = (opt) => ans != null && (ans === opt.label || ans.includes(opt.label));
|
|
393
|
+
for (const opt of opts) {
|
|
394
|
+
const chosen = isChosen(opt);
|
|
395
|
+
const label = chosen ? `**${esc(opt.label)}**` : esc(opt.label);
|
|
396
|
+
const desc = opt.description ? ` — ${esc(oneLine(opt.description))}` : '';
|
|
397
|
+
lines.push(`- ${label}${desc}${chosen ? ' **(chosen)**' : ''}`);
|
|
398
|
+
}
|
|
399
|
+
if (ans != null) {
|
|
400
|
+
const custom = !opts.some(isChosen);
|
|
401
|
+
lines.push(`**Chosen:** ${esc(ans)}${custom ? ' _(custom)_' : ''}`);
|
|
402
|
+
} else {
|
|
403
|
+
lines.push(`**Chosen:** _(pending)_`);
|
|
404
|
+
}
|
|
405
|
+
lines.push('');
|
|
406
|
+
}
|
|
407
|
+
return lines.join('\n');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function renderPlanBlock(toolUse, resultMap) {
|
|
411
|
+
const res = resultMap.get(toolUse.id);
|
|
412
|
+
const txt = res ? resultText(res) : '';
|
|
413
|
+
const lines = ['- **Plan submitted for approval (ExitPlanMode)**'];
|
|
414
|
+
if (txt && txt.trim()) {
|
|
415
|
+
const body = txt
|
|
416
|
+
.replace(/<\/details>/gi, '</details>') // never let an inner tag close our wrapper
|
|
417
|
+
.replace(/^(#{1,6})\s+(.*)$/gm, '**$2**'); // demote headings so they don't pollute the note outline
|
|
418
|
+
const capped = body.length > 12000 ? `${body.slice(0, 12000)}\n\n… (truncated)` : body;
|
|
419
|
+
lines.push('', '<details>', '<summary>Plan / approval</summary>', '', capped, '', '</details>', '');
|
|
420
|
+
} else {
|
|
421
|
+
lines.push(' - ↳ _(awaiting approval)_');
|
|
422
|
+
}
|
|
423
|
+
return lines.join('\n');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function renderUserPrompt(turn, text, entry, queued = false) {
|
|
427
|
+
const clean = stripImageRefs(text);
|
|
428
|
+
const imageOnly = !clean;
|
|
429
|
+
const heading = imageOnly ? '(image)' : (firstWords(clean, 9) || '(empty)');
|
|
430
|
+
const ts = tsStamp(entry.timestamp);
|
|
431
|
+
const body = imageOnly ? '_(image attachment)_'
|
|
432
|
+
: (clean.length > 3000 ? `${clean.slice(0, 3000)}\n\n… (truncated)` : clean);
|
|
433
|
+
const parts = [`### ${turn}. ${heading}`];
|
|
434
|
+
const meta = [ts, queued ? '_(queued while working)_' : ''].filter(Boolean).join(' · ');
|
|
435
|
+
if (meta) parts.push(`*${meta}*`);
|
|
436
|
+
parts.push('', blockquote(body), '');
|
|
437
|
+
return parts.join('\n');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function render(entries, meta) {
|
|
441
|
+
const resultMap = buildResultMap(entries);
|
|
442
|
+
const out = [];
|
|
443
|
+
out.push(frontmatter(meta));
|
|
444
|
+
out.push(`# ${meta.title}`, '');
|
|
445
|
+
out.push(`> Live capture of source session \`${meta.sessionId}\`. Auto-generated — do not edit by hand.`, '');
|
|
446
|
+
out.push('## Conversation', '');
|
|
447
|
+
|
|
448
|
+
let turn = 0;
|
|
449
|
+
for (const e of entries) {
|
|
450
|
+
const content = e.message?.content;
|
|
451
|
+
if (e.type === 'user') {
|
|
452
|
+
if (typeof content === 'string') {
|
|
453
|
+
if (!isSystemInjection(content)) { turn++; out.push(renderUserPrompt(turn, content, e)); }
|
|
454
|
+
} else if (Array.isArray(content)) {
|
|
455
|
+
const hasToolResult = content.some((b) => b?.type === 'tool_result');
|
|
456
|
+
if (!hasToolResult) {
|
|
457
|
+
const texts = content
|
|
458
|
+
.filter((b) => b?.type === 'text' && b.text && !isSystemInjection(b.text))
|
|
459
|
+
.map((b) => b.text);
|
|
460
|
+
if (texts.length) { turn++; out.push(renderUserPrompt(turn, texts.join('\n\n'), e)); }
|
|
461
|
+
}
|
|
462
|
+
// tool_result blocks are folded into the assistant tool_use rendering below
|
|
463
|
+
}
|
|
464
|
+
} else if (e.type === 'queue-operation' && e.operation === 'enqueue') {
|
|
465
|
+
// Messages the user sent while the agent was working are persisted here,
|
|
466
|
+
// NOT as `user` entries. Render real ones; skip task-notification plumbing.
|
|
467
|
+
const text = typeof e.content === 'string' ? e.content : '';
|
|
468
|
+
if (text.trim() && !isSystemInjection(text)) {
|
|
469
|
+
turn++;
|
|
470
|
+
out.push(renderUserPrompt(turn, text, e, true));
|
|
471
|
+
}
|
|
472
|
+
} else if (e.type === 'assistant' && Array.isArray(content)) {
|
|
473
|
+
let emitted = false;
|
|
474
|
+
for (const b of content) {
|
|
475
|
+
if (b.type === 'text') {
|
|
476
|
+
if (b.text && b.text.trim()) { out.push(b.text.trim(), ''); emitted = true; }
|
|
477
|
+
} else if (b.type === 'thinking') {
|
|
478
|
+
// omitted (readable depth)
|
|
479
|
+
} else if (b.type === 'tool_use') {
|
|
480
|
+
if (b.name === 'AskUserQuestion') out.push(renderDecisionBlock(b, resultMap));
|
|
481
|
+
else if (b.name === 'ExitPlanMode') out.push(renderPlanBlock(b, resultMap));
|
|
482
|
+
else {
|
|
483
|
+
out.push(`- **${b.name}** — ${summarizeToolUse(b.name, b.input)}`);
|
|
484
|
+
const res = resultMap.get(b.id);
|
|
485
|
+
// Echo-result tools: the tool-use line already names the target, so the
|
|
486
|
+
// success result is noise — a cat -n file preview for Read, a "… updated
|
|
487
|
+
// successfully (file state is current …)" confirmation for the writers.
|
|
488
|
+
// Drop it on success; keep it on error (e.g. "File does not exist",
|
|
489
|
+
// "String to replace not found"). Outcome-bearing tools (Bash/PowerShell,
|
|
490
|
+
// Grep, Glob, Web*) keep their result — that result IS the point of the step.
|
|
491
|
+
const echoTool = b.name === 'Read' || b.name === 'Edit' || b.name === 'Write'
|
|
492
|
+
|| b.name === 'MultiEdit' || b.name === 'NotebookEdit';
|
|
493
|
+
const r = summarizeToolResult(res);
|
|
494
|
+
if (r && !(echoTool && !res?.is_error)) out.push(` - ↳ ${r}`);
|
|
495
|
+
}
|
|
496
|
+
emitted = true;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (emitted) out.push('');
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ------------------------------------------------------------------ output IO
|
|
507
|
+
|
|
508
|
+
function writeOut(outPath, content) {
|
|
509
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
510
|
+
const tmp = `${outPath}.tmp`;
|
|
511
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
512
|
+
fs.renameSync(tmp, outPath);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ------------------------------------------------------------ cloud sync (Noesis)
|
|
516
|
+
//
|
|
517
|
+
// The watcher pushes the rendered note to the Noesis cloud itself, so cloud
|
|
518
|
+
// freshness no longer depends on a Claude session running a sync loop. Auth is
|
|
519
|
+
// read from the environment or ~/.claude.json (never logged). Hash + endpoint
|
|
520
|
+
// match the md-manager MCP server so a direct push is interchangeable with
|
|
521
|
+
// mcp__noesis__sync_notes for these auto-generated, local-authoritative notes.
|
|
522
|
+
|
|
523
|
+
const CLIENT_OS = process.platform === 'darwin' ? 'darwin'
|
|
524
|
+
: process.platform === 'win32' ? 'win32' : 'linux';
|
|
525
|
+
|
|
526
|
+
// sha256 of LF-normalized content — identical to NoesisClient.computeHash.
|
|
527
|
+
function computeHash(content) {
|
|
528
|
+
return crypto.createHash('sha256')
|
|
529
|
+
.update(String(content).replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
|
|
530
|
+
.digest('hex');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Expand ~ / %USERPROFILE% in a root's stored local path (mirrors md-manager).
|
|
534
|
+
function expandHome(p) {
|
|
535
|
+
if (!p) return '';
|
|
536
|
+
if (p === '~') return HOME;
|
|
537
|
+
if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(HOME, p.slice(2));
|
|
538
|
+
const m = p.match(/^%USERPROFILE%([\\/].*)?$/i);
|
|
539
|
+
if (m) return m[1] ? path.join(HOME, m[1].slice(1)) : HOME;
|
|
540
|
+
return p;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Resolve {token, baseUrl} from env, else ~/.claude.json -> mcpServers.noesis.env.
|
|
544
|
+
// Returns null when no token is available (caller runs local-only).
|
|
545
|
+
function loadCloudConfig() {
|
|
546
|
+
let token = process.env.NOESIS_API_TOKEN || '';
|
|
547
|
+
let baseUrl = process.env.NOESIS_API_URL || '';
|
|
548
|
+
if (!token || !baseUrl) {
|
|
549
|
+
try {
|
|
550
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(HOME, '.claude.json'), 'utf8'));
|
|
551
|
+
const env = (cfg && cfg.mcpServers && cfg.mcpServers.noesis && cfg.mcpServers.noesis.env) || {};
|
|
552
|
+
token = token || env.NOESIS_API_TOKEN || '';
|
|
553
|
+
baseUrl = baseUrl || env.NOESIS_API_URL || '';
|
|
554
|
+
} catch { /* no usable config -> local-only */ }
|
|
555
|
+
}
|
|
556
|
+
baseUrl = (baseUrl || 'https://noesisbrain.com').replace(/\/+$/, '');
|
|
557
|
+
if (!token) return null;
|
|
558
|
+
return { token, baseUrl, clientOs: CLIENT_OS };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function noesisFetch(cfg, method, p, body) {
|
|
562
|
+
const res = await fetch(cfg.baseUrl + p, {
|
|
563
|
+
method,
|
|
564
|
+
headers: {
|
|
565
|
+
Authorization: `Bearer ${cfg.token}`,
|
|
566
|
+
'Content-Type': 'application/json',
|
|
567
|
+
'X-Client-OS': cfg.clientOs,
|
|
568
|
+
},
|
|
569
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
570
|
+
signal: AbortSignal.timeout(20000),
|
|
571
|
+
});
|
|
572
|
+
if (!res.ok) {
|
|
573
|
+
let detail = '';
|
|
574
|
+
try { detail = JSON.stringify(await res.json()); }
|
|
575
|
+
catch { try { detail = await res.text(); } catch { /* ignore */ } }
|
|
576
|
+
const err = new Error(`HTTP ${res.status}${detail ? ' ' + detail.slice(0, 180) : ''}`);
|
|
577
|
+
err.status = res.status;
|
|
578
|
+
throw err;
|
|
579
|
+
}
|
|
580
|
+
return res.json();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Choose the default capture output directory: a `captures/` folder under a
|
|
584
|
+
// registered Noesis root, so the cloud push always finds a matching root.
|
|
585
|
+
// Prefers the standard ~/Noesis root; else the first root configured for this OS.
|
|
586
|
+
// Falls back to DEFAULT_OUT_DIR when the cloud/roots are unavailable. All paths are
|
|
587
|
+
// home-expanded HERE so the caller (and the /noesis-capture skill via --resolve-only)
|
|
588
|
+
// never has to expand a literal `~` / `%USERPROFILE%` from the stored root path.
|
|
589
|
+
async function resolveDefaultOutDir(cfg) {
|
|
590
|
+
if (!cfg) return DEFAULT_OUT_DIR;
|
|
591
|
+
try {
|
|
592
|
+
const data = await noesisFetch(cfg, 'GET', '/api/mcp/roots');
|
|
593
|
+
const roots = Array.isArray(data?.roots) ? data.roots : [];
|
|
594
|
+
const expanded = roots
|
|
595
|
+
.map((r) => (r.local_paths && r.local_paths[cfg.clientOs]) || r.path || '')
|
|
596
|
+
.filter(Boolean)
|
|
597
|
+
.map((lp) => path.resolve(expandHome(lp)));
|
|
598
|
+
const standard = path.resolve(path.join(HOME, 'Noesis'));
|
|
599
|
+
if (expanded.some((p) => p.toLowerCase() === standard.toLowerCase())) {
|
|
600
|
+
return path.join(standard, 'captures');
|
|
601
|
+
}
|
|
602
|
+
if (expanded.length) return path.join(expanded[0], 'captures');
|
|
603
|
+
} catch { /* fall through to the default */ }
|
|
604
|
+
return DEFAULT_OUT_DIR;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Map an absolute outPath to {rootId, rootName, relativePath} via GET /api/mcp/roots
|
|
608
|
+
// (longest-prefix match, case-insensitive on Windows). Returns null if no root matches.
|
|
609
|
+
async function resolveRoot(cfg, outPath) {
|
|
610
|
+
const data = await noesisFetch(cfg, 'GET', '/api/mcp/roots');
|
|
611
|
+
const roots = Array.isArray(data?.roots) ? data.roots : [];
|
|
612
|
+
const targetReal = path.resolve(outPath).replace(/\\/g, '/');
|
|
613
|
+
const targetCmp = targetReal.toLowerCase().replace(/\/+$/, '');
|
|
614
|
+
let best = null;
|
|
615
|
+
for (const r of roots) {
|
|
616
|
+
const lp = (r.local_paths && r.local_paths[cfg.clientOs]) || r.path || '';
|
|
617
|
+
if (!lp) continue;
|
|
618
|
+
const rootReal = path.resolve(expandHome(lp)).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
619
|
+
const rootCmp = rootReal.toLowerCase();
|
|
620
|
+
if (targetCmp === rootCmp || targetCmp.startsWith(rootCmp + '/')) {
|
|
621
|
+
if (!best || rootCmp.length > best.len) {
|
|
622
|
+
let rel = targetReal.slice(rootReal.length).replace(/^\/+/, '');
|
|
623
|
+
if (!rel) rel = path.basename(targetReal);
|
|
624
|
+
best = { rootId: r.id, rootName: r.name, relativePath: rel, len: rootCmp.length };
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return best ? { rootId: best.rootId, rootName: best.rootName, relativePath: best.relativePath } : null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// POST the note. force:false lets the server short-circuit unchanged content to
|
|
632
|
+
// "skipped"; regenerateMetadata:false / preserveMetadata:true keep AI metadata
|
|
633
|
+
// off the hot path. Returns { action: 'created'|'updated'|'skipped' }.
|
|
634
|
+
async function upsertNote(cfg, root, outPath, content, { force = false } = {}) {
|
|
635
|
+
const body = {
|
|
636
|
+
file: {
|
|
637
|
+
path: path.resolve(outPath).replace(/\\/g, '/'),
|
|
638
|
+
relativePath: root.relativePath,
|
|
639
|
+
content,
|
|
640
|
+
rootId: root.rootId,
|
|
641
|
+
rootName: root.rootName,
|
|
642
|
+
hash: computeHash(content),
|
|
643
|
+
size: Buffer.byteLength(content, 'utf8'),
|
|
644
|
+
},
|
|
645
|
+
metadata: {},
|
|
646
|
+
force,
|
|
647
|
+
regenerateMetadata: false,
|
|
648
|
+
preserveMetadata: true,
|
|
649
|
+
};
|
|
650
|
+
return noesisFetch(cfg, 'POST', '/api/mcp/notes/upsert', body);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Lightweight liveness ping — deliberately separate from upsertNote(), which
|
|
654
|
+
// carries the note's entire content. Powers the frontend's "actively under
|
|
655
|
+
// watching" title indicator (Noesis web app), which treats capture as live
|
|
656
|
+
// only while this heartbeat is recent (see capture_heartbeat_at staleness
|
|
657
|
+
// check there) — a killed/crashed watcher never sends watching:false, so the
|
|
658
|
+
// frontend times it out instead of waiting for an explicit stop signal.
|
|
659
|
+
async function sendHeartbeat(cfg, root, watching) {
|
|
660
|
+
return noesisFetch(cfg, 'POST', '/api/mcp/notes/heartbeat', {
|
|
661
|
+
rootId: root.rootId,
|
|
662
|
+
relativePath: root.relativePath,
|
|
663
|
+
watching,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Per-session status sidecar the statusline reads (.<id>.cloud.json). Owned solely
|
|
668
|
+
// by this watcher; written atomically so it never races the skill's state file.
|
|
669
|
+
function sidecarPath(outPath) {
|
|
670
|
+
const id = path.basename(outPath).replace(/\.md$/i, '');
|
|
671
|
+
return path.join(path.dirname(outPath), `.${id}.cloud.json`);
|
|
672
|
+
}
|
|
673
|
+
function writeSidecar(outPath, obj) {
|
|
674
|
+
try {
|
|
675
|
+
const p = sidecarPath(outPath);
|
|
676
|
+
const tmp = `${p}.tmp`;
|
|
677
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf8');
|
|
678
|
+
fs.renameSync(tmp, p);
|
|
679
|
+
} catch { /* sidecar is best-effort; statusline tolerates its absence */ }
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ------------------------------------------------------------------ main
|
|
683
|
+
|
|
684
|
+
const USAGE = `noesis-capture-watcher.mjs --session "<id-or-name>" [--out <path>] [--once] [--resolve-only] [--print-self] [--self <id>] [--interval-ms 1500] [--max-life-ms <ms>]`;
|
|
685
|
+
|
|
686
|
+
async function main() {
|
|
687
|
+
const args = parseArgs(process.argv.slice(2));
|
|
688
|
+
if (args.help) { process.stdout.write(`${USAGE}\n`); process.exit(0); }
|
|
689
|
+
if (args.printSelf) { process.stdout.write(`SELF=${selfSessionId()}\n`); process.exit(0); }
|
|
690
|
+
if (!args.session) { process.stderr.write(`ERROR: --session is required\n${USAGE}\n`); process.exit(1); }
|
|
691
|
+
|
|
692
|
+
const resolved = resolve(args.session);
|
|
693
|
+
if (!resolved) {
|
|
694
|
+
process.stderr.write(`ERROR: could not resolve session "${args.session}"\n`);
|
|
695
|
+
process.exit(2);
|
|
696
|
+
}
|
|
697
|
+
if (args.self && args.self === resolved.sessionId) {
|
|
698
|
+
process.stderr.write(`REFUSED: self-monitor guard — session "${resolved.sessionId}" is the current (controller) session.\n`);
|
|
699
|
+
process.exit(3);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Cloud config drives both root resolution (below) and the sync loop. Resolve it
|
|
703
|
+
// once, up front, so --resolve-only reports the same root-derived outPath the run uses.
|
|
704
|
+
const cloudCfg = args.noCloud ? null : loadCloudConfig();
|
|
705
|
+
const outPath = args.out || path.join(await resolveDefaultOutDir(cloudCfg), `${resolved.sessionId}.md`);
|
|
706
|
+
|
|
707
|
+
if (args.resolveOnly) {
|
|
708
|
+
const meta = extractMeta(readEntries(resolved.transcriptPath), resolved.sessionId);
|
|
709
|
+
process.stdout.write(`${JSON.stringify({
|
|
710
|
+
sessionId: resolved.sessionId,
|
|
711
|
+
transcriptPath: resolved.transcriptPath,
|
|
712
|
+
title: meta.title,
|
|
713
|
+
cwd: meta.cwd,
|
|
714
|
+
gitBranch: meta.gitBranch,
|
|
715
|
+
outPath,
|
|
716
|
+
candidates: resolved.candidates || [],
|
|
717
|
+
}, null, 2)}\n`);
|
|
718
|
+
process.exit(0);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// -------- cloud-sync state (deterministic; no Claude session involved) --------
|
|
722
|
+
const pushMinIntervalMs = args.pushMinIntervalMs && args.pushMinIntervalMs > 0 ? args.pushMinIntervalMs : 5000;
|
|
723
|
+
const heartbeatIntervalMs = 45000; // must stay well under the frontend's ~2min staleness cutoff
|
|
724
|
+
let lastHeartbeatAt = 0;
|
|
725
|
+
let cloudRoot = null;
|
|
726
|
+
let lastRootAttempt = 0;
|
|
727
|
+
let currentMd = null; // latest rendered markdown
|
|
728
|
+
let currentHash = null; // sha256 of currentMd
|
|
729
|
+
let lastPushedHash = null; // sha256 of the content last confirmed on the cloud
|
|
730
|
+
let lastPushAttempt = 0;
|
|
731
|
+
let pushBackoffMs = 0;
|
|
732
|
+
let pushInFlight = false;
|
|
733
|
+
const status = { lastPushTime: 0, lastAction: null, lastError: null };
|
|
734
|
+
|
|
735
|
+
const publishSidecar = () => writeSidecar(outPath, {
|
|
736
|
+
sessionId: resolved.sessionId,
|
|
737
|
+
cloudEnabled: !!cloudCfg,
|
|
738
|
+
rootResolved: !!cloudRoot,
|
|
739
|
+
relativePath: cloudRoot ? cloudRoot.relativePath : null,
|
|
740
|
+
lastPushTime: status.lastPushTime,
|
|
741
|
+
lastAction: status.lastAction,
|
|
742
|
+
lastError: status.lastError,
|
|
743
|
+
updatedAt: Date.now(),
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
// Resolve the note's Noesis root once (lazy retry every 30s on failure).
|
|
747
|
+
const ensureRoot = async () => {
|
|
748
|
+
if (!cloudCfg || cloudRoot) return;
|
|
749
|
+
const now = Date.now();
|
|
750
|
+
if (now - lastRootAttempt < 30000) return;
|
|
751
|
+
lastRootAttempt = now;
|
|
752
|
+
try {
|
|
753
|
+
cloudRoot = await resolveRoot(cloudCfg, outPath);
|
|
754
|
+
if (cloudRoot) { status.lastError = null; log(`cloud: root "${cloudRoot.rootName}" -> ${cloudRoot.relativePath}`); }
|
|
755
|
+
else { status.lastError = 'no-matching-root'; log(`cloud: no Noesis root matches ${outPath} — push disabled`); }
|
|
756
|
+
} catch (err) {
|
|
757
|
+
status.lastError = `roots: ${err.message}`.slice(0, 200);
|
|
758
|
+
log(`cloud: roots fetch failed — ${err.message}`);
|
|
759
|
+
}
|
|
760
|
+
publishSidecar();
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
// Push the latest content when it differs from the cloud copy and the debounce
|
|
764
|
+
// window (or post-failure backoff) has elapsed. Safe to call every tick: it
|
|
765
|
+
// self-guards on in-flight / interval, so a transient failure retries on the
|
|
766
|
+
// next tick even when the transcript is idle. Never rejects.
|
|
767
|
+
const syncTick = async (force = false) => {
|
|
768
|
+
if (!cloudCfg) return;
|
|
769
|
+
await ensureRoot();
|
|
770
|
+
if (!cloudRoot) return;
|
|
771
|
+
if (currentHash == null || currentHash === lastPushedHash) return; // nothing pending
|
|
772
|
+
if (pushInFlight) return;
|
|
773
|
+
const now = Date.now();
|
|
774
|
+
if (!force && now - lastPushAttempt < Math.max(pushMinIntervalMs, pushBackoffMs)) return;
|
|
775
|
+
pushInFlight = true;
|
|
776
|
+
lastPushAttempt = now;
|
|
777
|
+
const hashToPush = currentHash;
|
|
778
|
+
const contentToPush = currentMd;
|
|
779
|
+
try {
|
|
780
|
+
let res;
|
|
781
|
+
try { res = await upsertNote(cloudCfg, cloudRoot, outPath, contentToPush); }
|
|
782
|
+
catch (e) {
|
|
783
|
+
if (e.status === 409) res = await upsertNote(cloudCfg, cloudRoot, outPath, contentToPush, { force: true });
|
|
784
|
+
else throw e;
|
|
785
|
+
}
|
|
786
|
+
lastPushedHash = hashToPush;
|
|
787
|
+
pushBackoffMs = 0;
|
|
788
|
+
status.lastPushTime = Date.now();
|
|
789
|
+
status.lastAction = (res && res.action) || 'updated';
|
|
790
|
+
status.lastError = null;
|
|
791
|
+
log(`cloud: ${status.lastAction} ${cloudRoot.relativePath}`);
|
|
792
|
+
} catch (err) {
|
|
793
|
+
pushBackoffMs = Math.min(pushBackoffMs ? pushBackoffMs * 2 : 5000, 120000);
|
|
794
|
+
status.lastError = String(err.message).slice(0, 200);
|
|
795
|
+
log(`cloud: push failed — ${err.message} (retry in ~${Math.round(pushBackoffMs / 1000)}s)`);
|
|
796
|
+
} finally {
|
|
797
|
+
pushInFlight = false;
|
|
798
|
+
publishSidecar();
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
// Periodic liveness ping, independent of content changes — an idle session
|
|
803
|
+
// (no new transcript entries) would otherwise never push anything via
|
|
804
|
+
// syncTick(), leaving the frontend's staleness check with no way to tell
|
|
805
|
+
// "quiet but alive" from "dead". Fire-and-forget; a dropped heartbeat just
|
|
806
|
+
// waits for the next tick rather than backing off like content pushes do.
|
|
807
|
+
const heartbeatTick = async (watching) => {
|
|
808
|
+
if (!cloudCfg || !cloudRoot) return;
|
|
809
|
+
lastHeartbeatAt = Date.now();
|
|
810
|
+
try { await sendHeartbeat(cloudCfg, cloudRoot, watching); }
|
|
811
|
+
catch (err) { log(`cloud: heartbeat failed — ${err.message}`); }
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
// Best-effort final push on graceful shutdown (Ctrl+C / SIGTERM). A force-kill
|
|
815
|
+
// by the SessionEnd hook skips this, but the cloud is already current from the
|
|
816
|
+
// last change-driven push, so that is acceptable.
|
|
817
|
+
const finalPush = async () => {
|
|
818
|
+
if (!cloudCfg || !cloudRoot || currentHash == null || currentHash === lastPushedHash) return;
|
|
819
|
+
try {
|
|
820
|
+
const res = await upsertNote(cloudCfg, cloudRoot, outPath, currentMd);
|
|
821
|
+
lastPushedHash = currentHash;
|
|
822
|
+
status.lastPushTime = Date.now();
|
|
823
|
+
status.lastAction = (res && res.action) || 'updated';
|
|
824
|
+
status.lastError = null;
|
|
825
|
+
publishSidecar();
|
|
826
|
+
log(`cloud: final ${status.lastAction}`);
|
|
827
|
+
} catch (err) {
|
|
828
|
+
log(`cloud: final push failed — ${err.message}`);
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
const renderOnce = () => {
|
|
833
|
+
const entries = readEntries(resolved.transcriptPath);
|
|
834
|
+
const meta = extractMeta(entries, resolved.sessionId);
|
|
835
|
+
const md = render(entries, meta);
|
|
836
|
+
writeOut(outPath, md);
|
|
837
|
+
currentMd = md;
|
|
838
|
+
currentHash = computeHash(md);
|
|
839
|
+
return { entries: entries.length, bytes: md.length };
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
const first = renderOnce();
|
|
843
|
+
log(`resolved ${resolved.sessionId} -> ${outPath} (${first.entries} entries, ${first.bytes} bytes)`);
|
|
844
|
+
|
|
845
|
+
if (args.once) {
|
|
846
|
+
if (cloudCfg) await syncTick(true); else log('cloud: disabled (--no-cloud)');
|
|
847
|
+
process.exit(0);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (cloudCfg) log(`cloud: enabled -> ${cloudCfg.baseUrl} (push on change, >= every ${Math.round(pushMinIntervalMs / 1000)}s)`);
|
|
851
|
+
else log('cloud: disabled — no NOESIS_API_TOKEN in env or ~/.claude.json (local render only)');
|
|
852
|
+
publishSidecar();
|
|
853
|
+
if (cloudCfg) syncTick(true).catch(() => {}); // initial cloud push (fire-and-forget)
|
|
854
|
+
// lastHeartbeatAt stays 0 — the main loop below sends the first heartbeat as soon
|
|
855
|
+
// as cloudRoot resolves (heartbeatTick no-ops until then), skipping the case where
|
|
856
|
+
// the note doesn't exist in the cloud yet.
|
|
857
|
+
|
|
858
|
+
let last = { mtimeMs: statMtime(resolved.transcriptPath), size: -1 };
|
|
859
|
+
try { const s = fs.statSync(resolved.transcriptPath); last = { mtimeMs: s.mtimeMs, size: s.size }; } catch {}
|
|
860
|
+
|
|
861
|
+
// Optional hard lifetime cap: self-exit after maxLifeMs from now. Backstop that
|
|
862
|
+
// fires even if the controller session has gone away.
|
|
863
|
+
const lifeStartMs = Date.now();
|
|
864
|
+
const maxLifeMs = args.maxLifeMs && args.maxLifeMs > 0 ? args.maxLifeMs : 0;
|
|
865
|
+
if (maxLifeMs) log(`lifetime cap: ${Math.round(maxLifeMs / 60000)}m — will self-exit when reached`);
|
|
866
|
+
|
|
867
|
+
log(`watching every ${args.intervalMs}ms — Ctrl+C to stop`);
|
|
868
|
+
const timer = setInterval(() => {
|
|
869
|
+
if (maxLifeMs && Date.now() - lifeStartMs >= maxLifeMs) {
|
|
870
|
+
clearInterval(timer);
|
|
871
|
+
log(`lifetime cap reached (${Math.round(maxLifeMs / 60000)}m) — stopping watcher`);
|
|
872
|
+
(async () => { await heartbeatTick(false); await finalPush(); process.exit(0); })();
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const s = fs.statSync(resolved.transcriptPath, { throwIfNoEntry: false });
|
|
876
|
+
if (s && (s.mtimeMs !== last.mtimeMs || s.size !== last.size)) {
|
|
877
|
+
last = { mtimeMs: s.mtimeMs, size: s.size };
|
|
878
|
+
try {
|
|
879
|
+
const r = renderOnce();
|
|
880
|
+
log(`updated -> ${outPath} (${r.entries} entries)`);
|
|
881
|
+
} catch (err) {
|
|
882
|
+
log(`render error: ${err.message}`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
// Cloud push: drive a pending change OR retry a prior failure, regardless of
|
|
886
|
+
// whether the transcript changed this tick.
|
|
887
|
+
syncTick().catch(() => {});
|
|
888
|
+
// Liveness heartbeat: independent of content changes, so an idle-but-alive
|
|
889
|
+
// session still refreshes capture_heartbeat_at before it goes stale.
|
|
890
|
+
if (Date.now() - lastHeartbeatAt >= heartbeatIntervalMs) {
|
|
891
|
+
heartbeatTick(true).catch(() => {});
|
|
892
|
+
}
|
|
893
|
+
}, args.intervalMs);
|
|
894
|
+
|
|
895
|
+
const stop = () => {
|
|
896
|
+
clearInterval(timer);
|
|
897
|
+
log('stopped');
|
|
898
|
+
(async () => { await heartbeatTick(false); await finalPush(); process.exit(0); })();
|
|
899
|
+
};
|
|
900
|
+
process.on('SIGINT', stop);
|
|
901
|
+
process.on('SIGTERM', stop);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
main().catch((err) => {
|
|
905
|
+
process.stderr.write(`FATAL: ${err && err.stack ? err.stack : err}\n`);
|
|
906
|
+
process.exit(1);
|
|
907
|
+
});
|