terry-core 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -2,6 +2,7 @@ import { type WorkspaceSettings } from "./types.js";
2
2
  export declare function terryHomeDir(): string;
3
3
  export declare function workspacesFile(): string;
4
4
  export declare function auditLogFile(): string;
5
+ export declare function telemetryLogFile(): string;
5
6
  export declare function usageDir(): string;
6
7
  export declare function cloudSyncConfigFile(): string;
7
8
  export declare function memoryFile(): string;
package/dist/config.js CHANGED
@@ -7,11 +7,13 @@ const DEFAULT_TOOLS = {
7
7
  listDirectory: "enabled",
8
8
  readFile: "enabled",
9
9
  writeFile: "enabled",
10
+ moveFile: "enabled",
10
11
  searchInFiles: "enabled",
11
12
  webSearch: "enabled",
12
13
  gitStatus: "enabled",
13
14
  gitDiff: "enabled",
14
15
  runCommand: "enabled",
16
+ imageGenerate: "ask",
15
17
  memorySet: "enabled",
16
18
  memoryGet: "enabled",
17
19
  openCalendarEvent: "ask",
@@ -22,6 +24,22 @@ const DEFAULT_TOOLS = {
22
24
  openEmail: "ask",
23
25
  openMapsDirections: "ask"
24
26
  };
27
+ function normalizeExecutionMode(value) {
28
+ const normalized = String(value ?? "").trim().toLowerCase();
29
+ if (normalized === "autonomous")
30
+ return "full";
31
+ if (normalized === "plan" || normalized === "safe" || normalized === "full") {
32
+ return normalized;
33
+ }
34
+ return DEFAULT_EXECUTION_MODE;
35
+ }
36
+ function normalizeWorkspaceKey(workspacePath) {
37
+ const resolved = path.resolve(workspacePath);
38
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
39
+ }
40
+ function canonicalWorkspacePath(workspacePath) {
41
+ return path.resolve(workspacePath);
42
+ }
25
43
  export function terryHomeDir() {
26
44
  return path.join(os.homedir(), ".terry");
27
45
  }
@@ -31,6 +49,9 @@ export function workspacesFile() {
31
49
  export function auditLogFile() {
32
50
  return path.join(terryHomeDir(), "audit.log.jsonl");
33
51
  }
52
+ export function telemetryLogFile() {
53
+ return path.join(terryHomeDir(), "telemetry.log.jsonl");
54
+ }
34
55
  export function usageDir() {
35
56
  return path.join(terryHomeDir(), "usage");
36
57
  }
@@ -60,10 +81,23 @@ export async function saveWorkspaceMap(map) {
60
81
  }
61
82
  export async function getWorkspaceSettings(workspacePath) {
62
83
  const map = await loadWorkspaceMap();
63
- const key = path.resolve(workspacePath);
84
+ const key = normalizeWorkspaceKey(workspacePath);
85
+ const canonicalPath = canonicalWorkspacePath(workspacePath);
86
+ const legacyKey = Object.keys(map).find((entry) => normalizeWorkspaceKey(entry) === key);
87
+ if (legacyKey && legacyKey !== key) {
88
+ const legacyValue = map[legacyKey];
89
+ if (legacyValue) {
90
+ map[key] = {
91
+ ...legacyValue,
92
+ workspacePath: canonicalPath
93
+ };
94
+ delete map[legacyKey];
95
+ await saveWorkspaceMap(map);
96
+ }
97
+ }
64
98
  if (!map[key]) {
65
99
  map[key] = {
66
- workspacePath: key,
100
+ workspacePath: canonicalPath,
67
101
  dryRun: false,
68
102
  mode: DEFAULT_MODE,
69
103
  executionMode: DEFAULT_EXECUTION_MODE,
@@ -75,12 +109,20 @@ export async function getWorkspaceSettings(workspacePath) {
75
109
  map[key] = {
76
110
  ...map[key],
77
111
  mode: DEFAULT_MODE,
78
- executionMode: map[key].executionMode ?? DEFAULT_EXECUTION_MODE,
112
+ executionMode: normalizeExecutionMode(map[key].executionMode),
79
113
  tools: { ...DEFAULT_TOOLS, ...map[key].tools }
80
114
  };
81
115
  await saveWorkspaceMap(map);
82
116
  }
83
117
  else {
118
+ const normalizedExecution = normalizeExecutionMode(map[key].executionMode);
119
+ if (normalizedExecution !== map[key].executionMode) {
120
+ map[key] = {
121
+ ...map[key],
122
+ executionMode: normalizedExecution
123
+ };
124
+ await saveWorkspaceMap(map);
125
+ }
84
126
  const levels = Object.values(map[key].tools ?? {});
85
127
  const allDisabled = levels.length > 0 && levels.every((level) => level === "disabled");
86
128
  const legacyPattern = map[key].tools?.listDirectory === "disabled" &&
@@ -110,17 +152,31 @@ export async function getWorkspaceSettings(workspacePath) {
110
152
  }
111
153
  export async function setWorkspaceSettings(workspacePath, patch) {
112
154
  const map = await loadWorkspaceMap();
113
- const key = path.resolve(workspacePath);
155
+ const key = normalizeWorkspaceKey(workspacePath);
156
+ const canonicalPath = canonicalWorkspacePath(workspacePath);
157
+ const legacyKey = Object.keys(map).find((entry) => normalizeWorkspaceKey(entry) === key);
158
+ if (legacyKey && legacyKey !== key) {
159
+ const legacyValue = map[legacyKey];
160
+ if (legacyValue) {
161
+ map[key] = {
162
+ ...legacyValue,
163
+ workspacePath: canonicalPath
164
+ };
165
+ delete map[legacyKey];
166
+ }
167
+ }
114
168
  const current = map[key] ?? {
115
- workspacePath: key,
169
+ workspacePath: canonicalPath,
116
170
  dryRun: false,
117
171
  mode: DEFAULT_MODE,
118
172
  executionMode: DEFAULT_EXECUTION_MODE,
119
173
  tools: { ...DEFAULT_TOOLS }
120
174
  };
175
+ const nextExecutionMode = normalizeExecutionMode((patch.executionMode ?? current.executionMode));
121
176
  map[key] = {
122
177
  ...current,
123
178
  ...patch,
179
+ executionMode: nextExecutionMode,
124
180
  tools: { ...current.tools, ...(patch.tools ?? {}) }
125
181
  };
126
182
  await saveWorkspaceMap(map);
package/dist/index.d.ts CHANGED
@@ -15,3 +15,5 @@ export * from "./status.js";
15
15
  export * from "./templates.js";
16
16
  export * from "./health.js";
17
17
  export * from "./usage.js";
18
+ export * from "./status-events.js";
19
+ export * from "./telemetry.js";
package/dist/index.js CHANGED
@@ -15,3 +15,5 @@ export * from "./status.js";
15
15
  export * from "./templates.js";
16
16
  export * from "./health.js";
17
17
  export * from "./usage.js";
18
+ export * from "./status-events.js";
19
+ export * from "./telemetry.js";
@@ -1,2 +1,3 @@
1
- import type { DecideNextAction } from "./types.js";
1
+ import type { DecideNextAction, ToolName } from "./types.js";
2
+ export declare function inferArgsForTool(tool: ToolName, content: string): unknown;
2
3
  export declare const rulesOrchestrator: DecideNextAction;
@@ -1,6 +1,22 @@
1
- const KNOWN_COMMANDS = ["git", "npm", "pnpm", "yarn", "node", "npx"];
1
+ const KNOWN_COMMANDS = [
2
+ "git",
3
+ "npm",
4
+ "pnpm",
5
+ "yarn",
6
+ "node",
7
+ "npx",
8
+ "powershell",
9
+ "pwsh",
10
+ "cmd",
11
+ "bash",
12
+ "sh",
13
+ "python",
14
+ "python3"
15
+ ];
2
16
  const TOOL_HINTS = [
3
- { tool: "webSearch", match: /\b(web search|search web|lookup online|latest on|news on|current events?)\b/i },
17
+ { tool: "webSearch", match: /\b(web search|search web|lookup online|latest(?:\s+on)?|news(?:\s+on)?|current events?)\b/i },
18
+ { tool: "imageGenerate", match: /\b(generate|create|make|design)\b.*\b(image|logo|icon|picture|art|illustration)\b|\b(dall[- ]?e|image generation)\b/i },
19
+ { tool: "moveFile", match: /\b(move|rename)\b.*\b(file|folder|directory)\b|\bmove\s+.+\s+to\s+.+\b/i },
4
20
  { tool: "memorySet", match: /\bremember(?:\s+that)?\b/i },
5
21
  { tool: "memoryGet", match: /\b(what do you remember|what did i tell you|recall|remember my|what is my)\b/i },
6
22
  { tool: "openCalendarEvent", match: /\b(schedule|calendar|meeting|event)\b/i },
@@ -10,7 +26,7 @@ const TOOL_HINTS = [
10
26
  { tool: "openSlackCompose", match: /\bslack\b.*\b(message|send|compose)\b/i },
11
27
  { tool: "openEmail", match: /\b(draft an email|send an email|mailto|email)\b/i },
12
28
  { tool: "openMapsDirections", match: /\b(directions?|navigate|maps?)\b/i },
13
- { tool: "runCommand", match: /\b(run|execute)\b|\b(git|npm|pnpm|yarn|node|npx)\b/i },
29
+ { tool: "runCommand", match: /\b(run|execute)\b|\b(git|npm|pnpm|yarn|node|npx|powershell|pwsh|cmd|bash|python|python3)\b/i },
14
30
  { tool: "writeFile", match: /\b(write|create|update|edit|save)\b.*\b(file|readme|json|md|ts|js)\b/i },
15
31
  { tool: "readFile", match: /\b(read|open|show)\b.*\b(file|readme|json|md|ts|js)\b/i },
16
32
  { tool: "searchInFiles", match: /\b(search|find|grep|look for)\b/i },
@@ -18,6 +34,27 @@ const TOOL_HINTS = [
18
34
  { tool: "gitDiff", match: /\bgit diff|show diff|changes\b/i },
19
35
  { tool: "listDirectory", match: /\b(list|ls|folders?|directories?)\b/i }
20
36
  ];
37
+ const TOOL_PRECEDENCE = [
38
+ "imageGenerate",
39
+ "webSearch",
40
+ "openCalendarEvent",
41
+ "openGitHubIssue",
42
+ "openNotionPage",
43
+ "openLinearIssue",
44
+ "openSlackCompose",
45
+ "openEmail",
46
+ "openMapsDirections",
47
+ "memorySet",
48
+ "memoryGet",
49
+ "runCommand",
50
+ "writeFile",
51
+ "moveFile",
52
+ "readFile",
53
+ "searchInFiles",
54
+ "gitStatus",
55
+ "gitDiff",
56
+ "listDirectory"
57
+ ];
21
58
  function stripWrappers(value) {
22
59
  return value.trim().replace(/^["'`]|["'`]$/g, "");
23
60
  }
@@ -59,7 +96,13 @@ function extractFirstPath(content) {
59
96
  const quoted = content.match(/["'`]([^"'`]+)["'`]/);
60
97
  if (quoted?.[1])
61
98
  return quoted[1].trim();
62
- const direct = content.match(/\b([./\\]?[a-zA-Z0-9_\-/\\.]+\.[a-zA-Z0-9_]+)\b/);
99
+ const windows = content.match(/\b([a-zA-Z]:\\[^\s"'`]+(?:\\[^\s"'`]+)*\.[a-zA-Z0-9_]+)\b/);
100
+ if (windows?.[1])
101
+ return windows[1].trim();
102
+ const unixAbs = content.match(/(?:^|[\s"'`])(\/[^\s"'`]+(?:\/[^\s"'`]+)*\.[a-zA-Z0-9_]+)/);
103
+ if (unixAbs?.[1])
104
+ return unixAbs[1].trim();
105
+ const direct = content.match(/\b([./\\]?[a-zA-Z0-9_./\\-]+\.[a-zA-Z0-9_]+)\b/);
63
106
  if (direct?.[1])
64
107
  return direct[1].trim();
65
108
  return null;
@@ -121,7 +164,7 @@ function parseRunCommand(content) {
121
164
  content.match(/\b(?:run|execute)(?:\s+the)?(?:\s+command)?\s+(.+)$/i)?.[1] ??
122
165
  content.match(new RegExp(`^\\s*(${KNOWN_COMMANDS.join("|")})\\b(.+)?$`, "i"))?.[0] ??
123
166
  "";
124
- const normalized = candidate.trim().replace(/[.;,\s]+$/g, "");
167
+ const normalized = candidate.trim().replace(/^[>]+/, "").replace(/[.;,\s]+$/g, "");
125
168
  if (!normalized)
126
169
  return null;
127
170
  const tokens = tokenizeCommand(normalized);
@@ -151,6 +194,25 @@ function parseWriteFileArgs(content) {
151
194
  content: contentMatch.trim()
152
195
  };
153
196
  }
197
+ function parseMoveFileArgs(content) {
198
+ const quoted = [...content.matchAll(/["'`]([^"'`]+)["'`]/g)]
199
+ .map((match) => match[1]?.trim())
200
+ .filter((value) => Boolean(value));
201
+ if (quoted.length >= 2) {
202
+ return {
203
+ fromPathRelativeToWorkspace: quoted[0],
204
+ toPathRelativeToWorkspace: quoted[1]
205
+ };
206
+ }
207
+ const moveTo = content.match(/\b(?:move|rename)\s+(.+?)\s+\bto\b\s+(.+)$/i);
208
+ if (moveTo?.[1] && moveTo?.[2]) {
209
+ return {
210
+ fromPathRelativeToWorkspace: stripWrappers(moveTo[1].trim()),
211
+ toPathRelativeToWorkspace: stripWrappers(moveTo[2].trim())
212
+ };
213
+ }
214
+ return null;
215
+ }
154
216
  function parseSearchArgs(content) {
155
217
  const quoted = content.match(/["'`]([^"'`]+)["'`]/)?.[1];
156
218
  const phrase = quoted ??
@@ -187,6 +249,29 @@ function parseMemoryGetArgs(content) {
187
249
  content.replace(/\?+$/, "");
188
250
  return { key: parsed.trim() || "note" };
189
251
  }
252
+ function parseImageGenerateArgs(content) {
253
+ const prompt = extractQuoted(content) ??
254
+ content
255
+ .replace(/\b(generate|create|make|design)\b/gi, "")
256
+ .replace(/\b(an?|the)\b/gi, "")
257
+ .replace(/\b(image|logo|icon|picture|art|illustration)\b/gi, "")
258
+ .replace(/\b(for me|please)\b/gi, "")
259
+ .replace(/\s+/g, " ")
260
+ .trim();
261
+ const sizeMatch = content.match(/\b(1024x1024|1024x1536|1536x1024)\b/i)?.[1]?.toLowerCase();
262
+ const quality = /\b(hd|high quality)\b/i.test(content) ? "hd" : "standard";
263
+ const style = /\bnatural\b/i.test(content) ? "natural" : "vivid";
264
+ const filePath = content.match(/\b(?:to|into|save(?:d)?(?:\s+to)?)\s+["'`]([^"'`]+\.(?:png|jpg|jpeg))["'`]/i)?.[1] ??
265
+ content.match(/\b(?:to|into|save(?:d)?(?:\s+to)?)\s+([^\s]+\.(?:png|jpg|jpeg))\b/i)?.[1] ??
266
+ undefined;
267
+ return {
268
+ prompt: prompt || "A clean terminal-themed image concept",
269
+ size: sizeMatch ?? "1024x1024",
270
+ quality,
271
+ style,
272
+ pathRelativeToWorkspace: filePath
273
+ };
274
+ }
190
275
  function parseCalendarArgs(content) {
191
276
  const quoted = extractQuoted(content);
192
277
  const title = quoted ??
@@ -254,18 +339,125 @@ function parseDirectionsArgs(content) {
254
339
  const provider = /\bapple maps?\b/i.test(content) ? "apple" : "google";
255
340
  return { destination, provider };
256
341
  }
257
- function inferTool(content) {
342
+ function isDeepLinkTool(tool) {
343
+ return (tool === "openCalendarEvent" ||
344
+ tool === "openGitHubIssue" ||
345
+ tool === "openNotionPage" ||
346
+ tool === "openLinearIssue" ||
347
+ tool === "openSlackCompose" ||
348
+ tool === "openEmail" ||
349
+ tool === "openMapsDirections");
350
+ }
351
+ function precedenceIndex(tool) {
352
+ const idx = TOOL_PRECEDENCE.indexOf(tool);
353
+ return idx === -1 ? TOOL_PRECEDENCE.length : idx;
354
+ }
355
+ function scoreToolMatch(tool, content, baseScore) {
356
+ let score = baseScore;
357
+ if (tool === "runCommand") {
358
+ score += parseRunCommand(content) ? 3 : -0.7;
359
+ if (/\b(git status|git diff|show diff|repo status|what changed)\b/i.test(content)) {
360
+ score -= 2;
361
+ }
362
+ }
363
+ if (tool === "gitStatus" && /\bgit status\b|\brepo status\b|\bwhat changed\b/i.test(content)) {
364
+ score += 3;
365
+ }
366
+ if (tool === "gitDiff" && /\bgit diff\b|\bshow diff\b|\bchanges\b/i.test(content)) {
367
+ score += 2.5;
368
+ }
369
+ if (tool === "imageGenerate" && /\b(image|logo|icon|picture|photo|art|illustration|graphic)\b/i.test(content)) {
370
+ score += 1.5;
371
+ }
372
+ if (tool === "webSearch" && /\b(latest|today|news|current|online|web)\b/i.test(content)) {
373
+ score += 1.4;
374
+ }
375
+ if ((tool === "readFile" || tool === "writeFile" || tool === "moveFile") && extractFirstPath(content)) {
376
+ score += 1.1;
377
+ }
378
+ if (tool === "listDirectory" && /\b(list|ls|folders?|directories?)\b/i.test(content)) {
379
+ score += 1.2;
380
+ }
381
+ if (tool === "searchInFiles" && /\b(todo|fixme|pattern|match|grep)\b/i.test(content)) {
382
+ score += 0.8;
383
+ }
384
+ if (tool === "openCalendarEvent" && /\b(tomorrow|today|at\s+\d|meeting|event)\b/i.test(content)) {
385
+ score += 1;
386
+ }
387
+ if (tool === "openNotionPage" && /\bnotion\b.*\b(page|note)\b/i.test(content)) {
388
+ score += 2;
389
+ }
390
+ if (tool === "openLinearIssue" && /\b(linear|jira)\b.*\b(issue|ticket)\b/i.test(content)) {
391
+ score += 2;
392
+ }
393
+ if (tool === "openSlackCompose" && /\bslack\b.*\b(message|send|compose)\b/i.test(content)) {
394
+ score += 2;
395
+ }
396
+ if (tool === "openMapsDirections" && /\b(directions?|navigate|maps?)\b/i.test(content)) {
397
+ score += 1.8;
398
+ }
399
+ if (tool === "openEmail" && /\b(draft an email|send an email|mailto|email)\b/i.test(content)) {
400
+ score += 1.4;
401
+ }
402
+ if (tool === "memorySet" && /\bremember(?:\s+that)?\b/i.test(content) && /\b(is|=|as|:)\b/i.test(content)) {
403
+ score += 1.3;
404
+ }
405
+ if (tool === "memoryGet" && /\b(what do you remember|what did i tell you|recall|remember my|what is my)\b/i.test(content)) {
406
+ score += 1.3;
407
+ }
408
+ if (tool === "openGitHubIssue" && /\b(github|label|repo|issue)\b/i.test(content)) {
409
+ score += 1;
410
+ }
411
+ if (tool === "openEmail" && /\b(to\s+[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}|subject)\b/i.test(content)) {
412
+ score += 1;
413
+ }
414
+ return score;
415
+ }
416
+ function inferTool(content, availableTools) {
417
+ const candidates = [];
258
418
  for (const hint of TOOL_HINTS) {
259
- if (hint.match.test(content))
260
- return hint.tool;
419
+ if (!availableTools.includes(hint.tool))
420
+ continue;
421
+ if (!hint.match.test(content))
422
+ continue;
423
+ const candidate = {
424
+ tool: hint.tool,
425
+ score: scoreToolMatch(hint.tool, content, 1)
426
+ };
427
+ candidates.push(candidate);
261
428
  }
262
- return null;
429
+ candidates.sort((a, b) => {
430
+ if (b.score !== a.score)
431
+ return b.score - a.score;
432
+ return precedenceIndex(a.tool) - precedenceIndex(b.tool);
433
+ });
434
+ if (candidates.length === 0) {
435
+ return { tool: null, confidence: "none", alternatives: [] };
436
+ }
437
+ const top = candidates[0];
438
+ const second = candidates[1];
439
+ const gap = second ? top.score - second.score : top.score;
440
+ let confidence = "medium";
441
+ if (top.score >= 2.4 && gap >= 0.7)
442
+ confidence = "high";
443
+ else if (top.score < 1.2 || gap < 0.2)
444
+ confidence = "low";
445
+ if (second && isDeepLinkTool(top.tool) && isDeepLinkTool(second.tool) && top.tool !== second.tool) {
446
+ confidence = "low";
447
+ }
448
+ return {
449
+ tool: top.tool,
450
+ confidence,
451
+ alternatives: candidates.slice(1, 3).map((candidate) => candidate.tool)
452
+ };
263
453
  }
264
- function inferArgs(tool, content) {
454
+ export function inferArgsForTool(tool, content) {
265
455
  if (tool === "searchInFiles")
266
456
  return parseSearchArgs(content);
267
457
  if (tool === "webSearch")
268
458
  return parseWebSearchArgs(content);
459
+ if (tool === "imageGenerate")
460
+ return parseImageGenerateArgs(content);
269
461
  if (tool === "runCommand")
270
462
  return parseRunCommand(content);
271
463
  if (tool === "listDirectory")
@@ -280,6 +472,8 @@ function inferArgs(tool, content) {
280
472
  return parseReadFileArgs(content);
281
473
  if (tool === "writeFile")
282
474
  return parseWriteFileArgs(content);
475
+ if (tool === "moveFile")
476
+ return parseMoveFileArgs(content);
283
477
  if (tool === "memorySet")
284
478
  return parseMemorySetArgs(content);
285
479
  if (tool === "memoryGet")
@@ -300,24 +494,43 @@ function inferArgs(tool, content) {
300
494
  return parseDirectionsArgs(content);
301
495
  return {};
302
496
  }
303
- export const rulesOrchestrator = (messages) => {
497
+ export const rulesOrchestrator = (messages, tools) => {
304
498
  const lastUser = [...messages].reverse().find((m) => m.role === "user");
305
499
  if (!lastUser)
306
500
  return { kind: "summarize", summary: "No task provided." };
307
- const tool = inferTool(lastUser.content);
501
+ const inference = inferTool(lastUser.content, tools);
502
+ const tool = inference.tool;
308
503
  if (!tool) {
309
504
  return {
310
505
  kind: "summarize",
311
- summary: "I can help with workspace files, search, git, memory, and assistant actions like calendar/email/deep-links."
506
+ summary: "I can help with workspace files (read/write/move), search, git, memory, deep-links (calendar/email/issues), and image generation."
312
507
  };
313
508
  }
314
- const args = inferArgs(tool, lastUser.content);
509
+ const args = inferArgsForTool(tool, lastUser.content);
315
510
  if (tool === "runCommand" && !args) {
316
511
  return {
317
512
  kind: "summarize",
318
513
  summary: "I can run approved workspace commands. Please provide an explicit command, for example: run `npm test`."
319
514
  };
320
515
  }
516
+ if (tool === "moveFile" && !args) {
517
+ return {
518
+ kind: "summarize",
519
+ summary: "Please provide both source and destination paths. Example: move \"docs/old.md\" to \"docs/new.md\"."
520
+ };
521
+ }
522
+ if (inference.confidence === "low") {
523
+ if (inference.alternatives.length > 0) {
524
+ return {
525
+ kind: "summarize",
526
+ summary: `I can do this, but I need one clarification. Did you want ${tool}, or ${inference.alternatives.join(" / ")}?`
527
+ };
528
+ }
529
+ return {
530
+ kind: "summarize",
531
+ summary: `I can handle this with ${tool}, but I need one detail to avoid the wrong action. Please provide exact file path or command.`
532
+ };
533
+ }
321
534
  return {
322
535
  kind: "ask_permission",
323
536
  tool,
@@ -2,6 +2,8 @@ export function getToolPermission(settings, tool) {
2
2
  return settings.tools[tool] ?? "disabled";
3
3
  }
4
4
  export function shouldAskEveryTime(settings, tool) {
5
+ if (settings.executionMode === "full")
6
+ return false;
5
7
  if (tool === "runCommand")
6
8
  return true;
7
9
  if (tool === "writeFile")
@@ -18,7 +20,7 @@ export function isToolEnabled(settings, tool) {
18
20
  export function toolRiskLevel(tool) {
19
21
  if (tool === "runCommand")
20
22
  return "high";
21
- if (tool === "writeFile")
23
+ if (tool === "writeFile" || tool === "moveFile" || tool === "imageGenerate")
22
24
  return "medium";
23
25
  if (tool === "openCalendarEvent" ||
24
26
  tool === "openGitHubIssue" ||
package/dist/policy.js CHANGED
@@ -7,7 +7,21 @@ const READ_ONLY_TOOLS = [
7
7
  "gitDiff",
8
8
  "memoryGet"
9
9
  ];
10
- const COMMAND_ALLOWLIST = new Set(["git", "npm", "pnpm", "yarn", "node", "npx"]);
10
+ const COMMAND_ALLOWLIST = new Set([
11
+ "git",
12
+ "npm",
13
+ "pnpm",
14
+ "yarn",
15
+ "node",
16
+ "npx",
17
+ "powershell",
18
+ "pwsh",
19
+ "cmd",
20
+ "bash",
21
+ "sh",
22
+ "python",
23
+ "python3"
24
+ ]);
11
25
  export function isReadOnlyTool(tool) {
12
26
  return READ_ONLY_TOOLS.includes(tool);
13
27
  }
@@ -27,7 +41,10 @@ export function shouldExecuteInMode(settings, tool, args) {
27
41
  return { execute: true, ask: false, reason: "Safe mode auto-executes read-only tools." };
28
42
  return { execute: false, ask: true, reason: "Safe mode requires approval for writes and commands." };
29
43
  }
30
- if (tool === "runCommand") {
44
+ if (mode === "full") {
45
+ return { execute: true, ask: false, reason: "Full access executes tools directly." };
46
+ }
47
+ if (mode === "autonomous" && tool === "runCommand") {
31
48
  const cmd = args?.cmd ?? "";
32
49
  if (!isCommandAllowlisted(cmd)) {
33
50
  return {
@@ -37,5 +54,8 @@ export function shouldExecuteInMode(settings, tool, args) {
37
54
  };
38
55
  }
39
56
  }
40
- return { execute: true, ask: false, reason: "Autonomous mode executes approved plan directly." };
57
+ if (mode === "autonomous") {
58
+ return { execute: true, ask: false, reason: "Autonomous mode executes approved plan directly." };
59
+ }
60
+ return { execute: false, ask: true, reason: "Unknown execution mode; approval required." };
41
61
  }
@@ -0,0 +1,9 @@
1
+ import type { ToolName } from "./types.js";
2
+ export type ToolStage = "planning" | "requesting_model" | "rendering" | "receiving_payload" | "saving_file" | "completed" | "failed";
3
+ export type StatusEvent = "planning" | "planning:cloud" | `proposed:${ToolName}` | `approval:${ToolName}` | `executing:${ToolName}` | `blocked:${ToolName}` | `tool:${ToolName}:${string}` | "complete" | "failed" | "cancelled";
4
+ export declare function statusProposed(tool: ToolName): StatusEvent;
5
+ export declare function statusApproval(tool: ToolName): StatusEvent;
6
+ export declare function statusExecuting(tool: ToolName): StatusEvent;
7
+ export declare function statusBlocked(tool: ToolName): StatusEvent;
8
+ export declare function statusToolStage(tool: ToolName, stage: ToolStage | string): StatusEvent;
9
+ export declare function parseStatusEvent(raw: string): StatusEvent;
@@ -0,0 +1,27 @@
1
+ export function statusProposed(tool) {
2
+ return `proposed:${tool}`;
3
+ }
4
+ export function statusApproval(tool) {
5
+ return `approval:${tool}`;
6
+ }
7
+ export function statusExecuting(tool) {
8
+ return `executing:${tool}`;
9
+ }
10
+ export function statusBlocked(tool) {
11
+ return `blocked:${tool}`;
12
+ }
13
+ export function statusToolStage(tool, stage) {
14
+ return `tool:${tool}:${stage}`;
15
+ }
16
+ export function parseStatusEvent(raw) {
17
+ if (raw === "planning" || raw === "planning:cloud" || raw === "complete" || raw === "failed" || raw === "cancelled") {
18
+ return raw;
19
+ }
20
+ if (/^(proposed|approval|executing|blocked):[a-zA-Z]+$/.test(raw)) {
21
+ return raw;
22
+ }
23
+ if (/^tool:[a-zA-Z]+:[a-zA-Z0-9_:-]+$/.test(raw)) {
24
+ return raw;
25
+ }
26
+ return "failed";
27
+ }
@@ -0,0 +1,9 @@
1
+ export type TelemetryEvent = {
2
+ timestamp: string;
3
+ event: string;
4
+ workspacePath?: string;
5
+ sessionId?: string;
6
+ status?: string;
7
+ metadata?: Record<string, unknown>;
8
+ };
9
+ export declare function appendTelemetryEvent(event: TelemetryEvent): Promise<void>;
@@ -0,0 +1,12 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { telemetryLogFile } from "./config.js";
4
+ export async function appendTelemetryEvent(event) {
5
+ try {
6
+ await fs.mkdir(path.dirname(telemetryLogFile()), { recursive: true });
7
+ await fs.appendFile(telemetryLogFile(), `${JSON.stringify(event)}\n`, "utf8");
8
+ }
9
+ catch {
10
+ // Telemetry failures should never block primary flows.
11
+ }
12
+ }
package/dist/tools.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { ToolArgsMap, ToolName, ToolResult, WorkspaceSettings } from "./typ
2
2
  type ToolContext = {
3
3
  workspacePath: string;
4
4
  settings: WorkspaceSettings;
5
+ onStatus?: (status: string) => void;
5
6
  cloud?: {
6
7
  webSearch?: (query: string, maxResults?: number) => Promise<{
7
8
  query: string;
@@ -12,6 +13,12 @@ type ToolContext = {
12
13
  snippet: string;
13
14
  }>;
14
15
  }>;
16
+ imageGenerate?: (args: ToolArgsMap["imageGenerate"], onProgress?: (stage: "requesting" | "rendering" | "received") => void) => Promise<{
17
+ model: string;
18
+ mimeType: string;
19
+ base64: string;
20
+ revisedPrompt?: string;
21
+ }>;
15
22
  };
16
23
  };
17
24
  export declare function executeTool<T extends ToolName>(context: ToolContext, tool: T, args: ToolArgsMap[T]): Promise<ToolResult>;
package/dist/tools.js CHANGED
@@ -7,6 +7,7 @@ import { resolveInWorkspace } from "./sandbox.js";
7
7
  import { appendAudit } from "./audit.js";
8
8
  import { applyPatchProposal, createPatchProposal } from "./patches.js";
9
9
  import { memoryFile } from "./config.js";
10
+ import { statusToolStage } from "./status-events.js";
10
11
  const execFileAsync = promisify(execFile);
11
12
  function dryRunResult(tool, files = []) {
12
13
  return { ok: true, output: `[dry-run] ${tool} skipped execution.`, files };
@@ -98,6 +99,61 @@ function buildMapsUrl(args) {
98
99
  }
99
100
  return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(args.destination)}`;
100
101
  }
102
+ function sanitizeFileStem(value) {
103
+ return value
104
+ .toLowerCase()
105
+ .replace(/[^a-z0-9]+/g, "-")
106
+ .replace(/^-+|-+$/g, "")
107
+ .slice(0, 48);
108
+ }
109
+ function imageExtFromMime(mimeType) {
110
+ const lowered = mimeType.toLowerCase();
111
+ if (lowered.includes("jpeg") || lowered.includes("jpg"))
112
+ return "jpg";
113
+ if (lowered.includes("webp"))
114
+ return "webp";
115
+ return "png";
116
+ }
117
+ function imageFailureMessage(kind, detail) {
118
+ const cleanDetail = (detail ?? "").trim();
119
+ const suffix = cleanDetail ? ` Detail: ${cleanDetail}` : "";
120
+ if (kind === "invalid_prompt") {
121
+ return ("Image generation failed [invalid_prompt]. Provide a non-empty prompt after /image or \"generate image\"." +
122
+ suffix);
123
+ }
124
+ if (kind === "config_missing") {
125
+ return ("Image generation failed [config_missing]. Configure Supabase secrets OPENAI_API_KEY and OPENAI_IMAGE_MODEL, then redeploy image-generate." +
126
+ suffix);
127
+ }
128
+ if (kind === "auth_session") {
129
+ return ("Image generation failed [auth_session]. Run \"terry auth login\", then verify \"terry cloud status\" shows a valid session." +
130
+ suffix);
131
+ }
132
+ if (kind === "file_save") {
133
+ return ("Image generation failed [file_save]. Terry generated the image but could not save it. Check file path permissions and disk availability." +
134
+ suffix);
135
+ }
136
+ return ("Image generation failed [upstream]. The image API request failed. Retry once, then inspect Supabase function logs for image-generate." +
137
+ suffix);
138
+ }
139
+ function classifyImageCloudFailure(raw) {
140
+ const lowered = raw.toLowerCase();
141
+ if (lowered.includes("[cloud_misconfigured]") ||
142
+ lowered.includes("cloud is not configured") ||
143
+ lowered.includes("server configuration is incomplete") ||
144
+ lowered.includes("openai_api_key")) {
145
+ return "config_missing";
146
+ }
147
+ if (lowered.includes("[auth_failed]") ||
148
+ lowered.includes("[auth_required]") ||
149
+ lowered.includes("not logged in") ||
150
+ lowered.includes("session expired") ||
151
+ lowered.includes("authentication failed") ||
152
+ lowered.includes("unauthorized")) {
153
+ return "auth_session";
154
+ }
155
+ return "upstream";
156
+ }
101
157
  async function openExternalUrl(url) {
102
158
  if (process.platform === "win32") {
103
159
  const escaped = url.replace(/'/g, "''");
@@ -168,7 +224,7 @@ export async function executeTool(context, tool, args) {
168
224
  const full = resolveInWorkspace(context.workspacePath, parsed.pathRelativeToWorkspace);
169
225
  const proposal = await createPatchProposal(context.workspacePath, parsed.pathRelativeToWorkspace, parsed.content, {
170
226
  persist: !context.settings.dryRun,
171
- preApproveAll: context.settings.executionMode === "autonomous"
227
+ preApproveAll: context.settings.executionMode === "full" || context.settings.executionMode === "autonomous"
172
228
  });
173
229
  if (context.settings.dryRun) {
174
230
  return {
@@ -193,6 +249,21 @@ export async function executeTool(context, tool, args) {
193
249
  patchProposalId: proposal.id
194
250
  };
195
251
  });
252
+ case "moveFile":
253
+ return withAudit(context, tool, args, [], async () => {
254
+ const parsed = args;
255
+ const fromPath = resolveInWorkspace(context.workspacePath, parsed.fromPathRelativeToWorkspace);
256
+ const toPath = resolveInWorkspace(context.workspacePath, parsed.toPathRelativeToWorkspace);
257
+ if (context.settings.dryRun)
258
+ return dryRunResult(tool, [fromPath, toPath]);
259
+ await fs.mkdir(path.dirname(toPath), { recursive: true });
260
+ await fs.rename(fromPath, toPath);
261
+ return {
262
+ ok: true,
263
+ output: `Moved ${parsed.fromPathRelativeToWorkspace} -> ${parsed.toPathRelativeToWorkspace}`,
264
+ files: [fromPath, toPath]
265
+ };
266
+ });
196
267
  case "searchInFiles":
197
268
  return withAudit(context, tool, args, [], async () => {
198
269
  const parsed = args;
@@ -265,6 +336,92 @@ export async function executeTool(context, tool, args) {
265
336
  const { stdout, stderr } = await execFileAsync(parsed.cmd, parsed.args ?? [], { cwd });
266
337
  return { ok: true, output: `${stdout}${stderr}`.trim() };
267
338
  });
339
+ case "imageGenerate":
340
+ return withAudit(context, tool, args, [], async () => {
341
+ const imageStart = Date.now();
342
+ const parsed = args;
343
+ context.onStatus?.(statusToolStage("imageGenerate", "planning"));
344
+ if (!parsed.prompt?.trim()) {
345
+ context.onStatus?.(statusToolStage("imageGenerate", "failed"));
346
+ return {
347
+ ok: false,
348
+ error: imageFailureMessage("invalid_prompt")
349
+ };
350
+ }
351
+ if (!context.cloud?.imageGenerate) {
352
+ context.onStatus?.(statusToolStage("imageGenerate", "failed"));
353
+ return {
354
+ ok: false,
355
+ error: imageFailureMessage("config_missing", "imageGenerate requires cloud relay integration.")
356
+ };
357
+ }
358
+ const requestedPath = parsed.pathRelativeToWorkspace?.trim();
359
+ const defaultStem = sanitizeFileStem(parsed.prompt) || "terry-image";
360
+ const safePath = requestedPath || `generated/${defaultStem}-${Date.now()}.png`;
361
+ const fullPath = resolveInWorkspace(context.workspacePath, safePath);
362
+ if (context.settings.dryRun) {
363
+ context.onStatus?.(statusToolStage("imageGenerate", "completed"));
364
+ return {
365
+ ok: true,
366
+ output: [
367
+ "[dry-run] image generation preview",
368
+ `Saved path: ${safePath}`
369
+ ].join("\n"),
370
+ files: [fullPath]
371
+ };
372
+ }
373
+ let generated;
374
+ try {
375
+ generated = await context.cloud.imageGenerate(parsed, (stage) => {
376
+ if (stage === "requesting")
377
+ context.onStatus?.(statusToolStage("imageGenerate", "requesting_model"));
378
+ if (stage === "rendering")
379
+ context.onStatus?.(statusToolStage("imageGenerate", "rendering"));
380
+ if (stage === "received")
381
+ context.onStatus?.(statusToolStage("imageGenerate", "receiving_payload"));
382
+ });
383
+ }
384
+ catch (error) {
385
+ context.onStatus?.(statusToolStage("imageGenerate", "failed"));
386
+ const detail = error instanceof Error ? error.message : String(error);
387
+ return {
388
+ ok: false,
389
+ error: imageFailureMessage(classifyImageCloudFailure(detail), detail)
390
+ };
391
+ }
392
+ try {
393
+ const ext = path.extname(fullPath) ? "" : `.${imageExtFromMime(generated.mimeType)}`;
394
+ const writePath = ext ? `${fullPath}${ext}` : fullPath;
395
+ context.onStatus?.(statusToolStage("imageGenerate", "saving_file"));
396
+ await fs.mkdir(path.dirname(writePath), { recursive: true });
397
+ await fs.writeFile(writePath, Buffer.from(generated.base64, "base64"));
398
+ context.onStatus?.(statusToolStage("imageGenerate", "completed"));
399
+ const relPath = path.relative(context.workspacePath, writePath);
400
+ const elapsedMs = Date.now() - imageStart;
401
+ const lines = [
402
+ `Saved path: ${relPath}`,
403
+ `Model: ${generated.model}`,
404
+ `Mime type: ${generated.mimeType}`,
405
+ `Total duration: ${(elapsedMs / 1000).toFixed(2)}s`
406
+ ];
407
+ if (generated.revisedPrompt?.trim()) {
408
+ lines.push(`Revised prompt: ${generated.revisedPrompt.trim()}`);
409
+ }
410
+ return {
411
+ ok: true,
412
+ output: lines.join("\n"),
413
+ files: [writePath]
414
+ };
415
+ }
416
+ catch (error) {
417
+ context.onStatus?.(statusToolStage("imageGenerate", "failed"));
418
+ const message = error instanceof Error ? error.message : String(error);
419
+ return {
420
+ ok: false,
421
+ error: imageFailureMessage("file_save", message)
422
+ };
423
+ }
424
+ });
268
425
  case "memorySet":
269
426
  return withAudit(context, tool, args, [memoryFile()], async () => {
270
427
  const parsed = args;
package/dist/types.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export type PermissionLevel = "disabled" | "enabled" | "ask";
2
2
  export type AgentMode = "general" | "focus" | "builder";
3
- export type ExecutionMode = "plan" | "safe" | "autonomous";
4
- export type ToolName = "listDirectory" | "readFile" | "writeFile" | "searchInFiles" | "webSearch" | "gitStatus" | "gitDiff" | "runCommand" | "memorySet" | "memoryGet" | "openCalendarEvent" | "openGitHubIssue" | "openNotionPage" | "openLinearIssue" | "openSlackCompose" | "openEmail" | "openMapsDirections";
3
+ export type ExecutionMode = "plan" | "safe" | "full" | "autonomous";
4
+ export type ToolName = "listDirectory" | "readFile" | "writeFile" | "moveFile" | "searchInFiles" | "webSearch" | "gitStatus" | "gitDiff" | "runCommand" | "imageGenerate" | "memorySet" | "memoryGet" | "openCalendarEvent" | "openGitHubIssue" | "openNotionPage" | "openLinearIssue" | "openSlackCompose" | "openEmail" | "openMapsDirections";
5
5
  export type ToolArgsMap = {
6
6
  listDirectory: {
7
7
  pathRelativeToWorkspace?: string;
@@ -13,6 +13,10 @@ export type ToolArgsMap = {
13
13
  pathRelativeToWorkspace: string;
14
14
  content: string;
15
15
  };
16
+ moveFile: {
17
+ fromPathRelativeToWorkspace: string;
18
+ toPathRelativeToWorkspace: string;
19
+ };
16
20
  searchInFiles: {
17
21
  query: string;
18
22
  };
@@ -29,6 +33,13 @@ export type ToolArgsMap = {
29
33
  args?: string[];
30
34
  cwdRelativeToWorkspace?: string;
31
35
  };
36
+ imageGenerate: {
37
+ prompt: string;
38
+ size?: "1024x1024" | "1024x1536" | "1536x1024";
39
+ quality?: "standard" | "hd";
40
+ style?: "vivid" | "natural";
41
+ pathRelativeToWorkspace?: string;
42
+ };
32
43
  memorySet: {
33
44
  key: string;
34
45
  value: string;
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "terry-core",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "description": "Core policy, tooling, and orchestration modules for Terry Agent.",
5
5
  "license": "MIT",
6
6
  "author": "ELEVAREL",
7
7
  "homepage": "https://github.com/ELEVAREL/terry-agent",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/ELEVAREL/terry-agent.git",
10
+ "url": "git+https://github.com/ELEVAREL/terry-agent.git",
11
11
  "directory": "packages/terry-core"
12
12
  },
13
13
  "type": "module",