@maci0/dsh-caveman 0.16.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 -0
- package/README.md +192 -0
- package/cordis.patch.yml +13 -0
- package/icon.svg +6 -0
- package/lib/client.js +488 -0
- package/lib/compress-detect.js +98 -0
- package/lib/compress-files.js +155 -0
- package/lib/compress-pipeline.js +109 -0
- package/lib/compress-rules.js +308 -0
- package/lib/compress-validate.js +227 -0
- package/lib/frontmatter.js +347 -0
- package/lib/host.js +15 -0
- package/lib/index.js +616 -0
- package/lib/modes.js +126 -0
- package/lib/skills.js +177 -0
- package/lib/types/compress-detect.d.ts +18 -0
- package/lib/types/compress-files.d.ts +76 -0
- package/lib/types/compress-pipeline.d.ts +32 -0
- package/lib/types/compress-rules.d.ts +65 -0
- package/lib/types/compress-validate.d.ts +36 -0
- package/lib/types/frontmatter.d.ts +57 -0
- package/lib/types/host.d.ts +201 -0
- package/lib/types/index.d.ts +87 -0
- package/lib/types/modes.d.ts +102 -0
- package/lib/types/skills.d.ts +56 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +112 -0
- package/scripts/sync-upstream.mjs +158 -0
- package/skills/cavecrew/SKILL.md +91 -0
- package/skills/cavecrew/cavecrew-builder.md +46 -0
- package/skills/cavecrew/cavecrew-investigator.md +56 -0
- package/skills/cavecrew/cavecrew-reviewer.md +47 -0
- package/skills/caveman/SKILL.md +103 -0
- package/skills/caveman-commit/SKILL.md +63 -0
- package/skills/caveman-compress/SKILL.md +105 -0
- package/skills/caveman-explore/SKILL.md +42 -0
- package/skills/caveman-help/SKILL.md +68 -0
- package/skills/caveman-review/SKILL.md +53 -0
- package/skills/caveman-stats/SKILL.md +30 -0
- package/skills/investigate-first/SKILL.md +16 -0
- package/skills/lean-build/SKILL.md +18 -0
- package/skills/migration/SKILL.md +17 -0
- package/skills/safe-refactor/SKILL.md +16 -0
- package/skills/surgical-patch/SKILL.md +16 -0
- package/skills/verify-and-stop/SKILL.md +16 -0
- package/sync.manifest.json +25 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-caveman: Caveman terse-talk mode, as a DeepSeek Harness plugin.
|
|
3
|
+
*
|
|
4
|
+
* Four capabilities, all mounted through public Cordis extension points:
|
|
5
|
+
*
|
|
6
|
+
* - the bundled skills (`caveman`, `cavecrew`, `-commit`, `-review`,
|
|
7
|
+
* `-compress`, `-stats`, `-help`, plus six work patterns) become one
|
|
8
|
+
* `ctx.skills` provider;
|
|
9
|
+
* - while a level other than `off` is active, the mode-filtered ruleset is
|
|
10
|
+
* contributed to the system prompt on every assembly;
|
|
11
|
+
* - the level is switchable from the model (`caveman` tool) and the human
|
|
12
|
+
* (`/caveman` command);
|
|
13
|
+
* - the `caveman` settings namespace makes the level persistent and pairs with
|
|
14
|
+
* this package's browser half, which renders the card in the Web client's
|
|
15
|
+
* Plugins page, on the caveman row's Configure control.
|
|
16
|
+
*
|
|
17
|
+
* Skill content is adapted from the reference implementation
|
|
18
|
+
* (https://github.com/JuliusBrussee/caveman, MIT, © JuliusBrussee). Only the
|
|
19
|
+
* skill (talking-style) half is ported: the proxy, CLI verbs, and Cloud
|
|
20
|
+
* engine need an external runtime the harness has no extension point for.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-caveman
|
|
23
|
+
*/
|
|
24
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
25
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import z from '@deepseek-ai/schemastery';
|
|
29
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
30
|
+
import { buildModeInstructions, isDeactivationCommand, normalizeCommandMode, normalizeMode, resolveDefaultMode, RUNTIME_MODES, } from './modes.js';
|
|
31
|
+
import { createSkillProvider } from './skills.js';
|
|
32
|
+
import { compressFile } from './compress-pipeline.js';
|
|
33
|
+
import { MAX_FILE_SIZE } from './compress-files.js';
|
|
34
|
+
import { parseFrontmatter } from './frontmatter.js';
|
|
35
|
+
/** Plugin name as it appears in the loader. */
|
|
36
|
+
export const name = 'caveman';
|
|
37
|
+
/**
|
|
38
|
+
* Route the browser half reads for the level in use and its source. The card
|
|
39
|
+
* and the chip cannot see the env, the upstream config file, or a
|
|
40
|
+
* session-local level, so they ask the host instead of the settings document.
|
|
41
|
+
*/
|
|
42
|
+
export const LEVEL_ROUTE = '/caveman/level';
|
|
43
|
+
/**
|
|
44
|
+
* Every accepted level as a schema union, shared by the persisted settings and
|
|
45
|
+
* the plugin row so the accepted set is declared once.
|
|
46
|
+
*/
|
|
47
|
+
const ModeSchema = z.union([...RUNTIME_MODES]);
|
|
48
|
+
/** The levels a one-shot `once` call accepts: every level but `off`. */
|
|
49
|
+
const ONCE_MODES = RUNTIME_MODES.filter((mode) => mode !== 'off');
|
|
50
|
+
/**
|
|
51
|
+
* Row schema: the accepted levels and the size cap live here.
|
|
52
|
+
*
|
|
53
|
+
* Both fields are volatile, the only kind the settings document accepts: a
|
|
54
|
+
* change commits into the running config without remounting the plugin. Each is
|
|
55
|
+
* read at the moment it is used (the level at every prompt assembly, the size
|
|
56
|
+
* cap at every compress call), so an edit from the Plugins card takes effect on
|
|
57
|
+
* the next use rather than on a restart.
|
|
58
|
+
*
|
|
59
|
+
* `defaultMode` carries no default, so absence keeps flowing to
|
|
60
|
+
* `resolveDefaultMode`. A schema default would fill the field before `apply`,
|
|
61
|
+
* which would silently outrank `CAVEMAN_DEFAULT_MODE` and
|
|
62
|
+
* `~/.config/caveman/config.json`.
|
|
63
|
+
*/
|
|
64
|
+
export const Config = z.object({
|
|
65
|
+
defaultMode: ModeSchema.volatile(),
|
|
66
|
+
maxFileSize: z.number().min(1).default(MAX_FILE_SIZE).volatile(),
|
|
67
|
+
});
|
|
68
|
+
/** Upstream config file, read the way upstream reads it. */
|
|
69
|
+
const UPSTREAM_CONFIG_PATH = join(homedir(), '.config', 'caveman', 'config.json');
|
|
70
|
+
/**
|
|
71
|
+
* Read the upstream config file's `defaultMode`, ignoring everything that
|
|
72
|
+
* would make startup fail: a missing file, an unreadable file, invalid JSON,
|
|
73
|
+
* or a non-object document all mean "no file default".
|
|
74
|
+
* @param path - config file path; the upstream location unless tests override it.
|
|
75
|
+
* @returns the parsed document, or `undefined` when there is nothing usable.
|
|
76
|
+
*/
|
|
77
|
+
export function readUpstreamConfigFile(path = UPSTREAM_CONFIG_PATH) {
|
|
78
|
+
let source;
|
|
79
|
+
try {
|
|
80
|
+
if (!existsSync(path))
|
|
81
|
+
return undefined;
|
|
82
|
+
source = readFileSync(path, 'utf8');
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
let parsed;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(source);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
95
|
+
return undefined;
|
|
96
|
+
return parsed;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Mount the plugin.
|
|
100
|
+
* @param ctx - the host context.
|
|
101
|
+
* @param config - the schema-resolved row configuration.
|
|
102
|
+
*/
|
|
103
|
+
export function apply(ctx, config) {
|
|
104
|
+
// Reject configuration that would silently do the wrong thing. The loader
|
|
105
|
+
// already validated the row; this keeps a caller that bypasses it from
|
|
106
|
+
// mounting a bad level.
|
|
107
|
+
const configured = config.defaultMode.get();
|
|
108
|
+
if (configured !== undefined && normalizeMode(configured) === undefined) {
|
|
109
|
+
throw new Error(`[caveman] defaultMode must be one of ${RUNTIME_MODES.join(', ')}; got ${JSON.stringify(configured)}`);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The size cap as it stands for this call. Reading it per compress keeps a
|
|
113
|
+
* card edit live; the check runs on every read because the settings document
|
|
114
|
+
* only validates the schema's bounds, not the value a later writer left.
|
|
115
|
+
*/
|
|
116
|
+
const maxFileSize = () => {
|
|
117
|
+
const value = config.maxFileSize.get();
|
|
118
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
119
|
+
throw new Error(`[caveman] maxFileSize must be a positive number of bytes; got ${JSON.stringify(value)}`);
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
};
|
|
123
|
+
// Fail at load on a row that is already unusable, rather than on first use.
|
|
124
|
+
maxFileSize();
|
|
125
|
+
// `<package>/skills`, resolved from this module's own location.
|
|
126
|
+
const skillsDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
127
|
+
// The env and the upstream file are read once, at mount; the row is read at
|
|
128
|
+
// every use, so clearing it falls back to them rather than to its old value.
|
|
129
|
+
const envLevel = { CAVEMAN_DEFAULT_MODE: process.env['CAVEMAN_DEFAULT_MODE'] };
|
|
130
|
+
const configFile = readUpstreamConfigFile();
|
|
131
|
+
// Parsed once, at load: the ruleset is filtered per assembly, so the
|
|
132
|
+
// frontmatter must not have to be re-read for every request. A missing body
|
|
133
|
+
// means a broken install: fail while loading rather than injecting a silently
|
|
134
|
+
// truncated ruleset.
|
|
135
|
+
const skillBody = parseFrontmatter(readFileSync(join(skillsDir, 'caveman', 'SKILL.md'), 'utf8')).body.trimStart();
|
|
136
|
+
const warn = (message) => {
|
|
137
|
+
console.warn(`[caveman] ${message}`);
|
|
138
|
+
};
|
|
139
|
+
/** Session-local level, used when the profile write cannot hold the level. */
|
|
140
|
+
let override;
|
|
141
|
+
let modeGeneration = 0;
|
|
142
|
+
/**
|
|
143
|
+
* The level in use and its source. The row is live: a committed settings
|
|
144
|
+
* change updates the volatile reference in place.
|
|
145
|
+
*/
|
|
146
|
+
const activeLevel = () => override !== undefined
|
|
147
|
+
? { mode: override, source: 'session' }
|
|
148
|
+
: resolveDefaultMode({ configured: config.defaultMode.get(), env: envLevel, configFile });
|
|
149
|
+
const activeMode = () => activeLevel().mode;
|
|
150
|
+
/**
|
|
151
|
+
* Persist a level through the settings document; false when it cannot hold it.
|
|
152
|
+
* @param next - the level to write.
|
|
153
|
+
* @param signal - cancels the write when the calling tool was cancelled.
|
|
154
|
+
*/
|
|
155
|
+
const persist = async (next, signal) => {
|
|
156
|
+
const settings = ctx.get('settings');
|
|
157
|
+
const entryId = ctx.fiber?.entry?.options?.id;
|
|
158
|
+
if (settings === undefined || typeof entryId !== 'string' || normalizeMode(next) === undefined)
|
|
159
|
+
return false;
|
|
160
|
+
signal?.throwIfAborted();
|
|
161
|
+
let persisted;
|
|
162
|
+
try {
|
|
163
|
+
await settings.update(entryId, { defaultMode: next });
|
|
164
|
+
persisted = true;
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
warn(`could not persist level "${next}": ${error instanceof Error ? error.message : String(error)}`);
|
|
168
|
+
persisted = false;
|
|
169
|
+
}
|
|
170
|
+
// Outside the try: an abort is not a persistence failure to be swallowed.
|
|
171
|
+
signal?.throwIfAborted();
|
|
172
|
+
return persisted;
|
|
173
|
+
};
|
|
174
|
+
const setMode = async (next, signal) => {
|
|
175
|
+
signal?.throwIfAborted();
|
|
176
|
+
const started = ++modeGeneration;
|
|
177
|
+
const previous = activeMode();
|
|
178
|
+
const persisted = await persist(next, signal);
|
|
179
|
+
// A refused older request cannot restore a level the human already ended.
|
|
180
|
+
if (started === modeGeneration)
|
|
181
|
+
override = persisted ? undefined : next;
|
|
182
|
+
const mode = activeMode();
|
|
183
|
+
return { previous, mode, changed: mode !== previous };
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Turn the level off because the human's own message was a deactivation
|
|
187
|
+
* command.
|
|
188
|
+
*
|
|
189
|
+
* The override is set before the settings write is awaited: the durable
|
|
190
|
+
* `user/message` event arrives before the turn's prompt is assembled, and
|
|
191
|
+
* awaiting the document would let that same turn assemble with the ruleset
|
|
192
|
+
* still injected: the one turn the user just asked to end. A committed
|
|
193
|
+
* document then becomes the source of truth again, so the card and the
|
|
194
|
+
* prompt cannot disagree.
|
|
195
|
+
*/
|
|
196
|
+
const deactivateFromMessage = () => {
|
|
197
|
+
if (activeMode() === 'off')
|
|
198
|
+
return;
|
|
199
|
+
const started = ++modeGeneration;
|
|
200
|
+
override = 'off';
|
|
201
|
+
void persist('off').then((persisted) => {
|
|
202
|
+
if (persisted && started === modeGeneration)
|
|
203
|
+
override = undefined;
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
ctx.on('loader/volatile-update', () => {
|
|
207
|
+
modeGeneration += 1;
|
|
208
|
+
override = undefined;
|
|
209
|
+
});
|
|
210
|
+
ctx.inject(['systemPrompt'], (scope) => {
|
|
211
|
+
scope.systemPrompt.section({
|
|
212
|
+
name: 'caveman',
|
|
213
|
+
order: 700, // after the persona prefix, before tool guidance
|
|
214
|
+
// Evaluated at each assembly, so a level change lands on the next request.
|
|
215
|
+
// `off` returns empty text, which assembly drops.
|
|
216
|
+
text: () => buildModeInstructions({ mode: activeMode(), skillBody }),
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
ctx.inject(['skills'], (scope) => {
|
|
220
|
+
scope.skills.registerProvider(() => createSkillProvider({ skillsDir, onWarn: warn }));
|
|
221
|
+
});
|
|
222
|
+
ctx.inject(['tools'], (scope) => {
|
|
223
|
+
scope.tools.register(createModeTool(activeMode, setMode, (exec) => readSessionUsage(scope, exec)));
|
|
224
|
+
scope.tools.register(createCompressTool(maxFileSize));
|
|
225
|
+
});
|
|
226
|
+
ctx.inject(['commands'], (scope) => {
|
|
227
|
+
scope.commands.register({
|
|
228
|
+
name: 'caveman',
|
|
229
|
+
description: '🪨 Set the caveman level (lite, full, ultra, wenyan-*, off) or report the current one.',
|
|
230
|
+
input: { hint: 'lite | full | ultra | wenyan-lite | wenyan-full | wenyan-ultra | off' },
|
|
231
|
+
handler: async (invocation) => handleModeCommand(invocation, activeMode, setMode),
|
|
232
|
+
});
|
|
233
|
+
scope.commands.register({
|
|
234
|
+
name: 'caveman-compress',
|
|
235
|
+
description: '🗜 Compress a memory file with local rules (backup kept).',
|
|
236
|
+
input: { hint: '<filepath>' },
|
|
237
|
+
handler: async (invocation) => handleCompressCommand(invocation, maxFileSize()),
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
ctx.inject(['webServer', 'connection'], (scope) => {
|
|
241
|
+
scope.effect(() => scope.webServer.register({
|
|
242
|
+
kind: 'exact',
|
|
243
|
+
path: LEVEL_ROUTE,
|
|
244
|
+
handler: (req, res) => {
|
|
245
|
+
const rejection = scope.connection.requestRejection(req);
|
|
246
|
+
if (rejection !== undefined) {
|
|
247
|
+
res.statusCode = rejection;
|
|
248
|
+
res.end();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (req.method !== undefined && req.method !== 'GET') {
|
|
252
|
+
res.statusCode = 405;
|
|
253
|
+
res.setHeader('allow', 'GET');
|
|
254
|
+
res.end();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
res.statusCode = 200;
|
|
258
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
259
|
+
res.setHeader('cache-control', 'no-store');
|
|
260
|
+
res.end(JSON.stringify(activeLevel()));
|
|
261
|
+
},
|
|
262
|
+
}), `caveman: GET ${LEVEL_ROUTE}`);
|
|
263
|
+
});
|
|
264
|
+
// "stop caveman" / "normal mode" typed as an ordinary message, given the
|
|
265
|
+
// same effect as `/caveman off`. The command path is unaffected: this only
|
|
266
|
+
// claims messages that are exactly the command and come from the human.
|
|
267
|
+
ctx.on('session/event', (_session, event) => {
|
|
268
|
+
if (event.type !== 'user/message')
|
|
269
|
+
return;
|
|
270
|
+
const text = userMessageText(event.data);
|
|
271
|
+
if (text === undefined || !isDeactivationCommand(text))
|
|
272
|
+
return;
|
|
273
|
+
deactivateFromMessage();
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Read the plain text of a genuine user message.
|
|
278
|
+
*
|
|
279
|
+
* Injected context (skill bodies, file references, replayed history) rides the
|
|
280
|
+
* same event stream, so a message only counts when the harness marks it as the
|
|
281
|
+
* user's own; an injected instruction that happened to read "normal mode" must
|
|
282
|
+
* never toggle the level.
|
|
283
|
+
* @param data - the `user/message` event payload.
|
|
284
|
+
* @returns the concatenated text blocks, or `undefined` when this is not the
|
|
285
|
+
* human's own text.
|
|
286
|
+
*/
|
|
287
|
+
function userMessageText(data) {
|
|
288
|
+
if (data === null || typeof data !== 'object')
|
|
289
|
+
return undefined;
|
|
290
|
+
const message = data;
|
|
291
|
+
if (message.source?.kind !== 'user')
|
|
292
|
+
return undefined;
|
|
293
|
+
if (!Array.isArray(message.content))
|
|
294
|
+
return undefined;
|
|
295
|
+
const text = message.content
|
|
296
|
+
.map((block) => (block.type === 'text' && typeof block.text === 'string' ? block.text : ''))
|
|
297
|
+
.join('\n');
|
|
298
|
+
return text.trim() === '' ? undefined : text;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Read this session's cumulative provider-reported usage through the
|
|
302
|
+
* token-meter `tokenUsage` projection, when the host mounts it.
|
|
303
|
+
*
|
|
304
|
+
* Counts only what the provider reported: never a saving, a percentage, or
|
|
305
|
+
* a cost. `undefined` when the service, the session, or the unit is absent.
|
|
306
|
+
* @param scope - the tools-callback scope, which may carry `sessionProjections`.
|
|
307
|
+
* @param exec - the tool execution, carrying the calling agent's session.
|
|
308
|
+
* @returns the usage totals, or `undefined`.
|
|
309
|
+
*/
|
|
310
|
+
function readSessionUsage(scope, exec) {
|
|
311
|
+
// Read through the accessor: `sessionProjections` is an optional seam, and a
|
|
312
|
+
// Cordis context throws on a property access for a service it does not
|
|
313
|
+
// provide, which would turn "no usage available" into a failed tool call.
|
|
314
|
+
const projections = scope.get('sessionProjections');
|
|
315
|
+
const session = exec?.agent?.session;
|
|
316
|
+
if (projections === undefined || session === undefined)
|
|
317
|
+
return undefined;
|
|
318
|
+
let state;
|
|
319
|
+
try {
|
|
320
|
+
state = projections.stateOf(session, 'tokenUsage');
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
const totals = state?.totals;
|
|
326
|
+
if (totals === undefined)
|
|
327
|
+
return undefined;
|
|
328
|
+
// The unit's own bucket names, mapped to this plugin's labels.
|
|
329
|
+
return {
|
|
330
|
+
input: totals.uncachedInputTokens ?? 0,
|
|
331
|
+
output: totals.outputTokens ?? 0,
|
|
332
|
+
cacheRead: totals.cacheReadTokens ?? 0,
|
|
333
|
+
cacheWrite: totals.cacheWriteTokens ?? 0,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Build the model-facing level tool.
|
|
338
|
+
* @param getMode - reads the active level.
|
|
339
|
+
* @param setMode - applies and persists a level.
|
|
340
|
+
* @param getUsage - reads this session's provider-reported usage, when available.
|
|
341
|
+
* @returns the registered tool definition.
|
|
342
|
+
*/
|
|
343
|
+
function createModeTool(getMode, setMode, getUsage) {
|
|
344
|
+
return defineTool({
|
|
345
|
+
name: 'caveman',
|
|
346
|
+
// The `enum` below already names every level, and the injected ruleset
|
|
347
|
+
// explains what each one does; repeating both here only costs tokens.
|
|
348
|
+
description: 'Set or report the caveman level, which governs how terse replies are. '
|
|
349
|
+
+ 'The level persists in the user settings document. '
|
|
350
|
+
+ 'Call with no arguments to report the current level. '
|
|
351
|
+
+ 'A per-call `mode` applies to this call only and is not persisted.',
|
|
352
|
+
parameters: {
|
|
353
|
+
mode: {
|
|
354
|
+
type: 'string',
|
|
355
|
+
enum: [...RUNTIME_MODES],
|
|
356
|
+
description: 'Level to activate and persist. Omit to report the current level.',
|
|
357
|
+
},
|
|
358
|
+
once: {
|
|
359
|
+
type: 'string',
|
|
360
|
+
enum: [...ONCE_MODES],
|
|
361
|
+
description: 'Level for this call only. Not persisted; `mode` wins when both are given.',
|
|
362
|
+
},
|
|
363
|
+
usage: {
|
|
364
|
+
type: 'boolean',
|
|
365
|
+
description: 'Include this session’s provider-reported token totals (input, output, cache read/write). Never a saving.',
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
output: {
|
|
369
|
+
schema: {
|
|
370
|
+
type: 'object',
|
|
371
|
+
additionalProperties: false,
|
|
372
|
+
properties: {
|
|
373
|
+
mode: { type: 'string', enum: [...RUNTIME_MODES], required: true },
|
|
374
|
+
previous: { type: 'string', enum: [...RUNTIME_MODES], required: true },
|
|
375
|
+
changed: { type: 'boolean', required: true },
|
|
376
|
+
active: { type: 'boolean', required: true },
|
|
377
|
+
once: { type: 'string', enum: [...ONCE_MODES] },
|
|
378
|
+
usage: {
|
|
379
|
+
type: 'object',
|
|
380
|
+
additionalProperties: false,
|
|
381
|
+
properties: {
|
|
382
|
+
input: { type: 'number', required: true },
|
|
383
|
+
output: { type: 'number', required: true },
|
|
384
|
+
cacheRead: { type: 'number', required: true },
|
|
385
|
+
cacheWrite: { type: 'number', required: true },
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
render: (_args, value) => [{ type: 'text', text: renderModeResult(value) }],
|
|
391
|
+
},
|
|
392
|
+
async execute(args, exec) {
|
|
393
|
+
// A cancelled call must not start, and must not persist a level it can
|
|
394
|
+
// no longer report.
|
|
395
|
+
exec?.signal?.throwIfAborted();
|
|
396
|
+
const once = args.once;
|
|
397
|
+
const previous = getMode();
|
|
398
|
+
if (args.mode === undefined) {
|
|
399
|
+
return {
|
|
400
|
+
mode: once ?? previous,
|
|
401
|
+
previous,
|
|
402
|
+
changed: false,
|
|
403
|
+
active: (once ?? previous) !== 'off',
|
|
404
|
+
...(once !== undefined ? { once } : {}),
|
|
405
|
+
...usageField(args, exec, getUsage),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const applied = await setMode(args.mode, exec?.signal);
|
|
409
|
+
return {
|
|
410
|
+
mode: applied.mode,
|
|
411
|
+
previous: applied.previous,
|
|
412
|
+
changed: applied.changed,
|
|
413
|
+
active: applied.mode !== 'off',
|
|
414
|
+
...(once !== undefined ? { once } : {}),
|
|
415
|
+
...usageField(args, exec, getUsage),
|
|
416
|
+
};
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Read the optional `usage` flag's field: the session totals when asked and
|
|
422
|
+
* available, otherwise nothing. Savings are never inferred: the log has no
|
|
423
|
+
* unbuilt baseline to subtract.
|
|
424
|
+
*/
|
|
425
|
+
function usageField(args, exec, getUsage) {
|
|
426
|
+
if (getUsage === undefined)
|
|
427
|
+
return {};
|
|
428
|
+
if (args === null || typeof args !== 'object')
|
|
429
|
+
return {};
|
|
430
|
+
if (args['usage'] !== true)
|
|
431
|
+
return {};
|
|
432
|
+
const usage = getUsage(exec);
|
|
433
|
+
return usage === undefined ? {} : { usage };
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Build the model-facing compress tool. Local deterministic rules only:
|
|
437
|
+
* no model call, no bytes leave the machine.
|
|
438
|
+
* @param maxFileSize - reads the configured size cap in bytes, so a card edit
|
|
439
|
+
* applies to the next call.
|
|
440
|
+
* @returns the registered tool definition.
|
|
441
|
+
*/
|
|
442
|
+
function createCompressTool(maxFileSize) {
|
|
443
|
+
return defineTool({
|
|
444
|
+
name: 'caveman-compress',
|
|
445
|
+
description: 'Compress a natural-language file (memory file, todo list) with local '
|
|
446
|
+
+ 'caveman rules. Code, URLs, paths, and headings are preserved; the '
|
|
447
|
+
+ 'original is backed up out-of-tree.',
|
|
448
|
+
parameters: {
|
|
449
|
+
filepath: {
|
|
450
|
+
type: 'string',
|
|
451
|
+
required: true,
|
|
452
|
+
description: 'Path of the file to compress; a relative path resolves against the session working directory.',
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
output: {
|
|
456
|
+
schema: {
|
|
457
|
+
type: 'object',
|
|
458
|
+
additionalProperties: false,
|
|
459
|
+
properties: {
|
|
460
|
+
ok: { type: 'boolean', required: true },
|
|
461
|
+
reason: { type: 'string' },
|
|
462
|
+
backupPath: { type: 'string' },
|
|
463
|
+
originalChars: { type: 'number' },
|
|
464
|
+
compressedChars: { type: 'number' },
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
render: (_args, value) => [{ type: 'text', text: renderCompressResult(value) }],
|
|
468
|
+
},
|
|
469
|
+
async execute(args, exec) {
|
|
470
|
+
exec?.signal?.throwIfAborted();
|
|
471
|
+
// The schema owns the type; an empty path is the one shape it cannot see.
|
|
472
|
+
if (args.filepath.trim() === '') {
|
|
473
|
+
throw new Error('caveman-compress needs a filepath string.');
|
|
474
|
+
}
|
|
475
|
+
const target = sessionPath(args.filepath, exec?.agent);
|
|
476
|
+
const cwd = exec?.agent?.session?.header?.cwd;
|
|
477
|
+
// The model may be steered by file content it read, so it writes only inside
|
|
478
|
+
// the session workspace, as the harness's own write tool does by default.
|
|
479
|
+
// The human /caveman-compress names its own file and is not confined.
|
|
480
|
+
if (cwd !== undefined && !insideWorkspace(target, cwd)) {
|
|
481
|
+
return { ok: false, reason: `${args.filepath} is outside the session workspace ${cwd}.` };
|
|
482
|
+
}
|
|
483
|
+
const outcome = compressFile(target, maxFileSize());
|
|
484
|
+
// The write is atomic but not free; a cancelled call must not claim it.
|
|
485
|
+
exec?.signal?.throwIfAborted();
|
|
486
|
+
return outcome;
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Whether `target` (symlinks resolved) lies inside the `root` directory. A
|
|
492
|
+
* target that does not exist is judged by its resolved path; `compressFile`
|
|
493
|
+
* then reports it missing.
|
|
494
|
+
* @param target - absolute path the tool would rewrite.
|
|
495
|
+
* @param root - the session workspace.
|
|
496
|
+
* @returns true when the real target is the root or below it.
|
|
497
|
+
*/
|
|
498
|
+
function insideWorkspace(target, root) {
|
|
499
|
+
const real = (path) => (existsSync(path) ? realpathSync(path) : resolve(path));
|
|
500
|
+
const rel = relative(real(root), real(target));
|
|
501
|
+
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Resolve a file path the way the harness's own file tools do: a relative path
|
|
505
|
+
* means the calling agent's session workspace, not the server's launch
|
|
506
|
+
* directory. Without a session cwd the path is left for `compressFile` to
|
|
507
|
+
* resolve against the process cwd.
|
|
508
|
+
* @param filepath - the path as the model or the human wrote it.
|
|
509
|
+
* @param agent - the calling agent, when the host supplied one.
|
|
510
|
+
* @returns the path `compressFile` should open.
|
|
511
|
+
*/
|
|
512
|
+
function sessionPath(filepath, agent) {
|
|
513
|
+
const cwd = agent?.session?.header?.cwd;
|
|
514
|
+
return cwd === undefined ? filepath : resolve(cwd, filepath);
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Phrase one successful compression. Shared by the model-facing tool and the
|
|
518
|
+
* human command, which report the same three numbers.
|
|
519
|
+
* @param originalChars - body length before compression.
|
|
520
|
+
* @param compressedChars - body length after compression.
|
|
521
|
+
* @param backupPath - out-of-tree backup file path.
|
|
522
|
+
* @returns the sentence both surfaces report.
|
|
523
|
+
*/
|
|
524
|
+
function compressSentence(originalChars, compressedChars, backupPath) {
|
|
525
|
+
return `Compressed ${originalChars} to ${compressedChars} chars. Original backed up at ${backupPath}.`;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Render the canonical compress value for the model.
|
|
529
|
+
* @param value - the canonical value returned by `execute`.
|
|
530
|
+
* @returns model-facing prose.
|
|
531
|
+
*/
|
|
532
|
+
function renderCompressResult(value) {
|
|
533
|
+
const record = (value ?? {});
|
|
534
|
+
if (record['ok'] !== true) {
|
|
535
|
+
return typeof record['reason'] === 'string' ? record['reason'] : 'compression failed';
|
|
536
|
+
}
|
|
537
|
+
return compressSentence(typeof record['originalChars'] === 'number' ? record['originalChars'] : 0, typeof record['compressedChars'] === 'number' ? record['compressedChars'] : 0, typeof record['backupPath'] === 'string' ? record['backupPath'] : 'unknown');
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Phrase one level outcome. Shared by the model-facing tool and the human
|
|
541
|
+
* command, which report the same three transitions.
|
|
542
|
+
* @param mode - the level now active.
|
|
543
|
+
* @param previous - the level before the call.
|
|
544
|
+
* @param changed - whether the call moved the level.
|
|
545
|
+
* @returns the sentence both surfaces start from.
|
|
546
|
+
*/
|
|
547
|
+
function modeSentence(mode, previous, changed) {
|
|
548
|
+
if (!changed)
|
|
549
|
+
return `Caveman level: ${mode}.`;
|
|
550
|
+
return mode === 'off'
|
|
551
|
+
? `Caveman off (was ${previous}). Normal behavior.`
|
|
552
|
+
: `Caveman level: ${mode} (was ${previous}).`;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Render the canonical tool value for the model.
|
|
556
|
+
* @param value - the canonical value returned by `execute`.
|
|
557
|
+
* @returns model-facing prose.
|
|
558
|
+
*/
|
|
559
|
+
function renderModeResult(value) {
|
|
560
|
+
const record = (value ?? {});
|
|
561
|
+
const mode = typeof record['mode'] === 'string' ? record['mode'] : 'unknown';
|
|
562
|
+
const previous = typeof record['previous'] === 'string' ? record['previous'] : mode;
|
|
563
|
+
const changed = record['changed'] === true;
|
|
564
|
+
const active = record['active'] === true;
|
|
565
|
+
const once = typeof record['once'] === 'string' ? record['once'] : undefined;
|
|
566
|
+
const usage = record['usage'];
|
|
567
|
+
const core = (!changed && !active)
|
|
568
|
+
? 'Caveman is off. Normal behavior.'
|
|
569
|
+
: active
|
|
570
|
+
? `${modeSentence(mode, previous, changed)} The ruleset is injected into every request.`
|
|
571
|
+
: modeSentence(mode, previous, changed);
|
|
572
|
+
const onceLine = once !== undefined ? ` Reply to this call in ${once}; the persisted level is unchanged.` : '';
|
|
573
|
+
if (usage === undefined)
|
|
574
|
+
return `${core}${onceLine}`;
|
|
575
|
+
const line = (name) => typeof usage[name] === 'number' ? usage[name] : 0;
|
|
576
|
+
return `${core}${onceLine} Session usage so far: input ${line('input')}, output ${line('output')}, cache read ${line('cacheRead')}, cache write ${line('cacheWrite')}. Savings unknown without a measured comparison.`;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Handle the human `/caveman [level]` command.
|
|
580
|
+
* @param invocation - the command invocation.
|
|
581
|
+
* @param getMode - reads the active level.
|
|
582
|
+
* @param setMode - applies and persists a level.
|
|
583
|
+
* @returns the direct-UI result.
|
|
584
|
+
*/
|
|
585
|
+
async function handleModeCommand(invocation, getMode, setMode) {
|
|
586
|
+
const input = invocation.rawInput.trim().toLowerCase();
|
|
587
|
+
if (input === '')
|
|
588
|
+
return { kind: 'success', text: modeSentence(getMode(), getMode(), false) };
|
|
589
|
+
const requested = isDeactivationCommand(input) ? 'off' : normalizeCommandMode(input);
|
|
590
|
+
if (requested === undefined) {
|
|
591
|
+
return {
|
|
592
|
+
kind: 'error',
|
|
593
|
+
text: `Unknown caveman level "${invocation.rawInput.trim()}". Use one of: ${RUNTIME_MODES.join(', ')}.`,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
const { previous, mode, changed } = await setMode(requested);
|
|
597
|
+
return { kind: 'success', text: modeSentence(mode, previous, changed) };
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Handle the human `/caveman-compress <filepath>` command.
|
|
601
|
+
* @param invocation - the command invocation.
|
|
602
|
+
* @returns the direct-UI result.
|
|
603
|
+
*/
|
|
604
|
+
async function handleCompressCommand(invocation, maxFileSize) {
|
|
605
|
+
const filepath = invocation.rawInput.trim();
|
|
606
|
+
if (filepath === '') {
|
|
607
|
+
return { kind: 'error', text: 'Usage: /caveman-compress <filepath>' };
|
|
608
|
+
}
|
|
609
|
+
const outcome = compressFile(sessionPath(filepath, invocation.agent), maxFileSize);
|
|
610
|
+
if (!outcome.ok)
|
|
611
|
+
return { kind: 'error', text: outcome.reason };
|
|
612
|
+
return {
|
|
613
|
+
kind: 'success',
|
|
614
|
+
text: compressSentence(outcome.originalChars, outcome.compressedChars, outcome.backupPath),
|
|
615
|
+
};
|
|
616
|
+
}
|