@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 CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orxataguy/tyr",
3
- "version": "1.0.34",
3
+ "version": "1.0.36",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tyr": "./bin/tyr.js"
@@ -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
- const SEARCH_REPLACE_REGEX = /<<<<<<<\s*SEARCH\s*\n([\s\S]*?)\n=======\s*\n([\s\S]*?)\n>>>>>>>\s*REPLACE/g;
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 y a `reasoning_effort` en
47
- * los modelos de OpenAI que lo soportan (serie 'o'); Gemini no tiene un equivalente público en
48
- * este momento y el valor se ignora silenciosamente para ese vendor.
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
- const THINKING_BUDGETS: Record<ThinkingEffort, number> = {
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: 'max' },
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
- const route = PRIORITY_ROUTING[priority];
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 = THINKING_BUDGETS[config.thinking];
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
- * low/medium/high (no 'none'/'max') — mapped from our vendor-agnostic ThinkingEffort. Those
487
- * models also reject a custom `temperature`, so it's omitted whenever reasoning_effort is set. */
488
- private openaiReasoningFields(config: VendorConfig): { reasoning_effort?: string; temperature?: number } {
489
- const isReasoningModel = /^o\d/.test(config.model);
490
- if (!isReasoningModel || config.thinking === 'none') return { temperature: config.temperature };
491
-
492
- const effortMap: Partial<Record<ThinkingEffort, string>> = { low: 'low', medium: 'medium', high: 'high', max: 'high' };
493
- return { reasoning_effort: effortMap[config.thinking] };
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
- max_tokens: config.maxTokens,
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
  },
@@ -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({ message: userMessage, history: session.history, dir: session.dir });
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; white-space: pre-wrap; word-break: break-word; font-size: 14px; }
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 (/^&gt;\\s?/.test(line)) {
927
+ flushParagraph();
928
+ const quoteLines = [];
929
+ while (i < lines.length && /^&gt;\\s?/.test(lines[i])) {
930
+ quoteLines.push(lines[i].replace(/^&gt;\\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 = escapeHtml(msg.text || '').replace(/\\n/g, '<br>');
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
- loadTree();
1186
+ loadContext().then(loadTree);
872
1187
  input.focus();
873
1188
  })();
874
1189
  </script>