@hybridaione/hybridclaw 0.2.2 → 0.2.6

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.
Files changed (277) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +85 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +41 -16
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +71 -8
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1303 -0
  12. package/container/src/browser-tools.ts +431 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +34 -13
  15. package/container/src/index.ts +451 -109
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +20 -10
  18. package/container/src/tools.ts +599 -225
  19. package/container/src/types.ts +32 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +10 -2
  23. package/dist/agent.js.map +1 -1
  24. package/dist/audit-cli.d.ts.map +1 -1
  25. package/dist/audit-cli.js +4 -2
  26. package/dist/audit-cli.js.map +1 -1
  27. package/dist/audit-events.d.ts.map +1 -1
  28. package/dist/audit-events.js +53 -3
  29. package/dist/audit-events.js.map +1 -1
  30. package/dist/audit-trail.d.ts.map +1 -1
  31. package/dist/audit-trail.js +17 -8
  32. package/dist/audit-trail.js.map +1 -1
  33. package/dist/channels/discord/attachments.d.ts.map +1 -1
  34. package/dist/channels/discord/attachments.js +14 -7
  35. package/dist/channels/discord/attachments.js.map +1 -1
  36. package/dist/channels/discord/debounce.d.ts +9 -0
  37. package/dist/channels/discord/debounce.d.ts.map +1 -0
  38. package/dist/channels/discord/debounce.js +20 -0
  39. package/dist/channels/discord/debounce.js.map +1 -0
  40. package/dist/channels/discord/delivery.d.ts +4 -1
  41. package/dist/channels/discord/delivery.d.ts.map +1 -1
  42. package/dist/channels/discord/delivery.js +19 -3
  43. package/dist/channels/discord/delivery.js.map +1 -1
  44. package/dist/channels/discord/human-delay.d.ts +16 -0
  45. package/dist/channels/discord/human-delay.d.ts.map +1 -0
  46. package/dist/channels/discord/human-delay.js +29 -0
  47. package/dist/channels/discord/human-delay.js.map +1 -0
  48. package/dist/channels/discord/inbound.d.ts +4 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +45 -4
  51. package/dist/channels/discord/inbound.js.map +1 -1
  52. package/dist/channels/discord/mentions.d.ts.map +1 -1
  53. package/dist/channels/discord/mentions.js +16 -4
  54. package/dist/channels/discord/mentions.js.map +1 -1
  55. package/dist/channels/discord/presence.d.ts +33 -0
  56. package/dist/channels/discord/presence.d.ts.map +1 -0
  57. package/dist/channels/discord/presence.js +111 -0
  58. package/dist/channels/discord/presence.js.map +1 -0
  59. package/dist/channels/discord/rate-limiter.d.ts +14 -0
  60. package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
  61. package/dist/channels/discord/rate-limiter.js +49 -0
  62. package/dist/channels/discord/rate-limiter.js.map +1 -0
  63. package/dist/channels/discord/reactions.d.ts +38 -0
  64. package/dist/channels/discord/reactions.d.ts.map +1 -0
  65. package/dist/channels/discord/reactions.js +151 -0
  66. package/dist/channels/discord/reactions.js.map +1 -0
  67. package/dist/channels/discord/runtime.d.ts +6 -3
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +621 -125
  70. package/dist/channels/discord/runtime.js.map +1 -1
  71. package/dist/channels/discord/stream.d.ts +4 -1
  72. package/dist/channels/discord/stream.d.ts.map +1 -1
  73. package/dist/channels/discord/stream.js +16 -8
  74. package/dist/channels/discord/stream.js.map +1 -1
  75. package/dist/channels/discord/tool-actions.d.ts.map +1 -1
  76. package/dist/channels/discord/tool-actions.js +24 -12
  77. package/dist/channels/discord/tool-actions.js.map +1 -1
  78. package/dist/channels/discord/typing.d.ts +15 -0
  79. package/dist/channels/discord/typing.d.ts.map +1 -0
  80. package/dist/channels/discord/typing.js +106 -0
  81. package/dist/channels/discord/typing.js.map +1 -0
  82. package/dist/chunk.d.ts.map +1 -1
  83. package/dist/chunk.js +4 -2
  84. package/dist/chunk.js.map +1 -1
  85. package/dist/cli.js +47 -22
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +19 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +103 -18
  90. package/dist/config.js.map +1 -1
  91. package/dist/container-runner.d.ts.map +1 -1
  92. package/dist/container-runner.js +58 -26
  93. package/dist/container-runner.js.map +1 -1
  94. package/dist/container-setup.d.ts.map +1 -1
  95. package/dist/container-setup.js +10 -9
  96. package/dist/container-setup.js.map +1 -1
  97. package/dist/conversation.d.ts +2 -2
  98. package/dist/conversation.d.ts.map +1 -1
  99. package/dist/conversation.js +1 -1
  100. package/dist/conversation.js.map +1 -1
  101. package/dist/db.d.ts +118 -2
  102. package/dist/db.d.ts.map +1 -1
  103. package/dist/db.js +1568 -50
  104. package/dist/db.js.map +1 -1
  105. package/dist/delegation-manager.d.ts.map +1 -1
  106. package/dist/delegation-manager.js +3 -2
  107. package/dist/delegation-manager.js.map +1 -1
  108. package/dist/gateway-client.d.ts +2 -2
  109. package/dist/gateway-client.d.ts.map +1 -1
  110. package/dist/gateway-client.js +10 -4
  111. package/dist/gateway-client.js.map +1 -1
  112. package/dist/gateway-service.d.ts +3 -3
  113. package/dist/gateway-service.d.ts.map +1 -1
  114. package/dist/gateway-service.js +563 -73
  115. package/dist/gateway-service.js.map +1 -1
  116. package/dist/gateway-types.d.ts +24 -0
  117. package/dist/gateway-types.d.ts.map +1 -1
  118. package/dist/gateway-types.js.map +1 -1
  119. package/dist/gateway.js +179 -24
  120. package/dist/gateway.js.map +1 -1
  121. package/dist/health.d.ts.map +1 -1
  122. package/dist/health.js +20 -10
  123. package/dist/health.js.map +1 -1
  124. package/dist/heartbeat.d.ts +4 -0
  125. package/dist/heartbeat.d.ts.map +1 -1
  126. package/dist/heartbeat.js +48 -20
  127. package/dist/heartbeat.js.map +1 -1
  128. package/dist/hybridai-bots.d.ts.map +1 -1
  129. package/dist/hybridai-bots.js +4 -2
  130. package/dist/hybridai-bots.js.map +1 -1
  131. package/dist/instruction-approval-audit.d.ts.map +1 -1
  132. package/dist/instruction-approval-audit.js.map +1 -1
  133. package/dist/instruction-integrity.d.ts.map +1 -1
  134. package/dist/instruction-integrity.js +8 -2
  135. package/dist/instruction-integrity.js.map +1 -1
  136. package/dist/ipc.d.ts.map +1 -1
  137. package/dist/ipc.js +6 -1
  138. package/dist/ipc.js.map +1 -1
  139. package/dist/logger.js.map +1 -1
  140. package/dist/memory-consolidation.d.ts +17 -0
  141. package/dist/memory-consolidation.d.ts.map +1 -0
  142. package/dist/memory-consolidation.js +25 -0
  143. package/dist/memory-consolidation.js.map +1 -0
  144. package/dist/memory-service.d.ts +200 -0
  145. package/dist/memory-service.d.ts.map +1 -0
  146. package/dist/memory-service.js +294 -0
  147. package/dist/memory-service.js.map +1 -0
  148. package/dist/mount-security.d.ts.map +1 -1
  149. package/dist/mount-security.js +31 -7
  150. package/dist/mount-security.js.map +1 -1
  151. package/dist/observability-ingest.d.ts.map +1 -1
  152. package/dist/observability-ingest.js +32 -11
  153. package/dist/observability-ingest.js.map +1 -1
  154. package/dist/onboarding.d.ts.map +1 -1
  155. package/dist/onboarding.js +32 -9
  156. package/dist/onboarding.js.map +1 -1
  157. package/dist/proactive-policy.d.ts.map +1 -1
  158. package/dist/proactive-policy.js +2 -1
  159. package/dist/proactive-policy.js.map +1 -1
  160. package/dist/prompt-hooks.d.ts.map +1 -1
  161. package/dist/prompt-hooks.js +9 -7
  162. package/dist/prompt-hooks.js.map +1 -1
  163. package/dist/runtime-config.d.ts +98 -1
  164. package/dist/runtime-config.d.ts.map +1 -1
  165. package/dist/runtime-config.js +477 -23
  166. package/dist/runtime-config.js.map +1 -1
  167. package/dist/scheduled-task-runner.d.ts +1 -0
  168. package/dist/scheduled-task-runner.d.ts.map +1 -1
  169. package/dist/scheduled-task-runner.js +29 -10
  170. package/dist/scheduled-task-runner.js.map +1 -1
  171. package/dist/scheduler.d.ts +43 -4
  172. package/dist/scheduler.d.ts.map +1 -1
  173. package/dist/scheduler.js +530 -56
  174. package/dist/scheduler.js.map +1 -1
  175. package/dist/session-export.d.ts +26 -0
  176. package/dist/session-export.d.ts.map +1 -0
  177. package/dist/session-export.js +149 -0
  178. package/dist/session-export.js.map +1 -0
  179. package/dist/session-maintenance.d.ts.map +1 -1
  180. package/dist/session-maintenance.js +75 -13
  181. package/dist/session-maintenance.js.map +1 -1
  182. package/dist/session-transcripts.d.ts.map +1 -1
  183. package/dist/session-transcripts.js.map +1 -1
  184. package/dist/side-effects.d.ts.map +1 -1
  185. package/dist/side-effects.js +14 -2
  186. package/dist/side-effects.js.map +1 -1
  187. package/dist/skills-guard.d.ts.map +1 -1
  188. package/dist/skills-guard.js +893 -130
  189. package/dist/skills-guard.js.map +1 -1
  190. package/dist/skills.d.ts +5 -0
  191. package/dist/skills.d.ts.map +1 -1
  192. package/dist/skills.js +29 -15
  193. package/dist/skills.js.map +1 -1
  194. package/dist/token-efficiency.d.ts.map +1 -1
  195. package/dist/token-efficiency.js.map +1 -1
  196. package/dist/tui.js +92 -11
  197. package/dist/tui.js.map +1 -1
  198. package/dist/types.d.ts +146 -0
  199. package/dist/types.d.ts.map +1 -1
  200. package/dist/types.js +24 -1
  201. package/dist/types.js.map +1 -1
  202. package/dist/update.d.ts.map +1 -1
  203. package/dist/update.js +42 -14
  204. package/dist/update.js.map +1 -1
  205. package/dist/workspace.d.ts.map +1 -1
  206. package/dist/workspace.js +49 -9
  207. package/dist/workspace.js.map +1 -1
  208. package/docs/chat.html +9 -3
  209. package/docs/index.html +37 -13
  210. package/package.json +8 -2
  211. package/src/agent.ts +16 -3
  212. package/src/audit-cli.ts +44 -16
  213. package/src/audit-events.ts +69 -5
  214. package/src/audit-trail.ts +41 -15
  215. package/src/channels/discord/attachments.ts +81 -27
  216. package/src/channels/discord/debounce.ts +25 -0
  217. package/src/channels/discord/delivery.ts +57 -13
  218. package/src/channels/discord/human-delay.ts +48 -0
  219. package/src/channels/discord/inbound.ts +66 -7
  220. package/src/channels/discord/mentions.ts +42 -18
  221. package/src/channels/discord/presence.ts +148 -0
  222. package/src/channels/discord/rate-limiter.ts +58 -0
  223. package/src/channels/discord/reactions.ts +211 -0
  224. package/src/channels/discord/runtime.ts +1048 -182
  225. package/src/channels/discord/stream.ts +73 -27
  226. package/src/channels/discord/tool-actions.ts +78 -37
  227. package/src/channels/discord/typing.ts +140 -0
  228. package/src/chunk.ts +12 -4
  229. package/src/cli.ts +141 -56
  230. package/src/config.ts +192 -34
  231. package/src/container-runner.ts +132 -42
  232. package/src/container-setup.ts +57 -22
  233. package/src/conversation.ts +9 -7
  234. package/src/db.ts +2217 -84
  235. package/src/delegation-manager.ts +6 -2
  236. package/src/gateway-client.ts +41 -17
  237. package/src/gateway-service.ts +1019 -201
  238. package/src/gateway-types.ts +33 -0
  239. package/src/gateway.ts +321 -48
  240. package/src/health.ts +66 -26
  241. package/src/heartbeat.ts +84 -22
  242. package/src/hybridai-bots.ts +14 -5
  243. package/src/instruction-approval-audit.ts +4 -1
  244. package/src/instruction-integrity.ts +30 -9
  245. package/src/ipc.ts +23 -5
  246. package/src/logger.ts +4 -1
  247. package/src/memory-consolidation.ts +41 -0
  248. package/src/memory-service.ts +606 -0
  249. package/src/mount-security.ts +58 -13
  250. package/src/observability-ingest.ts +134 -35
  251. package/src/onboarding.ts +126 -35
  252. package/src/proactive-policy.ts +3 -1
  253. package/src/prompt-hooks.ts +40 -17
  254. package/src/runtime-config.ts +1114 -99
  255. package/src/scheduled-task-runner.ts +63 -11
  256. package/src/scheduler.ts +683 -60
  257. package/src/session-export.ts +196 -0
  258. package/src/session-maintenance.ts +125 -22
  259. package/src/session-transcripts.ts +12 -3
  260. package/src/side-effects.ts +28 -5
  261. package/src/skills-guard.ts +1067 -219
  262. package/src/skills.ts +163 -65
  263. package/src/token-efficiency.ts +31 -9
  264. package/src/tui.ts +166 -25
  265. package/src/types.ts +195 -2
  266. package/src/update.ts +79 -23
  267. package/src/workspace.ts +63 -11
  268. package/tests/approval-policy.test.ts +224 -0
  269. package/tests/discord.basic.test.ts +82 -2
  270. package/tests/discord.human-presence.test.ts +85 -0
  271. package/tests/gateway-service.media-routing.test.ts +8 -2
  272. package/tests/memory-service.test.ts +1114 -0
  273. package/tests/token-efficiency.basic.test.ts +8 -2
  274. package/vitest.e2e.config.ts +3 -1
  275. package/vitest.integration.config.ts +3 -1
  276. package/vitest.live.config.ts +3 -1
  277. package/vitest.unit.config.ts +9 -0
package/src/db.ts CHANGED
@@ -1,22 +1,118 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
1
4
  import Database from 'better-sqlite3';
2
- import fs from 'fs';
3
- import path from 'path';
4
-
5
+ import type { AuditEventPayload, WireRecord } from './audit-trail.js';
5
6
  import { DB_PATH } from './config.js';
6
7
  import { logger } from './logger.js';
7
- import type { AuditEventPayload, WireRecord } from './audit-trail.js';
8
8
  import type {
9
9
  ApprovalAuditEntry,
10
10
  AuditEntry,
11
+ CanonicalSession,
12
+ CanonicalSessionContext,
13
+ CanonicalSessionMessage,
14
+ KnowledgeEntity,
15
+ KnowledgeEntityTypeValue,
16
+ KnowledgeGraphMatch,
17
+ KnowledgeGraphPattern,
18
+ KnowledgeRelationTypeValue,
11
19
  ScheduledTask,
20
+ SemanticMemoryEntry,
12
21
  Session,
13
22
  StoredMessage,
14
23
  StructuredAuditEntry,
24
+ UsageAgentAggregate,
25
+ UsageDailyAggregate,
26
+ UsageModelAggregate,
27
+ UsageTotals,
28
+ UsageWindow,
15
29
  } from './types.js';
30
+ import { KnowledgeEntityType, KnowledgeRelationType } from './types.js';
16
31
 
17
32
  let db: Database.Database;
18
33
 
19
- function createSchema(database: Database.Database): void {
34
+ const SCHEMA_VERSION = 4;
35
+
36
+ interface InitDatabaseOptions {
37
+ quiet?: boolean;
38
+ dbPath?: string;
39
+ }
40
+
41
+ interface TableInfoRow {
42
+ name: string;
43
+ }
44
+
45
+ function getSchemaVersion(database: Database.Database): number {
46
+ const raw = database.pragma('user_version', { simple: true });
47
+ const value = typeof raw === 'number' ? raw : Number(raw);
48
+ if (!Number.isFinite(value)) return 0;
49
+ return Math.max(0, Math.trunc(value));
50
+ }
51
+
52
+ function setSchemaVersion(database: Database.Database, version: number): void {
53
+ const bounded = Math.max(0, Math.trunc(version));
54
+ database.pragma(`user_version = ${bounded}`);
55
+ }
56
+
57
+ function tableExists(database: Database.Database, table: string): boolean {
58
+ const row = database
59
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
60
+ .get(table) as { name: string } | undefined;
61
+ return Boolean(row?.name);
62
+ }
63
+
64
+ function columnExists(
65
+ database: Database.Database,
66
+ table: string,
67
+ column: string,
68
+ ): boolean {
69
+ const cols = database.pragma(`table_info(${table})`) as TableInfoRow[];
70
+ return cols.some((entry) => entry.name === column);
71
+ }
72
+
73
+ function addColumnIfMissing(params: {
74
+ database: Database.Database;
75
+ table: string;
76
+ column: string;
77
+ ddl: string;
78
+ quiet: boolean;
79
+ }): void {
80
+ if (!tableExists(params.database, params.table)) return;
81
+ if (columnExists(params.database, params.table, params.column)) return;
82
+ params.database.exec(`ALTER TABLE ${params.table} ADD COLUMN ${params.ddl}`);
83
+ if (!params.quiet) {
84
+ logger.info(
85
+ { table: params.table, column: params.column },
86
+ 'Migrated table: added column',
87
+ );
88
+ }
89
+ }
90
+
91
+ function ensureMigrationTable(database: Database.Database): void {
92
+ database.exec(`
93
+ CREATE TABLE IF NOT EXISTS migrations (
94
+ version INTEGER PRIMARY KEY,
95
+ applied_at TEXT NOT NULL,
96
+ description TEXT
97
+ );
98
+ `);
99
+ }
100
+
101
+ function recordMigration(
102
+ database: Database.Database,
103
+ version: number,
104
+ description: string,
105
+ ): void {
106
+ ensureMigrationTable(database);
107
+ database
108
+ .prepare(
109
+ `INSERT OR IGNORE INTO migrations (version, applied_at, description)
110
+ VALUES (?, datetime('now'), ?)`,
111
+ )
112
+ .run(version, description);
113
+ }
114
+
115
+ function migrateV1(database: Database.Database): void {
20
116
  database.exec(`
21
117
  CREATE TABLE IF NOT EXISTS sessions (
22
118
  id TEXT PRIMARY KEY,
@@ -45,14 +141,45 @@ function createSchema(database: Database.Database): void {
45
141
  );
46
142
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
47
143
 
144
+ CREATE TABLE IF NOT EXISTS semantic_memories (
145
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
146
+ session_id TEXT NOT NULL,
147
+ role TEXT NOT NULL,
148
+ source TEXT NOT NULL DEFAULT 'conversation',
149
+ scope TEXT NOT NULL DEFAULT 'episodic',
150
+ metadata TEXT NOT NULL DEFAULT '{}',
151
+ content TEXT NOT NULL,
152
+ confidence REAL NOT NULL DEFAULT 1.0,
153
+ embedding BLOB,
154
+ source_message_id INTEGER,
155
+ created_at TEXT DEFAULT (datetime('now')),
156
+ accessed_at TEXT DEFAULT (datetime('now')),
157
+ access_count INTEGER NOT NULL DEFAULT 0,
158
+ deleted INTEGER NOT NULL DEFAULT 0
159
+ );
160
+
161
+ CREATE TABLE IF NOT EXISTS kv_store (
162
+ agent_id TEXT NOT NULL,
163
+ key TEXT NOT NULL,
164
+ value BLOB NOT NULL,
165
+ version INTEGER NOT NULL DEFAULT 1,
166
+ updated_at TEXT NOT NULL,
167
+ PRIMARY KEY (agent_id, key)
168
+ );
169
+ CREATE INDEX IF NOT EXISTS idx_kv_store_agent ON kv_store(agent_id);
170
+
48
171
  CREATE TABLE IF NOT EXISTS tasks (
49
172
  id INTEGER PRIMARY KEY AUTOINCREMENT,
50
173
  session_id TEXT NOT NULL,
51
174
  channel_id TEXT NOT NULL,
52
175
  cron_expr TEXT NOT NULL,
176
+ run_at TEXT,
177
+ every_ms INTEGER,
53
178
  prompt TEXT NOT NULL,
54
179
  enabled INTEGER DEFAULT 1,
55
180
  last_run TEXT,
181
+ last_status TEXT,
182
+ consecutive_errors INTEGER DEFAULT 0,
56
183
  created_at TEXT DEFAULT (datetime('now'))
57
184
  );
58
185
 
@@ -119,57 +246,1329 @@ function createSchema(database: Database.Database): void {
119
246
  queued_at TEXT DEFAULT (datetime('now'))
120
247
  );
121
248
  CREATE INDEX IF NOT EXISTS idx_proactive_queue_id ON proactive_message_queue(id);
249
+
250
+ CREATE TABLE IF NOT EXISTS migrations (
251
+ version INTEGER PRIMARY KEY,
252
+ applied_at TEXT NOT NULL,
253
+ description TEXT
254
+ );
122
255
  `);
256
+ recordMigration(database, 1, 'Initial schema');
123
257
  }
124
258
 
125
- interface InitDatabaseOptions {
126
- quiet?: boolean;
259
+ function migrateV2(
260
+ database: Database.Database,
261
+ opts?: InitDatabaseOptions,
262
+ ): void {
263
+ const quiet = opts?.quiet === true;
264
+ addColumnIfMissing({
265
+ database,
266
+ table: 'sessions',
267
+ column: 'model',
268
+ ddl: 'model TEXT',
269
+ quiet,
270
+ });
271
+ addColumnIfMissing({
272
+ database,
273
+ table: 'sessions',
274
+ column: 'session_summary',
275
+ ddl: 'session_summary TEXT',
276
+ quiet,
277
+ });
278
+ addColumnIfMissing({
279
+ database,
280
+ table: 'sessions',
281
+ column: 'summary_updated_at',
282
+ ddl: 'summary_updated_at TEXT',
283
+ quiet,
284
+ });
285
+ addColumnIfMissing({
286
+ database,
287
+ table: 'sessions',
288
+ column: 'compaction_count',
289
+ ddl: 'compaction_count INTEGER DEFAULT 0',
290
+ quiet,
291
+ });
292
+ addColumnIfMissing({
293
+ database,
294
+ table: 'sessions',
295
+ column: 'memory_flush_at',
296
+ ddl: 'memory_flush_at TEXT',
297
+ quiet,
298
+ });
299
+
300
+ addColumnIfMissing({
301
+ database,
302
+ table: 'tasks',
303
+ column: 'run_at',
304
+ ddl: 'run_at TEXT',
305
+ quiet,
306
+ });
307
+ addColumnIfMissing({
308
+ database,
309
+ table: 'tasks',
310
+ column: 'every_ms',
311
+ ddl: 'every_ms INTEGER',
312
+ quiet,
313
+ });
314
+ addColumnIfMissing({
315
+ database,
316
+ table: 'tasks',
317
+ column: 'last_status',
318
+ ddl: 'last_status TEXT',
319
+ quiet,
320
+ });
321
+ addColumnIfMissing({
322
+ database,
323
+ table: 'tasks',
324
+ column: 'consecutive_errors',
325
+ ddl: 'consecutive_errors INTEGER DEFAULT 0',
326
+ quiet,
327
+ });
328
+
329
+ addColumnIfMissing({
330
+ database,
331
+ table: 'semantic_memories',
332
+ column: 'embedding',
333
+ ddl: 'embedding BLOB',
334
+ quiet,
335
+ });
336
+ addColumnIfMissing({
337
+ database,
338
+ table: 'semantic_memories',
339
+ column: 'source',
340
+ ddl: "source TEXT NOT NULL DEFAULT 'conversation'",
341
+ quiet,
342
+ });
343
+ addColumnIfMissing({
344
+ database,
345
+ table: 'semantic_memories',
346
+ column: 'scope',
347
+ ddl: "scope TEXT NOT NULL DEFAULT 'episodic'",
348
+ quiet,
349
+ });
350
+ addColumnIfMissing({
351
+ database,
352
+ table: 'semantic_memories',
353
+ column: 'metadata',
354
+ ddl: "metadata TEXT NOT NULL DEFAULT '{}'",
355
+ quiet,
356
+ });
357
+ addColumnIfMissing({
358
+ database,
359
+ table: 'semantic_memories',
360
+ column: 'deleted',
361
+ ddl: 'deleted INTEGER NOT NULL DEFAULT 0',
362
+ quiet,
363
+ });
364
+
365
+ // Semantic indexes are created after column migrations so older DBs can boot.
366
+ database.exec(`
367
+ CREATE INDEX IF NOT EXISTS idx_semantic_memories_session ON semantic_memories(session_id);
368
+ CREATE INDEX IF NOT EXISTS idx_semantic_memories_scope ON semantic_memories(scope);
369
+ CREATE INDEX IF NOT EXISTS idx_semantic_memories_confidence ON semantic_memories(confidence);
370
+ CREATE INDEX IF NOT EXISTS idx_semantic_memories_accessed ON semantic_memories(accessed_at);
371
+ CREATE INDEX IF NOT EXISTS idx_semantic_memories_deleted ON semantic_memories(deleted);
372
+ `);
373
+
374
+ if (tableExists(database, 'memory_kv')) {
375
+ database.exec(
376
+ `INSERT OR IGNORE INTO kv_store (agent_id, key, value, version, updated_at)
377
+ SELECT session_id,
378
+ mem_key,
379
+ CAST(value_json AS BLOB),
380
+ 1,
381
+ COALESCE(updated_at, datetime('now'))
382
+ FROM memory_kv`,
383
+ );
384
+ if (!quiet) logger.info('Migrated legacy memory_kv rows into kv_store');
385
+ }
386
+
387
+ recordMigration(
388
+ database,
389
+ 2,
390
+ 'Backfill legacy columns/indexes and migrate memory_kv to kv_store',
391
+ );
127
392
  }
128
393
 
129
- function migrateSchema(database: Database.Database, opts?: InitDatabaseOptions): void {
394
+ function migrateV3(database: Database.Database): void {
395
+ database.exec(`
396
+ CREATE TABLE IF NOT EXISTS entities (
397
+ id TEXT PRIMARY KEY,
398
+ entity_type TEXT NOT NULL,
399
+ name TEXT NOT NULL,
400
+ properties TEXT NOT NULL DEFAULT '{}',
401
+ created_at TEXT NOT NULL,
402
+ updated_at TEXT NOT NULL
403
+ );
404
+
405
+ CREATE TABLE IF NOT EXISTS relations (
406
+ id TEXT PRIMARY KEY,
407
+ source_entity TEXT NOT NULL,
408
+ relation_type TEXT NOT NULL,
409
+ target_entity TEXT NOT NULL,
410
+ properties TEXT NOT NULL DEFAULT '{}',
411
+ confidence REAL NOT NULL DEFAULT 1.0,
412
+ created_at TEXT NOT NULL
413
+ );
414
+ CREATE INDEX IF NOT EXISTS idx_relations_source ON relations(source_entity);
415
+ CREATE INDEX IF NOT EXISTS idx_relations_target ON relations(target_entity);
416
+ CREATE INDEX IF NOT EXISTS idx_relations_type ON relations(relation_type);
417
+ `);
418
+
419
+ recordMigration(
420
+ database,
421
+ 3,
422
+ 'Add knowledge graph entities/relations tables and indexes',
423
+ );
424
+ }
425
+
426
+ function migrateV4(database: Database.Database): void {
427
+ database.exec(`
428
+ CREATE TABLE IF NOT EXISTS canonical_sessions (
429
+ canonical_id TEXT PRIMARY KEY,
430
+ agent_id TEXT NOT NULL,
431
+ user_id TEXT NOT NULL,
432
+ messages TEXT NOT NULL DEFAULT '[]',
433
+ compaction_cursor INTEGER NOT NULL DEFAULT 0,
434
+ compacted_summary TEXT,
435
+ message_count INTEGER NOT NULL DEFAULT 0,
436
+ created_at TEXT NOT NULL,
437
+ updated_at TEXT NOT NULL,
438
+ UNIQUE(agent_id, user_id)
439
+ );
440
+ CREATE INDEX IF NOT EXISTS idx_canonical_sessions_agent_user ON canonical_sessions(agent_id, user_id);
441
+ CREATE INDEX IF NOT EXISTS idx_canonical_sessions_updated ON canonical_sessions(updated_at);
442
+
443
+ CREATE TABLE IF NOT EXISTS usage_events (
444
+ id TEXT PRIMARY KEY,
445
+ session_id TEXT NOT NULL,
446
+ agent_id TEXT NOT NULL,
447
+ timestamp TEXT NOT NULL,
448
+ model TEXT NOT NULL,
449
+ input_tokens INTEGER NOT NULL DEFAULT 0,
450
+ output_tokens INTEGER NOT NULL DEFAULT 0,
451
+ total_tokens INTEGER NOT NULL DEFAULT 0,
452
+ cost_usd REAL NOT NULL DEFAULT 0.0,
453
+ tool_calls INTEGER NOT NULL DEFAULT 0
454
+ );
455
+ CREATE INDEX IF NOT EXISTS idx_usage_events_agent_time ON usage_events(agent_id, timestamp);
456
+ CREATE INDEX IF NOT EXISTS idx_usage_events_time ON usage_events(timestamp);
457
+ CREATE INDEX IF NOT EXISTS idx_usage_events_model_time ON usage_events(model, timestamp);
458
+ CREATE INDEX IF NOT EXISTS idx_usage_events_session_time ON usage_events(session_id, timestamp);
459
+ `);
460
+
461
+ recordMigration(
462
+ database,
463
+ 4,
464
+ 'Add canonical_sessions and usage_events tables',
465
+ );
466
+ }
467
+
468
+ function runMigrations(
469
+ database: Database.Database,
470
+ opts?: InitDatabaseOptions,
471
+ ): void {
472
+ const currentVersion = getSchemaVersion(database);
130
473
  const quiet = opts?.quiet === true;
131
- const addColumnIfMissing = (table: string, column: string, ddl: string): void => {
132
- const cols = database.pragma(`table_info(${table})`) as Array<{ name: string }>;
133
- if (!cols.some((c) => c.name === column)) {
134
- database.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
135
- if (!quiet) logger.info({ table, column }, 'Migrated table: added column');
474
+ if (currentVersion > SCHEMA_VERSION) {
475
+ if (!quiet) {
476
+ logger.warn(
477
+ { currentVersion, supportedVersion: SCHEMA_VERSION },
478
+ 'Database schema version is newer than this binary supports; skipping migrations',
479
+ );
136
480
  }
137
- };
138
-
139
- // Add session columns if they don't exist
140
- const sessionCols = database.pragma('table_info(sessions)') as Array<{ name: string }>;
141
- if (!sessionCols.some((c) => c.name === 'model')) {
142
- database.exec('ALTER TABLE sessions ADD COLUMN model TEXT');
143
- if (!quiet) logger.info('Migrated sessions table: added model column');
481
+ return;
144
482
  }
145
- addColumnIfMissing('sessions', 'session_summary', 'session_summary TEXT');
146
- addColumnIfMissing('sessions', 'summary_updated_at', 'summary_updated_at TEXT');
147
- addColumnIfMissing('sessions', 'compaction_count', 'compaction_count INTEGER DEFAULT 0');
148
- addColumnIfMissing('sessions', 'memory_flush_at', 'memory_flush_at TEXT');
149
483
 
150
- // Add run_at and every_ms columns to tasks if they don't exist
151
- const taskCols = database.pragma('table_info(tasks)') as Array<{ name: string }>;
152
- if (!taskCols.some((c) => c.name === 'run_at')) {
153
- database.exec('ALTER TABLE tasks ADD COLUMN run_at TEXT');
154
- if (!quiet) logger.info('Migrated tasks table: added run_at column');
155
- }
156
- if (!taskCols.some((c) => c.name === 'every_ms')) {
157
- database.exec('ALTER TABLE tasks ADD COLUMN every_ms INTEGER');
158
- if (!quiet) logger.info('Migrated tasks table: added every_ms column');
484
+ if (currentVersion < 1) migrateV1(database);
485
+ if (currentVersion < 2) migrateV2(database, opts);
486
+ if (currentVersion < 3) migrateV3(database);
487
+ if (currentVersion < 4) migrateV4(database);
488
+
489
+ setSchemaVersion(database, SCHEMA_VERSION);
490
+ if (!quiet && currentVersion < SCHEMA_VERSION) {
491
+ logger.info(
492
+ { fromVersion: currentVersion, toVersion: SCHEMA_VERSION },
493
+ 'Database schema migrated',
494
+ );
159
495
  }
160
496
  }
161
497
 
162
498
  export function initDatabase(opts?: InitDatabaseOptions): void {
163
499
  const quiet = opts?.quiet === true;
164
- const dbPath = path.resolve(DB_PATH);
500
+ const dbPath = path.resolve(opts?.dbPath || DB_PATH);
165
501
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
166
502
  db = new Database(dbPath);
167
503
  db.pragma('journal_mode = WAL');
168
- createSchema(db);
169
- migrateSchema(db, opts);
504
+ db.pragma('busy_timeout = 5000');
505
+ runMigrations(db, opts);
170
506
  if (!quiet) logger.info({ path: dbPath }, 'Database initialized');
171
507
  }
172
508
 
509
+ // --- Structured Memory (KV) ---
510
+
511
+ interface MemoryKvRow {
512
+ agent_id: string;
513
+ key: string;
514
+ value: Buffer | Uint8Array | string;
515
+ version: number;
516
+ updated_at: string;
517
+ }
518
+
519
+ function normalizeMemoryKvKey(key: string): string {
520
+ return key.trim();
521
+ }
522
+
523
+ function serializeMemoryKvValue(value: unknown): Buffer {
524
+ if (typeof value === 'undefined') return Buffer.from('null', 'utf8');
525
+ try {
526
+ const serialized = JSON.stringify(value);
527
+ return Buffer.from(
528
+ typeof serialized === 'string' ? serialized : 'null',
529
+ 'utf8',
530
+ );
531
+ } catch {
532
+ return Buffer.from('null', 'utf8');
533
+ }
534
+ }
535
+
536
+ function parseMemoryKvValue(raw: unknown): unknown {
537
+ const text = Buffer.isBuffer(raw)
538
+ ? raw.toString('utf8')
539
+ : raw instanceof Uint8Array
540
+ ? Buffer.from(raw).toString('utf8')
541
+ : typeof raw === 'string'
542
+ ? raw
543
+ : null;
544
+ if (text == null) return null;
545
+ try {
546
+ return JSON.parse(text) as unknown;
547
+ } catch {
548
+ return text;
549
+ }
550
+ }
551
+
552
+ export function getMemoryValue(sessionId: string, key: string): unknown | null {
553
+ const normalizedKey = normalizeMemoryKvKey(key);
554
+ if (!normalizedKey) return null;
555
+ const row = db
556
+ .prepare(
557
+ `SELECT value
558
+ FROM kv_store
559
+ WHERE agent_id = ?
560
+ AND key = ?`,
561
+ )
562
+ .get(sessionId, normalizedKey) as
563
+ | { value: Buffer | Uint8Array | string }
564
+ | undefined;
565
+ if (!row) return null;
566
+ return parseMemoryKvValue(row.value);
567
+ }
568
+
569
+ export function setMemoryValue(
570
+ sessionId: string,
571
+ key: string,
572
+ value: unknown,
573
+ ): void {
574
+ const normalizedKey = normalizeMemoryKvKey(key);
575
+ if (!normalizedKey) return;
576
+ const valueBlob = serializeMemoryKvValue(value);
577
+ const now = new Date().toISOString();
578
+ db.prepare(
579
+ `INSERT INTO kv_store (agent_id, key, value, version, updated_at)
580
+ VALUES (?, ?, ?, 1, ?)
581
+ ON CONFLICT(agent_id, key)
582
+ DO UPDATE SET value = excluded.value, version = version + 1, updated_at = excluded.updated_at`,
583
+ ).run(sessionId, normalizedKey, valueBlob, now);
584
+ }
585
+
586
+ export function deleteMemoryValue(sessionId: string, key: string): boolean {
587
+ const normalizedKey = normalizeMemoryKvKey(key);
588
+ if (!normalizedKey) return false;
589
+ const result = db
590
+ .prepare(
591
+ `DELETE FROM kv_store
592
+ WHERE agent_id = ?
593
+ AND key = ?`,
594
+ )
595
+ .run(sessionId, normalizedKey);
596
+ return result.changes > 0;
597
+ }
598
+
599
+ export function listMemoryValues(
600
+ sessionId: string,
601
+ prefix?: string,
602
+ ): Array<{
603
+ agent_id: string;
604
+ key: string;
605
+ value: unknown;
606
+ version: number;
607
+ updated_at: string;
608
+ }> {
609
+ const normalizedPrefix = (prefix || '').trim();
610
+ const rows = normalizedPrefix
611
+ ? (db
612
+ .prepare(
613
+ `SELECT agent_id, key, value, version, updated_at
614
+ FROM kv_store
615
+ WHERE agent_id = ?
616
+ AND key LIKE ?
617
+ ORDER BY key ASC`,
618
+ )
619
+ .all(sessionId, `${normalizedPrefix}%`) as MemoryKvRow[])
620
+ : (db
621
+ .prepare(
622
+ `SELECT agent_id, key, value, version, updated_at
623
+ FROM kv_store
624
+ WHERE agent_id = ?
625
+ ORDER BY key ASC`,
626
+ )
627
+ .all(sessionId) as MemoryKvRow[]);
628
+
629
+ return rows.map((row) => ({
630
+ agent_id: row.agent_id,
631
+ key: row.key,
632
+ value: parseMemoryKvValue(row.value),
633
+ version: row.version,
634
+ updated_at: row.updated_at,
635
+ }));
636
+ }
637
+
638
+ // --- Canonical Sessions (Cross-Channel Memory) ---
639
+
640
+ const DEFAULT_CANONICAL_WINDOW = 50;
641
+ const DEFAULT_CANONICAL_COMPACTION_THRESHOLD = 100;
642
+ const CANONICAL_SUMMARY_MAX_CHARS = 4_000;
643
+ const CANONICAL_MESSAGE_MAX_CHARS = 220;
644
+
645
+ function canonicalSessionId(agentId: string, userId: string): string {
646
+ return `${agentId}:${userId}`;
647
+ }
648
+
649
+ function normalizeCanonicalRole(role: string): string {
650
+ const normalized = role.trim().toLowerCase();
651
+ if (
652
+ normalized === 'user' ||
653
+ normalized === 'assistant' ||
654
+ normalized === 'system' ||
655
+ normalized === 'tool'
656
+ ) {
657
+ return normalized;
658
+ }
659
+ return 'user';
660
+ }
661
+
662
+ function truncateCanonicalContent(content: string): string {
663
+ const compact = content.replace(/\s+/g, ' ').trim();
664
+ if (compact.length <= CANONICAL_MESSAGE_MAX_CHARS) return compact;
665
+ return `${compact.slice(0, CANONICAL_MESSAGE_MAX_CHARS)}...`;
666
+ }
667
+
668
+ function parseCanonicalMessages(raw: unknown): CanonicalSessionMessage[] {
669
+ const text = typeof raw === 'string' ? raw.trim() : '';
670
+ if (!text) return [];
671
+ try {
672
+ const parsed = JSON.parse(text) as unknown;
673
+ if (!Array.isArray(parsed)) return [];
674
+ const messages: CanonicalSessionMessage[] = [];
675
+ for (const item of parsed) {
676
+ if (!item || typeof item !== 'object') continue;
677
+ const row = item as Partial<CanonicalSessionMessage>;
678
+ const content = typeof row.content === 'string' ? row.content.trim() : '';
679
+ if (!content) continue;
680
+ const sessionId =
681
+ typeof row.session_id === 'string' ? row.session_id.trim() : '';
682
+ if (!sessionId) continue;
683
+ const createdAt =
684
+ typeof row.created_at === 'string' && row.created_at.trim()
685
+ ? row.created_at.trim()
686
+ : new Date().toISOString();
687
+ messages.push({
688
+ role: normalizeCanonicalRole(
689
+ typeof row.role === 'string' ? row.role : 'user',
690
+ ),
691
+ content,
692
+ session_id: sessionId,
693
+ channel_id:
694
+ typeof row.channel_id === 'string' && row.channel_id.trim()
695
+ ? row.channel_id.trim()
696
+ : null,
697
+ created_at: createdAt,
698
+ });
699
+ }
700
+ return messages;
701
+ } catch {
702
+ return [];
703
+ }
704
+ }
705
+
706
+ function serializeCanonicalMessages(
707
+ messages: CanonicalSessionMessage[],
708
+ ): string {
709
+ try {
710
+ return JSON.stringify(messages);
711
+ } catch {
712
+ return '[]';
713
+ }
714
+ }
715
+
716
+ function buildCanonicalSummary(params: {
717
+ previousSummary: string | null;
718
+ compactingMessages: CanonicalSessionMessage[];
719
+ }): string | null {
720
+ const lines: string[] = [];
721
+ const previous = (params.previousSummary || '').trim();
722
+ if (previous) lines.push(previous);
723
+ for (const message of params.compactingMessages) {
724
+ const role =
725
+ message.role === 'assistant'
726
+ ? 'Assistant'
727
+ : message.role === 'system'
728
+ ? 'System'
729
+ : message.role === 'tool'
730
+ ? 'Tool'
731
+ : 'User';
732
+ const compact = truncateCanonicalContent(message.content);
733
+ if (!compact) continue;
734
+ lines.push(`${role}: ${compact}`);
735
+ }
736
+ if (lines.length === 0) return previous || null;
737
+ const merged = lines.join('\n');
738
+ if (merged.length <= CANONICAL_SUMMARY_MAX_CHARS) return merged;
739
+ return merged.slice(Math.max(0, merged.length - CANONICAL_SUMMARY_MAX_CHARS));
740
+ }
741
+
742
+ interface CanonicalSessionRow {
743
+ canonical_id: string;
744
+ agent_id: string;
745
+ user_id: string;
746
+ messages: string;
747
+ compaction_cursor: number;
748
+ compacted_summary: string | null;
749
+ message_count: number;
750
+ created_at: string;
751
+ updated_at: string;
752
+ }
753
+
754
+ function saveCanonicalSession(session: CanonicalSession): void {
755
+ db.prepare(
756
+ `INSERT INTO canonical_sessions
757
+ (canonical_id, agent_id, user_id, messages, compaction_cursor, compacted_summary, message_count, created_at, updated_at)
758
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
759
+ ON CONFLICT(canonical_id) DO UPDATE SET
760
+ messages = excluded.messages,
761
+ compaction_cursor = excluded.compaction_cursor,
762
+ compacted_summary = excluded.compacted_summary,
763
+ message_count = excluded.message_count,
764
+ updated_at = excluded.updated_at`,
765
+ ).run(
766
+ session.canonical_id,
767
+ session.agent_id,
768
+ session.user_id,
769
+ serializeCanonicalMessages(session.messages),
770
+ Math.max(0, Math.floor(session.compaction_cursor)),
771
+ session.compacted_summary,
772
+ Math.max(0, Math.floor(session.message_count)),
773
+ session.created_at,
774
+ session.updated_at,
775
+ );
776
+ }
777
+
778
+ export function loadCanonicalSession(
779
+ agentId: string,
780
+ userId: string,
781
+ ): CanonicalSession {
782
+ const normalizedAgentId = agentId.trim();
783
+ const normalizedUserId = userId.trim();
784
+ if (!normalizedAgentId) {
785
+ throw new Error('Canonical session agentId is required');
786
+ }
787
+ if (!normalizedUserId) {
788
+ throw new Error('Canonical session userId is required');
789
+ }
790
+ const row = db
791
+ .prepare(
792
+ `SELECT canonical_id, agent_id, user_id, messages, compaction_cursor, compacted_summary, message_count, created_at, updated_at
793
+ FROM canonical_sessions
794
+ WHERE agent_id = ?
795
+ AND user_id = ?
796
+ LIMIT 1`,
797
+ )
798
+ .get(normalizedAgentId, normalizedUserId) as
799
+ | CanonicalSessionRow
800
+ | undefined;
801
+
802
+ const now = new Date().toISOString();
803
+ if (!row) {
804
+ return {
805
+ canonical_id: canonicalSessionId(normalizedAgentId, normalizedUserId),
806
+ agent_id: normalizedAgentId,
807
+ user_id: normalizedUserId,
808
+ messages: [],
809
+ compaction_cursor: 0,
810
+ compacted_summary: null,
811
+ message_count: 0,
812
+ created_at: now,
813
+ updated_at: now,
814
+ };
815
+ }
816
+
817
+ return {
818
+ canonical_id: row.canonical_id,
819
+ agent_id: row.agent_id,
820
+ user_id: row.user_id,
821
+ messages: parseCanonicalMessages(row.messages),
822
+ compaction_cursor: Math.max(0, Math.floor(row.compaction_cursor || 0)),
823
+ compacted_summary: row.compacted_summary,
824
+ message_count: Math.max(0, Math.floor(row.message_count || 0)),
825
+ created_at: row.created_at || now,
826
+ updated_at: row.updated_at || now,
827
+ };
828
+ }
829
+
830
+ export function appendCanonicalMessages(params: {
831
+ agentId: string;
832
+ userId: string;
833
+ newMessages: Array<{
834
+ role: string;
835
+ content: string;
836
+ sessionId: string;
837
+ channelId?: string | null;
838
+ createdAt?: string | null;
839
+ }>;
840
+ windowSize?: number;
841
+ compactionThreshold?: number;
842
+ }): CanonicalSession {
843
+ const canonical = loadCanonicalSession(params.agentId, params.userId);
844
+ const normalizedMessages = params.newMessages
845
+ .map((entry) => {
846
+ const content = entry.content.trim();
847
+ const sessionId = entry.sessionId.trim();
848
+ if (!content || !sessionId) return null;
849
+ return {
850
+ role: normalizeCanonicalRole(entry.role),
851
+ content,
852
+ session_id: sessionId,
853
+ channel_id:
854
+ typeof entry.channelId === 'string' && entry.channelId.trim()
855
+ ? entry.channelId.trim()
856
+ : null,
857
+ created_at:
858
+ typeof entry.createdAt === 'string' && entry.createdAt.trim()
859
+ ? entry.createdAt.trim()
860
+ : new Date().toISOString(),
861
+ } satisfies CanonicalSessionMessage;
862
+ })
863
+ .filter((entry): entry is CanonicalSessionMessage => Boolean(entry));
864
+
865
+ if (normalizedMessages.length === 0) return canonical;
866
+
867
+ canonical.messages.push(...normalizedMessages);
868
+ canonical.message_count += normalizedMessages.length;
869
+
870
+ const windowSize = Math.max(
871
+ 1,
872
+ Math.floor(params.windowSize || DEFAULT_CANONICAL_WINDOW),
873
+ );
874
+ const compactionThreshold = Math.max(
875
+ windowSize + 1,
876
+ Math.floor(
877
+ params.compactionThreshold || DEFAULT_CANONICAL_COMPACTION_THRESHOLD,
878
+ ),
879
+ );
880
+
881
+ if (canonical.messages.length > compactionThreshold) {
882
+ const toCompact = canonical.messages.length - windowSize;
883
+ if (toCompact > canonical.compaction_cursor) {
884
+ const compacting = canonical.messages.slice(
885
+ canonical.compaction_cursor,
886
+ toCompact,
887
+ );
888
+ canonical.compacted_summary = buildCanonicalSummary({
889
+ previousSummary: canonical.compacted_summary,
890
+ compactingMessages: compacting,
891
+ });
892
+ canonical.compaction_cursor = toCompact;
893
+ canonical.messages = canonical.messages.slice(toCompact);
894
+ canonical.compaction_cursor = 0;
895
+ }
896
+ }
897
+
898
+ canonical.updated_at = new Date().toISOString();
899
+ saveCanonicalSession(canonical);
900
+ return canonical;
901
+ }
902
+
903
+ export function getCanonicalContext(params: {
904
+ agentId: string;
905
+ userId: string;
906
+ windowSize?: number;
907
+ excludeSessionId?: string | null;
908
+ }): CanonicalSessionContext {
909
+ const canonical = loadCanonicalSession(params.agentId, params.userId);
910
+ const windowSize = Math.max(
911
+ 1,
912
+ Math.floor(params.windowSize || DEFAULT_CANONICAL_WINDOW),
913
+ );
914
+ const start = Math.max(0, canonical.messages.length - windowSize);
915
+ const recent = canonical.messages.slice(start);
916
+ const excludeSessionId =
917
+ typeof params.excludeSessionId === 'string'
918
+ ? params.excludeSessionId.trim()
919
+ : '';
920
+ const filtered = excludeSessionId
921
+ ? recent.filter((message) => message.session_id !== excludeSessionId)
922
+ : recent;
923
+ return {
924
+ summary: canonical.compacted_summary,
925
+ recent_messages: filtered,
926
+ };
927
+ }
928
+
929
+ // --- Usage Tracking / Aggregation ---
930
+
931
+ function normalizeUsageWindow(window: UsageWindow | undefined): UsageWindow {
932
+ if (window === 'daily' || window === 'monthly' || window === 'all') {
933
+ return window;
934
+ }
935
+ return 'all';
936
+ }
937
+
938
+ function usageWindowWhereClause(window: UsageWindow): string | null {
939
+ if (window === 'daily') {
940
+ return "timestamp >= datetime('now', 'start of day')";
941
+ }
942
+ if (window === 'monthly') {
943
+ return "timestamp >= datetime('now', 'start of month')";
944
+ }
945
+ return null;
946
+ }
947
+
948
+ function normalizeUsageNumber(value: unknown): number {
949
+ if (typeof value === 'number' && Number.isFinite(value)) {
950
+ return Math.max(0, Math.floor(value));
951
+ }
952
+ return 0;
953
+ }
954
+
955
+ function normalizeUsageCost(value: unknown): number {
956
+ if (typeof value === 'number' && Number.isFinite(value)) {
957
+ return Math.max(0, value);
958
+ }
959
+ return 0;
960
+ }
961
+
962
+ function applyUsageFilters(params: {
963
+ whereClauses: string[];
964
+ args: unknown[];
965
+ agentId?: string;
966
+ window?: UsageWindow;
967
+ }): void {
968
+ const agentId = params.agentId?.trim();
969
+ if (agentId) {
970
+ params.whereClauses.push('agent_id = ?');
971
+ params.args.push(agentId);
972
+ }
973
+ const window = normalizeUsageWindow(params.window);
974
+ const windowClause = usageWindowWhereClause(window);
975
+ if (windowClause) params.whereClauses.push(windowClause);
976
+ }
977
+
978
+ export function recordUsageEvent(params: {
979
+ sessionId: string;
980
+ agentId: string;
981
+ model: string;
982
+ inputTokens: number;
983
+ outputTokens: number;
984
+ totalTokens?: number;
985
+ toolCalls?: number;
986
+ costUsd?: number;
987
+ timestamp?: string;
988
+ }): void {
989
+ const sessionId = params.sessionId.trim();
990
+ const agentId = params.agentId.trim();
991
+ const model = params.model.trim() || 'unknown';
992
+ if (!sessionId || !agentId) return;
993
+ const inputTokens = normalizeUsageNumber(params.inputTokens);
994
+ const outputTokens = normalizeUsageNumber(params.outputTokens);
995
+ const totalTokens = normalizeUsageNumber(
996
+ params.totalTokens ?? inputTokens + outputTokens,
997
+ );
998
+ const toolCalls = normalizeUsageNumber(params.toolCalls);
999
+ const costUsd = normalizeUsageCost(params.costUsd);
1000
+ const timestamp =
1001
+ typeof params.timestamp === 'string' && params.timestamp.trim()
1002
+ ? params.timestamp.trim()
1003
+ : new Date().toISOString();
1004
+
1005
+ db.prepare(
1006
+ `INSERT INTO usage_events
1007
+ (id, session_id, agent_id, timestamp, model, input_tokens, output_tokens, total_tokens, cost_usd, tool_calls)
1008
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1009
+ ).run(
1010
+ randomUUID(),
1011
+ sessionId,
1012
+ agentId,
1013
+ timestamp,
1014
+ model,
1015
+ inputTokens,
1016
+ outputTokens,
1017
+ totalTokens,
1018
+ costUsd,
1019
+ toolCalls,
1020
+ );
1021
+ }
1022
+
1023
+ export function getUsageTotals(params?: {
1024
+ agentId?: string;
1025
+ window?: UsageWindow;
1026
+ }): UsageTotals {
1027
+ const whereClauses: string[] = [];
1028
+ const args: unknown[] = [];
1029
+ applyUsageFilters({
1030
+ whereClauses,
1031
+ args,
1032
+ agentId: params?.agentId,
1033
+ window: params?.window,
1034
+ });
1035
+ const where =
1036
+ whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
1037
+
1038
+ const row = db
1039
+ .prepare(
1040
+ `SELECT
1041
+ COALESCE(SUM(input_tokens), 0) AS total_input_tokens,
1042
+ COALESCE(SUM(output_tokens), 0) AS total_output_tokens,
1043
+ COALESCE(SUM(total_tokens), 0) AS total_tokens,
1044
+ COALESCE(SUM(cost_usd), 0.0) AS total_cost_usd,
1045
+ COUNT(*) AS call_count,
1046
+ COALESCE(SUM(tool_calls), 0) AS total_tool_calls
1047
+ FROM usage_events
1048
+ ${where}`,
1049
+ )
1050
+ .get(...args) as UsageTotals;
1051
+
1052
+ return {
1053
+ total_input_tokens: normalizeUsageNumber(row.total_input_tokens),
1054
+ total_output_tokens: normalizeUsageNumber(row.total_output_tokens),
1055
+ total_tokens: normalizeUsageNumber(row.total_tokens),
1056
+ total_cost_usd: normalizeUsageCost(row.total_cost_usd),
1057
+ call_count: normalizeUsageNumber(row.call_count),
1058
+ total_tool_calls: normalizeUsageNumber(row.total_tool_calls),
1059
+ };
1060
+ }
1061
+
1062
+ export function listUsageByModel(params?: {
1063
+ agentId?: string;
1064
+ window?: UsageWindow;
1065
+ }): UsageModelAggregate[] {
1066
+ const whereClauses: string[] = [];
1067
+ const args: unknown[] = [];
1068
+ applyUsageFilters({
1069
+ whereClauses,
1070
+ args,
1071
+ agentId: params?.agentId,
1072
+ window: params?.window,
1073
+ });
1074
+ const where =
1075
+ whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
1076
+ const rows = db
1077
+ .prepare(
1078
+ `SELECT
1079
+ model,
1080
+ COALESCE(SUM(input_tokens), 0) AS total_input_tokens,
1081
+ COALESCE(SUM(output_tokens), 0) AS total_output_tokens,
1082
+ COALESCE(SUM(total_tokens), 0) AS total_tokens,
1083
+ COALESCE(SUM(cost_usd), 0.0) AS total_cost_usd,
1084
+ COUNT(*) AS call_count,
1085
+ COALESCE(SUM(tool_calls), 0) AS total_tool_calls
1086
+ FROM usage_events
1087
+ ${where}
1088
+ GROUP BY model
1089
+ ORDER BY total_cost_usd DESC, total_tokens DESC, call_count DESC`,
1090
+ )
1091
+ .all(...args) as UsageModelAggregate[];
1092
+
1093
+ return rows.map((row) => ({
1094
+ model: row.model,
1095
+ total_input_tokens: normalizeUsageNumber(row.total_input_tokens),
1096
+ total_output_tokens: normalizeUsageNumber(row.total_output_tokens),
1097
+ total_tokens: normalizeUsageNumber(row.total_tokens),
1098
+ total_cost_usd: normalizeUsageCost(row.total_cost_usd),
1099
+ call_count: normalizeUsageNumber(row.call_count),
1100
+ total_tool_calls: normalizeUsageNumber(row.total_tool_calls),
1101
+ }));
1102
+ }
1103
+
1104
+ export function listUsageByAgent(params?: {
1105
+ window?: UsageWindow;
1106
+ }): UsageAgentAggregate[] {
1107
+ const whereClauses: string[] = [];
1108
+ const args: unknown[] = [];
1109
+ applyUsageFilters({
1110
+ whereClauses,
1111
+ args,
1112
+ window: params?.window,
1113
+ });
1114
+ const where =
1115
+ whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
1116
+ const rows = db
1117
+ .prepare(
1118
+ `SELECT
1119
+ agent_id,
1120
+ COALESCE(SUM(input_tokens), 0) AS total_input_tokens,
1121
+ COALESCE(SUM(output_tokens), 0) AS total_output_tokens,
1122
+ COALESCE(SUM(total_tokens), 0) AS total_tokens,
1123
+ COALESCE(SUM(cost_usd), 0.0) AS total_cost_usd,
1124
+ COUNT(*) AS call_count,
1125
+ COALESCE(SUM(tool_calls), 0) AS total_tool_calls
1126
+ FROM usage_events
1127
+ ${where}
1128
+ GROUP BY agent_id
1129
+ ORDER BY total_cost_usd DESC, total_tokens DESC, call_count DESC`,
1130
+ )
1131
+ .all(...args) as UsageAgentAggregate[];
1132
+
1133
+ return rows.map((row) => ({
1134
+ agent_id: row.agent_id,
1135
+ total_input_tokens: normalizeUsageNumber(row.total_input_tokens),
1136
+ total_output_tokens: normalizeUsageNumber(row.total_output_tokens),
1137
+ total_tokens: normalizeUsageNumber(row.total_tokens),
1138
+ total_cost_usd: normalizeUsageCost(row.total_cost_usd),
1139
+ call_count: normalizeUsageNumber(row.call_count),
1140
+ total_tool_calls: normalizeUsageNumber(row.total_tool_calls),
1141
+ }));
1142
+ }
1143
+
1144
+ export function listUsageDailyBreakdown(params?: {
1145
+ agentId?: string;
1146
+ days?: number;
1147
+ }): UsageDailyAggregate[] {
1148
+ const days = Math.max(1, Math.min(365, Math.floor(params?.days || 30)));
1149
+ const whereClauses: string[] = [
1150
+ `timestamp >= datetime('now', '-${days} days')`,
1151
+ ];
1152
+ const args: unknown[] = [];
1153
+ const agentId = params?.agentId?.trim();
1154
+ if (agentId) {
1155
+ whereClauses.push('agent_id = ?');
1156
+ args.push(agentId);
1157
+ }
1158
+ const rows = db
1159
+ .prepare(
1160
+ `SELECT
1161
+ date(timestamp) AS day,
1162
+ COALESCE(SUM(input_tokens), 0) AS total_input_tokens,
1163
+ COALESCE(SUM(output_tokens), 0) AS total_output_tokens,
1164
+ COALESCE(SUM(total_tokens), 0) AS total_tokens,
1165
+ COALESCE(SUM(cost_usd), 0.0) AS total_cost_usd,
1166
+ COUNT(*) AS call_count,
1167
+ COALESCE(SUM(tool_calls), 0) AS total_tool_calls
1168
+ FROM usage_events
1169
+ WHERE ${whereClauses.join(' AND ')}
1170
+ GROUP BY day
1171
+ ORDER BY day ASC`,
1172
+ )
1173
+ .all(...args) as UsageDailyAggregate[];
1174
+
1175
+ return rows.map((row) => ({
1176
+ day: row.day,
1177
+ total_input_tokens: normalizeUsageNumber(row.total_input_tokens),
1178
+ total_output_tokens: normalizeUsageNumber(row.total_output_tokens),
1179
+ total_tokens: normalizeUsageNumber(row.total_tokens),
1180
+ total_cost_usd: normalizeUsageCost(row.total_cost_usd),
1181
+ call_count: normalizeUsageNumber(row.call_count),
1182
+ total_tool_calls: normalizeUsageNumber(row.total_tool_calls),
1183
+ }));
1184
+ }
1185
+
1186
+ // --- Knowledge Graph ---
1187
+
1188
+ interface RawKnowledgeGraphRow {
1189
+ s_id: string;
1190
+ s_type: string;
1191
+ s_name: string;
1192
+ s_properties: string;
1193
+ s_created_at: string;
1194
+ s_updated_at: string;
1195
+ r_id: string;
1196
+ r_source: string;
1197
+ r_type: string;
1198
+ r_target: string;
1199
+ r_properties: string;
1200
+ r_confidence: number;
1201
+ r_created_at: string;
1202
+ t_id: string;
1203
+ t_type: string;
1204
+ t_name: string;
1205
+ t_properties: string;
1206
+ t_created_at: string;
1207
+ t_updated_at: string;
1208
+ }
1209
+
1210
+ function normalizeKnowledgeCustomValue(raw: string): string {
1211
+ const value = raw.trim().toLowerCase();
1212
+ return value || 'unknown';
1213
+ }
1214
+
1215
+ function normalizeEntityType(
1216
+ entityType: KnowledgeEntityTypeValue | string,
1217
+ ): KnowledgeEntityTypeValue {
1218
+ if (typeof entityType === 'object' && entityType) {
1219
+ if (typeof entityType.custom === 'string') {
1220
+ return { custom: normalizeKnowledgeCustomValue(entityType.custom) };
1221
+ }
1222
+ return { custom: 'unknown' };
1223
+ }
1224
+
1225
+ const normalized = normalizeKnowledgeCustomValue(entityType);
1226
+ switch (normalized) {
1227
+ case 'person':
1228
+ return KnowledgeEntityType.Person;
1229
+ case 'organization':
1230
+ case 'org':
1231
+ return KnowledgeEntityType.Organization;
1232
+ case 'project':
1233
+ return KnowledgeEntityType.Project;
1234
+ case 'concept':
1235
+ return KnowledgeEntityType.Concept;
1236
+ case 'event':
1237
+ return KnowledgeEntityType.Event;
1238
+ case 'location':
1239
+ return KnowledgeEntityType.Location;
1240
+ case 'document':
1241
+ case 'doc':
1242
+ return KnowledgeEntityType.Document;
1243
+ case 'tool':
1244
+ return KnowledgeEntityType.Tool;
1245
+ default:
1246
+ return { custom: normalized };
1247
+ }
1248
+ }
1249
+
1250
+ function normalizeRelationType(
1251
+ relation: KnowledgeRelationTypeValue | string,
1252
+ ): KnowledgeRelationTypeValue {
1253
+ if (typeof relation === 'object' && relation) {
1254
+ if (typeof relation.custom === 'string') {
1255
+ return { custom: normalizeKnowledgeCustomValue(relation.custom) };
1256
+ }
1257
+ return { custom: 'unknown' };
1258
+ }
1259
+
1260
+ const normalized = normalizeKnowledgeCustomValue(relation)
1261
+ .replace(/[\s-]+/g, '_')
1262
+ .replace(/_+/g, '_');
1263
+ switch (normalized) {
1264
+ case 'works_at':
1265
+ case 'worksat':
1266
+ return KnowledgeRelationType.WorksAt;
1267
+ case 'knows_about':
1268
+ case 'knowsabout':
1269
+ case 'knows':
1270
+ return KnowledgeRelationType.KnowsAbout;
1271
+ case 'related_to':
1272
+ case 'relatedto':
1273
+ case 'related':
1274
+ return KnowledgeRelationType.RelatedTo;
1275
+ case 'depends_on':
1276
+ case 'dependson':
1277
+ case 'depends':
1278
+ return KnowledgeRelationType.DependsOn;
1279
+ case 'owned_by':
1280
+ case 'ownedby':
1281
+ return KnowledgeRelationType.OwnedBy;
1282
+ case 'created_by':
1283
+ case 'createdby':
1284
+ return KnowledgeRelationType.CreatedBy;
1285
+ case 'located_in':
1286
+ case 'locatedin':
1287
+ return KnowledgeRelationType.LocatedIn;
1288
+ case 'part_of':
1289
+ case 'partof':
1290
+ return KnowledgeRelationType.PartOf;
1291
+ case 'uses':
1292
+ return KnowledgeRelationType.Uses;
1293
+ case 'produces':
1294
+ return KnowledgeRelationType.Produces;
1295
+ default:
1296
+ return { custom: normalized };
1297
+ }
1298
+ }
1299
+
1300
+ function serializeEntityType(
1301
+ entityType: KnowledgeEntityTypeValue | string,
1302
+ ): string {
1303
+ const normalized = normalizeEntityType(entityType);
1304
+ return typeof normalized === 'string'
1305
+ ? JSON.stringify(normalized)
1306
+ : JSON.stringify({ custom: normalized.custom });
1307
+ }
1308
+
1309
+ function serializeRelationType(
1310
+ relation: KnowledgeRelationTypeValue | string,
1311
+ ): string {
1312
+ const normalized = normalizeRelationType(relation);
1313
+ return typeof normalized === 'string'
1314
+ ? JSON.stringify(normalized)
1315
+ : JSON.stringify({ custom: normalized.custom });
1316
+ }
1317
+
1318
+ function parseEntityType(
1319
+ raw: string | null | undefined,
1320
+ ): KnowledgeEntityTypeValue {
1321
+ const value = (raw || '').trim();
1322
+ if (!value) return { custom: 'unknown' };
1323
+
1324
+ try {
1325
+ const parsed = JSON.parse(value) as unknown;
1326
+ if (typeof parsed === 'string') return normalizeEntityType(parsed);
1327
+ if (
1328
+ parsed &&
1329
+ typeof parsed === 'object' &&
1330
+ typeof (parsed as { custom?: unknown }).custom === 'string'
1331
+ ) {
1332
+ return normalizeEntityType({
1333
+ custom: (parsed as { custom: string }).custom,
1334
+ });
1335
+ }
1336
+ } catch {
1337
+ return normalizeEntityType(value);
1338
+ }
1339
+
1340
+ return { custom: 'unknown' };
1341
+ }
1342
+
1343
+ function parseRelationType(
1344
+ raw: string | null | undefined,
1345
+ ): KnowledgeRelationTypeValue {
1346
+ const value = (raw || '').trim();
1347
+ if (!value) return { custom: 'unknown' };
1348
+
1349
+ try {
1350
+ const parsed = JSON.parse(value) as unknown;
1351
+ if (typeof parsed === 'string') return normalizeRelationType(parsed);
1352
+ if (
1353
+ parsed &&
1354
+ typeof parsed === 'object' &&
1355
+ typeof (parsed as { custom?: unknown }).custom === 'string'
1356
+ ) {
1357
+ return normalizeRelationType({
1358
+ custom: (parsed as { custom: string }).custom,
1359
+ });
1360
+ }
1361
+ } catch {
1362
+ return normalizeRelationType(value);
1363
+ }
1364
+
1365
+ return { custom: 'unknown' };
1366
+ }
1367
+
1368
+ function serializeKnowledgeProperties(
1369
+ properties: Record<string, unknown> | null | undefined,
1370
+ ): string {
1371
+ if (
1372
+ !properties ||
1373
+ typeof properties !== 'object' ||
1374
+ Array.isArray(properties)
1375
+ ) {
1376
+ return '{}';
1377
+ }
1378
+ try {
1379
+ return JSON.stringify(properties);
1380
+ } catch {
1381
+ return '{}';
1382
+ }
1383
+ }
1384
+
1385
+ function parseKnowledgeProperties(raw: unknown): Record<string, unknown> {
1386
+ const text = Buffer.isBuffer(raw)
1387
+ ? raw.toString('utf8')
1388
+ : raw instanceof Uint8Array
1389
+ ? Buffer.from(raw).toString('utf8')
1390
+ : typeof raw === 'string'
1391
+ ? raw
1392
+ : '{}';
1393
+ try {
1394
+ const parsed = JSON.parse(text) as unknown;
1395
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
1396
+ return {};
1397
+ return parsed as Record<string, unknown>;
1398
+ } catch {
1399
+ return {};
1400
+ }
1401
+ }
1402
+
1403
+ function mapKnowledgeEntity(params: {
1404
+ id: string;
1405
+ entityTypeRaw: string;
1406
+ name: string;
1407
+ propertiesRaw: unknown;
1408
+ createdAt: string;
1409
+ updatedAt: string;
1410
+ }): KnowledgeEntity {
1411
+ return {
1412
+ id: params.id,
1413
+ entity_type: parseEntityType(params.entityTypeRaw),
1414
+ name: params.name,
1415
+ properties: parseKnowledgeProperties(params.propertiesRaw),
1416
+ created_at: params.createdAt,
1417
+ updated_at: params.updatedAt,
1418
+ };
1419
+ }
1420
+
1421
+ function mapKnowledgeMatchRow(row: RawKnowledgeGraphRow): KnowledgeGraphMatch {
1422
+ return {
1423
+ source: mapKnowledgeEntity({
1424
+ id: row.s_id,
1425
+ entityTypeRaw: row.s_type,
1426
+ name: row.s_name,
1427
+ propertiesRaw: row.s_properties,
1428
+ createdAt: row.s_created_at,
1429
+ updatedAt: row.s_updated_at,
1430
+ }),
1431
+ relation: {
1432
+ source: row.r_source,
1433
+ relation: parseRelationType(row.r_type),
1434
+ target: row.r_target,
1435
+ properties: parseKnowledgeProperties(row.r_properties),
1436
+ confidence: Math.max(0, Math.min(1, Number(row.r_confidence) || 0)),
1437
+ created_at: row.r_created_at,
1438
+ },
1439
+ target: mapKnowledgeEntity({
1440
+ id: row.t_id,
1441
+ entityTypeRaw: row.t_type,
1442
+ name: row.t_name,
1443
+ propertiesRaw: row.t_properties,
1444
+ createdAt: row.t_created_at,
1445
+ updatedAt: row.t_updated_at,
1446
+ }),
1447
+ };
1448
+ }
1449
+
1450
+ export function addKnowledgeEntity(params: {
1451
+ id?: string | null;
1452
+ name: string;
1453
+ entityType: KnowledgeEntityTypeValue | string;
1454
+ properties?: Record<string, unknown> | null;
1455
+ }): string {
1456
+ const name = params.name.trim();
1457
+ if (!name) throw new Error('Knowledge graph entity name is required');
1458
+
1459
+ const entityId = params.id?.trim() || randomUUID();
1460
+ const now = new Date().toISOString();
1461
+ db.prepare(
1462
+ `INSERT INTO entities (id, entity_type, name, properties, created_at, updated_at)
1463
+ VALUES (?, ?, ?, ?, ?, ?)
1464
+ ON CONFLICT(id) DO UPDATE SET
1465
+ name = excluded.name,
1466
+ properties = excluded.properties,
1467
+ updated_at = excluded.updated_at`,
1468
+ ).run(
1469
+ entityId,
1470
+ serializeEntityType(params.entityType),
1471
+ name,
1472
+ serializeKnowledgeProperties(params.properties),
1473
+ now,
1474
+ now,
1475
+ );
1476
+
1477
+ return entityId;
1478
+ }
1479
+
1480
+ export function addKnowledgeRelation(params: {
1481
+ source: string;
1482
+ relation: KnowledgeRelationTypeValue | string;
1483
+ target: string;
1484
+ properties?: Record<string, unknown> | null;
1485
+ confidence?: number;
1486
+ }): string {
1487
+ const source = params.source.trim();
1488
+ const target = params.target.trim();
1489
+ if (!source) throw new Error('Knowledge graph relation source is required');
1490
+ if (!target) throw new Error('Knowledge graph relation target is required');
1491
+
1492
+ const id = randomUUID();
1493
+ const rawConfidence =
1494
+ typeof params.confidence === 'number' && Number.isFinite(params.confidence)
1495
+ ? params.confidence
1496
+ : 1;
1497
+ const confidence = Math.max(0, Math.min(1, rawConfidence));
1498
+ db.prepare(
1499
+ `INSERT INTO relations
1500
+ (id, source_entity, relation_type, target_entity, properties, confidence, created_at)
1501
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1502
+ ).run(
1503
+ id,
1504
+ source,
1505
+ serializeRelationType(params.relation),
1506
+ target,
1507
+ serializeKnowledgeProperties(params.properties),
1508
+ confidence,
1509
+ new Date().toISOString(),
1510
+ );
1511
+
1512
+ return id;
1513
+ }
1514
+
1515
+ export function queryKnowledgeGraph(
1516
+ pattern: KnowledgeGraphPattern = {},
1517
+ ): KnowledgeGraphMatch[] {
1518
+ const sql = [
1519
+ `SELECT
1520
+ s.id AS s_id,
1521
+ s.entity_type AS s_type,
1522
+ s.name AS s_name,
1523
+ s.properties AS s_properties,
1524
+ s.created_at AS s_created_at,
1525
+ s.updated_at AS s_updated_at,
1526
+ r.id AS r_id,
1527
+ r.source_entity AS r_source,
1528
+ r.relation_type AS r_type,
1529
+ r.target_entity AS r_target,
1530
+ r.properties AS r_properties,
1531
+ r.confidence AS r_confidence,
1532
+ r.created_at AS r_created_at,
1533
+ t.id AS t_id,
1534
+ t.entity_type AS t_type,
1535
+ t.name AS t_name,
1536
+ t.properties AS t_properties,
1537
+ t.created_at AS t_created_at,
1538
+ t.updated_at AS t_updated_at
1539
+ FROM relations r
1540
+ JOIN entities s ON r.source_entity = s.id
1541
+ JOIN entities t ON r.target_entity = t.id
1542
+ WHERE 1 = 1`,
1543
+ ];
1544
+ const args: unknown[] = [];
1545
+
1546
+ const source = pattern.source?.trim();
1547
+ if (source) {
1548
+ sql.push('AND (s.id = ? OR s.name = ?)');
1549
+ args.push(source, source);
1550
+ }
1551
+
1552
+ if (pattern.relation) {
1553
+ sql.push('AND r.relation_type = ?');
1554
+ args.push(serializeRelationType(pattern.relation));
1555
+ }
1556
+
1557
+ const target = pattern.target?.trim();
1558
+ if (target) {
1559
+ sql.push('AND (t.id = ? OR t.name = ?)');
1560
+ args.push(target, target);
1561
+ }
1562
+
1563
+ // OpenFang-compatible v1 query semantics: single-hop relation scan, max 100.
1564
+ sql.push('LIMIT 100');
1565
+
1566
+ const rows = db
1567
+ .prepare(sql.join('\n'))
1568
+ .all(...args) as RawKnowledgeGraphRow[];
1569
+ return rows.map(mapKnowledgeMatchRow);
1570
+ }
1571
+
173
1572
  // --- Sessions ---
174
1573
 
175
1574
  export function getOrCreateSession(
@@ -180,7 +1579,9 @@ export function getOrCreateSession(
180
1579
  const existing = getSessionById(sessionId);
181
1580
 
182
1581
  if (existing) {
183
- db.prepare('UPDATE sessions SET last_active = datetime(\'now\') WHERE id = ?').run(sessionId);
1582
+ db.prepare(
1583
+ "UPDATE sessions SET last_active = datetime('now') WHERE id = ?",
1584
+ ).run(sessionId);
184
1585
  return existing;
185
1586
  }
186
1587
 
@@ -192,34 +1593,69 @@ export function getOrCreateSession(
192
1593
  }
193
1594
 
194
1595
  export function getSessionById(sessionId: string): Session | undefined {
195
- return db
196
- .prepare('SELECT * FROM sessions WHERE id = ?')
197
- .get(sessionId) as Session | undefined;
1596
+ return db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) as
1597
+ | Session
1598
+ | undefined;
198
1599
  }
199
1600
 
200
- export function updateSessionChatbot(sessionId: string, chatbotId: string | null): void {
201
- db.prepare('UPDATE sessions SET chatbot_id = ? WHERE id = ?').run(chatbotId, sessionId);
1601
+ export function updateSessionChatbot(
1602
+ sessionId: string,
1603
+ chatbotId: string | null,
1604
+ ): void {
1605
+ db.prepare('UPDATE sessions SET chatbot_id = ? WHERE id = ?').run(
1606
+ chatbotId,
1607
+ sessionId,
1608
+ );
202
1609
  }
203
1610
 
204
- export function updateSessionModel(sessionId: string, model: string | null): void {
205
- db.prepare('UPDATE sessions SET model = ? WHERE id = ?').run(model, sessionId);
1611
+ export function updateSessionModel(
1612
+ sessionId: string,
1613
+ model: string | null,
1614
+ ): void {
1615
+ db.prepare('UPDATE sessions SET model = ? WHERE id = ?').run(
1616
+ model,
1617
+ sessionId,
1618
+ );
206
1619
  }
207
1620
 
208
1621
  export function updateSessionRag(sessionId: string, enableRag: boolean): void {
209
- db.prepare('UPDATE sessions SET enable_rag = ? WHERE id = ?').run(enableRag ? 1 : 0, sessionId);
1622
+ db.prepare('UPDATE sessions SET enable_rag = ? WHERE id = ?').run(
1623
+ enableRag ? 1 : 0,
1624
+ sessionId,
1625
+ );
210
1626
  }
211
1627
 
212
1628
  export function getAllSessions(): Session[] {
213
- return db.prepare('SELECT * FROM sessions ORDER BY last_active DESC').all() as Session[];
1629
+ return db
1630
+ .prepare('SELECT * FROM sessions ORDER BY last_active DESC')
1631
+ .all() as Session[];
214
1632
  }
215
1633
 
216
1634
  export function getSessionCount(): number {
217
- const row = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
1635
+ const row = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as {
1636
+ count: number;
1637
+ };
218
1638
  return row.count;
219
1639
  }
220
1640
 
1641
+ export function getMostRecentSessionChannelId(): string | null {
1642
+ const row = db
1643
+ .prepare(
1644
+ 'SELECT channel_id FROM sessions ORDER BY last_active DESC LIMIT 1',
1645
+ )
1646
+ .get() as { channel_id?: string } | undefined;
1647
+ if (!row || typeof row.channel_id !== 'string') return null;
1648
+ const channelId = row.channel_id.trim();
1649
+ return channelId || null;
1650
+ }
1651
+
221
1652
  export function clearSessionHistory(sessionId: string): number {
222
- const result = db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
1653
+ const result = db
1654
+ .prepare('DELETE FROM messages WHERE session_id = ?')
1655
+ .run(sessionId);
1656
+ db.prepare('DELETE FROM semantic_memories WHERE session_id = ?').run(
1657
+ sessionId,
1658
+ );
223
1659
  db.prepare(
224
1660
  'UPDATE sessions SET message_count = 0, session_summary = NULL, summary_updated_at = NULL, compaction_count = 0, memory_flush_at = NULL WHERE id = ?',
225
1661
  ).run(sessionId);
@@ -234,17 +1670,24 @@ export function storeMessage(
234
1670
  username: string | null,
235
1671
  role: string,
236
1672
  content: string,
237
- ): void {
238
- db.prepare(
239
- 'INSERT INTO messages (session_id, user_id, username, role, content) VALUES (?, ?, ?, ?, ?)',
240
- ).run(sessionId, userId, username, role, content);
1673
+ ): number {
1674
+ const result = db
1675
+ .prepare(
1676
+ 'INSERT INTO messages (session_id, user_id, username, role, content) VALUES (?, ?, ?, ?, ?)',
1677
+ )
1678
+ .run(sessionId, userId, username, role, content);
241
1679
 
242
1680
  db.prepare(
243
- 'UPDATE sessions SET message_count = message_count + 1, last_active = datetime(\'now\') WHERE id = ?',
1681
+ "UPDATE sessions SET message_count = message_count + 1, last_active = datetime('now') WHERE id = ?",
244
1682
  ).run(sessionId);
1683
+
1684
+ return result.lastInsertRowid as number;
245
1685
  }
246
1686
 
247
- export function getConversationHistory(sessionId: string, limit = 50): StoredMessage[] {
1687
+ export function getConversationHistory(
1688
+ sessionId: string,
1689
+ limit = 50,
1690
+ ): StoredMessage[] {
248
1691
  return db
249
1692
  .prepare(
250
1693
  'SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?',
@@ -252,6 +1695,590 @@ export function getConversationHistory(sessionId: string, limit = 50): StoredMes
252
1695
  .all(sessionId, limit) as StoredMessage[];
253
1696
  }
254
1697
 
1698
+ export function getRecentMessages(
1699
+ sessionId: string,
1700
+ limit?: number,
1701
+ ): StoredMessage[] {
1702
+ const boundedLimit =
1703
+ typeof limit === 'number' && Number.isFinite(limit)
1704
+ ? Math.max(1, Math.floor(limit))
1705
+ : null;
1706
+
1707
+ if (boundedLimit == null) {
1708
+ return db
1709
+ .prepare('SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC')
1710
+ .all(sessionId) as StoredMessage[];
1711
+ }
1712
+
1713
+ const rows = db
1714
+ .prepare(
1715
+ 'SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT ?',
1716
+ )
1717
+ .all(sessionId, boundedLimit) as StoredMessage[];
1718
+ return rows.reverse();
1719
+ }
1720
+
1721
+ function parseTimestamp(raw: string): number {
1722
+ const value = raw.trim();
1723
+ if (!value) return 0;
1724
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)) {
1725
+ const parsed = Date.parse(`${value.replace(' ', 'T')}Z`);
1726
+ return Number.isNaN(parsed) ? 0 : parsed;
1727
+ }
1728
+ const parsed = Date.parse(value);
1729
+ return Number.isNaN(parsed) ? 0 : parsed;
1730
+ }
1731
+
1732
+ function parseQueryTerms(query: string): string[] {
1733
+ const lower = query
1734
+ .toLowerCase()
1735
+ .split(/[^a-z0-9_-]+/g)
1736
+ .map((term) => term.trim())
1737
+ .filter((term) => term.length >= 2);
1738
+ if (lower.length === 0) return [];
1739
+ const unique = new Set<string>();
1740
+ for (const term of lower) {
1741
+ unique.add(term);
1742
+ if (unique.size >= 8) break;
1743
+ }
1744
+ return [...unique];
1745
+ }
1746
+
1747
+ const MAX_EMBEDDING_DIMENSIONS = 2048;
1748
+
1749
+ function normalizeEmbeddingInput(
1750
+ embedding: number[] | null | undefined,
1751
+ ): Float32Array | null {
1752
+ if (!Array.isArray(embedding) || embedding.length === 0) return null;
1753
+ if (embedding.length > MAX_EMBEDDING_DIMENSIONS) return null;
1754
+ const values: number[] = [];
1755
+ for (const value of embedding) {
1756
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
1757
+ values.push(value);
1758
+ }
1759
+ if (values.length === 0) return null;
1760
+ return new Float32Array(values);
1761
+ }
1762
+
1763
+ function embeddingToBlob(embedding: Float32Array): Buffer {
1764
+ const buffer = Buffer.allocUnsafe(embedding.length * 4);
1765
+ for (let i = 0; i < embedding.length; i += 1) {
1766
+ buffer.writeFloatLE(embedding[i], i * 4);
1767
+ }
1768
+ return buffer;
1769
+ }
1770
+
1771
+ function embeddingFromBlob(raw: unknown): number[] | null {
1772
+ if (!raw) return null;
1773
+ const bytes = Buffer.isBuffer(raw)
1774
+ ? raw
1775
+ : raw instanceof Uint8Array
1776
+ ? Buffer.from(raw)
1777
+ : null;
1778
+ if (!bytes || bytes.length === 0 || bytes.length % 4 !== 0) return null;
1779
+ const values: number[] = [];
1780
+ for (let i = 0; i < bytes.length; i += 4) {
1781
+ values.push(bytes.readFloatLE(i));
1782
+ }
1783
+ return values.length > 0 ? values : null;
1784
+ }
1785
+
1786
+ function cosineSimilarity(a: Float32Array, b: number[]): number {
1787
+ if (a.length === 0 || b.length === 0 || a.length !== b.length) return -1;
1788
+ let dot = 0;
1789
+ let normA = 0;
1790
+ let normB = 0;
1791
+ for (let i = 0; i < a.length; i += 1) {
1792
+ const bv = b[i];
1793
+ if (!Number.isFinite(bv)) return -1;
1794
+ dot += a[i] * bv;
1795
+ normA += a[i] * a[i];
1796
+ normB += bv * bv;
1797
+ }
1798
+ if (normA <= Number.EPSILON || normB <= Number.EPSILON) return -1;
1799
+ return dot / Math.sqrt(normA * normB);
1800
+ }
1801
+
1802
+ function scoreSemanticLikeCandidate(
1803
+ row: SemanticMemoryEntry,
1804
+ normalizedQuery: string,
1805
+ queryTerms: string[],
1806
+ ): number {
1807
+ const content = row.content.toLowerCase();
1808
+ let score = 0;
1809
+ if (content.includes(normalizedQuery)) score += 8;
1810
+ if (content.startsWith(normalizedQuery)) score += 3;
1811
+
1812
+ let termHits = 0;
1813
+ for (const term of queryTerms) {
1814
+ if (content.includes(term)) termHits += 1;
1815
+ }
1816
+ score += termHits * 2;
1817
+ score += Math.max(0, Math.min(1, row.confidence)) * 4;
1818
+
1819
+ const hoursSinceAccess = Math.max(
1820
+ 0,
1821
+ (Date.now() - parseTimestamp(row.accessed_at)) / 3_600_000,
1822
+ );
1823
+ if (hoursSinceAccess < 24) score += 1;
1824
+ return score;
1825
+ }
1826
+
1827
+ interface RawSemanticMemoryRow {
1828
+ id: number;
1829
+ session_id: string;
1830
+ role: string;
1831
+ source: string;
1832
+ scope: string;
1833
+ metadata: string | null;
1834
+ content: string;
1835
+ confidence: number;
1836
+ embedding: Buffer | Uint8Array | null;
1837
+ source_message_id: number | null;
1838
+ created_at: string;
1839
+ accessed_at: string;
1840
+ access_count: number;
1841
+ }
1842
+
1843
+ function parseSemanticMetadata(
1844
+ raw: string | null | undefined,
1845
+ ): Record<string, unknown> {
1846
+ if (!raw) return {};
1847
+ try {
1848
+ const parsed = JSON.parse(raw) as unknown;
1849
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
1850
+ return {};
1851
+ return parsed as Record<string, unknown>;
1852
+ } catch {
1853
+ return {};
1854
+ }
1855
+ }
1856
+
1857
+ function serializeSemanticMetadata(metadata: Record<string, unknown>): string {
1858
+ try {
1859
+ return JSON.stringify(metadata);
1860
+ } catch {
1861
+ return '{}';
1862
+ }
1863
+ }
1864
+
1865
+ function mapSemanticMemoryRow(row: RawSemanticMemoryRow): SemanticMemoryEntry {
1866
+ return {
1867
+ id: row.id,
1868
+ session_id: row.session_id,
1869
+ role: row.role,
1870
+ source: (row.source || '').trim() || 'conversation',
1871
+ scope: (row.scope || '').trim() || 'episodic',
1872
+ metadata: parseSemanticMetadata(row.metadata),
1873
+ content: row.content,
1874
+ confidence: row.confidence,
1875
+ embedding: embeddingFromBlob(row.embedding),
1876
+ source_message_id: row.source_message_id,
1877
+ created_at: row.created_at,
1878
+ accessed_at: row.accessed_at,
1879
+ access_count: row.access_count,
1880
+ };
1881
+ }
1882
+
1883
+ function touchSemanticMemoryRows(entries: SemanticMemoryEntry[]): void {
1884
+ if (entries.length === 0) return;
1885
+ const touch = db.prepare(
1886
+ `UPDATE semantic_memories
1887
+ SET access_count = access_count + 1,
1888
+ accessed_at = datetime('now')
1889
+ WHERE id = ?
1890
+ AND deleted = 0`,
1891
+ );
1892
+ const transaction = db.transaction((rows: SemanticMemoryEntry[]) => {
1893
+ for (const row of rows) {
1894
+ touch.run(row.id);
1895
+ }
1896
+ });
1897
+ transaction(entries);
1898
+ }
1899
+
1900
+ export function touchSemanticMemories(ids: number[]): void {
1901
+ const uniqueIds = [
1902
+ ...new Set(ids.map((id) => Math.floor(id)).filter((id) => id > 0)),
1903
+ ];
1904
+ if (uniqueIds.length === 0) return;
1905
+ const touch = db.prepare(
1906
+ `UPDATE semantic_memories
1907
+ SET access_count = access_count + 1,
1908
+ accessed_at = datetime('now')
1909
+ WHERE id = ?
1910
+ AND deleted = 0`,
1911
+ );
1912
+ const transaction = db.transaction((rowIds: number[]) => {
1913
+ for (const id of rowIds) {
1914
+ touch.run(id);
1915
+ }
1916
+ });
1917
+ transaction(uniqueIds);
1918
+ }
1919
+
1920
+ export interface SemanticRecallFilter {
1921
+ role?: string;
1922
+ source?: string;
1923
+ scope?: string;
1924
+ after?: string;
1925
+ before?: string;
1926
+ }
1927
+
1928
+ function applySemanticRecallFilterClauses(params: {
1929
+ whereClauses: string[];
1930
+ args: unknown[];
1931
+ filter?: SemanticRecallFilter;
1932
+ }): void {
1933
+ if (!params.filter) return;
1934
+ const role = params.filter.role?.trim();
1935
+ if (role) {
1936
+ params.whereClauses.push('role = ?');
1937
+ params.args.push(role);
1938
+ }
1939
+ const source = params.filter.source?.trim();
1940
+ if (source) {
1941
+ params.whereClauses.push('source = ?');
1942
+ params.args.push(source);
1943
+ }
1944
+ const scope = params.filter.scope?.trim();
1945
+ if (scope) {
1946
+ params.whereClauses.push('scope = ?');
1947
+ params.args.push(scope);
1948
+ }
1949
+ const after = params.filter.after?.trim();
1950
+ if (after) {
1951
+ params.whereClauses.push('created_at >= ?');
1952
+ params.args.push(after);
1953
+ }
1954
+ const before = params.filter.before?.trim();
1955
+ if (before) {
1956
+ params.whereClauses.push('created_at <= ?');
1957
+ params.args.push(before);
1958
+ }
1959
+ }
1960
+
1961
+ function recallSemanticMemoriesByLike(params: {
1962
+ sessionId: string;
1963
+ normalizedQuery: string;
1964
+ queryTerms: string[];
1965
+ limit: number;
1966
+ minConfidence: number;
1967
+ filter?: SemanticRecallFilter;
1968
+ }): SemanticMemoryEntry[] {
1969
+ if (params.queryTerms.length === 0) return [];
1970
+ const candidateLimit = Math.max(params.limit * 8, 50);
1971
+ const likePatterns = params.queryTerms.map((term) => `%${term}%`);
1972
+ const placeholders = likePatterns
1973
+ .map(() => 'LOWER(content) LIKE ?')
1974
+ .join(' OR ');
1975
+ const whereClauses: string[] = [
1976
+ 'session_id = ?',
1977
+ 'deleted = 0',
1978
+ 'confidence >= ?',
1979
+ `(${placeholders})`,
1980
+ ];
1981
+ const args: unknown[] = [
1982
+ params.sessionId,
1983
+ params.minConfidence,
1984
+ ...likePatterns,
1985
+ ];
1986
+ applySemanticRecallFilterClauses({
1987
+ whereClauses,
1988
+ args,
1989
+ filter: params.filter,
1990
+ });
1991
+ args.push(candidateLimit);
1992
+
1993
+ const rawRows = db
1994
+ .prepare(
1995
+ `SELECT *
1996
+ FROM semantic_memories
1997
+ WHERE ${whereClauses.join('\n AND ')}
1998
+ ORDER BY confidence DESC, accessed_at DESC
1999
+ LIMIT ?`,
2000
+ )
2001
+ .all(...args) as RawSemanticMemoryRow[];
2002
+ if (rawRows.length === 0) return [];
2003
+
2004
+ const ranked = rawRows
2005
+ .map(mapSemanticMemoryRow)
2006
+ .map((row) => ({
2007
+ row,
2008
+ score: scoreSemanticLikeCandidate(
2009
+ row,
2010
+ params.normalizedQuery,
2011
+ params.queryTerms,
2012
+ ),
2013
+ }))
2014
+ .filter((entry) => entry.score > 0)
2015
+ .sort((a, b) => {
2016
+ if (b.score !== a.score) return b.score - a.score;
2017
+ if (b.row.confidence !== a.row.confidence) {
2018
+ return b.row.confidence - a.row.confidence;
2019
+ }
2020
+ return (
2021
+ parseTimestamp(b.row.accessed_at) - parseTimestamp(a.row.accessed_at)
2022
+ );
2023
+ })
2024
+ .slice(0, params.limit)
2025
+ .map((entry) => entry.row);
2026
+
2027
+ touchSemanticMemoryRows(ranked);
2028
+ return ranked;
2029
+ }
2030
+
2031
+ function recallSemanticMemoriesByVector(params: {
2032
+ sessionId: string;
2033
+ queryEmbedding: Float32Array;
2034
+ limit: number;
2035
+ minConfidence: number;
2036
+ filter?: SemanticRecallFilter;
2037
+ }): SemanticMemoryEntry[] {
2038
+ const candidateLimit = Math.max(params.limit * 10, 100);
2039
+ const whereClauses: string[] = [
2040
+ 'session_id = ?',
2041
+ 'deleted = 0',
2042
+ 'confidence >= ?',
2043
+ ];
2044
+ const args: unknown[] = [params.sessionId, params.minConfidence];
2045
+ applySemanticRecallFilterClauses({
2046
+ whereClauses,
2047
+ args,
2048
+ filter: params.filter,
2049
+ });
2050
+ args.push(candidateLimit);
2051
+ const rawRows = db
2052
+ .prepare(
2053
+ `SELECT *
2054
+ FROM semantic_memories
2055
+ WHERE ${whereClauses.join('\n AND ')}
2056
+ ORDER BY accessed_at DESC, confidence DESC
2057
+ LIMIT ?`,
2058
+ )
2059
+ .all(...args) as RawSemanticMemoryRow[];
2060
+ if (rawRows.length === 0) return [];
2061
+
2062
+ const rows = rawRows.map(mapSemanticMemoryRow);
2063
+ const ranked = rows
2064
+ .map((row) => {
2065
+ const similarity = row.embedding
2066
+ ? cosineSimilarity(params.queryEmbedding, row.embedding)
2067
+ : -1;
2068
+ return {
2069
+ row,
2070
+ similarity,
2071
+ };
2072
+ })
2073
+ .sort((a, b) => {
2074
+ if (b.similarity !== a.similarity) return b.similarity - a.similarity;
2075
+ if (b.row.confidence !== a.row.confidence) {
2076
+ return b.row.confidence - a.row.confidence;
2077
+ }
2078
+ return (
2079
+ parseTimestamp(b.row.accessed_at) - parseTimestamp(a.row.accessed_at)
2080
+ );
2081
+ })
2082
+ .slice(0, params.limit)
2083
+ .map((entry) => entry.row);
2084
+
2085
+ touchSemanticMemoryRows(ranked);
2086
+ return ranked;
2087
+ }
2088
+
2089
+ function recallSemanticMemoriesByRecent(params: {
2090
+ sessionId: string;
2091
+ limit: number;
2092
+ minConfidence: number;
2093
+ filter?: SemanticRecallFilter;
2094
+ }): SemanticMemoryEntry[] {
2095
+ const whereClauses: string[] = [
2096
+ 'session_id = ?',
2097
+ 'deleted = 0',
2098
+ 'confidence >= ?',
2099
+ ];
2100
+ const args: unknown[] = [params.sessionId, params.minConfidence];
2101
+ applySemanticRecallFilterClauses({
2102
+ whereClauses,
2103
+ args,
2104
+ filter: params.filter,
2105
+ });
2106
+ args.push(params.limit);
2107
+ const rows = db
2108
+ .prepare(
2109
+ `SELECT *
2110
+ FROM semantic_memories
2111
+ WHERE ${whereClauses.join('\n AND ')}
2112
+ ORDER BY accessed_at DESC, confidence DESC
2113
+ LIMIT ?`,
2114
+ )
2115
+ .all(...args) as RawSemanticMemoryRow[];
2116
+ const mapped = rows.map(mapSemanticMemoryRow);
2117
+ touchSemanticMemoryRows(mapped);
2118
+ return mapped;
2119
+ }
2120
+
2121
+ export function storeSemanticMemory(params: {
2122
+ sessionId: string;
2123
+ role: string;
2124
+ source?: string | null;
2125
+ scope?: string | null;
2126
+ metadata?: Record<string, unknown> | string | null;
2127
+ content: string;
2128
+ confidence?: number;
2129
+ embedding?: number[] | null;
2130
+ sourceMessageId?: number | null;
2131
+ createdAt?: string | null;
2132
+ accessedAt?: string | null;
2133
+ deleted?: boolean | number | null;
2134
+ }): number {
2135
+ const normalizedContent = params.content.trim();
2136
+ const source = (params.source || '').trim() || 'conversation';
2137
+ const scope = (params.scope || '').trim() || 'episodic';
2138
+ const metadata =
2139
+ typeof params.metadata === 'string'
2140
+ ? parseSemanticMetadata(params.metadata)
2141
+ : params.metadata && typeof params.metadata === 'object'
2142
+ ? params.metadata
2143
+ : {};
2144
+ const metadataJson = serializeSemanticMetadata(metadata);
2145
+ const deleted = params.deleted === true || params.deleted === 1 ? 1 : 0;
2146
+ const rawConfidence =
2147
+ typeof params.confidence === 'number' && Number.isFinite(params.confidence)
2148
+ ? params.confidence
2149
+ : 1;
2150
+ const boundedConfidence = Math.max(0, Math.min(1, rawConfidence));
2151
+ const normalizedEmbedding = normalizeEmbeddingInput(params.embedding);
2152
+ const embeddingBlob = normalizedEmbedding
2153
+ ? embeddingToBlob(normalizedEmbedding)
2154
+ : null;
2155
+ const createdAt = params.createdAt?.trim() || null;
2156
+ const accessedAt = params.accessedAt?.trim() || createdAt || null;
2157
+ const result = db
2158
+ .prepare(
2159
+ `INSERT INTO semantic_memories
2160
+ (session_id, role, source, scope, metadata, content, confidence, embedding, source_message_id, created_at, accessed_at, access_count, deleted)
2161
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(?, datetime('now')), COALESCE(?, datetime('now')), 0, ?)`,
2162
+ )
2163
+ .run(
2164
+ params.sessionId,
2165
+ params.role,
2166
+ source,
2167
+ scope,
2168
+ metadataJson,
2169
+ normalizedContent,
2170
+ boundedConfidence,
2171
+ embeddingBlob,
2172
+ params.sourceMessageId ?? null,
2173
+ createdAt,
2174
+ accessedAt,
2175
+ deleted,
2176
+ );
2177
+ return result.lastInsertRowid as number;
2178
+ }
2179
+
2180
+ export function recallSemanticMemories(params: {
2181
+ sessionId: string;
2182
+ query: string;
2183
+ limit?: number;
2184
+ minConfidence?: number;
2185
+ queryEmbedding?: number[] | null;
2186
+ filter?: SemanticRecallFilter;
2187
+ }): SemanticMemoryEntry[] {
2188
+ const normalizedQuery = params.query.trim().toLowerCase();
2189
+ const queryTerms = parseQueryTerms(normalizedQuery);
2190
+ const queryEmbedding = normalizeEmbeddingInput(params.queryEmbedding);
2191
+
2192
+ const limit = Math.max(1, Math.min(Math.floor(params.limit || 5), 50));
2193
+ const rawMinConfidence =
2194
+ typeof params.minConfidence === 'number' &&
2195
+ Number.isFinite(params.minConfidence)
2196
+ ? params.minConfidence
2197
+ : 0.2;
2198
+ const minConfidence = Math.max(0, Math.min(1, rawMinConfidence));
2199
+
2200
+ if (!queryEmbedding && queryTerms.length === 0) {
2201
+ return recallSemanticMemoriesByRecent({
2202
+ sessionId: params.sessionId,
2203
+ limit,
2204
+ minConfidence,
2205
+ filter: params.filter,
2206
+ });
2207
+ }
2208
+
2209
+ if (queryEmbedding) {
2210
+ return recallSemanticMemoriesByVector({
2211
+ sessionId: params.sessionId,
2212
+ queryEmbedding,
2213
+ limit,
2214
+ minConfidence,
2215
+ filter: params.filter,
2216
+ });
2217
+ }
2218
+
2219
+ return recallSemanticMemoriesByLike({
2220
+ sessionId: params.sessionId,
2221
+ normalizedQuery,
2222
+ queryTerms,
2223
+ limit,
2224
+ minConfidence,
2225
+ filter: params.filter,
2226
+ });
2227
+ }
2228
+
2229
+ export function forgetSemanticMemory(id: number): boolean {
2230
+ const normalizedId = Math.floor(id);
2231
+ if (!Number.isFinite(normalizedId) || normalizedId <= 0) return false;
2232
+ const result = db
2233
+ .prepare(
2234
+ `UPDATE semantic_memories
2235
+ SET deleted = 1
2236
+ WHERE id = ?
2237
+ AND deleted = 0`,
2238
+ )
2239
+ .run(normalizedId);
2240
+ return result.changes > 0;
2241
+ }
2242
+
2243
+ export function decaySemanticMemories(params?: {
2244
+ decayRate?: number;
2245
+ staleAfterDays?: number;
2246
+ minConfidence?: number;
2247
+ }): number {
2248
+ const rawDecayRate =
2249
+ typeof params?.decayRate === 'number' && Number.isFinite(params.decayRate)
2250
+ ? params.decayRate
2251
+ : 0.1;
2252
+ const decayRate = Math.max(0, Math.min(0.95, rawDecayRate));
2253
+ const decayFactor = 1 - decayRate;
2254
+ const rawStaleAfterDays =
2255
+ typeof params?.staleAfterDays === 'number' &&
2256
+ Number.isFinite(params.staleAfterDays)
2257
+ ? params.staleAfterDays
2258
+ : 7;
2259
+ const staleAfterDays = Math.max(
2260
+ 1,
2261
+ Math.min(365, Math.floor(rawStaleAfterDays)),
2262
+ );
2263
+ const rawMinConfidence =
2264
+ typeof params?.minConfidence === 'number' &&
2265
+ Number.isFinite(params.minConfidence)
2266
+ ? params.minConfidence
2267
+ : 0.1;
2268
+ const minConfidence = Math.max(0, Math.min(0.95, rawMinConfidence));
2269
+ const cutoff = `-${staleAfterDays} days`;
2270
+ const result = db
2271
+ .prepare(
2272
+ `UPDATE semantic_memories
2273
+ SET confidence = MAX(?, confidence * ?)
2274
+ WHERE deleted = 0
2275
+ AND confidence > ?
2276
+ AND accessed_at < datetime('now', ?)`,
2277
+ )
2278
+ .run(minConfidence, decayFactor, minConfidence, cutoff);
2279
+ return result.changes;
2280
+ }
2281
+
255
2282
  export interface CompactionCandidate {
256
2283
  cutoffId: number;
257
2284
  olderMessages: StoredMessage[];
@@ -263,12 +2290,16 @@ export function getCompactionCandidateMessages(
263
2290
  ): CompactionCandidate | null {
264
2291
  const keep = Math.max(1, Math.floor(keepRecent));
265
2292
  const cutoffRow = db
266
- .prepare('SELECT id FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?')
2293
+ .prepare(
2294
+ 'SELECT id FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1 OFFSET ?',
2295
+ )
267
2296
  .get(sessionId, keep - 1) as { id: number } | undefined;
268
2297
  if (!cutoffRow) return null;
269
2298
 
270
2299
  const older = db
271
- .prepare('SELECT * FROM messages WHERE session_id = ? AND id < ? ORDER BY id ASC')
2300
+ .prepare(
2301
+ 'SELECT * FROM messages WHERE session_id = ? AND id < ? ORDER BY id ASC',
2302
+ )
272
2303
  .all(sessionId, cutoffRow.id) as StoredMessage[];
273
2304
  if (older.length === 0) return null;
274
2305
 
@@ -278,12 +2309,15 @@ export function getCompactionCandidateMessages(
278
2309
  };
279
2310
  }
280
2311
 
281
- export function deleteMessagesBeforeId(sessionId: string, cutoffId: number): number {
2312
+ export function deleteMessagesBeforeId(
2313
+ sessionId: string,
2314
+ cutoffId: number,
2315
+ ): number {
282
2316
  const result = db
283
2317
  .prepare('DELETE FROM messages WHERE session_id = ? AND id < ?')
284
2318
  .run(sessionId, cutoffId);
285
2319
  db.prepare(
286
- 'UPDATE sessions SET message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?), last_active = datetime(\'now\') WHERE id = ?',
2320
+ "UPDATE sessions SET message_count = (SELECT COUNT(*) FROM messages WHERE session_id = ?), last_active = datetime('now') WHERE id = ?",
287
2321
  ).run(sessionId, sessionId);
288
2322
  return result.changes;
289
2323
  }
@@ -291,12 +2325,14 @@ export function deleteMessagesBeforeId(sessionId: string, cutoffId: number): num
291
2325
  export function updateSessionSummary(sessionId: string, summary: string): void {
292
2326
  const normalized = summary.trim();
293
2327
  db.prepare(
294
- 'UPDATE sessions SET session_summary = ?, summary_updated_at = datetime(\'now\'), compaction_count = compaction_count + 1 WHERE id = ?',
2328
+ "UPDATE sessions SET session_summary = ?, summary_updated_at = datetime('now'), compaction_count = compaction_count + 1 WHERE id = ?",
295
2329
  ).run(normalized || null, sessionId);
296
2330
  }
297
2331
 
298
2332
  export function markSessionMemoryFlush(sessionId: string): void {
299
- db.prepare('UPDATE sessions SET memory_flush_at = datetime(\'now\') WHERE id = ?').run(sessionId);
2333
+ db.prepare(
2334
+ "UPDATE sessions SET memory_flush_at = datetime('now') WHERE id = ?",
2335
+ ).run(sessionId);
300
2336
  }
301
2337
 
302
2338
  // --- Tasks ---
@@ -309,28 +2345,83 @@ export function createTask(
309
2345
  runAt?: string,
310
2346
  everyMs?: number,
311
2347
  ): number {
312
- const result = db.prepare(
313
- 'INSERT INTO tasks (session_id, channel_id, cron_expr, prompt, run_at, every_ms) VALUES (?, ?, ?, ?, ?, ?)',
314
- ).run(sessionId, channelId, cronExpr, prompt, runAt || null, everyMs || null);
2348
+ const result = db
2349
+ .prepare(
2350
+ 'INSERT INTO tasks (session_id, channel_id, cron_expr, prompt, run_at, every_ms) VALUES (?, ?, ?, ?, ?, ?)',
2351
+ )
2352
+ .run(
2353
+ sessionId,
2354
+ channelId,
2355
+ cronExpr,
2356
+ prompt,
2357
+ runAt || null,
2358
+ everyMs || null,
2359
+ );
315
2360
  return result.lastInsertRowid as number;
316
2361
  }
317
2362
 
318
2363
  export function getTasksForSession(sessionId: string): ScheduledTask[] {
319
2364
  return db
320
- .prepare('SELECT * FROM tasks WHERE session_id = ? ORDER BY created_at DESC')
2365
+ .prepare(
2366
+ 'SELECT * FROM tasks WHERE session_id = ? ORDER BY created_at DESC',
2367
+ )
321
2368
  .all(sessionId) as ScheduledTask[];
322
2369
  }
323
2370
 
324
2371
  export function getAllEnabledTasks(): ScheduledTask[] {
325
- return db.prepare('SELECT * FROM tasks WHERE enabled = 1').all() as ScheduledTask[];
2372
+ return db
2373
+ .prepare('SELECT * FROM tasks WHERE enabled = 1')
2374
+ .all() as ScheduledTask[];
326
2375
  }
327
2376
 
328
2377
  export function updateTaskLastRun(taskId: number): void {
329
- db.prepare('UPDATE tasks SET last_run = datetime(\'now\') WHERE id = ?').run(taskId);
2378
+ db.prepare("UPDATE tasks SET last_run = datetime('now') WHERE id = ?").run(
2379
+ taskId,
2380
+ );
2381
+ }
2382
+
2383
+ export function markTaskSuccess(taskId: number): void {
2384
+ db.prepare(
2385
+ 'UPDATE tasks SET last_status = ?, consecutive_errors = 0 WHERE id = ?',
2386
+ ).run('success', taskId);
2387
+ }
2388
+
2389
+ export function markTaskFailure(
2390
+ taskId: number,
2391
+ maxConsecutiveErrors = 5,
2392
+ ): { disabled: boolean; consecutiveErrors: number } {
2393
+ const row = db
2394
+ .prepare('SELECT consecutive_errors FROM tasks WHERE id = ?')
2395
+ .get(taskId) as { consecutive_errors?: number } | undefined;
2396
+ if (!row) {
2397
+ return { disabled: false, consecutiveErrors: 0 };
2398
+ }
2399
+
2400
+ const nextCount = Math.max(0, Math.floor(row.consecutive_errors || 0)) + 1;
2401
+ const shouldDisable =
2402
+ nextCount >= Math.max(1, Math.floor(maxConsecutiveErrors));
2403
+ db.prepare(
2404
+ 'UPDATE tasks SET last_status = ?, consecutive_errors = ?, enabled = ? WHERE id = ?',
2405
+ ).run('error', nextCount, shouldDisable ? 0 : 1, taskId);
2406
+ return {
2407
+ disabled: shouldDisable,
2408
+ consecutiveErrors: nextCount,
2409
+ };
330
2410
  }
331
2411
 
332
2412
  export function toggleTask(taskId: number, enabled: boolean): void {
333
- db.prepare('UPDATE tasks SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, taskId);
2413
+ db.prepare('UPDATE tasks SET enabled = ? WHERE id = ?').run(
2414
+ enabled ? 1 : 0,
2415
+ taskId,
2416
+ );
2417
+ }
2418
+
2419
+ export function pauseTask(taskId: number): void {
2420
+ toggleTask(taskId, false);
2421
+ }
2422
+
2423
+ export function resumeTask(taskId: number): void {
2424
+ toggleTask(taskId, true);
334
2425
  }
335
2426
 
336
2427
  export function deleteTask(taskId: number): void {
@@ -347,7 +2438,12 @@ export function logAudit(
347
2438
  ): void {
348
2439
  db.prepare(
349
2440
  'INSERT INTO audit_log (session_id, event, detail, duration_ms) VALUES (?, ?, ?, ?)',
350
- ).run(sessionId || null, event, detail ? JSON.stringify(detail) : null, durationMs || null);
2441
+ ).run(
2442
+ sessionId || null,
2443
+ event,
2444
+ detail ? JSON.stringify(detail) : null,
2445
+ durationMs || null,
2446
+ );
351
2447
  }
352
2448
 
353
2449
  export function getRecentAudit(limit = 20): AuditEntry[] {
@@ -360,12 +2456,18 @@ function toPayloadObject(payload: AuditEventPayload): Record<string, unknown> {
360
2456
  return payload as unknown as Record<string, unknown>;
361
2457
  }
362
2458
 
363
- function readPayloadStringValue(payload: Record<string, unknown>, key: string): string | null {
2459
+ function readPayloadStringValue(
2460
+ payload: Record<string, unknown>,
2461
+ key: string,
2462
+ ): string | null {
364
2463
  const value = payload[key];
365
2464
  return typeof value === 'string' ? value : null;
366
2465
  }
367
2466
 
368
- function readPayloadBooleanValue(payload: Record<string, unknown>, key: string): boolean | null {
2467
+ function readPayloadBooleanValue(
2468
+ payload: Record<string, unknown>,
2469
+ key: string,
2470
+ ): boolean | null {
369
2471
  const value = payload[key];
370
2472
  return typeof value === 'boolean' ? value : null;
371
2473
  }
@@ -393,7 +2495,8 @@ export function logStructuredAuditEvent(record: WireRecord): void {
393
2495
  if (eventType !== 'approval.response') return;
394
2496
 
395
2497
  const payload = toPayloadObject(record.event);
396
- const toolCallId = readPayloadStringValue(payload, 'toolCallId') || `seq:${record.seq}`;
2498
+ const toolCallId =
2499
+ readPayloadStringValue(payload, 'toolCallId') || `seq:${record.seq}`;
397
2500
  const action = readPayloadStringValue(payload, 'action') || 'unknown';
398
2501
  const description = readPayloadStringValue(payload, 'description');
399
2502
  const approved = readPayloadBooleanValue(payload, 'approved') ? 1 : 0;
@@ -425,14 +2528,22 @@ export function getRecentStructuredAudit(limit = 20): StructuredAuditEntry[] {
425
2528
  .all(bounded) as StructuredAuditEntry[];
426
2529
  }
427
2530
 
428
- export function getRecentStructuredAuditForSession(sessionId: string, limit = 20): StructuredAuditEntry[] {
2531
+ export function getRecentStructuredAuditForSession(
2532
+ sessionId: string,
2533
+ limit = 20,
2534
+ ): StructuredAuditEntry[] {
429
2535
  const bounded = Math.max(1, Math.min(limit, 200));
430
2536
  return db
431
- .prepare('SELECT * FROM audit_events WHERE session_id = ? ORDER BY seq DESC LIMIT ?')
2537
+ .prepare(
2538
+ 'SELECT * FROM audit_events WHERE session_id = ? ORDER BY seq DESC LIMIT ?',
2539
+ )
432
2540
  .all(sessionId, bounded) as StructuredAuditEntry[];
433
2541
  }
434
2542
 
435
- export function getStructuredAuditAfterId(afterId: number, limit = 200): StructuredAuditEntry[] {
2543
+ export function getStructuredAuditAfterId(
2544
+ afterId: number,
2545
+ limit = 200,
2546
+ ): StructuredAuditEntry[] {
436
2547
  const boundedAfterId = Math.max(0, Math.floor(afterId));
437
2548
  const boundedLimit = Math.max(1, Math.min(Math.floor(limit), 5_000));
438
2549
  return db
@@ -440,7 +2551,10 @@ export function getStructuredAuditAfterId(afterId: number, limit = 200): Structu
440
2551
  .all(boundedAfterId, boundedLimit) as StructuredAuditEntry[];
441
2552
  }
442
2553
 
443
- export function searchStructuredAudit(query: string, limit = 20): StructuredAuditEntry[] {
2554
+ export function searchStructuredAudit(
2555
+ query: string,
2556
+ limit = 20,
2557
+ ): StructuredAuditEntry[] {
444
2558
  const normalized = query.trim();
445
2559
  if (!normalized) return [];
446
2560
  const bounded = Math.max(1, Math.min(limit, 200));
@@ -459,11 +2573,16 @@ export function searchStructuredAudit(query: string, limit = 20): StructuredAudi
459
2573
  .all(like, like, like, like, bounded) as StructuredAuditEntry[];
460
2574
  }
461
2575
 
462
- export function getRecentApprovals(limit = 20, deniedOnly = false): ApprovalAuditEntry[] {
2576
+ export function getRecentApprovals(
2577
+ limit = 20,
2578
+ deniedOnly = false,
2579
+ ): ApprovalAuditEntry[] {
463
2580
  const bounded = Math.max(1, Math.min(limit, 200));
464
2581
  if (deniedOnly) {
465
2582
  return db
466
- .prepare('SELECT * FROM approvals WHERE approved = 0 ORDER BY id DESC LIMIT ?')
2583
+ .prepare(
2584
+ 'SELECT * FROM approvals WHERE approved = 0 ORDER BY id DESC LIMIT ?',
2585
+ )
467
2586
  .all(bounded) as ApprovalAuditEntry[];
468
2587
  }
469
2588
  return db
@@ -475,12 +2594,17 @@ export function getObservabilityOffset(streamKey: string): number {
475
2594
  const normalized = streamKey.trim();
476
2595
  if (!normalized) return 0;
477
2596
  const row = db
478
- .prepare('SELECT last_event_id FROM observability_offsets WHERE stream_key = ?')
2597
+ .prepare(
2598
+ 'SELECT last_event_id FROM observability_offsets WHERE stream_key = ?',
2599
+ )
479
2600
  .get(normalized) as { last_event_id: number } | undefined;
480
2601
  return row ? Math.max(0, Math.floor(row.last_event_id)) : 0;
481
2602
  }
482
2603
 
483
- export function setObservabilityOffset(streamKey: string, lastEventId: number): void {
2604
+ export function setObservabilityOffset(
2605
+ streamKey: string,
2606
+ lastEventId: number,
2607
+ ): void {
484
2608
  const normalized = streamKey.trim();
485
2609
  if (!normalized) return;
486
2610
  const boundedLastEventId = Math.max(0, Math.floor(lastEventId));
@@ -497,14 +2621,19 @@ export function getObservabilityIngestToken(tokenKey: string): string | null {
497
2621
  const normalized = tokenKey.trim();
498
2622
  if (!normalized) return null;
499
2623
  const row = db
500
- .prepare('SELECT token FROM observability_ingest_tokens WHERE token_key = ?')
2624
+ .prepare(
2625
+ 'SELECT token FROM observability_ingest_tokens WHERE token_key = ?',
2626
+ )
501
2627
  .get(normalized) as { token: string } | undefined;
502
2628
  if (!row || typeof row.token !== 'string') return null;
503
2629
  const token = row.token.trim();
504
2630
  return token || null;
505
2631
  }
506
2632
 
507
- export function setObservabilityIngestToken(tokenKey: string, token: string): void {
2633
+ export function setObservabilityIngestToken(
2634
+ tokenKey: string,
2635
+ token: string,
2636
+ ): void {
508
2637
  const normalizedKey = tokenKey.trim();
509
2638
  const normalizedToken = token.trim();
510
2639
  if (!normalizedKey || !normalizedToken) return;
@@ -520,7 +2649,9 @@ export function setObservabilityIngestToken(tokenKey: string, token: string): vo
520
2649
  export function deleteObservabilityIngestToken(tokenKey: string): void {
521
2650
  const normalized = tokenKey.trim();
522
2651
  if (!normalized) return;
523
- db.prepare('DELETE FROM observability_ingest_tokens WHERE token_key = ?').run(normalized);
2652
+ db.prepare('DELETE FROM observability_ingest_tokens WHERE token_key = ?').run(
2653
+ normalized,
2654
+ );
524
2655
  }
525
2656
 
526
2657
  // --- Proactive Message Queue ---
@@ -541,7 +2672,7 @@ export function enqueueProactiveMessage(
541
2672
  ): { queued: number; dropped: number } {
542
2673
  const boundedMax = Math.max(1, Math.floor(maxQueueSize));
543
2674
  db.prepare(
544
- 'INSERT INTO proactive_message_queue (channel_id, text, source, queued_at) VALUES (?, ?, ?, datetime(\'now\'))',
2675
+ "INSERT INTO proactive_message_queue (channel_id, text, source, queued_at) VALUES (?, ?, ?, datetime('now'))",
545
2676
  ).run(channelId, text, source);
546
2677
 
547
2678
  const countRow = db
@@ -566,7 +2697,9 @@ export function enqueueProactiveMessage(
566
2697
  };
567
2698
  }
568
2699
 
569
- export function listQueuedProactiveMessages(limit = 100): QueuedProactiveMessage[] {
2700
+ export function listQueuedProactiveMessages(
2701
+ limit = 100,
2702
+ ): QueuedProactiveMessage[] {
570
2703
  const boundedLimit = Math.max(1, Math.floor(limit));
571
2704
  return db
572
2705
  .prepare('SELECT * FROM proactive_message_queue ORDER BY id ASC LIMIT ?')