ccrecall 0.0.4
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 +265 -0
- package/package.json +43 -0
- package/src/cli.ts +113 -0
- package/src/db.ts +450 -0
- package/src/index.ts +6 -0
- package/src/parser.ts +208 -0
- package/src/sync-teams.ts +172 -0
- package/src/sync.ts +191 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { Database } from './db.ts';
|
|
4
|
+
|
|
5
|
+
// Team directories to scan
|
|
6
|
+
// Primary: standard Claude Code location
|
|
7
|
+
// Sneakpeek: temporary parallel build - can be removed when merged upstream
|
|
8
|
+
const TEAMS_DIRS = [
|
|
9
|
+
join(Bun.env.HOME!, '.claude', 'teams'),
|
|
10
|
+
join(
|
|
11
|
+
Bun.env.HOME!,
|
|
12
|
+
'.claude-sneakpeek',
|
|
13
|
+
'claudesp',
|
|
14
|
+
'config',
|
|
15
|
+
'teams',
|
|
16
|
+
),
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const TASKS_DIRS = [
|
|
20
|
+
join(Bun.env.HOME!, '.claude', 'tasks'),
|
|
21
|
+
join(
|
|
22
|
+
Bun.env.HOME!,
|
|
23
|
+
'.claude-sneakpeek',
|
|
24
|
+
'claudesp',
|
|
25
|
+
'config',
|
|
26
|
+
'tasks',
|
|
27
|
+
),
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
interface TeamConfig {
|
|
31
|
+
name: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
createdAt: number;
|
|
34
|
+
leadAgentId?: string;
|
|
35
|
+
leadSessionId?: string;
|
|
36
|
+
members?: Array<{
|
|
37
|
+
agentId: string;
|
|
38
|
+
name: string;
|
|
39
|
+
agentType?: string;
|
|
40
|
+
model?: string;
|
|
41
|
+
prompt?: string;
|
|
42
|
+
color?: string;
|
|
43
|
+
cwd?: string;
|
|
44
|
+
joinedAt?: number;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TaskFile {
|
|
49
|
+
id: string;
|
|
50
|
+
subject: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
status?: string;
|
|
53
|
+
owner?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TeamSyncResult {
|
|
57
|
+
teams_synced: number;
|
|
58
|
+
members_synced: number;
|
|
59
|
+
tasks_synced: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function sync_teams(
|
|
63
|
+
db: Database,
|
|
64
|
+
verbose = false,
|
|
65
|
+
): Promise<TeamSyncResult> {
|
|
66
|
+
const result: TeamSyncResult = {
|
|
67
|
+
teams_synced: 0,
|
|
68
|
+
members_synced: 0,
|
|
69
|
+
tasks_synced: 0,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Sync teams and members
|
|
73
|
+
for (const teams_dir of TEAMS_DIRS) {
|
|
74
|
+
if (!existsSync(teams_dir)) continue;
|
|
75
|
+
|
|
76
|
+
const team_dirs = readdirSync(teams_dir, { withFileTypes: true })
|
|
77
|
+
.filter((d) => d.isDirectory())
|
|
78
|
+
.map((d) => d.name);
|
|
79
|
+
|
|
80
|
+
for (const team_dir of team_dirs) {
|
|
81
|
+
const config_path = join(teams_dir, team_dir, 'config.json');
|
|
82
|
+
if (!existsSync(config_path)) continue;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const config: TeamConfig = JSON.parse(
|
|
86
|
+
readFileSync(config_path, 'utf-8'),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (verbose) {
|
|
90
|
+
console.log(` Team: ${config.name}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Use directory name as ID (handles both named and UUID dirs)
|
|
94
|
+
const team_id = team_dir;
|
|
95
|
+
|
|
96
|
+
db.upsert_team({
|
|
97
|
+
id: team_id,
|
|
98
|
+
name: config.name,
|
|
99
|
+
description: config.description,
|
|
100
|
+
lead_session_id: config.leadSessionId,
|
|
101
|
+
created_at: config.createdAt,
|
|
102
|
+
});
|
|
103
|
+
result.teams_synced++;
|
|
104
|
+
|
|
105
|
+
// Sync members
|
|
106
|
+
if (config.members) {
|
|
107
|
+
for (const member of config.members) {
|
|
108
|
+
db.upsert_team_member({
|
|
109
|
+
id: member.agentId,
|
|
110
|
+
team_id: team_id,
|
|
111
|
+
name: member.name,
|
|
112
|
+
agent_type: member.agentType,
|
|
113
|
+
model: member.model,
|
|
114
|
+
prompt: member.prompt,
|
|
115
|
+
color: member.color,
|
|
116
|
+
cwd: member.cwd,
|
|
117
|
+
joined_at: member.joinedAt ?? config.createdAt,
|
|
118
|
+
});
|
|
119
|
+
result.members_synced++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (verbose) {
|
|
124
|
+
console.error(` Error parsing ${config_path}: ${err}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Sync tasks
|
|
131
|
+
for (const tasks_dir of TASKS_DIRS) {
|
|
132
|
+
if (!existsSync(tasks_dir)) continue;
|
|
133
|
+
|
|
134
|
+
const task_team_dirs = readdirSync(tasks_dir, {
|
|
135
|
+
withFileTypes: true,
|
|
136
|
+
})
|
|
137
|
+
.filter((d) => d.isDirectory())
|
|
138
|
+
.map((d) => d.name);
|
|
139
|
+
|
|
140
|
+
for (const team_dir of task_team_dirs) {
|
|
141
|
+
const team_tasks_dir = join(tasks_dir, team_dir);
|
|
142
|
+
const task_files = readdirSync(team_tasks_dir).filter(
|
|
143
|
+
(f) => f.endsWith('.json') && f !== '.lock',
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
for (const task_file of task_files) {
|
|
147
|
+
const task_path = join(team_tasks_dir, task_file);
|
|
148
|
+
try {
|
|
149
|
+
const task: TaskFile = JSON.parse(
|
|
150
|
+
readFileSync(task_path, 'utf-8'),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
db.upsert_team_task({
|
|
154
|
+
id: `${team_dir}:${task.id}`,
|
|
155
|
+
team_id: team_dir,
|
|
156
|
+
owner_name: task.owner,
|
|
157
|
+
subject: task.subject,
|
|
158
|
+
description: task.description,
|
|
159
|
+
status: task.status,
|
|
160
|
+
});
|
|
161
|
+
result.tasks_synced++;
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (verbose) {
|
|
164
|
+
console.error(` Error parsing ${task_path}: ${err}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
}
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { join, relative } from 'path';
|
|
3
|
+
import { Database } from './db.ts';
|
|
4
|
+
import { parse_file } from './parser.ts';
|
|
5
|
+
|
|
6
|
+
// Session directories to scan for transcripts
|
|
7
|
+
// Primary: standard Claude Code location
|
|
8
|
+
// Sneakpeek: temporary parallel build with unlocked features - can be removed when merged upstream
|
|
9
|
+
const PROJECTS_DIRS = [
|
|
10
|
+
join(Bun.env.HOME!, '.claude', 'projects'),
|
|
11
|
+
join(
|
|
12
|
+
Bun.env.HOME!,
|
|
13
|
+
'.claude-sneakpeek',
|
|
14
|
+
'claudesp',
|
|
15
|
+
'config',
|
|
16
|
+
'projects',
|
|
17
|
+
), // TEMPORARY: github.com/mikekelly/claude-sneakpeek
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export interface SyncResult {
|
|
21
|
+
files_scanned: number;
|
|
22
|
+
files_processed: number;
|
|
23
|
+
messages_added: number;
|
|
24
|
+
sessions_added: number;
|
|
25
|
+
tool_calls_added: number;
|
|
26
|
+
tool_results_added: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function sync(
|
|
30
|
+
db: Database,
|
|
31
|
+
verbose = false,
|
|
32
|
+
): Promise<SyncResult> {
|
|
33
|
+
const result: SyncResult = {
|
|
34
|
+
files_scanned: 0,
|
|
35
|
+
files_processed: 0,
|
|
36
|
+
messages_added: 0,
|
|
37
|
+
sessions_added: 0,
|
|
38
|
+
tool_calls_added: 0,
|
|
39
|
+
tool_results_added: 0,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Auto-migration: if messages exist but no tool_calls, reset sync state
|
|
43
|
+
const stats = db.get_stats();
|
|
44
|
+
if (stats.messages > 0 && stats.tool_calls === 0) {
|
|
45
|
+
console.log(
|
|
46
|
+
'Migrating: resetting sync state to populate tool_calls...',
|
|
47
|
+
);
|
|
48
|
+
db.reset_sync_state();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const glob = new Bun.Glob('**/*.jsonl');
|
|
52
|
+
const files: string[] = [];
|
|
53
|
+
for (const projects_dir of PROJECTS_DIRS) {
|
|
54
|
+
if (!existsSync(projects_dir)) continue;
|
|
55
|
+
for await (const file of glob.scan({
|
|
56
|
+
cwd: projects_dir,
|
|
57
|
+
absolute: true,
|
|
58
|
+
})) {
|
|
59
|
+
files.push(file);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
result.files_scanned = files.length;
|
|
64
|
+
console.log(`Found ${files.length} transcript files`);
|
|
65
|
+
|
|
66
|
+
const seen_sessions = new Set<string>();
|
|
67
|
+
let file_idx = 0;
|
|
68
|
+
|
|
69
|
+
db.disable_foreign_keys();
|
|
70
|
+
db.begin();
|
|
71
|
+
|
|
72
|
+
for (const file_path of files) {
|
|
73
|
+
file_idx++;
|
|
74
|
+
if (file_idx % 100 === 0) {
|
|
75
|
+
process.stdout.write(
|
|
76
|
+
`\r Progress: ${file_idx}/${files.length}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const file = Bun.file(file_path);
|
|
80
|
+
const last_modified = file.lastModified;
|
|
81
|
+
|
|
82
|
+
const sync_state = db.get_sync_state(file_path);
|
|
83
|
+
|
|
84
|
+
// Skip if file hasn't changed
|
|
85
|
+
if (sync_state && sync_state.last_modified >= last_modified) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const start_offset = sync_state?.last_byte_offset ?? 0;
|
|
90
|
+
const project_path = extract_project_path(file_path);
|
|
91
|
+
|
|
92
|
+
if (verbose) {
|
|
93
|
+
console.log(`Processing: ${file_path}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let last_byte_offset = start_offset;
|
|
97
|
+
let file_messages_added = 0;
|
|
98
|
+
|
|
99
|
+
for await (const { message, byte_offset } of parse_file(
|
|
100
|
+
file_path,
|
|
101
|
+
start_offset,
|
|
102
|
+
)) {
|
|
103
|
+
last_byte_offset = byte_offset;
|
|
104
|
+
|
|
105
|
+
// Ensure session exists
|
|
106
|
+
if (!seen_sessions.has(message.session_id)) {
|
|
107
|
+
db.upsert_session({
|
|
108
|
+
id: message.session_id,
|
|
109
|
+
project_path: project_path,
|
|
110
|
+
git_branch: message.git_branch,
|
|
111
|
+
cwd: message.cwd,
|
|
112
|
+
timestamp: message.timestamp,
|
|
113
|
+
summary: message.summary,
|
|
114
|
+
});
|
|
115
|
+
seen_sessions.add(message.session_id);
|
|
116
|
+
result.sessions_added++;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Update session with summary if this is a summary message
|
|
120
|
+
if (message.type === 'summary' && message.summary) {
|
|
121
|
+
db.upsert_session({
|
|
122
|
+
id: message.session_id,
|
|
123
|
+
project_path: project_path,
|
|
124
|
+
timestamp: message.timestamp,
|
|
125
|
+
summary: message.summary,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
db.insert_message(message);
|
|
130
|
+
file_messages_added++;
|
|
131
|
+
|
|
132
|
+
// Insert tool calls
|
|
133
|
+
for (const tool_call of message.tool_calls) {
|
|
134
|
+
db.insert_tool_call({
|
|
135
|
+
id: tool_call.id,
|
|
136
|
+
message_uuid: message.uuid,
|
|
137
|
+
session_id: message.session_id,
|
|
138
|
+
tool_name: tool_call.tool_name,
|
|
139
|
+
tool_input: tool_call.tool_input,
|
|
140
|
+
timestamp: message.timestamp,
|
|
141
|
+
});
|
|
142
|
+
result.tool_calls_added++;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Insert tool results
|
|
146
|
+
for (const tool_result of message.tool_results) {
|
|
147
|
+
db.insert_tool_result({
|
|
148
|
+
tool_call_id: tool_result.tool_call_id,
|
|
149
|
+
message_uuid: message.uuid,
|
|
150
|
+
session_id: message.session_id,
|
|
151
|
+
content: tool_result.content,
|
|
152
|
+
is_error: tool_result.is_error,
|
|
153
|
+
timestamp: message.timestamp,
|
|
154
|
+
});
|
|
155
|
+
result.tool_results_added++;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (file_messages_added > 0) {
|
|
160
|
+
result.files_processed++;
|
|
161
|
+
result.messages_added += file_messages_added;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
db.set_sync_state(file_path, last_modified, last_byte_offset);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
db.commit();
|
|
168
|
+
db.enable_foreign_keys();
|
|
169
|
+
|
|
170
|
+
if (files.length >= 100) {
|
|
171
|
+
console.log(); // newline after progress
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function extract_project_path(file_path: string): string {
|
|
178
|
+
// Find which base dir this file belongs to
|
|
179
|
+
for (const base of PROJECTS_DIRS) {
|
|
180
|
+
if (file_path.startsWith(base)) {
|
|
181
|
+
const rel = relative(base, file_path);
|
|
182
|
+
const project_dir = rel.split('/')[0];
|
|
183
|
+
if (project_dir.startsWith('-')) {
|
|
184
|
+
return project_dir.slice(1).replace(/-/g, '/');
|
|
185
|
+
}
|
|
186
|
+
return project_dir;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Fallback: use filename
|
|
190
|
+
return file_path.split('/').slice(-2, -1)[0] ?? 'unknown';
|
|
191
|
+
}
|