kongbrain 0.3.16 → 0.4.1

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.
@@ -33,14 +33,34 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
33
33
  // Measure assistant text output (used for token estimation and planning gate)
34
34
  const textLen = event.assistantTexts.reduce((s, t) => s + t.length, 0);
35
35
 
36
- // Extract token counts — fall back to text-length estimate when provider
37
- // doesn't report usage (OpenClaw often passes 0 or undefined)
38
- let inputTokens = event.usage?.input ?? 0;
39
- let outputTokens = event.usage?.output ?? 0;
40
- if (inputTokens + outputTokens === 0 && textLen > 0) {
41
- outputTokens = Math.ceil(textLen / 4); // ~4 chars per token
36
+ // Extract token counts — OpenClaw's getUsageTotals() returns CUMULATIVE totals
37
+ // across all API calls in the session, not per-response values.
38
+ // Compute the delta since last call to avoid quadratic overcounting.
39
+ const reportedInput = event.usage?.input ?? 0;
40
+ const reportedOutput = event.usage?.output ?? 0;
41
+ const reportedCacheRead = event.usage?.cacheRead ?? 0;
42
+ const reportedCacheWrite = event.usage?.cacheWrite ?? 0;
43
+ const reportedTotal = reportedInput + reportedOutput + reportedCacheRead + reportedCacheWrite;
44
+
45
+ let deltaTokens: number;
46
+ if (reportedTotal > 0) {
47
+ deltaTokens = Math.max(0, reportedTotal - session.lastSeenUsageTotal);
48
+ session.lastSeenUsageTotal = reportedTotal;
49
+ } else if (textLen > 0) {
50
+ // No usage data — fall back to text-length estimate
51
+ deltaTokens = Math.ceil(textLen / 4); // ~4 chars per token
52
+ } else {
53
+ deltaTokens = 0;
42
54
  }
43
55
 
56
+ // DB stats: approximate input/output split from the delta
57
+ const inputTokens = reportedTotal > 0 && deltaTokens > 0
58
+ ? Math.round(deltaTokens * (reportedInput / reportedTotal))
59
+ : 0;
60
+ const outputTokens = reportedTotal > 0 && deltaTokens > 0
61
+ ? Math.round(deltaTokens * (reportedOutput / reportedTotal))
62
+ : (deltaTokens > 0 ? deltaTokens : Math.ceil(textLen / 4));
63
+
44
64
  // Always update session stats — turn_count must increment even without usage data
45
65
  if (session.surrealSessionId) {
46
66
  try {
@@ -55,8 +75,8 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
55
75
  }
56
76
 
57
77
  // Accumulate for daemon batching and mid-session cleanup
58
- session.newContentTokens += inputTokens + outputTokens;
59
- session.cumulativeTokens += inputTokens + outputTokens;
78
+ session.newContentTokens += deltaTokens;
79
+ session.cumulativeTokens += deltaTokens;
60
80
 
61
81
  // Track accumulated text output for planning gate
62
82
  session.turnTextLength += textLen;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * subagent_spawned / subagent_ended hooks — track spawned subagents in the graph.
3
+ *
4
+ * Creates `subagent` records and `spawned` edges (session → subagent).
5
+ * Updates subagent records with outcome on completion.
6
+ */
7
+
8
+ import type { GlobalPluginState } from "../state.js";
9
+ import { swallow } from "../errors.js";
10
+
11
+ // ── Event shapes (from OpenClaw gateway) ─────────────────────────────────
12
+
13
+ interface SubagentSpawnedEvent {
14
+ runId: string;
15
+ childSessionKey: string;
16
+ agentId?: string;
17
+ label?: string;
18
+ requester?: {
19
+ channel?: string;
20
+ accountId?: string;
21
+ to?: string;
22
+ threadId?: string;
23
+ };
24
+ threadRequested?: boolean;
25
+ mode?: string; // "run" | "session"
26
+ }
27
+
28
+ interface SubagentSpawnedContext {
29
+ runId: string;
30
+ childSessionKey: string;
31
+ requesterSessionKey?: string;
32
+ }
33
+
34
+ interface SubagentEndedEvent {
35
+ targetSessionKey: string;
36
+ targetKind?: string;
37
+ reason?: string;
38
+ sendFarewell?: boolean;
39
+ accountId?: string;
40
+ runId: string;
41
+ endedAt?: string;
42
+ outcome?: string;
43
+ error?: string;
44
+ }
45
+
46
+ interface SubagentEndedContext {
47
+ runId: string;
48
+ childSessionKey: string;
49
+ requesterSessionKey?: string;
50
+ }
51
+
52
+ // ── Handlers ─────────────────────────────────────────────────────────────
53
+
54
+ export function createSubagentSpawnedHandler(state: GlobalPluginState) {
55
+ return async (event: SubagentSpawnedEvent, ctx: SubagentSpawnedContext) => {
56
+ try {
57
+ const store = state.store;
58
+
59
+ // Create the subagent record
60
+ const rows = await store.queryFirst<{ id: string }>(
61
+ `CREATE subagent CONTENT {
62
+ run_id: $run_id,
63
+ parent_session_key: $parent_key,
64
+ child_session_key: $child_key,
65
+ parent_session_id: $parent_key,
66
+ child_session_id: $child_key,
67
+ agent_id: $agent_id,
68
+ label: $label,
69
+ mode: $mode,
70
+ task: $label,
71
+ status: "running",
72
+ created_at: time::now()
73
+ } RETURN id`,
74
+ {
75
+ run_id: event.runId,
76
+ parent_key: ctx.requesterSessionKey ?? "unknown",
77
+ child_key: event.childSessionKey,
78
+ agent_id: event.agentId ?? "default",
79
+ label: event.label ?? null,
80
+ mode: event.mode ?? "run",
81
+ },
82
+ );
83
+
84
+ const subagentId = String(rows[0]?.id ?? "");
85
+ if (!subagentId) return;
86
+
87
+ // Find the parent's surreal session ID to create the spawned edge.
88
+ // The requesterSessionKey is the OpenClaw session key — we need to
89
+ // find the matching surreal session record.
90
+ if (ctx.requesterSessionKey) {
91
+ // Look up active session state first (fast path)
92
+ const parentSession = state.getSession(ctx.requesterSessionKey);
93
+ if (parentSession?.surrealSessionId) {
94
+ await store.relate(parentSession.surrealSessionId, "spawned", subagentId);
95
+ } else {
96
+ // Fallback: find the most recent session record that's still active
97
+ const sessions = await store.queryFirst<{ id: string }>(
98
+ `SELECT id FROM session
99
+ WHERE ended_at IS NONE
100
+ ORDER BY started_at DESC LIMIT 1`,
101
+ );
102
+ if (sessions.length > 0) {
103
+ await store.relate(String(sessions[0].id), "spawned", subagentId);
104
+ }
105
+ }
106
+ }
107
+ } catch (e) {
108
+ swallow.warn("hook:subagentSpawned", e);
109
+ }
110
+ };
111
+ }
112
+
113
+ export function createSubagentEndedHandler(state: GlobalPluginState) {
114
+ return async (event: SubagentEndedEvent, ctx: SubagentEndedContext) => {
115
+ try {
116
+ const store = state.store;
117
+
118
+ // Update the subagent record by run_id
119
+ await store.queryExec(
120
+ `UPDATE subagent SET
121
+ status = $status,
122
+ outcome = $outcome,
123
+ error = $error,
124
+ reason = $reason,
125
+ ended_at = $ended_at
126
+ WHERE run_id = $run_id`,
127
+ {
128
+ run_id: event.runId,
129
+ status: event.outcome === "success" ? "completed"
130
+ : event.reason === "spawn-failed" ? "error"
131
+ : event.outcome ?? "completed",
132
+ outcome: event.outcome ?? null,
133
+ error: event.error ?? null,
134
+ reason: event.reason ?? null,
135
+ ended_at: event.endedAt ?? new Date().toISOString(),
136
+ },
137
+ );
138
+ } catch (e) {
139
+ swallow.warn("hook:subagentEnded", e);
140
+ }
141
+ };
142
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import { createBeforePromptBuildHandler } from "./hooks/before-prompt-build.js";
20
20
  import { createBeforeToolCallHandler } from "./hooks/before-tool-call.js";
21
21
  import { createAfterToolCallHandler } from "./hooks/after-tool-call.js";
22
22
  import { createLlmOutputHandler } from "./hooks/llm-output.js";
23
+ import { createSubagentSpawnedHandler, createSubagentEndedHandler } from "./hooks/subagent-lifecycle.js";
23
24
  import { startMemoryDaemon } from "./daemon-manager.js";
24
25
  import { seedIdentity } from "./identity.js";
25
26
  import { seedCognitiveBootstrap } from "./cognitive-bootstrap.js";
@@ -146,11 +147,18 @@ async function runSessionCleanup(
146
147
  new Promise(resolve => setTimeout(resolve, 150_000)),
147
148
  ]);
148
149
 
150
+ // Await the graduation promise once and reuse the result below
151
+ let gradResult: Awaited<typeof graduationPromise> = null;
152
+ try {
153
+ gradResult = await graduationPromise;
154
+ } catch (e) {
155
+ swallow.warn("cleanup:graduationAwait", e);
156
+ }
157
+
149
158
  // If soul graduation just happened, persist a graduation event so the next
150
159
  // session can celebrate with the user. We also fire a system event for
151
160
  // immediate visibility if the session is still active.
152
161
  try {
153
- const gradResult = await graduationPromise;
154
162
  if (gradResult?.graduated && gradResult.soul) {
155
163
  // Check if this is a NEW graduation (not a pre-existing soul)
156
164
  const isNewGraduation = gradResult.report.stage === "ready";
@@ -188,7 +196,6 @@ async function runSessionCleanup(
188
196
  // Soul evolution — if soul already exists, check if it should be revised
189
197
  // based on new experience (runs every 10 sessions after last revision)
190
198
  try {
191
- const gradResult = await graduationPromise;
192
199
  if (gradResult?.graduated && gradResult.report.stage !== "ready") {
193
200
  // Pre-existing soul — check for evolution
194
201
  await evolveSoul(s, complete);
@@ -421,6 +428,8 @@ export default definePluginEntry({
421
428
  api.on("before_tool_call", createBeforeToolCallHandler(globalState));
422
429
  api.on("after_tool_call", createAfterToolCallHandler(globalState));
423
430
  api.on("llm_output", createLlmOutputHandler(globalState));
431
+ api.on("subagent_spawned", createSubagentSpawnedHandler(globalState));
432
+ api.on("subagent_ended", createSubagentEndedHandler(globalState));
424
433
  }
425
434
 
426
435
  // ── Session lifecycle (also register once) ─────────────────────────
@@ -192,7 +192,7 @@ export async function preflight(
192
192
 
193
193
  // Non-first-turn short inputs → continuation
194
194
  if (orch.turnIndex > 1 && input.length < 20 && !input.includes("?")) {
195
- const inheritedLimit = Math.max(orch.lastConfig.toolLimit, 25);
195
+ const inheritedLimit = Math.min(orch.lastConfig.toolLimit, 25);
196
196
  const config: AdaptiveConfig = {
197
197
  ...orch.lastConfig, toolLimit: inheritedLimit, skipRetrieval: true,
198
198
  vectorSearchLimits: { turn: 0, identity: 0, concept: 0, memory: 0, artifact: 0 },
package/src/reflection.ts CHANGED
@@ -207,6 +207,7 @@ export async function generateReflection(
207
207
  { record },
208
208
  );
209
209
  const reflectionId = String(rows[0]?.id ?? "");
210
+ store.clearReflectionCache();
210
211
 
211
212
  if (reflectionId && surrealSessionId) {
212
213
  await store.relate(reflectionId, "reflects_on", surrealSessionId).catch(e => swallow.warn("reflection:relate", e));
package/src/soul.ts CHANGED
@@ -191,7 +191,7 @@ export async function getQualitySignals(store: SurrealStore): Promise<QualitySig
191
191
  const totalSuccess = Number(skillRow?.totalSuccess ?? 0);
192
192
  const totalFailure = Number(skillRow?.totalFailure ?? 0);
193
193
  const skillTotal = totalSuccess + totalFailure;
194
- const skillSuccessRate = skillTotal > 0 ? totalSuccess / skillTotal : 0;
194
+ const skillSuccessRate = skillTotal > 0 && Number.isFinite(skillTotal) ? totalSuccess / skillTotal : 0;
195
195
 
196
196
  const critCount = Number(critRow?.count ?? 0);
197
197
  const reflCount = Number(totalRow?.count ?? 0);
package/src/state.ts CHANGED
@@ -62,6 +62,9 @@ export class SessionState {
62
62
  cumulativeTokens = 0;
63
63
  lastCleanupTokens = 0;
64
64
  midSessionCleanupThreshold = 25_000;
65
+ /** Last cumulative usage total seen from OpenClaw — used to compute per-call deltas
66
+ * since getUsageTotals() returns running totals, not per-response values. */
67
+ lastSeenUsageTotal = 0;
65
68
 
66
69
  // Cleanup tracking
67
70
  cleanedUp = false;
@@ -72,6 +75,17 @@ export class SessionState {
72
75
  // Pending tool args for artifact tracking
73
76
  readonly pendingToolArgs = new Map<string, unknown>();
74
77
 
78
+ // Tool call optimization state (claw-code patterns)
79
+ /** Query vector from this turn's context retrieval — used to detect redundant recall calls. */
80
+ lastQueryVec: number[] | null = null;
81
+ /** Summary of what graphTransformContext injected — shown in planning gate. */
82
+ lastRetrievalSummary = "";
83
+ /** API request cycle counter — hard cap prevents runaway token spend. */
84
+ apiCycleCount = 0;
85
+ /** Tracks which static context sections the model has already seen in the conversation window.
86
+ * Persists across turns (NOT cleared in resetTurn) — cleared only when messages drop from window. */
87
+ readonly injectedSections = new Set<string>();
88
+
75
89
  // 5-pillar IDs (populated at bootstrap)
76
90
  agentId = "";
77
91
  projectId = "";
@@ -92,6 +106,10 @@ export class SessionState {
92
106
  this.softInterrupted = false;
93
107
  this.turnStartMs = Date.now();
94
108
  this.pendingThinking.length = 0;
109
+ this.lastRetrievalSummary = "";
110
+ this.apiCycleCount = 0;
111
+ // NOTE: lastQueryVec and injectedSections are NOT cleared here —
112
+ // they persist across turns within the session.
95
113
  }
96
114
  }
97
115
 
package/src/surreal.ts CHANGED
@@ -1408,6 +1408,10 @@ export class SurrealStore {
1408
1408
 
1409
1409
  private _reflectionSessions: Set<string> | null = null;
1410
1410
 
1411
+ clearReflectionCache(): void {
1412
+ this._reflectionSessions = null;
1413
+ }
1414
+
1411
1415
  async getReflectionSessionIds(): Promise<Set<string>> {
1412
1416
  if (this._reflectionSessions) return this._reflectionSessions;
1413
1417
  try {
@@ -46,7 +46,7 @@ export function createCoreMemoryToolDef(state: GlobalPluginState, session: Sessi
46
46
  }
47
47
  const formatted = entries.map((e, i) => {
48
48
  const sid = e.session_id ? ` session:${e.session_id}` : "";
49
- return `${i + 1}. [T${e.tier}/${e.category}/p${e.priority}${sid}] ${e.id}\n ${e.text.slice(0, 200)}`;
49
+ return `${i + 1}. [T${e.tier}/${e.category}/p${e.priority}${sid}] ${e.id}\n ${e.text.slice(0, 120)}`;
50
50
  }).join("\n\n");
51
51
  return {
52
52
  content: [{ type: "text" as const, text: `${entries.length} core memory entries:\n\n${formatted}` }],
@@ -73,6 +73,8 @@ export function createCoreMemoryToolDef(state: GlobalPluginState, session: Sessi
73
73
  details: { error: true },
74
74
  };
75
75
  }
76
+ // Invalidate cached section so updated content re-injects next turn
77
+ session.injectedSections.delete(tier === 0 ? "tier0" : "tier1");
76
78
  return {
77
79
  content: [{ type: "text" as const, text: `Created core memory: ${id} (tier ${tier}, ${params.category ?? "general"}, p${params.priority ?? 50})` }],
78
80
  details: { id },
@@ -95,6 +97,9 @@ export function createCoreMemoryToolDef(state: GlobalPluginState, session: Sessi
95
97
  details: { error: true },
96
98
  };
97
99
  }
100
+ // Invalidate both tiers — update may have changed the tier
101
+ session.injectedSections.delete("tier0");
102
+ session.injectedSections.delete("tier1");
98
103
  return {
99
104
  content: [{ type: "text" as const, text: `Updated core memory: ${params.id}` }],
100
105
  details: { id: params.id },
@@ -106,6 +111,9 @@ export function createCoreMemoryToolDef(state: GlobalPluginState, session: Sessi
106
111
  return { content: [{ type: "text" as const, text: "Error: 'id' is required for deactivate action." }], details: null };
107
112
  }
108
113
  await store.deleteCoreMemory(params.id);
114
+ // Invalidate both tiers so removal is reflected next turn
115
+ session.injectedSections.delete("tier0");
116
+ session.injectedSections.delete("tier1");
109
117
  return {
110
118
  content: [{ type: "text" as const, text: `Deactivated core memory: ${params.id}` }],
111
119
  details: { id: params.id },
@@ -34,7 +34,7 @@ export function createRecallToolDef(state: GlobalPluginState, session: SessionSt
34
34
  return { content: [{ type: "text" as const, text: "Memory system unavailable." }], details: null };
35
35
  }
36
36
 
37
- const maxResults = Math.min(params.limit ?? 5, 15);
37
+ const maxResults = Math.min(params.limit ?? 3, 15);
38
38
 
39
39
  try {
40
40
  const queryVec = await embeddings.embed(params.query);
@@ -63,7 +63,7 @@ export function createRecallToolDef(state: GlobalPluginState, session: SessionSt
63
63
 
64
64
  const topIds = results
65
65
  .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
66
- .slice(0, 5)
66
+ .slice(0, Math.min(maxResults, 8))
67
67
  .map((r) => r.id);
68
68
 
69
69
  let neighbors: VectorSearchResult[] = [];
@@ -87,7 +87,7 @@ export function createRecallToolDef(state: GlobalPluginState, session: SessionSt
87
87
  const tag = r.table === "turn" ? `[${r.role ?? "turn"}]` : `[${r.table}]`;
88
88
  const time = r.timestamp ? ` (${new Date(r.timestamp).toLocaleDateString()})` : "";
89
89
  const score = r.score ? ` score:${r.score.toFixed(2)}` : "";
90
- return `${i + 1}. ${tag}${time}${score}\n ${(r.text ?? "").slice(0, 500)}`;
90
+ return `${i + 1}. ${tag}${time}${score}\n ${(r.text ?? "").slice(0, 300)}`;
91
91
  }).join("\n\n");
92
92
 
93
93
  return {