@openspecui/server 1.0.4 → 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 +544 -389
  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";
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
+ }
18
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,6 +1479,17 @@ const kvRouter = router({
1086
1479
  })
1087
1480
  });
1088
1481
  /**
1482
+ * Search router - unified fulltext search over specs/changes/archives
1483
+ */
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
+ /**
1089
1493
  * Main app router
1090
1494
  */
1091
1495
  const appRouter = router({
@@ -1095,402 +1499,145 @@ const appRouter = router({
1095
1499
  init: initRouter,
1096
1500
  realtime: realtimeRouter,
1097
1501
  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
1129
- */
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
- function resolveDefaultShell(platform, env) {
1148
- if (platform === "windows") return env.ComSpec?.trim() || "cmd.exe";
1149
- return env.SHELL?.trim() || "/bin/sh";
1150
- }
1151
- function resolvePtyCommand(opts) {
1152
- const command = opts.command?.trim();
1153
- if (command) return {
1154
- command,
1155
- args: opts.args ?? []
1156
- };
1157
- return {
1158
- command: resolveDefaultShell(opts.platform, opts.env),
1159
- args: []
1160
- };
1161
- }
1162
- var PtySession = class extends EventEmitter$1 {
1163
- id;
1164
- command;
1165
- args;
1166
- platform;
1167
- createdAt;
1168
- process;
1169
- titleInterval = null;
1170
- lastTitle = "";
1171
- buffer = [];
1172
- bufferByteLength = 0;
1173
- maxBufferLines;
1174
- maxBufferBytes;
1175
- isExited = false;
1176
- exitCode = null;
1177
- constructor(id, opts) {
1178
- super();
1179
- this.id = id;
1180
- this.createdAt = Date.now();
1181
- const resolvedCommand = resolvePtyCommand({
1182
- platform: opts.platform,
1183
- command: opts.command,
1184
- args: opts.args,
1185
- env: process.env
1186
- });
1187
- this.command = resolvedCommand.command;
1188
- this.args = resolvedCommand.args;
1189
- this.platform = opts.platform;
1190
- this.maxBufferLines = opts.scrollback ?? DEFAULT_SCROLLBACK;
1191
- this.maxBufferBytes = opts.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
1192
- this.process = pty.spawn(this.command, this.args, {
1193
- name: "xterm-256color",
1194
- cols: opts.cols ?? 80,
1195
- rows: opts.rows ?? 24,
1196
- cwd: opts.cwd,
1197
- env: {
1198
- ...process.env,
1199
- TERM: "xterm-256color"
1200
- }
1201
- });
1202
- this.process.onData((data) => {
1203
- this.appendBuffer(data);
1204
- this.emit("data", data);
1205
- });
1206
- this.process.onExit(({ exitCode }) => {
1207
- if (this.titleInterval) {
1208
- clearInterval(this.titleInterval);
1209
- this.titleInterval = null;
1210
- }
1211
- this.isExited = true;
1212
- this.exitCode = exitCode;
1213
- this.emit("exit", exitCode);
1214
- });
1215
- this.titleInterval = setInterval(() => {
1216
- try {
1217
- const title = this.process.process;
1218
- if (title && title !== this.lastTitle) {
1219
- this.lastTitle = title;
1220
- this.emit("title", title);
1221
- }
1222
- } catch {}
1223
- }, 1e3);
1224
- }
1225
- get title() {
1226
- return this.lastTitle;
1227
- }
1228
- appendBuffer(data) {
1229
- let chunk = data;
1230
- if (chunk.length > this.maxBufferBytes) chunk = chunk.slice(-this.maxBufferBytes);
1231
- this.buffer.push(chunk);
1232
- this.bufferByteLength += chunk.length;
1233
- while (this.bufferByteLength > this.maxBufferBytes && this.buffer.length > 0) {
1234
- const removed = this.buffer.shift();
1235
- this.bufferByteLength -= removed.length;
1236
- }
1237
- while (this.buffer.length > this.maxBufferLines) {
1238
- const removed = this.buffer.shift();
1239
- this.bufferByteLength -= removed.length;
1240
- }
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
+ });
1241
1528
  }
1242
- getBuffer() {
1243
- 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
+ });
1244
1547
  }
1245
- write(data) {
1246
- 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
+ });
1247
1566
  }
1248
- resize(cols, rows) {
1249
- 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
+ });
1250
1585
  }
1251
- close() {
1252
- if (this.titleInterval) {
1253
- clearInterval(this.titleInterval);
1254
- this.titleInterval = null;
1255
- }
1586
+ async init() {
1587
+ if (this.initialized) return;
1588
+ if (this.initPromise) return this.initPromise;
1589
+ this.initPromise = this.rebuildIndex(true);
1256
1590
  try {
1257
- this.process.kill();
1258
- } catch {}
1259
- this.removeAllListeners();
1260
- }
1261
- toInfo() {
1262
- return {
1263
- id: this.id,
1264
- title: this.lastTitle,
1265
- command: this.command,
1266
- args: this.args,
1267
- platform: this.platform,
1268
- isExited: this.isExited,
1269
- exitCode: this.exitCode,
1270
- createdAt: this.createdAt
1271
- };
1272
- }
1273
- };
1274
- var PtyManager = class {
1275
- sessions = /* @__PURE__ */ new Map();
1276
- idCounter = 0;
1277
- platform;
1278
- constructor(defaultCwd) {
1279
- this.defaultCwd = defaultCwd;
1280
- this.platform = detectPtyPlatform();
1591
+ await this.initPromise;
1592
+ } finally {
1593
+ this.initPromise = null;
1594
+ }
1281
1595
  }
1282
- create(opts) {
1283
- const id = `pty-${++this.idCounter}`;
1284
- const session = new PtySession(id, {
1285
- cols: opts.cols,
1286
- rows: opts.rows,
1287
- command: opts.command,
1288
- args: opts.args,
1289
- cwd: this.defaultCwd,
1290
- scrollback: opts.scrollback,
1291
- maxBufferBytes: opts.maxBufferBytes,
1292
- platform: this.platform
1293
- });
1294
- this.sessions.set(id, session);
1295
- return session;
1596
+ async query(input) {
1597
+ const parsed = SearchQuerySchema.parse(input);
1598
+ await this.init();
1599
+ return this.provider.search(parsed);
1296
1600
  }
1297
- get(id) {
1298
- 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);
1299
1605
  }
1300
- list() {
1301
- const result = [];
1302
- for (const session of this.sessions.values()) result.push(session.toInfo());
1303
- return result;
1606
+ async dispose() {
1607
+ this.cancelRebuild();
1608
+ await this.provider.dispose();
1304
1609
  }
1305
- write(id, data) {
1306
- 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);
1307
1616
  }
1308
- resize(id, cols, rows) {
1309
- this.sessions.get(id)?.resize(cols, rows);
1617
+ cancelRebuild() {
1618
+ if (!this.rebuildTimer) return;
1619
+ clearTimeout(this.rebuildTimer);
1620
+ this.rebuildTimer = null;
1310
1621
  }
1311
- close(id) {
1312
- const session = this.sessions.get(id);
1313
- if (session) {
1314
- session.close();
1315
- 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;
1316
1637
  }
1317
1638
  }
1318
- closeAll() {
1319
- for (const session of this.sessions.values()) session.close();
1320
- this.sessions.clear();
1321
- }
1322
1639
  };
1323
1640
 
1324
- //#endregion
1325
- //#region src/pty-websocket.ts
1326
- function createPtyWebSocketHandler(ptyManager) {
1327
- return (ws) => {
1328
- const cleanups = /* @__PURE__ */ new Map();
1329
- const send = (msg) => {
1330
- if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
1331
- };
1332
- const sendError = (code, message, opts) => {
1333
- send({
1334
- type: "error",
1335
- code,
1336
- message,
1337
- sessionId: opts?.sessionId
1338
- });
1339
- };
1340
- const attachToSession = (session, opts) => {
1341
- const sessionId = session.id;
1342
- cleanups.get(sessionId)?.();
1343
- if (opts?.cols && opts?.rows && !session.isExited) session.resize(opts.cols, opts.rows);
1344
- const onData = (data) => {
1345
- send({
1346
- type: "output",
1347
- sessionId,
1348
- data
1349
- });
1350
- };
1351
- const onExit = (exitCode) => {
1352
- send({
1353
- type: "exit",
1354
- sessionId,
1355
- exitCode
1356
- });
1357
- };
1358
- const onTitle = (title) => {
1359
- send({
1360
- type: "title",
1361
- sessionId,
1362
- title
1363
- });
1364
- };
1365
- session.on("data", onData);
1366
- session.on("exit", onExit);
1367
- session.on("title", onTitle);
1368
- cleanups.set(sessionId, () => {
1369
- session.removeListener("data", onData);
1370
- session.removeListener("exit", onExit);
1371
- session.removeListener("title", onTitle);
1372
- cleanups.delete(sessionId);
1373
- });
1374
- };
1375
- ws.on("message", (raw) => {
1376
- let parsed;
1377
- try {
1378
- parsed = JSON.parse(String(raw));
1379
- } catch {
1380
- sendError("INVALID_JSON", "Invalid JSON payload");
1381
- return;
1382
- }
1383
- const parsedMessage = PtyClientMessageSchema.safeParse(parsed);
1384
- if (!parsedMessage.success) {
1385
- const firstIssue = parsedMessage.error.issues[0]?.message;
1386
- sendError("INVALID_MESSAGE", firstIssue ?? "Invalid PTY message");
1387
- return;
1388
- }
1389
- const msg = parsedMessage.data;
1390
- switch (msg.type) {
1391
- case "create":
1392
- try {
1393
- const session = ptyManager.create({
1394
- cols: msg.cols,
1395
- rows: msg.rows,
1396
- command: msg.command,
1397
- args: msg.args
1398
- });
1399
- send({
1400
- type: "created",
1401
- requestId: msg.requestId,
1402
- sessionId: session.id,
1403
- platform: session.platform
1404
- });
1405
- attachToSession(session);
1406
- } catch (err) {
1407
- sendError("PTY_CREATE_FAILED", err instanceof Error ? err.message : String(err), { sessionId: msg.requestId });
1408
- }
1409
- break;
1410
- case "attach": {
1411
- const session = ptyManager.get(msg.sessionId);
1412
- if (!session) {
1413
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1414
- send({
1415
- type: "exit",
1416
- sessionId: msg.sessionId,
1417
- exitCode: -1
1418
- });
1419
- break;
1420
- }
1421
- attachToSession(session, {
1422
- cols: msg.cols,
1423
- rows: msg.rows
1424
- });
1425
- const buffer = session.getBuffer();
1426
- if (buffer) send({
1427
- type: "buffer",
1428
- sessionId: session.id,
1429
- data: buffer
1430
- });
1431
- if (session.title) send({
1432
- type: "title",
1433
- sessionId: session.id,
1434
- title: session.title
1435
- });
1436
- if (session.isExited) send({
1437
- type: "exit",
1438
- sessionId: session.id,
1439
- exitCode: session.exitCode ?? -1
1440
- });
1441
- break;
1442
- }
1443
- case "list":
1444
- send({
1445
- type: "list",
1446
- sessions: ptyManager.list().map((s) => ({
1447
- id: s.id,
1448
- title: s.title,
1449
- command: s.command,
1450
- args: s.args,
1451
- platform: s.platform,
1452
- isExited: s.isExited,
1453
- exitCode: s.exitCode
1454
- }))
1455
- });
1456
- break;
1457
- case "input": {
1458
- const session = ptyManager.get(msg.sessionId);
1459
- if (!session) {
1460
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1461
- break;
1462
- }
1463
- session.write(msg.data);
1464
- break;
1465
- }
1466
- case "resize": {
1467
- const session = ptyManager.get(msg.sessionId);
1468
- if (!session) {
1469
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1470
- break;
1471
- }
1472
- session.resize(msg.cols, msg.rows);
1473
- break;
1474
- }
1475
- case "close": {
1476
- const session = ptyManager.get(msg.sessionId);
1477
- if (!session) {
1478
- sendError("SESSION_NOT_FOUND", `Session not found: ${msg.sessionId}`, { sessionId: msg.sessionId });
1479
- break;
1480
- }
1481
- cleanups.get(msg.sessionId)?.();
1482
- ptyManager.close(session.id);
1483
- break;
1484
- }
1485
- }
1486
- });
1487
- ws.on("close", () => {
1488
- for (const cleanup of cleanups.values()) cleanup();
1489
- cleanups.clear();
1490
- });
1491
- };
1492
- }
1493
-
1494
1641
  //#endregion
1495
1642
  //#region src/server.ts
1496
1643
  /**
@@ -1514,6 +1661,7 @@ function createServer(config) {
1514
1661
  const cliExecutor = new CliExecutor(configManager, config.projectDir);
1515
1662
  const kernel = config.kernel;
1516
1663
  const watcher = config.enableWatcher !== false ? new OpenSpecWatcher(config.projectDir) : void 0;
1664
+ const searchService = new SearchService(adapter, watcher);
1517
1665
  const app = new Hono();
1518
1666
  const corsOrigins = config.corsOrigins ?? ["http://localhost:5173", "http://localhost:3000"];
1519
1667
  app.use("*", cors({
@@ -1537,6 +1685,7 @@ function createServer(config) {
1537
1685
  configManager,
1538
1686
  cliExecutor,
1539
1687
  kernel,
1688
+ searchService,
1540
1689
  watcher,
1541
1690
  projectDir: config.projectDir
1542
1691
  })
@@ -1547,6 +1696,7 @@ function createServer(config) {
1547
1696
  configManager,
1548
1697
  cliExecutor,
1549
1698
  kernel,
1699
+ searchService,
1550
1700
  watcher,
1551
1701
  projectDir: config.projectDir
1552
1702
  });
@@ -1556,6 +1706,7 @@ function createServer(config) {
1556
1706
  configManager,
1557
1707
  cliExecutor,
1558
1708
  kernel,
1709
+ searchService,
1559
1710
  watcher,
1560
1711
  createContext,
1561
1712
  port: config.port ?? 3100
@@ -1597,6 +1748,7 @@ async function createWebSocketServer(server, httpServer, config) {
1597
1748
  ptyWss.close();
1598
1749
  wss.close();
1599
1750
  server.watcher?.stop();
1751
+ server.searchService.dispose().catch(() => {});
1600
1752
  }
1601
1753
  };
1602
1754
  }
@@ -1629,6 +1781,9 @@ async function startServer(config, setupApp) {
1629
1781
  kernel.warmup().catch((err) => {
1630
1782
  console.error("Kernel warmup failed:", err);
1631
1783
  });
1784
+ server.searchService.init().catch((err) => {
1785
+ console.error("Search service warmup failed:", err);
1786
+ });
1632
1787
  return {
1633
1788
  url,
1634
1789
  port,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openspecui/server",
3
- "version": "1.0.4",
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.4"
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
  }