@jigs.design/server 0.2.0 → 0.3.1

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.
@@ -0,0 +1,155 @@
1
+ import { INTERNAL_MESSAGE, JigsError, isJigsError } from "./errors.js";
2
+ import { createHandler, issuesText, parseBounded } from "./handler.js";
3
+ import { MAX_CONTENT, runSession } from "./session.js";
4
+ import { createReplyParser, repairBlock, streamChat } from "./chat.js";
5
+ import { z } from "zod";
6
+ //#region src/model-server.ts
7
+ /**
8
+ * What `jigs.chat` reads from the route besides the request. `blocks` and
9
+ * `prompt` come from the browser, so they are checked like a session's
10
+ * messages.
11
+ */
12
+ const chatInputSchema = z.object({
13
+ blocks: z.array(z.object({
14
+ name: z.string().max(200),
15
+ source: z.string().max(MAX_CONTENT)
16
+ })).max(200).optional(),
17
+ prompt: z.string().max(MAX_CONTENT).optional()
18
+ });
19
+ /** The core with a model: `session`, `chat`, and a handler that runs turns. */
20
+ function createModelServer(internals, core, options) {
21
+ const system = core.prompt("document");
22
+ const chatSystem = core.prompt("chat");
23
+ const maxRepairs = options.maxRepairs ?? 1;
24
+ const programIntervalMs = options.programIntervalMs ?? 250;
25
+ const designRepair = new Set(options.designRepair ?? []);
26
+ /** A session request checked, its options parsed, and a named document resolved for `ctx`. */
27
+ const prepareSession = async (body, call) => {
28
+ let turnOptions;
29
+ if (options.options === void 0) turnOptions = void 0;
30
+ else {
31
+ const parsed = options.options.safeParse(body.options ?? {});
32
+ if (!parsed.success) return { error: {
33
+ code: "BAD_INPUT",
34
+ message: `options: ${issuesText(parsed.error)}`
35
+ } };
36
+ turnOptions = parsed.data;
37
+ }
38
+ const turn = await internals.prepareSession(body, call);
39
+ return "error" in turn ? turn : {
40
+ options: turnOptions,
41
+ turn
42
+ };
43
+ };
44
+ const sessionDeps = {
45
+ checker: internals.checker,
46
+ designRepair,
47
+ host: internals.host,
48
+ kind: "session",
49
+ maxRepairs,
50
+ model: options.model,
51
+ onError: internals.onError,
52
+ onTurn: options.onTurn,
53
+ programIntervalMs,
54
+ system,
55
+ systemPrefix: core.promptPrefix("document")
56
+ };
57
+ const chat = async (request, chatOptions) => {
58
+ const refused = internals.handlerDeps.http.refusal(request);
59
+ if (refused !== null) throw new JigsError(refused.code, refused.message);
60
+ let ctx;
61
+ try {
62
+ ctx = await internals.context(request);
63
+ } catch (error) {
64
+ if (isJigsError(error)) throw error;
65
+ internals.onError(error, { type: "chat" });
66
+ throw new JigsError("INTERNAL", INTERNAL_MESSAGE);
67
+ }
68
+ const input = parseBounded(() => chatInputSchema.safeParse({
69
+ blocks: chatOptions?.blocks,
70
+ prompt: chatOptions?.prompt
71
+ }));
72
+ if (!input.success) throw new JigsError("BAD_INPUT", input.message);
73
+ let turnOptions;
74
+ if (options.options === void 0) turnOptions = void 0;
75
+ else {
76
+ const schema = options.options;
77
+ const parsed = parseBounded(() => schema.safeParse(chatOptions?.options ?? {}));
78
+ if (!parsed.success) throw new JigsError("BAD_INPUT", `options: ${parsed.message}`);
79
+ turnOptions = parsed.data;
80
+ }
81
+ const turnCall = {
82
+ ctx,
83
+ signal: chatOptions?.signal ?? new AbortController().signal
84
+ };
85
+ const repairRounds = Math.max(0, maxRepairs - 1);
86
+ const parser = createReplyParser({
87
+ catalogVersion: internals.host.components.version,
88
+ compile: (body) => internals.checker.compile(body),
89
+ designRepair,
90
+ now: () => performance.now(),
91
+ programIntervalMs,
92
+ repairs: maxRepairs > 0,
93
+ tags: internals.checker.tags
94
+ }, input.data.blocks ?? []);
95
+ const turnDeps = {
96
+ call: turnCall,
97
+ options: turnOptions,
98
+ parser,
99
+ prompt: input.data.prompt,
100
+ session: {
101
+ ...sessionDeps,
102
+ kind: "chat-repair",
103
+ maxRepairs: repairRounds
104
+ }
105
+ };
106
+ const repair = (block) => repairBlock(turnDeps, block);
107
+ return {
108
+ end: parser.end,
109
+ push: parser.push,
110
+ repair,
111
+ results: parser.results,
112
+ stream: (reply) => streamChat({
113
+ end: parser.end,
114
+ push: parser.push,
115
+ repair
116
+ }, reply, chatOptions?.signal),
117
+ system: chatSystem,
118
+ systemPrefix: core.promptPrefix("chat")
119
+ };
120
+ };
121
+ return {
122
+ call: core.call,
123
+ chat,
124
+ compile: core.compile,
125
+ data: core.data,
126
+ events: core.events,
127
+ frame: core.frame,
128
+ handler: createHandler({
129
+ ...internals.handlerDeps,
130
+ session: async (body, call) => {
131
+ const prepared = await prepareSession(body, call);
132
+ if ("error" in prepared) return prepared;
133
+ return { turn: (turnCall) => runSession(sessionDeps, prepared.turn, prepared.options, turnCall) };
134
+ }
135
+ }),
136
+ maxBodyBytes: core.maxBodyBytes,
137
+ page: core.page,
138
+ prompt: core.prompt,
139
+ promptPrefix: core.promptPrefix,
140
+ async *session(body, context) {
141
+ const prepared = await prepareSession(body, context);
142
+ if ("error" in prepared) {
143
+ yield {
144
+ error: prepared.error,
145
+ type: "error"
146
+ };
147
+ return;
148
+ }
149
+ yield* runSession(sessionDeps, prepared.turn, prepared.options, context);
150
+ },
151
+ version: core.version
152
+ };
153
+ }
154
+ //#endregion
155
+ export { createModelServer };
package/dist/session.d.ts CHANGED
@@ -3,5 +3,4 @@ import "./compile.js";
3
3
  import "./types.js";
4
4
  import "@jigs.design/compiler";
5
5
  import { Host } from "@jigs.design/host";
6
- import { Diagnostic, Message, SessionEvent } from "@jigs.design/protocol";
7
- import { z } from "zod";
6
+ import { Diagnostic, JigsErrorData, Message, SessionEvent } from "@jigs.design/protocol";
package/dist/session.js CHANGED
@@ -1,15 +1,60 @@
1
1
  import { errorData, isJigsError } from "./errors.js";
2
2
  import { belowPrefix, pragmaLine, splitPragma, versionDiagnostics, withPragma } from "./pragma.js";
3
3
  import { reorderDeclarations } from "./rewrite.js";
4
+ import { issuesText } from "./handler.js";
4
5
  import { addUsage } from "./usage.js";
5
6
  import { applyEdits, editBreakage, isEditReply, parseEdits } from "@jigs.design/compiler";
6
- import { editRepairText, fullDocumentText, needsRepair, repairText } from "@jigs.design/prompt";
7
+ import { editRepairText, fullDocumentText, needsRepair, repairText, withDocument } from "@jigs.design/prompt";
7
8
  import { z } from "zod";
8
9
  const MAX_CONTENT = 2e5;
9
- const messagesSchema = z.array(z.object({
10
- content: z.string().max(MAX_CONTENT),
11
- role: z.enum(["user", "assistant"])
12
- })).min(1).max(200);
10
+ const documentSchema = z.union([z.strictObject({ source: z.string().min(1).max(MAX_CONTENT) }), z.strictObject({
11
+ name: z.string().min(1).max(200),
12
+ props: z.record(z.string(), z.union([
13
+ z.boolean(),
14
+ z.number(),
15
+ z.string()
16
+ ])).exactOptional()
17
+ })]);
18
+ const sessionSchema = z.object({
19
+ document: documentSchema.optional(),
20
+ messages: z.array(z.object({
21
+ content: z.string().max(MAX_CONTENT),
22
+ role: z.enum(["user", "assistant"])
23
+ })).max(200)
24
+ });
25
+ /** Whether a turn calls the model: its conversation ends with an ask. */
26
+ const asksModel = (messages) => messages.at(-1)?.role === "user";
27
+ /**
28
+ * Checks a session request's `document` and `messages`: their shape and
29
+ * limits, then that the turn has something to do. A reply with no document
30
+ * is history the model could not act on, so it is refused.
31
+ */
32
+ function parseSessionInput(body) {
33
+ const parsed = sessionSchema.safeParse({
34
+ document: body.document,
35
+ messages: body.messages
36
+ });
37
+ if (!parsed.success) return { error: {
38
+ code: "BAD_INPUT",
39
+ message: issuesText(parsed.error)
40
+ } };
41
+ const document = parsed.data.document ?? null;
42
+ const messages = parsed.data.messages;
43
+ if (document === null) {
44
+ if (messages.length === 0) return { error: {
45
+ code: "BAD_INPUT",
46
+ message: "Send a document, a message, or both."
47
+ } };
48
+ if (messages.some((message) => message.role === "assistant")) return { error: {
49
+ code: "BAD_INPUT",
50
+ message: "messages holds a reply, so send the document it produced as `document`."
51
+ } };
52
+ }
53
+ return {
54
+ document,
55
+ messages
56
+ };
57
+ }
13
58
  const modelResult = z.object({ usage: z.object({
14
59
  cacheReadTokens: z.number().nonnegative().exactOptional(),
15
60
  cacheWriteTokens: z.number().nonnegative().exactOptional(),
@@ -58,20 +103,41 @@ function applyChecked(base, edits, compile) {
58
103
  reason: "broken"
59
104
  };
60
105
  }
61
- const lastAssistantBody = (messages) => {
62
- for (let index = messages.length - 1; index >= 0; index--) {
63
- const message = messages[index];
64
- if (message?.role === "assistant") return splitPragma(message.content).body;
65
- }
66
- return "";
67
- };
68
106
  /**
69
- * One turn. A conversation that ends with an assistant message calls no
70
- * model: its source is checked and compiled again. Otherwise the model's
71
- * reply is compiled at each committed line, edits are applied to the last
72
- * checked source, and errors get up to `maxRepairs` repair rounds.
107
+ * A turn that starts from a document and asks nothing: its source is
108
+ * checked and compiled again, with no model call.
73
109
  */
74
- async function* runSession(deps, messages, options, call) {
110
+ function* restoreTurn(checker, catalogVersion, content, fresh) {
111
+ const restored = splitPragma(content);
112
+ const compiled = checker.compile(restored.body);
113
+ const diagnostics = [...versionDiagnostics(restored.versions, catalogVersion), ...belowPrefix(compiled.diagnostics, restored.versions === null ? pragmaLine(catalogVersion) : content.slice(0, content.length - restored.body.length))];
114
+ yield {
115
+ diagnostics,
116
+ fresh,
117
+ program: compiled.program,
118
+ revision: 1,
119
+ type: "program"
120
+ };
121
+ yield {
122
+ diagnostics,
123
+ revision: 1,
124
+ source: restored.versions === null ? withPragma(restored.body, catalogVersion) : content,
125
+ type: "done"
126
+ };
127
+ }
128
+ /**
129
+ * One turn. A document with a conversation that does not end with an ask
130
+ * calls no model: its source is checked and compiled again. Otherwise the
131
+ * model's reply is compiled at each committed line, edits are applied to
132
+ * the document (or to an empty one, for a new UI), and errors get up to
133
+ * `maxRepairs` repair rounds.
134
+ *
135
+ * In a session turn, every model call sees the current UI in its last user
136
+ * message (`withDocument`), and each round's reply goes into the
137
+ * conversation as written. A chat repair shows no document: its assistant
138
+ * turns hold the checked source its repair texts are numbered by.
139
+ */
140
+ async function* runSession(deps, turn, options, call) {
75
141
  const catalogVersion = deps.host.components.version;
76
142
  /**
77
143
  * Diagnostics as the browser sees them: spans in the sent source, which
@@ -89,41 +155,31 @@ async function* runSession(deps, messages, options, call) {
89
155
  const placement = deps.kind === "session" ? "document" : "chat";
90
156
  /** Whether a round already asked for the design changes `designRepair` names. */
91
157
  let designRepaired = false;
92
- const last = messages.at(-1);
93
- if (last?.role === "assistant") {
94
- const restored = splitPragma(last.content);
95
- const compiled = compile(restored.body);
96
- const diagnostics = [...versionDiagnostics(restored.versions, catalogVersion), ...restored.versions === null ? belowPragma(compiled.diagnostics) : belowPrefix(compiled.diagnostics, last.content.slice(0, last.content.length - restored.body.length))];
97
- yield {
98
- diagnostics,
99
- fresh: deps.kind === "session",
100
- program: compiled.program,
101
- revision: 1,
102
- type: "program"
103
- };
104
- yield {
105
- diagnostics,
106
- revision: 1,
107
- source: restored.versions === null ? withPragma(restored.body, catalogVersion) : last.content,
108
- type: "done"
109
- };
110
- return;
111
- }
112
- if (deps.model === void 0) {
113
- yield {
114
- error: {
115
- code: "INTERNAL",
116
- message: "This server has no model."
117
- },
118
- type: "error"
119
- };
158
+ const { messages } = turn;
159
+ if (turn.document !== null && !asksModel(messages)) {
160
+ yield* restoreTurn(deps.checker, catalogVersion, turn.document, true);
120
161
  return;
121
162
  }
122
163
  const conversation = messages.map((message) => message.role === "assistant" ? {
123
164
  content: splitPragma(message.content).body,
124
165
  role: "assistant"
125
166
  } : message);
126
- let current = lastAssistantBody(messages);
167
+ /** The UI the turn edits; empty for a new one. */
168
+ let current = turn.document === null ? "" : splitPragma(turn.document).body;
169
+ /** Whether model calls show `current`: from the start with a document, after the first round without. */
170
+ let showsDocument = deps.kind === "session" && turn.document !== null;
171
+ /**
172
+ * The conversation as `model` gets it, a copy the turn does not grow:
173
+ * the last user message framed with `current` when the call shows it.
174
+ */
175
+ const request = () => {
176
+ const ask = conversation.at(-1);
177
+ if (!showsDocument || ask?.role !== "user") return [...conversation];
178
+ return [...conversation.slice(0, -1), {
179
+ content: withDocument(ask.content, current),
180
+ role: "user"
181
+ }];
182
+ };
127
183
  let revision = 0;
128
184
  /**
129
185
  * Whether the next program starts a new UI: the first one of a session
@@ -166,7 +222,7 @@ async function* runSession(deps, messages, options, call) {
166
222
  const iterator = deps.model({
167
223
  ctx: call.ctx,
168
224
  kind: kindOf(round),
169
- messages: conversation,
225
+ messages: request(),
170
226
  options,
171
227
  signal: call.signal,
172
228
  system: deps.system,
@@ -219,6 +275,8 @@ async function* runSession(deps, messages, options, call) {
219
275
  iterator.return?.();
220
276
  }
221
277
  if (call.signal.aborted) return;
278
+ /** The reply as the model wrote it, without a fence around it. */
279
+ const written = cleanReply(text);
222
280
  let source;
223
281
  let edits;
224
282
  if (isEditReply(text) === true) {
@@ -237,7 +295,7 @@ async function* runSession(deps, messages, options, call) {
237
295
  if (round < deps.maxRepairs) {
238
296
  editFailures++;
239
297
  conversation.push({
240
- content: text,
298
+ content: written,
241
299
  role: "assistant"
242
300
  }, {
243
301
  content: editFailures === 1 ? editRepairText(applied, current) : fullDocumentText(applied, current),
@@ -256,7 +314,7 @@ async function* runSession(deps, messages, options, call) {
256
314
  return;
257
315
  }
258
316
  source = applied.text;
259
- } else source = cleanReply(text);
317
+ } else source = written;
260
318
  let compiled = compile(source);
261
319
  const reordered = reorderDeclarations(source, compiled, compile);
262
320
  if (reordered !== null) {
@@ -289,14 +347,18 @@ async function* runSession(deps, messages, options, call) {
289
347
  const chosen = designRepaired ? [] : advice.filter((entry) => deps.designRepair.has(entry.code));
290
348
  if ((needsRepair(compiled.diagnostics) || chosen.length > 0) && round < deps.maxRepairs) {
291
349
  designRepaired ||= chosen.length > 0;
292
- conversation.push({
350
+ conversation.push(deps.kind === "session" ? {
351
+ content: written,
352
+ role: "assistant"
353
+ } : {
293
354
  content: source,
294
355
  role: "assistant"
295
356
  }, {
296
- content: repairText(compiled.diagnostics, chosen),
357
+ content: repairText(compiled.diagnostics, chosen, deps.kind === "session" ? "shown" : "answer"),
297
358
  role: "user"
298
359
  });
299
360
  current = source;
361
+ showsDocument = deps.kind === "session";
300
362
  repairs++;
301
363
  continue;
302
364
  }
@@ -306,6 +368,7 @@ async function* runSession(deps, messages, options, call) {
306
368
  shown("done");
307
369
  yield {
308
370
  diagnostics: finalDiagnostics,
371
+ reply: round === 0 && reordered === null ? written : source,
309
372
  revision,
310
373
  source: finalSource,
311
374
  type: "done"
@@ -334,6 +397,7 @@ async function* runSession(deps, messages, options, call) {
334
397
  completed: finalSource !== null,
335
398
  ctx: call.ctx,
336
399
  diagnostics: finalDiagnostics,
400
+ document: turn.document,
337
401
  firstProgramMs,
338
402
  kind: deps.kind,
339
403
  messages,
@@ -349,4 +413,4 @@ async function* runSession(deps, messages, options, call) {
349
413
  }
350
414
  }
351
415
  //#endregion
352
- export { MAX_CONTENT, applyChecked, editResult, messagesSchema, runSession };
416
+ export { MAX_CONTENT, applyChecked, asksModel, editResult, parseSessionInput, restoreTurn, runSession };