@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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,KAAK,EACV,cAAc,EACd,eAAe,EACf,iBAAiB,EACjB,cAAc,EACd,aAAa,EAId,MAAM,sBAAsB,CAAC;AAU9B,YAAY,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,cAAc,EAAE,aAAa,EAAE,CAAC;AAsrElG;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAU1F;;;;;;;;AAMD,wBAwBG"}
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, emptyPluginConfigSchema } from 'openclaw/plugin-sdk/plugin-entry';
23
- import { detectTopicShift, stripMessageMetadata, SessionTopicMap, applyToolGradientToWindow, canPersistReshapedHistory } from '@psiclawops/hypermem';
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
- const HYPERMEM_PATH = path.join(os.homedir(), '.openclaw/workspace/repo/hypermem/dist/index.js');
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 optional user config from ~/.openclaw/hypermem/config.json.
113
- * Supports overriding compositor tuning knobs without editing plugin source.
114
- * Unknown keys are ignored. Missing file is silently skipped.
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
- const configPath = path.join(os.homedir(), '.openclaw/hypermem/config.json');
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
- const parsed = JSON.parse(raw);
121
- console.log(`[hypermem-plugin] Loaded user config from ${configPath}`);
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
- // Apply provider-specific model + dimension defaults when not explicitly set
159
- model: ue.model ?? (ue.provider === 'openai' ? 'text-embedding-3-small' : 'nomic-embed-text'),
160
- dimensions: ue.dimensions ?? (ue.provider === 'openai' ? 1536 : 768),
161
- timeout: ue.timeout ?? 10000,
162
- batchSize: ue.batchSize ?? (ue.provider === 'openai' ? 128 : 32),
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.5.3',
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 pre-flight: proactively truncate the JSONL on disk if it is over
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 block (SOUL.md + IDENTITY.md + MOTIVATIONS.md)
750
- // and pass into warm() so the compositor identity slot is populated.
751
- // Previously opts.identity was always undefined the slot was allocated
752
- // but always empty. Non-fatal: missing files are silently skipped.
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
- ...tr,
901
- content: `[tool result omitted by wave-guard at ${(redisPressure * 100).toFixed(0)}% Redis pressure]`,
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: emptyPluginConfigSchema(),
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.
@@ -2,6 +2,9 @@
2
2
  "id": "hypercompositor",
3
3
  "enabledByDefault": false,
4
4
  "kind": "context-engine",
5
+ "activation": {
6
+ "onCapabilities": ["context-engine"]
7
+ },
5
8
  "configSchema": {
6
9
  "type": "object",
7
10
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@psiclawops/hypercompositor",
3
- "version": "0.5.4",
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.5.2"
41
+ "@psiclawops/hypermem": "^0.6.3"
34
42
  },
35
43
  "devDependencies": {
36
44
  "openclaw": "*",