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.
@@ -0,0 +1,308 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ appendFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { dirname, join, relative, sep } from 'node:path';
12
+
13
+ export const MEMORY_SCHEMA_VERSION = 1;
14
+ export const MEMORY_EVENTS_FILE = 'MEMORY_EVENTS.jsonl';
15
+ export const MEMORY_INDEX_FILE = 'MEMORY_INDEX.json';
16
+ export const MEMORY_ROOT = 'memory';
17
+ export const MAX_MEMORY_CONTENT_BYTES = 2 * 1024 * 1024;
18
+ export const MEMORY_MODES = new Set(['mirror', 'container-read', 'container-authority']);
19
+
20
+ const ENTITY_TYPES = new Set(['session', 'decision', 'bug', 'learning', 'spec', 'change', 'memory']);
21
+ const OPERATIONS = new Set(['upsert', 'delete']);
22
+ const PROJECT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,120}$/;
23
+ const ROOTS = [
24
+ '02-Sessões/',
25
+ '04-Decisões/',
26
+ '05-Bugs/',
27
+ '06-Aprendizados/',
28
+ '07-Specs/',
29
+ '08-Mudanças/',
30
+ '.brain/',
31
+ ];
32
+ const ROOT_FILES = new Set(['CORE.md', 'DIGEST.md', 'SHARED_MEMORY.md']);
33
+
34
+ function hashContent(content) {
35
+ return createHash('sha256').update(content).digest('hex');
36
+ }
37
+
38
+ function atomicJson(path, value) {
39
+ const temp = path + '.' + process.pid + '.' + Date.now() + '.tmp';
40
+ writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', 'utf8');
41
+ renameSync(temp, path);
42
+ }
43
+
44
+ function readJson(path, fallback) {
45
+ if (!existsSync(path)) return fallback;
46
+ try {
47
+ return JSON.parse(readFileSync(path, 'utf8'));
48
+ } catch {
49
+ return fallback;
50
+ }
51
+ }
52
+
53
+ function ensureDataDir(dataDir) {
54
+ if (!dataDir) throw new Error('dataDir é obrigatório.');
55
+ mkdirSync(dataDir, { recursive: true });
56
+ }
57
+
58
+ function projectIdValid(projectId) {
59
+ return typeof projectId === 'string' && PROJECT_ID_RE.test(projectId);
60
+ }
61
+
62
+ function normalizedLogicalPath(value) {
63
+ const original = String(value ?? '');
64
+ const normalized = original.replaceAll('\\', '/').replace(/^\/+/, '');
65
+ if (!normalized || /^[A-Za-z]:\//.test(normalized) || original.startsWith('/') || original.startsWith('\\')) {
66
+ return '';
67
+ }
68
+ const parts = normalized.split('/');
69
+ if (parts.some((part) => !part || part === '.' || part === '..')) return '';
70
+ if (!(ROOT_FILES.has(normalized) || ROOTS.some((root) => normalized.startsWith(root)))) return '';
71
+ return normalized;
72
+ }
73
+
74
+ function memoryFilePath(dataDir, projectId, logicalPath) {
75
+ const base = join(dataDir, MEMORY_ROOT, projectId);
76
+ const target = join(base, ...logicalPath.split('/'));
77
+ const rel = relative(base, target);
78
+ if (rel.startsWith('..' + sep) || rel === '..' || /^[A-Za-z]:/.test(rel)) {
79
+ throw new Error('logical_path fora do projeto.');
80
+ }
81
+ return target;
82
+ }
83
+
84
+ function defaultIndex() {
85
+ return {
86
+ schema_version: MEMORY_SCHEMA_VERSION,
87
+ generated_at: new Date().toISOString(),
88
+ projects: {},
89
+ };
90
+ }
91
+
92
+ function loadIndex(dataDir) {
93
+ ensureDataDir(dataDir);
94
+ const index = readJson(join(dataDir, MEMORY_INDEX_FILE), null);
95
+ if (index?.schema_version === MEMORY_SCHEMA_VERSION && index.projects && typeof index.projects === 'object') {
96
+ return index;
97
+ }
98
+ return defaultIndex();
99
+ }
100
+
101
+ function saveIndex(dataDir, index) {
102
+ index.generated_at = new Date().toISOString();
103
+ atomicJson(join(dataDir, MEMORY_INDEX_FILE), index);
104
+ }
105
+
106
+ function readEventLines(dataDir) {
107
+ const path = join(dataDir, MEMORY_EVENTS_FILE);
108
+ if (!existsSync(path)) return [];
109
+ return readFileSync(path, 'utf8')
110
+ .replace(/\r\n/g, '\n')
111
+ .split('\n')
112
+ .filter((line) => line.trim())
113
+ .flatMap((line) => {
114
+ try { return [JSON.parse(line)]; } catch { return []; }
115
+ });
116
+ }
117
+
118
+ function projectState(index, projectId) {
119
+ if (!index.projects[projectId]) {
120
+ index.projects[projectId] = {
121
+ project_id: projectId,
122
+ mode: 'mirror',
123
+ documents: {},
124
+ event_count: 0,
125
+ conflict_count: 0,
126
+ last_event_at: '',
127
+ };
128
+ }
129
+ return index.projects[projectId];
130
+ }
131
+
132
+ function eventPayload(event) {
133
+ const clone = { ...event };
134
+ delete clone.event_id;
135
+ return JSON.stringify(clone);
136
+ }
137
+
138
+ export function validateMemoryEvent(event) {
139
+ const errors = [];
140
+ if (!event || typeof event !== 'object' || Array.isArray(event)) {
141
+ return { ok: false, errors: ['evento deve ser um objeto JSON.'] };
142
+ }
143
+ if (event.schema_version !== MEMORY_SCHEMA_VERSION) errors.push('schema_version incompatível.');
144
+ if (typeof event.event_id !== 'string' || !event.event_id.trim()) errors.push('event_id ausente.');
145
+ if (!projectIdValid(event.project_id)) errors.push('project_id inválido.');
146
+ if (!ENTITY_TYPES.has(event.entity_type)) errors.push('entity_type inválido.');
147
+ const path = normalizedLogicalPath(event.logical_path);
148
+ if (!path) errors.push('logical_path inválido ou fora das raízes autorizadas.');
149
+ if (!OPERATIONS.has(event.operation)) errors.push('operation inválida.');
150
+ if (!Number.isInteger(event.revision) || event.revision < 1) errors.push('revision inválida.');
151
+ if (typeof event.content_hash !== 'string' || !/^[a-f0-9]{64}$/.test(event.content_hash)) {
152
+ errors.push('content_hash inválido.');
153
+ }
154
+ if (typeof event.captured_at !== 'string' || Number.isNaN(Date.parse(event.captured_at))) {
155
+ errors.push('captured_at inválido.');
156
+ }
157
+ if (event.operation === 'upsert') {
158
+ if (typeof event.content !== 'string') errors.push('content ausente.');
159
+ else {
160
+ if (Buffer.byteLength(event.content, 'utf8') > MAX_MEMORY_CONTENT_BYTES) errors.push('content excede o limite.');
161
+ if (event.content_hash !== hashContent(event.content)) errors.push('content_hash não corresponde ao conteúdo.');
162
+ }
163
+ }
164
+ return { ok: errors.length === 0, errors, logical_path: path };
165
+ }
166
+
167
+ export function applyMemoryEvent(dataDir, event) {
168
+ ensureDataDir(dataDir);
169
+ const validation = validateMemoryEvent(event);
170
+ if (!validation.ok) return { accepted: false, errors: validation.errors };
171
+ const index = loadIndex(dataDir);
172
+ const state = projectState(index, event.project_id);
173
+ const existingEvent = readEventLines(dataDir).find((item) => item.event_id === event.event_id);
174
+ if (existingEvent) {
175
+ if (eventPayload(existingEvent) === eventPayload(event)) {
176
+ return { accepted: false, duplicate: true, event_id: event.event_id };
177
+ }
178
+ state.conflict_count += 1;
179
+ saveIndex(dataDir, index);
180
+ return { accepted: false, conflict: true, errors: ['event_id reutilizado com payload diferente.'] };
181
+ }
182
+
183
+ const current = state.documents[validation.logical_path];
184
+ if (current && event.revision < current.revision) {
185
+ state.conflict_count += 1;
186
+ saveIndex(dataDir, index);
187
+ return { accepted: false, conflict: true, stale: true, errors: ['revisão antiga não pode substituir a atual.'] };
188
+ }
189
+ if (current && event.revision === current.revision && current.content_hash !== event.content_hash) {
190
+ state.conflict_count += 1;
191
+ saveIndex(dataDir, index);
192
+ return { accepted: false, conflict: true, errors: ['revisão já possui conteúdo diferente.'] };
193
+ }
194
+
195
+ const path = memoryFilePath(dataDir, event.project_id, validation.logical_path);
196
+ if (event.operation === 'delete') {
197
+ rmSync(path, { force: true });
198
+ delete state.documents[validation.logical_path];
199
+ } else {
200
+ mkdirSync(dirname(path), { recursive: true });
201
+ const temp = path + '.' + process.pid + '.' + Date.now() + '.tmp';
202
+ writeFileSync(temp, event.content, 'utf8');
203
+ renameSync(temp, path);
204
+ state.documents[validation.logical_path] = {
205
+ project_id: event.project_id,
206
+ logical_path: validation.logical_path,
207
+ entity_type: event.entity_type,
208
+ content_hash: event.content_hash,
209
+ revision: event.revision,
210
+ source_session_id: String(event.source_session_id || ''),
211
+ source_turn_id: String(event.source_turn_id || ''),
212
+ captured_at: event.captured_at,
213
+ bytes: Buffer.byteLength(event.content, 'utf8'),
214
+ };
215
+ }
216
+ appendFileSync(join(dataDir, MEMORY_EVENTS_FILE), JSON.stringify(event) + '\n', 'utf8');
217
+ state.event_count += 1;
218
+ state.last_event_at = event.captured_at;
219
+ saveIndex(dataDir, index);
220
+ return { accepted: true, duplicate: false, event_id: event.event_id, document: state.documents[validation.logical_path] || null };
221
+ }
222
+
223
+ export function readMemoryTree(dataDir, projectId, prefix = '') {
224
+ const index = loadIndex(dataDir);
225
+ const state = index.projects[projectId] || projectState(index, projectId);
226
+ const normalizedPrefix = String(prefix || '').replaceAll('\\', '/').replace(/^\/+|\/+$/g, '');
227
+ const documents = Object.values(state.documents)
228
+ .filter((item) => !normalizedPrefix || item.logical_path.startsWith(normalizedPrefix + '/') || item.logical_path === normalizedPrefix)
229
+ .sort((a, b) => a.logical_path.localeCompare(b.logical_path));
230
+ return {
231
+ schema_version: MEMORY_SCHEMA_VERSION,
232
+ project_id: projectId,
233
+ documents,
234
+ document_count: documents.length,
235
+ categories: [...new Set(documents.map((item) => item.logical_path.split('/')[0]))].sort(),
236
+ };
237
+ }
238
+
239
+ export function setMemoryMode(dataDir, projectId, mode) {
240
+ if (!MEMORY_MODES.has(mode)) {
241
+ const error = new Error('modo de memória inválido.');
242
+ error.code = 'invalid_memory_mode';
243
+ throw error;
244
+ }
245
+ const index = loadIndex(dataDir);
246
+ const state = projectState(index, projectId);
247
+ state.mode = mode;
248
+ saveIndex(dataDir, index);
249
+ return readMemorySync(dataDir, projectId);
250
+ }
251
+
252
+ export function readMemoryDocument(dataDir, projectId, logicalPath) {
253
+ const path = normalizedLogicalPath(logicalPath);
254
+ if (!projectIdValid(projectId) || !path) {
255
+ const error = new Error('documento inválido.');
256
+ error.code = 'invalid_memory_path';
257
+ throw error;
258
+ }
259
+ const index = loadIndex(dataDir);
260
+ const metadata = index.projects[projectId]?.documents?.[path];
261
+ if (!metadata) {
262
+ const error = new Error('documento não encontrado.');
263
+ error.code = 'memory_not_found';
264
+ throw error;
265
+ }
266
+ return { ...metadata, content: readFileSync(memoryFilePath(dataDir, projectId, path), 'utf8') };
267
+ }
268
+
269
+ export function exportMemoryBundle(dataDir, projectId) {
270
+ const tree = readMemoryTree(dataDir, projectId);
271
+ return {
272
+ schema_version: MEMORY_SCHEMA_VERSION,
273
+ project_id: projectId,
274
+ mode: readMemorySync(dataDir, projectId).mode,
275
+ documents: tree.documents.map((metadata) => ({
276
+ ...metadata,
277
+ content: readMemoryDocument(dataDir, projectId, metadata.logical_path).content,
278
+ })),
279
+ };
280
+ }
281
+
282
+ export function searchMemory(dataDir, projectId, query) {
283
+ const term = String(query || '').trim().toLowerCase();
284
+ if (!term) return [];
285
+ return readMemoryTree(dataDir, projectId).documents.flatMap((metadata) => {
286
+ let content;
287
+ try { content = readFileSync(memoryFilePath(dataDir, projectId, metadata.logical_path), 'utf8'); } catch { return []; }
288
+ const haystack = metadata.logical_path + '\n' + content;
289
+ const at = haystack.toLowerCase().indexOf(term);
290
+ if (at < 0) return [];
291
+ const start = Math.max(0, at - 80);
292
+ return [{ ...metadata, excerpt: haystack.slice(start, start + 240).replace(/\s+/g, ' ').trim() }];
293
+ });
294
+ }
295
+
296
+ export function readMemorySync(dataDir, projectId) {
297
+ const index = loadIndex(dataDir);
298
+ const state = index.projects[projectId] || projectState(index, projectId);
299
+ return {
300
+ project_id: projectId,
301
+ mode: state.mode || 'mirror',
302
+ document_count: Object.keys(state.documents).length,
303
+ event_count: Number(state.event_count || 0),
304
+ conflict_count: Number(state.conflict_count || 0),
305
+ pending_count: 0,
306
+ last_event_at: state.last_event_at || '',
307
+ };
308
+ }
@@ -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 { publishObserverSql } from './observer-sql-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,34 @@ 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(),
93
+ input = {},
94
94
  } = {}) {
95
95
  try {
96
96
  const event = buildProjectSnapshot({ vaultBase, projectRoot, now });
97
- if (!url) return { ok: true, skipped: true, queued: false, hookExitCode: 0, event_id: event.event_id };
97
+ const sql = await publishObserverSql({
98
+ vaultBase,
99
+ projectId: event.project_id,
100
+ url,
101
+ input,
102
+ now,
103
+ });
104
+ if (!url) return { ok: sql.ok, skipped: true, queued: sql.queued, hookExitCode: 0, event_id: event.event_id, sql };
98
105
 
99
- await retryObserverOutbox({ vaultBase, url, token });
106
+ await retryObserverOutbox({ vaultBase, url });
107
+ // SQL is the live authority. Keep the legacy-shaped `memory` field for
108
+ // older integrations while reporting the real SQL publication separately.
109
+ const memory = { ok: sql.ok, queued: sql.queued, changed: sql.changed, pending: sql.pending, authority: 'sqlite' };
100
110
  try {
101
- const response = await postSnapshot(url, token, event);
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,
119
+ sql,
108
120
  };
109
121
  } catch (error) {
110
122
  queueOutbox(vaultBase, event);
@@ -114,6 +126,8 @@ export async function publishObserverSnapshot({
114
126
  hookExitCode: 0,
115
127
  event_id: event.event_id,
116
128
  error: error.message,
129
+ memory,
130
+ sql,
117
131
  };
118
132
  }
119
133
  } catch (error) {