@kodrunhq/opencode-autopilot 1.15.2 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/bin/cli.ts +5 -0
  2. package/bin/inspect.ts +337 -0
  3. package/package.json +1 -1
  4. package/src/agents/autopilot.ts +7 -15
  5. package/src/health/checks.ts +29 -4
  6. package/src/index.ts +103 -11
  7. package/src/inspect/formatters.ts +225 -0
  8. package/src/inspect/repository.ts +882 -0
  9. package/src/kernel/database.ts +45 -0
  10. package/src/kernel/migrations.ts +62 -0
  11. package/src/kernel/repository.ts +571 -0
  12. package/src/kernel/schema.ts +122 -0
  13. package/src/kernel/types.ts +66 -0
  14. package/src/memory/capture.ts +221 -25
  15. package/src/memory/database.ts +74 -12
  16. package/src/memory/index.ts +17 -1
  17. package/src/memory/project-key.ts +6 -0
  18. package/src/memory/repository.ts +833 -42
  19. package/src/memory/retrieval.ts +83 -169
  20. package/src/memory/schemas.ts +39 -7
  21. package/src/memory/types.ts +4 -0
  22. package/src/observability/event-handlers.ts +28 -17
  23. package/src/observability/event-store.ts +29 -1
  24. package/src/observability/forensic-log.ts +159 -0
  25. package/src/observability/forensic-schemas.ts +69 -0
  26. package/src/observability/forensic-types.ts +10 -0
  27. package/src/observability/index.ts +21 -27
  28. package/src/observability/log-reader.ts +142 -111
  29. package/src/observability/log-writer.ts +41 -83
  30. package/src/observability/retention.ts +2 -2
  31. package/src/observability/session-logger.ts +36 -57
  32. package/src/observability/summary-generator.ts +31 -19
  33. package/src/observability/types.ts +12 -24
  34. package/src/orchestrator/contracts/invariants.ts +14 -0
  35. package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
  36. package/src/orchestrator/fallback/event-handler.ts +47 -3
  37. package/src/orchestrator/handlers/architect.ts +2 -1
  38. package/src/orchestrator/handlers/build.ts +55 -97
  39. package/src/orchestrator/handlers/retrospective.ts +2 -1
  40. package/src/orchestrator/handlers/types.ts +0 -1
  41. package/src/orchestrator/lesson-memory.ts +29 -9
  42. package/src/orchestrator/orchestration-logger.ts +37 -23
  43. package/src/orchestrator/phase.ts +8 -4
  44. package/src/orchestrator/state.ts +79 -17
  45. package/src/projects/database.ts +47 -0
  46. package/src/projects/repository.ts +264 -0
  47. package/src/projects/resolve.ts +301 -0
  48. package/src/projects/schemas.ts +30 -0
  49. package/src/projects/types.ts +12 -0
  50. package/src/review/memory.ts +29 -9
  51. package/src/tools/doctor.ts +26 -2
  52. package/src/tools/forensics.ts +7 -12
  53. package/src/tools/logs.ts +6 -5
  54. package/src/tools/memory-preferences.ts +157 -0
  55. package/src/tools/memory-status.ts +17 -96
  56. package/src/tools/orchestrate.ts +97 -81
  57. package/src/tools/pipeline-report.ts +3 -2
  58. package/src/tools/quick.ts +2 -2
  59. package/src/tools/review.ts +39 -6
  60. package/src/tools/session-stats.ts +3 -2
  61. package/src/utils/paths.ts +20 -1
@@ -0,0 +1,122 @@
1
+ export const KERNEL_SCHEMA_VERSION = 1;
2
+
3
+ export const KERNEL_SCHEMA_STATEMENTS: readonly string[] = Object.freeze([
4
+ `CREATE TABLE IF NOT EXISTS pipeline_runs (
5
+ project_id TEXT NOT NULL,
6
+ run_id TEXT PRIMARY KEY,
7
+ schema_version INTEGER NOT NULL,
8
+ status TEXT NOT NULL,
9
+ current_phase TEXT,
10
+ idea TEXT NOT NULL,
11
+ state_revision INTEGER NOT NULL,
12
+ started_at TEXT NOT NULL,
13
+ last_updated_at TEXT NOT NULL,
14
+ failure_phase TEXT,
15
+ failure_agent TEXT,
16
+ failure_message TEXT,
17
+ last_successful_phase TEXT,
18
+ state_json TEXT NOT NULL,
19
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
20
+ )`,
21
+ `CREATE INDEX IF NOT EXISTS idx_pipeline_runs_project_updated_at ON pipeline_runs(project_id, last_updated_at DESC, run_id DESC)`,
22
+ `CREATE TABLE IF NOT EXISTS run_phases (
23
+ run_id TEXT NOT NULL,
24
+ phase_name TEXT NOT NULL,
25
+ status TEXT NOT NULL,
26
+ completed_at TEXT,
27
+ confidence TEXT,
28
+ PRIMARY KEY (run_id, phase_name),
29
+ FOREIGN KEY (run_id) REFERENCES pipeline_runs(run_id) ON DELETE CASCADE
30
+ )`,
31
+ `CREATE TABLE IF NOT EXISTS run_tasks (
32
+ run_id TEXT NOT NULL,
33
+ task_id INTEGER NOT NULL,
34
+ title TEXT NOT NULL,
35
+ status TEXT NOT NULL,
36
+ wave INTEGER NOT NULL,
37
+ depends_on_json TEXT NOT NULL,
38
+ attempt INTEGER NOT NULL,
39
+ strike INTEGER NOT NULL,
40
+ PRIMARY KEY (run_id, task_id),
41
+ FOREIGN KEY (run_id) REFERENCES pipeline_runs(run_id) ON DELETE CASCADE
42
+ )`,
43
+ `CREATE TABLE IF NOT EXISTS run_pending_dispatches (
44
+ run_id TEXT NOT NULL,
45
+ dispatch_id TEXT NOT NULL,
46
+ phase TEXT NOT NULL,
47
+ agent TEXT NOT NULL,
48
+ issued_at TEXT NOT NULL,
49
+ result_kind TEXT NOT NULL,
50
+ task_id INTEGER,
51
+ PRIMARY KEY (run_id, dispatch_id),
52
+ FOREIGN KEY (run_id) REFERENCES pipeline_runs(run_id) ON DELETE CASCADE
53
+ )`,
54
+ `CREATE TABLE IF NOT EXISTS run_processed_results (
55
+ run_id TEXT NOT NULL,
56
+ result_id TEXT NOT NULL,
57
+ PRIMARY KEY (run_id, result_id),
58
+ FOREIGN KEY (run_id) REFERENCES pipeline_runs(run_id) ON DELETE CASCADE
59
+ )`,
60
+ `CREATE TABLE IF NOT EXISTS active_review_state (
61
+ project_id TEXT PRIMARY KEY,
62
+ stage INTEGER NOT NULL,
63
+ scope TEXT NOT NULL,
64
+ started_at TEXT NOT NULL,
65
+ saved_at TEXT NOT NULL,
66
+ state_json TEXT NOT NULL,
67
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
68
+ )`,
69
+ `CREATE TABLE IF NOT EXISTS project_review_memory (
70
+ project_id TEXT PRIMARY KEY,
71
+ schema_version INTEGER NOT NULL,
72
+ last_reviewed_at TEXT,
73
+ state_json TEXT NOT NULL,
74
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
75
+ )`,
76
+ `CREATE TABLE IF NOT EXISTS project_lesson_memory (
77
+ project_id TEXT PRIMARY KEY,
78
+ schema_version INTEGER NOT NULL,
79
+ last_updated_at TEXT,
80
+ state_json TEXT NOT NULL,
81
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
82
+ )`,
83
+ `CREATE TABLE IF NOT EXISTS project_lessons (
84
+ lesson_id INTEGER PRIMARY KEY AUTOINCREMENT,
85
+ project_id TEXT NOT NULL,
86
+ content TEXT NOT NULL,
87
+ domain TEXT NOT NULL,
88
+ extracted_at TEXT NOT NULL,
89
+ source_phase TEXT NOT NULL,
90
+ last_updated_at TEXT,
91
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
92
+ UNIQUE(project_id, extracted_at, domain, source_phase, content)
93
+ )`,
94
+ `CREATE INDEX IF NOT EXISTS idx_project_lessons_project_extracted_at ON project_lessons(project_id, extracted_at DESC, lesson_id DESC)`,
95
+ `CREATE INDEX IF NOT EXISTS idx_project_lessons_domain ON project_lessons(project_id, domain, extracted_at DESC, lesson_id DESC)`,
96
+ `CREATE TABLE IF NOT EXISTS forensic_events (
97
+ event_id INTEGER PRIMARY KEY AUTOINCREMENT,
98
+ project_id TEXT NOT NULL,
99
+ schema_version INTEGER NOT NULL,
100
+ timestamp TEXT NOT NULL,
101
+ project_root TEXT NOT NULL,
102
+ domain TEXT NOT NULL,
103
+ run_id TEXT,
104
+ session_id TEXT,
105
+ parent_session_id TEXT,
106
+ phase TEXT,
107
+ dispatch_id TEXT,
108
+ task_id INTEGER,
109
+ agent TEXT,
110
+ type TEXT NOT NULL,
111
+ code TEXT,
112
+ message TEXT,
113
+ payload_json TEXT NOT NULL,
114
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
115
+ )`,
116
+ `CREATE INDEX IF NOT EXISTS idx_forensic_events_timestamp ON forensic_events(timestamp, event_id)`,
117
+ `CREATE INDEX IF NOT EXISTS idx_forensic_events_project ON forensic_events(project_id, timestamp, event_id)`,
118
+ `CREATE INDEX IF NOT EXISTS idx_forensic_events_session ON forensic_events(session_id, timestamp, event_id)`,
119
+ `CREATE INDEX IF NOT EXISTS idx_forensic_events_run ON forensic_events(run_id, timestamp, event_id)`,
120
+ `CREATE INDEX IF NOT EXISTS idx_forensic_events_dispatch ON forensic_events(dispatch_id, timestamp, event_id)`,
121
+ `CREATE INDEX IF NOT EXISTS idx_forensic_events_type ON forensic_events(type, timestamp, event_id)`,
122
+ ]);
@@ -0,0 +1,66 @@
1
+ import type { ForensicEvent } from "../observability/forensic-types";
2
+ import type { LessonMemory } from "../orchestrator/lesson-types";
3
+ import type { PipelineState } from "../orchestrator/types";
4
+ import type { ReviewMemory, ReviewState } from "../review/types";
5
+
6
+ export const KERNEL_STATE_CONFLICT_CODE = "E_STATE_CONFLICT";
7
+
8
+ export interface PipelineRunRow {
9
+ readonly project_id: string;
10
+ readonly run_id: string;
11
+ readonly schema_version: number;
12
+ readonly status: PipelineState["status"];
13
+ readonly current_phase: string | null;
14
+ readonly idea: string;
15
+ readonly state_revision: number;
16
+ readonly started_at: string;
17
+ readonly last_updated_at: string;
18
+ readonly failure_phase: string | null;
19
+ readonly failure_agent: string | null;
20
+ readonly failure_message: string | null;
21
+ readonly last_successful_phase: string | null;
22
+ readonly state_json: string;
23
+ }
24
+
25
+ export interface ActiveReviewStateRow {
26
+ readonly project_id: string;
27
+ readonly stage: ReviewState["stage"];
28
+ readonly scope: string;
29
+ readonly started_at: string;
30
+ readonly saved_at: string;
31
+ readonly state_json: string;
32
+ }
33
+
34
+ export interface ProjectReviewMemoryRow {
35
+ readonly project_id: string;
36
+ readonly schema_version: number;
37
+ readonly last_reviewed_at: string | null;
38
+ readonly state_json: string;
39
+ }
40
+
41
+ export interface ProjectLessonMemoryRow {
42
+ readonly project_id: string;
43
+ readonly schema_version: number;
44
+ readonly last_updated_at: string | null;
45
+ readonly state_json: string;
46
+ }
47
+
48
+ export interface ForensicEventRow {
49
+ readonly event_id: number;
50
+ readonly project_id: string;
51
+ readonly schema_version: number;
52
+ readonly timestamp: string;
53
+ readonly project_root: string;
54
+ readonly domain: ForensicEvent["domain"];
55
+ readonly run_id: string | null;
56
+ readonly session_id: string | null;
57
+ readonly parent_session_id: string | null;
58
+ readonly phase: string | null;
59
+ readonly dispatch_id: string | null;
60
+ readonly task_id: number | null;
61
+ readonly agent: string | null;
62
+ readonly type: ForensicEvent["type"];
63
+ readonly code: string | null;
64
+ readonly message: string | null;
65
+ readonly payload_json: string;
66
+ }
@@ -1,34 +1,38 @@
1
1
  /**
2
- * Event capture handler for memory observations.
2
+ * Memory capture handlers.
3
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.
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.
11
7
  *
12
8
  * @module
13
9
  */
14
10
 
15
11
  import type { Database } from "bun:sqlite";
16
12
  import { basename } from "node:path";
13
+ import { resolveProjectIdentity } from "../projects/resolve";
17
14
  import { pruneStaleObservations } from "./decay";
18
- import { computeProjectKey } from "./project-key";
19
- import { insertObservation, upsertProject } from "./repository";
15
+ import { insertObservation, upsertPreferenceRecord, upsertProject } from "./repository";
20
16
  import type { ObservationType } from "./types";
21
17
 
22
18
  /**
23
- * Dependencies for the memory capture handler.
19
+ * Dependencies for the memory capture handlers.
24
20
  */
25
21
  export interface MemoryCaptureDeps {
26
22
  readonly getDb: () => Database;
27
23
  readonly projectRoot: string;
28
24
  }
29
25
 
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
+
30
34
  /**
31
- * Events that produce memory observations.
35
+ * Events that produce supporting observations.
32
36
  */
33
37
  const CAPTURE_EVENT_TYPES = new Set([
34
38
  "session.created",
@@ -38,6 +42,33 @@ const CAPTURE_EVENT_TYPES = new Set([
38
42
  "app.phase_transition",
39
43
  ]);
40
44
 
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
+
41
72
  /**
42
73
  * Extracts a session ID from event properties.
43
74
  * Supports properties.sessionID, properties.info.id, properties.info.sessionID.
@@ -52,6 +83,107 @@ function extractSessionId(properties: Record<string, unknown>): string | undefin
52
83
  return undefined;
53
84
  }
54
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
+
55
187
  /**
56
188
  * Safely truncate a string to maxLen characters.
57
189
  */
@@ -60,12 +192,7 @@ function truncate(s: string, maxLen: number): string {
60
192
  }
61
193
 
62
194
  /**
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.
195
+ * Creates an event capture handler matching the plugin event hook signature.
69
196
  */
70
197
  export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
71
198
  let currentSessionId: string | null = null;
@@ -110,7 +237,6 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
110
237
  ? (rawProps as Record<string, unknown>)
111
238
  : {};
112
239
 
113
- // Skip noisy events early
114
240
  if (!CAPTURE_EVENT_TYPES.has(event.type)) return;
115
241
 
116
242
  switch (event.type) {
@@ -121,7 +247,10 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
121
247
  if (!info.id) return;
122
248
 
123
249
  currentSessionId = info.id;
124
- currentProjectKey = computeProjectKey(deps.projectRoot);
250
+ const resolvedProject = await resolveProjectIdentity(deps.projectRoot, {
251
+ db: deps.getDb(),
252
+ });
253
+ currentProjectKey = resolvedProject.id;
125
254
  const projectName = basename(deps.projectRoot);
126
255
 
127
256
  try {
@@ -130,6 +259,7 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
130
259
  id: currentProjectKey,
131
260
  path: deps.projectRoot,
132
261
  name: projectName,
262
+ firstSeenAt: resolvedProject.firstSeenAt,
133
263
  lastUpdated: now(),
134
264
  },
135
265
  deps.getDb(),
@@ -144,12 +274,9 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
144
274
  const projectKey = currentProjectKey;
145
275
  const db = deps.getDb();
146
276
 
147
- // Reset state
148
277
  currentSessionId = null;
149
278
  currentProjectKey = null;
150
279
 
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
280
  if (projectKey) {
154
281
  queueMicrotask(() => {
155
282
  try {
@@ -197,9 +324,8 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
197
324
  typeof properties.fromPhase === "string" ? properties.fromPhase : "unknown";
198
325
  const toPhase = typeof properties.toPhase === "string" ? properties.toPhase : "unknown";
199
326
  const content = `Phase transition: ${fromPhase} -> ${toPhase}`;
200
- const summary = content;
201
327
 
202
- safeInsert("pattern", content, summary, 0.6);
328
+ safeInsert("pattern", content, content, 0.6);
203
329
  return;
204
330
  }
205
331
 
@@ -208,3 +334,73 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
208
334
  }
209
335
  };
210
336
  }
337
+
338
+ /**
339
+ * Creates a chat.message capture handler that records explicit user preferences.
340
+ */
341
+ export function createMemoryChatMessageHandler(deps: MemoryCaptureDeps) {
342
+ return async (
343
+ input: { readonly sessionID: string },
344
+ output: { readonly parts: unknown[] },
345
+ ): Promise<void> => {
346
+ try {
347
+ const candidates = extractExplicitPreferenceCandidates(output.parts);
348
+ if (candidates.length === 0) {
349
+ return;
350
+ }
351
+
352
+ const resolvedProject = await resolveProjectIdentity(deps.projectRoot, {
353
+ db: deps.getDb(),
354
+ });
355
+ const projectName = basename(deps.projectRoot);
356
+ const timestamp = new Date().toISOString();
357
+
358
+ upsertProject(
359
+ {
360
+ id: resolvedProject.id,
361
+ path: deps.projectRoot,
362
+ name: projectName,
363
+ firstSeenAt: resolvedProject.firstSeenAt,
364
+ lastUpdated: timestamp,
365
+ },
366
+ deps.getDb(),
367
+ );
368
+
369
+ for (const candidate of candidates) {
370
+ upsertPreferenceRecord(
371
+ {
372
+ key: candidate.key,
373
+ value: candidate.value,
374
+ scope: candidate.scope,
375
+ projectId: candidate.scope === "project" ? resolvedProject.id : null,
376
+ status: "confirmed",
377
+ confidence: candidate.confidence,
378
+ sourceSession: input.sessionID,
379
+ createdAt: timestamp,
380
+ lastUpdated: timestamp,
381
+ evidence: [
382
+ {
383
+ sessionId: input.sessionID,
384
+ statement: candidate.statement,
385
+ confidence: candidate.confidence,
386
+ confirmed: true,
387
+ createdAt: timestamp,
388
+ },
389
+ ],
390
+ },
391
+ deps.getDb(),
392
+ );
393
+ }
394
+ } catch (err) {
395
+ console.warn("[opencode-autopilot] explicit preference capture failed:", err);
396
+ }
397
+ };
398
+ }
399
+
400
+ export const memoryCaptureInternals = Object.freeze({
401
+ extractExplicitPreferenceCandidates,
402
+ extractTextPartContent,
403
+ inferPreferenceScope,
404
+ normalizePreferenceKey,
405
+ normalizePreferenceValue,
406
+ });
@@ -1,8 +1,8 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { mkdirSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { getGlobalConfigDir } from "../utils/paths";
5
- import { DB_FILE, MEMORY_DIR } from "./constants";
3
+ import { dirname } from "node:path";
4
+ import { runProjectRegistryMigrations } from "../projects/database";
5
+ import { getAutopilotDbPath } from "../utils/paths";
6
6
 
7
7
  let db: Database | null = null;
8
8
 
@@ -11,12 +11,7 @@ let db: Database | null = null;
11
11
  * Idempotent via IF NOT EXISTS.
12
12
  */
13
13
  export function initMemoryDb(database: Database): void {
14
- database.run(`CREATE TABLE IF NOT EXISTS projects (
15
- id TEXT PRIMARY KEY,
16
- path TEXT NOT NULL UNIQUE,
17
- name TEXT NOT NULL,
18
- last_updated TEXT NOT NULL
19
- )`);
14
+ runProjectRegistryMigrations(database);
20
15
 
21
16
  database.run(`CREATE TABLE IF NOT EXISTS observations (
22
17
  id INTEGER PRIMARY KEY,
@@ -42,6 +37,73 @@ export function initMemoryDb(database: Database): void {
42
37
  last_updated TEXT NOT NULL
43
38
  )`);
44
39
 
40
+ database.run(`CREATE TABLE IF NOT EXISTS preference_records (
41
+ id TEXT PRIMARY KEY,
42
+ key TEXT NOT NULL,
43
+ value TEXT NOT NULL,
44
+ scope TEXT NOT NULL CHECK(scope IN ('global', 'project')),
45
+ project_id TEXT,
46
+ status TEXT NOT NULL CHECK(status IN ('candidate', 'confirmed', 'rejected')) DEFAULT 'confirmed',
47
+ confidence REAL NOT NULL DEFAULT 0.5,
48
+ source_session TEXT,
49
+ created_at TEXT NOT NULL,
50
+ last_updated TEXT NOT NULL,
51
+ FOREIGN KEY (project_id) REFERENCES projects(id),
52
+ UNIQUE(key, scope, project_id)
53
+ )`);
54
+
55
+ database.run(`CREATE INDEX IF NOT EXISTS idx_preference_records_scope_updated
56
+ ON preference_records(scope, last_updated DESC, key ASC)`);
57
+ database.run(`CREATE INDEX IF NOT EXISTS idx_preference_records_project_updated
58
+ ON preference_records(project_id, last_updated DESC, key ASC)`);
59
+
60
+ database.run(`CREATE TABLE IF NOT EXISTS preference_evidence (
61
+ id TEXT PRIMARY KEY,
62
+ preference_id TEXT NOT NULL,
63
+ session_id TEXT,
64
+ run_id TEXT,
65
+ statement TEXT NOT NULL,
66
+ statement_hash TEXT NOT NULL,
67
+ confidence REAL NOT NULL DEFAULT 0.5,
68
+ confirmed INTEGER NOT NULL DEFAULT 0,
69
+ created_at TEXT NOT NULL,
70
+ FOREIGN KEY (preference_id) REFERENCES preference_records(id) ON DELETE CASCADE,
71
+ UNIQUE(preference_id, statement_hash)
72
+ )`);
73
+
74
+ database.run(`CREATE INDEX IF NOT EXISTS idx_preference_evidence_preference_created
75
+ ON preference_evidence(preference_id, created_at DESC, id DESC)`);
76
+
77
+ database.run(`INSERT INTO preference_records (
78
+ id,
79
+ key,
80
+ value,
81
+ scope,
82
+ project_id,
83
+ status,
84
+ confidence,
85
+ source_session,
86
+ created_at,
87
+ last_updated
88
+ )
89
+ SELECT
90
+ p.id,
91
+ p.key,
92
+ p.value,
93
+ 'global',
94
+ NULL,
95
+ 'confirmed',
96
+ p.confidence,
97
+ p.source_session,
98
+ p.created_at,
99
+ p.last_updated
100
+ FROM preferences p
101
+ WHERE NOT EXISTS (
102
+ SELECT 1
103
+ FROM preference_records pr
104
+ WHERE pr.id = p.id
105
+ )`);
106
+
45
107
  database.run(`CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
46
108
  content, summary,
47
109
  content=observations,
@@ -79,9 +141,9 @@ export function getMemoryDb(dbPath?: string): Database {
79
141
  const resolvedPath =
80
142
  dbPath ??
81
143
  (() => {
82
- const memoryDir = join(getGlobalConfigDir(), MEMORY_DIR);
83
- mkdirSync(memoryDir, { recursive: true });
84
- return join(memoryDir, DB_FILE);
144
+ const runtimeDbPath = getAutopilotDbPath();
145
+ mkdirSync(dirname(runtimeDbPath), { recursive: true });
146
+ return runtimeDbPath;
85
147
  })();
86
148
 
87
149
  db = new Database(resolvedPath);
@@ -1,4 +1,9 @@
1
- export { createMemoryCaptureHandler, type MemoryCaptureDeps } from "./capture";
1
+ export {
2
+ createMemoryCaptureHandler,
3
+ createMemoryChatMessageHandler,
4
+ type MemoryCaptureDeps,
5
+ memoryCaptureInternals,
6
+ } from "./capture";
2
7
  export * from "./constants";
3
8
  export { closeMemoryDb, getMemoryDb, initMemoryDb } from "./database";
4
9
  export { computeRelevanceScore, pruneStaleObservations } from "./decay";
@@ -6,13 +11,24 @@ export { createMemoryInjector, type MemoryInjectorConfig } from "./injector";
6
11
  export { computeProjectKey } from "./project-key";
7
12
  export {
8
13
  deleteObservation,
14
+ deletePreferenceRecord,
15
+ deletePreferencesByKey,
9
16
  getAllPreferences,
17
+ getConfirmedPreferencesForProject,
10
18
  getObservationsByProject,
19
+ getPreferenceRecordById,
11
20
  getProjectByPath,
21
+ getRecentFailureObservations,
12
22
  insertObservation,
23
+ listPreferenceEvidence,
24
+ listPreferenceRecords,
25
+ listRelevantLessons,
26
+ prunePreferenceEvidence,
27
+ prunePreferences,
13
28
  searchObservations,
14
29
  updateAccessCount,
15
30
  upsertPreference,
31
+ upsertPreferenceRecord,
16
32
  upsertProject,
17
33
  } from "./repository";
18
34
  export {
@@ -1,5 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
+ /**
4
+ * Legacy path-hash project key.
5
+ *
6
+ * Kept only for migration compatibility while the runtime moves to the
7
+ * project registry / stable project identity model.
8
+ */
3
9
  export function computeProjectKey(projectPath: string): string {
4
10
  return createHash("sha256").update(projectPath).digest("hex");
5
11
  }