wendkeep 0.65.0 → 0.66.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.
@@ -1,32 +1,32 @@
1
- // .agent/hooks/brain-recall.mjs
2
- // Query engine read-only: pontua o índice por tópico. Token só no resultado.
3
- // Uso: node .agent/hooks/brain-recall.mjs <termos da busca>
4
- import { pathToFileURL } from 'node:url';
5
- import { getVaultBase } from './obsidian-common.mjs';
6
- import { loadIndex } from './brain-core.mjs';
7
-
8
- export { loadIndex };
9
-
10
- export function scoreRows(rows, query, topK = 5) {
11
- const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
12
- if (!terms.length) return [];
13
- return rows
14
- .map((r) => {
15
- // Inclui slug do file + paths das derivadas (ADR/bug/aprendizado) no haystack:
16
- // os títulos das sessões e tags são genéricos; o sinal tópico vem dos slugs.
17
- const hay = `${r.summary || ''} ${(r.tags || []).join(' ')} ${r.file || ''} ${(r.decisions || []).join(' ')} ${(r.bugs || []).join(' ')} ${(r.learnings || []).join(' ')}`.toLowerCase();
18
- let score = 0;
19
- for (const t of terms) if (hay.includes(t)) score++;
20
- return { row: r, score };
21
- })
22
- .filter((s) => s.score > 0)
23
- .sort((a, b) => b.score - a.score || String(b.row.date || '').localeCompare(String(a.row.date || '')))
24
- .slice(0, topK)
25
- .map((s) => s.row);
26
- }
27
-
28
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
29
- const vaultBase = getVaultBase();
30
- const hits = scoreRows(loadIndex(vaultBase), process.argv.slice(2).join(' '));
31
- process.stdout.write(JSON.stringify(hits, null, 2) + '\n');
32
- }
1
+ // .agent/hooks/brain-recall.mjs
2
+ // Query engine read-only: pontua o índice por tópico. Token só no resultado.
3
+ // Uso: node .agent/hooks/brain-recall.mjs <termos da busca>
4
+ import { pathToFileURL } from 'node:url';
5
+ import { getVaultBase } from './obsidian-common.mjs';
6
+ import { loadIndex } from './brain-core.mjs';
7
+
8
+ export { loadIndex };
9
+
10
+ export function scoreRows(rows, query, topK = 5) {
11
+ const terms = String(query).toLowerCase().split(/\s+/).filter(Boolean);
12
+ if (!terms.length) return [];
13
+ return rows
14
+ .map((r) => {
15
+ // Inclui slug do file + paths das derivadas (ADR/bug/aprendizado) no haystack:
16
+ // os títulos das sessões e tags são genéricos; o sinal tópico vem dos slugs.
17
+ const hay = `${r.summary || ''} ${(r.tags || []).join(' ')} ${r.file || ''} ${(r.decisions || []).join(' ')} ${(r.bugs || []).join(' ')} ${(r.learnings || []).join(' ')}`.toLowerCase();
18
+ let score = 0;
19
+ for (const t of terms) if (hay.includes(t)) score++;
20
+ return { row: r, score };
21
+ })
22
+ .filter((s) => s.score > 0)
23
+ .sort((a, b) => b.score - a.score || String(b.row.date || '').localeCompare(String(a.row.date || '')))
24
+ .slice(0, topK)
25
+ .map((s) => s.row);
26
+ }
27
+
28
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
29
+ const vaultBase = getVaultBase();
30
+ const hits = scoreRows(loadIndex(vaultBase), process.argv.slice(2).join(' '));
31
+ process.stdout.write(JSON.stringify(hits, null, 2) + '\n');
32
+ }
@@ -1,13 +1,13 @@
1
- // .agent/hooks/brain-reindex.mjs
2
- // Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
3
- // Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
4
- import { pathToFileURL } from 'node:url';
5
- import { getVaultBase } from './obsidian-common.mjs';
6
- import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
7
-
8
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
9
- const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
10
- const rows = buildBrainIndex(vaultBase);
11
- const digest = buildBrainDigest(vaultBase, rows);
12
- process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
13
- }
1
+ // .agent/hooks/brain-reindex.mjs
2
+ // Backfill manual: reconstrói .brain/index.jsonl + .brain/DIGEST.md varrendo todo 02-Sessões.
3
+ // Uso: node .agent/hooks/brain-reindex.mjs [caminho-do-vault]
4
+ import { pathToFileURL } from 'node:url';
5
+ import { getVaultBase } from './obsidian-common.mjs';
6
+ import { buildBrainDigest, buildBrainIndex, brainDir } from './brain-core.mjs';
7
+
8
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
9
+ const vaultBase = getVaultBase({ obsidian_vault_path: process.argv[2] });
10
+ const rows = buildBrainIndex(vaultBase);
11
+ const digest = buildBrainDigest(vaultBase, rows);
12
+ process.stdout.write(`[brain] index: ${rows.length} sessões; digest: ${digest.length} linhas → ${brainDir(vaultBase)}\n`);
13
+ }
@@ -7,6 +7,26 @@ import { resolveProjectVault } from '../src/project-vault.mjs';
7
7
  import {
8
8
  assertVaultPathSafe, mkdirVaultPath, writeVaultFileAtomic,
9
9
  } from './vault-path-safety.mjs';
10
+ import {
11
+ salvageTruncatedJson,
12
+ parseHookInput,
13
+ stringifyHookOutput,
14
+ detectProvider as detectProviderFromEnvironment,
15
+ providerMeta as providerMetaFromProvider,
16
+ extractHookPrompt,
17
+ } from '../packages/integrations/src/hook-envelope.mjs';
18
+ import {
19
+ isBootstrapPrompt,
20
+ redactSecrets,
21
+ } from '../packages/integrations/src/prompt-content.mjs';
22
+ import { transcriptsMatch } from '../packages/integrations/src/session-identity.mjs';
23
+ export {
24
+ salvageTruncatedJson,
25
+ extractHookPrompt,
26
+ isBootstrapPrompt,
27
+ redactSecrets,
28
+ transcriptsMatch,
29
+ };
10
30
 
11
31
  // Deprecated export kept for consumers that imported it before 0.39.0. Automatic
12
32
  // hooks never use this fallback: an unbound project fails closed.
@@ -27,56 +47,12 @@ export const VAULT_COMPLEMENT_RULES = [
27
47
  'Atualize `SHARED_MEMORY.md` somente quando a síntese mudar estado ativo que outro agente precise saber.',
28
48
  ];
29
49
 
30
- // Codex on Windows serializes the Stop payload with `last_assistant_message` cut mid-string
31
- // and never closed when the assistant text carries non-ASCII (openai/codex#23784). That field
32
- // is LAST in codex-rs's StopCommandInput, so everything wendkeep consumes — session_id,
33
- // turn_id, transcript_path, cwd — sits in the intact prefix.
34
- //
35
- // One pass, tracking quotes/escapes/depth, remembering the offset of the last top-level comma
36
- // that was NOT inside a string. Re-closing there yields the well-formed prefix. Deliberately
37
- // NOT a decreasing brute-force parse: this runs on every turn and the payload can be tens of
38
- // KB. The truncated field is dropped, never reconstructed — half an assistant message is
39
- // invented data, and it is the one field we do not need.
40
- export function salvageTruncatedJson(raw) {
41
- const text = String(raw || '');
42
- if (text[0] !== '{') return null;
43
- let inString = false;
44
- let escaped = false;
45
- let depth = 0;
46
- let lastBoundary = -1;
47
- for (let i = 0; i < text.length; i += 1) {
48
- const ch = text[i];
49
- if (escaped) { escaped = false; continue; }
50
- if (ch === '\\') { if (inString) escaped = true; continue; }
51
- if (ch === '"') { inString = !inString; continue; }
52
- if (inString) continue;
53
- if (ch === '{' || ch === '[') depth += 1;
54
- else if (ch === '}' || ch === ']') depth -= 1;
55
- else if (ch === ',' && depth === 1) lastBoundary = i;
56
- }
57
- if (lastBoundary === -1) return null;
58
- try {
59
- const parsed = JSON.parse(`${text.slice(0, lastBoundary)}}`);
60
- return parsed && typeof parsed === 'object' ? parsed : null;
61
- } catch { return null; }
62
- }
63
-
64
50
  export function readHookInput() {
65
- const raw = readFileSync(0, 'utf-8').trim();
66
- if (!raw) return {};
67
- try {
68
- return JSON.parse(raw);
69
- } catch (error) {
70
- const salvaged = salvageTruncatedJson(raw);
71
- // `_wk` prefix: the object is the harness payload merged with our own metadata, and a
72
- // silent key collision here would be worse than the ugly prefix.
73
- if (salvaged) return { ...salvaged, _wkSalvaged: true };
74
- throw error;
75
- }
51
+ return parseHookInput(readFileSync(0, 'utf-8'));
76
52
  }
77
53
 
78
54
  export function writeHookOutput(payload = {}) {
79
- process.stdout.write(JSON.stringify(payload));
55
+ process.stdout.write(stringifyHookOutput(payload));
80
56
  }
81
57
 
82
58
  // Resolve from explicit hook payload or the nearest project-local binding. A legacy
@@ -118,17 +94,11 @@ export function warnIfDefaultVault(input = {}) {
118
94
  // Detecta o agente real que está executando o hook. Claude Code expõe
119
95
  // CLAUDECODE / CLAUDE_CODE_SESSION_ID / CLAUDE_PROJECT_DIR; Codex não.
120
96
  export function detectProvider() {
121
- if (process.env.CLAUDECODE === '1' || process.env.CLAUDE_CODE_SESSION_ID || process.env.CLAUDE_PROJECT_DIR) {
122
- return 'claude';
123
- }
124
- return 'codex';
97
+ return detectProviderFromEnvironment(process.env);
125
98
  }
126
99
 
127
100
  export function providerMeta(provider = detectProvider()) {
128
- if (provider === 'claude') {
129
- return { id: 'claude', label: 'Claude Code', tag: 'claude', source: 'claude-hook' };
130
- }
131
- return { id: 'codex', label: 'Codex', tag: 'codex', source: 'codex-hook' };
101
+ return providerMetaFromProvider(provider);
132
102
  }
133
103
 
134
104
  export function ensureDir(path) {
@@ -807,44 +777,6 @@ export function keysBate(a = '', b = '') {
807
777
  return a === b || a.startsWith(b) || b.startsWith(a);
808
778
  }
809
779
 
810
- export function extractHookPrompt(input = {}) {
811
- const candidates = [
812
- input.prompt,
813
- input.user_prompt,
814
- input.userPrompt,
815
- input.message,
816
- input.input,
817
- ];
818
-
819
- for (const candidate of candidates) {
820
- if (typeof candidate === 'string' && candidate.trim()) return candidate.trim();
821
- }
822
-
823
- if (Array.isArray(input.messages)) {
824
- const text = input.messages
825
- .map((message) => message?.content || message?.text || '')
826
- .filter((item) => typeof item === 'string' && item.trim())
827
- .join('\n')
828
- .trim();
829
- if (text) return text;
830
- }
831
-
832
- return '';
833
- }
834
-
835
- export function isBootstrapPrompt(text = '') {
836
- const clean = String(text || '').trim();
837
- return clean.startsWith('# AGENTS.md instructions')
838
- || clean.startsWith('<environment_context>')
839
- || clean.startsWith('<permissions instructions>')
840
- // Codex injects the available-plugins catalogue as the first userPrompt of turn 1.
841
- // Anchored with startsWith on purpose: matching the bare substring would discard a
842
- // legitimate prompt that merely asks about plugins.
843
- || clean.startsWith('<recommended_plugins>')
844
- || clean.includes('You are Codex, a coding agent')
845
- || clean.startsWith('## Memory');
846
- }
847
-
848
780
  export function summarizePromptForTitle(text = '', fallback = 'session') {
849
781
  const cleaned = redactSecrets(String(text || ''))
850
782
  .replace(/\[@[^\]]+\]\([^)]+\)/g, ' ')
@@ -901,27 +833,6 @@ export function shouldReuseActiveSession(control = {}, now = new Date()) {
901
833
  return now.getTime() - startedMs <= windowMinutes * 60 * 1000;
902
834
  }
903
835
 
904
- function normalizeTranscript(p) {
905
- return String(p || '').replace(/\\/g, '/').toLowerCase();
906
- }
907
-
908
- function transcriptBasename(p) {
909
- const n = normalizeTranscript(p);
910
- const i = n.lastIndexOf('/');
911
- return i === -1 ? n : n.slice(i + 1);
912
- }
913
-
914
- // Mesmo transcript apesar de caixa/separador diferentes (o Claude Code emite o
915
- // slug do projeto ora `c--`, ora `C--`) ou prefixo de path diferente (WSL vs
916
- // Windows). Compara normalizado e, em último caso, pelo basename
917
- // (`<session_id>.jsonl`, globalmente único). Evita rupturas de sessão no restart.
918
- export function transcriptsMatch(a, b) {
919
- if (!a || !b) return false;
920
- if (normalizeTranscript(a) === normalizeTranscript(b)) return true;
921
- const ba = transcriptBasename(a);
922
- return !!ba && ba === transcriptBasename(b);
923
- }
924
-
925
836
  // O `transcript_path` é estável dentro de uma conversa mesmo quando o
926
837
  // SessionStart re-dispara (compactação/resume) com `session_id` novo. Achar a
927
838
  // sessão ativa do mesmo transcript evita criar placeholders `HH-MM-codex`.
@@ -940,17 +851,6 @@ export function findActiveSessionByTranscript(vaultBase, transcriptPath) {
940
851
  return best;
941
852
  }
942
853
 
943
- export function redactSecrets(text) {
944
- if (!text) return '';
945
- return String(text)
946
- .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, '[REDACTED_SECRET]')
947
- .replace(/\b(whsec_[A-Za-z0-9_/-]{8,})\b/g, '[REDACTED_SECRET]')
948
- .replace(/\b(gh[pousr]_[A-Za-z0-9_]{12,})\b/g, '[REDACTED_SECRET]')
949
- .replace(/\b(xox[baprs]-[A-Za-z0-9-]{12,})\b/g, '[REDACTED_SECRET]')
950
- .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY)[A-Z0-9_]*)\s*[:=]\s*["']?[^"'\s]+/gi, '$1=[REDACTED_SECRET]')
951
- .replace(/:\/\/([^:\s/@]+):([^@\s/]+)@/g, '://[REDACTED_SECRET]@');
952
- }
953
-
954
854
  export function truncate(text, max = 240) {
955
855
  const clean = redactSecrets(String(text || '').replace(/\s+/g, ' ').trim());
956
856
  if (clean.length <= max) return clean;
@@ -1,152 +1,37 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { basename } from 'path';
3
- import { detectProvider, readSessionRegistry, transcriptsMatch } from './obsidian-common.mjs';
4
-
5
- function parseLines(path) {
6
- if (!path || !existsSync(path)) return [];
7
- return readFileSync(path, 'utf-8').split('\n').filter(Boolean).map((line) => {
8
- try { return JSON.parse(line); } catch { return null; }
9
- }).filter(Boolean);
10
- }
3
+ import { detectProvider, readSessionRegistry } from './obsidian-common.mjs';
4
+ import {
5
+ inspectTranscriptIdentityContent,
6
+ resolveSessionIdentitySnapshot,
7
+ } from '../packages/integrations/src/session-identity.mjs';
11
8
 
12
9
  export function inspectTranscriptIdentity(transcriptPath) {
13
- const lines = parseLines(transcriptPath);
14
- const codexMeta = lines.find((event) => event.type === 'session_meta')?.payload;
15
- if (codexMeta) {
16
- return {
17
- transcriptProvider: 'openai',
18
- provider: 'codex',
19
- canonicalConversationId: codexMeta.session_id || codexMeta.id || '',
20
- transcriptId: codexMeta.id || basename(transcriptPath, '.jsonl'),
21
- parentConversationId: codexMeta.parent_thread_id || codexMeta.forked_from_id || '',
22
- };
23
- }
24
- const claudeEvent = lines.find((event) => event.sessionId);
25
- if (claudeEvent) {
26
- return {
27
- transcriptProvider: 'anthropic',
28
- provider: 'claude',
29
- canonicalConversationId: claudeEvent.sessionId,
30
- transcriptId: basename(transcriptPath, '.jsonl'),
31
- parentConversationId: '',
32
- };
33
- }
34
- return { transcriptProvider: 'unknown', provider: 'unknown', canonicalConversationId: '', transcriptId: '', parentConversationId: '' };
35
- }
36
-
37
- function compatible(provider, transcriptProvider) {
38
- return (provider === 'codex' && transcriptProvider === 'openai')
39
- || (provider === 'claude' && transcriptProvider === 'anthropic');
40
- }
41
-
42
- function canonicalUuid(value = '') {
43
- const id = String(value || '').trim().toLowerCase();
44
- return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id) ? id : '';
10
+ const content = transcriptPath && existsSync(transcriptPath)
11
+ ? readFileSync(transcriptPath, 'utf-8')
12
+ : '';
13
+ return inspectTranscriptIdentityContent(content, {
14
+ fallbackTranscriptId: transcriptPath ? basename(transcriptPath, '.jsonl') : '',
15
+ });
45
16
  }
46
17
 
47
18
  export function resolveSessionIdentity(vaultBase, input = {}, provider = detectProvider()) {
48
19
  const transcriptPath = input.transcript_path || input.transcriptPath || '';
49
- const inspected = inspectTranscriptIdentity(transcriptPath);
50
- const hookId = input.session_id || input.sessionId || '';
51
- const codexThreadId = canonicalUuid(input.codex_thread_id || input.codexThreadId || process.env.CODEX_THREAD_ID || '');
52
-
53
- // Codex Desktop exposes the stable canonical conversation through
54
- // CODEX_THREAD_ID even when SessionStart/UserPromptSubmit omit transcript_path.
55
- // This is safer than the hook session_id (which may rotate on resume). Once the
56
- // rollout materializes, require both canonical sources to agree.
57
- const transcriptConversationId = canonicalUuid(inspected.canonicalConversationId);
58
- if (provider === 'codex' && transcriptConversationId && codexThreadId
59
- && transcriptConversationId !== codexThreadId) {
60
- return {
61
- state: 'deferred', provider, transcriptPath,
62
- diagnostics: [`CODEX_THREAD_ID diverge do session_id do transcript (${codexThreadId} != ${transcriptConversationId})`],
63
- };
64
- }
65
- if (provider === 'codex' && !inspected.canonicalConversationId && codexThreadId) {
66
- return {
67
- state: 'resolved',
68
- provider,
69
- canonicalConversationId: codexThreadId,
70
- hookSessionId: hookId,
71
- transcriptPath,
72
- transcriptId: transcriptPath ? basename(transcriptPath, '.jsonl') : codexThreadId,
73
- parentConversationId: '',
74
- diagnostics: [],
75
- };
76
- }
77
-
78
- // Claude: input.session_id já é o id canônico e estável da conversa — idêntico
79
- // ao sessionId que cada linha do transcript grava. Numa sessão nova o arquivo
80
- // ainda não materializou em disco quando o hook roda, então inspectTranscriptIdentity
81
- // volta vazio e o gate abaixo adiaria o 1º turno inteiro (SessionStart + 1º prompt
82
- // sem nota; sessão curta nunca registrada). Não adiar: usar o hookId direto.
83
- // Codex NÃO entra aqui de propósito — lá o id do hook no resume é efêmero e ≠ do
84
- // thread canônico, então seguimos exigindo rollout/registry (incidente 2026-07-11,
85
- // contaminação cross-provider de sessão).
86
- if (provider === 'claude' && hookId && !inspected.canonicalConversationId) {
87
- return {
88
- state: 'resolved',
89
- provider,
90
- canonicalConversationId: hookId,
91
- hookSessionId: hookId,
92
- transcriptPath,
93
- transcriptId: transcriptPath ? basename(transcriptPath, '.jsonl') : hookId,
94
- parentConversationId: '',
95
- diagnostics: [],
96
- };
97
- }
98
-
99
- // Codex on Windows can deliver a Stop payload whose transcript_path never arrives (or is
100
- // lost to a truncated JSON, openai/codex#23784). The registry already knows the mapping —
101
- // the lookup below just sat under this gate, unreachable in the one case it solves. The
102
- // comment above says we require "rollout/registry"; the registry half was never wired.
103
- // Requiring the entry's provider to match preserves the cross-provider invariant from the
104
- // 2026-07-11 incident: we are not minting a canonical id, we are finding an ALREADY
105
- // REGISTERED session whose key is the hook's own id. A resume with a fresh id simply
106
- // misses and stays deferred.
107
- if (!transcriptPath && hookId) {
108
- const entry = readSessionRegistry(vaultBase).sessions?.[hookId];
109
- if (entry?.transcript_path && entry.provider === provider) {
110
- return {
111
- state: 'resolved',
112
- provider,
113
- canonicalConversationId: hookId,
114
- hookSessionId: hookId,
115
- transcriptPath: entry.transcript_path,
116
- transcriptId: entry.transcript_id || basename(entry.transcript_path, '.jsonl'),
117
- parentConversationId: '',
118
- diagnostics: ['transcript recuperado do SESSION_REGISTRY'],
119
- };
120
- }
121
- }
122
-
123
- if (!transcriptPath || !inspected.canonicalConversationId) {
124
- return { state: 'deferred', provider, transcriptPath, diagnostics: ['transcript ausente ou sem identidade canônica'] };
125
- }
126
- if (!compatible(provider, inspected.transcriptProvider)) {
127
- return { state: 'deferred', provider, transcriptPath, diagnostics: [`provider ${provider} incompatível com transcript ${inspected.transcriptProvider}`] };
128
- }
129
-
130
- const registry = readSessionRegistry(vaultBase);
131
- const byTranscript = Object.entries(registry.sessions || {}).find(([, entry]) => {
132
- const paths = [...(Array.isArray(entry?.transcript_paths) ? entry.transcript_paths : []), entry?.transcript_path].filter(Boolean);
133
- return paths.some((path) => transcriptsMatch(path, transcriptPath));
134
- });
135
- const canonicalConversationId = byTranscript?.[0] || inspected.canonicalConversationId;
136
- return {
137
- state: 'resolved',
20
+ return resolveSessionIdentitySnapshot({
21
+ input,
138
22
  provider,
139
- canonicalConversationId,
140
- hookSessionId: hookId,
23
+ codexThreadId: process.env.CODEX_THREAD_ID || '',
141
24
  transcriptPath,
142
- transcriptId: inspected.transcriptId,
143
- parentConversationId: inspected.parentConversationId,
144
- diagnostics: [],
145
- };
25
+ inspected: inspectTranscriptIdentity(transcriptPath),
26
+ registry: readSessionRegistry(vaultBase),
27
+ });
146
28
  }
147
29
 
148
30
  export function resolveSessionEntry(vaultBase, input = {}, provider = detectProvider()) {
149
31
  const identity = resolveSessionIdentity(vaultBase, input, provider);
150
32
  if (identity.state !== 'resolved') return { identity, entry: null };
151
- return { identity, entry: readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null };
33
+ return {
34
+ identity,
35
+ entry: readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null,
36
+ };
152
37
  }