dsh-reference-anything 0.3.0 → 0.3.2
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 +22 -22
- package/NOTICE.md +52 -25
- package/README.md +339 -286
- package/cordis.patch.yml +89 -18
- package/lib/client.js +13538 -591
- package/lib/contract.d.ts +155 -16
- package/lib/contract.js +68 -7
- package/lib/download-directory.d.ts +18 -0
- package/lib/download-directory.js +55 -0
- package/lib/errors.d.ts +4 -0
- package/lib/host.d.ts +96 -8
- package/lib/host.js +88 -19
- package/lib/index.d.ts +22 -2
- package/lib/index.js +57 -7
- package/lib/opencli.d.ts +5 -3
- package/lib/opencli.js +13 -6
- package/lib/openlist/host.d.ts +157 -0
- package/lib/openlist/host.js +873 -0
- package/lib/openlist/index.d.ts +84 -0
- package/lib/openlist/index.js +507 -0
- package/lib/render.d.ts +12 -7
- package/lib/render.js +27 -21
- package/lib/sources/cloud-drive/cache.d.ts +60 -0
- package/lib/sources/cloud-drive/cache.js +107 -0
- package/lib/sources/cloud-drive/index.d.ts +190 -0
- package/lib/sources/cloud-drive/index.js +526 -0
- package/lib/sources/cloud-drive/providers/http.d.ts +27 -0
- package/lib/sources/cloud-drive/providers/http.js +73 -0
- package/lib/sources/cloud-drive/providers/openlist.d.ts +59 -0
- package/lib/sources/cloud-drive/providers/openlist.js +535 -0
- package/lib/sources/cloud-drive/registry.d.ts +53 -0
- package/lib/sources/cloud-drive/registry.js +101 -0
- package/lib/sources/cloud-drive/types.d.ts +169 -0
- package/lib/sources/cloud-drive/types.js +17 -0
- package/lib/sources/file.d.ts +2 -0
- package/lib/sources/file.js +2 -0
- package/lib/sources/local-agent/adapters/claude-code.d.ts +16 -0
- package/lib/sources/local-agent/adapters/claude-code.js +171 -0
- package/lib/sources/local-agent/adapters/codex.d.ts +20 -0
- package/lib/sources/local-agent/adapters/codex.js +204 -0
- package/lib/sources/local-agent/adapters/cursor.d.ts +23 -0
- package/lib/sources/local-agent/adapters/cursor.js +121 -0
- package/lib/sources/local-agent/adapters/gemini-cli.d.ts +20 -0
- package/lib/sources/local-agent/adapters/gemini-cli.js +141 -0
- package/lib/sources/local-agent/adapters/grokbuild.d.ts +22 -0
- package/lib/sources/local-agent/adapters/grokbuild.js +76 -0
- package/lib/sources/local-agent/adapters/hermes.d.ts +19 -0
- package/lib/sources/local-agent/adapters/hermes.js +85 -0
- package/lib/sources/local-agent/adapters/kimi.d.ts +28 -0
- package/lib/sources/local-agent/adapters/kimi.js +261 -0
- package/lib/sources/local-agent/adapters/openclaw.d.ts +21 -0
- package/lib/sources/local-agent/adapters/openclaw.js +120 -0
- package/lib/sources/local-agent/adapters/opencode.d.ts +44 -0
- package/lib/sources/local-agent/adapters/opencode.js +236 -0
- package/lib/sources/local-agent/adapters/pi.d.ts +26 -0
- package/lib/sources/local-agent/adapters/pi.js +242 -0
- package/lib/sources/local-agent/adapters/qoder.d.ts +19 -0
- package/lib/sources/local-agent/adapters/qoder.js +93 -0
- package/lib/sources/local-agent/adapters/reasonix.d.ts +23 -0
- package/lib/sources/local-agent/adapters/reasonix.js +90 -0
- package/lib/sources/local-agent/adapters/shared.d.ts +163 -0
- package/lib/sources/local-agent/adapters/shared.js +415 -0
- package/lib/sources/local-agent/adapters/sqlite-shared.d.ts +92 -0
- package/lib/sources/local-agent/adapters/sqlite-shared.js +215 -0
- package/lib/sources/local-agent/adapters/zcode.d.ts +41 -0
- package/lib/sources/local-agent/adapters/zcode.js +159 -0
- package/lib/sources/local-agent/index.d.ts +222 -0
- package/lib/sources/local-agent/index.js +656 -0
- package/lib/sources/local-agent/page.d.ts +55 -0
- package/lib/sources/local-agent/page.js +83 -0
- package/lib/sources/local-agent/path.d.ts +8 -0
- package/lib/sources/local-agent/path.js +43 -0
- package/lib/sources/local-agent/registry.d.ts +68 -0
- package/lib/sources/local-agent/registry.js +116 -0
- package/lib/sources/local-agent/scan.d.ts +175 -0
- package/lib/sources/local-agent/scan.js +472 -0
- package/lib/sources/local-agent/sqlite.d.ts +45 -0
- package/lib/sources/local-agent/sqlite.js +113 -0
- package/lib/sources/local-agent/store.d.ts +93 -0
- package/lib/sources/local-agent/store.js +113 -0
- package/lib/sources/local-agent/types.d.ts +338 -0
- package/lib/sources/local-agent/types.js +20 -0
- package/lib/sources/web-chat/index.d.ts +5 -1
- package/lib/sources/web-chat/index.js +47 -8
- package/lib/store/spec.d.ts +50 -4
- package/lib/store/spec.js +2 -2
- package/lib/sync/index.d.ts +1 -0
- package/lib/sync/index.js +28 -8
- package/lib/tool.d.ts +5 -0
- package/lib/tool.js +418 -45
- package/lib/typert.js +13 -2
- package/lib/types.d.ts +28 -2
- package/lib/update.d.ts +6 -0
- package/lib/update.js +29 -1
- package/lib/web.js +5 -1
- package/lib/wire.d.ts +80 -9
- package/lib/wire.js +30 -4
- package/opencli-plugin/chatgpt.js +30 -27
- package/opencli-plugin/claude.js +12 -8
- package/opencli-plugin/common.js +266 -187
- package/opencli-plugin/deepseek.js +8 -5
- package/opencli-plugin/gemini.js +42 -38
- package/opencli-plugin/grok.js +22 -21
- package/opencli-plugin/kimi.js +34 -31
- package/opencli-plugin/opencli-plugin.json +6 -6
- package/opencli-plugin/package.json +8 -8
- package/package.json +182 -167
- package/lib/profile-mode.d.ts +0 -30
- package/lib/profile-mode.js +0 -90
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reasonix sessions: `~/.reasonix/sessions/<stem>.jsonl`.
|
|
3
|
+
*
|
|
4
|
+
* Records are OpenAI chat messages written straight to disk with no envelope
|
|
5
|
+
* — `{role, content}` and nothing wrapping it — which makes this the one
|
|
6
|
+
* supported format whose lines would be valid request payloads as they stand.
|
|
7
|
+
*
|
|
8
|
+
* Two versions are in the wild and both are accepted: v1 nests a tool call
|
|
9
|
+
* under `function: {name, arguments}`, v2 flattens it to `{name, arguments}`.
|
|
10
|
+
* `arguments` is a JSON *string* in both, which the shared renderer handles.
|
|
11
|
+
*
|
|
12
|
+
* Sibling files share the directory — `<stem>.meta.json` holds the workspace
|
|
13
|
+
* and summary, `<stem>.events.jsonl` is a write-ahead log replayed over the
|
|
14
|
+
* checkpoint — and neither is a transcript, so both are rejected by path. The
|
|
15
|
+
* WAL means a session's newest turns can be missing from the file this reads;
|
|
16
|
+
* that is a staleness bound, not a correctness one, and it resolves itself the
|
|
17
|
+
* next time Reasonix checkpoints.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-reference-anything/local-agent/adapters/reasonix
|
|
20
|
+
*/
|
|
21
|
+
import { joinLocalPath } from "../path.js";
|
|
22
|
+
import { arrayField, contentBlockUserText, createSharedState, emitUser, flushAssistant, normalizeTitle, objectField, parseRecord, parseTimestamp, pushAssistant, renderToolCall, renderToolResult, stringField, } from "./shared.js";
|
|
23
|
+
/** Reads Reasonix session transcripts. */
|
|
24
|
+
export const reasonixAdapter = {
|
|
25
|
+
kind: 'reasonix',
|
|
26
|
+
displayName: 'Reasonix',
|
|
27
|
+
defaultRoots(home) {
|
|
28
|
+
return [joinLocalPath(home, '.reasonix', 'sessions')];
|
|
29
|
+
},
|
|
30
|
+
matches(relativePath) {
|
|
31
|
+
const path = relativePath.toLowerCase();
|
|
32
|
+
return path.endsWith('.jsonl') && !path.endsWith('.events.jsonl');
|
|
33
|
+
},
|
|
34
|
+
createState() {
|
|
35
|
+
return createSharedState();
|
|
36
|
+
},
|
|
37
|
+
step(line, state, options) {
|
|
38
|
+
const record = parseRecord(line);
|
|
39
|
+
if (record === undefined)
|
|
40
|
+
return [];
|
|
41
|
+
if (record['role'] === 'user') {
|
|
42
|
+
const text = contentBlockUserText(record['content']);
|
|
43
|
+
return text === undefined ? [] : emitUser(state, text);
|
|
44
|
+
}
|
|
45
|
+
if (record['role'] === 'tool') {
|
|
46
|
+
pushAssistant(state, renderToolResult(record['content'], options.toolResults, options.toolSummaryChars));
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
if (record['role'] !== 'assistant')
|
|
50
|
+
return [];
|
|
51
|
+
pushAssistant(state, contentBlockUserText(record['content'], false));
|
|
52
|
+
if (options.includeThinking)
|
|
53
|
+
pushAssistant(state, stringField(record, 'reasoning_content'));
|
|
54
|
+
for (const call of arrayField(record, 'tool_calls') ?? []) {
|
|
55
|
+
if (typeof call !== 'object' || call === null)
|
|
56
|
+
continue;
|
|
57
|
+
const entry = call;
|
|
58
|
+
// v1 nests under `function`, v2 is flat. Reading the nested shape first
|
|
59
|
+
// means a v1 record never falls through to v2's undefined fields.
|
|
60
|
+
const fn = objectField(entry, 'function') ?? entry;
|
|
61
|
+
pushAssistant(state, renderToolCall(fn['name'], fn['arguments'], options.toolCalls, options.toolSummaryChars));
|
|
62
|
+
}
|
|
63
|
+
return [];
|
|
64
|
+
},
|
|
65
|
+
flush(state, _options) {
|
|
66
|
+
return flushAssistant(state);
|
|
67
|
+
},
|
|
68
|
+
head(headLines, _tailLines) {
|
|
69
|
+
let firstPrompt;
|
|
70
|
+
let createdAt;
|
|
71
|
+
for (const line of headLines) {
|
|
72
|
+
const record = parseRecord(line);
|
|
73
|
+
if (record === undefined)
|
|
74
|
+
continue;
|
|
75
|
+
createdAt ??= parseTimestamp(record['createdAt']);
|
|
76
|
+
if (firstPrompt === undefined && record['role'] === 'user') {
|
|
77
|
+
firstPrompt = contentBlockUserText(record['content']);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// The transcript carries no title of its own — Reasonix keeps the summary
|
|
81
|
+
// in the sibling `.meta.json`, which a probe of this file cannot see — so
|
|
82
|
+
// the opening prompt names the session.
|
|
83
|
+
const title = normalizeTitle(firstPrompt ?? '');
|
|
84
|
+
return {
|
|
85
|
+
...title === '' ? {} : { title },
|
|
86
|
+
...firstPrompt === undefined ? {} : { firstPrompt },
|
|
87
|
+
...createdAt === undefined ? {} : { createdAt },
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection rules every transcript adapter needs, in one place.
|
|
3
|
+
*
|
|
4
|
+
* These are the parts of the fold that are about *conversations* rather than
|
|
5
|
+
* about any one agent's record shape — merging an assistant run, deciding what
|
|
6
|
+
* a tool call looks like, telling a person's words from the harness's. Keeping
|
|
7
|
+
* them here is what makes two adapters agree on what a "turn" is.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-reference-anything/local-agent/adapters/shared
|
|
10
|
+
*/
|
|
11
|
+
import type { AdapterState, ConvertOptions, ParsedTurn, ToolCallMode } from '../types.ts';
|
|
12
|
+
/**
|
|
13
|
+
* Precedes the text an agent left in place of the turns a compaction discarded.
|
|
14
|
+
*
|
|
15
|
+
* Shared rather than restated per adapter because it is a contract with the
|
|
16
|
+
* reader, not a detail of any one format: four agents compact, and a model that
|
|
17
|
+
* learned what the marker means in one transcript must not meet a different
|
|
18
|
+
* wording in the next.
|
|
19
|
+
*/
|
|
20
|
+
export declare const COMPACTION_MARKER = "[compacted summary of the earlier conversation]";
|
|
21
|
+
/** Longest title kept; past this a name stops being scannable in a menu row. */
|
|
22
|
+
export declare const TITLE_MAX_CHARS = 80;
|
|
23
|
+
/**
|
|
24
|
+
* Collapse a candidate title to one scannable line.
|
|
25
|
+
* @param text - raw title text from any field.
|
|
26
|
+
* @returns the normalized title, or an empty string when nothing survived.
|
|
27
|
+
*/
|
|
28
|
+
export declare function normalizeTitle(text: unknown): string;
|
|
29
|
+
/**
|
|
30
|
+
* Remove harness-injected wrappers from a message.
|
|
31
|
+
* @param text - the raw message text.
|
|
32
|
+
* @returns the surviving text, and whether anything was removed.
|
|
33
|
+
*/
|
|
34
|
+
export declare function stripHarnessBlocks(text: string): {
|
|
35
|
+
readonly text: string;
|
|
36
|
+
readonly stripped: boolean;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Reduce one user message to the words a person actually typed.
|
|
40
|
+
*
|
|
41
|
+
* Codex opens a session by sending the repository's `AGENTS.md` as a user
|
|
42
|
+
* message: a markdown heading naming the file, then the whole file inside an
|
|
43
|
+
* `<INSTRUCTIONS>` block. Measured over 60 rollouts, stripping the block alone
|
|
44
|
+
* leaves exactly that orphaned heading — which then became the rollout's title
|
|
45
|
+
* for 58 of them. So a heading left standing alone by a stripped block goes too.
|
|
46
|
+
* @param text - the raw message text.
|
|
47
|
+
* @returns the user's words, or an empty string when the message was all machinery.
|
|
48
|
+
*/
|
|
49
|
+
export declare function cleanUserText(text: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Render one tool call at the configured fidelity.
|
|
52
|
+
* @param name - the tool's name; a missing one still records that a call happened.
|
|
53
|
+
* @param input - the call's arguments, as the transcript recorded them.
|
|
54
|
+
* @param mode - how much detail to keep.
|
|
55
|
+
* @param summaryChars - cap for `'summarize'`.
|
|
56
|
+
* @returns the rendered line, or undefined when the mode drops calls.
|
|
57
|
+
*/
|
|
58
|
+
export declare function renderToolCall(name: unknown, input: unknown, mode: ToolCallMode, summaryChars: number): string | undefined;
|
|
59
|
+
/** Render one tool result with the same fidelity policy as its call. */
|
|
60
|
+
export declare function renderToolResult(output: unknown, mode: ToolCallMode, summaryChars: number): string | undefined;
|
|
61
|
+
/** Append Anthropic-style tool_result blocks attributed to the user role. */
|
|
62
|
+
export declare function pushToolResults(state: AdapterState, blocks: unknown, options: ConvertOptions): void;
|
|
63
|
+
/** Fresh state for one file. */
|
|
64
|
+
export declare function createSharedState(): AdapterState;
|
|
65
|
+
/**
|
|
66
|
+
* Hold one raw record for a format that cannot fold until the file ends.
|
|
67
|
+
* @param state - fold state, mutated in place.
|
|
68
|
+
* @param line - the raw line, kept verbatim so `flush` can reassemble the document.
|
|
69
|
+
* @returns nothing, always — a held record completes no turn by definition.
|
|
70
|
+
*/
|
|
71
|
+
export declare function holdLine(state: AdapterState, line: string): readonly ParsedTurn[];
|
|
72
|
+
/**
|
|
73
|
+
* Take everything held so far as one document, leaving the state empty.
|
|
74
|
+
* @param state - fold state, mutated in place.
|
|
75
|
+
* @returns the reassembled text, or undefined when nothing was held.
|
|
76
|
+
*/
|
|
77
|
+
export declare function takeHeld(state: AdapterState): string | undefined;
|
|
78
|
+
/**
|
|
79
|
+
* Parse a whole JSON document, tolerating the ways a probe can truncate one.
|
|
80
|
+
* @param text - the document, or a prefix of it.
|
|
81
|
+
* @returns the parsed value, or undefined when it was not complete JSON.
|
|
82
|
+
*/
|
|
83
|
+
export declare function parseDocument(text: string | undefined): unknown;
|
|
84
|
+
/**
|
|
85
|
+
* Fold an Anthropic-style content-block array into the open assistant run.
|
|
86
|
+
*
|
|
87
|
+
* Four of the supported agents adopted the Messages API's block shape
|
|
88
|
+
* verbatim — `{type: 'text' | 'thinking' | 'tool_use' | 'tool_result', …}` —
|
|
89
|
+
* so the projection is shared rather than copied per adapter. Aliases that a
|
|
90
|
+
* particular harness uses for plain text (`input_text`, `output_text`) are
|
|
91
|
+
* accepted here too, because rejecting them would silently drop replies.
|
|
92
|
+
* @param state - fold state, mutated in place.
|
|
93
|
+
* @param blocks - the content array; a non-array is ignored.
|
|
94
|
+
* @param options - projection settings.
|
|
95
|
+
*/
|
|
96
|
+
export declare function pushContentBlocks(state: AdapterState, blocks: unknown, options: ConvertOptions): void;
|
|
97
|
+
/**
|
|
98
|
+
* The words a person typed, from an Anthropic-style message content value.
|
|
99
|
+
*
|
|
100
|
+
* A string body is always a real turn. An array body is usually a batch of
|
|
101
|
+
* `tool_result` blocks the harness attributes to the user role — plumbing —
|
|
102
|
+
* but occasionally carries `text` blocks, such as the note left when someone
|
|
103
|
+
* interrupts a tool call. So the discriminator is the block type, not the
|
|
104
|
+
* container type.
|
|
105
|
+
* @param content - the `content` field, of unknown shape.
|
|
106
|
+
* @param clean - whether to strip harness-injected wrappers.
|
|
107
|
+
* @returns the user's words, or undefined when the record is not a turn.
|
|
108
|
+
*/
|
|
109
|
+
export declare function contentBlockUserText(content: unknown, clean?: boolean): string | undefined;
|
|
110
|
+
/**
|
|
111
|
+
* Normalize a message body and collapse an empty result to `undefined`.
|
|
112
|
+
* @param text - the raw body.
|
|
113
|
+
* @param clean - whether to strip harness-injected wrappers first.
|
|
114
|
+
* @returns the surviving text, or undefined when nothing survived.
|
|
115
|
+
*/
|
|
116
|
+
export declare function blankToUndefined(text: string, clean?: boolean): string | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* Buffer one fragment of the assistant run currently open.
|
|
119
|
+
* @param state - fold state, mutated in place.
|
|
120
|
+
* @param text - the fragment; blank fragments are ignored.
|
|
121
|
+
*/
|
|
122
|
+
export declare function pushAssistant(state: AdapterState, text: string | undefined): void;
|
|
123
|
+
/**
|
|
124
|
+
* Close the assistant run, if one is open.
|
|
125
|
+
*
|
|
126
|
+
* Agents emit an assistant reply as a run of separate records — text, then
|
|
127
|
+
* reasoning, then one per tool call — so the run is what corresponds to a turn
|
|
128
|
+
* a person would recognize. Joining with a blank line keeps the fragments
|
|
129
|
+
* legible without inventing structure the transcript did not have.
|
|
130
|
+
* @param state - fold state, mutated in place.
|
|
131
|
+
* @returns the completed turn, or nothing when no run was open.
|
|
132
|
+
*/
|
|
133
|
+
export declare function flushAssistant(state: AdapterState): readonly ParsedTurn[];
|
|
134
|
+
/**
|
|
135
|
+
* Close any open assistant run and open a user turn after it.
|
|
136
|
+
* @param state - fold state, mutated in place.
|
|
137
|
+
* @param text - the user's words; a blank message yields no turn.
|
|
138
|
+
* @returns the closed assistant run followed by the user turn.
|
|
139
|
+
*/
|
|
140
|
+
export declare function emitUser(state: AdapterState, text: string): readonly ParsedTurn[];
|
|
141
|
+
/**
|
|
142
|
+
* Parse one JSONL record.
|
|
143
|
+
*
|
|
144
|
+
* A malformed line is an ordinary state, not a failure: the agent that owns
|
|
145
|
+
* the file may be mid-write on its last line while this one reads it.
|
|
146
|
+
* @param line - one raw line.
|
|
147
|
+
* @returns the record, or undefined when the line is not a JSON object.
|
|
148
|
+
*/
|
|
149
|
+
export declare function parseRecord(line: string): Record<string, unknown> | undefined;
|
|
150
|
+
/** Read a string field, treating a blank one as absent. */
|
|
151
|
+
export declare function stringField(record: Record<string, unknown> | undefined, key: string): string | undefined;
|
|
152
|
+
/** Read a nested object field. */
|
|
153
|
+
export declare function objectField(record: Record<string, unknown> | undefined, key: string): Record<string, unknown> | undefined;
|
|
154
|
+
/** Read an array field. */
|
|
155
|
+
export declare function arrayField(record: Record<string, unknown> | undefined, key: string): unknown[] | undefined;
|
|
156
|
+
/**
|
|
157
|
+
* Parse an ISO timestamp to epoch milliseconds.
|
|
158
|
+
* @param value - the raw field, of unknown type.
|
|
159
|
+
* @returns the timestamp, or undefined when it is not a usable date.
|
|
160
|
+
*/
|
|
161
|
+
export declare function parseTimestamp(value: unknown): number | undefined;
|
|
162
|
+
/** Default projection settings, used by tests and by any caller that has no config. */
|
|
163
|
+
export declare const DEFAULT_CONVERT_OPTIONS: ConvertOptions;
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Projection rules every transcript adapter needs, in one place.
|
|
3
|
+
*
|
|
4
|
+
* These are the parts of the fold that are about *conversations* rather than
|
|
5
|
+
* about any one agent's record shape — merging an assistant run, deciding what
|
|
6
|
+
* a tool call looks like, telling a person's words from the harness's. Keeping
|
|
7
|
+
* them here is what makes two adapters agree on what a "turn" is.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-reference-anything/local-agent/adapters/shared
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Precedes the text an agent left in place of the turns a compaction discarded.
|
|
13
|
+
*
|
|
14
|
+
* Shared rather than restated per adapter because it is a contract with the
|
|
15
|
+
* reader, not a detail of any one format: four agents compact, and a model that
|
|
16
|
+
* learned what the marker means in one transcript must not meet a different
|
|
17
|
+
* wording in the next.
|
|
18
|
+
*/
|
|
19
|
+
export const COMPACTION_MARKER = '[compacted summary of the earlier conversation]';
|
|
20
|
+
/** Longest title kept; past this a name stops being scannable in a menu row. */
|
|
21
|
+
export const TITLE_MAX_CHARS = 80;
|
|
22
|
+
/** Marks a title that was cut, so a truncated name never reads as the whole one. */
|
|
23
|
+
const ELLIPSIS = '…';
|
|
24
|
+
/**
|
|
25
|
+
* Collapse a candidate title to one scannable line.
|
|
26
|
+
* @param text - raw title text from any field.
|
|
27
|
+
* @returns the normalized title, or an empty string when nothing survived.
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeTitle(text) {
|
|
30
|
+
const collapsed = String(text ?? '').replace(/\s+/gu, ' ').trim();
|
|
31
|
+
if (collapsed.length <= TITLE_MAX_CHARS)
|
|
32
|
+
return collapsed;
|
|
33
|
+
return collapsed.slice(0, TITLE_MAX_CHARS - ELLIPSIS.length) + ELLIPSIS;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Wrappers a harness is known to inject around, or instead of, a person's words.
|
|
37
|
+
*
|
|
38
|
+
* Listed by name so a single-line one — `<system-reminder>` arrives appended to
|
|
39
|
+
* the end of a real message — is still recognized. Anything not on this list
|
|
40
|
+
* has to look like an injection structurally; see {@link HARNESS_TAG_SHAPE}.
|
|
41
|
+
*/
|
|
42
|
+
const KNOWN_HARNESS_TAGS = new Set([
|
|
43
|
+
'system-reminder',
|
|
44
|
+
'command-name',
|
|
45
|
+
'command-message',
|
|
46
|
+
'command-args',
|
|
47
|
+
'command-contents',
|
|
48
|
+
'environment_context',
|
|
49
|
+
'user_instructions',
|
|
50
|
+
'skills_instructions',
|
|
51
|
+
'recommended_plugins',
|
|
52
|
+
'turn_aborted',
|
|
53
|
+
'INSTRUCTIONS',
|
|
54
|
+
]);
|
|
55
|
+
/**
|
|
56
|
+
* What an injected tag name looks like when it is not on the list.
|
|
57
|
+
*
|
|
58
|
+
* Harness tags are multi-word identifiers — `recommended_plugins`,
|
|
59
|
+
* `system-reminder` — or shouted ones like `INSTRUCTIONS`. Ordinary markup a
|
|
60
|
+
* person might paste into a question is a single lowercase word: `div`, `p`,
|
|
61
|
+
* `pre`, `code`. Splitting on that shape is what lets an unknown injection be
|
|
62
|
+
* stripped the day it appears without eating a web developer's example.
|
|
63
|
+
*/
|
|
64
|
+
const HARNESS_TAG_SHAPE = /^(?:[a-z][a-z0-9]*(?:[_-][a-z0-9]+)+|[A-Z][A-Z0-9_]*)$/u;
|
|
65
|
+
/** A balanced, attribute-free element and the whitespace after it. */
|
|
66
|
+
const BLOCK = /<([A-Za-z_][\w.-]*)>[\s\S]*?<\/\1>[ \t]*\n?/gu;
|
|
67
|
+
/** A residue that is only a heading, left behind when its body was an injection. */
|
|
68
|
+
const LONE_HEADING = /^#{1,6}[ \t][^\n]*$/u;
|
|
69
|
+
/**
|
|
70
|
+
* Remove harness-injected wrappers from a message.
|
|
71
|
+
* @param text - the raw message text.
|
|
72
|
+
* @returns the surviving text, and whether anything was removed.
|
|
73
|
+
*/
|
|
74
|
+
export function stripHarnessBlocks(text) {
|
|
75
|
+
let stripped = false;
|
|
76
|
+
const survived = text.replace(BLOCK, (match, tag) => {
|
|
77
|
+
// An unknown tag has to be both injection-shaped and multi-line: a
|
|
78
|
+
// one-line `<my-component>x</my-component>` in a question is not markup
|
|
79
|
+
// this package gets to delete.
|
|
80
|
+
if (!KNOWN_HARNESS_TAGS.has(tag) && !(HARNESS_TAG_SHAPE.test(tag) && match.includes('\n')))
|
|
81
|
+
return match;
|
|
82
|
+
stripped = true;
|
|
83
|
+
return '';
|
|
84
|
+
});
|
|
85
|
+
return { text: survived.trim(), stripped };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Reduce one user message to the words a person actually typed.
|
|
89
|
+
*
|
|
90
|
+
* Codex opens a session by sending the repository's `AGENTS.md` as a user
|
|
91
|
+
* message: a markdown heading naming the file, then the whole file inside an
|
|
92
|
+
* `<INSTRUCTIONS>` block. Measured over 60 rollouts, stripping the block alone
|
|
93
|
+
* leaves exactly that orphaned heading — which then became the rollout's title
|
|
94
|
+
* for 58 of them. So a heading left standing alone by a stripped block goes too.
|
|
95
|
+
* @param text - the raw message text.
|
|
96
|
+
* @returns the user's words, or an empty string when the message was all machinery.
|
|
97
|
+
*/
|
|
98
|
+
export function cleanUserText(text) {
|
|
99
|
+
const { text: body, stripped } = stripHarnessBlocks(text);
|
|
100
|
+
if (stripped && LONE_HEADING.test(body))
|
|
101
|
+
return '';
|
|
102
|
+
return body;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Render one tool call at the configured fidelity.
|
|
106
|
+
* @param name - the tool's name; a missing one still records that a call happened.
|
|
107
|
+
* @param input - the call's arguments, as the transcript recorded them.
|
|
108
|
+
* @param mode - how much detail to keep.
|
|
109
|
+
* @param summaryChars - cap for `'summarize'`.
|
|
110
|
+
* @returns the rendered line, or undefined when the mode drops calls.
|
|
111
|
+
*/
|
|
112
|
+
export function renderToolCall(name, input, mode, summaryChars) {
|
|
113
|
+
if (mode === 'drop')
|
|
114
|
+
return undefined;
|
|
115
|
+
const label = typeof name === 'string' && name.trim() !== '' ? name.trim() : 'unknown';
|
|
116
|
+
if (mode === 'elide')
|
|
117
|
+
return `[tool: ${label}]`;
|
|
118
|
+
if (mode === 'full') {
|
|
119
|
+
const detail = fullInput(input);
|
|
120
|
+
return detail === '' ? `[tool: ${label}]` : `[tool: ${label}] ${detail}`;
|
|
121
|
+
}
|
|
122
|
+
const detail = summarizeInput(input, summaryChars);
|
|
123
|
+
return detail === '' ? `[tool: ${label}]` : `[tool: ${label}] ${detail}`;
|
|
124
|
+
}
|
|
125
|
+
/** Render one tool result with the same fidelity policy as its call. */
|
|
126
|
+
export function renderToolResult(output, mode, summaryChars) {
|
|
127
|
+
if (mode === 'drop')
|
|
128
|
+
return undefined;
|
|
129
|
+
if (mode === 'elide')
|
|
130
|
+
return '[tool output]';
|
|
131
|
+
const detail = mode === 'full' ? fullInput(output) : summarizeInput(output, summaryChars);
|
|
132
|
+
return detail === '' ? '[tool output]' : `[tool output] ${detail}`;
|
|
133
|
+
}
|
|
134
|
+
/** Append Anthropic-style tool_result blocks attributed to the user role. */
|
|
135
|
+
export function pushToolResults(state, blocks, options) {
|
|
136
|
+
if (!Array.isArray(blocks))
|
|
137
|
+
return;
|
|
138
|
+
for (const block of blocks) {
|
|
139
|
+
if (typeof block !== 'object' || block === null)
|
|
140
|
+
continue;
|
|
141
|
+
const entry = block;
|
|
142
|
+
if (entry['type'] === 'tool_result') {
|
|
143
|
+
pushAssistant(state, renderToolResult(entry['content'], options.toolResults, options.toolSummaryChars));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Serialize a call's complete argument payload without truncating or flattening it. */
|
|
148
|
+
function fullInput(input) {
|
|
149
|
+
if (input === undefined || input === null)
|
|
150
|
+
return '';
|
|
151
|
+
return typeof input === 'string' ? input : safeStringify(input);
|
|
152
|
+
}
|
|
153
|
+
/** Flatten a call's arguments to one bounded line. */
|
|
154
|
+
function summarizeInput(input, summaryChars) {
|
|
155
|
+
if (input === undefined || input === null)
|
|
156
|
+
return '';
|
|
157
|
+
const raw = typeof input === 'string' ? input : safeStringify(input);
|
|
158
|
+
const collapsed = raw.replace(/\s+/gu, ' ').trim();
|
|
159
|
+
if (collapsed.length <= summaryChars)
|
|
160
|
+
return collapsed;
|
|
161
|
+
return collapsed.slice(0, Math.max(0, summaryChars - ELLIPSIS.length)) + ELLIPSIS;
|
|
162
|
+
}
|
|
163
|
+
/** JSON that cannot throw: a cyclic or exotic value degrades to its type name. */
|
|
164
|
+
function safeStringify(value) {
|
|
165
|
+
try {
|
|
166
|
+
return JSON.stringify(value) ?? '';
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return `[${typeof value}]`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Fresh state for one file. */
|
|
173
|
+
export function createSharedState() {
|
|
174
|
+
return { pending: [], held: [], seen: new Set(), compacted: false };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Hold one raw record for a format that cannot fold until the file ends.
|
|
178
|
+
* @param state - fold state, mutated in place.
|
|
179
|
+
* @param line - the raw line, kept verbatim so `flush` can reassemble the document.
|
|
180
|
+
* @returns nothing, always — a held record completes no turn by definition.
|
|
181
|
+
*/
|
|
182
|
+
export function holdLine(state, line) {
|
|
183
|
+
state.held.push(line);
|
|
184
|
+
return [];
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Take everything held so far as one document, leaving the state empty.
|
|
188
|
+
* @param state - fold state, mutated in place.
|
|
189
|
+
* @returns the reassembled text, or undefined when nothing was held.
|
|
190
|
+
*/
|
|
191
|
+
export function takeHeld(state) {
|
|
192
|
+
if (state.held.length === 0)
|
|
193
|
+
return undefined;
|
|
194
|
+
const text = state.held.join('\n');
|
|
195
|
+
state.held.length = 0;
|
|
196
|
+
return text;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Parse a whole JSON document, tolerating the ways a probe can truncate one.
|
|
200
|
+
* @param text - the document, or a prefix of it.
|
|
201
|
+
* @returns the parsed value, or undefined when it was not complete JSON.
|
|
202
|
+
*/
|
|
203
|
+
export function parseDocument(text) {
|
|
204
|
+
if (text === undefined)
|
|
205
|
+
return undefined;
|
|
206
|
+
const trimmed = text.trim();
|
|
207
|
+
if (trimmed === '')
|
|
208
|
+
return undefined;
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(trimmed);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// A prefix of a JSON document is not a smaller document. The caller
|
|
214
|
+
// degrades to a filename label rather than guessing at the missing half.
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Fold an Anthropic-style content-block array into the open assistant run.
|
|
220
|
+
*
|
|
221
|
+
* Four of the supported agents adopted the Messages API's block shape
|
|
222
|
+
* verbatim — `{type: 'text' | 'thinking' | 'tool_use' | 'tool_result', …}` —
|
|
223
|
+
* so the projection is shared rather than copied per adapter. Aliases that a
|
|
224
|
+
* particular harness uses for plain text (`input_text`, `output_text`) are
|
|
225
|
+
* accepted here too, because rejecting them would silently drop replies.
|
|
226
|
+
* @param state - fold state, mutated in place.
|
|
227
|
+
* @param blocks - the content array; a non-array is ignored.
|
|
228
|
+
* @param options - projection settings.
|
|
229
|
+
*/
|
|
230
|
+
export function pushContentBlocks(state, blocks, options) {
|
|
231
|
+
if (!Array.isArray(blocks))
|
|
232
|
+
return;
|
|
233
|
+
for (const block of blocks) {
|
|
234
|
+
if (typeof block !== 'object' || block === null)
|
|
235
|
+
continue;
|
|
236
|
+
const entry = block;
|
|
237
|
+
switch (entry['type']) {
|
|
238
|
+
case 'text':
|
|
239
|
+
case 'input_text':
|
|
240
|
+
case 'output_text':
|
|
241
|
+
pushAssistant(state, typeof entry['text'] === 'string' ? entry['text'] : undefined);
|
|
242
|
+
break;
|
|
243
|
+
case 'thinking':
|
|
244
|
+
case 'reasoning':
|
|
245
|
+
if (options.includeThinking) {
|
|
246
|
+
const thought = entry['thinking'] ?? entry['text'] ?? entry['reasoning'];
|
|
247
|
+
pushAssistant(state, typeof thought === 'string' ? thought : undefined);
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
case 'tool_use':
|
|
251
|
+
case 'toolCall':
|
|
252
|
+
case 'function_call':
|
|
253
|
+
pushAssistant(state, renderToolCall(entry['name'],
|
|
254
|
+
// Pi names the argument bag `arguments`; the Messages API names it
|
|
255
|
+
// `input`. Accepting both costs one `??` and saves a whole adapter.
|
|
256
|
+
entry['input'] ?? entry['arguments'], options.toolCalls, options.toolSummaryChars));
|
|
257
|
+
break;
|
|
258
|
+
case 'tool_result':
|
|
259
|
+
pushAssistant(state, renderToolResult(entry['content'], options.toolResults, options.toolSummaryChars));
|
|
260
|
+
break;
|
|
261
|
+
default:
|
|
262
|
+
// `redacted_thinking`, images, and whatever a later
|
|
263
|
+
// build adds carry nothing this projection can use. Skipping silently
|
|
264
|
+
// is what keeps a new block type from breaking an old adapter.
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The words a person typed, from an Anthropic-style message content value.
|
|
271
|
+
*
|
|
272
|
+
* A string body is always a real turn. An array body is usually a batch of
|
|
273
|
+
* `tool_result` blocks the harness attributes to the user role — plumbing —
|
|
274
|
+
* but occasionally carries `text` blocks, such as the note left when someone
|
|
275
|
+
* interrupts a tool call. So the discriminator is the block type, not the
|
|
276
|
+
* container type.
|
|
277
|
+
* @param content - the `content` field, of unknown shape.
|
|
278
|
+
* @param clean - whether to strip harness-injected wrappers.
|
|
279
|
+
* @returns the user's words, or undefined when the record is not a turn.
|
|
280
|
+
*/
|
|
281
|
+
export function contentBlockUserText(content, clean = true) {
|
|
282
|
+
if (typeof content === 'string')
|
|
283
|
+
return blankToUndefined(content, clean);
|
|
284
|
+
if (!Array.isArray(content))
|
|
285
|
+
return undefined;
|
|
286
|
+
const parts = [];
|
|
287
|
+
for (const block of content) {
|
|
288
|
+
if (typeof block === 'string') {
|
|
289
|
+
parts.push(block);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (typeof block !== 'object' || block === null)
|
|
293
|
+
continue;
|
|
294
|
+
const entry = block;
|
|
295
|
+
const type = entry['type'];
|
|
296
|
+
if (type !== 'text' && type !== 'input_text' && type !== 'output_text')
|
|
297
|
+
continue;
|
|
298
|
+
if (typeof entry['text'] === 'string')
|
|
299
|
+
parts.push(entry['text']);
|
|
300
|
+
}
|
|
301
|
+
return parts.length === 0 ? undefined : blankToUndefined(parts.join('\n'), clean);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Normalize a message body and collapse an empty result to `undefined`.
|
|
305
|
+
* @param text - the raw body.
|
|
306
|
+
* @param clean - whether to strip harness-injected wrappers first.
|
|
307
|
+
* @returns the surviving text, or undefined when nothing survived.
|
|
308
|
+
*/
|
|
309
|
+
export function blankToUndefined(text, clean = true) {
|
|
310
|
+
const body = (clean ? cleanUserText(text) : text).trim();
|
|
311
|
+
return body === '' ? undefined : body;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Buffer one fragment of the assistant run currently open.
|
|
315
|
+
* @param state - fold state, mutated in place.
|
|
316
|
+
* @param text - the fragment; blank fragments are ignored.
|
|
317
|
+
*/
|
|
318
|
+
export function pushAssistant(state, text) {
|
|
319
|
+
const trimmed = text?.trim() ?? '';
|
|
320
|
+
if (trimmed !== '')
|
|
321
|
+
state.pending.push(trimmed);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Close the assistant run, if one is open.
|
|
325
|
+
*
|
|
326
|
+
* Agents emit an assistant reply as a run of separate records — text, then
|
|
327
|
+
* reasoning, then one per tool call — so the run is what corresponds to a turn
|
|
328
|
+
* a person would recognize. Joining with a blank line keeps the fragments
|
|
329
|
+
* legible without inventing structure the transcript did not have.
|
|
330
|
+
* @param state - fold state, mutated in place.
|
|
331
|
+
* @returns the completed turn, or nothing when no run was open.
|
|
332
|
+
*/
|
|
333
|
+
export function flushAssistant(state) {
|
|
334
|
+
if (state.pending.length === 0)
|
|
335
|
+
return [];
|
|
336
|
+
const text = state.pending.join('\n\n').trim();
|
|
337
|
+
state.pending.length = 0;
|
|
338
|
+
return text === '' ? [] : [{ role: 'assistant', text }];
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Close any open assistant run and open a user turn after it.
|
|
342
|
+
* @param state - fold state, mutated in place.
|
|
343
|
+
* @param text - the user's words; a blank message yields no turn.
|
|
344
|
+
* @returns the closed assistant run followed by the user turn.
|
|
345
|
+
*/
|
|
346
|
+
export function emitUser(state, text) {
|
|
347
|
+
const closed = flushAssistant(state);
|
|
348
|
+
const trimmed = text.trim();
|
|
349
|
+
return trimmed === '' ? closed : [...closed, { role: 'user', text: trimmed }];
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Parse one JSONL record.
|
|
353
|
+
*
|
|
354
|
+
* A malformed line is an ordinary state, not a failure: the agent that owns
|
|
355
|
+
* the file may be mid-write on its last line while this one reads it.
|
|
356
|
+
* @param line - one raw line.
|
|
357
|
+
* @returns the record, or undefined when the line is not a JSON object.
|
|
358
|
+
*/
|
|
359
|
+
export function parseRecord(line) {
|
|
360
|
+
const trimmed = line.trim();
|
|
361
|
+
if (trimmed === '' || trimmed.charCodeAt(0) !== 0x7b)
|
|
362
|
+
return undefined;
|
|
363
|
+
let parsed;
|
|
364
|
+
try {
|
|
365
|
+
parsed = JSON.parse(trimmed);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
371
|
+
return undefined;
|
|
372
|
+
return parsed;
|
|
373
|
+
}
|
|
374
|
+
/** Read a string field, treating a blank one as absent. */
|
|
375
|
+
export function stringField(record, key) {
|
|
376
|
+
const value = record?.[key];
|
|
377
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
378
|
+
}
|
|
379
|
+
/** Read a nested object field. */
|
|
380
|
+
export function objectField(record, key) {
|
|
381
|
+
const value = record?.[key];
|
|
382
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
383
|
+
return undefined;
|
|
384
|
+
return value;
|
|
385
|
+
}
|
|
386
|
+
/** Read an array field. */
|
|
387
|
+
export function arrayField(record, key) {
|
|
388
|
+
const value = record?.[key];
|
|
389
|
+
return Array.isArray(value) ? value : undefined;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Parse an ISO timestamp to epoch milliseconds.
|
|
393
|
+
* @param value - the raw field, of unknown type.
|
|
394
|
+
* @returns the timestamp, or undefined when it is not a usable date.
|
|
395
|
+
*/
|
|
396
|
+
export function parseTimestamp(value) {
|
|
397
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
398
|
+
// Seconds and milliseconds are both in the wild; anything below this
|
|
399
|
+
// threshold is a second count no plausible transcript could mean as ms.
|
|
400
|
+
return value < 1e12 ? Math.trunc(value * 1000) : Math.trunc(value);
|
|
401
|
+
}
|
|
402
|
+
if (typeof value !== 'string')
|
|
403
|
+
return undefined;
|
|
404
|
+
const parsed = Date.parse(value);
|
|
405
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
406
|
+
}
|
|
407
|
+
/** Default projection settings, used by tests and by any caller that has no config. */
|
|
408
|
+
export const DEFAULT_CONVERT_OPTIONS = {
|
|
409
|
+
includeThinking: false,
|
|
410
|
+
toolCalls: 'full',
|
|
411
|
+
toolResults: 'full',
|
|
412
|
+
includeSidechains: false,
|
|
413
|
+
stripEnvironmentPreamble: true,
|
|
414
|
+
toolSummaryChars: 200,
|
|
415
|
+
};
|