@kodrunhq/opencode-autopilot 1.15.2 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.ts +5 -0
- package/bin/inspect.ts +337 -0
- package/package.json +1 -1
- package/src/agents/autopilot.ts +7 -15
- 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 +3 -3
- package/src/health/checks.ts +126 -4
- package/src/health/types.ts +1 -1
- package/src/index.ts +128 -13
- package/src/inspect/formatters.ts +225 -0
- package/src/inspect/repository.ts +882 -0
- package/src/kernel/database.ts +45 -0
- package/src/kernel/migrations.ts +62 -0
- package/src/kernel/repository.ts +571 -0
- package/src/kernel/schema.ts +122 -0
- package/src/kernel/transaction.ts +48 -0
- package/src/kernel/types.ts +65 -0
- 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 +82 -67
- package/src/memory/database.ts +74 -12
- package/src/memory/decay.ts +11 -2
- package/src/memory/index.ts +17 -1
- 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/project-key.ts +6 -0
- package/src/memory/projects.ts +83 -0
- package/src/memory/repository.ts +52 -216
- package/src/memory/retrieval.ts +88 -170
- package/src/memory/schemas.ts +39 -7
- package/src/memory/types.ts +4 -0
- package/src/observability/context-display.ts +8 -0
- package/src/observability/event-handlers.ts +69 -20
- package/src/observability/event-store.ts +29 -1
- package/src/observability/forensic-log.ts +167 -0
- package/src/observability/forensic-schemas.ts +77 -0
- package/src/observability/forensic-types.ts +10 -0
- package/src/observability/index.ts +21 -27
- package/src/observability/log-reader.ts +161 -111
- package/src/observability/log-writer.ts +41 -83
- package/src/observability/retention.ts +2 -2
- package/src/observability/session-logger.ts +36 -57
- package/src/observability/summary-generator.ts +31 -19
- package/src/observability/types.ts +12 -24
- package/src/orchestrator/contracts/invariants.ts +14 -0
- package/src/orchestrator/contracts/legacy-result-adapter.ts +8 -20
- package/src/orchestrator/error-context.ts +24 -0
- package/src/orchestrator/fallback/event-handler.ts +47 -3
- package/src/orchestrator/handlers/architect.ts +2 -1
- package/src/orchestrator/handlers/build-utils.ts +118 -0
- package/src/orchestrator/handlers/build.ts +42 -219
- package/src/orchestrator/handlers/retrospective.ts +2 -2
- package/src/orchestrator/handlers/types.ts +0 -1
- package/src/orchestrator/lesson-memory.ts +36 -11
- package/src/orchestrator/orchestration-logger.ts +53 -24
- package/src/orchestrator/phase.ts +8 -4
- package/src/orchestrator/progress.ts +63 -0
- package/src/orchestrator/state.ts +79 -17
- package/src/projects/database.ts +47 -0
- package/src/projects/repository.ts +264 -0
- package/src/projects/resolve.ts +301 -0
- package/src/projects/schemas.ts +30 -0
- package/src/projects/types.ts +12 -0
- package/src/review/memory.ts +39 -11
- 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/doctor.ts +28 -4
- package/src/tools/forensics.ts +7 -12
- package/src/tools/logs.ts +38 -11
- package/src/tools/memory-preferences.ts +157 -0
- package/src/tools/memory-status.ts +17 -96
- package/src/tools/orchestrate.ts +108 -90
- package/src/tools/pipeline-report.ts +3 -2
- package/src/tools/quick.ts +2 -2
- package/src/tools/replay.ts +42 -0
- package/src/tools/review.ts +46 -7
- package/src/tools/session-stats.ts +3 -2
- package/src/tools/summary.ts +43 -0
- package/src/utils/paths.ts +20 -1
- package/src/utils/random.ts +33 -0
- package/src/ux/session-summary.ts +56 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log rotation and retention for the OpenCode Autopilot plugin.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Max file-count enforcement (oldest files pruned first)
|
|
6
|
+
* - Time-based expiry (files older than `maxAgeDays` are removed)
|
|
7
|
+
* - Gzip compression of rotated `.log` / `.jsonl` files
|
|
8
|
+
*
|
|
9
|
+
* All filesystem operations use `node:fs/promises` for portability.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
15
|
+
import { readdir, rename, stat, unlink } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { createGzip } from "node:zlib";
|
|
18
|
+
import { isEnoentError } from "../utils/fs-helpers";
|
|
19
|
+
|
|
20
|
+
/** Extensions eligible for gzip compression during rotation. */
|
|
21
|
+
const COMPRESSIBLE_EXTENSIONS = new Set([".log", ".jsonl"]);
|
|
22
|
+
|
|
23
|
+
/** Default maximum number of log files to keep (excluding compressed archives). */
|
|
24
|
+
const DEFAULT_MAX_FILES = 10;
|
|
25
|
+
|
|
26
|
+
/** Default maximum log file size in bytes (10 MiB). */
|
|
27
|
+
const DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Default maximum age in days before a file is deleted. */
|
|
30
|
+
const DEFAULT_MAX_AGE_DAYS = 30;
|
|
31
|
+
|
|
32
|
+
export interface RotationOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Maximum number of log files to retain (oldest are pruned first).
|
|
35
|
+
* Does not count `.gz` archives.
|
|
36
|
+
* @default 10
|
|
37
|
+
*/
|
|
38
|
+
readonly maxFiles?: number;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Maximum individual file size in bytes. Files exceeding this limit are
|
|
42
|
+
* compressed and renamed with a `.gz` extension before the next write.
|
|
43
|
+
* @default 10_485_760 (10 MiB)
|
|
44
|
+
*/
|
|
45
|
+
readonly maxSize?: number;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Maximum age in days. Files (and archives) older than this are deleted.
|
|
49
|
+
* @default 30
|
|
50
|
+
*/
|
|
51
|
+
readonly maxAgeDays?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RotationResult {
|
|
55
|
+
/** Number of files compressed into `.gz` archives. */
|
|
56
|
+
readonly compressed: number;
|
|
57
|
+
/** Number of files deleted (age or count limit exceeded). */
|
|
58
|
+
readonly deleted: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface FileEntry {
|
|
62
|
+
readonly name: string;
|
|
63
|
+
readonly path: string;
|
|
64
|
+
readonly mtimeMs: number;
|
|
65
|
+
readonly size: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isCompressible(name: string): boolean {
|
|
69
|
+
const dot = name.lastIndexOf(".");
|
|
70
|
+
if (dot === -1) return false;
|
|
71
|
+
const ext = name.slice(dot);
|
|
72
|
+
return COMPRESSIBLE_EXTENSIONS.has(ext);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isArchive(name: string): boolean {
|
|
76
|
+
return name.endsWith(".gz");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compresses `sourcePath` to `sourcePath + ".gz"` then removes the original.
|
|
81
|
+
* Returns `true` on success, `false` if the source vanished mid-flight.
|
|
82
|
+
*/
|
|
83
|
+
async function gzipFile(sourcePath: string): Promise<boolean> {
|
|
84
|
+
const archivePath = `${sourcePath}.gz`;
|
|
85
|
+
await new Promise<void>((resolve, reject) => {
|
|
86
|
+
const readStream = createReadStream(sourcePath);
|
|
87
|
+
const writeStream = createWriteStream(archivePath);
|
|
88
|
+
const gzip = createGzip();
|
|
89
|
+
|
|
90
|
+
readStream.on("error", reject);
|
|
91
|
+
writeStream.on("error", reject);
|
|
92
|
+
writeStream.on("finish", resolve);
|
|
93
|
+
|
|
94
|
+
readStream.pipe(gzip).pipe(writeStream);
|
|
95
|
+
});
|
|
96
|
+
// Only remove the original after the archive is fully written.
|
|
97
|
+
await unlink(sourcePath);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reads all entries in `logDir`, resolving `stat` for each.
|
|
103
|
+
* Silently skips entries that disappear between readdir and stat.
|
|
104
|
+
*/
|
|
105
|
+
async function listEntries(logDir: string): Promise<readonly FileEntry[]> {
|
|
106
|
+
let names: string[];
|
|
107
|
+
try {
|
|
108
|
+
names = await readdir(logDir);
|
|
109
|
+
} catch (error: unknown) {
|
|
110
|
+
if (isEnoentError(error)) return [];
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const entries: FileEntry[] = [];
|
|
115
|
+
for (const name of names) {
|
|
116
|
+
const filePath = join(logDir, name);
|
|
117
|
+
try {
|
|
118
|
+
const fileStat = await stat(filePath);
|
|
119
|
+
if (!fileStat.isFile()) continue;
|
|
120
|
+
entries.push({
|
|
121
|
+
name,
|
|
122
|
+
path: filePath,
|
|
123
|
+
mtimeMs: fileStat.mtimeMs,
|
|
124
|
+
size: fileStat.size,
|
|
125
|
+
});
|
|
126
|
+
} catch (error: unknown) {
|
|
127
|
+
if (!isEnoentError(error)) throw error;
|
|
128
|
+
// File disappeared between readdir and stat — skip it.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return entries;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Checks whether a single file exceeds the given `maxSize` threshold.
|
|
137
|
+
*
|
|
138
|
+
* Intended for use by writers that want to rotate before the next append.
|
|
139
|
+
*
|
|
140
|
+
* @param filePath - Absolute path to the log file.
|
|
141
|
+
* @param maxSize - Size limit in bytes.
|
|
142
|
+
* @returns `true` when the file exists and its size exceeds `maxSize`.
|
|
143
|
+
*/
|
|
144
|
+
export async function exceedsMaxSize(filePath: string, maxSize: number): Promise<boolean> {
|
|
145
|
+
try {
|
|
146
|
+
const fileStat = await stat(filePath);
|
|
147
|
+
return fileStat.isFile() && fileStat.size > maxSize;
|
|
148
|
+
} catch (error: unknown) {
|
|
149
|
+
if (isEnoentError(error)) return false;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Rotates a single active log file by compressing it to `<filePath>.gz`.
|
|
156
|
+
*
|
|
157
|
+
* The caller is responsible for opening a fresh log file afterwards.
|
|
158
|
+
* Returns `true` when the file was successfully rotated, `false` when the
|
|
159
|
+
* file did not exist (nothing to rotate).
|
|
160
|
+
*
|
|
161
|
+
* @param filePath - Absolute path to the log file to rotate.
|
|
162
|
+
*/
|
|
163
|
+
export async function rotateFile(filePath: string): Promise<boolean> {
|
|
164
|
+
try {
|
|
165
|
+
const fileStat = await stat(filePath);
|
|
166
|
+
if (!fileStat.isFile()) return false;
|
|
167
|
+
} catch (error: unknown) {
|
|
168
|
+
if (isEnoentError(error)) return false;
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!isCompressible(filePath)) {
|
|
173
|
+
// Non-compressible files are renamed with a timestamp suffix.
|
|
174
|
+
const rotatedPath = `${filePath}.${Date.now()}.bak`;
|
|
175
|
+
await rename(filePath, rotatedPath);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return gzipFile(filePath);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Runs the full rotation and retention policy for all log files in `logDir`.
|
|
184
|
+
*
|
|
185
|
+
* **What this does (in order):**
|
|
186
|
+
* 1. Compress oversized `.log` / `.jsonl` files into `.gz` archives.
|
|
187
|
+
* 2. Delete files (any extension) older than `maxAgeDays`.
|
|
188
|
+
* 3. Prune oldest plain log files when their count exceeds `maxFiles`.
|
|
189
|
+
*
|
|
190
|
+
* @param logDir - Directory containing log files.
|
|
191
|
+
* @param options - Rotation and retention options.
|
|
192
|
+
* @returns Counts of compressed and deleted files.
|
|
193
|
+
*/
|
|
194
|
+
export async function rotateLogs(
|
|
195
|
+
logDir: string,
|
|
196
|
+
options?: RotationOptions,
|
|
197
|
+
): Promise<RotationResult> {
|
|
198
|
+
const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES;
|
|
199
|
+
const maxSize = options?.maxSize ?? DEFAULT_MAX_SIZE_BYTES;
|
|
200
|
+
const maxAgeDays = options?.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS;
|
|
201
|
+
const ageThresholdMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
202
|
+
|
|
203
|
+
let compressed = 0;
|
|
204
|
+
let deleted = 0;
|
|
205
|
+
|
|
206
|
+
// --- Pass 1: Compress oversized plain log files ---
|
|
207
|
+
{
|
|
208
|
+
const entries = await listEntries(logDir);
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
if (isArchive(entry.name)) continue;
|
|
211
|
+
if (!isCompressible(entry.name)) continue;
|
|
212
|
+
if (entry.size <= maxSize) continue;
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const rotated = await gzipFile(entry.path);
|
|
216
|
+
if (rotated) compressed++;
|
|
217
|
+
} catch (error: unknown) {
|
|
218
|
+
if (!isEnoentError(error)) throw error;
|
|
219
|
+
// File disappeared — not an error.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// --- Pass 2: Delete files older than maxAgeDays (any extension) ---
|
|
225
|
+
{
|
|
226
|
+
const entries = await listEntries(logDir);
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
if (entry.mtimeMs >= ageThresholdMs) continue;
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
await unlink(entry.path);
|
|
232
|
+
deleted++;
|
|
233
|
+
} catch (error: unknown) {
|
|
234
|
+
if (!isEnoentError(error)) throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- Pass 3: Prune oldest plain log files that exceed maxFiles count ---
|
|
240
|
+
{
|
|
241
|
+
const entries = await listEntries(logDir);
|
|
242
|
+
const plainLogs = entries
|
|
243
|
+
.filter((e) => !isArchive(e.name) && isCompressible(e.name))
|
|
244
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
245
|
+
|
|
246
|
+
const overflow = plainLogs.length - maxFiles;
|
|
247
|
+
if (overflow > 0) {
|
|
248
|
+
const toDelete = plainLogs.slice(0, overflow);
|
|
249
|
+
for (const entry of toDelete) {
|
|
250
|
+
try {
|
|
251
|
+
await unlink(entry.path);
|
|
252
|
+
deleted++;
|
|
253
|
+
} catch (error: unknown) {
|
|
254
|
+
if (!isEnoentError(error)) throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return { compressed, deleted };
|
|
261
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logging type definitions for the OpenCode Autopilot plugin.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
|
|
8
|
+
|
|
9
|
+
export interface LogMetadata {
|
|
10
|
+
readonly domain: string;
|
|
11
|
+
readonly subsystem?: string;
|
|
12
|
+
readonly operation?: string;
|
|
13
|
+
readonly [key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LogEntry {
|
|
17
|
+
readonly timestamp: string;
|
|
18
|
+
readonly level: LogLevel;
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly metadata: LogMetadata;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LogSink {
|
|
24
|
+
write(entry: LogEntry): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Logger {
|
|
28
|
+
debug(message: string, metadata?: Partial<LogMetadata>): void;
|
|
29
|
+
info(message: string, metadata?: Partial<LogMetadata>): void;
|
|
30
|
+
warn(message: string, metadata?: Partial<LogMetadata>): void;
|
|
31
|
+
error(message: string, metadata?: Partial<LogMetadata>): void;
|
|
32
|
+
child(metadata: Partial<LogMetadata>): Logger;
|
|
33
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const PROJECT_SCOPE_HINTS = [
|
|
2
|
+
"in this repo",
|
|
3
|
+
"for this repo",
|
|
4
|
+
"in this project",
|
|
5
|
+
"for this project",
|
|
6
|
+
"in this codebase",
|
|
7
|
+
"for this codebase",
|
|
8
|
+
"here ",
|
|
9
|
+
"this repo ",
|
|
10
|
+
"this project ",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
const EXPLICIT_PREFERENCE_PATTERNS = [
|
|
14
|
+
{
|
|
15
|
+
regex: /\b(?:please|do|always|generally)\s+(?:use|prefer|keep|run|avoid)\s+(.+?)(?:[.!?]|$)/i,
|
|
16
|
+
buildValue: (match: RegExpMatchArray) => match[1]?.trim() ?? "",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
regex: /\b(?:i|we)\s+(?:prefer|want|need|like)\s+(.+?)(?:[.!?]|$)/i,
|
|
20
|
+
buildValue: (match: RegExpMatchArray) => match[1]?.trim() ?? "",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
regex: /\b(?:don't|do not|never)\s+(.+?)(?:[.!?]|$)/i,
|
|
24
|
+
buildValue: (match: RegExpMatchArray) => `avoid ${match[1]?.trim() ?? ""}`,
|
|
25
|
+
},
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export interface PreferenceCandidate {
|
|
29
|
+
readonly key: string;
|
|
30
|
+
readonly value: string;
|
|
31
|
+
readonly scope: "global" | "project";
|
|
32
|
+
readonly confidence: number;
|
|
33
|
+
readonly statement: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function extractSessionId(properties: Record<string, unknown>): string | undefined {
|
|
37
|
+
if (typeof properties.sessionID === "string") return properties.sessionID;
|
|
38
|
+
if (properties.info !== null && typeof properties.info === "object") {
|
|
39
|
+
const info = properties.info as Record<string, unknown>;
|
|
40
|
+
if (typeof info.sessionID === "string") return info.sessionID;
|
|
41
|
+
if (typeof info.id === "string") return info.id;
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizePreferenceKey(value: string): string {
|
|
47
|
+
const normalized = value
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
50
|
+
.trim()
|
|
51
|
+
.split(/\s+/)
|
|
52
|
+
.slice(0, 6)
|
|
53
|
+
.join(".");
|
|
54
|
+
return normalized.length > 0 ? normalized : "user.preference";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizePreferenceValue(value: string): string {
|
|
58
|
+
return value
|
|
59
|
+
.replace(/\s+/g, " ")
|
|
60
|
+
.trim()
|
|
61
|
+
.replace(/[.!?]+$/, "");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function inferPreferenceScope(text: string): "global" | "project" {
|
|
65
|
+
const lowerText = text.toLowerCase();
|
|
66
|
+
return PROJECT_SCOPE_HINTS.some((hint) => lowerText.includes(hint)) ? "project" : "global";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function extractTextPartContent(part: unknown): string | null {
|
|
70
|
+
if (part === null || typeof part !== "object") {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const record = part as Record<string, unknown>;
|
|
75
|
+
if (record.type !== "text") {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof record.text === "string" && record.text.trim().length > 0) {
|
|
80
|
+
return record.text;
|
|
81
|
+
}
|
|
82
|
+
if (typeof record.content === "string" && record.content.trim().length > 0) {
|
|
83
|
+
return record.content;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function extractExplicitPreferenceCandidates(
|
|
90
|
+
parts: readonly unknown[],
|
|
91
|
+
): readonly PreferenceCandidate[] {
|
|
92
|
+
const joinedText = parts
|
|
93
|
+
.map(extractTextPartContent)
|
|
94
|
+
.filter((value): value is string => value !== null)
|
|
95
|
+
.join("\n")
|
|
96
|
+
.trim();
|
|
97
|
+
if (joinedText.length === 0) {
|
|
98
|
+
return Object.freeze([]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const candidates: PreferenceCandidate[] = [];
|
|
102
|
+
const scope = inferPreferenceScope(joinedText);
|
|
103
|
+
const lines = joinedText
|
|
104
|
+
.split(/\n+/)
|
|
105
|
+
.flatMap((line) => line.split(/(?<=[.!?])\s+/))
|
|
106
|
+
.map((line) => line.trim())
|
|
107
|
+
.filter((line) => line.length > 0 && line.length <= 500);
|
|
108
|
+
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
for (const pattern of EXPLICIT_PREFERENCE_PATTERNS) {
|
|
111
|
+
const match = line.match(pattern.regex);
|
|
112
|
+
if (!match) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const value = normalizePreferenceValue(pattern.buildValue(match));
|
|
117
|
+
if (value.length < 6) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
candidates.push(
|
|
122
|
+
Object.freeze({
|
|
123
|
+
key: normalizePreferenceKey(value),
|
|
124
|
+
value,
|
|
125
|
+
scope,
|
|
126
|
+
confidence: 0.9,
|
|
127
|
+
statement: line,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const seen = new Set<string>();
|
|
135
|
+
return Object.freeze(
|
|
136
|
+
candidates.filter((candidate) => {
|
|
137
|
+
const uniqueness = `${candidate.scope}:${candidate.key}:${candidate.value}`;
|
|
138
|
+
if (seen.has(uniqueness)) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
seen.add(uniqueness);
|
|
142
|
+
return true;
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function truncate(s: string, maxLen: number): string {
|
|
148
|
+
return s.length > maxLen ? s.slice(0, maxLen) : s;
|
|
149
|
+
}
|
package/src/memory/capture.ts
CHANGED
|
@@ -1,35 +1,19 @@
|
|
|
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
1
|
import type { Database } from "bun:sqlite";
|
|
16
2
|
import { basename } from "node:path";
|
|
3
|
+
import { getLogger } from "../logging/domains";
|
|
4
|
+
import { resolveProjectIdentity } from "../projects/resolve";
|
|
5
|
+
import * as captureUtils from "./capture-utils";
|
|
17
6
|
import { pruneStaleObservations } from "./decay";
|
|
18
|
-
import {
|
|
19
|
-
import { insertObservation, upsertProject } from "./repository";
|
|
7
|
+
import { insertObservation, upsertPreferenceRecord, upsertProject } from "./repository";
|
|
20
8
|
import type { ObservationType } from "./types";
|
|
21
9
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*/
|
|
10
|
+
const logger = getLogger("memory", "capture");
|
|
11
|
+
|
|
25
12
|
export interface MemoryCaptureDeps {
|
|
26
13
|
readonly getDb: () => Database;
|
|
27
14
|
readonly projectRoot: string;
|
|
28
15
|
}
|
|
29
16
|
|
|
30
|
-
/**
|
|
31
|
-
* Events that produce memory observations.
|
|
32
|
-
*/
|
|
33
17
|
const CAPTURE_EVENT_TYPES = new Set([
|
|
34
18
|
"session.created",
|
|
35
19
|
"session.deleted",
|
|
@@ -38,35 +22,6 @@ const CAPTURE_EVENT_TYPES = new Set([
|
|
|
38
22
|
"app.phase_transition",
|
|
39
23
|
]);
|
|
40
24
|
|
|
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
25
|
export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
|
|
71
26
|
let currentSessionId: string | null = null;
|
|
72
27
|
let currentProjectKey: string | null = null;
|
|
@@ -87,7 +42,7 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
|
|
|
87
42
|
sessionId: currentSessionId,
|
|
88
43
|
type,
|
|
89
44
|
content,
|
|
90
|
-
summary: truncate(summary, 200),
|
|
45
|
+
summary: captureUtils.truncate(summary, 200),
|
|
91
46
|
confidence,
|
|
92
47
|
accessCount: 0,
|
|
93
48
|
createdAt: now(),
|
|
@@ -96,7 +51,7 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
|
|
|
96
51
|
deps.getDb(),
|
|
97
52
|
);
|
|
98
53
|
} catch (err) {
|
|
99
|
-
|
|
54
|
+
logger.warn("memory capture failed", { error: String(err) });
|
|
100
55
|
}
|
|
101
56
|
}
|
|
102
57
|
|
|
@@ -110,7 +65,6 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
|
|
|
110
65
|
? (rawProps as Record<string, unknown>)
|
|
111
66
|
: {};
|
|
112
67
|
|
|
113
|
-
// Skip noisy events early
|
|
114
68
|
if (!CAPTURE_EVENT_TYPES.has(event.type)) return;
|
|
115
69
|
|
|
116
70
|
switch (event.type) {
|
|
@@ -121,7 +75,10 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
|
|
|
121
75
|
if (!info.id) return;
|
|
122
76
|
|
|
123
77
|
currentSessionId = info.id;
|
|
124
|
-
|
|
78
|
+
const resolvedProject = await resolveProjectIdentity(deps.projectRoot, {
|
|
79
|
+
db: deps.getDb(),
|
|
80
|
+
});
|
|
81
|
+
currentProjectKey = resolvedProject.id;
|
|
125
82
|
const projectName = basename(deps.projectRoot);
|
|
126
83
|
|
|
127
84
|
try {
|
|
@@ -130,12 +87,13 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
|
|
|
130
87
|
id: currentProjectKey,
|
|
131
88
|
path: deps.projectRoot,
|
|
132
89
|
name: projectName,
|
|
90
|
+
firstSeenAt: resolvedProject.firstSeenAt,
|
|
133
91
|
lastUpdated: now(),
|
|
134
92
|
},
|
|
135
93
|
deps.getDb(),
|
|
136
94
|
);
|
|
137
95
|
} catch (err) {
|
|
138
|
-
|
|
96
|
+
logger.warn("upsertProject failed", { error: String(err) });
|
|
139
97
|
}
|
|
140
98
|
return;
|
|
141
99
|
}
|
|
@@ -144,18 +102,15 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
|
|
|
144
102
|
const projectKey = currentProjectKey;
|
|
145
103
|
const db = deps.getDb();
|
|
146
104
|
|
|
147
|
-
// Reset state
|
|
148
105
|
currentSessionId = null;
|
|
149
106
|
currentProjectKey = null;
|
|
150
107
|
|
|
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
108
|
if (projectKey) {
|
|
154
109
|
queueMicrotask(() => {
|
|
155
110
|
try {
|
|
156
111
|
pruneStaleObservations(projectKey, db);
|
|
157
112
|
} catch (err) {
|
|
158
|
-
|
|
113
|
+
logger.warn("pruneStaleObservations failed", { error: String(err) });
|
|
159
114
|
}
|
|
160
115
|
});
|
|
161
116
|
}
|
|
@@ -163,21 +118,21 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
|
|
|
163
118
|
}
|
|
164
119
|
|
|
165
120
|
case "session.error": {
|
|
166
|
-
const sessionId = extractSessionId(properties);
|
|
121
|
+
const sessionId = captureUtils.extractSessionId(properties);
|
|
167
122
|
if (!sessionId || sessionId !== currentSessionId) return;
|
|
168
123
|
|
|
169
124
|
const error = properties.error as Record<string, unknown> | undefined;
|
|
170
125
|
const errorType = typeof error?.type === "string" ? error.type : "unknown";
|
|
171
126
|
const message = typeof error?.message === "string" ? error.message : "Unknown error";
|
|
172
127
|
const content = `${errorType}: ${message}`;
|
|
173
|
-
const summary = truncate(message, 200);
|
|
128
|
+
const summary = captureUtils.truncate(message, 200);
|
|
174
129
|
|
|
175
130
|
safeInsert("error", content, summary, 0.7);
|
|
176
131
|
return;
|
|
177
132
|
}
|
|
178
133
|
|
|
179
134
|
case "app.decision": {
|
|
180
|
-
const sessionId = extractSessionId(properties);
|
|
135
|
+
const sessionId = captureUtils.extractSessionId(properties);
|
|
181
136
|
if (!sessionId || sessionId !== currentSessionId) return;
|
|
182
137
|
|
|
183
138
|
const decision = typeof properties.decision === "string" ? properties.decision : "";
|
|
@@ -185,21 +140,20 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
|
|
|
185
140
|
|
|
186
141
|
if (!decision) return;
|
|
187
142
|
|
|
188
|
-
safeInsert("decision", decision, rationale || truncate(decision, 200), 0.8);
|
|
143
|
+
safeInsert("decision", decision, rationale || captureUtils.truncate(decision, 200), 0.8);
|
|
189
144
|
return;
|
|
190
145
|
}
|
|
191
146
|
|
|
192
147
|
case "app.phase_transition": {
|
|
193
|
-
const sessionId = extractSessionId(properties);
|
|
148
|
+
const sessionId = captureUtils.extractSessionId(properties);
|
|
194
149
|
if (!sessionId || sessionId !== currentSessionId) return;
|
|
195
150
|
|
|
196
151
|
const fromPhase =
|
|
197
152
|
typeof properties.fromPhase === "string" ? properties.fromPhase : "unknown";
|
|
198
153
|
const toPhase = typeof properties.toPhase === "string" ? properties.toPhase : "unknown";
|
|
199
154
|
const content = `Phase transition: ${fromPhase} -> ${toPhase}`;
|
|
200
|
-
const summary = content;
|
|
201
155
|
|
|
202
|
-
safeInsert("pattern", content,
|
|
156
|
+
safeInsert("pattern", content, content, 0.6);
|
|
203
157
|
return;
|
|
204
158
|
}
|
|
205
159
|
|
|
@@ -208,3 +162,64 @@ export function createMemoryCaptureHandler(deps: MemoryCaptureDeps) {
|
|
|
208
162
|
}
|
|
209
163
|
};
|
|
210
164
|
}
|
|
165
|
+
|
|
166
|
+
export function createMemoryChatMessageHandler(deps: MemoryCaptureDeps) {
|
|
167
|
+
return async (
|
|
168
|
+
input: { readonly sessionID: string },
|
|
169
|
+
output: { readonly parts: unknown[] },
|
|
170
|
+
): Promise<void> => {
|
|
171
|
+
try {
|
|
172
|
+
const candidates = captureUtils.extractExplicitPreferenceCandidates(output.parts);
|
|
173
|
+
if (candidates.length === 0) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const resolvedProject = await resolveProjectIdentity(deps.projectRoot, {
|
|
178
|
+
db: deps.getDb(),
|
|
179
|
+
});
|
|
180
|
+
const projectName = basename(deps.projectRoot);
|
|
181
|
+
const timestamp = new Date().toISOString();
|
|
182
|
+
|
|
183
|
+
upsertProject(
|
|
184
|
+
{
|
|
185
|
+
id: resolvedProject.id,
|
|
186
|
+
path: deps.projectRoot,
|
|
187
|
+
name: projectName,
|
|
188
|
+
firstSeenAt: resolvedProject.firstSeenAt,
|
|
189
|
+
lastUpdated: timestamp,
|
|
190
|
+
},
|
|
191
|
+
deps.getDb(),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
for (const candidate of candidates) {
|
|
195
|
+
upsertPreferenceRecord(
|
|
196
|
+
{
|
|
197
|
+
key: candidate.key,
|
|
198
|
+
value: candidate.value,
|
|
199
|
+
scope: candidate.scope,
|
|
200
|
+
projectId: candidate.scope === "project" ? resolvedProject.id : null,
|
|
201
|
+
status: "confirmed",
|
|
202
|
+
confidence: candidate.confidence,
|
|
203
|
+
sourceSession: input.sessionID,
|
|
204
|
+
createdAt: timestamp,
|
|
205
|
+
lastUpdated: timestamp,
|
|
206
|
+
evidence: [
|
|
207
|
+
{
|
|
208
|
+
sessionId: input.sessionID,
|
|
209
|
+
statement: candidate.statement,
|
|
210
|
+
confidence: candidate.confidence,
|
|
211
|
+
confirmed: true,
|
|
212
|
+
createdAt: timestamp,
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
deps.getDb(),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
logger.warn("explicit preference capture failed", { error: String(err) });
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export { captureUtils as memoryCaptureInternals };
|