engrm 0.4.23 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -473,6 +473,125 @@ function normalizeItem(value) {
473
473
  return value.toLowerCase().replace(/\*+/g, "").replace(/\s+/g, " ").trim();
474
474
  }
475
475
 
476
+ // src/tools/session-story.ts
477
+ function getSessionStory(db, input) {
478
+ const session = db.getSessionById(input.session_id);
479
+ const summary = db.getSessionSummary(input.session_id);
480
+ const prompts = db.getSessionUserPrompts(input.session_id, 50);
481
+ const chatMessages = db.getSessionChatMessages(input.session_id, 50);
482
+ const toolEvents = db.getSessionToolEvents(input.session_id, 100);
483
+ const allObservations = db.getObservationsBySession(input.session_id);
484
+ const handoffs = allObservations.filter((obs) => looksLikeHandoff(obs));
485
+ const rollingHandoffDrafts = handoffs.filter((obs) => isDraftHandoff(obs));
486
+ const savedHandoffs = handoffs.filter((obs) => !isDraftHandoff(obs));
487
+ const observations = allObservations.filter((obs) => !looksLikeHandoff(obs));
488
+ const metrics = db.getSessionMetrics(input.session_id);
489
+ const projectName = session?.project_id !== null && session?.project_id !== undefined ? db.getProjectById(session.project_id)?.name ?? null : null;
490
+ const latestRequest = prompts[prompts.length - 1]?.prompt?.trim() || summary?.request?.trim() || null;
491
+ return {
492
+ session,
493
+ project_name: projectName,
494
+ summary,
495
+ prompts,
496
+ chat_messages: chatMessages,
497
+ tool_events: toolEvents,
498
+ observations,
499
+ handoffs,
500
+ saved_handoffs: savedHandoffs,
501
+ rolling_handoff_drafts: rollingHandoffDrafts,
502
+ metrics,
503
+ capture_state: classifyCaptureState({
504
+ hasSummary: Boolean(summary?.request || summary?.completed),
505
+ promptCount: prompts.length,
506
+ toolEventCount: toolEvents.length
507
+ }),
508
+ capture_gaps: buildCaptureGaps({
509
+ promptCount: prompts.length,
510
+ toolEventCount: toolEvents.length,
511
+ toolCallsCount: metrics?.tool_calls_count ?? 0,
512
+ observationCount: observations.length,
513
+ hasSummary: Boolean(summary?.request || summary?.completed)
514
+ }),
515
+ latest_request: latestRequest,
516
+ recent_outcomes: collectRecentOutcomes(observations),
517
+ hot_files: collectHotFiles(observations),
518
+ provenance_summary: collectProvenanceSummary(observations)
519
+ };
520
+ }
521
+ function classifyCaptureState(input) {
522
+ if (input.promptCount > 0 && input.toolEventCount > 0)
523
+ return "rich";
524
+ if (input.promptCount > 0 || input.toolEventCount > 0)
525
+ return "partial";
526
+ if (input.hasSummary)
527
+ return "summary-only";
528
+ return "legacy";
529
+ }
530
+ function buildCaptureGaps(input) {
531
+ const gaps = [];
532
+ if (input.promptCount === 0)
533
+ gaps.push("missing prompts");
534
+ if (input.toolCallsCount > 0 && input.toolEventCount === 0) {
535
+ gaps.push("missing raw tool chronology");
536
+ } else if (input.toolEventCount === 0) {
537
+ gaps.push("no tool events");
538
+ }
539
+ if (input.observationCount === 0 && input.hasSummary) {
540
+ gaps.push("summary without reusable observations");
541
+ }
542
+ return gaps;
543
+ }
544
+ function collectRecentOutcomes(observations) {
545
+ const seen = new Set;
546
+ const outcomes = [];
547
+ for (const obs of observations) {
548
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
549
+ continue;
550
+ const title = obs.title.trim();
551
+ if (!title || looksLikeFileOperationTitle(title))
552
+ continue;
553
+ const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
554
+ if (seen.has(normalized))
555
+ continue;
556
+ seen.add(normalized);
557
+ outcomes.push(title);
558
+ if (outcomes.length >= 6)
559
+ break;
560
+ }
561
+ return outcomes;
562
+ }
563
+ function collectHotFiles(observations) {
564
+ const counts = new Map;
565
+ for (const obs of observations) {
566
+ for (const path of [...parseJsonArray(obs.files_modified), ...parseJsonArray(obs.files_read)]) {
567
+ counts.set(path, (counts.get(path) ?? 0) + 1);
568
+ }
569
+ }
570
+ return Array.from(counts.entries()).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)).slice(0, 8);
571
+ }
572
+ function parseJsonArray(value) {
573
+ if (!value)
574
+ return [];
575
+ try {
576
+ const parsed = JSON.parse(value);
577
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
578
+ } catch {
579
+ return [];
580
+ }
581
+ }
582
+ function looksLikeFileOperationTitle(value) {
583
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
584
+ }
585
+ function collectProvenanceSummary(observations) {
586
+ const counts = new Map;
587
+ for (const obs of observations) {
588
+ if (!obs.source_tool)
589
+ continue;
590
+ counts.set(obs.source_tool, (counts.get(obs.source_tool) ?? 0) + 1);
591
+ }
592
+ 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);
593
+ }
594
+
476
595
  // src/tools/save.ts
477
596
  import { relative, isAbsolute } from "node:path";
478
597
 
@@ -1136,71 +1255,996 @@ async function saveObservation(db, config, input) {
1136
1255
  }
1137
1256
  } catch {}
1138
1257
  }
1139
- return {
1140
- success: true,
1141
- observation_id: obs.id,
1142
- quality_score: qualityScore,
1143
- recall_hint: recallHint,
1144
- conflict_warning: conflictWarning
1145
- };
1258
+ return {
1259
+ success: true,
1260
+ observation_id: obs.id,
1261
+ quality_score: qualityScore,
1262
+ recall_hint: recallHint,
1263
+ conflict_warning: conflictWarning
1264
+ };
1265
+ }
1266
+ function toRelativePath(filePath, projectRoot) {
1267
+ if (!isAbsolute(filePath))
1268
+ return filePath;
1269
+ const rel = relative(projectRoot, filePath);
1270
+ if (rel.startsWith(".."))
1271
+ return filePath;
1272
+ return rel;
1273
+ }
1274
+
1275
+ // src/tools/handoffs.ts
1276
+ async function upsertRollingHandoff(db, config, input) {
1277
+ const resolved = resolveTargetSession(db, input.cwd, config.user_id, input.session_id);
1278
+ if (!resolved.session) {
1279
+ return {
1280
+ success: false,
1281
+ reason: "No recent session found to draft a handoff yet"
1282
+ };
1283
+ }
1284
+ const story = getSessionStory(db, { session_id: resolved.session.session_id });
1285
+ if (!story.session) {
1286
+ return {
1287
+ success: false,
1288
+ reason: `Session ${resolved.session.session_id} not found`
1289
+ };
1290
+ }
1291
+ const includeChat = input.include_chat === true || input.include_chat !== false && shouldAutoIncludeChat(story);
1292
+ const chatLimit = Math.max(1, Math.min(input.chat_limit ?? 3, 6));
1293
+ const title = `Handoff Draft: ${buildHandoffTitle(story.summary, story.latest_request)}`;
1294
+ const narrative = buildHandoffNarrative(story.summary, story, {
1295
+ includeChat,
1296
+ chatLimit
1297
+ });
1298
+ const facts = buildHandoffFacts(story.summary, story);
1299
+ const concepts = buildDraftHandoffConcepts(story.project_name, story.capture_state);
1300
+ const existing = getSessionRollingHandoff(db, story.session.session_id);
1301
+ const now = Math.floor(Date.now() / 1000);
1302
+ if (existing) {
1303
+ const nextFacts = JSON.stringify(facts);
1304
+ const nextConcepts = JSON.stringify(concepts);
1305
+ const shouldRefresh = existing.title !== title || (existing.narrative ?? null) !== narrative || (existing.facts ?? null) !== nextFacts || (existing.concepts ?? null) !== nextConcepts || now - existing.created_at_epoch >= 120;
1306
+ if (!shouldRefresh) {
1307
+ return {
1308
+ success: true,
1309
+ observation_id: existing.id,
1310
+ session_id: story.session.session_id,
1311
+ title: existing.title
1312
+ };
1313
+ }
1314
+ const updated = db.updateObservationContent(existing.id, {
1315
+ title,
1316
+ narrative,
1317
+ facts: nextFacts,
1318
+ concepts: nextConcepts,
1319
+ created_at_epoch: now
1320
+ });
1321
+ if (!updated) {
1322
+ return {
1323
+ success: false,
1324
+ reason: "Failed to update rolling handoff draft"
1325
+ };
1326
+ }
1327
+ db.addToOutbox("observation", updated.id);
1328
+ return {
1329
+ success: true,
1330
+ observation_id: updated.id,
1331
+ session_id: story.session.session_id,
1332
+ title: updated.title
1333
+ };
1334
+ }
1335
+ const result = await saveObservation(db, config, {
1336
+ type: "message",
1337
+ title,
1338
+ narrative,
1339
+ facts,
1340
+ concepts,
1341
+ session_id: story.session.session_id,
1342
+ cwd: input.cwd,
1343
+ agent: "engrm-handoff",
1344
+ source_tool: "rolling_handoff"
1345
+ });
1346
+ return {
1347
+ success: result.success,
1348
+ observation_id: result.observation_id,
1349
+ session_id: story.session.session_id,
1350
+ title,
1351
+ reason: result.reason
1352
+ };
1353
+ }
1354
+ function getRecentHandoffs(db, input) {
1355
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
1356
+ const queryLimit = input.current_device_id ? Math.max(limit, Math.min(limit * 5, 50)) : limit;
1357
+ const projectScoped = input.project_scoped !== false;
1358
+ let projectId = null;
1359
+ let projectName;
1360
+ if (projectScoped) {
1361
+ const cwd = input.cwd ?? process.cwd();
1362
+ const detected = detectProject(cwd);
1363
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
1364
+ if (project) {
1365
+ projectId = project.id;
1366
+ projectName = project.name;
1367
+ }
1368
+ }
1369
+ const conditions = [
1370
+ "o.type = 'message'",
1371
+ "o.lifecycle IN ('active', 'aging', 'pinned')",
1372
+ "o.superseded_by IS NULL",
1373
+ `(o.title LIKE 'Handoff:%' OR o.concepts LIKE '%"handoff"%')`
1374
+ ];
1375
+ const params = [];
1376
+ if (input.user_id) {
1377
+ conditions.push("(o.sensitivity != 'personal' OR o.user_id = ?)");
1378
+ params.push(input.user_id);
1379
+ }
1380
+ if (projectId !== null) {
1381
+ conditions.push("o.project_id = ?");
1382
+ params.push(projectId);
1383
+ }
1384
+ params.push(queryLimit);
1385
+ const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
1386
+ FROM observations o
1387
+ LEFT JOIN projects p ON p.id = o.project_id
1388
+ WHERE ${conditions.join(" AND ")}
1389
+ ORDER BY o.created_at_epoch DESC, o.id DESC
1390
+ LIMIT ?`).all(...params);
1391
+ handoffs.sort((a, b) => compareHandoffs(a, b, input.current_device_id));
1392
+ return {
1393
+ handoffs: handoffs.slice(0, limit),
1394
+ project: projectName
1395
+ };
1396
+ }
1397
+ function formatHandoffSource(handoff) {
1398
+ const ageSeconds = Math.max(0, Math.floor(Date.now() / 1000) - handoff.created_at_epoch);
1399
+ const ageLabel = ageSeconds < 3600 ? `${Math.max(1, Math.floor(ageSeconds / 60) || 1)}m ago` : ageSeconds < 86400 ? `${Math.floor(ageSeconds / 3600)}h ago` : `${Math.floor(ageSeconds / 86400)}d ago`;
1400
+ return `from ${handoff.device_id} · ${ageLabel}`;
1401
+ }
1402
+ function isDraftHandoff(obs) {
1403
+ if (obs.title.startsWith("Handoff Draft:"))
1404
+ return true;
1405
+ const concepts = parseJsonArray2(obs.concepts);
1406
+ return concepts.includes("draft-handoff") || concepts.includes("auto-handoff");
1407
+ }
1408
+ function getSessionRollingHandoff(db, sessionId) {
1409
+ return db.db.query(`SELECT o.*, p.name AS project_name
1410
+ FROM observations o
1411
+ LEFT JOIN projects p ON p.id = o.project_id
1412
+ WHERE o.session_id = ?
1413
+ AND o.type = 'message'
1414
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
1415
+ AND o.superseded_by IS NULL
1416
+ AND (o.title LIKE 'Handoff Draft:%' OR o.concepts LIKE '%"draft-handoff"%')
1417
+ ORDER BY o.created_at_epoch DESC, o.id DESC
1418
+ LIMIT 1`).get(sessionId) ?? null;
1419
+ }
1420
+ function compareHandoffs(a, b, currentDeviceId) {
1421
+ const aDraft = isDraftHandoff(a) ? 1 : 0;
1422
+ const bDraft = isDraftHandoff(b) ? 1 : 0;
1423
+ if (aDraft !== bDraft)
1424
+ return aDraft - bDraft;
1425
+ if (currentDeviceId) {
1426
+ const aOther = a.device_id !== currentDeviceId ? 1 : 0;
1427
+ const bOther = b.device_id !== currentDeviceId ? 1 : 0;
1428
+ if (aOther !== bOther)
1429
+ return bOther - aOther;
1430
+ }
1431
+ if (b.created_at_epoch !== a.created_at_epoch) {
1432
+ return b.created_at_epoch - a.created_at_epoch;
1433
+ }
1434
+ return b.id - a.id;
1435
+ }
1436
+ function resolveTargetSession(db, cwd, userId, sessionId) {
1437
+ if (sessionId) {
1438
+ const session = db.getSessionById(sessionId);
1439
+ if (!session)
1440
+ return { session: null };
1441
+ const projectName = session.project_id ? db.getProjectById(session.project_id)?.name : undefined;
1442
+ return {
1443
+ session: {
1444
+ ...session,
1445
+ project_name: projectName ?? null,
1446
+ request: db.getSessionSummary(sessionId)?.request ?? null,
1447
+ completed: db.getSessionSummary(sessionId)?.completed ?? null,
1448
+ current_thread: db.getSessionSummary(sessionId)?.current_thread ?? null,
1449
+ capture_state: db.getSessionSummary(sessionId)?.capture_state ?? null,
1450
+ recent_tool_names: db.getSessionSummary(sessionId)?.recent_tool_names ?? null,
1451
+ hot_files: db.getSessionSummary(sessionId)?.hot_files ?? null,
1452
+ recent_outcomes: db.getSessionSummary(sessionId)?.recent_outcomes ?? null,
1453
+ prompt_count: db.getSessionUserPrompts(sessionId, 200).length,
1454
+ tool_event_count: db.getSessionToolEvents(sessionId, 200).length
1455
+ },
1456
+ projectName: projectName ?? undefined
1457
+ };
1458
+ }
1459
+ const detected = detectProject(cwd ?? process.cwd());
1460
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
1461
+ const sessions = db.getRecentSessions(project?.id ?? null, 10, userId);
1462
+ return {
1463
+ session: sessions[0] ?? null,
1464
+ projectName: project?.name
1465
+ };
1466
+ }
1467
+ function buildHandoffTitle(summary, latestRequest, explicit) {
1468
+ const chosen = explicit?.trim() || summary?.current_thread?.trim() || summary?.completed?.trim() || latestRequest?.trim() || "Current work";
1469
+ return compactLine(chosen) ?? "Current work";
1470
+ }
1471
+ function buildHandoffNarrative(summary, story, options) {
1472
+ const sections = [];
1473
+ if (summary?.request || story.latest_request) {
1474
+ sections.push(`Request: ${summary?.request ?? story.latest_request}`);
1475
+ }
1476
+ if (summary?.current_thread) {
1477
+ sections.push(`Current thread: ${summary.current_thread}`);
1478
+ }
1479
+ if (summary?.investigated) {
1480
+ sections.push(`Investigated: ${summary.investigated}`);
1481
+ }
1482
+ if (summary?.learned) {
1483
+ sections.push(`Learned: ${summary.learned}`);
1484
+ }
1485
+ if (summary?.completed) {
1486
+ sections.push(`Completed: ${summary.completed}`);
1487
+ }
1488
+ if (summary?.next_steps) {
1489
+ sections.push(`Next Steps: ${summary.next_steps}`);
1490
+ }
1491
+ if (story.recent_outcomes.length > 0) {
1492
+ sections.push(`Recent outcomes:
1493
+ ${story.recent_outcomes.slice(0, 5).map((item) => `- ${item}`).join(`
1494
+ `)}`);
1495
+ }
1496
+ if (story.hot_files.length > 0) {
1497
+ sections.push(`Hot files:
1498
+ ${story.hot_files.slice(0, 5).map((file) => `- ${file.path}`).join(`
1499
+ `)}`);
1500
+ }
1501
+ if (story.provenance_summary.length > 0) {
1502
+ sections.push(`Tool trail:
1503
+ ${story.provenance_summary.slice(0, 5).map((item) => `- ${item.tool}: ${item.count}`).join(`
1504
+ `)}`);
1505
+ }
1506
+ if (options.includeChat && story.chat_messages.length > 0) {
1507
+ const chatLines = story.chat_messages.slice(-options.chatLimit).map((msg) => `- [${msg.role}] ${compactLine(msg.content) ?? msg.content.slice(0, 120)}`);
1508
+ sections.push(`Chat snippets:
1509
+ ${chatLines.join(`
1510
+ `)}`);
1511
+ }
1512
+ return sections.filter(Boolean).join(`
1513
+
1514
+ `);
1515
+ }
1516
+ function shouldAutoIncludeChat(story) {
1517
+ if (story.chat_messages.length === 0)
1518
+ return false;
1519
+ const summary = story.summary;
1520
+ const thinSummary = !summary?.completed && !summary?.current_thread && story.recent_outcomes.length < 2;
1521
+ const thinChronology = story.capture_state !== "rich" || story.tool_events.length === 0;
1522
+ return thinSummary || thinChronology;
1523
+ }
1524
+ function buildHandoffFacts(summary, story) {
1525
+ const facts = [
1526
+ `session_id=${story.session?.session_id ?? "unknown"}`,
1527
+ `capture_state=${story.capture_state}`,
1528
+ story.project_name ? `project=${story.project_name}` : null,
1529
+ summary?.current_thread ? `current_thread=${summary.current_thread}` : null,
1530
+ story.hot_files[0] ? `hot_file=${story.hot_files[0].path}` : null,
1531
+ story.provenance_summary[0] ? `primary_tool=${story.provenance_summary[0].tool}` : null
1532
+ ];
1533
+ return facts.filter((item) => Boolean(item));
1534
+ }
1535
+ function buildDraftHandoffConcepts(projectName, captureState) {
1536
+ return [
1537
+ "handoff",
1538
+ "draft-handoff",
1539
+ "auto-handoff",
1540
+ `capture:${captureState}`,
1541
+ ...projectName ? [projectName] : []
1542
+ ];
1543
+ }
1544
+ function looksLikeHandoff(obs) {
1545
+ if (obs.title.startsWith("Handoff:") || obs.title.startsWith("Handoff Draft:"))
1546
+ return true;
1547
+ const concepts = parseJsonArray2(obs.concepts);
1548
+ return concepts.includes("handoff") || concepts.includes("session-handoff") || concepts.includes("draft-handoff");
1549
+ }
1550
+ function parseJsonArray2(value) {
1551
+ if (!value)
1552
+ return [];
1553
+ try {
1554
+ const parsed = JSON.parse(value);
1555
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
1556
+ } catch {
1557
+ return [];
1558
+ }
1559
+ }
1560
+ function compactLine(value) {
1561
+ const trimmed = value?.replace(/\s+/g, " ").trim();
1562
+ if (!trimmed)
1563
+ return null;
1564
+ return trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed;
1565
+ }
1566
+
1567
+ // src/context/inject.ts
1568
+ var FRESH_CONTINUITY_WINDOW_DAYS = 3;
1569
+ function tokenizeProjectHint(text) {
1570
+ return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
1571
+ }
1572
+ function parseSummaryJsonList(value) {
1573
+ if (!value)
1574
+ return [];
1575
+ try {
1576
+ const parsed = JSON.parse(value);
1577
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
1578
+ } catch {
1579
+ return [];
1580
+ }
1581
+ }
1582
+ function isObservationRelatedToProject(obs, detected) {
1583
+ const hints = new Set([
1584
+ ...tokenizeProjectHint(detected.name),
1585
+ ...tokenizeProjectHint(detected.canonical_id)
1586
+ ]);
1587
+ if (hints.size === 0)
1588
+ return false;
1589
+ const haystack = [
1590
+ obs.title,
1591
+ obs.narrative ?? "",
1592
+ obs.facts ?? "",
1593
+ obs.concepts ?? "",
1594
+ obs.files_read ?? "",
1595
+ obs.files_modified ?? "",
1596
+ obs._source_project ?? ""
1597
+ ].join(`
1598
+ `).toLowerCase();
1599
+ for (const hint of hints) {
1600
+ if (haystack.includes(hint))
1601
+ return true;
1602
+ }
1603
+ return false;
1604
+ }
1605
+ function estimateTokens(text) {
1606
+ if (!text)
1607
+ return 0;
1608
+ return Math.ceil(text.length / 4);
1609
+ }
1610
+ function buildSessionContext(db, cwd, options = {}) {
1611
+ const opts = typeof options === "number" ? { maxCount: options } : options;
1612
+ const tokenBudget = opts.tokenBudget ?? 3000;
1613
+ const maxCount = opts.maxCount;
1614
+ const visibilityClause = opts.userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
1615
+ const visibilityParams = opts.userId ? [opts.userId] : [];
1616
+ const detected = detectProject(cwd);
1617
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
1618
+ const projectId = project?.id ?? -1;
1619
+ const isNewProject = !project;
1620
+ const totalActive = isNewProject ? (db.db.query(`SELECT COUNT(*) as c FROM observations
1621
+ WHERE lifecycle IN ('active', 'aging', 'pinned')
1622
+ ${visibilityClause}
1623
+ AND superseded_by IS NULL`).get(...visibilityParams) ?? { c: 0 }).c : (db.db.query(`SELECT COUNT(*) as c FROM observations
1624
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging', 'pinned')
1625
+ ${visibilityClause}
1626
+ AND superseded_by IS NULL`).get(projectId, ...visibilityParams) ?? { c: 0 }).c;
1627
+ const candidateLimit = maxCount ?? 50;
1628
+ let pinned = [];
1629
+ let recent = [];
1630
+ let candidates = [];
1631
+ if (!isNewProject) {
1632
+ const MAX_PINNED = 5;
1633
+ pinned = db.db.query(`SELECT * FROM observations
1634
+ WHERE project_id = ? AND lifecycle = 'pinned'
1635
+ AND superseded_by IS NULL
1636
+ ${visibilityClause}
1637
+ ORDER BY quality DESC, created_at_epoch DESC
1638
+ LIMIT ?`).all(projectId, ...visibilityParams, MAX_PINNED);
1639
+ const MAX_RECENT = 5;
1640
+ recent = db.db.query(`SELECT * FROM observations
1641
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
1642
+ AND superseded_by IS NULL
1643
+ ${visibilityClause}
1644
+ ORDER BY created_at_epoch DESC
1645
+ LIMIT ?`).all(projectId, ...visibilityParams, MAX_RECENT);
1646
+ candidates = db.db.query(`SELECT * FROM observations
1647
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
1648
+ AND quality >= 0.3
1649
+ AND superseded_by IS NULL
1650
+ ${visibilityClause}
1651
+ ORDER BY quality DESC, created_at_epoch DESC
1652
+ LIMIT ?`).all(projectId, ...visibilityParams, candidateLimit);
1653
+ }
1654
+ let crossProjectCandidates = [];
1655
+ if (opts.scope === "all" || isNewProject) {
1656
+ const crossLimit = isNewProject ? Math.max(30, candidateLimit) : Math.max(10, Math.floor(candidateLimit / 3));
1657
+ const qualityThreshold = isNewProject ? 0.3 : 0.5;
1658
+ const rawCross = isNewProject ? db.db.query(`SELECT * FROM observations
1659
+ WHERE lifecycle IN ('active', 'aging', 'pinned')
1660
+ AND quality >= ?
1661
+ AND superseded_by IS NULL
1662
+ ${visibilityClause}
1663
+ ORDER BY quality DESC, created_at_epoch DESC
1664
+ LIMIT ?`).all(qualityThreshold, ...visibilityParams, crossLimit) : db.db.query(`SELECT * FROM observations
1665
+ WHERE project_id != ? AND lifecycle IN ('active', 'aging')
1666
+ AND quality >= ?
1667
+ AND superseded_by IS NULL
1668
+ ${visibilityClause}
1669
+ ORDER BY quality DESC, created_at_epoch DESC
1670
+ LIMIT ?`).all(projectId, qualityThreshold, ...visibilityParams, crossLimit);
1671
+ const projectNameCache = new Map;
1672
+ crossProjectCandidates = rawCross.map((obs) => {
1673
+ if (!projectNameCache.has(obs.project_id)) {
1674
+ const proj = db.getProjectById(obs.project_id);
1675
+ if (proj)
1676
+ projectNameCache.set(obs.project_id, proj.name);
1677
+ }
1678
+ return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
1679
+ });
1680
+ if (isNewProject) {
1681
+ crossProjectCandidates = crossProjectCandidates.filter((obs) => isObservationRelatedToProject(obs, detected));
1682
+ }
1683
+ }
1684
+ const seenIds = new Set(pinned.map((o) => o.id));
1685
+ const dedupedRecent = recent.filter((o) => {
1686
+ if (seenIds.has(o.id))
1687
+ return false;
1688
+ seenIds.add(o.id);
1689
+ return true;
1690
+ });
1691
+ const deduped = candidates.filter((o) => !seenIds.has(o.id));
1692
+ for (const obs of crossProjectCandidates) {
1693
+ if (!seenIds.has(obs.id)) {
1694
+ seenIds.add(obs.id);
1695
+ deduped.push(obs);
1696
+ }
1697
+ }
1698
+ const nowEpoch = Math.floor(Date.now() / 1000);
1699
+ const sorted = [...deduped].sort((a, b) => {
1700
+ const scoreA = computeObservationPriority(a, nowEpoch);
1701
+ const scoreB = computeObservationPriority(b, nowEpoch);
1702
+ return scoreB - scoreA;
1703
+ });
1704
+ const projectName = project?.name ?? detected.name;
1705
+ const canonicalId = project?.canonical_id ?? detected.canonical_id;
1706
+ if (maxCount !== undefined) {
1707
+ const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
1708
+ let all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
1709
+ const recentPrompts2 = isNewProject ? [] : db.getRecentUserPrompts(projectId, 6, opts.userId);
1710
+ const recentToolEvents2 = isNewProject ? [] : db.getRecentToolEvents(projectId, 6, opts.userId);
1711
+ const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
1712
+ const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
1713
+ const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions2);
1714
+ const recentHandoffs2 = isNewProject ? [] : getRecentHandoffs(db, {
1715
+ cwd,
1716
+ project_scoped: true,
1717
+ user_id: opts.userId,
1718
+ current_device_id: opts.currentDeviceId,
1719
+ limit: 3
1720
+ }).handoffs;
1721
+ const recentChatMessages2 = !isNewProject && project ? db.getRecentChatMessages(project.id, 4, opts.userId) : [];
1722
+ all = filterAutoLoadedObservationsForContinuity(all, pinned, isNewProject, recentPrompts2, recentToolEvents2, recentSessions2, recentHandoffs2, recentChatMessages2, summariesFromRecentSessions(db, projectId, recentSessions2));
1723
+ return {
1724
+ project_name: projectName,
1725
+ canonical_id: canonicalId,
1726
+ observations: all.map(toContextObservation),
1727
+ session_count: all.length,
1728
+ total_active: totalActive,
1729
+ recentPrompts: recentPrompts2.length > 0 ? recentPrompts2 : undefined,
1730
+ recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
1731
+ recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
1732
+ projectTypeCounts: projectTypeCounts2,
1733
+ recentOutcomes: recentOutcomes2,
1734
+ recentHandoffs: recentHandoffs2.length > 0 ? recentHandoffs2 : undefined,
1735
+ recentChatMessages: recentChatMessages2.length > 0 ? recentChatMessages2 : undefined
1736
+ };
1737
+ }
1738
+ let remainingBudget = tokenBudget - 30;
1739
+ const selected = [];
1740
+ for (const obs of pinned) {
1741
+ const cost = estimateObservationTokens(obs, selected.length);
1742
+ remainingBudget -= cost;
1743
+ selected.push(obs);
1744
+ }
1745
+ for (const obs of dedupedRecent) {
1746
+ const cost = estimateObservationTokens(obs, selected.length);
1747
+ remainingBudget -= cost;
1748
+ selected.push(obs);
1749
+ }
1750
+ for (const obs of sorted) {
1751
+ const cost = estimateObservationTokens(obs, selected.length);
1752
+ if (remainingBudget - cost < 0 && selected.length > 0)
1753
+ break;
1754
+ remainingBudget -= cost;
1755
+ selected.push(obs);
1756
+ }
1757
+ const summaries = isNewProject ? [] : db.getRecentSummaries(projectId, 5);
1758
+ const recentPrompts = isNewProject ? [] : db.getRecentUserPrompts(projectId, 6, opts.userId);
1759
+ const recentToolEvents = isNewProject ? [] : db.getRecentToolEvents(projectId, 6, opts.userId);
1760
+ const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
1761
+ const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
1762
+ const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions);
1763
+ const recentHandoffs = isNewProject ? [] : getRecentHandoffs(db, {
1764
+ cwd,
1765
+ project_scoped: true,
1766
+ user_id: opts.userId,
1767
+ current_device_id: opts.currentDeviceId,
1768
+ limit: 3
1769
+ }).handoffs;
1770
+ const recentChatMessages = !isNewProject ? db.getRecentChatMessages(projectId, 4, opts.userId) : [];
1771
+ const filteredSelected = filterAutoLoadedObservationsForContinuity(selected, pinned, isNewProject, recentPrompts, recentToolEvents, recentSessions, recentHandoffs, recentChatMessages, summaries);
1772
+ let securityFindings = [];
1773
+ if (!isNewProject) {
1774
+ try {
1775
+ const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
1776
+ securityFindings = db.db.query(`SELECT * FROM security_findings
1777
+ WHERE project_id = ? AND created_at_epoch > ?
1778
+ ORDER BY severity DESC, created_at_epoch DESC
1779
+ LIMIT ?`).all(projectId, weekAgo, 10);
1780
+ } catch {}
1781
+ }
1782
+ let recentProjects;
1783
+ if (isNewProject) {
1784
+ try {
1785
+ const nowEpochSec = Math.floor(Date.now() / 1000);
1786
+ const projectRows = db.db.query(`SELECT p.name, p.canonical_id, p.last_active_epoch,
1787
+ (SELECT COUNT(*) FROM observations o
1788
+ WHERE o.project_id = p.id
1789
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
1790
+ ${opts.userId ? "AND (o.sensitivity != 'personal' OR o.user_id = ?)" : ""}
1791
+ AND o.superseded_by IS NULL) as obs_count
1792
+ FROM projects p
1793
+ ORDER BY p.last_active_epoch DESC
1794
+ LIMIT 10`).all(...visibilityParams);
1795
+ if (projectRows.length > 0) {
1796
+ recentProjects = projectRows.map((r) => {
1797
+ const daysAgo = Math.max(0, Math.floor((nowEpochSec - r.last_active_epoch) / 86400));
1798
+ const lastActive = new Date(r.last_active_epoch * 1000).toISOString().split("T")[0];
1799
+ return {
1800
+ name: r.name,
1801
+ canonical_id: r.canonical_id,
1802
+ observation_count: r.obs_count,
1803
+ last_active: lastActive,
1804
+ days_ago: daysAgo
1805
+ };
1806
+ });
1807
+ }
1808
+ } catch {}
1809
+ }
1810
+ let staleDecisions;
1811
+ try {
1812
+ const stale = isNewProject ? findStaleDecisionsGlobal(db) : findStaleDecisions(db, projectId);
1813
+ if (stale.length > 0)
1814
+ staleDecisions = stale;
1815
+ } catch {}
1816
+ return {
1817
+ project_name: projectName,
1818
+ canonical_id: canonicalId,
1819
+ observations: filteredSelected.map(toContextObservation),
1820
+ session_count: filteredSelected.length,
1821
+ total_active: totalActive,
1822
+ summaries: summaries.length > 0 ? summaries : undefined,
1823
+ securityFindings: securityFindings.length > 0 ? securityFindings : undefined,
1824
+ recentProjects,
1825
+ staleDecisions,
1826
+ recentPrompts: recentPrompts.length > 0 ? recentPrompts : undefined,
1827
+ recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
1828
+ recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
1829
+ projectTypeCounts,
1830
+ recentOutcomes,
1831
+ recentHandoffs: recentHandoffs.length > 0 ? recentHandoffs : undefined,
1832
+ recentChatMessages: recentChatMessages.length > 0 ? recentChatMessages : undefined
1833
+ };
1834
+ }
1835
+ function filterAutoLoadedObservationsForContinuity(observations, pinned, isNewProject, recentPrompts, recentToolEvents, recentSessions, recentHandoffs, recentChatMessages, summaries) {
1836
+ if (isNewProject)
1837
+ return observations;
1838
+ if (hasFreshProjectContinuity(recentPrompts, recentToolEvents, recentSessions, recentHandoffs, recentChatMessages, summaries)) {
1839
+ return observations;
1840
+ }
1841
+ const pinnedIds = new Set(pinned.map((obs) => obs.id));
1842
+ return observations.filter((obs) => {
1843
+ if (pinnedIds.has(obs.id))
1844
+ return true;
1845
+ return observationAgeDays(obs.created_at_epoch) <= FRESH_CONTINUITY_WINDOW_DAYS;
1846
+ });
1847
+ }
1848
+ function hasFreshProjectContinuity(recentPrompts, recentToolEvents, recentSessions, recentHandoffs, recentChatMessages, summaries) {
1849
+ const freshEnough = (epoch) => typeof epoch === "number" && observationAgeDays(epoch) <= FRESH_CONTINUITY_WINDOW_DAYS;
1850
+ return recentPrompts.some((item) => freshEnough(item.created_at_epoch)) || recentToolEvents.some((item) => freshEnough(item.created_at_epoch)) || recentSessions.some((item) => freshEnough(item.completed_at_epoch ?? item.started_at_epoch)) || recentHandoffs.some((item) => freshEnough(item.created_at_epoch)) || recentChatMessages.some((item) => freshEnough(item.created_at_epoch)) || summaries.some((item) => freshEnough(item.created_at_epoch));
1851
+ }
1852
+ function summariesFromRecentSessions(db, projectId, recentSessions) {
1853
+ const seen = new Set;
1854
+ const rows = [];
1855
+ for (const session of recentSessions) {
1856
+ if (seen.has(session.session_id))
1857
+ continue;
1858
+ seen.add(session.session_id);
1859
+ const summary = db.getSessionSummary(session.session_id);
1860
+ if (summary && summary.project_id === projectId)
1861
+ rows.push(summary);
1862
+ }
1863
+ return rows;
1864
+ }
1865
+ function observationAgeDays(createdAtEpoch) {
1866
+ return Math.max(0, (Math.floor(Date.now() / 1000) - createdAtEpoch) / 86400);
1867
+ }
1868
+ function estimateObservationTokens(obs, index) {
1869
+ const DETAILED_THRESHOLD = 5;
1870
+ const titleCost = estimateTokens(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
1871
+ if (index >= DETAILED_THRESHOLD) {
1872
+ return titleCost;
1873
+ }
1874
+ const detailText = formatObservationDetail(obs);
1875
+ return titleCost + estimateTokens(detailText);
1876
+ }
1877
+ function formatContextForInjection(context) {
1878
+ if (context.observations.length === 0 && (!context.recentPrompts || context.recentPrompts.length === 0) && (!context.recentToolEvents || context.recentToolEvents.length === 0) && (!context.recentSessions || context.recentSessions.length === 0) && (!context.projectTypeCounts || Object.keys(context.projectTypeCounts).length === 0)) {
1879
+ return `Project: ${context.project_name} (no prior observations)`;
1880
+ }
1881
+ const DETAILED_COUNT = 5;
1882
+ const isCrossProject = context.recentProjects && context.recentProjects.length > 0;
1883
+ const lines = [];
1884
+ if (isCrossProject) {
1885
+ lines.push(`## Engrm Memory — Workspace Overview`);
1886
+ lines.push(`This is a new project folder. Here is context from your recent work:`);
1887
+ lines.push("");
1888
+ lines.push("**Active projects in memory:**");
1889
+ for (const rp of context.recentProjects) {
1890
+ const activity = rp.days_ago === 0 ? "today" : rp.days_ago === 1 ? "yesterday" : `${rp.days_ago}d ago`;
1891
+ lines.push(`- **${rp.name}** — ${rp.observation_count} observations, last active ${activity}`);
1892
+ }
1893
+ lines.push("");
1894
+ lines.push(`${context.session_count} relevant observation(s) from across projects:`);
1895
+ lines.push("");
1896
+ } else {
1897
+ lines.push(`## Project Memory: ${context.project_name}`);
1898
+ lines.push(`${context.session_count} relevant observation(s) from prior sessions:`);
1899
+ lines.push("");
1900
+ }
1901
+ if (context.recentHandoffs && context.recentHandoffs.length > 0) {
1902
+ lines.push("## Recent Handoffs");
1903
+ for (const handoff of context.recentHandoffs.slice(0, 3)) {
1904
+ const title = handoff.title.replace(/^Handoff(?: Draft)?:\s*/i, "").replace(/\s+·\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
1905
+ if (title) {
1906
+ lines.push(`- ${truncateText(`${title} (${formatHandoffSource(handoff)})`, 160)}`);
1907
+ }
1908
+ const narrative = handoff.narrative?.split(/\n{2,}/).map((part) => part.replace(/\s+/g, " ").trim()).find((part) => /^(Current thread:|Completed:|Next Steps:)/i.test(part));
1909
+ if (narrative) {
1910
+ lines.push(` ${truncateText(narrative, 180)}`);
1911
+ }
1912
+ }
1913
+ lines.push("");
1914
+ }
1915
+ if (context.recentChatMessages && context.recentChatMessages.length > 0) {
1916
+ lines.push("## Recent Chat");
1917
+ for (const message of context.recentChatMessages.slice(0, 4).reverse()) {
1918
+ lines.push(`- [${message.role}] ${truncateText(message.content.replace(/\s+/g, " ").trim(), 160)}`);
1919
+ }
1920
+ lines.push("");
1921
+ }
1922
+ if (context.recentPrompts && context.recentPrompts.length > 0) {
1923
+ const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
1924
+ if (promptLines.length > 0) {
1925
+ lines.push("## Recent Requests");
1926
+ for (const prompt of promptLines) {
1927
+ const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
1928
+ lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
1929
+ }
1930
+ lines.push("");
1931
+ }
1932
+ }
1933
+ if (context.recentToolEvents && context.recentToolEvents.length > 0) {
1934
+ lines.push("## Recent Tools");
1935
+ for (const tool of context.recentToolEvents.slice(0, 5)) {
1936
+ lines.push(`- ${tool.tool_name}: ${formatToolEventDetail(tool)}`);
1937
+ }
1938
+ lines.push("");
1939
+ }
1940
+ if (context.recentSessions && context.recentSessions.length > 0) {
1941
+ const recentSessionLines = context.recentSessions.slice(0, 4).map((session) => {
1942
+ const summary = chooseMeaningfulSessionHeadline(session.request, session.completed);
1943
+ if (summary === "(no summary)")
1944
+ return null;
1945
+ return `- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
1946
+ }).filter((line) => Boolean(line));
1947
+ if (recentSessionLines.length > 0) {
1948
+ lines.push("## Recent Sessions");
1949
+ lines.push(...recentSessionLines);
1950
+ lines.push("");
1951
+ }
1952
+ }
1953
+ if (context.recentOutcomes && context.recentOutcomes.length > 0) {
1954
+ lines.push("## Recent Outcomes");
1955
+ for (const outcome of context.recentOutcomes.slice(0, 5)) {
1956
+ lines.push(`- ${truncateText(outcome, 160)}`);
1957
+ }
1958
+ lines.push("");
1959
+ }
1960
+ if (context.projectTypeCounts && Object.keys(context.projectTypeCounts).length > 0) {
1961
+ const topTypes = Object.entries(context.projectTypeCounts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([type, count]) => `${type} ${count}`).join(" · ");
1962
+ if (topTypes) {
1963
+ lines.push(`## Project Signals`);
1964
+ lines.push(`Top memory types: ${topTypes}`);
1965
+ lines.push("");
1966
+ }
1967
+ }
1968
+ for (let i = 0;i < context.observations.length; i++) {
1969
+ const obs = context.observations[i];
1970
+ const date = obs.created_at.split("T")[0];
1971
+ const fromLabel = obs.source_project ? ` [from: ${obs.source_project}]` : "";
1972
+ const fileLabel = formatObservationFiles(obs);
1973
+ lines.push(`- **#${obs.id} [${obs.type}]** ${obs.title} (${date}, q=${obs.quality.toFixed(1)})${fromLabel}${fileLabel}`);
1974
+ if (i < DETAILED_COUNT) {
1975
+ const detail = formatObservationDetailFromContext(obs);
1976
+ if (detail) {
1977
+ lines.push(detail);
1978
+ }
1979
+ }
1980
+ }
1981
+ if (context.summaries && context.summaries.length > 0) {
1982
+ lines.push("");
1983
+ lines.push("## Recent Project Briefs");
1984
+ for (const summary of context.summaries.slice(0, 3)) {
1985
+ lines.push(...formatSessionBrief(summary));
1986
+ lines.push("");
1987
+ }
1988
+ }
1989
+ if (context.securityFindings && context.securityFindings.length > 0) {
1990
+ lines.push("");
1991
+ lines.push("Security findings (recent):");
1992
+ for (const finding of context.securityFindings) {
1993
+ const date = new Date(finding.created_at_epoch * 1000).toISOString().split("T")[0];
1994
+ const file = finding.file_path ? ` in ${finding.file_path}` : finding.tool_name ? ` via ${finding.tool_name}` : "";
1995
+ lines.push(`- [${finding.severity.toUpperCase()}] ${finding.pattern_name}${file} (${date})`);
1996
+ }
1997
+ }
1998
+ if (context.staleDecisions && context.staleDecisions.length > 0) {
1999
+ lines.push("");
2000
+ lines.push("Stale commitments (decided but no implementation observed):");
2001
+ for (const sd of context.staleDecisions) {
2002
+ const date = sd.created_at.split("T")[0];
2003
+ lines.push(`- [DECISION] ${sd.title} (${date}, ${sd.days_ago}d ago)`);
2004
+ if (sd.best_match_title) {
2005
+ lines.push(` Closest match: "${sd.best_match_title}" (${Math.round((sd.best_match_similarity ?? 0) * 100)}% similar — not enough to count as done)`);
2006
+ }
2007
+ }
2008
+ }
2009
+ const remaining = context.total_active - context.session_count;
2010
+ if (remaining > 0) {
2011
+ lines.push("");
2012
+ lines.push(`${remaining} more observation(s) available via search tool.`);
2013
+ }
2014
+ return lines.join(`
2015
+ `);
2016
+ }
2017
+ function formatSessionBrief(summary) {
2018
+ const lines = [];
2019
+ const heading = summary.request ? `### ${truncateText(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
2020
+ lines.push(heading);
2021
+ const sections = [
2022
+ ["Investigated", summary.investigated, 180],
2023
+ ["Learned", summary.learned, 180],
2024
+ ["Completed", summary.completed, 180],
2025
+ ["Next Steps", summary.next_steps, 140]
2026
+ ];
2027
+ for (const [label, value, maxLen] of sections) {
2028
+ const formatted = formatSummarySection(value, maxLen);
2029
+ if (formatted) {
2030
+ lines.push(`${label}:`);
2031
+ lines.push(formatted);
2032
+ }
2033
+ }
2034
+ return lines;
2035
+ }
2036
+ function chooseMeaningfulSessionHeadline(request, completed) {
2037
+ if (request && !looksLikeFileOperationTitle2(request))
2038
+ return request;
2039
+ const completedItems = extractMeaningfulLines(completed, 1);
2040
+ if (completedItems.length > 0)
2041
+ return completedItems[0];
2042
+ return request ?? completed ?? "(no summary)";
2043
+ }
2044
+ function formatSummarySection(value, maxLen) {
2045
+ return formatSummaryItems(value, maxLen);
2046
+ }
2047
+ function truncateText(text, maxLen) {
2048
+ if (text.length <= maxLen)
2049
+ return text;
2050
+ return text.slice(0, maxLen - 3) + "...";
2051
+ }
2052
+ function isMeaningfulPrompt(value) {
2053
+ if (!value)
2054
+ return false;
2055
+ const compact = value.replace(/\s+/g, " ").trim();
2056
+ if (compact.length < 8)
2057
+ return false;
2058
+ return /[a-z]{3,}/i.test(compact);
2059
+ }
2060
+ function looksLikeFileOperationTitle2(value) {
2061
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
2062
+ }
2063
+ function stripInlineSectionLabel(value) {
2064
+ return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
2065
+ }
2066
+ function extractMeaningfulLines(value, limit) {
2067
+ if (!value)
2068
+ return [];
2069
+ return extractSummaryItems(value).map((line) => stripInlineSectionLabel(line)).filter((line) => line.length > 0 && !looksLikeFileOperationTitle2(line)).slice(0, limit);
2070
+ }
2071
+ function formatObservationDetailFromContext(obs) {
2072
+ if (obs.facts) {
2073
+ const bullets = parseFacts(obs.facts);
2074
+ if (bullets.length > 0) {
2075
+ return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
2076
+ `);
2077
+ }
2078
+ }
2079
+ if (obs.narrative) {
2080
+ const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
2081
+ return ` ${snippet}`;
2082
+ }
2083
+ return null;
2084
+ }
2085
+ function formatObservationDetail(obs) {
2086
+ if (obs.facts) {
2087
+ const bullets = parseFacts(obs.facts);
2088
+ if (bullets.length > 0) {
2089
+ return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
2090
+ `);
2091
+ }
2092
+ }
2093
+ if (obs.narrative) {
2094
+ const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
2095
+ return ` ${snippet}`;
2096
+ }
2097
+ return "";
2098
+ }
2099
+ function parseFacts(facts) {
2100
+ if (!facts)
2101
+ return [];
2102
+ try {
2103
+ const parsed = JSON.parse(facts);
2104
+ if (Array.isArray(parsed)) {
2105
+ return parsed.filter((f) => typeof f === "string" && f.length > 0);
2106
+ }
2107
+ } catch {
2108
+ if (facts.trim().length > 0) {
2109
+ return [facts.trim()];
2110
+ }
2111
+ }
2112
+ return [];
2113
+ }
2114
+ function toContextObservation(obs) {
2115
+ return {
2116
+ id: obs.id,
2117
+ type: obs.type,
2118
+ title: obs.title,
2119
+ narrative: obs.narrative,
2120
+ facts: obs.facts,
2121
+ files_read: obs.files_read,
2122
+ files_modified: obs.files_modified,
2123
+ quality: obs.quality,
2124
+ created_at: obs.created_at,
2125
+ ...obs._source_project ? { source_project: obs._source_project } : {}
2126
+ };
2127
+ }
2128
+ function formatObservationFiles(obs) {
2129
+ const modified = parseJsonStringArray(obs.files_modified);
2130
+ if (modified.length > 0) {
2131
+ return ` · files: ${truncateText(modified.slice(0, 2).join(", "), 60)}`;
2132
+ }
2133
+ const read = parseJsonStringArray(obs.files_read);
2134
+ if (read.length > 0) {
2135
+ return ` · read: ${truncateText(read.slice(0, 2).join(", "), 60)}`;
2136
+ }
2137
+ return "";
2138
+ }
2139
+ function parseJsonStringArray(value) {
2140
+ if (!value)
2141
+ return [];
2142
+ try {
2143
+ const parsed = JSON.parse(value);
2144
+ if (!Array.isArray(parsed))
2145
+ return [];
2146
+ return parsed.filter((item) => typeof item === "string" && item.trim().length > 0);
2147
+ } catch {
2148
+ return [];
2149
+ }
1146
2150
  }
1147
- function toRelativePath(filePath, projectRoot) {
1148
- if (!isAbsolute(filePath))
1149
- return filePath;
1150
- const rel = relative(projectRoot, filePath);
1151
- if (rel.startsWith(".."))
1152
- return filePath;
1153
- return rel;
2151
+ function formatToolEventDetail(tool) {
2152
+ const detail = tool.file_path ?? tool.command ?? tool.tool_response_preview ?? "";
2153
+ return truncateText(detail || "recent tool execution", 160);
1154
2154
  }
1155
-
1156
- // src/tools/handoffs.ts
1157
- function getRecentHandoffs(db, input) {
1158
- const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
1159
- const projectScoped = input.project_scoped !== false;
1160
- let projectId = null;
1161
- let projectName;
1162
- if (projectScoped) {
1163
- const cwd = input.cwd ?? process.cwd();
1164
- const detected = detectProject(cwd);
1165
- const project = db.getProjectByCanonicalId(detected.canonical_id);
1166
- if (project) {
1167
- projectId = project.id;
1168
- projectName = project.name;
2155
+ function getProjectTypeCounts(db, projectId, userId) {
2156
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
2157
+ const rows = db.db.query(`SELECT type, COUNT(*) as count
2158
+ FROM observations
2159
+ WHERE project_id = ?
2160
+ AND lifecycle IN ('active', 'aging', 'pinned')
2161
+ AND superseded_by IS NULL
2162
+ ${visibilityClause}
2163
+ GROUP BY type`).all(projectId, ...userId ? [userId] : []);
2164
+ const counts = {};
2165
+ for (const row of rows) {
2166
+ counts[row.type] = row.count;
2167
+ }
2168
+ return counts;
2169
+ }
2170
+ function getRecentOutcomes(db, projectId, userId, recentSessions) {
2171
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
2172
+ const visibilityParams = userId ? [userId] : [];
2173
+ const summaries = db.db.query(`SELECT * FROM session_summaries
2174
+ WHERE project_id = ?
2175
+ ORDER BY created_at_epoch DESC
2176
+ LIMIT 6`).all(projectId);
2177
+ const picked = [];
2178
+ const seen = new Set;
2179
+ for (const summary of summaries) {
2180
+ for (const item of parseSummaryJsonList(summary.recent_outcomes)) {
2181
+ const normalized = item.toLowerCase().replace(/\s+/g, " ").trim();
2182
+ if (!normalized || seen.has(normalized))
2183
+ continue;
2184
+ seen.add(normalized);
2185
+ picked.push(item);
2186
+ if (picked.length >= 5)
2187
+ return picked;
2188
+ }
2189
+ for (const line of [
2190
+ ...extractMeaningfulLines(summary.completed, 2),
2191
+ ...extractMeaningfulLines(summary.learned, 1)
2192
+ ]) {
2193
+ const normalized = line.toLowerCase().replace(/\s+/g, " ").trim();
2194
+ if (!normalized || seen.has(normalized))
2195
+ continue;
2196
+ seen.add(normalized);
2197
+ picked.push(line);
2198
+ if (picked.length >= 5)
2199
+ return picked;
1169
2200
  }
1170
2201
  }
1171
- const conditions = [
1172
- "o.type = 'message'",
1173
- "o.lifecycle IN ('active', 'aging', 'pinned')",
1174
- "o.superseded_by IS NULL",
1175
- `(o.title LIKE 'Handoff:%' OR o.concepts LIKE '%"handoff"%')`
1176
- ];
1177
- const params = [];
1178
- if (input.user_id) {
1179
- conditions.push("(o.sensitivity != 'personal' OR o.user_id = ?)");
1180
- params.push(input.user_id);
2202
+ for (const session of recentSessions ?? []) {
2203
+ for (const item of parseSummaryJsonList(session.recent_outcomes)) {
2204
+ const normalized = item.toLowerCase().replace(/\s+/g, " ").trim();
2205
+ if (!normalized || seen.has(normalized))
2206
+ continue;
2207
+ seen.add(normalized);
2208
+ picked.push(item);
2209
+ if (picked.length >= 5)
2210
+ return picked;
2211
+ }
1181
2212
  }
1182
- if (projectId !== null) {
1183
- conditions.push("o.project_id = ?");
1184
- params.push(projectId);
2213
+ const rows = db.db.query(`SELECT * FROM observations
2214
+ WHERE project_id = ?
2215
+ AND lifecycle IN ('active', 'aging', 'pinned')
2216
+ AND superseded_by IS NULL
2217
+ ${visibilityClause}
2218
+ ORDER BY created_at_epoch DESC
2219
+ LIMIT 20`).all(projectId, ...visibilityParams);
2220
+ for (const obs of rows) {
2221
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
2222
+ continue;
2223
+ const title = stripInlineSectionLabel(obs.title);
2224
+ const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
2225
+ if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle2(title))
2226
+ continue;
2227
+ seen.add(normalized);
2228
+ picked.push(title);
2229
+ if (picked.length >= 5)
2230
+ break;
1185
2231
  }
1186
- params.push(limit);
1187
- const handoffs = db.db.query(`SELECT o.*, p.name AS project_name
1188
- FROM observations o
1189
- LEFT JOIN projects p ON p.id = o.project_id
1190
- WHERE ${conditions.join(" AND ")}
1191
- ORDER BY o.created_at_epoch DESC, o.id DESC
1192
- LIMIT ?`).all(...params);
1193
- return {
1194
- handoffs,
1195
- project: projectName
1196
- };
2232
+ return picked;
2233
+ }
2234
+
2235
+ // src/tools/handoffs.ts
2236
+ function formatHandoffSource2(handoff) {
2237
+ const ageSeconds = Math.max(0, Math.floor(Date.now() / 1000) - handoff.created_at_epoch);
2238
+ const ageLabel = ageSeconds < 3600 ? `${Math.max(1, Math.floor(ageSeconds / 60) || 1)}m ago` : ageSeconds < 86400 ? `${Math.floor(ageSeconds / 3600)}h ago` : `${Math.floor(ageSeconds / 86400)}d ago`;
2239
+ return `from ${handoff.device_id} · ${ageLabel}`;
1197
2240
  }
1198
2241
 
1199
2242
  // src/context/inject.ts
1200
- function tokenizeProjectHint(text) {
2243
+ var FRESH_CONTINUITY_WINDOW_DAYS2 = 3;
2244
+ function tokenizeProjectHint2(text) {
1201
2245
  return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
1202
2246
  }
1203
- function parseSummaryJsonList(value) {
2247
+ function parseSummaryJsonList2(value) {
1204
2248
  if (!value)
1205
2249
  return [];
1206
2250
  try {
@@ -1210,10 +2254,10 @@ function parseSummaryJsonList(value) {
1210
2254
  return [];
1211
2255
  }
1212
2256
  }
1213
- function isObservationRelatedToProject(obs, detected) {
2257
+ function isObservationRelatedToProject2(obs, detected) {
1214
2258
  const hints = new Set([
1215
- ...tokenizeProjectHint(detected.name),
1216
- ...tokenizeProjectHint(detected.canonical_id)
2259
+ ...tokenizeProjectHint2(detected.name),
2260
+ ...tokenizeProjectHint2(detected.canonical_id)
1217
2261
  ]);
1218
2262
  if (hints.size === 0)
1219
2263
  return false;
@@ -1233,12 +2277,12 @@ function isObservationRelatedToProject(obs, detected) {
1233
2277
  }
1234
2278
  return false;
1235
2279
  }
1236
- function estimateTokens(text) {
2280
+ function estimateTokens2(text) {
1237
2281
  if (!text)
1238
2282
  return 0;
1239
2283
  return Math.ceil(text.length / 4);
1240
2284
  }
1241
- function buildSessionContext(db, cwd, options = {}) {
2285
+ function buildSessionContext2(db, cwd, options = {}) {
1242
2286
  const opts = typeof options === "number" ? { maxCount: options } : options;
1243
2287
  const tokenBudget = opts.tokenBudget ?? 3000;
1244
2288
  const maxCount = opts.maxCount;
@@ -1309,7 +2353,7 @@ function buildSessionContext(db, cwd, options = {}) {
1309
2353
  return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
1310
2354
  });
1311
2355
  if (isNewProject) {
1312
- crossProjectCandidates = crossProjectCandidates.filter((obs) => isObservationRelatedToProject(obs, detected));
2356
+ crossProjectCandidates = crossProjectCandidates.filter((obs) => isObservationRelatedToProject2(obs, detected));
1313
2357
  }
1314
2358
  }
1315
2359
  const seenIds = new Set(pinned.map((o) => o.id));
@@ -1336,22 +2380,25 @@ function buildSessionContext(db, cwd, options = {}) {
1336
2380
  const canonicalId = project?.canonical_id ?? detected.canonical_id;
1337
2381
  if (maxCount !== undefined) {
1338
2382
  const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
1339
- const all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
1340
- const recentPrompts2 = db.getRecentUserPrompts(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
1341
- const recentToolEvents2 = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
2383
+ let all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
2384
+ const recentPrompts2 = isNewProject ? [] : db.getRecentUserPrompts(projectId, 6, opts.userId);
2385
+ const recentToolEvents2 = isNewProject ? [] : db.getRecentToolEvents(projectId, 6, opts.userId);
1342
2386
  const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
1343
- const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
1344
- const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions2);
1345
- const recentHandoffs2 = getRecentHandoffs(db, {
2387
+ const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts2(db, projectId, opts.userId);
2388
+ const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes2(db, projectId, opts.userId, recentSessions2);
2389
+ const recentHandoffs2 = isNewProject ? [] : getRecentHandoffs(db, {
1346
2390
  cwd,
1347
- project_scoped: !isNewProject,
2391
+ project_scoped: true,
1348
2392
  user_id: opts.userId,
2393
+ current_device_id: opts.currentDeviceId,
1349
2394
  limit: 3
1350
2395
  }).handoffs;
2396
+ const recentChatMessages2 = !isNewProject && project ? db.getRecentChatMessages(project.id, 4, opts.userId) : [];
2397
+ all = filterAutoLoadedObservationsForContinuity2(all, pinned, isNewProject, recentPrompts2, recentToolEvents2, recentSessions2, recentHandoffs2, recentChatMessages2, summariesFromRecentSessions2(db, projectId, recentSessions2));
1351
2398
  return {
1352
2399
  project_name: projectName,
1353
2400
  canonical_id: canonicalId,
1354
- observations: all.map(toContextObservation),
2401
+ observations: all.map(toContextObservation2),
1355
2402
  session_count: all.length,
1356
2403
  total_active: totalActive,
1357
2404
  recentPrompts: recentPrompts2.length > 0 ? recentPrompts2 : undefined,
@@ -1359,40 +2406,44 @@ function buildSessionContext(db, cwd, options = {}) {
1359
2406
  recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
1360
2407
  projectTypeCounts: projectTypeCounts2,
1361
2408
  recentOutcomes: recentOutcomes2,
1362
- recentHandoffs: recentHandoffs2.length > 0 ? recentHandoffs2 : undefined
2409
+ recentHandoffs: recentHandoffs2.length > 0 ? recentHandoffs2 : undefined,
2410
+ recentChatMessages: recentChatMessages2.length > 0 ? recentChatMessages2 : undefined
1363
2411
  };
1364
2412
  }
1365
2413
  let remainingBudget = tokenBudget - 30;
1366
2414
  const selected = [];
1367
2415
  for (const obs of pinned) {
1368
- const cost = estimateObservationTokens(obs, selected.length);
2416
+ const cost = estimateObservationTokens2(obs, selected.length);
1369
2417
  remainingBudget -= cost;
1370
2418
  selected.push(obs);
1371
2419
  }
1372
2420
  for (const obs of dedupedRecent) {
1373
- const cost = estimateObservationTokens(obs, selected.length);
2421
+ const cost = estimateObservationTokens2(obs, selected.length);
1374
2422
  remainingBudget -= cost;
1375
2423
  selected.push(obs);
1376
2424
  }
1377
2425
  for (const obs of sorted) {
1378
- const cost = estimateObservationTokens(obs, selected.length);
2426
+ const cost = estimateObservationTokens2(obs, selected.length);
1379
2427
  if (remainingBudget - cost < 0 && selected.length > 0)
1380
2428
  break;
1381
2429
  remainingBudget -= cost;
1382
2430
  selected.push(obs);
1383
2431
  }
1384
2432
  const summaries = isNewProject ? [] : db.getRecentSummaries(projectId, 5);
1385
- const recentPrompts = db.getRecentUserPrompts(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
1386
- const recentToolEvents = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
2433
+ const recentPrompts = isNewProject ? [] : db.getRecentUserPrompts(projectId, 6, opts.userId);
2434
+ const recentToolEvents = isNewProject ? [] : db.getRecentToolEvents(projectId, 6, opts.userId);
1387
2435
  const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
1388
- const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
1389
- const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId, recentSessions);
1390
- const recentHandoffs = getRecentHandoffs(db, {
2436
+ const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts2(db, projectId, opts.userId);
2437
+ const recentOutcomes = isNewProject ? undefined : getRecentOutcomes2(db, projectId, opts.userId, recentSessions);
2438
+ const recentHandoffs = isNewProject ? [] : getRecentHandoffs(db, {
1391
2439
  cwd,
1392
- project_scoped: !isNewProject,
2440
+ project_scoped: true,
1393
2441
  user_id: opts.userId,
2442
+ current_device_id: opts.currentDeviceId,
1394
2443
  limit: 3
1395
2444
  }).handoffs;
2445
+ const recentChatMessages = !isNewProject ? db.getRecentChatMessages(projectId, 4, opts.userId) : [];
2446
+ const filteredSelected = filterAutoLoadedObservationsForContinuity2(selected, pinned, isNewProject, recentPrompts, recentToolEvents, recentSessions, recentHandoffs, recentChatMessages, summaries);
1396
2447
  let securityFindings = [];
1397
2448
  if (!isNewProject) {
1398
2449
  try {
@@ -1440,8 +2491,8 @@ function buildSessionContext(db, cwd, options = {}) {
1440
2491
  return {
1441
2492
  project_name: projectName,
1442
2493
  canonical_id: canonicalId,
1443
- observations: selected.map(toContextObservation),
1444
- session_count: selected.length,
2494
+ observations: filteredSelected.map(toContextObservation2),
2495
+ session_count: filteredSelected.length,
1445
2496
  total_active: totalActive,
1446
2497
  summaries: summaries.length > 0 ? summaries : undefined,
1447
2498
  securityFindings: securityFindings.length > 0 ? securityFindings : undefined,
@@ -1452,19 +2503,53 @@ function buildSessionContext(db, cwd, options = {}) {
1452
2503
  recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
1453
2504
  projectTypeCounts,
1454
2505
  recentOutcomes,
1455
- recentHandoffs: recentHandoffs.length > 0 ? recentHandoffs : undefined
2506
+ recentHandoffs: recentHandoffs.length > 0 ? recentHandoffs : undefined,
2507
+ recentChatMessages: recentChatMessages.length > 0 ? recentChatMessages : undefined
1456
2508
  };
1457
2509
  }
1458
- function estimateObservationTokens(obs, index) {
2510
+ function filterAutoLoadedObservationsForContinuity2(observations, pinned, isNewProject, recentPrompts, recentToolEvents, recentSessions, recentHandoffs, recentChatMessages, summaries) {
2511
+ if (isNewProject)
2512
+ return observations;
2513
+ if (hasFreshProjectContinuity2(recentPrompts, recentToolEvents, recentSessions, recentHandoffs, recentChatMessages, summaries)) {
2514
+ return observations;
2515
+ }
2516
+ const pinnedIds = new Set(pinned.map((obs) => obs.id));
2517
+ return observations.filter((obs) => {
2518
+ if (pinnedIds.has(obs.id))
2519
+ return true;
2520
+ return observationAgeDays2(obs.created_at_epoch) <= FRESH_CONTINUITY_WINDOW_DAYS2;
2521
+ });
2522
+ }
2523
+ function hasFreshProjectContinuity2(recentPrompts, recentToolEvents, recentSessions, recentHandoffs, recentChatMessages, summaries) {
2524
+ const freshEnough = (epoch) => typeof epoch === "number" && observationAgeDays2(epoch) <= FRESH_CONTINUITY_WINDOW_DAYS2;
2525
+ return recentPrompts.some((item) => freshEnough(item.created_at_epoch)) || recentToolEvents.some((item) => freshEnough(item.created_at_epoch)) || recentSessions.some((item) => freshEnough(item.completed_at_epoch ?? item.started_at_epoch)) || recentHandoffs.some((item) => freshEnough(item.created_at_epoch)) || recentChatMessages.some((item) => freshEnough(item.created_at_epoch)) || summaries.some((item) => freshEnough(item.created_at_epoch));
2526
+ }
2527
+ function summariesFromRecentSessions2(db, projectId, recentSessions) {
2528
+ const seen = new Set;
2529
+ const rows = [];
2530
+ for (const session of recentSessions) {
2531
+ if (seen.has(session.session_id))
2532
+ continue;
2533
+ seen.add(session.session_id);
2534
+ const summary = db.getSessionSummary(session.session_id);
2535
+ if (summary && summary.project_id === projectId)
2536
+ rows.push(summary);
2537
+ }
2538
+ return rows;
2539
+ }
2540
+ function observationAgeDays2(createdAtEpoch) {
2541
+ return Math.max(0, (Math.floor(Date.now() / 1000) - createdAtEpoch) / 86400);
2542
+ }
2543
+ function estimateObservationTokens2(obs, index) {
1459
2544
  const DETAILED_THRESHOLD = 5;
1460
- const titleCost = estimateTokens(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
2545
+ const titleCost = estimateTokens2(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
1461
2546
  if (index >= DETAILED_THRESHOLD) {
1462
2547
  return titleCost;
1463
2548
  }
1464
- const detailText = formatObservationDetail(obs);
1465
- return titleCost + estimateTokens(detailText);
2549
+ const detailText = formatObservationDetail2(obs);
2550
+ return titleCost + estimateTokens2(detailText);
1466
2551
  }
1467
- function formatContextForInjection(context) {
2552
+ function formatContextForInjection2(context) {
1468
2553
  if (context.observations.length === 0 && (!context.recentPrompts || context.recentPrompts.length === 0) && (!context.recentToolEvents || context.recentToolEvents.length === 0) && (!context.recentSessions || context.recentSessions.length === 0) && (!context.projectTypeCounts || Object.keys(context.projectTypeCounts).length === 0)) {
1469
2554
  return `Project: ${context.project_name} (no prior observations)`;
1470
2555
  }
@@ -1488,13 +2573,34 @@ function formatContextForInjection(context) {
1488
2573
  lines.push(`${context.session_count} relevant observation(s) from prior sessions:`);
1489
2574
  lines.push("");
1490
2575
  }
2576
+ if (context.recentHandoffs && context.recentHandoffs.length > 0) {
2577
+ lines.push("## Recent Handoffs");
2578
+ for (const handoff of context.recentHandoffs.slice(0, 3)) {
2579
+ const title = handoff.title.replace(/^Handoff(?: Draft)?:\s*/i, "").replace(/\s+·\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
2580
+ if (title) {
2581
+ lines.push(`- ${truncateText2(`${title} (${formatHandoffSource(handoff)})`, 160)}`);
2582
+ }
2583
+ const narrative = handoff.narrative?.split(/\n{2,}/).map((part) => part.replace(/\s+/g, " ").trim()).find((part) => /^(Current thread:|Completed:|Next Steps:)/i.test(part));
2584
+ if (narrative) {
2585
+ lines.push(` ${truncateText2(narrative, 180)}`);
2586
+ }
2587
+ }
2588
+ lines.push("");
2589
+ }
2590
+ if (context.recentChatMessages && context.recentChatMessages.length > 0) {
2591
+ lines.push("## Recent Chat");
2592
+ for (const message of context.recentChatMessages.slice(0, 4).reverse()) {
2593
+ lines.push(`- [${message.role}] ${truncateText2(message.content.replace(/\s+/g, " ").trim(), 160)}`);
2594
+ }
2595
+ lines.push("");
2596
+ }
1491
2597
  if (context.recentPrompts && context.recentPrompts.length > 0) {
1492
- const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
2598
+ const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt2(prompt.prompt)).slice(0, 5);
1493
2599
  if (promptLines.length > 0) {
1494
2600
  lines.push("## Recent Requests");
1495
2601
  for (const prompt of promptLines) {
1496
2602
  const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
1497
- lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
2603
+ lines.push(`- ${label}: ${truncateText2(prompt.prompt.replace(/\s+/g, " "), 160)}`);
1498
2604
  }
1499
2605
  lines.push("");
1500
2606
  }
@@ -1502,16 +2608,16 @@ function formatContextForInjection(context) {
1502
2608
  if (context.recentToolEvents && context.recentToolEvents.length > 0) {
1503
2609
  lines.push("## Recent Tools");
1504
2610
  for (const tool of context.recentToolEvents.slice(0, 5)) {
1505
- lines.push(`- ${tool.tool_name}: ${formatToolEventDetail(tool)}`);
2611
+ lines.push(`- ${tool.tool_name}: ${formatToolEventDetail2(tool)}`);
1506
2612
  }
1507
2613
  lines.push("");
1508
2614
  }
1509
2615
  if (context.recentSessions && context.recentSessions.length > 0) {
1510
2616
  const recentSessionLines = context.recentSessions.slice(0, 4).map((session) => {
1511
- const summary = chooseMeaningfulSessionHeadline(session.request, session.completed);
2617
+ const summary = chooseMeaningfulSessionHeadline2(session.request, session.completed);
1512
2618
  if (summary === "(no summary)")
1513
2619
  return null;
1514
- return `- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
2620
+ return `- ${session.session_id}: ${truncateText2(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
1515
2621
  }).filter((line) => Boolean(line));
1516
2622
  if (recentSessionLines.length > 0) {
1517
2623
  lines.push("## Recent Sessions");
@@ -1522,7 +2628,7 @@ function formatContextForInjection(context) {
1522
2628
  if (context.recentOutcomes && context.recentOutcomes.length > 0) {
1523
2629
  lines.push("## Recent Outcomes");
1524
2630
  for (const outcome of context.recentOutcomes.slice(0, 5)) {
1525
- lines.push(`- ${truncateText(outcome, 160)}`);
2631
+ lines.push(`- ${truncateText2(outcome, 160)}`);
1526
2632
  }
1527
2633
  lines.push("");
1528
2634
  }
@@ -1538,10 +2644,10 @@ function formatContextForInjection(context) {
1538
2644
  const obs = context.observations[i];
1539
2645
  const date = obs.created_at.split("T")[0];
1540
2646
  const fromLabel = obs.source_project ? ` [from: ${obs.source_project}]` : "";
1541
- const fileLabel = formatObservationFiles(obs);
2647
+ const fileLabel = formatObservationFiles2(obs);
1542
2648
  lines.push(`- **#${obs.id} [${obs.type}]** ${obs.title} (${date}, q=${obs.quality.toFixed(1)})${fromLabel}${fileLabel}`);
1543
2649
  if (i < DETAILED_COUNT) {
1544
- const detail = formatObservationDetailFromContext(obs);
2650
+ const detail = formatObservationDetailFromContext2(obs);
1545
2651
  if (detail) {
1546
2652
  lines.push(detail);
1547
2653
  }
@@ -1551,7 +2657,7 @@ function formatContextForInjection(context) {
1551
2657
  lines.push("");
1552
2658
  lines.push("## Recent Project Briefs");
1553
2659
  for (const summary of context.summaries.slice(0, 3)) {
1554
- lines.push(...formatSessionBrief(summary));
2660
+ lines.push(...formatSessionBrief2(summary));
1555
2661
  lines.push("");
1556
2662
  }
1557
2663
  }
@@ -1583,9 +2689,9 @@ function formatContextForInjection(context) {
1583
2689
  return lines.join(`
1584
2690
  `);
1585
2691
  }
1586
- function formatSessionBrief(summary) {
2692
+ function formatSessionBrief2(summary) {
1587
2693
  const lines = [];
1588
- const heading = summary.request ? `### ${truncateText(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
2694
+ const heading = summary.request ? `### ${truncateText2(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
1589
2695
  lines.push(heading);
1590
2696
  const sections = [
1591
2697
  ["Investigated", summary.investigated, 180],
@@ -1594,7 +2700,7 @@ function formatSessionBrief(summary) {
1594
2700
  ["Next Steps", summary.next_steps, 140]
1595
2701
  ];
1596
2702
  for (const [label, value, maxLen] of sections) {
1597
- const formatted = formatSummarySection(value, maxLen);
2703
+ const formatted = formatSummarySection2(value, maxLen);
1598
2704
  if (formatted) {
1599
2705
  lines.push(`${label}:`);
1600
2706
  lines.push(formatted);
@@ -1602,23 +2708,23 @@ function formatSessionBrief(summary) {
1602
2708
  }
1603
2709
  return lines;
1604
2710
  }
1605
- function chooseMeaningfulSessionHeadline(request, completed) {
1606
- if (request && !looksLikeFileOperationTitle(request))
2711
+ function chooseMeaningfulSessionHeadline2(request, completed) {
2712
+ if (request && !looksLikeFileOperationTitle3(request))
1607
2713
  return request;
1608
- const completedItems = extractMeaningfulLines(completed, 1);
2714
+ const completedItems = extractMeaningfulLines2(completed, 1);
1609
2715
  if (completedItems.length > 0)
1610
2716
  return completedItems[0];
1611
2717
  return request ?? completed ?? "(no summary)";
1612
2718
  }
1613
- function formatSummarySection(value, maxLen) {
2719
+ function formatSummarySection2(value, maxLen) {
1614
2720
  return formatSummaryItems(value, maxLen);
1615
2721
  }
1616
- function truncateText(text, maxLen) {
2722
+ function truncateText2(text, maxLen) {
1617
2723
  if (text.length <= maxLen)
1618
2724
  return text;
1619
2725
  return text.slice(0, maxLen - 3) + "...";
1620
2726
  }
1621
- function isMeaningfulPrompt(value) {
2727
+ function isMeaningfulPrompt2(value) {
1622
2728
  if (!value)
1623
2729
  return false;
1624
2730
  const compact = value.replace(/\s+/g, " ").trim();
@@ -1626,20 +2732,20 @@ function isMeaningfulPrompt(value) {
1626
2732
  return false;
1627
2733
  return /[a-z]{3,}/i.test(compact);
1628
2734
  }
1629
- function looksLikeFileOperationTitle(value) {
2735
+ function looksLikeFileOperationTitle3(value) {
1630
2736
  return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
1631
2737
  }
1632
- function stripInlineSectionLabel(value) {
2738
+ function stripInlineSectionLabel2(value) {
1633
2739
  return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
1634
2740
  }
1635
- function extractMeaningfulLines(value, limit) {
2741
+ function extractMeaningfulLines2(value, limit) {
1636
2742
  if (!value)
1637
2743
  return [];
1638
- return extractSummaryItems(value).map((line) => stripInlineSectionLabel(line)).filter((line) => line.length > 0 && !looksLikeFileOperationTitle(line)).slice(0, limit);
2744
+ return extractSummaryItems(value).map((line) => stripInlineSectionLabel2(line)).filter((line) => line.length > 0 && !looksLikeFileOperationTitle3(line)).slice(0, limit);
1639
2745
  }
1640
- function formatObservationDetailFromContext(obs) {
2746
+ function formatObservationDetailFromContext2(obs) {
1641
2747
  if (obs.facts) {
1642
- const bullets = parseFacts(obs.facts);
2748
+ const bullets = parseFacts2(obs.facts);
1643
2749
  if (bullets.length > 0) {
1644
2750
  return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
1645
2751
  `);
@@ -1651,9 +2757,9 @@ function formatObservationDetailFromContext(obs) {
1651
2757
  }
1652
2758
  return null;
1653
2759
  }
1654
- function formatObservationDetail(obs) {
2760
+ function formatObservationDetail2(obs) {
1655
2761
  if (obs.facts) {
1656
- const bullets = parseFacts(obs.facts);
2762
+ const bullets = parseFacts2(obs.facts);
1657
2763
  if (bullets.length > 0) {
1658
2764
  return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
1659
2765
  `);
@@ -1665,7 +2771,7 @@ function formatObservationDetail(obs) {
1665
2771
  }
1666
2772
  return "";
1667
2773
  }
1668
- function parseFacts(facts) {
2774
+ function parseFacts2(facts) {
1669
2775
  if (!facts)
1670
2776
  return [];
1671
2777
  try {
@@ -1680,7 +2786,7 @@ function parseFacts(facts) {
1680
2786
  }
1681
2787
  return [];
1682
2788
  }
1683
- function toContextObservation(obs) {
2789
+ function toContextObservation2(obs) {
1684
2790
  return {
1685
2791
  id: obs.id,
1686
2792
  type: obs.type,
@@ -1694,18 +2800,18 @@ function toContextObservation(obs) {
1694
2800
  ...obs._source_project ? { source_project: obs._source_project } : {}
1695
2801
  };
1696
2802
  }
1697
- function formatObservationFiles(obs) {
1698
- const modified = parseJsonStringArray(obs.files_modified);
2803
+ function formatObservationFiles2(obs) {
2804
+ const modified = parseJsonStringArray2(obs.files_modified);
1699
2805
  if (modified.length > 0) {
1700
- return ` · files: ${truncateText(modified.slice(0, 2).join(", "), 60)}`;
2806
+ return ` · files: ${truncateText2(modified.slice(0, 2).join(", "), 60)}`;
1701
2807
  }
1702
- const read = parseJsonStringArray(obs.files_read);
2808
+ const read = parseJsonStringArray2(obs.files_read);
1703
2809
  if (read.length > 0) {
1704
- return ` · read: ${truncateText(read.slice(0, 2).join(", "), 60)}`;
2810
+ return ` · read: ${truncateText2(read.slice(0, 2).join(", "), 60)}`;
1705
2811
  }
1706
2812
  return "";
1707
2813
  }
1708
- function parseJsonStringArray(value) {
2814
+ function parseJsonStringArray2(value) {
1709
2815
  if (!value)
1710
2816
  return [];
1711
2817
  try {
@@ -1717,11 +2823,11 @@ function parseJsonStringArray(value) {
1717
2823
  return [];
1718
2824
  }
1719
2825
  }
1720
- function formatToolEventDetail(tool) {
2826
+ function formatToolEventDetail2(tool) {
1721
2827
  const detail = tool.file_path ?? tool.command ?? tool.tool_response_preview ?? "";
1722
- return truncateText(detail || "recent tool execution", 160);
2828
+ return truncateText2(detail || "recent tool execution", 160);
1723
2829
  }
1724
- function getProjectTypeCounts(db, projectId, userId) {
2830
+ function getProjectTypeCounts2(db, projectId, userId) {
1725
2831
  const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
1726
2832
  const rows = db.db.query(`SELECT type, COUNT(*) as count
1727
2833
  FROM observations
@@ -1736,7 +2842,7 @@ function getProjectTypeCounts(db, projectId, userId) {
1736
2842
  }
1737
2843
  return counts;
1738
2844
  }
1739
- function getRecentOutcomes(db, projectId, userId, recentSessions) {
2845
+ function getRecentOutcomes2(db, projectId, userId, recentSessions) {
1740
2846
  const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
1741
2847
  const visibilityParams = userId ? [userId] : [];
1742
2848
  const summaries = db.db.query(`SELECT * FROM session_summaries
@@ -1746,7 +2852,7 @@ function getRecentOutcomes(db, projectId, userId, recentSessions) {
1746
2852
  const picked = [];
1747
2853
  const seen = new Set;
1748
2854
  for (const summary of summaries) {
1749
- for (const item of parseSummaryJsonList(summary.recent_outcomes)) {
2855
+ for (const item of parseSummaryJsonList2(summary.recent_outcomes)) {
1750
2856
  const normalized = item.toLowerCase().replace(/\s+/g, " ").trim();
1751
2857
  if (!normalized || seen.has(normalized))
1752
2858
  continue;
@@ -1756,8 +2862,8 @@ function getRecentOutcomes(db, projectId, userId, recentSessions) {
1756
2862
  return picked;
1757
2863
  }
1758
2864
  for (const line of [
1759
- ...extractMeaningfulLines(summary.completed, 2),
1760
- ...extractMeaningfulLines(summary.learned, 1)
2865
+ ...extractMeaningfulLines2(summary.completed, 2),
2866
+ ...extractMeaningfulLines2(summary.learned, 1)
1761
2867
  ]) {
1762
2868
  const normalized = line.toLowerCase().replace(/\s+/g, " ").trim();
1763
2869
  if (!normalized || seen.has(normalized))
@@ -1769,7 +2875,7 @@ function getRecentOutcomes(db, projectId, userId, recentSessions) {
1769
2875
  }
1770
2876
  }
1771
2877
  for (const session of recentSessions ?? []) {
1772
- for (const item of parseSummaryJsonList(session.recent_outcomes)) {
2878
+ for (const item of parseSummaryJsonList2(session.recent_outcomes)) {
1773
2879
  const normalized = item.toLowerCase().replace(/\s+/g, " ").trim();
1774
2880
  if (!normalized || seen.has(normalized))
1775
2881
  continue;
@@ -1789,9 +2895,9 @@ function getRecentOutcomes(db, projectId, userId, recentSessions) {
1789
2895
  for (const obs of rows) {
1790
2896
  if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
1791
2897
  continue;
1792
- const title = stripInlineSectionLabel(obs.title);
2898
+ const title = stripInlineSectionLabel2(obs.title);
1793
2899
  const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
1794
- if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle(title))
2900
+ if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle3(title))
1795
2901
  continue;
1796
2902
  seen.add(normalized);
1797
2903
  picked.push(title);
@@ -1801,6 +2907,28 @@ function getRecentOutcomes(db, projectId, userId, recentSessions) {
1801
2907
  return picked;
1802
2908
  }
1803
2909
 
2910
+ // src/tools/project-memory-index.ts
2911
+ function classifyContinuityState(recentRequestsCount, recentToolsCount, recentHandoffsCount, recentChatCount, recentSessions, recentOutcomesCount) {
2912
+ const hasRaw = recentRequestsCount > 0 || recentToolsCount > 0;
2913
+ const hasResume = recentHandoffsCount > 0 || recentChatCount > 0;
2914
+ const hasSessionThread = recentSessions.length > 0 || recentOutcomesCount > 0;
2915
+ if (hasRaw && (hasResume || hasSessionThread))
2916
+ return "fresh";
2917
+ if (hasRaw || hasResume || hasSessionThread)
2918
+ return "thin";
2919
+ return "cold";
2920
+ }
2921
+ function describeContinuityState(state) {
2922
+ switch (state) {
2923
+ case "fresh":
2924
+ return "Fresh repo-local continuity is available.";
2925
+ case "thin":
2926
+ return "Only partial continuity is available; recent prompts/chat are safer than older memory.";
2927
+ default:
2928
+ return "No fresh repo-local continuity yet; older memory should be treated cautiously.";
2929
+ }
2930
+ }
2931
+
1804
2932
  // src/telemetry/stack-detect.ts
1805
2933
  import { existsSync as existsSync2 } from "node:fs";
1806
2934
  import { join as join2, extname } from "node:path";
@@ -1921,7 +3049,7 @@ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync
1921
3049
  import { join as join3 } from "node:path";
1922
3050
  import { homedir } from "node:os";
1923
3051
  var STATE_PATH = join3(homedir(), ".engrm", "config-fingerprint.json");
1924
- var CLIENT_VERSION = "0.4.23";
3052
+ var CLIENT_VERSION = "0.4.26";
1925
3053
  function hashFile(filePath) {
1926
3054
  try {
1927
3055
  if (!existsSync3(filePath))
@@ -2448,7 +3576,9 @@ function mergeRemoteChat(db, config, change, projectId) {
2448
3576
  device_id: (typeof change.metadata?.device_id === "string" ? change.metadata.device_id : null) ?? "remote",
2449
3577
  agent: typeof change.metadata?.agent === "string" ? change.metadata.agent : "unknown",
2450
3578
  created_at_epoch: typeof change.metadata?.created_at_epoch === "number" ? change.metadata.created_at_epoch : undefined,
2451
- remote_source_id: change.source_id
3579
+ remote_source_id: change.source_id,
3580
+ source_kind: change.metadata?.source_kind === "transcript" ? "transcript" : "hook",
3581
+ transcript_index: typeof change.metadata?.transcript_index === "number" ? change.metadata.transcript_index : null
2452
3582
  });
2453
3583
  return true;
2454
3584
  }
@@ -3101,6 +4231,19 @@ var MIGRATIONS = [
3101
4231
  CREATE INDEX idx_outbox_status ON sync_outbox(status, next_retry_epoch);
3102
4232
  CREATE INDEX idx_outbox_record ON sync_outbox(record_type, record_id);
3103
4233
  `
4234
+ },
4235
+ {
4236
+ version: 17,
4237
+ description: "Track transcript-backed chat messages separately from hook chat",
4238
+ sql: `
4239
+ ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook';
4240
+ ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER;
4241
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind
4242
+ ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC);
4243
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript
4244
+ ON chat_messages(session_id, transcript_index)
4245
+ WHERE transcript_index IS NOT NULL;
4246
+ `
3104
4247
  }
3105
4248
  ];
3106
4249
  function isVecExtensionLoaded(db) {
@@ -3171,6 +4314,9 @@ function inferLegacySchemaVersion(db) {
3171
4314
  if (syncOutboxSupportsChatMessages(db)) {
3172
4315
  version = Math.max(version, 16);
3173
4316
  }
4317
+ if (columnExists(db, "chat_messages", "source_kind") && columnExists(db, "chat_messages", "transcript_index")) {
4318
+ version = Math.max(version, 17);
4319
+ }
3174
4320
  return version;
3175
4321
  }
3176
4322
  function runMigrations(db) {
@@ -3274,9 +4420,17 @@ function ensureChatMessageColumns(db) {
3274
4420
  db.exec("ALTER TABLE chat_messages ADD COLUMN remote_source_id TEXT");
3275
4421
  }
3276
4422
  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");
4423
+ if (!columnExists(db, "chat_messages", "source_kind")) {
4424
+ db.exec("ALTER TABLE chat_messages ADD COLUMN source_kind TEXT DEFAULT 'hook'");
4425
+ }
4426
+ if (!columnExists(db, "chat_messages", "transcript_index")) {
4427
+ db.exec("ALTER TABLE chat_messages ADD COLUMN transcript_index INTEGER");
4428
+ }
4429
+ db.exec("CREATE INDEX IF NOT EXISTS idx_chat_messages_source_kind ON chat_messages(session_id, source_kind, transcript_index, created_at_epoch DESC, id DESC)");
4430
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_messages_session_transcript ON chat_messages(session_id, transcript_index) WHERE transcript_index IS NOT NULL");
3277
4431
  const current = getSchemaVersion(db);
3278
- if (current < 15) {
3279
- db.exec("PRAGMA user_version = 15");
4432
+ if (current < 17) {
4433
+ db.exec("PRAGMA user_version = 17");
3280
4434
  }
3281
4435
  }
3282
4436
  function ensureSyncOutboxSupportsChatMessages(db) {
@@ -3481,6 +4635,22 @@ class MemDatabase {
3481
4635
  getObservationById(id) {
3482
4636
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
3483
4637
  }
4638
+ updateObservationContent(id, update) {
4639
+ const existing = this.getObservationById(id);
4640
+ if (!existing)
4641
+ return null;
4642
+ const createdAtEpoch = update.created_at_epoch ?? existing.created_at_epoch;
4643
+ const createdAt = new Date(createdAtEpoch * 1000).toISOString();
4644
+ this.db.query(`UPDATE observations
4645
+ SET title = ?, narrative = ?, facts = ?, concepts = ?, created_at = ?, created_at_epoch = ?
4646
+ WHERE id = ?`).run(update.title, update.narrative ?? null, update.facts ?? null, update.concepts ?? null, createdAt, createdAtEpoch, id);
4647
+ this.ftsDelete(existing);
4648
+ const refreshed = this.getObservationById(id);
4649
+ if (!refreshed)
4650
+ return null;
4651
+ this.ftsInsert(refreshed);
4652
+ return refreshed;
4653
+ }
3484
4654
  getObservationsByIds(ids, userId) {
3485
4655
  if (ids.length === 0)
3486
4656
  return [];
@@ -3752,8 +4922,8 @@ class MemDatabase {
3752
4922
  const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
3753
4923
  const content = input.content.trim();
3754
4924
  const result = this.db.query(`INSERT INTO chat_messages (
3755
- session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id
3756
- ) 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);
4925
+ session_id, project_id, role, content, user_id, device_id, agent, created_at_epoch, remote_source_id, source_kind, transcript_index
4926
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.session_id, input.project_id, input.role, content, input.user_id, input.device_id, input.agent ?? "claude-code", createdAt, input.remote_source_id ?? null, input.source_kind ?? "hook", input.transcript_index ?? null);
3757
4927
  return this.getChatMessageById(Number(result.lastInsertRowid));
3758
4928
  }
3759
4929
  getChatMessageById(id) {
@@ -3765,7 +4935,17 @@ class MemDatabase {
3765
4935
  getSessionChatMessages(sessionId, limit = 50) {
3766
4936
  return this.db.query(`SELECT * FROM chat_messages
3767
4937
  WHERE session_id = ?
3768
- ORDER BY created_at_epoch ASC, id ASC
4938
+ AND (
4939
+ source_kind = 'transcript'
4940
+ OR NOT EXISTS (
4941
+ SELECT 1 FROM chat_messages t2
4942
+ WHERE t2.session_id = chat_messages.session_id
4943
+ AND t2.source_kind = 'transcript'
4944
+ )
4945
+ )
4946
+ ORDER BY
4947
+ CASE WHEN transcript_index IS NULL THEN created_at_epoch ELSE transcript_index END ASC,
4948
+ id ASC
3769
4949
  LIMIT ?`).all(sessionId, limit);
3770
4950
  }
3771
4951
  getRecentChatMessages(projectId, limit = 20, userId) {
@@ -3773,11 +4953,27 @@ class MemDatabase {
3773
4953
  if (projectId !== null) {
3774
4954
  return this.db.query(`SELECT * FROM chat_messages
3775
4955
  WHERE project_id = ?${visibilityClause}
4956
+ AND (
4957
+ source_kind = 'transcript'
4958
+ OR NOT EXISTS (
4959
+ SELECT 1 FROM chat_messages t2
4960
+ WHERE t2.session_id = chat_messages.session_id
4961
+ AND t2.source_kind = 'transcript'
4962
+ )
4963
+ )
3776
4964
  ORDER BY created_at_epoch DESC, id DESC
3777
4965
  LIMIT ?`).all(projectId, ...userId ? [userId] : [], limit);
3778
4966
  }
3779
4967
  return this.db.query(`SELECT * FROM chat_messages
3780
4968
  WHERE 1 = 1${visibilityClause}
4969
+ AND (
4970
+ source_kind = 'transcript'
4971
+ OR NOT EXISTS (
4972
+ SELECT 1 FROM chat_messages t2
4973
+ WHERE t2.session_id = chat_messages.session_id
4974
+ AND t2.source_kind = 'transcript'
4975
+ )
4976
+ )
3781
4977
  ORDER BY created_at_epoch DESC, id DESC
3782
4978
  LIMIT ?`).all(...userId ? [userId] : [], limit);
3783
4979
  }
@@ -3788,14 +4984,33 @@ class MemDatabase {
3788
4984
  return this.db.query(`SELECT * FROM chat_messages
3789
4985
  WHERE project_id = ?
3790
4986
  AND lower(content) LIKE ?${visibilityClause}
4987
+ AND (
4988
+ source_kind = 'transcript'
4989
+ OR NOT EXISTS (
4990
+ SELECT 1 FROM chat_messages t2
4991
+ WHERE t2.session_id = chat_messages.session_id
4992
+ AND t2.source_kind = 'transcript'
4993
+ )
4994
+ )
3791
4995
  ORDER BY created_at_epoch DESC, id DESC
3792
4996
  LIMIT ?`).all(projectId, needle, ...userId ? [userId] : [], limit);
3793
4997
  }
3794
4998
  return this.db.query(`SELECT * FROM chat_messages
3795
4999
  WHERE lower(content) LIKE ?${visibilityClause}
5000
+ AND (
5001
+ source_kind = 'transcript'
5002
+ OR NOT EXISTS (
5003
+ SELECT 1 FROM chat_messages t2
5004
+ WHERE t2.session_id = chat_messages.session_id
5005
+ AND t2.source_kind = 'transcript'
5006
+ )
5007
+ )
3796
5008
  ORDER BY created_at_epoch DESC, id DESC
3797
5009
  LIMIT ?`).all(needle, ...userId ? [userId] : [], limit);
3798
5010
  }
5011
+ getTranscriptChatMessage(sessionId, transcriptIndex) {
5012
+ return this.db.query("SELECT * FROM chat_messages WHERE session_id = ? AND transcript_index = ?").get(sessionId, transcriptIndex) ?? null;
5013
+ }
3799
5014
  addToOutbox(recordType, recordId) {
3800
5015
  const now = Math.floor(Date.now() / 1000);
3801
5016
  this.db.query(`INSERT INTO sync_outbox (record_type, record_id, created_at_epoch)
@@ -4094,7 +5309,8 @@ async function main() {
4094
5309
  const context = buildSessionContext(db, event.cwd, {
4095
5310
  tokenBudget: 800,
4096
5311
  scope: config.search.scope,
4097
- userId: config.user_id
5312
+ userId: config.user_id,
5313
+ currentDeviceId: config.device_id
4098
5314
  });
4099
5315
  if (context) {
4100
5316
  try {
@@ -4249,10 +5465,12 @@ function formatSplashScreen(data) {
4249
5465
  }
4250
5466
  function formatVisibleStartupBrief(context) {
4251
5467
  const lines = [];
5468
+ const continuityState = getStartupContinuityState(context);
4252
5469
  const latest = pickPrimarySummary(context);
4253
5470
  const observationFallbacks = buildObservationFallbacks(context);
4254
5471
  const promptFallback = buildPromptFallback(context);
4255
5472
  const promptLines = buildPromptLines(context);
5473
+ const recentChatLines = buildRecentChatLines(context);
4256
5474
  const latestPromptLine = promptLines[0] ?? null;
4257
5475
  const currentRequest = latest ? chooseRequest(latest.request, promptFallback ?? sessionFallbacksFromContext(context)[0] ?? observationFallbacks.request) : promptFallback;
4258
5476
  const toolFallbacks = buildToolFallbacks(context);
@@ -4262,6 +5480,7 @@ function formatVisibleStartupBrief(context) {
4262
5480
  const projectSignals = buildProjectSignalLine(context);
4263
5481
  const shownItems = new Set;
4264
5482
  const latestHandoffLines = buildLatestHandoffLines(context);
5483
+ const freshContinuity = hasFreshContinuitySignal(context);
4265
5484
  if (latestHandoffLines.length > 0) {
4266
5485
  lines.push(`${c2.cyan}Latest handoff:${c2.reset}`);
4267
5486
  for (const item of latestHandoffLines) {
@@ -4269,6 +5488,7 @@ function formatVisibleStartupBrief(context) {
4269
5488
  rememberShownItem(shownItems, item);
4270
5489
  }
4271
5490
  }
5491
+ lines.push(`${c2.cyan}Continuity:${c2.reset} ${continuityState} \u2014 ${truncateInline(describeContinuityState(continuityState), 160)}`);
4272
5492
  if (promptLines.length > 0) {
4273
5493
  lines.push(`${c2.cyan}Asked recently:${c2.reset}`);
4274
5494
  for (const item of promptLines) {
@@ -4276,6 +5496,13 @@ function formatVisibleStartupBrief(context) {
4276
5496
  rememberShownItem(shownItems, item);
4277
5497
  }
4278
5498
  }
5499
+ if (promptLines.length === 0 && recentChatLines.length > 0) {
5500
+ lines.push(`${c2.cyan}Chat trail:${c2.reset}`);
5501
+ for (const item of recentChatLines) {
5502
+ lines.push(` - ${truncateInline(item, 160)}`);
5503
+ rememberShownItem(shownItems, item);
5504
+ }
5505
+ }
4279
5506
  if (latest) {
4280
5507
  const sanitizedNextSteps = sanitizeNextSteps(latest.next_steps, {
4281
5508
  request: currentRequest,
@@ -4353,12 +5580,15 @@ function formatVisibleStartupBrief(context) {
4353
5580
  lines.push(`${c2.cyan}Signal mix:${c2.reset}`);
4354
5581
  lines.push(` - ${truncateInline(projectSignals, 160)}`);
4355
5582
  }
5583
+ if (!freshContinuity && lines.length > 0 && (promptLines.length > 0 || recentChatLines.length > 0)) {
5584
+ lines.push(`${c2.dim}Fresh repo-local handoff is still thin; recent prompts/chat are more trustworthy than older memory here.${c2.reset}`);
5585
+ }
4356
5586
  const stale = pickRelevantStaleDecision(context, latest);
4357
5587
  if (stale) {
4358
5588
  lines.push(`${c2.yellow}Watch:${c2.reset} ${truncateInline(`Decision still looks unfinished: ${stale.title}`, 170)}`);
4359
5589
  }
4360
5590
  if (lines.length === 0 && context.observations.length > 0) {
4361
- const top = context.observations.filter((obs) => obs.type !== "digest").filter((obs) => obs.type !== "decision").filter((obs) => !looksLikeFileOperationTitle2(obs.title)).slice(0, 3);
5591
+ const top = context.observations.filter((obs) => obs.type !== "digest").filter((obs) => obs.type !== "decision").filter((obs) => !looksLikeFileOperationTitle4(obs.title)).slice(0, 3);
4362
5592
  for (const obs of top) {
4363
5593
  lines.push(`${c2.cyan}${capitalize(obs.type)}:${c2.reset} ${truncateInline(obs.title, 170)}`);
4364
5594
  }
@@ -4370,9 +5600,9 @@ function buildLatestHandoffLines(context) {
4370
5600
  if (!latest)
4371
5601
  return [];
4372
5602
  const lines = [];
4373
- const title = latest.title.replace(/^Handoff:\s*/i, "").replace(/\s+\u00B7\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
5603
+ const title = latest.title.replace(/^Handoff(?: Draft)?:\s*/i, "").replace(/\s+\u00B7\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}Z$/, "").trim();
4374
5604
  if (title)
4375
- lines.push(title);
5605
+ lines.push(`${title} (${formatHandoffSource2(latest)})`);
4376
5606
  const narrative = latest.narrative?.split(/\n{2,}/).map((part) => part.replace(/\s+/g, " ").trim()).find((part) => /^(Current thread:|Completed:|Next Steps:)/i.test(part));
4377
5607
  if (narrative) {
4378
5608
  lines.push(narrative.replace(/^(Current thread:|Completed:|Next Steps:)\s*/i, ""));
@@ -4402,6 +5632,9 @@ function formatLegend() {
4402
5632
  }
4403
5633
  function formatContextIndex(context, shownItems) {
4404
5634
  const selected = pickContextIndexObservations(context, shownItems);
5635
+ if (!hasFreshContinuitySignal(context)) {
5636
+ return { lines: [], observationIds: [] };
5637
+ }
4405
5638
  const rows = selected.map((obs) => {
4406
5639
  const icon = observationIcon(obs.type);
4407
5640
  const fileHint = extractPrimaryFileHint(obs);
@@ -4419,19 +5652,29 @@ function formatContextIndex(context, shownItems) {
4419
5652
  }
4420
5653
  function formatInspectHints(context, visibleObservationIds = []) {
4421
5654
  const hints = [];
5655
+ const continuityState = getStartupContinuityState(context);
4422
5656
  if ((context.recentSessions?.length ?? 0) > 0) {
4423
5657
  hints.push("recent_sessions");
4424
5658
  hints.push("session_story");
4425
5659
  hints.push("create_handoff");
4426
5660
  }
4427
- if ((context.recentPrompts?.length ?? 0) > 0 || (context.recentToolEvents?.length ?? 0) > 0) {
5661
+ if ((context.recentPrompts?.length ?? 0) > 0 || (context.recentToolEvents?.length ?? 0) > 0 || (context.recentChatMessages?.length ?? 0) > 0) {
4428
5662
  hints.push("activity_feed");
4429
5663
  }
4430
5664
  if (context.observations.length > 0) {
4431
5665
  hints.push("memory_console");
4432
5666
  }
4433
- if ((context.recentSessions?.length ?? 0) > 0) {
5667
+ if ((context.recentHandoffs?.length ?? 0) > 0) {
5668
+ hints.push("load_handoff");
5669
+ hints.push("recent_handoffs");
5670
+ }
5671
+ if ((context.recentChatMessages?.length ?? 0) > 0) {
5672
+ hints.push("recent_chat");
5673
+ }
5674
+ if (continuityState !== "fresh") {
5675
+ hints.push("recent_chat");
4434
5676
  hints.push("recent_handoffs");
5677
+ hints.push("refresh_chat_recall");
4435
5678
  }
4436
5679
  const unique = Array.from(new Set(hints)).slice(0, 4);
4437
5680
  if (unique.length === 0)
@@ -4484,17 +5727,25 @@ function filterAdditiveToolFallbacks(toolFallbacks, shownItems) {
4484
5727
  });
4485
5728
  }
4486
5729
  function buildPromptFallback(context) {
4487
- const latest = (context.recentPrompts ?? []).find((prompt) => isMeaningfulPrompt2(prompt.prompt));
5730
+ const latest = (context.recentPrompts ?? []).find((prompt) => isMeaningfulPrompt3(prompt.prompt));
4488
5731
  if (!latest?.prompt)
4489
5732
  return null;
4490
5733
  return latest.prompt.replace(/\s+/g, " ").trim();
4491
5734
  }
4492
5735
  function buildPromptLines(context) {
4493
- return (context.recentPrompts ?? []).filter((prompt) => isMeaningfulPrompt2(prompt.prompt)).slice(0, 2).map((prompt) => {
5736
+ return (context.recentPrompts ?? []).filter((prompt) => isMeaningfulPrompt3(prompt.prompt)).slice(0, 2).map((prompt) => {
4494
5737
  const prefix = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : "request";
4495
5738
  return `${prefix}: ${prompt.prompt.replace(/\s+/g, " ").trim()}`;
4496
5739
  }).filter((item) => item.length > 0);
4497
5740
  }
5741
+ function buildRecentChatLines(context) {
5742
+ return (context.recentChatMessages ?? []).slice(0, 2).map((message) => {
5743
+ const content = message.content.replace(/\s+/g, " ").trim();
5744
+ if (!content)
5745
+ return null;
5746
+ return `[${message.role}] ${content}`;
5747
+ }).filter((item) => Boolean(item));
5748
+ }
4498
5749
  function duplicatesPromptLine(request, promptLine) {
4499
5750
  if (!request || !promptLine)
4500
5751
  return false;
@@ -4548,11 +5799,11 @@ function buildRecentOutcomeLines(context, summary) {
4548
5799
  }
4549
5800
  }
4550
5801
  if (picked.length < 2) {
4551
- for (const obs of context.observations) {
5802
+ for (const obs of getFreshStartupObservations(context)) {
4552
5803
  if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
4553
5804
  continue;
4554
- const title = stripInlineSectionLabel2(obs.title);
4555
- if (!title || looksLikeFileOperationTitle2(title))
5805
+ const title = stripInlineSectionLabel3(obs.title);
5806
+ if (!title || looksLikeFileOperationTitle4(title))
4556
5807
  continue;
4557
5808
  const normalized = normalizeStartupItem(title);
4558
5809
  if (!normalized || seen.has(normalized))
@@ -4567,26 +5818,29 @@ function buildRecentOutcomeLines(context, summary) {
4567
5818
  }
4568
5819
  function buildCurrentThreadLine(context, summary) {
4569
5820
  const explicit = summary?.current_thread ?? null;
4570
- if (explicit && !looksLikeFileOperationTitle2(explicit))
5821
+ if (explicit && !looksLikeFileOperationTitle4(explicit))
4571
5822
  return explicit;
4572
5823
  for (const session of context.recentSessions ?? []) {
4573
- if (session.current_thread && !looksLikeFileOperationTitle2(session.current_thread)) {
5824
+ if (session.current_thread && !looksLikeFileOperationTitle4(session.current_thread)) {
4574
5825
  return session.current_thread;
4575
5826
  }
4576
5827
  }
4577
5828
  const request = buildPromptFallback(context);
4578
5829
  const outcome = buildRecentOutcomeLines(context, summary)[0] ?? null;
4579
5830
  const tool = buildToolFallbacks(context)[0] ?? null;
5831
+ const hasContinuity = hasFreshContinuitySignal(context);
4580
5832
  if (outcome && tool)
4581
5833
  return `${outcome} \xB7 ${tool}`;
5834
+ if (!hasContinuity && !outcome)
5835
+ return request;
4582
5836
  return outcome ?? request ?? null;
4583
5837
  }
4584
5838
  function chooseMeaningfulSessionSummary(request, completed) {
4585
- if (request && !looksLikeFileOperationTitle2(request))
5839
+ if (request && !looksLikeFileOperationTitle4(request))
4586
5840
  return request;
4587
5841
  if (completed) {
4588
5842
  const lines = completed.split(`
4589
- `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean).map((line) => stripInlineSectionLabel2(line)).filter((line) => !looksLikeFileOperationTitle2(line));
5843
+ `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean).map((line) => stripInlineSectionLabel3(line)).filter((line) => !looksLikeFileOperationTitle4(line));
4590
5844
  if (lines.length > 0)
4591
5845
  return lines[0] ?? null;
4592
5846
  }
@@ -4695,7 +5949,7 @@ function pickContextIndexObservations(context, shownItems) {
4695
5949
  score += 2.5;
4696
5950
  return score;
4697
5951
  };
4698
- for (const obs of context.observations.filter((obs2) => obs2.type !== "digest").filter((obs2) => {
5952
+ for (const obs of getFreshStartupObservations(context).filter((obs2) => obs2.type !== "digest").filter((obs2) => {
4699
5953
  const normalized = normalizeStartupItem(obs2.title);
4700
5954
  return normalized && !hidden.has(normalized);
4701
5955
  }).sort((a, b) => {
@@ -4726,7 +5980,7 @@ function toSplashLines(value, maxItems) {
4726
5980
  if (!value)
4727
5981
  return [];
4728
5982
  const lines = value.split(`
4729
- `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "")).map((line) => stripInlineSectionLabel2(line)).map((line) => dedupeFragments(line)).filter(Boolean).sort((a, b) => scoreSplashLine(b) - scoreSplashLine(a)).slice(0, maxItems).map((line) => `- ${truncateInline(line, 140)}`);
5983
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "")).map((line) => stripInlineSectionLabel3(line)).map((line) => dedupeFragments(line)).filter(Boolean).sort((a, b) => scoreSplashLine(b) - scoreSplashLine(a)).slice(0, maxItems).map((line) => `- ${truncateInline(line, 140)}`);
4730
5984
  return dedupeFragmentsInLines(lines);
4731
5985
  }
4732
5986
  function pickPrimarySummary(context) {
@@ -4737,7 +5991,7 @@ function pickPrimarySummary(context) {
4737
5991
  const request = summary.request?.trim();
4738
5992
  const learned = summary.learned?.trim();
4739
5993
  const completed = summary.completed?.trim();
4740
- return Boolean(request && !looksLikeFileOperationTitle2(request) || learned || hasMeaningfulCompleted(completed));
5994
+ return Boolean(request && !looksLikeFileOperationTitle4(request) || learned || hasMeaningfulCompleted(completed));
4741
5995
  });
4742
5996
  return meaningfulRecent ?? summaries[0] ?? null;
4743
5997
  }
@@ -4745,7 +5999,7 @@ function hasMeaningfulCompleted(value) {
4745
5999
  if (!value)
4746
6000
  return false;
4747
6001
  return value.split(`
4748
- `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean).some((line) => !looksLikeFileOperationTitle2(stripInlineSectionLabel2(line)));
6002
+ `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean).some((line) => !looksLikeFileOperationTitle4(stripInlineSectionLabel3(line)));
4749
6003
  }
4750
6004
  function sectionItemCount(value) {
4751
6005
  if (!value)
@@ -4770,7 +6024,7 @@ function dedupeFragmentsInLines(lines) {
4770
6024
  const seen = new Set;
4771
6025
  const deduped = [];
4772
6026
  for (const line of lines) {
4773
- const normalized = stripInlineSectionLabel2(line).toLowerCase().replace(/\s+/g, " ").trim();
6027
+ const normalized = stripInlineSectionLabel3(line).toLowerCase().replace(/\s+/g, " ").trim();
4774
6028
  if (!normalized || seen.has(normalized))
4775
6029
  continue;
4776
6030
  seen.add(normalized);
@@ -4782,7 +6036,7 @@ function hasRequestSection(lines) {
4782
6036
  return lines.some((line) => line.includes("Request:"));
4783
6037
  }
4784
6038
  function normalizeStartupItem(value) {
4785
- return stripInlineSectionLabel2(value).replace(/^#?\d+:\s*/, "").replace(/^-\s*/, "").replace(/\([^)]*\)/g, " ").replace(/[^a-z0-9\s]/gi, " ").toLowerCase().replace(/\s+/g, " ").trim();
6039
+ return stripInlineSectionLabel3(value).replace(/^#?\d+:\s*/, "").replace(/^-\s*/, "").replace(/\([^)]*\)/g, " ").replace(/[^a-z0-9\s]/gi, " ").toLowerCase().replace(/\s+/g, " ").trim();
4786
6040
  }
4787
6041
  function titlesRoughlyMatch(left, right) {
4788
6042
  const a = normalizeStartupItem(left ?? "");
@@ -4801,7 +6055,7 @@ function titlesRoughlyMatch(left, right) {
4801
6055
  const minSize = Math.min(aTokens.length, bTokens.length);
4802
6056
  return shared.length >= Math.max(3, Math.ceil(minSize * 0.6));
4803
6057
  }
4804
- function isMeaningfulPrompt2(value) {
6058
+ function isMeaningfulPrompt3(value) {
4805
6059
  if (!value)
4806
6060
  return false;
4807
6061
  const compact = value.replace(/\s+/g, " ").trim();
@@ -4810,7 +6064,7 @@ function isMeaningfulPrompt2(value) {
4810
6064
  return /[a-z]{3,}/i.test(compact);
4811
6065
  }
4812
6066
  function chooseRequest(primary, fallback) {
4813
- if (primary && !looksLikeFileOperationTitle2(primary))
6067
+ if (primary && !looksLikeFileOperationTitle4(primary))
4814
6068
  return primary;
4815
6069
  return fallback;
4816
6070
  }
@@ -4828,10 +6082,10 @@ function isWeakCompletedSection(value) {
4828
6082
  `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean);
4829
6083
  if (!items.length)
4830
6084
  return true;
4831
- const weakCount = items.filter((item) => looksLikeFileOperationTitle2(item)).length;
6085
+ const weakCount = items.filter((item) => looksLikeFileOperationTitle4(item)).length;
4832
6086
  return weakCount === items.length;
4833
6087
  }
4834
- function looksLikeFileOperationTitle2(value) {
6088
+ function looksLikeFileOperationTitle4(value) {
4835
6089
  const trimmed = value.trim();
4836
6090
  if (/^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(trimmed)) {
4837
6091
  return true;
@@ -4844,7 +6098,7 @@ function looksLikeGenericSummaryWrapper(value) {
4844
6098
  }
4845
6099
  function scoreSplashLine(value) {
4846
6100
  let score = 0;
4847
- if (!looksLikeFileOperationTitle2(value))
6101
+ if (!looksLikeFileOperationTitle4(value))
4848
6102
  score += 2;
4849
6103
  if (/[:;]/.test(value))
4850
6104
  score += 1;
@@ -4853,9 +6107,9 @@ function scoreSplashLine(value) {
4853
6107
  return score;
4854
6108
  }
4855
6109
  function buildObservationFallbacks(context) {
4856
- const request = context.observations.find((obs) => obs.type !== "decision" && !looksLikeFileOperationTitle2(obs.title))?.title ?? null;
6110
+ const request = getFreshStartupObservations(context).find((obs) => obs.type !== "decision" && !looksLikeFileOperationTitle4(obs.title))?.title ?? null;
4857
6111
  const investigated = collectObservationTitles(context, (obs) => obs.type === "discovery", 2);
4858
- const completed = collectObservationTitles(context, (obs) => ["bugfix", "feature", "refactor", "change"].includes(obs.type) && !looksLikeFileOperationTitle2(obs.title), 2);
6112
+ const completed = collectObservationTitles(context, (obs) => ["bugfix", "feature", "refactor", "change"].includes(obs.type) && !looksLikeFileOperationTitle4(obs.title), 2);
4859
6113
  return {
4860
6114
  request,
4861
6115
  investigated,
@@ -4865,21 +6119,38 @@ function buildObservationFallbacks(context) {
4865
6119
  function collectObservationTitles(context, predicate, limit) {
4866
6120
  const seen = new Set;
4867
6121
  const picked = [];
4868
- for (const obs of context.observations) {
6122
+ for (const obs of getFreshStartupObservations(context)) {
4869
6123
  if (!predicate(obs))
4870
6124
  continue;
4871
- const normalized = stripInlineSectionLabel2(obs.title).toLowerCase().replace(/\s+/g, " ").trim();
6125
+ const normalized = stripInlineSectionLabel3(obs.title).toLowerCase().replace(/\s+/g, " ").trim();
4872
6126
  if (!normalized || seen.has(normalized))
4873
6127
  continue;
4874
6128
  seen.add(normalized);
4875
- picked.push(`- ${stripInlineSectionLabel2(obs.title)}`);
6129
+ picked.push(`- ${stripInlineSectionLabel3(obs.title)}`);
4876
6130
  if (picked.length >= limit)
4877
6131
  break;
4878
6132
  }
4879
6133
  return picked.length ? picked.join(`
4880
6134
  `) : null;
4881
6135
  }
4882
- function stripInlineSectionLabel2(value) {
6136
+ function getFreshStartupObservations(context) {
6137
+ if (hasFreshContinuitySignal(context))
6138
+ return context.observations;
6139
+ return context.observations.filter((obs) => observationAgeDays3(obs) <= 3);
6140
+ }
6141
+ function hasFreshContinuitySignal(context) {
6142
+ return getStartupContinuityState(context) === "fresh";
6143
+ }
6144
+ function getStartupContinuityState(context) {
6145
+ return classifyContinuityState(context.recentPrompts?.length ?? 0, context.recentToolEvents?.length ?? 0, context.recentHandoffs?.length ?? 0, context.recentChatMessages?.length ?? 0, context.recentSessions ?? [], context.recentOutcomes?.length ?? 0);
6146
+ }
6147
+ function observationAgeDays3(obs) {
6148
+ const createdAt = new Date(obs.created_at).getTime();
6149
+ if (!Number.isFinite(createdAt))
6150
+ return Number.POSITIVE_INFINITY;
6151
+ return Math.max(0, (Date.now() - createdAt) / 86400000);
6152
+ }
6153
+ function stripInlineSectionLabel3(value) {
4883
6154
  return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
4884
6155
  }
4885
6156
  function pickRelevantStaleDecision(context, summary) {
@@ -4970,7 +6241,8 @@ function capitalize(value) {
4970
6241
  }
4971
6242
  var __testables = {
4972
6243
  formatSplashScreen,
4973
- formatVisibleStartupBrief
6244
+ formatVisibleStartupBrief,
6245
+ getStartupContinuityState
4974
6246
  };
4975
6247
  runHook("session-start", main);
4976
6248
  export {