logdig 0.1.1 → 0.2.0

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.
@@ -0,0 +1,135 @@
1
+ import { createInterface } from "node:readline";
2
+ import { stdin, stdout } from "node:process";
3
+ import { checkDirectory } from "./directories.js";
4
+ import { environmentOverrideFor, loadSettings, saveSettings, validateSettings } from "./settings.js";
5
+ import { statusPrefix } from "./cli-status.js";
6
+
7
+ const fields = [
8
+ { key: "dailyDirectory", label: "Daily-notes folder", folder: "write",
9
+ hint: "The folder holding YYYY-MM-DD.md notes, not the vault root." },
10
+ { key: "cacheDirectory", label: "Summary cache folder", folder: "write",
11
+ hint: "Keeps all three summary lengths. Put it inside your vault to browse them in Obsidian." },
12
+ { key: "dailyHeader", label: "Journal heading",
13
+ hint: "One Markdown heading, including its # level, for example # Projects." },
14
+ { key: "dailyHeaderAnchor", label: "Anchor heading", empty: "none",
15
+ hint: "Create the journal section after this heading's full section. Type none to append at the end. Existing sections stay in place." },
16
+ { key: "dailySummary", label: "Daily summary length", choices: ["small", "medium", "large"],
17
+ hint: "Small: a few sentences. Medium: goal, progress, and next steps. Large: a short timeline." },
18
+ { key: "timeZone", label: "Timezone",
19
+ hint: "Determines journal dates and times, for example Europe/Berlin or UTC." },
20
+ { key: "sessionDirectory", label: "Pi session-history folder", folder: "read",
21
+ hint: "The folder containing saved Pi sessions. Missing folders are allowed." },
22
+ { key: "piCommand", label: "Pi executable",
23
+ hint: "A command on your PATH, such as pi, or a full executable path." },
24
+ { key: "model", label: "Summary model", empty: "default",
25
+ hint: "Use provider/model, or type default to remove the LogDig override. Changing the model can require new summaries." },
26
+ { key: "thinkingLevel", label: "Thinking level", empty: "default",
27
+ choices: ["default", "off", "minimal", "low", "medium", "high", "xhigh", "max"],
28
+ hint: "More thinking may take longer and cost more. Model support varies. Default removes the LogDig override." },
29
+ { key: "concurrency", label: "Maximum parallel sessions",
30
+ hint: "A positive integer. Use 1 for sequential processing or to reduce rate-limit pressure." },
31
+ { key: "autoCapture", label: "Automatic capture", choices: ["yes", "no"],
32
+ hint: "Capture on Pi shutdown. Requires the Pi extension; sends selected, redacted history to your model, may incur charges, and can delay shutdown. Redaction is not a complete secret scanner." },
33
+ ];
34
+
35
+ function displayValue(field, value) {
36
+ if (typeof value === "boolean") return value ? "yes" : "no";
37
+ return String(value || field.empty || "not configured");
38
+ }
39
+
40
+ export async function editConfig(commandName = "logdig", { input = stdin, output = stdout, env = process.env } = {}) {
41
+ let settings = await loadSettings({ env, applyEnvironment: false });
42
+ const print = (text = "") => output.write(`${text}\n`);
43
+ const labelWidth = Math.max(...fields.map((field) => field.label.length));
44
+ const showMenu = () => {
45
+ print("\nLogDig settings");
46
+ print(`Settings: ${settings.filePath}`);
47
+ print("Values below are saved preferences (or defaults). Environment overrides take precedence.");
48
+ print();
49
+ fields.forEach((field, index) => {
50
+ const variable = environmentOverrideFor(field.key, env);
51
+ print(` ${String(index + 1).padStart(2)}. ${field.label.padEnd(labelWidth)} ${displayValue(field, settings[field.key])}`);
52
+ if (variable) print(` Override: ${variable}=${JSON.stringify(env[variable])} (saved changes will not override it)`);
53
+ });
54
+ print("\nChanges save immediately. No summaries or daily notes are written here.");
55
+ };
56
+ showMenu();
57
+ if (!settings.dailyDirectory || !settings.cacheDirectory) {
58
+ print(`\nRun '${commandName} init' first to configure your notes and cache folders.`);
59
+ return;
60
+ }
61
+
62
+ const rl = createInterface({ input, output, terminal: Boolean(output.isTTY) && env.TERM !== "dumb" });
63
+ const answers = rl[Symbol.asyncIterator]();
64
+ let interrupted = false;
65
+ const cancel = () => { interrupted = true; rl.close(); };
66
+ rl.on("SIGINT", cancel);
67
+ process.once("SIGINT", cancel);
68
+ const checkInterrupted = () => {
69
+ if (!interrupted) return;
70
+ const error = new Error("Configuration cancelled. Previously saved changes are kept.");
71
+ error.code = "LOGDIG_CONFIG_CANCELLED";
72
+ throw error;
73
+ };
74
+ const ask = async (prompt) => {
75
+ rl.setPrompt(prompt);
76
+ rl.prompt();
77
+ const answer = await answers.next();
78
+ checkInterrupted();
79
+ return answer.done ? "q" : answer.value.trim();
80
+ };
81
+
82
+ try {
83
+ while (true) {
84
+ const selection = await ask("\nChoose a setting by number or name (q to quit): ");
85
+ if (selection.toLowerCase() === "q") break;
86
+ if (!selection) continue;
87
+ const matches = /^\d+$/.test(selection)
88
+ ? fields.filter((_, index) => index + 1 === Number(selection))
89
+ : fields.filter((field) => `${field.label} ${field.key}`.toLowerCase().includes(selection.toLowerCase()));
90
+ if (matches.length !== 1) {
91
+ print(matches.length
92
+ ? `Please be more specific: ${matches.map((field) => `${fields.indexOf(field) + 1}. ${field.label}`).join("; ")}.`
93
+ : `Choose a number from 1 to ${fields.length}, a setting name, or q.`);
94
+ continue;
95
+ }
96
+ const field = matches[0];
97
+ print(`\n${field.label}`);
98
+ print(field.hint);
99
+ if (field.choices) print(`Available values: ${field.choices.join(", ")}`);
100
+ print("Enter keeps the current value. q quits without changing this field.");
101
+ while (true) {
102
+ const value = await ask(`New value [${displayValue(field, settings[field.key])}]: `);
103
+ if (value.toLowerCase() === "q") return;
104
+ if (!value) {
105
+ print("Kept the current value.");
106
+ break;
107
+ }
108
+ let nextSettings;
109
+ try {
110
+ let parsed = value;
111
+ if (field.key === "autoCapture") {
112
+ if (!/^(yes|no|y|n)$/i.test(value)) throw new Error("Please enter yes or no");
113
+ parsed = /^(yes|y)$/i.test(value);
114
+ } else if (field.key === "model" && value.toLowerCase() === "default") parsed = undefined;
115
+ else if (field.key === "dailyHeaderAnchor" && value.toLowerCase() === "none") parsed = "";
116
+ nextSettings = validateSettings({ ...settings, [field.key]: parsed });
117
+ if (field.folder) await checkDirectory(nextSettings[field.key], { writable: field.folder === "write", allowMissing: true });
118
+ } catch (error) {
119
+ print(` ${error.message}. Let's try that again.`);
120
+ continue;
121
+ }
122
+ checkInterrupted();
123
+ await saveSettings(nextSettings, settings.filePath);
124
+ settings = nextSettings;
125
+ print(`\n${statusPrefix("ok", { isTTY: output.isTTY, term: env.TERM, icons: env.LOGDIG_ICONS !== "0", noColor: env.NO_COLOR !== undefined })}${field.label} saved: ${displayValue(field, settings[field.key])}`);
126
+ if (field.key === "autoCapture" && settings.autoCapture) print(`If the Pi extension is not installed, run '${commandName} pi-install'.`);
127
+ break;
128
+ }
129
+ showMenu();
130
+ }
131
+ } finally {
132
+ process.removeListener("SIGINT", cancel);
133
+ rl.close();
134
+ }
135
+ }
package/src/index.js CHANGED
@@ -1,9 +1,9 @@
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";
6
+ import { progressStatus } from "./cli-status.js";
7
7
 
8
8
  function requireJournalSettings(settings) {
9
9
  if (!settings.cacheDirectory) throw new Error("Run 'logdig init' or set PI_JOURNAL_DIR to a LogDig cache folder");
@@ -23,7 +23,6 @@ function currentSession(ctx) {
23
23
  entries: ctx.sessionManager.getEntries(),
24
24
  sourcePath: ctx.sessionManager.getSessionFile() || "active Pi session",
25
25
  };
26
- session.fingerprint = fingerprintSession(session);
27
26
  return session;
28
27
  }
29
28
 
@@ -37,26 +36,29 @@ function extensionModelClient(ctx, settings) {
37
36
 
38
37
  return {
39
38
  modelLabel: model ? `${model.provider}/${model.id}` : "Pi current model",
40
- cacheKey: settings.model || "Pi default",
39
+ cachePolicy: summaryCachePolicy(settings),
41
40
  complete: async (prompt) => {
42
41
  if (!model) throw new Error("Pi has no active model for journal generation");
43
42
  if (!ctx.modelRegistry.hasConfiguredAuth(model)) {
44
43
  throw new Error(`No configured authentication for ${model.provider}/${model.id}. Run /login in Pi, then try /journal again`);
45
44
  }
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
- );
45
+ const context = {
46
+ systemPrompt: JOURNAL_SYSTEM_PROMPT,
47
+ messages: [
48
+ {
49
+ role: "user",
50
+ content: [{ type: "text", text: prompt }],
51
+ timestamp: Date.now(),
52
+ },
53
+ ],
54
+ };
55
+ const options = { sessionId: randomUUID(), cacheRetention: "none", signal: ctx.signal };
56
+ // Preserve existing provider defaults unless LogDig explicitly selects thinking.
57
+ if (settings.thinkingLevel === undefined) return ctx.modelRegistry.complete(model, context, options);
58
+ return ctx.modelRegistry.streamSimple(model, context, {
59
+ ...options,
60
+ reasoning: settings.thinkingLevel === "off" ? undefined : settings.thinkingLevel,
61
+ }).result();
60
62
  },
61
63
  };
62
64
  }
@@ -67,10 +69,11 @@ function notify(ctx, message, level = "info") {
67
69
  }
68
70
 
69
71
  function journalProgress(ctx) {
70
- return ({ index, total, project, status, error, phase }) => {
71
- const message = `LogDig [${index}/${total}]${project ? ` ${project}` : ""}: ${error || status}`;
72
+ return ({ index, total, sessionId, project, date, time, status }) => {
73
+ const label = `${date ? `${date}${time ? ` ${time}` : ""} · ` : ""}${project || sessionId}`;
74
+ const message = `LogDig [${index}/${total}] ${label} · ${progressStatus(status || "CHECKING")}`;
72
75
  if (ctx.hasUI) ctx.ui.setStatus?.("logdig", message);
73
- else if (["summarizing", "complete", "error"].includes(phase)) console.log(message);
76
+ else console.log(message);
74
77
  };
75
78
  }
76
79
 
@@ -88,7 +91,7 @@ async function runBackfill(ctx, argument) {
88
91
  days: range.days,
89
92
  currentSession: currentSession(ctx),
90
93
  });
91
- const client = dryRun ? { cacheKey: settings.model || "Pi default" } : extensionModelClient(ctx, settings);
94
+ const client = dryRun ? { cachePolicy: summaryCachePolicy(settings) } : extensionModelClient(ctx, settings);
92
95
  const result = await writeSessions(client, found.sessions, settings, { ...found, dryRun, onProgress: journalProgress(ctx) });
93
96
  const warnings = [...found.warnings, ...result.errors];
94
97
  const summaryCount = `${result.summariesCreated} ${result.summariesCreated === 1 ? "summary" : "summaries"}`;
package/src/journal.js CHANGED
@@ -4,7 +4,7 @@ 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.",
@@ -209,6 +211,10 @@ export async function listJournaledSessions(cacheDirectory) {
209
211
  date: metadata.date,
210
212
  time: metadata.time,
211
213
  sourceFingerprint: metadata.sourceFingerprint,
214
+ cacheFingerprint: metadata.cacheFingerprint,
215
+ blockId: metadata.blockId,
216
+ blockStart: metadata.blockStart,
217
+ continuationOf: metadata.continuationOf,
212
218
  summary: parseSessionNote(markdown)?.summary,
213
219
  });
214
220
  sessions.set(metadata.sessionId, versions);
@@ -251,8 +257,11 @@ export function enrichSessionNote(markdown, sourceFingerprint, metrics) {
251
257
  if (!note || note.sourceFingerprint !== sourceFingerprint) return markdown;
252
258
  const closing = markdown.match(/^---\n[\s\S]*?\n---\n/);
253
259
  let header = closing[0].slice(0, -4).replace(LEGACY_METRIC_LINE, "");
254
- const usage = note.sessionUsage || formatUsage({ ...note, ...metrics });
255
- 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
+ }
256
265
  const updated = `${header}${markdown.slice(closing[0].length - 4)}`;
257
266
  return updated;
258
267
  }
@@ -267,6 +276,9 @@ export function renderSessionNote(session, summary, cacheFingerprint, model, log
267
276
  `time: ${frontmatterValue(session.time)}`,
268
277
  `timezone: ${frontmatterValue(session.timezone)}`,
269
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)}`] : []),
270
282
  `project: ${frontmatterValue(session.project)}`,
271
283
  `cwd: ${frontmatterValue(session.header.cwd || "")}`,
272
284
  `sourceFingerprint: ${frontmatterValue(session.sourceFingerprint)}`,
@@ -277,6 +289,7 @@ export function renderSessionNote(session, summary, cacheFingerprint, model, log
277
289
  ...(logUsage ? [`logUsage: ${JSON.stringify(logUsage)}`] : []),
278
290
  "---",
279
291
  "",
292
+ ...(session.continuationOf ? [`Continues [[${session.continuationOf}|previous entry]].`, ""] : []),
280
293
  "# Small",
281
294
  "",
282
295
  summary.small,
@@ -308,25 +321,46 @@ async function readMarkdownIfPresent(filePath) {
308
321
  }
309
322
  }
310
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
+
311
339
  export async function inspectSessionSummary(modelClient, cacheDirectory, session) {
312
- const cacheParts = [SUMMARY_VERSION, session.sourceFingerprint, session.timezone];
313
- if (modelClient.cacheKey && modelClient.cacheKey !== "Pi default") cacheParts.push(modelClient.cacheKey);
314
- const cacheFingerprint = hashValue(cacheParts);
315
- 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`);
316
347
  const cached = await readMarkdownIfPresent(sessionPath);
317
348
  const existing = cached && parseSessionNote(cached);
318
349
 
319
350
  const reused = existing?.cacheFingerprint === cacheFingerprint;
320
- return { sessionPath, cacheFingerprint, reused, summary: reused ? existing.summary : undefined };
351
+ return { sessionPath, sourceFingerprint, cacheFingerprint, reused, continuationOf: existing?.continuationOf, summary: reused ? existing.summary : undefined };
321
352
  }
322
353
 
323
354
  export async function saveSessionSummary(modelClient, cacheDirectory, session, { onGenerate } = {}) {
324
355
  const cached = await inspectSessionSummary(modelClient, cacheDirectory, session);
325
356
  if (cached.reused) {
326
357
  const markdown = await readFile(cached.sessionPath, "utf8");
327
- 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);
328
362
  if (enriched !== markdown) await writeAtomically(cached.sessionPath, enriched);
329
- return cached;
363
+ return { ...cached, continuationOf: continuation };
330
364
  }
331
365
 
332
366
  onGenerate?.();
@@ -336,9 +370,12 @@ export async function saveSessionSummary(modelClient, cacheDirectory, session, {
336
370
  const summary = await summarizeSession(modelClient, session, (usage) => usages.push(usage));
337
371
  const logMetrics = sessionMetrics({ header: {}, entries: usages.map((usage) => ({ type: "usage", usage })) });
338
372
  logMetrics.durationSeconds = Math.round((Date.now() - started) / 1000);
339
- 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);
340
377
  await writeAtomically(sessionPath, markdown);
341
- return { sessionPath, summary, cacheFingerprint, reused: false };
378
+ return { ...cached, summary, continuationOf: session.continuationOf, reused: false };
342
379
  }
343
380
 
344
381
  function headingLevel(line) {
@@ -354,7 +391,7 @@ function isProjectHeading(line, targetLevel) {
354
391
  function addEntryAt(markdown, offset, entry) {
355
392
  const before = markdown.slice(0, offset).replace(/\n{3,}$/, "\n\n");
356
393
  const after = markdown.slice(offset);
357
- const separator = before.endsWith("\n\n") ? "" : before.endsWith("\n") ? "\n" : "\n\n";
394
+ const separator = /\r?\n\r?\n$/.test(before) ? "" : before.endsWith("\n") ? "\n" : "\n\n";
358
395
  const suffix = after ? "\n\n" : "\n";
359
396
  return `${before}${separator}${entry}${suffix}${after}`;
360
397
  }
@@ -466,7 +503,7 @@ function removeEmptyProjectGroups(markdown, heading) {
466
503
  return removeMarkdownRanges(markdown, emptyGroups);
467
504
  }
468
505
 
469
- function appendUnderProject(markdown, heading, entry) {
506
+ function appendUnderProject(markdown, heading, entry, anchorHeading) {
470
507
  const targetLevel = headingLevel(heading);
471
508
  const project = entry.project.replace(/[\\`*_[\]<>|&#\r\n]/g, (character) =>
472
509
  /[\r\n]/.test(character) ? " " : `&#${character.charCodeAt(0)};`);
@@ -529,24 +566,36 @@ function appendUnderProject(markdown, heading, entry) {
529
566
  lineStart = newline + 1;
530
567
  }
531
568
 
532
- const text = dailyEntryText(entry, heading, projectEntries.length === 0);
569
+ const text = dailyEntryText(entry, heading);
533
570
  if (sectionStart === -1) {
534
- const separator = !markdown ? "" : markdown.endsWith("\n\n") ? "" : markdown.endsWith("\n") ? "\n" : "\n\n";
535
- return `${markdown}${separator}${heading}\n\n${projectHeading}\n\n${text}\n`;
571
+ let anchorFound = false;
572
+ let offset = markdown.length;
573
+ const anchorLevel = headingLevel(anchorHeading || "");
574
+ if (anchorHeading) {
575
+ for (const line of markdownLines(markdown)) {
576
+ if (line.blocked) continue;
577
+ if (!anchorFound) {
578
+ if (line.text.trimEnd() === anchorHeading) anchorFound = true;
579
+ } else if (headingLevel(line.text) <= anchorLevel) {
580
+ offset = line.start;
581
+ break;
582
+ }
583
+ }
584
+ }
585
+ const section = `${heading}\n\n${projectHeading}\n\n${text}`;
586
+ if (offset < markdown.length) return addEntryAt(markdown, offset, section);
587
+ const separator = !markdown || /\r?\n\r?\n$/.test(markdown) ? "" : markdown.endsWith("\n") ? "\n" : "\n\n";
588
+ return `${markdown}${separator}${section}\n`;
536
589
  }
537
590
 
538
591
  if (projectStart === -1) return addEntryAt(markdown, sectionEnd, `${projectHeading}\n\n${text}`);
539
- for (const timestamp of projectEntries) {
540
- if (!timestamp.inline) continue;
541
- const separator = timestamp.start + timestamp.header.length;
542
- // Both separators are two characters, so recorded insertion offsets remain valid.
543
- markdown = `${markdown.slice(0, separator)}\n\n${markdown.slice(separator + 2)}`;
544
- }
545
592
  return addEntryAt(markdown, nextTimestamp ?? projectEnd ?? sectionEnd, text);
546
593
  }
547
594
 
548
595
  export function dailyEntryId(entry, heading) {
549
- return hashValue([entry.sessionId, entry.cacheFingerprint, entry.summaryLevel, heading]);
596
+ const identity = [entry.sessionId, entry.cacheFingerprint, entry.summaryLevel, heading];
597
+ if (entry.blockId) identity.push(entry.blockId, entry.continuationOf || null);
598
+ return hashValue(identity);
550
599
  }
551
600
 
552
601
  function formatDailySummary(summary) {
@@ -555,16 +604,17 @@ function formatDailySummary(summary) {
555
604
  .join("\n").trim();
556
605
  }
557
606
 
558
- function dailyEntryText(entry, heading, inline) {
607
+ function dailyEntryText(entry, heading) {
559
608
  const id = dailyEntryId(entry, heading);
560
609
  // Daily notes stay section-safe; structured detail lives in the linked snapshot.
561
- const summary = entry.summaryOverride ?? formatDailySummary(entry.summary);
610
+ const blurb = entry.summaryOverride ?? formatDailySummary(entry.summary);
611
+ const summary = `${blurb}${entry.continuationOf ? `\n\nContinues [[${entry.continuationOf}|previous entry]].` : ""}`;
562
612
  const timestamp = `**[[${id}|${entry.time}]]**`;
563
- return inline ? `${timestamp}: ${summary}` : `${timestamp}\n\n${summary}`;
613
+ return `${timestamp}: ${summary}`;
564
614
  }
565
615
 
566
616
  async function sessionEntryLocations(dailyDirectory, heading, entry, existing, journaledEntries) {
567
- const versions = journaledEntries.get(entry.sessionId) || [];
617
+ const versions = (journaledEntries.get(entry.sessionId) || []).filter((version) => !entry.blockId || version.blockId === entry.blockId);
568
618
  const snapshots = new Map(versions.map((version) => [version.id, version]));
569
619
  const ids = new Set([...snapshots.keys(), dailyEntryId(entry, heading)]);
570
620
  const dates = new Set([entry.date, ...versions.map((version) => version.date).filter((date) => /^\d{4}-\d{2}-\d{2}$/.test(date || ""))]);
@@ -584,10 +634,17 @@ async function sessionEntryLocations(dailyDirectory, heading, entry, existing, j
584
634
  return { documents, blocks };
585
635
  }
586
636
 
637
+ function entryBlurb(block) {
638
+ const continuation = block.snapshot?.continuationOf;
639
+ const text = block.displayedSummary.trim();
640
+ const suffix = continuation ? `\n\nContinues [[${continuation}|previous entry]].` : "";
641
+ return suffix && text.endsWith(suffix) ? text.slice(0, -suffix.length).trim() : text;
642
+ }
643
+
587
644
  function isUneditedEntry(block) {
588
645
  if (!block.displayedSummary) return true;
589
646
  return Object.values(block.snapshot?.summary || {}).some((summary) =>
590
- formatDailySummary(summary).trim() === block.displayedSummary.trim(),
647
+ formatDailySummary(summary).trim() === entryBlurb(block),
591
648
  );
592
649
  }
593
650
 
@@ -603,12 +660,14 @@ export async function inspectDailyEntry(dailyDirectory, heading, entry, journale
603
660
  const locations = await sessionEntryLocations(dailyDirectory, heading, entry, existing, snapshots);
604
661
  const oldBlocks = locations.blocks.filter((block) => block.id !== id);
605
662
  const sameIdBlocks = locations.blocks.filter((block) => block.id === id);
606
- const updated = oldBlocks.length > 0 || sameIdBlocks.length > 1 || (!present && sameIdBlocks.length > 0);
663
+ 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);
607
665
  return {
608
666
  dailyPath,
609
667
  existing,
610
668
  appended: !present && !updated,
611
669
  updated,
670
+ legacyEntry: locations.blocks.some((block) => block.snapshot?.legacy),
612
671
  sessionBlocks: locations.blocks,
613
672
  sessionDocuments: locations.documents,
614
673
  };
@@ -625,11 +684,11 @@ async function updateEntrySnapshot(entryPath, entry, createIfMissing) {
625
684
  if (enriched !== snapshot) await writeAtomically(entryPath, enriched);
626
685
  }
627
686
 
628
- async function updateSessionDailyNotes(heading, entry, inspection) {
687
+ async function updateSessionDailyNotes(heading, entry, inspection, anchorHeading) {
629
688
  const { dailyPath, existing, sessionBlocks, sessionDocuments } = inspection;
630
689
  const customSummaries = [...new Set(sessionBlocks
631
690
  .filter((block) => !isUneditedEntry(block))
632
- .map((block) => block.displayedSummary.trim())
691
+ .map((block) => entryBlurb(block))
633
692
  .filter(Boolean))];
634
693
  let markdown = existing;
635
694
  const cleanedDocuments = new Map();
@@ -643,21 +702,21 @@ async function updateSessionDailyNotes(heading, entry, inspection) {
643
702
  const updatedMarkdown = appendUnderProject(markdown, heading, {
644
703
  ...entry,
645
704
  ...(customSummaries.length ? { summaryOverride: customSummaries.join("\n\n") } : {}),
646
- });
705
+ }, anchorHeading);
647
706
  await writeAtomically(dailyPath, updatedMarkdown);
648
707
  for (const [filePath, contents] of cleanedDocuments) await writeAtomically(filePath, contents);
649
708
  }
650
709
 
651
- export async function appendDailyEntry(dailyDirectory, heading, entry, journaledEntries) {
710
+ export async function appendDailyEntry(dailyDirectory, heading, entry, journaledEntries, anchorHeading = "") {
652
711
  const inspection = await inspectDailyEntry(dailyDirectory, heading, entry, journaledEntries);
653
712
  const { dailyPath, appended, updated } = inspection;
654
713
  const entryPath = path.join(path.dirname(entry.sessionPath), "..", "Entries", `${dailyEntryId(entry, heading)}.md`);
655
714
  if (!appended && !updated) {
656
- await updateEntrySnapshot(entryPath, entry, false);
657
- return { dailyPath, appended: false, updated: false };
715
+ await updateEntrySnapshot(entryPath, entry, true);
716
+ return { dailyPath, appended: false, updated: false, legacyEntry: inspection.legacyEntry };
658
717
  }
659
718
 
660
719
  await updateEntrySnapshot(entryPath, entry, true);
661
- await updateSessionDailyNotes(heading, entry, inspection);
662
- return { dailyPath, entryPath, appended, updated: updated || inspection.sessionBlocks.length > 0 };
720
+ await updateSessionDailyNotes(heading, entry, inspection, anchorHeading);
721
+ return { dailyPath, entryPath, appended, updated: updated || inspection.sessionBlocks.length > 0, legacyEntry: inspection.legacyEntry };
663
722
  }
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
  }