@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 +21 -3
- package/dist/compaction.d.ts +59 -0
- package/dist/compaction.js +115 -0
- package/dist/config.d.ts +12 -0
- package/dist/config.js +7 -0
- package/dist/cortext.d.ts +4 -0
- package/dist/cortext.js +13 -8
- package/dist/engine.d.ts +10 -3
- package/dist/engine.js +83 -24
- package/dist/register.js +1 -1
- package/openclaw.plugin.json +14 -1
- package/package.json +1 -1
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
|
|
102
|
-
`ownsCompaction:
|
|
103
|
-
`compact`
|
|
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
|
-
|
|
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
|
|
17
|
-
private readonly recallLimit;
|
|
17
|
+
private readonly cfg;
|
|
18
18
|
readonly info: ContextEngineInfo;
|
|
19
|
-
|
|
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
|
-
|
|
70
|
-
recallLimit;
|
|
70
|
+
cfg;
|
|
71
71
|
info = {
|
|
72
72
|
id: "cortext",
|
|
73
73
|
name: "Cortext Memory",
|
|
74
|
-
version: "0.
|
|
75
|
-
ownsCompaction:
|
|
74
|
+
version: "0.2.0",
|
|
75
|
+
ownsCompaction: true,
|
|
76
76
|
};
|
|
77
|
-
|
|
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.
|
|
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
|
|
116
|
+
messages,
|
|
110
117
|
estimatedTokens,
|
|
111
|
-
|
|
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
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "cortext",
|
|
3
3
|
"name": "Cortext Memory",
|
|
4
|
-
"version": "0.
|
|
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.
|
|
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",
|