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 +43 -0
- package/README.md +16 -4
- package/extensions/index.ts +684 -35
- package/package.json +1 -1
- package/scripts/test.mjs +65 -4
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
|
|
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
|
|
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
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
9
9
|
import { Type } from "typebox";
|
|
10
|
-
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { copyFile, mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
11
11
|
import { existsSync } from "node:fs";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
12
13
|
import { basename, dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
13
15
|
|
|
14
16
|
const EXTENSION_ID = "pi-observational-memory";
|
|
15
|
-
const STATE_VERSION =
|
|
17
|
+
const STATE_VERSION = 3;
|
|
16
18
|
const DEFAULT_OBSERVATION_MODEL = "google/gemini-2.5-flash";
|
|
17
19
|
const DEFAULT_REFLECTION_MODEL = "google/gemini-2.5-flash";
|
|
18
20
|
const OBSERVATION_THRESHOLD = 30_000;
|
|
@@ -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\
|
|
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\
|
|
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:
|
|
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
|
|
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
|
|
728
|
-
runtime.statePath = statePath;
|
|
870
|
+
const legacyStatePath = join(dir, `${sanitizeFileName(sessionId)}.json`);
|
|
729
871
|
runtime.debugDir = debugDir;
|
|
730
872
|
let state: PiOMRecord | undefined;
|
|
731
|
-
|
|
873
|
+
let statePath = legacyStatePath;
|
|
874
|
+
if (existsSync(legacyStatePath)) {
|
|
732
875
|
try {
|
|
733
|
-
state = normalizeState(JSON.parse(await readFile(
|
|
876
|
+
state = normalizeState(JSON.parse(await readFile(legacyStatePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
734
877
|
} catch (error) {
|
|
735
|
-
const backupPath = `${
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
|
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: [
|
|
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
|
|
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
|
|
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] =
|
|
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
|
|
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 =
|
|
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
|
|
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,
|
|
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.
|
|
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,
|
|
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,
|
|
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,
|
|
61
|
-
|
|
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);
|