logdig 0.1.0 → 0.1.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/src/index.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
- import { JOURNAL_SYSTEM_PROMPT } from "./journal.js";
3
+ import { JOURNAL_SYSTEM_PROMPT, summaryCachePolicy } from "./journal.js";
4
4
  import { collectSessions, parseBackfillArgument, writeSessions } from "./session-runner.js";
5
5
  import { loadSettings } from "./settings.js";
6
- import { fingerprintSession } from "./transcript.js";
7
6
 
8
7
  function requireJournalSettings(settings) {
9
8
  if (!settings.cacheDirectory) throw new Error("Run 'logdig init' or set PI_JOURNAL_DIR to a LogDig cache folder");
@@ -23,7 +22,6 @@ function currentSession(ctx) {
23
22
  entries: ctx.sessionManager.getEntries(),
24
23
  sourcePath: ctx.sessionManager.getSessionFile() || "active Pi session",
25
24
  };
26
- session.fingerprint = fingerprintSession(session);
27
25
  return session;
28
26
  }
29
27
 
@@ -37,26 +35,29 @@ function extensionModelClient(ctx, settings) {
37
35
 
38
36
  return {
39
37
  modelLabel: model ? `${model.provider}/${model.id}` : "Pi current model",
40
- cacheKey: settings.model || "Pi default",
38
+ cachePolicy: summaryCachePolicy(settings),
41
39
  complete: async (prompt) => {
42
40
  if (!model) throw new Error("Pi has no active model for journal generation");
43
41
  if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
44
42
  throw new Error(`No configured authentication for ${model.provider}/${model.id}. Run /login in Pi, then try /journal again`);
45
43
  }
46
- return ctx.modelRegistry.complete(
47
- model,
48
- {
49
- systemPrompt: JOURNAL_SYSTEM_PROMPT,
50
- messages: [
51
- {
52
- role: "user",
53
- content: [{ type: "text", text: prompt }],
54
- timestamp: Date.now(),
55
- },
56
- ],
57
- },
58
- { sessionId: randomUUID(), cacheRetention: "none", signal: ctx.signal },
59
- );
44
+ const context = {
45
+ systemPrompt: JOURNAL_SYSTEM_PROMPT,
46
+ messages: [
47
+ {
48
+ role: "user",
49
+ content: [{ type: "text", text: prompt }],
50
+ timestamp: Date.now(),
51
+ },
52
+ ],
53
+ };
54
+ const options = { sessionId: randomUUID(), cacheRetention: "none", signal: ctx.signal };
55
+ // Preserve existing provider defaults unless LogDig explicitly selects thinking.
56
+ if (settings.thinkingLevel === undefined) return ctx.modelRegistry.complete(model, context, options);
57
+ return ctx.modelRegistry.streamSimple(model, context, {
58
+ ...options,
59
+ reasoning: settings.thinkingLevel === "off" ? undefined : settings.thinkingLevel,
60
+ }).result();
60
61
  },
61
62
  };
62
63
  }
@@ -88,14 +89,15 @@ async function runBackfill(ctx, argument) {
88
89
  days: range.days,
89
90
  currentSession: currentSession(ctx),
90
91
  });
91
- const client = dryRun ? { cacheKey: settings.model || "Pi default" } : extensionModelClient(ctx, settings);
92
+ const client = dryRun ? { cachePolicy: summaryCachePolicy(settings) } : extensionModelClient(ctx, settings);
92
93
  const result = await writeSessions(client, found.sessions, settings, { ...found, dryRun, onProgress: journalProgress(ctx) });
93
94
  const warnings = [...found.warnings, ...result.errors];
94
95
  const summaryCount = `${result.summariesCreated} ${result.summariesCreated === 1 ? "summary" : "summaries"}`;
95
96
  const entryCount = `${result.entriesAppended} daily ${result.entriesAppended === 1 ? "entry" : "entries"}`;
97
+ const updateCount = `${result.entriesUpdated} ${result.entriesUpdated === 1 ? "entry" : "entries"}`;
96
98
  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.`;
99
+ ? `LogDig preview: would create ${summaryCount}, reuse ${result.summariesReused}, append ${entryCount}; ${result.entriesSkipped} already present; update ${updateCount}.`
100
+ : `LogDig: ${summaryCount} created, ${result.summariesReused} reused, appended ${entryCount}; ${result.entriesSkipped} already present; updated ${updateCount}.`;
99
101
  const details = warnings.length
100
102
  ? `\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
103
  : result.dates.length ? `\nDaily notes: ${settings.dailyDirectory} (${result.dates.join(", ")})` : "\nNothing to journal in this range. Try /journal backfill 7 --dry-run.";
@@ -121,7 +123,7 @@ function helpText() {
121
123
  export default function (pi) {
122
124
  let saving = false;
123
125
  pi.registerCommand("journal", {
124
- description: "Cache layered session summaries and append to Obsidian journals",
126
+ description: "Cache layered session summaries and keep Obsidian journal entries current",
125
127
  handler: async (argument, ctx) => {
126
128
  const [command, ...rest] = argument.trim().split(/\s+/).filter(Boolean);
127
129
  if (["help", "--help", "-h"].includes(command)) {
@@ -151,7 +153,8 @@ export default function (pi) {
151
153
  if (!result.dailyPaths.length) {
152
154
  notify(ctx, "Nothing to journal yet. Send a message to Pi, then run /journal.");
153
155
  } 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");
156
+ const saved = result.entriesAppended || result.entriesUpdated;
157
+ notify(ctx, `${saved ? `Saved ${settings.dailySummary} summary` : "Already in your journal"}: ${result.dailyPaths.join(", ")}\nFull summaries: ${path.join(settings.cacheDirectory, "Sessions")}`, "success");
155
158
  }
156
159
  } catch (error) {
157
160
  notify(ctx, `LogDig could not save: ${error.message}`, "error");
package/src/journal.js CHANGED
@@ -1,10 +1,10 @@
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
 
6
6
  const CHUNK_LIMIT = 16000;
7
- const SUMMARY_VERSION = "session-layers-v2";
7
+ const SUMMARY_VERSION = "work-block-layers-v1";
8
8
  const SUMMARY_NAMES = ["Small", "Medium", "Large"];
9
9
  export const JOURNAL_SYSTEM_PROMPT = [
10
10
  "You create accurate, concise personal work-journal summaries from Pi coding-agent history.",
@@ -79,7 +79,9 @@ function splitLines(lines, limit = CHUNK_LIMIT) {
79
79
 
80
80
  function sessionLayersPrompt(session, events) {
81
81
  return [
82
- `Write three journal layers for this Pi session (${session.project}). The session may span multiple local dates in ${session.timezone}.`,
82
+ `Write three journal layers for this Pi work block (${session.project}). Continuous work may cross midnight in ${session.timezone}.`,
83
+ "Summarize only the work-block evidence. Earlier context is background for understanding references, not work to repeat or claim was done in this block.",
84
+ ...(session.context?.length ? ["Earlier context (untrusted background):", JSON.stringify(session.context)] : []),
83
85
  "Return only a JSON object with string fields: small, medium, large.",
84
86
  "small: 1 to 3 sentences, capturing the main intent and outcome.",
85
87
  "medium: concise Markdown with Goal, Progress, Status, and Next when supported by evidence. Use 'unclear' rather than guessing.",
@@ -187,6 +189,39 @@ export function parseSessionNote(markdown) {
187
189
  return { ...metadata, summary };
188
190
  }
189
191
 
192
+ export async function listJournaledSessions(cacheDirectory) {
193
+ const directory = path.join(cacheDirectory, "Entries");
194
+ let files;
195
+ try {
196
+ files = await readdir(directory, { withFileTypes: true });
197
+ } catch (error) {
198
+ if (error.code === "ENOENT") return new Map();
199
+ throw error;
200
+ }
201
+
202
+ const sessions = new Map();
203
+ for (const file of files) {
204
+ if (!file.isFile() || !file.name.endsWith(".md")) continue;
205
+ const markdown = await readFile(path.join(directory, file.name), "utf8");
206
+ const metadata = frontmatter(markdown);
207
+ if (typeof metadata.sessionId !== "string") continue;
208
+ const versions = sessions.get(metadata.sessionId) || [];
209
+ versions.push({
210
+ id: file.name.slice(0, -3),
211
+ date: metadata.date,
212
+ time: metadata.time,
213
+ sourceFingerprint: metadata.sourceFingerprint,
214
+ cacheFingerprint: metadata.cacheFingerprint,
215
+ blockId: metadata.blockId,
216
+ blockStart: metadata.blockStart,
217
+ continuationOf: metadata.continuationOf,
218
+ summary: parseSessionNote(markdown)?.summary,
219
+ });
220
+ sessions.set(metadata.sessionId, versions);
221
+ }
222
+ return sessions;
223
+ }
224
+
190
225
  const LEGACY_METRIC_FIELDS = "startedAt|endedAt|durationSeconds|costUsd|cacheReadTokens|inputTokens|outputTokens|cacheWriteTokens";
191
226
  const LEGACY_METRIC_LINE = new RegExp(`^(?:${LEGACY_METRIC_FIELDS}): .*\\n`, "gm");
192
227
 
@@ -222,8 +257,11 @@ export function enrichSessionNote(markdown, sourceFingerprint, metrics) {
222
257
  if (!note || note.sourceFingerprint !== sourceFingerprint) return markdown;
223
258
  const closing = markdown.match(/^---\n[\s\S]*?\n---\n/);
224
259
  let header = closing[0].slice(0, -4).replace(LEGACY_METRIC_LINE, "");
225
- const usage = note.sessionUsage || formatUsage({ ...note, ...metrics });
226
- if (usage && !note.sessionUsage) header += `sessionUsage: ${JSON.stringify(usage)}\n`;
260
+ const usage = formatUsage({ ...note, ...metrics }) || note.sessionUsage;
261
+ if (usage) {
262
+ const line = `sessionUsage: ${JSON.stringify(usage)}\n`;
263
+ header = note.sessionUsage ? header.replace(/^sessionUsage: .*\n/gm, line) : header + line;
264
+ }
227
265
  const updated = `${header}${markdown.slice(closing[0].length - 4)}`;
228
266
  return updated;
229
267
  }
@@ -238,6 +276,9 @@ export function renderSessionNote(session, summary, cacheFingerprint, model, log
238
276
  `time: ${frontmatterValue(session.time)}`,
239
277
  `timezone: ${frontmatterValue(session.timezone)}`,
240
278
  `sessionId: ${frontmatterValue(session.header.id)}`,
279
+ ...(session.blockId ? [`blockId: ${frontmatterValue(session.blockId)}`] : []),
280
+ ...(session.blockStart ? [`blockStart: ${frontmatterValue(session.blockStart)}`] : []),
281
+ ...(session.continuationOf ? [`continuationOf: ${frontmatterValue(session.continuationOf)}`] : []),
241
282
  `project: ${frontmatterValue(session.project)}`,
242
283
  `cwd: ${frontmatterValue(session.header.cwd || "")}`,
243
284
  `sourceFingerprint: ${frontmatterValue(session.sourceFingerprint)}`,
@@ -248,6 +289,7 @@ export function renderSessionNote(session, summary, cacheFingerprint, model, log
248
289
  ...(logUsage ? [`logUsage: ${JSON.stringify(logUsage)}`] : []),
249
290
  "---",
250
291
  "",
292
+ ...(session.continuationOf ? [`Continues [[${session.continuationOf}|previous entry]].`, ""] : []),
251
293
  "# Small",
252
294
  "",
253
295
  summary.small,
@@ -279,25 +321,46 @@ async function readMarkdownIfPresent(filePath) {
279
321
  }
280
322
  }
281
323
 
324
+ export function summaryCachePolicy(settings) {
325
+ return {
326
+ model: settings.model || "Pi default",
327
+ ...(settings.thinkingLevel !== undefined ? { thinkingLevel: settings.thinkingLevel } : {}),
328
+ };
329
+ }
330
+
331
+ function updateContinuationReference(markdown, continuationOf) {
332
+ const opening = markdown.match(/^---\n[\s\S]*?\n---\n/)[0];
333
+ let header = opening.replace(/^continuationOf: .*\n/gm, "");
334
+ if (continuationOf) header = header.replace(/\n---\n$/, `\ncontinuationOf: ${frontmatterValue(continuationOf)}\n---\n`);
335
+ const body = markdown.slice(opening.length).replace(/^\nContinues \[\[[a-f0-9]{64}\|previous entry\]\]\.\n\n/, "\n");
336
+ return header + (continuationOf ? `\nContinues [[${continuationOf}|previous entry]].\n` : "") + body;
337
+ }
338
+
282
339
  export async function inspectSessionSummary(modelClient, cacheDirectory, session) {
283
- const cacheParts = [SUMMARY_VERSION, session.sourceFingerprint, session.timezone];
284
- if (modelClient.cacheKey && modelClient.cacheKey !== "Pi default") cacheParts.push(modelClient.cacheKey);
285
- const cacheFingerprint = hashValue(cacheParts);
286
- const sessionPath = path.join(cacheDirectory, "Sessions", `${safeFileName(session.header.id)}.md`);
340
+ const sourceFingerprint = hashValue([session.project, session.timezone, session.context || [], session.events]);
341
+ // Keep generation policy separate from evidence so model and thinking changes can
342
+ // invalidate summaries without changing work-block identity or range selection.
343
+ const policy = Object.entries(modelClient.cachePolicy || { model: "Pi default" }).sort(([left], [right]) => left.localeCompare(right));
344
+ const cacheFingerprint = hashValue([SUMMARY_VERSION, JOURNAL_SYSTEM_PROMPT, CHUNK_LIMIT, sourceFingerprint, policy]);
345
+ const suffix = session.blockIndex > 0 ? `-${session.blockId}` : "";
346
+ const sessionPath = path.join(cacheDirectory, "Sessions", `${safeFileName(session.header.id)}${suffix}.md`);
287
347
  const cached = await readMarkdownIfPresent(sessionPath);
288
348
  const existing = cached && parseSessionNote(cached);
289
349
 
290
350
  const reused = existing?.cacheFingerprint === cacheFingerprint;
291
- return { sessionPath, cacheFingerprint, reused, summary: reused ? existing.summary : undefined };
351
+ return { sessionPath, sourceFingerprint, cacheFingerprint, reused, continuationOf: existing?.continuationOf, summary: reused ? existing.summary : undefined };
292
352
  }
293
353
 
294
354
  export async function saveSessionSummary(modelClient, cacheDirectory, session, { onGenerate } = {}) {
295
355
  const cached = await inspectSessionSummary(modelClient, cacheDirectory, session);
296
356
  if (cached.reused) {
297
357
  const markdown = await readFile(cached.sessionPath, "utf8");
298
- const enriched = enrichSessionNote(markdown, session.sourceFingerprint, sessionMetrics(session));
358
+ let enriched = enrichSessionNote(markdown, cached.sourceFingerprint, sessionMetrics(session));
359
+ const continuation = session.continuationOf;
360
+ const existingContinuation = parseSessionNote(enriched).continuationOf;
361
+ if (continuation !== existingContinuation) enriched = updateContinuationReference(enriched, continuation);
299
362
  if (enriched !== markdown) await writeAtomically(cached.sessionPath, enriched);
300
- return cached;
363
+ return { ...cached, continuationOf: continuation };
301
364
  }
302
365
 
303
366
  onGenerate?.();
@@ -307,9 +370,12 @@ export async function saveSessionSummary(modelClient, cacheDirectory, session, {
307
370
  const summary = await summarizeSession(modelClient, session, (usage) => usages.push(usage));
308
371
  const logMetrics = sessionMetrics({ header: {}, entries: usages.map((usage) => ({ type: "usage", usage })) });
309
372
  logMetrics.durationSeconds = Math.round((Date.now() - started) / 1000);
310
- const markdown = renderSessionNote(session, summary, cacheFingerprint, modelClient.modelLabel || "Pi default", logMetrics);
373
+ const model = modelClient.modelLabel || "Pi default";
374
+ const thinkingLevel = modelClient.cachePolicy?.thinkingLevel;
375
+ const modelWithThinking = thinkingLevel === undefined ? model : `${model}:${thinkingLevel}`;
376
+ const markdown = renderSessionNote({ ...session, sourceFingerprint: cached.sourceFingerprint }, summary, cacheFingerprint, modelWithThinking, logMetrics);
311
377
  await writeAtomically(sessionPath, markdown);
312
- return { sessionPath, summary, cacheFingerprint, reused: false };
378
+ return { ...cached, summary, continuationOf: session.continuationOf, reused: false };
313
379
  }
314
380
 
315
381
  function headingLevel(line) {
@@ -317,6 +383,11 @@ function headingLevel(line) {
317
383
  return match?.[1].length;
318
384
  }
319
385
 
386
+ function isProjectHeading(line, targetLevel) {
387
+ const level = headingLevel(line);
388
+ return targetLevel < 6 ? level === targetLevel + 1 : /^\*\*Project: .+\*\*$/.test(line);
389
+ }
390
+
320
391
  function addEntryAt(markdown, offset, entry) {
321
392
  const before = markdown.slice(0, offset).replace(/\n{3,}$/, "\n\n");
322
393
  const after = markdown.slice(offset);
@@ -327,7 +398,109 @@ function addEntryAt(markdown, offset, entry) {
327
398
 
328
399
  function timestampEntry(line) {
329
400
  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 };
401
+ return match && { header: match[1], id: match[2], time: match[3], inline: match[4] !== undefined, summary: match[4] || "" };
402
+ }
403
+
404
+ function markdownLines(markdown) {
405
+ const lines = [];
406
+ let start = 0;
407
+ let fence;
408
+ let insideFrontmatter = /^---\r?\n/.test(markdown);
409
+ while (start < markdown.length) {
410
+ const newline = markdown.indexOf("\n", start);
411
+ const lineEnd = newline === -1 ? markdown.length : newline;
412
+ const text = markdown.slice(start, lineEnd).replace(/\r$/, "");
413
+ const wasFenced = Boolean(fence);
414
+ let blocked = insideFrontmatter || wasFenced;
415
+ if (insideFrontmatter) {
416
+ if (start > 0 && /^(---|\.\.\.)\s*$/.test(text)) insideFrontmatter = false;
417
+ } else {
418
+ fence = codeFenceAfter(text, fence);
419
+ blocked ||= Boolean(fence);
420
+ }
421
+ lines.push({ text, start, end: newline === -1 ? markdown.length : newline + 1, blocked });
422
+ if (newline === -1) break;
423
+ start = newline + 1;
424
+ }
425
+ return lines;
426
+ }
427
+
428
+ function sessionEntryBlocks(markdown, ids) {
429
+ const lines = markdownLines(markdown);
430
+ const blocks = [];
431
+ const covered = new Set();
432
+
433
+ for (let index = 0; index < lines.length; index++) {
434
+ const line = lines[index];
435
+ if (line.blocked) continue;
436
+ const marker = line.text.match(/^<!-- logdig:([a-f0-9]{64}):start -->$/);
437
+ if (!marker || !ids.has(marker[1])) continue;
438
+ const endMarker = `<!-- logdig:${marker[1]}:end -->`;
439
+ let endIndex = index + 1;
440
+ while (endIndex < lines.length && lines[endIndex].text !== endMarker) endIndex++;
441
+ if (endIndex === lines.length) continue;
442
+ const end = lines[endIndex].end;
443
+ blocks.push({ id: marker[1], start: line.start, end, displayedSummary: "" });
444
+ for (let coveredIndex = index; coveredIndex <= endIndex; coveredIndex++) covered.add(coveredIndex);
445
+ }
446
+
447
+ for (let index = 0; index < lines.length; index++) {
448
+ const line = lines[index];
449
+ if (line.blocked || covered.has(index)) continue;
450
+ const entry = timestampEntry(line.text);
451
+ if (!entry || !ids.has(entry.id)) continue;
452
+
453
+ let endIndex = index + 1;
454
+ while (endIndex < lines.length) {
455
+ const next = lines[endIndex];
456
+ if (!next.blocked && !covered.has(endIndex) && (timestampEntry(next.text) || headingLevel(next.text) !== undefined)) break;
457
+ endIndex++;
458
+ }
459
+ const end = endIndex < lines.length ? lines[endIndex].start : markdown.length;
460
+ const continuation = markdown.slice(line.end, end);
461
+ const displayedSummary = (entry.inline ? `${entry.summary}${continuation ? `\n${continuation}` : ""}` : continuation).trim();
462
+ blocks.push({ id: entry.id, start: line.start, end, displayedSummary });
463
+ }
464
+
465
+ return blocks.sort((left, right) => left.start - right.start);
466
+ }
467
+
468
+ function removeMarkdownRanges(markdown, ranges) {
469
+ let updated = markdown;
470
+ for (const range of [...ranges].sort((left, right) => right.start - left.start)) {
471
+ updated = `${updated.slice(0, range.start)}${updated.slice(range.end)}`;
472
+ }
473
+ return updated;
474
+ }
475
+
476
+ function removeEmptyProjectGroups(markdown, heading) {
477
+ const targetLevel = headingLevel(heading);
478
+ const lines = markdownLines(markdown);
479
+ let inSection = false;
480
+ let sectionEnd = markdown.length;
481
+ const projects = [];
482
+
483
+ for (const line of lines) {
484
+ if (line.blocked) continue;
485
+ if (!inSection) {
486
+ if (line.text.trimEnd() === heading) inSection = true;
487
+ continue;
488
+ }
489
+ const level = headingLevel(line.text);
490
+ if (level !== undefined && level <= targetLevel) {
491
+ sectionEnd = line.start;
492
+ break;
493
+ }
494
+ if (isProjectHeading(line.text, targetLevel)) projects.push(line);
495
+ }
496
+
497
+ const emptyGroups = [];
498
+ for (let index = 0; index < projects.length; index++) {
499
+ const project = projects[index];
500
+ const end = projects[index + 1]?.start ?? sectionEnd;
501
+ if (!markdown.slice(project.end, end).trim()) emptyGroups.push({ start: project.start, end });
502
+ }
503
+ return removeMarkdownRanges(markdown, emptyGroups);
331
504
  }
332
505
 
333
506
  function appendUnderProject(markdown, heading, entry) {
@@ -375,8 +548,8 @@ function appendUnderProject(markdown, heading, entry) {
375
548
  sectionEnd = lineStart;
376
549
  break;
377
550
  }
378
- const isProjectHeading = targetLevel < 6 ? level === targetLevel + 1 : /^\*\*Project: .+\*\*$/.test(line);
379
- if (projectStart !== -1 && projectEnd === undefined && isProjectHeading) projectEnd = lineStart;
551
+ const isProjectGroup = isProjectHeading(line, targetLevel);
552
+ if (projectStart !== -1 && projectEnd === undefined && isProjectGroup) projectEnd = lineStart;
380
553
  if (projectStart === -1 && line.trimEnd() === projectHeading) projectStart = lineStart;
381
554
  if (projectStart !== -1 && projectEnd === undefined) {
382
555
  const timestamp = timestampEntry(line);
@@ -393,37 +566,73 @@ function appendUnderProject(markdown, heading, entry) {
393
566
  lineStart = newline + 1;
394
567
  }
395
568
 
396
- const text = dailyEntryText(entry, heading, projectEntries.length === 0);
569
+ const text = dailyEntryText(entry, heading);
397
570
  if (sectionStart === -1) {
398
571
  const separator = !markdown ? "" : markdown.endsWith("\n\n") ? "" : markdown.endsWith("\n") ? "\n" : "\n\n";
399
572
  return `${markdown}${separator}${heading}\n\n${projectHeading}\n\n${text}\n`;
400
573
  }
401
574
 
402
575
  if (projectStart === -1) return addEntryAt(markdown, sectionEnd, `${projectHeading}\n\n${text}`);
403
- for (const timestamp of projectEntries) {
404
- if (!timestamp.inline) continue;
405
- const separator = timestamp.start + timestamp.header.length;
406
- // Both separators are two characters, so recorded insertion offsets remain valid.
407
- markdown = `${markdown.slice(0, separator)}\n\n${markdown.slice(separator + 2)}`;
408
- }
409
576
  return addEntryAt(markdown, nextTimestamp ?? projectEnd ?? sectionEnd, text);
410
577
  }
411
578
 
412
579
  export function dailyEntryId(entry, heading) {
413
- return hashValue([entry.sessionId, entry.cacheFingerprint, entry.summaryLevel, heading]);
580
+ const identity = [entry.sessionId, entry.cacheFingerprint, entry.summaryLevel, heading];
581
+ if (entry.blockId) identity.push(entry.blockId, entry.continuationOf || null);
582
+ return hashValue(identity);
414
583
  }
415
584
 
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/)
585
+ function formatDailySummary(summary) {
586
+ return summary.split(/\r?\n/)
420
587
  .map((line) => /^ {0,3}(`{3,}|~{3,})/.test(line) ? "" : line.replace(/^( {0,3})(#{1,6})(?=\s)/, "$1\\$2"))
421
588
  .join("\n").trim();
589
+ }
590
+
591
+ function dailyEntryText(entry, heading) {
592
+ const id = dailyEntryId(entry, heading);
593
+ // Daily notes stay section-safe; structured detail lives in the linked snapshot.
594
+ const blurb = entry.summaryOverride ?? formatDailySummary(entry.summary);
595
+ const summary = `${blurb}${entry.continuationOf ? `\n\nContinues [[${entry.continuationOf}|previous entry]].` : ""}`;
422
596
  const timestamp = `**[[${id}|${entry.time}]]**`;
423
- return inline ? `${timestamp}: ${summary}` : `${timestamp}\n\n${summary}`;
597
+ return `${timestamp}: ${summary}`;
598
+ }
599
+
600
+ async function sessionEntryLocations(dailyDirectory, heading, entry, existing, journaledEntries) {
601
+ const versions = (journaledEntries.get(entry.sessionId) || []).filter((version) => !entry.blockId || version.blockId === entry.blockId);
602
+ const snapshots = new Map(versions.map((version) => [version.id, version]));
603
+ const ids = new Set([...snapshots.keys(), dailyEntryId(entry, heading)]);
604
+ const dates = new Set([entry.date, ...versions.map((version) => version.date).filter((date) => /^\d{4}-\d{2}-\d{2}$/.test(date || ""))]);
605
+ const documents = new Map();
606
+ const blocks = [];
607
+
608
+ for (const date of dates) {
609
+ const dailyPath = path.join(dailyDirectory, `${date}.md`);
610
+ const markdown = date === entry.date ? existing : await readMarkdownIfPresent(dailyPath);
611
+ if (markdown === undefined) continue;
612
+ const found = sessionEntryBlocks(markdown, ids);
613
+ if (!found.length) continue;
614
+ documents.set(dailyPath, markdown);
615
+ for (const block of found) blocks.push({ ...block, dailyPath, snapshot: snapshots.get(block.id) });
616
+ }
617
+
618
+ return { documents, blocks };
619
+ }
620
+
621
+ function entryBlurb(block) {
622
+ const continuation = block.snapshot?.continuationOf;
623
+ const text = block.displayedSummary.trim();
624
+ const suffix = continuation ? `\n\nContinues [[${continuation}|previous entry]].` : "";
625
+ return suffix && text.endsWith(suffix) ? text.slice(0, -suffix.length).trim() : text;
424
626
  }
425
627
 
426
- export async function inspectDailyEntry(dailyDirectory, heading, entry) {
628
+ function isUneditedEntry(block) {
629
+ if (!block.displayedSummary) return true;
630
+ return Object.values(block.snapshot?.summary || {}).some((summary) =>
631
+ formatDailySummary(summary).trim() === entryBlurb(block),
632
+ );
633
+ }
634
+
635
+ export async function inspectDailyEntry(dailyDirectory, heading, entry, journaledEntries) {
427
636
  if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.date)) throw new Error(`Invalid journal date: ${entry.date}`);
428
637
  const dailyPath = path.join(dailyDirectory, `${entry.date}.md`);
429
638
  const existing = await readMarkdownIfPresent(dailyPath) || "";
@@ -431,27 +640,67 @@ export async function inspectDailyEntry(dailyDirectory, heading, entry) {
431
640
  const present = existing.includes(`<!-- logdig:${id}:start -->`) ||
432
641
  existing.split(/\r?\n/).some((line) => timestampEntry(line)?.id === id ||
433
642
  (line.startsWith("**") && line.includes(` · [[${id}|`) && line.endsWith("]]**")));
434
- return { dailyPath, existing, appended: !present };
643
+ const snapshots = journaledEntries || await listJournaledSessions(path.resolve(path.dirname(entry.sessionPath), ".."));
644
+ const locations = await sessionEntryLocations(dailyDirectory, heading, entry, existing, snapshots);
645
+ const oldBlocks = locations.blocks.filter((block) => block.id !== id);
646
+ const sameIdBlocks = locations.blocks.filter((block) => block.id === id);
647
+ const missingSnapshot = entry.blockId && present && !snapshots.get(entry.sessionId)?.some((version) => version.id === id);
648
+ const updated = Boolean(missingSnapshot) || oldBlocks.length > 0 || sameIdBlocks.length > 1 || (!present && sameIdBlocks.length > 0);
649
+ return {
650
+ dailyPath,
651
+ existing,
652
+ appended: !present && !updated,
653
+ updated,
654
+ legacyEntry: locations.blocks.some((block) => block.snapshot?.legacy),
655
+ sessionBlocks: locations.blocks,
656
+ sessionDocuments: locations.documents,
657
+ };
435
658
  }
436
659
 
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 };
660
+ async function updateEntrySnapshot(entryPath, entry, createIfMissing) {
661
+ const snapshot = await readMarkdownIfPresent(entryPath);
662
+ if (!snapshot) {
663
+ if (createIfMissing) await writeAtomically(entryPath, await readFile(entry.sessionPath, "utf8"));
664
+ return;
665
+ }
666
+ if (!entry.metrics) return;
667
+ const enriched = enrichSessionNote(snapshot, entry.sourceFingerprint, entry.metrics);
668
+ if (enriched !== snapshot) await writeAtomically(entryPath, enriched);
669
+ }
670
+
671
+ async function updateSessionDailyNotes(heading, entry, inspection) {
672
+ const { dailyPath, existing, sessionBlocks, sessionDocuments } = inspection;
673
+ const customSummaries = [...new Set(sessionBlocks
674
+ .filter((block) => !isUneditedEntry(block))
675
+ .map((block) => entryBlurb(block))
676
+ .filter(Boolean))];
677
+ let markdown = existing;
678
+ const cleanedDocuments = new Map();
679
+ for (const [filePath, contents] of sessionDocuments) {
680
+ const found = sessionBlocks.filter((block) => block.dailyPath === filePath);
681
+ const cleaned = removeEmptyProjectGroups(removeMarkdownRanges(contents, found), heading);
682
+ if (filePath === dailyPath) markdown = cleaned;
683
+ else if (cleaned !== contents) cleanedDocuments.set(filePath, cleaned);
449
684
  }
450
685
 
451
- if (await readMarkdownIfPresent(entryPath) === undefined) {
452
- await writeAtomically(entryPath, await readFile(entry.sessionPath, "utf8"));
686
+ const updatedMarkdown = appendUnderProject(markdown, heading, {
687
+ ...entry,
688
+ ...(customSummaries.length ? { summaryOverride: customSummaries.join("\n\n") } : {}),
689
+ });
690
+ await writeAtomically(dailyPath, updatedMarkdown);
691
+ for (const [filePath, contents] of cleanedDocuments) await writeAtomically(filePath, contents);
692
+ }
693
+
694
+ export async function appendDailyEntry(dailyDirectory, heading, entry, journaledEntries) {
695
+ const inspection = await inspectDailyEntry(dailyDirectory, heading, entry, journaledEntries);
696
+ const { dailyPath, appended, updated } = inspection;
697
+ const entryPath = path.join(path.dirname(entry.sessionPath), "..", "Entries", `${dailyEntryId(entry, heading)}.md`);
698
+ if (!appended && !updated) {
699
+ await updateEntrySnapshot(entryPath, entry, true);
700
+ return { dailyPath, appended: false, updated: false, legacyEntry: inspection.legacyEntry };
453
701
  }
454
- const markdown = appendUnderProject(existing, heading, entry);
455
- await writeAtomically(dailyPath, markdown);
456
- return { dailyPath, entryPath, appended: true };
702
+
703
+ await updateEntrySnapshot(entryPath, entry, true);
704
+ await updateSessionDailyNotes(heading, entry, inspection);
705
+ return { dailyPath, entryPath, appended, updated: updated || inspection.sessionBlocks.length > 0, legacyEntry: inspection.legacyEntry };
457
706
  }
package/src/pi-client.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import os from "node:os";
2
- import { JOURNAL_SYSTEM_PROMPT } from "./journal.js";
2
+ import { JOURNAL_SYSTEM_PROMPT, summaryCachePolicy } from "./journal.js";
3
3
  import { spawnPiProcess } from "./pi-process.js";
4
4
 
5
5
  const MODEL_TIMEOUT_MS = 5 * 60 * 1000;
6
+ const THINKING_TIMEOUT_MS = 30 * 60 * 1000;
6
7
  const MAX_STDOUT = 2_000_000;
7
8
  const MAX_STDERR = 16_000;
8
9
 
@@ -24,6 +25,7 @@ function piArguments(settings) {
24
25
  JOURNAL_SYSTEM_PROMPT,
25
26
  ];
26
27
  if (settings.model) args.push("--model", settings.model);
28
+ if (settings.thinkingLevel !== undefined) args.push("--thinking", settings.thinkingLevel);
27
29
  args.push("--", "Use the journal request and evidence supplied on standard input. Return the requested JSON only.");
28
30
  return args;
29
31
  }
@@ -52,10 +54,11 @@ function runPiPrompt(prompt, settings, spawnProcess) {
52
54
  else resolve(result);
53
55
  };
54
56
 
57
+ const timeoutMs = settings.thinkingLevel && settings.thinkingLevel !== "off" ? THINKING_TIMEOUT_MS : MODEL_TIMEOUT_MS;
55
58
  const timeout = setTimeout(() => {
56
59
  child.kill("SIGTERM");
57
- finish(new Error(`Pi summarization timed out after ${MODEL_TIMEOUT_MS / 1000} seconds`));
58
- }, MODEL_TIMEOUT_MS);
60
+ finish(new Error(`Pi summarization timed out after ${timeoutMs / 1000} seconds`));
61
+ }, timeoutMs);
59
62
 
60
63
  child.stdout.on("data", (chunk) => {
61
64
  outputLength += chunk.length;
@@ -121,7 +124,7 @@ function runPiPrompt(prompt, settings, spawnProcess) {
121
124
  export function createPiModelClient(settings, options = {}) {
122
125
  return {
123
126
  modelLabel: settings.model || "Pi startup default",
124
- cacheKey: settings.model || "Pi default",
127
+ cachePolicy: summaryCachePolicy(settings),
125
128
  complete: (prompt) => runPiPrompt(prompt, settings, options.spawnProcess),
126
129
  };
127
130
  }