wendkeep 0.37.0 → 0.38.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.
@@ -0,0 +1,97 @@
1
+ import { existsSync, readFileSync } from 'fs';
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
+ }
11
+
12
+ 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
+ export function resolveSessionIdentity(vaultBase, input = {}, provider = detectProvider()) {
43
+ const transcriptPath = input.transcript_path || input.transcriptPath || '';
44
+ const inspected = inspectTranscriptIdentity(transcriptPath);
45
+ const hookId = input.session_id || input.sessionId || '';
46
+
47
+ // Claude: input.session_id já é o id canônico e estável da conversa — idêntico
48
+ // ao sessionId que cada linha do transcript grava. Numa sessão nova o arquivo
49
+ // ainda não materializou em disco quando o hook roda, então inspectTranscriptIdentity
50
+ // volta vazio e o gate abaixo adiaria o 1º turno inteiro (SessionStart + 1º prompt
51
+ // sem nota; sessão curta nunca registrada). Não adiar: usar o hookId direto.
52
+ // Codex NÃO entra aqui de propósito — lá o id do hook no resume é efêmero e ≠ do
53
+ // thread canônico, então seguimos exigindo rollout/registry (incidente 2026-07-11,
54
+ // contaminação cross-provider de sessão).
55
+ if (provider === 'claude' && hookId && !inspected.canonicalConversationId) {
56
+ return {
57
+ state: 'resolved',
58
+ provider,
59
+ canonicalConversationId: hookId,
60
+ hookSessionId: hookId,
61
+ transcriptPath,
62
+ transcriptId: transcriptPath ? basename(transcriptPath, '.jsonl') : hookId,
63
+ parentConversationId: '',
64
+ diagnostics: [],
65
+ };
66
+ }
67
+
68
+ if (!transcriptPath || !inspected.canonicalConversationId) {
69
+ return { state: 'deferred', provider, transcriptPath, diagnostics: ['transcript ausente ou sem identidade canônica'] };
70
+ }
71
+ if (!compatible(provider, inspected.transcriptProvider)) {
72
+ return { state: 'deferred', provider, transcriptPath, diagnostics: [`provider ${provider} incompatível com transcript ${inspected.transcriptProvider}`] };
73
+ }
74
+
75
+ const registry = readSessionRegistry(vaultBase);
76
+ const byTranscript = Object.entries(registry.sessions || {}).find(([, entry]) => {
77
+ const paths = [...(Array.isArray(entry?.transcript_paths) ? entry.transcript_paths : []), entry?.transcript_path].filter(Boolean);
78
+ return paths.some((path) => transcriptsMatch(path, transcriptPath));
79
+ });
80
+ const canonicalConversationId = byTranscript?.[0] || inspected.canonicalConversationId;
81
+ return {
82
+ state: 'resolved',
83
+ provider,
84
+ canonicalConversationId,
85
+ hookSessionId: hookId,
86
+ transcriptPath,
87
+ transcriptId: inspected.transcriptId,
88
+ parentConversationId: inspected.parentConversationId,
89
+ diagnostics: [],
90
+ };
91
+ }
92
+
93
+ export function resolveSessionEntry(vaultBase, input = {}, provider = detectProvider()) {
94
+ const identity = resolveSessionIdentity(vaultBase, input, provider);
95
+ if (identity.state !== 'resolved') return { identity, entry: null };
96
+ return { identity, entry: readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null };
97
+ }
@@ -2,6 +2,7 @@
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { collectSessionUsage } from './token-usage.mjs';
4
4
  import { collectSubagentUsage, sessionDirFromTranscript } from './subagent-usage.mjs';
5
+ import { inspectTranscriptIdentity } from './session-identity.mjs';
5
6
 
6
7
  const HEADING = '## Agentes, tokens e custos';
7
8
  const LEGACY_HEADINGS = ['## Uso de tokens e custos', '## Subagents & Workflows'];
@@ -153,9 +154,22 @@ export function buildSessionObservability({ sessionContent, transcriptPath }) {
153
154
  return { snapshot, content: upsertObservabilitySection(content, renderSessionObservability(snapshot)) };
154
155
  }
155
156
 
156
- export function updateSessionObservability({ sessionPath, transcriptPath }) {
157
+ export function updateSessionObservability({ sessionPath, transcriptPath, caller = 'unknown', canonicalConversationId = '' }) {
157
158
  if (!sessionPath || !existsSync(sessionPath)) return null;
158
- const result = buildSessionObservability({ sessionContent: readFileSync(sessionPath, 'utf8'), transcriptPath });
159
+ const sessionContent = readFileSync(sessionPath, 'utf8');
160
+ const noteProvider = sessionContent.match(/^provider:\s*"?([^"\n]+)"?/m)?.[1]?.trim() || 'unknown';
161
+ const identity = inspectTranscriptIdentity(transcriptPath);
162
+ if ((noteProvider === 'codex' && identity.transcriptProvider !== 'openai')
163
+ || (noteProvider === 'claude' && identity.transcriptProvider !== 'anthropic')) {
164
+ throw new Error(`observability provider mismatch: note=${noteProvider}, transcript=${identity.transcriptProvider}`);
165
+ }
166
+ let annotated = setFrontmatterField(sessionContent, 'observability_caller', `"${caller}"`);
167
+ annotated = setFrontmatterField(annotated, 'observability_session_id', `"${canonicalConversationId || identity.canonicalConversationId || ''}"`);
168
+ annotated = setFrontmatterField(annotated, 'observability_transcript_id', `"${identity.transcriptId || ''}"`);
169
+ if (!/^observability_updated_at:/m.test(annotated)) {
170
+ annotated = setFrontmatterField(annotated, 'observability_updated_at', `"${new Date().toISOString()}"`);
171
+ }
172
+ const result = buildSessionObservability({ sessionContent: annotated, transcriptPath });
159
173
  if (!result) return null;
160
174
  writeFileSync(sessionPath, result.content, 'utf8');
161
175
  return result.snapshot;