rulereceipt 0.1.82 → 0.1.84
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/NOTICE.md +2 -0
- package/dist/adapters/cursor.d.ts +7 -0
- package/dist/adapters/cursor.js +190 -0
- package/dist/adapters/index.d.ts +4 -0
- package/dist/adapters/index.js +26 -4
- package/dist/adapters/opencode.d.ts +7 -0
- package/dist/adapters/opencode.js +179 -0
- package/package.json +1 -1
package/NOTICE.md
CHANGED
|
@@ -31,3 +31,5 @@ we did not copy the code):
|
|
|
31
31
|
`e486cd22a592d89d890cff056624647fbe9cbe80`, which itself reverse-engineered it
|
|
32
32
|
from **Pilan-AI/mnemo** (MIT). Our reader is `src/adapters/copilot.ts`.
|
|
33
33
|
- **Gemini CLI** session format (`~/.gemini/tmp/<hash>/chats/*.{jsonl,json}`, legacy `~/.gemini/sessions/*.json`): documented by **yigitkonur/cli-continues** (MIT, pinned `e486cd2`; from mnemo MIT). Our reader is `src/adapters/gemini.ts`.
|
|
34
|
+
- **Cursor** agent-transcripts (`~/.cursor/projects/<slug>/agent-transcripts/**/*.jsonl`, Anthropic-API shaped): documented by **yigitkonur/cli-continues** (MIT, pinned `e486cd2`). Our reader `src/adapters/cursor.ts`. (Old `state.vscdb` SQLite path deferred.)
|
|
35
|
+
- **OpenCode** JSON file store (`$XDG_DATA_HOME/opencode/storage/{session,message,part}/*.json`): documented by **yigitkonur/cli-continues** (MIT, pinned `e486cd2`). Our reader `src/adapters/opencode.ts`. (opencode.db SQLite path deferred.)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TranscriptEvent } from "../types.js";
|
|
2
|
+
/** Cursor agent-transcript files for this cwd, newest first. */
|
|
3
|
+
export declare function listCursorSessions(cwd: string): string[];
|
|
4
|
+
/** True when a file looks like a Cursor agent-transcript (≥1 {role,content[]} line). */
|
|
5
|
+
export declare function cursorFormatIsKnown(file: string): boolean;
|
|
6
|
+
/** Parse a Cursor agent-transcript JSONL into neutral events. Tolerant. */
|
|
7
|
+
export declare function parseCursorTranscript(file: string): TranscriptEvent[];
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Cursor adapter — EXPERIMENTAL (no real-session fixtures yet).
|
|
6
|
+
*
|
|
7
|
+
* Reads the CURRENT Cursor agent-transcripts (JSONL, Anthropic-API shaped), not
|
|
8
|
+
* the old `state.vscdb` SQLite. Format documented by yigitkonur/cli-continues
|
|
9
|
+
* (MIT, src/parsers/cursor.ts, pinned e486cd22a592d89d890cff056624647fbe9cbe80).
|
|
10
|
+
* Credit: NOTICE.md / REUSE.md. Read-only; never opens/locks state.vscdb.
|
|
11
|
+
*
|
|
12
|
+
* Storage: `~/.cursor/projects/<slug>/agent-transcripts/**/*.jsonl` (nested
|
|
13
|
+
* `<uuid>/transcript.jsonl` or flat `<uuid>.jsonl`). The project's cwd is in
|
|
14
|
+
* `~/.cursor/projects/<slug>/repo.json` (`workspace` | `rootPath` | `path`).
|
|
15
|
+
* Each JSONL line is `{ role:'user'|'assistant', content: [Anthropic blocks] }`:
|
|
16
|
+
* {type:'text',text} -> text
|
|
17
|
+
* {type:'tool_use',id,name,input} -> tool_use (shell -> canonical Bash)
|
|
18
|
+
* {type:'tool_result',tool_use_id,content,is_error} -> tool_result
|
|
19
|
+
*
|
|
20
|
+
* DEFERRED: the legacy `state.vscdb` SQLite path (partial, reverse-engineered,
|
|
21
|
+
* multi-migration) — it needs a WASM sqlite reader and is low-confidence without
|
|
22
|
+
* real samples. Logged in REUSE.md.
|
|
23
|
+
*
|
|
24
|
+
* RESILIENCE: only the fields above; unknown blocks/lines skipped, never guessed;
|
|
25
|
+
* `cursorFormatIsKnown` gates use so another tool's log is not mis-parsed.
|
|
26
|
+
*/
|
|
27
|
+
function cursorProjectsDir() {
|
|
28
|
+
const env = process.env.CURSOR_HOME?.trim();
|
|
29
|
+
return env && env.length > 0 ? join(env, "projects") : join(homedir(), ".cursor", "projects");
|
|
30
|
+
}
|
|
31
|
+
function realpathOr(p) { try {
|
|
32
|
+
return realpathSync(p);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return p;
|
|
36
|
+
} }
|
|
37
|
+
function str(v) { return typeof v === "string" && v.length > 0 ? v : undefined; }
|
|
38
|
+
function rec(v) { return v && typeof v === "object" && !Array.isArray(v) ? v : {}; }
|
|
39
|
+
/** The cwd a Cursor project dir maps to, from its repo.json. */
|
|
40
|
+
function projectCwd(projectDir) {
|
|
41
|
+
try {
|
|
42
|
+
const o = JSON.parse(readFileSync(join(projectDir, "repo.json"), "utf-8"));
|
|
43
|
+
return str(o.workspace) ?? str(o.rootPath) ?? str(o.path) ?? null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function jsonlFilesUnder(dir, out, depth = 0) {
|
|
50
|
+
if (depth > 4 || !existsSync(dir))
|
|
51
|
+
return;
|
|
52
|
+
let entries = [];
|
|
53
|
+
try {
|
|
54
|
+
entries = readdirSync(dir);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const name of entries) {
|
|
60
|
+
const p = join(dir, name);
|
|
61
|
+
let isDir = false;
|
|
62
|
+
try {
|
|
63
|
+
isDir = statSync(p).isDirectory();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (isDir)
|
|
69
|
+
jsonlFilesUnder(p, out, depth + 1);
|
|
70
|
+
else if (name.endsWith(".jsonl"))
|
|
71
|
+
out.push(p);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Cursor agent-transcript files for this cwd, newest first. */
|
|
75
|
+
export function listCursorSessions(cwd) {
|
|
76
|
+
const base = cursorProjectsDir();
|
|
77
|
+
if (!existsSync(base))
|
|
78
|
+
return [];
|
|
79
|
+
const target = realpathOr(cwd);
|
|
80
|
+
let projects = [];
|
|
81
|
+
try {
|
|
82
|
+
projects = readdirSync(base);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const hits = [];
|
|
88
|
+
for (const slug of projects) {
|
|
89
|
+
const projectDir = join(base, slug);
|
|
90
|
+
const mapped = projectCwd(projectDir);
|
|
91
|
+
if (mapped && realpathOr(mapped) !== target)
|
|
92
|
+
continue; // another project
|
|
93
|
+
const files = [];
|
|
94
|
+
jsonlFilesUnder(join(projectDir, "agent-transcripts"), files);
|
|
95
|
+
for (const f of files) {
|
|
96
|
+
try {
|
|
97
|
+
hits.push({ file: f, mtimeMs: statSync(f).mtimeMs });
|
|
98
|
+
}
|
|
99
|
+
catch { /* skip */ }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return hits.sort((a, b) => b.mtimeMs - a.mtimeMs).map((h) => h.file);
|
|
103
|
+
}
|
|
104
|
+
function toToolUse(name, input, id, ts) {
|
|
105
|
+
const command = str(input.command) ?? str(input.cmd);
|
|
106
|
+
const filePath = str(input.file_path) ?? str(input.path) ?? str(input.target_file);
|
|
107
|
+
const lower = name.toLowerCase();
|
|
108
|
+
if (command && (/(shell|bash|terminal|exec|run|command)/.test(lower) || !filePath)) {
|
|
109
|
+
return { role: "assistant", kind: "tool_use", toolName: "Bash", toolUseId: id, input: { command }, timestamp: ts };
|
|
110
|
+
}
|
|
111
|
+
if (filePath && /(write|create|edit|replace|patch|insert|modif|apply)/.test(lower)) {
|
|
112
|
+
return { role: "assistant", kind: "tool_use", toolName: /(edit|replace|patch|apply)/.test(lower) ? "Edit" : "Write", toolUseId: id, input: { file_path: filePath, ...input }, timestamp: ts };
|
|
113
|
+
}
|
|
114
|
+
if (filePath && /(read|view|open|cat|show)/.test(lower)) {
|
|
115
|
+
return { role: "assistant", kind: "tool_use", toolName: "Read", toolUseId: id, input: { file_path: filePath }, timestamp: ts };
|
|
116
|
+
}
|
|
117
|
+
return { role: "assistant", kind: "tool_use", toolName: name, toolUseId: id, input, timestamp: ts };
|
|
118
|
+
}
|
|
119
|
+
function toolResultContent(block) {
|
|
120
|
+
const c = block.content;
|
|
121
|
+
if (typeof c === "string")
|
|
122
|
+
return c;
|
|
123
|
+
if (Array.isArray(c))
|
|
124
|
+
return c.map((p) => str(rec(p).text) ?? "").filter(Boolean).join("\n");
|
|
125
|
+
return c ? JSON.stringify(c).slice(0, 2000) : "";
|
|
126
|
+
}
|
|
127
|
+
/** True when a file looks like a Cursor agent-transcript (≥1 {role,content[]} line). */
|
|
128
|
+
export function cursorFormatIsKnown(file) {
|
|
129
|
+
let raw;
|
|
130
|
+
try {
|
|
131
|
+
raw = readFileSync(file, "utf-8");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
for (const line of raw.split("\n")) {
|
|
137
|
+
if (!line.trim())
|
|
138
|
+
continue;
|
|
139
|
+
try {
|
|
140
|
+
const o = JSON.parse(line);
|
|
141
|
+
// A Cursor line has a top-level role + content array and NO Claude-style
|
|
142
|
+
// `message` wrapper and NO Codex/Copilot `type`.
|
|
143
|
+
if ((o.role === "user" || o.role === "assistant") && Array.isArray(o.content) && o.message === undefined && o.type === undefined)
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
catch { /* skip */ }
|
|
147
|
+
break; // only the first non-blank line decides
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
/** Parse a Cursor agent-transcript JSONL into neutral events. Tolerant. */
|
|
152
|
+
export function parseCursorTranscript(file) {
|
|
153
|
+
let raw;
|
|
154
|
+
try {
|
|
155
|
+
raw = readFileSync(file, "utf-8");
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
const out = [];
|
|
161
|
+
for (const line of raw.split("\n")) {
|
|
162
|
+
if (!line.trim())
|
|
163
|
+
continue;
|
|
164
|
+
let o;
|
|
165
|
+
try {
|
|
166
|
+
o = JSON.parse(line);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const role = o.role === "assistant" ? "assistant" : o.role === "user" ? "user" : undefined;
|
|
172
|
+
if (!role || !Array.isArray(o.content))
|
|
173
|
+
continue;
|
|
174
|
+
for (const raw2 of o.content) {
|
|
175
|
+
const b = rec(raw2);
|
|
176
|
+
if (b.type === "text" && str(b.text)) {
|
|
177
|
+
out.push({ role, kind: "text", text: b.text, timestamp: "" });
|
|
178
|
+
}
|
|
179
|
+
else if (b.type === "tool_use" && role === "assistant") {
|
|
180
|
+
const name = str(b.name);
|
|
181
|
+
if (name)
|
|
182
|
+
out.push(toToolUse(name, rec(b.input), str(b.id), ""));
|
|
183
|
+
}
|
|
184
|
+
else if (b.type === "tool_result") {
|
|
185
|
+
out.push({ role: "user", kind: "tool_result", toolUseId: str(b.tool_use_id), content: toolResultContent(b), isError: b.is_error === true, timestamp: "" });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -44,6 +44,10 @@ export declare const copilotCliAdapter: SessionAdapter;
|
|
|
44
44
|
export declare const ADAPTERS: SessionAdapter[];
|
|
45
45
|
/** Gemini CLI — EXPERIMENTAL. Our reader; format per cli-continues (MIT, pinned). */
|
|
46
46
|
export declare const geminiCliAdapter: SessionAdapter;
|
|
47
|
+
/** Cursor — EXPERIMENTAL. Current agent-transcripts (JSONL, Anthropic-shaped); old SQLite deferred. */
|
|
48
|
+
export declare const cursorAdapter: SessionAdapter;
|
|
49
|
+
/** OpenCode — EXPERIMENTAL. JSON file store (3-dir join); opencode.db SQLite deferred. */
|
|
50
|
+
export declare const openCodeAdapter: SessionAdapter;
|
|
47
51
|
/** Experimental adapters: reader exists, awaiting real+planted+clean fixtures. */
|
|
48
52
|
export declare const EXPERIMENTAL_ADAPTERS: SessionAdapter[];
|
|
49
53
|
/**
|
package/dist/adapters/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { listAllSessionFiles, readTranscriptFromFile, findSubagentFiles } from "
|
|
|
4
4
|
import { listCodexSessions, parseCodexTranscript } from "./codex.js";
|
|
5
5
|
import { listCopilotSessions, parseCopilotTranscript, copilotFormatIsKnown } from "./copilot.js";
|
|
6
6
|
import { listGeminiSessions, parseGeminiTranscript, geminiFormatIsKnown } from "./gemini.js";
|
|
7
|
+
import { listCursorSessions, parseCursorTranscript, cursorFormatIsKnown } from "./cursor.js";
|
|
8
|
+
import { listOpenCodeSessions, parseOpenCodeTranscript, openCodeFormatIsKnown } from "./opencode.js";
|
|
7
9
|
/**
|
|
8
10
|
* Claude Code — the original and reference adapter. Delegates to the existing
|
|
9
11
|
* transcriptParser so its behaviour (including subagent transcripts) is
|
|
@@ -46,8 +48,22 @@ export const geminiCliAdapter = {
|
|
|
46
48
|
parse: (sessionFile) => parseGeminiTranscript(sessionFile),
|
|
47
49
|
experimental: true,
|
|
48
50
|
};
|
|
51
|
+
/** Cursor — EXPERIMENTAL. Current agent-transcripts (JSONL, Anthropic-shaped); old SQLite deferred. */
|
|
52
|
+
export const cursorAdapter = {
|
|
53
|
+
tool: "cursor",
|
|
54
|
+
listSessions: (cwd) => listCursorSessions(cwd),
|
|
55
|
+
parse: (sessionFile) => parseCursorTranscript(sessionFile),
|
|
56
|
+
experimental: true,
|
|
57
|
+
};
|
|
58
|
+
/** OpenCode — EXPERIMENTAL. JSON file store (3-dir join); opencode.db SQLite deferred. */
|
|
59
|
+
export const openCodeAdapter = {
|
|
60
|
+
tool: "opencode",
|
|
61
|
+
listSessions: (cwd) => listOpenCodeSessions(cwd),
|
|
62
|
+
parse: (sessionFile) => parseOpenCodeTranscript(sessionFile),
|
|
63
|
+
experimental: true,
|
|
64
|
+
};
|
|
49
65
|
/** Experimental adapters: reader exists, awaiting real+planted+clean fixtures. */
|
|
50
|
-
export const EXPERIMENTAL_ADAPTERS = [copilotCliAdapter, geminiCliAdapter];
|
|
66
|
+
export const EXPERIMENTAL_ADAPTERS = [copilotCliAdapter, geminiCliAdapter, cursorAdapter, openCodeAdapter];
|
|
51
67
|
/**
|
|
52
68
|
* Tools deliberately NOT read yet, with the honest reason. Kept as data (not
|
|
53
69
|
* silence) so the tool — and its docs — can state exactly where the line is
|
|
@@ -55,9 +71,7 @@ export const EXPERIMENTAL_ADAPTERS = [copilotCliAdapter, geminiCliAdapter];
|
|
|
55
71
|
*/
|
|
56
72
|
export const UNSUPPORTED_TOOLS = [
|
|
57
73
|
{ tool: "aider", reason: "history is a Markdown transcript (.aider.chat.history.md), not structured events — needs a prose parser, not a field mapping" },
|
|
58
|
-
{ tool: "
|
|
59
|
-
{ tool: "cursor", reason: "IDE-embedded; chat history lives in undocumented internal state that changes across Cursor versions — real ongoing maintenance, out of scope for this pass" },
|
|
60
|
-
{ tool: "windsurf", reason: "IDE-embedded; history in undocumented internal state, same as Cursor" },
|
|
74
|
+
{ tool: "windsurf", reason: "IDE-embedded; history in undocumented internal state, same as old Cursor" },
|
|
61
75
|
];
|
|
62
76
|
/**
|
|
63
77
|
* The single most recently modified session across ALL supported tools for
|
|
@@ -149,6 +163,14 @@ export function parseSessionFile(file) {
|
|
|
149
163
|
// Confirmed by a known-format check so a Claude/other log is never mis-read.
|
|
150
164
|
if (geminiFormatIsKnown(file))
|
|
151
165
|
return parseGeminiTranscript(file);
|
|
166
|
+
// Cursor agent-transcript: a JSONL line `{role, content:[...]}` with no Claude
|
|
167
|
+
// `message` wrapper and no `type`. Gated the same way.
|
|
168
|
+
if (cursorFormatIsKnown(file))
|
|
169
|
+
return parseCursorTranscript(file);
|
|
170
|
+
// OpenCode: a single session JSON (`id` starting `ses_`); the message/part
|
|
171
|
+
// dirs are resolved relative to this file.
|
|
172
|
+
if (openCodeFormatIsKnown(file))
|
|
173
|
+
return parseOpenCodeTranscript(file);
|
|
152
174
|
return readTranscriptFromFile(file);
|
|
153
175
|
}
|
|
154
176
|
/**
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TranscriptEvent } from "../types.js";
|
|
2
|
+
/** OpenCode session files for this cwd, newest first. */
|
|
3
|
+
export declare function listOpenCodeSessions(cwd: string): string[];
|
|
4
|
+
/** True when the file is an OpenCode session JSON (id starting `ses_`). */
|
|
5
|
+
export declare function openCodeFormatIsKnown(sessionFile: string): boolean;
|
|
6
|
+
/** Parse one OpenCode session (by its ses_*.json) into neutral events. Tolerant. */
|
|
7
|
+
export declare function parseOpenCodeTranscript(sessionFile: string): TranscriptEvent[];
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* OpenCode adapter — EXPERIMENTAL (no real-session fixtures yet).
|
|
6
|
+
*
|
|
7
|
+
* Reads the JSON file store (NOT the newer `opencode.db` SQLite, which is
|
|
8
|
+
* deferred — see REUSE.md). Format documented by yigitkonur/cli-continues (MIT,
|
|
9
|
+
* src/parsers/opencode.ts, pinned e486cd22a592d89d890cff056624647fbe9cbe80).
|
|
10
|
+
* Credit: NOTICE.md / REUSE.md.
|
|
11
|
+
*
|
|
12
|
+
* Store (under `$XDG_DATA_HOME/opencode/storage` or `~/.local/share/opencode/
|
|
13
|
+
* storage`): a 3-directory join
|
|
14
|
+
* session/<proj>/ses_*.json → { id, projectID, directory, time:{updated} }
|
|
15
|
+
* message/<sessionId>/msg_*.json → { id, role }
|
|
16
|
+
* part/<messageId>/prt_*.json → { type:'text'|'tool'|…, text?, tool?, callID?,
|
|
17
|
+
* state:{ input, status, output, error } }
|
|
18
|
+
* cwd = session.directory, else project/<projectID>.json `.worktree`.
|
|
19
|
+
* text part -> text (role of its message)
|
|
20
|
+
* tool part -> tool_use (shell -> canonical Bash) + a tool_result from its state.
|
|
21
|
+
*
|
|
22
|
+
* The message/part dirs are resolved RELATIVE to the session file, so
|
|
23
|
+
* `--transcript <…/session/<proj>/ses_X.json>` works wherever the store lives.
|
|
24
|
+
*
|
|
25
|
+
* RESILIENCE: only the fields above; unknown part types / bad files skipped,
|
|
26
|
+
* never guessed; `openCodeFormatIsKnown` gates use.
|
|
27
|
+
*/
|
|
28
|
+
function realpathOr(p) { try {
|
|
29
|
+
return realpathSync(p);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return p;
|
|
33
|
+
} }
|
|
34
|
+
function str(v) { return typeof v === "string" && v.length > 0 ? v : undefined; }
|
|
35
|
+
function rec(v) { return v && typeof v === "object" && !Array.isArray(v) ? v : {}; }
|
|
36
|
+
function storageDir() {
|
|
37
|
+
const xdg = process.env.XDG_DATA_HOME?.trim();
|
|
38
|
+
const base = xdg && xdg.length > 0 ? join(xdg, "opencode") : join(homedir(), ".local", "share", "opencode");
|
|
39
|
+
return join(base, "storage");
|
|
40
|
+
}
|
|
41
|
+
function readJson(file) {
|
|
42
|
+
try {
|
|
43
|
+
const o = JSON.parse(readFileSync(file, "utf-8"));
|
|
44
|
+
return rec(o);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** storage root for a given session file (…/storage/session/<proj>/ses_X.json). */
|
|
51
|
+
function storageRootOf(sessionFile) {
|
|
52
|
+
return join(dirname(sessionFile), "..", "..");
|
|
53
|
+
}
|
|
54
|
+
/** cwd of a session JSON: its `directory`, else project/<projectID>.json .worktree. */
|
|
55
|
+
function sessionCwd(sessionFile, s) {
|
|
56
|
+
const dir = str(s.directory);
|
|
57
|
+
if (dir)
|
|
58
|
+
return dir;
|
|
59
|
+
const pid = str(s.projectID);
|
|
60
|
+
if (pid) {
|
|
61
|
+
const proj = readJson(join(storageRootOf(sessionFile), "project", `${pid}.json`));
|
|
62
|
+
if (proj)
|
|
63
|
+
return str(proj.worktree) ?? null;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function listSessionJsonFiles(storage) {
|
|
68
|
+
const sessionBase = join(storage, "session");
|
|
69
|
+
const out = [];
|
|
70
|
+
let projs = [];
|
|
71
|
+
try {
|
|
72
|
+
projs = readdirSync(sessionBase);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
for (const p of projs) {
|
|
78
|
+
const d = join(sessionBase, p);
|
|
79
|
+
let names = [];
|
|
80
|
+
try {
|
|
81
|
+
names = readdirSync(d);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (const n of names)
|
|
87
|
+
if (n.startsWith("ses_") && n.endsWith(".json"))
|
|
88
|
+
out.push(join(d, n));
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** OpenCode session files for this cwd, newest first. */
|
|
93
|
+
export function listOpenCodeSessions(cwd) {
|
|
94
|
+
const storage = storageDir();
|
|
95
|
+
if (!existsSync(storage))
|
|
96
|
+
return [];
|
|
97
|
+
const target = realpathOr(cwd);
|
|
98
|
+
const hits = [];
|
|
99
|
+
for (const file of listSessionJsonFiles(storage)) {
|
|
100
|
+
const s = readJson(file);
|
|
101
|
+
if (!s)
|
|
102
|
+
continue;
|
|
103
|
+
const c = sessionCwd(file, s);
|
|
104
|
+
if (c && realpathOr(c) !== target)
|
|
105
|
+
continue;
|
|
106
|
+
try {
|
|
107
|
+
hits.push({ file, mtimeMs: statSync(file).mtimeMs });
|
|
108
|
+
}
|
|
109
|
+
catch { /* skip */ }
|
|
110
|
+
}
|
|
111
|
+
return hits.sort((a, b) => b.mtimeMs - a.mtimeMs).map((h) => h.file);
|
|
112
|
+
}
|
|
113
|
+
function sortedJson(dir) {
|
|
114
|
+
try {
|
|
115
|
+
return readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function toToolUse(name, input, id, ts) {
|
|
122
|
+
const command = str(input.command) ?? str(input.cmd);
|
|
123
|
+
const filePath = str(input.file_path) ?? str(input.path) ?? str(input.filePath);
|
|
124
|
+
const lower = name.toLowerCase();
|
|
125
|
+
if (command && (/(shell|bash|terminal|exec|run|command)/.test(lower) || !filePath)) {
|
|
126
|
+
return { role: "assistant", kind: "tool_use", toolName: "Bash", toolUseId: id, input: { command }, timestamp: ts };
|
|
127
|
+
}
|
|
128
|
+
if (filePath && /(write|create|edit|replace|patch|insert|modif|apply)/.test(lower)) {
|
|
129
|
+
return { role: "assistant", kind: "tool_use", toolName: /(edit|replace|patch|apply)/.test(lower) ? "Edit" : "Write", toolUseId: id, input: { file_path: filePath, ...input }, timestamp: ts };
|
|
130
|
+
}
|
|
131
|
+
if (filePath && /(read|view|open|cat|show)/.test(lower)) {
|
|
132
|
+
return { role: "assistant", kind: "tool_use", toolName: "Read", toolUseId: id, input: { file_path: filePath }, timestamp: ts };
|
|
133
|
+
}
|
|
134
|
+
return { role: "assistant", kind: "tool_use", toolName: name, toolUseId: id, input, timestamp: ts };
|
|
135
|
+
}
|
|
136
|
+
/** True when the file is an OpenCode session JSON (id starting `ses_`). */
|
|
137
|
+
export function openCodeFormatIsKnown(sessionFile) {
|
|
138
|
+
const s = readJson(sessionFile);
|
|
139
|
+
return Boolean(s && str(s.id)?.startsWith("ses_"));
|
|
140
|
+
}
|
|
141
|
+
/** Parse one OpenCode session (by its ses_*.json) into neutral events. Tolerant. */
|
|
142
|
+
export function parseOpenCodeTranscript(sessionFile) {
|
|
143
|
+
const s = readJson(sessionFile);
|
|
144
|
+
const sessionId = s ? str(s.id) : undefined;
|
|
145
|
+
if (!s || !sessionId)
|
|
146
|
+
return [];
|
|
147
|
+
const root = storageRootOf(sessionFile);
|
|
148
|
+
const messageDir = join(root, "message", sessionId);
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const msgFile of sortedJson(messageDir)) {
|
|
151
|
+
const m = readJson(join(messageDir, msgFile));
|
|
152
|
+
if (!m)
|
|
153
|
+
continue;
|
|
154
|
+
const role = m.role === "assistant" ? "assistant" : m.role === "user" ? "user" : undefined;
|
|
155
|
+
const msgId = str(m.id);
|
|
156
|
+
if (!role || !msgId)
|
|
157
|
+
continue;
|
|
158
|
+
const partDir = join(root, "part", msgId);
|
|
159
|
+
for (const prtFile of sortedJson(partDir)) {
|
|
160
|
+
const part = readJson(join(partDir, prtFile));
|
|
161
|
+
if (!part)
|
|
162
|
+
continue;
|
|
163
|
+
if (part.type === "text" && str(part.text)) {
|
|
164
|
+
out.push({ role, kind: "text", text: part.text, timestamp: "" });
|
|
165
|
+
}
|
|
166
|
+
else if (part.type === "tool" && role === "assistant") {
|
|
167
|
+
const name = str(part.tool);
|
|
168
|
+
if (!name)
|
|
169
|
+
continue;
|
|
170
|
+
const state = rec(part.state);
|
|
171
|
+
const id = str(part.callID);
|
|
172
|
+
out.push(toToolUse(name, rec(state.input), id, ""));
|
|
173
|
+
const content = str(state.output) ?? str(state.error) ?? "";
|
|
174
|
+
out.push({ role: "user", kind: "tool_result", toolUseId: id, content: content.slice(0, 2000), isError: state.status === "error", timestamp: "" });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.84",
|
|
4
4
|
"description": "Checks whether your AI coding agent followed your rules, with evidence. Works with Claude Code (Codex in testing); reads CLAUDE.md, AGENTS.md, Cursor, Copilot and Windsurf rules.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|