logdig 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/QUICKSTART.md +10 -5
- package/README.md +39 -11
- package/bin/logdig.js +168 -25
- package/package.json +1 -1
- package/src/cli-status.js +35 -0
- package/src/index.js +26 -23
- package/src/journal.js +298 -49
- package/src/pi-client.js +7 -4
- package/src/session-runner.js +133 -62
- package/src/settings.js +10 -0
- package/src/setup.js +8 -3
- package/src/transcript.js +13 -16
- package/src/work-blocks.js +99 -0
package/src/session-runner.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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";
|
|
4
|
-
import {
|
|
3
|
+
import { appendDailyEntry, dailyEntryId, inspectDailyEntry, inspectSessionSummary, listJournaledSessions, saveSessionSummary } from "./journal.js";
|
|
4
|
+
import { sessionFromJsonl, sessionMetrics } from "./transcript.js";
|
|
5
|
+
import { assignLegacyBlocks, blockInRange, workBlocksForSession } from "./work-blocks.js";
|
|
5
6
|
|
|
6
7
|
function localDate(timestamp, timeZone) {
|
|
7
8
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
@@ -83,7 +84,6 @@ export async function collectSessions({ sessionDirectory, timeZone, days, curren
|
|
|
83
84
|
if (modifiedAfter && fileStat.mtimeMs < modifiedAfter) continue;
|
|
84
85
|
const session = sessionFromJsonl(await readFile(file, "utf8"), file);
|
|
85
86
|
if (session.header.id === currentId || seen.has(session.header.id)) continue;
|
|
86
|
-
session.fingerprint = fingerprintSession(session);
|
|
87
87
|
seen.add(session.header.id);
|
|
88
88
|
sessions.push(session);
|
|
89
89
|
} catch (error) {
|
|
@@ -96,90 +96,161 @@ export async function collectSessions({ sessionDirectory, timeZone, days, curren
|
|
|
96
96
|
return { sessions, warnings, firstDate, lastDate: today };
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
function selectedBlockIndexes(blocks, versions, range) {
|
|
100
|
+
const selected = new Set(blocks.flatMap((block, index) => blockInRange(block, range) ? [index] : []));
|
|
101
|
+
// A continuation must link to a real snapshot, not a predicted filename.
|
|
102
|
+
for (let index = blocks.length - 1; index > 0; index--) {
|
|
103
|
+
if (selected.has(index) && !versions.some((version) => version.blockId === blocks[index - 1].blockId && version.summary)) {
|
|
104
|
+
selected.add(index - 1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return selected;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function precedingSnapshot(modelClient, previous, versions, settings) {
|
|
111
|
+
const snapshots = versions.filter((version) => version.blockId === previous.blockId && version.summary);
|
|
112
|
+
if (!snapshots.length) throw new Error("preceding work block has no saved snapshot; fix its error and retry");
|
|
113
|
+
const cached = await inspectSessionSummary(modelClient, settings.cacheDirectory, previous);
|
|
114
|
+
const expectedId = dailyEntryId({
|
|
115
|
+
sessionId: previous.header.id,
|
|
116
|
+
blockId: previous.blockId,
|
|
117
|
+
cacheFingerprint: cached.cacheFingerprint,
|
|
118
|
+
continuationOf: cached.continuationOf,
|
|
119
|
+
summaryLevel: settings.dailySummary,
|
|
120
|
+
}, settings.dailyHeader);
|
|
121
|
+
return (snapshots.find((snapshot) => snapshot.id === expectedId) || snapshots[0]).id;
|
|
122
|
+
}
|
|
123
|
+
|
|
99
124
|
export async function writeSessions(modelClient, sessions, settings, range = {}) {
|
|
100
125
|
let summariesCreated = 0;
|
|
101
126
|
let summariesReused = 0;
|
|
102
127
|
let entriesAppended = 0;
|
|
128
|
+
let entriesUpdated = 0;
|
|
103
129
|
let entriesSkipped = 0;
|
|
104
130
|
let sessionsSkipped = 0;
|
|
105
131
|
const dates = new Set();
|
|
106
132
|
const dailyPaths = new Set();
|
|
107
133
|
const errors = [];
|
|
134
|
+
const sessionResults = [];
|
|
135
|
+
const journaledEntries = range.journaledEntries || await listJournaledSessions(settings.cacheDirectory);
|
|
108
136
|
|
|
109
137
|
for (const [index, session] of sessions.entries()) {
|
|
110
138
|
const progress = { index: index + 1, total: sessions.length, sessionId: session.header.id };
|
|
139
|
+
let blocks;
|
|
111
140
|
try {
|
|
112
|
-
|
|
113
|
-
const lastUserEvent = events.findLast((event) => event.kind === "user intent");
|
|
114
|
-
const closingEvent = lastUserEvent || events.at(-1);
|
|
115
|
-
if (!closingEvent || (range.firstDate && (closingEvent.date < range.firstDate || closingEvent.date > range.lastDate))) {
|
|
116
|
-
sessionsSkipped++;
|
|
117
|
-
range.onProgress?.({ ...progress, phase: "skipped", status: closingEvent ? "outside date range" : "no journalable events" });
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const journalSession = {
|
|
122
|
-
...session,
|
|
123
|
-
date: closingEvent.date,
|
|
124
|
-
time: closingEvent.time,
|
|
125
|
-
timezone: settings.timeZone,
|
|
126
|
-
project: events[0].project || "unknown project",
|
|
127
|
-
events,
|
|
128
|
-
sourceFingerprint: session.fingerprint || fingerprintSession(session),
|
|
129
|
-
};
|
|
130
|
-
Object.assign(progress, { project: journalSession.project, date: journalSession.date, time: journalSession.time });
|
|
131
|
-
range.onProgress?.({ ...progress, phase: "checking", status: "checking saved summary" });
|
|
132
|
-
const cached = range.dryRun
|
|
133
|
-
? await inspectSessionSummary(modelClient, settings.cacheDirectory, journalSession)
|
|
134
|
-
: await saveSessionSummary(modelClient, settings.cacheDirectory, journalSession, {
|
|
135
|
-
onGenerate: () => range.onProgress?.({ ...progress, phase: "summarizing", status: "summarizing with Pi; large sessions may take a few minutes" }),
|
|
136
|
-
});
|
|
137
|
-
if (cached.reused) summariesReused++;
|
|
138
|
-
else summariesCreated++;
|
|
139
|
-
|
|
140
|
-
const entry = {
|
|
141
|
-
date: journalSession.date,
|
|
142
|
-
time: journalSession.time,
|
|
143
|
-
project: journalSession.project,
|
|
144
|
-
sessionId: journalSession.header.id,
|
|
145
|
-
cacheFingerprint: cached.cacheFingerprint,
|
|
146
|
-
sessionPath: cached.sessionPath,
|
|
147
|
-
sourceFingerprint: journalSession.sourceFingerprint,
|
|
148
|
-
metrics: sessionMetrics(journalSession),
|
|
149
|
-
summaryLevel: settings.dailySummary,
|
|
150
|
-
summary: cached.summary?.[settings.dailySummary],
|
|
151
|
-
};
|
|
152
|
-
const dailyEntry = range.dryRun
|
|
153
|
-
? await inspectDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry)
|
|
154
|
-
: await appendDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry);
|
|
155
|
-
if (dailyEntry.appended) entriesAppended++;
|
|
156
|
-
else entriesSkipped++;
|
|
157
|
-
dates.add(journalSession.date);
|
|
158
|
-
dailyPaths.add(dailyEntry.dailyPath);
|
|
159
|
-
range.onProgress?.({
|
|
160
|
-
...progress,
|
|
161
|
-
phase: "complete",
|
|
162
|
-
sessionPath: cached.sessionPath,
|
|
163
|
-
dailyPath: dailyEntry.dailyPath,
|
|
164
|
-
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"}`,
|
|
167
|
-
});
|
|
141
|
+
blocks = workBlocksForSession(session, settings.timeZone);
|
|
168
142
|
} catch (error) {
|
|
169
143
|
errors.push(`${session.header.id}: ${error.message}`);
|
|
144
|
+
sessionResults.push({ sessionId: session.header.id, status: "error", error: error.message });
|
|
170
145
|
range.onProgress?.({ ...progress, phase: "error", error: error.message });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const versions = journaledEntries.get(session.header.id) || [];
|
|
149
|
+
journaledEntries.set(session.header.id, versions);
|
|
150
|
+
assignLegacyBlocks(versions, blocks);
|
|
151
|
+
const selected = selectedBlockIndexes(blocks, versions, range);
|
|
152
|
+
if (!selected.size) {
|
|
153
|
+
sessionsSkipped++;
|
|
154
|
+
const reason = blocks.length ? "outside date range" : "no journalable events";
|
|
155
|
+
sessionResults.push({ sessionId: session.header.id, status: "skipped", reason });
|
|
156
|
+
range.onProgress?.({ ...progress, phase: "skipped", status: reason });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const plannedSnapshots = new Map();
|
|
161
|
+
for (const [blockIndex, block] of blocks.entries()) {
|
|
162
|
+
if (!selected.has(blockIndex)) continue;
|
|
163
|
+
const blockProgress = { ...progress, blockId: block.blockId, project: block.project, date: block.date, time: block.time };
|
|
164
|
+
try {
|
|
165
|
+
const previous = blocks[blockIndex - 1];
|
|
166
|
+
const continuationOf = previous
|
|
167
|
+
? plannedSnapshots.get(previous.blockId) || await precedingSnapshot(modelClient, previous, versions, settings)
|
|
168
|
+
: undefined;
|
|
169
|
+
const journalBlock = { ...block, continuationOf };
|
|
170
|
+
const prerequisite = !blockInRange(block, range);
|
|
171
|
+
range.onProgress?.({ ...blockProgress, phase: "checking", status: "checking saved summary" });
|
|
172
|
+
const cached = range.dryRun
|
|
173
|
+
? await inspectSessionSummary(modelClient, settings.cacheDirectory, journalBlock)
|
|
174
|
+
: await saveSessionSummary(modelClient, settings.cacheDirectory, journalBlock, {
|
|
175
|
+
onGenerate: () => range.onProgress?.({ ...blockProgress, phase: "summarizing", status: "summarizing with Pi; large sessions may take a few minutes" }),
|
|
176
|
+
});
|
|
177
|
+
if (cached.reused) summariesReused++;
|
|
178
|
+
else summariesCreated++;
|
|
179
|
+
|
|
180
|
+
const entry = {
|
|
181
|
+
date: block.date,
|
|
182
|
+
time: block.time,
|
|
183
|
+
project: block.project,
|
|
184
|
+
sessionId: session.header.id,
|
|
185
|
+
blockId: block.blockId,
|
|
186
|
+
blockStart: block.blockStart,
|
|
187
|
+
continuationOf,
|
|
188
|
+
cacheFingerprint: cached.cacheFingerprint,
|
|
189
|
+
sessionPath: cached.sessionPath,
|
|
190
|
+
sourceFingerprint: cached.sourceFingerprint,
|
|
191
|
+
metrics: sessionMetrics(block),
|
|
192
|
+
summaryLevel: settings.dailySummary,
|
|
193
|
+
summary: cached.summary?.[settings.dailySummary],
|
|
194
|
+
};
|
|
195
|
+
const dailyEntry = range.dryRun
|
|
196
|
+
? await inspectDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry, journaledEntries)
|
|
197
|
+
: await appendDailyEntry(settings.dailyDirectory, settings.dailyHeader, entry, journaledEntries);
|
|
198
|
+
if (dailyEntry.updated) entriesUpdated++;
|
|
199
|
+
else if (dailyEntry.appended) entriesAppended++;
|
|
200
|
+
else entriesSkipped++;
|
|
201
|
+
const entryId = dailyEntryId(entry, settings.dailyHeader);
|
|
202
|
+
plannedSnapshots.set(block.blockId, entryId);
|
|
203
|
+
sessionResults.push({
|
|
204
|
+
sessionId: session.header.id,
|
|
205
|
+
blockId: block.blockId,
|
|
206
|
+
project: block.project,
|
|
207
|
+
date: block.date,
|
|
208
|
+
time: block.time,
|
|
209
|
+
activityDates: block.activityDates,
|
|
210
|
+
prerequisite,
|
|
211
|
+
continuationOf,
|
|
212
|
+
entryPresent: !dailyEntry.appended && !dailyEntry.updated,
|
|
213
|
+
entryUpdated: Boolean(dailyEntry.updated),
|
|
214
|
+
legacyEntry: dailyEntry.legacyEntry,
|
|
215
|
+
summaryReused: cached.reused,
|
|
216
|
+
sourceFingerprint: cached.sourceFingerprint,
|
|
217
|
+
cacheFingerprint: cached.cacheFingerprint,
|
|
218
|
+
sessionPath: cached.sessionPath,
|
|
219
|
+
dailyPath: dailyEntry.dailyPath,
|
|
220
|
+
});
|
|
221
|
+
// Dry-run predictions stay in plannedSnapshots, never in the real index.
|
|
222
|
+
if (!range.dryRun && !versions.some((version) => version.id === entryId)) {
|
|
223
|
+
versions.push({ ...entry, id: entryId, summary: cached.summary });
|
|
224
|
+
}
|
|
225
|
+
dates.add(block.date);
|
|
226
|
+
dailyPaths.add(dailyEntry.dailyPath);
|
|
227
|
+
range.onProgress?.({
|
|
228
|
+
...blockProgress,
|
|
229
|
+
phase: "complete",
|
|
230
|
+
prerequisite,
|
|
231
|
+
sessionPath: cached.sessionPath,
|
|
232
|
+
dailyPath: dailyEntry.dailyPath,
|
|
233
|
+
status: (range.dryRun
|
|
234
|
+
? `${cached.reused ? "would reuse summary" : "would summarize with Pi"}, ${dailyEntry.updated ? "would update entry" : dailyEntry.appended ? "would append entry" : "entry already present"}`
|
|
235
|
+
: `${cached.reused ? "summary reused" : "summary created"}, ${dailyEntry.updated ? "entry updated" : dailyEntry.appended ? "entry appended" : "entry already present"}`) + (prerequisite ? " (earlier block needed for continuation link)" : ""),
|
|
236
|
+
});
|
|
237
|
+
} catch (error) {
|
|
238
|
+
errors.push(`${session.header.id}: ${block.date} ${block.time}: ${error.message}`);
|
|
239
|
+
sessionResults.push({ sessionId: session.header.id, blockId: block.blockId, status: "error", error: error.message });
|
|
240
|
+
range.onProgress?.({ ...blockProgress, phase: "error", error: error.message });
|
|
241
|
+
}
|
|
171
242
|
}
|
|
172
243
|
}
|
|
173
244
|
|
|
174
|
-
return { summariesCreated, summariesReused, entriesAppended, entriesSkipped, sessionsSkipped, dates: [...dates].sort(), dailyPaths: [...dailyPaths].sort(), errors };
|
|
245
|
+
return { summariesCreated, summariesReused, entriesAppended, entriesUpdated, entriesSkipped, sessionsSkipped, dates: [...dates].sort(), dailyPaths: [...dailyPaths].sort(), errors, sessionResults };
|
|
175
246
|
}
|
|
176
247
|
|
|
177
|
-
export function parseBackfillArgument(argument = "") {
|
|
248
|
+
export function parseBackfillArgument(argument = "", command = "backfill") {
|
|
178
249
|
const value = argument.trim().toLowerCase() || "3";
|
|
179
250
|
if (value === "all") return { all: true };
|
|
180
251
|
const days = Number(value);
|
|
181
252
|
if (!/^\d+$/.test(value) || !Number.isInteger(days) || days < 1 || days > 3650) {
|
|
182
|
-
throw new Error(
|
|
253
|
+
throw new Error(`Usage: logdig ${command} [number-of-days|all]. Choose 1 to 3650 days, or 'all'.`);
|
|
183
254
|
}
|
|
184
255
|
return { days };
|
|
185
256
|
}
|
package/src/settings.js
CHANGED
|
@@ -10,6 +10,7 @@ const ENVIRONMENT_SETTINGS = [
|
|
|
10
10
|
["PI_JOURNAL_DAILY_SUMMARY", "dailySummary"],
|
|
11
11
|
["PI_JOURNAL_TIMEZONE", "timeZone"],
|
|
12
12
|
["PI_JOURNAL_MODEL", "model"],
|
|
13
|
+
["PI_JOURNAL_THINKING", "thinkingLevel"],
|
|
13
14
|
["PI_JOURNAL_PI_COMMAND", "piCommand"],
|
|
14
15
|
["PI_CODING_AGENT_SESSION_DIR", "sessionDirectory"],
|
|
15
16
|
];
|
|
@@ -85,6 +86,14 @@ export function validateSettings(settings, { requirePaths = false, home = homedi
|
|
|
85
86
|
if (!provider || !modelId) throw new Error("model must use provider/model format");
|
|
86
87
|
settings.model = `${provider}/${modelId}`;
|
|
87
88
|
}
|
|
89
|
+
if (settings.thinkingLevel !== undefined) {
|
|
90
|
+
if (typeof settings.thinkingLevel !== "string") throw new Error("thinkingLevel must be default, off, minimal, low, medium, high, xhigh, or max");
|
|
91
|
+
const level = settings.thinkingLevel.trim().toLowerCase();
|
|
92
|
+
if (!["default", "off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(level)) {
|
|
93
|
+
throw new Error("thinkingLevel must be default, off, minimal, low, medium, high, xhigh, or max");
|
|
94
|
+
}
|
|
95
|
+
settings.thinkingLevel = level === "default" ? undefined : level;
|
|
96
|
+
}
|
|
88
97
|
if (settings.autoCapture !== undefined && typeof settings.autoCapture !== "boolean") {
|
|
89
98
|
throw new Error("autoCapture must be a boolean");
|
|
90
99
|
}
|
|
@@ -144,6 +153,7 @@ export async function saveSettings(settings, filePath = settings.filePath || set
|
|
|
144
153
|
piCommand: settings.piCommand || "pi",
|
|
145
154
|
autoCapture: settings.autoCapture === true,
|
|
146
155
|
...(settings.model ? { model: settings.model } : {}),
|
|
156
|
+
...(settings.thinkingLevel !== undefined ? { thinkingLevel: settings.thinkingLevel } : {}),
|
|
147
157
|
};
|
|
148
158
|
validateSettings(persisted, { requirePaths: true });
|
|
149
159
|
|
package/src/setup.js
CHANGED
|
@@ -76,14 +76,18 @@ export async function configureLogDig(settings, commandName = "logdig") {
|
|
|
76
76
|
console.log("\n2. Pi and privacy");
|
|
77
77
|
console.log(`History: ${settings.sessionDirectory}`);
|
|
78
78
|
console.log(`Model: ${settings.model || "Pi's default (your active model for /journal)"}`);
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
console.log(`Thinking: ${settings.thinkingLevel || "default (no LogDig override)"}`);
|
|
80
|
+
const advanced = await ask("Change history folder, Pi executable, model, or thinking level? (yes/no)", "no", yesNo);
|
|
81
|
+
let { sessionDirectory, piCommand, model, thinkingLevel } = settings;
|
|
81
82
|
if (advanced) {
|
|
82
83
|
sessionDirectory = await ask("Pi session-history folder", sessionDirectory,
|
|
83
84
|
(value) => folder("sessionDirectory", value, false));
|
|
84
85
|
piCommand = await ask("Pi executable", piCommand, (value) => field("piCommand", value));
|
|
85
86
|
model = await ask("Model (provider/model), or 'default'", model || "default",
|
|
86
87
|
(value) => value.toLowerCase() === "default" ? undefined : field("model", value));
|
|
88
|
+
console.log("More thinking can help distinguish attempts from outcomes, but takes longer and may cost more. Support depends on the model.");
|
|
89
|
+
thinkingLevel = await ask("Thinking level (default/off/minimal/low/medium/high/xhigh/max)", thinkingLevel || "default",
|
|
90
|
+
(value) => field("thinkingLevel", value));
|
|
87
91
|
}
|
|
88
92
|
console.log("Summarizing sends selected, redacted history to your Pi model. Provider charges may apply.");
|
|
89
93
|
console.log("Common secrets are redacted, but this is not a complete secret scanner. Pi keeps your credentials.");
|
|
@@ -91,7 +95,7 @@ export async function configureLogDig(settings, commandName = "logdig") {
|
|
|
91
95
|
const autoCapture = await ask("Capture on Pi shutdown? (yes/no)", settings.autoCapture ? "yes" : "no", yesNo);
|
|
92
96
|
installExtension = await ask("Install the Pi extension for /journal? (yes/no)", "no", yesNo);
|
|
93
97
|
|
|
94
|
-
nextSettings = { ...settings, cacheDirectory, dailyDirectory, dailyHeader, dailySummary, timeZone, sessionDirectory, piCommand, model, autoCapture };
|
|
98
|
+
nextSettings = { ...settings, cacheDirectory, dailyDirectory, dailyHeader, dailySummary, timeZone, sessionDirectory, piCommand, model, thinkingLevel, autoCapture };
|
|
95
99
|
console.log([
|
|
96
100
|
"",
|
|
97
101
|
"Ready to save",
|
|
@@ -102,6 +106,7 @@ export async function configureLogDig(settings, commandName = "logdig") {
|
|
|
102
106
|
` History: ${sessionDirectory}`,
|
|
103
107
|
` Pi: ${piCommand}`,
|
|
104
108
|
` Model: ${model || "Pi default"}`,
|
|
109
|
+
` Thinking: ${thinkingLevel || "default (no LogDig override)"}`,
|
|
105
110
|
` Auto-capture: ${autoCapture ? "on" : "off"}`,
|
|
106
111
|
` Extension: ${installExtension ? "install now" : "leave unchanged"}`,
|
|
107
112
|
"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,99 @@
|
|
|
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
|
+
return !range.firstDate || block.activityDates.some((date) => date >= range.firstDate && date <= range.lastDate);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Legacy entries used their last user timestamp. Blocks that disappear after a
|
|
86
|
+
// timezone change also need reassignment, without altering their linked notes.
|
|
87
|
+
export function assignLegacyBlocks(versions, blocks) {
|
|
88
|
+
if (!blocks.length) return;
|
|
89
|
+
const currentIds = new Set(blocks.map((block) => block.blockId));
|
|
90
|
+
for (const version of versions) {
|
|
91
|
+
if (currentIds.has(version.blockId)) continue;
|
|
92
|
+
const anchor = `${version.date} ${version.time}`;
|
|
93
|
+
const block = blocks.findLast((candidate) => version.blockStart
|
|
94
|
+
? candidate.blockStart <= version.blockStart
|
|
95
|
+
: `${candidate.date} ${candidate.time}` <= anchor) || blocks[0];
|
|
96
|
+
version.blockId = block.blockId;
|
|
97
|
+
version.legacy = true;
|
|
98
|
+
}
|
|
99
|
+
}
|