wendkeep 0.75.0 → 0.75.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/docs/en/commands/observer.md +7 -2
- package/docs/pt-BR/commands/observer.md +5 -0
- package/package.json +1 -1
- package/src/observer-sql-publish.mjs +27 -13
- package/src/observer.mjs +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ 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.75.1] — 2026-08-20
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Coalescência sem perda.** A outbox seleciona o identificador próprio de cada tipo de evento;
|
|
12
|
+
chamadas, agentes, rollups e transcripts distintos da mesma sessão não disputam mais a mesma chave.
|
|
13
|
+
- **Reconciliação realmente integral.** `observer reconcile --url` e `observer memory import`
|
|
14
|
+
ignoram o cursor incremental local, consultam as revisões remotas e regeneram documentos,
|
|
15
|
+
consumo, chamadas e transcripts mesmo após restauração ou troca do Observer.
|
|
16
|
+
- **Leases compatíveis com o transporte.** Locks de batch e publisher permanecem válidos além do
|
|
17
|
+
timeout máximo de request e só podem ser liberados pelo proprietário que os adquiriu.
|
|
18
|
+
|
|
7
19
|
## [0.75.0] — 2026-08-20
|
|
8
20
|
|
|
9
21
|
### Added
|
package/README.en.md
CHANGED
|
@@ -88,7 +88,7 @@ Decisions, dead ends, the reason you chose X over Y — gone next session. The p
|
|
|
88
88
|
| **Cost** — what it all cost | Per‑model, cache‑aware token pricing per session — plus `cost --trend` with a run‑rate projection across the whole vault; research previews without a final rate remain unestimated. |
|
|
89
89
|
| **Multi‑agent** — one vault, both agents | `init` wires the session hooks into `.claude/settings.json` *and* `.codex/hooks.json`, and every note is tagged with the agent that wrote it: Claude Code is detected from its environment, anything else is recorded as Codex. One shared graph, whichever agent you are in. |
|
|
90
90
|
| **Local‑first** — no cloud, no account | Everything is plain Markdown on your disk. An optional MCP server (`@bitbonsai/mcpvault`) lets the agent read/write the vault. |
|
|
91
|
-
| **Local Observer** — many projects, one view | `wendkeep observer` keeps documents, FTS5 chunks, sessions, agents, tokens, costs, calls, and transcripts in SQLite. Identities and foreign keys are project-scoped; each event is atomic. Hooks publish only what changed
|
|
91
|
+
| **Local Observer** — many projects, one view | `wendkeep observer` keeps documents, FTS5 chunks, sessions, agents, tokens, costs, calls, and transcripts in SQLite. Identities and foreign keys are project-scoped; each event is atomic. Hooks publish only what changed; `observer reconcile --url` ignores the incremental cursor to regenerate the complete projection while preserving local/remote revision baselines. |
|
|
92
92
|
|
|
93
93
|
During historical migration, the Observer preserves differences between frontmatter totals and the
|
|
94
94
|
ledger as explicit reconciliation rows, and disambiguates duplicate `session_id` values per file
|
package/README.md
CHANGED
|
@@ -88,7 +88,7 @@ Decisions, dead ends, the reason you chose X over Y — gone next session. The p
|
|
|
88
88
|
| **Cost** — what it all cost | Per‑model, cache‑aware token pricing per session — plus `cost --trend` with a run‑rate projection across the whole vault; research previews without a final rate remain unestimated. |
|
|
89
89
|
| **Multi‑agent** — one vault, both agents | `init` wires the session hooks into `.claude/settings.json` *and* `.codex/hooks.json`, and every note is tagged with the agent that wrote it: Claude Code is detected from its environment, anything else is recorded as Codex. One shared graph, whichever agent you are in. |
|
|
90
90
|
| **Local‑first** — no cloud, no account | Everything is plain Markdown on your disk. An optional MCP server (`@bitbonsai/mcpvault`) lets the agent read/write the vault. |
|
|
91
|
-
| **Local Observer** — many projects, one view | `wendkeep observer` keeps documents, FTS5 chunks, sessions, agents, tokens, costs, calls, and transcripts in SQLite. Identities and foreign keys are project-scoped; each event is atomic. Hooks publish only what changed
|
|
91
|
+
| **Local Observer** — many projects, one view | `wendkeep observer` keeps documents, FTS5 chunks, sessions, agents, tokens, costs, calls, and transcripts in SQLite. Identities and foreign keys are project-scoped; each event is atomic. Hooks publish only what changed; `observer reconcile --url` ignores the incremental cursor to regenerate the complete projection while preserving local/remote revision baselines. |
|
|
92
92
|
|
|
93
93
|
During historical migration, the Observer preserves differences between frontmatter totals and the
|
|
94
94
|
ledger as explicit reconciliation rows, and disambiguates duplicate `session_id` values per file
|
|
@@ -92,8 +92,13 @@ agent sessions, cost rollups, and calls. Messages and transcripts are only sent
|
|
|
92
92
|
that explicitly enable them. The container stores everything in
|
|
93
93
|
`/data/observer.sqlite`; it does not mount `C:\GitHub` or any `.WendKeep-vault`. Markdown is only
|
|
94
94
|
the text held in SQL and is recreated as files only by an explicit read-only export.
|
|
95
|
-
`memory import` performs the initial load and returns file/hash parity.
|
|
96
|
-
|
|
95
|
+
`memory import` performs the initial load and returns file/hash parity. The remote recovery
|
|
96
|
+
commands `observer reconcile --url` and `observer memory import` do not use the
|
|
97
|
+
incremental cursor to decide what to omit: they regenerate documents, sessions, usage, calls, and
|
|
98
|
+
transcripts. The cursor still supplies the local revision baseline and the remote tree, when
|
|
99
|
+
available, supplies the highest known baseline; a transient read failure never lowers the locally
|
|
100
|
+
persisted revision. During migration, the cost/token total recorded in frontmatter is preserved
|
|
101
|
+
through an explicit reconciliation row when
|
|
97
102
|
the detailed ledger does not add up; that row does not invent calls. Historical sessions sharing
|
|
98
103
|
one `session_id` receive a canonical per-file identity so one rollup cannot overwrite the other.
|
|
99
104
|
|
|
@@ -94,6 +94,11 @@ níveis de captura que os habilitam. O container grava tudo em
|
|
|
94
94
|
`/data/observer.sqlite`; não monta `C:\GitHub` nem qualquer `.WendKeep-vault`. Markdown é aceito
|
|
95
95
|
somente como conteúdo de uma coluna SQL e volta a existir como arquivo apenas pela exportação
|
|
96
96
|
read-only sob demanda. `memory import` faz a carga inicial e retorna a paridade por arquivo e hash.
|
|
97
|
+
Na recuperação remota, `observer reconcile --url` e `observer memory import` não usam o cursor
|
|
98
|
+
incremental para decidir o que omitir: regeneram documentos, sessões, consumo, chamadas e
|
|
99
|
+
transcripts. O cursor continua fornecendo o baseline local de revisão e a árvore remota, quando
|
|
100
|
+
disponível, fornece o maior baseline conhecido; uma falha temporária nessa leitura nunca reduz a
|
|
101
|
+
revisão persistida localmente.
|
|
97
102
|
Na migração, o total de custo/token registrado no frontmatter é preservado por uma linha de
|
|
98
103
|
reconciliação quando o ledger detalhado não fecha com ele; essa linha não inventa chamadas.
|
|
99
104
|
Sessões históricas com o mesmo `session_id` recebem uma identidade canônica por arquivo para
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.75.
|
|
3
|
+
"version": "0.75.1",
|
|
4
4
|
"description": "Vault-first persistent memory for AI coding agents, with an optional profile-aware governance runtime: OFF, FLOW, GUIDE, GOVERN, or ASSURE. Local-first and agent-agnostic (Claude Code, Codex, Cursor…).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -26,6 +26,7 @@ export const SQL_EVENT_BATCH_SIZE = 64;
|
|
|
26
26
|
export const SQL_EVENT_BATCH_BYTES = 8 * 1024 * 1024;
|
|
27
27
|
const REQUEST_TIMEOUT_MS = 15000;
|
|
28
28
|
const MAX_REQUEST_TIMEOUT_MS = 120000;
|
|
29
|
+
export const SQL_LEASE_STALE_MS = MAX_REQUEST_TIMEOUT_MS + 30000;
|
|
29
30
|
const REQUEST_TIMEOUT_BYTES_STEP = 1024 * 1024;
|
|
30
31
|
const CAPTURE_LEVELS = new Set(['metadata', 'messages', 'full-transcript']);
|
|
31
32
|
|
|
@@ -262,7 +263,7 @@ function dedupeEvents(events) {
|
|
|
262
263
|
});
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, now = new Date(), state = readState(vaultBase), remoteDocuments = {}, captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata' } = {}) {
|
|
266
|
+
export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, now = new Date(), state = readState(vaultBase), remoteDocuments = {}, captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata', forceFull = false } = {}) {
|
|
266
267
|
if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
|
|
267
268
|
const occurredAt = isoNow(now);
|
|
268
269
|
const resolvedCaptureLevel = normalizeObserverCaptureLevel(captureLevel);
|
|
@@ -289,7 +290,7 @@ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, n
|
|
|
289
290
|
nextState.files[file.logicalPath] = { content_hash: contentHash, revision: revision || 1 };
|
|
290
291
|
const fm = parseFrontmatter(content);
|
|
291
292
|
if (fm.type === 'session') sessionContexts.push({ file, content, fm, contentHash, revision: revision || 1, sessionId: sessionIdentity.get(file.logicalPath) });
|
|
292
|
-
if (previous?.content_hash === contentHash) continue;
|
|
293
|
+
if (!forceFull && previous?.content_hash === contentHash) continue;
|
|
293
294
|
changed += 1;
|
|
294
295
|
events.push(documentEvent({ projectId, logicalPath: file.logicalPath, content, metadata: fm, revision: revision || 1, occurredAt }));
|
|
295
296
|
if (fm.type === 'session') {
|
|
@@ -309,7 +310,7 @@ export function buildObserverSqlEventBatch({ vaultBase, projectId, input = {}, n
|
|
|
309
310
|
const content = readFileSync(source.path, 'utf8');
|
|
310
311
|
const fingerprint = hash(content);
|
|
311
312
|
const previousTranscript = state.transcripts?.[source.transcriptId];
|
|
312
|
-
if (previousTranscript?.content_hash === fingerprint && previousTranscript?.coverage === resolvedCaptureLevel) continue;
|
|
313
|
+
if (!forceFull && previousTranscript?.content_hash === fingerprint && previousTranscript?.coverage === resolvedCaptureLevel) continue;
|
|
313
314
|
const complete = completeTranscriptEvents({ projectId, sessionId, mainAgentId, provider, model, source, now: occurredAt, captureLevel: resolvedCaptureLevel });
|
|
314
315
|
nextState.transcripts[source.transcriptId] = { content_hash: complete.fingerprint, coverage: resolvedCaptureLevel };
|
|
315
316
|
const summaryId = complete.transcriptId;
|
|
@@ -467,11 +468,20 @@ function queueOutbox(vaultBase, batch) {
|
|
|
467
468
|
|
|
468
469
|
function eventCoalesceKey(event) {
|
|
469
470
|
const payload = event.payload || {};
|
|
471
|
+
const entityFields = {
|
|
472
|
+
'document.upsert': ['logical_path'],
|
|
473
|
+
'document.delete': ['logical_path'],
|
|
474
|
+
'session.upsert': ['session_id'],
|
|
475
|
+
'agent.upsert': ['agent_id'],
|
|
476
|
+
'usage.rollup': ['rollup_key'],
|
|
477
|
+
llm_call: ['call_id'],
|
|
478
|
+
'transcript.upsert': ['transcript_id'],
|
|
479
|
+
}[event.kind] || [];
|
|
480
|
+
const entityId = entityFields.map((field) => payload[field]).find(Boolean) || event.event_id;
|
|
470
481
|
return [
|
|
471
482
|
event.project_id,
|
|
472
483
|
event.kind,
|
|
473
|
-
|
|
474
|
-
|| payload.call_id || payload.transcript_id || event.event_id,
|
|
484
|
+
entityId,
|
|
475
485
|
].join('\u001f');
|
|
476
486
|
}
|
|
477
487
|
|
|
@@ -480,15 +490,17 @@ function waitSync(milliseconds) { Atomics.wait(WAIT_ARRAY, 0, 0, milliseconds);
|
|
|
480
490
|
|
|
481
491
|
function acquireBatchFileLease(path, waitMs = 0) {
|
|
482
492
|
const lock = `${path}.lock`;
|
|
493
|
+
const token = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
483
494
|
const deadline = Date.now() + Math.max(0, waitMs);
|
|
484
495
|
do {
|
|
485
496
|
try {
|
|
486
497
|
mkdirSync(lock);
|
|
487
|
-
|
|
498
|
+
atomicJson(join(lock, 'owner.json'), { token, pid: process.pid, acquired_at: new Date().toISOString() });
|
|
499
|
+
return { path: lock, token };
|
|
488
500
|
} catch (error) {
|
|
489
501
|
if (error?.code !== 'EEXIST') throw error;
|
|
490
502
|
try {
|
|
491
|
-
if (Date.now() - statSync(lock).mtimeMs >
|
|
503
|
+
if (Date.now() - statSync(lock).mtimeMs > SQL_LEASE_STALE_MS) {
|
|
492
504
|
const stale = `${lock}.stale-${process.pid}-${Date.now()}`;
|
|
493
505
|
renameSync(lock, stale);
|
|
494
506
|
rmSync(stale, { recursive: true, force: true });
|
|
@@ -502,8 +514,10 @@ function acquireBatchFileLease(path, waitMs = 0) {
|
|
|
502
514
|
return null;
|
|
503
515
|
}
|
|
504
516
|
|
|
505
|
-
function releaseBatchFileLease(
|
|
506
|
-
if (
|
|
517
|
+
function releaseBatchFileLease(lease) {
|
|
518
|
+
if (!lease) return;
|
|
519
|
+
const owner = readJson(join(lease.path, 'owner.json'), {});
|
|
520
|
+
if (owner.token === lease.token) rmSync(lease.path, { recursive: true, force: true });
|
|
507
521
|
}
|
|
508
522
|
|
|
509
523
|
/** Queue a precise writer batch and replace older pending state for the same logical scope. */
|
|
@@ -625,7 +639,7 @@ function acquirePublisherLease(vaultBase, currentTime = Date.now()) {
|
|
|
625
639
|
if (error?.code !== 'EEXIST') throw error;
|
|
626
640
|
let age = 0;
|
|
627
641
|
try { age = currentTime - statSync(path).mtimeMs; } catch { return null; }
|
|
628
|
-
if (age <=
|
|
642
|
+
if (age <= SQL_LEASE_STALE_MS) return null;
|
|
629
643
|
const stale = `${path}.stale-${token}`;
|
|
630
644
|
try {
|
|
631
645
|
renameSync(path, stale);
|
|
@@ -770,14 +784,14 @@ export async function publishObserverSqlIncremental({
|
|
|
770
784
|
};
|
|
771
785
|
}
|
|
772
786
|
|
|
773
|
-
export async function publishObserverSql({ vaultBase, projectId, url = process.env.WENDKEEP_OBSERVER_URL || '', input = {}, now = new Date(), fetchImpl = globalThis.fetch, token = process.env.WENDKEEP_OBSERVER_TOKEN || '', captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata' } = {}) {
|
|
787
|
+
export async function publishObserverSql({ vaultBase, projectId, url = process.env.WENDKEEP_OBSERVER_URL || '', input = {}, now = new Date(), fetchImpl = globalThis.fetch, token = process.env.WENDKEEP_OBSERVER_TOKEN || '', captureLevel = process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata', forceFull = false } = {}) {
|
|
774
788
|
if (!vaultBase || !projectId) throw new Error('vaultBase e projectId são obrigatórios.');
|
|
775
789
|
const replay = await retryObserverSqlOutbox({ vaultBase, projectId, url, fetchImpl, token });
|
|
776
790
|
const state = readState(vaultBase);
|
|
777
|
-
const remoteDocuments = Object.keys(state.files || {}).length === 0
|
|
791
|
+
const remoteDocuments = forceFull || Object.keys(state.files || {}).length === 0
|
|
778
792
|
? await readRemoteDocuments({ url, projectId, fetchImpl, token })
|
|
779
793
|
: {};
|
|
780
|
-
const batch = buildObserverSqlEventBatch({ vaultBase, projectId, input, now, state, remoteDocuments, captureLevel });
|
|
794
|
+
const batch = buildObserverSqlEventBatch({ vaultBase, projectId, input, now, state, remoteDocuments, captureLevel, forceFull });
|
|
781
795
|
if (!batch.events.length) {
|
|
782
796
|
atomicJson(statePath(vaultBase), batch.nextState);
|
|
783
797
|
return { ok: true, queued: false, scanned: batch.scanned, changed: batch.changed, pending: listSqlOutbox(vaultBase).length, replay };
|
package/src/observer.mjs
CHANGED
|
@@ -147,6 +147,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
|
|
|
147
147
|
projectId: snapshot.project_id,
|
|
148
148
|
url,
|
|
149
149
|
token,
|
|
150
|
+
forceFull: true,
|
|
150
151
|
captureLevel: optionValue(argv, '--capture-level') || process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
|
|
151
152
|
});
|
|
152
153
|
const snapshotResponse = await fetch(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(snapshot.project_id)}/snapshot`, {
|
|
@@ -209,6 +210,7 @@ export async function runObserver(argv = [], { write = (chunk) => process.stdout
|
|
|
209
210
|
projectId: snapshot.project_id,
|
|
210
211
|
url,
|
|
211
212
|
token,
|
|
213
|
+
forceFull: true,
|
|
212
214
|
captureLevel: optionValue(argv, '--capture-level') || process.env.WENDKEEP_OBSERVER_CAPTURE_LEVEL || 'metadata',
|
|
213
215
|
});
|
|
214
216
|
const snapshotResponse = await fetch(`${String(url).replace(/\/$/, '')}/v1/projects/${encodeURIComponent(snapshot.project_id)}/snapshot`, {
|