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.
@@ -873,6 +873,19 @@ var MIGRATIONS = [
873
873
  CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
874
874
  CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
875
875
  `
876
+ },
877
+ {
878
+ version: 17,
879
+ description: "Track transcript-backed chat messages separately from hook chat",
880
+ sql: `
881
+ ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook';
882
+ ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER;
883
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind
884
+ ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC);
885
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript
886
+ ON chat_messages(session_id, transcript_index)
887
+ WHERE transcript_index IS NOT NULL;
888
+ `
876
889
  }
877
890
  ];
878
891
  function isVecExtensionLoaded(db) {
@@ -943,6 +956,9 @@ function inferLegacySchemaVersion(db) {
943
956
  if (syncOutboxSupportsChatMessages(db)) {
944
957
  version = Math.max(version, 16);
945
958
  }
959
+ if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
960
+ version = Math.max(version, 17);
961
+ }
946
962
  return version;
947
963
  }
948
964
  function runMigrations(db) {
@@ -1046,9 +1062,17 @@ function ensureChatMessageColumns(db) {
1046
1062
  db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
1047
1063
  }
1048
1064
  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");
1065
+ if (!columnExists(db, "chat_messages", "source_kind")) {
1066
+ db.exec("ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook'");
1067
+ }
1068
+ if (!columnExists(db, "chat_messages", "transcript_index")) {
1069
+ db.exec("ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER");
1070
+ }
1071
+ 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)");
1072
+ 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");
1049
1073
  const current = getSchemaVersion(db);
1050
- if (current < 15) {
1051
- db.exec("PRAGMA user_version = 15");
1074
+ if (current < 17) {
1075
+ db.exec("PRAGMA user_version = 17");
1052
1076
  }
1053
1077
  }
1054
1078
  function ensureSyncOutboxSupportsChatMessages(db) {
@@ -1333,6 +1357,22 @@ class MemDatabase {
1333
1357
  getObservationById(id) {
1334
1358
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
1335
1359
  }
1360
+ updateObservationContent(id, update) {
1361
+ const existing = this.getObservationById(id);
1362
+ if (!existing)
1363
+ return null;
1364
+ const createdAtEpoch = update.created_at_epoch ?? existing.created_at_epoch;
1365
+ const createdAt = new Date(createdAtEpoch * 1000).toISOString();
1366
+ this.db.query(`UPDATE observations
1367
+ SET title = ?, narrative = ?, facts = ?, concepts = ?, created_at = ?, created_at_epoch = ?
1368
+ WHERE id = ?`).run(update.title, update.narrative ?? null, update.facts ?? null, update.concepts ?? null, createdAt, createdAtEpoch, id);
1369
+ this.ftsDelete(existing);
1370
+ const refreshed = this.getObservationById(id);
1371
+ if (!refreshed)
1372
+ return null;
1373
+ this.ftsInsert(refreshed);
1374
+ return refreshed;
1375
+ }
1336
1376
  getObservationsByIds(ids, userId) {
1337
1377
  if (ids.length === 0)
1338
1378
  return [];
@@ -1604,8 +1644,8 @@ class MemDatabase {
1604
1644
  const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
1605
1645
  const content = input.content.trim();
1606
1646
  const result = this.db.query(`INSERT INTO chat_messages (
1607
- session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
1608
- ) 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);
1647
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id, source_kind, transcript_index
1648
+ ) 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);
1609
1649
  return this.getChatMessageById(Number(result.lastInsertRowid));
1610
1650
  }
1611
1651
  getChatMessageById(id) {
@@ -1617,7 +1657,17 @@ class MemDatabase {
1617
1657
  getSessionChatMessages(sessionId, limit = 50) {
1618
1658
  return this.db.query(`SELECT * FROM chat_messages
1619
1659
  WHERE session_id = ?
1620
- ORDER BY created_at_epoch ASC, id ASC
1660
+ AND (
1661
+ source_kind = 'transcript'
1662
+ OR NOT EXISTS (
1663
+ SELECT 1 FROM chat_messages t2
1664
+ WHERE t2.session_id = chat_messages.session_id
1665
+ AND t2.source_kind = 'transcript'
1666
+ )
1667
+ )
1668
+ ORDER BY
1669
+ CASE WHEN transcript_index IS NULL THEN created_at_epoch ELSE transcript_index END ASC,
1670
+ id ASC
1621
1671
  LIMIT ?`).all(sessionId, limit);
1622
1672
  }
1623
1673
  getRecentChatMessages(projectId, limit = 20, userId) {
@@ -1625,11 +1675,27 @@ class MemDatabase {
1625
1675
  if (projectId !== null) {
1626
1676
  return this.db.query(`SELECT * FROM chat_messages
1627
1677
  WHERE project_id = ?${visibilityClause}
1678
+ AND (
1679
+ source_kind = 'transcript'
1680
+ OR NOT EXISTS (
1681
+ SELECT 1 FROM chat_messages t2
1682
+ WHERE t2.session_id = chat_messages.session_id
1683
+ AND t2.source_kind = 'transcript'
1684
+ )
1685
+ )
1628
1686
  ORDER BY created_at_epoch DESC, id DESC
1629
1687
  LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
1630
1688
  }
1631
1689
  return this.db.query(`SELECT * FROM chat_messages
1632
1690
  WHERE 1 = 1${visibilityClause}
1691
+ AND (
1692
+ source_kind = 'transcript'
1693
+ OR NOT EXISTS (
1694
+ SELECT 1 FROM chat_messages t2
1695
+ WHERE t2.session_id = chat_messages.session_id
1696
+ AND t2.source_kind = 'transcript'
1697
+ )
1698
+ )
1633
1699
  ORDER BY created_at_epoch DESC, id DESC
1634
1700
  LIMIT ?`).all(...userId ? [userId] : [], limit);
1635
1701
  }
@@ -1640,14 +1706,33 @@ class MemDatabase {
1640
1706
  return this.db.query(`SELECT * FROM chat_messages
1641
1707
  WHERE project_id = ?
1642
1708
  AND lower(content) LIKE ?${visibilityClause}
1709
+ AND (
1710
+ source_kind = 'transcript'
1711
+ OR NOT EXISTS (
1712
+ SELECT 1 FROM chat_messages t2
1713
+ WHERE t2.session_id = chat_messages.session_id
1714
+ AND t2.source_kind = 'transcript'
1715
+ )
1716
+ )
1643
1717
  ORDER BY created_at_epoch DESC, id DESC
1644
1718
  LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
1645
1719
  }
1646
1720
  return this.db.query(`SELECT * FROM chat_messages
1647
1721
  WHERE lower(content) LIKE ?${visibilityClause}
1722
+ AND (
1723
+ source_kind = 'transcript'
1724
+ OR NOT EXISTS (
1725
+ SELECT 1 FROM chat_messages t2
1726
+ WHERE t2.session_id = chat_messages.session_id
1727
+ AND t2.source_kind = 'transcript'
1728
+ )
1729
+ )
1648
1730
  ORDER BY created_at_epoch DESC, id DESC
1649
1731
  LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
1650
1732
  }
1733
+ getTranscriptChatMessage(sessionId, transcriptIndex) {
1734
+ return this.db.query("SELECT * FROM chat_messages WHERE session_id = ? AND transcript_index = ?").get(sessionId, transcriptIndex) ?? null;
1735
+ }
1651
1736
  addToOutbox(recordType, recordId) {
1652
1737
  const now = Math.floor(Date.now() / 1000);
1653
1738
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -3562,6 +3647,580 @@ function parseJsonArray(value) {
3562
3647
  }
3563
3648
  }
3564
3649
 
3650
+ // src/capture/transcript.ts
3651
+ import { readFileSync as readFileSync4, existsSync as existsSync4 } from "node:fs";
3652
+ import { join as join4 } from "node:path";
3653
+ import { homedir as homedir3 } from "node:os";
3654
+ function resolveTranscriptPath(sessionId, cwd, transcriptPath) {
3655
+ if (transcriptPath)
3656
+ return transcriptPath;
3657
+ const encodedCwd = cwd.replace(/\//g, "-");
3658
+ return join4(homedir3(), ".claude", "projects", encodedCwd, `${sessionId}.jsonl`);
3659
+ }
3660
+ function readTranscript(sessionId, cwd, transcriptPath) {
3661
+ const path = resolveTranscriptPath(sessionId, cwd, transcriptPath);
3662
+ if (!existsSync4(path))
3663
+ return [];
3664
+ let raw;
3665
+ try {
3666
+ raw = readFileSync4(path, "utf-8");
3667
+ } catch {
3668
+ return [];
3669
+ }
3670
+ const messages = [];
3671
+ for (const line of raw.split(`
3672
+ `)) {
3673
+ if (!line.trim())
3674
+ continue;
3675
+ let entry;
3676
+ try {
3677
+ entry = JSON.parse(line);
3678
+ } catch {
3679
+ continue;
3680
+ }
3681
+ const role = entry.role;
3682
+ if (role !== "user" && role !== "assistant")
3683
+ continue;
3684
+ const content = entry.content;
3685
+ if (typeof content === "string") {
3686
+ messages.push({ role, text: content });
3687
+ continue;
3688
+ }
3689
+ if (Array.isArray(content)) {
3690
+ const textParts = [];
3691
+ for (const block of content) {
3692
+ if (block.type === "text" && typeof block.text === "string") {
3693
+ textParts.push(block.text);
3694
+ }
3695
+ }
3696
+ if (textParts.length > 0) {
3697
+ messages.push({ role, text: textParts.join(`
3698
+ `) });
3699
+ }
3700
+ }
3701
+ }
3702
+ return messages;
3703
+ }
3704
+ function syncTranscriptChat(db, config, sessionId, cwd, transcriptPath) {
3705
+ const messages = readTranscript(sessionId, cwd, transcriptPath).map((message) => ({
3706
+ ...message,
3707
+ text: message.text.trim()
3708
+ })).filter((message) => message.text.length > 0);
3709
+ if (messages.length === 0)
3710
+ return { imported: 0, total: 0 };
3711
+ const session = db.getSessionById(sessionId);
3712
+ const projectId = session?.project_id ?? null;
3713
+ const now = Math.floor(Date.now() / 1000);
3714
+ let imported = 0;
3715
+ for (let index = 0;index < messages.length; index++) {
3716
+ const transcriptIndex = index + 1;
3717
+ if (db.getTranscriptChatMessage(sessionId, transcriptIndex))
3718
+ continue;
3719
+ const message = messages[index];
3720
+ const createdAtEpoch = Math.max(0, now - (messages.length - transcriptIndex));
3721
+ const row = db.insertChatMessage({
3722
+ session_id: sessionId,
3723
+ project_id: projectId,
3724
+ role: message.role,
3725
+ content: message.text,
3726
+ user_id: config.user_id,
3727
+ device_id: config.device_id,
3728
+ agent: "claude-code",
3729
+ created_at_epoch: createdAtEpoch,
3730
+ source_kind: "transcript",
3731
+ transcript_index: transcriptIndex
3732
+ });
3733
+ db.addToOutbox("chat_message", row.id);
3734
+ imported++;
3735
+ }
3736
+ return { imported, total: messages.length };
3737
+ }
3738
+ function truncateTranscript(messages, maxBytes = 50000) {
3739
+ const lines = [];
3740
+ for (const msg of messages) {
3741
+ lines.push(`[${msg.role}]: ${msg.text}`);
3742
+ }
3743
+ const full = lines.join(`
3744
+ `);
3745
+ if (Buffer.byteLength(full, "utf-8") <= maxBytes)
3746
+ return full;
3747
+ let result = "";
3748
+ for (let i = lines.length - 1;i >= 0; i--) {
3749
+ const candidate = lines[i] + `
3750
+ ` + result;
3751
+ if (Buffer.byteLength(candidate, "utf-8") > maxBytes)
3752
+ break;
3753
+ result = candidate;
3754
+ }
3755
+ return result.trim();
3756
+ }
3757
+ async function analyzeTranscript(config, transcript, sessionId) {
3758
+ if (!config.candengo_url || !config.candengo_api_key)
3759
+ return null;
3760
+ const url = `${config.candengo_url}/v1/mem/transcript-analysis`;
3761
+ const controller = new AbortController;
3762
+ const timeout = setTimeout(() => controller.abort(), 30000);
3763
+ try {
3764
+ const response = await fetch(url, {
3765
+ method: "POST",
3766
+ headers: {
3767
+ "Content-Type": "application/json",
3768
+ Authorization: `Bearer ${config.candengo_api_key}`
3769
+ },
3770
+ body: JSON.stringify({
3771
+ transcript,
3772
+ session_id: sessionId
3773
+ }),
3774
+ signal: controller.signal
3775
+ });
3776
+ if (!response.ok)
3777
+ return null;
3778
+ const data = await response.json();
3779
+ if (!Array.isArray(data.plans) || !Array.isArray(data.decisions) || !Array.isArray(data.insights)) {
3780
+ return null;
3781
+ }
3782
+ return data;
3783
+ } catch {
3784
+ return null;
3785
+ } finally {
3786
+ clearTimeout(timeout);
3787
+ }
3788
+ }
3789
+ async function saveTranscriptResults(db, config, results, sessionId, cwd) {
3790
+ let saved = 0;
3791
+ const items = [
3792
+ ...results.plans.map((item) => ({ item, type: "decision" })),
3793
+ ...results.decisions.map((item) => ({ item, type: "decision" })),
3794
+ ...results.insights.map((item) => ({ item, type: "discovery" }))
3795
+ ];
3796
+ for (const { item, type } of items) {
3797
+ if (!item.title || item.title.trim().length === 0)
3798
+ continue;
3799
+ const result = await saveObservation(db, config, {
3800
+ type,
3801
+ title: item.title.slice(0, 80),
3802
+ narrative: item.narrative,
3803
+ concepts: item.concepts,
3804
+ session_id: sessionId,
3805
+ cwd
3806
+ });
3807
+ if (result.success)
3808
+ saved++;
3809
+ }
3810
+ return saved;
3811
+ }
3812
+
3813
+ // src/tools/session-story.ts
3814
+ function getSessionStory(db, input) {
3815
+ const session = db.getSessionById(input.session_id);
3816
+ const summary = db.getSessionSummary(input.session_id);
3817
+ const prompts = db.getSessionUserPrompts(input.session_id, 50);
3818
+ const chatMessages = db.getSessionChatMessages(input.session_id, 50);
3819
+ const toolEvents = db.getSessionToolEvents(input.session_id, 100);
3820
+ const allObservations = db.getObservationsBySession(input.session_id);
3821
+ const handoffs = allObservations.filter((obs) => looksLikeHandoff(obs));
3822
+ const rollingHandoffDrafts = handoffs.filter((obs) => isDraftHandoff(obs));
3823
+ const savedHandoffs = handoffs.filter((obs) => !isDraftHandoff(obs));
3824
+ const observations = allObservations.filter((obs) => !looksLikeHandoff(obs));
3825
+ const metrics = db.getSessionMetrics(input.session_id);
3826
+ const projectName = session?.project_id !== null && session?.project_id !== undefined ? db.getProjectById(session.project_id)?.name ?? null : null;
3827
+ const latestRequest = prompts[prompts.length - 1]?.prompt?.trim() || summary?.request?.trim() || null;
3828
+ return {
3829
+ session,
3830
+ project_name: projectName,
3831
+ summary,
3832
+ prompts,
3833
+ chat_messages: chatMessages,
3834
+ tool_events: toolEvents,
3835
+ observations,
3836
+ handoffs,
3837
+ saved_handoffs: savedHandoffs,
3838
+ rolling_handoff_drafts: rollingHandoffDrafts,
3839
+ metrics,
3840
+ capture_state: classifyCaptureState({
3841
+ hasSummary: Boolean(summary?.request || summary?.completed),
3842
+ promptCount: prompts.length,
3843
+ toolEventCount: toolEvents.length
3844
+ }),
3845
+ capture_gaps: buildCaptureGaps({
3846
+ promptCount: prompts.length,
3847
+ toolEventCount: toolEvents.length,
3848
+ toolCallsCount: metrics?.tool_calls_count ?? 0,
3849
+ observationCount: observations.length,
3850
+ hasSummary: Boolean(summary?.request || summary?.completed)
3851
+ }),
3852
+ latest_request: latestRequest,
3853
+ recent_outcomes: collectRecentOutcomes(observations),
3854
+ hot_files: collectHotFiles(observations),
3855
+ provenance_summary: collectProvenanceSummary(observations)
3856
+ };
3857
+ }
3858
+ function classifyCaptureState(input) {
3859
+ if (input.promptCount > 0 && input.toolEventCount > 0)
3860
+ return "rich";
3861
+ if (input.promptCount > 0 || input.toolEventCount > 0)
3862
+ return "partial";
3863
+ if (input.hasSummary)
3864
+ return "summary-only";
3865
+ return "legacy";
3866
+ }
3867
+ function buildCaptureGaps(input) {
3868
+ const gaps = [];
3869
+ if (input.promptCount === 0)
3870
+ gaps.push("missing prompts");
3871
+ if (input.toolCallsCount > 0 && input.toolEventCount === 0) {
3872
+ gaps.push("missing raw tool chronology");
3873
+ } else if (input.toolEventCount === 0) {
3874
+ gaps.push("no tool events");
3875
+ }
3876
+ if (input.observationCount === 0 && input.hasSummary) {
3877
+ gaps.push("summary without reusable observations");
3878
+ }
3879
+ return gaps;
3880
+ }
3881
+ function collectRecentOutcomes(observations) {
3882
+ const seen = new Set;
3883
+ const outcomes = [];
3884
+ for (const obs of observations) {
3885
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
3886
+ continue;
3887
+ const title = obs.title.trim();
3888
+ if (!title || looksLikeFileOperationTitle(title))
3889
+ continue;
3890
+ const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
3891
+ if (seen.has(normalized))
3892
+ continue;
3893
+ seen.add(normalized);
3894
+ outcomes.push(title);
3895
+ if (outcomes.length >= 6)
3896
+ break;
3897
+ }
3898
+ return outcomes;
3899
+ }
3900
+ function collectHotFiles(observations) {
3901
+ const counts = new Map;
3902
+ for (const obs of observations) {
3903
+ for (const path of [...parseJsonArray2(obs.files_modified), ...parseJsonArray2(obs.files_read)]) {
3904
+ counts.set(path, (counts.get(path) ?? 0) + 1);
3905
+ }
3906
+ }
3907
+ 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);
3908
+ }
3909
+ function parseJsonArray2(value) {
3910
+ if (!value)
3911
+ return [];
3912
+ try {
3913
+ const parsed = JSON.parse(value);
3914
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
3915
+ } catch {
3916
+ return [];
3917
+ }
3918
+ }
3919
+ function looksLikeFileOperationTitle(value) {
3920
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
3921
+ }
3922
+ function collectProvenanceSummary(observations) {
3923
+ const counts = new Map;
3924
+ for (const obs of observations) {
3925
+ if (!obs.source_tool)
3926
+ continue;
3927
+ counts.set(obs.source_tool, (counts.get(obs.source_tool) ?? 0) + 1);
3928
+ }
3929
+ 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);
3930
+ }
3931
+
3932
+ // src/tools/handoffs.ts
3933
+ async function upsertRollingHandoff(db, config, input) {
3934
+ const resolved = resolveTargetSession(db, input.cwd, config.user_id, input.session_id);
3935
+ if (!resolved.session) {
3936
+ return {
3937
+ success: false,
3938
+ reason: "No recent session found to draft a handoff yet"
3939
+ };
3940
+ }
3941
+ const story = getSessionStory(db, { session_id: resolved.session.session_id });
3942
+ if (!story.session) {
3943
+ return {
3944
+ success: false,
3945
+ reason: `Session ${resolved.session.session_id} not found`
3946
+ };
3947
+ }
3948
+ const includeChat = input.include_chat === true || input.include_chat !== false && shouldAutoIncludeChat(story);
3949
+ const chatLimit = Math.max(1, Math.min(input.chat_limit ?? 3, 6));
3950
+ const title = `Handoff Draft: ${buildHandoffTitle(story.summary, story.latest_request)}`;
3951
+ const narrative = buildHandoffNarrative(story.summary, story, {
3952
+ includeChat,
3953
+ chatLimit
3954
+ });
3955
+ const facts = buildHandoffFacts(story.summary, story);
3956
+ const concepts = buildDraftHandoffConcepts(story.project_name, story.capture_state);
3957
+ const existing = getSessionRollingHandoff(db, story.session.session_id);
3958
+ const now = Math.floor(Date.now() / 1000);
3959
+ if (existing) {
3960
+ const nextFacts = JSON.stringify(facts);
3961
+ const nextConcepts = JSON.stringify(concepts);
3962
+ const shouldRefresh = existing.title !== title || (existing.narrative ?? null) !== narrative || (existing.facts ?? null) !== nextFacts || (existing.concepts ?? null) !== nextConcepts || now - existing.created_at_epoch >= 120;
3963
+ if (!shouldRefresh) {
3964
+ return {
3965
+ success: true,
3966
+ observation_id: existing.id,
3967
+ session_id: story.session.session_id,
3968
+ title: existing.title
3969
+ };
3970
+ }
3971
+ const updated = db.updateObservationContent(existing.id, {
3972
+ title,
3973
+ narrative,
3974
+ facts: nextFacts,
3975
+ concepts: nextConcepts,
3976
+ created_at_epoch: now
3977
+ });
3978
+ if (!updated) {
3979
+ return {
3980
+ success: false,
3981
+ reason: "Failed to update rolling handoff draft"
3982
+ };
3983
+ }
3984
+ db.addToOutbox("observation", updated.id);
3985
+ return {
3986
+ success: true,
3987
+ observation_id: updated.id,
3988
+ session_id: story.session.session_id,
3989
+ title: updated.title
3990
+ };
3991
+ }
3992
+ const result = await saveObservation(db, config, {
3993
+ type: "message",
3994
+ title,
3995
+ narrative,
3996
+ facts,
3997
+ concepts,
3998
+ session_id: story.session.session_id,
3999
+ cwd: input.cwd,
4000
+ agent: "engrm-handoff",
4001
+ source_tool: "rolling_handoff"
4002
+ });
4003
+ return {
4004
+ success: result.success,
4005
+ observation_id: result.observation_id,
4006
+ session_id: story.session.session_id,
4007
+ title,
4008
+ reason: result.reason
4009
+ };
4010
+ }
4011
+ function getRecentHandoffs(db, input) {
4012
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
4013
+ const queryLimit = input.current_device_id ? Math.max(limit, Math.min(limit * 5, 50)) : limit;
4014
+ const projectScoped = input.project_scoped !== false;
4015
+ let projectId = null;
4016
+ let projectName;
4017
+ if (projectScoped) {
4018
+ const cwd = input.cwd ?? process.cwd();
4019
+ const detected = detectProject(cwd);
4020
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
4021
+ if (project) {
4022
+ projectId = project.id;
4023
+ projectName = project.name;
4024
+ }
4025
+ }
4026
+ const conditions = [
4027
+ "o.type = 'message'",
4028
+ "o.lifecycle IN ('active', 'aging', 'pinned')",
4029
+ "o.superseded_by IS NULL",
4030
+ `(o.title LIKE 'Handoff:%' OR o.concepts LIKE '%"handoff"%')`
4031
+ ];
4032
+ const params = [];
4033
+ if (input.user_id) {
4034
+ conditions.push("(o.sensitivity != 'personal' OR o.user_id = ?)");
4035
+ params.push(input.user_id);
4036
+ }
4037
+ if (projectId !== null) {
4038
+ conditions.push("o.project_id = ?");
4039
+ params.push(projectId);
4040
+ }
4041
+ params.push(queryLimit);
4042
+ const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
4043
+ FROM observations o
4044
+ LEFT JOIN projects p ON p.id = o.project_id
4045
+ WHERE ${conditions.join(" AND ")}
4046
+ ORDER BY o.created_at_epoch DESC, o.id DESC
4047
+ LIMIT ?`).all(...params);
4048
+ handoffs.sort((a, b) => compareHandoffs(a, b, input.current_device_id));
4049
+ return {
4050
+ handoffs: handoffs.slice(0, limit),
4051
+ project: projectName
4052
+ };
4053
+ }
4054
+ function formatHandoffSource(handoff) {
4055
+ const ageSeconds = Math.max(0, Math.floor(Date.now() / 1000) - handoff.created_at_epoch);
4056
+ 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`;
4057
+ return `from ${handoff.device_id} · ${ageLabel}`;
4058
+ }
4059
+ function isDraftHandoff(obs) {
4060
+ if (obs.title.startsWith("Handoff Draft:"))
4061
+ return true;
4062
+ const concepts = parseJsonArray3(obs.concepts);
4063
+ return concepts.includes("draft-handoff") || concepts.includes("auto-handoff");
4064
+ }
4065
+ function getSessionRollingHandoff(db, sessionId) {
4066
+ return db.db.query(`SELECT o.*, p.name AS project_name
4067
+ FROM observations o
4068
+ LEFT JOIN projects p ON p.id = o.project_id
4069
+ WHERE o.session_id = ?
4070
+ AND o.type = 'message'
4071
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
4072
+ AND o.superseded_by IS NULL
4073
+ AND (o.title LIKE 'Handoff Draft:%' OR o.concepts LIKE '%"draft-handoff"%')
4074
+ ORDER BY o.created_at_epoch DESC, o.id DESC
4075
+ LIMIT 1`).get(sessionId) ?? null;
4076
+ }
4077
+ function compareHandoffs(a, b, currentDeviceId) {
4078
+ const aDraft = isDraftHandoff(a) ? 1 : 0;
4079
+ const bDraft = isDraftHandoff(b) ? 1 : 0;
4080
+ if (aDraft !== bDraft)
4081
+ return aDraft - bDraft;
4082
+ if (currentDeviceId) {
4083
+ const aOther = a.device_id !== currentDeviceId ? 1 : 0;
4084
+ const bOther = b.device_id !== currentDeviceId ? 1 : 0;
4085
+ if (aOther !== bOther)
4086
+ return bOther - aOther;
4087
+ }
4088
+ if (b.created_at_epoch !== a.created_at_epoch) {
4089
+ return b.created_at_epoch - a.created_at_epoch;
4090
+ }
4091
+ return b.id - a.id;
4092
+ }
4093
+ function resolveTargetSession(db, cwd, userId, sessionId) {
4094
+ if (sessionId) {
4095
+ const session = db.getSessionById(sessionId);
4096
+ if (!session)
4097
+ return { session: null };
4098
+ const projectName = session.project_id ? db.getProjectById(session.project_id)?.name : undefined;
4099
+ return {
4100
+ session: {
4101
+ ...session,
4102
+ project_name: projectName ?? null,
4103
+ request: db.getSessionSummary(sessionId)?.request ?? null,
4104
+ completed: db.getSessionSummary(sessionId)?.completed ?? null,
4105
+ current_thread: db.getSessionSummary(sessionId)?.current_thread ?? null,
4106
+ capture_state: db.getSessionSummary(sessionId)?.capture_state ?? null,
4107
+ recent_tool_names: db.getSessionSummary(sessionId)?.recent_tool_names ?? null,
4108
+ hot_files: db.getSessionSummary(sessionId)?.hot_files ?? null,
4109
+ recent_outcomes: db.getSessionSummary(sessionId)?.recent_outcomes ?? null,
4110
+ prompt_count: db.getSessionUserPrompts(sessionId, 200).length,
4111
+ tool_event_count: db.getSessionToolEvents(sessionId, 200).length
4112
+ },
4113
+ projectName: projectName ?? undefined
4114
+ };
4115
+ }
4116
+ const detected = detectProject(cwd ?? process.cwd());
4117
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
4118
+ const sessions = db.getRecentSessions(project?.id ?? null, 10, userId);
4119
+ return {
4120
+ session: sessions[0] ?? null,
4121
+ projectName: project?.name
4122
+ };
4123
+ }
4124
+ function buildHandoffTitle(summary, latestRequest, explicit) {
4125
+ const chosen = explicit?.trim() || summary?.current_thread?.trim() || summary?.completed?.trim() || latestRequest?.trim() || "Current work";
4126
+ return compactLine2(chosen) ?? "Current work";
4127
+ }
4128
+ function buildHandoffNarrative(summary, story, options) {
4129
+ const sections = [];
4130
+ if (summary?.request || story.latest_request) {
4131
+ sections.push(`Request: ${summary?.request ?? story.latest_request}`);
4132
+ }
4133
+ if (summary?.current_thread) {
4134
+ sections.push(`Current thread: ${summary.current_thread}`);
4135
+ }
4136
+ if (summary?.investigated) {
4137
+ sections.push(`Investigated: ${summary.investigated}`);
4138
+ }
4139
+ if (summary?.learned) {
4140
+ sections.push(`Learned: ${summary.learned}`);
4141
+ }
4142
+ if (summary?.completed) {
4143
+ sections.push(`Completed: ${summary.completed}`);
4144
+ }
4145
+ if (summary?.next_steps) {
4146
+ sections.push(`Next Steps: ${summary.next_steps}`);
4147
+ }
4148
+ if (story.recent_outcomes.length > 0) {
4149
+ sections.push(`Recent outcomes:
4150
+ ${story.recent_outcomes.slice(0, 5).map((item) => `- ${item}`).join(`
4151
+ `)}`);
4152
+ }
4153
+ if (story.hot_files.length > 0) {
4154
+ sections.push(`Hot files:
4155
+ ${story.hot_files.slice(0, 5).map((file) => `- ${file.path}`).join(`
4156
+ `)}`);
4157
+ }
4158
+ if (story.provenance_summary.length > 0) {
4159
+ sections.push(`Tool trail:
4160
+ ${story.provenance_summary.slice(0, 5).map((item) => `- ${item.tool}: ${item.count}`).join(`
4161
+ `)}`);
4162
+ }
4163
+ if (options.includeChat && story.chat_messages.length > 0) {
4164
+ const chatLines = story.chat_messages.slice(-options.chatLimit).map((msg) => `- [${msg.role}] ${compactLine2(msg.content) ?? msg.content.slice(0, 120)}`);
4165
+ sections.push(`Chat snippets:
4166
+ ${chatLines.join(`
4167
+ `)}`);
4168
+ }
4169
+ return sections.filter(Boolean).join(`
4170
+
4171
+ `);
4172
+ }
4173
+ function shouldAutoIncludeChat(story) {
4174
+ if (story.chat_messages.length === 0)
4175
+ return false;
4176
+ const summary = story.summary;
4177
+ const thinSummary = !summary?.completed && !summary?.current_thread && story.recent_outcomes.length < 2;
4178
+ const thinChronology = story.capture_state !== "rich" || story.tool_events.length === 0;
4179
+ return thinSummary || thinChronology;
4180
+ }
4181
+ function buildHandoffFacts(summary, story) {
4182
+ const facts = [
4183
+ `session_id=${story.session?.session_id ?? "unknown"}`,
4184
+ `capture_state=${story.capture_state}`,
4185
+ story.project_name ? `project=${story.project_name}` : null,
4186
+ summary?.current_thread ? `current_thread=${summary.current_thread}` : null,
4187
+ story.hot_files[0] ? `hot_file=${story.hot_files[0].path}` : null,
4188
+ story.provenance_summary[0] ? `primary_tool=${story.provenance_summary[0].tool}` : null
4189
+ ];
4190
+ return facts.filter((item) => Boolean(item));
4191
+ }
4192
+ function buildDraftHandoffConcepts(projectName, captureState) {
4193
+ return [
4194
+ "handoff",
4195
+ "draft-handoff",
4196
+ "auto-handoff",
4197
+ `capture:${captureState}`,
4198
+ ...projectName ? [projectName] : []
4199
+ ];
4200
+ }
4201
+ function looksLikeHandoff(obs) {
4202
+ if (obs.title.startsWith("Handoff:") || obs.title.startsWith("Handoff Draft:"))
4203
+ return true;
4204
+ const concepts = parseJsonArray3(obs.concepts);
4205
+ return concepts.includes("handoff") || concepts.includes("session-handoff") || concepts.includes("draft-handoff");
4206
+ }
4207
+ function parseJsonArray3(value) {
4208
+ if (!value)
4209
+ return [];
4210
+ try {
4211
+ const parsed = JSON.parse(value);
4212
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
4213
+ } catch {
4214
+ return [];
4215
+ }
4216
+ }
4217
+ function compactLine2(value) {
4218
+ const trimmed = value?.replace(/\s+/g, " ").trim();
4219
+ if (!trimmed)
4220
+ return null;
4221
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
4222
+ }
4223
+
3565
4224
  // hooks/post-tool-use.ts
3566
4225
  async function main() {
3567
4226
  const raw = await readStdin();
@@ -3589,6 +4248,7 @@ async function main() {
3589
4248
  try {
3590
4249
  if (event.session_id) {
3591
4250
  persistRawToolChronology(event, config.user_id, config.device_id);
4251
+ syncTranscriptChat(db, config, event.session_id, event.cwd);
3592
4252
  }
3593
4253
  const textToScan = extractScanText(event);
3594
4254
  if (textToScan) {
@@ -3666,7 +4326,7 @@ async function main() {
3666
4326
  }), 1000);
3667
4327
  if (observed) {
3668
4328
  const result = await saveObservation(db, config, observed);
3669
- updateRollingSummaryFromObservation(db, result.observation_id, event, config.user_id);
4329
+ await updateRollingSummaryFromObservation(db, result.observation_id, event, config.user_id, config);
3670
4330
  incrementObserverSaveCount(event.session_id);
3671
4331
  saved = true;
3672
4332
  }
@@ -3685,7 +4345,7 @@ async function main() {
3685
4345
  cwd: event.cwd,
3686
4346
  source_tool: event.tool_name
3687
4347
  });
3688
- updateRollingSummaryFromObservation(db, result.observation_id, event, config.user_id);
4348
+ await updateRollingSummaryFromObservation(db, result.observation_id, event, config.user_id, config);
3689
4349
  incrementObserverSaveCount(event.session_id);
3690
4350
  }
3691
4351
  }
@@ -3750,7 +4410,7 @@ function detectProjectForEvent(event) {
3750
4410
  const touchedPaths = extractTouchedPaths(event);
3751
4411
  return touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, event.cwd) : detectProject(event.cwd);
3752
4412
  }
3753
- function updateRollingSummaryFromObservation(db, observationId, event, userId) {
4413
+ async function updateRollingSummaryFromObservation(db, observationId, event, userId, config) {
3754
4414
  if (!observationId || !event.session_id)
3755
4415
  return;
3756
4416
  const observation = db.getObservationById(observationId);
@@ -3782,6 +4442,10 @@ function updateRollingSummaryFromObservation(db, observationId, event, userId) {
3782
4442
  recent_outcomes: JSON.stringify(handoff.recent_outcomes)
3783
4443
  });
3784
4444
  db.addToOutbox("summary", summary.id);
4445
+ await upsertRollingHandoff(db, config, {
4446
+ session_id: event.session_id,
4447
+ cwd: event.cwd
4448
+ });
3785
4449
  }
3786
4450
  function extractTouchedPaths(event) {
3787
4451
  const paths = [];
@@ -3853,16 +4517,16 @@ function extractScanText(event) {
3853
4517
  }
3854
4518
  function incrementRecallMetrics(sessionId, hit) {
3855
4519
  try {
3856
- const { existsSync: existsSync4, readFileSync: readFileSync4, writeFileSync: writeFileSync3, mkdirSync: mkdirSync3 } = __require("node:fs");
3857
- const { join: join4 } = __require("node:path");
3858
- const { homedir: homedir3 } = __require("node:os");
3859
- const dir = join4(homedir3(), ".engrm", "observer-sessions");
3860
- const path = join4(dir, `${sessionId}.json`);
4520
+ const { existsSync: existsSync5, readFileSync: readFileSync5, writeFileSync: writeFileSync3, mkdirSync: mkdirSync3 } = __require("node:fs");
4521
+ const { join: join5 } = __require("node:path");
4522
+ const { homedir: homedir4 } = __require("node:os");
4523
+ const dir = join5(homedir4(), ".engrm", "observer-sessions");
4524
+ const path = join5(dir, `${sessionId}.json`);
3861
4525
  let state = {};
3862
- if (existsSync4(path)) {
3863
- state = JSON.parse(readFileSync4(path, "utf-8"));
4526
+ if (existsSync5(path)) {
4527
+ state = JSON.parse(readFileSync5(path, "utf-8"));
3864
4528
  } else {
3865
- if (!existsSync4(dir))
4529
+ if (!existsSync5(dir))
3866
4530
  mkdirSync3(dir, { recursive: true });
3867
4531
  }
3868
4532
  state.recallAttempts = (state.recallAttempts || 0) + 1;