recallnest 1.4.1

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.
Files changed (261) hide show
  1. package/.claude-plugin/CLAUDE.md +29 -0
  2. package/.claude-plugin/marketplace.json +18 -0
  3. package/.env.example +3 -0
  4. package/.github/workflows/ci.yml +28 -0
  5. package/.mcp.json +9 -0
  6. package/CHANGELOG.md +51 -0
  7. package/CLAUDE.md +42 -0
  8. package/DEMO.md +94 -0
  9. package/EVAL.md +89 -0
  10. package/FAILURES.md +75 -0
  11. package/LICENSE +21 -0
  12. package/OPERATIONS.md +59 -0
  13. package/README.md +427 -0
  14. package/README_CN.md +425 -0
  15. package/ROADMAP.md +198 -0
  16. package/assets/recallnest-workbench.png +0 -0
  17. package/assets/ui/app.js +807 -0
  18. package/assets/ui/index.html +154 -0
  19. package/assets/ui/styles.css +605 -0
  20. package/assets/wechat-qr.jpg +0 -0
  21. package/config.json.example +38 -0
  22. package/daily-recall.sh +87 -0
  23. package/daily-reflections/2026-03-05.md +15 -0
  24. package/daily-reflections/2026-03-06.md +15 -0
  25. package/daily-reflections/2026-03-07.md +7 -0
  26. package/daily-reflections/2026-03-08.md +34 -0
  27. package/daily-reflections/2026-03-09.md +35 -0
  28. package/daily-reflections/2026-03-10.md +36 -0
  29. package/daily-reflections/2026-03-11.md +35 -0
  30. package/daily-reflections/2026-03-12.md +36 -0
  31. package/daily-reflections/2026-03-13.md +35 -0
  32. package/daily-reflections/2026-03-14.md +35 -0
  33. package/daily-reflections/2026-03-15.md +35 -0
  34. package/daily-reflections/2026-03-16.md +41 -0
  35. package/daily-reflections/2026-03-17.md +10 -0
  36. package/daily-reflections/2026-03-18.md +10 -0
  37. package/daily-reflections/2026-03-19.md +10 -0
  38. package/daily-reflections/2026-03-20.md +10 -0
  39. package/daily-reflections/2026-03-21.md +10 -0
  40. package/daily-reflections/2026-03-22.md +10 -0
  41. package/daily-reflections/2026-03-23.md +10 -0
  42. package/daily-reflections/2026-03-24.md +10 -0
  43. package/daily-reflections/2026-03-25.md +32 -0
  44. package/daily-reflections/2026-03-26.md +37 -0
  45. package/daily-reflections/2026-03-27.md +35 -0
  46. package/daily-reflections/2026-03-28.md +35 -0
  47. package/daily-reflections/2026-03-29.md +35 -0
  48. package/daily-reflections/2026-03-30.md +36 -0
  49. package/daily-reflections/2026-03-31.md +35 -0
  50. package/daily-reflections/2026-04-01.md +35 -0
  51. package/docs/api-reference.md +1291 -0
  52. package/docs/architecture.md +168 -0
  53. package/docs/memory-boundary-contract.md +226 -0
  54. package/docs/memory-categories.md +119 -0
  55. package/docs/pipeline-audit-ultramemory.md +177 -0
  56. package/docs/plans/2026-03-08-query-expansion.md +270 -0
  57. package/docs/plans/2026-03-16-continuity-layer.md +196 -0
  58. package/docs/plans/mcp-tiering-plan.md +126 -0
  59. package/docs/research/recall-quality-audit-2026-03-22.md +153 -0
  60. package/docs/self-evolution.md +198 -0
  61. package/docs/superpowers/plans/2026-03-19-ingest-pipeline-upgrade.md +782 -0
  62. package/docs/superpowers/specs/2026-03-19-ingest-pipeline-upgrade-design.md +77 -0
  63. package/docs/superpowers/specs/2026-03-23-pull-claude-desktop-design.md +95 -0
  64. package/eval/cases.json +85 -0
  65. package/eval/continuity/case-seeds.json +90 -0
  66. package/eval/continuity/cases.json +302 -0
  67. package/eval/continuity/memory-seeds.json +26 -0
  68. package/eval/continuity/pattern-seeds.json +100 -0
  69. package/eval/continuity/results-2026-03-16.md +117 -0
  70. package/eval/continuity/results-2026-03-19.md +453 -0
  71. package/eval/continuity/runbook.md +110 -0
  72. package/eval/continuity/seed-cases.md +54 -0
  73. package/eval/reports/2026-03-06-abstract-query-baseline.md +96 -0
  74. package/eval/reports/2026-03-06-aws-ssh-baseline.md +82 -0
  75. package/eval/reports/2026-03-06-baseline.md +82 -0
  76. package/eval/reports/query-expansion.md +138 -0
  77. package/integrations/README.md +57 -0
  78. package/integrations/claude-code/README.md +133 -0
  79. package/integrations/claude-code/claude-md-snippet.md +31 -0
  80. package/integrations/claude-code/setup.sh +66 -0
  81. package/integrations/claude-code/smoke-continuity.sh +452 -0
  82. package/integrations/codex/README.md +49 -0
  83. package/integrations/codex/agents-md-snippet.md +31 -0
  84. package/integrations/codex/setup.sh +58 -0
  85. package/integrations/examples/claude-agent-sdk/memory-agent.ts +182 -0
  86. package/integrations/examples/langchain/memory-chain.py +111 -0
  87. package/integrations/examples/openai-agents-sdk/memory-agent.py +109 -0
  88. package/integrations/gemini-cli/README.md +57 -0
  89. package/integrations/gemini-cli/gemini-md-snippet.md +31 -0
  90. package/integrations/gemini-cli/setup.sh +64 -0
  91. package/integrations/shared/install-managed-block.sh +51 -0
  92. package/lm +19 -0
  93. package/package.json +50 -0
  94. package/plan-v1.2-distributable.md +105 -0
  95. package/scripts/archive-distilled-junk.ts +116 -0
  96. package/scripts/backfill-l0l1l2.ts +98 -0
  97. package/scripts/blind-scan.ts +588 -0
  98. package/scripts/cleanup-step1-exact-dups.ts +117 -0
  99. package/scripts/cleanup-step2-template-dups.ts +113 -0
  100. package/scripts/cleanup-step3-stale-content.ts +149 -0
  101. package/scripts/cleanup-step4-orphan-scopes.ts +131 -0
  102. package/scripts/delete-distilled-junk.ts +88 -0
  103. package/scripts/distill-facts.ts +305 -0
  104. package/scripts/health-check.ts +343 -0
  105. package/scripts/incremental-ingest.sh +71 -0
  106. package/scripts/mlp-health-check.ts +382 -0
  107. package/scripts/pull-claude-desktop.ts +515 -0
  108. package/scripts/re-ingest-cc.sh +66 -0
  109. package/scripts/rebalance-memory-health.ts +181 -0
  110. package/scripts/run-desktop-ingest.sh +11 -0
  111. package/scripts/start-server.sh +12 -0
  112. package/scripts/viz-dashboard.html +423 -0
  113. package/scripts/viz-export.ts +703 -0
  114. package/src/__tests__/access-tracker-novelty.test.ts +228 -0
  115. package/src/__tests__/auto-consolidation.test.ts +168 -0
  116. package/src/__tests__/auto-recall.test.ts +131 -0
  117. package/src/__tests__/batch-dedup.test.ts +65 -0
  118. package/src/__tests__/capture-engine.test.ts +1142 -0
  119. package/src/__tests__/capture-heuristic.test.ts +171 -0
  120. package/src/__tests__/cascade-forget.test.ts +210 -0
  121. package/src/__tests__/category-min-scores.test.ts +33 -0
  122. package/src/__tests__/confidence-tracker.test.ts +196 -0
  123. package/src/__tests__/conflict-advisor.test.ts +206 -0
  124. package/src/__tests__/conflict-escalation.test.ts +149 -0
  125. package/src/__tests__/conflict-lifecycle.test.ts +83 -0
  126. package/src/__tests__/conflict-output.test.ts +85 -0
  127. package/src/__tests__/conflict-store.test.ts +374 -0
  128. package/src/__tests__/consolidation-engine.test.ts +181 -0
  129. package/src/__tests__/context-composer-pins.test.ts +64 -0
  130. package/src/__tests__/context-composer-task-selection.test.ts +24 -0
  131. package/src/__tests__/context-renderer.test.ts +91 -0
  132. package/src/__tests__/continuity-eval.test.ts +275 -0
  133. package/src/__tests__/continuity-schema.test.ts +97 -0
  134. package/src/__tests__/core-recall-eval.test.ts +235 -0
  135. package/src/__tests__/core-summary.test.ts +109 -0
  136. package/src/__tests__/dedup-actions.test.ts +58 -0
  137. package/src/__tests__/deterministic-id.test.ts +39 -0
  138. package/src/__tests__/doctor.test.ts +303 -0
  139. package/src/__tests__/drill-down.test.ts +108 -0
  140. package/src/__tests__/embedder.test.ts +122 -0
  141. package/src/__tests__/entity-resolver.test.ts +58 -0
  142. package/src/__tests__/event-segmenter.test.ts +153 -0
  143. package/src/__tests__/hotness-blend.test.ts +76 -0
  144. package/src/__tests__/ingest.test.ts +360 -0
  145. package/src/__tests__/kg-extractor.test.ts +184 -0
  146. package/src/__tests__/kg-store.test.ts +198 -0
  147. package/src/__tests__/llm-consolidation.test.ts +226 -0
  148. package/src/__tests__/memory-boundaries.test.ts +237 -0
  149. package/src/__tests__/memory-health-rebalance.test.ts +144 -0
  150. package/src/__tests__/memory-output.test.ts +215 -0
  151. package/src/__tests__/multi-vector.test.ts +242 -0
  152. package/src/__tests__/ppr-traversal.test.ts +256 -0
  153. package/src/__tests__/preference-guard-smoke.test.ts +211 -0
  154. package/src/__tests__/preference-matcher.test.ts +230 -0
  155. package/src/__tests__/preference-slots.test.ts +106 -0
  156. package/src/__tests__/prospective-memory.test.ts +283 -0
  157. package/src/__tests__/query-entity-detector.test.ts +171 -0
  158. package/src/__tests__/query-expander.test.ts +34 -0
  159. package/src/__tests__/recall-mode.test.ts +64 -0
  160. package/src/__tests__/result-synthesizer.test.ts +198 -0
  161. package/src/__tests__/resume-context.test.ts +4743 -0
  162. package/src/__tests__/retrieval-trace.test.ts +213 -0
  163. package/src/__tests__/retriever-boundary-weight.test.ts +136 -0
  164. package/src/__tests__/retroactive-boost.test.ts +223 -0
  165. package/src/__tests__/scope-inventory.test.ts +200 -0
  166. package/src/__tests__/scope-policy.test.ts +69 -0
  167. package/src/__tests__/session-checkpoint.test.ts +259 -0
  168. package/src/__tests__/session-gc.test.ts +215 -0
  169. package/src/__tests__/store-scope-audit.test.ts +81 -0
  170. package/src/__tests__/temporal-parser.test.ts +241 -0
  171. package/src/__tests__/term-registry.test.ts +121 -0
  172. package/src/__tests__/tool-output-compressor.test.ts +133 -0
  173. package/src/__tests__/version-manager.test.ts +213 -0
  174. package/src/__tests__/workflow-observation.test.ts +400 -0
  175. package/src/access-tracker.ts +316 -0
  176. package/src/adaptive-retrieval.ts +64 -0
  177. package/src/api-server.ts +667 -0
  178. package/src/asset-sync.ts +79 -0
  179. package/src/auto-consolidation.ts +133 -0
  180. package/src/auto-gc.ts +124 -0
  181. package/src/auto-recall.ts +95 -0
  182. package/src/capture-engine.ts +931 -0
  183. package/src/capture-heuristic.ts +177 -0
  184. package/src/cascade-forget.ts +135 -0
  185. package/src/chunker.ts +253 -0
  186. package/src/cli.ts +1917 -0
  187. package/src/confidence-tracker.ts +166 -0
  188. package/src/conflict-advisor.ts +433 -0
  189. package/src/conflict-engine.ts +181 -0
  190. package/src/conflict-escalation.ts +138 -0
  191. package/src/conflict-lifecycle.ts +67 -0
  192. package/src/conflict-output.ts +218 -0
  193. package/src/conflict-schema.ts +130 -0
  194. package/src/conflict-store.ts +127 -0
  195. package/src/consolidation-engine.ts +256 -0
  196. package/src/context-composer-pins.ts +183 -0
  197. package/src/context-composer-queries.ts +121 -0
  198. package/src/context-composer-scope.ts +56 -0
  199. package/src/context-composer-stable-selection.ts +200 -0
  200. package/src/context-composer-stable.ts +217 -0
  201. package/src/context-composer-task-fallbacks.ts +36 -0
  202. package/src/context-composer-task-ranking.ts +197 -0
  203. package/src/context-composer-task-results.ts +137 -0
  204. package/src/context-composer-task-selection.ts +342 -0
  205. package/src/context-composer-text.ts +57 -0
  206. package/src/context-composer.ts +300 -0
  207. package/src/context-renderer.ts +179 -0
  208. package/src/decay-engine.ts +189 -0
  209. package/src/doctor.ts +543 -0
  210. package/src/embedder.ts +557 -0
  211. package/src/entity-resolver.ts +131 -0
  212. package/src/eval.ts +632 -0
  213. package/src/event-segmenter.ts +216 -0
  214. package/src/ingest.ts +1822 -0
  215. package/src/kg-extractor.ts +217 -0
  216. package/src/kg-store.ts +353 -0
  217. package/src/legacy-scope-review.ts +53 -0
  218. package/src/llm-client.ts +629 -0
  219. package/src/llm-consolidation.ts +168 -0
  220. package/src/mcp-server.ts +1337 -0
  221. package/src/memory-assets.ts +475 -0
  222. package/src/memory-boundaries.ts +354 -0
  223. package/src/memory-health-rebalance.ts +220 -0
  224. package/src/memory-output.ts +474 -0
  225. package/src/memory-schema.ts +184 -0
  226. package/src/multi-vector.ts +234 -0
  227. package/src/noise-filter.ts +155 -0
  228. package/src/ppr-traversal.ts +279 -0
  229. package/src/preference-matcher.ts +174 -0
  230. package/src/preference-slots.ts +259 -0
  231. package/src/prospective-memory.ts +198 -0
  232. package/src/query-entity-detector.ts +207 -0
  233. package/src/query-expander.ts +98 -0
  234. package/src/result-synthesizer.ts +114 -0
  235. package/src/retrieval-profiles.ts +101 -0
  236. package/src/retrieval-trace.ts +94 -0
  237. package/src/retriever.ts +1338 -0
  238. package/src/retroactive-boost.ts +154 -0
  239. package/src/rif.ts +73 -0
  240. package/src/runtime-config.ts +172 -0
  241. package/src/schema-utils.ts +56 -0
  242. package/src/scope-inventory.ts +310 -0
  243. package/src/scope-policy.ts +142 -0
  244. package/src/session-engine.ts +168 -0
  245. package/src/session-output.ts +96 -0
  246. package/src/session-schema.ts +60 -0
  247. package/src/session-store.ts +293 -0
  248. package/src/stderr-log.ts +16 -0
  249. package/src/store.ts +903 -0
  250. package/src/temporal-parser.ts +318 -0
  251. package/src/term-registry.ts +791 -0
  252. package/src/tool-output-compressor.ts +115 -0
  253. package/src/tracker.ts +57 -0
  254. package/src/ui-server.ts +430 -0
  255. package/src/version-manager.ts +172 -0
  256. package/src/workflow-observation-engine.ts +316 -0
  257. package/src/workflow-observation-managed.ts +83 -0
  258. package/src/workflow-observation-output.ts +86 -0
  259. package/src/workflow-observation-schema.ts +87 -0
  260. package/src/workflow-observation-scope-review.ts +409 -0
  261. package/src/workflow-observation-store.ts +69 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Version Manager — Tier 3.3
3
+ *
4
+ * Replaces the "archive loser" SUPERSEDE model with version coexistence:
5
+ * - Entries that would have been merged are instead grouped by version_group
6
+ * - Each version has a rank (confidence × log1p(accessCount + 1))
7
+ * - During retrieval, only the top-ranked version per group is returned
8
+ * - All versions remain in DB for history/audit
9
+ *
10
+ * Brain science inspiration: Imprint Competition — competing memory traces
11
+ * can coexist; the stronger one dominates recall but the weaker remains
12
+ * available for re-evaluation.
13
+ */
14
+
15
+ import type { MemoryEntry, MemoryStore } from "./store.js";
16
+ import { logInfo } from "./stderr-log.js";
17
+
18
+ // ============================================================================
19
+ // Types
20
+ // ============================================================================
21
+
22
+ export interface VersionGroupMetadata {
23
+ /** Shared UUID for all versions in this group */
24
+ version_group: string;
25
+ /** Rank within group (higher = preferred for retrieval) */
26
+ version_rank: number;
27
+ /** When this version was created/grouped */
28
+ version_created: string;
29
+ }
30
+
31
+ // ============================================================================
32
+ // Core Functions
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Create a version group from two entries that would otherwise be merged.
37
+ * Instead of archiving the weaker one, both get tagged with the same group.
38
+ *
39
+ * @param store Memory store
40
+ * @param canonical The stronger entry (higher canonicalScore)
41
+ * @param member The weaker entry
42
+ * @param scope Scope filter for updates
43
+ * @returns The version_group ID
44
+ */
45
+ export async function createVersionGroup(
46
+ store: MemoryStore,
47
+ canonical: MemoryEntry,
48
+ member: MemoryEntry,
49
+ scope: string,
50
+ ): Promise<string> {
51
+ // Check if canonical already has a version_group
52
+ const canonMeta = parseMetadata(canonical.metadata);
53
+ const existingGroup = typeof canonMeta.version_group === "string" ? canonMeta.version_group : null;
54
+ const groupId = existingGroup ?? generateGroupId();
55
+ const now = new Date().toISOString();
56
+
57
+ // Update canonical
58
+ const canonRank = computeVersionRank(canonical);
59
+ canonMeta.version_group = groupId;
60
+ canonMeta.version_rank = canonRank;
61
+ if (!canonMeta.version_created) canonMeta.version_created = now;
62
+ await store.update(canonical.id, { metadata: JSON.stringify(canonMeta) }, [scope]);
63
+
64
+ // Update member
65
+ const memberMeta = parseMetadata(member.metadata);
66
+ const memberRank = computeVersionRank(member);
67
+ memberMeta.version_group = groupId;
68
+ memberMeta.version_rank = memberRank;
69
+ memberMeta.version_created = now;
70
+ await store.update(member.id, { metadata: JSON.stringify(memberMeta) }, [scope]);
71
+
72
+ logInfo(
73
+ `[INFO] Version group ${groupId.slice(0, 8)}: ` +
74
+ `${canonical.id.slice(0, 8)} (rank=${canonRank.toFixed(2)}) + ` +
75
+ `${member.id.slice(0, 8)} (rank=${memberRank.toFixed(2)})`,
76
+ );
77
+
78
+ return groupId;
79
+ }
80
+
81
+ /**
82
+ * Compute version rank for an entry.
83
+ * Formula: (0.5 * importance + 0.5 * confidence) × (1 + log1p(accessCount))
84
+ * Combines both importance (from extraction) and confidence (from user feedback)
85
+ * with access frequency to determine which version wins.
86
+ * Higher = preferred for retrieval.
87
+ */
88
+ export function computeVersionRank(entry: MemoryEntry): number {
89
+ const meta = parseMetadata(entry.metadata);
90
+ const confidence = typeof meta.confidence === "number" ? meta.confidence : 0.7;
91
+ const accessCount = typeof meta.accessCount === "number" ? meta.accessCount : 0;
92
+ const importance = typeof entry.importance === "number" ? entry.importance : 0.5;
93
+ const quality = 0.5 * importance + 0.5 * confidence;
94
+ return quality * (1 + Math.log1p(accessCount));
95
+ }
96
+
97
+ /**
98
+ * Deduplicate retrieval results by version group.
99
+ * For each version_group, keep only the entry with the highest version_rank.
100
+ * Entries without a version_group pass through unchanged.
101
+ *
102
+ * @param results Array of retrieval results (must have entry.metadata)
103
+ * @returns Filtered array with at most one entry per version group
104
+ */
105
+ export function deduplicateByVersionGroup<T extends { entry: MemoryEntry; score: number }>(
106
+ results: T[],
107
+ ): T[] {
108
+ // First pass: find the best entry per version group
109
+ const groupBest = new Map<string, T>();
110
+
111
+ for (const r of results) {
112
+ const meta = parseMetadata(r.entry.metadata);
113
+ const group = typeof meta.version_group === "string" ? meta.version_group : null;
114
+ if (!group) continue;
115
+
116
+ const existing = groupBest.get(group);
117
+ if (!existing) {
118
+ groupBest.set(group, r);
119
+ } else {
120
+ const existingRank = getVersionRank(existing.entry);
121
+ const newRank = getVersionRank(r.entry);
122
+ if (newRank > existingRank) {
123
+ groupBest.set(group, r);
124
+ }
125
+ }
126
+ }
127
+
128
+ // Second pass: emit results preserving order, replacing groups with their winner
129
+ const groupWinnerIds = new Set([...groupBest.values()].map(r => r.entry.id));
130
+ const seenGroups = new Set<string>();
131
+ const output: T[] = [];
132
+
133
+ for (const r of results) {
134
+ const meta = parseMetadata(r.entry.metadata);
135
+ const group = typeof meta.version_group === "string" ? meta.version_group : null;
136
+
137
+ if (!group) {
138
+ // No version group → pass through
139
+ output.push(r);
140
+ } else if (!seenGroups.has(group) && groupWinnerIds.has(r.entry.id)) {
141
+ // First appearance of this group's winner
142
+ output.push(r);
143
+ seenGroups.add(group);
144
+ }
145
+ // Skip non-winners and duplicate group appearances
146
+ }
147
+
148
+ return output;
149
+ }
150
+
151
+ // ============================================================================
152
+ // Helpers
153
+ // ============================================================================
154
+
155
+ function getVersionRank(entry: MemoryEntry): number {
156
+ const meta = parseMetadata(entry.metadata);
157
+ return typeof meta.version_rank === "number" ? meta.version_rank : computeVersionRank(entry);
158
+ }
159
+
160
+ function parseMetadata(raw?: string): Record<string, unknown> {
161
+ try {
162
+ return JSON.parse(raw || "{}") as Record<string, unknown>;
163
+ } catch {
164
+ return {};
165
+ }
166
+ }
167
+
168
+ function generateGroupId(): string {
169
+ // Simple UUID v4-like (no crypto needed for grouping)
170
+ const hex = () => Math.random().toString(16).slice(2, 6);
171
+ return `vg-${hex()}${hex()}`;
172
+ }
@@ -0,0 +1,316 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import type {
4
+ WorkflowEvidencePack,
5
+ WorkflowHealthDashboardItem,
6
+ WorkflowHealthReport,
7
+ WorkflowHealthWindow,
8
+ WorkflowObservationInput,
9
+ WorkflowObservationOutcome,
10
+ WorkflowObservationRecord,
11
+ } from "./workflow-observation-schema.js";
12
+ import { WorkflowObservationInputSchema, WorkflowObservationRecordSchema } from "./workflow-observation-schema.js";
13
+ import type { WorkflowObservationStore } from "./workflow-observation-store.js";
14
+
15
+ const DEFAULT_WINDOWS = [7, 30] as const;
16
+ const MAX_OBSERVATIONS_FOR_ANALYSIS = 1000;
17
+ const ISSUE_OUTCOMES: WorkflowObservationOutcome[] = ["failure", "corrected", "missed"];
18
+
19
+ function pct(value: number): string {
20
+ return `${Math.round(value * 100)}%`;
21
+ }
22
+
23
+ function countBySignal(records: WorkflowObservationRecord[]): Array<{ signal: string; count: number }> {
24
+ const counts = new Map<string, number>();
25
+ for (const record of records) {
26
+ const key = record.signal?.trim();
27
+ if (!key) continue;
28
+ counts.set(key, (counts.get(key) || 0) + 1);
29
+ }
30
+ return [...counts.entries()]
31
+ .map(([signal, count]) => ({ signal, count }))
32
+ .sort((a, b) => b.count - a.count || a.signal.localeCompare(b.signal))
33
+ .slice(0, 3);
34
+ }
35
+
36
+ function summarizeWindow(days: number, records: WorkflowObservationRecord[], nowMs: number): WorkflowHealthWindow {
37
+ const cutoff = nowMs - days * 24 * 60 * 60 * 1000;
38
+ const scoped = records.filter((record) => Date.parse(record.recordedAt) >= cutoff);
39
+ const total = scoped.length;
40
+ const successes = scoped.filter((record) => record.outcome === "success").length;
41
+ const failures = scoped.filter((record) => record.outcome === "failure").length;
42
+ const corrected = scoped.filter((record) => record.outcome === "corrected").length;
43
+ const missed = scoped.filter((record) => record.outcome === "missed").length;
44
+ const issueCount = failures + corrected + missed;
45
+ const latestAt = scoped[0]?.recordedAt;
46
+
47
+ return {
48
+ days,
49
+ total,
50
+ successes,
51
+ failures,
52
+ corrected,
53
+ missed,
54
+ issueCount,
55
+ successRate: total > 0 ? successes / total : 0,
56
+ issueRate: total > 0 ? issueCount / total : 0,
57
+ latestAt,
58
+ topSignals: countBySignal(scoped.filter((record) => ISSUE_OUTCOMES.includes(record.outcome))),
59
+ };
60
+ }
61
+
62
+ function deriveStatus(window30: WorkflowHealthWindow): WorkflowHealthReport["status"] {
63
+ if (window30.total === 0) return "no-data";
64
+ if (
65
+ window30.failures >= 1 ||
66
+ window30.missed >= 2 ||
67
+ window30.issueCount >= 3 ||
68
+ (window30.total >= 4 && window30.successRate < 0.5)
69
+ ) {
70
+ return "critical";
71
+ }
72
+ if (
73
+ window30.issueCount >= 1 ||
74
+ window30.successRate < 0.75
75
+ ) {
76
+ return "watch";
77
+ }
78
+ return "healthy";
79
+ }
80
+
81
+ function buildHealthSummary(workflowId: string, status: WorkflowHealthReport["status"], window30: WorkflowHealthWindow): string {
82
+ if (status === "no-data") {
83
+ return `No workflow observations recorded yet for ${workflowId}.`;
84
+ }
85
+ const signalText = window30.topSignals.length > 0
86
+ ? ` Top issues: ${window30.topSignals.map((item) => `${item.signal} (${item.count})`).join(", ")}.`
87
+ : "";
88
+ return `${workflowId} is ${status} over 30d: ${window30.total} observations, ${pct(window30.successRate)} success, ${window30.issueCount} issues.${signalText}`;
89
+ }
90
+
91
+ function uniqueStrings(values: string[]): string[] {
92
+ const deduped: string[] = [];
93
+ const seen = new Set<string>();
94
+ for (const value of values) {
95
+ const normalized = value.trim();
96
+ if (!normalized) continue;
97
+ const key = normalized.toLowerCase();
98
+ if (seen.has(key)) continue;
99
+ seen.add(key);
100
+ deduped.push(normalized);
101
+ }
102
+ return deduped;
103
+ }
104
+
105
+ function buildEvidenceSuggestions(
106
+ workflowId: string,
107
+ records: WorkflowObservationRecord[],
108
+ health: WorkflowHealthReport,
109
+ ): string[] {
110
+ const window30 = health.windows.find((window) => window.days === 30) || health.windows[health.windows.length - 1];
111
+ if (!window30 || window30.total === 0) {
112
+ return [
113
+ `Start recording ${workflowId} outcomes in real tasks before automating proactive alerts.`,
114
+ ];
115
+ }
116
+
117
+ const suggestions: string[] = [];
118
+ if (window30.missed > 0) {
119
+ suggestions.push("Tighten trigger coverage so this workflow fires before repo exploration or user correction.");
120
+ }
121
+ if (window30.corrected > 0) {
122
+ suggestions.push("Review recurring user corrections and turn them into explicit guardrails or narrower response rules.");
123
+ }
124
+ if (window30.failures > 0) {
125
+ suggestions.push("Add a regression test or smoke case that reproduces the failing path.");
126
+ }
127
+ if (window30.topSignals.some((item) => /repo-state|git status|checkpoint/i.test(item.signal))) {
128
+ suggestions.push("Keep volatile repo-state text out of saved checkpoints and handoff summaries unless this window verified it.");
129
+ }
130
+ if (workflowId === "resume_context" && window30.issueCount > 0) {
131
+ suggestions.push("Strengthen fresh-window continuity triggers and acceptance smoke for startup recovery.");
132
+ }
133
+ if (workflowId === "checkpoint_session" && window30.issueCount > 0) {
134
+ suggestions.push("Add end-of-window guards so checkpoint content is sanitized before it becomes the next handoff.");
135
+ }
136
+ if (window30.total < 4) {
137
+ suggestions.push("Collect a few more observations before changing thresholds or automating proactive advice.");
138
+ }
139
+
140
+ return uniqueStrings(suggestions).slice(0, 4);
141
+ }
142
+
143
+ export function resolveWorkflowObservationScope(input: Pick<WorkflowObservationInput, "scope">): string {
144
+ return input.scope || "global";
145
+ }
146
+
147
+ export function buildWorkflowObservationRecord(rawInput: unknown): WorkflowObservationRecord {
148
+ const input = WorkflowObservationInputSchema.parse(rawInput);
149
+ return WorkflowObservationRecordSchema.parse({
150
+ ...input,
151
+ observationId: randomUUID(),
152
+ resolvedScope: resolveWorkflowObservationScope(input),
153
+ });
154
+ }
155
+
156
+ export function summarizeWorkflowHealthRecords(
157
+ workflowId: string,
158
+ records: WorkflowObservationRecord[],
159
+ options: { scope?: string; now?: Date | number; windows?: number[] } = {},
160
+ ): WorkflowHealthReport {
161
+ const nowMs = options.now instanceof Date
162
+ ? options.now.getTime()
163
+ : typeof options.now === "number"
164
+ ? options.now
165
+ : Date.now();
166
+ const windows = (options.windows && options.windows.length > 0 ? options.windows : [...DEFAULT_WINDOWS])
167
+ .map((value) => Math.max(1, Math.trunc(value)));
168
+ const filtered = records
169
+ .filter((record) => record.workflowId === workflowId)
170
+ .filter((record) => !options.scope || record.resolvedScope === options.scope)
171
+ .sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt));
172
+
173
+ const summarized = windows.map((days) => summarizeWindow(days, filtered, nowMs));
174
+ const primary = summarized.find((window) => window.days === 30) || summarized[summarized.length - 1];
175
+ const status = deriveStatus(primary);
176
+
177
+ return {
178
+ workflowId,
179
+ scope: options.scope,
180
+ status,
181
+ summary: buildHealthSummary(workflowId, status, primary),
182
+ latestObservationAt: filtered[0]?.recordedAt,
183
+ windows: summarized,
184
+ };
185
+ }
186
+
187
+ export function buildWorkflowHealthDashboard(
188
+ records: WorkflowObservationRecord[],
189
+ options: { scope?: string; limit?: number; now?: Date | number } = {},
190
+ ): WorkflowHealthDashboardItem[] {
191
+ const grouped = new Map<string, WorkflowObservationRecord[]>();
192
+ for (const record of records) {
193
+ if (options.scope && record.resolvedScope !== options.scope) continue;
194
+ const key = options.scope ? record.workflowId : `${record.resolvedScope}::${record.workflowId}`;
195
+ const bucket = grouped.get(key) || [];
196
+ bucket.push(record);
197
+ grouped.set(key, bucket);
198
+ }
199
+
200
+ const items = [...grouped.values()].map((bucket) => {
201
+ const workflowId = bucket[0].workflowId;
202
+ const scope = options.scope || bucket[0].resolvedScope;
203
+ const report = summarizeWorkflowHealthRecords(workflowId, bucket, {
204
+ scope,
205
+ now: options.now,
206
+ });
207
+ const primary = report.windows.find((window) => window.days === 30) || report.windows[report.windows.length - 1];
208
+ return {
209
+ workflowId,
210
+ scope,
211
+ status: report.status,
212
+ total: primary.total,
213
+ issueCount: primary.issueCount,
214
+ successRate: primary.successRate,
215
+ latestObservationAt: report.latestObservationAt,
216
+ summary: report.summary,
217
+ } satisfies WorkflowHealthDashboardItem;
218
+ });
219
+
220
+ const priority = { critical: 0, watch: 1, healthy: 2, "no-data": 3 } as const;
221
+ const limit = options.limit || 10;
222
+ return items
223
+ .sort((a, b) => {
224
+ const byStatus = priority[a.status] - priority[b.status];
225
+ if (byStatus !== 0) return byStatus;
226
+ const byIssues = b.issueCount - a.issueCount;
227
+ if (byIssues !== 0) return byIssues;
228
+ return (Date.parse(b.latestObservationAt || "1970-01-01T00:00:00.000Z") -
229
+ Date.parse(a.latestObservationAt || "1970-01-01T00:00:00.000Z"));
230
+ })
231
+ .slice(0, limit);
232
+ }
233
+
234
+ export function generateWorkflowEvidencePack(
235
+ workflowId: string,
236
+ records: WorkflowObservationRecord[],
237
+ options: { scope?: string; now?: Date | number; limit?: number } = {},
238
+ ): WorkflowEvidencePack {
239
+ const filtered = records
240
+ .filter((record) => record.workflowId === workflowId)
241
+ .filter((record) => !options.scope || record.resolvedScope === options.scope)
242
+ .sort((a, b) => Date.parse(b.recordedAt) - Date.parse(a.recordedAt));
243
+ const health = summarizeWorkflowHealthRecords(workflowId, filtered, {
244
+ scope: options.scope,
245
+ now: options.now,
246
+ });
247
+ const issues = filtered
248
+ .filter((record) => ISSUE_OUTCOMES.includes(record.outcome))
249
+ .slice(0, options.limit || 5)
250
+ .map((record) => ({
251
+ observationId: record.observationId,
252
+ outcome: record.outcome,
253
+ summary: record.summary,
254
+ signal: record.signal,
255
+ recordedAt: record.recordedAt,
256
+ task: record.task,
257
+ }));
258
+ const issueRecords = filtered.filter((record) => ISSUE_OUTCOMES.includes(record.outcome));
259
+ const suggestions = buildEvidenceSuggestions(workflowId, issueRecords, health);
260
+ const topSignals = countBySignal(issueRecords);
261
+ const summary = issues.length === 0
262
+ ? `No issue observations recorded yet for ${workflowId}.`
263
+ : `${workflowId} has ${issues.length} recent issue observation(s) ready for review.`;
264
+
265
+ return {
266
+ workflowId,
267
+ scope: options.scope,
268
+ generatedAt: new Date(
269
+ options.now instanceof Date
270
+ ? options.now.getTime()
271
+ : typeof options.now === "number"
272
+ ? options.now
273
+ : Date.now(),
274
+ ).toISOString(),
275
+ summary,
276
+ health,
277
+ topSignals,
278
+ recentIssues: issues,
279
+ suggestions,
280
+ };
281
+ }
282
+
283
+ export async function inspectWorkflowHealth(
284
+ store: WorkflowObservationStore,
285
+ params: { workflowId: string; scope?: string; now?: Date | number },
286
+ ): Promise<WorkflowHealthReport> {
287
+ const records = await store.listRecent({
288
+ workflowId: params.workflowId,
289
+ scope: params.scope,
290
+ limit: MAX_OBSERVATIONS_FOR_ANALYSIS,
291
+ });
292
+ return summarizeWorkflowHealthRecords(params.workflowId, records, params);
293
+ }
294
+
295
+ export async function inspectWorkflowDashboard(
296
+ store: WorkflowObservationStore,
297
+ params: { scope?: string; limit?: number; now?: Date | number } = {},
298
+ ): Promise<WorkflowHealthDashboardItem[]> {
299
+ const records = await store.listRecent({
300
+ scope: params.scope,
301
+ limit: MAX_OBSERVATIONS_FOR_ANALYSIS,
302
+ });
303
+ return buildWorkflowHealthDashboard(records, params);
304
+ }
305
+
306
+ export async function buildWorkflowEvidence(
307
+ store: WorkflowObservationStore,
308
+ params: { workflowId: string; scope?: string; limit?: number; now?: Date | number },
309
+ ): Promise<WorkflowEvidencePack> {
310
+ const records = await store.listRecent({
311
+ workflowId: params.workflowId,
312
+ scope: params.scope,
313
+ limit: MAX_OBSERVATIONS_FOR_ANALYSIS,
314
+ });
315
+ return generateWorkflowEvidencePack(params.workflowId, records, params);
316
+ }
@@ -0,0 +1,83 @@
1
+ import type { ResumeContextRequest, ResumeContextResponse } from "./session-schema.js";
2
+ import type { SessionCheckpointBuildResult } from "./session-engine.js";
3
+ import type { WorkflowObservationInput } from "./workflow-observation-schema.js";
4
+
5
+ const MANAGED_SOURCE = "managed";
6
+ const MANAGED_TAGS = ["continuity", "managed"];
7
+
8
+ function dedupeTags(tags: string[]): string[] {
9
+ const seen = new Set<string>();
10
+ const deduped: string[] = [];
11
+ for (const tag of tags) {
12
+ const normalized = tag.trim().toLowerCase();
13
+ if (!normalized || seen.has(normalized)) continue;
14
+ seen.add(normalized);
15
+ deduped.push(normalized);
16
+ }
17
+ return deduped;
18
+ }
19
+
20
+ function resolveObservationScope(
21
+ request: Pick<ResumeContextRequest, "scope" | "sessionId">,
22
+ response?: Pick<ResumeContextResponse, "resolvedScope" | "latestCheckpoint">,
23
+ ): string | undefined {
24
+ return request.scope
25
+ || (request.sessionId ? `session:${request.sessionId}` : undefined)
26
+ || response?.resolvedScope
27
+ || response?.latestCheckpoint?.resolvedScope;
28
+ }
29
+
30
+ export function buildManagedResumeObservation(
31
+ request: Pick<ResumeContextRequest, "scope" | "sessionId" | "task">,
32
+ response: Pick<ResumeContextResponse, "stableContext" | "relevantPatterns" | "recentCases" | "latestCheckpoint" | "responseMode" | "resolvedScope">,
33
+ ): WorkflowObservationInput {
34
+ const stableCount = response.stableContext.length;
35
+ const patternCount = response.relevantPatterns.length;
36
+ const caseCount = response.recentCases.length;
37
+ const checkpointText = response.latestCheckpoint ? " plus the latest checkpoint" : "";
38
+ return {
39
+ workflowId: "resume_context",
40
+ outcome: "success",
41
+ summary: response.responseMode === "recall-only"
42
+ ? `Managed resume_context recovered ${stableCount} stable item(s) in recall-only mode${checkpointText}.`
43
+ : `Managed resume_context recovered ${stableCount} stable item(s), ${patternCount} pattern(s), and ${caseCount} case(s)${checkpointText}.`,
44
+ scope: resolveObservationScope(request, response),
45
+ source: MANAGED_SOURCE,
46
+ signal: response.responseMode === "recall-only" ? "managed-recall-resolved" : "managed-resume-resolved",
47
+ task: request.task,
48
+ tags: dedupeTags([
49
+ ...MANAGED_TAGS,
50
+ response.responseMode === "recall-only" ? "recall-only" : "startup-recovery",
51
+ ]),
52
+ tools: ["resume_context"],
53
+ };
54
+ }
55
+
56
+ function formatCheckpointFieldList(fields: string[]): string {
57
+ if (fields.length === 1) return fields[0];
58
+ if (fields.length === 2) return `${fields[0]} and ${fields[1]}`;
59
+ return `${fields.slice(0, -1).join(", ")}, and ${fields[fields.length - 1]}`;
60
+ }
61
+
62
+ export function buildManagedCheckpointObservation(
63
+ result: SessionCheckpointBuildResult,
64
+ ): WorkflowObservationInput {
65
+ const corrected = result.sanitization.changed;
66
+ const fieldText = formatCheckpointFieldList(result.sanitization.changedFields);
67
+ return {
68
+ workflowId: "checkpoint_session",
69
+ outcome: corrected ? "corrected" : "success",
70
+ summary: corrected
71
+ ? `Managed checkpoint_session sanitized repo-state text out of ${fieldText} before saving the handoff.`
72
+ : "Managed checkpoint_session saved a handoff without repo-state corrections.",
73
+ scope: result.record.resolvedScope,
74
+ source: MANAGED_SOURCE,
75
+ signal: corrected ? "repo-state-sanitized" : "checkpoint-saved",
76
+ task: result.record.task,
77
+ tags: dedupeTags([
78
+ ...MANAGED_TAGS,
79
+ corrected ? "sanitized" : "handoff-saved",
80
+ ]),
81
+ tools: ["checkpoint_session"],
82
+ };
83
+ }
@@ -0,0 +1,86 @@
1
+ import type {
2
+ WorkflowEvidencePack,
3
+ WorkflowHealthDashboardItem,
4
+ WorkflowHealthReport,
5
+ WorkflowObservationRecord,
6
+ } from "./workflow-observation-schema.js";
7
+
8
+ function pct(value: number): string {
9
+ return `${Math.round(value * 100)}%`;
10
+ }
11
+
12
+ export function formatWorkflowObservationSaved(record: WorkflowObservationRecord): string {
13
+ const lines = [
14
+ `Workflow observation ${record.observationId.slice(0, 8)}`,
15
+ `Workflow: ${record.workflowId}`,
16
+ `Outcome: ${record.outcome}`,
17
+ `Scope: ${record.resolvedScope}`,
18
+ `Source: ${record.source}`,
19
+ `Recorded: ${record.recordedAt}`,
20
+ `Summary: ${record.summary}`,
21
+ ];
22
+ if (record.signal) lines.push(`Signal: ${record.signal}`);
23
+ if (record.task) lines.push(`Task: ${record.task}`);
24
+ if (record.tools.length > 0) lines.push(`Tools: ${record.tools.join(", ")}`);
25
+ return lines.join("\n");
26
+ }
27
+
28
+ export function formatWorkflowHealthReport(report: WorkflowHealthReport): string {
29
+ const lines = [
30
+ `Workflow health: ${report.workflowId}`,
31
+ `Scope: ${report.scope || "all"}`,
32
+ `Status: ${report.status}`,
33
+ `Summary: ${report.summary}`,
34
+ ];
35
+ if (report.latestObservationAt) lines.push(`Latest: ${report.latestObservationAt}`);
36
+ for (const window of report.windows) {
37
+ lines.push(
38
+ `${window.days}d: total ${window.total}, success ${pct(window.successRate)}, issues ${window.issueCount} (${window.failures} failure / ${window.corrected} corrected / ${window.missed} missed)`,
39
+ );
40
+ if (window.topSignals.length > 0) {
41
+ lines.push(`Top signals: ${window.topSignals.map((item) => `${item.signal} (${item.count})`).join(", ")}`);
42
+ }
43
+ }
44
+ return lines.join("\n");
45
+ }
46
+
47
+ export function formatWorkflowHealthDashboard(items: WorkflowHealthDashboardItem[], scope?: string): string {
48
+ if (items.length === 0) {
49
+ return `No workflow observations found${scope ? ` in ${scope}` : ""}.`;
50
+ }
51
+ return [
52
+ `Workflow health dashboard${scope ? ` (${scope})` : ""}`,
53
+ ...items.map((item, index) =>
54
+ `${index + 1}. [${item.status}] ${item.workflowId}${item.scope ? ` @ ${item.scope}` : ""} — ${item.total} obs, ${pct(item.successRate)} success, ${item.issueCount} issues`,
55
+ ),
56
+ ].join("\n");
57
+ }
58
+
59
+ export function formatWorkflowEvidencePack(pack: WorkflowEvidencePack): string {
60
+ const lines = [
61
+ `Workflow evidence: ${pack.workflowId}`,
62
+ `Scope: ${pack.scope || "all"}`,
63
+ `Generated: ${pack.generatedAt}`,
64
+ `Summary: ${pack.summary}`,
65
+ `Health: ${pack.health.summary}`,
66
+ ];
67
+ if (pack.topSignals.length > 0) {
68
+ lines.push(`Top signals: ${pack.topSignals.map((item) => `${item.signal} (${item.count})`).join(", ")}`);
69
+ }
70
+ if (pack.recentIssues.length > 0) {
71
+ lines.push("Recent issues:");
72
+ for (const issue of pack.recentIssues) {
73
+ lines.push(
74
+ `- ${issue.recordedAt.slice(0, 10)} [${issue.outcome}] ${issue.signal ? `${issue.signal}: ` : ""}${issue.summary}`,
75
+ );
76
+ }
77
+ }
78
+ if (pack.suggestions.length > 0) {
79
+ lines.push("Suggestions:");
80
+ for (const suggestion of pack.suggestions) {
81
+ lines.push(`- ${suggestion}`);
82
+ }
83
+ }
84
+ return lines.join("\n");
85
+ }
86
+