kongbrain 0.4.0 → 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.
@@ -6,7 +6,7 @@
6
6
  * so the next session's wakeup has context even before deferred
7
7
  * extraction runs.
8
8
  */
9
- import { readFileSync, writeFileSync, unlinkSync, existsSync, chmodSync } from "node:fs";
9
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, renameSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
12
  const HANDOFF_FILENAME = ".kongbrain-handoff.json";
@@ -42,14 +42,21 @@ export function readAndDeleteHandoffFile(
42
42
  workspaceDir: string,
43
43
  ): HandoffFileData | null {
44
44
  const path = join(workspaceDir, HANDOFF_FILENAME);
45
+ const processingPath = path + ".processing";
46
+ // Also clean up stale .processing files from prior crashes
47
+ if (existsSync(processingPath) && !existsSync(path)) {
48
+ try { unlinkSync(processingPath); } catch { /* ignore */ }
49
+ }
45
50
  if (!existsSync(path)) return null;
46
51
  try {
47
- const raw = readFileSync(path, "utf-8");
48
- unlinkSync(path);
52
+ // Atomic rename first so a crash between read and delete can't re-process
53
+ renameSync(path, processingPath);
54
+ const raw = readFileSync(processingPath, "utf-8");
55
+ unlinkSync(processingPath);
49
56
  const parsed = JSON.parse(raw);
50
57
  // Runtime validation — reject prototype pollution and malformed data
51
58
  if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
52
- if ("__proto__" in parsed || "constructor" in parsed) return null;
59
+ if (Object.hasOwn(parsed, "__proto__") || Object.hasOwn(parsed, "constructor")) return null;
53
60
  const data: HandoffFileData = {
54
61
  sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId.slice(0, 200) : "",
55
62
  timestamp: typeof parsed.timestamp === "string" ? parsed.timestamp.slice(0, 50) : "",
@@ -61,7 +68,7 @@ export function readAndDeleteHandoffFile(
61
68
  return data;
62
69
  } catch {
63
70
  // Corrupted or deleted between check and read
64
- try { unlinkSync(path); } catch { /* ignore */ }
71
+ try { unlinkSync(processingPath); } catch { /* ignore */ }
65
72
  return null;
66
73
  }
67
74
  }
@@ -54,7 +54,7 @@ export function createAfterToolCallHandler(state: GlobalPluginState) {
54
54
  });
55
55
  if (assistantTurnId) session.lastAssistantTurnId = assistantTurnId;
56
56
  } catch (e) {
57
- swallow("hook:afterToolCall:eagerAssistantTurn", e);
57
+ swallow.warn("hook:afterToolCall:eagerAssistantTurn", e);
58
58
  }
59
59
  }
60
60
  if (session.lastAssistantTurnId) {
@@ -63,11 +63,12 @@ export function createAfterToolCallHandler(state: GlobalPluginState) {
63
63
  }
64
64
  }
65
65
  } catch (e) {
66
- swallow("hook:afterToolCall:store", e);
66
+ swallow.warn("hook:afterToolCall:store", e);
67
67
  }
68
68
 
69
69
  // Auto-track file artifacts from write/edit tools
70
70
  if (!isError) {
71
+ // Fire-and-forget: artifact tracking is best-effort enrichment, not critical path
71
72
  trackArtifact(event.toolName, event.params, session.taskId, session.projectId, state)
72
73
  .catch(e => swallow.warn("hook:afterToolCall:artifact", e));
73
74
  }
@@ -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); }
@@ -147,11 +151,18 @@ async function runSessionCleanup(
147
151
  new Promise(resolve => setTimeout(resolve, 150_000)),
148
152
  ]);
149
153
 
154
+ // Await the graduation promise once and reuse the result below
155
+ let gradResult: Awaited<typeof graduationPromise> = null;
156
+ try {
157
+ gradResult = await graduationPromise;
158
+ } catch (e) {
159
+ swallow.warn("cleanup:graduationAwait", e);
160
+ }
161
+
150
162
  // If soul graduation just happened, persist a graduation event so the next
151
163
  // session can celebrate with the user. We also fire a system event for
152
164
  // immediate visibility if the session is still active.
153
165
  try {
154
- const gradResult = await graduationPromise;
155
166
  if (gradResult?.graduated && gradResult.soul) {
156
167
  // Check if this is a NEW graduation (not a pre-existing soul)
157
168
  const isNewGraduation = gradResult.report.stage === "ready";
@@ -189,7 +200,6 @@ async function runSessionCleanup(
189
200
  // Soul evolution — if soul already exists, check if it should be revised
190
201
  // based on new experience (runs every 10 sessions after last revision)
191
202
  try {
192
- const gradResult = await graduationPromise;
193
203
  if (gradResult?.graduated && gradResult.report.stage !== "ready") {
194
204
  // Pre-existing soul — check for evolution
195
205
  await evolveSoul(s, complete);
@@ -272,7 +282,7 @@ async function detectGraduationEvent(
272
282
  }
273
283
 
274
284
  // Flag the session for context engine injection
275
- (session as any)._graduationCelebration = {
285
+ session._graduationCelebration = {
276
286
  qualityScore: event.quality_score,
277
287
  volumeScore: event.volume_score,
278
288
  soulSummary,
@@ -361,12 +371,13 @@ export default definePluginEntry({
361
371
  // Pass apiKey directly in options so the provider can use it
362
372
  const response = await piAi!.completeSimple(model, context, {
363
373
  apiKey: auth.apiKey,
374
+ ...(params.outputFormat && { outputFormat: params.outputFormat }),
364
375
  });
365
376
  let text = "";
366
377
  let thinking: string | undefined;
367
378
  for (const block of response.content) {
368
379
  if (block.type === "text") text += block.text;
369
- 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;
370
381
  }
371
382
  return { text, thinking, usage: { input: response.usage.input, output: response.usage.output } };
372
383
  };
@@ -472,7 +483,7 @@ export default definePluginEntry({
472
483
  hasMigratableFiles(globalState!.workspaceDir)
473
484
  .then(hasMigratable => {
474
485
  if (hasMigratable) {
475
- (session as any)._hasMigratableFiles = true;
486
+ session._hasMigratableFiles = true;
476
487
  }
477
488
  })
478
489
  .catch(e => swallow.warn("index:migrationCheck", e));
@@ -486,7 +497,7 @@ export default definePluginEntry({
486
497
  .catch(e => swallow("index:graduationDetect", e));
487
498
 
488
499
  // Synthesize wakeup briefing — store the promise so assemble() can await it
489
- (session as any)._wakeupPromise = synthesizeWakeup(
500
+ session._wakeupPromise = synthesizeWakeup(
490
501
  globalState!.store, globalState!.complete, session.sessionId, globalState!.workspaceDir,
491
502
  ).catch(e => { swallow.warn("index:wakeup", e); return null; });
492
503
 
@@ -535,7 +546,7 @@ export default definePluginEntry({
535
546
  const syncExitHandler = () => {
536
547
  const gs = getGlobalState();
537
548
  if (!gs?.workspaceDir) return;
538
- const sessions = [...(gs as any).sessions.values()] as import("./state.js").SessionState[];
549
+ const sessions = gs.allSessions();
539
550
  for (const session of sessions) {
540
551
  if (session.cleanedUp) continue;
541
552
  writeHandoffFileSync({
@@ -553,14 +564,14 @@ export default definePluginEntry({
553
564
  const asyncExitHandler = () => {
554
565
  const gs = getGlobalState();
555
566
  if (!gs) return;
556
- const sessions = [...(gs as any).sessions.values()] as import("./state.js").SessionState[];
567
+ const sessions = gs.allSessions();
557
568
  if (sessions.length === 0 && !shutdownPromise) return;
558
569
 
559
570
  const cleanups = sessions.map(s => runSessionCleanup(s, gs));
560
571
  if (shutdownPromise) cleanups.push(shutdownPromise);
561
572
 
562
573
  const done = Promise.allSettled(cleanups).then(() => {
563
- gs.shutdown().catch(() => {});
574
+ gs.shutdown().catch(e => log.error("shutdown error:", e));
564
575
  });
565
576
 
566
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
  }
@@ -192,7 +196,7 @@ export async function preflight(
192
196
 
193
197
  // Non-first-turn short inputs → continuation
194
198
  if (orch.turnIndex > 1 && input.length < 20 && !input.includes("?")) {
195
- const inheritedLimit = Math.max(orch.lastConfig.toolLimit, 25);
199
+ const inheritedLimit = Math.min(orch.lastConfig.toolLimit, 25);
196
200
  const config: AdaptiveConfig = {
197
201
  ...orch.lastConfig, toolLimit: inheritedLimit, skipRetrieval: true,
198
202
  vectorSearchLimits: { turn: 0, identity: 0, concept: 0, memory: 0, artifact: 0 },
@@ -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()`,
@@ -207,6 +214,7 @@ export async function generateReflection(
207
214
  { record },
208
215
  );
209
216
  const reflectionId = String(rows[0]?.id ?? "");
217
+ store.clearReflectionCache();
210
218
 
211
219
  if (reflectionId && surrealSessionId) {
212
220
  await store.relate(reflectionId, "reflects_on", surrealSessionId).catch(e => swallow.warn("reflection:relate", e));
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
@@ -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);
@@ -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]);