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.
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();
package/src/surreal.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Surreal } from "surrealdb";
2
2
  import type { SurrealConfig } from "./config.js";
3
3
  import { swallow } from "./errors.js";
4
+ import { log } from "./log.js";
4
5
  import { loadSchema } from "./schema-loader.js";
5
6
 
6
7
  /** Record with a vector similarity score from SurrealDB search */
@@ -54,6 +55,26 @@ function assertRecordId(id: string): void {
54
55
  }
55
56
  }
56
57
 
58
+ /** Whitelist of valid SurrealDB edge table names — prevents SQL injection via edge interpolation. */
59
+ const VALID_EDGES = new Set([
60
+ // Semantic edges
61
+ "responds_to", "tool_result_of", "summarizes", "mentions", "related_to",
62
+ "narrower", "broader", "about_concept", "reflects_on",
63
+ // Skill edges
64
+ "skill_from_task", "skill_uses_concept",
65
+ // Structural pillar edges
66
+ "owns", "performed", "task_part_of", "session_task",
67
+ "produced", "derived_from", "relevant_to", "used_in", "artifact_mentions",
68
+ // Causal edges
69
+ "caused_by", "supports", "contradicts", "describes",
70
+ // Session edges
71
+ "part_of",
72
+ ]);
73
+
74
+ function assertValidEdge(edge: string): void {
75
+ if (!VALID_EDGES.has(edge)) throw new Error(`Invalid edge name: ${edge}`);
76
+ }
77
+
57
78
  function patchOrderByFields(sql: string): string {
58
79
  const s = sql.trim();
59
80
  if (!/^\s*SELECT\b/i.test(s) || !/\bORDER\s+BY\b/i.test(s)) return sql;
@@ -139,8 +160,8 @@ export class SurrealStore {
139
160
  const BACKOFF_MS = [500, 1500, 4000];
140
161
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
141
162
  try {
142
- console.warn(
143
- `[warn] SurrealDB disconnected — reconnecting (attempt ${attempt}/${MAX_ATTEMPTS})...`,
163
+ log.warn(
164
+ `SurrealDB disconnected — reconnecting (attempt ${attempt}/${MAX_ATTEMPTS})...`,
144
165
  );
145
166
  this.db = new Surreal();
146
167
  const CONNECT_TIMEOUT_MS = 5_000;
@@ -157,13 +178,13 @@ export class SurrealStore {
157
178
  ),
158
179
  ),
159
180
  ]);
160
- console.warn("[warn] SurrealDB reconnected successfully.");
181
+ log.warn("SurrealDB reconnected successfully.");
161
182
  return;
162
183
  } catch (e) {
163
184
  if (attempt < MAX_ATTEMPTS) {
164
185
  await new Promise((r) => setTimeout(r, BACKOFF_MS[attempt - 1]));
165
186
  } else {
166
- console.error(`[ERROR] SurrealDB reconnection failed after ${MAX_ATTEMPTS} attempts.`);
187
+ log.error(`SurrealDB reconnection failed after ${MAX_ATTEMPTS} attempts.`);
167
188
  throw new Error("SurrealDB reconnection failed");
168
189
  }
169
190
  }
@@ -218,7 +239,7 @@ export class SurrealStore {
218
239
 
219
240
  /** Returns true if an error is a connection-level failure worth retrying. */
220
241
  private isConnectionError(e: unknown): boolean {
221
- const msg = String((e as any)?.message ?? e);
242
+ const msg = String((e as { message?: string })?.message ?? e);
222
243
  return msg.includes("must be connected") || msg.includes("ConnectionUnavailable");
223
244
  }
224
245
 
@@ -280,6 +301,24 @@ export class SurrealStore {
280
301
  });
281
302
  }
282
303
 
304
+ /**
305
+ * Execute N SQL statements in a single SurrealDB round-trip.
306
+ * Returns one result array per statement; bindings are shared across all statements.
307
+ */
308
+ async queryBatch<T = any>(statements: string[], bindings?: Record<string, unknown>): Promise<T[][]> {
309
+ if (statements.length === 0) return [];
310
+ await this.ensureConnected();
311
+ return this.withRetry(async () => {
312
+ const ns = this.config.ns;
313
+ const dbName = this.config.db;
314
+ const joined = statements.map(s => patchOrderByFields(s)).join(";\n");
315
+ const fullSql = `USE NS ${ns} DB ${dbName};\n${joined}`;
316
+ const raw = await this.db.query(fullSql, bindings) as unknown[];
317
+ // First result is the USE statement (empty), skip it
318
+ return raw.slice(1).map(r => (Array.isArray(r) ? r : []).filter(Boolean)) as T[][];
319
+ });
320
+ }
321
+
283
322
  private async safeQuery(
284
323
  sql: string,
285
324
  bindings: Record<string, unknown>,
@@ -294,6 +333,7 @@ export class SurrealStore {
294
333
 
295
334
  // ── Vector search ──────────────────────────────────────────────────────
296
335
 
336
+ /** Multi-table cosine similarity search across turns, concepts, memories, artifacts, monologues, and identity chunks. Returns merged results sorted by score. */
297
337
  async vectorSearch(
298
338
  vec: number[],
299
339
  sessionId: string,
@@ -319,74 +359,52 @@ export class SurrealStore {
319
359
  const crossTurnLim = lim.turn - sessionTurnLim;
320
360
  const emb = withEmbeddings ? ", embedding" : "";
321
361
 
322
- const [sessionTurns, crossTurns, concepts, memories, artifacts, monologues, identityChunks] =
323
- await Promise.all([
324
- this.safeQuery(
325
- `SELECT id, text, role, timestamp, 0 AS accessCount, 'turn' AS table,
326
- vector::similarity::cosine(embedding, $vec) AS score${emb}
327
- FROM turn
328
- WHERE embedding != NONE AND array::len(embedding) > 0
329
- AND session_id = $sid
330
- ORDER BY score DESC LIMIT $lim`,
331
- { vec, lim: sessionTurnLim, sid: sessionId },
332
- ),
333
- this.safeQuery(
334
- `SELECT id, text, role, timestamp, 0 AS accessCount, 'turn' AS table,
335
- vector::similarity::cosine(embedding, $vec) AS score${emb}
336
- FROM turn
337
- WHERE embedding != NONE AND array::len(embedding) > 0
338
- AND session_id != $sid
339
- ORDER BY score DESC LIMIT $lim`,
340
- { vec, lim: crossTurnLim, sid: sessionId },
341
- ),
342
- this.safeQuery(
343
- `SELECT id, content AS text, stability AS importance, access_count AS accessCount,
344
- created_at AS timestamp, 'concept' AS table,
345
- vector::similarity::cosine(embedding, $vec) AS score${emb}
346
- FROM concept
347
- WHERE embedding != NONE AND array::len(embedding) > 0
348
- ORDER BY score DESC LIMIT $lim`,
349
- { vec, lim: lim.concept },
350
- ),
351
- this.safeQuery(
352
- `SELECT id, text, importance, access_count AS accessCount,
353
- created_at AS timestamp, session_id AS sessionId, 'memory' AS table,
354
- vector::similarity::cosine(embedding, $vec) AS score${emb}
355
- FROM memory
356
- WHERE embedding != NONE AND array::len(embedding) > 0
357
- AND (status = 'active' OR status IS NONE)
358
- ORDER BY score DESC LIMIT $lim`,
359
- { vec, lim: lim.memory },
360
- ),
361
- this.safeQuery(
362
- `SELECT id, description AS text, 0 AS accessCount,
363
- created_at AS timestamp, 'artifact' AS table,
364
- vector::similarity::cosine(embedding, $vec) AS score${emb}
365
- FROM artifact
366
- WHERE embedding != NONE AND array::len(embedding) > 0
367
- ORDER BY score DESC LIMIT $lim`,
368
- { vec, lim: lim.artifact },
369
- ),
370
- this.safeQuery(
371
- `SELECT id, content AS text, category AS source, 0.5 AS importance, 0 AS accessCount,
372
- timestamp, 'monologue' AS table,
373
- vector::similarity::cosine(embedding, $vec) AS score${emb}
374
- FROM monologue
375
- WHERE embedding != NONE AND array::len(embedding) > 0
376
- ORDER BY score DESC LIMIT $lim`,
377
- { vec, lim: lim.monologue },
378
- ),
379
- // Identity chunks — agent self-knowledge, searchable mid-conversation
380
- this.safeQuery(
381
- `SELECT id, text, importance, 0 AS accessCount,
382
- 'identity_chunk' AS table,
383
- vector::similarity::cosine(embedding, $vec) AS score${emb}
384
- FROM identity_chunk
385
- WHERE embedding != NONE AND array::len(embedding) > 0
386
- ORDER BY score DESC LIMIT $lim`,
387
- { vec, lim: lim.identity },
388
- ),
389
- ]);
362
+ // Batch all 7 vector searches into a single round-trip (limits inlined — per-table)
363
+ const stmts = [
364
+ `SELECT id, text, role, timestamp, 0 AS accessCount, 'turn' AS table,
365
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
366
+ FROM turn WHERE embedding != NONE AND array::len(embedding) > 0
367
+ AND session_id = $sid ORDER BY score DESC LIMIT ${sessionTurnLim}`,
368
+ `SELECT id, text, role, timestamp, 0 AS accessCount, 'turn' AS table,
369
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
370
+ FROM turn WHERE embedding != NONE AND array::len(embedding) > 0
371
+ AND session_id != $sid ORDER BY score DESC LIMIT ${crossTurnLim}`,
372
+ `SELECT id, content AS text, stability AS importance, access_count AS accessCount,
373
+ created_at AS timestamp, 'concept' AS table,
374
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
375
+ FROM concept WHERE embedding != NONE AND array::len(embedding) > 0
376
+ ORDER BY score DESC LIMIT ${lim.concept}`,
377
+ `SELECT id, text, importance, access_count AS accessCount,
378
+ created_at AS timestamp, session_id AS sessionId, 'memory' AS table,
379
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
380
+ FROM memory WHERE embedding != NONE AND array::len(embedding) > 0
381
+ AND (status = 'active' OR status IS NONE) ORDER BY score DESC LIMIT ${lim.memory}`,
382
+ `SELECT id, description AS text, 0 AS accessCount,
383
+ created_at AS timestamp, 'artifact' AS table,
384
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
385
+ FROM artifact WHERE embedding != NONE AND array::len(embedding) > 0
386
+ ORDER BY score DESC LIMIT ${lim.artifact}`,
387
+ `SELECT id, content AS text, category AS source, 0.5 AS importance, 0 AS accessCount,
388
+ timestamp, 'monologue' AS table,
389
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
390
+ FROM monologue WHERE embedding != NONE AND array::len(embedding) > 0
391
+ ORDER BY score DESC LIMIT ${lim.monologue}`,
392
+ `SELECT id, text, importance, 0 AS accessCount,
393
+ 'identity_chunk' AS table,
394
+ vector::similarity::cosine(embedding, $vec) AS score${emb}
395
+ FROM identity_chunk WHERE embedding != NONE AND array::len(embedding) > 0
396
+ ORDER BY score DESC LIMIT ${lim.identity}`,
397
+ ];
398
+
399
+ let batchResults: any[][];
400
+ try {
401
+ batchResults = await this.queryBatch<any>(stmts, { vec, sid: sessionId });
402
+ } catch (e) {
403
+ swallow.warn("surreal:vectorSearch:batch", e);
404
+ return [];
405
+ }
406
+ const [sessionTurns = [], crossTurns = [], concepts = [], memories = [], artifacts = [], monologues = [], identityChunks = []] =
407
+ batchResults as VectorSearchResult[][];
390
408
  return [
391
409
  ...sessionTurns,
392
410
  ...crossTurns,
@@ -570,6 +588,10 @@ export class SurrealStore {
570
588
 
571
589
  // ── Graph traversal ────────────────────────────────────────────────────
572
590
 
591
+ /**
592
+ * BFS expansion from seed nodes along typed edges, with batched per-hop queries.
593
+ * Each edge query is LIMIT 3 (EDGE_NEIGHBOR_LIMIT) to bound fan-out per node.
594
+ */
573
595
  async graphExpand(
574
596
  nodeIds: string[],
575
597
  queryVec: number[],
@@ -577,6 +599,10 @@ export class SurrealStore {
577
599
  ): Promise<VectorSearchResult[]> {
578
600
  if (nodeIds.length === 0) return [];
579
601
 
602
+ const MAX_FRONTIER_SEEDS = 5; // max seed nodes to start BFS from
603
+ const MAX_FRONTIER_PER_HOP = 3; // max nodes carried forward per hop (by score)
604
+ const EDGE_NEIGHBOR_LIMIT = 3; // max neighbors per edge traversal (inlined in SQL LIMIT)
605
+
580
606
  const forwardEdges = [
581
607
  // Semantic edges
582
608
  "responds_to", "tool_result_of", "summarizes",
@@ -602,32 +628,23 @@ export class SurrealStore {
602
628
 
603
629
  const seen = new Set<string>(nodeIds);
604
630
  const allNeighbors: VectorSearchResult[] = [];
605
- let frontier = nodeIds.slice(0, 5).filter((id) => RECORD_ID_RE.test(id));
631
+ let frontier = nodeIds.slice(0, MAX_FRONTIER_SEEDS).filter((id) => RECORD_ID_RE.test(id));
606
632
 
607
633
  for (let hop = 0; hop < hops && frontier.length > 0; hop++) {
608
- const forwardQueries = frontier.flatMap((id) =>
609
- forwardEdges.map((edge) =>
610
- this.queryFirst<any>(`${selectFields} FROM ${id}->${edge}->? LIMIT 3`, bindings).catch(
611
- (e) => {
612
- swallow.warn("surreal:graphExpand", e);
613
- return [] as Record<string, unknown>[];
614
- },
615
- ),
616
- ),
617
- );
618
-
619
- const reverseQueries = frontier.flatMap((id) =>
620
- reverseEdges.map((edge) =>
621
- this.queryFirst<any>(`${selectFields} FROM ${id}<-${edge}<-? LIMIT 3`, bindings).catch(
622
- (e) => {
623
- swallow.warn("surreal:graphExpand", e);
624
- return [] as Record<string, unknown>[];
625
- },
626
- ),
627
- ),
628
- );
634
+ // Batch all edge traversals for this hop in a single round-trip
635
+ const stmts: string[] = [];
636
+ for (const id of frontier) {
637
+ for (const edge of forwardEdges) { assertValidEdge(edge); stmts.push(`${selectFields} FROM ${id}->${edge}->? LIMIT ${EDGE_NEIGHBOR_LIMIT}`); }
638
+ for (const edge of reverseEdges) { assertValidEdge(edge); stmts.push(`${selectFields} FROM ${id}<-${edge}<-? LIMIT ${EDGE_NEIGHBOR_LIMIT}`); }
639
+ }
629
640
 
630
- const queryResults = await Promise.all([...forwardQueries, ...reverseQueries]);
641
+ let queryResults: any[][];
642
+ try {
643
+ queryResults = await this.queryBatch<any>(stmts, bindings);
644
+ } catch (e) {
645
+ swallow.warn("surreal:graphExpand:batch", e);
646
+ break;
647
+ }
631
648
  const nextFrontier: { id: string; score: number }[] = [];
632
649
 
633
650
  for (const rows of queryResults) {
@@ -657,7 +674,7 @@ export class SurrealStore {
657
674
 
658
675
  frontier = nextFrontier
659
676
  .sort((a, b) => b.score - a.score)
660
- .slice(0, 3)
677
+ .slice(0, MAX_FRONTIER_PER_HOP)
661
678
  .map((n) => n.id);
662
679
  }
663
680
 
@@ -665,15 +682,18 @@ export class SurrealStore {
665
682
  }
666
683
 
667
684
  async bumpAccessCounts(ids: string[]): Promise<void> {
668
- for (const id of ids) {
669
- try {
670
- assertRecordId(id);
671
- await this.queryExec(
672
- `UPDATE ${id} SET access_count += 1, last_accessed = time::now()`,
673
- );
674
- } catch (e) {
675
- swallow.warn("surreal:bumpAccessCounts", e);
676
- }
685
+ const validated = ids.filter(id => { try { assertRecordId(id); return true; } catch { return false; } });
686
+ if (validated.length === 0) return;
687
+ try {
688
+ // Direct interpolation (safe: assertRecordId validates format above).
689
+ // Cannot use `UPDATE $ids` binding SurrealDB treats string arrays as
690
+ // literal strings, not record references, causing silent no-ops.
691
+ const stmts = validated.map(id =>
692
+ `UPDATE ${id} SET access_count += 1, last_accessed = time::now()`,
693
+ );
694
+ await this.queryBatch(stmts);
695
+ } catch (e) {
696
+ swallow.warn("surreal:bumpAccessCounts", e);
677
697
  }
678
698
  }
679
699
 
@@ -1304,9 +1324,13 @@ export class SurrealStore {
1304
1324
  if (rows.length === 0) return [];
1305
1325
  const ids = rows.map((r) => r.memory_id).filter(Boolean);
1306
1326
  if (ids.length === 0) return [];
1327
+ // Direct interpolation — SurrealDB treats string-array bindings as
1328
+ // literal strings, not record references, causing silent empty results.
1329
+ const validated = ids.filter(id => { try { assertRecordId(String(id)); return true; } catch { return false; } });
1330
+ if (validated.length === 0) return [];
1331
+ const idList = validated.join(", ");
1307
1332
  return this.queryFirst<{ id: string; text: string }>(
1308
- `SELECT id, text FROM memory WHERE id IN $ids AND (status = 'active' OR status IS NONE)`,
1309
- { ids },
1333
+ `SELECT id, text FROM memory WHERE id IN [${idList}] AND (status = 'active' OR status IS NONE)`,
1310
1334
  );
1311
1335
  } catch (e) {
1312
1336
  swallow.warn("surreal:getSessionRetrievedMemories", e);
@@ -1408,6 +1432,10 @@ export class SurrealStore {
1408
1432
 
1409
1433
  private _reflectionSessions: Set<string> | null = null;
1410
1434
 
1435
+ clearReflectionCache(): void {
1436
+ this._reflectionSessions = null;
1437
+ }
1438
+
1411
1439
  async getReflectionSessionIds(): Promise<Set<string>> {
1412
1440
  if (this._reflectionSessions) return this._reflectionSessions;
1413
1441
  try {
@@ -1430,7 +1458,7 @@ export class SurrealStore {
1430
1458
  const current = await this.queryFirst<{ fib_index: number }>(
1431
1459
  `SELECT fib_index FROM $id`, { id: memoryId },
1432
1460
  );
1433
- const idx = (current as any)?.[0]?.fib_index ?? 0;
1461
+ const idx = (current as { fib_index: number }[] | undefined)?.[0]?.fib_index ?? 0;
1434
1462
  const nextIdx = Math.min(idx + 1, SurrealStore.FIB_DAYS.length - 1);
1435
1463
  const days = nextIdx < SurrealStore.FIB_DAYS.length
1436
1464
  ? SurrealStore.FIB_DAYS[nextIdx]
@@ -1459,4 +1487,4 @@ export class SurrealStore {
1459
1487
  }
1460
1488
  }
1461
1489
 
1462
- export { assertRecordId };
1490
+ export { assertRecordId, assertValidEdge, VALID_EDGES };
@@ -215,7 +215,7 @@ async function verifyAction(store: any, recordId?: string) {
215
215
 
216
216
  const record = rows[0];
217
217
  const cleaned: Record<string, unknown> = {};
218
- for (const [key, val] of Object.entries(record as any)) {
218
+ for (const [key, val] of Object.entries(record as Record<string, unknown>)) {
219
219
  if (Array.isArray(val) && val.length > 100 && typeof val[0] === "number") {
220
220
  cleaned[key] = `[${val.length} dims]`;
221
221
  } else {
@@ -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[] = [];
package/src/wakeup.ts CHANGED
@@ -16,14 +16,6 @@ import type { MaturityStage } from "./soul.js";
16
16
  import { readAndDeleteHandoffFile } from "./handoff-file.js";
17
17
  import { swallow } from "./errors.js";
18
18
 
19
- // --- Types ---
20
-
21
- export interface StartupCognition {
22
- greeting: string;
23
- thoughts: string[];
24
- intent: "continue_prior" | "fresh_start" | "unknown";
25
- }
26
-
27
19
  // --- Depth signals ---
28
20
 
29
21
  async function getDepthSignals(store: SurrealStore): Promise<{ sessions: number; monologueCount: number; memoryCount: number; spanDays: number }> {
@@ -194,137 +186,3 @@ export async function synthesizeWakeup(
194
186
  }
195
187
  }
196
188
 
197
- // --- Startup cognition ---
198
-
199
- /**
200
- * Proactive startup cognition: reasons over recent state and produces
201
- * a contextual greeting + thoughts to carry into the session.
202
- */
203
- export async function synthesizeStartupCognition(
204
- store: SurrealStore,
205
- complete: CompleteFn,
206
- ): Promise<StartupCognition | null> {
207
- if (!store.isAvailable()) return null;
208
-
209
- const [handoff, unresolved, failedCausal, monologues, depth, previousTurns] = await Promise.all([
210
- store.getLatestHandoff(),
211
- store.getUnresolvedMemories(5),
212
- store.getRecentFailedCausal(3),
213
- store.getRecentMonologues(3),
214
- getDepthSignals(store),
215
- store.getPreviousSessionTurns(undefined, 5),
216
- ]);
217
-
218
- if (!handoff && unresolved.length === 0 && monologues.length === 0 && previousTurns.length === 0) return null;
219
-
220
- const sections: string[] = [];
221
-
222
- if (previousTurns.length > 0) {
223
- const turnLines = previousTurns.map((t: any) => {
224
- const prefix = t.role === "user" ? "USER" : t.tool_name ? `TOOL(${t.tool_name})` : "ASSISTANT";
225
- const text = t.text.length > 300 ? t.text.slice(0, 300) + "..." : t.text;
226
- return `${prefix}: ${text}`;
227
- });
228
- sections.push(`[PREVIOUS SESSION — LAST TURNS]\n${turnLines.join("\n")}`);
229
- }
230
-
231
- if (handoff) {
232
- const resolvedCount = await store.countResolvedSinceHandoff(handoff.created_at).catch(() => 0);
233
- const ageHours = Math.floor((Date.now() - new Date(handoff.created_at).getTime()) / 3_600_000);
234
- let annotation = `(${ageHours}h old`;
235
- if (resolvedCount > 0) annotation += `, ${resolvedCount} memories resolved since`;
236
- annotation += ")";
237
- sections.push(`[LAST HANDOFF] ${annotation}\n${handoff.text.slice(0, 500)}`);
238
- }
239
-
240
- if (unresolved.length > 0) {
241
- const lines = unresolved.map((m: any) => `- [${m.category}] (importance: ${m.importance}) ${m.text.slice(0, 150)}`);
242
- sections.push(`[UNRESOLVED MEMORIES]\n${lines.join("\n")}`);
243
- }
244
-
245
- if (failedCausal.length > 0) {
246
- const lines = failedCausal.map((c: any) => `- [${c.chain_type}] ${c.description}`);
247
- sections.push(`[RECENT FAILURES]\n${lines.join("\n")}`);
248
- }
249
-
250
- if (monologues.length > 0) {
251
- const lines = monologues.map((m) => `[${m.category}] ${m.content}`);
252
- sections.push(`[RECENT THINKING]\n${lines.join("\n")}`);
253
- }
254
-
255
- if (depth.sessions > 0) {
256
- sections.push(`[DEPTH] ${depth.sessions} sessions | ${depth.memoryCount} memories | ${depth.spanDays} days`);
257
- }
258
-
259
- try {
260
- const greetingPrompts = [
261
- `You are waking up for a new session. Be direct and casual. Based on what you remember, produce JSON:
262
- "greeting": string (1-2 sentences, direct and matter-of-fact. FIRST look at [PREVIOUS SESSION — LAST TURNS] — reference exactly what we were just doing. Under 25 words.)
263
- "proactive_thoughts": string[] (max 3. Brief observations. If the last turns show something was FIXED or RESOLVED, do NOT list it as open.)
264
- "session_intent": "continue_prior" | "fresh_start" | "unknown"
265
- Return ONLY valid JSON.`,
266
- `You are waking up for a new session. Be irreverent and witty. Based on what you remember, produce JSON:
267
- "greeting": string (1-2 sentences, snarky and self-aware. FIRST look at [PREVIOUS SESSION — LAST TURNS]. Under 25 words.)
268
- "proactive_thoughts": string[] (max 3. Only surface genuinely unfinished work.)
269
- "session_intent": "continue_prior" | "fresh_start" | "unknown"
270
- Return ONLY valid JSON.`,
271
- `You are waking up for a new session. Be pragmatic and no-nonsense. Based on what you remember, produce JSON:
272
- "greeting": string (1-2 sentences, action-oriented. FIRST look at [PREVIOUS SESSION — LAST TURNS]. Under 25 words.)
273
- "proactive_thoughts": string[] (max 3. Focus on what's blocking or next.)
274
- "session_intent": "continue_prior" | "fresh_start" | "unknown"
275
- Return ONLY valid JSON.`,
276
- `You are waking up for a new session. Be warm and encouraging. Based on what you remember, produce JSON:
277
- "greeting": string (1-2 sentences, supportive. FIRST look at [PREVIOUS SESSION — LAST TURNS]. Under 25 words.)
278
- "proactive_thoughts": string[] (max 3. Acknowledge what we accomplished.)
279
- "session_intent": "continue_prior" | "fresh_start" | "unknown"
280
- Return ONLY valid JSON.`,
281
- `You are waking up for a new session. Be analytical and focused. Based on what you remember, produce JSON:
282
- "greeting": string (1-2 sentences, precise. FIRST look at [PREVIOUS SESSION — LAST TURNS]. Under 25 words.)
283
- "proactive_thoughts": string[] (max 3. What needs tackling?)
284
- "session_intent": "continue_prior" | "fresh_start" | "unknown"
285
- Return ONLY valid JSON.`,
286
- ];
287
-
288
- const systemPrompt = greetingPrompts[Math.floor(Math.random() * greetingPrompts.length)];
289
-
290
- const response = await complete({
291
- system: systemPrompt,
292
- messages: [{
293
- role: "user",
294
- content: sections.join("\n\n"),
295
- }],
296
- });
297
-
298
- const text = response.text;
299
-
300
- const jsonMatch = text.match(/\{[\s\S]*?\}/);
301
- if (!jsonMatch) return null;
302
-
303
- let raw: any;
304
- try {
305
- raw = JSON.parse(jsonMatch[0]);
306
- } catch {
307
- try {
308
- raw = JSON.parse(jsonMatch[0].replace(/,\s*([}\]])/g, "$1"));
309
- } catch { return null; }
310
- }
311
-
312
- const greeting = String(raw.greeting ?? "").slice(0, 200);
313
- if (!greeting) return null;
314
-
315
- const thoughts: string[] = [];
316
- if (Array.isArray(raw.proactive_thoughts)) {
317
- for (const t of raw.proactive_thoughts.slice(0, 3)) {
318
- if (typeof t === "string" && t.length > 0) thoughts.push(t.slice(0, 200));
319
- }
320
- }
321
-
322
- const INTENTS = new Set(["continue_prior", "fresh_start", "unknown"]);
323
- const intent = INTENTS.has(raw.session_intent) ? raw.session_intent : "unknown";
324
-
325
- return { greeting, thoughts, intent };
326
- } catch (e) {
327
- swallow.warn("wakeup:startupCognition", e);
328
- return null;
329
- }
330
- }