@hmharness/kernel 0.7.0 → 0.8.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.
package/dist/session.d.ts CHANGED
@@ -5,6 +5,11 @@ export type SessionEvent = {
5
5
  time: string;
6
6
  cwd: string;
7
7
  model: string;
8
+ git?: {
9
+ branch?: string;
10
+ commit?: string;
11
+ };
12
+ forkedFrom?: string;
8
13
  } | {
9
14
  t: 'user';
10
15
  time: string;
@@ -32,12 +37,26 @@ export type SessionEvent = {
32
37
  turns: number;
33
38
  toolUses: number;
34
39
  };
40
+ /** codex-rs/rollout list.rs MAX_SCAN_FILES: hard cap bounding worst-case scan work. */
41
+ export declare const MAX_SCAN_FILES = 10000;
42
+ /** codex-rs/tui resume_picker PAGE_SIZE: sessions per listSessions page. */
43
+ export declare const SESSIONS_PAGE_SIZE = 25;
44
+ /** codex-rs/tui LOAD_NEAR_THRESHOLD: prefetch next page this close to the end. */
45
+ export declare const LOAD_NEAR_THRESHOLD = 5;
35
46
  export declare class Session {
36
47
  readonly id: string;
37
48
  readonly file: string;
38
49
  /** Append chain: events serialize in call order, even fire-and-forget ones. */
39
50
  private tail;
40
- constructor(home: string, cwd: string, model: string);
51
+ private constructor();
52
+ /** New rollout in the date-nested layout, session_meta first line included. */
53
+ static create(home: string, cwd: string, model: string, opts?: {
54
+ forkedFrom?: string;
55
+ }): Session;
56
+ /** Codex Resume semantics: append to the SAME rollout - no new session_meta
57
+ * line, the thread id and audit trail stay continuous. Accepts an id
58
+ * (prefix) or an absolute file path. Returns null when unresolvable. */
59
+ static resume(home: string, idOrFile: string): Promise<Session | null>;
41
60
  append(event: SessionEvent): Promise<void>;
42
61
  user(text: string): Promise<void>;
43
62
  assistant(text: string | null, toolCalls?: unknown[]): Promise<void>;
@@ -49,8 +68,66 @@ export interface SessionTranscript {
49
68
  id: string;
50
69
  model: string;
51
70
  cwd: string;
71
+ file: string;
52
72
  messages: ChatMessage[];
53
73
  }
74
+ /** Head summary of a rollout without a full parse: ONE 64KB read extracts the
75
+ * session_meta line plus the first user event (the list title). Codex reads
76
+ * HEAD_RECORD_LIMIT(10)+USER_EVENT_SCAN_LIMIT(200) lines for the same fields;
77
+ * a single bounded head buffer gets both cheaper. */
78
+ export interface SessionHead {
79
+ id: string;
80
+ time: string;
81
+ cwd: string;
82
+ model: string;
83
+ git?: {
84
+ branch?: string;
85
+ commit?: string;
86
+ };
87
+ forkedFrom?: string;
88
+ firstUser: string;
89
+ }
90
+ export declare function readSessionHead(file: string): Promise<SessionHead | null>;
91
+ /** One session per list row (codex ThreadItem, trimmed to what hmharness surfaces). */
92
+ export interface SessionSummary {
93
+ id: string;
94
+ file: string;
95
+ /** first user message - the row title (codex preview) */
96
+ title: string;
97
+ cwd: string;
98
+ model: string;
99
+ branch?: string;
100
+ createdAt: string;
101
+ updatedAt: string;
102
+ }
103
+ export interface ListSessionsOptions {
104
+ limit?: number;
105
+ /** opaque anchor token from a previous page (codex Cursor: skip until older) */
106
+ cursor?: string;
107
+ /** null/undefined = all sessions; a path = only that workspace */
108
+ cwd?: string | null;
109
+ sort?: 'updated' | 'created';
110
+ }
111
+ export interface SessionsPage {
112
+ items: SessionSummary[];
113
+ nextCursor: string | null;
114
+ numScanned: number;
115
+ reachedScanCap: boolean;
116
+ }
117
+ /** Encode/decode the pagination anchor: base64url({t, i}). Stable under files
118
+ * appearing mid-pagination: everything strictly newer than the anchor is
119
+ * skipped (codex AnchorState). */
120
+ export declare function encodeSessionCursor(ts: string, id: string): string;
121
+ export declare function decodeSessionCursor(token: string): {
122
+ t: string;
123
+ i: string;
124
+ } | null;
125
+ /** Session listing with cursor pagination (codex get_threads transplant):
126
+ * anchor-skip, per-file head reads only for surviving candidates, cwd filter
127
+ * applied post-head like codex's local cwd match, page-limit collection. */
128
+ export declare function listSessions(home: string, opts?: ListSessionsOptions): Promise<SessionsPage>;
129
+ /** Resolve an id (or unambiguous prefix) to its rollout file across layouts. */
130
+ export declare function findSessionFile(home: string, prefix: string): Promise<string | null>;
54
131
  /** Find the newest session file under home/sessions matching an id prefix. */
55
132
  export declare function latestSession(home: string, prefix?: string): Promise<string | null>;
56
133
  /**
package/dist/session.js CHANGED
@@ -1,21 +1,112 @@
1
1
  /**
2
- * @hmharness/kernel - session
3
- * Append-only JSONL session log under HMH_HOME/sessions/. Every loop event
4
- * is durably recorded - the audit trail the 2026 consensus calls
5
- * non-negotiable, and the raw material the evolution subsystem learns from.
2
+ * @hmharness/kernel - session (rollout persistence, transplanted from openai/codex)
3
+ * Append-only JSONL rollouts under HMH_HOME/sessions/. New sessions land in
4
+ * date-nested dirs (sessions/YYYY/MM/DD/<id>.jsonl - codex-rs/rollout
5
+ * precompute_new_rollout_path); legacy flat files stay listable and resumable.
6
+ * The first line is the session_meta equivalent (t: 'session/start' with id,
7
+ * cwd, model, git context, forkedFrom). Resuming OPENS THE SAME FILE and
8
+ * appends - the conversation keeps one rollout per thread (codex
9
+ * RolloutRecorderParams::Resume), instead of forking into a new file per task.
10
+ * Every loop event is durably recorded - the audit trail the 2026 consensus
11
+ * calls non-negotiable, and the raw material the evolution subsystem learns from.
6
12
  */
7
- import { appendFile, mkdir, readdir, readFile } from 'node:fs/promises';
13
+ import { appendFile, mkdir, open as fopen, readdir, readFile, stat } from 'node:fs/promises';
14
+ import { readFileSync, statSync } from 'node:fs';
8
15
  import { join } from 'node:path';
16
+ /** codex-rs/rollout list.rs MAX_SCAN_FILES: hard cap bounding worst-case scan work. */
17
+ export const MAX_SCAN_FILES = 10_000;
18
+ /** codex-rs/tui resume_picker PAGE_SIZE: sessions per listSessions page. */
19
+ export const SESSIONS_PAGE_SIZE = 25;
20
+ /** codex-rs/tui LOAD_NEAR_THRESHOLD: prefetch next page this close to the end. */
21
+ export const LOAD_NEAR_THRESHOLD = 5;
22
+ /** Best-effort git context via pure fs (kernel stays zero-dependency):
23
+ * walk up to find .git (dir or worktree pointer file), parse HEAD for the
24
+ * branch (or detached sha). Codex records this in session_meta.git. */
25
+ function readGitInfo(cwd) {
26
+ let dir = cwd;
27
+ for (let i = 0; i < 8; i++) {
28
+ const dot = join(dir, '.git');
29
+ let headFile = join(dot, 'HEAD');
30
+ try {
31
+ let isDir = true;
32
+ try {
33
+ isDir = statSync(dot).isDirectory();
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ if (!isDir) {
39
+ // worktree/submodule: .git is a "gitdir: <path>" pointer file
40
+ const raw = readFileSync(dot, 'utf8').trim();
41
+ if (!raw.startsWith('gitdir:'))
42
+ return undefined;
43
+ headFile = join(raw.slice(7).trim(), 'HEAD');
44
+ }
45
+ const head = readFileSync(headFile, 'utf8').trim();
46
+ if (head.startsWith('ref: refs/heads/'))
47
+ return { branch: head.slice('ref: refs/heads/'.length) };
48
+ if (/^[0-9a-f]{40}$/i.test(head))
49
+ return { commit: head.slice(0, 10) };
50
+ return undefined;
51
+ }
52
+ catch { /* not a repo here - keep walking up */ }
53
+ const parent = join(dir, '..');
54
+ if (parent === dir)
55
+ return undefined;
56
+ dir = parent;
57
+ }
58
+ return undefined;
59
+ }
9
60
  export class Session {
10
61
  id;
11
62
  file;
12
63
  /** Append chain: events serialize in call order, even fire-and-forget ones. */
13
64
  tail = Promise.resolve();
14
- constructor(home, cwd, model) {
15
- const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
16
- this.id = `${stamp}-${Math.random().toString(36).slice(2, 8)}`;
17
- this.file = join(home, 'sessions', `${this.id}.jsonl`);
18
- this.append({ t: 'session/start', id: this.id, time: new Date().toISOString(), cwd, model }).catch(() => undefined);
65
+ constructor(id, file, firstEvent) {
66
+ this.id = id;
67
+ this.file = file;
68
+ if (firstEvent)
69
+ this.append(firstEvent).catch(() => undefined);
70
+ }
71
+ /** New rollout in the date-nested layout, session_meta first line included. */
72
+ static create(home, cwd, model, opts = {}) {
73
+ const now = new Date();
74
+ const stamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
75
+ const id = `${stamp}-${Math.random().toString(36).slice(2, 8)}`;
76
+ const file = join(home, 'sessions', String(now.getFullYear()), pad2(now.getMonth() + 1), pad2(now.getDate()), `${id}.jsonl`);
77
+ const git = readGitInfo(cwd);
78
+ return new Session(id, file, {
79
+ t: 'session/start', id, time: now.toISOString(), cwd, model,
80
+ ...(git ? { git } : {}), ...(opts.forkedFrom ? { forkedFrom: opts.forkedFrom } : {}),
81
+ });
82
+ }
83
+ /** Codex Resume semantics: append to the SAME rollout - no new session_meta
84
+ * line, the thread id and audit trail stay continuous. Accepts an id
85
+ * (prefix) or an absolute file path. Returns null when unresolvable. */
86
+ static async resume(home, idOrFile) {
87
+ const file = /[\\/.]/.test(idOrFile) && idOrFile.endsWith('.jsonl')
88
+ ? idOrFile
89
+ : await findSessionFile(home, idOrFile);
90
+ if (!file)
91
+ return null;
92
+ const head = await readSessionHead(file);
93
+ if (!head?.id)
94
+ return null;
95
+ // codex ensure_rollout_is_newline_terminated: a torn last line must not
96
+ // glue onto the next event - pad the separator before any append lands
97
+ try {
98
+ const fh = await fopen(file, 'r');
99
+ const { size } = await fh.stat();
100
+ if (size > 0) {
101
+ const buf = Buffer.alloc(1);
102
+ await fh.read(buf, 0, 1, size - 1);
103
+ if (buf[0] !== 0x0a)
104
+ await appendFile(file, '\n', 'utf8');
105
+ }
106
+ await fh.close();
107
+ }
108
+ catch { /* unreadable - the append itself will surface the error */ }
109
+ return new Session(head.id, file);
19
110
  }
20
111
  async append(event) {
21
112
  const write = this.tail.then(async () => {
@@ -43,17 +134,184 @@ export class Session {
43
134
  return this.append({ t: 'final', time: new Date().toISOString(), text, turns, toolUses });
44
135
  }
45
136
  }
46
- /** Find the newest session file under home/sessions matching an id prefix. */
47
- export async function latestSession(home, prefix = '') {
48
- let files;
137
+ function pad2(n) { return String(n).padStart(2, '0'); }
138
+ export async function readSessionHead(file) {
49
139
  try {
50
- files = (await readdir(join(home, 'sessions'))).filter((f) => f.endsWith('.jsonl') && f.startsWith(prefix));
140
+ const fh = await fopen(file, 'r');
141
+ try {
142
+ const buf = Buffer.alloc(65_536);
143
+ const { bytesRead } = await fh.read(buf, 0, 65_536, 0);
144
+ const text = buf.toString('utf8', 0, bytesRead);
145
+ let head = null;
146
+ for (const line of text.split('\n')) {
147
+ if (!line.trim())
148
+ continue;
149
+ let ev;
150
+ try {
151
+ ev = JSON.parse(line);
152
+ }
153
+ catch {
154
+ continue;
155
+ } // torn tail line - no preview
156
+ if (ev.t === 'session/start' && ev.id) {
157
+ head = { id: ev.id, time: ev.time ?? '', cwd: ev.cwd ?? '', model: ev.model ?? '', git: ev.git, forkedFrom: ev.forkedFrom, firstUser: '' };
158
+ }
159
+ else if (ev.t === 'user' && typeof ev.text === 'string') {
160
+ if (head) {
161
+ head.firstUser = ev.text;
162
+ return head;
163
+ }
164
+ }
165
+ }
166
+ return head;
167
+ }
168
+ finally {
169
+ await fh.close();
170
+ }
51
171
  }
52
172
  catch {
53
173
  return null;
54
174
  }
55
- files.sort();
56
- return files.length > 0 ? join(home, 'sessions', files[files.length - 1]) : null;
175
+ }
176
+ /** Encode/decode the pagination anchor: base64url({t, i}). Stable under files
177
+ * appearing mid-pagination: everything strictly newer than the anchor is
178
+ * skipped (codex AnchorState). */
179
+ export function encodeSessionCursor(ts, id) {
180
+ return Buffer.from(JSON.stringify({ t: ts, i: id }), 'utf8').toString('base64url');
181
+ }
182
+ export function decodeSessionCursor(token) {
183
+ try {
184
+ const v = JSON.parse(Buffer.from(token, 'base64url').toString('utf8'));
185
+ return typeof v.t === 'string' && typeof v.i === 'string' ? { t: v.t, i: v.i } : null;
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
191
+ /** id stamps are `YYYY-MM-DDThh-mm-ss-<rand>`: normalize to a sortable ISO time. */
192
+ function stampToDate(id) {
193
+ const m = id.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/);
194
+ return m ? Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`) : 0;
195
+ }
196
+ /** Walk sessions/YYYY/MM/DD/*.jsonl plus legacy flat sessions/*.jsonl,
197
+ * skipping trash/archive and non-date dirs. Returns newest-first candidates
198
+ * for the requested sort key (codex walk_rollout_files + visitor). */
199
+ async function collectCandidates(home, sort) {
200
+ const root = join(home, 'sessions');
201
+ const out = [];
202
+ let scanned = 0;
203
+ let capped = false;
204
+ const push = async (file) => {
205
+ if (++scanned > MAX_SCAN_FILES) {
206
+ capped = true;
207
+ return;
208
+ }
209
+ const id = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
210
+ const createdMs = stampToDate(id);
211
+ let updatedMs = createdMs;
212
+ try {
213
+ updatedMs = (await stat(file)).mtimeMs;
214
+ }
215
+ catch {
216
+ return;
217
+ }
218
+ out.push({ id, file, createdMs, updatedMs });
219
+ };
220
+ let rootEntries = [];
221
+ try {
222
+ rootEntries = await readdir(root);
223
+ }
224
+ catch {
225
+ return { list: [], scanned, capped };
226
+ }
227
+ const years = rootEntries.filter((e) => /^\d{4}$/.test(e)).sort().reverse();
228
+ for (const y of years) {
229
+ const months = (await safeReaddir(join(root, y))).filter((e) => /^\d{2}$/.test(e)).sort().reverse();
230
+ for (const mo of months) {
231
+ const days = (await safeReaddir(join(root, y, mo))).filter((e) => /^\d{2}$/.test(e)).sort().reverse();
232
+ for (const d of days) {
233
+ for (const f of (await safeReaddir(join(root, y, mo, d))).filter((x) => x.endsWith('.jsonl') && !x.startsWith('.'))) {
234
+ await push(join(root, y, mo, d, f));
235
+ if (capped)
236
+ return { list: out, scanned: out.length, capped };
237
+ }
238
+ }
239
+ }
240
+ }
241
+ // legacy flat layout keeps working (codex lists legacy rollouts the same way)
242
+ for (const f of rootEntries) {
243
+ if (f.endsWith('.jsonl') && !f.startsWith('.')) {
244
+ await push(join(root, f));
245
+ if (capped)
246
+ return { list: out, scanned: out.length, capped };
247
+ }
248
+ }
249
+ const key = sort === 'created' ? (c) => c.createdMs : (c) => c.updatedMs;
250
+ out.sort((a, b) => key(b) - key(a) || (a.id < b.id ? 1 : -1));
251
+ return { list: out, scanned: out.length, capped };
252
+ }
253
+ async function safeReaddir(dir) {
254
+ try {
255
+ return await readdir(dir);
256
+ }
257
+ catch {
258
+ return [];
259
+ }
260
+ }
261
+ /** Session listing with cursor pagination (codex get_threads transplant):
262
+ * anchor-skip, per-file head reads only for surviving candidates, cwd filter
263
+ * applied post-head like codex's local cwd match, page-limit collection. */
264
+ export async function listSessions(home, opts = {}) {
265
+ const limit = Math.max(1, opts.limit ?? SESSIONS_PAGE_SIZE);
266
+ const sort = opts.sort ?? 'updated';
267
+ const { list, capped } = await collectCandidates(home, sort);
268
+ const anchor = opts.cursor ? decodeSessionCursor(opts.cursor) : null;
269
+ const items = [];
270
+ let lastEmitted = null;
271
+ let exhausted = true;
272
+ for (let idx = 0; idx < list.length; idx++) {
273
+ const c = list[idx];
274
+ const ts = sort === 'created' ? c.createdMs : c.updatedMs;
275
+ if (anchor) {
276
+ const at = Date.parse(anchor.t);
277
+ if (ts > at || (ts === at && c.id >= anchor.i))
278
+ continue;
279
+ }
280
+ const head = await readSessionHead(c.file);
281
+ if (!head)
282
+ continue;
283
+ if (opts.cwd && normalizePath(head.cwd) !== normalizePath(opts.cwd))
284
+ continue;
285
+ items.push({
286
+ id: c.id, file: c.file, title: head.firstUser, cwd: head.cwd, model: head.model,
287
+ branch: head.git?.branch,
288
+ createdAt: new Date(c.createdMs || Date.parse(head.time) || c.updatedMs).toISOString(),
289
+ updatedAt: new Date(c.updatedMs).toISOString(),
290
+ });
291
+ lastEmitted = { ts: new Date(ts || c.updatedMs).toISOString(), id: c.id };
292
+ if (items.length >= limit) {
293
+ // more matching rows may follow - only claim exhaustion when the scan ended
294
+ exhausted = idx >= list.length - 1;
295
+ break;
296
+ }
297
+ }
298
+ const nextCursor = items.length >= limit && !exhausted && lastEmitted ? encodeSessionCursor(lastEmitted.ts, lastEmitted.id) : null;
299
+ return { items, nextCursor, numScanned: list.length, reachedScanCap: capped };
300
+ }
301
+ function normalizePath(p) {
302
+ return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
303
+ }
304
+ /** Resolve an id (or unambiguous prefix) to its rollout file across layouts. */
305
+ export async function findSessionFile(home, prefix) {
306
+ if (!prefix)
307
+ return null;
308
+ const { list } = await collectCandidates(home, 'created');
309
+ const hit = list.find((c) => c.id === prefix) ?? list.find((c) => c.id.startsWith(prefix));
310
+ return hit?.file ?? null;
311
+ }
312
+ /** Find the newest session file under home/sessions matching an id prefix. */
313
+ export async function latestSession(home, prefix = '') {
314
+ return findSessionFile(home, prefix);
57
315
  }
58
316
  /**
59
317
  * Rebuild a chat transcript from a session log. Tool events don't record
@@ -68,7 +326,7 @@ export async function loadTranscript(file) {
68
326
  catch {
69
327
  return null;
70
328
  }
71
- const out = { id: '', model: '', cwd: '', messages: [] };
329
+ const out = { id: '', model: '', cwd: '', file, messages: [] };
72
330
  let pendingCallIds = [];
73
331
  for (const line of raw.split('\n')) {
74
332
  if (!line.trim())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hmharness/kernel",
3
- "version": "0.7.0",
3
+ "version": "0.8.2",
4
4
  "description": "hmharness kernel: tool registry, provider adapters, the agent loop, session log, config. Zero runtime dependencies (Node >=22 native fetch).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",