@botcord/daemon 0.2.5 → 0.2.8
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/agent-discovery.d.ts +4 -0
- package/dist/agent-discovery.js +8 -0
- package/dist/agent-workspace.d.ts +62 -0
- package/dist/agent-workspace.js +140 -8
- package/dist/config.d.ts +64 -1
- package/dist/config.js +73 -1
- package/dist/daemon-config-map.d.ts +27 -9
- package/dist/daemon-config-map.js +105 -8
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +76 -6
- package/dist/doctor.d.ts +27 -1
- package/dist/doctor.js +22 -1
- package/dist/gateway/cli-resolver.d.ts +34 -0
- package/dist/gateway/cli-resolver.js +74 -0
- package/dist/gateway/dispatcher.d.ts +31 -1
- package/dist/gateway/dispatcher.js +337 -29
- package/dist/gateway/gateway.d.ts +29 -1
- package/dist/gateway/gateway.js +10 -0
- package/dist/gateway/index.d.ts +2 -0
- package/dist/gateway/index.js +2 -0
- package/dist/gateway/policy-resolver.d.ts +57 -0
- package/dist/gateway/policy-resolver.js +123 -0
- package/dist/gateway/runtimes/acp-stream.d.ts +99 -0
- package/dist/gateway/runtimes/acp-stream.js +394 -0
- package/dist/gateway/runtimes/codex.js +7 -0
- package/dist/gateway/runtimes/hermes-agent.d.ts +83 -0
- package/dist/gateway/runtimes/hermes-agent.js +180 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +7 -2
- package/dist/gateway/runtimes/ndjson-stream.js +16 -3
- package/dist/gateway/runtimes/openclaw-acp.d.ts +44 -0
- package/dist/gateway/runtimes/openclaw-acp.js +500 -0
- package/dist/gateway/runtimes/registry.d.ts +4 -0
- package/dist/gateway/runtimes/registry.js +22 -0
- package/dist/gateway/transcript-paths.d.ts +30 -0
- package/dist/gateway/transcript-paths.js +114 -0
- package/dist/gateway/transcript.d.ts +123 -0
- package/dist/gateway/transcript.js +147 -0
- package/dist/gateway/types.d.ts +31 -0
- package/dist/index.js +309 -27
- package/dist/mention-scan.d.ts +22 -0
- package/dist/mention-scan.js +35 -0
- package/dist/openclaw-discovery.d.ts +28 -0
- package/dist/openclaw-discovery.js +228 -0
- package/dist/provision.d.ts +113 -1
- package/dist/provision.js +564 -12
- package/dist/system-context.d.ts +5 -4
- package/dist/system-context.js +35 -5
- package/dist/url-utils.d.ts +9 -0
- package/dist/url-utils.js +18 -0
- package/package.json +3 -2
- package/src/__tests__/agent-workspace.test.ts +93 -0
- package/src/__tests__/daemon-config-map.test.ts +79 -0
- package/src/__tests__/openclaw-acp.test.ts +234 -0
- package/src/__tests__/openclaw-discovery.test.ts +150 -0
- package/src/__tests__/policy-resolver.test.ts +124 -0
- package/src/__tests__/policy-updated-handler.test.ts +144 -0
- package/src/__tests__/provision.test.ts +265 -0
- package/src/__tests__/system-context.test.ts +52 -0
- package/src/__tests__/url-utils.test.ts +37 -0
- package/src/agent-discovery.ts +8 -0
- package/src/agent-workspace.ts +173 -7
- package/src/config.ts +168 -4
- package/src/daemon-config-map.ts +154 -9
- package/src/daemon.ts +96 -6
- package/src/doctor.ts +49 -2
- package/src/gateway/__tests__/dispatcher.test.ts +65 -0
- package/src/gateway/__tests__/hermes-agent-adapter.test.ts +302 -0
- package/src/gateway/__tests__/transcript.test.ts +496 -0
- package/src/gateway/cli-resolver.ts +92 -0
- package/src/gateway/dispatcher.ts +394 -26
- package/src/gateway/gateway.ts +46 -0
- package/src/gateway/index.ts +25 -0
- package/src/gateway/policy-resolver.ts +171 -0
- package/src/gateway/runtimes/acp-stream.ts +535 -0
- package/src/gateway/runtimes/codex.ts +7 -0
- package/src/gateway/runtimes/hermes-agent.ts +206 -0
- package/src/gateway/runtimes/ndjson-stream.ts +16 -3
- package/src/gateway/runtimes/openclaw-acp.ts +606 -0
- package/src/gateway/runtimes/registry.ts +24 -0
- package/src/gateway/transcript-paths.ts +145 -0
- package/src/gateway/transcript.ts +300 -0
- package/src/gateway/types.ts +32 -0
- package/src/index.ts +321 -30
- package/src/mention-scan.ts +38 -0
- package/src/openclaw-discovery.ts +262 -0
- package/src/provision.ts +682 -14
- package/src/system-context.ts +41 -9
- package/src/url-utils.ts +17 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const FAST_PATH_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
5
|
+
const WIN_RESERVED = new Set([
|
|
6
|
+
"CON", "PRN", "AUX", "NUL",
|
|
7
|
+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
8
|
+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
9
|
+
]);
|
|
10
|
+
const MAX_LEN = 200;
|
|
11
|
+
const TRUNCATE_PREFIX = 191;
|
|
12
|
+
|
|
13
|
+
function sha256_8(raw: string): string {
|
|
14
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 8);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isControlOrNul(ch: number): boolean {
|
|
18
|
+
return ch === 0 || ch < 0x20 || ch === 0x7f;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isAllControl(raw: string): boolean {
|
|
22
|
+
for (let i = 0; i < raw.length; i++) {
|
|
23
|
+
if (!isControlOrNul(raw.charCodeAt(i))) return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function percentEncodeByte(byte: number): string {
|
|
29
|
+
return "%" + byte.toString(16).toUpperCase().padStart(2, "0");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isWhitelistByte(byte: number): boolean {
|
|
33
|
+
// [A-Za-z0-9_-%] retained as literal
|
|
34
|
+
return (
|
|
35
|
+
(byte >= 0x30 && byte <= 0x39) || // 0-9
|
|
36
|
+
(byte >= 0x41 && byte <= 0x5a) || // A-Z
|
|
37
|
+
(byte >= 0x61 && byte <= 0x7a) || // a-z
|
|
38
|
+
byte === 0x5f || // _
|
|
39
|
+
byte === 0x2d || // -
|
|
40
|
+
byte === 0x25 // % (kept literal — design §3.1)
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function escapeRaw(raw: string): string {
|
|
45
|
+
const bytes = Buffer.from(raw, "utf8");
|
|
46
|
+
let out = "";
|
|
47
|
+
for (const b of bytes) {
|
|
48
|
+
out += isWhitelistByte(b) ? String.fromCharCode(b) : percentEncodeByte(b);
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Truncate an escaped string to exactly MAX_LEN chars without splitting a `%XX`
|
|
55
|
+
* sequence. Keep first TRUNCATE_PREFIX chars (rolled back if mid-`%XX`), then
|
|
56
|
+
* `_` + sha256-8(raw) so the total is always ≤ MAX_LEN.
|
|
57
|
+
*/
|
|
58
|
+
function truncateEscaped(escaped: string, raw: string): string {
|
|
59
|
+
let cut = TRUNCATE_PREFIX;
|
|
60
|
+
// Roll back if cut sits inside a `%XX` sequence.
|
|
61
|
+
// A '%' at position cut-1 or cut-2 means the next 1 or 2 chars belong to it.
|
|
62
|
+
if (cut >= 1 && escaped[cut - 1] === "%") cut -= 1;
|
|
63
|
+
else if (cut >= 2 && escaped[cut - 2] === "%") cut -= 2;
|
|
64
|
+
const hash = sha256_8(raw);
|
|
65
|
+
return escaped.slice(0, cut) + "_" + hash;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Convert a raw ID into a filesystem-safe path segment.
|
|
70
|
+
*
|
|
71
|
+
* Order (must not be reordered — see design §3.1):
|
|
72
|
+
* 1. obviously invalid (empty / `.` / `..` / all control/NUL)
|
|
73
|
+
* → `_invalid_<sha256-8>`
|
|
74
|
+
* 2. Windows reserved name (CON/PRN/AUX/NUL/COM1-9/LPT1-9, case-insensitive)
|
|
75
|
+
* → `_win_<raw>`
|
|
76
|
+
* 3. fast path (`^[A-Za-z0-9_-]{1,128}$`) → return raw
|
|
77
|
+
* 4. percent-encode non-whitelist bytes; truncate at 200 chars without
|
|
78
|
+
* splitting a `%XX` (191 prefix + `_` + sha256-8)
|
|
79
|
+
*
|
|
80
|
+
* The original ID is always written into the transcript record itself; this
|
|
81
|
+
* helper only sanitizes the on-disk filename.
|
|
82
|
+
*/
|
|
83
|
+
export function safePathSegment(raw: string): string {
|
|
84
|
+
// 1. obviously invalid
|
|
85
|
+
if (raw === "" || raw === "." || raw === ".." || isAllControl(raw)) {
|
|
86
|
+
return "_invalid_" + sha256_8(raw);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. Windows reserved names — must run BEFORE fast path so `CON` is not
|
|
90
|
+
// leaked unchanged on case-insensitive filesystems.
|
|
91
|
+
if (WIN_RESERVED.has(raw.toUpperCase())) {
|
|
92
|
+
return "_win_" + raw;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 3. fast path
|
|
96
|
+
if (FAST_PATH_RE.test(raw)) return raw;
|
|
97
|
+
|
|
98
|
+
// 4. escape + maybe truncate
|
|
99
|
+
const escaped = escapeRaw(raw);
|
|
100
|
+
if (escaped.length <= MAX_LEN) return escaped;
|
|
101
|
+
return truncateEscaped(escaped, raw);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve the on-disk transcript file for a given (agent, room, topic). Used
|
|
106
|
+
* by the writer AND the CLI subcommands so both look at the same file.
|
|
107
|
+
*
|
|
108
|
+
* Layout (design §3.1):
|
|
109
|
+
* <rootDir>/<agentId>/transcripts/<roomId>/<topicId|_default>.jsonl
|
|
110
|
+
*
|
|
111
|
+
* Where <rootDir> is typically `~/.botcord/agents`.
|
|
112
|
+
*/
|
|
113
|
+
export function transcriptFilePath(
|
|
114
|
+
rootDir: string,
|
|
115
|
+
agentId: string,
|
|
116
|
+
roomId: string,
|
|
117
|
+
topicId: string | null,
|
|
118
|
+
): string {
|
|
119
|
+
return path.join(
|
|
120
|
+
rootDir,
|
|
121
|
+
safePathSegment(agentId),
|
|
122
|
+
"transcripts",
|
|
123
|
+
safePathSegment(roomId),
|
|
124
|
+
(topicId === null ? "_default" : safePathSegment(topicId)) + ".jsonl",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Directory holding a (agent, room) pair's transcript files. */
|
|
129
|
+
export function transcriptRoomDir(
|
|
130
|
+
rootDir: string,
|
|
131
|
+
agentId: string,
|
|
132
|
+
roomId: string,
|
|
133
|
+
): string {
|
|
134
|
+
return path.join(
|
|
135
|
+
rootDir,
|
|
136
|
+
safePathSegment(agentId),
|
|
137
|
+
"transcripts",
|
|
138
|
+
safePathSegment(roomId),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Directory holding all transcript rooms for a single agent. */
|
|
143
|
+
export function transcriptAgentRoot(rootDir: string, agentId: string): string {
|
|
144
|
+
return path.join(rootDir, safePathSegment(agentId), "transcripts");
|
|
145
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import type { GatewayLogger } from "./log.js";
|
|
6
|
+
import { transcriptFilePath } from "./transcript-paths.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Soft cap on a single textual field (`text` / `composedText` / `finalText`).
|
|
10
|
+
* Anything longer is truncated and `truncated.<field>` set to `true`.
|
|
11
|
+
*/
|
|
12
|
+
export const TRANSCRIPT_TEXT_LIMIT = 32 * 1024;
|
|
13
|
+
|
|
14
|
+
/** Soft cap on a single transcript file before rotation. */
|
|
15
|
+
export const TRANSCRIPT_FILE_LIMIT = 8 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
/** Default root directory for per-agent transcript trees. */
|
|
18
|
+
export function defaultTranscriptRoot(): string {
|
|
19
|
+
return path.join(homedir(), ".botcord", "agents");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Record types — see design §3.2
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export type TranscriptRecordKind =
|
|
27
|
+
| "inbound"
|
|
28
|
+
| "dispatched"
|
|
29
|
+
| "compose_failed"
|
|
30
|
+
| "outbound"
|
|
31
|
+
| "turn_error"
|
|
32
|
+
| "attention_skipped"
|
|
33
|
+
| "dropped";
|
|
34
|
+
|
|
35
|
+
export interface TranscriptRecordBase {
|
|
36
|
+
ts: string;
|
|
37
|
+
kind: TranscriptRecordKind;
|
|
38
|
+
turnId: string;
|
|
39
|
+
agentId: string;
|
|
40
|
+
roomId: string;
|
|
41
|
+
topicId: string | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TranscriptSenderInfo {
|
|
45
|
+
id: string;
|
|
46
|
+
kind: "user" | "agent" | "system";
|
|
47
|
+
name?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface InboundTranscriptRecord extends TranscriptRecordBase {
|
|
51
|
+
kind: "inbound";
|
|
52
|
+
messageId: string;
|
|
53
|
+
sender: TranscriptSenderInfo;
|
|
54
|
+
text: string;
|
|
55
|
+
rawBatchEntries?: number;
|
|
56
|
+
trace?: { id: string; streamable?: boolean };
|
|
57
|
+
truncated?: { text?: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DispatchedTranscriptRecord extends TranscriptRecordBase {
|
|
61
|
+
kind: "dispatched";
|
|
62
|
+
composedText: string;
|
|
63
|
+
mergedFromTurnIds?: string[];
|
|
64
|
+
runtime: string;
|
|
65
|
+
truncated?: { composedText?: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ComposeFailedTranscriptRecord extends TranscriptRecordBase {
|
|
69
|
+
kind: "compose_failed";
|
|
70
|
+
error: string;
|
|
71
|
+
fallback: "raw_text";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type DeliveryStatus =
|
|
75
|
+
| "delivered"
|
|
76
|
+
| "gated_non_owner_chat"
|
|
77
|
+
| "empty_text"
|
|
78
|
+
| "send_failed";
|
|
79
|
+
|
|
80
|
+
export interface TranscriptBlockSummary {
|
|
81
|
+
type: string;
|
|
82
|
+
chars?: number;
|
|
83
|
+
name?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface OutboundTranscriptRecord extends TranscriptRecordBase {
|
|
87
|
+
kind: "outbound";
|
|
88
|
+
runtime: string;
|
|
89
|
+
runtimeSessionId?: string | null;
|
|
90
|
+
durationMs: number;
|
|
91
|
+
costUsd?: number;
|
|
92
|
+
finalText: string;
|
|
93
|
+
deliveryStatus: DeliveryStatus;
|
|
94
|
+
deliveryReason?: string | null;
|
|
95
|
+
blocks?: TranscriptBlockSummary[];
|
|
96
|
+
truncated?: { finalText?: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface TurnErrorTranscriptRecord extends TranscriptRecordBase {
|
|
100
|
+
kind: "turn_error";
|
|
101
|
+
phase: "runtime" | "timeout";
|
|
102
|
+
error: string;
|
|
103
|
+
durationMs: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface AttentionSkippedTranscriptRecord extends TranscriptRecordBase {
|
|
107
|
+
kind: "attention_skipped";
|
|
108
|
+
reason: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type DroppedReason =
|
|
112
|
+
| "batch_merged"
|
|
113
|
+
| "queue_cancel_previous"
|
|
114
|
+
| "queue_overflow";
|
|
115
|
+
|
|
116
|
+
export interface DroppedTranscriptRecord extends TranscriptRecordBase {
|
|
117
|
+
kind: "dropped";
|
|
118
|
+
reason: DroppedReason;
|
|
119
|
+
supersededBy?: string | null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type TranscriptRecord =
|
|
123
|
+
| InboundTranscriptRecord
|
|
124
|
+
| DispatchedTranscriptRecord
|
|
125
|
+
| ComposeFailedTranscriptRecord
|
|
126
|
+
| OutboundTranscriptRecord
|
|
127
|
+
| TurnErrorTranscriptRecord
|
|
128
|
+
| AttentionSkippedTranscriptRecord
|
|
129
|
+
| DroppedTranscriptRecord;
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Truncation helper
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Truncate `value` to TRANSCRIPT_TEXT_LIMIT chars. Returns the (possibly
|
|
137
|
+
* truncated) text and whether truncation occurred. Surrogate-pair aware: if
|
|
138
|
+
* the cut would split a pair, step back one char.
|
|
139
|
+
*/
|
|
140
|
+
export function truncateTextField(value: string): { text: string; truncated: boolean } {
|
|
141
|
+
if (value.length <= TRANSCRIPT_TEXT_LIMIT) return { text: value, truncated: false };
|
|
142
|
+
let cut = TRANSCRIPT_TEXT_LIMIT;
|
|
143
|
+
const code = value.charCodeAt(cut - 1);
|
|
144
|
+
if (code >= 0xd800 && code <= 0xdbff) cut -= 1; // mid-surrogate
|
|
145
|
+
return { text: value.slice(0, cut), truncated: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Writer
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
export interface TranscriptWriter {
|
|
153
|
+
/** Append a record. Failures are logged and swallowed. */
|
|
154
|
+
write(rec: TranscriptRecord): void;
|
|
155
|
+
/** Whether persistence is on. CLI / tests may read this. */
|
|
156
|
+
readonly enabled: boolean;
|
|
157
|
+
/** Root directory used for path resolution. */
|
|
158
|
+
readonly rootDir: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface CreateTranscriptWriterOptions {
|
|
162
|
+
/** Defaults to `~/.botcord/agents`. */
|
|
163
|
+
rootDir?: string;
|
|
164
|
+
log: GatewayLogger;
|
|
165
|
+
/** Defaults to `false` — see design §6 (default-off). */
|
|
166
|
+
enabled?: boolean;
|
|
167
|
+
/** Override file rotation threshold (bytes). Defaults to TRANSCRIPT_FILE_LIMIT. */
|
|
168
|
+
maxFileBytes?: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface FileMeta {
|
|
172
|
+
size: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
class NoopTranscriptWriter implements TranscriptWriter {
|
|
176
|
+
readonly enabled = false;
|
|
177
|
+
readonly rootDir: string;
|
|
178
|
+
constructor(rootDir: string) {
|
|
179
|
+
this.rootDir = rootDir;
|
|
180
|
+
}
|
|
181
|
+
write(_rec: TranscriptRecord): void {
|
|
182
|
+
// intentionally empty
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
class FsTranscriptWriter implements TranscriptWriter {
|
|
187
|
+
readonly enabled = true;
|
|
188
|
+
readonly rootDir: string;
|
|
189
|
+
private readonly log: GatewayLogger;
|
|
190
|
+
private readonly maxFileBytes: number;
|
|
191
|
+
private readonly fileMeta = new Map<string, FileMeta>();
|
|
192
|
+
private firstWriteAnnounced = false;
|
|
193
|
+
|
|
194
|
+
constructor(rootDir: string, log: GatewayLogger, maxFileBytes: number) {
|
|
195
|
+
this.rootDir = rootDir;
|
|
196
|
+
this.log = log;
|
|
197
|
+
this.maxFileBytes = maxFileBytes;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
write(rec: TranscriptRecord): void {
|
|
201
|
+
try {
|
|
202
|
+
const file = transcriptFilePath(this.rootDir, rec.agentId, rec.roomId, rec.topicId);
|
|
203
|
+
const dir = path.dirname(file);
|
|
204
|
+
try {
|
|
205
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
206
|
+
} catch {
|
|
207
|
+
// best-effort — appendFileSync below will surface a real error
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const line = JSON.stringify(rec) + "\n";
|
|
211
|
+
const bytes = Buffer.byteLength(line, "utf8");
|
|
212
|
+
|
|
213
|
+
// Rotate before appending if the existing file would exceed the cap.
|
|
214
|
+
const meta = this.statFile(file);
|
|
215
|
+
if (meta.size > 0 && meta.size + bytes > this.maxFileBytes) {
|
|
216
|
+
this.rotate(file);
|
|
217
|
+
this.fileMeta.delete(file);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
appendFileSync(file, line, { mode: 0o600 });
|
|
221
|
+
const cur = this.fileMeta.get(file) ?? { size: meta.size };
|
|
222
|
+
cur.size = (cur.size || 0) + bytes;
|
|
223
|
+
this.fileMeta.set(file, cur);
|
|
224
|
+
|
|
225
|
+
if (!this.firstWriteAnnounced) {
|
|
226
|
+
this.firstWriteAnnounced = true;
|
|
227
|
+
this.log.info("transcript enabled", { dir: this.rootDir });
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
this.log.warn("transcript: write failed", {
|
|
231
|
+
kind: rec.kind,
|
|
232
|
+
turnId: rec.turnId,
|
|
233
|
+
error: err instanceof Error ? err.message : String(err),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private statFile(file: string): FileMeta {
|
|
239
|
+
const cached = this.fileMeta.get(file);
|
|
240
|
+
if (cached) return cached;
|
|
241
|
+
try {
|
|
242
|
+
const st = statSync(file);
|
|
243
|
+
const meta = { size: st.size };
|
|
244
|
+
this.fileMeta.set(file, meta);
|
|
245
|
+
return meta;
|
|
246
|
+
} catch {
|
|
247
|
+
const meta = { size: 0 };
|
|
248
|
+
this.fileMeta.set(file, meta);
|
|
249
|
+
return meta;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private rotate(file: string): void {
|
|
254
|
+
const stamp = formatStamp(new Date());
|
|
255
|
+
const ext = ".jsonl";
|
|
256
|
+
const base = file.endsWith(ext) ? file.slice(0, -ext.length) : file;
|
|
257
|
+
const rotated = `${base}.${stamp}${ext}`;
|
|
258
|
+
try {
|
|
259
|
+
renameSync(file, rotated);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
this.log.warn("transcript: rotate failed", {
|
|
262
|
+
file,
|
|
263
|
+
error: err instanceof Error ? err.message : String(err),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function createTranscriptWriter(
|
|
270
|
+
opts: CreateTranscriptWriterOptions,
|
|
271
|
+
): TranscriptWriter {
|
|
272
|
+
const rootDir = opts.rootDir ?? defaultTranscriptRoot();
|
|
273
|
+
const enabled = opts.enabled ?? false;
|
|
274
|
+
if (!enabled) return new NoopTranscriptWriter(rootDir);
|
|
275
|
+
const maxBytes = opts.maxFileBytes ?? TRANSCRIPT_FILE_LIMIT;
|
|
276
|
+
return new FsTranscriptWriter(rootDir, opts.log, maxBytes);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Resolve the tri-state enable flag (env wins; otherwise config). See design §5.
|
|
281
|
+
* - env === "1" → true (force on)
|
|
282
|
+
* - env === "0" → false (force off)
|
|
283
|
+
* - any other / unset → fall back to `configEnabled`
|
|
284
|
+
*/
|
|
285
|
+
export function resolveTranscriptEnabled(
|
|
286
|
+
envVal: string | undefined,
|
|
287
|
+
configEnabled: boolean,
|
|
288
|
+
): boolean {
|
|
289
|
+
if (envVal === "1") return true;
|
|
290
|
+
if (envVal === "0") return false;
|
|
291
|
+
return configEnabled;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function formatStamp(d: Date): string {
|
|
295
|
+
const pad = (n: number) => n.toString().padStart(2, "0");
|
|
296
|
+
return (
|
|
297
|
+
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
|
298
|
+
`-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
|
|
299
|
+
);
|
|
300
|
+
}
|
package/src/gateway/types.ts
CHANGED
|
@@ -21,6 +21,20 @@ export type QueueMode = "serial" | "cancel-previous";
|
|
|
21
21
|
/** Source-based trust tier used by runtimes to pick default permission flags. */
|
|
22
22
|
export type TrustLevel = "owner" | "trusted" | "public";
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Resolved OpenClaw gateway endpoint for a route. Built eagerly in
|
|
26
|
+
* `toGatewayConfig` from the `DaemonConfig.openclawGateways` registry plus the
|
|
27
|
+
* `RouteRule.gateway` / `openclawAgent` choice — the dispatcher never needs
|
|
28
|
+
* to re-query the registry. `name` is preserved purely for logging/snapshot.
|
|
29
|
+
*/
|
|
30
|
+
export interface ResolvedOpenclawGateway {
|
|
31
|
+
name: string;
|
|
32
|
+
url: string;
|
|
33
|
+
token?: string;
|
|
34
|
+
/** OpenClaw agent profile, with the route override already applied. */
|
|
35
|
+
openclawAgent?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
/** Declarative route entry selecting the runtime and execution flags for matched messages. */
|
|
25
39
|
export interface GatewayRoute {
|
|
26
40
|
match?: RouteMatch;
|
|
@@ -29,6 +43,8 @@ export interface GatewayRoute {
|
|
|
29
43
|
extraArgs?: string[];
|
|
30
44
|
queueMode?: QueueMode;
|
|
31
45
|
trustLevel?: TrustLevel;
|
|
46
|
+
/** Required when `runtime === "openclaw-acp"`. Resolved at config-load time. */
|
|
47
|
+
gateway?: ResolvedOpenclawGateway;
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
// ---------------------------------------------------------------------------
|
|
@@ -268,6 +284,15 @@ export interface RuntimeRunOptions {
|
|
|
268
284
|
* per-agent `CODEX_HOME` carrying the AGENTS.md that injects systemContext.
|
|
269
285
|
*/
|
|
270
286
|
accountId: string;
|
|
287
|
+
/**
|
|
288
|
+
* Hub URL the owning agent is registered against. Forwarded to runtimes
|
|
289
|
+
* so spawned CLI subprocesses can target the correct hub via
|
|
290
|
+
* `BOTCORD_HUB` (see `cli-resolver.buildCliEnv`). Optional because the
|
|
291
|
+
* dispatcher cannot always resolve a per-agent hub (e.g. for agents
|
|
292
|
+
* provisioned after boot); when unset, runtimes leave `BOTCORD_HUB`
|
|
293
|
+
* unspecified and the bundled CLI falls back to its own default.
|
|
294
|
+
*/
|
|
295
|
+
hubUrl?: string;
|
|
271
296
|
signal: AbortSignal;
|
|
272
297
|
extraArgs?: string[];
|
|
273
298
|
trustLevel: TrustLevel;
|
|
@@ -277,6 +302,13 @@ export interface RuntimeRunOptions {
|
|
|
277
302
|
context?: Record<string, unknown>;
|
|
278
303
|
/** Called for every parsed block while the turn is in progress. */
|
|
279
304
|
onBlock?: (block: StreamBlock) => void;
|
|
305
|
+
/**
|
|
306
|
+
* External service endpoint required by some runtimes (first user:
|
|
307
|
+
* openclaw-acp). Resolved at config-load time and passed through here per
|
|
308
|
+
* call — runtime factories do not see it. Mirrors the `hubUrl` precedent of
|
|
309
|
+
* lifting service URLs out of `extraArgs` into typed first-class fields.
|
|
310
|
+
*/
|
|
311
|
+
gateway?: ResolvedOpenclawGateway;
|
|
280
312
|
}
|
|
281
313
|
|
|
282
314
|
/** Result returned by a runtime adapter after a turn completes. */
|