engrm 0.4.11 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -14119,6 +14119,18 @@ var MIGRATIONS = [
14119
14119
  CREATE INDEX IF NOT EXISTS idx_tool_events_created
14120
14120
  ON tool_events(created_at_epoch DESC, id DESC);
14121
14121
  `
14122
+ },
14123
+ {
14124
+ version: 11,
14125
+ description: "Add observation provenance from tool and prompt chronology",
14126
+ sql: `
14127
+ ALTER TABLE observations ADD COLUMN source_tool TEXT;
14128
+ ALTER TABLE observations ADD COLUMN source_prompt_number INTEGER;
14129
+ CREATE INDEX IF NOT EXISTS idx_observations_source_tool
14130
+ ON observations(source_tool, created_at_epoch DESC);
14131
+ CREATE INDEX IF NOT EXISTS idx_observations_source_prompt
14132
+ ON observations(session_id, source_prompt_number DESC);
14133
+ `
14122
14134
  }
14123
14135
  ];
14124
14136
  function isVecExtensionLoaded(db) {
@@ -14172,6 +14184,8 @@ function inferLegacySchemaVersion(db) {
14172
14184
  version2 = Math.max(version2, 9);
14173
14185
  if (tableExists(db, "tool_events"))
14174
14186
  version2 = Math.max(version2, 10);
14187
+ if (columnExists(db, "observations", "source_tool"))
14188
+ version2 = Math.max(version2, 11);
14175
14189
  return version2;
14176
14190
  }
14177
14191
  function runMigrations(db) {
@@ -14258,6 +14272,86 @@ var LATEST_SCHEMA_VERSION = MIGRATIONS.filter((m) => !m.condition).reduce((max,
14258
14272
 
14259
14273
  // src/storage/sqlite.ts
14260
14274
  import { createHash as createHash2 } from "node:crypto";
14275
+
14276
+ // src/intelligence/summary-sections.ts
14277
+ function extractSummaryItems(section, limit) {
14278
+ if (!section || !section.trim())
14279
+ return [];
14280
+ const rawLines = section.split(`
14281
+ `).map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean);
14282
+ const items = [];
14283
+ const seen = new Set;
14284
+ let heading = null;
14285
+ for (const rawLine of rawLines) {
14286
+ const line = stripSectionPrefix(rawLine);
14287
+ if (!line)
14288
+ continue;
14289
+ const headingOnly = parseHeading(line);
14290
+ if (headingOnly) {
14291
+ heading = headingOnly;
14292
+ continue;
14293
+ }
14294
+ const isBullet = /^[-*•]\s+/.test(line);
14295
+ const stripped = line.replace(/^[-*•]\s+/, "").trim();
14296
+ if (!stripped)
14297
+ continue;
14298
+ const item = heading && isBullet ? `${heading}: ${stripped}` : stripped;
14299
+ const normalized = normalizeItem(item);
14300
+ if (!normalized || seen.has(normalized))
14301
+ continue;
14302
+ seen.add(normalized);
14303
+ items.push(item);
14304
+ if (limit && items.length >= limit)
14305
+ break;
14306
+ }
14307
+ return items;
14308
+ }
14309
+ function formatSummaryItems(section, maxLen) {
14310
+ const items = extractSummaryItems(section);
14311
+ if (items.length === 0)
14312
+ return null;
14313
+ const cleaned = items.map((item) => `- ${item}`).join(`
14314
+ `);
14315
+ if (cleaned.length <= maxLen)
14316
+ return cleaned;
14317
+ const truncated = cleaned.slice(0, maxLen).trimEnd();
14318
+ const lastBreak = Math.max(truncated.lastIndexOf(`
14319
+ `), truncated.lastIndexOf(" "));
14320
+ const safe = lastBreak > maxLen * 0.5 ? truncated.slice(0, lastBreak) : truncated;
14321
+ return `${safe.trimEnd()}…`;
14322
+ }
14323
+ function normalizeSummarySection(section) {
14324
+ const items = extractSummaryItems(section);
14325
+ if (items.length === 0) {
14326
+ const cleaned = section?.replace(/\s+/g, " ").trim() ?? "";
14327
+ return cleaned || null;
14328
+ }
14329
+ return items.map((item) => `- ${item}`).join(`
14330
+ `);
14331
+ }
14332
+ function normalizeSummaryRequest(value) {
14333
+ const cleaned = value?.replace(/\s+/g, " ").trim() ?? "";
14334
+ return cleaned || null;
14335
+ }
14336
+ function stripSectionPrefix(value) {
14337
+ return value.replace(/^(request|investigated|learned|completed|next steps|summary):\s*/i, "").trim();
14338
+ }
14339
+ function parseHeading(value) {
14340
+ const boldMatch = value.match(/^\*{1,2}\s*(.+?)\s*:\*{1,2}$/);
14341
+ if (boldMatch?.[1]) {
14342
+ return boldMatch[1].trim().replace(/\s+/g, " ");
14343
+ }
14344
+ const plainMatch = value.match(/^(.+?):$/);
14345
+ if (plainMatch?.[1]) {
14346
+ return plainMatch[1].trim().replace(/\s+/g, " ");
14347
+ }
14348
+ return null;
14349
+ }
14350
+ function normalizeItem(value) {
14351
+ return value.toLowerCase().replace(/\*+/g, "").replace(/\s+/g, " ").trim();
14352
+ }
14353
+
14354
+ // src/storage/sqlite.ts
14261
14355
  var IS_BUN = typeof globalThis.Bun !== "undefined";
14262
14356
  function openDatabase(dbPath) {
14263
14357
  if (IS_BUN) {
@@ -14294,6 +14388,7 @@ function openNodeDatabase(dbPath) {
14294
14388
  const BetterSqlite3 = __require("better-sqlite3");
14295
14389
  const raw = new BetterSqlite3(dbPath);
14296
14390
  return {
14391
+ __raw: raw,
14297
14392
  query(sql) {
14298
14393
  const stmt = raw.prepare(sql);
14299
14394
  return {
@@ -14331,7 +14426,7 @@ class MemDatabase {
14331
14426
  loadVecExtension() {
14332
14427
  try {
14333
14428
  const sqliteVec = __require("sqlite-vec");
14334
- sqliteVec.load(this.db);
14429
+ sqliteVec.load(this.db.__raw ?? this.db);
14335
14430
  return true;
14336
14431
  } catch {
14337
14432
  return false;
@@ -14372,8 +14467,9 @@ class MemDatabase {
14372
14467
  const result = this.db.query(`INSERT INTO observations (
14373
14468
  session_id, project_id, type, title, narrative, facts, concepts,
14374
14469
  files_read, files_modified, quality, lifecycle, sensitivity,
14375
- user_id, device_id, agent, created_at, created_at_epoch
14376
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(obs.session_id ?? null, obs.project_id, obs.type, obs.title, obs.narrative ?? null, obs.facts ?? null, obs.concepts ?? null, obs.files_read ?? null, obs.files_modified ?? null, obs.quality, obs.lifecycle ?? "active", obs.sensitivity ?? "shared", obs.user_id, obs.device_id, obs.agent ?? "claude-code", createdAt, now);
14470
+ user_id, device_id, agent, source_tool, source_prompt_number,
14471
+ created_at, created_at_epoch
14472
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(obs.session_id ?? null, obs.project_id, obs.type, obs.title, obs.narrative ?? null, obs.facts ?? null, obs.concepts ?? null, obs.files_read ?? null, obs.files_modified ?? null, obs.quality, obs.lifecycle ?? "active", obs.sensitivity ?? "shared", obs.user_id, obs.device_id, obs.agent ?? "claude-code", obs.source_tool ?? null, obs.source_prompt_number ?? null, createdAt, now);
14377
14473
  const id = Number(result.lastInsertRowid);
14378
14474
  const row = this.getObservationById(id);
14379
14475
  this.ftsInsert(row);
@@ -14614,6 +14710,13 @@ class MemDatabase {
14614
14710
  ORDER BY prompt_number ASC
14615
14711
  LIMIT ?`).all(sessionId, limit);
14616
14712
  }
14713
+ getLatestSessionPromptNumber(sessionId) {
14714
+ const row = this.db.query(`SELECT prompt_number FROM user_prompts
14715
+ WHERE session_id = ?
14716
+ ORDER BY prompt_number DESC
14717
+ LIMIT 1`).get(sessionId);
14718
+ return row?.prompt_number ?? null;
14719
+ }
14617
14720
  insertToolEvent(input) {
14618
14721
  const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
14619
14722
  const result = this.db.query(`INSERT INTO tool_events (
@@ -14723,8 +14826,15 @@ class MemDatabase {
14723
14826
  }
14724
14827
  insertSessionSummary(summary) {
14725
14828
  const now = Math.floor(Date.now() / 1000);
14829
+ const normalized = {
14830
+ request: normalizeSummaryRequest(summary.request),
14831
+ investigated: normalizeSummarySection(summary.investigated),
14832
+ learned: normalizeSummarySection(summary.learned),
14833
+ completed: normalizeSummarySection(summary.completed),
14834
+ next_steps: normalizeSummarySection(summary.next_steps)
14835
+ };
14726
14836
  const result = this.db.query(`INSERT INTO session_summaries (session_id, project_id, user_id, request, investigated, learned, completed, next_steps, created_at_epoch)
14727
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(summary.session_id, summary.project_id, summary.user_id, summary.request, summary.investigated, summary.learned, summary.completed, summary.next_steps, now);
14837
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(summary.session_id, summary.project_id, summary.user_id, normalized.request, normalized.investigated, normalized.learned, normalized.completed, normalized.next_steps, now);
14728
14838
  const id = Number(result.lastInsertRowid);
14729
14839
  return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
14730
14840
  }
@@ -15624,6 +15734,7 @@ async function saveObservation(db, config2, input) {
15624
15734
  reason: `Merged into existing observation #${duplicate.id}`
15625
15735
  };
15626
15736
  }
15737
+ const sourcePromptNumber = input.source_prompt_number ?? (input.session_id ? db.getLatestSessionPromptNumber(input.session_id) : null);
15627
15738
  const obs = db.insertObservation({
15628
15739
  session_id: input.session_id ?? null,
15629
15740
  project_id: project.id,
@@ -15639,7 +15750,9 @@ async function saveObservation(db, config2, input) {
15639
15750
  sensitivity,
15640
15751
  user_id: config2.user_id,
15641
15752
  device_id: config2.device_id,
15642
- agent: input.agent ?? "claude-code"
15753
+ agent: input.agent ?? "claude-code",
15754
+ source_tool: input.source_tool ?? null,
15755
+ source_prompt_number: sourcePromptNumber
15643
15756
  });
15644
15757
  db.addToOutbox("observation", obs.id);
15645
15758
  if (db.vecAvailable) {
@@ -16007,8 +16120,11 @@ function getSessionStory(db, input) {
16007
16120
  const toolEvents = db.getSessionToolEvents(input.session_id, 100);
16008
16121
  const observations = db.getObservationsBySession(input.session_id);
16009
16122
  const metrics = db.getSessionMetrics(input.session_id);
16123
+ const projectName = session?.project_id !== null && session?.project_id !== undefined ? db.getProjectById(session.project_id)?.name ?? null : null;
16124
+ const latestRequest = prompts[prompts.length - 1]?.prompt?.trim() || summary?.request?.trim() || null;
16010
16125
  return {
16011
16126
  session,
16127
+ project_name: projectName,
16012
16128
  summary,
16013
16129
  prompts,
16014
16130
  tool_events: toolEvents,
@@ -16025,7 +16141,11 @@ function getSessionStory(db, input) {
16025
16141
  toolCallsCount: metrics?.tool_calls_count ?? 0,
16026
16142
  observationCount: observations.length,
16027
16143
  hasSummary: Boolean(summary?.request || summary?.completed)
16028
- })
16144
+ }),
16145
+ latest_request: latestRequest,
16146
+ recent_outcomes: collectRecentOutcomes(observations),
16147
+ hot_files: collectHotFiles(observations),
16148
+ provenance_summary: collectProvenanceSummary(observations)
16029
16149
  };
16030
16150
  }
16031
16151
  function classifyCaptureState(input) {
@@ -16051,6 +16171,56 @@ function buildCaptureGaps(input) {
16051
16171
  }
16052
16172
  return gaps;
16053
16173
  }
16174
+ function collectRecentOutcomes(observations) {
16175
+ const seen = new Set;
16176
+ const outcomes = [];
16177
+ for (const obs of observations) {
16178
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
16179
+ continue;
16180
+ const title = obs.title.trim();
16181
+ if (!title || looksLikeFileOperationTitle(title))
16182
+ continue;
16183
+ const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
16184
+ if (seen.has(normalized))
16185
+ continue;
16186
+ seen.add(normalized);
16187
+ outcomes.push(title);
16188
+ if (outcomes.length >= 6)
16189
+ break;
16190
+ }
16191
+ return outcomes;
16192
+ }
16193
+ function collectHotFiles(observations) {
16194
+ const counts = new Map;
16195
+ for (const obs of observations) {
16196
+ for (const path of [...parseJsonArray(obs.files_modified), ...parseJsonArray(obs.files_read)]) {
16197
+ counts.set(path, (counts.get(path) ?? 0) + 1);
16198
+ }
16199
+ }
16200
+ return Array.from(counts.entries()).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)).slice(0, 8);
16201
+ }
16202
+ function parseJsonArray(value) {
16203
+ if (!value)
16204
+ return [];
16205
+ try {
16206
+ const parsed = JSON.parse(value);
16207
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
16208
+ } catch {
16209
+ return [];
16210
+ }
16211
+ }
16212
+ function looksLikeFileOperationTitle(value) {
16213
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
16214
+ }
16215
+ function collectProvenanceSummary(observations) {
16216
+ const counts = new Map;
16217
+ for (const obs of observations) {
16218
+ if (!obs.source_tool)
16219
+ continue;
16220
+ counts.set(obs.source_tool, (counts.get(obs.source_tool) ?? 0) + 1);
16221
+ }
16222
+ return Array.from(counts.entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
16223
+ }
16054
16224
 
16055
16225
  // src/tools/recent-sessions.ts
16056
16226
  function getRecentSessions(db, input) {
@@ -16088,1292 +16258,1641 @@ function classifyCaptureState2(session) {
16088
16258
  return "legacy";
16089
16259
  }
16090
16260
 
16091
- // src/tools/project-memory-index.ts
16092
- function getProjectMemoryIndex(db, input) {
16093
- const cwd = input.cwd ?? process.cwd();
16094
- const detected = detectProject(cwd);
16095
- const project = db.getProjectByCanonicalId(detected.canonical_id);
16096
- if (!project)
16097
- return null;
16098
- const visibilityClause = input.user_id ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
16099
- const visibilityParams = input.user_id ? [input.user_id] : [];
16100
- const observations = db.db.query(`SELECT * FROM observations
16101
- WHERE project_id = ?
16102
- AND lifecycle IN ('active', 'aging', 'pinned')
16103
- AND superseded_by IS NULL
16104
- ${visibilityClause}
16105
- ORDER BY created_at_epoch DESC`).all(project.id, ...visibilityParams);
16106
- const counts = {};
16107
- for (const obs of observations) {
16108
- counts[obs.type] = (counts[obs.type] ?? 0) + 1;
16109
- }
16110
- const fileCounts = new Map;
16111
- for (const obs of observations) {
16112
- for (const path of extractPaths(obs.files_modified)) {
16113
- fileCounts.set(path, (fileCounts.get(path) ?? 0) + 1);
16261
+ // src/intelligence/followthrough.ts
16262
+ var FOLLOW_THROUGH_THRESHOLD = 0.25;
16263
+ var STALE_AFTER_DAYS = 3;
16264
+ var DECISION_WINDOW_DAYS = 30;
16265
+ var IMPLEMENTATION_TYPES = new Set([
16266
+ "feature",
16267
+ "bugfix",
16268
+ "change",
16269
+ "refactor"
16270
+ ]);
16271
+ function findStaleDecisions(db, projectId, options) {
16272
+ const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
16273
+ const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
16274
+ const nowEpoch = Math.floor(Date.now() / 1000);
16275
+ const windowStart = nowEpoch - windowDays * 86400;
16276
+ const staleThreshold = nowEpoch - staleAfterDays * 86400;
16277
+ const decisions = db.db.query(`SELECT * FROM observations
16278
+ WHERE project_id = ? AND type = 'decision'
16279
+ AND lifecycle IN ('active', 'aging', 'pinned')
16280
+ AND superseded_by IS NULL
16281
+ AND created_at_epoch >= ?
16282
+ ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
16283
+ if (decisions.length === 0)
16284
+ return [];
16285
+ const implementations = db.db.query(`SELECT * FROM observations
16286
+ WHERE project_id = ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
16287
+ AND lifecycle IN ('active', 'aging', 'pinned')
16288
+ AND superseded_by IS NULL
16289
+ AND created_at_epoch >= ?
16290
+ ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
16291
+ const crossProjectImpls = db.db.query(`SELECT * FROM observations
16292
+ WHERE project_id != ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
16293
+ AND lifecycle IN ('active', 'aging', 'pinned')
16294
+ AND superseded_by IS NULL
16295
+ AND created_at_epoch >= ?
16296
+ ORDER BY created_at_epoch DESC
16297
+ LIMIT 200`).all(projectId, windowStart);
16298
+ const allImpls = [...implementations, ...crossProjectImpls];
16299
+ const stale = [];
16300
+ for (const decision of decisions) {
16301
+ if (decision.created_at_epoch > staleThreshold)
16302
+ continue;
16303
+ const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
16304
+ let decisionConcepts = [];
16305
+ try {
16306
+ const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
16307
+ if (Array.isArray(parsed))
16308
+ decisionConcepts = parsed;
16309
+ } catch {}
16310
+ let bestTitle = "";
16311
+ let bestScore = 0;
16312
+ for (const impl of allImpls) {
16313
+ if (impl.created_at_epoch <= decision.created_at_epoch)
16314
+ continue;
16315
+ const titleScore = jaccardSimilarity(decision.title, impl.title);
16316
+ let conceptBoost = 0;
16317
+ if (decisionConcepts.length > 0) {
16318
+ try {
16319
+ const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
16320
+ if (Array.isArray(implConcepts) && implConcepts.length > 0) {
16321
+ const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
16322
+ const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
16323
+ conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
16324
+ }
16325
+ } catch {}
16326
+ }
16327
+ let narrativeBoost = 0;
16328
+ if (impl.narrative) {
16329
+ const decWords = new Set(decision.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3));
16330
+ if (decWords.size > 0) {
16331
+ const implNarrativeLower = impl.narrative.toLowerCase();
16332
+ const hits = [...decWords].filter((w) => implNarrativeLower.includes(w)).length;
16333
+ narrativeBoost = hits / decWords.size * 0.1;
16334
+ }
16335
+ }
16336
+ const totalScore = titleScore + conceptBoost + narrativeBoost;
16337
+ if (totalScore > bestScore) {
16338
+ bestScore = totalScore;
16339
+ bestTitle = impl.title;
16340
+ }
16341
+ }
16342
+ if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
16343
+ stale.push({
16344
+ id: decision.id,
16345
+ title: decision.title,
16346
+ narrative: decision.narrative,
16347
+ concepts: decisionConcepts,
16348
+ created_at: decision.created_at,
16349
+ days_ago: daysAgo,
16350
+ ...bestScore > 0.1 ? {
16351
+ best_match_title: bestTitle,
16352
+ best_match_similarity: Math.round(bestScore * 100) / 100
16353
+ } : {}
16354
+ });
16114
16355
  }
16115
16356
  }
16116
- const hotFiles = Array.from(fileCounts.entries()).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)).slice(0, 8);
16117
- const topTitles = observations.slice(0, 12).map((obs) => ({
16118
- type: obs.type,
16119
- title: obs.title,
16120
- id: obs.id
16121
- }));
16122
- const recentSessions = getRecentSessions(db, {
16123
- cwd,
16124
- project_scoped: true,
16125
- user_id: input.user_id,
16126
- limit: 6
16127
- }).sessions;
16128
- const recentRequestsCount = getRecentRequests(db, {
16129
- cwd,
16130
- project_scoped: true,
16131
- user_id: input.user_id,
16132
- limit: 20
16133
- }).prompts.length;
16134
- const recentToolsCount = getRecentTools(db, {
16135
- cwd,
16136
- project_scoped: true,
16137
- user_id: input.user_id,
16138
- limit: 20
16139
- }).tool_events.length;
16140
- const recentOutcomes = observations.filter((obs) => ["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type)).map((obs) => obs.title.trim()).filter((title) => title.length > 0 && !looksLikeFileOperationTitle(title)).slice(0, 8);
16141
- return {
16142
- project: project.name,
16143
- canonical_id: project.canonical_id,
16144
- observation_counts: counts,
16145
- recent_sessions: recentSessions,
16146
- recent_outcomes: recentOutcomes,
16147
- recent_requests_count: recentRequestsCount,
16148
- recent_tools_count: recentToolsCount,
16149
- raw_capture_active: recentRequestsCount > 0 || recentToolsCount > 0,
16150
- hot_files: hotFiles,
16151
- top_titles: topTitles
16152
- };
16357
+ stale.sort((a, b) => b.days_ago - a.days_ago);
16358
+ return stale.slice(0, 5);
16153
16359
  }
16154
- function extractPaths(value) {
16155
- if (!value)
16156
- return [];
16157
- try {
16158
- const parsed = JSON.parse(value);
16159
- if (!Array.isArray(parsed))
16160
- return [];
16161
- return parsed.filter((item) => typeof item === "string" && item.trim().length > 0);
16162
- } catch {
16360
+ function findStaleDecisionsGlobal(db, options) {
16361
+ const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
16362
+ const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
16363
+ const nowEpoch = Math.floor(Date.now() / 1000);
16364
+ const windowStart = nowEpoch - windowDays * 86400;
16365
+ const staleThreshold = nowEpoch - staleAfterDays * 86400;
16366
+ const decisions = db.db.query(`SELECT * FROM observations
16367
+ WHERE type = 'decision'
16368
+ AND lifecycle IN ('active', 'aging', 'pinned')
16369
+ AND superseded_by IS NULL
16370
+ AND created_at_epoch >= ?
16371
+ ORDER BY created_at_epoch DESC`).all(windowStart);
16372
+ if (decisions.length === 0)
16163
16373
  return [];
16164
- }
16165
- }
16166
- function looksLikeFileOperationTitle(value) {
16167
- return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
16168
- }
16169
-
16170
- // src/tools/memory-console.ts
16171
- function getMemoryConsole(db, input) {
16172
- const cwd = input.cwd ?? process.cwd();
16173
- const projectScoped = input.project_scoped !== false;
16174
- const detected = projectScoped ? detectProject(cwd) : null;
16175
- const project = detected ? db.getProjectByCanonicalId(detected.canonical_id) : null;
16176
- const sessions = getRecentSessions(db, {
16177
- cwd,
16178
- project_scoped: projectScoped,
16179
- user_id: input.user_id,
16180
- limit: 6
16181
- }).sessions;
16182
- const requests = getRecentRequests(db, {
16183
- cwd,
16184
- project_scoped: projectScoped,
16185
- user_id: input.user_id,
16186
- limit: 6
16187
- }).prompts;
16188
- const tools = getRecentTools(db, {
16189
- cwd,
16190
- project_scoped: projectScoped,
16191
- user_id: input.user_id,
16192
- limit: 8
16193
- }).tool_events;
16194
- const observations = getRecentActivity(db, {
16195
- cwd,
16196
- project_scoped: projectScoped,
16197
- user_id: input.user_id,
16198
- limit: 8
16199
- }).observations;
16200
- const projectIndex = projectScoped ? getProjectMemoryIndex(db, {
16201
- cwd,
16202
- user_id: input.user_id
16203
- }) : null;
16204
- return {
16205
- project: project?.name,
16206
- capture_mode: requests.length > 0 || tools.length > 0 ? "rich" : "observations-only",
16207
- sessions,
16208
- requests,
16209
- tools,
16210
- observations,
16211
- recent_outcomes: projectIndex?.recent_outcomes ?? [],
16212
- hot_files: projectIndex?.hot_files ?? []
16213
- };
16214
- }
16215
-
16216
- // src/tools/workspace-memory-index.ts
16217
- function getWorkspaceMemoryIndex(db, input) {
16218
- const limit = Math.max(1, Math.min(input.limit ?? 12, 50));
16219
- const visibilityClause = input.user_id ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
16220
- const params = input.user_id ? [input.user_id, limit] : [limit];
16221
- const projects = db.db.query(`SELECT
16222
- p.canonical_id,
16223
- p.name,
16224
- (
16225
- SELECT COUNT(*) FROM observations o
16226
- WHERE o.project_id = p.id
16227
- AND o.lifecycle IN ('active', 'aging', 'pinned')
16228
- AND o.superseded_by IS NULL
16229
- ${visibilityClause}
16230
- ) AS observation_count,
16231
- (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count,
16232
- (SELECT COUNT(*) FROM user_prompts up WHERE up.project_id = p.id) AS prompt_count,
16233
- (SELECT COUNT(*) FROM tool_events te WHERE te.project_id = p.id) AS tool_event_count,
16234
- p.last_active_epoch
16235
- FROM projects p
16236
- ORDER BY p.last_active_epoch DESC
16237
- LIMIT ?`).all(...params);
16238
- const totals = projects.reduce((acc, project) => {
16239
- acc.observations += project.observation_count;
16240
- acc.sessions += project.session_count;
16241
- acc.prompts += project.prompt_count;
16242
- acc.tool_events += project.tool_event_count;
16243
- return acc;
16244
- }, { observations: 0, sessions: 0, prompts: 0, tool_events: 0 });
16245
- return {
16246
- projects,
16247
- totals,
16248
- projects_with_raw_capture: projects.filter((project) => project.prompt_count > 0 || project.tool_event_count > 0).length
16249
- };
16250
- }
16251
-
16252
- // src/tools/project-related-work.ts
16253
- function getProjectRelatedWork(db, input) {
16254
- const cwd = input.cwd ?? process.cwd();
16255
- const detected = detectProject(cwd);
16256
- const limit = Math.max(1, Math.min(input.limit ?? 20, 100));
16257
- const visibilityClause = input.user_id ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
16258
- const visibilityParams = input.user_id ? [input.user_id] : [];
16259
- const terms = buildProjectTerms(detected);
16260
- if (terms.length === 0) {
16261
- return {
16262
- project: detected.name,
16263
- canonical_id: detected.canonical_id,
16264
- related: []
16265
- };
16266
- }
16267
- const localProject = db.getProjectByCanonicalId(detected.canonical_id);
16268
- const localProjectId = localProject?.id ?? -1;
16269
- const params = [];
16270
- const whereParts = [];
16271
- for (const term of terms) {
16272
- const like = `%${term}%`;
16273
- whereParts.push(`(LOWER(o.title) LIKE ? OR LOWER(COALESCE(o.narrative, '')) LIKE ? OR LOWER(COALESCE(o.files_read, '')) LIKE ? OR LOWER(COALESCE(o.files_modified, '')) LIKE ?)`);
16274
- params.push(like, like, like, like);
16275
- }
16276
- const rows = db.db.query(`SELECT
16277
- o.id,
16278
- o.type,
16279
- o.title,
16280
- o.project_id as source_project_id,
16281
- p.name as source_project,
16282
- o.narrative,
16283
- o.files_read,
16284
- o.files_modified
16285
- FROM observations o
16286
- LEFT JOIN projects p ON p.id = o.project_id
16287
- WHERE o.project_id != ?
16288
- AND o.lifecycle IN ('active', 'aging', 'pinned')
16289
- AND o.superseded_by IS NULL
16290
- ${visibilityClause}
16291
- AND (${whereParts.join(" OR ")})
16292
- ORDER BY o.created_at_epoch DESC
16293
- LIMIT ?`).all(localProjectId, ...visibilityParams, ...params, limit);
16294
- return {
16295
- project: detected.name,
16296
- canonical_id: detected.canonical_id,
16297
- related: rows.map((row) => ({
16298
- id: row.id,
16299
- type: row.type,
16300
- title: row.title,
16301
- source_project: row.source_project ?? "unknown",
16302
- source_project_id: row.source_project_id,
16303
- matched_on: classifyMatch(row, terms)
16304
- }))
16305
- };
16306
- }
16307
- function buildProjectTerms(detected) {
16308
- const explicit = new Set;
16309
- explicit.add(detected.name.toLowerCase());
16310
- const canonicalParts = detected.canonical_id.toLowerCase().split("/");
16311
- for (const part of canonicalParts) {
16312
- if (part.length >= 4)
16313
- explicit.add(part);
16314
- }
16315
- if (detected.name.toLowerCase() === "huginn") {
16316
- explicit.add("aiserver");
16317
- }
16318
- return [...explicit].filter(Boolean);
16319
- }
16320
- function classifyMatch(row, terms) {
16321
- const title = row.title.toLowerCase();
16322
- const narrative = (row.narrative ?? "").toLowerCase();
16323
- const files = `${row.files_read ?? ""} ${row.files_modified ?? ""}`.toLowerCase();
16324
- for (const term of terms) {
16325
- if (files.includes(term))
16326
- return `files:${term}`;
16327
- if (title.includes(term))
16328
- return `title:${term}`;
16329
- if (narrative.includes(term))
16330
- return `narrative:${term}`;
16331
- }
16332
- return "related";
16333
- }
16334
-
16335
- // src/tools/reclassify-project-memory.ts
16336
- function reclassifyProjectMemory(db, input) {
16337
- const cwd = input.cwd ?? process.cwd();
16338
- const detected = detectProject(cwd);
16339
- const target = db.upsertProject({
16340
- canonical_id: detected.canonical_id,
16341
- name: detected.name,
16342
- local_path: detected.local_path,
16343
- remote_url: detected.remote_url
16344
- });
16345
- const related = getProjectRelatedWork(db, {
16346
- cwd,
16347
- user_id: input.user_id,
16348
- limit: input.limit ?? 50
16349
- }).related;
16350
- let moved = 0;
16351
- const candidates = related.map((item) => {
16352
- const eligible = item.matched_on.startsWith("files:") || item.matched_on.startsWith("title:") || item.matched_on.startsWith("narrative:");
16353
- const shouldMove = eligible && item.source_project_id !== target.id;
16354
- if (shouldMove && input.dry_run !== true) {
16355
- const ok = db.reassignObservationProject(item.id, target.id);
16356
- if (ok)
16357
- moved += 1;
16358
- return {
16359
- id: item.id,
16360
- title: item.title,
16361
- type: item.type,
16362
- from: item.source_project,
16363
- matched_on: item.matched_on,
16364
- moved: ok
16365
- };
16374
+ const implementations = db.db.query(`SELECT * FROM observations
16375
+ WHERE type IN ('feature', 'bugfix', 'change', 'refactor')
16376
+ AND lifecycle IN ('active', 'aging', 'pinned')
16377
+ AND superseded_by IS NULL
16378
+ AND created_at_epoch >= ?
16379
+ ORDER BY created_at_epoch DESC
16380
+ LIMIT 500`).all(windowStart);
16381
+ const stale = [];
16382
+ for (const decision of decisions) {
16383
+ if (decision.created_at_epoch > staleThreshold)
16384
+ continue;
16385
+ const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
16386
+ let decisionConcepts = [];
16387
+ try {
16388
+ const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
16389
+ if (Array.isArray(parsed))
16390
+ decisionConcepts = parsed;
16391
+ } catch {}
16392
+ let bestScore = 0;
16393
+ let bestTitle = "";
16394
+ for (const impl of implementations) {
16395
+ if (impl.created_at_epoch <= decision.created_at_epoch)
16396
+ continue;
16397
+ const titleScore = jaccardSimilarity(decision.title, impl.title);
16398
+ let conceptBoost = 0;
16399
+ if (decisionConcepts.length > 0) {
16400
+ try {
16401
+ const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
16402
+ if (Array.isArray(implConcepts) && implConcepts.length > 0) {
16403
+ const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
16404
+ const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
16405
+ conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
16406
+ }
16407
+ } catch {}
16408
+ }
16409
+ const totalScore = titleScore + conceptBoost;
16410
+ if (totalScore > bestScore) {
16411
+ bestScore = totalScore;
16412
+ bestTitle = impl.title;
16413
+ }
16366
16414
  }
16367
- return {
16368
- id: item.id,
16369
- title: item.title,
16370
- type: item.type,
16371
- from: item.source_project,
16372
- matched_on: item.matched_on,
16373
- moved: false
16374
- };
16375
- });
16376
- return {
16377
- project: target.name,
16378
- canonical_id: target.canonical_id,
16379
- target_project_id: target.id,
16380
- moved,
16381
- candidates
16382
- };
16415
+ if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
16416
+ stale.push({
16417
+ id: decision.id,
16418
+ title: decision.title,
16419
+ narrative: decision.narrative,
16420
+ concepts: decisionConcepts,
16421
+ created_at: decision.created_at,
16422
+ days_ago: daysAgo,
16423
+ ...bestScore > 0.1 ? {
16424
+ best_match_title: bestTitle,
16425
+ best_match_similarity: Math.round(bestScore * 100) / 100
16426
+ } : {}
16427
+ });
16428
+ }
16429
+ }
16430
+ stale.sort((a, b) => b.days_ago - a.days_ago);
16431
+ return stale.slice(0, 5);
16383
16432
  }
16384
16433
 
16385
- // src/tools/activity-feed.ts
16386
- function toPromptEvent(prompt) {
16387
- return {
16388
- kind: "prompt",
16389
- created_at_epoch: prompt.created_at_epoch,
16390
- session_id: prompt.session_id,
16391
- id: prompt.id,
16392
- title: `#${prompt.prompt_number} ${prompt.prompt.replace(/\s+/g, " ").trim()}`
16393
- };
16394
- }
16395
- function toToolEvent(tool) {
16396
- const detail = tool.file_path ?? tool.command ?? tool.tool_response_preview ?? undefined;
16397
- return {
16398
- kind: "tool",
16399
- created_at_epoch: tool.created_at_epoch,
16400
- session_id: tool.session_id,
16401
- id: tool.id,
16402
- title: tool.tool_name,
16403
- detail: detail?.replace(/\s+/g, " ").trim()
16404
- };
16434
+ // src/context/inject.ts
16435
+ function tokenizeProjectHint(text) {
16436
+ return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
16405
16437
  }
16406
- function toObservationEvent(obs) {
16407
- return {
16408
- kind: "observation",
16409
- created_at_epoch: obs.created_at_epoch,
16410
- session_id: obs.session_id,
16411
- id: obs.id,
16412
- title: obs.title,
16413
- observation_type: obs.type
16414
- };
16438
+ function isObservationRelatedToProject(obs, detected) {
16439
+ const hints = new Set([
16440
+ ...tokenizeProjectHint(detected.name),
16441
+ ...tokenizeProjectHint(detected.canonical_id)
16442
+ ]);
16443
+ if (hints.size === 0)
16444
+ return false;
16445
+ const haystack = [
16446
+ obs.title,
16447
+ obs.narrative ?? "",
16448
+ obs.facts ?? "",
16449
+ obs.concepts ?? "",
16450
+ obs.files_read ?? "",
16451
+ obs.files_modified ?? "",
16452
+ obs._source_project ?? ""
16453
+ ].join(`
16454
+ `).toLowerCase();
16455
+ for (const hint of hints) {
16456
+ if (haystack.includes(hint))
16457
+ return true;
16458
+ }
16459
+ return false;
16415
16460
  }
16416
- function toSummaryEvent(summary, fallbackEpoch = 0) {
16417
- const title = summary.request ?? summary.completed ?? summary.learned ?? summary.investigated;
16418
- if (!title)
16419
- return null;
16420
- const detail = [
16421
- summary.completed && summary.completed !== title ? `Completed: ${summary.completed}` : null,
16422
- summary.learned ? `Learned: ${summary.learned}` : null,
16423
- summary.next_steps ? `Next: ${summary.next_steps}` : null
16424
- ].filter(Boolean).join(" | ");
16425
- return {
16426
- kind: "summary",
16427
- created_at_epoch: summary.created_at_epoch ?? fallbackEpoch,
16428
- session_id: summary.session_id,
16429
- id: summary.id,
16430
- title: title.replace(/\s+/g, " ").trim(),
16431
- detail: detail || undefined
16432
- };
16461
+ function estimateTokens(text) {
16462
+ if (!text)
16463
+ return 0;
16464
+ return Math.ceil(text.length / 4);
16433
16465
  }
16434
- function compareEvents(a, b) {
16435
- if (b.created_at_epoch !== a.created_at_epoch) {
16436
- return b.created_at_epoch - a.created_at_epoch;
16437
- }
16438
- const kindOrder = {
16439
- summary: 0,
16440
- observation: 1,
16441
- tool: 2,
16442
- prompt: 3
16443
- };
16444
- if (kindOrder[a.kind] !== kindOrder[b.kind]) {
16445
- return kindOrder[a.kind] - kindOrder[b.kind];
16466
+ function buildSessionContext(db, cwd, options = {}) {
16467
+ const opts = typeof options === "number" ? { maxCount: options } : options;
16468
+ const tokenBudget = opts.tokenBudget ?? 3000;
16469
+ const maxCount = opts.maxCount;
16470
+ const visibilityClause = opts.userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
16471
+ const visibilityParams = opts.userId ? [opts.userId] : [];
16472
+ const detected = detectProject(cwd);
16473
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
16474
+ const projectId = project?.id ?? -1;
16475
+ const isNewProject = !project;
16476
+ const totalActive = isNewProject ? (db.db.query(`SELECT COUNT(*) as c FROM observations
16477
+ WHERE lifecycle IN ('active', 'aging', 'pinned')
16478
+ ${visibilityClause}
16479
+ AND superseded_by IS NULL`).get(...visibilityParams) ?? { c: 0 }).c : (db.db.query(`SELECT COUNT(*) as c FROM observations
16480
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging', 'pinned')
16481
+ ${visibilityClause}
16482
+ AND superseded_by IS NULL`).get(projectId, ...visibilityParams) ?? { c: 0 }).c;
16483
+ const candidateLimit = maxCount ?? 50;
16484
+ let pinned = [];
16485
+ let recent = [];
16486
+ let candidates = [];
16487
+ if (!isNewProject) {
16488
+ const MAX_PINNED = 5;
16489
+ pinned = db.db.query(`SELECT * FROM observations
16490
+ WHERE project_id = ? AND lifecycle = 'pinned'
16491
+ AND superseded_by IS NULL
16492
+ ${visibilityClause}
16493
+ ORDER BY quality DESC, created_at_epoch DESC
16494
+ LIMIT ?`).all(projectId, ...visibilityParams, MAX_PINNED);
16495
+ const MAX_RECENT = 5;
16496
+ recent = db.db.query(`SELECT * FROM observations
16497
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
16498
+ AND superseded_by IS NULL
16499
+ ${visibilityClause}
16500
+ ORDER BY created_at_epoch DESC
16501
+ LIMIT ?`).all(projectId, ...visibilityParams, MAX_RECENT);
16502
+ candidates = db.db.query(`SELECT * FROM observations
16503
+ WHERE project_id = ? AND lifecycle IN ('active', 'aging')
16504
+ AND quality >= 0.3
16505
+ AND superseded_by IS NULL
16506
+ ${visibilityClause}
16507
+ ORDER BY quality DESC, created_at_epoch DESC
16508
+ LIMIT ?`).all(projectId, ...visibilityParams, candidateLimit);
16446
16509
  }
16447
- return (b.id ?? 0) - (a.id ?? 0);
16448
- }
16449
- function getActivityFeed(db, input) {
16450
- const limit = Math.max(1, Math.min(input.limit ?? 30, 100));
16451
- if (input.session_id) {
16452
- const story = getSessionStory(db, { session_id: input.session_id });
16453
- const project = story.session?.project_id !== null && story.session?.project_id !== undefined ? db.getProjectById(story.session.project_id)?.name : undefined;
16454
- const events2 = [
16455
- ...story.summary ? [toSummaryEvent(story.summary, story.session?.completed_at_epoch ?? story.session?.started_at_epoch ?? 0)].filter((event) => event !== null) : [],
16456
- ...story.prompts.map(toPromptEvent),
16457
- ...story.tool_events.map(toToolEvent),
16458
- ...story.observations.map(toObservationEvent)
16459
- ].sort(compareEvents).slice(0, limit);
16460
- return { events: events2, project };
16510
+ let crossProjectCandidates = [];
16511
+ if (opts.scope === "all" || isNewProject) {
16512
+ const crossLimit = isNewProject ? Math.max(30, candidateLimit) : Math.max(10, Math.floor(candidateLimit / 3));
16513
+ const qualityThreshold = isNewProject ? 0.3 : 0.5;
16514
+ const rawCross = isNewProject ? db.db.query(`SELECT * FROM observations
16515
+ WHERE lifecycle IN ('active', 'aging', 'pinned')
16516
+ AND quality >= ?
16517
+ AND superseded_by IS NULL
16518
+ ${visibilityClause}
16519
+ ORDER BY quality DESC, created_at_epoch DESC
16520
+ LIMIT ?`).all(qualityThreshold, ...visibilityParams, crossLimit) : db.db.query(`SELECT * FROM observations
16521
+ WHERE project_id != ? AND lifecycle IN ('active', 'aging')
16522
+ AND quality >= ?
16523
+ AND superseded_by IS NULL
16524
+ ${visibilityClause}
16525
+ ORDER BY quality DESC, created_at_epoch DESC
16526
+ LIMIT ?`).all(projectId, qualityThreshold, ...visibilityParams, crossLimit);
16527
+ const projectNameCache = new Map;
16528
+ crossProjectCandidates = rawCross.map((obs) => {
16529
+ if (!projectNameCache.has(obs.project_id)) {
16530
+ const proj = db.getProjectById(obs.project_id);
16531
+ if (proj)
16532
+ projectNameCache.set(obs.project_id, proj.name);
16533
+ }
16534
+ return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
16535
+ });
16536
+ if (isNewProject) {
16537
+ crossProjectCandidates = crossProjectCandidates.filter((obs) => isObservationRelatedToProject(obs, detected));
16538
+ }
16461
16539
  }
16462
- const projectScoped = input.project_scoped !== false;
16463
- let projectName;
16464
- if (projectScoped) {
16465
- const cwd = input.cwd ?? process.cwd();
16466
- const detected = detectProject(cwd);
16467
- const project = db.getProjectByCanonicalId(detected.canonical_id);
16468
- if (project) {
16469
- projectName = project.name;
16540
+ const seenIds = new Set(pinned.map((o) => o.id));
16541
+ const dedupedRecent = recent.filter((o) => {
16542
+ if (seenIds.has(o.id))
16543
+ return false;
16544
+ seenIds.add(o.id);
16545
+ return true;
16546
+ });
16547
+ const deduped = candidates.filter((o) => !seenIds.has(o.id));
16548
+ for (const obs of crossProjectCandidates) {
16549
+ if (!seenIds.has(obs.id)) {
16550
+ seenIds.add(obs.id);
16551
+ deduped.push(obs);
16470
16552
  }
16471
16553
  }
16472
- const prompts = getRecentRequests(db, { ...input, limit }).prompts;
16473
- const tools = getRecentTools(db, { ...input, limit }).tool_events;
16474
- const observations = getRecentActivity(db, {
16475
- limit,
16476
- project_scoped: input.project_scoped,
16477
- cwd: input.cwd,
16478
- user_id: input.user_id
16479
- }).observations;
16480
- const sessions = getRecentSessions(db, {
16481
- limit,
16482
- project_scoped: input.project_scoped,
16483
- cwd: input.cwd,
16484
- user_id: input.user_id
16485
- }).sessions;
16486
- const summaryEvents = sessions.map((session) => {
16487
- const summary = db.getSessionSummary(session.session_id);
16488
- if (!summary)
16489
- return null;
16490
- return toSummaryEvent(summary, session.completed_at_epoch ?? session.started_at_epoch ?? 0);
16491
- }).filter((event) => event !== null);
16492
- const events = [
16493
- ...summaryEvents,
16494
- ...prompts.map(toPromptEvent),
16495
- ...tools.map(toToolEvent),
16496
- ...observations.map(toObservationEvent)
16497
- ].sort(compareEvents).slice(0, limit);
16498
- return {
16499
- events,
16500
- project: projectName
16501
- };
16502
- }
16503
-
16504
- // src/tools/capture-status.ts
16505
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
16506
- import { homedir as homedir2 } from "node:os";
16507
- import { join as join3 } from "node:path";
16508
- var LEGACY_CODEX_SERVER_NAME = `candengo-${"mem"}`;
16509
- function getCaptureStatus(db, input = {}) {
16510
- const hours = Math.max(1, Math.min(input.lookback_hours ?? 24, 24 * 30));
16511
- const sinceEpoch = Math.floor(Date.now() / 1000) - hours * 3600;
16512
- const home = input.home_dir ?? homedir2();
16513
- const claudeJson = join3(home, ".claude.json");
16514
- const claudeSettings = join3(home, ".claude", "settings.json");
16515
- const codexConfig = join3(home, ".codex", "config.toml");
16516
- const codexHooks = join3(home, ".codex", "hooks.json");
16517
- const claudeJsonContent = existsSync3(claudeJson) ? readFileSync3(claudeJson, "utf-8") : "";
16518
- const claudeSettingsContent = existsSync3(claudeSettings) ? readFileSync3(claudeSettings, "utf-8") : "";
16519
- const codexConfigContent = existsSync3(codexConfig) ? readFileSync3(codexConfig, "utf-8") : "";
16520
- const codexHooksContent = existsSync3(codexHooks) ? readFileSync3(codexHooks, "utf-8") : "";
16521
- const claudeMcpRegistered = claudeJsonContent.includes('"engrm"');
16522
- const claudeHooksRegistered = claudeSettingsContent.includes("engrm") || claudeSettingsContent.includes("session-start") || claudeSettingsContent.includes("user-prompt-submit");
16523
- const codexMcpRegistered = codexConfigContent.includes("[mcp_servers.engrm]") || codexConfigContent.includes(`[mcp_servers.${LEGACY_CODEX_SERVER_NAME}]`);
16524
- const codexHooksRegistered = codexHooksContent.includes('"SessionStart"') && codexHooksContent.includes('"Stop"');
16525
- let claudeHookCount = 0;
16526
- let claudeSessionStartHook = false;
16527
- let claudeUserPromptHook = false;
16528
- let claudePostToolHook = false;
16529
- let claudeStopHook = false;
16530
- if (claudeHooksRegistered) {
16554
+ const nowEpoch = Math.floor(Date.now() / 1000);
16555
+ const sorted = [...deduped].sort((a, b) => {
16556
+ const scoreA = computeObservationPriority(a, nowEpoch);
16557
+ const scoreB = computeObservationPriority(b, nowEpoch);
16558
+ return scoreB - scoreA;
16559
+ });
16560
+ const projectName = project?.name ?? detected.name;
16561
+ const canonicalId = project?.canonical_id ?? detected.canonical_id;
16562
+ if (maxCount !== undefined) {
16563
+ const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
16564
+ const all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
16565
+ const recentPrompts2 = db.getRecentUserPrompts(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16566
+ const recentToolEvents2 = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16567
+ const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
16568
+ const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
16569
+ const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
16570
+ return {
16571
+ project_name: projectName,
16572
+ canonical_id: canonicalId,
16573
+ observations: all.map(toContextObservation),
16574
+ session_count: all.length,
16575
+ total_active: totalActive,
16576
+ recentPrompts: recentPrompts2.length > 0 ? recentPrompts2 : undefined,
16577
+ recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
16578
+ recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
16579
+ projectTypeCounts: projectTypeCounts2,
16580
+ recentOutcomes: recentOutcomes2
16581
+ };
16582
+ }
16583
+ let remainingBudget = tokenBudget - 30;
16584
+ const selected = [];
16585
+ for (const obs of pinned) {
16586
+ const cost = estimateObservationTokens(obs, selected.length);
16587
+ remainingBudget -= cost;
16588
+ selected.push(obs);
16589
+ }
16590
+ for (const obs of dedupedRecent) {
16591
+ const cost = estimateObservationTokens(obs, selected.length);
16592
+ remainingBudget -= cost;
16593
+ selected.push(obs);
16594
+ }
16595
+ for (const obs of sorted) {
16596
+ const cost = estimateObservationTokens(obs, selected.length);
16597
+ if (remainingBudget - cost < 0 && selected.length > 0)
16598
+ break;
16599
+ remainingBudget -= cost;
16600
+ selected.push(obs);
16601
+ }
16602
+ const summaries = isNewProject ? [] : db.getRecentSummaries(projectId, 5);
16603
+ const recentPrompts = db.getRecentUserPrompts(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16604
+ const recentToolEvents = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16605
+ const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
16606
+ const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
16607
+ const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
16608
+ let securityFindings = [];
16609
+ if (!isNewProject) {
16531
16610
  try {
16532
- const settings = JSON.parse(claudeSettingsContent);
16533
- const hooks = settings?.hooks ?? {};
16534
- claudeSessionStartHook = Array.isArray(hooks["SessionStart"]);
16535
- claudeUserPromptHook = Array.isArray(hooks["UserPromptSubmit"]);
16536
- claudePostToolHook = Array.isArray(hooks["PostToolUse"]);
16537
- claudeStopHook = Array.isArray(hooks["Stop"]);
16538
- for (const entries of Object.values(hooks)) {
16539
- if (!Array.isArray(entries))
16540
- continue;
16541
- for (const entry of entries) {
16542
- const e = entry;
16543
- if (e.hooks?.some((h) => h.command?.includes("engrm") || h.command?.includes("session-start") || h.command?.includes("user-prompt-submit") || h.command?.includes("sentinel") || h.command?.includes("post-tool-use") || h.command?.includes("pre-compact") || h.command?.includes("stop") || h.command?.includes("elicitation"))) {
16544
- claudeHookCount++;
16545
- }
16546
- }
16611
+ const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
16612
+ securityFindings = db.db.query(`SELECT * FROM security_findings
16613
+ WHERE project_id = ? AND created_at_epoch > ?
16614
+ ORDER BY severity DESC, created_at_epoch DESC
16615
+ LIMIT ?`).all(projectId, weekAgo, 10);
16616
+ } catch {}
16617
+ }
16618
+ let recentProjects;
16619
+ if (isNewProject) {
16620
+ try {
16621
+ const nowEpochSec = Math.floor(Date.now() / 1000);
16622
+ const projectRows = db.db.query(`SELECT p.name, p.canonical_id, p.last_active_epoch,
16623
+ (SELECT COUNT(*) FROM observations o
16624
+ WHERE o.project_id = p.id
16625
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
16626
+ ${opts.userId ? "AND (o.sensitivity != 'personal' OR o.user_id = ?)" : ""}
16627
+ AND o.superseded_by IS NULL) as obs_count
16628
+ FROM projects p
16629
+ ORDER BY p.last_active_epoch DESC
16630
+ LIMIT 10`).all(...visibilityParams);
16631
+ if (projectRows.length > 0) {
16632
+ recentProjects = projectRows.map((r) => {
16633
+ const daysAgo = Math.max(0, Math.floor((nowEpochSec - r.last_active_epoch) / 86400));
16634
+ const lastActive = new Date(r.last_active_epoch * 1000).toISOString().split("T")[0];
16635
+ return {
16636
+ name: r.name,
16637
+ canonical_id: r.canonical_id,
16638
+ observation_count: r.obs_count,
16639
+ last_active: lastActive,
16640
+ days_ago: daysAgo
16641
+ };
16642
+ });
16547
16643
  }
16548
16644
  } catch {}
16549
16645
  }
16550
- let codexSessionStartHook = false;
16551
- let codexStopHook = false;
16646
+ let staleDecisions;
16552
16647
  try {
16553
- const hooks = codexHooksContent ? JSON.parse(codexHooksContent)?.hooks ?? {} : {};
16554
- codexSessionStartHook = Array.isArray(hooks["SessionStart"]);
16555
- codexStopHook = Array.isArray(hooks["Stop"]);
16648
+ const stale = isNewProject ? findStaleDecisionsGlobal(db) : findStaleDecisions(db, projectId);
16649
+ if (stale.length > 0)
16650
+ staleDecisions = stale;
16556
16651
  } catch {}
16557
- const visibilityClause = input.user_id ? " AND user_id = ?" : "";
16558
- const params = input.user_id ? [sinceEpoch, input.user_id] : [sinceEpoch];
16559
- const recentUserPrompts = db.db.query(`SELECT COUNT(*) as count FROM user_prompts
16560
- WHERE created_at_epoch >= ?${visibilityClause}`).get(...params)?.count ?? 0;
16561
- const recentToolEvents = db.db.query(`SELECT COUNT(*) as count FROM tool_events
16562
- WHERE created_at_epoch >= ?${visibilityClause}`).get(...params)?.count ?? 0;
16563
- const recentSessionsWithRawCapture = db.db.query(`SELECT COUNT(*) as count
16564
- FROM sessions s
16565
- WHERE COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) >= ?
16566
- ${input.user_id ? "AND s.user_id = ?" : ""}
16567
- AND (
16568
- EXISTS (SELECT 1 FROM user_prompts up WHERE up.session_id = s.session_id)
16569
- OR EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id)
16570
- )`).get(...params)?.count ?? 0;
16571
- const recentSessionsWithPartialCapture = db.db.query(`SELECT COUNT(*) as count
16572
- FROM sessions s
16573
- WHERE COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) >= ?
16574
- ${input.user_id ? "AND s.user_id = ?" : ""}
16575
- AND (
16576
- (s.tool_calls_count > 0 AND NOT EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id))
16577
- OR (
16578
- EXISTS (SELECT 1 FROM user_prompts up WHERE up.session_id = s.session_id)
16579
- AND NOT EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id)
16580
- )
16581
- )`).get(...params)?.count ?? 0;
16582
- const latestPromptEpoch = db.db.query(`SELECT created_at_epoch FROM user_prompts
16583
- WHERE 1 = 1${input.user_id ? " AND user_id = ?" : ""}
16584
- ORDER BY created_at_epoch DESC, prompt_number DESC
16585
- LIMIT 1`).get(...input.user_id ? [input.user_id] : [])?.created_at_epoch ?? null;
16586
- const latestToolEventEpoch = db.db.query(`SELECT created_at_epoch FROM tool_events
16587
- WHERE 1 = 1${input.user_id ? " AND user_id = ?" : ""}
16588
- ORDER BY created_at_epoch DESC, id DESC
16589
- LIMIT 1`).get(...input.user_id ? [input.user_id] : [])?.created_at_epoch ?? null;
16590
- const latestPostToolHookEpoch = parseNullableInt(db.getSyncState("hook_post_tool_last_seen_epoch"));
16591
- const latestPostToolParseStatus = db.getSyncState("hook_post_tool_last_parse_status");
16592
- const latestPostToolName = db.getSyncState("hook_post_tool_last_tool_name");
16593
- const schemaVersion = getSchemaVersion(db.db);
16594
16652
  return {
16595
- schema_version: schemaVersion,
16596
- schema_current: schemaVersion >= LATEST_SCHEMA_VERSION,
16597
- claude_mcp_registered: claudeMcpRegistered,
16598
- claude_hooks_registered: claudeHooksRegistered,
16599
- claude_hook_count: claudeHookCount,
16600
- claude_session_start_hook: claudeSessionStartHook,
16601
- claude_user_prompt_hook: claudeUserPromptHook,
16602
- claude_post_tool_hook: claudePostToolHook,
16603
- claude_stop_hook: claudeStopHook,
16604
- codex_mcp_registered: codexMcpRegistered,
16605
- codex_hooks_registered: codexHooksRegistered,
16606
- codex_session_start_hook: codexSessionStartHook,
16607
- codex_stop_hook: codexStopHook,
16608
- codex_raw_chronology_supported: false,
16609
- recent_user_prompts: recentUserPrompts,
16610
- recent_tool_events: recentToolEvents,
16611
- recent_sessions_with_raw_capture: recentSessionsWithRawCapture,
16612
- recent_sessions_with_partial_capture: recentSessionsWithPartialCapture,
16613
- latest_prompt_epoch: latestPromptEpoch,
16614
- latest_tool_event_epoch: latestToolEventEpoch,
16615
- latest_post_tool_hook_epoch: latestPostToolHookEpoch,
16616
- latest_post_tool_parse_status: latestPostToolParseStatus,
16617
- latest_post_tool_name: latestPostToolName,
16618
- raw_capture_active: recentUserPrompts > 0 || recentToolEvents > 0 || recentSessionsWithRawCapture > 0
16653
+ project_name: projectName,
16654
+ canonical_id: canonicalId,
16655
+ observations: selected.map(toContextObservation),
16656
+ session_count: selected.length,
16657
+ total_active: totalActive,
16658
+ summaries: summaries.length > 0 ? summaries : undefined,
16659
+ securityFindings: securityFindings.length > 0 ? securityFindings : undefined,
16660
+ recentProjects,
16661
+ staleDecisions,
16662
+ recentPrompts: recentPrompts.length > 0 ? recentPrompts : undefined,
16663
+ recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
16664
+ recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
16665
+ projectTypeCounts,
16666
+ recentOutcomes
16619
16667
  };
16620
16668
  }
16621
- function parseNullableInt(value) {
16622
- if (!value)
16623
- return null;
16624
- const parsed = Number.parseInt(value, 10);
16625
- return Number.isFinite(parsed) ? parsed : null;
16626
- }
16627
-
16628
- // src/intelligence/followthrough.ts
16629
- var FOLLOW_THROUGH_THRESHOLD = 0.25;
16630
- var STALE_AFTER_DAYS = 3;
16631
- var DECISION_WINDOW_DAYS = 30;
16632
- var IMPLEMENTATION_TYPES = new Set([
16633
- "feature",
16634
- "bugfix",
16635
- "change",
16636
- "refactor"
16637
- ]);
16638
- function findStaleDecisions(db, projectId, options) {
16639
- const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
16640
- const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
16641
- const nowEpoch = Math.floor(Date.now() / 1000);
16642
- const windowStart = nowEpoch - windowDays * 86400;
16643
- const staleThreshold = nowEpoch - staleAfterDays * 86400;
16644
- const decisions = db.db.query(`SELECT * FROM observations
16645
- WHERE project_id = ? AND type = 'decision'
16646
- AND lifecycle IN ('active', 'aging', 'pinned')
16647
- AND superseded_by IS NULL
16648
- AND created_at_epoch >= ?
16649
- ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
16650
- if (decisions.length === 0)
16651
- return [];
16652
- const implementations = db.db.query(`SELECT * FROM observations
16653
- WHERE project_id = ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
16654
- AND lifecycle IN ('active', 'aging', 'pinned')
16655
- AND superseded_by IS NULL
16656
- AND created_at_epoch >= ?
16657
- ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
16658
- const crossProjectImpls = db.db.query(`SELECT * FROM observations
16659
- WHERE project_id != ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
16660
- AND lifecycle IN ('active', 'aging', 'pinned')
16661
- AND superseded_by IS NULL
16662
- AND created_at_epoch >= ?
16663
- ORDER BY created_at_epoch DESC
16664
- LIMIT 200`).all(projectId, windowStart);
16665
- const allImpls = [...implementations, ...crossProjectImpls];
16666
- const stale = [];
16667
- for (const decision of decisions) {
16668
- if (decision.created_at_epoch > staleThreshold)
16669
- continue;
16670
- const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
16671
- let decisionConcepts = [];
16672
- try {
16673
- const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
16674
- if (Array.isArray(parsed))
16675
- decisionConcepts = parsed;
16676
- } catch {}
16677
- let bestTitle = "";
16678
- let bestScore = 0;
16679
- for (const impl of allImpls) {
16680
- if (impl.created_at_epoch <= decision.created_at_epoch)
16681
- continue;
16682
- const titleScore = jaccardSimilarity(decision.title, impl.title);
16683
- let conceptBoost = 0;
16684
- if (decisionConcepts.length > 0) {
16685
- try {
16686
- const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
16687
- if (Array.isArray(implConcepts) && implConcepts.length > 0) {
16688
- const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
16689
- const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
16690
- conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
16691
- }
16692
- } catch {}
16669
+ function estimateObservationTokens(obs, index) {
16670
+ const DETAILED_THRESHOLD = 5;
16671
+ const titleCost = estimateTokens(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
16672
+ if (index >= DETAILED_THRESHOLD) {
16673
+ return titleCost;
16674
+ }
16675
+ const detailText = formatObservationDetail(obs);
16676
+ return titleCost + estimateTokens(detailText);
16677
+ }
16678
+ function formatContextForInjection(context) {
16679
+ if (context.observations.length === 0 && (!context.recentPrompts || context.recentPrompts.length === 0) && (!context.recentToolEvents || context.recentToolEvents.length === 0) && (!context.recentSessions || context.recentSessions.length === 0) && (!context.projectTypeCounts || Object.keys(context.projectTypeCounts).length === 0)) {
16680
+ return `Project: ${context.project_name} (no prior observations)`;
16681
+ }
16682
+ const DETAILED_COUNT = 5;
16683
+ const isCrossProject = context.recentProjects && context.recentProjects.length > 0;
16684
+ const lines = [];
16685
+ if (isCrossProject) {
16686
+ lines.push(`## Engrm Memory — Workspace Overview`);
16687
+ lines.push(`This is a new project folder. Here is context from your recent work:`);
16688
+ lines.push("");
16689
+ lines.push("**Active projects in memory:**");
16690
+ for (const rp of context.recentProjects) {
16691
+ const activity = rp.days_ago === 0 ? "today" : rp.days_ago === 1 ? "yesterday" : `${rp.days_ago}d ago`;
16692
+ lines.push(`- **${rp.name}** ${rp.observation_count} observations, last active ${activity}`);
16693
+ }
16694
+ lines.push("");
16695
+ lines.push(`${context.session_count} relevant observation(s) from across projects:`);
16696
+ lines.push("");
16697
+ } else {
16698
+ lines.push(`## Project Memory: ${context.project_name}`);
16699
+ lines.push(`${context.session_count} relevant observation(s) from prior sessions:`);
16700
+ lines.push("");
16701
+ }
16702
+ if (context.recentPrompts && context.recentPrompts.length > 0) {
16703
+ const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
16704
+ if (promptLines.length > 0) {
16705
+ lines.push("## Recent Requests");
16706
+ for (const prompt of promptLines) {
16707
+ const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
16708
+ lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
16693
16709
  }
16694
- let narrativeBoost = 0;
16695
- if (impl.narrative) {
16696
- const decWords = new Set(decision.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3));
16697
- if (decWords.size > 0) {
16698
- const implNarrativeLower = impl.narrative.toLowerCase();
16699
- const hits = [...decWords].filter((w) => implNarrativeLower.includes(w)).length;
16700
- narrativeBoost = hits / decWords.size * 0.1;
16701
- }
16710
+ lines.push("");
16711
+ }
16712
+ }
16713
+ if (context.recentToolEvents && context.recentToolEvents.length > 0) {
16714
+ lines.push("## Recent Tools");
16715
+ for (const tool of context.recentToolEvents.slice(0, 5)) {
16716
+ lines.push(`- ${tool.tool_name}: ${formatToolEventDetail(tool)}`);
16717
+ }
16718
+ lines.push("");
16719
+ }
16720
+ if (context.recentSessions && context.recentSessions.length > 0) {
16721
+ const recentSessionLines = context.recentSessions.slice(0, 4).map((session) => {
16722
+ const summary = chooseMeaningfulSessionHeadline(session.request, session.completed);
16723
+ if (summary === "(no summary)")
16724
+ return null;
16725
+ return `- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
16726
+ }).filter((line) => Boolean(line));
16727
+ if (recentSessionLines.length > 0) {
16728
+ lines.push("## Recent Sessions");
16729
+ lines.push(...recentSessionLines);
16730
+ lines.push("");
16731
+ }
16732
+ }
16733
+ if (context.recentOutcomes && context.recentOutcomes.length > 0) {
16734
+ lines.push("## Recent Outcomes");
16735
+ for (const outcome of context.recentOutcomes.slice(0, 5)) {
16736
+ lines.push(`- ${truncateText(outcome, 160)}`);
16737
+ }
16738
+ lines.push("");
16739
+ }
16740
+ if (context.projectTypeCounts && Object.keys(context.projectTypeCounts).length > 0) {
16741
+ const topTypes = Object.entries(context.projectTypeCounts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([type, count]) => `${type} ${count}`).join(" · ");
16742
+ if (topTypes) {
16743
+ lines.push(`## Project Signals`);
16744
+ lines.push(`Top memory types: ${topTypes}`);
16745
+ lines.push("");
16746
+ }
16747
+ }
16748
+ for (let i = 0;i < context.observations.length; i++) {
16749
+ const obs = context.observations[i];
16750
+ const date5 = obs.created_at.split("T")[0];
16751
+ const fromLabel = obs.source_project ? ` [from: ${obs.source_project}]` : "";
16752
+ const fileLabel = formatObservationFiles(obs);
16753
+ lines.push(`- **#${obs.id} [${obs.type}]** ${obs.title} (${date5}, q=${obs.quality.toFixed(1)})${fromLabel}${fileLabel}`);
16754
+ if (i < DETAILED_COUNT) {
16755
+ const detail = formatObservationDetailFromContext(obs);
16756
+ if (detail) {
16757
+ lines.push(detail);
16702
16758
  }
16703
- const totalScore = titleScore + conceptBoost + narrativeBoost;
16704
- if (totalScore > bestScore) {
16705
- bestScore = totalScore;
16706
- bestTitle = impl.title;
16759
+ }
16760
+ }
16761
+ if (context.summaries && context.summaries.length > 0) {
16762
+ lines.push("");
16763
+ lines.push("## Recent Project Briefs");
16764
+ for (const summary of context.summaries.slice(0, 3)) {
16765
+ lines.push(...formatSessionBrief(summary));
16766
+ lines.push("");
16767
+ }
16768
+ }
16769
+ if (context.securityFindings && context.securityFindings.length > 0) {
16770
+ lines.push("");
16771
+ lines.push("Security findings (recent):");
16772
+ for (const finding of context.securityFindings) {
16773
+ const date5 = new Date(finding.created_at_epoch * 1000).toISOString().split("T")[0];
16774
+ const file2 = finding.file_path ? ` in ${finding.file_path}` : finding.tool_name ? ` via ${finding.tool_name}` : "";
16775
+ lines.push(`- [${finding.severity.toUpperCase()}] ${finding.pattern_name}${file2} (${date5})`);
16776
+ }
16777
+ }
16778
+ if (context.staleDecisions && context.staleDecisions.length > 0) {
16779
+ lines.push("");
16780
+ lines.push("Stale commitments (decided but no implementation observed):");
16781
+ for (const sd of context.staleDecisions) {
16782
+ const date5 = sd.created_at.split("T")[0];
16783
+ lines.push(`- [DECISION] ${sd.title} (${date5}, ${sd.days_ago}d ago)`);
16784
+ if (sd.best_match_title) {
16785
+ lines.push(` Closest match: "${sd.best_match_title}" (${Math.round((sd.best_match_similarity ?? 0) * 100)}% similar — not enough to count as done)`);
16707
16786
  }
16708
16787
  }
16709
- if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
16710
- stale.push({
16711
- id: decision.id,
16712
- title: decision.title,
16713
- narrative: decision.narrative,
16714
- concepts: decisionConcepts,
16715
- created_at: decision.created_at,
16716
- days_ago: daysAgo,
16717
- ...bestScore > 0.1 ? {
16718
- best_match_title: bestTitle,
16719
- best_match_similarity: Math.round(bestScore * 100) / 100
16720
- } : {}
16721
- });
16788
+ }
16789
+ const remaining = context.total_active - context.session_count;
16790
+ if (remaining > 0) {
16791
+ lines.push("");
16792
+ lines.push(`${remaining} more observation(s) available via search tool.`);
16793
+ }
16794
+ return lines.join(`
16795
+ `);
16796
+ }
16797
+ function formatSessionBrief(summary) {
16798
+ const lines = [];
16799
+ const heading = summary.request ? `### ${truncateText(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
16800
+ lines.push(heading);
16801
+ const sections = [
16802
+ ["Investigated", summary.investigated, 180],
16803
+ ["Learned", summary.learned, 180],
16804
+ ["Completed", summary.completed, 180],
16805
+ ["Next Steps", summary.next_steps, 140]
16806
+ ];
16807
+ for (const [label, value, maxLen] of sections) {
16808
+ const formatted = formatSummarySection(value, maxLen);
16809
+ if (formatted) {
16810
+ lines.push(`${label}:`);
16811
+ lines.push(formatted);
16722
16812
  }
16723
16813
  }
16724
- stale.sort((a, b) => b.days_ago - a.days_ago);
16725
- return stale.slice(0, 5);
16814
+ return lines;
16726
16815
  }
16727
- function findStaleDecisionsGlobal(db, options) {
16728
- const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
16729
- const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
16730
- const nowEpoch = Math.floor(Date.now() / 1000);
16731
- const windowStart = nowEpoch - windowDays * 86400;
16732
- const staleThreshold = nowEpoch - staleAfterDays * 86400;
16733
- const decisions = db.db.query(`SELECT * FROM observations
16734
- WHERE type = 'decision'
16735
- AND lifecycle IN ('active', 'aging', 'pinned')
16736
- AND superseded_by IS NULL
16737
- AND created_at_epoch >= ?
16738
- ORDER BY created_at_epoch DESC`).all(windowStart);
16739
- if (decisions.length === 0)
16816
+ function chooseMeaningfulSessionHeadline(request, completed) {
16817
+ if (request && !looksLikeFileOperationTitle2(request))
16818
+ return request;
16819
+ const completedItems = extractMeaningfulLines(completed, 1);
16820
+ if (completedItems.length > 0)
16821
+ return completedItems[0];
16822
+ return request ?? completed ?? "(no summary)";
16823
+ }
16824
+ function formatSummarySection(value, maxLen) {
16825
+ return formatSummaryItems(value, maxLen);
16826
+ }
16827
+ function truncateText(text, maxLen) {
16828
+ if (text.length <= maxLen)
16829
+ return text;
16830
+ return text.slice(0, maxLen - 3) + "...";
16831
+ }
16832
+ function isMeaningfulPrompt(value) {
16833
+ if (!value)
16834
+ return false;
16835
+ const compact = value.replace(/\s+/g, " ").trim();
16836
+ if (compact.length < 8)
16837
+ return false;
16838
+ return /[a-z]{3,}/i.test(compact);
16839
+ }
16840
+ function looksLikeFileOperationTitle2(value) {
16841
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
16842
+ }
16843
+ function stripInlineSectionLabel(value) {
16844
+ return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
16845
+ }
16846
+ function extractMeaningfulLines(value, limit) {
16847
+ if (!value)
16740
16848
  return [];
16741
- const implementations = db.db.query(`SELECT * FROM observations
16742
- WHERE type IN ('feature', 'bugfix', 'change', 'refactor')
16743
- AND lifecycle IN ('active', 'aging', 'pinned')
16744
- AND superseded_by IS NULL
16745
- AND created_at_epoch >= ?
16746
- ORDER BY created_at_epoch DESC
16747
- LIMIT 500`).all(windowStart);
16748
- const stale = [];
16749
- for (const decision of decisions) {
16750
- if (decision.created_at_epoch > staleThreshold)
16751
- continue;
16752
- const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
16753
- let decisionConcepts = [];
16754
- try {
16755
- const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
16756
- if (Array.isArray(parsed))
16757
- decisionConcepts = parsed;
16758
- } catch {}
16759
- let bestScore = 0;
16760
- let bestTitle = "";
16761
- for (const impl of implementations) {
16762
- if (impl.created_at_epoch <= decision.created_at_epoch)
16763
- continue;
16764
- const titleScore = jaccardSimilarity(decision.title, impl.title);
16765
- let conceptBoost = 0;
16766
- if (decisionConcepts.length > 0) {
16767
- try {
16768
- const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
16769
- if (Array.isArray(implConcepts) && implConcepts.length > 0) {
16770
- const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
16771
- const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
16772
- conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
16773
- }
16774
- } catch {}
16775
- }
16776
- const totalScore = titleScore + conceptBoost;
16777
- if (totalScore > bestScore) {
16778
- bestScore = totalScore;
16779
- bestTitle = impl.title;
16780
- }
16849
+ return extractSummaryItems(value).map((line) => stripInlineSectionLabel(line)).filter((line) => line.length > 0 && !looksLikeFileOperationTitle2(line)).slice(0, limit);
16850
+ }
16851
+ function formatObservationDetailFromContext(obs) {
16852
+ if (obs.facts) {
16853
+ const bullets = parseFacts(obs.facts);
16854
+ if (bullets.length > 0) {
16855
+ return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
16856
+ `);
16857
+ }
16858
+ }
16859
+ if (obs.narrative) {
16860
+ const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
16861
+ return ` ${snippet}`;
16862
+ }
16863
+ return null;
16864
+ }
16865
+ function formatObservationDetail(obs) {
16866
+ if (obs.facts) {
16867
+ const bullets = parseFacts(obs.facts);
16868
+ if (bullets.length > 0) {
16869
+ return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
16870
+ `);
16781
16871
  }
16782
- if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
16783
- stale.push({
16784
- id: decision.id,
16785
- title: decision.title,
16786
- narrative: decision.narrative,
16787
- concepts: decisionConcepts,
16788
- created_at: decision.created_at,
16789
- days_ago: daysAgo,
16790
- ...bestScore > 0.1 ? {
16791
- best_match_title: bestTitle,
16792
- best_match_similarity: Math.round(bestScore * 100) / 100
16793
- } : {}
16794
- });
16872
+ }
16873
+ if (obs.narrative) {
16874
+ const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
16875
+ return ` ${snippet}`;
16876
+ }
16877
+ return "";
16878
+ }
16879
+ function parseFacts(facts) {
16880
+ if (!facts)
16881
+ return [];
16882
+ try {
16883
+ const parsed = JSON.parse(facts);
16884
+ if (Array.isArray(parsed)) {
16885
+ return parsed.filter((f) => typeof f === "string" && f.length > 0);
16886
+ }
16887
+ } catch {
16888
+ if (facts.trim().length > 0) {
16889
+ return [facts.trim()];
16795
16890
  }
16796
16891
  }
16797
- stale.sort((a, b) => b.days_ago - a.days_ago);
16798
- return stale.slice(0, 5);
16892
+ return [];
16799
16893
  }
16800
-
16801
- // src/context/inject.ts
16802
- function tokenizeProjectHint(text) {
16803
- return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
16894
+ function toContextObservation(obs) {
16895
+ return {
16896
+ id: obs.id,
16897
+ type: obs.type,
16898
+ title: obs.title,
16899
+ narrative: obs.narrative,
16900
+ facts: obs.facts,
16901
+ files_read: obs.files_read,
16902
+ files_modified: obs.files_modified,
16903
+ quality: obs.quality,
16904
+ created_at: obs.created_at,
16905
+ ...obs._source_project ? { source_project: obs._source_project } : {}
16906
+ };
16804
16907
  }
16805
- function isObservationRelatedToProject(obs, detected) {
16806
- const hints = new Set([
16807
- ...tokenizeProjectHint(detected.name),
16808
- ...tokenizeProjectHint(detected.canonical_id)
16809
- ]);
16810
- if (hints.size === 0)
16811
- return false;
16812
- const haystack = [
16813
- obs.title,
16814
- obs.narrative ?? "",
16815
- obs.facts ?? "",
16816
- obs.concepts ?? "",
16817
- obs.files_read ?? "",
16818
- obs.files_modified ?? "",
16819
- obs._source_project ?? ""
16820
- ].join(`
16821
- `).toLowerCase();
16822
- for (const hint of hints) {
16823
- if (haystack.includes(hint))
16824
- return true;
16908
+ function formatObservationFiles(obs) {
16909
+ const modified = parseJsonStringArray(obs.files_modified);
16910
+ if (modified.length > 0) {
16911
+ return ` · files: ${truncateText(modified.slice(0, 2).join(", "), 60)}`;
16825
16912
  }
16826
- return false;
16913
+ const read = parseJsonStringArray(obs.files_read);
16914
+ if (read.length > 0) {
16915
+ return ` · read: ${truncateText(read.slice(0, 2).join(", "), 60)}`;
16916
+ }
16917
+ return "";
16827
16918
  }
16828
- function estimateTokens(text) {
16829
- if (!text)
16830
- return 0;
16831
- return Math.ceil(text.length / 4);
16919
+ function parseJsonStringArray(value) {
16920
+ if (!value)
16921
+ return [];
16922
+ try {
16923
+ const parsed = JSON.parse(value);
16924
+ if (!Array.isArray(parsed))
16925
+ return [];
16926
+ return parsed.filter((item) => typeof item === "string" && item.trim().length > 0);
16927
+ } catch {
16928
+ return [];
16929
+ }
16832
16930
  }
16833
- function buildSessionContext(db, cwd, options = {}) {
16834
- const opts = typeof options === "number" ? { maxCount: options } : options;
16835
- const tokenBudget = opts.tokenBudget ?? 3000;
16836
- const maxCount = opts.maxCount;
16837
- const visibilityClause = opts.userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
16838
- const visibilityParams = opts.userId ? [opts.userId] : [];
16839
- const detected = detectProject(cwd);
16840
- const project = db.getProjectByCanonicalId(detected.canonical_id);
16841
- const projectId = project?.id ?? -1;
16842
- const isNewProject = !project;
16843
- const totalActive = isNewProject ? (db.db.query(`SELECT COUNT(*) as c FROM observations
16844
- WHERE lifecycle IN ('active', 'aging', 'pinned')
16845
- ${visibilityClause}
16846
- AND superseded_by IS NULL`).get(...visibilityParams) ?? { c: 0 }).c : (db.db.query(`SELECT COUNT(*) as c FROM observations
16847
- WHERE project_id = ? AND lifecycle IN ('active', 'aging', 'pinned')
16848
- ${visibilityClause}
16849
- AND superseded_by IS NULL`).get(projectId, ...visibilityParams) ?? { c: 0 }).c;
16850
- const candidateLimit = maxCount ?? 50;
16851
- let pinned = [];
16852
- let recent = [];
16853
- let candidates = [];
16854
- if (!isNewProject) {
16855
- const MAX_PINNED = 5;
16856
- pinned = db.db.query(`SELECT * FROM observations
16857
- WHERE project_id = ? AND lifecycle = 'pinned'
16858
- AND superseded_by IS NULL
16859
- ${visibilityClause}
16860
- ORDER BY quality DESC, created_at_epoch DESC
16861
- LIMIT ?`).all(projectId, ...visibilityParams, MAX_PINNED);
16862
- const MAX_RECENT = 5;
16863
- recent = db.db.query(`SELECT * FROM observations
16864
- WHERE project_id = ? AND lifecycle IN ('active', 'aging')
16865
- AND superseded_by IS NULL
16866
- ${visibilityClause}
16867
- ORDER BY created_at_epoch DESC
16868
- LIMIT ?`).all(projectId, ...visibilityParams, MAX_RECENT);
16869
- candidates = db.db.query(`SELECT * FROM observations
16870
- WHERE project_id = ? AND lifecycle IN ('active', 'aging')
16871
- AND quality >= 0.3
16931
+ function formatToolEventDetail(tool) {
16932
+ const detail = tool.file_path ?? tool.command ?? tool.tool_response_preview ?? "";
16933
+ return truncateText(detail || "recent tool execution", 160);
16934
+ }
16935
+ function getProjectTypeCounts(db, projectId, userId) {
16936
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
16937
+ const rows = db.db.query(`SELECT type, COUNT(*) as count
16938
+ FROM observations
16939
+ WHERE project_id = ?
16940
+ AND lifecycle IN ('active', 'aging', 'pinned')
16872
16941
  AND superseded_by IS NULL
16873
16942
  ${visibilityClause}
16874
- ORDER BY quality DESC, created_at_epoch DESC
16875
- LIMIT ?`).all(projectId, ...visibilityParams, candidateLimit);
16943
+ GROUP BY type`).all(projectId, ...userId ? [userId] : []);
16944
+ const counts = {};
16945
+ for (const row of rows) {
16946
+ counts[row.type] = row.count;
16876
16947
  }
16877
- let crossProjectCandidates = [];
16878
- if (opts.scope === "all" || isNewProject) {
16879
- const crossLimit = isNewProject ? Math.max(30, candidateLimit) : Math.max(10, Math.floor(candidateLimit / 3));
16880
- const qualityThreshold = isNewProject ? 0.3 : 0.5;
16881
- const rawCross = isNewProject ? db.db.query(`SELECT * FROM observations
16882
- WHERE lifecycle IN ('active', 'aging', 'pinned')
16883
- AND quality >= ?
16884
- AND superseded_by IS NULL
16885
- ${visibilityClause}
16886
- ORDER BY quality DESC, created_at_epoch DESC
16887
- LIMIT ?`).all(qualityThreshold, ...visibilityParams, crossLimit) : db.db.query(`SELECT * FROM observations
16888
- WHERE project_id != ? AND lifecycle IN ('active', 'aging')
16889
- AND quality >= ?
16890
- AND superseded_by IS NULL
16891
- ${visibilityClause}
16892
- ORDER BY quality DESC, created_at_epoch DESC
16893
- LIMIT ?`).all(projectId, qualityThreshold, ...visibilityParams, crossLimit);
16894
- const projectNameCache = new Map;
16895
- crossProjectCandidates = rawCross.map((obs) => {
16896
- if (!projectNameCache.has(obs.project_id)) {
16897
- const proj = db.getProjectById(obs.project_id);
16898
- if (proj)
16899
- projectNameCache.set(obs.project_id, proj.name);
16900
- }
16901
- return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
16902
- });
16903
- if (isNewProject) {
16904
- crossProjectCandidates = crossProjectCandidates.filter((obs) => isObservationRelatedToProject(obs, detected));
16948
+ return counts;
16949
+ }
16950
+ function getRecentOutcomes(db, projectId, userId) {
16951
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
16952
+ const visibilityParams = userId ? [userId] : [];
16953
+ const summaries = db.db.query(`SELECT * FROM session_summaries
16954
+ WHERE project_id = ?
16955
+ ORDER BY created_at_epoch DESC
16956
+ LIMIT 6`).all(projectId);
16957
+ const picked = [];
16958
+ const seen = new Set;
16959
+ for (const summary of summaries) {
16960
+ for (const line of [
16961
+ ...extractMeaningfulLines(summary.completed, 2),
16962
+ ...extractMeaningfulLines(summary.learned, 1)
16963
+ ]) {
16964
+ const normalized = line.toLowerCase().replace(/\s+/g, " ").trim();
16965
+ if (!normalized || seen.has(normalized))
16966
+ continue;
16967
+ seen.add(normalized);
16968
+ picked.push(line);
16969
+ if (picked.length >= 5)
16970
+ return picked;
16905
16971
  }
16906
16972
  }
16907
- const seenIds = new Set(pinned.map((o) => o.id));
16908
- const dedupedRecent = recent.filter((o) => {
16909
- if (seenIds.has(o.id))
16910
- return false;
16911
- seenIds.add(o.id);
16912
- return true;
16913
- });
16914
- const deduped = candidates.filter((o) => !seenIds.has(o.id));
16915
- for (const obs of crossProjectCandidates) {
16916
- if (!seenIds.has(obs.id)) {
16917
- seenIds.add(obs.id);
16918
- deduped.push(obs);
16919
- }
16973
+ const rows = db.db.query(`SELECT * FROM observations
16974
+ WHERE project_id = ?
16975
+ AND lifecycle IN ('active', 'aging', 'pinned')
16976
+ AND superseded_by IS NULL
16977
+ ${visibilityClause}
16978
+ ORDER BY created_at_epoch DESC
16979
+ LIMIT 20`).all(projectId, ...visibilityParams);
16980
+ for (const obs of rows) {
16981
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
16982
+ continue;
16983
+ const title = stripInlineSectionLabel(obs.title);
16984
+ const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
16985
+ if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle2(title))
16986
+ continue;
16987
+ seen.add(normalized);
16988
+ picked.push(title);
16989
+ if (picked.length >= 5)
16990
+ break;
16991
+ }
16992
+ return picked;
16993
+ }
16994
+
16995
+ // src/tools/project-memory-index.ts
16996
+ function getProjectMemoryIndex(db, input) {
16997
+ const cwd = input.cwd ?? process.cwd();
16998
+ const detected = detectProject(cwd);
16999
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
17000
+ if (!project)
17001
+ return null;
17002
+ const visibilityClause = input.user_id ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
17003
+ const visibilityParams = input.user_id ? [input.user_id] : [];
17004
+ const observations = db.db.query(`SELECT * FROM observations
17005
+ WHERE project_id = ?
17006
+ AND lifecycle IN ('active', 'aging', 'pinned')
17007
+ AND superseded_by IS NULL
17008
+ ${visibilityClause}
17009
+ ORDER BY created_at_epoch DESC`).all(project.id, ...visibilityParams);
17010
+ const counts = {};
17011
+ for (const obs of observations) {
17012
+ counts[obs.type] = (counts[obs.type] ?? 0) + 1;
16920
17013
  }
16921
- const nowEpoch = Math.floor(Date.now() / 1000);
16922
- const sorted = [...deduped].sort((a, b) => {
16923
- const scoreA = computeObservationPriority(a, nowEpoch);
16924
- const scoreB = computeObservationPriority(b, nowEpoch);
16925
- return scoreB - scoreA;
16926
- });
16927
- const projectName = project?.name ?? detected.name;
16928
- const canonicalId = project?.canonical_id ?? detected.canonical_id;
16929
- if (maxCount !== undefined) {
16930
- const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
16931
- const all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
16932
- const recentPrompts2 = db.getRecentUserPrompts(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16933
- const recentToolEvents2 = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16934
- const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
16935
- const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
16936
- const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
16937
- return {
16938
- project_name: projectName,
16939
- canonical_id: canonicalId,
16940
- observations: all.map(toContextObservation),
16941
- session_count: all.length,
16942
- total_active: totalActive,
16943
- recentPrompts: recentPrompts2.length > 0 ? recentPrompts2 : undefined,
16944
- recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
16945
- recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
16946
- projectTypeCounts: projectTypeCounts2,
16947
- recentOutcomes: recentOutcomes2
16948
- };
17014
+ const fileCounts = new Map;
17015
+ for (const obs of observations) {
17016
+ for (const path of extractPaths(obs.files_modified)) {
17017
+ fileCounts.set(path, (fileCounts.get(path) ?? 0) + 1);
17018
+ }
16949
17019
  }
16950
- let remainingBudget = tokenBudget - 30;
16951
- const selected = [];
16952
- for (const obs of pinned) {
16953
- const cost = estimateObservationTokens(obs, selected.length);
16954
- remainingBudget -= cost;
16955
- selected.push(obs);
17020
+ const hotFiles = Array.from(fileCounts.entries()).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)).slice(0, 8);
17021
+ const provenanceSummary = Array.from(observations.reduce((acc, obs) => {
17022
+ if (!obs.source_tool)
17023
+ return acc;
17024
+ acc.set(obs.source_tool, (acc.get(obs.source_tool) ?? 0) + 1);
17025
+ return acc;
17026
+ }, new Map).entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
17027
+ const assistantCheckpointCount = observations.filter((obs) => obs.source_tool === "assistant-stop").length;
17028
+ const assistantCheckpointTypes = Array.from(observations.filter((obs) => obs.source_tool === "assistant-stop").reduce((acc, obs) => {
17029
+ acc.set(obs.type, (acc.get(obs.type) ?? 0) + 1);
17030
+ return acc;
17031
+ }, new Map).entries()).map(([type, count]) => ({ type, count })).sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)).slice(0, 5);
17032
+ const topTitles = observations.slice(0, 12).map((obs) => ({
17033
+ type: obs.type,
17034
+ title: obs.title,
17035
+ id: obs.id
17036
+ }));
17037
+ const recentSessions = getRecentSessions(db, {
17038
+ cwd,
17039
+ project_scoped: true,
17040
+ user_id: input.user_id,
17041
+ limit: 6
17042
+ }).sessions;
17043
+ const recentRequestsCount = getRecentRequests(db, {
17044
+ cwd,
17045
+ project_scoped: true,
17046
+ user_id: input.user_id,
17047
+ limit: 20
17048
+ }).prompts.length;
17049
+ const recentToolsCount = getRecentTools(db, {
17050
+ cwd,
17051
+ project_scoped: true,
17052
+ user_id: input.user_id,
17053
+ limit: 20
17054
+ }).tool_events.length;
17055
+ const recentOutcomes = observations.filter((obs) => ["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type)).map((obs) => obs.title.trim()).filter((title) => title.length > 0 && !looksLikeFileOperationTitle3(title)).slice(0, 8);
17056
+ const captureSummary = summarizeCaptureState(recentSessions);
17057
+ const topTypes = Object.entries(counts).map(([type, count]) => ({ type, count })).sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)).slice(0, 5);
17058
+ const suggestedTools = buildSuggestedTools(recentSessions, recentRequestsCount, recentToolsCount, observations.length);
17059
+ const estimatedReadTokens = estimateTokens([
17060
+ recentOutcomes.join(`
17061
+ `),
17062
+ topTitles.map((item) => `${item.type}: ${item.title}`).join(`
17063
+ `),
17064
+ hotFiles.map((item) => `${item.path} (${item.count})`).join(`
17065
+ `)
17066
+ ].filter(Boolean).join(`
17067
+ `));
17068
+ return {
17069
+ project: project.name,
17070
+ canonical_id: project.canonical_id,
17071
+ observation_counts: counts,
17072
+ recent_sessions: recentSessions,
17073
+ recent_outcomes: recentOutcomes,
17074
+ recent_requests_count: recentRequestsCount,
17075
+ recent_tools_count: recentToolsCount,
17076
+ raw_capture_active: recentRequestsCount > 0 || recentToolsCount > 0,
17077
+ capture_summary: captureSummary,
17078
+ hot_files: hotFiles,
17079
+ provenance_summary: provenanceSummary,
17080
+ assistant_checkpoint_count: assistantCheckpointCount,
17081
+ assistant_checkpoint_types: assistantCheckpointTypes,
17082
+ top_titles: topTitles,
17083
+ top_types: topTypes,
17084
+ estimated_read_tokens: estimatedReadTokens,
17085
+ suggested_tools: suggestedTools
17086
+ };
17087
+ }
17088
+ function extractPaths(value) {
17089
+ if (!value)
17090
+ return [];
17091
+ try {
17092
+ const parsed = JSON.parse(value);
17093
+ if (!Array.isArray(parsed))
17094
+ return [];
17095
+ return parsed.filter((item) => typeof item === "string" && item.trim().length > 0);
17096
+ } catch {
17097
+ return [];
16956
17098
  }
16957
- for (const obs of dedupedRecent) {
16958
- const cost = estimateObservationTokens(obs, selected.length);
16959
- remainingBudget -= cost;
16960
- selected.push(obs);
17099
+ }
17100
+ function looksLikeFileOperationTitle3(value) {
17101
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
17102
+ }
17103
+ function summarizeCaptureState(sessions) {
17104
+ const summary = {
17105
+ rich_sessions: 0,
17106
+ partial_sessions: 0,
17107
+ summary_only_sessions: 0,
17108
+ legacy_sessions: 0
17109
+ };
17110
+ for (const session of sessions) {
17111
+ switch (session.capture_state) {
17112
+ case "rich":
17113
+ summary.rich_sessions += 1;
17114
+ break;
17115
+ case "partial":
17116
+ summary.partial_sessions += 1;
17117
+ break;
17118
+ case "summary-only":
17119
+ summary.summary_only_sessions += 1;
17120
+ break;
17121
+ default:
17122
+ summary.legacy_sessions += 1;
17123
+ break;
17124
+ }
16961
17125
  }
16962
- for (const obs of sorted) {
16963
- const cost = estimateObservationTokens(obs, selected.length);
16964
- if (remainingBudget - cost < 0 && selected.length > 0)
16965
- break;
16966
- remainingBudget -= cost;
16967
- selected.push(obs);
17126
+ return summary;
17127
+ }
17128
+ function buildSuggestedTools(sessions, requestCount, toolCount, observationCount) {
17129
+ const suggested = [];
17130
+ if (sessions.length > 0) {
17131
+ suggested.push("recent_sessions");
16968
17132
  }
16969
- const summaries = isNewProject ? [] : db.getRecentSummaries(projectId, 5);
16970
- const recentPrompts = db.getRecentUserPrompts(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16971
- const recentToolEvents = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16972
- const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
16973
- const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
16974
- const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
16975
- let securityFindings = [];
16976
- if (!isNewProject) {
16977
- try {
16978
- const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
16979
- securityFindings = db.db.query(`SELECT * FROM security_findings
16980
- WHERE project_id = ? AND created_at_epoch > ?
16981
- ORDER BY severity DESC, created_at_epoch DESC
16982
- LIMIT ?`).all(projectId, weekAgo, 10);
16983
- } catch {}
17133
+ if (requestCount > 0 || toolCount > 0) {
17134
+ suggested.push("activity_feed");
16984
17135
  }
16985
- let recentProjects;
16986
- if (isNewProject) {
16987
- try {
16988
- const nowEpochSec = Math.floor(Date.now() / 1000);
16989
- const projectRows = db.db.query(`SELECT p.name, p.canonical_id, p.last_active_epoch,
16990
- (SELECT COUNT(*) FROM observations o
16991
- WHERE o.project_id = p.id
16992
- AND o.lifecycle IN ('active', 'aging', 'pinned')
16993
- ${opts.userId ? "AND (o.sensitivity != 'personal' OR o.user_id = ?)" : ""}
16994
- AND o.superseded_by IS NULL) as obs_count
16995
- FROM projects p
16996
- ORDER BY p.last_active_epoch DESC
16997
- LIMIT 10`).all(...visibilityParams);
16998
- if (projectRows.length > 0) {
16999
- recentProjects = projectRows.map((r) => {
17000
- const daysAgo = Math.max(0, Math.floor((nowEpochSec - r.last_active_epoch) / 86400));
17001
- const lastActive = new Date(r.last_active_epoch * 1000).toISOString().split("T")[0];
17002
- return {
17003
- name: r.name,
17004
- canonical_id: r.canonical_id,
17005
- observation_count: r.obs_count,
17006
- last_active: lastActive,
17007
- days_ago: daysAgo
17008
- };
17009
- });
17010
- }
17011
- } catch {}
17136
+ if (observationCount > 0) {
17137
+ suggested.push("tool_memory_index", "capture_git_worktree");
17012
17138
  }
17013
- let staleDecisions;
17014
- try {
17015
- const stale = isNewProject ? findStaleDecisionsGlobal(db) : findStaleDecisions(db, projectId);
17016
- if (stale.length > 0)
17017
- staleDecisions = stale;
17018
- } catch {}
17139
+ return Array.from(new Set(suggested)).slice(0, 4);
17140
+ }
17141
+
17142
+ // src/tools/memory-console.ts
17143
+ function getMemoryConsole(db, input) {
17144
+ const cwd = input.cwd ?? process.cwd();
17145
+ const projectScoped = input.project_scoped !== false;
17146
+ const detected = projectScoped ? detectProject(cwd) : null;
17147
+ const project = detected ? db.getProjectByCanonicalId(detected.canonical_id) : null;
17148
+ const sessions = getRecentSessions(db, {
17149
+ cwd,
17150
+ project_scoped: projectScoped,
17151
+ user_id: input.user_id,
17152
+ limit: 6
17153
+ }).sessions;
17154
+ const requests = getRecentRequests(db, {
17155
+ cwd,
17156
+ project_scoped: projectScoped,
17157
+ user_id: input.user_id,
17158
+ limit: 6
17159
+ }).prompts;
17160
+ const tools = getRecentTools(db, {
17161
+ cwd,
17162
+ project_scoped: projectScoped,
17163
+ user_id: input.user_id,
17164
+ limit: 8
17165
+ }).tool_events;
17166
+ const observations = getRecentActivity(db, {
17167
+ cwd,
17168
+ project_scoped: projectScoped,
17169
+ user_id: input.user_id,
17170
+ limit: 8
17171
+ }).observations;
17172
+ const projectIndex = projectScoped ? getProjectMemoryIndex(db, {
17173
+ cwd,
17174
+ user_id: input.user_id
17175
+ }) : null;
17019
17176
  return {
17020
- project_name: projectName,
17021
- canonical_id: canonicalId,
17022
- observations: selected.map(toContextObservation),
17023
- session_count: selected.length,
17024
- total_active: totalActive,
17025
- summaries: summaries.length > 0 ? summaries : undefined,
17026
- securityFindings: securityFindings.length > 0 ? securityFindings : undefined,
17027
- recentProjects,
17028
- staleDecisions,
17029
- recentPrompts: recentPrompts.length > 0 ? recentPrompts : undefined,
17030
- recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
17031
- recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
17032
- projectTypeCounts,
17033
- recentOutcomes
17177
+ project: project?.name,
17178
+ capture_mode: requests.length > 0 || tools.length > 0 ? "rich" : "observations-only",
17179
+ sessions,
17180
+ requests,
17181
+ tools,
17182
+ observations,
17183
+ capture_summary: projectIndex?.capture_summary,
17184
+ recent_outcomes: projectIndex?.recent_outcomes ?? [],
17185
+ hot_files: projectIndex?.hot_files ?? [],
17186
+ provenance_summary: projectIndex?.provenance_summary ?? [],
17187
+ assistant_checkpoint_count: projectIndex?.assistant_checkpoint_count,
17188
+ assistant_checkpoint_types: projectIndex?.assistant_checkpoint_types ?? [],
17189
+ top_types: projectIndex?.top_types ?? [],
17190
+ estimated_read_tokens: projectIndex?.estimated_read_tokens,
17191
+ suggested_tools: projectIndex?.suggested_tools ?? buildFallbackSuggestedTools(sessions.length, requests.length, tools.length, observations.length)
17192
+ };
17193
+ }
17194
+ function buildFallbackSuggestedTools(sessionCount, requestCount, toolCount, observationCount) {
17195
+ const suggested = [];
17196
+ if (sessionCount > 0)
17197
+ suggested.push("recent_sessions");
17198
+ if (requestCount > 0 || toolCount > 0)
17199
+ suggested.push("activity_feed");
17200
+ if (observationCount > 0)
17201
+ suggested.push("tool_memory_index", "capture_git_worktree");
17202
+ return Array.from(new Set(suggested)).slice(0, 4);
17203
+ }
17204
+
17205
+ // src/tools/workspace-memory-index.ts
17206
+ function getWorkspaceMemoryIndex(db, input) {
17207
+ const limit = Math.max(1, Math.min(input.limit ?? 12, 50));
17208
+ const visibilityClause = input.user_id ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
17209
+ const projects = db.db.query(`SELECT
17210
+ p.canonical_id,
17211
+ p.name,
17212
+ (
17213
+ SELECT COUNT(*) FROM observations o
17214
+ WHERE o.project_id = p.id
17215
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
17216
+ AND o.superseded_by IS NULL
17217
+ ${visibilityClause}
17218
+ ) AS observation_count,
17219
+ (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count,
17220
+ (SELECT COUNT(*) FROM user_prompts up WHERE up.project_id = p.id) AS prompt_count,
17221
+ (SELECT COUNT(*) FROM tool_events te WHERE te.project_id = p.id) AS tool_event_count,
17222
+ (
17223
+ SELECT COUNT(*) FROM observations o
17224
+ WHERE o.project_id = p.id
17225
+ AND o.source_tool = 'assistant-stop'
17226
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
17227
+ AND o.superseded_by IS NULL
17228
+ ${visibilityClause}
17229
+ ) AS assistant_checkpoint_count,
17230
+ p.last_active_epoch
17231
+ FROM projects p
17232
+ ORDER BY p.last_active_epoch DESC
17233
+ LIMIT ?`).all(...input.user_id ? [input.user_id, input.user_id, limit] : [limit]);
17234
+ const totals = projects.reduce((acc, project) => {
17235
+ acc.observations += project.observation_count;
17236
+ acc.sessions += project.session_count;
17237
+ acc.prompts += project.prompt_count;
17238
+ acc.tool_events += project.tool_event_count;
17239
+ acc.assistant_checkpoints += project.assistant_checkpoint_count;
17240
+ return acc;
17241
+ }, { observations: 0, sessions: 0, prompts: 0, tool_events: 0, assistant_checkpoints: 0 });
17242
+ const visibilityClauseForProvenance = input.user_id ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
17243
+ const provenanceRows = db.db.query(`SELECT source_tool, COUNT(*) as count
17244
+ FROM observations
17245
+ WHERE source_tool IS NOT NULL
17246
+ AND lifecycle IN ('active', 'aging', 'pinned')
17247
+ AND superseded_by IS NULL
17248
+ ${visibilityClauseForProvenance}
17249
+ GROUP BY source_tool
17250
+ ORDER BY count DESC, source_tool ASC
17251
+ LIMIT 8`).all(...input.user_id ? [input.user_id] : []);
17252
+ return {
17253
+ projects,
17254
+ totals,
17255
+ projects_with_raw_capture: projects.filter((project) => project.prompt_count > 0 || project.tool_event_count > 0).length,
17256
+ provenance_summary: provenanceRows.map((row) => ({
17257
+ tool: row.source_tool,
17258
+ count: row.count
17259
+ }))
17034
17260
  };
17035
17261
  }
17036
- function estimateObservationTokens(obs, index) {
17037
- const DETAILED_THRESHOLD = 5;
17038
- const titleCost = estimateTokens(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
17039
- if (index >= DETAILED_THRESHOLD) {
17040
- return titleCost;
17041
- }
17042
- const detailText = formatObservationDetail(obs);
17043
- return titleCost + estimateTokens(detailText);
17044
- }
17045
- function formatContextForInjection(context) {
17046
- if (context.observations.length === 0 && (!context.recentPrompts || context.recentPrompts.length === 0) && (!context.recentToolEvents || context.recentToolEvents.length === 0) && (!context.recentSessions || context.recentSessions.length === 0) && (!context.projectTypeCounts || Object.keys(context.projectTypeCounts).length === 0)) {
17047
- return `Project: ${context.project_name} (no prior observations)`;
17048
- }
17049
- const DETAILED_COUNT = 5;
17050
- const isCrossProject = context.recentProjects && context.recentProjects.length > 0;
17051
- const lines = [];
17052
- if (isCrossProject) {
17053
- lines.push(`## Engrm Memory — Workspace Overview`);
17054
- lines.push(`This is a new project folder. Here is context from your recent work:`);
17055
- lines.push("");
17056
- lines.push("**Active projects in memory:**");
17057
- for (const rp of context.recentProjects) {
17058
- const activity = rp.days_ago === 0 ? "today" : rp.days_ago === 1 ? "yesterday" : `${rp.days_ago}d ago`;
17059
- lines.push(`- **${rp.name}** — ${rp.observation_count} observations, last active ${activity}`);
17060
- }
17061
- lines.push("");
17062
- lines.push(`${context.session_count} relevant observation(s) from across projects:`);
17063
- lines.push("");
17064
- } else {
17065
- lines.push(`## Project Memory: ${context.project_name}`);
17066
- lines.push(`${context.session_count} relevant observation(s) from prior sessions:`);
17067
- lines.push("");
17068
- }
17069
- if (context.recentPrompts && context.recentPrompts.length > 0) {
17070
- const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
17071
- if (promptLines.length > 0) {
17072
- lines.push("## Recent Requests");
17073
- for (const prompt of promptLines) {
17074
- const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
17075
- lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
17076
- }
17077
- lines.push("");
17078
- }
17079
- }
17080
- if (context.recentToolEvents && context.recentToolEvents.length > 0) {
17081
- lines.push("## Recent Tools");
17082
- for (const tool of context.recentToolEvents.slice(0, 5)) {
17083
- lines.push(`- ${tool.tool_name}: ${formatToolEventDetail(tool)}`);
17084
- }
17085
- lines.push("");
17086
- }
17087
- if (context.recentSessions && context.recentSessions.length > 0) {
17088
- const recentSessionLines = context.recentSessions.slice(0, 4).map((session) => {
17089
- const summary = chooseMeaningfulSessionHeadline(session.request, session.completed);
17090
- if (summary === "(no summary)")
17091
- return null;
17092
- return `- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
17093
- }).filter((line) => Boolean(line));
17094
- if (recentSessionLines.length > 0) {
17095
- lines.push("## Recent Sessions");
17096
- lines.push(...recentSessionLines);
17097
- lines.push("");
17098
- }
17262
+
17263
+ // src/tools/project-related-work.ts
17264
+ function getProjectRelatedWork(db, input) {
17265
+ const cwd = input.cwd ?? process.cwd();
17266
+ const detected = detectProject(cwd);
17267
+ const limit = Math.max(1, Math.min(input.limit ?? 20, 100));
17268
+ const visibilityClause = input.user_id ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
17269
+ const visibilityParams = input.user_id ? [input.user_id] : [];
17270
+ const terms = buildProjectTerms(detected);
17271
+ if (terms.length === 0) {
17272
+ return {
17273
+ project: detected.name,
17274
+ canonical_id: detected.canonical_id,
17275
+ related: []
17276
+ };
17099
17277
  }
17100
- if (context.recentOutcomes && context.recentOutcomes.length > 0) {
17101
- lines.push("## Recent Outcomes");
17102
- for (const outcome of context.recentOutcomes.slice(0, 5)) {
17103
- lines.push(`- ${truncateText(outcome, 160)}`);
17104
- }
17105
- lines.push("");
17278
+ const localProject = db.getProjectByCanonicalId(detected.canonical_id);
17279
+ const localProjectId = localProject?.id ?? -1;
17280
+ const params = [];
17281
+ const whereParts = [];
17282
+ for (const term of terms) {
17283
+ const like = `%${term}%`;
17284
+ whereParts.push(`(LOWER(o.title) LIKE ? OR LOWER(COALESCE(o.narrative, '')) LIKE ? OR LOWER(COALESCE(o.files_read, '')) LIKE ? OR LOWER(COALESCE(o.files_modified, '')) LIKE ?)`);
17285
+ params.push(like, like, like, like);
17106
17286
  }
17107
- if (context.projectTypeCounts && Object.keys(context.projectTypeCounts).length > 0) {
17108
- const topTypes = Object.entries(context.projectTypeCounts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5).map(([type, count]) => `${type} ${count}`).join(" · ");
17109
- if (topTypes) {
17110
- lines.push(`## Project Signals`);
17111
- lines.push(`Top memory types: ${topTypes}`);
17112
- lines.push("");
17113
- }
17287
+ const rows = db.db.query(`SELECT
17288
+ o.id,
17289
+ o.type,
17290
+ o.title,
17291
+ o.project_id as source_project_id,
17292
+ p.name as source_project,
17293
+ o.narrative,
17294
+ o.files_read,
17295
+ o.files_modified
17296
+ FROM observations o
17297
+ LEFT JOIN projects p ON p.id = o.project_id
17298
+ WHERE o.project_id != ?
17299
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
17300
+ AND o.superseded_by IS NULL
17301
+ ${visibilityClause}
17302
+ AND (${whereParts.join(" OR ")})
17303
+ ORDER BY o.created_at_epoch DESC
17304
+ LIMIT ?`).all(localProjectId, ...visibilityParams, ...params, limit);
17305
+ return {
17306
+ project: detected.name,
17307
+ canonical_id: detected.canonical_id,
17308
+ related: rows.map((row) => ({
17309
+ id: row.id,
17310
+ type: row.type,
17311
+ title: row.title,
17312
+ source_project: row.source_project ?? "unknown",
17313
+ source_project_id: row.source_project_id,
17314
+ matched_on: classifyMatch(row, terms)
17315
+ }))
17316
+ };
17317
+ }
17318
+ function buildProjectTerms(detected) {
17319
+ const explicit = new Set;
17320
+ explicit.add(detected.name.toLowerCase());
17321
+ const canonicalParts = detected.canonical_id.toLowerCase().split("/");
17322
+ for (const part of canonicalParts) {
17323
+ if (part.length >= 4)
17324
+ explicit.add(part);
17114
17325
  }
17115
- for (let i = 0;i < context.observations.length; i++) {
17116
- const obs = context.observations[i];
17117
- const date5 = obs.created_at.split("T")[0];
17118
- const fromLabel = obs.source_project ? ` [from: ${obs.source_project}]` : "";
17119
- const fileLabel = formatObservationFiles(obs);
17120
- lines.push(`- **#${obs.id} [${obs.type}]** ${obs.title} (${date5}, q=${obs.quality.toFixed(1)})${fromLabel}${fileLabel}`);
17121
- if (i < DETAILED_COUNT) {
17122
- const detail = formatObservationDetailFromContext(obs);
17123
- if (detail) {
17124
- lines.push(detail);
17125
- }
17126
- }
17326
+ if (detected.name.toLowerCase() === "huginn") {
17327
+ explicit.add("aiserver");
17127
17328
  }
17128
- if (context.summaries && context.summaries.length > 0) {
17129
- lines.push("");
17130
- lines.push("## Recent Project Briefs");
17131
- for (const summary of context.summaries.slice(0, 3)) {
17132
- lines.push(...formatSessionBrief(summary));
17133
- lines.push("");
17134
- }
17329
+ return [...explicit].filter(Boolean);
17330
+ }
17331
+ function classifyMatch(row, terms) {
17332
+ const title = row.title.toLowerCase();
17333
+ const narrative = (row.narrative ?? "").toLowerCase();
17334
+ const files = `${row.files_read ?? ""} ${row.files_modified ?? ""}`.toLowerCase();
17335
+ for (const term of terms) {
17336
+ if (files.includes(term))
17337
+ return `files:${term}`;
17338
+ if (title.includes(term))
17339
+ return `title:${term}`;
17340
+ if (narrative.includes(term))
17341
+ return `narrative:${term}`;
17135
17342
  }
17136
- if (context.securityFindings && context.securityFindings.length > 0) {
17137
- lines.push("");
17138
- lines.push("Security findings (recent):");
17139
- for (const finding of context.securityFindings) {
17140
- const date5 = new Date(finding.created_at_epoch * 1000).toISOString().split("T")[0];
17141
- const file2 = finding.file_path ? ` in ${finding.file_path}` : finding.tool_name ? ` via ${finding.tool_name}` : "";
17142
- lines.push(`- [${finding.severity.toUpperCase()}] ${finding.pattern_name}${file2} (${date5})`);
17343
+ return "related";
17344
+ }
17345
+
17346
+ // src/tools/reclassify-project-memory.ts
17347
+ function reclassifyProjectMemory(db, input) {
17348
+ const cwd = input.cwd ?? process.cwd();
17349
+ const detected = detectProject(cwd);
17350
+ const target = db.upsertProject({
17351
+ canonical_id: detected.canonical_id,
17352
+ name: detected.name,
17353
+ local_path: detected.local_path,
17354
+ remote_url: detected.remote_url
17355
+ });
17356
+ const related = getProjectRelatedWork(db, {
17357
+ cwd,
17358
+ user_id: input.user_id,
17359
+ limit: input.limit ?? 50
17360
+ }).related;
17361
+ let moved = 0;
17362
+ const candidates = related.map((item) => {
17363
+ const eligible = item.matched_on.startsWith("files:") || item.matched_on.startsWith("title:") || item.matched_on.startsWith("narrative:");
17364
+ const shouldMove = eligible && item.source_project_id !== target.id;
17365
+ if (shouldMove && input.dry_run !== true) {
17366
+ const ok = db.reassignObservationProject(item.id, target.id);
17367
+ if (ok)
17368
+ moved += 1;
17369
+ return {
17370
+ id: item.id,
17371
+ title: item.title,
17372
+ type: item.type,
17373
+ from: item.source_project,
17374
+ matched_on: item.matched_on,
17375
+ moved: ok
17376
+ };
17143
17377
  }
17378
+ return {
17379
+ id: item.id,
17380
+ title: item.title,
17381
+ type: item.type,
17382
+ from: item.source_project,
17383
+ matched_on: item.matched_on,
17384
+ moved: false
17385
+ };
17386
+ });
17387
+ return {
17388
+ project: target.name,
17389
+ canonical_id: target.canonical_id,
17390
+ target_project_id: target.id,
17391
+ moved,
17392
+ candidates
17393
+ };
17394
+ }
17395
+
17396
+ // src/tools/activity-feed.ts
17397
+ function toPromptEvent(prompt) {
17398
+ return {
17399
+ kind: "prompt",
17400
+ created_at_epoch: prompt.created_at_epoch,
17401
+ session_id: prompt.session_id,
17402
+ id: prompt.id,
17403
+ title: `#${prompt.prompt_number} ${prompt.prompt.replace(/\s+/g, " ").trim()}`
17404
+ };
17405
+ }
17406
+ function toToolEvent(tool) {
17407
+ const detail = tool.file_path ?? tool.command ?? tool.tool_response_preview ?? undefined;
17408
+ return {
17409
+ kind: "tool",
17410
+ created_at_epoch: tool.created_at_epoch,
17411
+ session_id: tool.session_id,
17412
+ id: tool.id,
17413
+ title: tool.tool_name,
17414
+ detail: detail?.replace(/\s+/g, " ").trim()
17415
+ };
17416
+ }
17417
+ function toObservationEvent(obs) {
17418
+ const detailBits = [];
17419
+ if (obs.source_tool)
17420
+ detailBits.push(`via ${obs.source_tool}`);
17421
+ if (typeof obs.source_prompt_number === "number") {
17422
+ detailBits.push(`#${obs.source_prompt_number}`);
17144
17423
  }
17145
- if (context.staleDecisions && context.staleDecisions.length > 0) {
17146
- lines.push("");
17147
- lines.push("Stale commitments (decided but no implementation observed):");
17148
- for (const sd of context.staleDecisions) {
17149
- const date5 = sd.created_at.split("T")[0];
17150
- lines.push(`- [DECISION] ${sd.title} (${date5}, ${sd.days_ago}d ago)`);
17151
- if (sd.best_match_title) {
17152
- lines.push(` Closest match: "${sd.best_match_title}" (${Math.round((sd.best_match_similarity ?? 0) * 100)}% similar — not enough to count as done)`);
17153
- }
17154
- }
17424
+ return {
17425
+ kind: "observation",
17426
+ created_at_epoch: obs.created_at_epoch,
17427
+ session_id: obs.session_id,
17428
+ id: obs.id,
17429
+ title: obs.title,
17430
+ detail: detailBits.length > 0 ? detailBits.join(" · ") : undefined,
17431
+ observation_type: obs.type
17432
+ };
17433
+ }
17434
+ function toSummaryEvent(summary, fallbackEpoch = 0, extras) {
17435
+ const title = summary.request ?? summary.completed ?? summary.learned ?? summary.investigated;
17436
+ if (!title)
17437
+ return null;
17438
+ const detail = [
17439
+ extras?.capture_state ? `Capture: ${extras.capture_state}` : null,
17440
+ typeof extras?.prompt_count === "number" && typeof extras?.tool_event_count === "number" ? `Prompts/tools: ${extras.prompt_count}/${extras.tool_event_count}` : null,
17441
+ extras?.latest_request && extras.latest_request !== title ? `Latest request: ${extras.latest_request}` : null,
17442
+ summary.completed && summary.completed !== title ? `Completed: ${summary.completed}` : null,
17443
+ summary.learned ? `Learned: ${summary.learned}` : null,
17444
+ summary.next_steps ? `Next: ${summary.next_steps}` : null
17445
+ ].filter(Boolean).join(" | ");
17446
+ return {
17447
+ kind: "summary",
17448
+ created_at_epoch: summary.created_at_epoch ?? fallbackEpoch,
17449
+ session_id: summary.session_id,
17450
+ id: summary.id,
17451
+ title: title.replace(/\s+/g, " ").trim(),
17452
+ detail: detail || undefined
17453
+ };
17454
+ }
17455
+ function compareEvents(a, b) {
17456
+ if (b.created_at_epoch !== a.created_at_epoch) {
17457
+ return b.created_at_epoch - a.created_at_epoch;
17155
17458
  }
17156
- const remaining = context.total_active - context.session_count;
17157
- if (remaining > 0) {
17158
- lines.push("");
17159
- lines.push(`${remaining} more observation(s) available via search tool.`);
17459
+ const kindOrder = {
17460
+ summary: 0,
17461
+ observation: 1,
17462
+ tool: 2,
17463
+ prompt: 3
17464
+ };
17465
+ if (kindOrder[a.kind] !== kindOrder[b.kind]) {
17466
+ return kindOrder[a.kind] - kindOrder[b.kind];
17160
17467
  }
17161
- return lines.join(`
17162
- `);
17468
+ return (b.id ?? 0) - (a.id ?? 0);
17163
17469
  }
17164
- function formatSessionBrief(summary) {
17165
- const lines = [];
17166
- const heading = summary.request ? `### ${truncateText(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
17167
- lines.push(heading);
17168
- const sections = [
17169
- ["Investigated", summary.investigated, 180],
17170
- ["Learned", summary.learned, 180],
17171
- ["Completed", summary.completed, 180],
17172
- ["Next Steps", summary.next_steps, 140]
17173
- ];
17174
- for (const [label, value, maxLen] of sections) {
17175
- const formatted = formatSummarySection(value, maxLen);
17176
- if (formatted) {
17177
- lines.push(`${label}:`);
17178
- lines.push(formatted);
17470
+ function getActivityFeed(db, input) {
17471
+ const limit = Math.max(1, Math.min(input.limit ?? 30, 100));
17472
+ if (input.session_id) {
17473
+ const story = getSessionStory(db, { session_id: input.session_id });
17474
+ const project = story.session?.project_id !== null && story.session?.project_id !== undefined ? db.getProjectById(story.session.project_id)?.name : undefined;
17475
+ const events2 = [
17476
+ ...story.summary ? [toSummaryEvent(story.summary, story.session?.completed_at_epoch ?? story.session?.started_at_epoch ?? 0, {
17477
+ capture_state: story.capture_state,
17478
+ prompt_count: story.prompts.length,
17479
+ tool_event_count: story.tool_events.length,
17480
+ latest_request: story.latest_request
17481
+ })].filter((event) => event !== null) : [],
17482
+ ...story.prompts.map(toPromptEvent),
17483
+ ...story.tool_events.map(toToolEvent),
17484
+ ...story.observations.map(toObservationEvent)
17485
+ ].sort(compareEvents).slice(0, limit);
17486
+ return { events: events2, project };
17487
+ }
17488
+ const projectScoped = input.project_scoped !== false;
17489
+ let projectName;
17490
+ if (projectScoped) {
17491
+ const cwd = input.cwd ?? process.cwd();
17492
+ const detected = detectProject(cwd);
17493
+ const project = db.getProjectByCanonicalId(detected.canonical_id);
17494
+ if (project) {
17495
+ projectName = project.name;
17179
17496
  }
17180
17497
  }
17181
- return lines;
17182
- }
17183
- function chooseMeaningfulSessionHeadline(request, completed) {
17184
- if (request && !looksLikeFileOperationTitle2(request))
17185
- return request;
17186
- const completedItems = extractMeaningfulLines(completed, 1);
17187
- if (completedItems.length > 0)
17188
- return completedItems[0];
17189
- return request ?? completed ?? "(no summary)";
17190
- }
17191
- function formatSummarySection(value, maxLen) {
17192
- if (!value)
17193
- return null;
17194
- const cleaned = value.split(`
17195
- `).map((line) => line.trim()).filter(Boolean).map((line) => line.startsWith("-") ? line : `- ${line}`).join(`
17196
- `);
17197
- if (!cleaned)
17198
- return null;
17199
- return truncateMultilineText(cleaned, maxLen);
17200
- }
17201
- function truncateMultilineText(text, maxLen) {
17202
- if (text.length <= maxLen)
17203
- return text;
17204
- const truncated = text.slice(0, maxLen).trimEnd();
17205
- const lastBreak = Math.max(truncated.lastIndexOf(`
17206
- `), truncated.lastIndexOf(" "));
17207
- const safe = lastBreak > maxLen * 0.5 ? truncated.slice(0, lastBreak) : truncated;
17208
- return `${safe.trimEnd()}…`;
17498
+ const prompts = getRecentRequests(db, { ...input, limit }).prompts;
17499
+ const tools = getRecentTools(db, { ...input, limit }).tool_events;
17500
+ const observations = getRecentActivity(db, {
17501
+ limit,
17502
+ project_scoped: input.project_scoped,
17503
+ cwd: input.cwd,
17504
+ user_id: input.user_id
17505
+ }).observations;
17506
+ const sessions = getRecentSessions(db, {
17507
+ limit,
17508
+ project_scoped: input.project_scoped,
17509
+ cwd: input.cwd,
17510
+ user_id: input.user_id
17511
+ }).sessions;
17512
+ const summaryEvents = sessions.map((session) => {
17513
+ const summary = db.getSessionSummary(session.session_id);
17514
+ if (!summary)
17515
+ return null;
17516
+ return toSummaryEvent(summary, session.completed_at_epoch ?? session.started_at_epoch ?? 0, {
17517
+ capture_state: session.capture_state,
17518
+ prompt_count: session.prompt_count,
17519
+ tool_event_count: session.tool_event_count,
17520
+ latest_request: summary.request
17521
+ });
17522
+ }).filter((event) => event !== null);
17523
+ const events = [
17524
+ ...summaryEvents,
17525
+ ...prompts.map(toPromptEvent),
17526
+ ...tools.map(toToolEvent),
17527
+ ...observations.map(toObservationEvent)
17528
+ ].sort(compareEvents).slice(0, limit);
17529
+ return {
17530
+ events,
17531
+ project: projectName
17532
+ };
17209
17533
  }
17210
- function truncateText(text, maxLen) {
17211
- if (text.length <= maxLen)
17212
- return text;
17213
- return text.slice(0, maxLen - 3) + "...";
17534
+
17535
+ // src/tools/capture-status.ts
17536
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
17537
+ import { homedir as homedir2 } from "node:os";
17538
+ import { join as join3 } from "node:path";
17539
+ var LEGACY_CODEX_SERVER_NAME = `candengo-${"mem"}`;
17540
+ function getCaptureStatus(db, input = {}) {
17541
+ const hours = Math.max(1, Math.min(input.lookback_hours ?? 24, 24 * 30));
17542
+ const sinceEpoch = Math.floor(Date.now() / 1000) - hours * 3600;
17543
+ const home = input.home_dir ?? homedir2();
17544
+ const claudeJson = join3(home, ".claude.json");
17545
+ const claudeSettings = join3(home, ".claude", "settings.json");
17546
+ const codexConfig = join3(home, ".codex", "config.toml");
17547
+ const codexHooks = join3(home, ".codex", "hooks.json");
17548
+ const claudeJsonContent = existsSync3(claudeJson) ? readFileSync3(claudeJson, "utf-8") : "";
17549
+ const claudeSettingsContent = existsSync3(claudeSettings) ? readFileSync3(claudeSettings, "utf-8") : "";
17550
+ const codexConfigContent = existsSync3(codexConfig) ? readFileSync3(codexConfig, "utf-8") : "";
17551
+ const codexHooksContent = existsSync3(codexHooks) ? readFileSync3(codexHooks, "utf-8") : "";
17552
+ const claudeMcpRegistered = claudeJsonContent.includes('"engrm"');
17553
+ const claudeHooksRegistered = claudeSettingsContent.includes("engrm") || claudeSettingsContent.includes("session-start") || claudeSettingsContent.includes("user-prompt-submit");
17554
+ const codexMcpRegistered = codexConfigContent.includes("[mcp_servers.engrm]") || codexConfigContent.includes(`[mcp_servers.${LEGACY_CODEX_SERVER_NAME}]`);
17555
+ const codexHooksRegistered = codexHooksContent.includes('"SessionStart"') && codexHooksContent.includes('"Stop"');
17556
+ let claudeHookCount = 0;
17557
+ let claudeSessionStartHook = false;
17558
+ let claudeUserPromptHook = false;
17559
+ let claudePostToolHook = false;
17560
+ let claudeStopHook = false;
17561
+ if (claudeHooksRegistered) {
17562
+ try {
17563
+ const settings = JSON.parse(claudeSettingsContent);
17564
+ const hooks = settings?.hooks ?? {};
17565
+ claudeSessionStartHook = Array.isArray(hooks["SessionStart"]);
17566
+ claudeUserPromptHook = Array.isArray(hooks["UserPromptSubmit"]);
17567
+ claudePostToolHook = Array.isArray(hooks["PostToolUse"]);
17568
+ claudeStopHook = Array.isArray(hooks["Stop"]);
17569
+ for (const entries of Object.values(hooks)) {
17570
+ if (!Array.isArray(entries))
17571
+ continue;
17572
+ for (const entry of entries) {
17573
+ const e = entry;
17574
+ if (e.hooks?.some((h) => h.command?.includes("engrm") || h.command?.includes("session-start") || h.command?.includes("user-prompt-submit") || h.command?.includes("sentinel") || h.command?.includes("post-tool-use") || h.command?.includes("pre-compact") || h.command?.includes("stop") || h.command?.includes("elicitation"))) {
17575
+ claudeHookCount++;
17576
+ }
17577
+ }
17578
+ }
17579
+ } catch {}
17580
+ }
17581
+ let codexSessionStartHook = false;
17582
+ let codexStopHook = false;
17583
+ try {
17584
+ const hooks = codexHooksContent ? JSON.parse(codexHooksContent)?.hooks ?? {} : {};
17585
+ codexSessionStartHook = Array.isArray(hooks["SessionStart"]);
17586
+ codexStopHook = Array.isArray(hooks["Stop"]);
17587
+ } catch {}
17588
+ const visibilityClause = input.user_id ? " AND user_id = ?" : "";
17589
+ const params = input.user_id ? [sinceEpoch, input.user_id] : [sinceEpoch];
17590
+ const recentUserPrompts = db.db.query(`SELECT COUNT(*) as count FROM user_prompts
17591
+ WHERE created_at_epoch >= ?${visibilityClause}`).get(...params)?.count ?? 0;
17592
+ const recentToolEvents = db.db.query(`SELECT COUNT(*) as count FROM tool_events
17593
+ WHERE created_at_epoch >= ?${visibilityClause}`).get(...params)?.count ?? 0;
17594
+ const recentSessionsWithRawCapture = db.db.query(`SELECT COUNT(*) as count
17595
+ FROM sessions s
17596
+ WHERE COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) >= ?
17597
+ ${input.user_id ? "AND s.user_id = ?" : ""}
17598
+ AND (
17599
+ EXISTS (SELECT 1 FROM user_prompts up WHERE up.session_id = s.session_id)
17600
+ OR EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id)
17601
+ )`).get(...params)?.count ?? 0;
17602
+ const recentSessionsWithPartialCapture = db.db.query(`SELECT COUNT(*) as count
17603
+ FROM sessions s
17604
+ WHERE COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) >= ?
17605
+ ${input.user_id ? "AND s.user_id = ?" : ""}
17606
+ AND (
17607
+ (s.tool_calls_count > 0 AND NOT EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id))
17608
+ OR (
17609
+ EXISTS (SELECT 1 FROM user_prompts up WHERE up.session_id = s.session_id)
17610
+ AND NOT EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id)
17611
+ )
17612
+ )`).get(...params)?.count ?? 0;
17613
+ const latestPromptEpoch = db.db.query(`SELECT created_at_epoch FROM user_prompts
17614
+ WHERE 1 = 1${input.user_id ? " AND user_id = ?" : ""}
17615
+ ORDER BY created_at_epoch DESC, prompt_number DESC
17616
+ LIMIT 1`).get(...input.user_id ? [input.user_id] : [])?.created_at_epoch ?? null;
17617
+ const latestToolEventEpoch = db.db.query(`SELECT created_at_epoch FROM tool_events
17618
+ WHERE 1 = 1${input.user_id ? " AND user_id = ?" : ""}
17619
+ ORDER BY created_at_epoch DESC, id DESC
17620
+ LIMIT 1`).get(...input.user_id ? [input.user_id] : [])?.created_at_epoch ?? null;
17621
+ const latestPostToolHookEpoch = parseNullableInt(db.getSyncState("hook_post_tool_last_seen_epoch"));
17622
+ const latestPostToolParseStatus = db.getSyncState("hook_post_tool_last_parse_status");
17623
+ const latestPostToolName = db.getSyncState("hook_post_tool_last_tool_name");
17624
+ const schemaVersion = getSchemaVersion(db.db);
17625
+ return {
17626
+ schema_version: schemaVersion,
17627
+ schema_current: schemaVersion >= LATEST_SCHEMA_VERSION,
17628
+ claude_mcp_registered: claudeMcpRegistered,
17629
+ claude_hooks_registered: claudeHooksRegistered,
17630
+ claude_hook_count: claudeHookCount,
17631
+ claude_session_start_hook: claudeSessionStartHook,
17632
+ claude_user_prompt_hook: claudeUserPromptHook,
17633
+ claude_post_tool_hook: claudePostToolHook,
17634
+ claude_stop_hook: claudeStopHook,
17635
+ codex_mcp_registered: codexMcpRegistered,
17636
+ codex_hooks_registered: codexHooksRegistered,
17637
+ codex_session_start_hook: codexSessionStartHook,
17638
+ codex_stop_hook: codexStopHook,
17639
+ codex_raw_chronology_supported: false,
17640
+ recent_user_prompts: recentUserPrompts,
17641
+ recent_tool_events: recentToolEvents,
17642
+ recent_sessions_with_raw_capture: recentSessionsWithRawCapture,
17643
+ recent_sessions_with_partial_capture: recentSessionsWithPartialCapture,
17644
+ latest_prompt_epoch: latestPromptEpoch,
17645
+ latest_tool_event_epoch: latestToolEventEpoch,
17646
+ latest_post_tool_hook_epoch: latestPostToolHookEpoch,
17647
+ latest_post_tool_parse_status: latestPostToolParseStatus,
17648
+ latest_post_tool_name: latestPostToolName,
17649
+ raw_capture_active: recentUserPrompts > 0 || recentToolEvents > 0 || recentSessionsWithRawCapture > 0
17650
+ };
17214
17651
  }
17215
- function isMeaningfulPrompt(value) {
17652
+ function parseNullableInt(value) {
17216
17653
  if (!value)
17217
- return false;
17218
- const compact = value.replace(/\s+/g, " ").trim();
17219
- if (compact.length < 8)
17220
- return false;
17221
- return /[a-z]{3,}/i.test(compact);
17222
- }
17223
- function looksLikeFileOperationTitle2(value) {
17224
- return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
17654
+ return null;
17655
+ const parsed = Number.parseInt(value, 10);
17656
+ return Number.isFinite(parsed) ? parsed : null;
17225
17657
  }
17226
- function stripInlineSectionLabel(value) {
17227
- return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
17658
+
17659
+ // src/tools/capture-quality.ts
17660
+ function getCaptureQuality(db, input = {}) {
17661
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
17662
+ const workspace = getWorkspaceMemoryIndex(db, {
17663
+ limit,
17664
+ user_id: input.user_id
17665
+ });
17666
+ const allSessions = getRecentSessions(db, {
17667
+ limit: 200,
17668
+ project_scoped: false,
17669
+ user_id: input.user_id
17670
+ }).sessions;
17671
+ const sessionStates = {
17672
+ rich: allSessions.filter((s) => s.capture_state === "rich").length,
17673
+ partial: allSessions.filter((s) => s.capture_state === "partial").length,
17674
+ summary_only: allSessions.filter((s) => s.capture_state === "summary-only").length,
17675
+ legacy: allSessions.filter((s) => s.capture_state === "legacy").length
17676
+ };
17677
+ const topProjects = workspace.projects.slice(0, limit).map((project) => ({
17678
+ name: project.name,
17679
+ canonical_id: project.canonical_id,
17680
+ observation_count: project.observation_count,
17681
+ session_count: project.session_count,
17682
+ prompt_count: project.prompt_count,
17683
+ tool_event_count: project.tool_event_count,
17684
+ assistant_checkpoint_count: project.assistant_checkpoint_count,
17685
+ raw_capture_state: project.prompt_count > 0 && project.tool_event_count > 0 ? "rich" : project.prompt_count > 0 || project.tool_event_count > 0 ? "partial" : "summary-only"
17686
+ }));
17687
+ const checkpointTypeRows = db.db.query(`SELECT type, COUNT(*) as count
17688
+ FROM observations
17689
+ WHERE source_tool = 'assistant-stop'
17690
+ AND lifecycle IN ('active', 'aging', 'pinned')
17691
+ AND superseded_by IS NULL
17692
+ ${input.user_id ? " AND (sensitivity != 'personal' OR user_id = ?)" : ""}
17693
+ GROUP BY type
17694
+ ORDER BY count DESC, type ASC
17695
+ LIMIT 8`).all(...input.user_id ? [input.user_id] : []);
17696
+ const provenanceTypeRows = db.db.query(`SELECT source_tool, type, COUNT(*) as count
17697
+ FROM observations
17698
+ WHERE source_tool IS NOT NULL
17699
+ AND lifecycle IN ('active', 'aging', 'pinned')
17700
+ AND superseded_by IS NULL
17701
+ ${input.user_id ? " AND (sensitivity != 'personal' OR user_id = ?)" : ""}
17702
+ GROUP BY source_tool, type
17703
+ ORDER BY source_tool ASC, count DESC, type ASC`).all(...input.user_id ? [input.user_id] : []);
17704
+ const provenanceTypeMix = Array.from(provenanceTypeRows.reduce((acc, row) => {
17705
+ const group = acc.get(row.source_tool) ?? [];
17706
+ group.push({ type: row.type, count: row.count });
17707
+ acc.set(row.source_tool, group);
17708
+ return acc;
17709
+ }, new Map).entries()).map(([tool, topTypes]) => ({
17710
+ tool,
17711
+ count: topTypes.reduce((sum, item) => sum + item.count, 0),
17712
+ top_types: topTypes.slice(0, 4)
17713
+ })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 8);
17714
+ return {
17715
+ totals: {
17716
+ projects: workspace.projects.length,
17717
+ observations: workspace.totals.observations,
17718
+ sessions: workspace.totals.sessions,
17719
+ prompts: workspace.totals.prompts,
17720
+ tool_events: workspace.totals.tool_events,
17721
+ assistant_checkpoints: workspace.totals.assistant_checkpoints
17722
+ },
17723
+ session_states: sessionStates,
17724
+ projects_with_raw_capture: workspace.projects_with_raw_capture,
17725
+ provenance_summary: workspace.provenance_summary,
17726
+ provenance_type_mix: provenanceTypeMix,
17727
+ assistant_checkpoint_types: checkpointTypeRows.map((row) => ({
17728
+ type: row.type,
17729
+ count: row.count
17730
+ })),
17731
+ top_projects: topProjects
17732
+ };
17228
17733
  }
17229
- function extractMeaningfulLines(value, limit) {
17734
+
17735
+ // src/tools/tool-memory-index.ts
17736
+ function parseConcepts(value) {
17230
17737
  if (!value)
17231
17738
  return [];
17232
- return value.split(`
17233
- `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "")).map((line) => stripInlineSectionLabel(line)).filter((line) => line.length > 0 && !looksLikeFileOperationTitle2(line)).slice(0, limit);
17234
- }
17235
- function formatObservationDetailFromContext(obs) {
17236
- if (obs.facts) {
17237
- const bullets = parseFacts(obs.facts);
17238
- if (bullets.length > 0) {
17239
- return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
17240
- `);
17241
- }
17242
- }
17243
- if (obs.narrative) {
17244
- const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
17245
- return ` ${snippet}`;
17246
- }
17247
- return null;
17248
- }
17249
- function formatObservationDetail(obs) {
17250
- if (obs.facts) {
17251
- const bullets = parseFacts(obs.facts);
17252
- if (bullets.length > 0) {
17253
- return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
17254
- `);
17255
- }
17256
- }
17257
- if (obs.narrative) {
17258
- const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
17259
- return ` ${snippet}`;
17260
- }
17261
- return "";
17262
- }
17263
- function parseFacts(facts) {
17264
- if (!facts)
17265
- return [];
17266
17739
  try {
17267
- const parsed = JSON.parse(facts);
17268
- if (Array.isArray(parsed)) {
17269
- return parsed.filter((f) => typeof f === "string" && f.length > 0);
17270
- }
17740
+ const parsed = JSON.parse(value);
17741
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
17271
17742
  } catch {
17272
- if (facts.trim().length > 0) {
17273
- return [facts.trim()];
17274
- }
17743
+ return [];
17275
17744
  }
17276
- return [];
17277
17745
  }
17278
- function toContextObservation(obs) {
17746
+ function getToolMemoryIndex(db, input = {}) {
17747
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 25));
17748
+ const projectScoped = input.project_scoped !== false;
17749
+ const cwd = input.cwd ?? process.cwd();
17750
+ const detected = projectScoped ? detectProject(cwd) : null;
17751
+ const project = detected ? db.getProjectByCanonicalId(detected.canonical_id) : null;
17752
+ const projectClause = project ? " AND o.project_id = ?" : "";
17753
+ const visibilityClause = input.user_id ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
17754
+ const baseParams = [];
17755
+ if (project)
17756
+ baseParams.push(project.id);
17757
+ if (input.user_id)
17758
+ baseParams.push(input.user_id);
17759
+ const toolRows = db.db.query(`SELECT
17760
+ o.source_tool,
17761
+ COUNT(*) as observation_count,
17762
+ MAX(o.created_at_epoch) as latest_epoch,
17763
+ COUNT(DISTINCT o.session_id) as session_count,
17764
+ MAX(o.source_prompt_number) as latest_prompt_number
17765
+ FROM observations o
17766
+ WHERE o.source_tool IS NOT NULL
17767
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
17768
+ AND o.superseded_by IS NULL
17769
+ ${projectClause}
17770
+ ${visibilityClause}
17771
+ GROUP BY o.source_tool
17772
+ ORDER BY observation_count DESC, latest_epoch DESC, o.source_tool ASC
17773
+ LIMIT ?`).all(...baseParams, limit);
17774
+ const tools = toolRows.map((row) => {
17775
+ const rowParams = [row.source_tool];
17776
+ if (project)
17777
+ rowParams.push(project.id);
17778
+ if (input.user_id)
17779
+ rowParams.push(input.user_id);
17780
+ const topTypes = db.db.query(`SELECT
17781
+ o.type,
17782
+ COUNT(*) as count
17783
+ FROM observations o
17784
+ WHERE o.source_tool = ?
17785
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
17786
+ AND o.superseded_by IS NULL
17787
+ ${projectClause}
17788
+ ${visibilityClause}
17789
+ GROUP BY o.type
17790
+ ORDER BY count DESC, o.type ASC
17791
+ LIMIT 5`).all(...rowParams).map((typeRow) => ({
17792
+ type: typeRow.type,
17793
+ count: typeRow.count
17794
+ }));
17795
+ const observationRows = db.db.query(`SELECT o.title, o.concepts
17796
+ FROM observations o
17797
+ WHERE o.source_tool = ?
17798
+ AND o.lifecycle IN ('active', 'aging', 'pinned')
17799
+ AND o.superseded_by IS NULL
17800
+ ${projectClause}
17801
+ ${visibilityClause}
17802
+ ORDER BY o.created_at_epoch DESC, o.id DESC
17803
+ LIMIT 50`).all(...rowParams);
17804
+ const topPlugins = Array.from(observationRows.reduce((acc, obs) => {
17805
+ for (const concept of parseConcepts(obs.concepts)) {
17806
+ if (!concept.startsWith("plugin:"))
17807
+ continue;
17808
+ const plugin = concept.slice("plugin:".length);
17809
+ if (!plugin)
17810
+ continue;
17811
+ acc.set(plugin, (acc.get(plugin) ?? 0) + 1);
17812
+ }
17813
+ return acc;
17814
+ }, new Map).entries()).map(([plugin, count]) => ({ plugin, count })).sort((a, b) => b.count - a.count || a.plugin.localeCompare(b.plugin)).slice(0, 4);
17815
+ const sampleTitles = observationRows.map((sample) => sample.title).slice(0, 4);
17816
+ return {
17817
+ tool: row.source_tool,
17818
+ observation_count: row.observation_count,
17819
+ latest_epoch: row.latest_epoch,
17820
+ top_types: topTypes,
17821
+ top_plugins: topPlugins,
17822
+ sample_titles: sampleTitles,
17823
+ session_count: row.session_count,
17824
+ latest_prompt_number: row.latest_prompt_number
17825
+ };
17826
+ });
17279
17827
  return {
17280
- id: obs.id,
17281
- type: obs.type,
17282
- title: obs.title,
17283
- narrative: obs.narrative,
17284
- facts: obs.facts,
17285
- files_read: obs.files_read,
17286
- files_modified: obs.files_modified,
17287
- quality: obs.quality,
17288
- created_at: obs.created_at,
17289
- ...obs._source_project ? { source_project: obs._source_project } : {}
17828
+ project: project?.name,
17829
+ tools
17290
17830
  };
17291
17831
  }
17292
- function formatObservationFiles(obs) {
17293
- const modified = parseJsonStringArray(obs.files_modified);
17294
- if (modified.length > 0) {
17295
- return ` · files: ${truncateText(modified.slice(0, 2).join(", "), 60)}`;
17296
- }
17297
- const read = parseJsonStringArray(obs.files_read);
17298
- if (read.length > 0) {
17299
- return ` · read: ${truncateText(read.slice(0, 2).join(", "), 60)}`;
17300
- }
17301
- return "";
17302
- }
17303
- function parseJsonStringArray(value) {
17832
+
17833
+ // src/tools/session-tool-memory.ts
17834
+ function parseConcepts2(value) {
17304
17835
  if (!value)
17305
17836
  return [];
17306
17837
  try {
17307
17838
  const parsed = JSON.parse(value);
17308
- if (!Array.isArray(parsed))
17309
- return [];
17310
- return parsed.filter((item) => typeof item === "string" && item.trim().length > 0);
17839
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
17311
17840
  } catch {
17312
17841
  return [];
17313
17842
  }
17314
17843
  }
17315
- function formatToolEventDetail(tool) {
17316
- const detail = tool.file_path ?? tool.command ?? tool.tool_response_preview ?? "";
17317
- return truncateText(detail || "recent tool execution", 160);
17318
- }
17319
- function getProjectTypeCounts(db, projectId, userId) {
17320
- const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
17321
- const rows = db.db.query(`SELECT type, COUNT(*) as count
17322
- FROM observations
17323
- WHERE project_id = ?
17324
- AND lifecycle IN ('active', 'aging', 'pinned')
17325
- AND superseded_by IS NULL
17326
- ${visibilityClause}
17327
- GROUP BY type`).all(projectId, ...userId ? [userId] : []);
17328
- const counts = {};
17329
- for (const row of rows) {
17330
- counts[row.type] = row.count;
17331
- }
17332
- return counts;
17333
- }
17334
- function getRecentOutcomes(db, projectId, userId) {
17335
- const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
17336
- const visibilityParams = userId ? [userId] : [];
17337
- const summaries = db.db.query(`SELECT * FROM session_summaries
17338
- WHERE project_id = ?
17339
- ORDER BY created_at_epoch DESC
17340
- LIMIT 6`).all(projectId);
17341
- const picked = [];
17342
- const seen = new Set;
17343
- for (const summary of summaries) {
17344
- for (const line of [
17345
- ...extractMeaningfulLines(summary.completed, 2),
17346
- ...extractMeaningfulLines(summary.learned, 1)
17347
- ]) {
17348
- const normalized = line.toLowerCase().replace(/\s+/g, " ").trim();
17349
- if (!normalized || seen.has(normalized))
17350
- continue;
17351
- seen.add(normalized);
17352
- picked.push(line);
17353
- if (picked.length >= 5)
17354
- return picked;
17355
- }
17356
- }
17357
- const rows = db.db.query(`SELECT * FROM observations
17358
- WHERE project_id = ?
17359
- AND lifecycle IN ('active', 'aging', 'pinned')
17360
- AND superseded_by IS NULL
17361
- ${visibilityClause}
17362
- ORDER BY created_at_epoch DESC
17363
- LIMIT 20`).all(projectId, ...visibilityParams);
17364
- for (const obs of rows) {
17365
- if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
17366
- continue;
17367
- const title = stripInlineSectionLabel(obs.title);
17368
- const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
17369
- if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle2(title))
17370
- continue;
17371
- seen.add(normalized);
17372
- picked.push(title);
17373
- if (picked.length >= 5)
17374
- break;
17375
- }
17376
- return picked;
17844
+ function getSessionToolMemory(db, input) {
17845
+ const toolEvents = db.getSessionToolEvents(input.session_id, 500);
17846
+ const observations = db.getObservationsBySession(input.session_id);
17847
+ const toolEventCounts = toolEvents.reduce((acc, event) => {
17848
+ acc.set(event.tool_name, (acc.get(event.tool_name) ?? 0) + 1);
17849
+ return acc;
17850
+ }, new Map);
17851
+ const observationGroups = observations.reduce((acc, obs) => {
17852
+ if (!obs.source_tool)
17853
+ return acc;
17854
+ const group = acc.get(obs.source_tool) ?? [];
17855
+ group.push(obs);
17856
+ acc.set(obs.source_tool, group);
17857
+ return acc;
17858
+ }, new Map);
17859
+ const tools = Array.from(observationGroups.entries()).map(([tool, groupedObservations]) => {
17860
+ const topTypes = Array.from(groupedObservations.reduce((acc, obs) => {
17861
+ acc.set(obs.type, (acc.get(obs.type) ?? 0) + 1);
17862
+ return acc;
17863
+ }, new Map).entries()).map(([type, count]) => ({ type, count })).sort((a, b) => b.count - a.count || a.type.localeCompare(b.type)).slice(0, 5);
17864
+ const sampleTitles = groupedObservations.map((obs) => obs.title).filter((title, index, all) => all.indexOf(title) === index).slice(0, 4);
17865
+ const topPlugins = Array.from(groupedObservations.reduce((acc, obs) => {
17866
+ for (const concept of parseConcepts2(obs.concepts)) {
17867
+ if (!concept.startsWith("plugin:"))
17868
+ continue;
17869
+ const plugin = concept.slice("plugin:".length);
17870
+ if (!plugin)
17871
+ continue;
17872
+ acc.set(plugin, (acc.get(plugin) ?? 0) + 1);
17873
+ }
17874
+ return acc;
17875
+ }, new Map).entries()).map(([plugin, count]) => ({ plugin, count })).sort((a, b) => b.count - a.count || a.plugin.localeCompare(b.plugin)).slice(0, 4);
17876
+ const latestPromptNumber = groupedObservations.reduce((latest, obs) => typeof obs.source_prompt_number === "number" ? latest === null || obs.source_prompt_number > latest ? obs.source_prompt_number : latest : latest, null);
17877
+ return {
17878
+ tool,
17879
+ tool_event_count: toolEventCounts.get(tool) ?? 0,
17880
+ observation_count: groupedObservations.length,
17881
+ top_types: topTypes,
17882
+ top_plugins: topPlugins,
17883
+ sample_titles: sampleTitles,
17884
+ latest_prompt_number: latestPromptNumber
17885
+ };
17886
+ }).sort((a, b) => b.observation_count - a.observation_count || a.tool.localeCompare(b.tool));
17887
+ const toolsWithoutMemory = Array.from(toolEventCounts.entries()).filter(([tool]) => !observationGroups.has(tool)).map(([tool, toolEventCount]) => ({
17888
+ tool,
17889
+ tool_event_count: toolEventCount
17890
+ })).sort((a, b) => b.tool_event_count - a.tool_event_count || a.tool.localeCompare(b.tool));
17891
+ return {
17892
+ session_id: input.session_id,
17893
+ tools,
17894
+ tools_without_memory: toolsWithoutMemory
17895
+ };
17377
17896
  }
17378
17897
 
17379
17898
  // src/tools/session-context.ts
@@ -17386,6 +17905,7 @@ function getSessionContext(db, input) {
17386
17905
  });
17387
17906
  if (!context)
17388
17907
  return null;
17908
+ const preview = formatContextForInjection(context);
17389
17909
  const recentRequests = context.recentPrompts?.length ?? 0;
17390
17910
  const recentTools = context.recentToolEvents?.length ?? 0;
17391
17911
  const captureState = recentRequests > 0 && recentTools > 0 ? "rich" : recentRequests > 0 || recentTools > 0 ? "partial" : "summary-only";
@@ -17402,19 +17922,21 @@ function getSessionContext(db, input) {
17402
17922
  hot_files: hotFiles,
17403
17923
  capture_state: captureState,
17404
17924
  raw_capture_active: recentRequests > 0 || recentTools > 0,
17405
- preview: formatContextForInjection(context)
17925
+ estimated_read_tokens: estimateTokens(preview),
17926
+ suggested_tools: buildSuggestedTools2(context),
17927
+ preview
17406
17928
  };
17407
17929
  }
17408
17930
  function buildHotFiles(context) {
17409
17931
  const counts = new Map;
17410
17932
  for (const obs of context.observations) {
17411
- for (const path of [...parseJsonArray(obs.files_read), ...parseJsonArray(obs.files_modified)]) {
17933
+ for (const path of [...parseJsonArray2(obs.files_read), ...parseJsonArray2(obs.files_modified)]) {
17412
17934
  counts.set(path, (counts.get(path) ?? 0) + 1);
17413
17935
  }
17414
17936
  }
17415
17937
  return Array.from(counts.entries()).map(([path, count]) => ({ path, count })).sort((a, b) => b.count - a.count || a.path.localeCompare(b.path)).slice(0, 6);
17416
17938
  }
17417
- function parseJsonArray(value) {
17939
+ function parseJsonArray2(value) {
17418
17940
  if (!value)
17419
17941
  return [];
17420
17942
  try {
@@ -17424,6 +17946,161 @@ function parseJsonArray(value) {
17424
17946
  return [];
17425
17947
  }
17426
17948
  }
17949
+ function buildSuggestedTools2(context) {
17950
+ const tools = [];
17951
+ if ((context.recentSessions?.length ?? 0) > 0) {
17952
+ tools.push("recent_sessions");
17953
+ }
17954
+ if ((context.recentPrompts?.length ?? 0) > 0 || (context.recentToolEvents?.length ?? 0) > 0) {
17955
+ tools.push("activity_feed");
17956
+ }
17957
+ if (context.observations.length > 0) {
17958
+ tools.push("tool_memory_index", "capture_git_worktree");
17959
+ }
17960
+ return Array.from(new Set(tools)).slice(0, 4);
17961
+ }
17962
+
17963
+ // src/tools/capture-git-worktree.ts
17964
+ import { execSync as execSync2 } from "node:child_process";
17965
+ import { existsSync as existsSync4 } from "node:fs";
17966
+ import { resolve as resolve2 } from "node:path";
17967
+ function runGitCommand(cwd, args) {
17968
+ return execSync2(`git ${args.join(" ")}`, {
17969
+ cwd,
17970
+ encoding: "utf-8",
17971
+ timeout: 8000,
17972
+ stdio: ["pipe", "pipe", "pipe"]
17973
+ }).trim();
17974
+ }
17975
+ function captureGitWorktree(input = {}) {
17976
+ const cwd = resolve2(input.cwd ?? process.cwd());
17977
+ if (!existsSync4(cwd)) {
17978
+ throw new Error(`Path does not exist: ${cwd}`);
17979
+ }
17980
+ try {
17981
+ runGitCommand(cwd, ["rev-parse", "--is-inside-work-tree"]);
17982
+ } catch {
17983
+ throw new Error(`Not a git repository: ${cwd}`);
17984
+ }
17985
+ const diffArgs = input.staged ? ["diff", "--cached", "--no-ext-diff", "--minimal"] : ["diff", "--no-ext-diff", "--minimal"];
17986
+ const fileArgs = input.staged ? ["diff", "--cached", "--name-only"] : ["diff", "--name-only"];
17987
+ const diff = runGitCommand(cwd, diffArgs);
17988
+ const files = runGitCommand(cwd, fileArgs).split(`
17989
+ `).map((line) => line.trim()).filter(Boolean);
17990
+ return {
17991
+ cwd,
17992
+ diff,
17993
+ files
17994
+ };
17995
+ }
17996
+
17997
+ // src/tools/capture-repo-scan.ts
17998
+ import { execSync as execSync3 } from "node:child_process";
17999
+ import { existsSync as existsSync5 } from "node:fs";
18000
+ import { resolve as resolve3 } from "node:path";
18001
+ function runRg(cwd, args) {
18002
+ return execSync3(`rg ${args.join(" ")}`, {
18003
+ cwd,
18004
+ encoding: "utf-8",
18005
+ timeout: 8000,
18006
+ stdio: ["pipe", "pipe", "pipe"]
18007
+ }).trim();
18008
+ }
18009
+ function safeRunRg(cwd, args) {
18010
+ try {
18011
+ return runRg(cwd, args);
18012
+ } catch {
18013
+ return "";
18014
+ }
18015
+ }
18016
+ function quotePattern(pattern) {
18017
+ return JSON.stringify(pattern);
18018
+ }
18019
+ function parseUniqueFiles(output) {
18020
+ return Array.from(new Set(output.split(`
18021
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.split(":")[0] ?? line).filter(Boolean)));
18022
+ }
18023
+ function buildDefaultFindings(cwd) {
18024
+ const findings = [];
18025
+ const todoOutput = safeRunRg(cwd, ["-n", "-S", "-m", "12", quotePattern("TODO|FIXME|HACK|XXX"), "."]);
18026
+ const todoFiles = parseUniqueFiles(todoOutput);
18027
+ if (todoFiles.length > 0) {
18028
+ findings.push({
18029
+ kind: "risk",
18030
+ title: `Outstanding TODO/FIXME markers found in ${todoFiles.length} files`,
18031
+ severity: todoFiles.length >= 5 ? "high" : "medium",
18032
+ file: todoFiles[0],
18033
+ detail: todoOutput.split(`
18034
+ `).slice(0, 3).join(`
18035
+ `)
18036
+ });
18037
+ }
18038
+ const authOutput = safeRunRg(cwd, ["-l", "-S", quotePattern("auth|oauth|token|session|middleware"), "."]);
18039
+ const authFiles = parseUniqueFiles(authOutput).slice(0, 8);
18040
+ if (authFiles.length > 0) {
18041
+ findings.push({
18042
+ kind: "discovery",
18043
+ title: `Auth/session logic concentrated in ${authFiles.length} files`,
18044
+ file: authFiles[0],
18045
+ detail: authFiles.join(", ")
18046
+ });
18047
+ }
18048
+ const routeOutput = safeRunRg(cwd, ["-l", "-S", quotePattern("router|route|endpoint|express\\.Router|FastAPI|APIRouter"), "."]);
18049
+ const routeFiles = parseUniqueFiles(routeOutput).slice(0, 8);
18050
+ if (routeFiles.length > 0) {
18051
+ findings.push({
18052
+ kind: "pattern",
18053
+ title: `Routing/API structure appears in ${routeFiles.length} files`,
18054
+ file: routeFiles[0],
18055
+ detail: routeFiles.join(", ")
18056
+ });
18057
+ }
18058
+ const testOutput = safeRunRg(cwd, ["-l", "-S", quotePattern("\\.test\\.|\\.spec\\.|describe\\(|test\\("), "."]);
18059
+ const testFiles = parseUniqueFiles(testOutput).slice(0, 8);
18060
+ if (testFiles.length > 0) {
18061
+ findings.push({
18062
+ kind: "change",
18063
+ title: `Test-related files present across ${testFiles.length} files`,
18064
+ file: testFiles[0],
18065
+ detail: testFiles.join(", ")
18066
+ });
18067
+ }
18068
+ return findings;
18069
+ }
18070
+ function buildFocusFindings(cwd, focus) {
18071
+ const findings = [];
18072
+ for (const topic of focus) {
18073
+ const cleaned = topic.trim();
18074
+ if (!cleaned)
18075
+ continue;
18076
+ const output = safeRunRg(cwd, ["-n", "-S", "-m", "12", quotePattern(cleaned), "."]);
18077
+ const files = parseUniqueFiles(output).slice(0, 8);
18078
+ if (files.length === 0)
18079
+ continue;
18080
+ findings.push({
18081
+ kind: "discovery",
18082
+ title: `Found ${cleaned} references in ${files.length} files`,
18083
+ file: files[0],
18084
+ detail: files.join(", ")
18085
+ });
18086
+ }
18087
+ return findings;
18088
+ }
18089
+ function captureRepoScan(input = {}) {
18090
+ const cwd = resolve3(input.cwd ?? process.cwd());
18091
+ if (!existsSync5(cwd)) {
18092
+ throw new Error(`Path does not exist: ${cwd}`);
18093
+ }
18094
+ const focus = (input.focus ?? []).map((item) => item.trim()).filter(Boolean);
18095
+ const findings = [
18096
+ ...buildDefaultFindings(cwd),
18097
+ ...buildFocusFindings(cwd, focus)
18098
+ ].slice(0, Math.max(1, Math.min(input.max_findings ?? 8, 20)));
18099
+ return {
18100
+ cwd,
18101
+ findings
18102
+ };
18103
+ }
17427
18104
 
17428
18105
  // src/tools/send-message.ts
17429
18106
  async function sendMessage(db, config2, input) {
@@ -17549,10 +18226,7 @@ function countPresentSections(summary) {
17549
18226
  ].filter(hasContent).length;
17550
18227
  }
17551
18228
  function extractSectionItems(section) {
17552
- if (!hasContent(section))
17553
- return [];
17554
- return section.split(`
17555
- `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
18229
+ return extractSummaryItems(section);
17556
18230
  }
17557
18231
  function extractObservationTitles(observations, types) {
17558
18232
  const typeSet = new Set(types);
@@ -17994,6 +18668,8 @@ function buildVectorDocument(obs, config2, project) {
17994
18668
  concepts: obs.concepts ? JSON.parse(obs.concepts) : [],
17995
18669
  files_read: obs.files_read ? JSON.parse(obs.files_read) : [],
17996
18670
  files_modified: obs.files_modified ? JSON.parse(obs.files_modified) : [],
18671
+ source_tool: obs.source_tool,
18672
+ source_prompt_number: obs.source_prompt_number,
17997
18673
  session_id: obs.session_id,
17998
18674
  created_at_epoch: obs.created_at_epoch,
17999
18675
  created_at: obs.created_at,
@@ -18047,6 +18723,8 @@ function buildSummaryVectorDocument(summary, config2, project, observations = []
18047
18723
  recent_tool_commands: captureContext?.recent_tool_commands ?? [],
18048
18724
  hot_files: captureContext?.hot_files ?? [],
18049
18725
  recent_outcomes: captureContext?.recent_outcomes ?? [],
18726
+ observation_source_tools: captureContext?.observation_source_tools ?? [],
18727
+ latest_observation_prompt_number: captureContext?.latest_observation_prompt_number ?? null,
18050
18728
  decisions_count: valueSignals.decisions_count,
18051
18729
  lessons_count: valueSignals.lessons_count,
18052
18730
  discoveries_count: valueSignals.discoveries_count,
@@ -18159,10 +18837,7 @@ function countPresentSections2(summary) {
18159
18837
  ].filter((value) => Boolean(value && value.trim())).length;
18160
18838
  }
18161
18839
  function extractSectionItems2(section) {
18162
- if (!section)
18163
- return [];
18164
- return section.split(`
18165
- `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "").trim()).filter(Boolean).slice(0, 4);
18840
+ return extractSummaryItems(section, 4);
18166
18841
  }
18167
18842
  function buildSummaryCaptureContext(prompts, toolEvents, observations) {
18168
18843
  const latestRequest = prompts.length > 0 ? prompts[prompts.length - 1]?.prompt ?? null : null;
@@ -18170,11 +18845,18 @@ function buildSummaryCaptureContext(prompts, toolEvents, observations) {
18170
18845
  const recentToolNames = [...new Set(toolEvents.slice(-8).map((tool) => tool.tool_name).filter(Boolean))];
18171
18846
  const recentToolCommands = [...new Set(toolEvents.slice(-5).map((tool) => (tool.command ?? tool.file_path ?? "").trim()).filter(Boolean))];
18172
18847
  const hotFiles = [...new Set(observations.flatMap((obs) => [
18173
- ...parseJsonArray2(obs.files_modified),
18174
- ...parseJsonArray2(obs.files_read)
18848
+ ...parseJsonArray3(obs.files_modified),
18849
+ ...parseJsonArray3(obs.files_read)
18175
18850
  ]).filter(Boolean))].slice(0, 6);
18176
18851
  const recentOutcomes = observations.filter((obs) => ["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type)).map((obs) => obs.title.trim()).filter((title) => title.length > 0).slice(0, 6);
18177
18852
  const captureState = prompts.length > 0 && toolEvents.length > 0 ? "rich" : prompts.length > 0 || toolEvents.length > 0 ? "partial" : "summary-only";
18853
+ const observationSourceTools = Array.from(observations.reduce((acc, obs) => {
18854
+ if (!obs.source_tool)
18855
+ return acc;
18856
+ acc.set(obs.source_tool, (acc.get(obs.source_tool) ?? 0) + 1);
18857
+ return acc;
18858
+ }, new Map).entries()).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool)).slice(0, 6);
18859
+ const latestObservationPromptNumber = observations.map((obs) => obs.source_prompt_number).filter((value) => typeof value === "number").sort((a, b) => b - a)[0] ?? null;
18178
18860
  return {
18179
18861
  prompt_count: prompts.length,
18180
18862
  tool_event_count: toolEvents.length,
@@ -18184,10 +18866,12 @@ function buildSummaryCaptureContext(prompts, toolEvents, observations) {
18184
18866
  recent_tool_commands: recentToolCommands,
18185
18867
  capture_state: captureState,
18186
18868
  hot_files: hotFiles,
18187
- recent_outcomes: recentOutcomes
18869
+ recent_outcomes: recentOutcomes,
18870
+ observation_source_tools: observationSourceTools,
18871
+ latest_observation_prompt_number: latestObservationPromptNumber
18188
18872
  };
18189
18873
  }
18190
- function parseJsonArray2(value) {
18874
+ function parseJsonArray3(value) {
18191
18875
  if (!value)
18192
18876
  return [];
18193
18877
  try {
@@ -18449,7 +19133,7 @@ async function backfillEmbeddings(db, batchSize = 50) {
18449
19133
  }
18450
19134
 
18451
19135
  // src/packs/recommender.ts
18452
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "node:fs";
19136
+ import { existsSync as existsSync6, readdirSync, readFileSync as readFileSync4 } from "node:fs";
18453
19137
  import { join as join4, basename as basename2, dirname as dirname2 } from "node:path";
18454
19138
  import { fileURLToPath } from "node:url";
18455
19139
  function getPacksDir() {
@@ -18458,7 +19142,7 @@ function getPacksDir() {
18458
19142
  }
18459
19143
  function loadPack(name) {
18460
19144
  const packPath = join4(getPacksDir(), `${name}.json`);
18461
- if (!existsSync4(packPath))
19145
+ if (!existsSync6(packPath))
18462
19146
  return null;
18463
19147
  try {
18464
19148
  const raw = readFileSync4(packPath, "utf-8");
@@ -18469,7 +19153,7 @@ function loadPack(name) {
18469
19153
  }
18470
19154
 
18471
19155
  // src/server.ts
18472
- import { existsSync as existsSync5, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
19156
+ import { existsSync as existsSync7, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
18473
19157
  import { join as join5 } from "node:path";
18474
19158
  import { homedir as homedir3 } from "node:os";
18475
19159
 
@@ -18798,6 +19482,218 @@ function reduceGitDiffToMemory(input) {
18798
19482
  };
18799
19483
  }
18800
19484
 
19485
+ // src/plugins/repo-scan.ts
19486
+ function uniq2(items) {
19487
+ const seen = new Set;
19488
+ const out = [];
19489
+ for (const item of items) {
19490
+ const cleaned = item.trim();
19491
+ if (!cleaned)
19492
+ continue;
19493
+ const key = cleaned.toLowerCase();
19494
+ if (seen.has(key))
19495
+ continue;
19496
+ seen.add(key);
19497
+ out.push(cleaned);
19498
+ }
19499
+ return out;
19500
+ }
19501
+ function countByKind(findings) {
19502
+ return findings.reduce((acc, finding) => {
19503
+ acc[finding.kind] += 1;
19504
+ return acc;
19505
+ }, { risk: 0, discovery: 0, pattern: 0, change: 0 });
19506
+ }
19507
+ function inferType(findings) {
19508
+ if (findings.some((finding) => finding.kind === "risk"))
19509
+ return "pattern";
19510
+ if (findings.some((finding) => finding.kind === "discovery"))
19511
+ return "discovery";
19512
+ return "change";
19513
+ }
19514
+ function inferTitle2(summary, findings) {
19515
+ const cleanedSummary = summary?.trim();
19516
+ if (cleanedSummary && cleanedSummary.length >= 8) {
19517
+ return cleanedSummary.replace(/\.$/, "");
19518
+ }
19519
+ const firstRisk = findings.find((finding) => finding.kind === "risk");
19520
+ if (firstRisk) {
19521
+ return `Repo scan: ${firstRisk.title}`;
19522
+ }
19523
+ const firstDiscovery = findings.find((finding) => finding.kind === "discovery");
19524
+ if (firstDiscovery) {
19525
+ return `Repo scan: ${firstDiscovery.title}`;
19526
+ }
19527
+ return "Repo scan findings";
19528
+ }
19529
+ function buildFacts2(findings) {
19530
+ const facts = [];
19531
+ const counts = countByKind(findings);
19532
+ facts.push(`Repo scan findings: ${counts.risk} risks, ${counts.discovery} discoveries, ${counts.pattern} patterns, ${counts.change} changes`);
19533
+ for (const finding of findings.slice(0, 3)) {
19534
+ const severity = finding.severity ? `${finding.severity} ` : "";
19535
+ const file2 = finding.file ? ` (${finding.file})` : "";
19536
+ facts.push(`${severity}${finding.title}${file2}`.trim());
19537
+ }
19538
+ return uniq2(facts).slice(0, 4);
19539
+ }
19540
+ function buildTags(findings) {
19541
+ const tags = ["repo-scan"];
19542
+ if (findings.some((finding) => finding.kind === "risk"))
19543
+ tags.push("risk-finding");
19544
+ if (findings.some((finding) => finding.kind === "discovery"))
19545
+ tags.push("discovery");
19546
+ if (findings.some((finding) => finding.kind === "pattern"))
19547
+ tags.push("pattern");
19548
+ for (const severity of ["critical", "high", "medium", "low"]) {
19549
+ if (findings.some((finding) => finding.severity === severity)) {
19550
+ tags.push(`severity:${severity}`);
19551
+ }
19552
+ }
19553
+ return uniq2(tags);
19554
+ }
19555
+ function buildSummary2(summary, findings) {
19556
+ if (summary && summary.trim().length >= 16) {
19557
+ return summary.trim();
19558
+ }
19559
+ const counts = countByKind(findings);
19560
+ return `Reduced a repository scan into reusable memory with ` + `${counts.risk} risks, ${counts.discovery} discoveries, ` + `${counts.pattern} patterns, and ${counts.change} changes.`;
19561
+ }
19562
+ function reduceRepoScanToMemory(input) {
19563
+ const findings = input.findings.filter((finding) => finding.title.trim().length > 0);
19564
+ const files = uniq2(findings.map((finding) => finding.file ?? "").filter(Boolean)).slice(0, 6);
19565
+ return {
19566
+ plugin_id: "engrm.repo-scan",
19567
+ type: inferType(findings),
19568
+ title: inferTitle2(input.summary, findings),
19569
+ summary: buildSummary2(input.summary, findings),
19570
+ facts: buildFacts2(findings),
19571
+ tags: buildTags(findings),
19572
+ source: "repo-scan",
19573
+ source_refs: files.map((file2) => ({ kind: "file", value: file2 })),
19574
+ surfaces: ["startup", "briefs", "sentinel", "insights"],
19575
+ files_read: files,
19576
+ session_id: input.session_id,
19577
+ cwd: input.cwd,
19578
+ agent: input.agent
19579
+ };
19580
+ }
19581
+
19582
+ // src/plugins/openclaw-content.ts
19583
+ function dedupe2(items) {
19584
+ const seen = new Set;
19585
+ const output = [];
19586
+ for (const item of items) {
19587
+ const cleaned = item.trim();
19588
+ if (!cleaned)
19589
+ continue;
19590
+ const key = cleaned.toLowerCase();
19591
+ if (seen.has(key))
19592
+ continue;
19593
+ seen.add(key);
19594
+ output.push(cleaned);
19595
+ }
19596
+ return output;
19597
+ }
19598
+ function inferType2(input) {
19599
+ if ((input.next_actions ?? []).length > 0)
19600
+ return "decision";
19601
+ if ((input.researched ?? []).length > 0)
19602
+ return "discovery";
19603
+ if ((input.posted ?? []).length > 0 || (input.outcomes ?? []).length > 0)
19604
+ return "change";
19605
+ return "message";
19606
+ }
19607
+ function inferTitle3(input) {
19608
+ const explicit = input.title?.trim();
19609
+ if (explicit && explicit.length >= 8)
19610
+ return explicit.replace(/\.$/, "");
19611
+ const firstPosted = input.posted?.find((item) => item.trim().length > 0);
19612
+ if (firstPosted)
19613
+ return firstPosted;
19614
+ const firstOutcome = input.outcomes?.find((item) => item.trim().length > 0);
19615
+ if (firstOutcome)
19616
+ return firstOutcome;
19617
+ const firstResearch = input.researched?.find((item) => item.trim().length > 0);
19618
+ if (firstResearch)
19619
+ return firstResearch;
19620
+ return "OpenClaw content work";
19621
+ }
19622
+ function buildFacts3(input) {
19623
+ const posted = dedupe2(input.posted ?? []);
19624
+ const researched = dedupe2(input.researched ?? []);
19625
+ const outcomes = dedupe2(input.outcomes ?? []);
19626
+ const nextActions = dedupe2(input.next_actions ?? []);
19627
+ const facts = [
19628
+ posted.length > 0 ? `Posted: ${posted.length}` : null,
19629
+ researched.length > 0 ? `Researched: ${researched.length}` : null,
19630
+ outcomes.length > 0 ? `Outcomes: ${outcomes.length}` : null,
19631
+ nextActions.length > 0 ? `Next actions: ${nextActions.length}` : null,
19632
+ ...posted.slice(0, 2),
19633
+ ...researched.slice(0, 2),
19634
+ ...outcomes.slice(0, 2),
19635
+ ...nextActions.slice(0, 2)
19636
+ ].filter((item) => Boolean(item));
19637
+ return dedupe2(facts).slice(0, 8);
19638
+ }
19639
+ function buildSummary3(input) {
19640
+ const sections = [];
19641
+ const posted = dedupe2(input.posted ?? []);
19642
+ const researched = dedupe2(input.researched ?? []);
19643
+ const outcomes = dedupe2(input.outcomes ?? []);
19644
+ const nextActions = dedupe2(input.next_actions ?? []);
19645
+ if (posted.length > 0) {
19646
+ sections.push(`Posted:
19647
+ ${posted.map((item) => `- ${item}`).join(`
19648
+ `)}`);
19649
+ }
19650
+ if (researched.length > 0) {
19651
+ sections.push(`Researched:
19652
+ ${researched.map((item) => `- ${item}`).join(`
19653
+ `)}`);
19654
+ }
19655
+ if (outcomes.length > 0) {
19656
+ sections.push(`Outcomes:
19657
+ ${outcomes.map((item) => `- ${item}`).join(`
19658
+ `)}`);
19659
+ }
19660
+ if (nextActions.length > 0) {
19661
+ sections.push(`Next Actions:
19662
+ ${nextActions.map((item) => `- ${item}`).join(`
19663
+ `)}`);
19664
+ }
19665
+ return sections.join(`
19666
+
19667
+ `) || "Reduced OpenClaw content activity into reusable memory.";
19668
+ }
19669
+ function reduceOpenClawContentToMemory(input) {
19670
+ const links = dedupe2(input.links ?? []);
19671
+ const posted = dedupe2(input.posted ?? []);
19672
+ const researched = dedupe2(input.researched ?? []);
19673
+ const outcomes = dedupe2(input.outcomes ?? []);
19674
+ const nextActions = dedupe2(input.next_actions ?? []);
19675
+ return {
19676
+ plugin_id: "engrm.openclaw-content",
19677
+ type: inferType2(input),
19678
+ title: inferTitle3(input),
19679
+ summary: buildSummary3(input),
19680
+ facts: buildFacts3(input),
19681
+ tags: dedupe2([
19682
+ "openclaw-content",
19683
+ posted.length > 0 ? "posted" : "",
19684
+ researched.length > 0 ? "researched" : "",
19685
+ outcomes.length > 0 ? "outcomes" : "",
19686
+ nextActions.length > 0 ? "next-actions" : ""
19687
+ ]),
19688
+ source: "openclaw",
19689
+ source_refs: links.map((value) => ({ kind: "thread", value })),
19690
+ surfaces: ["briefs", "startup", "insights"],
19691
+ session_id: input.session_id,
19692
+ cwd: input.cwd,
19693
+ agent: input.agent
19694
+ };
19695
+ }
19696
+
18801
19697
  // src/server.ts
18802
19698
  if (!configExists()) {
18803
19699
  console.error("Engrm is not configured. Run: engrm init --manual");
@@ -18819,7 +19715,7 @@ var MCP_METRICS_PATH = join5(homedir3(), ".engrm", "mcp-session-metrics.json");
18819
19715
  function persistSessionMetrics() {
18820
19716
  try {
18821
19717
  const dir = join5(homedir3(), ".engrm");
18822
- if (!existsSync5(dir))
19718
+ if (!existsSync7(dir))
18823
19719
  mkdirSync2(dir, { recursive: true });
18824
19720
  writeFileSync2(MCP_METRICS_PATH, JSON.stringify(sessionMetrics), "utf-8");
18825
19721
  } catch {}
@@ -18868,7 +19764,7 @@ process.on("SIGTERM", () => {
18868
19764
  });
18869
19765
  var server = new McpServer({
18870
19766
  name: "engrm",
18871
- version: "0.4.8"
19767
+ version: "0.4.13"
18872
19768
  });
18873
19769
  server.tool("save_observation", "Save an observation to memory", {
18874
19770
  type: exports_external.enum([
@@ -19044,6 +19940,164 @@ Facts: ${reduced.facts.join("; ")}` : "";
19044
19940
  ]
19045
19941
  };
19046
19942
  });
19943
+ server.tool("capture_git_worktree", "Read the current git worktree diff, reduce it into durable memory, and save it with plugin provenance", {
19944
+ cwd: exports_external.string().optional().describe("Git repo path; defaults to the current working directory"),
19945
+ staged: exports_external.boolean().optional().describe("Capture staged changes instead of unstaged worktree changes"),
19946
+ summary: exports_external.string().optional().describe("Optional human summary or commit-style title"),
19947
+ session_id: exports_external.string().optional()
19948
+ }, async (params) => {
19949
+ let worktree;
19950
+ try {
19951
+ worktree = captureGitWorktree({
19952
+ cwd: params.cwd ?? process.cwd(),
19953
+ staged: params.staged
19954
+ });
19955
+ } catch (error48) {
19956
+ return {
19957
+ content: [
19958
+ {
19959
+ type: "text",
19960
+ text: `Not captured: ${error48 instanceof Error ? error48.message : "unable to read git worktree"}`
19961
+ }
19962
+ ]
19963
+ };
19964
+ }
19965
+ if (!worktree.diff.trim()) {
19966
+ return {
19967
+ content: [
19968
+ {
19969
+ type: "text",
19970
+ text: `No ${params.staged ? "staged" : "unstaged"} git diff found in ${worktree.cwd}`
19971
+ }
19972
+ ]
19973
+ };
19974
+ }
19975
+ const reduced = reduceGitDiffToMemory({
19976
+ diff: worktree.diff,
19977
+ summary: params.summary,
19978
+ files: worktree.files,
19979
+ session_id: params.session_id,
19980
+ cwd: worktree.cwd,
19981
+ agent: getDetectedAgent()
19982
+ });
19983
+ const result = await savePluginMemory(db, config2, reduced);
19984
+ if (!result.success) {
19985
+ return {
19986
+ content: [
19987
+ {
19988
+ type: "text",
19989
+ text: `Not saved: ${result.reason}`
19990
+ }
19991
+ ]
19992
+ };
19993
+ }
19994
+ return {
19995
+ content: [
19996
+ {
19997
+ type: "text",
19998
+ text: `Saved ${params.staged ? "staged" : "worktree"} git diff as observation #${result.observation_id} ` + `(${reduced.type}: ${reduced.title})`
19999
+ }
20000
+ ]
20001
+ };
20002
+ });
20003
+ server.tool("capture_repo_scan", "Run a lightweight repository scan, reduce the findings into durable memory, and save it with plugin provenance", {
20004
+ cwd: exports_external.string().optional().describe("Repo path to scan; defaults to the current working directory"),
20005
+ focus: exports_external.array(exports_external.string()).optional().describe("Optional extra topics to search for, like 'billing' or 'validation'"),
20006
+ max_findings: exports_external.number().optional().describe("Maximum findings to keep"),
20007
+ summary: exports_external.string().optional().describe("Optional human summary for the scan"),
20008
+ session_id: exports_external.string().optional()
20009
+ }, async (params) => {
20010
+ let scan;
20011
+ try {
20012
+ scan = captureRepoScan({
20013
+ cwd: params.cwd ?? process.cwd(),
20014
+ focus: params.focus,
20015
+ max_findings: params.max_findings
20016
+ });
20017
+ } catch (error48) {
20018
+ return {
20019
+ content: [
20020
+ {
20021
+ type: "text",
20022
+ text: `Not captured: ${error48 instanceof Error ? error48.message : "unable to scan repository"}`
20023
+ }
20024
+ ]
20025
+ };
20026
+ }
20027
+ if (scan.findings.length === 0) {
20028
+ return {
20029
+ content: [
20030
+ {
20031
+ type: "text",
20032
+ text: `No lightweight repo-scan findings found in ${scan.cwd}`
20033
+ }
20034
+ ]
20035
+ };
20036
+ }
20037
+ const reduced = reduceRepoScanToMemory({
20038
+ summary: params.summary,
20039
+ findings: scan.findings,
20040
+ session_id: params.session_id,
20041
+ cwd: scan.cwd,
20042
+ agent: getDetectedAgent()
20043
+ });
20044
+ const result = await savePluginMemory(db, config2, reduced);
20045
+ if (!result.success) {
20046
+ return {
20047
+ content: [
20048
+ {
20049
+ type: "text",
20050
+ text: `Not saved: ${result.reason}`
20051
+ }
20052
+ ]
20053
+ };
20054
+ }
20055
+ const findingSummary = scan.findings.slice(0, 3).map((finding) => finding.title).join("; ");
20056
+ return {
20057
+ content: [
20058
+ {
20059
+ type: "text",
20060
+ text: `Saved repo scan as observation #${result.observation_id} ` + `(${reduced.type}: ${reduced.title})` + `${findingSummary ? `
20061
+ Findings: ${findingSummary}` : ""}`
20062
+ }
20063
+ ]
20064
+ };
20065
+ });
20066
+ server.tool("capture_openclaw_content", "Reduce OpenClaw content/research activity into durable memory and save it with plugin provenance", {
20067
+ title: exports_external.string().optional().describe("Short content or campaign title"),
20068
+ posted: exports_external.array(exports_external.string()).optional().describe("Concrete posted items or shipped content outcomes"),
20069
+ researched: exports_external.array(exports_external.string()).optional().describe("Research or discovery items"),
20070
+ outcomes: exports_external.array(exports_external.string()).optional().describe("Meaningful outcomes from the run"),
20071
+ next_actions: exports_external.array(exports_external.string()).optional().describe("Real follow-up actions"),
20072
+ links: exports_external.array(exports_external.string()).optional().describe("Thread or source URLs"),
20073
+ session_id: exports_external.string().optional(),
20074
+ cwd: exports_external.string().optional()
20075
+ }, async (params) => {
20076
+ const reduced = reduceOpenClawContentToMemory({
20077
+ ...params,
20078
+ cwd: params.cwd ?? process.cwd(),
20079
+ agent: getDetectedAgent()
20080
+ });
20081
+ const result = await savePluginMemory(db, config2, reduced);
20082
+ if (!result.success) {
20083
+ return {
20084
+ content: [
20085
+ {
20086
+ type: "text",
20087
+ text: `Not saved: ${result.reason}`
20088
+ }
20089
+ ]
20090
+ };
20091
+ }
20092
+ return {
20093
+ content: [
20094
+ {
20095
+ type: "text",
20096
+ text: `Saved OpenClaw content memory as observation #${result.observation_id} ` + `(${reduced.type}: ${reduced.title})`
20097
+ }
20098
+ ]
20099
+ };
20100
+ });
19047
20101
  server.tool("search", "Search memory for observations", {
19048
20102
  query: exports_external.string().describe("Search query"),
19049
20103
  project_scoped: exports_external.boolean().optional().describe("Scope to project (default: true)"),
@@ -19389,7 +20443,22 @@ server.tool("memory_console", "Show a high-signal local overview of what Engrm c
19389
20443
  return `- ${tool.tool_name}${detail ? ` — ${detail}` : ""}`;
19390
20444
  }).join(`
19391
20445
  `) : "- (none)";
19392
- const observationLines = result.observations.length > 0 ? result.observations.map((obs) => `- #${obs.id} [${obs.type}] ${obs.title}`).join(`
20446
+ const observationLines = result.observations.length > 0 ? result.observations.map((obs) => {
20447
+ const provenance = [];
20448
+ if (obs.source_tool)
20449
+ provenance.push(`via ${obs.source_tool}`);
20450
+ if (typeof obs.source_prompt_number === "number")
20451
+ provenance.push(`#${obs.source_prompt_number}`);
20452
+ return `- #${obs.id} [${obs.type}] ${obs.title}${provenance.length ? ` (${provenance.join(" · ")})` : ""}`;
20453
+ }).join(`
20454
+ `) : "- (none)";
20455
+ const provenanceLines = result.provenance_summary.length > 0 ? result.provenance_summary.map((item) => `- ${item.tool}: ${item.count}`).join(`
20456
+ `) : "- (none)";
20457
+ const provenanceMixLines2 = result.provenance_type_mix.length > 0 ? result.provenance_type_mix.map((item) => `- ${item.tool}: ${item.top_types.map((entry) => `${entry.type} ${entry.count}`).join(", ")}`).join(`
20458
+ `) : "- (none)";
20459
+ const checkpointTypeLines = result.assistant_checkpoint_types.length > 0 ? result.assistant_checkpoint_types.map((item) => `- ${item.type}: ${item.count}`).join(`
20460
+ `) : "- (none)";
20461
+ const topTypes = result.top_types.length > 0 ? result.top_types.map((item) => `- ${item.type}: ${item.count}`).join(`
19393
20462
  `) : "- (none)";
19394
20463
  const projectLine = result.project ? `Project: ${result.project}
19395
20464
 
@@ -19403,7 +20472,20 @@ server.tool("memory_console", "Show a high-signal local overview of what Engrm c
19403
20472
  content: [
19404
20473
  {
19405
20474
  type: "text",
19406
- text: `${projectLine}` + `${captureLine}` + `Recent sessions:
20475
+ text: `${projectLine}` + `${captureLine}` + `${typeof result.assistant_checkpoint_count === "number" ? `Assistant checkpoints: ${result.assistant_checkpoint_count}
20476
+ ` : ""}` + `${typeof result.estimated_read_tokens === "number" ? `Estimated read cost: ~${result.estimated_read_tokens}t
20477
+ ` : ""}` + `Suggested tools: ${result.suggested_tools.join(", ") || "(none)"}
20478
+
20479
+ ` + `Top types:
20480
+ ${topTypes}
20481
+
20482
+ ` + `Assistant checkpoint types:
20483
+ ${checkpointTypeLines}
20484
+
20485
+ ` + `Observation provenance:
20486
+ ${provenanceLines}
20487
+
20488
+ ` + `Recent sessions:
19407
20489
  ${sessionLines}
19408
20490
 
19409
20491
  ` + `Recent requests:
@@ -19454,6 +20536,106 @@ server.tool("capture_status", "Show whether Engrm hook registration and recent p
19454
20536
  ]
19455
20537
  };
19456
20538
  });
20539
+ server.tool("capture_quality", "Show workspace-wide capture richness, checkpoints, and provenance across projects", {
20540
+ limit: exports_external.number().optional(),
20541
+ user_id: exports_external.string().optional()
20542
+ }, async (params) => {
20543
+ const result = getCaptureQuality(db, {
20544
+ limit: params.limit,
20545
+ user_id: params.user_id ?? config2.user_id
20546
+ });
20547
+ const provenanceLines = result.provenance_summary.length > 0 ? result.provenance_summary.map((item) => `- ${item.tool}: ${item.count}`).join(`
20548
+ `) : "- (none)";
20549
+ const checkpointTypeLines = result.assistant_checkpoint_types.length > 0 ? result.assistant_checkpoint_types.map((item) => `- ${item.type}: ${item.count}`).join(`
20550
+ `) : "- (none)";
20551
+ const projectLines = result.top_projects.length > 0 ? result.top_projects.map((project) => `- ${project.name} [${project.raw_capture_state}] obs=${project.observation_count} sessions=${project.session_count} prompts=${project.prompt_count} tools=${project.tool_event_count} checkpoints=${project.assistant_checkpoint_count}`).join(`
20552
+ `) : "- (none)";
20553
+ return {
20554
+ content: [
20555
+ {
20556
+ type: "text",
20557
+ text: `Workspace totals: projects=${result.totals.projects}, observations=${result.totals.observations}, sessions=${result.totals.sessions}, prompts=${result.totals.prompts}, tools=${result.totals.tool_events}, checkpoints=${result.totals.assistant_checkpoints}
20558
+
20559
+ ` + `Session capture states: rich=${result.session_states.rich}, partial=${result.session_states.partial}, summary-only=${result.session_states.summary_only}, legacy=${result.session_states.legacy}
20560
+
20561
+ ` + `Projects with raw capture: ${result.projects_with_raw_capture}
20562
+
20563
+ ` + `Assistant checkpoints by type:
20564
+ ${checkpointTypeLines}
20565
+
20566
+ ` + `Observation provenance:
20567
+ ${provenanceLines}
20568
+
20569
+ ` + `Provenance type mix:
20570
+ ${provenanceMixLines}
20571
+
20572
+ ` + `Top projects:
20573
+ ${projectLines}`
20574
+ }
20575
+ ]
20576
+ };
20577
+ });
20578
+ server.tool("tool_memory_index", "Show which source tools are producing durable memory and what kinds of memory they create", {
20579
+ cwd: exports_external.string().optional(),
20580
+ project_scoped: exports_external.boolean().optional(),
20581
+ limit: exports_external.number().optional(),
20582
+ user_id: exports_external.string().optional()
20583
+ }, async (params) => {
20584
+ const result = getToolMemoryIndex(db, {
20585
+ cwd: params.cwd ?? process.cwd(),
20586
+ project_scoped: params.project_scoped,
20587
+ limit: params.limit,
20588
+ user_id: params.user_id ?? config2.user_id
20589
+ });
20590
+ const toolLines = result.tools.length > 0 ? result.tools.map((tool) => {
20591
+ const typeMix = tool.top_types.map((item) => `${item.type} ${item.count}`).join(", ");
20592
+ const pluginMix = tool.top_plugins.length > 0 ? ` plugins=[${tool.top_plugins.map((item) => `${item.plugin} ${item.count}`).join(", ")}]` : "";
20593
+ const sample = tool.sample_titles[0] ? ` sample="${tool.sample_titles[0]}"` : "";
20594
+ const promptInfo = typeof tool.latest_prompt_number === "number" ? ` latest_prompt=#${tool.latest_prompt_number}` : "";
20595
+ return `- ${tool.tool}: obs=${tool.observation_count} sessions=${tool.session_count}${promptInfo} types=[${typeMix}]${pluginMix}${sample}`;
20596
+ }).join(`
20597
+ `) : "- (none)";
20598
+ return {
20599
+ content: [
20600
+ {
20601
+ type: "text",
20602
+ text: `${result.project ? `Project: ${result.project}
20603
+
20604
+ ` : ""}` + `Tools producing durable memory:
20605
+ ${toolLines}`
20606
+ }
20607
+ ]
20608
+ };
20609
+ });
20610
+ server.tool("session_tool_memory", "Show which tools in one session produced durable memory and which tools produced none", {
20611
+ session_id: exports_external.string().describe("Session ID to inspect")
20612
+ }, async (params) => {
20613
+ const result = getSessionToolMemory(db, params);
20614
+ const toolLines = result.tools.length > 0 ? result.tools.map((tool) => {
20615
+ const typeMix = tool.top_types.map((item) => `${item.type} ${item.count}`).join(", ");
20616
+ const pluginMix = tool.top_plugins.length > 0 ? ` plugins=[${tool.top_plugins.map((item) => `${item.plugin} ${item.count}`).join(", ")}]` : "";
20617
+ const sample = tool.sample_titles[0] ? ` sample="${tool.sample_titles[0]}"` : "";
20618
+ const promptInfo = typeof tool.latest_prompt_number === "number" ? ` latest_prompt=#${tool.latest_prompt_number}` : "";
20619
+ return `- ${tool.tool}: events=${tool.tool_event_count} observations=${tool.observation_count}${promptInfo} types=[${typeMix}]${pluginMix}${sample}`;
20620
+ }).join(`
20621
+ `) : "- (none)";
20622
+ const unmappedLines = result.tools_without_memory.length > 0 ? result.tools_without_memory.map((tool) => `- ${tool.tool}: events=${tool.tool_event_count}`).join(`
20623
+ `) : "- (none)";
20624
+ return {
20625
+ content: [
20626
+ {
20627
+ type: "text",
20628
+ text: `Session: ${result.session_id}
20629
+
20630
+ ` + `Tools producing durable memory:
20631
+ ${toolLines}
20632
+
20633
+ ` + `Tools without durable memory:
20634
+ ${unmappedLines}`
20635
+ }
20636
+ ]
20637
+ };
20638
+ });
19457
20639
  server.tool("session_context", "Preview the exact project memory context Engrm would inject at session start", {
19458
20640
  cwd: exports_external.string().optional(),
19459
20641
  token_budget: exports_external.number().optional(),
@@ -19540,6 +20722,10 @@ server.tool("project_memory_index", "Show a typed local memory index for the cur
19540
20722
  }).join(`
19541
20723
  `) : "- (none)";
19542
20724
  const hotFiles = result.hot_files.length > 0 ? result.hot_files.map((file2) => `- ${file2.path} (${file2.count})`).join(`
20725
+ `) : "- (none)";
20726
+ const provenance = result.provenance_summary.length > 0 ? result.provenance_summary.map((item) => `- ${item.tool}: ${item.count}`).join(`
20727
+ `) : "- (none)";
20728
+ const topTypes = result.top_types.length > 0 ? result.top_types.map((item) => `- ${item.type}: ${item.count}`).join(`
19543
20729
  `) : "- (none)";
19544
20730
  const topTitles = result.top_titles.length > 0 ? result.top_titles.map((item) => `- #${item.id} [${item.type}] ${item.title}`).join(`
19545
20731
  `) : "- (none)";
@@ -19554,15 +20740,25 @@ server.tool("project_memory_index", "Show a typed local memory index for the cur
19554
20740
 
19555
20741
  ` + `Raw chronology: ${result.raw_capture_active ? "active" : "observations-only so far"}
19556
20742
 
20743
+ ` + `Assistant checkpoints: ${result.assistant_checkpoint_count}
20744
+ ` + `Estimated read cost: ~${result.estimated_read_tokens}t
20745
+ ` + `Suggested tools: ${result.suggested_tools.join(", ") || "(none)"}
20746
+
19557
20747
  ` + `Observation counts:
19558
20748
  ${counts}
19559
20749
 
20750
+ ` + `Top types:
20751
+ ${topTypes}
20752
+
19560
20753
  ` + `Recent sessions:
19561
20754
  ${sessions}
19562
20755
 
19563
20756
  ` + `Hot files:
19564
20757
  ${hotFiles}
19565
20758
 
20759
+ ` + `Observation provenance:
20760
+ ${provenance}
20761
+
19566
20762
  ` + `Recent memory objects:
19567
20763
  ${topTitles}`
19568
20764
  }
@@ -19633,17 +20829,22 @@ server.tool("workspace_memory_index", "Show a cross-project local memory index f
19633
20829
  });
19634
20830
  const projectLines = result.projects.length > 0 ? result.projects.map((project) => {
19635
20831
  const when = new Date(project.last_active_epoch * 1000).toISOString().split("T")[0];
19636
- return `- ${project.name} (${when}) obs=${project.observation_count} sessions=${project.session_count} prompts=${project.prompt_count} tools=${project.tool_event_count}`;
20832
+ return `- ${project.name} (${when}) obs=${project.observation_count} sessions=${project.session_count} prompts=${project.prompt_count} tools=${project.tool_event_count} checkpoints=${project.assistant_checkpoint_count}`;
19637
20833
  }).join(`
20834
+ `) : "- (none)";
20835
+ const provenanceLines = result.provenance_summary.length > 0 ? result.provenance_summary.map((item) => `- ${item.tool}: ${item.count}`).join(`
19638
20836
  `) : "- (none)";
19639
20837
  return {
19640
20838
  content: [
19641
20839
  {
19642
20840
  type: "text",
19643
- text: `Workspace totals: observations=${result.totals.observations}, sessions=${result.totals.sessions}, prompts=${result.totals.prompts}, tools=${result.totals.tool_events}
20841
+ text: `Workspace totals: observations=${result.totals.observations}, sessions=${result.totals.sessions}, prompts=${result.totals.prompts}, tools=${result.totals.tool_events}, checkpoints=${result.totals.assistant_checkpoints}
19644
20842
 
19645
20843
  ` + `Projects with raw chronology: ${result.projects_with_raw_capture}
19646
20844
 
20845
+ ` + `Observation provenance:
20846
+ ${provenanceLines}
20847
+
19647
20848
  ` + `Projects:
19648
20849
  ${projectLines}`
19649
20850
  }
@@ -19751,10 +20952,19 @@ server.tool("session_story", "Show the full local memory story for one session",
19751
20952
  return `- ${tool.tool_name}${detail ? ` — ${detail}` : ""}`;
19752
20953
  }).join(`
19753
20954
  `) : "- (none)";
19754
- const observationLines = result.observations.length > 0 ? result.observations.slice(-15).map((obs) => `- #${obs.id} [${obs.type}] ${obs.title}`).join(`
20955
+ const observationLines = result.observations.length > 0 ? result.observations.slice(-15).map((obs) => {
20956
+ const provenance = [];
20957
+ if (obs.source_tool)
20958
+ provenance.push(`via ${obs.source_tool}`);
20959
+ if (typeof obs.source_prompt_number === "number")
20960
+ provenance.push(`#${obs.source_prompt_number}`);
20961
+ return `- #${obs.id} [${obs.type}] ${obs.title}${provenance.length ? ` (${provenance.join(" · ")})` : ""}`;
20962
+ }).join(`
19755
20963
  `) : "- (none)";
19756
20964
  const metrics = result.metrics ? `files=${result.metrics.files_touched_count}, searches=${result.metrics.searches_performed}, tools=${result.metrics.tool_calls_count}, observations=${result.metrics.observation_count}` : "metrics unavailable";
19757
20965
  const captureGaps = result.capture_gaps.length > 0 ? result.capture_gaps.map((gap) => `- ${gap}`).join(`
20966
+ `) : "- none";
20967
+ const provenanceSummary = result.provenance_summary.length > 0 ? result.provenance_summary.map((item) => `- ${item.tool}: ${item.count}`).join(`
19758
20968
  `) : "- none";
19759
20969
  return {
19760
20970
  content: [
@@ -19774,6 +20984,9 @@ ${promptLines}
19774
20984
  ` + `Tools:
19775
20985
  ${toolLines}
19776
20986
 
20987
+ ` + `Provenance:
20988
+ ${provenanceSummary}
20989
+
19777
20990
  ` + `Capture gaps:
19778
20991
  ${captureGaps}
19779
20992