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.
- package/README.md +53 -7
- package/dist/cli.js +103 -5
- package/dist/hooks/elicitation-result.js +90 -5
- package/dist/hooks/post-tool-use.js +680 -16
- package/dist/hooks/pre-compact.js +719 -27
- package/dist/hooks/sentinel.js +90 -5
- package/dist/hooks/session-start.js +1442 -170
- package/dist/hooks/stop.js +546 -8
- package/dist/hooks/user-prompt-submit.js +1390 -6
- package/dist/server.js +738 -76
- package/package.json +1 -1
package/dist/hooks/stop.js
CHANGED
|
@@ -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 <
|
|
1158
|
-
db.exec("PRAGMA user_version =
|
|
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
|
-
|
|
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.
|
|
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) {
|