tina4-nodejs 3.13.99 → 3.13.101

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.
@@ -119,26 +119,33 @@ function skillsRef(): string {
119
119
  * MEASURED (2026-08-13): a real GitHub raw-content fetch occasionally drops a
120
120
  * request under load (transient DNS/TLS hiccup, not a missing file — every
121
121
  * URL here resolves fine on its own) while its siblings in the same batch
122
- * succeed. One retry pass over just the stragglers, still inside the same
123
- * child process, fixes that for real installer users too, not only the test.
122
+ * succeed. One retry pass over only transport failures and transient HTTP
123
+ * statuses, still inside the same child process, fixes that for real installer
124
+ * users too. Permanent 4xx responses are final answers and are not retried.
125
+ *
126
+ * Exported (like `writeOrMerge`/`markersFor`/`skillBlock` above) so
127
+ * aiFetchRetry.test.ts can drive it directly against a real local server —
128
+ * a pure visibility change, no behaviour change.
124
129
  *
125
130
  * @param jobs one entry per unique URL, with every file path it should land in
126
131
  * @returns the set of URLs that were fetched and written to disk
127
132
  */
128
- function downloadSkillsSync(jobs: { url: string; dests: string[] }[]): Set<string> {
133
+ export function downloadSkillsSync(jobs: { url: string; dests: string[] }[]): Set<string> {
129
134
  if (jobs.length === 0) return new Set();
130
135
  const child = `
131
136
  const jobs = JSON.parse(process.argv[1]);
132
137
  const fs = require("node:fs");
133
138
  const path = require("node:path");
139
+ const transientStatuses = new Set([429, 500, 502, 503, 504]);
134
140
  async function fetchOne(job) {
135
141
  const resp = await fetch(job.url, { signal: AbortSignal.timeout(15000) });
136
- if (!resp.ok) throw new Error("HTTP " + resp.status);
142
+ if (!resp.ok) return { ok: false, retry: transientStatuses.has(resp.status) };
137
143
  const buf = Buffer.from(await resp.arrayBuffer());
138
144
  for (const dest of job.dests) {
139
145
  fs.mkdirSync(path.dirname(dest), { recursive: true });
140
146
  fs.writeFileSync(dest, buf);
141
147
  }
148
+ return { ok: true, retry: false };
142
149
  }
143
150
  (async () => {
144
151
  const ok = [];
@@ -147,9 +154,11 @@ function downloadSkillsSync(jobs: { url: string; dests: string[] }[]): Set<strin
147
154
  const failed = [];
148
155
  await Promise.all(pending.map(async (job) => {
149
156
  try {
150
- await fetchOne(job);
151
- ok.push(job.url);
157
+ const result = await fetchOne(job);
158
+ if (result.ok) ok.push(job.url);
159
+ else if (result.retry) failed.push(job);
152
160
  } catch {
161
+ // DNS, TLS, timeout and connection failures are transient.
153
162
  failed.push(job);
154
163
  }
155
164
  }));
@@ -0,0 +1,288 @@
1
+ /** Zero-dependency app-facing AI client (ADR-0053). */
2
+ import http, { type IncomingMessage } from "node:http";
3
+ import https from "node:https";
4
+
5
+ export class AiError extends Error {}
6
+ export class AiConfigError extends AiError {}
7
+ export class AiTimeoutError extends AiError {}
8
+ export class AiParseError extends AiError {}
9
+ export class AiHTTPError extends AiError {
10
+ constructor(message: string, public readonly status: number | null = null) { super(message); }
11
+ }
12
+
13
+ export interface ChatResponse {
14
+ text: string;
15
+ model: string;
16
+ usage: { promptTokens: number; completionTokens: number; totalTokens: number };
17
+ finishReason: string | null;
18
+ raw: Record<string, unknown>;
19
+ }
20
+ export interface AiMessage { role: "system" | "user" | "assistant"; content: string }
21
+ export interface AiChatOptions {
22
+ model?: string;
23
+ temperature?: number;
24
+ maxTokens?: number;
25
+ stream?: boolean;
26
+ timeout?: number;
27
+ provider?: "local" | "openai" | "anthropic";
28
+ }
29
+ export interface AiEmbedOptions { model?: string; timeout?: number; provider?: "local" | "openai" | "anthropic" }
30
+
31
+ interface Config {
32
+ provider: "local" | "openai" | "anthropic";
33
+ url: string;
34
+ model: string;
35
+ key: string | null;
36
+ totalTimeout: number;
37
+ connectTimeout: number;
38
+ maxRetries: number;
39
+ }
40
+ interface OpenResponse { response: IncomingMessage; cleanup: () => void }
41
+
42
+ export class Ai {
43
+ static chat(messages: AiMessage[], options: AiChatOptions & { stream: true }): AsyncGenerator<string>;
44
+ static chat(messages: AiMessage[], options?: AiChatOptions & { stream?: false }): Promise<ChatResponse>;
45
+ static chat(messages: AiMessage[], options: AiChatOptions = {}): Promise<ChatResponse> | AsyncGenerator<string> {
46
+ this.validateMessages(messages);
47
+ const config = this.config("chat", options);
48
+ const body = this.chatBody(config, messages, options);
49
+ const headers = this.headers(config);
50
+ return options.stream ? this.streamRequest(config, headers, body) : this.chatResponse(config, headers, body);
51
+ }
52
+
53
+ static async complete(prompt: string, options: Omit<AiChatOptions, "stream"> = {}): Promise<string> {
54
+ if (typeof prompt !== "string") throw new AiConfigError("AI prompt must be a string");
55
+ return (await this.chat([{ role: "user", content: prompt }], { ...options, stream: false })).text;
56
+ }
57
+
58
+ static async embed(textOrTexts: string | string[], options: AiEmbedOptions = {}): Promise<number[] | number[][]> {
59
+ const single = typeof textOrTexts === "string";
60
+ if (!single && (!Array.isArray(textOrTexts) || textOrTexts.length === 0 || !textOrTexts.every((item) => typeof item === "string"))) {
61
+ throw new AiConfigError("AI embedding input must be a string or a non-empty list of strings");
62
+ }
63
+ const config = this.config("embed", options);
64
+ if (config.provider === "anthropic") throw new AiConfigError("Anthropic does not provide the embedding endpoint in this contract");
65
+ const raw = await this.requestJson(config, this.headers(config), { model: config.model, input: textOrTexts });
66
+ try {
67
+ const data = (raw.data as Array<{ index?: number; embedding?: unknown }>).sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
68
+ const vectors = data.map((item) => item.embedding);
69
+ const expected = single ? 1 : textOrTexts.length;
70
+ if (vectors.length !== expected || !vectors.every((vector) => Array.isArray(vector) && vector.length > 0 && vector.every((value) => typeof value === "number" && Number.isFinite(value)))) throw new Error();
71
+ return single ? vectors[0] as number[] : vectors as number[][];
72
+ } catch {
73
+ throw new AiParseError("AI provider returned a malformed embedding response");
74
+ }
75
+ }
76
+
77
+ private static validateMessages(messages: AiMessage[]): void {
78
+ if (!Array.isArray(messages) || messages.length === 0 || !messages.every((message) => message && ["system", "user", "assistant"].includes(message.role) && typeof message.content === "string")) {
79
+ throw new AiConfigError("AI messages must contain supported roles and string content");
80
+ }
81
+ }
82
+
83
+ private static number(name: string, fallback: number, minimum: number): number {
84
+ const value = process.env[name] === undefined ? fallback : Number(process.env[name]);
85
+ if (!Number.isFinite(value) || value < minimum) throw new AiConfigError(`${name} must be numeric and at least ${minimum}`);
86
+ return value;
87
+ }
88
+
89
+ private static config(capability: "chat" | "embed", options: AiChatOptions | AiEmbedOptions): Config {
90
+ const provider = (options.provider ?? process.env.TINA4_AI_PROVIDER ?? "local").trim().toLowerCase();
91
+ if (provider !== "local" && provider !== "openai" && provider !== "anthropic") throw new AiConfigError("TINA4_AI_PROVIDER must be local, openai, or anthropic");
92
+ const key = process.env.TINA4_AI_KEY || null;
93
+ if ((provider === "openai" || provider === "anthropic") && !key) throw new AiConfigError(`TINA4_AI_KEY is required for the ${provider} provider`);
94
+ const defaults: Record<Config["provider"], [string, string]> = {
95
+ local: ["http://localhost:11437", "llama3.2"],
96
+ openai: ["https://api.openai.com/v1", "gpt-4o-mini"],
97
+ anthropic: ["https://api.anthropic.com/v1", "claude-3-5-haiku-latest"],
98
+ };
99
+ const rawUrl = capability === "embed" && process.env.TINA4_EMBED_URL ? process.env.TINA4_EMBED_URL : (process.env.TINA4_AI_URL ?? defaults[provider][0]);
100
+ const model = (options.model ?? process.env.TINA4_AI_MODEL ?? defaults[provider][1]).trim();
101
+ if (!model) throw new AiConfigError("AI model must be a non-empty string");
102
+ const totalTimeout = options.timeout === undefined ? this.number("TINA4_AI_TIMEOUT", 60, 0.001) : Number(options.timeout);
103
+ if (!Number.isFinite(totalTimeout) || totalTimeout <= 0) throw new AiConfigError("AI timeout must be greater than zero");
104
+ return { provider, url: this.endpoint(rawUrl, capability, provider), model, key, totalTimeout, connectTimeout: this.number("TINA4_AI_CONNECT_TIMEOUT", 10, 0.001), maxRetries: Math.trunc(this.number("TINA4_AI_MAX_RETRIES", 2, 0)) };
105
+ }
106
+
107
+ private static endpoint(value: string, capability: "chat" | "embed", provider: Config["provider"]): string {
108
+ let url: URL;
109
+ try { url = new URL(value); } catch { throw new AiConfigError("AI URL must be an http or https URL"); }
110
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new AiConfigError("AI URL must be an http or https URL");
111
+ const path = url.pathname.replace(/\/+$/, "");
112
+ if (path === "" || path === "/v1" || path === "/api") {
113
+ const suffix = provider === "anthropic" ? "/messages" : capability === "embed" ? "/embeddings" : "/chat/completions";
114
+ url.pathname = (path || "/v1") + suffix;
115
+ }
116
+ return url.toString();
117
+ }
118
+
119
+ private static headers(config: Config): Record<string, string> {
120
+ const headers: Record<string, string> = { "content-type": "application/json", accept: "application/json" };
121
+ if (config.provider === "openai") headers.authorization = `Bearer ${config.key}`;
122
+ if (config.provider === "anthropic") { headers["x-api-key"] = config.key!; headers["anthropic-version"] = "2023-06-01"; }
123
+ return headers;
124
+ }
125
+
126
+ private static chatBody(config: Config, messages: AiMessage[], options: AiChatOptions): Record<string, unknown> {
127
+ const body: Record<string, unknown> = { model: config.model, messages, stream: options.stream ?? false };
128
+ if (options.temperature !== undefined) body.temperature = options.temperature;
129
+ if (options.maxTokens !== undefined) body.max_tokens = options.maxTokens;
130
+ if (config.provider === "anthropic") {
131
+ const system = messages.filter((message) => message.role === "system").map((message) => message.content);
132
+ body.messages = messages.filter((message) => message.role !== "system");
133
+ body.max_tokens = options.maxTokens ?? 1024;
134
+ if (system.length) body.system = system.join("\n\n");
135
+ }
136
+ return body;
137
+ }
138
+
139
+ private static open(config: Config, deadline: number, headers: Record<string, string>, body: Record<string, unknown>): Promise<OpenResponse> {
140
+ const remainingMs = deadline - performance.now();
141
+ if (remainingMs <= 0) return Promise.reject(new AiTimeoutError("AI total request timeout expired"));
142
+ const url = new URL(config.url);
143
+ const payload = JSON.stringify(body);
144
+ const controller = new AbortController();
145
+ const totalTimer = setTimeout(() => controller.abort(new AiTimeoutError("AI total request timeout expired")), remainingMs);
146
+ return new Promise((resolve, reject) => {
147
+ const client = url.protocol === "https:" ? https : http;
148
+ const request = client.request(url, { method: "POST", headers: { ...headers, "content-length": Buffer.byteLength(payload) }, signal: controller.signal }, (response) => {
149
+ clearTimeout(connectTimer);
150
+ resolve({ response, cleanup: () => { clearTimeout(totalTimer); clearTimeout(connectTimer); } });
151
+ });
152
+ const connectTimer = setTimeout(() => request.destroy(new AiTimeoutError("AI connection timeout expired")), Math.min(config.connectTimeout * 1000, remainingMs));
153
+ request.on("socket", (socket) => {
154
+ if (!socket.connecting) clearTimeout(connectTimer);
155
+ socket.once(url.protocol === "https:" ? "secureConnect" : "connect", () => clearTimeout(connectTimer));
156
+ });
157
+ request.once("error", (error) => {
158
+ clearTimeout(totalTimer); clearTimeout(connectTimer);
159
+ if (error instanceof AiError) reject(error);
160
+ else if (controller.signal.aborted) reject(new AiTimeoutError("AI total request timeout expired"));
161
+ else reject(new AiHTTPError(`AI transport failed (${error.name})`));
162
+ });
163
+ request.end(payload);
164
+ });
165
+ }
166
+
167
+ private static async readBody(response: IncomingMessage): Promise<string> {
168
+ const chunks: Buffer[] = [];
169
+ for await (const chunk of response) chunks.push(Buffer.from(chunk));
170
+ return Buffer.concat(chunks).toString("utf8");
171
+ }
172
+
173
+ private static retryDelay(headers: http.IncomingHttpHeaders, deadline: number): Promise<void> {
174
+ const value = Array.isArray(headers["retry-after"]) ? headers["retry-after"][0] : headers["retry-after"];
175
+ const requested = value !== undefined && Number.isFinite(Number(value)) ? Math.max(0, Number(value) * 1000) : 100;
176
+ const delay = Math.min(requested, Math.max(0, deadline - performance.now()));
177
+ return new Promise((resolve) => setTimeout(resolve, delay));
178
+ }
179
+
180
+ private static async requestJson(config: Config, headers: Record<string, string>, body: Record<string, unknown>): Promise<Record<string, unknown>> {
181
+ const deadline = performance.now() + config.totalTimeout * 1000;
182
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
183
+ let opened: OpenResponse | null = null;
184
+ try {
185
+ opened = await this.open(config, deadline, headers, body);
186
+ const status = opened.response.statusCode ?? 0;
187
+ const responseHeaders = opened.response.headers;
188
+ const raw = await this.readBody(opened.response);
189
+ opened.cleanup(); opened = null;
190
+ if (status < 200 || status >= 300) {
191
+ if ((status === 429 || status >= 500) && attempt < config.maxRetries) { await this.retryDelay(responseHeaders, deadline); continue; }
192
+ throw new AiHTTPError(`AI provider returned HTTP ${status}`, status);
193
+ }
194
+ let parsed: unknown;
195
+ try { parsed = JSON.parse(raw); } catch { throw new AiParseError("AI provider returned malformed JSON"); }
196
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new AiParseError("AI provider returned a non-object JSON response");
197
+ return parsed as Record<string, unknown>;
198
+ } catch (error) {
199
+ opened?.cleanup();
200
+ if (error instanceof AiParseError || (error instanceof AiHTTPError && error.status !== null)) throw error;
201
+ if (attempt >= config.maxRetries) throw error;
202
+ }
203
+ }
204
+ throw new AiHTTPError("AI request failed");
205
+ }
206
+
207
+ private static normalizeChat(provider: Config["provider"], raw: Record<string, unknown>): ChatResponse {
208
+ try {
209
+ if (provider === "anthropic") {
210
+ const content = raw.content as Array<{ type?: string; text?: unknown }>;
211
+ const parts = content.filter((item) => (item.type ?? "text") === "text").map((item) => item.text);
212
+ if (!parts.length || !parts.every((part) => typeof part === "string")) throw new Error();
213
+ const usage = (raw.usage ?? {}) as Record<string, unknown>;
214
+ const promptTokens = Number(usage.input_tokens ?? 0); const completionTokens = Number(usage.output_tokens ?? 0);
215
+ return { text: parts.join(""), model: String(raw.model ?? ""), usage: { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens }, finishReason: raw.stop_reason == null ? null : String(raw.stop_reason), raw };
216
+ }
217
+ const choice = (raw.choices as Array<Record<string, unknown>>)[0];
218
+ const text = (choice.message as Record<string, unknown>).content;
219
+ if (typeof text !== "string") throw new Error();
220
+ const usage = (raw.usage ?? {}) as Record<string, unknown>;
221
+ return { text, model: String(raw.model ?? ""), usage: { promptTokens: Number(usage.prompt_tokens ?? 0), completionTokens: Number(usage.completion_tokens ?? 0), totalTokens: Number(usage.total_tokens ?? 0) }, finishReason: choice.finish_reason == null ? null : String(choice.finish_reason), raw };
222
+ } catch { throw new AiParseError("AI provider returned a malformed chat response"); }
223
+ }
224
+
225
+ private static async chatResponse(config: Config, headers: Record<string, string>, body: Record<string, unknown>): Promise<ChatResponse> {
226
+ return this.normalizeChat(config.provider, await this.requestJson(config, headers, body));
227
+ }
228
+
229
+ private static streamDelta(provider: Config["provider"], data: string): { completed: boolean; text?: string } {
230
+ if (data === "[DONE]") return { completed: true };
231
+ let event: Record<string, unknown>;
232
+ try { event = JSON.parse(data) as Record<string, unknown>; } catch { throw new AiParseError("AI provider returned malformed stream data"); }
233
+ const text = provider === "anthropic"
234
+ ? (event.type === "content_block_delta" ? (event.delta as Record<string, unknown>)?.text : undefined)
235
+ : (((event.choices as Array<Record<string, unknown>>)?.[0]?.delta as Record<string, unknown>)?.content);
236
+ if (text !== undefined && text !== null && typeof text !== "string") throw new AiParseError("AI provider returned malformed stream data");
237
+ return { completed: false, text: text as string | undefined };
238
+ }
239
+
240
+ private static async *streamData(response: IncomingMessage): AsyncGenerator<string> {
241
+ let buffer = "";
242
+ for await (const chunk of response) {
243
+ buffer += Buffer.from(chunk).toString("utf8");
244
+ let newline: number;
245
+ while ((newline = buffer.indexOf("\n")) >= 0) {
246
+ const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1);
247
+ if (line.startsWith("data:")) yield line.slice(5).trim();
248
+ }
249
+ }
250
+ }
251
+
252
+ private static streamError(error: unknown): AiError {
253
+ if (error instanceof AiError) return error;
254
+ if (error instanceof Error && error.name === "AbortError") return new AiTimeoutError("AI total request timeout expired");
255
+ return new AiHTTPError(`AI transport failed (${error instanceof Error ? error.name : "Error"})`);
256
+ }
257
+
258
+ private static async *streamRequest(config: Config, headers: Record<string, string>, body: Record<string, unknown>): AsyncGenerator<string> {
259
+ const deadline = performance.now() + config.totalTimeout * 1000;
260
+ let yielded = false;
261
+ for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
262
+ let opened: OpenResponse | null = null;
263
+ try {
264
+ opened = await this.open(config, deadline, { ...headers, accept: "text/event-stream" }, body);
265
+ const status = opened.response.statusCode ?? 0;
266
+ if (status < 200 || status >= 300) {
267
+ await this.readBody(opened.response);
268
+ if ((status === 429 || status >= 500) && attempt < config.maxRetries) { await this.retryDelay(opened.response.headers, deadline); opened.cleanup(); opened = null; continue; }
269
+ throw new AiHTTPError(`AI provider returned HTTP ${status}`, status);
270
+ }
271
+ let completed = false;
272
+ for await (const data of this.streamData(opened.response)) {
273
+ const delta = this.streamDelta(config.provider, data);
274
+ if (delta.completed) { completed = true; break; }
275
+ if (delta.text === undefined) continue;
276
+ yielded = true; yield delta.text;
277
+ }
278
+ opened.cleanup(); opened = null;
279
+ if (completed) return;
280
+ throw new AiParseError("AI provider stream ended before [DONE]");
281
+ } catch (error) {
282
+ opened?.cleanup();
283
+ const failure = this.streamError(error);
284
+ if (failure instanceof AiParseError || (failure instanceof AiHTTPError && failure.status !== null) || yielded || attempt >= config.maxRetries) throw failure;
285
+ }
286
+ }
287
+ }
288
+ }
@@ -17,7 +17,7 @@ import type { Router } from "./router.js";
17
17
  import type { RouteHandler, Tina4Request } from "./types.js";
18
18
  import { DevMailbox } from "./devMailbox.js";
19
19
  import { isTruthy } from "./dotenv.js";
20
- import { quickMetrics, fullAnalysis, fileDetail, MetricsEngineError } from "./metrics.js";
20
+ import { fullAnalysis, fileDetail, MetricsEngineError } from "./metrics.js";
21
21
  import { registerFeedbackRoutes } from "./feedback.js";
22
22
  import { getDefaultDevServer, mcpEnabled, isRequestAllowed, isLoopback } from "./mcp.js";
23
23
  import { timingSafeEqual } from "node:crypto";
@@ -596,7 +596,6 @@ export class DevAdmin {
596
596
  { method: "GET", pattern: "/__dev/api/gallery", handler: handleGalleryList },
597
597
  { method: "POST", pattern: "/__dev/api/gallery/deploy", handler: handleGalleryDeploy(router) },
598
598
  // Metrics
599
- { method: "GET", pattern: "/__dev/api/metrics", handler: (_req: any, res: any) => { res.json(quickMetrics()); } },
600
599
  // No fallback (ADR-0002): a missing or stale CLI is a 503 naming the
601
600
  // install command, never zeros that read as a healthy codebase.
602
601
  { method: "GET", pattern: "/__dev/api/metrics/full", handler: (_req: any, res: any) => {
@@ -107,6 +107,8 @@ export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htm
107
107
  export { renderErrorOverlay, isDebugMode } from "./errorOverlay.js";
108
108
  export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
109
109
  export type { AiTool } from "./ai.js";
110
+ export { Ai, AiError, AiConfigError, AiHTTPError, AiTimeoutError, AiParseError } from "./aiClient.js";
111
+ export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions } from "./aiClient.js";
110
112
  export type { ImapMessage, ImapFullMessage, ImapAttachment } from "./messenger.js";
111
113
  export { LiteBackend } from "./queueBackends/liteBackend.js";
112
114
  export { RabbitMQBackend, parseAmqpUrl } from "./queueBackends/rabbitmqBackend.js";