wendkeep 0.68.6 → 0.70.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.en.md +7 -3
  3. package/README.md +7 -3
  4. package/docs/en/commands/changes-and-verification.md +4 -0
  5. package/docs/en/commands/costs-and-observability.md +11 -2
  6. package/docs/en/commands/memory.md +10 -1
  7. package/docs/en/commands/observer.md +104 -0
  8. package/docs/pt-BR/commands/changes-and-verification.md +4 -0
  9. package/docs/pt-BR/commands/costs-and-observability.md +12 -2
  10. package/docs/pt-BR/commands/memory.md +10 -1
  11. package/docs/pt-BR/commands/observer.md +105 -0
  12. package/hooks/brain-core.mjs +46 -2
  13. package/hooks/brain-inject.mjs +3 -3
  14. package/hooks/harness-doctor.mjs +21 -7
  15. package/hooks/observer-publish.mjs +21 -0
  16. package/hooks/pricing.json +10 -1
  17. package/hooks/session-ensure.mjs +23 -0
  18. package/hooks/session-identity.mjs +4 -2
  19. package/hooks/session-stop.mjs +17 -0
  20. package/hooks/token-usage.mjs +13 -0
  21. package/hooks/vault-health.mjs +13 -0
  22. package/package.json +3 -3
  23. package/packages/cli/src/index.mjs +9 -2
  24. package/packages/integrations/src/host-hooks.mjs +4 -0
  25. package/packages/vault/src/memory-handoff.mjs +75 -9
  26. package/packages/vault/src/memory-store.mjs +34 -6
  27. package/packages/vault/src/validate-core.mjs +29 -15
  28. package/packages/vault/src/validate-memory.mjs +209 -6
  29. package/src/doctor.mjs +4 -0
  30. package/src/memory.mjs +4 -1
  31. package/src/observer-publish.mjs +122 -0
  32. package/src/observer-server.mjs +203 -0
  33. package/src/observer-snapshot.mjs +153 -0
  34. package/src/observer-store.mjs +155 -0
  35. package/src/observer.mjs +108 -0
  36. package/src/taxonomy.mjs +2 -0
@@ -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
+ }
@@ -0,0 +1,108 @@
1
+ import { homedir } from 'node:os';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+ import { appendObserverEvent, readObserverIndex, registerObserverProject } from './observer-store.mjs';
4
+ import { buildProjectSnapshot } from './observer-snapshot.mjs';
5
+ import { startObserverServer } from './observer-server.mjs';
6
+ import { resolveProjectVault } from '../packages/vault/src/project-vault.mjs';
7
+
8
+ export const OBSERVER_HELP = `wendkeep observer — Observer local multi-projeto
9
+
10
+ Uso:
11
+ wendkeep observer serve [--data-dir P] [--host 127.0.0.1] [--port 8787] [--token T]
12
+ [--allow-non-loopback]
13
+ wendkeep observer register --project P [--vault V] [--data-dir D] [--json]
14
+ wendkeep observer publish --project P [--vault V] [--data-dir D] [--json]
15
+ wendkeep observer status [--data-dir D] [--json]
16
+
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
+ `;
20
+
21
+ function optionValue(argv, name) {
22
+ const index = argv.indexOf(name);
23
+ if (index >= 0) return argv[index + 1] || '';
24
+ return argv.find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1) || '';
25
+ }
26
+
27
+ function dataDir(argv) {
28
+ return resolve(optionValue(argv, '--data-dir')
29
+ || process.env.WENDKEEP_OBSERVER_DATA_DIR
30
+ || `${homedir()}/.wendkeep-observer`);
31
+ }
32
+
33
+ function projectRoot(argv) {
34
+ const value = optionValue(argv, '--project') || process.cwd();
35
+ return isAbsolute(value) ? resolve(value) : resolve(process.cwd(), value);
36
+ }
37
+
38
+ function vaultBase(argv, root) {
39
+ const explicit = optionValue(argv, '--vault');
40
+ if (explicit) return isAbsolute(explicit) ? resolve(explicit) : resolve(root, explicit);
41
+ return resolveProjectVault({ startDir: root }).base;
42
+ }
43
+
44
+ function print(value, asJson) {
45
+ process.stdout.write(`${asJson ? JSON.stringify(value, null, 2) : String(value)}\n`);
46
+ }
47
+
48
+ function summary(index) {
49
+ return {
50
+ schema_version: index.schema_version,
51
+ projects: index.projects.map(({ snapshot, ...item }) => item),
52
+ };
53
+ }
54
+
55
+ export async function runObserver(argv = []) {
56
+ const [sub] = argv;
57
+ const asJson = argv.includes('--json');
58
+ if (!sub || sub === 'help') {
59
+ process.stdout.write(OBSERVER_HELP);
60
+ return 0;
61
+ }
62
+ const dir = dataDir(argv);
63
+
64
+ if (sub === 'status') {
65
+ print(summary(readObserverIndex(dir)), asJson);
66
+ return 0;
67
+ }
68
+
69
+ if (sub === 'serve') {
70
+ const token = optionValue(argv, '--token') || process.env.WENDKEEP_OBSERVER_TOKEN || '';
71
+ const host = optionValue(argv, '--host') || '127.0.0.1';
72
+ const server = await startObserverServer({
73
+ dataDir: dir,
74
+ host,
75
+ port: Number(optionValue(argv, '--port') || 8787),
76
+ token,
77
+ allowNonLoopback: argv.includes('--allow-non-loopback'),
78
+ });
79
+ if (!token) {
80
+ await server.close();
81
+ throw new Error('Observer exige --token ou WENDKEEP_OBSERVER_TOKEN.');
82
+ }
83
+ const address = server.address();
84
+ process.stdout.write(`wendkeep observer listening: http://${address.address}:${address.port}\n`);
85
+ return 0;
86
+ }
87
+
88
+ if (!['register', 'publish'].includes(sub)) throw new Error(`observer: subcomando desconhecido: ${sub}`);
89
+ const root = projectRoot(argv);
90
+ const vault = vaultBase(argv, root);
91
+ const snapshot = buildProjectSnapshot({ vaultBase: vault, projectRoot: root });
92
+
93
+ if (sub === 'register') {
94
+ const result = registerObserverProject(dir, {
95
+ projectId: snapshot.project_id,
96
+ projectName: snapshot.project_name,
97
+ wendkeepVersion: snapshot.wendkeep_version,
98
+ });
99
+ if (!result.registered) throw new Error(result.errors.join(' '));
100
+ print(result, asJson);
101
+ return 0;
102
+ }
103
+
104
+ const result = appendObserverEvent(dir, snapshot);
105
+ if (!result.accepted && !result.duplicate) throw new Error(result.errors.join(' '));
106
+ print({ ok: true, ...result }, asJson);
107
+ return 0;
108
+ }
package/src/taxonomy.mjs CHANGED
@@ -99,6 +99,7 @@ export const HOOK_FILES = [
99
99
  'change-guard.mjs',
100
100
  'change-nag.mjs',
101
101
  'plan-capture.mjs',
102
+ 'observer-publish.mjs',
102
103
  ];
103
104
 
104
105
  // Hook scripts that are safe to invoke directly via `wendkeep hook <name>`.
@@ -122,6 +123,7 @@ export const RUNNABLE_HOOKS = [
122
123
  'change-guard',
123
124
  'change-nag',
124
125
  'plan-capture',
126
+ 'observer-publish',
125
127
  ];
126
128
 
127
129
  // --- companion plugins / MCP --------------------------------------------------