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.
@@ -11,7 +11,7 @@ import { homedir as homedir3 } from "os";
11
11
  // src/storage/projects.ts
12
12
  import { execSync } from "node:child_process";
13
13
  import { existsSync, readFileSync } from "node:fs";
14
- import { basename, join } from "node:path";
14
+ import { basename, dirname, join, resolve } from "node:path";
15
15
  function normaliseGitRemoteUrl(remoteUrl) {
16
16
  let url = remoteUrl.trim();
17
17
  url = url.replace(/^(?:https?|ssh|git):\/\//, "");
@@ -65,6 +65,19 @@ function getGitRemoteUrl(directory) {
65
65
  }
66
66
  }
67
67
  }
68
+ function getGitTopLevel(directory) {
69
+ try {
70
+ const root = execSync("git rev-parse --show-toplevel", {
71
+ cwd: directory,
72
+ encoding: "utf-8",
73
+ timeout: 5000,
74
+ stdio: ["pipe", "pipe", "pipe"]
75
+ }).trim();
76
+ return root || null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
68
81
  function readProjectConfigFile(directory) {
69
82
  const configPath = join(directory, ".engrm.json");
70
83
  if (!existsSync(configPath))
@@ -87,11 +100,12 @@ function detectProject(directory) {
87
100
  const remoteUrl = getGitRemoteUrl(directory);
88
101
  if (remoteUrl) {
89
102
  const canonicalId = normaliseGitRemoteUrl(remoteUrl);
103
+ const repoRoot = getGitTopLevel(directory) ?? directory;
90
104
  return {
91
105
  canonical_id: canonicalId,
92
106
  name: projectNameFromCanonicalId(canonicalId),
93
107
  remote_url: remoteUrl,
94
- local_path: directory
108
+ local_path: repoRoot
95
109
  };
96
110
  }
97
111
  const configFile = readProjectConfigFile(directory);
@@ -111,6 +125,32 @@ function detectProject(directory) {
111
125
  local_path: directory
112
126
  };
113
127
  }
128
+ function detectProjectForPath(filePath, fallbackCwd) {
129
+ const absolutePath = resolve(fallbackCwd ?? process.cwd(), filePath);
130
+ const candidateDir = existsSync(absolutePath) && !absolutePath.endsWith("/") ? dirname(absolutePath) : dirname(absolutePath);
131
+ const detected = detectProject(candidateDir);
132
+ if (detected.canonical_id.startsWith("local/"))
133
+ return null;
134
+ return detected;
135
+ }
136
+ function detectProjectFromTouchedPaths(paths, fallbackCwd) {
137
+ const counts = new Map;
138
+ for (const rawPath of paths) {
139
+ if (!rawPath || !rawPath.trim())
140
+ continue;
141
+ const detected = detectProjectForPath(rawPath, fallbackCwd);
142
+ if (!detected)
143
+ continue;
144
+ const existing = counts.get(detected.canonical_id);
145
+ if (existing) {
146
+ existing.count += 1;
147
+ } else {
148
+ counts.set(detected.canonical_id, { project: detected, count: 1 });
149
+ }
150
+ }
151
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count || a.project.name.localeCompare(b.project.name));
152
+ return ranked[0]?.project ?? detectProject(fallbackCwd);
153
+ }
114
154
 
115
155
  // src/capture/dedup.ts
116
156
  function tokenise(text) {
@@ -356,6 +396,32 @@ function computeObservationPriority(obs, nowEpoch) {
356
396
  }
357
397
 
358
398
  // src/context/inject.ts
399
+ function tokenizeProjectHint(text) {
400
+ return Array.from(new Set((text.toLowerCase().match(/[a-z0-9_+-]{4,}/g) ?? []).filter(Boolean)));
401
+ }
402
+ function isObservationRelatedToProject(obs, detected) {
403
+ const hints = new Set([
404
+ ...tokenizeProjectHint(detected.name),
405
+ ...tokenizeProjectHint(detected.canonical_id)
406
+ ]);
407
+ if (hints.size === 0)
408
+ return false;
409
+ const haystack = [
410
+ obs.title,
411
+ obs.narrative ?? "",
412
+ obs.facts ?? "",
413
+ obs.concepts ?? "",
414
+ obs.files_read ?? "",
415
+ obs.files_modified ?? "",
416
+ obs._source_project ?? ""
417
+ ].join(`
418
+ `).toLowerCase();
419
+ for (const hint of hints) {
420
+ if (haystack.includes(hint))
421
+ return true;
422
+ }
423
+ return false;
424
+ }
359
425
  function estimateTokens(text) {
360
426
  if (!text)
361
427
  return 0;
@@ -431,6 +497,9 @@ function buildSessionContext(db, cwd, options = {}) {
431
497
  }
432
498
  return { ...obs, _source_project: projectNameCache.get(obs.project_id) };
433
499
  });
500
+ if (isNewProject) {
501
+ crossProjectCandidates = crossProjectCandidates.filter((obs) => isObservationRelatedToProject(obs, detected));
502
+ }
434
503
  }
435
504
  const seenIds = new Set(pinned.map((o) => o.id));
436
505
  const dedupedRecent = recent.filter((o) => {
@@ -461,6 +530,7 @@ function buildSessionContext(db, cwd, options = {}) {
461
530
  const recentToolEvents2 = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
462
531
  const recentSessions2 = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
463
532
  const projectTypeCounts2 = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
533
+ const recentOutcomes2 = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
464
534
  return {
465
535
  project_name: projectName,
466
536
  canonical_id: canonicalId,
@@ -470,7 +540,8 @@ function buildSessionContext(db, cwd, options = {}) {
470
540
  recentPrompts: recentPrompts2.length > 0 ? recentPrompts2 : undefined,
471
541
  recentToolEvents: recentToolEvents2.length > 0 ? recentToolEvents2 : undefined,
472
542
  recentSessions: recentSessions2.length > 0 ? recentSessions2 : undefined,
473
- projectTypeCounts: projectTypeCounts2
543
+ projectTypeCounts: projectTypeCounts2,
544
+ recentOutcomes: recentOutcomes2
474
545
  };
475
546
  }
476
547
  let remainingBudget = tokenBudget - 30;
@@ -497,6 +568,7 @@ function buildSessionContext(db, cwd, options = {}) {
497
568
  const recentToolEvents = db.getRecentToolEvents(isNewProject ? null : projectId, isNewProject ? 8 : 6, opts.userId);
498
569
  const recentSessions = isNewProject ? [] : db.getRecentSessions(projectId, 5, opts.userId);
499
570
  const projectTypeCounts = isNewProject ? undefined : getProjectTypeCounts(db, projectId, opts.userId);
571
+ const recentOutcomes = isNewProject ? undefined : getRecentOutcomes(db, projectId, opts.userId);
500
572
  let securityFindings = [];
501
573
  if (!isNewProject) {
502
574
  try {
@@ -554,7 +626,8 @@ function buildSessionContext(db, cwd, options = {}) {
554
626
  recentPrompts: recentPrompts.length > 0 ? recentPrompts : undefined,
555
627
  recentToolEvents: recentToolEvents.length > 0 ? recentToolEvents : undefined,
556
628
  recentSessions: recentSessions.length > 0 ? recentSessions : undefined,
557
- projectTypeCounts
629
+ projectTypeCounts,
630
+ recentOutcomes
558
631
  };
559
632
  }
560
633
  function estimateObservationTokens(obs, index) {
@@ -591,12 +664,15 @@ function formatContextForInjection(context) {
591
664
  lines.push("");
592
665
  }
593
666
  if (context.recentPrompts && context.recentPrompts.length > 0) {
594
- lines.push("## Recent Requests");
595
- for (const prompt of context.recentPrompts.slice(0, 5)) {
596
- const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
597
- lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
667
+ const promptLines = context.recentPrompts.filter((prompt) => isMeaningfulPrompt(prompt.prompt)).slice(0, 5);
668
+ if (promptLines.length > 0) {
669
+ lines.push("## Recent Requests");
670
+ for (const prompt of promptLines) {
671
+ const label = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : new Date(prompt.created_at_epoch * 1000).toISOString().split("T")[0];
672
+ lines.push(`- ${label}: ${truncateText(prompt.prompt.replace(/\s+/g, " "), 160)}`);
673
+ }
674
+ lines.push("");
598
675
  }
599
- lines.push("");
600
676
  }
601
677
  if (context.recentToolEvents && context.recentToolEvents.length > 0) {
602
678
  lines.push("## Recent Tools");
@@ -606,10 +682,22 @@ function formatContextForInjection(context) {
606
682
  lines.push("");
607
683
  }
608
684
  if (context.recentSessions && context.recentSessions.length > 0) {
609
- lines.push("## Recent Sessions");
610
- for (const session of context.recentSessions.slice(0, 4)) {
611
- const summary = session.request ?? session.completed ?? "(no summary)";
612
- lines.push(`- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`);
685
+ const recentSessionLines = context.recentSessions.slice(0, 4).map((session) => {
686
+ const summary = chooseMeaningfulSessionHeadline(session.request, session.completed);
687
+ if (summary === "(no summary)")
688
+ return null;
689
+ return `- ${session.session_id}: ${truncateText(summary.replace(/\s+/g, " "), 140)} ` + `(prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
690
+ }).filter((line) => Boolean(line));
691
+ if (recentSessionLines.length > 0) {
692
+ lines.push("## Recent Sessions");
693
+ lines.push(...recentSessionLines);
694
+ lines.push("");
695
+ }
696
+ }
697
+ if (context.recentOutcomes && context.recentOutcomes.length > 0) {
698
+ lines.push("## Recent Outcomes");
699
+ for (const outcome of context.recentOutcomes.slice(0, 5)) {
700
+ lines.push(`- ${truncateText(outcome, 160)}`);
613
701
  }
614
702
  lines.push("");
615
703
  }
@@ -689,6 +777,14 @@ function formatSessionBrief(summary) {
689
777
  }
690
778
  return lines;
691
779
  }
780
+ function chooseMeaningfulSessionHeadline(request, completed) {
781
+ if (request && !looksLikeFileOperationTitle(request))
782
+ return request;
783
+ const completedItems = extractMeaningfulLines(completed, 1);
784
+ if (completedItems.length > 0)
785
+ return completedItems[0];
786
+ return request ?? completed ?? "(no summary)";
787
+ }
692
788
  function formatSummarySection(value, maxLen) {
693
789
  if (!value)
694
790
  return null;
@@ -713,6 +809,26 @@ function truncateText(text, maxLen) {
713
809
  return text;
714
810
  return text.slice(0, maxLen - 3) + "...";
715
811
  }
812
+ function isMeaningfulPrompt(value) {
813
+ if (!value)
814
+ return false;
815
+ const compact = value.replace(/\s+/g, " ").trim();
816
+ if (compact.length < 8)
817
+ return false;
818
+ return /[a-z]{3,}/i.test(compact);
819
+ }
820
+ function looksLikeFileOperationTitle(value) {
821
+ return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
822
+ }
823
+ function stripInlineSectionLabel(value) {
824
+ return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
825
+ }
826
+ function extractMeaningfulLines(value, limit) {
827
+ if (!value)
828
+ return [];
829
+ return value.split(`
830
+ `).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);
831
+ }
716
832
  function formatObservationDetailFromContext(obs) {
717
833
  if (obs.facts) {
718
834
  const bullets = parseFacts(obs.facts);
@@ -812,6 +928,50 @@ function getProjectTypeCounts(db, projectId, userId) {
812
928
  }
813
929
  return counts;
814
930
  }
931
+ function getRecentOutcomes(db, projectId, userId) {
932
+ const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
933
+ const visibilityParams = userId ? [userId] : [];
934
+ const summaries = db.db.query(`SELECT * FROM session_summaries
935
+ WHERE project_id = ?
936
+ ORDER BY created_at_epoch DESC
937
+ LIMIT 6`).all(projectId);
938
+ const picked = [];
939
+ const seen = new Set;
940
+ for (const summary of summaries) {
941
+ for (const line of [
942
+ ...extractMeaningfulLines(summary.completed, 2),
943
+ ...extractMeaningfulLines(summary.learned, 1)
944
+ ]) {
945
+ const normalized = line.toLowerCase().replace(/\s+/g, " ").trim();
946
+ if (!normalized || seen.has(normalized))
947
+ continue;
948
+ seen.add(normalized);
949
+ picked.push(line);
950
+ if (picked.length >= 5)
951
+ return picked;
952
+ }
953
+ }
954
+ const rows = db.db.query(`SELECT * FROM observations
955
+ WHERE project_id = ?
956
+ AND lifecycle IN ('active', 'aging', 'pinned')
957
+ AND superseded_by IS NULL
958
+ ${visibilityClause}
959
+ ORDER BY created_at_epoch DESC
960
+ LIMIT 20`).all(projectId, ...visibilityParams);
961
+ for (const obs of rows) {
962
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
963
+ continue;
964
+ const title = stripInlineSectionLabel(obs.title);
965
+ const normalized = title.toLowerCase().replace(/\s+/g, " ").trim();
966
+ if (!normalized || seen.has(normalized) || looksLikeFileOperationTitle(title))
967
+ continue;
968
+ seen.add(normalized);
969
+ picked.push(title);
970
+ if (picked.length >= 5)
971
+ break;
972
+ }
973
+ return picked;
974
+ }
815
975
 
816
976
  // src/telemetry/stack-detect.ts
817
977
  import { existsSync as existsSync2 } from "node:fs";
@@ -1003,7 +1163,7 @@ function computeAndSaveFingerprint(cwd) {
1003
1163
 
1004
1164
  // src/packs/recommender.ts
1005
1165
  import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "node:fs";
1006
- import { join as join4, basename as basename3, dirname } from "node:path";
1166
+ import { join as join4, basename as basename3, dirname as dirname2 } from "node:path";
1007
1167
  import { fileURLToPath } from "node:url";
1008
1168
  var STACK_PACK_MAP = {
1009
1169
  typescript: ["typescript-patterns"],
@@ -1015,7 +1175,7 @@ var STACK_PACK_MAP = {
1015
1175
  bun: ["node-security"]
1016
1176
  };
1017
1177
  function getPacksDir() {
1018
- const thisDir = dirname(fileURLToPath(import.meta.url));
1178
+ const thisDir = dirname2(fileURLToPath(import.meta.url));
1019
1179
  return join4(thisDir, "../../packs");
1020
1180
  }
1021
1181
  function listAvailablePacks() {
@@ -1420,10 +1580,15 @@ function mergeChanges(db, config, changes) {
1420
1580
  name: change.metadata?.project_name ?? projectCanonical.split("/").pop() ?? "unknown"
1421
1581
  });
1422
1582
  }
1583
+ const normalizedType = normalizeRemoteObservationType(change.metadata?.type, change.source_id);
1584
+ if (!normalizedType) {
1585
+ skipped++;
1586
+ continue;
1587
+ }
1423
1588
  const obs = db.insertObservation({
1424
1589
  session_id: change.metadata?.session_id ?? null,
1425
1590
  project_id: project.id,
1426
- type: change.metadata?.type ?? "discovery",
1591
+ type: normalizedType,
1427
1592
  title: change.metadata?.title ?? change.content.split(`
1428
1593
  `)[0] ?? "Untitled",
1429
1594
  narrative: extractNarrative(change.content),
@@ -1446,6 +1611,23 @@ function mergeChanges(db, config, changes) {
1446
1611
  }
1447
1612
  return { merged, skipped };
1448
1613
  }
1614
+ function normalizeRemoteObservationType(rawType, sourceId) {
1615
+ const type = typeof rawType === "string" ? rawType.trim().toLowerCase() : "";
1616
+ if (type === "bugfix" || type === "discovery" || type === "decision" || type === "pattern" || type === "change" || type === "feature" || type === "refactor" || type === "digest" || type === "standard" || type === "message") {
1617
+ return type;
1618
+ }
1619
+ if (type === "summary") {
1620
+ return "digest";
1621
+ }
1622
+ if (!type) {
1623
+ if (sourceId.includes("-summary-"))
1624
+ return "digest";
1625
+ if (sourceId.includes("-message-"))
1626
+ return "message";
1627
+ return "standard";
1628
+ }
1629
+ return "standard";
1630
+ }
1449
1631
  async function embedAndInsert(db, obs) {
1450
1632
  const text = composeEmbeddingText(obs);
1451
1633
  const embedding = await embedText(text);
@@ -2062,6 +2244,11 @@ import { createHash as createHash3 } from "node:crypto";
2062
2244
  var IS_BUN = typeof globalThis.Bun !== "undefined";
2063
2245
  function openDatabase(dbPath) {
2064
2246
  if (IS_BUN) {
2247
+ if (process.platform === "darwin") {
2248
+ try {
2249
+ return openNodeDatabase(dbPath);
2250
+ } catch {}
2251
+ }
2065
2252
  return openBunDatabase(dbPath);
2066
2253
  }
2067
2254
  return openNodeDatabase(dbPath);
@@ -2178,6 +2365,15 @@ class MemDatabase {
2178
2365
  }
2179
2366
  return row;
2180
2367
  }
2368
+ reassignObservationProject(observationId, projectId) {
2369
+ const existing = this.getObservationById(observationId);
2370
+ if (!existing)
2371
+ return false;
2372
+ if (existing.project_id === projectId)
2373
+ return true;
2374
+ this.db.query("UPDATE observations SET project_id = ? WHERE id = ?").run(projectId, observationId);
2375
+ return true;
2376
+ }
2181
2377
  getObservationById(id) {
2182
2378
  return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
2183
2379
  }
@@ -2311,8 +2507,13 @@ class MemDatabase {
2311
2507
  }
2312
2508
  upsertSession(sessionId, projectId, userId, deviceId, agent = "claude-code") {
2313
2509
  const existing = this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
2314
- if (existing)
2510
+ if (existing) {
2511
+ if (existing.project_id === null && projectId !== null) {
2512
+ this.db.query("UPDATE sessions SET project_id = ? WHERE session_id = ?").run(projectId, sessionId);
2513
+ return this.db.query("SELECT * FROM sessions WHERE session_id = ?").get(sessionId);
2514
+ }
2315
2515
  return existing;
2516
+ }
2316
2517
  const now = Math.floor(Date.now() / 1000);
2317
2518
  this.db.query(`INSERT INTO sessions (session_id, project_id, user_id, device_id, agent, started_at_epoch)
2318
2519
  VALUES (?, ?, ?, ?, ?, ?)`).run(sessionId, projectId, userId, deviceId, agent, now);
@@ -2792,7 +2993,7 @@ function formatSplashScreen(data) {
2792
2993
  const brief = formatVisibleStartupBrief(data.context);
2793
2994
  if (brief.length > 0) {
2794
2995
  lines.push("");
2795
- lines.push(` ${c2.bold}Startup brief${c2.reset}`);
2996
+ lines.push(` ${c2.bold}Startup context${c2.reset}`);
2796
2997
  for (const line of brief) {
2797
2998
  lines.push(` ${line}`);
2798
2999
  }
@@ -2803,15 +3004,25 @@ function formatSplashScreen(data) {
2803
3004
  }
2804
3005
  function formatVisibleStartupBrief(context) {
2805
3006
  const lines = [];
2806
- const latest = pickBestSummary(context);
3007
+ const latest = pickPrimarySummary(context);
2807
3008
  const observationFallbacks = buildObservationFallbacks(context);
2808
3009
  const promptFallback = buildPromptFallback(context);
3010
+ const promptLines = buildPromptLines(context);
3011
+ const latestPromptLine = promptLines[0] ?? null;
3012
+ const currentRequest = latest ? chooseRequest(latest.request, promptFallback ?? sessionFallbacksFromContext(context)[0] ?? observationFallbacks.request) : promptFallback;
2809
3013
  const toolFallbacks = buildToolFallbacks(context);
2810
- const sessionFallbacks = buildSessionFallbacks(context);
3014
+ const sessionFallbacks = sessionFallbacksFromContext(context);
3015
+ const recentOutcomeLines = buildRecentOutcomeLines(context, latest);
2811
3016
  const projectSignals = buildProjectSignalLine(context);
3017
+ if (promptLines.length > 0) {
3018
+ lines.push(`${c2.cyan}Recent Requests:${c2.reset}`);
3019
+ for (const item of promptLines) {
3020
+ lines.push(` - ${truncateInline(item, 160)}`);
3021
+ }
3022
+ }
2812
3023
  if (latest) {
2813
3024
  const sections = [
2814
- ["Request", chooseRequest(latest.request, promptFallback ?? observationFallbacks.request), 1],
3025
+ ["Request", currentRequest, 1],
2815
3026
  ["Investigated", chooseSection(latest.investigated, observationFallbacks.investigated, "Investigated"), 2],
2816
3027
  ["Learned", latest.learned, 2],
2817
3028
  ["Completed", chooseSection(latest.completed, observationFallbacks.completed, "Completed"), 2],
@@ -2826,58 +3037,131 @@ function formatVisibleStartupBrief(context) {
2826
3037
  }
2827
3038
  }
2828
3039
  }
2829
- } else if (promptFallback) {
2830
- lines.push(`${c2.cyan}Request:${c2.reset}`);
2831
- lines.push(` - ${truncateInline(promptFallback, 140)}`);
3040
+ } else if (currentRequest && !duplicatesPromptLine(currentRequest, latestPromptLine)) {
3041
+ lines.push(`${c2.cyan}Current Request:${c2.reset}`);
3042
+ lines.push(` - ${truncateInline(currentRequest, 160)}`);
2832
3043
  if (toolFallbacks.length > 0) {
2833
3044
  lines.push(`${c2.cyan}Recent Tools:${c2.reset}`);
2834
3045
  for (const item of toolFallbacks) {
2835
- lines.push(` - ${truncateInline(item, 140)}`);
3046
+ lines.push(` - ${truncateInline(item, 160)}`);
2836
3047
  }
2837
3048
  }
2838
3049
  }
3050
+ if (latest && currentRequest && !hasRequestSection(lines) && !duplicatesPromptLine(currentRequest, latestPromptLine)) {
3051
+ lines.push(`${c2.cyan}Current Request:${c2.reset}`);
3052
+ lines.push(` - ${truncateInline(currentRequest, 160)}`);
3053
+ }
3054
+ if (recentOutcomeLines.length > 0) {
3055
+ lines.push(`${c2.cyan}Recent Work:${c2.reset}`);
3056
+ for (const item of recentOutcomeLines) {
3057
+ lines.push(` - ${truncateInline(item, 160)}`);
3058
+ }
3059
+ }
3060
+ if (toolFallbacks.length > 0 && latest) {
3061
+ lines.push(`${c2.cyan}Recent Tools:${c2.reset}`);
3062
+ for (const item of toolFallbacks) {
3063
+ lines.push(` - ${truncateInline(item, 160)}`);
3064
+ }
3065
+ }
2839
3066
  if (sessionFallbacks.length > 0) {
2840
3067
  lines.push(`${c2.cyan}Recent Sessions:${c2.reset}`);
2841
3068
  for (const item of sessionFallbacks) {
2842
- lines.push(` - ${truncateInline(item, 140)}`);
3069
+ lines.push(` - ${truncateInline(item, 160)}`);
2843
3070
  }
2844
3071
  }
2845
3072
  if (projectSignals) {
2846
3073
  lines.push(`${c2.cyan}Project Signals:${c2.reset}`);
2847
- lines.push(` - ${truncateInline(projectSignals, 140)}`);
3074
+ lines.push(` - ${truncateInline(projectSignals, 160)}`);
2848
3075
  }
2849
3076
  const stale = pickRelevantStaleDecision(context, latest);
2850
3077
  if (stale) {
2851
3078
  lines.push(`${c2.yellow}Watch:${c2.reset} ${truncateInline(`Decision still looks unfinished: ${stale.title}`, 170)}`);
2852
3079
  }
2853
3080
  if (lines.length === 0 && context.observations.length > 0) {
2854
- const top = context.observations.filter((obs) => obs.type !== "digest").filter((obs) => !looksLikeFileOperationTitle(obs.title)).slice(0, 2);
3081
+ const top = context.observations.filter((obs) => obs.type !== "digest").filter((obs) => obs.type !== "decision").filter((obs) => !looksLikeFileOperationTitle2(obs.title)).slice(0, 3);
2855
3082
  for (const obs of top) {
2856
3083
  lines.push(`${c2.cyan}${capitalize(obs.type)}:${c2.reset} ${truncateInline(obs.title, 170)}`);
2857
3084
  }
2858
3085
  }
2859
- return lines.slice(0, 10);
3086
+ return lines.slice(0, 14);
2860
3087
  }
2861
3088
  function buildPromptFallback(context) {
2862
- const latest = context.recentPrompts?.[0];
3089
+ const latest = (context.recentPrompts ?? []).find((prompt) => isMeaningfulPrompt2(prompt.prompt));
2863
3090
  if (!latest?.prompt)
2864
3091
  return null;
2865
3092
  return latest.prompt.replace(/\s+/g, " ").trim();
2866
3093
  }
3094
+ function buildPromptLines(context) {
3095
+ return (context.recentPrompts ?? []).filter((prompt) => isMeaningfulPrompt2(prompt.prompt)).slice(0, 2).map((prompt) => {
3096
+ const prefix = prompt.prompt_number > 0 ? `#${prompt.prompt_number}` : "request";
3097
+ return `${prefix}: ${prompt.prompt.replace(/\s+/g, " ").trim()}`;
3098
+ }).filter((item) => item.length > 0);
3099
+ }
3100
+ function duplicatesPromptLine(request, promptLine) {
3101
+ if (!request || !promptLine)
3102
+ return false;
3103
+ const promptBody = promptLine.replace(/^#?\d+:\s*/, "").trim();
3104
+ return normalizeStartupItem(request) === normalizeStartupItem(promptBody);
3105
+ }
2867
3106
  function buildToolFallbacks(context) {
2868
3107
  return (context.recentToolEvents ?? []).slice(0, 3).map((tool) => {
2869
3108
  const detail = tool.file_path ?? tool.command ?? tool.tool_response_preview ?? "";
2870
- return `${tool.tool_name}: ${detail}`.trim();
3109
+ return `${tool.tool_name}${detail ? `: ${detail}` : ""}`.trim();
2871
3110
  }).filter((item) => item.length > 0);
2872
3111
  }
2873
- function buildSessionFallbacks(context) {
3112
+ function sessionFallbacksFromContext(context) {
2874
3113
  return (context.recentSessions ?? []).slice(0, 2).map((session) => {
2875
- const summary = session.request ?? session.completed ?? "";
3114
+ const summary = chooseMeaningfulSessionSummary(session.request, session.completed);
2876
3115
  if (!summary)
2877
3116
  return "";
2878
- return `${session.session_id}: ${summary}`;
3117
+ return `${session.session_id}: ${summary} (prompts ${session.prompt_count}, tools ${session.tool_event_count}, obs ${session.observation_count})`;
2879
3118
  }).filter((item) => item.length > 0);
2880
3119
  }
3120
+ function buildRecentOutcomeLines(context, summary) {
3121
+ const picked = [];
3122
+ const seen = new Set;
3123
+ const push = (value) => {
3124
+ for (const line of toSplashLines(value ?? null, 2)) {
3125
+ const normalized = normalizeStartupItem(line);
3126
+ if (!normalized || seen.has(normalized))
3127
+ continue;
3128
+ seen.add(normalized);
3129
+ picked.push(line.replace(/^-\s*/, ""));
3130
+ if (picked.length >= 2)
3131
+ return;
3132
+ }
3133
+ };
3134
+ push(summary?.completed);
3135
+ push(summary?.learned);
3136
+ if (picked.length < 2) {
3137
+ for (const obs of context.observations) {
3138
+ if (!["bugfix", "feature", "refactor", "change", "decision"].includes(obs.type))
3139
+ continue;
3140
+ const title = stripInlineSectionLabel2(obs.title);
3141
+ if (!title || looksLikeFileOperationTitle2(title))
3142
+ continue;
3143
+ const normalized = normalizeStartupItem(title);
3144
+ if (!normalized || seen.has(normalized))
3145
+ continue;
3146
+ seen.add(normalized);
3147
+ picked.push(title);
3148
+ if (picked.length >= 2)
3149
+ break;
3150
+ }
3151
+ }
3152
+ return picked;
3153
+ }
3154
+ function chooseMeaningfulSessionSummary(request, completed) {
3155
+ if (request && !looksLikeFileOperationTitle2(request))
3156
+ return request;
3157
+ if (completed) {
3158
+ const lines = completed.split(`
3159
+ `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean).map((line) => stripInlineSectionLabel2(line)).filter((line) => !looksLikeFileOperationTitle2(line));
3160
+ if (lines.length > 0)
3161
+ return lines[0] ?? null;
3162
+ }
3163
+ return request ?? completed ?? null;
3164
+ }
2881
3165
  function buildProjectSignalLine(context) {
2882
3166
  if (!context.projectTypeCounts)
2883
3167
  return null;
@@ -2888,29 +3172,26 @@ function toSplashLines(value, maxItems) {
2888
3172
  if (!value)
2889
3173
  return [];
2890
3174
  const lines = value.split(`
2891
- `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "")).map((line) => stripInlineSectionLabel(line)).map((line) => dedupeFragments(line)).filter(Boolean).sort((a, b) => scoreSplashLine(b) - scoreSplashLine(a)).slice(0, maxItems).map((line) => `- ${truncateInline(line, 140)}`);
3175
+ `).map((line) => line.trim()).filter(Boolean).map((line) => line.replace(/^[-*]\s*/, "")).map((line) => stripInlineSectionLabel2(line)).map((line) => dedupeFragments(line)).filter(Boolean).sort((a, b) => scoreSplashLine(b) - scoreSplashLine(a)).slice(0, maxItems).map((line) => `- ${truncateInline(line, 140)}`);
2892
3176
  return dedupeFragmentsInLines(lines);
2893
3177
  }
2894
- function pickBestSummary(context) {
3178
+ function pickPrimarySummary(context) {
2895
3179
  const summaries = context.summaries || [];
2896
3180
  if (!summaries.length)
2897
3181
  return null;
2898
- return [...summaries].sort((a, b) => scoreSummary(b) - scoreSummary(a))[0] ?? null;
3182
+ const meaningfulRecent = summaries.find((summary) => {
3183
+ const request = summary.request?.trim();
3184
+ const learned = summary.learned?.trim();
3185
+ const completed = summary.completed?.trim();
3186
+ return Boolean(request && !looksLikeFileOperationTitle2(request) || learned || hasMeaningfulCompleted(completed));
3187
+ });
3188
+ return meaningfulRecent ?? summaries[0] ?? null;
2899
3189
  }
2900
- function scoreSummary(summary) {
2901
- let score = 0;
2902
- if (summary.request)
2903
- score += 3;
2904
- if (summary.investigated)
2905
- score += 4;
2906
- if (summary.learned)
2907
- score += 5;
2908
- if (summary.completed)
2909
- score += 5;
2910
- if (summary.next_steps)
2911
- score += 4;
2912
- score += Math.min(4, sectionItemCount(summary.completed) + sectionItemCount(summary.learned));
2913
- return score;
3190
+ function hasMeaningfulCompleted(value) {
3191
+ if (!value)
3192
+ return false;
3193
+ return value.split(`
3194
+ `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean).some((line) => !looksLikeFileOperationTitle2(stripInlineSectionLabel2(line)));
2914
3195
  }
2915
3196
  function sectionItemCount(value) {
2916
3197
  if (!value)
@@ -2935,7 +3216,7 @@ function dedupeFragmentsInLines(lines) {
2935
3216
  const seen = new Set;
2936
3217
  const deduped = [];
2937
3218
  for (const line of lines) {
2938
- const normalized = stripInlineSectionLabel(line).toLowerCase().replace(/\s+/g, " ").trim();
3219
+ const normalized = stripInlineSectionLabel2(line).toLowerCase().replace(/\s+/g, " ").trim();
2939
3220
  if (!normalized || seen.has(normalized))
2940
3221
  continue;
2941
3222
  seen.add(normalized);
@@ -2943,8 +3224,22 @@ function dedupeFragmentsInLines(lines) {
2943
3224
  }
2944
3225
  return deduped;
2945
3226
  }
3227
+ function hasRequestSection(lines) {
3228
+ return lines.some((line) => line.includes("Request:"));
3229
+ }
3230
+ function normalizeStartupItem(value) {
3231
+ return stripInlineSectionLabel2(value).replace(/^#?\d+:\s*/, "").replace(/^-\s*/, "").toLowerCase().replace(/\s+/g, " ").trim();
3232
+ }
3233
+ function isMeaningfulPrompt2(value) {
3234
+ if (!value)
3235
+ return false;
3236
+ const compact = value.replace(/\s+/g, " ").trim();
3237
+ if (compact.length < 8)
3238
+ return false;
3239
+ return /[a-z]{3,}/i.test(compact);
3240
+ }
2946
3241
  function chooseRequest(primary, fallback) {
2947
- if (primary && !looksLikeFileOperationTitle(primary))
3242
+ if (primary && !looksLikeFileOperationTitle2(primary))
2948
3243
  return primary;
2949
3244
  return fallback;
2950
3245
  }
@@ -2962,15 +3257,15 @@ function isWeakCompletedSection(value) {
2962
3257
  `).map((line) => line.trim().replace(/^[-*]\s*/, "")).filter(Boolean);
2963
3258
  if (!items.length)
2964
3259
  return true;
2965
- const weakCount = items.filter((item) => looksLikeFileOperationTitle(item)).length;
3260
+ const weakCount = items.filter((item) => looksLikeFileOperationTitle2(item)).length;
2966
3261
  return weakCount === items.length;
2967
3262
  }
2968
- function looksLikeFileOperationTitle(value) {
3263
+ function looksLikeFileOperationTitle2(value) {
2969
3264
  return /^(modified|updated|edited|touched|changed|extended|refactored|redesigned)\s+[A-Za-z0-9_.\-\/]+(?:\s*\([^)]*\))?$/i.test(value.trim());
2970
3265
  }
2971
3266
  function scoreSplashLine(value) {
2972
3267
  let score = 0;
2973
- if (!looksLikeFileOperationTitle(value))
3268
+ if (!looksLikeFileOperationTitle2(value))
2974
3269
  score += 2;
2975
3270
  if (/[:;]/.test(value))
2976
3271
  score += 1;
@@ -2979,9 +3274,9 @@ function scoreSplashLine(value) {
2979
3274
  return score;
2980
3275
  }
2981
3276
  function buildObservationFallbacks(context) {
2982
- const request = context.observations.find((obs) => !looksLikeFileOperationTitle(obs.title))?.title ?? null;
3277
+ const request = context.observations.find((obs) => obs.type !== "decision" && !looksLikeFileOperationTitle2(obs.title))?.title ?? null;
2983
3278
  const investigated = collectObservationTitles(context, (obs) => obs.type === "discovery", 2);
2984
- const completed = collectObservationTitles(context, (obs) => ["feature", "refactor", "change"].includes(obs.type) && !looksLikeFileOperationTitle(obs.title), 2);
3279
+ const completed = collectObservationTitles(context, (obs) => ["bugfix", "feature", "refactor", "change"].includes(obs.type) && !looksLikeFileOperationTitle2(obs.title), 2);
2985
3280
  return {
2986
3281
  request,
2987
3282
  investigated,
@@ -2994,18 +3289,18 @@ function collectObservationTitles(context, predicate, limit) {
2994
3289
  for (const obs of context.observations) {
2995
3290
  if (!predicate(obs))
2996
3291
  continue;
2997
- const normalized = stripInlineSectionLabel(obs.title).toLowerCase().replace(/\s+/g, " ").trim();
3292
+ const normalized = stripInlineSectionLabel2(obs.title).toLowerCase().replace(/\s+/g, " ").trim();
2998
3293
  if (!normalized || seen.has(normalized))
2999
3294
  continue;
3000
3295
  seen.add(normalized);
3001
- picked.push(`- ${stripInlineSectionLabel(obs.title)}`);
3296
+ picked.push(`- ${stripInlineSectionLabel2(obs.title)}`);
3002
3297
  if (picked.length >= limit)
3003
3298
  break;
3004
3299
  }
3005
3300
  return picked.length ? picked.join(`
3006
3301
  `) : null;
3007
3302
  }
3008
- function stripInlineSectionLabel(value) {
3303
+ function stripInlineSectionLabel2(value) {
3009
3304
  return value.replace(/^(request|investigated|learned|completed|next steps|digest|summary):\s*/i, "").trim();
3010
3305
  }
3011
3306
  function pickRelevantStaleDecision(context, summary) {
@@ -3094,4 +3389,10 @@ function capitalize(value) {
3094
3389
  return value;
3095
3390
  return value.charAt(0).toUpperCase() + value.slice(1);
3096
3391
  }
3392
+ var __testables = {
3393
+ formatVisibleStartupBrief
3394
+ };
3097
3395
  runHook("session-start", main);
3396
+ export {
3397
+ __testables
3398
+ };