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
package/src/session-runner.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { appendDailyEntry, inspectDailyEntry, inspectSessionSummary, listJournaledSessions, saveSessionSummary } from "./journal.js";
|
|
4
|
-
import {
|
|
3
|
+
import { appendDailyEntry, dailyEntryId, inspectDailyEntry, inspectSessionSummary, listJournaledSessions, saveSessionSummary } from "./journal.js";
|
|
4
|
+
import { sessionFromJsonl, sessionMetrics } from "./transcript.js";
|
|
5
|
+
import { DEFAULT_CONCURRENCY } from "./settings.js";
|
|
6
|
+
import { assignLegacyBlocks, blockInRange, workBlocksForSession } from "./work-blocks.js";
|
|
5
7
|
|
|
6
8
|
function localDate(timestamp, timeZone) {
|
|
7
9
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
@@ -65,13 +67,15 @@ async function findSessionFiles(root) {
|
|
|
65
67
|
return { files, warnings };
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
export async function collectSessions({ sessionDirectory, timeZone, days, currentSession }) {
|
|
70
|
+
export async function collectSessions({ sessionDirectory, timeZone, days, currentSession, skipToday = false }) {
|
|
69
71
|
const { files, warnings } = await findSessionFiles(sessionDirectory);
|
|
70
72
|
const currentId = currentSession?.header.id;
|
|
71
73
|
const currentPath = currentSession?.sourcePath;
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
const
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const today = localDate(now, timeZone);
|
|
76
|
+
const lastDate = skipToday ? shiftDate(today, -1) : today;
|
|
77
|
+
const firstDate = days ? shiftDate(lastDate, 1 - days) : undefined;
|
|
78
|
+
const modifiedAfter = days ? now - (days + 1 + Number(skipToday)) * 86400000 : undefined;
|
|
75
79
|
const sessions = [];
|
|
76
80
|
const seen = new Set();
|
|
77
81
|
|
|
@@ -83,7 +87,6 @@ export async function collectSessions({ sessionDirectory, timeZone, days, curren
|
|
|
83
87
|
if (modifiedAfter && fileStat.mtimeMs < modifiedAfter) continue;
|
|
84
88
|
const session = sessionFromJsonl(await readFile(file, "utf8"), file);
|
|
85
89
|
if (session.header.id === currentId || seen.has(session.header.id)) continue;
|
|
86
|
-
session.fingerprint = fingerprintSession(session);
|
|
87
90
|
seen.add(session.header.id);
|
|
88
91
|
sessions.push(session);
|
|
89
92
|
} catch (error) {
|
|
@@ -93,7 +96,32 @@ export async function collectSessions({ sessionDirectory, timeZone, days, curren
|
|
|
93
96
|
|
|
94
97
|
if (currentSession && !seen.has(currentSession.header.id)) sessions.push(currentSession);
|
|
95
98
|
sessions.sort((left, right) => sessionEndTime(left) - sessionEndTime(right) || left.header.id.localeCompare(right.header.id));
|
|
96
|
-
return { sessions, warnings, firstDate, lastDate:
|
|
99
|
+
return { sessions, warnings, firstDate, lastDate, ...(skipToday ? { skipToday: true } : {}) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function selectedBlockIndexes(blocks, versions, range) {
|
|
103
|
+
const selected = new Set(blocks.flatMap((block, index) => blockInRange(block, range) ? [index] : []));
|
|
104
|
+
// A continuation must link to a real snapshot, not a predicted filename.
|
|
105
|
+
for (let index = blocks.length - 1; index > 0; index--) {
|
|
106
|
+
if (selected.has(index) && !versions.some((version) => version.blockId === blocks[index - 1].blockId && version.summary)) {
|
|
107
|
+
selected.add(index - 1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return selected;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function precedingSnapshot(modelClient, previous, versions, settings) {
|
|
114
|
+
const snapshots = versions.filter((version) => version.blockId === previous.blockId && version.summary);
|
|
115
|
+
if (!snapshots.length) throw new Error("preceding work block has no saved snapshot; fix its error and retry");
|
|
116
|
+
const cached = await inspectSessionSummary(modelClient, settings.cacheDirectory, previous);
|
|
117
|
+
const expectedId = dailyEntryId({
|
|
118
|
+
sessionId: previous.header.id,
|
|
119
|
+
blockId: previous.blockId,
|
|
120
|
+
cacheFingerprint: cached.cacheFingerprint,
|
|
121
|
+
continuationOf: cached.continuationOf,
|
|
122
|
+
summaryLevel: settings.dailySummary,
|
|
123
|
+
}, settings.dailyHeader);
|
|
124
|
+
return (snapshots.find((snapshot) => snapshot.id === expectedId) || snapshots[0]).id;
|
|
97
125
|
}
|
|
98
126
|
|
|
99
127
|
export async function writeSessions(modelClient, sessions, settings, range = {}) {
|
|
@@ -103,94 +131,155 @@ export async function writeSessions(modelClient, sessions, settings, range = {})
|
|
|
103
131
|
let entriesUpdated = 0;
|
|
104
132
|
let entriesSkipped = 0;
|
|
105
133
|
let sessionsSkipped = 0;
|
|
134
|
+
let blocksExcludedToday = 0;
|
|
106
135
|
const dates = new Set();
|
|
107
136
|
const dailyPaths = new Set();
|
|
108
|
-
const
|
|
109
|
-
const sessionResults = [];
|
|
137
|
+
const results = sessions.map(() => ({ errors: [], sessionResults: [] }));
|
|
110
138
|
const journaledEntries = range.journaledEntries || await listJournaledSessions(settings.cacheDirectory);
|
|
139
|
+
let dailyWrites = Promise.resolve();
|
|
140
|
+
const writeDailyEntry = (entry) => {
|
|
141
|
+
// Entry migration can touch multiple dates, so serialize all daily-note read/modify/writes.
|
|
142
|
+
const pending = dailyWrites.then(() => appendDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry, journaledEntries, settings.dailyHeaderAnchor));
|
|
143
|
+
// The caller reports a failed write; it must not poison later queued writes.
|
|
144
|
+
dailyWrites = pending.catch(() => {});
|
|
145
|
+
return pending;
|
|
146
|
+
};
|
|
111
147
|
|
|
112
|
-
|
|
148
|
+
async function writeSession(session, index) {
|
|
149
|
+
const { errors, sessionResults } = results[index];
|
|
113
150
|
const progress = { index: index + 1, total: sessions.length, sessionId: session.header.id };
|
|
151
|
+
let blocks;
|
|
114
152
|
try {
|
|
115
|
-
|
|
116
|
-
const lastUserEvent = events.findLast((event) => event.kind === "user intent");
|
|
117
|
-
const closingEvent = lastUserEvent || events.at(-1);
|
|
118
|
-
if (!closingEvent || (range.firstDate && (closingEvent.date < range.firstDate || closingEvent.date > range.lastDate))) {
|
|
119
|
-
sessionsSkipped++;
|
|
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 });
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const journalSession = {
|
|
127
|
-
...session,
|
|
128
|
-
date: closingEvent.date,
|
|
129
|
-
time: closingEvent.time,
|
|
130
|
-
timezone: settings.timeZone,
|
|
131
|
-
project: events[0].project || "unknown project",
|
|
132
|
-
events,
|
|
133
|
-
sourceFingerprint: session.fingerprint || fingerprintSession(session),
|
|
134
|
-
};
|
|
135
|
-
Object.assign(progress, { project: journalSession.project, date: journalSession.date, time: journalSession.time });
|
|
136
|
-
range.onProgress?.({ ...progress, phase: "checking", status: "checking saved summary" });
|
|
137
|
-
const cached = range.dryRun
|
|
138
|
-
? await inspectSessionSummary(modelClient, settings.cacheDirectory, journalSession)
|
|
139
|
-
: await saveSessionSummary(modelClient, settings.cacheDirectory, journalSession, {
|
|
140
|
-
onGenerate: () => range.onProgress?.({ ...progress, phase: "summarizing", status: "summarizing with Pi; large sessions may take a few minutes" }),
|
|
141
|
-
});
|
|
142
|
-
if (cached.reused) summariesReused++;
|
|
143
|
-
else summariesCreated++;
|
|
144
|
-
|
|
145
|
-
const entry = {
|
|
146
|
-
date: journalSession.date,
|
|
147
|
-
time: journalSession.time,
|
|
148
|
-
project: journalSession.project,
|
|
149
|
-
sessionId: journalSession.header.id,
|
|
150
|
-
cacheFingerprint: cached.cacheFingerprint,
|
|
151
|
-
sessionPath: cached.sessionPath,
|
|
152
|
-
sourceFingerprint: journalSession.sourceFingerprint,
|
|
153
|
-
metrics: sessionMetrics(journalSession),
|
|
154
|
-
summaryLevel: settings.dailySummary,
|
|
155
|
-
summary: cached.summary?.[settings.dailySummary],
|
|
156
|
-
};
|
|
157
|
-
const dailyEntry = range.dryRun
|
|
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++;
|
|
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
|
-
});
|
|
175
|
-
dates.add(journalSession.date);
|
|
176
|
-
dailyPaths.add(dailyEntry.dailyPath);
|
|
177
|
-
range.onProgress?.({
|
|
178
|
-
...progress,
|
|
179
|
-
phase: "complete",
|
|
180
|
-
sessionPath: cached.sessionPath,
|
|
181
|
-
dailyPath: dailyEntry.dailyPath,
|
|
182
|
-
status: range.dryRun
|
|
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"}`,
|
|
185
|
-
});
|
|
153
|
+
blocks = workBlocksForSession(session, settings.timeZone);
|
|
186
154
|
} catch (error) {
|
|
187
155
|
errors.push(`${session.header.id}: ${error.message}`);
|
|
188
156
|
sessionResults.push({ sessionId: session.header.id, status: "error", error: error.message });
|
|
189
|
-
range.onProgress?.({ ...progress, phase: "error", error: error.message });
|
|
157
|
+
range.onProgress?.({ ...progress, phase: "error", status: "FAILED", error: error.message });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const versions = journaledEntries.get(session.header.id) || [];
|
|
161
|
+
journaledEntries.set(session.header.id, versions);
|
|
162
|
+
assignLegacyBlocks(versions, blocks);
|
|
163
|
+
const excludedToday = range.skipToday ? blocks.filter((block) => block.activityDates.some((date) => date > range.lastDate)).length : 0;
|
|
164
|
+
blocksExcludedToday += excludedToday;
|
|
165
|
+
const selected = selectedBlockIndexes(blocks, versions, range);
|
|
166
|
+
if (!selected.size) {
|
|
167
|
+
sessionsSkipped++;
|
|
168
|
+
const reason = excludedToday ? "work periods active today (--skip-today)" : blocks.length ? "outside date range" : "no journalable events";
|
|
169
|
+
const latestBlock = blocks.at(-1);
|
|
170
|
+
sessionResults.push({ sessionId: session.header.id, status: "skipped", reason });
|
|
171
|
+
range.onProgress?.({
|
|
172
|
+
...progress,
|
|
173
|
+
...(latestBlock && { project: latestBlock.project, date: latestBlock.date, time: latestBlock.time }),
|
|
174
|
+
phase: "skipped",
|
|
175
|
+
status: "SKIPPED",
|
|
176
|
+
reason,
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const plannedSnapshots = new Map();
|
|
182
|
+
for (const [blockIndex, block] of blocks.entries()) {
|
|
183
|
+
if (!selected.has(blockIndex)) continue;
|
|
184
|
+
const blockProgress = {
|
|
185
|
+
...progress, blockId: block.blockId, blockIndex: blockIndex + 1, blockTotal: blocks.length,
|
|
186
|
+
project: block.project, date: block.date, time: block.time, prerequisite: !blockInRange(block, range),
|
|
187
|
+
};
|
|
188
|
+
try {
|
|
189
|
+
const previous = blocks[blockIndex - 1];
|
|
190
|
+
const continuationOf = previous
|
|
191
|
+
? plannedSnapshots.get(previous.blockId) || await precedingSnapshot(modelClient, previous, versions, settings)
|
|
192
|
+
: undefined;
|
|
193
|
+
const journalBlock = { ...block, continuationOf };
|
|
194
|
+
const { prerequisite } = blockProgress;
|
|
195
|
+
range.onProgress?.({ ...blockProgress, phase: "checking", status: "CHECKING" });
|
|
196
|
+
const cached = range.dryRun
|
|
197
|
+
? await inspectSessionSummary(modelClient, settings.cacheDirectory, journalBlock)
|
|
198
|
+
: await saveSessionSummary(modelClient, settings.cacheDirectory, journalBlock, {
|
|
199
|
+
onGenerate: () => range.onProgress?.({ ...blockProgress, phase: "summarizing", status: "SUMMARIZING" }),
|
|
200
|
+
});
|
|
201
|
+
if (cached.reused) summariesReused++;
|
|
202
|
+
else summariesCreated++;
|
|
203
|
+
|
|
204
|
+
const entry = {
|
|
205
|
+
date: block.date,
|
|
206
|
+
time: block.time,
|
|
207
|
+
project: block.project,
|
|
208
|
+
sessionId: session.header.id,
|
|
209
|
+
blockId: block.blockId,
|
|
210
|
+
blockStart: block.blockStart,
|
|
211
|
+
continuationOf,
|
|
212
|
+
cacheFingerprint: cached.cacheFingerprint,
|
|
213
|
+
sessionPath: cached.sessionPath,
|
|
214
|
+
sourceFingerprint: cached.sourceFingerprint,
|
|
215
|
+
metrics: sessionMetrics(block),
|
|
216
|
+
summaryLevel: settings.dailySummary,
|
|
217
|
+
summary: cached.summary?.[settings.dailySummary],
|
|
218
|
+
};
|
|
219
|
+
const dailyEntry = range.dryRun
|
|
220
|
+
? await inspectDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry, journaledEntries)
|
|
221
|
+
: await writeDailyEntry(entry);
|
|
222
|
+
if (dailyEntry.updated) entriesUpdated++;
|
|
223
|
+
else if (dailyEntry.appended) entriesAppended++;
|
|
224
|
+
else entriesSkipped++;
|
|
225
|
+
const entryId = dailyEntryId(entry, settings.dailyHeader);
|
|
226
|
+
plannedSnapshots.set(block.blockId, entryId);
|
|
227
|
+
sessionResults.push({
|
|
228
|
+
sessionId: session.header.id,
|
|
229
|
+
blockId: block.blockId,
|
|
230
|
+
project: block.project,
|
|
231
|
+
date: block.date,
|
|
232
|
+
time: block.time,
|
|
233
|
+
activityDates: block.activityDates,
|
|
234
|
+
prerequisite,
|
|
235
|
+
continuationOf,
|
|
236
|
+
entryPresent: !dailyEntry.appended && !dailyEntry.updated,
|
|
237
|
+
entryUpdated: Boolean(dailyEntry.updated),
|
|
238
|
+
legacyEntry: dailyEntry.legacyEntry,
|
|
239
|
+
summaryReused: cached.reused,
|
|
240
|
+
sourceFingerprint: cached.sourceFingerprint,
|
|
241
|
+
cacheFingerprint: cached.cacheFingerprint,
|
|
242
|
+
sessionPath: cached.sessionPath,
|
|
243
|
+
dailyPath: dailyEntry.dailyPath,
|
|
244
|
+
});
|
|
245
|
+
// Dry-run predictions stay in plannedSnapshots, never in the real index.
|
|
246
|
+
if (!range.dryRun && !versions.some((version) => version.id === entryId)) {
|
|
247
|
+
versions.push({ ...entry, id: entryId, summary: cached.summary });
|
|
248
|
+
}
|
|
249
|
+
dates.add(block.date);
|
|
250
|
+
dailyPaths.add(dailyEntry.dailyPath);
|
|
251
|
+
range.onProgress?.({
|
|
252
|
+
...blockProgress,
|
|
253
|
+
phase: "complete",
|
|
254
|
+
prerequisite,
|
|
255
|
+
sessionPath: cached.sessionPath,
|
|
256
|
+
dailyPath: dailyEntry.dailyPath,
|
|
257
|
+
status: range.dryRun ? "PREVIEW" : dailyEntry.updated ? "UPDATED" : dailyEntry.appended ? "SAVED" : "CURRENT",
|
|
258
|
+
});
|
|
259
|
+
} catch (error) {
|
|
260
|
+
errors.push(`${session.header.id}: ${block.date} ${block.time}: ${error.message}`);
|
|
261
|
+
sessionResults.push({ sessionId: session.header.id, blockId: block.blockId, status: "error", error: error.message });
|
|
262
|
+
range.onProgress?.({ ...blockProgress, phase: "error", status: "FAILED", error: error.message });
|
|
263
|
+
}
|
|
190
264
|
}
|
|
191
265
|
}
|
|
192
266
|
|
|
193
|
-
|
|
267
|
+
let nextIndex = 0;
|
|
268
|
+
const concurrency = Math.min(settings.concurrency ?? DEFAULT_CONCURRENCY, sessions.length);
|
|
269
|
+
await Promise.all(Array.from({ length: concurrency }, async () => {
|
|
270
|
+
while (nextIndex < sessions.length) {
|
|
271
|
+
const index = nextIndex++;
|
|
272
|
+
await writeSession(sessions[index], index);
|
|
273
|
+
range.onSessionComplete?.({ index: index + 1, total: sessions.length, sessionId: sessions[index].header.id });
|
|
274
|
+
}
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
summariesCreated, summariesReused, entriesAppended, entriesUpdated, entriesSkipped, sessionsSkipped, blocksExcludedToday,
|
|
279
|
+
dates: [...dates].sort(), dailyPaths: [...dailyPaths].sort(),
|
|
280
|
+
errors: results.flatMap((result) => result.errors),
|
|
281
|
+
sessionResults: results.flatMap((result) => result.sessionResults),
|
|
282
|
+
};
|
|
194
283
|
}
|
|
195
284
|
|
|
196
285
|
export function parseBackfillArgument(argument = "", command = "backfill") {
|
package/src/settings.js
CHANGED
|
@@ -3,13 +3,17 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
|
|
6
|
+
export const DEFAULT_CONCURRENCY = 4;
|
|
7
|
+
|
|
6
8
|
const ENVIRONMENT_SETTINGS = [
|
|
7
9
|
["PI_JOURNAL_DIR", "cacheDirectory"],
|
|
8
10
|
["PI_JOURNAL_DAILY_DIR", "dailyDirectory"],
|
|
9
11
|
["PI_JOURNAL_DAILY_HEADER", "dailyHeader"],
|
|
12
|
+
["PI_JOURNAL_DAILY_HEADER_ANCHOR", "dailyHeaderAnchor"],
|
|
10
13
|
["PI_JOURNAL_DAILY_SUMMARY", "dailySummary"],
|
|
11
14
|
["PI_JOURNAL_TIMEZONE", "timeZone"],
|
|
12
15
|
["PI_JOURNAL_MODEL", "model"],
|
|
16
|
+
["PI_JOURNAL_THINKING", "thinkingLevel"],
|
|
13
17
|
["PI_JOURNAL_PI_COMMAND", "piCommand"],
|
|
14
18
|
["PI_CODING_AGENT_SESSION_DIR", "sessionDirectory"],
|
|
15
19
|
];
|
|
@@ -39,11 +43,13 @@ function defaults({ env = process.env, home = homedir() } = {}) {
|
|
|
39
43
|
cacheDirectory: undefined,
|
|
40
44
|
dailyDirectory: undefined,
|
|
41
45
|
dailyHeader: "# Projects",
|
|
46
|
+
dailyHeaderAnchor: "",
|
|
42
47
|
dailySummary: "small",
|
|
43
48
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
44
49
|
sessionDirectory: path.join(expandPath(agentDirectory, home), "sessions"),
|
|
45
50
|
piCommand: "pi",
|
|
46
|
-
|
|
51
|
+
concurrency: DEFAULT_CONCURRENCY,
|
|
52
|
+
autoCapture: false,
|
|
47
53
|
};
|
|
48
54
|
}
|
|
49
55
|
|
|
@@ -63,6 +69,14 @@ export function validateSettings(settings, { requirePaths = false, home = homedi
|
|
|
63
69
|
if (!/^#{1,6}\s+[^\r\n]+$/.test(settings.dailyHeader)) {
|
|
64
70
|
throw new Error("dailyHeader must be one Markdown heading, such as '# Projects'");
|
|
65
71
|
}
|
|
72
|
+
settings.dailyHeaderAnchor ??= "";
|
|
73
|
+
if (typeof settings.dailyHeaderAnchor !== "string") {
|
|
74
|
+
throw new Error("dailyHeaderAnchor must be blank or one Markdown heading, such as '# Log'");
|
|
75
|
+
}
|
|
76
|
+
settings.dailyHeaderAnchor = settings.dailyHeaderAnchor.trim();
|
|
77
|
+
if (settings.dailyHeaderAnchor && !/^#{1,6}\s+[^\r\n]+$/.test(settings.dailyHeaderAnchor)) {
|
|
78
|
+
throw new Error("dailyHeaderAnchor must be blank or one Markdown heading, such as '# Log'");
|
|
79
|
+
}
|
|
66
80
|
if (typeof settings.timeZone !== "string" || !settings.timeZone.trim()) throw new Error("timeZone must be a valid timezone");
|
|
67
81
|
try {
|
|
68
82
|
new Intl.DateTimeFormat("en", { timeZone: settings.timeZone });
|
|
@@ -85,6 +99,22 @@ export function validateSettings(settings, { requirePaths = false, home = homedi
|
|
|
85
99
|
if (!provider || !modelId) throw new Error("model must use provider/model format");
|
|
86
100
|
settings.model = `${provider}/${modelId}`;
|
|
87
101
|
}
|
|
102
|
+
if (settings.thinkingLevel !== undefined) {
|
|
103
|
+
if (typeof settings.thinkingLevel !== "string") throw new Error("thinkingLevel must be default, off, minimal, low, medium, high, xhigh, or max");
|
|
104
|
+
const level = settings.thinkingLevel.trim().toLowerCase();
|
|
105
|
+
if (!["default", "off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(level)) {
|
|
106
|
+
throw new Error("thinkingLevel must be default, off, minimal, low, medium, high, xhigh, or max");
|
|
107
|
+
}
|
|
108
|
+
settings.thinkingLevel = level === "default" ? undefined : level;
|
|
109
|
+
}
|
|
110
|
+
if (settings.concurrency !== undefined) {
|
|
111
|
+
if (typeof settings.concurrency === "string" && /^\d+$/.test(settings.concurrency.trim())) {
|
|
112
|
+
settings.concurrency = Number(settings.concurrency.trim());
|
|
113
|
+
}
|
|
114
|
+
if (!Number.isSafeInteger(settings.concurrency) || settings.concurrency < 1) {
|
|
115
|
+
throw new Error("concurrency must be a positive integer (1 runs sessions sequentially)");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
88
118
|
if (settings.autoCapture !== undefined && typeof settings.autoCapture !== "boolean") {
|
|
89
119
|
throw new Error("autoCapture must be a boolean");
|
|
90
120
|
}
|
|
@@ -102,7 +132,13 @@ export function validateSettings(settings, { requirePaths = false, home = homedi
|
|
|
102
132
|
|
|
103
133
|
export function environmentOverrides(env = process.env) {
|
|
104
134
|
return [...ENVIRONMENT_SETTINGS.map(([variable]) => variable), "PI_JOURNAL_AUTO"]
|
|
105
|
-
.filter((variable) => env[variable] !== undefined && (env[variable] !== "" || variable === "PI_JOURNAL_AUTO"));
|
|
135
|
+
.filter((variable) => env[variable] !== undefined && (env[variable] !== "" || variable === "PI_JOURNAL_AUTO" || variable === "PI_JOURNAL_DAILY_HEADER_ANCHOR"));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function environmentOverrideFor(key, env = process.env) {
|
|
139
|
+
const variable = [...ENVIRONMENT_SETTINGS, ["PI_JOURNAL_AUTO", "autoCapture"]]
|
|
140
|
+
.find(([, setting]) => setting === key)?.[0];
|
|
141
|
+
return environmentOverrides(env).includes(variable) ? variable : undefined;
|
|
106
142
|
}
|
|
107
143
|
|
|
108
144
|
export async function loadSettings(options = {}) {
|
|
@@ -124,10 +160,12 @@ export async function loadSettings(options = {}) {
|
|
|
124
160
|
}
|
|
125
161
|
|
|
126
162
|
const settings = { ...defaults({ env, home }), ...stored };
|
|
127
|
-
|
|
128
|
-
|
|
163
|
+
if (options.applyEnvironment !== false) {
|
|
164
|
+
for (const [variable, key] of ENVIRONMENT_SETTINGS) {
|
|
165
|
+
if (env[variable] !== undefined && (env[variable] !== "" || key === "dailyHeaderAnchor")) settings[key] = env[variable];
|
|
166
|
+
}
|
|
167
|
+
if (env.PI_JOURNAL_AUTO !== undefined) settings.autoCapture = env.PI_JOURNAL_AUTO === "1" || env.PI_JOURNAL_AUTO === "true";
|
|
129
168
|
}
|
|
130
|
-
if (env.PI_JOURNAL_AUTO !== undefined) settings.autoCapture = env.PI_JOURNAL_AUTO === "1" || env.PI_JOURNAL_AUTO === "true";
|
|
131
169
|
validateSettings(settings);
|
|
132
170
|
return { ...settings, filePath, configured };
|
|
133
171
|
}
|
|
@@ -138,12 +176,15 @@ export async function saveSettings(settings, filePath = settings.filePath || set
|
|
|
138
176
|
cacheDirectory: settings.cacheDirectory,
|
|
139
177
|
dailyDirectory: settings.dailyDirectory,
|
|
140
178
|
dailyHeader: settings.dailyHeader || "# Projects",
|
|
179
|
+
dailyHeaderAnchor: settings.dailyHeaderAnchor ?? "",
|
|
141
180
|
dailySummary: settings.dailySummary || "small",
|
|
142
181
|
timeZone: settings.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
143
182
|
sessionDirectory: settings.sessionDirectory,
|
|
144
183
|
piCommand: settings.piCommand || "pi",
|
|
184
|
+
concurrency: settings.concurrency ?? DEFAULT_CONCURRENCY,
|
|
145
185
|
autoCapture: settings.autoCapture === true,
|
|
146
186
|
...(settings.model ? { model: settings.model } : {}),
|
|
187
|
+
...(settings.thinkingLevel !== undefined ? { thinkingLevel: settings.thinkingLevel } : {}),
|
|
147
188
|
};
|
|
148
189
|
validateSettings(persisted, { requirePaths: true });
|
|
149
190
|
|
package/src/setup.js
CHANGED
|
@@ -69,6 +69,9 @@ export async function configureLogDig(settings, commandName = "logdig") {
|
|
|
69
69
|
(value) => folder("cacheDirectory", value, true));
|
|
70
70
|
console.log("Entries are grouped by project, then by time, under this heading. Use # Projects to keep your personal log separate.");
|
|
71
71
|
const dailyHeader = await ask("Heading for journal entries", settings.dailyHeader, (value) => field("dailyHeader", value));
|
|
72
|
+
console.log("Create that section after an anchor's full section, before the next same-level or higher-level heading. Blank or missing anchors append at the end; existing sections stay in place.");
|
|
73
|
+
const dailyHeaderAnchor = await ask("Anchor heading (e.g. # Log), or 'none'", settings.dailyHeaderAnchor || "none",
|
|
74
|
+
(value) => field("dailyHeaderAnchor", value.toLowerCase() === "none" ? "" : value));
|
|
72
75
|
console.log("Small: a few sentences. Medium: goal, progress, and next steps. Large: a short timeline.");
|
|
73
76
|
const dailySummary = await ask("Daily summary (small/medium/large)", settings.dailySummary, (value) => field("dailySummary", value));
|
|
74
77
|
const timeZone = await ask("Timezone", settings.timeZone, (value) => field("timeZone", value));
|
|
@@ -76,14 +79,21 @@ export async function configureLogDig(settings, commandName = "logdig") {
|
|
|
76
79
|
console.log("\n2. Pi and privacy");
|
|
77
80
|
console.log(`History: ${settings.sessionDirectory}`);
|
|
78
81
|
console.log(`Model: ${settings.model || "Pi's default (your active model for /journal)"}`);
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
console.log(`Thinking: ${settings.thinkingLevel || "default (no LogDig override)"}`);
|
|
83
|
+
console.log(`Parallel sessions: ${settings.concurrency}`);
|
|
84
|
+
const advanced = await ask("Change history folder, Pi executable, model, thinking level, or concurrency? (yes/no)", "no", yesNo);
|
|
85
|
+
let { sessionDirectory, piCommand, model, thinkingLevel, concurrency } = settings;
|
|
81
86
|
if (advanced) {
|
|
82
87
|
sessionDirectory = await ask("Pi session-history folder", sessionDirectory,
|
|
83
88
|
(value) => folder("sessionDirectory", value, false));
|
|
84
89
|
piCommand = await ask("Pi executable", piCommand, (value) => field("piCommand", value));
|
|
85
90
|
model = await ask("Model (provider/model), or 'default'", model || "default",
|
|
86
91
|
(value) => value.toLowerCase() === "default" ? undefined : field("model", value));
|
|
92
|
+
console.log("More thinking can help distinguish attempts from outcomes, but takes longer and may cost more. Support depends on the model.");
|
|
93
|
+
thinkingLevel = await ask("Thinking level (default/off/minimal/low/medium/high/xhigh/max)", thinkingLevel || "default",
|
|
94
|
+
(value) => field("thinkingLevel", value));
|
|
95
|
+
console.log("Independent sessions run in parallel. Use 1 for sequential processing or to reduce provider rate-limit pressure.");
|
|
96
|
+
concurrency = await ask("Maximum parallel sessions", concurrency, (value) => field("concurrency", value));
|
|
87
97
|
}
|
|
88
98
|
console.log("Summarizing sends selected, redacted history to your Pi model. Provider charges may apply.");
|
|
89
99
|
console.log("Common secrets are redacted, but this is not a complete secret scanner. Pi keeps your credentials.");
|
|
@@ -91,17 +101,20 @@ export async function configureLogDig(settings, commandName = "logdig") {
|
|
|
91
101
|
const autoCapture = await ask("Capture on Pi shutdown? (yes/no)", settings.autoCapture ? "yes" : "no", yesNo);
|
|
92
102
|
installExtension = await ask("Install the Pi extension for /journal? (yes/no)", "no", yesNo);
|
|
93
103
|
|
|
94
|
-
nextSettings = { ...settings, cacheDirectory, dailyDirectory, dailyHeader, dailySummary, timeZone, sessionDirectory, piCommand, model, autoCapture };
|
|
104
|
+
nextSettings = { ...settings, cacheDirectory, dailyDirectory, dailyHeader, dailyHeaderAnchor, dailySummary, timeZone, sessionDirectory, piCommand, model, thinkingLevel, concurrency, autoCapture };
|
|
95
105
|
console.log([
|
|
96
106
|
"",
|
|
97
107
|
"Ready to save",
|
|
98
108
|
` Daily notes: ${dailyDirectory}/YYYY-MM-DD.md`,
|
|
99
109
|
` Heading: ${dailyHeader}`,
|
|
110
|
+
` Anchor: ${dailyHeaderAnchor || "none (append at end)"}`,
|
|
100
111
|
` Summary: ${dailySummary}, in ${timeZone}`,
|
|
101
112
|
` Cache: ${path.join(cacheDirectory, "Sessions")}`,
|
|
102
113
|
` History: ${sessionDirectory}`,
|
|
103
114
|
` Pi: ${piCommand}`,
|
|
104
115
|
` Model: ${model || "Pi default"}`,
|
|
116
|
+
` Thinking: ${thinkingLevel || "default (no LogDig override)"}`,
|
|
117
|
+
` Parallel sessions: ${concurrency}`,
|
|
105
118
|
` Auto-capture: ${autoCapture ? "on" : "off"}`,
|
|
106
119
|
` Extension: ${installExtension ? "install now" : "leave unchanged"}`,
|
|
107
120
|
"Missing note and cache folders will be created on your first real backfill.",
|
package/src/transcript.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
|
|
4
3
|
const READ_TOOLS = new Set([
|
|
@@ -45,12 +44,6 @@ export function sessionFromJsonl(text, sourcePath = "<session>") {
|
|
|
45
44
|
return parseSessionJsonl(text, sourcePath);
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
export function fingerprintSession(session) {
|
|
49
|
-
return createHash("sha256")
|
|
50
|
-
.update(JSON.stringify([session.header, session.entries]))
|
|
51
|
-
.digest("hex");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
47
|
export function textContent(content) {
|
|
55
48
|
if (typeof content === "string") return content;
|
|
56
49
|
if (!Array.isArray(content)) return "";
|
|
@@ -69,7 +62,7 @@ export function redactAndClip(value, maxLength = 1800) {
|
|
|
69
62
|
return `${text.slice(0, maxLength)}\n[truncated]`;
|
|
70
63
|
}
|
|
71
64
|
|
|
72
|
-
function timestampOf(entry) {
|
|
65
|
+
export function timestampOf(entry) {
|
|
73
66
|
const value = entry.timestamp ?? entry.message?.timestamp;
|
|
74
67
|
if (typeof value === "number") return value;
|
|
75
68
|
if (typeof value === "string") {
|
|
@@ -121,8 +114,8 @@ export function sessionMetrics(session) {
|
|
|
121
114
|
return metrics;
|
|
122
115
|
}
|
|
123
116
|
|
|
124
|
-
function
|
|
125
|
-
|
|
117
|
+
export function localTimeFormatter(timeZone) {
|
|
118
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
126
119
|
timeZone,
|
|
127
120
|
year: "numeric",
|
|
128
121
|
month: "2-digit",
|
|
@@ -130,7 +123,11 @@ function localParts(timestamp, timeZone) {
|
|
|
130
123
|
hour: "2-digit",
|
|
131
124
|
minute: "2-digit",
|
|
132
125
|
hourCycle: "h23",
|
|
133
|
-
})
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function localParts(timestamp, formatter) {
|
|
130
|
+
const parts = formatter.formatToParts(new Date(timestamp));
|
|
134
131
|
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
|
135
132
|
return {
|
|
136
133
|
date: `${values.year}-${values.month}-${values.day}`,
|
|
@@ -172,10 +169,10 @@ function outputExcerpt(content, limit = 420) {
|
|
|
172
169
|
return `${text.slice(0, half)}\n[…output clipped…]\n${text.slice(-half)}`;
|
|
173
170
|
}
|
|
174
171
|
|
|
175
|
-
function eventFor(entry,
|
|
172
|
+
function eventFor(entry, formatter, toolCalls) {
|
|
176
173
|
const timestamp = timestampOf(entry);
|
|
177
174
|
if (timestamp === undefined) return undefined;
|
|
178
|
-
const { date, time } = localParts(timestamp,
|
|
175
|
+
const { date, time } = localParts(timestamp, formatter);
|
|
179
176
|
const message = entry.message;
|
|
180
177
|
|
|
181
178
|
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
@@ -251,14 +248,14 @@ function eventFor(entry, timeZone, toolCalls) {
|
|
|
251
248
|
return undefined;
|
|
252
249
|
}
|
|
253
250
|
|
|
254
|
-
export function eventsForSession(session, timeZone) {
|
|
251
|
+
export function eventsForSession(session, timeZone, extraction = { formatter: localTimeFormatter(timeZone), toolCalls: new Map() }) {
|
|
255
252
|
const events = [];
|
|
256
|
-
const toolCalls =
|
|
253
|
+
const { formatter, toolCalls } = extraction;
|
|
257
254
|
const cwd = session.header.cwd || "";
|
|
258
255
|
const project = (cwd ? path.basename(cwd) : "").replace(/^\d{4}-\d{2}-\d{2}-(?=.)/, "") || "unknown project";
|
|
259
256
|
|
|
260
257
|
for (const entry of session.entries) {
|
|
261
|
-
const generated = eventFor(entry,
|
|
258
|
+
const generated = eventFor(entry, formatter, toolCalls);
|
|
262
259
|
if (!generated) continue;
|
|
263
260
|
for (const event of Array.isArray(generated) ? generated : [generated]) {
|
|
264
261
|
events.push({
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { eventsForSession, localParts, localTimeFormatter, timestampOf } from "./transcript.js";
|
|
3
|
+
|
|
4
|
+
const CONTINUATION_GAP_MS = 4 * 60 * 60 * 1000;
|
|
5
|
+
const CONTEXT_LIMIT = 3200;
|
|
6
|
+
const CONTEXT_EVENTS = 6;
|
|
7
|
+
const ACTIVITY_ROLES = new Set(["user", "assistant", "toolResult", "bashExecution"]);
|
|
8
|
+
|
|
9
|
+
function precedingContext(events) {
|
|
10
|
+
const context = [];
|
|
11
|
+
let size = 0;
|
|
12
|
+
for (let index = events.length - 1; index >= 0 && context.length < CONTEXT_EVENTS; index--) {
|
|
13
|
+
const event = events[index];
|
|
14
|
+
const length = JSON.stringify(event).length;
|
|
15
|
+
if (size + length > CONTEXT_LIMIT) break;
|
|
16
|
+
context.unshift(event);
|
|
17
|
+
size += length;
|
|
18
|
+
}
|
|
19
|
+
return context;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function workBlocksForSession(session, timeZone) {
|
|
23
|
+
const formatter = localTimeFormatter(timeZone);
|
|
24
|
+
const blocks = [];
|
|
25
|
+
let current;
|
|
26
|
+
let lastActivity;
|
|
27
|
+
let userIndex = 0;
|
|
28
|
+
const pending = [];
|
|
29
|
+
|
|
30
|
+
for (const entry of session.entries) {
|
|
31
|
+
const contextSummary = ["compaction", "branch_summary"].includes(entry.type) && typeof entry.summary === "string" && entry.summary.trim();
|
|
32
|
+
const activity = (entry.type === "message" && ACTIVITY_ROLES.has(entry.message?.role)) || contextSummary;
|
|
33
|
+
const timestamp = activity ? timestampOf(entry) : undefined;
|
|
34
|
+
if (timestamp !== undefined) {
|
|
35
|
+
const { date, time } = localParts(timestamp, formatter);
|
|
36
|
+
const user = entry.message?.role === "user";
|
|
37
|
+
if (user) userIndex++;
|
|
38
|
+
if (!current || (user && date > current.date && timestamp - lastActivity >= CONTINUATION_GAP_MS)) {
|
|
39
|
+
const blockId = createHash("sha256")
|
|
40
|
+
.update(JSON.stringify([session.header.id, entry.id || [timestamp, userIndex]]))
|
|
41
|
+
.digest("hex");
|
|
42
|
+
current = {
|
|
43
|
+
...session,
|
|
44
|
+
header: blocks.length ? { ...session.header, timestamp: new Date(timestamp).toISOString() } : session.header,
|
|
45
|
+
entries: blocks.length ? [] : pending.splice(0),
|
|
46
|
+
blockId,
|
|
47
|
+
blockStart: new Date(timestamp).toISOString(),
|
|
48
|
+
date,
|
|
49
|
+
time,
|
|
50
|
+
timezone: timeZone,
|
|
51
|
+
activityDates: new Set(),
|
|
52
|
+
};
|
|
53
|
+
blocks.push(current);
|
|
54
|
+
}
|
|
55
|
+
current.activityDates.add(date);
|
|
56
|
+
lastActivity = Math.max(lastActivity ?? timestamp, timestamp);
|
|
57
|
+
}
|
|
58
|
+
if (current) current.entries.push(entry);
|
|
59
|
+
else pending.push(entry);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const journalable = [];
|
|
63
|
+
const extraction = { formatter, toolCalls: new Map() };
|
|
64
|
+
for (const block of blocks) {
|
|
65
|
+
const events = eventsForSession(block, timeZone, extraction);
|
|
66
|
+
if (!events.length) continue;
|
|
67
|
+
const previous = journalable.at(-1);
|
|
68
|
+
journalable.push({
|
|
69
|
+
...block,
|
|
70
|
+
blockIndex: journalable.length,
|
|
71
|
+
project: events[0].project,
|
|
72
|
+
events,
|
|
73
|
+
context: previous ? precedingContext(previous.events) : [],
|
|
74
|
+
previousBlockId: previous?.blockId,
|
|
75
|
+
activityDates: [...block.activityDates],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return journalable;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function blockInRange(block, range) {
|
|
82
|
+
// Never create a partial summary of a work period that continues into today.
|
|
83
|
+
if (range.skipToday && block.activityDates.some((date) => date > range.lastDate)) return false;
|
|
84
|
+
return !range.firstDate || block.activityDates.some((date) => date >= range.firstDate && date <= range.lastDate);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Legacy entries used their last user timestamp. Blocks that disappear after a
|
|
88
|
+
// timezone change also need reassignment, without altering their linked notes.
|
|
89
|
+
export function assignLegacyBlocks(versions, blocks) {
|
|
90
|
+
if (!blocks.length) return;
|
|
91
|
+
const currentIds = new Set(blocks.map((block) => block.blockId));
|
|
92
|
+
for (const version of versions) {
|
|
93
|
+
if (currentIds.has(version.blockId)) continue;
|
|
94
|
+
const anchor = `${version.date} ${version.time}`;
|
|
95
|
+
const block = blocks.findLast((candidate) => version.blockStart
|
|
96
|
+
? candidate.blockStart <= version.blockStart
|
|
97
|
+
: `${candidate.date} ${candidate.time}` <= anchor) || blocks[0];
|
|
98
|
+
version.blockId = block.blockId;
|
|
99
|
+
version.legacy = true;
|
|
100
|
+
}
|
|
101
|
+
}
|