@kodrunhq/opencode-autopilot 1.16.0 → 1.18.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.
- package/assets/commands/oc-doctor.md +17 -0
- package/bin/configure-tui.ts +1 -1
- package/bin/inspect.ts +2 -2
- package/package.json +1 -1
- package/src/config/index.ts +29 -0
- package/src/config/migrations.ts +196 -0
- package/src/config/v7.ts +45 -0
- package/src/config.ts +108 -24
- package/src/health/checks.ts +165 -0
- package/src/health/runner.ts +8 -2
- package/src/health/types.ts +1 -1
- package/src/index.ts +25 -2
- package/src/kernel/transaction.ts +48 -0
- package/src/kernel/types.ts +1 -2
- package/src/logging/domains.ts +39 -0
- package/src/logging/forensic-writer.ts +177 -0
- package/src/logging/index.ts +4 -0
- package/src/logging/logger.ts +44 -0
- package/src/logging/performance.ts +59 -0
- package/src/logging/rotation.ts +261 -0
- package/src/logging/types.ts +33 -0
- package/src/memory/capture-utils.ts +149 -0
- package/src/memory/capture.ts +16 -197
- package/src/memory/decay.ts +11 -2
- package/src/memory/injector.ts +4 -1
- package/src/memory/lessons.ts +85 -0
- package/src/memory/observations.ts +177 -0
- package/src/memory/preferences.ts +718 -0
- package/src/memory/projects.ts +83 -0
- package/src/memory/repository.ts +46 -1001
- package/src/memory/retrieval.ts +5 -1
- package/src/observability/context-display.ts +8 -0
- package/src/observability/event-handlers.ts +44 -6
- package/src/observability/forensic-log.ts +10 -2
- package/src/observability/forensic-schemas.ts +9 -1
- package/src/observability/log-reader.ts +20 -1
- package/src/orchestrator/error-context.ts +24 -0
- package/src/orchestrator/handlers/build-utils.ts +118 -0
- package/src/orchestrator/handlers/build.ts +13 -148
- package/src/orchestrator/handlers/retrospective.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +7 -2
- package/src/orchestrator/orchestration-logger.ts +46 -31
- package/src/orchestrator/progress.ts +63 -0
- package/src/review/memory.ts +11 -3
- package/src/review/parse-findings.ts +116 -0
- package/src/review/pipeline.ts +3 -107
- package/src/review/selection.ts +38 -4
- package/src/scoring/time-provider.ts +23 -0
- package/src/tools/configure.ts +1 -1
- package/src/tools/doctor.ts +2 -2
- package/src/tools/logs.ts +32 -6
- package/src/tools/orchestrate.ts +11 -9
- package/src/tools/replay.ts +42 -0
- package/src/tools/review.ts +8 -2
- package/src/tools/summary.ts +43 -0
- package/src/types/background.ts +51 -0
- package/src/types/mcp.ts +27 -0
- package/src/types/recovery.ts +39 -0
- package/src/types/routing.ts +39 -0
- package/src/utils/random.ts +33 -0
- package/src/ux/session-summary.ts +56 -0
package/src/memory/capture.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
+
logger.warn("explicit preference capture failed", { error: String(err) });
|
|
396
221
|
}
|
|
397
222
|
};
|
|
398
223
|
}
|
|
399
224
|
|
|
400
|
-
export
|
|
401
|
-
extractExplicitPreferenceCandidates,
|
|
402
|
-
extractTextPartContent,
|
|
403
|
-
inferPreferenceScope,
|
|
404
|
-
normalizePreferenceKey,
|
|
405
|
-
normalizePreferenceValue,
|
|
406
|
-
});
|
|
225
|
+
export { captureUtils as memoryCaptureInternals };
|
package/src/memory/decay.ts
CHANGED
|
@@ -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 =
|
|
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(
|
|
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;
|
package/src/memory/injector.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation repository operations.
|
|
3
|
+
*
|
|
4
|
+
* Handles observation CRUD, search, and access tracking.
|
|
5
|
+
* Extracted from repository.ts for better module organization.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Database } from "bun:sqlite";
|
|
11
|
+
import { withTransaction } from "../kernel/transaction";
|
|
12
|
+
import { OBSERVATION_TYPES } from "./constants";
|
|
13
|
+
import { getMemoryDb } from "./database";
|
|
14
|
+
import { observationSchema } from "./schemas";
|
|
15
|
+
import type { Observation, ObservationType } from "./types";
|
|
16
|
+
|
|
17
|
+
/** Resolve optional db parameter to singleton fallback. */
|
|
18
|
+
function resolveDb(db?: Database): Database {
|
|
19
|
+
return db ?? getMemoryDb();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Validate observation type at runtime. */
|
|
23
|
+
function parseObservationType(value: unknown): ObservationType {
|
|
24
|
+
if (typeof value === "string" && (OBSERVATION_TYPES as readonly string[]).includes(value)) {
|
|
25
|
+
return value as ObservationType;
|
|
26
|
+
}
|
|
27
|
+
return "context";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Map a snake_case DB row to camelCase Observation. */
|
|
31
|
+
function rowToObservation(row: Record<string, unknown>): Observation {
|
|
32
|
+
return {
|
|
33
|
+
id: row.id as number,
|
|
34
|
+
projectId: (row.project_id as string) ?? null,
|
|
35
|
+
sessionId: row.session_id as string,
|
|
36
|
+
type: parseObservationType(row.type),
|
|
37
|
+
content: row.content as string,
|
|
38
|
+
summary: row.summary as string,
|
|
39
|
+
confidence: row.confidence as number,
|
|
40
|
+
accessCount: row.access_count as number,
|
|
41
|
+
createdAt: row.created_at as string,
|
|
42
|
+
lastAccessed: row.last_accessed as string,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Insert an observation. Validates via Zod before writing.
|
|
48
|
+
* Returns the observation with the generated id.
|
|
49
|
+
*/
|
|
50
|
+
export function insertObservation(obs: Omit<Observation, "id">, db?: Database): Observation {
|
|
51
|
+
const validated = observationSchema.omit({ id: true }).parse(obs);
|
|
52
|
+
const d = resolveDb(db);
|
|
53
|
+
|
|
54
|
+
return withTransaction(d, () => {
|
|
55
|
+
const row = d
|
|
56
|
+
.query<
|
|
57
|
+
{ id: number },
|
|
58
|
+
[string | null, string, ObservationType, string, string, number, number, string, string]
|
|
59
|
+
>(
|
|
60
|
+
`INSERT INTO observations (project_id, session_id, type, content, summary, confidence, access_count, created_at, last_accessed)
|
|
61
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
|
62
|
+
)
|
|
63
|
+
.get(
|
|
64
|
+
validated.projectId,
|
|
65
|
+
validated.sessionId,
|
|
66
|
+
validated.type,
|
|
67
|
+
validated.content,
|
|
68
|
+
validated.summary,
|
|
69
|
+
validated.confidence,
|
|
70
|
+
validated.accessCount,
|
|
71
|
+
validated.createdAt,
|
|
72
|
+
validated.lastAccessed,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (!row) {
|
|
76
|
+
throw new Error("Failed to insert observation");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { ...validated, id: row.id };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Search observations using FTS5 MATCH with BM25 ranking.
|
|
85
|
+
* Filters by projectId (null for user-level observations).
|
|
86
|
+
*/
|
|
87
|
+
export function searchObservations(
|
|
88
|
+
query: string,
|
|
89
|
+
projectId: string | null,
|
|
90
|
+
limit = 20,
|
|
91
|
+
db?: Database,
|
|
92
|
+
): Array<Observation & { ftsRank: number }> {
|
|
93
|
+
const d = resolveDb(db);
|
|
94
|
+
|
|
95
|
+
const projectFilter = projectId === null ? "AND o.project_id IS NULL" : "AND o.project_id = ?";
|
|
96
|
+
const safeFtsQuery = `"${query.replace(/"/g, '""')}"`;
|
|
97
|
+
const params: Array<string | number> =
|
|
98
|
+
projectId === null ? [safeFtsQuery, limit] : [safeFtsQuery, projectId, limit];
|
|
99
|
+
|
|
100
|
+
const rows = d
|
|
101
|
+
.query(
|
|
102
|
+
`SELECT o.*, bm25(observations_fts) as fts_rank
|
|
103
|
+
FROM observations_fts f
|
|
104
|
+
JOIN observations o ON o.id = f.rowid
|
|
105
|
+
WHERE observations_fts MATCH ?
|
|
106
|
+
${projectFilter}
|
|
107
|
+
ORDER BY fts_rank
|
|
108
|
+
LIMIT ?`,
|
|
109
|
+
)
|
|
110
|
+
.all(...params) as Array<Record<string, unknown>>;
|
|
111
|
+
|
|
112
|
+
return rows.map((row) => ({
|
|
113
|
+
...rowToObservation(row),
|
|
114
|
+
ftsRank: row.fts_rank as number,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get observations filtered by project_id, ordered by created_at DESC.
|
|
120
|
+
*/
|
|
121
|
+
export function getObservationsByProject(
|
|
122
|
+
projectId: string | null,
|
|
123
|
+
limit = 50,
|
|
124
|
+
db?: Database,
|
|
125
|
+
): readonly Observation[] {
|
|
126
|
+
const d = resolveDb(db);
|
|
127
|
+
|
|
128
|
+
const whereClause = projectId === null ? "WHERE project_id IS NULL" : "WHERE project_id = ?";
|
|
129
|
+
const params: Array<string | number> = projectId === null ? [limit] : [projectId, limit];
|
|
130
|
+
|
|
131
|
+
const rows = d
|
|
132
|
+
.query(`SELECT * FROM observations ${whereClause} ORDER BY created_at DESC LIMIT ?`)
|
|
133
|
+
.all(...params) as Array<Record<string, unknown>>;
|
|
134
|
+
|
|
135
|
+
return rows.map(rowToObservation);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get recent failure observations for a project.
|
|
140
|
+
*/
|
|
141
|
+
export function getRecentFailureObservations(
|
|
142
|
+
projectId: string,
|
|
143
|
+
limit = 5,
|
|
144
|
+
db?: Database,
|
|
145
|
+
): readonly Observation[] {
|
|
146
|
+
const d = resolveDb(db);
|
|
147
|
+
const rows = d
|
|
148
|
+
.query(
|
|
149
|
+
`SELECT *
|
|
150
|
+
FROM observations
|
|
151
|
+
WHERE project_id = ?
|
|
152
|
+
AND type = 'error'
|
|
153
|
+
ORDER BY created_at DESC, id DESC
|
|
154
|
+
LIMIT ?`,
|
|
155
|
+
)
|
|
156
|
+
.all(projectId, limit) as Array<Record<string, unknown>>;
|
|
157
|
+
return Object.freeze(rows.map(rowToObservation));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Delete an observation by id.
|
|
162
|
+
*/
|
|
163
|
+
export function deleteObservation(id: number, db?: Database): void {
|
|
164
|
+
const d = resolveDb(db);
|
|
165
|
+
d.run("DELETE FROM observations WHERE id = ?", [id]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Increment access_count and update last_accessed for an observation.
|
|
170
|
+
*/
|
|
171
|
+
export function updateAccessCount(id: number, db?: Database): void {
|
|
172
|
+
const d = resolveDb(db);
|
|
173
|
+
d.run("UPDATE observations SET access_count = access_count + 1, last_accessed = ? WHERE id = ?", [
|
|
174
|
+
new Date().toISOString(),
|
|
175
|
+
id,
|
|
176
|
+
]);
|
|
177
|
+
}
|