@chronicle.app/claude-code 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/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/connectors/ClaudeCodeExtractor.d.ts +113 -0
- package/dist/connectors/ClaudeCodeExtractor.js +341 -0
- package/dist/connectors/ClaudeCodeTransformer.d.ts +33 -0
- package/dist/connectors/ClaudeCodeTransformer.js +135 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Louis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @chronicle.app/claude-code
|
|
2
|
+
|
|
3
|
+
`ClaudeCodeExtractor` reads a session JSONL file, a project directory, or the Claude Code projects root (`~/.claude/projects` by default). `ClaudeCodeTransformer` emits MessageAction/Message records grouped into session Threads and machine-scoped Projects, with assistant models recorded as instruments.
|
|
4
|
+
|
|
5
|
+
Options: `assistant: 'full' | 'terse' | 'off'` (default full), `tools: boolean` (default false), inclusive `since`/`until`, and `limit` (zero means unlimited). Sessions are visited by newest modification time, then messages in reverse transcript order. This is file order, not a global timestamp sort. Subagent subdirectories are excluded when scanning projects.
|
|
6
|
+
|
|
7
|
+
Preserves the existing format contract: user prompts must carry `origin.kind: 'human'`; meta messages, tool results, synthetic assistant replies, and sessions without a human prompt/working directory are excluded. Malformed JSON lines and invalid message dates are skipped. Older transcript formats without human-origin metadata are not inferred.
|
|
8
|
+
|
|
9
|
+
Account identity comes from the transcript, or the matching install's `.claude.json` account metadata when the input is under `.claude`. Copies outside that install do not borrow the host account. Source account ids win over install metadata. Transformer `hostname` can identify the original machine for exported transcripts. Tools are summaries only; no commands are executed.
|
|
10
|
+
|
|
11
|
+
Tests generate synthetic transcripts and account metadata in temporary directories; no personal sessions are read.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Extractor, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import ClaudeCodeTransformer from './ClaudeCodeTransformer.js';
|
|
4
|
+
declare const ASSISTANT_MODES: readonly ["full", "terse", "off"];
|
|
5
|
+
export type AssistantMode = (typeof ASSISTANT_MODES)[number];
|
|
6
|
+
/** One imported message: a human prompt, an assistant reply, or a tool call. */
|
|
7
|
+
export interface TranscriptMessage {
|
|
8
|
+
/** The transcript line's own uuid: the extract-time key and the Message identity. */
|
|
9
|
+
uuid: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
role: 'human' | 'assistant' | 'tool';
|
|
12
|
+
body: string;
|
|
13
|
+
/** The model that produced an assistant reply or tool call (`message.model`). */
|
|
14
|
+
model?: string;
|
|
15
|
+
}
|
|
16
|
+
/** The Anthropic account a session ran under. */
|
|
17
|
+
export interface Account {
|
|
18
|
+
/** The account uuid, shared with claude.ai's own exports of the same account. */
|
|
19
|
+
uuid: string;
|
|
20
|
+
email?: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
}
|
|
23
|
+
/** The session every message of a file belongs to. */
|
|
24
|
+
export interface SessionContext {
|
|
25
|
+
recordType: 'messages';
|
|
26
|
+
/** The session uuid: the file basename and `sessionId` on every line. */
|
|
27
|
+
sessionId: string;
|
|
28
|
+
/** The last custom title, else the last AI title, else the first prompt. */
|
|
29
|
+
title: string;
|
|
30
|
+
/** Working directory of the first human prompt: the project the session belongs to. */
|
|
31
|
+
cwd: string;
|
|
32
|
+
file: string;
|
|
33
|
+
/**
|
|
34
|
+
* From the session's own `bridge-session` or artifact ledger lines, else
|
|
35
|
+
* the logged-in account of the Claude Code install the transcript belongs
|
|
36
|
+
* to. Absent when neither says.
|
|
37
|
+
*/
|
|
38
|
+
account?: Account;
|
|
39
|
+
}
|
|
40
|
+
export declare class ClaudeCodeExtractor extends Extractor<typeof ClaudeCodeExtractor> {
|
|
41
|
+
static source: string;
|
|
42
|
+
static description: string;
|
|
43
|
+
static delivery: "local";
|
|
44
|
+
static strategy: string;
|
|
45
|
+
static recordTypes: string[];
|
|
46
|
+
static default: boolean;
|
|
47
|
+
static temporality: "event";
|
|
48
|
+
static defaultTransformer: typeof ClaudeCodeTransformer;
|
|
49
|
+
static schema: z.ZodObject<{
|
|
50
|
+
since: z.ZodOptional<z.ZodDate>;
|
|
51
|
+
until: z.ZodOptional<z.ZodDate>;
|
|
52
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
53
|
+
} & {
|
|
54
|
+
input: z.ZodDefault<z.ZodString>;
|
|
55
|
+
assistant: z.ZodDefault<z.ZodEnum<["full", "terse", "off"]>>;
|
|
56
|
+
tools: z.ZodDefault<z.ZodBoolean>;
|
|
57
|
+
}, "strip", z.ZodTypeAny, {
|
|
58
|
+
assistant: "full" | "terse" | "off";
|
|
59
|
+
input: string;
|
|
60
|
+
tools: boolean;
|
|
61
|
+
since?: Date | undefined;
|
|
62
|
+
until?: Date | undefined;
|
|
63
|
+
limit?: number | undefined;
|
|
64
|
+
}, {
|
|
65
|
+
assistant?: "full" | "terse" | "off" | undefined;
|
|
66
|
+
input?: string | undefined;
|
|
67
|
+
tools?: boolean | undefined;
|
|
68
|
+
since?: Date | undefined;
|
|
69
|
+
until?: Date | undefined;
|
|
70
|
+
limit?: number | undefined;
|
|
71
|
+
}>;
|
|
72
|
+
private files;
|
|
73
|
+
/** The install's logged-in account, when the input lives under its `.claude`. */
|
|
74
|
+
private localAccount?;
|
|
75
|
+
setup(): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* A session file, a project directory (session files at its top level), or
|
|
78
|
+
* the projects root (one directory per project). Subagent transcripts live
|
|
79
|
+
* under a session's own directory and are never read.
|
|
80
|
+
*/
|
|
81
|
+
private findSessionFiles;
|
|
82
|
+
/**
|
|
83
|
+
* Claude Code keeps the logged-in account in `.claude.json` beside the
|
|
84
|
+
* `.claude` directory its transcripts live under. Only that install's own
|
|
85
|
+
* data is read: a copied directory carries no account of its own.
|
|
86
|
+
*/
|
|
87
|
+
private readLocalAccount;
|
|
88
|
+
private sessionFilesIn;
|
|
89
|
+
extract(): AsyncGenerator<Record>;
|
|
90
|
+
/** Apply the assistant and tools flags to a parsed session, in file order. */
|
|
91
|
+
private messagesOf;
|
|
92
|
+
/** The responses of a turn whose text becomes a Message. */
|
|
93
|
+
private replies;
|
|
94
|
+
/** One line describing a tool call's input; the result is never imported. */
|
|
95
|
+
private summarise;
|
|
96
|
+
/** Parse one session file; null when it holds no human prompt. */
|
|
97
|
+
private readSession;
|
|
98
|
+
/**
|
|
99
|
+
* The session's own account uuid wins; the install's email and name attach
|
|
100
|
+
* only when they describe that same account.
|
|
101
|
+
*/
|
|
102
|
+
private account;
|
|
103
|
+
/**
|
|
104
|
+
* A prompt the person typed or queued. Tool results, compaction summaries,
|
|
105
|
+
* interruptions, slash-command echoes and other harness-injected lines are
|
|
106
|
+
* `user` lines too, but carry no human origin or are marked meta.
|
|
107
|
+
*/
|
|
108
|
+
private humanPrompt;
|
|
109
|
+
/** Add an assistant line's blocks to the response they belong to. */
|
|
110
|
+
private collect;
|
|
111
|
+
}
|
|
112
|
+
export {};
|
|
113
|
+
//# sourceMappingURL=ClaudeCodeExtractor.d.ts.map
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { createReadStream, promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import { Extractor } from '@chronicle.app/etl';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import ClaudeCodeTransformer from './ClaudeCodeTransformer.js';
|
|
8
|
+
const ASSISTANT_MODES = ['full', 'terse', 'off'];
|
|
9
|
+
/** How long a tool call's summary, or a title derived from a prompt, may be. */
|
|
10
|
+
const SUMMARY_LENGTH = 200;
|
|
11
|
+
const TITLE_LENGTH = 80;
|
|
12
|
+
/** Tools whose input is best summarised by the path they touch. */
|
|
13
|
+
const PATH_INPUTS = ['file_path', 'path', 'notebook_path', 'pattern'];
|
|
14
|
+
export class ClaudeCodeExtractor extends Extractor {
|
|
15
|
+
static source = 'claude-code';
|
|
16
|
+
static description = 'Session transcripts: prompts and replies';
|
|
17
|
+
static delivery = 'local';
|
|
18
|
+
static strategy = 'transcripts';
|
|
19
|
+
static recordTypes = ['messages'];
|
|
20
|
+
static default = true;
|
|
21
|
+
static temporality = 'event';
|
|
22
|
+
static defaultTransformer = ClaudeCodeTransformer;
|
|
23
|
+
static schema = Extractor.schema.extend({
|
|
24
|
+
input: z
|
|
25
|
+
.string()
|
|
26
|
+
.default(path.join(os.homedir(), '.claude', 'projects'))
|
|
27
|
+
.describe('The Claude Code projects directory, one project directory, or one session file'),
|
|
28
|
+
assistant: z
|
|
29
|
+
.enum(ASSISTANT_MODES)
|
|
30
|
+
.default('full')
|
|
31
|
+
.describe("Assistant replies to import: full (every reply), terse (each turn's final reply only), off"),
|
|
32
|
+
tools: z.boolean().default(false).describe('Import each tool call as a one-line message'),
|
|
33
|
+
});
|
|
34
|
+
files = [];
|
|
35
|
+
/** The install's logged-in account, when the input lives under its `.claude`. */
|
|
36
|
+
localAccount;
|
|
37
|
+
async setup() {
|
|
38
|
+
const input = path.resolve(String(this.config.input));
|
|
39
|
+
this.localAccount = await this.readLocalAccount(input);
|
|
40
|
+
const entries = await Promise.all((await this.findSessionFiles(input)).map(async (file) => ({
|
|
41
|
+
file,
|
|
42
|
+
mtime: (await fs.stat(file)).mtime,
|
|
43
|
+
})));
|
|
44
|
+
// Newest sessions first so a limited import shows recent work; path order
|
|
45
|
+
// breaks ties so an unchanged directory yields the same stream.
|
|
46
|
+
entries.sort((a, b) => b.mtime.getTime() - a.mtime.getTime() || a.file.localeCompare(b.file));
|
|
47
|
+
this.files = entries.map(entry => entry.file);
|
|
48
|
+
this.logVerboseStep(`${this.files.length} session files under ${input}`);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A session file, a project directory (session files at its top level), or
|
|
52
|
+
* the projects root (one directory per project). Subagent transcripts live
|
|
53
|
+
* under a session's own directory and are never read.
|
|
54
|
+
*/
|
|
55
|
+
async findSessionFiles(input) {
|
|
56
|
+
if ((await fs.stat(input)).isFile())
|
|
57
|
+
return [input];
|
|
58
|
+
const own = await this.sessionFilesIn(input);
|
|
59
|
+
if (own.length > 0)
|
|
60
|
+
return own;
|
|
61
|
+
const files = [];
|
|
62
|
+
for (const entry of await fs.readdir(input, { withFileTypes: true })) {
|
|
63
|
+
if (entry.isDirectory() && !entry.name.startsWith('.'))
|
|
64
|
+
files.push(...(await this.sessionFilesIn(path.join(input, entry.name))));
|
|
65
|
+
}
|
|
66
|
+
return files;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Claude Code keeps the logged-in account in `.claude.json` beside the
|
|
70
|
+
* `.claude` directory its transcripts live under. Only that install's own
|
|
71
|
+
* data is read: a copied directory carries no account of its own.
|
|
72
|
+
*/
|
|
73
|
+
async readLocalAccount(input) {
|
|
74
|
+
let dir = input;
|
|
75
|
+
while (path.basename(dir) !== '.claude') {
|
|
76
|
+
const parent = path.dirname(dir);
|
|
77
|
+
if (parent === dir)
|
|
78
|
+
return undefined;
|
|
79
|
+
dir = parent;
|
|
80
|
+
}
|
|
81
|
+
const file = path.join(path.dirname(dir), '.claude.json');
|
|
82
|
+
let config;
|
|
83
|
+
try {
|
|
84
|
+
config = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const oauth = config?.oauthAccount;
|
|
90
|
+
if (typeof oauth?.accountUuid !== 'string')
|
|
91
|
+
return undefined;
|
|
92
|
+
return {
|
|
93
|
+
uuid: oauth.accountUuid,
|
|
94
|
+
...(typeof oauth.emailAddress === 'string' && {
|
|
95
|
+
email: oauth.emailAddress,
|
|
96
|
+
}),
|
|
97
|
+
...(typeof oauth.fullName === 'string' && { name: oauth.fullName }),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async sessionFilesIn(dir) {
|
|
101
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
102
|
+
return entries
|
|
103
|
+
.filter(entry => entry.isFile() && entry.name.endsWith('.jsonl'))
|
|
104
|
+
.map(entry => path.join(dir, entry.name))
|
|
105
|
+
.sort((a, b) => a.localeCompare(b));
|
|
106
|
+
}
|
|
107
|
+
async *extract() {
|
|
108
|
+
const config = this.config;
|
|
109
|
+
let count = 0;
|
|
110
|
+
for (const file of this.files) {
|
|
111
|
+
if (this.shouldStopExtracting(count))
|
|
112
|
+
return;
|
|
113
|
+
const session = await this.readSession(file);
|
|
114
|
+
if (!session)
|
|
115
|
+
continue;
|
|
116
|
+
// Newest message first, like the sessions, so a limited import is the
|
|
117
|
+
// latest messages rather than the openings of the latest sessions.
|
|
118
|
+
const messages = [...this.messagesOf(session, config.assistant, config.tools)].reverse();
|
|
119
|
+
for (const message of messages) {
|
|
120
|
+
if (this.shouldStopExtracting(count))
|
|
121
|
+
return;
|
|
122
|
+
const timestamp = new Date(message.timestamp);
|
|
123
|
+
if (!Number.isFinite(timestamp.getTime()))
|
|
124
|
+
continue;
|
|
125
|
+
if (config.since && timestamp < config.since)
|
|
126
|
+
continue;
|
|
127
|
+
if (config.until && timestamp > config.until)
|
|
128
|
+
continue;
|
|
129
|
+
yield this.createRecord(message, session.context);
|
|
130
|
+
count++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Apply the assistant and tools flags to a parsed session, in file order. */
|
|
135
|
+
*messagesOf(session, assistant, tools) {
|
|
136
|
+
for (const turn of session.turns) {
|
|
137
|
+
yield turn.prompt;
|
|
138
|
+
const replies = this.replies(turn.responses, assistant);
|
|
139
|
+
for (const response of turn.responses) {
|
|
140
|
+
if (replies.has(response)) {
|
|
141
|
+
const [first] = response.texts;
|
|
142
|
+
yield {
|
|
143
|
+
uuid: first.uuid,
|
|
144
|
+
timestamp: first.timestamp,
|
|
145
|
+
role: 'assistant',
|
|
146
|
+
body: response.texts.map(block => block.text).join('\n\n'),
|
|
147
|
+
model: response.model,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (!tools)
|
|
151
|
+
continue;
|
|
152
|
+
for (const tool of response.tools)
|
|
153
|
+
yield {
|
|
154
|
+
uuid: tool.uuid,
|
|
155
|
+
timestamp: tool.timestamp,
|
|
156
|
+
role: 'tool',
|
|
157
|
+
body: `${tool.name}: ${this.summarise(tool.name, tool.input)}`,
|
|
158
|
+
model: response.model,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** The responses of a turn whose text becomes a Message. */
|
|
164
|
+
replies(responses, mode) {
|
|
165
|
+
const withText = responses.filter(response => response.texts.length > 0);
|
|
166
|
+
if (mode === 'off')
|
|
167
|
+
return new Set();
|
|
168
|
+
if (mode === 'terse')
|
|
169
|
+
return new Set(withText.slice(-1));
|
|
170
|
+
return new Set(withText);
|
|
171
|
+
}
|
|
172
|
+
/** One line describing a tool call's input; the result is never imported. */
|
|
173
|
+
summarise(name, input) {
|
|
174
|
+
const fields = (input ?? {});
|
|
175
|
+
let summary;
|
|
176
|
+
if (name === 'Bash') {
|
|
177
|
+
summary =
|
|
178
|
+
typeof fields.description === 'string'
|
|
179
|
+
? fields.description
|
|
180
|
+
: typeof fields.command === 'string'
|
|
181
|
+
? fields.command
|
|
182
|
+
: undefined;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const key = PATH_INPUTS.find(field => typeof fields[field] === 'string');
|
|
186
|
+
if (key)
|
|
187
|
+
summary = fields[key];
|
|
188
|
+
}
|
|
189
|
+
summary ??= JSON.stringify(input ?? {});
|
|
190
|
+
const line = summary.replaceAll(/\s*\n\s*/g, ' ').trim();
|
|
191
|
+
return line.length > SUMMARY_LENGTH ? `${line.slice(0, SUMMARY_LENGTH - 1)}…` : line;
|
|
192
|
+
}
|
|
193
|
+
/** Parse one session file; null when it holds no human prompt. */
|
|
194
|
+
async readSession(file) {
|
|
195
|
+
const sessionId = path.basename(file, '.jsonl');
|
|
196
|
+
let customTitle;
|
|
197
|
+
let aiTitle;
|
|
198
|
+
let cwd;
|
|
199
|
+
let accountUuid;
|
|
200
|
+
const turns = [];
|
|
201
|
+
let responses = [];
|
|
202
|
+
let lineNumber = 0;
|
|
203
|
+
const lines = readline.createInterface({
|
|
204
|
+
input: createReadStream(file, { encoding: 'utf8' }),
|
|
205
|
+
crlfDelay: Number.POSITIVE_INFINITY,
|
|
206
|
+
});
|
|
207
|
+
for await (const raw of lines) {
|
|
208
|
+
lineNumber++;
|
|
209
|
+
if (!raw.trim())
|
|
210
|
+
continue;
|
|
211
|
+
let line;
|
|
212
|
+
try {
|
|
213
|
+
line = JSON.parse(raw);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
this.logger.warn(`${file}:${lineNumber}: unparseable line skipped`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!line || typeof line !== 'object')
|
|
220
|
+
continue;
|
|
221
|
+
switch (line.type) {
|
|
222
|
+
case 'custom-title':
|
|
223
|
+
if (typeof line.customTitle === 'string' && line.customTitle.trim())
|
|
224
|
+
customTitle = line.customTitle.trim();
|
|
225
|
+
break;
|
|
226
|
+
case 'ai-title':
|
|
227
|
+
if (typeof line.aiTitle === 'string' && line.aiTitle.trim())
|
|
228
|
+
aiTitle = line.aiTitle.trim();
|
|
229
|
+
break;
|
|
230
|
+
case 'bridge-session':
|
|
231
|
+
if (typeof line.ownerAccountUuid === 'string')
|
|
232
|
+
accountUuid = line.ownerAccountUuid;
|
|
233
|
+
break;
|
|
234
|
+
case 'artifact-autoreact-ledger':
|
|
235
|
+
if (typeof line.accountUuid === 'string')
|
|
236
|
+
accountUuid = line.accountUuid;
|
|
237
|
+
break;
|
|
238
|
+
case 'user': {
|
|
239
|
+
const prompt = this.humanPrompt(line);
|
|
240
|
+
if (!prompt)
|
|
241
|
+
break;
|
|
242
|
+
cwd ??= typeof line.cwd === 'string' ? line.cwd : undefined;
|
|
243
|
+
responses = [];
|
|
244
|
+
turns.push({ prompt, responses });
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case 'assistant':
|
|
248
|
+
// Lines before the first prompt (a resumed session's history) have
|
|
249
|
+
// no turn to belong to.
|
|
250
|
+
if (turns.length > 0)
|
|
251
|
+
this.collect(line, responses);
|
|
252
|
+
break;
|
|
253
|
+
default:
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (turns.length === 0 || cwd === undefined)
|
|
258
|
+
return null;
|
|
259
|
+
const first = turns[0].prompt.body.replaceAll(/\s+/g, ' ').trim();
|
|
260
|
+
const title = customTitle ??
|
|
261
|
+
aiTitle ??
|
|
262
|
+
(first.length > TITLE_LENGTH ? `${first.slice(0, TITLE_LENGTH - 1)}…` : first);
|
|
263
|
+
const account = this.account(accountUuid);
|
|
264
|
+
return {
|
|
265
|
+
context: {
|
|
266
|
+
recordType: 'messages',
|
|
267
|
+
sessionId,
|
|
268
|
+
title,
|
|
269
|
+
cwd,
|
|
270
|
+
file,
|
|
271
|
+
...(account && { account }),
|
|
272
|
+
},
|
|
273
|
+
turns,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The session's own account uuid wins; the install's email and name attach
|
|
278
|
+
* only when they describe that same account.
|
|
279
|
+
*/
|
|
280
|
+
account(uuid) {
|
|
281
|
+
const local = this.localAccount;
|
|
282
|
+
if (uuid === undefined)
|
|
283
|
+
return local;
|
|
284
|
+
return local?.uuid === uuid ? local : { uuid };
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* A prompt the person typed or queued. Tool results, compaction summaries,
|
|
288
|
+
* interruptions, slash-command echoes and other harness-injected lines are
|
|
289
|
+
* `user` lines too, but carry no human origin or are marked meta.
|
|
290
|
+
*/
|
|
291
|
+
humanPrompt(line) {
|
|
292
|
+
if (line.isMeta === true || line.origin?.kind !== 'human')
|
|
293
|
+
return null;
|
|
294
|
+
if (typeof line.uuid !== 'string' || typeof line.timestamp !== 'string')
|
|
295
|
+
return null;
|
|
296
|
+
const content = line.message?.content;
|
|
297
|
+
const body = typeof content === 'string'
|
|
298
|
+
? content
|
|
299
|
+
: Array.isArray(content)
|
|
300
|
+
? content
|
|
301
|
+
.filter(block => block?.type === 'text' && typeof block.text === 'string')
|
|
302
|
+
.map(block => block.text)
|
|
303
|
+
.join('\n')
|
|
304
|
+
: '';
|
|
305
|
+
if (!body.trim())
|
|
306
|
+
return null;
|
|
307
|
+
return { uuid: line.uuid, timestamp: line.timestamp, role: 'human', body };
|
|
308
|
+
}
|
|
309
|
+
/** Add an assistant line's blocks to the response they belong to. */
|
|
310
|
+
collect(line, responses) {
|
|
311
|
+
const model = line.message?.model;
|
|
312
|
+
if (typeof model !== 'string' || model === '<synthetic>')
|
|
313
|
+
return;
|
|
314
|
+
if (typeof line.uuid !== 'string' || typeof line.timestamp !== 'string')
|
|
315
|
+
return;
|
|
316
|
+
const id = typeof line.message?.id === 'string' ? line.message.id : line.uuid;
|
|
317
|
+
let response = responses.find(candidate => candidate.id === id);
|
|
318
|
+
if (!response) {
|
|
319
|
+
response = { id, model, texts: [], tools: [] };
|
|
320
|
+
responses.push(response);
|
|
321
|
+
}
|
|
322
|
+
for (const block of Array.isArray(line.message?.content) ? line.message.content : []) {
|
|
323
|
+
if (block?.type === 'text' && typeof block.text === 'string') {
|
|
324
|
+
if (block.text.trim())
|
|
325
|
+
response.texts.push({
|
|
326
|
+
uuid: line.uuid,
|
|
327
|
+
timestamp: line.timestamp,
|
|
328
|
+
text: block.text,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
else if (block?.type === 'tool_use' && typeof block.name === 'string') {
|
|
332
|
+
response.tools.push({
|
|
333
|
+
uuid: typeof block.id === 'string' ? block.id : line.uuid,
|
|
334
|
+
timestamp: line.timestamp,
|
|
335
|
+
name: block.name,
|
|
336
|
+
input: block.input,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ChronicleTransformer, Record } from '@chronicle.app/etl';
|
|
2
|
+
import { MessageAction } from '@chronicle.app/schema';
|
|
3
|
+
/**
|
|
4
|
+
* One MessageAction per transcript message: the person's prompts authored by
|
|
5
|
+
* `@me`, the assistant's replies and tool calls authored by Claude with the
|
|
6
|
+
* model that produced them as the action's instrument. Every message is part
|
|
7
|
+
* of the session's Thread, and the Thread is part of the Project the session
|
|
8
|
+
* ran in, scoped to this machine.
|
|
9
|
+
*/
|
|
10
|
+
export default class ClaudeCodeTransformer extends ChronicleTransformer {
|
|
11
|
+
transform(record: Record): Promise<MessageAction[]>;
|
|
12
|
+
/**
|
|
13
|
+
* Me. Claude Code has no user identity of its own: the only id it records
|
|
14
|
+
* is the Anthropic account uuid, which claude.ai exports of the same account
|
|
15
|
+
* carry too, so the self IS that account's Person (the way iMessage's agents
|
|
16
|
+
* are iCloud or phone identities under `imessage` messages). The email joins
|
|
17
|
+
* the address the mail and messaging sources know. A transcript that names no
|
|
18
|
+
* account gets the per-source singleton; `@me` still merges both.
|
|
19
|
+
*/
|
|
20
|
+
private self;
|
|
21
|
+
/**
|
|
22
|
+
* The model a reply ran on: a kind, not an individual — the same weights
|
|
23
|
+
* answer everyone — so it is the instrument of the reply, not its author.
|
|
24
|
+
*/
|
|
25
|
+
private model;
|
|
26
|
+
private thread;
|
|
27
|
+
/**
|
|
28
|
+
* The working directory as a Project on this machine — the same hostname
|
|
29
|
+
* Realm the shell and Timing sources scope their paths and accounts by.
|
|
30
|
+
*/
|
|
31
|
+
private project;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=ClaudeCodeTransformer.d.ts.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { ChronicleTransformer, selfAgent } from '@chronicle.app/etl';
|
|
3
|
+
const SOURCE = 'claude-code';
|
|
4
|
+
const BY_SOURCE_ID = ['@type', 'source', 'sourceId'];
|
|
5
|
+
const BY_HANDLE = ['@type', 'source', 'handle'];
|
|
6
|
+
/**
|
|
7
|
+
* Claude Code only reports these ids: the account is claude.ai's (the same
|
|
8
|
+
* uuid its exports carry); the assistant and its model ids are Anthropic's.
|
|
9
|
+
*/
|
|
10
|
+
const ACCOUNT_SOURCE = 'claude';
|
|
11
|
+
const ANTHROPIC = 'anthropic';
|
|
12
|
+
/**
|
|
13
|
+
* The assistant you talk to, one entity across every surface it appears on:
|
|
14
|
+
* Claude Code is Claude in a terminal, and the message source records which.
|
|
15
|
+
*/
|
|
16
|
+
const CLAUDE = {
|
|
17
|
+
'@type': 'SoftwareAgent',
|
|
18
|
+
'@key': BY_HANDLE,
|
|
19
|
+
source: ANTHROPIC,
|
|
20
|
+
handle: 'claude',
|
|
21
|
+
name: 'Claude',
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* One MessageAction per transcript message: the person's prompts authored by
|
|
25
|
+
* `@me`, the assistant's replies and tool calls authored by Claude with the
|
|
26
|
+
* model that produced them as the action's instrument. Every message is part
|
|
27
|
+
* of the session's Thread, and the Thread is part of the Project the session
|
|
28
|
+
* ran in, scoped to this machine.
|
|
29
|
+
*/
|
|
30
|
+
export default class ClaudeCodeTransformer extends ChronicleTransformer {
|
|
31
|
+
async transform(record) {
|
|
32
|
+
if (record.extraction.recordType !== 'messages')
|
|
33
|
+
return [];
|
|
34
|
+
const message = record.data;
|
|
35
|
+
const session = record.context;
|
|
36
|
+
const human = message.role === 'human';
|
|
37
|
+
if (!human && !message.model)
|
|
38
|
+
return [];
|
|
39
|
+
const author = human ? this.self(session.account) : CLAUDE;
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
'@type': 'MessageAction',
|
|
43
|
+
'@key': BY_SOURCE_ID,
|
|
44
|
+
source: SOURCE,
|
|
45
|
+
sourceId: message.uuid,
|
|
46
|
+
timestamp: new Date(message.timestamp),
|
|
47
|
+
agent: author,
|
|
48
|
+
...(!human && { instrument: this.model(message.model) }),
|
|
49
|
+
object: {
|
|
50
|
+
'@type': 'Message',
|
|
51
|
+
'@key': BY_SOURCE_ID,
|
|
52
|
+
source: SOURCE,
|
|
53
|
+
sourceId: message.uuid,
|
|
54
|
+
body: message.body,
|
|
55
|
+
author: [author],
|
|
56
|
+
isPartOf: [await this.thread(session)],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Me. Claude Code has no user identity of its own: the only id it records
|
|
63
|
+
* is the Anthropic account uuid, which claude.ai exports of the same account
|
|
64
|
+
* carry too, so the self IS that account's Person (the way iMessage's agents
|
|
65
|
+
* are iCloud or phone identities under `imessage` messages). The email joins
|
|
66
|
+
* the address the mail and messaging sources know. A transcript that names no
|
|
67
|
+
* account gets the per-source singleton; `@me` still merges both.
|
|
68
|
+
*/
|
|
69
|
+
self(account) {
|
|
70
|
+
if (!account)
|
|
71
|
+
return selfAgent({ source: SOURCE });
|
|
72
|
+
const sameAs = account.email
|
|
73
|
+
? [
|
|
74
|
+
{
|
|
75
|
+
'@type': 'Agent',
|
|
76
|
+
'@key': BY_HANDLE,
|
|
77
|
+
source: 'email',
|
|
78
|
+
handle: account.email,
|
|
79
|
+
...(account.name !== undefined && { name: account.name }),
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
: [];
|
|
83
|
+
return selfAgent({
|
|
84
|
+
source: ACCOUNT_SOURCE,
|
|
85
|
+
sourceId: account.uuid,
|
|
86
|
+
handle: account.email,
|
|
87
|
+
name: account.name,
|
|
88
|
+
sameAs,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The model a reply ran on: a kind, not an individual — the same weights
|
|
93
|
+
* answer everyone — so it is the instrument of the reply, not its author.
|
|
94
|
+
*/
|
|
95
|
+
model(id) {
|
|
96
|
+
return {
|
|
97
|
+
'@type': 'SoftwareApplication',
|
|
98
|
+
'@key': BY_HANDLE,
|
|
99
|
+
source: ANTHROPIC,
|
|
100
|
+
handle: id,
|
|
101
|
+
name: id,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async thread(session) {
|
|
105
|
+
return {
|
|
106
|
+
'@type': 'Thread',
|
|
107
|
+
'@key': BY_SOURCE_ID,
|
|
108
|
+
source: SOURCE,
|
|
109
|
+
sourceId: session.sessionId,
|
|
110
|
+
name: session.title,
|
|
111
|
+
isPartOf: [await this.project(session.cwd)],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The working directory as a Project on this machine — the same hostname
|
|
116
|
+
* Realm the shell and Timing sources scope their paths and accounts by.
|
|
117
|
+
*/
|
|
118
|
+
async project(cwd) {
|
|
119
|
+
const realm = {
|
|
120
|
+
'@type': 'Realm',
|
|
121
|
+
'@key': BY_HANDLE,
|
|
122
|
+
source: 'hostname',
|
|
123
|
+
handle: (this.config.hostname ?? hostname()).trim().split('.')[0].toLowerCase(),
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
'@type': 'Project',
|
|
127
|
+
'@key': ['@type', 'source', 'handle', 'inRealm.handle'],
|
|
128
|
+
source: SOURCE,
|
|
129
|
+
handle: cwd,
|
|
130
|
+
// The full path, so projects that share a directory name stay apart.
|
|
131
|
+
name: cwd,
|
|
132
|
+
inRealm: realm,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chronicle.app/claude-code",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"types": "./dist/index.d.ts",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist/**/*.js",
|
|
8
|
+
"dist/**/*.d.ts"
|
|
9
|
+
],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -b tsconfig.lib.json",
|
|
12
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
13
|
+
"lint": "eslint src --ext .ts,.js",
|
|
14
|
+
"test": "node --test src/*.test.js"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [],
|
|
17
|
+
"author": "Andrew Louis",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"description": "Chronicle claude-code extraction plugin",
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@chronicle.app/tsconfig": "0.1.0",
|
|
23
|
+
"@types/node": "^22.10.1",
|
|
24
|
+
"typescript": "^5.7.2"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://chronicle.app",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/chronicle-app/chronicle.git",
|
|
30
|
+
"directory": "plugins/claude-code"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"registry": "https://registry.npmjs.org/"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@chronicle.app/etl": "0.1.0",
|
|
38
|
+
"@chronicle.app/schema": "0.1.0",
|
|
39
|
+
"zod": "^3.24.1"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=22.13.0"
|
|
43
|
+
},
|
|
44
|
+
"exports": {
|
|
45
|
+
".": {
|
|
46
|
+
"types": "./dist/index.d.ts",
|
|
47
|
+
"import": "./dist/index.js"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"chronicle": {
|
|
51
|
+
"plugin": true
|
|
52
|
+
},
|
|
53
|
+
"oclif": {}
|
|
54
|
+
}
|