@yeaft/webchat-agent 0.1.519 → 0.1.521

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * atomic.js — Crash-safe single-file writes.
3
+ *
4
+ * task-334o §Δ15 / §Δ22 §Δ23 shard-store foundation.
5
+ *
6
+ * Contract: writeAtomic(path, data) never leaves a half-written file at `path`.
7
+ * If the process crashes at any point, `path` is either the pre-existing content
8
+ * or the new content — never a torn mix. Leftover `*.tmp.<pid>.<n>` files are
9
+ * the only debris; they are safe to delete on boot (see sweepTmp()).
10
+ *
11
+ * Implementation:
12
+ * 1. Write bytes to `path.tmp.<pid>.<counter>` via writeFileSync.
13
+ * 2. fsync the tmp file (force bytes to disk before rename).
14
+ * 3. rename(tmp, path) — POSIX-atomic on same filesystem.
15
+ * 4. fsync the parent dir (persist the rename itself).
16
+ *
17
+ * Step 4 is what most naive "atomic write" implementations skip. Without it,
18
+ * a crash after the rename call returns can still lose the rename on ext4
19
+ * with data=ordered. We do the dir fsync on Linux/macOS; on Windows we skip
20
+ * (fsync on a directory is an error there) and accept the minor risk window.
21
+ *
22
+ * This module has no knowledge of VP/task/message — it's a pure primitive.
23
+ */
24
+
25
+ import {
26
+ writeFileSync,
27
+ renameSync,
28
+ openSync,
29
+ fsyncSync,
30
+ closeSync,
31
+ existsSync,
32
+ unlinkSync,
33
+ readdirSync,
34
+ } from 'fs';
35
+ import { dirname, basename, join } from 'path';
36
+
37
+ let tmpCounter = 0;
38
+
39
+ /**
40
+ * Atomically write `data` (string | Buffer) to `path`.
41
+ * Throws on failure; never leaves `path` in a half-written state.
42
+ */
43
+ export function writeAtomic(path, data) {
44
+ const dir = dirname(path);
45
+ const tmpPath = `${path}.tmp.${process.pid}.${++tmpCounter}`;
46
+
47
+ writeFileSync(tmpPath, data);
48
+
49
+ // fsync the tmp file so the bytes hit disk before we swap.
50
+ try {
51
+ const fd = openSync(tmpPath, 'r+');
52
+ try {
53
+ fsyncSync(fd);
54
+ } finally {
55
+ closeSync(fd);
56
+ }
57
+ } catch {
58
+ // Best-effort; some filesystems / platforms don't support fsync on a file
59
+ // opened r+. The rename below is still the atomic boundary.
60
+ }
61
+
62
+ renameSync(tmpPath, path);
63
+
64
+ // fsync the parent directory so the rename is durable.
65
+ // Windows: cannot fsync a directory; skip.
66
+ if (process.platform !== 'win32') {
67
+ try {
68
+ const dfd = openSync(dir, 'r');
69
+ try {
70
+ fsyncSync(dfd);
71
+ } finally {
72
+ closeSync(dfd);
73
+ }
74
+ } catch {
75
+ // Directory fsync is best-effort. A failure here does not invalidate
76
+ // the rename itself; it only weakens durability on power loss.
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Remove any leftover `*.tmp.*` files in `dir` from a previous crashed write.
83
+ * Safe to call on boot. Returns the count removed.
84
+ *
85
+ * Only matches the specific `<basename>.tmp.<pid>.<counter>` shape — won't
86
+ * touch user files that happen to end in `.tmp`.
87
+ */
88
+ export function sweepTmp(dir) {
89
+ if (!existsSync(dir)) return 0;
90
+ let removed = 0;
91
+ for (const name of readdirSync(dir)) {
92
+ if (/\.tmp\.\d+\.\d+$/.test(name)) {
93
+ try {
94
+ unlinkSync(join(dir, name));
95
+ removed++;
96
+ } catch {
97
+ // Ignore — another process may have beaten us to it.
98
+ }
99
+ }
100
+ }
101
+ return removed;
102
+ }
103
+
104
+ /** Check whether a given file path looks like our tmp sidecar. Test helper. */
105
+ export function isTmpPath(path) {
106
+ return /\.tmp\.\d+\.\d+$/.test(basename(path));
107
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * compact.js — External compaction entry point for 334g (dream).
3
+ *
4
+ * `runCompact({ dir, threshold, schema })` opens a shard store at `dir`,
5
+ * inspects each shard for softCap breach, and rewrites shard files that
6
+ * need it. Unlike the internal `compactShard` used by `remove()`, this is
7
+ * the call dream uses to periodically reclaim space across the whole store.
8
+ *
9
+ * This module does NOT:
10
+ * - decide what's stale enough to delete (that's dream's job, using
11
+ * `supersededBy` chains)
12
+ * - re-score or re-rank entries
13
+ * - touch memory files belonging to other stores (it only touches `dir`)
14
+ *
15
+ * Parameters:
16
+ * dir : shard-store directory
17
+ * schema : shard schema (shards[], softCap)
18
+ * threshold : optional override — compact any shard where
19
+ * entries >= threshold.entries || bytes >= threshold.bytes
20
+ * (if omitted, uses the schema's softCap)
21
+ * deleteIds : optional array of ids that dream has decided to purge
22
+ * (lets dream do "compact + delete" in one pass)
23
+ *
24
+ * Returns:
25
+ * { compacted: [shardName, ...], deleted: [id, ...], stillOver: [shardName, ...] }
26
+ */
27
+
28
+ import { openShardStore } from './shard-store.js';
29
+
30
+ export async function runCompact({ dir, schema = {}, threshold, deleteIds = [] } = {}) {
31
+ if (!dir) throw new Error('runCompact: dir required');
32
+ const store = openShardStore(dir, schema);
33
+ const stats = store.stats();
34
+ const compacted = [];
35
+ const deleted = [];
36
+ const stillOver = [];
37
+
38
+ // First pass: honour explicit deletions (dream hands us a hitlist).
39
+ const byShard = new Map();
40
+ for (const id of deleteIds) {
41
+ const entry = store.getIndex().entries.find((e) => e.id === id);
42
+ if (!entry) continue;
43
+ const list = byShard.get(entry.shard) || [];
44
+ list.push(id);
45
+ byShard.set(entry.shard, list);
46
+ }
47
+
48
+ for (const [shardName] of byShard) {
49
+ // Use public remove() which already calls the internal compacter.
50
+ for (const id of byShard.get(shardName)) {
51
+ if (store.remove(id)) deleted.push(id);
52
+ }
53
+ compacted.push(shardName);
54
+ }
55
+
56
+ // Second pass: defrag shards whose size still exceeds the threshold.
57
+ const thr = threshold || {};
58
+ for (const shardName of Object.keys(stats.shards)) {
59
+ if (compacted.includes(shardName)) continue;
60
+ const bucket = store.stats().shards[shardName];
61
+ if (!bucket) continue;
62
+ const cap = thr.entries != null || thr.bytes != null
63
+ ? thr
64
+ : (schema.softCap?.[shardName] || schema.defaultSoftCap);
65
+ if (!cap) continue;
66
+ const overEntries = cap.entries != null && bucket.entries > cap.entries;
67
+ const overBytes = cap.bytes != null && bucket.bytes > cap.bytes;
68
+ if (overEntries || overBytes) {
69
+ store.compact(shardName);
70
+ compacted.push(shardName);
71
+ // Re-read stats after compaction; if still over, surface to caller.
72
+ const newBucket = store.stats().shards[shardName];
73
+ if (newBucket.entries > (cap.entries ?? Infinity) || newBucket.bytes > (cap.bytes ?? Infinity)) {
74
+ stillOver.push(shardName);
75
+ }
76
+ }
77
+ }
78
+
79
+ return { compacted, deleted, stillOver };
80
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * storage/ — task-334o Storage Layer v1.
3
+ *
4
+ * Business-semantics-free primitives shared by 334b (group messages),
5
+ * 334f (VP memory), 334l (user memory), 334i (migration), 334n (summaries).
6
+ *
7
+ * Modules:
8
+ * - atomic : writeAtomic (tmp → rename + fsync)
9
+ * - jsonl-log : append-only log with size/line rotation
10
+ * - jsonl-index : segment manifest for jsonl-log
11
+ * - shard-store : schema-aware shard storage (get/put/query/remove/compact)
12
+ * - shard-index : manifest for shard-store
13
+ * - compact : external compaction entry point for dream
14
+ *
15
+ * API stability: 334o freezes its public API. Downstream slices must not
16
+ * request new fields — extensions go through `shard-schema.js` versioning
17
+ * (see slice spec §framework §2).
18
+ */
19
+
20
+ export { writeAtomic, sweepTmp, isTmpPath } from './atomic.js';
21
+ export { openLog } from './jsonl-log.js';
22
+ export {
23
+ emptyIndex,
24
+ loadIndex,
25
+ saveIndex,
26
+ listSegmentFiles,
27
+ statSegmentFromDisk,
28
+ nextSegmentName,
29
+ INDEX_FILE,
30
+ INDEX_VERSION,
31
+ } from './jsonl-index.js';
32
+ export { openShardStore } from './shard-store.js';
33
+ export {
34
+ emptyShardIndex,
35
+ loadShardIndex,
36
+ saveShardIndex,
37
+ rebuildShardIndexFromDisk,
38
+ shardFileName,
39
+ START_MARK,
40
+ END_MARK,
41
+ SHARD_INDEX_FILE,
42
+ SHARD_INDEX_VERSION,
43
+ } from './shard-index.js';
44
+ export { runCompact } from './compact.js';
@@ -0,0 +1,122 @@
1
+ /**
2
+ * jsonl-index.js — Manifest for a jsonl-log directory.
3
+ *
4
+ * The index file tracks which segment files cover which ID/timestamp ranges
5
+ * so `jsonl-log.readRange` can do O(1) segment selection without scanning
6
+ * the whole log. It's rewritten atomically on every rotation.
7
+ *
8
+ * Schema (index.json):
9
+ * {
10
+ * version: 1,
11
+ * nextId: <number | null>, // optional — caller-managed id counter
12
+ * segments: [
13
+ * { file:"000001.jsonl", firstId, lastId, firstTs, lastTs, count, bytes }
14
+ * ]
15
+ * }
16
+ *
17
+ * This module owns ONLY the manifest — it does not read or write the jsonl
18
+ * segments themselves. jsonl-log.js drives rotation and hands us updated
19
+ * segment metadata.
20
+ *
21
+ * No business semantics. "id" and "ts" are opaque; we don't care if they're
22
+ * msg_xxx, mem_xxx, numeric, or empty.
23
+ */
24
+
25
+ import { readFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
26
+ import { join } from 'path';
27
+ import { writeAtomic } from './atomic.js';
28
+
29
+ export const INDEX_FILE = 'index.json';
30
+ export const INDEX_VERSION = 1;
31
+
32
+ /** Build an empty manifest. */
33
+ export function emptyIndex() {
34
+ return { version: INDEX_VERSION, nextId: null, segments: [] };
35
+ }
36
+
37
+ /**
38
+ * Load the index from `dir/index.json`. Returns `null` if the file is missing,
39
+ * unreadable, or corrupt (caller should rebuild). Does NOT auto-rebuild —
40
+ * the caller decides whether a missing / corrupt index is fatal.
41
+ */
42
+ export function loadIndex(dir) {
43
+ const path = join(dir, INDEX_FILE);
44
+ if (!existsSync(path)) return null;
45
+ try {
46
+ const raw = readFileSync(path, 'utf8');
47
+ const parsed = JSON.parse(raw);
48
+ if (!parsed || typeof parsed !== 'object') return null;
49
+ if (!Array.isArray(parsed.segments)) return null;
50
+ return {
51
+ version: parsed.version || INDEX_VERSION,
52
+ nextId: parsed.nextId ?? null,
53
+ segments: parsed.segments,
54
+ };
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /** Atomically persist an index. Creates `dir` if missing. */
61
+ export function saveIndex(dir, index) {
62
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
63
+ const payload = JSON.stringify({
64
+ version: INDEX_VERSION,
65
+ nextId: index.nextId ?? null,
66
+ segments: index.segments,
67
+ }, null, 2);
68
+ writeAtomic(join(dir, INDEX_FILE), payload);
69
+ }
70
+
71
+ /**
72
+ * List *.jsonl segment files in `dir`, sorted lexicographically.
73
+ * Returns names only, not full paths.
74
+ */
75
+ export function listSegmentFiles(dir) {
76
+ if (!existsSync(dir)) return [];
77
+ return readdirSync(dir)
78
+ .filter((n) => /^\d+\.jsonl$/.test(n))
79
+ .sort();
80
+ }
81
+
82
+ /**
83
+ * Produce an index entry by reading a segment file off disk. Used when
84
+ * recovering from a missing/corrupt index.json.
85
+ *
86
+ * Caller provides `parseLine(line) -> { id, ts }` so we can populate the
87
+ * firstId/lastId/firstTs/lastTs fields without this module knowing the schema.
88
+ * Malformed lines are skipped (best-effort rebuild).
89
+ */
90
+ export function statSegmentFromDisk(dir, fileName, parseLine) {
91
+ const path = join(dir, fileName);
92
+ const bytes = statSync(path).size;
93
+ const raw = readFileSync(path, 'utf8');
94
+ const lines = raw.split('\n').filter((l) => l.length > 0);
95
+ let firstId = null, lastId = null, firstTs = null, lastTs = null;
96
+ let count = 0;
97
+ for (const line of lines) {
98
+ let parsed;
99
+ try {
100
+ parsed = parseLine(line);
101
+ } catch {
102
+ continue;
103
+ }
104
+ if (!parsed) continue;
105
+ count++;
106
+ if (firstId === null) firstId = parsed.id ?? null;
107
+ lastId = parsed.id ?? lastId;
108
+ if (firstTs === null) firstTs = parsed.ts ?? null;
109
+ lastTs = parsed.ts ?? lastTs;
110
+ }
111
+ return { file: fileName, firstId, lastId, firstTs, lastTs, count, bytes };
112
+ }
113
+
114
+ /** Pick the highest segment number in `segments` (caller decides next filename). */
115
+ export function nextSegmentName(segments) {
116
+ let max = 0;
117
+ for (const seg of segments) {
118
+ const m = /^(\d+)\.jsonl$/.exec(seg.file);
119
+ if (m) max = Math.max(max, parseInt(m[1], 10));
120
+ }
121
+ return String(max + 1).padStart(6, '0') + '.jsonl';
122
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * jsonl-log.js — Append-only JSONL log with size-based rotation.
3
+ *
4
+ * task-334o acceptance criterion 1:
5
+ * - append single-line write < 2ms on local SSD
6
+ * - rotate at maxSegmentBytes=1 MiB OR maxSegmentLines=5000 (first to hit)
7
+ * - index.json is atomically updated on every rotation
8
+ *
9
+ * Layout (managed here):
10
+ * <dir>/000001.jsonl
11
+ * <dir>/000002.jsonl
12
+ * <dir>/index.json
13
+ *
14
+ * API:
15
+ * const log = openLog(dir, { maxSegmentBytes, maxSegmentLines, parseLine });
16
+ * log.append(obj) // writes JSON.stringify(obj) + '\n'
17
+ * log.readRange(firstId, lastId) // iterable of records in [firstId, lastId]
18
+ * log.streamAll() // iterable of all records, oldest -> newest
19
+ * log.rotate() // force rotate (test hook)
20
+ * log.close() // close fd, flush index
21
+ *
22
+ * No business semantics: the log does not know what an id means; it only
23
+ * needs the caller to provide a `parseLine(line) -> {id, ts}` so the index
24
+ * metadata can be rebuilt from disk if index.json is lost or corrupt.
25
+ */
26
+
27
+ import {
28
+ openSync,
29
+ closeSync,
30
+ writeSync,
31
+ readFileSync,
32
+ existsSync,
33
+ mkdirSync,
34
+ statSync,
35
+ } from 'fs';
36
+ import { join } from 'path';
37
+ import {
38
+ loadIndex,
39
+ saveIndex,
40
+ listSegmentFiles,
41
+ statSegmentFromDisk,
42
+ nextSegmentName,
43
+ emptyIndex,
44
+ } from './jsonl-index.js';
45
+
46
+ const DEFAULT_MAX_BYTES = 1 * 1024 * 1024; // 1 MiB
47
+ const DEFAULT_MAX_LINES = 5000;
48
+
49
+ const defaultParseLine = (line) => {
50
+ const obj = JSON.parse(line);
51
+ return { id: obj.id ?? null, ts: obj.ts ?? null };
52
+ };
53
+
54
+ /**
55
+ * Open (or create) an append-only JSONL log rooted at `dir`.
56
+ * On startup, verifies / rebuilds index.json against files on disk.
57
+ */
58
+ export function openLog(dir, opts = {}) {
59
+ const maxSegmentBytes = opts.maxSegmentBytes ?? DEFAULT_MAX_BYTES;
60
+ const maxSegmentLines = opts.maxSegmentLines ?? DEFAULT_MAX_LINES;
61
+ const parseLine = opts.parseLine ?? defaultParseLine;
62
+
63
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
64
+
65
+ let index = loadIndex(dir);
66
+ const filesOnDisk = listSegmentFiles(dir);
67
+
68
+ // Rebuild index if missing, corrupt, or out-of-sync with actual segment files.
69
+ if (!index || !indexMatchesDisk(index, filesOnDisk)) {
70
+ index = rebuildIndexFromDisk(dir, filesOnDisk, parseLine);
71
+ saveIndex(dir, index);
72
+ }
73
+
74
+ // Current segment = last one in the index; create 000001.jsonl if empty.
75
+ let current = index.segments[index.segments.length - 1];
76
+ if (!current) {
77
+ current = {
78
+ file: '000001.jsonl',
79
+ firstId: null, lastId: null,
80
+ firstTs: null, lastTs: null,
81
+ count: 0, bytes: 0,
82
+ };
83
+ index.segments.push(current);
84
+ // Touch the file so the fd is openable.
85
+ const path = join(dir, current.file);
86
+ if (!existsSync(path)) closeSync(openSync(path, 'a'));
87
+ }
88
+
89
+ let fd = openSync(join(dir, current.file), 'a');
90
+
91
+ /** Detect & perform rotation when current segment is full. */
92
+ function maybeRotate() {
93
+ if (current.bytes >= maxSegmentBytes || current.count >= maxSegmentLines) {
94
+ rotate();
95
+ }
96
+ }
97
+
98
+ function rotate() {
99
+ // Close current fd.
100
+ closeSync(fd);
101
+ // Open new segment.
102
+ const newFile = nextSegmentName(index.segments);
103
+ const newSeg = {
104
+ file: newFile,
105
+ firstId: null, lastId: null,
106
+ firstTs: null, lastTs: null,
107
+ count: 0, bytes: 0,
108
+ };
109
+ index.segments.push(newSeg);
110
+ current = newSeg;
111
+ // Persist the rotation atomically before we start writing to the new file
112
+ // so a crash mid-rotation doesn't lose the boundary.
113
+ saveIndex(dir, index);
114
+ fd = openSync(join(dir, current.file), 'a');
115
+ }
116
+
117
+ function append(obj) {
118
+ const line = JSON.stringify(obj) + '\n';
119
+ const buf = Buffer.from(line, 'utf8');
120
+ writeSync(fd, buf, 0, buf.length);
121
+ current.count += 1;
122
+ current.bytes += buf.length;
123
+ const id = obj.id ?? null;
124
+ const ts = obj.ts ?? null;
125
+ if (current.firstId === null) current.firstId = id;
126
+ current.lastId = id;
127
+ if (current.firstTs === null) current.firstTs = ts;
128
+ current.lastTs = ts;
129
+ maybeRotate();
130
+ }
131
+
132
+ function* streamAll() {
133
+ for (const seg of index.segments) {
134
+ const path = join(dir, seg.file);
135
+ if (!existsSync(path)) continue;
136
+ const raw = readFileSync(path, 'utf8');
137
+ for (const line of raw.split('\n')) {
138
+ if (!line) continue;
139
+ try {
140
+ yield JSON.parse(line);
141
+ } catch {
142
+ // Skip malformed line — don't crash the read pipeline.
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ /** Read records whose id falls in `[firstId, lastId]` inclusive (string or number). */
149
+ function* readRange(firstId, lastId) {
150
+ for (const seg of index.segments) {
151
+ if (!segmentOverlaps(seg, firstId, lastId)) continue;
152
+ const path = join(dir, seg.file);
153
+ if (!existsSync(path)) continue;
154
+ const raw = readFileSync(path, 'utf8');
155
+ for (const line of raw.split('\n')) {
156
+ if (!line) continue;
157
+ let obj;
158
+ try { obj = JSON.parse(line); } catch { continue; }
159
+ const id = obj.id ?? null;
160
+ if (id === null) continue;
161
+ if (idBetween(id, firstId, lastId)) yield obj;
162
+ }
163
+ }
164
+ }
165
+
166
+ function setNextId(n) { index.nextId = n; saveIndex(dir, index); }
167
+ function getNextId() { return index.nextId; }
168
+ function getIndex() { return index; }
169
+ function flushIndex() { saveIndex(dir, index); }
170
+
171
+ function close() {
172
+ try { closeSync(fd); } catch { /* already closed */ }
173
+ saveIndex(dir, index);
174
+ }
175
+
176
+ return {
177
+ append, readRange, streamAll, rotate, close,
178
+ setNextId, getNextId, getIndex, flushIndex,
179
+ };
180
+ }
181
+
182
+ /** True when index's segment list matches exactly the files on disk. */
183
+ function indexMatchesDisk(index, filesOnDisk) {
184
+ const indexFiles = index.segments.map((s) => s.file);
185
+ if (indexFiles.length !== filesOnDisk.length) return false;
186
+ for (let i = 0; i < indexFiles.length; i++) {
187
+ if (indexFiles[i] !== filesOnDisk[i]) return false;
188
+ }
189
+ return true;
190
+ }
191
+
192
+ /**
193
+ * Rebuild the index by scanning every segment file on disk. Expensive but
194
+ * only happens on first open or after index.json loss.
195
+ */
196
+ function rebuildIndexFromDisk(dir, files, parseLine) {
197
+ const idx = emptyIndex();
198
+ for (const file of files) {
199
+ idx.segments.push(statSegmentFromDisk(dir, file, parseLine));
200
+ }
201
+ return idx;
202
+ }
203
+
204
+ /**
205
+ * Cheap overlap test. If a segment's firstId/lastId are null (empty segment),
206
+ * we treat it as non-overlapping.
207
+ */
208
+ function segmentOverlaps(seg, first, last) {
209
+ if (seg.firstId == null || seg.lastId == null) return false;
210
+ // Ordering on strings is lexicographic which matches ULID-style ids used
211
+ // by the product (msg_01HW...). For numeric ids, JS > / < works too.
212
+ return !(compareId(seg.lastId, first) < 0 || compareId(seg.firstId, last) > 0);
213
+ }
214
+
215
+ function idBetween(id, first, last) {
216
+ return compareId(id, first) >= 0 && compareId(id, last) <= 0;
217
+ }
218
+
219
+ function compareId(a, b) {
220
+ if (a === b) return 0;
221
+ return a < b ? -1 : 1;
222
+ }