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/QUICKSTART.md +10 -5
- package/README.md +39 -11
- package/bin/logdig.js +168 -25
- package/package.json +1 -1
- package/src/cli-status.js +35 -0
- package/src/index.js +26 -23
- package/src/journal.js +298 -49
- package/src/pi-client.js +7 -4
- package/src/session-runner.js +133 -62
- package/src/settings.js +10 -0
- package/src/setup.js +8 -3
- package/src/transcript.js +13 -16
- package/src/work-blocks.js +99 -0
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 ? {
|
|
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}
|
|
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
|
|
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
|
-
|
|
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 = "
|
|
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
|
|
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 =
|
|
226
|
-
if (usage
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
379
|
-
if (projectStart !== -1 && projectEnd === undefined &&
|
|
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
|
|
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
|
-
|
|
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
|
|
417
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
await
|
|
456
|
-
|
|
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 ${
|
|
58
|
-
},
|
|
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
|
-
|
|
127
|
+
cachePolicy: summaryCachePolicy(settings),
|
|
125
128
|
complete: (prompt) => runPiPrompt(prompt, settings, options.spawnProcess),
|
|
126
129
|
};
|
|
127
130
|
}
|