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