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.
- package/CHANGELOG.md +44 -0
- package/README.en.md +19 -6
- package/README.md +19 -6
- package/docs/en/commands/getting-started.md +8 -0
- package/docs/en/commands/memory-migration.md +2 -1
- package/docs/en/commands/memory.md +14 -3
- package/docs/pt-BR/commands/getting-started.md +8 -0
- package/docs/pt-BR/commands/memory-migration.md +2 -1
- package/docs/pt-BR/commands/memory.md +13 -3
- package/hooks/brain-core.mjs +159 -159
- package/hooks/brain-recall.mjs +32 -32
- package/hooks/brain-reindex.mjs +13 -13
- package/hooks/obsidian-common.mjs +24 -124
- package/hooks/session-identity.mjs +21 -136
- package/hooks/session-stop.mjs +18 -384
- package/hooks/token-usage.mjs +12 -56
- package/package.json +13 -4
- package/packages/cli/src/index.mjs +1 -1
- package/packages/integrations/package.json +2 -1
- package/packages/integrations/src/hook-envelope.mjs +103 -0
- package/packages/integrations/src/host-hooks.mjs +80 -0
- package/packages/integrations/src/index.mjs +6 -0
- package/packages/integrations/src/prompt-content.mjs +20 -0
- package/packages/integrations/src/session-identity.mjs +171 -0
- package/packages/integrations/src/transcript-usage.mjs +53 -0
- package/packages/integrations/src/transcripts.mjs +423 -0
- package/packages/vault/src/memory-schema.mjs +25 -0
- package/packages/vault/src/memory-store.mjs +22 -2
- package/src/memory.mjs +177 -17
- package/src/taxonomy.mjs +24 -78
package/hooks/brain-recall.mjs
CHANGED
|
@@ -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
|
+
}
|
package/hooks/brain-reindex.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
122
|
-
return 'claude';
|
|
123
|
-
}
|
|
124
|
-
return 'codex';
|
|
97
|
+
return detectProviderFromEnvironment(process.env);
|
|
125
98
|
}
|
|
126
99
|
|
|
127
100
|
export function providerMeta(provider = detectProvider()) {
|
|
128
|
-
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
140
|
-
hookSessionId: hookId,
|
|
23
|
+
codexThreadId: process.env.CODEX_THREAD_ID || '',
|
|
141
24
|
transcriptPath,
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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 {
|
|
33
|
+
return {
|
|
34
|
+
identity,
|
|
35
|
+
entry: readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null,
|
|
36
|
+
};
|
|
152
37
|
}
|