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.
- package/dist/bin/run-dmcp.js +16 -1
- package/dist/db/schema.js +4 -3
- package/dist/index.d.ts +6 -2
- package/dist/index.js +12 -1
- package/dist/mcp-server.d.ts +6 -1
- package/dist/mcp-server.js +11 -3
- package/dist/reader/preflight.d.ts +62 -0
- package/dist/reader/preflight.js +40 -0
- package/dist/reader/turnReader.d.ts +169 -13
- package/dist/reader/turnReader.js +232 -78
- package/dist/register/conditions.d.ts +16 -0
- package/dist/register/conditions.js +45 -0
- package/dist/register/reader.d.ts +2 -0
- package/dist/register/reader.js +132 -0
- package/dist/register/render.js +7 -2
- package/dist/register/resolve.js +8 -2
- package/dist/register/timeline.js +29 -2
- package/dist/rpg/server.d.ts +2 -0
- package/dist/schemas/index.d.ts +10 -10
- package/dist/timeline/constrained.d.ts +4 -5
- package/dist/timeline/constrained.js +31 -25
- package/dist/timeline/declared.d.ts +147 -0
- package/dist/timeline/declared.js +388 -0
- package/dist/timeline/render.d.ts +3 -0
- package/dist/timeline/render.js +1 -1
- package/dist/timeline/replay.d.ts +15 -0
- package/dist/timeline/replay.js +33 -2
- package/dist/timeline/resolve.d.ts +13 -3
- package/dist/timeline/resolve.js +34 -3
- package/dist/timeline/schema.js +28 -0
- package/dist/timeline/turns.d.ts +60 -0
- package/dist/timeline/turns.js +93 -0
- package/dist/utils/output-schemas.d.ts +6 -6
- package/package.json +1 -1
|
@@ -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
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
|
159
|
-
const
|
|
160
|
-
const
|
|
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
|
|
241
|
+
return { citation: { sourceId, quote } };
|
|
175
242
|
}
|
|
176
243
|
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
274
|
+
const existing = rejectedByQuestion.get(questionId);
|
|
203
275
|
if (existing) {
|
|
204
276
|
existing.push(row);
|
|
205
277
|
}
|
|
206
278
|
else {
|
|
207
|
-
rejectedByQuestion.set(
|
|
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 =
|
|
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.
|
|
229
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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,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
|
+
}
|
package/dist/register/render.js
CHANGED
|
@@ -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) {
|
package/dist/register/resolve.js
CHANGED
|
@@ -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) {
|