chamba 0.5.0 → 0.5.1
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/package.json
CHANGED
|
@@ -35,11 +35,15 @@ const MAX_JSON_BYTES = 1024 * 1024;
|
|
|
35
35
|
const MAX_STORE_DEPTH = 5;
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* Every matching file under `dir`, in no particular order, each handed to `onFile`. Returning true from
|
|
39
|
+
* `onFile` stops the walk, which is what lets a caller that only needs to know whether there is one at all
|
|
40
|
+
* stop at the first instead of listing a whole store.
|
|
41
|
+
*
|
|
42
|
+
* Depth-limited, and unreadable dirs contribute nothing. Symlinks are skipped rather than followed: these
|
|
43
|
+
* stores are the agents' own, and a link in one of them would only ever lead somewhere this has no business
|
|
44
|
+
* reading - readdir reports one as neither a file nor a directory, which is what leaves it out.
|
|
40
45
|
*/
|
|
41
|
-
|
|
42
|
-
const found = [];
|
|
46
|
+
function walkStore(dir, pattern, depth, onFile) {
|
|
43
47
|
const queue = [{ dir, depth }];
|
|
44
48
|
while (queue.length > 0) {
|
|
45
49
|
const current = queue.shift();
|
|
@@ -51,25 +55,52 @@ export function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
|
|
|
51
55
|
}
|
|
52
56
|
for (const entry of entries) {
|
|
53
57
|
const full = join(current.dir, entry.name);
|
|
54
|
-
// Symlinks are skipped rather than followed: these stores are the agents' own, and a link in one
|
|
55
|
-
// of them would only ever lead somewhere this lookup has no business reading.
|
|
56
58
|
if (entry.isDirectory() && current.depth > 0) {
|
|
57
59
|
queue.push({ dir: full, depth: current.depth - 1 });
|
|
58
|
-
} else if (entry.isFile() && pattern.test(entry.name)) {
|
|
59
|
-
|
|
60
|
-
found.push({ path: full, mtime: statSync(full).mtimeMs });
|
|
61
|
-
} catch {
|
|
62
|
-
// Vanished between the listing and the stat.
|
|
63
|
-
}
|
|
60
|
+
} else if (entry.isFile() && pattern.test(entry.name) && onFile(full)) {
|
|
61
|
+
return;
|
|
64
62
|
}
|
|
65
63
|
}
|
|
66
64
|
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Files under `dir` matching `pattern`, newest first, capped.
|
|
69
|
+
* Exported for resume.js, which reads the same stores to answer a different question.
|
|
70
|
+
*/
|
|
71
|
+
export function filesNewestFirst(dir, pattern, depth = MAX_STORE_DEPTH) {
|
|
72
|
+
const found = [];
|
|
73
|
+
walkStore(dir, pattern, depth, (path) => {
|
|
74
|
+
try {
|
|
75
|
+
found.push({ path, mtime: statSync(path).mtimeMs });
|
|
76
|
+
} catch {
|
|
77
|
+
// Vanished between the listing and the stat.
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
});
|
|
67
81
|
return found
|
|
68
82
|
.sort((a, b) => b.mtime - a.mtime)
|
|
69
83
|
.slice(0, MAX_CANDIDATES)
|
|
70
84
|
.map((entry) => entry.path);
|
|
71
85
|
}
|
|
72
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Whether the store holds anything matching at all, stopping at the first one it finds.
|
|
89
|
+
*
|
|
90
|
+
* The cheap half of the question above, and the only half resume.js asks of codex and opencode: they are
|
|
91
|
+
* reopened by their own flag rather than by id, so all that is wanted is whether there is a conversation
|
|
92
|
+
* there. Answering that by listing and stat-ing everything first is what would make a heavy opencode store -
|
|
93
|
+
* one file per message part - a pause on the way to a new session.
|
|
94
|
+
*/
|
|
95
|
+
export function hasAnyFile(dir, pattern, depth = MAX_STORE_DEPTH) {
|
|
96
|
+
let found = false;
|
|
97
|
+
walkStore(dir, pattern, depth, () => {
|
|
98
|
+
found = true;
|
|
99
|
+
return true;
|
|
100
|
+
});
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
73
104
|
/**
|
|
74
105
|
* The head of a file as text, or "" when it cannot be read.
|
|
75
106
|
*
|
|
@@ -196,6 +227,12 @@ export function claudeConversationId(session, stores) {
|
|
|
196
227
|
// rollout-<timestamp>-<uuid>.jsonl - the uuid is the conversation id codex resumes by.
|
|
197
228
|
export const CODEX_ROLLOUT = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/;
|
|
198
229
|
|
|
230
|
+
// The same rollout once codex has compressed it. Rollouts are compressed when they go cold, so a store whose
|
|
231
|
+
// history has all gone cold holds nothing matching the pattern above - which is why resume.js asks with this
|
|
232
|
+
// one instead. It is deliberately not what a live session is matched against below: compressing a rollout
|
|
233
|
+
// rewrites it, and the fresh mtime that leaves behind looks exactly like a session writing to it.
|
|
234
|
+
export const CODEX_ANY_ROLLOUT = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl(\.zst)?$/;
|
|
235
|
+
|
|
199
236
|
/** Codex's conversation id for a session: the newest rollout file that could be this session's. */
|
|
200
237
|
export function codexConversationId(session, stores) {
|
|
201
238
|
for (const path of filesNewestFirst(stores.codexSessions, CODEX_ROLLOUT)) {
|
|
@@ -208,13 +245,20 @@ export function codexConversationId(session, stores) {
|
|
|
208
245
|
|
|
209
246
|
// --- OpenCode ----------------------------------------------------------------------------------------------------------------------------
|
|
210
247
|
|
|
248
|
+
// How far under opencode's store a session record sits. It nests everything under
|
|
249
|
+
// storage/session/{info,message,part}/, and only info/ holds one file per session - message/ and part/ hold
|
|
250
|
+
// one per message and one per message part, deeper down and far newer. So the bound is not a cost saving
|
|
251
|
+
// here but the whole of the answer: without it the newest .json in the store is a message part, and the pane
|
|
252
|
+
// would file a conversation's pages under an id that changes with every message.
|
|
253
|
+
const OPENCODE_SESSION_DEPTH = 1;
|
|
254
|
+
|
|
211
255
|
/**
|
|
212
256
|
* OpenCode's conversation id for a session: the newest session record in its local store that could be this
|
|
213
257
|
* session's. The id comes from the record when it carries one and from the filename otherwise, which is the
|
|
214
258
|
* same value - opencode names the file after the session.
|
|
215
259
|
*/
|
|
216
260
|
export function opencodeConversationId(session, stores) {
|
|
217
|
-
for (const path of filesNewestFirst(stores.opencodeSessions, /\.json
|
|
261
|
+
for (const path of filesNewestFirst(stores.opencodeSessions, /\.json$/, OPENCODE_SESSION_DEPTH)) {
|
|
218
262
|
if (mtimeOf(path) < session.since) continue;
|
|
219
263
|
const record = readJson(path);
|
|
220
264
|
const recorded = firstPathField(record);
|
|
@@ -17,7 +17,15 @@
|
|
|
17
17
|
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
19
|
import { basename, dirname } from "node:path";
|
|
20
|
-
import { CLAUDE_TRANSCRIPT,
|
|
20
|
+
import { CLAUDE_TRANSCRIPT, CODEX_ANY_ROLLOUT, filesNewestFirst, hasAnyFile } from "./conversation.js";
|
|
21
|
+
|
|
22
|
+
// How far below a store's root a conversation can sit, per store, because the shapes are not alike and a walk
|
|
23
|
+
// deep enough for the deepest is wasted on the others. codex files rollouts under a date tree
|
|
24
|
+
// (sessions/2026/08/24/); opencode nests everything under storage/session/{info,message,part}/, where part/
|
|
25
|
+
// holds one file per message part and is the reason a bound matters at all; claude's transcripts sit exactly
|
|
26
|
+
// one level down, in a dir named after the directory the session ran in, and anything deeper there is a
|
|
27
|
+
// session's own scratch rather than this workspace's history.
|
|
28
|
+
const STORE_DEPTH = { claudeProjects: 1, codexSessions: 3, opencodeSessions: 2 };
|
|
21
29
|
|
|
22
30
|
// --- Claude ------------------------------------------------------------------------------------------------------------------------------
|
|
23
31
|
|
|
@@ -42,7 +50,7 @@ export function claudeProjectKey(containerPath) {
|
|
|
42
50
|
* the fallback, and for those the dir name is the only evidence there is.
|
|
43
51
|
*/
|
|
44
52
|
function claudeResumeId(cwd, stores) {
|
|
45
|
-
const transcripts = filesNewestFirst(stores.claudeProjects, CLAUDE_TRANSCRIPT);
|
|
53
|
+
const transcripts = filesNewestFirst(stores.claudeProjects, CLAUDE_TRANSCRIPT, STORE_DEPTH.claudeProjects);
|
|
46
54
|
const byRecordedDir = firstResumable(transcripts, cwd);
|
|
47
55
|
if (byRecordedDir !== null) return byRecordedDir;
|
|
48
56
|
const key = claudeProjectKey(cwd);
|
|
@@ -113,10 +121,11 @@ function transcriptFacts(transcriptPath) {
|
|
|
113
121
|
//
|
|
114
122
|
// These two are asked a shallower question than claude - does the store hold a conversation file at all -
|
|
115
123
|
// and their own flag picks which one. Reading each store's format to find out whether the newest
|
|
116
|
-
// conversation is really resumable would tie chamba to two more internal layouts for no gain.
|
|
124
|
+
// conversation is really resumable would tie chamba to two more internal layouts for no gain. codex is asked
|
|
125
|
+
// with the pattern that also matches a compressed rollout, because a cold history is still a history.
|
|
117
126
|
export const CONTINUE = {
|
|
118
127
|
opencode: { argv: ["opencode", "--continue"], store: "opencodeSessions", pattern: /\.json$/ },
|
|
119
|
-
codex: { argv: ["codex", "resume", "--last"], store: "codexSessions", pattern:
|
|
128
|
+
codex: { argv: ["codex", "resume", "--last"], store: "codexSessions", pattern: CODEX_ANY_ROLLOUT },
|
|
120
129
|
};
|
|
121
130
|
|
|
122
131
|
// --- The one entry point -----------------------------------------------------------------------------------------------------------------
|
|
@@ -136,7 +145,7 @@ export function resumeArgvFor(agent, cwd, stores) {
|
|
|
136
145
|
}
|
|
137
146
|
const known = CONTINUE[agent];
|
|
138
147
|
if (!known) return null;
|
|
139
|
-
return
|
|
148
|
+
return hasAnyFile(stores[known.store], known.pattern, STORE_DEPTH[known.store]) ? [...known.argv] : null;
|
|
140
149
|
} catch {
|
|
141
150
|
// A store that changed shape under us must never take the session down with it - starting fresh is
|
|
142
151
|
// a worse session than the user asked for, but it is a session.
|