@augmem/cortext-openclaw-plugin 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,6 +87,8 @@ Under `plugins.entries.cortext.config`:
87
87
  | `ingestReasoning` | `true` | feed `thinking` deltas, not just answer text |
88
88
  | `forceRepass` | `true` | request a revise on interrupt (see limits — may be a no-op) |
89
89
  | `autoConsolidate` | `true` | consolidate on compaction |
90
+ | `compactionMode` | `hybrid` | `hybrid`: system + recall + verbatim tail; `full`: system + recall + working memory only |
91
+ | `protectTail` | `6` | hybrid: trailing messages kept verbatim (exchange-aligned) |
90
92
 
91
93
  ## Design and limits
92
94
 
@@ -98,9 +100,25 @@ Under `plugins.entries.cortext.config`:
98
100
  - **No cross-turn recall cache.** Recall queries Cortext live every assembly, so
99
101
  a correction ingested this turn is reflected immediately (an earlier caching
100
102
  bug returned stale facts).
101
- - **Compaction.** Cortext memory persists out-of-band, so the engine reports
102
- `ownsCompaction: false` and delegates transcript compaction to the host; on
103
- `compact` it consolidates its own graph.
103
+ - **Compaction is a window, not surgery.** Cortext owns compaction
104
+ (`ownsCompaction: true`) and never calls a summarizer LLM: every message is
105
+ already in the durable store, so `compact` picks an exchange-aligned cut,
106
+ and each `assemble` drops the archived prefix from the model context and
107
+ bridges it with recalled memory. The on-disk transcript is never mutated —
108
+ nothing is destroyed, and archived content comes back through query-relevant
109
+ recall each turn (fresher than a frozen summary). The cut anchor is
110
+ content-based and self-healing: if the host rotates the transcript, the
111
+ window clears rather than over-dropping. Two modes (`compactionMode`):
112
+ - **`hybrid`** (default): keep system prompt + long-term recall + a verbatim
113
+ tail of the last `protectTail` messages, walked back to a user-message
114
+ boundary so the tail is a self-contained exchange.
115
+ - **`full`**: keep system prompt + Cortext memory only (long-term recall plus
116
+ the live working-memory snapshot); the verbatim window shrinks to the
117
+ current exchange. Maximum savings — memory IS the context.
118
+
119
+ Verified live (gateway + budget-pressure compaction): 16 messages archived
120
+ with no LLM call, and a fact that existed *only* behind the window was
121
+ answered correctly from memory injection on the next turn.
104
122
  - **The gate cannot splice into a live decode**, but it requests a re-pass.
105
123
  The agent event stream is one-way (observe only). On `should_interrupt` the
106
124
  plugin (a) stages the recalled memory for the next assembly and (b) via
@@ -0,0 +1,59 @@
1
+ import type { AgentMessage } from "./openclaw.js";
2
+ /**
3
+ * Compaction as a window over the transcript, not transcript surgery.
4
+ *
5
+ * When the host asks the engine to compact (ownsCompaction: true), we pick an
6
+ * exchange-aligned cut point in the last assembled view and remember the first
7
+ * KEPT message as an anchor. Every subsequent assemble() drops the prefix
8
+ * before the anchor and bridges it with Cortext memory — the on-disk transcript
9
+ * is never mutated, so nothing is destroyed and anything dropped remains
10
+ * recallable from the durable store.
11
+ *
12
+ * The anchor is content-based (role + text prefix), not positional: if the
13
+ * host rotates or rewrites the transcript and the anchor vanishes, the window
14
+ * self-heals by clearing (worst case the context regrows until the next
15
+ * compaction — never over-drops).
16
+ */
17
+ export interface CompactionAnchor {
18
+ role: string;
19
+ /** First KEEP_PREFIX_CHARS of the anchor message's extracted text. */
20
+ textPrefix: string;
21
+ /** Messages dropped when the anchor was set (telemetry only). */
22
+ dropped: number;
23
+ ts: number;
24
+ }
25
+ export declare class CompactionState {
26
+ private anchors;
27
+ private loadedFrom;
28
+ /** Load persisted anchors from the store dir (idempotent per dir). */
29
+ load(dir: string): void;
30
+ get(scopeKey: string): CompactionAnchor | undefined;
31
+ set(scopeKey: string, anchor: CompactionAnchor, dir: string): void;
32
+ clear(scopeKey: string, dir: string): void;
33
+ private persist;
34
+ }
35
+ export declare function anchorFor(message: AgentMessage, extract: (content: unknown) => string, dropped: number): CompactionAnchor;
36
+ export declare function matchesAnchor(message: AgentMessage, anchor: CompactionAnchor, extract: (content: unknown) => string): boolean;
37
+ /**
38
+ * Choose the index of the first message to KEEP.
39
+ *
40
+ * - "full": keep from the last user message onward (the current exchange).
41
+ * - "hybrid": keep the last `protectTail` messages, then walk the cut back to
42
+ * a user message so the tail starts on a self-contained exchange (never on a
43
+ * tool result or mid tool-exchange).
44
+ *
45
+ * System-role messages are ignored here — the caller always keeps them.
46
+ * Returns 0 when there is nothing worth cutting.
47
+ */
48
+ export declare function chooseCut(messages: AgentMessage[], mode: "hybrid" | "full", protectTail: number): number;
49
+ /**
50
+ * Cold-start fallback: when compact() runs before any assemble in this process
51
+ * (the host's preflight compaction on a fresh gateway), read the message
52
+ * entries straight from the transcript jsonl. Linear read of `type:"message"`
53
+ * entries — good enough to pick a cut; if a branched/rotated DAG makes the
54
+ * anchor stale, assemble's anchor-miss self-heal clears it rather than
55
+ * over-dropping.
56
+ */
57
+ export declare function readTranscriptMessages(sessionFile: string | undefined): AgentMessage[];
58
+ /** The bridge message inserted where the archived prefix used to be. */
59
+ export declare function bridgeMessage(mode: "hybrid" | "full"): AgentMessage;
@@ -0,0 +1,115 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const KEEP_PREFIX_CHARS = 200;
4
+ const STATE_FILE = "compaction.json";
5
+ export class CompactionState {
6
+ anchors = new Map();
7
+ loadedFrom = null;
8
+ /** Load persisted anchors from the store dir (idempotent per dir). */
9
+ load(dir) {
10
+ if (this.loadedFrom === dir)
11
+ return;
12
+ this.loadedFrom = dir;
13
+ try {
14
+ const raw = JSON.parse(readFileSync(join(dir, STATE_FILE), "utf-8"));
15
+ this.anchors = new Map(Object.entries(raw));
16
+ }
17
+ catch { /* first run or unreadable — start empty */ }
18
+ }
19
+ get(scopeKey) {
20
+ return this.anchors.get(scopeKey);
21
+ }
22
+ set(scopeKey, anchor, dir) {
23
+ this.anchors.set(scopeKey, anchor);
24
+ this.persist(dir);
25
+ }
26
+ clear(scopeKey, dir) {
27
+ if (this.anchors.delete(scopeKey))
28
+ this.persist(dir);
29
+ }
30
+ persist(dir) {
31
+ try {
32
+ writeFileSync(join(dir, STATE_FILE), JSON.stringify(Object.fromEntries(this.anchors)), "utf-8");
33
+ }
34
+ catch { /* best-effort — state also lives in memory */ }
35
+ }
36
+ }
37
+ export function anchorFor(message, extract, dropped) {
38
+ return {
39
+ role: String(message.role ?? ""),
40
+ textPrefix: extract(message.content).slice(0, KEEP_PREFIX_CHARS),
41
+ dropped,
42
+ ts: Date.now(),
43
+ };
44
+ }
45
+ export function matchesAnchor(message, anchor, extract) {
46
+ return String(message.role ?? "") === anchor.role &&
47
+ extract(message.content).slice(0, KEEP_PREFIX_CHARS) === anchor.textPrefix;
48
+ }
49
+ /**
50
+ * Choose the index of the first message to KEEP.
51
+ *
52
+ * - "full": keep from the last user message onward (the current exchange).
53
+ * - "hybrid": keep the last `protectTail` messages, then walk the cut back to
54
+ * a user message so the tail starts on a self-contained exchange (never on a
55
+ * tool result or mid tool-exchange).
56
+ *
57
+ * System-role messages are ignored here — the caller always keeps them.
58
+ * Returns 0 when there is nothing worth cutting.
59
+ */
60
+ export function chooseCut(messages, mode, protectTail) {
61
+ const lastUser = (from) => {
62
+ for (let i = from; i >= 0; i--) {
63
+ if (messages[i]?.role === "user")
64
+ return i;
65
+ }
66
+ return 0;
67
+ };
68
+ if (mode === "full")
69
+ return lastUser(messages.length - 1);
70
+ let cut = Math.max(0, messages.length - Math.max(1, protectTail));
71
+ cut = lastUser(cut);
72
+ return cut;
73
+ }
74
+ /**
75
+ * Cold-start fallback: when compact() runs before any assemble in this process
76
+ * (the host's preflight compaction on a fresh gateway), read the message
77
+ * entries straight from the transcript jsonl. Linear read of `type:"message"`
78
+ * entries — good enough to pick a cut; if a branched/rotated DAG makes the
79
+ * anchor stale, assemble's anchor-miss self-heal clears it rather than
80
+ * over-dropping.
81
+ */
82
+ export function readTranscriptMessages(sessionFile) {
83
+ if (!sessionFile)
84
+ return [];
85
+ let raw;
86
+ try {
87
+ raw = readFileSync(sessionFile, "utf-8");
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ const messages = [];
93
+ for (const line of raw.split("\n")) {
94
+ if (!line.trim())
95
+ continue;
96
+ try {
97
+ const entry = JSON.parse(line);
98
+ if (entry.type === "message" && entry.message && typeof entry.message.role === "string") {
99
+ messages.push(entry.message);
100
+ }
101
+ }
102
+ catch { /* skip malformed line */ }
103
+ }
104
+ return messages;
105
+ }
106
+ /** The bridge message inserted where the archived prefix used to be. */
107
+ export function bridgeMessage(mode) {
108
+ const wm = mode === "full" ? " and its working-memory snapshot" : "";
109
+ return {
110
+ role: "user",
111
+ content: `[Earlier conversation was archived to Cortext durable memory. ` +
112
+ `Relevant context is recalled into the system prompt each turn${wm}. ` +
113
+ `Continue the conversation naturally.]`,
114
+ };
115
+ }
package/dist/config.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  export type MemoryScope = "agent" | "session" | "global";
2
+ /** How Cortext compacts the model-visible window (`ownsCompaction`):
3
+ * - "hybrid": keep system prompt + recalled long-term memory + a verbatim
4
+ * tail of recent messages (exchange-aligned). Safe default.
5
+ * - "full": keep system prompt + Cortext memory only (long-term recall plus
6
+ * the live working-memory snapshot); the verbatim window shrinks to the
7
+ * current exchange. Maximum token savings — memory IS the context. */
8
+ export type CompactionMode = "hybrid" | "full";
2
9
  export interface CortextPluginConfig {
3
10
  dbPath: string;
4
11
  /** Isolation boundary for memory. "session" (default): one store per session
@@ -18,6 +25,11 @@ export interface CortextPluginConfig {
18
25
  * memory (costs one extra pass per trigger). */
19
26
  forceRepass: boolean;
20
27
  autoConsolidate: boolean;
28
+ /** Compaction window mode (see CompactionMode). */
29
+ compactionMode: CompactionMode;
30
+ /** Hybrid mode: number of trailing messages kept verbatim (the cut is walked
31
+ * back to a user-message boundary so the tail is a self-contained exchange). */
32
+ protectTail: number;
21
33
  }
22
34
  export declare const DEFAULTS: CortextPluginConfig;
23
35
  export declare function resolveConfig(raw: Record<string, unknown> | undefined): CortextPluginConfig;
package/dist/config.js CHANGED
@@ -11,8 +11,11 @@ export const DEFAULTS = {
11
11
  ingestReasoning: true,
12
12
  forceRepass: true,
13
13
  autoConsolidate: true,
14
+ compactionMode: "hybrid",
15
+ protectTail: 6,
14
16
  };
15
17
  const SCOPES = ["agent", "session", "global"];
18
+ const COMPACTION_MODES = ["hybrid", "full"];
16
19
  export function resolveConfig(raw) {
17
20
  const cfg = { ...DEFAULTS };
18
21
  if (!raw)
@@ -25,5 +28,9 @@ export function resolveConfig(raw) {
25
28
  }
26
29
  if (!SCOPES.includes(cfg.memoryScope))
27
30
  cfg.memoryScope = "session";
31
+ if (!COMPACTION_MODES.includes(cfg.compactionMode))
32
+ cfg.compactionMode = "hybrid";
33
+ if (!Number.isFinite(cfg.protectTail) || cfg.protectTail < 0)
34
+ cfg.protectTail = 6;
28
35
  return cfg;
29
36
  }
package/dist/cortext.d.ts CHANGED
@@ -39,6 +39,10 @@ export declare class CortextStore {
39
39
  scopeKey(ids: ScopeIds): string;
40
40
  forScope(key: string): CortextEngine;
41
41
  for(ids: ScopeIds): CortextEngine;
42
+ /** The on-disk directory holding this store's scope databases (also used for
43
+ * the compaction-state sidecar). safe() permits dots, so "."/".." are
44
+ * rejected — dbPath must stay under baseDir. */
45
+ storeDir(): string;
42
46
  disposeAll(): void;
43
47
  }
44
48
  export declare function memoryText(item: CortextMemory): string;
package/dist/cortext.js CHANGED
@@ -90,14 +90,7 @@ export class CortextStore {
90
90
  this.engines.set(key, existing);
91
91
  return existing;
92
92
  }
93
- // safe() permits dots, so guard "."/".." — dbPath must stay under baseDir.
94
- const name = safe(this.cfg.dbPath);
95
- const dir = join(this.baseDir, /^\.+$/.test(name) || !name ? "cortext" : name);
96
- try {
97
- mkdirSync(dir, { recursive: true });
98
- }
99
- catch { /* exists */ }
100
- const engine = new CortextEngine(join(dir, `${key}.sqlite`), this.cfg);
93
+ const engine = new CortextEngine(join(this.storeDir(), `${key}.sqlite`), this.cfg);
101
94
  this.engines.set(key, engine);
102
95
  while (this.engines.size > MAX_ENGINES) {
103
96
  const oldest = this.engines.keys().next().value;
@@ -109,6 +102,18 @@ export class CortextStore {
109
102
  for(ids) {
110
103
  return this.forScope(this.scopeKey(ids));
111
104
  }
105
+ /** The on-disk directory holding this store's scope databases (also used for
106
+ * the compaction-state sidecar). safe() permits dots, so "."/".." are
107
+ * rejected — dbPath must stay under baseDir. */
108
+ storeDir() {
109
+ const name = safe(this.cfg.dbPath);
110
+ const dir = join(this.baseDir, /^\.+$/.test(name) || !name ? "cortext" : name);
111
+ try {
112
+ mkdirSync(dir, { recursive: true });
113
+ }
114
+ catch { /* exists */ }
115
+ return dir;
116
+ }
112
117
  disposeAll() {
113
118
  for (const e of this.engines.values())
114
119
  e.flush();
package/dist/engine.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AssembleParams, AssembleResult, CompactParams, CompactResult, ContextEngine, ContextEngineInfo, IngestParams, IngestResult, Logger } from "./openclaw.js";
2
+ import type { CortextPluginConfig } from "./config.js";
2
3
  import { CortextStore } from "./cortext.js";
3
4
  import type { InterruptBus } from "./store.js";
4
5
  /**
@@ -13,12 +14,18 @@ export declare class CortextContextEngine implements ContextEngine {
13
14
  private readonly store;
14
15
  private readonly bus;
15
16
  private readonly logger;
16
- private readonly autoConsolidate;
17
- private readonly recallLimit;
17
+ private readonly cfg;
18
18
  readonly info: ContextEngineInfo;
19
- constructor(store: CortextStore, bus: InterruptBus, logger: Logger, autoConsolidate: boolean, recallLimit: number);
19
+ /** Last full (pre-window) assembled view per scope compact() picks its cut
20
+ * from this, since CompactParams carry no messages. */
21
+ private lastView;
22
+ private compaction;
23
+ constructor(store: CortextStore, bus: InterruptBus, logger: Logger, cfg: CortextPluginConfig);
20
24
  ingest(params: IngestParams): Promise<IngestResult>;
21
25
  assemble(params: AssembleParams): Promise<AssembleResult>;
26
+ /** Drop the archived prefix (everything before the anchor), keeping system
27
+ * messages and bridging with a note. Self-heals if the anchor is gone. */
28
+ private applyWindow;
22
29
  compact(params: CompactParams): Promise<CompactResult>;
23
30
  dispose(): Promise<void>;
24
31
  private source;
package/dist/engine.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { formatMemories, memoryBlock, safe } from "./cortext.js";
2
+ import { CompactionState, anchorFor, bridgeMessage, chooseCut, matchesAnchor, readTranscriptMessages } from "./compaction.js";
2
3
  // Bound serialized tool-call arguments so a huge payload (a file write, a long
3
4
  // patch) doesn't dominate the store; the result text is ingested separately.
4
5
  const TOOL_ARGS_MAX_CHARS = 2000;
@@ -66,20 +67,22 @@ export class CortextContextEngine {
66
67
  store;
67
68
  bus;
68
69
  logger;
69
- autoConsolidate;
70
- recallLimit;
70
+ cfg;
71
71
  info = {
72
72
  id: "cortext",
73
73
  name: "Cortext Memory",
74
- version: "0.1.2",
75
- ownsCompaction: false,
74
+ version: "0.2.0",
75
+ ownsCompaction: true,
76
76
  };
77
- constructor(store, bus, logger, autoConsolidate, recallLimit) {
77
+ /** Last full (pre-window) assembled view per scope — compact() picks its cut
78
+ * from this, since CompactParams carry no messages. */
79
+ lastView = new Map();
80
+ compaction = new CompactionState();
81
+ constructor(store, bus, logger, cfg) {
78
82
  this.store = store;
79
83
  this.bus = bus;
80
84
  this.logger = logger;
81
- this.autoConsolidate = autoConsolidate;
82
- this.recallLimit = recallLimit;
85
+ this.cfg = cfg;
83
86
  }
84
87
  async ingest(params) {
85
88
  const role = String(params.message?.role ?? "user");
@@ -91,35 +94,91 @@ export class CortextContextEngine {
91
94
  return { ingested: ctx !== null };
92
95
  }
93
96
  async assemble(params) {
94
- const estimatedTokens = estimateTokens(params.messages);
95
- const query = (params.prompt ?? latestUserText(params.messages)).trim();
96
- if (!query)
97
- return { messages: params.messages, estimatedTokens };
98
97
  const scopeKey = this.store.scopeKey({ sessionKey: params.sessionKey, sessionId: params.sessionId });
98
+ // Remember the full pre-window view: compact() picks its cut from it.
99
+ this.lastView.set(scopeKey, params.messages);
100
+ const { messages, windowed } = this.applyWindow(scopeKey, params.messages);
101
+ const estimatedTokens = estimateTokens(messages);
102
+ const query = (params.prompt ?? latestUserText(params.messages)).trim();
99
103
  const engine = this.store.forScope(scopeKey);
100
- const ctx = engine.recall(query, this.source(params.sessionId, "agent", "assemble"));
101
- const recalled = ctx ? formatMemories(ctx.retrieved_memory, this.recallLimit) : "";
104
+ const ctx = query ? engine.recall(query, this.source(params.sessionId, "agent", "assemble")) : null;
105
+ const recalled = ctx ? formatMemories(ctx.retrieved_memory, this.cfg.recallLimit) : "";
106
+ // Full mode with an active window: the verbatim transcript is gone, so the
107
+ // live working-memory snapshot rides along with long-term recall.
108
+ const working = windowed && this.cfg.compactionMode === "full" && ctx
109
+ ? formatMemories(ctx.working_memory, this.cfg.recallLimit)
110
+ : "";
102
111
  // Drain what the gate staged mid-generation, keyed by the SAME scope key —
103
112
  // so a different scope's assemble can never pick it up.
104
113
  const staged = this.bus.take(scopeKey);
105
- const body = [staged, recalled].filter(Boolean).join("\n");
106
- if (!body)
107
- return { messages: params.messages, estimatedTokens };
114
+ const body = [staged, recalled, working].filter(Boolean).join("\n");
108
115
  return {
109
- messages: params.messages,
116
+ messages,
110
117
  estimatedTokens,
111
- systemPromptAddition: memoryBlock(body),
118
+ ...(windowed ? { promptAuthority: "assembled" } : {}),
119
+ ...(body ? { systemPromptAddition: memoryBlock(body) } : {}),
112
120
  };
113
121
  }
122
+ /** Drop the archived prefix (everything before the anchor), keeping system
123
+ * messages and bridging with a note. Self-heals if the anchor is gone. */
124
+ applyWindow(scopeKey, messages) {
125
+ const dir = this.store.storeDir();
126
+ this.compaction.load(dir);
127
+ const anchor = this.compaction.get(scopeKey);
128
+ if (!anchor)
129
+ return { messages, windowed: false };
130
+ let idx = -1;
131
+ for (let i = 0; i < messages.length; i++) {
132
+ if (messages[i].role !== "system" && matchesAnchor(messages[i], anchor, messageText)) {
133
+ idx = i;
134
+ break;
135
+ }
136
+ }
137
+ if (idx < 0) {
138
+ // Transcript rotated/rewritten under us — never over-drop; regrow instead.
139
+ this.compaction.clear(scopeKey, dir);
140
+ return { messages, windowed: false };
141
+ }
142
+ if (idx === 0)
143
+ return { messages, windowed: false };
144
+ const head = messages.slice(0, idx).filter((m) => m.role === "system");
145
+ return { messages: [...head, bridgeMessage(this.cfg.compactionMode), ...messages.slice(idx)], windowed: true };
146
+ }
114
147
  async compact(params) {
115
- // Cortext memory persists out-of-band; consolidate its graph, delegate
116
- // transcript compaction to the host. (Compact params carry no agentId in
117
- // the real openclaw types; agent scope derives from the sessionKey.)
118
- const engine = this.store.for({ sessionKey: params.sessionKey, sessionId: params.sessionId });
119
- if (this.autoConsolidate)
148
+ // Compaction = moving the window, not destroying the transcript. Every
149
+ // message is already in the durable store (ingest), so we pick an
150
+ // exchange-aligned cut in the last assembled view, anchor it, and let
151
+ // assemble() drop the archived prefix from the model context. The on-disk
152
+ // transcript is untouched; dropped content stays recallable. No LLM call.
153
+ // (Compact params carry no agentId in the real openclaw types; agent scope
154
+ // derives from the sessionKey.)
155
+ const scopeKey = this.store.scopeKey({ sessionKey: params.sessionKey, sessionId: params.sessionId });
156
+ const engine = this.store.forScope(scopeKey);
157
+ if (this.cfg.autoConsolidate)
120
158
  engine.consolidate();
121
159
  engine.flush();
122
- return { ok: true, compacted: false, reason: "cortext retains memory out-of-band; transcript compaction delegated to host" };
160
+ // Preflight compaction on a fresh gateway process runs before any
161
+ // assemble — fall back to reading the transcript file so a cold-start
162
+ // compact still works (returning compacted:false here fails the turn).
163
+ const view = this.lastView.get(scopeKey) ?? readTranscriptMessages(params.sessionFile);
164
+ if (!view.length) {
165
+ return { ok: true, compacted: false, reason: "no assembled view or readable transcript for this scope" };
166
+ }
167
+ const cut = chooseCut(view, this.cfg.compactionMode, this.cfg.protectTail);
168
+ const dropped = view.slice(0, cut).filter((m) => m.role !== "system").length;
169
+ if (cut <= 0 || dropped === 0) {
170
+ return { ok: true, compacted: false, reason: "nothing before the protected window to archive" };
171
+ }
172
+ const dir = this.store.storeDir();
173
+ this.compaction.load(dir);
174
+ this.compaction.set(scopeKey, anchorFor(view[cut], messageText, dropped), dir);
175
+ const tokensBefore = estimateTokens(view);
176
+ const kept = this.applyWindow(scopeKey, view).messages;
177
+ const tokensAfter = estimateTokens(kept);
178
+ const summary = `Archived ${dropped} message(s) to Cortext durable memory ` +
179
+ `(${this.cfg.compactionMode} mode; recalled per turn, no summarizer LLM call).`;
180
+ this.logger.info(`cortext compaction: ${summary} ~${tokensBefore} -> ~${tokensAfter} tokens (scope ${scopeKey})`);
181
+ return { ok: true, compacted: true, reason: summary, result: { summary, tokensBefore, tokensAfter } };
123
182
  }
124
183
  async dispose() {
125
184
  this.store.disposeAll();
package/dist/register.js CHANGED
@@ -18,7 +18,7 @@ export function register(api) {
18
18
  const store = new CortextStore(cfg, join(homedir(), ".openclaw", "cortext"));
19
19
  api.registerContextEngine("cortext", (ctx) => {
20
20
  store.setBaseDir(ctx?.agentDir);
21
- return new CortextContextEngine(store, bus, api.logger, cfg.autoConsolidate, cfg.recallLimit);
21
+ return new CortextContextEngine(store, bus, api.logger, cfg);
22
22
  });
23
23
  if (cfg.interruptGate) {
24
24
  const gate = new InterruptGate(store, bus, api.logger, cfg.ingestReasoning, cfg.recallLimit);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "cortext",
3
3
  "name": "Cortext Memory",
4
- "version": "0.1.2",
4
+ "version": "0.2.0",
5
5
  "description": "Durable local memory for OpenClaw. Per-conversation-isolated context engine with an interrupt gate over streaming reasoning.",
6
6
  "kind": "context-engine",
7
7
  "configSchema": {
@@ -66,6 +66,19 @@
66
66
  "type": "boolean",
67
67
  "description": "Consolidate memory on compaction.",
68
68
  "default": true
69
+ },
70
+ "compactionMode": {
71
+ "type": "string",
72
+ "enum": ["hybrid", "full"],
73
+ "description": "Cortext owns compaction (no summarizer LLM call). 'hybrid': keep system prompt + long-term recall + a verbatim tail of recent messages. 'full': keep system prompt + Cortext memory only (long-term recall plus the working-memory snapshot); the verbatim window shrinks to the current exchange.",
74
+ "default": "hybrid"
75
+ },
76
+ "protectTail": {
77
+ "type": "integer",
78
+ "minimum": 0,
79
+ "maximum": 64,
80
+ "description": "Hybrid mode: trailing messages kept verbatim after compaction (cut is walked back to a user-message boundary).",
81
+ "default": 6
69
82
  }
70
83
  }
71
84
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@augmem/cortext-openclaw-plugin",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Cortext memory for OpenClaw: a per-conversation-isolated context engine plus an interrupt gate over streaming reasoning.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",