opencode-clm 0.1.1 → 0.3.0
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/CHANGELOG.md +1 -1
- package/NOTICE +9 -1
- package/README.md +112 -299
- package/index.ts +778 -34
- package/package.json +19 -4
- package/src/atomic.ts +27 -0
- package/src/budget.ts +12 -10
- package/src/channel.ts +187 -0
- package/src/clm.ts +610 -27
- package/src/commands.ts +18 -0
- package/src/compaction.ts +243 -0
- package/src/context-document.ts +2 -1
- package/src/continuity.ts +127 -1
- package/src/history.ts +109 -0
- package/src/mirror-store.ts +41 -3
- package/src/opencode.ts +59 -0
- package/src/overrides.ts +205 -0
- package/src/panel/command.ts +75 -0
- package/src/panel/diff.ts +422 -0
- package/src/panel/files.ts +107 -0
- package/src/panel/keys.ts +235 -0
- package/src/panel/lines.ts +269 -0
- package/src/panel/model.ts +569 -0
- package/src/panel/text.ts +22 -0
- package/src/panel/timeline.ts +460 -0
- package/src/panel/view.ts +554 -0
- package/src/presentation.ts +6 -0
- package/src/projection.ts +8 -0
- package/src/session-files.ts +286 -0
- package/src/settings-table.ts +454 -0
- package/src/settings.ts +33 -5
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +241 -0
- package/src/tui/intercept.ts +60 -0
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +205 -0
- package/src/tui/plugin.ts +466 -0
- package/src/tui/remote.ts +199 -0
- package/tui.ts +7 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// Read-only access to one session's CLM directory (`<mirrorDir>/clm-<session>/`) for the
|
|
2
|
+
// `/clm` panel: events.jsonl, state.json, snapshot.json and revisions/rN.{json,md}. Missing
|
|
3
|
+
// files are normal (a v0.1 session has no snapshot or rN.json); torn or corrupt lines and
|
|
4
|
+
// files are skipped, never thrown. Reads go through a `SessionReader`: the disk, or the
|
|
5
|
+
// server's file API for an attached TUI (src/tui/remote.ts). Written for this package.
|
|
6
|
+
|
|
7
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import { safeSessionId } from "./mirror-store.ts";
|
|
11
|
+
import {
|
|
12
|
+
EVENTS_FILE,
|
|
13
|
+
MIRROR_FILE,
|
|
14
|
+
REVISIONS_DIRECTORY,
|
|
15
|
+
SNAPSHOT_FILE,
|
|
16
|
+
type ClmEvent,
|
|
17
|
+
type SessionFiles,
|
|
18
|
+
} from "./panel/files.ts";
|
|
19
|
+
import { STATE_FILE } from "./state.ts";
|
|
20
|
+
import { activeContinuityAnnotations, ANNOTATIONS_FILE, reconstructAnnotations } from "./continuity.ts";
|
|
21
|
+
|
|
22
|
+
/** Counts of annotations.jsonl (latest snapshot per id, as the server reconstructs them). */
|
|
23
|
+
export function countAnnotations(text: string): { active: number; archived: number; total: number } {
|
|
24
|
+
const annotations = reconstructAnnotations(parseEvents(text).events);
|
|
25
|
+
return {
|
|
26
|
+
active: activeContinuityAnnotations(annotations).length,
|
|
27
|
+
archived: annotations.filter((annotation) => annotation.retention === "archive" && annotation.resolvedAt === undefined).length,
|
|
28
|
+
total: annotations.length,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `<mirrorDir>/clm-<safe session id>`, the directory MirrorStore creates for the session. */
|
|
33
|
+
export function sessionDirectory(mirrorDir: string, sessionID: string): string {
|
|
34
|
+
return join(mirrorDir, `clm-${safeSessionId(sessionID)}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parses events.jsonl text. Blank lines are ignored; lines that are not a JSON object (a
|
|
39
|
+
* torn final line from a concurrent append, or corruption) are counted and skipped.
|
|
40
|
+
*/
|
|
41
|
+
export function parseEvents(text: string): { events: ClmEvent[]; skipped: number } {
|
|
42
|
+
const events: ClmEvent[] = [];
|
|
43
|
+
let skipped = 0;
|
|
44
|
+
const lines = text.split("\n");
|
|
45
|
+
lines.forEach((line, index) => {
|
|
46
|
+
if (line.trim() === "") return;
|
|
47
|
+
// An unterminated last line is usually an append in progress: parse it if it is
|
|
48
|
+
// whole, but do not count it as unreadable.
|
|
49
|
+
const unterminated = index === lines.length - 1;
|
|
50
|
+
try {
|
|
51
|
+
const value: unknown = JSON.parse(line);
|
|
52
|
+
if (value && typeof value === "object" && !Array.isArray(value)) events.push(value as ClmEvent);
|
|
53
|
+
else if (!unterminated) skipped++;
|
|
54
|
+
} catch {
|
|
55
|
+
if (!unterminated) skipped++;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
return { events, skipped };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Where a session directory is read from: the local disk, or the server through OpenCode's
|
|
63
|
+
* file API when the TUI runs elsewhere (src/tui/remote.ts). Paths are the server's absolute
|
|
64
|
+
* paths either way.
|
|
65
|
+
*/
|
|
66
|
+
export interface SessionReader {
|
|
67
|
+
/** Cache-key prefix, so the same path read two ways does not share entries. */
|
|
68
|
+
readonly name: string;
|
|
69
|
+
/** True for a directory; false when missing. Throws for other errors. */
|
|
70
|
+
isDirectory(path: string): Promise<boolean>;
|
|
71
|
+
/** File text; undefined when missing. Throws for other errors. */
|
|
72
|
+
readText(path: string): Promise<string | undefined>;
|
|
73
|
+
/** Entry names; [] when the directory is missing. Throws for other errors. */
|
|
74
|
+
list(path: string): Promise<string[]>;
|
|
75
|
+
/**
|
|
76
|
+
* Version stamp of a file for the revision cache (mtime and size locally); undefined
|
|
77
|
+
* when missing. A constant stamp caches the file for good (rN files are written once).
|
|
78
|
+
*/
|
|
79
|
+
stamp(path: string): Promise<string | undefined>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const localReader: SessionReader = {
|
|
83
|
+
name: "local",
|
|
84
|
+
async isDirectory(path) {
|
|
85
|
+
try {
|
|
86
|
+
return (await stat(path)).isDirectory();
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (isMissing(error)) return false;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
async readText(path) {
|
|
93
|
+
try {
|
|
94
|
+
return await readFile(path, "utf8");
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (isMissing(error)) return undefined;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
async list(path) {
|
|
101
|
+
try {
|
|
102
|
+
return await readdir(path);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (isMissing(error)) return [];
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
async stamp(path) {
|
|
109
|
+
try {
|
|
110
|
+
const info = await stat(path);
|
|
111
|
+
return `${info.mtimeMs}:${info.size}`;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (isMissing(error)) return undefined;
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Revision files keyed by reader and path, kept while their stamp is unchanged, so a reload
|
|
121
|
+
* on every streamed message reads only new or changed revisions. rN files are written once.
|
|
122
|
+
*/
|
|
123
|
+
const revisionCache = new Map<string, { stamp: string; size: number; value: unknown }>();
|
|
124
|
+
const REVISION_CACHE_LIMIT = 2_000;
|
|
125
|
+
/** Total text kept (characters); the oldest entries go first once it is exceeded. */
|
|
126
|
+
const REVISION_CACHE_BYTES = 32 * 1024 * 1024;
|
|
127
|
+
let revisionCacheBytes = 0;
|
|
128
|
+
|
|
129
|
+
/** The last `state.json` revision seen per reader and session directory. */
|
|
130
|
+
const lastStateRevision = new Map<string, number>();
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Drop the cached revision files of one session directory when `state.json`'s revision went
|
|
134
|
+
* down since the last read: the session restarted its count (a corrupt state file starts at
|
|
135
|
+
* 0), so its next rN files replace old ones. Needed for readers with a constant stamp (no
|
|
136
|
+
* mtime over the file API or the channel); harmless for the disk.
|
|
137
|
+
*/
|
|
138
|
+
function invalidateRevisions(reader: SessionReader, revisionsDirectory: string, stateRevision: number): void {
|
|
139
|
+
const prefix = `${reader.name}:${revisionsDirectory}/`;
|
|
140
|
+
const last = lastStateRevision.get(prefix);
|
|
141
|
+
lastStateRevision.set(prefix, stateRevision);
|
|
142
|
+
if (last === undefined || stateRevision >= last) return;
|
|
143
|
+
for (const [key, entry] of [...revisionCache.entries()]) {
|
|
144
|
+
if (!key.startsWith(prefix)) continue;
|
|
145
|
+
revisionCache.delete(key);
|
|
146
|
+
revisionCacheBytes -= entry.size;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function evictRevision(): void {
|
|
151
|
+
const [path, entry] = revisionCache.entries().next().value!;
|
|
152
|
+
revisionCache.delete(path);
|
|
153
|
+
revisionCacheBytes -= entry.size;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function readCached(reader: SessionReader, path: string, kind: "json" | "md", warnings: string[]): Promise<unknown> {
|
|
157
|
+
let stamp: string | undefined;
|
|
158
|
+
try {
|
|
159
|
+
stamp = await reader.stamp(path);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
warnings.push(`Could not read ${path}: ${describe(error)}`);
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
if (stamp === undefined) return undefined;
|
|
165
|
+
const key = `${reader.name}:${path}`;
|
|
166
|
+
const cached = revisionCache.get(key);
|
|
167
|
+
if (cached && cached.stamp === stamp) return cached.value;
|
|
168
|
+
const text = await readText(reader, path, warnings);
|
|
169
|
+
if (text === undefined) return undefined;
|
|
170
|
+
const value = kind === "json" ? parseJson(text, path, warnings) : text;
|
|
171
|
+
if (value !== undefined) {
|
|
172
|
+
if (cached) {
|
|
173
|
+
revisionCache.delete(key);
|
|
174
|
+
revisionCacheBytes -= cached.size;
|
|
175
|
+
}
|
|
176
|
+
while (revisionCache.size > 0 && (revisionCache.size >= REVISION_CACHE_LIMIT || revisionCacheBytes + text.length > REVISION_CACHE_BYTES)) evictRevision();
|
|
177
|
+
revisionCache.set(key, { stamp, size: text.length, value });
|
|
178
|
+
revisionCacheBytes += text.length;
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isRevisionFile(value: unknown): boolean {
|
|
184
|
+
return Boolean(value) && typeof value === "object" && (value as { version?: unknown }).version === 1 &&
|
|
185
|
+
Array.isArray((value as { rows?: unknown }).rows);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isMissing(error: unknown): boolean {
|
|
189
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
190
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function describe(error: unknown): string {
|
|
194
|
+
return error instanceof Error ? error.message : String(error);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function readText(reader: SessionReader, path: string, warnings: string[]): Promise<string | undefined> {
|
|
198
|
+
try {
|
|
199
|
+
return await reader.readText(path);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
warnings.push(`Could not read ${path}: ${describe(error)}`);
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function parseJson(text: string, path: string, warnings: string[]): unknown {
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(text);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
warnings.push(`${path} is not valid JSON: ${describe(error)}`);
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function readJson(reader: SessionReader, path: string, warnings: string[]): Promise<unknown> {
|
|
216
|
+
const text = await readText(reader, path, warnings);
|
|
217
|
+
return text === undefined ? undefined : parseJson(text, path, warnings);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Reads the session directory of `sessionID` under `mirrorDir`. */
|
|
221
|
+
export function readSessionFiles(mirrorDir: string, sessionID: string): Promise<SessionFiles> {
|
|
222
|
+
return readSessionDirectory(sessionDirectory(mirrorDir, sessionID), sessionID);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Reads a session directory given its path. Never throws for missing or corrupt files. */
|
|
226
|
+
export async function readSessionDirectory(directory: string, sessionID: string, reader: SessionReader = localReader): Promise<SessionFiles> {
|
|
227
|
+
const warnings: string[] = [];
|
|
228
|
+
const files: SessionFiles = {
|
|
229
|
+
sessionID,
|
|
230
|
+
directory,
|
|
231
|
+
found: false,
|
|
232
|
+
events: [],
|
|
233
|
+
skippedEventLines: 0,
|
|
234
|
+
revisions: new Map(),
|
|
235
|
+
revisionTexts: new Map(),
|
|
236
|
+
mirrorPath: join(directory, MIRROR_FILE),
|
|
237
|
+
warnings,
|
|
238
|
+
};
|
|
239
|
+
try {
|
|
240
|
+
files.found = await reader.isDirectory(directory);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
warnings.push(`Could not read ${directory}: ${describe(error)}`);
|
|
243
|
+
}
|
|
244
|
+
if (!files.found) return files;
|
|
245
|
+
|
|
246
|
+
const [eventsText, state, snapshot, annotationsText] = await Promise.all([
|
|
247
|
+
readText(reader, join(directory, EVENTS_FILE), warnings),
|
|
248
|
+
readJson(reader, join(directory, STATE_FILE), warnings),
|
|
249
|
+
readJson(reader, join(directory, SNAPSHOT_FILE), warnings),
|
|
250
|
+
readText(reader, join(directory, ANNOTATIONS_FILE), warnings),
|
|
251
|
+
]);
|
|
252
|
+
if (annotationsText !== undefined) files.annotations = countAnnotations(annotationsText);
|
|
253
|
+
if (eventsText !== undefined) {
|
|
254
|
+
const parsed = parseEvents(eventsText);
|
|
255
|
+
files.events = parsed.events;
|
|
256
|
+
files.skippedEventLines = parsed.skipped;
|
|
257
|
+
}
|
|
258
|
+
if (state !== undefined) files.state = state;
|
|
259
|
+
if (snapshot !== undefined) files.snapshot = snapshot;
|
|
260
|
+
|
|
261
|
+
const revisionsDirectory = join(directory, REVISIONS_DIRECTORY);
|
|
262
|
+
const stateRevision = (state as { revision?: unknown } | undefined)?.revision;
|
|
263
|
+
if (typeof stateRevision === "number" && Number.isInteger(stateRevision)) invalidateRevisions(reader, revisionsDirectory, stateRevision);
|
|
264
|
+
let names: string[] = [];
|
|
265
|
+
try {
|
|
266
|
+
names = await reader.list(revisionsDirectory);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
warnings.push(`Could not list ${revisionsDirectory}: ${describe(error)}`);
|
|
269
|
+
}
|
|
270
|
+
const jsonRevisions = new Set<number>();
|
|
271
|
+
const mdRevisions = new Set<number>();
|
|
272
|
+
for (const name of names) {
|
|
273
|
+
const match = /^r(\d+)\.(json|md)$/.exec(name);
|
|
274
|
+
if (match) (match[2] === "json" ? jsonRevisions : mdRevisions).add(Number(match[1]));
|
|
275
|
+
}
|
|
276
|
+
await Promise.all([...jsonRevisions].map(async (revision) => {
|
|
277
|
+
const value = await readCached(reader, join(revisionsDirectory, `r${revision}.json`), "json", warnings);
|
|
278
|
+
if (value !== undefined) files.revisions.set(revision, value);
|
|
279
|
+
}));
|
|
280
|
+
// rN.md is only shown when rN.json is missing or invalid.
|
|
281
|
+
await Promise.all([...mdRevisions].filter((revision) => !isRevisionFile(files.revisions.get(revision))).map(async (revision) => {
|
|
282
|
+
const text = await readCached(reader, join(revisionsDirectory, `r${revision}.md`), "md", warnings);
|
|
283
|
+
if (typeof text === "string") files.revisionTexts.set(revision, text);
|
|
284
|
+
}));
|
|
285
|
+
return files;
|
|
286
|
+
}
|