pgkiln 0.78.0 → 0.80.0

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/public/app.js CHANGED
@@ -4041,6 +4041,89 @@ function areaFilter(map, f) {
4041
4041
  btn.addEventListener('click', (x) => x.preventDefault());
4042
4042
  }
4043
4043
  });
4044
+ // (sprint 148, WS-4) the answer as the model writes it: the message is posted with fetch asking for server-sent
4045
+ // events; "text" pieces are added as text (never as HTML), "tool" names the tool being used, and "done" loads the
4046
+ // page, which shows the saved answer formatted, its links and actions, or the error. Without fetch streams (or when
4047
+ // the server answers with a page, e.g. a refusal) the form is sent the ordinary way.
4048
+ const streams = typeof ReadableStream !== 'undefined' && typeof TextDecoder !== 'undefined' && typeof fetch === 'function';
4049
+ const go = (url) => {
4050
+ const u = new URL(url, location.href);
4051
+ if (u.pathname === location.pathname && u.search === location.search) {
4052
+ history.replaceState(history.state, '', u);
4053
+ location.reload();
4054
+ } else location.assign(u);
4055
+ };
4056
+ async function streamAnswer(f) {
4057
+ const box = document.querySelector(`textarea[form="${CSS.escape(f.id)}"]`);
4058
+ const wrap = box?.closest('.assistant');
4059
+ const log = wrap?.querySelector('.assistant-log');
4060
+ const entry = (role, label) => {
4061
+ const li = document.createElement('li');
4062
+ li.className = `assistant-msg assistant-msg-${role}`;
4063
+ const who = document.createElement('span');
4064
+ who.className = 'assistant-who';
4065
+ who.textContent = label || '';
4066
+ const text = document.createElement('div');
4067
+ text.className = 'assistant-text';
4068
+ li.append(who, text);
4069
+ log.append(li);
4070
+ return { li, text };
4071
+ };
4072
+ const mine = entry('user', wrap.dataset.you);
4073
+ const said = document.createElement('p');
4074
+ said.textContent = box.value;
4075
+ mine.text.append(said);
4076
+ const answer = entry('assistant', wrap.dataset.who);
4077
+ answer.text.setAttribute('aria-busy', 'true');
4078
+ const para = document.createElement('p');
4079
+ para.className = 'assistant-streaming';
4080
+ answer.text.append(para);
4081
+ const status = document.createElement('small');
4082
+ status.className = 'assistant-tools muted';
4083
+ answer.li.append(status);
4084
+ answer.li.scrollIntoView({ block: 'nearest' });
4085
+ let res;
4086
+ try {
4087
+ res = await fetch(f.action, { method: 'POST', body: new URLSearchParams(new FormData(f)), headers: { accept: 'text/event-stream' }, credentials: 'same-origin' });
4088
+ } catch {
4089
+ return HTMLFormElement.prototype.submit.call(f);
4090
+ }
4091
+ if (!res.ok || !(res.headers.get('content-type') || '').startsWith('text/event-stream') || !res.body) return HTMLFormElement.prototype.submit.call(f);
4092
+ const reader = res.body.getReader();
4093
+ const decoder = new TextDecoder();
4094
+ let buf = '';
4095
+ try {
4096
+ for (;;) {
4097
+ const { value, done } = await reader.read();
4098
+ if (done) break;
4099
+ buf += decoder.decode(value, { stream: true });
4100
+ let cut;
4101
+ while ((cut = buf.indexOf('\n\n')) >= 0) {
4102
+ const block = buf.slice(0, cut);
4103
+ buf = buf.slice(cut + 2);
4104
+ const event = /^event: (.*)$/m.exec(block)?.[1];
4105
+ const line = /^data: (.*)$/m.exec(block)?.[1];
4106
+ if (!event || line === undefined) continue;
4107
+ const data = JSON.parse(line);
4108
+ if (event === 'text' && typeof data === 'string') {
4109
+ para.textContent += data;
4110
+ answer.li.scrollIntoView({ block: 'nearest' });
4111
+ } else if (event === 'tool' && typeof data === 'string') status.textContent = (wrap.dataset.used || '{tools}').replace('{tools}', data);
4112
+ else if (event === 'done' && data && typeof data.url === 'string') return go(data.url);
4113
+ }
4114
+ }
4115
+ } catch {
4116
+ // the connection broke: the server still saves the answer; the page shows it
4117
+ }
4118
+ location.reload();
4119
+ }
4120
+ document.addEventListener('submit', (e) => {
4121
+ const f = e.target;
4122
+ if (e.defaultPrevented || !streams || !f.matches || !f.matches('form.assistant-form') || !f.id) return;
4123
+ if (!document.querySelector(`textarea[form="${CSS.escape(f.id)}"]`)?.closest('.assistant')?.querySelector('.assistant-log')) return;
4124
+ e.preventDefault();
4125
+ streamAnswer(f);
4126
+ });
4044
4127
  // Ctrl+Enter (or ⌘+Enter) sends the message
4045
4128
  document.addEventListener('keydown', (e) => {
4046
4129
  const box = e.target;
package/src/ai/chat.ts CHANGED
@@ -52,6 +52,12 @@ export interface ChatRequest {
52
52
  effort?: 'low' | 'medium' | 'high' | null;
53
53
  /** (121) an agent's output limit per call (never above the service's maximum) */
54
54
  maxTokens?: number | null;
55
+ /**
56
+ * (sprint 148, WS-4) streamed answers: the text as the model writes it, in pieces (also the text of a round that
57
+ * then calls tools), and each tool before it runs. Without them a call waits for the whole answer, as before.
58
+ */
59
+ onText?: (delta: string) => void;
60
+ onTool?: (name: string) => void;
55
61
  }
56
62
 
57
63
  export interface ChatResult {
@@ -122,7 +128,7 @@ async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], r
122
128
  const msg = await metered(s, limits, info, async () => {
123
129
  let m: Anthropic.Beta.BetaMessage;
124
130
  try {
125
- m = await client.beta.messages.stream({
131
+ const stream = client.beta.messages.stream({
126
132
  model: s.model,
127
133
  max_tokens: outputLimit(s, req),
128
134
  system: req.system,
@@ -130,7 +136,9 @@ async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], r
130
136
  ...(tools.length ? { tools } : {}),
131
137
  ...((req.effort ?? s.effort) ? { output_config: { effort: (req.effort ?? s.effort)! } } : {}),
132
138
  ...(s.refusal_fallback ? { betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' as const } : {}),
133
- }).finalMessage();
139
+ });
140
+ if (req.onText) stream.on('text', (delta) => req.onText!(delta));
141
+ m = await stream.finalMessage();
134
142
  } catch (e) {
135
143
  throw claudeError(e);
136
144
  }
@@ -150,6 +158,7 @@ async function claudeChat(s: AiService, limits: Parameters<typeof metered>[1], r
150
158
  if (round >= req.maxRounds) throw new AiError('output', TOO_MANY_ROUNDS);
151
159
  const results: Anthropic.Beta.BetaToolResultBlockParam[] = [];
152
160
  for (const call of calls) {
161
+ req.onTool?.(call.name);
153
162
  const out = await req.runTool(call.name, (call.input ?? {}) as Record<string, unknown>);
154
163
  used.push({ name: call.name, ok: !out.isError });
155
164
  results.push({ type: 'tool_result', tool_use_id: call.id, content: clip(out.content), ...(out.isError ? { is_error: true } : {}) });
@@ -169,15 +178,32 @@ async function openAiChat(s: AiService, limits: Parameters<typeof metered>[1], r
169
178
  const res = await metered(s, limits, info, async () => {
170
179
  let r: OpenAI.Chat.ChatCompletion;
171
180
  try {
172
- r = await client.chat.completions.create({
181
+ const params = {
173
182
  model: s.model,
174
183
  // the system prompt is sent with each request, not kept in the history
175
- messages: [{ role: 'system', content: req.system }, ...messages],
184
+ messages: [{ role: 'system' as const, content: req.system }, ...messages],
176
185
  max_completion_tokens: outputLimit(s, req),
177
186
  ...(tools.length ? { tools } : {}),
178
187
  // (121) an agent's reasoning effort; models without reasoning refuse it, so none is sent without one
179
188
  ...(req.effort ? { reasoning_effort: req.effort } : {}),
180
- });
189
+ };
190
+ if (req.onText) {
191
+ // (sprint 148) streamed: the content as it comes; the usage in the last chunk
192
+ // an OpenAI-compatible endpoint that refuses streaming (400) gets the plain request once
193
+ let streamed: OpenAI.Chat.ChatCompletion | null = null;
194
+ let sent = false;
195
+ try {
196
+ const stream = client.chat.completions.stream({ ...params, stream_options: { include_usage: true } });
197
+ stream.on('content', (delta) => {
198
+ sent = true;
199
+ req.onText!(delta);
200
+ });
201
+ streamed = await stream.finalChatCompletion();
202
+ } catch (e) {
203
+ if (sent || (e as { status?: number }).status !== 400) throw e;
204
+ }
205
+ r = streamed ?? (await client.chat.completions.create(params));
206
+ } else r = await client.chat.completions.create(params);
181
207
  } catch (e) {
182
208
  throw openAiError(e);
183
209
  }
@@ -198,6 +224,7 @@ async function openAiChat(s: AiService, limits: Parameters<typeof metered>[1], r
198
224
  for (const call of calls) {
199
225
  let input: Record<string, unknown>;
200
226
  let out: ToolOutcome;
227
+ req.onTool?.(call.function.name);
201
228
  try {
202
229
  input = JSON.parse(call.function.arguments || '{}');
203
230
  out = input && typeof input === 'object' && !Array.isArray(input) ? await req.runTool(call.function.name, input) : { content: 'The arguments are not a JSON object.', isError: true };
package/src/app.ts CHANGED
@@ -99,6 +99,7 @@ import { mailQueueRoutes } from './builder/mail-queue.ts';
99
99
  import { networkAccessRoutes } from './builder/network-access.ts';
100
100
  import { workspaceMailRoutes } from './builder/workspace-mail.ts';
101
101
  import { workspaceResourceRoutes } from './builder/workspace-resources.ts';
102
+ import { remoteDatabaseRoutes } from './builder/remote-databases.ts';
102
103
  import { runtimeScope } from './db.ts';
103
104
  // (sprint 139, AP-6) OpenTelemetry: a trace per request, /metrics, the browser relay
104
105
  import { startTelemetry } from './telemetry/export.ts';
@@ -288,6 +289,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
288
289
  await app.register(networkAccessRoutes);
289
290
  await app.register(workspaceMailRoutes);
290
291
  await app.register(workspaceResourceRoutes);
292
+ await app.register(remoteDatabaseRoutes);
291
293
  // (sprint 138, AP-5) an application's Utilities
292
294
  await app.register(utilityRoutes);
293
295
  await app.register(dependencyRoutes);