@simbimbo/memory-ocmemog 0.1.14 → 0.1.16
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 +18 -0
- package/README.md +18 -8
- package/index.ts +215 -14
- package/ocmemog/__init__.py +1 -1
- package/ocmemog/runtime/memory/conversation_state.py +138 -32
- package/ocmemog/runtime/memory/retrieval.py +135 -6
- package/ocmemog/sidecar/app.py +249 -13
- package/ocmemog/sidecar/transcript_watcher.py +191 -61
- package/package.json +1 -1
- package/scripts/ocmemog-hydrate-stress.py +628 -0
- package/scripts/ocmemog-release-check.sh +35 -1
- package/scripts/ocmemog-sidecar.sh +24 -2
- package/scripts/ocmemog-test-rig.py +15 -1
- package/scripts/ocmemog-transcript-append.py +17 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.16 — 2026-03-25
|
|
4
|
+
|
|
5
|
+
Platform support doc clarification for Linux/Windows service guidance.
|
|
6
|
+
|
|
7
|
+
### Highlights
|
|
8
|
+
- documented Linux systemd and Windows service runner guidance (no new code changes)
|
|
9
|
+
|
|
10
|
+
## 0.1.15 — 2026-03-25
|
|
11
|
+
|
|
12
|
+
Hydration stabilization + cross-platform default cleanup.
|
|
13
|
+
|
|
14
|
+
### Highlights
|
|
15
|
+
- made `/conversation/hydrate` read-only (no inline `refresh_state()` on the hot read path)
|
|
16
|
+
- added hydrate stage timing + refresh_state source tagging for root-cause clarity
|
|
17
|
+
- added plugin prepend-size logging and a dedicated hydrate stress harness
|
|
18
|
+
- expanded platform-aware OpenClaw home defaults (OPENCLAW_HOME / OCMEMOG_OPENCLAW_HOME / XDG / Windows AppData)
|
|
19
|
+
- updated transcript/test rig helpers and docs to match cross-platform defaults
|
|
20
|
+
|
|
3
21
|
## 0.1.14 — 2026-03-22
|
|
4
22
|
|
|
5
23
|
Corrective follow-up to make the published release fully version-aligned.
|
package/README.md
CHANGED
|
@@ -73,10 +73,10 @@ The doctor command currently checks:
|
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
75
|
# defaults:
|
|
76
|
-
# - transcript mode:
|
|
77
|
-
# - session mode:
|
|
78
|
-
export OCMEMOG_TRANSCRIPT_DIR="$HOME/.openclaw/workspace/memory/transcripts"
|
|
79
|
-
export OCMEMOG_SESSION_DIR="$HOME/.openclaw/agents/main/sessions"
|
|
76
|
+
# - transcript mode: <openclaw-home>/workspace/memory/transcripts
|
|
77
|
+
# - session mode: <openclaw-home>/agents/main/sessions (used when OCMEMOG_TRANSCRIPT_DIR is unset)
|
|
78
|
+
export OCMEMOG_TRANSCRIPT_DIR="${OPENCLAW_HOME:-$HOME/.openclaw}/workspace/memory/transcripts"
|
|
79
|
+
export OCMEMOG_SESSION_DIR="${OPENCLAW_HOME:-$HOME/.openclaw}/agents/main/sessions"
|
|
80
80
|
./scripts/ocmemog-transcript-watcher.sh
|
|
81
81
|
```
|
|
82
82
|
|
|
@@ -117,9 +117,10 @@ Optional environment variables:
|
|
|
117
117
|
- `OCMEMOG_EMBED_MODEL_LOCAL` (`simple` by default; legacy alias: `BRAIN_EMBED_MODEL_LOCAL`)
|
|
118
118
|
- `OCMEMOG_EMBED_MODEL_PROVIDER` (`local-openai` to use the local llama.cpp embedding endpoint; `openai` remains available for hosted embeddings; legacy alias: `BRAIN_EMBED_MODEL_PROVIDER`)
|
|
119
119
|
- `OCMEMOG_TRANSCRIPT_WATCHER` (`true` to auto-start transcript watcher inside the sidecar)
|
|
120
|
-
- `
|
|
121
|
-
- `
|
|
122
|
-
- `
|
|
120
|
+
- `OPENCLAW_HOME` / `OCMEMOG_OPENCLAW_HOME` (optional OpenClaw home override; default fallback is platform-aware: `~/.openclaw` on Unix, `XDG_DATA_HOME/openclaw` when set, or `%APPDATA%/OpenClaw` on Windows)
|
|
121
|
+
- `OCMEMOG_TRANSCRIPT_ROOTS` (comma-separated allowed roots for transcript context retrieval; default: `<openclaw-home>/workspace/memory`)
|
|
122
|
+
- `OCMEMOG_TRANSCRIPT_DIR` (default: `<openclaw-home>/workspace/memory/transcripts`)
|
|
123
|
+
- `OCMEMOG_SESSION_DIR` (default: `<openclaw-home>/agents/main/sessions`)
|
|
123
124
|
- `OCMEMOG_TRANSCRIPT_POLL_SECONDS` (poll interval for file/session watcher; default: `30`, or `120` in battery mode)
|
|
124
125
|
- `OCMEMOG_INGEST_BATCH_SECONDS` (max lines per watcher batch; default: `30`, or `120` in battery mode)
|
|
125
126
|
- `OCMEMOG_INGEST_BATCH_MAX` (max watcher batches before yield; default: `25`, or `10` in battery mode)
|
|
@@ -157,6 +158,15 @@ Boolean env values are parsed case-insensitively and support `1/0`, `true/false`
|
|
|
157
158
|
- Sidecar binds to **127.0.0.1** by default. Keep it local unless you add auth + firewall rules.
|
|
158
159
|
- If you expose the sidecar, set `OCMEMOG_API_TOKEN` and pass the header `x-ocmemog-token`.
|
|
159
160
|
|
|
161
|
+
## Platform support
|
|
162
|
+
|
|
163
|
+
- **Core Python package / sidecar:** intended to run cross-platform when Python + SQLite are available.
|
|
164
|
+
- **Watcher path defaults:** now resolve from a platform-aware OpenClaw home (`OPENCLAW_HOME` / `OCMEMOG_OPENCLAW_HOME`, XDG, Windows AppData, then legacy `~/.openclaw`).
|
|
165
|
+
- **Service/install helpers:** still split by platform.
|
|
166
|
+
- macOS: LaunchAgents supported in-tree
|
|
167
|
+
- Linux: run the sidecar directly with env overrides; if you want a service, create a systemd unit that calls `scripts/ocmemog-sidecar.sh` from your venv
|
|
168
|
+
- Windows: run the sidecar directly with env overrides; use Task Scheduler or NSSM if you need a persistent service
|
|
169
|
+
|
|
160
170
|
## One‑shot installer (macOS / local dev)
|
|
161
171
|
|
|
162
172
|
```bash
|
|
@@ -213,7 +223,7 @@ launchctl bootstrap gui/$UID scripts/launchagents/com.openclaw.ocmemog.guard.pli
|
|
|
213
223
|
|
|
214
224
|
## Recent changes
|
|
215
225
|
|
|
216
|
-
### 0.1.
|
|
226
|
+
### 0.1.16 (current main)
|
|
217
227
|
|
|
218
228
|
Current main now includes:
|
|
219
229
|
- integrated release-gate validation with a fresh-state memory contract proof
|
package/index.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
|
|
3
6
|
const DEFAULT_ENDPOINT = "http://127.0.0.1:17891";
|
|
4
7
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
@@ -12,6 +15,10 @@ type PluginConfig = {
|
|
|
12
15
|
const AUTO_HYDRATION_ENABLED = ["1", "true", "yes"].includes(
|
|
13
16
|
String(process.env.OCMEMOG_AUTO_HYDRATION ?? "false").trim().toLowerCase(),
|
|
14
17
|
);
|
|
18
|
+
const DURABLE_OUTBOX_ENABLED = !["0", "false", "no"].includes(
|
|
19
|
+
String(process.env.OCMEMOG_DURABLE_OUTBOX ?? "true").trim().toLowerCase(),
|
|
20
|
+
);
|
|
21
|
+
const DEFAULT_OUTBOX_PATH = path.join(os.homedir(), ".openclaw", "ocmemog-outbox.json");
|
|
15
22
|
|
|
16
23
|
type SearchResponse = {
|
|
17
24
|
ok: boolean;
|
|
@@ -57,6 +64,7 @@ type ConversationHydrateResponse = {
|
|
|
57
64
|
recent_turns?: Array<Record<string, unknown>>;
|
|
58
65
|
linked_memories?: Array<{ reference?: string; content?: string }>;
|
|
59
66
|
summary?: Record<string, unknown>;
|
|
67
|
+
predictive_brief?: Record<string, unknown>;
|
|
60
68
|
state?: Record<string, unknown>;
|
|
61
69
|
error?: string;
|
|
62
70
|
};
|
|
@@ -73,6 +81,122 @@ type ConversationScope = {
|
|
|
73
81
|
thread_id?: string;
|
|
74
82
|
};
|
|
75
83
|
|
|
84
|
+
type DurableOutboxRecord = {
|
|
85
|
+
id: string;
|
|
86
|
+
kind: "conversation_ingest" | "conversation_checkpoint";
|
|
87
|
+
path: string;
|
|
88
|
+
body: Record<string, unknown>;
|
|
89
|
+
createdAt: string;
|
|
90
|
+
attempts: number;
|
|
91
|
+
lastError?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function durableOutboxPath(): string {
|
|
95
|
+
const raw = String(process.env.OCMEMOG_DURABLE_OUTBOX_PATH ?? DEFAULT_OUTBOX_PATH).trim();
|
|
96
|
+
return raw || DEFAULT_OUTBOX_PATH;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function makeOutboxRecord(kind: DurableOutboxRecord["kind"], pathValue: string, body: Record<string, unknown>): DurableOutboxRecord {
|
|
100
|
+
return {
|
|
101
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
102
|
+
kind,
|
|
103
|
+
path: pathValue,
|
|
104
|
+
body,
|
|
105
|
+
createdAt: new Date().toISOString(),
|
|
106
|
+
attempts: 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function loadOutbox(): Promise<DurableOutboxRecord[]> {
|
|
111
|
+
if (!DURABLE_OUTBOX_ENABLED) return [];
|
|
112
|
+
const filePath = durableOutboxPath();
|
|
113
|
+
try {
|
|
114
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
115
|
+
const parsed = JSON.parse(raw);
|
|
116
|
+
return Array.isArray(parsed) ? (parsed as DurableOutboxRecord[]) : [];
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
119
|
+
if (code === "ENOENT") return [];
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function saveOutbox(records: DurableOutboxRecord[]): Promise<void> {
|
|
125
|
+
if (!DURABLE_OUTBOX_ENABLED) return;
|
|
126
|
+
const filePath = durableOutboxPath();
|
|
127
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
128
|
+
await fs.writeFile(filePath, JSON.stringify(records, null, 2), "utf8");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function enqueueOutbox(record: DurableOutboxRecord): Promise<void> {
|
|
132
|
+
if (!DURABLE_OUTBOX_ENABLED) return;
|
|
133
|
+
const records = await loadOutbox();
|
|
134
|
+
records.push(record);
|
|
135
|
+
await saveOutbox(records);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function flushOutbox(
|
|
139
|
+
api: OpenClawPluginApi,
|
|
140
|
+
config: PluginConfig,
|
|
141
|
+
options: { maxItems?: number } = {},
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
const maxItems = options.maxItems ?? 25;
|
|
144
|
+
if (!DURABLE_OUTBOX_ENABLED) return;
|
|
145
|
+
const records = await loadOutbox();
|
|
146
|
+
if (!records.length) return;
|
|
147
|
+
const remaining: DurableOutboxRecord[] = [];
|
|
148
|
+
let processed = 0;
|
|
149
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
150
|
+
const record = records[index];
|
|
151
|
+
if (processed >= maxItems) {
|
|
152
|
+
remaining.push(...records.slice(index));
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
await postJson(config, record.path, record.body);
|
|
157
|
+
processed += 1;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
remaining.push({
|
|
160
|
+
...record,
|
|
161
|
+
attempts: (record.attempts || 0) + 1,
|
|
162
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
163
|
+
});
|
|
164
|
+
remaining.push(...records.slice(index + 1));
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
await saveOutbox(remaining);
|
|
169
|
+
if (processed > 0) {
|
|
170
|
+
api.logger.info(`ocmemog durable outbox flushed ${processed} item(s)`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function durablePostJson(
|
|
175
|
+
api: OpenClawPluginApi,
|
|
176
|
+
config: PluginConfig,
|
|
177
|
+
record: DurableOutboxRecord,
|
|
178
|
+
options: { flushFirst?: boolean } = {},
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
const flushFirst = options.flushFirst ?? true;
|
|
181
|
+
if (flushFirst) {
|
|
182
|
+
try {
|
|
183
|
+
await flushOutbox(api, config);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
api.logger.warn(`ocmemog durable outbox pre-flush failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
await postJson(config, record.path, record.body);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
await enqueueOutbox({
|
|
192
|
+
...record,
|
|
193
|
+
attempts: (record.attempts || 0) + 1,
|
|
194
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
195
|
+
});
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
76
200
|
function readConfig(raw: unknown): PluginConfig {
|
|
77
201
|
const cfg = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
|
78
202
|
return {
|
|
@@ -299,6 +423,52 @@ function sanitizeContinuityNoise(text: string, maxLen = 280): string {
|
|
|
299
423
|
return cleaned;
|
|
300
424
|
}
|
|
301
425
|
|
|
426
|
+
function buildPredictiveBriefContext(payload: ConversationHydrateResponse): string {
|
|
427
|
+
if (!payload.ok) {
|
|
428
|
+
return "";
|
|
429
|
+
}
|
|
430
|
+
const brief = asRecord(payload.predictive_brief);
|
|
431
|
+
if (!brief) {
|
|
432
|
+
return "";
|
|
433
|
+
}
|
|
434
|
+
const lines: string[] = [];
|
|
435
|
+
const lane = sanitizeContinuityNoise(firstString(brief.lane), 48);
|
|
436
|
+
if (lane) {
|
|
437
|
+
lines.push(`Lane: ${lane}`);
|
|
438
|
+
}
|
|
439
|
+
const checkpoint = asRecord(brief.checkpoint);
|
|
440
|
+
const checkpointSummary = sanitizeContinuityNoise(firstString(checkpoint?.summary), 140);
|
|
441
|
+
if (checkpointSummary) {
|
|
442
|
+
lines.push(`Checkpoint: ${checkpointSummary}`);
|
|
443
|
+
}
|
|
444
|
+
const memories = Array.isArray(brief.memories) ? brief.memories : [];
|
|
445
|
+
const memoryLines = memories
|
|
446
|
+
.slice(0, 4)
|
|
447
|
+
.map((item) => {
|
|
448
|
+
const record = asRecord(item);
|
|
449
|
+
return sanitizeContinuityNoise(firstString(record?.content, record?.reference), 120);
|
|
450
|
+
})
|
|
451
|
+
.filter(Boolean);
|
|
452
|
+
if (memoryLines.length) {
|
|
453
|
+
lines.push(`Likely-needed facts: ${memoryLines.join(" | ")}`);
|
|
454
|
+
}
|
|
455
|
+
const openLoops = Array.isArray(brief.open_loops) ? brief.open_loops : [];
|
|
456
|
+
const openLoopLines = openLoops
|
|
457
|
+
.slice(0, 2)
|
|
458
|
+
.map((item) => {
|
|
459
|
+
const record = asRecord(item);
|
|
460
|
+
return sanitizeContinuityNoise(firstString(record?.summary, record?.reference), 100);
|
|
461
|
+
})
|
|
462
|
+
.filter(Boolean);
|
|
463
|
+
if (openLoopLines.length) {
|
|
464
|
+
lines.push(`Open loops: ${openLoopLines.join(" | ")}`);
|
|
465
|
+
}
|
|
466
|
+
if (!lines.length) {
|
|
467
|
+
return "";
|
|
468
|
+
}
|
|
469
|
+
return `Working memory brief (JIT by ocmemog):\n- ${lines.join("\n- ")}`;
|
|
470
|
+
}
|
|
471
|
+
|
|
302
472
|
function buildHydrationContext(payload: ConversationHydrateResponse): string {
|
|
303
473
|
if (!payload.ok) {
|
|
304
474
|
return "";
|
|
@@ -352,6 +522,10 @@ function buildTurnMetadata(message: unknown, ctx: { agentId?: string; sessionKey
|
|
|
352
522
|
}
|
|
353
523
|
|
|
354
524
|
function registerAutomaticContinuityHooks(api: OpenClawPluginApi, config: PluginConfig) {
|
|
525
|
+
void flushOutbox(api, config).catch((error) => {
|
|
526
|
+
api.logger.warn(`ocmemog durable outbox startup flush failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
527
|
+
});
|
|
528
|
+
|
|
355
529
|
api.on("before_message_write", (event, ctx) => {
|
|
356
530
|
try {
|
|
357
531
|
const role = extractRole(event.message);
|
|
@@ -366,7 +540,7 @@ function registerAutomaticContinuityHooks(api: OpenClawPluginApi, config: Plugin
|
|
|
366
540
|
if (!scope.session_id && !scope.thread_id && !scope.conversation_id) {
|
|
367
541
|
return;
|
|
368
542
|
}
|
|
369
|
-
|
|
543
|
+
const body = {
|
|
370
544
|
...scope,
|
|
371
545
|
role,
|
|
372
546
|
content,
|
|
@@ -374,8 +548,9 @@ function registerAutomaticContinuityHooks(api: OpenClawPluginApi, config: Plugin
|
|
|
374
548
|
timestamp: extractTimestamp(event.message),
|
|
375
549
|
source: "openclaw.before_message_write",
|
|
376
550
|
metadata: buildTurnMetadata(event.message, ctx),
|
|
377
|
-
}
|
|
378
|
-
|
|
551
|
+
};
|
|
552
|
+
void durablePostJson(api, config, makeOutboxRecord("conversation_ingest", "/conversation/ingest_turn", body)).catch((error) => {
|
|
553
|
+
api.logger.warn(`ocmemog continuity ingest failed (queued for retry): ${error instanceof Error ? error.message : String(error)}`);
|
|
379
554
|
});
|
|
380
555
|
} catch (error) {
|
|
381
556
|
api.logger.warn(`ocmemog continuity ingest scheduling failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -387,6 +562,7 @@ function registerAutomaticContinuityHooks(api: OpenClawPluginApi, config: Plugin
|
|
|
387
562
|
// failures if a host runtime persists prepended context into transcript history.
|
|
388
563
|
// Keep the memory backend and sidecar tools active, but only prepend continuity
|
|
389
564
|
// when explicitly enabled and after the host runtime has been validated.
|
|
565
|
+
api.logger.info(`ocmemog auto hydration env raw=${String(process.env.OCMEMOG_AUTO_HYDRATION ?? '<unset>')} computed=${String(AUTO_HYDRATION_ENABLED)}`);
|
|
390
566
|
if (AUTO_HYDRATION_ENABLED) {
|
|
391
567
|
api.on("before_prompt_build", async (event, ctx) => {
|
|
392
568
|
try {
|
|
@@ -399,7 +575,12 @@ function registerAutomaticContinuityHooks(api: OpenClawPluginApi, config: Plugin
|
|
|
399
575
|
turns_limit: 4,
|
|
400
576
|
memory_limit: 3,
|
|
401
577
|
});
|
|
402
|
-
const
|
|
578
|
+
const briefContext = buildPredictiveBriefContext(payload);
|
|
579
|
+
const continuityContext = buildHydrationContext(payload);
|
|
580
|
+
const prependContext = [briefContext, continuityContext].filter(Boolean).join("\n\n");
|
|
581
|
+
api.logger.info(
|
|
582
|
+
`ocmemog hydration prepend sizes brief=${briefContext.length} continuity=${continuityContext.length} combined=${prependContext.length}`,
|
|
583
|
+
);
|
|
403
584
|
if (!prependContext) {
|
|
404
585
|
return;
|
|
405
586
|
}
|
|
@@ -419,11 +600,15 @@ function registerAutomaticContinuityHooks(api: OpenClawPluginApi, config: Plugin
|
|
|
419
600
|
if (!sessionId) {
|
|
420
601
|
return;
|
|
421
602
|
}
|
|
422
|
-
await
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
603
|
+
await durablePostJson(
|
|
604
|
+
api,
|
|
605
|
+
config,
|
|
606
|
+
makeOutboxRecord("conversation_checkpoint", "/conversation/checkpoint", {
|
|
607
|
+
session_id: sessionId,
|
|
608
|
+
checkpoint_kind: "compaction",
|
|
609
|
+
turns_limit: 32,
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
427
612
|
} catch (error) {
|
|
428
613
|
api.logger.warn(`ocmemog compaction checkpoint failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
429
614
|
}
|
|
@@ -435,11 +620,15 @@ function registerAutomaticContinuityHooks(api: OpenClawPluginApi, config: Plugin
|
|
|
435
620
|
if (!sessionId) {
|
|
436
621
|
return;
|
|
437
622
|
}
|
|
438
|
-
await
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
623
|
+
await durablePostJson(
|
|
624
|
+
api,
|
|
625
|
+
config,
|
|
626
|
+
makeOutboxRecord("conversation_checkpoint", "/conversation/checkpoint", {
|
|
627
|
+
session_id: sessionId,
|
|
628
|
+
checkpoint_kind: "session_end",
|
|
629
|
+
turns_limit: 48,
|
|
630
|
+
}),
|
|
631
|
+
);
|
|
443
632
|
} catch (error) {
|
|
444
633
|
api.logger.warn(`ocmemog session-end checkpoint failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
445
634
|
}
|
|
@@ -473,6 +662,14 @@ const ocmemogPlugin = {
|
|
|
473
662
|
items: { type: "string" },
|
|
474
663
|
description: "Memory category.",
|
|
475
664
|
},
|
|
665
|
+
metadataFilters: {
|
|
666
|
+
type: "object",
|
|
667
|
+
description: "Optional metadata filters (for example { domain: 'tbc', site: 'dal' }).",
|
|
668
|
+
},
|
|
669
|
+
lane: {
|
|
670
|
+
type: "string",
|
|
671
|
+
description: "Optional retrieval lane/domain hint (for example 'tbc'). Usually not needed when query context is clear.",
|
|
672
|
+
},
|
|
476
673
|
},
|
|
477
674
|
},
|
|
478
675
|
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
@@ -481,6 +678,8 @@ const ocmemogPlugin = {
|
|
|
481
678
|
query: params.query,
|
|
482
679
|
limit: params.limit,
|
|
483
680
|
categories: params.categories,
|
|
681
|
+
metadata_filters: params.metadataFilters,
|
|
682
|
+
lane: params.lane,
|
|
484
683
|
});
|
|
485
684
|
|
|
486
685
|
const results = payload.results ?? [];
|
|
@@ -648,6 +847,7 @@ const ocmemogPlugin = {
|
|
|
648
847
|
memoryType: { type: "string", description: "memory bucket (knowledge/reflections/etc.)" },
|
|
649
848
|
source: { type: "string", description: "Optional source label." },
|
|
650
849
|
taskId: { type: "string", description: "Optional task id for experience ingest." },
|
|
850
|
+
metadata: { type: "object", description: "Optional structured metadata (for example { domain: 'tbc', site: 'dal' })." },
|
|
651
851
|
},
|
|
652
852
|
},
|
|
653
853
|
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
@@ -658,6 +858,7 @@ const ocmemogPlugin = {
|
|
|
658
858
|
memory_type: params.memoryType,
|
|
659
859
|
source: params.source,
|
|
660
860
|
task_id: params.taskId,
|
|
861
|
+
metadata: params.metadata,
|
|
661
862
|
});
|
|
662
863
|
return {
|
|
663
864
|
content: [{ type: "text", text: `memory_ingest: ${payload.ok ? "ok" : "failed"}` }],
|
package/ocmemog/__init__.py
CHANGED
|
@@ -7,6 +7,6 @@ from importlib.metadata import PackageNotFoundError, version as _package_version
|
|
|
7
7
|
try:
|
|
8
8
|
__version__ = _package_version("ocmemog-sidecar")
|
|
9
9
|
except PackageNotFoundError: # pragma: no cover - package metadata may be unavailable in source layouts.
|
|
10
|
-
__version__ = "0.1.
|
|
10
|
+
__version__ = "0.1.16"
|
|
11
11
|
|
|
12
12
|
__all__ = ["__version__"]
|
|
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
6
8
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
7
9
|
|
|
8
10
|
from ocmemog.runtime import state_store
|
|
@@ -17,6 +19,7 @@ _COMMITMENT_RE = re.compile(
|
|
|
17
19
|
)
|
|
18
20
|
_CHECKPOINT_EVERY = max(0, int(os.environ.get("OCMEMOG_CONVERSATION_CHECKPOINT_EVERY", "6") or "6"))
|
|
19
21
|
_MAX_STATE_TURNS = max(6, int(os.environ.get("OCMEMOG_CONVERSATION_STATE_TURNS", "24") or "24"))
|
|
22
|
+
_SESSION_SOURCE_INLINE_MAINTENANCE = os.environ.get("OCMEMOG_SESSION_SOURCE_INLINE_MAINTENANCE", "false").strip().lower() in {"1", "true", "yes", "on"}
|
|
20
23
|
_SHORT_REPLY_NORMALIZED = {
|
|
21
24
|
"yes",
|
|
22
25
|
"yeah",
|
|
@@ -578,6 +581,17 @@ def _get_turns_between_ids(
|
|
|
578
581
|
return _rows_to_turns(rows)
|
|
579
582
|
|
|
580
583
|
|
|
584
|
+
def _normalized_turn_source(source: Optional[str]) -> str:
|
|
585
|
+
return str(source or "").strip().lower()
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _should_run_inline_turn_maintenance(*, source: Optional[str]) -> bool:
|
|
589
|
+
normalized = _normalized_turn_source(source)
|
|
590
|
+
if normalized == "session" and not _SESSION_SOURCE_INLINE_MAINTENANCE:
|
|
591
|
+
return False
|
|
592
|
+
return True
|
|
593
|
+
|
|
594
|
+
|
|
581
595
|
def record_turn(
|
|
582
596
|
*,
|
|
583
597
|
role: str,
|
|
@@ -711,34 +725,107 @@ def record_turn(
|
|
|
711
725
|
finally:
|
|
712
726
|
conn.close()
|
|
713
727
|
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
728
|
+
def _write_session_fast() -> int:
|
|
729
|
+
conn = store.connect()
|
|
730
|
+
try:
|
|
731
|
+
if timestamp:
|
|
732
|
+
cur = conn.execute(
|
|
733
|
+
"""
|
|
734
|
+
INSERT INTO conversation_turns (
|
|
735
|
+
timestamp, conversation_id, session_id, thread_id, message_id,
|
|
736
|
+
role, content, transcript_path, transcript_offset, transcript_end_offset,
|
|
737
|
+
source, metadata_json, schema_version
|
|
738
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
739
|
+
""",
|
|
740
|
+
(
|
|
741
|
+
timestamp,
|
|
742
|
+
conversation_id,
|
|
743
|
+
session_id,
|
|
744
|
+
thread_id,
|
|
745
|
+
message_id,
|
|
746
|
+
turn_role,
|
|
747
|
+
turn_content,
|
|
748
|
+
transcript_path,
|
|
749
|
+
transcript_offset,
|
|
750
|
+
transcript_end_offset,
|
|
751
|
+
source,
|
|
752
|
+
json.dumps(enriched_metadata, ensure_ascii=False),
|
|
753
|
+
store.SCHEMA_VERSION,
|
|
754
|
+
),
|
|
755
|
+
)
|
|
756
|
+
else:
|
|
757
|
+
cur = conn.execute(
|
|
758
|
+
"""
|
|
759
|
+
INSERT INTO conversation_turns (
|
|
760
|
+
conversation_id, session_id, thread_id, message_id,
|
|
761
|
+
role, content, transcript_path, transcript_offset, transcript_end_offset,
|
|
762
|
+
source, metadata_json, schema_version
|
|
763
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
764
|
+
""",
|
|
765
|
+
(
|
|
766
|
+
conversation_id,
|
|
767
|
+
session_id,
|
|
768
|
+
thread_id,
|
|
769
|
+
message_id,
|
|
770
|
+
turn_role,
|
|
771
|
+
turn_content,
|
|
772
|
+
transcript_path,
|
|
773
|
+
transcript_offset,
|
|
774
|
+
transcript_end_offset,
|
|
775
|
+
source,
|
|
776
|
+
json.dumps(enriched_metadata, ensure_ascii=False),
|
|
777
|
+
store.SCHEMA_VERSION,
|
|
778
|
+
),
|
|
779
|
+
)
|
|
780
|
+
conn.commit()
|
|
781
|
+
return int(cur.lastrowid)
|
|
782
|
+
finally:
|
|
783
|
+
conn.close()
|
|
784
|
+
|
|
785
|
+
normalized_source = _normalized_turn_source(source)
|
|
786
|
+
writer = _write_session_fast if normalized_source == "session" else _write
|
|
787
|
+
turn_id = int(store.submit_write(writer, timeout=30.0))
|
|
788
|
+
maintenance_ran = False
|
|
789
|
+
if _should_run_inline_turn_maintenance(source=source):
|
|
790
|
+
try:
|
|
791
|
+
refresh_state(
|
|
792
|
+
conversation_id=conversation_id,
|
|
793
|
+
session_id=session_id,
|
|
794
|
+
thread_id=thread_id,
|
|
795
|
+
source="record_turn",
|
|
796
|
+
)
|
|
797
|
+
if _CHECKPOINT_EVERY > 0:
|
|
798
|
+
counts = get_turn_counts(conversation_id=conversation_id, session_id=session_id, thread_id=thread_id)
|
|
799
|
+
if counts["total"] > 0 and counts["total"] % _CHECKPOINT_EVERY == 0:
|
|
800
|
+
latest = get_latest_checkpoint(conversation_id=conversation_id, session_id=session_id, thread_id=thread_id)
|
|
801
|
+
if not latest or int(latest.get("turn_end_id") or 0) < turn_id:
|
|
802
|
+
create_checkpoint(
|
|
803
|
+
conversation_id=conversation_id,
|
|
804
|
+
session_id=session_id,
|
|
805
|
+
thread_id=thread_id,
|
|
806
|
+
upto_turn_id=turn_id,
|
|
807
|
+
checkpoint_kind="rolling",
|
|
808
|
+
)
|
|
809
|
+
maintenance_ran = True
|
|
810
|
+
except Exception as exc:
|
|
811
|
+
if normalized_source != "session":
|
|
812
|
+
emit_event(
|
|
813
|
+
LOGFILE,
|
|
814
|
+
"brain_conversation_turn_post_write_maintenance_failed",
|
|
815
|
+
status="warn",
|
|
816
|
+
error=str(exc),
|
|
817
|
+
turn_id=turn_id,
|
|
818
|
+
)
|
|
819
|
+
if normalized_source != "session":
|
|
734
820
|
emit_event(
|
|
735
821
|
LOGFILE,
|
|
736
|
-
"
|
|
737
|
-
status="
|
|
738
|
-
|
|
822
|
+
"brain_conversation_turn_recorded",
|
|
823
|
+
status="ok",
|
|
824
|
+
role=turn_role,
|
|
739
825
|
turn_id=turn_id,
|
|
826
|
+
source=source or "",
|
|
827
|
+
inline_maintenance=maintenance_ran,
|
|
740
828
|
)
|
|
741
|
-
emit_event(LOGFILE, "brain_conversation_turn_recorded", status="ok", role=turn_role, turn_id=turn_id)
|
|
742
829
|
return turn_id
|
|
743
830
|
|
|
744
831
|
|
|
@@ -1468,7 +1555,7 @@ def create_checkpoint(
|
|
|
1468
1555
|
emit_event(LOGFILE, "brain_conversation_checkpoint_created", status="ok", checkpoint_id=checkpoint_id, checkpoint_kind=checkpoint_kind)
|
|
1469
1556
|
payload = get_checkpoint_by_id(checkpoint_id)
|
|
1470
1557
|
try:
|
|
1471
|
-
refresh_state(conversation_id=conversation_id, session_id=session_id, thread_id=thread_id)
|
|
1558
|
+
refresh_state(conversation_id=conversation_id, session_id=session_id, thread_id=thread_id, source="checkpoint")
|
|
1472
1559
|
except Exception as exc:
|
|
1473
1560
|
emit_event(
|
|
1474
1561
|
LOGFILE,
|
|
@@ -1761,7 +1848,9 @@ def refresh_state(
|
|
|
1761
1848
|
session_id: Optional[str] = None,
|
|
1762
1849
|
thread_id: Optional[str] = None,
|
|
1763
1850
|
tolerate_write_failure: bool = False,
|
|
1851
|
+
source: str = "unknown",
|
|
1764
1852
|
) -> Optional[Dict[str, Any]]:
|
|
1853
|
+
refresh_started = time.perf_counter()
|
|
1765
1854
|
_self_heal_legacy_continuity_artifacts(
|
|
1766
1855
|
conversation_id=conversation_id,
|
|
1767
1856
|
session_id=session_id,
|
|
@@ -1795,8 +1884,9 @@ def refresh_state(
|
|
|
1795
1884
|
latest_checkpoint=latest_checkpoint,
|
|
1796
1885
|
linked_memories=[],
|
|
1797
1886
|
)
|
|
1887
|
+
result: Optional[Dict[str, Any]]
|
|
1798
1888
|
try:
|
|
1799
|
-
|
|
1889
|
+
result = _upsert_state(
|
|
1800
1890
|
conversation_id=conversation_id,
|
|
1801
1891
|
session_id=session_id,
|
|
1802
1892
|
thread_id=thread_id,
|
|
@@ -1822,10 +1912,26 @@ def refresh_state(
|
|
|
1822
1912
|
if existing:
|
|
1823
1913
|
metadata = existing.get("metadata") if isinstance(existing.get("metadata"), dict) else {}
|
|
1824
1914
|
existing["metadata"] = {**metadata, "state_status": "stale_persisted"}
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1915
|
+
result = existing
|
|
1916
|
+
else:
|
|
1917
|
+
result = _state_from_payload(
|
|
1918
|
+
payload,
|
|
1919
|
+
conversation_id=conversation_id,
|
|
1920
|
+
session_id=session_id,
|
|
1921
|
+
thread_id=thread_id,
|
|
1922
|
+
)
|
|
1923
|
+
elapsed_ms = round((time.perf_counter() - refresh_started) * 1000, 3)
|
|
1924
|
+
trace_refresh = str(os.environ.get("OCMEMOG_TRACE_REFRESH_STATE", "")).strip().lower() in {"1", "true", "yes", "on"}
|
|
1925
|
+
warn_ms_raw = os.environ.get("OCMEMOG_TRACE_REFRESH_STATE_WARN_MS", "15").strip()
|
|
1926
|
+
try:
|
|
1927
|
+
warn_ms = max(0.0, float(warn_ms_raw))
|
|
1928
|
+
except Exception:
|
|
1929
|
+
warn_ms = 15.0
|
|
1930
|
+
if trace_refresh or elapsed_ms >= warn_ms:
|
|
1931
|
+
print(
|
|
1932
|
+
"[ocmemog][state] refresh_state "
|
|
1933
|
+
f"source={source or 'unknown'} elapsed_ms={elapsed_ms:.3f} turns={len(turns)} unresolved_items={len(unresolved_items)} "
|
|
1934
|
+
f"conversation_id={conversation_id or '-'} session_id={session_id or '-'} thread_id={thread_id or '-'}",
|
|
1935
|
+
file=sys.stderr,
|
|
1831
1936
|
)
|
|
1937
|
+
return result
|