@voybio/ace-swarm 0.1.0 → 0.2.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.
@@ -1,258 +1,437 @@
1
1
  /**
2
- * AcePackedStore
2
+ * AcePackedStore — ACEPACK v2
3
3
  *
4
- * Custom single-file store that implements Zarrita's AsyncReadable + AsyncWritable
5
- * interfaces over a packed binary file at `.agents/ACE/ace-state.zarr`.
4
+ * Single-file binary store. One file, no subdirectories, no companion files.
5
+ * Events grow in the columnar section indefinitely (Option A). compact() only
6
+ * reclaims dead space in the KV chunk region — it does NOT reset or archive
7
+ * events. Historical event replay across all past runs is always available.
6
8
  *
7
- * File format:
8
- * [ Header 64 bytes ] magic(8) version(4) flags(4) index_offset(8) index_length(8) reserved(32)
9
- * [ Chunk region ] chunk_0: [uint32 length][bytes] ... chunk_N: [uint32 length][bytes]
10
- * [ Index region ] UTF-8 JSON: { key → { offset, length } }
9
+ * File layout:
11
10
  *
12
- * Write protocol:
13
- * 1. Append new chunk to end of chunk region.
14
- * 2. Update in-memory index.
15
- * 3. On commit(): rewrite index region → update header → fsync.
11
+ * ┌─────────────────────────────────────────────────────────┐
12
+ * Header (128 bytes, big-endian) │
13
+ * │ magic "ACEPACK\0" · version uint32 · flags uint32 │
14
+ * │ kv_index_offset uint64 · kv_index_length uint64 │
15
+ * │ kv_chunk_end uint64 │
16
+ * │ evt_offset uint64 · evt_length uint64 │
17
+ * │ evt_count uint32 · evt_base_id uint32 │
18
+ * │ reserved (zeros) │
19
+ * ├─────────────────────────────────────────────────────────┤
20
+ * │ KV Chunk Region (append-only, random-access) │
21
+ * │ knowledge/agents/{name}/{file} → instruction text │
22
+ * │ knowledge/skills/{name}/{file} → skill content │
23
+ * │ topology/{kind} → JSON array │
24
+ * │ state/{...} → runtime state blobs │
25
+ * │ meta/{...} → schema version etc. │
26
+ * │ Each chunk: [uint32 length BE][bytes] │
27
+ * ├─────────────────────────────────────────────────────────┤
28
+ * │ Columnar Event Section (rewritten on every commit) │
29
+ * │ uint32 count │
30
+ * │ int64[N] timestamps (epoch ms, big-endian) │
31
+ * │ uint8[N] kinds (EntityKind enum) │
32
+ * │ uint8[N] sources (ContentSource enum) │
33
+ * │ uint8[N] flags (0x01=deleted) │
34
+ * │ [pad to 4-byte alignment] │
35
+ * │ uint32[N] pay_offsets (relative to pool start) │
36
+ * │ uint32[N] pay_lengths │
37
+ * │ uint32 pool_length │
38
+ * │ bytes[M] payload pool (UTF-8 JSON: {key,payload,...})│
39
+ * ├─────────────────────────────────────────────────────────┤
40
+ * │ KV Index (JSON, rewritten on commit) │
41
+ * │ { "knowledge/agents/ace-ops/AGENT.md": {offset, length}, ... }
42
+ * └─────────────────────────────────────────────────────────┘
16
43
  *
17
- * Compaction:
18
- * Write live chunks to temp file validate atomic rename.
44
+ * Event overhead: ~19 bytes fixed + payload (vs ~150 bytes full JSON).
45
+ * Timestamp/kind scanning reads only the fixed-width columns, never the pool.
19
46
  *
20
- * Locking:
21
- * Advisory flock-style via a `.lock` sidecar file (PID + timestamp).
22
- * Stale detection: lock held >30s and PID not alive → break lock.
47
+ * compact() removes dead KV space (overwritten keys) via atomic tmp-rename.
48
+ * Events are preserved across compact() they accumulate for the workspace
49
+ * lifetime. See HOT_COLD_EVENT_TIERING.md for a future branch that adds
50
+ * BGZF batch archiving for very long-lived workspaces.
51
+ *
52
+ * Backward compat: v1 files (64-byte header) are migrated on first commit().
53
+ * v1 events live in core/log/ KV blobs and are pulled into the columnar section.
23
54
  */
24
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
25
- import { open as fsOpen } from "fs/promises";
26
- import { dirname } from "path";
27
- import { MAGIC, STORE_VERSION, HEADER_SIZE, ContentSource, EntityKind, } from "./types.js";
28
- const LOCK_TIMEOUT_MS = 30_000;
55
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
56
+ import { open as fsOpen } from "node:fs/promises";
57
+ import { dirname } from "node:path";
58
+ import { MAGIC, STORE_VERSION, HEADER_SIZE, HEADER_SIZE_V1, ContentSource, EntityKind, } from "./types.js";
59
+ // ── Constants ─────────────────────────────────────────────────────────────────
60
+ // Header field byte offsets (big-endian throughout)
61
+ const H_KV_IDX_OFF = 16; // uint64: KV index offset
62
+ const H_KV_IDX_LEN = 24; // uint64: KV index length
63
+ const H_KV_CHUNK_END = 32; // uint64: end of KV chunk region (v2 only)
64
+ const H_EVT_OFF = 40; // uint64: event section offset (v2 only)
65
+ const H_EVT_LEN = 48; // uint64: event section length (v2 only)
66
+ const H_EVT_CNT = 56; // uint32: event count (v2 only)
67
+ const H_EVT_BASE = 60; // uint32: event base id (v2 only)
29
68
  const TEXT = new TextEncoder();
30
69
  const DECODE = new TextDecoder();
31
- function fnv1a(s) {
32
- let h = 2166136261;
33
- for (let i = 0; i < s.length; i++) {
34
- h ^= s.charCodeAt(i);
35
- h = Math.imul(h, 16777619) >>> 0;
36
- }
37
- return h >>> 0;
38
- }
39
- function uint32ToBytes(n) {
70
+ // ── Byte helpers ──────────────────────────────────────────────────────────────
71
+ function u32be(n) {
40
72
  const b = new Uint8Array(4);
41
73
  new DataView(b.buffer).setUint32(0, n, false);
42
74
  return b;
43
75
  }
44
- function bytesToUint32(b, offset = 0) {
45
- return new DataView(b.buffer, b.byteOffset).getUint32(offset, false);
46
- }
47
- function uint64ToBytes(n) {
48
- // JS numbers are safe up to 2^53 — sufficient for file offsets
76
+ function u64be(n) {
49
77
  const b = new Uint8Array(8);
50
78
  const dv = new DataView(b.buffer);
51
79
  dv.setUint32(0, Math.floor(n / 2 ** 32), false);
52
80
  dv.setUint32(4, n >>> 0, false);
53
81
  return b;
54
82
  }
55
- function bytesToUint64(b, offset = 0) {
56
- const dv = new DataView(b.buffer, b.byteOffset);
57
- return dv.getUint32(offset, false) * 2 ** 32 + dv.getUint32(offset + 4, false);
58
- }
59
- async function readExact(handle, buffer, position) {
60
- let offset = 0;
61
- while (offset < buffer.length) {
62
- const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, position + offset);
63
- if (bytesRead <= 0) {
64
- throw new Error("AcePackedStore: unexpected EOF while reading chunk");
65
- }
66
- offset += bytesRead;
67
- }
68
- }
69
- async function writeExact(handle, buffer, position) {
70
- const source = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
71
- let offset = 0;
72
- while (offset < source.length) {
73
- const { bytesWritten } = await handle.write(source, offset, source.length - offset, position + offset);
74
- if (bytesWritten <= 0) {
75
- throw new Error("AcePackedStore: failed to write chunk");
76
- }
77
- offset += bytesWritten;
78
- }
79
- }
80
- function acquireLock(lockPath) {
81
- if (existsSync(lockPath)) {
82
- const raw = readFileSync(lockPath, "utf8");
83
- try {
84
- const rec = JSON.parse(raw);
85
- const age = Date.now() - rec.ts;
86
- if (age < LOCK_TIMEOUT_MS) {
87
- // Check if PID is alive
88
- try {
89
- process.kill(rec.pid, 0);
90
- throw new Error(`AcePackedStore: locked by PID ${rec.pid} (${age}ms ago)`);
91
- }
92
- catch (e) {
93
- // PID not alive — stale lock, fall through to break it
94
- if (e.code !== "ESRCH")
95
- throw e;
96
- }
97
- }
98
- }
99
- catch {
100
- // Unparseable lock — break it
101
- }
102
- }
103
- writeFileSync(lockPath, JSON.stringify({ pid: process.pid, ts: Date.now() }), "utf8");
83
+ function readU32(b, off = 0) {
84
+ return new DataView(b.buffer, b.byteOffset).getUint32(off, false);
104
85
  }
105
- function releaseLock(lockPath) {
106
- if (existsSync(lockPath)) {
107
- try {
108
- unlinkSync(lockPath);
109
- }
110
- catch { /* ignore */ }
111
- }
86
+ function readU64(b, off = 0) {
87
+ const dv = new DataView(b.buffer, b.byteOffset);
88
+ return dv.getUint32(off, false) * 2 ** 32 + dv.getUint32(off + 4, false);
112
89
  }
113
- // ── Header serialisation ──────────────────────────────────────────────────────
114
- function writeHeader(buf, indexOffset, indexLength) {
115
- // magic
90
+ function writeHeaderV2(buf, h) {
116
91
  for (let i = 0; i < 8; i++)
117
92
  buf[i] = MAGIC.charCodeAt(i);
118
- // version
119
- buf.set(uint32ToBytes(STORE_VERSION), 8);
120
- // flags
121
- buf.set(uint32ToBytes(0), 12);
122
- // index_offset
123
- buf.set(uint64ToBytes(indexOffset), 16);
124
- // index_length
125
- buf.set(uint64ToBytes(indexLength), 24);
126
- // reserved: zero-filled (already 0 on new Uint8Array)
93
+ buf.set(u32be(STORE_VERSION), 8);
94
+ buf.set(u32be(0), 12);
95
+ buf.set(u64be(h.kvIndexOffset), H_KV_IDX_OFF);
96
+ buf.set(u64be(h.kvIndexLength), H_KV_IDX_LEN);
97
+ buf.set(u64be(h.kvChunkEnd), H_KV_CHUNK_END);
98
+ buf.set(u64be(h.evtOffset), H_EVT_OFF);
99
+ buf.set(u64be(h.evtLength), H_EVT_LEN);
100
+ buf.set(u32be(h.evtCount), H_EVT_CNT);
101
+ buf.set(u32be(h.evtBaseId), H_EVT_BASE);
127
102
  }
128
- function readHeader(buf) {
103
+ function readHeaderV2(buf) {
129
104
  const magic = DECODE.decode(buf.slice(0, 8));
130
105
  if (magic !== MAGIC)
131
- throw new Error("AcePackedStore: invalid magic bytes — not an ACEPACK file");
106
+ throw new Error("AcePackedStore: invalid magic — not an ACEPACK file");
107
+ const version = readU32(buf, 8);
108
+ const isV2 = version >= 2;
132
109
  return {
133
- version: bytesToUint32(buf, 8),
134
- indexOffset: bytesToUint64(buf, 16),
135
- indexLength: bytesToUint64(buf, 24),
110
+ version,
111
+ kvIndexOffset: readU64(buf, H_KV_IDX_OFF),
112
+ kvIndexLength: readU64(buf, H_KV_IDX_LEN),
113
+ kvChunkEnd: isV2 ? readU64(buf, H_KV_CHUNK_END) : readU64(buf, H_KV_IDX_OFF),
114
+ evtOffset: isV2 ? readU64(buf, H_EVT_OFF) : 0,
115
+ evtLength: isV2 ? readU64(buf, H_EVT_LEN) : 0,
116
+ evtCount: isV2 ? readU32(buf, H_EVT_CNT) : 0,
117
+ evtBaseId: isV2 ? readU32(buf, H_EVT_BASE) : 0,
136
118
  };
137
119
  }
120
+ // ── Columnar event section serialiser ────────────────────────────────────────
121
+ function serializeEventSection(events) {
122
+ const N = events.length;
123
+ const payloadBytes = events.map(e => TEXT.encode(e.blob));
124
+ const poolSize = payloadBytes.reduce((s, b) => s + b.length, 0);
125
+ const pad = (4 - (N % 4)) % 4;
126
+ // Layout: [4] count + [8N] ts + [N] kind + [N] source + [N] flags + [pad]
127
+ // + [4N] pay_off + [4N] pay_len + [4] pool_len + [M] pool
128
+ const size = 4 + 8 * N + N + N + N + pad + 4 * N + 4 * N + 4 + poolSize;
129
+ const buf = new Uint8Array(size);
130
+ const dv = new DataView(buf.buffer);
131
+ let pos = 0;
132
+ dv.setUint32(pos, N, false);
133
+ pos += 4;
134
+ for (const e of events) {
135
+ dv.setUint32(pos, Math.floor(e.ts / 2 ** 32), false);
136
+ dv.setUint32(pos + 4, e.ts >>> 0, false);
137
+ pos += 8;
138
+ }
139
+ for (const e of events)
140
+ buf[pos++] = e.kind;
141
+ for (const e of events)
142
+ buf[pos++] = e.source;
143
+ for (const e of events)
144
+ buf[pos++] = e.flags;
145
+ pos += pad;
146
+ let poolOff = 0;
147
+ for (const pb of payloadBytes) {
148
+ dv.setUint32(pos, poolOff, false);
149
+ pos += 4;
150
+ poolOff += pb.length;
151
+ }
152
+ for (const pb of payloadBytes) {
153
+ dv.setUint32(pos, pb.length, false);
154
+ pos += 4;
155
+ }
156
+ dv.setUint32(pos, poolSize, false);
157
+ pos += 4;
158
+ for (const pb of payloadBytes) {
159
+ buf.set(pb, pos);
160
+ pos += pb.length;
161
+ }
162
+ return buf;
163
+ }
164
+ function deserializeEventSection(buf) {
165
+ if (buf.length < 4)
166
+ return [];
167
+ const dv = new DataView(buf.buffer, buf.byteOffset);
168
+ const N = dv.getUint32(0, false);
169
+ if (N === 0)
170
+ return [];
171
+ let pos = 4;
172
+ const ts = [];
173
+ for (let i = 0; i < N; i++) {
174
+ ts.push(dv.getUint32(pos, false) * 2 ** 32 + dv.getUint32(pos + 4, false));
175
+ pos += 8;
176
+ }
177
+ const kinds = [];
178
+ for (let i = 0; i < N; i++)
179
+ kinds.push(buf[pos++]);
180
+ const sources = [];
181
+ for (let i = 0; i < N; i++)
182
+ sources.push(buf[pos++]);
183
+ const flags = [];
184
+ for (let i = 0; i < N; i++)
185
+ flags.push(buf[pos++]);
186
+ pos += (4 - (N % 4)) % 4; // skip pad
187
+ const payOffs = [];
188
+ for (let i = 0; i < N; i++) {
189
+ payOffs.push(dv.getUint32(pos, false));
190
+ pos += 4;
191
+ }
192
+ const payLens = [];
193
+ for (let i = 0; i < N; i++) {
194
+ payLens.push(dv.getUint32(pos, false));
195
+ pos += 4;
196
+ }
197
+ pos += 4; // pool_length (implicit from payLens sum)
198
+ const poolStart = pos;
199
+ return Array.from({ length: N }, (_, i) => ({
200
+ ts: ts[i],
201
+ kind: kinds[i],
202
+ source: sources[i],
203
+ flags: flags[i],
204
+ blob: DECODE.decode(buf.slice(poolStart + payOffs[i], poolStart + payOffs[i] + payLens[i])),
205
+ }));
206
+ }
207
+ // ── File I/O helpers ──────────────────────────────────────────────────────────
208
+ async function readExact(fh, buf, position) {
209
+ let off = 0;
210
+ while (off < buf.length) {
211
+ const { bytesRead } = await fh.read(buf, off, buf.length - off, position + off);
212
+ if (bytesRead <= 0)
213
+ throw new Error("AcePackedStore: unexpected EOF");
214
+ off += bytesRead;
215
+ }
216
+ }
217
+ async function writeExact(fh, data, position) {
218
+ let off = 0;
219
+ while (off < data.length) {
220
+ const { bytesWritten } = await fh.write(data, off, data.length - off, position + off);
221
+ if (bytesWritten <= 0)
222
+ throw new Error("AcePackedStore: write failed");
223
+ off += bytesWritten;
224
+ }
225
+ }
138
226
  // ── AcePackedStore ────────────────────────────────────────────────────────────
139
227
  export class AcePackedStore {
140
228
  storePath = "";
141
- lockPath = "";
142
229
  readOnly = false;
143
- index = new Map();
144
- chunkEnd = HEADER_SIZE; // byte offset immediately after last chunk
145
230
  fh = null;
146
- dirty = false;
147
- entryCounter = 0;
148
- // ── Lifecycle ─────────────────────────────────────────────────────────────
231
+ // KV region
232
+ kvIndex = new Map();
233
+ kvChunkEnd = HEADER_SIZE; // end of KV chunk region in file
234
+ // Event log (in-memory)
235
+ committed = []; // loaded from file
236
+ pending = []; // written since last commit
237
+ evtBaseId = 0; // events [0..evtBaseId] are in the archive
238
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
149
239
  async open(path, opts = {}) {
150
240
  this.storePath = path;
151
- this.lockPath = path + ".lock";
152
241
  this.readOnly = opts.readOnly ?? false;
153
- if (!this.readOnly) {
154
- acquireLock(this.lockPath);
155
- }
156
242
  if (!existsSync(path)) {
157
243
  if (this.readOnly)
158
- throw new Error(`AcePackedStore: store not found at ${path}`);
159
- await this._initNewStore(path);
244
+ throw new Error(`AcePackedStore: file not found: ${path}`);
245
+ await this._initNew(path);
160
246
  }
161
247
  else {
162
- await this._loadExistingStore(path);
248
+ await this._loadExisting(path);
163
249
  }
164
250
  this.fh = await fsOpen(path, this.readOnly ? "r" : "r+");
165
251
  }
166
- async _initNewStore(path) {
252
+ async _initNew(path) {
167
253
  mkdirSync(dirname(path), { recursive: true });
254
+ const evtBytes = serializeEventSection([]);
255
+ const idxBytes = TEXT.encode("{}");
256
+ const evtOff = HEADER_SIZE;
257
+ const kvIdxOff = evtOff + evtBytes.length;
258
+ const totalSize = kvIdxOff + idxBytes.length;
168
259
  const header = new Uint8Array(HEADER_SIZE);
169
- // Index starts immediately after header (empty at init)
170
- const emptyIndex = TEXT.encode("{}");
171
- writeHeader(header, HEADER_SIZE, emptyIndex.length);
172
- const buf = new Uint8Array(HEADER_SIZE + emptyIndex.length);
173
- buf.set(header);
174
- buf.set(emptyIndex, HEADER_SIZE);
175
- writeFileSync(path, buf);
176
- this.chunkEnd = HEADER_SIZE;
177
- this.index = new Map();
178
- }
179
- async _loadExistingStore(path) {
260
+ writeHeaderV2(header, {
261
+ kvIndexOffset: kvIdxOff,
262
+ kvIndexLength: idxBytes.length,
263
+ kvChunkEnd: HEADER_SIZE,
264
+ evtOffset: evtOff,
265
+ evtLength: evtBytes.length,
266
+ evtCount: 0,
267
+ evtBaseId: 0,
268
+ });
269
+ const out = new Uint8Array(totalSize);
270
+ out.set(header, 0);
271
+ out.set(evtBytes, evtOff);
272
+ out.set(idxBytes, kvIdxOff);
273
+ writeFileSync(path, out);
274
+ this.kvChunkEnd = HEADER_SIZE;
275
+ this.kvIndex = new Map();
276
+ this.committed = [];
277
+ this.pending = [];
278
+ this.evtBaseId = 0;
279
+ }
280
+ async _loadExisting(path) {
180
281
  const file = readFileSync(path);
181
- if (file.length < HEADER_SIZE)
282
+ if (file.length < HEADER_SIZE_V1)
182
283
  throw new Error("AcePackedStore: file too short");
183
- const { indexOffset, indexLength } = readHeader(file.slice(0, HEADER_SIZE));
184
- const idxBytes = file.slice(indexOffset, indexOffset + indexLength);
185
- const idxJson = JSON.parse(DECODE.decode(idxBytes));
186
- this.index = new Map(Object.entries(idxJson));
187
- // chunkEnd = start of index region (chunks live between header and index)
188
- this.chunkEnd = indexOffset;
189
- // Re-derive entry counter from unified log entries
190
- const logKeys = [...this.index.keys()].filter(k => k.startsWith("core/log/"));
191
- this.entryCounter = logKeys.length;
284
+ const hdr = readHeaderV2(file.slice(0, Math.max(HEADER_SIZE, file.length)));
285
+ const isV1 = hdr.version < 2;
286
+ // Load KV index
287
+ const idxRaw = file.slice(hdr.kvIndexOffset, hdr.kvIndexOffset + hdr.kvIndexLength);
288
+ const idxObj = JSON.parse(DECODE.decode(idxRaw));
289
+ this.kvIndex = new Map(Object.entries(idxObj));
290
+ this.kvChunkEnd = hdr.kvChunkEnd;
291
+ if (isV1) {
292
+ // v1 → v2 migration: pull events from core/log/ KV entries
293
+ const logKeys = [...this.kvIndex.keys()]
294
+ .filter(k => k.startsWith("core/log/") && k !== "core/log/__seq")
295
+ .sort();
296
+ this.committed = [];
297
+ for (const lk of logKeys) {
298
+ const entry = await this._readKvBlobDirect(file, lk);
299
+ if (!entry)
300
+ continue;
301
+ try {
302
+ const e = JSON.parse(entry);
303
+ this.committed.push({
304
+ ts: e.ts,
305
+ kind: e.kind,
306
+ source: e.content_source,
307
+ flags: e.flags ?? 0,
308
+ blob: JSON.stringify({ key: e.key, payload: e.payload, parent_id: e.parent_id }),
309
+ });
310
+ }
311
+ catch { /* skip malformed */ }
312
+ }
313
+ // Remove core/log/ and meta/log_entry_seq from KV (they go to columnar section)
314
+ for (const k of [...this.kvIndex.keys()]) {
315
+ if (k.startsWith("core/log/") || k === "meta/log_entry_seq")
316
+ this.kvIndex.delete(k);
317
+ }
318
+ this.evtBaseId = 0;
319
+ // force commit to write v2 format (pending is non-empty from migration)
320
+ }
321
+ else {
322
+ // v2: load event section
323
+ if (hdr.evtLength > 0) {
324
+ const evtBuf = file.slice(hdr.evtOffset, hdr.evtOffset + hdr.evtLength);
325
+ this.committed = deserializeEventSection(evtBuf);
326
+ }
327
+ else {
328
+ this.committed = [];
329
+ }
330
+ this.evtBaseId = hdr.evtBaseId;
331
+ }
332
+ this.pending = [];
333
+ }
334
+ /** Read a KV blob directly from a loaded file buffer (used during migration). */
335
+ async _readKvBlobDirect(file, key) {
336
+ const entry = this.kvIndex.get(key);
337
+ if (!entry)
338
+ return undefined;
339
+ const start = entry.offset + 4;
340
+ const end = start + entry.length;
341
+ if (end > file.length)
342
+ return undefined;
343
+ return DECODE.decode(file.slice(start, end));
192
344
  }
193
345
  async commit() {
194
- if (this.readOnly || !this.fh || !this.dirty)
346
+ if (this.readOnly || !this.fh)
195
347
  return;
196
- const idxJson = JSON.stringify(Object.fromEntries(this.index));
348
+ // Merge pending into committed
349
+ this.committed.push(...this.pending);
350
+ this.pending = [];
351
+ // Serialize event section
352
+ const evtBytes = serializeEventSection(this.committed);
353
+ // Write KV index after event section
354
+ const idxJson = JSON.stringify(Object.fromEntries(this.kvIndex));
197
355
  const idxBytes = TEXT.encode(idxJson);
198
- // Write index at current chunkEnd
199
- await writeExact(this.fh, idxBytes, this.chunkEnd);
356
+ const evtOff = this.kvChunkEnd;
357
+ const kvIdxOff = evtOff + evtBytes.length;
358
+ await writeExact(this.fh, evtBytes, evtOff);
359
+ await writeExact(this.fh, idxBytes, kvIdxOff);
360
+ await this.fh.truncate(kvIdxOff + idxBytes.length);
200
361
  // Update header
201
362
  const header = new Uint8Array(HEADER_SIZE);
202
- writeHeader(header, this.chunkEnd, idxBytes.length);
363
+ writeHeaderV2(header, {
364
+ kvIndexOffset: kvIdxOff,
365
+ kvIndexLength: idxBytes.length,
366
+ kvChunkEnd: this.kvChunkEnd,
367
+ evtOffset: evtOff,
368
+ evtLength: evtBytes.length,
369
+ evtCount: this.committed.length,
370
+ evtBaseId: this.evtBaseId,
371
+ });
203
372
  await writeExact(this.fh, header, 0);
204
- // Truncate any stale trailing bytes
205
- await this.fh.truncate(this.chunkEnd + idxBytes.length);
206
373
  await this.fh.datasync();
207
- this.dirty = false;
208
374
  }
375
+ /**
376
+ * Compacts the KV chunk region — removes dead space left by overwritten keys.
377
+ * Events are NOT reset or archived; they accumulate in the columnar section
378
+ * for the workspace lifetime (Option A — grow forever).
379
+ * See HOT_COLD_EVENT_TIERING.md for the future branch that adds BGZF archiving.
380
+ */
209
381
  async compact() {
210
- if (!this.storePath)
382
+ if (!this.storePath || this.readOnly)
211
383
  return;
212
- const lockPath = this.lockPath;
213
- const tmpPath = this.storePath + ".tmp";
214
- // Write live chunks to temp file
215
- const liveIndex = new Map();
216
- let writeOffset = HEADER_SIZE;
384
+ // Flush pending events into committed so they survive the rewrite
385
+ this.committed.push(...this.pending);
386
+ this.pending = [];
387
+ // Rebuild KV chunk region with only live keys (removes dead space)
217
388
  const srcFile = readFileSync(this.storePath);
389
+ const liveKv = new Map();
218
390
  const chunks = [];
219
- for (const [key, { offset, length }] of this.index) {
220
- // offset points to the uint32 length prefix; skip 4 bytes to get pure data
221
- const chunk = srcFile.slice(offset + 4, offset + 4 + length);
222
- chunks.push(chunk);
223
- liveIndex.set(key, { offset: writeOffset, length });
224
- writeOffset += 4 + length; // uint32 length prefix + data
391
+ let writeOff = HEADER_SIZE;
392
+ for (const [key, { offset, length }] of this.kvIndex) {
393
+ chunks.push(srcFile.slice(offset + 4, offset + 4 + length));
394
+ liveKv.set(key, { offset: writeOff, length });
395
+ writeOff += 4 + length;
225
396
  }
226
- const idxJson = JSON.stringify(Object.fromEntries(liveIndex));
227
- const idxBytes = TEXT.encode(idxJson);
228
- const totalSize = HEADER_SIZE + chunks.reduce((s, c) => s + 4 + c.length, 0) + idxBytes.length;
397
+ this.kvIndex = liveKv;
398
+ this.kvChunkEnd = writeOff;
399
+ // Serialize events (preserved in full) + new KV index
400
+ const evtBytes = serializeEventSection(this.committed);
401
+ const idxBytes = TEXT.encode(JSON.stringify(Object.fromEntries(liveKv)));
402
+ const evtOff = writeOff;
403
+ const kvIdxOff = evtOff + evtBytes.length;
404
+ const totalSize = kvIdxOff + idxBytes.length;
229
405
  const out = new Uint8Array(totalSize);
230
406
  const header = new Uint8Array(HEADER_SIZE);
231
- writeHeader(header, writeOffset, idxBytes.length);
232
- out.set(header);
407
+ writeHeaderV2(header, {
408
+ kvIndexOffset: kvIdxOff,
409
+ kvIndexLength: idxBytes.length,
410
+ kvChunkEnd: writeOff,
411
+ evtOffset: evtOff,
412
+ evtLength: evtBytes.length,
413
+ evtCount: this.committed.length,
414
+ evtBaseId: this.evtBaseId,
415
+ });
416
+ out.set(header, 0);
233
417
  let pos = HEADER_SIZE;
234
418
  for (const chunk of chunks) {
235
- out.set(uint32ToBytes(chunk.length), pos);
236
- out.set(chunk, pos + 4);
237
- pos += 4 + chunk.length;
419
+ out.set(u32be(chunk.length), pos);
420
+ pos += 4;
421
+ out.set(chunk, pos);
422
+ pos += chunk.length;
238
423
  }
239
- out.set(idxBytes, pos);
424
+ out.set(evtBytes, evtOff);
425
+ out.set(idxBytes, kvIdxOff);
426
+ const tmpPath = this.storePath + ".tmp";
240
427
  writeFileSync(tmpPath, out);
241
- // Validate temp file magic
242
- const tmpBuf = readFileSync(tmpPath);
243
- readHeader(tmpBuf.slice(0, HEADER_SIZE)); // throws if invalid
244
- // Atomic rename
428
+ readHeaderV2(readFileSync(tmpPath).slice(0, HEADER_SIZE)); // validate
245
429
  if (this.fh) {
246
430
  await this.fh.close();
247
431
  this.fh = null;
248
432
  }
249
433
  renameSync(tmpPath, this.storePath);
250
- releaseLock(lockPath);
251
- acquireLock(lockPath);
252
434
  this.fh = await fsOpen(this.storePath, "r+");
253
- this.index = liveIndex;
254
- this.chunkEnd = writeOffset;
255
- this.dirty = false;
256
435
  }
257
436
  async close() {
258
437
  await this.commit();
@@ -260,83 +439,95 @@ export class AcePackedStore {
260
439
  await this.fh.close();
261
440
  this.fh = null;
262
441
  }
263
- if (!this.readOnly)
264
- releaseLock(this.lockPath);
265
442
  }
266
- // ── Zarrita protocol ──────────────────────────────────────────────────────
443
+ // ── Core KV ─────────────────────────────────────────────────────────────────
267
444
  async get(key) {
268
- const entry = this.index.get(key);
269
- if (!entry)
445
+ const entry = this.kvIndex.get(key);
446
+ if (!entry || !this.fh)
270
447
  return undefined;
271
- if (!this.fh)
272
- throw new Error("AcePackedStore: store not open");
273
- const dataBuf = Buffer.alloc(entry.length);
274
- await readExact(this.fh, dataBuf, entry.offset + 4);
275
- return dataBuf;
448
+ const buf = new Uint8Array(entry.length);
449
+ await readExact(this.fh, buf, entry.offset + 4);
450
+ return buf;
276
451
  }
277
452
  async set(key, value) {
278
453
  if (this.readOnly)
279
- throw new Error("AcePackedStore: cannot write — store opened read-only");
454
+ throw new Error("AcePackedStore: read-only");
280
455
  if (!this.fh)
281
- throw new Error("AcePackedStore: store not open");
282
- const lenPrefix = uint32ToBytes(value.length);
283
- const chunkOffset = this.chunkEnd;
284
- await writeExact(this.fh, lenPrefix, chunkOffset);
285
- await writeExact(this.fh, value, chunkOffset + 4);
286
- this.index.set(key, { offset: chunkOffset, length: value.length });
287
- this.chunkEnd += 4 + value.length;
288
- this.dirty = true;
456
+ throw new Error("AcePackedStore: not open");
457
+ // Append chunk to KV chunk region (at kvChunkEnd)
458
+ await writeExact(this.fh, u32be(value.length), this.kvChunkEnd);
459
+ await writeExact(this.fh, value, this.kvChunkEnd + 4);
460
+ this.kvIndex.set(key, { offset: this.kvChunkEnd, length: value.length });
461
+ this.kvChunkEnd += 4 + value.length;
289
462
  }
290
463
  async delete(key) {
291
- const existed = this.index.has(key);
292
- this.index.delete(key);
293
- if (existed)
294
- this.dirty = true;
464
+ const existed = this.kvIndex.has(key);
465
+ this.kvIndex.delete(key);
295
466
  return existed;
296
467
  }
297
468
  async *list() {
298
- for (const key of this.index.keys())
469
+ for (const key of this.kvIndex.keys())
299
470
  yield key;
300
471
  }
301
- // ── JSON / Blob convenience ───────────────────────────────────────────────
472
+ // ── JSON / Blob convenience ──────────────────────────────────────────────────
302
473
  async getJSON(key) {
303
474
  const raw = await this.get(key);
304
475
  if (!raw)
305
476
  return undefined;
306
- return JSON.parse(DECODE.decode(raw));
477
+ try {
478
+ return JSON.parse(DECODE.decode(raw));
479
+ }
480
+ catch {
481
+ return undefined;
482
+ }
307
483
  }
308
484
  async setJSON(key, value) {
309
485
  await this.set(key, TEXT.encode(JSON.stringify(value)));
310
486
  }
311
487
  async getBlob(key) {
312
488
  const raw = await this.get(key);
313
- if (!raw)
314
- return undefined;
315
- return DECODE.decode(raw);
489
+ return raw ? DECODE.decode(raw) : undefined;
316
490
  }
317
491
  async setBlob(key, content) {
318
492
  await this.set(key, TEXT.encode(content));
319
493
  }
320
- // ── Unified entry log ─────────────────────────────────────────────────────
494
+ // ── Unified event log ────────────────────────────────────────────────────────
321
495
  async appendEntry(entry) {
322
- const seqKey = "meta/log_entry_seq";
323
- const current = (await this.getJSON(seqKey)) ?? this.entryCounter;
324
- const id = current + 1;
325
- this.entryCounter = id;
326
- await this.setJSON(seqKey, id);
327
- const full = { ...entry, id, ts: Date.now() };
328
- const logKey = `core/log/${String(id).padStart(12, "0")}`;
329
- await this.setJSON(logKey, full);
330
- return full;
496
+ const id = this.evtBaseId + this.committed.length + this.pending.length + 1;
497
+ const ts = Date.now();
498
+ const rec = {
499
+ ts,
500
+ kind: entry.kind,
501
+ source: entry.content_source,
502
+ flags: entry.flags ?? 0,
503
+ blob: JSON.stringify({ key: entry.key, payload: entry.payload, parent_id: entry.parent_id }),
504
+ };
505
+ this.pending.push(rec);
506
+ return { id, ts, kind: entry.kind, content_source: entry.content_source, key: entry.key, payload: entry.payload, flags: entry.flags };
331
507
  }
332
508
  async getEntries(filter) {
509
+ const all = [...this.committed, ...this.pending];
333
510
  const results = [];
334
- for (const key of this.index.keys()) {
335
- if (!key.startsWith("core/log/") || key === "core/log/__seq")
336
- continue;
337
- const entry = await this.getJSON(key);
338
- if (!entry)
511
+ let id = this.evtBaseId + 1;
512
+ for (const rec of all) {
513
+ let parsed;
514
+ try {
515
+ parsed = JSON.parse(rec.blob);
516
+ }
517
+ catch {
518
+ id++;
339
519
  continue;
520
+ }
521
+ const entry = {
522
+ id: id++,
523
+ ts: rec.ts,
524
+ kind: rec.kind,
525
+ content_source: rec.source,
526
+ key: parsed.key,
527
+ payload: parsed.payload,
528
+ parent_id: parsed.parent_id,
529
+ flags: rec.flags,
530
+ };
340
531
  if (filter?.kind !== undefined && entry.kind !== filter.kind)
341
532
  continue;
342
533
  if (filter?.content_source !== undefined && entry.content_source !== filter.content_source)
@@ -349,80 +540,76 @@ export class AcePackedStore {
349
540
  continue;
350
541
  results.push(entry);
351
542
  }
352
- return results.sort((a, b) => a.id - b.id);
543
+ return results;
353
544
  }
354
- async getEntry(key) {
355
- return this.getJSON(key);
545
+ async getEntry(_key) {
546
+ // Events are no longer in KV — scan by id if key is a core/log/ path
547
+ const match = _key.match(/^core\/log\/(\d+)$/);
548
+ if (!match)
549
+ return undefined;
550
+ const targetId = parseInt(match[1], 10);
551
+ const all = await this.getEntries();
552
+ return all.find(e => e.id === targetId);
356
553
  }
357
- // ── Topology ──────────────────────────────────────────────────────────────
554
+ // ── Topology ─────────────────────────────────────────────────────────────────
358
555
  async getTopology(kind) {
359
556
  return (await this.getJSON(`topology/${kind}`)) ?? [];
360
557
  }
361
558
  async setTopology(kind, entries) {
362
559
  await this.setJSON(`topology/${kind}`, entries);
363
560
  }
364
- // ── Knowledge: agents ─────────────────────────────────────────────────────
561
+ // ── Knowledge: agents ────────────────────────────────────────────────────────
365
562
  async getAgentInstruction(agent, file) {
366
563
  return this.getBlob(`knowledge/agents/${agent}/${file}`);
367
564
  }
368
565
  async setAgentInstruction(agent, file, content, source = ContentSource.Package) {
369
566
  const key = `knowledge/agents/${agent}/${file}`;
370
567
  await this.setBlob(key, content);
371
- await this.appendEntry({
372
- kind: EntityKind.AgentInstruction,
373
- content_source: source,
374
- key,
375
- payload: { agent, file, length: content.length },
376
- });
568
+ await this.appendEntry({ kind: EntityKind.AgentInstruction, content_source: source, key, payload: { agent, file, length: content.length } });
377
569
  }
378
570
  async listAgents() {
379
571
  const agents = new Set();
380
- for (const key of this.index.keys()) {
381
- if (key.startsWith("knowledge/agents/")) {
382
- const parts = key.split("/");
383
- if (parts[2])
384
- agents.add(parts[2]);
572
+ for (const k of this.kvIndex.keys()) {
573
+ if (k.startsWith("knowledge/agents/")) {
574
+ const p = k.split("/");
575
+ if (p[2])
576
+ agents.add(p[2]);
385
577
  }
386
578
  }
387
579
  return [...agents].sort();
388
580
  }
389
- // ── Knowledge: skills ─────────────────────────────────────────────────────
581
+ // ── Knowledge: skills ────────────────────────────────────────────────────────
390
582
  async getSkillContent(skill, file) {
391
583
  return this.getBlob(`knowledge/skills/${skill}/${file}`);
392
584
  }
393
585
  async setSkillContent(skill, file, content, source = ContentSource.Package) {
394
586
  const key = `knowledge/skills/${skill}/${file}`;
395
587
  await this.setBlob(key, content);
396
- await this.appendEntry({
397
- kind: EntityKind.SkillDefinition,
398
- content_source: source,
399
- key,
400
- payload: { skill, file, length: content.length },
401
- });
588
+ await this.appendEntry({ kind: EntityKind.SkillDefinition, content_source: source, key, payload: { skill, file, length: content.length } });
402
589
  }
403
590
  async listSkills() {
404
591
  const skills = new Set();
405
- for (const key of this.index.keys()) {
406
- if (key.startsWith("knowledge/skills/")) {
407
- const parts = key.split("/");
408
- if (parts[2])
409
- skills.add(parts[2]);
592
+ for (const k of this.kvIndex.keys()) {
593
+ if (k.startsWith("knowledge/skills/")) {
594
+ const p = k.split("/");
595
+ if (p[2])
596
+ skills.add(p[2]);
410
597
  }
411
598
  }
412
599
  return [...skills].sort();
413
600
  }
414
- // ── Internal helpers ──────────────────────────────────────────────────────
415
- /** Dead space ratio used to decide if compaction is needed */
601
+ // ── Introspection ─────────────────────────────────────────────────────────────
602
+ /** Dead space ratio in the KV chunk region. Used to decide if compaction is needed. */
416
603
  get deadSpaceRatio() {
417
- if (!existsSync(this.storePath))
604
+ const totalKv = this.kvChunkEnd - HEADER_SIZE;
605
+ if (totalKv <= 0)
418
606
  return 0;
419
- const totalSize = this.chunkEnd;
420
- const liveSize = HEADER_SIZE + [...this.index.values()].reduce((s, e) => s + 4 + e.length, 0);
421
- return totalSize > 0 ? 1 - liveSize / totalSize : 0;
607
+ const liveKv = [...this.kvIndex.values()].reduce((s, e) => s + 4 + e.length, 0);
608
+ return Math.max(0, 1 - liveKv / totalKv);
422
609
  }
423
- /** Number of entries in the unified log */
610
+ /** Total events in log (committed + pending). */
424
611
  get entryCount() {
425
- return this.entryCounter;
612
+ return this.committed.length + this.pending.length;
426
613
  }
427
614
  }
428
615
  // ── Factory ────────────────────────────────────────────────────────────────────