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.
- package/README.md +60 -13
- package/dist/cli.js +288 -28
- package/dist/hooks/codex-stop.js +62 -0
- package/dist/hooks/elicitation-result.js +1690 -1637
- package/dist/hooks/post-tool-use.js +284 -231
- package/dist/hooks/pre-compact.js +410 -78
- package/dist/hooks/sentinel.js +150 -103
- package/dist/hooks/session-start.js +2284 -2039
- package/dist/hooks/stop.js +196 -130
- package/dist/server.js +614 -118
- package/package.json +6 -5
- package/bin/build.mjs +0 -97
- package/bin/engrm.mjs +0 -13
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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(
|
|
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:
|
|
1220
|
-
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(
|
|
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
|
|
1249
|
-
|
|
1250
|
-
|
|
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:
|
|
1256
|
-
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
|
|
1279
|
-
|
|
1280
|
-
|
|
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("
|
|
1298
|
-
for (const summary of context.summaries) {
|
|
1299
|
-
|
|
1300
|
-
|
|
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;
|