wendkeep 0.71.1 → 0.72.1
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/CHANGELOG.md +64 -3
- package/README.en.md +6 -2
- package/README.md +6 -2
- package/docs/en/commands/observer.md +68 -43
- package/docs/en/commands/sessions-and-import.md +4 -4
- package/docs/pt-BR/commands/observer.md +70 -45
- package/docs/pt-BR/commands/sessions-and-import.md +4 -4
- package/hooks/observer-publish.mjs +1 -0
- package/hooks/understand-inject.mjs +1 -1
- package/package.json +5 -4
- package/packages/integrations/src/host-hooks.mjs +3 -1
- package/packages/vault/src/memory-store.mjs +17 -4
- package/schema/observer/001-authority.sql +107 -0
- package/schema/observer/002-usage.sql +72 -0
- package/schema/observer/003-transcripts.sql +21 -0
- package/src/init.mjs +2 -2
- package/src/observer-auth.mjs +10 -0
- package/src/observer-memory-publish.mjs +15 -9
- package/src/observer-privacy.mjs +23 -0
- package/src/observer-publish.mjs +25 -20
- package/src/observer-server.mjs +274 -40
- package/src/observer-sql-migrate.mjs +335 -0
- package/src/observer-sql-publish.mjs +439 -0
- package/src/observer-sql-store.mjs +573 -0
- package/src/observer-transcript-store.mjs +49 -0
- package/src/observer.mjs +33 -8
- package/src/release-changelog.mjs +1 -1
- package/src/release-provenance.mjs +68 -0
- package/src/taxonomy.mjs +1 -1
- package/src/vault-readme.mjs +2 -2
- package/web/observer/app.mjs +248 -1
- package/web/observer/index.html +1 -0
- package/web/observer/styles.css +34 -1
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { join, basename, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { decodeTranscript, encodeTranscript } from './observer-transcript-store.mjs';
|
|
7
|
+
|
|
8
|
+
export const OBSERVER_SQL_FILE = 'observer.sqlite';
|
|
9
|
+
export const OBSERVER_SQL_SCHEMA_VERSION = 3;
|
|
10
|
+
export const OBSERVER_EVENT_SCHEMA_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
const SCHEMA_DIR = fileURLToPath(new URL('../schema/observer/', import.meta.url));
|
|
13
|
+
const PROJECT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,120}$/;
|
|
14
|
+
const EVENT_KINDS = new Set([
|
|
15
|
+
'document.upsert', 'document.delete', 'session.upsert', 'agent.upsert',
|
|
16
|
+
'usage.rollup', 'llm_call', 'transcript.upsert',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const OBSERVER_SQL_MINIMUM_NODE = '22.13.0';
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
let DatabaseSync;
|
|
22
|
+
|
|
23
|
+
export function observerSqlRuntimeSupport(version = process.versions.node) {
|
|
24
|
+
const current = String(version || '0.0.0');
|
|
25
|
+
const [major = 0, minor = 0] = current.split('.').map((part) => Number(part) || 0);
|
|
26
|
+
return {
|
|
27
|
+
supported: major > 22 || (major === 22 && minor >= 13),
|
|
28
|
+
minimum: OBSERVER_SQL_MINIMUM_NODE,
|
|
29
|
+
current,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function observerSqlRuntimeError(support = observerSqlRuntimeSupport()) {
|
|
34
|
+
const error = new Error(`Observer SQL requer Node.js >= ${support.minimum}; atual: ${support.current}. O Keep Core continua compatível com Node.js >= 18.`);
|
|
35
|
+
error.code = 'WENDKEEP_OBSERVER_NODE_UNSUPPORTED';
|
|
36
|
+
return error;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function observerDatabaseSync() {
|
|
40
|
+
const support = observerSqlRuntimeSupport();
|
|
41
|
+
if (!support.supported) throw observerSqlRuntimeError(support);
|
|
42
|
+
if (!DatabaseSync) {
|
|
43
|
+
try { ({ DatabaseSync } = require('node:sqlite')); }
|
|
44
|
+
catch { throw observerSqlRuntimeError(support); }
|
|
45
|
+
}
|
|
46
|
+
return DatabaseSync;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function now() { return new Date().toISOString(); }
|
|
50
|
+
|
|
51
|
+
function text(value, fallback = '') { return String(value ?? fallback); }
|
|
52
|
+
|
|
53
|
+
function number(value, fallback = 0) {
|
|
54
|
+
const parsed = Number(value);
|
|
55
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function integer(value, fallback = 0) {
|
|
59
|
+
return Math.max(0, Math.trunc(number(value, fallback)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function json(value, fallback = {}) {
|
|
63
|
+
try { return JSON.stringify(value ?? fallback); } catch { return JSON.stringify(fallback); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseJson(value, fallback = {}) {
|
|
67
|
+
try { return JSON.parse(value || JSON.stringify(fallback)); } catch { return fallback; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function hash(value) {
|
|
71
|
+
return createHash('sha256').update(typeof value === 'string' ? value : json(value)).digest('hex');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validProjectId(value) { return typeof value === 'string' && PROJECT_ID_RE.test(value); }
|
|
75
|
+
|
|
76
|
+
function requireProject(db, projectId) {
|
|
77
|
+
if (!validProjectId(projectId)) throw new Error('project_id inválido.');
|
|
78
|
+
const project = db.prepare('SELECT project_id FROM projects WHERE project_id = ?').get(projectId);
|
|
79
|
+
if (!project) {
|
|
80
|
+
const error = new Error(`project_id não registrado: ${projectId}`);
|
|
81
|
+
error.code = 'project_not_registered';
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
return project;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function migrationFiles() {
|
|
88
|
+
return readdirSync(SCHEMA_DIR)
|
|
89
|
+
.filter((name) => /^\d+-.*\.sql$/i.test(name))
|
|
90
|
+
.sort((a, b) => Number(a.split('-')[0]) - Number(b.split('-')[0]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function openObserverDatabase(dataDir) {
|
|
94
|
+
if (!dataDir) throw new Error('dataDir é obrigatório.');
|
|
95
|
+
mkdirSync(dataDir, { recursive: true });
|
|
96
|
+
const SqliteDatabase = observerDatabaseSync();
|
|
97
|
+
const db = new SqliteDatabase(join(dataDir, OBSERVER_SQL_FILE));
|
|
98
|
+
db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;');
|
|
99
|
+
return db;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function migrateObserverDatabase(db) {
|
|
103
|
+
if (!db) throw new Error('db é obrigatório.');
|
|
104
|
+
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)');
|
|
105
|
+
const applied = new Set(db.prepare('SELECT version FROM schema_migrations').all().map((row) => Number(row.version)));
|
|
106
|
+
for (const file of migrationFiles()) {
|
|
107
|
+
const version = Number(file.split('-')[0]);
|
|
108
|
+
if (applied.has(version)) continue;
|
|
109
|
+
const sql = readFileSync(join(SCHEMA_DIR, file), 'utf8');
|
|
110
|
+
db.exec('BEGIN IMMEDIATE');
|
|
111
|
+
try {
|
|
112
|
+
db.exec(sql);
|
|
113
|
+
db.prepare('INSERT INTO schema_migrations(version, name, applied_at) VALUES (?, ?, ?)').run(version, file, now());
|
|
114
|
+
db.exec('COMMIT');
|
|
115
|
+
} catch (error) {
|
|
116
|
+
db.exec('ROLLBACK');
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
version: Number(db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get().version || 0),
|
|
122
|
+
applied: db.prepare('SELECT version, name, applied_at FROM schema_migrations ORDER BY version').all(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function ensureObserverDatabase(dataDir) {
|
|
127
|
+
const db = openObserverDatabase(dataDir);
|
|
128
|
+
try {
|
|
129
|
+
migrateObserverDatabase(db);
|
|
130
|
+
return db;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
db.close();
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function registerSqlProject(db, { projectId, projectName = projectId, wendkeepVersion = '', registeredAt = now() } = {}) {
|
|
138
|
+
if (!validProjectId(projectId)) return { registered: false, errors: ['project_id inválido.'] };
|
|
139
|
+
const timestamp = now();
|
|
140
|
+
db.prepare(`
|
|
141
|
+
INSERT INTO projects(project_id, project_name, wendkeep_version, registered_at, updated_at)
|
|
142
|
+
VALUES (?, ?, ?, ?, ?)
|
|
143
|
+
ON CONFLICT(project_id) DO UPDATE SET
|
|
144
|
+
project_name = excluded.project_name,
|
|
145
|
+
wendkeep_version = excluded.wendkeep_version,
|
|
146
|
+
updated_at = excluded.updated_at
|
|
147
|
+
`).run(projectId, text(projectName || projectId).slice(0, 200), text(wendkeepVersion).slice(0, 40), text(registeredAt), timestamp);
|
|
148
|
+
return { registered: true, project: db.prepare('SELECT * FROM projects WHERE project_id = ?').get(projectId) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validateEvent(event, projectId) {
|
|
152
|
+
const errors = [];
|
|
153
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) errors.push('evento deve ser um objeto.');
|
|
154
|
+
if (event?.schema_version !== OBSERVER_EVENT_SCHEMA_VERSION) errors.push('schema_version incompatível.');
|
|
155
|
+
if (!event?.event_id || typeof event.event_id !== 'string') errors.push('event_id ausente.');
|
|
156
|
+
if (event?.project_id !== projectId) errors.push('project_id do evento não corresponde à rota.');
|
|
157
|
+
if (!EVENT_KINDS.has(event?.kind)) errors.push('kind de evento inválido.');
|
|
158
|
+
if (!event?.occurred_at || Number.isNaN(Date.parse(event.occurred_at))) errors.push('occurred_at inválido.');
|
|
159
|
+
if (!event?.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)) errors.push('payload inválido.');
|
|
160
|
+
return { ok: errors.length === 0, errors };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function tokenFields(tokens = {}) {
|
|
164
|
+
return [
|
|
165
|
+
integer(tokens.input), integer(tokens.cache_write ?? tokens.cacheWrite), integer(tokens.cache_read ?? tokens.cached),
|
|
166
|
+
integer(tokens.output), integer(tokens.reasoning), integer(tokens.total),
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function ensureSession(db, projectId, payload) {
|
|
171
|
+
const sessionId = text(payload.session_id || payload.sessionId);
|
|
172
|
+
if (!sessionId) throw new Error('session_id ausente.');
|
|
173
|
+
db.prepare(`
|
|
174
|
+
INSERT INTO sessions(session_id, project_id, provider, status, summary, change_slug, started_at, ended_at, updated_at, metadata_json)
|
|
175
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
176
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
177
|
+
provider = CASE WHEN excluded.provider <> '' THEN excluded.provider ELSE sessions.provider END,
|
|
178
|
+
status = CASE WHEN excluded.status <> '' THEN excluded.status ELSE sessions.status END,
|
|
179
|
+
summary = CASE WHEN excluded.summary <> '' THEN excluded.summary ELSE sessions.summary END,
|
|
180
|
+
change_slug = CASE WHEN excluded.change_slug <> '' THEN excluded.change_slug ELSE sessions.change_slug END,
|
|
181
|
+
started_at = COALESCE(excluded.started_at, sessions.started_at),
|
|
182
|
+
ended_at = COALESCE(excluded.ended_at, sessions.ended_at),
|
|
183
|
+
updated_at = excluded.updated_at,
|
|
184
|
+
metadata_json = CASE WHEN excluded.metadata_json <> '{}' THEN excluded.metadata_json ELSE sessions.metadata_json END
|
|
185
|
+
`).run(
|
|
186
|
+
sessionId, projectId, text(payload.provider), text(payload.status, 'unknown'), text(payload.summary), text(payload.change_slug || payload.changeSlug),
|
|
187
|
+
payload.started_at || payload.startedAt || null, payload.ended_at || payload.endedAt || null, now(), json(payload.metadata),
|
|
188
|
+
);
|
|
189
|
+
return sessionId;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function ensureAgent(db, projectId, payload) {
|
|
193
|
+
const agentId = text(payload.agent_id || payload.agentId);
|
|
194
|
+
if (!agentId) throw new Error('agent_id ausente.');
|
|
195
|
+
const sessionId = ensureSession(db, projectId, payload);
|
|
196
|
+
db.prepare(`
|
|
197
|
+
INSERT INTO agent_runs(agent_id, project_id, session_id, parent_agent_id, role, agent_name, agent_type, workflow, status, model, effort, started_at, ended_at, metadata_json)
|
|
198
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
199
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
200
|
+
parent_agent_id = COALESCE(excluded.parent_agent_id, agent_runs.parent_agent_id),
|
|
201
|
+
role = excluded.role,
|
|
202
|
+
agent_name = CASE WHEN excluded.agent_name <> '' THEN excluded.agent_name ELSE agent_runs.agent_name END,
|
|
203
|
+
agent_type = CASE WHEN excluded.agent_type <> '' THEN excluded.agent_type ELSE agent_runs.agent_type END,
|
|
204
|
+
workflow = CASE WHEN excluded.workflow <> '' THEN excluded.workflow ELSE agent_runs.workflow END,
|
|
205
|
+
status = CASE WHEN excluded.status <> '' THEN excluded.status ELSE agent_runs.status END,
|
|
206
|
+
model = CASE WHEN excluded.model <> '' THEN excluded.model ELSE agent_runs.model END,
|
|
207
|
+
effort = CASE WHEN excluded.effort <> '' THEN excluded.effort ELSE agent_runs.effort END,
|
|
208
|
+
started_at = COALESCE(excluded.started_at, agent_runs.started_at),
|
|
209
|
+
ended_at = COALESCE(excluded.ended_at, agent_runs.ended_at),
|
|
210
|
+
metadata_json = CASE WHEN excluded.metadata_json <> '{}' THEN excluded.metadata_json ELSE agent_runs.metadata_json END
|
|
211
|
+
`).run(
|
|
212
|
+
agentId, projectId, sessionId, payload.parent_agent_id || payload.parentAgentId || null, text(payload.role, 'main'),
|
|
213
|
+
text(payload.agent_name || payload.agentName), text(payload.agent_type || payload.agentType), text(payload.workflow),
|
|
214
|
+
text(payload.status, 'unknown'), text(payload.model), text(payload.effort), payload.started_at || null, payload.ended_at || null, json(payload.metadata),
|
|
215
|
+
);
|
|
216
|
+
return agentId;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function applyDocument(db, event) {
|
|
220
|
+
const p = event.payload;
|
|
221
|
+
const content = text(p.content);
|
|
222
|
+
const logicalPath = text(p.logical_path || p.logicalPath);
|
|
223
|
+
if (!logicalPath || logicalPath.includes('..') || /^[A-Za-z]:[\\/]/.test(logicalPath)) throw new Error('logical_path inválido.');
|
|
224
|
+
const current = db.prepare('SELECT revision FROM documents WHERE project_id = ? AND logical_path = ?').get(event.project_id, logicalPath);
|
|
225
|
+
const revision = integer(p.revision, 1);
|
|
226
|
+
const contentHash = text(p.content_hash || p.contentHash) || hash(content);
|
|
227
|
+
if (current && revision < Number(current.revision)) return { stale: true };
|
|
228
|
+
if (current && revision === Number(current.revision)) {
|
|
229
|
+
const currentHash = db.prepare('SELECT content_hash FROM documents WHERE project_id = ? AND logical_path = ?').get(event.project_id, logicalPath)?.content_hash;
|
|
230
|
+
if (currentHash === contentHash) return { stale: true };
|
|
231
|
+
const error = new Error(`revisão ${revision} já existe para ${logicalPath} com conteúdo diferente.`);
|
|
232
|
+
error.code = 'document_conflict';
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
const memoryEvent = db.prepare('SELECT content_hash FROM memory_events WHERE project_id = ? AND logical_path = ? AND revision = ?')
|
|
236
|
+
.get(event.project_id, logicalPath, revision);
|
|
237
|
+
if (memoryEvent) {
|
|
238
|
+
if (memoryEvent.content_hash === contentHash) return { stale: true };
|
|
239
|
+
const error = new Error(`revisão ${revision} já existe para ${logicalPath} com conteúdo diferente.`);
|
|
240
|
+
error.code = 'document_conflict';
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
const title = text(p.title || basename(logicalPath).replace(/\.md$/i, ''));
|
|
244
|
+
const documentId = text(p.document_id || p.documentId) || `${event.project_id}:${logicalPath}`;
|
|
245
|
+
db.prepare(`
|
|
246
|
+
INSERT INTO documents(document_id, project_id, logical_path, entity_type, title, content, metadata_json, content_hash, revision, source_session_id, source_turn_id, captured_at, deleted_at)
|
|
247
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
|
248
|
+
ON CONFLICT(project_id, logical_path) DO UPDATE SET
|
|
249
|
+
document_id = excluded.document_id,
|
|
250
|
+
entity_type = excluded.entity_type,
|
|
251
|
+
title = excluded.title,
|
|
252
|
+
content = excluded.content,
|
|
253
|
+
metadata_json = excluded.metadata_json,
|
|
254
|
+
content_hash = excluded.content_hash,
|
|
255
|
+
revision = excluded.revision,
|
|
256
|
+
source_session_id = excluded.source_session_id,
|
|
257
|
+
source_turn_id = excluded.source_turn_id,
|
|
258
|
+
captured_at = excluded.captured_at,
|
|
259
|
+
deleted_at = NULL
|
|
260
|
+
`).run(
|
|
261
|
+
documentId, event.project_id, logicalPath, text(p.entity_type || p.entityType, 'memory'), title, content, json(p.metadata), contentHash,
|
|
262
|
+
revision, text(p.source_session_id || p.sourceSessionId), text(p.source_turn_id || p.sourceTurnId), text(p.captured_at || event.occurred_at),
|
|
263
|
+
);
|
|
264
|
+
db.prepare(`
|
|
265
|
+
INSERT INTO memory_events(event_id, project_id, entity_type, logical_path, operation, revision, content_hash, source_session_id, source_turn_id, occurred_at, payload_json)
|
|
266
|
+
VALUES (?, ?, ?, ?, 'upsert', ?, ?, ?, ?, ?, ?)
|
|
267
|
+
`).run(event.event_id, event.project_id, text(p.entity_type || p.entityType, 'memory'), logicalPath, revision, contentHash,
|
|
268
|
+
text(p.source_session_id || p.sourceSessionId), text(p.source_turn_id || p.sourceTurnId), event.occurred_at, json(p));
|
|
269
|
+
return { stale: false };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function applyDocumentDelete(db, event) {
|
|
273
|
+
const logicalPath = text(event.payload.logical_path || event.payload.logicalPath);
|
|
274
|
+
if (!logicalPath) throw new Error('logical_path ausente.');
|
|
275
|
+
db.prepare('UPDATE documents SET deleted_at = ?, revision = MAX(revision, ?) WHERE project_id = ? AND logical_path = ?')
|
|
276
|
+
.run(event.occurred_at, integer(event.payload.revision, 1), event.project_id, logicalPath);
|
|
277
|
+
db.prepare(`
|
|
278
|
+
INSERT INTO memory_events(event_id, project_id, entity_type, logical_path, operation, revision, content_hash, source_session_id, source_turn_id, occurred_at, payload_json)
|
|
279
|
+
VALUES (?, ?, ?, ?, 'delete', ?, '', ?, ?, ?, ?)
|
|
280
|
+
`).run(event.event_id, event.project_id, text(event.payload.entity_type || event.payload.entityType, 'memory'), logicalPath,
|
|
281
|
+
integer(event.payload.revision, 1), text(event.payload.source_session_id || event.payload.sourceSessionId), text(event.payload.source_turn_id || event.payload.sourceTurnId), event.occurred_at, json(event.payload));
|
|
282
|
+
return { stale: false };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function applyUsageRollup(db, event) {
|
|
286
|
+
const p = event.payload;
|
|
287
|
+
const sessionId = ensureSession(db, event.project_id, p);
|
|
288
|
+
const agentId = ensureAgent(db, event.project_id, p);
|
|
289
|
+
const [input, cacheWrite, cacheRead, output, reasoning, total] = tokenFields(p.tokens);
|
|
290
|
+
const rollupKey = text(p.rollup_key || p.rollupKey) || [event.project_id, sessionId, agentId, p.model_provider || p.modelProvider || '', p.model || '', p.effort || ''].join(':');
|
|
291
|
+
const revision = integer(p.revision, 1);
|
|
292
|
+
const current = db.prepare('SELECT revision FROM usage_rollups WHERE rollup_key = ?').get(rollupKey);
|
|
293
|
+
if (current && revision < Number(current.revision)) return { stale: true };
|
|
294
|
+
db.prepare(`
|
|
295
|
+
INSERT INTO usage_rollups(rollup_key, project_id, session_id, agent_id, role, provider, model_provider, model, effort, calls, tokens_input, tokens_cache_write, tokens_cache_read, tokens_output, tokens_reasoning, tokens_total, cost_usd, cost_status, pricing_source, pricing_version, wasted_usd, revision, occurred_at, source_event_id, metadata_json)
|
|
296
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
297
|
+
ON CONFLICT(rollup_key) DO UPDATE SET
|
|
298
|
+
calls = excluded.calls, tokens_input = excluded.tokens_input, tokens_cache_write = excluded.tokens_cache_write,
|
|
299
|
+
tokens_cache_read = excluded.tokens_cache_read, tokens_output = excluded.tokens_output, tokens_reasoning = excluded.tokens_reasoning,
|
|
300
|
+
tokens_total = excluded.tokens_total, cost_usd = excluded.cost_usd, cost_status = excluded.cost_status,
|
|
301
|
+
pricing_source = excluded.pricing_source, pricing_version = excluded.pricing_version, wasted_usd = excluded.wasted_usd,
|
|
302
|
+
revision = excluded.revision, occurred_at = excluded.occurred_at, source_event_id = excluded.source_event_id, metadata_json = excluded.metadata_json
|
|
303
|
+
`).run(
|
|
304
|
+
rollupKey, event.project_id, sessionId, agentId, text(p.role, 'main'), text(p.provider), text(p.model_provider || p.modelProvider), text(p.model), text(p.effort),
|
|
305
|
+
integer(p.calls), input, cacheWrite, cacheRead, output, reasoning, total, number(p.cost_usd ?? p.costUsd), text(p.cost_status || p.costStatus, 'unknown'),
|
|
306
|
+
text(p.pricing_source || p.pricingSource), text(p.pricing_version || p.pricingVersion), number(p.wasted_usd ?? p.wastedUsd), revision, event.occurred_at, event.event_id, json(p.metadata),
|
|
307
|
+
);
|
|
308
|
+
return { stale: false };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function applyCall(db, event) {
|
|
312
|
+
const p = event.payload;
|
|
313
|
+
const sessionId = ensureSession(db, event.project_id, p);
|
|
314
|
+
const agentId = ensureAgent(db, event.project_id, p);
|
|
315
|
+
const callId = text(p.call_id || p.callId);
|
|
316
|
+
if (!callId) throw new Error('call_id ausente.');
|
|
317
|
+
if (db.prepare('SELECT call_id FROM llm_calls WHERE call_id = ?').get(callId)) {
|
|
318
|
+
const error = new Error(`call_id já existe: ${callId}`);
|
|
319
|
+
error.code = 'call_conflict';
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
const [input, cacheWrite, cacheRead, output, reasoning, total] = tokenFields(p.tokens);
|
|
323
|
+
db.prepare(`
|
|
324
|
+
INSERT INTO llm_calls(call_id, project_id, session_id, agent_id, role, provider, model_provider, model, effort, sequence, occurred_at, tokens_input, tokens_cache_write, tokens_cache_read, tokens_output, tokens_reasoning, tokens_total, cost_usd, cost_status, transcript_id, prompt_text, response_text, status, metadata_json)
|
|
325
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
326
|
+
`).run(
|
|
327
|
+
callId, event.project_id, sessionId, agentId, text(p.role, 'main'), text(p.provider), text(p.model_provider || p.modelProvider), text(p.model), text(p.effort), integer(p.sequence),
|
|
328
|
+
text(p.occurred_at || event.occurred_at), input, cacheWrite, cacheRead, output, reasoning, total, number(p.cost_usd ?? p.costUsd), text(p.cost_status || p.costStatus, 'unknown'),
|
|
329
|
+
p.transcript_id || p.transcriptId || null, text(p.prompt_text || p.promptText || p.prompt), text(p.response_text || p.responseText || p.response), text(p.status, 'complete'), json(p.metadata),
|
|
330
|
+
);
|
|
331
|
+
return { stale: false };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function applyTranscript(db, event) {
|
|
335
|
+
const p = event.payload;
|
|
336
|
+
const sessionId = ensureSession(db, event.project_id, p);
|
|
337
|
+
const agentId = ensureAgent(db, event.project_id, p);
|
|
338
|
+
const transcriptId = text(p.transcript_id || p.transcriptId);
|
|
339
|
+
if (!transcriptId) throw new Error('transcript_id ausente.');
|
|
340
|
+
const encoded = encodeTranscript(p.content);
|
|
341
|
+
db.prepare(`
|
|
342
|
+
INSERT INTO transcripts(transcript_id, project_id, session_id, agent_id, coverage, codec, content_gzip, content_sha256, original_bytes, compressed_bytes, source, occurred_at, metadata_json)
|
|
343
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
344
|
+
ON CONFLICT(transcript_id) DO UPDATE SET
|
|
345
|
+
coverage = excluded.coverage, codec = excluded.codec, content_gzip = excluded.content_gzip,
|
|
346
|
+
content_sha256 = excluded.content_sha256, original_bytes = excluded.original_bytes, compressed_bytes = excluded.compressed_bytes,
|
|
347
|
+
source = excluded.source, occurred_at = excluded.occurred_at, metadata_json = excluded.metadata_json
|
|
348
|
+
`).run(
|
|
349
|
+
transcriptId, event.project_id, sessionId, agentId, text(p.coverage, 'complete'), encoded.codec, encoded.content_gzip, encoded.content_sha256,
|
|
350
|
+
encoded.original_bytes, encoded.compressed_bytes, text(p.source), event.occurred_at, json(p.metadata),
|
|
351
|
+
);
|
|
352
|
+
return { stale: false };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function applyEvent(db, event) {
|
|
356
|
+
if (event.kind === 'document.upsert') return applyDocument(db, event);
|
|
357
|
+
if (event.kind === 'document.delete') return applyDocumentDelete(db, event);
|
|
358
|
+
if (event.kind === 'session.upsert') { ensureSession(db, event.project_id, event.payload); return { stale: false }; }
|
|
359
|
+
if (event.kind === 'agent.upsert') { ensureAgent(db, event.project_id, event.payload); return { stale: false }; }
|
|
360
|
+
if (event.kind === 'usage.rollup') return applyUsageRollup(db, event);
|
|
361
|
+
if (event.kind === 'llm_call') return applyCall(db, event);
|
|
362
|
+
if (event.kind === 'transcript.upsert') return applyTranscript(db, event);
|
|
363
|
+
throw new Error('kind não implementado.');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function ingestObserverEvents(db, { projectId, events = [] } = {}) {
|
|
367
|
+
requireProject(db, projectId);
|
|
368
|
+
const result = { accepted: 0, duplicates: 0, conflicts: 0, stale: 0, rejected: 0, results: [] };
|
|
369
|
+
for (const event of events) {
|
|
370
|
+
const validation = validateEvent(event, projectId);
|
|
371
|
+
if (!validation.ok) {
|
|
372
|
+
result.rejected += 1;
|
|
373
|
+
result.results.push({ accepted: false, errors: validation.errors, event_id: event?.event_id || '' });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
// The event ID is the stable identity. Capture time is transport metadata:
|
|
377
|
+
// retries must remain idempotent when the same event is reconstructed later.
|
|
378
|
+
// The payload itself still detects a real content/semantic conflict.
|
|
379
|
+
const payloadHash = hash({ kind: event.kind, project_id: event.project_id, payload: event.payload });
|
|
380
|
+
const existing = db.prepare('SELECT payload_hash, payload_json, kind, project_id FROM ingest_events WHERE event_id = ?').get(event.event_id);
|
|
381
|
+
if (existing) {
|
|
382
|
+
const existingCanonicalHash = hash({ kind: existing.kind, project_id: existing.project_id, payload: parseJson(existing.payload_json) });
|
|
383
|
+
if (existing.payload_hash === payloadHash || existingCanonicalHash === payloadHash) {
|
|
384
|
+
if (existing.payload_hash !== payloadHash) {
|
|
385
|
+
db.prepare('UPDATE ingest_events SET payload_hash = ? WHERE event_id = ?').run(payloadHash, event.event_id);
|
|
386
|
+
}
|
|
387
|
+
result.duplicates += 1;
|
|
388
|
+
result.results.push({ accepted: false, duplicate: true, event_id: event.event_id });
|
|
389
|
+
} else {
|
|
390
|
+
result.conflicts += 1;
|
|
391
|
+
result.results.push({ accepted: false, conflict: true, event_id: event.event_id });
|
|
392
|
+
}
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
db.prepare('INSERT INTO ingest_events(event_id, project_id, kind, payload_hash, payload_json, occurred_at, ingested_at, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
|
397
|
+
.run(event.event_id, projectId, event.kind, payloadHash, json(event.payload), event.occurred_at, now(), 'accepted');
|
|
398
|
+
const applied = applyEvent(db, event);
|
|
399
|
+
if (applied.stale) {
|
|
400
|
+
db.prepare('UPDATE ingest_events SET status = ? WHERE event_id = ?').run('stale', event.event_id);
|
|
401
|
+
result.stale += 1;
|
|
402
|
+
result.results.push({ accepted: false, stale: true, event_id: event.event_id });
|
|
403
|
+
} else {
|
|
404
|
+
result.accepted += 1;
|
|
405
|
+
result.results.push({ accepted: true, event_id: event.event_id });
|
|
406
|
+
}
|
|
407
|
+
} catch (error) {
|
|
408
|
+
db.prepare('DELETE FROM ingest_events WHERE event_id = ?').run(event.event_id);
|
|
409
|
+
if (error?.code === 'call_conflict' || error?.code === 'document_conflict') {
|
|
410
|
+
result.conflicts += 1;
|
|
411
|
+
result.results.push({ accepted: false, conflict: true, event_id: event.event_id, errors: [error.message] });
|
|
412
|
+
} else {
|
|
413
|
+
result.rejected += 1;
|
|
414
|
+
result.results.push({ accepted: false, event_id: event.event_id, errors: [error.message] });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return result;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function usageFilter(filters = {}, alias = 'u') {
|
|
422
|
+
const where = [`${alias}.project_id = ?`];
|
|
423
|
+
const params = [filters.projectId];
|
|
424
|
+
if (filters.from) { where.push(`${alias}.occurred_at >= ?`); params.push(filters.from); }
|
|
425
|
+
if (filters.to) { where.push(`${alias}.occurred_at <= ?`); params.push(filters.to); }
|
|
426
|
+
if (filters.agentId) { where.push(`${alias}.agent_id = ?`); params.push(filters.agentId); }
|
|
427
|
+
if (filters.subagentId) { where.push(`${alias}.agent_id = ?`); params.push(filters.subagentId); where.push(`${alias}.role = 'subagent'`); }
|
|
428
|
+
if (filters.sessionId) { where.push(`${alias}.session_id = ?`); params.push(filters.sessionId); }
|
|
429
|
+
if (filters.changeSlug) {
|
|
430
|
+
where.push(`${alias}.session_id IN (SELECT session_id FROM sessions WHERE project_id = ? AND change_slug = ?)`);
|
|
431
|
+
params.push(filters.projectId, filters.changeSlug);
|
|
432
|
+
}
|
|
433
|
+
if (filters.role) { where.push(`${alias}.role = ?`); params.push(filters.role); }
|
|
434
|
+
if (filters.model) { where.push(`${alias}.model = ?`); params.push(filters.model); }
|
|
435
|
+
if (filters.provider) { where.push(`${alias}.provider = ?`); params.push(filters.provider); }
|
|
436
|
+
if (filters.modelProvider) { where.push(`${alias}.model_provider = ?`); params.push(filters.modelProvider); }
|
|
437
|
+
return { sql: where.join(' AND '), params };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function tokenObject(row, prefix = '') {
|
|
441
|
+
return {
|
|
442
|
+
input: integer(row?.[`${prefix}tokens_input`]), cache_write: integer(row?.[`${prefix}tokens_cache_write`]), cache_read: integer(row?.[`${prefix}tokens_cache_read`]),
|
|
443
|
+
output: integer(row?.[`${prefix}tokens_output`]), reasoning: integer(row?.[`${prefix}tokens_reasoning`]), total: integer(row?.[`${prefix}tokens_total`]),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function readUsageSummary(db, projectId, filters = {}) {
|
|
448
|
+
const f = usageFilter({ ...filters, projectId });
|
|
449
|
+
const aggregate = db.prepare(`SELECT COUNT(*) AS rollups, COALESCE(SUM(u.calls), 0) AS calls, COUNT(DISTINCT u.session_id) AS sessions, COUNT(DISTINCT u.agent_id) AS agents, COUNT(DISTINCT CASE WHEN u.role = 'subagent' THEN u.agent_id END) AS subagents, COUNT(DISTINCT u.model_provider || ':' || u.model) AS models, COALESCE(SUM(u.cost_usd), 0) AS cost_usd, COALESCE(SUM(CASE WHEN u.role = 'main' THEN u.cost_usd ELSE 0 END), 0) AS main_cost_usd, COALESCE(SUM(CASE WHEN u.role = 'subagent' THEN u.cost_usd ELSE 0 END), 0) AS subagent_cost_usd, COALESCE(SUM(u.wasted_usd), 0) AS wasted_usd, COALESCE(SUM(u.tokens_input), 0) AS tokens_input, COALESCE(SUM(u.tokens_cache_write), 0) AS tokens_cache_write, COALESCE(SUM(u.tokens_cache_read), 0) AS tokens_cache_read, COALESCE(SUM(u.tokens_output), 0) AS tokens_output, COALESCE(SUM(u.tokens_reasoning), 0) AS tokens_reasoning, COALESCE(SUM(u.tokens_total), 0) AS tokens_total, COALESCE(SUM(CASE WHEN u.cost_status = 'unknown' THEN 1 ELSE 0 END), 0) AS unknown_priced_rollups FROM usage_rollups u WHERE ${f.sql}`).get(...f.params);
|
|
450
|
+
const callsFilter = usageFilter({ ...filters, projectId }, 'c');
|
|
451
|
+
const raw = db.prepare(`SELECT COUNT(*) AS raw_calls FROM llm_calls c WHERE ${callsFilter.sql}`).get(...callsFilter.params);
|
|
452
|
+
const coverage = db.prepare(`SELECT COUNT(*) AS transcripts, COALESCE(SUM(CASE WHEN coverage = 'complete' THEN 1 ELSE 0 END), 0) AS complete, COALESCE(SUM(CASE WHEN coverage <> 'complete' THEN 1 ELSE 0 END), 0) AS summary_only FROM transcripts WHERE project_id = ?`).get(projectId);
|
|
453
|
+
const byDay = db.prepare(`SELECT substr(u.occurred_at, 1, 10) AS date, ROUND(COALESCE(SUM(u.cost_usd), 0), 4) AS cost_usd, COALESCE(SUM(u.tokens_total), 0) AS tokens_total, COALESCE(SUM(u.calls), 0) AS calls FROM usage_rollups u WHERE ${f.sql} GROUP BY substr(u.occurred_at, 1, 10) ORDER BY date`).all(...f.params);
|
|
454
|
+
return {
|
|
455
|
+
project_id: projectId,
|
|
456
|
+
sessions: integer(aggregate.sessions), agents: integer(aggregate.agents), subagents: integer(aggregate.subagents), models: integer(aggregate.models),
|
|
457
|
+
rollups: integer(aggregate.rollups), calls: integer(aggregate.calls), raw_calls: integer(raw.raw_calls),
|
|
458
|
+
cost_usd: Number(number(aggregate.cost_usd).toFixed(4)), main_cost_usd: Number(number(aggregate.main_cost_usd).toFixed(4)),
|
|
459
|
+
subagent_cost_usd: Number(number(aggregate.subagent_cost_usd).toFixed(4)), wasted_usd: Number(number(aggregate.wasted_usd).toFixed(4)),
|
|
460
|
+
tokens: tokenObject(aggregate), unknown_priced_rollups: integer(aggregate.unknown_priced_rollups), coverage: { transcripts: integer(coverage.transcripts), complete: integer(coverage.complete), summary_only: integer(coverage.summary_only) },
|
|
461
|
+
by_day: byDay.map((row) => ({ date: row.date, cost_usd: Number(number(row.cost_usd).toFixed(4)), tokens_total: integer(row.tokens_total), calls: integer(row.calls) })),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function readUsageBreakdown(db, projectId, filters = {}) {
|
|
466
|
+
const f = usageFilter({ ...filters, projectId });
|
|
467
|
+
const modelStatement = db.prepare(`SELECT u.agent_id, u.model_provider, u.model, u.effort, u.role, COALESCE(SUM(u.calls), 0) AS calls, COALESCE(SUM(u.cost_usd), 0) AS cost_usd, COALESCE(SUM(u.tokens_total), 0) AS tokens_total FROM usage_rollups u WHERE ${f.sql} GROUP BY u.agent_id, u.model_provider, u.model, u.effort ORDER BY cost_usd DESC`);
|
|
468
|
+
const modelRows = modelStatement.all(...f.params);
|
|
469
|
+
const allAgents = db.prepare(`SELECT a.agent_id, a.parent_agent_id, a.role, a.agent_name, a.agent_type, a.workflow, a.status, COALESCE(SUM(u.calls), 0) AS calls, COALESCE(SUM(u.cost_usd), 0) AS cost_usd, COALESCE(SUM(u.tokens_total), 0) AS tokens_total FROM agent_runs a LEFT JOIN usage_rollups u ON u.agent_id = a.agent_id AND ${f.sql.replace(/^u\./g, 'u.')} WHERE a.project_id = ? GROUP BY a.agent_id ORDER BY cost_usd DESC, a.agent_id`).all(...f.params, projectId);
|
|
470
|
+
const allById = new Map(allAgents.map((agent) => [agent.agent_id, agent]));
|
|
471
|
+
const visibleIds = new Set(modelRows.map((row) => row.agent_id));
|
|
472
|
+
for (const agentId of [...visibleIds]) {
|
|
473
|
+
let parentId = allById.get(agentId)?.parent_agent_id;
|
|
474
|
+
while (parentId && allById.has(parentId) && !visibleIds.has(parentId)) {
|
|
475
|
+
visibleIds.add(parentId);
|
|
476
|
+
parentId = allById.get(parentId).parent_agent_id;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const agents = allAgents.filter((agent) => visibleIds.has(agent.agent_id));
|
|
480
|
+
return {
|
|
481
|
+
project_id: projectId,
|
|
482
|
+
agents: agents.map((agent) => ({
|
|
483
|
+
...agent,
|
|
484
|
+
calls: integer(agent.calls), tokens_total: integer(agent.tokens_total), cost_usd: Number(number(agent.cost_usd).toFixed(4)),
|
|
485
|
+
models: modelRows.filter((row) => row.agent_id === agent.agent_id).map((row) => ({
|
|
486
|
+
model_provider: row.model_provider, model: row.model, effort: row.effort, role: row.role,
|
|
487
|
+
calls: integer(row.calls), tokens_total: integer(row.tokens_total), cost_usd: Number(number(row.cost_usd).toFixed(4)),
|
|
488
|
+
})),
|
|
489
|
+
})),
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export function readUsageCalls(db, projectId, filters = {}) {
|
|
494
|
+
const f = usageFilter({ ...filters, projectId }, 'c');
|
|
495
|
+
const limit = Math.min(500, Math.max(1, integer(filters.limit, 100)));
|
|
496
|
+
const offset = Math.max(0, integer(filters.offset));
|
|
497
|
+
const rows = db.prepare(`SELECT c.*, COUNT(*) OVER() AS total_count FROM llm_calls c WHERE ${f.sql} ORDER BY c.occurred_at DESC, c.sequence DESC, c.call_id LIMIT ? OFFSET ?`).all(...f.params, limit, offset);
|
|
498
|
+
return {
|
|
499
|
+
project_id: projectId,
|
|
500
|
+
offset,
|
|
501
|
+
limit,
|
|
502
|
+
total: integer(rows[0]?.total_count),
|
|
503
|
+
calls: rows.map((row) => ({
|
|
504
|
+
call_id: row.call_id, session_id: row.session_id, agent_id: row.agent_id, role: row.role, provider: row.provider,
|
|
505
|
+
model_provider: row.model_provider, model: row.model, effort: row.effort, sequence: integer(row.sequence), occurred_at: row.occurred_at,
|
|
506
|
+
tokens: tokenObject(row), cost_usd: Number(number(row.cost_usd).toFixed(4)), cost_status: row.cost_status,
|
|
507
|
+
transcript_id: row.transcript_id, prompt: row.prompt_text, response: row.response_text, status: row.status, metadata: parseJson(row.metadata_json),
|
|
508
|
+
})),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function readTranscript(db, projectId, transcriptId) {
|
|
513
|
+
requireProject(db, projectId);
|
|
514
|
+
const row = db.prepare('SELECT * FROM transcripts WHERE project_id = ? AND transcript_id = ?').get(projectId, transcriptId);
|
|
515
|
+
if (!row) {
|
|
516
|
+
const error = new Error('transcript não encontrado.');
|
|
517
|
+
error.code = 'transcript_not_found';
|
|
518
|
+
throw error;
|
|
519
|
+
}
|
|
520
|
+
return decodeTranscript(row);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function readSqlDocument(db, projectId, logicalPath) {
|
|
524
|
+
requireProject(db, projectId);
|
|
525
|
+
if (!logicalPath || logicalPath.includes('..') || /^[A-Za-z]:[\\/]/.test(logicalPath) || logicalPath.startsWith('/')) {
|
|
526
|
+
const error = new Error('logical_path inválido.');
|
|
527
|
+
error.code = 'invalid_memory_path';
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
const row = db.prepare('SELECT * FROM documents WHERE project_id = ? AND logical_path = ? AND deleted_at IS NULL').get(projectId, logicalPath);
|
|
531
|
+
if (!row) {
|
|
532
|
+
const error = new Error('documento não encontrado.');
|
|
533
|
+
error.code = 'memory_not_found';
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
return { ...row, metadata: parseJson(row.metadata_json) };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export function readSqlTree(db, projectId, prefix = '') {
|
|
540
|
+
requireProject(db, projectId);
|
|
541
|
+
const rows = db.prepare('SELECT logical_path, entity_type, title, content_hash, revision, captured_at, source_session_id FROM documents WHERE project_id = ? AND deleted_at IS NULL AND logical_path LIKE ? ORDER BY logical_path').all(projectId, `${prefix}%`);
|
|
542
|
+
return { schema_version: OBSERVER_SQL_SCHEMA_VERSION, project_id: projectId, prefix, documents: rows };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export function searchSqlDocuments(db, projectId, query = '') {
|
|
546
|
+
requireProject(db, projectId);
|
|
547
|
+
const q = `%${text(query).replace(/[%_]/g, '\\$&')}%`;
|
|
548
|
+
const rows = db.prepare(`SELECT logical_path, entity_type, title, content_hash, revision, captured_at, source_session_id, content FROM documents WHERE project_id = ? AND deleted_at IS NULL AND (logical_path LIKE ? ESCAPE '\\' OR title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\') ORDER BY captured_at DESC, logical_path LIMIT 100`).all(projectId, q, q, q);
|
|
549
|
+
return rows.map((row) => ({ ...row, excerpt: row.content.slice(0, 240) }));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function readSqlSync(db, projectId) {
|
|
553
|
+
requireProject(db, projectId);
|
|
554
|
+
const documents = db.prepare('SELECT COUNT(*) AS count FROM documents WHERE project_id = ? AND deleted_at IS NULL').get(projectId);
|
|
555
|
+
const events = db.prepare('SELECT COUNT(*) AS count FROM memory_events WHERE project_id = ?').get(projectId);
|
|
556
|
+
const pending = db.prepare("SELECT COUNT(*) AS count FROM ingest_events WHERE project_id = ? AND status <> 'accepted'").get(projectId);
|
|
557
|
+
return { mode: 'container-authority', project_id: projectId, document_count: integer(documents.count), event_count: integer(events.count), pending_count: integer(pending.count), database: OBSERVER_SQL_FILE, schema_version: OBSERVER_SQL_SCHEMA_VERSION };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export function exportSqlMemoryBundle(db, projectId) {
|
|
561
|
+
requireProject(db, projectId);
|
|
562
|
+
const rows = db.prepare('SELECT logical_path, entity_type, content, content_hash, revision, captured_at FROM documents WHERE project_id = ? AND deleted_at IS NULL ORDER BY logical_path').all(projectId);
|
|
563
|
+
return { schema_version: OBSERVER_SQL_SCHEMA_VERSION, project_id: projectId, generated_at: now(), documents: rows };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function readSqlProject(db, projectId) {
|
|
567
|
+
requireProject(db, projectId);
|
|
568
|
+
return db.prepare('SELECT * FROM projects WHERE project_id = ?').get(projectId);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function listSqlProjects(db) {
|
|
572
|
+
return db.prepare('SELECT * FROM projects ORDER BY project_name, project_id').all();
|
|
573
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { gunzipSync, gzipSync } from 'node:zlib';
|
|
3
|
+
|
|
4
|
+
export const TRANSCRIPT_CODEC = 'gzip';
|
|
5
|
+
|
|
6
|
+
export function encodeTranscript(content) {
|
|
7
|
+
const text = String(content ?? '');
|
|
8
|
+
const raw = Buffer.from(text, 'utf8');
|
|
9
|
+
const compressed = gzipSync(raw, { mtime: 0 });
|
|
10
|
+
return {
|
|
11
|
+
codec: TRANSCRIPT_CODEC,
|
|
12
|
+
content_gzip: compressed,
|
|
13
|
+
content_sha256: createHash('sha256').update(raw).digest('hex'),
|
|
14
|
+
original_bytes: raw.byteLength,
|
|
15
|
+
compressed_bytes: compressed.byteLength,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function decodeTranscript(row) {
|
|
20
|
+
if (!row) return null;
|
|
21
|
+
const compressed = Buffer.from(row.content_gzip || []);
|
|
22
|
+
const raw = row.codec === TRANSCRIPT_CODEC ? gunzipSync(compressed) : compressed;
|
|
23
|
+
const content = raw.toString('utf8');
|
|
24
|
+
const contentSha256 = createHash('sha256').update(raw).digest('hex');
|
|
25
|
+
if (row.content_sha256 && row.content_sha256 !== contentSha256) {
|
|
26
|
+
const error = new Error('hash do transcript não corresponde ao conteúdo.');
|
|
27
|
+
error.code = 'transcript_hash_mismatch';
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
transcript_id: row.transcript_id,
|
|
32
|
+
project_id: row.project_id,
|
|
33
|
+
session_id: row.session_id,
|
|
34
|
+
agent_id: row.agent_id,
|
|
35
|
+
coverage: row.coverage,
|
|
36
|
+
codec: row.codec,
|
|
37
|
+
content,
|
|
38
|
+
content_sha256: contentSha256,
|
|
39
|
+
original_bytes: Number(row.original_bytes) || raw.byteLength,
|
|
40
|
+
compressed_bytes: Number(row.compressed_bytes) || compressed.byteLength,
|
|
41
|
+
source: row.source || '',
|
|
42
|
+
occurred_at: row.occurred_at,
|
|
43
|
+
metadata: parseJson(row.metadata_json),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseJson(value) {
|
|
48
|
+
try { return JSON.parse(value || '{}'); } catch { return {}; }
|
|
49
|
+
}
|