wendkeep 0.36.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 +29 -6
- package/README.md +4 -0
- package/README.pt-BR.md +4 -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/import-sessions.mjs +3 -7
- 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 +176 -0
- package/hooks/session-start.mjs +41 -18
- package/hooks/session-stop.mjs +27 -27
- package/hooks/subagent-stop.mjs +10 -9
- package/hooks/subagent-usage.mjs +8 -4
- package/hooks/task-log.mjs +5 -4
- package/hooks/token-usage.mjs +58 -31
- package/hooks/vault-health.mjs +21 -13
- package/package.json +1 -1
- package/src/change.mjs +14 -2
- package/src/rebuild-costs.mjs +6 -8
- package/src/session.mjs +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ 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
|
+
|
|
21
|
+
## [0.37.0] — 2026-07-11
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Observabilidade consolidada em `## Agentes, tokens e custos`: Stop, SubagentStop, importação e rebuild usam um único writer atômico para main + subagents.
|
|
26
|
+
- Ledger por modelo/origem com reasoning tokens e effort, sem alterar a regra de preço do modelo.
|
|
27
|
+
- Novo `wendkeep cost rebuild`, dry-run por padrão; `--apply` reconstrói sessões antigas via `SESSION_REGISTRY` e grava `.brain/COST_REBUILD.json`.
|
|
28
|
+
- Preços API-equivalentes de GPT-5.6 Sol, Terra e Luna.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Custos de subagents são atribuídos ao modelo que realmente os executou, em vez do modelo principal.
|
|
33
|
+
- Migração remove os headings legados sem perder reaberturas ou iterações mal posicionadas.
|
|
34
|
+
- Totais combinados de tokens/custo são atualizados também no `SubagentStop` e persistidos em campos compatíveis com os dashboards existentes.
|
|
35
|
+
|
|
7
36
|
## [0.35.0] — 2026-07-11
|
|
8
37
|
|
|
9
38
|
### Fixed
|
|
@@ -870,9 +899,3 @@ Initial release — the capture engine, extracted from a system in daily product
|
|
|
870
899
|
[0.2.7]: https://www.npmjs.com/package/wendkeep/v/0.2.7
|
|
871
900
|
[0.2.0]: https://www.npmjs.com/package/wendkeep/v/0.2.0
|
|
872
901
|
[0.1.0]: https://www.npmjs.com/package/wendkeep/v/0.1.0
|
|
873
|
-
# 0.36.0 — 2026-07-11
|
|
874
|
-
|
|
875
|
-
- Custos de subagents agora são atribuídos ao modelo que realmente os executou por meio de um ledger por sessão; campos legados continuam compatíveis.
|
|
876
|
-
- Novo `wendkeep cost rebuild`, dry-run por padrão, reconstrói sessões antigas via `SESSION_REGISTRY` e, com `--apply`, grava um manifesto auditável em `.brain/COST_REBUILD.json`.
|
|
877
|
-
- Totais combinados de tokens/custo passam a ser persistidos explicitamente nas notas.
|
|
878
|
-
- Preços API-equivalentes de GPT-5.6 Sol, Terra e Luna adicionados à tabela editável e ao fallback interno.
|
package/README.md
CHANGED
|
@@ -119,6 +119,10 @@ 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. |
|
|
124
|
+
|
|
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.
|
|
122
126
|
| `wendkeep lesson add "t" "l"` | Record a project-local lesson (injected at the next SessionStart). |
|
|
123
127
|
| `wendkeep sync-defs` | Copy `.brain/agents\|skills` into `.codex/agents`, `.claude/skills`, `.agents/skills`; `--check` detects drift. |
|
|
124
128
|
| `wendkeep validate-memory [path]` | Validate `.brain/CORE.md` (cap 25, 3 sections, no secrets/PII). |
|
package/README.pt-BR.md
CHANGED
|
@@ -116,6 +116,10 @@ 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. |
|
|
121
|
+
|
|
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.
|
|
119
123
|
| `wendkeep stats [--vault P]` | Uma linha compartilhável: sessões · prompts · gasto · período · modelos (`--json`). |
|
|
120
124
|
| `wendkeep import [opts]` | **Memória retroativa** — importa sessões passadas de **Claude + Codex** pro cofre (dedup por `session_id`). `--source all\|claude\|codex` / `--from <dir>` / `--codex-from <dir>` / `--stamp-ids` / `--since d` / `--limit n` / `--dry-run` / `--json`. |
|
|
121
125
|
| `wendkeep dashboard [--force]` | (Re)gera os Bases filtrados por pasta + o MOC `00-Dashboard`. |
|
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);
|
|
@@ -16,8 +16,7 @@ import {
|
|
|
16
16
|
} from './session-stop.mjs';
|
|
17
17
|
import { buildSessionContent, allocateSessionPath } from './session-start.mjs';
|
|
18
18
|
import { createLinkedNotes } from './linked-notes.mjs';
|
|
19
|
-
import {
|
|
20
|
-
import { upsertSubagentUsage } from './subagent-usage.mjs';
|
|
19
|
+
import { updateSessionObservability } from './session-observability.mjs';
|
|
21
20
|
import { readSessionRegistry, upsertSessionRegistry, formatLocalIso, formatDate, providerMeta } from './obsidian-common.mjs';
|
|
22
21
|
import { getLocale } from './locale.mjs';
|
|
23
22
|
import { captureProseDecisions } from './decision-capture.mjs';
|
|
@@ -211,11 +210,8 @@ export function importSession(vaultBase, txPath, opts = {}) {
|
|
|
211
210
|
|
|
212
211
|
// Cost + subagent telemetry, exactly like the live Stop hook. Fail-open.
|
|
213
212
|
try {
|
|
214
|
-
|
|
215
|
-
} catch { /*
|
|
216
|
-
try {
|
|
217
|
-
upsertSubagentUsage(absPath, txPath);
|
|
218
|
-
} catch { /* subagent telemetry is best-effort */ }
|
|
213
|
+
updateSessionObservability({ sessionPath: absPath, transcriptPath: txPath });
|
|
214
|
+
} catch { /* observability is best-effort */ }
|
|
219
215
|
|
|
220
216
|
// Finalize: derived notes + closing section + ended_at from the last turn.
|
|
221
217
|
const endedAt = formatLocalIso(endDate);
|
|
@@ -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,
|