@simbimbo/memory-ocmemog 0.1.14 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.15 — 2026-03-25
4
+
5
+ Hydration stabilization + cross-platform default cleanup.
6
+
7
+ ### Highlights
8
+ - made `/conversation/hydrate` read-only (no inline `refresh_state()` on the hot read path)
9
+ - added hydrate stage timing + refresh_state source tagging for root-cause clarity
10
+ - added plugin prepend-size logging and a dedicated hydrate stress harness
11
+ - expanded platform-aware OpenClaw home defaults (OPENCLAW_HOME / OCMEMOG_OPENCLAW_HOME / XDG / Windows AppData)
12
+ - updated transcript/test rig helpers and docs to match cross-platform defaults
13
+
3
14
  ## 0.1.14 — 2026-03-22
4
15
 
5
16
  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: ~/.openclaw/workspace/memory/transcripts
77
- # - session mode: ~/.openclaw/agents/main/sessions (used when OCMEMOG_TRANSCRIPT_DIR is unset)
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
- - `OCMEMOG_TRANSCRIPT_ROOTS` (comma-separated allowed roots for transcript context retrieval; default: `~/.openclaw/workspace/memory`)
121
- - `OCMEMOG_TRANSCRIPT_DIR` (default: `~/.openclaw/workspace/memory/transcripts`)
122
- - `OCMEMOG_SESSION_DIR` (default: `~/.openclaw/agents/main/sessions`)
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,14 @@ 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/Windows: run the sidecar directly with env overrides today; service wrappers are not yet first-class in this repo
168
+
160
169
  ## One‑shot installer (macOS / local dev)
161
170
 
162
171
  ```bash
@@ -213,7 +222,7 @@ launchctl bootstrap gui/$UID scripts/launchagents/com.openclaw.ocmemog.guard.pli
213
222
 
214
223
  ## Recent changes
215
224
 
216
- ### 0.1.14 (current main)
225
+ ### 0.1.15 (current main)
217
226
 
218
227
  Current main now includes:
219
228
  - 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
- void postJson<{ ok: boolean }>(config, "/conversation/ingest_turn", {
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
- }).catch((error) => {
378
- api.logger.warn(`ocmemog continuity ingest failed: ${error instanceof Error ? error.message : String(error)}`);
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 prependContext = buildHydrationContext(payload);
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 postJson<ConversationCheckpointResponse>(config, "/conversation/checkpoint", {
423
- session_id: sessionId,
424
- checkpoint_kind: "compaction",
425
- turns_limit: 32,
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 postJson<ConversationCheckpointResponse>(config, "/conversation/checkpoint", {
439
- session_id: sessionId,
440
- checkpoint_kind: "session_end",
441
- turns_limit: 48,
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"}` }],
@@ -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.14"
10
+ __version__ = "0.1.15"
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
- turn_id = int(store.submit_write(_write, timeout=30.0))
715
- try:
716
- refresh_state(
717
- conversation_id=conversation_id,
718
- session_id=session_id,
719
- thread_id=thread_id,
720
- )
721
- if _CHECKPOINT_EVERY > 0:
722
- counts = get_turn_counts(conversation_id=conversation_id, session_id=session_id, thread_id=thread_id)
723
- if counts["total"] > 0 and counts["total"] % _CHECKPOINT_EVERY == 0:
724
- latest = get_latest_checkpoint(conversation_id=conversation_id, session_id=session_id, thread_id=thread_id)
725
- if not latest or int(latest.get("turn_end_id") or 0) < turn_id:
726
- create_checkpoint(
727
- conversation_id=conversation_id,
728
- session_id=session_id,
729
- thread_id=thread_id,
730
- upto_turn_id=turn_id,
731
- checkpoint_kind="rolling",
732
- )
733
- except Exception as exc:
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
- "brain_conversation_turn_post_write_maintenance_failed",
737
- status="warn",
738
- error=str(exc),
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
- return _upsert_state(
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
- return existing
1826
- return _state_from_payload(
1827
- payload,
1828
- conversation_id=conversation_id,
1829
- session_id=session_id,
1830
- thread_id=thread_id,
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