engrm 0.4.12 → 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) {
@@ -14373,8 +14467,9 @@ class MemDatabase {
14373
14467
  const result = this.db.query(`INSERT INTO observations (
14374
14468
  session_id, project_id, type, title, narrative, facts, concepts,
14375
14469
  files_read, files_modified, quality, lifecycle, sensitivity,
14376
- user_id, device_id, agent, created_at, created_at_epoch
14377
- ) 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);
14378
14473
  const id = Number(result.lastInsertRowid);
14379
14474
  const row = this.getObservationById(id);
14380
14475
  this.ftsInsert(row);
@@ -14615,6 +14710,13 @@ class MemDatabase {
14615
14710
  ORDER BY prompt_number ASC
14616
14711
  LIMIT ?`).all(sessionId, limit);
14617
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
+ }
14618
14720
  insertToolEvent(input) {
14619
14721
  const createdAt = input.created_at_epoch ?? Math.floor(Date.now() / 1000);
14620
14722
  const result = this.db.query(`INSERT INTO tool_events (
@@ -14724,8 +14826,15 @@ class MemDatabase {
14724
14826
  }
14725
14827
  insertSessionSummary(summary) {
14726
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
+ };
14727
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)
14728
- 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);
14729
14838
  const id = Number(result.lastInsertRowid);
14730
14839
  return this.db.query("SELECT * FROM session_summaries WHERE id = ?").get(id);
14731
14840
  }
@@ -15625,6 +15734,7 @@ async function saveObservation(db, config2, input) {
15625
15734
  reason: `Merged into existing observation #${duplicate.id}`
15626
15735
  };
15627
15736
  }
15737
+ const sourcePromptNumber = input.source_prompt_number ?? (input.session_id ? db.getLatestSessionPromptNumber(input.session_id) : null);
15628
15738
  const obs = db.insertObservation({
15629
15739
  session_id: input.session_id ?? null,
15630
15740
  project_id: project.id,
@@ -15640,7 +15750,9 @@ async function saveObservation(db, config2, input) {
15640
15750
  sensitivity,
15641
15751
  user_id: config2.user_id,
15642
15752
  device_id: config2.device_id,
15643
- agent: input.agent ?? "claude-code"
15753
+ agent: input.agent ?? "claude-code",
15754
+ source_tool: input.source_tool ?? null,
15755
+ source_prompt_number: sourcePromptNumber
15644
15756
  });
15645
15757
  db.addToOutbox("observation", obs.id);
15646
15758
  if (db.vecAvailable) {
@@ -16008,8 +16120,11 @@ function getSessionStory(db, input) {
16008
16120
  const toolEvents = db.getSessionToolEvents(input.session_id, 100);
16009
16121
  const observations = db.getObservationsBySession(input.session_id);
16010
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;
16011
16125
  return {
16012
16126
  session,
16127
+ project_name: projectName,
16013
16128
  summary,
16014
16129
  prompts,
16015
16130
  tool_events: toolEvents,
@@ -16026,7 +16141,11 @@ function getSessionStory(db, input) {
16026
16141
  toolCallsCount: metrics?.tool_calls_count ?? 0,
16027
16142
  observationCount: observations.length,
16028
16143
  hasSummary: Boolean(summary?.request || summary?.completed)
16029
- })
16144
+ }),
16145
+ latest_request: latestRequest,
16146
+ recent_outcomes: collectRecentOutcomes(observations),
16147
+ hot_files: collectHotFiles(observations),
16148
+ provenance_summary: collectProvenanceSummary(observations)
16030
16149
  };
16031
16150
  }
16032
16151
  function classifyCaptureState(input) {
@@ -16052,6 +16171,56 @@ function buildCaptureGaps(input) {
16052
16171
  }
16053
16172
  return gaps;
16054
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
+ }
16055
16224
 
16056
16225
  // src/tools/recent-sessions.ts
16057
16226
  function getRecentSessions(db, input) {
@@ -16089,1292 +16258,1641 @@ function classifyCaptureState2(session) {
16089
16258
  return "legacy";
16090
16259
  }
16091
16260
 
16092
- // src/tools/project-memory-index.ts
16093
- function getProjectMemoryIndex(db, input) {
16094
- const cwd = input.cwd ?? process.cwd();
16095
- const detected = detectProject(cwd);
16096
- const project = db.getProjectByCanonicalId(detected.canonical_id);
16097
- if (!project)
16098
- return null;
16099
- const visibilityClause = input.user_id ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
16100
- const visibilityParams = input.user_id ? [input.user_id] : [];
16101
- const observations = db.db.query(`SELECT * FROM observations
16102
- WHERE project_id = ?
16103
- AND lifecycle IN ('active', 'aging', 'pinned')
16104
- AND superseded_by IS NULL
16105
- ${visibilityClause}
16106
- ORDER BY created_at_epoch DESC`).all(project.id, ...visibilityParams);
16107
- const counts = {};
16108
- for (const obs of observations) {
16109
- counts[obs.type] = (counts[obs.type] ?? 0) + 1;
16110
- }
16111
- const fileCounts = new Map;
16112
- for (const obs of observations) {
16113
- for (const path of extractPaths(obs.files_modified)) {
16114
- 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
+ });
16115
16355
  }
16116
16356
  }
16117
- 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);
16118
- const topTitles = observations.slice(0, 12).map((obs) => ({
16119
- type: obs.type,
16120
- title: obs.title,
16121
- id: obs.id
16122
- }));
16123
- const recentSessions = getRecentSessions(db, {
16124
- cwd,
16125
- project_scoped: true,
16126
- user_id: input.user_id,
16127
- limit: 6
16128
- }).sessions;
16129
- const recentRequestsCount = getRecentRequests(db, {
16130
- cwd,
16131
- project_scoped: true,
16132
- user_id: input.user_id,
16133
- limit: 20
16134
- }).prompts.length;
16135
- const recentToolsCount = getRecentTools(db, {
16136
- cwd,
16137
- project_scoped: true,
16138
- user_id: input.user_id,
16139
- limit: 20
16140
- }).tool_events.length;
16141
- 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);
16142
- return {
16143
- project: project.name,
16144
- canonical_id: project.canonical_id,
16145
- observation_counts: counts,
16146
- recent_sessions: recentSessions,
16147
- recent_outcomes: recentOutcomes,
16148
- recent_requests_count: recentRequestsCount,
16149
- recent_tools_count: recentToolsCount,
16150
- raw_capture_active: recentRequestsCount > 0 || recentToolsCount > 0,
16151
- hot_files: hotFiles,
16152
- top_titles: topTitles
16153
- };
16357
+ stale.sort((a, b) => b.days_ago - a.days_ago);
16358
+ return stale.slice(0, 5);
16154
16359
  }
16155
- function extractPaths(value) {
16156
- if (!value)
16157
- return [];
16158
- try {
16159
- const parsed = JSON.parse(value);
16160
- if (!Array.isArray(parsed))
16161
- return [];
16162
- return parsed.filter((item) => typeof item === "string" && item.trim().length > 0);
16163
- } 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)
16164
16373
  return [];
16165
- }
16166
- }
16167
- function looksLikeFileOperationTitle(value) {
16168
- return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
16169
- }
16170
-
16171
- // src/tools/memory-console.ts
16172
- function getMemoryConsole(db, input) {
16173
- const cwd = input.cwd ?? process.cwd();
16174
- const projectScoped = input.project_scoped !== false;
16175
- const detected = projectScoped ? detectProject(cwd) : null;
16176
- const project = detected ? db.getProjectByCanonicalId(detected.canonical_id) : null;
16177
- const sessions = getRecentSessions(db, {
16178
- cwd,
16179
- project_scoped: projectScoped,
16180
- user_id: input.user_id,
16181
- limit: 6
16182
- }).sessions;
16183
- const requests = getRecentRequests(db, {
16184
- cwd,
16185
- project_scoped: projectScoped,
16186
- user_id: input.user_id,
16187
- limit: 6
16188
- }).prompts;
16189
- const tools = getRecentTools(db, {
16190
- cwd,
16191
- project_scoped: projectScoped,
16192
- user_id: input.user_id,
16193
- limit: 8
16194
- }).tool_events;
16195
- const observations = getRecentActivity(db, {
16196
- cwd,
16197
- project_scoped: projectScoped,
16198
- user_id: input.user_id,
16199
- limit: 8
16200
- }).observations;
16201
- const projectIndex = projectScoped ? getProjectMemoryIndex(db, {
16202
- cwd,
16203
- user_id: input.user_id
16204
- }) : null;
16205
- return {
16206
- project: project?.name,
16207
- capture_mode: requests.length > 0 || tools.length > 0 ? "rich" : "observations-only",
16208
- sessions,
16209
- requests,
16210
- tools,
16211
- observations,
16212
- recent_outcomes: projectIndex?.recent_outcomes ?? [],
16213
- hot_files: projectIndex?.hot_files ?? []
16214
- };
16215
- }
16216
-
16217
- // src/tools/workspace-memory-index.ts
16218
- function getWorkspaceMemoryIndex(db, input) {
16219
- const limit = Math.max(1, Math.min(input.limit ?? 12, 50));
16220
- const visibilityClause = input.user_id ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
16221
- const params = input.user_id ? [input.user_id, limit] : [limit];
16222
- const projects = db.db.query(`SELECT
16223
- p.canonical_id,
16224
- p.name,
16225
- (
16226
- SELECT COUNT(*) FROM observations o
16227
- WHERE o.project_id = p.id
16228
- AND o.lifecycle IN ('active', 'aging', 'pinned')
16229
- AND o.superseded_by IS NULL
16230
- ${visibilityClause}
16231
- ) AS observation_count,
16232
- (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count,
16233
- (SELECT COUNT(*) FROM user_prompts up WHERE up.project_id = p.id) AS prompt_count,
16234
- (SELECT COUNT(*) FROM tool_events te WHERE te.project_id = p.id) AS tool_event_count,
16235
- p.last_active_epoch
16236
- FROM projects p
16237
- ORDER BY p.last_active_epoch DESC
16238
- LIMIT ?`).all(...params);
16239
- const totals = projects.reduce((acc, project) => {
16240
- acc.observations += project.observation_count;
16241
- acc.sessions += project.session_count;
16242
- acc.prompts += project.prompt_count;
16243
- acc.tool_events += project.tool_event_count;
16244
- return acc;
16245
- }, { observations: 0, sessions: 0, prompts: 0, tool_events: 0 });
16246
- return {
16247
- projects,
16248
- totals,
16249
- projects_with_raw_capture: projects.filter((project) => project.prompt_count > 0 || project.tool_event_count > 0).length
16250
- };
16251
- }
16252
-
16253
- // src/tools/project-related-work.ts
16254
- function getProjectRelatedWork(db, input) {
16255
- const cwd = input.cwd ?? process.cwd();
16256
- const detected = detectProject(cwd);
16257
- const limit = Math.max(1, Math.min(input.limit ?? 20, 100));
16258
- const visibilityClause = input.user_id ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
16259
- const visibilityParams = input.user_id ? [input.user_id] : [];
16260
- const terms = buildProjectTerms(detected);
16261
- if (terms.length === 0) {
16262
- return {
16263
- project: detected.name,
16264
- canonical_id: detected.canonical_id,
16265
- related: []
16266
- };
16267
- }
16268
- const localProject = db.getProjectByCanonicalId(detected.canonical_id);
16269
- const localProjectId = localProject?.id ?? -1;
16270
- const params = [];
16271
- const whereParts = [];
16272
- for (const term of terms) {
16273
- const like = `%${term}%`;
16274
- 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 ?)`);
16275
- params.push(like, like, like, like);
16276
- }
16277
- const rows = db.db.query(`SELECT
16278
- o.id,
16279
- o.type,
16280
- o.title,
16281
- o.project_id as source_project_id,
16282
- p.name as source_project,
16283
- o.narrative,
16284
- o.files_read,
16285
- o.files_modified
16286
- FROM observations o
16287
- LEFT JOIN projects p ON p.id = o.project_id
16288
- WHERE o.project_id != ?
16289
- AND o.lifecycle IN ('active', 'aging', 'pinned')
16290
- AND o.superseded_by IS NULL
16291
- ${visibilityClause}
16292
- AND (${whereParts.join(" OR ")})
16293
- ORDER BY o.created_at_epoch DESC
16294
- LIMIT ?`).all(localProjectId, ...visibilityParams, ...params, limit);
16295
- return {
16296
- project: detected.name,
16297
- canonical_id: detected.canonical_id,
16298
- related: rows.map((row) => ({
16299
- id: row.id,
16300
- type: row.type,
16301
- title: row.title,
16302
- source_project: row.source_project ?? "unknown",
16303
- source_project_id: row.source_project_id,
16304
- matched_on: classifyMatch(row, terms)
16305
- }))
16306
- };
16307
- }
16308
- function buildProjectTerms(detected) {
16309
- const explicit = new Set;
16310
- explicit.add(detected.name.toLowerCase());
16311
- const canonicalParts = detected.canonical_id.toLowerCase().split("/");
16312
- for (const part of canonicalParts) {
16313
- if (part.length >= 4)
16314
- explicit.add(part);
16315
- }
16316
- if (detected.name.toLowerCase() === "huginn") {
16317
- explicit.add("aiserver");
16318
- }
16319
- return [...explicit].filter(Boolean);
16320
- }
16321
- function classifyMatch(row, terms) {
16322
- const title = row.title.toLowerCase();
16323
- const narrative = (row.narrative ?? "").toLowerCase();
16324
- const files = `${row.files_read ?? ""} ${row.files_modified ?? ""}`.toLowerCase();
16325
- for (const term of terms) {
16326
- if (files.includes(term))
16327
- return `files:${term}`;
16328
- if (title.includes(term))
16329
- return `title:${term}`;
16330
- if (narrative.includes(term))
16331
- return `narrative:${term}`;
16332
- }
16333
- return "related";
16334
- }
16335
-
16336
- // src/tools/reclassify-project-memory.ts
16337
- function reclassifyProjectMemory(db, input) {
16338
- const cwd = input.cwd ?? process.cwd();
16339
- const detected = detectProject(cwd);
16340
- const target = db.upsertProject({
16341
- canonical_id: detected.canonical_id,
16342
- name: detected.name,
16343
- local_path: detected.local_path,
16344
- remote_url: detected.remote_url
16345
- });
16346
- const related = getProjectRelatedWork(db, {
16347
- cwd,
16348
- user_id: input.user_id,
16349
- limit: input.limit ?? 50
16350
- }).related;
16351
- let moved = 0;
16352
- const candidates = related.map((item) => {
16353
- const eligible = item.matched_on.startsWith("files:") || item.matched_on.startsWith("title:") || item.matched_on.startsWith("narrative:");
16354
- const shouldMove = eligible && item.source_project_id !== target.id;
16355
- if (shouldMove && input.dry_run !== true) {
16356
- const ok = db.reassignObservationProject(item.id, target.id);
16357
- if (ok)
16358
- moved += 1;
16359
- return {
16360
- id: item.id,
16361
- title: item.title,
16362
- type: item.type,
16363
- from: item.source_project,
16364
- matched_on: item.matched_on,
16365
- moved: ok
16366
- };
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
+ }
16367
16414
  }
16368
- return {
16369
- id: item.id,
16370
- title: item.title,
16371
- type: item.type,
16372
- from: item.source_project,
16373
- matched_on: item.matched_on,
16374
- moved: false
16375
- };
16376
- });
16377
- return {
16378
- project: target.name,
16379
- canonical_id: target.canonical_id,
16380
- target_project_id: target.id,
16381
- moved,
16382
- candidates
16383
- };
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);
16384
16432
  }
16385
16433
 
16386
- // src/tools/activity-feed.ts
16387
- function toPromptEvent(prompt) {
16388
- return {
16389
- kind: "prompt",
16390
- created_at_epoch: prompt.created_at_epoch,
16391
- session_id: prompt.session_id,
16392
- id: prompt.id,
16393
- title: `#${prompt.prompt_number} ${prompt.prompt.replace(/\s+/g, " ").trim()}`
16394
- };
16395
- }
16396
- function toToolEvent(tool) {
16397
- const detail = tool.file_path ?? tool.command ?? tool.tool_response_preview ?? undefined;
16398
- return {
16399
- kind: "tool",
16400
- created_at_epoch: tool.created_at_epoch,
16401
- session_id: tool.session_id,
16402
- id: tool.id,
16403
- title: tool.tool_name,
16404
- detail: detail?.replace(/\s+/g, " ").trim()
16405
- };
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)));
16406
16437
  }
16407
- function toObservationEvent(obs) {
16408
- return {
16409
- kind: "observation",
16410
- created_at_epoch: obs.created_at_epoch,
16411
- session_id: obs.session_id,
16412
- id: obs.id,
16413
- title: obs.title,
16414
- observation_type: obs.type
16415
- };
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;
16416
16460
  }
16417
- function toSummaryEvent(summary, fallbackEpoch = 0) {
16418
- const title = summary.request ?? summary.completed ?? summary.learned ?? summary.investigated;
16419
- if (!title)
16420
- return null;
16421
- const detail = [
16422
- summary.completed && summary.completed !== title ? `Completed: ${summary.completed}` : null,
16423
- summary.learned ? `Learned: ${summary.learned}` : null,
16424
- summary.next_steps ? `Next: ${summary.next_steps}` : null
16425
- ].filter(Boolean).join(" | ");
16426
- return {
16427
- kind: "summary",
16428
- created_at_epoch: summary.created_at_epoch ?? fallbackEpoch,
16429
- session_id: summary.session_id,
16430
- id: summary.id,
16431
- title: title.replace(/\s+/g, " ").trim(),
16432
- detail: detail || undefined
16433
- };
16461
+ function estimateTokens(text) {
16462
+ if (!text)
16463
+ return 0;
16464
+ return Math.ceil(text.length / 4);
16434
16465
  }
16435
- function compareEvents(a, b) {
16436
- if (b.created_at_epoch !== a.created_at_epoch) {
16437
- return b.created_at_epoch - a.created_at_epoch;
16438
- }
16439
- const kindOrder = {
16440
- summary: 0,
16441
- observation: 1,
16442
- tool: 2,
16443
- prompt: 3
16444
- };
16445
- if (kindOrder[a.kind] !== kindOrder[b.kind]) {
16446
- 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);
16447
16509
  }
16448
- return (b.id ?? 0) - (a.id ?? 0);
16449
- }
16450
- function getActivityFeed(db, input) {
16451
- const limit = Math.max(1, Math.min(input.limit ?? 30, 100));
16452
- if (input.session_id) {
16453
- const story = getSessionStory(db, { session_id: input.session_id });
16454
- const project = story.session?.project_id !== null && story.session?.project_id !== undefined ? db.getProjectById(story.session.project_id)?.name : undefined;
16455
- const events2 = [
16456
- ...story.summary ? [toSummaryEvent(story.summary, story.session?.completed_at_epoch ?? story.session?.started_at_epoch ?? 0)].filter((event) => event !== null) : [],
16457
- ...story.prompts.map(toPromptEvent),
16458
- ...story.tool_events.map(toToolEvent),
16459
- ...story.observations.map(toObservationEvent)
16460
- ].sort(compareEvents).slice(0, limit);
16461
- 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
+ }
16462
16539
  }
16463
- const projectScoped = input.project_scoped !== false;
16464
- let projectName;
16465
- if (projectScoped) {
16466
- const cwd = input.cwd ?? process.cwd();
16467
- const detected = detectProject(cwd);
16468
- const project = db.getProjectByCanonicalId(detected.canonical_id);
16469
- if (project) {
16470
- 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);
16471
16552
  }
16472
16553
  }
16473
- const prompts = getRecentRequests(db, { ...input, limit }).prompts;
16474
- const tools = getRecentTools(db, { ...input, limit }).tool_events;
16475
- const observations = getRecentActivity(db, {
16476
- limit,
16477
- project_scoped: input.project_scoped,
16478
- cwd: input.cwd,
16479
- user_id: input.user_id
16480
- }).observations;
16481
- const sessions = getRecentSessions(db, {
16482
- limit,
16483
- project_scoped: input.project_scoped,
16484
- cwd: input.cwd,
16485
- user_id: input.user_id
16486
- }).sessions;
16487
- const summaryEvents = sessions.map((session) => {
16488
- const summary = db.getSessionSummary(session.session_id);
16489
- if (!summary)
16490
- return null;
16491
- return toSummaryEvent(summary, session.completed_at_epoch ?? session.started_at_epoch ?? 0);
16492
- }).filter((event) => event !== null);
16493
- const events = [
16494
- ...summaryEvents,
16495
- ...prompts.map(toPromptEvent),
16496
- ...tools.map(toToolEvent),
16497
- ...observations.map(toObservationEvent)
16498
- ].sort(compareEvents).slice(0, limit);
16499
- return {
16500
- events,
16501
- project: projectName
16502
- };
16503
- }
16504
-
16505
- // src/tools/capture-status.ts
16506
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
16507
- import { homedir as homedir2 } from "node:os";
16508
- import { join as join3 } from "node:path";
16509
- var LEGACY_CODEX_SERVER_NAME = `candengo-${"mem"}`;
16510
- function getCaptureStatus(db, input = {}) {
16511
- const hours = Math.max(1, Math.min(input.lookback_hours ?? 24, 24 * 30));
16512
- const sinceEpoch = Math.floor(Date.now() / 1000) - hours * 3600;
16513
- const home = input.home_dir ?? homedir2();
16514
- const claudeJson = join3(home, ".claude.json");
16515
- const claudeSettings = join3(home, ".claude", "settings.json");
16516
- const codexConfig = join3(home, ".codex", "config.toml");
16517
- const codexHooks = join3(home, ".codex", "hooks.json");
16518
- const claudeJsonContent = existsSync3(claudeJson) ? readFileSync3(claudeJson, "utf-8") : "";
16519
- const claudeSettingsContent = existsSync3(claudeSettings) ? readFileSync3(claudeSettings, "utf-8") : "";
16520
- const codexConfigContent = existsSync3(codexConfig) ? readFileSync3(codexConfig, "utf-8") : "";
16521
- const codexHooksContent = existsSync3(codexHooks) ? readFileSync3(codexHooks, "utf-8") : "";
16522
- const claudeMcpRegistered = claudeJsonContent.includes('"engrm"');
16523
- const claudeHooksRegistered = claudeSettingsContent.includes("engrm") || claudeSettingsContent.includes("session-start") || claudeSettingsContent.includes("user-prompt-submit");
16524
- const codexMcpRegistered = codexConfigContent.includes("[mcp_servers.engrm]") || codexConfigContent.includes(`[mcp_servers.${LEGACY_CODEX_SERVER_NAME}]`);
16525
- const codexHooksRegistered = codexHooksContent.includes('"SessionStart"') && codexHooksContent.includes('"Stop"');
16526
- let claudeHookCount = 0;
16527
- let claudeSessionStartHook = false;
16528
- let claudeUserPromptHook = false;
16529
- let claudePostToolHook = false;
16530
- let claudeStopHook = false;
16531
- 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) {
16532
16610
  try {
16533
- const settings = JSON.parse(claudeSettingsContent);
16534
- const hooks = settings?.hooks ?? {};
16535
- claudeSessionStartHook = Array.isArray(hooks["SessionStart"]);
16536
- claudeUserPromptHook = Array.isArray(hooks["UserPromptSubmit"]);
16537
- claudePostToolHook = Array.isArray(hooks["PostToolUse"]);
16538
- claudeStopHook = Array.isArray(hooks["Stop"]);
16539
- for (const entries of Object.values(hooks)) {
16540
- if (!Array.isArray(entries))
16541
- continue;
16542
- for (const entry of entries) {
16543
- const e = entry;
16544
- 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"))) {
16545
- claudeHookCount++;
16546
- }
16547
- }
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
+ });
16548
16643
  }
16549
16644
  } catch {}
16550
16645
  }
16551
- let codexSessionStartHook = false;
16552
- let codexStopHook = false;
16646
+ let staleDecisions;
16553
16647
  try {
16554
- const hooks = codexHooksContent ? JSON.parse(codexHooksContent)?.hooks ?? {} : {};
16555
- codexSessionStartHook = Array.isArray(hooks["SessionStart"]);
16556
- codexStopHook = Array.isArray(hooks["Stop"]);
16648
+ const stale = isNewProject ? findStaleDecisionsGlobal(db) : findStaleDecisions(db, projectId);
16649
+ if (stale.length > 0)
16650
+ staleDecisions = stale;
16557
16651
  } catch {}
16558
- const visibilityClause = input.user_id ? " AND user_id = ?" : "";
16559
- const params = input.user_id ? [sinceEpoch, input.user_id] : [sinceEpoch];
16560
- const recentUserPrompts = db.db.query(`SELECT COUNT(*) as count FROM user_prompts
16561
- WHERE created_at_epoch >= ?${visibilityClause}`).get(...params)?.count ?? 0;
16562
- const recentToolEvents = db.db.query(`SELECT COUNT(*) as count FROM tool_events
16563
- WHERE created_at_epoch >= ?${visibilityClause}`).get(...params)?.count ?? 0;
16564
- const recentSessionsWithRawCapture = db.db.query(`SELECT COUNT(*) as count
16565
- FROM sessions s
16566
- WHERE COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) >= ?
16567
- ${input.user_id ? "AND s.user_id = ?" : ""}
16568
- AND (
16569
- EXISTS (SELECT 1 FROM user_prompts up WHERE up.session_id = s.session_id)
16570
- OR EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id)
16571
- )`).get(...params)?.count ?? 0;
16572
- const recentSessionsWithPartialCapture = db.db.query(`SELECT COUNT(*) as count
16573
- FROM sessions s
16574
- WHERE COALESCE(s.completed_at_epoch, s.started_at_epoch, 0) >= ?
16575
- ${input.user_id ? "AND s.user_id = ?" : ""}
16576
- AND (
16577
- (s.tool_calls_count > 0 AND NOT EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id))
16578
- OR (
16579
- EXISTS (SELECT 1 FROM user_prompts up WHERE up.session_id = s.session_id)
16580
- AND NOT EXISTS (SELECT 1 FROM tool_events te WHERE te.session_id = s.session_id)
16581
- )
16582
- )`).get(...params)?.count ?? 0;
16583
- const latestPromptEpoch = db.db.query(`SELECT created_at_epoch FROM user_prompts
16584
- WHERE 1 = 1${input.user_id ? " AND user_id = ?" : ""}
16585
- ORDER BY created_at_epoch DESC, prompt_number DESC
16586
- LIMIT 1`).get(...input.user_id ? [input.user_id] : [])?.created_at_epoch ?? null;
16587
- const latestToolEventEpoch = db.db.query(`SELECT created_at_epoch FROM tool_events
16588
- WHERE 1 = 1${input.user_id ? " AND user_id = ?" : ""}
16589
- ORDER BY created_at_epoch DESC, id DESC
16590
- LIMIT 1`).get(...input.user_id ? [input.user_id] : [])?.created_at_epoch ?? null;
16591
- const latestPostToolHookEpoch = parseNullableInt(db.getSyncState("hook_post_tool_last_seen_epoch"));
16592
- const latestPostToolParseStatus = db.getSyncState("hook_post_tool_last_parse_status");
16593
- const latestPostToolName = db.getSyncState("hook_post_tool_last_tool_name");
16594
- const schemaVersion = getSchemaVersion(db.db);
16595
16652
  return {
16596
- schema_version: schemaVersion,
16597
- schema_current: schemaVersion >= LATEST_SCHEMA_VERSION,
16598
- claude_mcp_registered: claudeMcpRegistered,
16599
- claude_hooks_registered: claudeHooksRegistered,
16600
- claude_hook_count: claudeHookCount,
16601
- claude_session_start_hook: claudeSessionStartHook,
16602
- claude_user_prompt_hook: claudeUserPromptHook,
16603
- claude_post_tool_hook: claudePostToolHook,
16604
- claude_stop_hook: claudeStopHook,
16605
- codex_mcp_registered: codexMcpRegistered,
16606
- codex_hooks_registered: codexHooksRegistered,
16607
- codex_session_start_hook: codexSessionStartHook,
16608
- codex_stop_hook: codexStopHook,
16609
- codex_raw_chronology_supported: false,
16610
- recent_user_prompts: recentUserPrompts,
16611
- recent_tool_events: recentToolEvents,
16612
- recent_sessions_with_raw_capture: recentSessionsWithRawCapture,
16613
- recent_sessions_with_partial_capture: recentSessionsWithPartialCapture,
16614
- latest_prompt_epoch: latestPromptEpoch,
16615
- latest_tool_event_epoch: latestToolEventEpoch,
16616
- latest_post_tool_hook_epoch: latestPostToolHookEpoch,
16617
- latest_post_tool_parse_status: latestPostToolParseStatus,
16618
- latest_post_tool_name: latestPostToolName,
16619
- 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
16620
16667
  };
16621
16668
  }
16622
- function parseNullableInt(value) {
16623
- if (!value)
16624
- return null;
16625
- const parsed = Number.parseInt(value, 10);
16626
- return Number.isFinite(parsed) ? parsed : null;
16627
- }
16628
-
16629
- // src/intelligence/followthrough.ts
16630
- var FOLLOW_THROUGH_THRESHOLD = 0.25;
16631
- var STALE_AFTER_DAYS = 3;
16632
- var DECISION_WINDOW_DAYS = 30;
16633
- var IMPLEMENTATION_TYPES = new Set([
16634
- "feature",
16635
- "bugfix",
16636
- "change",
16637
- "refactor"
16638
- ]);
16639
- function findStaleDecisions(db, projectId, options) {
16640
- const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
16641
- const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
16642
- const nowEpoch = Math.floor(Date.now() / 1000);
16643
- const windowStart = nowEpoch - windowDays * 86400;
16644
- const staleThreshold = nowEpoch - staleAfterDays * 86400;
16645
- const decisions = db.db.query(`SELECT * FROM observations
16646
- WHERE project_id = ? AND type = 'decision'
16647
- AND lifecycle IN ('active', 'aging', 'pinned')
16648
- AND superseded_by IS NULL
16649
- AND created_at_epoch >= ?
16650
- ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
16651
- if (decisions.length === 0)
16652
- return [];
16653
- const implementations = db.db.query(`SELECT * FROM observations
16654
- WHERE project_id = ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
16655
- AND lifecycle IN ('active', 'aging', 'pinned')
16656
- AND superseded_by IS NULL
16657
- AND created_at_epoch >= ?
16658
- ORDER BY created_at_epoch DESC`).all(projectId, windowStart);
16659
- const crossProjectImpls = db.db.query(`SELECT * FROM observations
16660
- WHERE project_id != ? AND type IN ('feature', 'bugfix', 'change', 'refactor')
16661
- AND lifecycle IN ('active', 'aging', 'pinned')
16662
- AND superseded_by IS NULL
16663
- AND created_at_epoch >= ?
16664
- ORDER BY created_at_epoch DESC
16665
- LIMIT 200`).all(projectId, windowStart);
16666
- const allImpls = [...implementations, ...crossProjectImpls];
16667
- const stale = [];
16668
- for (const decision of decisions) {
16669
- if (decision.created_at_epoch > staleThreshold)
16670
- continue;
16671
- const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
16672
- let decisionConcepts = [];
16673
- try {
16674
- const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
16675
- if (Array.isArray(parsed))
16676
- decisionConcepts = parsed;
16677
- } catch {}
16678
- let bestTitle = "";
16679
- let bestScore = 0;
16680
- for (const impl of allImpls) {
16681
- if (impl.created_at_epoch <= decision.created_at_epoch)
16682
- continue;
16683
- const titleScore = jaccardSimilarity(decision.title, impl.title);
16684
- let conceptBoost = 0;
16685
- if (decisionConcepts.length > 0) {
16686
- try {
16687
- const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
16688
- if (Array.isArray(implConcepts) && implConcepts.length > 0) {
16689
- const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
16690
- const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
16691
- conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
16692
- }
16693
- } 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)}`);
16694
16709
  }
16695
- let narrativeBoost = 0;
16696
- if (impl.narrative) {
16697
- const decWords = new Set(decision.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3));
16698
- if (decWords.size > 0) {
16699
- const implNarrativeLower = impl.narrative.toLowerCase();
16700
- const hits = [...decWords].filter((w) => implNarrativeLower.includes(w)).length;
16701
- narrativeBoost = hits / decWords.size * 0.1;
16702
- }
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);
16703
16758
  }
16704
- const totalScore = titleScore + conceptBoost + narrativeBoost;
16705
- if (totalScore > bestScore) {
16706
- bestScore = totalScore;
16707
- 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)`);
16708
16786
  }
16709
16787
  }
16710
- if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
16711
- stale.push({
16712
- id: decision.id,
16713
- title: decision.title,
16714
- narrative: decision.narrative,
16715
- concepts: decisionConcepts,
16716
- created_at: decision.created_at,
16717
- days_ago: daysAgo,
16718
- ...bestScore > 0.1 ? {
16719
- best_match_title: bestTitle,
16720
- best_match_similarity: Math.round(bestScore * 100) / 100
16721
- } : {}
16722
- });
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);
16723
16812
  }
16724
16813
  }
16725
- stale.sort((a, b) => b.days_ago - a.days_ago);
16726
- return stale.slice(0, 5);
16814
+ return lines;
16727
16815
  }
16728
- function findStaleDecisionsGlobal(db, options) {
16729
- const staleAfterDays = options?.staleAfterDays ?? STALE_AFTER_DAYS;
16730
- const windowDays = options?.windowDays ?? DECISION_WINDOW_DAYS;
16731
- const nowEpoch = Math.floor(Date.now() / 1000);
16732
- const windowStart = nowEpoch - windowDays * 86400;
16733
- const staleThreshold = nowEpoch - staleAfterDays * 86400;
16734
- const decisions = db.db.query(`SELECT * FROM observations
16735
- WHERE type = 'decision'
16736
- AND lifecycle IN ('active', 'aging', 'pinned')
16737
- AND superseded_by IS NULL
16738
- AND created_at_epoch >= ?
16739
- ORDER BY created_at_epoch DESC`).all(windowStart);
16740
- 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)
16741
16848
  return [];
16742
- const implementations = db.db.query(`SELECT * FROM observations
16743
- WHERE type IN ('feature', 'bugfix', 'change', 'refactor')
16744
- AND lifecycle IN ('active', 'aging', 'pinned')
16745
- AND superseded_by IS NULL
16746
- AND created_at_epoch >= ?
16747
- ORDER BY created_at_epoch DESC
16748
- LIMIT 500`).all(windowStart);
16749
- const stale = [];
16750
- for (const decision of decisions) {
16751
- if (decision.created_at_epoch > staleThreshold)
16752
- continue;
16753
- const daysAgo = Math.floor((nowEpoch - decision.created_at_epoch) / 86400);
16754
- let decisionConcepts = [];
16755
- try {
16756
- const parsed = decision.concepts ? JSON.parse(decision.concepts) : [];
16757
- if (Array.isArray(parsed))
16758
- decisionConcepts = parsed;
16759
- } catch {}
16760
- let bestScore = 0;
16761
- let bestTitle = "";
16762
- for (const impl of implementations) {
16763
- if (impl.created_at_epoch <= decision.created_at_epoch)
16764
- continue;
16765
- const titleScore = jaccardSimilarity(decision.title, impl.title);
16766
- let conceptBoost = 0;
16767
- if (decisionConcepts.length > 0) {
16768
- try {
16769
- const implConcepts = impl.concepts ? JSON.parse(impl.concepts) : [];
16770
- if (Array.isArray(implConcepts) && implConcepts.length > 0) {
16771
- const decSet = new Set(decisionConcepts.map((c) => c.toLowerCase()));
16772
- const overlap = implConcepts.filter((c) => decSet.has(c.toLowerCase())).length;
16773
- conceptBoost = overlap / Math.max(decisionConcepts.length, 1) * 0.15;
16774
- }
16775
- } catch {}
16776
- }
16777
- const totalScore = titleScore + conceptBoost;
16778
- if (totalScore > bestScore) {
16779
- bestScore = totalScore;
16780
- bestTitle = impl.title;
16781
- }
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
+ `);
16782
16871
  }
16783
- if (bestScore < FOLLOW_THROUGH_THRESHOLD) {
16784
- stale.push({
16785
- id: decision.id,
16786
- title: decision.title,
16787
- narrative: decision.narrative,
16788
- concepts: decisionConcepts,
16789
- created_at: decision.created_at,
16790
- days_ago: daysAgo,
16791
- ...bestScore > 0.1 ? {
16792
- best_match_title: bestTitle,
16793
- best_match_similarity: Math.round(bestScore * 100) / 100
16794
- } : {}
16795
- });
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()];
16796
16890
  }
16797
16891
  }
16798
- stale.sort((a, b) => b.days_ago - a.days_ago);
16799
- return stale.slice(0, 5);
16892
+ return [];
16800
16893
  }
16801
-
16802
- // src/context/inject.ts
16803
- function tokenizeProjectHint(text) {
16804
- 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
+ };
16805
16907
  }
16806
- function isObservationRelatedToProject(obs, detected) {
16807
- const hints = new Set([
16808
- ...tokenizeProjectHint(detected.name),
16809
- ...tokenizeProjectHint(detected.canonical_id)
16810
- ]);
16811
- if (hints.size === 0)
16812
- return false;
16813
- const haystack = [
16814
- obs.title,
16815
- obs.narrative ?? "",
16816
- obs.facts ?? "",
16817
- obs.concepts ?? "",
16818
- obs.files_read ?? "",
16819
- obs.files_modified ?? "",
16820
- obs._source_project ?? ""
16821
- ].join(`
16822
- `).toLowerCase();
16823
- for (const hint of hints) {
16824
- if (haystack.includes(hint))
16825
- 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)}`;
16826
16912
  }
16827
- 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 "";
16828
16918
  }
16829
- function estimateTokens(text) {
16830
- if (!text)
16831
- return 0;
16832
- 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
+ }
16833
16930
  }
16834
- function buildSessionContext(db, cwd, options = {}) {
16835
- const opts = typeof options === "number" ? { maxCount: options } : options;
16836
- const tokenBudget = opts.tokenBudget ?? 3000;
16837
- const maxCount = opts.maxCount;
16838
- const visibilityClause = opts.userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
16839
- const visibilityParams = opts.userId ? [opts.userId] : [];
16840
- const detected = detectProject(cwd);
16841
- const project = db.getProjectByCanonicalId(detected.canonical_id);
16842
- const projectId = project?.id ?? -1;
16843
- const isNewProject = !project;
16844
- const totalActive = isNewProject ? (db.db.query(`SELECT COUNT(*) as c FROM observations
16845
- WHERE lifecycle IN ('active', 'aging', 'pinned')
16846
- ${visibilityClause}
16847
- AND superseded_by IS NULL`).get(...visibilityParams) ?? { c: 0 }).c : (db.db.query(`SELECT COUNT(*) as c FROM observations
16848
- WHERE project_id = ? AND lifecycle IN ('active', 'aging', 'pinned')
16849
- ${visibilityClause}
16850
- AND superseded_by IS NULL`).get(projectId, ...visibilityParams) ?? { c: 0 }).c;
16851
- const candidateLimit = maxCount ?? 50;
16852
- let pinned = [];
16853
- let recent = [];
16854
- let candidates = [];
16855
- if (!isNewProject) {
16856
- const MAX_PINNED = 5;
16857
- pinned = db.db.query(`SELECT * FROM observations
16858
- WHERE project_id = ? AND lifecycle = 'pinned'
16859
- AND superseded_by IS NULL
16860
- ${visibilityClause}
16861
- ORDER BY quality DESC, created_at_epoch DESC
16862
- LIMIT ?`).all(projectId, ...visibilityParams, MAX_PINNED);
16863
- const MAX_RECENT = 5;
16864
- recent = db.db.query(`SELECT * FROM observations
16865
- WHERE project_id = ? AND lifecycle IN ('active', 'aging')
16866
- AND superseded_by IS NULL
16867
- ${visibilityClause}
16868
- ORDER BY created_at_epoch DESC
16869
- LIMIT ?`).all(projectId, ...visibilityParams, MAX_RECENT);
16870
- candidates = db.db.query(`SELECT * FROM observations
16871
- WHERE project_id = ? AND lifecycle IN ('active', 'aging')
16872
- 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')
16873
16941
  AND superseded_by IS NULL
16874
16942
  ${visibilityClause}
16875
- ORDER BY quality DESC, created_at_epoch DESC
16876
- 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;
16877
16947
  }
16878
- let crossProjectCandidates = [];
16879
- if (opts.scope === "all" || isNewProject) {
16880
- const crossLimit = isNewProject ? Math.max(30, candidateLimit) : Math.max(10, Math.floor(candidateLimit / 3));
16881
- const qualityThreshold = isNewProject ? 0.3 : 0.5;
16882
- const rawCross = isNewProject ? db.db.query(`SELECT * FROM observations
16883
- WHERE lifecycle IN ('active', 'aging', 'pinned')
16884
- AND quality >= ?
16885
- AND superseded_by IS NULL
16886
- ${visibilityClause}
16887
- ORDER BY quality DESC, created_at_epoch DESC
16888
- LIMIT ?`).all(qualityThreshold, ...visibilityParams, crossLimit) : db.db.query(`SELECT * FROM observations
16889
- WHERE project_id != ? AND lifecycle IN ('active', 'aging')
16890
- AND quality >= ?
16891
- AND superseded_by IS NULL
16892
- ${visibilityClause}
16893
- ORDER BY quality DESC, created_at_epoch DESC
16894
- LIMIT ?`).all(projectId, qualityThreshold, ...visibilityParams, crossLimit);
16895
- const projectNameCache = new Map;
16896
- crossProjectCandidates = rawCross.map((obs) => {
16897
- if (!projectNameCache.has(obs.project_id)) {
16898
- const proj = db.getProjectById(obs.project_id);
16899
- if (proj)
16900
- projectNameCache.set(obs.project_id, proj.name);
16901
- }
16902
- return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
16903
- });
16904
- if (isNewProject) {
16905
- 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;
16906
16971
  }
16907
16972
  }
16908
- const seenIds = new Set(pinned.map((o) => o.id));
16909
- const dedupedRecent = recent.filter((o) => {
16910
- if (seenIds.has(o.id))
16911
- return false;
16912
- seenIds.add(o.id);
16913
- return true;
16914
- });
16915
- const deduped = candidates.filter((o) => !seenIds.has(o.id));
16916
- for (const obs of crossProjectCandidates) {
16917
- if (!seenIds.has(obs.id)) {
16918
- seenIds.add(obs.id);
16919
- deduped.push(obs);
16920
- }
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;
16921
17013
  }
16922
- const nowEpoch = Math.floor(Date.now() / 1000);
16923
- const sorted = [...deduped].sort((a, b) => {
16924
- const scoreA = computeObservationPriority(a, nowEpoch);
16925
- const scoreB = computeObservationPriority(b, nowEpoch);
16926
- return scoreB - scoreA;
16927
- });
16928
- const projectName = project?.name ?? detected.name;
16929
- const canonicalId = project?.canonical_id ?? detected.canonical_id;
16930
- if (maxCount !== undefined) {
16931
- const remaining = Math.max(0, maxCount - pinned.length - dedupedRecent.length);
16932
- const all = [...pinned, ...dedupedRecent, ...sorted.slice(0, remaining)];
16933
- const recentPrompts2 = db.getRecentUserPrompts(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16934
- const recentToolEvents2 = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16935
- const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
16936
- const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
16937
- const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
16938
- return {
16939
- project_name: projectName,
16940
- canonical_id: canonicalId,
16941
- observations: all.map(toContextObservation),
16942
- session_count: all.length,
16943
- total_active: totalActive,
16944
- recentPrompts: recentPrompts2.length > 0 ? recentPrompts2 : undefined,
16945
- recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
16946
- recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
16947
- projectTypeCounts: projectTypeCounts2,
16948
- recentOutcomes: recentOutcomes2
16949
- };
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
+ }
16950
17019
  }
16951
- let remainingBudget = tokenBudget - 30;
16952
- const selected = [];
16953
- for (const obs of pinned) {
16954
- const cost = estimateObservationTokens(obs, selected.length);
16955
- remainingBudget -= cost;
16956
- 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 [];
16957
17098
  }
16958
- for (const obs of dedupedRecent) {
16959
- const cost = estimateObservationTokens(obs, selected.length);
16960
- remainingBudget -= cost;
16961
- 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
+ }
16962
17125
  }
16963
- for (const obs of sorted) {
16964
- const cost = estimateObservationTokens(obs, selected.length);
16965
- if (remainingBudget - cost < 0 && selected.length > 0)
16966
- break;
16967
- remainingBudget -= cost;
16968
- 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");
16969
17132
  }
16970
- const summaries = isNewProject ? [] : db.getRecentSummaries(projectId, 5);
16971
- const recentPrompts = db.getRecentUserPrompts(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16972
- const recentToolEvents = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
16973
- const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
16974
- const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
16975
- const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
16976
- let securityFindings = [];
16977
- if (!isNewProject) {
16978
- try {
16979
- const weekAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
16980
- securityFindings = db.db.query(`SELECT * FROM security_findings
16981
- WHERE project_id = ? AND created_at_epoch > ?
16982
- ORDER BY severity DESC, created_at_epoch DESC
16983
- LIMIT ?`).all(projectId, weekAgo, 10);
16984
- } catch {}
17133
+ if (requestCount > 0 || toolCount > 0) {
17134
+ suggested.push("activity_feed");
16985
17135
  }
16986
- let recentProjects;
16987
- if (isNewProject) {
16988
- try {
16989
- const nowEpochSec = Math.floor(Date.now() / 1000);
16990
- const projectRows = db.db.query(`SELECT p.name, p.canonical_id, p.last_active_epoch,
16991
- (SELECT COUNT(*) FROM observations o
16992
- WHERE o.project_id = p.id
16993
- AND o.lifecycle IN ('active', 'aging', 'pinned')
16994
- ${opts.userId ? "AND (o.sensitivity != 'personal' OR o.user_id = ?)" : ""}
16995
- AND o.superseded_by IS NULL) as obs_count
16996
- FROM projects p
16997
- ORDER BY p.last_active_epoch DESC
16998
- LIMIT 10`).all(...visibilityParams);
16999
- if (projectRows.length > 0) {
17000
- recentProjects = projectRows.map((r) => {
17001
- const daysAgo = Math.max(0, Math.floor((nowEpochSec - r.last_active_epoch) / 86400));
17002
- const lastActive = new Date(r.last_active_epoch * 1000).toISOString().split("T")[0];
17003
- return {
17004
- name: r.name,
17005
- canonical_id: r.canonical_id,
17006
- observation_count: r.obs_count,
17007
- last_active: lastActive,
17008
- days_ago: daysAgo
17009
- };
17010
- });
17011
- }
17012
- } catch {}
17136
+ if (observationCount > 0) {
17137
+ suggested.push("tool_memory_index", "capture_git_worktree");
17013
17138
  }
17014
- let staleDecisions;
17015
- try {
17016
- const stale = isNewProject ? findStaleDecisionsGlobal(db) : findStaleDecisions(db, projectId);
17017
- if (stale.length > 0)
17018
- staleDecisions = stale;
17019
- } 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;
17020
17176
  return {
17021
- project_name: projectName,
17022
- canonical_id: canonicalId,
17023
- observations: selected.map(toContextObservation),
17024
- session_count: selected.length,
17025
- total_active: totalActive,
17026
- summaries: summaries.length > 0 ? summaries : undefined,
17027
- securityFindings: securityFindings.length > 0 ? securityFindings : undefined,
17028
- recentProjects,
17029
- staleDecisions,
17030
- recentPrompts: recentPrompts.length > 0 ? recentPrompts : undefined,
17031
- recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
17032
- recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
17033
- projectTypeCounts,
17034
- 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
+ }))
17035
17260
  };
17036
17261
  }
17037
- function estimateObservationTokens(obs, index) {
17038
- const DETAILED_THRESHOLD = 5;
17039
- const titleCost = estimateTokens(`- **[${obs.type}]** ${obs.title} (2026-01-01, q=0.5)`);
17040
- if (index >= DETAILED_THRESHOLD) {
17041
- return titleCost;
17042
- }
17043
- const detailText = formatObservationDetail(obs);
17044
- return titleCost + estimateTokens(detailText);
17045
- }
17046
- function formatContextForInjection(context) {
17047
- 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)) {
17048
- return `Project: ${context.project_name} (no prior observations)`;
17049
- }
17050
- const DETAILED_COUNT = 5;
17051
- const isCrossProject = context.recentProjects && context.recentProjects.length > 0;
17052
- const lines = [];
17053
- if (isCrossProject) {
17054
- lines.push(`## Engrm Memory — Workspace Overview`);
17055
- lines.push(`This is a new project folder. Here is context from your recent work:`);
17056
- lines.push("");
17057
- lines.push("**Active projects in memory:**");
17058
- for (const rp of context.recentProjects) {
17059
- const activity = rp.days_ago === 0 ? "today" : rp.days_ago === 1 ? "yesterday" : `${rp.days_ago}d ago`;
17060
- lines.push(`- **${rp.name}** — ${rp.observation_count} observations, last active ${activity}`);
17061
- }
17062
- lines.push("");
17063
- lines.push(`${context.session_count} relevant observation(s) from across projects:`);
17064
- lines.push("");
17065
- } else {
17066
- lines.push(`## Project Memory: ${context.project_name}`);
17067
- lines.push(`${context.session_count} relevant observation(s) from prior sessions:`);
17068
- lines.push("");
17069
- }
17070
- if (context.recentPrompts && context.recentPrompts.length > 0) {
17071
- const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
17072
- if (promptLines.length > 0) {
17073
- lines.push("## Recent Requests");
17074
- for (const prompt of promptLines) {
17075
- const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
17076
- lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
17077
- }
17078
- lines.push("");
17079
- }
17080
- }
17081
- if (context.recentToolEvents && context.recentToolEvents.length > 0) {
17082
- lines.push("## Recent Tools");
17083
- for (const tool of context.recentToolEvents.slice(0, 5)) {
17084
- lines.push(`- ${tool.tool_name}: ${formatToolEventDetail(tool)}`);
17085
- }
17086
- lines.push("");
17087
- }
17088
- if (context.recentSessions && context.recentSessions.length > 0) {
17089
- const recentSessionLines = context.recentSessions.slice(0, 4).map((session) => {
17090
- const summary = chooseMeaningfulSessionHeadline(session.request, session.completed);
17091
- if (summary === "(no summary)")
17092
- return null;
17093
- return `- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
17094
- }).filter((line) => Boolean(line));
17095
- if (recentSessionLines.length > 0) {
17096
- lines.push("## Recent Sessions");
17097
- lines.push(...recentSessionLines);
17098
- lines.push("");
17099
- }
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
+ };
17100
17277
  }
17101
- if (context.recentOutcomes && context.recentOutcomes.length > 0) {
17102
- lines.push("## Recent Outcomes");
17103
- for (const outcome of context.recentOutcomes.slice(0, 5)) {
17104
- lines.push(`- ${truncateText(outcome, 160)}`);
17105
- }
17106
- 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);
17107
17286
  }
17108
- if (context.projectTypeCounts && Object.keys(context.projectTypeCounts).length > 0) {
17109
- 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(" · ");
17110
- if (topTypes) {
17111
- lines.push(`## Project Signals`);
17112
- lines.push(`Top memory types: ${topTypes}`);
17113
- lines.push("");
17114
- }
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);
17115
17325
  }
17116
- for (let i = 0;i < context.observations.length; i++) {
17117
- const obs = context.observations[i];
17118
- const date5 = obs.created_at.split("T")[0];
17119
- const fromLabel = obs.source_project ? ` [from: ${obs.source_project}]` : "";
17120
- const fileLabel = formatObservationFiles(obs);
17121
- lines.push(`- **#${obs.id} [${obs.type}]** ${obs.title} (${date5}, q=${obs.quality.toFixed(1)})${fromLabel}${fileLabel}`);
17122
- if (i < DETAILED_COUNT) {
17123
- const detail = formatObservationDetailFromContext(obs);
17124
- if (detail) {
17125
- lines.push(detail);
17126
- }
17127
- }
17326
+ if (detected.name.toLowerCase() === "huginn") {
17327
+ explicit.add("aiserver");
17128
17328
  }
17129
- if (context.summaries && context.summaries.length > 0) {
17130
- lines.push("");
17131
- lines.push("## Recent Project Briefs");
17132
- for (const summary of context.summaries.slice(0, 3)) {
17133
- lines.push(...formatSessionBrief(summary));
17134
- lines.push("");
17135
- }
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}`;
17136
17342
  }
17137
- if (context.securityFindings && context.securityFindings.length > 0) {
17138
- lines.push("");
17139
- lines.push("Security findings (recent):");
17140
- for (const finding of context.securityFindings) {
17141
- const date5 = new Date(finding.created_at_epoch * 1000).toISOString().split("T")[0];
17142
- const file2 = finding.file_path ? ` in ${finding.file_path}` : finding.tool_name ? ` via ${finding.tool_name}` : "";
17143
- 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
+ };
17144
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}`);
17145
17423
  }
17146
- if (context.staleDecisions && context.staleDecisions.length > 0) {
17147
- lines.push("");
17148
- lines.push("Stale commitments (decided but no implementation observed):");
17149
- for (const sd of context.staleDecisions) {
17150
- const date5 = sd.created_at.split("T")[0];
17151
- lines.push(`- [DECISION] ${sd.title} (${date5}, ${sd.days_ago}d ago)`);
17152
- if (sd.best_match_title) {
17153
- lines.push(` Closest match: "${sd.best_match_title}" (${Math.round((sd.best_match_similarity ?? 0) * 100)}% similar — not enough to count as done)`);
17154
- }
17155
- }
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;
17156
17458
  }
17157
- const remaining = context.total_active - context.session_count;
17158
- if (remaining > 0) {
17159
- lines.push("");
17160
- 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];
17161
17467
  }
17162
- return lines.join(`
17163
- `);
17468
+ return (b.id ?? 0) - (a.id ?? 0);
17164
17469
  }
17165
- function formatSessionBrief(summary) {
17166
- const lines = [];
17167
- const heading = summary.request ? `### ${truncateText(summary.request, 120)}` : `### Session ${summary.session_id.slice(0, 8)}`;
17168
- lines.push(heading);
17169
- const sections = [
17170
- ["Investigated", summary.investigated, 180],
17171
- ["Learned", summary.learned, 180],
17172
- ["Completed", summary.completed, 180],
17173
- ["Next Steps", summary.next_steps, 140]
17174
- ];
17175
- for (const [label, value, maxLen] of sections) {
17176
- const formatted = formatSummarySection(value, maxLen);
17177
- if (formatted) {
17178
- lines.push(`${label}:`);
17179
- 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;
17180
17496
  }
17181
17497
  }
17182
- return lines;
17183
- }
17184
- function chooseMeaningfulSessionHeadline(request, completed) {
17185
- if (request && !looksLikeFileOperationTitle2(request))
17186
- return request;
17187
- const completedItems = extractMeaningfulLines(completed, 1);
17188
- if (completedItems.length > 0)
17189
- return completedItems[0];
17190
- return request ?? completed ?? "(no summary)";
17191
- }
17192
- function formatSummarySection(value, maxLen) {
17193
- if (!value)
17194
- return null;
17195
- const cleaned = value.split(`
17196
- `).map((line) => line.trim()).filter(Boolean).map((line) => line.startsWith("-") ? line : `- ${line}`).join(`
17197
- `);
17198
- if (!cleaned)
17199
- return null;
17200
- return truncateMultilineText(cleaned, maxLen);
17201
- }
17202
- function truncateMultilineText(text, maxLen) {
17203
- if (text.length <= maxLen)
17204
- return text;
17205
- const truncated = text.slice(0, maxLen).trimEnd();
17206
- const lastBreak = Math.max(truncated.lastIndexOf(`
17207
- `), truncated.lastIndexOf(" "));
17208
- const safe = lastBreak > maxLen * 0.5 ? truncated.slice(0, lastBreak) : truncated;
17209
- 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
+ };
17210
17533
  }
17211
- function truncateText(text, maxLen) {
17212
- if (text.length <= maxLen)
17213
- return text;
17214
- 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
+ };
17215
17651
  }
17216
- function isMeaningfulPrompt(value) {
17652
+ function parseNullableInt(value) {
17217
17653
  if (!value)
17218
- return false;
17219
- const compact = value.replace(/\s+/g, " ").trim();
17220
- if (compact.length < 8)
17221
- return false;
17222
- return /[a-z]{3,}/i.test(compact);
17223
- }
17224
- function looksLikeFileOperationTitle2(value) {
17225
- 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;
17226
17657
  }
17227
- function stripInlineSectionLabel(value) {
17228
- 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
+ };
17229
17733
  }
17230
- function extractMeaningfulLines(value, limit) {
17734
+
17735
+ // src/tools/tool-memory-index.ts
17736
+ function parseConcepts(value) {
17231
17737
  if (!value)
17232
17738
  return [];
17233
- return value.split(`
17234
- `).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);
17235
- }
17236
- function formatObservationDetailFromContext(obs) {
17237
- if (obs.facts) {
17238
- const bullets = parseFacts(obs.facts);
17239
- if (bullets.length > 0) {
17240
- return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
17241
- `);
17242
- }
17243
- }
17244
- if (obs.narrative) {
17245
- const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
17246
- return ` ${snippet}`;
17247
- }
17248
- return null;
17249
- }
17250
- function formatObservationDetail(obs) {
17251
- if (obs.facts) {
17252
- const bullets = parseFacts(obs.facts);
17253
- if (bullets.length > 0) {
17254
- return bullets.slice(0, 4).map((f) => ` - ${f}`).join(`
17255
- `);
17256
- }
17257
- }
17258
- if (obs.narrative) {
17259
- const snippet = obs.narrative.length > 120 ? obs.narrative.slice(0, 117) + "..." : obs.narrative;
17260
- return ` ${snippet}`;
17261
- }
17262
- return "";
17263
- }
17264
- function parseFacts(facts) {
17265
- if (!facts)
17266
- return [];
17267
17739
  try {
17268
- const parsed = JSON.parse(facts);
17269
- if (Array.isArray(parsed)) {
17270
- return parsed.filter((f) => typeof f === "string" && f.length > 0);
17271
- }
17740
+ const parsed = JSON.parse(value);
17741
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
17272
17742
  } catch {
17273
- if (facts.trim().length > 0) {
17274
- return [facts.trim()];
17275
- }
17743
+ return [];
17276
17744
  }
17277
- return [];
17278
17745
  }
17279
- 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
+ });
17280
17827
  return {
17281
- id: obs.id,
17282
- type: obs.type,
17283
- title: obs.title,
17284
- narrative: obs.narrative,
17285
- facts: obs.facts,
17286
- files_read: obs.files_read,
17287
- files_modified: obs.files_modified,
17288
- quality: obs.quality,
17289
- created_at: obs.created_at,
17290
- ...obs._source_project ? { source_project: obs._source_project } : {}
17828
+ project: project?.name,
17829
+ tools
17291
17830
  };
17292
17831
  }
17293
- function formatObservationFiles(obs) {
17294
- const modified = parseJsonStringArray(obs.files_modified);
17295
- if (modified.length > 0) {
17296
- return ` · files: ${truncateText(modified.slice(0, 2).join(", "), 60)}`;
17297
- }
17298
- const read = parseJsonStringArray(obs.files_read);
17299
- if (read.length > 0) {
17300
- return ` · read: ${truncateText(read.slice(0, 2).join(", "), 60)}`;
17301
- }
17302
- return "";
17303
- }
17304
- function parseJsonStringArray(value) {
17832
+
17833
+ // src/tools/session-tool-memory.ts
17834
+ function parseConcepts2(value) {
17305
17835
  if (!value)
17306
17836
  return [];
17307
17837
  try {
17308
17838
  const parsed = JSON.parse(value);
17309
- if (!Array.isArray(parsed))
17310
- return [];
17311
- 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) : [];
17312
17840
  } catch {
17313
17841
  return [];
17314
17842
  }
17315
17843
  }
17316
- function formatToolEventDetail(tool) {
17317
- const detail = tool.file_path ?? tool.command ?? tool.tool_response_preview ?? "";
17318
- return truncateText(detail || "recent tool execution", 160);
17319
- }
17320
- function getProjectTypeCounts(db, projectId, userId) {
17321
- const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
17322
- const rows = db.db.query(`SELECT type, COUNT(*) as count
17323
- FROM observations
17324
- WHERE project_id = ?
17325
- AND lifecycle IN ('active', 'aging', 'pinned')
17326
- AND superseded_by IS NULL
17327
- ${visibilityClause}
17328
- GROUP BY type`).all(projectId, ...userId ? [userId] : []);
17329
- const counts = {};
17330
- for (const row of rows) {
17331
- counts[row.type] = row.count;
17332
- }
17333
- return counts;
17334
- }
17335
- function getRecentOutcomes(db, projectId, userId) {
17336
- const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
17337
- const visibilityParams = userId ? [userId] : [];
17338
- const summaries = db.db.query(`SELECT * FROM session_summaries
17339
- WHERE project_id = ?
17340
- ORDER BY created_at_epoch DESC
17341
- LIMIT 6`).all(projectId);
17342
- const picked = [];
17343
- const seen = new Set;
17344
- for (const summary of summaries) {
17345
- for (const line of [
17346
- ...extractMeaningfulLines(summary.completed, 2),
17347
- ...extractMeaningfulLines(summary.learned, 1)
17348
- ]) {
17349
- const normalized = line.toLowerCase().replace(/\s+/g, " ").trim();
17350
- if (!normalized || seen.has(normalized))
17351
- continue;
17352
- seen.add(normalized);
17353
- picked.push(line);
17354
- if (picked.length >= 5)
17355
- return picked;
17356
- }
17357
- }
17358
- const rows = db.db.query(`SELECT * FROM observations
17359
- WHERE project_id = ?
17360
- AND lifecycle IN ('active', 'aging', 'pinned')
17361
- AND superseded_by IS NULL
17362
- ${visibilityClause}
17363
- ORDER BY created_at_epoch DESC
17364
- LIMIT 20`).all(projectId, ...visibilityParams);
17365
- for (const obs of rows) {
17366
- if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
17367
- continue;
17368
- const title = stripInlineSectionLabel(obs.title);
17369
- const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
17370
- if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle2(title))
17371
- continue;
17372
- seen.add(normalized);
17373
- picked.push(title);
17374
- if (picked.length >= 5)
17375
- break;
17376
- }
17377
- 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
+ };
17378
17896
  }
17379
17897
 
17380
17898
  // src/tools/session-context.ts
@@ -17387,6 +17905,7 @@ function getSessionContext(db, input) {
17387
17905
  });
17388
17906
  if (!context)
17389
17907
  return null;
17908
+ const preview = formatContextForInjection(context);
17390
17909
  const recentRequests = context.recentPrompts?.length ?? 0;
17391
17910
  const recentTools = context.recentToolEvents?.length ?? 0;
17392
17911
  const captureState = recentRequests > 0 && recentTools > 0 ? "rich" : recentRequests > 0 || recentTools > 0 ? "partial" : "summary-only";
@@ -17403,19 +17922,21 @@ function getSessionContext(db, input) {
17403
17922
  hot_files: hotFiles,
17404
17923
  capture_state: captureState,
17405
17924
  raw_capture_active: recentRequests > 0 || recentTools > 0,
17406
- preview: formatContextForInjection(context)
17925
+ estimated_read_tokens: estimateTokens(preview),
17926
+ suggested_tools: buildSuggestedTools2(context),
17927
+ preview
17407
17928
  };
17408
17929
  }
17409
17930
  function buildHotFiles(context) {
17410
17931
  const counts = new Map;
17411
17932
  for (const obs of context.observations) {
17412
- 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)]) {
17413
17934
  counts.set(path, (counts.get(path) ?? 0) + 1);
17414
17935
  }
17415
17936
  }
17416
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);
17417
17938
  }
17418
- function parseJsonArray(value) {
17939
+ function parseJsonArray2(value) {
17419
17940
  if (!value)
17420
17941
  return [];
17421
17942
  try {
@@ -17425,6 +17946,161 @@ function parseJsonArray(value) {
17425
17946
  return [];
17426
17947
  }
17427
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
+ }
17428
18104
 
17429
18105
  // src/tools/send-message.ts
17430
18106
  async function sendMessage(db, config2, input) {
@@ -17550,10 +18226,7 @@ function countPresentSections(summary) {
17550
18226
  ].filter(hasContent).length;
17551
18227
  }
17552
18228
  function extractSectionItems(section) {
17553
- if (!hasContent(section))
17554
- return [];
17555
- return section.split(`
17556
- `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
18229
+ return extractSummaryItems(section);
17557
18230
  }
17558
18231
  function extractObservationTitles(observations, types) {
17559
18232
  const typeSet = new Set(types);
@@ -17995,6 +18668,8 @@ function buildVectorDocument(obs, config2, project) {
17995
18668
  concepts: obs.concepts ? JSON.parse(obs.concepts) : [],
17996
18669
  files_read: obs.files_read ? JSON.parse(obs.files_read) : [],
17997
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,
17998
18673
  session_id: obs.session_id,
17999
18674
  created_at_epoch: obs.created_at_epoch,
18000
18675
  created_at: obs.created_at,
@@ -18048,6 +18723,8 @@ function buildSummaryVectorDocument(summary, config2, project, observations = []
18048
18723
  recent_tool_commands: captureContext?.recent_tool_commands ?? [],
18049
18724
  hot_files: captureContext?.hot_files ?? [],
18050
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,
18051
18728
  decisions_count: valueSignals.decisions_count,
18052
18729
  lessons_count: valueSignals.lessons_count,
18053
18730
  discoveries_count: valueSignals.discoveries_count,
@@ -18160,10 +18837,7 @@ function countPresentSections2(summary) {
18160
18837
  ].filter((value) => Boolean(value && value.trim())).length;
18161
18838
  }
18162
18839
  function extractSectionItems2(section) {
18163
- if (!section)
18164
- return [];
18165
- return section.split(`
18166
- `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "").trim()).filter(Boolean).slice(0, 4);
18840
+ return extractSummaryItems(section, 4);
18167
18841
  }
18168
18842
  function buildSummaryCaptureContext(prompts, toolEvents, observations) {
18169
18843
  const latestRequest = prompts.length > 0 ? prompts[prompts.length - 1]?.prompt ?? null : null;
@@ -18171,11 +18845,18 @@ function buildSummaryCaptureContext(prompts, toolEvents, observations) {
18171
18845
  const recentToolNames = [...new Set(toolEvents.slice(-8).map((tool) => tool.tool_name).filter(Boolean))];
18172
18846
  const recentToolCommands = [...new Set(toolEvents.slice(-5).map((tool) => (tool.command ?? tool.file_path ?? "").trim()).filter(Boolean))];
18173
18847
  const hotFiles = [...new Set(observations.flatMap((obs) => [
18174
- ...parseJsonArray2(obs.files_modified),
18175
- ...parseJsonArray2(obs.files_read)
18848
+ ...parseJsonArray3(obs.files_modified),
18849
+ ...parseJsonArray3(obs.files_read)
18176
18850
  ]).filter(Boolean))].slice(0, 6);
18177
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);
18178
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;
18179
18860
  return {
18180
18861
  prompt_count: prompts.length,
18181
18862
  tool_event_count: toolEvents.length,
@@ -18185,10 +18866,12 @@ function buildSummaryCaptureContext(prompts, toolEvents, observations) {
18185
18866
  recent_tool_commands: recentToolCommands,
18186
18867
  capture_state: captureState,
18187
18868
  hot_files: hotFiles,
18188
- recent_outcomes: recentOutcomes
18869
+ recent_outcomes: recentOutcomes,
18870
+ observation_source_tools: observationSourceTools,
18871
+ latest_observation_prompt_number: latestObservationPromptNumber
18189
18872
  };
18190
18873
  }
18191
- function parseJsonArray2(value) {
18874
+ function parseJsonArray3(value) {
18192
18875
  if (!value)
18193
18876
  return [];
18194
18877
  try {
@@ -18450,7 +19133,7 @@ async function backfillEmbeddings(db, batchSize = 50) {
18450
19133
  }
18451
19134
 
18452
19135
  // src/packs/recommender.ts
18453
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "node:fs";
19136
+ import { existsSync as existsSync6, readdirSync, readFileSync as readFileSync4 } from "node:fs";
18454
19137
  import { join as join4, basename as basename2, dirname as dirname2 } from "node:path";
18455
19138
  import { fileURLToPath } from "node:url";
18456
19139
  function getPacksDir() {
@@ -18459,7 +19142,7 @@ function getPacksDir() {
18459
19142
  }
18460
19143
  function loadPack(name) {
18461
19144
  const packPath = join4(getPacksDir(), `${name}.json`);
18462
- if (!existsSync4(packPath))
19145
+ if (!existsSync6(packPath))
18463
19146
  return null;
18464
19147
  try {
18465
19148
  const raw = readFileSync4(packPath, "utf-8");
@@ -18470,7 +19153,7 @@ function loadPack(name) {
18470
19153
  }
18471
19154
 
18472
19155
  // src/server.ts
18473
- 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";
18474
19157
  import { join as join5 } from "node:path";
18475
19158
  import { homedir as homedir3 } from "node:os";
18476
19159
 
@@ -18799,6 +19482,218 @@ function reduceGitDiffToMemory(input) {
18799
19482
  };
18800
19483
  }
18801
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
+
18802
19697
  // src/server.ts
18803
19698
  if (!configExists()) {
18804
19699
  console.error("Engrm is not configured. Run: engrm init --manual");
@@ -18820,7 +19715,7 @@ var MCP_METRICS_PATH = join5(homedir3(), ".engrm", "mcp-session-metrics.json");
18820
19715
  function persistSessionMetrics() {
18821
19716
  try {
18822
19717
  const dir = join5(homedir3(), ".engrm");
18823
- if (!existsSync5(dir))
19718
+ if (!existsSync7(dir))
18824
19719
  mkdirSync2(dir, { recursive: true });
18825
19720
  writeFileSync2(MCP_METRICS_PATH, JSON.stringify(sessionMetrics), "utf-8");
18826
19721
  } catch {}
@@ -18869,7 +19764,7 @@ process.on("SIGTERM", () => {
18869
19764
  });
18870
19765
  var server = new McpServer({
18871
19766
  name: "engrm",
18872
- version: "0.4.8"
19767
+ version: "0.4.13"
18873
19768
  });
18874
19769
  server.tool("save_observation", "Save an observation to memory", {
18875
19770
  type: exports_external.enum([
@@ -19045,6 +19940,164 @@ Facts: ${reduced.facts.join("; ")}` : "";
19045
19940
  ]
19046
19941
  };
19047
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
+ });
19048
20101
  server.tool("search", "Search memory for observations", {
19049
20102
  query: exports_external.string().describe("Search query"),
19050
20103
  project_scoped: exports_external.boolean().optional().describe("Scope to project (default: true)"),
@@ -19390,7 +20443,22 @@ server.tool("memory_console", "Show a high-signal local overview of what Engrm c
19390
20443
  return `- ${tool.tool_name}${detail ? ` — ${detail}` : ""}`;
19391
20444
  }).join(`
19392
20445
  `) : "- (none)";
19393
- 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(`
19394
20462
  `) : "- (none)";
19395
20463
  const projectLine = result.project ? `Project: ${result.project}
19396
20464
 
@@ -19404,7 +20472,20 @@ server.tool("memory_console", "Show a high-signal local overview of what Engrm c
19404
20472
  content: [
19405
20473
  {
19406
20474
  type: "text",
19407
- 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:
19408
20489
  ${sessionLines}
19409
20490
 
19410
20491
  ` + `Recent requests:
@@ -19455,6 +20536,106 @@ server.tool("capture_status", "Show whether Engrm hook registration and recent p
19455
20536
  ]
19456
20537
  };
19457
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
+ });
19458
20639
  server.tool("session_context", "Preview the exact project memory context Engrm would inject at session start", {
19459
20640
  cwd: exports_external.string().optional(),
19460
20641
  token_budget: exports_external.number().optional(),
@@ -19541,6 +20722,10 @@ server.tool("project_memory_index", "Show a typed local memory index for the cur
19541
20722
  }).join(`
19542
20723
  `) : "- (none)";
19543
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(`
19544
20729
  `) : "- (none)";
19545
20730
  const topTitles = result.top_titles.length > 0 ? result.top_titles.map((item) => `- #${item.id} [${item.type}] ${item.title}`).join(`
19546
20731
  `) : "- (none)";
@@ -19555,15 +20740,25 @@ server.tool("project_memory_index", "Show a typed local memory index for the cur
19555
20740
 
19556
20741
  ` + `Raw chronology: ${result.raw_capture_active ? "active" : "observations-only so far"}
19557
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
+
19558
20747
  ` + `Observation counts:
19559
20748
  ${counts}
19560
20749
 
20750
+ ` + `Top types:
20751
+ ${topTypes}
20752
+
19561
20753
  ` + `Recent sessions:
19562
20754
  ${sessions}
19563
20755
 
19564
20756
  ` + `Hot files:
19565
20757
  ${hotFiles}
19566
20758
 
20759
+ ` + `Observation provenance:
20760
+ ${provenance}
20761
+
19567
20762
  ` + `Recent memory objects:
19568
20763
  ${topTitles}`
19569
20764
  }
@@ -19634,17 +20829,22 @@ server.tool("workspace_memory_index", "Show a cross-project local memory index f
19634
20829
  });
19635
20830
  const projectLines = result.projects.length > 0 ? result.projects.map((project) => {
19636
20831
  const when = new Date(project.last_active_epoch * 1000).toISOString().split("T")[0];
19637
- 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}`;
19638
20833
  }).join(`
20834
+ `) : "- (none)";
20835
+ const provenanceLines = result.provenance_summary.length > 0 ? result.provenance_summary.map((item) => `- ${item.tool}: ${item.count}`).join(`
19639
20836
  `) : "- (none)";
19640
20837
  return {
19641
20838
  content: [
19642
20839
  {
19643
20840
  type: "text",
19644
- 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}
19645
20842
 
19646
20843
  ` + `Projects with raw chronology: ${result.projects_with_raw_capture}
19647
20844
 
20845
+ ` + `Observation provenance:
20846
+ ${provenanceLines}
20847
+
19648
20848
  ` + `Projects:
19649
20849
  ${projectLines}`
19650
20850
  }
@@ -19752,10 +20952,19 @@ server.tool("session_story", "Show the full local memory story for one session",
19752
20952
  return `- ${tool.tool_name}${detail ? ` — ${detail}` : ""}`;
19753
20953
  }).join(`
19754
20954
  `) : "- (none)";
19755
- 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(`
19756
20963
  `) : "- (none)";
19757
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";
19758
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(`
19759
20968
  `) : "- none";
19760
20969
  return {
19761
20970
  content: [
@@ -19775,6 +20984,9 @@ ${promptLines}
19775
20984
  ` + `Tools:
19776
20985
  ${toolLines}
19777
20986
 
20987
+ ` + `Provenance:
20988
+ ${provenanceSummary}
20989
+
19778
20990
  ` + `Capture gaps:
19779
20991
  ${captureGaps}
19780
20992