logdig 0.2.0 → 0.2.2
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 +2 -2
- package/bin/logdig.js +1 -1
- package/package.json +1 -1
- package/src/backfill-progress.js +37 -9
- package/src/journal.js +38 -4
- package/src/session-runner.js +5 -2
- package/src/transcript.js +71 -1
package/README.md
CHANGED
|
@@ -68,7 +68,7 @@ My Vault/
|
|
|
68
68
|
└── <entry-id>.md snapshot of all three layers for a journal entry
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
Daily entries live under `# Projects`, grouped by project in first-seen order, with timestamps sorted within each project. For [try](https://github.com/tobi/try)-style directories, a leading `YYYY-MM-DD-` is omitted from the project label: `2026-01-17-learn` becomes `learn`. The source path is unchanged.
|
|
71
|
+
Daily entries live under `# Projects`, grouped by project in first-seen order, with timestamps sorted within each project. For Git worktrees, LogDig uses the shared repository's name rather than the worktree directory's name. For [try](https://github.com/tobi/try)-style directories, a leading `YYYY-MM-DD-` is omitted from the project label: `2026-01-17-learn` becomes `learn`. The source path is unchanged.
|
|
72
72
|
|
|
73
73
|
For example:
|
|
74
74
|
|
|
@@ -232,7 +232,7 @@ The menu uses Node's built-in line prompts with no runtime dependencies. Save co
|
|
|
232
232
|
|
|
233
233
|
This inserts a new Projects section after the entire Log section, including its subheadings, and before the next same-level or higher-level heading. Use the same heading level for both settings to keep the sections as siblings. The anchor must be one Markdown heading, including its `#` level, and matches the first occurrence outside frontmatter and fenced code. The default is `""` (blank): append at the end of the note. A missing anchor also falls back to the end. Existing Projects sections stay where they are; this setting does not relocate them or regenerate summaries. Run `config` to edit the anchor or choose `none` to clear it. `PI_JOURNAL_DAILY_HEADER_ANCHOR` overrides the saved setting, including an empty value to clear it.
|
|
234
234
|
|
|
235
|
-
Backfill and automatic capture process up to **four independent sessions concurrently** by default. Set `concurrency` in the settings file or choose “Maximum parallel sessions” in setup's advanced settings. It must be a positive integer; use `1` for sequential processing or to reduce provider rate-limit pressure. Work blocks and extraction requests within each session remain sequential to preserve continuation links. Shared daily-note updates are serialized to avoid overwriting entries. CLI result rows appear in session order even when parallel sessions finish out of order. Session numbers are zero-padded to match the total, for example `[01/65]`, followed by the date, a fixed-width status column, project, and short session ID. Each work period has one final result row; resumed sessions can have multiple dated rows with the same session number. In interactive terminals, a bounded live panel below the permanent results shows active
|
|
235
|
+
Backfill and automatic capture process up to **four independent sessions concurrently** by default. Set `concurrency` in the settings file or choose “Maximum parallel sessions” in setup's advanced settings. It must be a positive integer; use `1` for sequential processing or to reduce provider rate-limit pressure. Work blocks and extraction requests within each session remain sequential to preserve continuation links. Shared daily-note updates are serialized to avoid overwriting entries. CLI result rows appear in session order even when parallel sessions finish out of order. Session numbers are zero-padded to match the total, for example `[01/65]`, followed by the date, a fixed-width status column, project, and short session ID. Each work period has one final result row; resumed sessions can have multiple dated rows with the same session number. In interactive terminals, a bounded live panel below the permanent results separates active sessions from finished results. Its progress line shows completed, active, and queued (not yet started) session counts. Active rows show the elapsed time in their current stage (`CHECKING` or `SUMMARIZING`), refreshed every second even while the model is silent. Finished sessions no longer occupy active rows. Instead, a message such as “20 finished sessions waiting to print after #021” identifies the earlier session holding up result display; completed counts include these unprinted results. Short terminals show fewer active rows and indicate how many are not shown. The panel redraws in place and clears when the run finishes. A slow earlier session can delay printing later result rows, but other sessions keep processing and picking up queued work. Redirected output and `TERM=dumb` use plain `Active` and `Finished` log lines instead of cursor controls. Dry-run output stays static. Blocks outside the selected range that are needed for continuation links are marked `prerequisite`. The final checked count distinguishes work blocks from sessions. Status reports retain chronological session order. Changing concurrency does not invalidate cached summaries. Avoid running separate LogDig commands against the same notes at the same time; the write queue is local to one run.
|
|
236
236
|
|
|
237
237
|
Set `LOGDIG_CONFIG_PATH` to choose another settings file. Existing `PI_JOURNAL_*` environment variables remain supported and override saved values. Setup, `config`, and `doctor` name active overrides so you can see why a saved preference is not taking effect.
|
|
238
238
|
|
package/bin/logdig.js
CHANGED
|
@@ -198,7 +198,7 @@ async function backfill(args) {
|
|
|
198
198
|
|
|
199
199
|
console.log("Results are shown in session order; processing remains parallel.");
|
|
200
200
|
console.log("One result per work period; resumed sessions may have multiple dated rows.");
|
|
201
|
-
if (!dryRun) console.log("Summarizing
|
|
201
|
+
if (!dryRun) console.log("Summarizing may take a few minutes. Finished results wait to print until earlier sessions finish; other sessions keep processing.");
|
|
202
202
|
console.log();
|
|
203
203
|
const progress = createBackfillProgress({ total: found.sessions.length, concurrency: settings.concurrency, dryRun });
|
|
204
204
|
const interrupt = () => { cleanupProgress(); process.kill(process.pid, "SIGINT"); };
|
package/package.json
CHANGED
package/src/backfill-progress.js
CHANGED
|
@@ -30,6 +30,7 @@ export function createBackfillProgress({ total, concurrency, dryRun = false, str
|
|
|
30
30
|
let nextResult = 0;
|
|
31
31
|
let completed = 0;
|
|
32
32
|
let panelRows = 0;
|
|
33
|
+
let refreshTimer;
|
|
33
34
|
let closed = false;
|
|
34
35
|
|
|
35
36
|
function clearPanel() {
|
|
@@ -38,24 +39,44 @@ export function createBackfillProgress({ total, concurrency, dryRun = false, str
|
|
|
38
39
|
panelRows = 0;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
function sessionLabel(event, status = event.status) {
|
|
42
|
+
function sessionLabel(event, status = event.status, elapsed = "") {
|
|
42
43
|
const position = `${String(event.index).padStart(String(total).length, "0")}/${total}`;
|
|
43
44
|
const timestamp = `${cleanText(event.date)}${event.time ? ` ${cleanText(event.time)}` : ""}`.padEnd(16);
|
|
44
|
-
return `[${position}] ${timestamp} · ${progressStatus(status, statusOptions)} · ${cleanText(event.project) || "session"} (${cleanText(event.sessionId).slice(-8)})`;
|
|
45
|
+
return `[${position}] ${timestamp} · ${progressStatus(status, statusOptions)} · ${elapsed ? `${elapsed} · ` : ""}${cleanText(event.project) || "session"} (${cleanText(event.sessionId).slice(-8)})`;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
function renderPanel() {
|
|
48
49
|
if (!live || closed) return;
|
|
49
50
|
clearPanel();
|
|
50
51
|
const pending = sessions.slice(nextResult).filter((session) => session.event);
|
|
51
|
-
if (!pending.length) return;
|
|
52
52
|
const waiting = pending.filter((session) => session.complete);
|
|
53
53
|
const active = pending.filter((session) => !session.complete);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
54
|
+
if (active.length && !refreshTimer) {
|
|
55
|
+
refreshTimer = setInterval(renderPanel, 1000);
|
|
56
|
+
refreshTimer.unref();
|
|
57
|
+
} else if (!active.length && refreshTimer) {
|
|
58
|
+
clearInterval(refreshTimer);
|
|
59
|
+
refreshTimer = undefined;
|
|
60
|
+
}
|
|
61
|
+
if (!pending.length) return;
|
|
62
|
+
const queued = total - completed - active.length;
|
|
63
|
+
const overhead = 1 + Number(active.length > 0) + Number(waiting.length > 0);
|
|
64
|
+
const capacity = Math.max(0, Math.min(concurrency, (stream.rows || 24) - 2 - overhead));
|
|
65
|
+
const lines = [`Progress: ${completed}/${total} complete · ${active.length} active · ${queued} queued`];
|
|
66
|
+
if (active.length) {
|
|
67
|
+
const hidden = Math.max(0, active.length - capacity);
|
|
68
|
+
lines.push(`Active sessions (stage elapsed${hidden ? `; ${hidden} not shown` : ""}):`);
|
|
69
|
+
for (const session of active.slice(0, capacity)) {
|
|
70
|
+
const seconds = Math.max(0, Math.floor((Date.now() - session.stageStartedAt) / 1000));
|
|
71
|
+
const elapsed = seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
72
|
+
lines.push(sessionLabel(session.event, session.event.status, elapsed));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (waiting.length) {
|
|
76
|
+
const blocker = String(nextResult + 1).padStart(String(total).length, "0");
|
|
77
|
+
const failed = waiting.filter((session) => session.failed).length;
|
|
78
|
+
lines.push(`${waiting.length} finished session${waiting.length === 1 ? "" : "s"} waiting to print after #${blocker}.${failed ? ` ${failed} with errors.` : ""}`);
|
|
79
|
+
}
|
|
59
80
|
stream.write(lines.map((line) => fitLiveLine(line, stream.columns || 80)).join("\n") + "\n");
|
|
60
81
|
panelRows = lines.length;
|
|
61
82
|
}
|
|
@@ -63,6 +84,10 @@ export function createBackfillProgress({ total, concurrency, dryRun = false, str
|
|
|
63
84
|
function onProgress(event) {
|
|
64
85
|
if (closed) return;
|
|
65
86
|
const session = sessions[event.index - 1];
|
|
87
|
+
const previous = session.event;
|
|
88
|
+
if (!previous || previous.phase !== event.phase || previous.blockId !== event.blockId || previous.date !== event.date || previous.time !== event.time) {
|
|
89
|
+
session.stageStartedAt = Date.now();
|
|
90
|
+
}
|
|
66
91
|
session.event = event;
|
|
67
92
|
if (event.phase === "error") session.failed = true;
|
|
68
93
|
if (["complete", "skipped", "error"].includes(event.phase)) {
|
|
@@ -85,7 +110,8 @@ export function createBackfillProgress({ total, concurrency, dryRun = false, str
|
|
|
85
110
|
completed++;
|
|
86
111
|
clearPanel();
|
|
87
112
|
if (!live && !dryRun && index - 1 > nextResult) {
|
|
88
|
-
|
|
113
|
+
const blocker = String(nextResult + 1).padStart(String(total).length, "0");
|
|
114
|
+
stream.write(`Finished ${sessionLabel(session.event, session.failed ? "FAILED" : "DONE")} (waiting to print after #${blocker}; processing continues)\n`);
|
|
89
115
|
}
|
|
90
116
|
while (sessions[nextResult]?.complete) {
|
|
91
117
|
const { lines } = sessions[nextResult];
|
|
@@ -100,6 +126,8 @@ export function createBackfillProgress({ total, concurrency, dryRun = false, str
|
|
|
100
126
|
if (closed) return;
|
|
101
127
|
clearPanel();
|
|
102
128
|
closed = true;
|
|
129
|
+
clearInterval(refreshTimer);
|
|
130
|
+
refreshTimer = undefined;
|
|
103
131
|
if (live) stream.removeListener("resize", renderPanel);
|
|
104
132
|
}
|
|
105
133
|
|
package/src/journal.js
CHANGED
|
@@ -212,6 +212,7 @@ export async function listJournaledSessions(cacheDirectory) {
|
|
|
212
212
|
time: metadata.time,
|
|
213
213
|
sourceFingerprint: metadata.sourceFingerprint,
|
|
214
214
|
cacheFingerprint: metadata.cacheFingerprint,
|
|
215
|
+
project: metadata.project,
|
|
215
216
|
blockId: metadata.blockId,
|
|
216
217
|
blockStart: metadata.blockStart,
|
|
217
218
|
continuationOf: metadata.continuationOf,
|
|
@@ -336,6 +337,18 @@ function updateContinuationReference(markdown, continuationOf) {
|
|
|
336
337
|
return header + (continuationOf ? `\nContinues [[${continuationOf}|previous entry]].\n` : "") + body;
|
|
337
338
|
}
|
|
338
339
|
|
|
340
|
+
function sourceFingerprintForProject(session, project) {
|
|
341
|
+
const events = [...(session.context || []), ...(session.events || [])];
|
|
342
|
+
if (events.some((event) => event.project !== session.project)) return undefined;
|
|
343
|
+
const relabel = (items) => items.map((event) => ({ ...event, project }));
|
|
344
|
+
return hashValue([
|
|
345
|
+
project,
|
|
346
|
+
session.timezone,
|
|
347
|
+
relabel(session.context || []),
|
|
348
|
+
relabel(session.events || []),
|
|
349
|
+
]);
|
|
350
|
+
}
|
|
351
|
+
|
|
339
352
|
export async function inspectSessionSummary(modelClient, cacheDirectory, session) {
|
|
340
353
|
const sourceFingerprint = hashValue([session.project, session.timezone, session.context || [], session.events]);
|
|
341
354
|
// Keep generation policy separate from evidence so model and thinking changes can
|
|
@@ -348,7 +361,20 @@ export async function inspectSessionSummary(modelClient, cacheDirectory, session
|
|
|
348
361
|
const existing = cached && parseSessionNote(cached);
|
|
349
362
|
|
|
350
363
|
const reused = existing?.cacheFingerprint === cacheFingerprint;
|
|
351
|
-
|
|
364
|
+
const previousSourceFingerprint = existing?.project && existing.project !== session.project
|
|
365
|
+
? sourceFingerprintForProject(session, existing.project)
|
|
366
|
+
: undefined;
|
|
367
|
+
const relabeledReuse = !reused && previousSourceFingerprint === existing?.sourceFingerprint &&
|
|
368
|
+
existing?.cacheFingerprint === hashValue([SUMMARY_VERSION, JOURNAL_SYSTEM_PROMPT, CHUNK_LIMIT, previousSourceFingerprint, policy]);
|
|
369
|
+
const reusable = reused || relabeledReuse;
|
|
370
|
+
return {
|
|
371
|
+
sessionPath,
|
|
372
|
+
sourceFingerprint: reusable ? existing.sourceFingerprint : sourceFingerprint,
|
|
373
|
+
cacheFingerprint: reusable ? existing.cacheFingerprint : cacheFingerprint,
|
|
374
|
+
reused: reusable,
|
|
375
|
+
continuationOf: existing?.continuationOf,
|
|
376
|
+
summary: reusable ? existing.summary : undefined,
|
|
377
|
+
};
|
|
352
378
|
}
|
|
353
379
|
|
|
354
380
|
export async function saveSessionSummary(modelClient, cacheDirectory, session, { onGenerate } = {}) {
|
|
@@ -661,7 +687,10 @@ export async function inspectDailyEntry(dailyDirectory, heading, entry, journale
|
|
|
661
687
|
const oldBlocks = locations.blocks.filter((block) => block.id !== id);
|
|
662
688
|
const sameIdBlocks = locations.blocks.filter((block) => block.id === id);
|
|
663
689
|
const missingSnapshot = entry.blockId && present && !snapshots.get(entry.sessionId)?.some((version) => version.id === id);
|
|
664
|
-
const
|
|
690
|
+
const projectChanged = locations.blocks.some((block) =>
|
|
691
|
+
block.snapshot?.project && entry.project && block.snapshot.project !== entry.project,
|
|
692
|
+
);
|
|
693
|
+
const updated = Boolean(missingSnapshot) || oldBlocks.length > 0 || sameIdBlocks.length > 1 || (!present && sameIdBlocks.length > 0) || projectChanged;
|
|
665
694
|
return {
|
|
666
695
|
dailyPath,
|
|
667
696
|
existing,
|
|
@@ -673,14 +702,19 @@ export async function inspectDailyEntry(dailyDirectory, heading, entry, journale
|
|
|
673
702
|
};
|
|
674
703
|
}
|
|
675
704
|
|
|
705
|
+
function updateSnapshotProject(markdown, project) {
|
|
706
|
+
if (!project || frontmatter(markdown).project === project) return markdown;
|
|
707
|
+
return markdown.replace(/^project: .*$/m, `project: ${frontmatterValue(project)}`);
|
|
708
|
+
}
|
|
709
|
+
|
|
676
710
|
async function updateEntrySnapshot(entryPath, entry, createIfMissing) {
|
|
677
711
|
const snapshot = await readMarkdownIfPresent(entryPath);
|
|
678
712
|
if (!snapshot) {
|
|
679
713
|
if (createIfMissing) await writeAtomically(entryPath, await readFile(entry.sessionPath, "utf8"));
|
|
680
714
|
return;
|
|
681
715
|
}
|
|
682
|
-
|
|
683
|
-
const enriched = enrichSessionNote(
|
|
716
|
+
const withProject = updateSnapshotProject(snapshot, entry.project);
|
|
717
|
+
const enriched = entry.metrics ? enrichSessionNote(withProject, entry.sourceFingerprint, entry.metrics) : withProject;
|
|
684
718
|
if (enriched !== snapshot) await writeAtomically(entryPath, enriched);
|
|
685
719
|
}
|
|
686
720
|
|
package/src/session-runner.js
CHANGED
|
@@ -243,8 +243,11 @@ export async function writeSessions(modelClient, sessions, settings, range = {})
|
|
|
243
243
|
dailyPath: dailyEntry.dailyPath,
|
|
244
244
|
});
|
|
245
245
|
// Dry-run predictions stay in plannedSnapshots, never in the real index.
|
|
246
|
-
if (!range.dryRun
|
|
247
|
-
versions.
|
|
246
|
+
if (!range.dryRun) {
|
|
247
|
+
const versionIndex = versions.findIndex((version) => version.id === entryId);
|
|
248
|
+
const updatedVersion = { ...entry, id: entryId, summary: cached.summary };
|
|
249
|
+
if (versionIndex === -1) versions.push(updatedVersion);
|
|
250
|
+
else versions[versionIndex] = updatedVersion;
|
|
248
251
|
}
|
|
249
252
|
dates.add(block.date);
|
|
250
253
|
dailyPaths.add(dailyEntry.dailyPath);
|
package/src/transcript.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
|
|
3
4
|
const READ_TOOLS = new Set([
|
|
@@ -248,11 +249,80 @@ function eventFor(entry, formatter, toolCalls) {
|
|
|
248
249
|
return undefined;
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
const PROJECT_NAME_CACHE = new Map();
|
|
253
|
+
|
|
254
|
+
function cleanProjectName(name) {
|
|
255
|
+
return name.replace(/^\d{4}-\d{2}-\d{2}-(?=.)/, "");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function commonGitDirectory(gitDir) {
|
|
259
|
+
try {
|
|
260
|
+
const commonDir = readFileSync(path.join(gitDir, "commondir"), "utf8").trim();
|
|
261
|
+
return commonDir ? path.resolve(gitDir, commonDir) : undefined;
|
|
262
|
+
} catch {
|
|
263
|
+
const segments = gitDir.split(path.sep);
|
|
264
|
+
const worktreesIndex = segments.lastIndexOf("worktrees");
|
|
265
|
+
return worktreesIndex >= 0 ? segments.slice(0, worktreesIndex).join(path.sep) : undefined;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function projectNameFromGitMarker(directory) {
|
|
270
|
+
try {
|
|
271
|
+
const gitMarker = path.join(directory, ".git");
|
|
272
|
+
const markerStat = statSync(gitMarker);
|
|
273
|
+
if (markerStat.isDirectory()) return cleanProjectName(path.basename(directory)) || undefined;
|
|
274
|
+
if (!markerStat.isFile()) return undefined;
|
|
275
|
+
|
|
276
|
+
const gitDirMatch = readFileSync(gitMarker, "utf8").match(/^gitdir:\s*(.+?)\s*$/m);
|
|
277
|
+
if (!gitDirMatch) return undefined;
|
|
278
|
+
const commonDir = commonGitDirectory(path.resolve(directory, gitDirMatch[1]));
|
|
279
|
+
return commonDir ? cleanProjectName(path.basename(path.dirname(commonDir))) || undefined : undefined;
|
|
280
|
+
} catch {
|
|
281
|
+
// Project names are best-effort when historical checkout metadata is missing.
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function projectNameFromCwd(cwd) {
|
|
287
|
+
if (!cwd) return "unknown project";
|
|
288
|
+
const resolved = path.resolve(cwd);
|
|
289
|
+
if (PROJECT_NAME_CACHE.has(resolved)) return PROJECT_NAME_CACHE.get(resolved);
|
|
290
|
+
|
|
291
|
+
let directory = resolved;
|
|
292
|
+
while (true) {
|
|
293
|
+
const project = projectNameFromGitMarker(directory);
|
|
294
|
+
if (project) {
|
|
295
|
+
PROJECT_NAME_CACHE.set(resolved, project);
|
|
296
|
+
return project;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const parent = path.dirname(directory);
|
|
300
|
+
if (parent === directory) break;
|
|
301
|
+
directory = parent;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Keep historical sessions grouped when a worktree's checkout has been removed.
|
|
305
|
+
const segments = resolved.split(path.sep);
|
|
306
|
+
const worktreesIndex = segments.lastIndexOf("worktrees");
|
|
307
|
+
const dotWorktreesIndex = segments.lastIndexOf(".worktrees");
|
|
308
|
+
let fallback = path.basename(resolved);
|
|
309
|
+
if (worktreesIndex >= 0) {
|
|
310
|
+
fallback = segments.length > worktreesIndex + 2
|
|
311
|
+
? segments[worktreesIndex + 1]
|
|
312
|
+
: segments[worktreesIndex - 1] || fallback;
|
|
313
|
+
} else if (dotWorktreesIndex > 0) {
|
|
314
|
+
fallback = segments[dotWorktreesIndex - 1];
|
|
315
|
+
}
|
|
316
|
+
const name = cleanProjectName(fallback) || "unknown project";
|
|
317
|
+
PROJECT_NAME_CACHE.set(resolved, name);
|
|
318
|
+
return name;
|
|
319
|
+
}
|
|
320
|
+
|
|
251
321
|
export function eventsForSession(session, timeZone, extraction = { formatter: localTimeFormatter(timeZone), toolCalls: new Map() }) {
|
|
252
322
|
const events = [];
|
|
253
323
|
const { formatter, toolCalls } = extraction;
|
|
254
324
|
const cwd = session.header.cwd || "";
|
|
255
|
-
const project = (cwd
|
|
325
|
+
const project = projectNameFromCwd(cwd);
|
|
256
326
|
|
|
257
327
|
for (const entry of session.entries) {
|
|
258
328
|
const generated = eventFor(entry, formatter, toolCalls);
|