engrm 0.4.21 → 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'
@@ -14122,6 +14122,18 @@ var MIGRATIONS = [
14122
14122
  },
14123
14123
  {
14124
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
+ },
14135
+ {
14136
+ version: 12,
14125
14137
  description: "Add synced handoff metadata to session summaries",
14126
14138
  sql: `
14127
14139
  ALTER TABLE session_summaries ADD COLUMN capture_state TEXT;
@@ -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
  ];
@@ -14196,6 +14272,21 @@ function inferLegacySchemaVersion(db) {
14196
14272
  version2 = Math.max(version2, 10);
14197
14273
  if (columnExists(db, "observations", "source_tool"))
14198
14274
  version2 = Math.max(version2, 11);
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")) {
14276
+ version2 = Math.max(version2, 12);
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
+ }
14199
14290
  return version2;
14200
14291
  }
14201
14292
  function runMigrations(db) {
@@ -14274,6 +14365,89 @@ function ensureObservationTypes(db) {
14274
14365
  }
14275
14366
  }
14276
14367
  }
14368
+ function ensureSessionSummaryColumns(db) {
14369
+ const required2 = [
14370
+ "capture_state",
14371
+ "recent_tool_names",
14372
+ "hot_files",
14373
+ "recent_outcomes",
14374
+ "current_thread"
14375
+ ];
14376
+ for (const column of required2) {
14377
+ if (columnExists(db, "session_summaries", column))
14378
+ continue;
14379
+ db.exec(`ALTER TABLE session_summaries ADD COLUMN ${column} TEXT`);
14380
+ }
14381
+ const current = getSchemaVersion(db);
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)}`);
14444
+ }
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
+ }
14277
14451
  function getSchemaVersion(db) {
14278
14452
  const result = db.query("PRAGMA user_version").get();
14279
14453
  return result.user_version;
@@ -14432,6 +14606,9 @@ class MemDatabase {
14432
14606
  this.vecAvailable = this.loadVecExtension();
14433
14607
  runMigrations(this.db);
14434
14608
  ensureObservationTypes(this.db);
14609
+ ensureSessionSummaryColumns(this.db);
14610
+ ensureChatMessageColumns(this.db);
14611
+ ensureSyncOutboxSupportsChatMessages(this.db);
14435
14612
  }
14436
14613
  loadVecExtension() {
14437
14614
  try {
@@ -14657,6 +14834,7 @@ class MemDatabase {
14657
14834
  p.name AS project_name,
14658
14835
  ss.request AS request,
14659
14836
  ss.completed AS completed,
14837
+ ss.current_thread AS current_thread,
14660
14838
  ss.capture_state AS capture_state,
14661
14839
  ss.recent_tool_names AS recent_tool_names,
14662
14840
  ss.hot_files AS hot_files,
@@ -14675,6 +14853,7 @@ class MemDatabase {
14675
14853
  p.name AS project_name,
14676
14854
  ss.request AS request,
14677
14855
  ss.completed AS completed,
14856
+ ss.current_thread AS current_thread,
14678
14857
  ss.capture_state AS capture_state,
14679
14858
  ss.recent_tool_names AS recent_tool_names,
14680
14859
  ss.hot_files AS hot_files,
@@ -14765,6 +14944,54 @@ class MemDatabase {
14765
14944
  ORDER BY created_at_epoch DESC, id DESC
14766
14945
  LIMIT ?`).all(...userId ? [userId] : [], limit);
14767
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
+ }
14768
14995
  addToOutbox(recordType, recordId) {
14769
14996
  const now = Math.floor(Date.now() / 1000);
14770
14997
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -14853,9 +15080,9 @@ class MemDatabase {
14853
15080
  };
14854
15081
  const result = this.db.query(`INSERT INTO session_summaries (
14855
15082
  session_id, project_id, user_id, request, investigated, learned, completed, next_steps,
14856
- 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
14857
15084
  )
14858
- 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);
14859
15086
  const id = Number(result.lastInsertRowid);
14860
15087
  return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
14861
15088
  }
@@ -14871,6 +15098,7 @@ class MemDatabase {
14871
15098
  learned: normalizeSummarySection(summary.learned ?? existing.learned),
14872
15099
  completed: normalizeSummarySection(summary.completed ?? existing.completed),
14873
15100
  next_steps: normalizeSummarySection(summary.next_steps ?? existing.next_steps),
15101
+ current_thread: summary.current_thread ?? existing.current_thread,
14874
15102
  capture_state: summary.capture_state ?? existing.capture_state,
14875
15103
  recent_tool_names: summary.recent_tool_names ?? existing.recent_tool_names,
14876
15104
  hot_files: summary.hot_files ?? existing.hot_files,
@@ -14884,12 +15112,13 @@ class MemDatabase {
14884
15112
  learned = ?,
14885
15113
  completed = ?,
14886
15114
  next_steps = ?,
15115
+ current_thread = ?,
14887
15116
  capture_state = ?,
14888
15117
  recent_tool_names = ?,
14889
15118
  hot_files = ?,
14890
15119
  recent_outcomes = ?,
14891
15120
  created_at_epoch = ?
14892
- 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);
14893
15122
  return this.getSessionSummary(summary.session_id);
14894
15123
  }
14895
15124
  getSessionSummary(sessionId) {
@@ -16140,12 +16369,12 @@ function getRecentRequests(db, input) {
16140
16369
  };
16141
16370
  }
16142
16371
 
16143
- // src/tools/recent-tools.ts
16144
- function getRecentTools(db, input) {
16145
- 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));
16146
16375
  if (input.session_id) {
16147
16376
  return {
16148
- tool_events: db.getSessionToolEvents(input.session_id, limit).slice(-limit).reverse()
16377
+ messages: db.getSessionChatMessages(input.session_id, limit).slice(-limit).reverse()
16149
16378
  };
16150
16379
  }
16151
16380
  const projectScoped = input.project_scoped !== false;
@@ -16161,7 +16390,28 @@ function getRecentTools(db, input) {
16161
16390
  }
16162
16391
  }
16163
16392
  return {
16164
- 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),
16165
16415
  project: projectName
16166
16416
  };
16167
16417
  }
@@ -16171,8 +16421,11 @@ function getSessionStory(db, input) {
16171
16421
  const session = db.getSessionById(input.session_id);
16172
16422
  const summary = db.getSessionSummary(input.session_id);
16173
16423
  const prompts = db.getSessionUserPrompts(input.session_id, 50);
16424
+ const chatMessages = db.getSessionChatMessages(input.session_id, 50);
16174
16425
  const toolEvents = db.getSessionToolEvents(input.session_id, 100);
16175
- 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));
16176
16429
  const metrics = db.getSessionMetrics(input.session_id);
16177
16430
  const projectName = session?.project_id !== null && session?.project_id !== undefined ? db.getProjectById(session.project_id)?.name ?? null : null;
16178
16431
  const latestRequest = prompts[prompts.length - 1]?.prompt?.trim() || summary?.request?.trim() || null;
@@ -16181,8 +16434,10 @@ function getSessionStory(db, input) {
16181
16434
  project_name: projectName,
16182
16435
  summary,
16183
16436
  prompts,
16437
+ chat_messages: chatMessages,
16184
16438
  tool_events: toolEvents,
16185
16439
  observations,
16440
+ handoffs,
16186
16441
  metrics,
16187
16442
  capture_state: classifyCaptureState({
16188
16443
  hasSummary: Boolean(summary?.request || summary?.completed),
@@ -16276,6 +16531,269 @@ function collectProvenanceSummary(observations) {
16276
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);
16277
16532
  }
16278
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
+
16279
16797
  // src/tools/recent-sessions.ts
16280
16798
  function getRecentSessions(db, input) {
16281
16799
  const limit = Math.max(1, Math.min(input.limit ?? 10, 50));
@@ -16631,6 +17149,12 @@ function buildSessionContext(db, cwd, options = {}) {
16631
17149
  const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
16632
17150
  const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
16633
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;
16634
17158
  return {
16635
17159
  project_name: projectName,
16636
17160
  canonical_id: canonicalId,
@@ -16641,7 +17165,8 @@ function buildSessionContext(db, cwd, options = {}) {
16641
17165
  recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
16642
17166
  recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
16643
17167
  projectTypeCounts: projectTypeCounts2,
16644
- recentOutcomes: recentOutcomes2
17168
+ recentOutcomes: recentOutcomes2,
17169
+ recentHandoffs: recentHandoffs2.length > 0 ? recentHandoffs2 : undefined
16645
17170
  };
16646
17171
  }
16647
17172
  let remainingBudget = tokenBudget - 30;
@@ -16669,6 +17194,12 @@ function buildSessionContext(db, cwd, options = {}) {
16669
17194
  const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
16670
17195
  const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
16671
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;
16672
17203
  let securityFindings = [];
16673
17204
  if (!isNewProject) {
16674
17205
  try {
@@ -16727,7 +17258,8 @@ function buildSessionContext(db, cwd, options = {}) {
16727
17258
  recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
16728
17259
  recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
16729
17260
  projectTypeCounts,
16730
- recentOutcomes
17261
+ recentOutcomes,
17262
+ recentHandoffs: recentHandoffs.length > 0 ? recentHandoffs : undefined
16731
17263
  };
16732
17264
  }
16733
17265
  function estimateObservationTokens(obs, index) {
@@ -17220,6 +17752,9 @@ function buildSuggestedTools(sessions, requestCount, toolCount, observationCount
17220
17752
  if (observationCount > 0) {
17221
17753
  suggested.push("tool_memory_index", "capture_git_worktree");
17222
17754
  }
17755
+ if (sessions.length > 0) {
17756
+ suggested.push("create_handoff", "recent_handoffs");
17757
+ }
17223
17758
  return Array.from(new Set(suggested)).slice(0, 4);
17224
17759
  }
17225
17760
 
@@ -17283,6 +17818,8 @@ function buildFallbackSuggestedTools(sessionCount, requestCount, toolCount, obse
17283
17818
  suggested.push("activity_feed");
17284
17819
  if (observationCount > 0)
17285
17820
  suggested.push("tool_memory_index", "capture_git_worktree");
17821
+ if (sessionCount > 0)
17822
+ suggested.push("create_handoff", "recent_handoffs");
17286
17823
  return Array.from(new Set(suggested)).slice(0, 4);
17287
17824
  }
17288
17825
 
@@ -17499,6 +18036,17 @@ function toToolEvent(tool) {
17499
18036
  };
17500
18037
  }
17501
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
+ }
17502
18050
  const detailBits = [];
17503
18051
  if (obs.source_tool)
17504
18052
  detailBits.push(`via ${obs.source_tool}`);
@@ -17542,9 +18090,10 @@ function compareEvents(a, b) {
17542
18090
  }
17543
18091
  const kindOrder = {
17544
18092
  summary: 0,
17545
- observation: 1,
17546
- tool: 2,
17547
- prompt: 3
18093
+ handoff: 1,
18094
+ observation: 2,
18095
+ tool: 3,
18096
+ prompt: 4
17548
18097
  };
17549
18098
  if (kindOrder[a.kind] !== kindOrder[b.kind]) {
17550
18099
  return kindOrder[a.kind] - kindOrder[b.kind];
@@ -17565,6 +18114,7 @@ function getActivityFeed(db, input) {
17565
18114
  })].filter((event) => event !== null) : [],
17566
18115
  ...story.prompts.map(toPromptEvent),
17567
18116
  ...story.tool_events.map(toToolEvent),
18117
+ ...story.handoffs.map(toObservationEvent),
17568
18118
  ...story.observations.map(toObservationEvent)
17569
18119
  ].sort(compareEvents).slice(0, limit);
17570
18120
  return { events: events2, project };
@@ -18014,13 +18564,13 @@ function getSessionContext(db, input) {
18014
18564
  function buildHotFiles(context) {
18015
18565
  const counts = new Map;
18016
18566
  for (const obs of context.observations) {
18017
- 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)]) {
18018
18568
  counts.set(path, (counts.get(path) ?? 0) + 1);
18019
18569
  }
18020
18570
  }
18021
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);
18022
18572
  }
18023
- function parseJsonArray2(value) {
18573
+ function parseJsonArray3(value) {
18024
18574
  if (!value)
18025
18575
  return [];
18026
18576
  try {
@@ -18041,6 +18591,9 @@ function buildSuggestedTools2(context) {
18041
18591
  if (context.observations.length > 0) {
18042
18592
  tools.push("tool_memory_index", "capture_git_worktree");
18043
18593
  }
18594
+ if ((context.recentSessions?.length ?? 0) > 0) {
18595
+ tools.push("create_handoff", "recent_handoffs");
18596
+ }
18044
18597
  return Array.from(new Set(tools)).slice(0, 4);
18045
18598
  }
18046
18599
 
@@ -18591,22 +19144,27 @@ function buildSourceId(config2, localId, type = "obs") {
18591
19144
  return `${config2.user_id}-${config2.device_id}-${type}-${localId}`;
18592
19145
  }
18593
19146
  function parseSourceId(sourceId) {
18594
- const obsIndex = sourceId.lastIndexOf("-obs-");
18595
- if (obsIndex === -1)
18596
- return null;
18597
- const prefix = sourceId.slice(0, obsIndex);
18598
- const localIdStr = sourceId.slice(obsIndex + 5);
18599
- const localId = parseInt(localIdStr, 10);
18600
- if (isNaN(localId))
18601
- return null;
18602
- const firstDash = prefix.indexOf("-");
18603
- if (firstDash === -1)
18604
- return null;
18605
- return {
18606
- userId: prefix.slice(0, firstDash),
18607
- deviceId: prefix.slice(firstDash + 1),
18608
- localId
18609
- };
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;
18610
19168
  }
18611
19169
 
18612
19170
  // src/sync/client.ts
@@ -18712,7 +19270,106 @@ class VectorApiError extends Error {
18712
19270
  }
18713
19271
  }
18714
19272
 
19273
+ // src/capture/session-handoff.ts
19274
+ function buildSessionHandoffMetadata(prompts, toolEvents, observations) {
19275
+ const latestRequest = prompts.length > 0 ? prompts[prompts.length - 1]?.prompt ?? null : null;
19276
+ const recentRequestPrompts = prompts.slice(-3).map((prompt) => prompt.prompt.trim()).filter(Boolean);
19277
+ const recentToolNames = [...new Set(toolEvents.slice(-8).map((tool) => tool.tool_name).filter(Boolean))];
19278
+ const recentToolCommands = [...new Set(toolEvents.slice(-5).map((tool) => (tool.command ?? tool.file_path ?? "").trim()).filter(Boolean))];
19279
+ const hotFiles = [...new Set(observations.flatMap((obs) => [
19280
+ ...parseJsonArray4(obs.files_modified),
19281
+ ...parseJsonArray4(obs.files_read)
19282
+ ]).filter(Boolean))].slice(0, 6);
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);
19284
+ const captureState = prompts.length > 0 && toolEvents.length > 0 ? "rich" : prompts.length > 0 || toolEvents.length > 0 ? "partial" : "summary-only";
19285
+ const observationSourceTools = Array.from(observations.reduce((acc, obs) => {
19286
+ if (!obs.source_tool)
19287
+ return acc;
19288
+ acc.set(obs.source_tool, (acc.get(obs.source_tool) ?? 0) + 1);
19289
+ return acc;
19290
+ }, new Map).entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
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);
19293
+ return {
19294
+ prompt_count: prompts.length,
19295
+ tool_event_count: toolEvents.length,
19296
+ recent_request_prompts: recentRequestPrompts,
19297
+ latest_request: latestRequest,
19298
+ current_thread: currentThread,
19299
+ recent_tool_names: recentToolNames,
19300
+ recent_tool_commands: recentToolCommands,
19301
+ capture_state: captureState,
19302
+ hot_files: hotFiles,
19303
+ recent_outcomes: recentOutcomes,
19304
+ observation_source_tools: observationSourceTools,
19305
+ latest_observation_prompt_number: latestObservationPromptNumber
19306
+ };
19307
+ }
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) {
19340
+ if (!value)
19341
+ return [];
19342
+ try {
19343
+ const parsed = JSON.parse(value);
19344
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
19345
+ } catch {
19346
+ return [];
19347
+ }
19348
+ }
19349
+
18715
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
+ }
18716
19373
  function buildVectorDocument(obs, config2, project) {
18717
19374
  const parts = [obs.title];
18718
19375
  if (obs.narrative)
@@ -18793,6 +19450,7 @@ function buildSummaryVectorDocument(summary, config2, project, observations = []
18793
19450
  learned: summary.learned,
18794
19451
  completed: summary.completed,
18795
19452
  next_steps: summary.next_steps,
19453
+ current_thread: summary.current_thread,
18796
19454
  summary_sections_present: countPresentSections2(summary),
18797
19455
  investigated_items: extractSectionItems2(summary.investigated),
18798
19456
  learned_items: extractSectionItems2(summary.learned),
@@ -18803,6 +19461,7 @@ function buildSummaryVectorDocument(summary, config2, project, observations = []
18803
19461
  capture_state: captureContext?.capture_state ?? "summary-only",
18804
19462
  recent_request_prompts: captureContext?.recent_request_prompts ?? [],
18805
19463
  latest_request: captureContext?.latest_request ?? null,
19464
+ current_thread: captureContext?.current_thread ?? null,
18806
19465
  recent_tool_names: captureContext?.recent_tool_names ?? [],
18807
19466
  recent_tool_commands: captureContext?.recent_tool_commands ?? [],
18808
19467
  hot_files: captureContext?.hot_files ?? [],
@@ -18852,7 +19511,33 @@ async function pushOutbox(db, client, config2, batchSize = 50) {
18852
19511
  const doc3 = buildSummaryVectorDocument(summary, config2, {
18853
19512
  canonical_id: project2.canonical_id,
18854
19513
  name: project2.name
18855
- }, summaryObservations, buildSummaryCaptureContext(sessionPrompts, sessionToolEvents, summaryObservations));
19514
+ }, summaryObservations, buildSessionHandoffMetadata(sessionPrompts, sessionToolEvents, summaryObservations));
19515
+ batch.push({ entryId: entry.id, doc: doc3 });
19516
+ continue;
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
+ });
18856
19541
  batch.push({ entryId: entry.id, doc: doc3 });
18857
19542
  continue;
18858
19543
  }
@@ -18893,7 +19578,13 @@ async function pushOutbox(db, client, config2, batchSize = 50) {
18893
19578
  return { pushed, failed, skipped };
18894
19579
  try {
18895
19580
  await client.batchIngest(batch.map((b) => b.doc));
18896
- 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
+ }
18897
19588
  markSynced(db, entryId);
18898
19589
  pushed++;
18899
19590
  }
@@ -18901,6 +19592,12 @@ async function pushOutbox(db, client, config2, batchSize = 50) {
18901
19592
  for (const { entryId, doc: doc2 } of batch) {
18902
19593
  try {
18903
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
+ }
18904
19601
  markSynced(db, entryId);
18905
19602
  pushed++;
18906
19603
  } catch (err) {
@@ -18923,48 +19620,6 @@ function countPresentSections2(summary) {
18923
19620
  function extractSectionItems2(section) {
18924
19621
  return extractSummaryItems(section, 4);
18925
19622
  }
18926
- function buildSummaryCaptureContext(prompts, toolEvents, observations) {
18927
- const latestRequest = prompts.length > 0 ? prompts[prompts.length - 1]?.prompt ?? null : null;
18928
- const recentRequestPrompts = prompts.slice(-3).map((prompt) => prompt.prompt.trim()).filter(Boolean);
18929
- const recentToolNames = [...new Set(toolEvents.slice(-8).map((tool) => tool.tool_name).filter(Boolean))];
18930
- const recentToolCommands = [...new Set(toolEvents.slice(-5).map((tool) => (tool.command ?? tool.file_path ?? "").trim()).filter(Boolean))];
18931
- const hotFiles = [...new Set(observations.flatMap((obs) => [
18932
- ...parseJsonArray3(obs.files_modified),
18933
- ...parseJsonArray3(obs.files_read)
18934
- ]).filter(Boolean))].slice(0, 6);
18935
- 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);
18936
- const captureState = prompts.length > 0 && toolEvents.length > 0 ? "rich" : prompts.length > 0 || toolEvents.length > 0 ? "partial" : "summary-only";
18937
- const observationSourceTools = Array.from(observations.reduce((acc, obs) => {
18938
- if (!obs.source_tool)
18939
- return acc;
18940
- acc.set(obs.source_tool, (acc.get(obs.source_tool) ?? 0) + 1);
18941
- return acc;
18942
- }, new Map).entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
18943
- const latestObservationPromptNumber = observations.map((obs) => obs.source_prompt_number).filter((value) => typeof value === "number").sort((a, b) => b - a)[0] ?? null;
18944
- return {
18945
- prompt_count: prompts.length,
18946
- tool_event_count: toolEvents.length,
18947
- recent_request_prompts: recentRequestPrompts,
18948
- latest_request: latestRequest,
18949
- recent_tool_names: recentToolNames,
18950
- recent_tool_commands: recentToolCommands,
18951
- capture_state: captureState,
18952
- hot_files: hotFiles,
18953
- recent_outcomes: recentOutcomes,
18954
- observation_source_tools: observationSourceTools,
18955
- latest_observation_prompt_number: latestObservationPromptNumber
18956
- };
18957
- }
18958
- function parseJsonArray3(value) {
18959
- if (!value)
18960
- return [];
18961
- try {
18962
- const parsed = JSON.parse(value);
18963
- return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
18964
- } catch {
18965
- return [];
18966
- }
18967
- }
18968
19623
 
18969
19624
  // src/sync/pull.ts
18970
19625
  var PULL_CURSOR_KEY = "pull_cursor";
@@ -18995,6 +19650,7 @@ function mergeChanges(db, config2, changes) {
18995
19650
  for (const change of changes) {
18996
19651
  const parsed = parseSourceId(change.source_id);
18997
19652
  const remoteSummary = isRemoteSummary(change);
19653
+ const remoteChat = isRemoteChat(change);
18998
19654
  if (parsed && parsed.deviceId === config2.device_id) {
18999
19655
  skipped++;
19000
19656
  continue;
@@ -19017,6 +19673,15 @@ function mergeChanges(db, config2, changes) {
19017
19673
  merged++;
19018
19674
  }
19019
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
+ }
19020
19685
  const existing = db.db.query("SELECT id FROM observations WHERE remote_source_id = ?").get(change.source_id);
19021
19686
  if (existing) {
19022
19687
  if (!remoteSummary)
@@ -19058,6 +19723,10 @@ function isRemoteSummary(change) {
19058
19723
  const rawType = typeof change.metadata?.type === "string" ? change.metadata.type.toLowerCase() : "";
19059
19724
  return rawType === "summary" || change.source_id.includes("-summary-");
19060
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
+ }
19061
19730
  function mergeRemoteSummary(db, config2, change, projectId) {
19062
19731
  const sessionId = typeof change.metadata?.session_id === "string" ? change.metadata.session_id : null;
19063
19732
  if (!sessionId)
@@ -19071,6 +19740,7 @@ function mergeRemoteSummary(db, config2, change, projectId) {
19071
19740
  learned: typeof change.metadata?.learned === "string" ? change.metadata.learned : null,
19072
19741
  completed: typeof change.metadata?.completed === "string" ? change.metadata.completed : null,
19073
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,
19074
19744
  capture_state: typeof change.metadata?.capture_state === "string" ? change.metadata.capture_state : null,
19075
19745
  recent_tool_names: encodeStringArray(change.metadata?.recent_tool_names),
19076
19746
  hot_files: encodeStringArray(change.metadata?.hot_files),
@@ -19078,6 +19748,26 @@ function mergeRemoteSummary(db, config2, change, projectId) {
19078
19748
  });
19079
19749
  return Boolean(summary);
19080
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
+ }
19081
19771
  function encodeStringArray(value) {
19082
19772
  if (!Array.isArray(value))
19083
19773
  return null;
@@ -19886,7 +20576,7 @@ process.on("SIGTERM", () => {
19886
20576
  });
19887
20577
  var server = new McpServer({
19888
20578
  name: "engrm",
19889
- version: "0.4.21"
20579
+ version: "0.4.23"
19890
20580
  });
19891
20581
  server.tool("save_observation", "Save an observation to memory", {
19892
20582
  type: exports_external.enum([
@@ -20973,6 +21663,143 @@ ${projectLines}`
20973
21663
  ]
20974
21664
  };
20975
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
+ });
20976
21803
  server.tool("recent_requests", "Inspect recently captured raw user requests and prompt chronology", {
20977
21804
  limit: exports_external.number().optional(),
20978
21805
  project_scoped: exports_external.boolean().optional(),
@@ -21068,6 +21895,8 @@ server.tool("session_story", "Show the full local memory story for one session",
21068
21895
  ].filter(Boolean).join(`
21069
21896
  `) : "(none)";
21070
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(`
21071
21900
  `) : "- (none)";
21072
21901
  const toolLines = result.tool_events.length > 0 ? result.tool_events.slice(-15).map((tool) => {
21073
21902
  const detail = tool.file_path ?? tool.command ?? tool.tool_response_preview ?? "";
@@ -21082,6 +21911,8 @@ server.tool("session_story", "Show the full local memory story for one session",
21082
21911
  provenance.push(`#${obs.source_prompt_number}`);
21083
21912
  return `- #${obs.id} [${obs.type}] ${obs.title}${provenance.length ? ` (${provenance.join(" · ")})` : ""}`;
21084
21913
  }).join(`
21914
+ `) : "- (none)";
21915
+ const handoffLines = result.handoffs.length > 0 ? result.handoffs.slice(-8).map((obs) => `- #${obs.id} ${obs.title}`).join(`
21085
21916
  `) : "- (none)";
21086
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";
21087
21918
  const captureGaps = result.capture_gaps.length > 0 ? result.capture_gaps.map((gap) => `- ${gap}`).join(`
@@ -21103,9 +21934,15 @@ ${summaryLines}
21103
21934
  ` + `Prompts:
21104
21935
  ${promptLines}
21105
21936
 
21937
+ ` + `Chat:
21938
+ ${chatLines}
21939
+
21106
21940
  ` + `Tools:
21107
21941
  ${toolLines}
21108
21942
 
21943
+ ` + `Handoffs:
21944
+ ${handoffLines}
21945
+
21109
21946
  ` + `Provenance:
21110
21947
  ${provenanceSummary}
21111
21948