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/README.md +49 -5
- 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 +326 -231
- package/dist/hooks/pre-compact.js +410 -78
- package/dist/hooks/sentinel.js +150 -103
- package/dist/hooks/session-start.js +2311 -1983
- package/dist/hooks/stop.js +302 -147
- package/dist/server.js +634 -118
- package/package.json +6 -5
- package/bin/build.mjs +0 -97
- package/bin/engrm.mjs +0 -13
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
15479
|
-
|
|
15480
|
-
|
|
15481
|
-
|
|
15482
|
-
|
|
15483
|
-
|
|
15484
|
-
|
|
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
|
-
|
|
15504
|
-
|
|
15505
|
-
|
|
15506
|
-
|
|
15507
|
-
|
|
15508
|
-
|
|
15509
|
-
|
|
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(
|
|
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:
|
|
15555
|
-
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(
|
|
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
|
|
15584
|
-
|
|
15585
|
-
|
|
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:
|
|
15591
|
-
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
|
|
15614
|
-
|
|
15615
|
-
|
|
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("
|
|
15633
|
-
for (const summary of context.summaries) {
|
|
15634
|
-
|
|
15635
|
-
|
|
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,
|
|
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,
|
|
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'
|
|
16738
|
-
|
|
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
|
+
};
|