@kodrunhq/opencode-autopilot 1.15.2 → 1.16.0

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 (61) hide show
  1. package/bin/cli.ts +5 -0
  2. package/bin/inspect.ts +337 -0
  3. package/package.json +1 -1
  4. package/src/agents/autopilot.ts +7 -15
  5. package/src/health/checks.ts +29 -4
  6. package/src/index.ts +103 -11
  7. package/src/inspect/formatters.ts +225 -0
  8. package/src/inspect/repository.ts +882 -0
  9. package/src/kernel/database.ts +45 -0
  10. package/src/kernel/migrations.ts +62 -0
  11. package/src/kernel/repository.ts +571 -0
  12. package/src/kernel/schema.ts +122 -0
  13. package/src/kernel/types.ts +66 -0
  14. package/src/memory/capture.ts +221 -25
  15. package/src/memory/database.ts +74 -12
  16. package/src/memory/index.ts +17 -1
  17. package/src/memory/project-key.ts +6 -0
  18. package/src/memory/repository.ts +833 -42
  19. package/src/memory/retrieval.ts +83 -169
  20. package/src/memory/schemas.ts +39 -7
  21. package/src/memory/types.ts +4 -0
  22. package/src/observability/event-handlers.ts +28 -17
  23. package/src/observability/event-store.ts +29 -1
  24. package/src/observability/forensic-log.ts +159 -0
  25. package/src/observability/forensic-schemas.ts +69 -0
  26. package/src/observability/forensic-types.ts +10 -0
  27. package/src/observability/index.ts +21 -27
  28. package/src/observability/log-reader.ts +142 -111
  29. package/src/observability/log-writer.ts +41 -83
  30. package/src/observability/retention.ts +2 -2
  31. package/src/observability/session-logger.ts +36 -57
  32. package/src/observability/summary-generator.ts +31 -19
  33. package/src/observability/types.ts +12 -24
  34. package/src/orchestrator/contracts/invariants.ts +14 -0
  35. package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
  36. package/src/orchestrator/fallback/event-handler.ts +47 -3
  37. package/src/orchestrator/handlers/architect.ts +2 -1
  38. package/src/orchestrator/handlers/build.ts +55 -97
  39. package/src/orchestrator/handlers/retrospective.ts +2 -1
  40. package/src/orchestrator/handlers/types.ts +0 -1
  41. package/src/orchestrator/lesson-memory.ts +29 -9
  42. package/src/orchestrator/orchestration-logger.ts +37 -23
  43. package/src/orchestrator/phase.ts +8 -4
  44. package/src/orchestrator/state.ts +79 -17
  45. package/src/projects/database.ts +47 -0
  46. package/src/projects/repository.ts +264 -0
  47. package/src/projects/resolve.ts +301 -0
  48. package/src/projects/schemas.ts +30 -0
  49. package/src/projects/types.ts +12 -0
  50. package/src/review/memory.ts +29 -9
  51. package/src/tools/doctor.ts +26 -2
  52. package/src/tools/forensics.ts +7 -12
  53. package/src/tools/logs.ts +6 -5
  54. package/src/tools/memory-preferences.ts +157 -0
  55. package/src/tools/memory-status.ts +17 -96
  56. package/src/tools/orchestrate.ts +97 -81
  57. package/src/tools/pipeline-report.ts +3 -2
  58. package/src/tools/quick.ts +2 -2
  59. package/src/tools/review.ts +39 -6
  60. package/src/tools/session-stats.ts +3 -2
  61. package/src/utils/paths.ts +20 -1
@@ -1,12 +1,13 @@
1
1
  /**
2
- * 3-layer progressive disclosure retrieval with token-budgeted context building.
2
+ * Provenance-first memory retrieval.
3
3
  *
4
- * Layer 1 (always): Observation summaries grouped by type (up to 5 per group)
5
- * Layer 2 (if budget allows): Recent Activity timeline
6
- * Layer 3 (if budget allows): Full content for top 1-2 observations
4
+ * Injects bounded memory context from explicit, inspectable sources:
5
+ * - confirmed project and global preferences
6
+ * - recent project lessons
7
+ * - recent failure-avoidance notes from error observations
7
8
  *
8
- * Token budget enforcement: never exceeds CHARS_PER_TOKEN * tokenBudget characters.
9
- * Same approach as buildMultiSkillContext in src/skills/adaptive-injector.ts.
9
+ * Generic observations remain queryable for inspection, but are no longer the
10
+ * primary injected memory contract.
10
11
  *
11
12
  * @module
12
13
  */
@@ -16,9 +17,10 @@ import { CHARS_PER_TOKEN, DEFAULT_INJECTION_BUDGET } from "./constants";
16
17
  import { getMemoryDb } from "./database";
17
18
  import { computeRelevanceScore } from "./decay";
18
19
  import {
19
- getAllPreferences,
20
- getObservationsByProject,
20
+ getConfirmedPreferencesForProject,
21
21
  getProjectByPath,
22
+ getRecentFailureObservations,
23
+ listRelevantLessons,
22
24
  updateAccessCount,
23
25
  } from "./repository";
24
26
  import type { Observation, Preference } from "./types";
@@ -48,200 +50,113 @@ export function scoreAndRankObservations(
48
50
  .sort((a, b) => b.relevanceScore - a.relevanceScore);
49
51
  }
50
52
 
51
- /**
52
- * Type-to-section header mapping for Layer 1 grouping.
53
- */
54
- const SECTION_HEADERS: Readonly<Record<string, string>> = Object.freeze({
55
- decision: "### Key Decisions",
56
- pattern: "### Patterns",
57
- error: "### Recent Errors",
58
- preference: "### Learned Preferences",
59
- context: "### Context Notes",
60
- tool_usage: "### Tool Usage Patterns",
61
- });
62
-
63
- /** Section display order (most valuable first). */
64
- const SECTION_ORDER = ["decision", "pattern", "error", "preference", "context", "tool_usage"];
65
-
66
- /** Max observations per group in Layer 1. */
67
- const MAX_PER_GROUP = 5;
68
-
69
- /** Minimum chars remaining to include Layer 2. */
70
- const LAYER_2_THRESHOLD = 500;
71
-
72
- /** Minimum chars remaining to include Layer 3. */
73
- const LAYER_3_THRESHOLD = 1000;
74
-
75
- /**
76
- * Options for building memory context.
77
- */
78
53
  interface BuildMemoryContextOptions {
79
54
  readonly projectName: string;
80
55
  readonly lastSessionDate: string | null;
81
- readonly observations: readonly ScoredObservation[];
82
56
  readonly preferences: readonly Preference[];
57
+ readonly lessons: readonly {
58
+ readonly content: string;
59
+ readonly domain: string;
60
+ readonly extractedAt: string;
61
+ readonly sourcePhase: string;
62
+ }[];
63
+ readonly recentFailures: readonly ScoredObservation[];
83
64
  readonly tokenBudget?: number;
84
65
  }
85
66
 
67
+ function appendSection(
68
+ parts: string[],
69
+ totalChars: number,
70
+ charBudget: number,
71
+ section: string,
72
+ ): number {
73
+ if (totalChars + section.length > charBudget) {
74
+ return totalChars;
75
+ }
76
+ parts.push(section);
77
+ return totalChars + section.length;
78
+ }
79
+
86
80
  /**
87
81
  * Build a markdown memory context string within token budget.
88
- *
89
- * Uses 3-layer progressive disclosure:
90
- * - Layer 1: Summaries grouped by type (always included if budget allows)
91
- * - Layer 2: Recent Activity timeline (if remaining budget > 500 chars)
92
- * - Layer 3: Full content for top observations (if remaining budget > 1000 chars)
93
82
  */
94
83
  export function buildMemoryContext(options: BuildMemoryContextOptions): string {
95
84
  const {
96
85
  projectName,
97
86
  lastSessionDate,
98
- observations,
99
87
  preferences,
88
+ lessons,
89
+ recentFailures,
100
90
  tokenBudget = DEFAULT_INJECTION_BUDGET,
101
91
  } = options;
102
92
 
103
- if (observations.length === 0 && preferences.length === 0) return "";
93
+ if (preferences.length === 0 && lessons.length === 0 && recentFailures.length === 0) {
94
+ return "";
95
+ }
104
96
 
105
97
  const charBudget = tokenBudget * CHARS_PER_TOKEN;
106
98
  let totalChars = 0;
107
99
  const parts: string[] = [];
108
100
 
109
- // Header
110
101
  const header = `## Project Memory (auto-injected)\n**Project:** ${projectName}\n**Last session:** ${lastSessionDate ?? "first session"}\n`;
111
- if (totalChars + header.length > charBudget) {
102
+ if (header.length > charBudget) {
112
103
  return header.slice(0, charBudget);
113
104
  }
114
105
  parts.push(header);
115
106
  totalChars += header.length;
116
107
 
117
- // --- Layer 1: Grouped summaries ---
118
- // Sort by relevance within the function to ensure highest-first in each group
119
- const sorted = [...observations].sort((a, b) => b.relevanceScore - a.relevanceScore);
120
- const grouped = groupByType(sorted);
121
-
122
- for (const type of SECTION_ORDER) {
123
- const group = grouped.get(type);
124
- if (!group || group.length === 0) continue;
125
-
126
- const sectionHeader = SECTION_HEADERS[type] ?? `### ${type}`;
127
- const items = group.slice(0, MAX_PER_GROUP);
128
- const lines = items.map((obs) => `- ${obs.summary} (confidence: ${obs.confidence})`);
129
- const section = `\n${sectionHeader}\n${lines.join("\n")}\n`;
130
-
131
- if (totalChars + section.length > charBudget) break;
132
- parts.push(section);
133
- totalChars += section.length;
134
- }
135
-
136
- // Preferences section
137
108
  if (preferences.length > 0) {
138
- const prefLines = preferences.map((p) => `- **${p.key}:** ${p.value}`);
139
- const prefSection = `\n### Preferences\n${prefLines.join("\n")}\n`;
140
-
141
- if (totalChars + prefSection.length <= charBudget) {
142
- parts.push(prefSection);
143
- totalChars += prefSection.length;
109
+ const projectPreferences = preferences.filter((preference) => preference.scope === "project");
110
+ const globalPreferences = preferences.filter((preference) => preference.scope === "global");
111
+
112
+ if (projectPreferences.length > 0) {
113
+ const section = `\n### Confirmed Project Preferences\n${projectPreferences
114
+ .map(
115
+ (preference) =>
116
+ `- **${preference.key}:** ${preference.value} (confidence: ${preference.confidence}, evidence: ${preference.evidenceCount})`,
117
+ )
118
+ .join("\n")}\n`;
119
+ totalChars = appendSection(parts, totalChars, charBudget, section);
144
120
  }
145
- }
146
121
 
147
- // --- Layer 2: Recent Activity timeline (if budget allows) ---
148
- const remainingAfterL1 = charBudget - totalChars;
149
- if (remainingAfterL1 > LAYER_2_THRESHOLD && observations.length > 0) {
150
- const timeline = buildTimeline(observations);
151
- if (timeline.length > 0) {
152
- const timelineSection = `\n### Recent Activity\n${timeline}\n`;
153
- if (totalChars + timelineSection.length <= charBudget) {
154
- parts.push(timelineSection);
155
- totalChars += timelineSection.length;
156
- }
122
+ if (globalPreferences.length > 0) {
123
+ const section = `\n### Confirmed User Preferences\n${globalPreferences
124
+ .map(
125
+ (preference) =>
126
+ `- **${preference.key}:** ${preference.value} (confidence: ${preference.confidence}, evidence: ${preference.evidenceCount})`,
127
+ )
128
+ .join("\n")}\n`;
129
+ totalChars = appendSection(parts, totalChars, charBudget, section);
157
130
  }
158
131
  }
159
132
 
160
- // --- Layer 3: Full content for top observations (if budget allows) ---
161
- const remainingAfterL2 = charBudget - totalChars;
162
- if (remainingAfterL2 > LAYER_3_THRESHOLD && observations.length > 0) {
163
- const topObs = observations.slice(0, 2);
164
- const detailLines: string[] = [];
165
- const headerOverhead = "\n### Details\n\n".length;
166
- let linesBudget = remainingAfterL2 - headerOverhead;
167
-
168
- for (const obs of topObs) {
169
- const detail = `**${obs.type}:** ${obs.content}`;
170
- const cost = detail.length + 1;
171
- if (cost > linesBudget) break;
172
- detailLines.push(detail);
173
- linesBudget -= cost;
174
- }
133
+ if (lessons.length > 0) {
134
+ const section = `\n### Recent Lessons\n${lessons
135
+ .map(
136
+ (lesson) =>
137
+ `- ${lesson.content} (${lesson.domain}, ${lesson.sourcePhase.toLowerCase()}, ${lesson.extractedAt.split("T")[0]})`,
138
+ )
139
+ .join("\n")}\n`;
140
+ totalChars = appendSection(parts, totalChars, charBudget, section);
141
+ }
175
142
 
176
- if (detailLines.length > 0) {
177
- const detailSection = `\n### Details\n${detailLines.join("\n")}\n`;
178
- if (totalChars + detailSection.length <= charBudget) {
179
- parts.push(detailSection);
180
- totalChars += detailSection.length;
181
- }
182
- }
143
+ if (recentFailures.length > 0) {
144
+ const sortedFailures = [...recentFailures].sort((a, b) => b.relevanceScore - a.relevanceScore);
145
+ const section = `\n### Failure Avoidance Notes\n${sortedFailures
146
+ .map(
147
+ (observation) =>
148
+ `- ${observation.summary} (confidence: ${observation.confidence}, ${observation.createdAt.split("T")[0]})`,
149
+ )
150
+ .join("\n")}\n`;
151
+ totalChars = appendSection(parts, totalChars, charBudget, section);
183
152
  }
184
153
 
185
154
  const result = parts.join("");
186
- // Final safety truncation
187
155
  return result.length > charBudget ? result.slice(0, charBudget) : result;
188
156
  }
189
157
 
190
- /**
191
- * Group scored observations by type, preserving relevance order within groups.
192
- */
193
- function groupByType(
194
- observations: readonly ScoredObservation[],
195
- ): ReadonlyMap<string, readonly ScoredObservation[]> {
196
- const groups = new Map<string, ScoredObservation[]>();
197
-
198
- for (const obs of observations) {
199
- const existing = groups.get(obs.type);
200
- if (existing) {
201
- existing.push(obs);
202
- } else {
203
- groups.set(obs.type, [obs]);
204
- }
205
- }
206
-
207
- return groups;
208
- }
209
-
210
- /**
211
- * Build a brief timeline of recent sessions from observations.
212
- */
213
- function buildTimeline(observations: readonly ScoredObservation[]): string {
214
- // Group by session, take last 5 sessions
215
- const sessions = new Map<string, { date: string; count: number }>();
216
-
217
- for (const obs of observations) {
218
- const existing = sessions.get(obs.sessionId);
219
- if (existing) {
220
- existing.count++;
221
- if (new Date(obs.createdAt).getTime() > new Date(existing.date).getTime()) {
222
- existing.date = obs.createdAt;
223
- }
224
- } else {
225
- sessions.set(obs.sessionId, { date: obs.createdAt, count: 1 });
226
- }
227
- }
228
-
229
- const sorted = [...sessions.values()]
230
- .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
231
- .slice(0, 5);
232
-
233
- return sorted
234
- .map((s) => {
235
- const dateStr = s.date.split("T")[0];
236
- return `- ${dateStr}: ${s.count} observation${s.count !== 1 ? "s" : ""}`;
237
- })
238
- .join("\n");
239
- }
240
-
241
158
  /**
242
159
  * Convenience function: retrieve memory context for a project path.
243
- *
244
- * Ties together: project lookup, observation retrieval, scoring, preferences, and context building.
245
160
  */
246
161
  export function retrieveMemoryContext(
247
162
  projectPath: string,
@@ -252,25 +167,24 @@ export function retrieveMemoryContext(
252
167
  const project = getProjectByPath(projectPath, db);
253
168
  if (!project) return "";
254
169
 
255
- const observations = getObservationsByProject(project.id, 100, db);
256
- const scored = scoreAndRankObservations(observations, halfLifeDays);
257
- const preferences = getAllPreferences(db);
170
+ const preferences = getConfirmedPreferencesForProject(project.id, db);
171
+ const lessons = listRelevantLessons(project.id, 5, db);
172
+ const failures = scoreAndRankObservations(
173
+ getRecentFailureObservations(project.id, 5, db),
174
+ halfLifeDays,
175
+ );
258
176
 
259
177
  const context = buildMemoryContext({
260
178
  projectName: project.name,
261
179
  lastSessionDate: project.lastUpdated,
262
- observations: scored,
263
180
  preferences,
181
+ lessons,
182
+ recentFailures: failures,
264
183
  tokenBudget,
265
184
  });
266
185
 
267
- // Batch-update access counts in a single transaction to avoid N+1 writes.
268
- // Only observations that could plausibly fit in context are updated.
269
- // Best-effort: failures are swallowed to avoid blocking retrieval.
270
- const maxInContext = MAX_PER_GROUP * SECTION_ORDER.length;
271
- const idsToUpdate = scored
272
- .slice(0, maxInContext)
273
- .map((obs) => obs.id)
186
+ const idsToUpdate = failures
187
+ .map((observation) => observation.id)
274
188
  .filter((id): id is number => id !== undefined);
275
189
  if (idsToUpdate.length > 0) {
276
190
  try {
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { projectRecordSchema } from "../projects/schemas";
2
3
  import { OBSERVATION_TYPES } from "./constants";
3
4
 
4
5
  export const observationTypeSchema = z.enum(OBSERVATION_TYPES);
@@ -16,19 +17,50 @@ export const observationSchema = z.object({
16
17
  lastAccessed: z.string(),
17
18
  });
18
19
 
19
- export const projectSchema = z.object({
20
- id: z.string(),
21
- path: z.string(),
22
- name: z.string(),
23
- lastUpdated: z.string(),
24
- });
20
+ export const projectSchema = projectRecordSchema;
25
21
 
26
- export const preferenceSchema = z.object({
22
+ const preferenceBaseSchema = z.object({
27
23
  id: z.string(),
28
24
  key: z.string().min(1).max(200),
29
25
  value: z.string().min(1).max(2000),
30
26
  confidence: z.number().min(0).max(1).default(0.5),
27
+ scope: z.enum(["global", "project"]).default("global"),
28
+ projectId: z.string().nullable().default(null),
29
+ status: z.enum(["candidate", "confirmed", "rejected"]).default("confirmed"),
30
+ evidenceCount: z.number().int().min(0).default(0),
31
31
  sourceSession: z.string().nullable().default(null),
32
32
  createdAt: z.string(),
33
33
  lastUpdated: z.string(),
34
34
  });
35
+
36
+ function validatePreferenceScope(value: {
37
+ scope: "global" | "project";
38
+ projectId: string | null;
39
+ }): boolean {
40
+ return (
41
+ (value.scope === "global" && value.projectId === null) ||
42
+ (value.scope === "project" && value.projectId !== null)
43
+ );
44
+ }
45
+
46
+ export const preferenceSchema = preferenceBaseSchema.refine(validatePreferenceScope, {
47
+ message: "projectId must be set for project scope and null for global scope",
48
+ path: ["projectId"],
49
+ });
50
+
51
+ export const preferenceRecordSchema = preferenceBaseSchema.refine(validatePreferenceScope, {
52
+ message: "projectId must be set for project scope and null for global scope",
53
+ path: ["projectId"],
54
+ });
55
+
56
+ export const preferenceEvidenceSchema = z.object({
57
+ id: z.string(),
58
+ preferenceId: z.string().min(1),
59
+ sessionId: z.string().nullable().default(null),
60
+ runId: z.string().nullable().default(null),
61
+ statement: z.string().min(1).max(4000),
62
+ statementHash: z.string().min(1).max(128),
63
+ confidence: z.number().min(0).max(1).default(0.5),
64
+ confirmed: z.boolean().default(false),
65
+ createdAt: z.string(),
66
+ });
@@ -2,6 +2,8 @@ import type { z } from "zod";
2
2
  import type {
3
3
  observationSchema,
4
4
  observationTypeSchema,
5
+ preferenceEvidenceSchema,
6
+ preferenceRecordSchema,
5
7
  preferenceSchema,
6
8
  projectSchema,
7
9
  } from "./schemas";
@@ -10,3 +12,5 @@ export type ObservationType = z.infer<typeof observationTypeSchema>;
10
12
  export type Observation = z.infer<typeof observationSchema>;
11
13
  export type Project = z.infer<typeof projectSchema>;
12
14
  export type Preference = z.infer<typeof preferenceSchema>;
15
+ export type PreferenceRecord = z.infer<typeof preferenceRecordSchema>;
16
+ export type PreferenceEvidence = z.infer<typeof preferenceEvidenceSchema>;
@@ -190,11 +190,13 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
190
190
  const sessionId = extractSessionId(properties);
191
191
  if (!sessionId) return;
192
192
 
193
- // Snapshot to disk (fire-and-forget per Pitfall 2) — session continues
194
- const sessionData = eventStore.getSession(sessionId);
195
- writeSessionLog(sessionData).catch((err) => {
196
- console.error("[opencode-autopilot]", err);
197
- });
193
+ // Persist only new events since the last flush.
194
+ const sessionData = eventStore.getUnpersistedSession(sessionId);
195
+ if (sessionData && sessionData.events.length > 0) {
196
+ writeSessionLog(sessionData).catch((err) => {
197
+ console.error("[opencode-autopilot]", err);
198
+ });
199
+ }
198
200
  return;
199
201
  }
200
202
 
@@ -202,11 +204,21 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
202
204
  const sessionId = extractSessionId(properties);
203
205
  if (!sessionId) return;
204
206
 
207
+ eventStore.appendEvent(sessionId, {
208
+ type: "session_end",
209
+ timestamp: new Date().toISOString(),
210
+ sessionId,
211
+ durationMs: 0,
212
+ totalCost: 0,
213
+ });
214
+
205
215
  // Final flush — session is done, remove from store
206
216
  const sessionData = eventStore.flush(sessionId);
207
- writeSessionLog(sessionData).catch((err) => {
208
- console.error("[opencode-autopilot]", err);
209
- });
217
+ if (sessionData && sessionData.events.length > 0) {
218
+ writeSessionLog(sessionData).catch((err) => {
219
+ console.error("[opencode-autopilot]", err);
220
+ });
221
+ }
210
222
 
211
223
  // Clean up context monitor
212
224
  contextMonitor.cleanup(sessionId);
@@ -219,21 +231,20 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
219
231
 
220
232
  // Append compaction decision event (not session_start)
221
233
  const compactEvent: ObservabilityEvent = Object.freeze({
222
- type: "decision" as const,
234
+ type: "compacted" as const,
223
235
  timestamp: new Date().toISOString(),
224
236
  sessionId,
225
- phase: "COMPACT",
226
- agent: "system",
227
- decision: "Session compacted",
228
- rationale: "Context window compaction triggered",
237
+ trigger: "context_window",
229
238
  });
230
239
  eventStore.appendEvent(sessionId, compactEvent);
231
240
 
232
241
  // Snapshot to disk — session continues after compaction
233
- const sessionData = eventStore.getSession(sessionId);
234
- writeSessionLog(sessionData).catch((err) => {
235
- console.error("[opencode-autopilot]", err);
236
- });
242
+ const sessionData = eventStore.getUnpersistedSession(sessionId);
243
+ if (sessionData && sessionData.events.length > 0) {
244
+ writeSessionLog(sessionData).catch((err) => {
245
+ console.error("[opencode-autopilot]", err);
246
+ });
247
+ }
237
248
  return;
238
249
  }
239
250
 
@@ -81,6 +81,12 @@ export type ObservabilityEvent =
81
81
  readonly fromPhase: string;
82
82
  readonly toPhase: string;
83
83
  }
84
+ | {
85
+ readonly type: "compacted";
86
+ readonly timestamp: string;
87
+ readonly sessionId: string;
88
+ readonly trigger: string;
89
+ }
84
90
  | { readonly type: "session_start"; readonly timestamp: string; readonly sessionId: string }
85
91
  | {
86
92
  readonly type: "session_end";
@@ -118,6 +124,7 @@ export interface SessionEvents {
118
124
  interface MutableSessionData {
119
125
  sessionId: string;
120
126
  events: ObservabilityEvent[];
127
+ persistedEventCount: number;
121
128
  tokens: TokenAggregate;
122
129
  toolMetrics: Map<string, ToolMetrics>;
123
130
  currentPhase: string | null;
@@ -138,6 +145,7 @@ export class SessionEventStore {
138
145
  this.sessions.set(sessionId, {
139
146
  sessionId,
140
147
  events: [],
148
+ persistedEventCount: 0,
141
149
  tokens: createEmptyTokenAggregate(),
142
150
  toolMetrics: new Map(),
143
151
  currentPhase: null,
@@ -213,11 +221,31 @@ export class SessionEventStore {
213
221
  };
214
222
  }
215
223
 
224
+ /**
225
+ * Returns only events not yet persisted and marks them as persisted.
226
+ */
227
+ getUnpersistedSession(sessionId: string): SessionEvents | undefined {
228
+ const session = this.sessions.get(sessionId);
229
+ if (!session) return undefined;
230
+
231
+ const events = session.events.slice(session.persistedEventCount);
232
+ session.persistedEventCount = session.events.length;
233
+
234
+ return {
235
+ sessionId: session.sessionId,
236
+ events,
237
+ tokens: session.tokens,
238
+ toolMetrics: new Map(session.toolMetrics),
239
+ currentPhase: session.currentPhase,
240
+ startedAt: session.startedAt,
241
+ };
242
+ }
243
+
216
244
  /**
217
245
  * Returns session data and removes it from the store (for disk flush).
218
246
  */
219
247
  flush(sessionId: string): SessionEvents | undefined {
220
- const session = this.getSession(sessionId);
248
+ const session = this.getUnpersistedSession(sessionId);
221
249
  if (session) {
222
250
  this.sessions.delete(sessionId);
223
251
  }