@diffin/cli 0.1.1 → 0.1.3
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/lib/capture.mjs +19 -15
- package/lib/folders.mjs +4 -4
- package/lib/ignore.mjs +52 -12
- package/lib/stream.mjs +9 -1
- package/package.json +2 -2
- package/templates/skills/capture/SKILL.md +7 -2
package/lib/capture.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { putFile } from './backfill.mjs';
|
|
|
24
24
|
import { machineHash } from './machine.mjs';
|
|
25
25
|
import { detectAgent, agentById } from './agents.mjs';
|
|
26
26
|
import { ensureInit } from './init.mjs';
|
|
27
|
-
import { ALL_LOOKBACK_MS, allowFolders, allowedFolders,
|
|
27
|
+
import { ALL_LOOKBACK_MS, allowFolders, allowedFolders, excludeFolders, excludedFolders, folderCensus, heldBy, heldFolders, ignoredBy, includeFolder, topFolders, withoutIgnored } from './ignore.mjs';
|
|
28
28
|
import { homedir } from 'node:os';
|
|
29
29
|
|
|
30
30
|
const ROOT = join(process.env.DIFFIN_HOME ?? homedir(), '.claude', 'projects');
|
|
@@ -277,8 +277,8 @@ export async function planAll(ad, floors, to, entries) {
|
|
|
277
277
|
* Candidate sessions: moved recently, and not in a folder the person excluded (ignore.mjs — the folder it began in
|
|
278
278
|
* or the one it is in now, either of them under a `.diffin-ignore`).
|
|
279
279
|
*/
|
|
280
|
-
export async function candidates(ad, now = Date.now()
|
|
281
|
-
return withoutIgnored(ad.enumerate({ from: new Date(now - ALL_LOOKBACK_MS).toISOString() })
|
|
280
|
+
export async function candidates(ad, now = Date.now()) {
|
|
281
|
+
return withoutIgnored(ad.enumerate({ from: new Date(now - ALL_LOOKBACK_MS).toISOString() }));
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
/**
|
|
@@ -289,8 +289,10 @@ export async function candidates(ad, now = Date.now(), { hold = false } = {}) {
|
|
|
289
289
|
async function seedAllowed() {
|
|
290
290
|
if (allowedFolders() !== null) return;
|
|
291
291
|
const dirs = [];
|
|
292
|
-
//
|
|
293
|
-
|
|
292
|
+
// every folder a turn of these sessions was typed in — what the capture would have sent before the list existed, now
|
|
293
|
+
// that a turn is held by its own folder (2026-10-12; before, the two ends of each session)
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
for (const ad of allAdapters()) { try { const { kept } = await candidates(ad, now); dirs.push(...(await folderCensus(kept, { tsOf: ad.tsOf, fromMs: now - ALL_LOOKBACK_MS })).keys()); } catch { /* an unreadable agent seeds nothing */ } }
|
|
294
296
|
if (dirs.length) allowFolders(topFolders(dirs));
|
|
295
297
|
}
|
|
296
298
|
|
|
@@ -304,10 +306,10 @@ const unscrub = (p) => (p === '~' ? homedir() : p.startsWith('~/') ? join(homedi
|
|
|
304
306
|
* decision's own time — only the work after it goes up. Nothing here may stop a capture: a server that cannot be
|
|
305
307
|
* reached, or an older server without the route, leaves the lists as they are.
|
|
306
308
|
*/
|
|
307
|
-
async function syncFolders(token,
|
|
309
|
+
async function syncFolders(token, heldDirs) {
|
|
308
310
|
const allowed = allowedFolders();
|
|
309
311
|
if (allowed === null) return { changed: false };
|
|
310
|
-
const held = topFolders(
|
|
312
|
+
const held = topFolders(heldDirs).map((f) => posixPath(scrub(f)));
|
|
311
313
|
let body = null;
|
|
312
314
|
try {
|
|
313
315
|
const res = await fetchRetry(`${SERVER}/folders/held`, () => ({ method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ device: deviceId(), held, allowed: allowed.map((f) => posixPath(scrub(f))), excluded: excludedFolders().map((f) => posixPath(scrub(f))) }) }), 1);
|
|
@@ -328,8 +330,8 @@ async function syncFolders(token, heldAll) {
|
|
|
328
330
|
}
|
|
329
331
|
|
|
330
332
|
/** the one line about folders waiting for the person — the agent relays it, the person acts on a terminal */
|
|
331
|
-
function sayHeld(
|
|
332
|
-
const folders = topFolders(
|
|
333
|
+
function sayHeld(heldDirs) {
|
|
334
|
+
const folders = topFolders(heldDirs);
|
|
333
335
|
if (!folders.length) return;
|
|
334
336
|
say(marks.wait(), `새 폴더 ${folders.length}곳은 아직 기록하지 않습니다 · ${folders.map((f) => posixPath(scrub(f))).join(' · ')}`,
|
|
335
337
|
'허용하려면 터미널에서 diffin allow <폴더> 또는 diffin folders (사람이 직접 실행합니다)');
|
|
@@ -353,13 +355,15 @@ async function captureAll() {
|
|
|
353
355
|
// sent one agent's sessions kept the others' waiting for their own agent to ask after the gap — with three agents
|
|
354
356
|
// on a machine, most of a day's work. The run is the asker's; a session of another agent names its own.
|
|
355
357
|
await seedAllowed();
|
|
358
|
+
// the folders waiting are those of the turns typed in the lookback in a folder not allowed — the whole lookback, not
|
|
359
|
+
// only since the last capture: the server REPLACES its pending list with each report, so a folder reported once and
|
|
360
|
+
// left out of the next would vanish from the app before the person decided on it
|
|
356
361
|
const gather = async () => {
|
|
357
362
|
const found = [], heldAll = [];
|
|
358
363
|
for (const ad of allAdapters()) {
|
|
359
|
-
let kept = []
|
|
360
|
-
try { ({ kept
|
|
364
|
+
let kept = [];
|
|
365
|
+
try { ({ kept } = await candidates(ad, Date.now())); heldAll.push(...(await heldFolders(kept, { tsOf: ad.tsOf, fromMs: Date.now() - ALL_LOOKBACK_MS })).keys()); } catch { /* an agent whose sessions cannot be read holds nobody else back */ }
|
|
361
366
|
for (const e of kept) found.push({ ad, e });
|
|
362
|
-
heldAll.push(...held);
|
|
363
367
|
}
|
|
364
368
|
return { found, heldAll };
|
|
365
369
|
};
|
|
@@ -367,13 +371,13 @@ async function captureAll() {
|
|
|
367
371
|
// the app may have decided on a held folder since the last capture: apply, then look again with the lists as they are now
|
|
368
372
|
const { changed } = await syncFolders(token, heldAll);
|
|
369
373
|
if (changed) ({ found, heldAll } = await gather());
|
|
370
|
-
// a
|
|
371
|
-
|
|
374
|
+
// a folder the person allowed after the fact goes up from that moment — judged per turn as the rows are read
|
|
375
|
+
// (`turnHeld`), so no session-wide `since` is sent any more: one would floor the session's other folders too
|
|
372
376
|
const offered = newestFirst(found);
|
|
373
377
|
if (!offered.length) { say(marks.wait(), '최근 움직인 세션이 없습니다.'); sayHeld(heldAll); return; }
|
|
374
378
|
const res = await fetchRetry(`${SERVER}/backfill/runs`, () => ({
|
|
375
379
|
method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
376
|
-
body: JSON.stringify({ source, device: deviceId(), ...(machineHash() ? { computer: machineHash() } : {}), capture: { all: true, sessions: offered.map(({ ad, e }) => ({ id: e.sid, ...(e.last ? { last: new Date(e.last).toISOString() } : {}), ...(
|
|
380
|
+
body: JSON.stringify({ source, device: deviceId(), ...(machineHash() ? { computer: machineHash() } : {}), capture: { all: true, sessions: offered.map(({ ad, e }) => ({ id: e.sid, ...(e.last ? { last: new Date(e.last).toISOString() } : {}), ...(ad.name !== source ? { source: ad.name } : {}) })) } }),
|
|
377
381
|
}));
|
|
378
382
|
if (!res.ok) { await refused(res); return; }
|
|
379
383
|
const { runId, machine, to, floors, resumed, done } = await res.json();
|
package/lib/folders.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// result line names the held folders and these two commands; the agent relays the line, the person types.
|
|
7
7
|
import { resolve as resolvePath } from 'node:path';
|
|
8
8
|
import { allAdapters } from './sources/index.mjs';
|
|
9
|
-
import { ALL_LOOKBACK_MS, allowFolders, allowedFolders, allowedFrom, excludeFolders, excludedFrom, folderCensus, folderTree, heldBy, ignoreFileFor, includeFolder, topFolders, withoutIgnored } from './ignore.mjs';
|
|
9
|
+
import { ALL_LOOKBACK_MS, allowFolders, allowedFolders, allowedFrom, excludeFolders, excludedFrom, folderCensus, folderTree, heldBy, heldFolders, ignoreFileFor, includeFolder, topFolders, withoutIgnored } from './ignore.mjs';
|
|
10
10
|
import { posixPath, scrub } from './scrub.mjs';
|
|
11
11
|
import { dim, fail, ok, out, selectMany, spin, stepLive } from './ui.mjs';
|
|
12
12
|
|
|
@@ -20,9 +20,9 @@ async function recent() {
|
|
|
20
20
|
const jobs = [], heldDirs = [];
|
|
21
21
|
for (const ad of allAdapters()) {
|
|
22
22
|
try {
|
|
23
|
-
const { kept
|
|
24
|
-
jobs.push({ ad, entries:
|
|
25
|
-
heldDirs.push(...
|
|
23
|
+
const { kept } = await withoutIgnored(ad.enumerate({ from: new Date(fromMs).toISOString() }));
|
|
24
|
+
jobs.push({ ad, entries: kept, cutOpts: { tsOf: ad.tsOf, fromMs, toMs: now } });
|
|
25
|
+
heldDirs.push(...(await heldFolders(kept, { tsOf: ad.tsOf, fromMs })).keys());
|
|
26
26
|
} catch { /* an agent whose sessions cannot be read is left out */ }
|
|
27
27
|
}
|
|
28
28
|
return { jobs, held: topFolders(heldDirs) };
|
package/lib/ignore.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export function topFolders(folders) {
|
|
|
81
81
|
}
|
|
82
82
|
/**
|
|
83
83
|
* Allow folders. `at` is the moment the PERSON allowed them (a terminal command, the app's decision): only the work after
|
|
84
|
-
* it goes up
|
|
84
|
+
* it goes up — each turn made in that folder is judged against it (`turnHeld`). The onboarding list and the seed pass no time — those
|
|
85
85
|
* folders' history is the backfill's and the watermark's, as before.
|
|
86
86
|
*/
|
|
87
87
|
export function allowFolders(folders, at) {
|
|
@@ -149,12 +149,9 @@ export async function folderCensus(entries, { tsOf, fromMs = 0, toMs = Infinity
|
|
|
149
149
|
const turns = new Map();
|
|
150
150
|
for (const e of entries) {
|
|
151
151
|
for await (const r of e.files[0].rows()) {
|
|
152
|
-
if (r
|
|
152
|
+
if (!isSaid(r)) continue;
|
|
153
153
|
const t = tsOf(r);
|
|
154
154
|
if (t && (t < fromMs || t >= toMs)) continue;
|
|
155
|
-
const c = r.message?.content;
|
|
156
|
-
const said = typeof c === 'string' ? c.trim() : Array.isArray(c) && !c.some((b) => b?.type === 'tool_result') && c.some((b) => b?.type === 'text' && String(b.text ?? '').trim());
|
|
157
|
-
if (!said) continue;
|
|
158
155
|
const dir = String(r.cwd ?? e.cwd ?? '');
|
|
159
156
|
if (!dir || ignoredBy(dir)) continue;
|
|
160
157
|
turns.set(dir, (turns.get(dir) ?? 0) + 1);
|
|
@@ -190,19 +187,62 @@ export function excludedFrom(tree, on) {
|
|
|
190
187
|
}
|
|
191
188
|
|
|
192
189
|
/**
|
|
193
|
-
* Split a list of sessions into the ones that may be sent
|
|
194
|
-
* the
|
|
195
|
-
*
|
|
190
|
+
* Split a list of sessions into the ones that may be sent and how many a `.diffin-ignore` or an exclusion held back
|
|
191
|
+
* (by the folder a session began in OR is in now — an exclusion stays session-wide, the stricter reading).
|
|
192
|
+
*
|
|
193
|
+
* Waiting for an allowance is NOT decided here any more (2026-10-12): it is decided per TURN, by the folder the person
|
|
194
|
+
* spoke in (`turnHeld`, applied as rows are read in stream.mjs). Judged per session, a session that began in a folder
|
|
195
|
+
* since renamed was held for good (the front session: 183 turns under the old name, 722 under the allowed one), and a
|
|
196
|
+
* session that began and ended in an allowed folder sent the turns it made in another project in between (miner-server:
|
|
197
|
+
* 281 turns in two other apps; this repository's oldest session: 74 in two folders never allowed).
|
|
196
198
|
*/
|
|
197
|
-
export async function withoutIgnored(entries
|
|
198
|
-
const kept = []
|
|
199
|
+
export async function withoutIgnored(entries) {
|
|
200
|
+
const kept = [];
|
|
199
201
|
let ignored = 0;
|
|
200
202
|
for (const e of entries) {
|
|
201
203
|
const f = await sessionFolders(e);
|
|
202
204
|
if (f.ignored) { ignored++; continue; }
|
|
203
205
|
const cwd = f.last ?? f.first ?? e.cwd;
|
|
204
|
-
if (hold && (heldBy(f.first) || heldBy(f.last))) { held.push({ entry: { ...e, cwd }, folder: heldBy(f.last) ? f.last : f.first }); continue; }
|
|
205
206
|
kept.push({ ...e, cwd, cwd0: f.first ?? cwd });
|
|
206
207
|
}
|
|
207
|
-
return { kept, ignored
|
|
208
|
+
return { kept, ignored };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Whether a turn the person made in `dir` at `ms` waits (is not sent): its folder is not allowed on a machine that has a
|
|
213
|
+
* list, or it was made before the person allowed that folder. A machine with no list holds nothing — it sends as before.
|
|
214
|
+
*/
|
|
215
|
+
export function turnHeld(dir, ms) {
|
|
216
|
+
if (!dir || allowedFolders() === null) return false;
|
|
217
|
+
if (heldBy(dir)) return true;
|
|
218
|
+
const since = allowedSince(dir);
|
|
219
|
+
return Boolean(since && ms && ms < Date.parse(since));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** a row that is a turn the person typed — not a tool result, not a meta line — the unit a folder is judged by */
|
|
223
|
+
export function isSaid(r) {
|
|
224
|
+
if (r?.type !== 'user' || r.isMeta) return false;
|
|
225
|
+
const c = r.message?.content;
|
|
226
|
+
return typeof c === 'string' ? Boolean(c.trim()) : Array.isArray(c) && !c.some((b) => b?.type === 'tool_result') && c.some((b) => b?.type === 'text' && String(b.text ?? '').trim());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The folders of the turns that are waiting — made at or after `fromMs` in a folder the person has not allowed — with how
|
|
231
|
+
* many. What a capture reports to the app and names in its result line, and what `diffin folders` puts first: the folders
|
|
232
|
+
* a person actually spoke in, never the name a session began under.
|
|
233
|
+
*/
|
|
234
|
+
export async function heldFolders(entries, { tsOf, fromMs = 0 }) {
|
|
235
|
+
const turns = new Map();
|
|
236
|
+
if (allowedFolders() === null) return turns;
|
|
237
|
+
for (const e of entries) {
|
|
238
|
+
for await (const r of e.files[0].rows()) {
|
|
239
|
+
if (!isSaid(r)) continue;
|
|
240
|
+
const t = tsOf(r);
|
|
241
|
+
if (t && t < fromMs) continue;
|
|
242
|
+
const dir = String(r.cwd ?? e.cwd ?? '');
|
|
243
|
+
if (!dir || ignoredBy(dir) || !heldBy(dir)) continue;
|
|
244
|
+
turns.set(dir, (turns.get(dir) ?? 0) + 1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return turns;
|
|
208
248
|
}
|
package/lib/stream.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// that has a database yields rows straight from its query. Nothing is written to disk
|
|
8
8
|
// on the way — so no unscrubbed copy of a session ever sits in a temp directory.
|
|
9
9
|
import { cleanRow, queuedTurn } from './scrub.mjs';
|
|
10
|
-
import { ignoredBy } from './ignore.mjs';
|
|
10
|
+
import { ignoredBy, isSaid, turnHeld } from './ignore.mjs';
|
|
11
11
|
import { offTheRecord } from './state.mjs';
|
|
12
12
|
|
|
13
13
|
const TAIL = 8;
|
|
@@ -19,6 +19,7 @@ const TAIL_CHARS = 300;
|
|
|
19
19
|
export async function* streamCutScrub(rows, { tsOf, fromMs = 0, toMs = Infinity }) {
|
|
20
20
|
const tail = [];
|
|
21
21
|
let started = false, hidden = false;
|
|
22
|
+
let waiting = null; // the current turn waits for an allowance: set at each turn the person typed, inherited by its answer
|
|
22
23
|
const off = offTheRecord(); // the stretches the person had recording paused
|
|
23
24
|
|
|
24
25
|
for await (const row of rows) {
|
|
@@ -27,6 +28,13 @@ export async function* streamCutScrub(rows, { tsOf, fromMs = 0, toMs = Infinity
|
|
|
27
28
|
// passed through an excluded folder loses those turns and keeps the rest.
|
|
28
29
|
if (row?.cwd && ignoredBy(String(row.cwd))) continue;
|
|
29
30
|
const t = tsOf(row);
|
|
31
|
+
// …and a TURN the person made in a folder they have not allowed (or before they allowed it) waits, with its answer:
|
|
32
|
+
// judged at the row the person typed, by the folder they spoke in, and inherited by every row up to the next one —
|
|
33
|
+
// so an agent that steps into /tmp mid-answer does not cut the answer, and an answer given while the person sat in
|
|
34
|
+
// another project does not go up with the allowed work around it (ignore.mjs `turnHeld`, 2026-10-12). Before the
|
|
35
|
+
// first typed turn a row is judged by its own folder.
|
|
36
|
+
if (row?.cwd && (isSaid(row) || waiting === null)) waiting = turnHeld(String(row.cwd), t); // a turn with no folder keeps the last verdict
|
|
37
|
+
if (waiting) continue;
|
|
30
38
|
// …and a row from a paused stretch does not either: paused means off the record, not sent later. A row with no
|
|
31
39
|
// time goes with the row before it, as it does at the window's edge.
|
|
32
40
|
if (off) { if (t) hidden = off(t); if (hidden) continue; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diffin/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Capture your AI work history into diffin",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://diffin.work",
|
|
@@ -18,6 +18,6 @@
|
|
|
18
18
|
"templates"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "for f in bin/*.mjs lib/*.mjs lib/sources/*.mjs; do node --check $f || exit 1; done && node test/no-shadowed-import.mjs && node test/ui.mjs && node test/scrub-self.mjs && node test/scrub-rules.mjs && node test/run-pin.mjs && node test/capture.mjs && node test/capture-all.mjs && node test/ignore.mjs && node test/allow.mjs && node test/machine.mjs && node test/launch.mjs && node test/command.mjs && node test/init.mjs && node test/onboard.mjs && node test/watch.mjs && node test/journey.mjs && node test/net-https.mjs && node test/net-429.mjs && node test/state.mjs && node test/hermes.mjs && node test/codex.mjs && node test/antigravity.mjs && node test/status.mjs"
|
|
21
|
+
"test": "for f in bin/*.mjs lib/*.mjs lib/sources/*.mjs; do node --check $f || exit 1; done && node test/no-shadowed-import.mjs && node test/ui.mjs && node test/scrub-self.mjs && node test/scrub-rules.mjs && node test/run-pin.mjs && node test/capture.mjs && node test/capture-all.mjs && node test/ignore.mjs && node test/allow.mjs && node test/turn-hold.mjs && node test/machine.mjs && node test/launch.mjs && node test/command.mjs && node test/init.mjs && node test/onboard.mjs && node test/watch.mjs && node test/journey.mjs && node test/net-https.mjs && node test/net-429.mjs && node test/state.mjs && node test/hermes.mjs && node test/codex.mjs && node test/antigravity.mjs && node test/status.mjs"
|
|
22
22
|
}
|
|
23
23
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: capture
|
|
3
|
-
description: Records the work of every agent session on this machine to diffin (the AI-work ledger). Run `diffin capture` at the end of ANY turn in which a step of work got done
|
|
3
|
+
description: "Records the work of every agent session on this machine to diffin (the AI-work ledger). Run `diffin capture` at the end of ANY turn in which a step of work got done, in any kind of work, not only code. A step is done when (1) something left the person's hands (sent, submitted, published, shared, exported, committed, merged, deployed); (2) a version was accepted (a draft, design, plan or fix the person said yes to); (3) a question got its answer (research, comparison, diagnosis the person will act on); (4) a direction was decided; (5) the person wrapped up or moved on. Outside code: an email ready to send, a report section finished, a deck shared, an analysis answered. Always when the user says \"기록해\", \"캡처해\", \"record this\", \"capture\". The bar is low: when unsure, run it. The command ends a call that comes too soon without sending, so an extra call costs nothing and a missed one loses the record. A failed call never ends capturing: run it again at the next step. Runs alone, no confirmation."
|
|
4
4
|
license: Proprietary. Part of the diffin CLI.
|
|
5
5
|
compatibility: Requires the diffin CLI on PATH (`npx @diffin/cli onboard` installs it; or npm i -g @diffin/cli), signed in once with `diffin login`, and network access to the diffin server. Reads the agent session transcripts on this machine — every session that moved since it was last recorded. Capture supports Claude Code, Codex, Antigravity and Hermes sessions; under other agents the command says so.
|
|
6
6
|
allowed-tools: Bash(diffin capture:*)
|
|
7
7
|
metadata:
|
|
8
8
|
author: diffin
|
|
9
|
-
version: "3.
|
|
9
|
+
version: "3.14"
|
|
10
10
|
---
|
|
11
11
|
|
|
12
12
|
# Capture (diffin)
|
|
@@ -96,6 +96,11 @@ Then one short line at most, and stop:
|
|
|
96
96
|
| a daily limit | nothing — tomorrow |
|
|
97
97
|
| anything else | show the message once; do not retry |
|
|
98
98
|
|
|
99
|
+
"Do not retry" is about THAT call, never about the session. A capture that failed, crashed or was refused does not
|
|
100
|
+
end capturing here: at the next finished step, run `diffin capture` again as if nothing had happened — the cause may
|
|
101
|
+
already be fixed, and the command decides for itself whether it may send. Do not leave capturing to another session,
|
|
102
|
+
another agent or the person because one call failed.
|
|
103
|
+
|
|
99
104
|
## Never
|
|
100
105
|
|
|
101
106
|
- Write, edit or summarise the record yourself — the server extracts it.
|