@psiclawops/hypercompositor 0.5.4 → 0.7.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/dist/index.d.ts.map +1 -1
- package/dist/index.js +325 -58
- package/openclaw.plugin.json +3 -0
- package/package.json +11 -3
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAaH,OAAO,KAAK,EACV,cAAc,EACd,eAAe,EACf,iBAAiB,EACjB,cAAc,EACd,aAAa,EAId,MAAM,sBAAsB,CAAC;AAW9B,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,cAAc,EAAE,aAAa,EAAE,CAAC;AAq5ElG;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAU1F;;;;;;;;AA8DD,wBA4CG"}
|
package/dist/index.js
CHANGED
|
@@ -19,20 +19,24 @@
|
|
|
19
19
|
*
|
|
20
20
|
* Session key format expected: "agent:<agentId>:<channel>:<name>"
|
|
21
21
|
*/
|
|
22
|
-
import { definePluginEntry
|
|
23
|
-
import {
|
|
22
|
+
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
|
|
23
|
+
import { buildPluginConfigSchema } from 'openclaw/plugin-sdk/core';
|
|
24
|
+
import { z } from 'zod';
|
|
25
|
+
import { detectTopicShift, stripMessageMetadata, SessionTopicMap, applyToolGradientToWindow, canPersistReshapedHistory, OPENCLAW_BOOTSTRAP_FILES } from '@psiclawops/hypermem';
|
|
24
26
|
import { evictStaleContent } from '@psiclawops/hypermem/image-eviction';
|
|
25
27
|
import { repairToolPairs } from '@psiclawops/hypermem';
|
|
26
28
|
import os from 'os';
|
|
27
29
|
import path from 'path';
|
|
28
30
|
import fs from 'fs/promises';
|
|
29
31
|
import { createRequire } from 'module';
|
|
32
|
+
import { fileURLToPath } from 'url';
|
|
30
33
|
// ─── hypermem singleton ────────────────────────────────────────
|
|
31
34
|
// Runtime load is dynamic (hypermem is a sibling package loaded from repo dist,
|
|
32
35
|
// not installed via npm). Types come from the core package devDependency.
|
|
33
36
|
// This pattern keeps the runtime path stable while TypeScript resolves types
|
|
34
37
|
// from the canonical source — no more local shim drift.
|
|
35
|
-
|
|
38
|
+
// Resolved at init time: pluginConfig.hyperMemPath > require.resolve('@psiclawops/hypermem') > dev fallback
|
|
39
|
+
let HYPERMEM_PATH = '';
|
|
36
40
|
const require = createRequire(import.meta.url);
|
|
37
41
|
let _hm = null;
|
|
38
42
|
let _hmInitPromise = null;
|
|
@@ -108,25 +112,52 @@ function computeEffectiveBudget(tokenBudget) {
|
|
|
108
112
|
// Derived from window config: floor to avoid fractional tokens
|
|
109
113
|
return Math.floor(_contextWindowSize * (1 - _contextWindowReserve));
|
|
110
114
|
}
|
|
115
|
+
// ─── Plugin config cache ───────────────────────────────────────
|
|
116
|
+
// Populated from openclaw.json plugins.entries.hypercompositor.config
|
|
117
|
+
// during register(). loadUserConfig() merges this over config.json.
|
|
118
|
+
let _pluginConfig = {};
|
|
111
119
|
/**
|
|
112
|
-
* Load
|
|
113
|
-
*
|
|
114
|
-
*
|
|
120
|
+
* Load user config with priority: pluginConfig (openclaw.json) > config.json (legacy).
|
|
121
|
+
* pluginConfig values win; config.json provides fallback for keys not set in openclaw.json.
|
|
122
|
+
* This allows gradual migration from the shadow config.json to central config.
|
|
115
123
|
*/
|
|
116
124
|
async function loadUserConfig() {
|
|
117
|
-
|
|
125
|
+
// Resolve data dir: pluginConfig > default
|
|
126
|
+
const dataDir = _pluginConfig.dataDir ?? path.join(os.homedir(), '.openclaw/hypermem');
|
|
127
|
+
const configPath = path.join(dataDir, 'config.json');
|
|
128
|
+
let fileConfig = {};
|
|
118
129
|
try {
|
|
119
130
|
const raw = await fs.readFile(configPath, 'utf-8');
|
|
120
|
-
|
|
121
|
-
console.log(`[hypermem-plugin] Loaded
|
|
122
|
-
return parsed;
|
|
131
|
+
fileConfig = JSON.parse(raw);
|
|
132
|
+
console.log(`[hypermem-plugin] Loaded legacy config from ${configPath}`);
|
|
123
133
|
}
|
|
124
134
|
catch (err) {
|
|
125
135
|
if (err.code !== 'ENOENT') {
|
|
126
136
|
console.warn(`[hypermem-plugin] Failed to parse config.json (using defaults):`, err.message);
|
|
127
137
|
}
|
|
128
|
-
return {};
|
|
129
138
|
}
|
|
139
|
+
// Merge: pluginConfig (openclaw.json) wins over fileConfig (legacy config.json).
|
|
140
|
+
// Top-level scalar keys from pluginConfig override fileConfig.
|
|
141
|
+
// Nested objects (compositor, eviction, embedding) are shallow-merged.
|
|
142
|
+
const merged = { ...fileConfig };
|
|
143
|
+
if (_pluginConfig.contextWindowSize != null)
|
|
144
|
+
merged.contextWindowSize = _pluginConfig.contextWindowSize;
|
|
145
|
+
if (_pluginConfig.contextWindowReserve != null)
|
|
146
|
+
merged.contextWindowReserve = _pluginConfig.contextWindowReserve;
|
|
147
|
+
if (_pluginConfig.deferToolPruning != null)
|
|
148
|
+
merged.deferToolPruning = _pluginConfig.deferToolPruning;
|
|
149
|
+
if (_pluginConfig.subagentWarming != null)
|
|
150
|
+
merged.subagentWarming = _pluginConfig.subagentWarming;
|
|
151
|
+
if (_pluginConfig.compositor)
|
|
152
|
+
merged.compositor = { ...merged.compositor, ..._pluginConfig.compositor };
|
|
153
|
+
if (_pluginConfig.eviction)
|
|
154
|
+
merged.eviction = { ...merged.eviction, ..._pluginConfig.eviction };
|
|
155
|
+
if (_pluginConfig.embedding)
|
|
156
|
+
merged.embedding = { ...merged.embedding, ..._pluginConfig.embedding };
|
|
157
|
+
if (Object.keys(fileConfig).length > 0 && Object.keys(_pluginConfig).filter(k => k !== 'hyperMemPath' && k !== 'dataDir').length > 0) {
|
|
158
|
+
console.log('[hypermem-plugin] Note: migrating config.json keys to plugins.entries.hypercompositor.config in openclaw.json is recommended');
|
|
159
|
+
}
|
|
160
|
+
return merged;
|
|
130
161
|
}
|
|
131
162
|
async function getHyperMem() {
|
|
132
163
|
if (_hm)
|
|
@@ -150,16 +181,24 @@ async function getHyperMem() {
|
|
|
150
181
|
// (VectorStore init) and the _generateEmbeddings closure above.
|
|
151
182
|
if (userConfig.embedding) {
|
|
152
183
|
const ue = userConfig.embedding;
|
|
184
|
+
// Provider-specific model/dimension/batch defaults
|
|
185
|
+
const providerDefaults = ue.provider === 'gemini'
|
|
186
|
+
? { model: 'gemini-embedding-001', dimensions: 3072, batchSize: 100, timeout: 15000 }
|
|
187
|
+
: ue.provider === 'openai'
|
|
188
|
+
? { model: 'text-embedding-3-small', dimensions: 1536, batchSize: 128, timeout: 10000 }
|
|
189
|
+
: { model: 'nomic-embed-text', dimensions: 768, batchSize: 32, timeout: 10000 };
|
|
153
190
|
_embeddingConfig = {
|
|
154
191
|
provider: ue.provider ?? 'ollama',
|
|
155
192
|
ollamaUrl: ue.ollamaUrl ?? 'http://localhost:11434',
|
|
156
193
|
openaiBaseUrl: ue.openaiBaseUrl ?? 'https://api.openai.com/v1',
|
|
157
194
|
openaiApiKey: ue.openaiApiKey,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
195
|
+
geminiBaseUrl: ue.geminiBaseUrl,
|
|
196
|
+
geminiIndexTaskType: ue.geminiIndexTaskType,
|
|
197
|
+
geminiQueryTaskType: ue.geminiQueryTaskType,
|
|
198
|
+
model: ue.model ?? providerDefaults.model,
|
|
199
|
+
dimensions: ue.dimensions ?? providerDefaults.dimensions,
|
|
200
|
+
timeout: ue.timeout ?? providerDefaults.timeout,
|
|
201
|
+
batchSize: ue.batchSize ?? providerDefaults.batchSize,
|
|
163
202
|
};
|
|
164
203
|
console.log(`[hypermem-plugin] Embedding provider: ${_embeddingConfig.provider} ` +
|
|
165
204
|
`(model: ${_embeddingConfig.model}, ${_embeddingConfig.dimensions}d, batch: ${_embeddingConfig.batchSize})`);
|
|
@@ -192,7 +231,7 @@ async function getHyperMem() {
|
|
|
192
231
|
`${Math.round(_contextWindowReserve * 100)}% reserved (${reservedTokens} tokens), ` +
|
|
193
232
|
`effective history budget: ${_contextWindowSize - reservedTokens} tokens`);
|
|
194
233
|
const instance = await HyperMem.create({
|
|
195
|
-
dataDir: path.join(os.homedir(), '.openclaw/hypermem'),
|
|
234
|
+
dataDir: _pluginConfig.dataDir ?? path.join(os.homedir(), '.openclaw/hypermem'),
|
|
196
235
|
cache: {
|
|
197
236
|
keyPrefix: 'hm:',
|
|
198
237
|
sessionTTL: 14400, // 4h for system/identity/meta slots
|
|
@@ -679,7 +718,7 @@ function createHyperMemEngine() {
|
|
|
679
718
|
info: {
|
|
680
719
|
id: 'hypermem',
|
|
681
720
|
name: 'hypermem context engine',
|
|
682
|
-
version: '0.
|
|
721
|
+
version: '0.6.3',
|
|
683
722
|
// We own compaction — assemble() trims to budget via the compositor safety
|
|
684
723
|
// valve, so runtime compaction is never needed. compact() handles any
|
|
685
724
|
// explicit calls by trimming the Redis history window directly.
|
|
@@ -703,34 +742,7 @@ function createHyperMemEngine() {
|
|
|
703
742
|
const hm = await getHyperMem();
|
|
704
743
|
const sk = resolveSessionKey(sessionId, sessionKey);
|
|
705
744
|
const agentId = extractAgentId(sk);
|
|
706
|
-
// EC1
|
|
707
|
-
// the safe replay threshold. Fires BEFORE warm() so the next restart
|
|
708
|
-
// (not this one) loads a clean file. Combined with the preflight script
|
|
709
|
-
// run before each gateway restart, this closes the EC1 loop entirely.
|
|
710
|
-
//
|
|
711
|
-
// Why this doesn't help the CURRENT session:
|
|
712
|
-
// OpenClaw has already replayed the JSONL into memory by the time
|
|
713
|
-
// bootstrap() is called. Disk truncation here is forward-looking only.
|
|
714
|
-
//
|
|
715
|
-
// Why it still matters:
|
|
716
|
-
// Without this, a session that saturates in operation (no preflight ran)
|
|
717
|
-
// would restart saturated on the next boot. This ensures at least one
|
|
718
|
-
// restart cycle later the session comes up clean.
|
|
719
|
-
try {
|
|
720
|
-
const sessionDir = path.join(os.homedir(), '.openclaw', 'agents', agentId, 'sessions');
|
|
721
|
-
const jsonlPath = path.join(sessionDir, `${sessionId}.jsonl`);
|
|
722
|
-
// EC1 threshold: 60 conversation messages (token-capped at 40% of 128k)
|
|
723
|
-
const EC1_MAX_MESSAGES = 60;
|
|
724
|
-
const EC1_TOKEN_BUDGET = Math.floor(128_000 * 0.40);
|
|
725
|
-
const truncated = await truncateJsonlIfNeeded(jsonlPath, EC1_MAX_MESSAGES, false, EC1_TOKEN_BUDGET);
|
|
726
|
-
if (truncated) {
|
|
727
|
-
console.log(`[hypermem-plugin] bootstrap: proactive JSONL trim for ${agentId} ` +
|
|
728
|
-
`(EC1 guard — next restart will load clean)`);
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
catch {
|
|
732
|
-
// Non-fatal — JSONL truncation is best-effort
|
|
733
|
-
}
|
|
745
|
+
// EC1 JSONL truncation moved to maintain() — bootstrap stays fast.
|
|
734
746
|
// Fast path: if session already has history in Redis, skip warm entirely.
|
|
735
747
|
// sessionExists() is a single EXISTS call — sub-millisecond cost.
|
|
736
748
|
const alreadyWarm = await hm.cache.sessionExists(agentId, sk);
|
|
@@ -746,10 +758,12 @@ function createHyperMemEngine() {
|
|
|
746
758
|
return { bootstrapped: true };
|
|
747
759
|
}
|
|
748
760
|
// Cold start: warm Redis with the session — pre-loads history + slots
|
|
749
|
-
// CRIT-002: Load identity
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
761
|
+
// CRIT-002: Load supplemental identity files (MOTIVATIONS.md, STYLE.md) that are
|
|
762
|
+
// NOT already injected by OpenClaw's contextInjection into the system prompt.
|
|
763
|
+
// SOUL.md and IDENTITY.md are filtered out here because OpenClaw injects them
|
|
764
|
+
// via workspace bootstrap — re-injecting them via the identity slot would cause
|
|
765
|
+
// duplication. Only agent-specific extras (MOTIVATIONS.md, STYLE.md) are included.
|
|
766
|
+
// Non-fatal: missing files are silently skipped.
|
|
753
767
|
let identityBlock;
|
|
754
768
|
try {
|
|
755
769
|
// Council agents live at workspace-council/<agentId>/
|
|
@@ -764,7 +778,8 @@ function createHyperMemEngine() {
|
|
|
764
778
|
catch {
|
|
765
779
|
wsPath = workspacePath;
|
|
766
780
|
}
|
|
767
|
-
const identityFiles = ['SOUL.md', 'IDENTITY.md', 'MOTIVATIONS.md', 'STYLE.md']
|
|
781
|
+
const identityFiles = ['SOUL.md', 'IDENTITY.md', 'MOTIVATIONS.md', 'STYLE.md']
|
|
782
|
+
.filter(f => !OPENCLAW_BOOTSTRAP_FILES.has(f));
|
|
768
783
|
const parts = [];
|
|
769
784
|
for (const fname of identityFiles) {
|
|
770
785
|
try {
|
|
@@ -853,6 +868,67 @@ function createHyperMemEngine() {
|
|
|
853
868
|
return { bootstrapped: false, reason: err.message };
|
|
854
869
|
}
|
|
855
870
|
},
|
|
871
|
+
/**
|
|
872
|
+
* Transcript maintenance — runs after bootstrap, successful turns, or compaction.
|
|
873
|
+
*
|
|
874
|
+
* Moved from bootstrap: proactive JSONL truncation is forward-looking (helps
|
|
875
|
+
* next restart, not current session), so it belongs in maintenance, not init.
|
|
876
|
+
* Also runs tool pair repair on Redis history to fix orphaned pairs from
|
|
877
|
+
* trim/compaction passes.
|
|
878
|
+
*/
|
|
879
|
+
async maintain({ sessionId, sessionKey, sessionFile }) {
|
|
880
|
+
let changed = false;
|
|
881
|
+
let bytesFreed = 0;
|
|
882
|
+
let rewrittenEntries = 0;
|
|
883
|
+
try {
|
|
884
|
+
const hm = await getHyperMem();
|
|
885
|
+
const sk = resolveSessionKey(sessionId, sessionKey);
|
|
886
|
+
const agentId = extractAgentId(sk);
|
|
887
|
+
// 1. Proactive JSONL truncation (EC1 guard — next restart loads clean)
|
|
888
|
+
try {
|
|
889
|
+
const EC1_MAX_MESSAGES = 60;
|
|
890
|
+
const EC1_TOKEN_BUDGET = Math.floor(128_000 * 0.40);
|
|
891
|
+
const truncated = await truncateJsonlIfNeeded(sessionFile, EC1_MAX_MESSAGES, false, EC1_TOKEN_BUDGET);
|
|
892
|
+
if (truncated) {
|
|
893
|
+
console.log(`[hypermem-plugin] maintain: proactive JSONL trim for ${agentId} ` +
|
|
894
|
+
`(EC1 guard — next restart will load clean)`);
|
|
895
|
+
changed = true;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
// Non-fatal — JSONL truncation is best-effort
|
|
900
|
+
}
|
|
901
|
+
// 2. Redis history tool pair repair
|
|
902
|
+
// Compaction and trim passes can orphan tool_call/tool_result pairs.
|
|
903
|
+
// Anthropic and Gemini reject orphaned pairs with 400 errors.
|
|
904
|
+
try {
|
|
905
|
+
const history = await hm.cache.getHistory(agentId, sk);
|
|
906
|
+
if (history && history.length > 0) {
|
|
907
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
908
|
+
const repairedHistory = repairToolPairs(history);
|
|
909
|
+
const removedCount = history.length - repairedHistory.length;
|
|
910
|
+
if (removedCount > 0) {
|
|
911
|
+
await hm.cache.replaceHistory(agentId, sk, repairedHistory);
|
|
912
|
+
await hm.cache.invalidateWindow(agentId, sk);
|
|
913
|
+
console.log(`[hypermem-plugin] maintain: repaired tool pairs in Redis history ` +
|
|
914
|
+
`for ${agentId} (removed ${removedCount} orphaned messages)`);
|
|
915
|
+
changed = true;
|
|
916
|
+
rewrittenEntries += removedCount;
|
|
917
|
+
// Rough estimate: ~500 bytes per removed message
|
|
918
|
+
bytesFreed += removedCount * 500;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
// Non-fatal
|
|
924
|
+
}
|
|
925
|
+
return { changed, bytesFreed, rewrittenEntries };
|
|
926
|
+
}
|
|
927
|
+
catch (err) {
|
|
928
|
+
console.warn('[hypermem-plugin] maintain failed:', err.message);
|
|
929
|
+
return { changed, bytesFreed, rewrittenEntries, reason: err.message };
|
|
930
|
+
}
|
|
931
|
+
},
|
|
856
932
|
/**
|
|
857
933
|
* Ingest a single message into hypermem's message store.
|
|
858
934
|
* Skip heartbeats — they're noise in the memory store.
|
|
@@ -891,17 +967,24 @@ function createHyperMemEngine() {
|
|
|
891
967
|
const redisTokens = await estimateWindowTokens(hm, agentId, sk);
|
|
892
968
|
const effectiveBudget = computeEffectiveBudget(undefined);
|
|
893
969
|
const redisPressure = redisTokens / effectiveBudget;
|
|
970
|
+
// Error tool results are always preserved intact — they're small and
|
|
971
|
+
// the model needs the error signal to understand what went wrong.
|
|
972
|
+
const hasErrorResult = neutral.toolResults.some(tr => tr.isError);
|
|
894
973
|
if (redisPressure > 0.85) {
|
|
895
974
|
// FIX (Bug 4): Never skip a tool result entirely — that leaves an orphaned
|
|
896
975
|
// tool_call in Redis history (the assistant message was already recorded).
|
|
897
976
|
// Anthropic rejects assistant messages with tool_calls that have no matching result.
|
|
898
977
|
// Instead, record a compact stub that preserves pair integrity in history.
|
|
899
|
-
const stubbedResults = neutral.toolResults.map(tr =>
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
978
|
+
const stubbedResults = neutral.toolResults.map(tr => {
|
|
979
|
+
if (tr.isError)
|
|
980
|
+
return tr; // preserve error results intact
|
|
981
|
+
return {
|
|
982
|
+
...tr,
|
|
983
|
+
content: `[tool result omitted by wave-guard at ${(redisPressure * 100).toFixed(0)}% Redis pressure]`,
|
|
984
|
+
};
|
|
985
|
+
});
|
|
903
986
|
const stubNeutral = { ...neutral, toolResults: stubbedResults };
|
|
904
|
-
console.log(`[hypermem] ingest wave-guard: stubbing toolResult (Redis pressure ${(redisPressure * 100).toFixed(0)}% > 85%) — preserving pair integrity`);
|
|
987
|
+
console.log(`[hypermem] ingest wave-guard: stubbing toolResult (Redis pressure ${(redisPressure * 100).toFixed(0)}% > 85%)${hasErrorResult ? ' — error results preserved' : ''} — preserving pair integrity`);
|
|
905
988
|
await hm.recordAssistantMessage(agentId, sk, stubNeutral);
|
|
906
989
|
return { ingested: true };
|
|
907
990
|
}
|
|
@@ -911,6 +994,8 @@ function createHyperMemEngine() {
|
|
|
911
994
|
neutral = {
|
|
912
995
|
...neutral,
|
|
913
996
|
toolResults: neutral.toolResults.map(tr => {
|
|
997
|
+
if (tr.isError)
|
|
998
|
+
return tr; // preserve error results intact
|
|
914
999
|
const content = typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content);
|
|
915
1000
|
if (content.length <= MAX_TOOL_RESULT_CHARS)
|
|
916
1001
|
return tr;
|
|
@@ -920,7 +1005,7 @@ function createHyperMemEngine() {
|
|
|
920
1005
|
};
|
|
921
1006
|
}),
|
|
922
1007
|
};
|
|
923
|
-
console.log(`[hypermem] ingest wave-guard: truncated toolResult (Redis pressure ${(redisPressure * 100).toFixed(0)}% > 70%)`);
|
|
1008
|
+
console.log(`[hypermem] ingest wave-guard: truncated toolResult (Redis pressure ${(redisPressure * 100).toFixed(0)}% > 70%)${hasErrorResult ? ' — error results preserved' : ''}`);
|
|
924
1009
|
}
|
|
925
1010
|
}
|
|
926
1011
|
await hm.recordAssistantMessage(agentId, sk, neutral);
|
|
@@ -932,6 +1017,41 @@ function createHyperMemEngine() {
|
|
|
932
1017
|
return { ingested: false };
|
|
933
1018
|
}
|
|
934
1019
|
},
|
|
1020
|
+
/**
|
|
1021
|
+
* Batch ingest: process multiple messages in a single call.
|
|
1022
|
+
*
|
|
1023
|
+
* Note: when afterTurn() is defined (which it is), the runtime calls
|
|
1024
|
+
* afterTurn instead of ingest/ingestBatch. This is here for interface
|
|
1025
|
+
* completeness and forward compatibility.
|
|
1026
|
+
*/
|
|
1027
|
+
async ingestBatch({ sessionId, sessionKey, messages, isHeartbeat }) {
|
|
1028
|
+
if (isHeartbeat) {
|
|
1029
|
+
return { ingestedCount: 0 };
|
|
1030
|
+
}
|
|
1031
|
+
let ingestedCount = 0;
|
|
1032
|
+
try {
|
|
1033
|
+
const hm = await getHyperMem();
|
|
1034
|
+
const sk = resolveSessionKey(sessionId, sessionKey);
|
|
1035
|
+
const agentId = extractAgentId(sk);
|
|
1036
|
+
for (const message of messages) {
|
|
1037
|
+
const msg = message;
|
|
1038
|
+
if (msg.role === 'system')
|
|
1039
|
+
continue;
|
|
1040
|
+
const neutral = toNeutralMessage(msg);
|
|
1041
|
+
if (neutral.role === 'user' && !neutral.toolResults?.length) {
|
|
1042
|
+
await hm.recordUserMessage(agentId, sk, stripMessageMetadata(neutral.textContent ?? ''));
|
|
1043
|
+
}
|
|
1044
|
+
else {
|
|
1045
|
+
await hm.recordAssistantMessage(agentId, sk, neutral);
|
|
1046
|
+
}
|
|
1047
|
+
ingestedCount++;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
catch (err) {
|
|
1051
|
+
console.warn('[hypermem-plugin] ingestBatch failed:', err.message);
|
|
1052
|
+
}
|
|
1053
|
+
return { ingestedCount };
|
|
1054
|
+
},
|
|
935
1055
|
/**
|
|
936
1056
|
* Assemble model context from all four hypermem layers.
|
|
937
1057
|
*
|
|
@@ -1903,6 +2023,81 @@ function createHyperMemEngine() {
|
|
|
1903
2023
|
console.warn('[hypermem-plugin] afterTurn failed:', err.message);
|
|
1904
2024
|
}
|
|
1905
2025
|
},
|
|
2026
|
+
/**
|
|
2027
|
+
* Prepare context for a subagent session before it starts.
|
|
2028
|
+
*
|
|
2029
|
+
* Seeds the child session's Redis with parent context based on the
|
|
2030
|
+
* subagentWarming config ('full' | 'light' | 'off').
|
|
2031
|
+
* Returns a rollback handle to clean up if spawn fails.
|
|
2032
|
+
*/
|
|
2033
|
+
async prepareSubagentSpawn({ parentSessionKey, childSessionKey }) {
|
|
2034
|
+
if (_subagentWarming === 'off') {
|
|
2035
|
+
return undefined;
|
|
2036
|
+
}
|
|
2037
|
+
try {
|
|
2038
|
+
const hm = await getHyperMem();
|
|
2039
|
+
const parentAgentId = extractAgentId(parentSessionKey);
|
|
2040
|
+
const childAgentId = extractAgentId(childSessionKey);
|
|
2041
|
+
// Seed child with parent's active facts
|
|
2042
|
+
const facts = hm.getActiveFacts(parentAgentId, { limit: 50 });
|
|
2043
|
+
if (facts && facts.length > 0) {
|
|
2044
|
+
const factBlock = facts
|
|
2045
|
+
.map(f => f.content)
|
|
2046
|
+
.join('\n');
|
|
2047
|
+
await hm.cache.setSlot(childAgentId, childSessionKey, 'parentFacts', factBlock);
|
|
2048
|
+
}
|
|
2049
|
+
// For 'full' warming, also seed recent history context
|
|
2050
|
+
if (_subagentWarming === 'full') {
|
|
2051
|
+
const history = await hm.cache.getHistory(parentAgentId, parentSessionKey);
|
|
2052
|
+
if (history && history.length > 0) {
|
|
2053
|
+
const recentHistory = history.slice(-10);
|
|
2054
|
+
await hm.cache.setSlot(childAgentId, childSessionKey, 'parentHistory', JSON.stringify(recentHistory));
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
console.log(`[hypermem-plugin] prepareSubagentSpawn: seeded ${childSessionKey} ` +
|
|
2058
|
+
`from ${parentSessionKey} (warming=${_subagentWarming})`);
|
|
2059
|
+
return {
|
|
2060
|
+
async rollback() {
|
|
2061
|
+
try {
|
|
2062
|
+
const hm = await getHyperMem();
|
|
2063
|
+
await hm.cache.setSlot(childAgentId, childSessionKey, 'parentFacts', '');
|
|
2064
|
+
await hm.cache.setSlot(childAgentId, childSessionKey, 'parentHistory', '');
|
|
2065
|
+
}
|
|
2066
|
+
catch {
|
|
2067
|
+
// Rollback is best-effort
|
|
2068
|
+
}
|
|
2069
|
+
},
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
catch (err) {
|
|
2073
|
+
console.warn('[hypermem-plugin] prepareSubagentSpawn failed (non-fatal):', err.message);
|
|
2074
|
+
return undefined;
|
|
2075
|
+
}
|
|
2076
|
+
},
|
|
2077
|
+
/**
|
|
2078
|
+
* Clean up after a subagent session ends.
|
|
2079
|
+
*
|
|
2080
|
+
* Removes Redis slots and invalidates caches for the dead session
|
|
2081
|
+
* to prevent stale data accumulation.
|
|
2082
|
+
*/
|
|
2083
|
+
async onSubagentEnded({ childSessionKey, reason }) {
|
|
2084
|
+
try {
|
|
2085
|
+
const hm = await getHyperMem();
|
|
2086
|
+
const childAgentId = extractAgentId(childSessionKey);
|
|
2087
|
+
await Promise.all([
|
|
2088
|
+
hm.cache.setSlot(childAgentId, childSessionKey, 'parentFacts', ''),
|
|
2089
|
+
hm.cache.setSlot(childAgentId, childSessionKey, 'parentHistory', ''),
|
|
2090
|
+
hm.cache.setSlot(childAgentId, childSessionKey, 'assemblyContextBlock', ''),
|
|
2091
|
+
hm.cache.setSlot(childAgentId, childSessionKey, 'assemblyContextAt', '0'),
|
|
2092
|
+
hm.cache.invalidateWindow(childAgentId, childSessionKey).catch(() => { }),
|
|
2093
|
+
]);
|
|
2094
|
+
_overheadCache.delete(childSessionKey);
|
|
2095
|
+
console.log(`[hypermem-plugin] onSubagentEnded: cleaned up ${childSessionKey} (reason=${reason})`);
|
|
2096
|
+
}
|
|
2097
|
+
catch (err) {
|
|
2098
|
+
console.warn('[hypermem-plugin] onSubagentEnded failed (non-fatal):', err.message);
|
|
2099
|
+
}
|
|
2100
|
+
},
|
|
1906
2101
|
/**
|
|
1907
2102
|
* Dispose: intentionally a no-op.
|
|
1908
2103
|
*
|
|
@@ -2028,6 +2223,58 @@ export async function bustAssemblyCache(agentId, sessionKey) {
|
|
|
2028
2223
|
// Non-fatal
|
|
2029
2224
|
}
|
|
2030
2225
|
}
|
|
2226
|
+
// ─── Plugin Config Schema ────────────────────────────────────────
|
|
2227
|
+
// Exposed via openclaw.json → plugins.entries.hypercompositor.config
|
|
2228
|
+
// Validated by OpenClaw on gateway start. Visible via `openclaw config get`.
|
|
2229
|
+
const hypercompositorConfigSchema = z.object({
|
|
2230
|
+
/** Path to HyperMem core dist/index.js. Auto-resolved if omitted. */
|
|
2231
|
+
hyperMemPath: z.string().optional(),
|
|
2232
|
+
/** HyperMem data directory. Default: ~/.openclaw/hypermem */
|
|
2233
|
+
dataDir: z.string().optional(),
|
|
2234
|
+
/** Full model context window size in tokens. Default: 128000 */
|
|
2235
|
+
contextWindowSize: z.number().int().positive().optional(),
|
|
2236
|
+
/** Fraction [0.0–0.5] reserved for system prompts + headroom. Default: 0.25 */
|
|
2237
|
+
contextWindowReserve: z.number().min(0).max(0.5).optional(),
|
|
2238
|
+
/** Defer tool pruning to OpenClaw's contextPruning. Default: false */
|
|
2239
|
+
deferToolPruning: z.boolean().optional(),
|
|
2240
|
+
/** Subagent context injection: 'full' | 'light' | 'off'. Default: 'light' */
|
|
2241
|
+
subagentWarming: z.enum(['full', 'light', 'off']).optional(),
|
|
2242
|
+
/** Compositor tuning overrides */
|
|
2243
|
+
compositor: z.object({
|
|
2244
|
+
defaultTokenBudget: z.number().int().positive().optional(),
|
|
2245
|
+
maxHistoryMessages: z.number().int().positive().optional(),
|
|
2246
|
+
maxFacts: z.number().int().positive().optional(),
|
|
2247
|
+
maxCrossSessionContext: z.number().int().nonnegative().optional(),
|
|
2248
|
+
maxRecentToolPairs: z.number().int().nonnegative().optional(),
|
|
2249
|
+
maxProseToolPairs: z.number().int().nonnegative().optional(),
|
|
2250
|
+
warmHistoryBudgetFraction: z.number().min(0).max(1).optional(),
|
|
2251
|
+
keystoneHistoryFraction: z.number().min(0).max(1).optional(),
|
|
2252
|
+
keystoneMaxMessages: z.number().int().nonnegative().optional(),
|
|
2253
|
+
keystoneMinSignificance: z.number().min(0).max(1).optional(),
|
|
2254
|
+
}).optional(),
|
|
2255
|
+
/** Image/tool eviction settings */
|
|
2256
|
+
eviction: z.object({
|
|
2257
|
+
enabled: z.boolean().optional(),
|
|
2258
|
+
imageAgeTurns: z.number().int().nonnegative().optional(),
|
|
2259
|
+
toolResultAgeTurns: z.number().int().nonnegative().optional(),
|
|
2260
|
+
minTokensToEvict: z.number().int().nonnegative().optional(),
|
|
2261
|
+
keepPreviewChars: z.number().int().nonnegative().optional(),
|
|
2262
|
+
}).optional(),
|
|
2263
|
+
/** Embedding provider config */
|
|
2264
|
+
embedding: z.object({
|
|
2265
|
+
provider: z.enum(['ollama', 'openai', 'gemini']).optional(),
|
|
2266
|
+
ollamaUrl: z.string().optional(),
|
|
2267
|
+
openaiApiKey: z.string().optional(),
|
|
2268
|
+
openaiBaseUrl: z.string().optional(),
|
|
2269
|
+
geminiBaseUrl: z.string().optional(),
|
|
2270
|
+
geminiIndexTaskType: z.string().optional(),
|
|
2271
|
+
geminiQueryTaskType: z.string().optional(),
|
|
2272
|
+
model: z.string().optional(),
|
|
2273
|
+
dimensions: z.number().int().positive().optional(),
|
|
2274
|
+
timeout: z.number().int().positive().optional(),
|
|
2275
|
+
batchSize: z.number().int().positive().optional(),
|
|
2276
|
+
}).optional(),
|
|
2277
|
+
});
|
|
2031
2278
|
// ─── Plugin Entry ───────────────────────────────────────────────
|
|
2032
2279
|
const engine = createHyperMemEngine();
|
|
2033
2280
|
export default definePluginEntry({
|
|
@@ -2035,8 +2282,28 @@ export default definePluginEntry({
|
|
|
2035
2282
|
name: 'HyperCompositor — context engine',
|
|
2036
2283
|
description: 'Four-layer memory architecture for OpenClaw agents: Redis hot cache, message history, vector search, and structured library.',
|
|
2037
2284
|
kind: 'context-engine',
|
|
2038
|
-
configSchema:
|
|
2285
|
+
configSchema: buildPluginConfigSchema(hypercompositorConfigSchema),
|
|
2039
2286
|
register(api) {
|
|
2287
|
+
// ── Resolve plugin config from openclaw.json ──
|
|
2288
|
+
const pluginCfg = (api.pluginConfig ?? {});
|
|
2289
|
+
_pluginConfig = pluginCfg;
|
|
2290
|
+
// ── Resolve HYPERMEM_PATH: pluginConfig > npm resolve > dev fallback ──
|
|
2291
|
+
if (pluginCfg.hyperMemPath) {
|
|
2292
|
+
HYPERMEM_PATH = pluginCfg.hyperMemPath;
|
|
2293
|
+
console.log(`[hypermem-plugin] Using configured hyperMemPath: ${HYPERMEM_PATH}`);
|
|
2294
|
+
}
|
|
2295
|
+
else {
|
|
2296
|
+
try {
|
|
2297
|
+
HYPERMEM_PATH = require.resolve('@psiclawops/hypermem');
|
|
2298
|
+
console.log(`[hypermem-plugin] Resolved @psiclawops/hypermem from node_modules: ${HYPERMEM_PATH}`);
|
|
2299
|
+
}
|
|
2300
|
+
catch {
|
|
2301
|
+
// Dev fallback: resolve relative to plugin directory
|
|
2302
|
+
const __pluginDir = path.dirname(fileURLToPath(import.meta.url));
|
|
2303
|
+
HYPERMEM_PATH = path.resolve(__pluginDir, '../../dist/index.js');
|
|
2304
|
+
console.log(`[hypermem-plugin] Falling back to dev path: ${HYPERMEM_PATH}`);
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2040
2307
|
api.registerContextEngine('hypercompositor', () => engine);
|
|
2041
2308
|
// P1.7: Bind TaskFlow runtime for task visibility — best-effort.
|
|
2042
2309
|
// Guard: api.runtime.taskFlow may not exist on older OpenClaw versions.
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@psiclawops/hypercompositor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "HyperCompositor — context engine plugin for OpenClaw",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -23,14 +23,22 @@
|
|
|
23
23
|
},
|
|
24
24
|
"extensions": [
|
|
25
25
|
"./dist/index.js"
|
|
26
|
-
]
|
|
26
|
+
],
|
|
27
|
+
"compat": {
|
|
28
|
+
"pluginApi": ">=2026.4.12",
|
|
29
|
+
"minGatewayVersion": "2026.4.12"
|
|
30
|
+
},
|
|
31
|
+
"build": {
|
|
32
|
+
"openclawVersion": "2026.4.9",
|
|
33
|
+
"pluginSdkVersion": "2026.4.12"
|
|
34
|
+
}
|
|
27
35
|
},
|
|
28
36
|
"scripts": {
|
|
29
37
|
"build": "tsc",
|
|
30
38
|
"typecheck": "tsc --noEmit"
|
|
31
39
|
},
|
|
32
40
|
"dependencies": {
|
|
33
|
-
"@psiclawops/hypermem": "^0.
|
|
41
|
+
"@psiclawops/hypermem": "^0.6.3"
|
|
34
42
|
},
|
|
35
43
|
"devDependencies": {
|
|
36
44
|
"openclaw": "*",
|