engrm 0.4.23 → 0.4.25

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.
@@ -667,6 +667,19 @@ var MIGRATIONS = [
667
667
  CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
668
668
  CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
669
669
  `
670
+ },
671
+ {
672
+ version: 17,
673
+ description: "Track transcript-backed chat messages separately from hook chat",
674
+ sql: `
675
+ ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook';
676
+ ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER;
677
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind
678
+ ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC);
679
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript
680
+ ON chat_messages(session_id, transcript_index)
681
+ WHERE transcript_index IS NOT NULL;
682
+ `
670
683
  }
671
684
  ];
672
685
  function isVecExtensionLoaded(db) {
@@ -737,6 +750,9 @@ function inferLegacySchemaVersion(db) {
737
750
  if (syncOutboxSupportsChatMessages(db)) {
738
751
  version = Math.max(version, 16);
739
752
  }
753
+ if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
754
+ version = Math.max(version, 17);
755
+ }
740
756
  return version;
741
757
  }
742
758
  function runMigrations(db) {
@@ -840,9 +856,17 @@ function ensureChatMessageColumns(db) {
840
856
  db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
841
857
  }
842
858
  db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_remote_source ON chat_messages(remote_source_id) WHERE remote_source_id IS NOT NULL");
859
+ if (!columnExists(db, "chat_messages", "source_kind")) {
860
+ db.exec("ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook'");
861
+ }
862
+ if (!columnExists(db, "chat_messages", "transcript_index")) {
863
+ db.exec("ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER");
864
+ }
865
+ db.exec("CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC)");
866
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript ON chat_messages(session_id, transcript_index) WHERE transcript_index IS NOT NULL");
843
867
  const current = getSchemaVersion(db);
844
- if (current < 15) {
845
- db.exec("PRAGMA user_version = 15");
868
+ if (current < 17) {
869
+ db.exec("PRAGMA user_version = 17");
846
870
  }
847
871
  }
848
872
  function ensureSyncOutboxSupportsChatMessages(db) {
@@ -1127,6 +1151,22 @@ class MemDatabase {
1127
1151
  getObservationById(id) {
1128
1152
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
1129
1153
  }
1154
+ updateObservationContent(id, update) {
1155
+ const existing = this.getObservationById(id);
1156
+ if (!existing)
1157
+ return null;
1158
+ const createdAtEpoch = update.created_at_epoch ?? existing.created_at_epoch;
1159
+ const createdAt = new Date(createdAtEpoch * 1000).toISOString();
1160
+ this.db.query(`UPDATE observations
1161
+ SET title = ?, narrative = ?, facts = ?, concepts = ?, created_at = ?, created_at_epoch = ?
1162
+ WHERE id = ?`).run(update.title, update.narrative ?? null, update.facts ?? null, update.concepts ?? null, createdAt, createdAtEpoch, id);
1163
+ this.ftsDelete(existing);
1164
+ const refreshed = this.getObservationById(id);
1165
+ if (!refreshed)
1166
+ return null;
1167
+ this.ftsInsert(refreshed);
1168
+ return refreshed;
1169
+ }
1130
1170
  getObservationsByIds(ids, userId) {
1131
1171
  if (ids.length === 0)
1132
1172
  return [];
@@ -1398,8 +1438,8 @@ class MemDatabase {
1398
1438
  const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1399
1439
  const content = input.content.trim();
1400
1440
  const result = this.db.query(`INSERT INTO chat_messages (
1401
- session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
1402
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.session_id, input.project_id, input.role, content, input.user_id, input.device_id, input.agent ?? "claude-code", createdAt, input.remote_source_id ?? null);
1441
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id, source_kind, transcript_index
1442
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.session_id, input.project_id, input.role, content, input.user_id, input.device_id, input.agent ?? "claude-code", createdAt, input.remote_source_id ?? null, input.source_kind ?? "hook", input.transcript_index ?? null);
1403
1443
  return this.getChatMessageById(Number(result.lastInsertRowid));
1404
1444
  }
1405
1445
  getChatMessageById(id) {
@@ -1411,7 +1451,17 @@ class MemDatabase {
1411
1451
  getSessionChatMessages(sessionId, limit = 50) {
1412
1452
  return this.db.query(`SELECT * FROM chat_messages
1413
1453
  WHERE session_id = ?
1414
- ORDER BY created_at_epoch ASC, id ASC
1454
+ AND (
1455
+ source_kind = 'transcript'
1456
+ OR NOT EXISTS (
1457
+ SELECT 1 FROM chat_messages t2
1458
+ WHERE t2.session_id = chat_messages.session_id
1459
+ AND t2.source_kind = 'transcript'
1460
+ )
1461
+ )
1462
+ ORDER BY
1463
+ CASE WHEN transcript_index IS NULL THEN created_at_epoch ELSE transcript_index END ASC,
1464
+ id ASC
1415
1465
  LIMIT ?`).all(sessionId, limit);
1416
1466
  }
1417
1467
  getRecentChatMessages(projectId, limit = 20, userId) {
@@ -1419,11 +1469,27 @@ class MemDatabase {
1419
1469
  if (projectId !== null) {
1420
1470
  return this.db.query(`SELECT * FROM chat_messages
1421
1471
  WHERE project_id = ?${visibilityClause}
1472
+ AND (
1473
+ source_kind = 'transcript'
1474
+ OR NOT EXISTS (
1475
+ SELECT 1 FROM chat_messages t2
1476
+ WHERE t2.session_id = chat_messages.session_id
1477
+ AND t2.source_kind = 'transcript'
1478
+ )
1479
+ )
1422
1480
  ORDER BY created_at_epoch DESC, id DESC
1423
1481
  LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1424
1482
  }
1425
1483
  return this.db.query(`SELECT * FROM chat_messages
1426
1484
  WHERE 1 = 1${visibilityClause}
1485
+ AND (
1486
+ source_kind = 'transcript'
1487
+ OR NOT EXISTS (
1488
+ SELECT 1 FROM chat_messages t2
1489
+ WHERE t2.session_id = chat_messages.session_id
1490
+ AND t2.source_kind = 'transcript'
1491
+ )
1492
+ )
1427
1493
  ORDER BY created_at_epoch DESC, id DESC
1428
1494
  LIMIT ?`).all(...userId ? [userId] : [], limit);
1429
1495
  }
@@ -1434,14 +1500,33 @@ class MemDatabase {
1434
1500
  return this.db.query(`SELECT * FROM chat_messages
1435
1501
  WHERE project_id = ?
1436
1502
  AND lower(content) LIKE ?${visibilityClause}
1503
+ AND (
1504
+ source_kind = 'transcript'
1505
+ OR NOT EXISTS (
1506
+ SELECT 1 FROM chat_messages t2
1507
+ WHERE t2.session_id = chat_messages.session_id
1508
+ AND t2.source_kind = 'transcript'
1509
+ )
1510
+ )
1437
1511
  ORDER BY created_at_epoch DESC, id DESC
1438
1512
  LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
1439
1513
  }
1440
1514
  return this.db.query(`SELECT * FROM chat_messages
1441
1515
  WHERE lower(content) LIKE ?${visibilityClause}
1516
+ AND (
1517
+ source_kind = 'transcript'
1518
+ OR NOT EXISTS (
1519
+ SELECT 1 FROM chat_messages t2
1520
+ WHERE t2.session_id = chat_messages.session_id
1521
+ AND t2.source_kind = 'transcript'
1522
+ )
1523
+ )
1442
1524
  ORDER BY created_at_epoch DESC, id DESC
1443
1525
  LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1444
1526
  }
1527
+ getTranscriptChatMessage(sessionId, transcriptIndex) {
1528
+ return this.db.query("SELECT * FROM chat_messages WHERE session_id = ? AND transcript_index = ?").get(sessionId, transcriptIndex) ?? null;
1529
+ }
1445
1530
  addToOutbox(recordType, recordId) {
1446
1531
  const now = Math.floor(Date.now() / 1000);
1447
1532
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -2048,6 +2133,125 @@ function computeObservationPriority(obs, nowEpoch) {
2048
2133
  return computeBlendedScore(obs.quality, obs.created_at_epoch, nowEpoch) + observationTypeBoost(obs.type);
2049
2134
  }
2050
2135
 
2136
+ // src/tools/session-story.ts
2137
+ function getSessionStory(db, input) {
2138
+ const session = db.getSessionById(input.session_id);
2139
+ const summary = db.getSessionSummary(input.session_id);
2140
+ const prompts = db.getSessionUserPrompts(input.session_id, 50);
2141
+ const chatMessages = db.getSessionChatMessages(input.session_id, 50);
2142
+ const toolEvents = db.getSessionToolEvents(input.session_id, 100);
2143
+ const allObservations = db.getObservationsBySession(input.session_id);
2144
+ const handoffs = allObservations.filter((obs) => looksLikeHandoff(obs));
2145
+ const rollingHandoffDrafts = handoffs.filter((obs) => isDraftHandoff(obs));
2146
+ const savedHandoffs = handoffs.filter((obs) => !isDraftHandoff(obs));
2147
+ const observations = allObservations.filter((obs) => !looksLikeHandoff(obs));
2148
+ const metrics = db.getSessionMetrics(input.session_id);
2149
+ const projectName = session?.project_id !== null && session?.project_id !== undefined ? db.getProjectById(session.project_id)?.name ?? null : null;
2150
+ const latestRequest = prompts[prompts.length - 1]?.prompt?.trim() || summary?.request?.trim() || null;
2151
+ return {
2152
+ session,
2153
+ project_name: projectName,
2154
+ summary,
2155
+ prompts,
2156
+ chat_messages: chatMessages,
2157
+ tool_events: toolEvents,
2158
+ observations,
2159
+ handoffs,
2160
+ saved_handoffs: savedHandoffs,
2161
+ rolling_handoff_drafts: rollingHandoffDrafts,
2162
+ metrics,
2163
+ capture_state: classifyCaptureState({
2164
+ hasSummary: Boolean(summary?.request || summary?.completed),
2165
+ promptCount: prompts.length,
2166
+ toolEventCount: toolEvents.length
2167
+ }),
2168
+ capture_gaps: buildCaptureGaps({
2169
+ promptCount: prompts.length,
2170
+ toolEventCount: toolEvents.length,
2171
+ toolCallsCount: metrics?.tool_calls_count ?? 0,
2172
+ observationCount: observations.length,
2173
+ hasSummary: Boolean(summary?.request || summary?.completed)
2174
+ }),
2175
+ latest_request: latestRequest,
2176
+ recent_outcomes: collectRecentOutcomes(observations),
2177
+ hot_files: collectHotFiles(observations),
2178
+ provenance_summary: collectProvenanceSummary(observations)
2179
+ };
2180
+ }
2181
+ function classifyCaptureState(input) {
2182
+ if (input.promptCount > 0 && input.toolEventCount > 0)
2183
+ return "rich";
2184
+ if (input.promptCount > 0 || input.toolEventCount > 0)
2185
+ return "partial";
2186
+ if (input.hasSummary)
2187
+ return "summary-only";
2188
+ return "legacy";
2189
+ }
2190
+ function buildCaptureGaps(input) {
2191
+ const gaps = [];
2192
+ if (input.promptCount === 0)
2193
+ gaps.push("missing prompts");
2194
+ if (input.toolCallsCount > 0 && input.toolEventCount === 0) {
2195
+ gaps.push("missing raw tool chronology");
2196
+ } else if (input.toolEventCount === 0) {
2197
+ gaps.push("no tool events");
2198
+ }
2199
+ if (input.observationCount === 0 && input.hasSummary) {
2200
+ gaps.push("summary without reusable observations");
2201
+ }
2202
+ return gaps;
2203
+ }
2204
+ function collectRecentOutcomes(observations) {
2205
+ const seen = new Set;
2206
+ const outcomes = [];
2207
+ for (const obs of observations) {
2208
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
2209
+ continue;
2210
+ const title = obs.title.trim();
2211
+ if (!title || looksLikeFileOperationTitle(title))
2212
+ continue;
2213
+ const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
2214
+ if (seen.has(normalized))
2215
+ continue;
2216
+ seen.add(normalized);
2217
+ outcomes.push(title);
2218
+ if (outcomes.length >= 6)
2219
+ break;
2220
+ }
2221
+ return outcomes;
2222
+ }
2223
+ function collectHotFiles(observations) {
2224
+ const counts = new Map;
2225
+ for (const obs of observations) {
2226
+ for (const path of [...parseJsonArray(obs.files_modified), ...parseJsonArray(obs.files_read)]) {
2227
+ counts.set(path, (counts.get(path) ?? 0) + 1);
2228
+ }
2229
+ }
2230
+ return Array.from(counts.entries()).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)).slice(0, 8);
2231
+ }
2232
+ function parseJsonArray(value) {
2233
+ if (!value)
2234
+ return [];
2235
+ try {
2236
+ const parsed = JSON.parse(value);
2237
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
2238
+ } catch {
2239
+ return [];
2240
+ }
2241
+ }
2242
+ function looksLikeFileOperationTitle(value) {
2243
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
2244
+ }
2245
+ function collectProvenanceSummary(observations) {
2246
+ const counts = new Map;
2247
+ for (const obs of observations) {
2248
+ if (!obs.source_tool)
2249
+ continue;
2250
+ counts.set(obs.source_tool, (counts.get(obs.source_tool) ?? 0) + 1);
2251
+ }
2252
+ return Array.from(counts.entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
2253
+ }
2254
+
2051
2255
  // src/tools/save.ts
2052
2256
  import { relative, isAbsolute } from "node:path";
2053
2257
 
@@ -2729,8 +2933,87 @@ function toRelativePath(filePath, projectRoot) {
2729
2933
  }
2730
2934
 
2731
2935
  // src/tools/handoffs.ts
2936
+ async function upsertRollingHandoff(db, config, input) {
2937
+ const resolved = resolveTargetSession(db, input.cwd, config.user_id, input.session_id);
2938
+ if (!resolved.session) {
2939
+ return {
2940
+ success: false,
2941
+ reason: "No recent session found to draft a handoff yet"
2942
+ };
2943
+ }
2944
+ const story = getSessionStory(db, { session_id: resolved.session.session_id });
2945
+ if (!story.session) {
2946
+ return {
2947
+ success: false,
2948
+ reason: `Session ${resolved.session.session_id} not found`
2949
+ };
2950
+ }
2951
+ const includeChat = input.include_chat === true || input.include_chat !== false && shouldAutoIncludeChat(story);
2952
+ const chatLimit = Math.max(1, Math.min(input.chat_limit ?? 3, 6));
2953
+ const title = `Handoff Draft: ${buildHandoffTitle(story.summary, story.latest_request)}`;
2954
+ const narrative = buildHandoffNarrative(story.summary, story, {
2955
+ includeChat,
2956
+ chatLimit
2957
+ });
2958
+ const facts = buildHandoffFacts(story.summary, story);
2959
+ const concepts = buildDraftHandoffConcepts(story.project_name, story.capture_state);
2960
+ const existing = getSessionRollingHandoff(db, story.session.session_id);
2961
+ const now = Math.floor(Date.now() / 1000);
2962
+ if (existing) {
2963
+ const nextFacts = JSON.stringify(facts);
2964
+ const nextConcepts = JSON.stringify(concepts);
2965
+ const shouldRefresh = existing.title !== title || (existing.narrative ?? null) !== narrative || (existing.facts ?? null) !== nextFacts || (existing.concepts ?? null) !== nextConcepts || now - existing.created_at_epoch >= 120;
2966
+ if (!shouldRefresh) {
2967
+ return {
2968
+ success: true,
2969
+ observation_id: existing.id,
2970
+ session_id: story.session.session_id,
2971
+ title: existing.title
2972
+ };
2973
+ }
2974
+ const updated = db.updateObservationContent(existing.id, {
2975
+ title,
2976
+ narrative,
2977
+ facts: nextFacts,
2978
+ concepts: nextConcepts,
2979
+ created_at_epoch: now
2980
+ });
2981
+ if (!updated) {
2982
+ return {
2983
+ success: false,
2984
+ reason: "Failed to update rolling handoff draft"
2985
+ };
2986
+ }
2987
+ db.addToOutbox("observation", updated.id);
2988
+ return {
2989
+ success: true,
2990
+ observation_id: updated.id,
2991
+ session_id: story.session.session_id,
2992
+ title: updated.title
2993
+ };
2994
+ }
2995
+ const result = await saveObservation(db, config, {
2996
+ type: "message",
2997
+ title,
2998
+ narrative,
2999
+ facts,
3000
+ concepts,
3001
+ session_id: story.session.session_id,
3002
+ cwd: input.cwd,
3003
+ agent: "engrm-handoff",
3004
+ source_tool: "rolling_handoff"
3005
+ });
3006
+ return {
3007
+ success: result.success,
3008
+ observation_id: result.observation_id,
3009
+ session_id: story.session.session_id,
3010
+ title,
3011
+ reason: result.reason
3012
+ };
3013
+ }
2732
3014
  function getRecentHandoffs(db, input) {
2733
3015
  const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
3016
+ const queryLimit = input.current_device_id ? Math.max(limit, Math.min(limit * 5, 50)) : limit;
2734
3017
  const projectScoped = input.project_scoped !== false;
2735
3018
  let projectId = null;
2736
3019
  let projectName;
@@ -2758,18 +3041,188 @@ function getRecentHandoffs(db, input) {
2758
3041
  conditions.push("o.project_id = ?");
2759
3042
  params.push(projectId);
2760
3043
  }
2761
- params.push(limit);
3044
+ params.push(queryLimit);
2762
3045
  const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
2763
3046
  FROM observations o
2764
3047
  LEFT JOIN projects p ON p.id = o.project_id
2765
3048
  WHERE ${conditions.join(" AND ")}
2766
- ORDER BY o.created_at_epoch DESC, o.id DESC
2767
- LIMIT ?`).all(...params);
3049
+ ORDER BY o.created_at_epoch DESC, o.id DESC
3050
+ LIMIT ?`).all(...params);
3051
+ handoffs.sort((a, b) => compareHandoffs(a, b, input.current_device_id));
2768
3052
  return {
2769
- handoffs,
3053
+ handoffs: handoffs.slice(0, limit),
2770
3054
  project: projectName
2771
3055
  };
2772
3056
  }
3057
+ function formatHandoffSource(handoff) {
3058
+ const ageSeconds = Math.max(0, Math.floor(Date.now() / 1000) - handoff.created_at_epoch);
3059
+ const ageLabel = ageSeconds < 3600 ? `${Math.max(1, Math.floor(ageSeconds / 60) || 1)}m ago` : ageSeconds < 86400 ? `${Math.floor(ageSeconds / 3600)}h ago` : `${Math.floor(ageSeconds / 86400)}d ago`;
3060
+ return `from ${handoff.device_id} · ${ageLabel}`;
3061
+ }
3062
+ function isDraftHandoff(obs) {
3063
+ if (obs.title.startsWith("Handoff Draft:"))
3064
+ return true;
3065
+ const concepts = parseJsonArray2(obs.concepts);
3066
+ return concepts.includes("draft-handoff") || concepts.includes("auto-handoff");
3067
+ }
3068
+ function getSessionRollingHandoff(db, sessionId) {
3069
+ return db.db.query(`SELECT o.*, p.name AS project_name
3070
+ FROM observations o
3071
+ LEFT JOIN projects p ON p.id = o.project_id
3072
+ WHERE o.session_id = ?
3073
+ AND o.type = 'message'
3074
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
3075
+ AND o.superseded_by IS NULL
3076
+ AND (o.title LIKE 'Handoff Draft:%' OR o.concepts LIKE '%"draft-handoff"%')
3077
+ ORDER BY o.created_at_epoch DESC, o.id DESC
3078
+ LIMIT 1`).get(sessionId) ?? null;
3079
+ }
3080
+ function compareHandoffs(a, b, currentDeviceId) {
3081
+ const aDraft = isDraftHandoff(a) ? 1 : 0;
3082
+ const bDraft = isDraftHandoff(b) ? 1 : 0;
3083
+ if (aDraft !== bDraft)
3084
+ return aDraft - bDraft;
3085
+ if (currentDeviceId) {
3086
+ const aOther = a.device_id !== currentDeviceId ? 1 : 0;
3087
+ const bOther = b.device_id !== currentDeviceId ? 1 : 0;
3088
+ if (aOther !== bOther)
3089
+ return bOther - aOther;
3090
+ }
3091
+ if (b.created_at_epoch !== a.created_at_epoch) {
3092
+ return b.created_at_epoch - a.created_at_epoch;
3093
+ }
3094
+ return b.id - a.id;
3095
+ }
3096
+ function resolveTargetSession(db, cwd, userId, sessionId) {
3097
+ if (sessionId) {
3098
+ const session = db.getSessionById(sessionId);
3099
+ if (!session)
3100
+ return { session: null };
3101
+ const projectName = session.project_id ? db.getProjectById(session.project_id)?.name : undefined;
3102
+ return {
3103
+ session: {
3104
+ ...session,
3105
+ project_name: projectName ?? null,
3106
+ request: db.getSessionSummary(sessionId)?.request ?? null,
3107
+ completed: db.getSessionSummary(sessionId)?.completed ?? null,
3108
+ current_thread: db.getSessionSummary(sessionId)?.current_thread ?? null,
3109
+ capture_state: db.getSessionSummary(sessionId)?.capture_state ?? null,
3110
+ recent_tool_names: db.getSessionSummary(sessionId)?.recent_tool_names ?? null,
3111
+ hot_files: db.getSessionSummary(sessionId)?.hot_files ?? null,
3112
+ recent_outcomes: db.getSessionSummary(sessionId)?.recent_outcomes ?? null,
3113
+ prompt_count: db.getSessionUserPrompts(sessionId, 200).length,
3114
+ tool_event_count: db.getSessionToolEvents(sessionId, 200).length
3115
+ },
3116
+ projectName: projectName ?? undefined
3117
+ };
3118
+ }
3119
+ const detected = detectProject(cwd ?? process.cwd());
3120
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
3121
+ const sessions = db.getRecentSessions(project?.id ?? null, 10, userId);
3122
+ return {
3123
+ session: sessions[0] ?? null,
3124
+ projectName: project?.name
3125
+ };
3126
+ }
3127
+ function buildHandoffTitle(summary, latestRequest, explicit) {
3128
+ const chosen = explicit?.trim() || summary?.current_thread?.trim() || summary?.completed?.trim() || latestRequest?.trim() || "Current work";
3129
+ return compactLine(chosen) ?? "Current work";
3130
+ }
3131
+ function buildHandoffNarrative(summary, story, options) {
3132
+ const sections = [];
3133
+ if (summary?.request || story.latest_request) {
3134
+ sections.push(`Request: ${summary?.request ?? story.latest_request}`);
3135
+ }
3136
+ if (summary?.current_thread) {
3137
+ sections.push(`Current thread: ${summary.current_thread}`);
3138
+ }
3139
+ if (summary?.investigated) {
3140
+ sections.push(`Investigated: ${summary.investigated}`);
3141
+ }
3142
+ if (summary?.learned) {
3143
+ sections.push(`Learned: ${summary.learned}`);
3144
+ }
3145
+ if (summary?.completed) {
3146
+ sections.push(`Completed: ${summary.completed}`);
3147
+ }
3148
+ if (summary?.next_steps) {
3149
+ sections.push(`Next Steps: ${summary.next_steps}`);
3150
+ }
3151
+ if (story.recent_outcomes.length > 0) {
3152
+ sections.push(`Recent outcomes:
3153
+ ${story.recent_outcomes.slice(0, 5).map((item) => `- ${item}`).join(`
3154
+ `)}`);
3155
+ }
3156
+ if (story.hot_files.length > 0) {
3157
+ sections.push(`Hot files:
3158
+ ${story.hot_files.slice(0, 5).map((file) => `- ${file.path}`).join(`
3159
+ `)}`);
3160
+ }
3161
+ if (story.provenance_summary.length > 0) {
3162
+ sections.push(`Tool trail:
3163
+ ${story.provenance_summary.slice(0, 5).map((item) => `- ${item.tool}: ${item.count}`).join(`
3164
+ `)}`);
3165
+ }
3166
+ if (options.includeChat && story.chat_messages.length > 0) {
3167
+ const chatLines = story.chat_messages.slice(-options.chatLimit).map((msg) => `- [${msg.role}] ${compactLine(msg.content) ?? msg.content.slice(0, 120)}`);
3168
+ sections.push(`Chat snippets:
3169
+ ${chatLines.join(`
3170
+ `)}`);
3171
+ }
3172
+ return sections.filter(Boolean).join(`
3173
+
3174
+ `);
3175
+ }
3176
+ function shouldAutoIncludeChat(story) {
3177
+ if (story.chat_messages.length === 0)
3178
+ return false;
3179
+ const summary = story.summary;
3180
+ const thinSummary = !summary?.completed && !summary?.current_thread && story.recent_outcomes.length < 2;
3181
+ const thinChronology = story.capture_state !== "rich" || story.tool_events.length === 0;
3182
+ return thinSummary || thinChronology;
3183
+ }
3184
+ function buildHandoffFacts(summary, story) {
3185
+ const facts = [
3186
+ `session_id=${story.session?.session_id ?? "unknown"}`,
3187
+ `capture_state=${story.capture_state}`,
3188
+ story.project_name ? `project=${story.project_name}` : null,
3189
+ summary?.current_thread ? `current_thread=${summary.current_thread}` : null,
3190
+ story.hot_files[0] ? `hot_file=${story.hot_files[0].path}` : null,
3191
+ story.provenance_summary[0] ? `primary_tool=${story.provenance_summary[0].tool}` : null
3192
+ ];
3193
+ return facts.filter((item) => Boolean(item));
3194
+ }
3195
+ function buildDraftHandoffConcepts(projectName, captureState) {
3196
+ return [
3197
+ "handoff",
3198
+ "draft-handoff",
3199
+ "auto-handoff",
3200
+ `capture:${captureState}`,
3201
+ ...projectName ? [projectName] : []
3202
+ ];
3203
+ }
3204
+ function looksLikeHandoff(obs) {
3205
+ if (obs.title.startsWith("Handoff:") || obs.title.startsWith("Handoff Draft:"))
3206
+ return true;
3207
+ const concepts = parseJsonArray2(obs.concepts);
3208
+ return concepts.includes("handoff") || concepts.includes("session-handoff") || concepts.includes("draft-handoff");
3209
+ }
3210
+ function parseJsonArray2(value) {
3211
+ if (!value)
3212
+ return [];
3213
+ try {
3214
+ const parsed = JSON.parse(value);
3215
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
3216
+ } catch {
3217
+ return [];
3218
+ }
3219
+ }
3220
+ function compactLine(value) {
3221
+ const trimmed = value?.replace(/\s+/g, " ").trim();
3222
+ if (!trimmed)
3223
+ return null;
3224
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
3225
+ }
2773
3226
 
2774
3227
  // src/context/inject.ts
2775
3228
  function tokenizeProjectHint(text) {
@@ -2921,8 +3374,10 @@ function buildSessionContext(db, cwd, options = {}) {
2921
3374
  cwd,
2922
3375
  project_scoped: !isNewProject,
2923
3376
  user_id: opts.userId,
3377
+ current_device_id: opts.currentDeviceId,
2924
3378
  limit: 3
2925
3379
  }).handoffs;
3380
+ const recentChatMessages2 = !isNewProject && project ? db.getRecentChatMessages(project.id, 4, opts.userId) : [];
2926
3381
  return {
2927
3382
  project_name: projectName,
2928
3383
  canonical_id: canonicalId,
@@ -2934,7 +3389,8 @@ function buildSessionContext(db, cwd, options = {}) {
2934
3389
  recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
2935
3390
  projectTypeCounts: projectTypeCounts2,
2936
3391
  recentOutcomes: recentOutcomes2,
2937
- recentHandoffs: recentHandoffs2.length > 0 ? recentHandoffs2 : undefined
3392
+ recentHandoffs: recentHandoffs2.length > 0 ? recentHandoffs2 : undefined,
3393
+ recentChatMessages: recentChatMessages2.length > 0 ? recentChatMessages2 : undefined
2938
3394
  };
2939
3395
  }
2940
3396
  let remainingBudget = tokenBudget - 30;
@@ -2966,8 +3422,10 @@ function buildSessionContext(db, cwd, options = {}) {
2966
3422
  cwd,
2967
3423
  project_scoped: !isNewProject,
2968
3424
  user_id: opts.userId,
3425
+ current_device_id: opts.currentDeviceId,
2969
3426
  limit: 3
2970
3427
  }).handoffs;
3428
+ const recentChatMessages = !isNewProject ? db.getRecentChatMessages(projectId, 4, opts.userId) : [];
2971
3429
  let securityFindings = [];
2972
3430
  if (!isNewProject) {
2973
3431
  try {
@@ -3027,7 +3485,8 @@ function buildSessionContext(db, cwd, options = {}) {
3027
3485
  recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
3028
3486
  projectTypeCounts,
3029
3487
  recentOutcomes,
3030
- recentHandoffs: recentHandoffs.length > 0 ? recentHandoffs : undefined
3488
+ recentHandoffs: recentHandoffs.length > 0 ? recentHandoffs : undefined,
3489
+ recentChatMessages: recentChatMessages.length > 0 ? recentChatMessages : undefined
3031
3490
  };
3032
3491
  }
3033
3492
  function estimateObservationTokens(obs, index) {
@@ -3063,6 +3522,27 @@ function formatContextForInjection(context) {
3063
3522
  lines.push(`${context.session_count} relevant observation(s) from prior sessions:`);
3064
3523
  lines.push("");
3065
3524
  }
3525
+ if (context.recentHandoffs && context.recentHandoffs.length > 0) {
3526
+ lines.push("## Recent Handoffs");
3527
+ for (const handoff of context.recentHandoffs.slice(0, 3)) {
3528
+ const title = handoff.title.replace(/^Handoff(?: Draft)?:\s*/i, "").replace(/\s+·\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
3529
+ if (title) {
3530
+ lines.push(`- ${truncateText(`${title} (${formatHandoffSource(handoff)})`, 160)}`);
3531
+ }
3532
+ const narrative = handoff.narrative?.split(/\n{2,}/).map((part) => part.replace(/\s+/g, " ").trim()).find((part) => /^(Current thread:|Completed:|Next Steps:)/i.test(part));
3533
+ if (narrative) {
3534
+ lines.push(` ${truncateText(narrative, 180)}`);
3535
+ }
3536
+ }
3537
+ lines.push("");
3538
+ }
3539
+ if (context.recentChatMessages && context.recentChatMessages.length > 0) {
3540
+ lines.push("## Recent Chat");
3541
+ for (const message of context.recentChatMessages.slice(0, 4).reverse()) {
3542
+ lines.push(`- [${message.role}] ${truncateText(message.content.replace(/\s+/g, " ").trim(), 160)}`);
3543
+ }
3544
+ lines.push("");
3545
+ }
3066
3546
  if (context.recentPrompts && context.recentPrompts.length > 0) {
3067
3547
  const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
3068
3548
  if (promptLines.length > 0) {
@@ -3178,7 +3658,7 @@ function formatSessionBrief(summary) {
3178
3658
  return lines;
3179
3659
  }
3180
3660
  function chooseMeaningfulSessionHeadline(request, completed) {
3181
- if (request && !looksLikeFileOperationTitle(request))
3661
+ if (request && !looksLikeFileOperationTitle2(request))
3182
3662
  return request;
3183
3663
  const completedItems = extractMeaningfulLines(completed, 1);
3184
3664
  if (completedItems.length > 0)
@@ -3201,7 +3681,7 @@ function isMeaningfulPrompt(value) {
3201
3681
  return false;
3202
3682
  return /[a-z]{3,}/i.test(compact);
3203
3683
  }
3204
- function looksLikeFileOperationTitle(value) {
3684
+ function looksLikeFileOperationTitle2(value) {
3205
3685
  return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
3206
3686
  }
3207
3687
  function stripInlineSectionLabel(value) {
@@ -3210,7 +3690,7 @@ function stripInlineSectionLabel(value) {
3210
3690
  function extractMeaningfulLines(value, limit) {
3211
3691
  if (!value)
3212
3692
  return [];
3213
- return extractSummaryItems(value).map((line) => stripInlineSectionLabel(line)).filter((line) => line.length > 0 && !looksLikeFileOperationTitle(line)).slice(0, limit);
3693
+ return extractSummaryItems(value).map((line) => stripInlineSectionLabel(line)).filter((line) => line.length > 0 && !looksLikeFileOperationTitle2(line)).slice(0, limit);
3214
3694
  }
3215
3695
  function formatObservationDetailFromContext(obs) {
3216
3696
  if (obs.facts) {
@@ -3366,7 +3846,7 @@ function getRecentOutcomes(db, projectId, userId, recentSessions) {
3366
3846
  continue;
3367
3847
  const title = stripInlineSectionLabel(obs.title);
3368
3848
  const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
3369
- if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle(title))
3849
+ if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle2(title))
3370
3850
  continue;
3371
3851
  seen.add(normalized);
3372
3852
  picked.push(title);
@@ -3376,6 +3856,169 @@ function getRecentOutcomes(db, projectId, userId, recentSessions) {
3376
3856
  return picked;
3377
3857
  }
3378
3858
 
3859
+ // src/capture/transcript.ts
3860
+ import { readFileSync as readFileSync3, existsSync as existsSync3 } from "node:fs";
3861
+ import { join as join3 } from "node:path";
3862
+ import { homedir as homedir2 } from "node:os";
3863
+ function resolveTranscriptPath(sessionId, cwd, transcriptPath) {
3864
+ if (transcriptPath)
3865
+ return transcriptPath;
3866
+ const encodedCwd = cwd.replace(/\//g, "-");
3867
+ return join3(homedir2(), ".claude", "projects", encodedCwd, `${sessionId}.jsonl`);
3868
+ }
3869
+ function readTranscript(sessionId, cwd, transcriptPath) {
3870
+ const path = resolveTranscriptPath(sessionId, cwd, transcriptPath);
3871
+ if (!existsSync3(path))
3872
+ return [];
3873
+ let raw;
3874
+ try {
3875
+ raw = readFileSync3(path, "utf-8");
3876
+ } catch {
3877
+ return [];
3878
+ }
3879
+ const messages = [];
3880
+ for (const line of raw.split(`
3881
+ `)) {
3882
+ if (!line.trim())
3883
+ continue;
3884
+ let entry;
3885
+ try {
3886
+ entry = JSON.parse(line);
3887
+ } catch {
3888
+ continue;
3889
+ }
3890
+ const role = entry.role;
3891
+ if (role !== "user" && role !== "assistant")
3892
+ continue;
3893
+ const content = entry.content;
3894
+ if (typeof content === "string") {
3895
+ messages.push({ role, text: content });
3896
+ continue;
3897
+ }
3898
+ if (Array.isArray(content)) {
3899
+ const textParts = [];
3900
+ for (const block of content) {
3901
+ if (block.type === "text" && typeof block.text === "string") {
3902
+ textParts.push(block.text);
3903
+ }
3904
+ }
3905
+ if (textParts.length > 0) {
3906
+ messages.push({ role, text: textParts.join(`
3907
+ `) });
3908
+ }
3909
+ }
3910
+ }
3911
+ return messages;
3912
+ }
3913
+ function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
3914
+ const messages = readTranscript(sessionId, cwd, transcriptPath).map((message) => ({
3915
+ ...message,
3916
+ text: message.text.trim()
3917
+ })).filter((message) => message.text.length > 0);
3918
+ if (messages.length === 0)
3919
+ return { imported: 0, total: 0 };
3920
+ const session = db.getSessionById(sessionId);
3921
+ const projectId = session?.project_id ?? null;
3922
+ const now = Math.floor(Date.now() / 1000);
3923
+ let imported = 0;
3924
+ for (let index = 0;index < messages.length; index++) {
3925
+ const transcriptIndex = index + 1;
3926
+ if (db.getTranscriptChatMessage(sessionId, transcriptIndex))
3927
+ continue;
3928
+ const message = messages[index];
3929
+ const createdAtEpoch = Math.max(0, now - (messages.length - transcriptIndex));
3930
+ const row = db.insertChatMessage({
3931
+ session_id: sessionId,
3932
+ project_id: projectId,
3933
+ role: message.role,
3934
+ content: message.text,
3935
+ user_id: config.user_id,
3936
+ device_id: config.device_id,
3937
+ agent: "claude-code",
3938
+ created_at_epoch: createdAtEpoch,
3939
+ source_kind: "transcript",
3940
+ transcript_index: transcriptIndex
3941
+ });
3942
+ db.addToOutbox("chat_message", row.id);
3943
+ imported++;
3944
+ }
3945
+ return { imported, total: messages.length };
3946
+ }
3947
+ function truncateTranscript(messages, maxBytes = 50000) {
3948
+ const lines = [];
3949
+ for (const msg of messages) {
3950
+ lines.push(`[${msg.role}]: ${msg.text}`);
3951
+ }
3952
+ const full = lines.join(`
3953
+ `);
3954
+ if (Buffer.byteLength(full, "utf-8") <= maxBytes)
3955
+ return full;
3956
+ let result = "";
3957
+ for (let i = lines.length - 1;i >= 0; i--) {
3958
+ const candidate = lines[i] + `
3959
+ ` + result;
3960
+ if (Buffer.byteLength(candidate, "utf-8") > maxBytes)
3961
+ break;
3962
+ result = candidate;
3963
+ }
3964
+ return result.trim();
3965
+ }
3966
+ async function analyzeTranscript(config, transcript, sessionId) {
3967
+ if (!config.candengo_url || !config.candengo_api_key)
3968
+ return null;
3969
+ const url = `${config.candengo_url}/v1/mem/transcript-analysis`;
3970
+ const controller = new AbortController;
3971
+ const timeout = setTimeout(() => controller.abort(), 30000);
3972
+ try {
3973
+ const response = await fetch(url, {
3974
+ method: "POST",
3975
+ headers: {
3976
+ "Content-Type": "application/json",
3977
+ Authorization: `Bearer ${config.candengo_api_key}`
3978
+ },
3979
+ body: JSON.stringify({
3980
+ transcript,
3981
+ session_id: sessionId
3982
+ }),
3983
+ signal: controller.signal
3984
+ });
3985
+ if (!response.ok)
3986
+ return null;
3987
+ const data = await response.json();
3988
+ if (!Array.isArray(data.plans) || !Array.isArray(data.decisions) || !Array.isArray(data.insights)) {
3989
+ return null;
3990
+ }
3991
+ return data;
3992
+ } catch {
3993
+ return null;
3994
+ } finally {
3995
+ clearTimeout(timeout);
3996
+ }
3997
+ }
3998
+ async function saveTranscriptResults(db, config, results, sessionId, cwd) {
3999
+ let saved = 0;
4000
+ const items = [
4001
+ ...results.plans.map((item) => ({ item, type: "decision" })),
4002
+ ...results.decisions.map((item) => ({ item, type: "decision" })),
4003
+ ...results.insights.map((item) => ({ item, type: "discovery" }))
4004
+ ];
4005
+ for (const { item, type } of items) {
4006
+ if (!item.title || item.title.trim().length === 0)
4007
+ continue;
4008
+ const result = await saveObservation(db, config, {
4009
+ type,
4010
+ title: item.title.slice(0, 80),
4011
+ narrative: item.narrative,
4012
+ concepts: item.concepts,
4013
+ session_id: sessionId,
4014
+ cwd
4015
+ });
4016
+ if (result.success)
4017
+ saved++;
4018
+ }
4019
+ return saved;
4020
+ }
4021
+
3379
4022
  // hooks/pre-compact.ts
3380
4023
  function formatCurrentSessionContext(observations) {
3381
4024
  if (observations.length === 0)
@@ -3428,9 +4071,19 @@ async function main() {
3428
4071
  process.exit(0);
3429
4072
  }
3430
4073
  try {
4074
+ let importedChat = 0;
4075
+ if (event.session_id) {
4076
+ const chatSync = syncTranscriptChat(db, config, event.session_id, event.cwd);
4077
+ importedChat = chatSync.imported;
4078
+ await upsertRollingHandoff(db, config, {
4079
+ session_id: event.session_id,
4080
+ cwd: event.cwd
4081
+ });
4082
+ }
3431
4083
  const context = buildSessionContext(db, event.cwd, {
3432
4084
  tokenBudget: 800,
3433
- scope: config.search.scope
4085
+ scope: config.search.scope,
4086
+ currentDeviceId: config.device_id
3434
4087
  });
3435
4088
  if (context && context.observations.length > 0) {
3436
4089
  console.log(formatContextForInjection(context));
@@ -3456,6 +4109,9 @@ async function main() {
3456
4109
  if (sessionCount > 0) {
3457
4110
  console.error(`Engrm: ${sessionCount} session observation(s) carried forward`);
3458
4111
  }
4112
+ if (importedChat > 0) {
4113
+ console.error(`Engrm: ${importedChat} transcript chat message(s) preserved before compaction`);
4114
+ }
3459
4115
  } finally {
3460
4116
  db.close();
3461
4117
  }