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.
Files changed (82) hide show
  1. package/.mcp.json +9 -0
  2. package/AUTH-DESIGN.md +436 -0
  3. package/BRIEF.md +197 -0
  4. package/CLAUDE.md +44 -0
  5. package/COMPETITIVE.md +174 -0
  6. package/CONTEXT-OPTIMIZATION.md +305 -0
  7. package/INFRASTRUCTURE.md +252 -0
  8. package/LICENSE +105 -0
  9. package/MARKET.md +230 -0
  10. package/PLAN.md +278 -0
  11. package/README.md +121 -0
  12. package/SENTINEL.md +293 -0
  13. package/SERVER-API-PLAN.md +553 -0
  14. package/SPEC.md +843 -0
  15. package/SWOT.md +148 -0
  16. package/SYNC-ARCHITECTURE.md +294 -0
  17. package/VIBE-CODER-STRATEGY.md +250 -0
  18. package/bun.lock +375 -0
  19. package/hooks/post-tool-use.ts +144 -0
  20. package/hooks/session-start.ts +64 -0
  21. package/hooks/stop.ts +131 -0
  22. package/mem-page.html +1305 -0
  23. package/package.json +30 -0
  24. package/src/capture/dedup.test.ts +103 -0
  25. package/src/capture/dedup.ts +76 -0
  26. package/src/capture/extractor.test.ts +245 -0
  27. package/src/capture/extractor.ts +330 -0
  28. package/src/capture/quality.test.ts +168 -0
  29. package/src/capture/quality.ts +104 -0
  30. package/src/capture/retrospective.test.ts +115 -0
  31. package/src/capture/retrospective.ts +121 -0
  32. package/src/capture/scanner.test.ts +131 -0
  33. package/src/capture/scanner.ts +100 -0
  34. package/src/capture/scrubber.test.ts +144 -0
  35. package/src/capture/scrubber.ts +181 -0
  36. package/src/cli.ts +517 -0
  37. package/src/config.ts +238 -0
  38. package/src/context/inject.test.ts +940 -0
  39. package/src/context/inject.ts +382 -0
  40. package/src/embeddings/backfill.ts +50 -0
  41. package/src/embeddings/embedder.test.ts +76 -0
  42. package/src/embeddings/embedder.ts +139 -0
  43. package/src/lifecycle/aging.test.ts +103 -0
  44. package/src/lifecycle/aging.ts +36 -0
  45. package/src/lifecycle/compaction.test.ts +264 -0
  46. package/src/lifecycle/compaction.ts +190 -0
  47. package/src/lifecycle/purge.test.ts +100 -0
  48. package/src/lifecycle/purge.ts +37 -0
  49. package/src/lifecycle/scheduler.test.ts +120 -0
  50. package/src/lifecycle/scheduler.ts +101 -0
  51. package/src/provisioning/browser-auth.ts +172 -0
  52. package/src/provisioning/provision.test.ts +198 -0
  53. package/src/provisioning/provision.ts +94 -0
  54. package/src/register.test.ts +167 -0
  55. package/src/register.ts +178 -0
  56. package/src/server.ts +436 -0
  57. package/src/storage/migrations.test.ts +244 -0
  58. package/src/storage/migrations.ts +261 -0
  59. package/src/storage/outbox.test.ts +229 -0
  60. package/src/storage/outbox.ts +131 -0
  61. package/src/storage/projects.test.ts +137 -0
  62. package/src/storage/projects.ts +184 -0
  63. package/src/storage/sqlite.test.ts +798 -0
  64. package/src/storage/sqlite.ts +934 -0
  65. package/src/storage/vec.test.ts +198 -0
  66. package/src/sync/auth.test.ts +76 -0
  67. package/src/sync/auth.ts +68 -0
  68. package/src/sync/client.ts +183 -0
  69. package/src/sync/engine.test.ts +94 -0
  70. package/src/sync/engine.ts +127 -0
  71. package/src/sync/pull.test.ts +279 -0
  72. package/src/sync/pull.ts +170 -0
  73. package/src/sync/push.test.ts +117 -0
  74. package/src/sync/push.ts +230 -0
  75. package/src/tools/get.ts +34 -0
  76. package/src/tools/pin.ts +47 -0
  77. package/src/tools/save.test.ts +301 -0
  78. package/src/tools/save.ts +231 -0
  79. package/src/tools/search.test.ts +69 -0
  80. package/src/tools/search.ts +181 -0
  81. package/src/tools/timeline.ts +64 -0
  82. 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
+ }