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.
@@ -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 { 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 { 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 today = localDate(Date.now(), timeZone);
73
- const firstDate = days ? shiftDate(today, 1 - days) : undefined;
74
- const modifiedAfter = days ? Date.now() - (days + 1) * 86400000 : undefined;
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: today };
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 errors = [];
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
- for (const [index, session] of sessions.entries()) {
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
- const events = eventsForSession(session, settings.timeZone);
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
- return { summariesCreated, summariesReused, entriesAppended, entriesUpdated, entriesSkipped, sessionsSkipped, dates: [...dates].sort(), dailyPaths: [...dailyPaths].sort(), errors, sessionResults };
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
- autoCapture: env.PI_JOURNAL_AUTO === "1",
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
- for (const [variable, key] of ENVIRONMENT_SETTINGS) {
128
- if (env[variable] !== undefined && env[variable] !== "") settings[key] = env[variable];
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
- const advanced = await ask("Change history folder, Pi executable, or model? (yes/no)", "no", yesNo);
80
- let { sessionDirectory, piCommand, model } = settings;
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 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,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
+ }