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.
@@ -0,0 +1,334 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ appendFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ renameSync,
9
+ statSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { MAX_MEMORY_CONTENT_BYTES } from './observer-memory.mjs';
15
+
16
+ export const MEMORY_OUTBOX_REL = '.brain/observer-memory-outbox';
17
+ export const MEMORY_STATE_FILE = '.brain/observer-memory-state.json';
18
+ const MEMORY_SCHEMA_VERSION = 1;
19
+ const ROOT_FILES = new Set(['CORE.md', 'DIGEST.md', 'SHARED_MEMORY.md']);
20
+ const ROOTS = [
21
+ '02-Sessões',
22
+ '04-Decisões',
23
+ '05-Bugs',
24
+ '06-Aprendizados',
25
+ '07-Specs',
26
+ '08-Mudanças',
27
+ '.brain',
28
+ ];
29
+ const TRANSIENT_NAMES = new Set(['observer-memory-outbox', 'observer-outbox']);
30
+
31
+ function isoNow(value) {
32
+ const date = value instanceof Date ? value : new Date(value || Date.now());
33
+ if (Number.isNaN(date.getTime())) throw new Error('captured_at inválido.');
34
+ return date.toISOString();
35
+ }
36
+
37
+ function hash(value) {
38
+ return createHash('sha256').update(value).digest('hex');
39
+ }
40
+
41
+ function readJson(path, fallback) {
42
+ if (!existsSync(path)) return fallback;
43
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; }
44
+ }
45
+
46
+ function atomicJson(path, value) {
47
+ mkdirSync(join(path, '..'), { recursive: true });
48
+ const temp = path + '.' + process.pid + '.' + Date.now() + '.tmp';
49
+ writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', 'utf8');
50
+ renameSync(temp, path);
51
+ }
52
+
53
+ function readState(vaultBase) {
54
+ return readJson(join(vaultBase, MEMORY_STATE_FILE), {
55
+ schema_version: MEMORY_SCHEMA_VERSION,
56
+ files: {},
57
+ });
58
+ }
59
+
60
+ function entityType(logicalPath) {
61
+ if (logicalPath.startsWith('02-Sessões/')) return 'session';
62
+ if (logicalPath.startsWith('04-Decisões/')) return 'decision';
63
+ if (logicalPath.startsWith('05-Bugs/')) return 'bug';
64
+ if (logicalPath.startsWith('06-Aprendizados/')) return 'learning';
65
+ if (logicalPath.startsWith('07-Specs/')) return 'spec';
66
+ if (logicalPath.startsWith('08-Mudanças/')) return 'change';
67
+ return 'memory';
68
+ }
69
+
70
+ function shouldSkip(name, relativePath) {
71
+ if (TRANSIENT_NAMES.has(name) || name.endsWith('.tmp') || name.endsWith('.lock')) return true;
72
+ if (relativePath === MEMORY_STATE_FILE) return true;
73
+ return false;
74
+ }
75
+
76
+ function walkDirectory(root, relativeRoot, output) {
77
+ if (!existsSync(root)) return;
78
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
79
+ const logicalPath = relativeRoot ? relativeRoot + '/' + entry.name : entry.name;
80
+ if (shouldSkip(entry.name, logicalPath)) continue;
81
+ const absolute = join(root, entry.name);
82
+ if (entry.isDirectory()) {
83
+ walkDirectory(absolute, logicalPath, output);
84
+ } else if (entry.isFile()) {
85
+ output.push({ absolute, logicalPath });
86
+ }
87
+ }
88
+ }
89
+
90
+ function memoryFiles(vaultBase) {
91
+ const files = [];
92
+ for (const root of ROOTS) {
93
+ const absolute = join(vaultBase, root);
94
+ if (root === '.brain' || root.includes('-')) walkDirectory(absolute, root, files);
95
+ }
96
+ for (const rootFile of ROOT_FILES) {
97
+ const absolute = join(vaultBase, rootFile);
98
+ if (existsSync(absolute) && statSync(absolute).isFile()) files.push({ absolute, logicalPath: rootFile });
99
+ }
100
+ return files.sort((a, b) => a.logicalPath.localeCompare(b.logicalPath));
101
+ }
102
+
103
+ export function localMemoryManifest(vaultBase) {
104
+ return Object.fromEntries(memoryFiles(vaultBase).map((file) => {
105
+ const content = readFileSync(file.absolute, 'utf8');
106
+ return [file.logicalPath, {
107
+ logical_path: file.logicalPath,
108
+ content_hash: hash(content),
109
+ bytes: Buffer.byteLength(content, 'utf8'),
110
+ }];
111
+ }));
112
+ }
113
+
114
+ function projectIdFromVault(vaultBase) {
115
+ const project = readJson(join(vaultBase, '.brain', 'PROJECT.json'), null);
116
+ return project?.projectId || project?.project_id || '';
117
+ }
118
+
119
+ function eventId(projectId, logicalPath, revision, contentHash) {
120
+ return 'mem-' + hash([projectId, logicalPath, revision, contentHash].join(':')).slice(0, 24);
121
+ }
122
+
123
+ function makeEvent({ projectId, logicalPath, content, revision, sourceSessionId, sourceTurnId, capturedAt, operation = 'upsert' }) {
124
+ const body = content || '';
125
+ const contentHash = hash(body);
126
+ return {
127
+ schema_version: MEMORY_SCHEMA_VERSION,
128
+ event_id: eventId(projectId, logicalPath, revision, contentHash),
129
+ project_id: projectId,
130
+ entity_type: entityType(logicalPath),
131
+ logical_path: logicalPath,
132
+ operation,
133
+ ...(operation === 'upsert' ? { content: body } : {}),
134
+ content_hash: contentHash,
135
+ revision,
136
+ source_session_id: String(sourceSessionId || ''),
137
+ source_turn_id: String(sourceTurnId || ''),
138
+ captured_at: capturedAt,
139
+ };
140
+ }
141
+
142
+ export function buildMemoryEventBatch({
143
+ vaultBase,
144
+ projectId = projectIdFromVault(vaultBase),
145
+ sourceSessionId = '',
146
+ sourceTurnId = '',
147
+ now = new Date(),
148
+ state = readState(vaultBase),
149
+ } = {}) {
150
+ if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
151
+ const capturedAt = isoNow(now);
152
+ const currentFiles = memoryFiles(vaultBase);
153
+ const currentPaths = new Set(currentFiles.map((file) => file.logicalPath));
154
+ const nextState = {
155
+ schema_version: MEMORY_SCHEMA_VERSION,
156
+ files: { ...(state.files || {}) },
157
+ };
158
+ const events = [];
159
+
160
+ for (const file of currentFiles) {
161
+ const content = readFileSync(file.absolute, 'utf8');
162
+ if (Buffer.byteLength(content, 'utf8') > MAX_MEMORY_CONTENT_BYTES) {
163
+ throw new Error('arquivo excede o limite de memória: ' + file.logicalPath);
164
+ }
165
+ const contentHash = hash(content);
166
+ const previous = state.files?.[file.logicalPath];
167
+ if (previous?.content_hash === contentHash) {
168
+ nextState.files[file.logicalPath] = previous;
169
+ continue;
170
+ }
171
+ const revision = Number(previous?.revision || 0) + 1;
172
+ const next = { content_hash: contentHash, revision, event_id: eventId(projectId, file.logicalPath, revision, contentHash) };
173
+ nextState.files[file.logicalPath] = next;
174
+ events.push(makeEvent({
175
+ projectId,
176
+ logicalPath: file.logicalPath,
177
+ content,
178
+ revision,
179
+ sourceSessionId,
180
+ sourceTurnId,
181
+ capturedAt,
182
+ }));
183
+ }
184
+
185
+ for (const [logicalPath, previous] of Object.entries(state.files || {})) {
186
+ if (currentPaths.has(logicalPath)) continue;
187
+ const revision = Number(previous.revision || 0) + 1;
188
+ events.push(makeEvent({
189
+ projectId,
190
+ logicalPath,
191
+ content: '',
192
+ revision,
193
+ sourceSessionId,
194
+ sourceTurnId,
195
+ capturedAt,
196
+ operation: 'delete',
197
+ }));
198
+ delete nextState.files[logicalPath];
199
+ }
200
+
201
+ return { events, nextState, scanned: currentFiles.length, changed: events.length };
202
+ }
203
+
204
+ export function commitMemoryPublishState(vaultBase, state) {
205
+ atomicJson(join(vaultBase, MEMORY_STATE_FILE), state);
206
+ }
207
+
208
+ function outboxDir(vaultBase) {
209
+ return join(vaultBase, MEMORY_OUTBOX_REL);
210
+ }
211
+
212
+ function outboxPath(vaultBase, events) {
213
+ const id = hash(JSON.stringify(events)).slice(0, 24);
214
+ return join(outboxDir(vaultBase), id + '.json');
215
+ }
216
+
217
+ function queueMemoryBatch(vaultBase, events) {
218
+ mkdirSync(outboxDir(vaultBase), { recursive: true });
219
+ const path = outboxPath(vaultBase, events);
220
+ if (!existsSync(path)) atomicJson(path, { schema_version: MEMORY_SCHEMA_VERSION, events });
221
+ return path;
222
+ }
223
+
224
+ export function listMemoryOutbox(vaultBase) {
225
+ const dir = outboxDir(vaultBase);
226
+ if (!existsSync(dir)) return [];
227
+ return readdirSync(dir)
228
+ .filter((name) => name.endsWith('.json'))
229
+ .sort()
230
+ .map((name) => join(dir, name));
231
+ }
232
+
233
+ async function postBatch(url, projectId, events, fetchImpl = globalThis.fetch) {
234
+ const response = await fetchImpl(
235
+ String(url).replace(/\/$/, '') + '/v1/projects/' + encodeURIComponent(projectId) + '/memory/events',
236
+ {
237
+ method: 'POST',
238
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
239
+ body: JSON.stringify({ events }),
240
+ },
241
+ );
242
+ if (!response.ok) throw new Error('Observer respondeu HTTP ' + response.status + '.');
243
+ return response.json();
244
+ }
245
+
246
+ export async function retryObserverMemoryOutbox({
247
+ vaultBase,
248
+ projectId = projectIdFromVault(vaultBase),
249
+ url,
250
+ fetchImpl = globalThis.fetch,
251
+ } = {}) {
252
+ const files = listMemoryOutbox(vaultBase);
253
+ if (!url) return { attempted: 0, confirmed: 0, pending: files.length };
254
+ let attempted = 0;
255
+ let confirmed = 0;
256
+ for (const path of files) {
257
+ attempted += 1;
258
+ try {
259
+ const batch = readJson(path, null);
260
+ if (!batch?.events?.length) {
261
+ unlinkSync(path);
262
+ continue;
263
+ }
264
+ await postBatch(url, projectId, batch.events, fetchImpl);
265
+ unlinkSync(path);
266
+ confirmed += 1;
267
+ } catch {
268
+ break;
269
+ }
270
+ }
271
+ return { attempted, confirmed, pending: listMemoryOutbox(vaultBase).length };
272
+ }
273
+
274
+ export async function compareMemoryParity({
275
+ vaultBase,
276
+ projectId = projectIdFromVault(vaultBase),
277
+ url,
278
+ fetchImpl = globalThis.fetch,
279
+ } = {}) {
280
+ const response = await fetchImpl(
281
+ String(url).replace(/\/$/, '') + '/v1/projects/' + encodeURIComponent(projectId) + '/memory/tree',
282
+ { headers: { accept: 'application/json' } },
283
+ );
284
+ if (!response.ok) throw new Error('Observer respondeu HTTP ' + response.status + '.');
285
+ const body = await response.json();
286
+ const local = localMemoryManifest(vaultBase);
287
+ const remote = Object.fromEntries((body.documents || []).map((item) => [item.logical_path, item]));
288
+ const missing = Object.keys(local).filter((path) => !remote[path]);
289
+ const mismatched = Object.keys(local).filter((path) => remote[path] && remote[path].content_hash !== local[path].content_hash);
290
+ const extra = Object.keys(remote).filter((path) => !local[path]);
291
+ return {
292
+ files: Object.keys(local).length,
293
+ remote_files: Object.keys(remote).length,
294
+ missing: missing.length,
295
+ mismatched: mismatched.length,
296
+ extra: extra.length,
297
+ missing_paths: missing,
298
+ mismatched_paths: mismatched,
299
+ extra_paths: extra,
300
+ };
301
+ }
302
+
303
+ export async function publishObserverMemory({
304
+ vaultBase,
305
+ projectId = projectIdFromVault(vaultBase),
306
+ url,
307
+ sourceSessionId = '',
308
+ sourceTurnId = '',
309
+ now = new Date(),
310
+ fetchImpl = globalThis.fetch,
311
+ } = {}) {
312
+ if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
313
+ await retryObserverMemoryOutbox({ vaultBase, projectId, url, fetchImpl });
314
+ const state = readState(vaultBase);
315
+ const batch = buildMemoryEventBatch({ vaultBase, projectId, sourceSessionId, sourceTurnId, now, state });
316
+ if (batch.events.length === 0) {
317
+ commitMemoryPublishState(vaultBase, batch.nextState);
318
+ return { ok: true, queued: false, scanned: batch.scanned, changed: 0, pending: listMemoryOutbox(vaultBase).length };
319
+ }
320
+ if (!url) {
321
+ queueMemoryBatch(vaultBase, batch.events);
322
+ commitMemoryPublishState(vaultBase, batch.nextState);
323
+ return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listMemoryOutbox(vaultBase).length, hookExitCode: 0 };
324
+ }
325
+ try {
326
+ await postBatch(url, projectId, batch.events, fetchImpl);
327
+ commitMemoryPublishState(vaultBase, batch.nextState);
328
+ return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listMemoryOutbox(vaultBase).length };
329
+ } catch (error) {
330
+ queueMemoryBatch(vaultBase, batch.events);
331
+ commitMemoryPublishState(vaultBase, batch.nextState);
332
+ return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listMemoryOutbox(vaultBase).length, hookExitCode: 0, error: error.message };
333
+ }
334
+ }
@@ -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
+ }