@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 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
+ }
@@ -0,0 +1,3 @@
1
+ export { ClaudeCodeExtractor } from './connectors/ClaudeCodeExtractor.js';
2
+ export { default as ClaudeCodeTransformer } from './connectors/ClaudeCodeTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { ClaudeCodeExtractor } from './connectors/ClaudeCodeExtractor.js';
2
+ export { default as ClaudeCodeTransformer } from './connectors/ClaudeCodeTransformer.js';
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
+ }