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.
- package/QUICKSTART.md +23 -5
- package/README.md +81 -16
- package/bin/logdig.js +128 -70
- package/package.json +1 -1
- package/src/backfill-progress.js +108 -0
- package/src/cli-status.js +60 -0
- package/src/config-menu.js +135 -0
- package/src/index.js +25 -22
- package/src/journal.js +98 -39
- package/src/pi-client.js +7 -4
- package/src/session-runner.js +173 -84
- package/src/settings.js +46 -5
- package/src/setup.js +16 -3
- package/src/transcript.js +13 -16
- package/src/work-blocks.js +101 -0
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,
|
|
71
|
-
const
|
|
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
|
|
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 ? {
|
|
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 = "
|
|
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.",
|
|
@@ -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 =
|
|
255
|
-
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
|
+
}
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
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`);
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
|
569
|
+
const text = dailyEntryText(entry, heading);
|
|
533
570
|
if (sectionStart === -1) {
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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 ${
|
|
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
|
}
|