dexto 2.0.0-beta.0 → 2.0.0-beta.1
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/dist/agents/coding-agent/coding-agent.yml +0 -2
- package/dist/cli/agent-audit.d.ts +12 -0
- package/dist/cli/agent-audit.d.ts.map +1 -0
- package/dist/cli/agent-audit.js +52 -0
- package/dist/cli/cloud/command.d.ts +3 -39
- package/dist/cli/cloud/command.d.ts.map +1 -1
- package/dist/cli/cloud/command.js +12 -60
- package/dist/cli/cloud/commands/account.d.ts.map +1 -1
- package/dist/cli/cloud/commands/account.js +2 -1
- package/dist/cli/cloud/commands/agents.d.ts.map +1 -1
- package/dist/cli/cloud/commands/agents.js +3 -1
- package/dist/cli/cloud/commands/artifacts.d.ts +3 -0
- package/dist/cli/cloud/commands/artifacts.d.ts.map +1 -0
- package/dist/cli/cloud/commands/artifacts.js +267 -0
- package/dist/cli/cloud/commands/automations.d.ts.map +1 -1
- package/dist/cli/cloud/commands/automations.js +2 -1
- package/dist/cli/cloud/commands/chats.d.ts.map +1 -1
- package/dist/cli/cloud/commands/chats.js +20 -5
- package/dist/cli/cloud/commands/data-deletions.d.ts.map +1 -1
- package/dist/cli/cloud/commands/data-deletions.js +2 -1
- package/dist/cli/cloud/commands/deploys.d.ts.map +1 -1
- package/dist/cli/cloud/commands/deploys.js +3 -1
- package/dist/cli/cloud/commands/drive.d.ts.map +1 -1
- package/dist/cli/cloud/commands/drive.js +2 -1
- package/dist/cli/cloud/commands/files.d.ts.map +1 -1
- package/dist/cli/cloud/commands/files.js +3 -1
- package/dist/cli/cloud/commands/models.d.ts.map +1 -1
- package/dist/cli/cloud/commands/models.js +3 -1
- package/dist/cli/cloud/commands/profile.d.ts.map +1 -1
- package/dist/cli/cloud/commands/profile.js +2 -1
- package/dist/cli/cloud/commands/publish.d.ts.map +1 -1
- package/dist/cli/cloud/commands/publish.js +2 -1
- package/dist/cli/cloud/commands/sandboxes.d.ts.map +1 -1
- package/dist/cli/cloud/commands/sandboxes.js +2 -1
- package/dist/cli/cloud/commands/secrets.d.ts.map +1 -1
- package/dist/cli/cloud/commands/secrets.js +2 -1
- package/dist/cli/cloud/commands/skills.d.ts.map +1 -1
- package/dist/cli/cloud/commands/skills.js +2 -1
- package/dist/cli/cloud/commands/traces.d.ts +3 -0
- package/dist/cli/cloud/commands/traces.d.ts.map +1 -0
- package/dist/cli/cloud/commands/traces.js +142 -0
- package/dist/cli/cloud/commands/workspace-apps.d.ts.map +1 -1
- package/dist/cli/cloud/commands/workspace-apps.js +2 -1
- package/dist/cli/cloud/commands/workspace.d.ts.map +1 -1
- package/dist/cli/cloud/commands/workspace.js +3 -1
- package/dist/cli/cloud/host.d.ts.map +1 -1
- package/dist/cli/cloud/host.js +4 -0
- package/dist/cli/cloud/output.d.ts.map +1 -1
- package/dist/cli/cloud/output.js +2 -1
- package/dist/cli/cloud/run.d.ts.map +1 -1
- package/dist/cli/cloud/run.js +10 -10
- package/dist/cli/cloud/transfer.d.ts.map +1 -1
- package/dist/cli/cloud/transfer.js +2 -1
- package/dist/cli/cloud/v1.d.ts +2 -1
- package/dist/cli/cloud/v1.d.ts.map +1 -1
- package/dist/cli/cloud/v1.js +2 -1
- package/dist/cli/commands/auth/sign-in-commands.d.ts.map +1 -1
- package/dist/cli/commands/auth/sign-in-commands.js +2 -1
- package/dist/cli/commands/cloud/register.d.ts.map +1 -1
- package/dist/cli/commands/cloud/register.js +2 -0
- package/dist/cli/commands/run/headless.d.ts.map +1 -1
- package/dist/cli/commands/run/headless.js +55 -0
- package/dist/cli/commands/run/step-log.d.ts +11 -0
- package/dist/cli/commands/run/step-log.d.ts.map +1 -0
- package/dist/cli/commands/run/step-log.js +42 -0
- package/dist/cli/failure.d.ts +29 -0
- package/dist/cli/failure.d.ts.map +1 -0
- package/dist/cli/failure.js +31 -0
- package/dist/cli/help-json.d.ts.map +1 -1
- package/dist/cli/help-json.js +1 -1
- package/dist/cli/import/claude-code.d.ts +10 -0
- package/dist/cli/import/claude-code.d.ts.map +1 -0
- package/dist/cli/import/claude-code.js +131 -0
- package/dist/cli/import/codex.d.ts +27 -0
- package/dist/cli/import/codex.d.ts.map +1 -0
- package/dist/cli/import/codex.js +148 -0
- package/dist/cli/import/git.d.ts +19 -0
- package/dist/cli/import/git.d.ts.map +1 -0
- package/dist/cli/import/git.js +95 -0
- package/dist/cli/import/local-chats.d.ts +28 -0
- package/dist/cli/import/local-chats.d.ts.map +1 -0
- package/dist/cli/import/local-chats.js +129 -0
- package/dist/cli/import/redaction.d.ts +37 -0
- package/dist/cli/import/redaction.d.ts.map +1 -0
- package/dist/cli/import/redaction.js +148 -0
- package/dist/cli/import/test-utils.d.ts +16 -0
- package/dist/cli/import/test-utils.d.ts.map +1 -0
- package/dist/cli/import/test-utils.js +223 -0
- package/dist/cli/import/transcript.d.ts +75 -0
- package/dist/cli/import/transcript.d.ts.map +1 -0
- package/dist/cli/import/transcript.js +123 -0
- package/dist/cli/interaction.d.ts +18 -0
- package/dist/cli/interaction.d.ts.map +1 -0
- package/dist/cli/interaction.js +30 -0
- package/dist/cli/output.d.ts +9 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +6 -0
- package/dist/index-main.js +12 -1
- package/package.json +10 -10
- package/dist/cli/cloud/local-chats.d.ts +0 -73
- package/dist/cli/cloud/local-chats.d.ts.map +0 -1
- package/dist/cli/cloud/local-chats.js +0 -441
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Finds the git repository a folder is in by reading `.git` on disk; git itself is never run.
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* The repository `folder` is in: the main checkout's folder, so every worktree of one repository
|
|
6
|
+
* names the same one, and its `origin` remote. Null when the folder is gone or in no repository.
|
|
7
|
+
*/
|
|
8
|
+
export async function findRepository(folder) {
|
|
9
|
+
const resolved = path.resolve(folder);
|
|
10
|
+
if (!(await stat(resolved).catch(() => null))?.isDirectory())
|
|
11
|
+
return null;
|
|
12
|
+
const found = await findDotGit(resolved);
|
|
13
|
+
if (found === null)
|
|
14
|
+
return null;
|
|
15
|
+
const commonDir = await commonGitDir(found);
|
|
16
|
+
if (commonDir === null)
|
|
17
|
+
return null;
|
|
18
|
+
const root = path.basename(commonDir) === '.git' ? path.dirname(commonDir) : path.dirname(found.path);
|
|
19
|
+
const config = await readFile(path.join(commonDir, 'config'), 'utf8').catch(() => null);
|
|
20
|
+
return { remoteUrl: config === null ? null : originUrl(config), root };
|
|
21
|
+
}
|
|
22
|
+
/** A lookup of `findRepository` that reads each folder once. */
|
|
23
|
+
export function createRepositoryFinder() {
|
|
24
|
+
const found = new Map();
|
|
25
|
+
return async (folder) => {
|
|
26
|
+
if (folder === null)
|
|
27
|
+
return null;
|
|
28
|
+
let repository = found.get(folder);
|
|
29
|
+
if (repository === undefined) {
|
|
30
|
+
repository = findRepository(folder);
|
|
31
|
+
found.set(folder, repository);
|
|
32
|
+
}
|
|
33
|
+
return await repository;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** The nearest `.git` at or above `folder`: a directory, or a worktree's `gitdir:` file. */
|
|
37
|
+
async function findDotGit(folder) {
|
|
38
|
+
for (let current = folder;; current = path.dirname(current)) {
|
|
39
|
+
const candidate = path.join(current, '.git');
|
|
40
|
+
const stats = await stat(candidate).catch(() => null);
|
|
41
|
+
if (stats?.isDirectory())
|
|
42
|
+
return { kind: 'dir', path: candidate };
|
|
43
|
+
if (stats?.isFile())
|
|
44
|
+
return { kind: 'file', path: candidate };
|
|
45
|
+
if (path.dirname(current) === current)
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** The git directory shared by every worktree: a worktree's `gitdir`, then its `commondir`. */
|
|
50
|
+
async function commonGitDir(dotGit) {
|
|
51
|
+
if (dotGit.kind === 'dir')
|
|
52
|
+
return dotGit.path;
|
|
53
|
+
const pointer = await readFile(dotGit.path, 'utf8').catch(() => null);
|
|
54
|
+
const gitDir = /^gitdir:\s*(.+)$/mu.exec(pointer ?? '')?.[1]?.trim();
|
|
55
|
+
if (gitDir === undefined)
|
|
56
|
+
return null;
|
|
57
|
+
const worktreeDir = path.resolve(path.dirname(dotGit.path), gitDir);
|
|
58
|
+
const common = await readFile(path.join(worktreeDir, 'commondir'), 'utf8').catch(() => null);
|
|
59
|
+
return common === null ? worktreeDir : path.resolve(worktreeDir, common.trim());
|
|
60
|
+
}
|
|
61
|
+
/** `[remote "origin"] url = …` in a git config, without credentials. */
|
|
62
|
+
export function originUrl(config) {
|
|
63
|
+
let inOrigin = false;
|
|
64
|
+
for (const raw of config.split(/\r?\n/u)) {
|
|
65
|
+
const line = raw.trim();
|
|
66
|
+
if (line.startsWith('[')) {
|
|
67
|
+
inOrigin = /^\[remote\s+"origin"\]$/u.test(line);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!inOrigin)
|
|
71
|
+
continue;
|
|
72
|
+
const url = /^url\s*=\s*(.+)$/u.exec(line)?.[1]?.trim();
|
|
73
|
+
if (url !== undefined && url !== '')
|
|
74
|
+
return withoutCredentials(url);
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A remote URL with userinfo, query and fragment removed, including scp-style userinfo.
|
|
80
|
+
*/
|
|
81
|
+
export function withoutCredentials(url) {
|
|
82
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//iu.test(url))
|
|
83
|
+
return url.replace(/^(\/\/)?[^\s]*@(?=[^/]+(?:\/|:|$))/u, '$1').split(/[?#]/u, 1)[0] ?? '';
|
|
84
|
+
try {
|
|
85
|
+
const parsed = new URL(url);
|
|
86
|
+
parsed.username = '';
|
|
87
|
+
parsed.password = '';
|
|
88
|
+
parsed.search = '';
|
|
89
|
+
parsed.hash = '';
|
|
90
|
+
return parsed.toString();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return url.replace(/\/\/[^/@]*@/u, '//').split(/[?#]/u, 1)[0] ?? '';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { CloudRuntime } from '../cloud/runtime.js';
|
|
2
|
+
import type { LocalChat, LocalChatSource } from './transcript.js';
|
|
3
|
+
export interface LocalChatQuery {
|
|
4
|
+
/** Another sessions folder to read instead of the source's own. */
|
|
5
|
+
readonly dir: string | null;
|
|
6
|
+
/** Keep sessions whose working directory is this folder or inside it. */
|
|
7
|
+
readonly folder: string | null;
|
|
8
|
+
/** Keep sessions with a turn at or after this date. */
|
|
9
|
+
readonly since: string | null;
|
|
10
|
+
readonly source: LocalChatSource;
|
|
11
|
+
}
|
|
12
|
+
/** Something about this machine's sessions that the chats found don't show. */
|
|
13
|
+
export interface LocalChatNotice {
|
|
14
|
+
readonly code: 'codex_history_migrated';
|
|
15
|
+
/** How many sessions it is about. */
|
|
16
|
+
readonly count: number;
|
|
17
|
+
readonly message: string;
|
|
18
|
+
}
|
|
19
|
+
export interface LocalChatScan {
|
|
20
|
+
/** Newest first. */
|
|
21
|
+
readonly chats: readonly LocalChat[];
|
|
22
|
+
readonly notices: readonly LocalChatNotice[];
|
|
23
|
+
}
|
|
24
|
+
type FinderRuntime = Pick<CloudRuntime, 'environment' | 'homeDirectory'>;
|
|
25
|
+
/** The source's sessions on this machine that match the query, newest first. */
|
|
26
|
+
export declare function findLocalChats(runtime: FinderRuntime, query: LocalChatQuery): Promise<LocalChatScan>;
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=local-chats.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-chats.d.ts","sourceRoot":"","sources":["../../../src/cli/import/local-chats.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAIxD,OAAO,KAAK,EAAE,SAAS,EAAE,eAAe,EAAc,MAAM,iBAAiB,CAAC;AAK9E,MAAM,WAAW,cAAc;IAC3B,mEAAmE;IACnE,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,yEAAyE;IACzE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,uDAAuD;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;CACpC;AAED,+EAA+E;AAC/E,MAAM,WAAW,eAAe;IAC5B,QAAQ,CAAC,IAAI,EAAE,wBAAwB,CAAC;IACxC,qCAAqC;IACrC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,aAAa;IAC1B,oBAAoB;IACpB,QAAQ,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,CAAC;IACrC,QAAQ,CAAC,OAAO,EAAE,SAAS,eAAe,EAAE,CAAC;CAChD;AAED,KAAK,aAAa,GAAG,IAAI,CAAC,YAAY,EAAE,aAAa,GAAG,eAAe,CAAC,CAAC;AAEzE,gFAAgF;AAChF,wBAAsB,cAAc,CAChC,OAAO,EAAE,aAAa,EACtB,KAAK,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CA+BxB"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Finds the Claude Code and Codex chats on this machine that a query asks for.
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { UsageError } from '../cloud/command.js';
|
|
5
|
+
import { claudeCodeFolder, parseClaudeCodeTranscript } from './claude-code.js';
|
|
6
|
+
import { codexHome, parseCodexRollout, readCodexThreads } from './codex.js';
|
|
7
|
+
import { createRepositoryFinder, withoutCredentials } from './git.js';
|
|
8
|
+
/** A transcript file larger than this is not read. */
|
|
9
|
+
const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
/** The source's sessions on this machine that match the query, newest first. */
|
|
11
|
+
export async function findLocalChats(runtime, query) {
|
|
12
|
+
if (query.dir !== null)
|
|
13
|
+
await readdir(query.dir).catch(() => {
|
|
14
|
+
throw new UsageError(`--dir ${query.dir} is not a folder this can read.`);
|
|
15
|
+
});
|
|
16
|
+
const since = query.since === null ? null : new Date(query.since);
|
|
17
|
+
const within = query.folder === null ? null : path.resolve(query.folder);
|
|
18
|
+
const matches = (updatedAt, cwd) => (since === null || new Date(updatedAt) >= since) &&
|
|
19
|
+
(within === null || insideFolder(cwd, within));
|
|
20
|
+
const sessions = query.source === 'claude-code'
|
|
21
|
+
? await claudeCodeSessions(runtime, query.dir)
|
|
22
|
+
: await codexSessions(runtime, query.dir);
|
|
23
|
+
const repositoryOf = createRepositoryFinder();
|
|
24
|
+
const chats = [];
|
|
25
|
+
for (const file of sessions.files) {
|
|
26
|
+
const stats = await stat(file).catch(() => null);
|
|
27
|
+
if (stats === null || stats.size > MAX_FILE_BYTES)
|
|
28
|
+
continue;
|
|
29
|
+
const text = await readFile(file, 'utf8').catch(() => null);
|
|
30
|
+
if (text === null)
|
|
31
|
+
continue;
|
|
32
|
+
const parsed = sessions.parse(text, file);
|
|
33
|
+
if (parsed === null || !matches(parsed.updatedAt, parsed.cwd))
|
|
34
|
+
continue;
|
|
35
|
+
chats.push(await withRepository(parsed, repositoryOf));
|
|
36
|
+
}
|
|
37
|
+
const notices = sessions.notices(matches);
|
|
38
|
+
return {
|
|
39
|
+
chats: chats.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)),
|
|
40
|
+
notices,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function claudeCodeSessions(runtime, dir) {
|
|
44
|
+
const folder = dir ?? claudeCodeFolder(runtime.environment, runtime.homeDirectory);
|
|
45
|
+
return {
|
|
46
|
+
files: await claudeCodeFiles(folder),
|
|
47
|
+
notices: () => [],
|
|
48
|
+
parse: parseClaudeCodeTranscript,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Codex rollouts, titled as Codex lists them. Its own state database is read only for its own
|
|
53
|
+
* sessions folder: a `--dir` is a copy, whose threads that database doesn't describe.
|
|
54
|
+
*/
|
|
55
|
+
async function codexSessions(runtime, dir) {
|
|
56
|
+
const home = codexHome(runtime.environment, runtime.homeDirectory);
|
|
57
|
+
const threads = dir === null ? await readCodexThreads(home) : null;
|
|
58
|
+
const titles = new Map((threads ?? []).map((thread) => [thread.id, thread.title]));
|
|
59
|
+
return {
|
|
60
|
+
files: await codexFiles(dir ?? path.join(home, 'sessions')),
|
|
61
|
+
notices: (matches) => migratedNotice(threads ?? [], matches),
|
|
62
|
+
parse: (text, file) => parseCodexRollout(text, file, (id) => titles.get(id) ?? null),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Codex threads whose history moved out of rollout files, which this can't read yet. */
|
|
66
|
+
function migratedNotice(threads, matches) {
|
|
67
|
+
const count = threads.filter((thread) => !thread.hasRollout && matches(thread.updatedAt, thread.cwd)).length;
|
|
68
|
+
if (count === 0)
|
|
69
|
+
return [];
|
|
70
|
+
const chats = count === 1 ? '1 Codex chat has' : `${count} Codex chats have`;
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
code: 'codex_history_migrated',
|
|
74
|
+
count,
|
|
75
|
+
message: `${chats} no rollout file on this machine, so it can't be imported yet: Codex keeps its history in its own database after \`codex migrate-rollouts\`.`,
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
async function withRepository(parsed, repositoryOf) {
|
|
80
|
+
const { recordedRemoteUrl, ...chat } = parsed;
|
|
81
|
+
const onDisk = await repositoryOf(chat.cwd);
|
|
82
|
+
// The remote Codex recorded when the session ran outlives a checkout that has since moved.
|
|
83
|
+
const remoteUrl = recordedRemoteUrl === null
|
|
84
|
+
? (onDisk?.remoteUrl ?? null)
|
|
85
|
+
: withoutCredentials(recordedRemoteUrl);
|
|
86
|
+
const root = onDisk?.root ?? null;
|
|
87
|
+
return {
|
|
88
|
+
...chat,
|
|
89
|
+
repository: root === null && remoteUrl === null ? null : { remoteUrl, root },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function insideFolder(cwd, folder) {
|
|
93
|
+
if (cwd === null)
|
|
94
|
+
return false;
|
|
95
|
+
const relative = path.relative(folder, cwd);
|
|
96
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
97
|
+
}
|
|
98
|
+
async function entries(folder) {
|
|
99
|
+
return await readdir(folder, { withFileTypes: true }).catch(() => []);
|
|
100
|
+
}
|
|
101
|
+
/** `projects/<project>/<session>.jsonl`; deeper files are a session's subagents. */
|
|
102
|
+
async function claudeCodeFiles(folder) {
|
|
103
|
+
const files = [];
|
|
104
|
+
for (const project of await entries(folder)) {
|
|
105
|
+
if (!project.isDirectory())
|
|
106
|
+
continue;
|
|
107
|
+
for (const file of await entries(path.join(folder, project.name)))
|
|
108
|
+
if (file.isFile() && file.name.endsWith('.jsonl'))
|
|
109
|
+
files.push(path.join(folder, project.name, file.name));
|
|
110
|
+
}
|
|
111
|
+
return files;
|
|
112
|
+
}
|
|
113
|
+
/** `sessions/YYYY/MM/DD/rollout-*.jsonl`; compressed rollouts are left out. */
|
|
114
|
+
async function codexFiles(folder) {
|
|
115
|
+
const files = [];
|
|
116
|
+
const visit = async (dir, depth) => {
|
|
117
|
+
for (const entry of await entries(dir)) {
|
|
118
|
+
const full = path.join(dir, entry.name);
|
|
119
|
+
if (entry.isDirectory() && depth < 3)
|
|
120
|
+
await visit(full, depth + 1);
|
|
121
|
+
else if (entry.isFile() &&
|
|
122
|
+
entry.name.startsWith('rollout-') &&
|
|
123
|
+
entry.name.endsWith('.jsonl'))
|
|
124
|
+
files.push(full);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
await visit(folder, 0);
|
|
128
|
+
return files;
|
|
129
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { LocalChat, LocalChatMessage, LocalChatSource } from './transcript.js';
|
|
2
|
+
/** What `dexto_chat_import` takes; its bounds are the server's too. */
|
|
3
|
+
export interface ChatImportInput {
|
|
4
|
+
readonly cwd?: string;
|
|
5
|
+
readonly gitBranch?: string;
|
|
6
|
+
readonly messages: readonly LocalChatMessage[];
|
|
7
|
+
readonly source: LocalChatSource;
|
|
8
|
+
readonly sourceSessionId: string;
|
|
9
|
+
readonly title: string;
|
|
10
|
+
}
|
|
11
|
+
export declare const MAX_IMPORT_MESSAGES = 2000;
|
|
12
|
+
export declare const MAX_MESSAGE_CHARS = 64000;
|
|
13
|
+
export declare const MAX_IMPORT_CHARS = 4000000;
|
|
14
|
+
export declare function truncate(text: string, max: number): string;
|
|
15
|
+
export declare function redactSecrets(text: string): {
|
|
16
|
+
counts: Record<string, number>;
|
|
17
|
+
text: string;
|
|
18
|
+
};
|
|
19
|
+
export type PreparedImport = {
|
|
20
|
+
readonly input: ChatImportInput;
|
|
21
|
+
readonly kind: 'ready';
|
|
22
|
+
/** How many of the session's oldest messages were left out to fit the import's bounds. */
|
|
23
|
+
readonly leftOut: number;
|
|
24
|
+
/** How many of each kind of secret were redacted, counted from the markers sent. */
|
|
25
|
+
readonly redactions: Readonly<Record<string, number>>;
|
|
26
|
+
/** How many messages were cut to the size limit. */
|
|
27
|
+
readonly truncated: number;
|
|
28
|
+
} | {
|
|
29
|
+
readonly kind: 'skipped';
|
|
30
|
+
readonly reason: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* The chat as it is sent: redacted, and each message within the size limit. A session longer than
|
|
34
|
+
* the import's bounds keeps its newest messages, after a note that says how many were left out.
|
|
35
|
+
*/
|
|
36
|
+
export declare function toImportInput(chat: Pick<LocalChat, 'cwd' | 'gitBranch' | 'id' | 'messages' | 'source' | 'title'>): PreparedImport;
|
|
37
|
+
//# sourceMappingURL=redaction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redaction.d.ts","sourceRoot":"","sources":["../../../src/cli/import/redaction.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEpF,uEAAuE;AACvE,MAAM,WAAW,eAAe;IAC5B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,SAAS,gBAAgB,EAAE,CAAC;IAC/C,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IACjC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CAC1B;AAED,eAAO,MAAM,mBAAmB,OAAQ,CAAC;AACzC,eAAO,MAAM,iBAAiB,QAAS,CAAC;AACxC,eAAO,MAAM,gBAAgB,UAAY,CAAC;AAM1C,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAI1D;AAsBD,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAuC5F;AAED,MAAM,MAAM,cAAc,GACpB;IACI,QAAQ,CAAC,KAAK,EAAE,eAAe,CAAC;IAChC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,0FAA0F;IAC1F,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,oFAAoF;IACpF,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtD,oDAAoD;IACpD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC9B,GACD;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAgB5D;;;GAGG;AACH,wBAAgB,aAAa,CACzB,IAAI,EAAE,IAAI,CAAC,SAAS,EAAE,KAAK,GAAG,WAAW,GAAG,IAAI,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,CAAC,GACpF,cAAc,CA0BhB"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Turns a local chat into what `dexto_chat_import` takes: redacted here, and within its bounds.
|
|
2
|
+
export const MAX_IMPORT_MESSAGES = 2_000;
|
|
3
|
+
export const MAX_MESSAGE_CHARS = 64_000;
|
|
4
|
+
export const MAX_IMPORT_CHARS = 4_000_000;
|
|
5
|
+
const MAX_TITLE_CHARS = 200;
|
|
6
|
+
const MAX_CWD_CHARS = 1_024;
|
|
7
|
+
const MAX_BRANCH_CHARS = 255;
|
|
8
|
+
const SOURCE_SESSION_ID = /^[A-Za-z0-9._:-]{1,200}$/u;
|
|
9
|
+
export function truncate(text, max) {
|
|
10
|
+
if (text.length <= max)
|
|
11
|
+
return text;
|
|
12
|
+
const marker = `\n… [${text.length - max} more characters left out]`;
|
|
13
|
+
return `${text.slice(0, Math.max(0, max - marker.length))}${marker}`;
|
|
14
|
+
}
|
|
15
|
+
/** Credential shapes, most specific first; each match becomes `[redacted:<kind>]`. */
|
|
16
|
+
const SECRET_PATTERNS = [
|
|
17
|
+
['private_key', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu],
|
|
18
|
+
['secret_key', /\bsk-[A-Za-z0-9_-]{20,}/gu],
|
|
19
|
+
['secret_key', /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9_-]{8,}/gu],
|
|
20
|
+
['gitlab_token', /\bglpat-[A-Za-z0-9_-]{8,}/gu],
|
|
21
|
+
['github_token', /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/gu],
|
|
22
|
+
['aws_access_key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/gu],
|
|
23
|
+
['slack_token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/gu],
|
|
24
|
+
['google_api_key', /\bAIza[0-9A-Za-z_-]{35}/gu],
|
|
25
|
+
['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/gu],
|
|
26
|
+
['bearer_token', /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gu],
|
|
27
|
+
['auth_token', /\bAuthorization\s*:\s*(?:Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/giu],
|
|
28
|
+
];
|
|
29
|
+
/** `password = value` and the like: the name stays, the value goes. */
|
|
30
|
+
const SECRET_ASSIGNMENT = /\b([A-Za-z_][A-Za-z0-9_-]*)(\\*["']?\s*[:=]\s*\\*["']?)([^\s"'[]{8,})/gu;
|
|
31
|
+
const REDACTED_MARKER = /\[redacted:([a-z_]+)\]/gu;
|
|
32
|
+
export function redactSecrets(text) {
|
|
33
|
+
const counts = {};
|
|
34
|
+
const count = (kind) => {
|
|
35
|
+
counts[kind] = (counts[kind] ?? 0) + 1;
|
|
36
|
+
};
|
|
37
|
+
let redacted = text;
|
|
38
|
+
for (const [kind, pattern] of SECRET_PATTERNS)
|
|
39
|
+
redacted = redacted.replace(pattern, () => {
|
|
40
|
+
count(kind);
|
|
41
|
+
return `[redacted:${kind}]`;
|
|
42
|
+
});
|
|
43
|
+
redacted = redacted.replace(/(:\/\/[^\s/:@]+:)([^\s/]+)(@)/gu, (_match, prefix, _password, suffix) => {
|
|
44
|
+
count('url_password');
|
|
45
|
+
return `${prefix}[redacted:url_password]${suffix}`;
|
|
46
|
+
});
|
|
47
|
+
redacted = redacted.replace(SECRET_ASSIGNMENT, (match, name, separator, value) => {
|
|
48
|
+
const words = name
|
|
49
|
+
.replace(/([a-z0-9])([A-Z])/gu, '$1_$2')
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.split(/[_-]+/u);
|
|
52
|
+
const secret = words.some((word) => ['password', 'passwd', 'pass', 'secret', 'token'].includes(word)) ||
|
|
53
|
+
words.some((word, index) => ['api', 'private', 'access'].includes(word) && words[index + 1] === 'key');
|
|
54
|
+
if (!secret || /^z\.\w+\(/u.test(value))
|
|
55
|
+
return match;
|
|
56
|
+
count('assignment');
|
|
57
|
+
return `${name}${separator}[redacted:assignment]`;
|
|
58
|
+
});
|
|
59
|
+
return { counts, text: redacted };
|
|
60
|
+
}
|
|
61
|
+
const SOURCE_NAMES = {
|
|
62
|
+
'claude-code': 'Claude Code',
|
|
63
|
+
codex: 'Codex',
|
|
64
|
+
};
|
|
65
|
+
/** The first message of a chat whose oldest messages were left out, saying so. */
|
|
66
|
+
function leftOutNote(source, leftOut) {
|
|
67
|
+
const what = leftOut === 1
|
|
68
|
+
? 'the first message of this session was'
|
|
69
|
+
: `the first ${leftOut.toLocaleString('en-US')} messages of this session were`;
|
|
70
|
+
return `[Imported from ${SOURCE_NAMES[source]}: ${what} left out. Dexto imports up to a session's newest ${MAX_IMPORT_MESSAGES.toLocaleString('en-US')} messages.]`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The chat as it is sent: redacted, and each message within the size limit. A session longer than
|
|
74
|
+
* the import's bounds keeps its newest messages, after a note that says how many were left out.
|
|
75
|
+
*/
|
|
76
|
+
export function toImportInput(chat) {
|
|
77
|
+
if (!SOURCE_SESSION_ID.test(chat.id))
|
|
78
|
+
return { kind: 'skipped', reason: "Its session id isn't one Dexto takes." };
|
|
79
|
+
let truncated = 0;
|
|
80
|
+
const bounded = chat.messages.map((message) => {
|
|
81
|
+
const { text } = redactSecrets(message.text);
|
|
82
|
+
if (text.length > MAX_MESSAGE_CHARS)
|
|
83
|
+
truncated += 1;
|
|
84
|
+
return {
|
|
85
|
+
createdAt: message.createdAt,
|
|
86
|
+
role: message.role,
|
|
87
|
+
text: truncate(text, MAX_MESSAGE_CHARS),
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
const { leftOut, messages } = newestWithin(bounded, chat.source);
|
|
91
|
+
const title = redactSecrets(chat.title).text.slice(0, MAX_TITLE_CHARS);
|
|
92
|
+
const cwd = chat.cwd === null ? null : redactSecrets(chat.cwd).text;
|
|
93
|
+
const gitBranch = chat.gitBranch === null ? null : redactSecrets(chat.gitBranch).text;
|
|
94
|
+
const input = {
|
|
95
|
+
...(cwd === null || cwd.length > MAX_CWD_CHARS ? {} : { cwd }),
|
|
96
|
+
...(gitBranch === null || gitBranch.length > MAX_BRANCH_CHARS ? {} : { gitBranch }),
|
|
97
|
+
messages,
|
|
98
|
+
source: chat.source,
|
|
99
|
+
sourceSessionId: chat.id,
|
|
100
|
+
title: title.trim() === '' ? 'Imported chat' : title,
|
|
101
|
+
};
|
|
102
|
+
return { input, kind: 'ready', leftOut, redactions: redactionCounts(input), truncated };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The messages as they are when they fit the import's message and character bounds; otherwise a
|
|
106
|
+
* note saying how many were left out, then the newest messages that fit alongside it.
|
|
107
|
+
*/
|
|
108
|
+
function newestWithin(messages, source) {
|
|
109
|
+
const chars = messages.reduce((sum, message) => sum + message.text.length, 0);
|
|
110
|
+
if (messages.length <= MAX_IMPORT_MESSAGES && chars <= MAX_IMPORT_CHARS)
|
|
111
|
+
return { leftOut: 0, messages: [...messages] };
|
|
112
|
+
// The note's own length depends on the count it states; the widest count fits every count.
|
|
113
|
+
const noteChars = leftOutNote(source, messages.length).length;
|
|
114
|
+
const kept = [];
|
|
115
|
+
let total = noteChars;
|
|
116
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
117
|
+
const message = messages[index];
|
|
118
|
+
if (message === undefined)
|
|
119
|
+
break;
|
|
120
|
+
if (kept.length === MAX_IMPORT_MESSAGES - 1)
|
|
121
|
+
break;
|
|
122
|
+
if (total + message.text.length > MAX_IMPORT_CHARS)
|
|
123
|
+
break;
|
|
124
|
+
kept.push(message);
|
|
125
|
+
total += message.text.length;
|
|
126
|
+
}
|
|
127
|
+
kept.reverse();
|
|
128
|
+
const leftOut = messages.length - kept.length;
|
|
129
|
+
// Date the note before all retained messages, including when source timestamps tie or regress.
|
|
130
|
+
const lastLeftOut = messages[leftOut - 1];
|
|
131
|
+
if (lastLeftOut === undefined)
|
|
132
|
+
return { leftOut: 0, messages: kept };
|
|
133
|
+
const note = {
|
|
134
|
+
createdAt: new Date(Math.min(Date.parse(lastLeftOut.createdAt), ...kept.map((message) => Date.parse(message.createdAt) - 1))).toISOString(),
|
|
135
|
+
role: 'assistant',
|
|
136
|
+
text: leftOutNote(source, leftOut),
|
|
137
|
+
};
|
|
138
|
+
return { leftOut, messages: [note, ...kept] };
|
|
139
|
+
}
|
|
140
|
+
/** Each `[redacted:<kind>]` in what is sent, including those made while reading tool output. */
|
|
141
|
+
function redactionCounts(input) {
|
|
142
|
+
const counts = {};
|
|
143
|
+
const sent = [input.title, input.cwd ?? '', input.gitBranch ?? ''];
|
|
144
|
+
for (const text of [...sent, ...input.messages.map((message) => message.text)])
|
|
145
|
+
for (const [, kind = ''] of text.matchAll(REDACTED_MARKER))
|
|
146
|
+
counts[kind] = (counts[kind] ?? 0) + 1;
|
|
147
|
+
return counts;
|
|
148
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const jsonl: (lines: readonly unknown[]) => string;
|
|
2
|
+
/** A synthetic Claude Code session, shaped as the Agent SDK's messages. */
|
|
3
|
+
export declare function claudeSession(sessionId: string): string;
|
|
4
|
+
/** A synthetic Codex rollout, shaped as `RolloutItemWire` lines. */
|
|
5
|
+
export declare function codexRollout(id: string): string;
|
|
6
|
+
/** A Codex `state_5.sqlite` with the `threads` columns an import reads, holding these rows. */
|
|
7
|
+
export declare function writeCodexState(home: string, threads: readonly {
|
|
8
|
+
readonly archived?: boolean;
|
|
9
|
+
readonly cwd: string;
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly name?: string | null;
|
|
12
|
+
readonly rolloutPath: string | null;
|
|
13
|
+
readonly title: string;
|
|
14
|
+
readonly updatedAt: string;
|
|
15
|
+
}[]): Promise<void>;
|
|
16
|
+
//# sourceMappingURL=test-utils.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test-utils.d.ts","sourceRoot":"","sources":["../../../src/cli/import/test-utils.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,KAAK,GAAI,OAAO,SAAS,OAAO,EAAE,WACS,CAAC;AAEzD,2EAA2E;AAC3E,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,UAsG9C;AAED,oEAAoE;AACpE,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,UAoGtC;AAED,+FAA+F;AAC/F,wBAAsB,eAAe,CACjC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,SAAS;IACd,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC9B,EAAE,GACJ,OAAO,CAAC,IAAI,CAAC,CAuBf"}
|