engrm 0.4.23 → 0.4.26

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.
@@ -980,6 +980,19 @@ var MIGRATIONS = [
980
980
  CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
981
981
  CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
982
982
  `
983
+ },
984
+ {
985
+ version: 17,
986
+ description: "Track transcript-backed chat messages separately from hook chat",
987
+ sql: `
988
+ ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook';
989
+ ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER;
990
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind
991
+ ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC);
992
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript
993
+ ON chat_messages(session_id, transcript_index)
994
+ WHERE transcript_index IS NOT NULL;
995
+ `
983
996
  }
984
997
  ];
985
998
  function isVecExtensionLoaded(db) {
@@ -1050,6 +1063,9 @@ function inferLegacySchemaVersion(db) {
1050
1063
  if (syncOutboxSupportsChatMessages(db)) {
1051
1064
  version = Math.max(version, 16);
1052
1065
  }
1066
+ if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
1067
+ version = Math.max(version, 17);
1068
+ }
1053
1069
  return version;
1054
1070
  }
1055
1071
  function runMigrations(db) {
@@ -1153,9 +1169,17 @@ function ensureChatMessageColumns(db) {
1153
1169
  db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
1154
1170
  }
1155
1171
  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");
1172
+ if (!columnExists(db, "chat_messages", "source_kind")) {
1173
+ db.exec("ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook'");
1174
+ }
1175
+ if (!columnExists(db, "chat_messages", "transcript_index")) {
1176
+ db.exec("ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER");
1177
+ }
1178
+ 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)");
1179
+ 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");
1156
1180
  const current = getSchemaVersion(db);
1157
- if (current < 15) {
1158
- db.exec("PRAGMA user_version = 15");
1181
+ if (current < 17) {
1182
+ db.exec("PRAGMA user_version = 17");
1159
1183
  }
1160
1184
  }
1161
1185
  function ensureSyncOutboxSupportsChatMessages(db) {
@@ -1360,6 +1384,22 @@ class MemDatabase {
1360
1384
  getObservationById(id) {
1361
1385
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
1362
1386
  }
1387
+ updateObservationContent(id, update) {
1388
+ const existing = this.getObservationById(id);
1389
+ if (!existing)
1390
+ return null;
1391
+ const createdAtEpoch = update.created_at_epoch ?? existing.created_at_epoch;
1392
+ const createdAt = new Date(createdAtEpoch * 1000).toISOString();
1393
+ this.db.query(`UPDATE observations
1394
+ SET title = ?, narrative = ?, facts = ?, concepts = ?, created_at = ?, created_at_epoch = ?
1395
+ WHERE id = ?`).run(update.title, update.narrative ?? null, update.facts ?? null, update.concepts ?? null, createdAt, createdAtEpoch, id);
1396
+ this.ftsDelete(existing);
1397
+ const refreshed = this.getObservationById(id);
1398
+ if (!refreshed)
1399
+ return null;
1400
+ this.ftsInsert(refreshed);
1401
+ return refreshed;
1402
+ }
1363
1403
  getObservationsByIds(ids, userId) {
1364
1404
  if (ids.length === 0)
1365
1405
  return [];
@@ -1631,8 +1671,8 @@ class MemDatabase {
1631
1671
  const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1632
1672
  const content = input.content.trim();
1633
1673
  const result = this.db.query(`INSERT INTO chat_messages (
1634
- session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
1635
- ) 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);
1674
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id, source_kind, transcript_index
1675
+ ) 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);
1636
1676
  return this.getChatMessageById(Number(result.lastInsertRowid));
1637
1677
  }
1638
1678
  getChatMessageById(id) {
@@ -1644,7 +1684,17 @@ class MemDatabase {
1644
1684
  getSessionChatMessages(sessionId, limit = 50) {
1645
1685
  return this.db.query(`SELECT * FROM chat_messages
1646
1686
  WHERE session_id = ?
1647
- ORDER BY created_at_epoch ASC, id ASC
1687
+ AND (
1688
+ source_kind = 'transcript'
1689
+ OR NOT EXISTS (
1690
+ SELECT 1 FROM chat_messages t2
1691
+ WHERE t2.session_id = chat_messages.session_id
1692
+ AND t2.source_kind = 'transcript'
1693
+ )
1694
+ )
1695
+ ORDER BY
1696
+ CASE WHEN transcript_index IS NULL THEN created_at_epoch ELSE transcript_index END ASC,
1697
+ id ASC
1648
1698
  LIMIT ?`).all(sessionId, limit);
1649
1699
  }
1650
1700
  getRecentChatMessages(projectId, limit = 20, userId) {
@@ -1652,11 +1702,27 @@ class MemDatabase {
1652
1702
  if (projectId !== null) {
1653
1703
  return this.db.query(`SELECT * FROM chat_messages
1654
1704
  WHERE project_id = ?${visibilityClause}
1705
+ AND (
1706
+ source_kind = 'transcript'
1707
+ OR NOT EXISTS (
1708
+ SELECT 1 FROM chat_messages t2
1709
+ WHERE t2.session_id = chat_messages.session_id
1710
+ AND t2.source_kind = 'transcript'
1711
+ )
1712
+ )
1655
1713
  ORDER BY created_at_epoch DESC, id DESC
1656
1714
  LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1657
1715
  }
1658
1716
  return this.db.query(`SELECT * FROM chat_messages
1659
1717
  WHERE 1 = 1${visibilityClause}
1718
+ AND (
1719
+ source_kind = 'transcript'
1720
+ OR NOT EXISTS (
1721
+ SELECT 1 FROM chat_messages t2
1722
+ WHERE t2.session_id = chat_messages.session_id
1723
+ AND t2.source_kind = 'transcript'
1724
+ )
1725
+ )
1660
1726
  ORDER BY created_at_epoch DESC, id DESC
1661
1727
  LIMIT ?`).all(...userId ? [userId] : [], limit);
1662
1728
  }
@@ -1667,14 +1733,33 @@ class MemDatabase {
1667
1733
  return this.db.query(`SELECT * FROM chat_messages
1668
1734
  WHERE project_id = ?
1669
1735
  AND lower(content) LIKE ?${visibilityClause}
1736
+ AND (
1737
+ source_kind = 'transcript'
1738
+ OR NOT EXISTS (
1739
+ SELECT 1 FROM chat_messages t2
1740
+ WHERE t2.session_id = chat_messages.session_id
1741
+ AND t2.source_kind = 'transcript'
1742
+ )
1743
+ )
1670
1744
  ORDER BY created_at_epoch DESC, id DESC
1671
1745
  LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
1672
1746
  }
1673
1747
  return this.db.query(`SELECT * FROM chat_messages
1674
1748
  WHERE lower(content) LIKE ?${visibilityClause}
1749
+ AND (
1750
+ source_kind = 'transcript'
1751
+ OR NOT EXISTS (
1752
+ SELECT 1 FROM chat_messages t2
1753
+ WHERE t2.session_id = chat_messages.session_id
1754
+ AND t2.source_kind = 'transcript'
1755
+ )
1756
+ )
1675
1757
  ORDER BY created_at_epoch DESC, id DESC
1676
1758
  LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1677
1759
  }
1760
+ getTranscriptChatMessage(sessionId, transcriptIndex) {
1761
+ return this.db.query("SELECT * FROM chat_messages WHERE session_id = ? AND transcript_index = ?").get(sessionId, transcriptIndex) ?? null;
1762
+ }
1678
1763
  addToOutbox(recordType, recordId) {
1679
1764
  const now = Math.floor(Date.now() / 1000);
1680
1765
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -2335,7 +2420,9 @@ function buildChatVectorDocument(chat, config, project) {
2335
2420
  role: chat.role,
2336
2421
  session_id: chat.session_id,
2337
2422
  created_at_epoch: chat.created_at_epoch,
2338
- local_id: chat.id
2423
+ local_id: chat.id,
2424
+ source_kind: chat.source_kind,
2425
+ transcript_index: chat.transcript_index
2339
2426
  }
2340
2427
  };
2341
2428
  }
@@ -2922,7 +3009,7 @@ function buildBeacon(db, config, sessionId, metrics) {
2922
3009
  sentinel_used: valueSignals.security_findings_count > 0,
2923
3010
  risk_score: riskScore,
2924
3011
  stacks_detected: stacks,
2925
- client_version: "0.4.23",
3012
+ client_version: "0.4.26",
2926
3013
  context_observations_injected: metrics?.contextObsInjected ?? 0,
2927
3014
  context_total_available: metrics?.contextTotalAvailable ?? 0,
2928
3015
  recall_attempts: metrics?.recallAttempts ?? 0,
@@ -3823,6 +3910,40 @@ function readTranscript(sessionId, cwd, transcriptPath) {
3823
3910
  }
3824
3911
  return messages;
3825
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
+ }
3826
3947
  function truncateTranscript(messages, maxBytes = 50000) {
3827
3948
  const lines = [];
3828
3949
  for (const msg of messages) {
@@ -3898,6 +4019,417 @@ async function saveTranscriptResults(db, config, results, sessionId, cwd) {
3898
4019
  return saved;
3899
4020
  }
3900
4021
 
4022
+ // src/tools/session-story.ts
4023
+ function getSessionStory(db, input) {
4024
+ const session = db.getSessionById(input.session_id);
4025
+ const summary = db.getSessionSummary(input.session_id);
4026
+ const prompts = db.getSessionUserPrompts(input.session_id, 50);
4027
+ const chatMessages = db.getSessionChatMessages(input.session_id, 50);
4028
+ const toolEvents = db.getSessionToolEvents(input.session_id, 100);
4029
+ const allObservations = db.getObservationsBySession(input.session_id);
4030
+ const handoffs = allObservations.filter((obs) => looksLikeHandoff(obs));
4031
+ const rollingHandoffDrafts = handoffs.filter((obs) => isDraftHandoff(obs));
4032
+ const savedHandoffs = handoffs.filter((obs) => !isDraftHandoff(obs));
4033
+ const observations = allObservations.filter((obs) => !looksLikeHandoff(obs));
4034
+ const metrics = db.getSessionMetrics(input.session_id);
4035
+ const projectName = session?.project_id !== null && session?.project_id !== undefined ? db.getProjectById(session.project_id)?.name ?? null : null;
4036
+ const latestRequest = prompts[prompts.length - 1]?.prompt?.trim() || summary?.request?.trim() || null;
4037
+ return {
4038
+ session,
4039
+ project_name: projectName,
4040
+ summary,
4041
+ prompts,
4042
+ chat_messages: chatMessages,
4043
+ tool_events: toolEvents,
4044
+ observations,
4045
+ handoffs,
4046
+ saved_handoffs: savedHandoffs,
4047
+ rolling_handoff_drafts: rollingHandoffDrafts,
4048
+ metrics,
4049
+ capture_state: classifyCaptureState({
4050
+ hasSummary: Boolean(summary?.request || summary?.completed),
4051
+ promptCount: prompts.length,
4052
+ toolEventCount: toolEvents.length
4053
+ }),
4054
+ capture_gaps: buildCaptureGaps({
4055
+ promptCount: prompts.length,
4056
+ toolEventCount: toolEvents.length,
4057
+ toolCallsCount: metrics?.tool_calls_count ?? 0,
4058
+ observationCount: observations.length,
4059
+ hasSummary: Boolean(summary?.request || summary?.completed)
4060
+ }),
4061
+ latest_request: latestRequest,
4062
+ recent_outcomes: collectRecentOutcomes(observations),
4063
+ hot_files: collectHotFiles(observations),
4064
+ provenance_summary: collectProvenanceSummary(observations)
4065
+ };
4066
+ }
4067
+ function classifyCaptureState(input) {
4068
+ if (input.promptCount > 0 && input.toolEventCount > 0)
4069
+ return "rich";
4070
+ if (input.promptCount > 0 || input.toolEventCount > 0)
4071
+ return "partial";
4072
+ if (input.hasSummary)
4073
+ return "summary-only";
4074
+ return "legacy";
4075
+ }
4076
+ function buildCaptureGaps(input) {
4077
+ const gaps = [];
4078
+ if (input.promptCount === 0)
4079
+ gaps.push("missing prompts");
4080
+ if (input.toolCallsCount > 0 && input.toolEventCount === 0) {
4081
+ gaps.push("missing raw tool chronology");
4082
+ } else if (input.toolEventCount === 0) {
4083
+ gaps.push("no tool events");
4084
+ }
4085
+ if (input.observationCount === 0 && input.hasSummary) {
4086
+ gaps.push("summary without reusable observations");
4087
+ }
4088
+ return gaps;
4089
+ }
4090
+ function collectRecentOutcomes(observations) {
4091
+ const seen = new Set;
4092
+ const outcomes = [];
4093
+ for (const obs of observations) {
4094
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
4095
+ continue;
4096
+ const title = obs.title.trim();
4097
+ if (!title || looksLikeFileOperationTitle(title))
4098
+ continue;
4099
+ const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
4100
+ if (seen.has(normalized))
4101
+ continue;
4102
+ seen.add(normalized);
4103
+ outcomes.push(title);
4104
+ if (outcomes.length >= 6)
4105
+ break;
4106
+ }
4107
+ return outcomes;
4108
+ }
4109
+ function collectHotFiles(observations) {
4110
+ const counts = new Map;
4111
+ for (const obs of observations) {
4112
+ for (const path of [...parseJsonArray3(obs.files_modified), ...parseJsonArray3(obs.files_read)]) {
4113
+ counts.set(path, (counts.get(path) ?? 0) + 1);
4114
+ }
4115
+ }
4116
+ 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);
4117
+ }
4118
+ function parseJsonArray3(value) {
4119
+ if (!value)
4120
+ return [];
4121
+ try {
4122
+ const parsed = JSON.parse(value);
4123
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
4124
+ } catch {
4125
+ return [];
4126
+ }
4127
+ }
4128
+ function looksLikeFileOperationTitle(value) {
4129
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
4130
+ }
4131
+ function collectProvenanceSummary(observations) {
4132
+ const counts = new Map;
4133
+ for (const obs of observations) {
4134
+ if (!obs.source_tool)
4135
+ continue;
4136
+ counts.set(obs.source_tool, (counts.get(obs.source_tool) ?? 0) + 1);
4137
+ }
4138
+ 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);
4139
+ }
4140
+
4141
+ // src/tools/handoffs.ts
4142
+ async function upsertRollingHandoff(db, config, input) {
4143
+ const resolved = resolveTargetSession(db, input.cwd, config.user_id, input.session_id);
4144
+ if (!resolved.session) {
4145
+ return {
4146
+ success: false,
4147
+ reason: "No recent session found to draft a handoff yet"
4148
+ };
4149
+ }
4150
+ const story = getSessionStory(db, { session_id: resolved.session.session_id });
4151
+ if (!story.session) {
4152
+ return {
4153
+ success: false,
4154
+ reason: `Session ${resolved.session.session_id} not found`
4155
+ };
4156
+ }
4157
+ const includeChat = input.include_chat === true || input.include_chat !== false && shouldAutoIncludeChat(story);
4158
+ const chatLimit = Math.max(1, Math.min(input.chat_limit ?? 3, 6));
4159
+ const title = `Handoff Draft: ${buildHandoffTitle(story.summary, story.latest_request)}`;
4160
+ const narrative = buildHandoffNarrative(story.summary, story, {
4161
+ includeChat,
4162
+ chatLimit
4163
+ });
4164
+ const facts = buildHandoffFacts(story.summary, story);
4165
+ const concepts = buildDraftHandoffConcepts(story.project_name, story.capture_state);
4166
+ const existing = getSessionRollingHandoff(db, story.session.session_id);
4167
+ const now = Math.floor(Date.now() / 1000);
4168
+ if (existing) {
4169
+ const nextFacts = JSON.stringify(facts);
4170
+ const nextConcepts = JSON.stringify(concepts);
4171
+ const shouldRefresh = existing.title !== title || (existing.narrative ?? null) !== narrative || (existing.facts ?? null) !== nextFacts || (existing.concepts ?? null) !== nextConcepts || now - existing.created_at_epoch >= 120;
4172
+ if (!shouldRefresh) {
4173
+ return {
4174
+ success: true,
4175
+ observation_id: existing.id,
4176
+ session_id: story.session.session_id,
4177
+ title: existing.title
4178
+ };
4179
+ }
4180
+ const updated = db.updateObservationContent(existing.id, {
4181
+ title,
4182
+ narrative,
4183
+ facts: nextFacts,
4184
+ concepts: nextConcepts,
4185
+ created_at_epoch: now
4186
+ });
4187
+ if (!updated) {
4188
+ return {
4189
+ success: false,
4190
+ reason: "Failed to update rolling handoff draft"
4191
+ };
4192
+ }
4193
+ db.addToOutbox("observation", updated.id);
4194
+ return {
4195
+ success: true,
4196
+ observation_id: updated.id,
4197
+ session_id: story.session.session_id,
4198
+ title: updated.title
4199
+ };
4200
+ }
4201
+ const result = await saveObservation(db, config, {
4202
+ type: "message",
4203
+ title,
4204
+ narrative,
4205
+ facts,
4206
+ concepts,
4207
+ session_id: story.session.session_id,
4208
+ cwd: input.cwd,
4209
+ agent: "engrm-handoff",
4210
+ source_tool: "rolling_handoff"
4211
+ });
4212
+ return {
4213
+ success: result.success,
4214
+ observation_id: result.observation_id,
4215
+ session_id: story.session.session_id,
4216
+ title,
4217
+ reason: result.reason
4218
+ };
4219
+ }
4220
+ function getRecentHandoffs(db, input) {
4221
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
4222
+ const queryLimit = input.current_device_id ? Math.max(limit, Math.min(limit * 5, 50)) : limit;
4223
+ const projectScoped = input.project_scoped !== false;
4224
+ let projectId = null;
4225
+ let projectName;
4226
+ if (projectScoped) {
4227
+ const cwd = input.cwd ?? process.cwd();
4228
+ const detected = detectProject(cwd);
4229
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
4230
+ if (project) {
4231
+ projectId = project.id;
4232
+ projectName = project.name;
4233
+ }
4234
+ }
4235
+ const conditions = [
4236
+ "o.type = 'message'",
4237
+ "o.lifecycle IN ('active', 'aging', 'pinned')",
4238
+ "o.superseded_by IS NULL",
4239
+ `(o.title LIKE 'Handoff:%' OR o.concepts LIKE '%"handoff"%')`
4240
+ ];
4241
+ const params = [];
4242
+ if (input.user_id) {
4243
+ conditions.push("(o.sensitivity != 'personal' OR o.user_id = ?)");
4244
+ params.push(input.user_id);
4245
+ }
4246
+ if (projectId !== null) {
4247
+ conditions.push("o.project_id = ?");
4248
+ params.push(projectId);
4249
+ }
4250
+ params.push(queryLimit);
4251
+ const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
4252
+ FROM observations o
4253
+ LEFT JOIN projects p ON p.id = o.project_id
4254
+ WHERE ${conditions.join(" AND ")}
4255
+ ORDER BY o.created_at_epoch DESC, o.id DESC
4256
+ LIMIT ?`).all(...params);
4257
+ handoffs.sort((a, b) => compareHandoffs(a, b, input.current_device_id));
4258
+ return {
4259
+ handoffs: handoffs.slice(0, limit),
4260
+ project: projectName
4261
+ };
4262
+ }
4263
+ function formatHandoffSource(handoff) {
4264
+ const ageSeconds = Math.max(0, Math.floor(Date.now() / 1000) - handoff.created_at_epoch);
4265
+ 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`;
4266
+ return `from ${handoff.device_id} · ${ageLabel}`;
4267
+ }
4268
+ function isDraftHandoff(obs) {
4269
+ if (obs.title.startsWith("Handoff Draft:"))
4270
+ return true;
4271
+ const concepts = parseJsonArray4(obs.concepts);
4272
+ return concepts.includes("draft-handoff") || concepts.includes("auto-handoff");
4273
+ }
4274
+ function getSessionRollingHandoff(db, sessionId) {
4275
+ return db.db.query(`SELECT o.*, p.name AS project_name
4276
+ FROM observations o
4277
+ LEFT JOIN projects p ON p.id = o.project_id
4278
+ WHERE o.session_id = ?
4279
+ AND o.type = 'message'
4280
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
4281
+ AND o.superseded_by IS NULL
4282
+ AND (o.title LIKE 'Handoff Draft:%' OR o.concepts LIKE '%"draft-handoff"%')
4283
+ ORDER BY o.created_at_epoch DESC, o.id DESC
4284
+ LIMIT 1`).get(sessionId) ?? null;
4285
+ }
4286
+ function compareHandoffs(a, b, currentDeviceId) {
4287
+ const aDraft = isDraftHandoff(a) ? 1 : 0;
4288
+ const bDraft = isDraftHandoff(b) ? 1 : 0;
4289
+ if (aDraft !== bDraft)
4290
+ return aDraft - bDraft;
4291
+ if (currentDeviceId) {
4292
+ const aOther = a.device_id !== currentDeviceId ? 1 : 0;
4293
+ const bOther = b.device_id !== currentDeviceId ? 1 : 0;
4294
+ if (aOther !== bOther)
4295
+ return bOther - aOther;
4296
+ }
4297
+ if (b.created_at_epoch !== a.created_at_epoch) {
4298
+ return b.created_at_epoch - a.created_at_epoch;
4299
+ }
4300
+ return b.id - a.id;
4301
+ }
4302
+ function resolveTargetSession(db, cwd, userId, sessionId) {
4303
+ if (sessionId) {
4304
+ const session = db.getSessionById(sessionId);
4305
+ if (!session)
4306
+ return { session: null };
4307
+ const projectName = session.project_id ? db.getProjectById(session.project_id)?.name : undefined;
4308
+ return {
4309
+ session: {
4310
+ ...session,
4311
+ project_name: projectName ?? null,
4312
+ request: db.getSessionSummary(sessionId)?.request ?? null,
4313
+ completed: db.getSessionSummary(sessionId)?.completed ?? null,
4314
+ current_thread: db.getSessionSummary(sessionId)?.current_thread ?? null,
4315
+ capture_state: db.getSessionSummary(sessionId)?.capture_state ?? null,
4316
+ recent_tool_names: db.getSessionSummary(sessionId)?.recent_tool_names ?? null,
4317
+ hot_files: db.getSessionSummary(sessionId)?.hot_files ?? null,
4318
+ recent_outcomes: db.getSessionSummary(sessionId)?.recent_outcomes ?? null,
4319
+ prompt_count: db.getSessionUserPrompts(sessionId, 200).length,
4320
+ tool_event_count: db.getSessionToolEvents(sessionId, 200).length
4321
+ },
4322
+ projectName: projectName ?? undefined
4323
+ };
4324
+ }
4325
+ const detected = detectProject(cwd ?? process.cwd());
4326
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
4327
+ const sessions = db.getRecentSessions(project?.id ?? null, 10, userId);
4328
+ return {
4329
+ session: sessions[0] ?? null,
4330
+ projectName: project?.name
4331
+ };
4332
+ }
4333
+ function buildHandoffTitle(summary, latestRequest, explicit) {
4334
+ const chosen = explicit?.trim() || summary?.current_thread?.trim() || summary?.completed?.trim() || latestRequest?.trim() || "Current work";
4335
+ return compactLine2(chosen) ?? "Current work";
4336
+ }
4337
+ function buildHandoffNarrative(summary, story, options) {
4338
+ const sections = [];
4339
+ if (summary?.request || story.latest_request) {
4340
+ sections.push(`Request: ${summary?.request ?? story.latest_request}`);
4341
+ }
4342
+ if (summary?.current_thread) {
4343
+ sections.push(`Current thread: ${summary.current_thread}`);
4344
+ }
4345
+ if (summary?.investigated) {
4346
+ sections.push(`Investigated: ${summary.investigated}`);
4347
+ }
4348
+ if (summary?.learned) {
4349
+ sections.push(`Learned: ${summary.learned}`);
4350
+ }
4351
+ if (summary?.completed) {
4352
+ sections.push(`Completed: ${summary.completed}`);
4353
+ }
4354
+ if (summary?.next_steps) {
4355
+ sections.push(`Next Steps: ${summary.next_steps}`);
4356
+ }
4357
+ if (story.recent_outcomes.length > 0) {
4358
+ sections.push(`Recent outcomes:
4359
+ ${story.recent_outcomes.slice(0, 5).map((item) => `- ${item}`).join(`
4360
+ `)}`);
4361
+ }
4362
+ if (story.hot_files.length > 0) {
4363
+ sections.push(`Hot files:
4364
+ ${story.hot_files.slice(0, 5).map((file) => `- ${file.path}`).join(`
4365
+ `)}`);
4366
+ }
4367
+ if (story.provenance_summary.length > 0) {
4368
+ sections.push(`Tool trail:
4369
+ ${story.provenance_summary.slice(0, 5).map((item) => `- ${item.tool}: ${item.count}`).join(`
4370
+ `)}`);
4371
+ }
4372
+ if (options.includeChat && story.chat_messages.length > 0) {
4373
+ const chatLines = story.chat_messages.slice(-options.chatLimit).map((msg) => `- [${msg.role}] ${compactLine2(msg.content) ?? msg.content.slice(0, 120)}`);
4374
+ sections.push(`Chat snippets:
4375
+ ${chatLines.join(`
4376
+ `)}`);
4377
+ }
4378
+ return sections.filter(Boolean).join(`
4379
+
4380
+ `);
4381
+ }
4382
+ function shouldAutoIncludeChat(story) {
4383
+ if (story.chat_messages.length === 0)
4384
+ return false;
4385
+ const summary = story.summary;
4386
+ const thinSummary = !summary?.completed && !summary?.current_thread && story.recent_outcomes.length < 2;
4387
+ const thinChronology = story.capture_state !== "rich" || story.tool_events.length === 0;
4388
+ return thinSummary || thinChronology;
4389
+ }
4390
+ function buildHandoffFacts(summary, story) {
4391
+ const facts = [
4392
+ `session_id=${story.session?.session_id ?? "unknown"}`,
4393
+ `capture_state=${story.capture_state}`,
4394
+ story.project_name ? `project=${story.project_name}` : null,
4395
+ summary?.current_thread ? `current_thread=${summary.current_thread}` : null,
4396
+ story.hot_files[0] ? `hot_file=${story.hot_files[0].path}` : null,
4397
+ story.provenance_summary[0] ? `primary_tool=${story.provenance_summary[0].tool}` : null
4398
+ ];
4399
+ return facts.filter((item) => Boolean(item));
4400
+ }
4401
+ function buildDraftHandoffConcepts(projectName, captureState) {
4402
+ return [
4403
+ "handoff",
4404
+ "draft-handoff",
4405
+ "auto-handoff",
4406
+ `capture:${captureState}`,
4407
+ ...projectName ? [projectName] : []
4408
+ ];
4409
+ }
4410
+ function looksLikeHandoff(obs) {
4411
+ if (obs.title.startsWith("Handoff:") || obs.title.startsWith("Handoff Draft:"))
4412
+ return true;
4413
+ const concepts = parseJsonArray4(obs.concepts);
4414
+ return concepts.includes("handoff") || concepts.includes("session-handoff") || concepts.includes("draft-handoff");
4415
+ }
4416
+ function parseJsonArray4(value) {
4417
+ if (!value)
4418
+ return [];
4419
+ try {
4420
+ const parsed = JSON.parse(value);
4421
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
4422
+ } catch {
4423
+ return [];
4424
+ }
4425
+ }
4426
+ function compactLine2(value) {
4427
+ const trimmed = value?.replace(/\s+/g, " ").trim();
4428
+ if (!trimmed)
4429
+ return null;
4430
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
4431
+ }
4432
+
3901
4433
  // hooks/stop.ts
3902
4434
  function printRetrospective(summary) {
3903
4435
  const lines = [];
@@ -3945,6 +4477,7 @@ async function main() {
3945
4477
  try {
3946
4478
  if (event.session_id) {
3947
4479
  db.completeSession(event.session_id);
4480
+ syncTranscriptChat(db, config, event.session_id, event.cwd, event.transcript_path);
3948
4481
  if (event.last_assistant_message) {
3949
4482
  try {
3950
4483
  const detected = detectProject(event.cwd);
@@ -3963,7 +4496,8 @@ async function main() {
3963
4496
  content: event.last_assistant_message,
3964
4497
  user_id: config.user_id,
3965
4498
  device_id: config.device_id,
3966
- agent: "claude-code"
4499
+ agent: "claude-code",
4500
+ source_kind: "hook"
3967
4501
  });
3968
4502
  db.addToOutbox("chat_message", chatMessage.id);
3969
4503
  }
@@ -3980,6 +4514,10 @@ async function main() {
3980
4514
  if (summary) {
3981
4515
  const row = db.upsertSessionSummary(summary);
3982
4516
  db.addToOutbox("summary", row.id);
4517
+ await upsertRollingHandoff(db, config, {
4518
+ session_id: event.session_id,
4519
+ cwd: event.cwd
4520
+ });
3983
4521
  let securityFindings = [];
3984
4522
  try {
3985
4523
  if (session?.project_id) {