pi-observational-memory-extension 0.1.2 → 0.2.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/CHANGELOG.md +83 -0
- package/README.md +35 -12
- package/extensions/index.ts +1004 -71
- package/package.json +32 -5
- package/scripts/test.mjs +131 -0
package/extensions/index.ts
CHANGED
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
9
9
|
import { Type } from "typebox";
|
|
10
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
10
|
+
import { copyFile, mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
11
11
|
import { existsSync } from "node:fs";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
12
13
|
import { basename, dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
13
15
|
|
|
14
16
|
const EXTENSION_ID = "pi-observational-memory";
|
|
15
|
-
const STATE_VERSION =
|
|
17
|
+
const STATE_VERSION = 3;
|
|
16
18
|
const DEFAULT_OBSERVATION_MODEL = "google/gemini-2.5-flash";
|
|
17
19
|
const DEFAULT_REFLECTION_MODEL = "google/gemini-2.5-flash";
|
|
18
20
|
const OBSERVATION_THRESHOLD = 30_000;
|
|
@@ -25,6 +27,14 @@ const TOOL_RESULT_MAX_CHARS = 8_000;
|
|
|
25
27
|
const MESSAGE_PART_MAX_CHARS = 20_000;
|
|
26
28
|
const MAX_OBSERVATION_LINE_CHARS = 10_000;
|
|
27
29
|
const MAX_RESTART_ERRORS = 3;
|
|
30
|
+
const STALE_OPERATION_LOCK_MS = 15 * 60 * 1000;
|
|
31
|
+
const PREVIOUS_OBSERVATIONS_MAX_TOKENS = 2_000;
|
|
32
|
+
const ATOMIC_BACKUP_SUFFIX = ".bak";
|
|
33
|
+
const MAX_ATTACHMENT_OBSERVE_BYTES = 2_000_000;
|
|
34
|
+
const VECTOR_TOP_K_DEFAULT = 6;
|
|
35
|
+
const VECTOR_THRESHOLD_DEFAULT = 0.12;
|
|
36
|
+
const PROJECT_REFERENCES_MAX_TOKENS = 2_000;
|
|
37
|
+
const VECTOR_MAX_INDEX_CHUNKS = 2_000;
|
|
28
38
|
|
|
29
39
|
const OBSERVATION_CONTEXT_PROMPT = `The following observations block contains your memory of past conversations with this user.`;
|
|
30
40
|
const OBSERVATION_CONTEXT_INSTRUCTIONS = `IMPORTANT: When responding, reference specific details from these observations. Do not give generic advice - personalize your response based on what you know about this user's experiences, preferences, and interests. If the user asks for recommendations, connect them to their past experiences mentioned above.
|
|
@@ -49,6 +59,29 @@ Your memory is comprised of observations which may mention raw message IDs. The
|
|
|
49
59
|
|
|
50
60
|
Use om_recall when the user asks to repeat/show/reproduce exact past content, code, quotes, error messages, URLs, file paths, or specific numbers; when observations mention something but lack detail; or when you need to verify a past event before answering. Default to recall for exact historical content. For high-level summaries, preferences, and facts already covered by observations, recall is not needed.`;
|
|
51
61
|
|
|
62
|
+
const CAVEMAN_OM_INSTRUCTION = `Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
|
63
|
+
|
|
64
|
+
Use full caveman compression style.
|
|
65
|
+
|
|
66
|
+
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact. Leave out the words "agent" and "assistant" at the start of each observation line, it is assumed each line is referring to the assistant unless it specifically says it was about the user. Leave out parenthesis and other text characters like * that would not contribute to understanding the observations.
|
|
67
|
+
|
|
68
|
+
Pattern: \`[thing] [action] [reason]. [next step]\`
|
|
69
|
+
|
|
70
|
+
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
|
71
|
+
Yes: "Bug in auth middleware. Token expiry check use < not <=. Fix:"
|
|
72
|
+
|
|
73
|
+
Example 1
|
|
74
|
+
🔴 14:31 user asks why React component rerenders
|
|
75
|
+
🟡 14:32 saw inline object prop create new ref each render, cause rerender
|
|
76
|
+
✅ 14:34 fixed render issue by wrap object in useMemo
|
|
77
|
+
|
|
78
|
+
Example 2
|
|
79
|
+
🟡 15:10 explained pool reuse DB connections, skip repeat handshake overhead
|
|
80
|
+
|
|
81
|
+
Don't say "Agent did x", say "did x". It will be assumed the agent did what was observed. The who should only be specified for the user or other third parties: "user asked x"
|
|
82
|
+
|
|
83
|
+
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question, and anything that requires remembering verbatim content. Resume caveman after clear part done`;
|
|
84
|
+
|
|
52
85
|
const OBSERVER_EXTRACTION_INSTRUCTIONS = `CRITICAL: DISTINGUISH USER ASSERTIONS FROM QUESTIONS
|
|
53
86
|
|
|
54
87
|
When the user TELLS you something about themselves, mark it as an assertion:
|
|
@@ -165,7 +198,8 @@ const OBSERVER_GUIDELINES = `- Be specific enough for the assistant to act on
|
|
|
165
198
|
- Observe WHAT the agent did and WHAT it means
|
|
166
199
|
- If the user provides detailed messages or code snippets, observe all important details`;
|
|
167
200
|
|
|
168
|
-
function buildObserverSystemPrompt(): string {
|
|
201
|
+
function buildObserverSystemPrompt(caveman = false): string {
|
|
202
|
+
const cavemanInstruction = caveman ? `\n\n${CAVEMAN_OM_INSTRUCTION}` : "";
|
|
169
203
|
return `You are the memory consciousness of an AI assistant. Your observations will be the ONLY information the assistant has about past interactions with this user.
|
|
170
204
|
|
|
171
205
|
Extract observations that will help the assistant remember:
|
|
@@ -188,20 +222,22 @@ Do NOT add thread identifiers, thread IDs, or <thread> tags to your observations
|
|
|
188
222
|
|
|
189
223
|
Remember: These observations are the assistant's ONLY memory. Make them count.
|
|
190
224
|
|
|
191
|
-
User messages are extremely important. If the user asks a question or gives a new task, make it clear in <current-task> that this is the priority. If the assistant needs to respond to the user, indicate in <suggested-response> that it should pause for user reply before continuing other tasks
|
|
225
|
+
User messages are extremely important. If the user asks a question or gives a new task, make it clear in <current-task> that this is the priority. If the assistant needs to respond to the user, indicate in <suggested-response> that it should pause for user reply before continuing other tasks.${cavemanInstruction}`;
|
|
192
226
|
}
|
|
193
227
|
|
|
194
228
|
function buildObserverTaskPrompt(existingObservations: string | undefined, opts: { priorCurrentTask?: string; priorSuggestedResponse?: string; wasTruncated?: boolean } = {}): string {
|
|
195
229
|
let prompt = "";
|
|
196
|
-
|
|
197
|
-
|
|
230
|
+
const limitedExisting = limitTextByTokens(existingObservations, PREVIOUS_OBSERVATIONS_MAX_TOKENS);
|
|
231
|
+
const previousWasTruncated = Boolean(existingObservations?.trim()) && limitedExisting !== existingObservations;
|
|
232
|
+
if (limitedExisting?.trim()) {
|
|
233
|
+
prompt += `## Previous Observations\n\n${limitedExisting}\n\n---\n\nDo not repeat these existing observations. Your new observations will be appended to the existing observations. Previous observations may be truncated to the most recent/relevant tail for context budget safety.\n\n`;
|
|
198
234
|
}
|
|
199
235
|
const metadata: string[] = [];
|
|
200
236
|
if (opts.priorCurrentTask) metadata.push(`- prior current-task: ${opts.priorCurrentTask}`);
|
|
201
237
|
if (opts.priorSuggestedResponse) metadata.push(`- prior suggested-response: ${opts.priorSuggestedResponse}`);
|
|
202
238
|
if (metadata.length) {
|
|
203
239
|
prompt += `## Prior Thread Metadata\n\n${metadata.join("\n")}\n\n`;
|
|
204
|
-
if (opts.wasTruncated) {
|
|
240
|
+
if (opts.wasTruncated || previousWasTruncated) {
|
|
205
241
|
prompt += `Previous observations were truncated for context budget reasons. The main agent still has full memory context outside this observer window.\n`;
|
|
206
242
|
}
|
|
207
243
|
prompt += `Use prior current-task and suggested-response as continuity hints, then update them based on the new messages.\n\n---\n\n`;
|
|
@@ -210,7 +246,8 @@ function buildObserverTaskPrompt(existingObservations: string | undefined, opts:
|
|
|
210
246
|
return prompt;
|
|
211
247
|
}
|
|
212
248
|
|
|
213
|
-
function buildReflectorSystemPrompt(): string {
|
|
249
|
+
function buildReflectorSystemPrompt(caveman = false): string {
|
|
250
|
+
const cavemanInstruction = caveman ? `\n\n${CAVEMAN_OM_INSTRUCTION}` : "";
|
|
214
251
|
return `You are the memory consciousness of an AI assistant. Your memory observation reflections will be the ONLY information the assistant has about past interactions with this user.
|
|
215
252
|
|
|
216
253
|
The following instructions were given to another part of your psyche (the observer) to create memories.
|
|
@@ -258,7 +295,7 @@ State current task(s) explicitly: primary and secondary pending tasks. Mark wait
|
|
|
258
295
|
Hint for the agent's immediate next message.
|
|
259
296
|
</suggested-response>
|
|
260
297
|
|
|
261
|
-
User messages are extremely important. If the user asks a question or gives a new task, make it clear in <current-task> that this is the priority. If the assistant needs to respond, indicate in <suggested-response> that it should pause for user reply before continuing other tasks
|
|
298
|
+
User messages are extremely important. If the user asks a question or gives a new task, make it clear in <current-task> that this is the priority. If the assistant needs to respond, indicate in <suggested-response> that it should pause for user reply before continuing other tasks.${cavemanInstruction}`;
|
|
262
299
|
}
|
|
263
300
|
|
|
264
301
|
type CompressionLevel = 0 | 1 | 2 | 3 | 4;
|
|
@@ -292,6 +329,22 @@ type BufferedChunk = {
|
|
|
292
329
|
createdAt: string;
|
|
293
330
|
};
|
|
294
331
|
|
|
332
|
+
type ObserveAttachmentsMode = "auto" | "on" | "off";
|
|
333
|
+
type RetrievalProvider = "off" | "local" | "gemini";
|
|
334
|
+
|
|
335
|
+
type PiOMSettings = {
|
|
336
|
+
observationModel: string;
|
|
337
|
+
reflectionModel: string;
|
|
338
|
+
caveman: boolean;
|
|
339
|
+
observeAttachments: ObserveAttachmentsMode;
|
|
340
|
+
retrieval: {
|
|
341
|
+
provider: RetrievalProvider;
|
|
342
|
+
model: string;
|
|
343
|
+
topK: number;
|
|
344
|
+
threshold: number;
|
|
345
|
+
};
|
|
346
|
+
};
|
|
347
|
+
|
|
295
348
|
type PiOMRecord = {
|
|
296
349
|
version: number;
|
|
297
350
|
enabled: boolean;
|
|
@@ -307,13 +360,32 @@ type PiOMRecord = {
|
|
|
307
360
|
pendingMessageTokens: number;
|
|
308
361
|
observationTokens: number;
|
|
309
362
|
thresholds: { observation: number; reflection: number; blockAfter: number; bufferTokens: number; bufferActivation: number };
|
|
363
|
+
settings: PiOMSettings;
|
|
310
364
|
buffered: { observations: BufferedChunk[]; reflection?: BufferedChunk };
|
|
311
365
|
operationLock?: { type: OperationType; startedAt: string };
|
|
312
366
|
lastOperation?: { type: OperationType; startedAt: string; endedAt?: string; inputTokens: number; outputTokens?: number; error?: string; model?: string; compressionLevel?: number };
|
|
313
367
|
lastError?: string;
|
|
314
368
|
lastProvider?: string;
|
|
315
369
|
lastModel?: string;
|
|
370
|
+
vectorIndex?: VectorIndexState;
|
|
371
|
+
updatedAt: string;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
type VectorIndexEntry = {
|
|
375
|
+
id: string;
|
|
376
|
+
hash: string;
|
|
377
|
+
text: string;
|
|
378
|
+
embedding: number[];
|
|
379
|
+
tokens: number;
|
|
380
|
+
createdAt: string;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
type VectorIndexState = {
|
|
384
|
+
provider: RetrievalProvider;
|
|
385
|
+
model: string;
|
|
386
|
+
scopeKey: string;
|
|
316
387
|
updatedAt: string;
|
|
388
|
+
entries: VectorIndexEntry[];
|
|
317
389
|
};
|
|
318
390
|
|
|
319
391
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
@@ -335,6 +407,61 @@ type Runtime = {
|
|
|
335
407
|
|
|
336
408
|
const runtime: Runtime = { failureCount: 0 };
|
|
337
409
|
|
|
410
|
+
let checkedForUpdate = false;
|
|
411
|
+
let updateNotification: string | undefined = undefined;
|
|
412
|
+
|
|
413
|
+
function isNewerVersion(local: string, latest: string): boolean {
|
|
414
|
+
const localParts = local.split(".").map((x) => parseInt(x, 10) || 0);
|
|
415
|
+
const latestParts = latest.split(".").map((x) => parseInt(x, 10) || 0);
|
|
416
|
+
for (let i = 0; i < 3; i++) {
|
|
417
|
+
const localPart = localParts[i] ?? 0;
|
|
418
|
+
const latestPart = latestParts[i] ?? 0;
|
|
419
|
+
if (latestPart > localPart) return true;
|
|
420
|
+
if (latestPart < localPart) return false;
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function checkForUpdates(ctx: any) {
|
|
426
|
+
try {
|
|
427
|
+
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
428
|
+
const pkgPath = join(extensionDir, "..", "package.json");
|
|
429
|
+
if (!existsSync(pkgPath)) return;
|
|
430
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
431
|
+
const localVersion = pkg.version;
|
|
432
|
+
if (!localVersion) return;
|
|
433
|
+
|
|
434
|
+
if (typeof fetch === "undefined") return;
|
|
435
|
+
|
|
436
|
+
const controller = new AbortController();
|
|
437
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
const res = await fetch("https://registry.npmjs.org/pi-observational-memory-extension/latest", {
|
|
441
|
+
signal: controller.signal,
|
|
442
|
+
headers: {
|
|
443
|
+
"User-Agent": `pi-observational-memory-update-checker/${localVersion}`
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
clearTimeout(timeoutId);
|
|
447
|
+
|
|
448
|
+
if (!res.ok) return;
|
|
449
|
+
const data = (await res.json()) as { version?: string };
|
|
450
|
+
const latestVersion = data.version;
|
|
451
|
+
if (!latestVersion) return;
|
|
452
|
+
|
|
453
|
+
if (isNewerVersion(localVersion, latestVersion)) {
|
|
454
|
+
updateNotification = `A new version of pi-observational-memory-extension is available: v${latestVersion} (installed: v${localVersion}). Please update using npm to get the latest features!`;
|
|
455
|
+
ctx?.ui?.notify?.(updateNotification, "warning");
|
|
456
|
+
}
|
|
457
|
+
} catch {
|
|
458
|
+
clearTimeout(timeoutId);
|
|
459
|
+
}
|
|
460
|
+
} catch {
|
|
461
|
+
// Fail silently
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
338
465
|
export default function (pi: ExtensionAPI) {
|
|
339
466
|
pi.registerTool({
|
|
340
467
|
name: "om_recall",
|
|
@@ -373,6 +500,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
373
500
|
await refreshCounts(ctx);
|
|
374
501
|
updateStatus(ctx);
|
|
375
502
|
runtime.statusTimer = setInterval(() => updateStatus(ctx), 1000);
|
|
503
|
+
|
|
504
|
+
if (!checkedForUpdate) {
|
|
505
|
+
checkedForUpdate = true;
|
|
506
|
+
checkForUpdates(ctx).catch(() => {});
|
|
507
|
+
} else if (updateNotification) {
|
|
508
|
+
ctx?.ui?.notify?.(updateNotification, "warning");
|
|
509
|
+
}
|
|
376
510
|
});
|
|
377
511
|
|
|
378
512
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
@@ -436,7 +570,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
436
570
|
.filter(isMessageLikeEntry)
|
|
437
571
|
.map(entryToAgentMessage)
|
|
438
572
|
.filter(Boolean) as AgentMessage[];
|
|
439
|
-
const
|
|
573
|
+
const queryText = unobserved.map(m => formatAgentMessage(m)).join("\n\n");
|
|
574
|
+
const retrieved = await retrieveRelevantObservations(ctx, state, queryText);
|
|
575
|
+
const projectReferences = await buildProjectReferences(ctx, state);
|
|
576
|
+
const omMessage = buildOMContextMessage(state, retrieved, projectReferences);
|
|
440
577
|
const finalMessages = [omMessage, ...unobserved];
|
|
441
578
|
if (finalMessages.length <= 1) return { messages: [omMessage, ...event.messages.slice(-8)] };
|
|
442
579
|
return { messages: finalMessages };
|
|
@@ -565,6 +702,92 @@ export default function (pi: ExtensionAPI) {
|
|
|
565
702
|
},
|
|
566
703
|
});
|
|
567
704
|
|
|
705
|
+
pi.registerCommand("om", {
|
|
706
|
+
description: "Manage Observational Memory settings. Usage: /om, /om set <key> <value>, /om enable|disable|observe|reflect|memory",
|
|
707
|
+
handler: async (args, ctx) => {
|
|
708
|
+
const state = await ensureState(ctx);
|
|
709
|
+
const input = (args || "").trim();
|
|
710
|
+
if (!input || input === "status") {
|
|
711
|
+
await refreshCounts(ctx);
|
|
712
|
+
ctx.ui.notify(formatSettingsText(state), state.status === "failed" ? "error" : "info");
|
|
713
|
+
updateStatus(ctx);
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const [cmd, ...rest] = input.split(/\s+/);
|
|
717
|
+
if (cmd === "enable") {
|
|
718
|
+
state.enabled = true;
|
|
719
|
+
state.status = "idle";
|
|
720
|
+
await saveState(state);
|
|
721
|
+
updateStatus(ctx);
|
|
722
|
+
ctx.ui.notify("Observational Memory enabled", "info");
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (cmd === "disable") {
|
|
726
|
+
state.enabled = false;
|
|
727
|
+
state.status = "disabled";
|
|
728
|
+
await saveState(state);
|
|
729
|
+
updateStatus(ctx);
|
|
730
|
+
ctx.ui.notify("Observational Memory disabled", "warning");
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (cmd === "observe") {
|
|
734
|
+
await observeNow(ctx, { force: true, reason: "manual" });
|
|
735
|
+
updateStatus(ctx);
|
|
736
|
+
ctx.ui.notify("OM observation complete", "info");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (cmd === "reflect") {
|
|
740
|
+
await reflectNow(ctx, { reason: "manual", manualPrompt: rest.join(" ") || undefined });
|
|
741
|
+
updateStatus(ctx);
|
|
742
|
+
ctx.ui.notify("OM reflection complete", "info");
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (cmd === "memory") {
|
|
746
|
+
ctx.ui.notify(formatMemoryText(state), "info");
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (cmd === "vector") {
|
|
750
|
+
const sub = rest[0] || "status";
|
|
751
|
+
if (sub === "status") {
|
|
752
|
+
ctx.ui.notify(formatVectorStatus(state), "info");
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (sub === "rebuild") {
|
|
756
|
+
await rebuildVectorIndex(ctx, state);
|
|
757
|
+
await saveState(state);
|
|
758
|
+
ctx.ui.notify(formatVectorStatus(state), "info");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (sub === "search") {
|
|
762
|
+
const query = rest.slice(1).join(" ").trim();
|
|
763
|
+
if (!query) throw new Error("Usage: /om vector search <query>");
|
|
764
|
+
const result = await retrieveRelevantObservations(ctx, state, query);
|
|
765
|
+
ctx.ui.notify(result || "No matching OM vector results.", "info");
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
throw new Error("Unknown /om vector command. Use /om vector status|rebuild|search <query>");
|
|
769
|
+
}
|
|
770
|
+
if (cmd === "set") {
|
|
771
|
+
const key = rest[0];
|
|
772
|
+
const value = rest.slice(1).join(" ");
|
|
773
|
+
applySetting(state, key, value);
|
|
774
|
+
await saveState(state);
|
|
775
|
+
updateStatus(ctx);
|
|
776
|
+
ctx.ui.notify(`OM setting updated: ${key}=${value}`, "info");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (cmd === "reset") {
|
|
780
|
+
state.thresholds = defaultThresholds();
|
|
781
|
+
state.settings = defaultSettings();
|
|
782
|
+
await saveState(state);
|
|
783
|
+
updateStatus(ctx);
|
|
784
|
+
ctx.ui.notify("OM settings reset to defaults", "info");
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
throw new Error(`Unknown /om command: ${cmd}. Use /om, /om set <key> <value>, /om enable|disable|observe|reflect|memory|reset`);
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
|
|
568
791
|
pi.registerCommand("om-compact", {
|
|
569
792
|
description: "Run Pi compaction; pi-observational-memory will replace the summary with OM.",
|
|
570
793
|
handler: async (args, ctx) => {
|
|
@@ -644,53 +867,289 @@ async function ensureState(ctx: any): Promise<PiOMRecord> {
|
|
|
644
867
|
const dir = join(ctx.cwd, CONFIG_DIR_NAME, "om");
|
|
645
868
|
const debugDir = join(dir, "debug");
|
|
646
869
|
await mkdir(debugDir, { recursive: true });
|
|
647
|
-
const
|
|
648
|
-
runtime.statePath = statePath;
|
|
870
|
+
const legacyStatePath = join(dir, `${sanitizeFileName(sessionId)}.json`);
|
|
649
871
|
runtime.debugDir = debugDir;
|
|
650
872
|
let state: PiOMRecord | undefined;
|
|
651
|
-
|
|
873
|
+
let statePath = legacyStatePath;
|
|
874
|
+
if (existsSync(legacyStatePath)) {
|
|
652
875
|
try {
|
|
653
|
-
state = JSON.parse(await readFile(
|
|
876
|
+
state = normalizeState(JSON.parse(await readFile(legacyStatePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
654
877
|
} catch (error) {
|
|
655
|
-
|
|
878
|
+
const backupPath = `${legacyStatePath}${ATOMIC_BACKUP_SUFFIX}`;
|
|
879
|
+
if (existsSync(backupPath)) {
|
|
880
|
+
try {
|
|
881
|
+
state = normalizeState(JSON.parse(await readFile(backupPath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
882
|
+
} catch {
|
|
883
|
+
console.error(`[OM] Error: Failed to parse state and backup for ${legacyStatePath}. Falling back to default empty state.`);
|
|
884
|
+
try {
|
|
885
|
+
await rename(legacyStatePath, `${legacyStatePath}.corrupted`);
|
|
886
|
+
await rename(backupPath, `${backupPath}.corrupted`);
|
|
887
|
+
} catch {}
|
|
888
|
+
}
|
|
889
|
+
} else {
|
|
890
|
+
console.error(`[OM] Error: Failed to parse state for ${legacyStatePath} (no backup). Falling back to default empty state.`);
|
|
891
|
+
try {
|
|
892
|
+
await rename(legacyStatePath, `${legacyStatePath}.corrupted`);
|
|
893
|
+
} catch {}
|
|
894
|
+
}
|
|
656
895
|
}
|
|
657
896
|
}
|
|
658
|
-
if (!state
|
|
659
|
-
state = {
|
|
660
|
-
version: STATE_VERSION,
|
|
661
|
-
enabled: true,
|
|
662
|
-
sessionId,
|
|
663
|
-
sessionFile,
|
|
664
|
-
cwd: ctx.cwd,
|
|
665
|
-
scope: "session",
|
|
666
|
-
status: "idle",
|
|
667
|
-
observations: "",
|
|
668
|
-
pendingMessageTokens: 0,
|
|
669
|
-
observationTokens: 0,
|
|
670
|
-
thresholds: {
|
|
671
|
-
observation: OBSERVATION_THRESHOLD,
|
|
672
|
-
reflection: REFLECTION_THRESHOLD,
|
|
673
|
-
blockAfter: Math.round(OBSERVATION_THRESHOLD * DEFAULT_BLOCK_AFTER_MULTIPLIER),
|
|
674
|
-
bufferTokens: Math.round(OBSERVATION_THRESHOLD * BUFFER_TOKENS_RATIO),
|
|
675
|
-
bufferActivation: BUFFER_ACTIVATION_RATIO,
|
|
676
|
-
},
|
|
677
|
-
buffered: { observations: [] },
|
|
678
|
-
updatedAt: new Date().toISOString(),
|
|
679
|
-
};
|
|
680
|
-
await saveState(state);
|
|
897
|
+
if (!state) {
|
|
898
|
+
state = createDefaultState({ sessionId, sessionFile, cwd: ctx.cwd });
|
|
681
899
|
}
|
|
900
|
+
statePath = statePathFor(ctx.cwd, state.scope, sessionId);
|
|
901
|
+
if (statePath !== legacyStatePath && existsSync(statePath)) {
|
|
902
|
+
try {
|
|
903
|
+
state = normalizeState(JSON.parse(await readFile(statePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
904
|
+
} catch {
|
|
905
|
+
const backupPath = `${statePath}${ATOMIC_BACKUP_SUFFIX}`;
|
|
906
|
+
if (existsSync(backupPath)) {
|
|
907
|
+
try {
|
|
908
|
+
state = normalizeState(JSON.parse(await readFile(backupPath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
909
|
+
} catch {
|
|
910
|
+
try {
|
|
911
|
+
await rename(statePath, `${statePath}.corrupted`);
|
|
912
|
+
await rename(backupPath, `${backupPath}.corrupted`);
|
|
913
|
+
} catch {}
|
|
914
|
+
}
|
|
915
|
+
} else {
|
|
916
|
+
try {
|
|
917
|
+
await rename(statePath, `${statePath}.corrupted`);
|
|
918
|
+
} catch {}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
const resolvedStatePath = statePathFor(ctx.cwd, state.scope, sessionId);
|
|
923
|
+
if (resolvedStatePath !== statePath && existsSync(resolvedStatePath)) {
|
|
924
|
+
statePath = resolvedStatePath;
|
|
925
|
+
try {
|
|
926
|
+
state = normalizeState(JSON.parse(await readFile(statePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
927
|
+
} catch {
|
|
928
|
+
const backupPath = `${statePath}${ATOMIC_BACKUP_SUFFIX}`;
|
|
929
|
+
if (existsSync(backupPath)) {
|
|
930
|
+
try {
|
|
931
|
+
state = normalizeState(JSON.parse(await readFile(backupPath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
932
|
+
} catch {
|
|
933
|
+
try {
|
|
934
|
+
await rename(statePath, `${statePath}.corrupted`);
|
|
935
|
+
await rename(backupPath, `${backupPath}.corrupted`);
|
|
936
|
+
} catch {}
|
|
937
|
+
}
|
|
938
|
+
} else {
|
|
939
|
+
try {
|
|
940
|
+
await rename(statePath, `${statePath}.corrupted`);
|
|
941
|
+
} catch {}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
} else {
|
|
945
|
+
statePath = resolvedStatePath;
|
|
946
|
+
}
|
|
947
|
+
runtime.statePath = statePath;
|
|
948
|
+
const recovered = recoverStaleOperationLock(state);
|
|
682
949
|
runtime.state = state;
|
|
950
|
+
if (recovered || state.version !== STATE_VERSION || !existsSync(statePath)) await saveState(state);
|
|
683
951
|
return state;
|
|
684
952
|
}
|
|
685
953
|
|
|
954
|
+
function defaultSettings(): PiOMSettings {
|
|
955
|
+
return {
|
|
956
|
+
observationModel: DEFAULT_OBSERVATION_MODEL,
|
|
957
|
+
reflectionModel: DEFAULT_REFLECTION_MODEL,
|
|
958
|
+
caveman: false,
|
|
959
|
+
observeAttachments: "auto",
|
|
960
|
+
retrieval: { provider: "off", model: "local/hash-bow-v1", topK: VECTOR_TOP_K_DEFAULT, threshold: VECTOR_THRESHOLD_DEFAULT },
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function defaultThresholds(): PiOMRecord["thresholds"] {
|
|
965
|
+
return {
|
|
966
|
+
observation: OBSERVATION_THRESHOLD,
|
|
967
|
+
reflection: REFLECTION_THRESHOLD,
|
|
968
|
+
blockAfter: Math.round(OBSERVATION_THRESHOLD * DEFAULT_BLOCK_AFTER_MULTIPLIER),
|
|
969
|
+
bufferTokens: Math.round(OBSERVATION_THRESHOLD * BUFFER_TOKENS_RATIO),
|
|
970
|
+
bufferActivation: BUFFER_ACTIVATION_RATIO,
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function createDefaultState(identity: { sessionId: string; sessionFile?: string; cwd: string }): PiOMRecord {
|
|
975
|
+
return {
|
|
976
|
+
version: STATE_VERSION,
|
|
977
|
+
enabled: true,
|
|
978
|
+
sessionId: identity.sessionId,
|
|
979
|
+
sessionFile: identity.sessionFile,
|
|
980
|
+
cwd: identity.cwd,
|
|
981
|
+
scope: "session",
|
|
982
|
+
status: "idle",
|
|
983
|
+
observations: "",
|
|
984
|
+
pendingMessageTokens: 0,
|
|
985
|
+
observationTokens: 0,
|
|
986
|
+
thresholds: defaultThresholds(),
|
|
987
|
+
settings: defaultSettings(),
|
|
988
|
+
buffered: { observations: [] },
|
|
989
|
+
updatedAt: new Date().toISOString(),
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function normalizeState(raw: any, identity: { sessionId: string; sessionFile?: string; cwd: string }): PiOMRecord {
|
|
994
|
+
const defaults = createDefaultState(identity);
|
|
995
|
+
const state = {
|
|
996
|
+
...defaults,
|
|
997
|
+
...raw,
|
|
998
|
+
version: STATE_VERSION,
|
|
999
|
+
sessionId: raw?.sessionId || identity.sessionId,
|
|
1000
|
+
sessionFile: raw?.sessionFile || identity.sessionFile,
|
|
1001
|
+
cwd: raw?.cwd || identity.cwd,
|
|
1002
|
+
thresholds: { ...defaults.thresholds, ...(raw?.thresholds ?? {}) },
|
|
1003
|
+
settings: normalizeSettings(raw?.settings, defaults.settings),
|
|
1004
|
+
buffered: { observations: [], ...(raw?.buffered ?? {}) },
|
|
1005
|
+
} as PiOMRecord;
|
|
1006
|
+
state.observations = redactSecrets(String(state.observations ?? ""));
|
|
1007
|
+
state.currentTask = state.currentTask ? redactSecrets(state.currentTask) : undefined;
|
|
1008
|
+
state.suggestedResponse = state.suggestedResponse ? redactSecrets(state.suggestedResponse) : undefined;
|
|
1009
|
+
state.lastError = state.lastError ? redactSecrets(state.lastError) : undefined;
|
|
1010
|
+
state.scope = state.scope === "project" ? "project" : "session";
|
|
1011
|
+
state.observationTokens = estimateTokens(state.observations);
|
|
1012
|
+
state.vectorIndex = normalizeVectorIndex(state.vectorIndex, state);
|
|
1013
|
+
return state;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function normalizeSettings(raw: any, defaults: PiOMSettings): PiOMSettings {
|
|
1017
|
+
const legacyAttachments = raw?.observeAttachments;
|
|
1018
|
+
let observeAttachments: ObserveAttachmentsMode = defaults.observeAttachments;
|
|
1019
|
+
if (legacyAttachments === true) observeAttachments = "on";
|
|
1020
|
+
else if (legacyAttachments === false) observeAttachments = "off";
|
|
1021
|
+
else if (["auto", "on", "off"].includes(String(legacyAttachments))) observeAttachments = legacyAttachments;
|
|
1022
|
+
const rawRetrieval = raw?.retrieval ?? {};
|
|
1023
|
+
const provider = ["off", "local", "gemini"].includes(String(rawRetrieval.provider)) ? rawRetrieval.provider as RetrievalProvider : defaults.retrieval.provider;
|
|
1024
|
+
return {
|
|
1025
|
+
...defaults,
|
|
1026
|
+
...raw,
|
|
1027
|
+
observeAttachments,
|
|
1028
|
+
retrieval: {
|
|
1029
|
+
provider,
|
|
1030
|
+
model: String(rawRetrieval.model || (provider === "gemini" ? "gemini-embedding-001" : defaults.retrieval.model)),
|
|
1031
|
+
topK: clampInt(Number(rawRetrieval.topK ?? defaults.retrieval.topK), 1, 30),
|
|
1032
|
+
threshold: clampNumber(Number(rawRetrieval.threshold ?? defaults.retrieval.threshold), 0, 1),
|
|
1033
|
+
},
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function normalizeVectorIndex(raw: any, state: PiOMRecord): VectorIndexState | undefined {
|
|
1038
|
+
if (!raw || typeof raw !== "object" || !Array.isArray(raw.entries)) return undefined;
|
|
1039
|
+
return {
|
|
1040
|
+
provider: ["off", "local", "gemini"].includes(String(raw.provider)) ? raw.provider : state.settings.retrieval.provider,
|
|
1041
|
+
model: String(raw.model || state.settings.retrieval.model),
|
|
1042
|
+
scopeKey: String(raw.scopeKey || scopeKeyForState(state)),
|
|
1043
|
+
updatedAt: String(raw.updatedAt || new Date().toISOString()),
|
|
1044
|
+
entries: raw.entries.slice(-VECTOR_MAX_INDEX_CHUNKS).filter((e: any) => e && typeof e.text === "string" && Array.isArray(e.embedding)),
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function clampInt(value: number, min: number, max: number): number {
|
|
1049
|
+
if (!Number.isFinite(value)) return min;
|
|
1050
|
+
return Math.max(min, Math.min(max, Math.round(value)));
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function clampNumber(value: number, min: number, max: number): number {
|
|
1054
|
+
if (!Number.isFinite(value)) return min;
|
|
1055
|
+
return Math.max(min, Math.min(max, value));
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function statePathFor(cwd: string, scope: PiOMRecord["scope"], sessionId: string): string {
|
|
1059
|
+
const base = join(cwd, CONFIG_DIR_NAME, "om", scope === "project" ? "projects" : "sessions");
|
|
1060
|
+
return join(base, `${scope === "project" ? projectHashForCwd(cwd) : sanitizeFileName(sessionId)}.json`);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function projectHashForCwd(cwd: string): string {
|
|
1064
|
+
return createHash("sha256").update(cwd).digest("hex").slice(0, 16);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function scopeKeyForState(state: PiOMRecord): string {
|
|
1068
|
+
return state.scope === "project" ? `project:${projectHashForCwd(state.cwd)}` : `session:${sanitizeFileName(state.sessionId)}`;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function isStaleOperationLock(lock: PiOMRecord["operationLock"], now = Date.now()): boolean {
|
|
1072
|
+
if (!lock) return false;
|
|
1073
|
+
const started = Date.parse(lock.startedAt);
|
|
1074
|
+
return Number.isFinite(started) && now - started > STALE_OPERATION_LOCK_MS;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function recoverStaleOperationLock(state: PiOMRecord, now = Date.now()): boolean {
|
|
1078
|
+
if (!isStaleOperationLock(state.operationLock, now)) return false;
|
|
1079
|
+
const lock = state.operationLock!;
|
|
1080
|
+
state.lastOperation = { type: lock.type, startedAt: lock.startedAt, endedAt: new Date(now).toISOString(), inputTokens: 0, error: `Recovered stale OM operation lock after ${Math.round((now - Date.parse(lock.startedAt)) / 1000)}s` };
|
|
1081
|
+
state.lastError = state.lastOperation.error;
|
|
1082
|
+
state.operationLock = undefined;
|
|
1083
|
+
state.status = state.enabled ? "idle" : "disabled";
|
|
1084
|
+
return true;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
686
1087
|
async function saveState(state: PiOMRecord): Promise<void> {
|
|
1088
|
+
runtime.statePath = statePathFor(state.cwd, state.scope, state.sessionId);
|
|
687
1089
|
if (!runtime.statePath) return;
|
|
688
1090
|
state.updatedAt = new Date().toISOString();
|
|
689
|
-
|
|
690
|
-
|
|
1091
|
+
state.observations = redactSecrets(state.observations);
|
|
1092
|
+
if (state.currentTask) state.currentTask = redactSecrets(state.currentTask);
|
|
1093
|
+
if (state.suggestedResponse) state.suggestedResponse = redactSecrets(state.suggestedResponse);
|
|
1094
|
+
if (state.lastError) state.lastError = redactSecrets(state.lastError);
|
|
1095
|
+
if (state.settings.retrieval.provider === "local") state.vectorIndex = buildLocalVectorIndex(state);
|
|
1096
|
+
await mergeExistingScopeState(runtime.statePath, state);
|
|
1097
|
+
await atomicWriteJson(runtime.statePath, state);
|
|
1098
|
+
if (state.scope === "project") {
|
|
1099
|
+
const sessionPointerPath = statePathFor(state.cwd, "session", state.sessionId);
|
|
1100
|
+
const sessionPointer = { ...state, scope: "project" as const, observations: "", currentTask: undefined, suggestedResponse: undefined, vectorIndex: undefined };
|
|
1101
|
+
await atomicWriteJson(sessionPointerPath, sessionPointer);
|
|
1102
|
+
}
|
|
1103
|
+
await saveVectorIndex(state);
|
|
691
1104
|
runtime.overlayHandle?.requestRender();
|
|
692
1105
|
}
|
|
693
1106
|
|
|
1107
|
+
|
|
1108
|
+
async function mergeExistingScopeState(filePath: string, state: PiOMRecord): Promise<void> {
|
|
1109
|
+
if (!existsSync(filePath)) return;
|
|
1110
|
+
try {
|
|
1111
|
+
const existing = normalizeState(JSON.parse(await readFile(filePath, "utf8")), { sessionId: state.sessionId, sessionFile: state.sessionFile, cwd: state.cwd });
|
|
1112
|
+
if (existing.updatedAt === state.updatedAt) return;
|
|
1113
|
+
const merged = suppressDuplicateObservations(state.observations, existing.observations);
|
|
1114
|
+
if (merged) state.observations = [state.observations.trim(), merged].filter(Boolean).join("\n\n");
|
|
1115
|
+
state.currentTask = state.currentTask ?? existing.currentTask;
|
|
1116
|
+
state.suggestedResponse = state.suggestedResponse ?? existing.suggestedResponse;
|
|
1117
|
+
state.observationTokens = estimateTokens(state.observations);
|
|
1118
|
+
} catch {
|
|
1119
|
+
// Corrupt existing state is handled by .bak recovery during load; do not block saving current valid state.
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async function saveVectorIndex(state: PiOMRecord): Promise<void> {
|
|
1124
|
+
if (!state.vectorIndex || !runtime.statePath) return;
|
|
1125
|
+
const dir = join(dirname(dirname(runtime.statePath)), "vectors");
|
|
1126
|
+
await mkdir(dir, { recursive: true });
|
|
1127
|
+
const file = join(dir, `${sanitizeFileName(state.vectorIndex.scopeKey)}.jsonl`);
|
|
1128
|
+
const lines = state.vectorIndex.entries.map(entry => JSON.stringify(redactDeep(entry))).join("\n") + (state.vectorIndex.entries.length ? "\n" : "");
|
|
1129
|
+
await writeFile(file, lines, "utf8");
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
|
|
1133
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
1134
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
1135
|
+
const backupPath = `${filePath}${ATOMIC_BACKUP_SUFFIX}`;
|
|
1136
|
+
const json = JSON.stringify(redactDeep(value), null, 2) + "\n";
|
|
1137
|
+
await writeFile(tmpPath, json, "utf8");
|
|
1138
|
+
if (existsSync(filePath)) {
|
|
1139
|
+
try {
|
|
1140
|
+
await copyFile(filePath, backupPath);
|
|
1141
|
+
} catch {
|
|
1142
|
+
// Backup is best-effort; rename below is the durability boundary.
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
try {
|
|
1146
|
+
await rename(tmpPath, filePath);
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
try { await unlink(tmpPath); } catch {}
|
|
1149
|
+
throw error;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
694
1153
|
async function refreshCounts(ctx: any): Promise<void> {
|
|
695
1154
|
let isStale = false;
|
|
696
1155
|
try {
|
|
@@ -735,7 +1194,10 @@ async function observeNow(ctx: any, opts: { force: boolean; reason: string; sign
|
|
|
735
1194
|
if (candidates.length === 0 && !opts.manualText) return;
|
|
736
1195
|
const selected = opts.force ? candidates : selectEntriesForObservation(candidates, state);
|
|
737
1196
|
if (selected.length === 0 && !opts.manualText) return;
|
|
738
|
-
const
|
|
1197
|
+
const observerInput = opts.manualText
|
|
1198
|
+
? { text: opts.manualText, attachmentParts: [] as any[], omissions: [] as string[] }
|
|
1199
|
+
: buildObserverInput(ctx, selected, state);
|
|
1200
|
+
const inputText = observerInput.text;
|
|
739
1201
|
const startedAt = new Date().toISOString();
|
|
740
1202
|
state.operationLock = { type: "observation", startedAt };
|
|
741
1203
|
state.status = "observing";
|
|
@@ -745,7 +1207,7 @@ async function observeNow(ctx: any, opts: { force: boolean; reason: string; sign
|
|
|
745
1207
|
runtime.currentOperation = controller;
|
|
746
1208
|
const signal = mergeAbortSignals(opts.signal, controller.signal);
|
|
747
1209
|
try {
|
|
748
|
-
const result = await runObserver(ctx, inputText, signal, { source: opts.reason, existingObservations: state.observations });
|
|
1210
|
+
const result = await runObserver(ctx, inputText, signal, { source: opts.reason, existingObservations: state.observations, attachmentParts: observerInput.attachmentParts });
|
|
749
1211
|
appendObservations(state, result, estimateTokens(inputText));
|
|
750
1212
|
const last = selected.at(-1);
|
|
751
1213
|
if (last?.id) state.lastObservedEntryId = last.id;
|
|
@@ -753,7 +1215,7 @@ async function observeNow(ctx: any, opts: { force: boolean; reason: string; sign
|
|
|
753
1215
|
state.operationLock = undefined;
|
|
754
1216
|
state.status = "idle";
|
|
755
1217
|
state.lastError = undefined;
|
|
756
|
-
await writeDebug(ctx, "observation", { startedAt, reason: opts.reason, inputText, rawOutput: result.rawOutput, parsed: result });
|
|
1218
|
+
await writeDebug(ctx, "observation", { startedAt, reason: opts.reason, inputText, attachmentOmissions: observerInput.omissions, attachmentCount: observerInput.attachmentParts.length, rawOutput: result.rawOutput, parsed: result });
|
|
757
1219
|
await saveState(state);
|
|
758
1220
|
} catch (error) {
|
|
759
1221
|
state.operationLock = undefined;
|
|
@@ -778,7 +1240,8 @@ async function bufferObservation(ctx: any, reason: string): Promise<void> {
|
|
|
778
1240
|
if (tokens < state.thresholds.bufferTokens) return;
|
|
779
1241
|
const selected = takeEntriesUpTo(candidates, Math.min(tokens, OBSERVER_MAX_BATCH_TOKENS));
|
|
780
1242
|
if (selected.length === 0) return;
|
|
781
|
-
const
|
|
1243
|
+
const observerInput = buildObserverInput(ctx, selected, state);
|
|
1244
|
+
const inputText = observerInput.text;
|
|
782
1245
|
const startedAt = new Date().toISOString();
|
|
783
1246
|
state.operationLock = { type: "buffer", startedAt };
|
|
784
1247
|
state.status = "buffering";
|
|
@@ -787,7 +1250,7 @@ async function bufferObservation(ctx: any, reason: string): Promise<void> {
|
|
|
787
1250
|
const controller = new AbortController();
|
|
788
1251
|
runtime.currentOperation = controller;
|
|
789
1252
|
try {
|
|
790
|
-
const result = await runObserver(ctx, inputText, controller.signal, { source: reason, existingObservations: state.observations });
|
|
1253
|
+
const result = await runObserver(ctx, inputText, controller.signal, { source: reason, existingObservations: state.observations, attachmentParts: observerInput.attachmentParts });
|
|
791
1254
|
const observations = result.observations.trim();
|
|
792
1255
|
if (!observations) throw new Error("Observer returned empty buffered observations");
|
|
793
1256
|
state.buffered.observations.push({
|
|
@@ -803,8 +1266,8 @@ async function bufferObservation(ctx: any, reason: string): Promise<void> {
|
|
|
803
1266
|
});
|
|
804
1267
|
state.operationLock = undefined;
|
|
805
1268
|
state.status = "idle";
|
|
806
|
-
state.lastOperation = { type: "buffer", startedAt, endedAt: new Date().toISOString(), inputTokens: estimateTokens(inputText), outputTokens: estimateTokens(observations), model: DEFAULT_OBSERVATION_MODEL };
|
|
807
|
-
await writeDebug(ctx, "buffer", { startedAt, reason, inputText, rawOutput: result.rawOutput, parsed: result });
|
|
1269
|
+
state.lastOperation = { type: "buffer", startedAt, endedAt: new Date().toISOString(), inputTokens: estimateTokens(inputText), outputTokens: estimateTokens(observations), model: state.settings.observationModel || DEFAULT_OBSERVATION_MODEL };
|
|
1270
|
+
await writeDebug(ctx, "buffer", { startedAt, reason, inputText, attachmentOmissions: observerInput.omissions, attachmentCount: observerInput.attachmentParts.length, rawOutput: result.rawOutput, parsed: result });
|
|
808
1271
|
await saveState(state);
|
|
809
1272
|
} catch (error) {
|
|
810
1273
|
state.operationLock = undefined;
|
|
@@ -854,6 +1317,7 @@ async function activateBuffered(ctx: any, reason: string): Promise<void> {
|
|
|
854
1317
|
state.buffered.observations = chunks.slice(activated.length);
|
|
855
1318
|
state.pendingMessageTokens = Math.max(0, state.pendingMessageTokens - activatedTokens);
|
|
856
1319
|
state.observationTokens = estimateTokens(state.observations);
|
|
1320
|
+
state.vectorIndex = buildLocalVectorIndex(state);
|
|
857
1321
|
state.lastOperation = { type: "activation", startedAt, endedAt: new Date().toISOString(), inputTokens: activatedTokens, outputTokens: estimateTokens(observations) };
|
|
858
1322
|
state.operationLock = undefined;
|
|
859
1323
|
state.status = "idle";
|
|
@@ -900,10 +1364,11 @@ async function reflectNow(ctx: any, opts: { reason: string; signal?: AbortSignal
|
|
|
900
1364
|
state.currentTask = result.currentTask ?? state.currentTask;
|
|
901
1365
|
state.suggestedResponse = result.suggestedContinuation ?? state.suggestedResponse;
|
|
902
1366
|
state.observationTokens = reflectedTokens;
|
|
1367
|
+
state.vectorIndex = buildLocalVectorIndex(state);
|
|
903
1368
|
state.operationLock = undefined;
|
|
904
1369
|
state.status = "idle";
|
|
905
1370
|
state.lastError = undefined;
|
|
906
|
-
state.lastOperation = { type: "reflection", startedAt, endedAt: new Date().toISOString(), inputTokens: originalTokens, outputTokens: reflectedTokens, model: DEFAULT_REFLECTION_MODEL, compressionLevel: level };
|
|
1371
|
+
state.lastOperation = { type: "reflection", startedAt, endedAt: new Date().toISOString(), inputTokens: originalTokens, outputTokens: reflectedTokens, model: state.settings.reflectionModel || DEFAULT_REFLECTION_MODEL, compressionLevel: level };
|
|
907
1372
|
await saveState(state);
|
|
908
1373
|
return;
|
|
909
1374
|
}
|
|
@@ -922,11 +1387,16 @@ async function reflectNow(ctx: any, opts: { reason: string; signal?: AbortSignal
|
|
|
922
1387
|
}
|
|
923
1388
|
}
|
|
924
1389
|
|
|
925
|
-
async function runObserver(ctx: any, historyText: string, signal: AbortSignal | undefined, opts: { source: string; existingObservations?: string }): Promise<ObserverResult> {
|
|
926
|
-
const
|
|
1390
|
+
async function runObserver(ctx: any, historyText: string, signal: AbortSignal | undefined, opts: { source: string; existingObservations?: string; attachmentParts?: any[] }): Promise<ObserverResult> {
|
|
1391
|
+
const state = await ensureState(ctx);
|
|
1392
|
+
const modelId = state.settings.observationModel || DEFAULT_OBSERVATION_MODEL;
|
|
1393
|
+
const model = resolveModel(ctx, modelId);
|
|
927
1394
|
const response = await runModel(ctx, model, [
|
|
928
|
-
{ role: "user", content: [{ type: "text", text: buildObserverSystemPrompt() }] },
|
|
929
|
-
{ role: "user", content: [
|
|
1395
|
+
{ role: "user", content: [{ type: "text", text: buildObserverSystemPrompt(state.settings.caveman) }] },
|
|
1396
|
+
{ role: "user", content: [
|
|
1397
|
+
{ type: "text", text: `## New Message History to Observe\n\n${historyText}\n\n---\n\n${buildObserverTaskPrompt(opts.existingObservations, { priorCurrentTask: runtime.state?.currentTask, priorSuggestedResponse: runtime.state?.suggestedResponse })}` },
|
|
1398
|
+
...(opts.attachmentParts ?? []),
|
|
1399
|
+
] },
|
|
930
1400
|
], { temperature: 0.3, maxTokens: 100_000, signal });
|
|
931
1401
|
const text = responseText(response);
|
|
932
1402
|
const parsed = parseObserverOutput(text);
|
|
@@ -936,9 +1406,11 @@ async function runObserver(ctx: any, historyText: string, signal: AbortSignal |
|
|
|
936
1406
|
}
|
|
937
1407
|
|
|
938
1408
|
async function runReflector(ctx: any, observations: string, level: CompressionLevel, signal: AbortSignal | undefined, manualPrompt?: string): Promise<ReflectorResult> {
|
|
939
|
-
const
|
|
1409
|
+
const state = await ensureState(ctx);
|
|
1410
|
+
const modelId = state.settings.reflectionModel || DEFAULT_REFLECTION_MODEL;
|
|
1411
|
+
const model = resolveModel(ctx, modelId);
|
|
940
1412
|
const response = await runModel(ctx, model, [
|
|
941
|
-
{ role: "user", content: [{ type: "text", text: buildReflectorSystemPrompt() }] },
|
|
1413
|
+
{ role: "user", content: [{ type: "text", text: buildReflectorSystemPrompt(state.settings.caveman) }] },
|
|
942
1414
|
{ role: "user", content: [{ type: "text", text: buildReflectorPrompt(observations, level, manualPrompt) }] },
|
|
943
1415
|
], { temperature: 0, maxTokens: 100_000, signal });
|
|
944
1416
|
const text = responseText(response);
|
|
@@ -956,7 +1428,11 @@ function resolveModel(ctx: any, modelId: string): any {
|
|
|
956
1428
|
const slash = modelId.indexOf("/");
|
|
957
1429
|
const provider = slash >= 0 ? modelId.slice(0, slash) : ctx.model?.provider;
|
|
958
1430
|
const id = slash >= 0 ? modelId.slice(slash + 1) : modelId;
|
|
959
|
-
const
|
|
1431
|
+
const currentModel = ctx.model;
|
|
1432
|
+
if (currentModel && String(currentModel.provider) === String(provider) && String(currentModel.id ?? currentModel.model) === String(id)) {
|
|
1433
|
+
return currentModel;
|
|
1434
|
+
}
|
|
1435
|
+
const model = typeof ctx.modelRegistry?.find === "function" ? ctx.modelRegistry.find(provider, id) : undefined;
|
|
960
1436
|
if (!model) throw new Error(`Could not find OM model ${provider}/${id}`);
|
|
961
1437
|
return model;
|
|
962
1438
|
}
|
|
@@ -986,7 +1462,7 @@ function extractListItemsOnly(content: string): string {
|
|
|
986
1462
|
}
|
|
987
1463
|
|
|
988
1464
|
function sanitizeObservationLines(observations: string): string {
|
|
989
|
-
return observations.split("\n").map(line => line.length > MAX_OBSERVATION_LINE_CHARS ? `${line.slice(0, MAX_OBSERVATION_LINE_CHARS)} … [truncated]` : line).join("\n").trim();
|
|
1465
|
+
return redactSecrets(observations).split("\n").map(line => line.length > MAX_OBSERVATION_LINE_CHARS ? `${line.slice(0, MAX_OBSERVATION_LINE_CHARS)} … [truncated]` : line).join("\n").trim();
|
|
990
1466
|
}
|
|
991
1467
|
|
|
992
1468
|
function detectDegenerateRepetition(text: string): boolean {
|
|
@@ -1008,19 +1484,207 @@ function detectDegenerateRepetition(text: string): boolean {
|
|
|
1008
1484
|
}
|
|
1009
1485
|
|
|
1010
1486
|
function appendObservations(state: PiOMRecord, result: ObserverResult, inputTokens: number): void {
|
|
1011
|
-
const observations = result.observations.trim();
|
|
1012
|
-
if (!observations) throw new Error("No observations to append");
|
|
1487
|
+
const observations = suppressDuplicateObservations(state.observations, result.observations.trim());
|
|
1488
|
+
if (!observations) throw new Error("No new non-duplicate observations to append");
|
|
1013
1489
|
state.observations = [state.observations.trim(), observations].filter(Boolean).join("\n\n");
|
|
1014
1490
|
state.currentTask = result.currentTask ?? state.currentTask;
|
|
1015
1491
|
state.suggestedResponse = result.suggestedContinuation ?? state.suggestedResponse;
|
|
1016
1492
|
state.observationTokens = estimateTokens(state.observations);
|
|
1017
|
-
state.
|
|
1493
|
+
state.vectorIndex = buildLocalVectorIndex(state);
|
|
1494
|
+
state.lastOperation = { type: "observation", startedAt: state.operationLock?.startedAt ?? new Date().toISOString(), endedAt: new Date().toISOString(), inputTokens, outputTokens: estimateTokens(observations), model: state.settings.observationModel || DEFAULT_OBSERVATION_MODEL };
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function observationChunks(text: string): string[] {
|
|
1498
|
+
return text.split(/\n{2,}/).flatMap(block => {
|
|
1499
|
+
const lines = block.split("\n").map(l => l.trim()).filter(Boolean);
|
|
1500
|
+
if (lines.length <= 1) return lines;
|
|
1501
|
+
return lines.some(l => /^[-*]\s|^[🔴🟡🟢✅]/.test(l)) ? lines : [lines.join("\n")];
|
|
1502
|
+
}).map(redactSecrets).filter(Boolean);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
function normalizeForSimilarity(text: string): string {
|
|
1506
|
+
return text.toLowerCase().replace(/[🔴🟡🟢✅]/g, "").replace(/\([^)]*\)/g, "").replace(/[^a-z0-9а-яё\s._/-]/gi, " ").replace(/\s+/g, " ").trim();
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function textHash(text: string): string {
|
|
1510
|
+
return createHash("sha256").update(normalizeForSimilarity(text)).digest("hex");
|
|
1018
1511
|
}
|
|
1019
1512
|
|
|
1020
|
-
function
|
|
1513
|
+
function localEmbedding(text: string): number[] {
|
|
1514
|
+
const dims = 128;
|
|
1515
|
+
const vec = new Array(dims).fill(0);
|
|
1516
|
+
const words = normalizeForSimilarity(text).split(" ").filter(w => w.length > 2);
|
|
1517
|
+
for (const word of words) {
|
|
1518
|
+
const h = createHash("sha256").update(word).digest();
|
|
1519
|
+
const idx = h[0] % dims;
|
|
1520
|
+
vec[idx] += 1 + Math.min(3, word.length / 8);
|
|
1521
|
+
}
|
|
1522
|
+
const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)) || 1;
|
|
1523
|
+
return vec.map(v => Number((v / norm).toFixed(6)));
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function cosine(a: number[], b: number[]): number {
|
|
1527
|
+
const n = Math.min(a.length, b.length);
|
|
1528
|
+
let sum = 0;
|
|
1529
|
+
for (let i = 0; i < n; i++) sum += (a[i] ?? 0) * (b[i] ?? 0);
|
|
1530
|
+
return sum;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function suppressDuplicateObservations(existing: string, incoming: string): string {
|
|
1534
|
+
const existingChunks = observationChunks(existing);
|
|
1535
|
+
const existingHashes = new Set(existingChunks.map(textHash));
|
|
1536
|
+
const existingEmbeddings = existingChunks.map(localEmbedding);
|
|
1537
|
+
const kept: string[] = [];
|
|
1538
|
+
for (const chunk of observationChunks(incoming)) {
|
|
1539
|
+
const hash = textHash(chunk);
|
|
1540
|
+
if (existingHashes.has(hash)) continue;
|
|
1541
|
+
const emb = localEmbedding(chunk);
|
|
1542
|
+
if (existingEmbeddings.some(e => cosine(e, emb) >= 0.94)) continue;
|
|
1543
|
+
existingHashes.add(hash);
|
|
1544
|
+
existingEmbeddings.push(emb);
|
|
1545
|
+
kept.push(chunk);
|
|
1546
|
+
}
|
|
1547
|
+
return kept.join("\n").trim();
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function buildLocalVectorIndex(state: PiOMRecord): VectorIndexState | undefined {
|
|
1551
|
+
if (state.settings.retrieval.provider === "off") return undefined;
|
|
1552
|
+
const provider = state.settings.retrieval.provider;
|
|
1553
|
+
if (provider !== "local") return state.vectorIndex;
|
|
1554
|
+
const now = new Date().toISOString();
|
|
1555
|
+
const entries = observationChunks(state.observations).slice(-VECTOR_MAX_INDEX_CHUNKS).map((text, i) => ({
|
|
1556
|
+
id: `obs-${i}-${textHash(text).slice(0, 10)}`,
|
|
1557
|
+
hash: textHash(text),
|
|
1558
|
+
text,
|
|
1559
|
+
embedding: localEmbedding(text),
|
|
1560
|
+
tokens: estimateTokens(text),
|
|
1561
|
+
createdAt: now,
|
|
1562
|
+
}));
|
|
1563
|
+
return { provider: "local", model: state.settings.retrieval.model, scopeKey: scopeKeyForState(state), updatedAt: now, entries };
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
async function retrieveRelevantObservations(ctx: any, state: PiOMRecord, query: string): Promise<string> {
|
|
1567
|
+
const cfg = state.settings.retrieval;
|
|
1568
|
+
if (cfg.provider === "off" || !query.trim()) return "";
|
|
1569
|
+
if (cfg.provider === "gemini") return retrieveGeminiObservations(ctx, state, query);
|
|
1570
|
+
const index = state.vectorIndex?.provider === "local" ? state.vectorIndex : buildLocalVectorIndex(state);
|
|
1571
|
+
if (!index || index.entries.length === 0) return "";
|
|
1572
|
+
state.vectorIndex = index;
|
|
1573
|
+
const queryVec = localEmbedding(query);
|
|
1574
|
+
return index.entries
|
|
1575
|
+
.map(entry => ({ entry, score: cosine(queryVec, entry.embedding) }))
|
|
1576
|
+
.filter(x => x.score >= cfg.threshold)
|
|
1577
|
+
.sort((a, b) => b.score - a.score)
|
|
1578
|
+
.slice(0, cfg.topK)
|
|
1579
|
+
.map(x => `- (${x.score.toFixed(2)}) ${x.entry.text}`)
|
|
1580
|
+
.join("\n");
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
async function retrieveGeminiObservations(ctx: any, state: PiOMRecord, query: string): Promise<string> {
|
|
1584
|
+
const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY;
|
|
1585
|
+
if (!apiKey) throw new Error("OM Gemini retrieval requires GOOGLE_GENERATIVE_AI_API_KEY or GEMINI_API_KEY");
|
|
1586
|
+
const chunks = observationChunks(state.observations).slice(-VECTOR_MAX_INDEX_CHUNKS);
|
|
1587
|
+
if (chunks.length === 0) return "";
|
|
1588
|
+
const queryVec = await geminiEmbedding(apiKey, state.settings.retrieval.model, query);
|
|
1589
|
+
const entries = await Promise.all(chunks.map(async (text, i) => ({
|
|
1590
|
+
id: `gem-${i}-${textHash(text).slice(0, 10)}`,
|
|
1591
|
+
hash: textHash(text),
|
|
1592
|
+
text,
|
|
1593
|
+
embedding: await geminiEmbedding(apiKey, state.settings.retrieval.model, text),
|
|
1594
|
+
tokens: estimateTokens(text),
|
|
1595
|
+
createdAt: new Date().toISOString(),
|
|
1596
|
+
})));
|
|
1597
|
+
state.vectorIndex = { provider: "gemini", model: state.settings.retrieval.model, scopeKey: scopeKeyForState(state), updatedAt: new Date().toISOString(), entries };
|
|
1598
|
+
return entries
|
|
1599
|
+
.map(entry => ({ entry, score: cosine(queryVec, entry.embedding) }))
|
|
1600
|
+
.filter(x => x.score >= state.settings.retrieval.threshold)
|
|
1601
|
+
.sort((a, b) => b.score - a.score)
|
|
1602
|
+
.slice(0, state.settings.retrieval.topK)
|
|
1603
|
+
.map(x => `- (${x.score.toFixed(2)}) ${x.entry.text}`)
|
|
1604
|
+
.join("\n");
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
async function geminiEmbedding(apiKey: string, model: string, text: string): Promise<number[]> {
|
|
1608
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:embedContent?key=${encodeURIComponent(apiKey)}`;
|
|
1609
|
+
const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ content: { parts: [{ text: truncateText(redactSecrets(text), 20_000) }] } }) });
|
|
1610
|
+
if (!res.ok) throw new Error(`Gemini embedding failed: HTTP ${res.status} ${truncateText(await res.text(), 300)}`);
|
|
1611
|
+
const json = await res.json() as any;
|
|
1612
|
+
const values = json?.embedding?.values;
|
|
1613
|
+
if (!Array.isArray(values)) throw new Error("Gemini embedding response did not include embedding.values");
|
|
1614
|
+
const norm = Math.sqrt(values.reduce((s: number, v: number) => s + v * v, 0)) || 1;
|
|
1615
|
+
return values.map((v: number) => v / norm);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
async function buildProjectReferences(ctx: any, state: PiOMRecord): Promise<string> {
|
|
1620
|
+
if (state.scope !== "project") return "";
|
|
1621
|
+
const sessionsDir = join(ctx.cwd, CONFIG_DIR_NAME, "om", "sessions");
|
|
1622
|
+
if (!existsSync(sessionsDir)) return "";
|
|
1623
|
+
let files: string[] = [];
|
|
1624
|
+
try {
|
|
1625
|
+
files = (await readdir(sessionsDir)).filter(f => f.endsWith(".json") && f !== `${sanitizeFileName(state.sessionId)}.json`);
|
|
1626
|
+
} catch {
|
|
1627
|
+
return "";
|
|
1628
|
+
}
|
|
1629
|
+
const records: PiOMRecord[] = [];
|
|
1630
|
+
for (const file of files.slice(-20)) {
|
|
1631
|
+
try {
|
|
1632
|
+
const raw = JSON.parse(await readFile(join(sessionsDir, file), "utf8"));
|
|
1633
|
+
const normalized = normalizeState(raw, { sessionId: raw.sessionId || file.replace(/\.json$/, ""), sessionFile: raw.sessionFile, cwd: ctx.cwd });
|
|
1634
|
+
if (normalized.observations.trim()) records.push(normalized);
|
|
1635
|
+
} catch {
|
|
1636
|
+
// Skip unreadable references; main state remains authoritative.
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
records.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
1640
|
+
const blocks: string[] = [];
|
|
1641
|
+
let tokens = 0;
|
|
1642
|
+
for (const rec of records.slice(0, 5)) {
|
|
1643
|
+
const obs = limitTextByTokens(rec.observations, 500) || "";
|
|
1644
|
+
const block = `<other-session id="${sanitizeFileName(rec.sessionId)}" updated="${rec.updatedAt}">\n${obs}\n</other-session>`;
|
|
1645
|
+
tokens += estimateTokens(block);
|
|
1646
|
+
if (tokens > PROJECT_REFERENCES_MAX_TOKENS) break;
|
|
1647
|
+
blocks.push(block);
|
|
1648
|
+
}
|
|
1649
|
+
return blocks.join("\n\n");
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
async function rebuildVectorIndex(ctx: any, state: PiOMRecord): Promise<void> {
|
|
1653
|
+
if (state.settings.retrieval.provider === "off") {
|
|
1654
|
+
state.vectorIndex = undefined;
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
if (state.settings.retrieval.provider === "local") {
|
|
1658
|
+
state.vectorIndex = buildLocalVectorIndex(state);
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
if (state.settings.retrieval.provider === "gemini") {
|
|
1662
|
+
await retrieveGeminiObservations(ctx, state, state.observations.slice(0, 2_000) || "build index");
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
function formatVectorStatus(state: PiOMRecord): string {
|
|
1668
|
+
const idx = state.vectorIndex;
|
|
1669
|
+
return [
|
|
1670
|
+
"OM vector retrieval",
|
|
1671
|
+
`provider: ${state.settings.retrieval.provider}`,
|
|
1672
|
+
`model: ${state.settings.retrieval.model}`,
|
|
1673
|
+
`scopeKey: ${scopeKeyForState(state)}`,
|
|
1674
|
+
`topK: ${state.settings.retrieval.topK}`,
|
|
1675
|
+
`threshold: ${state.settings.retrieval.threshold}`,
|
|
1676
|
+
`index: ${idx ? `${idx.entries.length} entries, updated ${idx.updatedAt}` : "not built"}`,
|
|
1677
|
+
].join("\n");
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function buildOMContextMessage(state: PiOMRecord, retrievedObservations = "", projectReferences = ""): AgentMessage {
|
|
1681
|
+
const retrievalBlock = retrievedObservations ? `<relevant-observations>\n${retrievedObservations}\n</relevant-observations>` : "";
|
|
1682
|
+
const projectBlock = projectReferences ? `<other-sessions>\n${projectReferences}\n</other-sessions>` : "";
|
|
1021
1683
|
const sections = [
|
|
1022
1684
|
OBSERVATION_CONTEXT_PROMPT,
|
|
1023
1685
|
`<observations>\n${state.observations.trim()}\n</observations>`,
|
|
1686
|
+
retrievalBlock,
|
|
1687
|
+
projectBlock,
|
|
1024
1688
|
OBSERVATION_CONTEXT_INSTRUCTIONS,
|
|
1025
1689
|
state.currentTask ? `<current-task>\n${state.currentTask}\n</current-task>` : "",
|
|
1026
1690
|
state.suggestedResponse ? `<suggested-response>\n${state.suggestedResponse}\n</suggested-response>` : "",
|
|
@@ -1112,6 +1776,99 @@ function formatEntriesForObserver(entries: SessionEntry[]): string {
|
|
|
1112
1776
|
return entries.map(formatEntryForObserver).filter(Boolean).join("\n\n");
|
|
1113
1777
|
}
|
|
1114
1778
|
|
|
1779
|
+
function buildObserverInput(ctx: any, entries: SessionEntry[], state: PiOMRecord): { text: string; attachmentParts: any[]; omissions: string[] } {
|
|
1780
|
+
const model = resolveModel(ctx, state.settings.observationModel || DEFAULT_OBSERVATION_MODEL);
|
|
1781
|
+
const attachmentParts: any[] = [];
|
|
1782
|
+
const omissions: string[] = [];
|
|
1783
|
+
const text = entries.map(entry => formatEntryForObserverWithAttachments(entry, state, model, attachmentParts, omissions)).filter(Boolean).join("\n\n");
|
|
1784
|
+
return { text, attachmentParts, omissions };
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
function formatEntryForObserverWithAttachments(entry: SessionEntry, state: PiOMRecord, model: any, attachmentParts: any[], omissions: string[]): string {
|
|
1788
|
+
const msg = entryToAgentMessage(entry);
|
|
1789
|
+
const createdAt = new Date(entry.timestamp || msg?.timestamp || Date.now());
|
|
1790
|
+
const date = createdAt.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
1791
|
+
const time = createdAt.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
1792
|
+
const body = formatAgentMessageForObserverWithAttachments(msg, state, model, attachmentParts, omissions);
|
|
1793
|
+
return body ? `${date}:\n[entry ${entry.id}] ${time} ${body}` : "";
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
function formatAgentMessageForObserverWithAttachments(msg: any, state: PiOMRecord, model: any, attachmentParts: any[], omissions: string[]): string {
|
|
1797
|
+
if (!msg) return "";
|
|
1798
|
+
const cap = MESSAGE_PART_MAX_CHARS;
|
|
1799
|
+
switch (msg.role) {
|
|
1800
|
+
case "user": return `User: ${formatContentForObserverWithAttachments(msg.content, cap, state, model, attachmentParts, omissions)}`;
|
|
1801
|
+
case "assistant": return `Assistant: ${formatContentForObserverWithAttachments(msg.content, cap, state, model, attachmentParts, omissions)}`;
|
|
1802
|
+
case "toolResult": return `Tool Result ${msg.toolName}: ${truncateText(formatContentForObserverWithAttachments(msg.content, cap, state, model, attachmentParts, omissions), TOOL_RESULT_MAX_CHARS)}`;
|
|
1803
|
+
case "bashExecution": return `User Bash: ${msg.command}\nOutput: ${truncateText(msg.output ?? "", cap)}${msg.truncated ? "\n[output truncated by Pi]" : ""}`;
|
|
1804
|
+
case "custom": return msg.customType === EXTENSION_ID ? "" : `Custom ${msg.customType}: ${formatContentForObserverWithAttachments(msg.content, cap, state, model, attachmentParts, omissions)}`;
|
|
1805
|
+
case "compactionSummary": return `Previous Compaction Summary: ${truncateText(msg.summary ?? "", cap)}`;
|
|
1806
|
+
case "branchSummary": return `Branch Summary: ${truncateText(msg.summary ?? "", cap)}`;
|
|
1807
|
+
default: return `${msg.role ?? "Message"}: ${truncateText(JSON.stringify(redactDeep(msg)), cap)}`;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function formatContentForObserverWithAttachments(content: any, cap: number, state: PiOMRecord, model: any, attachmentParts: any[], omissions: string[]): string {
|
|
1812
|
+
if (typeof content === "string") return truncateText(redactSecrets(content), cap);
|
|
1813
|
+
if (!Array.isArray(content)) return truncateText(redactSecrets(JSON.stringify(redactDeep(content))), cap);
|
|
1814
|
+
return content.map(part => {
|
|
1815
|
+
if (part.type === "text") return redactSecrets(part.text);
|
|
1816
|
+
if (part.type === "thinking") return `[Thinking]: ${redactSecrets(part.thinking ?? "")}`;
|
|
1817
|
+
if (isAttachmentPart(part)) return handleObserverAttachmentPart(part, state, model, attachmentParts, omissions);
|
|
1818
|
+
if (part.type === "toolCall") return `[Tool Call ${part.name}: ${truncateText(redactSecrets(JSON.stringify(redactDeep(part.arguments ?? {}))), 2_000)}]`;
|
|
1819
|
+
return `[${part.type}: ${truncateText(redactSecrets(JSON.stringify(redactDeep(part))), 2_000)}]`;
|
|
1820
|
+
}).join("\n").slice(0, cap);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
function isAttachmentPart(part: any): boolean {
|
|
1824
|
+
return part?.type === "image" || part?.type === "file" || Boolean(part?.mimeType || part?.mediaType || part?.source?.mediaType);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
function modelSupportsAttachments(model: any): boolean {
|
|
1828
|
+
const provider = String(model?.provider || "").toLowerCase();
|
|
1829
|
+
const id = String(model?.id || model?.model || "").toLowerCase();
|
|
1830
|
+
if (provider.includes("google") || provider.includes("gemini")) return id.includes("gemini");
|
|
1831
|
+
if (provider.includes("openai")) return /gpt-4o|gpt-4\.1|o3|o4/.test(id);
|
|
1832
|
+
if (provider.includes("anthropic")) return /claude-3|claude-sonnet-4|claude-opus-4/.test(id);
|
|
1833
|
+
return false;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function handleObserverAttachmentPart(part: any, state: PiOMRecord, model: any, attachmentParts: any[], omissions: string[]): string {
|
|
1837
|
+
const mode = state.settings.observeAttachments;
|
|
1838
|
+
const mime = String(part.mimeType || part.mediaType || part.source?.mediaType || "unknown").toLowerCase();
|
|
1839
|
+
const raw = part.data || part.image || part.source?.data || "";
|
|
1840
|
+
const approxBytes = typeof raw === "string" ? Math.ceil(raw.length * 0.75) : 0;
|
|
1841
|
+
const label = `${mime}${approxBytes ? `, ~${approxBytes} bytes` : ""}`;
|
|
1842
|
+
if (mode === "off") return `[Attachment omitted: observation disabled; ${label}]`;
|
|
1843
|
+
if (!mime.startsWith("image/")) {
|
|
1844
|
+
const reason = `unsupported mime ${mime}`;
|
|
1845
|
+
omissions.push(reason);
|
|
1846
|
+
return `[Attachment omitted: ${reason}; ${label}]`;
|
|
1847
|
+
}
|
|
1848
|
+
if (approxBytes > MAX_ATTACHMENT_OBSERVE_BYTES) {
|
|
1849
|
+
const reason = `attachment too large ${approxBytes} > ${MAX_ATTACHMENT_OBSERVE_BYTES} bytes`;
|
|
1850
|
+
if (mode === "on") throw new Error(`OM attachment observation failed: ${reason}`);
|
|
1851
|
+
omissions.push(reason);
|
|
1852
|
+
return `[Attachment omitted: ${reason}; ${label}]`;
|
|
1853
|
+
}
|
|
1854
|
+
if (!modelSupportsAttachments(model)) {
|
|
1855
|
+
const reason = `unsupported observer model ${model?.provider ?? "unknown"}/${model?.id ?? "unknown"}`;
|
|
1856
|
+
if (mode === "on") throw new Error(`OM attachment observation failed: ${reason}`);
|
|
1857
|
+
omissions.push(reason);
|
|
1858
|
+
return `[Attachment omitted: ${reason}; ${label}]`;
|
|
1859
|
+
}
|
|
1860
|
+
const normalized = normalizeAttachmentPartForModel(part);
|
|
1861
|
+
attachmentParts.push(normalized);
|
|
1862
|
+
return `[Attachment included for Observer: ${label}]`;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function normalizeAttachmentPartForModel(part: any): any {
|
|
1866
|
+
const mimeType = part.mimeType || part.mediaType || part.source?.mediaType;
|
|
1867
|
+
const data = part.data || part.image || part.source?.data;
|
|
1868
|
+
if (part.type === "image") return { ...part, mimeType, data };
|
|
1869
|
+
return { type: "image", mimeType, data };
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1115
1872
|
function formatEntryForObserver(entry: SessionEntry): string {
|
|
1116
1873
|
const msg = entryToAgentMessage(entry);
|
|
1117
1874
|
const createdAt = new Date(entry.timestamp || msg?.timestamp || Date.now());
|
|
@@ -1136,15 +1893,72 @@ function formatAgentMessage(msg: any, mode: "observer" | "recall" = "observer",
|
|
|
1136
1893
|
}
|
|
1137
1894
|
}
|
|
1138
1895
|
|
|
1896
|
+
|
|
1897
|
+
function limitTextByTokens(text: string | undefined, maxTokens: number): string | undefined {
|
|
1898
|
+
if (!text) return text;
|
|
1899
|
+
if (estimateTokens(text) <= maxTokens) return text;
|
|
1900
|
+
const maxChars = Math.max(0, maxTokens * 4);
|
|
1901
|
+
const tail = text.slice(Math.max(0, text.length - maxChars));
|
|
1902
|
+
const lineBoundary = tail.indexOf("\n");
|
|
1903
|
+
const trimmedTail = lineBoundary >= 0 ? tail.slice(lineBoundary + 1) : tail;
|
|
1904
|
+
return `[Earlier observations truncated for observer prompt safety: kept last ~${maxTokens} tokens.]\n${trimmedTail}`;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function redactSecrets(input: string): string {
|
|
1908
|
+
if (!input) return input;
|
|
1909
|
+
return input
|
|
1910
|
+
.replace(/npm_[A-Za-z0-9]{16,}/g, "[REDACTED_NPM_TOKEN]")
|
|
1911
|
+
.replace(/github_pat_[A-Za-z0-9_]{20,}/g, "[REDACTED_GITHUB_TOKEN]")
|
|
1912
|
+
.replace(/gh[pousr]_[A-Za-z0-9_]{20,}/g, "[REDACTED_GITHUB_TOKEN]")
|
|
1913
|
+
.replace(/sk-[A-Za-z0-9_-]{20,}/g, "[REDACTED_API_KEY]")
|
|
1914
|
+
.replace(/(?<=(?:\b|["'])(?:api[_-]?key|token|secret|password)(?:\b|["'])\s*[:=]\s*["']?)[^"'\s,;}]{8,}/gi, "[REDACTED_SECRET]")
|
|
1915
|
+
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer [REDACTED_TOKEN]");
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function isSensitiveObjectKey(key: string): boolean {
|
|
1919
|
+
const normalized = key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
1920
|
+
return /^(api-?key|authorization|auth-?token|access-?token|refresh-?token|secret|password|bearer)$/i.test(normalized);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function redactDeep<T>(value: T): T {
|
|
1924
|
+
if (typeof value === "string") return redactSecrets(value) as T;
|
|
1925
|
+
if (Array.isArray(value)) return value.map(item => redactDeep(item)) as T;
|
|
1926
|
+
if (value && typeof value === "object") {
|
|
1927
|
+
const out: Record<string, unknown> = {};
|
|
1928
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
1929
|
+
out[key] = isSensitiveObjectKey(key) ? "[REDACTED_SECRET]" : redactDeep(child);
|
|
1930
|
+
}
|
|
1931
|
+
return out as T;
|
|
1932
|
+
}
|
|
1933
|
+
return value;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
function shouldObserveAttachment(part: any, state?: PiOMRecord): boolean {
|
|
1937
|
+
const mode = state?.settings.observeAttachments ?? "auto";
|
|
1938
|
+
if (mode === "off") return false;
|
|
1939
|
+
const mime = String(part.mimeType || part.mediaType || "").toLowerCase();
|
|
1940
|
+
if (mode === "auto" && mime && !mime.startsWith("image/")) return false;
|
|
1941
|
+
const raw = part.data || part.image || part.source?.data || "";
|
|
1942
|
+
const approxBytes = typeof raw === "string" ? Math.ceil(raw.length * 0.75) : 0;
|
|
1943
|
+
return approxBytes <= MAX_ATTACHMENT_OBSERVE_BYTES;
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function formatAttachmentPart(part: any): string {
|
|
1947
|
+
const mime = part.mimeType || part.mediaType || "unknown";
|
|
1948
|
+
const raw = part.data || part.image || part.source?.data || "";
|
|
1949
|
+
const approxBytes = typeof raw === "string" ? Math.ceil(raw.length * 0.75) : 0;
|
|
1950
|
+
return `[Attachment observed: ${mime}${approxBytes ? `, ~${approxBytes} bytes` : ""}]`;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1139
1953
|
function formatContent(content: any, cap: number): string {
|
|
1140
|
-
if (typeof content === "string") return truncateText(content, cap);
|
|
1141
|
-
if (!Array.isArray(content)) return truncateText(JSON.stringify(content), cap);
|
|
1954
|
+
if (typeof content === "string") return truncateText(redactSecrets(content), cap);
|
|
1955
|
+
if (!Array.isArray(content)) return truncateText(redactSecrets(JSON.stringify(redactDeep(content))), cap);
|
|
1142
1956
|
return content.map(part => {
|
|
1143
|
-
if (part.type === "text") return part.text;
|
|
1144
|
-
if (part.type === "thinking") return `[Thinking]: ${part.thinking}`;
|
|
1145
|
-
if (part.type === "image") return
|
|
1146
|
-
if (part.type === "toolCall") return `[Tool Call ${part.name}: ${truncateText(JSON.stringify(part.arguments ?? {}), 2_000)}]`;
|
|
1147
|
-
return `[${part.type}: ${truncateText(JSON.stringify(part), 2_000)}]`;
|
|
1957
|
+
if (part.type === "text") return redactSecrets(part.text);
|
|
1958
|
+
if (part.type === "thinking") return `[Thinking]: ${redactSecrets(part.thinking ?? "")}`;
|
|
1959
|
+
if (part.type === "image") return shouldObserveAttachment(part, runtime.state) ? formatAttachmentPart(part) : "[Image omitted: attachment observation disabled or unsupported]";
|
|
1960
|
+
if (part.type === "toolCall") return `[Tool Call ${part.name}: ${truncateText(redactSecrets(JSON.stringify(redactDeep(part.arguments ?? {}))), 2_000)}]`;
|
|
1961
|
+
return `[${part.type}: ${truncateText(redactSecrets(JSON.stringify(redactDeep(part))), 2_000)}]`;
|
|
1148
1962
|
}).join("\n").slice(0, cap);
|
|
1149
1963
|
}
|
|
1150
1964
|
|
|
@@ -1179,9 +1993,9 @@ function truncateText(text: string, maxChars: number): string {
|
|
|
1179
1993
|
}
|
|
1180
1994
|
|
|
1181
1995
|
function responseText(response: any): string {
|
|
1182
|
-
if (typeof response?.text === "string") return response.text;
|
|
1183
|
-
if (Array.isArray(response?.content)) return response.content.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n");
|
|
1184
|
-
if (typeof response === "string") return response;
|
|
1996
|
+
if (typeof response?.text === "string") return redactSecrets(response.text);
|
|
1997
|
+
if (Array.isArray(response?.content)) return redactSecrets(response.content.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n"));
|
|
1998
|
+
if (typeof response === "string") return redactSecrets(response);
|
|
1185
1999
|
return "";
|
|
1186
2000
|
}
|
|
1187
2001
|
|
|
@@ -1219,6 +2033,101 @@ function formatShortStatusColored(state: PiOMRecord): string {
|
|
|
1219
2033
|
return `om: ${statusColor}${state.status}\x1b[0m \x1b[2;37m|\x1b[0m msg \x1b[1;33m${formatTokens(state.pendingMessageTokens)}\x1b[0m/\x1b[2;37m${formatTokens(effectiveObservationThreshold(state))}\x1b[0m [${msgPctStr}] \x1b[2;37m|\x1b[0m mem \x1b[1;36m${formatTokens(state.observationTokens)}\x1b[0m/\x1b[2;37m${formatTokens(state.thresholds.reflection)}\x1b[0m [${memPctStr}]${buffer}${err}`;
|
|
1220
2034
|
}
|
|
1221
2035
|
|
|
2036
|
+
function parsePositiveIntSetting(key: string, value: string): number {
|
|
2037
|
+
const n = Number(value);
|
|
2038
|
+
if (!Number.isFinite(n) || n <= 0) throw new Error(`OM setting ${key} must be a positive number`);
|
|
2039
|
+
return Math.round(n);
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function parseRatioSetting(key: string, value: string): number {
|
|
2043
|
+
const raw = value.trim();
|
|
2044
|
+
const n = raw.endsWith("%") ? Number(raw.slice(0, -1)) / 100 : Number(raw);
|
|
2045
|
+
if (!Number.isFinite(n) || n <= 0 || n > 1) throw new Error(`OM setting ${key} must be a ratio between 0 and 1, or percent like 80%`);
|
|
2046
|
+
return n;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function parseBooleanSetting(key: string, value: string): boolean {
|
|
2050
|
+
const normalized = value.trim().toLowerCase();
|
|
2051
|
+
if (["1", "true", "yes", "on", "enabled"].includes(normalized)) return true;
|
|
2052
|
+
if (["0", "false", "no", "off", "disabled"].includes(normalized)) return false;
|
|
2053
|
+
throw new Error(`OM setting ${key} must be on/off or true/false`);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
function parseAttachmentSetting(key: string, value: string): ObserveAttachmentsMode {
|
|
2057
|
+
const normalized = value.trim().toLowerCase();
|
|
2058
|
+
if (["auto", "default"].includes(normalized)) return "auto";
|
|
2059
|
+
if (["1", "true", "yes", "on", "enabled"].includes(normalized)) return "on";
|
|
2060
|
+
if (["0", "false", "no", "off", "disabled"].includes(normalized)) return "off";
|
|
2061
|
+
throw new Error(`OM setting ${key} must be auto/on/off`);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
function parseRetrievalProvider(key: string, value: string): RetrievalProvider {
|
|
2065
|
+
const normalized = value.trim().toLowerCase();
|
|
2066
|
+
if (["off", "local", "gemini"].includes(normalized)) return normalized as RetrievalProvider;
|
|
2067
|
+
throw new Error(`OM setting ${key} must be off, local, or gemini`);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
function applySetting(state: PiOMRecord, key: string | undefined, value: string): void {
|
|
2071
|
+
if (!key) throw new Error("Missing OM setting key");
|
|
2072
|
+
if (!value) throw new Error(`Missing OM setting value for ${key}`);
|
|
2073
|
+
const normalized = key.toLowerCase().replace(/_/g, "-");
|
|
2074
|
+
if (["observation", "observation-threshold", "observe-threshold"].includes(normalized)) state.thresholds.observation = parsePositiveIntSetting(key, value);
|
|
2075
|
+
else if (["reflection", "reflection-threshold", "reflect-threshold"].includes(normalized)) state.thresholds.reflection = parsePositiveIntSetting(key, value);
|
|
2076
|
+
else if (["block-after", "blockafter"].includes(normalized)) state.thresholds.blockAfter = parsePositiveIntSetting(key, value);
|
|
2077
|
+
else if (["buffer", "buffer-tokens"].includes(normalized)) state.thresholds.bufferTokens = parsePositiveIntSetting(key, value);
|
|
2078
|
+
else if (["buffer-activation", "activation-ratio"].includes(normalized)) state.thresholds.bufferActivation = parseRatioSetting(key, value);
|
|
2079
|
+
else if (["observation-model", "observer-model", "observe-model"].includes(normalized)) state.settings.observationModel = value.trim();
|
|
2080
|
+
else if (["reflection-model", "reflector-model", "reflect-model"].includes(normalized)) state.settings.reflectionModel = value.trim();
|
|
2081
|
+
else if (normalized === "caveman") state.settings.caveman = parseBooleanSetting(key, value);
|
|
2082
|
+
else if (["attachments", "observe-attachments", "attachment-observation"].includes(normalized)) state.settings.observeAttachments = parseAttachmentSetting(key, value);
|
|
2083
|
+
else if (["retrieval", "retrieval-provider", "vector-provider"].includes(normalized)) {
|
|
2084
|
+
state.settings.retrieval.provider = parseRetrievalProvider(key, value);
|
|
2085
|
+
if (state.settings.retrieval.provider === "gemini" && state.settings.retrieval.model === "local/hash-bow-v1") state.settings.retrieval.model = "gemini-embedding-001";
|
|
2086
|
+
if (state.settings.retrieval.provider === "local" && state.settings.retrieval.model === "gemini-embedding-001") state.settings.retrieval.model = "local/hash-bow-v1";
|
|
2087
|
+
state.vectorIndex = undefined;
|
|
2088
|
+
}
|
|
2089
|
+
else if (["retrieval-model", "embedding-model", "vector-model"].includes(normalized)) { state.settings.retrieval.model = value.trim(); state.vectorIndex = undefined; }
|
|
2090
|
+
else if (["retrieval-top-k", "top-k", "vector-top-k"].includes(normalized)) state.settings.retrieval.topK = clampInt(parsePositiveIntSetting(key, value), 1, 30);
|
|
2091
|
+
else if (["retrieval-threshold", "vector-threshold"].includes(normalized)) state.settings.retrieval.threshold = parseRatioSetting(key, value);
|
|
2092
|
+
else if (normalized === "scope") {
|
|
2093
|
+
const scope = value.trim().toLowerCase();
|
|
2094
|
+
if (scope !== "session" && scope !== "project") throw new Error("OM scope must be session or project");
|
|
2095
|
+
if (state.scope !== scope) state.vectorIndex = undefined;
|
|
2096
|
+
state.scope = scope;
|
|
2097
|
+
} else {
|
|
2098
|
+
throw new Error(`Unknown OM setting: ${key}`);
|
|
2099
|
+
}
|
|
2100
|
+
if (state.thresholds.blockAfter < state.thresholds.observation) state.thresholds.blockAfter = Math.round(state.thresholds.observation * DEFAULT_BLOCK_AFTER_MULTIPLIER);
|
|
2101
|
+
state.observationTokens = estimateTokens(state.observations);
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function formatSettingsText(state: PiOMRecord): string {
|
|
2105
|
+
return [
|
|
2106
|
+
formatDetailedStatus(state),
|
|
2107
|
+
"",
|
|
2108
|
+
"settings:",
|
|
2109
|
+
` observation-model: ${state.settings.observationModel}`,
|
|
2110
|
+
` reflection-model: ${state.settings.reflectionModel}`,
|
|
2111
|
+
` caveman: ${state.settings.caveman ? "on" : "off"}`,
|
|
2112
|
+
` attachments: ${state.settings.observeAttachments}`,
|
|
2113
|
+
` scope: ${state.scope}`,
|
|
2114
|
+
` retrieval: ${state.settings.retrieval.provider}`,
|
|
2115
|
+
` retrieval-model: ${state.settings.retrieval.model}`,
|
|
2116
|
+
` retrieval-top-k: ${state.settings.retrieval.topK}`,
|
|
2117
|
+
` retrieval-threshold: ${state.settings.retrieval.threshold}`,
|
|
2118
|
+
` vector-index: ${state.vectorIndex ? `${state.vectorIndex.entries.length} entries (${state.vectorIndex.provider}/${state.vectorIndex.model})` : "none"}`,
|
|
2119
|
+
"",
|
|
2120
|
+
"usage:",
|
|
2121
|
+
" /om set observation-threshold 30000",
|
|
2122
|
+
" /om set reflection-threshold 40000",
|
|
2123
|
+
" /om set observation-model google/gemini-2.5-flash",
|
|
2124
|
+
" /om set caveman on",
|
|
2125
|
+
" /om set retrieval local",
|
|
2126
|
+
" /om vector status|rebuild|search <query>",
|
|
2127
|
+
" /om reset",
|
|
2128
|
+
].join("\n");
|
|
2129
|
+
}
|
|
2130
|
+
|
|
1222
2131
|
function formatDetailedStatus(state: PiOMRecord): string {
|
|
1223
2132
|
return `${formatShortStatus(state)}\nlastObservedEntryId: ${state.lastObservedEntryId ?? "none"}\ncurrentTask: ${state.currentTask ?? "none"}\nsuggestedResponse: ${state.suggestedResponse ?? "none"}\nlastOperation: ${state.lastOperation ? JSON.stringify(state.lastOperation, null, 2) : "none"}\nstatePath: ${runtime.statePath ?? "unknown"}`;
|
|
1224
2133
|
}
|
|
@@ -1402,7 +2311,7 @@ async function writeDebug(ctx: any, name: string, payload: unknown): Promise<voi
|
|
|
1402
2311
|
const dir = runtime.debugDir || join(ctx.cwd, CONFIG_DIR_NAME, "om", "debug");
|
|
1403
2312
|
await mkdir(dir, { recursive: true });
|
|
1404
2313
|
const file = join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${sanitizeFileName(name)}.json`);
|
|
1405
|
-
await writeFile(file, JSON.stringify({ extension: EXTENSION_ID, sessionId: state.sessionId, ...payload as any }, null, 2) + "\n", "utf8");
|
|
2314
|
+
await writeFile(file, JSON.stringify(redactDeep({ extension: EXTENSION_ID, sessionId: state.sessionId, ...payload as any }), null, 2) + "\n", "utf8");
|
|
1406
2315
|
} catch (error) {
|
|
1407
2316
|
// Ignore debug write errors if context is stale
|
|
1408
2317
|
}
|
|
@@ -1427,6 +2336,30 @@ function mergeAbortSignals(a?: AbortSignal, b?: AbortSignal): AbortSignal | unde
|
|
|
1427
2336
|
return controller.signal;
|
|
1428
2337
|
}
|
|
1429
2338
|
|
|
2339
|
+
export const __test = {
|
|
2340
|
+
redactSecrets,
|
|
2341
|
+
redactDeep,
|
|
2342
|
+
limitTextByTokens,
|
|
2343
|
+
buildObserverTaskPrompt,
|
|
2344
|
+
defaultSettings,
|
|
2345
|
+
defaultThresholds,
|
|
2346
|
+
createDefaultState,
|
|
2347
|
+
normalizeState,
|
|
2348
|
+
isStaleOperationLock,
|
|
2349
|
+
recoverStaleOperationLock,
|
|
2350
|
+
atomicWriteJson,
|
|
2351
|
+
applySetting,
|
|
2352
|
+
formatSettingsText,
|
|
2353
|
+
suppressDuplicateObservations,
|
|
2354
|
+
buildLocalVectorIndex,
|
|
2355
|
+
retrieveRelevantObservations,
|
|
2356
|
+
buildObserverInput,
|
|
2357
|
+
modelSupportsAttachments,
|
|
2358
|
+
formatVectorStatus,
|
|
2359
|
+
shouldObserveAttachment,
|
|
2360
|
+
geminiEmbedding,
|
|
2361
|
+
};
|
|
2362
|
+
|
|
1430
2363
|
class OMOverlay {
|
|
1431
2364
|
private scroll = 0;
|
|
1432
2365
|
private tab: "memory" | "status" | "debug" = "memory";
|