engrm 0.4.8 → 0.4.10

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.
@@ -863,6 +863,11 @@ import { createHash as createHash2 } from "node:crypto";
863
863
  var IS_BUN = typeof globalThis.Bun !== "undefined";
864
864
  function openDatabase(dbPath) {
865
865
  if (IS_BUN) {
866
+ if (process.platform === "darwin") {
867
+ try {
868
+ return openNodeDatabase(dbPath);
869
+ } catch {}
870
+ }
866
871
  return openBunDatabase(dbPath);
867
872
  }
868
873
  return openNodeDatabase(dbPath);
@@ -979,6 +984,15 @@ class MemDatabase {
979
984
  }
980
985
  return row;
981
986
  }
987
+ reassignObservationProject(observationId, projectId) {
988
+ const existing = this.getObservationById(observationId);
989
+ if (!existing)
990
+ return false;
991
+ if (existing.project_id === projectId)
992
+ return true;
993
+ this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
994
+ return true;
995
+ }
982
996
  getObservationById(id) {
983
997
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
984
998
  }
@@ -1112,8 +1126,13 @@ class MemDatabase {
1112
1126
  }
1113
1127
  upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
1114
1128
  const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
1115
- if (existing)
1129
+ if (existing) {
1130
+ if (existing.project_id === null && projectId !== null) {
1131
+ this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
1132
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
1133
+ }
1116
1134
  return existing;
1135
+ }
1117
1136
  const now = Math.floor(Date.now() / 1000);
1118
1137
  this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
1119
1138
  VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
@@ -1780,7 +1799,7 @@ function looksMeaningful(value) {
1780
1799
  // src/storage/projects.ts
1781
1800
  import { execSync } from "node:child_process";
1782
1801
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1783
- import { basename, join as join2 } from "node:path";
1802
+ import { basename, dirname, join as join2, resolve } from "node:path";
1784
1803
  function normaliseGitRemoteUrl(remoteUrl) {
1785
1804
  let url = remoteUrl.trim();
1786
1805
  url = url.replace(/^(?:https?|ssh|git):\/\//, "");
@@ -1834,6 +1853,19 @@ function getGitRemoteUrl(directory) {
1834
1853
  }
1835
1854
  }
1836
1855
  }
1856
+ function getGitTopLevel(directory) {
1857
+ try {
1858
+ const root = execSync("git rev-parse --show-toplevel", {
1859
+ cwd: directory,
1860
+ encoding: "utf-8",
1861
+ timeout: 5000,
1862
+ stdio: ["pipe", "pipe", "pipe"]
1863
+ }).trim();
1864
+ return root || null;
1865
+ } catch {
1866
+ return null;
1867
+ }
1868
+ }
1837
1869
  function readProjectConfigFile(directory) {
1838
1870
  const configPath = join2(directory, ".engrm.json");
1839
1871
  if (!existsSync2(configPath))
@@ -1856,11 +1888,12 @@ function detectProject(directory) {
1856
1888
  const remoteUrl = getGitRemoteUrl(directory);
1857
1889
  if (remoteUrl) {
1858
1890
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
1891
+ const repoRoot = getGitTopLevel(directory) ?? directory;
1859
1892
  return {
1860
1893
  canonical_id: canonicalId,
1861
1894
  name: projectNameFromCanonicalId(canonicalId),
1862
1895
  remote_url: remoteUrl,
1863
- local_path: directory
1896
+ local_path: repoRoot
1864
1897
  };
1865
1898
  }
1866
1899
  const configFile = readProjectConfigFile(directory);
@@ -1880,6 +1913,32 @@ function detectProject(directory) {
1880
1913
  local_path: directory
1881
1914
  };
1882
1915
  }
1916
+ function detectProjectForPath(filePath, fallbackCwd) {
1917
+ const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
1918
+ const candidateDir = existsSync2(absolutePath) && !absolutePath.endsWith("/") ? dirname(absolutePath) : dirname(absolutePath);
1919
+ const detected = detectProject(candidateDir);
1920
+ if (detected.canonical_id.startsWith("local/"))
1921
+ return null;
1922
+ return detected;
1923
+ }
1924
+ function detectProjectFromTouchedPaths(paths, fallbackCwd) {
1925
+ const counts = new Map;
1926
+ for (const rawPath of paths) {
1927
+ if (!rawPath || !rawPath.trim())
1928
+ continue;
1929
+ const detected = detectProjectForPath(rawPath, fallbackCwd);
1930
+ if (!detected)
1931
+ continue;
1932
+ const existing = counts.get(detected.canonical_id);
1933
+ if (existing) {
1934
+ existing.count += 1;
1935
+ } else {
1936
+ counts.set(detected.canonical_id, { project: detected, count: 1 });
1937
+ }
1938
+ }
1939
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
1940
+ return ranked[0]?.project ?? detectProject(fallbackCwd);
1941
+ }
1883
1942
 
1884
1943
  // src/embeddings/embedder.ts
1885
1944
  var _available = null;
@@ -2147,7 +2206,8 @@ async function saveObservation(db, config, input) {
2147
2206
  return { success: false, reason: "Title is required" };
2148
2207
  }
2149
2208
  const cwd = input.cwd ?? process.cwd();
2150
- const detected = detectProject(cwd);
2209
+ const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
2210
+ const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
2151
2211
  const project = db.upsertProject({
2152
2212
  canonical_id: detected.canonical_id,
2153
2213
  name: detected.name,
@@ -2599,6 +2659,7 @@ ${eventXml}`;
2599
2659
  const queryOptions = {
2600
2660
  model: options?.model ?? "haiku",
2601
2661
  maxTurns: 1,
2662
+ timeout: options?.timeoutMs ?? 800,
2602
2663
  disallowedTools: [
2603
2664
  "Bash",
2604
2665
  "Read",
@@ -2919,43 +2980,37 @@ function checkSessionFatigue(db, sessionId) {
2919
2980
 
2920
2981
  // hooks/post-tool-use.ts
2921
2982
  async function main() {
2922
- const event = await parseStdinJson();
2923
- if (!event)
2983
+ const raw = await readStdin();
2984
+ if (!raw.trim())
2924
2985
  process.exit(0);
2925
2986
  const boot = bootstrapHook("post-tool-use");
2926
2987
  if (!boot)
2927
2988
  process.exit(0);
2928
2989
  const { config, db } = boot;
2990
+ const now = Math.floor(Date.now() / 1000);
2991
+ let event = null;
2992
+ try {
2993
+ event = JSON.parse(raw);
2994
+ } catch {
2995
+ db.setSyncState("hook_post_tool_last_seen_epoch", String(now));
2996
+ db.setSyncState("hook_post_tool_last_parse_status", "invalid-json");
2997
+ db.setSyncState("hook_post_tool_last_payload_preview", truncatePreview(raw, 400) ?? "invalid");
2998
+ db.close();
2999
+ process.exit(0);
3000
+ }
3001
+ db.setSyncState("hook_post_tool_last_seen_epoch", String(now));
3002
+ db.setSyncState("hook_post_tool_last_parse_status", "parsed");
3003
+ db.setSyncState("hook_post_tool_last_tool_name", event.tool_name ?? "(unknown)");
3004
+ db.setSyncState("hook_post_tool_last_payload_preview", truncatePreview(raw, 400) ?? "parsed");
2929
3005
  try {
2930
3006
  if (event.session_id) {
2931
- const detected = detectProject(event.cwd);
2932
- const project = db.getProjectByCanonicalId(detected.canonical_id);
2933
- db.upsertSession(event.session_id, project?.id ?? null, config.user_id, config.device_id);
2934
- const metricsIncrement = {
2935
- toolCalls: 1
2936
- };
2937
- if ((event.tool_name === "Edit" || event.tool_name === "Write") && event.tool_input["file_path"]) {
2938
- metricsIncrement.files = 1;
2939
- }
2940
- db.incrementSessionMetrics(event.session_id, metricsIncrement);
2941
- db.insertToolEvent({
2942
- session_id: event.session_id,
2943
- project_id: project?.id ?? null,
2944
- tool_name: event.tool_name,
2945
- tool_input_json: safeSerializeToolInput(event.tool_input),
2946
- tool_response_preview: truncatePreview(event.tool_response, 1200),
2947
- file_path: extractFilePath(event.tool_input),
2948
- command: extractCommand(event.tool_input),
2949
- user_id: config.user_id,
2950
- device_id: config.device_id,
2951
- agent: "claude-code"
2952
- });
3007
+ persistRawToolChronology(event, config.user_id, config.device_id);
2953
3008
  }
2954
3009
  const textToScan = extractScanText(event);
2955
3010
  if (textToScan) {
2956
3011
  const findings = scanForSecrets(textToScan, config.scrubbing.custom_patterns);
2957
3012
  if (findings.length > 0) {
2958
- const detected = detectProject(event.cwd);
3013
+ const detected = detectProjectForEvent(event);
2959
3014
  const project = db.getProjectByCanonicalId(detected.canonical_id);
2960
3015
  if (project) {
2961
3016
  for (const finding of findings) {
@@ -3019,11 +3074,12 @@ async function main() {
3019
3074
  }
3020
3075
  }
3021
3076
  let saved = false;
3022
- if (config.observer?.enabled !== false) {
3077
+ if (shouldRunInlineObserver(event, config.observer?.enabled !== false)) {
3023
3078
  try {
3024
- const observed = await observeToolEvent(event, {
3025
- model: config.observer.model
3026
- });
3079
+ const observed = await withTimeout(observeToolEvent(event, {
3080
+ model: config.observer.model,
3081
+ timeoutMs: 800
3082
+ }), 1000);
3027
3083
  if (observed) {
3028
3084
  await saveObservation(db, config, observed);
3029
3085
  incrementObserverSaveCount(event.session_id);
@@ -3050,6 +3106,76 @@ async function main() {
3050
3106
  db.close();
3051
3107
  }
3052
3108
  }
3109
+ function persistRawToolChronology(event, userId, deviceId) {
3110
+ const rawDb = new MemDatabase(getDbPath());
3111
+ try {
3112
+ const detected = detectProjectForEvent(event);
3113
+ const project = rawDb.upsertProject({
3114
+ canonical_id: detected.canonical_id,
3115
+ name: detected.name,
3116
+ local_path: detected.local_path,
3117
+ remote_url: detected.remote_url ?? null
3118
+ });
3119
+ rawDb.upsertSession(event.session_id, project.id, userId, deviceId, "claude-code");
3120
+ const metricsIncrement = {
3121
+ toolCalls: 1
3122
+ };
3123
+ if ((event.tool_name === "Edit" || event.tool_name === "Write") && event.tool_input["file_path"]) {
3124
+ metricsIncrement.files = 1;
3125
+ }
3126
+ rawDb.incrementSessionMetrics(event.session_id, metricsIncrement);
3127
+ rawDb.insertToolEvent({
3128
+ session_id: event.session_id,
3129
+ project_id: project.id,
3130
+ tool_name: event.tool_name,
3131
+ tool_input_json: safeSerializeToolInput(event.tool_input),
3132
+ tool_response_preview: truncatePreview(event.tool_response, 1200),
3133
+ file_path: extractFilePath(event.tool_input),
3134
+ command: extractCommand(event.tool_input),
3135
+ user_id: userId,
3136
+ device_id: deviceId,
3137
+ agent: "claude-code"
3138
+ });
3139
+ } finally {
3140
+ rawDb.close();
3141
+ }
3142
+ }
3143
+ async function withTimeout(promise, timeoutMs) {
3144
+ let timeoutId = null;
3145
+ try {
3146
+ return await Promise.race([
3147
+ promise,
3148
+ new Promise((resolve2) => {
3149
+ timeoutId = setTimeout(() => resolve2(null), timeoutMs);
3150
+ })
3151
+ ]);
3152
+ } finally {
3153
+ if (timeoutId)
3154
+ clearTimeout(timeoutId);
3155
+ }
3156
+ }
3157
+ function shouldRunInlineObserver(event, observerEnabled) {
3158
+ if (!observerEnabled)
3159
+ return false;
3160
+ return event.tool_name === "Bash" || event.tool_name.startsWith("mcp__");
3161
+ }
3162
+ function detectProjectForEvent(event) {
3163
+ const touchedPaths = extractTouchedPaths(event);
3164
+ return touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, event.cwd) : detectProject(event.cwd);
3165
+ }
3166
+ function extractTouchedPaths(event) {
3167
+ const paths = [];
3168
+ const filePath = event.tool_input["file_path"];
3169
+ if (typeof filePath === "string" && filePath.trim().length > 0) {
3170
+ paths.push(filePath);
3171
+ }
3172
+ const extracted = extractObservation(event);
3173
+ if (extracted?.files_read)
3174
+ paths.push(...extracted.files_read);
3175
+ if (extracted?.files_modified)
3176
+ paths.push(...extracted.files_modified);
3177
+ return paths;
3178
+ }
3053
3179
  function safeSerializeToolInput(toolInput) {
3054
3180
  try {
3055
3181
  const raw = JSON.stringify(toolInput);
@@ -657,6 +657,11 @@ import { createHash as createHash2 } from "node:crypto";
657
657
  var IS_BUN = typeof globalThis.Bun !== "undefined";
658
658
  function openDatabase(dbPath) {
659
659
  if (IS_BUN) {
660
+ if (process.platform === "darwin") {
661
+ try {
662
+ return openNodeDatabase(dbPath);
663
+ } catch {}
664
+ }
660
665
  return openBunDatabase(dbPath);
661
666
  }
662
667
  return openNodeDatabase(dbPath);
@@ -773,6 +778,15 @@ class MemDatabase {
773
778
  }
774
779
  return row;
775
780
  }
781
+ reassignObservationProject(observationId, projectId) {
782
+ const existing = this.getObservationById(observationId);
783
+ if (!existing)
784
+ return false;
785
+ if (existing.project_id === projectId)
786
+ return true;
787
+ this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
788
+ return true;
789
+ }
776
790
  getObservationById(id) {
777
791
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
778
792
  }
@@ -906,8 +920,13 @@ class MemDatabase {
906
920
  }
907
921
  upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
908
922
  const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
909
- if (existing)
923
+ if (existing) {
924
+ if (existing.project_id === null && projectId !== null) {
925
+ this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
926
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
927
+ }
910
928
  return existing;
929
+ }
911
930
  const now = Math.floor(Date.now() / 1000);
912
931
  this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
913
932
  VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
@@ -1198,7 +1217,7 @@ function hashPrompt(prompt) {
1198
1217
  // src/storage/projects.ts
1199
1218
  import { execSync } from "node:child_process";
1200
1219
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1201
- import { basename, join as join2 } from "node:path";
1220
+ import { basename, dirname, join as join2, resolve } from "node:path";
1202
1221
  function normaliseGitRemoteUrl(remoteUrl) {
1203
1222
  let url = remoteUrl.trim();
1204
1223
  url = url.replace(/^(?:https?|ssh|git):\/\//, "");
@@ -1252,6 +1271,19 @@ function getGitRemoteUrl(directory) {
1252
1271
  }
1253
1272
  }
1254
1273
  }
1274
+ function getGitTopLevel(directory) {
1275
+ try {
1276
+ const root = execSync("git rev-parse --show-toplevel", {
1277
+ cwd: directory,
1278
+ encoding: "utf-8",
1279
+ timeout: 5000,
1280
+ stdio: ["pipe", "pipe", "pipe"]
1281
+ }).trim();
1282
+ return root || null;
1283
+ } catch {
1284
+ return null;
1285
+ }
1286
+ }
1255
1287
  function readProjectConfigFile(directory) {
1256
1288
  const configPath = join2(directory, ".engrm.json");
1257
1289
  if (!existsSync2(configPath))
@@ -1274,11 +1306,12 @@ function detectProject(directory) {
1274
1306
  const remoteUrl = getGitRemoteUrl(directory);
1275
1307
  if (remoteUrl) {
1276
1308
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
1309
+ const repoRoot = getGitTopLevel(directory) ?? directory;
1277
1310
  return {
1278
1311
  canonical_id: canonicalId,
1279
1312
  name: projectNameFromCanonicalId(canonicalId),
1280
1313
  remote_url: remoteUrl,
1281
- local_path: directory
1314
+ local_path: repoRoot
1282
1315
  };
1283
1316
  }
1284
1317
  const configFile = readProjectConfigFile(directory);
@@ -1298,6 +1331,32 @@ function detectProject(directory) {
1298
1331
  local_path: directory
1299
1332
  };
1300
1333
  }
1334
+ function detectProjectForPath(filePath, fallbackCwd) {
1335
+ const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
1336
+ const candidateDir = existsSync2(absolutePath) && !absolutePath.endsWith("/") ? dirname(absolutePath) : dirname(absolutePath);
1337
+ const detected = detectProject(candidateDir);
1338
+ if (detected.canonical_id.startsWith("local/"))
1339
+ return null;
1340
+ return detected;
1341
+ }
1342
+ function detectProjectFromTouchedPaths(paths, fallbackCwd) {
1343
+ const counts = new Map;
1344
+ for (const rawPath of paths) {
1345
+ if (!rawPath || !rawPath.trim())
1346
+ continue;
1347
+ const detected = detectProjectForPath(rawPath, fallbackCwd);
1348
+ if (!detected)
1349
+ continue;
1350
+ const existing = counts.get(detected.canonical_id);
1351
+ if (existing) {
1352
+ existing.count += 1;
1353
+ } else {
1354
+ counts.set(detected.canonical_id, { project: detected, count: 1 });
1355
+ }
1356
+ }
1357
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
1358
+ return ranked[0]?.project ?? detectProject(fallbackCwd);
1359
+ }
1301
1360
 
1302
1361
  // src/capture/dedup.ts
1303
1362
  function tokenise(text) {
@@ -1543,6 +1602,32 @@ function computeObservationPriority(obs, nowEpoch) {
1543
1602
  }
1544
1603
 
1545
1604
  // src/context/inject.ts
1605
+ function tokenizeProjectHint(text) {
1606
+ return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
1607
+ }
1608
+ function isObservationRelatedToProject(obs, detected) {
1609
+ const hints = new Set([
1610
+ ...tokenizeProjectHint(detected.name),
1611
+ ...tokenizeProjectHint(detected.canonical_id)
1612
+ ]);
1613
+ if (hints.size === 0)
1614
+ return false;
1615
+ const haystack = [
1616
+ obs.title,
1617
+ obs.narrative ?? "",
1618
+ obs.facts ?? "",
1619
+ obs.concepts ?? "",
1620
+ obs.files_read ?? "",
1621
+ obs.files_modified ?? "",
1622
+ obs._source_project ?? ""
1623
+ ].join(`
1624
+ `).toLowerCase();
1625
+ for (const hint of hints) {
1626
+ if (haystack.includes(hint))
1627
+ return true;
1628
+ }
1629
+ return false;
1630
+ }
1546
1631
  function estimateTokens(text) {
1547
1632
  if (!text)
1548
1633
  return 0;
@@ -1618,6 +1703,9 @@ function buildSessionContext(db, cwd, options = {}) {
1618
1703
  }
1619
1704
  return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
1620
1705
  });
1706
+ if (isNewProject) {
1707
+ crossProjectCandidates = crossProjectCandidates.filter((obs) => isObservationRelatedToProject(obs, detected));
1708
+ }
1621
1709
  }
1622
1710
  const seenIds = new Set(pinned.map((o) => o.id));
1623
1711
  const dedupedRecent = recent.filter((o) => {
@@ -1648,6 +1736,7 @@ function buildSessionContext(db, cwd, options = {}) {
1648
1736
  const recentToolEvents2 = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
1649
1737
  const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
1650
1738
  const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
1739
+ const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
1651
1740
  return {
1652
1741
  project_name: projectName,
1653
1742
  canonical_id: canonicalId,
@@ -1657,7 +1746,8 @@ function buildSessionContext(db, cwd, options = {}) {
1657
1746
  recentPrompts: recentPrompts2.length > 0 ? recentPrompts2 : undefined,
1658
1747
  recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
1659
1748
  recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
1660
- projectTypeCounts: projectTypeCounts2
1749
+ projectTypeCounts: projectTypeCounts2,
1750
+ recentOutcomes: recentOutcomes2
1661
1751
  };
1662
1752
  }
1663
1753
  let remainingBudget = tokenBudget - 30;
@@ -1684,6 +1774,7 @@ function buildSessionContext(db, cwd, options = {}) {
1684
1774
  const recentToolEvents = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
1685
1775
  const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
1686
1776
  const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
1777
+ const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
1687
1778
  let securityFindings = [];
1688
1779
  if (!isNewProject) {
1689
1780
  try {
@@ -1741,7 +1832,8 @@ function buildSessionContext(db, cwd, options = {}) {
1741
1832
  recentPrompts: recentPrompts.length > 0 ? recentPrompts : undefined,
1742
1833
  recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
1743
1834
  recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
1744
- projectTypeCounts
1835
+ projectTypeCounts,
1836
+ recentOutcomes
1745
1837
  };
1746
1838
  }
1747
1839
  function estimateObservationTokens(obs, index) {
@@ -1778,12 +1870,15 @@ function formatContextForInjection(context) {
1778
1870
  lines.push("");
1779
1871
  }
1780
1872
  if (context.recentPrompts && context.recentPrompts.length > 0) {
1781
- lines.push("## Recent Requests");
1782
- for (const prompt of context.recentPrompts.slice(0, 5)) {
1783
- const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
1784
- lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
1873
+ const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
1874
+ if (promptLines.length > 0) {
1875
+ lines.push("## Recent Requests");
1876
+ for (const prompt of promptLines) {
1877
+ const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
1878
+ lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
1879
+ }
1880
+ lines.push("");
1785
1881
  }
1786
- lines.push("");
1787
1882
  }
1788
1883
  if (context.recentToolEvents && context.recentToolEvents.length > 0) {
1789
1884
  lines.push("## Recent Tools");
@@ -1793,10 +1888,22 @@ function formatContextForInjection(context) {
1793
1888
  lines.push("");
1794
1889
  }
1795
1890
  if (context.recentSessions && context.recentSessions.length > 0) {
1796
- lines.push("## Recent Sessions");
1797
- for (const session of context.recentSessions.slice(0, 4)) {
1798
- const summary = session.request ?? session.completed ?? "(no summary)";
1799
- lines.push(`- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`);
1891
+ const recentSessionLines = context.recentSessions.slice(0, 4).map((session) => {
1892
+ const summary = chooseMeaningfulSessionHeadline(session.request, session.completed);
1893
+ if (summary === "(no summary)")
1894
+ return null;
1895
+ return `- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
1896
+ }).filter((line) => Boolean(line));
1897
+ if (recentSessionLines.length > 0) {
1898
+ lines.push("## Recent Sessions");
1899
+ lines.push(...recentSessionLines);
1900
+ lines.push("");
1901
+ }
1902
+ }
1903
+ if (context.recentOutcomes && context.recentOutcomes.length > 0) {
1904
+ lines.push("## Recent Outcomes");
1905
+ for (const outcome of context.recentOutcomes.slice(0, 5)) {
1906
+ lines.push(`- ${truncateText(outcome, 160)}`);
1800
1907
  }
1801
1908
  lines.push("");
1802
1909
  }
@@ -1876,6 +1983,14 @@ function formatSessionBrief(summary) {
1876
1983
  }
1877
1984
  return lines;
1878
1985
  }
1986
+ function chooseMeaningfulSessionHeadline(request, completed) {
1987
+ if (request && !looksLikeFileOperationTitle(request))
1988
+ return request;
1989
+ const completedItems = extractMeaningfulLines(completed, 1);
1990
+ if (completedItems.length > 0)
1991
+ return completedItems[0];
1992
+ return request ?? completed ?? "(no summary)";
1993
+ }
1879
1994
  function formatSummarySection(value, maxLen) {
1880
1995
  if (!value)
1881
1996
  return null;
@@ -1900,6 +2015,26 @@ function truncateText(text, maxLen) {
1900
2015
  return text;
1901
2016
  return text.slice(0, maxLen - 3) + "...";
1902
2017
  }
2018
+ function isMeaningfulPrompt(value) {
2019
+ if (!value)
2020
+ return false;
2021
+ const compact = value.replace(/\s+/g, " ").trim();
2022
+ if (compact.length < 8)
2023
+ return false;
2024
+ return /[a-z]{3,}/i.test(compact);
2025
+ }
2026
+ function looksLikeFileOperationTitle(value) {
2027
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
2028
+ }
2029
+ function stripInlineSectionLabel(value) {
2030
+ return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
2031
+ }
2032
+ function extractMeaningfulLines(value, limit) {
2033
+ if (!value)
2034
+ return [];
2035
+ return value.split(`
2036
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "")).map((line) => stripInlineSectionLabel(line)).filter((line) => line.length > 0 && !looksLikeFileOperationTitle(line)).slice(0, limit);
2037
+ }
1903
2038
  function formatObservationDetailFromContext(obs) {
1904
2039
  if (obs.facts) {
1905
2040
  const bullets = parseFacts(obs.facts);
@@ -1999,6 +2134,50 @@ function getProjectTypeCounts(db, projectId, userId) {
1999
2134
  }
2000
2135
  return counts;
2001
2136
  }
2137
+ function getRecentOutcomes(db, projectId, userId) {
2138
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
2139
+ const visibilityParams = userId ? [userId] : [];
2140
+ const summaries = db.db.query(`SELECT * FROM session_summaries
2141
+ WHERE project_id = ?
2142
+ ORDER BY created_at_epoch DESC
2143
+ LIMIT 6`).all(projectId);
2144
+ const picked = [];
2145
+ const seen = new Set;
2146
+ for (const summary of summaries) {
2147
+ for (const line of [
2148
+ ...extractMeaningfulLines(summary.completed, 2),
2149
+ ...extractMeaningfulLines(summary.learned, 1)
2150
+ ]) {
2151
+ const normalized = line.toLowerCase().replace(/\s+/g, " ").trim();
2152
+ if (!normalized || seen.has(normalized))
2153
+ continue;
2154
+ seen.add(normalized);
2155
+ picked.push(line);
2156
+ if (picked.length >= 5)
2157
+ return picked;
2158
+ }
2159
+ }
2160
+ const rows = db.db.query(`SELECT * FROM observations
2161
+ WHERE project_id = ?
2162
+ AND lifecycle IN ('active', 'aging', 'pinned')
2163
+ AND superseded_by IS NULL
2164
+ ${visibilityClause}
2165
+ ORDER BY created_at_epoch DESC
2166
+ LIMIT 20`).all(projectId, ...visibilityParams);
2167
+ for (const obs of rows) {
2168
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
2169
+ continue;
2170
+ const title = stripInlineSectionLabel(obs.title);
2171
+ const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
2172
+ if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle(title))
2173
+ continue;
2174
+ seen.add(normalized);
2175
+ picked.push(title);
2176
+ if (picked.length >= 5)
2177
+ break;
2178
+ }
2179
+ return picked;
2180
+ }
2002
2181
 
2003
2182
  // hooks/pre-compact.ts
2004
2183
  function formatCurrentSessionContext(observations) {