@neta-art/cohub-cli 10.7.0 → 10.8.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/README.md +4 -4
- package/dist/board-kernel.js +276 -214
- package/dist/commands/boards/examples.js +16 -16
- package/dist/commands/boards.js +1 -1
- package/dist/commands/runtime.js +3 -14
- package/dist/runtime/connection.js +161 -54
- 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 +20 -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 +5 -1
- package/dist/runtime/harnesses/stream-json.js +31 -9
- 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 +24 -2
- package/dist/runtime/native/ingest.js +76 -10
- package/dist/runtime/native/sessions.js +6 -1
- package/dist/runtime/native/transcript.d.ts +23 -0
- package/dist/runtime/native/transcript.js +40 -5
- package/package.json +3 -3
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { record } from "../json-rpc.js";
|
|
2
|
+
import { iso } from "../native/transcript.js";
|
|
3
|
+
import { chainBranch, extendTurn, readChainedLines, startTurn } from "./chained-transcript.js";
|
|
4
|
+
import { array, text } from "./process.js";
|
|
5
|
+
import { streamContent, streamUsage } from "./stream-json.js";
|
|
6
|
+
const COMMAND = /^<command-name>\s*([^<]*)<\/command-name>/;
|
|
7
|
+
const COMMAND_ARGS = /<command-args>([\s\S]*?)<\/command-args>/;
|
|
8
|
+
const BASH_INPUT = /^<bash-input>([\s\S]*?)<\/bash-input>/;
|
|
9
|
+
/** Output of a local command or `!` shell, recorded as a user message but never sent as a prompt. */
|
|
10
|
+
const LOCAL_OUTPUT = /^<(?:local-command-(?:stdout|stderr|caveat)|bash-(?:stdout|stderr))>/;
|
|
11
|
+
const INTERRUPTED = "[Request interrupted by user";
|
|
12
|
+
export const CLAUDE_REPLY_ENDED = ["end_turn", "stop_sequence", "max_tokens", "refusal", "aborted", "error"];
|
|
13
|
+
const nonEmpty = (block) => !(block.type === "text" && !block.text) && !(block.type === "thinking" && !block.thinking);
|
|
14
|
+
/** A local `/command` or `!shell` is shown as typed; one that never reached the model is no Turn. */
|
|
15
|
+
function prompt(value) {
|
|
16
|
+
const content = typeof value === "string" ? [{ type: "text", text: value }] : streamContent(value).filter((block) => block.type === "text" || block.type === "image");
|
|
17
|
+
const first = content.find((block) => block.type === "text")?.text.trimStart() ?? "";
|
|
18
|
+
if (LOCAL_OUTPUT.test(first) || first.startsWith(INTERRUPTED))
|
|
19
|
+
return null;
|
|
20
|
+
const command = COMMAND.exec(first);
|
|
21
|
+
if (command)
|
|
22
|
+
return { content: [{ type: "text", text: `${command[1]?.trim() ?? ""} ${COMMAND_ARGS.exec(first)?.[1]?.trim() ?? ""}`.trim() }], local: true };
|
|
23
|
+
const bash = BASH_INPUT.exec(first);
|
|
24
|
+
if (bash)
|
|
25
|
+
return { content: [{ type: "text", text: `!${bash[1] ?? ""}` }], local: true };
|
|
26
|
+
const visible = content.filter(nonEmpty);
|
|
27
|
+
return visible.length ? { content: visible, local: false } : null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Claude Code appends one record per content block, linked by `parentUuid` (`logicalParentUuid`
|
|
31
|
+
* across a compaction). A Turn starts at a prompt; its tool results and an interruption share the
|
|
32
|
+
* prompt's `promptId`. Interactive sessions record each end (`turn_duration`), `-p` sessions do not.
|
|
33
|
+
*/
|
|
34
|
+
export async function readClaudeTranscript(path, nativeSessionId, signal) {
|
|
35
|
+
const read = await readChainedLines(path, signal);
|
|
36
|
+
const branch = chainBranch(read.lines, (value) => value.isSidechain === true ? "" : text(value.uuid), (value) => text(value.parentUuid) || text(value.logicalParentUuid));
|
|
37
|
+
const turns = [];
|
|
38
|
+
const owners = new Map();
|
|
39
|
+
const messageIds = new Map();
|
|
40
|
+
let cwd = "", cloudSessionId = "";
|
|
41
|
+
let current = null;
|
|
42
|
+
let promptId = "", local = false, interrupted = false, completedAt = "";
|
|
43
|
+
const finish = () => {
|
|
44
|
+
if (!current)
|
|
45
|
+
return;
|
|
46
|
+
// A local command that never reached the model is not a conversation Turn.
|
|
47
|
+
if (local && !current.messages.length) {
|
|
48
|
+
current = null;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const last = current.messages.at(-1);
|
|
52
|
+
const status = interrupted || !last || last.stopReason === "tool_use" ? "interrupted" : last.stopReason === "error" ? "failed" : "completed";
|
|
53
|
+
current.result = { messages: current.messages, completedAt: completedAt || current.startedAt, status };
|
|
54
|
+
turns.push(current);
|
|
55
|
+
current = null;
|
|
56
|
+
};
|
|
57
|
+
for (const line of branch) {
|
|
58
|
+
const value = line.value;
|
|
59
|
+
cwd ||= text(value.cwd);
|
|
60
|
+
if (value.type === "user") {
|
|
61
|
+
const message = record(value.message);
|
|
62
|
+
const results = array(message.content).map(record).filter((block) => block.type === "tool_result");
|
|
63
|
+
const opened = value.isMeta === true || value.isCompactSummary === true || results.length ? null : prompt(message.content);
|
|
64
|
+
if (opened && !(current && text(value.promptId) && text(value.promptId) === promptId)) {
|
|
65
|
+
finish();
|
|
66
|
+
const cohub = record(value.cohub);
|
|
67
|
+
cloudSessionId ||= text(cohub.sessionId);
|
|
68
|
+
// Cohub sends its Turn id as the prompt's record id; a projected record names its Turn.
|
|
69
|
+
current = startTurn(line, text(value.uuid), turns.at(-1)?.key ?? null, opened.content, text(cohub.turnId) || text(value.uuid));
|
|
70
|
+
promptId = text(value.promptId);
|
|
71
|
+
local = opened.local;
|
|
72
|
+
interrupted = false;
|
|
73
|
+
completedAt = current.startedAt;
|
|
74
|
+
owners.clear();
|
|
75
|
+
messageIds.clear();
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!current)
|
|
79
|
+
continue;
|
|
80
|
+
for (const block of results) {
|
|
81
|
+
const id = text(block.tool_use_id);
|
|
82
|
+
const owner = owners.get(id);
|
|
83
|
+
if (!owner)
|
|
84
|
+
continue;
|
|
85
|
+
owner.content.push({ type: "tool_result", tool_use_id: id, content: typeof block.content === "string" ? block.content : streamContent(block.content), is_error: block.is_error === true });
|
|
86
|
+
}
|
|
87
|
+
if (array(message.content).some((block) => text(record(block).text).startsWith(INTERRUPTED)) || text(message.content).startsWith(INTERRUPTED)) {
|
|
88
|
+
interrupted = true;
|
|
89
|
+
current.ended = true;
|
|
90
|
+
}
|
|
91
|
+
completedAt = iso(value.timestamp);
|
|
92
|
+
extendTurn(current, line, true);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!current)
|
|
96
|
+
continue;
|
|
97
|
+
if (value.type === "assistant") {
|
|
98
|
+
const native = record(value.message);
|
|
99
|
+
const id = text(native.id);
|
|
100
|
+
let target = current.messages.at(-1);
|
|
101
|
+
if (!target || !id || messageIds.get(target) !== id) {
|
|
102
|
+
target = { content: [], provider: null, model: null, stopReason: null, errorMessage: null };
|
|
103
|
+
current.messages.push(target);
|
|
104
|
+
messageIds.set(target, id);
|
|
105
|
+
}
|
|
106
|
+
const content = streamContent(native.content).filter(nonEmpty);
|
|
107
|
+
for (const block of content) {
|
|
108
|
+
const index = block.type === "tool_use" ? target.content.findIndex((existing) => existing.type === "tool_use" && existing.id === block.id) : -1;
|
|
109
|
+
if (index >= 0)
|
|
110
|
+
target.content[index] = block;
|
|
111
|
+
else
|
|
112
|
+
target.content.push(block);
|
|
113
|
+
if (block.type === "tool_use")
|
|
114
|
+
owners.set(block.id, target);
|
|
115
|
+
}
|
|
116
|
+
const model = text(native.model);
|
|
117
|
+
if (model && model !== "<synthetic>")
|
|
118
|
+
target.model = model;
|
|
119
|
+
if (text(native.stop_reason))
|
|
120
|
+
target.stopReason = text(native.stop_reason);
|
|
121
|
+
const usage = streamUsage(native.usage, undefined);
|
|
122
|
+
if (usage)
|
|
123
|
+
target.usage = usage;
|
|
124
|
+
if (value.isApiErrorMessage === true) {
|
|
125
|
+
target.stopReason = "error";
|
|
126
|
+
target.errorMessage = content.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n") || "API error";
|
|
127
|
+
}
|
|
128
|
+
completedAt = iso(value.timestamp);
|
|
129
|
+
extendTurn(current, line, true);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (value.type === "system" && value.subtype === "turn_duration")
|
|
133
|
+
current.ended = true;
|
|
134
|
+
extendTurn(current, line, false);
|
|
135
|
+
}
|
|
136
|
+
finish();
|
|
137
|
+
return { nativeSessionId, cwd, ...(cloudSessionId ? { cloudSessionId } : {}), turns, prefixes: read.prefixes };
|
|
138
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
4
|
import { JsonRpcProcess, record } from "../json-rpc.js";
|
|
5
5
|
import { copyNativeArchive } from "../native/archive.js";
|
|
6
6
|
import { projectChainedSession, projectionMeta, projectionToolId, rebindSessionId } from "./chained-projection.js";
|
|
7
|
+
import { chainedSessionHeader, projectDirectory, projectTranscript } from "./chained-transcript.js";
|
|
8
|
+
import { CLAUDE_REPLY_ENDED, readClaudeTranscript } from "./claude-transcript.js";
|
|
7
9
|
import { array, harnessCommand, text } from "./process.js";
|
|
8
10
|
import { DEFAULT_MODEL, runStreamJson, streamJsonHelp, streamJsonModel } from "./stream-json.js";
|
|
9
11
|
const MAX_PROJECT_NAME = 200;
|
|
@@ -23,6 +25,9 @@ export function claudeProjectName(cwd) {
|
|
|
23
25
|
return `${name.slice(0, MAX_PROJECT_NAME)}-${Math.abs(hash).toString(36)}`;
|
|
24
26
|
}
|
|
25
27
|
export const claudeProjectDirectory = (cwd) => join(dataRoot(), "projects", claudeProjectName(cwd));
|
|
28
|
+
const projectsRoot = () => join(dataRoot(), "projects");
|
|
29
|
+
/** Every subdirectory's project name starts with this; a hashed long name keeps its first 200 units. */
|
|
30
|
+
const projectPrefix = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, "-").slice(0, MAX_PROJECT_NAME);
|
|
26
31
|
const profile = {
|
|
27
32
|
harness: "claude",
|
|
28
33
|
label: "Claude Code",
|
|
@@ -31,6 +36,7 @@ const profile = {
|
|
|
31
36
|
dataRoot,
|
|
32
37
|
projectDirectory: claudeProjectDirectory,
|
|
33
38
|
effortLevels: ["low", "medium", "high", "xhigh", "max"],
|
|
39
|
+
promptUuid: true,
|
|
34
40
|
};
|
|
35
41
|
function anthropicBlock(block) {
|
|
36
42
|
if (block.type === "text")
|
|
@@ -149,5 +155,17 @@ export const claudeHarness = {
|
|
|
149
155
|
},
|
|
150
156
|
},
|
|
151
157
|
},
|
|
152
|
-
|
|
158
|
+
transcript: {
|
|
159
|
+
roots: () => [projectsRoot()],
|
|
160
|
+
acceptsDirectory: (path, root, cwd) => projectDirectory(path, root, projectPrefix(cwd)),
|
|
161
|
+
acceptsTranscript: (path, cwd) => projectTranscript(path, projectsRoot(), projectPrefix(cwd)),
|
|
162
|
+
header: (path, signal) => chainedSessionHeader(path, "claude", signal),
|
|
163
|
+
read: (path, signal) => readClaudeTranscript(path, basename(path, ".jsonl"), signal),
|
|
164
|
+
boundaries: ['"promptSource"', '"subtype":"turn_duration"', '"interruptedMessageId"', '"isApiErrorMessage":true', ...CLAUDE_REPLY_ENDED.map((reason) => `"stop_reason":"${reason}"`)],
|
|
165
|
+
turnEndRecorded: false,
|
|
166
|
+
replyEnded: (turn) => CLAUDE_REPLY_ENDED.includes(turn.messages.at(-1)?.stopReason ?? ""),
|
|
167
|
+
userFinalMarked: false,
|
|
168
|
+
globalTurnKeys: false,
|
|
169
|
+
marksCohubTurns: true,
|
|
170
|
+
},
|
|
153
171
|
};
|
|
@@ -223,7 +223,7 @@ export const codexHarness = {
|
|
|
223
223
|
turnEndRecorded: true,
|
|
224
224
|
userFinalMarked: true,
|
|
225
225
|
globalTurnKeys: true,
|
|
226
|
+
marksCohubTurns: true,
|
|
226
227
|
archiveSource: (path, cacheDirectory) => ensurePlainCodexRollout(path, cacheDirectory),
|
|
227
228
|
},
|
|
228
|
-
nativeSync: true,
|
|
229
229
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { NativeTranscript } from "../native/transcript.js";
|
|
2
|
+
import type { SessionHeader } from "./types.js";
|
|
3
|
+
/** Chats (`store.db` and `meta.json`) live under the config directory, transcripts under the data directory. */
|
|
4
|
+
export declare const cursorConfigDirectory: () => string;
|
|
5
|
+
export declare const cursorProjectsRoot: () => string;
|
|
6
|
+
export declare const cursorChatsRoot: () => string;
|
|
7
|
+
/** Cursor's project name for a workspace path. */
|
|
8
|
+
export declare const cursorProjectName: (cwd: string) => string;
|
|
9
|
+
export declare const cursorTranscriptPath: (nativeSessionId: string, cwd: string) => string;
|
|
10
|
+
/** A chat's store directory is keyed by the MD5 of the directory the CLI ran in. */
|
|
11
|
+
export declare const cursorChatDirectory: (nativeSessionId: string, cwd: string) => string;
|
|
12
|
+
export declare function acceptsCursorDirectory(path: string, root: string, cwd: string): boolean;
|
|
13
|
+
export declare function acceptsCursorTranscript(path: string, cwd: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The directory a project name stands for, matched against real directories. On Windows the name
|
|
16
|
+
* starts with the drive letter and matching ignores case. Several directories with the same name
|
|
17
|
+
* (`a/b` and `a-b`) cannot be told apart, so none is chosen.
|
|
18
|
+
*/
|
|
19
|
+
type Entry = {
|
|
20
|
+
name: string;
|
|
21
|
+
isDirectory(): boolean;
|
|
22
|
+
isSymbolicLink(): boolean;
|
|
23
|
+
};
|
|
24
|
+
export declare function projectDirectory(name: string, platform?: NodeJS.Platform, list?: (directory: string) => Promise<Entry[]>): Promise<string | null>;
|
|
25
|
+
/**
|
|
26
|
+
* The transcript does not name its project. A CLI chat's `meta.json` does; the IDE writes the same
|
|
27
|
+
* transcripts without one, so its project name is resolved to the directory it stands for.
|
|
28
|
+
*/
|
|
29
|
+
export declare function cursorSessionHeader(path: string): Promise<SessionHeader | null>;
|
|
30
|
+
/**
|
|
31
|
+
* Cursor writes the model's messages without ids or times: a prompt (`<user_query>`, or a bare text
|
|
32
|
+
* that no other user line follows), one line per reply step with its tool calls (results are not
|
|
33
|
+
* recorded), and `turn_ended` after every Turn. A user line followed by another is context. A Turn
|
|
34
|
+
* is keyed by the file's checksum through its prompt, so a rewritten history never reuses a key.
|
|
35
|
+
*/
|
|
36
|
+
export declare function readCursorTranscript(path: string, nativeSessionId: string, signal?: AbortSignal): Promise<NativeTranscript>;
|
|
37
|
+
export {};
|
|
@@ -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;
|