skimpyclaw 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +14 -6
  2. package/dist/__tests__/api.test.js +1 -0
  3. package/dist/__tests__/channels.test.js +1 -1
  4. package/dist/__tests__/code-agents-orchestrator.test.js +74 -7
  5. package/dist/__tests__/code-agents-sandbox.test.d.ts +1 -0
  6. package/dist/__tests__/code-agents-sandbox.test.js +163 -0
  7. package/dist/__tests__/context-manager.test.d.ts +1 -0
  8. package/dist/__tests__/context-manager.test.js +236 -0
  9. package/dist/__tests__/package-manager-detection.test.js +5 -5
  10. package/dist/__tests__/setup.test.js +7 -5
  11. package/dist/__tests__/skills.test.js +2 -2
  12. package/dist/__tests__/structured-context.test.d.ts +1 -0
  13. package/dist/__tests__/structured-context.test.js +100 -0
  14. package/dist/__tests__/tools.test.js +65 -3
  15. package/dist/agent.js +4 -5
  16. package/dist/api.js +10 -58
  17. package/dist/audit.js +5 -51
  18. package/dist/channels/telegram/handlers.js +2 -60
  19. package/dist/channels/telegram/index.js +0 -7
  20. package/dist/channels.js +1 -1
  21. package/dist/cli.js +151 -16
  22. package/dist/code-agents/executor.d.ts +9 -4
  23. package/dist/code-agents/executor.js +187 -13
  24. package/dist/code-agents/index.d.ts +1 -1
  25. package/dist/code-agents/index.js +23 -21
  26. package/dist/code-agents/orchestrator.d.ts +8 -2
  27. package/dist/code-agents/orchestrator.js +297 -27
  28. package/dist/code-agents/structured-context.d.ts +7 -0
  29. package/dist/code-agents/structured-context.js +54 -0
  30. package/dist/code-agents/types.d.ts +2 -0
  31. package/dist/code-agents/utils.js +12 -2
  32. package/dist/code-agents/worktree.d.ts +40 -0
  33. package/dist/code-agents/worktree.js +215 -0
  34. package/dist/config.d.ts +1 -0
  35. package/dist/config.js +5 -3
  36. package/dist/cron.js +18 -4
  37. package/dist/dashboard/assets/{index-CkonC7Cd.js → index-BoTHPby4.js} +20 -20
  38. package/dist/dashboard/assets/{index-EAg6lqF5.css → index-D4mufvBg.css} +1 -1
  39. package/dist/dashboard/index.html +2 -2
  40. package/dist/discord.js +4 -40
  41. package/dist/exec-approval.js +1 -1
  42. package/dist/file-lock.js +1 -1
  43. package/dist/gateway.js +3 -10
  44. package/dist/providers/anthropic.js +9 -5
  45. package/dist/providers/codex.js +10 -6
  46. package/dist/providers/context-manager.d.ts +22 -0
  47. package/dist/providers/context-manager.js +100 -0
  48. package/dist/providers/openai.js +9 -5
  49. package/dist/providers/types.d.ts +1 -0
  50. package/dist/security.js +9 -0
  51. package/dist/setup.js +112 -14
  52. package/dist/skills.js +9 -2
  53. package/dist/subagent.js +33 -2
  54. package/dist/tools/bash-tool.js +8 -0
  55. package/dist/tools/browser-tool.js +2 -1
  56. package/dist/tools/definitions.d.ts +0 -27
  57. package/dist/tools/definitions.js +0 -18
  58. package/dist/tools/execute-context.d.ts +4 -4
  59. package/dist/tools/file-tools.d.ts +1 -1
  60. package/dist/tools/file-tools.js +1 -1
  61. package/dist/tools.d.ts +5 -5
  62. package/dist/tools.js +87 -98
  63. package/dist/types.d.ts +14 -22
  64. package/dist/usage.d.ts +1 -0
  65. package/dist/usage.js +30 -46
  66. package/dist/utils.d.ts +18 -0
  67. package/dist/utils.js +71 -0
  68. package/dist/voice.js +9 -7
  69. package/package.json +1 -1
package/dist/tools.d.ts CHANGED
@@ -1,21 +1,21 @@
1
1
  import type { ToolConfig } from './types.js';
2
- import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, SPAWN_SUBAGENT_TOOL, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL } from './tools/definitions.js';
2
+ import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL } from './tools/definitions.js';
3
3
  import type { ExecuteToolContext } from './tools/execute-context.js';
4
4
  import { cleanupBrowser } from './tools/browser-tool.js';
5
5
  export { getActiveCodeAgents, getRecentCodeAgents, getAllCodeAgents, getCodeAgent, cancelCodeAgent, restoreCodeAgentTasks, setCodeAgentConfig, computeWaves, decomposeTask, synthesizeResults, runValidation, runCodeAgentBackground, buildCodeAgentArgs, readTeamState, parseStreamJsonForLive, } from './code-agents/index.js';
6
- export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, SPAWN_SUBAGENT_TOOL, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
6
+ export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
7
7
  export type { ExecuteToolContext };
8
8
  export type { CodeAgentTask, DecomposedSubtask } from './code-agents/index.js';
9
9
  export declare function discoverMcpTools(): Promise<any[]>;
10
10
  export declare function clearMcpToolCache(): void;
11
11
  /**
12
- * Get all available tool definitions: built-ins + browser (if enabled) + MCP (auto-discovered) + spawn_subagent.
12
+ * Get all available tool definitions: built-ins + browser (if enabled) + MCP (auto-discovered) + agent tools.
13
13
  * This is the primary way to get tools — replaces the static TOOL_DEFINITIONS export.
14
- * Pass includeSpawnSubagent: true to include the spawn_subagent tool (e.g. for Telegram conversations).
14
+ * Pass includeAgentTools: true to include code_with_agent, code_with_team, check_code_agent.
15
15
  * Results are cached for 60s to avoid rebuilding the array on every agent turn.
16
16
  */
17
17
  export declare function getToolDefinitions(config?: ToolConfig, options?: {
18
- includeSpawnSubagent?: boolean;
18
+ includeAgentTools?: boolean;
19
19
  includeMcp?: boolean;
20
20
  projects?: Record<string, string>;
21
21
  }): Promise<any[]>;
package/dist/tools.js CHANGED
@@ -1,13 +1,16 @@
1
1
  // Tool definitions and executors for Anthropic API tool_use
2
- import { join } from 'path';
2
+ import { join, resolve } from 'path';
3
3
  import { homedir } from 'os';
4
4
  import { TTLCache } from './cache.js';
5
- import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, SPAWN_SUBAGENT_TOOL, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, } from './tools/definitions.js';
5
+ import { toErrorMessage } from './utils.js';
6
+ import { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, } from './tools/definitions.js';
6
7
  import { executeReadFile, executeWriteFileLocked, executeListDirectory } from './tools/file-tools.js';
7
8
  import { executeBash } from './tools/bash-tool.js';
8
9
  import { executeBrowser, cleanupBrowser } from './tools/browser-tool.js';
9
10
  import { ensureContainer, SANDBOX_DEFAULTS, translatePath, validateMountPaths } from './sandbox/index.js';
10
11
  import { sandboxBash, sandboxReadFile, sandboxWriteFile, sandboxListDir, sandboxGlob } from './sandbox/index.js';
12
+ import { isBashCommandSafe } from './security.js';
13
+ import { classifyCommandRisk, requiresApproval } from './exec-approval.js';
11
14
  // Re-export from code-agents module for backward compatibility
12
15
  export {
13
16
  // Registry functions
@@ -23,7 +26,7 @@ buildCodeAgentArgs, readTeamState,
23
26
  // Parsers
24
27
  parseStreamJsonForLive, } from './code-agents/index.js';
25
28
  // Re-export from definitions
26
- export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, SPAWN_SUBAGENT_TOOL, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
29
+ export { fromClaudeCodeName, toClaudeCodeName, BUILTIN_TOOL_DEFINITIONS, BROWSER_TOOL_DEFINITION, TOOL_DEFINITIONS, CODE_WITH_AGENT_TOOL, CODE_WITH_TEAM_TOOL, CHECK_CODE_AGENT_TOOL, cleanupBrowser, };
27
30
  // --- MCP (mcporter) ---
28
31
  let mcpRuntime = null;
29
32
  async function getMcpRuntime() {
@@ -82,83 +85,78 @@ export function clearMcpToolCache() {
82
85
  discoveredMcpTools = null;
83
86
  }
84
87
  const toolDefsCache = new TTLCache(60_000);
88
+ /** Inject project names into a tool's workdir description, or return the tool unchanged. */
89
+ function injectProjects(tool, projects) {
90
+ if (!projects || Object.keys(projects).length === 0)
91
+ return tool;
92
+ const projectList = Object.entries(projects)
93
+ .map(([name, path]) => `"${name}" → ${path}`)
94
+ .join(', ');
95
+ return {
96
+ ...tool,
97
+ input_schema: {
98
+ ...tool.input_schema,
99
+ properties: {
100
+ ...tool.input_schema.properties,
101
+ workdir: {
102
+ type: 'string',
103
+ description: `Working directory or project name. Named projects: ${projectList}. Default: SkimpyClaw repo root.`,
104
+ },
105
+ },
106
+ },
107
+ };
108
+ }
85
109
  /**
86
- * Get all available tool definitions: built-ins + browser (if enabled) + MCP (auto-discovered) + spawn_subagent.
110
+ * Get all available tool definitions: built-ins + browser (if enabled) + MCP (auto-discovered) + agent tools.
87
111
  * This is the primary way to get tools — replaces the static TOOL_DEFINITIONS export.
88
- * Pass includeSpawnSubagent: true to include the spawn_subagent tool (e.g. for Telegram conversations).
112
+ * Pass includeAgentTools: true to include code_with_agent, code_with_team, check_code_agent.
89
113
  * Results are cached for 60s to avoid rebuilding the array on every agent turn.
90
114
  */
91
115
  export async function getToolDefinitions(config, options) {
92
116
  const includeMcp = options?.includeMcp !== false; // default true for backwards compat
117
+ const profile = config?.toolProfile ?? 'full';
93
118
  const cacheKey = JSON.stringify({
94
119
  browser: config?.browser?.enabled,
95
- spawn: options?.includeSpawnSubagent,
120
+ agentTools: options?.includeAgentTools,
96
121
  mcp: includeMcp,
97
122
  projects: options?.projects,
123
+ profile,
98
124
  });
99
125
  const cached = toolDefsCache.get(cacheKey);
100
126
  if (cached)
101
127
  return cached;
102
128
  const tools = [...BUILTIN_TOOL_DEFINITIONS];
129
+ // Minimal profile: only the 4 built-in tools (Read, Write, Glob, Bash).
130
+ // Used by orchestrator decompose/synthesize calls.
131
+ if (profile === 'minimal') {
132
+ toolDefsCache.set(cacheKey, tools);
133
+ return tools;
134
+ }
103
135
  // Include browser tool only when explicitly enabled
104
136
  if (config?.browser?.enabled) {
105
137
  tools.push(BROWSER_TOOL_DEFINITION);
106
138
  }
139
+ // Coding profile: built-ins + browser + code_with_agent + check_code_agent.
140
+ // Skips MCP discovery and code_with_team.
141
+ if (profile === 'coding') {
142
+ if (options?.includeAgentTools) {
143
+ tools.push(injectProjects(CODE_WITH_AGENT_TOOL, options.projects));
144
+ tools.push(CHECK_CODE_AGENT_TOOL);
145
+ }
146
+ toolDefsCache.set(cacheKey, tools);
147
+ return tools;
148
+ }
149
+ // Full profile (default): everything including MCP and code_with_team.
107
150
  // Auto-discover MCP tools from mcporter config (only for Anthropic models)
108
151
  if (includeMcp) {
109
152
  const mcpTools = await discoverMcpTools();
110
153
  tools.push(...mcpTools);
111
154
  }
112
- // Include spawn_subagent, code_with_agent, and check_code_agent tools when requested
113
- if (options?.includeSpawnSubagent) {
114
- tools.push(SPAWN_SUBAGENT_TOOL);
115
- // Inject project names into code_with_agent description so the model knows what to use
155
+ // Include code_with_agent, code_with_team, and check_code_agent when requested
156
+ if (options?.includeAgentTools) {
116
157
  const projects = options.projects;
117
- if (projects && Object.keys(projects).length > 0) {
118
- const projectList = Object.entries(projects)
119
- .map(([name, path]) => `"${name}" → ${path}`)
120
- .join(', ');
121
- const codeAgentWithProjects = {
122
- ...CODE_WITH_AGENT_TOOL,
123
- input_schema: {
124
- ...CODE_WITH_AGENT_TOOL.input_schema,
125
- properties: {
126
- ...CODE_WITH_AGENT_TOOL.input_schema.properties,
127
- workdir: {
128
- type: 'string',
129
- description: `Working directory or project name. Named projects: ${projectList}. Default: SkimpyClaw repo root.`,
130
- },
131
- },
132
- },
133
- };
134
- tools.push(codeAgentWithProjects);
135
- }
136
- else {
137
- tools.push(CODE_WITH_AGENT_TOOL);
138
- }
139
- // Inject project names into code_with_team description too
140
- if (projects && Object.keys(projects).length > 0) {
141
- const projectList = Object.entries(projects)
142
- .map(([name, path]) => `"${name}" → ${path}`)
143
- .join(', ');
144
- const codeTeamWithProjects = {
145
- ...CODE_WITH_TEAM_TOOL,
146
- input_schema: {
147
- ...CODE_WITH_TEAM_TOOL.input_schema,
148
- properties: {
149
- ...CODE_WITH_TEAM_TOOL.input_schema.properties,
150
- workdir: {
151
- type: 'string',
152
- description: `Working directory or project name. Named projects: ${projectList}. Default: SkimpyClaw repo root.`,
153
- },
154
- },
155
- },
156
- };
157
- tools.push(codeTeamWithProjects);
158
- }
159
- else {
160
- tools.push(CODE_WITH_TEAM_TOOL);
161
- }
158
+ tools.push(injectProjects(CODE_WITH_AGENT_TOOL, projects));
159
+ tools.push(injectProjects(CODE_WITH_TEAM_TOOL, projects));
162
160
  tools.push(CHECK_CODE_AGENT_TOOL);
163
161
  }
164
162
  toolDefsCache.set(cacheKey, tools);
@@ -250,8 +248,6 @@ export async function executeTool(name, input, config, context) {
250
248
  const { isPathAllowed } = await import('./tools/path-utils.js');
251
249
  for (const [key, value] of Object.entries(input)) {
252
250
  if (typeof value === 'string' && (value.startsWith('/') || value.startsWith('~/') || value.startsWith('./'))) {
253
- const { resolve } = await import('path');
254
- const { homedir } = await import('os');
255
251
  const resolved = value.startsWith('~/') ? resolve(homedir(), value.slice(2)) : resolve(value);
256
252
  if (!isPathAllowed(resolved, config.allowedPaths)) {
257
253
  return `Error: MCP tool argument "${key}" references path outside allowed directories: ${value}`;
@@ -261,10 +257,6 @@ export async function executeTool(name, input, config, context) {
261
257
  }
262
258
  return await executeMcpToolGeneric(name, input);
263
259
  }
264
- // Route spawn_subagent
265
- if (name === 'spawn_subagent') {
266
- return await executeSpawnSubagent(input, context);
267
- }
268
260
  // Route code_with_agent - delegate to code-agents module
269
261
  if (name === 'code_with_agent') {
270
262
  const { executeCodeWithAgent } = await import('./code-agents/index.js');
@@ -330,8 +322,39 @@ export async function executeTool(name, input, config, context) {
330
322
  return reversed;
331
323
  };
332
324
  switch (normalized) {
333
- case 'bash':
334
- return await sandboxBash(containerName, translateBashPaths(input.command), input.cwd ? tp(input.cwd) : undefined, config.bashTimeout);
325
+ case 'bash': {
326
+ const translatedCmd = translateBashPaths(input.command);
327
+ // Apply hard safety blocks and exec-approval inside sandbox.
328
+ // The sandbox provides filesystem isolation but not command-level policy.
329
+ if (!isBashCommandSafe(translatedCmd)) {
330
+ return 'Error: Command blocked by safety filter.';
331
+ }
332
+ const classification = classifyCommandRisk(translatedCmd);
333
+ const approvalConfig = config.execApproval;
334
+ if (requiresApproval(classification, approvalConfig)) {
335
+ const isUnattended = context?.channel === 'subagent' ||
336
+ context?.isCronJob === true ||
337
+ (!context?.approverUserId && !context?.channelTargetId && !context?.chatId);
338
+ if (isUnattended) {
339
+ return `⛔ Command blocked — tier ${classification.tier} commands require approval but no approver is available in this context (${classification.reason}). Use safer alternatives or request approval via an interactive channel.`;
340
+ }
341
+ // Attended context — run full approval flow before executing in sandbox
342
+ const { createApprovalRequest: sbxCreate, waitForApproval: sbxWait } = await import('./exec-approval.js');
343
+ const ttlMs = approvalConfig?.ttlMs ?? 5 * 60 * 1000;
344
+ const channelMeta = context?.channel ? {
345
+ channel: context.channel,
346
+ chatId: context.channelTargetId ?? context.chatId,
347
+ userId: context.approverUserId,
348
+ username: context.approverUsername,
349
+ } : context?.chatId ? { channel: 'telegram', chatId: context.chatId } : undefined;
350
+ const sbxReq = sbxCreate(translatedCmd, input.cwd, classification, approvalConfig, channelMeta);
351
+ const sbxResolved = await sbxWait(sbxReq.id, ttlMs);
352
+ if (sbxResolved.status !== 'approved') {
353
+ return `⛔ Command not executed — approval ${sbxResolved.status} (tier ${classification.tier}: ${classification.reason}).`;
354
+ }
355
+ }
356
+ return await sandboxBash(containerName, translatedCmd, input.cwd ? tp(input.cwd) : undefined, config.bashTimeout);
357
+ }
335
358
  case 'read_file':
336
359
  return await sandboxReadFile(containerName, tp(input.file_path || input.path));
337
360
  case 'write_file':
@@ -365,41 +388,7 @@ export async function executeTool(name, input, config, context) {
365
388
  }
366
389
  }
367
390
  catch (err) {
368
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
391
+ return `Error: ${toErrorMessage(err)}`;
369
392
  }
370
393
  }
371
394
  // --- Individual Tool Implementations ---
372
- /**
373
- * Execute spawn_subagent tool — dispatches a background subagent.
374
- */
375
- async function executeSpawnSubagent(input, context) {
376
- if (!context?.fullConfig || !context?.chatId) {
377
- return 'Error: spawn_subagent requires a chat context (not available in this mode)';
378
- }
379
- const { dispatchSubagent } = await import('./subagent.js');
380
- const task = input.task;
381
- const type = input.type;
382
- const model = input.model;
383
- const label = input.label;
384
- // NOTE: allowedPaths deliberately NOT accepted from model input (security — prevents path escalation).
385
- // Subagents inherit their preset's allowedPaths from config.
386
- if (!task || !type) {
387
- return 'Error: task and type are required';
388
- }
389
- if (!['coding', 'research'].includes(type)) {
390
- return `Error: Invalid type "${type}". Must be coding or research.`;
391
- }
392
- try {
393
- const subagentTask = dispatchSubagent(type, task, context.chatId, context.fullConfig, model, context.history, { label });
394
- const labelStr = label ? ` "${label}"` : '';
395
- return JSON.stringify({
396
- status: 'accepted',
397
- runId: subagentTask.id,
398
- label: label || subagentTask.type,
399
- message: `Subagent ${subagentTask.id}${labelStr} dispatched (${subagentTask.type}, model: ${subagentTask.model}). Results will be announced when done.`,
400
- });
401
- }
402
- catch (err) {
403
- return `Error: ${err instanceof Error ? err.message : String(err)}`;
404
- }
405
- }
package/dist/types.d.ts CHANGED
@@ -60,10 +60,16 @@ export interface Config {
60
60
  token?: string;
61
61
  frontend?: 'framework';
62
62
  };
63
- subagents?: {
63
+ codeAgents?: {
64
64
  maxConcurrent?: number;
65
- maxRetries?: number;
66
- defaultCodeAgent?: string;
65
+ defaultAgent?: string;
66
+ timeoutMinutes?: number;
67
+ teamTimeoutMinutes?: number;
68
+ maxTurns?: number;
69
+ skipPlaywright?: boolean;
70
+ /** Per-project validation commands. Keys match project names from `projects` config.
71
+ * Values are shell commands run in the project dir. Overrides auto-detected build+test. */
72
+ validationCommands?: Record<string, string>;
67
73
  };
68
74
  langfuse?: {
69
75
  enabled?: boolean;
@@ -150,6 +156,11 @@ export interface ToolConfig {
150
156
  maxIterations?: number;
151
157
  bashTimeout?: number;
152
158
  maxTurnTokens?: number;
159
+ toolProfile?: 'minimal' | 'coding' | 'full';
160
+ contextManagement?: {
161
+ enabled?: boolean;
162
+ maxContextTokens?: number;
163
+ };
153
164
  browser?: {
154
165
  enabled?: boolean;
155
166
  type?: 'chromium' | 'firefox' | 'webkit';
@@ -200,25 +211,6 @@ export interface ModelProvider {
200
211
  name: string;
201
212
  chat(messages: ChatMessage[], options: ChatOptions): Promise<string>;
202
213
  }
203
- export type SubagentType = 'coding' | 'research';
204
- export type SubagentStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
205
- export interface SubagentTask {
206
- id: string;
207
- type: SubagentType;
208
- prompt: string;
209
- status: SubagentStatus;
210
- chatId: number;
211
- model: string;
212
- label?: string;
213
- createdAt: Date;
214
- startedAt?: Date;
215
- completedAt?: Date;
216
- result?: string;
217
- error?: string;
218
- retryCount?: number;
219
- maxRetries?: number;
220
- abortController: AbortController;
221
- }
222
214
  export type ImageContentBlock = {
223
215
  type: 'image';
224
216
  source: {
package/dist/usage.d.ts CHANGED
@@ -72,5 +72,6 @@ export declare function readUsageRecords(options?: ReadUsageOptions): {
72
72
  export declare function aggregateUsage(startDate: string, endDate: string): UsageAggregation;
73
73
  /**
74
74
  * Get usage summary for today, last 7 days, and last 30 days.
75
+ * Reads files once for the full 30-day window, then partitions in memory.
75
76
  */
76
77
  export declare function getUsageSummary(): UsageSummary;
package/dist/usage.js CHANGED
@@ -1,18 +1,10 @@
1
1
  // Usage tracking — JSONL append-only storage at ~/.skimpyclaw/logs/usage/YYYY-MM-DD.jsonl
2
2
  import { randomUUID } from 'crypto';
3
- import { readFileSync, appendFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
3
+ import { appendFileSync, existsSync, mkdirSync } from 'fs';
4
4
  import { join } from 'path';
5
5
  import { homedir } from 'os';
6
+ import { formatDate, readJsonlDir } from './utils.js';
6
7
  const USAGE_DIR = join(homedir(), '.skimpyclaw', 'logs', 'usage');
7
- function formatDate(date) {
8
- const y = date.getFullYear();
9
- const m = String(date.getMonth() + 1).padStart(2, '0');
10
- const d = String(date.getDate()).padStart(2, '0');
11
- return `${y}-${m}-${d}`;
12
- }
13
- function getUsageFilePath(dateStr) {
14
- return join(USAGE_DIR, `${dateStr}.jsonl`);
15
- }
16
8
  /** For testing: override the usage directory */
17
9
  let usageDirOverride = null;
18
10
  export function setUsageDirForTesting(dir) {
@@ -67,45 +59,15 @@ export function buildUsageRecord(opts) {
67
59
  */
68
60
  export function readUsageRecords(options = {}) {
69
61
  const dir = getUsageDir();
70
- if (!existsSync(dir)) {
71
- return { records: [], total: 0 };
72
- }
73
62
  const limit = options.limit ?? 50;
74
63
  const offset = options.offset ?? 0;
75
64
  const now = new Date();
76
65
  const endDate = options.endDate ?? formatDate(now);
77
66
  const startDate = options.startDate ?? formatDate(new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000));
78
- const files = readdirSync(dir)
79
- .filter(f => f.endsWith('.jsonl'))
80
- .sort()
81
- .reverse(); // newest first
82
- const allRecords = [];
83
- for (const file of files) {
84
- const dateStr = file.replace('.jsonl', '');
85
- if (dateStr < startDate || dateStr > endDate)
86
- continue;
87
- const filePath = join(dir, file);
88
- try {
89
- const content = readFileSync(filePath, 'utf-8');
90
- const lines = content.trim().split('\n').filter(Boolean);
91
- for (let i = lines.length - 1; i >= 0; i--) {
92
- try {
93
- const record = JSON.parse(lines[i]);
94
- if (options.model && record.model !== options.model)
95
- continue;
96
- allRecords.push(record);
97
- }
98
- catch {
99
- // Skip malformed lines
100
- }
101
- }
102
- }
103
- catch {
104
- // Skip unreadable files
105
- }
106
- }
67
+ const modelFilter = options.model;
68
+ const allRecords = readJsonlDir(dir, startDate, endDate, modelFilter ? (r) => r.model === modelFilter : undefined);
107
69
  // Sort newest first
108
- allRecords.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
70
+ allRecords.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
109
71
  const total = allRecords.length;
110
72
  const paged = allRecords.slice(offset, offset + limit);
111
73
  return { records: paged, total };
@@ -136,15 +98,37 @@ export function aggregateUsage(startDate, endDate) {
136
98
  }
137
99
  /**
138
100
  * Get usage summary for today, last 7 days, and last 30 days.
101
+ * Reads files once for the full 30-day window, then partitions in memory.
139
102
  */
140
103
  export function getUsageSummary() {
141
104
  const now = new Date();
142
105
  const todayStr = formatDate(now);
143
106
  const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
144
107
  const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
108
+ const weekAgoStr = formatDate(weekAgo);
109
+ const monthAgoStr = formatDate(monthAgo);
110
+ // Read all records once for the full 30-day range
111
+ const { records: allRecords } = readUsageRecords({ startDate: monthAgoStr, endDate: todayStr, limit: 100000 });
112
+ const aggregate = (records) => {
113
+ const agg = emptyAggregation();
114
+ for (const r of records) {
115
+ agg.totalCost += r.totalCost;
116
+ agg.totalInputTokens += r.inputTokens;
117
+ agg.totalOutputTokens += r.outputTokens;
118
+ agg.totalCalls++;
119
+ if (!agg.byModel[r.model]) {
120
+ agg.byModel[r.model] = { calls: 0, inputTokens: 0, outputTokens: 0, cost: 0 };
121
+ }
122
+ agg.byModel[r.model].calls++;
123
+ agg.byModel[r.model].inputTokens += r.inputTokens;
124
+ agg.byModel[r.model].outputTokens += r.outputTokens;
125
+ agg.byModel[r.model].cost += r.totalCost;
126
+ }
127
+ return agg;
128
+ };
145
129
  return {
146
- today: aggregateUsage(todayStr, todayStr),
147
- week: aggregateUsage(formatDate(weekAgo), todayStr),
148
- month: aggregateUsage(formatDate(monthAgo), todayStr),
130
+ today: aggregate(allRecords.filter(r => r.timestamp.slice(0, 10) === todayStr)),
131
+ week: aggregate(allRecords.filter(r => r.timestamp.slice(0, 10) >= weekAgoStr)),
132
+ month: aggregate(allRecords),
149
133
  };
150
134
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Format a Date as YYYY-MM-DD string.
3
+ */
4
+ export declare function formatDate(date: Date): string;
5
+ /**
6
+ * Extract an error message from an unknown caught value.
7
+ */
8
+ export declare function toErrorMessage(err: unknown): string;
9
+ /**
10
+ * Read JSONL files from a dated directory (YYYY-MM-DD.jsonl), filtered by date range.
11
+ * Returns records in reverse chronological order (newest first).
12
+ */
13
+ export declare function readJsonlDir<T>(dir: string, startDate: string, endDate: string, filter?: (record: T) => boolean): T[];
14
+ /**
15
+ * Timing-safe bearer token validation.
16
+ * Returns true if the provided token matches the expected token.
17
+ */
18
+ export declare function validateBearerToken(expected: string, authHeader: string | undefined): boolean;
package/dist/utils.js ADDED
@@ -0,0 +1,71 @@
1
+ // Shared utilities used across modules
2
+ import { readFileSync, readdirSync, existsSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { timingSafeEqual } from 'crypto';
5
+ /**
6
+ * Format a Date as YYYY-MM-DD string.
7
+ */
8
+ export function formatDate(date) {
9
+ const y = date.getFullYear();
10
+ const m = String(date.getMonth() + 1).padStart(2, '0');
11
+ const d = String(date.getDate()).padStart(2, '0');
12
+ return `${y}-${m}-${d}`;
13
+ }
14
+ /**
15
+ * Extract an error message from an unknown caught value.
16
+ */
17
+ export function toErrorMessage(err) {
18
+ return err instanceof Error ? err.message : String(err);
19
+ }
20
+ /**
21
+ * Read JSONL files from a dated directory (YYYY-MM-DD.jsonl), filtered by date range.
22
+ * Returns records in reverse chronological order (newest first).
23
+ */
24
+ export function readJsonlDir(dir, startDate, endDate, filter) {
25
+ if (!existsSync(dir))
26
+ return [];
27
+ const files = readdirSync(dir)
28
+ .filter(f => f.endsWith('.jsonl'))
29
+ .sort()
30
+ .reverse(); // newest first
31
+ const allRecords = [];
32
+ for (const file of files) {
33
+ const dateStr = file.replace('.jsonl', '');
34
+ if (dateStr < startDate || dateStr > endDate)
35
+ continue;
36
+ const filePath = join(dir, file);
37
+ try {
38
+ const content = readFileSync(filePath, 'utf-8');
39
+ const lines = content.trim().split('\n').filter(Boolean);
40
+ for (let i = lines.length - 1; i >= 0; i--) {
41
+ try {
42
+ const record = JSON.parse(lines[i]);
43
+ if (filter && !filter(record))
44
+ continue;
45
+ allRecords.push(record);
46
+ }
47
+ catch {
48
+ // Skip malformed lines
49
+ }
50
+ }
51
+ }
52
+ catch {
53
+ // Skip unreadable files
54
+ }
55
+ }
56
+ return allRecords;
57
+ }
58
+ /**
59
+ * Timing-safe bearer token validation.
60
+ * Returns true if the provided token matches the expected token.
61
+ */
62
+ export function validateBearerToken(expected, authHeader) {
63
+ if (!authHeader || !authHeader.startsWith('Bearer '))
64
+ return false;
65
+ const provided = authHeader.slice(7);
66
+ const expectedBuf = Buffer.from(expected, 'utf8');
67
+ const providedBuf = Buffer.from(provided, 'utf8');
68
+ if (expectedBuf.length !== providedBuf.length)
69
+ return false;
70
+ return timingSafeEqual(expectedBuf, providedBuf);
71
+ }
package/dist/voice.js CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, unlinkSync, readdirSync } from 'fs';
3
3
  import { execSync } from 'child_process';
4
4
  import { basename, dirname, join } from 'path';
5
5
  import { tmpdir } from 'os';
6
+ import { toErrorMessage } from './utils.js';
6
7
  function detectLocalWhisper() {
7
8
  // Prefer whisper.cpp (much faster)
8
9
  try {
@@ -62,7 +63,7 @@ function convertToWav(audioPath) {
62
63
  execSync(`ffmpeg -i "${audioPath}" -ar 16000 -ac 1 -c:a pcm_s16le "${wavPath}" -y`, { encoding: 'utf-8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] });
63
64
  }
64
65
  catch (err) {
65
- const msg = err instanceof Error ? err.message : String(err);
66
+ const msg = toErrorMessage(err);
66
67
  throw new Error(`ffmpeg conversion failed: ${msg}`);
67
68
  }
68
69
  if (!existsSync(wavPath)) {
@@ -98,7 +99,7 @@ async function transcribeWithWhisperCpp(audioPath, cliPath) {
98
99
  execSync(`"${cliPath}" -m "${WHISPER_CPP_MODEL}" -otxt -of "${outputBase}" -np -nt "${wavPath}"`, { encoding: 'utf-8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'] });
99
100
  }
100
101
  catch (err) {
101
- const msg = err instanceof Error ? err.message : String(err);
102
+ const msg = toErrorMessage(err);
102
103
  console.error(`[voice] whisper-cli failed: ${msg}`);
103
104
  throw new Error(`whisper-cli failed: ${msg}`);
104
105
  }
@@ -150,7 +151,7 @@ async function transcribeWithPythonWhisper(audioPath, cliPath) {
150
151
  execSync(`"${cliPath}" "${audioPath}" --model turbo --output_format txt --output_dir "${outputDir}"`, { encoding: 'utf-8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] });
151
152
  }
152
153
  catch (err) {
153
- const msg = err instanceof Error ? err.message : String(err);
154
+ const msg = toErrorMessage(err);
154
155
  throw new Error(`Python whisper failed: ${msg}`);
155
156
  }
156
157
  const txtPath = join(outputDir, `${baseName}.txt`);
@@ -374,6 +375,7 @@ export async function synthesizeSpeech(text, config) {
374
375
  throw new Error('No TTS provider configured. Add a provider with a tts config to your voice config.');
375
376
  }
376
377
  const { name, provider } = ttsProvider;
378
+ const macosVoice = config.providers?.macos?.tts?.voice || 'Zoe';
377
379
  // macOS `say` command
378
380
  if (name === 'macos') {
379
381
  const voice = provider.tts?.voice || 'Zoe';
@@ -385,7 +387,7 @@ export async function synthesizeSpeech(text, config) {
385
387
  if (!apiKey) {
386
388
  if (canFallbackToMacOS(config)) {
387
389
  console.warn('[voice] No ElevenLabs API key — falling back to macOS TTS');
388
- return synthesizeWithMacOS(text, config.providers?.macos?.tts?.voice || 'Zoe');
390
+ return synthesizeWithMacOS(text, macosVoice);
389
391
  }
390
392
  throw new Error('No API key configured for ElevenLabs TTS provider.');
391
393
  }
@@ -411,7 +413,7 @@ export async function synthesizeSpeech(text, config) {
411
413
  catch (err) {
412
414
  if (canFallbackToMacOS(config)) {
413
415
  console.warn(`[voice] ElevenLabs failed, falling back to macOS TTS: ${err.message}`);
414
- return synthesizeWithMacOS(text, config.providers?.macos?.tts?.voice || 'Zoe');
416
+ return synthesizeWithMacOS(text, macosVoice);
415
417
  }
416
418
  throw err;
417
419
  }
@@ -421,7 +423,7 @@ export async function synthesizeSpeech(text, config) {
421
423
  if (!apiKey) {
422
424
  if (canFallbackToMacOS(config)) {
423
425
  console.warn(`[voice] No API key for "${name}" — falling back to macOS TTS`);
424
- return synthesizeWithMacOS(text, config.providers?.macos?.tts?.voice || 'Zoe');
426
+ return synthesizeWithMacOS(text, macosVoice);
425
427
  }
426
428
  throw new Error(`No API key configured for TTS provider "${name}".`);
427
429
  }
@@ -445,7 +447,7 @@ export async function synthesizeSpeech(text, config) {
445
447
  catch (err) {
446
448
  if (canFallbackToMacOS(config)) {
447
449
  console.warn(`[voice] ${name} TTS failed, falling back to macOS TTS: ${err.message}`);
448
- return synthesizeWithMacOS(text, config.providers?.macos?.tts?.voice || 'Zoe');
450
+ return synthesizeWithMacOS(text, macosVoice);
449
451
  }
450
452
  throw err;
451
453
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skimpyclaw",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Lightweight personal AI assistant with Telegram and Discord integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",