@groundtrack/hooks 0.1.9 → 0.1.11
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/README.md +28 -10
- package/dist/agents.d.ts +55 -0
- package/dist/agents.js +77 -3
- package/dist/groundtrack-hook.js +0 -0
- package/dist/groundtrack.js +0 -0
- package/dist/session-import/canonical.d.ts +33 -0
- package/dist/session-import/canonical.js +10 -0
- package/dist/session-import/claude.d.ts +20 -0
- package/dist/session-import/claude.js +237 -0
- package/dist/session-import/cli.d.ts +3 -2
- package/dist/session-import/cli.js +161 -131
- package/dist/session-import/client.d.ts +4 -3
- package/dist/session-import/client.js +4 -3
- package/dist/session-import/codex.d.ts +6 -33
- package/dist/session-import/codex.js +62 -9
- package/dist/session-import/cursor.d.ts +13 -0
- package/dist/session-import/cursor.js +268 -0
- package/dist/session-import/discovery.d.ts +16 -7
- package/dist/session-import/discovery.js +127 -70
- package/dist/session-import/format.d.ts +51 -0
- package/dist/session-import/format.js +11 -0
- package/dist/session-import/formats.d.ts +4 -0
- package/dist/session-import/formats.js +13 -0
- package/dist/session-import/normalize.d.ts +21 -0
- package/dist/session-import/normalize.js +100 -0
- package/dist/session-import/opencode-cli.d.ts +14 -0
- package/dist/session-import/opencode-cli.js +178 -0
- package/dist/session-import/opencode-normalizer.d.ts +8 -0
- package/dist/session-import/opencode-normalizer.js +230 -0
- package/dist/session-import/opencode.d.ts +8 -0
- package/dist/session-import/opencode.js +230 -0
- package/dist/session-import/prune.d.ts +13 -4
- package/dist/session-import/prune.js +45 -79
- package/dist/session-import/receipts.d.ts +2 -1
- package/dist/session-import/receipts.js +3 -3
- package/dist/session-import/redaction.js +5 -3
- package/dist/session-import/types.d.ts +21 -7
- package/dist/setup/agents.d.ts +32 -0
- package/package.json +29 -1
- package/src/agents.ts +104 -3
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { CANONICAL_IMPORT_MAX_ENTRIES, isSessionRecord, sessionString, } from "./canonical.js";
|
|
2
|
+
import { sanitizeRepositoryUrl } from "./redaction.js";
|
|
3
|
+
export class SessionNormalizationError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "SessionNormalizationError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function recordTimestamp(record) {
|
|
12
|
+
const value = sessionString(record.timestamp);
|
|
13
|
+
if (!value)
|
|
14
|
+
return null;
|
|
15
|
+
const timestamp = new Date(value).getTime();
|
|
16
|
+
return Number.isNaN(timestamp) ? null : timestamp;
|
|
17
|
+
}
|
|
18
|
+
export function createSessionNormalizationAccumulator(normalizer, options = {}) {
|
|
19
|
+
const entries = [];
|
|
20
|
+
let totalRecordCount = 0;
|
|
21
|
+
let importedRecordCount = 0;
|
|
22
|
+
let importedEntryCount = 0;
|
|
23
|
+
let malformedRecordCount = 0;
|
|
24
|
+
let sensitiveRecordCount = 0;
|
|
25
|
+
let unsupportedRecordCount = 0;
|
|
26
|
+
let earliestTimestampMs = options.discoveryMetadata?.startedAt?.getTime() ?? null;
|
|
27
|
+
let latestTimestampMs = options.discoveryMetadata?.endedAt?.getTime() ?? null;
|
|
28
|
+
return {
|
|
29
|
+
add(record) {
|
|
30
|
+
totalRecordCount += 1;
|
|
31
|
+
if (!isSessionRecord(record)) {
|
|
32
|
+
malformedRecordCount += 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const timestamp = recordTimestamp(record);
|
|
36
|
+
if (timestamp !== null) {
|
|
37
|
+
earliestTimestampMs =
|
|
38
|
+
earliestTimestampMs === null ? timestamp : Math.min(earliestTimestampMs, timestamp);
|
|
39
|
+
latestTimestampMs =
|
|
40
|
+
latestTimestampMs === null ? timestamp : Math.max(latestTimestampMs, timestamp);
|
|
41
|
+
}
|
|
42
|
+
const normalized = normalizer.normalizeRecord(record, CANONICAL_IMPORT_MAX_ENTRIES - importedEntryCount);
|
|
43
|
+
if (normalized.sensitive)
|
|
44
|
+
sensitiveRecordCount += 1;
|
|
45
|
+
if (normalized.unsupported)
|
|
46
|
+
unsupportedRecordCount += 1;
|
|
47
|
+
if (normalized.overflowed)
|
|
48
|
+
throw new SessionNormalizationError("too_many_entries", `Session has too many entries (maximum ${CANONICAL_IMPORT_MAX_ENTRIES}).`);
|
|
49
|
+
if (normalized.entries.length > 0)
|
|
50
|
+
importedRecordCount += 1;
|
|
51
|
+
for (const entry of normalized.entries) {
|
|
52
|
+
importedEntryCount += 1;
|
|
53
|
+
if (importedEntryCount > CANONICAL_IMPORT_MAX_ENTRIES)
|
|
54
|
+
throw new SessionNormalizationError("too_many_entries", `Session has too many entries (maximum ${CANONICAL_IMPORT_MAX_ENTRIES}).`);
|
|
55
|
+
if (options.collectEntries)
|
|
56
|
+
entries.push(entry);
|
|
57
|
+
options.onEntry?.(entry);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
finish() {
|
|
61
|
+
const base = normalizer.canonicalMetadata();
|
|
62
|
+
if (!base)
|
|
63
|
+
throw new SessionNormalizationError("missing_session_metadata", "Session metadata is missing.");
|
|
64
|
+
if (importedEntryCount === 0)
|
|
65
|
+
throw new SessionNormalizationError("empty_trace", "Session has no supported trace entries.");
|
|
66
|
+
return {
|
|
67
|
+
metadata: {
|
|
68
|
+
...base,
|
|
69
|
+
repositoryUrl: options.discoveryMetadata
|
|
70
|
+
? sanitizeRepositoryUrl(options.discoveryMetadata.repositoryUrl)
|
|
71
|
+
: base.repositoryUrl,
|
|
72
|
+
startedAt: earliestTimestampMs === null
|
|
73
|
+
? (options.discoveryMetadata?.startedAt?.toISOString() ?? null)
|
|
74
|
+
: new Date(earliestTimestampMs).toISOString(),
|
|
75
|
+
endedAt: latestTimestampMs === null
|
|
76
|
+
? (options.discoveryMetadata?.endedAt?.toISOString() ?? null)
|
|
77
|
+
: new Date(latestTimestampMs).toISOString(),
|
|
78
|
+
statistics: {
|
|
79
|
+
totalRecordCount,
|
|
80
|
+
importedEntryCount,
|
|
81
|
+
skippedRecordCount: totalRecordCount - importedRecordCount,
|
|
82
|
+
unsupportedRecordCount,
|
|
83
|
+
malformedRecordCount,
|
|
84
|
+
sensitiveRecordCount,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
entries,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function normalizeSessionRecords(records, normalizer, discoveryMetadata) {
|
|
93
|
+
const accumulator = createSessionNormalizationAccumulator(normalizer, {
|
|
94
|
+
discoveryMetadata,
|
|
95
|
+
collectEntries: true,
|
|
96
|
+
});
|
|
97
|
+
for (const record of records)
|
|
98
|
+
accumulator.add(record);
|
|
99
|
+
return accumulator.finish();
|
|
100
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type MaterializedSessionCandidates, type SessionDiscoveryCandidates, type SessionDiscoveryContext, type SessionImportFormat } from "./format.js";
|
|
2
|
+
import type { AgentSessionCandidate } from "./types.js";
|
|
3
|
+
type OpenCodeSessionRow = Readonly<{
|
|
4
|
+
id?: unknown;
|
|
5
|
+
projectID?: unknown;
|
|
6
|
+
parentID?: unknown;
|
|
7
|
+
directory?: unknown;
|
|
8
|
+
created?: unknown;
|
|
9
|
+
updated?: unknown;
|
|
10
|
+
}>;
|
|
11
|
+
export declare function opencodeCandidates(context: SessionDiscoveryContext, rows?: readonly OpenCodeSessionRow[]): SessionDiscoveryCandidates;
|
|
12
|
+
export declare function materializeOpenCodeCandidates(candidates: readonly AgentSessionCandidate[], context: SessionDiscoveryContext): MaterializedSessionCandidates;
|
|
13
|
+
export declare const opencodeSessionFormat: SessionImportFormat;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, closeSync, constants, mkdtempSync, openSync, readFileSync, rmSync, unlinkSync, utimesSync, writeSync, } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { isSessionRecord, sessionString } from "./canonical.js";
|
|
6
|
+
import { sessionImportFormatIdentity, } from "./format.js";
|
|
7
|
+
import { createOpenCodeSessionNormalizer, parseOpenCodeSessionExport } from "./opencode.js";
|
|
8
|
+
const inventoryQuery = `
|
|
9
|
+
SELECT
|
|
10
|
+
id,
|
|
11
|
+
project_id AS projectID,
|
|
12
|
+
parent_id AS parentID,
|
|
13
|
+
directory,
|
|
14
|
+
time_created AS created,
|
|
15
|
+
time_updated AS updated
|
|
16
|
+
FROM session
|
|
17
|
+
ORDER BY time_updated ASC, id ASC
|
|
18
|
+
`;
|
|
19
|
+
function timestamp(value) {
|
|
20
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
21
|
+
return null;
|
|
22
|
+
const date = new Date(value);
|
|
23
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
24
|
+
}
|
|
25
|
+
function openCodeBinary(context) {
|
|
26
|
+
return context.env.OPENCODE_BIN || "opencode";
|
|
27
|
+
}
|
|
28
|
+
function runOpenCodeToFile(context, args, outputPath) {
|
|
29
|
+
const output = openSync(outputPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
30
|
+
let result;
|
|
31
|
+
try {
|
|
32
|
+
result = spawnSync(openCodeBinary(context), args, {
|
|
33
|
+
cwd: context.workingDirectory,
|
|
34
|
+
env: context.env,
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
stdio: ["ignore", output, "pipe"],
|
|
37
|
+
timeout: 120_000,
|
|
38
|
+
maxBuffer: 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
closeSync(output);
|
|
43
|
+
}
|
|
44
|
+
if (result.error) {
|
|
45
|
+
if (result.error.code === "ENOENT")
|
|
46
|
+
throw new Error("OpenCode session import requires the opencode CLI. Install it or set OPENCODE_BIN.");
|
|
47
|
+
throw result.error;
|
|
48
|
+
}
|
|
49
|
+
if (result.status !== 0) {
|
|
50
|
+
const detail = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
51
|
+
throw new Error(`OpenCode command failed (${result.status ?? "unknown"})${detail ? `: ${detail}` : "."}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function readJsonFile(path, label) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error(`OpenCode returned invalid ${label} JSON.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function withCommandOutput(context, args, label, read) {
|
|
63
|
+
const directory = mkdtempSync(join(tmpdir(), "groundtrack-opencode-command-"));
|
|
64
|
+
chmodSync(directory, 0o700);
|
|
65
|
+
try {
|
|
66
|
+
const path = join(directory, "output.json");
|
|
67
|
+
runOpenCodeToFile(context, args, path);
|
|
68
|
+
return read(readJsonFile(path, label));
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
rmSync(directory, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function readInventory(context) {
|
|
75
|
+
return withCommandOutput(context, ["db", inventoryQuery, "--format", "json", "--pure"], "session inventory", (value) => {
|
|
76
|
+
if (!Array.isArray(value) || !value.every(isSessionRecord))
|
|
77
|
+
throw new Error("OpenCode returned an invalid session inventory.");
|
|
78
|
+
return value;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export function opencodeCandidates(context, rows = readInventory(context)) {
|
|
82
|
+
const included = [];
|
|
83
|
+
const skipped = [];
|
|
84
|
+
for (const row of rows) {
|
|
85
|
+
const externalSessionId = sessionString(row.id);
|
|
86
|
+
const cwd = sessionString(row.directory);
|
|
87
|
+
const startedAt = timestamp(row.created);
|
|
88
|
+
const endedAt = timestamp(row.updated);
|
|
89
|
+
const path = externalSessionId ? `opencode:${externalSessionId}` : "opencode:unknown";
|
|
90
|
+
if (!externalSessionId || !startedAt || !endedAt || endedAt < startedAt) {
|
|
91
|
+
skipped.push({ path, reason: "invalid_metadata" });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
included.push({
|
|
95
|
+
path,
|
|
96
|
+
modifiedAt: endedAt,
|
|
97
|
+
metadata: {
|
|
98
|
+
externalSessionId,
|
|
99
|
+
timestamp: endedAt,
|
|
100
|
+
cwd,
|
|
101
|
+
repositoryUrl: null,
|
|
102
|
+
repositoryBranch: null,
|
|
103
|
+
repositoryCommitSha: null,
|
|
104
|
+
originator: "opencode",
|
|
105
|
+
isSubagent: sessionString(row.parentID) !== null,
|
|
106
|
+
startedAt,
|
|
107
|
+
endedAt,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return { included, skipped };
|
|
112
|
+
}
|
|
113
|
+
function writeJsonLine(file, value) {
|
|
114
|
+
writeSync(file, `${JSON.stringify(value)}\n`);
|
|
115
|
+
}
|
|
116
|
+
function materializeExport(context, candidate, directory, index) {
|
|
117
|
+
const rawPath = join(directory, `raw-${index}.json`);
|
|
118
|
+
runOpenCodeToFile(context, ["export", candidate.metadata.externalSessionId, "--pure"], rawPath);
|
|
119
|
+
let data;
|
|
120
|
+
try {
|
|
121
|
+
data = readJsonFile(rawPath, "session export");
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
unlinkSync(rawPath);
|
|
125
|
+
}
|
|
126
|
+
const decoded = parseOpenCodeSessionExport(data);
|
|
127
|
+
if (!decoded)
|
|
128
|
+
throw new Error("OpenCode returned an invalid session export.");
|
|
129
|
+
if (decoded.metadata.externalSessionId !== candidate.metadata.externalSessionId)
|
|
130
|
+
throw new Error("OpenCode exported a different session than requested.");
|
|
131
|
+
const path = join(directory, `session-${index}.jsonl`);
|
|
132
|
+
const file = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
133
|
+
try {
|
|
134
|
+
for (const record of decoded.records)
|
|
135
|
+
writeJsonLine(file, record);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
closeSync(file);
|
|
139
|
+
}
|
|
140
|
+
const modifiedAt = candidate.modifiedAt ?? candidate.metadata.timestamp;
|
|
141
|
+
if (modifiedAt)
|
|
142
|
+
utimesSync(path, modifiedAt, modifiedAt);
|
|
143
|
+
return { ...candidate, path };
|
|
144
|
+
}
|
|
145
|
+
export function materializeOpenCodeCandidates(candidates, context) {
|
|
146
|
+
if (candidates.length === 0)
|
|
147
|
+
return { included: [], failed: [], cleanup() { } };
|
|
148
|
+
const directory = mkdtempSync(join(tmpdir(), "groundtrack-opencode-import-"));
|
|
149
|
+
chmodSync(directory, 0o700);
|
|
150
|
+
const included = [];
|
|
151
|
+
const failed = [];
|
|
152
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
153
|
+
try {
|
|
154
|
+
included.push(materializeExport(context, candidate, directory, index));
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
failed.push({
|
|
158
|
+
path: candidate.path,
|
|
159
|
+
error: error instanceof Error ? error.message : String(error),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
included,
|
|
165
|
+
failed,
|
|
166
|
+
cleanup: () => rmSync(directory, { recursive: true, force: true }),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export const opencodeSessionFormat = {
|
|
170
|
+
...sessionImportFormatIdentity("opencode"),
|
|
171
|
+
discovery: "custom",
|
|
172
|
+
homeEnvironmentVariable: "XDG_DATA_HOME",
|
|
173
|
+
defaultHomeDirectory: ".local/share",
|
|
174
|
+
discoverCandidates: opencodeCandidates,
|
|
175
|
+
materializeCandidates: materializeOpenCodeCandidates,
|
|
176
|
+
createNormalizer: createOpenCodeSessionNormalizer,
|
|
177
|
+
sourceHashSeed: (metadata) => metadata.externalSessionId,
|
|
178
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SessionNormalizer } from "./format.js";
|
|
2
|
+
import type { AgentSessionMetadata } from "./types.js";
|
|
3
|
+
export type DecodedOpenCodeSession = Readonly<{
|
|
4
|
+
metadata: AgentSessionMetadata;
|
|
5
|
+
records: readonly unknown[];
|
|
6
|
+
}>;
|
|
7
|
+
export declare function decodeOpenCodeSessionExport(value: unknown): DecodedOpenCodeSession | null;
|
|
8
|
+
export declare function createOpenCodeSessionNormalizer(metadata?: AgentSessionMetadata): SessionNormalizer;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { isSessionRecord, sessionString, } from "./canonical.js";
|
|
2
|
+
import { containsInternalInstructionText, redactCredentialText, sanitizeSessionValue, sanitizeWorkingDirectory, } from "./redaction.js";
|
|
3
|
+
function unixMillisecondsDate(value) {
|
|
4
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
5
|
+
return null;
|
|
6
|
+
const date = new Date(value);
|
|
7
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
8
|
+
}
|
|
9
|
+
function messageTimestamp(message) {
|
|
10
|
+
const info = isSessionRecord(message.info) ? message.info : {};
|
|
11
|
+
const time = isSessionRecord(info.time) ? info.time : {};
|
|
12
|
+
return (unixMillisecondsDate(time.completed)?.toISOString() ??
|
|
13
|
+
unixMillisecondsDate(time.created)?.toISOString() ??
|
|
14
|
+
null);
|
|
15
|
+
}
|
|
16
|
+
export function decodeOpenCodeSessionExport(value) {
|
|
17
|
+
if (!isSessionRecord(value) || !isSessionRecord(value.info) || !Array.isArray(value.messages))
|
|
18
|
+
return null;
|
|
19
|
+
const externalSessionId = sessionString(value.info.id);
|
|
20
|
+
if (!externalSessionId)
|
|
21
|
+
return null;
|
|
22
|
+
const time = isSessionRecord(value.info.time) ? value.info.time : {};
|
|
23
|
+
const startedAt = unixMillisecondsDate(time.created);
|
|
24
|
+
const endedAt = unixMillisecondsDate(time.updated);
|
|
25
|
+
return {
|
|
26
|
+
metadata: {
|
|
27
|
+
externalSessionId,
|
|
28
|
+
timestamp: endedAt ?? startedAt,
|
|
29
|
+
cwd: sessionString(value.info.directory),
|
|
30
|
+
repositoryUrl: null,
|
|
31
|
+
repositoryBranch: null,
|
|
32
|
+
repositoryCommitSha: null,
|
|
33
|
+
originator: "opencode",
|
|
34
|
+
isSubagent: sessionString(value.info.parentID) !== null,
|
|
35
|
+
startedAt,
|
|
36
|
+
endedAt,
|
|
37
|
+
},
|
|
38
|
+
records: [
|
|
39
|
+
{
|
|
40
|
+
type: "opencode_session",
|
|
41
|
+
timestamp: startedAt?.toISOString() ?? null,
|
|
42
|
+
info: {
|
|
43
|
+
id: value.info.id,
|
|
44
|
+
directory: value.info.directory,
|
|
45
|
+
model: value.info.model,
|
|
46
|
+
version: value.info.version,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
...value.messages.map((message) => isSessionRecord(message)
|
|
50
|
+
? {
|
|
51
|
+
type: "opencode_message",
|
|
52
|
+
timestamp: messageTimestamp(message),
|
|
53
|
+
info: message.info,
|
|
54
|
+
parts: message.parts,
|
|
55
|
+
}
|
|
56
|
+
: message),
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function timestampIso(value) {
|
|
61
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
62
|
+
return null;
|
|
63
|
+
const date = new Date(value);
|
|
64
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
65
|
+
}
|
|
66
|
+
function modelName(value) {
|
|
67
|
+
if (typeof value === "string")
|
|
68
|
+
return value || null;
|
|
69
|
+
if (!isSessionRecord(value))
|
|
70
|
+
return null;
|
|
71
|
+
const provider = sessionString(value.providerID);
|
|
72
|
+
const model = sessionString(value.modelID) ?? sessionString(value.id);
|
|
73
|
+
return provider && model ? `${provider}/${model}` : model;
|
|
74
|
+
}
|
|
75
|
+
function containsInternalInstructions(value) {
|
|
76
|
+
if (typeof value === "string")
|
|
77
|
+
return containsInternalInstructionText(value);
|
|
78
|
+
if (Array.isArray(value))
|
|
79
|
+
return value.some(containsInternalInstructions);
|
|
80
|
+
if (!isSessionRecord(value))
|
|
81
|
+
return false;
|
|
82
|
+
return Object.values(value).some(containsInternalInstructions);
|
|
83
|
+
}
|
|
84
|
+
function redactHomePaths(value) {
|
|
85
|
+
if (typeof value === "string")
|
|
86
|
+
return value
|
|
87
|
+
.replace(/\/Users\/[^/\s"'`]+/g, "~")
|
|
88
|
+
.replace(/\/home\/[^/\s"'`]+/g, "~")
|
|
89
|
+
.replace(/[A-Za-z]:\\Users\\[^\\\s"'`]+/gi, "~");
|
|
90
|
+
if (Array.isArray(value))
|
|
91
|
+
return value.map(redactHomePaths);
|
|
92
|
+
if (!isSessionRecord(value))
|
|
93
|
+
return value;
|
|
94
|
+
return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, redactHomePaths(nested)]));
|
|
95
|
+
}
|
|
96
|
+
function sanitizeOpenCodeValue(value) {
|
|
97
|
+
return redactHomePaths(sanitizeSessionValue(value));
|
|
98
|
+
}
|
|
99
|
+
function safeValue(value) {
|
|
100
|
+
return containsInternalInstructions(value)
|
|
101
|
+
? "[REDACTED INTERNAL INSTRUCTIONS]"
|
|
102
|
+
: sanitizeOpenCodeValue(value);
|
|
103
|
+
}
|
|
104
|
+
function partTimestamp(part, key, fallback) {
|
|
105
|
+
const state = isSessionRecord(part.state) ? part.state : {};
|
|
106
|
+
const time = isSessionRecord(state.time) ? state.time : {};
|
|
107
|
+
return timestampIso(time[key]) ?? fallback;
|
|
108
|
+
}
|
|
109
|
+
function normalizeOpenCodeMessage(record, entryBudget) {
|
|
110
|
+
const info = isSessionRecord(record.info) ? record.info : null;
|
|
111
|
+
const role = info ? sessionString(info.role) : null;
|
|
112
|
+
if (!info || (role !== "user" && role !== "assistant") || !Array.isArray(record.parts))
|
|
113
|
+
return { entries: [], sensitive: false, unsupported: true };
|
|
114
|
+
const occurredAt = sessionString(record.timestamp);
|
|
115
|
+
const entries = [];
|
|
116
|
+
let sensitive = false;
|
|
117
|
+
let unsupported = false;
|
|
118
|
+
for (const part of record.parts) {
|
|
119
|
+
if (entries.length > entryBudget)
|
|
120
|
+
break;
|
|
121
|
+
if (!isSessionRecord(part)) {
|
|
122
|
+
unsupported = true;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const type = sessionString(part.type);
|
|
126
|
+
if (type === "reasoning" || type === "file" || type === "patch" || type === "snapshot") {
|
|
127
|
+
sensitive = true;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (type === "text") {
|
|
131
|
+
const text = sessionString(part.text);
|
|
132
|
+
if (!text || part.ignored === true)
|
|
133
|
+
continue;
|
|
134
|
+
if (part.synthetic === true || containsInternalInstructionText(text))
|
|
135
|
+
sensitive = true;
|
|
136
|
+
else
|
|
137
|
+
entries.push({
|
|
138
|
+
kind: role === "user" ? "user_message" : "assistant_message",
|
|
139
|
+
occurredAt,
|
|
140
|
+
payload: { text: sanitizeOpenCodeValue(text) },
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (type === "tool" && role === "assistant") {
|
|
145
|
+
const toolName = sessionString(part.tool);
|
|
146
|
+
const state = isSessionRecord(part.state) ? part.state : null;
|
|
147
|
+
if (!toolName || !state) {
|
|
148
|
+
unsupported = true;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const inputContainsInternalInstructions = containsInternalInstructions(state.input);
|
|
152
|
+
sensitive ||= inputContainsInternalInstructions;
|
|
153
|
+
entries.push({
|
|
154
|
+
kind: "tool_call",
|
|
155
|
+
occurredAt: partTimestamp(part, "start", occurredAt),
|
|
156
|
+
payload: {
|
|
157
|
+
callId: sessionString(part.callID),
|
|
158
|
+
toolName: redactCredentialText(toolName),
|
|
159
|
+
input: inputContainsInternalInstructions
|
|
160
|
+
? "[REDACTED INTERNAL INSTRUCTIONS]"
|
|
161
|
+
: sanitizeOpenCodeValue(state.input),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
const status = sessionString(state.status);
|
|
165
|
+
if (status === "completed" || status === "error") {
|
|
166
|
+
const output = status === "completed" ? state.output : state.error;
|
|
167
|
+
const outputContainsInternalInstructions = containsInternalInstructions(output);
|
|
168
|
+
sensitive ||= outputContainsInternalInstructions;
|
|
169
|
+
entries.push({
|
|
170
|
+
kind: "tool_result",
|
|
171
|
+
occurredAt: partTimestamp(part, "end", occurredAt),
|
|
172
|
+
payload: {
|
|
173
|
+
callId: sessionString(part.callID),
|
|
174
|
+
output: safeValue(output),
|
|
175
|
+
...(status === "error" ? { isError: true } : {}),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (type === "step-start" ||
|
|
182
|
+
type === "step-finish" ||
|
|
183
|
+
type === "retry" ||
|
|
184
|
+
type === "compaction")
|
|
185
|
+
continue;
|
|
186
|
+
if (type === "subtask" || type === "agent")
|
|
187
|
+
sensitive = true;
|
|
188
|
+
else
|
|
189
|
+
unsupported = true;
|
|
190
|
+
}
|
|
191
|
+
const overflowed = entries.length > entryBudget;
|
|
192
|
+
return {
|
|
193
|
+
entries: overflowed ? entries.slice(0, entryBudget) : entries,
|
|
194
|
+
sensitive,
|
|
195
|
+
unsupported,
|
|
196
|
+
overflowed,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
export function createOpenCodeSessionNormalizer(metadata) {
|
|
200
|
+
let model = null;
|
|
201
|
+
return {
|
|
202
|
+
normalizeRecord: (record, entryBudget = Number.POSITIVE_INFINITY) => {
|
|
203
|
+
if (record.type === "opencode_session") {
|
|
204
|
+
const info = isSessionRecord(record.info) ? record.info : {};
|
|
205
|
+
model ??= modelName(info.model);
|
|
206
|
+
return { entries: [], sensitive: false, unsupported: false };
|
|
207
|
+
}
|
|
208
|
+
if (record.type !== "opencode_message")
|
|
209
|
+
return { entries: [], sensitive: false, unsupported: true };
|
|
210
|
+
const info = isSessionRecord(record.info) ? record.info : {};
|
|
211
|
+
const provider = sessionString(info.providerID);
|
|
212
|
+
const modelId = sessionString(info.modelID);
|
|
213
|
+
model ??= provider && modelId ? `${provider}/${modelId}` : modelId;
|
|
214
|
+
return normalizeOpenCodeMessage(record, entryBudget);
|
|
215
|
+
},
|
|
216
|
+
canonicalMetadata: () => metadata
|
|
217
|
+
? {
|
|
218
|
+
type: "session",
|
|
219
|
+
version: 1,
|
|
220
|
+
externalSessionId: metadata.externalSessionId,
|
|
221
|
+
repositoryUrl: metadata.repositoryUrl,
|
|
222
|
+
repositoryBranch: metadata.repositoryBranch,
|
|
223
|
+
repositoryCommitSha: metadata.repositoryCommitSha,
|
|
224
|
+
workingDirectory: sanitizeWorkingDirectory(metadata.cwd),
|
|
225
|
+
originator: "opencode",
|
|
226
|
+
model: model ? redactCredentialText(model) : null,
|
|
227
|
+
}
|
|
228
|
+
: null,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SessionNormalizer } from "./format.js";
|
|
2
|
+
import type { AgentSessionMetadata } from "./types.js";
|
|
3
|
+
export type DecodedOpenCodeSession = Readonly<{
|
|
4
|
+
metadata: AgentSessionMetadata;
|
|
5
|
+
records: readonly unknown[];
|
|
6
|
+
}>;
|
|
7
|
+
export declare function parseOpenCodeSessionExport(value: unknown): DecodedOpenCodeSession | null;
|
|
8
|
+
export declare function createOpenCodeSessionNormalizer(metadata?: AgentSessionMetadata): SessionNormalizer;
|