@openspecui/server 1.0.3 → 1.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 (2) hide show
  1. package/dist/index.mjs +552 -375
  2. package/package.json +4 -3
package/dist/index.mjs CHANGED
@@ -1,21 +1,414 @@
1
1
  import { serve } from "@hono/node-server";
2
- import { Hono } from "hono";
3
- import { cors } from "hono/cors";
2
+ import { ApplyInstructionsSchema, ArtifactInstructionsSchema, ChangeStatusSchema, CliExecutor, ConfigManager, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, SchemaDetailSchema, SchemaInfoSchema, SchemaResolutionSchema, TemplatesSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, initWatcherPool, isWatcherPoolInitialized, reactiveReadDir, reactiveReadFile, reactiveStat, sniffGlobalCli } from "@openspecui/core";
4
3
  import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
5
4
  import { applyWSSHandler } from "@trpc/server/adapters/ws";
5
+ import { Hono } from "hono";
6
+ import { cors } from "hono/cors";
6
7
  import { WebSocketServer } from "ws";
7
- import { ApplyInstructionsSchema, ArtifactInstructionsSchema, ChangeStatusSchema, CliExecutor, ConfigManager, OpenSpecAdapter, OpenSpecWatcher, OpsxKernel, PtyClientMessageSchema, ReactiveContext, SchemaDetailSchema, SchemaInfoSchema, SchemaResolutionSchema, TemplatesSchema, getAllTools, getAvailableTools, getConfiguredTools, getDefaultCliCommandString, initWatcherPool, isWatcherPoolInitialized, reactiveReadDir, reactiveReadFile, reactiveStat, sniffGlobalCli } from "@openspecui/core";
8
+ import { createServer as createServer$1 } from "node:net";
9
+ import * as pty from "@lydell/node-pty";
10
+ import { EventEmitter } from "events";
11
+ import { SearchQuerySchema } from "@openspecui/search";
8
12
  import { initTRPC } from "@trpc/server";
9
13
  import { observable } from "@trpc/server/observable";
10
14
  import { mkdir, rm, writeFile } from "node:fs/promises";
11
15
  import { dirname, join, matchesGlob, relative, resolve, sep } from "node:path";
12
16
  import { z } from "zod";
13
17
  import { parse } from "yaml";
14
- import { EventEmitter } from "node:events";
15
- import { createServer as createServer$1 } from "node:net";
16
- import * as pty from "@lydell/node-pty";
17
- import { EventEmitter as EventEmitter$1 } from "events";
18
+ import { EventEmitter as EventEmitter$1 } from "node:events";
19
+ import { NodeWorkerSearchProvider } from "@openspecui/search/node";
18
20
 
21
+ //#region src/port-utils.ts
22
+ /**
23
+ * Check if a port is available by trying to listen on it.
24
+ * Uses default binding (both IPv4 and IPv6) to detect conflicts.
25
+ */
26
+ function isPortAvailable(port) {
27
+ return new Promise((resolve$1) => {
28
+ const server = createServer$1();
29
+ server.once("error", () => {
30
+ resolve$1(false);
31
+ });
32
+ server.once("listening", () => {
33
+ server.close(() => resolve$1(true));
34
+ });
35
+ server.listen(port);
36
+ });
37
+ }
38
+ /**
39
+ * Find an available port starting from the given port.
40
+ * Will try up to maxAttempts ports sequentially.
41
+ *
42
+ * @param startPort - The preferred port to start checking from
43
+ * @param maxAttempts - Maximum number of ports to try (default: 10)
44
+ * @returns The first available port found
45
+ * @throws Error if no available port is found in the range
46
+ */
47
+ async function findAvailablePort(startPort, maxAttempts = 10) {
48
+ for (let i = 0; i < maxAttempts; i++) {
49
+ const port = startPort + i;
50
+ if (await isPortAvailable(port)) return port;
51
+ }
52
+ throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
53
+ }
54
+
55
+ //#endregion
56
+ //#region src/pty-manager.ts
57
+ const DEFAULT_SCROLLBACK = 1e3;
58
+ const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
59
+ function detectPtyPlatform() {
60
+ if (process.platform === "win32") return "windows";
61
+ if (process.platform === "darwin") return "macos";
62
+ return "common";
63
+ }
64
+ function resolveDefaultShell(platform, env) {
65
+ if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
66
+ return env.SHELL?.trim() || "/bin/sh";
67
+ }
68
+ function resolvePtyCommand(opts) {
69
+ const command = opts.command?.trim();
70
+ if (command) return {
71
+ command,
72
+ args: opts.args ?? []
73
+ };
74
+ return {
75
+ command: resolveDefaultShell(opts.platform, opts.env),
76
+ args: []
77
+ };
78
+ }
79
+ var PtySession = class extends EventEmitter {
80
+ id;
81
+ command;
82
+ args;
83
+ platform;
84
+ createdAt;
85
+ process;
86
+ titleInterval = null;
87
+ lastTitle = "";
88
+ buffer = [];
89
+ bufferByteLength = 0;
90
+ maxBufferLines;
91
+ maxBufferBytes;
92
+ isExited = false;
93
+ exitCode = null;
94
+ constructor(id, opts) {
95
+ super();
96
+ this.id = id;
97
+ this.createdAt = Date.now();
98
+ const resolvedCommand = resolvePtyCommand({
99
+ platform: opts.platform,
100
+ command: opts.command,
101
+ args: opts.args,
102
+ env: process.env
103
+ });
104
+ this.command = resolvedCommand.command;
105
+ this.args = resolvedCommand.args;
106
+ this.platform = opts.platform;
107
+ this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
108
+ this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
109
+ this.process = pty.spawn(this.command, this.args, {
110
+ name: "xterm-256color",
111
+ cols: opts.cols ?? 80,
112
+ rows: opts.rows ?? 24,
113
+ cwd: opts.cwd,
114
+ env: {
115
+ ...process.env,
116
+ TERM: "xterm-256color"
117
+ }
118
+ });
119
+ this.process.onData((data) => {
120
+ this.appendBuffer(data);
121
+ this.emit("data", data);
122
+ });
123
+ this.process.onExit(({ exitCode }) => {
124
+ if (this.titleInterval) {
125
+ clearInterval(this.titleInterval);
126
+ this.titleInterval = null;
127
+ }
128
+ this.isExited = true;
129
+ this.exitCode = exitCode;
130
+ this.emit("exit", exitCode);
131
+ });
132
+ this.titleInterval = setInterval(() => {
133
+ try {
134
+ const title = this.process.process;
135
+ if (title && title !== this.lastTitle) {
136
+ this.lastTitle = title;
137
+ this.emit("title", title);
138
+ }
139
+ } catch {}
140
+ }, 1e3);
141
+ }
142
+ get title() {
143
+ return this.lastTitle;
144
+ }
145
+ appendBuffer(data) {
146
+ let chunk = data;
147
+ if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
148
+ this.buffer.push(chunk);
149
+ this.bufferByteLength += chunk.length;
150
+ while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
151
+ const removed = this.buffer.shift();
152
+ this.bufferByteLength -= removed.length;
153
+ }
154
+ while (this.buffer.length > this.maxBufferLines) {
155
+ const removed = this.buffer.shift();
156
+ this.bufferByteLength -= removed.length;
157
+ }
158
+ }
159
+ getBuffer() {
160
+ return this.buffer.join("");
161
+ }
162
+ write(data) {
163
+ if (!this.isExited) this.process.write(data);
164
+ }
165
+ resize(cols, rows) {
166
+ if (!this.isExited) this.process.resize(cols, rows);
167
+ }
168
+ close() {
169
+ if (this.titleInterval) {
170
+ clearInterval(this.titleInterval);
171
+ this.titleInterval = null;
172
+ }
173
+ try {
174
+ this.process.kill();
175
+ } catch {}
176
+ this.removeAllListeners();
177
+ }
178
+ toInfo() {
179
+ return {
180
+ id: this.id,
181
+ title: this.lastTitle,
182
+ command: this.command,
183
+ args: this.args,
184
+ platform: this.platform,
185
+ isExited: this.isExited,
186
+ exitCode: this.exitCode,
187
+ createdAt: this.createdAt
188
+ };
189
+ }
190
+ };
191
+ var PtyManager = class {
192
+ sessions = /* @__PURE__ */ new Map();
193
+ idCounter = 0;
194
+ platform;
195
+ constructor(defaultCwd) {
196
+ this.defaultCwd = defaultCwd;
197
+ this.platform = detectPtyPlatform();
198
+ }
199
+ create(opts) {
200
+ const id = `pty-${++this.idCounter}`;
201
+ const session = new PtySession(id, {
202
+ cols: opts.cols,
203
+ rows: opts.rows,
204
+ command: opts.command,
205
+ args: opts.args,
206
+ cwd: this.defaultCwd,
207
+ scrollback: opts.scrollback,
208
+ maxBufferBytes: opts.maxBufferBytes,
209
+ platform: this.platform
210
+ });
211
+ this.sessions.set(id, session);
212
+ return session;
213
+ }
214
+ get(id) {
215
+ return this.sessions.get(id);
216
+ }
217
+ list() {
218
+ const result = [];
219
+ for (const session of this.sessions.values()) result.push(session.toInfo());
220
+ return result;
221
+ }
222
+ write(id, data) {
223
+ this.sessions.get(id)?.write(data);
224
+ }
225
+ resize(id, cols, rows) {
226
+ this.sessions.get(id)?.resize(cols, rows);
227
+ }
228
+ close(id) {
229
+ const session = this.sessions.get(id);
230
+ if (session) {
231
+ session.close();
232
+ this.sessions.delete(id);
233
+ }
234
+ }
235
+ closeAll() {
236
+ for (const session of this.sessions.values()) session.close();
237
+ this.sessions.clear();
238
+ }
239
+ };
240
+
241
+ //#endregion
242
+ //#region src/pty-websocket.ts
243
+ function createPtyWebSocketHandler(ptyManager) {
244
+ return (ws) => {
245
+ const cleanups = /* @__PURE__ */ new Map();
246
+ const send = (msg) => {
247
+ if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
248
+ };
249
+ const sendError = (code, message, opts) => {
250
+ send({
251
+ type: "error",
252
+ code,
253
+ message,
254
+ sessionId: opts?.sessionId
255
+ });
256
+ };
257
+ const attachToSession = (session, opts) => {
258
+ const sessionId = session.id;
259
+ cleanups.get(sessionId)?.();
260
+ if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
261
+ const onData = (data) => {
262
+ send({
263
+ type: "output",
264
+ sessionId,
265
+ data
266
+ });
267
+ };
268
+ const onExit = (exitCode) => {
269
+ send({
270
+ type: "exit",
271
+ sessionId,
272
+ exitCode
273
+ });
274
+ };
275
+ const onTitle = (title) => {
276
+ send({
277
+ type: "title",
278
+ sessionId,
279
+ title
280
+ });
281
+ };
282
+ session.on("data", onData);
283
+ session.on("exit", onExit);
284
+ session.on("title", onTitle);
285
+ cleanups.set(sessionId, () => {
286
+ session.removeListener("data", onData);
287
+ session.removeListener("exit", onExit);
288
+ session.removeListener("title", onTitle);
289
+ cleanups.delete(sessionId);
290
+ });
291
+ };
292
+ ws.on("message", (raw) => {
293
+ let parsed;
294
+ try {
295
+ parsed = JSON.parse(String(raw));
296
+ } catch {
297
+ sendError("INVALID_JSON", "Invalid JSON payload");
298
+ return;
299
+ }
300
+ const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
301
+ if (!parsedMessage.success) {
302
+ const firstIssue = parsedMessage.error.issues[0]?.message;
303
+ sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
304
+ return;
305
+ }
306
+ const msg = parsedMessage.data;
307
+ switch (msg.type) {
308
+ case "create":
309
+ try {
310
+ const session = ptyManager.create({
311
+ cols: msg.cols,
312
+ rows: msg.rows,
313
+ command: msg.command,
314
+ args: msg.args
315
+ });
316
+ send({
317
+ type: "created",
318
+ requestId: msg.requestId,
319
+ sessionId: session.id,
320
+ platform: session.platform
321
+ });
322
+ attachToSession(session);
323
+ } catch (err) {
324
+ sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
325
+ }
326
+ break;
327
+ case "attach": {
328
+ const session = ptyManager.get(msg.sessionId);
329
+ if (!session) {
330
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
331
+ send({
332
+ type: "exit",
333
+ sessionId: msg.sessionId,
334
+ exitCode: -1
335
+ });
336
+ break;
337
+ }
338
+ attachToSession(session, {
339
+ cols: msg.cols,
340
+ rows: msg.rows
341
+ });
342
+ const buffer = session.getBuffer();
343
+ if (buffer) send({
344
+ type: "buffer",
345
+ sessionId: session.id,
346
+ data: buffer
347
+ });
348
+ if (session.title) send({
349
+ type: "title",
350
+ sessionId: session.id,
351
+ title: session.title
352
+ });
353
+ if (session.isExited) send({
354
+ type: "exit",
355
+ sessionId: session.id,
356
+ exitCode: session.exitCode ?? -1
357
+ });
358
+ break;
359
+ }
360
+ case "list":
361
+ send({
362
+ type: "list",
363
+ sessions: ptyManager.list().map((s) => ({
364
+ id: s.id,
365
+ title: s.title,
366
+ command: s.command,
367
+ args: s.args,
368
+ platform: s.platform,
369
+ isExited: s.isExited,
370
+ exitCode: s.exitCode
371
+ }))
372
+ });
373
+ break;
374
+ case "input": {
375
+ const session = ptyManager.get(msg.sessionId);
376
+ if (!session) {
377
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
378
+ break;
379
+ }
380
+ session.write(msg.data);
381
+ break;
382
+ }
383
+ case "resize": {
384
+ const session = ptyManager.get(msg.sessionId);
385
+ if (!session) {
386
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
387
+ break;
388
+ }
389
+ session.resize(msg.cols, msg.rows);
390
+ break;
391
+ }
392
+ case "close": {
393
+ const session = ptyManager.get(msg.sessionId);
394
+ if (!session) {
395
+ sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
396
+ break;
397
+ }
398
+ cleanups.get(msg.sessionId)?.();
399
+ ptyManager.close(session.id);
400
+ break;
401
+ }
402
+ }
403
+ });
404
+ ws.on("close", () => {
405
+ for (const cleanup of cleanups.values()) cleanup();
406
+ cleanups.clear();
407
+ });
408
+ };
409
+ }
410
+
411
+ //#endregion
19
412
  //#region src/cli-stream-observable.ts
20
413
  /**
21
414
  * 创建安全的 CLI 流式 observable
@@ -131,7 +524,7 @@ function parseSchemaYaml(content) {
131
524
  */
132
525
  var ReactiveKV = class {
133
526
  store = /* @__PURE__ */ new Map();
134
- emitter = new EventEmitter();
527
+ emitter = new EventEmitter$1();
135
528
  constructor() {
136
529
  this.emitter.setMaxListeners(200);
137
530
  }
@@ -1086,389 +1479,165 @@ const kvRouter = router({
1086
1479
  })
1087
1480
  });
1088
1481
  /**
1089
- * Main app router
1090
- */
1091
- const appRouter = router({
1092
- spec: specRouter,
1093
- change: changeRouter,
1094
- archive: archiveRouter,
1095
- init: initRouter,
1096
- realtime: realtimeRouter,
1097
- config: configRouter,
1098
- cli: cliRouter,
1099
- opsx: opsxRouter,
1100
- kv: kvRouter
1101
- });
1102
-
1103
- //#endregion
1104
- //#region src/port-utils.ts
1105
- /**
1106
- * Check if a port is available by trying to listen on it.
1107
- * Uses default binding (both IPv4 and IPv6) to detect conflicts.
1108
- */
1109
- function isPortAvailable(port) {
1110
- return new Promise((resolve$1) => {
1111
- const server = createServer$1();
1112
- server.once("error", () => {
1113
- resolve$1(false);
1114
- });
1115
- server.once("listening", () => {
1116
- server.close(() => resolve$1(true));
1117
- });
1118
- server.listen(port);
1119
- });
1120
- }
1121
- /**
1122
- * Find an available port starting from the given port.
1123
- * Will try up to maxAttempts ports sequentially.
1124
- *
1125
- * @param startPort - The preferred port to start checking from
1126
- * @param maxAttempts - Maximum number of ports to try (default: 10)
1127
- * @returns The first available port found
1128
- * @throws Error if no available port is found in the range
1482
+ * Search router - unified fulltext search over specs/changes/archives
1129
1483
  */
1130
- async function findAvailablePort(startPort, maxAttempts = 10) {
1131
- for (let i = 0; i < maxAttempts; i++) {
1132
- const port = startPort + i;
1133
- if (await isPortAvailable(port)) return port;
1134
- }
1135
- throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts - 1}`);
1136
- }
1137
-
1138
- //#endregion
1139
- //#region src/pty-manager.ts
1140
- const DEFAULT_SCROLLBACK = 1e3;
1141
- const DEFAULT_MAX_BUFFER_BYTES = 2 * 1024 * 1024;
1142
- function detectPtyPlatform() {
1143
- if (process.platform === "win32") return "windows";
1144
- if (process.platform === "darwin") return "macos";
1145
- return "common";
1146
- }
1147
- var PtySession = class extends EventEmitter$1 {
1148
- id;
1149
- command;
1150
- args;
1151
- platform;
1152
- createdAt;
1153
- process;
1154
- titleInterval = null;
1155
- lastTitle = "";
1156
- buffer = [];
1157
- bufferByteLength = 0;
1158
- maxBufferLines;
1159
- maxBufferBytes;
1160
- isExited = false;
1161
- exitCode = null;
1162
- constructor(id, opts) {
1163
- super();
1164
- this.id = id;
1165
- this.createdAt = Date.now();
1166
- const shell = opts.command ?? process.env.SHELL ?? "/bin/sh";
1167
- const args = opts.command ? opts.args ?? [] : [];
1168
- this.command = shell;
1169
- this.args = args;
1170
- this.platform = opts.platform;
1171
- this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
1172
- this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
1173
- this.process = pty.spawn(shell, args, {
1174
- name: "xterm-256color",
1175
- cols: opts.cols ?? 80,
1176
- rows: opts.rows ?? 24,
1177
- cwd: opts.cwd,
1178
- env: {
1179
- ...process.env,
1180
- TERM: "xterm-256color"
1181
- }
1182
- });
1183
- this.process.onData((data) => {
1184
- this.appendBuffer(data);
1185
- this.emit("data", data);
1186
- });
1187
- this.process.onExit(({ exitCode }) => {
1188
- if (this.titleInterval) {
1189
- clearInterval(this.titleInterval);
1190
- this.titleInterval = null;
1191
- }
1192
- this.isExited = true;
1193
- this.exitCode = exitCode;
1194
- this.emit("exit", exitCode);
1195
- });
1196
- this.titleInterval = setInterval(() => {
1197
- try {
1198
- const title = this.process.process;
1199
- if (title && title !== this.lastTitle) {
1200
- this.lastTitle = title;
1201
- this.emit("title", title);
1202
- }
1203
- } catch {}
1204
- }, 1e3);
1205
- }
1206
- get title() {
1207
- return this.lastTitle;
1208
- }
1209
- appendBuffer(data) {
1210
- let chunk = data;
1211
- if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
1212
- this.buffer.push(chunk);
1213
- this.bufferByteLength += chunk.length;
1214
- while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
1215
- const removed = this.buffer.shift();
1216
- this.bufferByteLength -= removed.length;
1217
- }
1218
- while (this.buffer.length > this.maxBufferLines) {
1219
- const removed = this.buffer.shift();
1220
- this.bufferByteLength -= removed.length;
1221
- }
1484
+ const searchRouter = router({
1485
+ query: publicProcedure.input(SearchQuerySchema).query(async ({ ctx, input }) => {
1486
+ return ctx.searchService.query(input);
1487
+ }),
1488
+ subscribe: publicProcedure.input(SearchQuerySchema).subscription(({ ctx, input }) => {
1489
+ return createReactiveSubscriptionWithInput((queryInput) => ctx.searchService.queryReactive(queryInput))(input);
1490
+ })
1491
+ });
1492
+ /**
1493
+ * Main app router
1494
+ */
1495
+ const appRouter = router({
1496
+ spec: specRouter,
1497
+ change: changeRouter,
1498
+ archive: archiveRouter,
1499
+ init: initRouter,
1500
+ realtime: realtimeRouter,
1501
+ config: configRouter,
1502
+ cli: cliRouter,
1503
+ opsx: opsxRouter,
1504
+ kv: kvRouter,
1505
+ search: searchRouter
1506
+ });
1507
+
1508
+ //#endregion
1509
+ //#region src/search-documents.ts
1510
+ function joinParts(parts) {
1511
+ return parts.map((part) => part?.trim() ?? "").filter((part) => part.length > 0).join("\n\n");
1512
+ }
1513
+ async function collectSearchDocuments(adapter) {
1514
+ const docs = [];
1515
+ const specs = await adapter.listSpecsWithMeta();
1516
+ for (const spec of specs) {
1517
+ const raw = await adapter.readSpecRaw(spec.id);
1518
+ if (!raw) continue;
1519
+ docs.push({
1520
+ id: `spec:${spec.id}`,
1521
+ kind: "spec",
1522
+ title: spec.name,
1523
+ href: `/specs/${encodeURIComponent(spec.id)}`,
1524
+ path: `openspec/specs/${spec.id}/spec.md`,
1525
+ content: raw,
1526
+ updatedAt: spec.updatedAt
1527
+ });
1222
1528
  }
1223
- getBuffer() {
1224
- return this.buffer.join("");
1529
+ const changes = await adapter.listChangesWithMeta();
1530
+ for (const change of changes) {
1531
+ const raw = await adapter.readChangeRaw(change.id);
1532
+ if (!raw) continue;
1533
+ docs.push({
1534
+ id: `change:${change.id}`,
1535
+ kind: "change",
1536
+ title: change.name,
1537
+ href: `/changes/${encodeURIComponent(change.id)}`,
1538
+ path: `openspec/changes/${change.id}`,
1539
+ content: joinParts([
1540
+ raw.proposal,
1541
+ raw.tasks,
1542
+ raw.design,
1543
+ ...raw.deltaSpecs.map((deltaSpec) => deltaSpec.content)
1544
+ ]),
1545
+ updatedAt: change.updatedAt
1546
+ });
1225
1547
  }
1226
- write(data) {
1227
- if (!this.isExited) this.process.write(data);
1548
+ const archives = await adapter.listArchivedChangesWithMeta();
1549
+ for (const archive of archives) {
1550
+ const raw = await adapter.readArchivedChangeRaw(archive.id);
1551
+ if (!raw) continue;
1552
+ docs.push({
1553
+ id: `archive:${archive.id}`,
1554
+ kind: "archive",
1555
+ title: archive.name,
1556
+ href: `/archive/${encodeURIComponent(archive.id)}`,
1557
+ path: `openspec/changes/archive/${archive.id}`,
1558
+ content: joinParts([
1559
+ raw.proposal,
1560
+ raw.tasks,
1561
+ raw.design,
1562
+ ...raw.deltaSpecs.map((deltaSpec) => deltaSpec.content)
1563
+ ]),
1564
+ updatedAt: archive.updatedAt
1565
+ });
1228
1566
  }
1229
- resize(cols, rows) {
1230
- if (!this.isExited) this.process.resize(cols, rows);
1567
+ return docs;
1568
+ }
1569
+
1570
+ //#endregion
1571
+ //#region src/search-service.ts
1572
+ const REBUILD_DEBOUNCE_MS = 250;
1573
+ var SearchService = class {
1574
+ provider;
1575
+ initialized = false;
1576
+ initPromise = null;
1577
+ rebuildPromise = null;
1578
+ rebuildTimer = null;
1579
+ constructor(adapter, watcher, provider = new NodeWorkerSearchProvider()) {
1580
+ this.adapter = adapter;
1581
+ this.provider = provider;
1582
+ watcher?.on("change", () => {
1583
+ this.scheduleRebuild();
1584
+ });
1231
1585
  }
1232
- close() {
1233
- if (this.titleInterval) {
1234
- clearInterval(this.titleInterval);
1235
- this.titleInterval = null;
1236
- }
1586
+ async init() {
1587
+ if (this.initialized) return;
1588
+ if (this.initPromise) return this.initPromise;
1589
+ this.initPromise = this.rebuildIndex(true);
1237
1590
  try {
1238
- this.process.kill();
1239
- } catch {}
1240
- this.removeAllListeners();
1241
- }
1242
- toInfo() {
1243
- return {
1244
- id: this.id,
1245
- title: this.lastTitle,
1246
- command: this.command,
1247
- args: this.args,
1248
- platform: this.platform,
1249
- isExited: this.isExited,
1250
- exitCode: this.exitCode,
1251
- createdAt: this.createdAt
1252
- };
1253
- }
1254
- };
1255
- var PtyManager = class {
1256
- sessions = /* @__PURE__ */ new Map();
1257
- idCounter = 0;
1258
- platform;
1259
- constructor(defaultCwd) {
1260
- this.defaultCwd = defaultCwd;
1261
- this.platform = detectPtyPlatform();
1591
+ await this.initPromise;
1592
+ } finally {
1593
+ this.initPromise = null;
1594
+ }
1262
1595
  }
1263
- create(opts) {
1264
- const id = `pty-${++this.idCounter}`;
1265
- const session = new PtySession(id, {
1266
- cols: opts.cols,
1267
- rows: opts.rows,
1268
- command: opts.command,
1269
- args: opts.args,
1270
- cwd: this.defaultCwd,
1271
- scrollback: opts.scrollback,
1272
- maxBufferBytes: opts.maxBufferBytes,
1273
- platform: this.platform
1274
- });
1275
- this.sessions.set(id, session);
1276
- return session;
1596
+ async query(input) {
1597
+ const parsed = SearchQuerySchema.parse(input);
1598
+ await this.init();
1599
+ return this.provider.search(parsed);
1277
1600
  }
1278
- get(id) {
1279
- return this.sessions.get(id);
1601
+ async queryReactive(input) {
1602
+ const parsed = SearchQuerySchema.parse(input);
1603
+ await this.rebuildIndex();
1604
+ return this.provider.search(parsed);
1280
1605
  }
1281
- list() {
1282
- const result = [];
1283
- for (const session of this.sessions.values()) result.push(session.toInfo());
1284
- return result;
1606
+ async dispose() {
1607
+ this.cancelRebuild();
1608
+ await this.provider.dispose();
1285
1609
  }
1286
- write(id, data) {
1287
- this.sessions.get(id)?.write(data);
1610
+ scheduleRebuild() {
1611
+ this.cancelRebuild();
1612
+ this.rebuildTimer = setTimeout(() => {
1613
+ this.rebuildTimer = null;
1614
+ this.rebuildIndex().catch(() => {});
1615
+ }, REBUILD_DEBOUNCE_MS);
1288
1616
  }
1289
- resize(id, cols, rows) {
1290
- this.sessions.get(id)?.resize(cols, rows);
1617
+ cancelRebuild() {
1618
+ if (!this.rebuildTimer) return;
1619
+ clearTimeout(this.rebuildTimer);
1620
+ this.rebuildTimer = null;
1291
1621
  }
1292
- close(id) {
1293
- const session = this.sessions.get(id);
1294
- if (session) {
1295
- session.close();
1296
- this.sessions.delete(id);
1622
+ async rebuildIndex(forceInit = false) {
1623
+ if (!forceInit && !this.initialized) return;
1624
+ if (this.rebuildPromise) return this.rebuildPromise;
1625
+ this.rebuildPromise = (async () => {
1626
+ const docs = await collectSearchDocuments(this.adapter);
1627
+ if (this.initialized) await this.provider.replaceAll(docs);
1628
+ else {
1629
+ await this.provider.init(docs);
1630
+ this.initialized = true;
1631
+ }
1632
+ })();
1633
+ try {
1634
+ await this.rebuildPromise;
1635
+ } finally {
1636
+ this.rebuildPromise = null;
1297
1637
  }
1298
1638
  }
1299
- closeAll() {
1300
- for (const session of this.sessions.values()) session.close();
1301
- this.sessions.clear();
1302
- }
1303
1639
  };
1304
1640
 
1305
- //#endregion
1306
- //#region src/pty-websocket.ts
1307
- function createPtyWebSocketHandler(ptyManager) {
1308
- return (ws) => {
1309
- const cleanups = /* @__PURE__ */ new Map();
1310
- const send = (msg) => {
1311
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
1312
- };
1313
- const sendError = (code, message, opts) => {
1314
- send({
1315
- type: "error",
1316
- code,
1317
- message,
1318
- sessionId: opts?.sessionId
1319
- });
1320
- };
1321
- const attachToSession = (session, opts) => {
1322
- const sessionId = session.id;
1323
- cleanups.get(sessionId)?.();
1324
- if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
1325
- const onData = (data) => {
1326
- send({
1327
- type: "output",
1328
- sessionId,
1329
- data
1330
- });
1331
- };
1332
- const onExit = (exitCode) => {
1333
- send({
1334
- type: "exit",
1335
- sessionId,
1336
- exitCode
1337
- });
1338
- };
1339
- const onTitle = (title) => {
1340
- send({
1341
- type: "title",
1342
- sessionId,
1343
- title
1344
- });
1345
- };
1346
- session.on("data", onData);
1347
- session.on("exit", onExit);
1348
- session.on("title", onTitle);
1349
- cleanups.set(sessionId, () => {
1350
- session.removeListener("data", onData);
1351
- session.removeListener("exit", onExit);
1352
- session.removeListener("title", onTitle);
1353
- cleanups.delete(sessionId);
1354
- });
1355
- };
1356
- ws.on("message", (raw) => {
1357
- let parsed;
1358
- try {
1359
- parsed = JSON.parse(String(raw));
1360
- } catch {
1361
- sendError("INVALID_JSON", "Invalid JSON payload");
1362
- return;
1363
- }
1364
- const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
1365
- if (!parsedMessage.success) {
1366
- const firstIssue = parsedMessage.error.issues[0]?.message;
1367
- sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
1368
- return;
1369
- }
1370
- const msg = parsedMessage.data;
1371
- switch (msg.type) {
1372
- case "create": {
1373
- const session = ptyManager.create({
1374
- cols: msg.cols,
1375
- rows: msg.rows,
1376
- command: msg.command,
1377
- args: msg.args
1378
- });
1379
- send({
1380
- type: "created",
1381
- requestId: msg.requestId,
1382
- sessionId: session.id,
1383
- platform: session.platform
1384
- });
1385
- attachToSession(session);
1386
- break;
1387
- }
1388
- case "attach": {
1389
- const session = ptyManager.get(msg.sessionId);
1390
- if (!session) {
1391
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1392
- send({
1393
- type: "exit",
1394
- sessionId: msg.sessionId,
1395
- exitCode: -1
1396
- });
1397
- break;
1398
- }
1399
- attachToSession(session, {
1400
- cols: msg.cols,
1401
- rows: msg.rows
1402
- });
1403
- const buffer = session.getBuffer();
1404
- if (buffer) send({
1405
- type: "buffer",
1406
- sessionId: session.id,
1407
- data: buffer
1408
- });
1409
- if (session.title) send({
1410
- type: "title",
1411
- sessionId: session.id,
1412
- title: session.title
1413
- });
1414
- if (session.isExited) send({
1415
- type: "exit",
1416
- sessionId: session.id,
1417
- exitCode: session.exitCode ?? -1
1418
- });
1419
- break;
1420
- }
1421
- case "list":
1422
- send({
1423
- type: "list",
1424
- sessions: ptyManager.list().map((s) => ({
1425
- id: s.id,
1426
- title: s.title,
1427
- command: s.command,
1428
- args: s.args,
1429
- platform: s.platform,
1430
- isExited: s.isExited,
1431
- exitCode: s.exitCode
1432
- }))
1433
- });
1434
- break;
1435
- case "input": {
1436
- const session = ptyManager.get(msg.sessionId);
1437
- if (!session) {
1438
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1439
- break;
1440
- }
1441
- session.write(msg.data);
1442
- break;
1443
- }
1444
- case "resize": {
1445
- const session = ptyManager.get(msg.sessionId);
1446
- if (!session) {
1447
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1448
- break;
1449
- }
1450
- session.resize(msg.cols, msg.rows);
1451
- break;
1452
- }
1453
- case "close": {
1454
- const session = ptyManager.get(msg.sessionId);
1455
- if (!session) {
1456
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1457
- break;
1458
- }
1459
- cleanups.get(msg.sessionId)?.();
1460
- ptyManager.close(session.id);
1461
- break;
1462
- }
1463
- }
1464
- });
1465
- ws.on("close", () => {
1466
- for (const cleanup of cleanups.values()) cleanup();
1467
- cleanups.clear();
1468
- });
1469
- };
1470
- }
1471
-
1472
1641
  //#endregion
1473
1642
  //#region src/server.ts
1474
1643
  /**
@@ -1492,6 +1661,7 @@ function createServer(config) {
1492
1661
  const cliExecutor = new CliExecutor(configManager, config.projectDir);
1493
1662
  const kernel = config.kernel;
1494
1663
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
1664
+ const searchService = new SearchService(adapter, watcher);
1495
1665
  const app = new Hono();
1496
1666
  const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
1497
1667
  app.use("*", cors({
@@ -1515,6 +1685,7 @@ function createServer(config) {
1515
1685
  configManager,
1516
1686
  cliExecutor,
1517
1687
  kernel,
1688
+ searchService,
1518
1689
  watcher,
1519
1690
  projectDir: config.projectDir
1520
1691
  })
@@ -1525,6 +1696,7 @@ function createServer(config) {
1525
1696
  configManager,
1526
1697
  cliExecutor,
1527
1698
  kernel,
1699
+ searchService,
1528
1700
  watcher,
1529
1701
  projectDir: config.projectDir
1530
1702
  });
@@ -1534,6 +1706,7 @@ function createServer(config) {
1534
1706
  configManager,
1535
1707
  cliExecutor,
1536
1708
  kernel,
1709
+ searchService,
1537
1710
  watcher,
1538
1711
  createContext,
1539
1712
  port: config.port ?? 3100
@@ -1575,6 +1748,7 @@ async function createWebSocketServer(server, httpServer, config) {
1575
1748
  ptyWss.close();
1576
1749
  wss.close();
1577
1750
  server.watcher?.stop();
1751
+ server.searchService.dispose().catch(() => {});
1578
1752
  }
1579
1753
  };
1580
1754
  }
@@ -1607,6 +1781,9 @@ async function startServer(config, setupApp) {
1607
1781
  kernel.warmup().catch((err) => {
1608
1782
  console.error("Kernel warmup failed:", err);
1609
1783
  });
1784
+ server.searchService.init().catch((err) => {
1785
+ console.error("Search service warmup failed:", err);
1786
+ });
1610
1787
  return {
1611
1788
  url,
1612
1789
  port,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.mjs",
6
6
  "exports": {
@@ -20,7 +20,8 @@
20
20
  "yaml": "^2.8.0",
21
21
  "yargs": "^18.0.0",
22
22
  "zod": "^3.24.1",
23
- "@openspecui/core": "1.0.3"
23
+ "@openspecui/search": "1.1.0",
24
+ "@openspecui/core": "1.1.0"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@types/node": "^22.10.2",
@@ -34,7 +35,7 @@
34
35
  "scripts": {
35
36
  "build": "tsdown src/index.ts --format esm --no-dts",
36
37
  "typecheck": "tsc --noEmit",
37
- "dev": "tsx watch --include '../core/dist/**' src/standalone.ts",
38
+ "dev": "tsx watch --include '../core/dist/**' --include '../search/dist/**' src/standalone.ts",
38
39
  "test": "vitest run",
39
40
  "test:watch": "vitest"
40
41
  }