@onebrain-ai/cli 2.0.0 → 2.0.2

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,427 +0,0 @@
1
- /**
2
- * checkpoint — internal command
3
- *
4
- * Implements stop/precompact/postcompact/reset modes, replacing checkpoint-hook.sh.
5
- *
6
- * State file: $TMPDIR/onebrain-{session_token}.state
7
- * Format: count:last_ts:last_stop_nn[:pending_stub_filename]
8
- *
9
- * Exit code always 0. Errors go to stderr only.
10
- * JSON decision blocks go to process.stdout.write (no console.log).
11
- */
12
-
13
- import { readFileSync, writeFileSync } from 'node:fs';
14
- import { mkdir, writeFile } from 'node:fs/promises';
15
- import { tmpdir as osTmpdir } from 'node:os';
16
- import { join } from 'node:path';
17
- import { loadVaultConfig } from '@onebrain/core';
18
-
19
- // ---------------------------------------------------------------------------
20
- // Constants
21
- // ---------------------------------------------------------------------------
22
-
23
- const SKIP_WINDOW = 60; // seconds — suppress re-trigger after reset
24
- const MIN_ACTIVITY = 2; // minimum messages to warrant checkpoint
25
- const PRECOMPACT_RECENCY = 300; // seconds — treat checkpoint as "recent" for precompact
26
-
27
- // Default thresholds (used when vault.yml is missing/unreadable)
28
- const DEFAULT_MESSAGES_THRESHOLD = 15;
29
- const DEFAULT_MINUTES_THRESHOLD = 30;
30
-
31
- // ---------------------------------------------------------------------------
32
- // Types
33
- // ---------------------------------------------------------------------------
34
-
35
- export interface CheckpointState {
36
- count: number;
37
- last_ts: number;
38
- last_stop_nn: string;
39
- pending_stub?: string;
40
- }
41
-
42
- // ---------------------------------------------------------------------------
43
- // State helpers
44
- // ---------------------------------------------------------------------------
45
-
46
- function stateFilePath(token: string, tmpDir: string): string {
47
- return join(tmpDir, `onebrain-${token}.state`);
48
- }
49
-
50
- /**
51
- * Read state from $tmpDir/onebrain-{token}.state.
52
- * Returns default state if file is missing or malformed (v1 compat: < 3 fields → parse error).
53
- * Sync — checkpoint hooks must not add async latency.
54
- */
55
- export function readState(token: string, tmpDir: string = osTmpdir()): CheckpointState {
56
- const path = stateFilePath(token, tmpDir);
57
- try {
58
- const raw = readFileSync(path, 'utf8').trim();
59
- const parts = raw.split(':');
60
- // v1 compat: fewer than 3 fields → treat as parse error
61
- if (parts.length < 3) {
62
- throw new Error('v1 state format');
63
- }
64
- const count = Number(parts[0]);
65
- const last_ts = Number(parts[1]);
66
- const last_stop_nn = parts[2] ?? '00';
67
- const pending_stub = parts[3] && parts[3].length > 0 ? parts[3] : undefined;
68
-
69
- if (!Number.isInteger(count) || !Number.isInteger(last_ts) || !/^\d{2}$/.test(last_stop_nn)) {
70
- throw new Error('malformed state');
71
- }
72
-
73
- return { count, last_ts, last_stop_nn, pending_stub };
74
- } catch {
75
- // Missing or malformed → fresh state
76
- // last_ts=0: avoids SKIP_WINDOW on first run (guard requires last_ts > 0)
77
- // and avoids false "recent checkpoint" in precompact (guard requires last_ts > 0)
78
- // Eagerly rewrite the state file so v1/malformed files don't accumulate.
79
- const now = Math.floor(Date.now() / 1000);
80
- try {
81
- writeFileSync(stateFilePath(token, tmpDir), `0:${now}:00`, 'utf8');
82
- } catch (writeErr) {
83
- process.stderr.write(
84
- `checkpoint: failed to rewrite state file for token ${token}: ${writeErr}\n`,
85
- );
86
- }
87
- return {
88
- count: 0,
89
- last_ts: 0,
90
- last_stop_nn: '00',
91
- };
92
- }
93
- }
94
-
95
- /**
96
- * Write state to $tmpDir/onebrain-{token}.state.
97
- * 3-field when no pending_stub, 4-field when pending_stub is set.
98
- * Sync.
99
- */
100
- export function writeState(
101
- token: string,
102
- state: CheckpointState,
103
- tmpDir: string = osTmpdir(),
104
- ): void {
105
- const path = stateFilePath(token, tmpDir);
106
- const base = `${state.count}:${state.last_ts}:${state.last_stop_nn}`;
107
- const content = state.pending_stub !== undefined ? `${base}:${state.pending_stub}` : base;
108
- try {
109
- writeFileSync(path, content, 'utf8');
110
- } catch (err) {
111
- process.stderr.write(`checkpoint: failed to write state file ${path}: ${err}\n`);
112
- }
113
- }
114
-
115
- // ---------------------------------------------------------------------------
116
- // Config helper
117
- // ---------------------------------------------------------------------------
118
-
119
- /**
120
- * Load messages and minutes thresholds from vault.yml.
121
- * Returns defaults if vault.yml is missing or throws.
122
- * Sync via readFileSync + yaml inline parse — avoids async in hot path.
123
- */
124
- function loadThresholds(vaultRoot: string): {
125
- messagesThreshold: number;
126
- minutesThreshold: number;
127
- } {
128
- try {
129
- const vaultYml = join(vaultRoot, 'vault.yml');
130
- const raw = readFileSync(vaultYml, 'utf8');
131
- // Find checkpoint block then parse keys within it
132
- const checkpointBlock = raw.match(/^checkpoint:\s*\n((?:[ \t]+[^\n]+\n?)*)/m);
133
- let messages = DEFAULT_MESSAGES_THRESHOLD;
134
- let minutes = DEFAULT_MINUTES_THRESHOLD;
135
- if (checkpointBlock?.[1]) {
136
- const block = checkpointBlock[1];
137
- const msgMatch = block.match(/messages:\s*(\d+)/);
138
- const minMatch = block.match(/minutes:\s*(\d+)/);
139
- if (msgMatch?.[1]) messages = Number(msgMatch[1]);
140
- if (minMatch?.[1]) minutes = Number(minMatch[1]);
141
- }
142
-
143
- return { messagesThreshold: messages, minutesThreshold: minutes };
144
- } catch {
145
- return {
146
- messagesThreshold: DEFAULT_MESSAGES_THRESHOLD,
147
- minutesThreshold: DEFAULT_MINUTES_THRESHOLD,
148
- };
149
- }
150
- }
151
-
152
- // ---------------------------------------------------------------------------
153
- // Date helpers
154
- // ---------------------------------------------------------------------------
155
-
156
- function formatDate(epochSeconds: number): string {
157
- const d = new Date(epochSeconds * 1000);
158
- const yyyy = d.getFullYear().toString();
159
- const mm = String(d.getMonth() + 1).padStart(2, '0');
160
- const dd = String(d.getDate()).padStart(2, '0');
161
- return `${yyyy}-${mm}-${dd}`;
162
- }
163
-
164
- function formatYYYY(epochSeconds: number): string {
165
- return new Date(epochSeconds * 1000).getFullYear().toString();
166
- }
167
-
168
- function formatMM(epochSeconds: number): string {
169
- return String(new Date(epochSeconds * 1000).getMonth() + 1).padStart(2, '0');
170
- }
171
-
172
- // ---------------------------------------------------------------------------
173
- // JSON output helper
174
- // ---------------------------------------------------------------------------
175
-
176
- function emitBlock(reason: string): void {
177
- process.stdout.write(`${JSON.stringify({ decision: 'block', reason })}\n`);
178
- }
179
-
180
- // ---------------------------------------------------------------------------
181
- // reset mode
182
- // ---------------------------------------------------------------------------
183
-
184
- /**
185
- * Reset state: write 0:<now>:00 to state file.
186
- * No stdout. Exit 0 always.
187
- */
188
- export function handleReset(
189
- token: string,
190
- now: number = Math.floor(Date.now() / 1000),
191
- tmpDir: string = osTmpdir(),
192
- ): void {
193
- writeState(token, { count: 0, last_ts: now, last_stop_nn: '00' }, tmpDir);
194
- }
195
-
196
- // ---------------------------------------------------------------------------
197
- // stop mode
198
- // ---------------------------------------------------------------------------
199
-
200
- /**
201
- * Stop hook: increment message count, check thresholds, emit block if needed.
202
- * Sync — no async/await.
203
- */
204
- export function handleStop(
205
- token: string,
206
- vaultRoot: string,
207
- now: number = Math.floor(Date.now() / 1000),
208
- tmpDir: string = osTmpdir(),
209
- ): void {
210
- const state = readState(token, tmpDir);
211
-
212
- // SKIP_WINDOW: if count=0 and last_ts is within 60s, this is right after a /wrapup reset
213
- if (state.count === 0 && state.last_ts > 0 && now - state.last_ts < SKIP_WINDOW) {
214
- return; // exit 0, state unchanged
215
- }
216
-
217
- // Increment count
218
- state.count += 1;
219
-
220
- const { messagesThreshold, minutesThreshold } = loadThresholds(vaultRoot);
221
- const timeThreshold = minutesThreshold * 60;
222
-
223
- // Elapsed: last_ts=0 is post-compact sentinel → treat as 0 elapsed
224
- const elapsed = state.last_ts === 0 ? 0 : now - state.last_ts;
225
-
226
- const thresholdMet = state.count >= messagesThreshold || elapsed >= timeThreshold;
227
-
228
- if (!thresholdMet) {
229
- // Update count but preserve last_ts
230
- writeState(
231
- token,
232
- { count: state.count, last_ts: state.last_ts, last_stop_nn: state.last_stop_nn },
233
- tmpDir,
234
- );
235
- return;
236
- }
237
-
238
- // MIN_ACTIVITY guard: threshold fired but not enough messages
239
- if (state.count < MIN_ACTIVITY) {
240
- // Preserve last_ts so time clock doesn't restart
241
- writeState(
242
- token,
243
- { count: state.count, last_ts: state.last_ts, last_stop_nn: state.last_stop_nn },
244
- tmpDir,
245
- );
246
- return;
247
- }
248
-
249
- // Emit checkpoint
250
- const nextNn = String(Number(state.last_stop_nn) + 1).padStart(2, '0');
251
- const date = formatDate(now);
252
- const filename = `${date}-${token}-checkpoint-${nextNn}.md`;
253
- const since =
254
- state.last_stop_nn === '00' ? ' since start' : ` since checkpoint-${state.last_stop_nn}`;
255
- emitBlock(`${filename}${since}`);
256
-
257
- // Reset state
258
- writeState(token, { count: 0, last_ts: now, last_stop_nn: nextNn }, tmpDir);
259
- }
260
-
261
- // ---------------------------------------------------------------------------
262
- // precompact mode
263
- // ---------------------------------------------------------------------------
264
-
265
- const PRECOMPACT_STUB_TEMPLATE = (date: string, nn: string): string => `---
266
- tags: [checkpoint, session-log]
267
- date: ${date}
268
- checkpoint: ${nn}
269
- trigger: precompact
270
- merged: false
271
- ---
272
-
273
- ## What We Worked On
274
-
275
- <!-- stub: written automatically before compact — fill in via postcompact -->
276
-
277
- ## Key Decisions
278
-
279
- -
280
-
281
- ## Insights & Learnings
282
-
283
- -
284
-
285
- ## What Worked / Didn't Work
286
-
287
- -
288
-
289
- ## Action Items
290
-
291
- -
292
-
293
- ## Open Questions
294
-
295
- -
296
- `;
297
-
298
- /**
299
- * Precompact hook: ensure a checkpoint exists before compact.
300
- * If a checkpoint was written within the last 5 minutes, let compact proceed (no-op).
301
- * Otherwise write a stub file and update state to 4-field.
302
- * Async (file writes).
303
- */
304
- export async function handlePrecompact(
305
- token: string,
306
- vaultRoot: string,
307
- now: number = Math.floor(Date.now() / 1000),
308
- tmpDir: string = osTmpdir(),
309
- ): Promise<void> {
310
- const state = readState(token, tmpDir);
311
-
312
- // Recency check: if last checkpoint < 5 minutes ago, let compact proceed
313
- if (state.last_ts > 0 && now - state.last_ts < PRECOMPACT_RECENCY) {
314
- return; // no-op
315
- }
316
-
317
- // Compute stub NN (last_stop_nn + 1, does NOT update last_stop_nn in state)
318
- const stubNn = String(Number(state.last_stop_nn) + 1).padStart(2, '0');
319
- const date = formatDate(now);
320
- const stubFilename = `${date}-${token}-checkpoint-${stubNn}.md`;
321
-
322
- // Determine logs folder from vault.yml (fallback to '07-logs')
323
- let logsFolder = '07-logs';
324
- try {
325
- const config = await loadVaultConfig(vaultRoot);
326
- logsFolder = config.folders.logs;
327
- } catch {
328
- // use default
329
- }
330
-
331
- const yyyy = formatYYYY(now);
332
- const mm = formatMM(now);
333
- const stubDir = join(vaultRoot, logsFolder, yyyy, mm);
334
- const stubPath = join(stubDir, stubFilename);
335
-
336
- try {
337
- await mkdir(stubDir, { recursive: true });
338
- await writeFile(stubPath, PRECOMPACT_STUB_TEMPLATE(date, stubNn), 'utf8');
339
- } catch (err) {
340
- process.stderr.write(`checkpoint: failed to write stub file ${stubPath}: ${err}\n`);
341
- return;
342
- }
343
-
344
- // Update state: count=0, last_ts UNCHANGED, last_stop_nn UNCHANGED, pending_stub set
345
- writeState(
346
- token,
347
- {
348
- count: 0,
349
- last_ts: state.last_ts,
350
- last_stop_nn: state.last_stop_nn,
351
- pending_stub: stubFilename,
352
- },
353
- tmpDir,
354
- );
355
- }
356
-
357
- // ---------------------------------------------------------------------------
358
- // postcompact mode
359
- // ---------------------------------------------------------------------------
360
-
361
- /**
362
- * Postcompact hook: handle pending stub from precompact.
363
- * If no pending stub: preserve last_ts, write clean 3-field state.
364
- * If pending stub: emit fill-checkpoint block, clear pending_stub, set last_ts=0.
365
- * Sync.
366
- */
367
- export function handlePostcompact(
368
- token: string,
369
- // _now kept for API symmetry with handleStop/handlePrecompact so call sites can pass now as 2nd arg
370
- _now: number = Math.floor(Date.now() / 1000),
371
- tmpDir: string = osTmpdir(),
372
- ): void {
373
- const state = readState(token, tmpDir);
374
-
375
- if (!state.pending_stub) {
376
- // No pending stub — preserve last_ts, write 3-field
377
- writeState(
378
- token,
379
- { count: 0, last_ts: state.last_ts, last_stop_nn: state.last_stop_nn },
380
- tmpDir,
381
- );
382
- return;
383
- }
384
-
385
- // Pending stub found — emit fill-checkpoint block
386
- const since =
387
- state.last_stop_nn === '00' ? ' since start' : ` since checkpoint-${state.last_stop_nn}`;
388
- emitBlock(`fill-checkpoint: ${state.pending_stub}${since}`);
389
-
390
- // Clear pending_stub, set last_ts=0 sentinel
391
- writeState(token, { count: 0, last_ts: 0, last_stop_nn: state.last_stop_nn }, tmpDir);
392
- }
393
-
394
- // ---------------------------------------------------------------------------
395
- // CLI entry point
396
- // ---------------------------------------------------------------------------
397
-
398
- /**
399
- * Dispatch to the correct mode handler.
400
- * Always exits 0 (errors to stderr only).
401
- */
402
- export async function checkpointCommand(
403
- mode: string,
404
- token: string,
405
- vaultRoot: string,
406
- ): Promise<void> {
407
- try {
408
- switch (mode) {
409
- case 'stop':
410
- handleStop(token, vaultRoot);
411
- break;
412
- case 'precompact':
413
- await handlePrecompact(token, vaultRoot);
414
- break;
415
- case 'postcompact':
416
- handlePostcompact(token);
417
- break;
418
- case 'reset':
419
- handleReset(token);
420
- break;
421
- default:
422
- process.stderr.write(`checkpoint: unknown mode '${mode}'\n`);
423
- }
424
- } catch (err) {
425
- process.stderr.write(`checkpoint: unexpected error in ${mode} mode: ${err}\n`);
426
- }
427
- }