tina4-nodejs 3.13.133 → 3.13.135
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/CLAUDE.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +3213 -3062
- package/packages/cli/src/commands/generate.ts +33 -22
- package/packages/cli/src/commands/lint.ts +77 -111
- package/packages/core/dist/index.js +3122 -2963
- package/packages/core/src/.tina4-metrics.json +15004 -0
- package/packages/core/src/aiClient.ts +199 -161
- package/packages/core/src/devAdmin.ts +46 -14
- package/packages/core/src/dispatchPipeline.ts +65 -67
- package/packages/core/src/docs.ts +52 -544
- package/packages/core/src/docsParser.ts +270 -0
- package/packages/core/src/docsScanner.ts +121 -0
- package/packages/core/src/docsSignatures.ts +165 -0
- package/packages/core/src/index.ts +2 -0
- package/packages/core/src/logger.ts +68 -82
- package/packages/core/src/mcp.ts +32 -60
- package/packages/core/src/messenger.ts +136 -157
- package/packages/core/src/middleware.ts +56 -60
- package/packages/core/src/plan.ts +78 -70
- package/packages/core/src/projectIndex.ts +15 -288
- package/packages/core/src/projectIndexExtractors.ts +126 -0
- package/packages/core/src/projectIndexStorage.ts +122 -0
- package/packages/core/src/push.ts +293 -0
- package/packages/core/src/server.ts +187 -183
- package/packages/frond/dist/index.js +607 -770
- package/packages/frond/src/engine.ts +670 -818
- package/packages/orm/dist/index.js +3132 -2976
- package/packages/orm/src/adapters/mongodb.ts +99 -144
- package/packages/orm/src/baseModel.ts +429 -515
- package/packages/orm/src/fakeData.ts +73 -61
- package/packages/orm/src/migration.ts +96 -126
- package/packages/orm/src/seeder.ts +6 -238
- package/packages/orm/src/seederTable.ts +101 -0
- package/packages/orm/src/seederTypes.ts +14 -0
- package/packages/orm/src/validation.ts +97 -80
- package/types/core/src/aiClient.d.ts +5 -0
- package/types/core/src/devAdmin.d.ts +23 -1
- package/types/core/src/docsParser.d.ts +28 -0
- package/types/core/src/docsScanner.d.ts +1 -0
- package/types/core/src/docsSignatures.d.ts +11 -0
- package/types/core/src/index.d.ts +2 -0
- package/types/core/src/messenger.d.ts +8 -0
- package/types/core/src/projectIndexExtractors.d.ts +3 -0
- package/types/core/src/projectIndexStorage.d.ts +13 -0
- package/types/core/src/push.d.ts +45 -0
- package/types/frond/src/engine.d.ts +25 -0
- package/types/orm/src/fakeData.d.ts +3 -0
- package/types/orm/src/seeder.d.ts +3 -89
- package/types/orm/src/seederTable.d.ts +9 -0
- package/types/orm/src/seederTypes.d.ts +16 -0
|
@@ -185,39 +185,53 @@ export class Ai {
|
|
|
185
185
|
if (!Array.isArray(content) || content.length === 0) {
|
|
186
186
|
throw new AiConfigError("AI message content must be a string or a non-empty list of parts");
|
|
187
187
|
}
|
|
188
|
-
for (const part of content)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
} else if (partType === "tool_result") {
|
|
209
|
-
if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
|
|
210
|
-
throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
|
|
211
|
-
}
|
|
212
|
-
if (typeof record.content !== "string") {
|
|
213
|
-
throw new AiConfigError("AI tool_result part requires a string 'content'");
|
|
214
|
-
}
|
|
215
|
-
} else {
|
|
216
|
-
throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
|
|
217
|
-
}
|
|
188
|
+
for (const part of content) this.validateContentPart(part);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private static validateContentPart(part: unknown): void {
|
|
192
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) {
|
|
193
|
+
throw new AiConfigError("AI content part must be an object with type and text/source");
|
|
194
|
+
}
|
|
195
|
+
const record = part as Record<string, unknown>;
|
|
196
|
+
switch (record.type) {
|
|
197
|
+
case "text":
|
|
198
|
+
this.validateTextPart(record);
|
|
199
|
+
return;
|
|
200
|
+
case "image":
|
|
201
|
+
this.validateImagePart(record);
|
|
202
|
+
return;
|
|
203
|
+
case "tool_result":
|
|
204
|
+
this.validateToolResultPart(record);
|
|
205
|
+
return;
|
|
206
|
+
default:
|
|
207
|
+
throw new AiConfigError(`AI content part has unknown type '${String(record.type)}'`);
|
|
218
208
|
}
|
|
219
209
|
}
|
|
220
210
|
|
|
211
|
+
private static validateTextPart(record: Record<string, unknown>): void {
|
|
212
|
+
if (typeof record.text !== "string") throw new AiConfigError("AI text content part requires a string 'text' field");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private static validateImagePart(record: Record<string, unknown>): void {
|
|
216
|
+
const source = record.source;
|
|
217
|
+
if (typeof source !== "string" || source.length === 0) {
|
|
218
|
+
throw new AiConfigError("AI image content part requires a non-empty string 'source' field");
|
|
219
|
+
}
|
|
220
|
+
if (!source.startsWith("data:") && !source.startsWith("https://")) {
|
|
221
|
+
throw new AiConfigError("AI image source must be a data: URI or an https:// URL");
|
|
222
|
+
}
|
|
223
|
+
if (source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(source)) {
|
|
224
|
+
throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private static validateToolResultPart(record: Record<string, unknown>): void {
|
|
229
|
+
if (typeof record.tool_use_id !== "string" || record.tool_use_id.length === 0) {
|
|
230
|
+
throw new AiConfigError("AI tool_result part requires a non-empty string 'tool_use_id'");
|
|
231
|
+
}
|
|
232
|
+
if (typeof record.content !== "string") throw new AiConfigError("AI tool_result part requires a string 'content'");
|
|
233
|
+
}
|
|
234
|
+
|
|
221
235
|
/**
|
|
222
236
|
* Validate the outbound tool declarations (ADR-0061). Each tool needs a
|
|
223
237
|
* non-empty `name`, a string `description`, and a JSON-Schema-shaped
|
|
@@ -566,27 +580,11 @@ export class Ai {
|
|
|
566
580
|
throw new AiHTTPError(`AI provider returned HTTP ${status}`, status);
|
|
567
581
|
}
|
|
568
582
|
const response = opened.response;
|
|
569
|
-
const chunks = this.responseChunks(response);
|
|
570
|
-
const events = parseSseStream(parseLineStream(chunks));
|
|
571
|
-
const aggregator = new AggregateState(config.provider);
|
|
572
583
|
let done = false;
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
yield emitted;
|
|
578
|
-
if (emitted.type === "done" || emitted.type === "error") { done = true; break; }
|
|
579
|
-
}
|
|
580
|
-
if (done) break;
|
|
581
|
-
}
|
|
582
|
-
} catch (error) {
|
|
583
|
-
if (yielded) {
|
|
584
|
-
yielded = true;
|
|
585
|
-
yield { type: "error", message: error instanceof AiParseError ? "AI provider returned malformed stream data" : `AI transport failed (${error instanceof Error ? error.name : "Error"})` };
|
|
586
|
-
opened.cleanup(); opened = null;
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
throw error;
|
|
584
|
+
for await (const emitted of this.readStream(response, config.provider)) {
|
|
585
|
+
yielded = true;
|
|
586
|
+
yield emitted;
|
|
587
|
+
if (emitted.type === "done" || emitted.type === "error") { done = true; break; }
|
|
590
588
|
}
|
|
591
589
|
opened.cleanup(); opened = null;
|
|
592
590
|
if (done) return;
|
|
@@ -611,6 +609,24 @@ export class Ai {
|
|
|
611
609
|
}
|
|
612
610
|
}
|
|
613
611
|
|
|
612
|
+
private static async *readStream(response: IncomingMessage, provider: Config["provider"]): AsyncGenerator<AiEvent> {
|
|
613
|
+
const events = parseSseStream(parseLineStream(this.responseChunks(response)));
|
|
614
|
+
const aggregator = new AggregateState(provider);
|
|
615
|
+
let yielded = false;
|
|
616
|
+
try {
|
|
617
|
+
for await (const sseEvent of events) {
|
|
618
|
+
for (const emitted of aggregator.consume(sseEvent)) {
|
|
619
|
+
yielded = true;
|
|
620
|
+
yield emitted;
|
|
621
|
+
if (emitted.type === "done" || emitted.type === "error") return;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
} catch (error) {
|
|
625
|
+
if (!yielded) throw error;
|
|
626
|
+
yield { type: "error", message: error instanceof AiParseError ? "AI provider returned malformed stream data" : `AI transport failed (${error instanceof Error ? error.name : "Error"})` };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
614
630
|
private static streamError(error: unknown): AiError {
|
|
615
631
|
if (error instanceof AiError) return error;
|
|
616
632
|
if (error instanceof Error && error.name === "AbortError") return new AiTimeoutError("AI total request timeout expired");
|
|
@@ -663,132 +679,154 @@ class AggregateState {
|
|
|
663
679
|
if (!Array.isArray(choices) || choices.length === 0) return;
|
|
664
680
|
const choice = choices[0];
|
|
665
681
|
const delta = (choice.delta ?? {}) as Record<string, unknown>;
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
const toolCalls = delta.tool_calls as Array<Record<string, unknown>> | undefined;
|
|
671
|
-
if (Array.isArray(toolCalls)) {
|
|
672
|
-
for (const call of toolCalls) {
|
|
673
|
-
const index = typeof call.index === "number" ? String(call.index) : String(this.toolBuffers.size);
|
|
674
|
-
const idFromCall = typeof call.id === "string" ? call.id : "";
|
|
675
|
-
const fn = (call.function ?? {}) as Record<string, unknown>;
|
|
676
|
-
const nameFromCall = typeof fn.name === "string" ? fn.name : "";
|
|
677
|
-
const argsFragment = typeof fn.arguments === "string" ? fn.arguments : "";
|
|
678
|
-
const existing = this.toolBuffers.get(index) ?? { id: "", name: "", args: "" };
|
|
679
|
-
if (idFromCall) existing.id = idFromCall;
|
|
680
|
-
if (nameFromCall) existing.name = nameFromCall;
|
|
681
|
-
existing.args += argsFragment;
|
|
682
|
-
this.toolBuffers.set(index, existing);
|
|
683
|
-
if (existing.name && existing.args) {
|
|
684
|
-
try {
|
|
685
|
-
const parsed = JSON.parse(existing.args) as unknown;
|
|
686
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
687
|
-
this.toolBuffers.delete(index);
|
|
688
|
-
yield { type: "tool_call", id: existing.id || `call_${index}`, name: existing.name, args: parsed as Record<string, unknown> };
|
|
689
|
-
}
|
|
690
|
-
} catch {
|
|
691
|
-
/* args not complete yet — keep buffering */
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
|
|
697
|
-
this.lastFinishReason = choice.finish_reason;
|
|
698
|
-
}
|
|
699
|
-
const usage = payload.usage as Record<string, unknown> | undefined;
|
|
700
|
-
if (usage && typeof usage === "object") {
|
|
701
|
-
const promptTokens = Number(usage.prompt_tokens ?? 0);
|
|
702
|
-
const completionTokens = Number(usage.completion_tokens ?? 0);
|
|
703
|
-
const totalTokens = Number(usage.total_tokens ?? promptTokens + completionTokens);
|
|
704
|
-
if (Number.isFinite(promptTokens) && Number.isFinite(completionTokens)) {
|
|
705
|
-
this.lastUsage = { promptTokens, completionTokens, totalTokens };
|
|
706
|
-
}
|
|
707
|
-
}
|
|
682
|
+
yield* this.consumeOpenAiContent(delta);
|
|
683
|
+
yield* this.consumeOpenAiTools(delta);
|
|
684
|
+
this.updateOpenAiFinishReason(choice);
|
|
685
|
+
this.updateOpenAiUsage(payload);
|
|
708
686
|
}
|
|
709
687
|
|
|
710
688
|
private *consumeAnthropic(payload: Record<string, unknown>): Iterable<AiEvent> {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
689
|
+
switch (payload.type) {
|
|
690
|
+
case "content_block_start":
|
|
691
|
+
this.consumeAnthropicBlockStart(payload);
|
|
692
|
+
return;
|
|
693
|
+
case "content_block_delta":
|
|
694
|
+
yield* this.consumeAnthropicBlockDelta(payload);
|
|
695
|
+
return;
|
|
696
|
+
case "content_block_stop":
|
|
697
|
+
yield* this.consumeAnthropicBlockStop(payload);
|
|
698
|
+
return;
|
|
699
|
+
case "message_delta":
|
|
700
|
+
this.consumeAnthropicMessageDelta(payload);
|
|
701
|
+
return;
|
|
702
|
+
case "message_stop":
|
|
703
|
+
yield* this.consumeAnthropicMessageStop();
|
|
704
|
+
return;
|
|
705
|
+
case "message_start":
|
|
706
|
+
this.consumeAnthropicMessageStart(payload);
|
|
707
|
+
return;
|
|
708
|
+
case "error":
|
|
709
|
+
this.consumeAnthropicError(payload);
|
|
727
710
|
return;
|
|
728
|
-
}
|
|
729
|
-
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
730
|
-
const existing = this.toolBuffers.get(index);
|
|
731
|
-
if (existing) existing.args += delta.partial_json;
|
|
732
|
-
}
|
|
733
|
-
return;
|
|
734
711
|
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private *consumeOpenAiContent(delta: Record<string, unknown>): Iterable<AiEvent> {
|
|
715
|
+
const content = delta.content;
|
|
716
|
+
if (typeof content === "string" && content.length > 0) yield { type: "text_delta", text: content };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private *consumeOpenAiTools(delta: Record<string, unknown>): Iterable<AiEvent> {
|
|
720
|
+
const toolCalls = delta.tool_calls as Array<Record<string, unknown>> | undefined;
|
|
721
|
+
if (!Array.isArray(toolCalls)) return;
|
|
722
|
+
for (const call of toolCalls) yield* this.consumeOpenAiTool(call);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private *consumeOpenAiTool(call: Record<string, unknown>): Iterable<AiEvent> {
|
|
726
|
+
const index = typeof call.index === "number" ? String(call.index) : String(this.toolBuffers.size);
|
|
727
|
+
const idFromCall = typeof call.id === "string" ? call.id : "";
|
|
728
|
+
const fn = (call.function ?? {}) as Record<string, unknown>;
|
|
729
|
+
const nameFromCall = typeof fn.name === "string" ? fn.name : "";
|
|
730
|
+
const argsFragment = typeof fn.arguments === "string" ? fn.arguments : "";
|
|
731
|
+
const existing = this.toolBuffers.get(index) ?? { id: "", name: "", args: "" };
|
|
732
|
+
if (idFromCall) existing.id = idFromCall;
|
|
733
|
+
if (nameFromCall) existing.name = nameFromCall;
|
|
734
|
+
existing.args += argsFragment;
|
|
735
|
+
this.toolBuffers.set(index, existing);
|
|
736
|
+
if (!existing.name || !existing.args) return;
|
|
737
|
+
try {
|
|
738
|
+
const parsed = JSON.parse(existing.args) as unknown;
|
|
739
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
739
740
|
this.toolBuffers.delete(index);
|
|
740
|
-
|
|
741
|
-
const parsed = existing.args ? JSON.parse(existing.args) : {};
|
|
742
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
743
|
-
yield { type: "tool_call", id: existing.id, name: existing.name, args: parsed as Record<string, unknown> };
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
746
|
-
throw new Error();
|
|
747
|
-
} catch {
|
|
748
|
-
throw new AiParseError("AI provider returned malformed tool-call JSON");
|
|
749
|
-
}
|
|
741
|
+
yield { type: "tool_call", id: existing.id || `call_${index}`, name: existing.name, args: parsed as Record<string, unknown> };
|
|
750
742
|
}
|
|
751
|
-
|
|
743
|
+
} catch {
|
|
744
|
+
/* args not complete yet — keep buffering */
|
|
752
745
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private updateOpenAiFinishReason(choice: Record<string, unknown>): void {
|
|
749
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) this.lastFinishReason = choice.finish_reason;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private updateOpenAiUsage(payload: Record<string, unknown>): void {
|
|
753
|
+
const usage = payload.usage as Record<string, unknown> | undefined;
|
|
754
|
+
if (!usage || typeof usage !== "object") return;
|
|
755
|
+
const promptTokens = Number(usage.prompt_tokens ?? 0);
|
|
756
|
+
const completionTokens = Number(usage.completion_tokens ?? 0);
|
|
757
|
+
const totalTokens = Number(usage.total_tokens ?? promptTokens + completionTokens);
|
|
758
|
+
if (Number.isFinite(promptTokens) && Number.isFinite(completionTokens)) this.lastUsage = { promptTokens, completionTokens, totalTokens };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private consumeAnthropicBlockStart(payload: Record<string, unknown>): void {
|
|
762
|
+
const block = (payload.content_block ?? {}) as Record<string, unknown>;
|
|
763
|
+
if (block.type !== "tool_use") return;
|
|
764
|
+
const index = String(payload.index ?? this.toolBuffers.size);
|
|
765
|
+
const id = typeof block.id === "string" ? block.id : `call_${index}`;
|
|
766
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
767
|
+
this.toolBuffers.set(index, { id, name, args: "" });
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
private *consumeAnthropicBlockDelta(payload: Record<string, unknown>): Iterable<AiEvent> {
|
|
771
|
+
const index = String(payload.index ?? 0);
|
|
772
|
+
const delta = (payload.delta ?? {}) as Record<string, unknown>;
|
|
773
|
+
if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text.length > 0) {
|
|
774
|
+
yield { type: "text_delta", text: delta.text };
|
|
764
775
|
return;
|
|
765
776
|
}
|
|
766
|
-
if (type === "
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
yield {
|
|
770
|
-
type: "done",
|
|
771
|
-
finishReason: this.lastFinishReason ?? "end_turn",
|
|
772
|
-
...(this.lastUsage ? { usage: this.lastUsage } : {}),
|
|
773
|
-
};
|
|
774
|
-
return;
|
|
777
|
+
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
778
|
+
const existing = this.toolBuffers.get(index);
|
|
779
|
+
if (existing) existing.args += delta.partial_json;
|
|
775
780
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private *consumeAnthropicBlockStop(payload: Record<string, unknown>): Iterable<AiEvent> {
|
|
784
|
+
const index = String(payload.index ?? 0);
|
|
785
|
+
const existing = this.toolBuffers.get(index);
|
|
786
|
+
if (!existing || !existing.name) return;
|
|
787
|
+
this.toolBuffers.delete(index);
|
|
788
|
+
try {
|
|
789
|
+
const parsed = existing.args ? JSON.parse(existing.args) : {};
|
|
790
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
791
|
+
yield { type: "tool_call", id: existing.id, name: existing.name, args: parsed as Record<string, unknown> };
|
|
792
|
+
return;
|
|
783
793
|
}
|
|
784
|
-
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
const err = (payload.error ?? {}) as Record<string, unknown>;
|
|
788
|
-
throw new AiParseError(typeof err.message === "string" ? err.message : "AI provider signalled a stream error");
|
|
794
|
+
throw new Error();
|
|
795
|
+
} catch {
|
|
796
|
+
throw new AiParseError("AI provider returned malformed tool-call JSON");
|
|
789
797
|
}
|
|
790
798
|
}
|
|
791
799
|
|
|
800
|
+
private consumeAnthropicMessageDelta(payload: Record<string, unknown>): void {
|
|
801
|
+
const delta = (payload.delta ?? {}) as Record<string, unknown>;
|
|
802
|
+
if (typeof delta.stop_reason === "string" && delta.stop_reason.length > 0) this.lastFinishReason = delta.stop_reason;
|
|
803
|
+
const usage = (payload.usage ?? {}) as Record<string, unknown>;
|
|
804
|
+
if (usage.output_tokens === undefined && usage.input_tokens === undefined) return;
|
|
805
|
+
const promptTokens = Number(usage.input_tokens ?? this.lastUsage?.promptTokens ?? 0);
|
|
806
|
+
const completionTokens = Number(usage.output_tokens ?? this.lastUsage?.completionTokens ?? 0);
|
|
807
|
+
this.lastUsage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private *consumeAnthropicMessageStop(): Iterable<AiEvent> {
|
|
811
|
+
if (this.doneEmitted) return;
|
|
812
|
+
this.doneEmitted = true;
|
|
813
|
+
yield { type: "done", finishReason: this.lastFinishReason ?? "end_turn", ...(this.lastUsage ? { usage: this.lastUsage } : {}) };
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private consumeAnthropicMessageStart(payload: Record<string, unknown>): void {
|
|
817
|
+
const message = (payload.message ?? {}) as Record<string, unknown>;
|
|
818
|
+
const usage = (message.usage ?? {}) as Record<string, unknown>;
|
|
819
|
+
if (usage.input_tokens === undefined && usage.output_tokens === undefined) return;
|
|
820
|
+
const promptTokens = Number(usage.input_tokens ?? 0);
|
|
821
|
+
const completionTokens = Number(usage.output_tokens ?? 0);
|
|
822
|
+
this.lastUsage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private consumeAnthropicError(payload: Record<string, unknown>): void {
|
|
826
|
+
const err = (payload.error ?? {}) as Record<string, unknown>;
|
|
827
|
+
throw new AiParseError(typeof err.message === "string" ? err.message : "AI provider signalled a stream error");
|
|
828
|
+
}
|
|
829
|
+
|
|
792
830
|
private *flushRemainingToolCalls(): Iterable<AiEvent> {
|
|
793
831
|
for (const [index, buffered] of this.toolBuffers) {
|
|
794
832
|
if (buffered.name && buffered.args) {
|
|
@@ -2069,24 +2069,48 @@ function handleGalleryDeploy(router: Router): RouteHandler {
|
|
|
2069
2069
|
// Version check — proxy to npm registry to avoid browser CORS errors
|
|
2070
2070
|
// ---------------------------------------------------------------------------
|
|
2071
2071
|
|
|
2072
|
-
|
|
2072
|
+
/**
|
|
2073
|
+
* Version check — a check that did not happen says so.
|
|
2074
|
+
*
|
|
2075
|
+
* This used to fall back to `latest = current` on any failure, and the toolbar
|
|
2076
|
+
* renders that as a green "You are up to date!" — so a developer several
|
|
2077
|
+
* releases behind, on a machine with no route out, was told the opposite of the
|
|
2078
|
+
* truth, and the toolbar's own "Could not check for updates" branch could never
|
|
2079
|
+
* fire because the failure arrived as a success. `latest` is `null` when the
|
|
2080
|
+
* check could not be made, and `error` says why. The registry URL is
|
|
2081
|
+
* `TINA4_VERSION_CHECK_URL` when set (a mirror, or a test's own server), else
|
|
2082
|
+
* npm. Mirrors Python `tina4_python.dev_admin._api_version_check`.
|
|
2083
|
+
*/
|
|
2084
|
+
export const handleVersionCheck: RouteHandler = async (_req, res) => {
|
|
2073
2085
|
const current = TINA4_VERSION;
|
|
2074
|
-
|
|
2086
|
+
const url =
|
|
2087
|
+
process.env.TINA4_VERSION_CHECK_URL ||
|
|
2088
|
+
"https://registry.npmjs.org/tina4-nodejs/latest";
|
|
2089
|
+
const failed = (why: string) => res.json({ current, latest: null, error: why });
|
|
2090
|
+
|
|
2091
|
+
let data: Record<string, unknown>;
|
|
2075
2092
|
try {
|
|
2076
2093
|
const controller = new AbortController();
|
|
2077
|
-
const
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
if (
|
|
2094
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
2095
|
+
try {
|
|
2096
|
+
const resp = await fetch(url, {
|
|
2097
|
+
signal: controller.signal,
|
|
2098
|
+
headers: { "User-Agent": `tina4-nodejs/${current}` },
|
|
2099
|
+
});
|
|
2100
|
+
// Reaching the registry is not the same as a 200 with a body.
|
|
2101
|
+
if (!resp.ok) return failed(`npm registry responded ${resp.status}`);
|
|
2102
|
+
data = (await resp.json()) as Record<string, unknown>;
|
|
2103
|
+
} finally {
|
|
2104
|
+
clearTimeout(timer);
|
|
2085
2105
|
}
|
|
2086
|
-
} catch {
|
|
2087
|
-
//
|
|
2106
|
+
} catch (exc) {
|
|
2107
|
+
// offline, timeout, DNS, unreadable body
|
|
2108
|
+
return failed(exc instanceof Error && exc.message ? exc.message : String(exc));
|
|
2088
2109
|
}
|
|
2089
|
-
|
|
2110
|
+
// An answer we cannot read a version out of is the same lie by another route.
|
|
2111
|
+
const latest = typeof data.version === "string" ? data.version : "";
|
|
2112
|
+
if (!latest) return failed("npm registry did not report a version");
|
|
2113
|
+
return res.json({ current, latest });
|
|
2090
2114
|
};
|
|
2091
2115
|
|
|
2092
2116
|
// ---------------------------------------------------------------------------
|
|
@@ -3019,7 +3043,7 @@ function toolbarCss(): string {
|
|
|
3019
3043
|
* starts when the toolbar's `data-reload` is "1" (reload not suppressed for this
|
|
3020
3044
|
* request/port). Mirrors PHP DevAdmin::toolbarJs().
|
|
3021
3045
|
*/
|
|
3022
|
-
function toolbarJs(): string {
|
|
3046
|
+
export function toolbarJs(): string {
|
|
3023
3047
|
return `(function () {
|
|
3024
3048
|
var bar = document.getElementById('tina4-dev-toolbar');
|
|
3025
3049
|
if (!bar) { return; }
|
|
@@ -3029,6 +3053,13 @@ function toolbarJs(): string {
|
|
|
3029
3053
|
el.className = 't4-ok';
|
|
3030
3054
|
el.innerHTML = 'Latest: <strong class="t4-ok">v' + latest + '</strong> — You are up to date!';
|
|
3031
3055
|
}
|
|
3056
|
+
// A check that did not happen is not a clean bill of health. The server
|
|
3057
|
+
// sends latest: null when it could not reach the registry, and saying so is
|
|
3058
|
+
// the whole point -- "up to date" here would be a guess dressed as a fact.
|
|
3059
|
+
function couldNotCheck(el, why) {
|
|
3060
|
+
el.className = 't4-err';
|
|
3061
|
+
el.textContent = 'Could not check for updates' + (why ? ' (' + why + ')' : '');
|
|
3062
|
+
}
|
|
3032
3063
|
function checkVersion() {
|
|
3033
3064
|
if (modal.style.display === 'block') { modal.style.display = 'none'; return; }
|
|
3034
3065
|
modal.style.display = 'block';
|
|
@@ -3037,6 +3068,7 @@ function toolbarJs(): string {
|
|
|
3037
3068
|
el.textContent = 'Checking for updates...';
|
|
3038
3069
|
fetch('/__dev/api/version-check').then(function (r) { return r.json(); }).then(function (d) {
|
|
3039
3070
|
var latest = d.latest, current = d.current;
|
|
3071
|
+
if (!latest) { couldNotCheck(el, d.error); return; }
|
|
3040
3072
|
if (latest === current) { upToDate(el, latest); return; }
|
|
3041
3073
|
var cP = current.split('.').map(Number), lP = latest.split('.').map(Number);
|
|
3042
3074
|
var isNewer = false, i, c, l;
|
|
@@ -206,6 +206,69 @@ function etagMatchesInm(ifNoneMatch: string, etag: string): boolean {
|
|
|
206
206
|
});
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
function maybeCompressResponse(body: Buffer, rawReq: IncomingMessage, rawRes: ServerResponse): Buffer {
|
|
210
|
+
const acceptEncoding = String(rawReq.headers["accept-encoding"] ?? "");
|
|
211
|
+
const contentTypeHeader = rawRes.getHeader("content-type");
|
|
212
|
+
const contentType = typeof contentTypeHeader === "string" ? contentTypeHeader : "";
|
|
213
|
+
const alreadyEncoded = !!rawRes.getHeader("content-encoding");
|
|
214
|
+
if (alreadyEncoded || body.length <= 1024 || !acceptEncoding.includes("gzip") || !isCompressibleContentType(contentType)) {
|
|
215
|
+
return body;
|
|
216
|
+
}
|
|
217
|
+
const compressed = gzipSync(body, { level: 6 });
|
|
218
|
+
rawRes.setHeader("Content-Encoding", "gzip");
|
|
219
|
+
rawRes.setHeader("Vary", "Accept-Encoding");
|
|
220
|
+
return compressed;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function responseIsNotModified(rawReq: IncomingMessage, rawRes: ServerResponse, etag: string): boolean {
|
|
224
|
+
const ifNoneMatch = String(rawReq.headers["if-none-match"] ?? "");
|
|
225
|
+
if (ifNoneMatch) return etagMatchesInm(ifNoneMatch, etag);
|
|
226
|
+
const lastModifiedHeader = rawRes.getHeader("last-modified");
|
|
227
|
+
if (typeof lastModifiedHeader !== "string") return false;
|
|
228
|
+
const ifModifiedSince = String(rawReq.headers["if-modified-since"] ?? "");
|
|
229
|
+
if (!ifModifiedSince) return false;
|
|
230
|
+
const modified = Date.parse(lastModifiedHeader);
|
|
231
|
+
const since = Date.parse(ifModifiedSince);
|
|
232
|
+
return !Number.isNaN(modified) && !Number.isNaN(since) && modified <= since;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function clearNotModifiedHeaders(rawRes: ServerResponse): void {
|
|
236
|
+
rawRes.statusCode = 304;
|
|
237
|
+
rawRes.removeHeader("Content-Type");
|
|
238
|
+
rawRes.removeHeader("Content-Encoding");
|
|
239
|
+
rawRes.removeHeader("Vary");
|
|
240
|
+
rawRes.removeHeader("Content-Length");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function applyResponseValidator(rawReq: IncomingMessage, rawRes: ServerResponse, body: Buffer): boolean {
|
|
244
|
+
if ((rawRes.statusCode || 200) !== 200 || body.length === 0) return false;
|
|
245
|
+
let etag = rawRes.getHeader("etag");
|
|
246
|
+
if (!etag) {
|
|
247
|
+
etag = `"${createHash("md5").update(body).digest("hex").slice(0, 16)}"`;
|
|
248
|
+
rawRes.setHeader("ETag", etag);
|
|
249
|
+
}
|
|
250
|
+
if (!responseIsNotModified(rawReq, rawRes, String(etag))) return false;
|
|
251
|
+
clearNotModifiedHeaders(rawRes);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function endBufferedResponse(
|
|
256
|
+
rawReq: IncomingMessage,
|
|
257
|
+
rawRes: ServerResponse,
|
|
258
|
+
body: Buffer,
|
|
259
|
+
realCb: ((...args: any[]) => void) | undefined,
|
|
260
|
+
origEnd: (...args: any[]) => any,
|
|
261
|
+
): any {
|
|
262
|
+
const finalBody = maybeCompressResponse(body, rawReq, rawRes);
|
|
263
|
+
if (applyResponseValidator(rawReq, rawRes, finalBody)) {
|
|
264
|
+
return typeof realCb === "function" ? origEnd(realCb) : origEnd();
|
|
265
|
+
}
|
|
266
|
+
if (!rawRes.headersSent && (rawRes.statusCode || 200) !== 304) {
|
|
267
|
+
rawRes.setHeader("Content-Length", finalBody.length);
|
|
268
|
+
}
|
|
269
|
+
return typeof realCb === "function" ? origEnd(finalBody, realCb) : origEnd(finalBody);
|
|
270
|
+
}
|
|
271
|
+
|
|
209
272
|
/**
|
|
210
273
|
* Gzip-compress + attach an ETag, and answer a matching conditional GET with
|
|
211
274
|
* a 304 that PRESERVES whichever validators the 200 would have carried
|
|
@@ -276,74 +339,9 @@ export function compressionEtagIntercept(rawReq: IncomingMessage, rawRes: Server
|
|
|
276
339
|
|
|
277
340
|
const buf = toBuffer(chunk, typeof encodingOrCb === "string" ? encodingOrCb : undefined);
|
|
278
341
|
if (buf) chunks.push(buf);
|
|
279
|
-
let body = chunks.length > 0 ? Buffer.concat(chunks) : Buffer.alloc(0);
|
|
280
342
|
const realCb = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
// Compression: body > 1024 bytes AND Accept-Encoding offers gzip AND the
|
|
284
|
-
// content type is compressible. Applies to ANY response (matches every
|
|
285
|
-
// route, not status-gated), same as the Python master.
|
|
286
|
-
//
|
|
287
|
-
// REAL BUG (found 2026-08-13, tina4cssServed.test.ts): a static-file
|
|
288
|
-
// response (static.ts) already gzips itself and sets Content-Encoding
|
|
289
|
-
// before calling res.raw.end() — but that end() is THIS intercepted one,
|
|
290
|
-
// so without the guard below it gzipped an already-gzipped body a second
|
|
291
|
-
// time. The client's one layer of automatic decompression then handed
|
|
292
|
-
// back a still-gzipped blob instead of the real bytes. Skip compression
|
|
293
|
-
// here whenever an earlier stage already set Content-Encoding.
|
|
294
|
-
const acceptEncoding = String(rawReq.headers["accept-encoding"] ?? "");
|
|
295
|
-
const contentTypeHeader = rawRes.getHeader("content-type");
|
|
296
|
-
const contentType = typeof contentTypeHeader === "string" ? contentTypeHeader : "";
|
|
297
|
-
const alreadyEncoded = !!rawRes.getHeader("content-encoding");
|
|
298
|
-
if (!alreadyEncoded && body.length > 1024 && acceptEncoding.includes("gzip") && isCompressibleContentType(contentType)) {
|
|
299
|
-
body = gzipSync(body, { level: 6 });
|
|
300
|
-
rawRes.setHeader("Content-Encoding", "gzip");
|
|
301
|
-
rawRes.setHeader("Vary", "Accept-Encoding");
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (statusCode === 200 && body.length > 0) {
|
|
305
|
-
// ETag: a strong md5 hash (first 16 hex chars) over the FINAL
|
|
306
|
-
// (post-compression) body, UNLESS a validator is already set — a
|
|
307
|
-
// static-file response (static.ts) pins its own weak size+mtime ETag
|
|
308
|
-
// before this ever runs (CE-DEC-02), so this never overwrites it with
|
|
309
|
-
// a content hash.
|
|
310
|
-
let etag = rawRes.getHeader("etag");
|
|
311
|
-
if (!etag) {
|
|
312
|
-
etag = `"${createHash("md5").update(body).digest("hex").slice(0, 16)}"`;
|
|
313
|
-
rawRes.setHeader("ETag", etag);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Conditional GET -> 304, preserving whichever validators are set.
|
|
317
|
-
// If-None-Match takes precedence over If-Modified-Since (RFC 9110 S13.1.3).
|
|
318
|
-
const ifNoneMatch = String(rawReq.headers["if-none-match"] ?? "");
|
|
319
|
-
const lastModifiedHeader = rawRes.getHeader("last-modified");
|
|
320
|
-
const lastModified = typeof lastModifiedHeader === "string" ? lastModifiedHeader : "";
|
|
321
|
-
let notModified = false;
|
|
322
|
-
if (ifNoneMatch) {
|
|
323
|
-
notModified = etagMatchesInm(ifNoneMatch, String(etag));
|
|
324
|
-
} else if (lastModified) {
|
|
325
|
-
const ifModifiedSince = String(rawReq.headers["if-modified-since"] ?? "");
|
|
326
|
-
if (ifModifiedSince) {
|
|
327
|
-
const modified = Date.parse(lastModified);
|
|
328
|
-
const since = Date.parse(ifModifiedSince);
|
|
329
|
-
notModified = !Number.isNaN(modified) && !Number.isNaN(since) && modified <= since;
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
if (notModified) {
|
|
334
|
-
rawRes.statusCode = 304;
|
|
335
|
-
rawRes.removeHeader("Content-Type");
|
|
336
|
-
rawRes.removeHeader("Content-Encoding");
|
|
337
|
-
rawRes.removeHeader("Vary");
|
|
338
|
-
rawRes.removeHeader("Content-Length");
|
|
339
|
-
return typeof realCb === "function" ? origEnd(realCb) : origEnd();
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
if (!rawRes.headersSent && statusCode !== 304) {
|
|
344
|
-
rawRes.setHeader("Content-Length", body.length);
|
|
345
|
-
}
|
|
346
|
-
return typeof realCb === "function" ? origEnd(body, realCb) : origEnd(body);
|
|
343
|
+
const body = chunks.length > 0 ? Buffer.concat(chunks) : Buffer.alloc(0);
|
|
344
|
+
return endBufferedResponse(rawReq, rawRes, body, realCb, origEnd);
|
|
347
345
|
}) as typeof rawRes.end;
|
|
348
346
|
}
|
|
349
347
|
|