@diffin/cli 0.0.0-stage → 0.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.
@@ -0,0 +1,170 @@
1
+ // Hermes adapter. Same contract as Claude Code — detect / enumerate / tsOf — so
2
+ // backfill.mjs treats it identically. The one structural difference: Hermes has no
3
+ // session files. Sessions live in a SQLite DB (~/.hermes/state.db), so each entry's
4
+ // rows() thunk queries the DB and yields CC-shaped rows straight from memory. Nothing
5
+ // touches disk on the way: an unscrubbed copy of a session never sits in a temp dir.
6
+ //
7
+ // node:sqlite is built in (Node ≥ 22.5), so this stays dependency-free.
8
+ import { existsSync } from 'node:fs';
9
+ import { projectDirOf } from './project-dir.mjs';
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ const DB = join(process.env.DIFFIN_HOME ?? homedir(), '.hermes', 'state.db');
14
+ const MIN_SCHEMA = 20; // schema is cumulative; anything at/after 20 is readable
15
+
16
+ let _sqlite = null;
17
+ function sqlite() {
18
+ if (_sqlite) return _sqlite;
19
+ // node:sqlite prints an ExperimentalWarning — our implementation detail, nothing the
20
+ // user can act on. It fires asynchronously, after the DB is opened, and Node's own
21
+ // default printer emits it, so swapping the 'warning' listener does not stop it.
22
+ // Intercept at the source: drop this one warning in process.emitWarning, pass all
23
+ // others through untouched.
24
+ const orig = process.emitWarning.bind(process);
25
+ process.emitWarning = (warning, ...rest) => {
26
+ const type = rest[0]?.type ?? rest[0];
27
+ const msg = typeof warning === 'string' ? warning : warning?.message ?? '';
28
+ if ((type === 'ExperimentalWarning' || warning?.name === 'ExperimentalWarning') && /SQLite/i.test(msg)) return;
29
+ return orig(warning, ...rest);
30
+ };
31
+ _sqlite = process.getBuiltinModule ? process.getBuiltinModule('node:sqlite') : null;
32
+ if (!_sqlite) throw new Error('node:sqlite unavailable (node < 22.5)');
33
+ return _sqlite;
34
+ }
35
+
36
+ function open() {
37
+ const { DatabaseSync } = sqlite();
38
+ return new DatabaseSync(DB, { readOnly: true });
39
+ }
40
+
41
+ export const hermes = {
42
+ name: 'hermes',
43
+
44
+ detect() {
45
+ if (!existsSync(DB)) return null;
46
+ let d;
47
+ try { d = open(); } catch { return null; }
48
+ try {
49
+ const schema = d.prepare('SELECT MAX(version) v FROM schema_version').get()?.v
50
+ ?? d.prepare("SELECT value FROM state_meta WHERE key='schema_version'").get()?.value ?? null;
51
+ if (schema == null || Number(schema) < MIN_SCHEMA) return { present: false, note: '지원 예정(스키마)' };
52
+ const row = d.prepare("SELECT COUNT(*) n FROM sessions WHERE source='cli' AND cwd IS NOT NULL").get();
53
+ return { present: (row?.n ?? 0) > 0, sessions: row?.n ?? 0 };
54
+ } catch { return null; }
55
+ finally { try { d.close(); } catch { /* closing */ } }
56
+ },
57
+
58
+ // List in-window sessions. mtime can't prefilter (no files), so filter on the
59
+ // session's last activity; the per-row cut in stream.mjs keeps only in-window turns
60
+ // like everywhere else. Each entry's rows() reads that one session when called.
61
+ enumerate({ from }) {
62
+ if (!existsSync(DB)) return [];
63
+ const fromSec = new Date(from).getTime() / 1000;
64
+ let d;
65
+ try { d = open(); } catch { return []; }
66
+ const out = [];
67
+ try {
68
+ const sessions = d.prepare(
69
+ "SELECT id, cwd, model, last_activity_at FROM sessions WHERE source='cli' AND cwd IS NOT NULL",
70
+ ).all();
71
+ for (const s of sessions) {
72
+ if ((s.last_activity_at ?? 0) < fromSec) continue;
73
+ const meta = { id: String(s.id), cwd: String(s.cwd), model: s.model };
74
+ out.push({
75
+ sid: 'hm-' + meta.id,
76
+ projectDir: projectDirOf(meta.cwd),
77
+ files: [{ path: 'main', rows: () => sessionRows(meta) }],
78
+ last: Number(s.last_activity_at ?? 0) * 1000,
79
+ cwd: meta.cwd,
80
+ });
81
+ }
82
+ } catch { /* read failure must not crash the whole backfill */ }
83
+ finally { try { d.close(); } catch { /* closing */ } }
84
+ return out;
85
+ },
86
+
87
+ tsOf(r) { return r?.timestamp ? new Date(r.timestamp).getTime() : 0; },
88
+
89
+ /**
90
+ * The session a capture is for: the one Hermes named in HERMES_SESSION_ID (it sets
91
+ * that for every terminal command), else the most recently active CLI session in
92
+ * this folder. Gateway sessions have no cwd and are not captured, as in backfill.
93
+ */
94
+ currentSession(cwd = process.cwd(), env = process.env) {
95
+ if (!existsSync(DB)) return null;
96
+ let d;
97
+ try { d = open(); } catch { return null; }
98
+ try {
99
+ const named = env.HERMES_SESSION_ID;
100
+ if (named) {
101
+ const s = d.prepare('SELECT id, cwd FROM sessions WHERE id=?').get(String(named));
102
+ if (s?.cwd) return { sid: 'hm-' + String(s.id), id: String(s.id), cwd: String(s.cwd), how: 'env' };
103
+ }
104
+ const s = d.prepare(
105
+ "SELECT id, cwd FROM sessions WHERE source='cli' AND cwd=? ORDER BY COALESCE(last_activity_at, started_at) DESC LIMIT 1",
106
+ ).get(String(cwd));
107
+ return s ? { sid: 'hm-' + String(s.id), id: String(s.id), cwd: String(s.cwd), how: 'newest' } : null;
108
+ } catch { return null; }
109
+ finally { try { d.close(); } catch { /* closing */ } }
110
+ },
111
+ };
112
+
113
+ // One Hermes session → CC-shaped rows: user turns and assistant turns (text + tool_use
114
+ // blocks), each stamped with a stable hm: uuid so refs land. Compaction re-injections
115
+ // and pure context blocks are dropped — they are not real utterances. Opens its own
116
+ // handle so the enumerate-time connection need not stay open across the upload.
117
+ async function* sessionRows(s) {
118
+ let d;
119
+ try { d = open(); } catch { return; }
120
+ let msgs;
121
+ try {
122
+ msgs = d.prepare(
123
+ 'SELECT id, role, content, tool_calls, timestamp FROM messages WHERE session_id=? ORDER BY id',
124
+ ).all(s.id);
125
+ } catch { return; }
126
+ finally { try { d.close(); } catch { /* closing */ } }
127
+
128
+ const seen = new Set(); // compaction re-inserts copies — dedup on content (>16 chars)
129
+ for (const m of msgs) {
130
+ const content = String(m.content ?? '');
131
+ if (content.length > 16) { if (seen.has(content)) continue; seen.add(content); }
132
+ const ts = new Date(Number(m.timestamp) * 1000).toISOString();
133
+ const uuid = 'hm:' + String(m.id);
134
+ if (m.role === 'user') {
135
+ yield { type: 'user', uuid, timestamp: ts, cwd: s.cwd, message: { content } };
136
+ } else if (m.role === 'assistant') {
137
+ if (!m.tool_calls && (content.startsWith('[PRIOR CONTEXT') || content.startsWith('[CONTEXT COMPACTION'))) continue;
138
+ const blocks = [];
139
+ if (m.content) blocks.push({ type: 'text', text: content });
140
+ if (m.tool_calls) {
141
+ try {
142
+ for (const tc of JSON.parse(m.tool_calls)) {
143
+ const t = mapTool(tc);
144
+ if (t.name) blocks.push({ type: 'tool_use', name: t.name, input: t.input, id: String(tc.id ?? tc.call_id ?? '') });
145
+ }
146
+ } catch { /* malformed tool_calls — skip the block */ }
147
+ }
148
+ yield { type: 'assistant', uuid, timestamp: ts, message: { content: blocks, model: s.model } };
149
+ }
150
+ // role='tool' dropped — the marker is the assistant's tool_use, outputs aren't shipped
151
+ }
152
+ }
153
+
154
+ // Hermes tool names → the Claude Code marker names the extractor understands. A tool
155
+ // with no CC equivalent (read_file, skill_*, browser_*) is dropped — it carries no
156
+ // signal the did needs.
157
+ function mapTool(tc) {
158
+ const fn = tc.function ?? {};
159
+ let args = {};
160
+ try { args = JSON.parse(fn.arguments ?? '{}'); } catch { /* keep {} */ }
161
+ switch (fn.name) {
162
+ case 'terminal': case 'process': return { name: 'Bash', input: { command: String(args.command ?? '') } };
163
+ case 'write_file': return { name: 'Write', input: { file_path: String(args.path ?? '') } };
164
+ case 'patch': return { name: 'Edit', input: { file_path: String(args.path ?? '') } };
165
+ case 'web_extract': return { name: 'WebFetch', input: { url: String((args.urls ?? [])[0] ?? '') } };
166
+ case 'web_search': case 'session_search': return { name: 'WebSearch', input: { query: String(args.query ?? '') } };
167
+ case 'delegate_task': return { name: 'Task', input: { description: String(args.goal ?? '') } };
168
+ default: return { name: null };
169
+ }
170
+ }
@@ -0,0 +1,29 @@
1
+ // The agent-source registry. backfill.mjs and capture.mjs iterate whatever registers here and never
2
+ // branch on which source it is — another agent is one new file and one line below. Each adapter answers:
3
+ // name → the source key the server's watermark and the run use (agents.mjs `source`)
4
+ // detect() → { present, sessions } | null
5
+ // enumerate({from}) → [{ sid, projectDir, files: [{ path, rows() }], last, cwd?, lastCwd?() }]
6
+ // sessions that moved since `from`; each file's rows() is a thunk
7
+ // returning an (async) iterable of Claude Code-shaped row objects —
8
+ // nothing is read until the upload streams it
9
+ // tsOf(row) → ms epoch of that row, the key the range cut uses
10
+ // currentSession(cwd, env) → { sid, cwd?, … } | null — the session a one-session capture is for
11
+ import { claudeCode } from './claude-code.mjs';
12
+ import { hermes } from './hermes.mjs';
13
+ import { codex } from './codex.mjs';
14
+ import { antigravity } from './antigravity.mjs';
15
+
16
+ // Order is display order only.
17
+ const ADAPTERS = [claudeCode, hermes, codex, antigravity];
18
+
19
+ export function detectSources() {
20
+ return ADAPTERS.map((a) => ({ name: a.name, ...(a.detect() ?? { present: false }) }))
21
+ .filter((s) => s.present);
22
+ }
23
+
24
+ /** every adapter — a capture reads all of them, whichever agent asked */
25
+ export const allAdapters = () => ADAPTERS;
26
+
27
+ export function adapter(name) {
28
+ return ADAPTERS.find((a) => a.name === name) ?? null;
29
+ }
@@ -0,0 +1,13 @@
1
+ // The name a session's files go up under, for an agent that keeps no project folder of its own (Hermes, Codex,
2
+ // Antigravity): the folder the session began in, encoded the way ~/.claude/projects encodes one — separators and a
3
+ // Windows drive's colon become '-'. No source prefix: run directories are already per source, and a prefix would
4
+ // glue a word character in front of "-Users-", which is exactly what the home-path scrub rule's lookbehind refuses —
5
+ // the username would leak.
6
+ //
7
+ // The server takes a name of 1–200 characters that does not start with a dot (backfill.controller.ts PROJECT_DIR)
8
+ // and refuses the upload otherwise, so a session with no folder on record, or one under a very long path, is given a
9
+ // name it accepts. Nothing reads the name back as a path: a record's folder comes from each row's own `cwd`.
10
+ export function projectDirOf(cwd) {
11
+ const name = String(cwd ?? '').replace(/[\\/:]/g, '-').replace(/^\.+/, '').slice(0, 200);
12
+ return name || 'unknown';
13
+ }
package/lib/state.mjs ADDED
@@ -0,0 +1,113 @@
1
+ // What this machine remembers between commands (~/.diffin/state.json, 0600): whether recording is paused, and when
2
+ // the server said the next capture may go up.
3
+ //
4
+ // That second fact is the whole link between the server's limit and the agent. The agent decides WHEN to record from
5
+ // the skill alone — no hook tells it, because a hook is something one agent has and the next does not — and it cannot
6
+ // know when the person last recorded: the capture may have gone up from another window, another folder, or a
7
+ // conversation since cleared. So the knowledge lives HERE, not in the agent's context: `diffin capture` reads it
8
+ // before it asks the server anything, and an early call ends on this machine in one line, with no request.
9
+ //
10
+ // The time is the SERVER's (`nextCaptureAt` on a completed capture, the seconds left on a refusal). This package holds
11
+ // no gap of its own: a number copied here would be wrong the day the server's moved, until every machine updated.
12
+ import { randomBytes } from 'node:crypto';
13
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ const HOME = () => process.env.DIFFIN_HOME ?? homedir();
18
+ const STATE = () => join(HOME(), '.diffin', 'state.json');
19
+
20
+ export function readState() {
21
+ try { return JSON.parse(readFileSync(STATE(), 'utf8')); } catch { return {}; }
22
+ }
23
+ export function writeState(s) {
24
+ try { mkdirSync(join(HOME(), '.diffin'), { recursive: true, mode: 0o700 }); writeFileSync(STATE(), JSON.stringify(s, null, 2) + '\n', { mode: 0o600 }); } catch { /* remembering is not worth an error */ }
25
+ }
26
+
27
+ /**
28
+ * `diffin pause` … `diffin resume`: off the record. A pause has no end time — it lasts until the person turns
29
+ * recording back on — and what was said in between is not sent, then or later: the stretches are kept here
30
+ * (`pauses`) and every row stamped inside one is dropped as transcripts are read (stream.mjs).
31
+ *
32
+ * It used to be a timer on the upload ("2h") and nothing more: the conversation of a paused stretch sat in the
33
+ * transcript and went up with the first capture after it, which is the opposite of what a person who says
34
+ * "don't record this" expects.
35
+ */
36
+ export function pause(at = new Date()) {
37
+ const s = readState();
38
+ if (!s.pausedSince) s.pausedSince = new Date(at).toISOString();
39
+ writeState(s);
40
+ return new Date(s.pausedSince);
41
+ }
42
+ export function resume(at = new Date()) {
43
+ const s = readState();
44
+ if (!s.pausedSince) return null;
45
+ const from = s.pausedSince;
46
+ s.pauses = [...(Array.isArray(s.pauses) ? s.pauses : []), { from, to: new Date(at).toISOString() }];
47
+ delete s.pausedSince;
48
+ writeState(s);
49
+ return new Date(from);
50
+ }
51
+ export function pausedSince() {
52
+ const u = readState().pausedSince; return u ? new Date(u) : null;
53
+ }
54
+ /** whether a moment falls in a stretch the person had recording off — one that ended, or the one still open */
55
+ export function offTheRecord() {
56
+ const s = readState();
57
+ const spans = (Array.isArray(s.pauses) ? s.pauses : []).map((p) => [Date.parse(p.from), Date.parse(p.to)]).filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b));
58
+ const open = s.pausedSince ? Date.parse(s.pausedSince) : NaN;
59
+ if (!spans.length && !Number.isFinite(open)) return null;
60
+ return (ms) => Number.isFinite(ms) && ms > 0 && ((Number.isFinite(open) && ms >= open) || spans.some(([a, b]) => ms >= a && ms < b));
61
+ }
62
+
63
+ /** what the server said: the next capture may go up at `at` (null forgets it — another account, or the server's word changed) */
64
+ export function setNextCapture(at) {
65
+ const s = readState();
66
+ const t = at ? new Date(at).getTime() : NaN;
67
+ if (Number.isFinite(t)) s.nextCaptureAt = new Date(t).toISOString(); else delete s.nextCaptureAt;
68
+ writeState(s);
69
+ }
70
+ /** the time before which a capture would be refused, or null when one may go up now as far as this machine knows */
71
+ export function nextCaptureAt(now = Date.now()) {
72
+ const u = readState().nextCaptureAt; if (!u) return null;
73
+ const t = Date.parse(u); return t > now ? new Date(t) : null;
74
+ }
75
+ /** a capture went up from this machine — what `diffin status` shows */
76
+ export function markSent(at = new Date()) {
77
+ const s = readState(); s.lastCaptureAt = new Date(at).toISOString(); writeState(s);
78
+ }
79
+ /** a different account is signing in or this one is leaving: what was remembered was about someone else */
80
+ /**
81
+ * This computer's own id for diffin: random, made the first time it is asked for, kept in state.json. It is not a
82
+ * hardware id and says nothing about the machine; it only lets the server tell two of the person's computers apart —
83
+ * a backfill is once per source on EACH device, and an open run is handed back only to the device that opened it.
84
+ * It outlives a change of account (`forgetAccount` keeps it): it names the computer, not the person.
85
+ */
86
+ export function deviceId() {
87
+ const s = readState();
88
+ if (typeof s.device === 'string' && /^[A-Za-z0-9_-]{8,64}$/.test(s.device)) return s.device;
89
+ s.device = `d-${randomBytes(12).toString('hex')}`;
90
+ writeState(s);
91
+ return s.device;
92
+ }
93
+
94
+ /** whether this computer was ever asked which folders not to record — a backfill's list, or the one a later connect shows */
95
+ export const foldersAsked = () => readState().foldersAsked === true;
96
+ export function markFoldersAsked() { const s = readState(); s.foldersAsked = true; writeState(s); }
97
+
98
+ export function forgetAccount() {
99
+ const s = readState(); delete s.nextCaptureAt; delete s.lastCaptureAt; delete s.sessions; writeState(s);
100
+ }
101
+
102
+ /**
103
+ * Can this process write where diffin keeps what it remembers? Under Codex it is the one thing that tells a command
104
+ * its rules allow from one they do not: a command Codex runs sits in a sandbox that refuses a write outside the
105
+ * workspace and has no network, and an allowed one has both (measured 2026-10-04 with a probe: `write: ok` and
106
+ * `net: 200` against `Operation not permitted` and no network). Codex's environment variables do NOT tell — the first
107
+ * cut read `CODEX_SANDBOX*` as "inside", on a list of variables that turned out to be the last three lines of a
108
+ * longer one, and refused a capture on a guess.
109
+ */
110
+ export function canWriteState() {
111
+ const p = join(HOME(), '.diffin', `.probe-${process.pid}`);
112
+ try { mkdirSync(join(HOME(), '.diffin'), { recursive: true, mode: 0o700 }); writeFileSync(p, ''); rmSync(p, { force: true }); return true; } catch { return false; }
113
+ }
package/lib/status.mjs ADDED
@@ -0,0 +1,71 @@
1
+ // `diffin status` — what this machine is set up to do, read from this machine alone:
2
+ // which version, which account and server, which agents carry the skill,
3
+ // whether recording is paused, and when the next capture may go up. No request is made:
4
+ // status is what a person runs when something is wrong, which is when the network may be
5
+ // what is wrong. So it says "로그인됨" about the credential it holds, not about what the
6
+ // server would answer — a capture is what finds out.
7
+ //
8
+ import { readFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { SERVER } from './net.mjs';
12
+ import { readCreds } from './creds.mjs';
13
+ import { agentMatrix } from './agents.mjs';
14
+ import { excludedFolders, excludeFolders, ignoreFileFor, ignoredBy, includeFolder } from './ignore.mjs';
15
+ import { posixPath, scrub } from './scrub.mjs';
16
+ import { resolve as resolvePath } from 'node:path';
17
+ import { bundledVersion, installedVersion } from './init.mjs';
18
+ import { nextCaptureAt, pausedSince, readState } from './state.mjs';
19
+ import { banner, dim, ok, out, stepDone, stepInfo, stepSkip, stepWarn, when } from './ui.mjs';
20
+
21
+
22
+ /** the package's own version, from the package.json that ships beside the code */
23
+ export function version() {
24
+ try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version ?? '?'; } catch { return '?'; }
25
+ }
26
+
27
+ export function status() {
28
+ banner(version());
29
+ const c = readCreds();
30
+ if (!c?.token) stepWarn('계정', '연결되지 않음', dim('앱의 연결 화면에 있는 명령을 실행하세요'));
31
+ else stepDone('계정', c.email ?? '(이메일 없음)', dim(process.env.DIFFIN_TOKEN ? '환경 변수의 토큰' : '연결됨'));
32
+ const want = bundledVersion();
33
+ for (const m of agentMatrix()) {
34
+ if (!m.present) continue;
35
+ const have = installedVersion(m.skillDir);
36
+ if (!have) stepWarn(m.agent.name, '캡처 스킬 없음', dim('diffin init 으로 설치합니다'));
37
+ else if (!m.agent.capture) stepSkip(m.agent.name, `캡처 스킬 ${have}`, dim('이 에이전트의 기록은 아직 지원하지 않습니다'));
38
+ else if (have !== want) stepDone(m.agent.name, `캡처 스킬 ${have} → ${want}`, dim('다음 기록 때 자동 갱신'));
39
+ else stepDone(m.agent.name, `캡처 스킬 ${have}`);
40
+ }
41
+ const st = readState();
42
+ const paused = pausedSince();
43
+ if (paused) stepWarn('자동 기록', `일시정지 중 — ${when(paused)} 부터`, dim('다시 켜려면 diffin resume'));
44
+ else stepDone('자동 기록', '켜짐', dim('멈추려면 diffin pause'));
45
+ const next = nextCaptureAt();
46
+ if (next) stepSkip('다음 기록', `${when(next)} 이후`, dim('그 전의 작업도 그때 함께 올라갑니다'));
47
+ const excl = ignoredBy(process.cwd());
48
+ if (excl) stepWarn('이 폴더', '기록하지 않음', dim(ignoreFileFor(process.cwd()) ? '.diffin-ignore 파일이 있습니다' : '제외한 폴더입니다 · 되돌리려면 diffin include'));
49
+ // facts with no verdict, under the ones that have one
50
+ const off = excludedFolders();
51
+ if (st.lastCaptureAt || off.length) out();
52
+ if (st.lastCaptureAt) stepInfo('마지막 기록', when(st.lastCaptureAt));
53
+ off.forEach((f, k) => stepInfo(k ? '' : '제외한 폴더', posixPath(scrub(f)), k ? '' : dim('되돌리려면 diffin include <폴더>')));
54
+ out();
55
+ stepInfo('서버', dim(SERVER));
56
+ }
57
+
58
+ /** `diffin exclude [폴더]` — this folder and everything under it is not recorded; `diffin include [폴더]` undoes it */
59
+ export function exclude(argv = []) {
60
+ const dir = resolvePath(argv[0] ?? process.cwd());
61
+ excludeFolders([dir]);
62
+ ok(`${posixPath(scrub(dir))} 와 그 아래 폴더는 기록하지 않습니다.`);
63
+ out(dim(' 이미 올라간 기록은 앱에서 지울 수 있습니다. 되돌리려면 diffin include'));
64
+ }
65
+ export function include(argv = []) {
66
+ const dir = resolvePath(argv[0] ?? process.cwd());
67
+ const file = ignoreFileFor(dir);
68
+ if (includeFolder(dir)) ok(`${posixPath(scrub(dir))} 를 다시 기록합니다.`);
69
+ else if (!file) out(` ${dim('○')} 제외한 폴더가 아닙니다.`);
70
+ if (file) out(dim(` ${posixPath(scrub(file))} 에 .diffin-ignore 파일이 있어 계속 제외됩니다. 그 파일을 지우면 기록합니다.`));
71
+ }
package/lib/stream.mjs ADDED
@@ -0,0 +1,67 @@
1
+ // One pass over a session's rows: cut to the window and scrub, holding only a small
2
+ // tail buffer. The predecessor read the whole file and parsed every row into an array
3
+ // — measured 1.7GB resident on a 336MB session, which would OOM the modest laptop this
4
+ // runs on. Here memory is flat regardless of session size.
5
+ //
6
+ // Takes rows, not a path: an adapter that has files streams them with readline; one
7
+ // that has a database yields rows straight from its query. Nothing is written to disk
8
+ // on the way — so no unscrubbed copy of a session ever sits in a temp directory.
9
+ import { cleanRow, queuedTurn } from './scrub.mjs';
10
+ import { ignoredBy } from './ignore.mjs';
11
+ import { offTheRecord } from './state.mjs';
12
+
13
+ const TAIL = 8;
14
+ const TAIL_CHARS = 300;
15
+
16
+ // tsOf(row)→ms decides the cut. Rows at/after `from` and before `to` are kept; rows
17
+ // before `from` feed the context lead-in; rows at/after `to` are simply dropped (an
18
+ // --extend run's upper bound is where existing coverage begins). from=0 keeps all.
19
+ export async function* streamCutScrub(rows, { tsOf, fromMs = 0, toMs = Infinity }) {
20
+ const tail = [];
21
+ let started = false, hidden = false;
22
+ const off = offTheRecord(); // the stretches the person had recording paused
23
+
24
+ for await (const row of rows) {
25
+ // A row made in a folder the person excluded does not leave the machine — whichever session it sits in, and not as
26
+ // the [PRIOR CONTEXT] lead-in either. Every row says where it was made, so the exclusion is exact: a session that
27
+ // passed through an excluded folder loses those turns and keeps the rest.
28
+ if (row?.cwd && ignoredBy(String(row.cwd))) continue;
29
+ const t = tsOf(row);
30
+ // …and a row from a paused stretch does not either: paused means off the record, not sent later. A row with no
31
+ // time goes with the row before it, as it does at the window's edge.
32
+ if (off) { if (t) hidden = off(t); if (hidden) continue; }
33
+ if (t && t >= toMs) continue;
34
+ const inWindow = fromMs === 0 || (t ? t >= fromMs : started);
35
+ if (!inWindow) {
36
+ tail.push(queuedTurn(row) ?? row); // a message typed mid-turn is part of the run-up too
37
+ if (tail.length > TAIL) tail.shift();
38
+ continue;
39
+ }
40
+ if (!started) {
41
+ started = true;
42
+ const ctx = contextRow(tail, row);
43
+ if (ctx) { const c = cleanRow(ctx); if (c != null) yield c; }
44
+ }
45
+ const c = cleanRow(row);
46
+ if (c != null) yield c;
47
+ }
48
+ }
49
+
50
+ // The [PRIOR CONTEXT] lead-in: the dropped tail as plain text with NO id, so MAP has
51
+ // the run-up without those turns being referenceable (a ref would double-own).
52
+ function contextRow(tail, firstKept) {
53
+ if (!tail.length) return null;
54
+ const text = tail.map((r) => {
55
+ const c = r?.message?.content;
56
+ const s = typeof c === 'string'
57
+ ? c
58
+ : Array.isArray(c) ? c.filter((b) => b?.type === 'text').map((b) => b.text).join(' ') : '';
59
+ return s ? `${r.type === 'user' ? 'user' : 'ai'}: ${s.slice(0, TAIL_CHARS)}` : null;
60
+ }).filter(Boolean).join('\n');
61
+ if (!text) return null;
62
+ return {
63
+ type: 'assistant',
64
+ timestamp: firstKept?.timestamp,
65
+ message: { content: [{ type: 'text', text: `[PRIOR CONTEXT — reference only; not new messages]\n${text}` }] },
66
+ };
67
+ }