kongbrain 0.4.1 → 0.4.2

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.
@@ -68,7 +68,8 @@ export function createAfterToolCallHandler(state: GlobalPluginState) {
68
68
 
69
69
  // Auto-track file artifacts from write/edit tools
70
70
  if (!isError) {
71
- await trackArtifact(event.toolName, event.params, session.taskId, session.projectId, state)
71
+ // Fire-and-forget: artifact tracking is best-effort enrichment, not critical path
72
+ trackArtifact(event.toolName, event.params, session.taskId, session.projectId, state)
72
73
  .catch(e => swallow.warn("hook:afterToolCall:artifact", e));
73
74
  }
74
75
 
@@ -10,7 +10,7 @@ import type { GlobalPluginState } from "../state.js";
10
10
  import { recordToolCall } from "../orchestrator.js";
11
11
  import { cosineSimilarity } from "../graph-context.js";
12
12
 
13
- const DEFAULT_TOOL_LIMIT = 10;
13
+ const DEFAULT_TOOL_LIMIT = 9;
14
14
  const CLASSIFICATION_LIMITS: Record<string, number> = { LOOKUP: 3, EDIT: 4, REFACTOR: 8 };
15
15
  const API_CYCLE_CAP = 16;
16
16
  const RECALL_SIMILARITY_THRESHOLD = 0.80;
@@ -59,7 +59,7 @@ export function createBeforeToolCallHandler(state: GlobalPluginState) {
59
59
  }
60
60
 
61
61
  // Tool limit
62
- if (session.toolCallCount > session.toolLimit) {
62
+ if (session.toolCallCount >= session.toolLimit) {
63
63
  return {
64
64
  block: true,
65
65
  blockReason: `Tool call limit reached (${session.toolLimit}). Stop calling tools. Continue exactly where you left off — deliver your answer from what you've gathered. Do NOT repeat anything you already said. State what's done and what remains.`,
@@ -100,22 +100,26 @@ export function createBeforeToolCallHandler(state: GlobalPluginState) {
100
100
  // Planning gate: model must output text before first tool call
101
101
  if (textLengthSoFar === 0 && toolIndex === 0) {
102
102
  const retrievalNote = session.lastRetrievalSummary
103
- ? `\nContext already injected: ${session.lastRetrievalSummary}. Read <graph_context> before calling tools.`
103
+ ? ` Context: ${session.lastRetrievalSummary}.`
104
104
  : "";
105
105
  return {
106
106
  block: true,
107
107
  blockReason:
108
- "PLANNING GATE You must announce your plan before making tool calls.\n" +
109
- "1. Classify: LOOKUP (3 calls max), EDIT (4 max), REFACTOR (8 max)\n" +
110
- "2. STATE WHAT YOU ALREADY KNOW from injected memory/context — if you have prior knowledge about these files, say so" +
111
- retrievalNote + "\n" +
112
- "3. List each planned call and what SPECIFIC GAP it fills that memory doesn't cover\n" +
113
- "4. Every step still happens, but COMBINED. Edit + test in one bash call, not two.\n" +
114
- "If injected context already answers the question, you may need ZERO tool calls.\n" +
115
- "Speak your plan, then proceed.",
108
+ "Plan before tools. Classify (LOOKUP/EDIT/REFACTOR), state what you know from <graph_context>," +
109
+ " list each call + what gap it fills. Combine steps. 0 calls if context answers it." +
110
+ retrievalNote,
116
111
  };
117
112
  }
118
113
 
114
+ // Inline classification: if text was emitted with a classification keyword,
115
+ // parse and apply the tool limit (even on first tool call after text)
116
+ if (toolIndex === 0 && textLengthSoFar > 0 && session.toolLimit === DEFAULT_TOOL_LIMIT) {
117
+ const parsed = parseClassificationFromText(session.lastAssistantText ?? "");
118
+ if (parsed !== null) {
119
+ session.toolLimit = parsed;
120
+ }
121
+ }
122
+
119
123
  return undefined;
120
124
  };
121
125
  }
@@ -61,16 +61,24 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
61
61
  ? Math.round(deltaTokens * (reportedOutput / reportedTotal))
62
62
  : (deltaTokens > 0 ? deltaTokens : Math.ceil(textLen / 4));
63
63
 
64
- // Always update session stats — turn_count must increment even without usage data
64
+ // Batch session stats writes accumulate in-memory, flush every 5th response
65
65
  if (session.surrealSessionId) {
66
- try {
67
- await state.store.updateSessionStats(
68
- session.surrealSessionId,
69
- inputTokens,
70
- outputTokens,
71
- );
72
- } catch (e) {
73
- swallow("hook:llmOutput:sessionStats", e);
66
+ session._pendingInputTokens = (session._pendingInputTokens ?? 0) + inputTokens;
67
+ session._pendingOutputTokens = (session._pendingOutputTokens ?? 0) + outputTokens;
68
+ session._statsFlushCounter = (session._statsFlushCounter ?? 0) + 1;
69
+ if (session._statsFlushCounter >= 5) {
70
+ try {
71
+ await state.store.updateSessionStats(
72
+ session.surrealSessionId,
73
+ session._pendingInputTokens,
74
+ session._pendingOutputTokens,
75
+ );
76
+ } catch (e) {
77
+ swallow("hook:llmOutput:sessionStats", e);
78
+ }
79
+ session._pendingInputTokens = 0;
80
+ session._pendingOutputTokens = 0;
81
+ session._statsFlushCounter = 0;
74
82
  }
75
83
  }
76
84
 
@@ -95,7 +103,7 @@ export function createLlmOutputHandler(state: GlobalPluginState) {
95
103
  }
96
104
 
97
105
  // Capture thinking blocks for monologue extraction
98
- const lastAssistant = event.lastAssistant as any;
106
+ const lastAssistant = event.lastAssistant as { content?: { type: string; thinking?: string; text?: string }[] } | undefined;
99
107
  if (lastAssistant?.content && Array.isArray(lastAssistant.content)) {
100
108
  for (const block of lastAssistant.content) {
101
109
  if (block.type === "thinking") {
package/src/index.ts CHANGED
@@ -33,6 +33,7 @@ import { hasMigratableFiles, migrateWorkspace } from "./workspace-migrate.js";
33
33
  import { writeHandoffFileSync } from "./handoff-file.js";
34
34
  import { runDeferredCleanup } from "./deferred-cleanup.js";
35
35
  import { swallow } from "./errors.js";
36
+ import { log } from "./log.js";
36
37
 
37
38
  // Use process-global symbols so state survives Jiti re-importing the module.
38
39
  // Jiti may load this file multiple times (fresh module scope each time),
@@ -40,17 +41,20 @@ import { swallow } from "./errors.js";
40
41
  const GLOBAL_KEY = Symbol.for("kongbrain.globalState");
41
42
  const REGISTERED_KEY = Symbol.for("kongbrain.registered");
42
43
 
44
+ // Typed accessor for process-global symbol keys on globalThis
45
+ const _g = globalThis as Record<symbol, unknown>;
46
+
43
47
  function getGlobalState(): GlobalPluginState | null {
44
- return (globalThis as any)[GLOBAL_KEY] ?? null;
48
+ return (_g[GLOBAL_KEY] as GlobalPluginState) ?? null;
45
49
  }
46
50
  function setGlobalState(state: GlobalPluginState): void {
47
- (globalThis as any)[GLOBAL_KEY] = state;
51
+ _g[GLOBAL_KEY] = state;
48
52
  }
49
53
  function isRegistered(): boolean {
50
- return (globalThis as any)[REGISTERED_KEY] === true;
54
+ return _g[REGISTERED_KEY] === true;
51
55
  }
52
56
  function markRegistered(): void {
53
- (globalThis as any)[REGISTERED_KEY] = true;
57
+ _g[REGISTERED_KEY] = true;
54
58
  }
55
59
 
56
60
  let shutdownPromise: Promise<void> | null = null;
@@ -107,7 +111,7 @@ async function runSessionCleanup(
107
111
  const turnData = recentTurns.map(t => ({
108
112
  role: t.role as "user" | "assistant",
109
113
  text: t.text,
110
- turnId: (t as any).id,
114
+ turnId: (t as { id?: string }).id,
111
115
  }));
112
116
  session.daemon!.sendTurnBatch(turnData, [...session.pendingThinking], []);
113
117
  } catch (e) { swallow.warn("cleanup:finalDaemonFlush", e); }
@@ -278,7 +282,7 @@ async function detectGraduationEvent(
278
282
  }
279
283
 
280
284
  // Flag the session for context engine injection
281
- (session as any)._graduationCelebration = {
285
+ session._graduationCelebration = {
282
286
  qualityScore: event.quality_score,
283
287
  volumeScore: event.volume_score,
284
288
  soulSummary,
@@ -367,12 +371,13 @@ export default definePluginEntry({
367
371
  // Pass apiKey directly in options so the provider can use it
368
372
  const response = await piAi!.completeSimple(model, context, {
369
373
  apiKey: auth.apiKey,
374
+ ...(params.outputFormat && { outputFormat: params.outputFormat }),
370
375
  });
371
376
  let text = "";
372
377
  let thinking: string | undefined;
373
378
  for (const block of response.content) {
374
379
  if (block.type === "text") text += block.text;
375
- else if ((block as any).type === "thinking") thinking = (thinking ?? "") + (block as any).thinking;
380
+ else if ((block as { type: string; thinking?: string }).type === "thinking") thinking = (thinking ?? "") + (block as { type: string; thinking?: string }).thinking;
376
381
  }
377
382
  return { text, thinking, usage: { input: response.usage.input, output: response.usage.output } };
378
383
  };
@@ -478,7 +483,7 @@ export default definePluginEntry({
478
483
  hasMigratableFiles(globalState!.workspaceDir)
479
484
  .then(hasMigratable => {
480
485
  if (hasMigratable) {
481
- (session as any)._hasMigratableFiles = true;
486
+ session._hasMigratableFiles = true;
482
487
  }
483
488
  })
484
489
  .catch(e => swallow.warn("index:migrationCheck", e));
@@ -492,7 +497,7 @@ export default definePluginEntry({
492
497
  .catch(e => swallow("index:graduationDetect", e));
493
498
 
494
499
  // Synthesize wakeup briefing — store the promise so assemble() can await it
495
- (session as any)._wakeupPromise = synthesizeWakeup(
500
+ session._wakeupPromise = synthesizeWakeup(
496
501
  globalState!.store, globalState!.complete, session.sessionId, globalState!.workspaceDir,
497
502
  ).catch(e => { swallow.warn("index:wakeup", e); return null; });
498
503
 
@@ -541,7 +546,7 @@ export default definePluginEntry({
541
546
  const syncExitHandler = () => {
542
547
  const gs = getGlobalState();
543
548
  if (!gs?.workspaceDir) return;
544
- const sessions = [...(gs as any).sessions.values()] as import("./state.js").SessionState[];
549
+ const sessions = gs.allSessions();
545
550
  for (const session of sessions) {
546
551
  if (session.cleanedUp) continue;
547
552
  writeHandoffFileSync({
@@ -559,14 +564,14 @@ export default definePluginEntry({
559
564
  const asyncExitHandler = () => {
560
565
  const gs = getGlobalState();
561
566
  if (!gs) return;
562
- const sessions = [...(gs as any).sessions.values()] as import("./state.js").SessionState[];
567
+ const sessions = gs.allSessions();
563
568
  if (sessions.length === 0 && !shutdownPromise) return;
564
569
 
565
570
  const cleanups = sessions.map(s => runSessionCleanup(s, gs));
566
571
  if (shutdownPromise) cleanups.push(shutdownPromise);
567
572
 
568
573
  const done = Promise.allSettled(cleanups).then(() => {
569
- gs.shutdown().catch(() => {});
574
+ gs.shutdown().catch(e => log.error("shutdown error:", e));
570
575
  });
571
576
 
572
577
  done.then(() => process.exit(0)).catch(() => process.exit(1));
package/src/intent.ts CHANGED
@@ -92,20 +92,21 @@ async function ensurePrototypes(embeddings: EmbeddingService): Promise<{ categor
92
92
  let promise = centroidInitPromise.get(embeddings);
93
93
  if (!promise) {
94
94
  promise = (async () => {
95
+ const vecs = await embeddings.embedBatch(PROTOTYPES.map(p => p.text));
95
96
  const byCategory = new Map<IntentCategory, number[][]>();
96
- for (const proto of PROTOTYPES) {
97
- const vec = await embeddings.embed(proto.text);
98
- if (!byCategory.has(proto.category)) byCategory.set(proto.category, []);
99
- byCategory.get(proto.category)!.push(vec);
97
+ for (let i = 0; i < PROTOTYPES.length; i++) {
98
+ const cat = PROTOTYPES[i].category;
99
+ if (!byCategory.has(cat)) byCategory.set(cat, []);
100
+ byCategory.get(cat)!.push(vecs[i]);
100
101
  }
101
102
  const centroids: { category: IntentCategory; vec: number[] }[] = [];
102
- for (const [category, vecs] of byCategory) {
103
- const dim = vecs[0].length;
103
+ for (const [category, catVecs] of byCategory) {
104
+ const dim = catVecs[0].length;
104
105
  const centroid = new Array(dim).fill(0);
105
- for (const v of vecs) {
106
+ for (const v of catVecs) {
106
107
  for (let d = 0; d < dim; d++) centroid[d] += v[d];
107
108
  }
108
- for (let d = 0; d < dim; d++) centroid[d] /= vecs.length;
109
+ for (let d = 0; d < dim; d++) centroid[d] /= catVecs.length;
109
110
  centroids.push({ category, vec: centroid });
110
111
  }
111
112
  centroidCache.set(embeddings, centroids);
package/src/log.ts ADDED
@@ -0,0 +1,11 @@
1
+ const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 } as const;
2
+ type Level = keyof typeof LEVELS;
3
+
4
+ const currentLevel: Level = (process.env.KONGBRAIN_LOG_LEVEL as Level) ?? "warn";
5
+
6
+ export const log = {
7
+ error: (...args: unknown[]) => { if (LEVELS[currentLevel] >= LEVELS.error) console.error("[kongbrain]", ...args); },
8
+ warn: (...args: unknown[]) => { if (LEVELS[currentLevel] >= LEVELS.warn) console.warn("[kongbrain]", ...args); },
9
+ info: (...args: unknown[]) => { if (LEVELS[currentLevel] >= LEVELS.info) console.info("[kongbrain]", ...args); },
10
+ debug: (...args: unknown[]) => { if (LEVELS[currentLevel] >= LEVELS.debug) console.debug("[kongbrain]", ...args); },
11
+ };
@@ -141,6 +141,8 @@ interface OrchestratorSessionState {
141
141
  turnIndex: number;
142
142
  currentTurnTools: { name: string; args?: string }[];
143
143
  steeringCandidates: SteeringCandidate[];
144
+ cachedUtilAvg: number | null;
145
+ utilAvgTurn: number;
144
146
  }
145
147
 
146
148
  const sessionOrchState = new WeakMap<SessionState, OrchestratorSessionState>();
@@ -153,6 +155,8 @@ function getOrchState(session: SessionState): OrchestratorSessionState {
153
155
  turnIndex: 0,
154
156
  currentTurnTools: [],
155
157
  steeringCandidates: [],
158
+ cachedUtilAvg: null,
159
+ utilAvgTurn: 0,
156
160
  };
157
161
  sessionOrchState.set(session, state);
158
162
  }
@@ -252,11 +256,14 @@ export async function preflight(
252
256
  config.toolLimit = Math.min(complexity.estimatedToolCalls, Math.ceil(config.toolLimit * 1.5), 20);
253
257
  }
254
258
 
255
- // Adaptive token budget from rolling retrieval quality
259
+ // Adaptive token budget from rolling retrieval quality (cached, refreshed every 10 turns)
256
260
  if (!config.skipRetrieval) {
257
- const recentUtil = await getRecentUtilizationAvg(session.sessionId, 10).catch(() => null);
258
- if (recentUtil !== null) {
259
- const scale = Math.max(0.5, Math.min(1.3, 0.5 + recentUtil * 0.8));
261
+ if (orch.cachedUtilAvg === null || orch.turnIndex - orch.utilAvgTurn >= 10) {
262
+ orch.cachedUtilAvg = await getRecentUtilizationAvg(session.sessionId, 10).catch(() => null);
263
+ orch.utilAvgTurn = orch.turnIndex;
264
+ }
265
+ if (orch.cachedUtilAvg !== null) {
266
+ const scale = Math.max(0.5, Math.min(1.3, 0.5 + orch.cachedUtilAvg * 0.8));
260
267
  config.tokenBudget = Math.round(config.tokenBudget * scale);
261
268
  }
262
269
  }
package/src/prefetch.ts CHANGED
@@ -115,7 +115,7 @@ export async function prefetchContext(
115
115
 
116
116
  evictStale();
117
117
 
118
- for (const query of queries) {
118
+ await Promise.all(queries.map(async (query) => {
119
119
  try {
120
120
  const queryVec = await embeddings.embed(query);
121
121
 
@@ -152,7 +152,7 @@ export async function prefetchContext(
152
152
  } catch (e) {
153
153
  swallow("prefetch:query", e);
154
154
  }
155
- }
155
+ }));
156
156
  }
157
157
 
158
158
  // --- Cache Lookup ---
package/src/reflection.ts CHANGED
@@ -143,8 +143,15 @@ export async function generateReflection(
143
143
  ): Promise<void> {
144
144
  if (!store.isAvailable()) return;
145
145
 
146
+ // Gate: only reflect if session metrics warrant it
147
+ const metrics = await gatherSessionMetrics(sessionId, store);
148
+ if (metrics) {
149
+ const { reflect } = shouldReflect(metrics);
150
+ if (!reflect) return;
151
+ }
152
+
146
153
  // Get session turns directly — no dependency on orchestrator_metrics
147
- const turns = await store.getSessionTurns(sessionId, 30).catch(() => []);
154
+ const turns = await store.getSessionTurns(sessionId, 15).catch(() => []);
148
155
  if (turns.length < 3) return; // Too short for meaningful reflection
149
156
 
150
157
  const transcript = turns
@@ -183,7 +190,7 @@ export async function generateReflection(
183
190
  { vec: reflEmb },
184
191
  );
185
192
  const top = existing[0];
186
- if (top && (top.score ?? 0) > 0.85) {
193
+ if (top && typeof top.score === "number" && top.score > 0.85) {
187
194
  const newImportance = Math.min(10, (top.importance ?? 7) + 0.5);
188
195
  await store.queryFirst<any>(
189
196
  `UPDATE $id SET importance = $imp, updated_at = time::now()`,
package/src/schema.surql CHANGED
@@ -42,6 +42,7 @@ DEFINE FIELD IF NOT EXISTS embedding ON artifact TYPE option<array<float>>;
42
42
  DEFINE FIELD IF NOT EXISTS tags ON artifact TYPE option<array>;
43
43
  DEFINE FIELD IF NOT EXISTS created_at ON artifact TYPE datetime DEFAULT time::now();
44
44
  DEFINE INDEX IF NOT EXISTS artifact_vec_idx ON artifact FIELDS embedding HNSW DIMENSION 1024 DIST COSINE;
45
+ DEFINE INDEX IF NOT EXISTS artifact_type_idx ON artifact FIELDS type;
45
46
 
46
47
  -- ============================================================
47
48
  -- PILLAR 5: Concept (semantic knowledge nodes)
@@ -78,6 +79,7 @@ DEFINE FIELD IF NOT EXISTS model ON turn TYPE option<string>;
78
79
  DEFINE FIELD IF NOT EXISTS usage ON turn TYPE option<object>;
79
80
  DEFINE INDEX IF NOT EXISTS turn_vec_idx ON turn FIELDS embedding HNSW DIMENSION 1024 DIST COSINE;
80
81
  DEFINE INDEX IF NOT EXISTS turn_session_idx ON turn FIELDS session_id;
82
+ DEFINE INDEX IF NOT EXISTS turn_tool_name_idx ON turn FIELDS tool_name;
81
83
 
82
84
  -- Identity chunks (agent persona / identity)
83
85
  DEFINE TABLE IF NOT EXISTS identity_chunk SCHEMALESS;
@@ -115,6 +117,7 @@ DEFINE FIELD IF NOT EXISTS status ON memory TYPE option<string> DEFAULT "active"
115
117
  DEFINE FIELD IF NOT EXISTS resolved_at ON memory TYPE option<datetime>;
116
118
  DEFINE FIELD IF NOT EXISTS resolved_by ON memory TYPE option<string>;
117
119
  DEFINE INDEX IF NOT EXISTS memory_vec_idx ON memory FIELDS embedding HNSW DIMENSION 1024 DIST COSINE;
120
+ DEFINE INDEX IF NOT EXISTS memory_category_idx ON memory FIELDS category;
118
121
 
119
122
  -- ============================================================
120
123
  -- GRAPH EDGES: Turn-level
@@ -260,6 +263,7 @@ DEFINE FIELD IF NOT EXISTS avg_duration_ms ON skill TYPE float DEFAULT 0.0;
260
263
  DEFINE FIELD IF NOT EXISTS last_used ON skill TYPE option<datetime>;
261
264
  DEFINE FIELD IF NOT EXISTS created_at ON skill TYPE datetime DEFAULT time::now();
262
265
  DEFINE INDEX IF NOT EXISTS skill_vec_idx ON skill FIELDS embedding HNSW DIMENSION 1024 DIST COSINE;
266
+ DEFINE INDEX IF NOT EXISTS skill_active_idx ON skill FIELDS active;
263
267
 
264
268
  DEFINE TABLE IF NOT EXISTS skill_from_task TYPE RELATION IN skill OUT task;
265
269
  DEFINE TABLE IF NOT EXISTS skill_uses_concept TYPE RELATION IN skill OUT concept;
package/src/skills.ts CHANGED
@@ -16,6 +16,20 @@ import { swallow } from "./errors.js";
16
16
  import { linkToRelevantConcepts } from "./concept-extract.js";
17
17
  import { assertRecordId } from "./surreal.js";
18
18
 
19
+ // --- Shared schema for structured output ---
20
+
21
+ const skillSchema = {
22
+ type: "object" as const,
23
+ properties: {
24
+ name: { type: "string" },
25
+ description: { type: "string" },
26
+ preconditions: { type: "string" },
27
+ steps: { type: "array", items: { type: "object", properties: { tool: { type: "string" }, description: { type: "string" } } } },
28
+ postconditions: { type: "string" },
29
+ },
30
+ required: ["name", "description", "steps"],
31
+ };
32
+
19
33
  // --- Types ---
20
34
 
21
35
  export interface SkillStep {
@@ -71,21 +85,27 @@ export async function extractSkill(
71
85
 
72
86
  try {
73
87
  const response = await complete({
74
- system: `Return JSON or null. Fields: {name, description, preconditions, steps: [{tool, description}] (max 8), postconditions}. Generic patterns only (no specific paths). null if no clear multi-step workflow.`,
88
+ system: `Extract a reusable skill procedure. Generic patterns only (no specific paths). Return null if no clear multi-step workflow.`,
75
89
  messages: [{
76
90
  role: "user",
77
91
  content: `${turns.length} turns:\n${transcript.slice(0, 20000)}`,
78
92
  }],
93
+ outputFormat: { type: "json_schema", schema: skillSchema },
79
94
  });
80
95
 
81
96
  const text = response.text;
82
97
 
83
98
  if (text.trim() === "null" || text.trim() === "None") return null;
84
99
 
85
- const jsonMatch = text.match(/\{[\s\S]*?\}/);
86
- if (!jsonMatch) return null;
87
-
88
- const parsed = JSON.parse(jsonMatch[0]) as ExtractedSkill;
100
+ // Try direct JSON.parse first (structured output), fall back to regex extraction
101
+ let parsed: ExtractedSkill;
102
+ try {
103
+ parsed = JSON.parse(text);
104
+ } catch {
105
+ const jsonMatch = text.match(/\{[\s\S]*\}/); // greedy — handles nested objects
106
+ if (!jsonMatch) return null;
107
+ parsed = JSON.parse(jsonMatch[0]) as ExtractedSkill;
108
+ }
89
109
  if (!parsed.name || !parsed.description || !Array.isArray(parsed.steps) || parsed.steps.length === 0) {
90
110
  return null;
91
111
  }
@@ -290,19 +310,21 @@ export async function graduateCausalToSkills(
290
310
  if (existing.length > 0) continue;
291
311
 
292
312
  const resp = await complete({
293
- system: `Return JSON: {name, description, preconditions, steps: [{tool, description}] (max 6), postconditions}. Synthesize a reusable procedure from these recurring patterns. Generic — no specific file paths or variable names.`,
313
+ system: `Synthesize a reusable procedure from recurring patterns. Generic — no specific file paths or variable names.`,
294
314
  messages: [{
295
315
  role: "user",
296
316
  content: `${group.cnt} successful "${group.chain_type}" patterns:\n${group.descriptions.slice(0, 8).join("\n")}`,
297
317
  }],
318
+ outputFormat: { type: "json_schema", schema: skillSchema },
298
319
  });
299
320
 
300
321
  const text = resp.text;
301
- const jsonMatch = text.match(/\{[\s\S]*?\}/);
302
- if (!jsonMatch) continue;
303
-
304
322
  let parsed: ExtractedSkill;
305
- try { parsed = JSON.parse(jsonMatch[0]); } catch { continue; }
323
+ try { parsed = JSON.parse(text); } catch {
324
+ const jsonMatch = text.match(/\{[\s\S]*?\}/);
325
+ if (!jsonMatch) continue;
326
+ try { parsed = JSON.parse(jsonMatch[0]); } catch { continue; }
327
+ }
306
328
  if (!parsed.name || !Array.isArray(parsed.steps) || parsed.steps.length === 0) continue;
307
329
 
308
330
  let skillEmb: number[] | null = null;
package/src/soul.ts CHANGED
@@ -551,16 +551,32 @@ Output ONLY valid JSON:
551
551
  Be honest, not aspirational. Only claim what the data supports.`;
552
552
 
553
553
  try {
554
+ const soulSchema = {
555
+ type: "object" as const,
556
+ properties: {
557
+ working_style: { type: "array", items: { type: "string" } },
558
+ emotional_dimensions: { type: "array", items: { type: "object" } },
559
+ self_observations: { type: "array", items: { type: "string" } },
560
+ earned_values: { type: "array", items: { type: "object" } },
561
+ },
562
+ required: ["working_style", "emotional_dimensions", "self_observations", "earned_values"],
563
+ };
564
+
554
565
  const response = await complete({
555
566
  system: "You are introspecting on your own experience to write a self-assessment. Be genuine and grounded.",
556
567
  messages: [{
557
568
  role: "user",
558
569
  content: prompt,
559
570
  }],
571
+ outputFormat: { type: "json_schema", schema: soulSchema },
560
572
  });
561
573
 
562
574
  const text = response.text.trim();
563
- const jsonMatch = text.match(/\{[\s\S]*?\}/);
575
+ // With structured output, response should be valid JSON directly
576
+ let jsonMatch: RegExpMatchArray | null;
577
+ try { JSON.parse(text); jsonMatch = [text]; } catch {
578
+ jsonMatch = text.match(/\{[\s\S]*?\}/);
579
+ }
564
580
  if (!jsonMatch) return null;
565
581
 
566
582
  const parsed = JSON.parse(jsonMatch[0]);
package/src/state.ts CHANGED
@@ -4,6 +4,12 @@ import type { EmbeddingService } from "./embeddings.js";
4
4
  import type { AdaptiveConfig } from "./orchestrator.js";
5
5
  import type { MemoryDaemon } from "./daemon-manager.js";
6
6
 
7
+ /** JSON schema for structured output (forces API to return valid JSON matching schema). */
8
+ export type OutputFormat = {
9
+ type: "json_schema";
10
+ schema: Record<string, unknown>;
11
+ };
12
+
7
13
  /** Parameters for an LLM completion call. */
8
14
  export type CompleteParams = {
9
15
  system?: string;
@@ -13,6 +19,8 @@ export type CompleteParams = {
13
19
  temperature?: number;
14
20
  maxTokens?: number;
15
21
  reasoning?: "none" | "low" | "medium" | "high";
22
+ /** When set, API returns structured JSON matching the schema (no markdown, no preamble). */
23
+ outputFormat?: OutputFormat;
16
24
  };
17
25
 
18
26
  /** Result of an LLM completion call. */
@@ -32,6 +40,7 @@ export type CompleteFn = (params: CompleteParams) => Promise<CompleteResult>;
32
40
 
33
41
  const DEFAULT_TOOL_LIMIT = 10;
34
42
 
43
+ /** Per-session mutable state: turn counters, daemon refs, 5-pillar IDs, and adaptive config. */
35
44
  export class SessionState {
36
45
  readonly sessionId: string;
37
46
  readonly sessionKey: string;
@@ -41,6 +50,8 @@ export class SessionState {
41
50
  lastAssistantTurnId = "";
42
51
  lastUserText = "";
43
52
  lastAssistantText = "";
53
+ /** Embedding of last user message from ingest — reused in buildContextualQueryVec to avoid re-embedding. */
54
+ lastUserEmbedding: number[] | null = null;
44
55
  toolCallCount = 0;
45
56
  toolLimit = DEFAULT_TOOL_LIMIT;
46
57
  turnTextLength = 0;
@@ -92,6 +103,20 @@ export class SessionState {
92
103
  taskId = "";
93
104
  surrealSessionId = "";
94
105
 
106
+ // Cross-concern state (set by index.ts hooks, consumed by context-engine.ts assemble)
107
+ /** Structured summary stashed after compaction for next assemble() injection. */
108
+ _compactionSummary?: string;
109
+ /** Promise resolving to wakeup briefing text (synthesized at session start). */
110
+ _wakeupPromise?: Promise<string | null>;
111
+ /** Graduation celebration payload for context injection. */
112
+ _graduationCelebration?: {
113
+ qualityScore: number;
114
+ volumeScore: number;
115
+ soulSummary: string;
116
+ };
117
+ /** Whether workspace has files from the default context engine that can be migrated. */
118
+ _hasMigratableFiles?: boolean;
119
+
95
120
  constructor(sessionId: string, sessionKey: string) {
96
121
  this.sessionId = sessionId;
97
122
  this.sessionKey = sessionKey;
@@ -118,6 +143,7 @@ export class SessionState {
118
143
  /** Function to enqueue a system event visible to the user. */
119
144
  export type EnqueueSystemEventFn = (text: string, options: { sessionKey: string }) => boolean;
120
145
 
146
+ /** Singleton shared state: config, SurrealDB store, embedding service, and session map. */
121
147
  export class GlobalPluginState {
122
148
  readonly config: KongBrainConfig;
123
149
  readonly store: SurrealStore;
@@ -162,6 +188,11 @@ export class GlobalPluginState {
162
188
  this.sessions.delete(sessionKey);
163
189
  }
164
190
 
191
+ /** Return all active sessions (for exit handlers). */
192
+ allSessions(): SessionState[] {
193
+ return [...this.sessions.values()];
194
+ }
195
+
165
196
  /** Shut down all shared resources. */
166
197
  async shutdown(): Promise<void> {
167
198
  this.sessions.clear();