wendkeep 0.70.0 → 0.72.0
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 +57 -0
- package/README.en.md +7 -3
- package/README.md +7 -3
- package/docs/en/commands/observer.md +80 -35
- package/docs/pt-BR/commands/observer.md +82 -37
- package/hooks/observer-publish.mjs +1 -0
- package/package.json +3 -2
- package/packages/integrations/src/host-hooks.mjs +2 -0
- 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/observer-memory-publish.mjs +335 -0
- package/src/observer-memory.mjs +308 -0
- package/src/observer-publish.mjs +23 -9
- package/src/observer-server.mjs +335 -22
- package/src/observer-sql-migrate.mjs +335 -0
- package/src/observer-sql-publish.mjs +398 -0
- package/src/observer-sql-store.mjs +542 -0
- package/src/observer-transcript-store.mjs +49 -0
- package/src/observer.mjs +72 -16
- package/web/observer/app.mjs +858 -0
- package/web/observer/favicon.svg +5 -0
- package/web/observer/index.html +126 -0
- package/web/observer/styles.css +262 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parseSessionCost } from './cost.mjs';
|
|
5
|
+
import {
|
|
6
|
+
ensureObserverDatabase,
|
|
7
|
+
ingestObserverEvents,
|
|
8
|
+
registerSqlProject,
|
|
9
|
+
} from './observer-sql-store.mjs';
|
|
10
|
+
|
|
11
|
+
const ROOT_FILES = new Set(['CORE.md', 'DIGEST.md', 'SHARED_MEMORY.md']);
|
|
12
|
+
const ROOTS = ['02-Sessões', '04-Decisões', '05-Bugs', '06-Aprendizados', '07-Specs', '08-Mudanças', '.brain'];
|
|
13
|
+
|
|
14
|
+
function hash(value) { return createHash('sha256').update(String(value)).digest('hex'); }
|
|
15
|
+
function text(value, fallback = '') { return String(value ?? fallback); }
|
|
16
|
+
function readJson(path, fallback) {
|
|
17
|
+
if (!existsSync(path)) return fallback;
|
|
18
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseScalar(raw) {
|
|
22
|
+
const value = text(raw).trim();
|
|
23
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1).replaceAll("''", "'");
|
|
24
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseFrontmatter(content) {
|
|
29
|
+
const match = String(content).match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
30
|
+
if (!match) return {};
|
|
31
|
+
const fields = {};
|
|
32
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
33
|
+
const field = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
|
34
|
+
if (field) fields[field[1]] = parseScalar(field[2]);
|
|
35
|
+
}
|
|
36
|
+
return fields;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function walk(root, relativeRoot, out) {
|
|
40
|
+
if (!existsSync(root)) return;
|
|
41
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
42
|
+
const logicalPath = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name;
|
|
43
|
+
if (entry.name.endsWith('.tmp') || entry.name.endsWith('.lock')
|
|
44
|
+
|| ['observer-memory-outbox', 'observer-outbox', 'observer-sql-outbox', 'observer-sql-state.json', 'observer-memory-state.json'].includes(entry.name)) continue;
|
|
45
|
+
const absolute = join(root, entry.name);
|
|
46
|
+
if (entry.isDirectory()) walk(absolute, logicalPath, out);
|
|
47
|
+
else if (entry.isFile()) out.push({ absolute, logicalPath });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function listMigrationDocuments(vaultBase) {
|
|
52
|
+
const files = [];
|
|
53
|
+
for (const root of ROOTS) walk(join(vaultBase, root), root, files);
|
|
54
|
+
for (const rootFile of ROOT_FILES) {
|
|
55
|
+
const absolute = join(vaultBase, rootFile);
|
|
56
|
+
if (existsSync(absolute) && statSync(absolute).isFile()) files.push({ absolute, logicalPath: rootFile });
|
|
57
|
+
}
|
|
58
|
+
return files.sort((a, b) => a.logicalPath.localeCompare(b.logicalPath));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function entityType(logicalPath) {
|
|
62
|
+
if (logicalPath.startsWith('02-Sessões/')) return 'session';
|
|
63
|
+
if (logicalPath.startsWith('04-Decisões/')) return 'decision';
|
|
64
|
+
if (logicalPath.startsWith('05-Bugs/')) return 'bug';
|
|
65
|
+
if (logicalPath.startsWith('06-Aprendizados/')) return 'learning';
|
|
66
|
+
if (logicalPath.startsWith('07-Specs/')) return 'spec';
|
|
67
|
+
if (logicalPath.startsWith('08-Mudanças/')) return 'change';
|
|
68
|
+
return 'memory';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function eventId(kind, projectId, seed) { return `migration-${kind}-${hash(`${projectId}:${seed}`).slice(0, 24)}`; }
|
|
72
|
+
|
|
73
|
+
function oldMemoryEvent(event) {
|
|
74
|
+
return {
|
|
75
|
+
schema_version: 1,
|
|
76
|
+
event_id: event.event_id,
|
|
77
|
+
kind: event.operation === 'delete' ? 'document.delete' : 'document.upsert',
|
|
78
|
+
project_id: event.project_id,
|
|
79
|
+
occurred_at: event.captured_at || new Date().toISOString(),
|
|
80
|
+
payload: {
|
|
81
|
+
logical_path: event.logical_path,
|
|
82
|
+
entity_type: event.entity_type || entityType(event.logical_path || ''),
|
|
83
|
+
content: event.content || '',
|
|
84
|
+
content_hash: event.content_hash || '',
|
|
85
|
+
revision: event.revision || 1,
|
|
86
|
+
source_session_id: event.source_session_id || '',
|
|
87
|
+
source_turn_id: event.source_turn_id || '',
|
|
88
|
+
metadata: event.metadata || {},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readMemoryEvents(dataDir, projectId) {
|
|
94
|
+
const path = join(dataDir, 'MEMORY_EVENTS.jsonl');
|
|
95
|
+
if (!existsSync(path)) return [];
|
|
96
|
+
return readFileSync(path, 'utf8').split(/\r?\n/).filter(Boolean).flatMap((line) => {
|
|
97
|
+
try {
|
|
98
|
+
const event = JSON.parse(line);
|
|
99
|
+
return event.project_id === projectId && event.logical_path ? [event] : [];
|
|
100
|
+
} catch { return []; }
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function buildSessionIdentityMap({ projectId, sessionFiles = [] } = {}) {
|
|
105
|
+
const counts = new Map();
|
|
106
|
+
for (const source of sessionFiles) {
|
|
107
|
+
const fm = source.fm || parseFrontmatter(source.content || '');
|
|
108
|
+
const declared = text(fm.session_id) || `historical:${hash(`${projectId}:${source.file.logicalPath}`).slice(0, 20)}`;
|
|
109
|
+
counts.set(declared, (counts.get(declared) || 0) + 1);
|
|
110
|
+
}
|
|
111
|
+
const occurrences = new Map();
|
|
112
|
+
return new Map(sessionFiles.map((source) => {
|
|
113
|
+
const fm = source.fm || parseFrontmatter(source.content || '');
|
|
114
|
+
const declared = text(fm.session_id) || `historical:${hash(`${projectId}:${source.file.logicalPath}`).slice(0, 20)}`;
|
|
115
|
+
const occurrence = occurrences.get(declared) || 0;
|
|
116
|
+
occurrences.set(declared, occurrence + 1);
|
|
117
|
+
const canonical = counts.get(declared) > 1 && occurrence > 0
|
|
118
|
+
? `${declared}:duplicate:${hash(`${projectId}:${source.file.logicalPath}`).slice(0, 16)}`
|
|
119
|
+
: declared;
|
|
120
|
+
return [source.file.logicalPath, canonical];
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readRegistry(vaultBase) {
|
|
125
|
+
const path = join(vaultBase, '.brain', 'SESSION_REGISTRY.json');
|
|
126
|
+
if (!existsSync(path)) return { sessions: {} };
|
|
127
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return { sessions: {} }; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function transcriptSourceFor(vaultBase, logicalPath, fields) {
|
|
131
|
+
const direct = text(fields.transcript_path || fields.transcriptPath);
|
|
132
|
+
if (direct && existsSync(direct)) return direct;
|
|
133
|
+
const sessionId = text(fields.session_id);
|
|
134
|
+
const entry = Object.entries(readRegistry(vaultBase).sessions || {})
|
|
135
|
+
.find(([id, item]) => id === sessionId || String(item?.session_file || '').replaceAll('\\', '/') === logicalPath)?.[1];
|
|
136
|
+
const candidate = text(entry?.transcript_path || entry?.transcript_paths?.[0]);
|
|
137
|
+
return candidate && existsSync(candidate) ? candidate : '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function ledgerRows(content, cost) {
|
|
141
|
+
const rows = cost.ledger?.length
|
|
142
|
+
? [...cost.ledger]
|
|
143
|
+
: [{ provider: '', model: cost.model, source: 'main', calls: 0, total: cost.tokens, cost: cost.mainCost }];
|
|
144
|
+
if (!cost.ledger?.length && cost.subCost) rows.push({ provider: '', model: 'subagents (histórico)', source: 'subagent', calls: 0, total: cost.subTokens, cost: cost.subCost });
|
|
145
|
+
const hasField = (key) => new RegExp(`^${key}:\\s*.+$`, 'm').test(content);
|
|
146
|
+
for (const [source, expectedCost, expectedTokens, hasCost, hasTokens] of [
|
|
147
|
+
['main', Number(cost.mainCost) || 0, Number(cost.tokens) || 0, hasField('custo_modelo_usd'), hasField('tokens_total')],
|
|
148
|
+
['subagent', Number(cost.subCost) || 0, Number(cost.subTokens) || 0, hasField('subagents_custo_usd'), hasField('subagents_tokens_total')],
|
|
149
|
+
]) {
|
|
150
|
+
const actual = rows.filter((row) => (text(row.source, 'main') === 'subagent' ? 'subagent' : 'main') === source);
|
|
151
|
+
const actualCost = actual.reduce((sum, row) => sum + (Number(row.cost) || 0), 0);
|
|
152
|
+
const actualTokens = actual.reduce((sum, row) => sum + (Number(row.total) || 0), 0);
|
|
153
|
+
const costDelta = hasCost ? Number((expectedCost - actualCost).toFixed(8)) : 0;
|
|
154
|
+
const tokenDelta = hasTokens ? Math.trunc(expectedTokens - actualTokens) : 0;
|
|
155
|
+
if (Math.abs(costDelta) > 0.00000001 || tokenDelta !== 0) {
|
|
156
|
+
rows.push({
|
|
157
|
+
provider: '', model: 'historical-frontmatter-adjustment', source, calls: 0,
|
|
158
|
+
input: 0, cacheWrite: 0, cached: 0, output: 0, reasoning: 0,
|
|
159
|
+
total: Math.max(0, tokenDelta), cost: costDelta,
|
|
160
|
+
metadata: { source: 'frontmatter-reconciliation', expected_cost: expectedCost, ledger_cost: actualCost },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return rows;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function usageEvent({ projectId, sessionId, agentId, row, index, occurredAt, provider, revision = 1, fingerprint = '' }) {
|
|
168
|
+
const source = text(row.source, 'main');
|
|
169
|
+
const role = source === 'subagent' ? 'subagent' : 'main';
|
|
170
|
+
return {
|
|
171
|
+
schema_version: 1,
|
|
172
|
+
event_id: eventId('usage', projectId, `${sessionId}:${source}:${row.provider || ''}:${row.model || ''}:${index}:${revision}:${fingerprint}`),
|
|
173
|
+
kind: 'usage.rollup',
|
|
174
|
+
project_id: projectId,
|
|
175
|
+
occurred_at: occurredAt,
|
|
176
|
+
payload: {
|
|
177
|
+
rollup_key: `${projectId}:${sessionId}:${agentId}:${row.provider || ''}:${row.model || ''}`,
|
|
178
|
+
revision,
|
|
179
|
+
session_id: sessionId,
|
|
180
|
+
agent_id: agentId,
|
|
181
|
+
role,
|
|
182
|
+
provider,
|
|
183
|
+
model_provider: row.provider || '',
|
|
184
|
+
model: row.model || '?',
|
|
185
|
+
effort: row.effort || '',
|
|
186
|
+
calls: Number(row.calls) || 0,
|
|
187
|
+
tokens: {
|
|
188
|
+
input: Number(row.input) || 0,
|
|
189
|
+
cache_write: Number(row.cacheWrite) || 0,
|
|
190
|
+
cache_read: Number(row.cached ?? row.cacheRead) || 0,
|
|
191
|
+
output: Number(row.output) || 0,
|
|
192
|
+
reasoning: Number(row.reasoning) || 0,
|
|
193
|
+
total: Number(row.total) || 0,
|
|
194
|
+
},
|
|
195
|
+
cost_usd: Number(row.cost) || 0,
|
|
196
|
+
cost_status: row.cost ? 'known' : 'unknown',
|
|
197
|
+
pricing_source: 'historical-frontmatter',
|
|
198
|
+
pricing_version: 'historical',
|
|
199
|
+
metadata: { migrated: true, source: 'custo_por_modelo_json', ...(row.metadata || {}) },
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function sessionEvents({ projectId, logicalPath, content, cost, revision = 1, sessionId: sessionIdOverride = '' }) {
|
|
205
|
+
const fm = parseFrontmatter(content);
|
|
206
|
+
const sessionId = text(sessionIdOverride) || text(fm.session_id) || `historical:${hash(`${projectId}:${logicalPath}`).slice(0, 20)}`;
|
|
207
|
+
const fingerprint = hash(content).slice(0, 24);
|
|
208
|
+
const provider = text(fm.provider);
|
|
209
|
+
const occurredAt = text(fm.ended_at || fm.updated_at || fm.date, new Date().toISOString());
|
|
210
|
+
const mainAgentId = `${projectId}:${sessionId}:main`;
|
|
211
|
+
const events = [
|
|
212
|
+
{
|
|
213
|
+
schema_version: 1, event_id: eventId('session', projectId, `${logicalPath}:${fingerprint}`), kind: 'session.upsert', project_id: projectId, occurred_at: occurredAt,
|
|
214
|
+
payload: { session_id: sessionId, provider, status: text(fm.status, 'unknown'), summary: text(fm.summary), change_slug: text(fm.change_slug), started_at: fm.started_at || null, ended_at: fm.ended_at || null, metadata: { migrated: true, logical_path: logicalPath } },
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
schema_version: 1, event_id: eventId('agent', projectId, `${logicalPath}:main:${fingerprint}`), kind: 'agent.upsert', project_id: projectId, occurred_at: occurredAt,
|
|
218
|
+
payload: { agent_id: mainAgentId, session_id: sessionId, role: 'main', agent_name: provider, agent_type: provider, status: text(fm.status, 'unknown'), model: text(fm.modelo), effort: text(fm.nivel_pensamento), started_at: fm.started_at || null, ended_at: fm.ended_at || null },
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
for (const [index, row] of ledgerRows(content, cost).entries()) {
|
|
222
|
+
const role = text(row.source, 'main') === 'subagent' ? 'subagent' : 'main';
|
|
223
|
+
const agentId = role === 'main' ? mainAgentId : `${projectId}:${sessionId}:subagent:${index}`;
|
|
224
|
+
if (role === 'subagent') {
|
|
225
|
+
events.push({
|
|
226
|
+
schema_version: 1, event_id: eventId('agent', projectId, `${logicalPath}:subagent:${index}:${fingerprint}`), kind: 'agent.upsert', project_id: projectId, occurred_at: occurredAt,
|
|
227
|
+
payload: { agent_id: agentId, session_id: sessionId, parent_agent_id: mainAgentId, role, agent_name: text(row.agent_nickname || row.agentType, 'historical-subagent'), agent_type: 'historical-subagent', status: 'done', model: text(row.model) },
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
events.push(usageEvent({ projectId, sessionId, agentId, row, index, occurredAt, provider, revision, fingerprint }));
|
|
231
|
+
}
|
|
232
|
+
const transcriptId = text(fm.observability_transcript_id || fm.observability_transcript_id);
|
|
233
|
+
if (transcriptId) events.push({
|
|
234
|
+
schema_version: 1, event_id: eventId('transcript', projectId, `${logicalPath}:${transcriptId}:${fingerprint}`), kind: 'transcript.upsert', project_id: projectId, occurred_at: occurredAt,
|
|
235
|
+
payload: { transcript_id: transcriptId, session_id: sessionId, agent_id: mainAgentId, coverage: 'summary_only', content: '', source: 'historical-frontmatter', metadata: { migrated: true } },
|
|
236
|
+
});
|
|
237
|
+
return { events, sessionId, rollups: events.filter((event) => event.kind === 'usage.rollup').length, summaryOnly: transcriptId ? 1 : 0 };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function migrateObserverData({ dataDir, vaultBase, projectId, projectName = projectId, transcriptSources = {}, database = null } = {}) {
|
|
241
|
+
if (!dataDir || !vaultBase || !projectId) throw new Error('dataDir, vaultBase e projectId são obrigatórios.');
|
|
242
|
+
const db = database || ensureObserverDatabase(dataDir);
|
|
243
|
+
const ownsDatabase = !database;
|
|
244
|
+
try {
|
|
245
|
+
registerSqlProject(db, { projectId, projectName });
|
|
246
|
+
const stats = { project_id: projectId, documents: 0, sessions: 0, rollups: 0, summary_only_transcripts: 0, accepted: 0, duplicates: 0, conflicts: 0, rejected: 0 };
|
|
247
|
+
const sourceEvents = readMemoryEvents(dataDir, projectId);
|
|
248
|
+
const seen = new Set(sourceEvents.map((event) => `${event.logical_path}:${event.revision || 1}`));
|
|
249
|
+
if (sourceEvents.length) {
|
|
250
|
+
const result = ingestObserverEvents(db, { projectId, events: sourceEvents.map(oldMemoryEvent) });
|
|
251
|
+
stats.accepted += result.accepted; stats.duplicates += result.duplicates; stats.conflicts += result.conflicts; stats.rejected += result.rejected;
|
|
252
|
+
}
|
|
253
|
+
const files = listMigrationDocuments(vaultBase);
|
|
254
|
+
const sessionSources = [];
|
|
255
|
+
for (const file of files) {
|
|
256
|
+
const content = readFileSync(file.absolute, 'utf8');
|
|
257
|
+
const fm = parseFrontmatter(content);
|
|
258
|
+
const revision = Number(fm.revision) || 1;
|
|
259
|
+
if (!seen.has(`${file.logicalPath}:${revision}`)) {
|
|
260
|
+
const event = {
|
|
261
|
+
schema_version: 1,
|
|
262
|
+
event_id: eventId('document', projectId, `${file.logicalPath}:${revision}:${hash(content)}`),
|
|
263
|
+
kind: 'document.upsert', project_id: projectId, occurred_at: text(fm.updated_at || fm.ended_at || fm.date, new Date(statSync(file.absolute).mtimeMs).toISOString()),
|
|
264
|
+
payload: { logical_path: file.logicalPath, entity_type: entityType(file.logicalPath), title: file.logicalPath.split('/').pop().replace(/\.md$/i, ''), content, content_hash: hash(content), revision, metadata: fm, source_session_id: text(fm.session_id) },
|
|
265
|
+
};
|
|
266
|
+
const result = ingestObserverEvents(db, { projectId, events: [event] });
|
|
267
|
+
stats.accepted += result.accepted; stats.duplicates += result.duplicates; stats.conflicts += result.conflicts; stats.rejected += result.rejected;
|
|
268
|
+
}
|
|
269
|
+
stats.documents += 1;
|
|
270
|
+
if (fm.type === 'session') sessionSources.push({ file, content });
|
|
271
|
+
}
|
|
272
|
+
const sessionIdentity = buildSessionIdentityMap({ projectId, sessionFiles: sessionSources });
|
|
273
|
+
for (const source of sessionSources) {
|
|
274
|
+
const cost = parseSessionCost(source.content);
|
|
275
|
+
const built = sessionEvents({ projectId, logicalPath: source.file.logicalPath, content: source.content, cost: cost || { model: '?', mainCost: 0, subCost: 0, tokens: 0, subTokens: 0, ledger: [] }, revision: Number(parseFrontmatter(source.content).revision) || 1, sessionId: sessionIdentity.get(source.file.logicalPath) });
|
|
276
|
+
const result = ingestObserverEvents(db, { projectId, events: built.events });
|
|
277
|
+
stats.sessions += 1;
|
|
278
|
+
stats.rollups += built.rollups;
|
|
279
|
+
stats.summary_only_transcripts += built.summaryOnly;
|
|
280
|
+
stats.accepted += result.accepted; stats.duplicates += result.duplicates; stats.conflicts += result.conflicts; stats.rejected += result.rejected;
|
|
281
|
+
const sourceFields = parseFrontmatter(source.content);
|
|
282
|
+
const transcriptId = sourceFields.observability_transcript_id;
|
|
283
|
+
const transcriptSource = transcriptId && (transcriptSources[transcriptId] || transcriptSourceFor(vaultBase, source.file.logicalPath, sourceFields));
|
|
284
|
+
if (transcriptSource) {
|
|
285
|
+
const sessionId = built.sessionId;
|
|
286
|
+
const transcriptContent = existsSync(transcriptSource) ? readFileSync(transcriptSource, 'utf8') : String(transcriptSource);
|
|
287
|
+
const agentId = `${projectId}:${sessionId}:main`;
|
|
288
|
+
const event = {
|
|
289
|
+
schema_version: 1, event_id: eventId('transcript-content', projectId, `${source.file.logicalPath}:${transcriptId}`), kind: 'transcript.upsert', project_id: projectId,
|
|
290
|
+
occurred_at: new Date().toISOString(), payload: { transcript_id: transcriptId, session_id: sessionId, agent_id: agentId, coverage: 'complete', content: transcriptContent, source: 'migration-source' },
|
|
291
|
+
};
|
|
292
|
+
const imported = ingestObserverEvents(db, { projectId, events: [event] });
|
|
293
|
+
stats.accepted += imported.accepted; stats.duplicates += imported.duplicates; stats.conflicts += imported.conflicts; stats.rejected += imported.rejected;
|
|
294
|
+
if (imported.accepted) stats.summary_only_transcripts = Math.max(0, stats.summary_only_transcripts - 1);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return stats;
|
|
298
|
+
} finally { if (ownsDatabase) db.close(); }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function migrateObserverContainerData(dataDir, { database = null } = {}) {
|
|
302
|
+
const memoryRoot = join(dataDir, 'memory');
|
|
303
|
+
if (!existsSync(memoryRoot)) return { skipped: true, projects: 0, documents: 0, events: 0 };
|
|
304
|
+
const markerPath = join(dataDir, 'observer-sql-legacy-migration.json');
|
|
305
|
+
const sourceFiles = [join(dataDir, 'MEMORY_EVENTS.jsonl'), join(dataDir, 'MEMORY_INDEX.json')]
|
|
306
|
+
.filter((path) => existsSync(path))
|
|
307
|
+
.map((path) => { const stat = statSync(path); return `${path}:${stat.size}:${stat.mtimeMs}`; });
|
|
308
|
+
const signature = hash(sourceFiles.join('|'));
|
|
309
|
+
const marker = existsSync(markerPath) ? readJson(markerPath, null) : null;
|
|
310
|
+
if (marker?.signature === signature) return { skipped: true, projects: Number(marker.projects || 0), documents: Number(marker.documents || 0), events: Number(marker.events || 0) };
|
|
311
|
+
const index = readJson(join(dataDir, 'MEMORY_INDEX.json'), { projects: {} });
|
|
312
|
+
const projectIds = new Set(Object.keys(index.projects || {}));
|
|
313
|
+
for (const entry of readdirSync(memoryRoot, { withFileTypes: true })) if (entry.isDirectory()) projectIds.add(entry.name);
|
|
314
|
+
const stats = { skipped: false, projects: 0, documents: 0, events: 0, accepted: 0, duplicates: 0, conflicts: 0, rejected: 0 };
|
|
315
|
+
for (const projectId of projectIds) {
|
|
316
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,120}$/.test(projectId)) continue;
|
|
317
|
+
const project = index.projects?.[projectId] || {};
|
|
318
|
+
const result = migrateObserverData({
|
|
319
|
+
dataDir,
|
|
320
|
+
vaultBase: join(memoryRoot, projectId),
|
|
321
|
+
projectId,
|
|
322
|
+
projectName: project.project_name || projectId,
|
|
323
|
+
database,
|
|
324
|
+
});
|
|
325
|
+
stats.projects += 1;
|
|
326
|
+
stats.documents += result.documents;
|
|
327
|
+
stats.events += result.accepted;
|
|
328
|
+
stats.accepted += result.accepted;
|
|
329
|
+
stats.duplicates += result.duplicates;
|
|
330
|
+
stats.conflicts += result.conflicts;
|
|
331
|
+
stats.rejected += result.rejected;
|
|
332
|
+
}
|
|
333
|
+
writeFileSync(markerPath, `${JSON.stringify({ signature, ...stats }, null, 2)}\n`, 'utf8');
|
|
334
|
+
return stats;
|
|
335
|
+
}
|