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 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 workers (`CHECKING` or `SUMMARIZING`), the completed-session count, and sessions that are `DONE` but awaiting ordered output. The panel redraws in place and clears when the run finishes. A slow earlier session can delay display of later result rows, but does not block processing or live feedback. 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.
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 new or changed work may take a few minutes; a slow session can delay later rows.");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logdig",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Turn saved Pi sessions into a work journal in your Obsidian daily notes.",
5
5
  "license": "MIT",
6
6
  "author": "Soleone",
@@ -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
- const capacity = Math.max(0, Math.min(concurrency, (stream.rows || 24) - 3));
55
- const lines = [
56
- `Completed: ${completed}/${total} sessions${waiting.length ? ` · ${waiting.length} awaiting ordered output` : ""}`,
57
- ...[...active, ...waiting].slice(0, capacity).map((session) => sessionLabel(session.event, session.complete ? session.failed ? "FAILED" : "DONE" : session.event.status)),
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
- stream.write(`Finished ${sessionLabel(session.event, session.failed ? "FAILED" : "DONE")} (waiting for earlier results)\n`);
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
- return { sessionPath, sourceFingerprint, cacheFingerprint, reused, continuationOf: existing?.continuationOf, summary: reused ? existing.summary : undefined };
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 updated = Boolean(missingSnapshot) || oldBlocks.length > 0 || sameIdBlocks.length > 1 || (!present && sameIdBlocks.length > 0);
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
- if (!entry.metrics) return;
683
- const enriched = enrichSessionNote(snapshot, entry.sourceFingerprint, entry.metrics);
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
 
@@ -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 && !versions.some((version) => version.id === entryId)) {
247
- versions.push({ ...entry, id: entryId, summary: cached.summary });
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 ? path.basename(cwd) : "").replace(/^\d{4}-\d{2}-\d{2}-(?=.)/, "") || "unknown project";
325
+ const project = projectNameFromCwd(cwd);
256
326
 
257
327
  for (const entry of session.entries) {
258
328
  const generated = eventFor(entry, formatter, toolCalls);