@nothumanwork/nn 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 +77 -0
- package/bin/nn.js +106 -0
- package/package.json +74 -0
- package/src/config/env.ts +31 -0
- package/src/config/paths.ts +50 -0
- package/src/config/runtime.ts +37 -0
- package/src/config/sync.ts +48 -0
- package/src/db/client.ts +333 -0
- package/src/db/libsql-native.ts +66 -0
- package/src/db/lock.ts +72 -0
- package/src/db/migrate.ts +246 -0
- package/src/db/replica-migrate.ts +162 -0
- package/src/db/schema.sql +99 -0
- package/src/export/claude.ts +92 -0
- package/src/export/codex.ts +86 -0
- package/src/export/cursor.ts +68 -0
- package/src/export/generic.ts +19 -0
- package/src/export/registry.ts +118 -0
- package/src/export/types.ts +44 -0
- package/src/hooks/ingest.ts +107 -0
- package/src/hooks/resolvers/antigravity.ts +44 -0
- package/src/hooks/resolvers/claude.ts +27 -0
- package/src/hooks/resolvers/codex.ts +65 -0
- package/src/hooks/resolvers/common.ts +21 -0
- package/src/hooks/resolvers/cursor.ts +31 -0
- package/src/hooks/resolvers/grok.ts +59 -0
- package/src/hooks/resolvers/index.ts +35 -0
- package/src/hooks/resolvers/pi.ts +72 -0
- package/src/hooks/types.ts +20 -0
- package/src/index.ts +247 -0
- package/src/ingest/jsonl.ts +38 -0
- package/src/ingest/pipeline.ts +101 -0
- package/src/install/index.ts +227 -0
- package/src/install/types.ts +85 -0
- package/src/ir/event-id.ts +26 -0
- package/src/ir/types.ts +84 -0
- package/src/providers/antigravity/index.ts +175 -0
- package/src/providers/claude/index.ts +228 -0
- package/src/providers/codex/index.ts +264 -0
- package/src/providers/copilot/index.ts +24 -0
- package/src/providers/cursor/index.ts +340 -0
- package/src/providers/grok/index.ts +146 -0
- package/src/providers/pi/index.ts +197 -0
- package/src/providers/registry.ts +31 -0
- package/src/providers/types.ts +53 -0
- package/src/sync/coordinator.ts +186 -0
- package/src/sync/turso.ts +64 -0
- package/src/types/assets.d.ts +4 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, renameSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { createClient, type Client, type Value } from "@libsql/client";
|
|
5
|
+
|
|
6
|
+
import { tursoConfigured } from "../config/sync.ts";
|
|
7
|
+
|
|
8
|
+
const BACKUP_SUFFIX = ".pre-sync-backup";
|
|
9
|
+
const IMPORTED_SUFFIX = ".pre-sync-imported";
|
|
10
|
+
const RECOVERY_SUFFIX = ".conflict-recovery";
|
|
11
|
+
|
|
12
|
+
export function isReplicaConflictError(error: unknown): boolean {
|
|
13
|
+
const parts: string[] = [];
|
|
14
|
+
let current: unknown = error;
|
|
15
|
+
while (current instanceof Error) {
|
|
16
|
+
parts.push(current.message);
|
|
17
|
+
current = current.cause;
|
|
18
|
+
}
|
|
19
|
+
if (typeof error === "object" && error !== null && "code" in error) {
|
|
20
|
+
parts.push(String((error as { code?: unknown }).code));
|
|
21
|
+
}
|
|
22
|
+
const message = parts.join(" ");
|
|
23
|
+
return (
|
|
24
|
+
message.includes("WalConflict") ||
|
|
25
|
+
message.includes("SQLITE_CORRUPT") ||
|
|
26
|
+
message.includes("database disk image is malformed")
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function recoverEmbeddedReplica(dbPath: string): string {
|
|
31
|
+
const backupDir = `${dbPath}${RECOVERY_SUFFIX}-${Date.now()}`;
|
|
32
|
+
mkdirSync(backupDir, { recursive: true });
|
|
33
|
+
|
|
34
|
+
for (const suffix of ["", "-wal", "-shm", "-info"]) {
|
|
35
|
+
const source = `${dbPath}${suffix}`;
|
|
36
|
+
if (!existsSync(source)) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
renameSync(source, join(backupDir, `${basename(dbPath)}${suffix}`));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.error(
|
|
43
|
+
`[nn-db] reset embedded replica after conflict; backup at ${backupDir}`,
|
|
44
|
+
);
|
|
45
|
+
return backupDir;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function replicaInfoPath(dbPath: string): string {
|
|
49
|
+
return `${dbPath}-info`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function localBackupDir(dbPath: string): string {
|
|
53
|
+
return `${dbPath}${BACKUP_SUFFIX}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function importedBackupDir(dbPath: string): string {
|
|
57
|
+
return `${dbPath}${IMPORTED_SUFFIX}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function isLocalOnlyReplica(dbPath: string): boolean {
|
|
61
|
+
return existsSync(dbPath) && !existsSync(replicaInfoPath(dbPath));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function findPendingBackup(dbPath: string): string | null {
|
|
65
|
+
const pending = localBackupDir(dbPath);
|
|
66
|
+
if (existsSync(join(pending, basename(dbPath)))) {
|
|
67
|
+
return pending;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function prepareForEmbeddedReplica(dbPath: string): string | null {
|
|
73
|
+
if (!tursoConfigured() || !isLocalOnlyReplica(dbPath)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const backupDir = localBackupDir(dbPath);
|
|
78
|
+
mkdirSync(backupDir, { recursive: true });
|
|
79
|
+
|
|
80
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
81
|
+
const source = `${dbPath}${suffix}`;
|
|
82
|
+
if (!existsSync(source)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
renameSync(source, join(backupDir, `${basename(dbPath)}${suffix}`));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.error(
|
|
89
|
+
`[nn-db] migrated local-only database to embedded replica; backup at ${backupDir}`,
|
|
90
|
+
);
|
|
91
|
+
return backupDir;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const IMPORT_TABLES = [
|
|
95
|
+
"projects",
|
|
96
|
+
"sessions",
|
|
97
|
+
"events",
|
|
98
|
+
"source_cursors",
|
|
99
|
+
"exports",
|
|
100
|
+
"schema_migrations",
|
|
101
|
+
] as const;
|
|
102
|
+
|
|
103
|
+
export async function importPendingLocalBackup(
|
|
104
|
+
client: Client,
|
|
105
|
+
dbPath: string,
|
|
106
|
+
): Promise<{ imported: boolean; backupPath: string | null }> {
|
|
107
|
+
const backupDir = findPendingBackup(dbPath);
|
|
108
|
+
if (!backupDir) {
|
|
109
|
+
return { imported: false, backupPath: null };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const backupDb = join(backupDir, basename(dbPath));
|
|
113
|
+
const backupClient = createClient({
|
|
114
|
+
url: backupDb.startsWith("file:") ? backupDb : `file:${backupDb}`,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
for (const table of IMPORT_TABLES) {
|
|
119
|
+
const result = await backupClient.execute(`SELECT * FROM ${table}`);
|
|
120
|
+
if (result.rows.length === 0) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const columns = result.columns;
|
|
125
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
126
|
+
const sql = `INSERT OR IGNORE INTO ${table} (${columns.join(", ")}) VALUES (${placeholders})`;
|
|
127
|
+
|
|
128
|
+
await client.execute("BEGIN");
|
|
129
|
+
try {
|
|
130
|
+
for (const row of result.rows) {
|
|
131
|
+
await client.execute({
|
|
132
|
+
sql,
|
|
133
|
+
args: columns.map((column) => {
|
|
134
|
+
const value = row[column as keyof typeof row];
|
|
135
|
+
return (value ?? null) as Value;
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
await client.execute("COMMIT");
|
|
140
|
+
} catch (error) {
|
|
141
|
+
await client.execute("ROLLBACK");
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await backupClient.close();
|
|
147
|
+
renameSync(backupDir, importedBackupDir(dbPath));
|
|
148
|
+
console.error(
|
|
149
|
+
`[nn-db] imported local backup into embedded replica from ${backupDir}`,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
if (tursoConfigured()) {
|
|
153
|
+
await client.sync();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { imported: true, backupPath: importedBackupDir(dbPath) };
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error("[nn-db] failed to import local backup", error);
|
|
159
|
+
await backupClient.close();
|
|
160
|
+
return { imported: false, backupPath: backupDir };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
2
|
+
version INTEGER PRIMARY KEY,
|
|
3
|
+
name TEXT NOT NULL,
|
|
4
|
+
checksum TEXT NOT NULL,
|
|
5
|
+
applied_at INTEGER NOT NULL
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
canonical_path TEXT NOT NULL UNIQUE,
|
|
11
|
+
display_name TEXT NOT NULL,
|
|
12
|
+
created_at INTEGER NOT NULL,
|
|
13
|
+
updated_at INTEGER NOT NULL
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
project_id TEXT NOT NULL REFERENCES projects(id),
|
|
19
|
+
source TEXT NOT NULL,
|
|
20
|
+
source_session_id TEXT NOT NULL,
|
|
21
|
+
workspace_root TEXT,
|
|
22
|
+
source_path TEXT,
|
|
23
|
+
title TEXT,
|
|
24
|
+
model TEXT,
|
|
25
|
+
started_at TEXT,
|
|
26
|
+
updated_at TEXT,
|
|
27
|
+
created_at INTEGER NOT NULL,
|
|
28
|
+
modified_at INTEGER NOT NULL,
|
|
29
|
+
UNIQUE(project_id, source, source_session_id)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
35
|
+
seq INTEGER NOT NULL,
|
|
36
|
+
boundary TEXT NOT NULL,
|
|
37
|
+
role TEXT NOT NULL,
|
|
38
|
+
timestamp TEXT NOT NULL,
|
|
39
|
+
content_text TEXT NOT NULL DEFAULT '',
|
|
40
|
+
tool_name TEXT,
|
|
41
|
+
tool_call_id TEXT,
|
|
42
|
+
tool_input_json TEXT,
|
|
43
|
+
tool_output_json TEXT,
|
|
44
|
+
raw_json TEXT NOT NULL,
|
|
45
|
+
parser_version TEXT NOT NULL,
|
|
46
|
+
raw_local_ref TEXT NOT NULL,
|
|
47
|
+
line_key TEXT NOT NULL,
|
|
48
|
+
content_hash TEXT NOT NULL,
|
|
49
|
+
created_at INTEGER NOT NULL
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_events_session_seq ON events(session_id, seq);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_events_line_key ON events(line_key);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source, source_session_id);
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS source_cursors (
|
|
57
|
+
source TEXT NOT NULL,
|
|
58
|
+
cursor_key TEXT NOT NULL,
|
|
59
|
+
last_byte_offset INTEGER NOT NULL DEFAULT 0,
|
|
60
|
+
last_line_uuid TEXT,
|
|
61
|
+
parser_version TEXT NOT NULL,
|
|
62
|
+
cursor_value TEXT,
|
|
63
|
+
updated_at INTEGER NOT NULL,
|
|
64
|
+
PRIMARY KEY (source, cursor_key)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS exports (
|
|
68
|
+
id TEXT PRIMARY KEY,
|
|
69
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
70
|
+
target_provider TEXT NOT NULL,
|
|
71
|
+
output_path TEXT NOT NULL,
|
|
72
|
+
exported_at INTEGER NOT NULL,
|
|
73
|
+
lossy INTEGER NOT NULL DEFAULT 0,
|
|
74
|
+
warnings_json TEXT NOT NULL DEFAULT '[]'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
|
|
78
|
+
content_text,
|
|
79
|
+
tool_name,
|
|
80
|
+
content='events',
|
|
81
|
+
content_rowid='rowid'
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TRIGGER IF NOT EXISTS events_ai AFTER INSERT ON events BEGIN
|
|
85
|
+
INSERT INTO events_fts(rowid, content_text, tool_name)
|
|
86
|
+
VALUES (new.rowid, new.content_text, COALESCE(new.tool_name, ''));
|
|
87
|
+
END;
|
|
88
|
+
|
|
89
|
+
CREATE TRIGGER IF NOT EXISTS events_ad AFTER DELETE ON events BEGIN
|
|
90
|
+
INSERT INTO events_fts(events_fts, rowid, content_text, tool_name)
|
|
91
|
+
VALUES ('delete', old.rowid, old.content_text, COALESCE(old.tool_name, ''));
|
|
92
|
+
END;
|
|
93
|
+
|
|
94
|
+
CREATE TRIGGER IF NOT EXISTS events_au AFTER UPDATE ON events BEGIN
|
|
95
|
+
INSERT INTO events_fts(events_fts, rowid, content_text, tool_name)
|
|
96
|
+
VALUES ('delete', old.rowid, old.content_text, COALESCE(old.tool_name, ''));
|
|
97
|
+
INSERT INTO events_fts(rowid, content_text, tool_name)
|
|
98
|
+
VALUES (new.rowid, new.content_text, COALESCE(new.tool_name, ''));
|
|
99
|
+
END;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { ExportContext, ExportResult, HarnessExporter } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export const claudeExporter: HarnessExporter = {
|
|
6
|
+
target: "claude",
|
|
7
|
+
export(context: ExportContext): ExportResult {
|
|
8
|
+
const warnings = [];
|
|
9
|
+
const lines: string[] = [];
|
|
10
|
+
|
|
11
|
+
for (const event of context.events) {
|
|
12
|
+
if (event.boundary === "tool_result") {
|
|
13
|
+
lines.push(
|
|
14
|
+
JSON.stringify({
|
|
15
|
+
type: "assistant",
|
|
16
|
+
sessionId: context.sourceSessionId,
|
|
17
|
+
uuid: randomUUID(),
|
|
18
|
+
timestamp: event.timestamp,
|
|
19
|
+
message: {
|
|
20
|
+
role: "assistant",
|
|
21
|
+
content: [
|
|
22
|
+
{
|
|
23
|
+
type: "tool_result",
|
|
24
|
+
tool_use_id: event.toolCallId,
|
|
25
|
+
content:
|
|
26
|
+
event.toolOutputJson ?
|
|
27
|
+
JSON.parse(event.toolOutputJson)
|
|
28
|
+
: event.contentText,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (event.boundary === "tool_call") {
|
|
38
|
+
lines.push(
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
type: "assistant",
|
|
41
|
+
sessionId: context.sourceSessionId,
|
|
42
|
+
uuid: randomUUID(),
|
|
43
|
+
timestamp: event.timestamp,
|
|
44
|
+
message: {
|
|
45
|
+
role: "assistant",
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "tool_use",
|
|
49
|
+
id: event.toolCallId ?? randomUUID(),
|
|
50
|
+
name: event.toolName ?? "tool",
|
|
51
|
+
input:
|
|
52
|
+
event.toolInputJson ? JSON.parse(event.toolInputJson) : {},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!event.contentText.trim()) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const type =
|
|
66
|
+
event.role === "user" ? "user"
|
|
67
|
+
: event.role === "assistant" ? "assistant"
|
|
68
|
+
: "system";
|
|
69
|
+
lines.push(
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
type,
|
|
72
|
+
sessionId: context.sourceSessionId,
|
|
73
|
+
uuid: randomUUID(),
|
|
74
|
+
timestamp: event.timestamp,
|
|
75
|
+
message: {
|
|
76
|
+
role: type,
|
|
77
|
+
content: event.contentText,
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (context.source !== "claude") {
|
|
84
|
+
warnings.push({
|
|
85
|
+
code: "cross_export",
|
|
86
|
+
message: `Exporting ${context.source} session to Claude-compatible JSONL may be lossy`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { lines, lossy: warnings.length > 0, warnings };
|
|
91
|
+
},
|
|
92
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ExportContext, ExportResult, HarnessExporter } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const codexExporter: HarnessExporter = {
|
|
4
|
+
target: "codex",
|
|
5
|
+
export(context: ExportContext): ExportResult {
|
|
6
|
+
const warnings = [];
|
|
7
|
+
const lines: string[] = [
|
|
8
|
+
JSON.stringify({
|
|
9
|
+
type: "session_meta",
|
|
10
|
+
timestamp: context.events[0]?.timestamp ?? new Date().toISOString(),
|
|
11
|
+
payload: {
|
|
12
|
+
id: context.sourceSessionId,
|
|
13
|
+
cwd: process.cwd(),
|
|
14
|
+
model_provider: "openai",
|
|
15
|
+
model: "gpt-5.4",
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
for (const event of context.events) {
|
|
21
|
+
if (event.boundary === "user_turn") {
|
|
22
|
+
lines.push(
|
|
23
|
+
JSON.stringify({
|
|
24
|
+
type: "event_msg",
|
|
25
|
+
timestamp: event.timestamp,
|
|
26
|
+
payload: { type: "user_message", message: event.contentText },
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (event.boundary === "tool_call") {
|
|
32
|
+
lines.push(
|
|
33
|
+
JSON.stringify({
|
|
34
|
+
type: "response_item",
|
|
35
|
+
timestamp: event.timestamp,
|
|
36
|
+
payload: {
|
|
37
|
+
type: "function_call",
|
|
38
|
+
name: event.toolName ?? "tool",
|
|
39
|
+
arguments: event.toolInputJson ?? "{}",
|
|
40
|
+
call_id: event.toolCallId ?? `call_${event.seq}`,
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (event.boundary === "tool_result") {
|
|
47
|
+
lines.push(
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
type: "response_item",
|
|
50
|
+
timestamp: event.timestamp,
|
|
51
|
+
payload: {
|
|
52
|
+
type: "function_call_output",
|
|
53
|
+
call_id: event.toolCallId ?? `call_${event.seq}`,
|
|
54
|
+
output:
|
|
55
|
+
event.toolOutputJson ?
|
|
56
|
+
JSON.parse(event.toolOutputJson)
|
|
57
|
+
: event.contentText,
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (event.boundary === "assistant_turn" && event.contentText.trim()) {
|
|
64
|
+
lines.push(
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
type: "response_item",
|
|
67
|
+
timestamp: event.timestamp,
|
|
68
|
+
payload: {
|
|
69
|
+
role: "assistant",
|
|
70
|
+
content: [{ type: "output_text", text: event.contentText }],
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (context.source !== "codex") {
|
|
78
|
+
warnings.push({
|
|
79
|
+
code: "cross_export",
|
|
80
|
+
message: `Exporting ${context.source} session to Codex rollout JSONL may be lossy`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { lines, lossy: warnings.length > 0, warnings };
|
|
85
|
+
},
|
|
86
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { ExportContext, ExportResult, HarnessExporter } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const cursorExporter: HarnessExporter = {
|
|
4
|
+
target: "cursor",
|
|
5
|
+
export(context: ExportContext): ExportResult {
|
|
6
|
+
const warnings = [];
|
|
7
|
+
const lines: string[] = [];
|
|
8
|
+
let current: {
|
|
9
|
+
role: string;
|
|
10
|
+
content: Array<Record<string, unknown>>;
|
|
11
|
+
} | null = null;
|
|
12
|
+
|
|
13
|
+
const flush = (): void => {
|
|
14
|
+
if (current && current.content.length > 0) {
|
|
15
|
+
lines.push(
|
|
16
|
+
JSON.stringify({
|
|
17
|
+
role: current.role,
|
|
18
|
+
message: { content: current.content },
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
current = null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
for (const event of context.events) {
|
|
26
|
+
if (event.boundary === "tool_result") {
|
|
27
|
+
warnings.push({
|
|
28
|
+
code: "tool_result_omitted",
|
|
29
|
+
message: "Cursor export omits explicit tool_result rows",
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (event.boundary === "compaction") {
|
|
34
|
+
warnings.push({
|
|
35
|
+
code: "thinking_stripped",
|
|
36
|
+
message: "Compaction/thinking rows omitted",
|
|
37
|
+
});
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const role = event.role === "tool" ? "assistant" : event.role;
|
|
42
|
+
if (!current || current.role !== role) {
|
|
43
|
+
flush();
|
|
44
|
+
current = { role, content: [] };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (event.boundary === "tool_call") {
|
|
48
|
+
current.content.push({
|
|
49
|
+
type: "tool_use",
|
|
50
|
+
name: event.toolName ?? "tool",
|
|
51
|
+
input: event.toolInputJson ? JSON.parse(event.toolInputJson) : {},
|
|
52
|
+
});
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (event.contentText.trim()) {
|
|
57
|
+
current.content.push({ type: "text", text: event.contentText });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
flush();
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
lines,
|
|
64
|
+
lossy: warnings.length > 0 || context.source !== "cursor",
|
|
65
|
+
warnings,
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ExportContext, ExportResult, HarnessExporter } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const genericExporter: HarnessExporter = {
|
|
4
|
+
target: "generic",
|
|
5
|
+
export(context: ExportContext): ExportResult {
|
|
6
|
+
const lines = context.events.map((event) =>
|
|
7
|
+
JSON.stringify({
|
|
8
|
+
boundary: event.boundary,
|
|
9
|
+
role: event.role,
|
|
10
|
+
timestamp: event.timestamp,
|
|
11
|
+
contentText: event.contentText,
|
|
12
|
+
toolName: event.toolName,
|
|
13
|
+
toolCallId: event.toolCallId,
|
|
14
|
+
raw: JSON.parse(event.rawJson),
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
return { lines, lossy: false, warnings: [] };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { recordExport } from "../db/client.ts";
|
|
5
|
+
import { getSessionEvents } from "../db/client.ts";
|
|
6
|
+
import { findSessionBySourceId } from "../db/client.ts";
|
|
7
|
+
import type { ProviderId } from "../ir/types.ts";
|
|
8
|
+
import { claudeExporter } from "./claude.ts";
|
|
9
|
+
import { codexExporter } from "./codex.ts";
|
|
10
|
+
import { cursorExporter } from "./cursor.ts";
|
|
11
|
+
import { genericExporter } from "./generic.ts";
|
|
12
|
+
import type { ExportManifest, ExportTarget, HarnessExporter } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
const exporters: Record<ExportTarget, HarnessExporter> = {
|
|
15
|
+
generic: genericExporter,
|
|
16
|
+
cursor: cursorExporter,
|
|
17
|
+
claude: claudeExporter,
|
|
18
|
+
codex: codexExporter,
|
|
19
|
+
copilot: genericExporter,
|
|
20
|
+
antigravity: genericExporter,
|
|
21
|
+
pi: genericExporter,
|
|
22
|
+
grok: genericExporter,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function getExporter(target: ExportTarget): HarnessExporter {
|
|
26
|
+
const exporter = exporters[target];
|
|
27
|
+
if (!exporter) {
|
|
28
|
+
throw new Error(`No exporter for target: ${target}`);
|
|
29
|
+
}
|
|
30
|
+
return exporter;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function manifestPathFor(outputPath: string): string {
|
|
34
|
+
if (outputPath.toLowerCase().endsWith(".jsonl")) {
|
|
35
|
+
return `${outputPath.slice(0, -".jsonl".length)}.manifest.json`;
|
|
36
|
+
}
|
|
37
|
+
return `${outputPath}.manifest.json`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function exportSession(
|
|
41
|
+
sessionId: string,
|
|
42
|
+
target?: ExportTarget,
|
|
43
|
+
outputPath?: string,
|
|
44
|
+
): Promise<{
|
|
45
|
+
content: string;
|
|
46
|
+
outputPath: string | null;
|
|
47
|
+
manifestPath: string | null;
|
|
48
|
+
manifest: ExportManifest;
|
|
49
|
+
}> {
|
|
50
|
+
const events = await getSessionEvents(sessionId);
|
|
51
|
+
if (events.length === 0) {
|
|
52
|
+
throw new Error(`No events found for session ${sessionId}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const resolvedTarget = target ?? events[0]!.source;
|
|
56
|
+
const exporter = getExporter(resolvedTarget);
|
|
57
|
+
const result = exporter.export({
|
|
58
|
+
sessionId,
|
|
59
|
+
source: events[0]!.source,
|
|
60
|
+
sourceSessionId: events[0]!.sourceSessionId,
|
|
61
|
+
events,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const content = result.lines.length > 0 ? `${result.lines.join("\n")}\n` : "";
|
|
65
|
+
|
|
66
|
+
const manifest: ExportManifest = {
|
|
67
|
+
source: events[0]!.source,
|
|
68
|
+
target: resolvedTarget,
|
|
69
|
+
sessionId,
|
|
70
|
+
sourceSessionId: events[0]!.sourceSessionId,
|
|
71
|
+
lossy: result.lossy,
|
|
72
|
+
warnings: result.warnings.map((warning) => warning.message),
|
|
73
|
+
exportedAt: new Date().toISOString(),
|
|
74
|
+
lineCount: result.lines.length,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (!outputPath) {
|
|
78
|
+
await recordExport(
|
|
79
|
+
sessionId,
|
|
80
|
+
resolvedTarget,
|
|
81
|
+
"-",
|
|
82
|
+
result.lossy,
|
|
83
|
+
manifest.warnings,
|
|
84
|
+
);
|
|
85
|
+
return { content, outputPath: null, manifestPath: null, manifest };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
89
|
+
writeFileSync(outputPath, content, "utf-8");
|
|
90
|
+
|
|
91
|
+
const manifestPath = manifestPathFor(outputPath);
|
|
92
|
+
writeFileSync(
|
|
93
|
+
manifestPath,
|
|
94
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
95
|
+
"utf-8",
|
|
96
|
+
);
|
|
97
|
+
await recordExport(
|
|
98
|
+
sessionId,
|
|
99
|
+
resolvedTarget,
|
|
100
|
+
outputPath,
|
|
101
|
+
result.lossy,
|
|
102
|
+
manifest.warnings,
|
|
103
|
+
);
|
|
104
|
+
return { content, outputPath, manifestPath, manifest };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function resolveSessionId(
|
|
108
|
+
source: ProviderId,
|
|
109
|
+
sourceSessionId: string,
|
|
110
|
+
): Promise<string> {
|
|
111
|
+
const id = await findSessionBySourceId(source, sourceSessionId);
|
|
112
|
+
if (!id) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Session not found in database: ${source}/${sourceSessionId}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return id;
|
|
118
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ExportWarning } from "../ir/types.ts";
|
|
2
|
+
import type { ProviderId } from "../ir/types.ts";
|
|
3
|
+
|
|
4
|
+
export type ExportTarget = ProviderId | "generic";
|
|
5
|
+
|
|
6
|
+
export interface ExportContext {
|
|
7
|
+
sessionId: string;
|
|
8
|
+
source: ProviderId;
|
|
9
|
+
sourceSessionId: string;
|
|
10
|
+
events: Array<{
|
|
11
|
+
seq: number;
|
|
12
|
+
boundary: string;
|
|
13
|
+
role: string;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
contentText: string;
|
|
16
|
+
toolName: string | null;
|
|
17
|
+
toolCallId: string | null;
|
|
18
|
+
toolInputJson: string | null;
|
|
19
|
+
toolOutputJson: string | null;
|
|
20
|
+
rawJson: string;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExportResult {
|
|
25
|
+
lines: string[];
|
|
26
|
+
lossy: boolean;
|
|
27
|
+
warnings: ExportWarning[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HarnessExporter {
|
|
31
|
+
target: ExportTarget;
|
|
32
|
+
export(context: ExportContext): ExportResult;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ExportManifest {
|
|
36
|
+
source: ProviderId;
|
|
37
|
+
target: ExportTarget;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
sourceSessionId: string;
|
|
40
|
+
lossy: boolean;
|
|
41
|
+
warnings: string[];
|
|
42
|
+
exportedAt: string;
|
|
43
|
+
lineCount: number;
|
|
44
|
+
}
|