assistme 0.3.4 → 0.3.6

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.
@@ -1,14 +1,23 @@
1
1
  import { readFile, writeFile, readdir, stat, mkdir } from "fs/promises";
2
- import { resolve, relative, join } from "path";
2
+ import { resolve, relative, join, sep } from "path";
3
3
  import { glob } from "glob";
4
4
  import { getConfig } from "../utils/config.js";
5
+ import { AppError } from "../utils/errors.js";
6
+ import {
7
+ MAX_FILE_SEARCH_RESULTS,
8
+ MAX_CONTENT_SEARCH_FILES,
9
+ MAX_CONTENT_SEARCH_RESULTS,
10
+ } from "../utils/constants.js";
5
11
 
6
12
  function assertWithinWorkspace(filePath: string): string {
7
13
  const config = getConfig();
8
14
  const resolved = resolve(config.workspacePath, filePath);
9
- if (!resolved.startsWith(config.workspacePath)) {
10
- throw new Error(
11
- `Access denied: path "${filePath}" is outside workspace "${config.workspacePath}"`
15
+ // Use path.relative() to prevent path traversal attacks like "/workspace-evil/workspace/file"
16
+ const rel = relative(config.workspacePath, resolved);
17
+ if (rel.startsWith("..") || rel.startsWith(sep + sep)) {
18
+ throw new AppError(
19
+ `Access denied: path "${filePath}" is outside workspace "${config.workspacePath}"`,
20
+ "PATH_TRAVERSAL"
12
21
  );
13
22
  }
14
23
  return resolved;
@@ -27,15 +36,10 @@ export async function readFileContent(
27
36
  const end = limit ? start + limit : lines.length;
28
37
  const selected = lines.slice(start, end);
29
38
 
30
- return selected
31
- .map((line, i) => `${String(start + i + 1).padStart(5)} | ${line}`)
32
- .join("\n");
39
+ return selected.map((line, i) => `${String(start + i + 1).padStart(5)} | ${line}`).join("\n");
33
40
  }
34
41
 
35
- export async function writeFileContent(
36
- path: string,
37
- content: string
38
- ): Promise<string> {
42
+ export async function writeFileContent(path: string, content: string): Promise<string> {
39
43
  const resolved = assertWithinWorkspace(path);
40
44
  // Ensure parent directory exists
41
45
  const dir = resolve(resolved, "..");
@@ -44,14 +48,9 @@ export async function writeFileContent(
44
48
  return `File written: ${path} (${content.length} bytes)`;
45
49
  }
46
50
 
47
- export async function searchFiles(
48
- pattern: string,
49
- directory?: string
50
- ): Promise<string> {
51
+ export async function searchFiles(pattern: string, directory?: string): Promise<string> {
51
52
  const config = getConfig();
52
- const cwd = directory
53
- ? assertWithinWorkspace(directory)
54
- : config.workspacePath;
53
+ const cwd = directory ? assertWithinWorkspace(directory) : config.workspacePath;
55
54
 
56
55
  const matches = await glob(pattern, {
57
56
  cwd,
@@ -62,16 +61,14 @@ export async function searchFiles(
62
61
  if (matches.length === 0) return "No files found matching the pattern.";
63
62
 
64
63
  return matches
65
- .slice(0, 50) // Cap at 50 results
64
+ .slice(0, MAX_FILE_SEARCH_RESULTS)
66
65
  .map((m) => relative(config.workspacePath, join(cwd, m)))
67
66
  .join("\n");
68
67
  }
69
68
 
70
69
  export async function listDirectory(path?: string): Promise<string> {
71
70
  const config = getConfig();
72
- const resolved = path
73
- ? assertWithinWorkspace(path)
74
- : config.workspacePath;
71
+ const resolved = path ? assertWithinWorkspace(path) : config.workspacePath;
75
72
 
76
73
  const entries = await readdir(resolved, { withFileTypes: true });
77
74
  const results: string[] = [];
@@ -80,9 +77,7 @@ export async function listDirectory(path?: string): Promise<string> {
80
77
  if (entry.name.startsWith(".") && entry.name !== ".env.example") continue;
81
78
  const icon = entry.isDirectory() ? "📁" : "📄";
82
79
  const info = entry.isFile()
83
- ? await stat(join(resolved, entry.name)).then(
84
- (s) => ` (${formatSize(s.size)})`
85
- )
80
+ ? await stat(join(resolved, entry.name)).then((s) => ` (${formatSize(s.size)})`)
86
81
  : "";
87
82
  results.push(`${icon} ${entry.name}${info}`);
88
83
  }
@@ -102,9 +97,7 @@ export async function searchContent(
102
97
  directory?: string
103
98
  ): Promise<string> {
104
99
  const config = getConfig();
105
- const cwd = directory
106
- ? assertWithinWorkspace(directory)
107
- : config.workspacePath;
100
+ const cwd = directory ? assertWithinWorkspace(directory) : config.workspacePath;
108
101
 
109
102
  const files = await glob(fileGlob || "**/*", {
110
103
  cwd,
@@ -112,10 +105,16 @@ export async function searchContent(
112
105
  ignore: ["node_modules/**", ".git/**", "dist/**", ".next/**"],
113
106
  });
114
107
 
115
- const regex = new RegExp(pattern, "gi");
108
+ let regex: RegExp;
109
+ try {
110
+ regex = new RegExp(pattern, "gi");
111
+ } catch {
112
+ // Fall back to literal string match if the pattern is invalid regex
113
+ regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
114
+ }
116
115
  const results: string[] = [];
117
116
 
118
- for (const file of files.slice(0, 200)) {
117
+ for (const file of files.slice(0, MAX_CONTENT_SEARCH_FILES)) {
119
118
  try {
120
119
  const content = await readFile(join(cwd, file), "utf-8");
121
120
  const lines = content.split("\n");
@@ -126,17 +125,15 @@ export async function searchContent(
126
125
  results.push(`${relPath}:${i + 1}: ${lines[i].trim()}`);
127
126
  regex.lastIndex = 0;
128
127
 
129
- if (results.length >= 30) break;
128
+ if (results.length >= MAX_CONTENT_SEARCH_RESULTS) break;
130
129
  }
131
130
  }
132
131
 
133
- if (results.length >= 30) break;
132
+ if (results.length >= MAX_CONTENT_SEARCH_RESULTS) break;
134
133
  } catch {
135
134
  // Skip binary/unreadable files
136
135
  }
137
136
  }
138
137
 
139
- return results.length > 0
140
- ? results.join("\n")
141
- : "No matches found.";
138
+ return results.length > 0 ? results.join("\n") : "No matches found.";
142
139
  }
@@ -1,33 +1,29 @@
1
1
  import { exec } from "child_process";
2
2
  import { getConfig } from "../utils/config.js";
3
-
4
- const TIMEOUT_MS = 30_000; // 30 seconds
5
- const MAX_OUTPUT = 50_000; // 50KB output cap
3
+ import { AppError } from "../utils/errors.js";
4
+ import { SHELL_TIMEOUT_MS, SHELL_MAX_OUTPUT } from "../utils/constants.js";
6
5
 
7
6
  // Commands that are never allowed — matched as case-insensitive regex patterns
8
7
  const BLOCKED_PATTERNS: RegExp[] = [
9
- /rm\s+(-\w*\s+)*-\w*r\w*\s+\/($|\s)/i, // rm -rf /, rm -fr /, etc.
10
- /rm\s+(-\w*\s+)*-\w*r\w*\s+~($|\s|\/)/i, // rm -rf ~, rm -fr ~/, etc.
11
- /\bmkfs\b/i, // mkfs (any form)
12
- /\bdd\s+.*\bif=/i, // dd if=
13
- /:\s*\(\s*\)\s*\{/, // fork bomb :(){}
14
- /\bchmod\s+(-\w+\s+)*-R\s+777\s+\//i, // chmod -R 777 /
15
- />\s*\/dev\/sd[a-z]/i, // write to raw disk
16
- /\bshutdown\b/i, // shutdown
17
- /\breboot\b/i, // reboot
18
- /\bsystemctl\s+(start|stop|disable|mask)\b/i, // dangerous systemctl ops
8
+ /rm\s+(-\w*\s+)*-\w*r\w*\s+\/($|\s)/i, // rm -rf /, rm -fr /, etc.
9
+ /rm\s+(-\w*\s+)*-\w*r\w*\s+~($|\s|\/)/i, // rm -rf ~, rm -fr ~/, etc.
10
+ /\bmkfs\b/i, // mkfs (any form)
11
+ /\bdd\s+.*\bif=/i, // dd if=
12
+ /:\s*\(\s*\)\s*\{/, // fork bomb :(){}
13
+ /\bchmod\s+(-\w+\s+)*-R\s+777\s+\//i, // chmod -R 777 /
14
+ />\s*\/dev\/sd[a-z]/i, // write to raw disk
15
+ /\bshutdown\b/i, // shutdown
16
+ /\breboot\b/i, // reboot
17
+ /\bsystemctl\s+(start|stop|disable|mask)\b/i, // dangerous systemctl ops
19
18
  ];
20
19
 
21
20
  export function isBlocked(command: string): boolean {
22
21
  return BLOCKED_PATTERNS.some((pattern) => pattern.test(command));
23
22
  }
24
23
 
25
- export async function executeShell(
26
- command: string,
27
- cwd?: string
28
- ): Promise<string> {
24
+ export async function executeShell(command: string, cwd?: string): Promise<string> {
29
25
  if (isBlocked(command)) {
30
- throw new Error(`Command blocked for safety: "${command}"`);
26
+ throw new AppError(`Command blocked for safety: "${command}"`, "COMMAND_BLOCKED");
31
27
  }
32
28
 
33
29
  const config = getConfig();
@@ -38,7 +34,7 @@ export async function executeShell(
38
34
  command,
39
35
  {
40
36
  cwd: workDir,
41
- timeout: TIMEOUT_MS,
37
+ timeout: SHELL_TIMEOUT_MS,
42
38
  maxBuffer: 1024 * 1024, // 1MB buffer
43
39
  env: { ...process.env, TERM: "dumb" },
44
40
  },
@@ -56,10 +52,10 @@ export async function executeShell(
56
52
  }
57
53
 
58
54
  // Truncate if too large
59
- if (output.length > MAX_OUTPUT) {
55
+ if (output.length > SHELL_MAX_OUTPUT) {
60
56
  output =
61
- output.slice(0, MAX_OUTPUT) +
62
- `\n\n[Output truncated at ${MAX_OUTPUT} bytes]`;
57
+ output.slice(0, SHELL_MAX_OUTPUT) +
58
+ `\n\n[Output truncated at ${SHELL_MAX_OUTPUT} bytes]`;
63
59
  }
64
60
 
65
61
  resolve(output || "(no output)");
@@ -49,7 +49,7 @@ describe("config module", () => {
49
49
  const config = getConfig();
50
50
  expect(config.sessionName).toBe("Default");
51
51
  expect(config.model).toBe("claude-sonnet-4-20250514");
52
- expect(config.maxTurns).toBe(50);
52
+ expect(config.maxTurns).toBe(200);
53
53
  });
54
54
 
55
55
  it("resolves workspacePath to absolute path", () => {
@@ -9,11 +9,18 @@ export interface AssistMeConfig {
9
9
  sessionName: string;
10
10
  model: string;
11
11
  maxTurns: number;
12
+ taskTimeoutMinutes: number;
12
13
  }
13
14
 
15
+ /**
16
+ * Default Supabase values — read from env vars first, then fall back to
17
+ * compile-time defaults. This keeps secrets out of plain source when deploying
18
+ * custom instances, while still allowing the hosted service to "just work".
19
+ */
14
20
  const SUPABASE_URL_DEFAULT =
15
- "https://msgplwbgohpokajtibew.supabase.co";
21
+ process.env.ASSISTME_SUPABASE_URL || "https://msgplwbgohpokajtibew.supabase.co";
16
22
  const SUPABASE_ANON_KEY_DEFAULT =
23
+ process.env.ASSISTME_SUPABASE_ANON_KEY ||
17
24
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1zZ3Bsd2Jnb2hwb2thanRpYmV3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjE3MTMzNTEsImV4cCI6MjA3NzI4OTM1MX0.YqiluL_mIBWKv5dteIcPAPQ_jRp9rzZlvAXvkQttDjs";
18
25
 
19
26
  const CONFIG_DEFAULTS: Partial<AssistMeConfig> = {
@@ -21,7 +28,8 @@ const CONFIG_DEFAULTS: Partial<AssistMeConfig> = {
21
28
  supabaseAnonKey: SUPABASE_ANON_KEY_DEFAULT,
22
29
  sessionName: "Default",
23
30
  model: "claude-sonnet-4-20250514",
24
- maxTurns: 50,
31
+ maxTurns: 200,
32
+ taskTimeoutMinutes: 10,
25
33
  };
26
34
 
27
35
  const config = new Conf<Partial<AssistMeConfig>>({
@@ -30,14 +38,11 @@ const config = new Conf<Partial<AssistMeConfig>>({
30
38
  });
31
39
 
32
40
  export function getConfig(): AssistMeConfig {
33
- const supabaseUrl =
34
- process.env.SUPABASE_URL || config.get("supabaseUrl") || SUPABASE_URL_DEFAULT;
41
+ const supabaseUrl = process.env.SUPABASE_URL || config.get("supabaseUrl") || SUPABASE_URL_DEFAULT;
35
42
  const supabaseAnonKey =
36
43
  process.env.SUPABASE_ANON_KEY || config.get("supabaseAnonKey") || SUPABASE_ANON_KEY_DEFAULT;
37
- const anthropicApiKey =
38
- process.env.ANTHROPIC_API_KEY || config.get("anthropicApiKey") || "";
39
- const workspacePath =
40
- config.get("workspacePath") || process.cwd();
44
+ const anthropicApiKey = process.env.ANTHROPIC_API_KEY || config.get("anthropicApiKey") || "";
45
+ const workspacePath = config.get("workspacePath") || process.cwd();
41
46
 
42
47
  return {
43
48
  supabaseUrl,
@@ -46,7 +51,8 @@ export function getConfig(): AssistMeConfig {
46
51
  workspacePath: resolve(workspacePath),
47
52
  sessionName: config.get("sessionName") || "Default",
48
53
  model: config.get("model") || "claude-sonnet-4-20250514",
49
- maxTurns: config.get("maxTurns") || 50,
54
+ maxTurns: config.get("maxTurns") || 200,
55
+ taskTimeoutMinutes: config.get("taskTimeoutMinutes") || 10,
50
56
  };
51
57
  }
52
58
 
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Centralized constants for the entire application.
3
+ * All magic numbers and limits should be defined here.
4
+ */
5
+
6
+ // ── Truncation Limits ──────────────────────────────────────────────
7
+
8
+ /** Max characters for final task response sent to DB */
9
+ export const MAX_RESPONSE_CONTENT_LENGTH = 50_000;
10
+
11
+ /** Max characters for tool result stored in events */
12
+ export const MAX_TOOL_RESULT_LENGTH = 10_000;
13
+
14
+ /** Max characters for tool result stored in skill extraction records */
15
+ export const MAX_SKILL_RECORD_RESULT_LENGTH = 300;
16
+
17
+ /** Max characters for tool input logged to console */
18
+ export const MAX_TOOL_INPUT_LOG_LENGTH = 200;
19
+
20
+ /** Max characters for job run summary */
21
+ export const MAX_JOB_SUMMARY_LENGTH = 10_000;
22
+
23
+ /** Max characters for conversation history per response */
24
+ export const MAX_HISTORY_RESPONSE_LENGTH = 1_500;
25
+
26
+ /** Max conversation history entries to include in prompt */
27
+ export const MAX_HISTORY_ENTRIES = 10;
28
+
29
+ /** Budget for skill descriptions in system prompt */
30
+ export const SKILL_DESCRIPTION_BUDGET_CHARS = 16_000;
31
+
32
+ // ── Timeouts ───────────────────────────────────────────────────────
33
+
34
+ /** Default task timeout in minutes */
35
+ export const DEFAULT_TASK_TIMEOUT_MINUTES = 10;
36
+
37
+ /** Shell command execution timeout in ms */
38
+ export const SHELL_TIMEOUT_MS = 30_000;
39
+
40
+ /** Shell output cap in characters */
41
+ export const SHELL_MAX_OUTPUT = 50_000;
42
+
43
+ /** CDP command timeout in ms */
44
+ export const CDP_COMMAND_TIMEOUT_MS = 15_000;
45
+
46
+ /** WebSocket connect timeout in ms */
47
+ export const WS_CONNECT_TIMEOUT_MS = 5_000;
48
+
49
+ /** Scheduler check interval in ms */
50
+ export const SCHEDULER_INTERVAL_MS = 30_000;
51
+
52
+ // ── Browser Cache Limits ───────────────────────────────────────────
53
+
54
+ /** Max entries in refCache before pruning */
55
+ export const REF_CACHE_MAX_SIZE = 5_000;
56
+
57
+ /** Max entries in frameContexts before pruning */
58
+ export const FRAME_CONTEXTS_MAX_SIZE = 500;
59
+
60
+ // ── Search & Matching ──────────────────────────────────────────────
61
+
62
+ /** Max file search results */
63
+ export const MAX_FILE_SEARCH_RESULTS = 50;
64
+
65
+ /** Max files to scan for content search */
66
+ export const MAX_CONTENT_SEARCH_FILES = 200;
67
+
68
+ /** Max content search result lines */
69
+ export const MAX_CONTENT_SEARCH_RESULTS = 30;
70
+
71
+ // ── Retry & Polling ────────────────────────────────────────────────
72
+
73
+ /** Max retries for event emission */
74
+ export const MAX_EMIT_RETRIES = 2;
75
+
76
+ /** Max retries for task completion */
77
+ export const MAX_COMPLETE_TASK_RETRIES = 2;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Structured error types for consistent error handling.
3
+ */
4
+
5
+ export class AppError extends Error {
6
+ constructor(
7
+ message: string,
8
+ public readonly code: ErrorCode,
9
+ public readonly cause?: unknown
10
+ ) {
11
+ super(message);
12
+ this.name = "AppError";
13
+ }
14
+
15
+ static fromUnknown(err: unknown, code: ErrorCode = "INTERNAL"): AppError {
16
+ if (err instanceof AppError) return err;
17
+ const message = err instanceof Error ? err.message : String(err);
18
+ return new AppError(message, code, err);
19
+ }
20
+ }
21
+
22
+ export type ErrorCode =
23
+ | "INTERNAL"
24
+ | "NETWORK"
25
+ | "AUTH"
26
+ | "VALIDATION"
27
+ | "TIMEOUT"
28
+ | "PATH_TRAVERSAL"
29
+ | "COMMAND_BLOCKED"
30
+ | "BROWSER"
31
+ | "DB";
32
+
33
+ /** Extract error message from unknown error value */
34
+ export function errorMessage(err: unknown): string {
35
+ if (err instanceof Error) return err.message;
36
+ return String(err);
37
+ }
@@ -0,0 +1,115 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * Zod schemas for runtime validation of MCP handler responses.
5
+ * Replaces unsafe `as` type casts throughout the codebase.
6
+ */
7
+
8
+ // ── Helpers ────────────────────────────────────────────────────────
9
+
10
+ /** Safely parse data with a Zod schema, returning null on failure */
11
+ export function safeParse<T>(schema: z.ZodType<T>, data: unknown): T | null {
12
+ const result = schema.safeParse(data);
13
+ return result.success ? result.data : null;
14
+ }
15
+
16
+ /** Parse data with a Zod schema, throwing on failure */
17
+ export function parse<T>(schema: z.ZodType<T>, data: unknown): T {
18
+ return schema.parse(data);
19
+ }
20
+
21
+ // ── DB Row Schemas ─────────────────────────────────────────────────
22
+
23
+ export const ConversationMessageSchema = z.object({
24
+ id: z.string(),
25
+ conversation_id: z.string(),
26
+ content: z.string().optional().default(""),
27
+ status: z.string().optional().nullable(),
28
+ metadata: z.record(z.string(), z.unknown()).optional().default({}),
29
+ created_at: z.string().optional(),
30
+ });
31
+
32
+ export const SkillRowSchema = z.object({
33
+ id: z.string(),
34
+ name: z.string(),
35
+ description: z.string().optional().default(""),
36
+ version: z.string().optional().default("1.0.0"),
37
+ user_invocable: z.boolean().optional().default(true),
38
+ disable_model_invocation: z.boolean().optional().default(false),
39
+ keywords: z.array(z.string()).optional().default([]),
40
+ allowed_tools: z.array(z.string()).optional().default([]),
41
+ argument_hint: z.string().optional().default(""),
42
+ metadata: z.unknown().optional().default({}),
43
+ homepage: z.string().optional().default(""),
44
+ content: z.string(),
45
+ source: z.string().optional().default("manual"),
46
+ source_skill_id: z.string().optional().nullable(),
47
+ invocation_count: z.number().optional().default(0),
48
+ });
49
+
50
+ export const JobRowSchema = z.object({
51
+ job_id: z.string(),
52
+ job_name: z.string(),
53
+ job_description: z.string().optional().default(""),
54
+ skill_id: z.string().optional().nullable(),
55
+ skill_name: z.string().optional().nullable(),
56
+ skill_description: z.string().optional().default(""),
57
+ skill_emoji: z.string().optional().default(""),
58
+ skill_content: z.string().optional().default(""),
59
+ });
60
+
61
+ export const JobListRowSchema = z.object({
62
+ id: z.string(),
63
+ name: z.string(),
64
+ description: z.string().optional().default(""),
65
+ skill_count: z.number().optional().default(0),
66
+ });
67
+
68
+ export const JobRunRowSchema = z.object({
69
+ run_id: z.string(),
70
+ job_name: z.string(),
71
+ status: z.string(),
72
+ trigger_type: z.string().optional().default("manual"),
73
+ started_at: z.string(),
74
+ completed_at: z.string().optional().nullable(),
75
+ summary: z.string().optional().nullable(),
76
+ });
77
+
78
+ export const SkillCreateResultSchema = z
79
+ .object({
80
+ out_id: z.string().optional(),
81
+ id: z.string().optional(),
82
+ out_name: z.string().optional(),
83
+ name: z.string().optional(),
84
+ })
85
+ .refine((data) => data.out_id || data.id, {
86
+ message: "Skill create result must have out_id or id",
87
+ });
88
+
89
+ export const SkillDecisionSchema = z.object({
90
+ action: z.enum(["create", "update", "skip"]),
91
+ name: z.string().optional(),
92
+ description: z.string().optional(),
93
+ instructions: z.string().optional(),
94
+ emoji: z.string().optional(),
95
+ keywords: z.array(z.string()).optional(),
96
+ existing_skill_name: z.string().optional(),
97
+ improved_instructions: z.string().optional(),
98
+ improved_description: z.string().optional(),
99
+ reason: z.string(),
100
+ });
101
+
102
+ export type SkillDecision = z.infer<typeof SkillDecisionSchema>;
103
+
104
+ export const BrowseSkillRowSchema = z.object({
105
+ id: z.string(),
106
+ name: z.string(),
107
+ description: z.string().optional().default(""),
108
+ emoji: z.string().optional().default(""),
109
+ version: z.string().optional().default("1.0.0"),
110
+ author_name: z.string().optional().default(""),
111
+ category: z.string().optional().default(""),
112
+ install_count: z.number().optional().default(0),
113
+ avg_rating: z.number().optional().nullable(),
114
+ rating_count: z.number().optional().default(0),
115
+ });
@@ -1,7 +0,0 @@
1
- import {
2
- JobRunner
3
- } from "./chunk-KX7ITO55.js";
4
- import "./chunk-TTEGHE2E.js";
5
- export {
6
- JobRunner
7
- };