@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,94 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createFcpServer, type FcpDomainAdapter, type OpResult } from "../src/server.js";
3
+ import { EventLog } from "../src/event-log.js";
4
+ import type { ParsedOp } from "../src/parsed-op.js";
5
+ import type { VerbSpec } from "../src/verb-registry.js";
6
+
7
+ // A minimal test domain
8
+ interface TestModel {
9
+ title: string;
10
+ items: string[];
11
+ }
12
+
13
+ type TestEvent = { type: "add"; item: string } | { type: "remove"; item: string };
14
+
15
+ const testVerbs: VerbSpec[] = [
16
+ { verb: "add", syntax: "add ITEM", category: "create", description: "Add an item" },
17
+ { verb: "remove", syntax: "remove ITEM", category: "modify", description: "Remove an item" },
18
+ { verb: "list", syntax: "list", category: "query", description: "List all items" },
19
+ ];
20
+
21
+ const testAdapter: FcpDomainAdapter<TestModel, TestEvent> = {
22
+ createEmpty(title) {
23
+ return { title, items: [] };
24
+ },
25
+ serialize(model) {
26
+ return JSON.stringify(model);
27
+ },
28
+ deserialize(data) {
29
+ return JSON.parse(typeof data === "string" ? data : data.toString());
30
+ },
31
+ rebuildIndices() {},
32
+ getDigest(model) {
33
+ return `[${model.title}: ${model.items.length} items]`;
34
+ },
35
+ dispatchOp(op: ParsedOp, model: TestModel, log: EventLog<TestEvent>): OpResult {
36
+ switch (op.verb) {
37
+ case "add": {
38
+ const item = op.positionals[0];
39
+ if (!item) return { success: false, message: "add requires an item" };
40
+ model.items.push(item);
41
+ log.append({ type: "add", item });
42
+ return { success: true, message: item, prefix: "+" };
43
+ }
44
+ case "remove": {
45
+ const item = op.positionals[0];
46
+ if (!item) return { success: false, message: "remove requires an item" };
47
+ const idx = model.items.indexOf(item);
48
+ if (idx === -1) return { success: false, message: `"${item}" not found` };
49
+ model.items.splice(idx, 1);
50
+ log.append({ type: "remove", item });
51
+ return { success: true, message: item, prefix: "-" };
52
+ }
53
+ default:
54
+ return { success: false, message: `unhandled verb "${op.verb}"` };
55
+ }
56
+ },
57
+ dispatchQuery(query: string, model: TestModel) {
58
+ if (query === "list") {
59
+ return model.items.length === 0 ? "empty" : model.items.join(", ");
60
+ }
61
+ return `unknown query: ${query}`;
62
+ },
63
+ reverseEvent(event: TestEvent, model: TestModel) {
64
+ if (event.type === "add") {
65
+ const idx = model.items.indexOf(event.item);
66
+ if (idx !== -1) model.items.splice(idx, 1);
67
+ } else if (event.type === "remove") {
68
+ model.items.push(event.item);
69
+ }
70
+ },
71
+ };
72
+
73
+ describe("createFcpServer", () => {
74
+ it("creates an MCP server without errors", () => {
75
+ const server = createFcpServer({
76
+ domain: "test",
77
+ adapter: testAdapter,
78
+ verbs: testVerbs,
79
+ });
80
+ expect(server).toBeDefined();
81
+ });
82
+
83
+ it("creates a server with reference card sections", () => {
84
+ const server = createFcpServer({
85
+ domain: "test",
86
+ adapter: testAdapter,
87
+ verbs: testVerbs,
88
+ referenceCard: {
89
+ sections: { "Notes": "Some additional notes" },
90
+ },
91
+ });
92
+ expect(server).toBeDefined();
93
+ });
94
+ });
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { SessionDispatcher, type SessionHooks } from "../src/session.js";
3
+ import { EventLog } from "../src/event-log.js";
4
+
5
+ interface MockModel {
6
+ title: string;
7
+ data: string[];
8
+ }
9
+
10
+ function createMockSession() {
11
+ const eventLog = new EventLog<string>();
12
+ const hooks: SessionHooks<MockModel> = {
13
+ onNew: vi.fn((params) => ({
14
+ title: params["title"] ?? "Untitled",
15
+ data: [],
16
+ })),
17
+ onOpen: vi.fn(async (path) => ({
18
+ title: path,
19
+ data: ["loaded"],
20
+ })),
21
+ onSave: vi.fn(async () => {}),
22
+ onRebuildIndices: vi.fn(),
23
+ getDigest: vi.fn((model) => `[${model.title}: ${model.data.length} items]`),
24
+ };
25
+ const reverseEvent = vi.fn();
26
+ const replayEvent = vi.fn();
27
+ const session = new SessionDispatcher<MockModel, string>(hooks, eventLog, {
28
+ reverseEvent,
29
+ replayEvent,
30
+ });
31
+ return { session, hooks, eventLog, reverseEvent, replayEvent };
32
+ }
33
+
34
+ describe("SessionDispatcher", () => {
35
+ describe("new", () => {
36
+ it("creates a new model with title", async () => {
37
+ const { session, hooks } = createMockSession();
38
+ const result = await session.dispatch('new "My Song"');
39
+ expect(result).toBe('new "My Song" created');
40
+ expect(session.model).not.toBeNull();
41
+ expect(session.model?.title).toBe("My Song");
42
+ expect(hooks.onNew).toHaveBeenCalledWith({ title: "My Song" });
43
+ });
44
+
45
+ it("creates a new model with params", async () => {
46
+ const { session, hooks } = createMockSession();
47
+ await session.dispatch('new "Test" tempo:120');
48
+ expect(hooks.onNew).toHaveBeenCalledWith({ title: "Test", tempo: "120" });
49
+ });
50
+
51
+ it("defaults to Untitled", async () => {
52
+ const { session } = createMockSession();
53
+ const result = await session.dispatch("new");
54
+ expect(result).toBe('new "Untitled" created');
55
+ });
56
+
57
+ it("clears file path", async () => {
58
+ const { session } = createMockSession();
59
+ await session.dispatch("new Test");
60
+ expect(session.filePath).toBeNull();
61
+ });
62
+ });
63
+
64
+ describe("open", () => {
65
+ it("opens a file and sets model", async () => {
66
+ const { session, hooks } = createMockSession();
67
+ const result = await session.dispatch("open ./test.mid");
68
+ expect(result).toBe('opened "./test.mid"');
69
+ expect(session.model).not.toBeNull();
70
+ expect(session.filePath).toBe("./test.mid");
71
+ expect(hooks.onOpen).toHaveBeenCalledWith("./test.mid");
72
+ });
73
+
74
+ it("returns error when no path given", async () => {
75
+ const { session } = createMockSession();
76
+ const result = await session.dispatch("open");
77
+ expect(result).toBe("open requires a file path");
78
+ });
79
+
80
+ it("returns error message on failure", async () => {
81
+ const { session, hooks } = createMockSession();
82
+ (hooks.onOpen as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("file not found"));
83
+ const result = await session.dispatch("open ./missing.mid");
84
+ expect(result).toBe("error: file not found");
85
+ });
86
+ });
87
+
88
+ describe("save", () => {
89
+ it("saves to the open file path", async () => {
90
+ const { session, hooks } = createMockSession();
91
+ await session.dispatch("open ./test.mid");
92
+ const result = await session.dispatch("save");
93
+ expect(result).toBe('saved "./test.mid"');
94
+ expect(hooks.onSave).toHaveBeenCalled();
95
+ });
96
+
97
+ it("saves to a new path with as:", async () => {
98
+ const { session } = createMockSession();
99
+ await session.dispatch("new Test");
100
+ const result = await session.dispatch("save as:./output.mid");
101
+ expect(result).toBe('saved "./output.mid"');
102
+ expect(session.filePath).toBe("./output.mid");
103
+ });
104
+
105
+ it("returns error when no model", async () => {
106
+ const { session } = createMockSession();
107
+ const result = await session.dispatch("save");
108
+ expect(result).toBe("error: no model to save");
109
+ });
110
+
111
+ it("returns error when no path", async () => {
112
+ const { session } = createMockSession();
113
+ await session.dispatch("new Test");
114
+ const result = await session.dispatch("save");
115
+ expect(result).toBe("error: no file path. Use save as:./file");
116
+ });
117
+ });
118
+
119
+ describe("checkpoint", () => {
120
+ it("creates a checkpoint", async () => {
121
+ const { session, eventLog } = createMockSession();
122
+ const result = await session.dispatch("checkpoint v1");
123
+ expect(result).toBe('checkpoint "v1" created');
124
+ expect(eventLog.cursor).toBeGreaterThan(0);
125
+ });
126
+
127
+ it("requires a name", async () => {
128
+ const { session } = createMockSession();
129
+ const result = await session.dispatch("checkpoint");
130
+ expect(result).toBe("checkpoint requires a name");
131
+ });
132
+ });
133
+
134
+ describe("undo", () => {
135
+ it("undoes events", async () => {
136
+ const { session, eventLog } = createMockSession();
137
+ await session.dispatch("new Test");
138
+ eventLog.append("event1");
139
+ eventLog.append("event2");
140
+ const result = await session.dispatch("undo");
141
+ expect(result).toBe("undone 1 event");
142
+ });
143
+
144
+ it("returns nothing to undo when empty", async () => {
145
+ const { session } = createMockSession();
146
+ const result = await session.dispatch("undo");
147
+ expect(result).toBe("nothing to undo");
148
+ });
149
+
150
+ it("undoes to a checkpoint", async () => {
151
+ const { session, eventLog } = createMockSession();
152
+ await session.dispatch("new Test");
153
+ eventLog.append("event1");
154
+ await session.dispatch("checkpoint v1");
155
+ eventLog.append("event2");
156
+ eventLog.append("event3");
157
+ const result = await session.dispatch("undo to:v1");
158
+ expect(result).toContain("undone");
159
+ expect(result).toContain("checkpoint");
160
+ });
161
+
162
+ it("calls onRebuildIndices after undo", async () => {
163
+ const { session, hooks, eventLog } = createMockSession();
164
+ await session.dispatch("new Test");
165
+ eventLog.append("event1");
166
+ await session.dispatch("undo");
167
+ expect(hooks.onRebuildIndices).toHaveBeenCalled();
168
+ });
169
+ });
170
+
171
+ describe("redo", () => {
172
+ it("redoes events", async () => {
173
+ const { session, eventLog } = createMockSession();
174
+ await session.dispatch("new Test");
175
+ eventLog.append("event1");
176
+ eventLog.undo();
177
+ const result = await session.dispatch("redo");
178
+ expect(result).toBe("redone 1 event");
179
+ });
180
+
181
+ it("returns nothing to redo when at end", async () => {
182
+ const { session } = createMockSession();
183
+ const result = await session.dispatch("redo");
184
+ expect(result).toBe("nothing to redo");
185
+ });
186
+
187
+ it("calls onRebuildIndices after redo", async () => {
188
+ const { session, hooks, eventLog } = createMockSession();
189
+ await session.dispatch("new Test");
190
+ eventLog.append("event1");
191
+ eventLog.undo();
192
+ await session.dispatch("redo");
193
+ expect(hooks.onRebuildIndices).toHaveBeenCalled();
194
+ });
195
+ });
196
+
197
+ describe("unknown commands", () => {
198
+ it("returns error for unknown actions", async () => {
199
+ const { session } = createMockSession();
200
+ const result = await session.dispatch("explode");
201
+ expect(result).toBe('unknown session action "explode"');
202
+ });
203
+
204
+ it("returns error for empty action", async () => {
205
+ const { session } = createMockSession();
206
+ const result = await session.dispatch("");
207
+ expect(result).toBe("empty action");
208
+ });
209
+ });
210
+ });
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { tokenize, isKeyValue, parseKeyValue, isArrow, isSelector } from "../src/tokenizer.js";
3
+
4
+ describe("tokenize", () => {
5
+ it("splits simple tokens", () => {
6
+ expect(tokenize("add svc AuthService")).toEqual(["add", "svc", "AuthService"]);
7
+ });
8
+
9
+ it("handles quoted strings", () => {
10
+ expect(tokenize('add svc "Auth Service" theme:blue')).toEqual([
11
+ "add", "svc", "Auth Service", "theme:blue",
12
+ ]);
13
+ });
14
+
15
+ it("handles escaped quotes inside quoted strings", () => {
16
+ expect(tokenize('label A "say \\"hello\\""')).toEqual([
17
+ "label", "A", 'say "hello"',
18
+ ]);
19
+ });
20
+
21
+ it("handles empty input", () => {
22
+ expect(tokenize("")).toEqual([]);
23
+ });
24
+
25
+ it("handles whitespace-only input", () => {
26
+ expect(tokenize(" ")).toEqual([]);
27
+ });
28
+
29
+ it("handles multiple spaces between tokens", () => {
30
+ expect(tokenize("add svc A")).toEqual(["add", "svc", "A"]);
31
+ });
32
+
33
+ it("converts literal \\n to newline in unquoted tokens", () => {
34
+ expect(tokenize("add svc Container\\nRegistry")).toEqual([
35
+ "add", "svc", "Container\nRegistry",
36
+ ]);
37
+ });
38
+
39
+ it("converts literal \\n to newline in quoted strings", () => {
40
+ expect(tokenize('add svc "Container\\nRegistry"')).toEqual([
41
+ "add", "svc", "Container\nRegistry",
42
+ ]);
43
+ });
44
+
45
+ it("handles embedded quoted values (key:\"value\")", () => {
46
+ expect(tokenize('label:"Line1\\nLine2"')).toEqual(["label:Line1\nLine2"]);
47
+ });
48
+
49
+ it("converts multiple \\n sequences", () => {
50
+ expect(tokenize("add svc A\\nB\\nC")).toEqual(["add", "svc", "A\nB\nC"]);
51
+ });
52
+
53
+ it("handles single token", () => {
54
+ expect(tokenize("add")).toEqual(["add"]);
55
+ });
56
+
57
+ it("handles empty quoted string", () => {
58
+ expect(tokenize('""')).toEqual([""]);
59
+ });
60
+
61
+ it("handles unclosed quote (takes rest as token)", () => {
62
+ expect(tokenize('"hello world')).toEqual(["hello world"]);
63
+ });
64
+
65
+ it("handles escaped backslash in quotes", () => {
66
+ expect(tokenize('"path\\\\dir"')).toEqual(["path\\dir"]);
67
+ });
68
+
69
+ it("handles key:value with colons in value", () => {
70
+ expect(tokenize("url:http://example.com")).toEqual(["url:http://example.com"]);
71
+ });
72
+ });
73
+
74
+ describe("isKeyValue", () => {
75
+ it("returns true for key:value", () => {
76
+ expect(isKeyValue("theme:blue")).toBe(true);
77
+ });
78
+
79
+ it("returns true for key:value with colons in value", () => {
80
+ expect(isKeyValue("url:http://x")).toBe(true);
81
+ });
82
+
83
+ it("returns false for selectors", () => {
84
+ expect(isKeyValue("@type:db")).toBe(false);
85
+ });
86
+
87
+ it("returns false for arrows", () => {
88
+ expect(isKeyValue("->")).toBe(false);
89
+ });
90
+
91
+ it("returns false for plain words", () => {
92
+ expect(isKeyValue("hello")).toBe(false);
93
+ });
94
+
95
+ it("returns false for trailing colon", () => {
96
+ expect(isKeyValue("key:")).toBe(false);
97
+ });
98
+
99
+ it("returns false for leading colon", () => {
100
+ expect(isKeyValue(":value")).toBe(false);
101
+ });
102
+ });
103
+
104
+ describe("parseKeyValue", () => {
105
+ it("parses simple key:value", () => {
106
+ expect(parseKeyValue("theme:blue")).toEqual({ key: "theme", value: "blue" });
107
+ });
108
+
109
+ it("parses value with colons", () => {
110
+ expect(parseKeyValue("url:http://x:8080")).toEqual({ key: "url", value: "http://x:8080" });
111
+ });
112
+ });
113
+
114
+ describe("isArrow", () => {
115
+ it("recognizes ->", () => {
116
+ expect(isArrow("->")).toBe(true);
117
+ });
118
+
119
+ it("recognizes <->", () => {
120
+ expect(isArrow("<->")).toBe(true);
121
+ });
122
+
123
+ it("recognizes --", () => {
124
+ expect(isArrow("--")).toBe(true);
125
+ });
126
+
127
+ it("rejects other tokens", () => {
128
+ expect(isArrow("=>")).toBe(false);
129
+ expect(isArrow("add")).toBe(false);
130
+ });
131
+ });
132
+
133
+ describe("isSelector", () => {
134
+ it("recognizes @-prefixed tokens", () => {
135
+ expect(isSelector("@type:db")).toBe(true);
136
+ expect(isSelector("@all")).toBe(true);
137
+ expect(isSelector("@recent:5")).toBe(true);
138
+ });
139
+
140
+ it("rejects non-@ tokens", () => {
141
+ expect(isSelector("type:db")).toBe(false);
142
+ expect(isSelector("add")).toBe(false);
143
+ });
144
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { VerbRegistry, type VerbSpec } from "../src/verb-registry.js";
3
+
4
+ describe("VerbRegistry", () => {
5
+ function createTestRegistry(): VerbRegistry {
6
+ const reg = new VerbRegistry();
7
+ reg.registerMany([
8
+ { verb: "add", syntax: "add TYPE LABEL [key:value]", category: "create" },
9
+ { verb: "remove", syntax: "remove SELECTOR", category: "modify" },
10
+ { verb: "connect", syntax: "connect SRC -> TGT", category: "create", description: "Create an edge" },
11
+ { verb: "style", syntax: "style REF [fill:#HEX]", category: "modify", params: ["fill", "stroke"] },
12
+ ]);
13
+ return reg;
14
+ }
15
+
16
+ describe("register/lookup", () => {
17
+ it("registers and looks up a verb", () => {
18
+ const reg = new VerbRegistry();
19
+ const spec: VerbSpec = { verb: "add", syntax: "add TYPE LABEL", category: "create" };
20
+ reg.register(spec);
21
+ expect(reg.lookup("add")).toEqual(spec);
22
+ });
23
+
24
+ it("returns undefined for unknown verbs", () => {
25
+ const reg = new VerbRegistry();
26
+ expect(reg.lookup("nonexistent")).toBeUndefined();
27
+ });
28
+
29
+ it("registers many at once", () => {
30
+ const reg = createTestRegistry();
31
+ expect(reg.lookup("add")).toBeDefined();
32
+ expect(reg.lookup("remove")).toBeDefined();
33
+ expect(reg.lookup("connect")).toBeDefined();
34
+ expect(reg.lookup("style")).toBeDefined();
35
+ });
36
+ });
37
+
38
+ describe("verbs", () => {
39
+ it("returns all registered specs", () => {
40
+ const reg = createTestRegistry();
41
+ expect(reg.verbs).toHaveLength(4);
42
+ const verbs = reg.verbs.map((v) => v.verb);
43
+ expect(verbs).toContain("add");
44
+ expect(verbs).toContain("remove");
45
+ expect(verbs).toContain("connect");
46
+ expect(verbs).toContain("style");
47
+ });
48
+ });
49
+
50
+ describe("generateReferenceCard", () => {
51
+ it("groups verbs by category", () => {
52
+ const reg = createTestRegistry();
53
+ const card = reg.generateReferenceCard();
54
+ expect(card).toContain("CREATE:");
55
+ expect(card).toContain("MODIFY:");
56
+ expect(card).toContain(" add TYPE LABEL [key:value]");
57
+ expect(card).toContain(" connect SRC -> TGT");
58
+ expect(card).toContain(" remove SELECTOR");
59
+ expect(card).toContain(" style REF [fill:#HEX]");
60
+ });
61
+
62
+ it("appends additional sections", () => {
63
+ const reg = createTestRegistry();
64
+ const card = reg.generateReferenceCard({
65
+ "Themes": " blue #dae8fc\n red #f8cecc",
66
+ });
67
+ expect(card).toContain("THEMES:");
68
+ expect(card).toContain(" blue #dae8fc");
69
+ });
70
+
71
+ it("returns empty string for empty registry", () => {
72
+ const reg = new VerbRegistry();
73
+ expect(reg.generateReferenceCard()).toBe("");
74
+ });
75
+ });
76
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "sourceMap": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "resolveJsonModule": true
16
+ },
17
+ "include": ["src/**/*.ts"],
18
+ "exclude": ["node_modules", "dist", "tests"]
19
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["tests/**/*.test.ts"],
6
+ },
7
+ });