@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 +78 -1
- package/dist/session.js +275 -17
- package/package.json +1 -1
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(
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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(
|
|
15
|
-
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
47
|
-
export async function
|
|
48
|
-
let files;
|
|
137
|
+
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
138
|
+
export async function readSessionHead(file) {
|
|
49
139
|
try {
|
|
50
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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.
|
|
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",
|