run-dmcp 0.9.0 → 0.10.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.
@@ -65,6 +65,22 @@
65
65
  * still-unanswered questions. `attemptsPerTransport` gives one rung its own
66
66
  * retry budget before the ladder gives up on it and moves on.
67
67
  */
68
+ /**
69
+ * The words of a source as a range citation numbers them: each maximal run
70
+ * of non-whitespace, numbered from 1. Exported so a caller that shows its
71
+ * model numbered words builds that numbering from the same function the
72
+ * rebuild uses -- two numberings that disagree by one word would cite the
73
+ * wrong span without any error. Lexical only: punctuation stays on its word
74
+ * and nothing is split by what it means (hard rule 4).
75
+ */
76
+ export function sourceWords(text) {
77
+ const words = [];
78
+ for (const match of text.matchAll(/\S+/g)) {
79
+ const start = match.index ?? 0;
80
+ words.push({ index: words.length + 1, word: match[0], start, end: start + match[0].length });
81
+ }
82
+ return words;
83
+ }
68
84
  /**
69
85
  * Construction-time validation, in the same voice as `validateMechanics`
70
86
  * (resolve.ts) and `validateMigrations` (src/db/schema.ts): every check
@@ -75,7 +91,7 @@
75
91
  * own declared `answerKeys` must be unconstructable, which is why
76
92
  * `safeDefault` membership is checked here rather than trusted.
77
93
  */
78
- function validateQuestions(questions) {
94
+ export function validateQuestions(questions) {
79
95
  const seenIds = new Set();
80
96
  for (const question of questions) {
81
97
  const id = question?.id;
@@ -106,6 +122,18 @@ function validateQuestions(questions) {
106
122
  }
107
123
  }
108
124
  }
125
+ /** A citation names its source by id, so two sources sharing one make a
126
+ * citation ambiguous. Checked everywhere sources are handed over: `read()`
127
+ * (which rejects), `verifyAnswers`, and the MCP verbs over it. Until
128
+ * 2026-09-27 `read()` let the last duplicate win silently. */
129
+ export function validateSourceIds(sources) {
130
+ const seen = new Set();
131
+ for (const source of sources) {
132
+ if (seen.has(source.id))
133
+ throw new Error(`duplicate source id '${source.id}'`);
134
+ seen.add(source.id);
135
+ }
136
+ }
109
137
  function validateAttemptsPerTransport(attemptsPerTransport) {
110
138
  if (!Number.isInteger(attemptsPerTransport) || attemptsPerTransport < 1) {
111
139
  throw new Error(`createTurnReader: 'attemptsPerTransport' must be an integer >= 1, got ${JSON.stringify(attemptsPerTransport)}`);
@@ -145,37 +173,81 @@ export function createTurnReader(params) {
145
173
  * (`String.prototype.includes`, no case folding, no trimming, no fuzzy
146
174
  * matching) inside that source's text. An offer that fails two conjuncts is
147
175
  * rejected either way -- which reason it carries is a reporting detail, not
148
- * a difference in whether it counts. Returns the first failing reason, or
149
- * `null` when the citation is accepted -- see the module doc comment's
150
- * rule-4 discussion for why this literal presence test does not become the
151
- * pattern-matching-meaning it is built beside.
176
+ * a difference in whether it counts. Returns the accepted citation, or the
177
+ * first failing reason -- see the module doc comment's rule-4 discussion for
178
+ * why this literal presence test does not become the pattern-matching-meaning
179
+ * it is built beside.
180
+ *
181
+ * A RANGE (issue #35) is rebuilt into a quote first, then held to the same
182
+ * rule -- which a rebuilt quote always passes, being a slice of the source.
183
+ * Its own conjuncts: both ends integers with 1 <= from <= to
184
+ * (`invalid-range`), and `from` naming a real word (`range-start-past-end`).
185
+ * A `to` past the last word is CLAMPED to it, not rejected: the span cites
186
+ * what is there plus nothing. Dropping those instead was the recorded
187
+ * failure -- short sources overshoot most, and in the consumer that hit it
188
+ * a whole class of four-word intents was never once ruled.
189
+ *
190
+ * A citation that names no span at all -- absent, `{}`, or a source alone
191
+ * -- is `missing-citation`, reported before any of the above. A null field
192
+ * counts as absent throughout.
152
193
  *
153
194
  * Defensive against a citation that is missing entirely or missing a field
154
195
  * -- a transport is caller-supplied code this module does not control at
155
196
  * runtime, and a malformed citation must still be rejected mechanically
156
197
  * rather than throwing out of this function and aborting the whole read.
157
198
  */
158
- function checkCitation(citation, sourcesById) {
159
- const sourceId = citation?.sourceId;
160
- const quote = citation?.quote;
199
+ export function resolveCitation(citation, sourcesById) {
200
+ const record = (typeof citation === "object" && citation !== null ? citation : {});
201
+ const sourceId = record.sourceId;
202
+ // Nothing that could name a span at all -- no citation, `{}`, or a source
203
+ // with neither a quote nor a range. Distinct from `empty-quote` because a
204
+ // reader chasing a quoting problem here would be chasing nothing.
205
+ // null is absent: models emit `"from": null` for a field they did not use.
206
+ const given = (v) => v !== undefined && v !== null;
207
+ if (!given(record.quote) && !given(record.from) && !given(record.to)) {
208
+ return { reason: "missing-citation" };
209
+ }
210
+ if (given(record.from) || given(record.to)) {
211
+ const { from, to } = record;
212
+ if (typeof sourceId !== "string")
213
+ return { reason: "unknown-source-id" };
214
+ const source = sourcesById.get(sourceId);
215
+ if (!source)
216
+ return { reason: "unknown-source-id" };
217
+ if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || from > to) {
218
+ return { reason: "invalid-range" };
219
+ }
220
+ const words = sourceWords(source.text);
221
+ if (from > words.length)
222
+ return { reason: "range-start-past-end" };
223
+ const end = Math.min(to, words.length);
224
+ const quote = source.text.slice(words[from - 1].start, words[end - 1].end);
225
+ return { citation: { sourceId, quote, range: { from: from, to: end } } };
226
+ }
227
+ const quote = record.quote;
161
228
  if (typeof quote !== "string" || quote.length === 0) {
162
- return "empty-quote";
229
+ return { reason: "empty-quote" };
163
230
  }
164
231
  if (typeof sourceId !== "string") {
165
- return "unknown-source-id";
232
+ return { reason: "unknown-source-id" };
166
233
  }
167
234
  const source = sourcesById.get(sourceId);
168
235
  if (!source) {
169
- return "unknown-source-id";
236
+ return { reason: "unknown-source-id" };
170
237
  }
171
238
  if (!source.text.includes(quote)) {
172
- return "quote-not-in-source";
239
+ return { reason: "quote-not-in-source" };
173
240
  }
174
- return null;
241
+ return { citation: { sourceId, quote } };
175
242
  }
176
243
  /**
177
- * Runs the fallback ladder for one `read()` call. `accepted` accumulates
178
- * across rungs, keyed by `questionId` -- once a question is in this map its
244
+ * The per-read bookkeeping every offer goes through, whichever rung (or, for
245
+ * `verifyAnswers`, whichever caller) produced it. There is exactly one path
246
+ * from "an offer" to "accepted" or "a rejected row with a reason", and this
247
+ * is it -- so the ladder and a one-shot verification cannot disagree about
248
+ * what counts.
249
+ *
250
+ * `accepted` is keyed by `questionId` -- once a question is in this map its
179
251
  * answer is FINAL for this read: the next rung's request omits it (rule 4,
180
252
  * "questions already answered validly are NOT re-asked"), and any further
181
253
  * offer for it from any rung -- including a non-compliant transport that
@@ -186,103 +258,185 @@ function checkCitation(citation, sourcesById) {
186
258
  * both are the same check, `accepted.has(questionId)`, evaluated at the
187
259
  * moment each offer is processed.
188
260
  */
189
- async function runLadder(questions, transports, attemptsPerTransport, sources) {
261
+ function createTally(questions, sources) {
190
262
  const questionsById = new Map(questions.map((q) => [q.id, q]));
191
263
  const sourcesById = new Map(sources.map((s) => [s.id, s]));
192
264
  const accepted = new Map();
193
265
  const rejectedByQuestion = new Map();
266
+ const askedByQuestion = new Map();
194
267
  const unmatched = [];
195
- function reject(reason, rung, offer) {
268
+ function reject(reason, rung, offer, questionId) {
196
269
  const row = { reason, rung, offer };
197
- const question = questionsById.get(offer.questionId);
198
- if (!question) {
270
+ if (questionId === null || !questionsById.has(questionId)) {
199
271
  unmatched.push(row);
200
272
  return;
201
273
  }
202
- const existing = rejectedByQuestion.get(offer.questionId);
274
+ const existing = rejectedByQuestion.get(questionId);
203
275
  if (existing) {
204
276
  existing.push(row);
205
277
  }
206
278
  else {
207
- rejectedByQuestion.set(offer.questionId, [row]);
279
+ rejectedByQuestion.set(questionId, [row]);
208
280
  }
209
281
  }
282
+ return {
283
+ remaining() {
284
+ return questions.filter((q) => !accepted.has(q.id));
285
+ },
286
+ asked(rung, asked) {
287
+ for (const q of asked) {
288
+ const rungs = askedByQuestion.get(q.id);
289
+ if (rungs)
290
+ rungs.push(rung);
291
+ else
292
+ askedByQuestion.set(q.id, [rung]);
293
+ }
294
+ },
295
+ offer(rung, offer) {
296
+ // A transport is caller code: an entry may not be an object at all.
297
+ // That is a row, never a throw that loses every other offer in the list.
298
+ if (typeof offer !== "object" || offer === null || typeof offer.questionId !== "string") {
299
+ reject("malformed-offer", rung, offer, null);
300
+ return;
301
+ }
302
+ const question = questionsById.get(offer.questionId);
303
+ if (!question) {
304
+ reject("unknown-question", rung, offer, null);
305
+ return;
306
+ }
307
+ if (accepted.has(offer.questionId)) {
308
+ reject("duplicate-answer", rung, offer, offer.questionId);
309
+ return;
310
+ }
311
+ if (!question.answerKeys.includes(offer.answerKey)) {
312
+ // Coercion to keys that ACTUALLY exist -- exact membership or
313
+ // nothing (rule 2). No fuzzy match, no case fold, no "nearest key".
314
+ reject("unknown-answer-key", rung, offer, offer.questionId);
315
+ return;
316
+ }
317
+ const cited = resolveCitation(offer.citation, sourcesById);
318
+ if ("reason" in cited) {
319
+ reject(cited.reason, rung, offer, offer.questionId);
320
+ return;
321
+ }
322
+ accepted.set(offer.questionId, { answerKey: offer.answerKey, rung, citation: cited.citation, offer });
323
+ },
324
+ answers() {
325
+ return questions.map((question) => {
326
+ const rejected = rejectedByQuestion.get(question.id) ?? [];
327
+ const askedOfRungs = askedByQuestion.get(question.id) ?? [];
328
+ const win = accepted.get(question.id);
329
+ if (win) {
330
+ return {
331
+ questionId: question.id,
332
+ answerKey: win.answerKey,
333
+ fromSafeDefault: false,
334
+ answeredByRung: win.rung,
335
+ citation: win.citation,
336
+ acceptedOffer: win.offer,
337
+ askedOfRungs,
338
+ rejected,
339
+ };
340
+ }
341
+ // Every rung that could answer this question was exhausted (or none
342
+ // were ever registered) -- the caller's own declared safe direction,
343
+ // never the engine's guess (rule 3).
344
+ return {
345
+ questionId: question.id,
346
+ answerKey: question.safeDefault,
347
+ fromSafeDefault: true,
348
+ answeredByRung: null,
349
+ citation: null,
350
+ acceptedOffer: null,
351
+ askedOfRungs,
352
+ rejected,
353
+ };
354
+ });
355
+ },
356
+ unmatched() {
357
+ return unmatched;
358
+ },
359
+ };
360
+ }
361
+ /**
362
+ * Runs the fallback ladder for one `read()` call, recording what each rung
363
+ * that was called came back as (issue #36) alongside the answers.
364
+ */
365
+ async function runLadder(questions, transports, attemptsPerTransport, sources) {
366
+ validateSourceIds(sources);
367
+ const tally = createTally(questions, sources);
368
+ const rungs = [];
210
369
  for (let rung = 0; rung < transports.length; rung++) {
211
- const remaining = questions.filter((q) => !accepted.has(q.id));
370
+ const remaining = tally.remaining();
212
371
  if (remaining.length === 0)
213
372
  break; // every question already answered -- nothing left for any further rung
214
373
  const transport = transports[rung];
374
+ const attempts = [];
215
375
  let answers = null;
376
+ tally.asked(rung, remaining);
216
377
  for (let attempt = 0; attempt < attemptsPerTransport; attempt++) {
217
378
  try {
218
379
  const result = await transport({ questions: remaining, sources });
219
380
  if (Array.isArray(result)) {
381
+ attempts.push({ outcome: "answered", offers: result.length });
220
382
  answers = result;
221
383
  break;
222
384
  }
223
385
  // Unusable output -- treated identically to a throw: retry within
224
386
  // this rung's budget, then fall through to the next rung.
387
+ attempts.push({ outcome: "not-a-list" });
225
388
  }
226
- catch {
389
+ catch (error) {
227
390
  // Threw or rejected -- retry within this rung's budget, then fall
228
- // through to the next rung. The engine does not distinguish WHY a
229
- // rung failed; it only advances.
391
+ // through to the next rung. What was thrown is recorded as given;
392
+ // the engine does not interpret it.
393
+ attempts.push({ outcome: "threw", error: String(error) });
230
394
  }
231
395
  }
396
+ rungs.push({ rung, asked: remaining.map((q) => q.id), attempts });
232
397
  if (answers === null)
233
398
  continue; // rung exhausted; the next rung sees the same `remaining` set
234
- for (const offer of answers) {
235
- const question = questionsById.get(offer.questionId);
236
- if (!question) {
237
- reject("unknown-question", rung, offer);
238
- continue;
239
- }
240
- if (accepted.has(offer.questionId)) {
241
- reject("duplicate-answer", rung, offer);
242
- continue;
243
- }
244
- if (!question.answerKeys.includes(offer.answerKey)) {
245
- // Coercion to keys that ACTUALLY exist -- exact membership or
246
- // nothing (rule 2). No fuzzy match, no case fold, no "nearest key".
247
- reject("unknown-answer-key", rung, offer);
248
- continue;
249
- }
250
- const citationProblem = checkCitation(offer.citation, sourcesById);
251
- if (citationProblem) {
252
- reject(citationProblem, rung, offer);
253
- continue;
254
- }
255
- accepted.set(offer.questionId, {
256
- answerKey: offer.answerKey,
257
- rung,
258
- citation: { sourceId: offer.citation.sourceId, quote: offer.citation.quote },
259
- });
260
- }
399
+ for (const offer of answers)
400
+ tally.offer(rung, offer);
261
401
  }
262
- const answersOut = questions.map((question) => {
263
- const rejected = rejectedByQuestion.get(question.id) ?? [];
264
- const win = accepted.get(question.id);
265
- if (win) {
266
- return {
267
- questionId: question.id,
268
- answerKey: win.answerKey,
269
- fromSafeDefault: false,
270
- answeredByRung: win.rung,
271
- citation: win.citation,
272
- rejected,
273
- };
274
- }
275
- // Every rung that could answer this question was exhausted (or none
276
- // were ever registered) -- the caller's own declared safe direction,
277
- // never the engine's guess (rule 3).
278
- return {
279
- questionId: question.id,
280
- answerKey: question.safeDefault,
281
- fromSafeDefault: true,
282
- answeredByRung: null,
283
- citation: null,
284
- rejected,
285
- };
286
- });
287
- return { answers: answersOut, unmatched };
402
+ return { answers: tally.answers(), unmatched: tally.unmatched(), rungs };
403
+ }
404
+ /**
405
+ * Verifies one list of answers a caller's own model produced, by the same
406
+ * rule a `read()` applies to a rung's offers (GitHub issue #39: intent in,
407
+ * ruling out, with the ruling model outside the engine). For a caller that
408
+ * cannot hand the engine a transport -- a client reaching it over MCP, whose
409
+ * model runs on its own side -- this is the second of the verb's two steps:
410
+ * the caller builds the request and gets it answered; the engine verifies the
411
+ * answers and returns the ruling. It never infers anything itself.
412
+ *
413
+ * The result is exactly what `createTurnReader({ questions, transports: [t]
414
+ * }).read(sources)` returns when `t` resolves to `offers`, and both refuse
415
+ * duplicate source ids: the offers are rung
416
+ * 0, asked every question, and go through the one tally every rung's offers
417
+ * go through, so the verb and the library cannot disagree about what counts.
418
+ * Synchronous, because there is nothing to wait for.
419
+ *
420
+ * The question set is validated as `createTurnReader` validates it. A
421
+ * non-list `offers` throws rather than reading as "no offers": at this
422
+ * boundary the CALLER parsed its model's reply, so a non-list is the caller's
423
+ * bug, not a rung failure to record. Individual malformed entries inside the
424
+ * list are still rows (`malformed-offer`), as on the ladder.
425
+ */
426
+ export function verifyAnswers(params) {
427
+ validateQuestions(params.questions);
428
+ validateSourceIds(params.sources);
429
+ if (!Array.isArray(params.offers)) {
430
+ throw new Error(`verifyAnswers: offers must be a list of answers, got ${JSON.stringify(params.offers)}`);
431
+ }
432
+ const tally = createTally(params.questions, params.sources);
433
+ const asked = tally.remaining();
434
+ tally.asked(0, asked);
435
+ for (const offer of params.offers)
436
+ tally.offer(0, offer);
437
+ return {
438
+ answers: tally.answers(),
439
+ unmatched: tally.unmatched(),
440
+ rungs: [{ rung: 0, asked: asked.map((q) => q.id), attempts: [{ outcome: "answered", offers: params.offers.length }] }],
441
+ };
288
442
  }
@@ -0,0 +1,16 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type DeclaredRules } from "../timeline/declared.js";
3
+ /**
4
+ * `conditions_at` (GitHub issue #41): a game's DECLARED conditions evaluated
5
+ * at `t` -- which hold, and each clause's observed value. Registered only
6
+ * when a server was given declared rules (createCoreMcpServer's `rules`, or
7
+ * the application's DMCP_RULES_FILE); a server with none has nothing to
8
+ * evaluate and serves no such tool, the same silence `resolve` keeps when no
9
+ * mechanics are registered.
10
+ *
11
+ * With `for`, it is one principal's condition list: the conditions declared
12
+ * for it, in declared order, from the same data that gates the declared
13
+ * mechanics. Rows, never a verdict -- `holds` compares a stored value to a
14
+ * declared one; what that means is the caller's.
15
+ */
16
+ export declare function registerConditionTools(server: McpServer, rules: DeclaredRules): void;
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+ import { ANNOTATIONS } from "../utils/tool-annotations.js";
3
+ import { createLogger } from "../utils/logger.js";
4
+ import { evaluateConditions } from "../timeline/declared.js";
5
+ const log = createLogger("conditions");
6
+ /**
7
+ * `conditions_at` (GitHub issue #41): a game's DECLARED conditions evaluated
8
+ * at `t` -- which hold, and each clause's observed value. Registered only
9
+ * when a server was given declared rules (createCoreMcpServer's `rules`, or
10
+ * the application's DMCP_RULES_FILE); a server with none has nothing to
11
+ * evaluate and serves no such tool, the same silence `resolve` keeps when no
12
+ * mechanics are registered.
13
+ *
14
+ * With `for`, it is one principal's condition list: the conditions declared
15
+ * for it, in declared order, from the same data that gates the declared
16
+ * mechanics. Rows, never a verdict -- `holds` compares a stored value to a
17
+ * declared one; what that means is the caller's.
18
+ */
19
+ export function registerConditionTools(server, rules) {
20
+ server.registerTool("conditions_at", {
21
+ description: "Evaluate this server's declared conditions for a game at t: each condition, whether it holds, and every " +
22
+ "clause's observed value against its declared threshold. Pass `for` to get one principal's condition list " +
23
+ "-- the conditions declared for it, in order, each with its declared 'then' -- from the same data that gates " +
24
+ "the declared mechanics. A structural comparison of stored facts, never a verdict about the game.",
25
+ inputSchema: {
26
+ gameId: z.string().max(100).describe("The game ID"),
27
+ t: z.number().finite().describe("An opaque ordinal on this game's declared time axis."),
28
+ for: z.string().max(200).optional().describe("Only the conditions declared for this principal."),
29
+ parameters: z
30
+ .record(z.string().max(200), z.union([z.string().max(1000), z.number(), z.null()]))
31
+ .optional()
32
+ .describe("Values for conditions that take {param} operands. A condition needing one not given is reported with missingParameter, and does not hold."),
33
+ },
34
+ annotations: ANNOTATIONS.READ_ONLY,
35
+ }, async ({ gameId, t, for: principal, parameters }) => {
36
+ try {
37
+ const rows = evaluateConditions({ gameId, t, rules, parameters, ...(principal !== undefined ? { for: principal } : {}) });
38
+ return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
39
+ }
40
+ catch (error) {
41
+ log.error("conditions_at failed", { gameId, t, error: error.message });
42
+ return { content: [{ type: "text", text: JSON.stringify({ error: error.message }) }], isError: true };
43
+ }
44
+ });
45
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function registerReaderTools(server: McpServer): void;
@@ -0,0 +1,132 @@
1
+ import { z } from "zod";
2
+ import { ANNOTATIONS } from "../utils/tool-annotations.js";
3
+ import { LIMITS } from "../utils/validation.js";
4
+ import { createLogger } from "../utils/logger.js";
5
+ import { sourceWords, validateQuestions, validateSourceIds, verifyAnswers, } from "../reader/turnReader.js";
6
+ const log = createLogger("reader");
7
+ /**
8
+ * The turn reader as two MCP verbs (GitHub issue #39: intent in, ruling out),
9
+ * for a caller whose ruling MODEL runs on its own side of the wire -- a
10
+ * client-side principal, a person typing, anything that cannot hand the engine a
11
+ * transport function. The engine owns the call shape and the verification;
12
+ * it never runs, names or configures a model (src/reader/turnReader.ts's
13
+ * header, and noVendorTransports.test.ts beside it):
14
+ *
15
+ * 1. `prepare_reading` -- the caller's own request (closed-key questions and
16
+ * the sources an answer may cite), validated exactly as
17
+ * `createTurnReader` validates it, handed back with every source's words
18
+ * numbered as a range citation counts them. A bad question set is
19
+ * refused here, before a model call is spent on it.
20
+ * 2. `verify_reading` -- the caller's model's answers, verified by the same
21
+ * tally the library's ladder runs (`verifyAnswers`): the citation rule,
22
+ * ranges and their clamp, the safe defaults, and a reason for every
23
+ * discarded offer.
24
+ *
25
+ * Stateless: neither verb reads or writes a game. The request travels with
26
+ * each call rather than being stored between them, so nothing here is a
27
+ * second copy of a caller's question set that could drift from the first.
28
+ * Everything the caller sends is opaque to the engine except the literal
29
+ * checks the reader already makes (hard rule 4).
30
+ */
31
+ const questionSchema = () => z.object({
32
+ id: z.string().max(100).describe("The question's id; answers name it as questionId."),
33
+ prompt: z.string().max(LIMITS.CONTENT_MAX).describe("The caller's question text. Opaque to the engine."),
34
+ answerKeys: z
35
+ .array(z.string().max(LIMITS.NAME_MAX))
36
+ .max(200)
37
+ .describe("The CLOSED set of keys an answer may take."),
38
+ safeDefault: z
39
+ .string()
40
+ .max(LIMITS.NAME_MAX)
41
+ .describe("The key a question falls to when no answer is accepted. Must be one of answerKeys."),
42
+ });
43
+ const sourceSchema = () => z.object({
44
+ id: z.string().max(100).describe("The source's id; a citation names it as sourceId."),
45
+ text: z.string().max(LIMITS.CONTENT_MAX).describe("The text a citation must come from, byte for byte."),
46
+ });
47
+ const requestShape = () => ({
48
+ questions: z.array(questionSchema()).max(100).describe("The caller's closed-key questions."),
49
+ sources: z.array(sourceSchema()).max(100).describe("Every source an answer may cite."),
50
+ });
51
+ /** A scalar of any JSON type, bounded where it is a string. */
52
+ const looseLeaf = () => z.union([z.string().max(LIMITS.NAME_MAX), z.number(), z.boolean(), z.null()]);
53
+ function fail(tool, error) {
54
+ log.error(`${tool} refused`, { error: error.message });
55
+ return {
56
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
57
+ isError: true,
58
+ };
59
+ }
60
+ export function registerReaderTools(server) {
61
+ server.registerTool("prepare_reading", {
62
+ description: "Step 1 of ruling free text against closed keys with your own model. Validates your request -- " +
63
+ "closed-key questions and the sources an answer may cite -- exactly as the engine's turn reader does, " +
64
+ "and returns it with every source's words numbered from 1. Show your model the numbered words and let " +
65
+ "it cite {sourceId, from, to} instead of retyping a quote: a range cannot misquote. Then send its " +
66
+ "answers to verify_reading. The engine never runs or chooses a model.",
67
+ inputSchema: requestShape(),
68
+ annotations: ANNOTATIONS.READ_ONLY,
69
+ }, async ({ questions, sources }) => {
70
+ try {
71
+ validateQuestions(questions);
72
+ validateSourceIds(sources);
73
+ const numbered = sources.map((s) => ({
74
+ id: s.id,
75
+ text: s.text,
76
+ words: sourceWords(s.text).map((w) => ({ index: w.index, word: w.word })),
77
+ }));
78
+ return { content: [{ type: "text", text: JSON.stringify({ questions, sources: numbered }, null, 2) }] };
79
+ }
80
+ catch (error) {
81
+ return fail("prepare_reading", error);
82
+ }
83
+ });
84
+ server.registerTool("verify_reading", {
85
+ description: "Step 2: your model's answers in, a ruling out. Each answer is {questionId, answerKey, citation}, where " +
86
+ "citation is {sourceId, from, to} (a word range from prepare_reading's numbering) or {sourceId, quote} " +
87
+ "(byte-exact). An answer counts only if its key is one of that question's answerKeys and its citation " +
88
+ "names text really in that source; a range ending past a source's last word ends at it. Every question " +
89
+ "gets exactly one answer: the accepted one, or its safeDefault. Every discarded answer is returned with " +
90
+ "the reason it was discarded. Send the same questions and sources you prepared.",
91
+ inputSchema: {
92
+ ...requestShape(),
93
+ offers: z
94
+ .array(
95
+ // Every leaf admits the wrong type on purpose: a model that writes
96
+ // "from": "3" or a numeric key has made an answer the reader
97
+ // rejects as a row with a reason, and refusing the whole call on
98
+ // schema validation would lose every other answer beside it --
99
+ // the same failure a `[null]` in a transport's list once caused.
100
+ z.union([
101
+ z.object({
102
+ questionId: looseLeaf().optional(),
103
+ answerKey: looseLeaf().optional(),
104
+ citation: z
105
+ .object({
106
+ sourceId: looseLeaf().optional(),
107
+ quote: z.union([z.string().max(LIMITS.CONTENT_MAX), z.number(), z.null()]).optional(),
108
+ from: looseLeaf().optional(),
109
+ to: looseLeaf().optional(),
110
+ })
111
+ .nullable()
112
+ .optional(),
113
+ }),
114
+ // An entry that is not an answer at all -- a model's stray
115
+ // null or string -- becomes a `malformed-offer` row.
116
+ looseLeaf(),
117
+ ]))
118
+ .max(1000)
119
+ .describe("Your model's answers, as parsed from its reply. An answer with a wrong or missing field is a rejected " +
120
+ "row with a reason, not a refused call."),
121
+ },
122
+ annotations: ANNOTATIONS.READ_ONLY,
123
+ }, async ({ questions, sources, offers }) => {
124
+ try {
125
+ const ruling = verifyAnswers({ questions, sources, offers: offers });
126
+ return { content: [{ type: "text", text: JSON.stringify(ruling, null, 2) }] };
127
+ }
128
+ catch (error) {
129
+ return fail("verify_reading", error);
130
+ }
131
+ });
132
+ }
@@ -32,11 +32,16 @@ export function registerRenderTools(server, renderer) {
32
32
  .finite()
33
33
  .describe("An opaque ordinal on this game's declared time axis -- never a datetime, and never an index " +
34
34
  "into units you might later re-cut."),
35
+ entityIds: z
36
+ .array(z.string().max(100))
37
+ .max(10000)
38
+ .optional()
39
+ .describe("Scope the read to these entities -- a selection you built, such as what one character perceives. Omitted: every entity. An empty list: none. An id not alive at t is simply absent."),
35
40
  },
36
41
  annotations: ANNOTATIONS.READ_ONLY,
37
- }, async ({ gameId, t }) => {
42
+ }, async ({ gameId, t, entityIds }) => {
38
43
  try {
39
- const rendered = renderer.render({ gameId, t });
44
+ const rendered = renderer.render({ gameId, t, entityIds });
40
45
  return { content: [{ type: "text", text: JSON.stringify(rendered, null, 2) }] };
41
46
  }
42
47
  catch (error) {
@@ -45,11 +45,17 @@ export function registerResolveTools(server, resolver) {
45
45
  .optional()
46
46
  .describe("Facts this proposal declares it depends on, verified BEFORE the mechanic is dispatched. A caller's " +
47
47
  "own declared precondition -- the engine only reports whether it holds, never why it should."),
48
+ actor: z
49
+ .string()
50
+ .max(100)
51
+ .optional()
52
+ .describe("The entity proposing. If the game has declared a turn order, a proposal from anyone not due at the " +
53
+ "current t is refused (out-of-turn) before the mechanic runs. Omit for the world's own mechanics."),
48
54
  },
49
55
  annotations: ANNOTATIONS.UPDATE,
50
- }, async ({ gameId, mechanic, parameters, expects }) => {
56
+ }, async ({ gameId, mechanic, parameters, expects, actor }) => {
51
57
  try {
52
- const outcome = resolver.resolve({ gameId, mechanic, parameters, expects });
58
+ const outcome = resolver.resolve({ gameId, mechanic, parameters, expects, actor });
53
59
  return { content: [{ type: "text", text: JSON.stringify(outcome, null, 2) }] };
54
60
  }
55
61
  catch (error) {