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,21 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from 'node:url';
3
+ import { debugLog, readHookInput, resolveVault } from './obsidian-common.mjs';
4
+ import { publishObserverSnapshot } from '../src/observer-publish.mjs';
5
+
6
+ async function main() {
7
+ const input = readHookInput();
8
+ const resolved = resolveVault(input);
9
+ const result = await publishObserverSnapshot({
10
+ vaultBase: resolved.base,
11
+ projectRoot: resolved.projectRoot,
12
+ });
13
+ if (!result.ok && result.error) debugLog('Observer publish fail-open:', result.error);
14
+ }
15
+
16
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
17
+ main().catch((error) => {
18
+ debugLog('Observer hook falhou de forma fail-open:', error);
19
+ process.exitCode = 0;
20
+ });
21
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_nota": "Preços API por milhão de tokens. cachedInput = cache read. Cache write aplica multiplicador no código: 5m = 1.25x input, 1h = 2x input. Editar aqui quando o provedor mudar preços (sem mexer no .mjs). Se o arquivo sumir ou ficar inválido, o hook usa a tabela embutida em token-usage.mjs.",
3
- "_fonte": "OpenAI https://openai.com/index/gpt-5-6/ e https://help.openai.com/en/articles/20001325-a-preview-of-gpt-56-sol-terra-and-luna; Anthropic https://www.anthropic.com/pricing — conferido 2026-07-11",
3
+ "_fonte": "OpenAI https://openai.com/index/gpt-5-6/, https://openai.com/index/introducing-gpt-5-3-codex-spark/ e https://help.openai.com/en/articles/20001106-codex-rate-card; Anthropic https://www.anthropic.com/pricing — conferido 2026-08-16",
4
4
  "models": {
5
5
  "gpt-5.6-sol": { "label": "GPT-5.6 Sol API", "provider": "openai", "input": 5, "cachedInput": 0.5, "output": 30 },
6
6
  "gpt-5.6-terra": { "label": "GPT-5.6 Terra API", "provider": "openai", "input": 2.5, "cachedInput": 0.25, "output": 15 },
@@ -12,6 +12,15 @@
12
12
  "cachedInput": 0.5,
13
13
  "output": 30
14
14
  },
15
+ "gpt-5.3-codex-spark": {
16
+ "label": "GPT-5.3-Codex-Spark (research preview)",
17
+ "provider": "openai",
18
+ "pricingStatus": "research-preview",
19
+ "pricingNote": "Tarifa final não publicada; custo não estimado.",
20
+ "input": null,
21
+ "cachedInput": null,
22
+ "output": null
23
+ },
15
24
  "claude-opus-4.7": {
16
25
  "label": "Claude Opus 4.7 API",
17
26
  "provider": "anthropic",
@@ -36,11 +36,29 @@ import { resolveSessionIdentity } from './session-identity.mjs';
36
36
  import { readCodexRolloutMeta } from './codex-rollout-meta.mjs';
37
37
  import { mutateSessionNote } from './session-note-io.mjs';
38
38
  import { captureProjectScope, projectScopePatch } from './project-scope.mjs';
39
+ import { sanitizeMemoryText } from './memory-schema.mjs';
39
40
 
40
41
  function sessionIdFromInput(input) {
41
42
  return input.session_id || input.sessionId || input.codex_session_id || '';
42
43
  }
43
44
 
45
+ function workSessionIdFromInput(input = {}) {
46
+ const shared = input.shared || input.handoff?.shared;
47
+ const value = input.work_session_id
48
+ || input.workSessionId
49
+ || shared?.work_session_id
50
+ || shared?.workSessionId
51
+ || input.handoff?.work_session_id
52
+ || input.handoff?.workSessionId
53
+ || '';
54
+ return sanitizeMemoryText(value).trim();
55
+ }
56
+
57
+ function workSessionPatch(input = {}) {
58
+ const workSessionId = workSessionIdFromInput(input);
59
+ return workSessionId ? { work_session_id: workSessionId } : {};
60
+ }
61
+
44
62
  function turnSequenceFromInput(input = {}) {
45
63
  const value = input.turn_sequence ?? input.turnSequence;
46
64
  const parsed = Number(value);
@@ -290,6 +308,7 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
290
308
  transcript_path: identity.transcriptPath,
291
309
  transcript_id: identity.transcriptId,
292
310
  provider: identity.provider,
311
+ ...workSessionPatch(input),
293
312
  ...scopePatch,
294
313
  ...causalTurnPatch(input, now),
295
314
  });
@@ -318,6 +337,7 @@ function createSession({ vaultBase, sessionId, input, now, identity, scopePatch
318
337
  transcript_path: identity.transcriptPath,
319
338
  transcript_id: identity.transcriptId,
320
339
  provider: identity.provider,
340
+ ...workSessionPatch(input),
321
341
  ...scopePatch,
322
342
  ...causalTurnPatch(input, now),
323
343
  });
@@ -361,6 +381,7 @@ function main() {
361
381
  upsertSessionRegistry(vaultBase, sessionId, {
362
382
  transcript_paths: [identity.transcriptPath],
363
383
  provider: identity.provider,
384
+ ...workSessionPatch(input),
364
385
  });
365
386
  }
366
387
  writeHookOutput({});
@@ -398,6 +419,7 @@ function main() {
398
419
  transcript_path: identity.transcriptPath,
399
420
  transcript_id: identity.transcriptId,
400
421
  provider: identity.provider,
422
+ ...workSessionPatch(input),
401
423
  ...scopePatch,
402
424
  ...causalTurnPatch(input, now),
403
425
  });
@@ -443,6 +465,7 @@ function main() {
443
465
  transcript_path: identity.transcriptPath,
444
466
  transcript_id: identity.transcriptId,
445
467
  provider: identity.provider,
468
+ ...workSessionPatch(input),
446
469
  ...scopePatch,
447
470
  ...causalTurnPatch(input, now),
448
471
  });
@@ -65,8 +65,10 @@ export function resolveSessionIdentity(vaultBase, input = {}, provider = detectP
65
65
  export function resolveSessionEntry(vaultBase, input = {}, provider = detectProvider()) {
66
66
  const identity = resolveSessionIdentity(vaultBase, input, provider);
67
67
  if (identity.state !== 'resolved') return { identity, entry: null };
68
+ const entry = readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null;
69
+ const workSessionId = entry?.work_session_id ? String(entry.work_session_id) : '';
68
70
  return {
69
- identity,
70
- entry: readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null,
71
+ identity: workSessionId ? { ...identity, work_session_id: workSessionId } : identity,
72
+ entry,
71
73
  };
72
74
  }
@@ -521,6 +521,21 @@ function shouldFinalizeSession() {
521
521
  return process.env.OBSIDIAN_NO_AUTO_FINALIZE !== '1';
522
522
  }
523
523
 
524
+ function sharedHandoffFromInput(input = {}, entry = {}) {
525
+ const supplied = input.shared || input.handoff?.shared;
526
+ const shared = supplied && typeof supplied === 'object' && !Array.isArray(supplied)
527
+ ? { ...supplied }
528
+ : {};
529
+ const workSessionId = shared.work_session_id
530
+ || shared.workSessionId
531
+ || input.work_session_id
532
+ || input.workSessionId
533
+ || entry?.work_session_id
534
+ || '';
535
+ if (!shared.work_session_id && workSessionId) shared.work_session_id = workSessionId;
536
+ return Object.keys(shared).length ? shared : null;
537
+ }
538
+
524
539
  export function commitSessionMemory(vaultBase, handoff, { projectOptions = {} } = {}) {
525
540
  if (detectMemoryMode(vaultBase).mode === 'legacy') {
526
541
  return { status: 'legacy', eventCount: 0, eventIds: [], checkpoint: null };
@@ -1378,6 +1393,7 @@ export async function main({
1378
1393
  summary: finalSummary,
1379
1394
  noteRel: sessionRel,
1380
1395
  });
1396
+ const sharedHandoff = sharedHandoffFromInput(input, entry);
1381
1397
  memoryHandoff = {
1382
1398
  projectId,
1383
1399
  identity,
@@ -1390,6 +1406,7 @@ export async function main({
1390
1406
  observedAt: turnIdentity.observedAt || new Date(0).toISOString(),
1391
1407
  summary: finalSummary,
1392
1408
  evidence: memoryEvidence,
1409
+ ...(sharedHandoff ? { shared: sharedHandoff } : {}),
1393
1410
  };
1394
1411
  memoryAttempt = stageMemory(vaultBase, {
1395
1412
  handoff: memoryHandoff,
@@ -30,6 +30,15 @@ const DEFAULT_PRICE_REFERENCE = {
30
30
  'gpt-5.6-sol': { label: 'GPT-5.6 Sol API', provider: 'openai', input: 5, cachedInput: 0.5, output: 30 },
31
31
  'gpt-5.6-terra': { label: 'GPT-5.6 Terra API', provider: 'openai', input: 2.5, cachedInput: 0.25, output: 15 },
32
32
  'gpt-5.6-luna': { label: 'GPT-5.6 Luna API', provider: 'openai', input: 1, cachedInput: 0.1, output: 6 },
33
+ 'gpt-5.3-codex-spark': {
34
+ label: 'GPT-5.3-Codex-Spark (research preview)',
35
+ provider: 'openai',
36
+ pricingStatus: 'research-preview',
37
+ pricingNote: 'Tarifa final não publicada; custo não estimado.',
38
+ input: null,
39
+ cachedInput: null,
40
+ output: null,
41
+ },
33
42
  'gpt-5.5': {
34
43
  label: 'GPT-5.5 API',
35
44
  provider: 'openai',
@@ -122,6 +131,10 @@ const MODEL_ALIASES = {
122
131
  'gpt-5.3-codex': 'gpt-5.5',
123
132
  'gpt-5.3': 'gpt-5.5',
124
133
  'openai/gpt-5.4': 'gpt-5.5',
134
+ 'gpt-5.3-codex-spark': 'gpt-5.3-codex-spark',
135
+ 'gpt-5-3-codex-spark': 'gpt-5.3-codex-spark',
136
+ 'openai/gpt-5.3-codex-spark': 'gpt-5.3-codex-spark',
137
+ 'openai/gpt-5-3-codex-spark': 'gpt-5.3-codex-spark',
125
138
  'claude-opus-4.7': 'claude-opus-4.7',
126
139
  'claude-opus-4-7': 'claude-opus-4.7',
127
140
  'anthropic/claude-opus-4.7': 'claude-opus-4.7',
@@ -231,6 +231,12 @@ function memoryMetrics() {
231
231
  pendingOutbox: 0,
232
232
  candidates: 0,
233
233
  activeConflicts: 0,
234
+ semanticStatus: null,
235
+ semanticCode: null,
236
+ semanticActiveKeys: [],
237
+ semanticProjectedKeys: [],
238
+ semanticMissingKeys: [],
239
+ semanticCounts: {},
234
240
  };
235
241
  }
236
242
 
@@ -505,6 +511,7 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
505
511
  for (const warning of bundle.warnings || []) warnings.push(warning);
506
512
 
507
513
  const ok = failures.length === 0;
514
+ const semantic = bundle.semantic || {};
508
515
  return {
509
516
  ok,
510
517
  status: ok ? (warnings.length ? 'warning' : 'healthy') : 'blocked',
@@ -519,6 +526,12 @@ export function checkMemoryBundle(vaultBase, { registry } = {}) {
519
526
  pendingOutbox: outbox.count,
520
527
  candidates: candidates.items.length,
521
528
  activeConflicts: activeConflicts.length,
529
+ semanticStatus: semantic.status ?? null,
530
+ semanticCode: semantic.code ?? null,
531
+ semanticActiveKeys: semantic.activeKeys || [],
532
+ semanticProjectedKeys: semantic.projectedKeys || [],
533
+ semanticMissingKeys: semantic.missingKeys || [],
534
+ semanticCounts: semantic.counts || {},
522
535
  },
523
536
  };
524
537
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wendkeep",
3
- "version": "0.68.6",
3
+ "version": "0.70.0",
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": [
@@ -40,7 +40,7 @@
40
40
  "node": ">=18"
41
41
  },
42
42
  "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",
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/observer-snapshot.mjs && node --check src/observer-store.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 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
44
  "test": "node --test --test-concurrency=2",
45
45
  "release": "node scripts/release.mjs",
46
46
  "release:dry": "node scripts/release.mjs --dry-run",
@@ -70,6 +70,6 @@
70
70
  },
71
71
  "devDependencies": {
72
72
  "acorn": "^8.18.0",
73
- "wendkeep": "^0.68.0"
73
+ "wendkeep": "^0.69.0"
74
74
  }
75
75
  }
@@ -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.
@@ -107,7 +108,7 @@ Usage:
107
108
  promote <candidate> [--event <event-id>] | reject <candidate>. --vault P.
108
109
  Reconcile is dry-run by default; the original attempt remains audited.
109
110
  wendkeep validate-memory [path] Validate .brain/CORE.md against the compaction
110
- protocol (cap 25, 3 sections, no secrets/PII).
111
+ protocol (cap 40, warning 35, 4 KiB, 320 chars/line, 3 sections, no secrets/PII).
111
112
  --vault <path> validates the complete v2 bundle.
112
113
  wendkeep sync-defs [opts] Copy versioned defs from the vault's .brain into the
113
114
  project: .brain/agents/*.toml -> .codex/agents,
@@ -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.
@@ -4,12 +4,55 @@ import { basename, join, relative } from 'node:path';
4
4
 
5
5
  import { sanitizeMemoryText } from './memory-schema.mjs';
6
6
 
7
+ const SHARED_HANDOFF_FIELDS = Object.freeze([
8
+ ['objective', 'objective.current'],
9
+ ['delivered', 'state.delivered'],
10
+ ['constraints', 'constraint.active'],
11
+ ['decisions', 'decision.active'],
12
+ ['next_actions', 'next.action'],
13
+ ['blockers', 'blocker.active'],
14
+ ['risks', 'risk.known'],
15
+ ]);
16
+
7
17
  function canonicalValue(value) {
8
18
  if (Array.isArray(value)) return value.map(canonicalValue);
9
19
  if (!value || typeof value !== 'object') return value;
10
20
  return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])]));
11
21
  }
12
22
 
23
+ function sanitizeValue(value) {
24
+ if (typeof value === 'string') return sanitizeMemoryText(value);
25
+ if (Array.isArray(value)) return value.map(sanitizeValue);
26
+ if (value && typeof value === 'object') {
27
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sanitizeValue(value[key])]));
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function hasMeaningfulValue(value) {
33
+ if (typeof value === 'string') return value.trim().length > 0;
34
+ if (Array.isArray(value)) return value.length > 0;
35
+ if (value && typeof value === 'object') return Object.keys(value).length > 0;
36
+ return value !== undefined && value !== null;
37
+ }
38
+
39
+ /** Normalize the portable operational handoff without inventing missing identity. */
40
+ export function normalizeSharedHandoff(shared) {
41
+ if (!shared || typeof shared !== 'object' || Array.isArray(shared)) return null;
42
+
43
+ const normalized = {};
44
+ const workSessionId = sanitizeMemoryText(shared.work_session_id ?? shared.workSessionId ?? '').trim();
45
+ if (workSessionId) normalized.work_session_id = workSessionId;
46
+
47
+ for (const [field] of SHARED_HANDOFF_FIELDS) {
48
+ if (!Object.hasOwn(shared, field)) continue;
49
+ const value = sanitizeValue(shared[field]);
50
+ if (hasMeaningfulValue(value)) normalized[field] = value;
51
+ }
52
+
53
+ return Object.keys(normalized).length ? normalized : null;
54
+ }
55
+
13
56
  function eventId(context, memoryKey, value) {
14
57
  const digest = createHash('sha256')
15
58
  .update(JSON.stringify([
@@ -26,8 +69,8 @@ function eventId(context, memoryKey, value) {
26
69
  }
27
70
 
28
71
  function makeEvent(context, { memoryKey, value, authority, evidence }) {
29
- const cleanValue = typeof value === 'string' ? sanitizeMemoryText(value) : canonicalValue(value);
30
- return {
72
+ const cleanValue = sanitizeValue(value);
73
+ const event = {
31
74
  v: 1,
32
75
  event_id: eventId(context, memoryKey, cleanValue),
33
76
  project_id: String(context.projectId || ''),
@@ -43,6 +86,8 @@ function makeEvent(context, { memoryKey, value, authority, evidence }) {
43
86
  observed_at: context.observedAt,
44
87
  evidence: (evidence || []).filter(Boolean).map((item) => sanitizeMemoryText(item)),
45
88
  };
89
+ if (context.workSessionId) event.work_session_id = context.workSessionId;
90
+ return event;
46
91
  }
47
92
 
48
93
  function readJson(path) {
@@ -133,14 +178,35 @@ export function buildSessionMemoryEvents({
133
178
  observedAt,
134
179
  summary,
135
180
  evidence = {},
181
+ shared,
136
182
  }) {
137
- const context = { projectId, identity, activation, turn, observedAt };
138
- const events = [makeEvent(context, {
139
- memoryKey: 'handoff.latest',
140
- value: sanitizeMemoryText(summary),
141
- authority: 'reported',
142
- evidence: [noteRel],
143
- })];
183
+ const normalizedShared = normalizeSharedHandoff(shared);
184
+ const context = {
185
+ projectId, identity, activation, turn, observedAt,
186
+ workSessionId: normalizedShared?.work_session_id || '',
187
+ };
188
+ const events = [];
189
+
190
+ if (normalizedShared) {
191
+ for (const [field, memoryKey] of SHARED_HANDOFF_FIELDS) {
192
+ if (!Object.hasOwn(normalizedShared, field)) continue;
193
+ events.push(makeEvent(context, {
194
+ memoryKey,
195
+ value: normalizedShared[field],
196
+ authority: 'reported',
197
+ evidence: [noteRel],
198
+ }));
199
+ }
200
+ }
201
+
202
+ if (!events.length) {
203
+ events.push(makeEvent(context, {
204
+ memoryKey: 'handoff.latest',
205
+ value: sanitizeMemoryText(summary),
206
+ authority: 'reported',
207
+ evidence: [noteRel],
208
+ }));
209
+ }
144
210
 
145
211
  if (evidence.change?.slug && evidence.change?.status && evidence.change?.adr) {
146
212
  events.push(makeEvent(context, {
@@ -437,6 +437,29 @@ function conflictCandidate(memoryKey, events, currentEvent = null) {
437
437
  };
438
438
  }
439
439
 
440
+ function conflictReviewEvent(candidate, candidateCount = 1) {
441
+ const source = candidate.events?.[0] || {};
442
+ const memoryKey = sanitizeMemoryText(candidate.memory_key || 'unknown');
443
+ const eventCount = Array.isArray(candidate.event_ids) ? candidate.event_ids.length : 0;
444
+ return {
445
+ v: 1,
446
+ event_id: `mem-review-${candidate.candidate_id}`,
447
+ project_id: source.project_id || '',
448
+ memory_key: candidate.memory_key,
449
+ operation: 'assert',
450
+ value: `[revisão pendente: ${memoryKey}; candidates: ${candidateCount}; events: ${eventCount}]`,
451
+ authority: 'candidate',
452
+ canonical_session_id: source.canonical_session_id || 'memory-reducer',
453
+ activation_id: source.activation_id || 'memory-reducer',
454
+ activation_epoch: Number.isInteger(source.activation_epoch) ? source.activation_epoch : 0,
455
+ turn_sequence: 0,
456
+ source_turn_id: 'memory-review',
457
+ observed_at: source.observed_at || new Date(0).toISOString(),
458
+ evidence: ['MEMORY_CANDIDATES.jsonl'],
459
+ review_pending: true,
460
+ };
461
+ }
462
+
440
463
  function sortedObject(entries) {
441
464
  return Object.fromEntries([...entries].sort(([left], [right]) => left.localeCompare(right)));
442
465
  }
@@ -753,14 +776,19 @@ export function reduceMemoryEvents(inputEvents = [], {
753
776
  && !resolvedCandidateIds.has(item.candidate_id));
754
777
  unresolvedCandidates.sort((left, right) => left.candidate_id.localeCompare(right.candidate_id));
755
778
  superseded.sort((left, right) => left.event_id.localeCompare(right.event_id));
756
- const activeEvents = Object.entries(recordObject).map(([memoryKey, record]) => ({
757
- ...record.source,
758
- memory_key: memoryKey,
759
- operation: 'assert',
760
- value: record.value,
761
- }));
762
779
  const eventCursor = events.at(-1)?.event_id || 'none';
763
780
  const stateHash = hashMemoryValue({ state, tombstones: tombstoneObject });
781
+ const activeEvents = [
782
+ ...Object.entries(recordObject).map(([memoryKey, record]) => ({
783
+ ...record.source,
784
+ memory_key: memoryKey,
785
+ operation: 'assert',
786
+ value: record.value,
787
+ })),
788
+ ...unresolvedCandidates.map((candidate) => (
789
+ conflictReviewEvent(candidate, unresolvedCandidates.length)
790
+ )),
791
+ ];
764
792
 
765
793
  return {
766
794
  state,
@@ -1,6 +1,7 @@
1
1
  // Memory-compaction protocol for the curated .brain/CORE.md layer.
2
2
  // Ported from NutriGym-Vision's scripts/validate-brain-core.js to ESM:
3
- // - cap 25 lines (hard), 22 (soft warning) — 1 durable item per line
3
+ // - cap 40 lines (hard), 35 (soft warning) — 1 durable item per line
4
+ // - 4 KiB and 320 characters per line
4
5
  // - 3 required sections
5
6
  // - no secrets / no real-provider PII emails
6
7
  // Plus the seeded skeleton and the protocol reference doc.
@@ -8,8 +9,12 @@
8
9
  import { existsSync, readFileSync } from 'node:fs';
9
10
  import { isAbsolute, join, resolve } from 'node:path';
10
11
 
11
- const HARD_LIMIT = 25;
12
- const SOFT_LIMIT = 22;
12
+ export const CORE_LIMITS = Object.freeze({
13
+ lines: 40,
14
+ warningLines: 35,
15
+ bytes: 4 * 1024,
16
+ lineChars: 320,
17
+ });
13
18
 
14
19
  // Bilingual (0.8.0): a CORE is valid when it carries the COMPLETE section set of either
15
20
  // locale — pt-BR or en. Mixed/partial sets fail (the 3 sections are one contract).
@@ -40,16 +45,25 @@ const SECRET_PATTERNS = [
40
45
 
41
46
  const PII_EMAIL_REGEX = /\b[A-Za-z0-9._%+-]+@(?!example\.(?:com|org|net)\b)(?:gmail|hotmail|yahoo|outlook|live|icloud|protonmail)\.[A-Za-z]{2,}\b/i;
42
47
 
43
- // Validate CORE.md content. Returns { ok, errors, warnings, lineCount }.
48
+ // Validate CORE.md content. Returns { ok, errors, warnings, lineCount, byteCount }.
44
49
  export function validateCore(content) {
45
50
  const text = String(content ?? '');
46
51
  const lines = text.split('\n');
47
52
  const lineCount = text.endsWith('\n') ? lines.length - 1 : lines.length;
53
+ const byteCount = Buffer.byteLength(text, 'utf8');
48
54
  const errors = [];
49
55
 
50
- if (lineCount > HARD_LIMIT) {
51
- errors.push(`Tamanho ${lineCount} > ${HARD_LIMIT} linhas (hard limit). Curar: remover itens resolvidos (detalhe vive no vault/git).`);
56
+ if (lineCount > CORE_LIMITS.lines) {
57
+ errors.push(`Tamanho ${lineCount} > ${CORE_LIMITS.lines} linhas (hard limit). Curar: remover itens resolvidos (detalhe vive no vault/git).`);
52
58
  }
59
+ if (byteCount > CORE_LIMITS.bytes) {
60
+ errors.push(`Tamanho ${byteCount} > ${CORE_LIMITS.bytes} bytes (budget do CORE). Curar: manter apenas estado durável.`);
61
+ }
62
+ lines.forEach((line, index) => {
63
+ if (line.length > CORE_LIMITS.lineChars) {
64
+ errors.push(`Linha ${index + 1} tem ${line.length} caracteres; limite ${CORE_LIMITS.lineChars}.`);
65
+ }
66
+ });
53
67
  // Pick the locale set that matches best; require it to be complete.
54
68
  const missingBySet = Object.values(SECTION_SETS).map((set) => set.filter(({ regex }) => !regex.test(text)));
55
69
  const best = missingBySet.reduce((a, b) => (b.length < a.length ? b : a));
@@ -62,11 +76,11 @@ export function validateCore(content) {
62
76
  if (em) errors.push(`Email real detectado: "${em[0]}" — usar user@example.com.`);
63
77
 
64
78
  const warnings = [];
65
- if (lineCount >= SOFT_LIMIT && lineCount <= HARD_LIMIT) {
66
- warnings.push(`Tamanho ${lineCount}/${HARD_LIMIT} linhas — perto do limite; remover itens resolvidos (≥${SOFT_LIMIT}).`);
79
+ if (lineCount >= CORE_LIMITS.warningLines && lineCount <= CORE_LIMITS.lines) {
80
+ warnings.push(`Tamanho ${lineCount}/${CORE_LIMITS.lines} linhas — perto do limite; remover itens resolvidos (≥${CORE_LIMITS.warningLines}).`);
67
81
  }
68
82
 
69
- return { ok: errors.length === 0, errors, warnings, lineCount };
83
+ return { ok: errors.length === 0, errors, warnings, lineCount, byteCount };
70
84
  }
71
85
 
72
86
  // The seeded CORE.md (must pass validateCore). Bootstraps the 3 sections so the
@@ -75,7 +89,7 @@ export function renderCoreSkeleton(localeId = 'pt-BR') {
75
89
  if (localeId === 'en') {
76
90
  return `# CORE — curated memory core (.brain)
77
91
 
78
- > RULE #1 — the project's canonical memory. Hand-curated, 25-line cap (validate: \`wendkeep validate-memory\`). Volatile facts live in DIGEST.md (auto). Depth: /brain-recall <topic>.
92
+ > RULE #1 — the project's canonical memory. Hand-curated, 40-line cap (validate: \`wendkeep validate-memory\`). Volatile facts live in DIGEST.md (auto). Depth: /brain-recall <topic>.
79
93
 
80
94
  ## User Preferences
81
95
  - (durable preferences: language, style, conventions)
@@ -89,7 +103,7 @@ export function renderCoreSkeleton(localeId = 'pt-BR') {
89
103
  }
90
104
  return `# CORE — núcleo curado da memória (.brain)
91
105
 
92
- > REGRA #1 — memória canônica do projeto. Curado à mão, cap 25 linhas (valide: \`wendkeep validate-memory\`). Volátil vive no DIGEST.md (auto). Profundidade: /brain-recall <tópico>.
106
+ > REGRA #1 — memória canônica do projeto. Curado à mão, cap 40 linhas (valide: \`wendkeep validate-memory\`). Volátil vive no DIGEST.md (auto). Profundidade: /brain-recall <tópico>.
93
107
 
94
108
  ## Preferências do Usuário
95
109
  - (preferências duráveis: idioma, estilo, convenções)
@@ -110,8 +124,8 @@ export function renderCompactionProtocol() {
110
124
 
111
125
  ## 1. Duas camadas
112
126
 
113
- - **QUENTE** (auto-injetada por sessão, budget ~45 linhas):
114
- - \`.brain/CORE.md\` — curado à mão, **≤25 linhas** (1 item/linha): preferências, padrões, pendências.
127
+ - **QUENTE** (auto-injetada por sessão, com budgets por camada):
128
+ - \`.brain/CORE.md\` — curado à mão, **≤40 linhas** (alerta em 35; 4 KiB; 320 caracteres por linha): preferências, padrões, pendências.
115
129
  - \`.brain/DIGEST.md\` — auto-gerado (0 token LLM, ≤15 linhas): decisões/sessões/bugs/aprendizados recentes.
116
130
  - **FRIA** (sob demanda):
117
131
  - \`.brain/index.jsonl\` — índice de todas as sessões (1/linha, frontmatter).
@@ -120,7 +134,7 @@ export function renderCompactionProtocol() {
120
134
  ## 2. Compactação = regra de geração (sem trabalho manual)
121
135
 
122
136
  - **DIGEST se auto-compacta**: caps determinísticos (5 decisões, 4 sessões, 2 bugs, 2 aprendizados + \`+N mais\`). O velho cai do quente sozinho e permanece no índice/vault. **NUNCA editar** \`DIGEST.md\`/\`index.jsonl\`.
123
- - **CORE**: quando ≥22 linhas (soft warning), remover itens resolvidos/obsoletos — o detalhe já vive no vault e no histórico do git.
137
+ - **CORE**: quando ≥35 linhas (soft warning), remover itens resolvidos/obsoletos — o detalhe já vive no vault e no histórico do git.
124
138
 
125
139
  ## 3. O que escrever no CORE
126
140
 
@@ -139,7 +153,7 @@ wendkeep validate-memory # valida <vault>/.brain/CORE.md
139
153
  wendkeep validate-memory <path> # valida outro arquivo
140
154
  \`\`\`
141
155
 
142
- Checa: cap 25 (soft 22), 3 seções, sem segredos/PII. Exit 0 = OK, 1 = falha.
156
+ Checa: cap 40 (soft 35), 4 KiB, 320 caracteres por linha, 3 seções, sem segredos/PII. Exit 0 = OK, 1 = falha.
143
157
  `;
144
158
  }
145
159