engrm 0.1.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/.mcp.json +9 -0
- package/AUTH-DESIGN.md +436 -0
- package/BRIEF.md +197 -0
- package/CLAUDE.md +44 -0
- package/COMPETITIVE.md +174 -0
- package/CONTEXT-OPTIMIZATION.md +305 -0
- package/INFRASTRUCTURE.md +252 -0
- package/LICENSE +105 -0
- package/MARKET.md +230 -0
- package/PLAN.md +278 -0
- package/README.md +121 -0
- package/SENTINEL.md +293 -0
- package/SERVER-API-PLAN.md +553 -0
- package/SPEC.md +843 -0
- package/SWOT.md +148 -0
- package/SYNC-ARCHITECTURE.md +294 -0
- package/VIBE-CODER-STRATEGY.md +250 -0
- package/bun.lock +375 -0
- package/hooks/post-tool-use.ts +144 -0
- package/hooks/session-start.ts +64 -0
- package/hooks/stop.ts +131 -0
- package/mem-page.html +1305 -0
- package/package.json +30 -0
- package/src/capture/dedup.test.ts +103 -0
- package/src/capture/dedup.ts +76 -0
- package/src/capture/extractor.test.ts +245 -0
- package/src/capture/extractor.ts +330 -0
- package/src/capture/quality.test.ts +168 -0
- package/src/capture/quality.ts +104 -0
- package/src/capture/retrospective.test.ts +115 -0
- package/src/capture/retrospective.ts +121 -0
- package/src/capture/scanner.test.ts +131 -0
- package/src/capture/scanner.ts +100 -0
- package/src/capture/scrubber.test.ts +144 -0
- package/src/capture/scrubber.ts +181 -0
- package/src/cli.ts +517 -0
- package/src/config.ts +238 -0
- package/src/context/inject.test.ts +940 -0
- package/src/context/inject.ts +382 -0
- package/src/embeddings/backfill.ts +50 -0
- package/src/embeddings/embedder.test.ts +76 -0
- package/src/embeddings/embedder.ts +139 -0
- package/src/lifecycle/aging.test.ts +103 -0
- package/src/lifecycle/aging.ts +36 -0
- package/src/lifecycle/compaction.test.ts +264 -0
- package/src/lifecycle/compaction.ts +190 -0
- package/src/lifecycle/purge.test.ts +100 -0
- package/src/lifecycle/purge.ts +37 -0
- package/src/lifecycle/scheduler.test.ts +120 -0
- package/src/lifecycle/scheduler.ts +101 -0
- package/src/provisioning/browser-auth.ts +172 -0
- package/src/provisioning/provision.test.ts +198 -0
- package/src/provisioning/provision.ts +94 -0
- package/src/register.test.ts +167 -0
- package/src/register.ts +178 -0
- package/src/server.ts +436 -0
- package/src/storage/migrations.test.ts +244 -0
- package/src/storage/migrations.ts +261 -0
- package/src/storage/outbox.test.ts +229 -0
- package/src/storage/outbox.ts +131 -0
- package/src/storage/projects.test.ts +137 -0
- package/src/storage/projects.ts +184 -0
- package/src/storage/sqlite.test.ts +798 -0
- package/src/storage/sqlite.ts +934 -0
- package/src/storage/vec.test.ts +198 -0
- package/src/sync/auth.test.ts +76 -0
- package/src/sync/auth.ts +68 -0
- package/src/sync/client.ts +183 -0
- package/src/sync/engine.test.ts +94 -0
- package/src/sync/engine.ts +127 -0
- package/src/sync/pull.test.ts +279 -0
- package/src/sync/pull.ts +170 -0
- package/src/sync/push.test.ts +117 -0
- package/src/sync/push.ts +230 -0
- package/src/tools/get.ts +34 -0
- package/src/tools/pin.ts +47 -0
- package/src/tools/save.test.ts +301 -0
- package/src/tools/save.ts +231 -0
- package/src/tools/search.test.ts +69 -0
- package/src/tools/search.ts +181 -0
- package/src/tools/timeline.ts +64 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation extractor for PostToolUse hooks.
|
|
3
|
+
*
|
|
4
|
+
* Analyses tool use events and decides:
|
|
5
|
+
* 1. Is this worth capturing? (signal vs noise)
|
|
6
|
+
* 2. What type of observation is it?
|
|
7
|
+
* 3. What title/narrative/files to record?
|
|
8
|
+
*
|
|
9
|
+
* Design: conservative by default — better to miss some observations
|
|
10
|
+
* than flood the database with noise.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// --- Types ---
|
|
14
|
+
|
|
15
|
+
export interface ToolUseEvent {
|
|
16
|
+
session_id: string;
|
|
17
|
+
hook_event_name: string;
|
|
18
|
+
tool_name: string;
|
|
19
|
+
tool_input: Record<string, unknown>;
|
|
20
|
+
tool_response: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExtractedObservation {
|
|
25
|
+
type: string;
|
|
26
|
+
title: string;
|
|
27
|
+
narrative: string;
|
|
28
|
+
files_read?: string[];
|
|
29
|
+
files_modified?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --- Skip rules (noise filters) ---
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Tools that are never worth capturing on their own.
|
|
36
|
+
*/
|
|
37
|
+
const SKIP_TOOLS = new Set([
|
|
38
|
+
"Glob",
|
|
39
|
+
"Grep",
|
|
40
|
+
"Read",
|
|
41
|
+
"WebSearch",
|
|
42
|
+
"WebFetch",
|
|
43
|
+
"Agent",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Bash commands that are navigational noise.
|
|
48
|
+
*/
|
|
49
|
+
const SKIP_BASH_PATTERNS = [
|
|
50
|
+
/^\s*(ls|pwd|cd|echo|cat|head|tail|wc|which|whoami|date|uname)\b/,
|
|
51
|
+
/^\s*git\s+(status|log|branch|diff|show|remote)\b/,
|
|
52
|
+
/^\s*(node|bun|npm|npx|yarn|pnpm)\s+--?version\b/,
|
|
53
|
+
/^\s*export\s+/,
|
|
54
|
+
/^\s*#/,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Bash responses indicating trivial success with no learning value.
|
|
59
|
+
*/
|
|
60
|
+
const TRIVIAL_RESPONSE_PATTERNS = [
|
|
61
|
+
/^$/,
|
|
62
|
+
/^\s*$/,
|
|
63
|
+
/^Already up to date\.$/,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
// --- Extraction logic ---
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Determine if a tool use event is worth capturing and extract observation data.
|
|
70
|
+
* Returns null if the event should be skipped.
|
|
71
|
+
*/
|
|
72
|
+
export function extractObservation(
|
|
73
|
+
event: ToolUseEvent
|
|
74
|
+
): ExtractedObservation | null {
|
|
75
|
+
const { tool_name, tool_input, tool_response } = event;
|
|
76
|
+
|
|
77
|
+
// Skip tools that are pure reads/navigation
|
|
78
|
+
if (SKIP_TOOLS.has(tool_name)) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
switch (tool_name) {
|
|
83
|
+
case "Edit":
|
|
84
|
+
return extractFromEdit(tool_input, tool_response);
|
|
85
|
+
case "Write":
|
|
86
|
+
return extractFromWrite(tool_input, tool_response);
|
|
87
|
+
case "Bash":
|
|
88
|
+
return extractFromBash(tool_input, tool_response);
|
|
89
|
+
default:
|
|
90
|
+
// MCP tool calls (mcp__server__tool) — capture if non-trivial
|
|
91
|
+
if (tool_name.startsWith("mcp__")) {
|
|
92
|
+
return extractFromMcpTool(tool_name, tool_input, tool_response);
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Per-tool extractors ---
|
|
99
|
+
|
|
100
|
+
function extractFromEdit(
|
|
101
|
+
input: Record<string, unknown>,
|
|
102
|
+
response: string
|
|
103
|
+
): ExtractedObservation | null {
|
|
104
|
+
const filePath = input["file_path"] as string | undefined;
|
|
105
|
+
if (!filePath) return null;
|
|
106
|
+
|
|
107
|
+
const oldStr = input["old_string"] as string | undefined;
|
|
108
|
+
const newStr = input["new_string"] as string | undefined;
|
|
109
|
+
if (!oldStr && !newStr) return null;
|
|
110
|
+
|
|
111
|
+
// Skip tiny cosmetic edits (whitespace, single-char changes)
|
|
112
|
+
if (oldStr && newStr) {
|
|
113
|
+
const oldTrimmed = oldStr.trim();
|
|
114
|
+
const newTrimmed = newStr.trim();
|
|
115
|
+
if (oldTrimmed === newTrimmed) return null;
|
|
116
|
+
if (Math.abs(oldTrimmed.length - newTrimmed.length) < 3 && oldTrimmed.length < 20) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fileName = filePath.split("/").pop() ?? filePath;
|
|
122
|
+
const changeSize = (newStr?.length ?? 0) - (oldStr?.length ?? 0);
|
|
123
|
+
const verb = changeSize > 50 ? "Extended" : changeSize < -50 ? "Reduced" : "Modified";
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
type: "change",
|
|
127
|
+
title: `${verb} ${fileName}`,
|
|
128
|
+
narrative: buildEditNarrative(oldStr, newStr, filePath),
|
|
129
|
+
files_modified: [filePath],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function extractFromWrite(
|
|
134
|
+
input: Record<string, unknown>,
|
|
135
|
+
response: string
|
|
136
|
+
): ExtractedObservation | null {
|
|
137
|
+
const filePath = input["file_path"] as string | undefined;
|
|
138
|
+
if (!filePath) return null;
|
|
139
|
+
|
|
140
|
+
const content = input["content"] as string | undefined;
|
|
141
|
+
const fileName = filePath.split("/").pop() ?? filePath;
|
|
142
|
+
|
|
143
|
+
// Skip very small files (likely config or trivial)
|
|
144
|
+
if (content === undefined || content.length < 50) return null;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
type: "change",
|
|
148
|
+
title: `Created ${fileName}`,
|
|
149
|
+
narrative: `New file created: ${filePath}`,
|
|
150
|
+
files_modified: [filePath],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function extractFromBash(
|
|
155
|
+
input: Record<string, unknown>,
|
|
156
|
+
response: string
|
|
157
|
+
): ExtractedObservation | null {
|
|
158
|
+
const command = input["command"] as string | undefined;
|
|
159
|
+
if (!command) return null;
|
|
160
|
+
|
|
161
|
+
// Skip navigational commands
|
|
162
|
+
for (const pattern of SKIP_BASH_PATTERNS) {
|
|
163
|
+
if (pattern.test(command)) return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Skip trivial responses
|
|
167
|
+
for (const pattern of TRIVIAL_RESPONSE_PATTERNS) {
|
|
168
|
+
if (pattern.test(response.trim())) return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Detect error → potential bugfix context
|
|
172
|
+
const hasError = detectError(response);
|
|
173
|
+
|
|
174
|
+
// Detect test runs
|
|
175
|
+
const isTestRun = detectTestRun(command);
|
|
176
|
+
|
|
177
|
+
if (isTestRun) {
|
|
178
|
+
return extractTestResult(command, response);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (hasError) {
|
|
182
|
+
return {
|
|
183
|
+
type: "bugfix",
|
|
184
|
+
title: summariseCommand(command) + " (error)",
|
|
185
|
+
narrative: `Command: ${truncate(command, 200)}\nError: ${truncate(response, 500)}`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Detect install/dependency changes
|
|
190
|
+
if (/\b(npm|bun|yarn|pnpm)\s+(install|add|remove|uninstall)\b/.test(command)) {
|
|
191
|
+
return {
|
|
192
|
+
type: "change",
|
|
193
|
+
title: `Dependency change: ${summariseCommand(command)}`,
|
|
194
|
+
narrative: `Command: ${truncate(command, 200)}\nOutput: ${truncate(response, 300)}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Detect build commands
|
|
199
|
+
if (/\b(npm|bun|yarn)\s+(run\s+)?(build|compile|bundle)\b/.test(command)) {
|
|
200
|
+
if (hasError) {
|
|
201
|
+
return {
|
|
202
|
+
type: "bugfix",
|
|
203
|
+
title: `Build failure: ${summariseCommand(command)}`,
|
|
204
|
+
narrative: `Build command failed.\nCommand: ${truncate(command, 200)}\nOutput: ${truncate(response, 500)}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// Successful builds are low signal
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Generic non-trivial bash — only capture if response is substantial
|
|
212
|
+
if (response.length > 200) {
|
|
213
|
+
return {
|
|
214
|
+
type: "change",
|
|
215
|
+
title: summariseCommand(command),
|
|
216
|
+
narrative: `Command: ${truncate(command, 200)}\nOutput: ${truncate(response, 300)}`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function extractFromMcpTool(
|
|
224
|
+
toolName: string,
|
|
225
|
+
input: Record<string, unknown>,
|
|
226
|
+
response: string
|
|
227
|
+
): ExtractedObservation | null {
|
|
228
|
+
// Skip our own engrm tools to avoid self-referential loops
|
|
229
|
+
if (toolName.startsWith("mcp__engrm__")) return null;
|
|
230
|
+
|
|
231
|
+
// Generic MCP tool capture — only if response is substantial
|
|
232
|
+
if (response.length < 100) return null;
|
|
233
|
+
|
|
234
|
+
const parts = toolName.split("__");
|
|
235
|
+
const serverName = parts[1] ?? "unknown";
|
|
236
|
+
const toolAction = parts[2] ?? "unknown";
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
type: "change",
|
|
240
|
+
title: `${serverName}: ${toolAction}`,
|
|
241
|
+
narrative: `MCP tool ${toolName} called.\nResponse: ${truncate(response, 300)}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- Helper functions ---
|
|
246
|
+
|
|
247
|
+
function detectError(response: string): boolean {
|
|
248
|
+
const lower = response.toLowerCase();
|
|
249
|
+
return (
|
|
250
|
+
lower.includes("error:") ||
|
|
251
|
+
lower.includes("error[") ||
|
|
252
|
+
lower.includes("failed") ||
|
|
253
|
+
lower.includes("exception") ||
|
|
254
|
+
lower.includes("traceback") ||
|
|
255
|
+
lower.includes("panic:") ||
|
|
256
|
+
lower.includes("fatal:") ||
|
|
257
|
+
/exit code [1-9]/.test(lower)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function detectTestRun(command: string): boolean {
|
|
262
|
+
return (
|
|
263
|
+
/\b(test|spec|jest|vitest|mocha|pytest|cargo\s+test|go\s+test|bun\s+test)\b/i.test(command)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function extractTestResult(
|
|
268
|
+
command: string,
|
|
269
|
+
response: string
|
|
270
|
+
): ExtractedObservation | null {
|
|
271
|
+
// Match "N fail" where N > 0, or standalone failure keywords
|
|
272
|
+
const hasFailure =
|
|
273
|
+
/[1-9]\d*\s+(fail|failed|failures?)\b/i.test(response) ||
|
|
274
|
+
/\bFAILED\b/.test(response) ||
|
|
275
|
+
/\berror\b/i.test(response);
|
|
276
|
+
const hasPass =
|
|
277
|
+
/\d+\s+(pass|passed|ok)\b/i.test(response) ||
|
|
278
|
+
/\bPASS\b/.test(response);
|
|
279
|
+
|
|
280
|
+
if (hasFailure) {
|
|
281
|
+
return {
|
|
282
|
+
type: "bugfix",
|
|
283
|
+
title: `Test failure: ${summariseCommand(command)}`,
|
|
284
|
+
narrative: `Test run failed.\nCommand: ${truncate(command, 200)}\nOutput: ${truncate(response, 500)}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (hasPass && !hasFailure) {
|
|
289
|
+
// All-pass test runs are low signal unless coming after a failure
|
|
290
|
+
// For now, skip — Phase 2 enhancement: track error→fix sequences
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function buildEditNarrative(
|
|
298
|
+
oldStr: string | undefined,
|
|
299
|
+
newStr: string | undefined,
|
|
300
|
+
filePath: string
|
|
301
|
+
): string {
|
|
302
|
+
const parts = [`File: ${filePath}`];
|
|
303
|
+
|
|
304
|
+
if (oldStr && newStr) {
|
|
305
|
+
const oldLines = oldStr.split("\n").length;
|
|
306
|
+
const newLines = newStr.split("\n").length;
|
|
307
|
+
if (oldLines !== newLines) {
|
|
308
|
+
parts.push(`Lines: ${oldLines} → ${newLines}`);
|
|
309
|
+
}
|
|
310
|
+
// Include a brief diff summary
|
|
311
|
+
parts.push(`Replaced: ${truncate(oldStr, 100)}`);
|
|
312
|
+
parts.push(`With: ${truncate(newStr, 100)}`);
|
|
313
|
+
} else if (newStr) {
|
|
314
|
+
parts.push(`Added: ${truncate(newStr, 150)}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return parts.join("\n");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function summariseCommand(command: string): string {
|
|
321
|
+
// Take the first meaningful part of the command
|
|
322
|
+
const trimmed = command.trim();
|
|
323
|
+
const firstLine = trimmed.split("\n")[0] ?? trimmed;
|
|
324
|
+
return truncate(firstLine, 80);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function truncate(text: string, maxLen: number): string {
|
|
328
|
+
if (text.length <= maxLen) return text;
|
|
329
|
+
return text.slice(0, maxLen - 3) + "...";
|
|
330
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
scoreQuality,
|
|
4
|
+
meetsQualityThreshold,
|
|
5
|
+
QUALITY_THRESHOLD,
|
|
6
|
+
type QualityInput,
|
|
7
|
+
} from "./quality.js";
|
|
8
|
+
|
|
9
|
+
describe("scoreQuality", () => {
|
|
10
|
+
test("bugfix type scores 0.3", () => {
|
|
11
|
+
const score = scoreQuality({ type: "bugfix", title: "fix" });
|
|
12
|
+
expect(score).toBeGreaterThanOrEqual(0.3);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("decision type scores 0.3", () => {
|
|
16
|
+
const score = scoreQuality({ type: "decision", title: "choose db" });
|
|
17
|
+
expect(score).toBeGreaterThanOrEqual(0.3);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("change type scores lowest (0.05)", () => {
|
|
21
|
+
const score = scoreQuality({ type: "change", title: "update" });
|
|
22
|
+
expect(score).toBe(0.05);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("unknown type scores 0", () => {
|
|
26
|
+
const score = scoreQuality({ type: "unknown", title: "test" });
|
|
27
|
+
expect(score).toBe(0.0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("narrative longer than 50 chars adds 0.15", () => {
|
|
31
|
+
const withNarrative = scoreQuality({
|
|
32
|
+
type: "change",
|
|
33
|
+
title: "test",
|
|
34
|
+
narrative: "a".repeat(51),
|
|
35
|
+
});
|
|
36
|
+
const without = scoreQuality({ type: "change", title: "test" });
|
|
37
|
+
expect(withNarrative - without).toBeCloseTo(0.15, 5);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("short narrative adds nothing", () => {
|
|
41
|
+
const withShort = scoreQuality({
|
|
42
|
+
type: "change",
|
|
43
|
+
title: "test",
|
|
44
|
+
narrative: "short",
|
|
45
|
+
});
|
|
46
|
+
const without = scoreQuality({ type: "change", title: "test" });
|
|
47
|
+
expect(withShort).toBe(without);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("2+ facts add 0.15", () => {
|
|
51
|
+
const score = scoreQuality({
|
|
52
|
+
type: "change",
|
|
53
|
+
title: "test",
|
|
54
|
+
facts: JSON.stringify(["fact one", "fact two"]),
|
|
55
|
+
});
|
|
56
|
+
expect(score).toBe(0.05 + 0.15);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("1 fact adds 0.05", () => {
|
|
60
|
+
const score = scoreQuality({
|
|
61
|
+
type: "change",
|
|
62
|
+
title: "test",
|
|
63
|
+
facts: JSON.stringify(["one fact"]),
|
|
64
|
+
});
|
|
65
|
+
expect(score).toBe(0.05 + 0.05);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("concepts add 0.1", () => {
|
|
69
|
+
const score = scoreQuality({
|
|
70
|
+
type: "change",
|
|
71
|
+
title: "test",
|
|
72
|
+
concepts: JSON.stringify(["auth"]),
|
|
73
|
+
});
|
|
74
|
+
expect(score).toBe(0.05 + 0.1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("3+ files modified adds 0.2", () => {
|
|
78
|
+
const score = scoreQuality({
|
|
79
|
+
type: "change",
|
|
80
|
+
title: "test",
|
|
81
|
+
filesModified: ["a.ts", "b.ts", "c.ts"],
|
|
82
|
+
});
|
|
83
|
+
expect(score).toBe(0.05 + 0.2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("1-2 files modified adds 0.1", () => {
|
|
87
|
+
const score = scoreQuality({
|
|
88
|
+
type: "change",
|
|
89
|
+
title: "test",
|
|
90
|
+
filesModified: ["a.ts"],
|
|
91
|
+
});
|
|
92
|
+
expect(score).toBe(0.05 + 0.1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("duplicate penalty subtracts 0.3", () => {
|
|
96
|
+
const normal = scoreQuality({ type: "bugfix", title: "fix" });
|
|
97
|
+
const dupe = scoreQuality({
|
|
98
|
+
type: "bugfix",
|
|
99
|
+
title: "fix",
|
|
100
|
+
isDuplicate: true,
|
|
101
|
+
});
|
|
102
|
+
expect(normal - dupe).toBeCloseTo(0.3, 5);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("score clamped to 0.0 minimum", () => {
|
|
106
|
+
const score = scoreQuality({
|
|
107
|
+
type: "unknown",
|
|
108
|
+
title: "x",
|
|
109
|
+
isDuplicate: true,
|
|
110
|
+
});
|
|
111
|
+
expect(score).toBe(0.0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("score clamped to 1.0 maximum", () => {
|
|
115
|
+
const score = scoreQuality({
|
|
116
|
+
type: "bugfix",
|
|
117
|
+
title: "big fix",
|
|
118
|
+
narrative: "a".repeat(100),
|
|
119
|
+
facts: JSON.stringify(["a", "b", "c"]),
|
|
120
|
+
concepts: JSON.stringify(["x", "y"]),
|
|
121
|
+
filesModified: ["a.ts", "b.ts", "c.ts"],
|
|
122
|
+
});
|
|
123
|
+
expect(score).toBeLessThanOrEqual(1.0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("rich bugfix observation scores high", () => {
|
|
127
|
+
const score = scoreQuality({
|
|
128
|
+
type: "bugfix",
|
|
129
|
+
title: "Fix OAuth token refresh race condition",
|
|
130
|
+
narrative:
|
|
131
|
+
"The OAuth token was being refreshed by multiple concurrent requests, causing 401 errors. Added a mutex lock around the refresh logic.",
|
|
132
|
+
facts: JSON.stringify([
|
|
133
|
+
"Race condition in token refresh",
|
|
134
|
+
"Multiple concurrent requests triggered simultaneous refreshes",
|
|
135
|
+
"Fixed with mutex lock",
|
|
136
|
+
]),
|
|
137
|
+
concepts: JSON.stringify(["oauth", "concurrency", "race-condition"]),
|
|
138
|
+
filesModified: ["src/auth/oauth.ts", "src/auth/mutex.ts"],
|
|
139
|
+
});
|
|
140
|
+
expect(score).toBeGreaterThanOrEqual(0.7);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("meetsQualityThreshold", () => {
|
|
145
|
+
test("threshold is 0.1", () => {
|
|
146
|
+
expect(QUALITY_THRESHOLD).toBe(0.1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("bugfix meets threshold", () => {
|
|
150
|
+
expect(meetsQualityThreshold({ type: "bugfix", title: "fix" })).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("unknown type with duplicate does not meet threshold", () => {
|
|
154
|
+
expect(
|
|
155
|
+
meetsQualityThreshold({
|
|
156
|
+
type: "unknown",
|
|
157
|
+
title: "x",
|
|
158
|
+
isDuplicate: true,
|
|
159
|
+
})
|
|
160
|
+
).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("bare change type does not meet threshold (0.05 < 0.1)", () => {
|
|
164
|
+
expect(
|
|
165
|
+
meetsQualityThreshold({ type: "change", title: "minor edit" })
|
|
166
|
+
).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation quality scoring (0.0 — 1.0).
|
|
3
|
+
*
|
|
4
|
+
* Phase 1: Scoring based on available fields (type, content richness, files).
|
|
5
|
+
* Phase 2: Extended with hook context (error→fix sequences, test results, etc.)
|
|
6
|
+
*
|
|
7
|
+
* Observations scoring below QUALITY_THRESHOLD are not saved.
|
|
8
|
+
* See SPEC §2 for the scoring table.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const QUALITY_THRESHOLD = 0.1;
|
|
12
|
+
|
|
13
|
+
export interface QualityInput {
|
|
14
|
+
type: string;
|
|
15
|
+
title: string;
|
|
16
|
+
narrative?: string | null;
|
|
17
|
+
facts?: string | null;
|
|
18
|
+
concepts?: string | null;
|
|
19
|
+
filesRead?: string[] | null;
|
|
20
|
+
filesModified?: string[] | null;
|
|
21
|
+
isDuplicate?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Score an observation's quality based on available signals.
|
|
26
|
+
* Returns a value between 0.0 and 1.0.
|
|
27
|
+
*/
|
|
28
|
+
export function scoreQuality(input: QualityInput): number {
|
|
29
|
+
let score = 0.0;
|
|
30
|
+
|
|
31
|
+
// Type-based scoring
|
|
32
|
+
switch (input.type) {
|
|
33
|
+
case "bugfix":
|
|
34
|
+
score += 0.3;
|
|
35
|
+
break;
|
|
36
|
+
case "decision":
|
|
37
|
+
score += 0.3;
|
|
38
|
+
break;
|
|
39
|
+
case "discovery":
|
|
40
|
+
score += 0.2;
|
|
41
|
+
break;
|
|
42
|
+
case "pattern":
|
|
43
|
+
score += 0.2;
|
|
44
|
+
break;
|
|
45
|
+
case "feature":
|
|
46
|
+
score += 0.15;
|
|
47
|
+
break;
|
|
48
|
+
case "refactor":
|
|
49
|
+
score += 0.15;
|
|
50
|
+
break;
|
|
51
|
+
case "change":
|
|
52
|
+
score += 0.05;
|
|
53
|
+
break;
|
|
54
|
+
case "digest":
|
|
55
|
+
// Digests inherit quality from source observations, not scored here
|
|
56
|
+
score += 0.3;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Content richness signals
|
|
61
|
+
if (input.narrative && input.narrative.length > 50) {
|
|
62
|
+
score += 0.15;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (input.facts) {
|
|
66
|
+
try {
|
|
67
|
+
const factsArray = JSON.parse(input.facts) as unknown[];
|
|
68
|
+
if (factsArray.length >= 2) score += 0.15;
|
|
69
|
+
else if (factsArray.length === 1) score += 0.05;
|
|
70
|
+
} catch {
|
|
71
|
+
// facts is a string, not JSON array — still has some value
|
|
72
|
+
if (input.facts.length > 20) score += 0.05;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (input.concepts) {
|
|
77
|
+
try {
|
|
78
|
+
const conceptsArray = JSON.parse(input.concepts) as unknown[];
|
|
79
|
+
if (conceptsArray.length >= 1) score += 0.1;
|
|
80
|
+
} catch {
|
|
81
|
+
if (input.concepts.length > 10) score += 0.05;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Files modified indicates non-trivial work
|
|
86
|
+
const modifiedCount = input.filesModified?.length ?? 0;
|
|
87
|
+
if (modifiedCount >= 3) score += 0.2;
|
|
88
|
+
else if (modifiedCount >= 1) score += 0.1;
|
|
89
|
+
|
|
90
|
+
// Deduplication penalty
|
|
91
|
+
if (input.isDuplicate) {
|
|
92
|
+
score -= 0.3;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Clamp to [0.0, 1.0]
|
|
96
|
+
return Math.max(0.0, Math.min(1.0, score));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check if an observation meets the minimum quality threshold.
|
|
101
|
+
*/
|
|
102
|
+
export function meetsQualityThreshold(input: QualityInput): boolean {
|
|
103
|
+
return scoreQuality(input) >= QUALITY_THRESHOLD;
|
|
104
|
+
}
|