run-dmcp 0.5.0 → 0.7.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/index.d.ts +2 -1
- package/dist/mcp-server.d.ts +1 -1
- package/dist/mcp-server.js +1 -1
- package/dist/timeline/constrained.d.ts +39 -0
- package/dist/timeline/constrained.js +78 -0
- package/dist/timeline/export.d.ts +13 -4
- package/dist/timeline/export.js +15 -9
- package/dist/timeline/irreversible.js +11 -6
- package/dist/timeline/narration.js +6 -4
- package/dist/timeline/projection.js +21 -4
- package/dist/timeline/provenance.d.ts +21 -28
- package/dist/timeline/provenance.js +30 -34
- package/dist/timeline/resolve.d.ts +18 -4
- package/dist/timeline/resolve.js +12 -3
- package/dist/timeline/schema.js +29 -1
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export { timelineDivergences } from "./timeline/checkpoint.js";
|
|
|
14
14
|
export type { Divergence } from "./timeline/checkpoint.js";
|
|
15
15
|
export { declareIrreversible, irreversibleFactFor, listIrreversibleFacts, } from "./timeline/irreversible.js";
|
|
16
16
|
export type { IrreversibleFact } from "./timeline/irreversible.js";
|
|
17
|
+
export type { SetTransition } from "./timeline/constrained.js";
|
|
17
18
|
export { openingEventId } from "./timeline/provenance.js";
|
|
18
19
|
export type { FactProvenance } from "./timeline/provenance.js";
|
|
19
20
|
export { narrationConstraintAt, contradictions, NARRATION_CONSTRAINT_FORMAT_VERSION, } from "./timeline/narration.js";
|
|
@@ -22,7 +23,7 @@ export { writeConstrainedValue, transferConstrainedValue, valueHistory, } from "
|
|
|
22
23
|
export type { ValueTransition } from "./timeline/constrained.js";
|
|
23
24
|
export { ConstraintViolationError, constraintsFor, conservedConstraintFor } from "./timeline/registry.js";
|
|
24
25
|
export { createResolver, ResolveProtocolError } from "./timeline/resolve.js";
|
|
25
|
-
export type { Mechanic, Resolver, Proposal, Expectation, AdjudicationInput, Adjudication, IntendedChange, IntendedWrite, IntendedTransfer, Outcome, ResolveRefusalReason, } from "./timeline/resolve.js";
|
|
26
|
+
export type { Mechanic, Resolver, Proposal, Expectation, AdjudicationInput, Adjudication, IntendedChange, IntendedWrite, IntendedTransfer, IntendedSet, Outcome, ResolveRefusalReason, } from "./timeline/resolve.js";
|
|
26
27
|
export { createStateRenderer } from "./timeline/render.js";
|
|
27
28
|
export type { RenderVocabulary, VocabularyEntry, StateRenderer, RenderedState, RenderedNoun, UnnamedFact, } from "./timeline/render.js";
|
|
28
29
|
export { createTurnReader } from "./reader/turnReader.js";
|
package/dist/mcp-server.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare const SERVER_NAME = "dmcp";
|
|
|
6
6
|
* the published package version by src/__tests__/serverVersion.test.ts --
|
|
7
7
|
* this said "0.3.0" for the whole of 0.4.0, because a release bumps
|
|
8
8
|
* package.json and nothing was watching this. */
|
|
9
|
-
export declare const SERVER_VERSION = "0.
|
|
9
|
+
export declare const SERVER_VERSION = "0.7.0";
|
|
10
10
|
/**
|
|
11
11
|
* Build an MCP server with every CORE tool, resource and prompt this engine
|
|
12
12
|
* serves -- entities, facts, events, the timeline, and the entity/property
|
package/dist/mcp-server.js
CHANGED
|
@@ -45,7 +45,7 @@ export const SERVER_NAME = "dmcp";
|
|
|
45
45
|
* the published package version by src/__tests__/serverVersion.test.ts --
|
|
46
46
|
* this said "0.3.0" for the whole of 0.4.0, because a release bumps
|
|
47
47
|
* package.json and nothing was watching this. */
|
|
48
|
-
export const SERVER_VERSION = "0.
|
|
48
|
+
export const SERVER_VERSION = "0.7.0";
|
|
49
49
|
/**
|
|
50
50
|
* Build an MCP server with every CORE tool, resource and prompt this engine
|
|
51
51
|
* serves -- entities, facts, events, the timeline, and the entity/property
|
|
@@ -218,3 +218,42 @@ export declare function transferConstrainedValue(params: {
|
|
|
218
218
|
* discipline. `limit` applies after ordering, never before.
|
|
219
219
|
*/
|
|
220
220
|
export declare function valueHistory(entityId: string, key: string, limit?: number): ValueTransition[];
|
|
221
|
+
/**
|
|
222
|
+
* One non-numeric column set, as `resolve()`'s `set` intent applies it (issue
|
|
223
|
+
* #32). A row, never a verdict: what the column held, what it holds now, and
|
|
224
|
+
* the fact the write opened -- `null` when the value did not move, because an
|
|
225
|
+
* unchanged value opens no new interval (projection.ts's update trigger).
|
|
226
|
+
*/
|
|
227
|
+
export interface SetTransition {
|
|
228
|
+
entityId: string;
|
|
229
|
+
key: string;
|
|
230
|
+
previousValue: string | number | null;
|
|
231
|
+
newValue: string | number | null;
|
|
232
|
+
t: T;
|
|
233
|
+
factId: string | null;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Sets one live column of an entity's projected table to `value` (issue #32).
|
|
237
|
+
* The engine stores what it is handed and never learns what the column means;
|
|
238
|
+
* the projection triggers version it exactly as they version every other
|
|
239
|
+
* column write, so nothing here touches `facts` directly.
|
|
240
|
+
*
|
|
241
|
+
* The same choke point as numeric writes, and the same checks in the same
|
|
242
|
+
* voice: the entity and column are resolved against `PROJECTED_TABLES` /
|
|
243
|
+
* `liveColumns` (never a caller-supplied table name); the column that places
|
|
244
|
+
* the entity in its game is refused; a key carrying a numeric constraint is
|
|
245
|
+
* refused, because that value changes by a write where the constraint is
|
|
246
|
+
* evaluated; `resolve_only` asks the one question it always asks, whether an
|
|
247
|
+
* adjudication window is open; and a contradicted irreversible fact is
|
|
248
|
+
* translated into a typed `ConstraintViolationError` with the one hop
|
|
249
|
+
* attached, by `translateIrreversibleFailure`, never by reading the trigger's
|
|
250
|
+
* message.
|
|
251
|
+
*
|
|
252
|
+
* Not exported from the library: `resolve()` is its only caller, so a
|
|
253
|
+
* non-numeric consequence reaches storage through a resolution or not at all.
|
|
254
|
+
*/
|
|
255
|
+
export declare function setProjectedValue(params: {
|
|
256
|
+
entityId: string;
|
|
257
|
+
key: string;
|
|
258
|
+
value: string | number | null;
|
|
259
|
+
}): SetTransition;
|
|
@@ -669,3 +669,81 @@ export function valueHistory(entityId, key, limit) {
|
|
|
669
669
|
const limited = limit !== undefined ? ranked.slice(0, limit) : ranked;
|
|
670
670
|
return limited.map((r) => r.transition);
|
|
671
671
|
}
|
|
672
|
+
/** The numeric members of the constraint family. A key carrying one of them
|
|
673
|
+
* changes by a write (`writeConstrainedValue`), where the constraint is
|
|
674
|
+
* evaluated; a `set` would step round it. */
|
|
675
|
+
const NUMERIC_CONSTRAINT_KINDS = new Set(["monotonic", "bounded", "conserved"]);
|
|
676
|
+
function openFactId(db, entityId, key) {
|
|
677
|
+
const row = db
|
|
678
|
+
.prepare(`SELECT id FROM facts WHERE entity_id = ? AND key = ? AND valid_to_t IS NULL ORDER BY valid_from_t DESC, id DESC LIMIT 1`)
|
|
679
|
+
.get(entityId, key);
|
|
680
|
+
return row?.id ?? null;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Sets one live column of an entity's projected table to `value` (issue #32).
|
|
684
|
+
* The engine stores what it is handed and never learns what the column means;
|
|
685
|
+
* the projection triggers version it exactly as they version every other
|
|
686
|
+
* column write, so nothing here touches `facts` directly.
|
|
687
|
+
*
|
|
688
|
+
* The same choke point as numeric writes, and the same checks in the same
|
|
689
|
+
* voice: the entity and column are resolved against `PROJECTED_TABLES` /
|
|
690
|
+
* `liveColumns` (never a caller-supplied table name); the column that places
|
|
691
|
+
* the entity in its game is refused; a key carrying a numeric constraint is
|
|
692
|
+
* refused, because that value changes by a write where the constraint is
|
|
693
|
+
* evaluated; `resolve_only` asks the one question it always asks, whether an
|
|
694
|
+
* adjudication window is open; and a contradicted irreversible fact is
|
|
695
|
+
* translated into a typed `ConstraintViolationError` with the one hop
|
|
696
|
+
* attached, by `translateIrreversibleFailure`, never by reading the trigger's
|
|
697
|
+
* message.
|
|
698
|
+
*
|
|
699
|
+
* Not exported from the library: `resolve()` is its only caller, so a
|
|
700
|
+
* non-numeric consequence reaches storage through a resolution or not at all.
|
|
701
|
+
*/
|
|
702
|
+
export function setProjectedValue(params) {
|
|
703
|
+
const resolved = resolveProjection(params.entityId, params.key);
|
|
704
|
+
const db = getDatabase();
|
|
705
|
+
const projected = PROJECTED_TABLES.find((p) => p.table === resolved.table);
|
|
706
|
+
if (projected && projected.gameIdColumn === params.key) {
|
|
707
|
+
throw new Error(`timeline: '${params.key}' places entity '${params.entityId}' in its game and cannot be set -- ` +
|
|
708
|
+
`an entity does not move between games`);
|
|
709
|
+
}
|
|
710
|
+
for (const constraint of constraintsFor(params.entityId, params.key)) {
|
|
711
|
+
if (NUMERIC_CONSTRAINT_KINDS.has(constraint.kind)) {
|
|
712
|
+
throw new ConstraintViolationError(constraint.kind, params.entityId, `Entity '${params.entityId}' is ${constraint.kind}-constrained for key '${params.key}', which a set does not ` +
|
|
713
|
+
`evaluate; this value changes by a write, where the constraint is checked.`);
|
|
714
|
+
}
|
|
715
|
+
if (constraint.kind === "resolve_only" && !adjudicationOpen()) {
|
|
716
|
+
throw new ConstraintViolationError("resolve_only", params.entityId, `Entity '${params.entityId}' is resolve_only-constrained for key '${params.key}'; direct writes are refused. ` +
|
|
717
|
+
`This value can only change through the adjudicating call that opens the resolution window.`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const row = db.prepare(`SELECT ${resolved.key} AS value FROM ${resolved.table} WHERE id = ?`).get(resolved.entityId);
|
|
721
|
+
if (!row) {
|
|
722
|
+
throw new Error(`timeline: no live row in '${resolved.table}' for entity '${resolved.entityId}' -- ` +
|
|
723
|
+
`it may have been destroyed since it was last confirmed to exist`);
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
return withTransaction(() => {
|
|
727
|
+
const before = openFactId(db, resolved.entityId, resolved.key);
|
|
728
|
+
db.prepare(`UPDATE ${resolved.table} SET ${resolved.key} = ? WHERE id = ?`).run(params.value, resolved.entityId);
|
|
729
|
+
const after = openFactId(db, resolved.entityId, resolved.key);
|
|
730
|
+
const story = currentStoryTime(resolved.gameId);
|
|
731
|
+
if (!story) {
|
|
732
|
+
throw new Error(`timeline: game '${resolved.gameId}' has no timeline clock -- a set has no t to attach to`);
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
entityId: resolved.entityId,
|
|
736
|
+
key: resolved.key,
|
|
737
|
+
previousValue: row.value,
|
|
738
|
+
newValue: params.value,
|
|
739
|
+
t: story.t,
|
|
740
|
+
factId: after !== before ? after : null,
|
|
741
|
+
};
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
if (params.value === null)
|
|
746
|
+
throw err;
|
|
747
|
+
translateIrreversibleFailure(db, [{ entityId: params.entityId, key: params.key, table: resolved.table, attemptedValue: params.value }], err);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
@@ -80,6 +80,13 @@ export interface TimelineExportFact {
|
|
|
80
80
|
validFromT: T;
|
|
81
81
|
validToT: T | null;
|
|
82
82
|
irreversible: boolean;
|
|
83
|
+
/** The one hop of causality (design §5.2c, issue #30) -- the event that
|
|
84
|
+
* opened this fact, or null when none is recorded. Optional, not just
|
|
85
|
+
* nullable: a v1 artifact written before issue #30 landed carries no such
|
|
86
|
+
* field at all, and `importTimeline` treats an absent field and an
|
|
87
|
+
* explicit `null` identically -- there was never a recorded hop for
|
|
88
|
+
* either, so there is nothing to guess and no reason to refuse. */
|
|
89
|
+
openedByEventId?: string | null;
|
|
83
90
|
}
|
|
84
91
|
export interface TimelineExportEvent {
|
|
85
92
|
id: string;
|
|
@@ -158,10 +165,12 @@ export declare function exportTimeline(gameId: string): TimelineExport;
|
|
|
158
165
|
* artifact shape above) is stamped fresh at import time; it was never
|
|
159
166
|
* exported and never round-trips.
|
|
160
167
|
*
|
|
161
|
-
* `entities` are inserted before `facts` because `facts.entity_id`
|
|
162
|
-
* real foreign key
|
|
163
|
-
*
|
|
164
|
-
*
|
|
168
|
+
* `entities` are inserted before `events` and `facts` because `facts.entity_id`
|
|
169
|
+
* is a real foreign key; `events` are inserted before `facts` (issue #30) for
|
|
170
|
+
* the identical reason now that `facts.opened_by_event_id` is one too --
|
|
171
|
+
* this database runs with `PRAGMA foreign_keys = ON` (`../db/connection.ts`),
|
|
172
|
+
* so inserting out of order would fail loudly rather than silently, but
|
|
173
|
+
* there is no reason to invite the failure.
|
|
165
174
|
*/
|
|
166
175
|
export declare function importTimeline(artifact: TimelineExport): TimelineImportResult;
|
|
167
176
|
/**
|
package/dist/timeline/export.js
CHANGED
|
@@ -103,7 +103,7 @@ function readTimeline(gameId) {
|
|
|
103
103
|
// scoped by joining back to entities, exactly the way replay.ts scopes
|
|
104
104
|
// "what was true of them" to "who was alive."
|
|
105
105
|
const factRows = db
|
|
106
|
-
.prepare(`SELECT f.id, f.entity_id, f.key, f.value, f.valid_from_t, f.valid_to_t, f.irreversible
|
|
106
|
+
.prepare(`SELECT f.id, f.entity_id, f.key, f.value, f.valid_from_t, f.valid_to_t, f.irreversible, f.opened_by_event_id
|
|
107
107
|
FROM facts f
|
|
108
108
|
JOIN entities e ON e.id = f.entity_id
|
|
109
109
|
WHERE e.game_id = ?
|
|
@@ -137,6 +137,7 @@ function readTimeline(gameId) {
|
|
|
137
137
|
validFromT: row.valid_from_t,
|
|
138
138
|
validToT: row.valid_to_t,
|
|
139
139
|
irreversible: Boolean(row.irreversible),
|
|
140
|
+
openedByEventId: row.opened_by_event_id,
|
|
140
141
|
})),
|
|
141
142
|
events: eventRows.map((row) => ({
|
|
142
143
|
id: row.id,
|
|
@@ -266,10 +267,12 @@ function assertTargetIsEmpty(gameId) {
|
|
|
266
267
|
* artifact shape above) is stamped fresh at import time; it was never
|
|
267
268
|
* exported and never round-trips.
|
|
268
269
|
*
|
|
269
|
-
* `entities` are inserted before `facts` because `facts.entity_id`
|
|
270
|
-
* real foreign key
|
|
271
|
-
*
|
|
272
|
-
*
|
|
270
|
+
* `entities` are inserted before `events` and `facts` because `facts.entity_id`
|
|
271
|
+
* is a real foreign key; `events` are inserted before `facts` (issue #30) for
|
|
272
|
+
* the identical reason now that `facts.opened_by_event_id` is one too --
|
|
273
|
+
* this database runs with `PRAGMA foreign_keys = ON` (`../db/connection.ts`),
|
|
274
|
+
* so inserting out of order would fail loudly rather than silently, but
|
|
275
|
+
* there is no reason to invite the failure.
|
|
273
276
|
*/
|
|
274
277
|
export function importTimeline(artifact) {
|
|
275
278
|
assertValidArtifactShape(artifact);
|
|
@@ -289,14 +292,17 @@ export function importTimeline(artifact) {
|
|
|
289
292
|
for (const entity of artifact.entities) {
|
|
290
293
|
insertEntity.run(entity.id, entity.gameId, entity.kind, entity.name, entity.createdAtT, entity.destroyedAtT);
|
|
291
294
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
295
|
+
// Events before facts (issue #30): `facts.opened_by_event_id` is a real
|
|
296
|
+
// foreign key into `events` now, on top of `facts.entity_id`'s existing
|
|
297
|
+
// one into `entities` -- see this function's doc comment.
|
|
296
298
|
const insertEvent = db.prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, ?, ?, ?)`);
|
|
297
299
|
for (const event of artifact.events) {
|
|
298
300
|
insertEvent.run(event.id, event.gameId, event.atT, event.kind, event.description, event.causes);
|
|
299
301
|
}
|
|
302
|
+
const insertFact = db.prepare(`INSERT INTO facts (id, entity_id, key, value, valid_from_t, valid_to_t, irreversible, opened_by_event_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
303
|
+
for (const fact of artifact.facts) {
|
|
304
|
+
insertFact.run(fact.id, fact.entityId, fact.key, fact.value, fact.validFromT, fact.validToT, fact.irreversible ? 1 : 0, fact.openedByEventId ?? null);
|
|
305
|
+
}
|
|
300
306
|
return {
|
|
301
307
|
gameId: artifact.gameId,
|
|
302
308
|
entities: artifact.entities.length,
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { getDatabase } from "../db/connection.js";
|
|
2
2
|
import { assertT } from "./t.js";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/** `gameId` is no longer used to look up the hop (issue #30: it is a stored
|
|
4
|
+
* column on the fact row itself, never derived), but stays a parameter so
|
|
5
|
+
* every call site here keeps naming the game it is working in -- and so a
|
|
6
|
+
* future caller that genuinely needs to re-scope by game has somewhere to
|
|
7
|
+
* put it without changing every signature in this file again. */
|
|
8
|
+
function toIrreversibleFact(row, _gameId) {
|
|
5
9
|
assertT(row.valid_from_t);
|
|
6
10
|
return {
|
|
7
11
|
factId: row.id,
|
|
@@ -9,7 +13,7 @@ function toIrreversibleFact(row, gameId) {
|
|
|
9
13
|
key: row.key,
|
|
10
14
|
value: row.value,
|
|
11
15
|
validFromT: row.valid_from_t,
|
|
12
|
-
openedByEventId:
|
|
16
|
+
openedByEventId: row.opened_by_event_id,
|
|
13
17
|
};
|
|
14
18
|
}
|
|
15
19
|
/**
|
|
@@ -44,7 +48,7 @@ export function declareIrreversible(params) {
|
|
|
44
48
|
// order. Same tiebreak as irreversibleFactFor below, so the two functions
|
|
45
49
|
// can never disagree about which row they mean.
|
|
46
50
|
const open = db
|
|
47
|
-
.prepare(`SELECT id, entity_id, key, value, valid_from_t FROM facts
|
|
51
|
+
.prepare(`SELECT id, entity_id, key, value, valid_from_t, opened_by_event_id FROM facts
|
|
48
52
|
WHERE entity_id = ? AND key = ? AND valid_to_t IS NULL
|
|
49
53
|
ORDER BY valid_from_t DESC, id DESC
|
|
50
54
|
LIMIT 1`)
|
|
@@ -73,7 +77,7 @@ export function irreversibleFactFor(entityId, key) {
|
|
|
73
77
|
if (!entity)
|
|
74
78
|
return null;
|
|
75
79
|
const row = db
|
|
76
|
-
.prepare(`SELECT id, entity_id, key, value, valid_from_t FROM facts
|
|
80
|
+
.prepare(`SELECT id, entity_id, key, value, valid_from_t, opened_by_event_id FROM facts
|
|
77
81
|
WHERE entity_id = ? AND key = ? AND irreversible = 1
|
|
78
82
|
ORDER BY valid_from_t DESC, id DESC
|
|
79
83
|
LIMIT 1`)
|
|
@@ -92,7 +96,8 @@ export function irreversibleFactFor(entityId, key) {
|
|
|
92
96
|
export function listIrreversibleFacts(params) {
|
|
93
97
|
const db = getDatabase();
|
|
94
98
|
let query = `
|
|
95
|
-
SELECT f.id AS id, f.entity_id AS entity_id, f.key AS key, f.value AS value, f.valid_from_t AS valid_from_t
|
|
99
|
+
SELECT f.id AS id, f.entity_id AS entity_id, f.key AS key, f.value AS value, f.valid_from_t AS valid_from_t,
|
|
100
|
+
f.opened_by_event_id AS opened_by_event_id
|
|
96
101
|
FROM facts f
|
|
97
102
|
JOIN entities e ON e.id = f.entity_id
|
|
98
103
|
WHERE e.game_id = ? AND f.irreversible = 1
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { getDatabase } from "../db/connection.js";
|
|
2
2
|
import { assertT, compareT } from "./t.js";
|
|
3
|
-
import { openingEventId } from "./provenance.js";
|
|
4
3
|
/**
|
|
5
4
|
* The outbound half of authority (design §5.2b/§5.2c, GitHub issues #11 and
|
|
6
5
|
* #12): "Here is what is true; depict it, do not argue with it." One
|
|
@@ -47,7 +46,10 @@ import { openingEventId } from "./provenance.js";
|
|
|
47
46
|
* opened it" in this codebase, not a second copy grown for this module.
|
|
48
47
|
*/
|
|
49
48
|
export const NARRATION_CONSTRAINT_FORMAT_VERSION = 1;
|
|
50
|
-
|
|
49
|
+
/** `gameId` is unused now that the hop is a stored column (issue #30) rather
|
|
50
|
+
* than derived per-row, but the parameter stays -- see irreversible.ts's
|
|
51
|
+
* identical note on `toIrreversibleFact`. */
|
|
52
|
+
function toConstraintFact(row, _gameId) {
|
|
51
53
|
assertT(row.valid_from_t);
|
|
52
54
|
if (row.valid_to_t !== null)
|
|
53
55
|
assertT(row.valid_to_t);
|
|
@@ -61,7 +63,7 @@ function toConstraintFact(row, gameId) {
|
|
|
61
63
|
irreversible: Boolean(row.irreversible),
|
|
62
64
|
entityKind: row.entity_kind,
|
|
63
65
|
entityName: row.entity_name,
|
|
64
|
-
openedByEventId:
|
|
66
|
+
openedByEventId: row.opened_by_event_id,
|
|
65
67
|
};
|
|
66
68
|
}
|
|
67
69
|
/**
|
|
@@ -125,7 +127,7 @@ export function narrationConstraintAt(params) {
|
|
|
125
127
|
const rows = db
|
|
126
128
|
.prepare(`SELECT f.id AS id, f.entity_id AS entity_id, f.key AS key, f.value AS value,
|
|
127
129
|
f.valid_from_t AS valid_from_t, f.valid_to_t AS valid_to_t, f.irreversible AS irreversible,
|
|
128
|
-
e.kind AS entity_kind, e.name AS entity_name
|
|
130
|
+
e.kind AS entity_kind, e.name AS entity_name, f.opened_by_event_id AS opened_by_event_id
|
|
129
131
|
FROM facts f
|
|
130
132
|
JOIN entities e ON e.id = f.entity_id
|
|
131
133
|
WHERE e.game_id = ?
|
|
@@ -41,10 +41,16 @@ function tExpr(gidExpr) {
|
|
|
41
41
|
/**
|
|
42
42
|
* `AFTER INSERT`: ensure the game's clock row exists, advance it, insert the
|
|
43
43
|
* entity, insert one fact per non-NULL column, insert a `<kind>.created`
|
|
44
|
-
* event
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
44
|
+
* event, then (issue #30) stamp every fact this firing just opened with that
|
|
45
|
+
* event's id via `last_insert_rowid()` -- the one hop of causality (design
|
|
46
|
+
* §5.2c), recorded rather than derived later. The `opened_by_event_id IS
|
|
47
|
+
* NULL` guard is what keeps a repeated `t` on a non-`sequence` axis correct:
|
|
48
|
+
* it restricts the stamp to facts THIS firing opened, never one a previous
|
|
49
|
+
* firing at the same `t` already stamped. Column names are interpolated
|
|
50
|
+
* directly (never bound as parameters) because they come from this
|
|
51
|
+
* codebase's own `pragma_table_info`, never from anything a caller supplied
|
|
52
|
+
* -- there is no user input anywhere in this SQL (trigger-sql-skeleton
|
|
53
|
+
* trap #6).
|
|
48
54
|
*/
|
|
49
55
|
function buildInsertTrigger(row, cols) {
|
|
50
56
|
const gid = `NEW.${row.gameIdColumn}`;
|
|
@@ -72,6 +78,9 @@ ${factInserts}
|
|
|
72
78
|
INSERT INTO events (id, game_id, at_t, kind, description, causes)
|
|
73
79
|
VALUES (lower(hex(randomblob(16))), ${gid}, ${t}, '${row.kind}.created', '${row.kind} created',
|
|
74
80
|
json_object('table', '${row.table}', 'row_id', NEW.id));
|
|
81
|
+
|
|
82
|
+
UPDATE facts SET opened_by_event_id = (SELECT id FROM events WHERE rowid = last_insert_rowid())
|
|
83
|
+
WHERE entity_id = NEW.id AND valid_from_t = ${t} AND opened_by_event_id IS NULL;
|
|
75
84
|
END;
|
|
76
85
|
`;
|
|
77
86
|
}
|
|
@@ -84,6 +93,11 @@ ${factInserts}
|
|
|
84
93
|
* column: reversed, the open's subquery would still see the value the
|
|
85
94
|
* close was about to retire and write nothing (trap #3). The five-case
|
|
86
95
|
* table this produces is walked by the test suite, not re-derived here.
|
|
96
|
+
* After the `<kind>.updated` event lands, every fact this firing just opened
|
|
97
|
+
* (across every column touched) is stamped with that event's id -- see
|
|
98
|
+
* `buildInsertTrigger`'s doc comment for why the `opened_by_event_id IS
|
|
99
|
+
* NULL` guard is what keeps this correct when a non-`sequence` axis repeats
|
|
100
|
+
* a `t` across firings (issue #30).
|
|
87
101
|
*/
|
|
88
102
|
function buildUpdateTrigger(row, cols) {
|
|
89
103
|
const gid = `NEW.${row.gameIdColumn}`;
|
|
@@ -111,6 +125,9 @@ ${perColumn}
|
|
|
111
125
|
INSERT INTO events (id, game_id, at_t, kind, description, causes)
|
|
112
126
|
VALUES (lower(hex(randomblob(16))), ${gid}, ${t}, '${row.kind}.updated', '${row.kind} updated',
|
|
113
127
|
json_object('table', '${row.table}', 'row_id', NEW.id));
|
|
128
|
+
|
|
129
|
+
UPDATE facts SET opened_by_event_id = (SELECT id FROM events WHERE rowid = last_insert_rowid())
|
|
130
|
+
WHERE entity_id = NEW.id AND valid_from_t = ${t} AND opened_by_event_id IS NULL;
|
|
114
131
|
END;
|
|
115
132
|
`;
|
|
116
133
|
}
|
|
@@ -32,35 +32,28 @@ export interface FactProvenance {
|
|
|
32
32
|
openedByEventId: string | null;
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
* The one hop of causality (design §5.2c)
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
35
|
+
* The one hop of causality (design §5.2c), READ rather than derived (issue
|
|
36
|
+
* #30). `facts.opened_by_event_id` is stamped by the projection triggers'
|
|
37
|
+
* `_ai`/`_au` bodies (projection.ts) at the moment a fact opens, in the same
|
|
38
|
+
* firing, via `last_insert_rowid()` against the event they just inserted --
|
|
39
|
+
* so this is a direct column lookup now, not a search over `events` keyed by
|
|
40
|
+
* `(at_t, causes.row_id)` with a random-hex tiebreak among rows sharing a
|
|
41
|
+
* `t`. That derivation is gone, along with the failure modes it carried: it
|
|
42
|
+
* could return null for an event whose `causes` was not valid JSON, and it
|
|
43
|
+
* broke ties among same-`t` events arbitrarily.
|
|
44
44
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* query, not to the offending row, so a single bad row anywhere in this
|
|
50
|
-
* game's events would make every function that calls this throw, including
|
|
51
|
-
* ones that have nothing to do with that event. That is reachable in
|
|
52
|
-
* practice: timeline import (export.ts) carries `causes` through verbatim
|
|
53
|
-
* by design, because an importer that rewrote a recorded cause would be
|
|
54
|
-
* inventing history. A hop of provenance must never be able to fail the
|
|
55
|
-
* write it annotates, so a row we cannot read simply does not match.
|
|
56
|
-
* Written as CASE rather than `json_valid(causes) AND json_extract(...)`
|
|
57
|
-
* because SQLite does not guarantee the evaluation order of AND operands --
|
|
58
|
-
* the planner may reorder them, and then the guard is decoration that
|
|
59
|
-
* happens to work today.
|
|
45
|
+
* Both internal callers of this shape (`irreversible.ts`, `narration.ts`)
|
|
46
|
+
* no longer call this function at all -- each already queries its own fact
|
|
47
|
+
* row and now selects `opened_by_event_id` directly as part of that same
|
|
48
|
+
* query, which is strictly cheaper than a second round trip through here.
|
|
60
49
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
50
|
+
* This function is kept, and re-pointed at the stored column rather than
|
|
51
|
+
* removed, because it is part of this package's published library surface
|
|
52
|
+
* (`src/index.ts` re-exports it) -- issue #30 did not ask for a public API
|
|
53
|
+
* removal, and removing an exported function silently would be exactly the
|
|
54
|
+
* kind of undocumented break root CLAUDE.md's "what we declare is what we
|
|
55
|
+
* mean" section warns against. A caller that already holds
|
|
56
|
+
* `(gameId, entityId, validFromT)` rather than a fact id can still use it;
|
|
57
|
+
* it now does less work to answer the same question.
|
|
65
58
|
*/
|
|
66
59
|
export declare function openingEventId(gameId: string, entityId: string, validFromT: number): string | null;
|
|
@@ -1,45 +1,41 @@
|
|
|
1
1
|
import { getDatabase } from "../db/connection.js";
|
|
2
2
|
/**
|
|
3
|
-
* The one hop of causality (design §5.2c)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
3
|
+
* The one hop of causality (design §5.2c), READ rather than derived (issue
|
|
4
|
+
* #30). `facts.opened_by_event_id` is stamped by the projection triggers'
|
|
5
|
+
* `_ai`/`_au` bodies (projection.ts) at the moment a fact opens, in the same
|
|
6
|
+
* firing, via `last_insert_rowid()` against the event they just inserted --
|
|
7
|
+
* so this is a direct column lookup now, not a search over `events` keyed by
|
|
8
|
+
* `(at_t, causes.row_id)` with a random-hex tiebreak among rows sharing a
|
|
9
|
+
* `t`. That derivation is gone, along with the failure modes it carried: it
|
|
10
|
+
* could return null for an event whose `causes` was not valid JSON, and it
|
|
11
|
+
* broke ties among same-`t` events arbitrarily.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* query, not to the offending row, so a single bad row anywhere in this
|
|
18
|
-
* game's events would make every function that calls this throw, including
|
|
19
|
-
* ones that have nothing to do with that event. That is reachable in
|
|
20
|
-
* practice: timeline import (export.ts) carries `causes` through verbatim
|
|
21
|
-
* by design, because an importer that rewrote a recorded cause would be
|
|
22
|
-
* inventing history. A hop of provenance must never be able to fail the
|
|
23
|
-
* write it annotates, so a row we cannot read simply does not match.
|
|
24
|
-
* Written as CASE rather than `json_valid(causes) AND json_extract(...)`
|
|
25
|
-
* because SQLite does not guarantee the evaluation order of AND operands --
|
|
26
|
-
* the planner may reorder them, and then the guard is decoration that
|
|
27
|
-
* happens to work today.
|
|
13
|
+
* Both internal callers of this shape (`irreversible.ts`, `narration.ts`)
|
|
14
|
+
* no longer call this function at all -- each already queries its own fact
|
|
15
|
+
* row and now selects `opened_by_event_id` directly as part of that same
|
|
16
|
+
* query, which is strictly cheaper than a second round trip through here.
|
|
28
17
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
18
|
+
* This function is kept, and re-pointed at the stored column rather than
|
|
19
|
+
* removed, because it is part of this package's published library surface
|
|
20
|
+
* (`src/index.ts` re-exports it) -- issue #30 did not ask for a public API
|
|
21
|
+
* removal, and removing an exported function silently would be exactly the
|
|
22
|
+
* kind of undocumented break root CLAUDE.md's "what we declare is what we
|
|
23
|
+
* mean" section warns against. A caller that already holds
|
|
24
|
+
* `(gameId, entityId, validFromT)` rather than a fact id can still use it;
|
|
25
|
+
* it now does less work to answer the same question.
|
|
33
26
|
*/
|
|
34
27
|
export function openingEventId(gameId, entityId, validFromT) {
|
|
35
28
|
const db = getDatabase();
|
|
36
29
|
const row = db
|
|
37
|
-
.prepare(`SELECT
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
.prepare(`SELECT f.opened_by_event_id AS id
|
|
31
|
+
FROM facts f
|
|
32
|
+
JOIN entities e ON e.id = f.entity_id
|
|
33
|
+
WHERE e.game_id = ?
|
|
34
|
+
AND f.entity_id = ?
|
|
35
|
+
AND f.valid_from_t = ?
|
|
36
|
+
AND f.opened_by_event_id IS NOT NULL
|
|
37
|
+
ORDER BY f.id
|
|
42
38
|
LIMIT 1`)
|
|
43
|
-
.get(gameId,
|
|
39
|
+
.get(gameId, entityId, validFromT);
|
|
44
40
|
return row?.id ?? null;
|
|
45
41
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type T } from "./t.js";
|
|
2
2
|
import { type NarrationConstraint, type Contradiction } from "./narration.js";
|
|
3
|
-
import { type ValueTransition } from "./constrained.js";
|
|
3
|
+
import { type ValueTransition, type SetTransition } from "./constrained.js";
|
|
4
4
|
/**
|
|
5
5
|
* The inbound half of authority (design §5.2a, GitHub issue #10): propose ->
|
|
6
6
|
* adjudicate -> outcome. The engine enforces the PROTOCOL -- resolution
|
|
@@ -71,8 +71,9 @@ import { type ValueTransition } from "./constrained.js";
|
|
|
71
71
|
* INSIDE it (adjudication.ts's own doc comment asks for exactly this
|
|
72
72
|
* nesting, so the window row rolls back with the writes it
|
|
73
73
|
* authorized). Every change goes through `writeConstrainedValue` /
|
|
74
|
-
* `transferConstrainedValue`
|
|
75
|
-
*
|
|
74
|
+
* `transferConstrainedValue` / `setProjectedValue` (issue #32, a
|
|
75
|
+
* non-numeric column) -- the one choke point (root CLAUDE.md hard
|
|
76
|
+
* rule 7) -- never a direct write. A constraint violation
|
|
76
77
|
* anywhere in the list propagates out of the transaction untouched
|
|
77
78
|
* (never caught and re-labelled here) and rolls back EVERY change the
|
|
78
79
|
* transaction made, including ones that individually would have
|
|
@@ -169,7 +170,17 @@ export interface IntendedTransfer {
|
|
|
169
170
|
maxValue: number | null;
|
|
170
171
|
};
|
|
171
172
|
}
|
|
172
|
-
|
|
173
|
+
/** One intended set of a non-numeric column on an entity's projected row
|
|
174
|
+
* (issue #32) -- a thing changing owner, a character changing place. The
|
|
175
|
+
* engine stores `value` and never learns what `key` means; see
|
|
176
|
+
* `setProjectedValue` (constrained.ts) for what it refuses. */
|
|
177
|
+
export interface IntendedSet {
|
|
178
|
+
kind: "set";
|
|
179
|
+
entityId: string;
|
|
180
|
+
key: string;
|
|
181
|
+
value: string | number | null;
|
|
182
|
+
}
|
|
183
|
+
export type IntendedChange = IntendedWrite | IntendedTransfer | IntendedSet;
|
|
173
184
|
/**
|
|
174
185
|
* What a mechanic returns. `changes` are intents, not writes -- `resolve()`
|
|
175
186
|
* applies every one of them through the one choke point (step 5); the
|
|
@@ -201,6 +212,9 @@ export interface Outcome {
|
|
|
201
212
|
t: T;
|
|
202
213
|
result: Record<string, unknown>;
|
|
203
214
|
transitions: ValueTransition[];
|
|
215
|
+
/** Every `set` this resolution applied, in order (issue #32). Kept apart
|
|
216
|
+
* from `transitions`, whose values are numbers. */
|
|
217
|
+
sets: SetTransition[];
|
|
204
218
|
constraint: NarrationConstraint;
|
|
205
219
|
eventId: string;
|
|
206
220
|
}
|
package/dist/timeline/resolve.js
CHANGED
|
@@ -3,7 +3,7 @@ import { getDatabase, withTransaction } from "../db/connection.js";
|
|
|
3
3
|
import { currentStoryTime } from "./clock.js";
|
|
4
4
|
import { narrationConstraintAt, contradictions } from "./narration.js";
|
|
5
5
|
import { withAdjudicationOpen } from "./adjudication.js";
|
|
6
|
-
import { writeConstrainedValue, transferConstrainedValue } from "./constrained.js";
|
|
6
|
+
import { writeConstrainedValue, transferConstrainedValue, setProjectedValue } from "./constrained.js";
|
|
7
7
|
/**
|
|
8
8
|
* Refused before dispatch, before any write, or (never, by construction --
|
|
9
9
|
* see step 5 above) mid-apply. `reason` is the discriminant a caller
|
|
@@ -81,6 +81,9 @@ export function createResolver(params) {
|
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
83
|
function applyChange(change) {
|
|
84
|
+
if (change.kind === "set") {
|
|
85
|
+
return setProjectedValue({ entityId: change.entityId, key: change.key, value: change.value });
|
|
86
|
+
}
|
|
84
87
|
if (change.kind === "write") {
|
|
85
88
|
return [
|
|
86
89
|
writeConstrainedValue({
|
|
@@ -180,8 +183,13 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
180
183
|
// is never relabelled as a ResolveProtocolError).
|
|
181
184
|
const applied = withTransaction(() => withAdjudicationOpen(gameId, () => {
|
|
182
185
|
const transitions = [];
|
|
186
|
+
const sets = [];
|
|
183
187
|
for (const change of changes) {
|
|
184
|
-
|
|
188
|
+
const applied = applyChange(change);
|
|
189
|
+
if (Array.isArray(applied))
|
|
190
|
+
transitions.push(...applied);
|
|
191
|
+
else
|
|
192
|
+
sets.push(applied);
|
|
185
193
|
}
|
|
186
194
|
// Re-read the clock AFTER every write has landed, inside this same
|
|
187
195
|
// transaction -- a sequence-axis game advances its own t once per
|
|
@@ -206,7 +214,7 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
206
214
|
getDatabase()
|
|
207
215
|
.prepare(`INSERT INTO events (id, game_id, at_t, kind, description, causes) VALUES (?, ?, ?, 'resolution.recorded', ?, ?)`)
|
|
208
216
|
.run(eventId, gameId, postStory.t, adjudication.description ?? null, JSON.stringify(causes));
|
|
209
|
-
return { transitions, eventId, postT: postStory.t };
|
|
217
|
+
return { transitions, sets, eventId, postT: postStory.t };
|
|
210
218
|
}));
|
|
211
219
|
// 7. The outcome's constraint, built AFTER the writes landed and the
|
|
212
220
|
// transaction holding them has already committed -- reachable only from a
|
|
@@ -220,6 +228,7 @@ function resolveProposal(mechanicsByName, proposal) {
|
|
|
220
228
|
t,
|
|
221
229
|
result: adjudication.result ?? {},
|
|
222
230
|
transitions: applied.transitions,
|
|
231
|
+
sets: applied.sets,
|
|
223
232
|
constraint: postConstraint,
|
|
224
233
|
eventId: applied.eventId,
|
|
225
234
|
};
|
package/dist/timeline/schema.js
CHANGED
|
@@ -84,6 +84,23 @@ export function initializeTimelineSchema() {
|
|
|
84
84
|
causes TEXT
|
|
85
85
|
)
|
|
86
86
|
`);
|
|
87
|
+
// facts.opened_by_event_id -- issue #30: the one hop of causality (design
|
|
88
|
+
// §5.2c) recorded at the moment it is true, instead of derived at read
|
|
89
|
+
// time by searching `events` for a row whose `at_t`/`causes.row_id` happen
|
|
90
|
+
// to match (the query this replaces lived in provenance.ts, and could
|
|
91
|
+
// return null for a bad `causes` JSON blob, or pick the wrong event of
|
|
92
|
+
// several sharing one `t` by an arbitrary hex-id tiebreak). Idempotent
|
|
93
|
+
// ALTER, ordered after `events` exists -- legal under SQLite's ADD COLUMN
|
|
94
|
+
// restrictions because the implicit default is NULL, and a database that
|
|
95
|
+
// already has this column (every run after the first) just throws here,
|
|
96
|
+
// caught and ignored, same idiom as every other migration in this
|
|
97
|
+
// codebase (root CLAUDE.md).
|
|
98
|
+
try {
|
|
99
|
+
db.exec("ALTER TABLE facts ADD COLUMN opened_by_event_id TEXT REFERENCES events(id)");
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Column already exists.
|
|
103
|
+
}
|
|
87
104
|
// timeline_clock -- one row per game, tracking the declared axis and its
|
|
88
105
|
// current position. A game that never declares an axis still needs one
|
|
89
106
|
// of these once its first entity/fact/event is written, with axis_kind
|
|
@@ -152,6 +169,16 @@ export function initializeTimelineSchema() {
|
|
|
152
169
|
// change. declareIrreversible() (irreversible.ts) relies on that last
|
|
153
170
|
// case to make re-declaring idempotent with a single UPDATE and no
|
|
154
171
|
// separate "already set" check.
|
|
172
|
+
// Third one-way latch (issue #30): NULL -> value, once, exactly like
|
|
173
|
+
// `valid_to_t` above -- once the projection trigger has stamped
|
|
174
|
+
// `opened_by_event_id`, nothing may rewrite it, but the stamp itself (the
|
|
175
|
+
// trigger's own UPDATE, immediately after the fact's INSERT -- see
|
|
176
|
+
// projection.ts) must be allowed through. This clause is load-bearing, not
|
|
177
|
+
// hygiene: probed against better-sqlite3 at this project's own pragma
|
|
178
|
+
// settings, the append-only guard fires on trigger-initiated UPDATEs too.
|
|
179
|
+
// Without it, the projection trigger's own stamp would abort the write it
|
|
180
|
+
// annotates; with it, a stamped edge can never be rewritten -- a re-stamp
|
|
181
|
+
// raises ABORT.
|
|
155
182
|
db.exec(`
|
|
156
183
|
DROP TRIGGER IF EXISTS timeline_facts_immutable;
|
|
157
184
|
CREATE TRIGGER timeline_facts_immutable
|
|
@@ -164,8 +191,9 @@ export function initializeTimelineSchema() {
|
|
|
164
191
|
OR (NEW.irreversible IS NOT OLD.irreversible
|
|
165
192
|
AND (OLD.irreversible IS NOT 0 OR NEW.irreversible IS NOT 1))
|
|
166
193
|
OR (OLD.valid_to_t IS NOT NULL AND NEW.valid_to_t IS NOT OLD.valid_to_t)
|
|
194
|
+
OR (OLD.opened_by_event_id IS NOT NULL AND NEW.opened_by_event_id IS NOT OLD.opened_by_event_id)
|
|
167
195
|
BEGIN
|
|
168
|
-
SELECT RAISE(ABORT, 'timeline: facts are append-only; valid_from_t cannot be rewritten, valid_to_t may only be closed once,
|
|
196
|
+
SELECT RAISE(ABORT, 'timeline: facts are append-only; valid_from_t cannot be rewritten, valid_to_t may only be closed once, irreversible may only move 0 -> 1, and opened_by_event_id may only be stamped once');
|
|
169
197
|
END;
|
|
170
198
|
`);
|
|
171
199
|
db.exec(`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "run-dmcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "An MCP server for LLM-run interactive fiction where the server owns what is true - including when it was true. A continuation of DMCP.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Derek Ferguson",
|