wendkeep 0.37.0 → 0.38.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.
- package/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/README.pt-BR.md +2 -0
- package/bin/wendkeep.mjs +7 -1
- package/hooks/brain-inject.mjs +13 -6
- package/hooks/change-context.mjs +8 -4
- package/hooks/decision-capture.mjs +4 -2
- package/hooks/obsidian-common.mjs +116 -34
- package/hooks/plan-capture.mjs +8 -7
- package/hooks/session-ensure.mjs +36 -15
- package/hooks/session-identity.mjs +75 -0
- package/hooks/session-observability.mjs +16 -2
- package/hooks/session-start.mjs +41 -18
- package/hooks/session-stop.mjs +21 -9
- package/hooks/subagent-stop.mjs +7 -5
- package/hooks/task-log.mjs +5 -4
- package/hooks/token-usage.mjs +1 -1
- package/hooks/vault-health.mjs +12 -9
- package/package.json +1 -1
- package/src/change.mjs +14 -2
- package/src/rebuild-costs.mjs +5 -5
- package/src/session.mjs +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to **wendkeep** are documented here. Format based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project follows
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.38.0] — 2026-07-12
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Registry multi-sessão v2: `SESSION_REGISTRY.json` passa a ser a autoridade por conversa, enquanto `CURRENT_SESSION.md` vira um dashboard compatível com todas as sessões ativas.
|
|
12
|
+
- `wendkeep session list|show|use` e `wendkeep change bind <slug> --session <id>` permitem inspecionar sessões concorrentes e transferir explicitamente o vínculo de uma change.
|
|
13
|
+
- Metadados de auditoria da observabilidade registram caller, conversa canônica, transcript e instante da atualização.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Roteamento cross-provider agora é fail-closed: Codex usa `session_meta.payload.session_id`, Claude usa o `sessionId` do transcript, e writers não recorrem ao foco global quando a identidade é ambígua.
|
|
18
|
+
- Atualizações concorrentes do registry usam lock, releitura e rename atômico; patches vazios não apagam transcript ou metadados válidos.
|
|
19
|
+
- `cost rebuild` reporta entradas órfãs, ausentes ou incompatíveis como estado não verde em vez de omiti-las silenciosamente.
|
|
20
|
+
|
|
7
21
|
## [0.37.0] — 2026-07-11
|
|
8
22
|
|
|
9
23
|
### Added
|
package/README.md
CHANGED
|
@@ -119,6 +119,8 @@ No re‑copying, no snapshot to re‑sync — the package is the single source o
|
|
|
119
119
|
| `wendkeep cost [--since d]` | Aggregate AI-coding spend across the vault's sessions — total, by model, by day (`--json`). |
|
|
120
120
|
| `wendkeep import [opts]` | **Retroactive memory** — backfill past **Claude + Codex** sessions into the vault (deduped by `session_id`). `--source all\|claude\|codex` / `--from <dir>` / `--codex-from <dir>` / `--since d` / `--limit n` / `--dry-run` / `--json`. |
|
|
121
121
|
| `wendkeep cost rebuild [opts]` | Recalcula custos históricos do transcript principal e subagents usando `SESSION_REGISTRY`. Dry-run por padrão; `--apply` atualiza notas e grava `.brain/COST_REBUILD.json`. Aceita `--session`, `--limit` e `--json`. |
|
|
122
|
+
| `wendkeep session list\|show\|use` | Lista o registry multi-sessão, mostra uma conversa ou muda somente o foco humano de `CURRENT_SESSION.md`. |
|
|
123
|
+
| `wendkeep change bind <slug> --session <id>` | Vincula ou transfere uma change para uma conversa canônica sem esconder as demais pendências. |
|
|
122
124
|
|
|
123
125
|
Session notes use one live `## Agentes, tokens e custos` snapshot. Main-agent and subagent hooks recompose it atomically, with costs, token dimensions, reasoning tokens and effort per model/source.
|
|
124
126
|
| `wendkeep lesson add "t" "l"` | Record a project-local lesson (injected at the next SessionStart). |
|
package/README.pt-BR.md
CHANGED
|
@@ -116,6 +116,8 @@ Sem recopiar, sem snapshot pra re‑sincronizar — o pacote é a única fonte d
|
|
|
116
116
|
| `wendkeep sensors <sub>` | `list` / `add <id> "<comando>"` — vê/edita `wendkeep.sensors.json` (JSON Schema incluso). |
|
|
117
117
|
| `wendkeep cost [opts]` | Agrega o gasto de IA nas sessões do cofre — total, por modelo, por dia · `--top [N]` · `--trend [day\|week\|month]` (+ projeção) · `--write` (gera `00-Custo.md`) · `--json`. |
|
|
118
118
|
| `wendkeep cost rebuild [opts]` | Reconstrói custos históricos do transcript principal e subagents via `SESSION_REGISTRY`. Dry-run por padrão; `--apply` grava notas e `.brain/COST_REBUILD.json`. |
|
|
119
|
+
| `wendkeep session list\|show\|use` | Lista o registry multi-sessão, mostra uma conversa ou muda somente o foco humano de `CURRENT_SESSION.md`. |
|
|
120
|
+
| `wendkeep change bind <slug> --session <id>` | Vincula ou transfere uma change para uma conversa canônica sem esconder as demais pendências. |
|
|
119
121
|
|
|
120
122
|
As notas de sessão usam um único snapshot vivo `## Agentes, tokens e custos`. Os hooks do agente principal e dos subagents recompõem o bloco atomicamente, incluindo custo, dimensões de tokens, reasoning e effort por modelo/origem.
|
|
121
123
|
| `wendkeep stats [--vault P]` | Uma linha compartilhável: sessões · prompts · gasto · período · modelos (`--json`). |
|
package/bin/wendkeep.mjs
CHANGED
|
@@ -43,9 +43,10 @@ Usage:
|
|
|
43
43
|
agent's JSON on stdin. Names: ${RUNNABLE_HOOKS.join(', ')}.
|
|
44
44
|
|
|
45
45
|
wendkeep doctor [--vault P] Run a vault health check.
|
|
46
|
-
wendkeep change <sub> Change lifecycle: new [--simple] | use | continue | list | show |
|
|
46
|
+
wendkeep change <sub> Change lifecycle: new [--simple] | use | bind <slug> --session <id> | continue | list | show |
|
|
47
47
|
status | done <id> | undone <id> | diff | archive [--force] | abandon.
|
|
48
48
|
archive exige verdict (rode verify --deep); abandon descarta sem ADR.
|
|
49
|
+
wendkeep session <sub> Session registry: list | show <id> | use <id>.
|
|
49
50
|
wendkeep spec <sub> Specs: list | show | effective [--change] [--json] | migrate | rebase.
|
|
50
51
|
wendkeep sensors <sub> list | add <id> "<command>" [--severity --type --report].
|
|
51
52
|
wendkeep cost [opts] Aggregate AI-coding spend across the vault's sessions.
|
|
@@ -132,6 +133,11 @@ async function main() {
|
|
|
132
133
|
runChange(rest);
|
|
133
134
|
break;
|
|
134
135
|
}
|
|
136
|
+
case 'session': {
|
|
137
|
+
const { runSession } = await import('../src/session.mjs');
|
|
138
|
+
runSession(rest);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
135
141
|
case 'verify': {
|
|
136
142
|
const { runVerify } = await import('../src/verify.mjs');
|
|
137
143
|
runVerify(rest);
|
package/hooks/brain-inject.mjs
CHANGED
|
@@ -9,7 +9,8 @@ import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.
|
|
|
9
9
|
import { brainDir } from './brain-core.mjs';
|
|
10
10
|
import { buildActiveChangeInjection, changeCtxState, writeSentinel } from './change-core.mjs';
|
|
11
11
|
import { buildLessonsInjection } from './lessons-core.mjs';
|
|
12
|
-
import { getLocale } from './locale.mjs';
|
|
12
|
+
import { getLocale } from './locale.mjs';
|
|
13
|
+
import { resolveSessionEntry } from './session-identity.mjs';
|
|
13
14
|
|
|
14
15
|
// The process ROUTER — the enforcement layer. The wk-* skills are passive files; without a
|
|
15
16
|
// standing instruction the model plans in chat, leaves the change scaffold raw and forces the
|
|
@@ -42,7 +43,7 @@ function processRouter(localeId) {
|
|
|
42
43
|
|
|
43
44
|
const MAX_LINES = 45; // CORE ≤25 + DIGEST ≤15 + folga; salvaguarda se o CORE crescer à mão
|
|
44
45
|
|
|
45
|
-
export function buildInjection(vaultBase) {
|
|
46
|
+
export function buildInjection(vaultBase, input = {}) {
|
|
46
47
|
const dir = brainDir(vaultBase);
|
|
47
48
|
const read = (name) => {
|
|
48
49
|
try { return readFileSync(join(dir, name), 'utf8').trim(); } catch { return ''; }
|
|
@@ -58,8 +59,12 @@ export function buildInjection(vaultBase) {
|
|
|
58
59
|
const brain = ['<brain_memory>', ...lines, pointer, '</brain_memory>'].join('\n');
|
|
59
60
|
const router = processRouter(getLocale(vaultBase).id);
|
|
60
61
|
const change = buildActiveChangeInjection(vaultBase);
|
|
61
|
-
const lessons = buildLessonsInjection(vaultBase);
|
|
62
|
-
|
|
62
|
+
const lessons = buildLessonsInjection(vaultBase);
|
|
63
|
+
const { identity, entry } = resolveSessionEntry(vaultBase, input);
|
|
64
|
+
const focus = identity.state === 'resolved' && entry?.change_slug
|
|
65
|
+
? `<session_change>Change vinculada a esta sessão: ${entry.change_slug}. Este vínculo prevalece para writes automáticos; todas as pendências continuam visíveis acima.</session_change>`
|
|
66
|
+
: '';
|
|
67
|
+
return [brain, router, focus, change, lessons].filter(Boolean).join('\n');
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
@@ -69,14 +74,16 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
|
|
69
74
|
writeHookOutput({
|
|
70
75
|
hookSpecificOutput: {
|
|
71
76
|
hookEventName: 'SessionStart',
|
|
72
|
-
additionalContext: buildInjection(vaultBase),
|
|
77
|
+
additionalContext: buildInjection(vaultBase, input),
|
|
73
78
|
},
|
|
74
79
|
});
|
|
75
80
|
// Sentinela do change-context: o backlog completo acabou de ser injetado aqui, então o hook
|
|
76
81
|
// UserPromptSubmit não precisa re-pingar no 1º prompt. Bônus — nunca derruba a injeção.
|
|
77
82
|
try {
|
|
78
83
|
const st = changeCtxState(vaultBase);
|
|
79
|
-
|
|
84
|
+
const { identity } = resolveSessionEntry(vaultBase, input);
|
|
85
|
+
const sid = identity.state === 'resolved' ? identity.canonicalConversationId : (input.session_id || input.sessionId || '');
|
|
86
|
+
if (st) writeSentinel(vaultBase, 'ctx', sid, st.hash);
|
|
80
87
|
} catch { /* sentinela é bônus */ }
|
|
81
88
|
} catch (error) {
|
|
82
89
|
process.stderr.write(`[brain] inject falhou: ${error.message}\n`);
|
package/hooks/change-context.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
9
|
import { getVaultBase, readHookInput, writeHookOutput } from './obsidian-common.mjs';
|
|
10
10
|
import { changeCtxState, readSentinel, renderOpenChanges, writeSentinel } from './change-core.mjs';
|
|
11
|
+
import { resolveSessionEntry } from './session-identity.mjs';
|
|
11
12
|
|
|
12
13
|
// Conservador de propósito: verbos de tarefa comuns (pt+en) + tamanho mínimo. Falso-negativo
|
|
13
14
|
// custa só o nudge; falso-positivo em pergunta curta viraria ruído.
|
|
@@ -19,12 +20,13 @@ export function looksLikeTask(prompt) {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
// Retorna { context, hash? } quando há algo a injetar; null = silêncio.
|
|
22
|
-
export function buildChangePing(vaultBase, sessionId, prompt = '') {
|
|
23
|
+
export function buildChangePing(vaultBase, sessionId, prompt = '', changeSlug = '') {
|
|
23
24
|
const st = changeCtxState(vaultBase);
|
|
24
25
|
if (st) {
|
|
25
26
|
if (readSentinel(vaultBase, 'ctx', sessionId) === st.hash) return null;
|
|
26
27
|
writeSentinel(vaultBase, 'ctx', sessionId, st.hash);
|
|
27
|
-
|
|
28
|
+
const focus = changeSlug ? `\n<session_change>Change vinculada a esta sessão: ${changeSlug}.</session_change>` : '';
|
|
29
|
+
return { context: `${renderOpenChanges(st, { tag: 'open_changes_ping' })}${focus}`, hash: st.hash };
|
|
28
30
|
}
|
|
29
31
|
// Sem changes abertas: gate de skill para prompt-tarefa, 1x por sessão.
|
|
30
32
|
if (!looksLikeTask(prompt)) return null;
|
|
@@ -42,8 +44,10 @@ export function buildChangePing(vaultBase, sessionId, prompt = '') {
|
|
|
42
44
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
43
45
|
try {
|
|
44
46
|
const input = readHookInput();
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
+
const vaultBase = getVaultBase(input);
|
|
48
|
+
const { identity, entry } = resolveSessionEntry(vaultBase, input);
|
|
49
|
+
const sid = identity.state === 'resolved' ? identity.canonicalConversationId : (input.session_id || input.sessionId || '');
|
|
50
|
+
const ping = buildChangePing(vaultBase, sid, input.prompt || '', entry?.change_slug || '');
|
|
47
51
|
if (!ping) { writeHookOutput({}); }
|
|
48
52
|
else writeHookOutput({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ping.context } });
|
|
49
53
|
} catch {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
wikilinkFromRel, readControl, toVaultRelative, getNextAdrNumber, derivedContentKey,
|
|
13
13
|
} from './obsidian-common.mjs';
|
|
14
14
|
import { getLocale } from './locale.mjs';
|
|
15
|
+
import { resolveSessionEntry } from './session-identity.mjs';
|
|
15
16
|
|
|
16
17
|
// Decision notes follow the ADR naming convention: ADR-NNNN-<slug>, NNNN a 4-digit sequential
|
|
17
18
|
// number assigned in the order decisions are made (getNextAdrNumber scans the whole 04-Decisões).
|
|
@@ -168,8 +169,9 @@ export function captureDecision(vaultBase, input) {
|
|
|
168
169
|
const dateStr = formatDate(now);
|
|
169
170
|
const provider = providerMeta(input.provider);
|
|
170
171
|
|
|
171
|
-
const
|
|
172
|
-
|
|
172
|
+
const { identity, entry } = resolveSessionEntry(vaultBase, input, provider.id);
|
|
173
|
+
if (identity.state !== 'resolved') return null;
|
|
174
|
+
const sessionRel = entry?.session_file || '';
|
|
173
175
|
|
|
174
176
|
const dir = join(vaultBase, monthFolderRelFromDateStr(loc.folders.decisions, dateStr, vaultBase));
|
|
175
177
|
ensureDir(dir);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
|
|
3
3
|
import { basename, dirname, join, relative } from 'path';
|
|
4
4
|
import { getLocale } from './locale.mjs';
|
|
5
5
|
|
|
@@ -189,7 +189,7 @@ export function readControl(vaultBase) {
|
|
|
189
189
|
return data;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
export function writeControl(vaultBase, data) {
|
|
192
|
+
export function writeControl(vaultBase, data) {
|
|
193
193
|
const path = controlPath(vaultBase);
|
|
194
194
|
ensureDir(dirname(path));
|
|
195
195
|
|
|
@@ -201,8 +201,16 @@ export function writeControl(vaultBase, data) {
|
|
|
201
201
|
const sessionId = data.session_id || '';
|
|
202
202
|
const lastLoggedTurnId = data.last_logged_turn_id || '';
|
|
203
203
|
|
|
204
|
-
const
|
|
205
|
-
|
|
204
|
+
const registry = readSessionRegistry(vaultBase);
|
|
205
|
+
const active = Object.entries(registry.sessions || {})
|
|
206
|
+
.filter(([, item]) => item?.status === 'active' && item.session_file)
|
|
207
|
+
.sort((a, b) => String(b[1].last_seen || b[1].updated_at || '').localeCompare(String(a[1].last_seen || a[1].updated_at || '')));
|
|
208
|
+
const activeRows = active.length
|
|
209
|
+
? active.map(([id, item]) => `| ${id} | ${item.provider || 'unknown'} | ${item.session_file} | ${item.change_slug || '-'} | ${item.last_seen || item.updated_at || '-'} |`).join('\n')
|
|
210
|
+
: '| - | - | nenhuma | - | - |';
|
|
211
|
+
|
|
212
|
+
const content = `---
|
|
213
|
+
status: "${status}"
|
|
206
214
|
session_file: "${sessionFile}"
|
|
207
215
|
last_session_file: "${lastSessionFile}"
|
|
208
216
|
started_at: "${startedAt}"
|
|
@@ -211,13 +219,21 @@ session_id: "${sessionId}"
|
|
|
211
219
|
last_logged_turn_id: "${lastLoggedTurnId}"
|
|
212
220
|
---
|
|
213
221
|
|
|
214
|
-
# CURRENT_SESSION
|
|
222
|
+
# CURRENT_SESSION
|
|
223
|
+
|
|
224
|
+
> Visão gerada pelo WendKeep. A autoridade de roteamento é .brain/SESSION_REGISTRY.json; hooks não usam este foco como fallback de escrita.
|
|
215
225
|
|
|
216
226
|
- **Status:** ${status}
|
|
217
227
|
- **Sessão ativa:** ${sessionFile || 'nenhuma'}
|
|
218
228
|
- **Última sessão encerrada:** ${lastSessionFile || 'nenhuma'}
|
|
219
229
|
- **Início:** ${startedAt || 'n/a'}
|
|
220
|
-
- **Fim:** ${endedAt || 'n/a'}
|
|
230
|
+
- **Fim:** ${endedAt || 'n/a'}
|
|
231
|
+
|
|
232
|
+
## Sessões ativas (${active.length})
|
|
233
|
+
|
|
234
|
+
| Conversa | Provider | Sessão | Change vinculada | Último sinal |
|
|
235
|
+
|---|---|---|---|---|
|
|
236
|
+
${activeRows}
|
|
221
237
|
|
|
222
238
|
Regra crítica: sempre anexar conteúdo à sessão ativa. Nunca sobrescrever o histórico de iterações.
|
|
223
239
|
`;
|
|
@@ -225,22 +241,22 @@ Regra crítica: sempre anexar conteúdo à sessão ativa. Nunca sobrescrever o h
|
|
|
225
241
|
writeFileSync(path, content, 'utf-8');
|
|
226
242
|
}
|
|
227
243
|
|
|
228
|
-
export function readSessionRegistry(vaultBase) {
|
|
244
|
+
export function readSessionRegistry(vaultBase) {
|
|
229
245
|
const path = registryPath(vaultBase);
|
|
230
|
-
if (!existsSync(path)) return { version:
|
|
246
|
+
if (!existsSync(path)) return { version: 2, sessions: {} };
|
|
231
247
|
|
|
232
248
|
try {
|
|
233
249
|
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
234
250
|
return {
|
|
235
|
-
version: parsed.version || 1,
|
|
251
|
+
version: Math.max(2, parsed.version || 1),
|
|
236
252
|
sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
|
|
237
253
|
};
|
|
238
254
|
} catch {
|
|
239
|
-
return { version:
|
|
255
|
+
return { version: 2, sessions: {} };
|
|
240
256
|
}
|
|
241
257
|
}
|
|
242
258
|
|
|
243
|
-
export function writeSessionRegistry(vaultBase, registry) {
|
|
259
|
+
export function writeSessionRegistry(vaultBase, registry) {
|
|
244
260
|
const path = registryPath(vaultBase);
|
|
245
261
|
ensureDir(dirname(path));
|
|
246
262
|
// Escrita atômica: grava em tmp e renomeia (rename é atômico no mesmo volume),
|
|
@@ -248,22 +264,86 @@ export function writeSessionRegistry(vaultBase, registry) {
|
|
|
248
264
|
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
249
265
|
writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf-8');
|
|
250
266
|
renameSync(tmp, path);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function registryLockPath(vaultBase) {
|
|
270
|
+
return `${registryPath(vaultBase)}.lock`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function waitBriefly(ms) {
|
|
274
|
+
const signal = new Int32Array(new SharedArrayBuffer(4));
|
|
275
|
+
Atomics.wait(signal, 0, 0, ms);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function mutateSessionRegistry(vaultBase, mutator, { timeoutMs = 2000 } = {}) {
|
|
279
|
+
const lock = registryLockPath(vaultBase);
|
|
280
|
+
ensureDir(dirname(lock));
|
|
281
|
+
const deadline = Date.now() + timeoutMs;
|
|
282
|
+
while (true) {
|
|
283
|
+
try {
|
|
284
|
+
mkdirSync(lock);
|
|
285
|
+
break;
|
|
286
|
+
} catch (error) {
|
|
287
|
+
if (error?.code === 'EEXIST') {
|
|
288
|
+
try {
|
|
289
|
+
if (Date.now() - statSync(lock).mtimeMs > 10_000) {
|
|
290
|
+
rmSync(lock, { recursive: true, force: true });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
} catch { /* outro processo pode ter liberado o lock */ }
|
|
294
|
+
}
|
|
295
|
+
if (error?.code !== 'EEXIST' || Date.now() >= deadline) {
|
|
296
|
+
throw new Error(`SESSION_REGISTRY lock indisponível: ${error.message}`);
|
|
297
|
+
}
|
|
298
|
+
waitBriefly(10);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const registry = readSessionRegistry(vaultBase);
|
|
304
|
+
registry.version = 2;
|
|
305
|
+
const result = mutator(registry);
|
|
306
|
+
writeSessionRegistry(vaultBase, registry);
|
|
307
|
+
return result;
|
|
308
|
+
} finally {
|
|
309
|
+
rmSync(lock, { recursive: true, force: true });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function meaningfulPatch(patch = {}) {
|
|
314
|
+
const protectedNonEmpty = new Set(['session_file', 'transcript_path', 'transcript_id', 'provider', 'started_at', 'change_slug']);
|
|
315
|
+
return Object.fromEntries(Object.entries(patch).filter(([key, value]) => {
|
|
316
|
+
if (value === undefined || value === null) return false;
|
|
317
|
+
if (value === '' && protectedNonEmpty.has(key)) return false;
|
|
318
|
+
return true;
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function upsertSessionRegistry(vaultBase, sessionId, patch) {
|
|
323
|
+
if (!sessionId) return null;
|
|
324
|
+
const clean = meaningfulPatch(patch);
|
|
325
|
+
const next = mutateSessionRegistry(vaultBase, (registry) => {
|
|
326
|
+
const current = registry.sessions[sessionId] || {};
|
|
327
|
+
const transcriptPaths = [...new Set([
|
|
328
|
+
...(Array.isArray(current.transcript_paths) ? current.transcript_paths : []),
|
|
329
|
+
current.transcript_path,
|
|
330
|
+
...(Array.isArray(clean.transcript_paths) ? clean.transcript_paths : []),
|
|
331
|
+
clean.transcript_path,
|
|
332
|
+
].filter(Boolean))];
|
|
333
|
+
const value = {
|
|
334
|
+
...current,
|
|
335
|
+
...clean,
|
|
336
|
+
...(transcriptPaths.length ? { transcript_paths: transcriptPaths, transcript_path: clean.transcript_path || current.transcript_path || transcriptPaths.at(-1) } : {}),
|
|
337
|
+
last_seen: clean.last_seen || clean.updated_at || formatLocalIso(new Date()),
|
|
338
|
+
updated_at: clean.updated_at || formatLocalIso(new Date()),
|
|
339
|
+
};
|
|
340
|
+
registry.sessions[sessionId] = value;
|
|
341
|
+
return value;
|
|
342
|
+
});
|
|
343
|
+
const focus = readControl(vaultBase);
|
|
344
|
+
writeControl(vaultBase, focus);
|
|
345
|
+
return next;
|
|
346
|
+
}
|
|
267
347
|
|
|
268
348
|
// Sessões sem evento de fim (janela fechada, crash, agente sem SessionEnd) ficam
|
|
269
349
|
// `active` para sempre. Após este limite ocioso, considera-se a sessão encerrada.
|
|
@@ -316,11 +396,12 @@ export function pruneRegistry(registry, nowMs, { keepDone = REGISTRY_KEEP_DONE,
|
|
|
316
396
|
|
|
317
397
|
// Wrapper de IO: varre as ociosas, poda o registry, grava e fecha a NOTA `.md` de cada
|
|
318
398
|
// sessão encerrada (mantém vault e registry alinhados). Devolve quantas fechou.
|
|
319
|
-
export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs = SESSION_IDLE_CLOSE_MS, excludeTranscriptPath = '') {
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
399
|
+
export function sweepStaleSessionsFile(vaultBase, now = new Date(), maxIdleMs = SESSION_IDLE_CLOSE_MS, excludeTranscriptPath = '') {
|
|
400
|
+
const closed = mutateSessionRegistry(vaultBase, (registry) => {
|
|
401
|
+
const result = sweepStaleSessions(registry, now.getTime(), maxIdleMs, excludeTranscriptPath);
|
|
402
|
+
pruneRegistry(registry, now.getTime());
|
|
403
|
+
return result;
|
|
404
|
+
});
|
|
324
405
|
for (const { session_file, ended_at } of closed) {
|
|
325
406
|
try { closeSessionNoteFile(vaultBase, session_file, ended_at); } catch { /* nunca derruba o sweep */ }
|
|
326
407
|
}
|
|
@@ -512,13 +593,14 @@ export function transcriptsMatch(a, b) {
|
|
|
512
593
|
// O `transcript_path` é estável dentro de uma conversa mesmo quando o
|
|
513
594
|
// SessionStart re-dispara (compactação/resume) com `session_id` novo. Achar a
|
|
514
595
|
// sessão ativa do mesmo transcript evita criar placeholders `HH-MM-codex`.
|
|
515
|
-
export function findActiveSessionByTranscript(vaultBase, transcriptPath) {
|
|
596
|
+
export function findActiveSessionByTranscript(vaultBase, transcriptPath) {
|
|
516
597
|
if (!transcriptPath) return null;
|
|
517
598
|
const registry = readSessionRegistry(vaultBase);
|
|
518
599
|
let best = null;
|
|
519
600
|
for (const [sessionId, item] of Object.entries(registry.sessions || {})) {
|
|
520
601
|
if (!item || item.status !== 'active' || !item.session_file) continue;
|
|
521
|
-
|
|
602
|
+
const paths = [...(Array.isArray(item.transcript_paths) ? item.transcript_paths : []), item.transcript_path].filter(Boolean);
|
|
603
|
+
if (!paths.some((path) => transcriptsMatch(path, transcriptPath))) continue;
|
|
522
604
|
if (!best || String(item.started_at || '') > String(best.started_at || '')) {
|
|
523
605
|
best = { sessionId, session_file: item.session_file, started_at: item.started_at || '' };
|
|
524
606
|
}
|
package/hooks/plan-capture.mjs
CHANGED
|
@@ -8,17 +8,17 @@ import { createHash } from 'node:crypto';
|
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { pathToFileURL } from 'node:url';
|
|
10
10
|
import {
|
|
11
|
-
findActiveSessionByTranscript,
|
|
12
11
|
formatDate,
|
|
13
12
|
getVaultBase,
|
|
14
|
-
readControl,
|
|
15
13
|
readHookInput,
|
|
16
14
|
slugify,
|
|
15
|
+
upsertSessionRegistry,
|
|
17
16
|
wikilinkFromRel,
|
|
18
17
|
writeHookOutput,
|
|
19
18
|
} from './obsidian-common.mjs';
|
|
20
19
|
import { activeChange, newChange } from './change-core.mjs';
|
|
21
20
|
import { getLocale } from './locale.mjs';
|
|
21
|
+
import { resolveSessionEntry } from './session-identity.mjs';
|
|
22
22
|
|
|
23
23
|
// O plano aprovado chega por um de três canais, conforme a versão do Claude Code:
|
|
24
24
|
// tool_input.plan (legado), o bloco "## Approved Plan" do tool_response, ou o arquivo de plano
|
|
@@ -94,15 +94,15 @@ export function capturePlan(vaultBase, input) {
|
|
|
94
94
|
const en = loc.id === 'en';
|
|
95
95
|
const dateStr = formatDate(new Date());
|
|
96
96
|
const rawNote = `---\ntype: plan\ndate: ${dateStr}\ntags:\n - plano\n---\n\n${plan.trim()}\n`;
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|| '';
|
|
97
|
+
const { identity, entry } = resolveSessionEntry(vaultBase, input, 'claude');
|
|
98
|
+
if (identity.state !== 'resolved') return null;
|
|
99
|
+
const sessionRel = entry?.session_file || '';
|
|
101
100
|
|
|
102
|
-
const active = activeChange(vaultBase);
|
|
101
|
+
const active = entry?.change_slug || activeChange(vaultBase);
|
|
103
102
|
if (active) {
|
|
104
103
|
const dir = join(vaultBase, loc.folders.changes, active);
|
|
105
104
|
persistApprovedPlan(dir, active, rawNote, plan, dateStr);
|
|
105
|
+
upsertSessionRegistry(vaultBase, identity.canonicalConversationId, { change_slug: active });
|
|
106
106
|
return {
|
|
107
107
|
slug: active, created: false,
|
|
108
108
|
context: `<plan_captured>\n${en
|
|
@@ -113,6 +113,7 @@ export function capturePlan(vaultBase, input) {
|
|
|
113
113
|
|
|
114
114
|
const slug = planSlug(plan);
|
|
115
115
|
newChange(vaultBase, slug, { dateStr, sessionRel });
|
|
116
|
+
upsertSessionRegistry(vaultBase, identity.canonicalConversationId, { change_slug: slug });
|
|
116
117
|
const dir = join(vaultBase, loc.folders.changes, slug);
|
|
117
118
|
const ctx = sectionBody(plan, ['Contexto', 'Context']) || plan.trim().split('\n').slice(0, 6).join('\n');
|
|
118
119
|
const source = sessionRel ? `\n - "${wikilinkFromRel(sessionRel)}"` : ' []';
|
package/hooks/session-ensure.mjs
CHANGED
|
@@ -29,7 +29,8 @@ import {
|
|
|
29
29
|
writeControl,
|
|
30
30
|
writeHookOutput,
|
|
31
31
|
yamlQuote,
|
|
32
|
-
} from './obsidian-common.mjs';
|
|
32
|
+
} from './obsidian-common.mjs';
|
|
33
|
+
import { resolveSessionIdentity } from './session-identity.mjs';
|
|
33
34
|
|
|
34
35
|
function sessionIdFromInput(input) {
|
|
35
36
|
return input.session_id || input.sessionId || input.codex_session_id || '';
|
|
@@ -239,7 +240,7 @@ function findSessionForInput(vaultBase, input, control) {
|
|
|
239
240
|
return { sessionId, relPath: '', startedAt: '', fromRegistry: false };
|
|
240
241
|
}
|
|
241
242
|
|
|
242
|
-
function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, input, now }) {
|
|
243
|
+
function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, input, now, identity }) {
|
|
243
244
|
const sessionPath = join(vaultBase, relPath);
|
|
244
245
|
if (!existsSync(sessionPath)) return false;
|
|
245
246
|
|
|
@@ -259,12 +260,14 @@ function activateExistingSession({ vaultBase, relPath, startedAt, sessionId, inp
|
|
|
259
260
|
status: 'active',
|
|
260
261
|
started_at: nextStartedAt,
|
|
261
262
|
ended_at: '',
|
|
262
|
-
transcript_path:
|
|
263
|
+
transcript_path: identity.transcriptPath,
|
|
264
|
+
transcript_id: identity.transcriptId,
|
|
265
|
+
provider: identity.provider,
|
|
263
266
|
});
|
|
264
267
|
return true;
|
|
265
268
|
}
|
|
266
269
|
|
|
267
|
-
function createSession({ vaultBase, sessionId, input, now }) {
|
|
270
|
+
function createSession({ vaultBase, sessionId, input, now, identity }) {
|
|
268
271
|
const summary = sessionSummaryFromInput(input);
|
|
269
272
|
const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
|
|
270
273
|
const startedAt = formatLocalIso(now);
|
|
@@ -283,7 +286,9 @@ function createSession({ vaultBase, sessionId, input, now }) {
|
|
|
283
286
|
status: 'active',
|
|
284
287
|
started_at: startedAt,
|
|
285
288
|
ended_at: '',
|
|
286
|
-
transcript_path:
|
|
289
|
+
transcript_path: identity.transcriptPath,
|
|
290
|
+
transcript_id: identity.transcriptId,
|
|
291
|
+
provider: identity.provider,
|
|
287
292
|
});
|
|
288
293
|
return { relPath, startedAt };
|
|
289
294
|
}
|
|
@@ -303,7 +308,18 @@ function main() {
|
|
|
303
308
|
const vaultBase = getVaultBase(input);
|
|
304
309
|
warnIfDefaultVault(input);
|
|
305
310
|
const now = new Date();
|
|
306
|
-
const
|
|
311
|
+
const identity = resolveSessionIdentity(vaultBase, input, providerMeta().id);
|
|
312
|
+
if (identity.state !== 'resolved') {
|
|
313
|
+
writeHookOutput({
|
|
314
|
+
hookSpecificOutput: {
|
|
315
|
+
hookEventName: 'UserPromptSubmit',
|
|
316
|
+
additionalContext: `<obsidian_session_deferred>Memória global disponível, sem escrita vinculada: ${identity.diagnostics.join('; ')}.</obsidian_session_deferred>`,
|
|
317
|
+
},
|
|
318
|
+
systemMessage: `[wendkeep] Identidade de sessão adiada: ${identity.diagnostics.join('; ')}`,
|
|
319
|
+
});
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const sessionId = identity.canonicalConversationId;
|
|
307
323
|
|
|
308
324
|
// Fast path: skip all writes if control file touched < 5 min ago and session matches
|
|
309
325
|
try {
|
|
@@ -316,7 +332,7 @@ function main() {
|
|
|
316
332
|
ctrl.session_file &&
|
|
317
333
|
!isPlaceholderSessionFile(ctrl.session_file) &&
|
|
318
334
|
existsSync(join(vaultBase, ctrl.session_file)) &&
|
|
319
|
-
|
|
335
|
+
ctrl.session_id === sessionId
|
|
320
336
|
) {
|
|
321
337
|
writeHookOutput({});
|
|
322
338
|
return;
|
|
@@ -333,7 +349,7 @@ function main() {
|
|
|
333
349
|
// Sem reuso-por-janela: só reaproveita a nota do control quando não há
|
|
334
350
|
// identidade pra checar (!sessionId) ou quando é a MESMA conversa. Conversa
|
|
335
351
|
// concorrente recente NÃO pode herdar a nota ativa do ponteiro global.
|
|
336
|
-
if (existsSync(activePath) &&
|
|
352
|
+
if (existsSync(activePath) && control.session_id === sessionId) {
|
|
337
353
|
const titled = maybeRetitleSession({
|
|
338
354
|
vaultBase,
|
|
339
355
|
relPath: control.session_file,
|
|
@@ -357,25 +373,30 @@ function main() {
|
|
|
357
373
|
status: 'active',
|
|
358
374
|
started_at: control.started_at,
|
|
359
375
|
ended_at: '',
|
|
360
|
-
transcript_path:
|
|
376
|
+
transcript_path: identity.transcriptPath,
|
|
377
|
+
transcript_id: identity.transcriptId,
|
|
378
|
+
provider: identity.provider,
|
|
361
379
|
});
|
|
362
380
|
writeHookOutput({});
|
|
363
381
|
return;
|
|
364
382
|
}
|
|
365
383
|
}
|
|
366
384
|
|
|
367
|
-
const
|
|
368
|
-
|
|
385
|
+
const registered = readSessionRegistry(vaultBase).sessions?.[sessionId];
|
|
386
|
+
const resolvedTarget = registered?.session_file
|
|
387
|
+
? { sessionId, relPath: registered.session_file, startedAt: registered.started_at || '' }
|
|
388
|
+
: { sessionId, relPath: '', startedAt: '' };
|
|
389
|
+
if (resolvedTarget.relPath && activateExistingSession({ vaultBase, relPath: resolvedTarget.relPath, startedAt: resolvedTarget.startedAt, sessionId, input, now, identity })) {
|
|
369
390
|
outputActiveContext({
|
|
370
|
-
relPath:
|
|
371
|
-
startedAt:
|
|
391
|
+
relPath: resolvedTarget.relPath,
|
|
392
|
+
startedAt: resolvedTarget.startedAt || formatLocalIso(now),
|
|
372
393
|
vaultBase,
|
|
373
|
-
message: `Sessão Obsidian reaberta em ${
|
|
394
|
+
message: `Sessão Obsidian reaberta em ${resolvedTarget.relPath}. ${basename(controlPath(vaultBase))} atualizado.`,
|
|
374
395
|
});
|
|
375
396
|
return;
|
|
376
397
|
}
|
|
377
398
|
|
|
378
|
-
const created = createSession({ vaultBase, sessionId, input, now });
|
|
399
|
+
const created = createSession({ vaultBase, sessionId, input, now, identity });
|
|
379
400
|
outputActiveContext({
|
|
380
401
|
relPath: created.relPath,
|
|
381
402
|
startedAt: created.startedAt,
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
if (!transcriptPath || !inspected.canonicalConversationId) {
|
|
47
|
+
return { state: 'deferred', provider, transcriptPath, diagnostics: ['transcript ausente ou sem identidade canônica'] };
|
|
48
|
+
}
|
|
49
|
+
if (!compatible(provider, inspected.transcriptProvider)) {
|
|
50
|
+
return { state: 'deferred', provider, transcriptPath, diagnostics: [`provider ${provider} incompatível com transcript ${inspected.transcriptProvider}`] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const registry = readSessionRegistry(vaultBase);
|
|
54
|
+
const byTranscript = Object.entries(registry.sessions || {}).find(([, entry]) => {
|
|
55
|
+
const paths = [...(Array.isArray(entry?.transcript_paths) ? entry.transcript_paths : []), entry?.transcript_path].filter(Boolean);
|
|
56
|
+
return paths.some((path) => transcriptsMatch(path, transcriptPath));
|
|
57
|
+
});
|
|
58
|
+
const canonicalConversationId = byTranscript?.[0] || inspected.canonicalConversationId;
|
|
59
|
+
return {
|
|
60
|
+
state: 'resolved',
|
|
61
|
+
provider,
|
|
62
|
+
canonicalConversationId,
|
|
63
|
+
hookSessionId: hookId,
|
|
64
|
+
transcriptPath,
|
|
65
|
+
transcriptId: inspected.transcriptId,
|
|
66
|
+
parentConversationId: inspected.parentConversationId,
|
|
67
|
+
diagnostics: [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveSessionEntry(vaultBase, input = {}, provider = detectProvider()) {
|
|
72
|
+
const identity = resolveSessionIdentity(vaultBase, input, provider);
|
|
73
|
+
if (identity.state !== 'resolved') return { identity, entry: null };
|
|
74
|
+
return { identity, entry: readSessionRegistry(vaultBase).sessions?.[identity.canonicalConversationId] || null };
|
|
75
|
+
}
|
|
@@ -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
|
|
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;
|
package/hooks/session-start.mjs
CHANGED
|
@@ -29,7 +29,8 @@ import {
|
|
|
29
29
|
writeControl,
|
|
30
30
|
writeHookOutput,
|
|
31
31
|
yamlQuote,
|
|
32
|
-
} from './obsidian-common.mjs';
|
|
32
|
+
} from './obsidian-common.mjs';
|
|
33
|
+
import { resolveSessionIdentity } from './session-identity.mjs';
|
|
33
34
|
|
|
34
35
|
export function buildSessionContent({ relPath, now, summary = 'session', provider: providerId, sessionId = '' }) {
|
|
35
36
|
const date = formatDate(now);
|
|
@@ -150,16 +151,29 @@ function buildAdditionalContext({ relPath, startedAt, vaultBase }) {
|
|
|
150
151
|
|
|
151
152
|
function main() {
|
|
152
153
|
const input = readHookInput();
|
|
153
|
-
const vaultBase = getVaultBase(input);
|
|
154
|
-
warnIfDefaultVault(input);
|
|
155
|
-
const now = new Date();
|
|
156
|
-
const
|
|
157
|
-
const
|
|
154
|
+
const vaultBase = getVaultBase(input);
|
|
155
|
+
warnIfDefaultVault(input);
|
|
156
|
+
const now = new Date();
|
|
157
|
+
const provider = providerMeta();
|
|
158
|
+
const identity = resolveSessionIdentity(vaultBase, input, provider.id);
|
|
159
|
+
if (identity.state !== 'resolved') {
|
|
160
|
+
writeHookOutput({
|
|
161
|
+
hookSpecificOutput: {
|
|
162
|
+
hookEventName: 'SessionStart',
|
|
163
|
+
additionalContext: `<obsidian_session_deferred>Memória global disponível, mas nenhuma escrita de sessão foi feita: ${identity.diagnostics.join('; ')}.</obsidian_session_deferred>`,
|
|
164
|
+
},
|
|
165
|
+
systemMessage: `[wendkeep] Identidade de sessão adiada: ${identity.diagnostics.join('; ')}`,
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const sessionId = identity.canonicalConversationId;
|
|
170
|
+
const transcriptPath = identity.transcriptPath;
|
|
171
|
+
const control = readControl(vaultBase);
|
|
158
172
|
|
|
159
173
|
// Fecha sessões `active` órfãs (sem evento de fim — janela fechada/crash) antes
|
|
160
174
|
// de seguir. Preserva a deste transcript: pode ser reaproveitada logo abaixo.
|
|
161
175
|
try {
|
|
162
|
-
sweepStaleSessionsFile(vaultBase, now, undefined,
|
|
176
|
+
sweepStaleSessionsFile(vaultBase, now, undefined, transcriptPath);
|
|
163
177
|
} catch (error) {
|
|
164
178
|
process.stderr.write(`[wendkeep] sweep de sessões falhou: ${error.message}\n`);
|
|
165
179
|
}
|
|
@@ -172,12 +186,15 @@ function main() {
|
|
|
172
186
|
if (control.status === 'active' && control.session_file && control.session_id === sessionId) {
|
|
173
187
|
const activePath = join(vaultBase, control.session_file);
|
|
174
188
|
if (existsSync(activePath)) {
|
|
175
|
-
upsertSessionRegistry(vaultBase, sessionId, {
|
|
189
|
+
upsertSessionRegistry(vaultBase, sessionId, {
|
|
176
190
|
session_file: control.session_file,
|
|
177
191
|
status: 'active',
|
|
178
192
|
started_at: control.started_at,
|
|
179
|
-
ended_at: '',
|
|
180
|
-
|
|
193
|
+
ended_at: '',
|
|
194
|
+
provider: provider.id,
|
|
195
|
+
transcript_path: transcriptPath,
|
|
196
|
+
transcript_id: identity.transcriptId,
|
|
197
|
+
});
|
|
181
198
|
writeHookOutput({
|
|
182
199
|
hookSpecificOutput: {
|
|
183
200
|
hookEventName: 'SessionStart',
|
|
@@ -220,7 +237,9 @@ function main() {
|
|
|
220
237
|
status: 'active',
|
|
221
238
|
started_at: startedAt,
|
|
222
239
|
ended_at: '',
|
|
223
|
-
transcript_path:
|
|
240
|
+
transcript_path: transcriptPath || known.transcript_path || '',
|
|
241
|
+
transcript_id: identity.transcriptId,
|
|
242
|
+
provider: provider.id,
|
|
224
243
|
});
|
|
225
244
|
writeHookOutput({
|
|
226
245
|
hookSpecificOutput: {
|
|
@@ -235,8 +254,7 @@ function main() {
|
|
|
235
254
|
// Re-init da conversa (compactação/resume) traz um session_id novo e cai fora
|
|
236
255
|
// da janela de reuso; o transcript continua o mesmo. Reaproveita a sessão ativa
|
|
237
256
|
// desse transcript em vez de criar um placeholder `HH-MM-codex`.
|
|
238
|
-
|
|
239
|
-
if (transcriptPath) {
|
|
257
|
+
if (transcriptPath) {
|
|
240
258
|
const match = findActiveSessionByTranscript(vaultBase, transcriptPath);
|
|
241
259
|
if (match) {
|
|
242
260
|
// fail-safe: a nota do registro pode ter sumido do disco (git stash/checkout/
|
|
@@ -262,7 +280,9 @@ function main() {
|
|
|
262
280
|
status: 'active',
|
|
263
281
|
started_at: startedAt,
|
|
264
282
|
ended_at: '',
|
|
265
|
-
transcript_path: transcriptPath,
|
|
283
|
+
transcript_path: transcriptPath,
|
|
284
|
+
transcript_id: identity.transcriptId,
|
|
285
|
+
provider: provider.id,
|
|
266
286
|
});
|
|
267
287
|
writeHookOutput({
|
|
268
288
|
hookSpecificOutput: {
|
|
@@ -277,7 +297,7 @@ function main() {
|
|
|
277
297
|
const summary = sessionSummaryFromInput(input);
|
|
278
298
|
const { absPath, relPath } = allocateSessionPath(vaultBase, now, summary);
|
|
279
299
|
const startedAt = formatLocalIso(now);
|
|
280
|
-
writeFileSync(absPath, buildSessionContent({ relPath, now, summary, sessionId }), 'utf-8');
|
|
300
|
+
writeFileSync(absPath, buildSessionContent({ relPath, now, summary, sessionId, provider: provider.id }), 'utf-8');
|
|
281
301
|
writeControl(vaultBase, {
|
|
282
302
|
status: 'active',
|
|
283
303
|
session_file: relPath,
|
|
@@ -289,8 +309,11 @@ function main() {
|
|
|
289
309
|
session_file: relPath,
|
|
290
310
|
status: 'active',
|
|
291
311
|
started_at: startedAt,
|
|
292
|
-
ended_at: '',
|
|
293
|
-
|
|
312
|
+
ended_at: '',
|
|
313
|
+
provider: provider.id,
|
|
314
|
+
transcript_path: transcriptPath,
|
|
315
|
+
transcript_id: identity.transcriptId,
|
|
316
|
+
});
|
|
294
317
|
|
|
295
318
|
writeHookOutput({
|
|
296
319
|
hookSpecificOutput: {
|
|
@@ -298,7 +321,7 @@ function main() {
|
|
|
298
321
|
additionalContext: buildAdditionalContext({ relPath, startedAt, vaultBase }),
|
|
299
322
|
},
|
|
300
323
|
systemMessage: [
|
|
301
|
-
`Sessão ${
|
|
324
|
+
`Sessão ${provider.label} criada em ${relPath}.`,
|
|
302
325
|
`${basename(controlPath(vaultBase))} atualizado.`,
|
|
303
326
|
'Iterações devem ser anexadas, nunca sobrescritas.',
|
|
304
327
|
].join(' '),
|
package/hooks/session-stop.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { buildBrainDigest, buildBrainIndex } from './brain-core.mjs';
|
|
|
9
9
|
import { activeChangeLink, pruneChangeSentinels } from './change-core.mjs';
|
|
10
10
|
import { getLocale } from './locale.mjs';
|
|
11
11
|
import { updateSessionObservability } from './session-observability.mjs';
|
|
12
|
+
import { resolveSessionEntry } from './session-identity.mjs';
|
|
12
13
|
import {
|
|
13
14
|
ensureDir,
|
|
14
15
|
findActiveSessionByTranscript,
|
|
@@ -1022,15 +1023,20 @@ function main() {
|
|
|
1022
1023
|
|
|
1023
1024
|
const vaultBase = getVaultBase(input);
|
|
1024
1025
|
warnIfDefaultVault(input);
|
|
1025
|
-
const control = readControl(vaultBase);
|
|
1026
|
-
const transcriptPath = input.transcript_path || input.transcriptPath || '';
|
|
1026
|
+
const control = readControl(vaultBase);
|
|
1027
|
+
const transcriptPath = input.transcript_path || input.transcriptPath || '';
|
|
1028
|
+
const { identity, entry } = resolveSessionEntry(vaultBase, input);
|
|
1029
|
+
if (identity.state !== 'resolved' || !entry?.session_file) {
|
|
1030
|
+
process.stderr.write(`[wendkeep] Stop sem identidade segura: ${identity.diagnostics?.join('; ') || 'sessão não registrada'}\n`);
|
|
1031
|
+
writeHookOutput({});
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1027
1034
|
|
|
1028
1035
|
// Roteia o turn pela sessão DO PRÓPRIO transcript (registry), não pelo
|
|
1029
1036
|
// CURRENT_SESSION global — que sessões concorrentes sobrescrevem, fazendo
|
|
1030
1037
|
// o turn cair na nota de outra conversa. Sem match por transcript NÃO caímos
|
|
1031
1038
|
// no global (contaminaria nota alheia): pulamos e o backfill recupera depois.
|
|
1032
|
-
const
|
|
1033
|
-
const sessionRel = matched?.session_file || '';
|
|
1039
|
+
const sessionRel = entry.session_file;
|
|
1034
1040
|
if (!sessionRel) {
|
|
1035
1041
|
writeHookOutput({});
|
|
1036
1042
|
return;
|
|
@@ -1044,7 +1050,7 @@ function main() {
|
|
|
1044
1050
|
|
|
1045
1051
|
const tx = parseTranscript(input.transcript_path || input.transcriptPath);
|
|
1046
1052
|
const turnId = input.turn_id || tx.latestTurnId || String(Date.now());
|
|
1047
|
-
const sessionId =
|
|
1053
|
+
const sessionId = identity.canonicalConversationId;
|
|
1048
1054
|
const logged = insertIteration(sessionPath, buildIterationBlock(tx, input), turnId, tx);
|
|
1049
1055
|
|
|
1050
1056
|
try {
|
|
@@ -1054,7 +1060,7 @@ function main() {
|
|
|
1054
1060
|
}
|
|
1055
1061
|
|
|
1056
1062
|
try {
|
|
1057
|
-
updateSessionObservability({ sessionPath, transcriptPath });
|
|
1063
|
+
updateSessionObservability({ sessionPath, transcriptPath, caller: 'stop', canonicalConversationId: sessionId });
|
|
1058
1064
|
} catch (error) {
|
|
1059
1065
|
process.stderr.write(`[wendkeep] Token usage falhou: ${error.message}\n`);
|
|
1060
1066
|
}
|
|
@@ -1076,7 +1082,9 @@ function main() {
|
|
|
1076
1082
|
// started_at de sessões concorrentes que sobrescrevem o ponteiro global.
|
|
1077
1083
|
ended_at: '',
|
|
1078
1084
|
last_turn_id: logged ? turnId : control.last_logged_turn_id,
|
|
1079
|
-
transcript_path: transcriptPath,
|
|
1085
|
+
transcript_path: transcriptPath,
|
|
1086
|
+
transcript_id: identity.transcriptId,
|
|
1087
|
+
provider: identity.provider,
|
|
1080
1088
|
});
|
|
1081
1089
|
pingObsidianVault(input.obsidian_api_key);
|
|
1082
1090
|
writeHookOutput({});
|
|
@@ -1095,7 +1103,9 @@ function main() {
|
|
|
1095
1103
|
// grafo quando a change fechava antes do turno seguinte. Aqui sobrevive ao reopen e acumula toda
|
|
1096
1104
|
// change que passou pela sessão (upsertListSection deduplica). Fail-quiet: nunca derruba o Stop.
|
|
1097
1105
|
try {
|
|
1098
|
-
const chgLink =
|
|
1106
|
+
const chgLink = entry.change_slug
|
|
1107
|
+
? `Change ativa: [[${getLocale(vaultBase).folders.changes}/${entry.change_slug}/proposta]]`
|
|
1108
|
+
: activeChangeLink(vaultBase);
|
|
1099
1109
|
const wl = (chgLink.match(/\[\[[^\]]+\]\]/) || [])[0];
|
|
1100
1110
|
if (wl) {
|
|
1101
1111
|
let cur = readFileSync(sessionPath, 'utf8');
|
|
@@ -1119,7 +1129,9 @@ function main() {
|
|
|
1119
1129
|
// started_at omitido: preserva o da própria entry (ver branch acima).
|
|
1120
1130
|
ended_at: endedAt,
|
|
1121
1131
|
last_turn_id: turnId,
|
|
1122
|
-
transcript_path: transcriptPath,
|
|
1132
|
+
transcript_path: transcriptPath,
|
|
1133
|
+
transcript_id: identity.transcriptId,
|
|
1134
|
+
provider: identity.provider,
|
|
1123
1135
|
});
|
|
1124
1136
|
|
|
1125
1137
|
// Reconstrói índice (camada fria) + digest (camada quente) ao finalizar. Nunca derruba o Stop.
|
package/hooks/subagent-stop.mjs
CHANGED
|
@@ -8,17 +8,19 @@
|
|
|
8
8
|
import { existsSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { pathToFileURL } from 'url';
|
|
11
|
-
import { readHookInput, writeHookOutput, getVaultBase
|
|
11
|
+
import { readHookInput, writeHookOutput, getVaultBase } from './obsidian-common.mjs';
|
|
12
12
|
import { updateSessionObservability } from './session-observability.mjs';
|
|
13
|
+
import { resolveSessionEntry } from './session-identity.mjs';
|
|
13
14
|
|
|
14
15
|
export function refreshSubagents(vaultBase, input) {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const
|
|
16
|
+
const { identity, entry } = resolveSessionEntry(vaultBase, input);
|
|
17
|
+
if (identity.state !== 'resolved') return false;
|
|
18
|
+
const transcriptPath = identity.transcriptPath;
|
|
19
|
+
const sessionRel = entry?.session_file || '';
|
|
18
20
|
if (!sessionRel) return false;
|
|
19
21
|
const sessionPath = join(vaultBase, sessionRel);
|
|
20
22
|
if (!existsSync(sessionPath)) return false;
|
|
21
|
-
updateSessionObservability({ sessionPath, transcriptPath });
|
|
23
|
+
updateSessionObservability({ sessionPath, transcriptPath, caller: 'subagent-stop', canonicalConversationId: identity.canonicalConversationId });
|
|
22
24
|
return true;
|
|
23
25
|
}
|
|
24
26
|
|
package/hooks/task-log.mjs
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { pathToFileURL } from 'url';
|
|
11
|
-
import { readHookInput, writeHookOutput, getVaultBase,
|
|
11
|
+
import { readHookInput, writeHookOutput, getVaultBase, formatHourMinute } from './obsidian-common.mjs';
|
|
12
12
|
import { getLocale } from './locale.mjs';
|
|
13
|
+
import { resolveSessionEntry } from './session-identity.mjs';
|
|
13
14
|
|
|
14
15
|
// Pull the task's human text from whatever field the payload carries.
|
|
15
16
|
export function taskText(input) {
|
|
@@ -41,9 +42,9 @@ export function appendProgress(content, line, heading) {
|
|
|
41
42
|
export function logTask(vaultBase, input) {
|
|
42
43
|
const text = taskText(input);
|
|
43
44
|
if (!text) return false;
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const sessionRel =
|
|
45
|
+
const { identity, entry } = resolveSessionEntry(vaultBase, input);
|
|
46
|
+
if (identity.state !== 'resolved') return false;
|
|
47
|
+
const sessionRel = entry?.session_file || '';
|
|
47
48
|
if (!sessionRel) return false;
|
|
48
49
|
const sessionPath = join(vaultBase, sessionRel);
|
|
49
50
|
if (!existsSync(sessionPath)) return false;
|
package/hooks/token-usage.mjs
CHANGED
|
@@ -366,7 +366,7 @@ function parseCodexLines(lines, result) {
|
|
|
366
366
|
const payload = event.payload || {};
|
|
367
367
|
|
|
368
368
|
if (event.type === 'session_meta') {
|
|
369
|
-
result.sessionId = payload.id || result.sessionId;
|
|
369
|
+
result.sessionId = payload.session_id || payload.id || result.sessionId;
|
|
370
370
|
currentProvider = normalizeProvider(payload.model_provider || currentProvider);
|
|
371
371
|
currentModel = normalizeModelName(payload.model || currentModel);
|
|
372
372
|
result.provider = currentProvider;
|
package/hooks/vault-health.mjs
CHANGED
|
@@ -118,13 +118,11 @@ function checkSession({ vaultBase, sessionRel, control, registry }) {
|
|
|
118
118
|
}
|
|
119
119
|
if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
|
|
120
120
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
failures.push('SESSION_REGISTRY diverge do CURRENT_SESSION.md para a sessão ativa.');
|
|
127
|
-
}
|
|
121
|
+
const registryPair = Object.entries(registry.sessions || {}).find(([, entry]) => entry?.session_file === sessionRel);
|
|
122
|
+
const registryEntry = registryPair?.[1];
|
|
123
|
+
if (!registryEntry) {
|
|
124
|
+
failures.push(`SESSION_REGISTRY não possui a sessão: ${sessionRel}`);
|
|
125
|
+
} else {
|
|
128
126
|
if (!registryEntry.transcript_path) {
|
|
129
127
|
warnings.push('SESSION_REGISTRY não possui transcript_path para a sessão ativa.');
|
|
130
128
|
} else if (!existsSync(registryEntry.transcript_path)) {
|
|
@@ -166,10 +164,15 @@ export function runVaultHealth({ vaultBase, session = '' }) {
|
|
|
166
164
|
failures.push(...sessionResult.failures);
|
|
167
165
|
warnings.push(...sessionResult.warnings);
|
|
168
166
|
|
|
169
|
-
const staleDone = Object.values(registry.sessions || {})
|
|
167
|
+
const staleDone = Object.values(registry.sessions || {})
|
|
170
168
|
.filter((item) => item.status === 'active' && item.ended_at)
|
|
171
169
|
.length;
|
|
172
|
-
if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
|
|
170
|
+
if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
|
|
171
|
+
const activeEntries = Object.values(registry.sessions || {}).filter((item) => item?.status === 'active');
|
|
172
|
+
for (const entry of activeEntries) {
|
|
173
|
+
if (!entry.session_file) failures.push('SESSION_REGISTRY possui sessão ativa sem session_file.');
|
|
174
|
+
if (!entry.transcript_path) warnings.push(`Sessão ativa sem transcript_path: ${entry.session_file || '(sem arquivo)'}`);
|
|
175
|
+
}
|
|
173
176
|
|
|
174
177
|
const locF = getLocale(vaultBase).folders;
|
|
175
178
|
const derivedFolders = [locF.decisions, locF.bugs, locF.learnings];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/change.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from '../hooks/change-core.mjs';
|
|
19
19
|
import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
|
|
20
20
|
import { buildEffectiveRequirementPackage, evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
|
|
21
|
-
import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
|
|
21
|
+
import { getNextAdrNumber, readControl, readSessionRegistry, upsertSessionRegistry } from '../hooks/obsidian-common.mjs';
|
|
22
22
|
import { getLocale } from '../hooks/locale.mjs';
|
|
23
23
|
|
|
24
24
|
function resolveVault(argv) {
|
|
@@ -51,7 +51,7 @@ function today() {
|
|
|
51
51
|
export function runChange(argv) {
|
|
52
52
|
const [sub, ...rest] = argv;
|
|
53
53
|
const vaultBase = resolveVault(rest);
|
|
54
|
-
const VALUE_FLAGS = new Set(['--vault', '--change', '--project']);
|
|
54
|
+
const VALUE_FLAGS = new Set(['--vault', '--change', '--project', '--session']);
|
|
55
55
|
const slugArg = () => rest.find((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
|
|
56
56
|
|
|
57
57
|
if (sub === 'new') {
|
|
@@ -74,6 +74,18 @@ export function runChange(argv) {
|
|
|
74
74
|
process.exit(0);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
if (sub === 'bind') {
|
|
78
|
+
const slug = slugArg();
|
|
79
|
+
const sessionId = opt(rest, '--session');
|
|
80
|
+
if (!slug || !sessionId) { process.stderr.write('wendkeep change bind: use <slug> --session <id>\n'); process.exit(2); }
|
|
81
|
+
const state = allChangesState(vaultBase);
|
|
82
|
+
if (!state.changes.some((item) => item.slug === slug)) { process.stderr.write(`wendkeep change bind: open change not found: ${slug}\n`); process.exit(2); }
|
|
83
|
+
if (!readSessionRegistry(vaultBase).sessions?.[sessionId]) { process.stderr.write(`wendkeep change bind: session not found: ${sessionId}\n`); process.exit(2); }
|
|
84
|
+
upsertSessionRegistry(vaultBase, sessionId, { change_slug: slug });
|
|
85
|
+
process.stdout.write(`session ${sessionId} -> change ${slug}\n`);
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
if (sub === 'continue') {
|
|
78
90
|
const positionals = rest.filter((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
|
|
79
91
|
const [archivedSlug, newSlug] = positionals;
|
package/src/rebuild-costs.mjs
CHANGED
|
@@ -9,19 +9,19 @@ export function rebuildSessionCosts(vaultBase, { apply = false, session = '', li
|
|
|
9
9
|
const registry = readSessionRegistry(vaultBase);
|
|
10
10
|
const report = { version: 1, generatedAt: new Date().toISOString(), mode: apply ? 'apply' : 'dry-run', scanned: 0, changed: 0, unchanged: 0, missing: [], errors: [], sessions: [] };
|
|
11
11
|
const entries = Object.entries(registry.sessions || {}).map(([sessionId, value]) => ({ sessionId, ...value }))
|
|
12
|
-
.filter((e) => e.session_file
|
|
12
|
+
.filter((e) => e.session_file)
|
|
13
13
|
.filter((e) => !session || e.sessionId === session || e.session_file === session);
|
|
14
14
|
for (const entry of entries) {
|
|
15
15
|
if (limit && report.scanned >= limit) break;
|
|
16
16
|
report.scanned += 1;
|
|
17
17
|
const note = join(vaultBase, entry.session_file);
|
|
18
|
-
if (!existsSync(note) || !existsSync(entry.transcript_path)) {
|
|
19
|
-
report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: existsSync(note), transcript: existsSync(entry.transcript_path) });
|
|
18
|
+
if (!entry.transcript_path || !existsSync(note) || !existsSync(entry.transcript_path)) {
|
|
19
|
+
report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: existsSync(note), transcript: !!entry.transcript_path && existsSync(entry.transcript_path), transcriptPath: entry.transcript_path || '' });
|
|
20
20
|
continue;
|
|
21
21
|
}
|
|
22
22
|
const before = readFileSync(note, 'utf8');
|
|
23
23
|
try {
|
|
24
|
-
updateSessionObservability({ sessionPath: note, transcriptPath: entry.transcript_path });
|
|
24
|
+
updateSessionObservability({ sessionPath: note, transcriptPath: entry.transcript_path, caller: 'cost-rebuild', canonicalConversationId: entry.sessionId });
|
|
25
25
|
const after = readFileSync(note, 'utf8');
|
|
26
26
|
const changed = before !== after;
|
|
27
27
|
if (changed) report.changed += 1; else report.unchanged += 1;
|
|
@@ -32,7 +32,7 @@ export function rebuildSessionCosts(vaultBase, { apply = false, session = '', li
|
|
|
32
32
|
report.errors.push({ sessionId: entry.sessionId, session: entry.session_file, error: error.message });
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
report.ok = report.errors.length === 0;
|
|
35
|
+
report.ok = report.errors.length === 0 && report.missing.length === 0;
|
|
36
36
|
if (apply) writeFileSync(join(vaultBase, '.brain', 'COST_REBUILD.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
37
37
|
return report;
|
|
38
38
|
}
|
package/src/session.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { readControl, readSessionRegistry, writeControl } from '../hooks/obsidian-common.mjs';
|
|
3
|
+
|
|
4
|
+
function vaultOf(argv) {
|
|
5
|
+
const i = argv.indexOf('--vault');
|
|
6
|
+
const raw = i >= 0 ? argv[i + 1] : argv.find((a) => a.startsWith('--vault='))?.slice(8) || process.env.OBSIDIAN_VAULT_PATH;
|
|
7
|
+
if (!raw) throw new Error('pass --vault <path> or set OBSIDIAN_VAULT_PATH');
|
|
8
|
+
return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function positionals(argv) {
|
|
12
|
+
return argv.filter((arg, index) => !arg.startsWith('-') && argv[index - 1] !== '--vault');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function runSession(argv) {
|
|
16
|
+
const vault = vaultOf(argv);
|
|
17
|
+
const [sub, id] = positionals(argv);
|
|
18
|
+
const registry = readSessionRegistry(vault);
|
|
19
|
+
const rows = Object.entries(registry.sessions || {}).sort((a, b) => String(b[1].last_seen || '').localeCompare(String(a[1].last_seen || '')));
|
|
20
|
+
if (sub === 'list') {
|
|
21
|
+
for (const [sessionId, item] of rows) process.stdout.write(`${sessionId}\t${item.status || 'unknown'}\t${item.provider || 'unknown'}\t${item.change_slug || '-'}\t${item.session_file || '-'}\n`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const entry = registry.sessions?.[id];
|
|
25
|
+
if (!entry) throw new Error(`session not found: ${id || '(missing id)'}`);
|
|
26
|
+
if (sub === 'show') {
|
|
27
|
+
process.stdout.write(`${JSON.stringify({ session_id: id, ...entry }, null, 2)}\n`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (sub === 'use') {
|
|
31
|
+
const control = readControl(vault);
|
|
32
|
+
writeControl(vault, { ...control, status: entry.status || 'active', session_id: id, session_file: entry.session_file || '', started_at: entry.started_at || '' });
|
|
33
|
+
process.stdout.write(`session focus: ${id}\n`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
throw new Error('use: wendkeep session list | show <id> | use <id>');
|
|
37
|
+
}
|