@aetherwing/fcp-core 0.1.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.
Files changed (50) hide show
  1. package/dist/event-log.d.ts +78 -0
  2. package/dist/event-log.d.ts.map +1 -0
  3. package/dist/event-log.js +184 -0
  4. package/dist/event-log.js.map +1 -0
  5. package/dist/formatter.d.ts +19 -0
  6. package/dist/formatter.d.ts.map +1 -0
  7. package/dist/formatter.js +64 -0
  8. package/dist/formatter.js.map +1 -0
  9. package/dist/index.d.ts +8 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +15 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/parsed-op.d.ts +32 -0
  14. package/dist/parsed-op.d.ts.map +1 -0
  15. package/dist/parsed-op.js +41 -0
  16. package/dist/parsed-op.js.map +1 -0
  17. package/dist/server.d.ts +71 -0
  18. package/dist/server.d.ts.map +1 -0
  19. package/dist/server.js +140 -0
  20. package/dist/server.js.map +1 -0
  21. package/dist/session.d.ts +40 -0
  22. package/dist/session.d.ts.map +1 -0
  23. package/dist/session.js +142 -0
  24. package/dist/session.js.map +1 -0
  25. package/dist/tokenizer.d.ts +26 -0
  26. package/dist/tokenizer.d.ts.map +1 -0
  27. package/dist/tokenizer.js +114 -0
  28. package/dist/tokenizer.js.map +1 -0
  29. package/dist/verb-registry.d.ts +41 -0
  30. package/dist/verb-registry.d.ts.map +1 -0
  31. package/dist/verb-registry.js +65 -0
  32. package/dist/verb-registry.js.map +1 -0
  33. package/package.json +30 -0
  34. package/src/event-log.ts +209 -0
  35. package/src/formatter.ts +70 -0
  36. package/src/index.ts +40 -0
  37. package/src/parsed-op.ts +64 -0
  38. package/src/server.ts +241 -0
  39. package/src/session.ts +163 -0
  40. package/src/tokenizer.ts +108 -0
  41. package/src/verb-registry.ts +84 -0
  42. package/tests/event-log.test.ts +177 -0
  43. package/tests/formatter.test.ts +61 -0
  44. package/tests/parsed-op.test.ts +95 -0
  45. package/tests/server.test.ts +94 -0
  46. package/tests/session.test.ts +210 -0
  47. package/tests/tokenizer.test.ts +144 -0
  48. package/tests/verb-registry.test.ts +76 -0
  49. package/tsconfig.json +19 -0
  50. package/vitest.config.ts +7 -0
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Sentinel object stored in the event log to mark a checkpoint.
3
+ */
4
+ const CHECKPOINT_SENTINEL = Symbol("checkpoint");
5
+
6
+ interface CheckpointEntry {
7
+ __type: typeof CHECKPOINT_SENTINEL;
8
+ name: string;
9
+ }
10
+
11
+ function isCheckpoint<T>(entry: T | CheckpointEntry): entry is CheckpointEntry {
12
+ return (
13
+ typeof entry === "object" &&
14
+ entry !== null &&
15
+ "__type" in entry &&
16
+ (entry as CheckpointEntry).__type === CHECKPOINT_SENTINEL
17
+ );
18
+ }
19
+
20
+ /**
21
+ * Generic cursor-based event log with undo/redo and named checkpoints.
22
+ *
23
+ * Events are appended at the cursor position. The cursor always points
24
+ * one past the last applied event. Undo moves the cursor back; redo
25
+ * moves it forward. Appending a new event truncates the redo tail.
26
+ *
27
+ * Checkpoint sentinels are stored in the log but skipped during
28
+ * undo/redo traversal.
29
+ */
30
+ export class EventLog<T> {
31
+ private events: Array<T | CheckpointEntry> = [];
32
+ private _cursor = 0;
33
+ private checkpoints = new Map<string, number>();
34
+
35
+ /**
36
+ * Append an event, truncating any redo history beyond the cursor.
37
+ */
38
+ append(event: T): void {
39
+ if (this._cursor < this.events.length) {
40
+ this.events.length = this._cursor;
41
+ // Remove checkpoints pointing beyond new length
42
+ for (const [name, idx] of this.checkpoints) {
43
+ if (idx > this._cursor) {
44
+ this.checkpoints.delete(name);
45
+ }
46
+ }
47
+ }
48
+ this.events.push(event);
49
+ this._cursor = this.events.length;
50
+ }
51
+
52
+ /**
53
+ * Create a named checkpoint at the current cursor position.
54
+ */
55
+ checkpoint(name: string): void {
56
+ this.checkpoints.set(name, this._cursor);
57
+ const sentinel: CheckpointEntry = { __type: CHECKPOINT_SENTINEL, name };
58
+ this.events.push(sentinel);
59
+ this._cursor = this.events.length;
60
+ }
61
+
62
+ /**
63
+ * Undo up to `count` non-checkpoint events. Returns events in reverse
64
+ * order (most recent first) for the caller to reverse-apply.
65
+ */
66
+ undo(count: number = 1): T[] {
67
+ const result: T[] = [];
68
+ let pos = this._cursor - 1;
69
+ let undone = 0;
70
+
71
+ while (pos >= 0 && undone < count) {
72
+ const entry = this.events[pos];
73
+ if (!isCheckpoint(entry)) {
74
+ result.push(entry);
75
+ undone++;
76
+ }
77
+ pos--;
78
+ }
79
+
80
+ this._cursor = pos + 1;
81
+ return result;
82
+ }
83
+
84
+ /**
85
+ * Undo to a named checkpoint. Returns events in reverse order.
86
+ * Returns null if the checkpoint doesn't exist or is at/beyond cursor.
87
+ */
88
+ undoTo(name: string): T[] | null {
89
+ const target = this.checkpoints.get(name);
90
+ if (target === undefined || target >= this._cursor) return null;
91
+
92
+ const result: T[] = [];
93
+ for (let i = this._cursor - 1; i >= target; i--) {
94
+ const entry = this.events[i];
95
+ if (!isCheckpoint(entry)) {
96
+ result.push(entry);
97
+ }
98
+ }
99
+ this._cursor = target;
100
+ return result;
101
+ }
102
+
103
+ /**
104
+ * Redo up to `count` non-checkpoint events. Returns events in forward
105
+ * order for the caller to re-apply.
106
+ */
107
+ redo(count: number = 1): T[] {
108
+ const result: T[] = [];
109
+ let pos = this._cursor;
110
+ let redone = 0;
111
+
112
+ while (pos < this.events.length && redone < count) {
113
+ const entry = this.events[pos];
114
+ if (!isCheckpoint(entry)) {
115
+ result.push(entry);
116
+ redone++;
117
+ }
118
+ pos++;
119
+ }
120
+
121
+ this._cursor = pos;
122
+ return result;
123
+ }
124
+
125
+ /**
126
+ * Get the last N non-checkpoint events (up to cursor). Returned in
127
+ * chronological order (oldest first).
128
+ */
129
+ recent(count?: number): T[] {
130
+ const limit = count ?? this._cursor;
131
+ const result: T[] = [];
132
+ for (let i = this._cursor - 1; i >= 0 && result.length < limit; i--) {
133
+ const entry = this.events[i];
134
+ if (!isCheckpoint(entry)) {
135
+ result.push(entry);
136
+ }
137
+ }
138
+ return result.reverse();
139
+ }
140
+
141
+ /**
142
+ * Current cursor position (one past last applied event).
143
+ */
144
+ get cursor(): number {
145
+ return this._cursor;
146
+ }
147
+
148
+ /**
149
+ * Total number of entries in the log (including checkpoints).
150
+ */
151
+ get length(): number {
152
+ return this.events.length;
153
+ }
154
+
155
+ /**
156
+ * Whether there are events before the cursor that can be undone.
157
+ */
158
+ canUndo(): boolean {
159
+ for (let i = this._cursor - 1; i >= 0; i--) {
160
+ if (!isCheckpoint(this.events[i])) return true;
161
+ }
162
+ return false;
163
+ }
164
+
165
+ /**
166
+ * Whether there are events after the cursor that can be redone.
167
+ */
168
+ canRedo(): boolean {
169
+ return this._cursor < this.events.length;
170
+ }
171
+
172
+ /**
173
+ * Get the event index for a named checkpoint.
174
+ * Returns undefined if the checkpoint doesn't exist.
175
+ */
176
+ getCheckpointIndex(name: string): number | undefined {
177
+ return this.checkpoints.get(name);
178
+ }
179
+
180
+ /**
181
+ * Number of named checkpoints.
182
+ */
183
+ get checkpointCount(): number {
184
+ return this.checkpoints.size;
185
+ }
186
+
187
+ /**
188
+ * Get all checkpoint names and their indices.
189
+ */
190
+ getCheckpoints(): Map<string, number> {
191
+ return new Map(this.checkpoints);
192
+ }
193
+
194
+ /**
195
+ * Get non-checkpoint events from a given index to the cursor.
196
+ * Used for diff queries.
197
+ */
198
+ eventsSince(fromIndex: number): T[] {
199
+ const result: T[] = [];
200
+ const end = Math.min(this._cursor, this.events.length);
201
+ for (let i = fromIndex; i < end; i++) {
202
+ const entry = this.events[i];
203
+ if (!isCheckpoint(entry)) {
204
+ result.push(entry);
205
+ }
206
+ }
207
+ return result;
208
+ }
209
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Format a result message with a prefix character.
3
+ *
4
+ * Prefix conventions:
5
+ * + created
6
+ * ~ modified (edge/connection)
7
+ * * changed (property)
8
+ * - removed
9
+ * ! meta/group operation
10
+ * @ bulk/layout operation
11
+ */
12
+ export function formatResult(
13
+ success: boolean,
14
+ message: string,
15
+ prefix?: string,
16
+ ): string {
17
+ if (!success) return `ERROR: ${message}`;
18
+ if (prefix) return `${prefix} ${message}`;
19
+ return message;
20
+ }
21
+
22
+ /**
23
+ * Suggest a correction for a misspelled input by finding the closest
24
+ * candidate using Levenshtein distance. Returns null if no candidate
25
+ * is close enough (distance > 3).
26
+ */
27
+ export function suggest(input: string, candidates: string[]): string | null {
28
+ if (candidates.length === 0) return null;
29
+
30
+ let best: string | null = null;
31
+ let bestDist = Infinity;
32
+
33
+ for (const candidate of candidates) {
34
+ const dist = levenshtein(input.toLowerCase(), candidate.toLowerCase());
35
+ if (dist < bestDist) {
36
+ bestDist = dist;
37
+ best = candidate;
38
+ }
39
+ }
40
+
41
+ return bestDist <= 3 ? best : null;
42
+ }
43
+
44
+ /**
45
+ * Compute Levenshtein distance between two strings.
46
+ */
47
+ function levenshtein(a: string, b: string): number {
48
+ const m = a.length;
49
+ const n = b.length;
50
+
51
+ // Use a single-row DP array
52
+ const prev = new Array<number>(n + 1);
53
+ for (let j = 0; j <= n; j++) prev[j] = j;
54
+
55
+ for (let i = 1; i <= m; i++) {
56
+ let prevDiag = prev[0];
57
+ prev[0] = i;
58
+ for (let j = 1; j <= n; j++) {
59
+ const temp = prev[j];
60
+ if (a[i - 1] === b[j - 1]) {
61
+ prev[j] = prevDiag;
62
+ } else {
63
+ prev[j] = 1 + Math.min(prevDiag, prev[j - 1], prev[j]);
64
+ }
65
+ prevDiag = temp;
66
+ }
67
+ }
68
+
69
+ return prev[n];
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ // Tokenizer
2
+ export {
3
+ tokenize,
4
+ isKeyValue,
5
+ parseKeyValue,
6
+ isArrow,
7
+ isSelector,
8
+ } from "./tokenizer.js";
9
+
10
+ // Parsed operation
11
+ export {
12
+ parseOp,
13
+ isParseError,
14
+ type ParsedOp,
15
+ type ParseError,
16
+ } from "./parsed-op.js";
17
+
18
+ // Event log
19
+ export { EventLog } from "./event-log.js";
20
+
21
+ // Verb registry
22
+ export { VerbRegistry, type VerbSpec } from "./verb-registry.js";
23
+
24
+ // Session
25
+ export {
26
+ SessionDispatcher,
27
+ type SessionHooks,
28
+ } from "./session.js";
29
+
30
+ // Formatter
31
+ export { formatResult, suggest } from "./formatter.js";
32
+
33
+ // Server
34
+ export {
35
+ createFcpServer,
36
+ type FcpServerConfig,
37
+ type FcpDomainAdapter,
38
+ type OpResult,
39
+ type QueryResult,
40
+ } from "./server.js";
@@ -0,0 +1,64 @@
1
+ import { tokenize, isKeyValue, parseKeyValue, isSelector, isArrow } from "./tokenizer.js";
2
+
3
+ /**
4
+ * A successfully parsed operation.
5
+ */
6
+ export interface ParsedOp {
7
+ verb: string;
8
+ positionals: string[];
9
+ params: Record<string, string>;
10
+ selectors: string[];
11
+ raw: string;
12
+ }
13
+
14
+ /**
15
+ * A parse failure.
16
+ */
17
+ export interface ParseError {
18
+ success: false;
19
+ error: string;
20
+ raw: string;
21
+ }
22
+
23
+ /**
24
+ * Parse an operation string into a structured ParsedOp.
25
+ *
26
+ * First token becomes the verb. Remaining tokens are classified:
27
+ * @-prefixed -> selectors
28
+ * key:value -> params
29
+ * everything else -> positionals (in order)
30
+ */
31
+ export function parseOp(input: string): ParsedOp | ParseError {
32
+ const raw = input.trim();
33
+ const tokens = tokenize(raw);
34
+
35
+ if (tokens.length === 0) {
36
+ return { success: false, error: "empty operation", raw };
37
+ }
38
+
39
+ const verb = tokens[0].toLowerCase();
40
+ const positionals: string[] = [];
41
+ const params: Record<string, string> = {};
42
+ const selectors: string[] = [];
43
+
44
+ for (let i = 1; i < tokens.length; i++) {
45
+ const token = tokens[i];
46
+ if (isSelector(token)) {
47
+ selectors.push(token);
48
+ } else if (isKeyValue(token)) {
49
+ const { key, value } = parseKeyValue(token);
50
+ params[key] = value;
51
+ } else {
52
+ positionals.push(token);
53
+ }
54
+ }
55
+
56
+ return { verb, positionals, params, selectors, raw };
57
+ }
58
+
59
+ /**
60
+ * Type guard: check if a parse result is an error.
61
+ */
62
+ export function isParseError(result: ParsedOp | ParseError): result is ParseError {
63
+ return "success" in result && result.success === false;
64
+ }
package/src/server.ts ADDED
@@ -0,0 +1,241 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { EventLog } from "./event-log.js";
4
+ import { parseOp, isParseError } from "./parsed-op.js";
5
+ import type { ParsedOp } from "./parsed-op.js";
6
+ import { VerbRegistry } from "./verb-registry.js";
7
+ import type { VerbSpec } from "./verb-registry.js";
8
+ import { SessionDispatcher, type SessionHooks } from "./session.js";
9
+ import { formatResult, suggest } from "./formatter.js";
10
+
11
+ /**
12
+ * Result of executing a single operation.
13
+ */
14
+ export interface OpResult {
15
+ success: boolean;
16
+ message: string;
17
+ prefix?: string;
18
+ }
19
+
20
+ /**
21
+ * Result of a query that may include an image.
22
+ */
23
+ export interface QueryResult {
24
+ text: string;
25
+ image?: { base64: string; mimeType: string };
26
+ }
27
+
28
+ /**
29
+ * Domain adapter that an FCP domain must implement.
30
+ */
31
+ export interface FcpDomainAdapter<Model, Event> {
32
+ /** Create a new empty model. */
33
+ createEmpty(title: string, params: Record<string, string>): Model;
34
+ /** Serialize model to saveable format. */
35
+ serialize(model: Model): Buffer | string;
36
+ /** Deserialize from file contents. */
37
+ deserialize(data: Buffer | string): Model;
38
+ /** Rebuild derived indices (e.g., after undo/redo). */
39
+ rebuildIndices(model: Model): void;
40
+ /** Return a compact digest for drift detection. */
41
+ getDigest(model: Model): string;
42
+ /** Execute a parsed operation against the model. */
43
+ dispatchOp(op: ParsedOp, model: Model, log: EventLog<Event>): OpResult | Promise<OpResult>;
44
+ /** Execute a query against the model. */
45
+ dispatchQuery(query: string, model: Model): string | QueryResult | Promise<string | QueryResult>;
46
+ /** Reverse a single event (for undo). */
47
+ reverseEvent(event: Event, model: Model): void;
48
+ /** Replay a single event (for redo). */
49
+ replayEvent(event: Event, model: Model): void;
50
+ }
51
+
52
+ /**
53
+ * Configuration for creating an FCP MCP server.
54
+ */
55
+ export interface FcpServerConfig<Model, Event> {
56
+ /** Domain name (e.g., "midi", "studio"). Used as tool name prefix. */
57
+ domain: string;
58
+ /** Domain adapter implementing all domain-specific logic. */
59
+ adapter: FcpDomainAdapter<Model, Event>;
60
+ /** Verb specifications for this domain. */
61
+ verbs: VerbSpec[];
62
+ /** Optional reference card configuration. */
63
+ referenceCard?: { sections?: Record<string, string> };
64
+ }
65
+
66
+ /**
67
+ * Create an MCP server wired up with FCP conventions.
68
+ *
69
+ * Registers 4 tools:
70
+ * {domain} — primary mutation tool (ops array)
71
+ * {domain}_query — read-only queries
72
+ * {domain}_session — lifecycle (new, open, save, checkpoint, undo, redo)
73
+ * {domain}_help — reference card
74
+ */
75
+ export function createFcpServer<Model, Event>(
76
+ config: FcpServerConfig<Model, Event>,
77
+ ): McpServer {
78
+ const { domain, adapter, verbs, referenceCard } = config;
79
+
80
+ // Build registry and reference card
81
+ const registry = new VerbRegistry();
82
+ registry.registerMany(verbs);
83
+ const refCard = registry.generateReferenceCard(referenceCard?.sections);
84
+
85
+ // Event log
86
+ const eventLog = new EventLog<Event>();
87
+
88
+ // Session dispatcher with hooks
89
+ const sessionHooks: SessionHooks<Model> = {
90
+ onNew(params) {
91
+ return adapter.createEmpty(params["title"] ?? "Untitled", params);
92
+ },
93
+ async onOpen(path) {
94
+ const { readFile } = await import("node:fs/promises");
95
+ const data = await readFile(path);
96
+ const model = adapter.deserialize(data);
97
+ adapter.rebuildIndices(model);
98
+ return model;
99
+ },
100
+ async onSave(model, path) {
101
+ const { writeFile } = await import("node:fs/promises");
102
+ const data = adapter.serialize(model);
103
+ await writeFile(path, data);
104
+ },
105
+ onRebuildIndices(model) {
106
+ adapter.rebuildIndices(model);
107
+ },
108
+ getDigest(model) {
109
+ return adapter.getDigest(model);
110
+ },
111
+ };
112
+ const session = new SessionDispatcher<Model, Event>(sessionHooks, eventLog, {
113
+ reverseEvent: (event, model) => adapter.reverseEvent(event, model),
114
+ replayEvent: (event, model) => adapter.replayEvent(event, model),
115
+ });
116
+
117
+ // MCP server
118
+ const server = new McpServer({
119
+ name: `fcp-${domain}`,
120
+ version: "0.1.0",
121
+ });
122
+
123
+ // ── Primary mutation tool ──────────────────────────────
124
+ server.tool(
125
+ domain,
126
+ `Execute ${domain} operations. Each op string follows the FCP verb DSL.\n\n${refCard}`,
127
+ {
128
+ ops: z
129
+ .array(z.string())
130
+ .describe("Array of operation strings"),
131
+ },
132
+ async ({ ops }) => {
133
+ const model = session.model;
134
+ if (!model) {
135
+ return {
136
+ content: [{ type: "text" as const, text: "error: no model loaded. Use session 'new' or 'open' first." }],
137
+ isError: true,
138
+ };
139
+ }
140
+
141
+ const lines: string[] = [];
142
+ let hasErrors = false;
143
+
144
+ for (const opStr of ops) {
145
+ const parsed = parseOp(opStr);
146
+ if (isParseError(parsed)) {
147
+ lines.push(`ERROR: ${parsed.error}`);
148
+ hasErrors = true;
149
+ continue;
150
+ }
151
+
152
+ // Check if verb is known
153
+ const spec = registry.lookup(parsed.verb);
154
+ if (!spec) {
155
+ const suggestion = suggest(parsed.verb, verbs.map((v) => v.verb));
156
+ const msg = `unknown verb "${parsed.verb}"`;
157
+ lines.push(suggestion ? `ERROR: ${msg}\n try: ${suggestion}` : `ERROR: ${msg}`);
158
+ hasErrors = true;
159
+ continue;
160
+ }
161
+
162
+ const result = await adapter.dispatchOp(parsed, model, eventLog);
163
+ lines.push(formatResult(result.success, result.message, result.prefix));
164
+ if (!result.success) hasErrors = true;
165
+ }
166
+
167
+ // Append digest
168
+ lines.push(adapter.getDigest(model));
169
+
170
+ return {
171
+ content: [{ type: "text" as const, text: lines.join("\n") }],
172
+ isError: hasErrors,
173
+ };
174
+ },
175
+ );
176
+
177
+ // ── Query tool ─────────────────────────────────────────
178
+ server.tool(
179
+ `${domain}_query`,
180
+ `Query ${domain} state. Read-only.`,
181
+ {
182
+ q: z.string().describe("Query string"),
183
+ },
184
+ async ({ q }) => {
185
+ const model = session.model;
186
+ if (!model) {
187
+ return {
188
+ content: [{ type: "text" as const, text: "error: no model loaded." }],
189
+ isError: true,
190
+ };
191
+ }
192
+
193
+ const result = await adapter.dispatchQuery(q, model);
194
+
195
+ if (typeof result === "string") {
196
+ return { content: [{ type: "text" as const, text: result }] };
197
+ }
198
+
199
+ const qr = result as QueryResult;
200
+ const content: Array<
201
+ | { type: "text"; text: string }
202
+ | { type: "image"; data: string; mimeType: string }
203
+ > = [];
204
+ if (qr.image) {
205
+ content.push({ type: "image" as const, data: qr.image.base64, mimeType: qr.image.mimeType });
206
+ }
207
+ content.push({ type: "text" as const, text: qr.text });
208
+ return { content };
209
+ },
210
+ );
211
+
212
+ // ── Session tool ───────────────────────────────────────
213
+ server.tool(
214
+ `${domain}_session`,
215
+ `${domain} lifecycle: new, open, save, checkpoint, undo, redo.`,
216
+ {
217
+ action: z.string().describe(
218
+ "Action: 'new \"Title\"', 'open ./file', 'save', 'save as:./out', 'checkpoint v1', 'undo', 'undo to:v1', 'redo'",
219
+ ),
220
+ },
221
+ async ({ action }) => {
222
+ const text = await session.dispatch(action);
223
+ const model = session.model;
224
+ const digest = model ? adapter.getDigest(model) : "";
225
+ const output = digest ? `${text}\n${digest}` : text;
226
+ return { content: [{ type: "text" as const, text: output }] };
227
+ },
228
+ );
229
+
230
+ // ── Help tool ──────────────────────────────────────────
231
+ server.tool(
232
+ `${domain}_help`,
233
+ `Returns the ${domain} FCP reference card.`,
234
+ {},
235
+ async () => {
236
+ return { content: [{ type: "text" as const, text: refCard }] };
237
+ },
238
+ );
239
+
240
+ return server;
241
+ }