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