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.
@@ -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 { eventsForSession, fingerprintSession, sessionFromJsonl, sessionMetrics } from "./transcript.js";
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
- const events = eventsForSession(session, settings.timeZone);
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("Usage: logdig backfill [number-of-days|all]. Choose 1 to 3650 days, or 'all'.");
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
- const advanced = await ask("Change history folder, Pi executable, or model? (yes/no)", "no", yesNo);
80
- let { sessionDirectory, piCommand, model } = settings;
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 localParts(timestamp, timeZone) {
125
- const parts = new Intl.DateTimeFormat("en-CA", {
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
- }).formatToParts(new Date(timestamp));
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, timeZone, toolCalls) {
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, timeZone);
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 = new Map();
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, timeZone, toolCalls);
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
+ }