wendkeep 0.69.0 → 0.71.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 +43 -0
- package/README.en.md +5 -3
- package/README.md +5 -3
- package/docs/en/commands/changes-and-verification.md +4 -0
- package/docs/en/commands/costs-and-observability.md +11 -2
- package/docs/en/commands/observer.md +130 -0
- package/docs/pt-BR/commands/changes-and-verification.md +4 -0
- package/docs/pt-BR/commands/costs-and-observability.md +12 -2
- package/docs/pt-BR/commands/observer.md +131 -0
- package/hooks/harness-doctor.mjs +21 -7
- package/hooks/observer-publish.mjs +21 -0
- package/hooks/pricing.json +10 -1
- package/hooks/token-usage.mjs +13 -0
- package/package.json +4 -3
- package/packages/cli/src/index.mjs +8 -1
- package/packages/integrations/src/host-hooks.mjs +4 -0
- package/src/observer-memory-publish.mjs +334 -0
- package/src/observer-memory.mjs +308 -0
- package/src/observer-publish.mjs +134 -0
- package/src/observer-server.mjs +333 -0
- package/src/observer-snapshot.mjs +153 -0
- package/src/observer-store.mjs +155 -0
- package/src/observer.mjs +146 -0
- package/src/taxonomy.mjs +2 -0
- package/web/observer/app.mjs +611 -0
- package/web/observer/favicon.svg +5 -0
- package/web/observer/index.html +125 -0
- package/web/observer/styles.css +229 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import {
|
|
5
|
+
appendObserverEvent,
|
|
6
|
+
getObserverProject,
|
|
7
|
+
listRegisteredObserverProjects,
|
|
8
|
+
readObserverIndex,
|
|
9
|
+
registerObserverProject,
|
|
10
|
+
} from './observer-store.mjs';
|
|
11
|
+
import { MAX_SNAPSHOT_BYTES, validateObserverSnapshot } from './observer-snapshot.mjs';
|
|
12
|
+
import {
|
|
13
|
+
applyMemoryEvent,
|
|
14
|
+
exportMemoryBundle,
|
|
15
|
+
readMemoryDocument,
|
|
16
|
+
readMemorySync,
|
|
17
|
+
readMemoryTree,
|
|
18
|
+
setMemoryMode,
|
|
19
|
+
searchMemory,
|
|
20
|
+
validateMemoryEvent,
|
|
21
|
+
} from './observer-memory.mjs';
|
|
22
|
+
|
|
23
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
24
|
+
const MAX_BODY_BYTES = MAX_SNAPSHOT_BYTES + 4096;
|
|
25
|
+
const MAX_MEMORY_BODY_BYTES = 8 * 1024 * 1024;
|
|
26
|
+
const STATIC_ROOT = fileURLToPath(new URL('../web/observer/', import.meta.url));
|
|
27
|
+
const STATIC_ASSETS = new Map([
|
|
28
|
+
['/index.html', { file: 'index.html', type: 'text/html; charset=utf-8' }],
|
|
29
|
+
['/styles.css', { file: 'styles.css', type: 'text/css; charset=utf-8' }],
|
|
30
|
+
['/app.mjs', { file: 'app.mjs', type: 'text/javascript; charset=utf-8' }],
|
|
31
|
+
['/favicon.svg', { file: 'favicon.svg', type: 'image/svg+xml' }],
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function loopbackOnly(host) {
|
|
35
|
+
return LOOPBACK_HOSTS.has(String(host || '').toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function json(res, status, body) {
|
|
39
|
+
const content = JSON.stringify(body);
|
|
40
|
+
res.writeHead(status, {
|
|
41
|
+
'content-type': 'application/json; charset=utf-8',
|
|
42
|
+
'cache-control': 'no-store',
|
|
43
|
+
'content-length': Buffer.byteLength(content),
|
|
44
|
+
});
|
|
45
|
+
res.end(content);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function errorResponse(res, status, code, message) {
|
|
49
|
+
json(res, status, { error: { code, message } });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function serveStatic(res, pathname) {
|
|
53
|
+
const asset = STATIC_ASSETS.get(pathname === '/' ? '/index.html' : pathname);
|
|
54
|
+
if (!asset) return false;
|
|
55
|
+
try {
|
|
56
|
+
const body = readFileSync(new URL(asset.file, `file://${STATIC_ROOT.replace(/\\/g, '/')}/`));
|
|
57
|
+
res.writeHead(200, {
|
|
58
|
+
'content-type': asset.type,
|
|
59
|
+
'cache-control': 'no-store',
|
|
60
|
+
'x-content-type-options': 'nosniff',
|
|
61
|
+
'content-length': body.byteLength,
|
|
62
|
+
});
|
|
63
|
+
res.end(body);
|
|
64
|
+
} catch {
|
|
65
|
+
errorResponse(res, 500, 'dashboard_asset_error', 'asset do dashboard indisponível.');
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readBody(req, maxBytes = MAX_BODY_BYTES) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
let size = 0;
|
|
73
|
+
let tooLarge = false;
|
|
74
|
+
const chunks = [];
|
|
75
|
+
req.on('data', (chunk) => {
|
|
76
|
+
if (tooLarge) return;
|
|
77
|
+
size += chunk.length;
|
|
78
|
+
if (size > maxBytes) {
|
|
79
|
+
tooLarge = true;
|
|
80
|
+
const error = new Error('corpo acima do limite.');
|
|
81
|
+
error.code = 'payload_too_large';
|
|
82
|
+
reject(error);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
chunks.push(chunk);
|
|
86
|
+
});
|
|
87
|
+
req.on('end', () => {
|
|
88
|
+
if (!tooLarge) resolve(Buffer.concat(chunks).toString('utf8'));
|
|
89
|
+
});
|
|
90
|
+
req.on('error', reject);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseJson(text) {
|
|
95
|
+
try { return JSON.parse(text || '{}'); }
|
|
96
|
+
catch {
|
|
97
|
+
const error = new Error('JSON inválido.');
|
|
98
|
+
error.code = 'invalid_json';
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pathParts(url) {
|
|
104
|
+
return new URL(url, 'http://127.0.0.1').pathname.split('/').filter(Boolean).map((part) => decodeURIComponent(part));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function projectIdFrom(parts) {
|
|
108
|
+
return parts[0] === 'v1' && parts[1] === 'projects' && parts[2] ? parts[2] : '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function projectKnown(dataDir, projectId) {
|
|
112
|
+
return Boolean(
|
|
113
|
+
getObserverProject(dataDir, projectId)
|
|
114
|
+
|| listRegisteredObserverProjects(dataDir).some((item) => item.projectId === projectId),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function startObserverServer({
|
|
119
|
+
host = '127.0.0.1',
|
|
120
|
+
port = 8787,
|
|
121
|
+
dataDir,
|
|
122
|
+
allowNonLoopback = false,
|
|
123
|
+
} = {}) {
|
|
124
|
+
if (!loopbackOnly(host) && !allowNonLoopback) {
|
|
125
|
+
throw new Error(`Observer HTTP aceita somente host loopback; recebido: ${host}`);
|
|
126
|
+
}
|
|
127
|
+
if (!dataDir) throw new Error('dataDir é obrigatório.');
|
|
128
|
+
const server = createServer(async (req, res) => {
|
|
129
|
+
try {
|
|
130
|
+
const pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname;
|
|
131
|
+
if (req.method === 'GET' && pathname === '/healthz') {
|
|
132
|
+
json(res, 200, { ok: true, service: 'wendkeep-observer', schema_version: 1 });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (req.method === 'GET' && (pathname === '/' || STATIC_ASSETS.has(pathname))) {
|
|
136
|
+
serveStatic(res, pathname);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (req.method === 'GET' && !pathname.startsWith('/v1/')) {
|
|
140
|
+
errorResponse(res, 404, 'not_found', 'rota não encontrada.');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const parts = pathParts(req.url || '/');
|
|
144
|
+
if (parts[0] !== 'v1' || parts[1] !== 'projects') {
|
|
145
|
+
errorResponse(res, 404, 'not_found', 'rota não encontrada.');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (parts.length === 2 && req.method === 'GET') {
|
|
150
|
+
const index = readObserverIndex(dataDir);
|
|
151
|
+
json(res, 200, {
|
|
152
|
+
schema_version: index.schema_version,
|
|
153
|
+
projects: index.projects.map(({ snapshot, ...summary }) => summary),
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const projectId = projectIdFrom(parts);
|
|
159
|
+
if (!projectId) {
|
|
160
|
+
errorResponse(res, 404, 'not_found', 'projeto não informado.');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (parts.length === 4 && parts[3] === 'sync' && req.method === 'GET') {
|
|
165
|
+
if (!projectKnown(dataDir, projectId)) {
|
|
166
|
+
errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
json(res, 200, readMemorySync(dataDir, projectId));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (parts.length === 4 && parts[3] === 'sync' && req.method === 'PUT') {
|
|
174
|
+
if (!projectKnown(dataDir, projectId)) {
|
|
175
|
+
errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const body = parseJson(await readBody(req));
|
|
179
|
+
try {
|
|
180
|
+
json(res, 200, setMemoryMode(dataDir, projectId, body.mode));
|
|
181
|
+
} catch (error) {
|
|
182
|
+
errorResponse(res, 400, error?.code || 'invalid_memory_mode', error?.message || 'modo inválido.');
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (parts.length >= 4 && parts[3] === 'memory') {
|
|
188
|
+
if (!projectKnown(dataDir, projectId)) {
|
|
189
|
+
errorResponse(res, 404, 'project_not_found', 'projeto não encontrado: ' + projectId);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const memoryAction = parts[4] || '';
|
|
193
|
+
if (memoryAction === 'tree' && req.method === 'GET') {
|
|
194
|
+
const query = new URL(req.url || '/', 'http://127.0.0.1').searchParams;
|
|
195
|
+
json(res, 200, readMemoryTree(dataDir, projectId, query.get('prefix') || ''));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (memoryAction === 'document' && req.method === 'GET') {
|
|
199
|
+
const query = new URL(req.url || '/', 'http://127.0.0.1').searchParams;
|
|
200
|
+
try {
|
|
201
|
+
json(res, 200, readMemoryDocument(dataDir, projectId, query.get('path') || ''));
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const status = error?.code === 'memory_not_found' ? 404 : 400;
|
|
204
|
+
errorResponse(res, status, error?.code || 'invalid_memory_path', error?.message || 'documento inválido.');
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (memoryAction === 'search' && req.method === 'GET') {
|
|
209
|
+
const query = new URL(req.url || '/', 'http://127.0.0.1').searchParams;
|
|
210
|
+
json(res, 200, { project_id: projectId, query: query.get('q') || '', results: searchMemory(dataDir, projectId, query.get('q') || '') });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (memoryAction === 'export' && req.method === 'GET') {
|
|
214
|
+
json(res, 200, exportMemoryBundle(dataDir, projectId));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (memoryAction === 'events' && req.method === 'POST') {
|
|
218
|
+
const body = parseJson(await readBody(req, MAX_MEMORY_BODY_BYTES));
|
|
219
|
+
const events = Array.isArray(body.events) ? body.events : [];
|
|
220
|
+
if (events.length === 0) {
|
|
221
|
+
errorResponse(res, 400, 'invalid_memory_batch', 'events deve conter pelo menos um evento.');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const results = [];
|
|
225
|
+
for (const event of events) {
|
|
226
|
+
if (event?.project_id !== projectId) {
|
|
227
|
+
results.push({ accepted: false, errors: ['project_id do evento não corresponde à rota.'] });
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const validation = validateMemoryEvent(event);
|
|
231
|
+
results.push(validation.ok ? applyMemoryEvent(dataDir, event) : { accepted: false, errors: validation.errors });
|
|
232
|
+
}
|
|
233
|
+
const accepted = results.filter((item) => item.accepted).length;
|
|
234
|
+
const duplicates = results.filter((item) => item.duplicate).length;
|
|
235
|
+
const conflicts = results.filter((item) => item.conflict).length;
|
|
236
|
+
const rejected = results.length - accepted - duplicates - conflicts;
|
|
237
|
+
if (conflicts > 0 || rejected > 0) {
|
|
238
|
+
json(res, conflicts > 0 ? 409 : 400, { accepted, duplicates, conflicts, rejected, results });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
json(res, accepted > 0 ? 201 : 200, { accepted, duplicates, conflicts, rejected, results });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (parts.length === 3 && req.method === 'GET') {
|
|
247
|
+
const project = getObserverProject(dataDir, projectId);
|
|
248
|
+
if (!project) {
|
|
249
|
+
errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
json(res, 200, project);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (parts.length === 3 && req.method === 'PUT') {
|
|
257
|
+
const body = parseJson(await readBody(req));
|
|
258
|
+
if (body.project_id !== projectId) {
|
|
259
|
+
errorResponse(res, 400, 'project_mismatch', 'project_id do corpo não corresponde à rota.');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const result = registerObserverProject(dataDir, {
|
|
263
|
+
projectId,
|
|
264
|
+
projectName: body.project_name,
|
|
265
|
+
wendkeepVersion: body.wendkeep_version,
|
|
266
|
+
});
|
|
267
|
+
if (!result.registered) {
|
|
268
|
+
errorResponse(res, 400, 'invalid_project', result.errors.join(' '));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
json(res, 201, result.project);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (parts.length === 4 && parts[3] === 'changes' && req.method === 'GET') {
|
|
276
|
+
const project = getObserverProject(dataDir, projectId);
|
|
277
|
+
if (!project) {
|
|
278
|
+
errorResponse(res, 404, 'project_not_found', `projeto não encontrado: ${projectId}`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
json(res, 200, { project_id: projectId, changes: project.snapshot?.changes || [] });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (parts.length === 4 && ['snapshot', 'snapshots'].includes(parts[3]) && req.method === 'POST') {
|
|
286
|
+
const body = parseJson(await readBody(req));
|
|
287
|
+
const validation = validateObserverSnapshot(body, { projectId });
|
|
288
|
+
if (!validation.ok) {
|
|
289
|
+
errorResponse(res, 400, 'invalid_snapshot', validation.errors.join(' '));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const result = appendObserverEvent(dataDir, body);
|
|
293
|
+
if (!result.accepted && result.duplicate) {
|
|
294
|
+
json(res, 200, { accepted: false, duplicate: true, event_id: body.event_id });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (!result.accepted) {
|
|
298
|
+
const unregistered = result.errors.some((item) => /não registrado/.test(item));
|
|
299
|
+
errorResponse(res, unregistered ? 409 : 400, unregistered ? 'project_not_registered' : 'invalid_event', result.errors.join(' '));
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
json(res, 201, { accepted: true, duplicate: false, event_id: body.event_id });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
errorResponse(res, 404, 'not_found', 'rota não encontrada.');
|
|
307
|
+
} catch (error) {
|
|
308
|
+
if (res.headersSent) return;
|
|
309
|
+
const status = error?.code === 'payload_too_large' ? 413 : error?.code === 'invalid_json' ? 400 : 500;
|
|
310
|
+
errorResponse(res, status, error?.code || 'observer_error', error?.message || 'erro interno do Observer.');
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await new Promise((resolve, reject) => {
|
|
315
|
+
const onError = (error) => {
|
|
316
|
+
server.off('listening', onListening);
|
|
317
|
+
reject(error);
|
|
318
|
+
};
|
|
319
|
+
const onListening = () => {
|
|
320
|
+
server.off('error', onError);
|
|
321
|
+
resolve();
|
|
322
|
+
};
|
|
323
|
+
server.once('error', onError);
|
|
324
|
+
server.once('listening', onListening);
|
|
325
|
+
server.listen(Number(port), host);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
server,
|
|
330
|
+
address: () => server.address(),
|
|
331
|
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { allChangesState } from '../hooks/change-core.mjs';
|
|
5
|
+
import { readControl, readSessionRegistry } from '../hooks/obsidian-common.mjs';
|
|
6
|
+
import { runVaultHealth } from '../hooks/vault-health.mjs';
|
|
7
|
+
import { readProjectForValidation } from '../packages/vault/src/validate-memory.mjs';
|
|
8
|
+
|
|
9
|
+
export const OBSERVER_SCHEMA_VERSION = 1;
|
|
10
|
+
export const MAX_SNAPSHOT_BYTES = 32 * 1024;
|
|
11
|
+
const MAX_TEXT = 160;
|
|
12
|
+
|
|
13
|
+
function fail(message, code = 'WENDKEEP_OBSERVER_SNAPSHOT_INVALID') {
|
|
14
|
+
const error = new Error(message);
|
|
15
|
+
error.code = code;
|
|
16
|
+
return error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function safeText(value, max = MAX_TEXT) {
|
|
20
|
+
return String(value ?? '')
|
|
21
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
22
|
+
.replace(/[A-Za-z]:[\\/][^\s"']*/g, '[REDACTED_PATH]')
|
|
23
|
+
.replace(/\\\\[^\s"']+/g, '[REDACTED_PATH]')
|
|
24
|
+
.replace(/\s+/g, ' ')
|
|
25
|
+
.trim()
|
|
26
|
+
.slice(0, max);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isoNow(value) {
|
|
30
|
+
const date = value instanceof Date ? value : new Date(value ?? Date.now());
|
|
31
|
+
if (Number.isNaN(date.getTime())) throw fail('captured_at inválido.');
|
|
32
|
+
return date.toISOString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function packageVersion() {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.0.0';
|
|
38
|
+
} catch {
|
|
39
|
+
return '0.0.0';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function activeSessionSummary(vaultBase, control, registry) {
|
|
44
|
+
const entries = Object.entries(registry?.sessions || {})
|
|
45
|
+
.map(([sessionId, entry]) => ({ sessionId, entry }))
|
|
46
|
+
.sort((a, b) => String(b.entry?.last_seen || b.entry?.updated_at || '').localeCompare(String(a.entry?.last_seen || a.entry?.updated_at || '')));
|
|
47
|
+
const selected = entries.find(({ sessionId }) => sessionId === control?.session_id) || entries[0];
|
|
48
|
+
const entry = selected?.entry || {};
|
|
49
|
+
return {
|
|
50
|
+
status: safeText(control?.status || entry.status || 'inactive', 32),
|
|
51
|
+
session_id: safeText(control?.session_id || selected?.sessionId || '', 100),
|
|
52
|
+
provider: safeText(entry.provider || '', 32),
|
|
53
|
+
change_slug: safeText(entry.change_slug || '', 100),
|
|
54
|
+
last_seen: safeText(entry.last_seen || entry.updated_at || '', 40),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function healthSummary(vaultBase) {
|
|
59
|
+
try {
|
|
60
|
+
const health = runVaultHealth({ vaultBase });
|
|
61
|
+
return {
|
|
62
|
+
ok: health.ok === true,
|
|
63
|
+
status: safeText(health.memoryStatus || (health.ok ? 'healthy' : 'degraded'), 40),
|
|
64
|
+
failure_count: Array.isArray(health.failures) ? health.failures.length : 0,
|
|
65
|
+
warning_count: Array.isArray(health.warnings) ? health.warnings.length : 0,
|
|
66
|
+
registry_sessions: Number(health.metrics?.registrySessions || 0),
|
|
67
|
+
derived_notes: Number(health.metrics?.derivedNotes || 0),
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
status: 'unavailable',
|
|
73
|
+
failure_count: 1,
|
|
74
|
+
warning_count: 0,
|
|
75
|
+
registry_sessions: 0,
|
|
76
|
+
derived_notes: 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hashEvent(snapshot) {
|
|
82
|
+
const canonical = JSON.stringify({ ...snapshot, event_id: undefined });
|
|
83
|
+
return `obs-${createHash('sha256').update(canonical).digest('hex').slice(0, 24)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function hasForbiddenKey(value) {
|
|
87
|
+
if (!value || typeof value !== 'object') return false;
|
|
88
|
+
for (const [key, child] of Object.entries(value)) {
|
|
89
|
+
if (/(?:core|shared|digest|transcript|secret|token|prompt|raw|path|vault)/i.test(key)) return true;
|
|
90
|
+
if (hasForbiddenKey(child)) return true;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hasAbsolutePath(value) {
|
|
96
|
+
if (typeof value === 'string') {
|
|
97
|
+
return /[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/]|(?:^|\s)\/(?:Users|home|mnt|var|tmp)\//.test(value);
|
|
98
|
+
}
|
|
99
|
+
if (!value || typeof value !== 'object') return false;
|
|
100
|
+
return Object.values(value).some(hasAbsolutePath);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function validateObserverSnapshot(snapshot, { projectId = '' } = {}) {
|
|
104
|
+
const errors = [];
|
|
105
|
+
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
|
106
|
+
return { ok: false, errors: ['snapshot deve ser um objeto JSON.'] };
|
|
107
|
+
}
|
|
108
|
+
if (snapshot.schema_version !== OBSERVER_SCHEMA_VERSION) errors.push('schema_version incompatível.');
|
|
109
|
+
for (const key of ['event_id', 'project_id', 'project_name', 'wendkeep_version', 'captured_at']) {
|
|
110
|
+
if (typeof snapshot[key] !== 'string' || !snapshot[key].trim()) errors.push(`${key} ausente ou inválido.`);
|
|
111
|
+
}
|
|
112
|
+
if (projectId && snapshot.project_id !== projectId) errors.push('project_id não corresponde ao projeto registrado.');
|
|
113
|
+
if (!Array.isArray(snapshot.changes)) errors.push('changes deve ser uma lista.');
|
|
114
|
+
if (!snapshot.session || typeof snapshot.session !== 'object') errors.push('session ausente.');
|
|
115
|
+
if (!snapshot.health || typeof snapshot.health !== 'object') errors.push('health ausente.');
|
|
116
|
+
if (hasForbiddenKey(snapshot)) errors.push('snapshot contém campo não permitido.');
|
|
117
|
+
if (hasAbsolutePath(snapshot)) errors.push('snapshot contém caminho absoluto.');
|
|
118
|
+
const size = Buffer.byteLength(JSON.stringify(snapshot), 'utf8');
|
|
119
|
+
if (size > MAX_SNAPSHOT_BYTES) errors.push(`snapshot excede ${MAX_SNAPSHOT_BYTES} bytes.`);
|
|
120
|
+
return { ok: errors.length === 0, errors, size };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildProjectSnapshot({ vaultBase, projectRoot = process.cwd(), now = new Date() } = {}) {
|
|
124
|
+
if (!vaultBase) throw fail('vaultBase é obrigatório.');
|
|
125
|
+
const project = readProjectForValidation(vaultBase);
|
|
126
|
+
if (!project.ok || !project.projectId) throw fail(project.errors?.join(' ') || 'PROJECT.json inválido.');
|
|
127
|
+
const control = readControl(vaultBase);
|
|
128
|
+
const registry = readSessionRegistry(vaultBase);
|
|
129
|
+
const changes = allChangesState(vaultBase).changes.map((change) => ({
|
|
130
|
+
slug: safeText(change.slug, 100),
|
|
131
|
+
current: change.current === true,
|
|
132
|
+
openTasks: Number(change.openCount || 0),
|
|
133
|
+
doneTasks: Number(change.doneCount || 0),
|
|
134
|
+
warning: safeText(change.warning || '', 120),
|
|
135
|
+
}));
|
|
136
|
+
const markerName = project.marker?.projectName || basename(projectRoot);
|
|
137
|
+
const snapshot = {
|
|
138
|
+
schema_version: OBSERVER_SCHEMA_VERSION,
|
|
139
|
+
event_id: '',
|
|
140
|
+
project_id: project.projectId,
|
|
141
|
+
projectId: project.projectId,
|
|
142
|
+
project_name: safeText(markerName || project.projectId, 100),
|
|
143
|
+
wendkeep_version: packageVersion(),
|
|
144
|
+
captured_at: isoNow(now),
|
|
145
|
+
session: activeSessionSummary(vaultBase, control, registry),
|
|
146
|
+
changes,
|
|
147
|
+
health: healthSummary(vaultBase),
|
|
148
|
+
};
|
|
149
|
+
snapshot.event_id = hashEvent(snapshot);
|
|
150
|
+
const validation = validateObserverSnapshot(snapshot, { projectId: project.projectId });
|
|
151
|
+
if (!validation.ok) throw fail(validation.errors.join(' '));
|
|
152
|
+
return snapshot;
|
|
153
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { MAX_SNAPSHOT_BYTES, OBSERVER_SCHEMA_VERSION, validateObserverSnapshot } from './observer-snapshot.mjs';
|
|
4
|
+
|
|
5
|
+
export const OBSERVER_DATA_SCHEMA_VERSION = 1;
|
|
6
|
+
export const OBSERVER_EVENTS_FILE = 'EVENTS.jsonl';
|
|
7
|
+
export const OBSERVER_INDEX_FILE = 'INDEX.json';
|
|
8
|
+
export const OBSERVER_PROJECTS_FILE = 'PROJECTS.json';
|
|
9
|
+
|
|
10
|
+
function ensureDataDir(dataDir) {
|
|
11
|
+
if (!dataDir) throw new Error('dataDir é obrigatório.');
|
|
12
|
+
mkdirSync(dataDir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function atomicJson(path, value) {
|
|
16
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
17
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
18
|
+
renameSync(temp, path);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readJson(path, fallback) {
|
|
22
|
+
if (!existsSync(path)) return fallback;
|
|
23
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
24
|
+
catch { return fallback; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function projectIdValid(projectId) {
|
|
28
|
+
return typeof projectId === 'string'
|
|
29
|
+
&& /^[A-Za-z0-9][A-Za-z0-9._:-]{0,120}$/.test(projectId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function registeredProjects(dataDir) {
|
|
33
|
+
const raw = readJson(join(dataDir, OBSERVER_PROJECTS_FILE), {
|
|
34
|
+
schema_version: OBSERVER_DATA_SCHEMA_VERSION,
|
|
35
|
+
projects: {},
|
|
36
|
+
});
|
|
37
|
+
return raw?.schema_version === OBSERVER_DATA_SCHEMA_VERSION && raw.projects && typeof raw.projects === 'object'
|
|
38
|
+
? raw.projects
|
|
39
|
+
: {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function registerObserverProject(dataDir, {
|
|
43
|
+
projectId,
|
|
44
|
+
projectName = projectId,
|
|
45
|
+
wendkeepVersion = '',
|
|
46
|
+
registeredAt = new Date().toISOString(),
|
|
47
|
+
} = {}) {
|
|
48
|
+
ensureDataDir(dataDir);
|
|
49
|
+
if (!projectIdValid(projectId)) return { registered: false, errors: ['project_id inválido.'] };
|
|
50
|
+
const projects = registeredProjects(dataDir);
|
|
51
|
+
const project = {
|
|
52
|
+
projectId,
|
|
53
|
+
projectName: String(projectName || projectId).replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 120),
|
|
54
|
+
wendkeepVersion: String(wendkeepVersion || '').slice(0, 40),
|
|
55
|
+
registeredAt: String(registeredAt),
|
|
56
|
+
};
|
|
57
|
+
projects[projectId] = project;
|
|
58
|
+
atomicJson(join(dataDir, OBSERVER_PROJECTS_FILE), {
|
|
59
|
+
schema_version: OBSERVER_DATA_SCHEMA_VERSION,
|
|
60
|
+
projects,
|
|
61
|
+
});
|
|
62
|
+
return { registered: true, project };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function listRegisteredObserverProjects(dataDir) {
|
|
66
|
+
return Object.values(registeredProjects(dataDir)).sort((a, b) => a.projectId.localeCompare(b.projectId));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readEvents(dataDir) {
|
|
70
|
+
const path = join(dataDir, OBSERVER_EVENTS_FILE);
|
|
71
|
+
if (!existsSync(path)) return [];
|
|
72
|
+
const events = [];
|
|
73
|
+
const lines = readFileSync(path, 'utf8').replace(/\r\n/g, '\n').split('\n').filter((line) => line.trim());
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
try {
|
|
76
|
+
const event = JSON.parse(line);
|
|
77
|
+
if (validateObserverSnapshot(event).ok) events.push(event);
|
|
78
|
+
} catch { /* corrupt lines never become part of the derived index */ }
|
|
79
|
+
}
|
|
80
|
+
return events;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function newer(left, right) {
|
|
84
|
+
const leftTime = Date.parse(left?.captured_at || '') || 0;
|
|
85
|
+
const rightTime = Date.parse(right?.captured_at || '') || 0;
|
|
86
|
+
return leftTime > rightTime || (leftTime === rightTime && String(left?.event_id).localeCompare(String(right?.event_id)) > 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function rebuildObserverIndex(dataDir) {
|
|
90
|
+
ensureDataDir(dataDir);
|
|
91
|
+
const byProject = new Map();
|
|
92
|
+
for (const event of readEvents(dataDir)) {
|
|
93
|
+
const current = byProject.get(event.project_id);
|
|
94
|
+
if (!current || newer(event, current.snapshot)) {
|
|
95
|
+
byProject.set(event.project_id, {
|
|
96
|
+
projectId: event.project_id,
|
|
97
|
+
projectName: event.project_name,
|
|
98
|
+
latestEventId: event.event_id,
|
|
99
|
+
capturedAt: event.captured_at,
|
|
100
|
+
snapshot: event,
|
|
101
|
+
eventCount: 0,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const event of readEvents(dataDir)) {
|
|
106
|
+
const item = byProject.get(event.project_id);
|
|
107
|
+
if (item) item.eventCount += 1;
|
|
108
|
+
}
|
|
109
|
+
const index = {
|
|
110
|
+
schema_version: OBSERVER_DATA_SCHEMA_VERSION,
|
|
111
|
+
generated_at: new Date().toISOString(),
|
|
112
|
+
projects: [...byProject.values()].sort((a, b) => a.projectId.localeCompare(b.projectId)),
|
|
113
|
+
};
|
|
114
|
+
atomicJson(join(dataDir, OBSERVER_INDEX_FILE), index);
|
|
115
|
+
return index;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function readObserverIndex(dataDir) {
|
|
119
|
+
ensureDataDir(dataDir);
|
|
120
|
+
const path = join(dataDir, OBSERVER_INDEX_FILE);
|
|
121
|
+
const index = readJson(path, null);
|
|
122
|
+
if (index?.schema_version === OBSERVER_DATA_SCHEMA_VERSION && Array.isArray(index.projects)) return index;
|
|
123
|
+
return rebuildObserverIndex(dataDir);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function appendObserverEvent(dataDir, event) {
|
|
127
|
+
ensureDataDir(dataDir);
|
|
128
|
+
const validation = validateObserverSnapshot(event);
|
|
129
|
+
if (!validation.ok || validation.size > MAX_SNAPSHOT_BYTES) {
|
|
130
|
+
return { accepted: false, errors: validation.errors || ['snapshot inválido.'] };
|
|
131
|
+
}
|
|
132
|
+
const projects = registeredProjects(dataDir);
|
|
133
|
+
if (!projects[event.project_id]) {
|
|
134
|
+
return { accepted: false, errors: [`project_id não registrado: ${event.project_id}`] };
|
|
135
|
+
}
|
|
136
|
+
const eventsPath = join(dataDir, OBSERVER_EVENTS_FILE);
|
|
137
|
+
const existing = readEvents(dataDir).find((item) => item.event_id === event.event_id);
|
|
138
|
+
if (existing) {
|
|
139
|
+
if (JSON.stringify(existing) !== JSON.stringify(event)) {
|
|
140
|
+
return { accepted: false, errors: [`event_id reutilizado com payload diferente: ${event.event_id}`] };
|
|
141
|
+
}
|
|
142
|
+
return { accepted: false, duplicate: true, event_id: event.event_id, index: readObserverIndex(dataDir) };
|
|
143
|
+
}
|
|
144
|
+
appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, 'utf8');
|
|
145
|
+
return {
|
|
146
|
+
accepted: true,
|
|
147
|
+
duplicate: false,
|
|
148
|
+
event_id: event.event_id,
|
|
149
|
+
index: rebuildObserverIndex(dataDir),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function getObserverProject(dataDir, projectId) {
|
|
154
|
+
return readObserverIndex(dataDir).projects.find((project) => project.projectId === projectId) || null;
|
|
155
|
+
}
|