@jbarket/loomai 0.5.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 (176) hide show
  1. package/CHANGELOG.md +475 -0
  2. package/LICENSE +661 -0
  3. package/README.md +702 -0
  4. package/assets/demo.cast +396 -0
  5. package/assets/skill/SKILL.md +192 -0
  6. package/dist/backends/embedding-runtime.d.ts +39 -0
  7. package/dist/backends/embedding-runtime.js +265 -0
  8. package/dist/backends/episodes.d.ts +36 -0
  9. package/dist/backends/episodes.js +126 -0
  10. package/dist/backends/fastembed.d.ts +18 -0
  11. package/dist/backends/fastembed.js +95 -0
  12. package/dist/backends/glob.d.ts +10 -0
  13. package/dist/backends/glob.js +16 -0
  14. package/dist/backends/index.d.ts +6 -0
  15. package/dist/backends/index.js +78 -0
  16. package/dist/backends/migrations.d.ts +38 -0
  17. package/dist/backends/migrations.js +157 -0
  18. package/dist/backends/mmr.d.ts +45 -0
  19. package/dist/backends/mmr.js +82 -0
  20. package/dist/backends/proposals.d.ts +65 -0
  21. package/dist/backends/proposals.js +182 -0
  22. package/dist/backends/recall-log.d.ts +42 -0
  23. package/dist/backends/recall-log.js +143 -0
  24. package/dist/backends/retry.d.ts +29 -0
  25. package/dist/backends/retry.js +60 -0
  26. package/dist/backends/salience.d.ts +57 -0
  27. package/dist/backends/salience.js +186 -0
  28. package/dist/backends/sqlite-knowledge.d.ts +37 -0
  29. package/dist/backends/sqlite-knowledge.js +788 -0
  30. package/dist/backends/sqlite-vec.d.ts +64 -0
  31. package/dist/backends/sqlite-vec.js +737 -0
  32. package/dist/backends/ttl.d.ts +24 -0
  33. package/dist/backends/ttl.js +50 -0
  34. package/dist/backends/types.d.ts +562 -0
  35. package/dist/backends/types.js +8 -0
  36. package/dist/blocks/harness.d.ts +45 -0
  37. package/dist/blocks/harness.js +142 -0
  38. package/dist/blocks/model.d.ts +4 -0
  39. package/dist/blocks/model.js +56 -0
  40. package/dist/blocks/types.d.ts +40 -0
  41. package/dist/blocks/types.js +34 -0
  42. package/dist/categories.d.ts +16 -0
  43. package/dist/categories.js +29 -0
  44. package/dist/cli/args.d.ts +21 -0
  45. package/dist/cli/args.js +47 -0
  46. package/dist/cli/bootstrap.d.ts +2 -0
  47. package/dist/cli/bootstrap.js +214 -0
  48. package/dist/cli/doctor.d.ts +2 -0
  49. package/dist/cli/doctor.js +206 -0
  50. package/dist/cli/forget.d.ts +2 -0
  51. package/dist/cli/forget.js +97 -0
  52. package/dist/cli/harness.d.ts +2 -0
  53. package/dist/cli/harness.js +91 -0
  54. package/dist/cli/index.d.ts +2 -0
  55. package/dist/cli/index.js +127 -0
  56. package/dist/cli/inject.d.ts +2 -0
  57. package/dist/cli/inject.js +298 -0
  58. package/dist/cli/install.d.ts +2 -0
  59. package/dist/cli/install.js +137 -0
  60. package/dist/cli/io.d.ts +14 -0
  61. package/dist/cli/io.js +63 -0
  62. package/dist/cli/knowledge.d.ts +2 -0
  63. package/dist/cli/knowledge.js +216 -0
  64. package/dist/cli/memory.d.ts +2 -0
  65. package/dist/cli/memory.js +608 -0
  66. package/dist/cli/migrate.d.ts +2 -0
  67. package/dist/cli/migrate.js +116 -0
  68. package/dist/cli/recall.d.ts +2 -0
  69. package/dist/cli/recall.js +91 -0
  70. package/dist/cli/remember.d.ts +2 -0
  71. package/dist/cli/remember.js +111 -0
  72. package/dist/cli/serve.d.ts +2 -0
  73. package/dist/cli/serve.js +71 -0
  74. package/dist/cli/subcommands.d.ts +7 -0
  75. package/dist/cli/subcommands.js +11 -0
  76. package/dist/cli/tui/multi-select.d.ts +53 -0
  77. package/dist/cli/tui/multi-select.js +116 -0
  78. package/dist/cli/update-identity.d.ts +2 -0
  79. package/dist/cli/update-identity.js +90 -0
  80. package/dist/cli/update.d.ts +2 -0
  81. package/dist/cli/update.js +87 -0
  82. package/dist/cli/wake.d.ts +2 -0
  83. package/dist/cli/wake.js +53 -0
  84. package/dist/clients.d.ts +8 -0
  85. package/dist/clients.js +63 -0
  86. package/dist/config.d.ts +59 -0
  87. package/dist/config.js +160 -0
  88. package/dist/index.d.ts +15 -0
  89. package/dist/index.js +75 -0
  90. package/dist/injection/harnesses.d.ts +17 -0
  91. package/dist/injection/harnesses.js +51 -0
  92. package/dist/injection/render.d.ts +17 -0
  93. package/dist/injection/render.js +50 -0
  94. package/dist/injection/writer.d.ts +25 -0
  95. package/dist/injection/writer.js +118 -0
  96. package/dist/install/harnesses.d.ts +26 -0
  97. package/dist/install/harnesses.js +90 -0
  98. package/dist/install/names.d.ts +16 -0
  99. package/dist/install/names.js +36 -0
  100. package/dist/install/render.d.ts +18 -0
  101. package/dist/install/render.js +42 -0
  102. package/dist/install/skill-source.d.ts +1 -0
  103. package/dist/install/skill-source.js +11 -0
  104. package/dist/path-safety.d.ts +19 -0
  105. package/dist/path-safety.js +62 -0
  106. package/dist/server.d.ts +8 -0
  107. package/dist/server.js +571 -0
  108. package/dist/tools/append-maintenance-log.d.ts +52 -0
  109. package/dist/tools/append-maintenance-log.js +103 -0
  110. package/dist/tools/archive.d.ts +2 -0
  111. package/dist/tools/archive.js +21 -0
  112. package/dist/tools/bootstrap.d.ts +11 -0
  113. package/dist/tools/bootstrap.js +161 -0
  114. package/dist/tools/dossier.d.ts +1 -0
  115. package/dist/tools/dossier.js +102 -0
  116. package/dist/tools/eurorack-import.d.ts +47 -0
  117. package/dist/tools/eurorack-import.js +199 -0
  118. package/dist/tools/find-similar.d.ts +3 -0
  119. package/dist/tools/find-similar.js +26 -0
  120. package/dist/tools/forget.d.ts +6 -0
  121. package/dist/tools/forget.js +70 -0
  122. package/dist/tools/harness.d.ts +18 -0
  123. package/dist/tools/harness.js +54 -0
  124. package/dist/tools/identity-scaffold.d.ts +22 -0
  125. package/dist/tools/identity-scaffold.js +142 -0
  126. package/dist/tools/identity.d.ts +4 -0
  127. package/dist/tools/identity.js +226 -0
  128. package/dist/tools/knowledge-archive.d.ts +5 -0
  129. package/dist/tools/knowledge-archive.js +24 -0
  130. package/dist/tools/knowledge-history.d.ts +6 -0
  131. package/dist/tools/knowledge-history.js +55 -0
  132. package/dist/tools/knowledge-maintain.d.ts +9 -0
  133. package/dist/tools/knowledge-maintain.js +113 -0
  134. package/dist/tools/knowledge-merge.d.ts +8 -0
  135. package/dist/tools/knowledge-merge.js +40 -0
  136. package/dist/tools/knowledge-move.d.ts +10 -0
  137. package/dist/tools/knowledge-move.js +48 -0
  138. package/dist/tools/knowledge-purge.d.ts +5 -0
  139. package/dist/tools/knowledge-purge.js +20 -0
  140. package/dist/tools/knowledge-recall.d.ts +20 -0
  141. package/dist/tools/knowledge-recall.js +142 -0
  142. package/dist/tools/knowledge-restore.d.ts +4 -0
  143. package/dist/tools/knowledge-restore.js +24 -0
  144. package/dist/tools/knowledge-supersede.d.ts +6 -0
  145. package/dist/tools/knowledge-supersede.js +36 -0
  146. package/dist/tools/knowledge-verify.d.ts +8 -0
  147. package/dist/tools/knowledge-verify.js +39 -0
  148. package/dist/tools/knowledge-write.d.ts +25 -0
  149. package/dist/tools/knowledge-write.js +87 -0
  150. package/dist/tools/memory-audit.d.ts +3 -0
  151. package/dist/tools/memory-audit.js +47 -0
  152. package/dist/tools/memory-list.d.ts +4 -0
  153. package/dist/tools/memory-list.js +23 -0
  154. package/dist/tools/prune.d.ts +4 -0
  155. package/dist/tools/prune.js +28 -0
  156. package/dist/tools/recall.d.ts +4 -0
  157. package/dist/tools/recall.js +24 -0
  158. package/dist/tools/remember.d.ts +10 -0
  159. package/dist/tools/remember.js +43 -0
  160. package/dist/tools/restore.d.ts +2 -0
  161. package/dist/tools/restore.js +20 -0
  162. package/dist/tools/update-identity.d.ts +34 -0
  163. package/dist/tools/update-identity.js +194 -0
  164. package/dist/tools/update.d.ts +2 -0
  165. package/dist/tools/update.js +16 -0
  166. package/dist/transport/guards.d.ts +35 -0
  167. package/dist/transport/guards.js +107 -0
  168. package/dist/transport/http-server.d.ts +17 -0
  169. package/dist/transport/http-server.js +198 -0
  170. package/dist/transport/mcp-app-hello.d.ts +3 -0
  171. package/dist/transport/mcp-app-hello.js +66 -0
  172. package/dist/widgets/display-modes.html +131 -0
  173. package/dist/widgets/hello.html +138 -0
  174. package/dist/widgets/proposals.html +131 -0
  175. package/dist/widgets/top-of-mind.html +131 -0
  176. package/package.json +73 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Update Identity tool — section-level edits on self-model and preferences.
3
+ *
4
+ * Identity files are markdown with H2 sections. This tool can:
5
+ * - List sections in a file
6
+ * - Replace a section's content (between its H2 and the next H2 or EOF)
7
+ * - Append a new section at the end
8
+ *
9
+ * IDENTITY.md is immutable — only self-model.md and preferences.md are editable.
10
+ */
11
+ import { readFile } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { atomicWriteWithBackup } from '../path-safety.js';
14
+ /** Files that can be edited via this tool. IDENTITY.md is explicitly excluded. */
15
+ const EDITABLE_FILES = {
16
+ 'self-model': 'self-model.md',
17
+ 'preferences': 'preferences.md',
18
+ };
19
+ /**
20
+ * Parse a markdown file into H2 sections.
21
+ * Content before the first H2 is captured as a preamble (header: '').
22
+ *
23
+ * Fence-aware: a `## ` line inside a ``` or ~~~ code fence is body content,
24
+ * not a section boundary. Closing fences must use the same character and be
25
+ * at least as long as the opener (CommonMark-style).
26
+ */
27
+ export function parseSections(text) {
28
+ const lines = text.split('\n');
29
+ const sections = [];
30
+ let currentHeader = '';
31
+ let currentLines = [];
32
+ let startLine = 0;
33
+ let fence = null; // opening fence marker while inside a fenced block
34
+ for (let i = 0; i < lines.length; i++) {
35
+ const line = lines[i];
36
+ const fenceMatch = line.match(/^\s{0,3}(`{3,}|~{3,})/);
37
+ if (fenceMatch) {
38
+ const marker = fenceMatch[1];
39
+ if (fence === null) {
40
+ fence = marker;
41
+ }
42
+ else if (marker[0] === fence[0] && marker.length >= fence.length) {
43
+ fence = null;
44
+ }
45
+ currentLines.push(line);
46
+ continue;
47
+ }
48
+ if (fence === null && line.startsWith('## ')) {
49
+ // Close previous section
50
+ sections.push({
51
+ header: currentHeader,
52
+ content: currentLines.join('\n').trim(),
53
+ startLine,
54
+ endLine: i - 1,
55
+ });
56
+ currentHeader = line.replace(/^## /, '').trim();
57
+ currentLines = [];
58
+ startLine = i;
59
+ }
60
+ else {
61
+ currentLines.push(line);
62
+ }
63
+ }
64
+ // Close final section
65
+ sections.push({
66
+ header: currentHeader,
67
+ content: currentLines.join('\n').trim(),
68
+ startLine,
69
+ endLine: lines.length - 1,
70
+ });
71
+ return sections;
72
+ }
73
+ /**
74
+ * Rebuild a markdown file from sections.
75
+ */
76
+ export function rebuildMarkdown(sections) {
77
+ const parts = [];
78
+ for (const section of sections) {
79
+ if (section.header === '') {
80
+ // Preamble — just the content
81
+ if (section.content) {
82
+ parts.push(section.content);
83
+ }
84
+ }
85
+ else {
86
+ parts.push(`## ${section.header}\n${section.content}`);
87
+ }
88
+ }
89
+ return parts.join('\n\n') + '\n';
90
+ }
91
+ /**
92
+ * Replace a single section's body in place, leaving every other line of the
93
+ * file byte-identical. The header line itself is preserved as-is; only the
94
+ * lines between it and the next section (or EOF) are swapped for `content`,
95
+ * followed by one blank separator line when another section follows.
96
+ */
97
+ function spliceSection(text, section, content) {
98
+ const lines = text.split('\n');
99
+ const before = lines.slice(0, section.startLine + 1); // includes the header line
100
+ const after = lines.slice(section.endLine + 1); // first line of the next section, if any
101
+ const body = content.replace(/\n+$/, '').split('\n');
102
+ const next = [...before, ...body];
103
+ if (after.length > 0) {
104
+ next.push(''); // blank line before the untouched next section
105
+ }
106
+ else if (text.endsWith('\n')) {
107
+ next.push(''); // preserve the file's trailing newline
108
+ }
109
+ return [...next, ...after].join('\n');
110
+ }
111
+ /**
112
+ * Append a new section at the end of the file, leaving existing content
113
+ * byte-identical apart from ensuring a trailing newline.
114
+ */
115
+ function appendSection(text, header, content) {
116
+ const block = `## ${header}\n${content.replace(/\n+$/, '')}\n`;
117
+ if (text.trim() === '')
118
+ return block;
119
+ let base = text.endsWith('\n') ? text : text + '\n';
120
+ if (!base.endsWith('\n\n'))
121
+ base += '\n';
122
+ return base + block;
123
+ }
124
+ /**
125
+ * List sections in an identity file.
126
+ */
127
+ export async function listSections(contextDir, file) {
128
+ const filename = EDITABLE_FILES[file];
129
+ if (!filename) {
130
+ return `Unknown file "${file}". Editable files: ${Object.keys(EDITABLE_FILES).join(', ')}`;
131
+ }
132
+ const filepath = join(contextDir, filename);
133
+ let text;
134
+ try {
135
+ text = await readFile(filepath, 'utf-8');
136
+ }
137
+ catch {
138
+ return `File not found: ${filename}. It will be created when you add a section.`;
139
+ }
140
+ const sections = parseSections(text);
141
+ const named = sections.filter(s => s.header !== '');
142
+ if (named.length === 0) {
143
+ return `${filename} has no H2 sections.`;
144
+ }
145
+ const list = named.map(s => `- **${s.header}** (${s.content.split('\n').length} lines)`).join('\n');
146
+ return `Sections in ${filename}:\n${list}`;
147
+ }
148
+ /**
149
+ * Update or append a section in an identity file.
150
+ */
151
+ export async function updateIdentity(contextDir, input) {
152
+ const { file, section, content, mode = 'replace' } = input;
153
+ // Validate file
154
+ const filename = EDITABLE_FILES[file];
155
+ if (!filename) {
156
+ return `Unknown file "${file}". Editable files: ${Object.keys(EDITABLE_FILES).join(', ')}`;
157
+ }
158
+ // List mode — no section or content means "show me what's there"
159
+ if (!section && !content) {
160
+ return listSections(contextDir, file);
161
+ }
162
+ if (!section) {
163
+ return 'Section name is required when providing content.';
164
+ }
165
+ if (!content) {
166
+ return 'Content is required when specifying a section.';
167
+ }
168
+ const filepath = join(contextDir, filename);
169
+ let text;
170
+ try {
171
+ text = await readFile(filepath, 'utf-8');
172
+ }
173
+ catch {
174
+ // File doesn't exist — create it with the new section (atomic, no .bak)
175
+ const newContent = `## ${section}\n${content}\n`;
176
+ await atomicWriteWithBackup(filepath, newContent);
177
+ return `Created ${filename} with section "${section}".`;
178
+ }
179
+ const sections = parseSections(text);
180
+ const existing = sections.find(s => s.header.toLowerCase() === section.toLowerCase());
181
+ if (mode === 'append') {
182
+ if (existing) {
183
+ return `Section "${section}" already exists in ${filename}. Use mode "replace" to update it.`;
184
+ }
185
+ await atomicWriteWithBackup(filepath, appendSection(text, section, content));
186
+ return `Appended new section "${section}" to ${filename}.`;
187
+ }
188
+ // Replace mode
189
+ if (!existing) {
190
+ return `Section "${section}" not found in ${filename}. Available sections: ${sections.filter(s => s.header !== '').map(s => s.header).join(', ')}. Use mode "append" to add a new section.`;
191
+ }
192
+ await atomicWriteWithBackup(filepath, spliceSection(text, existing, content));
193
+ return `Updated section "${section}" in ${filename}.`;
194
+ }
@@ -0,0 +1,2 @@
1
+ import type { UpdateInput } from '../backends/types.js';
2
+ export declare function update(contextDir: string, input: UpdateInput): Promise<string>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Update tool — modifies an existing memory by reference or by category+title.
3
+ *
4
+ * Delegates to the configured memory backend. Supports full content
5
+ * replacement or metadata-only updates.
6
+ */
7
+ import { createBackend } from '../backends/index.js';
8
+ export async function update(contextDir, input) {
9
+ const backend = createBackend(contextDir);
10
+ const result = await backend.update(input);
11
+ if (!result.updated) {
12
+ const identifier = input.ref ?? `${input.category}/${input.title}`;
13
+ return `Memory not found: "${identifier}". Use recall to find the correct reference.`;
14
+ }
15
+ return `Memory updated: ${result.ref}`;
16
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Transport guards (c-loom-transport §boundary) — the security boundary as
3
+ * pure, testable behavior, independent of the HTTP wiring that calls them.
4
+ *
5
+ * assertSafeBind — refuse a public / 0.0.0.0 bind (bind-safety)
6
+ * checkBearer — refuse an unauthenticated call when a token is set (auth gate)
7
+ * checkPayloadSize — refuse an oversized request before the handler runs
8
+ *
9
+ * The HTTP serve path applies these at startup (bind) and per request (auth,
10
+ * size); keeping them pure means the contract is verified without a live socket.
11
+ */
12
+ export interface GuardResult {
13
+ ok: boolean;
14
+ error?: string;
15
+ }
16
+ /** Default request-body cap (1 MiB) — overridable via LOOM_HTTP_MAX_BYTES. */
17
+ export declare const DEFAULT_MAX_BODY_BYTES = 1048576;
18
+ /**
19
+ * A host is mesh-or-local-safe if it is loopback, an RFC1918 private address,
20
+ * the Tailscale CGNAT range (100.64.0.0/10), or an IPv6 ULA / link-local /
21
+ * loopback. Everything else — and explicitly the 0.0.0.0 / :: bind-all wildcard
22
+ * and any globally routable address — is refused.
23
+ */
24
+ export declare function isSafeBindHost(hostRaw: string): boolean;
25
+ /** Bind-safety: throws at startup if asked to listen on a public/wildcard host. */
26
+ export declare function assertSafeBind(host: string): void;
27
+ /**
28
+ * Auth gate: when a bearer token is configured, a call must present the matching
29
+ * token. When no token is configured, the call is allowed (the network boundary
30
+ * — loopback/mesh per bind-safety — is the access control). `presented` is the
31
+ * raw Authorization header value (e.g. "Bearer abc"); the scheme is tolerated.
32
+ */
33
+ export declare function checkBearer(configured: string | undefined, presented: string | undefined): GuardResult;
34
+ /** Oversized guard: refuse a body beyond the cap before the handler runs. */
35
+ export declare function checkPayloadSize(bytes: number, cap?: number): GuardResult;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Transport guards (c-loom-transport §boundary) — the security boundary as
3
+ * pure, testable behavior, independent of the HTTP wiring that calls them.
4
+ *
5
+ * assertSafeBind — refuse a public / 0.0.0.0 bind (bind-safety)
6
+ * checkBearer — refuse an unauthenticated call when a token is set (auth gate)
7
+ * checkPayloadSize — refuse an oversized request before the handler runs
8
+ *
9
+ * The HTTP serve path applies these at startup (bind) and per request (auth,
10
+ * size); keeping them pure means the contract is verified without a live socket.
11
+ */
12
+ /** Default request-body cap (1 MiB) — overridable via LOOM_HTTP_MAX_BYTES. */
13
+ export const DEFAULT_MAX_BODY_BYTES = 1_048_576;
14
+ function v4Octets(host) {
15
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
16
+ if (!m)
17
+ return null;
18
+ const o = m.slice(1, 5).map((n) => Number(n));
19
+ if (o.some((n) => n > 255))
20
+ return null;
21
+ return o;
22
+ }
23
+ /**
24
+ * A host is mesh-or-local-safe if it is loopback, an RFC1918 private address,
25
+ * the Tailscale CGNAT range (100.64.0.0/10), or an IPv6 ULA / link-local /
26
+ * loopback. Everything else — and explicitly the 0.0.0.0 / :: bind-all wildcard
27
+ * and any globally routable address — is refused.
28
+ */
29
+ export function isSafeBindHost(hostRaw) {
30
+ const host = hostRaw.trim().toLowerCase();
31
+ if (host === '' || host === '*')
32
+ return false;
33
+ // bind-all wildcards: the exact thing bind-safety exists to refuse
34
+ if (host === '0.0.0.0' || host === '::' || host === '[::]' || host === '0:0:0:0:0:0:0:0') {
35
+ return false;
36
+ }
37
+ // named loopback
38
+ if (host === 'localhost')
39
+ return true;
40
+ const v4 = v4Octets(host);
41
+ if (v4) {
42
+ const [a, b] = v4;
43
+ if (a === 127)
44
+ return true; // loopback 127.0.0.0/8
45
+ if (a === 10)
46
+ return true; // private 10.0.0.0/8
47
+ if (a === 192 && b === 168)
48
+ return true; // private 192.168.0.0/16
49
+ if (a === 172 && b >= 16 && b <= 31)
50
+ return true; // private 172.16.0.0/12
51
+ if (a === 100 && b >= 64 && b <= 127)
52
+ return true; // Tailscale CGNAT 100.64.0.0/10
53
+ if (a === 169 && b === 254)
54
+ return true; // link-local 169.254.0.0/16
55
+ return false; // any other v4 is treated as public
56
+ }
57
+ // IPv6 (possibly bracketed)
58
+ const v6 = host.replace(/^\[|\]$/g, '');
59
+ if (v6 === '::1')
60
+ return true; // loopback
61
+ if (v6.startsWith('fe80:') || v6.startsWith('fe80::'))
62
+ return true; // link-local
63
+ if (/^f[cd][0-9a-f]{0,2}:/.test(v6))
64
+ return true; // ULA fc00::/7
65
+ return false; // any other v6 (incl. global unicast) is public
66
+ }
67
+ /** Bind-safety: throws at startup if asked to listen on a public/wildcard host. */
68
+ export function assertSafeBind(host) {
69
+ if (!isSafeBindHost(host)) {
70
+ throw new Error(`unsafe-bind: refusing to bind loom to "${host}" — only loopback or a mesh ` +
71
+ `(Tailscale/RFC1918) interface is allowed. Tailscale is the access control; ` +
72
+ `loom must never listen on a public or 0.0.0.0 interface.`);
73
+ }
74
+ }
75
+ /** Constant-time string compare (avoids leaking the token via timing). */
76
+ function timingSafeEqual(a, b) {
77
+ if (a.length !== b.length)
78
+ return false;
79
+ let diff = 0;
80
+ for (let i = 0; i < a.length; i++)
81
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
82
+ return diff === 0;
83
+ }
84
+ /**
85
+ * Auth gate: when a bearer token is configured, a call must present the matching
86
+ * token. When no token is configured, the call is allowed (the network boundary
87
+ * — loopback/mesh per bind-safety — is the access control). `presented` is the
88
+ * raw Authorization header value (e.g. "Bearer abc"); the scheme is tolerated.
89
+ */
90
+ export function checkBearer(configured, presented) {
91
+ if (!configured)
92
+ return { ok: true };
93
+ const token = (presented ?? '').replace(/^Bearer\s+/i, '').trim();
94
+ if (token === '')
95
+ return { ok: false, error: 'unauthorized: missing bearer token' };
96
+ if (!timingSafeEqual(token, configured)) {
97
+ return { ok: false, error: 'unauthorized: bearer token mismatch' };
98
+ }
99
+ return { ok: true };
100
+ }
101
+ /** Oversized guard: refuse a body beyond the cap before the handler runs. */
102
+ export function checkPayloadSize(bytes, cap = DEFAULT_MAX_BODY_BYTES) {
103
+ if (bytes > cap) {
104
+ return { ok: false, error: `oversized: request body ${bytes} bytes exceeds cap ${cap}` };
105
+ }
106
+ return { ok: true };
107
+ }
@@ -0,0 +1,17 @@
1
+ export interface HttpServeOptions {
2
+ contextDir: string;
3
+ host: string;
4
+ port: number;
5
+ /** When set, every request must present this bearer token. */
6
+ token?: string;
7
+ maxBytes?: number;
8
+ /** SSE keep-alive ping interval (ms). Default HEARTBEAT_MS; tests use a small value. */
9
+ heartbeatMs?: number;
10
+ }
11
+ export interface HttpServeHandle {
12
+ /** The actually-bound port (resolves a 0 request to the OS-assigned port). */
13
+ port: number;
14
+ host: string;
15
+ close(): Promise<void>;
16
+ }
17
+ export declare function startHttpServer(opts: HttpServeOptions): Promise<HttpServeHandle>;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * loom HTTP MCP transport (c-loom-transport) — the mesh-reachable surface.
3
+ *
4
+ * A thin node:http front for the StreamableHTTP MCP transport, applying loom's
5
+ * boundary guards: bind-safety at startup (assertSafeBind), a bearer auth gate
6
+ * and an oversized-payload guard per request. The MCP envelope/dispatch behavior
7
+ * itself is the SDK's McpServer — the same server factory that serves stdio, so
8
+ * results are transport-neutral by construction (ac-lt-envelope-ok parity).
9
+ *
10
+ * Session mode: each MCP session gets its own server+transport, keyed by the
11
+ * mcp-session-id header (the canonical persistent-daemon pattern, and what
12
+ * Claude Desktop uses). loom's durable state lives in the stores, not the
13
+ * session, so sessions are cheap routing handles over a shared contextDir.
14
+ * WHERE it binds on the mesh is deployment (the portable-MCP ADR); THAT it
15
+ * refuses an unsafe bind is this contract.
16
+ */
17
+ import { randomUUID } from 'node:crypto';
18
+ import { createServer } from 'node:http';
19
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
20
+ import { isInitializeRequest, EmptyResultSchema } from '@modelcontextprotocol/sdk/types.js';
21
+ import { createLoomServer } from '../server.js';
22
+ import { assertSafeBind, checkBearer, checkPayloadSize, DEFAULT_MAX_BODY_BYTES, } from './guards.js';
23
+ /**
24
+ * SSE keep-alive: the SDK opens the standalone GET stream but sends no
25
+ * heartbeat, so an idle stream gets reaped by proxies/NAT (Traefik's default
26
+ * idle is ~180s) and the session bricks. We drive a protocol-native `ping` to
27
+ * the client over the stream — it keeps the connection warm AND detects a dead
28
+ * client (after a few misses the session is closed, and the client re-inits via
29
+ * the 404 path). The interval stays well under typical idle windows.
30
+ */
31
+ const HEARTBEAT_MS = 25_000;
32
+ const HEARTBEAT_TIMEOUT_MS = 10_000;
33
+ const HEARTBEAT_MISSES_BEFORE_CLOSE = 2;
34
+ function sendError(res, status, message) {
35
+ // A typed error ENVELOPE, never a raw throw across the boundary.
36
+ if (res.headersSent)
37
+ return;
38
+ res.writeHead(status, { 'content-type': 'application/json' });
39
+ res.end(JSON.stringify({ error: message }));
40
+ }
41
+ const OVERSIZED = Symbol('oversized');
42
+ function collectBody(req, cap) {
43
+ return new Promise((resolve, reject) => {
44
+ let size = 0;
45
+ const chunks = [];
46
+ req.on('data', (c) => {
47
+ size += c.length;
48
+ if (size > cap) {
49
+ resolve(OVERSIZED);
50
+ req.destroy();
51
+ return;
52
+ }
53
+ chunks.push(c);
54
+ });
55
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
56
+ req.on('error', reject);
57
+ });
58
+ }
59
+ export async function startHttpServer(opts) {
60
+ // Bind-safety (ac-lt-bind-safety): refuse an unsafe host BEFORE we open a socket.
61
+ assertSafeBind(opts.host);
62
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BODY_BYTES;
63
+ const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
64
+ // One transport (+ its connected server) per live MCP session.
65
+ const sessions = new Map();
66
+ async function openSession() {
67
+ const { server } = createLoomServer({ contextDir: opts.contextDir });
68
+ // Fires AFTER the initialize handshake completes — getClientVersion() is
69
+ // populated here (it is not yet at onsessioninitialized). Logging the peer
70
+ // makes the resolved harness observable and reveals an unmapped client's
71
+ // real clientInfo.name so the alias table can be extended.
72
+ server.server.oninitialized = () => {
73
+ const peer = server.server.getClientVersion();
74
+ process.stderr.write(`loom: peer connected client=${JSON.stringify(peer ?? null)}\n`);
75
+ };
76
+ const transport = new StreamableHTTPServerTransport({
77
+ sessionIdGenerator: () => randomUUID(),
78
+ enableJsonResponse: true,
79
+ onsessioninitialized: (sid) => {
80
+ sessions.set(sid, transport);
81
+ },
82
+ });
83
+ // SSE keep-alive: ping the client over the standalone stream. The first tick
84
+ // is one interval out, by when the client has opened the stream. A live
85
+ // client pongs (the SDK answers ping automatically); repeated misses mean a
86
+ // dead peer or a reaped stream → close the session so it can't zombie, and
87
+ // the client recovers via the 404 re-init path.
88
+ let misses = 0;
89
+ const heartbeat = setInterval(() => {
90
+ void server.server
91
+ .request({ method: 'ping' }, EmptyResultSchema, { timeout: HEARTBEAT_TIMEOUT_MS })
92
+ .then(() => {
93
+ misses = 0;
94
+ })
95
+ .catch(() => {
96
+ misses += 1;
97
+ if (misses >= HEARTBEAT_MISSES_BEFORE_CLOSE) {
98
+ clearInterval(heartbeat);
99
+ void transport.close().catch(() => undefined);
100
+ }
101
+ });
102
+ }, heartbeatMs);
103
+ // Don't let the heartbeat keep the event loop alive (clean process exit / test teardown).
104
+ if (typeof heartbeat.unref === 'function')
105
+ heartbeat.unref();
106
+ transport.onclose = () => {
107
+ clearInterval(heartbeat);
108
+ if (transport.sessionId)
109
+ sessions.delete(transport.sessionId);
110
+ };
111
+ await server.connect(transport);
112
+ return transport;
113
+ }
114
+ const http = createServer((req, res) => {
115
+ void (async () => {
116
+ // Auth gate (ac-lt-auth-gate): reject unauthenticated when a token is set.
117
+ const auth = checkBearer(opts.token, req.headers['authorization']);
118
+ if (!auth.ok)
119
+ return sendError(res, 401, auth.error);
120
+ // Oversized guard (ac-lt-oversized-guard): fast path on declared length.
121
+ const declared = Number(req.headers['content-length'] ?? 0);
122
+ if (Number.isFinite(declared) && declared > 0 && !checkPayloadSize(declared, maxBytes).ok) {
123
+ return sendError(res, 413, `oversized: declared ${declared} bytes exceeds cap ${maxBytes}`);
124
+ }
125
+ const sessionId = req.headers['mcp-session-id'];
126
+ // GET = the server->client SSE stream (server push: listChanged, progress,
127
+ // elicitation, ui:// updates). It attaches to an established session, so a
128
+ // GET routes to that session's transport. The stream is kept alive by the
129
+ // per-session heartbeat. A GET for a session we no longer hold is 404 (the
130
+ // client re-initializes); a GET with no session at all is 405.
131
+ if (req.method === 'GET' || req.method === 'DELETE') {
132
+ const existing = sessionId ? sessions.get(sessionId) : undefined;
133
+ if (existing) {
134
+ return existing.handleRequest(req, res).catch((e) => sendError(res, 500, `internal: ${e.message}`));
135
+ }
136
+ if (sessionId) {
137
+ res.writeHead(404, { 'content-type': 'application/json' });
138
+ res.end(JSON.stringify({ error: 'session not found; reinitialize' }));
139
+ return;
140
+ }
141
+ res.writeHead(405, { 'content-type': 'application/json', allow: 'POST' });
142
+ res.end(JSON.stringify({ error: 'method-not-allowed: open a session with an initialize POST first' }));
143
+ return;
144
+ }
145
+ if (req.method !== 'POST')
146
+ return sendError(res, 405, 'method-not-allowed');
147
+ // POST: read + cap the body, then route by session / initialize.
148
+ const body = await collectBody(req, maxBytes);
149
+ if (body === OVERSIZED)
150
+ return sendError(res, 413, `oversized: body exceeds cap ${maxBytes}`);
151
+ let parsed;
152
+ try {
153
+ parsed = body ? JSON.parse(body) : undefined;
154
+ }
155
+ catch (e) {
156
+ return sendError(res, 400, `bad-input: malformed JSON (${e.message})`);
157
+ }
158
+ let transport = sessionId ? sessions.get(sessionId) : undefined;
159
+ if (!transport) {
160
+ if (sessionId) {
161
+ // A session id we don't have (expired / dropped). Per MCP spec, 404
162
+ // tells the client to start over with a fresh initialize — so an idle
163
+ // disconnect self-heals instead of bricking on a 400.
164
+ res.writeHead(404, { 'content-type': 'application/json' });
165
+ res.end(JSON.stringify({ error: 'session not found; reinitialize' }));
166
+ return;
167
+ }
168
+ if (!isInitializeRequest(parsed)) {
169
+ return sendError(res, 400, 'bad-input: no valid session; expected an initialize request');
170
+ }
171
+ transport = await openSession();
172
+ }
173
+ // Dispatch totality + typed envelopes (unknown tool / bad args -> JSON-RPC
174
+ // error) are the SDK server's job from here — never a raw throw out.
175
+ await transport.handleRequest(req, res, parsed);
176
+ })().catch((e) => sendError(res, 500, `internal: ${e.message}`));
177
+ });
178
+ await new Promise((resolve, reject) => {
179
+ http.once('error', reject);
180
+ http.listen(opts.port, opts.host, () => resolve());
181
+ });
182
+ const addr = http.address();
183
+ const port = typeof addr === 'object' && addr ? addr.port : opts.port;
184
+ return {
185
+ port,
186
+ host: opts.host,
187
+ async close() {
188
+ for (const t of sessions.values())
189
+ await t.close().catch(() => undefined);
190
+ sessions.clear();
191
+ await new Promise((resolve) => {
192
+ // Force-close lingering keep-alive/SSE sockets so close() can't hang.
193
+ http.closeAllConnections?.();
194
+ http.close(() => resolve());
195
+ });
196
+ },
197
+ };
198
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ /** Register the env-gated MCP-App probes + the first real widget. Call only when probing. */
3
+ export declare function registerHelloApp(server: McpServer, version: string, contextDir: string): void;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * MCP-App render probes (env-gated: LOOM_MCP_APP_HELLO), built the RIGHT way on
3
+ * the official @modelcontextprotocol/ext-apps SDK:
4
+ * - server side: registerAppResource / registerAppTool set the ui:// resource,
5
+ * the text/html;profile=mcp-app mimeType, and BOTH _meta keys the host needs.
6
+ * - widget side: the App class (bundled into dist/widgets/<name>.html by
7
+ * scripts/build-widgets.mjs) does the full handshake, autoResize, etc.
8
+ *
9
+ * Two probes:
10
+ * - loom_hello: a vanilla App-SDK "hello world" (renders + callServerTool).
11
+ * - loom_display_modes: a Preact widget exercising inline/fullscreen/pip.
12
+ *
13
+ * Not specced, not in the permanent tool surface — throwaway validation + the
14
+ * reference for loom's real widgets.
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { registerAppResource, registerAppTool, RESOURCE_MIME_TYPE, } from '@modelcontextprotocol/ext-apps/server';
19
+ import { resolveRepoRoot } from '../config.js';
20
+ import { digestData } from '../backends/salience.js';
21
+ import { listProposals } from '../backends/proposals.js';
22
+ function registerApp(server, app) {
23
+ const html = readFileSync(join(resolveRepoRoot(), 'dist', 'widgets', app.htmlFile), 'utf-8');
24
+ registerAppResource(server, `${app.tool}-ui`, app.uri, { description: app.description }, async (uri) => ({
25
+ contents: [{ uri: uri.href, mimeType: RESOURCE_MIME_TYPE, text: html }],
26
+ }));
27
+ registerAppTool(server, app.tool, {
28
+ description: app.description,
29
+ inputSchema: {},
30
+ _meta: { ui: { resourceUri: app.uri, visibility: ['model', 'app'] } },
31
+ }, async () => ({
32
+ content: [{ type: 'text', text: `${app.tool} — see the rendered widget.` }],
33
+ structuredContent: app.structured(),
34
+ }));
35
+ }
36
+ /** Register the env-gated MCP-App probes + the first real widget. Call only when probing. */
37
+ export function registerHelloApp(server, version, contextDir) {
38
+ registerApp(server, {
39
+ tool: 'loom_hello',
40
+ uri: 'ui://loom/hello',
41
+ htmlFile: 'hello.html',
42
+ description: 'MCP-App render probe — an interactive hello widget (vanilla + App SDK).',
43
+ structured: () => ({ message: 'loom MCP-App is live (ext-apps SDK)', version, at: new Date().toISOString() }),
44
+ });
45
+ registerApp(server, {
46
+ tool: 'loom_display_modes',
47
+ uri: 'ui://loom/display-modes',
48
+ htmlFile: 'display-modes.html',
49
+ description: 'MCP-App display-mode trial (Preact) — exercises inline / fullscreen / pip.',
50
+ structured: () => ({ message: 'pick a display mode in the widget', version }),
51
+ });
52
+ registerApp(server, {
53
+ tool: 'loom_top_of_mind',
54
+ uri: 'ui://loom/top-of-mind',
55
+ htmlFile: 'top-of-mind.html',
56
+ description: "Show what's top of mind — the salience-tiered memory digest as a widget. Inline = the hottest few; expand to fullscreen for the full Hot/Warm/Cool landscape.",
57
+ structured: () => digestData(contextDir) ?? { atoms: [], total: 0 },
58
+ });
59
+ registerApp(server, {
60
+ tool: 'loom_proposals',
61
+ uri: 'ui://loom/proposals',
62
+ htmlFile: 'proposals.html',
63
+ description: 'Review the capture-propose queue — pending memory drafts awaiting ratification. Ratify (commit to memory) or reject each. Inline = the count + newest; expand to review the full queue.',
64
+ structured: () => ({ proposals: listProposals(contextDir) }),
65
+ });
66
+ }