opencode-castlegate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/SPEC.md +182 -0
- package/docs/config.md +53 -0
- package/docs/security-model.md +78 -0
- package/package.json +49 -0
- package/src/config.ts +64 -0
- package/src/errors.ts +28 -0
- package/src/index.ts +245 -0
- package/src/intent.ts +196 -0
- package/src/log.ts +62 -0
- package/src/prompt-body.ts +27 -0
- package/src/prompts/intent-update.ts +107 -0
- package/src/prompts/intent-validate.ts +106 -0
- package/src/store.ts +162 -0
- package/src/tools/intent-context.ts +59 -0
- package/src/validate.ts +151 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { Plugin, Hooks } from "@opencode-ai/plugin";
|
|
2
|
+
import { safeParseConfig, type CastlegateConfig } from "./config.ts";
|
|
3
|
+
import { CastlegateIntentMismatchError, CASTLEGATE_TAG } from "./errors.ts";
|
|
4
|
+
import {
|
|
5
|
+
clearValidatorCache,
|
|
6
|
+
shouldValidate,
|
|
7
|
+
validateToolCall,
|
|
8
|
+
fallbackDecision,
|
|
9
|
+
} from "./validate.ts";
|
|
10
|
+
import { createSidecar, updateDigest } from "./intent.ts";
|
|
11
|
+
import { loadDigest, resolveStorageDir, deleteDigest } from "./store.ts";
|
|
12
|
+
import { createLogger, redactedArgKeys } from "./log.ts";
|
|
13
|
+
import { createIntentContextTool } from "./tools/intent-context.ts";
|
|
14
|
+
|
|
15
|
+
interface SessionState {
|
|
16
|
+
pendingDigestUpdate: ReturnType<typeof setTimeout> | null;
|
|
17
|
+
debounceMs: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEBOUNCE_MS = 1000;
|
|
21
|
+
|
|
22
|
+
export const CastlegatePlugin: Plugin = async (input, options) => {
|
|
23
|
+
const raw = (options ?? {}) as Record<string, unknown>;
|
|
24
|
+
const parsed = safeParseConfig(raw);
|
|
25
|
+
if (!parsed.ok) {
|
|
26
|
+
await input.client.app
|
|
27
|
+
.log({
|
|
28
|
+
body: {
|
|
29
|
+
service: "castlegate",
|
|
30
|
+
level: "error",
|
|
31
|
+
message: "invalid castlegate config; plugin disabled",
|
|
32
|
+
extra: { issues: parsed.error.issues },
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
.catch(() => {});
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cfg: CastlegateConfig = parsed.config;
|
|
40
|
+
const log = createLogger(input.client, cfg);
|
|
41
|
+
const sidecar = createSidecar();
|
|
42
|
+
const sessionStates = new Map<string, SessionState>();
|
|
43
|
+
|
|
44
|
+
function getSessionState(sessionID: string): SessionState {
|
|
45
|
+
let state = sessionStates.get(sessionID);
|
|
46
|
+
if (!state) {
|
|
47
|
+
state = { pendingDigestUpdate: null, debounceMs: DEBOUNCE_MS };
|
|
48
|
+
sessionStates.set(sessionID, state);
|
|
49
|
+
}
|
|
50
|
+
return state;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scheduleDigestUpdate(sessionID: string): void {
|
|
54
|
+
const state = getSessionState(sessionID);
|
|
55
|
+
if (state.pendingDigestUpdate) clearTimeout(state.pendingDigestUpdate);
|
|
56
|
+
state.pendingDigestUpdate = setTimeout(() => {
|
|
57
|
+
state.pendingDigestUpdate = null;
|
|
58
|
+
void runDigestUpdate(sessionID);
|
|
59
|
+
}, state.debounceMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function runDigestUpdate(sessionID: string): Promise<void> {
|
|
63
|
+
try {
|
|
64
|
+
const result = await updateDigest(
|
|
65
|
+
{ client: input.client, sessionId: sessionID, cfg },
|
|
66
|
+
sidecar,
|
|
67
|
+
);
|
|
68
|
+
if (!result.skipped) {
|
|
69
|
+
await log.event("intent.updated", {
|
|
70
|
+
sessionID,
|
|
71
|
+
version: result.version,
|
|
72
|
+
compacted: result.compacted,
|
|
73
|
+
intentShift: result.intentShift,
|
|
74
|
+
chars: result.digest.length,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
await log.event("intent.update_error", {
|
|
79
|
+
sessionID,
|
|
80
|
+
error: err instanceof Error ? err.message : String(err),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function ensureInitialized(sessionID: string): Promise<void> {
|
|
86
|
+
const dir = resolveStorageDir(input.directory, cfg.storage.directory);
|
|
87
|
+
const existing = await loadDigest(dir, sessionID);
|
|
88
|
+
if (!existing) {
|
|
89
|
+
const { saveDigest } = await import("./store.ts");
|
|
90
|
+
await saveDigest(dir, sessionID, "", { baseVersion: 0, lastMessageId: null });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
dispose: async () => {
|
|
96
|
+
for (const state of sessionStates.values()) {
|
|
97
|
+
if (state.pendingDigestUpdate) clearTimeout(state.pendingDigestUpdate);
|
|
98
|
+
}
|
|
99
|
+
sessionStates.clear();
|
|
100
|
+
clearValidatorCache();
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
event: async ({ event }) => {
|
|
104
|
+
switch (event.type) {
|
|
105
|
+
case "session.created": {
|
|
106
|
+
const sessionID = (event.properties.info as { id?: string }).id;
|
|
107
|
+
if (!sessionID) return;
|
|
108
|
+
try {
|
|
109
|
+
await ensureInitialized(sessionID);
|
|
110
|
+
await log.event("session.initialized", { sessionID });
|
|
111
|
+
} catch (err) {
|
|
112
|
+
await log.event("session.init_error", {
|
|
113
|
+
sessionID,
|
|
114
|
+
error: err instanceof Error ? err.message : String(err),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "session.deleted": {
|
|
120
|
+
const sessionID = (event.properties.info as { id?: string }).id;
|
|
121
|
+
if (!sessionID) return;
|
|
122
|
+
try {
|
|
123
|
+
const dir = resolveStorageDir(input.directory, cfg.storage.directory);
|
|
124
|
+
await deleteDigest(dir, sessionID);
|
|
125
|
+
sessionStates.delete(sessionID);
|
|
126
|
+
await log.event("session.deleted", { sessionID });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
await log.event("session.delete_error", {
|
|
129
|
+
sessionID,
|
|
130
|
+
error: err instanceof Error ? err.message : String(err),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case "message.updated": {
|
|
136
|
+
const info = event.properties.info as { sessionID?: string; id?: string };
|
|
137
|
+
const sessionID = info.sessionID;
|
|
138
|
+
if (!sessionID) return;
|
|
139
|
+
scheduleDigestUpdate(sessionID);
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case "message.part.updated":
|
|
143
|
+
case "message.part.removed":
|
|
144
|
+
case "message.removed": {
|
|
145
|
+
const props = event.properties as { sessionID?: string };
|
|
146
|
+
if (props.sessionID) scheduleDigestUpdate(props.sessionID);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
"tool.execute.before": async (toolInput, output) => {
|
|
153
|
+
const { tool, sessionID } = toolInput;
|
|
154
|
+
if (!sessionID) return;
|
|
155
|
+
if (!shouldValidate(tool, cfg)) return;
|
|
156
|
+
|
|
157
|
+
const dir = resolveStorageDir(input.directory, cfg.storage.directory);
|
|
158
|
+
let file;
|
|
159
|
+
try {
|
|
160
|
+
file = await loadDigest(dir, sessionID);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
await log.event("validate.digest_load_error", {
|
|
163
|
+
sessionID,
|
|
164
|
+
tool,
|
|
165
|
+
error: err instanceof Error ? err.message : String(err),
|
|
166
|
+
});
|
|
167
|
+
if (!cfg.validate.failOpenOnError) {
|
|
168
|
+
throw new CastlegateIntentMismatchError(
|
|
169
|
+
tool,
|
|
170
|
+
"castlegate: digest store unavailable",
|
|
171
|
+
"medium",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const digest = file?.body ?? "";
|
|
178
|
+
const digestVersion = file?.header.digestVersion ?? 0;
|
|
179
|
+
|
|
180
|
+
if (!digest) {
|
|
181
|
+
await log.event("validate.skip_no_digest", { sessionID, tool });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let decision;
|
|
186
|
+
try {
|
|
187
|
+
decision = await validateToolCall({
|
|
188
|
+
client: input.client,
|
|
189
|
+
cfg,
|
|
190
|
+
digest,
|
|
191
|
+
digestVersion,
|
|
192
|
+
tool,
|
|
193
|
+
callArgs: output.args,
|
|
194
|
+
sidecar,
|
|
195
|
+
});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
await log.event("validate.error", {
|
|
198
|
+
sessionID,
|
|
199
|
+
tool,
|
|
200
|
+
error: err instanceof Error ? err.message : String(err),
|
|
201
|
+
});
|
|
202
|
+
const fallback = fallbackDecision(tool, cfg);
|
|
203
|
+
if (fallback.match) return;
|
|
204
|
+
throw new CastlegateIntentMismatchError(tool, fallback.reason, fallback.severity);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (decision.result.match) {
|
|
208
|
+
await log.event("validate.match", {
|
|
209
|
+
sessionID,
|
|
210
|
+
tool,
|
|
211
|
+
cached: decision.cached,
|
|
212
|
+
severity: decision.result.severity,
|
|
213
|
+
});
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await log.event("validate.mismatch", {
|
|
218
|
+
sessionID,
|
|
219
|
+
tool,
|
|
220
|
+
reason: decision.result.reason,
|
|
221
|
+
severity: decision.result.severity,
|
|
222
|
+
argKeys: redactedArgKeys(output.args),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (!cfg.validate.requireApprovalOnMismatch) {
|
|
226
|
+
await log.event("validate.mismatch_allowed_by_config", { sessionID, tool });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
throw new CastlegateIntentMismatchError(
|
|
231
|
+
tool,
|
|
232
|
+
decision.result.reason,
|
|
233
|
+
decision.result.severity,
|
|
234
|
+
);
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
tool: {
|
|
238
|
+
intent_context: createIntentContextTool(() => cfg),
|
|
239
|
+
},
|
|
240
|
+
} satisfies Hooks;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
export default CastlegatePlugin;
|
|
244
|
+
|
|
245
|
+
export { CastlegateIntentMismatchError, CASTLEGATE_TAG };
|
package/src/intent.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { CastlegateConfig } from "./config.ts";
|
|
2
|
+
import { loadDigest, saveDigest, needsCompact, resolveStorageDir } from "./store.ts";
|
|
3
|
+
import {
|
|
4
|
+
buildIntentUpdatePrompt,
|
|
5
|
+
parseIntentUpdateResponse,
|
|
6
|
+
enforceMaxChars,
|
|
7
|
+
} from "./prompts/intent-update.ts";
|
|
8
|
+
import { buildSidecarPromptBody } from "./prompt-body.ts";
|
|
9
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
10
|
+
|
|
11
|
+
type PluginClient = PluginInput["client"];
|
|
12
|
+
|
|
13
|
+
export interface UpdateDigestArgs {
|
|
14
|
+
client: PluginClient;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
cfg: CastlegateConfig;
|
|
17
|
+
sinceMessageId?: string | null;
|
|
18
|
+
newMessages?: Array<{ role: string; author?: string; text: string }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UpdateDigestResult {
|
|
22
|
+
digest: string;
|
|
23
|
+
version: number;
|
|
24
|
+
compacted: boolean;
|
|
25
|
+
intentShift: string;
|
|
26
|
+
skipped: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function ensureSidecarSession(client: PluginClient): Promise<string> {
|
|
30
|
+
const created = await client.session.create({
|
|
31
|
+
body: { title: "[castlegate] validator sidecar" },
|
|
32
|
+
});
|
|
33
|
+
if (!created.data) {
|
|
34
|
+
throw new Error("castlegate: failed to create sidecar session");
|
|
35
|
+
}
|
|
36
|
+
return created.data.id;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function getSidecarId(
|
|
40
|
+
client: PluginClient,
|
|
41
|
+
cache: { id: string | null; pending: Promise<string> | null },
|
|
42
|
+
): Promise<string> {
|
|
43
|
+
if (cache.id) return cache.id;
|
|
44
|
+
if (!cache.pending) {
|
|
45
|
+
cache.pending = ensureSidecarSession(client).then((id) => {
|
|
46
|
+
cache.id = id;
|
|
47
|
+
return id;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return cache.pending;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createSidecar(): { id: string | null; pending: Promise<string> | null } {
|
|
54
|
+
return { id: null, pending: null };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function fetchRecentMessages(
|
|
58
|
+
client: PluginClient,
|
|
59
|
+
sessionId: string,
|
|
60
|
+
sinceMessageId: string | null | undefined,
|
|
61
|
+
limit: number,
|
|
62
|
+
): Promise<Array<{ id: string; role: string; author?: string; text: string }>> {
|
|
63
|
+
const result = await client.session.messages({ path: { id: sessionId } });
|
|
64
|
+
const data = result.data ?? [];
|
|
65
|
+
const collected: Array<{ id: string; role: string; author?: string; text: string }> = [];
|
|
66
|
+
let started = sinceMessageId == null;
|
|
67
|
+
|
|
68
|
+
for (let i = data.length - 1; i >= 0 && collected.length < limit; i--) {
|
|
69
|
+
const item = data[i]!;
|
|
70
|
+
if (!started && item.info.id === sinceMessageId) {
|
|
71
|
+
started = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!started) continue;
|
|
75
|
+
|
|
76
|
+
const role = (item.info as { role?: string }).role ?? "unknown";
|
|
77
|
+
const author = (item.info as { agent?: string }).agent;
|
|
78
|
+
|
|
79
|
+
const text = item.parts
|
|
80
|
+
.map((p: { type?: string; text?: string }) => {
|
|
81
|
+
const t = p.type;
|
|
82
|
+
if (t === "text" && typeof p.text === "string") {
|
|
83
|
+
return p.text;
|
|
84
|
+
}
|
|
85
|
+
return "";
|
|
86
|
+
})
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join("\n");
|
|
89
|
+
|
|
90
|
+
if (!text) continue;
|
|
91
|
+
collected.push({ id: item.info.id, role, ...(author ? { author } : {}), text });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return collected.reverse();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function callLightweightModel(
|
|
98
|
+
client: PluginClient,
|
|
99
|
+
sidecarId: string,
|
|
100
|
+
cfg: CastlegateConfig,
|
|
101
|
+
prompt: string,
|
|
102
|
+
): Promise<string> {
|
|
103
|
+
const result = await client.session.prompt({
|
|
104
|
+
path: { id: sidecarId },
|
|
105
|
+
body: buildSidecarPromptBody(cfg, prompt),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const parts = result.data?.parts ?? [];
|
|
109
|
+
const texts = parts
|
|
110
|
+
.map((p: { type?: string; text?: string }) => {
|
|
111
|
+
const t = p.type;
|
|
112
|
+
if (t === "text" && typeof p.text === "string") {
|
|
113
|
+
return p.text;
|
|
114
|
+
}
|
|
115
|
+
return "";
|
|
116
|
+
})
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
return texts.join("\n").trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function updateDigest(
|
|
122
|
+
args: UpdateDigestArgs,
|
|
123
|
+
sidecar: ReturnType<typeof createSidecar>,
|
|
124
|
+
): Promise<UpdateDigestResult> {
|
|
125
|
+
const { client, sessionId, cfg, sinceMessageId, newMessages } = args;
|
|
126
|
+
const dir = resolveStorageDir(process.cwd(), cfg.storage.directory);
|
|
127
|
+
const existing = await loadDigest(dir, sessionId);
|
|
128
|
+
|
|
129
|
+
const freshMessages =
|
|
130
|
+
newMessages ??
|
|
131
|
+
(await fetchRecentMessages(
|
|
132
|
+
client,
|
|
133
|
+
sessionId,
|
|
134
|
+
sinceMessageId ?? existing?.header.lastMessageId ?? null,
|
|
135
|
+
cfg.intent.maxRecentMessages,
|
|
136
|
+
));
|
|
137
|
+
|
|
138
|
+
if (freshMessages.length === 0 && existing) {
|
|
139
|
+
return {
|
|
140
|
+
digest: existing.body,
|
|
141
|
+
version: existing.header.digestVersion,
|
|
142
|
+
compacted: false,
|
|
143
|
+
intentShift: "",
|
|
144
|
+
skipped: true,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (freshMessages.length === 0 && !existing) {
|
|
149
|
+
return {
|
|
150
|
+
digest: "",
|
|
151
|
+
version: 0,
|
|
152
|
+
compacted: false,
|
|
153
|
+
intentShift: "",
|
|
154
|
+
skipped: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const compact = existing
|
|
159
|
+
? needsCompact(existing.body, cfg.intent.maxChars, cfg.intent.compactThreshold)
|
|
160
|
+
: false;
|
|
161
|
+
|
|
162
|
+
const prompt = buildIntentUpdatePrompt({
|
|
163
|
+
previousDigest: existing?.body ?? "",
|
|
164
|
+
maxChars: cfg.intent.maxChars,
|
|
165
|
+
newMessages: freshMessages.map((m) => ({
|
|
166
|
+
role: m.role,
|
|
167
|
+
...(m.author ? { author: m.author } : {}),
|
|
168
|
+
text: m.text,
|
|
169
|
+
})),
|
|
170
|
+
compact,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const sidecarId = await getSidecarId(client, sidecar);
|
|
174
|
+
const raw = await callLightweightModel(client, sidecarId, cfg, prompt);
|
|
175
|
+
const parsed = parseIntentUpdateResponse(raw);
|
|
176
|
+
|
|
177
|
+
const safeDigest = enforceMaxChars(parsed.digest, cfg.intent.maxChars);
|
|
178
|
+
const lastFresh = freshMessages[freshMessages.length - 1] as
|
|
179
|
+
| { id: string; role: string; author?: string; text: string }
|
|
180
|
+
| undefined;
|
|
181
|
+
const lastId = lastFresh?.id ?? existing?.header.lastMessageId ?? null;
|
|
182
|
+
const baseVersion = existing?.header.digestVersion ?? 0;
|
|
183
|
+
|
|
184
|
+
const header = await saveDigest(dir, sessionId, safeDigest, {
|
|
185
|
+
baseVersion,
|
|
186
|
+
lastMessageId: lastId,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
digest: safeDigest,
|
|
191
|
+
version: header.digestVersion,
|
|
192
|
+
compacted: compact,
|
|
193
|
+
intentShift: parsed.intentShift,
|
|
194
|
+
skipped: false,
|
|
195
|
+
};
|
|
196
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
import type { CastlegateConfig } from "./config.ts";
|
|
3
|
+
|
|
4
|
+
type LogLevel = "debug" | "info" | "warn" | "error";
|
|
5
|
+
|
|
6
|
+
export interface Logger {
|
|
7
|
+
event(name: string, fields: Record<string, unknown>): Promise<void>;
|
|
8
|
+
warn(name: string, fields: Record<string, unknown>): Promise<void>;
|
|
9
|
+
error(name: string, fields: Record<string, unknown>): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createLogger(client: PluginInput["client"], cfg: CastlegateConfig): Logger {
|
|
13
|
+
const send = async (level: LogLevel, name: string, fields: Record<string, unknown>) => {
|
|
14
|
+
if (!cfg.logging && level !== "error") return;
|
|
15
|
+
try {
|
|
16
|
+
await client.app.log({
|
|
17
|
+
body: {
|
|
18
|
+
service: "castlegate",
|
|
19
|
+
level,
|
|
20
|
+
message: name,
|
|
21
|
+
extra: fields,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
} catch {
|
|
25
|
+
// never let logging break the plugin
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async event(name, fields) {
|
|
31
|
+
await send("info", name, fields);
|
|
32
|
+
},
|
|
33
|
+
async warn(name, fields) {
|
|
34
|
+
await send("warn", name, fields);
|
|
35
|
+
},
|
|
36
|
+
async error(name, fields) {
|
|
37
|
+
await send("error", name, fields);
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function redactedArgKeys(args: unknown): string[] {
|
|
43
|
+
if (!args || typeof args !== "object") return [];
|
|
44
|
+
try {
|
|
45
|
+
return Object.keys(args as Record<string, unknown>);
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function previewArgValue(value: unknown, maxLen = 80): string {
|
|
52
|
+
if (value == null) return String(value);
|
|
53
|
+
if (typeof value === "string") {
|
|
54
|
+
return value.length <= maxLen ? value : value.slice(0, maxLen) + "...";
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
const s = JSON.stringify(value);
|
|
58
|
+
return s.length <= maxLen ? s : s.slice(0, maxLen) + "...";
|
|
59
|
+
} catch {
|
|
60
|
+
return "(unserializable)";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CastlegateConfig } from "./config.ts";
|
|
2
|
+
|
|
3
|
+
export interface SidecarPromptBody {
|
|
4
|
+
model?: { providerID: string; modelID: string };
|
|
5
|
+
system: string;
|
|
6
|
+
parts: Array<{ type: "text"; text: string }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const SYSTEM_PROMPT =
|
|
10
|
+
"You are a precise, terse JSON-producing subagent. Respond only with the requested JSON, no preamble, no fences.";
|
|
11
|
+
|
|
12
|
+
export function buildSidecarPromptBody(
|
|
13
|
+
cfg: CastlegateConfig,
|
|
14
|
+
prompt: string,
|
|
15
|
+
): SidecarPromptBody {
|
|
16
|
+
const body: SidecarPromptBody = {
|
|
17
|
+
system: SYSTEM_PROMPT,
|
|
18
|
+
parts: [{ type: "text", text: prompt }],
|
|
19
|
+
};
|
|
20
|
+
if (cfg.model) {
|
|
21
|
+
body.model = {
|
|
22
|
+
providerID: cfg.model.providerID,
|
|
23
|
+
modelID: cfg.model.modelID,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return body;
|
|
27
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { CastlegateConfig } from "../config.ts";
|
|
2
|
+
|
|
3
|
+
export interface IntentUpdatePromptArgs {
|
|
4
|
+
previousDigest: string;
|
|
5
|
+
maxChars: number;
|
|
6
|
+
newMessages: Array<{
|
|
7
|
+
role: string;
|
|
8
|
+
author?: string;
|
|
9
|
+
text: string;
|
|
10
|
+
}>;
|
|
11
|
+
compact: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildIntentUpdatePrompt(args: IntentUpdatePromptArgs): string {
|
|
15
|
+
const { previousDigest, maxChars, newMessages, compact } = args;
|
|
16
|
+
|
|
17
|
+
const messagesBlock = newMessages
|
|
18
|
+
.map((m) => {
|
|
19
|
+
const who = m.author ? `${m.role}/${m.author}` : m.role;
|
|
20
|
+
return `[${who}] ${m.text}`;
|
|
21
|
+
})
|
|
22
|
+
.join("\n\n");
|
|
23
|
+
|
|
24
|
+
const digestBlock = previousDigest
|
|
25
|
+
? previousDigest
|
|
26
|
+
: "(no prior digest — first update for this session)";
|
|
27
|
+
|
|
28
|
+
const instruction = compact
|
|
29
|
+
? "This update must REPLACE the previous digest with a shorter summary while preserving the anchor intent (the user's original goal)."
|
|
30
|
+
: "This update MERGES the previous digest with new context. Preserve everything that still matters; drop nothing important.";
|
|
31
|
+
|
|
32
|
+
return `You are the castlegate intent synthesizer. Maintain a compact digest of the user's session intent so downstream code can validate tool calls against it.
|
|
33
|
+
|
|
34
|
+
${instruction}
|
|
35
|
+
|
|
36
|
+
# Hard rules
|
|
37
|
+
- Stay under ${maxChars} characters total.
|
|
38
|
+
- Always preserve the ANCHOR INTENT (the user's original overarching goal) verbatim if it still appears to be the goal.
|
|
39
|
+
- Capture: (a) current objective, (b) active constraints/scope, (c) in-progress tasks, (d) explicit out-of-scope items, (e) recent intent shifts.
|
|
40
|
+
- Be terse. No prose, only structured bullets.
|
|
41
|
+
- NEVER execute actions or call tools — your only job is to update the digest text.
|
|
42
|
+
|
|
43
|
+
# Previous digest
|
|
44
|
+
${digestBlock}
|
|
45
|
+
|
|
46
|
+
# New messages since last digest update
|
|
47
|
+
${messagesBlock || "(none)"}
|
|
48
|
+
|
|
49
|
+
# Output format (REQUIRED)
|
|
50
|
+
Respond with a single JSON object, no prose, no code fences:
|
|
51
|
+
|
|
52
|
+
{"digest": "<the new digest body, ${maxChars} chars max>", "intentShift": "<one-sentence summary of any intent shift, or empty string if none>"}
|
|
53
|
+
|
|
54
|
+
JSON:`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseIntentUpdateResponse(raw: string): {
|
|
58
|
+
digest: string;
|
|
59
|
+
intentShift: string;
|
|
60
|
+
} {
|
|
61
|
+
const trimmed = raw.trim();
|
|
62
|
+
|
|
63
|
+
const fenceMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
|
|
64
|
+
const candidate = fenceMatch ? fenceMatch[1]!.trim() : trimmed;
|
|
65
|
+
|
|
66
|
+
let parsed: unknown;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(candidate);
|
|
69
|
+
} catch {
|
|
70
|
+
const objectMatch = /\{[\s\S]*\}/.exec(candidate);
|
|
71
|
+
if (!objectMatch) {
|
|
72
|
+
throw new Error("castlegate: intent update response is not valid JSON");
|
|
73
|
+
}
|
|
74
|
+
parsed = JSON.parse(objectMatch[0]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (
|
|
78
|
+
!parsed ||
|
|
79
|
+
typeof parsed !== "object" ||
|
|
80
|
+
typeof (parsed as Record<string, unknown>).digest !== "string"
|
|
81
|
+
) {
|
|
82
|
+
throw new Error("castlegate: intent update response missing 'digest' string field");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const obj = parsed as Record<string, unknown>;
|
|
86
|
+
return {
|
|
87
|
+
digest: (obj.digest as string).trim(),
|
|
88
|
+
intentShift: typeof obj.intentShift === "string" ? obj.intentShift.trim() : "",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function enforceMaxChars(digest: string, maxChars: number): string {
|
|
93
|
+
if (maxChars <= 0 || digest.length <= maxChars) return digest;
|
|
94
|
+
return digest.slice(0, maxChars);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function configForPrompt(cfg: CastlegateConfig): {
|
|
98
|
+
maxChars: number;
|
|
99
|
+
compactThreshold: number;
|
|
100
|
+
maxRecentMessages: number;
|
|
101
|
+
} {
|
|
102
|
+
return {
|
|
103
|
+
maxChars: cfg.intent.maxChars,
|
|
104
|
+
compactThreshold: cfg.intent.compactThreshold,
|
|
105
|
+
maxRecentMessages: cfg.intent.maxRecentMessages,
|
|
106
|
+
};
|
|
107
|
+
}
|