@kodrunhq/opencode-autopilot 1.15.2 → 1.17.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 (93) 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/config/index.ts +29 -0
  6. package/src/config/migrations.ts +196 -0
  7. package/src/config/v7.ts +45 -0
  8. package/src/config.ts +3 -3
  9. package/src/health/checks.ts +126 -4
  10. package/src/health/types.ts +1 -1
  11. package/src/index.ts +128 -13
  12. package/src/inspect/formatters.ts +225 -0
  13. package/src/inspect/repository.ts +882 -0
  14. package/src/kernel/database.ts +45 -0
  15. package/src/kernel/migrations.ts +62 -0
  16. package/src/kernel/repository.ts +571 -0
  17. package/src/kernel/schema.ts +122 -0
  18. package/src/kernel/transaction.ts +48 -0
  19. package/src/kernel/types.ts +65 -0
  20. package/src/logging/domains.ts +39 -0
  21. package/src/logging/forensic-writer.ts +177 -0
  22. package/src/logging/index.ts +4 -0
  23. package/src/logging/logger.ts +44 -0
  24. package/src/logging/performance.ts +59 -0
  25. package/src/logging/rotation.ts +261 -0
  26. package/src/logging/types.ts +33 -0
  27. package/src/memory/capture-utils.ts +149 -0
  28. package/src/memory/capture.ts +82 -67
  29. package/src/memory/database.ts +74 -12
  30. package/src/memory/decay.ts +11 -2
  31. package/src/memory/index.ts +17 -1
  32. package/src/memory/injector.ts +4 -1
  33. package/src/memory/lessons.ts +85 -0
  34. package/src/memory/observations.ts +177 -0
  35. package/src/memory/preferences.ts +718 -0
  36. package/src/memory/project-key.ts +6 -0
  37. package/src/memory/projects.ts +83 -0
  38. package/src/memory/repository.ts +52 -216
  39. package/src/memory/retrieval.ts +88 -170
  40. package/src/memory/schemas.ts +39 -7
  41. package/src/memory/types.ts +4 -0
  42. package/src/observability/context-display.ts +8 -0
  43. package/src/observability/event-handlers.ts +69 -20
  44. package/src/observability/event-store.ts +29 -1
  45. package/src/observability/forensic-log.ts +167 -0
  46. package/src/observability/forensic-schemas.ts +77 -0
  47. package/src/observability/forensic-types.ts +10 -0
  48. package/src/observability/index.ts +21 -27
  49. package/src/observability/log-reader.ts +161 -111
  50. package/src/observability/log-writer.ts +41 -83
  51. package/src/observability/retention.ts +2 -2
  52. package/src/observability/session-logger.ts +36 -57
  53. package/src/observability/summary-generator.ts +31 -19
  54. package/src/observability/types.ts +12 -24
  55. package/src/orchestrator/contracts/invariants.ts +14 -0
  56. package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
  57. package/src/orchestrator/error-context.ts +24 -0
  58. package/src/orchestrator/fallback/event-handler.ts +47 -3
  59. package/src/orchestrator/handlers/architect.ts +2 -1
  60. package/src/orchestrator/handlers/build-utils.ts +118 -0
  61. package/src/orchestrator/handlers/build.ts +42 -219
  62. package/src/orchestrator/handlers/retrospective.ts +2 -2
  63. package/src/orchestrator/handlers/types.ts +0 -1
  64. package/src/orchestrator/lesson-memory.ts +36 -11
  65. package/src/orchestrator/orchestration-logger.ts +53 -24
  66. package/src/orchestrator/phase.ts +8 -4
  67. package/src/orchestrator/progress.ts +63 -0
  68. package/src/orchestrator/state.ts +79 -17
  69. package/src/projects/database.ts +47 -0
  70. package/src/projects/repository.ts +264 -0
  71. package/src/projects/resolve.ts +301 -0
  72. package/src/projects/schemas.ts +30 -0
  73. package/src/projects/types.ts +12 -0
  74. package/src/review/memory.ts +39 -11
  75. package/src/review/parse-findings.ts +116 -0
  76. package/src/review/pipeline.ts +3 -107
  77. package/src/review/selection.ts +38 -4
  78. package/src/scoring/time-provider.ts +23 -0
  79. package/src/tools/doctor.ts +28 -4
  80. package/src/tools/forensics.ts +7 -12
  81. package/src/tools/logs.ts +38 -11
  82. package/src/tools/memory-preferences.ts +157 -0
  83. package/src/tools/memory-status.ts +17 -96
  84. package/src/tools/orchestrate.ts +108 -90
  85. package/src/tools/pipeline-report.ts +3 -2
  86. package/src/tools/quick.ts +2 -2
  87. package/src/tools/replay.ts +42 -0
  88. package/src/tools/review.ts +46 -7
  89. package/src/tools/session-stats.ts +3 -2
  90. package/src/tools/summary.ts +43 -0
  91. package/src/utils/paths.ts +20 -1
  92. package/src/utils/random.ts +33 -0
  93. package/src/ux/session-summary.ts +56 -0
@@ -1,28 +1,33 @@
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
  */
13
14
 
14
15
  import type { Database } from "bun:sqlite";
16
+ import { getLogger } from "../logging/domains";
15
17
  import { CHARS_PER_TOKEN, DEFAULT_INJECTION_BUDGET } from "./constants";
16
18
  import { getMemoryDb } from "./database";
17
19
  import { computeRelevanceScore } from "./decay";
18
20
  import {
19
- getAllPreferences,
20
- getObservationsByProject,
21
+ getConfirmedPreferencesForProject,
21
22
  getProjectByPath,
23
+ getRecentFailureObservations,
24
+ listRelevantLessons,
22
25
  updateAccessCount,
23
26
  } from "./repository";
24
27
  import type { Observation, Preference } from "./types";
25
28
 
29
+ const logger = getLogger("memory", "retrieval");
30
+
26
31
  /**
27
32
  * An observation with its computed relevance score.
28
33
  */
@@ -48,200 +53,113 @@ export function scoreAndRankObservations(
48
53
  .sort((a, b) => b.relevanceScore - a.relevanceScore);
49
54
  }
50
55
 
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
56
  interface BuildMemoryContextOptions {
79
57
  readonly projectName: string;
80
58
  readonly lastSessionDate: string | null;
81
- readonly observations: readonly ScoredObservation[];
82
59
  readonly preferences: readonly Preference[];
60
+ readonly lessons: readonly {
61
+ readonly content: string;
62
+ readonly domain: string;
63
+ readonly extractedAt: string;
64
+ readonly sourcePhase: string;
65
+ }[];
66
+ readonly recentFailures: readonly ScoredObservation[];
83
67
  readonly tokenBudget?: number;
84
68
  }
85
69
 
70
+ function appendSection(
71
+ parts: string[],
72
+ totalChars: number,
73
+ charBudget: number,
74
+ section: string,
75
+ ): number {
76
+ if (totalChars + section.length > charBudget) {
77
+ return totalChars;
78
+ }
79
+ parts.push(section);
80
+ return totalChars + section.length;
81
+ }
82
+
86
83
  /**
87
84
  * 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
85
  */
94
86
  export function buildMemoryContext(options: BuildMemoryContextOptions): string {
95
87
  const {
96
88
  projectName,
97
89
  lastSessionDate,
98
- observations,
99
90
  preferences,
91
+ lessons,
92
+ recentFailures,
100
93
  tokenBudget = DEFAULT_INJECTION_BUDGET,
101
94
  } = options;
102
95
 
103
- if (observations.length === 0 && preferences.length === 0) return "";
96
+ if (preferences.length === 0 && lessons.length === 0 && recentFailures.length === 0) {
97
+ return "";
98
+ }
104
99
 
105
100
  const charBudget = tokenBudget * CHARS_PER_TOKEN;
106
101
  let totalChars = 0;
107
102
  const parts: string[] = [];
108
103
 
109
- // Header
110
104
  const header = `## Project Memory (auto-injected)\n**Project:** ${projectName}\n**Last session:** ${lastSessionDate ?? "first session"}\n`;
111
- if (totalChars + header.length > charBudget) {
105
+ if (header.length > charBudget) {
112
106
  return header.slice(0, charBudget);
113
107
  }
114
108
  parts.push(header);
115
109
  totalChars += header.length;
116
110
 
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
111
  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;
112
+ const projectPreferences = preferences.filter((preference) => preference.scope === "project");
113
+ const globalPreferences = preferences.filter((preference) => preference.scope === "global");
114
+
115
+ if (projectPreferences.length > 0) {
116
+ const section = `\n### Confirmed Project Preferences\n${projectPreferences
117
+ .map(
118
+ (preference) =>
119
+ `- **${preference.key}:** ${preference.value} (confidence: ${preference.confidence}, evidence: ${preference.evidenceCount})`,
120
+ )
121
+ .join("\n")}\n`;
122
+ totalChars = appendSection(parts, totalChars, charBudget, section);
144
123
  }
145
- }
146
124
 
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
- }
125
+ if (globalPreferences.length > 0) {
126
+ const section = `\n### Confirmed User Preferences\n${globalPreferences
127
+ .map(
128
+ (preference) =>
129
+ `- **${preference.key}:** ${preference.value} (confidence: ${preference.confidence}, evidence: ${preference.evidenceCount})`,
130
+ )
131
+ .join("\n")}\n`;
132
+ totalChars = appendSection(parts, totalChars, charBudget, section);
157
133
  }
158
134
  }
159
135
 
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
- }
136
+ if (lessons.length > 0) {
137
+ const section = `\n### Recent Lessons\n${lessons
138
+ .map(
139
+ (lesson) =>
140
+ `- ${lesson.content} (${lesson.domain}, ${lesson.sourcePhase.toLowerCase()}, ${lesson.extractedAt.split("T")[0]})`,
141
+ )
142
+ .join("\n")}\n`;
143
+ totalChars = appendSection(parts, totalChars, charBudget, section);
144
+ }
175
145
 
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
- }
146
+ if (recentFailures.length > 0) {
147
+ const sortedFailures = [...recentFailures].sort((a, b) => b.relevanceScore - a.relevanceScore);
148
+ const section = `\n### Failure Avoidance Notes\n${sortedFailures
149
+ .map(
150
+ (observation) =>
151
+ `- ${observation.summary} (confidence: ${observation.confidence}, ${observation.createdAt.split("T")[0]})`,
152
+ )
153
+ .join("\n")}\n`;
154
+ totalChars = appendSection(parts, totalChars, charBudget, section);
183
155
  }
184
156
 
185
157
  const result = parts.join("");
186
- // Final safety truncation
187
158
  return result.length > charBudget ? result.slice(0, charBudget) : result;
188
159
  }
189
160
 
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
161
  /**
242
162
  * Convenience function: retrieve memory context for a project path.
243
- *
244
- * Ties together: project lookup, observation retrieval, scoring, preferences, and context building.
245
163
  */
246
164
  export function retrieveMemoryContext(
247
165
  projectPath: string,
@@ -252,25 +170,24 @@ export function retrieveMemoryContext(
252
170
  const project = getProjectByPath(projectPath, db);
253
171
  if (!project) return "";
254
172
 
255
- const observations = getObservationsByProject(project.id, 100, db);
256
- const scored = scoreAndRankObservations(observations, halfLifeDays);
257
- const preferences = getAllPreferences(db);
173
+ const preferences = getConfirmedPreferencesForProject(project.id, db);
174
+ const lessons = listRelevantLessons(project.id, 5, db);
175
+ const failures = scoreAndRankObservations(
176
+ getRecentFailureObservations(project.id, 5, db),
177
+ halfLifeDays,
178
+ );
258
179
 
259
180
  const context = buildMemoryContext({
260
181
  projectName: project.name,
261
182
  lastSessionDate: project.lastUpdated,
262
- observations: scored,
263
183
  preferences,
184
+ lessons,
185
+ recentFailures: failures,
264
186
  tokenBudget,
265
187
  });
266
188
 
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)
189
+ const idsToUpdate = failures
190
+ .map((observation) => observation.id)
274
191
  .filter((id): id is number => id !== undefined);
275
192
  if (idsToUpdate.length > 0) {
276
193
  try {
@@ -280,8 +197,9 @@ export function retrieveMemoryContext(
280
197
  updateAccessCount(id, db);
281
198
  }
282
199
  resolvedDb.run("COMMIT");
283
- } catch {
200
+ } catch (err) {
284
201
  // best-effort — access count update is non-critical
202
+ logger.warn("access count update failed", { error: String(err) });
285
203
  }
286
204
  }
287
205
 
@@ -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>;
@@ -0,0 +1,8 @@
1
+ export function getContextUtilizationString(usedTokens: number, maxTokens: number): string {
2
+ const safeMaxTokens = Math.max(0, maxTokens);
3
+ const safeUsedTokens = Math.max(0, usedTokens);
4
+ const utilization =
5
+ safeMaxTokens > 0 ? Math.min(100, Math.round((safeUsedTokens / safeMaxTokens) * 100)) : 0;
6
+
7
+ return `[${utilization}% used] ${safeUsedTokens} / ${safeMaxTokens} tokens`;
8
+ }
@@ -12,12 +12,17 @@
12
12
  * @module
13
13
  */
14
14
 
15
+ import { getLogger } from "../logging/domains";
15
16
  import { classifyErrorType, getErrorMessage } from "../orchestrator/fallback/error-classifier";
17
+ import { generateSessionSummary } from "../ux/session-summary";
18
+ import { getContextUtilizationString } from "./context-display";
16
19
  import type { ContextMonitor } from "./context-monitor";
17
20
  import { emitErrorEvent, emitToolCompleteEvent } from "./event-emitter";
18
21
  import type { ObservabilityEvent, SessionEventStore, SessionEvents } from "./event-store";
19
22
  import { accumulateTokensFromMessage, createEmptyTokenAggregate } from "./token-tracker";
20
23
 
24
+ const logger = getLogger("session", "event-handlers");
25
+
21
26
  /**
22
27
  * Dependencies for the observability event handler.
23
28
  */
@@ -161,7 +166,6 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
161
166
  // Check context utilization
162
167
  const utilResult = contextMonitor.processMessage(sessionId, info.tokens.input);
163
168
  if (utilResult.shouldWarn) {
164
- const pct = Math.round(utilResult.utilization * 100);
165
169
  // Append context_warning event
166
170
  const warningEvent: ObservabilityEvent = Object.freeze({
167
171
  type: "context_warning" as const,
@@ -176,10 +180,14 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
176
180
  // Fire toast (per D-35)
177
181
  showToast(
178
182
  "Context Warning",
179
- `Context at ${pct}% -- consider compacting`,
183
+ getContextUtilizationString(info.tokens.input, 200000),
180
184
  "warning",
181
185
  ).catch((err) => {
182
- console.error("[opencode-autopilot]", err);
186
+ logger.error("showToast failed for context warning", {
187
+ operation: "context_warning",
188
+ sessionId,
189
+ error: String(err),
190
+ });
183
191
  });
184
192
  }
185
193
  }
@@ -190,11 +198,17 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
190
198
  const sessionId = extractSessionId(properties);
191
199
  if (!sessionId) return;
192
200
 
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
- });
201
+ // Persist only new events since the last flush.
202
+ const sessionData = eventStore.getUnpersistedSession(sessionId);
203
+ if (sessionData && sessionData.events.length > 0) {
204
+ writeSessionLog(sessionData).catch((err) => {
205
+ logger.error("writeSessionLog failed on session.idle", {
206
+ operation: "session_end",
207
+ sessionId,
208
+ error: String(err),
209
+ });
210
+ });
211
+ }
198
212
  return;
199
213
  }
200
214
 
@@ -202,11 +216,43 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
202
216
  const sessionId = extractSessionId(properties);
203
217
  if (!sessionId) return;
204
218
 
219
+ eventStore.appendEvent(sessionId, {
220
+ type: "session_end",
221
+ timestamp: new Date().toISOString(),
222
+ sessionId,
223
+ durationMs: 0,
224
+ totalCost: 0,
225
+ });
226
+
227
+ const summary = generateSessionSummary(eventStore.getSession(sessionId), null);
228
+ logger.info(`Session ended summary:\n${summary}`, {
229
+ operation: "session_end",
230
+ sessionId,
231
+ });
232
+
233
+ void showToast(
234
+ "Session ended",
235
+ "Run /oc_summary to view the session summary.",
236
+ "info",
237
+ ).catch((err) => {
238
+ logger.error("showToast failed for session end", {
239
+ operation: "session_end",
240
+ sessionId,
241
+ error: String(err),
242
+ });
243
+ });
244
+
205
245
  // Final flush — session is done, remove from store
206
246
  const sessionData = eventStore.flush(sessionId);
207
- writeSessionLog(sessionData).catch((err) => {
208
- console.error("[opencode-autopilot]", err);
209
- });
247
+ if (sessionData && sessionData.events.length > 0) {
248
+ writeSessionLog(sessionData).catch((err) => {
249
+ logger.error("writeSessionLog failed on session.deleted", {
250
+ operation: "session_end",
251
+ sessionId,
252
+ error: String(err),
253
+ });
254
+ });
255
+ }
210
256
 
211
257
  // Clean up context monitor
212
258
  contextMonitor.cleanup(sessionId);
@@ -219,21 +265,24 @@ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps)
219
265
 
220
266
  // Append compaction decision event (not session_start)
221
267
  const compactEvent: ObservabilityEvent = Object.freeze({
222
- type: "decision" as const,
268
+ type: "compacted" as const,
223
269
  timestamp: new Date().toISOString(),
224
270
  sessionId,
225
- phase: "COMPACT",
226
- agent: "system",
227
- decision: "Session compacted",
228
- rationale: "Context window compaction triggered",
271
+ trigger: "context_window",
229
272
  });
230
273
  eventStore.appendEvent(sessionId, compactEvent);
231
274
 
232
275
  // 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
- });
276
+ const sessionData = eventStore.getUnpersistedSession(sessionId);
277
+ if (sessionData && sessionData.events.length > 0) {
278
+ writeSessionLog(sessionData).catch((err) => {
279
+ logger.error("writeSessionLog failed on session.compacted", {
280
+ operation: "compacted",
281
+ sessionId,
282
+ error: String(err),
283
+ });
284
+ });
285
+ }
237
286
  return;
238
287
  }
239
288
 
@@ -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
  }