@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.
- package/.github/workflows/ci.yml +70 -0
- package/.husky/pre-commit +1 -0
- package/CHANGELOG.md +85 -0
- package/CONTRIBUTING.md +33 -0
- package/README.md +41 -16
- package/SECURITY.md +17 -0
- package/biome.json +35 -0
- package/config.example.json +71 -8
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/approval-policy.ts +1303 -0
- package/container/src/browser-tools.ts +431 -136
- package/container/src/extensions.ts +36 -12
- package/container/src/hybridai-client.ts +34 -13
- package/container/src/index.ts +451 -109
- package/container/src/ipc.ts +5 -3
- package/container/src/token-usage.ts +20 -10
- package/container/src/tools.ts +599 -225
- package/container/src/types.ts +32 -2
- package/container/src/web-fetch.ts +89 -32
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -2
- package/dist/agent.js.map +1 -1
- package/dist/audit-cli.d.ts.map +1 -1
- package/dist/audit-cli.js +4 -2
- package/dist/audit-cli.js.map +1 -1
- package/dist/audit-events.d.ts.map +1 -1
- package/dist/audit-events.js +53 -3
- package/dist/audit-events.js.map +1 -1
- package/dist/audit-trail.d.ts.map +1 -1
- package/dist/audit-trail.js +17 -8
- package/dist/audit-trail.js.map +1 -1
- package/dist/channels/discord/attachments.d.ts.map +1 -1
- package/dist/channels/discord/attachments.js +14 -7
- package/dist/channels/discord/attachments.js.map +1 -1
- package/dist/channels/discord/debounce.d.ts +9 -0
- package/dist/channels/discord/debounce.d.ts.map +1 -0
- package/dist/channels/discord/debounce.js +20 -0
- package/dist/channels/discord/debounce.js.map +1 -0
- package/dist/channels/discord/delivery.d.ts +4 -1
- package/dist/channels/discord/delivery.d.ts.map +1 -1
- package/dist/channels/discord/delivery.js +19 -3
- package/dist/channels/discord/delivery.js.map +1 -1
- package/dist/channels/discord/human-delay.d.ts +16 -0
- package/dist/channels/discord/human-delay.d.ts.map +1 -0
- package/dist/channels/discord/human-delay.js +29 -0
- package/dist/channels/discord/human-delay.js.map +1 -0
- package/dist/channels/discord/inbound.d.ts +4 -0
- package/dist/channels/discord/inbound.d.ts.map +1 -1
- package/dist/channels/discord/inbound.js +45 -4
- package/dist/channels/discord/inbound.js.map +1 -1
- package/dist/channels/discord/mentions.d.ts.map +1 -1
- package/dist/channels/discord/mentions.js +16 -4
- package/dist/channels/discord/mentions.js.map +1 -1
- package/dist/channels/discord/presence.d.ts +33 -0
- package/dist/channels/discord/presence.d.ts.map +1 -0
- package/dist/channels/discord/presence.js +111 -0
- package/dist/channels/discord/presence.js.map +1 -0
- package/dist/channels/discord/rate-limiter.d.ts +14 -0
- package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
- package/dist/channels/discord/rate-limiter.js +49 -0
- package/dist/channels/discord/rate-limiter.js.map +1 -0
- package/dist/channels/discord/reactions.d.ts +38 -0
- package/dist/channels/discord/reactions.d.ts.map +1 -0
- package/dist/channels/discord/reactions.js +151 -0
- package/dist/channels/discord/reactions.js.map +1 -0
- package/dist/channels/discord/runtime.d.ts +6 -3
- package/dist/channels/discord/runtime.d.ts.map +1 -1
- package/dist/channels/discord/runtime.js +621 -125
- package/dist/channels/discord/runtime.js.map +1 -1
- package/dist/channels/discord/stream.d.ts +4 -1
- package/dist/channels/discord/stream.d.ts.map +1 -1
- package/dist/channels/discord/stream.js +16 -8
- package/dist/channels/discord/stream.js.map +1 -1
- package/dist/channels/discord/tool-actions.d.ts.map +1 -1
- package/dist/channels/discord/tool-actions.js +24 -12
- package/dist/channels/discord/tool-actions.js.map +1 -1
- package/dist/channels/discord/typing.d.ts +15 -0
- package/dist/channels/discord/typing.d.ts.map +1 -0
- package/dist/channels/discord/typing.js +106 -0
- package/dist/channels/discord/typing.js.map +1 -0
- package/dist/chunk.d.ts.map +1 -1
- package/dist/chunk.js +4 -2
- package/dist/chunk.js.map +1 -1
- package/dist/cli.js +47 -22
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +103 -18
- package/dist/config.js.map +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +58 -26
- package/dist/container-runner.js.map +1 -1
- package/dist/container-setup.d.ts.map +1 -1
- package/dist/container-setup.js +10 -9
- package/dist/container-setup.js.map +1 -1
- package/dist/conversation.d.ts +2 -2
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +1 -1
- package/dist/conversation.js.map +1 -1
- package/dist/db.d.ts +118 -2
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +1568 -50
- package/dist/db.js.map +1 -1
- package/dist/delegation-manager.d.ts.map +1 -1
- package/dist/delegation-manager.js +3 -2
- package/dist/delegation-manager.js.map +1 -1
- package/dist/gateway-client.d.ts +2 -2
- package/dist/gateway-client.d.ts.map +1 -1
- package/dist/gateway-client.js +10 -4
- package/dist/gateway-client.js.map +1 -1
- package/dist/gateway-service.d.ts +3 -3
- package/dist/gateway-service.d.ts.map +1 -1
- package/dist/gateway-service.js +563 -73
- package/dist/gateway-service.js.map +1 -1
- package/dist/gateway-types.d.ts +24 -0
- package/dist/gateway-types.d.ts.map +1 -1
- package/dist/gateway-types.js.map +1 -1
- package/dist/gateway.js +179 -24
- package/dist/gateway.js.map +1 -1
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +20 -10
- package/dist/health.js.map +1 -1
- package/dist/heartbeat.d.ts +4 -0
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +48 -20
- package/dist/heartbeat.js.map +1 -1
- package/dist/hybridai-bots.d.ts.map +1 -1
- package/dist/hybridai-bots.js +4 -2
- package/dist/hybridai-bots.js.map +1 -1
- package/dist/instruction-approval-audit.d.ts.map +1 -1
- package/dist/instruction-approval-audit.js.map +1 -1
- package/dist/instruction-integrity.d.ts.map +1 -1
- package/dist/instruction-integrity.js +8 -2
- package/dist/instruction-integrity.js.map +1 -1
- package/dist/ipc.d.ts.map +1 -1
- package/dist/ipc.js +6 -1
- package/dist/ipc.js.map +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/memory-consolidation.d.ts +17 -0
- package/dist/memory-consolidation.d.ts.map +1 -0
- package/dist/memory-consolidation.js +25 -0
- package/dist/memory-consolidation.js.map +1 -0
- package/dist/memory-service.d.ts +200 -0
- package/dist/memory-service.d.ts.map +1 -0
- package/dist/memory-service.js +294 -0
- package/dist/memory-service.js.map +1 -0
- package/dist/mount-security.d.ts.map +1 -1
- package/dist/mount-security.js +31 -7
- package/dist/mount-security.js.map +1 -1
- package/dist/observability-ingest.d.ts.map +1 -1
- package/dist/observability-ingest.js +32 -11
- package/dist/observability-ingest.js.map +1 -1
- package/dist/onboarding.d.ts.map +1 -1
- package/dist/onboarding.js +32 -9
- package/dist/onboarding.js.map +1 -1
- package/dist/proactive-policy.d.ts.map +1 -1
- package/dist/proactive-policy.js +2 -1
- package/dist/proactive-policy.js.map +1 -1
- package/dist/prompt-hooks.d.ts.map +1 -1
- package/dist/prompt-hooks.js +9 -7
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +98 -1
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +477 -23
- package/dist/runtime-config.js.map +1 -1
- package/dist/scheduled-task-runner.d.ts +1 -0
- package/dist/scheduled-task-runner.d.ts.map +1 -1
- package/dist/scheduled-task-runner.js +29 -10
- package/dist/scheduled-task-runner.js.map +1 -1
- package/dist/scheduler.d.ts +43 -4
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +530 -56
- package/dist/scheduler.js.map +1 -1
- package/dist/session-export.d.ts +26 -0
- package/dist/session-export.d.ts.map +1 -0
- package/dist/session-export.js +149 -0
- package/dist/session-export.js.map +1 -0
- package/dist/session-maintenance.d.ts.map +1 -1
- package/dist/session-maintenance.js +75 -13
- package/dist/session-maintenance.js.map +1 -1
- package/dist/session-transcripts.d.ts.map +1 -1
- package/dist/session-transcripts.js.map +1 -1
- package/dist/side-effects.d.ts.map +1 -1
- package/dist/side-effects.js +14 -2
- package/dist/side-effects.js.map +1 -1
- package/dist/skills-guard.d.ts.map +1 -1
- package/dist/skills-guard.js +893 -130
- package/dist/skills-guard.js.map +1 -1
- package/dist/skills.d.ts +5 -0
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +29 -15
- package/dist/skills.js.map +1 -1
- package/dist/token-efficiency.d.ts.map +1 -1
- package/dist/token-efficiency.js.map +1 -1
- package/dist/tui.js +92 -11
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +146 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +24 -1
- package/dist/types.js.map +1 -1
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +42 -14
- package/dist/update.js.map +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +49 -9
- package/dist/workspace.js.map +1 -1
- package/docs/chat.html +9 -3
- package/docs/index.html +37 -13
- package/package.json +8 -2
- package/src/agent.ts +16 -3
- package/src/audit-cli.ts +44 -16
- package/src/audit-events.ts +69 -5
- package/src/audit-trail.ts +41 -15
- package/src/channels/discord/attachments.ts +81 -27
- package/src/channels/discord/debounce.ts +25 -0
- package/src/channels/discord/delivery.ts +57 -13
- package/src/channels/discord/human-delay.ts +48 -0
- package/src/channels/discord/inbound.ts +66 -7
- package/src/channels/discord/mentions.ts +42 -18
- package/src/channels/discord/presence.ts +148 -0
- package/src/channels/discord/rate-limiter.ts +58 -0
- package/src/channels/discord/reactions.ts +211 -0
- package/src/channels/discord/runtime.ts +1048 -182
- package/src/channels/discord/stream.ts +73 -27
- package/src/channels/discord/tool-actions.ts +78 -37
- package/src/channels/discord/typing.ts +140 -0
- package/src/chunk.ts +12 -4
- package/src/cli.ts +141 -56
- package/src/config.ts +192 -34
- package/src/container-runner.ts +132 -42
- package/src/container-setup.ts +57 -22
- package/src/conversation.ts +9 -7
- package/src/db.ts +2217 -84
- package/src/delegation-manager.ts +6 -2
- package/src/gateway-client.ts +41 -17
- package/src/gateway-service.ts +1019 -201
- package/src/gateway-types.ts +33 -0
- package/src/gateway.ts +321 -48
- package/src/health.ts +66 -26
- package/src/heartbeat.ts +84 -22
- package/src/hybridai-bots.ts +14 -5
- package/src/instruction-approval-audit.ts +4 -1
- package/src/instruction-integrity.ts +30 -9
- package/src/ipc.ts +23 -5
- package/src/logger.ts +4 -1
- package/src/memory-consolidation.ts +41 -0
- package/src/memory-service.ts +606 -0
- package/src/mount-security.ts +58 -13
- package/src/observability-ingest.ts +134 -35
- package/src/onboarding.ts +126 -35
- package/src/proactive-policy.ts +3 -1
- package/src/prompt-hooks.ts +40 -17
- package/src/runtime-config.ts +1114 -99
- package/src/scheduled-task-runner.ts +63 -11
- package/src/scheduler.ts +683 -60
- package/src/session-export.ts +196 -0
- package/src/session-maintenance.ts +125 -22
- package/src/session-transcripts.ts +12 -3
- package/src/side-effects.ts +28 -5
- package/src/skills-guard.ts +1067 -219
- package/src/skills.ts +163 -65
- package/src/token-efficiency.ts +31 -9
- package/src/tui.ts +166 -25
- package/src/types.ts +195 -2
- package/src/update.ts +79 -23
- package/src/workspace.ts +63 -11
- package/tests/approval-policy.test.ts +224 -0
- package/tests/discord.basic.test.ts +82 -2
- package/tests/discord.human-presence.test.ts +85 -0
- package/tests/gateway-service.media-routing.test.ts +8 -2
- package/tests/memory-service.test.ts +1114 -0
- package/tests/token-efficiency.basic.test.ts +8 -2
- package/vitest.e2e.config.ts +3 -1
- package/vitest.integration.config.ts +3 -1
- package/vitest.live.config.ts +3 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (!
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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(
|
|
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
|
-
|
|
197
|
-
|
|
1596
|
+
return db.prepare('SELECT * FROM sessions WHERE id = ?').get(sessionId) as
|
|
1597
|
+
| Session
|
|
1598
|
+
| undefined;
|
|
198
1599
|
}
|
|
199
1600
|
|
|
200
|
-
export function updateSessionChatbot(
|
|
201
|
-
|
|
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(
|
|
205
|
-
|
|
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(
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
):
|
|
238
|
-
db
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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 ?')
|