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,107 @@
|
|
|
1
|
+
PRAGMA foreign_keys = ON;
|
|
2
|
+
|
|
3
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
4
|
+
version INTEGER PRIMARY KEY,
|
|
5
|
+
name TEXT NOT NULL,
|
|
6
|
+
applied_at TEXT NOT NULL
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
10
|
+
project_id TEXT PRIMARY KEY,
|
|
11
|
+
project_name TEXT NOT NULL,
|
|
12
|
+
wendkeep_version TEXT NOT NULL DEFAULT '',
|
|
13
|
+
registered_at TEXT NOT NULL,
|
|
14
|
+
updated_at TEXT NOT NULL
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS ingest_events (
|
|
18
|
+
event_id TEXT PRIMARY KEY,
|
|
19
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
20
|
+
kind TEXT NOT NULL,
|
|
21
|
+
payload_hash TEXT NOT NULL,
|
|
22
|
+
payload_json TEXT NOT NULL,
|
|
23
|
+
occurred_at TEXT NOT NULL,
|
|
24
|
+
ingested_at TEXT NOT NULL,
|
|
25
|
+
status TEXT NOT NULL DEFAULT 'accepted'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_ingest_events_project_time
|
|
29
|
+
ON ingest_events(project_id, occurred_at);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS memory_events (
|
|
32
|
+
event_id TEXT PRIMARY KEY REFERENCES ingest_events(event_id) ON DELETE CASCADE,
|
|
33
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
34
|
+
entity_type TEXT NOT NULL,
|
|
35
|
+
logical_path TEXT NOT NULL,
|
|
36
|
+
operation TEXT NOT NULL,
|
|
37
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
38
|
+
content_hash TEXT NOT NULL DEFAULT '',
|
|
39
|
+
source_session_id TEXT NOT NULL DEFAULT '',
|
|
40
|
+
source_turn_id TEXT NOT NULL DEFAULT '',
|
|
41
|
+
occurred_at TEXT NOT NULL,
|
|
42
|
+
payload_json TEXT NOT NULL,
|
|
43
|
+
UNIQUE(project_id, logical_path, revision)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_memory_events_project_path
|
|
47
|
+
ON memory_events(project_id, logical_path, revision);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
50
|
+
document_id TEXT PRIMARY KEY,
|
|
51
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
52
|
+
logical_path TEXT NOT NULL,
|
|
53
|
+
entity_type TEXT NOT NULL,
|
|
54
|
+
title TEXT NOT NULL DEFAULT '',
|
|
55
|
+
content TEXT NOT NULL DEFAULT '',
|
|
56
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
57
|
+
content_hash TEXT NOT NULL DEFAULT '',
|
|
58
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
59
|
+
source_session_id TEXT NOT NULL DEFAULT '',
|
|
60
|
+
source_turn_id TEXT NOT NULL DEFAULT '',
|
|
61
|
+
captured_at TEXT NOT NULL,
|
|
62
|
+
deleted_at TEXT,
|
|
63
|
+
UNIQUE(project_id, logical_path)
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_documents_project_type
|
|
67
|
+
ON documents(project_id, entity_type, deleted_at);
|
|
68
|
+
|
|
69
|
+
CREATE INDEX IF NOT EXISTS idx_documents_project_revision
|
|
70
|
+
ON documents(project_id, revision);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
73
|
+
session_id TEXT PRIMARY KEY,
|
|
74
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
75
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
76
|
+
status TEXT NOT NULL DEFAULT 'unknown',
|
|
77
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
78
|
+
change_slug TEXT NOT NULL DEFAULT '',
|
|
79
|
+
started_at TEXT,
|
|
80
|
+
ended_at TEXT,
|
|
81
|
+
updated_at TEXT NOT NULL,
|
|
82
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_project_time
|
|
86
|
+
ON sessions(project_id, started_at, ended_at);
|
|
87
|
+
|
|
88
|
+
CREATE TABLE IF NOT EXISTS agent_runs (
|
|
89
|
+
agent_id TEXT PRIMARY KEY,
|
|
90
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
91
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
|
|
92
|
+
parent_agent_id TEXT,
|
|
93
|
+
role TEXT NOT NULL DEFAULT 'main',
|
|
94
|
+
agent_name TEXT NOT NULL DEFAULT '',
|
|
95
|
+
agent_type TEXT NOT NULL DEFAULT '',
|
|
96
|
+
workflow TEXT NOT NULL DEFAULT '',
|
|
97
|
+
status TEXT NOT NULL DEFAULT 'unknown',
|
|
98
|
+
model TEXT NOT NULL DEFAULT '',
|
|
99
|
+
effort TEXT NOT NULL DEFAULT '',
|
|
100
|
+
started_at TEXT,
|
|
101
|
+
ended_at TEXT,
|
|
102
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
103
|
+
FOREIGN KEY(parent_agent_id) REFERENCES agent_runs(agent_id) ON DELETE SET NULL
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_agent_runs_project_session
|
|
107
|
+
ON agent_runs(project_id, session_id, role);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS usage_rollups (
|
|
2
|
+
rollup_key TEXT PRIMARY KEY,
|
|
3
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
4
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
|
|
5
|
+
agent_id TEXT NOT NULL REFERENCES agent_runs(agent_id) ON DELETE CASCADE,
|
|
6
|
+
role TEXT NOT NULL DEFAULT 'main',
|
|
7
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
8
|
+
model_provider TEXT NOT NULL DEFAULT '',
|
|
9
|
+
model TEXT NOT NULL DEFAULT '',
|
|
10
|
+
effort TEXT NOT NULL DEFAULT '',
|
|
11
|
+
calls INTEGER NOT NULL DEFAULT 0,
|
|
12
|
+
tokens_input INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
tokens_cache_write INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
tokens_cache_read INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
tokens_output INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
tokens_reasoning INTEGER NOT NULL DEFAULT 0,
|
|
17
|
+
tokens_total INTEGER NOT NULL DEFAULT 0,
|
|
18
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
19
|
+
cost_status TEXT NOT NULL DEFAULT 'unknown',
|
|
20
|
+
pricing_source TEXT NOT NULL DEFAULT '',
|
|
21
|
+
pricing_version TEXT NOT NULL DEFAULT '',
|
|
22
|
+
wasted_usd REAL NOT NULL DEFAULT 0,
|
|
23
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
24
|
+
occurred_at TEXT NOT NULL,
|
|
25
|
+
source_event_id TEXT NOT NULL REFERENCES ingest_events(event_id) ON DELETE CASCADE,
|
|
26
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_usage_rollups_project_time
|
|
30
|
+
ON usage_rollups(project_id, occurred_at);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_usage_rollups_project_agent
|
|
33
|
+
ON usage_rollups(project_id, agent_id, role);
|
|
34
|
+
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_usage_rollups_project_model
|
|
36
|
+
ON usage_rollups(project_id, model_provider, model);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS llm_calls (
|
|
39
|
+
call_id TEXT PRIMARY KEY,
|
|
40
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
41
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
|
|
42
|
+
agent_id TEXT NOT NULL REFERENCES agent_runs(agent_id) ON DELETE CASCADE,
|
|
43
|
+
role TEXT NOT NULL DEFAULT 'main',
|
|
44
|
+
provider TEXT NOT NULL DEFAULT '',
|
|
45
|
+
model_provider TEXT NOT NULL DEFAULT '',
|
|
46
|
+
model TEXT NOT NULL DEFAULT '',
|
|
47
|
+
effort TEXT NOT NULL DEFAULT '',
|
|
48
|
+
sequence INTEGER NOT NULL DEFAULT 0,
|
|
49
|
+
occurred_at TEXT NOT NULL,
|
|
50
|
+
tokens_input INTEGER NOT NULL DEFAULT 0,
|
|
51
|
+
tokens_cache_write INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
tokens_cache_read INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
tokens_output INTEGER NOT NULL DEFAULT 0,
|
|
54
|
+
tokens_reasoning INTEGER NOT NULL DEFAULT 0,
|
|
55
|
+
tokens_total INTEGER NOT NULL DEFAULT 0,
|
|
56
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
57
|
+
cost_status TEXT NOT NULL DEFAULT 'unknown',
|
|
58
|
+
transcript_id TEXT,
|
|
59
|
+
prompt_text TEXT NOT NULL DEFAULT '',
|
|
60
|
+
response_text TEXT NOT NULL DEFAULT '',
|
|
61
|
+
status TEXT NOT NULL DEFAULT 'complete',
|
|
62
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_llm_calls_project_time
|
|
66
|
+
ON llm_calls(project_id, occurred_at);
|
|
67
|
+
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_llm_calls_project_agent
|
|
69
|
+
ON llm_calls(project_id, agent_id, sequence);
|
|
70
|
+
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_llm_calls_project_model
|
|
72
|
+
ON llm_calls(project_id, model_provider, model);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS transcripts (
|
|
2
|
+
transcript_id TEXT PRIMARY KEY,
|
|
3
|
+
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
|
4
|
+
session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE,
|
|
5
|
+
agent_id TEXT NOT NULL REFERENCES agent_runs(agent_id) ON DELETE CASCADE,
|
|
6
|
+
coverage TEXT NOT NULL DEFAULT 'summary_only',
|
|
7
|
+
codec TEXT NOT NULL DEFAULT 'gzip',
|
|
8
|
+
content_gzip BLOB NOT NULL,
|
|
9
|
+
content_sha256 TEXT NOT NULL,
|
|
10
|
+
original_bytes INTEGER NOT NULL,
|
|
11
|
+
compressed_bytes INTEGER NOT NULL,
|
|
12
|
+
source TEXT NOT NULL DEFAULT '',
|
|
13
|
+
occurred_at TEXT NOT NULL,
|
|
14
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_transcripts_project_session
|
|
18
|
+
ON transcripts(project_id, session_id, occurred_at);
|
|
19
|
+
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_transcripts_project_coverage
|
|
21
|
+
ON transcripts(project_id, coverage);
|
|
@@ -0,0 +1,335 @@
|
|
|
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 === 'observer-sql-state.json' || name === 'observer-sql-outbox'
|
|
72
|
+
|| name.endsWith('.tmp') || name.endsWith('.lock')) return true;
|
|
73
|
+
if (relativePath === MEMORY_STATE_FILE) return true;
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function walkDirectory(root, relativeRoot, output) {
|
|
78
|
+
if (!existsSync(root)) return;
|
|
79
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
80
|
+
const logicalPath = relativeRoot ? relativeRoot + '/' + entry.name : entry.name;
|
|
81
|
+
if (shouldSkip(entry.name, logicalPath)) continue;
|
|
82
|
+
const absolute = join(root, entry.name);
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
walkDirectory(absolute, logicalPath, output);
|
|
85
|
+
} else if (entry.isFile()) {
|
|
86
|
+
output.push({ absolute, logicalPath });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function memoryFiles(vaultBase) {
|
|
92
|
+
const files = [];
|
|
93
|
+
for (const root of ROOTS) {
|
|
94
|
+
const absolute = join(vaultBase, root);
|
|
95
|
+
if (root === '.brain' || root.includes('-')) walkDirectory(absolute, root, files);
|
|
96
|
+
}
|
|
97
|
+
for (const rootFile of ROOT_FILES) {
|
|
98
|
+
const absolute = join(vaultBase, rootFile);
|
|
99
|
+
if (existsSync(absolute) && statSync(absolute).isFile()) files.push({ absolute, logicalPath: rootFile });
|
|
100
|
+
}
|
|
101
|
+
return files.sort((a, b) => a.logicalPath.localeCompare(b.logicalPath));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function localMemoryManifest(vaultBase) {
|
|
105
|
+
return Object.fromEntries(memoryFiles(vaultBase).map((file) => {
|
|
106
|
+
const content = readFileSync(file.absolute, 'utf8');
|
|
107
|
+
return [file.logicalPath, {
|
|
108
|
+
logical_path: file.logicalPath,
|
|
109
|
+
content_hash: hash(content),
|
|
110
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
111
|
+
}];
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function projectIdFromVault(vaultBase) {
|
|
116
|
+
const project = readJson(join(vaultBase, '.brain', 'PROJECT.json'), null);
|
|
117
|
+
return project?.projectId || project?.project_id || '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function eventId(projectId, logicalPath, revision, contentHash) {
|
|
121
|
+
return 'mem-' + hash([projectId, logicalPath, revision, contentHash].join(':')).slice(0, 24);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function makeEvent({ projectId, logicalPath, content, revision, sourceSessionId, sourceTurnId, capturedAt, operation = 'upsert' }) {
|
|
125
|
+
const body = content || '';
|
|
126
|
+
const contentHash = hash(body);
|
|
127
|
+
return {
|
|
128
|
+
schema_version: MEMORY_SCHEMA_VERSION,
|
|
129
|
+
event_id: eventId(projectId, logicalPath, revision, contentHash),
|
|
130
|
+
project_id: projectId,
|
|
131
|
+
entity_type: entityType(logicalPath),
|
|
132
|
+
logical_path: logicalPath,
|
|
133
|
+
operation,
|
|
134
|
+
...(operation === 'upsert' ? { content: body } : {}),
|
|
135
|
+
content_hash: contentHash,
|
|
136
|
+
revision,
|
|
137
|
+
source_session_id: String(sourceSessionId || ''),
|
|
138
|
+
source_turn_id: String(sourceTurnId || ''),
|
|
139
|
+
captured_at: capturedAt,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buildMemoryEventBatch({
|
|
144
|
+
vaultBase,
|
|
145
|
+
projectId = projectIdFromVault(vaultBase),
|
|
146
|
+
sourceSessionId = '',
|
|
147
|
+
sourceTurnId = '',
|
|
148
|
+
now = new Date(),
|
|
149
|
+
state = readState(vaultBase),
|
|
150
|
+
} = {}) {
|
|
151
|
+
if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
|
|
152
|
+
const capturedAt = isoNow(now);
|
|
153
|
+
const currentFiles = memoryFiles(vaultBase);
|
|
154
|
+
const currentPaths = new Set(currentFiles.map((file) => file.logicalPath));
|
|
155
|
+
const nextState = {
|
|
156
|
+
schema_version: MEMORY_SCHEMA_VERSION,
|
|
157
|
+
files: { ...(state.files || {}) },
|
|
158
|
+
};
|
|
159
|
+
const events = [];
|
|
160
|
+
|
|
161
|
+
for (const file of currentFiles) {
|
|
162
|
+
const content = readFileSync(file.absolute, 'utf8');
|
|
163
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_MEMORY_CONTENT_BYTES) {
|
|
164
|
+
throw new Error('arquivo excede o limite de memória: ' + file.logicalPath);
|
|
165
|
+
}
|
|
166
|
+
const contentHash = hash(content);
|
|
167
|
+
const previous = state.files?.[file.logicalPath];
|
|
168
|
+
if (previous?.content_hash === contentHash) {
|
|
169
|
+
nextState.files[file.logicalPath] = previous;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const revision = Number(previous?.revision || 0) + 1;
|
|
173
|
+
const next = { content_hash: contentHash, revision, event_id: eventId(projectId, file.logicalPath, revision, contentHash) };
|
|
174
|
+
nextState.files[file.logicalPath] = next;
|
|
175
|
+
events.push(makeEvent({
|
|
176
|
+
projectId,
|
|
177
|
+
logicalPath: file.logicalPath,
|
|
178
|
+
content,
|
|
179
|
+
revision,
|
|
180
|
+
sourceSessionId,
|
|
181
|
+
sourceTurnId,
|
|
182
|
+
capturedAt,
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const [logicalPath, previous] of Object.entries(state.files || {})) {
|
|
187
|
+
if (currentPaths.has(logicalPath)) continue;
|
|
188
|
+
const revision = Number(previous.revision || 0) + 1;
|
|
189
|
+
events.push(makeEvent({
|
|
190
|
+
projectId,
|
|
191
|
+
logicalPath,
|
|
192
|
+
content: '',
|
|
193
|
+
revision,
|
|
194
|
+
sourceSessionId,
|
|
195
|
+
sourceTurnId,
|
|
196
|
+
capturedAt,
|
|
197
|
+
operation: 'delete',
|
|
198
|
+
}));
|
|
199
|
+
delete nextState.files[logicalPath];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { events, nextState, scanned: currentFiles.length, changed: events.length };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function commitMemoryPublishState(vaultBase, state) {
|
|
206
|
+
atomicJson(join(vaultBase, MEMORY_STATE_FILE), state);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function outboxDir(vaultBase) {
|
|
210
|
+
return join(vaultBase, MEMORY_OUTBOX_REL);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function outboxPath(vaultBase, events) {
|
|
214
|
+
const id = hash(JSON.stringify(events)).slice(0, 24);
|
|
215
|
+
return join(outboxDir(vaultBase), id + '.json');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function queueMemoryBatch(vaultBase, events) {
|
|
219
|
+
mkdirSync(outboxDir(vaultBase), { recursive: true });
|
|
220
|
+
const path = outboxPath(vaultBase, events);
|
|
221
|
+
if (!existsSync(path)) atomicJson(path, { schema_version: MEMORY_SCHEMA_VERSION, events });
|
|
222
|
+
return path;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function listMemoryOutbox(vaultBase) {
|
|
226
|
+
const dir = outboxDir(vaultBase);
|
|
227
|
+
if (!existsSync(dir)) return [];
|
|
228
|
+
return readdirSync(dir)
|
|
229
|
+
.filter((name) => name.endsWith('.json'))
|
|
230
|
+
.sort()
|
|
231
|
+
.map((name) => join(dir, name));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function postBatch(url, projectId, events, fetchImpl = globalThis.fetch) {
|
|
235
|
+
const response = await fetchImpl(
|
|
236
|
+
String(url).replace(/\/$/, '') + '/v1/projects/' + encodeURIComponent(projectId) + '/memory/events',
|
|
237
|
+
{
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
240
|
+
body: JSON.stringify({ events }),
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
if (!response.ok) throw new Error('Observer respondeu HTTP ' + response.status + '.');
|
|
244
|
+
return response.json();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function retryObserverMemoryOutbox({
|
|
248
|
+
vaultBase,
|
|
249
|
+
projectId = projectIdFromVault(vaultBase),
|
|
250
|
+
url,
|
|
251
|
+
fetchImpl = globalThis.fetch,
|
|
252
|
+
} = {}) {
|
|
253
|
+
const files = listMemoryOutbox(vaultBase);
|
|
254
|
+
if (!url) return { attempted: 0, confirmed: 0, pending: files.length };
|
|
255
|
+
let attempted = 0;
|
|
256
|
+
let confirmed = 0;
|
|
257
|
+
for (const path of files) {
|
|
258
|
+
attempted += 1;
|
|
259
|
+
try {
|
|
260
|
+
const batch = readJson(path, null);
|
|
261
|
+
if (!batch?.events?.length) {
|
|
262
|
+
unlinkSync(path);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
await postBatch(url, projectId, batch.events, fetchImpl);
|
|
266
|
+
unlinkSync(path);
|
|
267
|
+
confirmed += 1;
|
|
268
|
+
} catch {
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return { attempted, confirmed, pending: listMemoryOutbox(vaultBase).length };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function compareMemoryParity({
|
|
276
|
+
vaultBase,
|
|
277
|
+
projectId = projectIdFromVault(vaultBase),
|
|
278
|
+
url,
|
|
279
|
+
fetchImpl = globalThis.fetch,
|
|
280
|
+
} = {}) {
|
|
281
|
+
const response = await fetchImpl(
|
|
282
|
+
String(url).replace(/\/$/, '') + '/v1/projects/' + encodeURIComponent(projectId) + '/memory/tree',
|
|
283
|
+
{ headers: { accept: 'application/json' } },
|
|
284
|
+
);
|
|
285
|
+
if (!response.ok) throw new Error('Observer respondeu HTTP ' + response.status + '.');
|
|
286
|
+
const body = await response.json();
|
|
287
|
+
const local = localMemoryManifest(vaultBase);
|
|
288
|
+
const remote = Object.fromEntries((body.documents || []).map((item) => [item.logical_path, item]));
|
|
289
|
+
const missing = Object.keys(local).filter((path) => !remote[path]);
|
|
290
|
+
const mismatched = Object.keys(local).filter((path) => remote[path] && remote[path].content_hash !== local[path].content_hash);
|
|
291
|
+
const extra = Object.keys(remote).filter((path) => !local[path]);
|
|
292
|
+
return {
|
|
293
|
+
files: Object.keys(local).length,
|
|
294
|
+
remote_files: Object.keys(remote).length,
|
|
295
|
+
missing: missing.length,
|
|
296
|
+
mismatched: mismatched.length,
|
|
297
|
+
extra: extra.length,
|
|
298
|
+
missing_paths: missing,
|
|
299
|
+
mismatched_paths: mismatched,
|
|
300
|
+
extra_paths: extra,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function publishObserverMemory({
|
|
305
|
+
vaultBase,
|
|
306
|
+
projectId = projectIdFromVault(vaultBase),
|
|
307
|
+
url,
|
|
308
|
+
sourceSessionId = '',
|
|
309
|
+
sourceTurnId = '',
|
|
310
|
+
now = new Date(),
|
|
311
|
+
fetchImpl = globalThis.fetch,
|
|
312
|
+
} = {}) {
|
|
313
|
+
if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
|
|
314
|
+
await retryObserverMemoryOutbox({ vaultBase, projectId, url, fetchImpl });
|
|
315
|
+
const state = readState(vaultBase);
|
|
316
|
+
const batch = buildMemoryEventBatch({ vaultBase, projectId, sourceSessionId, sourceTurnId, now, state });
|
|
317
|
+
if (batch.events.length === 0) {
|
|
318
|
+
commitMemoryPublishState(vaultBase, batch.nextState);
|
|
319
|
+
return { ok: true, queued: false, scanned: batch.scanned, changed: 0, pending: listMemoryOutbox(vaultBase).length };
|
|
320
|
+
}
|
|
321
|
+
if (!url) {
|
|
322
|
+
queueMemoryBatch(vaultBase, batch.events);
|
|
323
|
+
commitMemoryPublishState(vaultBase, batch.nextState);
|
|
324
|
+
return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listMemoryOutbox(vaultBase).length, hookExitCode: 0 };
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
await postBatch(url, projectId, batch.events, fetchImpl);
|
|
328
|
+
commitMemoryPublishState(vaultBase, batch.nextState);
|
|
329
|
+
return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listMemoryOutbox(vaultBase).length };
|
|
330
|
+
} catch (error) {
|
|
331
|
+
queueMemoryBatch(vaultBase, batch.events);
|
|
332
|
+
commitMemoryPublishState(vaultBase, batch.nextState);
|
|
333
|
+
return { ok: false, queued: true, scanned: batch.scanned, changed: batch.changed, pending: listMemoryOutbox(vaultBase).length, hookExitCode: 0, error: error.message };
|
|
334
|
+
}
|
|
335
|
+
}
|