engrm 0.4.8 → 0.4.9

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.
@@ -979,6 +979,15 @@ class MemDatabase {
979
979
  }
980
980
  return row;
981
981
  }
982
+ reassignObservationProject(observationId, projectId) {
983
+ const existing = this.getObservationById(observationId);
984
+ if (!existing)
985
+ return false;
986
+ if (existing.project_id === projectId)
987
+ return true;
988
+ this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
989
+ return true;
990
+ }
982
991
  getObservationById(id) {
983
992
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
984
993
  }
@@ -1112,8 +1121,13 @@ class MemDatabase {
1112
1121
  }
1113
1122
  upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
1114
1123
  const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
1115
- if (existing)
1124
+ if (existing) {
1125
+ if (existing.project_id === null && projectId !== null) {
1126
+ this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
1127
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
1128
+ }
1116
1129
  return existing;
1130
+ }
1117
1131
  const now = Math.floor(Date.now() / 1000);
1118
1132
  this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
1119
1133
  VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
@@ -1780,7 +1794,7 @@ function looksMeaningful(value) {
1780
1794
  // src/storage/projects.ts
1781
1795
  import { execSync } from "node:child_process";
1782
1796
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1783
- import { basename, join as join2 } from "node:path";
1797
+ import { basename, dirname, join as join2, resolve } from "node:path";
1784
1798
  function normaliseGitRemoteUrl(remoteUrl) {
1785
1799
  let url = remoteUrl.trim();
1786
1800
  url = url.replace(/^(?:https?|ssh|git):\/\//, "");
@@ -1834,6 +1848,19 @@ function getGitRemoteUrl(directory) {
1834
1848
  }
1835
1849
  }
1836
1850
  }
1851
+ function getGitTopLevel(directory) {
1852
+ try {
1853
+ const root = execSync("git rev-parse --show-toplevel", {
1854
+ cwd: directory,
1855
+ encoding: "utf-8",
1856
+ timeout: 5000,
1857
+ stdio: ["pipe", "pipe", "pipe"]
1858
+ }).trim();
1859
+ return root || null;
1860
+ } catch {
1861
+ return null;
1862
+ }
1863
+ }
1837
1864
  function readProjectConfigFile(directory) {
1838
1865
  const configPath = join2(directory, ".engrm.json");
1839
1866
  if (!existsSync2(configPath))
@@ -1856,11 +1883,12 @@ function detectProject(directory) {
1856
1883
  const remoteUrl = getGitRemoteUrl(directory);
1857
1884
  if (remoteUrl) {
1858
1885
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
1886
+ const repoRoot = getGitTopLevel(directory) ?? directory;
1859
1887
  return {
1860
1888
  canonical_id: canonicalId,
1861
1889
  name: projectNameFromCanonicalId(canonicalId),
1862
1890
  remote_url: remoteUrl,
1863
- local_path: directory
1891
+ local_path: repoRoot
1864
1892
  };
1865
1893
  }
1866
1894
  const configFile = readProjectConfigFile(directory);
@@ -1880,6 +1908,32 @@ function detectProject(directory) {
1880
1908
  local_path: directory
1881
1909
  };
1882
1910
  }
1911
+ function detectProjectForPath(filePath, fallbackCwd) {
1912
+ const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
1913
+ const candidateDir = existsSync2(absolutePath) && !absolutePath.endsWith("/") ? dirname(absolutePath) : dirname(absolutePath);
1914
+ const detected = detectProject(candidateDir);
1915
+ if (detected.canonical_id.startsWith("local/"))
1916
+ return null;
1917
+ return detected;
1918
+ }
1919
+ function detectProjectFromTouchedPaths(paths, fallbackCwd) {
1920
+ const counts = new Map;
1921
+ for (const rawPath of paths) {
1922
+ if (!rawPath || !rawPath.trim())
1923
+ continue;
1924
+ const detected = detectProjectForPath(rawPath, fallbackCwd);
1925
+ if (!detected)
1926
+ continue;
1927
+ const existing = counts.get(detected.canonical_id);
1928
+ if (existing) {
1929
+ existing.count += 1;
1930
+ } else {
1931
+ counts.set(detected.canonical_id, { project: detected, count: 1 });
1932
+ }
1933
+ }
1934
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
1935
+ return ranked[0]?.project ?? detectProject(fallbackCwd);
1936
+ }
1883
1937
 
1884
1938
  // src/embeddings/embedder.ts
1885
1939
  var _available = null;
@@ -2147,7 +2201,8 @@ async function saveObservation(db, config, input) {
2147
2201
  return { success: false, reason: "Title is required" };
2148
2202
  }
2149
2203
  const cwd = input.cwd ?? process.cwd();
2150
- const detected = detectProject(cwd);
2204
+ const touchedPaths = [...input.files_read ?? [], ...input.files_modified ?? []];
2205
+ const detected = touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, cwd) : detectProject(cwd);
2151
2206
  const project = db.upsertProject({
2152
2207
  canonical_id: detected.canonical_id,
2153
2208
  name: detected.name,
@@ -2599,6 +2654,7 @@ ${eventXml}`;
2599
2654
  const queryOptions = {
2600
2655
  model: options?.model ?? "haiku",
2601
2656
  maxTurns: 1,
2657
+ timeout: options?.timeoutMs ?? 800,
2602
2658
  disallowedTools: [
2603
2659
  "Bash",
2604
2660
  "Read",
@@ -2919,43 +2975,37 @@ function checkSessionFatigue(db, sessionId) {
2919
2975
 
2920
2976
  // hooks/post-tool-use.ts
2921
2977
  async function main() {
2922
- const event = await parseStdinJson();
2923
- if (!event)
2978
+ const raw = await readStdin();
2979
+ if (!raw.trim())
2924
2980
  process.exit(0);
2925
2981
  const boot = bootstrapHook("post-tool-use");
2926
2982
  if (!boot)
2927
2983
  process.exit(0);
2928
2984
  const { config, db } = boot;
2985
+ const now = Math.floor(Date.now() / 1000);
2986
+ let event = null;
2987
+ try {
2988
+ event = JSON.parse(raw);
2989
+ } catch {
2990
+ db.setSyncState("hook_post_tool_last_seen_epoch", String(now));
2991
+ db.setSyncState("hook_post_tool_last_parse_status", "invalid-json");
2992
+ db.setSyncState("hook_post_tool_last_payload_preview", truncatePreview(raw, 400) ?? "invalid");
2993
+ db.close();
2994
+ process.exit(0);
2995
+ }
2996
+ db.setSyncState("hook_post_tool_last_seen_epoch", String(now));
2997
+ db.setSyncState("hook_post_tool_last_parse_status", "parsed");
2998
+ db.setSyncState("hook_post_tool_last_tool_name", event.tool_name ?? "(unknown)");
2999
+ db.setSyncState("hook_post_tool_last_payload_preview", truncatePreview(raw, 400) ?? "parsed");
2929
3000
  try {
2930
3001
  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
- });
3002
+ persistRawToolChronology(event, config.user_id, config.device_id);
2953
3003
  }
2954
3004
  const textToScan = extractScanText(event);
2955
3005
  if (textToScan) {
2956
3006
  const findings = scanForSecrets(textToScan, config.scrubbing.custom_patterns);
2957
3007
  if (findings.length > 0) {
2958
- const detected = detectProject(event.cwd);
3008
+ const detected = detectProjectForEvent(event);
2959
3009
  const project = db.getProjectByCanonicalId(detected.canonical_id);
2960
3010
  if (project) {
2961
3011
  for (const finding of findings) {
@@ -3019,11 +3069,12 @@ async function main() {
3019
3069
  }
3020
3070
  }
3021
3071
  let saved = false;
3022
- if (config.observer?.enabled !== false) {
3072
+ if (shouldRunInlineObserver(event, config.observer?.enabled !== false)) {
3023
3073
  try {
3024
- const observed = await observeToolEvent(event, {
3025
- model: config.observer.model
3026
- });
3074
+ const observed = await withTimeout(observeToolEvent(event, {
3075
+ model: config.observer.model,
3076
+ timeoutMs: 800
3077
+ }), 1000);
3027
3078
  if (observed) {
3028
3079
  await saveObservation(db, config, observed);
3029
3080
  incrementObserverSaveCount(event.session_id);
@@ -3050,6 +3101,76 @@ async function main() {
3050
3101
  db.close();
3051
3102
  }
3052
3103
  }
3104
+ function persistRawToolChronology(event, userId, deviceId) {
3105
+ const rawDb = new MemDatabase(getDbPath());
3106
+ try {
3107
+ const detected = detectProjectForEvent(event);
3108
+ const project = rawDb.upsertProject({
3109
+ canonical_id: detected.canonical_id,
3110
+ name: detected.name,
3111
+ local_path: detected.local_path,
3112
+ remote_url: detected.remote_url ?? null
3113
+ });
3114
+ rawDb.upsertSession(event.session_id, project.id, userId, deviceId, "claude-code");
3115
+ const metricsIncrement = {
3116
+ toolCalls: 1
3117
+ };
3118
+ if ((event.tool_name === "Edit" || event.tool_name === "Write") && event.tool_input["file_path"]) {
3119
+ metricsIncrement.files = 1;
3120
+ }
3121
+ rawDb.incrementSessionMetrics(event.session_id, metricsIncrement);
3122
+ rawDb.insertToolEvent({
3123
+ session_id: event.session_id,
3124
+ project_id: project.id,
3125
+ tool_name: event.tool_name,
3126
+ tool_input_json: safeSerializeToolInput(event.tool_input),
3127
+ tool_response_preview: truncatePreview(event.tool_response, 1200),
3128
+ file_path: extractFilePath(event.tool_input),
3129
+ command: extractCommand(event.tool_input),
3130
+ user_id: userId,
3131
+ device_id: deviceId,
3132
+ agent: "claude-code"
3133
+ });
3134
+ } finally {
3135
+ rawDb.close();
3136
+ }
3137
+ }
3138
+ async function withTimeout(promise, timeoutMs) {
3139
+ let timeoutId = null;
3140
+ try {
3141
+ return await Promise.race([
3142
+ promise,
3143
+ new Promise((resolve2) => {
3144
+ timeoutId = setTimeout(() => resolve2(null), timeoutMs);
3145
+ })
3146
+ ]);
3147
+ } finally {
3148
+ if (timeoutId)
3149
+ clearTimeout(timeoutId);
3150
+ }
3151
+ }
3152
+ function shouldRunInlineObserver(event, observerEnabled) {
3153
+ if (!observerEnabled)
3154
+ return false;
3155
+ return event.tool_name === "Bash" || event.tool_name.startsWith("mcp__");
3156
+ }
3157
+ function detectProjectForEvent(event) {
3158
+ const touchedPaths = extractTouchedPaths(event);
3159
+ return touchedPaths.length > 0 ? detectProjectFromTouchedPaths(touchedPaths, event.cwd) : detectProject(event.cwd);
3160
+ }
3161
+ function extractTouchedPaths(event) {
3162
+ const paths = [];
3163
+ const filePath = event.tool_input["file_path"];
3164
+ if (typeof filePath === "string" && filePath.trim().length > 0) {
3165
+ paths.push(filePath);
3166
+ }
3167
+ const extracted = extractObservation(event);
3168
+ if (extracted?.files_read)
3169
+ paths.push(...extracted.files_read);
3170
+ if (extracted?.files_modified)
3171
+ paths.push(...extracted.files_modified);
3172
+ return paths;
3173
+ }
3053
3174
  function safeSerializeToolInput(toolInput) {
3054
3175
  try {
3055
3176
  const raw = JSON.stringify(toolInput);
@@ -773,6 +773,15 @@ class MemDatabase {
773
773
  }
774
774
  return row;
775
775
  }
776
+ reassignObservationProject(observationId, projectId) {
777
+ const existing = this.getObservationById(observationId);
778
+ if (!existing)
779
+ return false;
780
+ if (existing.project_id === projectId)
781
+ return true;
782
+ this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
783
+ return true;
784
+ }
776
785
  getObservationById(id) {
777
786
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
778
787
  }
@@ -906,8 +915,13 @@ class MemDatabase {
906
915
  }
907
916
  upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
908
917
  const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
909
- if (existing)
918
+ if (existing) {
919
+ if (existing.project_id === null && projectId !== null) {
920
+ this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
921
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
922
+ }
910
923
  return existing;
924
+ }
911
925
  const now = Math.floor(Date.now() / 1000);
912
926
  this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
913
927
  VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
@@ -1198,7 +1212,7 @@ function hashPrompt(prompt) {
1198
1212
  // src/storage/projects.ts
1199
1213
  import { execSync } from "node:child_process";
1200
1214
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1201
- import { basename, join as join2 } from "node:path";
1215
+ import { basename, dirname, join as join2, resolve } from "node:path";
1202
1216
  function normaliseGitRemoteUrl(remoteUrl) {
1203
1217
  let url = remoteUrl.trim();
1204
1218
  url = url.replace(/^(?:https?|ssh|git):\/\//, "");
@@ -1252,6 +1266,19 @@ function getGitRemoteUrl(directory) {
1252
1266
  }
1253
1267
  }
1254
1268
  }
1269
+ function getGitTopLevel(directory) {
1270
+ try {
1271
+ const root = execSync("git rev-parse --show-toplevel", {
1272
+ cwd: directory,
1273
+ encoding: "utf-8",
1274
+ timeout: 5000,
1275
+ stdio: ["pipe", "pipe", "pipe"]
1276
+ }).trim();
1277
+ return root || null;
1278
+ } catch {
1279
+ return null;
1280
+ }
1281
+ }
1255
1282
  function readProjectConfigFile(directory) {
1256
1283
  const configPath = join2(directory, ".engrm.json");
1257
1284
  if (!existsSync2(configPath))
@@ -1274,11 +1301,12 @@ function detectProject(directory) {
1274
1301
  const remoteUrl = getGitRemoteUrl(directory);
1275
1302
  if (remoteUrl) {
1276
1303
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
1304
+ const repoRoot = getGitTopLevel(directory) ?? directory;
1277
1305
  return {
1278
1306
  canonical_id: canonicalId,
1279
1307
  name: projectNameFromCanonicalId(canonicalId),
1280
1308
  remote_url: remoteUrl,
1281
- local_path: directory
1309
+ local_path: repoRoot
1282
1310
  };
1283
1311
  }
1284
1312
  const configFile = readProjectConfigFile(directory);
@@ -1298,6 +1326,32 @@ function detectProject(directory) {
1298
1326
  local_path: directory
1299
1327
  };
1300
1328
  }
1329
+ function detectProjectForPath(filePath, fallbackCwd) {
1330
+ const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
1331
+ const candidateDir = existsSync2(absolutePath) && !absolutePath.endsWith("/") ? dirname(absolutePath) : dirname(absolutePath);
1332
+ const detected = detectProject(candidateDir);
1333
+ if (detected.canonical_id.startsWith("local/"))
1334
+ return null;
1335
+ return detected;
1336
+ }
1337
+ function detectProjectFromTouchedPaths(paths, fallbackCwd) {
1338
+ const counts = new Map;
1339
+ for (const rawPath of paths) {
1340
+ if (!rawPath || !rawPath.trim())
1341
+ continue;
1342
+ const detected = detectProjectForPath(rawPath, fallbackCwd);
1343
+ if (!detected)
1344
+ continue;
1345
+ const existing = counts.get(detected.canonical_id);
1346
+ if (existing) {
1347
+ existing.count += 1;
1348
+ } else {
1349
+ counts.set(detected.canonical_id, { project: detected, count: 1 });
1350
+ }
1351
+ }
1352
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
1353
+ return ranked[0]?.project ?? detectProject(fallbackCwd);
1354
+ }
1301
1355
 
1302
1356
  // src/capture/dedup.ts
1303
1357
  function tokenise(text) {
@@ -1543,6 +1597,32 @@ function computeObservationPriority(obs, nowEpoch) {
1543
1597
  }
1544
1598
 
1545
1599
  // src/context/inject.ts
1600
+ function tokenizeProjectHint(text) {
1601
+ return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
1602
+ }
1603
+ function isObservationRelatedToProject(obs, detected) {
1604
+ const hints = new Set([
1605
+ ...tokenizeProjectHint(detected.name),
1606
+ ...tokenizeProjectHint(detected.canonical_id)
1607
+ ]);
1608
+ if (hints.size === 0)
1609
+ return false;
1610
+ const haystack = [
1611
+ obs.title,
1612
+ obs.narrative ?? "",
1613
+ obs.facts ?? "",
1614
+ obs.concepts ?? "",
1615
+ obs.files_read ?? "",
1616
+ obs.files_modified ?? "",
1617
+ obs._source_project ?? ""
1618
+ ].join(`
1619
+ `).toLowerCase();
1620
+ for (const hint of hints) {
1621
+ if (haystack.includes(hint))
1622
+ return true;
1623
+ }
1624
+ return false;
1625
+ }
1546
1626
  function estimateTokens(text) {
1547
1627
  if (!text)
1548
1628
  return 0;
@@ -1618,6 +1698,9 @@ function buildSessionContext(db, cwd, options = {}) {
1618
1698
  }
1619
1699
  return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
1620
1700
  });
1701
+ if (isNewProject) {
1702
+ crossProjectCandidates = crossProjectCandidates.filter((obs) => isObservationRelatedToProject(obs, detected));
1703
+ }
1621
1704
  }
1622
1705
  const seenIds = new Set(pinned.map((o) => o.id));
1623
1706
  const dedupedRecent = recent.filter((o) => {
@@ -1648,6 +1731,7 @@ function buildSessionContext(db, cwd, options = {}) {
1648
1731
  const recentToolEvents2 = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
1649
1732
  const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
1650
1733
  const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
1734
+ const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
1651
1735
  return {
1652
1736
  project_name: projectName,
1653
1737
  canonical_id: canonicalId,
@@ -1657,7 +1741,8 @@ function buildSessionContext(db, cwd, options = {}) {
1657
1741
  recentPrompts: recentPrompts2.length > 0 ? recentPrompts2 : undefined,
1658
1742
  recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
1659
1743
  recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
1660
- projectTypeCounts: projectTypeCounts2
1744
+ projectTypeCounts: projectTypeCounts2,
1745
+ recentOutcomes: recentOutcomes2
1661
1746
  };
1662
1747
  }
1663
1748
  let remainingBudget = tokenBudget - 30;
@@ -1684,6 +1769,7 @@ function buildSessionContext(db, cwd, options = {}) {
1684
1769
  const recentToolEvents = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
1685
1770
  const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
1686
1771
  const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
1772
+ const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
1687
1773
  let securityFindings = [];
1688
1774
  if (!isNewProject) {
1689
1775
  try {
@@ -1741,7 +1827,8 @@ function buildSessionContext(db, cwd, options = {}) {
1741
1827
  recentPrompts: recentPrompts.length > 0 ? recentPrompts : undefined,
1742
1828
  recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
1743
1829
  recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
1744
- projectTypeCounts
1830
+ projectTypeCounts,
1831
+ recentOutcomes
1745
1832
  };
1746
1833
  }
1747
1834
  function estimateObservationTokens(obs, index) {
@@ -1778,12 +1865,15 @@ function formatContextForInjection(context) {
1778
1865
  lines.push("");
1779
1866
  }
1780
1867
  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)}`);
1868
+ const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
1869
+ if (promptLines.length > 0) {
1870
+ lines.push("## Recent Requests");
1871
+ for (const prompt of promptLines) {
1872
+ const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
1873
+ lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
1874
+ }
1875
+ lines.push("");
1785
1876
  }
1786
- lines.push("");
1787
1877
  }
1788
1878
  if (context.recentToolEvents && context.recentToolEvents.length > 0) {
1789
1879
  lines.push("## Recent Tools");
@@ -1793,10 +1883,22 @@ function formatContextForInjection(context) {
1793
1883
  lines.push("");
1794
1884
  }
1795
1885
  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})`);
1886
+ const recentSessionLines = context.recentSessions.slice(0, 4).map((session) => {
1887
+ const summary = chooseMeaningfulSessionHeadline(session.request, session.completed);
1888
+ if (summary === "(no summary)")
1889
+ return null;
1890
+ return `- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
1891
+ }).filter((line) => Boolean(line));
1892
+ if (recentSessionLines.length > 0) {
1893
+ lines.push("## Recent Sessions");
1894
+ lines.push(...recentSessionLines);
1895
+ lines.push("");
1896
+ }
1897
+ }
1898
+ if (context.recentOutcomes && context.recentOutcomes.length > 0) {
1899
+ lines.push("## Recent Outcomes");
1900
+ for (const outcome of context.recentOutcomes.slice(0, 5)) {
1901
+ lines.push(`- ${truncateText(outcome, 160)}`);
1800
1902
  }
1801
1903
  lines.push("");
1802
1904
  }
@@ -1876,6 +1978,14 @@ function formatSessionBrief(summary) {
1876
1978
  }
1877
1979
  return lines;
1878
1980
  }
1981
+ function chooseMeaningfulSessionHeadline(request, completed) {
1982
+ if (request && !looksLikeFileOperationTitle(request))
1983
+ return request;
1984
+ const completedItems = extractMeaningfulLines(completed, 1);
1985
+ if (completedItems.length > 0)
1986
+ return completedItems[0];
1987
+ return request ?? completed ?? "(no summary)";
1988
+ }
1879
1989
  function formatSummarySection(value, maxLen) {
1880
1990
  if (!value)
1881
1991
  return null;
@@ -1900,6 +2010,26 @@ function truncateText(text, maxLen) {
1900
2010
  return text;
1901
2011
  return text.slice(0, maxLen - 3) + "...";
1902
2012
  }
2013
+ function isMeaningfulPrompt(value) {
2014
+ if (!value)
2015
+ return false;
2016
+ const compact = value.replace(/\s+/g, " ").trim();
2017
+ if (compact.length < 8)
2018
+ return false;
2019
+ return /[a-z]{3,}/i.test(compact);
2020
+ }
2021
+ function looksLikeFileOperationTitle(value) {
2022
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
2023
+ }
2024
+ function stripInlineSectionLabel(value) {
2025
+ return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
2026
+ }
2027
+ function extractMeaningfulLines(value, limit) {
2028
+ if (!value)
2029
+ return [];
2030
+ return value.split(`
2031
+ `).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);
2032
+ }
1903
2033
  function formatObservationDetailFromContext(obs) {
1904
2034
  if (obs.facts) {
1905
2035
  const bullets = parseFacts(obs.facts);
@@ -1999,6 +2129,50 @@ function getProjectTypeCounts(db, projectId, userId) {
1999
2129
  }
2000
2130
  return counts;
2001
2131
  }
2132
+ function getRecentOutcomes(db, projectId, userId) {
2133
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
2134
+ const visibilityParams = userId ? [userId] : [];
2135
+ const summaries = db.db.query(`SELECT * FROM session_summaries
2136
+ WHERE project_id = ?
2137
+ ORDER BY created_at_epoch DESC
2138
+ LIMIT 6`).all(projectId);
2139
+ const picked = [];
2140
+ const seen = new Set;
2141
+ for (const summary of summaries) {
2142
+ for (const line of [
2143
+ ...extractMeaningfulLines(summary.completed, 2),
2144
+ ...extractMeaningfulLines(summary.learned, 1)
2145
+ ]) {
2146
+ const normalized = line.toLowerCase().replace(/\s+/g, " ").trim();
2147
+ if (!normalized || seen.has(normalized))
2148
+ continue;
2149
+ seen.add(normalized);
2150
+ picked.push(line);
2151
+ if (picked.length >= 5)
2152
+ return picked;
2153
+ }
2154
+ }
2155
+ const rows = db.db.query(`SELECT * FROM observations
2156
+ WHERE project_id = ?
2157
+ AND lifecycle IN ('active', 'aging', 'pinned')
2158
+ AND superseded_by IS NULL
2159
+ ${visibilityClause}
2160
+ ORDER BY created_at_epoch DESC
2161
+ LIMIT 20`).all(projectId, ...visibilityParams);
2162
+ for (const obs of rows) {
2163
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
2164
+ continue;
2165
+ const title = stripInlineSectionLabel(obs.title);
2166
+ const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
2167
+ if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle(title))
2168
+ continue;
2169
+ seen.add(normalized);
2170
+ picked.push(title);
2171
+ if (picked.length >= 5)
2172
+ break;
2173
+ }
2174
+ return picked;
2175
+ }
2002
2176
 
2003
2177
  // hooks/pre-compact.ts
2004
2178
  function formatCurrentSessionContext(observations) {