@orxataguy/tyr 1.0.37 → 1.0.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orxataguy/tyr",
3
- "version": "1.0.37",
3
+ "version": "1.0.39",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tyr": "./bin/tyr.js"
@@ -47,6 +47,7 @@
47
47
  "axios": "^1.13.2",
48
48
  "chalk": "^5.6.2",
49
49
  "cheerio": "^1.1.2",
50
+ "diff": "^9.0.0",
50
51
  "dotenv": "^17.2.3",
51
52
  "execa": "^6.1.0",
52
53
  "find-config": "^1.0.0",
@@ -57,6 +58,7 @@
57
58
  "tsx": "^4.21.0"
58
59
  },
59
60
  "devDependencies": {
61
+ "@types/diff": "^7.0.2",
60
62
  "@types/js-yaml": "^4.0.9",
61
63
  "@types/node": "^25.0.10",
62
64
  "@vitest/coverage-v8": "^3.2.4",
@@ -12,6 +12,8 @@ import { JiraManager } from '../lib/JiraManager.js';
12
12
  import { SetupManager } from '../lib/SetupManager.js';
13
13
  import { AIVendorManager } from '../lib/AIVendorManager.js';
14
14
  import { AIContextManager } from '../lib/AIContextManager.js';
15
+ import { SandboxManager } from '../lib/SandboxManager.js';
16
+ import { MemoryManager } from '../lib/MemoryManager.js';
15
17
  import { PromptTemplateManager } from '../lib/PromptTemplateManager.js';
16
18
  import { TokenManager } from '../lib/TokenManager.js';
17
19
  import { ChatManager } from '../lib/ChatManager.js';
@@ -38,6 +40,8 @@ export interface ServiceContainer {
38
40
  setup: SetupManager;
39
41
  aiVendor: AIVendorManager;
40
42
  aiContext: AIContextManager;
43
+ sandbox: SandboxManager;
44
+ memory: MemoryManager;
41
45
  prompts: PromptTemplateManager;
42
46
  tokens: TokenManager;
43
47
  chat: ChatManager;
@@ -58,7 +62,9 @@ export class Container {
58
62
  const web = new WebManager(logger);
59
63
  const fs = new FileSystemManager(logger);
60
64
  const aiVendor = new AIVendorManager(logger);
61
- const aiContext = new AIContextManager(fs, shell, aiVendor, logger);
65
+ const git = new GitManager(shell, logger);
66
+ const sandbox = new SandboxManager(logger);
67
+ const aiContext = new AIContextManager(fs, shell, aiVendor, logger, git, sandbox);
62
68
 
63
69
  this.services = {
64
70
  logger,
@@ -70,13 +76,15 @@ export class Container {
70
76
  fs,
71
77
  pkg: new PackageManager(shell, logger),
72
78
  docker: new DockerManager(shell, logger),
73
- git: new GitManager(shell, logger),
79
+ git,
74
80
  sys: new SystemManager(shell, logger),
75
81
  workspace: new WorkspaceManager(shell, fs, logger),
76
82
  jira: new JiraManager(web, shell, logger),
77
83
  setup: new SetupManager(shell, fs, logger),
78
84
  aiVendor,
79
85
  aiContext,
86
+ sandbox,
87
+ memory: new MemoryManager(fs, logger),
80
88
  prompts: new PromptTemplateManager(aiContext, logger),
81
89
  tokens: new TokenManager(logger),
82
90
  chat: new ChatManager(fs, logger),
@@ -1,7 +1,7 @@
1
1
  import path from 'path';
2
2
  import { readFile } from 'fs/promises';
3
3
  import { TyrContext } from '../Kernel';
4
- import type { AIContentBlock, AIMessage } from '../../lib/AIVendorManager';
4
+ import type { AIContentBlock, AIMessage, TaskPriority } from '../../lib/AIVendorManager';
5
5
  import type { ChatMessageContext } from '../../lib/ChatManager';
6
6
 
7
7
  function parseFlag(args: string[], name: string): string | undefined {
@@ -19,9 +19,25 @@ export default function chat({ logger, chat: chatManager, aiVendor, fail }: TyrC
19
19
  const port = portArg ? parseInt(portArg, 10) : undefined;
20
20
  const splitRatio = splitArg ? parseFloat(splitArg) : undefined;
21
21
 
22
+ // Which routing priority (model tier + thinking effort) answers a chat message with —
23
+ // see AIVendorManager.TaskPriority. Chat defaults to 'media-prioridad' (the same default
24
+ // ai:code uses) rather than something cheaper, since a wrong answer in an interactive
25
+ // back-and-forth is more disruptive than in a one-shot batch command.
26
+ const priority = (parseFlag(args, '--priority') as TaskPriority | undefined) ?? 'media-prioridad';
27
+
28
+ // Complexity/cost ceiling ("techo de complejidad"): no resolved priority may ever exceed
29
+ // this, no matter what --priority above (or a future per-message override) asks for. Falls
30
+ // back to AI_CHAT_MAX_PRIORITY so a chat session can be capped independently from ai:code /
31
+ // ai:describe (which fall back to the general AI_MAX_PRIORITY instead — see
32
+ // AIVendorManager.getPriorityCeiling()). --max-priority overrides both for this one run.
33
+ const maxPriority = (parseFlag(args, '--max-priority') as TaskPriority | undefined)
34
+ ?? (process.env.AI_CHAT_MAX_PRIORITY as TaskPriority | undefined);
35
+ if (maxPriority) aiVendor.setPriorityCeiling(maxPriority);
36
+
22
37
  // Default responder: forwards the conversation (plus any attached images) to the
23
- // configured AI vendor. Replace with your own chatManager.onMessage(...) in a custom
24
- // command if you want different behaviour.
38
+ // configured AI vendor, routed through the same priority/ceiling system as ai:code and
39
+ // ai:describe. Replace with your own chatManager.onMessage(...) in a custom command if you
40
+ // want different behaviour.
25
41
  chatManager.onMessage(async ({ message, history, dir: chatDir }: ChatMessageContext) => {
26
42
  const priorTurns: AIMessage[] = history.slice(0, -1).map((m) => ({
27
43
  role: m.role === 'user' ? 'user' : 'assistant',
@@ -49,7 +65,7 @@ export default function chat({ logger, chat: chatManager, aiVendor, fail }: TyrC
49
65
  { role: 'user', content: contentBlocks.length > 0 ? contentBlocks : message.text },
50
66
  ];
51
67
 
52
- const result = await aiVendor.complete(messages);
68
+ const result = await aiVendor.completeWithPriority(messages, priority);
53
69
  return result.content;
54
70
  });
55
71
 
@@ -65,6 +81,7 @@ export default function chat({ logger, chat: chatManager, aiVendor, fail }: TyrC
65
81
  const session = await chatManager.open(dir, { port, splitRatio });
66
82
  logger.success(`Chat ready at: ${session.url}`);
67
83
  logger.info(`Browsing: ${session.dir}`);
84
+ logger.info(`Priority: ${priority}${maxPriority ? ` (ceiling: ${maxPriority})` : ''}`);
68
85
  logger.info('Press Ctrl+C to stop.');
69
86
  } catch (e: any) {
70
87
  fail(`Could not start chat: ${e.message}`, 'Check that the directory exists and the port is free.');
package/src/index.ts CHANGED
@@ -4,3 +4,4 @@
4
4
 
5
5
  export type { TyrContext } from './core/Kernel.js';
6
6
  export type { TaskPriority } from './lib/AIVendorManager.js';
7
+ export { TASK_PRIORITIES } from './lib/AIVendorManager.js';
@@ -1,8 +1,11 @@
1
1
  import path from 'path';
2
2
  import { statSync } from 'fs';
3
+ import { createPatch } from 'diff';
3
4
 
4
5
  import { FileSystemManager } from './FileSystemManager.js';
5
6
  import { ShellManager } from './ShellManager.js';
7
+ import { GitManager } from './GitManager.js';
8
+ import { SandboxManager, SandboxResult } from './SandboxManager.js';
6
9
  import { AIVendorManager, AIMessage, AIContentBlock, AITool, TaskPriority } from './AIVendorManager.js';
7
10
  import { TokenManager } from './TokenManager.js';
8
11
  import { Logger } from '../core/Logger.js';
@@ -252,6 +255,20 @@ export const AGENT_TOOLS: AITool[] = [
252
255
  required: ['packageName', 'path'],
253
256
  },
254
257
  },
258
+ {
259
+ name: 'git_diff',
260
+ description:
261
+ "Shows the project's current git status and diff (uncommitted changes vs. HEAD), if it " +
262
+ 'is a git repository. Useful to see what has already been modified — by you earlier in ' +
263
+ 'this session, or by the user — before making further edits. Read-only: this tool can ' +
264
+ 'only inspect the working tree, it can NEVER stage (git add) or commit anything.',
265
+ input_schema: {
266
+ type: 'object',
267
+ properties: {
268
+ staged: { type: 'boolean', description: 'If true, show the staged diff instead of the unstaged working-tree diff.' },
269
+ },
270
+ },
271
+ },
255
272
  ];
256
273
 
257
274
  export interface AgentRunOptions {
@@ -274,11 +291,20 @@ export interface ValidationResult {
274
291
  output?: string;
275
292
  }
276
293
 
294
+ /** Unified diff for a single file changed by applyPatches(), meant to be shown to the human
295
+ * verbatim (never re-transcribed by the model) — see runCodeAgent()'s callers. */
296
+ export interface FileDiff {
297
+ path: string;
298
+ diff: string;
299
+ }
300
+
277
301
  export interface CodeAgentResult extends AgentRunResult {
278
302
  filesChanged: string[];
279
303
  blockedWrites: string[];
280
304
  failedEdits: string[];
305
+ fileDiffs: FileDiff[];
281
306
  validation: ValidationResult;
307
+ sandbox: SandboxResult;
282
308
  }
283
309
 
284
310
  interface FilePatchEdit {
@@ -318,12 +344,16 @@ export class AIContextManager {
318
344
  private shell: ShellManager;
319
345
  private ai: AIVendorManager;
320
346
  private logger: Logger;
347
+ private git: GitManager;
348
+ private sandbox: SandboxManager;
321
349
 
322
- constructor(fs: FileSystemManager, shell: ShellManager, ai: AIVendorManager, logger: Logger) {
350
+ constructor(fs: FileSystemManager, shell: ShellManager, ai: AIVendorManager, logger: Logger, git: GitManager, sandbox: SandboxManager) {
323
351
  this.fs = fs;
324
352
  this.shell = shell;
325
353
  this.ai = ai;
326
354
  this.logger = logger;
355
+ this.git = git;
356
+ this.sandbox = sandbox;
327
357
  }
328
358
 
329
359
  // === Guideline files (CLAUDE.md / AGENTS.md) =================================================
@@ -976,6 +1006,13 @@ export class AIContextManager {
976
1006
  return this.truncateForTool(content);
977
1007
  }
978
1008
 
1009
+ /** Read-only: status()/diff() never stage or commit anything (see GitManager). */
1010
+ private async toolGitDiff(ctx: AgentToolContext, input: any): Promise<string> {
1011
+ const status = await this.git.status(ctx.projectDir);
1012
+ const diffText = await this.git.diff(ctx.projectDir, { staged: !!input?.staged });
1013
+ return this.truncateForTool(`Status:\n${status}\n\nDiff:\n${diffText}`);
1014
+ }
1015
+
979
1016
  private async executeAgentTool(ctx: AgentToolContext, name: string, input: any): Promise<string> {
980
1017
  try {
981
1018
  switch (name) {
@@ -987,6 +1024,7 @@ export class AIContextManager {
987
1024
  case 'read_dependency_manifest': return await this.toolReadDependencyManifest(ctx, input);
988
1025
  case 'list_dependency_files': return await this.toolListDependencyFiles(ctx, input);
989
1026
  case 'read_dependency_file': return await this.toolReadDependencyFile(ctx, input);
1027
+ case 'git_diff': return await this.toolGitDiff(ctx, input);
990
1028
  default: return `Error: unknown tool "${name}".`;
991
1029
  }
992
1030
  } catch (err: any) {
@@ -1159,10 +1197,11 @@ export class AIContextManager {
1159
1197
  targetDir: string,
1160
1198
  blocks: FilePatchBlock[],
1161
1199
  seenFiles: Set<string>
1162
- ): Promise<{ filesChanged: string[]; blockedWrites: string[]; failedEdits: string[] }> {
1200
+ ): Promise<{ filesChanged: string[]; blockedWrites: string[]; failedEdits: string[]; fileDiffs: FileDiff[] }> {
1163
1201
  const filesChanged: string[] = [];
1164
1202
  const blockedWrites: string[] = [];
1165
1203
  const failedEdits: string[] = [];
1204
+ const fileDiffs: FileDiff[] = [];
1166
1205
 
1167
1206
  for (const block of blocks) {
1168
1207
  const resolvedPath = path.resolve(targetDir, block.relPath);
@@ -1188,7 +1227,8 @@ export class AIContextManager {
1188
1227
  continue;
1189
1228
  }
1190
1229
 
1191
- let content = existed ? (await this.fs.read(resolvedPath)) ?? '' : '';
1230
+ const beforeContent = existed ? ((await this.fs.read(resolvedPath)) ?? '') : '';
1231
+ let content = beforeContent;
1192
1232
  let ok = true;
1193
1233
 
1194
1234
  for (const edit of block.edits) {
@@ -1203,13 +1243,15 @@ export class AIContextManager {
1203
1243
  continue;
1204
1244
  }
1205
1245
 
1206
- await this.fs.write(resolvedPath, content.endsWith('\n') ? content : content + '\n');
1246
+ const finalContent = content.endsWith('\n') ? content : content + '\n';
1247
+ await this.fs.write(resolvedPath, finalContent);
1207
1248
  seenFiles.add(resolvedPath);
1208
1249
  this.logger.success(`${existed ? 'Modified' : 'Created'}: ${resolvedPath}`);
1209
1250
  filesChanged.push(block.relPath);
1251
+ fileDiffs.push({ path: block.relPath, diff: createPatch(block.relPath, beforeContent, finalContent, '', '', { context: 3 }) });
1210
1252
  }
1211
1253
 
1212
- return { filesChanged, blockedWrites, failedEdits };
1254
+ return { filesChanged, blockedWrites, failedEdits, fileDiffs };
1213
1255
  }
1214
1256
 
1215
1257
  // === Post-write validation (self-healing) ======================================================
@@ -1294,7 +1336,9 @@ export class AIContextManager {
1294
1336
  let filesChanged: string[] = [];
1295
1337
  let blockedWrites: string[] = [];
1296
1338
  let failedEdits: string[] = [];
1339
+ let fileDiffs: FileDiff[] = [];
1297
1340
  let validation: ValidationResult = { ran: false, ok: true };
1341
+ let sandboxResult: SandboxResult = { ran: false, ok: true };
1298
1342
 
1299
1343
  for (let attempt = 0; attempt <= MAX_SELF_HEAL_ATTEMPTS; attempt++) {
1300
1344
  const run = await this.runAgentLoop(messages, ctx, tokens, { ...options, priority });
@@ -1310,14 +1354,20 @@ export class AIContextManager {
1310
1354
  filesChanged = applied.filesChanged;
1311
1355
  blockedWrites = applied.blockedWrites;
1312
1356
  failedEdits = applied.failedEdits;
1357
+ fileDiffs = applied.fileDiffs;
1313
1358
  if (filesChanged.length === 0) break;
1314
1359
 
1315
1360
  validation = await this.runValidation(targetDir);
1316
- if (validation.ok || attempt === MAX_SELF_HEAL_ATTEMPTS) break;
1361
+ sandboxResult = validation.ok ? await this.sandbox.verify(targetDir) : { ran: false, ok: true };
1362
+
1363
+ const stepFailed = !validation.ok || !sandboxResult.ok;
1364
+ if (!stepFailed || attempt === MAX_SELF_HEAL_ATTEMPTS) break;
1317
1365
 
1318
1366
  const nextPriority = this.ai.bumpPriority(priority);
1367
+ const failureLabel = !validation.ok ? 'compilation/lint' : `the sandbox check (${sandboxResult.command})`;
1368
+ const failureOutput = !validation.ok ? validation.output : sandboxResult.output;
1319
1369
  this.logger.warn(
1320
- `Post-write validation failed (attempt ${attempt + 1}/${MAX_SELF_HEAL_ATTEMPTS + 1}); asking the model to fix it and bumping priority: ${priority} → ${nextPriority}`
1370
+ `Post-write ${failureLabel} failed (attempt ${attempt + 1}/${MAX_SELF_HEAL_ATTEMPTS + 1}); asking the model to fix it and bumping priority: ${priority} → ${nextPriority}`
1321
1371
  );
1322
1372
  priority = nextPriority;
1323
1373
 
@@ -1325,13 +1375,25 @@ export class AIContextManager {
1325
1375
  messages.push({
1326
1376
  role: 'user',
1327
1377
  content:
1328
- `SYSTEM: Your change broke compilation/lint with the following error:\n\n${validation.output ?? '(no output captured)'}\n\n` +
1378
+ `SYSTEM: Your change broke ${failureLabel} with the following error:\n\n${failureOutput ?? '(no output captured)'}\n\n` +
1329
1379
  'Fix it using more SEARCH/REPLACE blocks for the affected file(s). If you are not certain of a file\'s current ' +
1330
1380
  'exact content after your previous edit, re-read it with read_file before writing a new SEARCH block against it.',
1331
1381
  });
1332
1382
  }
1333
1383
 
1334
- return { content: lastContent, promptTokens, completionTokens, toolCallsUsed, priorityUsed: priority, filesChanged, blockedWrites, failedEdits, validation };
1384
+ return {
1385
+ content: lastContent,
1386
+ promptTokens,
1387
+ completionTokens,
1388
+ toolCallsUsed,
1389
+ priorityUsed: priority,
1390
+ filesChanged,
1391
+ blockedWrites,
1392
+ failedEdits,
1393
+ fileDiffs,
1394
+ validation,
1395
+ sandbox: sandboxResult,
1396
+ };
1335
1397
  }
1336
1398
 
1337
1399
  /**
@@ -261,8 +261,10 @@ function resolvePriorityRoute(priority: TaskPriority): { tier: ModelTier; thinki
261
261
  };
262
262
  }
263
263
 
264
- /** Ordered priority ladder, used by bumpPriority() to escalate one level at a time. */
265
- const PRIORITY_LADDER: TaskPriority[] = [
264
+ /** Ordered priority ladder, used by bumpPriority() to escalate one level at a time and by
265
+ * clampPriority() to enforce an optional ceiling. Exported so callers (e.g. a chat UI's effort
266
+ * selector) can list the valid levels without hardcoding them. */
267
+ export const TASK_PRIORITIES: TaskPriority[] = [
266
268
  'baja-prioridad',
267
269
  'baja-media-prioridad',
268
270
  'media-prioridad',
@@ -294,6 +296,7 @@ const PRIORITY_LADDER: TaskPriority[] = [
294
296
  */
295
297
  export class AIVendorManager {
296
298
  private logger: Logger;
299
+ private priorityCeilingOverride: TaskPriority | null = null;
297
300
 
298
301
  constructor(logger: Logger) {
299
302
  this.logger = logger;
@@ -360,9 +363,9 @@ export class AIVendorManager {
360
363
  .toLowerCase() as AIVendor;
361
364
 
362
365
  if (!PRIORITY_ROUTING[priority]) {
363
- throw new TyrError(`Unknown task priority: '${priority}'`, null, `Use one of: ${PRIORITY_LADDER.join(', ')}`);
366
+ throw new TyrError(`Unknown task priority: '${priority}'`, null, `Use one of: ${TASK_PRIORITIES.join(', ')}`);
364
367
  }
365
- const route = resolvePriorityRoute(priority);
368
+ const route = resolvePriorityRoute(this.clampPriority(priority));
366
369
 
367
370
  const tierModel = getEnvString(MODEL_TIER_ENV[vendor][route.tier], MODEL_TIER_DEFAULT[vendor]);
368
371
 
@@ -385,9 +388,44 @@ export class AIVendorManager {
385
388
  * priority = aiVendor.bumpPriority(priority); // one more level of thinking/model tier
386
389
  */
387
390
  public bumpPriority(priority: TaskPriority): TaskPriority {
388
- const index = PRIORITY_LADDER.indexOf(priority);
389
- if (index === -1 || index === PRIORITY_LADDER.length - 1) return priority;
390
- return PRIORITY_LADDER[index + 1];
391
+ const index = TASK_PRIORITIES.indexOf(priority);
392
+ if (index === -1 || index === TASK_PRIORITIES.length - 1) return priority;
393
+ return this.clampPriority(TASK_PRIORITIES[index + 1]);
394
+ }
395
+
396
+ /**
397
+ * @method setPriorityCeiling
398
+ * @description Sets (or clears, with `null`) an upper bound no resolved priority may ever
399
+ * exceed for this instance — including self-healing escalations via bumpPriority(). Meant for
400
+ * a developer-controlled cap (e.g. a chat UI's effort selector) so the model can never reach
401
+ * an expensive tier the developer didn't authorize for the task at hand, no matter how many
402
+ * retries a validation/sandbox failure triggers. An instance override always wins over the
403
+ * AI_MAX_PRIORITY env var (see getPriorityCeiling()).
404
+ * @param {TaskPriority | null} priority - The highest allowed priority, or null to remove the cap.
405
+ * @example
406
+ * aiVendor.setPriorityCeiling('media-prioridad'); // never route to alta/muy-alta, even on retry
407
+ */
408
+ public setPriorityCeiling(priority: TaskPriority | null): void {
409
+ this.priorityCeilingOverride = priority;
410
+ this.logger.info(priority ? `[AI] Priority ceiling set to '${priority}'.` : '[AI] Priority ceiling cleared.');
411
+ }
412
+
413
+ /** Instance override (set via setPriorityCeiling) wins over the AI_MAX_PRIORITY env var, which
414
+ * in turn is the default ceiling for non-interactive commands with no UI to set one live. */
415
+ private getPriorityCeiling(): TaskPriority | null {
416
+ if (this.priorityCeilingOverride) return this.priorityCeilingOverride;
417
+ const envCeiling = getEnvString('AI_MAX_PRIORITY') as TaskPriority | undefined;
418
+ return envCeiling && TASK_PRIORITIES.includes(envCeiling) ? envCeiling : null;
419
+ }
420
+
421
+ /** Clamps `priority` down to the current ceiling (if any and if it's actually lower). No-op
422
+ * when no ceiling is configured, so this is fully backward compatible by default. */
423
+ private clampPriority(priority: TaskPriority): TaskPriority {
424
+ const ceiling = this.getPriorityCeiling();
425
+ if (!ceiling) return priority;
426
+ if (TASK_PRIORITIES.indexOf(priority) <= TASK_PRIORITIES.indexOf(ceiling)) return priority;
427
+ this.logger.info(`[AI] Priority '${priority}' clamped down to ceiling '${ceiling}'.`);
428
+ return ceiling;
391
429
  }
392
430
 
393
431
  /**
@@ -28,6 +28,15 @@ export interface ChatMessage {
28
28
  createdAt: number;
29
29
  }
30
30
 
31
+ /** Opaque "effort ceiling" selector rendered in the chat header — ChatManager never interprets
32
+ * these strings (it doesn't know what a TaskPriority is, by design), it just displays `levels`
33
+ * and forwards whichever one the user picks via the 'priority:change' event. The caller (e.g.
34
+ * a command wiring AIVendorManager) decides what the values mean and what to do with a change. */
35
+ export interface ChatPriorityCeilingOptions {
36
+ levels: string[];
37
+ initial: string;
38
+ }
39
+
31
40
  export interface ChatOpenOptions {
32
41
  /** Preferred port. If busy, the next free port is used instead (default: 4646). */
33
42
  port?: number;
@@ -36,6 +45,8 @@ export interface ChatOpenOptions {
36
45
  /** Initial fraction of the width given to the chat pane, 0.2–0.8 (default: 0.4). The user
37
46
  * can still resize it live by dragging the divider between the two panes. */
38
47
  splitRatio?: number;
48
+ /** Renders an effort-ceiling dropdown in the chat header when provided. Omit to skip it entirely. */
49
+ priorityCeiling?: ChatPriorityCeilingOptions;
39
50
  }
40
51
 
41
52
  export interface ChatSession {
@@ -73,7 +84,8 @@ export type ChatEventName =
73
84
  | 'message:response'
74
85
  | 'message:error'
75
86
  | 'file:select'
76
- | 'context:change';
87
+ | 'context:change'
88
+ | 'priority:change';
77
89
 
78
90
  interface InternalSession {
79
91
  id: string;
@@ -90,6 +102,9 @@ interface InternalSession {
90
102
  * via addToContext() (e.g. a command detecting a file mention, or the AI itself asking for
91
103
  * one through a tool). Highlighted in the file browser (see /api/context). */
92
104
  contextFiles: Set<string>;
105
+ /** Current value of the effort-ceiling dropdown, if the caller opted into one via
106
+ * ChatOpenOptions.priorityCeiling. `current` is mutated by POST /api/priority-ceiling. */
107
+ priorityCeiling?: { levels: string[]; current: string };
93
108
  }
94
109
 
95
110
  const DEFAULT_PORT = 4646;
@@ -370,6 +385,9 @@ export class ChatManager {
370
385
  splitRatio,
371
386
  title,
372
387
  contextFiles: new Set(),
388
+ priorityCeiling: options.priorityCeiling
389
+ ? { levels: options.priorityCeiling.levels, current: options.priorityCeiling.initial }
390
+ : undefined,
373
391
  };
374
392
 
375
393
  const server = http.createServer((req, res) => {
@@ -437,7 +455,7 @@ export class ChatManager {
437
455
  const session = this.sessions.get(sessionId);
438
456
  if (!session) return;
439
457
 
440
- await this.emitSafe('chat:close', { sessionId });
458
+ await this.emitSafe('chat:close', { sessionId, dir: session.dir, history: session.history });
441
459
  await new Promise<void>((resolve) => session.server.close(() => resolve()));
442
460
 
443
461
  try {
@@ -520,6 +538,18 @@ export class ChatManager {
520
538
  return;
521
539
  }
522
540
 
541
+ if (pathname === '/api/priority-ceiling' && req.method === 'POST') {
542
+ const body = await this.readJsonBody(req);
543
+ const value = typeof body?.value === 'string' ? body.value : '';
544
+ if (!session.priorityCeiling || !session.priorityCeiling.levels.includes(value)) {
545
+ throw new TyrError('Invalid priority ceiling value', null, 'Send one of the values from BOOTSTRAP.priorityCeiling.levels.');
546
+ }
547
+ session.priorityCeiling.current = value;
548
+ await this.emitSafe('priority:change', { sessionId: session.id, dir: session.dir, value });
549
+ this.sendJson(res, 200, { ok: true, value });
550
+ return;
551
+ }
552
+
523
553
  if (pathname === '/api/tree' && req.method === 'GET') {
524
554
  const rel = url.searchParams.get('path') ?? '';
525
555
  const entries = await this.listChildren(session, rel);
@@ -727,6 +757,7 @@ export class ChatManager {
727
757
  dir: session.dir,
728
758
  splitRatio: session.splitRatio,
729
759
  title: session.title,
760
+ priorityCeiling: session.priorityCeiling ?? null,
730
761
  }).replace(/</g, '\\u003c');
731
762
 
732
763
  return `<!DOCTYPE html>
@@ -774,6 +805,7 @@ export class ChatManager {
774
805
  #send-btn { background: #4db8ff; color: #00121f; }
775
806
  #send-btn:disabled { opacity: 0.5; cursor: default; }
776
807
  #attach-btn { background: #333; color: #eee; }
808
+ #priority-select { display: none; background: #151515; color: #eee; border: 1px solid #333; border-radius: 6px; padding: 4px 8px; font-size: 12px; }
777
809
  #files-header span:last-child { color: #888; font-weight: 400; font-size: 12px; }
778
810
  #tree { flex: 1; overflow-y: auto; padding: 8px; }
779
811
  .tree-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; white-space: nowrap; }
@@ -791,7 +823,7 @@ export class ChatManager {
791
823
  <body>
792
824
  <div id="app">
793
825
  <div id="chat-pane">
794
- <header><span>💬 ${session.title}</span></header>
826
+ <header><span>💬 ${session.title}</span><select id="priority-select" title="Maximum effort/model tier this chat may ever use"></select></header>
795
827
  <div id="messages"></div>
796
828
  <div id="compose">
797
829
  <div id="pending-attachments"></div>
@@ -825,6 +857,7 @@ const BOOTSTRAP = ${bootstrap};
825
857
  const treeEl = document.getElementById('tree');
826
858
  const previewEl = document.getElementById('preview');
827
859
  const divider = document.getElementById('divider');
860
+ const prioritySelect = document.getElementById('priority-select');
828
861
 
829
862
  function applySplit(ratio) {
830
863
  app.style.setProperty('--chat-w', (ratio * 100) + '%');
@@ -844,6 +877,24 @@ const BOOTSTRAP = ${bootstrap};
844
877
  });
845
878
  window.addEventListener('mouseup', function () { dragging = false; document.body.style.cursor = ''; });
846
879
 
880
+ if (BOOTSTRAP.priorityCeiling) {
881
+ BOOTSTRAP.priorityCeiling.levels.forEach(function (level) {
882
+ const opt = document.createElement('option');
883
+ opt.value = level;
884
+ opt.textContent = level;
885
+ if (level === BOOTSTRAP.priorityCeiling.current) opt.selected = true;
886
+ prioritySelect.appendChild(opt);
887
+ });
888
+ prioritySelect.style.display = '';
889
+ prioritySelect.addEventListener('change', function () {
890
+ fetch('/api/priority-ceiling', {
891
+ method: 'POST',
892
+ headers: { 'Content-Type': 'application/json' },
893
+ body: JSON.stringify({ value: prioritySelect.value }),
894
+ });
895
+ });
896
+ }
897
+
847
898
  function escapeHtml(s) {
848
899
  return String(s).replace(/[&<>"']/g, function (c) {
849
900
  return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
@@ -1,3 +1,5 @@
1
+ import { execa } from 'execa';
2
+
1
3
  import { ShellManager } from './ShellManager.js';
2
4
  import { Logger } from '../core/Logger.js';
3
5
  import { TyrError } from '../core/TyrError.js';
@@ -123,6 +125,54 @@ export class GitManager {
123
125
  throw new TyrError(`Could not initialize git repository at ${dir}`, e);
124
126
  }
125
127
  }
128
+
129
+ /**
130
+ * @method status
131
+ * @description Read-only `git status --porcelain` for an arbitrary directory. Unlike the rest
132
+ * of this class, this targets `dir` directly via execa instead of the shared ShellManager's
133
+ * mutate-then-run cwd (the same reasoning AIContextManager.runValidation() already has to work
134
+ * around by saving/restoring the shell's cwd itself) — a read-only, dir-scoped check has no
135
+ * business touching shared state. Never throws: returns a friendly string for "not a repo" or
136
+ * "git unavailable" instead, since this is called speculatively by the AI agent's tool loop.
137
+ * @param {string} dir - Absolute path to check.
138
+ * @returns {Promise<string>} Porcelain status output, or a friendly explanation if unavailable.
139
+ * @example
140
+ * const status = await git.status('/path/to/project');
141
+ */
142
+ public async status(dir: string): Promise<string> {
143
+ try {
144
+ const result = await execa('git', ['status', '--porcelain'], { cwd: dir, reject: false });
145
+ if (result.exitCode !== 0) {
146
+ return `Not a git repository, or git status failed: ${String(result.stderr || result.stdout).trim()}`;
147
+ }
148
+ return result.stdout.trim() || '(clean — no changes)';
149
+ } catch (e: any) {
150
+ return `git is not available: ${e?.message ?? String(e)}`;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * @method diff
156
+ * @description Read-only `git diff` (working tree, or `--staged`) for an arbitrary directory.
157
+ * Same execa-direct, never-throws convention as status() — see its doc for why.
158
+ * @param {string} dir - Absolute path to diff.
159
+ * @param {{ staged?: boolean }} options - Pass `staged: true` for `git diff --staged`.
160
+ * @returns {Promise<string>} Unified diff text, or a friendly explanation if unavailable.
161
+ * @example
162
+ * const diff = await git.diff('/path/to/project');
163
+ */
164
+ public async diff(dir: string, options: { staged?: boolean } = {}): Promise<string> {
165
+ const args = ['diff', ...(options.staged ? ['--staged'] : [])];
166
+ try {
167
+ const result = await execa('git', args, { cwd: dir, reject: false });
168
+ if (result.exitCode !== 0) {
169
+ return `git diff failed: ${String(result.stderr || result.stdout).trim()}`;
170
+ }
171
+ return result.stdout.trim() || '(no differences)';
172
+ } catch (e: any) {
173
+ return `git is not available: ${e?.message ?? String(e)}`;
174
+ }
175
+ }
126
176
  }
127
177
 
128
178
  /**
@@ -0,0 +1,283 @@
1
+ import path from 'path';
2
+ import crypto from 'crypto';
3
+ import { homedir } from 'os';
4
+ import yaml from 'js-yaml';
5
+
6
+ import { FileSystemManager } from './FileSystemManager.js';
7
+ import { Logger } from '../core/Logger.js';
8
+ import { AIMessage } from './AIVendorManager.js';
9
+ import { getEnvInt } from '../core/util/getenv.js';
10
+
11
+ export type MemoryCommand = 'ai:chat' | 'ai:code' | 'ai:describe';
12
+
13
+ export interface MemoryHistoryTurn {
14
+ role: 'user' | 'assistant';
15
+ text: string;
16
+ }
17
+
18
+ export interface MemoryConversationEntry {
19
+ command: MemoryCommand;
20
+ history: MemoryHistoryTurn[];
21
+ filesChanged: string[];
22
+ }
23
+
24
+ export interface MemoryMatch {
25
+ filePath: string;
26
+ date: string;
27
+ command: MemoryCommand;
28
+ summary: string;
29
+ score: number;
30
+ }
31
+
32
+ interface MemoryFrontmatter {
33
+ project: string;
34
+ date: string;
35
+ command: MemoryCommand;
36
+ filesChanged: string[];
37
+ topics: string[];
38
+ }
39
+
40
+ const MIN_RELEVANCE_SCORE = getEnvInt('MEMORY_MIN_RELEVANCE_SCORE', 2);
41
+ const MAX_TOPICS = 12;
42
+ const MAX_TIMELINE_TURNS = 40;
43
+ const SUMMARY_EXCERPT_CHARS = 140;
44
+
45
+ // Small, deliberately conservative stopword set (EN + ES) — just enough to keep the most common
46
+ // filler words out of topic extraction, not a full NLP pipeline (see extractTopics()).
47
+ const STOPWORDS = new Set([
48
+ 'the', 'a', 'an', 'and', 'or', 'to', 'of', 'in', 'on', 'for', 'with', 'this', 'that', 'is',
49
+ 'are', 'was', 'were', 'be', 'been', 'it', 'as', 'at', 'by', 'from', 'not', 'but', 'you', 'your',
50
+ 'can', 'will', 'should', 'would', 'could', 'please', 'add', 'file', 'files',
51
+ 'de', 'la', 'el', 'los', 'las', 'un', 'una', 'unos', 'unas', 'y', 'o', 'que', 'en', 'con', 'para',
52
+ 'por', 'del', 'al', 'es', 'ser', 'está', 'esta', 'este', 'como', 'lo', 'se', 'su', 'sus', 'me',
53
+ 'te', 'le', 'les', 'quiero', 'quieres', 'puedes', 'archivo', 'archivos',
54
+ ]);
55
+
56
+ function sanitizeSlugPart(value: string): string {
57
+ return value.toLowerCase().replace(/[^a-z0-9-_]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
58
+ }
59
+
60
+ function truncateText(text: string, maxChars: number): string {
61
+ const trimmed = text.trim().replace(/\s+/g, ' ');
62
+ return trimmed.length <= maxChars ? trimmed : `${trimmed.slice(0, maxChars)}…`;
63
+ }
64
+
65
+ /** Word tokens (letters, 3+ chars) plus filename-like tokens (basename.ext) — the same style of
66
+ * cheap, deterministic tokenization used elsewhere in this codebase for mention detection. */
67
+ function tokenize(text: string): string[] {
68
+ const words = (text.match(/[A-Za-z][A-Za-z0-9_]{2,}/g) ?? []).map((t) => t.toLowerCase());
69
+ const fileTokens = (text.match(/[\w./-]+\.[A-Za-z0-9]+/g) ?? []).map((t) => t.toLowerCase());
70
+ return [...words.filter((t) => !STOPWORDS.has(t)), ...fileTokens];
71
+ }
72
+
73
+ function basenameNoExt(relPath: string): string {
74
+ return path.basename(relPath, path.extname(relPath)).toLowerCase();
75
+ }
76
+
77
+ /** 100% deterministic — no AI call. Basenames of changed files always count as topics (highest
78
+ * signal); everything else is ranked by raw frequency across the conversation's text. */
79
+ function extractTopics(history: MemoryHistoryTurn[], filesChanged: string[]): string[] {
80
+ const fileBasenames = filesChanged.map(basenameNoExt);
81
+ const combinedText = history.map((h) => h.text).join(' ');
82
+
83
+ const freq = new Map<string, number>();
84
+ for (const token of tokenize(combinedText)) {
85
+ freq.set(token, (freq.get(token) ?? 0) + 1);
86
+ }
87
+
88
+ const ranked = [...freq.entries()]
89
+ .filter(([token]) => !fileBasenames.includes(token))
90
+ .sort((a, b) => b[1] - a[1])
91
+ .slice(0, MAX_TOPICS)
92
+ .map(([token]) => token);
93
+
94
+ return [...new Set([...fileBasenames, ...ranked])];
95
+ }
96
+
97
+ function buildSummary(entry: MemoryConversationEntry): string {
98
+ const filesLine = entry.filesChanged.length > 0 ? `Files changed: ${entry.filesChanged.join(', ')}.` : 'No files changed.';
99
+ const firstUser = entry.history.find((h) => h.role === 'user');
100
+ const lastUser = [...entry.history].reverse().find((h) => h.role === 'user');
101
+
102
+ let messageLines = firstUser ? `First message: "${truncateText(firstUser.text, SUMMARY_EXCERPT_CHARS)}"` : '';
103
+ if (lastUser && lastUser !== firstUser) {
104
+ messageLines += ` Last message: "${truncateText(lastUser.text, SUMMARY_EXCERPT_CHARS)}"`;
105
+ }
106
+
107
+ return `${entry.history.length} message(s) exchanged. ${filesLine} ${messageLines}`.trim();
108
+ }
109
+
110
+ function buildTimeline(history: MemoryHistoryTurn[]): string {
111
+ const capped = history.slice(0, MAX_TIMELINE_TURNS);
112
+ const lines = capped.map((h) => `- ${h.role}: ${truncateText(h.text, 120)}`);
113
+ if (history.length > MAX_TIMELINE_TURNS) {
114
+ lines.push(`- (${history.length - MAX_TIMELINE_TURNS} earlier turn(s) omitted)`);
115
+ }
116
+ return lines.join('\n');
117
+ }
118
+
119
+ function parseFrontmatter(content: string): { data: MemoryFrontmatter; body: string } | null {
120
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
121
+ if (!match) return null;
122
+ try {
123
+ const data = yaml.load(match[1]) as MemoryFrontmatter;
124
+ if (!data || typeof data !== 'object') return null;
125
+ return { data, body: match[2] };
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ function extractSummarySection(body: string): string {
132
+ const match = body.match(/## Summary\n([\s\S]*?)(\n##|$)/);
133
+ return match ? match[1].trim() : '';
134
+ }
135
+
136
+ /**
137
+ * @class MemoryManager
138
+ * @description Global, cross-session memory of past ai:chat/ai:code/ai:describe conversations,
139
+ * stored as compacted Markdown files under ~/.tyr/.tyr-mem/<project-slug>/. Complements the
140
+ * per-project AGENTS.md/CLAUDE.md guidelines (see AIContextManager.getContext()) with "what we
141
+ * actually did here before" — grounded on real conversation history rather than hand-written docs.
142
+ *
143
+ * Both writing (recordConversation) and reading (findRelevant) are entirely deterministic — no AI
144
+ * call anywhere in this class. Compaction is built from data the caller already has (message text,
145
+ * files changed); retrieval is plain keyword-overlap scoring, scoped to the same project only, and
146
+ * returns nothing below a relevance threshold. This is a deliberate cost control: memory should
147
+ * only spend context-window tokens when it's actually likely to help, never as a blind dump.
148
+ */
149
+ export class MemoryManager {
150
+ private fs: FileSystemManager;
151
+ private logger: Logger;
152
+ /** Resolved once at construction (same convention as TokenManager's usageFile) — homedir() is
153
+ * read live at that point, so tests can point HOME elsewhere before constructing an instance. */
154
+ private memoryRoot: string;
155
+
156
+ constructor(fs: FileSystemManager, logger: Logger) {
157
+ this.fs = fs;
158
+ this.logger = logger;
159
+ this.memoryRoot = path.join(homedir(), '.tyr', '.tyr-mem');
160
+ }
161
+
162
+ private projectSlug(projectDir: string): string {
163
+ const resolved = path.resolve(projectDir);
164
+ const hash = crypto.createHash('sha1').update(resolved).digest('hex').slice(0, 8);
165
+ return `${sanitizeSlugPart(path.basename(resolved))}-${hash}`;
166
+ }
167
+
168
+ private projectMemoryDir(projectDir: string): string {
169
+ return path.join(this.memoryRoot, this.projectSlug(projectDir));
170
+ }
171
+
172
+ /**
173
+ * @method recordConversation
174
+ * @description Compacts and writes a conversation to disk under this project's memory folder.
175
+ * Compaction is entirely deterministic (turn count, files touched, truncated first/last
176
+ * message, capped timeline) — never an extra AI call.
177
+ * @param {string} projectDir - Absolute path to the project the conversation was about.
178
+ * @param {MemoryConversationEntry} entry - What happened: command, turns, files changed.
179
+ * @returns {Promise<string>} The path of the written memory file.
180
+ * @example
181
+ * await memory.recordConversation(targetDir, { command: 'ai:code', history, filesChanged });
182
+ */
183
+ public async recordConversation(projectDir: string, entry: MemoryConversationEntry): Promise<string> {
184
+ const isoStamp = new Date().toISOString().replace(/[:.]/g, '-');
185
+ const shortId = crypto.randomUUID().slice(0, 8);
186
+ const filePath = path.join(this.projectMemoryDir(projectDir), `${isoStamp}-${shortId}.md`);
187
+
188
+ const frontmatter: MemoryFrontmatter = {
189
+ project: path.resolve(projectDir),
190
+ date: new Date().toISOString(),
191
+ command: entry.command,
192
+ filesChanged: entry.filesChanged,
193
+ topics: extractTopics(entry.history, entry.filesChanged),
194
+ };
195
+
196
+ const content =
197
+ `---\n${yaml.dump(frontmatter, { lineWidth: -1 })}---\n\n` +
198
+ `## Summary\n${buildSummary(entry)}\n\n` +
199
+ `## Timeline\n${buildTimeline(entry.history)}\n`;
200
+
201
+ await this.fs.write(filePath, content);
202
+ return filePath;
203
+ }
204
+
205
+ /**
206
+ * @method findRelevant
207
+ * @description Scores every memory file for this project by deterministic keyword overlap
208
+ * against `taskText`, returning only matches at or above a minimum relevance threshold — never
209
+ * an unfiltered dump. No AI call. Scoped to this project only (no cross-project search in v1).
210
+ * @param {string} projectDir - Absolute path to the project.
211
+ * @param {string} taskText - The current message/task to find relevant past work for.
212
+ * @param {number} limit - Max matches to return (default 3).
213
+ * @returns {Promise<MemoryMatch[]>} Matches sorted by score desc, then date desc.
214
+ * @example
215
+ * const matches = await memory.findRelevant(targetDir, message.text);
216
+ */
217
+ public async findRelevant(projectDir: string, taskText: string, limit: number = 3): Promise<MemoryMatch[]> {
218
+ const memoryDir = this.projectMemoryDir(projectDir);
219
+ const fileNames = (await this.fs.readdir(memoryDir)).filter((name) => name.endsWith('.md'));
220
+ if (fileNames.length === 0) return [];
221
+
222
+ const queryTokens = new Set(tokenize(taskText));
223
+ if (queryTokens.size === 0) return [];
224
+
225
+ const matches: MemoryMatch[] = [];
226
+ for (const fileName of fileNames) {
227
+ const filePath = path.join(memoryDir, fileName);
228
+ const content = await this.fs.read(filePath);
229
+ if (!content) continue;
230
+
231
+ const parsed = parseFrontmatter(content);
232
+ if (!parsed) continue;
233
+
234
+ const fileBasenames = new Set((parsed.data.filesChanged ?? []).map(basenameNoExt));
235
+ let score = 0;
236
+ for (const topic of parsed.data.topics ?? []) {
237
+ if (queryTokens.has(topic)) score += fileBasenames.has(topic) ? 2 : 1;
238
+ }
239
+ if (score < MIN_RELEVANCE_SCORE) continue;
240
+
241
+ matches.push({
242
+ filePath,
243
+ date: parsed.data.date,
244
+ command: parsed.data.command,
245
+ summary: extractSummarySection(parsed.body),
246
+ score,
247
+ });
248
+ }
249
+
250
+ return matches
251
+ .sort((a, b) => b.score - a.score || (a.date < b.date ? 1 : -1))
252
+ .slice(0, limit);
253
+ }
254
+
255
+ /**
256
+ * @method getContextMessage
257
+ * @description Convenience wrapper mirroring AIContextManager.getContext()'s shape: a single
258
+ * 'system' message ready to splice into a prompt, or `null` when nothing relevant was found —
259
+ * so callers can skip it cheaply exactly like they already do for other optional context.
260
+ * @param {string} projectDir - Absolute path to the project.
261
+ * @param {string} taskText - The current message/task to find relevant past work for.
262
+ * @returns {Promise<AIMessage | null>}
263
+ * @example
264
+ * const memoryMessage = await memory.getContextMessage(targetDir, message.text);
265
+ * if (memoryMessage) messages.push(memoryMessage);
266
+ */
267
+ public async getContextMessage(projectDir: string, taskText: string): Promise<AIMessage | null> {
268
+ const matches = await this.findRelevant(projectDir, taskText);
269
+ if (matches.length === 0) return null;
270
+
271
+ const sections = matches.map((m) => `### ${m.command} — ${m.date}\n${m.summary}`);
272
+ return {
273
+ role: 'system',
274
+ content: `Relevant past work on this project (from memory):\n\n${sections.join('\n\n')}`,
275
+ };
276
+ }
277
+ }
278
+
279
+ /**
280
+ * @object MemoryManagerTests
281
+ * @description Test parameters to validate MemoryManager functionality.
282
+ */
283
+ export const MemoryManagerTests = {};
@@ -0,0 +1,152 @@
1
+ import fs from 'fs/promises';
2
+ import fsSync from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { execa } from 'execa';
6
+
7
+ import { Logger } from '../core/Logger.js';
8
+ import { getEnvInt } from '../core/util/getenv.js';
9
+
10
+ // Kept textually in sync with AIContextManager's own DEFAULT_IGNORED_DIRS — not imported from
11
+ // there on purpose, so SandboxManager stays independent of AIContextManager's internals.
12
+ const IGNORED_DIRS = new Set<string>([
13
+ 'node_modules', '.git', '.hg', '.svn',
14
+ '.turbo', '.next', '.nuxt', '.cache', '.parcel-cache',
15
+ '.vscode', '.idea',
16
+ 'dist', 'build', 'out', 'coverage', '.nyc_output',
17
+ 'target', 'vendor',
18
+ ]);
19
+
20
+ // npm's own placeholder for a project with no real tests yet — never worth "running".
21
+ const NPM_DEFAULT_TEST_SCRIPT = /^echo\s+"Error: no test specified"\s*&&\s*exit\s+1$/i;
22
+
23
+ const SANDBOX_TIMEOUT_MS = getEnvInt('SANDBOX_TIMEOUT_MS', 120000);
24
+ const MAX_OUTPUT_CHARS = 8000;
25
+
26
+ export interface SandboxResult {
27
+ ran: boolean;
28
+ ok: boolean;
29
+ /** The `npm run <script>` command actually executed, if any. */
30
+ command?: string;
31
+ /** Captured stdout/stderr, only populated on failure (truncated). */
32
+ output?: string;
33
+ }
34
+
35
+ function truncate(text: string, maxChars: number = MAX_OUTPUT_CHARS): string {
36
+ if (text.length <= maxChars) return text;
37
+ return `${text.slice(0, maxChars)}\n... (truncated, ${text.length - maxChars} more characters)`;
38
+ }
39
+
40
+ /**
41
+ * @class SandboxManager
42
+ * @description Runs a project's own test/build command in an isolated temp copy, as an extra
43
+ * verification step on top of AIContextManager's compile/lint validation — "does this actually
44
+ * work", not just "does it typecheck". Copies source files (skipping the same kind of noise
45
+ * directories the rest of the codebase already ignores) into a fresh temp directory, symlinks the
46
+ * original `node_modules` in rather than reinstalling (fast, and installing isn't this class's
47
+ * job), and executes with a hard timeout. Never touches the real project directory. Not a real OS
48
+ * sandbox (no container, no network/resource limits) — a deliberate scope choice, cheap to run
49
+ * for a personal tool; revisit if this ever needs to run untrusted code.
50
+ */
51
+ export class SandboxManager {
52
+ private logger: Logger;
53
+
54
+ constructor(logger: Logger) {
55
+ this.logger = logger;
56
+ }
57
+
58
+ /**
59
+ * @method verify
60
+ * @description Copies `projectDir` into a temp directory and runs its `test` script (or
61
+ * `build` if there's no real `test` script), reporting pass/fail. Returns `{ran: false, ok:
62
+ * true}` — the same "assume ok, nothing to check" convention AIContextManager's
63
+ * runValidation() uses — when the project has no package.json or no test/build script.
64
+ * @param {string} projectDir - Absolute path to the project to verify.
65
+ * @returns {Promise<SandboxResult>}
66
+ * @example
67
+ * const result = await sandbox.verify('/path/to/project');
68
+ * if (!result.ran || result.ok) { ... } // "assume ok" when nothing was checked
69
+ */
70
+ public async verify(projectDir: string): Promise<SandboxResult> {
71
+ const script = await this.detectScript(projectDir);
72
+ if (!script) return { ran: false, ok: true };
73
+
74
+ const tempDir = fsSync.mkdtempSync(path.join(os.tmpdir(), 'tyr-sandbox-'));
75
+ try {
76
+ await this.copySource(projectDir, tempDir);
77
+ this.linkNodeModules(projectDir, tempDir);
78
+
79
+ const command = `npm run ${script}`;
80
+ try {
81
+ const result = await execa('npm', ['run', script], {
82
+ cwd: tempDir,
83
+ timeout: SANDBOX_TIMEOUT_MS,
84
+ reject: false,
85
+ });
86
+ const ok = result.exitCode === 0;
87
+ return {
88
+ ran: true,
89
+ ok,
90
+ command,
91
+ output: ok ? undefined : truncate(String(result.stderr || result.stdout || 'Unknown sandbox failure')),
92
+ };
93
+ } catch (e: any) {
94
+ return { ran: true, ok: false, command, output: truncate(e?.message ?? String(e)) };
95
+ }
96
+ } finally {
97
+ fsSync.rmSync(tempDir, { recursive: true, force: true });
98
+ }
99
+ }
100
+
101
+ private async detectScript(projectDir: string): Promise<'test' | 'build' | null> {
102
+ const pkgPath = path.join(projectDir, 'package.json');
103
+ if (!fsSync.existsSync(pkgPath)) return null;
104
+
105
+ let scripts: Record<string, string> = {};
106
+ try {
107
+ const raw = await fs.readFile(pkgPath, 'utf-8');
108
+ scripts = JSON.parse(raw).scripts ?? {};
109
+ } catch {
110
+ return null;
111
+ }
112
+
113
+ if (scripts.test && !NPM_DEFAULT_TEST_SCRIPT.test(scripts.test.trim())) return 'test';
114
+ if (scripts.build) return 'build';
115
+ return null;
116
+ }
117
+
118
+ private async copySource(srcDir: string, destDir: string): Promise<void> {
119
+ const entries = await fs.readdir(srcDir, { withFileTypes: true });
120
+
121
+ for (const entry of entries) {
122
+ if (entry.name === 'node_modules' || IGNORED_DIRS.has(entry.name)) continue;
123
+ if (entry.isSymbolicLink()) continue;
124
+
125
+ const srcPath = path.join(srcDir, entry.name);
126
+ const destPath = path.join(destDir, entry.name);
127
+
128
+ if (entry.isDirectory()) {
129
+ await fs.mkdir(destPath, { recursive: true });
130
+ await this.copySource(srcPath, destPath);
131
+ } else if (entry.isFile()) {
132
+ await fs.copyFile(srcPath, destPath);
133
+ }
134
+ }
135
+ }
136
+
137
+ private linkNodeModules(projectDir: string, tempDir: string): void {
138
+ const original = path.join(projectDir, 'node_modules');
139
+ if (!fsSync.existsSync(original)) return;
140
+ try {
141
+ fsSync.symlinkSync(original, path.join(tempDir, 'node_modules'), 'dir');
142
+ } catch (e: any) {
143
+ this.logger.warn(`Sandbox: could not symlink node_modules (${e?.message ?? e}); the check may fail if dependencies are needed.`);
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * @object SandboxManagerTests
150
+ * @description Test parameters to validate SandboxManager functionality.
151
+ */
152
+ export const SandboxManagerTests = {};