engrm 0.4.1 → 0.4.4

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.
@@ -699,8 +699,8 @@ class MemDatabase {
699
699
  return this.db.query("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
700
700
  }
701
701
  insertObservation(obs) {
702
- const now = Math.floor(Date.now() / 1000);
703
- const createdAt = new Date().toISOString();
702
+ const now = obs.created_at_epoch ?? Math.floor(Date.now() / 1000);
703
+ const createdAt = obs.created_at ?? new Date(now * 1000).toISOString();
704
704
  const result = this.db.query(`INSERT INTO observations (
705
705
  session_id, project_id, type, title, narrative, facts, concepts,
706
706
  files_read, files_modified, quality, lifecycle, sensitivity,
@@ -717,11 +717,14 @@ class MemDatabase {
717
717
  getObservationById(id) {
718
718
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
719
719
  }
720
- getObservationsByIds(ids) {
720
+ getObservationsByIds(ids, userId) {
721
721
  if (ids.length === 0)
722
722
  return [];
723
723
  const placeholders = ids.map(() => "?").join(",");
724
- return this.db.query(`SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch DESC`).all(...ids);
724
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
725
+ return this.db.query(`SELECT * FROM observations
726
+ WHERE id IN (${placeholders})${visibilityClause}
727
+ ORDER BY created_at_epoch DESC`).all(...ids, ...userId ? [userId] : []);
725
728
  }
726
729
  getRecentObservations(projectId, sincEpoch, limit = 50) {
727
730
  return this.db.query(`SELECT * FROM observations
@@ -729,8 +732,9 @@ class MemDatabase {
729
732
  ORDER BY created_at_epoch DESC
730
733
  LIMIT ?`).all(projectId, sincEpoch, limit);
731
734
  }
732
- searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
735
+ searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
733
736
  const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
737
+ const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
734
738
  if (projectId !== null) {
735
739
  return this.db.query(`SELECT o.id, observations_fts.rank
736
740
  FROM observations_fts
@@ -738,33 +742,39 @@ class MemDatabase {
738
742
  WHERE observations_fts MATCH ?
739
743
  AND o.project_id = ?
740
744
  AND o.lifecycle IN (${lifecyclePlaceholders})
745
+ ${visibilityClause}
741
746
  ORDER BY observations_fts.rank
742
- LIMIT ?`).all(query, projectId, ...lifecycles, limit);
747
+ LIMIT ?`).all(query, projectId, ...lifecycles, ...userId ? [userId] : [], limit);
743
748
  }
744
749
  return this.db.query(`SELECT o.id, observations_fts.rank
745
750
  FROM observations_fts
746
751
  JOIN observations o ON o.id = observations_fts.rowid
747
752
  WHERE observations_fts MATCH ?
748
753
  AND o.lifecycle IN (${lifecyclePlaceholders})
754
+ ${visibilityClause}
749
755
  ORDER BY observations_fts.rank
750
- LIMIT ?`).all(query, ...lifecycles, limit);
756
+ LIMIT ?`).all(query, ...lifecycles, ...userId ? [userId] : [], limit);
751
757
  }
752
- getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3) {
753
- const anchor = this.getObservationById(anchorId);
758
+ getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3, userId) {
759
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
760
+ const anchor = this.db.query(`SELECT * FROM observations WHERE id = ?${visibilityClause}`).get(anchorId, ...userId ? [userId] : []) ?? null;
754
761
  if (!anchor)
755
762
  return [];
756
763
  const projectFilter = projectId !== null ? "AND project_id = ?" : "";
757
764
  const projectParams = projectId !== null ? [projectId] : [];
765
+ const visibilityParams = userId ? [userId] : [];
758
766
  const before = this.db.query(`SELECT * FROM observations
759
767
  WHERE created_at_epoch < ? ${projectFilter}
760
768
  AND lifecycle IN ('active', 'aging', 'pinned')
769
+ ${visibilityClause}
761
770
  ORDER BY created_at_epoch DESC
762
- LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthBefore);
771
+ LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthBefore);
763
772
  const after = this.db.query(`SELECT * FROM observations
764
773
  WHERE created_at_epoch > ? ${projectFilter}
765
774
  AND lifecycle IN ('active', 'aging', 'pinned')
775
+ ${visibilityClause}
766
776
  ORDER BY created_at_epoch ASC
767
- LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthAfter);
777
+ LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthAfter);
768
778
  return [...before.reverse(), anchor, ...after];
769
779
  }
770
780
  pinObservation(id, pinned) {
@@ -878,11 +888,12 @@ class MemDatabase {
878
888
  return;
879
889
  this.db.query("DELETE FROM vec_observations WHERE observation_id = ?").run(observationId);
880
890
  }
881
- searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
891
+ searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
882
892
  if (!this.vecAvailable)
883
893
  return [];
884
894
  const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
885
895
  const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
896
+ const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
886
897
  if (projectId !== null) {
887
898
  return this.db.query(`SELECT v.observation_id, v.distance
888
899
  FROM vec_observations v
@@ -891,7 +902,7 @@ class MemDatabase {
891
902
  AND k = ?
892
903
  AND o.project_id = ?
893
904
  AND o.lifecycle IN (${lifecyclePlaceholders})
894
- AND o.superseded_by IS NULL`).all(embeddingBlob, limit, projectId, ...lifecycles);
905
+ AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, projectId, ...lifecycles, ...userId ? [userId] : []);
895
906
  }
896
907
  return this.db.query(`SELECT v.observation_id, v.distance
897
908
  FROM vec_observations v
@@ -899,7 +910,7 @@ class MemDatabase {
899
910
  WHERE v.embedding MATCH ?
900
911
  AND k = ?
901
912
  AND o.lifecycle IN (${lifecyclePlaceholders})
902
- AND o.superseded_by IS NULL`).all(embeddingBlob, limit, ...lifecycles);
913
+ AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, ...lifecycles, ...userId ? [userId] : []);
903
914
  }
904
915
  getUnembeddedCount() {
905
916
  if (!this.vecAvailable)
@@ -1122,6 +1133,216 @@ function detectProject(directory) {
1122
1133
  };
1123
1134
  }
1124
1135
 
1136
+ // src/capture/dedup.ts
1137
+ function tokenise(text) {
1138
+ const cleaned = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
1139
+ const tokens = cleaned.split(/\s+/).filter((t) => t.length > 0);
1140
+ return new Set(tokens);
1141
+ }
1142
+ function jaccardSimilarity(a, b) {
1143
+ const tokensA = tokenise(a);
1144
+ const tokensB = tokenise(b);
1145
+ if (tokensA.size === 0 && tokensB.size === 0)
1146
+ return 1;
1147
+ if (tokensA.size === 0 || tokensB.size === 0)
1148
+ return 0;
1149
+ let intersectionSize = 0;
1150
+ for (const token of tokensA) {
1151
+ if (tokensB.has(token))
1152
+ intersectionSize++;
1153
+ }
1154
+ const unionSize = tokensA.size + tokensB.size - intersectionSize;
1155
+ if (unionSize === 0)
1156
+ return 0;
1157
+ return intersectionSize / unionSize;
1158
+ }
1159
+ var DEDUP_THRESHOLD = 0.8;
1160
+ function findDuplicate(newTitle, candidates) {
1161
+ let bestMatch = null;
1162
+ let bestScore = 0;
1163
+ for (const candidate of candidates) {
1164
+ const similarity = jaccardSimilarity(newTitle, candidate.title);
1165
+ if (similarity > DEDUP_THRESHOLD && similarity > bestScore) {
1166
+ bestScore = similarity;
1167
+ bestMatch = candidate;
1168
+ }
1169
+ }
1170
+ return bestMatch;
1171
+ }
1172
+
1173
+ // src/intelligence/followthrough.ts
1174
+ var FOLLOW_THROUGH_THRESHOLD = 0.25;
1175
+ var STALE_AFTER_DAYS = 3;
1176
+ var DECISION_WINDOW_DAYS = 30;
1177
+ var IMPLEMENTATION_TYPES = new Set([
1178
+ "feature",
1179
+ "bugfix",
1180
+ "change",
1181
+ "refactor"
1182
+ ]);
1183
+ function findStaleDecisions(db, projectId, options) {
1184
+ const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
1185
+ const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
1186
+ const nowEpoch = Math.floor(Date.now() / 1000);
1187
+ const windowStart = nowEpoch - windowDays * 86400;
1188
+ const staleThreshold = nowEpoch - staleAfterDays * 86400;
1189
+ const decisions = db.db.query(`SELECT * FROM observations
1190
+ WHERE project_id = ? AND type = 'decision'
1191
+ AND lifecycle IN ('active', 'aging', 'pinned')
1192
+ AND superseded_by IS NULL
1193
+ AND created_at_epoch >= ?
1194
+ ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
1195
+ if (decisions.length === 0)
1196
+ return [];
1197
+ const implementations = db.db.query(`SELECT * FROM observations
1198
+ WHERE project_id = ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
1199
+ AND lifecycle IN ('active', 'aging', 'pinned')
1200
+ AND superseded_by IS NULL
1201
+ AND created_at_epoch >= ?
1202
+ ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
1203
+ const crossProjectImpls = db.db.query(`SELECT * FROM observations
1204
+ WHERE project_id != ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
1205
+ AND lifecycle IN ('active', 'aging', 'pinned')
1206
+ AND superseded_by IS NULL
1207
+ AND created_at_epoch >= ?
1208
+ ORDER BY created_at_epoch DESC
1209
+ LIMIT 200`).all(projectId, windowStart);
1210
+ const allImpls = [...implementations, ...crossProjectImpls];
1211
+ const stale = [];
1212
+ for (const decision of decisions) {
1213
+ if (decision.created_at_epoch > staleThreshold)
1214
+ continue;
1215
+ const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
1216
+ let decisionConcepts = [];
1217
+ try {
1218
+ const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
1219
+ if (Array.isArray(parsed))
1220
+ decisionConcepts = parsed;
1221
+ } catch {}
1222
+ let bestTitle = "";
1223
+ let bestScore = 0;
1224
+ for (const impl of allImpls) {
1225
+ if (impl.created_at_epoch <= decision.created_at_epoch)
1226
+ continue;
1227
+ const titleScore = jaccardSimilarity(decision.title, impl.title);
1228
+ let conceptBoost = 0;
1229
+ if (decisionConcepts.length > 0) {
1230
+ try {
1231
+ const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
1232
+ if (Array.isArray(implConcepts) && implConcepts.length > 0) {
1233
+ const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
1234
+ const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
1235
+ conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
1236
+ }
1237
+ } catch {}
1238
+ }
1239
+ let narrativeBoost = 0;
1240
+ if (impl.narrative) {
1241
+ const decWords = new Set(decision.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3));
1242
+ if (decWords.size > 0) {
1243
+ const implNarrativeLower = impl.narrative.toLowerCase();
1244
+ const hits = [...decWords].filter((w) => implNarrativeLower.includes(w)).length;
1245
+ narrativeBoost = hits / decWords.size * 0.1;
1246
+ }
1247
+ }
1248
+ const totalScore = titleScore + conceptBoost + narrativeBoost;
1249
+ if (totalScore > bestScore) {
1250
+ bestScore = totalScore;
1251
+ bestTitle = impl.title;
1252
+ }
1253
+ }
1254
+ if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
1255
+ stale.push({
1256
+ id: decision.id,
1257
+ title: decision.title,
1258
+ narrative: decision.narrative,
1259
+ concepts: decisionConcepts,
1260
+ created_at: decision.created_at,
1261
+ days_ago: daysAgo,
1262
+ ...bestScore > 0.1 ? {
1263
+ best_match_title: bestTitle,
1264
+ best_match_similarity: Math.round(bestScore * 100) / 100
1265
+ } : {}
1266
+ });
1267
+ }
1268
+ }
1269
+ stale.sort((a, b) => b.days_ago - a.days_ago);
1270
+ return stale.slice(0, 5);
1271
+ }
1272
+ function findStaleDecisionsGlobal(db, options) {
1273
+ const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
1274
+ const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
1275
+ const nowEpoch = Math.floor(Date.now() / 1000);
1276
+ const windowStart = nowEpoch - windowDays * 86400;
1277
+ const staleThreshold = nowEpoch - staleAfterDays * 86400;
1278
+ const decisions = db.db.query(`SELECT * FROM observations
1279
+ WHERE type = 'decision'
1280
+ AND lifecycle IN ('active', 'aging', 'pinned')
1281
+ AND superseded_by IS NULL
1282
+ AND created_at_epoch >= ?
1283
+ ORDER BY created_at_epoch DESC`).all(windowStart);
1284
+ if (decisions.length === 0)
1285
+ return [];
1286
+ const implementations = db.db.query(`SELECT * FROM observations
1287
+ WHERE type IN ('feature', 'bugfix', 'change', 'refactor')
1288
+ AND lifecycle IN ('active', 'aging', 'pinned')
1289
+ AND superseded_by IS NULL
1290
+ AND created_at_epoch >= ?
1291
+ ORDER BY created_at_epoch DESC
1292
+ LIMIT 500`).all(windowStart);
1293
+ const stale = [];
1294
+ for (const decision of decisions) {
1295
+ if (decision.created_at_epoch > staleThreshold)
1296
+ continue;
1297
+ const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
1298
+ let decisionConcepts = [];
1299
+ try {
1300
+ const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
1301
+ if (Array.isArray(parsed))
1302
+ decisionConcepts = parsed;
1303
+ } catch {}
1304
+ let bestScore = 0;
1305
+ let bestTitle = "";
1306
+ for (const impl of implementations) {
1307
+ if (impl.created_at_epoch <= decision.created_at_epoch)
1308
+ continue;
1309
+ const titleScore = jaccardSimilarity(decision.title, impl.title);
1310
+ let conceptBoost = 0;
1311
+ if (decisionConcepts.length > 0) {
1312
+ try {
1313
+ const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
1314
+ if (Array.isArray(implConcepts) && implConcepts.length > 0) {
1315
+ const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
1316
+ const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
1317
+ conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
1318
+ }
1319
+ } catch {}
1320
+ }
1321
+ const totalScore = titleScore + conceptBoost;
1322
+ if (totalScore > bestScore) {
1323
+ bestScore = totalScore;
1324
+ bestTitle = impl.title;
1325
+ }
1326
+ }
1327
+ if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
1328
+ stale.push({
1329
+ id: decision.id,
1330
+ title: decision.title,
1331
+ narrative: decision.narrative,
1332
+ concepts: decisionConcepts,
1333
+ created_at: decision.created_at,
1334
+ days_ago: daysAgo,
1335
+ ...bestScore > 0.1 ? {
1336
+ best_match_title: bestTitle,
1337
+ best_match_similarity: Math.round(bestScore * 100) / 100
1338
+ } : {}
1339
+ });
1340
+ }
1341
+ }
1342
+ stale.sort((a, b) => b.days_ago - a.days_ago);
1343
+ return stale.slice(0, 5);
1344
+ }
1345
+
1125
1346
  // src/context/inject.ts
1126
1347
  var RECENCY_WINDOW_SECONDS = 30 * 86400;
1127
1348
  function computeBlendedScore(quality, createdAtEpoch, nowEpoch) {
@@ -1138,48 +1359,63 @@ function buildSessionContext(db, cwd, options = {}) {
1138
1359
  const opts = typeof options === "number" ? { maxCount: options } : options;
1139
1360
  const tokenBudget = opts.tokenBudget ?? 3000;
1140
1361
  const maxCount = opts.maxCount;
1362
+ const visibilityClause = opts.userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
1363
+ const visibilityParams = opts.userId ? [opts.userId] : [];
1141
1364
  const detected = detectProject(cwd);
1142
1365
  const project = db.getProjectByCanonicalId(detected.canonical_id);
1143
- if (!project) {
1144
- return {
1145
- project_name: detected.name,
1146
- canonical_id: detected.canonical_id,
1147
- observations: [],
1148
- session_count: 0,
1149
- total_active: 0
1150
- };
1151
- }
1152
- const totalActive = (db.db.query(`SELECT COUNT(*) as c FROM observations
1153
- WHERE project_id = ? AND lifecycle IN ('active', 'aging', 'pinned')
1154
- AND superseded_by IS NULL`).get(project.id) ?? { c: 0 }).c;
1155
- const MAX_PINNED = 5;
1156
- const pinned = db.db.query(`SELECT * FROM observations
1157
- WHERE project_id = ? AND lifecycle = 'pinned'
1158
- AND superseded_by IS NULL
1159
- ORDER BY quality DESC, created_at_epoch DESC
1160
- LIMIT ?`).all(project.id, MAX_PINNED);
1161
- const MAX_RECENT = 5;
1162
- const recent = db.db.query(`SELECT * FROM observations
1163
- WHERE project_id = ? AND lifecycle IN ('active', 'aging')
1164
- AND superseded_by IS NULL
1165
- ORDER BY created_at_epoch DESC
1166
- LIMIT ?`).all(project.id, MAX_RECENT);
1366
+ const projectId = project?.id ?? -1;
1367
+ const isNewProject = !project;
1368
+ const totalActive = isNewProject ? (db.db.query(`SELECT COUNT(*) as c FROM observations
1369
+ WHERE lifecycle IN ('active', 'aging', 'pinned')
1370
+ ${visibilityClause}
1371
+ AND superseded_by IS NULL`).get(...visibilityParams) ?? { c: 0 }).c : (db.db.query(`SELECT COUNT(*) as c FROM observations
1372
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging', 'pinned')
1373
+ ${visibilityClause}
1374
+ AND superseded_by IS NULL`).get(projectId, ...visibilityParams) ?? { c: 0 }).c;
1167
1375
  const candidateLimit = maxCount ?? 50;
1168
- const candidates = db.db.query(`SELECT * FROM observations
1169
- WHERE project_id = ? AND lifecycle IN ('active', 'aging')
1170
- AND quality >= 0.3
1171
- AND superseded_by IS NULL
1172
- ORDER BY quality DESC, created_at_epoch DESC
1173
- LIMIT ?`).all(project.id, candidateLimit);
1174
- let crossProjectCandidates = [];
1175
- if (opts.scope === "all") {
1176
- const crossLimit = Math.max(10, Math.floor(candidateLimit / 3));
1177
- const rawCross = db.db.query(`SELECT * FROM observations
1178
- WHERE project_id != ? AND lifecycle IN ('active', 'aging')
1179
- AND quality >= 0.5
1376
+ let pinned = [];
1377
+ let recent = [];
1378
+ let candidates = [];
1379
+ if (!isNewProject) {
1380
+ const MAX_PINNED = 5;
1381
+ pinned = db.db.query(`SELECT * FROM observations
1382
+ WHERE project_id = ? AND lifecycle = 'pinned'
1383
+ AND superseded_by IS NULL
1384
+ ${visibilityClause}
1385
+ ORDER BY quality DESC, created_at_epoch DESC
1386
+ LIMIT ?`).all(projectId, ...visibilityParams, MAX_PINNED);
1387
+ const MAX_RECENT = 5;
1388
+ recent = db.db.query(`SELECT * FROM observations
1389
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
1180
1390
  AND superseded_by IS NULL
1391
+ ${visibilityClause}
1392
+ ORDER BY created_at_epoch DESC
1393
+ LIMIT ?`).all(projectId, ...visibilityParams, MAX_RECENT);
1394
+ candidates = db.db.query(`SELECT * FROM observations
1395
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
1396
+ AND quality >= 0.3
1397
+ AND superseded_by IS NULL
1398
+ ${visibilityClause}
1181
1399
  ORDER BY quality DESC, created_at_epoch DESC
1182
- LIMIT ?`).all(project.id, crossLimit);
1400
+ LIMIT ?`).all(projectId, ...visibilityParams, candidateLimit);
1401
+ }
1402
+ let crossProjectCandidates = [];
1403
+ if (opts.scope === "all" || isNewProject) {
1404
+ const crossLimit = isNewProject ? Math.max(30, candidateLimit) : Math.max(10, Math.floor(candidateLimit / 3));
1405
+ const qualityThreshold = isNewProject ? 0.3 : 0.5;
1406
+ const rawCross = isNewProject ? db.db.query(`SELECT * FROM observations
1407
+ WHERE lifecycle IN ('active', 'aging', 'pinned')
1408
+ AND quality >= ?
1409
+ AND superseded_by IS NULL
1410
+ ${visibilityClause}
1411
+ ORDER BY quality DESC, created_at_epoch DESC
1412
+ LIMIT ?`).all(qualityThreshold, ...visibilityParams, crossLimit) : db.db.query(`SELECT * FROM observations
1413
+ WHERE project_id != ? AND lifecycle IN ('active', 'aging')
1414
+ AND quality >= ?
1415
+ AND superseded_by IS NULL
1416
+ ${visibilityClause}
1417
+ ORDER BY quality DESC, created_at_epoch DESC
1418
+ LIMIT ?`).all(projectId, qualityThreshold, ...visibilityParams, crossLimit);
1183
1419
  const projectNameCache = new Map;
1184
1420
  crossProjectCandidates = rawCross.map((obs) => {
1185
1421
  if (!projectNameCache.has(obs.project_id)) {
@@ -1212,12 +1448,14 @@ function buildSessionContext(db, cwd, options = {}) {
1212
1448
  const scoreB = computeBlendedScore(b.quality, b.created_at_epoch, nowEpoch) + boostB;
1213
1449
  return scoreB - scoreA;
1214
1450
  });
1451
+ const projectName = project?.name ?? detected.name;
1452
+ const canonicalId = project?.canonical_id ?? detected.canonical_id;
1215
1453
  if (maxCount !== undefined) {
1216
1454
  const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
1217
1455
  const all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
1218
1456
  return {
1219
- project_name: project.name,
1220
- canonical_id: project.canonical_id,
1457
+ project_name: projectName,
1458
+ canonical_id: canonicalId,
1221
1459
  observations: all.map(toContextObservation),
1222
1460
  session_count: all.length,
1223
1461
  total_active: totalActive
@@ -1242,23 +1480,61 @@ function buildSessionContext(db, cwd, options = {}) {
1242
1480
  remainingBudget -= cost;
1243
1481
  selected.push(obs);
1244
1482
  }
1245
- const summaries = db.getRecentSummaries(project.id, 5);
1483
+ const summaries = isNewProject ? [] : db.getRecentSummaries(projectId, 5);
1246
1484
  let securityFindings = [];
1485
+ if (!isNewProject) {
1486
+ try {
1487
+ const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
1488
+ securityFindings = db.db.query(`SELECT * FROM security_findings
1489
+ WHERE project_id = ? AND created_at_epoch > ?
1490
+ ORDER BY severity DESC, created_at_epoch DESC
1491
+ LIMIT ?`).all(projectId, weekAgo, 10);
1492
+ } catch {}
1493
+ }
1494
+ let recentProjects;
1495
+ if (isNewProject) {
1496
+ try {
1497
+ const nowEpochSec = Math.floor(Date.now() / 1000);
1498
+ const projectRows = db.db.query(`SELECT p.name, p.canonical_id, p.last_active_epoch,
1499
+ (SELECT COUNT(*) FROM observations o
1500
+ WHERE o.project_id = p.id
1501
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
1502
+ ${opts.userId ? "AND (o.sensitivity != 'personal' OR o.user_id = ?)" : ""}
1503
+ AND o.superseded_by IS NULL) as obs_count
1504
+ FROM projects p
1505
+ ORDER BY p.last_active_epoch DESC
1506
+ LIMIT 10`).all(...visibilityParams);
1507
+ if (projectRows.length > 0) {
1508
+ recentProjects = projectRows.map((r) => {
1509
+ const daysAgo = Math.max(0, Math.floor((nowEpochSec - r.last_active_epoch) / 86400));
1510
+ const lastActive = new Date(r.last_active_epoch * 1000).toISOString().split("T")[0];
1511
+ return {
1512
+ name: r.name,
1513
+ canonical_id: r.canonical_id,
1514
+ observation_count: r.obs_count,
1515
+ last_active: lastActive,
1516
+ days_ago: daysAgo
1517
+ };
1518
+ });
1519
+ }
1520
+ } catch {}
1521
+ }
1522
+ let staleDecisions;
1247
1523
  try {
1248
- const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
1249
- securityFindings = db.db.query(`SELECT * FROM security_findings
1250
- WHERE project_id = ? AND created_at_epoch > ?
1251
- ORDER BY severity DESC, created_at_epoch DESC
1252
- LIMIT ?`).all(project.id, weekAgo, 10);
1524
+ const stale = isNewProject ? findStaleDecisionsGlobal(db) : findStaleDecisions(db, projectId);
1525
+ if (stale.length > 0)
1526
+ staleDecisions = stale;
1253
1527
  } catch {}
1254
1528
  return {
1255
- project_name: project.name,
1256
- canonical_id: project.canonical_id,
1529
+ project_name: projectName,
1530
+ canonical_id: canonicalId,
1257
1531
  observations: selected.map(toContextObservation),
1258
1532
  session_count: selected.length,
1259
1533
  total_active: totalActive,
1260
1534
  summaries: summaries.length > 0 ? summaries : undefined,
1261
- securityFindings: securityFindings.length > 0 ? securityFindings : undefined
1535
+ securityFindings: securityFindings.length > 0 ? securityFindings : undefined,
1536
+ recentProjects,
1537
+ staleDecisions
1262
1538
  };
1263
1539
  }
1264
1540
  function estimateObservationTokens(obs, index) {
@@ -1275,11 +1551,25 @@ function formatContextForInjection(context) {
1275
1551
  return `Project: ${context.project_name} (no prior observations)`;
1276
1552
  }
1277
1553
  const DETAILED_COUNT = 5;
1278
- const lines = [
1279
- `## Project Memory: ${context.project_name}`,
1280
- `${context.session_count} relevant observation(s) from prior sessions:`,
1281
- ""
1282
- ];
1554
+ const isCrossProject = context.recentProjects && context.recentProjects.length > 0;
1555
+ const lines = [];
1556
+ if (isCrossProject) {
1557
+ lines.push(`## Engrm Memory — Workspace Overview`);
1558
+ lines.push(`This is a new project folder. Here is context from your recent work:`);
1559
+ lines.push("");
1560
+ lines.push("**Active projects in memory:**");
1561
+ for (const rp of context.recentProjects) {
1562
+ const activity = rp.days_ago === 0 ? "today" : rp.days_ago === 1 ? "yesterday" : `${rp.days_ago}d ago`;
1563
+ lines.push(`- **${rp.name}** — ${rp.observation_count} observations, last active ${activity}`);
1564
+ }
1565
+ lines.push("");
1566
+ lines.push(`${context.session_count} relevant observation(s) from across projects:`);
1567
+ lines.push("");
1568
+ } else {
1569
+ lines.push(`## Project Memory: ${context.project_name}`);
1570
+ lines.push(`${context.session_count} relevant observation(s) from prior sessions:`);
1571
+ lines.push("");
1572
+ }
1283
1573
  for (let i = 0;i < context.observations.length; i++) {
1284
1574
  const obs = context.observations[i];
1285
1575
  const date = obs.created_at.split("T")[0];
@@ -1294,17 +1584,10 @@ function formatContextForInjection(context) {
1294
1584
  }
1295
1585
  if (context.summaries && context.summaries.length > 0) {
1296
1586
  lines.push("");
1297
- lines.push("Lessons from recent sessions:");
1298
- for (const summary of context.summaries) {
1299
- if (summary.request) {
1300
- lines.push(`- Request: ${summary.request}`);
1301
- }
1302
- if (summary.learned) {
1303
- lines.push(` Learned: ${truncateText(summary.learned, 100)}`);
1304
- }
1305
- if (summary.next_steps) {
1306
- lines.push(` Next: ${truncateText(summary.next_steps, 80)}`);
1307
- }
1587
+ lines.push("## Recent Project Briefs");
1588
+ for (const summary of context.summaries.slice(0, 3)) {
1589
+ lines.push(...formatSessionBrief(summary));
1590
+ lines.push("");
1308
1591
  }
1309
1592
  }
1310
1593
  if (context.securityFindings && context.securityFindings.length > 0) {
@@ -1316,6 +1599,17 @@ function formatContextForInjection(context) {
1316
1599
  lines.push(`- [${finding.severity.toUpperCase()}] ${finding.pattern_name}${file} (${date})`);
1317
1600
  }
1318
1601
  }
1602
+ if (context.staleDecisions && context.staleDecisions.length > 0) {
1603
+ lines.push("");
1604
+ lines.push("Stale commitments (decided but no implementation observed):");
1605
+ for (const sd of context.staleDecisions) {
1606
+ const date = sd.created_at.split("T")[0];
1607
+ lines.push(`- [DECISION] ${sd.title} (${date}, ${sd.days_ago}d ago)`);
1608
+ if (sd.best_match_title) {
1609
+ lines.push(` Closest match: "${sd.best_match_title}" (${Math.round((sd.best_match_similarity ?? 0) * 100)}% similar — not enough to count as done)`);
1610
+ }
1611
+ }
1612
+ }
1319
1613
  const remaining = context.total_active - context.session_count;
1320
1614
  if (remaining > 0) {
1321
1615
  lines.push("");
@@ -1324,6 +1618,44 @@ function formatContextForInjection(context) {
1324
1618
  return lines.join(`
1325
1619
  `);
1326
1620
  }
1621
+ function formatSessionBrief(summary) {
1622
+ const lines = [];
1623
+ const heading = summary.request ? `### ${truncateText(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
1624
+ lines.push(heading);
1625
+ const sections = [
1626
+ ["Investigated", summary.investigated, 180],
1627
+ ["Learned", summary.learned, 180],
1628
+ ["Completed", summary.completed, 180],
1629
+ ["Next Steps", summary.next_steps, 140]
1630
+ ];
1631
+ for (const [label, value, maxLen] of sections) {
1632
+ const formatted = formatSummarySection(value, maxLen);
1633
+ if (formatted) {
1634
+ lines.push(`${label}:`);
1635
+ lines.push(formatted);
1636
+ }
1637
+ }
1638
+ return lines;
1639
+ }
1640
+ function formatSummarySection(value, maxLen) {
1641
+ if (!value)
1642
+ return null;
1643
+ const cleaned = value.split(`
1644
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.startsWith("-") ? line : `- ${line}`).join(`
1645
+ `);
1646
+ if (!cleaned)
1647
+ return null;
1648
+ return truncateMultilineText(cleaned, maxLen);
1649
+ }
1650
+ function truncateMultilineText(text, maxLen) {
1651
+ if (text.length <= maxLen)
1652
+ return text;
1653
+ const truncated = text.slice(0, maxLen).trimEnd();
1654
+ const lastBreak = Math.max(truncated.lastIndexOf(`
1655
+ `), truncated.lastIndexOf(" "));
1656
+ const safe = lastBreak > maxLen * 0.5 ? truncated.slice(0, lastBreak) : truncated;
1657
+ return `${safe.trimEnd()}…`;
1658
+ }
1327
1659
  function truncateText(text, maxLen) {
1328
1660
  if (text.length <= maxLen)
1329
1661
  return text;