@kodrunhq/opencode-autopilot 1.16.0 → 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 (53) hide show
  1. package/bin/inspect.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/config/index.ts +29 -0
  4. package/src/config/migrations.ts +196 -0
  5. package/src/config/v7.ts +45 -0
  6. package/src/config.ts +3 -3
  7. package/src/health/checks.ts +97 -0
  8. package/src/health/types.ts +1 -1
  9. package/src/index.ts +25 -2
  10. package/src/kernel/transaction.ts +48 -0
  11. package/src/kernel/types.ts +1 -2
  12. package/src/logging/domains.ts +39 -0
  13. package/src/logging/forensic-writer.ts +177 -0
  14. package/src/logging/index.ts +4 -0
  15. package/src/logging/logger.ts +44 -0
  16. package/src/logging/performance.ts +59 -0
  17. package/src/logging/rotation.ts +261 -0
  18. package/src/logging/types.ts +33 -0
  19. package/src/memory/capture-utils.ts +149 -0
  20. package/src/memory/capture.ts +16 -197
  21. package/src/memory/decay.ts +11 -2
  22. package/src/memory/injector.ts +4 -1
  23. package/src/memory/lessons.ts +85 -0
  24. package/src/memory/observations.ts +177 -0
  25. package/src/memory/preferences.ts +718 -0
  26. package/src/memory/projects.ts +83 -0
  27. package/src/memory/repository.ts +46 -1001
  28. package/src/memory/retrieval.ts +5 -1
  29. package/src/observability/context-display.ts +8 -0
  30. package/src/observability/event-handlers.ts +44 -6
  31. package/src/observability/forensic-log.ts +10 -2
  32. package/src/observability/forensic-schemas.ts +9 -1
  33. package/src/observability/log-reader.ts +20 -1
  34. package/src/orchestrator/error-context.ts +24 -0
  35. package/src/orchestrator/handlers/build-utils.ts +118 -0
  36. package/src/orchestrator/handlers/build.ts +13 -148
  37. package/src/orchestrator/handlers/retrospective.ts +0 -1
  38. package/src/orchestrator/lesson-memory.ts +7 -2
  39. package/src/orchestrator/orchestration-logger.ts +46 -31
  40. package/src/orchestrator/progress.ts +63 -0
  41. package/src/review/memory.ts +11 -3
  42. package/src/review/parse-findings.ts +116 -0
  43. package/src/review/pipeline.ts +3 -107
  44. package/src/review/selection.ts +38 -4
  45. package/src/scoring/time-provider.ts +23 -0
  46. package/src/tools/doctor.ts +2 -2
  47. package/src/tools/logs.ts +32 -6
  48. package/src/tools/orchestrate.ts +11 -9
  49. package/src/tools/replay.ts +42 -0
  50. package/src/tools/review.ts +8 -2
  51. package/src/tools/summary.ts +43 -0
  52. package/src/utils/random.ts +33 -0
  53. package/src/ux/session-summary.ts +56 -0
@@ -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,39 +1,19 @@
1
- /**
2
- * Memory capture handlers.
3
- *
4
- * Event capture remains a supporting path for project incidents and decisions.
5
- * Explicit user preference capture happens on the chat.message hook where the
6
- * actual outbound user-authored text parts are available.
7
- *
8
- * @module
9
- */
10
-
11
1
  import type { Database } from "bun:sqlite";
12
2
  import { basename } from "node:path";
3
+ import { getLogger } from "../logging/domains";
13
4
  import { resolveProjectIdentity } from "../projects/resolve";
5
+ import * as captureUtils from "./capture-utils";
14
6
  import { pruneStaleObservations } from "./decay";
15
7
  import { insertObservation, upsertPreferenceRecord, upsertProject } from "./repository";
16
8
  import type { ObservationType } from "./types";
17
9
 
18
- /**
19
- * Dependencies for the memory capture handlers.
20
- */
10
+ const logger = getLogger("memory", "capture");
11
+
21
12
  export interface MemoryCaptureDeps {
22
13
  readonly getDb: () => Database;
23
14
  readonly projectRoot: string;
24
15
  }
25
16
 
26
- interface PreferenceCandidate {
27
- readonly key: string;
28
- readonly value: string;
29
- readonly scope: "global" | "project";
30
- readonly confidence: number;
31
- readonly statement: string;
32
- }
33
-
34
- /**
35
- * Events that produce supporting observations.
36
- */
37
17
  const CAPTURE_EVENT_TYPES = new Set([
38
18
  "session.created",
39
19
  "session.deleted",
@@ -42,158 +22,6 @@ const CAPTURE_EVENT_TYPES = new Set([
42
22
  "app.phase_transition",
43
23
  ]);
44
24
 
45
- const PROJECT_SCOPE_HINTS = [
46
- "in this repo",
47
- "for this repo",
48
- "in this project",
49
- "for this project",
50
- "in this codebase",
51
- "for this codebase",
52
- "here ",
53
- "this repo ",
54
- "this project ",
55
- ] as const;
56
-
57
- const EXPLICIT_PREFERENCE_PATTERNS = [
58
- {
59
- regex: /\b(?:please|do|always|generally)\s+(?:use|prefer|keep|run|avoid)\s+(.+?)(?:[.!?]|$)/i,
60
- buildValue: (match: RegExpMatchArray) => match[1]?.trim() ?? "",
61
- },
62
- {
63
- regex: /\b(?:i|we)\s+(?:prefer|want|need|like)\s+(.+?)(?:[.!?]|$)/i,
64
- buildValue: (match: RegExpMatchArray) => match[1]?.trim() ?? "",
65
- },
66
- {
67
- regex: /\b(?:don't|do not|never)\s+(.+?)(?:[.!?]|$)/i,
68
- buildValue: (match: RegExpMatchArray) => `avoid ${match[1]?.trim() ?? ""}`,
69
- },
70
- ] as const;
71
-
72
- /**
73
- * Extracts a session ID from event properties.
74
- * Supports properties.sessionID, properties.info.id, properties.info.sessionID.
75
- */
76
- function extractSessionId(properties: Record<string, unknown>): string | undefined {
77
- if (typeof properties.sessionID === "string") return properties.sessionID;
78
- if (properties.info !== null && typeof properties.info === "object") {
79
- const info = properties.info as Record<string, unknown>;
80
- if (typeof info.sessionID === "string") return info.sessionID;
81
- if (typeof info.id === "string") return info.id;
82
- }
83
- return undefined;
84
- }
85
-
86
- function normalizePreferenceKey(value: string): string {
87
- const normalized = value
88
- .toLowerCase()
89
- .replace(/[^a-z0-9]+/g, " ")
90
- .trim()
91
- .split(/\s+/)
92
- .slice(0, 6)
93
- .join(".");
94
- return normalized.length > 0 ? normalized : "user.preference";
95
- }
96
-
97
- function normalizePreferenceValue(value: string): string {
98
- return value
99
- .replace(/\s+/g, " ")
100
- .trim()
101
- .replace(/[.!?]+$/, "");
102
- }
103
-
104
- function inferPreferenceScope(text: string): "global" | "project" {
105
- const lowerText = text.toLowerCase();
106
- return PROJECT_SCOPE_HINTS.some((hint) => lowerText.includes(hint)) ? "project" : "global";
107
- }
108
-
109
- function extractTextPartContent(part: unknown): string | null {
110
- if (part === null || typeof part !== "object") {
111
- return null;
112
- }
113
-
114
- const record = part as Record<string, unknown>;
115
- if (record.type !== "text") {
116
- return null;
117
- }
118
-
119
- if (typeof record.text === "string" && record.text.trim().length > 0) {
120
- return record.text;
121
- }
122
- if (typeof record.content === "string" && record.content.trim().length > 0) {
123
- return record.content;
124
- }
125
-
126
- return null;
127
- }
128
-
129
- function extractExplicitPreferenceCandidates(
130
- parts: readonly unknown[],
131
- ): readonly PreferenceCandidate[] {
132
- const joinedText = parts
133
- .map(extractTextPartContent)
134
- .filter((value): value is string => value !== null)
135
- .join("\n")
136
- .trim();
137
- if (joinedText.length === 0) {
138
- return Object.freeze([]);
139
- }
140
-
141
- const candidates: PreferenceCandidate[] = [];
142
- const scope = inferPreferenceScope(joinedText);
143
- const lines = joinedText
144
- .split(/\n+/)
145
- .flatMap((line) => line.split(/(?<=[.!?])\s+/))
146
- .map((line) => line.trim())
147
- .filter((line) => line.length > 0 && line.length <= 500);
148
-
149
- for (const line of lines) {
150
- for (const pattern of EXPLICIT_PREFERENCE_PATTERNS) {
151
- const match = line.match(pattern.regex);
152
- if (!match) {
153
- continue;
154
- }
155
-
156
- const value = normalizePreferenceValue(pattern.buildValue(match));
157
- if (value.length < 6) {
158
- continue;
159
- }
160
-
161
- candidates.push(
162
- Object.freeze({
163
- key: normalizePreferenceKey(value),
164
- value,
165
- scope,
166
- confidence: 0.9,
167
- statement: line,
168
- }),
169
- );
170
- break;
171
- }
172
- }
173
-
174
- const seen = new Set<string>();
175
- return Object.freeze(
176
- candidates.filter((candidate) => {
177
- const uniqueness = `${candidate.scope}:${candidate.key}:${candidate.value}`;
178
- if (seen.has(uniqueness)) {
179
- return false;
180
- }
181
- seen.add(uniqueness);
182
- return true;
183
- }),
184
- );
185
- }
186
-
187
- /**
188
- * Safely truncate a string to maxLen characters.
189
- */
190
- function truncate(s: string, maxLen: number): string {
191
- return s.length > maxLen ? s.slice(0, maxLen) : s;
192
- }
193
-
194
- /**
195
- * Creates an event capture handler matching the plugin event hook signature.
196
- */
197
25
  export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
198
26
  let currentSessionId: string | null = null;
199
27
  let currentProjectKey: string | null = null;
@@ -214,7 +42,7 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
214
42
  sessionId: currentSessionId,
215
43
  type,
216
44
  content,
217
- summary: truncate(summary, 200),
45
+ summary: captureUtils.truncate(summary, 200),
218
46
  confidence,
219
47
  accessCount: 0,
220
48
  createdAt: now(),
@@ -223,7 +51,7 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
223
51
  deps.getDb(),
224
52
  );
225
53
  } catch (err) {
226
- console.warn("[opencode-autopilot] memory capture failed:", err);
54
+ logger.warn("memory capture failed", { error: String(err) });
227
55
  }
228
56
  }
229
57
 
@@ -265,7 +93,7 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
265
93
  deps.getDb(),
266
94
  );
267
95
  } catch (err) {
268
- console.warn("[opencode-autopilot] upsertProject failed:", err);
96
+ logger.warn("upsertProject failed", { error: String(err) });
269
97
  }
270
98
  return;
271
99
  }
@@ -282,7 +110,7 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
282
110
  try {
283
111
  pruneStaleObservations(projectKey, db);
284
112
  } catch (err) {
285
- console.warn("[opencode-autopilot] pruneStaleObservations failed:", err);
113
+ logger.warn("pruneStaleObservations failed", { error: String(err) });
286
114
  }
287
115
  });
288
116
  }
@@ -290,21 +118,21 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
290
118
  }
291
119
 
292
120
  case "session.error": {
293
- const sessionId = extractSessionId(properties);
121
+ const sessionId = captureUtils.extractSessionId(properties);
294
122
  if (!sessionId || sessionId !== currentSessionId) return;
295
123
 
296
124
  const error = properties.error as Record<string, unknown> | undefined;
297
125
  const errorType = typeof error?.type === "string" ? error.type : "unknown";
298
126
  const message = typeof error?.message === "string" ? error.message : "Unknown error";
299
127
  const content = `${errorType}: ${message}`;
300
- const summary = truncate(message, 200);
128
+ const summary = captureUtils.truncate(message, 200);
301
129
 
302
130
  safeInsert("error", content, summary, 0.7);
303
131
  return;
304
132
  }
305
133
 
306
134
  case "app.decision": {
307
- const sessionId = extractSessionId(properties);
135
+ const sessionId = captureUtils.extractSessionId(properties);
308
136
  if (!sessionId || sessionId !== currentSessionId) return;
309
137
 
310
138
  const decision = typeof properties.decision === "string" ? properties.decision : "";
@@ -312,12 +140,12 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
312
140
 
313
141
  if (!decision) return;
314
142
 
315
- safeInsert("decision", decision, rationale || truncate(decision, 200), 0.8);
143
+ safeInsert("decision", decision, rationale || captureUtils.truncate(decision, 200), 0.8);
316
144
  return;
317
145
  }
318
146
 
319
147
  case "app.phase_transition": {
320
- const sessionId = extractSessionId(properties);
148
+ const sessionId = captureUtils.extractSessionId(properties);
321
149
  if (!sessionId || sessionId !== currentSessionId) return;
322
150
 
323
151
  const fromPhase =
@@ -335,16 +163,13 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
335
163
  };
336
164
  }
337
165
 
338
- /**
339
- * Creates a chat.message capture handler that records explicit user preferences.
340
- */
341
166
  export function createMemoryChatMessageHandler(deps: MemoryCaptureDeps) {
342
167
  return async (
343
168
  input: { readonly sessionID: string },
344
169
  output: { readonly parts: unknown[] },
345
170
  ): Promise<void> => {
346
171
  try {
347
- const candidates = extractExplicitPreferenceCandidates(output.parts);
172
+ const candidates = captureUtils.extractExplicitPreferenceCandidates(output.parts);
348
173
  if (candidates.length === 0) {
349
174
  return;
350
175
  }
@@ -392,15 +217,9 @@ export function createMemoryChatMessageHandler(deps: MemoryCaptureDeps) {
392
217
  );
393
218
  }
394
219
  } catch (err) {
395
- console.warn("[opencode-autopilot] explicit preference capture failed:", err);
220
+ logger.warn("explicit preference capture failed", { error: String(err) });
396
221
  }
397
222
  };
398
223
  }
399
224
 
400
- export const memoryCaptureInternals = Object.freeze({
401
- extractExplicitPreferenceCandidates,
402
- extractTextPartContent,
403
- inferPreferenceScope,
404
- normalizePreferenceKey,
405
- normalizePreferenceValue,
406
- });
225
+ export { captureUtils as memoryCaptureInternals };
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { Database } from "bun:sqlite";
13
+ import { systemTimeProvider, type TimeProvider } from "../scoring/time-provider";
13
14
  import {
14
15
  DEFAULT_HALF_LIFE_DAYS,
15
16
  MAX_OBSERVATIONS_PER_PROJECT,
@@ -35,8 +36,9 @@ export function computeRelevanceScore(
35
36
  accessCount: number,
36
37
  type: ObservationType,
37
38
  halfLifeDays: number = DEFAULT_HALF_LIFE_DAYS,
39
+ timeProvider: TimeProvider = systemTimeProvider,
38
40
  ): number {
39
- const ageMs = Date.now() - new Date(lastAccessed).getTime();
41
+ const ageMs = timeProvider.now() - new Date(lastAccessed).getTime();
40
42
  const ageDays = ageMs / MS_PER_DAY;
41
43
  const timeDecay = Math.exp(-ageDays / halfLifeDays);
42
44
  const frequencyWeight = Math.max(Math.log2(accessCount + 1), 1);
@@ -55,6 +57,7 @@ export function computeRelevanceScore(
55
57
  export function pruneStaleObservations(
56
58
  projectId: string | null,
57
59
  db?: Database,
60
+ timeProvider: TimeProvider = systemTimeProvider,
58
61
  ): { readonly pruned: number } {
59
62
  const fetchLimit = MAX_OBSERVATIONS_PER_PROJECT + 1000;
60
63
  const observations = getObservationsByProject(projectId, fetchLimit, db);
@@ -64,7 +67,13 @@ export function pruneStaleObservations(
64
67
  .filter((obs): obs is typeof obs & { id: number } => obs.id !== undefined)
65
68
  .map((obs) => ({
66
69
  id: obs.id,
67
- score: computeRelevanceScore(obs.lastAccessed, obs.accessCount, obs.type),
70
+ score: computeRelevanceScore(
71
+ obs.lastAccessed,
72
+ obs.accessCount,
73
+ obs.type,
74
+ DEFAULT_HALF_LIFE_DAYS,
75
+ timeProvider,
76
+ ),
68
77
  }));
69
78
 
70
79
  let pruned = 0;
@@ -11,8 +11,11 @@
11
11
  */
12
12
 
13
13
  import type { Database } from "bun:sqlite";
14
+ import { getLogger } from "../logging/domains";
14
15
  import { retrieveMemoryContext } from "./retrieval";
15
16
 
17
+ const logger = getLogger("memory", "injector");
18
+
16
19
  /**
17
20
  * Configuration for creating a memory injector.
18
21
  */
@@ -79,7 +82,7 @@ export function createMemoryInjector(config: MemoryInjectorConfig) {
79
82
  output.system.push(context);
80
83
  }
81
84
  } catch (err) {
82
- console.warn("[opencode-autopilot] memory injection failed:", err);
85
+ logger.warn("memory injection failed", { error: String(err) });
83
86
  }
84
87
  };
85
88
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Lesson repository operations.
3
+ * Handles retrieval of extracted lessons from past runs.
4
+ */
5
+
6
+ import type { Database } from "bun:sqlite";
7
+ import { lessonMemorySchema } from "../orchestrator/lesson-schemas";
8
+ import type { Lesson } from "../orchestrator/lesson-types";
9
+ import { getMemoryDb } from "./database";
10
+
11
+ function resolveDb(db?: Database): Database {
12
+ return db ?? getMemoryDb();
13
+ }
14
+
15
+ function tableExists(db: Database, tableName: string): boolean {
16
+ const row = db
17
+ .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
18
+ .get(tableName) as { name?: string } | null;
19
+ return row?.name === tableName;
20
+ }
21
+
22
+ interface ProjectLessonRow {
23
+ readonly content: string;
24
+ readonly domain: Lesson["domain"];
25
+ readonly extracted_at: string;
26
+ readonly source_phase: Lesson["sourcePhase"];
27
+ readonly last_updated_at: string | null;
28
+ }
29
+
30
+ function listLegacyLessons(projectId: string, db: Database): readonly Lesson[] {
31
+ if (!tableExists(db, "project_lesson_memory")) {
32
+ return Object.freeze([]);
33
+ }
34
+
35
+ const row = db
36
+ .query("SELECT state_json FROM project_lesson_memory WHERE project_id = ?")
37
+ .get(projectId) as { state_json?: string } | null;
38
+ if (row?.state_json === undefined) {
39
+ return Object.freeze([]);
40
+ }
41
+
42
+ try {
43
+ const parsed = lessonMemorySchema.parse(JSON.parse(row.state_json));
44
+ return Object.freeze(parsed.lessons);
45
+ } catch {
46
+ return Object.freeze([]);
47
+ }
48
+ }
49
+
50
+ function buildLessonsFromRows(rows: readonly ProjectLessonRow[]): readonly Lesson[] {
51
+ return Object.freeze(
52
+ rows.map((row) =>
53
+ Object.freeze({
54
+ content: row.content,
55
+ domain: row.domain,
56
+ extractedAt: row.extracted_at,
57
+ sourcePhase: row.source_phase,
58
+ }),
59
+ ),
60
+ );
61
+ }
62
+
63
+ export function listRelevantLessons(
64
+ projectId: string,
65
+ limit = 5,
66
+ db?: Database,
67
+ ): readonly Lesson[] {
68
+ const d = resolveDb(db);
69
+ if (tableExists(d, "project_lessons")) {
70
+ const rows = d
71
+ .query(
72
+ `SELECT content, domain, extracted_at, source_phase, last_updated_at
73
+ FROM project_lessons
74
+ WHERE project_id = ?
75
+ ORDER BY extracted_at DESC, lesson_id DESC
76
+ LIMIT ?`,
77
+ )
78
+ .all(projectId, limit) as ProjectLessonRow[];
79
+ if (rows.length > 0) {
80
+ return buildLessonsFromRows(rows);
81
+ }
82
+ }
83
+
84
+ return listLegacyLessons(projectId, d).slice(0, limit);
85
+ }