wendkeep 0.69.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.69.0",
3
+ "version": "0.71.1",
4
4
  "description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -30,6 +30,7 @@
30
30
  "hooks",
31
31
  "packages",
32
32
  "schema",
33
+ "web/observer",
33
34
  "docs/pt-BR/commands/*.md",
34
35
  "docs/en/commands/*.md",
35
36
  "README.md",
@@ -40,7 +41,7 @@
40
41
  "node": ">=18"
41
42
  },
42
43
  "scripts": {
43
- "check": "node --check scripts/release.mjs && node --check scripts/release-plan.mjs && node --check bin/wendkeep.mjs && node --check packages/cli/src/index.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/operating-profile-task-store.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/harness/src/index.mjs && node --check packages/harness/src/flow-store.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/integrations/src/host-hooks.mjs && node --check packages/integrations/src/hook-envelope.mjs && node --check packages/integrations/src/prompt-content.mjs && node --check packages/integrations/src/transcript-usage.mjs && node --check packages/integrations/src/transcripts.mjs && node --check packages/integrations/src/session-identity.mjs && node --check packages/integrations/src/index.mjs && node --check packages/mcp/src/config.mjs && node --check packages/mcp/src/index.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs && node --check packages/vault/src/locale.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
44
+ "check": "node --check scripts/release.mjs && node --check scripts/release-plan.mjs && node --check bin/wendkeep.mjs && node --check packages/cli/src/index.mjs && node --check src/init.mjs && node --check src/doctor.mjs && node --check src/project-vault.mjs && node --check src/observer-snapshot.mjs && node --check src/observer-store.mjs && node --check src/observer-memory.mjs && node --check src/observer-memory-publish.mjs && node --check src/observer-server.mjs && node --check src/observer.mjs && node --check src/observer-publish.mjs && node --check src/operating-profile.mjs && node --check src/profile.mjs && node --check src/flow.mjs && node --check web/observer/app.mjs && node --check hooks/observer-publish.mjs && node --check hooks/operating-profile-runtime.mjs && node --check hooks/operating-profile-task-store.mjs && node --check hooks/flow-core.mjs && node --check hooks/flow-protected-policy.mjs && node --check hooks/git-snapshot.mjs && node --check hooks/vault-path-safety.mjs && node --check hooks/vault-runtime-store.mjs && node --check packages/harness/src/index.mjs && node --check packages/harness/src/flow-store.mjs && node --check packages/harness/src/operating-profile.mjs && node --check packages/harness/src/sensors-core.mjs && node --check packages/integrations/src/host-hooks.mjs && node --check packages/integrations/src/hook-envelope.mjs && node --check packages/integrations/src/prompt-content.mjs && node --check packages/integrations/src/transcript-usage.mjs && node --check packages/integrations/src/transcripts.mjs && node --check packages/integrations/src/session-identity.mjs && node --check packages/integrations/src/index.mjs && node --check packages/mcp/src/config.mjs && node --check packages/mcp/src/index.mjs && node --check packages/vault/src/index.mjs && node --check packages/vault/src/project-vault.mjs && node --check packages/vault/src/vault-path-safety.mjs && node --check packages/vault/src/locale.mjs && node --check packages/vault/src/memory-schema.mjs && node --check packages/vault/src/memory-mode.mjs && node --check packages/vault/src/memory-handoff.mjs && node --check packages/vault/src/memory-store.mjs && node --check packages/vault/src/validate-core.mjs && node --check packages/vault/src/validate-memory.mjs",
44
45
  "test": "node --test --test-concurrency=2",
45
46
  "release": "node scripts/release.mjs",
46
47
  "release:dry": "node scripts/release.mjs --dry-run",
@@ -70,6 +71,6 @@
70
71
  },
71
72
  "devDependencies": {
72
73
  "acorn": "^8.18.0",
73
- "wendkeep": "^0.68.6"
74
+ "wendkeep": "^0.69.0"
74
75
  }
75
76
  }
@@ -49,6 +49,7 @@ Usage:
49
49
  cannot replace itself. · --vault P · --profile <name> · --yes.
50
50
 
51
51
  wendkeep doctor [--vault P] Run a vault health check.
52
+ wendkeep observer <sub> Local multi-project Observer: serve | register | publish | status.
52
53
  wendkeep change <sub> Change lifecycle: new [--simple] | use | bind <slug> --session <id> | continue | list | show |
53
54
  status | done <id> | undone <id> | diff | archive [--force] | abandon | relink | backlink.
54
55
  archive exige verdict (rode verify --deep); abandon descarta sem ADR.
@@ -199,7 +200,7 @@ async function main(argv) {
199
200
  // `sync` starts with `init` and resolves the freshly bound Vault itself. Pre-resolving
200
201
  // here would prevent that repair step from reporting a corrupt binding as its own
201
202
  // first-stage failure (and could never make it as far as the guarded init).
202
- && !['init', 'sync', 'hook', '--version', '-v', '--help', '-h', 'help'].includes(cmd)) {
203
+ && !['init', 'sync', 'hook', 'observer', '--version', '-v', '--help', '-h', 'help'].includes(cmd)) {
203
204
  await preferProjectVault(rest);
204
205
  }
205
206
  switch (cmd) {
@@ -216,6 +217,12 @@ async function main(argv) {
216
217
  process.exit(runDoctor(rest));
217
218
  break;
218
219
  }
220
+ case 'observer': {
221
+ const { runObserver } = await import('../../../src/observer.mjs');
222
+ const observerExitCode = await runObserver(rest);
223
+ if (rest[0] !== 'serve') process.exit(observerExitCode);
224
+ break;
225
+ }
219
226
  case 'sync': {
220
227
  const { runSync } = await import('../../../src/sync.mjs');
221
228
  process.exit(await runSync(rest));
@@ -13,7 +13,11 @@ export const SESSION_HOOKS = [
13
13
  // memory injection for the whole session.
14
14
  { event: 'SessionStart', matcher: 'startup|clear|compact', name: 'brain-inject', timeout: 45, order: -10, codex: true, statusMessage: 'wendkeep: injecting memory + active change' },
15
15
  { event: 'SessionStart', matcher: 'startup', name: 'session-start', timeout: 30, codex: true, statusMessage: 'wendkeep: opening Obsidian session' },
16
+ // Observer publication is a derived, fail-open projection and therefore runs only after
17
+ // the local lifecycle hook has written its authoritative session state.
18
+ { event: 'SessionStart', matcher: 'startup|resume|clear|compact', name: 'observer-publish', timeout: 5, order: 20, codex: true, statusMessage: 'wendkeep: publishing local observer snapshot' },
16
19
  { event: 'Stop', matcher: null, name: 'session-stop', timeout: 60, codex: true, statusMessage: 'wendkeep: writing session checkpoint' },
20
+ { event: 'Stop', matcher: null, name: 'observer-publish', timeout: 5, order: 20, codex: true, statusMessage: 'wendkeep: publishing local observer snapshot' },
17
21
  { event: 'UserPromptSubmit', matcher: null, name: 'session-ensure', timeout: 30, codex: true, statusMessage: 'wendkeep: ensuring active session' },
18
22
  // Capture an interactive decision (AskUserQuestion) — options + the user's choice — into 04-Decisões.
19
23
  // codex: AskUserQuestion is a Claude-only tool; there is nothing to match on.
@@ -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
+ }