@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.
Files changed (47) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +192 -0
  3. package/cordis.patch.yml +13 -0
  4. package/icon.svg +6 -0
  5. package/lib/client.js +488 -0
  6. package/lib/compress-detect.js +98 -0
  7. package/lib/compress-files.js +155 -0
  8. package/lib/compress-pipeline.js +109 -0
  9. package/lib/compress-rules.js +308 -0
  10. package/lib/compress-validate.js +227 -0
  11. package/lib/frontmatter.js +347 -0
  12. package/lib/host.js +15 -0
  13. package/lib/index.js +616 -0
  14. package/lib/modes.js +126 -0
  15. package/lib/skills.js +177 -0
  16. package/lib/types/compress-detect.d.ts +18 -0
  17. package/lib/types/compress-files.d.ts +76 -0
  18. package/lib/types/compress-pipeline.d.ts +32 -0
  19. package/lib/types/compress-rules.d.ts +65 -0
  20. package/lib/types/compress-validate.d.ts +36 -0
  21. package/lib/types/frontmatter.d.ts +57 -0
  22. package/lib/types/host.d.ts +201 -0
  23. package/lib/types/index.d.ts +87 -0
  24. package/lib/types/modes.d.ts +102 -0
  25. package/lib/types/skills.d.ts +56 -0
  26. package/locale/en.json +6 -0
  27. package/locale/zh.json +6 -0
  28. package/package.json +112 -0
  29. package/scripts/sync-upstream.mjs +158 -0
  30. package/skills/cavecrew/SKILL.md +91 -0
  31. package/skills/cavecrew/cavecrew-builder.md +46 -0
  32. package/skills/cavecrew/cavecrew-investigator.md +56 -0
  33. package/skills/cavecrew/cavecrew-reviewer.md +47 -0
  34. package/skills/caveman/SKILL.md +103 -0
  35. package/skills/caveman-commit/SKILL.md +63 -0
  36. package/skills/caveman-compress/SKILL.md +105 -0
  37. package/skills/caveman-explore/SKILL.md +42 -0
  38. package/skills/caveman-help/SKILL.md +68 -0
  39. package/skills/caveman-review/SKILL.md +53 -0
  40. package/skills/caveman-stats/SKILL.md +30 -0
  41. package/skills/investigate-first/SKILL.md +16 -0
  42. package/skills/lean-build/SKILL.md +18 -0
  43. package/skills/migration/SKILL.md +17 -0
  44. package/skills/safe-refactor/SKILL.md +16 -0
  45. package/skills/surgical-patch/SKILL.md +16 -0
  46. package/skills/verify-and-stop/SKILL.md +16 -0
  47. 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
+ }