skimpyclaw 0.3.9 → 0.3.10

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.
@@ -294,31 +294,58 @@ describe('formatSkillsPrompt', () => {
294
294
  it('returns empty string for no skills', () => {
295
295
  expect(formatSkillsPrompt([])).toBe('');
296
296
  });
297
- it('formats single skill with header', () => {
298
- const result = formatSkillsPrompt([makeSkill('greet', 'Say hello.')]);
299
- expect(result).toContain('## Active Skills');
300
- expect(result).toContain('### greet');
301
- expect(result).toContain('Say hello.');
302
- });
303
- it('includes emoji in header when present', () => {
304
- const result = formatSkillsPrompt([makeSkill('greet', 'Say hello.', '👋')]);
305
- expect(result).toContain('### 👋 greet');
306
- });
307
- it('separates multiple skills with dividers', () => {
308
- const result = formatSkillsPrompt([
309
- makeSkill('a', 'A body'),
310
- makeSkill('b', 'B body'),
311
- ]);
312
- expect(result).toContain('---');
313
- expect(result).toContain('### a');
314
- expect(result).toContain('### b');
315
- });
316
- it('does not skip skills based on prompt budget argument', () => {
317
- const result = formatSkillsPrompt([
318
- makeSkill('small', 'tiny'),
319
- makeSkill('big', 'x'.repeat(50000)),
320
- ], 100);
321
- expect(result).toContain('### small');
322
- expect(result).toContain('### big');
297
+ describe('dynamic loading (default)', () => {
298
+ it('formats skill catalog with names, descriptions, and paths', () => {
299
+ const result = formatSkillsPrompt([makeSkill('greet', 'Say hello.')]);
300
+ expect(result).toContain('## Skills');
301
+ expect(result).toContain('read the SKILL.md file');
302
+ expect(result).toContain('greet');
303
+ expect(result).toContain('`/fake/greet/SKILL.md`');
304
+ });
305
+ it('includes emoji in catalog', () => {
306
+ const result = formatSkillsPrompt([makeSkill('greet', 'Say hello.', '👋')]);
307
+ expect(result).toContain('👋 greet');
308
+ });
309
+ it('does not include skill body', () => {
310
+ const result = formatSkillsPrompt([makeSkill('greet', 'Say hello.')]);
311
+ expect(result).not.toContain('Say hello.');
312
+ });
313
+ it('lists multiple skills', () => {
314
+ const result = formatSkillsPrompt([
315
+ makeSkill('a', 'A body'),
316
+ makeSkill('b', 'B body'),
317
+ ]);
318
+ expect(result).toContain('| a |');
319
+ expect(result).toContain('| b |');
320
+ });
321
+ });
322
+ describe('inline loading (dynamicLoading=false)', () => {
323
+ it('formats single skill with header and body', () => {
324
+ const result = formatSkillsPrompt([makeSkill('greet', 'Say hello.')], undefined, false);
325
+ expect(result).toContain('## Active Skills');
326
+ expect(result).toContain('### greet');
327
+ expect(result).toContain('Say hello.');
328
+ });
329
+ it('includes emoji in header when present', () => {
330
+ const result = formatSkillsPrompt([makeSkill('greet', 'Say hello.', '👋')], undefined, false);
331
+ expect(result).toContain('### 👋 greet');
332
+ });
333
+ it('separates multiple skills with dividers', () => {
334
+ const result = formatSkillsPrompt([
335
+ makeSkill('a', 'A body'),
336
+ makeSkill('b', 'B body'),
337
+ ], undefined, false);
338
+ expect(result).toContain('---');
339
+ expect(result).toContain('### a');
340
+ expect(result).toContain('### b');
341
+ });
342
+ it('does not skip skills based on prompt budget argument', () => {
343
+ const result = formatSkillsPrompt([
344
+ makeSkill('small', 'tiny'),
345
+ makeSkill('big', 'x'.repeat(50000)),
346
+ ], 100, false);
347
+ expect(result).toContain('### small');
348
+ expect(result).toContain('### big');
349
+ });
323
350
  });
324
351
  });
@@ -1,27 +1,49 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, afterAll } from 'vitest';
2
2
  import { truncateToolResult } from '../providers/utils.js';
3
+ import { existsSync, readdirSync, unlinkSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { homedir } from 'os';
6
+ const scratchDir = join(homedir(), '.skimpyclaw', 'scratch');
7
+ // Clean up scratch files created during tests
8
+ afterAll(() => {
9
+ try {
10
+ if (existsSync(scratchDir)) {
11
+ for (const f of readdirSync(scratchDir)) {
12
+ try {
13
+ unlinkSync(join(scratchDir, f));
14
+ }
15
+ catch { /* ignore */ }
16
+ }
17
+ }
18
+ }
19
+ catch { /* ignore */ }
20
+ });
3
21
  describe('token efficiency', () => {
4
22
  describe('truncateToolResult', () => {
5
23
  it('returns short results unchanged', () => {
6
24
  const result = 'short result';
7
25
  expect(truncateToolResult(result)).toBe(result);
8
26
  });
9
- it('truncates at exact boundary', () => {
10
- const result = 'x'.repeat(10_240);
11
- expect(truncateToolResult(result)).toBe(result); // exactly at limit
27
+ it('returns results under mask threshold unchanged', () => {
28
+ const result = 'x'.repeat(7_999);
29
+ expect(truncateToolResult(result)).toBe(result);
12
30
  });
13
- it('truncates over limit with notice', () => {
14
- const result = 'x'.repeat(20_000);
15
- const truncated = truncateToolResult(result);
16
- expect(truncated.length).toBeLessThan(result.length);
17
- expect(truncated).toContain('[Truncated: 20000 chars total]');
18
- expect(truncated.startsWith('x'.repeat(10_240))).toBe(true);
31
+ it('masks large results to scratch file with summary', () => {
32
+ const result = 'START' + 'x'.repeat(10_000) + 'END';
33
+ const masked = truncateToolResult(result);
34
+ expect(masked.length).toBeLessThan(result.length);
35
+ expect(masked).toContain('[Full output');
36
+ expect(masked).toContain('saved to');
37
+ expect(masked).toContain('.skimpyclaw/scratch/');
38
+ expect(masked).toContain('use Read tool to access');
39
+ // Summary includes head and tail
40
+ expect(masked).toContain('START');
41
+ expect(masked).toContain('END');
19
42
  });
20
- it('respects custom maxBytes', () => {
21
- const result = 'abcdefghij'; // 10 chars
22
- const truncated = truncateToolResult(result, 5);
23
- expect(truncated).toContain('[Truncated: 10 chars total]');
24
- expect(truncated.startsWith('abcde')).toBe(true);
43
+ it('includes char count in masked output', () => {
44
+ const result = 'y'.repeat(20_000);
45
+ const masked = truncateToolResult(result);
46
+ expect(masked).toContain('20000 chars');
25
47
  });
26
48
  });
27
49
  describe('retry prompt compression', () => {
package/dist/agent.js CHANGED
@@ -52,7 +52,7 @@ export function buildSystemPrompt(agentId, skillsContext) {
52
52
  cronJobId: skillsContext?.cronJobId,
53
53
  tags: skillsContext?.tags,
54
54
  });
55
- skillsSection = formatSkillsPrompt(contextSkills, skillsContext?.skillConfig?.maxPromptTokens);
55
+ skillsSection = formatSkillsPrompt(contextSkills, skillsContext?.skillConfig?.maxPromptTokens, skillsContext?.skillConfig?.dynamicLoading);
56
56
  }
57
57
  const base = [soul, identity, tools, skillsSection].filter(Boolean).join('\n\n---\n\n');
58
58
  const userContext = [user, memory].filter(Boolean).join('\n\n');
@@ -54,8 +54,12 @@ export declare function getProvider(model: string): string;
54
54
  * Strip provider prefix from model name.
55
55
  */
56
56
  export declare function stripProvider(model: string, openaiClients?: Map<string, unknown>, responsesApiProviders?: Set<string>): string;
57
- /** Truncate tool result to maxBytes. Appends truncation notice. */
58
- export declare function truncateToolResult(result: string, maxBytes?: number): string;
57
+ /**
58
+ * Mask large tool outputs by writing to scratch files.
59
+ * Returns the original result if small enough, or a summary + file path if large.
60
+ * Falls back to simple truncation if file write fails.
61
+ */
62
+ export declare function truncateToolResult(result: string, _maxBytes?: number): string;
59
63
  /**
60
64
  * Build thinking config based on thinking level.
61
65
  */
@@ -1,4 +1,7 @@
1
1
  // Provider Utilities
2
+ import { writeFileSync, mkdirSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
2
5
  // Anti-hallucination instructions injected between the Claude Code identity
3
6
  // block and the actual system prompt. Prevents the model from roleplaying
4
7
  // Claude Code's full behavior (XML tool calls, fabricated output, etc.)
@@ -178,10 +181,39 @@ export function stripProvider(model, openaiClients, responsesApiProviders) {
178
181
  return model;
179
182
  }
180
183
  /** Truncate tool result to maxBytes. Appends truncation notice. */
181
- export function truncateToolResult(result, maxBytes = 10_240) {
182
- if (result.length <= maxBytes)
184
+ /**
185
+ * Observation masking threshold. Tool outputs above this size are written to
186
+ * a scratch file and replaced with a compact summary + file path.
187
+ * Outputs below this are returned inline (no file I/O overhead).
188
+ */
189
+ const MASK_THRESHOLD = 8_000; // ~2000 tokens
190
+ /**
191
+ * Mask large tool outputs by writing to scratch files.
192
+ * Returns the original result if small enough, or a summary + file path if large.
193
+ * Falls back to simple truncation if file write fails.
194
+ */
195
+ export function truncateToolResult(result, _maxBytes = 10_240) {
196
+ if (result.length <= MASK_THRESHOLD)
183
197
  return result;
184
- return result.slice(0, maxBytes) + `\n\n[Truncated: ${result.length} chars total]`;
198
+ try {
199
+ const scratchDir = join(homedir(), '.skimpyclaw', 'scratch');
200
+ if (!existsSync(scratchDir))
201
+ mkdirSync(scratchDir, { recursive: true });
202
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
203
+ const filePath = join(scratchDir, `${id}.txt`);
204
+ writeFileSync(filePath, result);
205
+ // Build a compact summary: first 500 chars + last 500 chars
206
+ const head = result.slice(0, 500);
207
+ const tail = result.slice(-500);
208
+ const summary = head + (result.length > 1000 ? '\n...\n' + tail : '');
209
+ console.log(`[context-manager] Masked ${result.length} chars → ${filePath}`);
210
+ return `${summary}\n\n[Full output (${result.length} chars) saved to ${filePath} — use Read tool to access]`;
211
+ }
212
+ catch (err) {
213
+ // Fallback: simple truncation
214
+ console.warn(`[context-manager] Masking failed: ${err instanceof Error ? err.message : err}`);
215
+ return result.slice(0, MASK_THRESHOLD) + `\n\n[Truncated: ${result.length} chars total]`;
216
+ }
185
217
  }
186
218
  /**
187
219
  * Build thinking config based on thinking level.
package/dist/service.js CHANGED
@@ -6,12 +6,37 @@ import { initProviders } from './agent.js';
6
6
  import { initLangfuse, shutdownLangfuse } from './langfuse.js';
7
7
  import { restoreCodeAgentTasks, setCodeAgentConfig } from './tools.js';
8
8
  import { releaseAll, cleanupOrphans, setRuntime, probeRuntime } from './sandbox/index.js';
9
+ /** Clean up old scratch files (observation masking). Keeps files < 24h. */
10
+ function cleanupScratch() {
11
+ try {
12
+ const { readdirSync, statSync, unlinkSync } = require('fs');
13
+ const { join } = require('path');
14
+ const { homedir } = require('os');
15
+ const dir = join(homedir(), '.skimpyclaw', 'scratch');
16
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
17
+ let count = 0;
18
+ for (const f of readdirSync(dir)) {
19
+ const p = join(dir, f);
20
+ try {
21
+ if (statSync(p).mtimeMs < cutoff) {
22
+ unlinkSync(p);
23
+ count++;
24
+ }
25
+ }
26
+ catch { /* skip */ }
27
+ }
28
+ if (count > 0)
29
+ console.log(`[scratch] Cleaned up ${count} old file(s)`);
30
+ }
31
+ catch { /* dir doesn't exist yet, fine */ }
32
+ }
9
33
  export async function startRuntime(config) {
10
34
  const smokeTest = process.env.SKIMPYCLAW_SMOKE_TEST === '1';
11
35
  initLangfuse(config);
12
36
  initProviders(config);
13
37
  restoreCodeAgentTasks();
14
38
  setCodeAgentConfig(config);
39
+ cleanupScratch();
15
40
  // Initialize sandbox runtime if configured — auto-disable if no runtime available
16
41
  if (config.sandbox?.enabled) {
17
42
  const detected = probeRuntime(config.sandbox.runtime);
@@ -62,4 +62,10 @@ export interface SkillConfig {
62
62
  entries?: Record<string, boolean>;
63
63
  /** Max approximate tokens for injected skills prompt (default: 4000) */
64
64
  maxPromptTokens?: number;
65
+ /**
66
+ * Dynamic loading: only include skill names and descriptions in the system prompt.
67
+ * Full skill content is loaded on-demand via the Read tool.
68
+ * Default: true (progressive disclosure)
69
+ */
70
+ dynamicLoading?: boolean;
65
71
  }
package/dist/skills.d.ts CHANGED
@@ -27,5 +27,9 @@ export declare function getSkillsForContext(skills: LoadedSkill[], context?: {
27
27
  }): LoadedSkill[];
28
28
  /**
29
29
  * Format eligible, context-filtered skills into a markdown prompt section.
30
+ *
31
+ * When dynamicLoading is true (default), only skill names, descriptions, and
32
+ * file paths are included. The agent loads full content on-demand via the Read tool.
33
+ * When false, full skill bodies are inlined (legacy behavior).
30
34
  */
31
- export declare function formatSkillsPrompt(skills: LoadedSkill[], _maxTokens?: number): string;
35
+ export declare function formatSkillsPrompt(skills: LoadedSkill[], _maxTokens?: number, dynamicLoading?: boolean): string;
package/dist/skills.js CHANGED
@@ -237,12 +237,35 @@ export function getSkillsForContext(skills, context) {
237
237
  }
238
238
  /**
239
239
  * Format eligible, context-filtered skills into a markdown prompt section.
240
+ *
241
+ * When dynamicLoading is true (default), only skill names, descriptions, and
242
+ * file paths are included. The agent loads full content on-demand via the Read tool.
243
+ * When false, full skill bodies are inlined (legacy behavior).
240
244
  */
241
- export function formatSkillsPrompt(skills, _maxTokens) {
245
+ export function formatSkillsPrompt(skills, _maxTokens, dynamicLoading) {
242
246
  if (skills.length === 0)
243
247
  return '';
248
+ // Default to dynamic loading
249
+ const useDynamic = dynamicLoading !== false;
250
+ if (useDynamic) {
251
+ const lines = [
252
+ '## Skills',
253
+ '',
254
+ 'Available skills — read the SKILL.md file with the Read tool when the task matches a skill\'s description.',
255
+ '',
256
+ '| Skill | Description | Path |',
257
+ '|-------|-------------|------|',
258
+ ];
259
+ for (const skill of skills) {
260
+ const emoji = skill.frontmatter.emoji ? `${skill.frontmatter.emoji} ` : '';
261
+ const desc = skill.frontmatter.description || '(no description)';
262
+ const path = join(skill.dirPath, 'SKILL.md');
263
+ lines.push(`| ${emoji}${skill.name} | ${desc} | \`${path}\` |`);
264
+ }
265
+ return lines.join('\n');
266
+ }
267
+ // Legacy: inline full skill bodies
244
268
  const sections = [];
245
- // Header
246
269
  const header = '## Active Skills\n';
247
270
  for (const skill of skills) {
248
271
  const emoji = skill.frontmatter.emoji ? `${skill.frontmatter.emoji} ` : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skimpyclaw",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Lightweight personal AI assistant with Telegram and Discord integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,25 +16,6 @@
16
16
  "README.md",
17
17
  "LICENSE"
18
18
  ],
19
- "scripts": {
20
- "cli": "tsx src/cli.ts",
21
- "start": "tsx src/index.ts",
22
- "dev": "tsx watch src/index.ts",
23
- "dashboard:dev": "pnpm --dir web/dashboard dev",
24
- "dashboard:build": "pnpm --dir web/dashboard install --frozen-lockfile && pnpm --dir web/dashboard build",
25
- "docs:dev": "pnpm --dir docs dev",
26
- "docs:build": "pnpm --dir docs install --frozen-lockfile && pnpm --dir docs build",
27
- "docs:preview": "pnpm --dir docs preview",
28
- "setup": "tsx src/setup.ts",
29
- "onboard": "tsx src/cli.ts onboard",
30
- "build": "tsc && pnpm dashboard:build",
31
- "release:check": "pnpm build && pnpm test",
32
- "release:local": "bash ./scripts/release.sh",
33
- "lint": "eslint \"src/**/*.ts\"",
34
- "typecheck": "tsc --noEmit",
35
- "test": "vitest run",
36
- "ci": "pnpm run lint && pnpm run typecheck && pnpm run test"
37
- },
38
19
  "dependencies": {
39
20
  "@anthropic-ai/sdk": "^0.52.0",
40
21
  "@grammyjs/runner": "^2.0.3",
@@ -56,11 +37,6 @@
56
37
  "openai": "^4.47.0",
57
38
  "playwright": "^1.49.0"
58
39
  },
59
- "pnpm": {
60
- "onlyBuiltDependencies": [
61
- "esbuild"
62
- ]
63
- },
64
40
  "devDependencies": {
65
41
  "@eslint/js": "^9.39.2",
66
42
  "@types/node": "^20.11.0",
@@ -70,5 +46,24 @@
70
46
  "typescript": "^5.4.0",
71
47
  "typescript-eslint": "^8.54.0",
72
48
  "vitest": "^4.0.18"
49
+ },
50
+ "scripts": {
51
+ "cli": "tsx src/cli.ts",
52
+ "start": "tsx src/index.ts",
53
+ "dev": "tsx watch src/index.ts",
54
+ "dashboard:dev": "pnpm --dir web/dashboard dev",
55
+ "dashboard:build": "pnpm --dir web/dashboard install --frozen-lockfile && pnpm --dir web/dashboard build",
56
+ "docs:dev": "pnpm --dir docs dev",
57
+ "docs:build": "pnpm --dir docs install --frozen-lockfile && pnpm --dir docs build",
58
+ "docs:preview": "pnpm --dir docs preview",
59
+ "setup": "tsx src/setup.ts",
60
+ "onboard": "tsx src/cli.ts onboard",
61
+ "build": "tsc && pnpm dashboard:build",
62
+ "release:check": "pnpm build && pnpm test",
63
+ "release:local": "bash ./scripts/release.sh",
64
+ "lint": "eslint \"src/**/*.ts\"",
65
+ "typecheck": "tsc --noEmit",
66
+ "test": "vitest run",
67
+ "ci": "pnpm run lint && pnpm run typecheck && pnpm run test"
73
68
  }
74
- }
69
+ }