pi-observational-memory-extension 0.1.2 → 0.2.0

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