engrm 0.4.0 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -14249,8 +14249,8 @@ class MemDatabase {
14249
14249
  return this.db.query("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
14250
14250
  }
14251
14251
  insertObservation(obs) {
14252
- const now = Math.floor(Date.now() / 1000);
14253
- const createdAt = new Date().toISOString();
14252
+ const now = obs.created_at_epoch ?? Math.floor(Date.now() / 1000);
14253
+ const createdAt = obs.created_at ?? new Date(now * 1000).toISOString();
14254
14254
  const result = this.db.query(`INSERT INTO observations (
14255
14255
  session_id, project_id, type, title, narrative, facts, concepts,
14256
14256
  files_read, files_modified, quality, lifecycle, sensitivity,
@@ -14267,11 +14267,14 @@ class MemDatabase {
14267
14267
  getObservationById(id) {
14268
14268
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
14269
14269
  }
14270
- getObservationsByIds(ids) {
14270
+ getObservationsByIds(ids, userId) {
14271
14271
  if (ids.length === 0)
14272
14272
  return [];
14273
14273
  const placeholders = ids.map(() => "?").join(",");
14274
- return this.db.query(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`).all(...ids);
14274
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
14275
+ return this.db.query(`SELECT * FROM observations
14276
+ WHERE id IN (${placeholders})${visibilityClause}
14277
+ ORDER BY created_at_epoch DESC`).all(...ids, ...userId ? [userId] : []);
14275
14278
  }
14276
14279
  getRecentObservations(projectId, sincEpoch, limit = 50) {
14277
14280
  return this.db.query(`SELECT * FROM observations
@@ -14279,8 +14282,9 @@ class MemDatabase {
14279
14282
  ORDER BY created_at_epoch DESC
14280
14283
  LIMIT ?`).all(projectId, sincEpoch, limit);
14281
14284
  }
14282
- searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
14285
+ searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
14283
14286
  const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
14287
+ const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
14284
14288
  if (projectId !== null) {
14285
14289
  return this.db.query(`SELECT o.id, observations_fts.rank
14286
14290
  FROM observations_fts
@@ -14288,33 +14292,39 @@ class MemDatabase {
14288
14292
  WHERE observations_fts MATCH ?
14289
14293
  AND o.project_id = ?
14290
14294
  AND o.lifecycle IN (${lifecyclePlaceholders})
14295
+ ${visibilityClause}
14291
14296
  ORDER BY observations_fts.rank
14292
- LIMIT ?`).all(query, projectId, ...lifecycles, limit);
14297
+ LIMIT ?`).all(query, projectId, ...lifecycles, ...userId ? [userId] : [], limit);
14293
14298
  }
14294
14299
  return this.db.query(`SELECT o.id, observations_fts.rank
14295
14300
  FROM observations_fts
14296
14301
  JOIN observations o ON o.id = observations_fts.rowid
14297
14302
  WHERE observations_fts MATCH ?
14298
14303
  AND o.lifecycle IN (${lifecyclePlaceholders})
14304
+ ${visibilityClause}
14299
14305
  ORDER BY observations_fts.rank
14300
- LIMIT ?`).all(query, ...lifecycles, limit);
14306
+ LIMIT ?`).all(query, ...lifecycles, ...userId ? [userId] : [], limit);
14301
14307
  }
14302
- getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3) {
14303
- const anchor = this.getObservationById(anchorId);
14308
+ getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3, userId) {
14309
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
14310
+ const anchor = this.db.query(`SELECT * FROM observations WHERE id = ?${visibilityClause}`).get(anchorId, ...userId ? [userId] : []) ?? null;
14304
14311
  if (!anchor)
14305
14312
  return [];
14306
14313
  const projectFilter = projectId !== null ? "AND project_id = ?" : "";
14307
14314
  const projectParams = projectId !== null ? [projectId] : [];
14315
+ const visibilityParams = userId ? [userId] : [];
14308
14316
  const before = this.db.query(`SELECT * FROM observations
14309
14317
  WHERE created_at_epoch < ? ${projectFilter}
14310
14318
  AND lifecycle IN ('active', 'aging', 'pinned')
14319
+ ${visibilityClause}
14311
14320
  ORDER BY created_at_epoch DESC
14312
- LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthBefore);
14321
+ LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthBefore);
14313
14322
  const after = this.db.query(`SELECT * FROM observations
14314
14323
  WHERE created_at_epoch > ? ${projectFilter}
14315
14324
  AND lifecycle IN ('active', 'aging', 'pinned')
14325
+ ${visibilityClause}
14316
14326
  ORDER BY created_at_epoch ASC
14317
- LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthAfter);
14327
+ LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthAfter);
14318
14328
  return [...before.reverse(), anchor, ...after];
14319
14329
  }
14320
14330
  pinObservation(id, pinned) {
@@ -14428,11 +14438,12 @@ class MemDatabase {
14428
14438
  return;
14429
14439
  this.db.query("DELETE FROM vec_observations WHERE observation_id = ?").run(observationId);
14430
14440
  }
14431
- searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
14441
+ searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
14432
14442
  if (!this.vecAvailable)
14433
14443
  return [];
14434
14444
  const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
14435
14445
  const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
14446
+ const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
14436
14447
  if (projectId !== null) {
14437
14448
  return this.db.query(`SELECT v.observation_id, v.distance
14438
14449
  FROM vec_observations v
@@ -14441,7 +14452,7 @@ class MemDatabase {
14441
14452
  AND k = ?
14442
14453
  AND o.project_id = ?
14443
14454
  AND o.lifecycle IN (${lifecyclePlaceholders})
14444
- AND o.superseded_by IS NULL`).all(embeddingBlob, limit, projectId, ...lifecycles);
14455
+ AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, projectId, ...lifecycles, ...userId ? [userId] : []);
14445
14456
  }
14446
14457
  return this.db.query(`SELECT v.observation_id, v.distance
14447
14458
  FROM vec_observations v
@@ -14449,7 +14460,7 @@ class MemDatabase {
14449
14460
  WHERE v.embedding MATCH ?
14450
14461
  AND k = ?
14451
14462
  AND o.lifecycle IN (${lifecyclePlaceholders})
14452
- AND o.superseded_by IS NULL`).all(embeddingBlob, limit, ...lifecycles);
14463
+ AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, ...lifecycles, ...userId ? [userId] : []);
14453
14464
  }
14454
14465
  getUnembeddedCount() {
14455
14466
  if (!this.vecAvailable)
@@ -14733,6 +14744,12 @@ function scoreQuality(input) {
14733
14744
  case "digest":
14734
14745
  score += 0.3;
14735
14746
  break;
14747
+ case "standard":
14748
+ score += 0.25;
14749
+ break;
14750
+ case "message":
14751
+ score += 0.1;
14752
+ break;
14736
14753
  }
14737
14754
  if (input.narrative && input.narrative.length > 50) {
14738
14755
  score += 0.15;
@@ -15329,11 +15346,11 @@ async function searchObservations(db, input) {
15329
15346
  }
15330
15347
  }
15331
15348
  const safeQuery = sanitizeFtsQuery(query);
15332
- const ftsResults = safeQuery ? db.searchFts(safeQuery, projectId, undefined, limit * 2) : [];
15349
+ const ftsResults = safeQuery ? db.searchFts(safeQuery, projectId, undefined, limit * 2, input.user_id) : [];
15333
15350
  let vecResults = [];
15334
15351
  const queryEmbedding = await embedText(query);
15335
15352
  if (queryEmbedding && db.vecAvailable) {
15336
- vecResults = db.searchVec(queryEmbedding, projectId, ["active", "aging", "pinned"], limit * 2);
15353
+ vecResults = db.searchVec(queryEmbedding, projectId, ["active", "aging", "pinned"], limit * 2, input.user_id);
15337
15354
  }
15338
15355
  const merged = mergeResults(ftsResults, vecResults, limit);
15339
15356
  if (merged.length === 0) {
@@ -15341,7 +15358,7 @@ async function searchObservations(db, input) {
15341
15358
  }
15342
15359
  const ids = merged.map((r) => r.id);
15343
15360
  const scoreMap = new Map(merged.map((r) => [r.id, r.score]));
15344
- const observations = db.getObservationsByIds(ids);
15361
+ const observations = db.getObservationsByIds(ids, input.user_id);
15345
15362
  const active = observations.filter((obs) => obs.superseded_by === null);
15346
15363
  const projectNameCache = new Map;
15347
15364
  if (!projectScoped) {
@@ -15368,7 +15385,7 @@ async function searchObservations(db, input) {
15368
15385
  lifecycle: obs.lifecycle,
15369
15386
  created_at: obs.created_at,
15370
15387
  rank: baseScore * lifecycleWeight,
15371
- ...!projectScoped && projectId !== null && obs.project_id !== projectId ? { project_name: projectNameCache.get(obs.project_id) } : {}
15388
+ ...!projectScoped ? { project_name: projectNameCache.get(obs.project_id) } : {}
15372
15389
  };
15373
15390
  });
15374
15391
  entries.sort((a, b) => b.rank - a.rank);
@@ -15404,7 +15421,7 @@ function getObservations(db, input) {
15404
15421
  if (input.ids.length === 0) {
15405
15422
  return { observations: [], not_found: [] };
15406
15423
  }
15407
- const observations = db.getObservationsByIds(input.ids);
15424
+ const observations = db.getObservationsByIds(input.ids, input.user_id);
15408
15425
  const foundIds = new Set(observations.map((o) => o.id));
15409
15426
  const notFound = input.ids.filter((id) => !foundIds.has(id));
15410
15427
  return { observations, not_found: notFound };
@@ -15426,7 +15443,7 @@ function getTimeline(db, input) {
15426
15443
  projectName = project.name;
15427
15444
  }
15428
15445
  }
15429
- const observations = db.getTimeline(input.anchor_id, projectId, depthBefore, depthAfter);
15446
+ const observations = db.getTimeline(input.anchor_id, projectId, depthBefore, depthAfter, input.user_id);
15430
15447
  const anchorIndex = observations.findIndex((o) => o.id === input.anchor_id);
15431
15448
  return {
15432
15449
  observations,
@@ -15457,6 +15474,295 @@ function pinObservation(db, input) {
15457
15474
  return { success: true };
15458
15475
  }
15459
15476
 
15477
+ // src/tools/recent.ts
15478
+ function getRecentActivity(db, input) {
15479
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 50));
15480
+ const projectScoped = input.project_scoped !== false;
15481
+ let projectId = null;
15482
+ let projectName;
15483
+ if (projectScoped) {
15484
+ const cwd = input.cwd ?? process.cwd();
15485
+ const detected = detectProject(cwd);
15486
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
15487
+ if (project) {
15488
+ projectId = project.id;
15489
+ projectName = project.name;
15490
+ }
15491
+ }
15492
+ const params = [];
15493
+ const conditions = [
15494
+ "lifecycle IN ('active', 'aging', 'pinned')",
15495
+ "superseded_by IS NULL"
15496
+ ];
15497
+ if (input.user_id) {
15498
+ conditions.push("(sensitivity != 'personal' OR user_id = ?)");
15499
+ params.push(input.user_id);
15500
+ }
15501
+ if (projectId !== null) {
15502
+ conditions.push("project_id = ?");
15503
+ params.push(projectId);
15504
+ }
15505
+ if (input.type) {
15506
+ conditions.push("type = ?");
15507
+ params.push(input.type);
15508
+ }
15509
+ params.push(limit);
15510
+ const observations = db.db.query(`SELECT observations.*, projects.name AS project_name
15511
+ FROM observations
15512
+ LEFT JOIN projects ON projects.id = observations.project_id
15513
+ WHERE ${conditions.join(" AND ")}
15514
+ ORDER BY observations.created_at_epoch DESC
15515
+ LIMIT ?`).all(...params);
15516
+ return {
15517
+ observations,
15518
+ project: projectName
15519
+ };
15520
+ }
15521
+
15522
+ // src/tools/send-message.ts
15523
+ async function sendMessage(db, config2, input) {
15524
+ const result = await saveObservation(db, config2, {
15525
+ type: "message",
15526
+ title: input.title,
15527
+ narrative: input.narrative,
15528
+ concepts: input.concepts,
15529
+ session_id: input.session_id,
15530
+ cwd: input.cwd
15531
+ });
15532
+ return {
15533
+ success: result.success,
15534
+ observation_id: result.observation_id,
15535
+ reason: result.reason
15536
+ };
15537
+ }
15538
+
15539
+ // src/storage/outbox.ts
15540
+ function getPendingEntries(db, limit = 50) {
15541
+ const now = Math.floor(Date.now() / 1000);
15542
+ return db.db.query(`SELECT * FROM sync_outbox
15543
+ WHERE (status = 'pending')
15544
+ OR (status = 'failed' AND retry_count < max_retries AND (next_retry_epoch IS NULL OR next_retry_epoch <= ?))
15545
+ ORDER BY created_at_epoch ASC
15546
+ LIMIT ?`).all(now, limit);
15547
+ }
15548
+ function markSyncing(db, entryId) {
15549
+ db.db.query("UPDATE sync_outbox SET status = 'syncing' WHERE id = ?").run(entryId);
15550
+ }
15551
+ function markSynced(db, entryId) {
15552
+ const now = Math.floor(Date.now() / 1000);
15553
+ db.db.query("UPDATE sync_outbox SET status = 'synced', synced_at_epoch = ? WHERE id = ?").run(now, entryId);
15554
+ }
15555
+ function markFailed(db, entryId, error48) {
15556
+ const now = Math.floor(Date.now() / 1000);
15557
+ db.db.query(`UPDATE sync_outbox SET
15558
+ status = 'failed',
15559
+ retry_count = retry_count + 1,
15560
+ last_error = ?,
15561
+ next_retry_epoch = ? + MIN(30 * (1 << retry_count), 3600)
15562
+ WHERE id = ?`).run(error48, now, entryId);
15563
+ }
15564
+ function getOutboxStats(db) {
15565
+ const rows = db.db.query("SELECT status, COUNT(*) as count FROM sync_outbox GROUP BY status").all();
15566
+ const stats = {
15567
+ pending: 0,
15568
+ syncing: 0,
15569
+ synced: 0,
15570
+ failed: 0
15571
+ };
15572
+ for (const row of rows) {
15573
+ stats[row.status] = row.count;
15574
+ }
15575
+ return stats;
15576
+ }
15577
+
15578
+ // src/tools/stats.ts
15579
+ function getMemoryStats(db) {
15580
+ const activeObservations = db.getActiveObservationCount();
15581
+ const messages = db.db.query(`SELECT COUNT(*) as count FROM observations
15582
+ WHERE type = 'message' AND lifecycle IN ('active', 'aging', 'pinned')`).get()?.count ?? 0;
15583
+ const sessionSummaries = db.db.query("SELECT COUNT(*) as count FROM session_summaries").get()?.count ?? 0;
15584
+ return {
15585
+ active_observations: activeObservations,
15586
+ messages,
15587
+ session_summaries: sessionSummaries,
15588
+ installed_packs: db.getInstalledPacks(),
15589
+ outbox: getOutboxStats(db)
15590
+ };
15591
+ }
15592
+
15593
+ // src/intelligence/followthrough.ts
15594
+ var FOLLOW_THROUGH_THRESHOLD = 0.25;
15595
+ var STALE_AFTER_DAYS = 3;
15596
+ var DECISION_WINDOW_DAYS = 30;
15597
+ var IMPLEMENTATION_TYPES = new Set([
15598
+ "feature",
15599
+ "bugfix",
15600
+ "change",
15601
+ "refactor"
15602
+ ]);
15603
+ function findStaleDecisions(db, projectId, options) {
15604
+ const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
15605
+ const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
15606
+ const nowEpoch = Math.floor(Date.now() / 1000);
15607
+ const windowStart = nowEpoch - windowDays * 86400;
15608
+ const staleThreshold = nowEpoch - staleAfterDays * 86400;
15609
+ const decisions = db.db.query(`SELECT * FROM observations
15610
+ WHERE project_id = ? AND type = 'decision'
15611
+ AND lifecycle IN ('active', 'aging', 'pinned')
15612
+ AND superseded_by IS NULL
15613
+ AND created_at_epoch >= ?
15614
+ ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
15615
+ if (decisions.length === 0)
15616
+ return [];
15617
+ const implementations = db.db.query(`SELECT * FROM observations
15618
+ WHERE project_id = ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
15619
+ AND lifecycle IN ('active', 'aging', 'pinned')
15620
+ AND superseded_by IS NULL
15621
+ AND created_at_epoch >= ?
15622
+ ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
15623
+ const crossProjectImpls = db.db.query(`SELECT * FROM observations
15624
+ WHERE project_id != ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
15625
+ AND lifecycle IN ('active', 'aging', 'pinned')
15626
+ AND superseded_by IS NULL
15627
+ AND created_at_epoch >= ?
15628
+ ORDER BY created_at_epoch DESC
15629
+ LIMIT 200`).all(projectId, windowStart);
15630
+ const allImpls = [...implementations, ...crossProjectImpls];
15631
+ const stale = [];
15632
+ for (const decision of decisions) {
15633
+ if (decision.created_at_epoch > staleThreshold)
15634
+ continue;
15635
+ const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
15636
+ let decisionConcepts = [];
15637
+ try {
15638
+ const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
15639
+ if (Array.isArray(parsed))
15640
+ decisionConcepts = parsed;
15641
+ } catch {}
15642
+ let bestTitle = "";
15643
+ let bestScore = 0;
15644
+ for (const impl of allImpls) {
15645
+ if (impl.created_at_epoch <= decision.created_at_epoch)
15646
+ continue;
15647
+ const titleScore = jaccardSimilarity(decision.title, impl.title);
15648
+ let conceptBoost = 0;
15649
+ if (decisionConcepts.length > 0) {
15650
+ try {
15651
+ const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
15652
+ if (Array.isArray(implConcepts) && implConcepts.length > 0) {
15653
+ const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
15654
+ const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
15655
+ conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
15656
+ }
15657
+ } catch {}
15658
+ }
15659
+ let narrativeBoost = 0;
15660
+ if (impl.narrative) {
15661
+ const decWords = new Set(decision.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3));
15662
+ if (decWords.size > 0) {
15663
+ const implNarrativeLower = impl.narrative.toLowerCase();
15664
+ const hits = [...decWords].filter((w) => implNarrativeLower.includes(w)).length;
15665
+ narrativeBoost = hits / decWords.size * 0.1;
15666
+ }
15667
+ }
15668
+ const totalScore = titleScore + conceptBoost + narrativeBoost;
15669
+ if (totalScore > bestScore) {
15670
+ bestScore = totalScore;
15671
+ bestTitle = impl.title;
15672
+ }
15673
+ }
15674
+ if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
15675
+ stale.push({
15676
+ id: decision.id,
15677
+ title: decision.title,
15678
+ narrative: decision.narrative,
15679
+ concepts: decisionConcepts,
15680
+ created_at: decision.created_at,
15681
+ days_ago: daysAgo,
15682
+ ...bestScore > 0.1 ? {
15683
+ best_match_title: bestTitle,
15684
+ best_match_similarity: Math.round(bestScore * 100) / 100
15685
+ } : {}
15686
+ });
15687
+ }
15688
+ }
15689
+ stale.sort((a, b) => b.days_ago - a.days_ago);
15690
+ return stale.slice(0, 5);
15691
+ }
15692
+ function findStaleDecisionsGlobal(db, options) {
15693
+ const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
15694
+ const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
15695
+ const nowEpoch = Math.floor(Date.now() / 1000);
15696
+ const windowStart = nowEpoch - windowDays * 86400;
15697
+ const staleThreshold = nowEpoch - staleAfterDays * 86400;
15698
+ const decisions = db.db.query(`SELECT * FROM observations
15699
+ WHERE type = 'decision'
15700
+ AND lifecycle IN ('active', 'aging', 'pinned')
15701
+ AND superseded_by IS NULL
15702
+ AND created_at_epoch >= ?
15703
+ ORDER BY created_at_epoch DESC`).all(windowStart);
15704
+ if (decisions.length === 0)
15705
+ return [];
15706
+ const implementations = db.db.query(`SELECT * FROM observations
15707
+ WHERE type IN ('feature', 'bugfix', 'change', 'refactor')
15708
+ AND lifecycle IN ('active', 'aging', 'pinned')
15709
+ AND superseded_by IS NULL
15710
+ AND created_at_epoch >= ?
15711
+ ORDER BY created_at_epoch DESC
15712
+ LIMIT 500`).all(windowStart);
15713
+ const stale = [];
15714
+ for (const decision of decisions) {
15715
+ if (decision.created_at_epoch > staleThreshold)
15716
+ continue;
15717
+ const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
15718
+ let decisionConcepts = [];
15719
+ try {
15720
+ const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
15721
+ if (Array.isArray(parsed))
15722
+ decisionConcepts = parsed;
15723
+ } catch {}
15724
+ let bestScore = 0;
15725
+ let bestTitle = "";
15726
+ for (const impl of implementations) {
15727
+ if (impl.created_at_epoch <= decision.created_at_epoch)
15728
+ continue;
15729
+ const titleScore = jaccardSimilarity(decision.title, impl.title);
15730
+ let conceptBoost = 0;
15731
+ if (decisionConcepts.length > 0) {
15732
+ try {
15733
+ const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
15734
+ if (Array.isArray(implConcepts) && implConcepts.length > 0) {
15735
+ const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
15736
+ const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
15737
+ conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
15738
+ }
15739
+ } catch {}
15740
+ }
15741
+ const totalScore = titleScore + conceptBoost;
15742
+ if (totalScore > bestScore) {
15743
+ bestScore = totalScore;
15744
+ bestTitle = impl.title;
15745
+ }
15746
+ }
15747
+ if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
15748
+ stale.push({
15749
+ id: decision.id,
15750
+ title: decision.title,
15751
+ narrative: decision.narrative,
15752
+ concepts: decisionConcepts,
15753
+ created_at: decision.created_at,
15754
+ days_ago: daysAgo,
15755
+ ...bestScore > 0.1 ? {
15756
+ best_match_title: bestTitle,
15757
+ best_match_similarity: Math.round(bestScore * 100) / 100
15758
+ } : {}
15759
+ });
15760
+ }
15761
+ }
15762
+ stale.sort((a, b) => b.days_ago - a.days_ago);
15763
+ return stale.slice(0, 5);
15764
+ }
15765
+
15460
15766
  // src/context/inject.ts
15461
15767
  var RECENCY_WINDOW_SECONDS = 30 * 86400;
15462
15768
  function computeBlendedScore(quality, createdAtEpoch, nowEpoch) {
@@ -15473,48 +15779,63 @@ function buildSessionContext(db, cwd, options = {}) {
15473
15779
  const opts = typeof options === "number" ? { maxCount: options } : options;
15474
15780
  const tokenBudget = opts.tokenBudget ?? 3000;
15475
15781
  const maxCount = opts.maxCount;
15782
+ const visibilityClause = opts.userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
15783
+ const visibilityParams = opts.userId ? [opts.userId] : [];
15476
15784
  const detected = detectProject(cwd);
15477
15785
  const project = db.getProjectByCanonicalId(detected.canonical_id);
15478
- if (!project) {
15479
- return {
15480
- project_name: detected.name,
15481
- canonical_id: detected.canonical_id,
15482
- observations: [],
15483
- session_count: 0,
15484
- total_active: 0
15485
- };
15486
- }
15487
- const totalActive = (db.db.query(`SELECT COUNT(*) as c FROM observations
15488
- WHERE project_id = ? AND lifecycle IN ('active', 'aging', 'pinned')
15489
- AND superseded_by IS NULL`).get(project.id) ?? { c: 0 }).c;
15490
- const MAX_PINNED = 5;
15491
- const pinned = db.db.query(`SELECT * FROM observations
15492
- WHERE project_id = ? AND lifecycle = 'pinned'
15493
- AND superseded_by IS NULL
15494
- ORDER BY quality DESC, created_at_epoch DESC
15495
- LIMIT ?`).all(project.id, MAX_PINNED);
15496
- const MAX_RECENT = 5;
15497
- const recent = db.db.query(`SELECT * FROM observations
15498
- WHERE project_id = ? AND lifecycle IN ('active', 'aging')
15499
- AND superseded_by IS NULL
15500
- ORDER BY created_at_epoch DESC
15501
- LIMIT ?`).all(project.id, MAX_RECENT);
15786
+ const projectId = project?.id ?? -1;
15787
+ const isNewProject = !project;
15788
+ const totalActive = isNewProject ? (db.db.query(`SELECT COUNT(*) as c FROM observations
15789
+ WHERE lifecycle IN ('active', 'aging', 'pinned')
15790
+ ${visibilityClause}
15791
+ AND superseded_by IS NULL`).get(...visibilityParams) ?? { c: 0 }).c : (db.db.query(`SELECT COUNT(*) as c FROM observations
15792
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging', 'pinned')
15793
+ ${visibilityClause}
15794
+ AND superseded_by IS NULL`).get(projectId, ...visibilityParams) ?? { c: 0 }).c;
15502
15795
  const candidateLimit = maxCount ?? 50;
15503
- const candidates = db.db.query(`SELECT * FROM observations
15504
- WHERE project_id = ? AND lifecycle IN ('active', 'aging')
15505
- AND quality >= 0.3
15506
- AND superseded_by IS NULL
15507
- ORDER BY quality DESC, created_at_epoch DESC
15508
- LIMIT ?`).all(project.id, candidateLimit);
15509
- let crossProjectCandidates = [];
15510
- if (opts.scope === "all") {
15511
- const crossLimit = Math.max(10, Math.floor(candidateLimit / 3));
15512
- const rawCross = db.db.query(`SELECT * FROM observations
15513
- WHERE project_id != ? AND lifecycle IN ('active', 'aging')
15514
- AND quality >= 0.5
15796
+ let pinned = [];
15797
+ let recent = [];
15798
+ let candidates = [];
15799
+ if (!isNewProject) {
15800
+ const MAX_PINNED = 5;
15801
+ pinned = db.db.query(`SELECT * FROM observations
15802
+ WHERE project_id = ? AND lifecycle = 'pinned'
15515
15803
  AND superseded_by IS NULL
15804
+ ${visibilityClause}
15516
15805
  ORDER BY quality DESC, created_at_epoch DESC
15517
- LIMIT ?`).all(project.id, crossLimit);
15806
+ LIMIT ?`).all(projectId, ...visibilityParams, MAX_PINNED);
15807
+ const MAX_RECENT = 5;
15808
+ recent = db.db.query(`SELECT * FROM observations
15809
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
15810
+ AND superseded_by IS NULL
15811
+ ${visibilityClause}
15812
+ ORDER BY created_at_epoch DESC
15813
+ LIMIT ?`).all(projectId, ...visibilityParams, MAX_RECENT);
15814
+ candidates = db.db.query(`SELECT * FROM observations
15815
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
15816
+ AND quality >= 0.3
15817
+ AND superseded_by IS NULL
15818
+ ${visibilityClause}
15819
+ ORDER BY quality DESC, created_at_epoch DESC
15820
+ LIMIT ?`).all(projectId, ...visibilityParams, candidateLimit);
15821
+ }
15822
+ let crossProjectCandidates = [];
15823
+ if (opts.scope === "all" || isNewProject) {
15824
+ const crossLimit = isNewProject ? Math.max(30, candidateLimit) : Math.max(10, Math.floor(candidateLimit / 3));
15825
+ const qualityThreshold = isNewProject ? 0.3 : 0.5;
15826
+ const rawCross = isNewProject ? db.db.query(`SELECT * FROM observations
15827
+ WHERE lifecycle IN ('active', 'aging', 'pinned')
15828
+ AND quality >= ?
15829
+ AND superseded_by IS NULL
15830
+ ${visibilityClause}
15831
+ ORDER BY quality DESC, created_at_epoch DESC
15832
+ LIMIT ?`).all(qualityThreshold, ...visibilityParams, crossLimit) : db.db.query(`SELECT * FROM observations
15833
+ WHERE project_id != ? AND lifecycle IN ('active', 'aging')
15834
+ AND quality >= ?
15835
+ AND superseded_by IS NULL
15836
+ ${visibilityClause}
15837
+ ORDER BY quality DESC, created_at_epoch DESC
15838
+ LIMIT ?`).all(projectId, qualityThreshold, ...visibilityParams, crossLimit);
15518
15839
  const projectNameCache = new Map;
15519
15840
  crossProjectCandidates = rawCross.map((obs) => {
15520
15841
  if (!projectNameCache.has(obs.project_id)) {
@@ -15547,12 +15868,14 @@ function buildSessionContext(db, cwd, options = {}) {
15547
15868
  const scoreB = computeBlendedScore(b.quality, b.created_at_epoch, nowEpoch) + boostB;
15548
15869
  return scoreB - scoreA;
15549
15870
  });
15871
+ const projectName = project?.name ?? detected.name;
15872
+ const canonicalId = project?.canonical_id ?? detected.canonical_id;
15550
15873
  if (maxCount !== undefined) {
15551
15874
  const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
15552
15875
  const all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
15553
15876
  return {
15554
- project_name: project.name,
15555
- canonical_id: project.canonical_id,
15877
+ project_name: projectName,
15878
+ canonical_id: canonicalId,
15556
15879
  observations: all.map(toContextObservation),
15557
15880
  session_count: all.length,
15558
15881
  total_active: totalActive
@@ -15577,23 +15900,61 @@ function buildSessionContext(db, cwd, options = {}) {
15577
15900
  remainingBudget -= cost;
15578
15901
  selected.push(obs);
15579
15902
  }
15580
- const summaries = db.getRecentSummaries(project.id, 5);
15903
+ const summaries = isNewProject ? [] : db.getRecentSummaries(projectId, 5);
15581
15904
  let securityFindings = [];
15905
+ if (!isNewProject) {
15906
+ try {
15907
+ const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
15908
+ securityFindings = db.db.query(`SELECT * FROM security_findings
15909
+ WHERE project_id = ? AND created_at_epoch > ?
15910
+ ORDER BY severity DESC, created_at_epoch DESC
15911
+ LIMIT ?`).all(projectId, weekAgo, 10);
15912
+ } catch {}
15913
+ }
15914
+ let recentProjects;
15915
+ if (isNewProject) {
15916
+ try {
15917
+ const nowEpochSec = Math.floor(Date.now() / 1000);
15918
+ const projectRows = db.db.query(`SELECT p.name, p.canonical_id, p.last_active_epoch,
15919
+ (SELECT COUNT(*) FROM observations o
15920
+ WHERE o.project_id = p.id
15921
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
15922
+ ${opts.userId ? "AND (o.sensitivity != 'personal' OR o.user_id = ?)" : ""}
15923
+ AND o.superseded_by IS NULL) as obs_count
15924
+ FROM projects p
15925
+ ORDER BY p.last_active_epoch DESC
15926
+ LIMIT 10`).all(...visibilityParams);
15927
+ if (projectRows.length > 0) {
15928
+ recentProjects = projectRows.map((r) => {
15929
+ const daysAgo = Math.max(0, Math.floor((nowEpochSec - r.last_active_epoch) / 86400));
15930
+ const lastActive = new Date(r.last_active_epoch * 1000).toISOString().split("T")[0];
15931
+ return {
15932
+ name: r.name,
15933
+ canonical_id: r.canonical_id,
15934
+ observation_count: r.obs_count,
15935
+ last_active: lastActive,
15936
+ days_ago: daysAgo
15937
+ };
15938
+ });
15939
+ }
15940
+ } catch {}
15941
+ }
15942
+ let staleDecisions;
15582
15943
  try {
15583
- const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
15584
- securityFindings = db.db.query(`SELECT * FROM security_findings
15585
- WHERE project_id = ? AND created_at_epoch > ?
15586
- ORDER BY severity DESC, created_at_epoch DESC
15587
- LIMIT ?`).all(project.id, weekAgo, 10);
15944
+ const stale = isNewProject ? findStaleDecisionsGlobal(db) : findStaleDecisions(db, projectId);
15945
+ if (stale.length > 0)
15946
+ staleDecisions = stale;
15588
15947
  } catch {}
15589
15948
  return {
15590
- project_name: project.name,
15591
- canonical_id: project.canonical_id,
15949
+ project_name: projectName,
15950
+ canonical_id: canonicalId,
15592
15951
  observations: selected.map(toContextObservation),
15593
15952
  session_count: selected.length,
15594
15953
  total_active: totalActive,
15595
15954
  summaries: summaries.length > 0 ? summaries : undefined,
15596
- securityFindings: securityFindings.length > 0 ? securityFindings : undefined
15955
+ securityFindings: securityFindings.length > 0 ? securityFindings : undefined,
15956
+ recentProjects,
15957
+ staleDecisions
15597
15958
  };
15598
15959
  }
15599
15960
  function estimateObservationTokens(obs, index) {
@@ -15610,11 +15971,25 @@ function formatContextForInjection(context) {
15610
15971
  return `Project: ${context.project_name} (no prior observations)`;
15611
15972
  }
15612
15973
  const DETAILED_COUNT = 5;
15613
- const lines = [
15614
- `## Project Memory: ${context.project_name}`,
15615
- `${context.session_count} relevant observation(s) from prior sessions:`,
15616
- ""
15617
- ];
15974
+ const isCrossProject = context.recentProjects && context.recentProjects.length > 0;
15975
+ const lines = [];
15976
+ if (isCrossProject) {
15977
+ lines.push(`## Engrm Memory — Workspace Overview`);
15978
+ lines.push(`This is a new project folder. Here is context from your recent work:`);
15979
+ lines.push("");
15980
+ lines.push("**Active projects in memory:**");
15981
+ for (const rp of context.recentProjects) {
15982
+ const activity = rp.days_ago === 0 ? "today" : rp.days_ago === 1 ? "yesterday" : `${rp.days_ago}d ago`;
15983
+ lines.push(`- **${rp.name}** — ${rp.observation_count} observations, last active ${activity}`);
15984
+ }
15985
+ lines.push("");
15986
+ lines.push(`${context.session_count} relevant observation(s) from across projects:`);
15987
+ lines.push("");
15988
+ } else {
15989
+ lines.push(`## Project Memory: ${context.project_name}`);
15990
+ lines.push(`${context.session_count} relevant observation(s) from prior sessions:`);
15991
+ lines.push("");
15992
+ }
15618
15993
  for (let i = 0;i < context.observations.length; i++) {
15619
15994
  const obs = context.observations[i];
15620
15995
  const date5 = obs.created_at.split("T")[0];
@@ -15629,17 +16004,10 @@ function formatContextForInjection(context) {
15629
16004
  }
15630
16005
  if (context.summaries && context.summaries.length > 0) {
15631
16006
  lines.push("");
15632
- lines.push("Lessons from recent sessions:");
15633
- for (const summary of context.summaries) {
15634
- if (summary.request) {
15635
- lines.push(`- Request: ${summary.request}`);
15636
- }
15637
- if (summary.learned) {
15638
- lines.push(` Learned: ${truncateText(summary.learned, 100)}`);
15639
- }
15640
- if (summary.next_steps) {
15641
- lines.push(` Next: ${truncateText(summary.next_steps, 80)}`);
15642
- }
16007
+ lines.push("## Recent Project Briefs");
16008
+ for (const summary of context.summaries.slice(0, 3)) {
16009
+ lines.push(...formatSessionBrief(summary));
16010
+ lines.push("");
15643
16011
  }
15644
16012
  }
15645
16013
  if (context.securityFindings && context.securityFindings.length > 0) {
@@ -15651,6 +16019,17 @@ function formatContextForInjection(context) {
15651
16019
  lines.push(`- [${finding.severity.toUpperCase()}] ${finding.pattern_name}${file2} (${date5})`);
15652
16020
  }
15653
16021
  }
16022
+ if (context.staleDecisions && context.staleDecisions.length > 0) {
16023
+ lines.push("");
16024
+ lines.push("Stale commitments (decided but no implementation observed):");
16025
+ for (const sd of context.staleDecisions) {
16026
+ const date5 = sd.created_at.split("T")[0];
16027
+ lines.push(`- [DECISION] ${sd.title} (${date5}, ${sd.days_ago}d ago)`);
16028
+ if (sd.best_match_title) {
16029
+ lines.push(` Closest match: "${sd.best_match_title}" (${Math.round((sd.best_match_similarity ?? 0) * 100)}% similar — not enough to count as done)`);
16030
+ }
16031
+ }
16032
+ }
15654
16033
  const remaining = context.total_active - context.session_count;
15655
16034
  if (remaining > 0) {
15656
16035
  lines.push("");
@@ -15659,6 +16038,44 @@ function formatContextForInjection(context) {
15659
16038
  return lines.join(`
15660
16039
  `);
15661
16040
  }
16041
+ function formatSessionBrief(summary) {
16042
+ const lines = [];
16043
+ const heading = summary.request ? `### ${truncateText(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
16044
+ lines.push(heading);
16045
+ const sections = [
16046
+ ["Investigated", summary.investigated, 180],
16047
+ ["Learned", summary.learned, 180],
16048
+ ["Completed", summary.completed, 180],
16049
+ ["Next Steps", summary.next_steps, 140]
16050
+ ];
16051
+ for (const [label, value, maxLen] of sections) {
16052
+ const formatted = formatSummarySection(value, maxLen);
16053
+ if (formatted) {
16054
+ lines.push(`${label}:`);
16055
+ lines.push(formatted);
16056
+ }
16057
+ }
16058
+ return lines;
16059
+ }
16060
+ function formatSummarySection(value, maxLen) {
16061
+ if (!value)
16062
+ return null;
16063
+ const cleaned = value.split(`
16064
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.startsWith("-") ? line : `- ${line}`).join(`
16065
+ `);
16066
+ if (!cleaned)
16067
+ return null;
16068
+ return truncateMultilineText(cleaned, maxLen);
16069
+ }
16070
+ function truncateMultilineText(text, maxLen) {
16071
+ if (text.length <= maxLen)
16072
+ return text;
16073
+ const truncated = text.slice(0, maxLen).trimEnd();
16074
+ const lastBreak = Math.max(truncated.lastIndexOf(`
16075
+ `), truncated.lastIndexOf(" "));
16076
+ const safe = lastBreak > maxLen * 0.5 ? truncated.slice(0, lastBreak) : truncated;
16077
+ return `${safe.trimEnd()}…`;
16078
+ }
15662
16079
  function truncateText(text, maxLen) {
15663
16080
  if (text.length <= maxLen)
15664
16081
  return text;
@@ -16039,32 +16456,6 @@ class VectorApiError extends Error {
16039
16456
  }
16040
16457
  }
16041
16458
 
16042
- // src/storage/outbox.ts
16043
- function getPendingEntries(db, limit = 50) {
16044
- const now = Math.floor(Date.now() / 1000);
16045
- return db.db.query(`SELECT * FROM sync_outbox
16046
- WHERE (status = 'pending')
16047
- OR (status = 'failed' AND retry_count < max_retries AND (next_retry_epoch IS NULL OR next_retry_epoch <= ?))
16048
- ORDER BY created_at_epoch ASC
16049
- LIMIT ?`).all(now, limit);
16050
- }
16051
- function markSyncing(db, entryId) {
16052
- db.db.query("UPDATE sync_outbox SET status = 'syncing' WHERE id = ?").run(entryId);
16053
- }
16054
- function markSynced(db, entryId) {
16055
- const now = Math.floor(Date.now() / 1000);
16056
- db.db.query("UPDATE sync_outbox SET status = 'synced', synced_at_epoch = ? WHERE id = ?").run(now, entryId);
16057
- }
16058
- function markFailed(db, entryId, error48) {
16059
- const now = Math.floor(Date.now() / 1000);
16060
- db.db.query(`UPDATE sync_outbox SET
16061
- status = 'failed',
16062
- retry_count = retry_count + 1,
16063
- last_error = ?,
16064
- next_retry_epoch = ? + MIN(30 * (1 << retry_count), 3600)
16065
- WHERE id = ?`).run(error48, now, entryId);
16066
- }
16067
-
16068
16459
  // src/sync/push.ts
16069
16460
  function buildVectorDocument(obs, config2, project) {
16070
16461
  const parts = [obs.title];
@@ -16107,6 +16498,8 @@ function buildVectorDocument(obs, config2, project) {
16107
16498
  files_modified: obs.files_modified ? JSON.parse(obs.files_modified) : [],
16108
16499
  session_id: obs.session_id,
16109
16500
  created_at_epoch: obs.created_at_epoch,
16501
+ created_at: obs.created_at,
16502
+ sensitivity: obs.sensitivity,
16110
16503
  local_id: obs.id
16111
16504
  }
16112
16505
  };
@@ -16284,10 +16677,12 @@ function mergeChanges(db, config2, changes) {
16284
16677
  concepts: change.metadata?.concepts ? JSON.stringify(change.metadata.concepts) : null,
16285
16678
  quality: change.metadata?.quality ?? 0.5,
16286
16679
  lifecycle: "active",
16287
- sensitivity: "shared",
16680
+ sensitivity: change.metadata?.sensitivity ?? "shared",
16288
16681
  user_id: change.metadata?.user_id ?? "unknown",
16289
16682
  device_id: change.metadata?.device_id ?? "unknown",
16290
- agent: change.metadata?.agent ?? "unknown"
16683
+ agent: change.metadata?.agent ?? "unknown",
16684
+ created_at: change.metadata?.created_at ?? undefined,
16685
+ created_at_epoch: change.metadata?.created_at_epoch ?? undefined
16291
16686
  });
16292
16687
  db.db.query("UPDATE observations SET remote_source_id = ? WHERE id = ?").run(change.source_id, obs.id);
16293
16688
  if (db.vecAvailable) {
@@ -16471,6 +16866,9 @@ function loadPack(name) {
16471
16866
  }
16472
16867
 
16473
16868
  // src/server.ts
16869
+ import { existsSync as existsSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
16870
+ import { join as join4 } from "node:path";
16871
+ import { homedir as homedir2 } from "node:os";
16474
16872
  if (!configExists()) {
16475
16873
  console.error("Engrm is not configured. Run: engrm init --manual");
16476
16874
  process.exit(1);
@@ -16479,6 +16877,23 @@ var config2 = loadConfig();
16479
16877
  var db = new MemDatabase(getDbPath());
16480
16878
  var contextServed = false;
16481
16879
  var _lastSearchSessionId = null;
16880
+ var sessionMetrics = {
16881
+ contextObsInjected: 0,
16882
+ contextTotalAvailable: 0,
16883
+ recallAttempts: 0,
16884
+ recallHits: 0,
16885
+ searchCount: 0,
16886
+ searchResultsTotal: 0
16887
+ };
16888
+ var MCP_METRICS_PATH = join4(homedir2(), ".engrm", "mcp-session-metrics.json");
16889
+ function persistSessionMetrics() {
16890
+ try {
16891
+ const dir = join4(homedir2(), ".engrm");
16892
+ if (!existsSync4(dir))
16893
+ mkdirSync2(dir, { recursive: true });
16894
+ writeFileSync2(MCP_METRICS_PATH, JSON.stringify(sessionMetrics), "utf-8");
16895
+ } catch {}
16896
+ }
16482
16897
  var _detectedAgent = null;
16483
16898
  function getDetectedAgent() {
16484
16899
  if (_detectedAgent)
@@ -16589,8 +17004,14 @@ server.tool("search", "Search memory for observations", {
16589
17004
  project_scoped: exports_external.boolean().optional().describe("Scope to project (default: true)"),
16590
17005
  limit: exports_external.number().optional().describe("Max results (default: 10)")
16591
17006
  }, async (params) => {
16592
- const result = await searchObservations(db, params);
17007
+ const result = await searchObservations(db, {
17008
+ ...params,
17009
+ user_id: config2.user_id
17010
+ });
16593
17011
  _lastSearchSessionId = "active";
17012
+ sessionMetrics.searchCount++;
17013
+ sessionMetrics.searchResultsTotal += result.total;
17014
+ persistSessionMetrics();
16594
17015
  if (result.total === 0) {
16595
17016
  return {
16596
17017
  content: [
@@ -16627,7 +17048,10 @@ ${rows.join(`
16627
17048
  server.tool("get_observations", "Get observations by ID", {
16628
17049
  ids: exports_external.array(exports_external.number()).describe("Observation IDs")
16629
17050
  }, async (params) => {
16630
- const result = getObservations(db, params);
17051
+ const result = getObservations(db, {
17052
+ ...params,
17053
+ user_id: config2.user_id
17054
+ });
16631
17055
  if (result.observations.length === 0) {
16632
17056
  return {
16633
17057
  content: [
@@ -16682,7 +17106,8 @@ server.tool("timeline", "Timeline around an observation", {
16682
17106
  anchor_id: params.anchor,
16683
17107
  depth_before: params.depth_before,
16684
17108
  depth_after: params.depth_after,
16685
- project_scoped: params.project_scoped
17109
+ project_scoped: params.project_scoped,
17110
+ user_id: config2.user_id
16686
17111
  });
16687
17112
  if (result.observations.length === 0) {
16688
17113
  return {
@@ -16734,8 +17159,12 @@ server.tool("check_messages", "Check for messages sent from other devices or ses
16734
17159
  const readKey = `messages_read_${config2.device_id}`;
16735
17160
  const lastReadId = parseInt(db.getSyncState(readKey) ?? "0", 10);
16736
17161
  const messages = db.db.query(`SELECT id, title, narrative, user_id, device_id, created_at FROM observations
16737
- WHERE type = 'message' AND id > ? AND lifecycle IN ('active', 'pinned')
16738
- ORDER BY created_at_epoch DESC LIMIT 20`).all(lastReadId);
17162
+ WHERE type = 'message'
17163
+ AND id > ?
17164
+ AND lifecycle IN ('active', 'pinned')
17165
+ AND device_id != ?
17166
+ AND (sensitivity != 'personal' OR user_id = ?)
17167
+ ORDER BY created_at_epoch DESC LIMIT 20`).all(lastReadId, config2.device_id, config2.user_id);
16739
17168
  if (messages.length === 0) {
16740
17169
  return {
16741
17170
  content: [{ type: "text", text: "No new messages." }]
@@ -16763,6 +17192,87 @@ ${lines.join(`
16763
17192
  }]
16764
17193
  };
16765
17194
  });
17195
+ server.tool("send_message", "Leave a cross-device or team note in Engrm's shared inbox", {
17196
+ title: exports_external.string().describe("Short message title"),
17197
+ narrative: exports_external.string().optional().describe("Optional message body"),
17198
+ concepts: exports_external.array(exports_external.string()).optional().describe("Optional tags"),
17199
+ session_id: exports_external.string().optional()
17200
+ }, async (params) => {
17201
+ const result = await sendMessage(db, config2, {
17202
+ ...params,
17203
+ cwd: process.cwd()
17204
+ });
17205
+ return {
17206
+ content: [
17207
+ {
17208
+ type: "text",
17209
+ text: result.success ? `Message saved as observation #${result.observation_id}` : `Failed: ${result.reason}`
17210
+ }
17211
+ ]
17212
+ };
17213
+ });
17214
+ server.tool("recent_activity", "Inspect the most recent observations captured by Engrm", {
17215
+ limit: exports_external.number().optional().describe("Max observations to return (default: 10)"),
17216
+ project_scoped: exports_external.boolean().optional().describe("Scope to current project (default: true)"),
17217
+ type: exports_external.string().optional().describe("Optional observation type filter")
17218
+ }, async (params) => {
17219
+ const result = getRecentActivity(db, {
17220
+ ...params,
17221
+ cwd: process.cwd(),
17222
+ user_id: config2.user_id
17223
+ });
17224
+ if (result.observations.length === 0) {
17225
+ return {
17226
+ content: [
17227
+ {
17228
+ type: "text",
17229
+ text: result.project ? `No recent observations found in project ${result.project}.` : "No recent observations found."
17230
+ }
17231
+ ]
17232
+ };
17233
+ }
17234
+ const showProject = !result.project;
17235
+ const header = showProject ? "| ID | Project | Type | Title | Created |" : "| ID | Type | Title | Created |";
17236
+ const separator = showProject ? "|---|---|---|---|---|" : "|---|---|---|---|";
17237
+ const rows = result.observations.map((obs) => {
17238
+ const date5 = obs.created_at.split("T")[0];
17239
+ if (showProject) {
17240
+ return `| ${obs.id} | ${obs.project_name ?? "(unknown)"} | ${obs.type} | ${obs.title} | ${date5} |`;
17241
+ }
17242
+ return `| ${obs.id} | ${obs.type} | ${obs.title} | ${date5} |`;
17243
+ });
17244
+ const projectLine = result.project ? `Project: ${result.project}
17245
+ ` : "";
17246
+ return {
17247
+ content: [
17248
+ {
17249
+ type: "text",
17250
+ text: `${projectLine}Recent activity:
17251
+
17252
+ ${header}
17253
+ ${separator}
17254
+ ${rows.join(`
17255
+ `)}`
17256
+ }
17257
+ ]
17258
+ };
17259
+ });
17260
+ server.tool("memory_stats", "Show high-level Engrm capture and sync statistics", {}, async () => {
17261
+ const stats = getMemoryStats(db);
17262
+ const packs = stats.installed_packs.length > 0 ? stats.installed_packs.join(", ") : "(none)";
17263
+ return {
17264
+ content: [
17265
+ {
17266
+ type: "text",
17267
+ text: `Active observations: ${stats.active_observations}
17268
+ ` + `Messages: ${stats.messages}
17269
+ ` + `Session summaries: ${stats.session_summaries}
17270
+ ` + `Installed packs: ${packs}
17271
+ ` + `Outbox: pending ${stats.outbox.pending ?? 0}, failed ${stats.outbox.failed ?? 0}, synced ${stats.outbox.synced ?? 0}`
17272
+ }
17273
+ ]
17274
+ };
17275
+ });
16766
17276
  function formatTimeAgo(isoDate) {
16767
17277
  const diff = Date.now() - new Date(isoDate).getTime();
16768
17278
  const mins = Math.floor(diff / 60000);
@@ -16834,7 +17344,7 @@ server.tool("session_context", "Load project memory for this session", {
16834
17344
  ]
16835
17345
  };
16836
17346
  }
16837
- const context = buildSessionContext(db, process.cwd(), params.max_observations ? { maxCount: params.max_observations } : { tokenBudget: 800 });
17347
+ const context = buildSessionContext(db, process.cwd(), params.max_observations ? { maxCount: params.max_observations, userId: config2.user_id } : { tokenBudget: 800, userId: config2.user_id });
16838
17348
  if (!context || context.observations.length === 0) {
16839
17349
  return {
16840
17350
  content: [
@@ -16846,6 +17356,9 @@ server.tool("session_context", "Load project memory for this session", {
16846
17356
  };
16847
17357
  }
16848
17358
  contextServed = true;
17359
+ sessionMetrics.contextObsInjected = context.observations.length;
17360
+ sessionMetrics.contextTotalAvailable = context.total_active;
17361
+ persistSessionMetrics();
16849
17362
  return {
16850
17363
  content: [
16851
17364
  {
@@ -16874,3 +17387,6 @@ main().catch((error48) => {
16874
17387
  db.close();
16875
17388
  process.exit(1);
16876
17389
  });
17390
+ export {
17391
+ sessionMetrics
17392
+ };