@orxataguy/tyr 1.0.34 → 1.0.36
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/bin/tyr.js +0 -0
- package/package.json +1 -1
- package/src/lib/AIContextManager.ts +5 -1
- package/src/lib/AIVendorManager.ts +144 -19
- package/src/lib/ChatManager.ts +322 -7
package/bin/tyr.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -292,7 +292,11 @@ interface FilePatchBlock {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
const FILE_BLOCK_REGEX = />>>FILE:\s*(.+?)\s*\n([\s\S]*?)\n<<<END/g;
|
|
295
|
-
|
|
295
|
+
// The `\n?` before `=======` (rather than a mandatory `\n`) matters for brand-new files: the
|
|
296
|
+
// model is told to leave the SEARCH section EMPTY, and it naturally writes "SEARCH\n======="
|
|
297
|
+
// with no blank line in between rather than "SEARCH\n\n=======" — a mandatory `\n` there made
|
|
298
|
+
// every new-file creation silently produce zero matches (no blocks, "no changes generated").
|
|
299
|
+
const SEARCH_REPLACE_REGEX = /<<<<<<<\s*SEARCH\s*\n([\s\S]*?)\n?=======\s*\n([\s\S]*?)\n>>>>>>>\s*REPLACE/g;
|
|
296
300
|
|
|
297
301
|
/**
|
|
298
302
|
* @class AIContextManager
|
|
@@ -43,9 +43,10 @@ export interface AITool {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Nivel de "esfuerzo de razonamiento" (extended thinking) pedido al modelo, independiente del
|
|
46
|
-
* vendor concreto. Se traduce a `thinking.budget_tokens` en Anthropic
|
|
47
|
-
*
|
|
48
|
-
*
|
|
46
|
+
* vendor concreto. Se traduce a `thinking.budget_tokens` en Anthropic, a
|
|
47
|
+
* `generationConfig.thinkingConfig.thinkingBudget` en Gemini 2.5+ (mismo concepto de presupuesto
|
|
48
|
+
* de tokens que Anthropic) y a `reasoning_effort` en los modelos de OpenAI que lo soportan (serie
|
|
49
|
+
* 'o'), que solo acepta un vocabulario fijo (low/medium/high) en lugar de un número de tokens.
|
|
49
50
|
*/
|
|
50
51
|
export type ThinkingEffort = 'none' | 'low' | 'medium' | 'high' | 'max';
|
|
51
52
|
|
|
@@ -144,8 +145,10 @@ const MODEL_TIER_DEFAULT: Record<string,string> = {
|
|
|
144
145
|
gemini: 'gemini-2.5-flash-lite',
|
|
145
146
|
};
|
|
146
147
|
|
|
147
|
-
/** budget_tokens sent to Anthropic's `thinking` param per effort level. 'none' disables thinking.
|
|
148
|
-
|
|
148
|
+
/** budget_tokens sent to Anthropic's `thinking` param per effort level. 'none' disables thinking.
|
|
149
|
+
* Each is overridable via env var (see ANTHROPIC_THINKING_BUDGET_ENV); these are the fallback
|
|
150
|
+
* values used when the corresponding env var is not set. */
|
|
151
|
+
const ANTHROPIC_THINKING_BUDGETS: Record<ThinkingEffort, number> = {
|
|
149
152
|
none: 0,
|
|
150
153
|
low: 1024,
|
|
151
154
|
medium: 4096,
|
|
@@ -153,20 +156,111 @@ const THINKING_BUDGETS: Record<ThinkingEffort, number> = {
|
|
|
153
156
|
max: 16384,
|
|
154
157
|
};
|
|
155
158
|
|
|
159
|
+
const ANTHROPIC_THINKING_BUDGET_ENV: Record<ThinkingEffort, string> = {
|
|
160
|
+
none: 'ANTHROPIC_THINKING_BUDGET_NONE',
|
|
161
|
+
low: 'ANTHROPIC_THINKING_BUDGET_LOW',
|
|
162
|
+
medium: 'ANTHROPIC_THINKING_BUDGET_MEDIUM',
|
|
163
|
+
high: 'ANTHROPIC_THINKING_BUDGET_HIGH',
|
|
164
|
+
max: 'ANTHROPIC_THINKING_BUDGET_MAX',
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Gemini 2.5+'s `generationConfig.thinkingConfig.thinkingBudget` is the same token-budget concept
|
|
168
|
+
* as Anthropic's, so it reuses the same default magnitudes — overridable independently via its
|
|
169
|
+
* own env vars since Gemini's actual valid range differs by model (flash allows 0, pro doesn't). */
|
|
170
|
+
const GEMINI_THINKING_BUDGETS: Record<ThinkingEffort, number> = {
|
|
171
|
+
none: 0,
|
|
172
|
+
low: 1024,
|
|
173
|
+
medium: 4096,
|
|
174
|
+
high: 8192,
|
|
175
|
+
max: 16384,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const GEMINI_THINKING_BUDGET_ENV: Record<ThinkingEffort, string> = {
|
|
179
|
+
none: 'GEMINI_THINKING_BUDGET_NONE',
|
|
180
|
+
low: 'GEMINI_THINKING_BUDGET_LOW',
|
|
181
|
+
medium: 'GEMINI_THINKING_BUDGET_MEDIUM',
|
|
182
|
+
high: 'GEMINI_THINKING_BUDGET_HIGH',
|
|
183
|
+
max: 'GEMINI_THINKING_BUDGET_MAX',
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
function resolveThinkingBudget(
|
|
187
|
+
effort: ThinkingEffort,
|
|
188
|
+
defaults: Record<ThinkingEffort, number>,
|
|
189
|
+
env: Record<ThinkingEffort, string>
|
|
190
|
+
): number {
|
|
191
|
+
return getEnvInt(env[effort], defaults[effort]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** OpenAI's reasoning models (serie 'o', gpt-5*) solo aceptan un vocabulario fijo para
|
|
195
|
+
* `reasoning_effort` — no un número de tokens — así que aquí se mapea ThinkingEffort a esas
|
|
196
|
+
* cadenas en lugar de a un budget. 'none' está ausente a propósito: esos modelos no permiten
|
|
197
|
+
* desactivar el razonamiento, así que ThinkingEffort 'none' simplemente omite el parámetro
|
|
198
|
+
* (ver openaiReasoningFields). Cada nivel es overridable vía env var por si OpenAI cambia el
|
|
199
|
+
* vocabulario aceptado (p.ej. añade 'minimal') sin tener que tocar código. */
|
|
200
|
+
const OPENAI_REASONING_EFFORTS: Partial<Record<ThinkingEffort, string>> = {
|
|
201
|
+
low: 'low',
|
|
202
|
+
medium: 'medium',
|
|
203
|
+
high: 'high',
|
|
204
|
+
max: 'high',
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const OPENAI_REASONING_EFFORT_ENV: Partial<Record<ThinkingEffort, string>> = {
|
|
208
|
+
low: 'OPENAI_REASONING_EFFORT_LOW',
|
|
209
|
+
medium: 'OPENAI_REASONING_EFFORT_MEDIUM',
|
|
210
|
+
high: 'OPENAI_REASONING_EFFORT_HIGH',
|
|
211
|
+
max: 'OPENAI_REASONING_EFFORT_MAX',
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
function resolveOpenAIReasoningEffort(effort: ThinkingEffort): string | undefined {
|
|
215
|
+
const envVar = OPENAI_REASONING_EFFORT_ENV[effort];
|
|
216
|
+
const override = envVar ? getEnvString(envVar) : undefined;
|
|
217
|
+
return override ?? OPENAI_REASONING_EFFORTS[effort];
|
|
218
|
+
}
|
|
219
|
+
|
|
156
220
|
/**
|
|
157
221
|
* Task-priority → (model tier, thinking effort) matrix. This is the routing table requested by
|
|
158
222
|
* the architecture spec: callers pick a priority by *intent* ("this is a trivial file", "this
|
|
159
223
|
* previous attempt failed"), not by model name, and AIVendorManager decides what that costs.
|
|
224
|
+
* Each entry is overridable via env var (see PRIORITY_ROUTING_ENV); these are the fallback values
|
|
225
|
+
* used when the corresponding env var is unset or holds an invalid tier/thinking value.
|
|
160
226
|
*/
|
|
161
227
|
const PRIORITY_ROUTING: Record<TaskPriority, { tier: ModelTier; thinking: ThinkingEffort }> = {
|
|
162
228
|
'baja-prioridad': { tier: 'cheap', thinking: 'none' },
|
|
163
|
-
'baja-media-prioridad': { tier: 'cheap', thinking: '
|
|
229
|
+
'baja-media-prioridad': { tier: 'cheap', thinking: 'high' },
|
|
164
230
|
'media-prioridad': { tier: 'balanced', thinking: 'none' },
|
|
165
231
|
'media-alta-prioridad': { tier: 'balanced', thinking: 'high' },
|
|
166
232
|
'alta-prioridad': { tier: 'balanced', thinking: 'max' },
|
|
167
233
|
'muy-alta-prioridad': { tier: 'flagship', thinking: 'max' },
|
|
168
234
|
};
|
|
169
235
|
|
|
236
|
+
const PRIORITY_ROUTING_ENV: Record<TaskPriority, { tier: string; thinking: string }> = {
|
|
237
|
+
'baja-prioridad': { tier: 'AI_PRIORITY_BAJA_TIER', thinking: 'AI_PRIORITY_BAJA_THINKING' },
|
|
238
|
+
'baja-media-prioridad': { tier: 'AI_PRIORITY_BAJA_MEDIA_TIER', thinking: 'AI_PRIORITY_BAJA_MEDIA_THINKING' },
|
|
239
|
+
'media-prioridad': { tier: 'AI_PRIORITY_MEDIA_TIER', thinking: 'AI_PRIORITY_MEDIA_THINKING' },
|
|
240
|
+
'media-alta-prioridad': { tier: 'AI_PRIORITY_MEDIA_ALTA_TIER', thinking: 'AI_PRIORITY_MEDIA_ALTA_THINKING' },
|
|
241
|
+
'alta-prioridad': { tier: 'AI_PRIORITY_ALTA_TIER', thinking: 'AI_PRIORITY_ALTA_THINKING' },
|
|
242
|
+
'muy-alta-prioridad': { tier: 'AI_PRIORITY_MUY_ALTA_TIER', thinking: 'AI_PRIORITY_MUY_ALTA_THINKING' },
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const VALID_MODEL_TIERS: ModelTier[] = ['cheap', 'balanced', 'flagship'];
|
|
246
|
+
const VALID_THINKING_EFFORTS: ThinkingEffort[] = ['none', 'low', 'medium', 'high', 'max'];
|
|
247
|
+
|
|
248
|
+
/** Resolves the (tier, thinking) route for a priority, letting env vars override either half of
|
|
249
|
+
* PRIORITY_ROUTING independently. An env var holding anything other than a valid tier/thinking
|
|
250
|
+
* value is ignored in favor of the hardcoded default, rather than passed through to the vendor. */
|
|
251
|
+
function resolvePriorityRoute(priority: TaskPriority): { tier: ModelTier; thinking: ThinkingEffort } {
|
|
252
|
+
const defaults = PRIORITY_ROUTING[priority];
|
|
253
|
+
const envVars = PRIORITY_ROUTING_ENV[priority];
|
|
254
|
+
|
|
255
|
+
const tierOverride = getEnvString(envVars.tier)?.toLowerCase() as ModelTier | undefined;
|
|
256
|
+
const thinkingOverride = getEnvString(envVars.thinking)?.toLowerCase() as ThinkingEffort | undefined;
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
tier: tierOverride && VALID_MODEL_TIERS.includes(tierOverride) ? tierOverride : defaults.tier,
|
|
260
|
+
thinking: thinkingOverride && VALID_THINKING_EFFORTS.includes(thinkingOverride) ? thinkingOverride : defaults.thinking,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
170
264
|
/** Ordered priority ladder, used by bumpPriority() to escalate one level at a time. */
|
|
171
265
|
const PRIORITY_LADDER: TaskPriority[] = [
|
|
172
266
|
'baja-prioridad',
|
|
@@ -257,10 +351,10 @@ export class AIVendorManager {
|
|
|
257
351
|
.toString()
|
|
258
352
|
.toLowerCase() as AIVendor;
|
|
259
353
|
|
|
260
|
-
|
|
261
|
-
if (!route) {
|
|
354
|
+
if (!PRIORITY_ROUTING[priority]) {
|
|
262
355
|
throw new TyrError(`Unknown task priority: '${priority}'`, null, `Use one of: ${PRIORITY_LADDER.join(', ')}`);
|
|
263
356
|
}
|
|
357
|
+
const route = resolvePriorityRoute(priority);
|
|
264
358
|
|
|
265
359
|
const tierModel = getEnvString(MODEL_TIER_ENV[vendor][route.tier], MODEL_TIER_DEFAULT[vendor]);
|
|
266
360
|
|
|
@@ -472,7 +566,7 @@ export class AIVendorManager {
|
|
|
472
566
|
/** Anthropic's extended thinking requires temperature === 1 and max_tokens strictly greater
|
|
473
567
|
* than budget_tokens; both are enforced here so callers never have to remember it. */
|
|
474
568
|
private anthropicThinkingFields(config: VendorConfig): { thinking?: any; temperature: number; max_tokens: number } {
|
|
475
|
-
const budget =
|
|
569
|
+
const budget = resolveThinkingBudget(config.thinking, ANTHROPIC_THINKING_BUDGETS, ANTHROPIC_THINKING_BUDGET_ENV);
|
|
476
570
|
if (!budget) return { temperature: config.temperature, max_tokens: config.maxTokens };
|
|
477
571
|
|
|
478
572
|
return {
|
|
@@ -482,15 +576,39 @@ export class AIVendorManager {
|
|
|
482
576
|
};
|
|
483
577
|
}
|
|
484
578
|
|
|
579
|
+
/** True for OpenAI's reasoning-capable model families ('o' models: o1, o3... and the gpt-5
|
|
580
|
+
* line), which share two API quirks handled below: they reject the legacy `max_tokens` body
|
|
581
|
+
* field (Use `max_completion_tokens` instead — see buildRequest) and, when reasoning is on,
|
|
582
|
+
* reject a custom `temperature`. */
|
|
583
|
+
private isOpenAIReasoningModel(model: string): boolean {
|
|
584
|
+
return /^(o\d|gpt-5)/i.test(model);
|
|
585
|
+
}
|
|
586
|
+
|
|
485
587
|
/** OpenAI's `reasoning_effort` only exists on the 'o' reasoning model family and only accepts
|
|
486
|
-
*
|
|
487
|
-
* models also reject a custom `temperature`, so it's
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
588
|
+
* a fixed vocabulary (no 'none') — mapped from our vendor-agnostic ThinkingEffort via
|
|
589
|
+
* OPENAI_REASONING_EFFORTS/_ENV. Those models also reject a custom `temperature`, so it's
|
|
590
|
+
* omitted whenever reasoning_effort is set.
|
|
591
|
+
*
|
|
592
|
+
* `reasoning_effort` combined with function `tools` on /v1/chat/completions is itself
|
|
593
|
+
* rejected with a 400 ("...are not supported... Please use /v1/responses instead") on at
|
|
594
|
+
* least the gpt-5 nano tier — so it's omitted whenever tools are present, trading explicit
|
|
595
|
+
* effort control for the request actually working; the model still reasons, just without an
|
|
596
|
+
* explicit tier hint. */
|
|
597
|
+
private openaiReasoningFields(config: VendorConfig, hasTools: boolean): { reasoning_effort?: string; temperature?: number } {
|
|
598
|
+
if (!this.isOpenAIReasoningModel(config.model) || config.thinking === 'none' || hasTools) {
|
|
599
|
+
return { temperature: config.temperature };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return { reasoning_effort: resolveOpenAIReasoningEffort(config.thinking) };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Gemini 2.5+'s `thinkingConfig.thinkingBudget` is omitted entirely for 'none' rather than
|
|
606
|
+
* sent as 0, because 0 is invalid on Pro models (which can't fully disable thinking) — only
|
|
607
|
+
* Flash accepts it. Omitting the field is the safe "off" behavior across both. */
|
|
608
|
+
private geminiThinkingFields(config: VendorConfig): { thinkingConfig?: { thinkingBudget: number } } {
|
|
609
|
+
if (config.thinking === 'none') return {};
|
|
610
|
+
const budget = resolveThinkingBudget(config.thinking, GEMINI_THINKING_BUDGETS, GEMINI_THINKING_BUDGET_ENV);
|
|
611
|
+
return { thinkingConfig: { thinkingBudget: budget } };
|
|
494
612
|
}
|
|
495
613
|
|
|
496
614
|
private buildRequest(config: VendorConfig, messages: AIMessage[], stream: boolean, tools?: AITool[]): VendorRequest {
|
|
@@ -519,7 +637,12 @@ export class AIVendorManager {
|
|
|
519
637
|
}
|
|
520
638
|
|
|
521
639
|
case 'openai': {
|
|
522
|
-
const reasoningFields = this.openaiReasoningFields(config);
|
|
640
|
+
const reasoningFields = this.openaiReasoningFields(config, !!vendorTools);
|
|
641
|
+
// Reasoning-family models (o1/o3/gpt-5...) reject the legacy `max_tokens` field
|
|
642
|
+
// with a 400 and require `max_completion_tokens` instead.
|
|
643
|
+
const tokenField = this.isOpenAIReasoningModel(config.model)
|
|
644
|
+
? { max_completion_tokens: config.maxTokens }
|
|
645
|
+
: { max_tokens: config.maxTokens };
|
|
523
646
|
return {
|
|
524
647
|
url: 'https://api.openai.com/v1/chat/completions',
|
|
525
648
|
headers: {
|
|
@@ -532,7 +655,7 @@ export class AIVendorManager {
|
|
|
532
655
|
...(system ? [{ role: 'system', content: system }] : []),
|
|
533
656
|
...this.buildOpenAIMessages(turns),
|
|
534
657
|
],
|
|
535
|
-
|
|
658
|
+
...tokenField,
|
|
536
659
|
stream,
|
|
537
660
|
...reasoningFields,
|
|
538
661
|
...(stream ? { stream_options: { include_usage: true } } : {}),
|
|
@@ -543,6 +666,7 @@ export class AIVendorManager {
|
|
|
543
666
|
|
|
544
667
|
case 'gemini': {
|
|
545
668
|
const action = stream ? 'streamGenerateContent?alt=sse&' : 'generateContent?';
|
|
669
|
+
const thinkingFields = this.geminiThinkingFields(config);
|
|
546
670
|
return {
|
|
547
671
|
url: `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:${action}key=${config.apiKey}`,
|
|
548
672
|
headers: { 'content-type': 'application/json' },
|
|
@@ -552,6 +676,7 @@ export class AIVendorManager {
|
|
|
552
676
|
generationConfig: {
|
|
553
677
|
temperature: config.temperature,
|
|
554
678
|
maxOutputTokens: config.maxTokens,
|
|
679
|
+
...thinkingFields,
|
|
555
680
|
},
|
|
556
681
|
...(vendorTools ? { tools: vendorTools } : {}),
|
|
557
682
|
},
|
package/src/lib/ChatManager.ts
CHANGED
|
@@ -53,6 +53,13 @@ export interface ChatMessageContext {
|
|
|
53
53
|
/** Full conversation so far, including the message that triggered this call. */
|
|
54
54
|
history: ChatMessage[];
|
|
55
55
|
dir: string;
|
|
56
|
+
/** Id of the session this message belongs to — pass it to addToContext()/removeFromContext()
|
|
57
|
+
* /getContext() to read or grow the "context of interest" file set while producing a reply. */
|
|
58
|
+
sessionId: string;
|
|
59
|
+
/** Snapshot (sorted, relative to `dir`) of the context-of-interest file set at the moment the
|
|
60
|
+
* handler was invoked. Mutations made via addToContext() during the handler are NOT reflected
|
|
61
|
+
* here — call getContext(sessionId) again for the live set. */
|
|
62
|
+
context: string[];
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
/** Function that produces the assistant's reply text for a user message. Registered via
|
|
@@ -65,7 +72,8 @@ export type ChatEventName =
|
|
|
65
72
|
| 'message:send'
|
|
66
73
|
| 'message:response'
|
|
67
74
|
| 'message:error'
|
|
68
|
-
| 'file:select'
|
|
75
|
+
| 'file:select'
|
|
76
|
+
| 'context:change';
|
|
69
77
|
|
|
70
78
|
interface InternalSession {
|
|
71
79
|
id: string;
|
|
@@ -77,6 +85,11 @@ interface InternalSession {
|
|
|
77
85
|
attachments: Map<string, ChatAttachment>;
|
|
78
86
|
splitRatio: number;
|
|
79
87
|
title: string;
|
|
88
|
+
/** "Context of interest" — relative (forward-slash) paths of files the conversation is
|
|
89
|
+
* currently grounded on: added by a double-click in the file browser, or programmatically
|
|
90
|
+
* via addToContext() (e.g. a command detecting a file mention, or the AI itself asking for
|
|
91
|
+
* one through a tool). Highlighted in the file browser (see /api/context). */
|
|
92
|
+
contextFiles: Set<string>;
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
const DEFAULT_PORT = 4646;
|
|
@@ -118,6 +131,14 @@ function mimeFromExt(ext: string): string {
|
|
|
118
131
|
*
|
|
119
132
|
* Attached images are written to a per-session temp directory (via os.tmpdir()) that is removed
|
|
120
133
|
* when the session is stopped or the process exits (SIGINT/SIGTERM/exit are all hooked).
|
|
134
|
+
*
|
|
135
|
+
* Each session also tracks a "context of interest" file set (addToContext() / removeFromContext()
|
|
136
|
+
* / toggleContext() / getContext()) — files the file browser highlights and that ChatMessageContext
|
|
137
|
+
* exposes to the onMessage handler, so a caller can ground replies on specific files without the
|
|
138
|
+
* model having to re-discover them every turn. The manager only tracks *which* paths are of
|
|
139
|
+
* interest and validates them (must exist, must be a file, must stay inside `dir`); reading their
|
|
140
|
+
* content and deciding what counts as "mentioned" is left to the onMessage handler, same division
|
|
141
|
+
* of responsibility as AI content generation itself.
|
|
121
142
|
*/
|
|
122
143
|
export class ChatManager {
|
|
123
144
|
private fsManager: FileSystemManager;
|
|
@@ -178,6 +199,110 @@ export class ChatManager {
|
|
|
178
199
|
this.emitter.off(event, listener);
|
|
179
200
|
}
|
|
180
201
|
|
|
202
|
+
private normalizeRelPath(relPath: string): string {
|
|
203
|
+
return (relPath || '')
|
|
204
|
+
.replace(/\\/g, '/')
|
|
205
|
+
.replace(/^(\.\/)+/, '')
|
|
206
|
+
.replace(/^\/+/, '')
|
|
207
|
+
.replace(/\/+$/, '');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @method getContext
|
|
212
|
+
* @description Returns the session's current "context of interest" — relative paths, sorted.
|
|
213
|
+
* @param {string} sessionId - The id of the session (see ChatMessageContext.sessionId).
|
|
214
|
+
* @returns {string[]} Sorted relative paths.
|
|
215
|
+
* @example
|
|
216
|
+
* const files = chat.getContext(sessionId);
|
|
217
|
+
*/
|
|
218
|
+
public getContext(sessionId: string): string[] {
|
|
219
|
+
const session = this.sessions.get(sessionId);
|
|
220
|
+
if (!session) return [];
|
|
221
|
+
return [...session.contextFiles].sort();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @method addToContext
|
|
226
|
+
* @description Adds one or more files to the session's context of interest. Each path is
|
|
227
|
+
* validated (must resolve inside the session's directory, must exist, must be a file) and
|
|
228
|
+
* silently skipped if it fails — invalid input never throws, since callers (mention detection,
|
|
229
|
+
* an AI tool call) work off untrusted text. Fires 'context:change' once if anything was added.
|
|
230
|
+
* @param {string} sessionId - The id of the session.
|
|
231
|
+
* @param {string[]} relPaths - Paths relative to the session's directory.
|
|
232
|
+
* @returns {Promise<string[]>} The subset of paths that were actually added (newly, validly).
|
|
233
|
+
* @example
|
|
234
|
+
* const added = await chat.addToContext(sessionId, ['src/index.ts']);
|
|
235
|
+
*/
|
|
236
|
+
public async addToContext(sessionId: string, relPaths: string[]): Promise<string[]> {
|
|
237
|
+
const session = this.sessions.get(sessionId);
|
|
238
|
+
if (!session) return [];
|
|
239
|
+
|
|
240
|
+
const added: string[] = [];
|
|
241
|
+
for (const raw of relPaths) {
|
|
242
|
+
const normalized = this.normalizeRelPath(raw);
|
|
243
|
+
if (!normalized || session.contextFiles.has(normalized)) continue;
|
|
244
|
+
|
|
245
|
+
let abs: string;
|
|
246
|
+
try {
|
|
247
|
+
abs = this.resolveSafe(session, normalized);
|
|
248
|
+
} catch {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (!fsSync.existsSync(abs) || !fsSync.statSync(abs).isFile()) continue;
|
|
252
|
+
|
|
253
|
+
session.contextFiles.add(normalized);
|
|
254
|
+
added.push(normalized);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (added.length > 0) {
|
|
258
|
+
await this.emitSafe('context:change', { sessionId, dir: session.dir, files: this.getContext(sessionId) });
|
|
259
|
+
}
|
|
260
|
+
return added;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* @method removeFromContext
|
|
265
|
+
* @description Removes a single file from the session's context of interest, if present.
|
|
266
|
+
* Fires 'context:change' when it actually removes something.
|
|
267
|
+
* @param {string} sessionId - The id of the session.
|
|
268
|
+
* @param {string} relPath - Path relative to the session's directory.
|
|
269
|
+
* @example
|
|
270
|
+
* await chat.removeFromContext(sessionId, 'src/index.ts');
|
|
271
|
+
*/
|
|
272
|
+
public async removeFromContext(sessionId: string, relPath: string): Promise<void> {
|
|
273
|
+
const session = this.sessions.get(sessionId);
|
|
274
|
+
if (!session) return;
|
|
275
|
+
|
|
276
|
+
const normalized = this.normalizeRelPath(relPath);
|
|
277
|
+
if (session.contextFiles.delete(normalized)) {
|
|
278
|
+
await this.emitSafe('context:change', { sessionId, dir: session.dir, files: this.getContext(sessionId) });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* @method toggleContext
|
|
284
|
+
* @description Adds `relPath` to the context of interest if absent, removes it if present —
|
|
285
|
+
* the file browser's double-click behaviour. Same validation as addToContext() on the add path.
|
|
286
|
+
* @param {string} sessionId - The id of the session.
|
|
287
|
+
* @param {string} relPath - Path relative to the session's directory.
|
|
288
|
+
* @returns {Promise<{ added: boolean; files: string[] }>} Whether the path ended up added, and the resulting set.
|
|
289
|
+
* @example
|
|
290
|
+
* const { added } = await chat.toggleContext(sessionId, 'src/index.ts');
|
|
291
|
+
*/
|
|
292
|
+
public async toggleContext(sessionId: string, relPath: string): Promise<{ added: boolean; files: string[] }> {
|
|
293
|
+
const session = this.sessions.get(sessionId);
|
|
294
|
+
if (!session) return { added: false, files: [] };
|
|
295
|
+
|
|
296
|
+
const normalized = this.normalizeRelPath(relPath);
|
|
297
|
+
if (session.contextFiles.has(normalized)) {
|
|
298
|
+
await this.removeFromContext(sessionId, normalized);
|
|
299
|
+
return { added: false, files: this.getContext(sessionId) };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const added = await this.addToContext(sessionId, [normalized]);
|
|
303
|
+
return { added: added.length > 0, files: this.getContext(sessionId) };
|
|
304
|
+
}
|
|
305
|
+
|
|
181
306
|
private async emitSafe(event: ChatEventName, payload: any): Promise<void> {
|
|
182
307
|
for (const listener of this.emitter.listeners(event)) {
|
|
183
308
|
try {
|
|
@@ -244,6 +369,7 @@ export class ChatManager {
|
|
|
244
369
|
attachments: new Map(),
|
|
245
370
|
splitRatio,
|
|
246
371
|
title,
|
|
372
|
+
contextFiles: new Set(),
|
|
247
373
|
};
|
|
248
374
|
|
|
249
375
|
const server = http.createServer((req, res) => {
|
|
@@ -380,6 +506,20 @@ export class ChatManager {
|
|
|
380
506
|
return;
|
|
381
507
|
}
|
|
382
508
|
|
|
509
|
+
if (pathname === '/api/context' && req.method === 'GET') {
|
|
510
|
+
this.sendJson(res, 200, { files: this.getContext(session.id) });
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (pathname === '/api/context/toggle' && req.method === 'POST') {
|
|
515
|
+
const body = await this.readJsonBody(req);
|
|
516
|
+
const relPath = typeof body?.path === 'string' ? body.path : '';
|
|
517
|
+
if (!relPath) throw new TyrError('Missing path', null, 'Send { path } to /api/context/toggle.');
|
|
518
|
+
const result = await this.toggleContext(session.id, relPath);
|
|
519
|
+
this.sendJson(res, 200, result);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
383
523
|
if (pathname === '/api/tree' && req.method === 'GET') {
|
|
384
524
|
const rel = url.searchParams.get('path') ?? '';
|
|
385
525
|
const entries = await this.listChildren(session, rel);
|
|
@@ -417,7 +557,7 @@ export class ChatManager {
|
|
|
417
557
|
if (pathname === '/api/message' && req.method === 'POST') {
|
|
418
558
|
const body = await this.readJsonBody(req);
|
|
419
559
|
const message = await this.handleMessage(session, body);
|
|
420
|
-
this.sendJson(res, 200, { message });
|
|
560
|
+
this.sendJson(res, 200, { message, context: this.getContext(session.id) });
|
|
421
561
|
return;
|
|
422
562
|
}
|
|
423
563
|
|
|
@@ -555,7 +695,13 @@ export class ChatManager {
|
|
|
555
695
|
|
|
556
696
|
try {
|
|
557
697
|
const handler = this.messageHandler ?? this.defaultHandler;
|
|
558
|
-
const replyText = await handler({
|
|
698
|
+
const replyText = await handler({
|
|
699
|
+
message: userMessage,
|
|
700
|
+
history: session.history,
|
|
701
|
+
dir: session.dir,
|
|
702
|
+
sessionId: session.id,
|
|
703
|
+
context: this.getContext(session.id),
|
|
704
|
+
});
|
|
559
705
|
|
|
560
706
|
const assistantMessage: ChatMessage = {
|
|
561
707
|
id: crypto.randomUUID(),
|
|
@@ -599,10 +745,22 @@ export class ChatManager {
|
|
|
599
745
|
#divider:hover { background: #4db8ff; }
|
|
600
746
|
header { padding: 12px 16px; border-bottom: 1px solid #333; font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
|
|
601
747
|
#messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
602
|
-
.msg { max-width: 85%; padding: 10px 14px; border-radius: 10px; line-height: 1.45;
|
|
748
|
+
.msg { max-width: 85%; padding: 10px 14px; border-radius: 10px; line-height: 1.45; word-break: break-word; font-size: 14px; }
|
|
603
749
|
.msg.user { align-self: flex-end; background: #2563eb22; border: 1px solid #2563eb55; }
|
|
604
750
|
.msg.assistant { align-self: flex-start; background: #2d2d2d; border: 1px solid #3a3a3a; }
|
|
605
751
|
.msg.error { align-self: flex-start; background: #4d1f1f; border: 1px solid #7a2b2b; color: #ffb4b4; }
|
|
752
|
+
.msg > *:first-child { margin-top: 0; }
|
|
753
|
+
.msg > *:last-child { margin-bottom: 0; }
|
|
754
|
+
.msg p { margin: 0 0 8px 0; }
|
|
755
|
+
.msg h1, .msg h2, .msg h3, .msg h4, .msg h5, .msg h6 { margin: 10px 0 6px 0; line-height: 1.3; }
|
|
756
|
+
.msg ul, .msg ol { margin: 4px 0 8px 22px; padding: 0; }
|
|
757
|
+
.msg li { margin: 2px 0; }
|
|
758
|
+
.msg pre { background: #111; padding: 8px 10px; border-radius: 6px; overflow-x: auto; margin: 6px 0; }
|
|
759
|
+
.msg code { background: #00000055; padding: 1px 4px; border-radius: 4px; font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.9em; }
|
|
760
|
+
.msg pre code { background: none; padding: 0; }
|
|
761
|
+
.msg blockquote { margin: 6px 0; padding-left: 10px; border-left: 3px solid #555; color: #aaa; }
|
|
762
|
+
.msg hr { border: none; border-top: 1px solid #444; margin: 10px 0; }
|
|
763
|
+
.msg a { color: #4db8ff; }
|
|
606
764
|
.attachments { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
|
607
765
|
.attachments img { width: 72px; height: 72px; object-fit: cover; border-radius: 6px; border: 1px solid #444; }
|
|
608
766
|
#compose { border-top: 1px solid #333; padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
@@ -620,6 +778,9 @@ export class ChatManager {
|
|
|
620
778
|
#tree { flex: 1; overflow-y: auto; padding: 8px; }
|
|
621
779
|
.tree-row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 6px; cursor: pointer; font-size: 13px; white-space: nowrap; }
|
|
622
780
|
.tree-row:hover { background: #262626; }
|
|
781
|
+
.tree-row.in-context { background: #4db8ff26; border: 1px solid #4db8ff88; }
|
|
782
|
+
.tree-row.in-context:hover { background: #4db8ff3a; }
|
|
783
|
+
.tree-row.in-context::after { content: '📌'; margin-left: 4px; opacity: 0.85; }
|
|
623
784
|
.tree-children { margin-left: 16px; display: none; }
|
|
624
785
|
.tree-children.open { display: block; }
|
|
625
786
|
#preview { border-top: 1px solid #333; max-height: 45%; overflow: auto; padding: 12px; font-family: 'SFMono-Regular', Consolas, monospace; font-size: 12.5px; }
|
|
@@ -652,7 +813,7 @@ export class ChatManager {
|
|
|
652
813
|
<script>
|
|
653
814
|
const BOOTSTRAP = ${bootstrap};
|
|
654
815
|
(function () {
|
|
655
|
-
const state = { pending: [], treeCache: {} };
|
|
816
|
+
const state = { pending: [], treeCache: {}, context: new Set() };
|
|
656
817
|
|
|
657
818
|
const app = document.getElementById('app');
|
|
658
819
|
const messagesEl = document.getElementById('messages');
|
|
@@ -689,10 +850,130 @@ const BOOTSTRAP = ${bootstrap};
|
|
|
689
850
|
});
|
|
690
851
|
}
|
|
691
852
|
|
|
853
|
+
// Minimal, dependency-free Markdown renderer. The whole input is HTML-escaped FIRST, and every
|
|
854
|
+
// tag below is introduced by this code afterwards — untrusted text (from the model or the user)
|
|
855
|
+
// is never interpreted as raw HTML, only as Markdown syntax on top of already-safe text.
|
|
856
|
+
var BACKTICK = '\\u0060';
|
|
857
|
+
|
|
858
|
+
function isSafeUrl(url) {
|
|
859
|
+
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return true;
|
|
860
|
+
return /^(https?|mailto):/i.test(url);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function renderInline(text) {
|
|
864
|
+
const codeRe = new RegExp(BACKTICK + '([^' + BACKTICK + ']+)' + BACKTICK, 'g');
|
|
865
|
+
text = text.replace(codeRe, function (m, code) { return '<code>' + code + '</code>'; });
|
|
866
|
+
text = text.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
|
|
867
|
+
text = text.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
|
868
|
+
text = text.replace(/\\*([^*]+)\\*/g, '<em>$1</em>');
|
|
869
|
+
text = text.replace(/(^|[^\\w])_([^_]+)_(?!\\w)/g, '$1<em>$2</em>');
|
|
870
|
+
text = text.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, function (m, label, url) {
|
|
871
|
+
const safeUrl = isSafeUrl(url) ? url : '#';
|
|
872
|
+
return '<a href="' + safeUrl + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
|
|
873
|
+
});
|
|
874
|
+
return text;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function renderMarkdown(raw) {
|
|
878
|
+
const escaped = escapeHtml(raw || '');
|
|
879
|
+
const lines = escaped.split('\\n');
|
|
880
|
+
const fence = BACKTICK + BACKTICK + BACKTICK;
|
|
881
|
+
const out = [];
|
|
882
|
+
let paragraph = [];
|
|
883
|
+
|
|
884
|
+
function flushParagraph() {
|
|
885
|
+
if (paragraph.length > 0) {
|
|
886
|
+
out.push('<p>' + paragraph.map(renderInline).join('<br>') + '</p>');
|
|
887
|
+
paragraph = [];
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
let i = 0;
|
|
892
|
+
while (i < lines.length) {
|
|
893
|
+
const line = lines[i];
|
|
894
|
+
|
|
895
|
+
if (line.indexOf(fence) === 0) {
|
|
896
|
+
flushParagraph();
|
|
897
|
+
const lang = line.slice(fence.length).trim().replace(/[^a-zA-Z0-9_-]/g, '');
|
|
898
|
+
const codeLines = [];
|
|
899
|
+
i++;
|
|
900
|
+
while (i < lines.length && lines[i].indexOf(fence) !== 0) {
|
|
901
|
+
codeLines.push(lines[i]);
|
|
902
|
+
i++;
|
|
903
|
+
}
|
|
904
|
+
i++;
|
|
905
|
+
const langClass = lang ? ' class="language-' + lang + '"' : '';
|
|
906
|
+
out.push('<pre><code' + langClass + '>' + codeLines.join('\\n') + '</code></pre>');
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const headerMatch = line.match(/^(#{1,6})\\s+(.*)$/);
|
|
911
|
+
if (headerMatch) {
|
|
912
|
+
flushParagraph();
|
|
913
|
+
const level = headerMatch[1].length;
|
|
914
|
+
out.push('<h' + level + '>' + renderInline(headerMatch[2]) + '</h' + level + '>');
|
|
915
|
+
i++;
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (/^(-{3,}|\\*{3,})\\s*$/.test(line)) {
|
|
920
|
+
flushParagraph();
|
|
921
|
+
out.push('<hr>');
|
|
922
|
+
i++;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (/^>\\s?/.test(line)) {
|
|
927
|
+
flushParagraph();
|
|
928
|
+
const quoteLines = [];
|
|
929
|
+
while (i < lines.length && /^>\\s?/.test(lines[i])) {
|
|
930
|
+
quoteLines.push(lines[i].replace(/^>\\s?/, ''));
|
|
931
|
+
i++;
|
|
932
|
+
}
|
|
933
|
+
out.push('<blockquote><p>' + quoteLines.map(renderInline).join('<br>') + '</p></blockquote>');
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (/^[-*+]\\s+/.test(line)) {
|
|
938
|
+
flushParagraph();
|
|
939
|
+
const items = [];
|
|
940
|
+
while (i < lines.length && /^[-*+]\\s+/.test(lines[i])) {
|
|
941
|
+
items.push('<li>' + renderInline(lines[i].replace(/^[-*+]\\s+/, '')) + '</li>');
|
|
942
|
+
i++;
|
|
943
|
+
}
|
|
944
|
+
out.push('<ul>' + items.join('') + '</ul>');
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
if (/^\\d+\\.\\s+/.test(line)) {
|
|
949
|
+
flushParagraph();
|
|
950
|
+
const oitems = [];
|
|
951
|
+
while (i < lines.length && /^\\d+\\.\\s+/.test(lines[i])) {
|
|
952
|
+
oitems.push('<li>' + renderInline(lines[i].replace(/^\\d+\\.\\s+/, '')) + '</li>');
|
|
953
|
+
i++;
|
|
954
|
+
}
|
|
955
|
+
out.push('<ol>' + oitems.join('') + '</ol>');
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if (line.trim() === '') {
|
|
960
|
+
flushParagraph();
|
|
961
|
+
i++;
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
paragraph.push(line);
|
|
966
|
+
i++;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
flushParagraph();
|
|
970
|
+
return out.join('');
|
|
971
|
+
}
|
|
972
|
+
|
|
692
973
|
function renderMessage(msg) {
|
|
693
974
|
const div = document.createElement('div');
|
|
694
975
|
div.className = 'msg ' + (msg.role === 'user' ? 'user' : (msg.role === 'error' ? 'error' : 'assistant'));
|
|
695
|
-
div.innerHTML =
|
|
976
|
+
div.innerHTML = renderMarkdown(msg.text || '');
|
|
696
977
|
if (msg.attachments && msg.attachments.length) {
|
|
697
978
|
const wrap = document.createElement('div');
|
|
698
979
|
wrap.className = 'attachments';
|
|
@@ -787,6 +1068,10 @@ const BOOTSTRAP = ${bootstrap};
|
|
|
787
1068
|
}).then(function (result) {
|
|
788
1069
|
if (!result.ok) throw new Error((result.data && result.data.error) || 'Request failed');
|
|
789
1070
|
renderMessage(result.data.message);
|
|
1071
|
+
if (result.data.context) {
|
|
1072
|
+
state.context = new Set(result.data.context);
|
|
1073
|
+
applyContextHighlight();
|
|
1074
|
+
}
|
|
790
1075
|
}).catch(function (e) {
|
|
791
1076
|
renderMessage({ role: 'error', text: 'Error: ' + e.message, attachments: [] });
|
|
792
1077
|
}).then(function () {
|
|
@@ -810,10 +1095,36 @@ const BOOTSTRAP = ${bootstrap};
|
|
|
810
1095
|
});
|
|
811
1096
|
}
|
|
812
1097
|
|
|
1098
|
+
function loadContext() {
|
|
1099
|
+
return fetch('/api/context').then(function (r) { return r.json(); }).then(function (data) {
|
|
1100
|
+
state.context = new Set(data.files || []);
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function applyContextHighlight() {
|
|
1105
|
+
const rows = treeEl.querySelectorAll('.tree-row[data-path]');
|
|
1106
|
+
rows.forEach(function (row) {
|
|
1107
|
+
row.classList.toggle('in-context', state.context.has(row.getAttribute('data-path')));
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function toggleContext(relPath) {
|
|
1112
|
+
fetch('/api/context/toggle', {
|
|
1113
|
+
method: 'POST',
|
|
1114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1115
|
+
body: JSON.stringify({ path: relPath }),
|
|
1116
|
+
}).then(function (r) { return r.json(); }).then(function (data) {
|
|
1117
|
+
state.context = new Set(data.files || []);
|
|
1118
|
+
applyContextHighlight();
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
813
1122
|
function buildNode(entry, container) {
|
|
814
1123
|
const row = document.createElement('div');
|
|
815
1124
|
row.className = 'tree-row';
|
|
1125
|
+
row.dataset.path = entry.path;
|
|
816
1126
|
row.textContent = folderIcon(entry.isDir) + ' ' + entry.name;
|
|
1127
|
+
if (!entry.isDir && state.context.has(entry.path)) row.classList.add('in-context');
|
|
817
1128
|
container.appendChild(row);
|
|
818
1129
|
|
|
819
1130
|
if (entry.isDir) {
|
|
@@ -832,6 +1143,10 @@ const BOOTSTRAP = ${bootstrap};
|
|
|
832
1143
|
});
|
|
833
1144
|
} else {
|
|
834
1145
|
row.addEventListener('click', function () { openFile(entry.path); });
|
|
1146
|
+
row.addEventListener('dblclick', function (e) {
|
|
1147
|
+
e.preventDefault();
|
|
1148
|
+
toggleContext(entry.path);
|
|
1149
|
+
});
|
|
835
1150
|
}
|
|
836
1151
|
}
|
|
837
1152
|
|
|
@@ -868,7 +1183,7 @@ const BOOTSTRAP = ${bootstrap};
|
|
|
868
1183
|
}
|
|
869
1184
|
|
|
870
1185
|
loadHistory();
|
|
871
|
-
|
|
1186
|
+
loadContext().then(loadTree);
|
|
872
1187
|
input.focus();
|
|
873
1188
|
})();
|
|
874
1189
|
</script>
|