pi-observational-memory-extension 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ---
9
+
10
+ ### 📖 How to Work with This Changelog
11
+
12
+ When preparing a new release:
13
+ 1. **Always use Semantic Versioning** (`[Major.Minor.Patch]`).
14
+ 2. **Group changes under standard subheadings**:
15
+ - `Added` for new features.
16
+ - `Changed` for changes in existing functionality.
17
+ - `Deprecated` for soon-to-be removed features.
18
+ - `Removed` for now removed features.
19
+ - `Fixed` for any bug fixes.
20
+ - `Security` in case of vulnerabilities or security updates.
21
+ 3. **Keep descriptions concise, technical, and objective** (lowcase, founder-builder voice).
22
+ 4. **Link references or commit highlights** if appropriate.
23
+
24
+ ---
25
+
26
+ ## [0.2.0] - 2026-06-23
27
+
28
+ ### Added
29
+ - **retrieval**: Added vector retrieval for observations before context injection.
30
+ - **retrieval**: Added local hash/BOW retrieval as the default offline provider.
31
+ - **retrieval**: Added configurable Gemini embedding retrieval with `gemini-embedding-001`.
32
+ - **scope**: Added `session` and `project` memory scopes with separate durable storage paths.
33
+ - **attachments**: Added `auto`, `on`, and `off` attachment observation modes with image and size safeguards.
34
+ - **caveman**: Replaced the simplified terse mode with Mastra-style caveman compression instructions.
35
+ - **dedupe**: Added exact-hash and similarity-based duplicate suppression before appending observations.
36
+
37
+ ### Changed
38
+ - **state**: Migrated OM state schema to v3 for retrieval, vectors, attachment modes, and scoped storage.
39
+ - **settings**: Extended `/om set` with `scope`, `retrieval`, `retrieval-model`, `retrieval-top-k`, and `retrieval-threshold`.
40
+
41
+ ### Fixed
42
+ - **state**: Resolved JSON state file corruption caused by running regex redactions after serialization.
43
+ - **redaction**: Refined token/secret sanitization to avoid over-redacting non-sensitive metrics (e.g., `observationTokens`).
44
+ - **scope**: Added durable session pointer resolution, ensuring project scope selection persists across separate run processes.
45
+ - **durability**: Added automatic `.corrupted` rename recovery for unparseable state files instead of crashing.
46
+
47
+ ### Tested
48
+ - **regression**: Added tests for scope settings, attachment gates, duplicate suppression, and local retrieval.
49
+ - **provider**: Smoke-tested Gemini embeddings against `gemini-embedding-001` with valid 3072-dimensional normalized vectors.
50
+
8
51
  ## [0.1.3] - 2026-06-23
9
52
 
10
53
  ### Added
package/README.md CHANGED
@@ -25,9 +25,13 @@ Legacy compaction compresses raw history into a single monolithic block of text,
25
25
  - **Async Buffering:** Extracts observations in the background at regular intervals (every 20% of the threshold) so that when the main threshold is hit, the accumulated memory is swapped/activated instantly with zero LLM-latency overhead.
26
26
  - **Adaptive Thresholds:** The active message-observation threshold expands dynamically based on remaining memory capacity in the reflection pool, maximizing raw context usage safely.
27
27
  - **Recall-driven Retrieval:** Exposes an `om_recall` tool to the Actor so it can retrieve full, raw message payloads from observed history when exact code, quotes, or numbers are needed.
28
+ - **Vector Retrieval:** Ranks relevant observations before context injection. Local hash/BOW retrieval is enabled by default; Gemini embeddings (`gemini-embedding-001`) are available through `/om set retrieval gemini`.
29
+ - **Session or Project Scope:** Stores memory either per session head or shared project memory via `/om set scope session|project`.
30
+ - **Attachment Observation Modes:** Supports `auto`, `on`, and `off` with image-only auto mode and size limits to avoid unsafe prompt bloat.
31
+ - **Duplicate Suppression:** Skips exact and near-duplicate observations before appending new memory.
28
32
  - **Custom Compaction Hook:** Plugs directly into Pi's `session_before_compact` lifecycle event. Typing `/compact` or triggering auto-compaction launches the Observer/Reflector memory consolidation flow instead of Pi's legacy summary compaction.
29
33
  - **TUI Overlay Panel:** Fully custom, responsive, multi-tab overlay (`/om-memory`) in interactive mode for inspecting memory. Features tabbed navigation, smooth scroll, and precise border framing.
30
- - **Durable Persistence:** Serializes and loads state files safely under `.pi/om/<session-id>.json` using atomic writes and `.bak` recovery. Outputs JSON-formatted diagnostic logs to `.pi/om/debug/` for every operation.
34
+ - **Durable Persistence:** Serializes and loads state files safely under `.pi/om/sessions/<session-id>.json` or `.pi/om/projects/project.json` using atomic writes and `.bak` recovery. Outputs JSON-formatted diagnostic logs to `.pi/om/debug/` for every operation.
31
35
  - **Secret Redaction:** Redacts common API keys, npm/GitHub tokens, bearer tokens, passwords, and secret fields before writing state/debug artifacts or observer text.
32
36
  - **Stale Lock Recovery:** Recovers old interrupted OM operation locks automatically so a crashed or killed process does not block future memory runs.
33
37
  - **Bounded Observer Context:** Sends only a safe tail of previous observations to the Observer, preventing recursive prompt bloat while preserving full memory in the main runtime.
@@ -36,7 +40,7 @@ Legacy compaction compresses raw history into a single monolithic block of text,
36
40
 
37
41
  ## 📋 Configuration & Settings
38
42
 
39
- Use `/om` in Pi to inspect and update runtime settings. Settings are persisted in the session OM state file under `.pi/om/<session-id>.json`.
43
+ Use `/om` in Pi to inspect and update runtime settings. Settings are persisted in the OM state file under `.pi/om/sessions/<session-id>.json` or `.pi/om/projects/project.json`.
40
44
 
41
45
  ```bash
42
46
  /om
@@ -48,7 +52,13 @@ Use `/om` in Pi to inspect and update runtime settings. Settings are persisted i
48
52
  /om set observation-model google/gemini-2.5-flash
49
53
  /om set reflection-model google/gemini-2.5-flash
50
54
  /om set caveman on
51
- /om set attachments off
55
+ /om set attachments auto
56
+ /om set scope project
57
+ /om set retrieval local
58
+ /om set retrieval gemini
59
+ /om set retrieval-model gemini-embedding-001
60
+ /om set retrieval-top-k 6
61
+ /om set retrieval-threshold 12%
52
62
  /om reset
53
63
  ```
54
64
 
@@ -56,7 +66,9 @@ Use `/om` in Pi to inspect and update runtime settings. Settings are persisted i
56
66
  - **Reflection Trigger Threshold:** default `40,000` observation tokens.
57
67
  - **Default Models:** `google/gemini-2.5-flash` with 0.3 temperature for Observer, and 0.0 temperature for Reflector.
58
68
  - **Caveman Mode:** optional terse compression style for denser memory.
59
- - **Attachment Observation Toggle:** controls whether image/attachment placeholders are exposed to observation text.
69
+ - **Attachment Observation Mode:** `auto` observes small images only, `on` allows supported attachments, and `off` omits them.
70
+ - **Scope:** `session` keeps each session isolated; `project` shares one memory file for the current project.
71
+ - **Retrieval:** `local` is the default and works offline; `gemini` uses Gemini embeddings when `GEMINI_API_KEY` or `GOOGLE_GENERATIVE_AI_API_KEY` is available; `off` disables vector retrieval.
60
72
 
61
73
  ---
62
74
 
@@ -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 { 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;
@@ -28,6 +30,11 @@ const MAX_RESTART_ERRORS = 3;
28
30
  const STALE_OPERATION_LOCK_MS = 15 * 60 * 1000;
29
31
  const PREVIOUS_OBSERVATIONS_MAX_TOKENS = 2_000;
30
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;
31
38
 
32
39
  const OBSERVATION_CONTEXT_PROMPT = `The following observations block contains your memory of past conversations with this user.`;
33
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.
@@ -52,6 +59,29 @@ Your memory is comprised of observations which may mention raw message IDs. The
52
59
 
53
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.`;
54
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
+
55
85
  const OBSERVER_EXTRACTION_INSTRUCTIONS = `CRITICAL: DISTINGUISH USER ASSERTIONS FROM QUESTIONS
56
86
 
57
87
  When the user TELLS you something about themselves, mark it as an assertion:
@@ -169,7 +199,7 @@ const OBSERVER_GUIDELINES = `- Be specific enough for the assistant to act on
169
199
  - If the user provides detailed messages or code snippets, observe all important details`;
170
200
 
171
201
  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.` : "";
202
+ const cavemanInstruction = caveman ? `\n\n${CAVEMAN_OM_INSTRUCTION}` : "";
173
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.
174
204
 
175
205
  Extract observations that will help the assistant remember:
@@ -217,7 +247,7 @@ function buildObserverTaskPrompt(existingObservations: string | undefined, opts:
217
247
  }
218
248
 
219
249
  function buildReflectorSystemPrompt(caveman = false): string {
220
- const cavemanInstruction = caveman ? `\n\nCAVEMAN MODE: Compress aggressively. Prefer terse facts over prose. Preserve only actionable, durable memory.` : "";
250
+ const cavemanInstruction = caveman ? `\n\n${CAVEMAN_OM_INSTRUCTION}` : "";
221
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.
222
252
 
223
253
  The following instructions were given to another part of your psyche (the observer) to create memories.
@@ -299,11 +329,20 @@ type BufferedChunk = {
299
329
  createdAt: string;
300
330
  };
301
331
 
332
+ type ObserveAttachmentsMode = "auto" | "on" | "off";
333
+ type RetrievalProvider = "off" | "local" | "gemini";
334
+
302
335
  type PiOMSettings = {
303
336
  observationModel: string;
304
337
  reflectionModel: string;
305
338
  caveman: boolean;
306
- observeAttachments: boolean;
339
+ observeAttachments: ObserveAttachmentsMode;
340
+ retrieval: {
341
+ provider: RetrievalProvider;
342
+ model: string;
343
+ topK: number;
344
+ threshold: number;
345
+ };
307
346
  };
308
347
 
309
348
  type PiOMRecord = {
@@ -328,7 +367,25 @@ type PiOMRecord = {
328
367
  lastError?: string;
329
368
  lastProvider?: string;
330
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;
331
387
  updatedAt: string;
388
+ entries: VectorIndexEntry[];
332
389
  };
333
390
 
334
391
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
@@ -350,6 +407,61 @@ type Runtime = {
350
407
 
351
408
  const runtime: Runtime = { failureCount: 0 };
352
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
+
353
465
  export default function (pi: ExtensionAPI) {
354
466
  pi.registerTool({
355
467
  name: "om_recall",
@@ -388,6 +500,13 @@ export default function (pi: ExtensionAPI) {
388
500
  await refreshCounts(ctx);
389
501
  updateStatus(ctx);
390
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
+ }
391
510
  });
392
511
 
393
512
  pi.on("session_shutdown", async (_event, ctx) => {
@@ -451,7 +570,10 @@ export default function (pi: ExtensionAPI) {
451
570
  .filter(isMessageLikeEntry)
452
571
  .map(entryToAgentMessage)
453
572
  .filter(Boolean) as AgentMessage[];
454
- 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);
455
577
  const finalMessages = [omMessage, ...unobserved];
456
578
  if (finalMessages.length <= 1) return { messages: [omMessage, ...event.messages.slice(-8)] };
457
579
  return { messages: finalMessages };
@@ -624,6 +746,27 @@ export default function (pi: ExtensionAPI) {
624
746
  ctx.ui.notify(formatMemoryText(state), "info");
625
747
  return;
626
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
+ }
627
770
  if (cmd === "set") {
628
771
  const key = rest[0];
629
772
  const value = rest.slice(1).join(" ");
@@ -724,38 +867,98 @@ async function ensureState(ctx: any): Promise<PiOMRecord> {
724
867
  const dir = join(ctx.cwd, CONFIG_DIR_NAME, "om");
725
868
  const debugDir = join(dir, "debug");
726
869
  await mkdir(debugDir, { recursive: true });
727
- const statePath = join(dir, `${sanitizeFileName(sessionId)}.json`);
728
- runtime.statePath = statePath;
870
+ const legacyStatePath = join(dir, `${sanitizeFileName(sessionId)}.json`);
729
871
  runtime.debugDir = debugDir;
730
872
  let state: PiOMRecord | undefined;
731
- if (existsSync(statePath)) {
873
+ let statePath = legacyStatePath;
874
+ if (existsSync(legacyStatePath)) {
732
875
  try {
733
- state = normalizeState(JSON.parse(await readFile(statePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
876
+ state = normalizeState(JSON.parse(await readFile(legacyStatePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
734
877
  } catch (error) {
735
- const backupPath = `${statePath}${ATOMIC_BACKUP_SUFFIX}`;
878
+ const backupPath = `${legacyStatePath}${ATOMIC_BACKUP_SUFFIX}`;
736
879
  if (existsSync(backupPath)) {
737
880
  try {
738
881
  state = normalizeState(JSON.parse(await readFile(backupPath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
739
882
  } catch {
740
- throw new Error(`Failed to load OM state ${statePath} and backup ${backupPath}: ${errorMessage(error)}`);
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 {}
741
888
  }
742
889
  } else {
743
- throw new Error(`Failed to load OM state ${statePath}: ${errorMessage(error)}`);
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 {}
744
894
  }
745
895
  }
746
896
  }
747
897
  if (!state) {
748
898
  state = createDefaultState({ sessionId, sessionFile, cwd: ctx.cwd });
749
- await saveState(state);
750
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;
751
948
  const recovered = recoverStaleOperationLock(state);
752
949
  runtime.state = state;
753
- if (recovered || state.version !== STATE_VERSION) await saveState(state);
950
+ if (recovered || state.version !== STATE_VERSION || !existsSync(statePath)) await saveState(state);
754
951
  return state;
755
952
  }
756
953
 
757
954
  function defaultSettings(): PiOMSettings {
758
- return { observationModel: DEFAULT_OBSERVATION_MODEL, reflectionModel: DEFAULT_REFLECTION_MODEL, caveman: false, observeAttachments: true };
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
+ };
759
962
  }
760
963
 
761
964
  function defaultThresholds(): PiOMRecord["thresholds"] {
@@ -797,17 +1000,74 @@ function normalizeState(raw: any, identity: { sessionId: string; sessionFile?: s
797
1000
  sessionFile: raw?.sessionFile || identity.sessionFile,
798
1001
  cwd: raw?.cwd || identity.cwd,
799
1002
  thresholds: { ...defaults.thresholds, ...(raw?.thresholds ?? {}) },
800
- settings: { ...defaults.settings, ...(raw?.settings ?? {}) },
1003
+ settings: normalizeSettings(raw?.settings, defaults.settings),
801
1004
  buffered: { observations: [], ...(raw?.buffered ?? {}) },
802
1005
  } as PiOMRecord;
803
1006
  state.observations = redactSecrets(String(state.observations ?? ""));
804
1007
  state.currentTask = state.currentTask ? redactSecrets(state.currentTask) : undefined;
805
1008
  state.suggestedResponse = state.suggestedResponse ? redactSecrets(state.suggestedResponse) : undefined;
806
1009
  state.lastError = state.lastError ? redactSecrets(state.lastError) : undefined;
1010
+ state.scope = state.scope === "project" ? "project" : "session";
807
1011
  state.observationTokens = estimateTokens(state.observations);
1012
+ state.vectorIndex = normalizeVectorIndex(state.vectorIndex, state);
808
1013
  return state;
809
1014
  }
810
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
+
811
1071
  function isStaleOperationLock(lock: PiOMRecord["operationLock"], now = Date.now()): boolean {
812
1072
  if (!lock) return false;
813
1073
  const started = Date.parse(lock.startedAt);
@@ -825,21 +1085,55 @@ function recoverStaleOperationLock(state: PiOMRecord, now = Date.now()): boolean
825
1085
  }
826
1086
 
827
1087
  async function saveState(state: PiOMRecord): Promise<void> {
1088
+ runtime.statePath = statePathFor(state.cwd, state.scope, state.sessionId);
828
1089
  if (!runtime.statePath) return;
829
1090
  state.updatedAt = new Date().toISOString();
830
1091
  state.observations = redactSecrets(state.observations);
831
1092
  if (state.currentTask) state.currentTask = redactSecrets(state.currentTask);
832
1093
  if (state.suggestedResponse) state.suggestedResponse = redactSecrets(state.suggestedResponse);
833
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);
834
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);
835
1104
  runtime.overlayHandle?.requestRender();
836
1105
  }
837
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
+
838
1132
  async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
839
1133
  await mkdir(dirname(filePath), { recursive: true });
840
1134
  const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
841
1135
  const backupPath = `${filePath}${ATOMIC_BACKUP_SUFFIX}`;
842
- const json = redactSecrets(JSON.stringify(value, null, 2)) + "\n";
1136
+ const json = JSON.stringify(redactDeep(value), null, 2) + "\n";
843
1137
  await writeFile(tmpPath, json, "utf8");
844
1138
  if (existsSync(filePath)) {
845
1139
  try {
@@ -900,7 +1194,10 @@ async function observeNow(ctx: any, opts: { force: boolean; reason: string; sign
900
1194
  if (candidates.length === 0 && !opts.manualText) return;
901
1195
  const selected = opts.force ? candidates : selectEntriesForObservation(candidates, state);
902
1196
  if (selected.length === 0 && !opts.manualText) return;
903
- 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;
904
1201
  const startedAt = new Date().toISOString();
905
1202
  state.operationLock = { type: "observation", startedAt };
906
1203
  state.status = "observing";
@@ -910,7 +1207,7 @@ async function observeNow(ctx: any, opts: { force: boolean; reason: string; sign
910
1207
  runtime.currentOperation = controller;
911
1208
  const signal = mergeAbortSignals(opts.signal, controller.signal);
912
1209
  try {
913
- 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 });
914
1211
  appendObservations(state, result, estimateTokens(inputText));
915
1212
  const last = selected.at(-1);
916
1213
  if (last?.id) state.lastObservedEntryId = last.id;
@@ -918,7 +1215,7 @@ async function observeNow(ctx: any, opts: { force: boolean; reason: string; sign
918
1215
  state.operationLock = undefined;
919
1216
  state.status = "idle";
920
1217
  state.lastError = undefined;
921
- 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 });
922
1219
  await saveState(state);
923
1220
  } catch (error) {
924
1221
  state.operationLock = undefined;
@@ -943,7 +1240,8 @@ async function bufferObservation(ctx: any, reason: string): Promise<void> {
943
1240
  if (tokens < state.thresholds.bufferTokens) return;
944
1241
  const selected = takeEntriesUpTo(candidates, Math.min(tokens, OBSERVER_MAX_BATCH_TOKENS));
945
1242
  if (selected.length === 0) return;
946
- const inputText = formatEntriesForObserver(selected);
1243
+ const observerInput = buildObserverInput(ctx, selected, state);
1244
+ const inputText = observerInput.text;
947
1245
  const startedAt = new Date().toISOString();
948
1246
  state.operationLock = { type: "buffer", startedAt };
949
1247
  state.status = "buffering";
@@ -952,7 +1250,7 @@ async function bufferObservation(ctx: any, reason: string): Promise<void> {
952
1250
  const controller = new AbortController();
953
1251
  runtime.currentOperation = controller;
954
1252
  try {
955
- 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 });
956
1254
  const observations = result.observations.trim();
957
1255
  if (!observations) throw new Error("Observer returned empty buffered observations");
958
1256
  state.buffered.observations.push({
@@ -969,7 +1267,7 @@ async function bufferObservation(ctx: any, reason: string): Promise<void> {
969
1267
  state.operationLock = undefined;
970
1268
  state.status = "idle";
971
1269
  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 });
1270
+ await writeDebug(ctx, "buffer", { startedAt, reason, inputText, attachmentOmissions: observerInput.omissions, attachmentCount: observerInput.attachmentParts.length, rawOutput: result.rawOutput, parsed: result });
973
1271
  await saveState(state);
974
1272
  } catch (error) {
975
1273
  state.operationLock = undefined;
@@ -1019,6 +1317,7 @@ async function activateBuffered(ctx: any, reason: string): Promise<void> {
1019
1317
  state.buffered.observations = chunks.slice(activated.length);
1020
1318
  state.pendingMessageTokens = Math.max(0, state.pendingMessageTokens - activatedTokens);
1021
1319
  state.observationTokens = estimateTokens(state.observations);
1320
+ state.vectorIndex = buildLocalVectorIndex(state);
1022
1321
  state.lastOperation = { type: "activation", startedAt, endedAt: new Date().toISOString(), inputTokens: activatedTokens, outputTokens: estimateTokens(observations) };
1023
1322
  state.operationLock = undefined;
1024
1323
  state.status = "idle";
@@ -1065,6 +1364,7 @@ async function reflectNow(ctx: any, opts: { reason: string; signal?: AbortSignal
1065
1364
  state.currentTask = result.currentTask ?? state.currentTask;
1066
1365
  state.suggestedResponse = result.suggestedContinuation ?? state.suggestedResponse;
1067
1366
  state.observationTokens = reflectedTokens;
1367
+ state.vectorIndex = buildLocalVectorIndex(state);
1068
1368
  state.operationLock = undefined;
1069
1369
  state.status = "idle";
1070
1370
  state.lastError = undefined;
@@ -1087,13 +1387,16 @@ async function reflectNow(ctx: any, opts: { reason: string; signal?: AbortSignal
1087
1387
  }
1088
1388
  }
1089
1389
 
1090
- async function runObserver(ctx: any, historyText: string, signal: AbortSignal | undefined, opts: { source: string; existingObservations?: string }): Promise<ObserverResult> {
1390
+ async function runObserver(ctx: any, historyText: string, signal: AbortSignal | undefined, opts: { source: string; existingObservations?: string; attachmentParts?: any[] }): Promise<ObserverResult> {
1091
1391
  const state = await ensureState(ctx);
1092
1392
  const modelId = state.settings.observationModel || DEFAULT_OBSERVATION_MODEL;
1093
1393
  const model = resolveModel(ctx, modelId);
1094
1394
  const response = await runModel(ctx, model, [
1095
1395
  { 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 })}` }] },
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
+ ] },
1097
1400
  ], { temperature: 0.3, maxTokens: 100_000, signal });
1098
1401
  const text = responseText(response);
1099
1402
  const parsed = parseObserverOutput(text);
@@ -1125,7 +1428,11 @@ function resolveModel(ctx: any, modelId: string): any {
1125
1428
  const slash = modelId.indexOf("/");
1126
1429
  const provider = slash >= 0 ? modelId.slice(0, slash) : ctx.model?.provider;
1127
1430
  const id = slash >= 0 ? modelId.slice(slash + 1) : modelId;
1128
- 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;
1129
1436
  if (!model) throw new Error(`Could not find OM model ${provider}/${id}`);
1130
1437
  return model;
1131
1438
  }
@@ -1177,19 +1484,207 @@ function detectDegenerateRepetition(text: string): boolean {
1177
1484
  }
1178
1485
 
1179
1486
  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");
1487
+ const observations = suppressDuplicateObservations(state.observations, result.observations.trim());
1488
+ if (!observations) throw new Error("No new non-duplicate observations to append");
1182
1489
  state.observations = [state.observations.trim(), observations].filter(Boolean).join("\n\n");
1183
1490
  state.currentTask = result.currentTask ?? state.currentTask;
1184
1491
  state.suggestedResponse = result.suggestedContinuation ?? state.suggestedResponse;
1185
1492
  state.observationTokens = estimateTokens(state.observations);
1493
+ state.vectorIndex = buildLocalVectorIndex(state);
1186
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 };
1187
1495
  }
1188
1496
 
1189
- function buildOMContextMessage(state: PiOMRecord): AgentMessage {
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");
1511
+ }
1512
+
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>` : "";
1190
1683
  const sections = [
1191
1684
  OBSERVATION_CONTEXT_PROMPT,
1192
1685
  `<observations>\n${state.observations.trim()}\n</observations>`,
1686
+ retrievalBlock,
1687
+ projectBlock,
1193
1688
  OBSERVATION_CONTEXT_INSTRUCTIONS,
1194
1689
  state.currentTask ? `<current-task>\n${state.currentTask}\n</current-task>` : "",
1195
1690
  state.suggestedResponse ? `<suggested-response>\n${state.suggestedResponse}\n</suggested-response>` : "",
@@ -1281,6 +1776,99 @@ function formatEntriesForObserver(entries: SessionEntry[]): string {
1281
1776
  return entries.map(formatEntryForObserver).filter(Boolean).join("\n\n");
1282
1777
  }
1283
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
+
1284
1872
  function formatEntryForObserver(entry: SessionEntry): string {
1285
1873
  const msg = entryToAgentMessage(entry);
1286
1874
  const createdAt = new Date(entry.timestamp || msg?.timestamp || Date.now());
@@ -1327,26 +1915,48 @@ function redactSecrets(input: string): string {
1327
1915
  .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer [REDACTED_TOKEN]");
1328
1916
  }
1329
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
+
1330
1923
  function redactDeep<T>(value: T): T {
1331
1924
  if (typeof value === "string") return redactSecrets(value) as T;
1332
1925
  if (Array.isArray(value)) return value.map(item => redactDeep(item)) as T;
1333
1926
  if (value && typeof value === "object") {
1334
1927
  const out: Record<string, unknown> = {};
1335
1928
  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);
1929
+ out[key] = isSensitiveObjectKey(key) ? "[REDACTED_SECRET]" : redactDeep(child);
1337
1930
  }
1338
1931
  return out as T;
1339
1932
  }
1340
1933
  return value;
1341
1934
  }
1342
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
+
1343
1953
  function formatContent(content: any, cap: number): string {
1344
1954
  if (typeof content === "string") return truncateText(redactSecrets(content), cap);
1345
1955
  if (!Array.isArray(content)) return truncateText(redactSecrets(JSON.stringify(redactDeep(content))), cap);
1346
1956
  return content.map(part => {
1347
1957
  if (part.type === "text") return redactSecrets(part.text);
1348
1958
  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"}]`;
1959
+ if (part.type === "image") return shouldObserveAttachment(part, runtime.state) ? formatAttachmentPart(part) : "[Image omitted: attachment observation disabled or unsupported]";
1350
1960
  if (part.type === "toolCall") return `[Tool Call ${part.name}: ${truncateText(redactSecrets(JSON.stringify(redactDeep(part.arguments ?? {}))), 2_000)}]`;
1351
1961
  return `[${part.type}: ${truncateText(redactSecrets(JSON.stringify(redactDeep(part))), 2_000)}]`;
1352
1962
  }).join("\n").slice(0, cap);
@@ -1443,6 +2053,20 @@ function parseBooleanSetting(key: string, value: string): boolean {
1443
2053
  throw new Error(`OM setting ${key} must be on/off or true/false`);
1444
2054
  }
1445
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
+
1446
2070
  function applySetting(state: PiOMRecord, key: string | undefined, value: string): void {
1447
2071
  if (!key) throw new Error("Missing OM setting key");
1448
2072
  if (!value) throw new Error(`Missing OM setting value for ${key}`);
@@ -1455,10 +2079,20 @@ function applySetting(state: PiOMRecord, key: string | undefined, value: string)
1455
2079
  else if (["observation-model", "observer-model", "observe-model"].includes(normalized)) state.settings.observationModel = value.trim();
1456
2080
  else if (["reflection-model", "reflector-model", "reflect-model"].includes(normalized)) state.settings.reflectionModel = value.trim();
1457
2081
  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);
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);
1459
2092
  else if (normalized === "scope") {
1460
2093
  const scope = value.trim().toLowerCase();
1461
2094
  if (scope !== "session" && scope !== "project") throw new Error("OM scope must be session or project");
2095
+ if (state.scope !== scope) state.vectorIndex = undefined;
1462
2096
  state.scope = scope;
1463
2097
  } else {
1464
2098
  throw new Error(`Unknown OM setting: ${key}`);
@@ -1475,14 +2109,21 @@ function formatSettingsText(state: PiOMRecord): string {
1475
2109
  ` observation-model: ${state.settings.observationModel}`,
1476
2110
  ` reflection-model: ${state.settings.reflectionModel}`,
1477
2111
  ` caveman: ${state.settings.caveman ? "on" : "off"}`,
1478
- ` attachments: ${state.settings.observeAttachments ? "on" : "off"}`,
2112
+ ` attachments: ${state.settings.observeAttachments}`,
1479
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"}`,
1480
2119
  "",
1481
2120
  "usage:",
1482
2121
  " /om set observation-threshold 30000",
1483
2122
  " /om set reflection-threshold 40000",
1484
2123
  " /om set observation-model google/gemini-2.5-flash",
1485
2124
  " /om set caveman on",
2125
+ " /om set retrieval local",
2126
+ " /om vector status|rebuild|search <query>",
1486
2127
  " /om reset",
1487
2128
  ].join("\n");
1488
2129
  }
@@ -1670,7 +2311,7 @@ async function writeDebug(ctx: any, name: string, payload: unknown): Promise<voi
1670
2311
  const dir = runtime.debugDir || join(ctx.cwd, CONFIG_DIR_NAME, "om", "debug");
1671
2312
  await mkdir(dir, { recursive: true });
1672
2313
  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");
2314
+ await writeFile(file, JSON.stringify(redactDeep({ extension: EXTENSION_ID, sessionId: state.sessionId, ...payload as any }), null, 2) + "\n", "utf8");
1674
2315
  } catch (error) {
1675
2316
  // Ignore debug write errors if context is stale
1676
2317
  }
@@ -1709,6 +2350,14 @@ export const __test = {
1709
2350
  atomicWriteJson,
1710
2351
  applySetting,
1711
2352
  formatSettingsText,
2353
+ suppressDuplicateObservations,
2354
+ buildLocalVectorIndex,
2355
+ retrieveRelevantObservations,
2356
+ buildObserverInput,
2357
+ modelSupportsAttachments,
2358
+ formatVectorStatus,
2359
+ shouldObserveAttachment,
2360
+ geminiEmbedding,
1712
2361
  };
1713
2362
 
1714
2363
  class OMOverlay {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-observational-memory-extension",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Mastra-style Observational Memory extension for Pi compaction and runtime context.",
5
5
  "license": "MIT",
6
6
  "author": "Nikita Nosov <20nik.nosov21@gmail.com>",
package/scripts/test.mjs CHANGED
@@ -40,25 +40,86 @@ __test.applySetting(state, "observation-model", "google/gemini-2.5-flash");
40
40
  __test.applySetting(state, "reflection-threshold", "12345");
41
41
  __test.applySetting(state, "caveman", "on");
42
42
  __test.applySetting(state, "attachments", "off");
43
+ __test.applySetting(state, "retrieval", "local");
44
+ __test.applySetting(state, "retrieval-top-k", "4");
45
+ __test.applySetting(state, "retrieval-threshold", "25%");
46
+ __test.applySetting(state, "scope", "project");
43
47
  assert.equal(state.settings.observationModel, "google/gemini-2.5-flash");
44
48
  assert.equal(state.thresholds.reflection, 12345);
45
49
  assert.equal(state.settings.caveman, true);
46
- assert.equal(state.settings.observeAttachments, false);
50
+ assert.equal(state.settings.observeAttachments, "off");
51
+ assert.equal(state.settings.retrieval.provider, "local");
52
+ assert.equal(state.settings.retrieval.topK, 4);
53
+ assert.equal(state.settings.retrieval.threshold, 0.25);
54
+ assert.equal(state.scope, "project");
55
+ state.vectorIndex = { provider: "local", model: "local/hash-bow-v1", scopeKey: "session:old", updatedAt: new Date().toISOString(), entries: [] };
56
+ __test.applySetting(state, "scope", "session");
57
+ assert.equal(state.scope, "session");
58
+ assert.equal(state.vectorIndex, undefined);
47
59
  assert.throws(() => __test.applySetting(state, "buffer-activation", "2"), /ratio/);
48
60
 
49
61
  // Migration/normalization: v1 state keeps observations and receives v2 settings.
50
62
  const migrated = __test.normalizeState({ version: 1, observations: "token=do-not-keep-this-secret", thresholds: { observation: 10 } }, { sessionId: "m", cwd: process.cwd() });
51
- assert.equal(migrated.version, 2);
63
+ assert.equal(migrated.version, 3);
52
64
  assert.equal(migrated.thresholds.observation, 10);
53
65
  assert.equal(migrated.settings.caveman, false);
66
+ assert.equal(migrated.settings.observeAttachments, "auto");
67
+ assert.equal(migrated.settings.retrieval.provider, "off");
54
68
  assert(!migrated.observations.includes("do-not-keep-this-secret"));
55
69
 
70
+ // Duplicate suppression and local retrieval: exact/similar repeats are skipped, relevant chunks rank first.
71
+ const deduped = __test.suppressDuplicateObservations("* 🔴 User likes Pi loopflows", "* 🔴 User likes Pi loopflows\n* 🟡 Vector retrieval implemented locally");
72
+ assert(!deduped.includes("User likes Pi loopflows"));
73
+ assert(deduped.includes("Vector retrieval"));
74
+ state.observations = "* 🔴 User builds Pi loopflows\n* 🟡 unrelated cooking note\n* ✅ Vector retrieval implemented locally";
75
+ state.vectorIndex = __test.buildLocalVectorIndex(state);
76
+ const retrieved = await __test.retrieveRelevantObservations({ }, state, "local vector retrieval for loopflows");
77
+ assert(retrieved.includes("Vector retrieval"));
78
+ assert(__test.formatVectorStatus(state).includes("provider: local"));
79
+
80
+ // Attachment observation: auto mode accepts small images, rejects non-images and oversized payloads.
81
+ state.settings.observeAttachments = "auto";
82
+ assert.equal(__test.shouldObserveAttachment({ type: "image", mimeType: "image/png", data: "abc" }, state), true);
83
+ assert.equal(__test.shouldObserveAttachment({ type: "image", mimeType: "application/pdf", data: "abc" }, state), false);
84
+ assert.equal(__test.shouldObserveAttachment({ type: "image", mimeType: "image/png", data: "x".repeat(3_000_000) }, state), false);
85
+ state.settings.observeAttachments = "off";
86
+ assert.equal(__test.shouldObserveAttachment({ type: "image", mimeType: "image/png", data: "abc" }, state), false);
87
+
88
+ // Attachment input builder: off/auto never leaks base64 into text; on + unsupported model hard-fails; Gemini model includes image part.
89
+ const attachmentEntry = {
90
+ id: "att-1",
91
+ type: "message",
92
+ timestamp: new Date().toISOString(),
93
+ message: { role: "user", content: [{ type: "text", text: "see image" }, { type: "image", mimeType: "image/png", data: "aGVsbG8=" }] },
94
+ };
95
+ state.settings.observationModel = "local/text-only";
96
+ state.settings.observeAttachments = "off";
97
+ let built = __test.buildObserverInput({ model: { provider: "local", id: "text-only" } }, [attachmentEntry], state);
98
+ assert(built.text.includes("Attachment omitted"));
99
+ assert(!built.text.includes("aGVsbG8="));
100
+ assert.equal(built.attachmentParts.length, 0);
101
+ state.settings.observeAttachments = "auto";
102
+ built = __test.buildObserverInput({ model: { provider: "local", id: "text-only" } }, [attachmentEntry], state);
103
+ assert(built.text.includes("unsupported observer model"));
104
+ assert.equal(built.attachmentParts.length, 0);
105
+ state.settings.observeAttachments = "on";
106
+ assert.throws(() => __test.buildObserverInput({ model: { provider: "local", id: "text-only" } }, [attachmentEntry], state), /unsupported observer model/);
107
+ state.settings.observationModel = "google/gemini-2.5-flash";
108
+ built = __test.buildObserverInput({ model: { provider: "google", id: "gemini-2.5-flash" } }, [attachmentEntry], state);
109
+ assert.equal(built.attachmentParts.length, 1);
110
+ assert.equal(built.attachmentParts[0].type, "image");
111
+
56
112
  // Atomic write: writes valid JSON, creates backup on subsequent write, removes temp files.
57
113
  const dir = await mkdtemp(join(tmpdir(), "pi-om-test-"));
58
114
  try {
59
115
  const file = join(dir, "state.json");
60
- await __test.atomicWriteJson(file, { a: 1, token: "supersecrettoken" });
61
- assert.equal(JSON.parse(await readFile(file, "utf8")).token, "[REDACTED_SECRET]");
116
+ await __test.atomicWriteJson(file, { a: 1, accessToken: "supersecrettoken", observationTokens: 238, pendingMessageTokens: 12, observations: 'Secret check: api_key="sk-live-test-should-redact-1234567890" stays JSON-safe' });
117
+ const firstWrite = JSON.parse(await readFile(file, "utf8"));
118
+ assert.equal(firstWrite.accessToken, "[REDACTED_SECRET]");
119
+ assert.equal(firstWrite.observationTokens, 238);
120
+ assert.equal(firstWrite.pendingMessageTokens, 12);
121
+ assert(firstWrite.observations.includes("[REDACTED_SECRET]"));
122
+ assert(!firstWrite.observations.includes("sk-live-test-should-redact"));
62
123
  await __test.atomicWriteJson(file, { a: 2 });
63
124
  assert.equal(JSON.parse(await readFile(file, "utf8")).a, 2);
64
125
  assert.equal(existsSync(`${file}.bak`), true);