@kodrunhq/opencode-autopilot 1.5.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.
- package/bin/configure-tui.ts +1 -1
- package/package.json +1 -1
- package/src/config.ts +76 -14
- package/src/index.ts +39 -2
- package/src/memory/capture.ts +205 -0
- package/src/memory/constants.ts +26 -0
- package/src/memory/database.ts +103 -0
- package/src/memory/decay.ts +94 -0
- package/src/memory/index.ts +24 -0
- package/src/memory/injector.ts +85 -0
- package/src/memory/project-key.ts +5 -0
- package/src/memory/repository.ts +217 -0
- package/src/memory/retrieval.ts +260 -0
- package/src/memory/schemas.ts +34 -0
- package/src/memory/types.ts +12 -0
- package/src/tools/configure.ts +1 -1
- package/src/tools/memory-status.ts +164 -0
package/bin/configure-tui.ts
CHANGED
|
@@ -307,7 +307,7 @@ export async function runConfigure(configPath: string = CONFIG_PATH): Promise<vo
|
|
|
307
307
|
|
|
308
308
|
const newConfig = {
|
|
309
309
|
...baseConfig,
|
|
310
|
-
version:
|
|
310
|
+
version: 5 as const,
|
|
311
311
|
configured: true,
|
|
312
312
|
groups: groupsRecord,
|
|
313
313
|
overrides: baseConfig.overrides ?? {},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kodrunhq/opencode-autopilot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Curated agents, skills, and commands for the OpenCode AI coding CLI — autonomous orchestrator, multi-agent code review, model fallback, and in-session asset creation tools.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"keywords": [
|
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
|
-
|
|
116
|
-
|
|
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
|
|
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):
|
|
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
|
|
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)
|
|
257
|
+
if (v4Result.success) {
|
|
258
|
+
const migrated = migrateV4toV5(v4Result.data);
|
|
259
|
+
await saveConfig(migrated, configPath);
|
|
260
|
+
return migrated;
|
|
261
|
+
}
|
|
204
262
|
|
|
205
|
-
// Try v3
|
|
263
|
+
// Try v3 → v4 → v5
|
|
206
264
|
const v3Result = pluginConfigSchemaV3.safeParse(parsed);
|
|
207
265
|
if (v3Result.success) {
|
|
208
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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";
|
|
@@ -148,6 +150,26 @@ const plugin: Plugin = async (input) => {
|
|
|
148
150
|
const chatMessageHandler = createChatMessageHandler(manager);
|
|
149
151
|
const toolExecuteAfterHandler = createToolExecuteAfterHandler(manager);
|
|
150
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
|
+
|
|
151
173
|
// --- Observability handlers ---
|
|
152
174
|
const toolStartTimes = new Map<string, number>();
|
|
153
175
|
const observabilityEventHandler = createObservabilityEventHandler({
|
|
@@ -195,12 +217,22 @@ const plugin: Plugin = async (input) => {
|
|
|
195
217
|
oc_mock_fallback: ocMockFallback,
|
|
196
218
|
oc_stocktake: ocStocktake,
|
|
197
219
|
oc_update_docs: ocUpdateDocs,
|
|
220
|
+
oc_memory_status: ocMemoryStatus,
|
|
198
221
|
},
|
|
199
222
|
event: async ({ event }) => {
|
|
200
223
|
// 1. Observability: collect (pure observer, no side effects on session)
|
|
201
224
|
await observabilityEventHandler({ event });
|
|
202
225
|
|
|
203
|
-
// 2.
|
|
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
|
|
204
236
|
if (event.type === "session.created" && isFirstLoad(config)) {
|
|
205
237
|
await sdkOps.showToast(
|
|
206
238
|
"Welcome to OpenCode Autopilot!",
|
|
@@ -209,7 +241,7 @@ const plugin: Plugin = async (input) => {
|
|
|
209
241
|
);
|
|
210
242
|
}
|
|
211
243
|
|
|
212
|
-
//
|
|
244
|
+
// 4. Fallback event handling
|
|
213
245
|
if (fallbackConfig.enabled) {
|
|
214
246
|
await fallbackEventHandler({ event });
|
|
215
247
|
}
|
|
@@ -257,6 +289,11 @@ const plugin: Plugin = async (input) => {
|
|
|
257
289
|
await toolExecuteAfterHandler(hookInput, output);
|
|
258
290
|
}
|
|
259
291
|
},
|
|
292
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
293
|
+
if (memoryInjector) {
|
|
294
|
+
await memoryInjector(input, output);
|
|
295
|
+
}
|
|
296
|
+
},
|
|
260
297
|
};
|
|
261
298
|
};
|
|
262
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
|
+
}
|