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.
@@ -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 = 2;
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\nCAVEMAN MODE: Write brutally short, dense observations. Remove filler. Preserve facts, decisions, dates, paths, and errors only.` : "";
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\nCAVEMAN MODE: Compress aggressively. Prefer terse facts over prose. Preserve only actionable, durable memory.` : "";
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: boolean;
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 ensureState(ctx);
388
- await refreshCounts(ctx);
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 omMessage = buildOMContextMessage(state);
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 || state.status === "failed") return;
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 statePath = join(dir, `${sanitizeFileName(sessionId)}.json`);
728
- runtime.statePath = statePath;
885
+ const legacyStatePath = join(dir, `${sanitizeFileName(sessionId)}.json`);
729
886
  runtime.debugDir = debugDir;
730
887
  let state: PiOMRecord | undefined;
731
- if (existsSync(statePath)) {
888
+ let statePath = legacyStatePath;
889
+ let recoveryWarning: string | undefined;
890
+ if (existsSync(legacyStatePath)) {
732
891
  try {
733
- state = normalizeState(JSON.parse(await readFile(statePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
892
+ state = normalizeState(JSON.parse(await readFile(legacyStatePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
734
893
  } catch (error) {
735
- const backupPath = `${statePath}${ATOMIC_BACKUP_SUFFIX}`;
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
- throw new Error(`Failed to load OM state ${statePath} and backup ${backupPath}: ${errorMessage(error)}`);
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
- throw new Error(`Failed to load OM state ${statePath}: ${errorMessage(error)}`);
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
- await saveState(state);
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 { observationModel: DEFAULT_OBSERVATION_MODEL, reflectionModel: DEFAULT_REFLECTION_MODEL, caveman: false, observeAttachments: true };
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: { ...defaults.settings, ...(raw?.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 = redactSecrets(JSON.stringify(value, null, 2)) + "\n";
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
- state.pendingMessageTokens = estimateEntriesTokens(pending);
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) return;
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 inputText = opts.manualText ?? formatEntriesForObserver(selected);
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: ${errorMessage(error)}`;
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 inputText = formatEntriesForObserver(selected);
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: [{ 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 })}` }] },
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 model = ctx.modelRegistry.find(provider, id);
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 buildOMContextMessage(state: PiOMRecord): AgentMessage {
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] = /apiKey|authorization|token|secret|password/i.test(key) ? "[REDACTED_SECRET]" : redactDeep(child);
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?.settings.observeAttachments === false ? "[Image omitted: attachment observation disabled]" : `[Image: ${part.mimeType ?? "unknown"}]`;
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 = Math.min(999, Math.round((state.pendingMessageTokens / effectiveObservationThreshold(state)) * 100));
1395
- const memPct = Math.min(999, Math.round((state.observationTokens / state.thresholds.reflection) * 100));
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 = Math.min(999, Math.round((state.pendingMessageTokens / effectiveObservationThreshold(state)) * 100));
1405
- const memPct = Math.min(999, Math.round((state.observationTokens / state.thresholds.reflection) * 100));
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 >= 80 ? `\x1b[1;91m${msgPct}%\x1b[0m` : msgPct >= 50 ? `\x1b[1;93m${msgPct}%\x1b[0m` : `\x1b[1;32m${msgPct}%\x1b[0m`;
1413
- const memPctStr = memPct >= 80 ? `\x1b[1;91m${memPct}%\x1b[0m` : memPct >= 50 ? `\x1b[1;93m${memPct}%\x1b[0m` : `\x1b[1;32m${memPct}%\x1b[0m`;
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 = parseBooleanSetting(key, value);
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 ? "on" : "off"}`,
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, redactSecrets(JSON.stringify(redactDeep({ extension: EXTENSION_ID, sessionId: state.sessionId, ...payload as any }), null, 2)) + "\n", "utf8");
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 {