pi-mega-compact 0.4.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/LICENSE +24 -0
- package/README.md +375 -0
- package/extensions/DASHBOARD.md +160 -0
- package/extensions/dashboard-server.test.ts +124 -0
- package/extensions/dashboard-server.ts +459 -0
- package/extensions/error-patterns.ts +175 -0
- package/extensions/mega-compact.test.ts +351 -0
- package/extensions/mega-compact.ts +846 -0
- package/extensions/openclaw-mega-compact.ts +370 -0
- package/package.json +61 -0
- package/src/adapt.ts +120 -0
- package/src/boundary.test.ts +61 -0
- package/src/boundary.ts +94 -0
- package/src/canary.ts +126 -0
- package/src/compact.test.ts +99 -0
- package/src/compact.ts +262 -0
- package/src/config/dedup.ts +120 -0
- package/src/config.ts +15 -0
- package/src/dedup/dedup.test.ts +46 -0
- package/src/dedup/digest.ts +40 -0
- package/src/dedup/l1-lsh.ts +67 -0
- package/src/dedup/l1-minhash.ts +90 -0
- package/src/dedup/l1-verify.ts +55 -0
- package/src/dedup/l1.test.ts +57 -0
- package/src/dedup/mmr.ts +54 -0
- package/src/dedup/normalize.ts +41 -0
- package/src/dedup/raptor/guardrails.ts +112 -0
- package/src/dedup/raptor/index.ts +118 -0
- package/src/dedup/raptor/kmeans.ts +156 -0
- package/src/dedup/raptor/raptor.test.ts +238 -0
- package/src/dedup/raptor/retrieval.ts +102 -0
- package/src/dedup/raptor/summarizer.ts +91 -0
- package/src/dedup/raptor/tree.ts +254 -0
- package/src/dedup/sprint12.test.ts +242 -0
- package/src/dedup/topk.ts +61 -0
- package/src/dedup-engine.test.ts +609 -0
- package/src/e2e.test.ts +843 -0
- package/src/embedder.ts +111 -0
- package/src/engine.test.ts +123 -0
- package/src/engine.ts +192 -0
- package/src/extractive.test.ts +156 -0
- package/src/extractive.ts +265 -0
- package/src/httpEmbedder.ts +154 -0
- package/src/log.test.ts +47 -0
- package/src/log.ts +60 -0
- package/src/monitoring.ts +171 -0
- package/src/ratio.bench.test.ts +1316 -0
- package/src/recall.integration.test.ts +96 -0
- package/src/recall.test.ts +59 -0
- package/src/recall.ts +100 -0
- package/src/sprint14.test.ts +245 -0
- package/src/store/backfill.ts +263 -0
- package/src/store/bloom.ts +122 -0
- package/src/store/compression.test.ts +83 -0
- package/src/store/compression.ts +203 -0
- package/src/store/integrity.ts +65 -0
- package/src/store/migrate.test.ts +158 -0
- package/src/store/migrate.ts +108 -0
- package/src/store/sprint10.test.ts +182 -0
- package/src/store/sqlite.ts +519 -0
- package/src/store.test.ts +169 -0
- package/src/store.ts +192 -0
- package/src/supersede.test.ts +42 -0
- package/src/supersede.ts +67 -0
- package/src/tokens.ts +35 -0
- package/src/types.test.ts +10 -0
- package/src/types.ts +49 -0
- package/src/vectorStore.test.ts +480 -0
- package/src/vectorStore.ts +544 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extractive.ts — deterministic, LLM-free extractive summary engine.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the "Key timeline" dump in compact.ts with structured extraction:
|
|
5
|
+
* topicSummary (one paragraph), keyDecisions, nextSteps, filesModified.
|
|
6
|
+
*
|
|
7
|
+
* Target compression: 70K tokens → ~2K tokens (35:1).
|
|
8
|
+
* Deterministic: same messages → same output, every time.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { EngineMessage } from "./types.js";
|
|
12
|
+
import { estimateBlockTokens } from "./tokens.js";
|
|
13
|
+
|
|
14
|
+
// ---- Limits ----------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const MAX_RECENT_USER = 3;
|
|
17
|
+
const MAX_DECISIONS = 5;
|
|
18
|
+
const MAX_FILES = 10;
|
|
19
|
+
const MAX_PENDING = 5;
|
|
20
|
+
const MAX_TOPIC_LINES = 12;
|
|
21
|
+
|
|
22
|
+
// ---- Truncation helper -----------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function truncate(s: string, maxLen: number): string {
|
|
25
|
+
if (s.length <= maxLen) return s;
|
|
26
|
+
return s.slice(0, maxLen - 1) + "…";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---- Turn brief (conversation arc) -----------------------------------------
|
|
30
|
+
|
|
31
|
+
export interface TurnBrief {
|
|
32
|
+
role: string;
|
|
33
|
+
action: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---- Full extracted summary ------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export interface ExtractiveSummary {
|
|
39
|
+
topicSummary: string;
|
|
40
|
+
keyDecisions: string[];
|
|
41
|
+
nextSteps: string[];
|
|
42
|
+
filesModified: string[];
|
|
43
|
+
tokenEstimate: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a one-paragraph topic summary from the message slice.
|
|
48
|
+
*
|
|
49
|
+
* This is the compressed replacement for the raw "Key timeline" loop.
|
|
50
|
+
* Captures: tools used, recent user requests, current work, key files,
|
|
51
|
+
* pending work. Typically 12 lines / ~500 tokens instead of ~70K.
|
|
52
|
+
*/
|
|
53
|
+
function buildTopicSummary(
|
|
54
|
+
messages: EngineMessage[],
|
|
55
|
+
tools: string[],
|
|
56
|
+
recentUser: string[],
|
|
57
|
+
currentWork: string | undefined,
|
|
58
|
+
keyFiles: string[],
|
|
59
|
+
pending: string[],
|
|
60
|
+
): string {
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
|
|
63
|
+
// Scope line
|
|
64
|
+
const users = messages.filter((m) => m.role === "user");
|
|
65
|
+
const assistants = messages.filter((m) => m.role === "assistant");
|
|
66
|
+
const toolMsgs = messages.filter((m) => m.role === "tool");
|
|
67
|
+
lines.push(
|
|
68
|
+
`Conversation: ${messages.length} messages (${users.length} user, ` +
|
|
69
|
+
`${assistants.length} assistant, ${toolMsgs.length} tool). ` +
|
|
70
|
+
(tools.length ? `Tools: ${tools.join(", ")}.` : "No tools used."),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Recent user requests
|
|
74
|
+
if (recentUser.length) {
|
|
75
|
+
lines.push("User requests:");
|
|
76
|
+
for (const r of recentUser) lines.push(` • ${r}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Current work
|
|
80
|
+
if (currentWork) lines.push(`Current work: ${currentWork}`);
|
|
81
|
+
|
|
82
|
+
// Key files
|
|
83
|
+
if (keyFiles.length) lines.push(`Key files: ${keyFiles.join(", ")}.`);
|
|
84
|
+
|
|
85
|
+
// Pending work
|
|
86
|
+
if (pending.length) {
|
|
87
|
+
lines.push("Pending work:");
|
|
88
|
+
for (const p of pending) lines.push(` • ${p}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Cap total length
|
|
92
|
+
return lines.slice(0, MAX_TOPIC_LINES).join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- File path extraction --------------------------------------------------
|
|
96
|
+
|
|
97
|
+
const INTERESTING_EXT = new Set(["rs", "ts", "tsx", "js", "json", "md"]);
|
|
98
|
+
const FILE_PATH_RE = /(?:^|\s)([^\s"`']+\.(rs|ts|tsx|js|json|md|py|sh|sql|toml|yaml|yml|css|html))\b/g;
|
|
99
|
+
|
|
100
|
+
function extractFilePaths(text: string): string[] {
|
|
101
|
+
const paths: string[] = [];
|
|
102
|
+
for (const m of text.matchAll(FILE_PATH_RE)) {
|
|
103
|
+
const filePath = m[1];
|
|
104
|
+
const ext = m[2];
|
|
105
|
+
const basename = filePath.split("/").pop() ?? filePath;
|
|
106
|
+
if (basename === "node_modules" || filePath.includes("node_modules/")) continue;
|
|
107
|
+
if (INTERESTING_EXT.has(ext)) paths.push(filePath);
|
|
108
|
+
}
|
|
109
|
+
return paths;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- Recent user requests (existing logic, kept) ---------------------------
|
|
113
|
+
|
|
114
|
+
function collectRecentUserRequests(
|
|
115
|
+
messages: EngineMessage[],
|
|
116
|
+
limit: number,
|
|
117
|
+
): string[] {
|
|
118
|
+
const requests: string[] = [];
|
|
119
|
+
for (let i = messages.length - 1; i >= 0 && requests.length < limit; i--) {
|
|
120
|
+
if (messages[i].role === "user") {
|
|
121
|
+
let snippet = messages[i].text.split("\n").slice(0, 3).join(" ");
|
|
122
|
+
snippet = snippet.replace(/^.+\nProcessed\$?\s*/i, "").replace(/\n/g, " ");
|
|
123
|
+
requests.push(truncate(snippet, 200));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return requests.reverse();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- Pending work (existing logic, kept) -----------------------------------
|
|
130
|
+
|
|
131
|
+
const PENDING_WORDS = ["todo", "next", "pending", "follow up", "remaining"];
|
|
132
|
+
|
|
133
|
+
function inferPendingWork(messages: EngineMessage[]): string[] {
|
|
134
|
+
const pending: string[] = [];
|
|
135
|
+
const recent = messages.slice(-5);
|
|
136
|
+
for (const m of recent) {
|
|
137
|
+
const t = m.text.toLowerCase();
|
|
138
|
+
if (PENDING_WORDS.some((w) => t.includes(w))) {
|
|
139
|
+
const snippet = m.text.split("\n").find((l) => PENDING_WORDS.some((w) => l.toLowerCase().includes(w)));
|
|
140
|
+
if (snippet) pending.push(truncate(snippet.trim(), 180));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return [...new Set(pending)].slice(0, MAX_PENDING);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- Current work (existing logic, kept) -----------------------------------
|
|
147
|
+
|
|
148
|
+
function inferCurrentWork(messages: EngineMessage[]): string | undefined {
|
|
149
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
150
|
+
const m = messages[i];
|
|
151
|
+
if (m.role !== "assistant") continue;
|
|
152
|
+
const path = m.text.match(
|
|
153
|
+
/(?:^|\s)([^\s"`':]+\.(rs|ts|tsx|js|json|md|py|toml|yaml|yml|sql))\b/m,
|
|
154
|
+
);
|
|
155
|
+
if (path) {
|
|
156
|
+
const line = m.text.split("\n").slice(0, 2).join(" ");
|
|
157
|
+
return truncate(line, 200);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- Key decisions ---------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
const DECISION_PATTERNS = [
|
|
166
|
+
/(?:I('ll| will| decided to| chose to| recommend| suggest))\s+(.{10,120})/i,
|
|
167
|
+
/(?:let's|we('ll| should| can| will))\s+(.{10,120})/i,
|
|
168
|
+
/(?:the (?:plan|approach|decision|strategy) is (?:to )?)\s*(.{10,120})/i,
|
|
169
|
+
/(?:going (?:with|forward))\s+(.{10,120})/i,
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
function extractDecisions(messages: EngineMessage[]): string[] {
|
|
173
|
+
const decisions: string[] = [];
|
|
174
|
+
// Only look at assistant messages (they make/receive decisions)
|
|
175
|
+
for (const m of messages) {
|
|
176
|
+
if (m.role !== "assistant") continue;
|
|
177
|
+
const text = m.text;
|
|
178
|
+
if (!text || text.length < 20) continue;
|
|
179
|
+
for (const pat of DECISION_PATTERNS) {
|
|
180
|
+
const match = text.match(pat);
|
|
181
|
+
if (match) {
|
|
182
|
+
const decision = match[2]?.trim();
|
|
183
|
+
if (decision && decision.length > 10) {
|
|
184
|
+
decisions.push(truncate(decision, 150));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (decisions.length >= MAX_DECISIONS) break;
|
|
189
|
+
}
|
|
190
|
+
return [...new Set(decisions)];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---- Files modified --------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
function extractFilesModified(tools: EngineMessage[]): string[] {
|
|
196
|
+
const files = new Set<string>();
|
|
197
|
+
for (const m of tools) {
|
|
198
|
+
if (!m.toolName) continue;
|
|
199
|
+
const name = m.toolName.toLowerCase();
|
|
200
|
+
if (name === "write" || name === "edit" || name === "notebookedit") {
|
|
201
|
+
// Extract file path from input payload
|
|
202
|
+
const input = m.input ?? m.text;
|
|
203
|
+
const pathMatch = input.match(/["']?(\/[^\s"']+\.\w+)["']?/);
|
|
204
|
+
if (pathMatch) files.add(pathMatch[1]);
|
|
205
|
+
}
|
|
206
|
+
if (name === "bash") {
|
|
207
|
+
const cmd = m.input ?? m.text;
|
|
208
|
+
if (cmd.includes("git add") || cmd.includes("git commit") || cmd.includes("git diff")) {
|
|
209
|
+
for (const p of extractFilePaths(cmd)) files.add(p);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return [...files].slice(0, MAX_FILES);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- Public API ------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Deterministic extractive summary. Same messages → same output, every time.
|
|
220
|
+
*
|
|
221
|
+
* Returns structured data + a pre-formatted topicSummary string.
|
|
222
|
+
* Compression target: 70K tokens → ~2K tokens.
|
|
223
|
+
*/
|
|
224
|
+
export function extractiveSummarize(messages: EngineMessage[]): ExtractiveSummary {
|
|
225
|
+
if (messages.length === 0) {
|
|
226
|
+
return { topicSummary: "(empty)", keyDecisions: [], nextSteps: [], filesModified: [], tokenEstimate: 0 };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const toolMsgs = messages.filter((m) => m.role === "tool");
|
|
230
|
+
const tools = [...new Set(messages.flatMap((m) => (m.toolName ? [m.toolName] : [])))].sort();
|
|
231
|
+
|
|
232
|
+
const recentUser = collectRecentUserRequests(messages, MAX_RECENT_USER);
|
|
233
|
+
const currentWork = inferCurrentWork(messages);
|
|
234
|
+
const keyFiles = collectKeyFiles(messages);
|
|
235
|
+
const pending = inferPendingWork(messages);
|
|
236
|
+
const keyDecisions = extractDecisions(messages);
|
|
237
|
+
const filesModified = extractFilesModified(toolMsgs);
|
|
238
|
+
|
|
239
|
+
const topicSummary = buildTopicSummary(
|
|
240
|
+
messages, tools, recentUser, currentWork, keyFiles, pending,
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const tokenEstimate = estimateBlockTokens(topicSummary);
|
|
244
|
+
|
|
245
|
+
return { topicSummary, keyDecisions, nextSteps: pending, filesModified, tokenEstimate };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---- Key files (existing logic from compact.ts, moved here) ----------------
|
|
249
|
+
|
|
250
|
+
const MAX_KEY_FILES = 5;
|
|
251
|
+
const FRESHNESS_WINDOW = 10;
|
|
252
|
+
|
|
253
|
+
function collectKeyFiles(messages: EngineMessage[]): string[] {
|
|
254
|
+
const recent = messages.slice(-FRESHNESS_WINDOW);
|
|
255
|
+
const pathFreq = new Map<string, number>();
|
|
256
|
+
for (const m of recent) {
|
|
257
|
+
for (const p of extractFilePaths(m.text)) {
|
|
258
|
+
pathFreq.set(p, (pathFreq.get(p) ?? 0) + 1);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return [...pathFreq.entries()]
|
|
262
|
+
.sort((a, b) => b[1] - a[1])
|
|
263
|
+
.slice(0, MAX_KEY_FILES)
|
|
264
|
+
.map(([p]) => p);
|
|
265
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* httpEmbedder.ts — pluggable LOCALHOST embeddings client (Sprint 12, BYO).
|
|
3
|
+
*
|
|
4
|
+
* Lets the user bring their own embedding backend WITHOUT this extension
|
|
5
|
+
* shipping a model, a native dependency, or a remote call. The backend is a
|
|
6
|
+
* localhost HTTP server the user runs themselves (local ONNX/TEI/llamafile/
|
|
7
|
+
* Ollama-embeddings/…) and points us at via MEGACOMPACT_EMBEDDING_URL.
|
|
8
|
+
*
|
|
9
|
+
* This honors PREVENT-PI-004 (critical: local-only, zero remote network): the
|
|
10
|
+
* only allowed network is a user-spawned localhost endpoint, in the same
|
|
11
|
+
* exception class as the optional /dashboard UI server. It is NOT a remote
|
|
12
|
+
* provider call — compacted conversation content never leaves the machine.
|
|
13
|
+
*
|
|
14
|
+
* The endpoint contract (OpenAI-style, tolerant parser):
|
|
15
|
+
* request: POST { url } body { "input": ["<text>"] }
|
|
16
|
+
* response: { "data": [ { "embedding": [0.1, …] } ] } (also accepts
|
|
17
|
+
* { "embeddings": [...] } and { "data": [[...]] })
|
|
18
|
+
*
|
|
19
|
+
* VectorStore is deliberately synchronous, so embed() runs the network call in
|
|
20
|
+
* a short-lived child process (its own event loop) and blocks the parent with
|
|
21
|
+
* spawnSync. We deliberately do NOT use Atomics.wait on the main thread — that
|
|
22
|
+
* would deadlock fetch (the blocked main thread can't pump the socket, so the
|
|
23
|
+
* promise never settles). A child process has its own event loop, so spawnSync
|
|
24
|
+
* blocks without that deadlock. Only used when this embedder is selected; the
|
|
25
|
+
* default TrigramEmbedder path stays pure-sync, zero-network, zero-native.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { Embedder, Vector } from "./embedder.js";
|
|
29
|
+
import { l2Normalize } from "./embedder.js";
|
|
30
|
+
import { spawnSync } from "node:child_process"; // guardrails-allow PREVENT-PI-004: localhost-only user-spawned embedding server (BYO backend, never remote)
|
|
31
|
+
|
|
32
|
+
export interface HttpEmbedderOptions {
|
|
33
|
+
url: string;
|
|
34
|
+
/** Bearer token, if the local server requires one. */
|
|
35
|
+
apiKey?: string;
|
|
36
|
+
/** Extra request headers as a JSON object (env: MEGACOMPACT_EMBEDDING_HEADERS). */
|
|
37
|
+
headers?: Record<string, string>;
|
|
38
|
+
/** Known embedding dimension, if the server exposes it statically. */
|
|
39
|
+
dim?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read + validate the localhost embeddings config from the environment. */
|
|
43
|
+
export function embeddingConfigFromEnv(): HttpEmbedderOptions | null {
|
|
44
|
+
const url = process.env.MEGACOMPACT_EMBEDDING_URL;
|
|
45
|
+
if (!url) return null;
|
|
46
|
+
if (!/^https?:\/\/localhost[:/]/.test(url) && !/^https?:\/\/127\.0\.0\.1[:/]/.test(url)) {
|
|
47
|
+
// Only loopback is permitted — a remote host would violate PREVENT-PI-004.
|
|
48
|
+
throw new Error(
|
|
49
|
+
`MEGACOMPACT_EMBEDDING_URL must be a localhost/127.0.0.1 endpoint (got ${url}). ` +
|
|
50
|
+
`Remote embedding endpoints are not allowed (PREVENT-PI-004).`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const headers: Record<string, string> = {};
|
|
54
|
+
if (process.env.MEGACOMPACT_EMBEDDING_HEADERS) {
|
|
55
|
+
try {
|
|
56
|
+
Object.assign(headers, JSON.parse(process.env.MEGACOMPACT_EMBEDDING_HEADERS));
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error("MEGACOMPACT_EMBEDDING_HEADERS must be valid JSON");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const dim = process.env.MEGACOMPACT_EMBEDDING_DIM
|
|
62
|
+
? Number(process.env.MEGACOMPACT_EMBEDDING_DIM)
|
|
63
|
+
: undefined;
|
|
64
|
+
return {
|
|
65
|
+
url,
|
|
66
|
+
apiKey: process.env.MEGACOMPACT_EMBEDDING_KEY,
|
|
67
|
+
headers,
|
|
68
|
+
dim: Number.isFinite(dim) ? dim : undefined,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Extract a single embedding vector from a tolerant OpenAI-style response. */
|
|
73
|
+
function parseEmbedding(body: unknown): number[] {
|
|
74
|
+
if (body && typeof body === "object") {
|
|
75
|
+
const b = body as Record<string, unknown>;
|
|
76
|
+
if (Array.isArray(b.data) && b.data[0] && typeof b.data[0] === "object") {
|
|
77
|
+
const first = b.data[0] as Record<string, unknown>;
|
|
78
|
+
if (Array.isArray(first.embedding)) return first.embedding as number[];
|
|
79
|
+
if (Array.isArray(first)) return first as number[];
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(b.embeddings)) return b.embeddings[0] as number[];
|
|
82
|
+
if (Array.isArray(b.data)) return b.data as number[];
|
|
83
|
+
}
|
|
84
|
+
throw new Error("embeddings response missing a recognized vector shape");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Inline worker script: performs the async fetch in a child process that has
|
|
88
|
+
// its own event loop (no main-thread deadlock), writes the JSON response to
|
|
89
|
+
// stdout. Reads request from env to avoid shell-quoting the body.
|
|
90
|
+
const WORKER = String.raw`
|
|
91
|
+
const u = process.env.MC_URL, b = process.env.MC_BODY, h = JSON.parse(process.env.MC_HEADERS || "{}");
|
|
92
|
+
try {
|
|
93
|
+
const r = await fetch(u, { method: "POST", headers: h, body: b }); // guardrails-allow PREVENT-PI-004: localhost-only user-spawned embedding server (BYO backend, never remote)
|
|
94
|
+
const out = JSON.stringify({ status: r.status, ok: r.ok, json: await r.json() });
|
|
95
|
+
process.stdout.write(out);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
process.stdout.write(JSON.stringify({ error: String(e && e.message ? e.message : e) }));
|
|
98
|
+
}
|
|
99
|
+
`;
|
|
100
|
+
|
|
101
|
+
export class HttpEmbedder implements Embedder {
|
|
102
|
+
private readonly url: string;
|
|
103
|
+
private readonly apiKey?: string;
|
|
104
|
+
private readonly headers: Record<string, string>;
|
|
105
|
+
private resolvedDim: number;
|
|
106
|
+
|
|
107
|
+
constructor(opts: HttpEmbedderOptions) {
|
|
108
|
+
this.url = opts.url;
|
|
109
|
+
this.apiKey = opts.apiKey;
|
|
110
|
+
this.headers = opts.headers ?? {};
|
|
111
|
+
this.resolvedDim = opts.dim ?? 0; // resolved after the first embed
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get dim(): number {
|
|
115
|
+
return this.resolvedDim;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
embed(text: string): Vector {
|
|
119
|
+
const body = JSON.stringify({ input: [text] });
|
|
120
|
+
const headers: Record<string, string> = {
|
|
121
|
+
"content-type": "application/json",
|
|
122
|
+
...this.headers,
|
|
123
|
+
};
|
|
124
|
+
if (this.apiKey) headers["authorization"] = `Bearer ${this.apiKey}`;
|
|
125
|
+
|
|
126
|
+
// localhost-only fetch — audited PREVENT-PI-004 exception (user-spawned
|
|
127
|
+
// local embedding server, same class as the /dashboard localhost UI). The
|
|
128
|
+
// child has its own event loop, so spawnSync blocks without deadlocking.
|
|
129
|
+
const res = spawnSync(process.execPath, ["-e", WORKER], { // guardrails-allow PREVENT-PI-004: localhost-only user-spawned embedding server (BYO backend, never remote)
|
|
130
|
+
encoding: "utf8",
|
|
131
|
+
env: {
|
|
132
|
+
...process.env,
|
|
133
|
+
MC_URL: this.url,
|
|
134
|
+
MC_BODY: body,
|
|
135
|
+
MC_HEADERS: JSON.stringify(headers),
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
if (res.error || typeof res.stdout !== "string" || res.stdout.length === 0) {
|
|
139
|
+
const detail = res.error ? String(res.error) : res.stderr || "empty response";
|
|
140
|
+
throw new Error(`embedding server ${this.url} unreachable: ${detail}`);
|
|
141
|
+
}
|
|
142
|
+
let parsed: { status?: number; ok?: boolean; json?: unknown; error?: string };
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(res.stdout);
|
|
145
|
+
} catch {
|
|
146
|
+
throw new Error(`embedding server ${this.url} returned non-JSON: ${res.stdout.slice(0, 200)}`);
|
|
147
|
+
}
|
|
148
|
+
if (parsed.error) throw new Error(`embedding server ${this.url} failed: ${parsed.error}`);
|
|
149
|
+
if (!parsed.ok) throw new Error(`embedding server ${this.url} returned ${parsed.status}`);
|
|
150
|
+
const vec = parseEmbedding(parsed.json);
|
|
151
|
+
if (this.resolvedDim === 0) this.resolvedDim = vec.length;
|
|
152
|
+
return l2Normalize(vec);
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/log.test.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Logger } from "./log.js";
|
|
7
|
+
|
|
8
|
+
const baseTmp = mkdtempSync(join(tmpdir(), "mc-log-"));
|
|
9
|
+
let counter = 0;
|
|
10
|
+
function logPath() {
|
|
11
|
+
return join(baseTmp, `run-${counter++}`, "mega-compact.log");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test("logger appends one JSON line per entry", () => {
|
|
15
|
+
const path = logPath();
|
|
16
|
+
let clock = 1000;
|
|
17
|
+
const log = new Logger({ path, now: () => clock++ });
|
|
18
|
+
log.info("compact", { checkpointId: "chkpt_001" });
|
|
19
|
+
log.warn("recall-empty", { query: "x" });
|
|
20
|
+
const lines = readFileSync(path, "utf8").trim().split("\n");
|
|
21
|
+
assert.equal(lines.length, 2);
|
|
22
|
+
const first = JSON.parse(lines[0]);
|
|
23
|
+
assert.equal(first.level, "info");
|
|
24
|
+
assert.equal(first.event, "compact");
|
|
25
|
+
assert.equal(first.checkpointId, "chkpt_001");
|
|
26
|
+
assert.equal(first.ts, 1000);
|
|
27
|
+
const second = JSON.parse(lines[1]);
|
|
28
|
+
assert.equal(second.level, "warn");
|
|
29
|
+
assert.equal(second.ts, 1001);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("disabled logger writes nothing", () => {
|
|
33
|
+
const path = logPath();
|
|
34
|
+
const log = new Logger({ path, enabled: false });
|
|
35
|
+
log.info("compact", { a: 1 });
|
|
36
|
+
assert.equal(existsSync(path), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("logger never throws on a bad path", () => {
|
|
40
|
+
// A path whose parent cannot be created (null byte) — must be swallowed.
|
|
41
|
+
const log = new Logger({ path: "/\0/nope.log" });
|
|
42
|
+
assert.doesNotThrow(() => log.error("boom", { x: 1 }));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("cleanup", () => {
|
|
46
|
+
rmSync(baseTmp, { recursive: true, force: true });
|
|
47
|
+
});
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* log.ts — tiny append-only structured logger.
|
|
3
|
+
*
|
|
4
|
+
* Writes one JSON object per line to a log file (default:
|
|
5
|
+
* ~/.pi/agent/extensions/mega-compact.log). Best-effort: logging never throws
|
|
6
|
+
* into the extension. Pi-agnostic and dependency-free so it can be unit-tested.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { STATE_DIR_DEFAULT } from "./config.js";
|
|
12
|
+
|
|
13
|
+
export type LogLevel = "info" | "warn" | "error";
|
|
14
|
+
|
|
15
|
+
export interface LogEntry {
|
|
16
|
+
ts: number;
|
|
17
|
+
level: LogLevel;
|
|
18
|
+
event: string;
|
|
19
|
+
[k: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Default log path lives alongside the state dir. */
|
|
23
|
+
export function defaultLogPath(): string {
|
|
24
|
+
return join(STATE_DIR_DEFAULT, "mega-compact.log");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class Logger {
|
|
28
|
+
private readonly path: string;
|
|
29
|
+
private readonly enabled: boolean;
|
|
30
|
+
/** Monotonic clock injected by the caller so the module stays deterministic. */
|
|
31
|
+
private readonly now: () => number;
|
|
32
|
+
|
|
33
|
+
constructor(opts: { path?: string; enabled?: boolean; now?: () => number } = {}) {
|
|
34
|
+
this.path = opts.path ?? defaultLogPath();
|
|
35
|
+
this.enabled = opts.enabled ?? true;
|
|
36
|
+
this.now = opts.now ?? (() => Date.now());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Append one structured line. Swallows all I/O errors. */
|
|
40
|
+
log(level: LogLevel, event: string, fields: Record<string, unknown> = {}): void {
|
|
41
|
+
if (!this.enabled) return;
|
|
42
|
+
const entry: LogEntry = { ts: this.now(), level, event, ...fields };
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
45
|
+
appendFileSync(this.path, `${JSON.stringify(entry)}\n`);
|
|
46
|
+
} catch {
|
|
47
|
+
/* best-effort: never break the extension on a log failure */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
info(event: string, fields?: Record<string, unknown>): void {
|
|
52
|
+
this.log("info", event, fields);
|
|
53
|
+
}
|
|
54
|
+
warn(event: string, fields?: Record<string, unknown>): void {
|
|
55
|
+
this.log("warn", event, fields);
|
|
56
|
+
}
|
|
57
|
+
error(event: string, fields?: Record<string, unknown>): void {
|
|
58
|
+
this.log("error", event, fields);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* monitoring.ts — local dedup monitoring + alerting (Sprint 14, Phase 7).
|
|
3
|
+
*
|
|
4
|
+
* Per-decision structured events go to `events.log` (append-only JSON).
|
|
5
|
+
* Aggregate metrics (hit rate, FP rate, per-tier p95 latency, storage) go to
|
|
6
|
+
* `dashboard.json` — the SAME local-only file the /dashboard UI reads. There is
|
|
7
|
+
* NO Prometheus port and NO network listener (PREVENT-PI-004). Alerting is local
|
|
8
|
+
* only: an FP-rate breach flips the tier to MARK_ONLY and writes a warning.
|
|
9
|
+
*
|
|
10
|
+
* Best-effort: logging/metrics never throw into the add()/search() path.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { STATE_DIR_DEFAULT } from "./config.js";
|
|
16
|
+
import type { DedupConfigShape } from "./config/dedup.js";
|
|
17
|
+
import type { DedupTier } from "./config/dedup.js";
|
|
18
|
+
|
|
19
|
+
export interface DedupDecisionEvent {
|
|
20
|
+
ts: number;
|
|
21
|
+
tier: DedupTier;
|
|
22
|
+
result: "deduped" | "new" | "mark_only";
|
|
23
|
+
reason?: string;
|
|
24
|
+
latencyMs: number;
|
|
25
|
+
/** True when this dedup was later found to be a false positive. */
|
|
26
|
+
falsePositive?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DedupMetrics {
|
|
30
|
+
/** Decisions per tier. */
|
|
31
|
+
decisions: Record<string, number>;
|
|
32
|
+
/** Deduped (collapsed) per tier. */
|
|
33
|
+
deduped: Record<string, number>;
|
|
34
|
+
/** Rolling FP count per tier (within the alert window). */
|
|
35
|
+
falsePositives: Record<string, number>;
|
|
36
|
+
/** Latency samples per tier (for p95). */
|
|
37
|
+
latency: Record<string, number[]>;
|
|
38
|
+
/** Total storage bytes (checkpoint blobs). */
|
|
39
|
+
storageBytes: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const TIERS: DedupTier[] = ["L0", "L1", "L2", "RAPTOR"];
|
|
43
|
+
|
|
44
|
+
function emptyMetrics(): DedupMetrics {
|
|
45
|
+
const dec: Record<string, number> = {};
|
|
46
|
+
const dp: Record<string, number> = {};
|
|
47
|
+
const fp: Record<string, number> = {};
|
|
48
|
+
const lat: Record<string, number[]> = {};
|
|
49
|
+
for (const t of TIERS) { dec[t] = 0; dp[t] = 0; fp[t] = 0; lat[t] = []; }
|
|
50
|
+
return { decisions: dec, deduped: dp, falsePositives: fp, latency: lat, storageBytes: 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Append a structured decision event to events.log (best-effort). */
|
|
54
|
+
export function logDecision(path: string, ev: DedupDecisionEvent): void {
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
57
|
+
appendFileSync(path, `${JSON.stringify(ev)}\n`);
|
|
58
|
+
} catch {
|
|
59
|
+
/* never break the extension on a log failure */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Load metrics from dashboard.json, or return a fresh empty snapshot.
|
|
65
|
+
* Kept simple + synchronous (no network).
|
|
66
|
+
*/
|
|
67
|
+
export function loadMetrics(path: string): DedupMetrics {
|
|
68
|
+
try {
|
|
69
|
+
if (existsSync(path)) {
|
|
70
|
+
const raw = readFileSync(path, "utf-8");
|
|
71
|
+
const parsed = JSON.parse(raw) as Partial<DedupMetrics>;
|
|
72
|
+
const base = emptyMetrics();
|
|
73
|
+
return {
|
|
74
|
+
decisions: { ...base.decisions, ...(parsed.decisions ?? {}) },
|
|
75
|
+
deduped: { ...base.deduped, ...(parsed.deduped ?? {}) },
|
|
76
|
+
falsePositives: { ...base.falsePositives, ...(parsed.falsePositives ?? {}) },
|
|
77
|
+
latency: { ...base.latency, ...(parsed.latency ?? {}) },
|
|
78
|
+
storageBytes: parsed.storageBytes ?? 0,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
/* corrupt metrics → fresh */
|
|
83
|
+
}
|
|
84
|
+
return emptyMetrics();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Persist metrics to dashboard.json (best-effort). */
|
|
88
|
+
export function saveMetrics(path: string, m: DedupMetrics): void {
|
|
89
|
+
try {
|
|
90
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
91
|
+
writeFileSync(path, JSON.stringify(m));
|
|
92
|
+
} catch {
|
|
93
|
+
/* never break the extension */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Compute the p95 latency (ms) for a tier from its samples. */
|
|
98
|
+
export function p95(samples: number[]): number {
|
|
99
|
+
if (samples.length === 0) return 0;
|
|
100
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
101
|
+
const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95));
|
|
102
|
+
return sorted[idx];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** FP rate for a tier over the current window (0..1). */
|
|
106
|
+
export function fpRate(m: DedupMetrics, tier: DedupTier): number {
|
|
107
|
+
const decisions = m.decisions[tier] ?? 0;
|
|
108
|
+
if (decisions === 0) return 0;
|
|
109
|
+
return (m.falsePositives[tier] ?? 0) / decisions;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface AlertResult {
|
|
113
|
+
/** Tiers newly flipped to MARK_ONLY by this alert pass. */
|
|
114
|
+
breached: DedupTier[];
|
|
115
|
+
/** Warning lines written to events.log. */
|
|
116
|
+
warnings: string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Evaluate FP-rate breaches against the config thresholds. A breached fuzzy tier
|
|
121
|
+
* (L0 vs L1/L2 have different thresholds) is auto-downgraded to MARK_ONLY — the
|
|
122
|
+
* local re-map of "alertmanager" (QA #18/#19): record but don't collapse, no
|
|
123
|
+
* remote alert. Returns the tiers flipped so the caller can mutate its config.
|
|
124
|
+
*/
|
|
125
|
+
export function evaluateAlerts(
|
|
126
|
+
m: DedupMetrics,
|
|
127
|
+
cfg: DedupConfigShape,
|
|
128
|
+
): AlertResult {
|
|
129
|
+
const breached: DedupTier[] = [];
|
|
130
|
+
const warnings: string[] = [];
|
|
131
|
+
for (const tier of TIERS) {
|
|
132
|
+
const rate = fpRate(m, tier);
|
|
133
|
+
const limit = tier === "L0" ? cfg.FP_RATE_L0 : cfg.FP_RATE_L1L2;
|
|
134
|
+
if (rate > limit) {
|
|
135
|
+
breached.push(tier);
|
|
136
|
+
warnings.push(`DEDUP FP BREACH tier=${tier} rate=${rate.toFixed(4)} > ${limit}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { breached, warnings };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Record one decision into the metrics snapshot (mutates `m` in place) and
|
|
144
|
+
* returns the updated snapshot. Caps stored latency samples to keep memory
|
|
145
|
+
* bounded (last 1000 per tier).
|
|
146
|
+
*/
|
|
147
|
+
export function recordDecision(
|
|
148
|
+
m: DedupMetrics,
|
|
149
|
+
tier: DedupTier,
|
|
150
|
+
result: "deduped" | "new" | "mark_only",
|
|
151
|
+
latencyMs: number,
|
|
152
|
+
falsePositive = false,
|
|
153
|
+
): DedupMetrics {
|
|
154
|
+
m.decisions[tier] = (m.decisions[tier] ?? 0) + 1;
|
|
155
|
+
if (result === "deduped") m.deduped[tier] = (m.deduped[tier] ?? 0) + 1;
|
|
156
|
+
if (falsePositive) m.falsePositives[tier] = (m.falsePositives[tier] ?? 0) + 1;
|
|
157
|
+
const arr = m.latency[tier] ?? (m.latency[tier] = []);
|
|
158
|
+
arr.push(latencyMs);
|
|
159
|
+
if (arr.length > 1000) arr.shift();
|
|
160
|
+
return m;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Default metrics path alongside the state dir. */
|
|
164
|
+
export function defaultMetricsPath(stateDir: string = STATE_DIR_DEFAULT): string {
|
|
165
|
+
return join(stateDir, "dashboard.json");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Default events-log path alongside the state dir. */
|
|
169
|
+
export function defaultEventsPath(stateDir: string = STATE_DIR_DEFAULT): string {
|
|
170
|
+
return join(stateDir, "events.log");
|
|
171
|
+
}
|