logdig 0.1.1 → 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/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
+ }