pi-observational-memory-extension 0.1.3 → 0.2.1
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 +56 -0
- package/README.md +16 -4
- package/extensions/index.ts +798 -49
- package/package.json +1 -1
- package/scripts/test.mjs +65 -4
package/extensions/index.ts
CHANGED
|
@@ -7,17 +7,21 @@ 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 { copyFile, mkdir, readFile, rename, unlink, 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;
|
|
19
21
|
const REFLECTION_THRESHOLD = 40_000;
|
|
20
22
|
const OBSERVER_MAX_BATCH_TOKENS = 10_000;
|
|
23
|
+
const MAX_PENDING_BACKLOG_TOKENS = OBSERVER_MAX_BATCH_TOKENS * 12;
|
|
24
|
+
const RETAIN_PENDING_TAIL_TOKENS = OBSERVER_MAX_BATCH_TOKENS * 3;
|
|
21
25
|
const BUFFER_TOKENS_RATIO = 0.2;
|
|
22
26
|
const BUFFER_ACTIVATION_RATIO = 0.8;
|
|
23
27
|
const DEFAULT_BLOCK_AFTER_MULTIPLIER = 1.2;
|
|
@@ -28,6 +32,11 @@ const MAX_RESTART_ERRORS = 3;
|
|
|
28
32
|
const STALE_OPERATION_LOCK_MS = 15 * 60 * 1000;
|
|
29
33
|
const PREVIOUS_OBSERVATIONS_MAX_TOKENS = 2_000;
|
|
30
34
|
const ATOMIC_BACKUP_SUFFIX = ".bak";
|
|
35
|
+
const MAX_ATTACHMENT_OBSERVE_BYTES = 2_000_000;
|
|
36
|
+
const VECTOR_TOP_K_DEFAULT = 6;
|
|
37
|
+
const VECTOR_THRESHOLD_DEFAULT = 0.12;
|
|
38
|
+
const PROJECT_REFERENCES_MAX_TOKENS = 2_000;
|
|
39
|
+
const VECTOR_MAX_INDEX_CHUNKS = 2_000;
|
|
31
40
|
|
|
32
41
|
const OBSERVATION_CONTEXT_PROMPT = `The following observations block contains your memory of past conversations with this user.`;
|
|
33
42
|
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.
|
|
@@ -52,6 +61,29 @@ Your memory is comprised of observations which may mention raw message IDs. The
|
|
|
52
61
|
|
|
53
62
|
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.`;
|
|
54
63
|
|
|
64
|
+
const CAVEMAN_OM_INSTRUCTION = `Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
|
65
|
+
|
|
66
|
+
Use full caveman compression style.
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
70
|
+
Pattern: \`[thing] [action] [reason]. [next step]\`
|
|
71
|
+
|
|
72
|
+
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
|
73
|
+
Yes: "Bug in auth middleware. Token expiry check use < not <=. Fix:"
|
|
74
|
+
|
|
75
|
+
Example 1
|
|
76
|
+
🔴 14:31 user asks why React component rerenders
|
|
77
|
+
🟡 14:32 saw inline object prop create new ref each render, cause rerender
|
|
78
|
+
✅ 14:34 fixed render issue by wrap object in useMemo
|
|
79
|
+
|
|
80
|
+
Example 2
|
|
81
|
+
🟡 15:10 explained pool reuse DB connections, skip repeat handshake overhead
|
|
82
|
+
|
|
83
|
+
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"
|
|
84
|
+
|
|
85
|
+
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`;
|
|
86
|
+
|
|
55
87
|
const OBSERVER_EXTRACTION_INSTRUCTIONS = `CRITICAL: DISTINGUISH USER ASSERTIONS FROM QUESTIONS
|
|
56
88
|
|
|
57
89
|
When the user TELLS you something about themselves, mark it as an assertion:
|
|
@@ -169,7 +201,7 @@ const OBSERVER_GUIDELINES = `- Be specific enough for the assistant to act on
|
|
|
169
201
|
- If the user provides detailed messages or code snippets, observe all important details`;
|
|
170
202
|
|
|
171
203
|
function buildObserverSystemPrompt(caveman = false): string {
|
|
172
|
-
const cavemanInstruction = caveman ? `\n\
|
|
204
|
+
const cavemanInstruction = caveman ? `\n\n${CAVEMAN_OM_INSTRUCTION}` : "";
|
|
173
205
|
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.
|
|
174
206
|
|
|
175
207
|
Extract observations that will help the assistant remember:
|
|
@@ -217,7 +249,7 @@ function buildObserverTaskPrompt(existingObservations: string | undefined, opts:
|
|
|
217
249
|
}
|
|
218
250
|
|
|
219
251
|
function buildReflectorSystemPrompt(caveman = false): string {
|
|
220
|
-
const cavemanInstruction = caveman ? `\n\
|
|
252
|
+
const cavemanInstruction = caveman ? `\n\n${CAVEMAN_OM_INSTRUCTION}` : "";
|
|
221
253
|
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.
|
|
222
254
|
|
|
223
255
|
The following instructions were given to another part of your psyche (the observer) to create memories.
|
|
@@ -299,11 +331,20 @@ type BufferedChunk = {
|
|
|
299
331
|
createdAt: string;
|
|
300
332
|
};
|
|
301
333
|
|
|
334
|
+
type ObserveAttachmentsMode = "auto" | "on" | "off";
|
|
335
|
+
type RetrievalProvider = "off" | "local" | "gemini";
|
|
336
|
+
|
|
302
337
|
type PiOMSettings = {
|
|
303
338
|
observationModel: string;
|
|
304
339
|
reflectionModel: string;
|
|
305
340
|
caveman: boolean;
|
|
306
|
-
observeAttachments:
|
|
341
|
+
observeAttachments: ObserveAttachmentsMode;
|
|
342
|
+
retrieval: {
|
|
343
|
+
provider: RetrievalProvider;
|
|
344
|
+
model: string;
|
|
345
|
+
topK: number;
|
|
346
|
+
threshold: number;
|
|
347
|
+
};
|
|
307
348
|
};
|
|
308
349
|
|
|
309
350
|
type PiOMRecord = {
|
|
@@ -328,9 +369,27 @@ type PiOMRecord = {
|
|
|
328
369
|
lastError?: string;
|
|
329
370
|
lastProvider?: string;
|
|
330
371
|
lastModel?: string;
|
|
372
|
+
vectorIndex?: VectorIndexState;
|
|
331
373
|
updatedAt: string;
|
|
332
374
|
};
|
|
333
375
|
|
|
376
|
+
type VectorIndexEntry = {
|
|
377
|
+
id: string;
|
|
378
|
+
hash: string;
|
|
379
|
+
text: string;
|
|
380
|
+
embedding: number[];
|
|
381
|
+
tokens: number;
|
|
382
|
+
createdAt: string;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
type VectorIndexState = {
|
|
386
|
+
provider: RetrievalProvider;
|
|
387
|
+
model: string;
|
|
388
|
+
scopeKey: string;
|
|
389
|
+
updatedAt: string;
|
|
390
|
+
entries: VectorIndexEntry[];
|
|
391
|
+
};
|
|
392
|
+
|
|
334
393
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
335
394
|
type AgentMessage = any;
|
|
336
395
|
|
|
@@ -350,6 +409,61 @@ type Runtime = {
|
|
|
350
409
|
|
|
351
410
|
const runtime: Runtime = { failureCount: 0 };
|
|
352
411
|
|
|
412
|
+
let checkedForUpdate = false;
|
|
413
|
+
let updateNotification: string | undefined = undefined;
|
|
414
|
+
|
|
415
|
+
function isNewerVersion(local: string, latest: string): boolean {
|
|
416
|
+
const localParts = local.split(".").map((x) => parseInt(x, 10) || 0);
|
|
417
|
+
const latestParts = latest.split(".").map((x) => parseInt(x, 10) || 0);
|
|
418
|
+
for (let i = 0; i < 3; i++) {
|
|
419
|
+
const localPart = localParts[i] ?? 0;
|
|
420
|
+
const latestPart = latestParts[i] ?? 0;
|
|
421
|
+
if (latestPart > localPart) return true;
|
|
422
|
+
if (latestPart < localPart) return false;
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function checkForUpdates(ctx: any) {
|
|
428
|
+
try {
|
|
429
|
+
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
430
|
+
const pkgPath = join(extensionDir, "..", "package.json");
|
|
431
|
+
if (!existsSync(pkgPath)) return;
|
|
432
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
433
|
+
const localVersion = pkg.version;
|
|
434
|
+
if (!localVersion) return;
|
|
435
|
+
|
|
436
|
+
if (typeof fetch === "undefined") return;
|
|
437
|
+
|
|
438
|
+
const controller = new AbortController();
|
|
439
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
440
|
+
|
|
441
|
+
try {
|
|
442
|
+
const res = await fetch("https://registry.npmjs.org/pi-observational-memory-extension/latest", {
|
|
443
|
+
signal: controller.signal,
|
|
444
|
+
headers: {
|
|
445
|
+
"User-Agent": `pi-observational-memory-update-checker/${localVersion}`
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
clearTimeout(timeoutId);
|
|
449
|
+
|
|
450
|
+
if (!res.ok) return;
|
|
451
|
+
const data = (await res.json()) as { version?: string };
|
|
452
|
+
const latestVersion = data.version;
|
|
453
|
+
if (!latestVersion) return;
|
|
454
|
+
|
|
455
|
+
if (isNewerVersion(localVersion, latestVersion)) {
|
|
456
|
+
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!`;
|
|
457
|
+
ctx?.ui?.notify?.(updateNotification, "warning");
|
|
458
|
+
}
|
|
459
|
+
} catch {
|
|
460
|
+
clearTimeout(timeoutId);
|
|
461
|
+
}
|
|
462
|
+
} catch {
|
|
463
|
+
// Fail silently
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
353
467
|
export default function (pi: ExtensionAPI) {
|
|
354
468
|
pi.registerTool({
|
|
355
469
|
name: "om_recall",
|
|
@@ -384,10 +498,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
384
498
|
});
|
|
385
499
|
|
|
386
500
|
pi.on("session_start", async (_event, ctx) => {
|
|
387
|
-
await
|
|
388
|
-
|
|
389
|
-
updateStatus(ctx);
|
|
501
|
+
await bootstrapStatus(ctx, "session_start");
|
|
502
|
+
if (runtime.statusTimer) clearInterval(runtime.statusTimer);
|
|
390
503
|
runtime.statusTimer = setInterval(() => updateStatus(ctx), 1000);
|
|
504
|
+
|
|
505
|
+
if (!checkedForUpdate) {
|
|
506
|
+
checkedForUpdate = true;
|
|
507
|
+
checkForUpdates(ctx).catch(() => {});
|
|
508
|
+
} else if (updateNotification) {
|
|
509
|
+
ctx?.ui?.notify?.(updateNotification, "warning");
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
514
|
+
await bootstrapStatus(ctx, "agent_start");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
518
|
+
await bootstrapStatus(ctx, "turn_start");
|
|
391
519
|
});
|
|
392
520
|
|
|
393
521
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
@@ -451,7 +579,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
451
579
|
.filter(isMessageLikeEntry)
|
|
452
580
|
.map(entryToAgentMessage)
|
|
453
581
|
.filter(Boolean) as AgentMessage[];
|
|
454
|
-
const
|
|
582
|
+
const queryText = unobserved.map(m => formatAgentMessage(m)).join("\n\n");
|
|
583
|
+
const retrieved = await retrieveRelevantObservations(ctx, state, queryText);
|
|
584
|
+
const projectReferences = await buildProjectReferences(ctx, state);
|
|
585
|
+
const omMessage = buildOMContextMessage(state, retrieved, projectReferences);
|
|
455
586
|
const finalMessages = [omMessage, ...unobserved];
|
|
456
587
|
if (finalMessages.length <= 1) return { messages: [omMessage, ...event.messages.slice(-8)] };
|
|
457
588
|
return { messages: finalMessages };
|
|
@@ -624,6 +755,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
624
755
|
ctx.ui.notify(formatMemoryText(state), "info");
|
|
625
756
|
return;
|
|
626
757
|
}
|
|
758
|
+
if (cmd === "vector") {
|
|
759
|
+
const sub = rest[0] || "status";
|
|
760
|
+
if (sub === "status") {
|
|
761
|
+
ctx.ui.notify(formatVectorStatus(state), "info");
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (sub === "rebuild") {
|
|
765
|
+
await rebuildVectorIndex(ctx, state);
|
|
766
|
+
await saveState(state);
|
|
767
|
+
ctx.ui.notify(formatVectorStatus(state), "info");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (sub === "search") {
|
|
771
|
+
const query = rest.slice(1).join(" ").trim();
|
|
772
|
+
if (!query) throw new Error("Usage: /om vector search <query>");
|
|
773
|
+
const result = await retrieveRelevantObservations(ctx, state, query);
|
|
774
|
+
ctx.ui.notify(result || "No matching OM vector results.", "info");
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
throw new Error("Unknown /om vector command. Use /om vector status|rebuild|search <query>");
|
|
778
|
+
}
|
|
627
779
|
if (cmd === "set") {
|
|
628
780
|
const key = rest[0];
|
|
629
781
|
const value = rest.slice(1).join(" ");
|
|
@@ -667,7 +819,13 @@ async function safeBoundary(ctx: any, boundary: string): Promise<void> {
|
|
|
667
819
|
if (isStale) return;
|
|
668
820
|
|
|
669
821
|
const state = await ensureState(ctx);
|
|
670
|
-
if (!state.enabled
|
|
822
|
+
if (!state.enabled) return;
|
|
823
|
+
if (state.status === "failed") {
|
|
824
|
+
state.status = "idle";
|
|
825
|
+
state.lastError = undefined;
|
|
826
|
+
state.operationLock = undefined;
|
|
827
|
+
await saveState(state);
|
|
828
|
+
}
|
|
671
829
|
await refreshCounts(ctx);
|
|
672
830
|
updateStatus(ctx);
|
|
673
831
|
if (runtime.currentOperation) return;
|
|
@@ -724,38 +882,100 @@ async function ensureState(ctx: any): Promise<PiOMRecord> {
|
|
|
724
882
|
const dir = join(ctx.cwd, CONFIG_DIR_NAME, "om");
|
|
725
883
|
const debugDir = join(dir, "debug");
|
|
726
884
|
await mkdir(debugDir, { recursive: true });
|
|
727
|
-
const
|
|
728
|
-
runtime.statePath = statePath;
|
|
885
|
+
const legacyStatePath = join(dir, `${sanitizeFileName(sessionId)}.json`);
|
|
729
886
|
runtime.debugDir = debugDir;
|
|
730
887
|
let state: PiOMRecord | undefined;
|
|
731
|
-
|
|
888
|
+
let statePath = legacyStatePath;
|
|
889
|
+
let recoveryWarning: string | undefined;
|
|
890
|
+
if (existsSync(legacyStatePath)) {
|
|
732
891
|
try {
|
|
733
|
-
state = normalizeState(JSON.parse(await readFile(
|
|
892
|
+
state = normalizeState(JSON.parse(await readFile(legacyStatePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
734
893
|
} catch (error) {
|
|
735
|
-
const backupPath = `${
|
|
894
|
+
const backupPath = `${legacyStatePath}${ATOMIC_BACKUP_SUFFIX}`;
|
|
736
895
|
if (existsSync(backupPath)) {
|
|
737
896
|
try {
|
|
738
897
|
state = normalizeState(JSON.parse(await readFile(backupPath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
739
898
|
} catch {
|
|
740
|
-
|
|
899
|
+
recoveryWarning = `Recovered from corrupted OM state and backup at ${legacyStatePath}; moved them to .corrupted files.`;
|
|
900
|
+
try {
|
|
901
|
+
await rename(legacyStatePath, `${legacyStatePath}.corrupted`);
|
|
902
|
+
await rename(backupPath, `${backupPath}.corrupted`);
|
|
903
|
+
} catch {}
|
|
741
904
|
}
|
|
742
905
|
} else {
|
|
743
|
-
|
|
906
|
+
recoveryWarning = `Recovered from corrupted OM state at ${legacyStatePath}; moved it to a .corrupted file.`;
|
|
907
|
+
try {
|
|
908
|
+
await rename(legacyStatePath, `${legacyStatePath}.corrupted`);
|
|
909
|
+
} catch {}
|
|
744
910
|
}
|
|
745
911
|
}
|
|
746
912
|
}
|
|
747
913
|
if (!state) {
|
|
748
914
|
state = createDefaultState({ sessionId, sessionFile, cwd: ctx.cwd });
|
|
749
|
-
|
|
915
|
+
if (recoveryWarning) state.lastError = recoveryWarning;
|
|
750
916
|
}
|
|
917
|
+
statePath = statePathFor(ctx.cwd, state.scope, sessionId);
|
|
918
|
+
if (statePath !== legacyStatePath && existsSync(statePath)) {
|
|
919
|
+
try {
|
|
920
|
+
state = normalizeState(JSON.parse(await readFile(statePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
921
|
+
} catch {
|
|
922
|
+
const backupPath = `${statePath}${ATOMIC_BACKUP_SUFFIX}`;
|
|
923
|
+
if (existsSync(backupPath)) {
|
|
924
|
+
try {
|
|
925
|
+
state = normalizeState(JSON.parse(await readFile(backupPath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
926
|
+
} catch {
|
|
927
|
+
try {
|
|
928
|
+
await rename(statePath, `${statePath}.corrupted`);
|
|
929
|
+
await rename(backupPath, `${backupPath}.corrupted`);
|
|
930
|
+
} catch {}
|
|
931
|
+
}
|
|
932
|
+
} else {
|
|
933
|
+
try {
|
|
934
|
+
await rename(statePath, `${statePath}.corrupted`);
|
|
935
|
+
} catch {}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const resolvedStatePath = statePathFor(ctx.cwd, state.scope, sessionId);
|
|
940
|
+
if (resolvedStatePath !== statePath && existsSync(resolvedStatePath)) {
|
|
941
|
+
statePath = resolvedStatePath;
|
|
942
|
+
try {
|
|
943
|
+
state = normalizeState(JSON.parse(await readFile(statePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
944
|
+
} catch {
|
|
945
|
+
const backupPath = `${statePath}${ATOMIC_BACKUP_SUFFIX}`;
|
|
946
|
+
if (existsSync(backupPath)) {
|
|
947
|
+
try {
|
|
948
|
+
state = normalizeState(JSON.parse(await readFile(backupPath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
949
|
+
} catch {
|
|
950
|
+
try {
|
|
951
|
+
await rename(statePath, `${statePath}.corrupted`);
|
|
952
|
+
await rename(backupPath, `${backupPath}.corrupted`);
|
|
953
|
+
} catch {}
|
|
954
|
+
}
|
|
955
|
+
} else {
|
|
956
|
+
try {
|
|
957
|
+
await rename(statePath, `${statePath}.corrupted`);
|
|
958
|
+
} catch {}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
} else {
|
|
962
|
+
statePath = resolvedStatePath;
|
|
963
|
+
}
|
|
964
|
+
runtime.statePath = statePath;
|
|
751
965
|
const recovered = recoverStaleOperationLock(state);
|
|
752
966
|
runtime.state = state;
|
|
753
|
-
if (recovered || state.version !== STATE_VERSION) await saveState(state);
|
|
967
|
+
if (recovered || state.version !== STATE_VERSION || !existsSync(statePath)) await saveState(state);
|
|
754
968
|
return state;
|
|
755
969
|
}
|
|
756
970
|
|
|
757
971
|
function defaultSettings(): PiOMSettings {
|
|
758
|
-
return {
|
|
972
|
+
return {
|
|
973
|
+
observationModel: DEFAULT_OBSERVATION_MODEL,
|
|
974
|
+
reflectionModel: DEFAULT_REFLECTION_MODEL,
|
|
975
|
+
caveman: false,
|
|
976
|
+
observeAttachments: "auto",
|
|
977
|
+
retrieval: { provider: "off", model: "local/hash-bow-v1", topK: VECTOR_TOP_K_DEFAULT, threshold: VECTOR_THRESHOLD_DEFAULT },
|
|
978
|
+
};
|
|
759
979
|
}
|
|
760
980
|
|
|
761
981
|
function defaultThresholds(): PiOMRecord["thresholds"] {
|
|
@@ -797,17 +1017,74 @@ function normalizeState(raw: any, identity: { sessionId: string; sessionFile?: s
|
|
|
797
1017
|
sessionFile: raw?.sessionFile || identity.sessionFile,
|
|
798
1018
|
cwd: raw?.cwd || identity.cwd,
|
|
799
1019
|
thresholds: { ...defaults.thresholds, ...(raw?.thresholds ?? {}) },
|
|
800
|
-
settings:
|
|
1020
|
+
settings: normalizeSettings(raw?.settings, defaults.settings),
|
|
801
1021
|
buffered: { observations: [], ...(raw?.buffered ?? {}) },
|
|
802
1022
|
} as PiOMRecord;
|
|
803
1023
|
state.observations = redactSecrets(String(state.observations ?? ""));
|
|
804
1024
|
state.currentTask = state.currentTask ? redactSecrets(state.currentTask) : undefined;
|
|
805
1025
|
state.suggestedResponse = state.suggestedResponse ? redactSecrets(state.suggestedResponse) : undefined;
|
|
806
1026
|
state.lastError = state.lastError ? redactSecrets(state.lastError) : undefined;
|
|
1027
|
+
state.scope = state.scope === "project" ? "project" : "session";
|
|
807
1028
|
state.observationTokens = estimateTokens(state.observations);
|
|
1029
|
+
state.vectorIndex = normalizeVectorIndex(state.vectorIndex, state);
|
|
808
1030
|
return state;
|
|
809
1031
|
}
|
|
810
1032
|
|
|
1033
|
+
function normalizeSettings(raw: any, defaults: PiOMSettings): PiOMSettings {
|
|
1034
|
+
const legacyAttachments = raw?.observeAttachments;
|
|
1035
|
+
let observeAttachments: ObserveAttachmentsMode = defaults.observeAttachments;
|
|
1036
|
+
if (legacyAttachments === true) observeAttachments = "on";
|
|
1037
|
+
else if (legacyAttachments === false) observeAttachments = "off";
|
|
1038
|
+
else if (["auto", "on", "off"].includes(String(legacyAttachments))) observeAttachments = legacyAttachments;
|
|
1039
|
+
const rawRetrieval = raw?.retrieval ?? {};
|
|
1040
|
+
const provider = ["off", "local", "gemini"].includes(String(rawRetrieval.provider)) ? rawRetrieval.provider as RetrievalProvider : defaults.retrieval.provider;
|
|
1041
|
+
return {
|
|
1042
|
+
...defaults,
|
|
1043
|
+
...raw,
|
|
1044
|
+
observeAttachments,
|
|
1045
|
+
retrieval: {
|
|
1046
|
+
provider,
|
|
1047
|
+
model: String(rawRetrieval.model || (provider === "gemini" ? "gemini-embedding-001" : defaults.retrieval.model)),
|
|
1048
|
+
topK: clampInt(Number(rawRetrieval.topK ?? defaults.retrieval.topK), 1, 30),
|
|
1049
|
+
threshold: clampNumber(Number(rawRetrieval.threshold ?? defaults.retrieval.threshold), 0, 1),
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function normalizeVectorIndex(raw: any, state: PiOMRecord): VectorIndexState | undefined {
|
|
1055
|
+
if (!raw || typeof raw !== "object" || !Array.isArray(raw.entries)) return undefined;
|
|
1056
|
+
return {
|
|
1057
|
+
provider: ["off", "local", "gemini"].includes(String(raw.provider)) ? raw.provider : state.settings.retrieval.provider,
|
|
1058
|
+
model: String(raw.model || state.settings.retrieval.model),
|
|
1059
|
+
scopeKey: String(raw.scopeKey || scopeKeyForState(state)),
|
|
1060
|
+
updatedAt: String(raw.updatedAt || new Date().toISOString()),
|
|
1061
|
+
entries: raw.entries.slice(-VECTOR_MAX_INDEX_CHUNKS).filter((e: any) => e && typeof e.text === "string" && Array.isArray(e.embedding)),
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function clampInt(value: number, min: number, max: number): number {
|
|
1066
|
+
if (!Number.isFinite(value)) return min;
|
|
1067
|
+
return Math.max(min, Math.min(max, Math.round(value)));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function clampNumber(value: number, min: number, max: number): number {
|
|
1071
|
+
if (!Number.isFinite(value)) return min;
|
|
1072
|
+
return Math.max(min, Math.min(max, value));
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function statePathFor(cwd: string, scope: PiOMRecord["scope"], sessionId: string): string {
|
|
1076
|
+
const base = join(cwd, CONFIG_DIR_NAME, "om", scope === "project" ? "projects" : "sessions");
|
|
1077
|
+
return join(base, `${scope === "project" ? projectHashForCwd(cwd) : sanitizeFileName(sessionId)}.json`);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function projectHashForCwd(cwd: string): string {
|
|
1081
|
+
return createHash("sha256").update(cwd).digest("hex").slice(0, 16);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function scopeKeyForState(state: PiOMRecord): string {
|
|
1085
|
+
return state.scope === "project" ? `project:${projectHashForCwd(state.cwd)}` : `session:${sanitizeFileName(state.sessionId)}`;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
811
1088
|
function isStaleOperationLock(lock: PiOMRecord["operationLock"], now = Date.now()): boolean {
|
|
812
1089
|
if (!lock) return false;
|
|
813
1090
|
const started = Date.parse(lock.startedAt);
|
|
@@ -825,21 +1102,55 @@ function recoverStaleOperationLock(state: PiOMRecord, now = Date.now()): boolean
|
|
|
825
1102
|
}
|
|
826
1103
|
|
|
827
1104
|
async function saveState(state: PiOMRecord): Promise<void> {
|
|
1105
|
+
runtime.statePath = statePathFor(state.cwd, state.scope, state.sessionId);
|
|
828
1106
|
if (!runtime.statePath) return;
|
|
829
1107
|
state.updatedAt = new Date().toISOString();
|
|
830
1108
|
state.observations = redactSecrets(state.observations);
|
|
831
1109
|
if (state.currentTask) state.currentTask = redactSecrets(state.currentTask);
|
|
832
1110
|
if (state.suggestedResponse) state.suggestedResponse = redactSecrets(state.suggestedResponse);
|
|
833
1111
|
if (state.lastError) state.lastError = redactSecrets(state.lastError);
|
|
1112
|
+
if (state.settings.retrieval.provider === "local") state.vectorIndex = buildLocalVectorIndex(state);
|
|
1113
|
+
await mergeExistingScopeState(runtime.statePath, state);
|
|
834
1114
|
await atomicWriteJson(runtime.statePath, state);
|
|
1115
|
+
if (state.scope === "project") {
|
|
1116
|
+
const sessionPointerPath = statePathFor(state.cwd, "session", state.sessionId);
|
|
1117
|
+
const sessionPointer = { ...state, scope: "project" as const, observations: "", currentTask: undefined, suggestedResponse: undefined, vectorIndex: undefined };
|
|
1118
|
+
await atomicWriteJson(sessionPointerPath, sessionPointer);
|
|
1119
|
+
}
|
|
1120
|
+
await saveVectorIndex(state);
|
|
835
1121
|
runtime.overlayHandle?.requestRender();
|
|
836
1122
|
}
|
|
837
1123
|
|
|
1124
|
+
|
|
1125
|
+
async function mergeExistingScopeState(filePath: string, state: PiOMRecord): Promise<void> {
|
|
1126
|
+
if (!existsSync(filePath)) return;
|
|
1127
|
+
try {
|
|
1128
|
+
const existing = normalizeState(JSON.parse(await readFile(filePath, "utf8")), { sessionId: state.sessionId, sessionFile: state.sessionFile, cwd: state.cwd });
|
|
1129
|
+
if (existing.updatedAt === state.updatedAt) return;
|
|
1130
|
+
const merged = suppressDuplicateObservations(state.observations, existing.observations);
|
|
1131
|
+
if (merged) state.observations = [state.observations.trim(), merged].filter(Boolean).join("\n\n");
|
|
1132
|
+
state.currentTask = state.currentTask ?? existing.currentTask;
|
|
1133
|
+
state.suggestedResponse = state.suggestedResponse ?? existing.suggestedResponse;
|
|
1134
|
+
state.observationTokens = estimateTokens(state.observations);
|
|
1135
|
+
} catch {
|
|
1136
|
+
// Corrupt existing state is handled by .bak recovery during load; do not block saving current valid state.
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async function saveVectorIndex(state: PiOMRecord): Promise<void> {
|
|
1141
|
+
if (!state.vectorIndex || !runtime.statePath) return;
|
|
1142
|
+
const dir = join(dirname(dirname(runtime.statePath)), "vectors");
|
|
1143
|
+
await mkdir(dir, { recursive: true });
|
|
1144
|
+
const file = join(dir, `${sanitizeFileName(state.vectorIndex.scopeKey)}.jsonl`);
|
|
1145
|
+
const lines = state.vectorIndex.entries.map(entry => JSON.stringify(redactDeep(entry))).join("\n") + (state.vectorIndex.entries.length ? "\n" : "");
|
|
1146
|
+
await writeFile(file, lines, "utf8");
|
|
1147
|
+
}
|
|
1148
|
+
|
|
838
1149
|
async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
|
|
839
1150
|
await mkdir(dirname(filePath), { recursive: true });
|
|
840
1151
|
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
841
1152
|
const backupPath = `${filePath}${ATOMIC_BACKUP_SUFFIX}`;
|
|
842
|
-
const json =
|
|
1153
|
+
const json = JSON.stringify(redactDeep(value), null, 2) + "\n";
|
|
843
1154
|
await writeFile(tmpPath, json, "utf8");
|
|
844
1155
|
if (existsSync(filePath)) {
|
|
845
1156
|
try {
|
|
@@ -868,14 +1179,57 @@ async function refreshCounts(ctx: any): Promise<void> {
|
|
|
868
1179
|
const state = await ensureState(ctx);
|
|
869
1180
|
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
870
1181
|
const pending = entriesAfter(branch, state.lastObservedEntryId).filter(isMessageLikeEntry);
|
|
871
|
-
|
|
1182
|
+
const pendingTokens = estimateEntriesTokens(pending);
|
|
1183
|
+
if (pendingTokens > MAX_PENDING_BACKLOG_TOKENS) {
|
|
1184
|
+
const retained = takeTailEntriesUpTo(pending, RETAIN_PENDING_TAIL_TOKENS);
|
|
1185
|
+
const firstRetained = retained[0];
|
|
1186
|
+
const firstRetainedIndex = firstRetained ? pending.findIndex(e => e.id === firstRetained.id) : -1;
|
|
1187
|
+
const cursor = firstRetainedIndex > 0 ? pending[firstRetainedIndex - 1] : undefined;
|
|
1188
|
+
if (cursor?.id) state.lastObservedEntryId = cursor.id;
|
|
1189
|
+
const retainedTokens = estimateEntriesTokens(retained);
|
|
1190
|
+
const skippedTokens = Math.max(0, pendingTokens - retainedTokens);
|
|
1191
|
+
const note = `OM recovery: skipped ~${formatTokens(skippedTokens)} tokens of stale unobserved backlog after reload/corruption recovery; retained latest ~${formatTokens(retainedTokens)} tokens for observation.`;
|
|
1192
|
+
state.observations = suppressDuplicateObservations(state.observations, `- ${note}`);
|
|
1193
|
+
state.lastOperation = { type: "observation", startedAt: new Date().toISOString(), endedAt: new Date().toISOString(), inputTokens: 0, outputTokens: estimateTokens(note) };
|
|
1194
|
+
state.lastError = note;
|
|
1195
|
+
state.pendingMessageTokens = retainedTokens;
|
|
1196
|
+
} else {
|
|
1197
|
+
state.pendingMessageTokens = pendingTokens;
|
|
1198
|
+
}
|
|
872
1199
|
state.observationTokens = estimateTokens(state.observations);
|
|
873
1200
|
await saveState(state);
|
|
874
1201
|
}
|
|
875
1202
|
|
|
1203
|
+
async function bootstrapStatus(ctx: any, reason: string): Promise<void> {
|
|
1204
|
+
try {
|
|
1205
|
+
const state = await ensureState(ctx);
|
|
1206
|
+
updateStatus(ctx);
|
|
1207
|
+
try {
|
|
1208
|
+
await refreshCounts(ctx);
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
state.lastError = `OM status refresh failed during ${reason}: ${errorMessage(error)}`;
|
|
1211
|
+
await saveState(state);
|
|
1212
|
+
}
|
|
1213
|
+
updateStatus(ctx);
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
try {
|
|
1216
|
+
ctx.ui.setStatus("om", `\x1b[31mOM error\x1b[0m \x1b[2m${truncateToWidth(errorMessage(error), 80, "…")}\x1b[0m`);
|
|
1217
|
+
} catch {
|
|
1218
|
+
// Context might be stale or UI disposed
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
876
1223
|
function updateStatus(ctx: any): void {
|
|
877
1224
|
const state = runtime.state;
|
|
878
|
-
if (!state)
|
|
1225
|
+
if (!state) {
|
|
1226
|
+
try {
|
|
1227
|
+
ctx.ui.setStatus("om", "\x1b[2;37mOM loading…\x1b[0m");
|
|
1228
|
+
} catch {
|
|
1229
|
+
// Context might be stale or UI disposed
|
|
1230
|
+
}
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
879
1233
|
try {
|
|
880
1234
|
ctx.ui.setStatus("om", formatShortStatusColored(state));
|
|
881
1235
|
} catch (e) {
|
|
@@ -898,9 +1252,12 @@ async function observeNow(ctx: any, opts: { force: boolean; reason: string; sign
|
|
|
898
1252
|
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
899
1253
|
const candidates = entriesAfter(branch, state.lastObservedEntryId).filter(isMessageLikeEntry);
|
|
900
1254
|
if (candidates.length === 0 && !opts.manualText) return;
|
|
901
|
-
const selected = opts.force ? candidates : selectEntriesForObservation(candidates, state);
|
|
1255
|
+
const selected = opts.force ? selectEntriesForForcedObservation(candidates) : selectEntriesForObservation(candidates, state);
|
|
902
1256
|
if (selected.length === 0 && !opts.manualText) return;
|
|
903
|
-
const
|
|
1257
|
+
const observerInput = opts.manualText
|
|
1258
|
+
? { text: opts.manualText, attachmentParts: [] as any[], omissions: [] as string[] }
|
|
1259
|
+
: buildObserverInput(ctx, selected, state);
|
|
1260
|
+
const inputText = observerInput.text;
|
|
904
1261
|
const startedAt = new Date().toISOString();
|
|
905
1262
|
state.operationLock = { type: "observation", startedAt };
|
|
906
1263
|
state.status = "observing";
|
|
@@ -910,7 +1267,7 @@ async function observeNow(ctx: any, opts: { force: boolean; reason: string; sign
|
|
|
910
1267
|
runtime.currentOperation = controller;
|
|
911
1268
|
const signal = mergeAbortSignals(opts.signal, controller.signal);
|
|
912
1269
|
try {
|
|
913
|
-
const result = await runObserver(ctx, inputText, signal, { source: opts.reason, existingObservations: state.observations });
|
|
1270
|
+
const result = await runObserver(ctx, inputText, signal, { source: opts.reason, existingObservations: state.observations, attachmentParts: observerInput.attachmentParts });
|
|
914
1271
|
appendObservations(state, result, estimateTokens(inputText));
|
|
915
1272
|
const last = selected.at(-1);
|
|
916
1273
|
if (last?.id) state.lastObservedEntryId = last.id;
|
|
@@ -918,12 +1275,24 @@ async function observeNow(ctx: any, opts: { force: boolean; reason: string; sign
|
|
|
918
1275
|
state.operationLock = undefined;
|
|
919
1276
|
state.status = "idle";
|
|
920
1277
|
state.lastError = undefined;
|
|
921
|
-
await writeDebug(ctx, "observation", { startedAt, reason: opts.reason, inputText, rawOutput: result.rawOutput, parsed: result });
|
|
1278
|
+
await writeDebug(ctx, "observation", { startedAt, reason: opts.reason, inputText, attachmentOmissions: observerInput.omissions, attachmentCount: observerInput.attachmentParts.length, rawOutput: result.rawOutput, parsed: result });
|
|
922
1279
|
await saveState(state);
|
|
923
1280
|
} catch (error) {
|
|
1281
|
+
const message = errorMessage(error);
|
|
1282
|
+
const last = selected.at(-1);
|
|
1283
|
+
if (!opts.manualText && (message.includes("Observer returned no observations") || message.includes("ctx is stale"))) {
|
|
1284
|
+
if (last?.id) state.lastObservedEntryId = last.id;
|
|
1285
|
+
state.pendingMessageTokens = Math.max(0, state.pendingMessageTokens - estimateEntriesTokens(selected));
|
|
1286
|
+
state.operationLock = undefined;
|
|
1287
|
+
state.status = "idle";
|
|
1288
|
+
state.lastError = undefined;
|
|
1289
|
+
state.lastOperation = { type: "observation", startedAt, endedAt: new Date().toISOString(), inputTokens: estimateTokens(inputText), error: `Non-fatal observer skip: ${message}` };
|
|
1290
|
+
await saveState(state);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
924
1293
|
state.operationLock = undefined;
|
|
925
1294
|
state.status = "failed";
|
|
926
|
-
state.lastError = `Observational Memory Observer failed: ${
|
|
1295
|
+
state.lastError = `Observational Memory Observer failed: ${message}`;
|
|
927
1296
|
state.lastOperation = { type: "observation", startedAt, endedAt: new Date().toISOString(), inputTokens: estimateTokens(inputText), error: state.lastError };
|
|
928
1297
|
await saveState(state);
|
|
929
1298
|
throw new Error(state.lastError);
|
|
@@ -943,7 +1312,8 @@ async function bufferObservation(ctx: any, reason: string): Promise<void> {
|
|
|
943
1312
|
if (tokens < state.thresholds.bufferTokens) return;
|
|
944
1313
|
const selected = takeEntriesUpTo(candidates, Math.min(tokens, OBSERVER_MAX_BATCH_TOKENS));
|
|
945
1314
|
if (selected.length === 0) return;
|
|
946
|
-
const
|
|
1315
|
+
const observerInput = buildObserverInput(ctx, selected, state);
|
|
1316
|
+
const inputText = observerInput.text;
|
|
947
1317
|
const startedAt = new Date().toISOString();
|
|
948
1318
|
state.operationLock = { type: "buffer", startedAt };
|
|
949
1319
|
state.status = "buffering";
|
|
@@ -952,7 +1322,7 @@ async function bufferObservation(ctx: any, reason: string): Promise<void> {
|
|
|
952
1322
|
const controller = new AbortController();
|
|
953
1323
|
runtime.currentOperation = controller;
|
|
954
1324
|
try {
|
|
955
|
-
const result = await runObserver(ctx, inputText, controller.signal, { source: reason, existingObservations: state.observations });
|
|
1325
|
+
const result = await runObserver(ctx, inputText, controller.signal, { source: reason, existingObservations: state.observations, attachmentParts: observerInput.attachmentParts });
|
|
956
1326
|
const observations = result.observations.trim();
|
|
957
1327
|
if (!observations) throw new Error("Observer returned empty buffered observations");
|
|
958
1328
|
state.buffered.observations.push({
|
|
@@ -969,7 +1339,7 @@ async function bufferObservation(ctx: any, reason: string): Promise<void> {
|
|
|
969
1339
|
state.operationLock = undefined;
|
|
970
1340
|
state.status = "idle";
|
|
971
1341
|
state.lastOperation = { type: "buffer", startedAt, endedAt: new Date().toISOString(), inputTokens: estimateTokens(inputText), outputTokens: estimateTokens(observations), model: state.settings.observationModel || DEFAULT_OBSERVATION_MODEL };
|
|
972
|
-
await writeDebug(ctx, "buffer", { startedAt, reason, inputText, rawOutput: result.rawOutput, parsed: result });
|
|
1342
|
+
await writeDebug(ctx, "buffer", { startedAt, reason, inputText, attachmentOmissions: observerInput.omissions, attachmentCount: observerInput.attachmentParts.length, rawOutput: result.rawOutput, parsed: result });
|
|
973
1343
|
await saveState(state);
|
|
974
1344
|
} catch (error) {
|
|
975
1345
|
state.operationLock = undefined;
|
|
@@ -1019,6 +1389,7 @@ async function activateBuffered(ctx: any, reason: string): Promise<void> {
|
|
|
1019
1389
|
state.buffered.observations = chunks.slice(activated.length);
|
|
1020
1390
|
state.pendingMessageTokens = Math.max(0, state.pendingMessageTokens - activatedTokens);
|
|
1021
1391
|
state.observationTokens = estimateTokens(state.observations);
|
|
1392
|
+
state.vectorIndex = buildLocalVectorIndex(state);
|
|
1022
1393
|
state.lastOperation = { type: "activation", startedAt, endedAt: new Date().toISOString(), inputTokens: activatedTokens, outputTokens: estimateTokens(observations) };
|
|
1023
1394
|
state.operationLock = undefined;
|
|
1024
1395
|
state.status = "idle";
|
|
@@ -1065,6 +1436,7 @@ async function reflectNow(ctx: any, opts: { reason: string; signal?: AbortSignal
|
|
|
1065
1436
|
state.currentTask = result.currentTask ?? state.currentTask;
|
|
1066
1437
|
state.suggestedResponse = result.suggestedContinuation ?? state.suggestedResponse;
|
|
1067
1438
|
state.observationTokens = reflectedTokens;
|
|
1439
|
+
state.vectorIndex = buildLocalVectorIndex(state);
|
|
1068
1440
|
state.operationLock = undefined;
|
|
1069
1441
|
state.status = "idle";
|
|
1070
1442
|
state.lastError = undefined;
|
|
@@ -1087,13 +1459,16 @@ async function reflectNow(ctx: any, opts: { reason: string; signal?: AbortSignal
|
|
|
1087
1459
|
}
|
|
1088
1460
|
}
|
|
1089
1461
|
|
|
1090
|
-
async function runObserver(ctx: any, historyText: string, signal: AbortSignal | undefined, opts: { source: string; existingObservations?: string }): Promise<ObserverResult> {
|
|
1462
|
+
async function runObserver(ctx: any, historyText: string, signal: AbortSignal | undefined, opts: { source: string; existingObservations?: string; attachmentParts?: any[] }): Promise<ObserverResult> {
|
|
1091
1463
|
const state = await ensureState(ctx);
|
|
1092
1464
|
const modelId = state.settings.observationModel || DEFAULT_OBSERVATION_MODEL;
|
|
1093
1465
|
const model = resolveModel(ctx, modelId);
|
|
1094
1466
|
const response = await runModel(ctx, model, [
|
|
1095
1467
|
{ role: "user", content: [{ type: "text", text: buildObserverSystemPrompt(state.settings.caveman) }] },
|
|
1096
|
-
{ role: "user", content: [
|
|
1468
|
+
{ role: "user", content: [
|
|
1469
|
+
{ 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 })}` },
|
|
1470
|
+
...(opts.attachmentParts ?? []),
|
|
1471
|
+
] },
|
|
1097
1472
|
], { temperature: 0.3, maxTokens: 100_000, signal });
|
|
1098
1473
|
const text = responseText(response);
|
|
1099
1474
|
const parsed = parseObserverOutput(text);
|
|
@@ -1125,7 +1500,11 @@ function resolveModel(ctx: any, modelId: string): any {
|
|
|
1125
1500
|
const slash = modelId.indexOf("/");
|
|
1126
1501
|
const provider = slash >= 0 ? modelId.slice(0, slash) : ctx.model?.provider;
|
|
1127
1502
|
const id = slash >= 0 ? modelId.slice(slash + 1) : modelId;
|
|
1128
|
-
const
|
|
1503
|
+
const currentModel = ctx.model;
|
|
1504
|
+
if (currentModel && String(currentModel.provider) === String(provider) && String(currentModel.id ?? currentModel.model) === String(id)) {
|
|
1505
|
+
return currentModel;
|
|
1506
|
+
}
|
|
1507
|
+
const model = typeof ctx.modelRegistry?.find === "function" ? ctx.modelRegistry.find(provider, id) : undefined;
|
|
1129
1508
|
if (!model) throw new Error(`Could not find OM model ${provider}/${id}`);
|
|
1130
1509
|
return model;
|
|
1131
1510
|
}
|
|
@@ -1177,19 +1556,207 @@ function detectDegenerateRepetition(text: string): boolean {
|
|
|
1177
1556
|
}
|
|
1178
1557
|
|
|
1179
1558
|
function appendObservations(state: PiOMRecord, result: ObserverResult, inputTokens: number): void {
|
|
1180
|
-
const observations = result.observations.trim();
|
|
1181
|
-
if (!observations) throw new Error("No observations to append");
|
|
1559
|
+
const observations = suppressDuplicateObservations(state.observations, result.observations.trim());
|
|
1560
|
+
if (!observations) throw new Error("No new non-duplicate observations to append");
|
|
1182
1561
|
state.observations = [state.observations.trim(), observations].filter(Boolean).join("\n\n");
|
|
1183
1562
|
state.currentTask = result.currentTask ?? state.currentTask;
|
|
1184
1563
|
state.suggestedResponse = result.suggestedContinuation ?? state.suggestedResponse;
|
|
1185
1564
|
state.observationTokens = estimateTokens(state.observations);
|
|
1565
|
+
state.vectorIndex = buildLocalVectorIndex(state);
|
|
1186
1566
|
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 };
|
|
1187
1567
|
}
|
|
1188
1568
|
|
|
1189
|
-
function
|
|
1569
|
+
function observationChunks(text: string): string[] {
|
|
1570
|
+
return text.split(/\n{2,}/).flatMap(block => {
|
|
1571
|
+
const lines = block.split("\n").map(l => l.trim()).filter(Boolean);
|
|
1572
|
+
if (lines.length <= 1) return lines;
|
|
1573
|
+
return lines.some(l => /^[-*]\s|^[🔴🟡🟢✅]/.test(l)) ? lines : [lines.join("\n")];
|
|
1574
|
+
}).map(redactSecrets).filter(Boolean);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function normalizeForSimilarity(text: string): string {
|
|
1578
|
+
return text.toLowerCase().replace(/[🔴🟡🟢✅]/g, "").replace(/\([^)]*\)/g, "").replace(/[^a-z0-9а-яё\s._/-]/gi, " ").replace(/\s+/g, " ").trim();
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function textHash(text: string): string {
|
|
1582
|
+
return createHash("sha256").update(normalizeForSimilarity(text)).digest("hex");
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
function localEmbedding(text: string): number[] {
|
|
1586
|
+
const dims = 128;
|
|
1587
|
+
const vec = new Array(dims).fill(0);
|
|
1588
|
+
const words = normalizeForSimilarity(text).split(" ").filter(w => w.length > 2);
|
|
1589
|
+
for (const word of words) {
|
|
1590
|
+
const h = createHash("sha256").update(word).digest();
|
|
1591
|
+
const idx = h[0] % dims;
|
|
1592
|
+
vec[idx] += 1 + Math.min(3, word.length / 8);
|
|
1593
|
+
}
|
|
1594
|
+
const norm = Math.sqrt(vec.reduce((s, v) => s + v * v, 0)) || 1;
|
|
1595
|
+
return vec.map(v => Number((v / norm).toFixed(6)));
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
function cosine(a: number[], b: number[]): number {
|
|
1599
|
+
const n = Math.min(a.length, b.length);
|
|
1600
|
+
let sum = 0;
|
|
1601
|
+
for (let i = 0; i < n; i++) sum += (a[i] ?? 0) * (b[i] ?? 0);
|
|
1602
|
+
return sum;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function suppressDuplicateObservations(existing: string, incoming: string): string {
|
|
1606
|
+
const existingChunks = observationChunks(existing);
|
|
1607
|
+
const existingHashes = new Set(existingChunks.map(textHash));
|
|
1608
|
+
const existingEmbeddings = existingChunks.map(localEmbedding);
|
|
1609
|
+
const kept: string[] = [];
|
|
1610
|
+
for (const chunk of observationChunks(incoming)) {
|
|
1611
|
+
const hash = textHash(chunk);
|
|
1612
|
+
if (existingHashes.has(hash)) continue;
|
|
1613
|
+
const emb = localEmbedding(chunk);
|
|
1614
|
+
if (existingEmbeddings.some(e => cosine(e, emb) >= 0.94)) continue;
|
|
1615
|
+
existingHashes.add(hash);
|
|
1616
|
+
existingEmbeddings.push(emb);
|
|
1617
|
+
kept.push(chunk);
|
|
1618
|
+
}
|
|
1619
|
+
return kept.join("\n").trim();
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function buildLocalVectorIndex(state: PiOMRecord): VectorIndexState | undefined {
|
|
1623
|
+
if (state.settings.retrieval.provider === "off") return undefined;
|
|
1624
|
+
const provider = state.settings.retrieval.provider;
|
|
1625
|
+
if (provider !== "local") return state.vectorIndex;
|
|
1626
|
+
const now = new Date().toISOString();
|
|
1627
|
+
const entries = observationChunks(state.observations).slice(-VECTOR_MAX_INDEX_CHUNKS).map((text, i) => ({
|
|
1628
|
+
id: `obs-${i}-${textHash(text).slice(0, 10)}`,
|
|
1629
|
+
hash: textHash(text),
|
|
1630
|
+
text,
|
|
1631
|
+
embedding: localEmbedding(text),
|
|
1632
|
+
tokens: estimateTokens(text),
|
|
1633
|
+
createdAt: now,
|
|
1634
|
+
}));
|
|
1635
|
+
return { provider: "local", model: state.settings.retrieval.model, scopeKey: scopeKeyForState(state), updatedAt: now, entries };
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
async function retrieveRelevantObservations(ctx: any, state: PiOMRecord, query: string): Promise<string> {
|
|
1639
|
+
const cfg = state.settings.retrieval;
|
|
1640
|
+
if (cfg.provider === "off" || !query.trim()) return "";
|
|
1641
|
+
if (cfg.provider === "gemini") return retrieveGeminiObservations(ctx, state, query);
|
|
1642
|
+
const index = state.vectorIndex?.provider === "local" ? state.vectorIndex : buildLocalVectorIndex(state);
|
|
1643
|
+
if (!index || index.entries.length === 0) return "";
|
|
1644
|
+
state.vectorIndex = index;
|
|
1645
|
+
const queryVec = localEmbedding(query);
|
|
1646
|
+
return index.entries
|
|
1647
|
+
.map(entry => ({ entry, score: cosine(queryVec, entry.embedding) }))
|
|
1648
|
+
.filter(x => x.score >= cfg.threshold)
|
|
1649
|
+
.sort((a, b) => b.score - a.score)
|
|
1650
|
+
.slice(0, cfg.topK)
|
|
1651
|
+
.map(x => `- (${x.score.toFixed(2)}) ${x.entry.text}`)
|
|
1652
|
+
.join("\n");
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
async function retrieveGeminiObservations(ctx: any, state: PiOMRecord, query: string): Promise<string> {
|
|
1656
|
+
const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY;
|
|
1657
|
+
if (!apiKey) throw new Error("OM Gemini retrieval requires GOOGLE_GENERATIVE_AI_API_KEY or GEMINI_API_KEY");
|
|
1658
|
+
const chunks = observationChunks(state.observations).slice(-VECTOR_MAX_INDEX_CHUNKS);
|
|
1659
|
+
if (chunks.length === 0) return "";
|
|
1660
|
+
const queryVec = await geminiEmbedding(apiKey, state.settings.retrieval.model, query);
|
|
1661
|
+
const entries = await Promise.all(chunks.map(async (text, i) => ({
|
|
1662
|
+
id: `gem-${i}-${textHash(text).slice(0, 10)}`,
|
|
1663
|
+
hash: textHash(text),
|
|
1664
|
+
text,
|
|
1665
|
+
embedding: await geminiEmbedding(apiKey, state.settings.retrieval.model, text),
|
|
1666
|
+
tokens: estimateTokens(text),
|
|
1667
|
+
createdAt: new Date().toISOString(),
|
|
1668
|
+
})));
|
|
1669
|
+
state.vectorIndex = { provider: "gemini", model: state.settings.retrieval.model, scopeKey: scopeKeyForState(state), updatedAt: new Date().toISOString(), entries };
|
|
1670
|
+
return entries
|
|
1671
|
+
.map(entry => ({ entry, score: cosine(queryVec, entry.embedding) }))
|
|
1672
|
+
.filter(x => x.score >= state.settings.retrieval.threshold)
|
|
1673
|
+
.sort((a, b) => b.score - a.score)
|
|
1674
|
+
.slice(0, state.settings.retrieval.topK)
|
|
1675
|
+
.map(x => `- (${x.score.toFixed(2)}) ${x.entry.text}`)
|
|
1676
|
+
.join("\n");
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
async function geminiEmbedding(apiKey: string, model: string, text: string): Promise<number[]> {
|
|
1680
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:embedContent?key=${encodeURIComponent(apiKey)}`;
|
|
1681
|
+
const res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ content: { parts: [{ text: truncateText(redactSecrets(text), 20_000) }] } }) });
|
|
1682
|
+
if (!res.ok) throw new Error(`Gemini embedding failed: HTTP ${res.status} ${truncateText(await res.text(), 300)}`);
|
|
1683
|
+
const json = await res.json() as any;
|
|
1684
|
+
const values = json?.embedding?.values;
|
|
1685
|
+
if (!Array.isArray(values)) throw new Error("Gemini embedding response did not include embedding.values");
|
|
1686
|
+
const norm = Math.sqrt(values.reduce((s: number, v: number) => s + v * v, 0)) || 1;
|
|
1687
|
+
return values.map((v: number) => v / norm);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
async function buildProjectReferences(ctx: any, state: PiOMRecord): Promise<string> {
|
|
1692
|
+
if (state.scope !== "project") return "";
|
|
1693
|
+
const sessionsDir = join(ctx.cwd, CONFIG_DIR_NAME, "om", "sessions");
|
|
1694
|
+
if (!existsSync(sessionsDir)) return "";
|
|
1695
|
+
let files: string[] = [];
|
|
1696
|
+
try {
|
|
1697
|
+
files = (await readdir(sessionsDir)).filter(f => f.endsWith(".json") && f !== `${sanitizeFileName(state.sessionId)}.json`);
|
|
1698
|
+
} catch {
|
|
1699
|
+
return "";
|
|
1700
|
+
}
|
|
1701
|
+
const records: PiOMRecord[] = [];
|
|
1702
|
+
for (const file of files.slice(-20)) {
|
|
1703
|
+
try {
|
|
1704
|
+
const raw = JSON.parse(await readFile(join(sessionsDir, file), "utf8"));
|
|
1705
|
+
const normalized = normalizeState(raw, { sessionId: raw.sessionId || file.replace(/\.json$/, ""), sessionFile: raw.sessionFile, cwd: ctx.cwd });
|
|
1706
|
+
if (normalized.observations.trim()) records.push(normalized);
|
|
1707
|
+
} catch {
|
|
1708
|
+
// Skip unreadable references; main state remains authoritative.
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
records.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
1712
|
+
const blocks: string[] = [];
|
|
1713
|
+
let tokens = 0;
|
|
1714
|
+
for (const rec of records.slice(0, 5)) {
|
|
1715
|
+
const obs = limitTextByTokens(rec.observations, 500) || "";
|
|
1716
|
+
const block = `<other-session id="${sanitizeFileName(rec.sessionId)}" updated="${rec.updatedAt}">\n${obs}\n</other-session>`;
|
|
1717
|
+
tokens += estimateTokens(block);
|
|
1718
|
+
if (tokens > PROJECT_REFERENCES_MAX_TOKENS) break;
|
|
1719
|
+
blocks.push(block);
|
|
1720
|
+
}
|
|
1721
|
+
return blocks.join("\n\n");
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
async function rebuildVectorIndex(ctx: any, state: PiOMRecord): Promise<void> {
|
|
1725
|
+
if (state.settings.retrieval.provider === "off") {
|
|
1726
|
+
state.vectorIndex = undefined;
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
if (state.settings.retrieval.provider === "local") {
|
|
1730
|
+
state.vectorIndex = buildLocalVectorIndex(state);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
if (state.settings.retrieval.provider === "gemini") {
|
|
1734
|
+
await retrieveGeminiObservations(ctx, state, state.observations.slice(0, 2_000) || "build index");
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function formatVectorStatus(state: PiOMRecord): string {
|
|
1740
|
+
const idx = state.vectorIndex;
|
|
1741
|
+
return [
|
|
1742
|
+
"OM vector retrieval",
|
|
1743
|
+
`provider: ${state.settings.retrieval.provider}`,
|
|
1744
|
+
`model: ${state.settings.retrieval.model}`,
|
|
1745
|
+
`scopeKey: ${scopeKeyForState(state)}`,
|
|
1746
|
+
`topK: ${state.settings.retrieval.topK}`,
|
|
1747
|
+
`threshold: ${state.settings.retrieval.threshold}`,
|
|
1748
|
+
`index: ${idx ? `${idx.entries.length} entries, updated ${idx.updatedAt}` : "not built"}`,
|
|
1749
|
+
].join("\n");
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function buildOMContextMessage(state: PiOMRecord, retrievedObservations = "", projectReferences = ""): AgentMessage {
|
|
1753
|
+
const retrievalBlock = retrievedObservations ? `<relevant-observations>\n${retrievedObservations}\n</relevant-observations>` : "";
|
|
1754
|
+
const projectBlock = projectReferences ? `<other-sessions>\n${projectReferences}\n</other-sessions>` : "";
|
|
1190
1755
|
const sections = [
|
|
1191
1756
|
OBSERVATION_CONTEXT_PROMPT,
|
|
1192
1757
|
`<observations>\n${state.observations.trim()}\n</observations>`,
|
|
1758
|
+
retrievalBlock,
|
|
1759
|
+
projectBlock,
|
|
1193
1760
|
OBSERVATION_CONTEXT_INSTRUCTIONS,
|
|
1194
1761
|
state.currentTask ? `<current-task>\n${state.currentTask}\n</current-task>` : "",
|
|
1195
1762
|
state.suggestedResponse ? `<suggested-response>\n${state.suggestedResponse}\n</suggested-response>` : "",
|
|
@@ -1242,6 +1809,10 @@ function selectEntriesForObservation(entries: SessionEntry[], state: PiOMRecord)
|
|
|
1242
1809
|
return takeEntriesUpTo(entries, Math.min(target, OBSERVER_MAX_BATCH_TOKENS));
|
|
1243
1810
|
}
|
|
1244
1811
|
|
|
1812
|
+
function selectEntriesForForcedObservation(entries: SessionEntry[]): SessionEntry[] {
|
|
1813
|
+
return takeEntriesUpTo(entries, OBSERVER_MAX_BATCH_TOKENS);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1245
1816
|
function takeEntriesUpTo(entries: SessionEntry[], targetTokens: number): SessionEntry[] {
|
|
1246
1817
|
const selected: SessionEntry[] = [];
|
|
1247
1818
|
let total = 0;
|
|
@@ -1253,6 +1824,18 @@ function takeEntriesUpTo(entries: SessionEntry[], targetTokens: number): Session
|
|
|
1253
1824
|
return selected;
|
|
1254
1825
|
}
|
|
1255
1826
|
|
|
1827
|
+
function takeTailEntriesUpTo(entries: SessionEntry[], targetTokens: number): SessionEntry[] {
|
|
1828
|
+
const selected: SessionEntry[] = [];
|
|
1829
|
+
let total = 0;
|
|
1830
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1831
|
+
const entry = entries[i];
|
|
1832
|
+
selected.unshift(entry);
|
|
1833
|
+
total += estimateEntryTokens(entry);
|
|
1834
|
+
if (total >= targetTokens) break;
|
|
1835
|
+
}
|
|
1836
|
+
return selected;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1256
1839
|
function entriesAfter(branch: SessionEntry[], id?: string): SessionEntry[] {
|
|
1257
1840
|
if (!id) return branch;
|
|
1258
1841
|
const idx = branch.findIndex(e => e.id === id);
|
|
@@ -1281,6 +1864,99 @@ function formatEntriesForObserver(entries: SessionEntry[]): string {
|
|
|
1281
1864
|
return entries.map(formatEntryForObserver).filter(Boolean).join("\n\n");
|
|
1282
1865
|
}
|
|
1283
1866
|
|
|
1867
|
+
function buildObserverInput(ctx: any, entries: SessionEntry[], state: PiOMRecord): { text: string; attachmentParts: any[]; omissions: string[] } {
|
|
1868
|
+
const model = resolveModel(ctx, state.settings.observationModel || DEFAULT_OBSERVATION_MODEL);
|
|
1869
|
+
const attachmentParts: any[] = [];
|
|
1870
|
+
const omissions: string[] = [];
|
|
1871
|
+
const text = entries.map(entry => formatEntryForObserverWithAttachments(entry, state, model, attachmentParts, omissions)).filter(Boolean).join("\n\n");
|
|
1872
|
+
return { text, attachmentParts, omissions };
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
function formatEntryForObserverWithAttachments(entry: SessionEntry, state: PiOMRecord, model: any, attachmentParts: any[], omissions: string[]): string {
|
|
1876
|
+
const msg = entryToAgentMessage(entry);
|
|
1877
|
+
const createdAt = new Date(entry.timestamp || msg?.timestamp || Date.now());
|
|
1878
|
+
const date = createdAt.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
1879
|
+
const time = createdAt.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
1880
|
+
const body = formatAgentMessageForObserverWithAttachments(msg, state, model, attachmentParts, omissions);
|
|
1881
|
+
return body ? `${date}:\n[entry ${entry.id}] ${time} ${body}` : "";
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function formatAgentMessageForObserverWithAttachments(msg: any, state: PiOMRecord, model: any, attachmentParts: any[], omissions: string[]): string {
|
|
1885
|
+
if (!msg) return "";
|
|
1886
|
+
const cap = MESSAGE_PART_MAX_CHARS;
|
|
1887
|
+
switch (msg.role) {
|
|
1888
|
+
case "user": return `User: ${formatContentForObserverWithAttachments(msg.content, cap, state, model, attachmentParts, omissions)}`;
|
|
1889
|
+
case "assistant": return `Assistant: ${formatContentForObserverWithAttachments(msg.content, cap, state, model, attachmentParts, omissions)}`;
|
|
1890
|
+
case "toolResult": return `Tool Result ${msg.toolName}: ${truncateText(formatContentForObserverWithAttachments(msg.content, cap, state, model, attachmentParts, omissions), TOOL_RESULT_MAX_CHARS)}`;
|
|
1891
|
+
case "bashExecution": return `User Bash: ${msg.command}\nOutput: ${truncateText(msg.output ?? "", cap)}${msg.truncated ? "\n[output truncated by Pi]" : ""}`;
|
|
1892
|
+
case "custom": return msg.customType === EXTENSION_ID ? "" : `Custom ${msg.customType}: ${formatContentForObserverWithAttachments(msg.content, cap, state, model, attachmentParts, omissions)}`;
|
|
1893
|
+
case "compactionSummary": return `Previous Compaction Summary: ${truncateText(msg.summary ?? "", cap)}`;
|
|
1894
|
+
case "branchSummary": return `Branch Summary: ${truncateText(msg.summary ?? "", cap)}`;
|
|
1895
|
+
default: return `${msg.role ?? "Message"}: ${truncateText(JSON.stringify(redactDeep(msg)), cap)}`;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
function formatContentForObserverWithAttachments(content: any, cap: number, state: PiOMRecord, model: any, attachmentParts: any[], omissions: string[]): string {
|
|
1900
|
+
if (typeof content === "string") return truncateText(redactSecrets(content), cap);
|
|
1901
|
+
if (!Array.isArray(content)) return truncateText(redactSecrets(JSON.stringify(redactDeep(content))), cap);
|
|
1902
|
+
return content.map(part => {
|
|
1903
|
+
if (part.type === "text") return redactSecrets(part.text);
|
|
1904
|
+
if (part.type === "thinking") return `[Thinking]: ${redactSecrets(part.thinking ?? "")}`;
|
|
1905
|
+
if (isAttachmentPart(part)) return handleObserverAttachmentPart(part, state, model, attachmentParts, omissions);
|
|
1906
|
+
if (part.type === "toolCall") return `[Tool Call ${part.name}: ${truncateText(redactSecrets(JSON.stringify(redactDeep(part.arguments ?? {}))), 2_000)}]`;
|
|
1907
|
+
return `[${part.type}: ${truncateText(redactSecrets(JSON.stringify(redactDeep(part))), 2_000)}]`;
|
|
1908
|
+
}).join("\n").slice(0, cap);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
function isAttachmentPart(part: any): boolean {
|
|
1912
|
+
return part?.type === "image" || part?.type === "file" || Boolean(part?.mimeType || part?.mediaType || part?.source?.mediaType);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function modelSupportsAttachments(model: any): boolean {
|
|
1916
|
+
const provider = String(model?.provider || "").toLowerCase();
|
|
1917
|
+
const id = String(model?.id || model?.model || "").toLowerCase();
|
|
1918
|
+
if (provider.includes("google") || provider.includes("gemini")) return id.includes("gemini");
|
|
1919
|
+
if (provider.includes("openai")) return /gpt-4o|gpt-4\.1|o3|o4/.test(id);
|
|
1920
|
+
if (provider.includes("anthropic")) return /claude-3|claude-sonnet-4|claude-opus-4/.test(id);
|
|
1921
|
+
return false;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function handleObserverAttachmentPart(part: any, state: PiOMRecord, model: any, attachmentParts: any[], omissions: string[]): string {
|
|
1925
|
+
const mode = state.settings.observeAttachments;
|
|
1926
|
+
const mime = String(part.mimeType || part.mediaType || part.source?.mediaType || "unknown").toLowerCase();
|
|
1927
|
+
const raw = part.data || part.image || part.source?.data || "";
|
|
1928
|
+
const approxBytes = typeof raw === "string" ? Math.ceil(raw.length * 0.75) : 0;
|
|
1929
|
+
const label = `${mime}${approxBytes ? `, ~${approxBytes} bytes` : ""}`;
|
|
1930
|
+
if (mode === "off") return `[Attachment omitted: observation disabled; ${label}]`;
|
|
1931
|
+
if (!mime.startsWith("image/")) {
|
|
1932
|
+
const reason = `unsupported mime ${mime}`;
|
|
1933
|
+
omissions.push(reason);
|
|
1934
|
+
return `[Attachment omitted: ${reason}; ${label}]`;
|
|
1935
|
+
}
|
|
1936
|
+
if (approxBytes > MAX_ATTACHMENT_OBSERVE_BYTES) {
|
|
1937
|
+
const reason = `attachment too large ${approxBytes} > ${MAX_ATTACHMENT_OBSERVE_BYTES} bytes`;
|
|
1938
|
+
if (mode === "on") throw new Error(`OM attachment observation failed: ${reason}`);
|
|
1939
|
+
omissions.push(reason);
|
|
1940
|
+
return `[Attachment omitted: ${reason}; ${label}]`;
|
|
1941
|
+
}
|
|
1942
|
+
if (!modelSupportsAttachments(model)) {
|
|
1943
|
+
const reason = `unsupported observer model ${model?.provider ?? "unknown"}/${model?.id ?? "unknown"}`;
|
|
1944
|
+
if (mode === "on") throw new Error(`OM attachment observation failed: ${reason}`);
|
|
1945
|
+
omissions.push(reason);
|
|
1946
|
+
return `[Attachment omitted: ${reason}; ${label}]`;
|
|
1947
|
+
}
|
|
1948
|
+
const normalized = normalizeAttachmentPartForModel(part);
|
|
1949
|
+
attachmentParts.push(normalized);
|
|
1950
|
+
return `[Attachment included for Observer: ${label}]`;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
function normalizeAttachmentPartForModel(part: any): any {
|
|
1954
|
+
const mimeType = part.mimeType || part.mediaType || part.source?.mediaType;
|
|
1955
|
+
const data = part.data || part.image || part.source?.data;
|
|
1956
|
+
if (part.type === "image") return { ...part, mimeType, data };
|
|
1957
|
+
return { type: "image", mimeType, data };
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1284
1960
|
function formatEntryForObserver(entry: SessionEntry): string {
|
|
1285
1961
|
const msg = entryToAgentMessage(entry);
|
|
1286
1962
|
const createdAt = new Date(entry.timestamp || msg?.timestamp || Date.now());
|
|
@@ -1327,26 +2003,48 @@ function redactSecrets(input: string): string {
|
|
|
1327
2003
|
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer [REDACTED_TOKEN]");
|
|
1328
2004
|
}
|
|
1329
2005
|
|
|
2006
|
+
function isSensitiveObjectKey(key: string): boolean {
|
|
2007
|
+
const normalized = key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
2008
|
+
return /^(api-?key|authorization|auth-?token|access-?token|refresh-?token|secret|password|bearer)$/i.test(normalized);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
1330
2011
|
function redactDeep<T>(value: T): T {
|
|
1331
2012
|
if (typeof value === "string") return redactSecrets(value) as T;
|
|
1332
2013
|
if (Array.isArray(value)) return value.map(item => redactDeep(item)) as T;
|
|
1333
2014
|
if (value && typeof value === "object") {
|
|
1334
2015
|
const out: Record<string, unknown> = {};
|
|
1335
2016
|
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
1336
|
-
out[key] =
|
|
2017
|
+
out[key] = isSensitiveObjectKey(key) ? "[REDACTED_SECRET]" : redactDeep(child);
|
|
1337
2018
|
}
|
|
1338
2019
|
return out as T;
|
|
1339
2020
|
}
|
|
1340
2021
|
return value;
|
|
1341
2022
|
}
|
|
1342
2023
|
|
|
2024
|
+
function shouldObserveAttachment(part: any, state?: PiOMRecord): boolean {
|
|
2025
|
+
const mode = state?.settings.observeAttachments ?? "auto";
|
|
2026
|
+
if (mode === "off") return false;
|
|
2027
|
+
const mime = String(part.mimeType || part.mediaType || "").toLowerCase();
|
|
2028
|
+
if (mode === "auto" && mime && !mime.startsWith("image/")) return false;
|
|
2029
|
+
const raw = part.data || part.image || part.source?.data || "";
|
|
2030
|
+
const approxBytes = typeof raw === "string" ? Math.ceil(raw.length * 0.75) : 0;
|
|
2031
|
+
return approxBytes <= MAX_ATTACHMENT_OBSERVE_BYTES;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function formatAttachmentPart(part: any): string {
|
|
2035
|
+
const mime = part.mimeType || part.mediaType || "unknown";
|
|
2036
|
+
const raw = part.data || part.image || part.source?.data || "";
|
|
2037
|
+
const approxBytes = typeof raw === "string" ? Math.ceil(raw.length * 0.75) : 0;
|
|
2038
|
+
return `[Attachment observed: ${mime}${approxBytes ? `, ~${approxBytes} bytes` : ""}]`;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
1343
2041
|
function formatContent(content: any, cap: number): string {
|
|
1344
2042
|
if (typeof content === "string") return truncateText(redactSecrets(content), cap);
|
|
1345
2043
|
if (!Array.isArray(content)) return truncateText(redactSecrets(JSON.stringify(redactDeep(content))), cap);
|
|
1346
2044
|
return content.map(part => {
|
|
1347
2045
|
if (part.type === "text") return redactSecrets(part.text);
|
|
1348
2046
|
if (part.type === "thinking") return `[Thinking]: ${redactSecrets(part.thinking ?? "")}`;
|
|
1349
|
-
if (part.type === "image") return runtime.state
|
|
2047
|
+
if (part.type === "image") return shouldObserveAttachment(part, runtime.state) ? formatAttachmentPart(part) : "[Image omitted: attachment observation disabled or unsupported]";
|
|
1350
2048
|
if (part.type === "toolCall") return `[Tool Call ${part.name}: ${truncateText(redactSecrets(JSON.stringify(redactDeep(part.arguments ?? {}))), 2_000)}]`;
|
|
1351
2049
|
return `[${part.type}: ${truncateText(redactSecrets(JSON.stringify(redactDeep(part))), 2_000)}]`;
|
|
1352
2050
|
}).join("\n").slice(0, cap);
|
|
@@ -1391,8 +2089,8 @@ function responseText(response: any): string {
|
|
|
1391
2089
|
|
|
1392
2090
|
function formatShortStatus(state: PiOMRecord): string {
|
|
1393
2091
|
if (!state.enabled) return "om: disabled";
|
|
1394
|
-
const msgPct =
|
|
1395
|
-
const memPct =
|
|
2092
|
+
const msgPct = boundedPercent(state.pendingMessageTokens, effectiveObservationThreshold(state));
|
|
2093
|
+
const memPct = boundedPercent(state.observationTokens, state.thresholds.reflection);
|
|
1396
2094
|
const buffer = state.buffered.observations.length ? ` | buf ${state.buffered.observations.length} ↓${formatTokens(state.buffered.observations.reduce((s, c) => s + c.messageTokens, 0))}` : "";
|
|
1397
2095
|
const err = state.status === "failed" && state.lastError ? ` | ${truncateText(state.lastError, 50).replace(/\n/g, " ")}` : "";
|
|
1398
2096
|
return `om: ${state.status} | msg ${formatTokens(state.pendingMessageTokens)}/${formatTokens(effectiveObservationThreshold(state))} ${msgPct}% | mem ${formatTokens(state.observationTokens)}/${formatTokens(state.thresholds.reflection)} ${memPct}%${buffer}${err}`;
|
|
@@ -1401,16 +2099,16 @@ function formatShortStatus(state: PiOMRecord): string {
|
|
|
1401
2099
|
function formatShortStatusColored(state: PiOMRecord): string {
|
|
1402
2100
|
if (!state.enabled) return "\x1b[2;31mom: disabled\x1b[0m";
|
|
1403
2101
|
|
|
1404
|
-
const msgPct =
|
|
1405
|
-
const memPct =
|
|
2102
|
+
const msgPct = boundedPercent(state.pendingMessageTokens, effectiveObservationThreshold(state));
|
|
2103
|
+
const memPct = boundedPercent(state.observationTokens, state.thresholds.reflection);
|
|
1406
2104
|
|
|
1407
2105
|
let statusColor = "\x1b[32m"; // green for idle
|
|
1408
2106
|
if (state.status === "failed") statusColor = "\x1b[91m";
|
|
1409
2107
|
else if (state.status !== "idle") statusColor = "\x1b[93m"; // yellow for active ops
|
|
1410
2108
|
|
|
1411
2109
|
// Highlight percentages
|
|
1412
|
-
const msgPctStr = msgPct
|
|
1413
|
-
const memPctStr = memPct
|
|
2110
|
+
const msgPctStr = formatPercentColored(msgPct);
|
|
2111
|
+
const memPctStr = formatPercentColored(memPct);
|
|
1414
2112
|
|
|
1415
2113
|
const buffer = state.buffered.observations.length
|
|
1416
2114
|
? ` \x1b[2;37m|\x1b[0m buf \x1b[1;36m${state.buffered.observations.length}\x1b[0m \x1b[36m↓${formatTokens(state.buffered.observations.reduce((s, c) => s + c.messageTokens, 0))}\x1b[0m`
|
|
@@ -1423,6 +2121,18 @@ function formatShortStatusColored(state: PiOMRecord): string {
|
|
|
1423
2121
|
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}`;
|
|
1424
2122
|
}
|
|
1425
2123
|
|
|
2124
|
+
function boundedPercent(value: number, total: number): number {
|
|
2125
|
+
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return 0;
|
|
2126
|
+
return Math.min(100, Math.max(0, Math.round((value / total) * 100)));
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function formatPercentColored(percent: number): string {
|
|
2130
|
+
const suffix = percent >= 100 ? "%+" : "%";
|
|
2131
|
+
if (percent >= 80) return `\x1b[1;91m${percent}${suffix}\x1b[0m`;
|
|
2132
|
+
if (percent >= 50) return `\x1b[1;93m${percent}${suffix}\x1b[0m`;
|
|
2133
|
+
return `\x1b[1;32m${percent}${suffix}\x1b[0m`;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
1426
2136
|
function parsePositiveIntSetting(key: string, value: string): number {
|
|
1427
2137
|
const n = Number(value);
|
|
1428
2138
|
if (!Number.isFinite(n) || n <= 0) throw new Error(`OM setting ${key} must be a positive number`);
|
|
@@ -1443,6 +2153,20 @@ function parseBooleanSetting(key: string, value: string): boolean {
|
|
|
1443
2153
|
throw new Error(`OM setting ${key} must be on/off or true/false`);
|
|
1444
2154
|
}
|
|
1445
2155
|
|
|
2156
|
+
function parseAttachmentSetting(key: string, value: string): ObserveAttachmentsMode {
|
|
2157
|
+
const normalized = value.trim().toLowerCase();
|
|
2158
|
+
if (["auto", "default"].includes(normalized)) return "auto";
|
|
2159
|
+
if (["1", "true", "yes", "on", "enabled"].includes(normalized)) return "on";
|
|
2160
|
+
if (["0", "false", "no", "off", "disabled"].includes(normalized)) return "off";
|
|
2161
|
+
throw new Error(`OM setting ${key} must be auto/on/off`);
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
function parseRetrievalProvider(key: string, value: string): RetrievalProvider {
|
|
2165
|
+
const normalized = value.trim().toLowerCase();
|
|
2166
|
+
if (["off", "local", "gemini"].includes(normalized)) return normalized as RetrievalProvider;
|
|
2167
|
+
throw new Error(`OM setting ${key} must be off, local, or gemini`);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
1446
2170
|
function applySetting(state: PiOMRecord, key: string | undefined, value: string): void {
|
|
1447
2171
|
if (!key) throw new Error("Missing OM setting key");
|
|
1448
2172
|
if (!value) throw new Error(`Missing OM setting value for ${key}`);
|
|
@@ -1455,10 +2179,20 @@ function applySetting(state: PiOMRecord, key: string | undefined, value: string)
|
|
|
1455
2179
|
else if (["observation-model", "observer-model", "observe-model"].includes(normalized)) state.settings.observationModel = value.trim();
|
|
1456
2180
|
else if (["reflection-model", "reflector-model", "reflect-model"].includes(normalized)) state.settings.reflectionModel = value.trim();
|
|
1457
2181
|
else if (normalized === "caveman") state.settings.caveman = parseBooleanSetting(key, value);
|
|
1458
|
-
else if (["attachments", "observe-attachments", "attachment-observation"].includes(normalized)) state.settings.observeAttachments =
|
|
2182
|
+
else if (["attachments", "observe-attachments", "attachment-observation"].includes(normalized)) state.settings.observeAttachments = parseAttachmentSetting(key, value);
|
|
2183
|
+
else if (["retrieval", "retrieval-provider", "vector-provider"].includes(normalized)) {
|
|
2184
|
+
state.settings.retrieval.provider = parseRetrievalProvider(key, value);
|
|
2185
|
+
if (state.settings.retrieval.provider === "gemini" && state.settings.retrieval.model === "local/hash-bow-v1") state.settings.retrieval.model = "gemini-embedding-001";
|
|
2186
|
+
if (state.settings.retrieval.provider === "local" && state.settings.retrieval.model === "gemini-embedding-001") state.settings.retrieval.model = "local/hash-bow-v1";
|
|
2187
|
+
state.vectorIndex = undefined;
|
|
2188
|
+
}
|
|
2189
|
+
else if (["retrieval-model", "embedding-model", "vector-model"].includes(normalized)) { state.settings.retrieval.model = value.trim(); state.vectorIndex = undefined; }
|
|
2190
|
+
else if (["retrieval-top-k", "top-k", "vector-top-k"].includes(normalized)) state.settings.retrieval.topK = clampInt(parsePositiveIntSetting(key, value), 1, 30);
|
|
2191
|
+
else if (["retrieval-threshold", "vector-threshold"].includes(normalized)) state.settings.retrieval.threshold = parseRatioSetting(key, value);
|
|
1459
2192
|
else if (normalized === "scope") {
|
|
1460
2193
|
const scope = value.trim().toLowerCase();
|
|
1461
2194
|
if (scope !== "session" && scope !== "project") throw new Error("OM scope must be session or project");
|
|
2195
|
+
if (state.scope !== scope) state.vectorIndex = undefined;
|
|
1462
2196
|
state.scope = scope;
|
|
1463
2197
|
} else {
|
|
1464
2198
|
throw new Error(`Unknown OM setting: ${key}`);
|
|
@@ -1475,14 +2209,21 @@ function formatSettingsText(state: PiOMRecord): string {
|
|
|
1475
2209
|
` observation-model: ${state.settings.observationModel}`,
|
|
1476
2210
|
` reflection-model: ${state.settings.reflectionModel}`,
|
|
1477
2211
|
` caveman: ${state.settings.caveman ? "on" : "off"}`,
|
|
1478
|
-
` attachments: ${state.settings.observeAttachments
|
|
2212
|
+
` attachments: ${state.settings.observeAttachments}`,
|
|
1479
2213
|
` scope: ${state.scope}`,
|
|
2214
|
+
` retrieval: ${state.settings.retrieval.provider}`,
|
|
2215
|
+
` retrieval-model: ${state.settings.retrieval.model}`,
|
|
2216
|
+
` retrieval-top-k: ${state.settings.retrieval.topK}`,
|
|
2217
|
+
` retrieval-threshold: ${state.settings.retrieval.threshold}`,
|
|
2218
|
+
` vector-index: ${state.vectorIndex ? `${state.vectorIndex.entries.length} entries (${state.vectorIndex.provider}/${state.vectorIndex.model})` : "none"}`,
|
|
1480
2219
|
"",
|
|
1481
2220
|
"usage:",
|
|
1482
2221
|
" /om set observation-threshold 30000",
|
|
1483
2222
|
" /om set reflection-threshold 40000",
|
|
1484
2223
|
" /om set observation-model google/gemini-2.5-flash",
|
|
1485
2224
|
" /om set caveman on",
|
|
2225
|
+
" /om set retrieval local",
|
|
2226
|
+
" /om vector status|rebuild|search <query>",
|
|
1486
2227
|
" /om reset",
|
|
1487
2228
|
].join("\n");
|
|
1488
2229
|
}
|
|
@@ -1670,7 +2411,7 @@ async function writeDebug(ctx: any, name: string, payload: unknown): Promise<voi
|
|
|
1670
2411
|
const dir = runtime.debugDir || join(ctx.cwd, CONFIG_DIR_NAME, "om", "debug");
|
|
1671
2412
|
await mkdir(dir, { recursive: true });
|
|
1672
2413
|
const file = join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${sanitizeFileName(name)}.json`);
|
|
1673
|
-
await writeFile(file,
|
|
2414
|
+
await writeFile(file, JSON.stringify(redactDeep({ extension: EXTENSION_ID, sessionId: state.sessionId, ...payload as any }), null, 2) + "\n", "utf8");
|
|
1674
2415
|
} catch (error) {
|
|
1675
2416
|
// Ignore debug write errors if context is stale
|
|
1676
2417
|
}
|
|
@@ -1709,6 +2450,14 @@ export const __test = {
|
|
|
1709
2450
|
atomicWriteJson,
|
|
1710
2451
|
applySetting,
|
|
1711
2452
|
formatSettingsText,
|
|
2453
|
+
suppressDuplicateObservations,
|
|
2454
|
+
buildLocalVectorIndex,
|
|
2455
|
+
retrieveRelevantObservations,
|
|
2456
|
+
buildObserverInput,
|
|
2457
|
+
modelSupportsAttachments,
|
|
2458
|
+
formatVectorStatus,
|
|
2459
|
+
shouldObserveAttachment,
|
|
2460
|
+
geminiEmbedding,
|
|
1712
2461
|
};
|
|
1713
2462
|
|
|
1714
2463
|
class OMOverlay {
|