@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
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Log rotation and retention for the OpenCode Autopilot plugin.
3
+ *
4
+ * Handles:
5
+ * - Max file-count enforcement (oldest files pruned first)
6
+ * - Time-based expiry (files older than `maxAgeDays` are removed)
7
+ * - Gzip compression of rotated `.log` / `.jsonl` files
8
+ *
9
+ * All filesystem operations use `node:fs/promises` for portability.
10
+ *
11
+ * @module
12
+ */
13
+
14
+ import { createReadStream, createWriteStream } from "node:fs";
15
+ import { readdir, rename, stat, unlink } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { createGzip } from "node:zlib";
18
+ import { isEnoentError } from "../utils/fs-helpers";
19
+
20
+ /** Extensions eligible for gzip compression during rotation. */
21
+ const COMPRESSIBLE_EXTENSIONS = new Set([".log", ".jsonl"]);
22
+
23
+ /** Default maximum number of log files to keep (excluding compressed archives). */
24
+ const DEFAULT_MAX_FILES = 10;
25
+
26
+ /** Default maximum log file size in bytes (10 MiB). */
27
+ const DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024;
28
+
29
+ /** Default maximum age in days before a file is deleted. */
30
+ const DEFAULT_MAX_AGE_DAYS = 30;
31
+
32
+ export interface RotationOptions {
33
+ /**
34
+ * Maximum number of log files to retain (oldest are pruned first).
35
+ * Does not count `.gz` archives.
36
+ * @default 10
37
+ */
38
+ readonly maxFiles?: number;
39
+
40
+ /**
41
+ * Maximum individual file size in bytes. Files exceeding this limit are
42
+ * compressed and renamed with a `.gz` extension before the next write.
43
+ * @default 10_485_760 (10 MiB)
44
+ */
45
+ readonly maxSize?: number;
46
+
47
+ /**
48
+ * Maximum age in days. Files (and archives) older than this are deleted.
49
+ * @default 30
50
+ */
51
+ readonly maxAgeDays?: number;
52
+ }
53
+
54
+ export interface RotationResult {
55
+ /** Number of files compressed into `.gz` archives. */
56
+ readonly compressed: number;
57
+ /** Number of files deleted (age or count limit exceeded). */
58
+ readonly deleted: number;
59
+ }
60
+
61
+ interface FileEntry {
62
+ readonly name: string;
63
+ readonly path: string;
64
+ readonly mtimeMs: number;
65
+ readonly size: number;
66
+ }
67
+
68
+ function isCompressible(name: string): boolean {
69
+ const dot = name.lastIndexOf(".");
70
+ if (dot === -1) return false;
71
+ const ext = name.slice(dot);
72
+ return COMPRESSIBLE_EXTENSIONS.has(ext);
73
+ }
74
+
75
+ function isArchive(name: string): boolean {
76
+ return name.endsWith(".gz");
77
+ }
78
+
79
+ /**
80
+ * Compresses `sourcePath` to `sourcePath + ".gz"` then removes the original.
81
+ * Returns `true` on success, `false` if the source vanished mid-flight.
82
+ */
83
+ async function gzipFile(sourcePath: string): Promise<boolean> {
84
+ const archivePath = `${sourcePath}.gz`;
85
+ await new Promise<void>((resolve, reject) => {
86
+ const readStream = createReadStream(sourcePath);
87
+ const writeStream = createWriteStream(archivePath);
88
+ const gzip = createGzip();
89
+
90
+ readStream.on("error", reject);
91
+ writeStream.on("error", reject);
92
+ writeStream.on("finish", resolve);
93
+
94
+ readStream.pipe(gzip).pipe(writeStream);
95
+ });
96
+ // Only remove the original after the archive is fully written.
97
+ await unlink(sourcePath);
98
+ return true;
99
+ }
100
+
101
+ /**
102
+ * Reads all entries in `logDir`, resolving `stat` for each.
103
+ * Silently skips entries that disappear between readdir and stat.
104
+ */
105
+ async function listEntries(logDir: string): Promise<readonly FileEntry[]> {
106
+ let names: string[];
107
+ try {
108
+ names = await readdir(logDir);
109
+ } catch (error: unknown) {
110
+ if (isEnoentError(error)) return [];
111
+ throw error;
112
+ }
113
+
114
+ const entries: FileEntry[] = [];
115
+ for (const name of names) {
116
+ const filePath = join(logDir, name);
117
+ try {
118
+ const fileStat = await stat(filePath);
119
+ if (!fileStat.isFile()) continue;
120
+ entries.push({
121
+ name,
122
+ path: filePath,
123
+ mtimeMs: fileStat.mtimeMs,
124
+ size: fileStat.size,
125
+ });
126
+ } catch (error: unknown) {
127
+ if (!isEnoentError(error)) throw error;
128
+ // File disappeared between readdir and stat — skip it.
129
+ }
130
+ }
131
+
132
+ return entries;
133
+ }
134
+
135
+ /**
136
+ * Checks whether a single file exceeds the given `maxSize` threshold.
137
+ *
138
+ * Intended for use by writers that want to rotate before the next append.
139
+ *
140
+ * @param filePath - Absolute path to the log file.
141
+ * @param maxSize - Size limit in bytes.
142
+ * @returns `true` when the file exists and its size exceeds `maxSize`.
143
+ */
144
+ export async function exceedsMaxSize(filePath: string, maxSize: number): Promise<boolean> {
145
+ try {
146
+ const fileStat = await stat(filePath);
147
+ return fileStat.isFile() && fileStat.size > maxSize;
148
+ } catch (error: unknown) {
149
+ if (isEnoentError(error)) return false;
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Rotates a single active log file by compressing it to `<filePath>.gz`.
156
+ *
157
+ * The caller is responsible for opening a fresh log file afterwards.
158
+ * Returns `true` when the file was successfully rotated, `false` when the
159
+ * file did not exist (nothing to rotate).
160
+ *
161
+ * @param filePath - Absolute path to the log file to rotate.
162
+ */
163
+ export async function rotateFile(filePath: string): Promise<boolean> {
164
+ try {
165
+ const fileStat = await stat(filePath);
166
+ if (!fileStat.isFile()) return false;
167
+ } catch (error: unknown) {
168
+ if (isEnoentError(error)) return false;
169
+ throw error;
170
+ }
171
+
172
+ if (!isCompressible(filePath)) {
173
+ // Non-compressible files are renamed with a timestamp suffix.
174
+ const rotatedPath = `${filePath}.${Date.now()}.bak`;
175
+ await rename(filePath, rotatedPath);
176
+ return true;
177
+ }
178
+
179
+ return gzipFile(filePath);
180
+ }
181
+
182
+ /**
183
+ * Runs the full rotation and retention policy for all log files in `logDir`.
184
+ *
185
+ * **What this does (in order):**
186
+ * 1. Compress oversized `.log` / `.jsonl` files into `.gz` archives.
187
+ * 2. Delete files (any extension) older than `maxAgeDays`.
188
+ * 3. Prune oldest plain log files when their count exceeds `maxFiles`.
189
+ *
190
+ * @param logDir - Directory containing log files.
191
+ * @param options - Rotation and retention options.
192
+ * @returns Counts of compressed and deleted files.
193
+ */
194
+ export async function rotateLogs(
195
+ logDir: string,
196
+ options?: RotationOptions,
197
+ ): Promise<RotationResult> {
198
+ const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES;
199
+ const maxSize = options?.maxSize ?? DEFAULT_MAX_SIZE_BYTES;
200
+ const maxAgeDays = options?.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;
201
+ const ageThresholdMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
202
+
203
+ let compressed = 0;
204
+ let deleted = 0;
205
+
206
+ // --- Pass 1: Compress oversized plain log files ---
207
+ {
208
+ const entries = await listEntries(logDir);
209
+ for (const entry of entries) {
210
+ if (isArchive(entry.name)) continue;
211
+ if (!isCompressible(entry.name)) continue;
212
+ if (entry.size <= maxSize) continue;
213
+
214
+ try {
215
+ const rotated = await gzipFile(entry.path);
216
+ if (rotated) compressed++;
217
+ } catch (error: unknown) {
218
+ if (!isEnoentError(error)) throw error;
219
+ // File disappeared — not an error.
220
+ }
221
+ }
222
+ }
223
+
224
+ // --- Pass 2: Delete files older than maxAgeDays (any extension) ---
225
+ {
226
+ const entries = await listEntries(logDir);
227
+ for (const entry of entries) {
228
+ if (entry.mtimeMs >= ageThresholdMs) continue;
229
+
230
+ try {
231
+ await unlink(entry.path);
232
+ deleted++;
233
+ } catch (error: unknown) {
234
+ if (!isEnoentError(error)) throw error;
235
+ }
236
+ }
237
+ }
238
+
239
+ // --- Pass 3: Prune oldest plain log files that exceed maxFiles count ---
240
+ {
241
+ const entries = await listEntries(logDir);
242
+ const plainLogs = entries
243
+ .filter((e) => !isArchive(e.name) && isCompressible(e.name))
244
+ .sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
245
+
246
+ const overflow = plainLogs.length - maxFiles;
247
+ if (overflow > 0) {
248
+ const toDelete = plainLogs.slice(0, overflow);
249
+ for (const entry of toDelete) {
250
+ try {
251
+ await unlink(entry.path);
252
+ deleted++;
253
+ } catch (error: unknown) {
254
+ if (!isEnoentError(error)) throw error;
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ return { compressed, deleted };
261
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Logging type definitions for the OpenCode Autopilot plugin.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
8
+
9
+ export interface LogMetadata {
10
+ readonly domain: string;
11
+ readonly subsystem?: string;
12
+ readonly operation?: string;
13
+ readonly [key: string]: unknown;
14
+ }
15
+
16
+ export interface LogEntry {
17
+ readonly timestamp: string;
18
+ readonly level: LogLevel;
19
+ readonly message: string;
20
+ readonly metadata: LogMetadata;
21
+ }
22
+
23
+ export interface LogSink {
24
+ write(entry: LogEntry): void;
25
+ }
26
+
27
+ export interface Logger {
28
+ debug(message: string, metadata?: Partial<LogMetadata>): void;
29
+ info(message: string, metadata?: Partial<LogMetadata>): void;
30
+ warn(message: string, metadata?: Partial<LogMetadata>): void;
31
+ error(message: string, metadata?: Partial<LogMetadata>): void;
32
+ child(metadata: Partial<LogMetadata>): Logger;
33
+ }
@@ -0,0 +1,149 @@
1
+ const PROJECT_SCOPE_HINTS = [
2
+ "in this repo",
3
+ "for this repo",
4
+ "in this project",
5
+ "for this project",
6
+ "in this codebase",
7
+ "for this codebase",
8
+ "here ",
9
+ "this repo ",
10
+ "this project ",
11
+ ] as const;
12
+
13
+ const EXPLICIT_PREFERENCE_PATTERNS = [
14
+ {
15
+ regex: /\b(?:please|do|always|generally)\s+(?:use|prefer|keep|run|avoid)\s+(.+?)(?:[.!?]|$)/i,
16
+ buildValue: (match: RegExpMatchArray) => match[1]?.trim() ?? "",
17
+ },
18
+ {
19
+ regex: /\b(?:i|we)\s+(?:prefer|want|need|like)\s+(.+?)(?:[.!?]|$)/i,
20
+ buildValue: (match: RegExpMatchArray) => match[1]?.trim() ?? "",
21
+ },
22
+ {
23
+ regex: /\b(?:don't|do not|never)\s+(.+?)(?:[.!?]|$)/i,
24
+ buildValue: (match: RegExpMatchArray) => `avoid ${match[1]?.trim() ?? ""}`,
25
+ },
26
+ ] as const;
27
+
28
+ export interface PreferenceCandidate {
29
+ readonly key: string;
30
+ readonly value: string;
31
+ readonly scope: "global" | "project";
32
+ readonly confidence: number;
33
+ readonly statement: string;
34
+ }
35
+
36
+ export function extractSessionId(properties: Record<string, unknown>): string | undefined {
37
+ if (typeof properties.sessionID === "string") return properties.sessionID;
38
+ if (properties.info !== null && typeof properties.info === "object") {
39
+ const info = properties.info as Record<string, unknown>;
40
+ if (typeof info.sessionID === "string") return info.sessionID;
41
+ if (typeof info.id === "string") return info.id;
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ export function normalizePreferenceKey(value: string): string {
47
+ const normalized = value
48
+ .toLowerCase()
49
+ .replace(/[^a-z0-9]+/g, " ")
50
+ .trim()
51
+ .split(/\s+/)
52
+ .slice(0, 6)
53
+ .join(".");
54
+ return normalized.length > 0 ? normalized : "user.preference";
55
+ }
56
+
57
+ export function normalizePreferenceValue(value: string): string {
58
+ return value
59
+ .replace(/\s+/g, " ")
60
+ .trim()
61
+ .replace(/[.!?]+$/, "");
62
+ }
63
+
64
+ export function inferPreferenceScope(text: string): "global" | "project" {
65
+ const lowerText = text.toLowerCase();
66
+ return PROJECT_SCOPE_HINTS.some((hint) => lowerText.includes(hint)) ? "project" : "global";
67
+ }
68
+
69
+ export function extractTextPartContent(part: unknown): string | null {
70
+ if (part === null || typeof part !== "object") {
71
+ return null;
72
+ }
73
+
74
+ const record = part as Record<string, unknown>;
75
+ if (record.type !== "text") {
76
+ return null;
77
+ }
78
+
79
+ if (typeof record.text === "string" && record.text.trim().length > 0) {
80
+ return record.text;
81
+ }
82
+ if (typeof record.content === "string" && record.content.trim().length > 0) {
83
+ return record.content;
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ export function extractExplicitPreferenceCandidates(
90
+ parts: readonly unknown[],
91
+ ): readonly PreferenceCandidate[] {
92
+ const joinedText = parts
93
+ .map(extractTextPartContent)
94
+ .filter((value): value is string => value !== null)
95
+ .join("\n")
96
+ .trim();
97
+ if (joinedText.length === 0) {
98
+ return Object.freeze([]);
99
+ }
100
+
101
+ const candidates: PreferenceCandidate[] = [];
102
+ const scope = inferPreferenceScope(joinedText);
103
+ const lines = joinedText
104
+ .split(/\n+/)
105
+ .flatMap((line) => line.split(/(?<=[.!?])\s+/))
106
+ .map((line) => line.trim())
107
+ .filter((line) => line.length > 0 && line.length <= 500);
108
+
109
+ for (const line of lines) {
110
+ for (const pattern of EXPLICIT_PREFERENCE_PATTERNS) {
111
+ const match = line.match(pattern.regex);
112
+ if (!match) {
113
+ continue;
114
+ }
115
+
116
+ const value = normalizePreferenceValue(pattern.buildValue(match));
117
+ if (value.length < 6) {
118
+ continue;
119
+ }
120
+
121
+ candidates.push(
122
+ Object.freeze({
123
+ key: normalizePreferenceKey(value),
124
+ value,
125
+ scope,
126
+ confidence: 0.9,
127
+ statement: line,
128
+ }),
129
+ );
130
+ break;
131
+ }
132
+ }
133
+
134
+ const seen = new Set<string>();
135
+ return Object.freeze(
136
+ candidates.filter((candidate) => {
137
+ const uniqueness = `${candidate.scope}:${candidate.key}:${candidate.value}`;
138
+ if (seen.has(uniqueness)) {
139
+ return false;
140
+ }
141
+ seen.add(uniqueness);
142
+ return true;
143
+ }),
144
+ );
145
+ }
146
+
147
+ export function truncate(s: string, maxLen: number): string {
148
+ return s.length > maxLen ? s.slice(0, maxLen) : s;
149
+ }
@@ -1,35 +1,19 @@
1
- /**
2
- * Event capture handler for memory observations.
3
- *
4
- * Subscribes to OpenCode session events and extracts memory-worthy
5
- * observations from decision, error, and phase_transition events.
6
- * Noisy events (tool_complete, context_warning, session_start/end)
7
- * are filtered out per Research Pitfall 4.
8
- *
9
- * Factory pattern matches createObservabilityEventHandler in
10
- * src/observability/event-handlers.ts.
11
- *
12
- * @module
13
- */
14
-
15
1
  import type { Database } from "bun:sqlite";
16
2
  import { basename } from "node:path";
3
+ import { getLogger } from "../logging/domains";
4
+ import { resolveProjectIdentity } from "../projects/resolve";
5
+ import * as captureUtils from "./capture-utils";
17
6
  import { pruneStaleObservations } from "./decay";
18
- import { computeProjectKey } from "./project-key";
19
- import { insertObservation, upsertProject } from "./repository";
7
+ import { insertObservation, upsertPreferenceRecord, upsertProject } from "./repository";
20
8
  import type { ObservationType } from "./types";
21
9
 
22
- /**
23
- * Dependencies for the memory capture handler.
24
- */
10
+ const logger = getLogger("memory", "capture");
11
+
25
12
  export interface MemoryCaptureDeps {
26
13
  readonly getDb: () => Database;
27
14
  readonly projectRoot: string;
28
15
  }
29
16
 
30
- /**
31
- * Events that produce memory observations.
32
- */
33
17
  const CAPTURE_EVENT_TYPES = new Set([
34
18
  "session.created",
35
19
  "session.deleted",
@@ -38,35 +22,6 @@ const CAPTURE_EVENT_TYPES = new Set([
38
22
  "app.phase_transition",
39
23
  ]);
40
24
 
41
- /**
42
- * Extracts a session ID from event properties.
43
- * Supports properties.sessionID, properties.info.id, properties.info.sessionID.
44
- */
45
- function extractSessionId(properties: Record<string, unknown>): string | undefined {
46
- if (typeof properties.sessionID === "string") return properties.sessionID;
47
- if (properties.info !== null && typeof properties.info === "object") {
48
- const info = properties.info as Record<string, unknown>;
49
- if (typeof info.sessionID === "string") return info.sessionID;
50
- if (typeof info.id === "string") return info.id;
51
- }
52
- return undefined;
53
- }
54
-
55
- /**
56
- * Safely truncate a string to maxLen characters.
57
- */
58
- function truncate(s: string, maxLen: number): string {
59
- return s.length > maxLen ? s.slice(0, maxLen) : s;
60
- }
61
-
62
- /**
63
- * Creates a memory capture handler that subscribes to OpenCode events.
64
- *
65
- * Returns an async function matching the event handler signature:
66
- * `(input: { event: { type: string; [key: string]: unknown } }) => Promise<void>`
67
- *
68
- * Pure observer: never modifies the event or session output.
69
- */
70
25
  export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
71
26
  let currentSessionId: string | null = null;
72
27
  let currentProjectKey: string | null = null;
@@ -87,7 +42,7 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
87
42
  sessionId: currentSessionId,
88
43
  type,
89
44
  content,
90
- summary: truncate(summary, 200),
45
+ summary: captureUtils.truncate(summary, 200),
91
46
  confidence,
92
47
  accessCount: 0,
93
48
  createdAt: now(),
@@ -96,7 +51,7 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
96
51
  deps.getDb(),
97
52
  );
98
53
  } catch (err) {
99
- console.warn("[opencode-autopilot] memory capture failed:", err);
54
+ logger.warn("memory capture failed", { error: String(err) });
100
55
  }
101
56
  }
102
57
 
@@ -110,7 +65,6 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
110
65
  ? (rawProps as Record<string, unknown>)
111
66
  : {};
112
67
 
113
- // Skip noisy events early
114
68
  if (!CAPTURE_EVENT_TYPES.has(event.type)) return;
115
69
 
116
70
  switch (event.type) {
@@ -121,7 +75,10 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
121
75
  if (!info.id) return;
122
76
 
123
77
  currentSessionId = info.id;
124
- currentProjectKey = computeProjectKey(deps.projectRoot);
78
+ const resolvedProject = await resolveProjectIdentity(deps.projectRoot, {
79
+ db: deps.getDb(),
80
+ });
81
+ currentProjectKey = resolvedProject.id;
125
82
  const projectName = basename(deps.projectRoot);
126
83
 
127
84
  try {
@@ -130,12 +87,13 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
130
87
  id: currentProjectKey,
131
88
  path: deps.projectRoot,
132
89
  name: projectName,
90
+ firstSeenAt: resolvedProject.firstSeenAt,
133
91
  lastUpdated: now(),
134
92
  },
135
93
  deps.getDb(),
136
94
  );
137
95
  } catch (err) {
138
- console.warn("[opencode-autopilot] upsertProject failed:", err);
96
+ logger.warn("upsertProject failed", { error: String(err) });
139
97
  }
140
98
  return;
141
99
  }
@@ -144,18 +102,15 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
144
102
  const projectKey = currentProjectKey;
145
103
  const db = deps.getDb();
146
104
 
147
- // Reset state
148
105
  currentSessionId = null;
149
106
  currentProjectKey = null;
150
107
 
151
- // Defer pruning to avoid blocking the session.deleted handler.
152
- // Best-effort: will not run if the process exits before this microtask drains.
153
108
  if (projectKey) {
154
109
  queueMicrotask(() => {
155
110
  try {
156
111
  pruneStaleObservations(projectKey, db);
157
112
  } catch (err) {
158
- console.warn("[opencode-autopilot] pruneStaleObservations failed:", err);
113
+ logger.warn("pruneStaleObservations failed", { error: String(err) });
159
114
  }
160
115
  });
161
116
  }
@@ -163,21 +118,21 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
163
118
  }
164
119
 
165
120
  case "session.error": {
166
- const sessionId = extractSessionId(properties);
121
+ const sessionId = captureUtils.extractSessionId(properties);
167
122
  if (!sessionId || sessionId !== currentSessionId) return;
168
123
 
169
124
  const error = properties.error as Record<string, unknown> | undefined;
170
125
  const errorType = typeof error?.type === "string" ? error.type : "unknown";
171
126
  const message = typeof error?.message === "string" ? error.message : "Unknown error";
172
127
  const content = `${errorType}: ${message}`;
173
- const summary = truncate(message, 200);
128
+ const summary = captureUtils.truncate(message, 200);
174
129
 
175
130
  safeInsert("error", content, summary, 0.7);
176
131
  return;
177
132
  }
178
133
 
179
134
  case "app.decision": {
180
- const sessionId = extractSessionId(properties);
135
+ const sessionId = captureUtils.extractSessionId(properties);
181
136
  if (!sessionId || sessionId !== currentSessionId) return;
182
137
 
183
138
  const decision = typeof properties.decision === "string" ? properties.decision : "";
@@ -185,21 +140,20 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
185
140
 
186
141
  if (!decision) return;
187
142
 
188
- safeInsert("decision", decision, rationale || truncate(decision, 200), 0.8);
143
+ safeInsert("decision", decision, rationale || captureUtils.truncate(decision, 200), 0.8);
189
144
  return;
190
145
  }
191
146
 
192
147
  case "app.phase_transition": {
193
- const sessionId = extractSessionId(properties);
148
+ const sessionId = captureUtils.extractSessionId(properties);
194
149
  if (!sessionId || sessionId !== currentSessionId) return;
195
150
 
196
151
  const fromPhase =
197
152
  typeof properties.fromPhase === "string" ? properties.fromPhase : "unknown";
198
153
  const toPhase = typeof properties.toPhase === "string" ? properties.toPhase : "unknown";
199
154
  const content = `Phase transition: ${fromPhase} -> ${toPhase}`;
200
- const summary = content;
201
155
 
202
- safeInsert("pattern", content, summary, 0.6);
156
+ safeInsert("pattern", content, content, 0.6);
203
157
  return;
204
158
  }
205
159
 
@@ -208,3 +162,64 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
208
162
  }
209
163
  };
210
164
  }
165
+
166
+ export function createMemoryChatMessageHandler(deps: MemoryCaptureDeps) {
167
+ return async (
168
+ input: { readonly sessionID: string },
169
+ output: { readonly parts: unknown[] },
170
+ ): Promise<void> => {
171
+ try {
172
+ const candidates = captureUtils.extractExplicitPreferenceCandidates(output.parts);
173
+ if (candidates.length === 0) {
174
+ return;
175
+ }
176
+
177
+ const resolvedProject = await resolveProjectIdentity(deps.projectRoot, {
178
+ db: deps.getDb(),
179
+ });
180
+ const projectName = basename(deps.projectRoot);
181
+ const timestamp = new Date().toISOString();
182
+
183
+ upsertProject(
184
+ {
185
+ id: resolvedProject.id,
186
+ path: deps.projectRoot,
187
+ name: projectName,
188
+ firstSeenAt: resolvedProject.firstSeenAt,
189
+ lastUpdated: timestamp,
190
+ },
191
+ deps.getDb(),
192
+ );
193
+
194
+ for (const candidate of candidates) {
195
+ upsertPreferenceRecord(
196
+ {
197
+ key: candidate.key,
198
+ value: candidate.value,
199
+ scope: candidate.scope,
200
+ projectId: candidate.scope === "project" ? resolvedProject.id : null,
201
+ status: "confirmed",
202
+ confidence: candidate.confidence,
203
+ sourceSession: input.sessionID,
204
+ createdAt: timestamp,
205
+ lastUpdated: timestamp,
206
+ evidence: [
207
+ {
208
+ sessionId: input.sessionID,
209
+ statement: candidate.statement,
210
+ confidence: candidate.confidence,
211
+ confirmed: true,
212
+ createdAt: timestamp,
213
+ },
214
+ ],
215
+ },
216
+ deps.getDb(),
217
+ );
218
+ }
219
+ } catch (err) {
220
+ logger.warn("explicit preference capture failed", { error: String(err) });
221
+ }
222
+ };
223
+ }
224
+
225
+ export { captureUtils as memoryCaptureInternals };