rulereceipt 0.1.81 → 0.1.83
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/gemini.d.ts +7 -0
- package/dist/adapters/gemini.js +219 -0
- package/dist/adapters/index.d.ts +4 -0
- package/dist/adapters/index.js +28 -5
- package/package.json +1 -1
package/NOTICE.md
CHANGED
|
@@ -30,3 +30,5 @@ we did not copy the code):
|
|
|
30
30
|
documented by **yigitkonur/cli-continues** (MIT), pinned commit
|
|
31
31
|
`e486cd22a592d89d890cff056624647fbe9cbe80`, which itself reverse-engineered it
|
|
32
32
|
from **Pilan-AI/mnemo** (MIT). Our reader is `src/adapters/copilot.ts`.
|
|
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.)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { TranscriptEvent } from "../types.js";
|
|
2
|
+
/** Gemini CLI session files for this cwd, newest first. */
|
|
3
|
+
export declare function listGeminiSessions(cwd: string): string[];
|
|
4
|
+
/** True when the file looks like a Gemini CLI session (≥1 user/gemini message). */
|
|
5
|
+
export declare function geminiFormatIsKnown(file: string): boolean;
|
|
6
|
+
/** Parse a Gemini CLI session into neutral events. Tolerant: unknown → skipped. */
|
|
7
|
+
export declare function parseGeminiTranscript(file: string): TranscriptEvent[];
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Gemini CLI adapter — EXPERIMENTAL (no real-session fixtures yet).
|
|
6
|
+
*
|
|
7
|
+
* Reader is OUR OWN code; the FORMAT is documented by yigitkonur/cli-continues
|
|
8
|
+
* (MIT, src/parsers/gemini.ts, pinned e486cd22a592d89d890cff056624647fbe9cbe80,
|
|
9
|
+
* reverse-engineered from Pilan-AI/mnemo MIT). Credit: NOTICE.md / REUSE.md.
|
|
10
|
+
*
|
|
11
|
+
* Storage: `~/.gemini/tmp/<project-hash>/chats/*.{jsonl,json}` (current) and
|
|
12
|
+
* `~/.gemini/sessions/*.json` (legacy). `GEMINI_HOME`/`HOME` set the root. The
|
|
13
|
+
* project-hash → cwd map is `~/.gemini/projects.json` ({ projects: { <cwd>:
|
|
14
|
+
* <id> } }); we also match by a session's own absolute tool paths as a fallback.
|
|
15
|
+
*
|
|
16
|
+
* A session is `{ messages: [...] }` (single JSON) or one record per JSONL line
|
|
17
|
+
* that accretes into that array. Each message: `{ type: 'user'|'gemini'|'info',
|
|
18
|
+
* content?, toolCalls?: [{ name, args:{command?,file_path?,path?}, status?,
|
|
19
|
+
* result?:[{functionResponse:{response:{output?,error?}}}], resultDisplay? }] }`.
|
|
20
|
+
* user -> text (user)
|
|
21
|
+
* gemini -> text (assistant); each toolCall -> tool_use (shell -> canonical
|
|
22
|
+
* Bash) + a tool_result from its response/status.
|
|
23
|
+
*
|
|
24
|
+
* RESILIENCE: depend only on those fields; ignore unknown; a bad line/record is
|
|
25
|
+
* skipped, never guessed. `geminiFormatIsKnown` gates use so a non-Gemini file
|
|
26
|
+
* is never mis-parsed.
|
|
27
|
+
*/
|
|
28
|
+
function geminiRoot() {
|
|
29
|
+
const env = process.env.GEMINI_HOME?.trim();
|
|
30
|
+
return env && env.length > 0 ? join(env, ".gemini") : join(homedir(), ".gemini");
|
|
31
|
+
}
|
|
32
|
+
function realpathOr(p) { try {
|
|
33
|
+
return realpathSync(p);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return p;
|
|
37
|
+
} }
|
|
38
|
+
function str(v) { return typeof v === "string" && v.length > 0 ? v : undefined; }
|
|
39
|
+
function rec(v) { return v && typeof v === "object" && !Array.isArray(v) ? v : {}; }
|
|
40
|
+
/** project-hash → cwd, from ~/.gemini/projects.json ({projects:{<cwd>:<id>}}). */
|
|
41
|
+
function projectHashToCwd() {
|
|
42
|
+
const out = new Map();
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(readFileSync(join(geminiRoot(), "projects.json"), "utf-8"));
|
|
45
|
+
for (const [cwd, id] of Object.entries(parsed.projects ?? {}))
|
|
46
|
+
out.set(id, cwd);
|
|
47
|
+
}
|
|
48
|
+
catch { /* absent */ }
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function sessionFilesIn(dir) {
|
|
52
|
+
const out = [];
|
|
53
|
+
let names = [];
|
|
54
|
+
try {
|
|
55
|
+
names = readdirSync(dir);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
for (const n of names)
|
|
61
|
+
if (n.endsWith(".jsonl") || n.endsWith(".json"))
|
|
62
|
+
out.push(join(dir, n));
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** Gemini CLI session files for this cwd, newest first. */
|
|
66
|
+
export function listGeminiSessions(cwd) {
|
|
67
|
+
const root = geminiRoot();
|
|
68
|
+
const target = realpathOr(cwd);
|
|
69
|
+
const hashCwd = projectHashToCwd();
|
|
70
|
+
const candidates = [];
|
|
71
|
+
const tmp = join(root, "tmp");
|
|
72
|
+
if (existsSync(tmp)) {
|
|
73
|
+
let hashes = [];
|
|
74
|
+
try {
|
|
75
|
+
hashes = readdirSync(tmp);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
hashes = [];
|
|
79
|
+
}
|
|
80
|
+
for (const h of hashes) {
|
|
81
|
+
const mapped = hashCwd.get(h);
|
|
82
|
+
// Only this project's hash dir when we can resolve it; when projects.json
|
|
83
|
+
// doesn't map it, fall back to per-file cwd inference below.
|
|
84
|
+
if (mapped && realpathOr(mapped) !== target)
|
|
85
|
+
continue;
|
|
86
|
+
candidates.push(...sessionFilesIn(join(tmp, h, "chats")));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const legacy = join(root, "sessions");
|
|
90
|
+
if (existsSync(legacy))
|
|
91
|
+
candidates.push(...sessionFilesIn(legacy));
|
|
92
|
+
const hits = [];
|
|
93
|
+
for (const file of candidates) {
|
|
94
|
+
try {
|
|
95
|
+
// When the hash wasn't in projects.json, confirm by the session's own
|
|
96
|
+
// absolute tool paths before claiming it for this cwd.
|
|
97
|
+
if (!fileBelongsToCwd(file, target, hashCwd))
|
|
98
|
+
continue;
|
|
99
|
+
hits.push({ file, mtimeMs: statSync(file).mtimeMs });
|
|
100
|
+
}
|
|
101
|
+
catch { /* skip */ }
|
|
102
|
+
}
|
|
103
|
+
return hits.sort((a, b) => b.mtimeMs - a.mtimeMs).map((h) => h.file);
|
|
104
|
+
}
|
|
105
|
+
/** True if a session file is for `target` cwd (by inferred absolute tool paths). */
|
|
106
|
+
function fileBelongsToCwd(file, target, hashCwd) {
|
|
107
|
+
// If its hash dir mapped to a cwd, listGeminiSessions already filtered it.
|
|
108
|
+
if (hashCwd.size > 0)
|
|
109
|
+
return true; // mapping-authoritative path handled above
|
|
110
|
+
for (const m of readGeminiMessages(file)) {
|
|
111
|
+
for (const tc of m.toolCalls ?? []) {
|
|
112
|
+
const p = str(rec(tc.args).file_path) ?? str(rec(tc.args).path);
|
|
113
|
+
if (p && p.startsWith("/"))
|
|
114
|
+
return realpathOr(p).startsWith(target);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return true; // no evidence either way: don't exclude (experimental, best-effort)
|
|
118
|
+
}
|
|
119
|
+
/** Messages from a Gemini session file, whether single-JSON or JSONL. Tolerant. */
|
|
120
|
+
function readGeminiMessages(file) {
|
|
121
|
+
let raw;
|
|
122
|
+
try {
|
|
123
|
+
raw = readFileSync(file, "utf-8");
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
// Single JSON object with a messages array?
|
|
129
|
+
try {
|
|
130
|
+
const obj = JSON.parse(raw);
|
|
131
|
+
if (obj && Array.isArray(obj.messages))
|
|
132
|
+
return obj.messages;
|
|
133
|
+
}
|
|
134
|
+
catch { /* not a single JSON: try JSONL */ }
|
|
135
|
+
const msgs = [];
|
|
136
|
+
for (const line of raw.split("\n")) {
|
|
137
|
+
if (!line.trim())
|
|
138
|
+
continue;
|
|
139
|
+
try {
|
|
140
|
+
const r = JSON.parse(line);
|
|
141
|
+
if (Array.isArray(r.messages)) {
|
|
142
|
+
msgs.push(...r.messages);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const m = (r.message ?? r);
|
|
146
|
+
if (typeof m.type === "string")
|
|
147
|
+
msgs.push(m);
|
|
148
|
+
}
|
|
149
|
+
catch { /* skip bad line */ }
|
|
150
|
+
}
|
|
151
|
+
return msgs;
|
|
152
|
+
}
|
|
153
|
+
function contentText(content) {
|
|
154
|
+
if (typeof content === "string")
|
|
155
|
+
return content || undefined;
|
|
156
|
+
if (Array.isArray(content)) {
|
|
157
|
+
const parts = content.map((p) => (typeof p === "string" ? p : str(rec(p).text))).filter((p) => Boolean(p));
|
|
158
|
+
return parts.length ? parts.join("\n") : undefined;
|
|
159
|
+
}
|
|
160
|
+
return content ? JSON.stringify(content).slice(0, 2000) : undefined;
|
|
161
|
+
}
|
|
162
|
+
function toToolUse(name, args, id, ts) {
|
|
163
|
+
const command = str(args.command) ?? str(args.cmd);
|
|
164
|
+
const filePath = str(args.file_path) ?? str(args.path);
|
|
165
|
+
const lower = name.toLowerCase();
|
|
166
|
+
if (command && (/(shell|bash|terminal|exec|run|command)/.test(lower) || !filePath)) {
|
|
167
|
+
return { role: "assistant", kind: "tool_use", toolName: "Bash", toolUseId: id, input: { command }, timestamp: ts };
|
|
168
|
+
}
|
|
169
|
+
if (filePath && /(write|create|edit|replace|patch|insert|modif)/.test(lower)) {
|
|
170
|
+
return { role: "assistant", kind: "tool_use", toolName: /(edit|replace|patch)/.test(lower) ? "Edit" : "Write", toolUseId: id, input: { file_path: filePath, ...args }, timestamp: ts };
|
|
171
|
+
}
|
|
172
|
+
if (filePath && /(read|view|open|cat|show)/.test(lower)) {
|
|
173
|
+
return { role: "assistant", kind: "tool_use", toolName: "Read", toolUseId: id, input: { file_path: filePath }, timestamp: ts };
|
|
174
|
+
}
|
|
175
|
+
return { role: "assistant", kind: "tool_use", toolName: name, toolUseId: id, input: args, timestamp: ts };
|
|
176
|
+
}
|
|
177
|
+
function toolResultText(tc) {
|
|
178
|
+
const first = Array.isArray(tc.result) ? tc.result[0] : undefined;
|
|
179
|
+
const resp = rec(rec(rec(first).functionResponse).response);
|
|
180
|
+
const out = str(resp.output);
|
|
181
|
+
const err = str(resp.error);
|
|
182
|
+
const display = typeof tc.resultDisplay === "string" ? tc.resultDisplay : str(rec(tc.resultDisplay).fileDiff) ?? str(rec(tc.resultDisplay).newContent);
|
|
183
|
+
return { content: (out ?? err ?? display ?? "").slice(0, 2000), isError: tc.status === "error" || Boolean(err) };
|
|
184
|
+
}
|
|
185
|
+
/** True when the file looks like a Gemini CLI session (≥1 user/gemini message). */
|
|
186
|
+
export function geminiFormatIsKnown(file) {
|
|
187
|
+
for (const m of readGeminiMessages(file)) {
|
|
188
|
+
if (m.type === "gemini" || (m.type === "user" && m.content !== undefined))
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
/** Parse a Gemini CLI session into neutral events. Tolerant: unknown → skipped. */
|
|
194
|
+
export function parseGeminiTranscript(file) {
|
|
195
|
+
const out = [];
|
|
196
|
+
let i = 0;
|
|
197
|
+
for (const m of readGeminiMessages(file)) {
|
|
198
|
+
if (m.type === "user") {
|
|
199
|
+
const text = contentText(m.content);
|
|
200
|
+
if (text)
|
|
201
|
+
out.push({ role: "user", kind: "text", text, timestamp: "" });
|
|
202
|
+
}
|
|
203
|
+
else if (m.type === "gemini") {
|
|
204
|
+
const text = contentText(m.content);
|
|
205
|
+
if (text)
|
|
206
|
+
out.push({ role: "assistant", kind: "text", text, timestamp: "" });
|
|
207
|
+
for (const tc of m.toolCalls ?? []) {
|
|
208
|
+
const name = str(tc.name);
|
|
209
|
+
if (!name)
|
|
210
|
+
continue;
|
|
211
|
+
const id = `g:${i++}`;
|
|
212
|
+
out.push(toToolUse(name, rec(tc.args), id, ""));
|
|
213
|
+
const { content, isError } = toolResultText(tc);
|
|
214
|
+
out.push({ role: "user", kind: "tool_result", toolUseId: id, content, isError, timestamp: "" });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
package/dist/adapters/index.d.ts
CHANGED
|
@@ -42,6 +42,10 @@ export declare const codexAdapter: SessionAdapter;
|
|
|
42
42
|
export declare const copilotCliAdapter: SessionAdapter;
|
|
43
43
|
/** Adapters with a verified, tested parser — these auto-detect the newest session. */
|
|
44
44
|
export declare const ADAPTERS: SessionAdapter[];
|
|
45
|
+
/** Gemini CLI — EXPERIMENTAL. Our reader; format per cli-continues (MIT, pinned). */
|
|
46
|
+
export declare const geminiCliAdapter: SessionAdapter;
|
|
47
|
+
/** Cursor — EXPERIMENTAL. Current agent-transcripts (JSONL, Anthropic-shaped); old SQLite deferred. */
|
|
48
|
+
export declare const cursorAdapter: SessionAdapter;
|
|
45
49
|
/** Experimental adapters: reader exists, awaiting real+planted+clean fixtures. */
|
|
46
50
|
export declare const EXPERIMENTAL_ADAPTERS: SessionAdapter[];
|
|
47
51
|
/**
|
package/dist/adapters/index.js
CHANGED
|
@@ -3,6 +3,8 @@ import { basename } from "node:path";
|
|
|
3
3
|
import { listAllSessionFiles, readTranscriptFromFile, findSubagentFiles } from "../parsers/transcriptParser.js";
|
|
4
4
|
import { listCodexSessions, parseCodexTranscript } from "./codex.js";
|
|
5
5
|
import { listCopilotSessions, parseCopilotTranscript, copilotFormatIsKnown } from "./copilot.js";
|
|
6
|
+
import { listGeminiSessions, parseGeminiTranscript, geminiFormatIsKnown } from "./gemini.js";
|
|
7
|
+
import { listCursorSessions, parseCursorTranscript, cursorFormatIsKnown } from "./cursor.js";
|
|
6
8
|
/**
|
|
7
9
|
* Claude Code — the original and reference adapter. Delegates to the existing
|
|
8
10
|
* transcriptParser so its behaviour (including subagent transcripts) is
|
|
@@ -38,19 +40,31 @@ export const copilotCliAdapter = {
|
|
|
38
40
|
};
|
|
39
41
|
/** Adapters with a verified, tested parser — these auto-detect the newest session. */
|
|
40
42
|
export const ADAPTERS = [claudeCodeAdapter, codexAdapter];
|
|
43
|
+
/** Gemini CLI — EXPERIMENTAL. Our reader; format per cli-continues (MIT, pinned). */
|
|
44
|
+
export const geminiCliAdapter = {
|
|
45
|
+
tool: "gemini-cli",
|
|
46
|
+
listSessions: (cwd) => listGeminiSessions(cwd),
|
|
47
|
+
parse: (sessionFile) => parseGeminiTranscript(sessionFile),
|
|
48
|
+
experimental: true,
|
|
49
|
+
};
|
|
50
|
+
/** Cursor — EXPERIMENTAL. Current agent-transcripts (JSONL, Anthropic-shaped); old SQLite deferred. */
|
|
51
|
+
export const cursorAdapter = {
|
|
52
|
+
tool: "cursor",
|
|
53
|
+
listSessions: (cwd) => listCursorSessions(cwd),
|
|
54
|
+
parse: (sessionFile) => parseCursorTranscript(sessionFile),
|
|
55
|
+
experimental: true,
|
|
56
|
+
};
|
|
41
57
|
/** Experimental adapters: reader exists, awaiting real+planted+clean fixtures. */
|
|
42
|
-
export const EXPERIMENTAL_ADAPTERS = [copilotCliAdapter];
|
|
58
|
+
export const EXPERIMENTAL_ADAPTERS = [copilotCliAdapter, geminiCliAdapter, cursorAdapter];
|
|
43
59
|
/**
|
|
44
60
|
* Tools deliberately NOT read yet, with the honest reason. Kept as data (not
|
|
45
61
|
* silence) so the tool — and its docs — can state exactly where the line is
|
|
46
62
|
* and why, and so adding one later is a visible change here.
|
|
47
63
|
*/
|
|
48
64
|
export const UNSUPPORTED_TOOLS = [
|
|
49
|
-
{ tool: "gemini-cli", reason: "session-log path is known (~/.gemini/tmp/<hash>/chats/*.json) but the per-line JSON schema is unverified — not parsed, to avoid fabricating events" },
|
|
50
65
|
{ tool: "aider", reason: "history is a Markdown transcript (.aider.chat.history.md), not structured events — needs a prose parser, not a field mapping" },
|
|
51
|
-
{ tool: "opencode", reason: "stores sessions in a SQLite DB (opencode.db)
|
|
52
|
-
{ tool: "
|
|
53
|
-
{ tool: "windsurf", reason: "IDE-embedded; history in undocumented internal state, same as Cursor" },
|
|
66
|
+
{ tool: "opencode", reason: "stores sessions in a SQLite DB (opencode.db) — needs a WASM sqlite reader; planned next" },
|
|
67
|
+
{ tool: "windsurf", reason: "IDE-embedded; history in undocumented internal state, same as old Cursor" },
|
|
54
68
|
];
|
|
55
69
|
/**
|
|
56
70
|
* The single most recently modified session across ALL supported tools for
|
|
@@ -137,6 +151,15 @@ export function parseSessionFile(file) {
|
|
|
137
151
|
catch {
|
|
138
152
|
/* first line is not JSON: treat as a Claude transcript below */
|
|
139
153
|
}
|
|
154
|
+
// Gemini CLI: a `{messages:[...]}` JSON or JSONL of `type:'gemini'|'user'`
|
|
155
|
+
// records (with their own `content`, unlike Claude's `message.content`).
|
|
156
|
+
// Confirmed by a known-format check so a Claude/other log is never mis-read.
|
|
157
|
+
if (geminiFormatIsKnown(file))
|
|
158
|
+
return parseGeminiTranscript(file);
|
|
159
|
+
// Cursor agent-transcript: a JSONL line `{role, content:[...]}` with no Claude
|
|
160
|
+
// `message` wrapper and no `type`. Gated the same way.
|
|
161
|
+
if (cursorFormatIsKnown(file))
|
|
162
|
+
return parseCursorTranscript(file);
|
|
140
163
|
return readTranscriptFromFile(file);
|
|
141
164
|
}
|
|
142
165
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rulereceipt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.83",
|
|
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",
|