@ssm-08/relay 0.4.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/bin/relay.mjs ADDED
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import url from 'node:url';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { isMain } from '../lib/util.mjs';
7
+
8
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
9
+
10
+ export function relayInit(targetDir) {
11
+ const relayDir = path.join(targetDir, '.relay');
12
+
13
+ if (fs.existsSync(relayDir)) {
14
+ console.log('Relay already initialized.');
15
+ return false;
16
+ }
17
+
18
+ // Create .relay/memory.md — optionally seeded from existing CLAUDE.md
19
+ fs.mkdirSync(relayDir, { recursive: true });
20
+
21
+ let seedSection = '';
22
+ const claudeMdPath = path.join(targetDir, 'CLAUDE.md');
23
+ let claudeStat;
24
+ try { claudeStat = fs.statSync(claudeMdPath); } catch {}
25
+ if (claudeStat && claudeStat.isFile()) {
26
+ const raw = fs.readFileSync(claudeMdPath, 'utf8');
27
+ const MAX = 8000;
28
+ const body = raw.length > MAX ? raw.slice(0, MAX) + '\n<!-- truncated -->' : raw;
29
+ const trimmed = body.trim();
30
+ if (trimmed) {
31
+ seedSection = `\n\n## Seeded from CLAUDE.md\n\n<!-- One-time import on relay init. Not kept in sync. -->\n\n${trimmed}`;
32
+ console.log(' → Seeded memory.md from existing CLAUDE.md');
33
+ }
34
+ }
35
+
36
+ fs.writeFileSync(
37
+ path.join(relayDir, 'memory.md'),
38
+ `# Relay Memory\n<!-- Populated by distiller. Edit manually to seed context. -->${seedSection}\n`,
39
+ 'utf8'
40
+ );
41
+
42
+ // Create .relay/broadcast/ with .gitkeep so git tracks the empty dir
43
+ fs.mkdirSync(path.join(relayDir, 'broadcast'), { recursive: true });
44
+ fs.writeFileSync(path.join(relayDir, 'broadcast', '.gitkeep'), '', 'utf8');
45
+
46
+ // Create .relay/broadcast/skills/ with .gitkeep so git tracks it before any skill is broadcast
47
+ fs.mkdirSync(path.join(relayDir, 'broadcast', 'skills'), { recursive: true });
48
+ fs.writeFileSync(path.join(relayDir, 'broadcast', 'skills', '.gitkeep'), '', 'utf8');
49
+
50
+ // Update .gitignore — idempotent
51
+ const gitignorePath = path.join(targetDir, '.gitignore');
52
+ let existing = '';
53
+ try { existing = fs.readFileSync(gitignorePath, 'utf8'); } catch {}
54
+
55
+ const toAdd = [];
56
+ if (!existing.includes('.relay/state/')) toAdd.push('.relay/state/');
57
+ if (!existing.includes('.relay/log')) toAdd.push('.relay/log');
58
+
59
+ if (toAdd.length > 0) {
60
+ const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
61
+ fs.appendFileSync(gitignorePath, prefix + toAdd.join('\n') + '\n', 'utf8');
62
+ }
63
+
64
+ console.log('Relay initialized.\n');
65
+ console.log(' git add .relay/ .gitignore');
66
+ console.log(' git commit -m "chore: add relay shared memory"');
67
+ console.log(' git push\n');
68
+ console.log('Teammates install once per machine:');
69
+ console.log(' npm install -g @ssm-08/relay && relay install');
70
+ return true;
71
+ }
72
+
73
+ export function relayStatus(targetDir) {
74
+ const relayDir = path.join(targetDir, '.relay');
75
+
76
+ if (!fs.existsSync(relayDir)) {
77
+ console.log('Relay not initialized in this repo. Run: relay init');
78
+ return;
79
+ }
80
+
81
+ // M6: fixed-width labels for readable demo output (11 chars + space)
82
+ const label = (s) => s.padEnd(11);
83
+
84
+ // Memory stats
85
+ const memPath = path.join(relayDir, 'memory.md');
86
+ if (fs.existsSync(memPath)) {
87
+ const content = fs.readFileSync(memPath, 'utf8');
88
+ const lines = content.split(/\r?\n/).length;
89
+ const bytes = fs.statSync(memPath).size;
90
+ console.log(`${label('Memory:')} .relay/memory.md (${(bytes / 1024).toFixed(1)} KB, ${lines} lines)`);
91
+ } else {
92
+ console.log(`${label('Memory:')} .relay/memory.md (not found)`);
93
+ }
94
+
95
+ // Watermark / distiller state
96
+ const watermarkPath = path.join(relayDir, 'state', 'watermark.json');
97
+ const logPath = path.join(relayDir, 'log');
98
+ if (fs.existsSync(watermarkPath)) {
99
+ let state = {};
100
+ try { state = JSON.parse(fs.readFileSync(watermarkPath, 'utf8')); } catch {}
101
+
102
+ if (state.last_distilled_at) {
103
+ const ago = Math.round((Date.now() - state.last_distilled_at) / 60_000);
104
+ console.log(`${label('Distilled:')} ${new Date(state.last_distilled_at).toISOString()} (${ago} min ago)`);
105
+ } else {
106
+ console.log(`${label('Distilled:')} never`);
107
+ }
108
+
109
+ if (state.last_uuid) {
110
+ console.log(`${label('Last UUID:')} ${state.last_uuid}`);
111
+ }
112
+
113
+ const turns = state.turns_since_distill ?? 0;
114
+ const threshold = parseInt(process.env.RELAY_TURNS_THRESHOLD ?? '5', 10);
115
+ const progressLine = state.distiller_running
116
+ ? `${turns} turns (distilling now...)`
117
+ : `${turns} / ${threshold} turns until next distill`;
118
+ console.log(`${label('Progress:')} ${progressLine}`);
119
+
120
+ if (state.last_transcript) {
121
+ console.log(`${label('Transcript:')} ${state.last_transcript}`);
122
+ }
123
+ } else {
124
+ console.log(`${label('Progress:')} (no state yet — run a session to start tracking)`);
125
+ }
126
+ const inj = getLastInjection(logPath);
127
+ if (inj) {
128
+ const ago = Math.round((Date.now() - inj.ts) / 60_000);
129
+ console.log(`${label('Injected:')} ${new Date(inj.ts).toISOString()} (${ago} min ago via ${inj.event})`);
130
+ } else {
131
+ console.log(`${label('Injected:')} never`);
132
+ }
133
+
134
+ // Git remote
135
+ try {
136
+ const r = spawnSync('git', ['remote', 'get-url', 'origin'], {
137
+ cwd: targetDir,
138
+ encoding: 'utf8',
139
+ windowsHide: true,
140
+ });
141
+ const remote = (r.stdout || '').trim();
142
+ if (r.status === 0 && remote) {
143
+ console.log(`${label('Remote:')} origin → ${remote}`);
144
+ } else {
145
+ console.log(`${label('Remote:')} (none configured)`);
146
+ }
147
+ } catch {
148
+ console.log(`${label('Remote:')} (none configured)`);
149
+ }
150
+
151
+ // Only show lock when held — not held is the normal state, not worth showing
152
+ const lockPath = path.join(relayDir, 'state', '.lock');
153
+ if (fs.existsSync(lockPath)) {
154
+ try {
155
+ const age = Math.round((Date.now() - fs.statSync(lockPath).mtimeMs) / 1000);
156
+ console.log(`${label('Lock:')} held (${age}s old)`);
157
+ } catch {
158
+ console.log(`${label('Lock:')} held`);
159
+ }
160
+ }
161
+ }
162
+
163
+ export async function relayDistill(targetDir, argv) {
164
+ const relayDir = path.join(targetDir, '.relay');
165
+
166
+ if (!fs.existsSync(relayDir)) {
167
+ console.error('Relay not initialized. Run: relay init');
168
+ process.exit(1);
169
+ }
170
+
171
+ // Parse flags
172
+ const flags = {};
173
+ for (let i = 0; i < argv.length; i++) {
174
+ const a = argv[i];
175
+ if (a === '--force') { flags.force = true; continue; }
176
+ if (a === '--dry-run') { flags.dryRun = true; continue; }
177
+ if (a === '--push') { flags.push = true; continue; }
178
+ if (a === '--transcript' && argv[i + 1]) { flags.transcript = argv[++i]; continue; }
179
+ }
180
+
181
+ // Resolve transcript path
182
+ let transcriptPath = flags.transcript;
183
+ if (!transcriptPath) {
184
+ const watermarkPath = path.join(relayDir, 'state', 'watermark.json');
185
+ try {
186
+ const state = JSON.parse(fs.readFileSync(watermarkPath, 'utf8'));
187
+ transcriptPath = state.last_transcript;
188
+ } catch {}
189
+ }
190
+
191
+ if (!transcriptPath) {
192
+ console.error('No transcript found. Start a Claude Code session in this repo first, or pass --transcript <path>.');
193
+ process.exit(1);
194
+ }
195
+
196
+ const memoryPath = path.join(relayDir, 'memory.md');
197
+ const distillerPath = path.join(__dirname, '..', 'distiller.mjs');
198
+
199
+ const args = [
200
+ distillerPath,
201
+ '--transcript', transcriptPath,
202
+ '--memory', memoryPath,
203
+ '--out', memoryPath,
204
+ '--cwd', targetDir,
205
+ ];
206
+ if (flags.force) args.push('--force');
207
+ if (flags.dryRun) args.push('--dry-run');
208
+
209
+ const result = spawnSync('node', args, { stdio: 'inherit' });
210
+
211
+ if (flags.push && result.status === 0 && !flags.dryRun) {
212
+ const { GitSync } = await import('../lib/sync.mjs');
213
+ const sync = new GitSync();
214
+ let release = () => {};
215
+ try { release = sync.lock(targetDir); } catch (e) {
216
+ if (e.message !== 'LOCKED') console.error(`relay distill: lock error: ${e.message}`);
217
+ else console.error('relay distill: sync locked by another process — retry in a moment');
218
+ // M1: exit 2 so callers can detect that push was skipped (not conflated with success)
219
+ process.exit(2);
220
+ }
221
+ try {
222
+ sync.push(targetDir, 'manual');
223
+ } catch (e) {
224
+ console.error(`relay distill: push failed: ${e.message}`);
225
+ } finally {
226
+ release();
227
+ }
228
+ }
229
+
230
+ process.exit(result.status ?? 0);
231
+ }
232
+
233
+ export function relayBroadcastSkill(targetDir, filePath) {
234
+ const relayDir = path.join(targetDir, '.relay');
235
+
236
+ if (!fs.existsSync(relayDir)) {
237
+ console.error('Relay not initialized. Run: relay init');
238
+ return null;
239
+ }
240
+
241
+ if (!filePath) {
242
+ console.error('Usage: relay broadcast-skill <file>');
243
+ return null;
244
+ }
245
+
246
+ if (!fs.existsSync(filePath)) {
247
+ console.error(`File not found: ${filePath}`);
248
+ return null;
249
+ }
250
+
251
+ const skillName = path.basename(filePath);
252
+ const ext = path.extname(filePath).toLowerCase();
253
+ const TEXT_EXTS = new Set(['.md', '.toml', '.txt', '.json', '.yaml', '.yml']);
254
+ if (!TEXT_EXTS.has(ext)) {
255
+ console.warn(`Warning: "${skillName}" has extension "${ext}" — expected a text skill file (.md, .toml). Proceeding anyway.`);
256
+ }
257
+
258
+ const destDir = path.join(relayDir, 'broadcast', 'skills');
259
+ fs.mkdirSync(destDir, { recursive: true });
260
+
261
+ const destPath = path.join(destDir, skillName);
262
+ fs.copyFileSync(filePath, destPath);
263
+ console.log(`Broadcast: .relay/broadcast/skills/${skillName}`);
264
+ return destPath;
265
+ }
266
+
267
+ const HELP_TEXT =
268
+ `Usage: relay <command>\n\nCommands:\n` +
269
+ ` init Initialize relay in current repository\n` +
270
+ ` status Show memory, watermark, and sync state\n` +
271
+ ` log Show distiller log [--lines <n>] (default 50)\n` +
272
+ ` distill Run distiller manually [--transcript <path>] [--force] [--dry-run] [--push]\n` +
273
+ ` broadcast-skill Broadcast a skill file to all teammates [<file>]\n` +
274
+ ` install Install relay hooks on this machine [--from-local <path>] [--home <path>]\n` +
275
+ ` update Update relay to latest version\n` +
276
+ ` uninstall Remove relay hooks from this machine [--yes]\n` +
277
+ ` doctor Verify relay install is working correctly\n\n` +
278
+ `Options:\n` +
279
+ ` --version Print relay version\n` +
280
+ ` --help Show this help`;
281
+
282
+ export function relayVersion() {
283
+ const pkgPath = path.join(__dirname, '..', 'package.json');
284
+ try {
285
+ const { version } = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
286
+ console.log(`relay ${version}`);
287
+ } catch {
288
+ console.log('relay (unknown version)');
289
+ }
290
+ }
291
+
292
+ export function relayLog(targetDir, argv) {
293
+ const logPath = path.join(targetDir, '.relay', 'log');
294
+
295
+ let lines = 50;
296
+ for (let i = 0; i < argv.length; i++) {
297
+ if ((argv[i] === '--lines' || argv[i] === '-n') && argv[i + 1]) {
298
+ const n = parseInt(argv[++i], 10);
299
+ if (!isNaN(n) && n > 0) lines = n;
300
+ else if (!isNaN(n)) console.error(`relay log: --lines must be a positive integer (got ${n}), using default ${lines}`);
301
+ }
302
+ }
303
+
304
+ if (!fs.existsSync(logPath)) {
305
+ console.log('No log yet — distiller has not run in this repo.');
306
+ return;
307
+ }
308
+
309
+ const content = fs.readFileSync(logPath, 'utf8');
310
+ const allLines = content.split(/\r?\n/);
311
+ // Drop trailing empty line from final newline
312
+ if (allLines.length > 0 && allLines[allLines.length - 1] === '') allLines.pop();
313
+
314
+ if (allLines.length === 0) {
315
+ console.log('Log is empty.');
316
+ return;
317
+ }
318
+
319
+ const tail = allLines.slice(-lines);
320
+ if (allLines.length > lines) {
321
+ console.log(`... (${allLines.length - lines} earlier lines omitted — use --lines to show more)\n`);
322
+ }
323
+ const inj = getLastInjection(logPath);
324
+ if (inj) {
325
+ const ago = Math.round((Date.now() - inj.ts) / 60_000);
326
+ console.log(`Last injected: ${new Date(inj.ts).toISOString()} (${ago} min ago via ${inj.event})\n`);
327
+ }
328
+ console.log(tail.join('\n'));
329
+ }
330
+
331
+ function getLastInjection(logPath) {
332
+ if (!fs.existsSync(logPath)) return null;
333
+ const lines = fs.readFileSync(logPath, 'utf8').split(/\r?\n/);
334
+ for (let i = lines.length - 1; i >= 0; i--) {
335
+ const line = lines[i];
336
+ if (!line) continue;
337
+ const m = line.match(/^\[([^\]]+)\]\s+injection:\s+([a-z0-9-]+)\s*$/i);
338
+ if (!m) continue;
339
+ const ts = Date.parse(m[1]);
340
+ if (!isNaN(ts)) return { ts, event: m[2] };
341
+ }
342
+ return null;
343
+ }
344
+
345
+ if (isMain(import.meta.url)) {
346
+ const [, , command, ...rest] = process.argv;
347
+
348
+ if (command === '--version' || command === '-v') {
349
+ relayVersion();
350
+ } else if (command === '--help' || command === '-h') {
351
+ console.log(HELP_TEXT);
352
+ } else if (command === 'init') {
353
+ relayInit(process.cwd());
354
+ } else if (command === 'status') {
355
+ relayStatus(process.cwd());
356
+ } else if (command === 'log') {
357
+ relayLog(process.cwd(), rest);
358
+ } else if (command === 'distill') {
359
+ await relayDistill(process.cwd(), rest);
360
+ } else if (command === 'broadcast-skill') {
361
+ const filePath = rest[0];
362
+ const dest = relayBroadcastSkill(process.cwd(), filePath);
363
+ if (!dest) process.exit(1);
364
+ const { GitSync } = await import('../lib/sync.mjs');
365
+ const sync = new GitSync();
366
+ let release = () => {};
367
+ try {
368
+ release = sync.lock(process.cwd());
369
+ } catch (e) {
370
+ if (e.message === 'LOCKED') {
371
+ console.error('relay: sync locked by another process');
372
+ process.exit(2);
373
+ }
374
+ throw e;
375
+ }
376
+ try {
377
+ sync.push(process.cwd(), 'broadcast');
378
+ console.log('Pushed to remote.');
379
+ } catch (e) {
380
+ console.error(`relay: push failed: ${e.message}`);
381
+ process.exit(1);
382
+ } finally {
383
+ release();
384
+ }
385
+ } else if (command === 'install' || command === 'update' || command === 'uninstall' || command === 'doctor') {
386
+ const { main: installerMain } = await import('../scripts/installer.mjs');
387
+ await installerMain([command, ...rest]);
388
+ } else if (command === undefined) {
389
+ console.log(HELP_TEXT);
390
+ } else {
391
+ console.error(`relay: unknown command '${command}'\n\n${HELP_TEXT}`);
392
+ process.exit(1);
393
+ }
394
+ }
@@ -0,0 +1,28 @@
1
+ description = "Write a handoff note to .relay/memory.md and push to teammates"
2
+ prompt = """
3
+ Help the user write a Relay handoff note so teammates pick up where they left off.
4
+
5
+ Steps:
6
+ 1. Ask the user: "What should teammates know when they pick this up? (Press Enter to write a timestamp-only note.)"
7
+ 2. Read the current contents of .relay/memory.md.
8
+ 3. Get the current UTC time by running: node -e "console.log(new Date().toISOString())" — use the output as the timestamp.
9
+ 4. Build the handoff entry:
10
+ - If the user provided text: "- [<timestamp>] <user text>"
11
+ - If the user pressed Enter / provided nothing: "- [<timestamp>] (session handoff — no note)"
12
+ 5. If a "## Handoff notes" section already exists in memory.md, prepend the new entry as the first bullet under that heading (newest-first order).
13
+ If no such section exists, prepend the following to the top of the file:
14
+ "## Handoff notes\n<entry>\n\n"
15
+ 6. Write atomically: write the full updated content to .relay/memory.md.tmp, then rename it to .relay/memory.md.
16
+ 7. Run: node bin/relay.mjs distill --push
17
+ If that errors with "No transcript found", fall back to these steps (handle conflicts):
18
+ a. git add .relay/memory.md
19
+ b. git commit -m "[relay] handoff note"
20
+ c. git push
21
+ d. If git push fails with "non-fast-forward" or "rejected":
22
+ git fetch && git reset --mixed FETCH_HEAD
23
+ git add .relay/memory.md
24
+ git commit -m "[relay] handoff note"
25
+ git push
26
+ e. If push still fails, tell the user: "Push failed — remote has diverged. Run 'relay status' to diagnose, then re-run /relay-handoff."
27
+ 8. Confirm to the user: "Handoff note written and pushed. Teammates will see it at their next SessionStart."
28
+ """
package/distiller.mjs ADDED
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import url from 'node:url';
5
+ import { spawn } from 'node:child_process';
6
+ import {
7
+ readTranscriptLines,
8
+ sliceSinceUuid,
9
+ renderForDistiller,
10
+ lastUuid,
11
+ } from './lib/transcript.mjs';
12
+ import { readMemory, writeMemoryAtomic } from './lib/memory.mjs';
13
+ import { hasTier0Signal } from './lib/filter.mjs';
14
+ import { GitSync } from './lib/sync.mjs';
15
+
16
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
17
+ const CLAUDE_TIMEOUT_MS = 120_000;
18
+
19
+ function parseArgs(argv) {
20
+ const out = {};
21
+ for (let i = 2; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ if (!a.startsWith('--')) continue;
24
+ const key = a.slice(2);
25
+ const val = argv[i + 1];
26
+ if (!val || val.startsWith('--')) {
27
+ out[key] = true;
28
+ } else {
29
+ out[key] = val;
30
+ i++;
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+
36
+ function usage() {
37
+ console.error(
38
+ `Usage: distiller.mjs --transcript <path> --memory <path> --out <path>` +
39
+ ` [--since <uuid>] [--cwd <dir>] [--limit <n>] [--model <id>] [--dry-run]`
40
+ );
41
+ process.exit(2);
42
+ }
43
+
44
+ function buildPrompt({ systemPrompt, sessionId, existingMemory, transcriptSlice }) {
45
+ return (
46
+ systemPrompt +
47
+ `\n\n<session-id>${sessionId || 'unknown'}</session-id>\n\n` +
48
+ `<existing-memory>\n${existingMemory || '(empty — first distillation)'}\n</existing-memory>\n\n` +
49
+ `<transcript-slice>\n${transcriptSlice}\n</transcript-slice>\n`
50
+ );
51
+ }
52
+
53
+ function runClaude(prompt, model) {
54
+ return new Promise((resolve, reject) => {
55
+ const claudeArgs = [
56
+ '-p',
57
+ '--bare',
58
+ '--no-session-persistence',
59
+ '--tools', '', // empty string = no tools; --bare doesn't disable tools
60
+ '--output-format', 'text',
61
+ '--max-budget-usd', '1.00',
62
+ ];
63
+ if (model) claudeArgs.push('--model', model);
64
+
65
+ // Windows: use cmd /c to invoke claude without shell:true (avoids injection surface)
66
+ const [exe, args] = process.platform === 'win32'
67
+ ? ['cmd', ['/c', 'claude', ...claudeArgs]]
68
+ : ['claude', claudeArgs];
69
+
70
+ let proc;
71
+ try {
72
+ proc = spawn(exe, args, {
73
+ windowsHide: true,
74
+ stdio: ['pipe', 'pipe', 'pipe'],
75
+ });
76
+ } catch (e) {
77
+ return reject(new Error(`failed to spawn claude: ${e.message}. Is claude CLI on PATH?`));
78
+ }
79
+
80
+ let stdout = '';
81
+ let stderr = '';
82
+ let timedOut = false;
83
+
84
+ const timer = setTimeout(() => {
85
+ timedOut = true;
86
+ proc.kill();
87
+ reject(new Error(`claude subprocess timed out after ${CLAUDE_TIMEOUT_MS}ms`));
88
+ }, CLAUDE_TIMEOUT_MS);
89
+
90
+ proc.stdout.on('data', (c) => (stdout += c.toString()));
91
+ proc.stderr.on('data', (c) => (stderr += c.toString()));
92
+ proc.on('error', (e) => {
93
+ clearTimeout(timer);
94
+ reject(new Error(`claude spawn error: ${e.message}. Is claude CLI on PATH?`));
95
+ });
96
+ proc.on('close', (code) => {
97
+ clearTimeout(timer);
98
+ if (timedOut) return;
99
+ if (code !== 0) {
100
+ reject(new Error(`claude exited with code ${code}\n${stderr}`));
101
+ } else {
102
+ resolve(stdout);
103
+ }
104
+ });
105
+
106
+ proc.stdin.end(prompt, 'utf8');
107
+ });
108
+ }
109
+
110
+ // Intentionally not imported from stop.mjs — distiller runs detached and must be self-contained.
111
+ function writeWatermark(cwd, uuid, { clearRunning = false, transcript = '' } = {}) {
112
+ if (!cwd) return;
113
+ const statePath = path.join(cwd, '.relay', 'state', 'watermark.json');
114
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
115
+ let state = {};
116
+ try { state = JSON.parse(fs.readFileSync(statePath, 'utf8')); } catch {}
117
+ if (uuid) {
118
+ state.last_uuid = uuid;
119
+ state.last_distilled_at = Date.now();
120
+ }
121
+ if (transcript) state.last_transcript = transcript;
122
+ if (clearRunning) {
123
+ delete state.distiller_running;
124
+ delete state.distiller_pid; // clear stale PID alongside the flag
125
+ }
126
+ const tmp = `${statePath}.${process.pid}.${Date.now()}.tmp`;
127
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
128
+ // Retry loop matches writeWatermarkAtomic in stop.mjs — EPERM/EBUSY transient on Windows
129
+ let lastErr;
130
+ for (let i = 0; i < 3; i++) {
131
+ try { fs.renameSync(tmp, statePath); return; } catch (e) {
132
+ lastErr = e;
133
+ if (e.code !== 'EPERM' && e.code !== 'EBUSY') break;
134
+ }
135
+ }
136
+ try { fs.unlinkSync(tmp); } catch {}
137
+ throw lastErr;
138
+ }
139
+
140
+ async function main() {
141
+ const args = parseArgs(process.argv);
142
+ if (!args.transcript || !args.memory || !args.out) usage();
143
+
144
+ const transcriptPath = args.transcript;
145
+ const memoryPath = args.memory;
146
+ const outPath = args.out;
147
+ const since = args.since && args.since !== 'true' ? args.since : '';
148
+ const cwd = args.cwd && args.cwd !== 'true' ? args.cwd : '';
149
+ const model = args.model && args.model !== 'true' ? args.model : 'claude-haiku-4-5-20251001';
150
+ const dryRun = !!args['dry-run'];
151
+ const force = !!args['force'];
152
+
153
+ // --limit applies to raw lines before --since slice; useful for A/B testing only.
154
+ // Chunk 3 wiring uses --since and omits --limit.
155
+ const limit = args.limit && args.limit !== 'true' ? parseInt(args.limit, 10) : 0;
156
+
157
+ if (!fs.existsSync(transcriptPath)) {
158
+ console.error(`transcript not found: ${transcriptPath}`);
159
+ process.exit(1);
160
+ }
161
+
162
+ const promptPath = path.join(__dirname, 'prompts', 'distill.md');
163
+ const systemPrompt = fs.readFileSync(promptPath, 'utf8');
164
+
165
+ const lines = await readTranscriptLines(transcriptPath);
166
+ const limited = limit > 0 ? lines.slice(0, limit) : lines;
167
+ const sliced = sliceSinceUuid(limited, since);
168
+ const transcriptSlice = renderForDistiller(sliced);
169
+
170
+ if (!transcriptSlice.trim()) {
171
+ console.error('empty transcript slice; nothing to distill');
172
+ // Write watermark to end of limited lines so next run advances correctly
173
+ const endUuid = lastUuid(limited) || lastUuid(lines);
174
+ if (endUuid) writeWatermark(cwd, endUuid, { clearRunning: true });
175
+ process.exit(0);
176
+ }
177
+
178
+ const existingMemory = readMemory(memoryPath);
179
+
180
+ // Use session id from transcript; fall back to filename-derived id to avoid
181
+ // empty provenance tags ([session , turn n]) in distilled memory entries.
182
+ const rawSessionId = (lines.find((l) => l && l.sessionId) || {}).sessionId || '';
183
+ const sessionId = rawSessionId.slice(0, 8) ||
184
+ path.basename(transcriptPath, '.jsonl').slice(0, 8);
185
+
186
+ const fullPrompt = buildPrompt({
187
+ systemPrompt,
188
+ sessionId,
189
+ existingMemory,
190
+ transcriptSlice,
191
+ });
192
+
193
+ if (dryRun) {
194
+ process.stdout.write(fullPrompt);
195
+ return;
196
+ }
197
+
198
+ // Tier 0: skip API call if slice has no actionable signal (bypass with --force)
199
+ if (!force && !hasTier0Signal(transcriptSlice, sliced)) {
200
+ console.error('distiller: low-signal session; skipping distillation');
201
+ const endUuid = lastUuid(sliced) || lastUuid(limited) || lastUuid(lines);
202
+ writeWatermark(cwd, endUuid, { clearRunning: true });
203
+ process.exit(0);
204
+ }
205
+
206
+ console.error(
207
+ `distiller: transcript=${transcriptPath} lines=${lines.length}` +
208
+ ` slice_chars=${transcriptSlice.length} memory_chars=${existingMemory.length} model=${model}`
209
+ );
210
+
211
+ try {
212
+ const newMemory = await runClaude(fullPrompt, model);
213
+ const cleaned = newMemory.trim() + '\n';
214
+ writeMemoryAtomic(outPath, cleaned);
215
+
216
+ const newLastUuid = lastUuid(sliced) || lastUuid(limited) || lastUuid(lines);
217
+ writeWatermark(cwd, newLastUuid, { clearRunning: true, transcript: transcriptPath });
218
+
219
+ console.error(
220
+ `distiller: wrote ${outPath} (${cleaned.length} chars). last_uuid=${newLastUuid}`
221
+ );
222
+
223
+ // Sync push (best-effort — failure is logged but never throws)
224
+ if (cwd) {
225
+ const sync = new GitSync();
226
+ let release = () => {};
227
+ let locked = false;
228
+ try {
229
+ release = sync.lock(cwd);
230
+ } catch (e) {
231
+ locked = true; // any lock failure → skip push (conservative)
232
+ const reason = e.message === 'LOCKED' ? 'locked by another process' : `lock error: ${e.message}`;
233
+ console.error(`distiller: ${reason}, skipping push`);
234
+ }
235
+ if (!locked) {
236
+ try {
237
+ sync.push(cwd, sessionId);
238
+ } catch (e) {
239
+ console.error(`distiller: sync.push error: ${e.message}`);
240
+ } finally {
241
+ release();
242
+ }
243
+ }
244
+ }
245
+ } catch (e) {
246
+ try { writeWatermark(cwd, null, { clearRunning: true }); } catch {}
247
+ throw e;
248
+ }
249
+ }
250
+
251
+ main().catch((e) => {
252
+ console.error(`distiller error: ${e && e.stack ? e.stack : e}`);
253
+ process.exit(1);
254
+ });