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.
Files changed (44) hide show
  1. package/README.md +27 -0
  2. package/dist/agentConfig.js +202 -0
  3. package/dist/agentConfigWithDaemon.js +42 -0
  4. package/dist/apiClient.js +54 -0
  5. package/dist/browser.js +20 -0
  6. package/dist/cloudApi.js +15 -0
  7. package/dist/commands/accept.js +20 -0
  8. package/dist/commands/agents.js +35 -0
  9. package/dist/commands/auth.js +55 -0
  10. package/dist/commands/daemon.js +99 -0
  11. package/dist/commands/init.js +59 -0
  12. package/dist/commands/logout.js +20 -0
  13. package/dist/commands/mcp.js +54 -0
  14. package/dist/commands/members.js +20 -0
  15. package/dist/commands/projects.js +55 -0
  16. package/dist/commands/revoke.js +15 -0
  17. package/dist/commands/share.js +18 -0
  18. package/dist/commands/sync.js +59 -0
  19. package/dist/commands/uninstall.js +61 -0
  20. package/dist/constants.js +61 -0
  21. package/dist/daemon.js +409 -0
  22. package/dist/daemonService.js +326 -0
  23. package/dist/deps.js +1 -0
  24. package/dist/dev.js +32 -0
  25. package/dist/device.js +40 -0
  26. package/dist/httpCloudApi.js +61 -0
  27. package/dist/index.js +160 -0
  28. package/dist/localCapture.js +18 -0
  29. package/dist/localHistory/claudeCode.js +82 -0
  30. package/dist/localHistory/codex.js +106 -0
  31. package/dist/localHistory/cursor.js +492 -0
  32. package/dist/localHistory/index.js +96 -0
  33. package/dist/localHistory/opencode.js +148 -0
  34. package/dist/localHistory/registry.js +66 -0
  35. package/dist/localHistory/shared.js +174 -0
  36. package/dist/paths.js +85 -0
  37. package/dist/projectRoot.js +77 -0
  38. package/dist/session.js +36 -0
  39. package/dist/syncCore.js +108 -0
  40. package/dist/syncState.js +51 -0
  41. package/dist/ui.js +289 -0
  42. package/dist/utils.js +41 -0
  43. package/dist/version.js +43 -0
  44. 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
+ }