threadroom-pi 0.1.0-beta.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/extensions/README.md +15 -0
  4. package/extensions/index.ts +280 -0
  5. package/extensions/native/index.ts +614 -0
  6. package/extensions/native/presentation.ts +30 -0
  7. package/extensions/native/receipt.ts +25 -0
  8. package/extensions/native/ui.ts +167 -0
  9. package/extensions/presentation/renderers.ts +185 -0
  10. package/extensions/questions/README.md +41 -0
  11. package/extensions/questions/compose.ts +82 -0
  12. package/extensions/questions/external-editor.ts +24 -0
  13. package/extensions/questions/host.ts +415 -0
  14. package/extensions/questions/index.ts +6 -0
  15. package/extensions/questions/model.ts +175 -0
  16. package/extensions/questions/stream.ts +299 -0
  17. package/extensions/questions/text.ts +10 -0
  18. package/extensions/questions/tool.ts +309 -0
  19. package/extensions/questions/types.ts +40 -0
  20. package/extensions/questions/view.ts +221 -0
  21. package/node_modules/threadroom-service/README.md +73 -0
  22. package/node_modules/threadroom-service/bin/threadroom-service.js +9 -0
  23. package/node_modules/threadroom-service/dist/public/app.js +349 -0
  24. package/node_modules/threadroom-service/dist/public/assets/mist-bloom.svg +17 -0
  25. package/node_modules/threadroom-service/dist/public/assets/mist-drift.svg +16 -0
  26. package/node_modules/threadroom-service/dist/public/assets/mist-prowler.svg +15 -0
  27. package/node_modules/threadroom-service/dist/public/client.js +30 -0
  28. package/node_modules/threadroom-service/dist/public/index.html +54 -0
  29. package/node_modules/threadroom-service/dist/public/presentations.js +194 -0
  30. package/node_modules/threadroom-service/dist/public/routes.js +15 -0
  31. package/node_modules/threadroom-service/dist/public/styles.css +263 -0
  32. package/node_modules/threadroom-service/dist/src/live.js +170 -0
  33. package/node_modules/threadroom-service/dist/src/main.js +33 -0
  34. package/node_modules/threadroom-service/dist/src/presentations.js +116 -0
  35. package/node_modules/threadroom-service/dist/src/server.js +143 -0
  36. package/node_modules/threadroom-service/dist/src/site.js +53 -0
  37. package/node_modules/threadroom-service/dist/src/store.js +459 -0
  38. package/node_modules/threadroom-service/dist/src/ui-main.js +14 -0
  39. package/node_modules/threadroom-service/lib/cli.js +188 -0
  40. package/node_modules/threadroom-service/lib/ensure.js +157 -0
  41. package/node_modules/threadroom-service/lib/paths.js +19 -0
  42. package/node_modules/threadroom-service/package.json +19 -0
  43. package/package.json +50 -0
  44. package/scripts/stage-service.js +32 -0
  45. package/scripts/verify-packed.js +85 -0
  46. package/scripts/verify-release.js +79 -0
  47. package/src/client.js +135 -0
  48. package/src/config.js +57 -0
  49. package/src/http-transport.js +44 -0
  50. package/src/participation.js +211 -0
  51. package/src/service-runtime.js +43 -0
@@ -0,0 +1,44 @@
1
+ import { Agent as HttpAgent, request as httpRequest } from 'node:http';
2
+ import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
3
+
4
+ /** Node HTTP connections owned by one client, not the host's fetch/dispatcher.
5
+ * Agents are allocated only on first use. close() cancels owned work, releases
6
+ * sockets, and permanently rejects further opens; it never closes another client.
7
+ */
8
+ export class HttpTransport {
9
+ constructor() { this.agents = new Map(); this.requests = new Map(); this.closed = false; }
10
+ async open(url, { method = 'GET', headers, body, signal } = {}) {
11
+ if (this.closed) throw new Error('Threadroom client is closed.');
12
+ const address = new URL(url);
13
+ if (!['http:', 'https:'].includes(address.protocol) || address.username || address.password) {
14
+ throw new Error('Threadroom needs an HTTP(S) address without embedded credentials.');
15
+ }
16
+ signal?.throwIfAborted();
17
+ let agent = this.agents.get(address.protocol);
18
+ if (!agent) {
19
+ agent = new (address.protocol === 'https:' ? HttpsAgent : HttpAgent)({ keepAlive: true });
20
+ this.agents.set(address.protocol, agent);
21
+ }
22
+ return new Promise((resolve, reject) => {
23
+ const request = (address.protocol === 'https:' ? httpsRequest : httpRequest)(address, { method, headers, agent, signal });
24
+ const closed = new Promise(resolve => request.once('close', () => { this.requests.delete(request); resolve(); }));
25
+ this.requests.set(request, closed);
26
+ request.once('error', reject);
27
+ request.once('response', response => {
28
+ // A cancellation can race the consumer attaching its body iterator.
29
+ // Keep that gap safe; the iterator still observes the stream's error.
30
+ response.once('error', () => {});
31
+ resolve(response);
32
+ });
33
+ request.end(body);
34
+ });
35
+ }
36
+ async close() {
37
+ this.closed = true;
38
+ const active = [...this.requests];
39
+ for (const [request] of active) request.destroy(new Error('Threadroom client is closed.'));
40
+ for (const agent of this.agents.values()) agent.destroy();
41
+ this.agents.clear();
42
+ await Promise.all(active.map(([, closed]) => closed));
43
+ }
44
+ }
@@ -0,0 +1,211 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { readable } from './client.js';
4
+
5
+ // Session participation, not another conversation store. Checkpoints contain only
6
+ // watched identities and a replay position. The host confirms persisted receipts.
7
+ export class Participation {
8
+ constructor(client, { state, received = [], author, checkpoint = () => {},
9
+ deliver = () => {}, connection = () => {} } = {}) {
10
+ this.client = client; this.author = author; this.checkpoint = checkpoint;
11
+ this.deliver = deliver; this.connection = connection;
12
+ this.watches = new Set(state?.watches || []);
13
+ this.cursor = state?.cursor || 0; this.received = new Set(received); this.offered = new Set();
14
+ this.pending = new Map(); this.waiters = new Map(); this.status = 'disconnected';
15
+ this.closed = false; this.generation = 0;
16
+ }
17
+ snapshot() {
18
+ const unconfirmed = [...this.pending.values()].map((p) => p.sequence - 1);
19
+ return { watches: [...this.watches], cursor: Math.min(this.cursor, ...unconfirmed) };
20
+ }
21
+ save() {
22
+ if (this.closed) return false;
23
+ try {
24
+ this.checkpoint(this.snapshot());
25
+ this.checkpointError = undefined;
26
+ return true;
27
+ } catch (error) {
28
+ // A checkpoint is recovery metadata, never the durable publication itself.
29
+ // Keep the live watch running and expose the persistence failure to callers
30
+ // rather than turning a confirmed write into an ambiguous failed request.
31
+ this.checkpointError = error instanceof Error ? error.message : String(error);
32
+ return false;
33
+ }
34
+ }
35
+ adjacent() { return { connection: this.status, watching: [...this.watches].slice(-12),
36
+ ...(this.watches.size > 12 ? { otherWatches: this.watches.size - 12 } : {}),
37
+ unconfirmedDeliveries: this.pending.size,
38
+ ...(this.checkpointError ? { checkpoint: { status: 'failed', error: this.checkpointError } } : {}) }; }
39
+ start() {
40
+ if (this.closed || !this.watches.size) return;
41
+ this.controller?.abort();
42
+ const generation = ++this.generation;
43
+ this.controller = new AbortController();
44
+ this.task = this.run(generation, this.controller.signal);
45
+ }
46
+ async close() {
47
+ if (this.closed) return;
48
+ this.closed = true; ++this.generation; this.controller?.abort();
49
+ for (const inspect of this.waiters.values()) inspect('cancelled');
50
+ await this.task;
51
+ }
52
+ async run(generation, signal) {
53
+ let backoff = 250;
54
+ while (!signal.aborted && generation === this.generation) {
55
+ try {
56
+ this.setStatus('connecting');
57
+ for await (const event of this.client.events(this.cursor, signal, () => {
58
+ if (!signal.aborted && generation === this.generation) this.setStatus('live');
59
+ })) {
60
+ if (signal.aborted || generation !== this.generation) return;
61
+ this.setStatus('live'); backoff = 250;
62
+ await this.consume(event, signal);
63
+ }
64
+ } catch (error) {
65
+ if (signal.aborted || generation !== this.generation) return;
66
+ this.setStatus('reconnecting', error.message);
67
+ await delay(backoff, undefined, { signal, ref: false }).catch(() => {});
68
+ backoff = Math.min(backoff * 2, 10000);
69
+ }
70
+ }
71
+ }
72
+ setStatus(status, error) {
73
+ this.status = status; this.connection({ status, ...(error ? { error } : {}) });
74
+ }
75
+ async consume(event, signal) {
76
+ if (!Number.isSafeInteger(event.sequence) || event.sequence <= this.cursor) return;
77
+ if (event.type === 'response.created' && this.watches.has(event.payload.questionId)) {
78
+ const id = event.payload.responseId;
79
+ if (!this.received.has(id)) {
80
+ // Do not advance past a response until its readable record is available.
81
+ const response = await this.client.read(id, { signal });
82
+ const target = await this.client.read(event.payload.questionId, { signal });
83
+ if (this.closed || signal.aborted) return;
84
+ if (!this.closed && !signal.aborted && !this.received.has(id) && this.watches.has(target.node.id) &&
85
+ !(this.author?.sessionId && response.node.author?.sessionId === this.author.sessionId)) {
86
+ this.pending.set(id, { eventId: event.id, sequence: event.sequence, responseId: id,
87
+ target: readable({ ...target, children: [] }, this.client), response: readable(response, this.client), sent: this.offered.has(id) || this.pending.get(id)?.sent || false });
88
+ if (this.waiters.has(target.node.id)) this.waiters.get(target.node.id)();
89
+ else this.flush();
90
+ }
91
+ }
92
+ }
93
+ this.cursor = event.sequence; this.save();
94
+ }
95
+ flush() {
96
+ if (this.closed) return;
97
+ for (const [id, receipt] of this.pending) {
98
+ if (receipt.sent || this.received.has(id) || this.waiters.has(receipt.target.node.id)) continue;
99
+ receipt.sent = true;
100
+ try { if (this.deliver(receipt) === false) receipt.sent = false; }
101
+ catch (error) { receipt.sent = false; this.setStatus('delivery_failed', error.message); }
102
+ }
103
+ }
104
+ // Called for actual transcript receipts, not merely sendMessage acceptance.
105
+ acknowledge(ids) {
106
+ for (const id of ids) { this.received.add(id); this.offered.delete(id); this.pending.delete(id); }
107
+ this.save();
108
+ }
109
+ async watch(id, { signal } = {}) {
110
+ const record = await this.client.read(id, { signal });
111
+ if (this.closed) throw new Error('Threadroom session has ended.');
112
+ if (!this.watches.has(id)) {
113
+ this.watches.add(id); this.cursor = 0; this.save(); this.start();
114
+ }
115
+ return { ...readable(record, this.client), participation: this.adjacent() };
116
+ }
117
+ unwatch(id) {
118
+ this.waiters.get(id)?.('cancelled');
119
+ this.watches.delete(id);
120
+ for (const [responseId, receipt] of this.pending) {
121
+ if (receipt.target.node.id === id) { this.offered.delete(responseId); this.pending.delete(responseId); }
122
+ }
123
+ this.save();
124
+ if (!this.watches.size) { this.controller?.abort(); this.setStatus('disconnected'); }
125
+ return { id, participation: this.adjacent(), note: 'Only this session stopped watching; shared history is unchanged.' };
126
+ }
127
+ async publish(input, { key = randomUUID(), waitMs, signal } = {}) {
128
+ let published;
129
+ try { published = await this.client.publish({ ...input, author: input.author || this.author }, { key, signal }); }
130
+ catch (error) { error.retryKey = key; throw error; }
131
+ if (this.closed) return { ...readable(published, this.client), retryKey: key };
132
+ // Publication is already durable. Watching before waiting closes the answer race.
133
+ if (!this.watches.has(published.node.id)) {
134
+ this.watches.add(published.node.id); this.cursor = 0; this.save(); this.start();
135
+ }
136
+ const result = waitMs !== undefined ? await this.wait(published.node.id, { timeoutMs: waitMs, signal }) : {};
137
+ return { ...readable(published, this.client),
138
+ ...(waitMs !== undefined ? { contextSnapshot: 'publication' } : {}), ...result,
139
+ retryKey: key, participation: this.adjacent() };
140
+ }
141
+ async respond(id, input, { key = randomUUID(), signal } = {}) {
142
+ let response;
143
+ try { response = await this.client.respond(id, { ...input, author: input.author || this.author }, { key, signal }); }
144
+ catch (error) { error.retryKey = key; throw error; }
145
+ return { ...readable(response, this.client),
146
+ target: response.target ? readable({ node: response.target, children: [], ancestors: [] }, this.client).node : undefined,
147
+ responseId: response.responseId, retryKey: key, participation: this.adjacent() };
148
+ }
149
+ async read(id, { signal } = {}) {
150
+ return { ...readable(await this.client.read(id, { signal }), this.client), participation: this.adjacent() };
151
+ }
152
+ async wait(id, { timeoutMs = 120000, signal } = {}) {
153
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 120000) throw new Error('Wait timeout is 0–120000 ms; later replies remain available.');
154
+ if (this.waiters.has(id)) throw new Error('This session is already waiting on that node.');
155
+ if (!this.watches.has(id)) await this.watch(id, { signal });
156
+ let finish, inspecting = false, inspectAgain = false, settled = false, lastRead;
157
+ const controller = new AbortController();
158
+ const waitSignal = AbortSignal.any([controller.signal, ...(signal ? [signal] : [])]);
159
+ const outcome = new Promise((resolve) => { finish = (value) => {
160
+ if (settled) return;
161
+ settled = true;
162
+ // Carry the very snapshot that established the outcome, without a new
163
+ // network dependency or a read that could race a subsequent team reply.
164
+ resolve({ ...(lastRead ? { ...readable(lastRead, this.client), contextSnapshot: 'wait_read' } : {}), ...value });
165
+ controller.abort();
166
+ }; });
167
+ const inspect = async (end) => {
168
+ if (end) return finish({ outcome: end, response: null });
169
+ if (settled) return;
170
+ if (inspecting) { inspectAgain = true; return; }
171
+ inspecting = true;
172
+ try {
173
+ do {
174
+ inspectAgain = false;
175
+ const record = await this.client.read(id, { signal: waitSignal });
176
+ if (settled || this.closed) return;
177
+ lastRead = record;
178
+ if (!record.node.expectsAnswer) throw new Error('This node does not request an answer.');
179
+ const latest = record.children.filter((child) => child.response).at(-1);
180
+ if (latest && ['answer', 'clarification', 'defer', 'reject'].includes(latest.response.kind)) {
181
+ const savedResponse = readable({ node: latest,
182
+ ancestors: [...record.ancestors, record.node], children: [] }, this.client);
183
+ this.offered.add(latest.id);
184
+ // Hold replay until the host persists the tool result, even when
185
+ // this read found the answer before its SSE event arrived.
186
+ if (!this.pending.has(latest.id)) this.pending.set(latest.id, { sequence: 1,
187
+ responseId: latest.id, target: readable(record, this.client), response: savedResponse, sent: true });
188
+ else this.pending.get(latest.id).sent = true;
189
+ this.save();
190
+ finish({ outcome: latest.response.kind, response: savedResponse.node,
191
+ receivedResponseIds: [latest.id] });
192
+ }
193
+ } while (inspectAgain && !this.closed);
194
+ } catch (error) {
195
+ finish({ outcome: signal?.aborted ? 'cancelled' : 'unavailable', response: null, error: error.message });
196
+ } finally { inspecting = false; }
197
+ };
198
+ this.waiters.set(id, inspect);
199
+ const timer = setTimeout(() => inspect('timeout'), timeoutMs);
200
+ const abort = () => inspect('cancelled');
201
+ signal?.addEventListener('abort', abort, { once: true });
202
+ if (signal?.aborted || this.closed) abort(); else void inspect();
203
+ try {
204
+ const result = await outcome;
205
+ return { ...result, timedOut: result.outcome === 'timeout', participation: this.adjacent() };
206
+ } finally {
207
+ clearTimeout(timer); signal?.removeEventListener('abort', abort);
208
+ this.waiters.delete(id); this.flush();
209
+ }
210
+ }
211
+ }
@@ -0,0 +1,43 @@
1
+ import { readFileSync, realpathSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+
5
+ const physicalDirectory = dirname(realpathSync(fileURLToPath(import.meta.url)));
6
+ const workspaceRoot = resolve(physicalDirectory, '../../..');
7
+ const sourcePackage = resolve(physicalDirectory, '../../service');
8
+ const sourceService = (name) => pathToFileURL(resolve(sourcePackage, 'lib', name)).href;
9
+
10
+ function recognizedSourceWorkspace() {
11
+ try {
12
+ const workspace = JSON.parse(readFileSync(resolve(workspaceRoot, 'package.json'), 'utf8'));
13
+ const service = JSON.parse(readFileSync(resolve(sourcePackage, 'package.json'), 'utf8'));
14
+ return workspace.name === 'threadroom' && workspace.workspaces?.includes('packages/*') && service.name === 'threadroom-service';
15
+ } catch { return false; }
16
+ }
17
+
18
+ /** Lazy adapter-to-service boundary. A recognized source checkout—including a
19
+ * literal configured symlink—uses current physical sibling source. Packed
20
+ * installs resolve the bundled package. Neither path is selected from cwd. */
21
+ export function createManagedThreadroomService({ baseUrl, databaseValue, invocationCwd = process.cwd() }) {
22
+ let runtime;
23
+ async function load() {
24
+ if (!runtime) runtime = (async () => {
25
+ let ensureModule, pathsModule;
26
+ if (recognizedSourceWorkspace()) {
27
+ // Source is authoritative even while npm pack temporarily stages a
28
+ // package-local bundle; verified sibling initialization errors surface.
29
+ [ensureModule, pathsModule] = await Promise.all([
30
+ import(sourceService('ensure.js')), import(sourceService('paths.js')),
31
+ ]);
32
+ } else {
33
+ [ensureModule, pathsModule] = await Promise.all([
34
+ import('threadroom-service/ensure'), import('threadroom-service/paths'),
35
+ ]);
36
+ }
37
+ const database = pathsModule.threadroomDatabasePath({ value: databaseValue, cwd: invocationCwd });
38
+ return ensureModule.createThreadroomServiceEnsurer({ baseUrl, database });
39
+ })();
40
+ return runtime;
41
+ }
42
+ return { async ensure() { return (await load()).ensure(); } };
43
+ }