engrm 0.4.22 → 0.4.23

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/dist/server.js CHANGED
@@ -13827,7 +13827,7 @@ var MIGRATIONS = [
13827
13827
  -- Sync outbox (offline-first queue)
13828
13828
  CREATE TABLE sync_outbox (
13829
13829
  id INTEGER PRIMARY KEY AUTOINCREMENT,
13830
- record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary')),
13830
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
13831
13831
  record_id INTEGER NOT NULL,
13832
13832
  status TEXT DEFAULT 'pending' CHECK (status IN (
13833
13833
  'pending', 'syncing', 'synced', 'failed'
@@ -14120,6 +14120,18 @@ var MIGRATIONS = [
14120
14120
  ON tool_events(created_at_epoch DESC, id DESC);
14121
14121
  `
14122
14122
  },
14123
+ {
14124
+ version: 11,
14125
+ description: "Add observation provenance from tool and prompt chronology",
14126
+ sql: `
14127
+ ALTER TABLE observations ADD COLUMN source_tool TEXT;
14128
+ ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
14129
+ CREATE INDEX IF NOT EXISTS idx_observations_source_tool
14130
+ ON observations(source_tool, created_at_epoch DESC);
14131
+ CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
14132
+ ON observations(session_id, source_prompt_number DESC);
14133
+ `
14134
+ },
14123
14135
  {
14124
14136
  version: 12,
14125
14137
  description: "Add synced handoff metadata to session summaries",
@@ -14131,15 +14143,79 @@ var MIGRATIONS = [
14131
14143
  `
14132
14144
  },
14133
14145
  {
14134
- version: 11,
14135
- description: "Add observation provenance from tool and prompt chronology",
14146
+ version: 13,
14147
+ description: "Add current_thread to session summaries",
14136
14148
  sql: `
14137
- ALTER TABLE observations ADD COLUMN source_tool TEXT;
14138
- ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
14139
- CREATE INDEX IF NOT EXISTS idx_observations_source_tool
14140
- ON observations(source_tool, created_at_epoch DESC);
14141
- CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
14142
- ON observations(session_id, source_prompt_number DESC);
14149
+ ALTER TABLE session_summaries ADD COLUMN current_thread TEXT;
14150
+ `
14151
+ },
14152
+ {
14153
+ version: 14,
14154
+ description: "Add chat_messages lane for raw conversation recall",
14155
+ sql: `
14156
+ CREATE TABLE IF NOT EXISTS chat_messages (
14157
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14158
+ session_id TEXT NOT NULL,
14159
+ project_id INTEGER REFERENCES projects(id),
14160
+ role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
14161
+ content TEXT NOT NULL,
14162
+ user_id TEXT NOT NULL,
14163
+ device_id TEXT NOT NULL,
14164
+ agent TEXT DEFAULT 'claude-code',
14165
+ created_at_epoch INTEGER NOT NULL
14166
+ );
14167
+
14168
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_session
14169
+ ON chat_messages(session_id, created_at_epoch DESC, id DESC);
14170
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_project
14171
+ ON chat_messages(project_id, created_at_epoch DESC, id DESC);
14172
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_created
14173
+ ON chat_messages(created_at_epoch DESC, id DESC);
14174
+ `
14175
+ },
14176
+ {
14177
+ version: 15,
14178
+ description: "Add remote_source_id for chat message sync deduplication",
14179
+ sql: `
14180
+ ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT;
14181
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_remote_source
14182
+ ON chat_messages(remote_source_id)
14183
+ WHERE remote_source_id IS NOT NULL;
14184
+ `
14185
+ },
14186
+ {
14187
+ version: 16,
14188
+ description: "Allow chat_message records in sync_outbox",
14189
+ sql: `
14190
+ CREATE TABLE sync_outbox_new (
14191
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14192
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
14193
+ record_id INTEGER NOT NULL,
14194
+ status TEXT DEFAULT 'pending' CHECK (status IN (
14195
+ 'pending', 'syncing', 'synced', 'failed'
14196
+ )),
14197
+ retry_count INTEGER DEFAULT 0,
14198
+ max_retries INTEGER DEFAULT 10,
14199
+ last_error TEXT,
14200
+ created_at_epoch INTEGER NOT NULL,
14201
+ synced_at_epoch INTEGER,
14202
+ next_retry_epoch INTEGER
14203
+ );
14204
+
14205
+ INSERT INTO sync_outbox_new (
14206
+ id, record_type, record_id, status, retry_count, max_retries,
14207
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
14208
+ )
14209
+ SELECT
14210
+ id, record_type, record_id, status, retry_count, max_retries,
14211
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
14212
+ FROM sync_outbox;
14213
+
14214
+ DROP TABLE sync_outbox;
14215
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
14216
+
14217
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
14218
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
14143
14219
  `
14144
14220
  }
14145
14221
  ];
@@ -14199,6 +14275,18 @@ function inferLegacySchemaVersion(db) {
14199
14275
  if (columnExists(db, "session_summaries", "capture_state") && columnExists(db, "session_summaries", "recent_tool_names") && columnExists(db, "session_summaries", "hot_files") && columnExists(db, "session_summaries", "recent_outcomes")) {
14200
14276
  version2 = Math.max(version2, 12);
14201
14277
  }
14278
+ if (columnExists(db, "session_summaries", "current_thread")) {
14279
+ version2 = Math.max(version2, 13);
14280
+ }
14281
+ if (tableExists(db, "chat_messages")) {
14282
+ version2 = Math.max(version2, 14);
14283
+ }
14284
+ if (columnExists(db, "chat_messages", "remote_source_id")) {
14285
+ version2 = Math.max(version2, 15);
14286
+ }
14287
+ if (syncOutboxSupportsChatMessages(db)) {
14288
+ version2 = Math.max(version2, 16);
14289
+ }
14202
14290
  return version2;
14203
14291
  }
14204
14292
  function runMigrations(db) {
@@ -14282,7 +14370,8 @@ function ensureSessionSummaryColumns(db) {
14282
14370
  "capture_state",
14283
14371
  "recent_tool_names",
14284
14372
  "hot_files",
14285
- "recent_outcomes"
14373
+ "recent_outcomes",
14374
+ "current_thread"
14286
14375
  ];
14287
14376
  for (const column of required2) {
14288
14377
  if (columnExists(db, "session_summaries", column))
@@ -14290,10 +14379,75 @@ function ensureSessionSummaryColumns(db) {
14290
14379
  db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
14291
14380
  }
14292
14381
  const current = getSchemaVersion(db);
14293
- if (current < 12) {
14294
- db.exec("PRAGMA user_version = 12");
14382
+ if (current < 13) {
14383
+ db.exec("PRAGMA user_version = 13");
14384
+ }
14385
+ }
14386
+ function ensureChatMessageColumns(db) {
14387
+ if (!tableExists(db, "chat_messages"))
14388
+ return;
14389
+ if (!columnExists(db, "chat_messages", "remote_source_id")) {
14390
+ db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
14391
+ }
14392
+ 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");
14393
+ const current = getSchemaVersion(db);
14394
+ if (current < 15) {
14395
+ db.exec("PRAGMA user_version = 15");
14396
+ }
14397
+ }
14398
+ function ensureSyncOutboxSupportsChatMessages(db) {
14399
+ if (syncOutboxSupportsChatMessages(db)) {
14400
+ const current = getSchemaVersion(db);
14401
+ if (current < 16) {
14402
+ db.exec("PRAGMA user_version = 16");
14403
+ }
14404
+ return;
14405
+ }
14406
+ db.exec("BEGIN TRANSACTION");
14407
+ try {
14408
+ db.exec(`
14409
+ CREATE TABLE sync_outbox_new (
14410
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
14411
+ record_type TEXT NOT NULL CHECK (record_type IN ('observation', 'summary', 'chat_message')),
14412
+ record_id INTEGER NOT NULL,
14413
+ status TEXT DEFAULT 'pending' CHECK (status IN (
14414
+ 'pending', 'syncing', 'synced', 'failed'
14415
+ )),
14416
+ retry_count INTEGER DEFAULT 0,
14417
+ max_retries INTEGER DEFAULT 10,
14418
+ last_error TEXT,
14419
+ created_at_epoch INTEGER NOT NULL,
14420
+ synced_at_epoch INTEGER,
14421
+ next_retry_epoch INTEGER
14422
+ );
14423
+
14424
+ INSERT INTO sync_outbox_new (
14425
+ id, record_type, record_id, status, retry_count, max_retries,
14426
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
14427
+ )
14428
+ SELECT
14429
+ id, record_type, record_id, status, retry_count, max_retries,
14430
+ last_error, created_at_epoch, synced_at_epoch, next_retry_epoch
14431
+ FROM sync_outbox;
14432
+
14433
+ DROP TABLE sync_outbox;
14434
+ ALTER TABLE sync_outbox_new RENAME TO sync_outbox;
14435
+
14436
+ CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
14437
+ CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
14438
+ `);
14439
+ db.exec("PRAGMA user_version = 16");
14440
+ db.exec("COMMIT");
14441
+ } catch (error48) {
14442
+ db.exec("ROLLBACK");
14443
+ throw new Error(`sync_outbox repair failed: ${error48 instanceof Error ? error48.message : String(error48)}`);
14295
14444
  }
14296
14445
  }
14446
+ function syncOutboxSupportsChatMessages(db) {
14447
+ const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("sync_outbox");
14448
+ const sql = row?.sql ?? "";
14449
+ return sql.includes("'chat_message'");
14450
+ }
14297
14451
  function getSchemaVersion(db) {
14298
14452
  const result = db.query("PRAGMA user_version").get();
14299
14453
  return result.user_version;
@@ -14453,6 +14607,8 @@ class MemDatabase {
14453
14607
  runMigrations(this.db);
14454
14608
  ensureObservationTypes(this.db);
14455
14609
  ensureSessionSummaryColumns(this.db);
14610
+ ensureChatMessageColumns(this.db);
14611
+ ensureSyncOutboxSupportsChatMessages(this.db);
14456
14612
  }
14457
14613
  loadVecExtension() {
14458
14614
  try {
@@ -14678,6 +14834,7 @@ class MemDatabase {
14678
14834
  p.name AS project_name,
14679
14835
  ss.request AS request,
14680
14836
  ss.completed AS completed,
14837
+ ss.current_thread AS current_thread,
14681
14838
  ss.capture_state AS capture_state,
14682
14839
  ss.recent_tool_names AS recent_tool_names,
14683
14840
  ss.hot_files AS hot_files,
@@ -14696,6 +14853,7 @@ class MemDatabase {
14696
14853
  p.name AS project_name,
14697
14854
  ss.request AS request,
14698
14855
  ss.completed AS completed,
14856
+ ss.current_thread AS current_thread,
14699
14857
  ss.capture_state AS capture_state,
14700
14858
  ss.recent_tool_names AS recent_tool_names,
14701
14859
  ss.hot_files AS hot_files,
@@ -14786,6 +14944,54 @@ class MemDatabase {
14786
14944
  ORDER BY created_at_epoch DESC, id DESC
14787
14945
  LIMIT ?`).all(...userId ? [userId] : [], limit);
14788
14946
  }
14947
+ insertChatMessage(input) {
14948
+ const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
14949
+ const content = input.content.trim();
14950
+ const result = this.db.query(`INSERT INTO chat_messages (
14951
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
14952
+ ) 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);
14953
+ return this.getChatMessageById(Number(result.lastInsertRowid));
14954
+ }
14955
+ getChatMessageById(id) {
14956
+ return this.db.query("SELECT * FROM chat_messages WHERE id = ?").get(id) ?? null;
14957
+ }
14958
+ getChatMessageByRemoteSourceId(remoteSourceId) {
14959
+ return this.db.query("SELECT * FROM chat_messages WHERE remote_source_id = ?").get(remoteSourceId) ?? null;
14960
+ }
14961
+ getSessionChatMessages(sessionId, limit = 50) {
14962
+ return this.db.query(`SELECT * FROM chat_messages
14963
+ WHERE session_id = ?
14964
+ ORDER BY created_at_epoch ASC, id ASC
14965
+ LIMIT ?`).all(sessionId, limit);
14966
+ }
14967
+ getRecentChatMessages(projectId, limit = 20, userId) {
14968
+ const visibilityClause = userId ? " AND user_id = ?" : "";
14969
+ if (projectId !== null) {
14970
+ return this.db.query(`SELECT * FROM chat_messages
14971
+ WHERE project_id = ?${visibilityClause}
14972
+ ORDER BY created_at_epoch DESC, id DESC
14973
+ LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
14974
+ }
14975
+ return this.db.query(`SELECT * FROM chat_messages
14976
+ WHERE 1 = 1${visibilityClause}
14977
+ ORDER BY created_at_epoch DESC, id DESC
14978
+ LIMIT ?`).all(...userId ? [userId] : [], limit);
14979
+ }
14980
+ searchChatMessages(query, projectId, limit = 20, userId) {
14981
+ const needle = `%${query.toLowerCase()}%`;
14982
+ const visibilityClause = userId ? " AND user_id = ?" : "";
14983
+ if (projectId !== null) {
14984
+ return this.db.query(`SELECT * FROM chat_messages
14985
+ WHERE project_id = ?
14986
+ AND lower(content) LIKE ?${visibilityClause}
14987
+ ORDER BY created_at_epoch DESC, id DESC
14988
+ LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
14989
+ }
14990
+ return this.db.query(`SELECT * FROM chat_messages
14991
+ WHERE lower(content) LIKE ?${visibilityClause}
14992
+ ORDER BY created_at_epoch DESC, id DESC
14993
+ LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
14994
+ }
14789
14995
  addToOutbox(recordType, recordId) {
14790
14996
  const now = Math.floor(Date.now() / 1000);
14791
14997
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -14874,9 +15080,9 @@ class MemDatabase {
14874
15080
  };
14875
15081
  const result = this.db.query(`INSERT INTO session_summaries (
14876
15082
  session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
14877
- capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
15083
+ current_thread, capture_state, recent_tool_names, hot_files, recent_outcomes, created_at_epoch
14878
15084
  )
14879
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(summary.session_id, summary.project_id, summary.user_id, normalized.request, normalized.investigated, normalized.learned, normalized.completed, normalized.next_steps, summary.capture_state ?? null, summary.recent_tool_names ?? null, summary.hot_files ?? null, summary.recent_outcomes ?? null, now);
15085
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(summary.session_id, summary.project_id, summary.user_id, normalized.request, normalized.investigated, normalized.learned, normalized.completed, normalized.next_steps, summary.current_thread ?? null, summary.capture_state ?? null, summary.recent_tool_names ?? null, summary.hot_files ?? null, summary.recent_outcomes ?? null, now);
14880
15086
  const id = Number(result.lastInsertRowid);
14881
15087
  return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
14882
15088
  }
@@ -14892,6 +15098,7 @@ class MemDatabase {
14892
15098
  learned: normalizeSummarySection(summary.learned ?? existing.learned),
14893
15099
  completed: normalizeSummarySection(summary.completed ?? existing.completed),
14894
15100
  next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
15101
+ current_thread: summary.current_thread ?? existing.current_thread,
14895
15102
  capture_state: summary.capture_state ?? existing.capture_state,
14896
15103
  recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
14897
15104
  hot_files: summary.hot_files ?? existing.hot_files,
@@ -14905,12 +15112,13 @@ class MemDatabase {
14905
15112
  learned = ?,
14906
15113
  completed = ?,
14907
15114
  next_steps = ?,
15115
+ current_thread = ?,
14908
15116
  capture_state = ?,
14909
15117
  recent_tool_names = ?,
14910
15118
  hot_files = ?,
14911
15119
  recent_outcomes = ?,
14912
15120
  created_at_epoch = ?
14913
- WHERE session_id = ?`).run(summary.project_id ?? existing.project_id, summary.user_id, normalized.request, normalized.investigated, normalized.learned, normalized.completed, normalized.next_steps, normalized.capture_state, normalized.recent_tool_names, normalized.hot_files, normalized.recent_outcomes, now, summary.session_id);
15121
+ WHERE session_id = ?`).run(summary.project_id ?? existing.project_id, summary.user_id, normalized.request, normalized.investigated, normalized.learned, normalized.completed, normalized.next_steps, normalized.current_thread, normalized.capture_state, normalized.recent_tool_names, normalized.hot_files, normalized.recent_outcomes, now, summary.session_id);
14914
15122
  return this.getSessionSummary(summary.session_id);
14915
15123
  }
14916
15124
  getSessionSummary(sessionId) {
@@ -16161,12 +16369,12 @@ function getRecentRequests(db, input) {
16161
16369
  };
16162
16370
  }
16163
16371
 
16164
- // src/tools/recent-tools.ts
16165
- function getRecentTools(db, input) {
16166
- const limit = Math.max(1, Math.min(input.limit ?? 10, 50));
16372
+ // src/tools/recent-chat.ts
16373
+ function getRecentChat(db, input) {
16374
+ const limit = Math.max(1, Math.min(input.limit ?? 20, 100));
16167
16375
  if (input.session_id) {
16168
16376
  return {
16169
- tool_events: db.getSessionToolEvents(input.session_id, limit).slice(-limit).reverse()
16377
+ messages: db.getSessionChatMessages(input.session_id, limit).slice(-limit).reverse()
16170
16378
  };
16171
16379
  }
16172
16380
  const projectScoped = input.project_scoped !== false;
@@ -16182,7 +16390,28 @@ function getRecentTools(db, input) {
16182
16390
  }
16183
16391
  }
16184
16392
  return {
16185
- tool_events: db.getRecentToolEvents(projectId, limit, input.user_id),
16393
+ messages: db.getRecentChatMessages(projectId, limit, input.user_id),
16394
+ project: projectName
16395
+ };
16396
+ }
16397
+
16398
+ // src/tools/search-chat.ts
16399
+ function searchChat(db, input) {
16400
+ const limit = Math.max(1, Math.min(input.limit ?? 20, 100));
16401
+ const projectScoped = input.project_scoped !== false;
16402
+ let projectId = null;
16403
+ let projectName;
16404
+ if (projectScoped) {
16405
+ const cwd = input.cwd ?? process.cwd();
16406
+ const detected = detectProject(cwd);
16407
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
16408
+ if (project) {
16409
+ projectId = project.id;
16410
+ projectName = project.name;
16411
+ }
16412
+ }
16413
+ return {
16414
+ messages: db.searchChatMessages(input.query, projectId, limit, input.user_id),
16186
16415
  project: projectName
16187
16416
  };
16188
16417
  }
@@ -16192,8 +16421,11 @@ function getSessionStory(db, input) {
16192
16421
  const session = db.getSessionById(input.session_id);
16193
16422
  const summary = db.getSessionSummary(input.session_id);
16194
16423
  const prompts = db.getSessionUserPrompts(input.session_id, 50);
16424
+ const chatMessages = db.getSessionChatMessages(input.session_id, 50);
16195
16425
  const toolEvents = db.getSessionToolEvents(input.session_id, 100);
16196
- const observations = db.getObservationsBySession(input.session_id);
16426
+ const allObservations = db.getObservationsBySession(input.session_id);
16427
+ const handoffs = allObservations.filter((obs) => looksLikeHandoff(obs));
16428
+ const observations = allObservations.filter((obs) => !looksLikeHandoff(obs));
16197
16429
  const metrics = db.getSessionMetrics(input.session_id);
16198
16430
  const projectName = session?.project_id !== null && session?.project_id !== undefined ? db.getProjectById(session.project_id)?.name ?? null : null;
16199
16431
  const latestRequest = prompts[prompts.length - 1]?.prompt?.trim() || summary?.request?.trim() || null;
@@ -16202,8 +16434,10 @@ function getSessionStory(db, input) {
16202
16434
  project_name: projectName,
16203
16435
  summary,
16204
16436
  prompts,
16437
+ chat_messages: chatMessages,
16205
16438
  tool_events: toolEvents,
16206
16439
  observations,
16440
+ handoffs,
16207
16441
  metrics,
16208
16442
  capture_state: classifyCaptureState({
16209
16443
  hasSummary: Boolean(summary?.request || summary?.completed),
@@ -16297,6 +16531,269 @@ function collectProvenanceSummary(observations) {
16297
16531
  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);
16298
16532
  }
16299
16533
 
16534
+ // src/tools/handoffs.ts
16535
+ async function createHandoff(db, config2, input) {
16536
+ const resolved = resolveTargetSession(db, input.cwd, config2.user_id, input.session_id);
16537
+ if (!resolved.session) {
16538
+ return {
16539
+ success: false,
16540
+ reason: "No recent session found to hand off yet"
16541
+ };
16542
+ }
16543
+ const story = getSessionStory(db, { session_id: resolved.session.session_id });
16544
+ if (!story.session) {
16545
+ return {
16546
+ success: false,
16547
+ reason: `Session ${resolved.session.session_id} not found`
16548
+ };
16549
+ }
16550
+ const includeChat = input.include_chat === true;
16551
+ const chatLimit = Math.max(1, Math.min(input.chat_limit ?? 4, 8));
16552
+ const generatedTitle = buildHandoffTitle(story.summary, story.latest_request, input.title);
16553
+ const title = `Handoff: ${generatedTitle} · ${formatTimestamp(Date.now())}`;
16554
+ const narrative = buildHandoffNarrative(story.summary, story, {
16555
+ includeChat,
16556
+ chatLimit
16557
+ });
16558
+ const facts = buildHandoffFacts(story.summary, story);
16559
+ const concepts = buildHandoffConcepts(story.project_name, story.capture_state);
16560
+ const result = await saveObservation(db, config2, {
16561
+ type: "message",
16562
+ title,
16563
+ narrative,
16564
+ facts,
16565
+ concepts,
16566
+ session_id: story.session.session_id,
16567
+ cwd: input.cwd,
16568
+ agent: "engrm-handoff",
16569
+ source_tool: "create_handoff"
16570
+ });
16571
+ return {
16572
+ success: result.success,
16573
+ observation_id: result.observation_id,
16574
+ session_id: story.session.session_id,
16575
+ title,
16576
+ reason: result.reason
16577
+ };
16578
+ }
16579
+ function getRecentHandoffs(db, input) {
16580
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
16581
+ const projectScoped = input.project_scoped !== false;
16582
+ let projectId = null;
16583
+ let projectName;
16584
+ if (projectScoped) {
16585
+ const cwd = input.cwd ?? process.cwd();
16586
+ const detected = detectProject(cwd);
16587
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
16588
+ if (project) {
16589
+ projectId = project.id;
16590
+ projectName = project.name;
16591
+ }
16592
+ }
16593
+ const conditions = [
16594
+ "o.type = 'message'",
16595
+ "o.lifecycle IN ('active', 'aging', 'pinned')",
16596
+ "o.superseded_by IS NULL",
16597
+ `(o.title LIKE 'Handoff:%' OR o.concepts LIKE '%"handoff"%')`
16598
+ ];
16599
+ const params = [];
16600
+ if (input.user_id) {
16601
+ conditions.push("(o.sensitivity != 'personal' OR o.user_id = ?)");
16602
+ params.push(input.user_id);
16603
+ }
16604
+ if (projectId !== null) {
16605
+ conditions.push("o.project_id = ?");
16606
+ params.push(projectId);
16607
+ }
16608
+ params.push(limit);
16609
+ const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
16610
+ FROM observations o
16611
+ LEFT JOIN projects p ON p.id = o.project_id
16612
+ WHERE ${conditions.join(" AND ")}
16613
+ ORDER BY o.created_at_epoch DESC, o.id DESC
16614
+ LIMIT ?`).all(...params);
16615
+ return {
16616
+ handoffs,
16617
+ project: projectName
16618
+ };
16619
+ }
16620
+ function loadHandoff(db, input) {
16621
+ if (typeof input.id === "number") {
16622
+ const obs = db.getObservationById(input.id);
16623
+ if (!obs || obs.type !== "message" || !looksLikeHandoff(obs)) {
16624
+ return { handoff: null };
16625
+ }
16626
+ const projectName = obs.project_id ? db.getProjectById(obs.project_id)?.name ?? null : null;
16627
+ return { handoff: { ...obs, project_name: projectName } };
16628
+ }
16629
+ const recent = getRecentHandoffs(db, {
16630
+ limit: 1,
16631
+ project_scoped: input.project_scoped,
16632
+ cwd: input.cwd,
16633
+ user_id: input.user_id
16634
+ });
16635
+ return {
16636
+ handoff: recent.handoffs[0] ?? null,
16637
+ project: recent.project
16638
+ };
16639
+ }
16640
+ function resolveTargetSession(db, cwd, userId, sessionId) {
16641
+ if (sessionId) {
16642
+ const session = db.getSessionById(sessionId);
16643
+ if (!session)
16644
+ return { session: null };
16645
+ const projectName = session.project_id ? db.getProjectById(session.project_id)?.name : undefined;
16646
+ return {
16647
+ session: {
16648
+ ...session,
16649
+ project_name: projectName ?? null,
16650
+ request: db.getSessionSummary(sessionId)?.request ?? null,
16651
+ completed: db.getSessionSummary(sessionId)?.completed ?? null,
16652
+ current_thread: db.getSessionSummary(sessionId)?.current_thread ?? null,
16653
+ capture_state: db.getSessionSummary(sessionId)?.capture_state ?? null,
16654
+ recent_tool_names: db.getSessionSummary(sessionId)?.recent_tool_names ?? null,
16655
+ hot_files: db.getSessionSummary(sessionId)?.hot_files ?? null,
16656
+ recent_outcomes: db.getSessionSummary(sessionId)?.recent_outcomes ?? null,
16657
+ prompt_count: db.getSessionUserPrompts(sessionId, 200).length,
16658
+ tool_event_count: db.getSessionToolEvents(sessionId, 200).length
16659
+ },
16660
+ projectName: projectName ?? undefined
16661
+ };
16662
+ }
16663
+ const detected = detectProject(cwd ?? process.cwd());
16664
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
16665
+ const sessions = db.getRecentSessions(project?.id ?? null, 10, userId);
16666
+ return {
16667
+ session: sessions[0] ?? null,
16668
+ projectName: project?.name
16669
+ };
16670
+ }
16671
+ function buildHandoffTitle(summary, latestRequest, explicit) {
16672
+ const chosen = explicit?.trim() || summary?.current_thread?.trim() || summary?.completed?.trim() || latestRequest?.trim() || "Current work";
16673
+ return compactLine(chosen) ?? "Current work";
16674
+ }
16675
+ function buildHandoffNarrative(summary, story, options) {
16676
+ const sections = [];
16677
+ if (summary?.request || story.latest_request) {
16678
+ sections.push(`Request: ${summary?.request ?? story.latest_request}`);
16679
+ }
16680
+ if (summary?.current_thread) {
16681
+ sections.push(`Current thread: ${summary.current_thread}`);
16682
+ }
16683
+ if (summary?.investigated) {
16684
+ sections.push(`Investigated: ${summary.investigated}`);
16685
+ }
16686
+ if (summary?.learned) {
16687
+ sections.push(`Learned: ${summary.learned}`);
16688
+ }
16689
+ if (summary?.completed) {
16690
+ sections.push(`Completed: ${summary.completed}`);
16691
+ }
16692
+ if (summary?.next_steps) {
16693
+ sections.push(`Next Steps: ${summary.next_steps}`);
16694
+ }
16695
+ if (story.recent_outcomes.length > 0) {
16696
+ sections.push(`Recent outcomes:
16697
+ ${story.recent_outcomes.slice(0, 5).map((item) => `- ${item}`).join(`
16698
+ `)}`);
16699
+ }
16700
+ if (story.hot_files.length > 0) {
16701
+ sections.push(`Hot files:
16702
+ ${story.hot_files.slice(0, 5).map((file2) => `- ${file2.path}`).join(`
16703
+ `)}`);
16704
+ }
16705
+ if (story.provenance_summary.length > 0) {
16706
+ sections.push(`Tool trail:
16707
+ ${story.provenance_summary.slice(0, 5).map((item) => `- ${item.tool}: ${item.count}`).join(`
16708
+ `)}`);
16709
+ }
16710
+ if (options.includeChat && story.chat_messages.length > 0) {
16711
+ const chatLines = story.chat_messages.slice(-options.chatLimit).map((msg) => `- [${msg.role}] ${compactLine(msg.content) ?? msg.content.slice(0, 120)}`);
16712
+ sections.push(`Chat snippets:
16713
+ ${chatLines.join(`
16714
+ `)}`);
16715
+ }
16716
+ return sections.filter(Boolean).join(`
16717
+
16718
+ `);
16719
+ }
16720
+ function buildHandoffFacts(summary, story) {
16721
+ const facts = [
16722
+ `session_id=${story.session?.session_id ?? "unknown"}`,
16723
+ `capture_state=${story.capture_state}`,
16724
+ story.project_name ? `project=${story.project_name}` : null,
16725
+ summary?.current_thread ? `current_thread=${summary.current_thread}` : null,
16726
+ story.hot_files[0] ? `hot_file=${story.hot_files[0].path}` : null,
16727
+ story.provenance_summary[0] ? `primary_tool=${story.provenance_summary[0].tool}` : null
16728
+ ];
16729
+ return facts.filter((item) => Boolean(item));
16730
+ }
16731
+ function buildHandoffConcepts(projectName, captureState) {
16732
+ return [
16733
+ "handoff",
16734
+ "session-handoff",
16735
+ `capture:${captureState}`,
16736
+ ...projectName ? [projectName] : []
16737
+ ];
16738
+ }
16739
+ function looksLikeHandoff(obs) {
16740
+ if (obs.title.startsWith("Handoff:"))
16741
+ return true;
16742
+ const concepts = parseJsonArray2(obs.concepts);
16743
+ return concepts.includes("handoff") || concepts.includes("session-handoff");
16744
+ }
16745
+ function parseJsonArray2(value) {
16746
+ if (!value)
16747
+ return [];
16748
+ try {
16749
+ const parsed = JSON.parse(value);
16750
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
16751
+ } catch {
16752
+ return [];
16753
+ }
16754
+ }
16755
+ function formatTimestamp(nowMs) {
16756
+ const d = new Date(nowMs);
16757
+ const yyyy = d.getUTCFullYear();
16758
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
16759
+ const dd = String(d.getUTCDate()).padStart(2, "0");
16760
+ const hh = String(d.getUTCHours()).padStart(2, "0");
16761
+ const mi = String(d.getUTCMinutes()).padStart(2, "0");
16762
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi}Z`;
16763
+ }
16764
+ function compactLine(value) {
16765
+ const trimmed = value?.replace(/\s+/g, " ").trim();
16766
+ if (!trimmed)
16767
+ return null;
16768
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
16769
+ }
16770
+
16771
+ // src/tools/recent-tools.ts
16772
+ function getRecentTools(db, input) {
16773
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 50));
16774
+ if (input.session_id) {
16775
+ return {
16776
+ tool_events: db.getSessionToolEvents(input.session_id, limit).slice(-limit).reverse()
16777
+ };
16778
+ }
16779
+ const projectScoped = input.project_scoped !== false;
16780
+ let projectId = null;
16781
+ let projectName;
16782
+ if (projectScoped) {
16783
+ const cwd = input.cwd ?? process.cwd();
16784
+ const detected = detectProject(cwd);
16785
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
16786
+ if (project) {
16787
+ projectId = project.id;
16788
+ projectName = project.name;
16789
+ }
16790
+ }
16791
+ return {
16792
+ tool_events: db.getRecentToolEvents(projectId, limit, input.user_id),
16793
+ project: projectName
16794
+ };
16795
+ }
16796
+
16300
16797
  // src/tools/recent-sessions.ts
16301
16798
  function getRecentSessions(db, input) {
16302
16799
  const limit = Math.max(1, Math.min(input.limit ?? 10, 50));
@@ -16652,6 +17149,12 @@ function buildSessionContext(db, cwd, options = {}) {
16652
17149
  const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
16653
17150
  const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
16654
17151
  const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions2);
17152
+ const recentHandoffs2 = getRecentHandoffs(db, {
17153
+ cwd,
17154
+ project_scoped: !isNewProject,
17155
+ user_id: opts.userId,
17156
+ limit: 3
17157
+ }).handoffs;
16655
17158
  return {
16656
17159
  project_name: projectName,
16657
17160
  canonical_id: canonicalId,
@@ -16662,7 +17165,8 @@ function buildSessionContext(db, cwd, options = {}) {
16662
17165
  recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
16663
17166
  recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
16664
17167
  projectTypeCounts: projectTypeCounts2,
16665
- recentOutcomes: recentOutcomes2
17168
+ recentOutcomes: recentOutcomes2,
17169
+ recentHandoffs: recentHandoffs2.length > 0 ? recentHandoffs2 : undefined
16666
17170
  };
16667
17171
  }
16668
17172
  let remainingBudget = tokenBudget - 30;
@@ -16690,6 +17194,12 @@ function buildSessionContext(db, cwd, options = {}) {
16690
17194
  const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
16691
17195
  const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
16692
17196
  const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions);
17197
+ const recentHandoffs = getRecentHandoffs(db, {
17198
+ cwd,
17199
+ project_scoped: !isNewProject,
17200
+ user_id: opts.userId,
17201
+ limit: 3
17202
+ }).handoffs;
16693
17203
  let securityFindings = [];
16694
17204
  if (!isNewProject) {
16695
17205
  try {
@@ -16748,7 +17258,8 @@ function buildSessionContext(db, cwd, options = {}) {
16748
17258
  recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
16749
17259
  recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
16750
17260
  projectTypeCounts,
16751
- recentOutcomes
17261
+ recentOutcomes,
17262
+ recentHandoffs: recentHandoffs.length > 0 ? recentHandoffs : undefined
16752
17263
  };
16753
17264
  }
16754
17265
  function estimateObservationTokens(obs, index) {
@@ -17241,6 +17752,9 @@ function buildSuggestedTools(sessions, requestCount, toolCount, observationCount
17241
17752
  if (observationCount > 0) {
17242
17753
  suggested.push("tool_memory_index", "capture_git_worktree");
17243
17754
  }
17755
+ if (sessions.length > 0) {
17756
+ suggested.push("create_handoff", "recent_handoffs");
17757
+ }
17244
17758
  return Array.from(new Set(suggested)).slice(0, 4);
17245
17759
  }
17246
17760
 
@@ -17304,6 +17818,8 @@ function buildFallbackSuggestedTools(sessionCount, requestCount, toolCount, obse
17304
17818
  suggested.push("activity_feed");
17305
17819
  if (observationCount > 0)
17306
17820
  suggested.push("tool_memory_index", "capture_git_worktree");
17821
+ if (sessionCount > 0)
17822
+ suggested.push("create_handoff", "recent_handoffs");
17307
17823
  return Array.from(new Set(suggested)).slice(0, 4);
17308
17824
  }
17309
17825
 
@@ -17520,6 +18036,17 @@ function toToolEvent(tool) {
17520
18036
  };
17521
18037
  }
17522
18038
  function toObservationEvent(obs) {
18039
+ if (looksLikeHandoff(obs)) {
18040
+ return {
18041
+ kind: "handoff",
18042
+ created_at_epoch: obs.created_at_epoch,
18043
+ session_id: obs.session_id,
18044
+ id: obs.id,
18045
+ title: obs.title,
18046
+ detail: obs.narrative?.replace(/\s+/g, " ").trim().slice(0, 220),
18047
+ observation_type: obs.type
18048
+ };
18049
+ }
17523
18050
  const detailBits = [];
17524
18051
  if (obs.source_tool)
17525
18052
  detailBits.push(`via ${obs.source_tool}`);
@@ -17563,9 +18090,10 @@ function compareEvents(a, b) {
17563
18090
  }
17564
18091
  const kindOrder = {
17565
18092
  summary: 0,
17566
- observation: 1,
17567
- tool: 2,
17568
- prompt: 3
18093
+ handoff: 1,
18094
+ observation: 2,
18095
+ tool: 3,
18096
+ prompt: 4
17569
18097
  };
17570
18098
  if (kindOrder[a.kind] !== kindOrder[b.kind]) {
17571
18099
  return kindOrder[a.kind] - kindOrder[b.kind];
@@ -17586,6 +18114,7 @@ function getActivityFeed(db, input) {
17586
18114
  })].filter((event) => event !== null) : [],
17587
18115
  ...story.prompts.map(toPromptEvent),
17588
18116
  ...story.tool_events.map(toToolEvent),
18117
+ ...story.handoffs.map(toObservationEvent),
17589
18118
  ...story.observations.map(toObservationEvent)
17590
18119
  ].sort(compareEvents).slice(0, limit);
17591
18120
  return { events: events2, project };
@@ -18035,13 +18564,13 @@ function getSessionContext(db, input) {
18035
18564
  function buildHotFiles(context) {
18036
18565
  const counts = new Map;
18037
18566
  for (const obs of context.observations) {
18038
- for (const path of [...parseJsonArray2(obs.files_read), ...parseJsonArray2(obs.files_modified)]) {
18567
+ for (const path of [...parseJsonArray3(obs.files_read), ...parseJsonArray3(obs.files_modified)]) {
18039
18568
  counts.set(path, (counts.get(path) ?? 0) + 1);
18040
18569
  }
18041
18570
  }
18042
18571
  return Array.from(counts.entries()).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)).slice(0, 6);
18043
18572
  }
18044
- function parseJsonArray2(value) {
18573
+ function parseJsonArray3(value) {
18045
18574
  if (!value)
18046
18575
  return [];
18047
18576
  try {
@@ -18062,6 +18591,9 @@ function buildSuggestedTools2(context) {
18062
18591
  if (context.observations.length > 0) {
18063
18592
  tools.push("tool_memory_index", "capture_git_worktree");
18064
18593
  }
18594
+ if ((context.recentSessions?.length ?? 0) > 0) {
18595
+ tools.push("create_handoff", "recent_handoffs");
18596
+ }
18065
18597
  return Array.from(new Set(tools)).slice(0, 4);
18066
18598
  }
18067
18599
 
@@ -18612,22 +19144,27 @@ function buildSourceId(config2, localId, type = "obs") {
18612
19144
  return `${config2.user_id}-${config2.device_id}-${type}-${localId}`;
18613
19145
  }
18614
19146
  function parseSourceId(sourceId) {
18615
- const obsIndex = sourceId.lastIndexOf("-obs-");
18616
- if (obsIndex === -1)
18617
- return null;
18618
- const prefix = sourceId.slice(0, obsIndex);
18619
- const localIdStr = sourceId.slice(obsIndex + 5);
18620
- const localId = parseInt(localIdStr, 10);
18621
- if (isNaN(localId))
18622
- return null;
18623
- const firstDash = prefix.indexOf("-");
18624
- if (firstDash === -1)
18625
- return null;
18626
- return {
18627
- userId: prefix.slice(0, firstDash),
18628
- deviceId: prefix.slice(firstDash + 1),
18629
- localId
18630
- };
19147
+ for (const type of ["obs", "summary", "chat"]) {
19148
+ const marker = `-${type}-`;
19149
+ const idx = sourceId.lastIndexOf(marker);
19150
+ if (idx === -1)
19151
+ continue;
19152
+ const prefix = sourceId.slice(0, idx);
19153
+ const localIdStr = sourceId.slice(idx + marker.length);
19154
+ const localId = parseInt(localIdStr, 10);
19155
+ if (isNaN(localId))
19156
+ return null;
19157
+ const firstDash = prefix.indexOf("-");
19158
+ if (firstDash === -1)
19159
+ return null;
19160
+ return {
19161
+ userId: prefix.slice(0, firstDash),
19162
+ deviceId: prefix.slice(firstDash + 1),
19163
+ localId,
19164
+ type
19165
+ };
19166
+ }
19167
+ return null;
18631
19168
  }
18632
19169
 
18633
19170
  // src/sync/client.ts
@@ -18740,8 +19277,8 @@ function buildSessionHandoffMetadata(prompts, toolEvents, observations) {
18740
19277
  const recentToolNames = [...new Set(toolEvents.slice(-8).map((tool) => tool.tool_name).filter(Boolean))];
18741
19278
  const recentToolCommands = [...new Set(toolEvents.slice(-5).map((tool) => (tool.command ?? tool.file_path ?? "").trim()).filter(Boolean))];
18742
19279
  const hotFiles = [...new Set(observations.flatMap((obs) => [
18743
- ...parseJsonArray3(obs.files_modified),
18744
- ...parseJsonArray3(obs.files_read)
19280
+ ...parseJsonArray4(obs.files_modified),
19281
+ ...parseJsonArray4(obs.files_read)
18745
19282
  ]).filter(Boolean))].slice(0, 6);
18746
19283
  const recentOutcomes = observations.filter((obs) => ["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type)).map((obs) => obs.title.trim()).filter((title) => title.length > 0).slice(0, 6);
18747
19284
  const captureState = prompts.length > 0 && toolEvents.length > 0 ? "rich" : prompts.length > 0 || toolEvents.length > 0 ? "partial" : "summary-only";
@@ -18752,11 +19289,13 @@ function buildSessionHandoffMetadata(prompts, toolEvents, observations) {
18752
19289
  return acc;
18753
19290
  }, new Map).entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
18754
19291
  const latestObservationPromptNumber = observations.map((obs) => obs.source_prompt_number).filter((value) => typeof value === "number").sort((a, b) => b - a)[0] ?? null;
19292
+ const currentThread = buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolNames);
18755
19293
  return {
18756
19294
  prompt_count: prompts.length,
18757
19295
  tool_event_count: toolEvents.length,
18758
19296
  recent_request_prompts: recentRequestPrompts,
18759
19297
  latest_request: latestRequest,
19298
+ current_thread: currentThread,
18760
19299
  recent_tool_names: recentToolNames,
18761
19300
  recent_tool_commands: recentToolCommands,
18762
19301
  capture_state: captureState,
@@ -18766,7 +19305,38 @@ function buildSessionHandoffMetadata(prompts, toolEvents, observations) {
18766
19305
  latest_observation_prompt_number: latestObservationPromptNumber
18767
19306
  };
18768
19307
  }
18769
- function parseJsonArray3(value) {
19308
+ function buildCurrentThread(latestRequest, recentOutcomes, hotFiles, recentToolNames) {
19309
+ const request = compactLine2(latestRequest);
19310
+ const outcome = recentOutcomes.map((item) => compactLine2(item)).find(Boolean);
19311
+ const file2 = hotFiles[0] ? compactFileHint(hotFiles[0]) : null;
19312
+ const tools = recentToolNames.slice(0, 2).join("/");
19313
+ if (outcome && file2) {
19314
+ return `${outcome} · ${file2}${tools ? ` · ${tools}` : ""}`;
19315
+ }
19316
+ if (request && file2) {
19317
+ return `${request} · ${file2}${tools ? ` · ${tools}` : ""}`;
19318
+ }
19319
+ if (outcome) {
19320
+ return `${outcome}${tools ? ` · ${tools}` : ""}`;
19321
+ }
19322
+ if (request) {
19323
+ return `${request}${tools ? ` · ${tools}` : ""}`;
19324
+ }
19325
+ return null;
19326
+ }
19327
+ function compactLine2(value) {
19328
+ const trimmed = value?.replace(/\s+/g, " ").trim();
19329
+ if (!trimmed)
19330
+ return null;
19331
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
19332
+ }
19333
+ function compactFileHint(value) {
19334
+ const parts = value.split("/");
19335
+ if (parts.length <= 2)
19336
+ return value;
19337
+ return parts.slice(-2).join("/");
19338
+ }
19339
+ function parseJsonArray4(value) {
18770
19340
  if (!value)
18771
19341
  return [];
18772
19342
  try {
@@ -18778,6 +19348,28 @@ function parseJsonArray3(value) {
18778
19348
  }
18779
19349
 
18780
19350
  // src/sync/push.ts
19351
+ function buildChatVectorDocument(chat, config2, project) {
19352
+ return {
19353
+ site_id: config2.site_id,
19354
+ namespace: config2.namespace,
19355
+ source_type: "chat",
19356
+ source_id: buildSourceId(config2, chat.id, "chat"),
19357
+ content: chat.content,
19358
+ metadata: {
19359
+ project_canonical: project.canonical_id,
19360
+ project_name: project.name,
19361
+ user_id: chat.user_id,
19362
+ device_id: chat.device_id,
19363
+ device_name: __require("node:os").hostname(),
19364
+ agent: chat.agent,
19365
+ type: "chat",
19366
+ role: chat.role,
19367
+ session_id: chat.session_id,
19368
+ created_at_epoch: chat.created_at_epoch,
19369
+ local_id: chat.id
19370
+ }
19371
+ };
19372
+ }
18781
19373
  function buildVectorDocument(obs, config2, project) {
18782
19374
  const parts = [obs.title];
18783
19375
  if (obs.narrative)
@@ -18858,6 +19450,7 @@ function buildSummaryVectorDocument(summary, config2, project, observations = []
18858
19450
  learned: summary.learned,
18859
19451
  completed: summary.completed,
18860
19452
  next_steps: summary.next_steps,
19453
+ current_thread: summary.current_thread,
18861
19454
  summary_sections_present: countPresentSections2(summary),
18862
19455
  investigated_items: extractSectionItems2(summary.investigated),
18863
19456
  learned_items: extractSectionItems2(summary.learned),
@@ -18868,6 +19461,7 @@ function buildSummaryVectorDocument(summary, config2, project, observations = []
18868
19461
  capture_state: captureContext?.capture_state ?? "summary-only",
18869
19462
  recent_request_prompts: captureContext?.recent_request_prompts ?? [],
18870
19463
  latest_request: captureContext?.latest_request ?? null,
19464
+ current_thread: captureContext?.current_thread ?? null,
18871
19465
  recent_tool_names: captureContext?.recent_tool_names ?? [],
18872
19466
  recent_tool_commands: captureContext?.recent_tool_commands ?? [],
18873
19467
  hot_files: captureContext?.hot_files ?? [],
@@ -18921,6 +19515,32 @@ async function pushOutbox(db, client, config2, batchSize = 50) {
18921
19515
  batch.push({ entryId: entry.id, doc: doc3 });
18922
19516
  continue;
18923
19517
  }
19518
+ if (entry.record_type === "chat_message") {
19519
+ const chat = db.getChatMessageById(entry.record_id);
19520
+ if (!chat || chat.remote_source_id) {
19521
+ markSynced(db, entry.id);
19522
+ skipped++;
19523
+ continue;
19524
+ }
19525
+ if (!chat.project_id) {
19526
+ markSynced(db, entry.id);
19527
+ skipped++;
19528
+ continue;
19529
+ }
19530
+ const project2 = db.getProjectById(chat.project_id);
19531
+ if (!project2) {
19532
+ markSynced(db, entry.id);
19533
+ skipped++;
19534
+ continue;
19535
+ }
19536
+ markSyncing(db, entry.id);
19537
+ const doc3 = buildChatVectorDocument(chat, config2, {
19538
+ canonical_id: project2.canonical_id,
19539
+ name: project2.name
19540
+ });
19541
+ batch.push({ entryId: entry.id, doc: doc3 });
19542
+ continue;
19543
+ }
18924
19544
  if (entry.record_type !== "observation") {
18925
19545
  skipped++;
18926
19546
  continue;
@@ -18958,7 +19578,13 @@ async function pushOutbox(db, client, config2, batchSize = 50) {
18958
19578
  return { pushed, failed, skipped };
18959
19579
  try {
18960
19580
  await client.batchIngest(batch.map((b) => b.doc));
18961
- for (const { entryId } of batch) {
19581
+ for (const { entryId, doc: doc2 } of batch) {
19582
+ if (doc2.source_type === "chat") {
19583
+ const localId = typeof doc2.metadata?.local_id === "number" ? doc2.metadata.local_id : null;
19584
+ if (localId !== null) {
19585
+ db.db.query("UPDATE chat_messages SET remote_source_id = ? WHERE id = ?").run(doc2.source_id, localId);
19586
+ }
19587
+ }
18962
19588
  markSynced(db, entryId);
18963
19589
  pushed++;
18964
19590
  }
@@ -18966,6 +19592,12 @@ async function pushOutbox(db, client, config2, batchSize = 50) {
18966
19592
  for (const { entryId, doc: doc2 } of batch) {
18967
19593
  try {
18968
19594
  await client.ingest(doc2);
19595
+ if (doc2.source_type === "chat") {
19596
+ const localId = typeof doc2.metadata?.local_id === "number" ? doc2.metadata.local_id : null;
19597
+ if (localId !== null) {
19598
+ db.db.query("UPDATE chat_messages SET remote_source_id = ? WHERE id = ?").run(doc2.source_id, localId);
19599
+ }
19600
+ }
18969
19601
  markSynced(db, entryId);
18970
19602
  pushed++;
18971
19603
  } catch (err) {
@@ -19018,6 +19650,7 @@ function mergeChanges(db, config2, changes) {
19018
19650
  for (const change of changes) {
19019
19651
  const parsed = parseSourceId(change.source_id);
19020
19652
  const remoteSummary = isRemoteSummary(change);
19653
+ const remoteChat = isRemoteChat(change);
19021
19654
  if (parsed && parsed.deviceId === config2.device_id) {
19022
19655
  skipped++;
19023
19656
  continue;
@@ -19040,6 +19673,15 @@ function mergeChanges(db, config2, changes) {
19040
19673
  merged++;
19041
19674
  }
19042
19675
  }
19676
+ if (remoteChat) {
19677
+ const mergedChat = mergeRemoteChat(db, config2, change, project.id);
19678
+ if (mergedChat) {
19679
+ merged++;
19680
+ } else {
19681
+ skipped++;
19682
+ }
19683
+ continue;
19684
+ }
19043
19685
  const existing = db.db.query("SELECT id FROM observations WHERE remote_source_id = ?").get(change.source_id);
19044
19686
  if (existing) {
19045
19687
  if (!remoteSummary)
@@ -19081,6 +19723,10 @@ function isRemoteSummary(change) {
19081
19723
  const rawType = typeof change.metadata?.type === "string" ? change.metadata.type.toLowerCase() : "";
19082
19724
  return rawType === "summary" || change.source_id.includes("-summary-");
19083
19725
  }
19726
+ function isRemoteChat(change) {
19727
+ const rawType = typeof change.metadata?.type === "string" ? change.metadata.type.toLowerCase() : "";
19728
+ return rawType === "chat" || change.source_id.includes("-chat-");
19729
+ }
19084
19730
  function mergeRemoteSummary(db, config2, change, projectId) {
19085
19731
  const sessionId = typeof change.metadata?.session_id === "string" ? change.metadata.session_id : null;
19086
19732
  if (!sessionId)
@@ -19094,6 +19740,7 @@ function mergeRemoteSummary(db, config2, change, projectId) {
19094
19740
  learned: typeof change.metadata?.learned === "string" ? change.metadata.learned : null,
19095
19741
  completed: typeof change.metadata?.completed === "string" ? change.metadata.completed : null,
19096
19742
  next_steps: typeof change.metadata?.next_steps === "string" ? change.metadata.next_steps : null,
19743
+ current_thread: typeof change.metadata?.current_thread === "string" ? change.metadata.current_thread : null,
19097
19744
  capture_state: typeof change.metadata?.capture_state === "string" ? change.metadata.capture_state : null,
19098
19745
  recent_tool_names: encodeStringArray(change.metadata?.recent_tool_names),
19099
19746
  hot_files: encodeStringArray(change.metadata?.hot_files),
@@ -19101,6 +19748,26 @@ function mergeRemoteSummary(db, config2, change, projectId) {
19101
19748
  });
19102
19749
  return Boolean(summary);
19103
19750
  }
19751
+ function mergeRemoteChat(db, config2, change, projectId) {
19752
+ if (db.getChatMessageByRemoteSourceId(change.source_id))
19753
+ return false;
19754
+ const sessionId = typeof change.metadata?.session_id === "string" ? change.metadata.session_id : null;
19755
+ const role = change.metadata?.role === "assistant" ? "assistant" : "user";
19756
+ if (!sessionId || typeof change.content !== "string" || !change.content.trim())
19757
+ return false;
19758
+ db.insertChatMessage({
19759
+ session_id: sessionId,
19760
+ project_id: projectId,
19761
+ role,
19762
+ content: change.content,
19763
+ user_id: (typeof change.metadata?.user_id === "string" ? change.metadata.user_id : null) ?? config2.user_id,
19764
+ device_id: (typeof change.metadata?.device_id === "string" ? change.metadata.device_id : null) ?? "remote",
19765
+ agent: typeof change.metadata?.agent === "string" ? change.metadata.agent : "unknown",
19766
+ created_at_epoch: typeof change.metadata?.created_at_epoch === "number" ? change.metadata.created_at_epoch : undefined,
19767
+ remote_source_id: change.source_id
19768
+ });
19769
+ return true;
19770
+ }
19104
19771
  function encodeStringArray(value) {
19105
19772
  if (!Array.isArray(value))
19106
19773
  return null;
@@ -19909,7 +20576,7 @@ process.on("SIGTERM", () => {
19909
20576
  });
19910
20577
  var server = new McpServer({
19911
20578
  name: "engrm",
19912
- version: "0.4.22"
20579
+ version: "0.4.23"
19913
20580
  });
19914
20581
  server.tool("save_observation", "Save an observation to memory", {
19915
20582
  type: exports_external.enum([
@@ -20996,6 +21663,143 @@ ${projectLines}`
20996
21663
  ]
20997
21664
  };
20998
21665
  });
21666
+ server.tool("create_handoff", "Capture an explicit cross-device handoff from the current or specified session into syncable memory", {
21667
+ session_id: exports_external.string().optional().describe("Optional session ID to hand off; defaults to the latest recent session"),
21668
+ cwd: exports_external.string().optional().describe("Repo path used to scope the handoff when no session ID is provided"),
21669
+ title: exports_external.string().optional().describe("Optional short handoff title"),
21670
+ include_chat: exports_external.boolean().optional().describe("Include a few recent chat snippets in the handoff"),
21671
+ chat_limit: exports_external.number().optional().describe("How many recent chat snippets to include when include_chat is true")
21672
+ }, async (params) => {
21673
+ const result = await createHandoff(db, config2, params);
21674
+ if (!result.success) {
21675
+ return {
21676
+ content: [{ type: "text", text: `Handoff not created: ${result.reason}` }]
21677
+ };
21678
+ }
21679
+ return {
21680
+ content: [
21681
+ {
21682
+ type: "text",
21683
+ text: `Created handoff #${result.observation_id} for session ${result.session_id}
21684
+ Title: ${result.title}`
21685
+ }
21686
+ ]
21687
+ };
21688
+ });
21689
+ server.tool("recent_handoffs", "List recent explicit handoffs so you can resume work on another device or in a new session", {
21690
+ limit: exports_external.number().optional(),
21691
+ project_scoped: exports_external.boolean().optional(),
21692
+ cwd: exports_external.string().optional(),
21693
+ user_id: exports_external.string().optional()
21694
+ }, async (params) => {
21695
+ const result = getRecentHandoffs(db, {
21696
+ ...params,
21697
+ user_id: params.user_id ?? config2.user_id
21698
+ });
21699
+ const projectLine = result.project ? `Project: ${result.project}
21700
+ ` : "";
21701
+ const rows = result.handoffs.length > 0 ? result.handoffs.map((handoff) => {
21702
+ const stamp = new Date(handoff.created_at_epoch * 1000).toISOString().replace("T", " ").slice(0, 16);
21703
+ return `- #${handoff.id} (${stamp}) ${handoff.title}${handoff.project_name ? ` [${handoff.project_name}]` : ""}`;
21704
+ }).join(`
21705
+ `) : "- (none)";
21706
+ return {
21707
+ content: [{ type: "text", text: `${projectLine}Recent handoffs:
21708
+ ${rows}` }]
21709
+ };
21710
+ });
21711
+ server.tool("load_handoff", "Open a saved handoff and turn it back into a clear resume point for a new session", {
21712
+ id: exports_external.number().optional().describe("Optional handoff observation ID; defaults to the latest recent handoff"),
21713
+ cwd: exports_external.string().optional(),
21714
+ project_scoped: exports_external.boolean().optional(),
21715
+ user_id: exports_external.string().optional()
21716
+ }, async (params) => {
21717
+ const result = loadHandoff(db, {
21718
+ ...params,
21719
+ user_id: params.user_id ?? config2.user_id
21720
+ });
21721
+ if (!result.handoff) {
21722
+ return {
21723
+ content: [{ type: "text", text: "No matching handoff found" }]
21724
+ };
21725
+ }
21726
+ const facts = result.handoff.facts ? (() => {
21727
+ try {
21728
+ const parsed = JSON.parse(result.handoff.facts);
21729
+ return Array.isArray(parsed) ? parsed : [];
21730
+ } catch {
21731
+ return [];
21732
+ }
21733
+ })() : [];
21734
+ const factLines = facts.length > 0 ? `
21735
+
21736
+ Facts:
21737
+ ${facts.map((fact) => `- ${fact}`).join(`
21738
+ `)}` : "";
21739
+ const projectLine = result.handoff.project_name ? `Project: ${result.handoff.project_name}
21740
+ ` : "";
21741
+ return {
21742
+ content: [
21743
+ {
21744
+ type: "text",
21745
+ text: `${projectLine}Handoff #${result.handoff.id}
21746
+ ` + `Title: ${result.handoff.title}
21747
+
21748
+ ` + `${result.handoff.narrative ?? "(no handoff narrative stored)"}${factLines}`
21749
+ }
21750
+ ]
21751
+ };
21752
+ });
21753
+ server.tool("recent_chat", "Inspect recently captured chat messages in the separate chat lane", {
21754
+ limit: exports_external.number().optional(),
21755
+ project_scoped: exports_external.boolean().optional(),
21756
+ session_id: exports_external.string().optional(),
21757
+ cwd: exports_external.string().optional(),
21758
+ user_id: exports_external.string().optional()
21759
+ }, async (params) => {
21760
+ const result = getRecentChat(db, params);
21761
+ const projectLine = result.project ? `Project: ${result.project}
21762
+ ` : "";
21763
+ const rows = result.messages.length > 0 ? result.messages.map((msg) => {
21764
+ const stamp = new Date(msg.created_at_epoch * 1000).toISOString().split("T")[0];
21765
+ return `- ${stamp} [${msg.role}] ${msg.content.replace(/\s+/g, " ").trim().slice(0, 200)}`;
21766
+ }).join(`
21767
+ `) : "- (none)";
21768
+ return {
21769
+ content: [
21770
+ {
21771
+ type: "text",
21772
+ text: `${projectLine}Recent chat:
21773
+ ${rows}`
21774
+ }
21775
+ ]
21776
+ };
21777
+ });
21778
+ server.tool("search_chat", "Search the separate chat lane without mixing it into durable memory observations", {
21779
+ query: exports_external.string().describe("Text to search for in captured chat"),
21780
+ limit: exports_external.number().optional(),
21781
+ project_scoped: exports_external.boolean().optional(),
21782
+ cwd: exports_external.string().optional(),
21783
+ user_id: exports_external.string().optional()
21784
+ }, async (params) => {
21785
+ const result = searchChat(db, params);
21786
+ const projectLine = result.project ? `Project: ${result.project}
21787
+ ` : "";
21788
+ const rows = result.messages.length > 0 ? result.messages.map((msg) => {
21789
+ const stamp = new Date(msg.created_at_epoch * 1000).toISOString().split("T")[0];
21790
+ return `- ${stamp} [${msg.role}] ${msg.content.replace(/\s+/g, " ").trim().slice(0, 200)}`;
21791
+ }).join(`
21792
+ `) : "- (none)";
21793
+ return {
21794
+ content: [
21795
+ {
21796
+ type: "text",
21797
+ text: `${projectLine}Chat search for "${params.query}":
21798
+ ${rows}`
21799
+ }
21800
+ ]
21801
+ };
21802
+ });
20999
21803
  server.tool("recent_requests", "Inspect recently captured raw user requests and prompt chronology", {
21000
21804
  limit: exports_external.number().optional(),
21001
21805
  project_scoped: exports_external.boolean().optional(),
@@ -21091,6 +21895,8 @@ server.tool("session_story", "Show the full local memory story for one session",
21091
21895
  ].filter(Boolean).join(`
21092
21896
  `) : "(none)";
21093
21897
  const promptLines = result.prompts.length > 0 ? result.prompts.map((prompt) => `- #${prompt.prompt_number} ${prompt.prompt.replace(/\s+/g, " ").trim()}`).join(`
21898
+ `) : "- (none)";
21899
+ const chatLines = result.chat_messages.length > 0 ? result.chat_messages.slice(-12).map((msg) => `- [${msg.role}] ${msg.content.replace(/\s+/g, " ").trim().slice(0, 200)}`).join(`
21094
21900
  `) : "- (none)";
21095
21901
  const toolLines = result.tool_events.length > 0 ? result.tool_events.slice(-15).map((tool) => {
21096
21902
  const detail = tool.file_path ?? tool.command ?? tool.tool_response_preview ?? "";
@@ -21105,6 +21911,8 @@ server.tool("session_story", "Show the full local memory story for one session",
21105
21911
  provenance.push(`#${obs.source_prompt_number}`);
21106
21912
  return `- #${obs.id} [${obs.type}] ${obs.title}${provenance.length ? ` (${provenance.join(" · ")})` : ""}`;
21107
21913
  }).join(`
21914
+ `) : "- (none)";
21915
+ const handoffLines = result.handoffs.length > 0 ? result.handoffs.slice(-8).map((obs) => `- #${obs.id} ${obs.title}`).join(`
21108
21916
  `) : "- (none)";
21109
21917
  const metrics = result.metrics ? `files=${result.metrics.files_touched_count}, searches=${result.metrics.searches_performed}, tools=${result.metrics.tool_calls_count}, observations=${result.metrics.observation_count}` : "metrics unavailable";
21110
21918
  const captureGaps = result.capture_gaps.length > 0 ? result.capture_gaps.map((gap) => `- ${gap}`).join(`
@@ -21126,9 +21934,15 @@ ${summaryLines}
21126
21934
  ` + `Prompts:
21127
21935
  ${promptLines}
21128
21936
 
21937
+ ` + `Chat:
21938
+ ${chatLines}
21939
+
21129
21940
  ` + `Tools:
21130
21941
  ${toolLines}
21131
21942
 
21943
+ ` + `Handoffs:
21944
+ ${handoffLines}
21945
+
21132
21946
  ` + `Provenance:
21133
21947
  ${provenanceSummary}
21134
21948