wendkeep 0.70.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.
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { buildProjectSnapshot } from './observer-snapshot.mjs';
4
+ import { publishObserverMemory } from './observer-memory-publish.mjs';
4
5
 
5
6
  const OUTBOX_REL = join('.brain', 'observer-outbox');
6
7
  const REQUEST_TIMEOUT_MS = 500;
@@ -45,14 +46,13 @@ function removeOutbox(vaultBase, eventId) {
45
46
  if (existsSync(path)) unlinkSync(path);
46
47
  }
47
48
 
48
- async function postSnapshot(url, token, event) {
49
+ async function postSnapshot(url, event) {
49
50
  const controller = new AbortController();
50
51
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
51
52
  try {
52
53
  const response = await fetch(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(event.project_id)}/snapshot`, {
53
54
  method: 'POST',
54
55
  headers: {
55
- authorization: `Bearer ${token}`,
56
56
  'content-type': 'application/json',
57
57
  },
58
58
  body: JSON.stringify(event),
@@ -70,14 +70,14 @@ async function postSnapshot(url, token, event) {
70
70
  }
71
71
  }
72
72
 
73
- export async function retryObserverOutbox({ vaultBase, url, token } = {}) {
74
- if (!url || !token) return { attempted: 0, confirmed: 0, pending: listOutbox(vaultBase).length };
73
+ export async function retryObserverOutbox({ vaultBase, url } = {}) {
74
+ if (!url) return { attempted: 0, confirmed: 0, pending: listOutbox(vaultBase).length };
75
75
  let attempted = 0;
76
76
  let confirmed = 0;
77
77
  for (const event of listOutbox(vaultBase)) {
78
78
  attempted += 1;
79
79
  try {
80
- await postSnapshot(url, token, event);
80
+ await postSnapshot(url, event);
81
81
  removeOutbox(vaultBase, event.event_id);
82
82
  confirmed += 1;
83
83
  } catch { /* preserve the event for a later retry */ }
@@ -89,22 +89,33 @@ export async function publishObserverSnapshot({
89
89
  vaultBase,
90
90
  projectRoot,
91
91
  url = process.env.WENDKEEP_OBSERVER_URL || '',
92
- token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
93
92
  now = new Date(),
94
93
  } = {}) {
95
94
  try {
96
95
  const event = buildProjectSnapshot({ vaultBase, projectRoot, now });
97
96
  if (!url) return { ok: true, skipped: true, queued: false, hookExitCode: 0, event_id: event.event_id };
98
97
 
99
- await retryObserverOutbox({ vaultBase, url, token });
98
+ await retryObserverOutbox({ vaultBase, url });
99
+ let memory;
100
100
  try {
101
- const response = await postSnapshot(url, token, event);
101
+ memory = await publishObserverMemory({
102
+ vaultBase,
103
+ projectId: event.project_id,
104
+ url,
105
+ now,
106
+ });
107
+ } catch (error) {
108
+ memory = { ok: false, queued: false, error: error.message };
109
+ }
110
+ try {
111
+ const response = await postSnapshot(url, event);
102
112
  return {
103
113
  ok: true,
104
114
  queued: false,
105
115
  hookExitCode: 0,
106
116
  event_id: event.event_id,
107
117
  duplicate: response.duplicate === true,
118
+ memory,
108
119
  };
109
120
  } catch (error) {
110
121
  queueOutbox(vaultBase, event);
@@ -114,6 +125,7 @@ export async function publishObserverSnapshot({
114
125
  hookExitCode: 0,
115
126
  event_id: event.event_id,
116
127
  error: error.message,
128
+ memory,
117
129
  };
118
130
  }
119
131
  } catch (error) {
@@ -1,9 +1,35 @@
1
1
  import { createServer } from 'node:http';
2
- import { appendObserverEvent, getObserverProject, readObserverIndex, registerObserverProject } from './observer-store.mjs';
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';
3
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';
4
22
 
5
23
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
6
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
+ ]);
7
33
 
8
34
  function loopbackOnly(host) {
9
35
  return LOOPBACK_HOSTS.has(String(host || '').toLowerCase());
@@ -23,7 +49,25 @@ function errorResponse(res, status, code, message) {
23
49
  json(res, status, { error: { code, message } });
24
50
  }
25
51
 
26
- function readBody(req) {
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) {
27
71
  return new Promise((resolve, reject) => {
28
72
  let size = 0;
29
73
  let tooLarge = false;
@@ -31,7 +75,7 @@ function readBody(req) {
31
75
  req.on('data', (chunk) => {
32
76
  if (tooLarge) return;
33
77
  size += chunk.length;
34
- if (size > MAX_BODY_BYTES) {
78
+ if (size > maxBytes) {
35
79
  tooLarge = true;
36
80
  const error = new Error('corpo acima do limite.');
37
81
  error.code = 'payload_too_large';
@@ -56,13 +100,6 @@ function parseJson(text) {
56
100
  }
57
101
  }
58
102
 
59
- function authorized(req, token) {
60
- if (!token) return false;
61
- const header = String(req.headers.authorization || '');
62
- return header === `Bearer ${token}`
63
- || req.headers['x-wendkeep-observer-token'] === token;
64
- }
65
-
66
103
  function pathParts(url) {
67
104
  return new URL(url, 'http://127.0.0.1').pathname.split('/').filter(Boolean).map((part) => decodeURIComponent(part));
68
105
  }
@@ -71,11 +108,17 @@ function projectIdFrom(parts) {
71
108
  return parts[0] === 'v1' && parts[1] === 'projects' && parts[2] ? parts[2] : '';
72
109
  }
73
110
 
111
+ function projectKnown(dataDir, projectId) {
112
+ return Boolean(
113
+ getObserverProject(dataDir, projectId)
114
+ || listRegisteredObserverProjects(dataDir).some((item) => item.projectId === projectId),
115
+ );
116
+ }
117
+
74
118
  export async function startObserverServer({
75
119
  host = '127.0.0.1',
76
120
  port = 8787,
77
121
  dataDir,
78
- token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
79
122
  allowNonLoopback = false,
80
123
  } = {}) {
81
124
  if (!loopbackOnly(host) && !allowNonLoopback) {
@@ -84,15 +127,20 @@ export async function startObserverServer({
84
127
  if (!dataDir) throw new Error('dataDir é obrigatório.');
85
128
  const server = createServer(async (req, res) => {
86
129
  try {
87
- const parts = pathParts(req.url || '/');
88
- if (req.method === 'GET' && parts.length === 1 && parts[0] === 'healthz') {
130
+ const pathname = new URL(req.url || '/', 'http://127.0.0.1').pathname;
131
+ if (req.method === 'GET' && pathname === '/healthz') {
89
132
  json(res, 200, { ok: true, service: 'wendkeep-observer', schema_version: 1 });
90
133
  return;
91
134
  }
92
- if (!authorized(req, token)) {
93
- errorResponse(res, 401, 'unauthorized', 'token local ausente ou inválido.');
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.');
94
141
  return;
95
142
  }
143
+ const parts = pathParts(req.url || '/');
96
144
  if (parts[0] !== 'v1' || parts[1] !== 'projects') {
97
145
  errorResponse(res, 404, 'not_found', 'rota não encontrada.');
98
146
  return;
@@ -113,6 +161,88 @@ export async function startObserverServer({
113
161
  return;
114
162
  }
115
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
+
116
246
  if (parts.length === 3 && req.method === 'GET') {
117
247
  const project = getObserverProject(dataDir, projectId);
118
248
  if (!project) {
package/src/observer.mjs CHANGED
@@ -2,20 +2,22 @@ import { homedir } from 'node:os';
2
2
  import { isAbsolute, resolve } from 'node:path';
3
3
  import { appendObserverEvent, readObserverIndex, registerObserverProject } from './observer-store.mjs';
4
4
  import { buildProjectSnapshot } from './observer-snapshot.mjs';
5
+ import { compareMemoryParity, publishObserverMemory } from './observer-memory-publish.mjs';
5
6
  import { startObserverServer } from './observer-server.mjs';
6
7
  import { resolveProjectVault } from '../packages/vault/src/project-vault.mjs';
7
8
 
8
9
  export const OBSERVER_HELP = `wendkeep observer — Observer local multi-projeto
9
10
 
10
11
  Uso:
11
- wendkeep observer serve [--data-dir P] [--host 127.0.0.1] [--port 8787] [--token T]
12
+ wendkeep observer serve [--data-dir P] [--host 127.0.0.1] [--port 8787]
12
13
  [--allow-non-loopback]
13
14
  wendkeep observer register --project P [--vault V] [--data-dir D] [--json]
14
15
  wendkeep observer publish --project P [--vault V] [--data-dir D] [--json]
16
+ wendkeep observer memory import --project P [--vault V] [--url U] [--json]
15
17
  wendkeep observer status [--data-dir D] [--json]
16
18
 
17
- O vault continua local e é a fonte oficial. O Observer armazena apenas snapshots sanitizados
18
- e um índice reconstruível; o container não monta nem copia vaults de projetos.
19
+ O Observer local pode manter snapshots operacionais e uma cópia completa da memória em volume
20
+ Docker. O comando memory import faz a primeira migração de um vault para o container.
19
21
  `;
20
22
 
21
23
  function optionValue(argv, name) {
@@ -41,8 +43,8 @@ function vaultBase(argv, root) {
41
43
  return resolveProjectVault({ startDir: root }).base;
42
44
  }
43
45
 
44
- function print(value, asJson) {
45
- process.stdout.write(`${asJson ? JSON.stringify(value, null, 2) : String(value)}\n`);
46
+ function print(value, asJson, write = (chunk) => process.stdout.write(chunk)) {
47
+ write((asJson ? JSON.stringify(value, null, 2) : String(value)) + '\n');
46
48
  }
47
49
 
48
50
  function summary(index) {
@@ -52,7 +54,7 @@ function summary(index) {
52
54
  };
53
55
  }
54
56
 
55
- export async function runObserver(argv = []) {
57
+ export async function runObserver(argv = [], { write = (chunk) => process.stdout.write(chunk) } = {}) {
56
58
  const [sub] = argv;
57
59
  const asJson = argv.includes('--json');
58
60
  if (!sub || sub === 'help') {
@@ -62,30 +64,66 @@ export async function runObserver(argv = []) {
62
64
  const dir = dataDir(argv);
63
65
 
64
66
  if (sub === 'status') {
65
- print(summary(readObserverIndex(dir)), asJson);
67
+ print(summary(readObserverIndex(dir)), asJson, write);
66
68
  return 0;
67
69
  }
68
70
 
69
71
  if (sub === 'serve') {
70
- const token = optionValue(argv, '--token') || process.env.WENDKEEP_OBSERVER_TOKEN || '';
71
72
  const host = optionValue(argv, '--host') || '127.0.0.1';
72
73
  const server = await startObserverServer({
73
74
  dataDir: dir,
74
75
  host,
75
76
  port: Number(optionValue(argv, '--port') || 8787),
76
- token,
77
77
  allowNonLoopback: argv.includes('--allow-non-loopback'),
78
78
  });
79
- if (!token) {
80
- await server.close();
81
- throw new Error('Observer exige --token ou WENDKEEP_OBSERVER_TOKEN.');
82
- }
83
79
  const address = server.address();
84
80
  process.stdout.write(`wendkeep observer listening: http://${address.address}:${address.port}\n`);
85
81
  return 0;
86
82
  }
87
83
 
88
- if (!['register', 'publish'].includes(sub)) throw new Error(`observer: subcomando desconhecido: ${sub}`);
84
+ if (sub === 'memory') {
85
+ const action = argv[1] || '';
86
+ if (action !== 'import') throw new Error('observer memory: use memory import.');
87
+ const root = projectRoot(argv);
88
+ const vault = vaultBase(argv, root);
89
+ const snapshot = buildProjectSnapshot({ vaultBase: vault, projectRoot: root });
90
+ const url = optionValue(argv, '--url') || process.env.WENDKEEP_OBSERVER_URL || '';
91
+ if (!url) throw new Error('observer memory import: --url ou WENDKEEP_OBSERVER_URL é obrigatório.');
92
+ const headers = { 'content-type': 'application/json', accept: 'application/json' };
93
+ const registration = await fetch(
94
+ String(url).replace(/\/$/, '') + '/v1/projects/' + encodeURIComponent(snapshot.project_id),
95
+ {
96
+ method: 'PUT',
97
+ headers,
98
+ body: JSON.stringify({
99
+ project_id: snapshot.project_id,
100
+ project_name: snapshot.project_name,
101
+ wendkeep_version: snapshot.wendkeep_version,
102
+ }),
103
+ },
104
+ );
105
+ if (!registration.ok) throw new Error('Observer não registrou o projeto: HTTP ' + registration.status + '.');
106
+ const memory = await publishObserverMemory({
107
+ vaultBase: vault,
108
+ projectId: snapshot.project_id,
109
+ url,
110
+ });
111
+ const parity = await compareMemoryParity({
112
+ vaultBase: vault,
113
+ projectId: snapshot.project_id,
114
+ url,
115
+ });
116
+ const result = {
117
+ ok: memory.ok && parity.missing === 0 && parity.mismatched === 0,
118
+ project_id: snapshot.project_id,
119
+ memory,
120
+ parity,
121
+ };
122
+ print(result, asJson, write);
123
+ return result.ok ? 0 : 1;
124
+ }
125
+
126
+ if (!['register', 'publish'].includes(sub)) throw new Error('observer: subcomando desconhecido: ' + sub);
89
127
  const root = projectRoot(argv);
90
128
  const vault = vaultBase(argv, root);
91
129
  const snapshot = buildProjectSnapshot({ vaultBase: vault, projectRoot: root });
@@ -97,12 +135,12 @@ export async function runObserver(argv = []) {
97
135
  wendkeepVersion: snapshot.wendkeep_version,
98
136
  });
99
137
  if (!result.registered) throw new Error(result.errors.join(' '));
100
- print(result, asJson);
138
+ print(result, asJson, write);
101
139
  return 0;
102
140
  }
103
141
 
104
142
  const result = appendObserverEvent(dir, snapshot);
105
143
  if (!result.accepted && !result.duplicate) throw new Error(result.errors.join(' '));
106
- print({ ok: true, ...result }, asJson);
144
+ print({ ok: true, ...result }, asJson, write);
107
145
  return 0;
108
146
  }