logdig 0.1.0 → 0.1.1

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/QUICKSTART.md CHANGED
@@ -29,10 +29,11 @@ Press Enter to accept defaults. A typo only re-asks that question. Review the ch
29
29
 
30
30
  ```sh
31
31
  logdig doctor
32
+ logdig status 7
32
33
  logdig backfill 1 --dry-run
33
34
  ```
34
35
 
35
- The preview shows projects, dates, destination files, cache hits, and which sessions need summarizing. **No model requests, file changes, or new folders.** It does not display transcript excerpts.
36
+ `status` shows what's already journaled, what needs updating or is missing, and which summaries would need a model request. It is read-only. The backfill preview shows projects, dates, destination files, cache hits, and which sessions need summarizing. **Neither command makes model requests or changes files.** The preview does not display transcript excerpts.
36
37
 
37
38
  `1` means today in your chosen timezone. If nothing happened today:
38
39
 
@@ -54,7 +55,7 @@ This may send selected, redacted history to your Pi model and incur provider cha
54
55
 
55
56
  Open the daily-note date printed by the command. You will find project subheadings under `# Projects`, with timestamped summaries grouped beneath each one. Your existing text stays in place. Click a timestamp to open that entry's saved snapshot of all three summary lengths, or browse `<cache folder>/Sessions/<session-id>.md` for the latest session summary.
56
57
 
57
- Repeating an unchanged session reuses its summary and does not insert that entry again. Updated sessions or changed summary settings can append a new version. Run only one save at a time against your journal, and let vault sync finish first.
58
+ Repeating an unchanged session reuses its summary and does not insert that entry again. If a session evolves, LogDig updates its existing daily row to the latest summary, preserving manually edited blurbs and keeping older snapshots in `Entries/`. Run only one save at a time against your journal, and let vault sync finish first.
58
59
 
59
60
  ## Optional: use `/journal` inside Pi
60
61
 
package/README.md CHANGED
@@ -94,7 +94,7 @@ Obsidian displays each link as just the timestamp; clicking it opens that entry'
94
94
 
95
95
  The **last user message** supplies the date and time, even if the assistant finishes after midnight. Alternate session branches are included as explorations, not assumed to be the final result.
96
96
 
97
- Unchanged sessions reuse their summaries. The identifier in each timestamp link prevents duplicate entries, without HTML comments. Older project-name links and comment-wrapped entries are still recognized. Changing the default does not override existing saved heading preferences; rerun setup to change them. Changing the session, timezone, explicit model, summary level, or heading can append a **new version** rather than replace an earlier journal entry. Linked entry notes keep their original summary even when the latest session cache changes. Handwritten content and summary wording are preserved; an inline timestamp can be expanded when its project gains another entry. Headings inside frontmatter or fenced code are not insertion targets.
97
+ Unchanged sessions reuse their summaries. The identifier in each timestamp link prevents duplicate entries, without HTML comments. Older project-name links and comment-wrapped entries are still recognized. When a session evolves, LogDig replaces its daily-note row with a link to the latest summary instead of adding another row. Previous linked snapshots remain unchanged in `Entries/`, and manually edited daily summaries are preserved. Changing the default does not override existing saved heading preferences; rerun setup to change them. An inline timestamp can be expanded when its project gains another entry. Headings inside frontmatter or fenced code are not insertion targets.
98
98
 
99
99
  Missing daily-note and cache folders are created only by a real save. Raw Pi history stays in Pi's storage.
100
100
 
@@ -113,11 +113,16 @@ logdig backfill all --dry-run preview every discoverable saved session
113
113
  logdig backfill 7 journal seven days
114
114
  logdig backfill 7 --model provider/model
115
115
  logdig backfill 7 --model default ignore a saved model override for this run
116
+ logdig status show coverage for the last 3 calendar days
117
+ logdig status 7 show coverage for seven days
118
+ logdig status all --json output coverage for every saved session as JSON
116
119
  logdig pi-install install the /journal extension
117
120
  logdig pi-uninstall remove it without deleting notes or summaries
118
121
  ```
119
122
 
120
- `--help` works before setup, including `logdig init --help` and `logdig backfill --help`.
123
+ `--help` works before setup, including `logdig init --help`, `logdig backfill --help`, and `logdig status --help`.
124
+
125
+ `status` is a read-only coverage check. It reports current journal entries, sessions that need updating, missing entries, and whether each summary is cached or needs a model request. It uses the last user message date in your configured timezone. It never calls a model or writes files; scan warnings make the command exit nonzero so incomplete coverage is clear.
121
126
 
122
127
  New summaries may incur provider charges. Large sessions are summarized in chunks and may need several model requests each. Backfill shows which project it is working on before the model completes. Failed sessions are reported, other sessions continue, and the command exits nonzero if anything needs attention. Fix the issue and rerun the same command; completed summaries are reused, even if a previous attempt failed to write a daily note.
123
128
 
package/bin/logdig.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import path from "node:path";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { listJournaledSessions } from "../src/journal.js";
5
6
  import { collectSessions, parseBackfillArgument, writeSessions } from "../src/session-runner.js";
6
7
  import { createPiModelClient } from "../src/pi-client.js";
7
8
  import { spawnPiProcess } from "../src/pi-process.js";
@@ -81,7 +82,6 @@ async function doctor() {
81
82
  const settings = await loadSettings();
82
83
  let issues = 0;
83
84
  let warnings = 0;
84
- console.log("LogDig checkup (no model requests or file changes)\n");
85
85
  console.log(`Settings: ${settings.filePath}${settings.configured ? " (saved)" : " (not saved; using defaults/environment)"}`);
86
86
  const overrides = environmentOverrides();
87
87
  if (overrides.length) console.log(`Environment overrides: ${overrides.join(", ")}`);
@@ -186,9 +186,10 @@ async function backfill(args) {
186
186
  });
187
187
  const summaryCount = `${result.summariesCreated} ${result.summariesCreated === 1 ? "summary" : "summaries"}`;
188
188
  const entryCount = `${result.entriesAppended} daily ${result.entriesAppended === 1 ? "entry" : "entries"}`;
189
+ const updateCount = `${result.entriesUpdated} ${result.entriesUpdated === 1 ? "entry" : "entries"}`;
189
190
  console.log(dryRun
190
- ? `\nWould create ${summaryCount}, reuse ${result.summariesReused}, append ${entryCount}; ${result.entriesSkipped} already present.`
191
- : `\nSaved: ${summaryCount} created, ${result.summariesReused} reused, ${entryCount} appended; ${result.entriesSkipped} already present.`);
191
+ ? `\nWould create ${summaryCount}, reuse ${result.summariesReused}, append ${entryCount}; ${result.entriesSkipped} already present; update ${updateCount}.`
192
+ : `\nSaved: ${summaryCount} created, ${result.summariesReused} reused, appended ${entryCount}; ${result.entriesSkipped} already present; updated ${updateCount}.`);
192
193
  if (result.sessionsSkipped) console.log(`${result.sessionsSkipped} session${result.sessionsSkipped === 1 ? "" : "s"} skipped (outside the date range or without journalable messages).`);
193
194
  if (result.dates.length) {
194
195
  console.log(`Daily-note dates: ${result.dates.join(", ")}`);
@@ -207,6 +208,117 @@ async function backfill(args) {
207
208
  }
208
209
  }
209
210
 
211
+ function parseStatusArguments(args) {
212
+ let rangeArgument;
213
+ let json = false;
214
+ for (const arg of args) {
215
+ if (arg === "--json") {
216
+ json = true;
217
+ } else if (arg.startsWith("-")) {
218
+ throw new Error(`Unknown status argument: ${arg}. Run '${commandName} status --help'.`);
219
+ } else if (rangeArgument !== undefined) {
220
+ throw new Error(`Usage: ${commandName} status [number-of-days|all] [--json]`);
221
+ } else {
222
+ rangeArgument = arg;
223
+ }
224
+ }
225
+ return { range: parseBackfillArgument(rangeArgument, "status"), json };
226
+ }
227
+
228
+ async function status(args) {
229
+ const { range, json } = parseStatusArguments(args);
230
+ const settings = await loadSettings();
231
+ requireJournalPaths(settings);
232
+ const found = await collectSessions({
233
+ sessionDirectory: settings.sessionDirectory,
234
+ timeZone: settings.timeZone,
235
+ days: range.days,
236
+ });
237
+ const previousEntries = await listJournaledSessions(settings.cacheDirectory);
238
+ const inspection = await writeSessions(
239
+ { cacheKey: settings.model || "Pi default" },
240
+ found.sessions,
241
+ settings,
242
+ { ...found, dryRun: true, journaledEntries: previousEntries },
243
+ );
244
+ const sessions = inspection.sessionResults
245
+ .filter((session) => !["skipped", "error"].includes(session.status))
246
+ .map((session) => {
247
+ const previous = previousEntries.get(session.sessionId) || [];
248
+ const status = session.entryPresent ? "logged" : previous.length ? "needs-update" : "missing";
249
+ const hasCurrentSource = previous.some((entry) => entry.sourceFingerprint === session.sourceFingerprint);
250
+ const sourceChanged = !hasCurrentSource && previous.some((entry) => entry.sourceFingerprint);
251
+ return {
252
+ sessionId: session.sessionId,
253
+ project: session.project,
254
+ date: session.date,
255
+ time: session.time,
256
+ status,
257
+ ...(status === "needs-update" ? {
258
+ reason: sourceChanged ? "session changed since its previous journal entry" : "previous journal entry is not current",
259
+ } : {}),
260
+ summaryStatus: session.summaryReused ? "cached" : "needs-generation",
261
+ dailyPath: session.dailyPath,
262
+ };
263
+ });
264
+ const needsUpdate = sessions.filter((session) => session.status === "needs-update").length;
265
+ const missing = sessions.filter((session) => session.status === "missing").length;
266
+ const needsModelRequest = sessions.filter((session) => session.summaryStatus === "needs-generation").length;
267
+ const warnings = [...found.warnings, ...inspection.errors];
268
+ const report = {
269
+ timeframe: {
270
+ kind: range.all ? "all" : "days",
271
+ ...(range.days ? { days: range.days } : {}),
272
+ firstDate: found.firstDate || null,
273
+ lastDate: found.lastDate,
274
+ timeZone: settings.timeZone,
275
+ },
276
+ totals: {
277
+ scanned: found.sessions.length,
278
+ eligible: sessions.length,
279
+ logged: sessions.filter((session) => session.status === "logged").length,
280
+ needsUpdate,
281
+ missing,
282
+ needsModelRequest,
283
+ skipped: inspection.sessionsSkipped,
284
+ errors: inspection.errors.length,
285
+ },
286
+ sessions,
287
+ warnings,
288
+ };
289
+
290
+ if (json) {
291
+ console.log(JSON.stringify(report, null, 2));
292
+ } else {
293
+ const label = range.all ? "all saved sessions" : `last ${range.days} calendar day${range.days === 1 ? "" : "s"}`;
294
+ console.log(`LogDig status: ${label} (${settings.timeZone})${found.firstDate ? ` · ${found.firstDate} through ${found.lastDate}` : ""}`);
295
+ console.log(`${report.totals.eligible} journalable: ${report.totals.logged} current, ${needsUpdate} need updating, ${missing} missing.`);
296
+ console.log(`Summaries: ${report.totals.eligible - needsModelRequest} cached, ${needsModelRequest} need model requests.`);
297
+ const attention = sessions.filter((session) => session.status !== "logged" || session.summaryStatus !== "cached");
298
+ if (attention.length) {
299
+ console.log("\nNeeds attention:");
300
+ for (const session of attention) {
301
+ const state = session.status === "logged" ? "logged" : session.reason || "not yet logged";
302
+ const summary = session.summaryStatus === "cached" ? "summary cached" : "summary needs a model request";
303
+ console.log(`- ${session.date} ${session.time} · ${session.project} · ${state}; ${summary} (${session.sessionId})`);
304
+ console.log(` Daily note: ${session.dailyPath}`);
305
+ }
306
+ } else if (!sessions.length) {
307
+ console.log("No journalable sessions found in this timeframe.");
308
+ } else {
309
+ console.log("Everything in this timeframe is current.");
310
+ }
311
+ if (missing || needsUpdate || needsModelRequest) {
312
+ console.log(`\nTo update the journal: ${commandName} backfill ${range.all ? "all" : range.days}`);
313
+ }
314
+ }
315
+
316
+ if (warnings.length) {
317
+ if (!json) console.error(`Status may be incomplete:\n${warnings.join("\n")}`);
318
+ process.exitCode = 1;
319
+ }
320
+ }
321
+
210
322
  function helpText() {
211
323
  return [
212
324
  "LogDig: a little work journal from your Pi sessions.",
@@ -222,6 +334,7 @@ function helpText() {
222
334
  " logdig config show settings and active environment overrides",
223
335
  " logdig backfill [N|all] [--dry-run] [--model provider/model|default]",
224
336
  " journal saved sessions (default: last 3 days)",
337
+ " logdig status [N|all] [--json] show journal coverage (default: last 3 days; read-only)",
225
338
  " logdig pi-install | pi-uninstall add or remove /journal integration",
226
339
  " logdig --version show the installed version",
227
340
  "",
@@ -237,13 +350,13 @@ function helpText() {
237
350
 
238
351
  async function main() {
239
352
  const [command = "help", ...args] = process.argv.slice(2);
240
- const commands = ["init", "config", "doctor", "backfill", "pi-install", "pi-uninstall", "help", "--help", "-h", "--version", "-v"];
353
+ const commands = ["init", "config", "doctor", "backfill", "status", "pi-install", "pi-uninstall", "help", "--help", "-h", "--version", "-v"];
241
354
  if (!commands.includes(command)) throw new Error(`Unknown command: ${command}. Run '${commandName} help'.`);
242
355
  if (args.includes("--help") || args.includes("-h")) {
243
356
  console.log(helpText());
244
357
  return;
245
358
  }
246
- if (command !== "backfill" && args.length) throw new Error(`'${command}' does not accept arguments. Run '${commandName} help'.`);
359
+ if (!["backfill", "status"].includes(command) && args.length) throw new Error(`'${command}' does not accept arguments. Run '${commandName} help'.`);
247
360
  switch (command) {
248
361
  case "--version":
249
362
  case "-v": {
@@ -263,6 +376,9 @@ async function main() {
263
376
  case "backfill":
264
377
  await backfill(args);
265
378
  break;
379
+ case "status":
380
+ await status(args);
381
+ break;
266
382
  case "pi-install": {
267
383
  const settings = await loadSettings();
268
384
  await runPiCommand(settings.piCommand, ["install", packageRoot], { inherit: true, timeoutMs: 120_000 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "logdig",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Turn saved Pi sessions into a work journal in your Obsidian daily notes.",
5
5
  "license": "MIT",
6
6
  "author": "Soleone",
package/src/index.js CHANGED
@@ -93,9 +93,10 @@ async function runBackfill(ctx, argument) {
93
93
  const warnings = [...found.warnings, ...result.errors];
94
94
  const summaryCount = `${result.summariesCreated} ${result.summariesCreated === 1 ? "summary" : "summaries"}`;
95
95
  const entryCount = `${result.entriesAppended} daily ${result.entriesAppended === 1 ? "entry" : "entries"}`;
96
+ const updateCount = `${result.entriesUpdated} ${result.entriesUpdated === 1 ? "entry" : "entries"}`;
96
97
  const summary = dryRun
97
- ? `LogDig preview: would create ${summaryCount}, reuse ${result.summariesReused}, append ${entryCount}; ${result.entriesSkipped} already present.`
98
- : `LogDig: ${summaryCount} created, ${result.summariesReused} reused, ${entryCount} appended; ${result.entriesSkipped} already present.`;
98
+ ? `LogDig preview: would create ${summaryCount}, reuse ${result.summariesReused}, append ${entryCount}; ${result.entriesSkipped} already present; update ${updateCount}.`
99
+ : `LogDig: ${summaryCount} created, ${result.summariesReused} reused, appended ${entryCount}; ${result.entriesSkipped} already present; updated ${updateCount}.`;
99
100
  const details = warnings.length
100
101
  ? `\n${warnings.slice(0, 3).join("\n")}${warnings.length > 3 ? `\n${warnings.length - 3} more warnings in the console.` : ""}\nFix the issues and rerun; successful summaries are cached.`
101
102
  : result.dates.length ? `\nDaily notes: ${settings.dailyDirectory} (${result.dates.join(", ")})` : "\nNothing to journal in this range. Try /journal backfill 7 --dry-run.";
@@ -121,7 +122,7 @@ function helpText() {
121
122
  export default function (pi) {
122
123
  let saving = false;
123
124
  pi.registerCommand("journal", {
124
- description: "Cache layered session summaries and append to Obsidian journals",
125
+ description: "Cache layered session summaries and keep Obsidian journal entries current",
125
126
  handler: async (argument, ctx) => {
126
127
  const [command, ...rest] = argument.trim().split(/\s+/).filter(Boolean);
127
128
  if (["help", "--help", "-h"].includes(command)) {
@@ -151,7 +152,8 @@ export default function (pi) {
151
152
  if (!result.dailyPaths.length) {
152
153
  notify(ctx, "Nothing to journal yet. Send a message to Pi, then run /journal.");
153
154
  } else {
154
- notify(ctx, `${result.entriesAppended ? `Saved ${settings.dailySummary} summary` : "Already in your journal"}: ${result.dailyPaths.join(", ")}\nFull summaries: ${path.join(settings.cacheDirectory, "Sessions")}`, "success");
155
+ const saved = result.entriesAppended || result.entriesUpdated;
156
+ notify(ctx, `${saved ? `Saved ${settings.dailySummary} summary` : "Already in your journal"}: ${result.dailyPaths.join(", ")}\nFull summaries: ${path.join(settings.cacheDirectory, "Sessions")}`, "success");
155
157
  }
156
158
  } catch (error) {
157
159
  notify(ctx, `LogDig could not save: ${error.message}`, "error");
package/src/journal.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { sessionMetrics } from "./transcript.js";
5
5
 
@@ -187,6 +187,35 @@ export function parseSessionNote(markdown) {
187
187
  return { ...metadata, summary };
188
188
  }
189
189
 
190
+ export async function listJournaledSessions(cacheDirectory) {
191
+ const directory = path.join(cacheDirectory, "Entries");
192
+ let files;
193
+ try {
194
+ files = await readdir(directory, { withFileTypes: true });
195
+ } catch (error) {
196
+ if (error.code === "ENOENT") return new Map();
197
+ throw error;
198
+ }
199
+
200
+ const sessions = new Map();
201
+ for (const file of files) {
202
+ if (!file.isFile() || !file.name.endsWith(".md")) continue;
203
+ const markdown = await readFile(path.join(directory, file.name), "utf8");
204
+ const metadata = frontmatter(markdown);
205
+ if (typeof metadata.sessionId !== "string") continue;
206
+ const versions = sessions.get(metadata.sessionId) || [];
207
+ versions.push({
208
+ id: file.name.slice(0, -3),
209
+ date: metadata.date,
210
+ time: metadata.time,
211
+ sourceFingerprint: metadata.sourceFingerprint,
212
+ summary: parseSessionNote(markdown)?.summary,
213
+ });
214
+ sessions.set(metadata.sessionId, versions);
215
+ }
216
+ return sessions;
217
+ }
218
+
190
219
  const LEGACY_METRIC_FIELDS = "startedAt|endedAt|durationSeconds|costUsd|cacheReadTokens|inputTokens|outputTokens|cacheWriteTokens";
191
220
  const LEGACY_METRIC_LINE = new RegExp(`^(?:${LEGACY_METRIC_FIELDS}): .*\\n`, "gm");
192
221
 
@@ -317,6 +346,11 @@ function headingLevel(line) {
317
346
  return match?.[1].length;
318
347
  }
319
348
 
349
+ function isProjectHeading(line, targetLevel) {
350
+ const level = headingLevel(line);
351
+ return targetLevel < 6 ? level === targetLevel + 1 : /^\*\*Project: .+\*\*$/.test(line);
352
+ }
353
+
320
354
  function addEntryAt(markdown, offset, entry) {
321
355
  const before = markdown.slice(0, offset).replace(/\n{3,}$/, "\n\n");
322
356
  const after = markdown.slice(offset);
@@ -327,7 +361,109 @@ function addEntryAt(markdown, offset, entry) {
327
361
 
328
362
  function timestampEntry(line) {
329
363
  const match = line.match(/^(\*\*\[\[([a-f0-9]{64})\|(\d{2}:\d{2})\]\]\*\*)(?:: (.*)|\s*)$/);
330
- return match && { header: match[1], id: match[2], time: match[3], inline: match[4] !== undefined };
364
+ return match && { header: match[1], id: match[2], time: match[3], inline: match[4] !== undefined, summary: match[4] || "" };
365
+ }
366
+
367
+ function markdownLines(markdown) {
368
+ const lines = [];
369
+ let start = 0;
370
+ let fence;
371
+ let insideFrontmatter = /^---\r?\n/.test(markdown);
372
+ while (start < markdown.length) {
373
+ const newline = markdown.indexOf("\n", start);
374
+ const lineEnd = newline === -1 ? markdown.length : newline;
375
+ const text = markdown.slice(start, lineEnd).replace(/\r$/, "");
376
+ const wasFenced = Boolean(fence);
377
+ let blocked = insideFrontmatter || wasFenced;
378
+ if (insideFrontmatter) {
379
+ if (start > 0 && /^(---|\.\.\.)\s*$/.test(text)) insideFrontmatter = false;
380
+ } else {
381
+ fence = codeFenceAfter(text, fence);
382
+ blocked ||= Boolean(fence);
383
+ }
384
+ lines.push({ text, start, end: newline === -1 ? markdown.length : newline + 1, blocked });
385
+ if (newline === -1) break;
386
+ start = newline + 1;
387
+ }
388
+ return lines;
389
+ }
390
+
391
+ function sessionEntryBlocks(markdown, ids) {
392
+ const lines = markdownLines(markdown);
393
+ const blocks = [];
394
+ const covered = new Set();
395
+
396
+ for (let index = 0; index < lines.length; index++) {
397
+ const line = lines[index];
398
+ if (line.blocked) continue;
399
+ const marker = line.text.match(/^<!-- logdig:([a-f0-9]{64}):start -->$/);
400
+ if (!marker || !ids.has(marker[1])) continue;
401
+ const endMarker = `<!-- logdig:${marker[1]}:end -->`;
402
+ let endIndex = index + 1;
403
+ while (endIndex < lines.length && lines[endIndex].text !== endMarker) endIndex++;
404
+ if (endIndex === lines.length) continue;
405
+ const end = lines[endIndex].end;
406
+ blocks.push({ id: marker[1], start: line.start, end, displayedSummary: "" });
407
+ for (let coveredIndex = index; coveredIndex <= endIndex; coveredIndex++) covered.add(coveredIndex);
408
+ }
409
+
410
+ for (let index = 0; index < lines.length; index++) {
411
+ const line = lines[index];
412
+ if (line.blocked || covered.has(index)) continue;
413
+ const entry = timestampEntry(line.text);
414
+ if (!entry || !ids.has(entry.id)) continue;
415
+
416
+ let endIndex = index + 1;
417
+ while (endIndex < lines.length) {
418
+ const next = lines[endIndex];
419
+ if (!next.blocked && !covered.has(endIndex) && (timestampEntry(next.text) || headingLevel(next.text) !== undefined)) break;
420
+ endIndex++;
421
+ }
422
+ const end = endIndex < lines.length ? lines[endIndex].start : markdown.length;
423
+ const continuation = markdown.slice(line.end, end);
424
+ const displayedSummary = (entry.inline ? `${entry.summary}${continuation ? `\n${continuation}` : ""}` : continuation).trim();
425
+ blocks.push({ id: entry.id, start: line.start, end, displayedSummary });
426
+ }
427
+
428
+ return blocks.sort((left, right) => left.start - right.start);
429
+ }
430
+
431
+ function removeMarkdownRanges(markdown, ranges) {
432
+ let updated = markdown;
433
+ for (const range of [...ranges].sort((left, right) => right.start - left.start)) {
434
+ updated = `${updated.slice(0, range.start)}${updated.slice(range.end)}`;
435
+ }
436
+ return updated;
437
+ }
438
+
439
+ function removeEmptyProjectGroups(markdown, heading) {
440
+ const targetLevel = headingLevel(heading);
441
+ const lines = markdownLines(markdown);
442
+ let inSection = false;
443
+ let sectionEnd = markdown.length;
444
+ const projects = [];
445
+
446
+ for (const line of lines) {
447
+ if (line.blocked) continue;
448
+ if (!inSection) {
449
+ if (line.text.trimEnd() === heading) inSection = true;
450
+ continue;
451
+ }
452
+ const level = headingLevel(line.text);
453
+ if (level !== undefined && level <= targetLevel) {
454
+ sectionEnd = line.start;
455
+ break;
456
+ }
457
+ if (isProjectHeading(line.text, targetLevel)) projects.push(line);
458
+ }
459
+
460
+ const emptyGroups = [];
461
+ for (let index = 0; index < projects.length; index++) {
462
+ const project = projects[index];
463
+ const end = projects[index + 1]?.start ?? sectionEnd;
464
+ if (!markdown.slice(project.end, end).trim()) emptyGroups.push({ start: project.start, end });
465
+ }
466
+ return removeMarkdownRanges(markdown, emptyGroups);
331
467
  }
332
468
 
333
469
  function appendUnderProject(markdown, heading, entry) {
@@ -375,8 +511,8 @@ function appendUnderProject(markdown, heading, entry) {
375
511
  sectionEnd = lineStart;
376
512
  break;
377
513
  }
378
- const isProjectHeading = targetLevel < 6 ? level === targetLevel + 1 : /^\*\*Project: .+\*\*$/.test(line);
379
- if (projectStart !== -1 && projectEnd === undefined && isProjectHeading) projectEnd = lineStart;
514
+ const isProjectGroup = isProjectHeading(line, targetLevel);
515
+ if (projectStart !== -1 && projectEnd === undefined && isProjectGroup) projectEnd = lineStart;
380
516
  if (projectStart === -1 && line.trimEnd() === projectHeading) projectStart = lineStart;
381
517
  if (projectStart !== -1 && projectEnd === undefined) {
382
518
  const timestamp = timestampEntry(line);
@@ -413,17 +549,49 @@ export function dailyEntryId(entry, heading) {
413
549
  return hashValue([entry.sessionId, entry.cacheFingerprint, entry.summaryLevel, heading]);
414
550
  }
415
551
 
416
- function dailyEntryText(entry, heading, inline) {
417
- const id = dailyEntryId(entry, heading);
418
- // Daily notes stay section-safe; structured headings and code live in the linked note.
419
- const summary = entry.summary.split(/\r?\n/)
552
+ function formatDailySummary(summary) {
553
+ return summary.split(/\r?\n/)
420
554
  .map((line) => /^ {0,3}(`{3,}|~{3,})/.test(line) ? "" : line.replace(/^( {0,3})(#{1,6})(?=\s)/, "$1\\$2"))
421
555
  .join("\n").trim();
556
+ }
557
+
558
+ function dailyEntryText(entry, heading, inline) {
559
+ const id = dailyEntryId(entry, heading);
560
+ // Daily notes stay section-safe; structured detail lives in the linked snapshot.
561
+ const summary = entry.summaryOverride ?? formatDailySummary(entry.summary);
422
562
  const timestamp = `**[[${id}|${entry.time}]]**`;
423
563
  return inline ? `${timestamp}: ${summary}` : `${timestamp}\n\n${summary}`;
424
564
  }
425
565
 
426
- export async function inspectDailyEntry(dailyDirectory, heading, entry) {
566
+ async function sessionEntryLocations(dailyDirectory, heading, entry, existing, journaledEntries) {
567
+ const versions = journaledEntries.get(entry.sessionId) || [];
568
+ const snapshots = new Map(versions.map((version) => [version.id, version]));
569
+ const ids = new Set([...snapshots.keys(), dailyEntryId(entry, heading)]);
570
+ const dates = new Set([entry.date, ...versions.map((version) => version.date).filter((date) => /^\d{4}-\d{2}-\d{2}$/.test(date || ""))]);
571
+ const documents = new Map();
572
+ const blocks = [];
573
+
574
+ for (const date of dates) {
575
+ const dailyPath = path.join(dailyDirectory, `${date}.md`);
576
+ const markdown = date === entry.date ? existing : await readMarkdownIfPresent(dailyPath);
577
+ if (markdown === undefined) continue;
578
+ const found = sessionEntryBlocks(markdown, ids);
579
+ if (!found.length) continue;
580
+ documents.set(dailyPath, markdown);
581
+ for (const block of found) blocks.push({ ...block, dailyPath, snapshot: snapshots.get(block.id) });
582
+ }
583
+
584
+ return { documents, blocks };
585
+ }
586
+
587
+ function isUneditedEntry(block) {
588
+ if (!block.displayedSummary) return true;
589
+ return Object.values(block.snapshot?.summary || {}).some((summary) =>
590
+ formatDailySummary(summary).trim() === block.displayedSummary.trim(),
591
+ );
592
+ }
593
+
594
+ export async function inspectDailyEntry(dailyDirectory, heading, entry, journaledEntries) {
427
595
  if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.date)) throw new Error(`Invalid journal date: ${entry.date}`);
428
596
  const dailyPath = path.join(dailyDirectory, `${entry.date}.md`);
429
597
  const existing = await readMarkdownIfPresent(dailyPath) || "";
@@ -431,27 +599,65 @@ export async function inspectDailyEntry(dailyDirectory, heading, entry) {
431
599
  const present = existing.includes(`<!-- logdig:${id}:start -->`) ||
432
600
  existing.split(/\r?\n/).some((line) => timestampEntry(line)?.id === id ||
433
601
  (line.startsWith("**") && line.includes(` · [[${id}|`) && line.endsWith("]]**")));
434
- return { dailyPath, existing, appended: !present };
602
+ const snapshots = journaledEntries || await listJournaledSessions(path.resolve(path.dirname(entry.sessionPath), ".."));
603
+ const locations = await sessionEntryLocations(dailyDirectory, heading, entry, existing, snapshots);
604
+ const oldBlocks = locations.blocks.filter((block) => block.id !== id);
605
+ const sameIdBlocks = locations.blocks.filter((block) => block.id === id);
606
+ const updated = oldBlocks.length > 0 || sameIdBlocks.length > 1 || (!present && sameIdBlocks.length > 0);
607
+ return {
608
+ dailyPath,
609
+ existing,
610
+ appended: !present && !updated,
611
+ updated,
612
+ sessionBlocks: locations.blocks,
613
+ sessionDocuments: locations.documents,
614
+ };
435
615
  }
436
616
 
437
- export async function appendDailyEntry(dailyDirectory, heading, entry) {
438
- const { dailyPath, existing, appended } = await inspectDailyEntry(dailyDirectory, heading, entry);
439
- const entryPath = path.join(path.dirname(entry.sessionPath), "..", "Entries", `${dailyEntryId(entry, heading)}.md`);
440
- if (!appended) {
441
- if (entry.metrics) {
442
- const snapshot = await readMarkdownIfPresent(entryPath);
443
- if (snapshot) {
444
- const enriched = enrichSessionNote(snapshot, entry.sourceFingerprint, entry.metrics);
445
- if (enriched !== snapshot) await writeAtomically(entryPath, enriched);
446
- }
447
- }
448
- return { dailyPath, appended: false };
617
+ async function updateEntrySnapshot(entryPath, entry, createIfMissing) {
618
+ const snapshot = await readMarkdownIfPresent(entryPath);
619
+ if (!snapshot) {
620
+ if (createIfMissing) await writeAtomically(entryPath, await readFile(entry.sessionPath, "utf8"));
621
+ return;
622
+ }
623
+ if (!entry.metrics) return;
624
+ const enriched = enrichSessionNote(snapshot, entry.sourceFingerprint, entry.metrics);
625
+ if (enriched !== snapshot) await writeAtomically(entryPath, enriched);
626
+ }
627
+
628
+ async function updateSessionDailyNotes(heading, entry, inspection) {
629
+ const { dailyPath, existing, sessionBlocks, sessionDocuments } = inspection;
630
+ const customSummaries = [...new Set(sessionBlocks
631
+ .filter((block) => !isUneditedEntry(block))
632
+ .map((block) => block.displayedSummary.trim())
633
+ .filter(Boolean))];
634
+ let markdown = existing;
635
+ const cleanedDocuments = new Map();
636
+ for (const [filePath, contents] of sessionDocuments) {
637
+ const found = sessionBlocks.filter((block) => block.dailyPath === filePath);
638
+ const cleaned = removeEmptyProjectGroups(removeMarkdownRanges(contents, found), heading);
639
+ if (filePath === dailyPath) markdown = cleaned;
640
+ else if (cleaned !== contents) cleanedDocuments.set(filePath, cleaned);
449
641
  }
450
642
 
451
- if (await readMarkdownIfPresent(entryPath) === undefined) {
452
- await writeAtomically(entryPath, await readFile(entry.sessionPath, "utf8"));
643
+ const updatedMarkdown = appendUnderProject(markdown, heading, {
644
+ ...entry,
645
+ ...(customSummaries.length ? { summaryOverride: customSummaries.join("\n\n") } : {}),
646
+ });
647
+ await writeAtomically(dailyPath, updatedMarkdown);
648
+ for (const [filePath, contents] of cleanedDocuments) await writeAtomically(filePath, contents);
649
+ }
650
+
651
+ export async function appendDailyEntry(dailyDirectory, heading, entry, journaledEntries) {
652
+ const inspection = await inspectDailyEntry(dailyDirectory, heading, entry, journaledEntries);
653
+ const { dailyPath, appended, updated } = inspection;
654
+ const entryPath = path.join(path.dirname(entry.sessionPath), "..", "Entries", `${dailyEntryId(entry, heading)}.md`);
655
+ if (!appended && !updated) {
656
+ await updateEntrySnapshot(entryPath, entry, false);
657
+ return { dailyPath, appended: false, updated: false };
453
658
  }
454
- const markdown = appendUnderProject(existing, heading, entry);
455
- await writeAtomically(dailyPath, markdown);
456
- return { dailyPath, entryPath, appended: true };
659
+
660
+ await updateEntrySnapshot(entryPath, entry, true);
661
+ await updateSessionDailyNotes(heading, entry, inspection);
662
+ return { dailyPath, entryPath, appended, updated: updated || inspection.sessionBlocks.length > 0 };
457
663
  }
@@ -1,6 +1,6 @@
1
1
  import { readFile, readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { appendDailyEntry, inspectDailyEntry, inspectSessionSummary, saveSessionSummary } from "./journal.js";
3
+ import { appendDailyEntry, inspectDailyEntry, inspectSessionSummary, listJournaledSessions, saveSessionSummary } from "./journal.js";
4
4
  import { eventsForSession, fingerprintSession, sessionFromJsonl, sessionMetrics } from "./transcript.js";
5
5
 
6
6
  function localDate(timestamp, timeZone) {
@@ -100,11 +100,14 @@ export async function writeSessions(modelClient, sessions, settings, range = {})
100
100
  let summariesCreated = 0;
101
101
  let summariesReused = 0;
102
102
  let entriesAppended = 0;
103
+ let entriesUpdated = 0;
103
104
  let entriesSkipped = 0;
104
105
  let sessionsSkipped = 0;
105
106
  const dates = new Set();
106
107
  const dailyPaths = new Set();
107
108
  const errors = [];
109
+ const sessionResults = [];
110
+ const journaledEntries = range.journaledEntries || await listJournaledSessions(settings.cacheDirectory);
108
111
 
109
112
  for (const [index, session] of sessions.entries()) {
110
113
  const progress = { index: index + 1, total: sessions.length, sessionId: session.header.id };
@@ -114,7 +117,9 @@ export async function writeSessions(modelClient, sessions, settings, range = {})
114
117
  const closingEvent = lastUserEvent || events.at(-1);
115
118
  if (!closingEvent || (range.firstDate && (closingEvent.date < range.firstDate || closingEvent.date > range.lastDate))) {
116
119
  sessionsSkipped++;
117
- range.onProgress?.({ ...progress, phase: "skipped", status: closingEvent ? "outside date range" : "no journalable events" });
120
+ const reason = closingEvent ? "outside date range" : "no journalable events";
121
+ sessionResults.push({ sessionId: session.header.id, status: "skipped", reason });
122
+ range.onProgress?.({ ...progress, phase: "skipped", status: reason });
118
123
  continue;
119
124
  }
120
125
 
@@ -150,10 +155,23 @@ export async function writeSessions(modelClient, sessions, settings, range = {})
150
155
  summary: cached.summary?.[settings.dailySummary],
151
156
  };
152
157
  const dailyEntry = range.dryRun
153
- ? await inspectDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry)
154
- : await appendDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry);
155
- if (dailyEntry.appended) entriesAppended++;
158
+ ? await inspectDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry, journaledEntries)
159
+ : await appendDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry, journaledEntries);
160
+ if (dailyEntry.updated) entriesUpdated++;
161
+ else if (dailyEntry.appended) entriesAppended++;
156
162
  else entriesSkipped++;
163
+ sessionResults.push({
164
+ sessionId: journalSession.header.id,
165
+ project: journalSession.project,
166
+ date: journalSession.date,
167
+ time: journalSession.time,
168
+ entryPresent: !dailyEntry.appended && !dailyEntry.updated,
169
+ entryUpdated: Boolean(dailyEntry.updated),
170
+ summaryReused: cached.reused,
171
+ sourceFingerprint: journalSession.sourceFingerprint,
172
+ sessionPath: cached.sessionPath,
173
+ dailyPath: dailyEntry.dailyPath,
174
+ });
157
175
  dates.add(journalSession.date);
158
176
  dailyPaths.add(dailyEntry.dailyPath);
159
177
  range.onProgress?.({
@@ -162,24 +180,25 @@ export async function writeSessions(modelClient, sessions, settings, range = {})
162
180
  sessionPath: cached.sessionPath,
163
181
  dailyPath: dailyEntry.dailyPath,
164
182
  status: range.dryRun
165
- ? `${cached.reused ? "would reuse summary" : "would summarize with Pi"}, ${dailyEntry.appended ? "would append entry" : "entry already present"}`
166
- : `${cached.reused ? "summary reused" : "summary created"}, ${dailyEntry.appended ? "entry appended" : "entry already present"}`,
183
+ ? `${cached.reused ? "would reuse summary" : "would summarize with Pi"}, ${dailyEntry.updated ? "would update entry" : dailyEntry.appended ? "would append entry" : "entry already present"}`
184
+ : `${cached.reused ? "summary reused" : "summary created"}, ${dailyEntry.updated ? "entry updated" : dailyEntry.appended ? "entry appended" : "entry already present"}`,
167
185
  });
168
186
  } catch (error) {
169
187
  errors.push(`${session.header.id}: ${error.message}`);
188
+ sessionResults.push({ sessionId: session.header.id, status: "error", error: error.message });
170
189
  range.onProgress?.({ ...progress, phase: "error", error: error.message });
171
190
  }
172
191
  }
173
192
 
174
- return { summariesCreated, summariesReused, entriesAppended, entriesSkipped, sessionsSkipped, dates: [...dates].sort(), dailyPaths: [...dailyPaths].sort(), errors };
193
+ return { summariesCreated, summariesReused, entriesAppended, entriesUpdated, entriesSkipped, sessionsSkipped, dates: [...dates].sort(), dailyPaths: [...dailyPaths].sort(), errors, sessionResults };
175
194
  }
176
195
 
177
- export function parseBackfillArgument(argument = "") {
196
+ export function parseBackfillArgument(argument = "", command = "backfill") {
178
197
  const value = argument.trim().toLowerCase() || "3";
179
198
  if (value === "all") return { all: true };
180
199
  const days = Number(value);
181
200
  if (!/^\d+$/.test(value) || !Number.isInteger(days) || days < 1 || days > 3650) {
182
- throw new Error("Usage: logdig backfill [number-of-days|all]. Choose 1 to 3650 days, or 'all'.");
201
+ throw new Error(`Usage: logdig ${command} [number-of-days|all]. Choose 1 to 3650 days, or 'all'.`);
183
202
  }
184
203
  return { days };
185
204
  }