@neta-art/cohub-cli 10.6.0 → 10.8.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/README.md +8 -5
- package/dist/board-kernel.js +207 -119
- package/dist/commands/boards/examples.js +16 -16
- package/dist/commands/boards.js +1 -1
- package/dist/commands/generations.js +23 -2
- package/dist/commands/runtime.js +3 -14
- package/dist/runtime/harnesses/chained-transcript.d.ts +24 -0
- package/dist/runtime/harnesses/chained-transcript.js +62 -0
- package/dist/runtime/harnesses/claude-transcript.d.ts +8 -0
- package/dist/runtime/harnesses/claude-transcript.js +138 -0
- package/dist/runtime/harnesses/claude.js +79 -2
- package/dist/runtime/harnesses/codex.js +1 -1
- package/dist/runtime/harnesses/cursor-transcript.d.ts +37 -0
- package/dist/runtime/harnesses/cursor-transcript.js +183 -0
- package/dist/runtime/harnesses/cursor.d.ts +24 -0
- package/dist/runtime/harnesses/cursor.js +620 -0
- package/dist/runtime/harnesses/index.d.ts +1 -3
- package/dist/runtime/harnesses/index.js +4 -10
- package/dist/runtime/harnesses/pi.js +1 -1
- package/dist/runtime/harnesses/stream-json.d.ts +7 -1
- package/dist/runtime/harnesses/stream-json.js +35 -12
- package/dist/runtime/harnesses/types.d.ts +10 -5
- package/dist/runtime/harnesses/workbuddy-transcript.d.ts +8 -0
- package/dist/runtime/harnesses/workbuddy-transcript.js +127 -0
- package/dist/runtime/harnesses/workbuddy.d.ts +1 -1
- package/dist/runtime/harnesses/workbuddy.js +31 -7
- package/dist/runtime/json-rpc.d.ts +2 -0
- package/dist/runtime/json-rpc.js +9 -0
- package/dist/runtime/native/attach.d.ts +1 -1
- package/dist/runtime/native/attach.js +1 -4
- package/dist/runtime/native/execution.js +14 -3
- package/dist/runtime/native/ingest.d.ts +14 -0
- package/dist/runtime/native/ingest.js +51 -5
- package/dist/runtime/native/sessions.js +6 -1
- package/dist/runtime/native/transcript.d.ts +19 -0
- package/dist/runtime/native/transcript.js +33 -2
- package/package.json +3 -3
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdirSync } from "node:fs";
|
|
3
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, join, posix, relative, resolve, sep, win32 } from "node:path";
|
|
6
|
+
import { record } from "../json-rpc.js";
|
|
7
|
+
import { extendTurn, readChainedLines } from "./chained-transcript.js";
|
|
8
|
+
import { array, missing, text } from "./process.js";
|
|
9
|
+
const TRANSCRIPTS = "agent-transcripts";
|
|
10
|
+
const USER_QUERY = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/;
|
|
11
|
+
const TIMESTAMP = /<timestamp>([^<]*)<\/timestamp>/;
|
|
12
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
13
|
+
/** Chats (`store.db` and `meta.json`) live under the config directory, transcripts under the data directory. */
|
|
14
|
+
export const cursorConfigDirectory = () => process.env.CURSOR_CONFIG_DIR?.trim()
|
|
15
|
+
|| (process.env.XDG_CONFIG_HOME?.trim() ? join(process.env.XDG_CONFIG_HOME.trim(), "cursor") : join(homedir(), ".cursor"));
|
|
16
|
+
const dataDirectory = () => process.env.CURSOR_DATA_DIR?.trim() || join(homedir(), ".cursor");
|
|
17
|
+
export const cursorProjectsRoot = () => join(dataDirectory(), "projects");
|
|
18
|
+
export const cursorChatsRoot = () => join(cursorConfigDirectory(), "chats");
|
|
19
|
+
/** Cursor's project name for a workspace path. */
|
|
20
|
+
export const cursorProjectName = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
21
|
+
/** Windows reuses a project folder whose name differs only in case, and file events name that folder. */
|
|
22
|
+
function projectFolder(cwd) {
|
|
23
|
+
const name = cursorProjectName(cwd);
|
|
24
|
+
if (process.platform !== "win32")
|
|
25
|
+
return name;
|
|
26
|
+
try {
|
|
27
|
+
return readdirSync(cursorProjectsRoot()).find((entry) => entry.toLowerCase() === name.toLowerCase()) ?? name;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return name;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export const cursorTranscriptPath = (nativeSessionId, cwd) => join(cursorProjectsRoot(), projectFolder(cwd), TRANSCRIPTS, nativeSessionId, `${nativeSessionId}.jsonl`);
|
|
34
|
+
/** A chat's store directory is keyed by the MD5 of the directory the CLI ran in. */
|
|
35
|
+
export const cursorChatDirectory = (nativeSessionId, cwd) => join(cursorChatsRoot(), createHash("md5").update(resolve(cwd)).digest("hex"), nativeSessionId);
|
|
36
|
+
/** A chat's project never changes, so its header is read once. */
|
|
37
|
+
const headers = new Map();
|
|
38
|
+
/** `<project>/agent-transcripts/<id>/<id>.jsonl`; a subdirectory's project name extends its parent's. Sub-agent files are skipped. */
|
|
39
|
+
function transcriptParts(path, root) {
|
|
40
|
+
const parts = relative(root, path).split(sep);
|
|
41
|
+
return parts.length && !parts[0]?.startsWith("..") ? parts : null;
|
|
42
|
+
}
|
|
43
|
+
/** Windows paths ignore case, so a terminal opened as `c:\\users` names the same project as `C:\\Users`. */
|
|
44
|
+
const fold = (value, platform) => platform === "win32" ? value.toLowerCase() : value;
|
|
45
|
+
const ownsProject = (name, cwd, platform = process.platform) => {
|
|
46
|
+
const project = fold(cursorProjectName(cwd), platform), own = fold(name ?? "", platform);
|
|
47
|
+
return own === project || own.startsWith(`${project}-`);
|
|
48
|
+
};
|
|
49
|
+
export function acceptsCursorDirectory(path, root, cwd) {
|
|
50
|
+
const parts = transcriptParts(path, root);
|
|
51
|
+
if (!parts || parts.length > 3 || !ownsProject(parts[0], cwd))
|
|
52
|
+
return false;
|
|
53
|
+
return parts.length === 1 || parts[1] === TRANSCRIPTS;
|
|
54
|
+
}
|
|
55
|
+
export function acceptsCursorTranscript(path, cwd) {
|
|
56
|
+
const parts = transcriptParts(path, cursorProjectsRoot());
|
|
57
|
+
return parts?.length === 4 && ownsProject(parts[0], cwd) && parts[1] === TRANSCRIPTS && parts[3] === `${parts[2]}.jsonl`;
|
|
58
|
+
}
|
|
59
|
+
export async function projectDirectory(name, platform = process.platform, list = (directory) => readdir(directory, { withFileTypes: true })) {
|
|
60
|
+
const paths = platform === "win32" ? win32 : posix;
|
|
61
|
+
const drive = platform === "win32" ? /^([A-Za-z])(?:-(.*))?$/.exec(name) : null;
|
|
62
|
+
if (platform === "win32" && !drive)
|
|
63
|
+
return null;
|
|
64
|
+
const found = [];
|
|
65
|
+
const walk = async (directory, rest) => {
|
|
66
|
+
if (found.length > 1)
|
|
67
|
+
return;
|
|
68
|
+
if (!rest) {
|
|
69
|
+
found.push(directory);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const entries = await list(directory).catch(() => []);
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
75
|
+
continue;
|
|
76
|
+
const part = fold(cursorProjectName(entry.name), platform);
|
|
77
|
+
if (part && (fold(rest, platform) === part || fold(rest, platform).startsWith(`${part}-`)))
|
|
78
|
+
await walk(paths.join(directory, entry.name), rest.slice(part.length + 1));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
await (drive ? walk(`${drive[1]?.toUpperCase()}:\\`, drive[2] ?? "") : walk("/", name));
|
|
82
|
+
return found.length === 1 ? found[0] ?? null : null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The transcript does not name its project. A CLI chat's `meta.json` does; the IDE writes the same
|
|
86
|
+
* transcripts without one, so its project name is resolved to the directory it stands for.
|
|
87
|
+
*/
|
|
88
|
+
export async function cursorSessionHeader(path) {
|
|
89
|
+
const nativeSessionId = basename(path, ".jsonl");
|
|
90
|
+
if (basename(dirname(path)) !== nativeSessionId)
|
|
91
|
+
return null;
|
|
92
|
+
const known = headers.get(nativeSessionId);
|
|
93
|
+
if (known)
|
|
94
|
+
return known;
|
|
95
|
+
let header = null;
|
|
96
|
+
const hashes = await readdir(cursorChatsRoot()).catch((error) => { if (missing(error))
|
|
97
|
+
return []; throw error; });
|
|
98
|
+
for (const hash of hashes) {
|
|
99
|
+
const meta = await readFile(join(cursorChatsRoot(), hash, nativeSessionId, "meta.json"), "utf8").then((raw) => record(JSON.parse(raw)), () => null);
|
|
100
|
+
if (!meta || !text(meta.cwd))
|
|
101
|
+
continue;
|
|
102
|
+
header = { harness: "cursor", nativeSessionId, cwd: text(meta.cwd), thread: meta.isSubagent === true ? "spawned" : "user" };
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
const project = basename(dirname(dirname(dirname(path))));
|
|
106
|
+
const cwd = header ? null : await projectDirectory(project);
|
|
107
|
+
header ??= cwd ? { harness: "cursor", nativeSessionId, cwd, thread: "user" } : null;
|
|
108
|
+
if (header)
|
|
109
|
+
headers.set(nativeSessionId, header);
|
|
110
|
+
return header;
|
|
111
|
+
}
|
|
112
|
+
/** "Saturday, Aug 01, 2026, 9:30 AM (UTC+8)": minutes only, so Turn times are approximate. */
|
|
113
|
+
function promptTime(value) {
|
|
114
|
+
const match = /^\w+, (\w{3}) (\d{1,2}), (\d{4}), (\d{1,2}):(\d{2}) (AM|PM) \(UTC(?:([+-])(\d{1,2})(?::(\d{2}))?)?\)$/.exec(value.trim());
|
|
115
|
+
const month = MONTHS.indexOf(match?.[1] ?? "");
|
|
116
|
+
if (!match || month < 0)
|
|
117
|
+
return null;
|
|
118
|
+
const hour = Number(match[4]) % 12 + (match[6] === "PM" ? 12 : 0);
|
|
119
|
+
const offset = (match[7] === "-" ? -1 : 1) * (Number(match[8] ?? 0) * 60 + Number(match[9] ?? 0));
|
|
120
|
+
const time = Date.UTC(Number(match[3]), month, Number(match[2]), hour, Number(match[5])) - offset * 60_000;
|
|
121
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
|
122
|
+
}
|
|
123
|
+
const blocks = (value) => array(record(record(value).message).content).map(record);
|
|
124
|
+
/**
|
|
125
|
+
* Cursor writes the model's messages without ids or times: a prompt (`<user_query>`, or a bare text
|
|
126
|
+
* that no other user line follows), one line per reply step with its tool calls (results are not
|
|
127
|
+
* recorded), and `turn_ended` after every Turn. A user line followed by another is context. A Turn
|
|
128
|
+
* is keyed by the file's checksum through its prompt, so a rewritten history never reuses a key.
|
|
129
|
+
*/
|
|
130
|
+
export async function readCursorTranscript(path, nativeSessionId, signal) {
|
|
131
|
+
const read = await readChainedLines(path, signal);
|
|
132
|
+
const modified = (await stat(path)).mtime.toISOString();
|
|
133
|
+
const turns = [];
|
|
134
|
+
let current = null;
|
|
135
|
+
for (const [index, line] of read.lines.entries()) {
|
|
136
|
+
const value = line.value;
|
|
137
|
+
if (value.role === "user") {
|
|
138
|
+
const raw = blocks(value).map((block) => text(block.text)).join("\n");
|
|
139
|
+
// A bare text is a prompt only once a reply or end follows it; until then it may be context.
|
|
140
|
+
const next = read.lines[index + 1]?.value;
|
|
141
|
+
const query = USER_QUERY.exec(raw)?.[1] ?? (next && next.role !== "user" ? raw.replace(TIMESTAMP, "").trim() : null);
|
|
142
|
+
if (!query)
|
|
143
|
+
continue;
|
|
144
|
+
const startedAt = promptTime(TIMESTAMP.exec(raw)?.[1] ?? "") ?? current?.startedAt ?? modified;
|
|
145
|
+
current = {
|
|
146
|
+
key: line.sha256, parentKey: turns.at(-1)?.key ?? null, userContent: [{ type: "text", text: query }], messages: [], startedAt,
|
|
147
|
+
startBytes: line.startBytes, endBytes: line.endBytes, contentEndBytes: line.endBytes, boundaries: {}, sha256: line.sha256, result: null,
|
|
148
|
+
};
|
|
149
|
+
turns.push(current);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!current)
|
|
153
|
+
continue;
|
|
154
|
+
if (value.role === "assistant") {
|
|
155
|
+
const content = blocks(value).flatMap((block, position) => {
|
|
156
|
+
if (block.type === "text")
|
|
157
|
+
return text(block.text) ? [{ type: "text", text: text(block.text) }] : [];
|
|
158
|
+
if (block.type !== "tool_use")
|
|
159
|
+
return [];
|
|
160
|
+
return [{ type: "tool_use", id: `cursor_${line.sha256.slice(0, 24)}_${position}`, name: text(block.name) || "tool", input: record(block.input) }];
|
|
161
|
+
});
|
|
162
|
+
if (content.length)
|
|
163
|
+
current.messages.push({ content, provider: null, model: null, stopReason: content.some((block) => block.type === "tool_use") ? "tool_use" : "stop", errorMessage: null });
|
|
164
|
+
extendTurn(current, line, true);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (value.type === "turn_ended" && !current.result) {
|
|
168
|
+
const status = text(value.status) || "success";
|
|
169
|
+
const last = current.messages.at(-1);
|
|
170
|
+
if (last && status === "error")
|
|
171
|
+
Object.assign(last, { stopReason: "error", errorMessage: text(value.error) || "Turn failed" });
|
|
172
|
+
if (last && status === "aborted")
|
|
173
|
+
last.stopReason = "aborted";
|
|
174
|
+
current.result = { messages: current.messages, completedAt: modified, status: status === "error" ? "failed" : status === "aborted" ? "interrupted" : "completed" };
|
|
175
|
+
current.ended = true;
|
|
176
|
+
extendTurn(current, line, false);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// A Turn ends no later than the next one starts.
|
|
180
|
+
turns.forEach((turn, index) => { if (turn.result)
|
|
181
|
+
turn.result.completedAt = turns[index + 1]?.startedAt ?? modified; });
|
|
182
|
+
return { nativeSessionId, cwd: (await cursorSessionHeader(path))?.cwd ?? "", turns, prefixes: read.prefixes };
|
|
183
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RuntimeExecutionEvent, RuntimeMessage } from "@neta-art/cohub";
|
|
2
|
+
import { type JsonRecord } from "../json-rpc.js";
|
|
3
|
+
import type { HarnessModel, LocalHarnessAdapter } from "./types.js";
|
|
4
|
+
/** The process a Cursor CLI invocation runs, and the variables its launcher would set. */
|
|
5
|
+
export declare function cursorCommand(executable: string, args: string[], cwd: string): {
|
|
6
|
+
command: [string, string[]];
|
|
7
|
+
env: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
/** `cursor-agent models`: `<id> - <name> (current, default)` per line. */
|
|
10
|
+
export declare function cursorModels(output: string): HarnessModel[];
|
|
11
|
+
/**
|
|
12
|
+
* Maps `--output-format stream-json --stream-partial-output` onto Cohub's streaming protocol. A reply
|
|
13
|
+
* step ends once its tool calls returned. Cursor repeats a step's streamed text as one line right
|
|
14
|
+
* before a tool call, question or retry (with a timestamp) and at the end (without one); such a line
|
|
15
|
+
* is held until the next event says whether it was a repeat.
|
|
16
|
+
*/
|
|
17
|
+
export declare function cursorTranslator(emit: (event: RuntimeExecutionEvent) => void, identity: {
|
|
18
|
+
model: string | null;
|
|
19
|
+
}): {
|
|
20
|
+
result: () => RuntimeMessage | null;
|
|
21
|
+
last: () => RuntimeMessage;
|
|
22
|
+
push(event: JsonRecord): boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare const cursorHarness: LocalHarnessAdapter;
|