@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 +3 -1
- package/src/core/Container.ts +10 -2
- package/src/core/sys/chat.ts +21 -4
- package/src/index.ts +1 -0
- package/src/lib/AIContextManager.ts +71 -9
- package/src/lib/AIVendorManager.ts +45 -7
- package/src/lib/ChatManager.ts +54 -3
- package/src/lib/GitManager.ts +50 -0
- package/src/lib/MemoryManager.ts +283 -0
- package/src/lib/SandboxManager.ts +152 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orxataguy/tyr",
|
|
3
|
-
"version": "1.0.
|
|
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",
|
package/src/core/Container.ts
CHANGED
|
@@ -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
|
|
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
|
|
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),
|
package/src/core/sys/chat.ts
CHANGED
|
@@ -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
|
|
24
|
-
//
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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: ${
|
|
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 =
|
|
389
|
-
if (index === -1 || index ===
|
|
390
|
-
return
|
|
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
|
/**
|
package/src/lib/ChatManager.ts
CHANGED
|
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
package/src/lib/GitManager.ts
CHANGED
|
@@ -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 = {};
|