@kodrunhq/opencode-autopilot 1.4.0 → 1.6.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 (46) hide show
  1. package/assets/commands/brainstorm.md +7 -0
  2. package/assets/commands/stocktake.md +7 -0
  3. package/assets/commands/tdd.md +7 -0
  4. package/assets/commands/update-docs.md +7 -0
  5. package/assets/commands/write-plan.md +7 -0
  6. package/assets/skills/brainstorming/SKILL.md +295 -0
  7. package/assets/skills/code-review/SKILL.md +241 -0
  8. package/assets/skills/e2e-testing/SKILL.md +266 -0
  9. package/assets/skills/git-worktrees/SKILL.md +296 -0
  10. package/assets/skills/go-patterns/SKILL.md +240 -0
  11. package/assets/skills/plan-executing/SKILL.md +258 -0
  12. package/assets/skills/plan-writing/SKILL.md +278 -0
  13. package/assets/skills/python-patterns/SKILL.md +255 -0
  14. package/assets/skills/rust-patterns/SKILL.md +293 -0
  15. package/assets/skills/strategic-compaction/SKILL.md +217 -0
  16. package/assets/skills/systematic-debugging/SKILL.md +299 -0
  17. package/assets/skills/tdd-workflow/SKILL.md +311 -0
  18. package/assets/skills/typescript-patterns/SKILL.md +278 -0
  19. package/assets/skills/verification/SKILL.md +240 -0
  20. package/bin/configure-tui.ts +1 -1
  21. package/package.json +1 -1
  22. package/src/config.ts +76 -14
  23. package/src/index.ts +43 -2
  24. package/src/memory/capture.ts +205 -0
  25. package/src/memory/constants.ts +26 -0
  26. package/src/memory/database.ts +103 -0
  27. package/src/memory/decay.ts +94 -0
  28. package/src/memory/index.ts +24 -0
  29. package/src/memory/injector.ts +85 -0
  30. package/src/memory/project-key.ts +5 -0
  31. package/src/memory/repository.ts +217 -0
  32. package/src/memory/retrieval.ts +260 -0
  33. package/src/memory/schemas.ts +34 -0
  34. package/src/memory/types.ts +12 -0
  35. package/src/orchestrator/skill-injection.ts +38 -0
  36. package/src/review/sanitize.ts +1 -1
  37. package/src/skills/adaptive-injector.ts +122 -0
  38. package/src/skills/dependency-resolver.ts +88 -0
  39. package/src/skills/linter.ts +113 -0
  40. package/src/skills/loader.ts +88 -0
  41. package/src/templates/skill-template.ts +4 -0
  42. package/src/tools/configure.ts +1 -1
  43. package/src/tools/create-skill.ts +12 -0
  44. package/src/tools/memory-status.ts +164 -0
  45. package/src/tools/stocktake.ts +170 -0
  46. package/src/tools/update-docs.ts +116 -0
package/src/config.ts CHANGED
@@ -76,6 +76,16 @@ const pluginConfigSchemaV3 = z.object({
76
76
 
77
77
  type PluginConfigV3 = z.infer<typeof pluginConfigSchemaV3>;
78
78
 
79
+ // --- Memory sub-schema ---
80
+
81
+ export const memoryConfigSchema = z.object({
82
+ enabled: z.boolean().default(true),
83
+ injectionBudget: z.number().min(500).max(5000).default(2000),
84
+ decayHalfLifeDays: z.number().min(7).max(365).default(90),
85
+ });
86
+
87
+ const memoryDefaults = memoryConfigSchema.parse({});
88
+
79
89
  // --- V4 sub-schemas ---
80
90
 
81
91
  const groupModelAssignmentSchema = z.object({
@@ -112,10 +122,37 @@ const pluginConfigSchemaV4 = z
112
122
  }
113
123
  });
114
124
 
115
- // Export aliases updated to v4
116
- export const pluginConfigSchema = pluginConfigSchemaV4;
125
+ type PluginConfigV4 = z.infer<typeof pluginConfigSchemaV4>;
126
+
127
+ // --- V5 schema ---
128
+
129
+ const pluginConfigSchemaV5 = z
130
+ .object({
131
+ version: z.literal(5),
132
+ configured: z.boolean(),
133
+ groups: z.record(z.string(), groupModelAssignmentSchema).default({}),
134
+ overrides: z.record(z.string(), agentOverrideSchema).default({}),
135
+ orchestrator: orchestratorConfigSchema.default(orchestratorDefaults),
136
+ confidence: confidenceConfigSchema.default(confidenceDefaults),
137
+ fallback: fallbackConfigSchema.default(fallbackDefaults),
138
+ memory: memoryConfigSchema.default(memoryDefaults),
139
+ })
140
+ .superRefine((config, ctx) => {
141
+ for (const groupId of Object.keys(config.groups)) {
142
+ if (!ALL_GROUP_IDS.includes(groupId as (typeof ALL_GROUP_IDS)[number])) {
143
+ ctx.addIssue({
144
+ code: z.ZodIssueCode.custom,
145
+ path: ["groups", groupId],
146
+ message: `Unknown group id "${groupId}". Expected one of: ${ALL_GROUP_IDS.join(", ")}`,
147
+ });
148
+ }
149
+ }
150
+ });
151
+
152
+ // Export aliases updated to v5
153
+ export const pluginConfigSchema = pluginConfigSchemaV5;
117
154
 
118
- export type PluginConfig = z.infer<typeof pluginConfigSchemaV4>;
155
+ export type PluginConfig = z.infer<typeof pluginConfigSchemaV5>;
119
156
 
120
157
  export const CONFIG_PATH = join(getGlobalConfigDir(), "opencode-autopilot.json");
121
158
 
@@ -142,7 +179,7 @@ function migrateV2toV3(v2Config: PluginConfigV2): PluginConfigV3 {
142
179
  };
143
180
  }
144
181
 
145
- function migrateV3toV4(v3Config: PluginConfigV3): PluginConfig {
182
+ function migrateV3toV4(v3Config: PluginConfigV3): PluginConfigV4 {
146
183
  const groups: Record<string, { primary: string; fallbacks: string[] }> = {};
147
184
  const overrides: Record<string, { primary: string }> = {};
148
185
 
@@ -191,6 +228,19 @@ function migrateV3toV4(v3Config: PluginConfigV3): PluginConfig {
191
228
  };
192
229
  }
193
230
 
231
+ function migrateV4toV5(v4Config: PluginConfigV4): PluginConfig {
232
+ return {
233
+ version: 5 as const,
234
+ configured: v4Config.configured,
235
+ groups: v4Config.groups,
236
+ overrides: v4Config.overrides,
237
+ orchestrator: v4Config.orchestrator,
238
+ confidence: v4Config.confidence,
239
+ fallback: v4Config.fallback,
240
+ memory: memoryDefaults,
241
+ };
242
+ }
243
+
194
244
  // --- Public API ---
195
245
 
196
246
  export async function loadConfig(configPath: string = CONFIG_PATH): Promise<PluginConfig | null> {
@@ -198,38 +248,49 @@ export async function loadConfig(configPath: string = CONFIG_PATH): Promise<Plug
198
248
  const raw = await readFile(configPath, "utf-8");
199
249
  const parsed = JSON.parse(raw);
200
250
 
201
- // Try v4 first
251
+ // Try v5 first
252
+ const v5Result = pluginConfigSchemaV5.safeParse(parsed);
253
+ if (v5Result.success) return v5Result.data;
254
+
255
+ // Try v4 and migrate to v5
202
256
  const v4Result = pluginConfigSchemaV4.safeParse(parsed);
203
- if (v4Result.success) return v4Result.data;
257
+ if (v4Result.success) {
258
+ const migrated = migrateV4toV5(v4Result.data);
259
+ await saveConfig(migrated, configPath);
260
+ return migrated;
261
+ }
204
262
 
205
- // Try v3 and migrate to v4
263
+ // Try v3 v4 v5
206
264
  const v3Result = pluginConfigSchemaV3.safeParse(parsed);
207
265
  if (v3Result.success) {
208
- const migrated = migrateV3toV4(v3Result.data);
266
+ const v4 = migrateV3toV4(v3Result.data);
267
+ const migrated = migrateV4toV5(v4);
209
268
  await saveConfig(migrated, configPath);
210
269
  return migrated;
211
270
  }
212
271
 
213
- // Try v2 → v3 → v4
272
+ // Try v2 → v3 → v4 → v5
214
273
  const v2Result = pluginConfigSchemaV2.safeParse(parsed);
215
274
  if (v2Result.success) {
216
275
  const v3 = migrateV2toV3(v2Result.data);
217
- const migrated = migrateV3toV4(v3);
276
+ const v4 = migrateV3toV4(v3);
277
+ const migrated = migrateV4toV5(v4);
218
278
  await saveConfig(migrated, configPath);
219
279
  return migrated;
220
280
  }
221
281
 
222
- // Try v1 → v2 → v3 → v4
282
+ // Try v1 → v2 → v3 → v4 → v5
223
283
  const v1Result = pluginConfigSchemaV1.safeParse(parsed);
224
284
  if (v1Result.success) {
225
285
  const v2 = migrateV1toV2(v1Result.data);
226
286
  const v3 = migrateV2toV3(v2);
227
- const migrated = migrateV3toV4(v3);
287
+ const v4 = migrateV3toV4(v3);
288
+ const migrated = migrateV4toV5(v4);
228
289
  await saveConfig(migrated, configPath);
229
290
  return migrated;
230
291
  }
231
292
 
232
- return pluginConfigSchemaV4.parse(parsed); // throw with proper error
293
+ return pluginConfigSchemaV5.parse(parsed); // throw with proper error
233
294
  } catch (error: unknown) {
234
295
  if (isEnoentError(error)) return null;
235
296
  throw error;
@@ -252,12 +313,13 @@ export function isFirstLoad(config: PluginConfig | null): boolean {
252
313
 
253
314
  export function createDefaultConfig(): PluginConfig {
254
315
  return {
255
- version: 4 as const,
316
+ version: 5 as const,
256
317
  configured: false,
257
318
  groups: {},
258
319
  overrides: {},
259
320
  orchestrator: orchestratorDefaults,
260
321
  confidence: confidenceDefaults,
261
322
  fallback: fallbackDefaults,
323
+ memory: memoryDefaults,
262
324
  };
263
325
  }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { configHook } from "./agents";
3
3
  import { isFirstLoad, loadConfig } from "./config";
4
4
  import { runHealthChecks } from "./health/runner";
5
5
  import { installAssets } from "./installer";
6
+ import { createMemoryCaptureHandler, createMemoryInjector, getMemoryDb } from "./memory";
6
7
  import { ContextMonitor } from "./observability/context-monitor";
7
8
  import {
8
9
  createObservabilityEventHandler,
@@ -35,6 +36,7 @@ import { ocCreateSkill } from "./tools/create-skill";
35
36
  import { ocDoctor } from "./tools/doctor";
36
37
  import { ocForensics } from "./tools/forensics";
37
38
  import { ocLogs } from "./tools/logs";
39
+ import { ocMemoryStatus } from "./tools/memory-status";
38
40
  import { ocMockFallback } from "./tools/mock-fallback";
39
41
  import { ocOrchestrate } from "./tools/orchestrate";
40
42
  import { ocPhase } from "./tools/phase";
@@ -44,6 +46,8 @@ import { ocQuick } from "./tools/quick";
44
46
  import { ocReview } from "./tools/review";
45
47
  import { ocSessionStats } from "./tools/session-stats";
46
48
  import { ocState } from "./tools/state";
49
+ import { ocStocktake } from "./tools/stocktake";
50
+ import { ocUpdateDocs } from "./tools/update-docs";
47
51
 
48
52
  let openCodeConfig: Config | null = null;
49
53
 
@@ -146,6 +150,26 @@ const plugin: Plugin = async (input) => {
146
150
  const chatMessageHandler = createChatMessageHandler(manager);
147
151
  const toolExecuteAfterHandler = createToolExecuteAfterHandler(manager);
148
152
 
153
+ // --- Memory subsystem initialization ---
154
+ const memoryConfig = config?.memory ?? {
155
+ enabled: true,
156
+ injectionBudget: 2000,
157
+ decayHalfLifeDays: 90,
158
+ };
159
+
160
+ const memoryCaptureHandler = memoryConfig.enabled
161
+ ? createMemoryCaptureHandler({ getDb: () => getMemoryDb(), projectRoot: process.cwd() })
162
+ : null;
163
+
164
+ const memoryInjector = memoryConfig.enabled
165
+ ? createMemoryInjector({
166
+ projectRoot: process.cwd(),
167
+ tokenBudget: memoryConfig.injectionBudget,
168
+ halfLifeDays: memoryConfig.decayHalfLifeDays,
169
+ getDb: () => getMemoryDb(),
170
+ })
171
+ : null;
172
+
149
173
  // --- Observability handlers ---
150
174
  const toolStartTimes = new Map<string, number>();
151
175
  const observabilityEventHandler = createObservabilityEventHandler({
@@ -191,12 +215,24 @@ const plugin: Plugin = async (input) => {
191
215
  oc_session_stats: ocSessionStats,
192
216
  oc_pipeline_report: ocPipelineReport,
193
217
  oc_mock_fallback: ocMockFallback,
218
+ oc_stocktake: ocStocktake,
219
+ oc_update_docs: ocUpdateDocs,
220
+ oc_memory_status: ocMemoryStatus,
194
221
  },
195
222
  event: async ({ event }) => {
196
223
  // 1. Observability: collect (pure observer, no side effects on session)
197
224
  await observabilityEventHandler({ event });
198
225
 
199
- // 2. First-load toast
226
+ // 2. Memory capture (pure observer, best-effort)
227
+ if (memoryCaptureHandler) {
228
+ try {
229
+ await memoryCaptureHandler({ event });
230
+ } catch {
231
+ /* best-effort */
232
+ }
233
+ }
234
+
235
+ // 3. First-load toast
200
236
  if (event.type === "session.created" && isFirstLoad(config)) {
201
237
  await sdkOps.showToast(
202
238
  "Welcome to OpenCode Autopilot!",
@@ -205,7 +241,7 @@ const plugin: Plugin = async (input) => {
205
241
  );
206
242
  }
207
243
 
208
- // 3. Fallback event handling
244
+ // 4. Fallback event handling
209
245
  if (fallbackConfig.enabled) {
210
246
  await fallbackEventHandler({ event });
211
247
  }
@@ -253,6 +289,11 @@ const plugin: Plugin = async (input) => {
253
289
  await toolExecuteAfterHandler(hookInput, output);
254
290
  }
255
291
  },
292
+ "experimental.chat.system.transform": async (input, output) => {
293
+ if (memoryInjector) {
294
+ await memoryInjector(input, output);
295
+ }
296
+ },
256
297
  };
257
298
  };
258
299
 
@@ -0,0 +1,205 @@
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
+ import type { Database } from "bun:sqlite";
16
+ import { basename } from "node:path";
17
+ import { pruneStaleObservations } from "./decay";
18
+ import { computeProjectKey } from "./project-key";
19
+ import { insertObservation, upsertProject } from "./repository";
20
+ import type { ObservationType } from "./types";
21
+
22
+ /**
23
+ * Dependencies for the memory capture handler.
24
+ */
25
+ export interface MemoryCaptureDeps {
26
+ readonly getDb: () => Database;
27
+ readonly projectRoot: string;
28
+ }
29
+
30
+ /**
31
+ * Events that produce memory observations.
32
+ */
33
+ const CAPTURE_EVENT_TYPES = new Set([
34
+ "session.created",
35
+ "session.deleted",
36
+ "session.error",
37
+ "app.decision",
38
+ "app.phase_transition",
39
+ ]);
40
+
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
+ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
71
+ let currentSessionId: string | null = null;
72
+ let currentProjectKey: string | null = null;
73
+
74
+ const now = () => new Date().toISOString();
75
+
76
+ function safeInsert(
77
+ type: ObservationType,
78
+ content: string,
79
+ summary: string,
80
+ confidence: number,
81
+ ): void {
82
+ if (!currentSessionId || !currentProjectKey) return;
83
+ try {
84
+ insertObservation(
85
+ {
86
+ projectId: currentProjectKey,
87
+ sessionId: currentSessionId,
88
+ type,
89
+ content,
90
+ summary: truncate(summary, 200),
91
+ confidence,
92
+ accessCount: 0,
93
+ createdAt: now(),
94
+ lastAccessed: now(),
95
+ },
96
+ deps.getDb(),
97
+ );
98
+ } catch (err) {
99
+ console.warn("[opencode-autopilot] memory capture failed:", err);
100
+ }
101
+ }
102
+
103
+ return async (input: {
104
+ readonly event: { readonly type: string; readonly [key: string]: unknown };
105
+ }): Promise<void> => {
106
+ const { event } = input;
107
+ const properties = (event.properties ?? {}) as Record<string, unknown>;
108
+
109
+ // Skip noisy events early
110
+ if (!CAPTURE_EVENT_TYPES.has(event.type)) return;
111
+
112
+ switch (event.type) {
113
+ case "session.created": {
114
+ const rawInfo = properties.info;
115
+ if (rawInfo === null || typeof rawInfo !== "object") return;
116
+ const info = rawInfo as { id?: string };
117
+ if (!info.id) return;
118
+
119
+ currentSessionId = info.id;
120
+ currentProjectKey = computeProjectKey(deps.projectRoot);
121
+ const projectName = basename(deps.projectRoot);
122
+
123
+ try {
124
+ upsertProject(
125
+ {
126
+ id: currentProjectKey,
127
+ path: deps.projectRoot,
128
+ name: projectName,
129
+ lastUpdated: now(),
130
+ },
131
+ deps.getDb(),
132
+ );
133
+ } catch (err) {
134
+ console.warn("[opencode-autopilot] upsertProject failed:", err);
135
+ }
136
+ return;
137
+ }
138
+
139
+ case "session.deleted": {
140
+ const projectKey = currentProjectKey;
141
+ const db = deps.getDb();
142
+
143
+ // Reset state
144
+ currentSessionId = null;
145
+ currentProjectKey = null;
146
+
147
+ // Defer pruning to avoid blocking the event loop
148
+ if (projectKey) {
149
+ setTimeout(() => {
150
+ try {
151
+ pruneStaleObservations(projectKey, db);
152
+ } catch (err) {
153
+ console.warn("[opencode-autopilot] pruneStaleObservations failed:", err);
154
+ }
155
+ }, 0);
156
+ }
157
+ return;
158
+ }
159
+
160
+ case "session.error": {
161
+ const sessionId = extractSessionId(properties);
162
+ if (!sessionId || sessionId !== currentSessionId) return;
163
+
164
+ const error = properties.error as Record<string, unknown> | undefined;
165
+ const errorType = typeof error?.type === "string" ? error.type : "unknown";
166
+ const message = typeof error?.message === "string" ? error.message : "Unknown error";
167
+ const content = `${errorType}: ${message}`;
168
+ const summary = truncate(message, 200);
169
+
170
+ safeInsert("error", content, summary, 0.7);
171
+ return;
172
+ }
173
+
174
+ case "app.decision": {
175
+ const sessionId = extractSessionId(properties);
176
+ if (!sessionId || sessionId !== currentSessionId) return;
177
+
178
+ const decision = typeof properties.decision === "string" ? properties.decision : "";
179
+ const rationale = typeof properties.rationale === "string" ? properties.rationale : "";
180
+
181
+ if (!decision) return;
182
+
183
+ safeInsert("decision", decision, rationale || truncate(decision, 200), 0.8);
184
+ return;
185
+ }
186
+
187
+ case "app.phase_transition": {
188
+ const sessionId = extractSessionId(properties);
189
+ if (!sessionId || sessionId !== currentSessionId) return;
190
+
191
+ const fromPhase =
192
+ typeof properties.fromPhase === "string" ? properties.fromPhase : "unknown";
193
+ const toPhase = typeof properties.toPhase === "string" ? properties.toPhase : "unknown";
194
+ const content = `Phase transition: ${fromPhase} -> ${toPhase}`;
195
+ const summary = content;
196
+
197
+ safeInsert("pattern", content, summary, 0.6);
198
+ return;
199
+ }
200
+
201
+ default:
202
+ return;
203
+ }
204
+ };
205
+ }
@@ -0,0 +1,26 @@
1
+ export const OBSERVATION_TYPES = [
2
+ "decision",
3
+ "pattern",
4
+ "error",
5
+ "preference",
6
+ "context",
7
+ "tool_usage",
8
+ ] as const;
9
+
10
+ export const TYPE_WEIGHTS: Readonly<Record<(typeof OBSERVATION_TYPES)[number], number>> =
11
+ Object.freeze({
12
+ decision: 1.5,
13
+ pattern: 1.2,
14
+ error: 1.0,
15
+ preference: 0.8,
16
+ context: 0.6,
17
+ tool_usage: 0.4,
18
+ });
19
+
20
+ export const DEFAULT_INJECTION_BUDGET = 2000;
21
+ export const DEFAULT_HALF_LIFE_DAYS = 90;
22
+ export const CHARS_PER_TOKEN = 4;
23
+ export const MAX_OBSERVATIONS_PER_PROJECT = 10000;
24
+ export const MIN_RELEVANCE_THRESHOLD = 0.1;
25
+ export const MEMORY_DIR = "memory";
26
+ export const DB_FILE = "memory.db";
@@ -0,0 +1,103 @@
1
+ import { Database } from "bun:sqlite";
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";
6
+
7
+ let db: Database | null = null;
8
+
9
+ /**
10
+ * Run all CREATE TABLE / CREATE INDEX / CREATE TRIGGER migrations.
11
+ * Idempotent via IF NOT EXISTS.
12
+ */
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
+ )`);
20
+
21
+ database.run(`CREATE TABLE IF NOT EXISTS observations (
22
+ id INTEGER PRIMARY KEY,
23
+ project_id TEXT,
24
+ session_id TEXT NOT NULL,
25
+ type TEXT NOT NULL CHECK(type IN ('decision','pattern','error','preference','context','tool_usage')),
26
+ content TEXT NOT NULL,
27
+ summary TEXT NOT NULL,
28
+ confidence REAL NOT NULL DEFAULT 0.5,
29
+ access_count INTEGER NOT NULL DEFAULT 0,
30
+ created_at TEXT NOT NULL,
31
+ last_accessed TEXT NOT NULL,
32
+ FOREIGN KEY (project_id) REFERENCES projects(id)
33
+ )`);
34
+
35
+ database.run(`CREATE TABLE IF NOT EXISTS preferences (
36
+ id TEXT PRIMARY KEY,
37
+ key TEXT NOT NULL UNIQUE,
38
+ value TEXT NOT NULL,
39
+ confidence REAL NOT NULL DEFAULT 0.5,
40
+ source_session TEXT,
41
+ created_at TEXT NOT NULL,
42
+ last_updated TEXT NOT NULL
43
+ )`);
44
+
45
+ database.run(`CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
46
+ content, summary,
47
+ content=observations,
48
+ content_rowid=id
49
+ )`);
50
+
51
+ database.run(`CREATE TRIGGER IF NOT EXISTS obs_ai AFTER INSERT ON observations BEGIN
52
+ INSERT INTO observations_fts(rowid, content, summary)
53
+ VALUES (new.id, new.content, new.summary);
54
+ END`);
55
+
56
+ database.run(`CREATE TRIGGER IF NOT EXISTS obs_ad AFTER DELETE ON observations BEGIN
57
+ INSERT INTO observations_fts(observations_fts, rowid, content, summary)
58
+ VALUES('delete', old.id, old.content, old.summary);
59
+ END`);
60
+
61
+ database.run(`CREATE TRIGGER IF NOT EXISTS obs_au AFTER UPDATE ON observations BEGIN
62
+ INSERT INTO observations_fts(observations_fts, rowid, content, summary)
63
+ VALUES('delete', old.id, old.content, old.summary);
64
+ INSERT INTO observations_fts(rowid, content, summary)
65
+ VALUES (new.id, new.content, new.summary);
66
+ END`);
67
+
68
+ database.run(`CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project_id)`);
69
+ database.run(`CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type)`);
70
+ }
71
+
72
+ /**
73
+ * Get or create the singleton memory database.
74
+ * Accepts optional dbPath for testing (e.g. ":memory:").
75
+ */
76
+ export function getMemoryDb(dbPath?: string): Database {
77
+ if (db) return db;
78
+
79
+ const resolvedPath =
80
+ dbPath ??
81
+ (() => {
82
+ const memoryDir = join(getGlobalConfigDir(), MEMORY_DIR);
83
+ mkdirSync(memoryDir, { recursive: true });
84
+ return join(memoryDir, DB_FILE);
85
+ })();
86
+
87
+ db = new Database(resolvedPath);
88
+ db.run("PRAGMA journal_mode=WAL");
89
+ db.run("PRAGMA foreign_keys=ON");
90
+ db.run("PRAGMA busy_timeout=5000");
91
+ initMemoryDb(db);
92
+ return db;
93
+ }
94
+
95
+ /**
96
+ * Close the singleton database and reset.
97
+ */
98
+ export function closeMemoryDb(): void {
99
+ if (db) {
100
+ db.close();
101
+ db = null;
102
+ }
103
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Time-weighted decay computation and pruning for memory observations.
3
+ *
4
+ * Relevance score = timeDecay * frequencyWeight * typeWeight
5
+ * - timeDecay: exponential decay with configurable half-life (default 90 days)
6
+ * - frequencyWeight: log2(accessCount + 1), minimum 1
7
+ * - typeWeight: per-type multiplier from constants
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import type { Database } from "bun:sqlite";
13
+ import {
14
+ DEFAULT_HALF_LIFE_DAYS,
15
+ MAX_OBSERVATIONS_PER_PROJECT,
16
+ MIN_RELEVANCE_THRESHOLD,
17
+ TYPE_WEIGHTS,
18
+ } from "./constants";
19
+ import { deleteObservation, getObservationsByProject } from "./repository";
20
+ import type { ObservationType } from "./types";
21
+
22
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
23
+
24
+ /**
25
+ * Compute the relevance score for an observation.
26
+ *
27
+ * Formula: timeDecay * frequencyWeight * typeWeight
28
+ * - timeDecay = exp(-ageDays / halfLifeDays)
29
+ * - frequencyWeight = max(log2(accessCount + 1), 1)
30
+ * - typeWeight = TYPE_WEIGHTS[type]
31
+ */
32
+ export function computeRelevanceScore(
33
+ lastAccessed: string,
34
+ accessCount: number,
35
+ type: ObservationType,
36
+ halfLifeDays: number = DEFAULT_HALF_LIFE_DAYS,
37
+ ): number {
38
+ const ageMs = Date.now() - new Date(lastAccessed).getTime();
39
+ const ageDays = ageMs / MS_PER_DAY;
40
+ const timeDecay = Math.exp(-ageDays / halfLifeDays);
41
+ const frequencyWeight = Math.max(Math.log2(accessCount + 1), 1);
42
+ const typeWeight = TYPE_WEIGHTS[type];
43
+ return timeDecay * frequencyWeight * typeWeight;
44
+ }
45
+
46
+ /**
47
+ * Prune stale observations for a project.
48
+ *
49
+ * 1. Remove observations where relevance score < MIN_RELEVANCE_THRESHOLD
50
+ * 2. If remaining count > MAX_OBSERVATIONS_PER_PROJECT, remove lowest-scored until at cap
51
+ *
52
+ * Uses deleteObservation for each deletion (not batch DELETE for safety).
53
+ */
54
+ export function pruneStaleObservations(
55
+ projectId: string | null,
56
+ db?: Database,
57
+ ): { readonly pruned: number } {
58
+ const fetchLimit = MAX_OBSERVATIONS_PER_PROJECT + 1000;
59
+ const observations = getObservationsByProject(projectId, fetchLimit, db);
60
+
61
+ // Score each observation — skip any without a valid id (schema allows optional)
62
+ const scored = observations
63
+ .filter((obs): obs is typeof obs & { id: number } => obs.id !== undefined)
64
+ .map((obs) => ({
65
+ id: obs.id,
66
+ score: computeRelevanceScore(obs.lastAccessed, obs.accessCount, obs.type),
67
+ }));
68
+
69
+ let pruned = 0;
70
+
71
+ // Phase 1: Remove observations below threshold
72
+ const belowThreshold = scored.filter((s) => s.score < MIN_RELEVANCE_THRESHOLD);
73
+ for (const entry of belowThreshold) {
74
+ deleteObservation(entry.id, db);
75
+ pruned++;
76
+ }
77
+
78
+ // Phase 2: Enforce cap on remaining
79
+ const remaining = scored
80
+ .filter((s) => s.score >= MIN_RELEVANCE_THRESHOLD)
81
+ .sort((a, b) => a.score - b.score);
82
+
83
+ const excess = remaining.length - MAX_OBSERVATIONS_PER_PROJECT;
84
+ if (excess > 0) {
85
+ // Remove lowest-scored excess
86
+ const toRemove = remaining.slice(0, excess);
87
+ for (const entry of toRemove) {
88
+ deleteObservation(entry.id, db);
89
+ pruned++;
90
+ }
91
+ }
92
+
93
+ return Object.freeze({ pruned });
94
+ }