resumecontext 0.1.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 +27 -0
- package/dist/agentConfig.js +202 -0
- package/dist/agentConfigWithDaemon.js +42 -0
- package/dist/apiClient.js +54 -0
- package/dist/browser.js +20 -0
- package/dist/cloudApi.js +15 -0
- package/dist/commands/accept.js +20 -0
- package/dist/commands/agents.js +35 -0
- package/dist/commands/auth.js +55 -0
- package/dist/commands/daemon.js +99 -0
- package/dist/commands/init.js +59 -0
- package/dist/commands/logout.js +20 -0
- package/dist/commands/mcp.js +54 -0
- package/dist/commands/members.js +20 -0
- package/dist/commands/projects.js +55 -0
- package/dist/commands/revoke.js +15 -0
- package/dist/commands/share.js +18 -0
- package/dist/commands/sync.js +59 -0
- package/dist/commands/uninstall.js +61 -0
- package/dist/constants.js +61 -0
- package/dist/daemon.js +409 -0
- package/dist/daemonService.js +326 -0
- package/dist/deps.js +1 -0
- package/dist/dev.js +32 -0
- package/dist/device.js +40 -0
- package/dist/httpCloudApi.js +61 -0
- package/dist/index.js +160 -0
- package/dist/localCapture.js +18 -0
- package/dist/localHistory/claudeCode.js +82 -0
- package/dist/localHistory/codex.js +106 -0
- package/dist/localHistory/cursor.js +492 -0
- package/dist/localHistory/index.js +96 -0
- package/dist/localHistory/opencode.js +148 -0
- package/dist/localHistory/registry.js +66 -0
- package/dist/localHistory/shared.js +174 -0
- package/dist/paths.js +85 -0
- package/dist/projectRoot.js +77 -0
- package/dist/session.js +36 -0
- package/dist/syncCore.js +108 -0
- package/dist/syncState.js +51 -0
- package/dist/ui.js +289 -0
- package/dist/utils.js +41 -0
- package/dist/version.js +43 -0
- package/package.json +64 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads real local coding-agent session files (Claude Code, Codex CLI,
|
|
3
|
+
* Cursor, opencode) directly off disk and normalizes them into
|
|
4
|
+
* SyncableTurn[] for one project -- in-process, no subprocess, no
|
|
5
|
+
* dependency on the separate ingest/ package. This is the CLI's own copy:
|
|
6
|
+
* every parsing decision and its rationale (documented in each agent's own
|
|
7
|
+
* file in this directory) was ported from ingest/src/ingest.ts, since that
|
|
8
|
+
* package's parsers are exactly what this needed and there was no reason
|
|
9
|
+
* to re-derive them from scratch -- but this directory owns it now, and
|
|
10
|
+
* the two are free to diverge.
|
|
11
|
+
*
|
|
12
|
+
* Unlike ingest/src/ingest.ts (which writes every project's turns to disk
|
|
13
|
+
* and reports on all of them), this only ever resolves and returns turns
|
|
14
|
+
* for the ONE project `collectLocalTurns` is asked about -- there's no
|
|
15
|
+
* output file, no index, no reporting; localCapture.ts calls this
|
|
16
|
+
* directly and gets SyncableTurn[] back in memory.
|
|
17
|
+
*
|
|
18
|
+
* Never assumes the OS-default location for any agent -- only scans the
|
|
19
|
+
* agents (and directories) present in the user's AgentConfig (see
|
|
20
|
+
* ../agentConfig.ts). An agent absent from the config is skipped entirely,
|
|
21
|
+
* even if its default directory exists on disk.
|
|
22
|
+
*/
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { findProjectRoot, readProjectMarker } from "../projectRoot.js";
|
|
25
|
+
import { unique } from "../utils.js";
|
|
26
|
+
import { AGENT_ADAPTERS, ALL_AGENT_NAMES } from "./registry.js";
|
|
27
|
+
import { sessionKey } from "../syncState.js";
|
|
28
|
+
function collectAllTurns(config, context) {
|
|
29
|
+
const turns = [];
|
|
30
|
+
for (const name of ALL_AGENT_NAMES) {
|
|
31
|
+
const configured = config.agents[name];
|
|
32
|
+
if (!configured)
|
|
33
|
+
continue; // agent not selected -- skip entirely
|
|
34
|
+
const adapter = AGENT_ADAPTERS[name];
|
|
35
|
+
// Deduplicate: the same directory listed twice would parse its turns
|
|
36
|
+
// twice, inflating the per-session counts syncState.ts keeps and
|
|
37
|
+
// making sync think there's more new history than there is.
|
|
38
|
+
for (const dir of unique(configured.dirs)) {
|
|
39
|
+
for (const turn of adapter.collect(dir, context))
|
|
40
|
+
turns.push(turn);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return turns;
|
|
44
|
+
}
|
|
45
|
+
/** Resolves each turn's raw session cwd to its project root (see
|
|
46
|
+
* ../projectRoot.ts), memoized per distinct raw cwd -- resolution does real
|
|
47
|
+
* filesystem walks, and many turns/sessions typically share the same raw
|
|
48
|
+
* cwd, so this avoids re-walking the same path repeatedly across a large
|
|
49
|
+
* corpus. */
|
|
50
|
+
function resolveProjectRoots(turns) {
|
|
51
|
+
const cache = new Map();
|
|
52
|
+
const resolveRoot = (rawCwd) => {
|
|
53
|
+
let root = cache.get(rawCwd);
|
|
54
|
+
if (root === undefined) {
|
|
55
|
+
root = findProjectRoot(rawCwd).root;
|
|
56
|
+
cache.set(rawCwd, root);
|
|
57
|
+
}
|
|
58
|
+
return root;
|
|
59
|
+
};
|
|
60
|
+
return turns.map((t) => ({ ...t, project_cwd: resolveRoot(t.project_cwd) }));
|
|
61
|
+
}
|
|
62
|
+
/** Scans every local coding-agent session store on this machine, resolves
|
|
63
|
+
* each turn's project, and returns only the turns belonging to
|
|
64
|
+
* `projectRoot` (an already-resolved root, e.g. from projectRoot.ts),
|
|
65
|
+
* sorted chronologically -- the same ordering guarantee syncState.ts's
|
|
66
|
+
* per-session cursor relies on. */
|
|
67
|
+
export function collectLocalTurns(projectRoot, config) {
|
|
68
|
+
const target = path.resolve(projectRoot);
|
|
69
|
+
const marker = readProjectMarker(target);
|
|
70
|
+
const context = {
|
|
71
|
+
projectRoot: target,
|
|
72
|
+
projectInitAt: marker?.createdAt ?? new Date(0).toISOString(),
|
|
73
|
+
};
|
|
74
|
+
const turns = resolveProjectRoots(collectAllTurns(config, context)).filter((t) => t.project_cwd === target);
|
|
75
|
+
turns.sort((a, b) => (a.timestamp || "").localeCompare(b.timestamp || ""));
|
|
76
|
+
const sessionMtimeMs = {};
|
|
77
|
+
for (const t of turns) {
|
|
78
|
+
if (t.source_mtime_ms == null || !t.session_id)
|
|
79
|
+
continue;
|
|
80
|
+
const key = sessionKey(t.agent, t.session_id);
|
|
81
|
+
const prev = sessionMtimeMs[key];
|
|
82
|
+
if (prev === undefined || t.source_mtime_ms > prev)
|
|
83
|
+
sessionMtimeMs[key] = t.source_mtime_ms;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
turns: turns.map((t) => ({
|
|
87
|
+
sessionId: t.session_id ?? "",
|
|
88
|
+
agent: t.agent,
|
|
89
|
+
role: t.role,
|
|
90
|
+
timestamp: t.timestamp,
|
|
91
|
+
text: t.text,
|
|
92
|
+
toolCalls: t.tool_calls,
|
|
93
|
+
})),
|
|
94
|
+
sessionMtimeMs,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode's storage is plain JSON files, three levels deep, no reverse
|
|
3
|
+
* engineering needed:
|
|
4
|
+
* storage/session/<projectId>/<sessionId>.json -- id, directory (cwd), time
|
|
5
|
+
* storage/message/<sessionId>/<messageId>.json -- role, time.created
|
|
6
|
+
* storage/part/<messageId>/<partId>.json -- the actual content
|
|
7
|
+
* A "part" is opencode's block: text, reasoning, tool (call+result already
|
|
8
|
+
* combined in one part, unlike Claude/Codex/Cursor which split them into
|
|
9
|
+
* separate turns -- rendered as one call+result pair within the owning
|
|
10
|
+
* message's turn, matching how opencode itself models it), step-start/
|
|
11
|
+
* step-finish (pure bookkeeping: cost/token counters, no conversational
|
|
12
|
+
* content -- skipped, nothing lost), and patch (a file-diff marker with a
|
|
13
|
+
* file list but no inline diff text in this store).
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { listDirs, listFiles, msToIso, newestMtimeMs, readJsonSafe } from "./shared.js";
|
|
19
|
+
export const DEFAULT_OPENCODE_DIR = path.join(os.homedir(), ".local", "share", "opencode");
|
|
20
|
+
export const DEFAULT_OPENCODE_STORAGE_DIR = path.join(DEFAULT_OPENCODE_DIR, "storage");
|
|
21
|
+
export function opencodeStorageDir(configuredDir) {
|
|
22
|
+
return path.join(path.resolve(configuredDir), "storage");
|
|
23
|
+
}
|
|
24
|
+
function renderOpencodePart(part) {
|
|
25
|
+
const ptype = part.type;
|
|
26
|
+
if (ptype === "text")
|
|
27
|
+
return part.text || "";
|
|
28
|
+
if (ptype === "reasoning") {
|
|
29
|
+
const text = part.text;
|
|
30
|
+
return text ? `[thinking] ${text}` : "";
|
|
31
|
+
}
|
|
32
|
+
if (ptype === "tool") {
|
|
33
|
+
const name = part.tool || "unknown_tool";
|
|
34
|
+
const state = part.state || {};
|
|
35
|
+
const call = `[tool_call: ${name}] ${JSON.stringify(state.input ?? {})}`;
|
|
36
|
+
if (state.status === "error") {
|
|
37
|
+
return `${call}\n[tool_result:error] ${state.error ?? ""}`;
|
|
38
|
+
}
|
|
39
|
+
if ("output" in state) {
|
|
40
|
+
let output = state.output;
|
|
41
|
+
if (typeof output !== "string")
|
|
42
|
+
output = JSON.stringify(output);
|
|
43
|
+
return `${call}\n[tool_result] ${output}`;
|
|
44
|
+
}
|
|
45
|
+
return call; // still running/pending -- no result yet
|
|
46
|
+
}
|
|
47
|
+
if (ptype === "patch") {
|
|
48
|
+
const files = part.files || [];
|
|
49
|
+
return `[patch] changed files: ${JSON.stringify(files)}`;
|
|
50
|
+
}
|
|
51
|
+
if (ptype === "step-start" || ptype === "step-finish") {
|
|
52
|
+
return ""; // bookkeeping only (snapshot hash, cost, token counts)
|
|
53
|
+
}
|
|
54
|
+
// Unrecognized part type -- dump raw rather than silently drop.
|
|
55
|
+
return `[${ptype || "unknown_part"}] ${JSON.stringify(part)}`;
|
|
56
|
+
}
|
|
57
|
+
/** Yields normalized turns from one opencode session.json, pulling its
|
|
58
|
+
* messages and their parts from the sibling message/ and part/ stores under
|
|
59
|
+
* `storageDir`. */
|
|
60
|
+
function parseOpencodeSession(sessionPath, storageDir) {
|
|
61
|
+
const session = readJsonSafe(sessionPath);
|
|
62
|
+
if (!session)
|
|
63
|
+
return [];
|
|
64
|
+
const cwd = session.directory;
|
|
65
|
+
const sessionId = session.id;
|
|
66
|
+
if (!cwd || !sessionId)
|
|
67
|
+
return [];
|
|
68
|
+
const messageDir = path.join(storageDir, "message", sessionId);
|
|
69
|
+
const partRootDir = path.join(storageDir, "part");
|
|
70
|
+
if (!fs.existsSync(messageDir))
|
|
71
|
+
return [];
|
|
72
|
+
const messages = [];
|
|
73
|
+
for (const fname of fs.readdirSync(messageDir)) {
|
|
74
|
+
if (!fname.endsWith(".json"))
|
|
75
|
+
continue;
|
|
76
|
+
const messagePath = path.join(messageDir, fname);
|
|
77
|
+
const m = readJsonSafe(messagePath);
|
|
78
|
+
if (m && (m.role === "user" || m.role === "assistant"))
|
|
79
|
+
messages.push({ m, path: messagePath });
|
|
80
|
+
}
|
|
81
|
+
messages.sort((a, b) => (a.m.time?.created || 0) - (b.m.time?.created || 0));
|
|
82
|
+
const turns = [];
|
|
83
|
+
for (const { m, path: messagePath } of messages) {
|
|
84
|
+
const messageId = m.id;
|
|
85
|
+
const createdMs = m.time?.created ?? null;
|
|
86
|
+
const msgPartDir = messageId ? path.join(partRootDir, messageId) : null;
|
|
87
|
+
const parts = [];
|
|
88
|
+
const partPaths = [];
|
|
89
|
+
if (msgPartDir && fs.existsSync(msgPartDir)) {
|
|
90
|
+
for (const fname of fs.readdirSync(msgPartDir)) {
|
|
91
|
+
if (!fname.endsWith(".json"))
|
|
92
|
+
continue;
|
|
93
|
+
const partPath = path.join(msgPartDir, fname);
|
|
94
|
+
const p = readJsonSafe(partPath);
|
|
95
|
+
if (p) {
|
|
96
|
+
parts.push(p);
|
|
97
|
+
partPaths.push(partPath);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Part filenames are ULID-like (lexicographically time-sortable);
|
|
101
|
+
// fall back to that when a part lacks its own time.start (e.g.
|
|
102
|
+
// step-start/step-finish, tool calls still in flight).
|
|
103
|
+
parts.sort((a, b) => {
|
|
104
|
+
const ta = a.time?.start || 0;
|
|
105
|
+
const tb = b.time?.start || 0;
|
|
106
|
+
if (ta !== tb)
|
|
107
|
+
return ta - tb;
|
|
108
|
+
return String(a.id || "").localeCompare(String(b.id || ""));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const rendered = parts.map(renderOpencodePart).filter((r) => r);
|
|
112
|
+
const text = rendered.join("\n");
|
|
113
|
+
const toolCalls = parts.filter((p) => p.type === "tool").map((p) => p.tool || "unknown_tool");
|
|
114
|
+
if (!text && toolCalls.length === 0)
|
|
115
|
+
continue;
|
|
116
|
+
const source_mtime_ms = newestMtimeMs([sessionPath, messagePath, ...partPaths]);
|
|
117
|
+
turns.push({
|
|
118
|
+
project_cwd: cwd, // resolved to the real project root later
|
|
119
|
+
session_id: sessionId,
|
|
120
|
+
agent: "opencode",
|
|
121
|
+
timestamp: createdMs !== null ? msToIso(createdMs) : null,
|
|
122
|
+
role: m.role,
|
|
123
|
+
text,
|
|
124
|
+
tool_calls: toolCalls,
|
|
125
|
+
source_mtime_ms,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return turns;
|
|
129
|
+
}
|
|
130
|
+
export const OPENCODE_DIR_SHAPE = "~/.local/share/opencode (storage/session|message|part/ is resolved at sync time)";
|
|
131
|
+
/** True when `dir` is the top-level opencode install dir (~/.local/share/opencode). */
|
|
132
|
+
export function looksLikeOpencodeDir(dir) {
|
|
133
|
+
const storageDir = opencodeStorageDir(dir);
|
|
134
|
+
return fs.existsSync(path.join(storageDir, "session")) && fs.existsSync(path.join(storageDir, "message"));
|
|
135
|
+
}
|
|
136
|
+
/** `configuredDir` is the user-configured top-level dir (~/.local/share/opencode). */
|
|
137
|
+
export function collectOpencodeTurns(configuredDir) {
|
|
138
|
+
const turns = [];
|
|
139
|
+
const storageDir = opencodeStorageDir(configuredDir);
|
|
140
|
+
const opencodeSessionDir = path.join(storageDir, "session");
|
|
141
|
+
for (const projectDir of listDirs(opencodeSessionDir)) {
|
|
142
|
+
for (const sessionFile of listFiles(projectDir, ".json")) {
|
|
143
|
+
for (const turn of parseOpencodeSession(sessionFile, storageDir))
|
|
144
|
+
turns.push(turn);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return turns;
|
|
148
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One entry per supported coding agent, tying together everything the rest
|
|
3
|
+
* of the CLI needs to know about it: what to call it, where it usually
|
|
4
|
+
* lives, how to recognize a valid data directory, and how to read turns out
|
|
5
|
+
* of one. Supporting a new agent is a new file in this directory plus one
|
|
6
|
+
* entry here -- nothing else in the codebase enumerates agents.
|
|
7
|
+
*/
|
|
8
|
+
import { DEFAULT_CLAUDE_DIR, CLAUDE_CODE_DIR_SHAPE, claudeProjectsDir, looksLikeClaudeCodeDir, collectClaudeCodeTurns, } from "./claudeCode.js";
|
|
9
|
+
import { DEFAULT_CODEX_DIR, CODEX_DIR_SHAPE, codexSessionsDir, looksLikeCodexDir, collectCodexTurns } from "./codex.js";
|
|
10
|
+
import { DEFAULT_CURSOR_DIR, CURSOR_DIR_SHAPE, cursorDerivedDirs, looksLikeCursorDir, collectCursorTurns, collectCursorTurnsForProject } from "./cursor.js";
|
|
11
|
+
import { DEFAULT_OPENCODE_DIR, OPENCODE_DIR_SHAPE, opencodeStorageDir, looksLikeOpencodeDir, collectOpencodeTurns, } from "./opencode.js";
|
|
12
|
+
export const AGENT_ADAPTERS = {
|
|
13
|
+
"claude-code": {
|
|
14
|
+
name: "claude-code",
|
|
15
|
+
label: "Claude Code",
|
|
16
|
+
defaultDir: DEFAULT_CLAUDE_DIR,
|
|
17
|
+
expectedShape: CLAUDE_CODE_DIR_SHAPE,
|
|
18
|
+
looksLikeDataDir: looksLikeClaudeCodeDir,
|
|
19
|
+
collect: collectClaudeCodeTurns,
|
|
20
|
+
fingerprintDirs: (_projectRoot, configuredDir) => [claudeProjectsDir(configuredDir)],
|
|
21
|
+
},
|
|
22
|
+
codex: {
|
|
23
|
+
name: "codex",
|
|
24
|
+
label: "Codex CLI",
|
|
25
|
+
defaultDir: DEFAULT_CODEX_DIR,
|
|
26
|
+
expectedShape: CODEX_DIR_SHAPE,
|
|
27
|
+
looksLikeDataDir: looksLikeCodexDir,
|
|
28
|
+
collect: collectCodexTurns,
|
|
29
|
+
fingerprintDirs: (_projectRoot, configuredDir) => [codexSessionsDir(configuredDir)],
|
|
30
|
+
},
|
|
31
|
+
cursor: {
|
|
32
|
+
name: "cursor",
|
|
33
|
+
label: "Cursor",
|
|
34
|
+
defaultDir: DEFAULT_CURSOR_DIR,
|
|
35
|
+
expectedShape: CURSOR_DIR_SHAPE,
|
|
36
|
+
looksLikeDataDir: looksLikeCursorDir,
|
|
37
|
+
collect: (dir, context) => context
|
|
38
|
+
? collectCursorTurnsForProject(context.projectRoot, Date.parse(context.projectInitAt), dir)
|
|
39
|
+
: collectCursorTurns(dir),
|
|
40
|
+
fingerprintDirs: (projectRoot, configuredDir) => cursorDerivedDirs(projectRoot, configuredDir).transcriptsDirs,
|
|
41
|
+
},
|
|
42
|
+
opencode: {
|
|
43
|
+
name: "opencode",
|
|
44
|
+
label: "opencode",
|
|
45
|
+
defaultDir: DEFAULT_OPENCODE_DIR,
|
|
46
|
+
expectedShape: OPENCODE_DIR_SHAPE,
|
|
47
|
+
looksLikeDataDir: looksLikeOpencodeDir,
|
|
48
|
+
collect: collectOpencodeTurns,
|
|
49
|
+
fingerprintDirs: (_projectRoot, configuredDir) => [opencodeStorageDir(configuredDir)],
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
/** Resolve configured agent dirs to the paths the daemon should fingerprint. */
|
|
53
|
+
export function fingerprintDirsForConfig(projectRoot, config) {
|
|
54
|
+
const dirs = [];
|
|
55
|
+
for (const name of ALL_AGENT_NAMES) {
|
|
56
|
+
const agent = config.agents[name];
|
|
57
|
+
if (!agent)
|
|
58
|
+
continue;
|
|
59
|
+
const adapter = AGENT_ADAPTERS[name];
|
|
60
|
+
for (const configured of agent.dirs) {
|
|
61
|
+
dirs.push(...adapter.fingerprintDirs(projectRoot, configured));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return dirs;
|
|
65
|
+
}
|
|
66
|
+
export const ALL_AGENT_NAMES = Object.keys(AGENT_ADAPTERS);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types and helpers shared across the per-agent parsers in this directory --
|
|
3
|
+
* the common turn shape they all produce, and generic filesystem/rendering
|
|
4
|
+
* helpers with no agent-specific knowledge in them.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
/** Newest mtime (epoch ms) across `paths`, ignoring unreadable ones.
|
|
9
|
+
* null when none of the paths could be stat'd. */
|
|
10
|
+
export function newestMtimeMs(paths) {
|
|
11
|
+
let newest = null;
|
|
12
|
+
for (const p of paths) {
|
|
13
|
+
try {
|
|
14
|
+
const ms = fs.statSync(p).mtimeMs;
|
|
15
|
+
if (newest === null || ms > newest)
|
|
16
|
+
newest = ms;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// unreadable -- skip
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return newest;
|
|
23
|
+
}
|
|
24
|
+
/** Epoch milliseconds -> the same 'YYYY-MM-DDTHH:MM:SS.mmmZ' shape Claude
|
|
25
|
+
* Code and Codex already write natively, so all four agents sort/slice
|
|
26
|
+
* identically downstream (lexicographic ISO8601, millisecond precision).
|
|
27
|
+
* Date#toISOString() already produces exactly this format. */
|
|
28
|
+
export function msToIso(ms) {
|
|
29
|
+
return new Date(ms).toISOString();
|
|
30
|
+
}
|
|
31
|
+
export function isPlainObject(v) {
|
|
32
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Render a single content block to text, keeping everything -- known block
|
|
36
|
+
* types get a readable rendering (including full tool arguments and full
|
|
37
|
+
* tool output, not just names or truncated snippets); any block type not
|
|
38
|
+
* explicitly recognized falls back to a raw JSON dump rather than being
|
|
39
|
+
* silently dropped. Covers Claude Code/Codex block-type spellings (thinking,
|
|
40
|
+
* tool_use, tool_result) and Cursor's own spellings for the same concepts
|
|
41
|
+
* (reasoning, tool-call, tool-result), since both feed the same renderer.
|
|
42
|
+
* Deliberate exclusions (opaque, non-informative): `encrypted_content` on
|
|
43
|
+
* Claude/Codex `reasoning` blocks, `data` on Cursor `redacted-reasoning`
|
|
44
|
+
* blocks, and `signature` on either -- all are encrypted/opaque blobs, not
|
|
45
|
+
* text either we or the model can read. Cursor tool-result's
|
|
46
|
+
* `experimental_content` is also skipped: verified to always duplicate the
|
|
47
|
+
* same text already in `result`, not additional information.
|
|
48
|
+
*/
|
|
49
|
+
export function renderBlock(block) {
|
|
50
|
+
if (!isPlainObject(block)) {
|
|
51
|
+
return block === null || block === undefined ? "" : String(block);
|
|
52
|
+
}
|
|
53
|
+
const btype = block.type;
|
|
54
|
+
if (btype === "text" || btype === "input_text" || btype === "output_text" || btype === "summary_text") {
|
|
55
|
+
return block.text || "";
|
|
56
|
+
}
|
|
57
|
+
if (btype === "thinking") {
|
|
58
|
+
const thinking = block.thinking;
|
|
59
|
+
return thinking ? `[thinking] ${thinking}` : "";
|
|
60
|
+
}
|
|
61
|
+
if (btype === "reasoning") {
|
|
62
|
+
// Cursor's content-block spelling of Claude's "thinking" -- same shape
|
|
63
|
+
// (a `text` field, often empty when only a signature is kept).
|
|
64
|
+
const text = block.text;
|
|
65
|
+
return text ? `[thinking] ${text}` : "";
|
|
66
|
+
}
|
|
67
|
+
if (btype === "redacted-reasoning") {
|
|
68
|
+
// Cursor's opaque encrypted reasoning blob (`data`) -- analogous to the
|
|
69
|
+
// Claude/Codex `encrypted_content` exclusion above.
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
if (btype === "tool_use") {
|
|
73
|
+
const name = block.name || "unknown_tool";
|
|
74
|
+
return `[tool_call: ${name}] ${JSON.stringify(block.input ?? {})}`;
|
|
75
|
+
}
|
|
76
|
+
if (btype === "tool-call") {
|
|
77
|
+
// Cursor's spelling: {toolName, args} instead of {name, input}.
|
|
78
|
+
const name = block.toolName || "unknown_tool";
|
|
79
|
+
return `[tool_call: ${name}] ${JSON.stringify(block.args ?? {})}`;
|
|
80
|
+
}
|
|
81
|
+
if (btype === "tool_result") {
|
|
82
|
+
const prefix = block.is_error ? "[tool_result:error]" : "[tool_result]";
|
|
83
|
+
return `${prefix} ${renderContent(block.content)}`;
|
|
84
|
+
}
|
|
85
|
+
if (btype === "tool-result") {
|
|
86
|
+
// Cursor's spelling: {toolName, result} instead of {content, is_error}.
|
|
87
|
+
// No error flag is exposed at this level, so no :error suffix.
|
|
88
|
+
let result = block.result;
|
|
89
|
+
if (typeof result !== "string")
|
|
90
|
+
result = JSON.stringify(result ?? "");
|
|
91
|
+
return `[tool_result] ${result}`;
|
|
92
|
+
}
|
|
93
|
+
// Unrecognized block type -- dump the whole thing raw so nothing is lost.
|
|
94
|
+
return `[${btype || "unknown_block"}] ${JSON.stringify(block)}`;
|
|
95
|
+
}
|
|
96
|
+
/** content may be a plain string, a list of blocks, or (rarely) something
|
|
97
|
+
* else entirely -- render whatever it is rather than assuming a shape. */
|
|
98
|
+
export function renderContent(content) {
|
|
99
|
+
if (typeof content === "string")
|
|
100
|
+
return content;
|
|
101
|
+
if (Array.isArray(content)) {
|
|
102
|
+
return content
|
|
103
|
+
.map(renderBlock)
|
|
104
|
+
.filter((p) => p)
|
|
105
|
+
.join("\n");
|
|
106
|
+
}
|
|
107
|
+
if (content === null || content === undefined)
|
|
108
|
+
return "";
|
|
109
|
+
return JSON.stringify(content);
|
|
110
|
+
}
|
|
111
|
+
export function listDirs(dir) {
|
|
112
|
+
if (!fs.existsSync(dir))
|
|
113
|
+
return [];
|
|
114
|
+
return fs
|
|
115
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
116
|
+
.filter((e) => e.isDirectory())
|
|
117
|
+
.map((e) => path.join(dir, e.name));
|
|
118
|
+
}
|
|
119
|
+
export function listFiles(dir, suffix) {
|
|
120
|
+
if (!fs.existsSync(dir))
|
|
121
|
+
return [];
|
|
122
|
+
return fs
|
|
123
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
124
|
+
.filter((e) => e.isFile() && e.name.endsWith(suffix))
|
|
125
|
+
.map((e) => path.join(dir, e.name));
|
|
126
|
+
}
|
|
127
|
+
/** Recursive .jsonl walk -- Codex nests rollout files under dated
|
|
128
|
+
* subdirectories (sessions/YYYY/MM/DD/*.jsonl). */
|
|
129
|
+
export function walkJsonl(dir) {
|
|
130
|
+
if (!fs.existsSync(dir))
|
|
131
|
+
return [];
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
134
|
+
const full = path.join(dir, entry.name);
|
|
135
|
+
// Not out.push(...walkJsonl(full)): spreading a large array into a
|
|
136
|
+
// function call hits V8's per-call argument limit (~65k+ elements) and
|
|
137
|
+
// throws "Maximum call stack size exceeded" -- not a stack-depth
|
|
138
|
+
// problem, an argument-count one. A directory of years of Codex
|
|
139
|
+
// sessions.YYYY/MM/DD/*.jsonl is exactly the shape that can accumulate
|
|
140
|
+
// enough files to hit it.
|
|
141
|
+
if (entry.isDirectory())
|
|
142
|
+
for (const file of walkJsonl(full))
|
|
143
|
+
out.push(file);
|
|
144
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
145
|
+
out.push(full);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
/** Short-circuiting, depth-bounded "is there any <suffix> file under here?"
|
|
150
|
+
* -- used to validate a user-entered data directory. Deliberately NOT
|
|
151
|
+
* walkJsonl(): a mistyped path (say `/`) would otherwise walk the entire
|
|
152
|
+
* filesystem collecting every match before answering a yes/no question. */
|
|
153
|
+
export function hasFileWithSuffix(dir, suffix, maxDepth = 6) {
|
|
154
|
+
if (maxDepth < 0)
|
|
155
|
+
return false;
|
|
156
|
+
let entries;
|
|
157
|
+
try {
|
|
158
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return false; // missing, or not readable -- either way, nothing to find
|
|
162
|
+
}
|
|
163
|
+
if (entries.some((e) => e.isFile() && e.name.endsWith(suffix)))
|
|
164
|
+
return true;
|
|
165
|
+
return entries.some((e) => e.isDirectory() && hasFileWithSuffix(path.join(dir, e.name), suffix, maxDepth - 1));
|
|
166
|
+
}
|
|
167
|
+
export function readJsonSafe(filePath) {
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where this CLI keeps its local state. Overridable via RESUMECONTEXT_HOME
|
|
3
|
+
* (tests point this at a temp directory so nothing touches a real user's
|
|
4
|
+
* `~/.resumecontext`, and no test run can affect another).
|
|
5
|
+
*/
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
export function resumecontextHome() {
|
|
9
|
+
return process.env.RESUMECONTEXT_HOME || defaultResumecontextHome();
|
|
10
|
+
}
|
|
11
|
+
/** The canonical state directory when RESUMECONTEXT_HOME is unset. */
|
|
12
|
+
export function defaultResumecontextHome() {
|
|
13
|
+
return path.join(os.homedir(), ".resumecontext");
|
|
14
|
+
}
|
|
15
|
+
/** Top-level entries this CLI creates under resumecontextHome(), plus a few
|
|
16
|
+
* legacy names kept so uninstall shape checks stay permissive across
|
|
17
|
+
* upgrades. Uninstall uses this as a positive signal (see
|
|
18
|
+
* looksLikeResumecontextHome in uninstall.ts), not a strict allowlist of
|
|
19
|
+
* every file that may exist. */
|
|
20
|
+
export const KNOWN_HOME_ENTRIES = new Set([
|
|
21
|
+
"credentials.json",
|
|
22
|
+
"device.json",
|
|
23
|
+
"fake-cloud.json",
|
|
24
|
+
"sync-state",
|
|
25
|
+
"agents",
|
|
26
|
+
"agents.json", // legacy single-file agent config, pre per-project layout
|
|
27
|
+
"daemon",
|
|
28
|
+
]);
|
|
29
|
+
export function credentialsFile() {
|
|
30
|
+
return path.join(resumecontextHome(), "credentials.json");
|
|
31
|
+
}
|
|
32
|
+
/** This machine's id (see device.ts). Per machine, not per project, and kept
|
|
33
|
+
* separate from credentials so signing out does not change which machine this
|
|
34
|
+
* is. */
|
|
35
|
+
export function deviceFile() {
|
|
36
|
+
return path.join(resumecontextHome(), "device.json");
|
|
37
|
+
}
|
|
38
|
+
/** Per-project record of sync progress (see syncState.ts) -- keyed by
|
|
39
|
+
* projectId, not written into the project directory, since it's local
|
|
40
|
+
* progress tracking, not something meant to be shared or committed (unlike
|
|
41
|
+
* the `.resumecontext.json` marker). */
|
|
42
|
+
export function syncStateFile(projectId) {
|
|
43
|
+
return path.join(resumecontextHome(), "sync-state", `${projectId}.json`);
|
|
44
|
+
}
|
|
45
|
+
/** Per-project record of which coding agents to scan for local history and
|
|
46
|
+
* where their data lives (see agentConfig.ts). Keyed by projectId and kept
|
|
47
|
+
* here rather than in the project's own `.resumecontext.json` marker on
|
|
48
|
+
* purpose: the marker is meant to be committed and shared with teammates,
|
|
49
|
+
* whereas these are absolute paths that are only meaningful on this
|
|
50
|
+
* machine. Same reasoning, and same layout, as syncStateFile above. */
|
|
51
|
+
export function agentConfigFile(projectId) {
|
|
52
|
+
return path.join(resumecontextHome(), "agents", `${projectId}.json`);
|
|
53
|
+
}
|
|
54
|
+
function daemonDir() {
|
|
55
|
+
return path.join(resumecontextHome(), "daemon");
|
|
56
|
+
}
|
|
57
|
+
/** The set of projects the background auto-sync daemon polls -- one shared
|
|
58
|
+
* file across every project on this machine (see daemon.ts). */
|
|
59
|
+
export function daemonRegistryFile() {
|
|
60
|
+
return path.join(daemonDir(), "projects.json");
|
|
61
|
+
}
|
|
62
|
+
/** The daemon runs detached with no terminal of its own, so its stdio is
|
|
63
|
+
* redirected here instead of being discarded -- a silently-failing
|
|
64
|
+
* background sync would otherwise be nearly impossible to debug. */
|
|
65
|
+
export function daemonLogFile() {
|
|
66
|
+
return path.join(daemonDir(), "daemon.log");
|
|
67
|
+
}
|
|
68
|
+
/** Each tick is now its own short-lived process (see daemon.ts's module
|
|
69
|
+
* doc for why), scheduled by the OS every DAEMON_INTERVAL_MS -- so two
|
|
70
|
+
* ticks CAN briefly overlap (a slow tick still running when the next one
|
|
71
|
+
* is scheduled). This file is an exclusive-create lock a tick holds for
|
|
72
|
+
* its duration; a second tick that finds it already held (and the holder
|
|
73
|
+
* still alive) skips its run rather than piling on top of the first. */
|
|
74
|
+
export function daemonLockFile() {
|
|
75
|
+
return path.join(daemonDir(), "lock");
|
|
76
|
+
}
|
|
77
|
+
/** Since each tick is a fresh process with no memory of the last one, the
|
|
78
|
+
* "skip scanning if nothing on disk changed" fingerprint (see
|
|
79
|
+
* fingerprintDirs in daemon.ts) has to be persisted here instead of held
|
|
80
|
+
* in memory -- otherwise every single tick would pay the full scan cost
|
|
81
|
+
* regardless of whether anything actually changed, which is exactly the
|
|
82
|
+
* cost fingerprinting exists to avoid. */
|
|
83
|
+
export function daemonFingerprintCacheFile() {
|
|
84
|
+
return path.join(daemonDir(), "fingerprints.json");
|
|
85
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the PROJECT a directory belongs to, walking up the filesystem
|
|
3
|
+
* looking ONLY for a `.resumecontext.json` marker -- nearest ancestor wins,
|
|
4
|
+
* not an exact path match. There is no `.git`-based fallback: project
|
|
5
|
+
* identity depends solely on this marker (see cloudApi.ts's module doc and
|
|
6
|
+
* commands/init.ts), so a directory with no marker anywhere above it simply
|
|
7
|
+
* has no project, full stop. Self-contained copy of the same validated
|
|
8
|
+
* logic in ingest/src/projectRoot.ts (this package is deliberately
|
|
9
|
+
* standalone, not dependent on ingest/'s internals -- see
|
|
10
|
+
* cli/src/localCapture.ts for how the two packages actually connect, via
|
|
11
|
+
* ingest's OUTPUT files, not its code).
|
|
12
|
+
*
|
|
13
|
+
* Resolution order, nearest-first, walking from the given directory upward:
|
|
14
|
+
* 1. A `.resumecontext.json` marker (written by `init`) -- explicit,
|
|
15
|
+
* user-declared project boundary. This is the ONLY thing that counts,
|
|
16
|
+
* so a monorepo subfolder with its own marker is its own project
|
|
17
|
+
* regardless of any enclosing repo.
|
|
18
|
+
* 2. No marker anywhere above the given directory -- caller decides what
|
|
19
|
+
* to do (init marks the exact directory given; other commands should
|
|
20
|
+
* treat this as "no project here").
|
|
21
|
+
*/
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
export const MARKER_FILENAME = ".resumecontext.json";
|
|
25
|
+
function readMarker(dir) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = fs.readFileSync(path.join(dir, MARKER_FILENAME), "utf-8");
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
if (parsed && typeof parsed.projectId === "string" && parsed.projectId) {
|
|
30
|
+
return { projectId: parsed.projectId, createdAt: parsed.createdAt ?? "" };
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Walks from `startDir` up to the filesystem root looking only for a
|
|
39
|
+
* `.resumecontext.json` marker. Never throws -- a nonexistent/inaccessible
|
|
40
|
+
* `startDir`, or simply finding no marker anywhere above it, just resolves
|
|
41
|
+
* to "none". */
|
|
42
|
+
export function findProjectRoot(startDir) {
|
|
43
|
+
let dir = path.resolve(startDir);
|
|
44
|
+
while (true) {
|
|
45
|
+
const marker = readMarker(dir);
|
|
46
|
+
if (marker)
|
|
47
|
+
return { root: dir, projectId: marker.projectId };
|
|
48
|
+
const parent = path.dirname(dir);
|
|
49
|
+
if (parent === dir)
|
|
50
|
+
break; // filesystem root
|
|
51
|
+
dir = parent;
|
|
52
|
+
}
|
|
53
|
+
return { root: path.resolve(startDir), projectId: null };
|
|
54
|
+
}
|
|
55
|
+
/** Reads the marker at `root` when present; null otherwise. */
|
|
56
|
+
export function readProjectMarker(root) {
|
|
57
|
+
return readMarker(path.resolve(root));
|
|
58
|
+
}
|
|
59
|
+
/** Writes the marker at `root`. Caller decides what root should be -- for
|
|
60
|
+
* `init`, always the exact directory the command was run in (see
|
|
61
|
+
* commands/init.ts for why that's deliberate, not some inferred parent
|
|
62
|
+
* directory). */
|
|
63
|
+
export function writeMarker(root, projectId) {
|
|
64
|
+
const marker = { projectId, createdAt: new Date().toISOString() };
|
|
65
|
+
fs.writeFileSync(path.join(root, MARKER_FILENAME), JSON.stringify(marker, null, 2) + "\n");
|
|
66
|
+
return marker;
|
|
67
|
+
}
|
|
68
|
+
/** Every project-scoped command (sync/share/accept/revoke/members) needs
|
|
69
|
+
* exactly this: an existing local marker, or a clear error telling the user
|
|
70
|
+
* to run `init` first. Centralized here so all of them fail identically. */
|
|
71
|
+
export function requireProject(cwd) {
|
|
72
|
+
const resolved = findProjectRoot(cwd);
|
|
73
|
+
if (!resolved.projectId) {
|
|
74
|
+
throw new Error(`No resumecontext project found at ${cwd} or any parent directory. Run \`resumecontext init\` here first.`);
|
|
75
|
+
}
|
|
76
|
+
return resolved;
|
|
77
|
+
}
|