reasonix 0.32.0 → 0.33.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.
- package/dist/cli/chat-EIFLHBZ6.js +39 -0
- package/dist/cli/chunk-2AWTGJ2C.js +110 -0
- package/dist/cli/chunk-2AWTGJ2C.js.map +1 -0
- package/dist/cli/chunk-3Q3C4W66.js +30 -0
- package/dist/cli/chunk-3Q3C4W66.js.map +1 -0
- package/dist/cli/chunk-4DCHFFEY.js +149 -0
- package/dist/cli/chunk-4DCHFFEY.js.map +1 -0
- package/dist/cli/chunk-5X7LZJDE.js +36 -0
- package/dist/cli/chunk-5X7LZJDE.js.map +1 -0
- package/dist/cli/chunk-6TMHAK5D.js +576 -0
- package/dist/cli/chunk-6TMHAK5D.js.map +1 -0
- package/dist/cli/chunk-APPB3ZPQ.js +43 -0
- package/dist/cli/chunk-APPB3ZPQ.js.map +1 -0
- package/dist/cli/chunk-BQNUJJN7.js +42 -0
- package/dist/cli/chunk-BQNUJJN7.js.map +1 -0
- package/dist/cli/chunk-CPOV2O73.js +39 -0
- package/dist/cli/chunk-CPOV2O73.js.map +1 -0
- package/dist/cli/chunk-D5DKXIP5.js +368 -0
- package/dist/cli/chunk-D5DKXIP5.js.map +1 -0
- package/dist/cli/chunk-DFP4YSVM.js +247 -0
- package/dist/cli/chunk-DFP4YSVM.js.map +1 -0
- package/dist/cli/chunk-DULSP7JH.js +410 -0
- package/dist/cli/chunk-DULSP7JH.js.map +1 -0
- package/dist/cli/chunk-FM57FNPJ.js +46 -0
- package/dist/cli/chunk-FM57FNPJ.js.map +1 -0
- package/dist/cli/chunk-FWGEHRB7.js +54 -0
- package/dist/cli/chunk-FWGEHRB7.js.map +1 -0
- package/dist/cli/chunk-FXGQ5NHE.js +513 -0
- package/dist/cli/chunk-FXGQ5NHE.js.map +1 -0
- package/dist/cli/chunk-G3XNWSFN.js +53 -0
- package/dist/cli/chunk-G3XNWSFN.js.map +1 -0
- package/dist/cli/chunk-I6YIAK6C.js +757 -0
- package/dist/cli/chunk-I6YIAK6C.js.map +1 -0
- package/dist/cli/chunk-J5VLP23S.js +94 -0
- package/dist/cli/chunk-J5VLP23S.js.map +1 -0
- package/dist/cli/chunk-KMWKGPFZ.js +303 -0
- package/dist/cli/chunk-KMWKGPFZ.js.map +1 -0
- package/dist/cli/chunk-LVQX5KGF.js +14934 -0
- package/dist/cli/chunk-LVQX5KGF.js.map +1 -0
- package/dist/cli/chunk-MHDNZXJJ.js +48 -0
- package/dist/cli/chunk-MHDNZXJJ.js.map +1 -0
- package/dist/cli/chunk-ORM6PK57.js +140 -0
- package/dist/cli/chunk-ORM6PK57.js.map +1 -0
- package/dist/cli/chunk-Q5GRLZJF.js +99 -0
- package/dist/cli/chunk-Q5GRLZJF.js.map +1 -0
- package/dist/cli/chunk-Q6YFXW7H.js +4986 -0
- package/dist/cli/chunk-Q6YFXW7H.js.map +1 -0
- package/dist/cli/chunk-QGE6AF76.js +1467 -0
- package/dist/cli/chunk-QGE6AF76.js.map +1 -0
- package/dist/cli/chunk-RFX7TYVV.js +28 -0
- package/dist/cli/chunk-RFX7TYVV.js.map +1 -0
- package/dist/cli/chunk-RZILUXUC.js +940 -0
- package/dist/cli/chunk-RZILUXUC.js.map +1 -0
- package/dist/cli/chunk-SDE5U32Z.js +535 -0
- package/dist/cli/chunk-SDE5U32Z.js.map +1 -0
- package/dist/cli/chunk-SOZE7V7V.js +340 -0
- package/dist/cli/chunk-SOZE7V7V.js.map +1 -0
- package/dist/cli/chunk-U3V2ZQ5J.js +479 -0
- package/dist/cli/chunk-U3V2ZQ5J.js.map +1 -0
- package/dist/cli/chunk-W4LDFAZ6.js +1544 -0
- package/dist/cli/chunk-W4LDFAZ6.js.map +1 -0
- package/dist/cli/chunk-WBDE4IRI.js +208 -0
- package/dist/cli/chunk-WBDE4IRI.js.map +1 -0
- package/dist/cli/chunk-XHQIK7B6.js +189 -0
- package/dist/cli/chunk-XHQIK7B6.js.map +1 -0
- package/dist/cli/chunk-XJLZ4HKU.js +307 -0
- package/dist/cli/chunk-XJLZ4HKU.js.map +1 -0
- package/dist/cli/chunk-ZPTSJGX5.js +88 -0
- package/dist/cli/chunk-ZPTSJGX5.js.map +1 -0
- package/dist/cli/chunk-ZTLZO42A.js +231 -0
- package/dist/cli/chunk-ZTLZO42A.js.map +1 -0
- package/dist/cli/code-F4KJOE3K.js +151 -0
- package/dist/cli/code-F4KJOE3K.js.map +1 -0
- package/dist/cli/commands-JWT2MWVH.js +352 -0
- package/dist/cli/commands-JWT2MWVH.js.map +1 -0
- package/dist/cli/commit-RPZBOZS2.js +288 -0
- package/dist/cli/commit-RPZBOZS2.js.map +1 -0
- package/dist/cli/diff-NTEHCSDW.js +145 -0
- package/dist/cli/diff-NTEHCSDW.js.map +1 -0
- package/dist/cli/doctor-3TGB2NZN.js +19 -0
- package/dist/cli/doctor-3TGB2NZN.js.map +1 -0
- package/dist/cli/events-P27CX7LN.js +338 -0
- package/dist/cli/events-P27CX7LN.js.map +1 -0
- package/dist/cli/index.js +80 -33693
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/mcp-ARTNQ24O.js +266 -0
- package/dist/cli/mcp-ARTNQ24O.js.map +1 -0
- package/dist/cli/mcp-browse-HLO2ENDL.js +163 -0
- package/dist/cli/mcp-browse-HLO2ENDL.js.map +1 -0
- package/dist/cli/mcp-inspect-T2HBR22P.js +103 -0
- package/dist/cli/mcp-inspect-T2HBR22P.js.map +1 -0
- package/dist/cli/{prompt-XHICFAYN.js → prompt-V47QKSAR.js} +3 -2
- package/dist/cli/prompt-V47QKSAR.js.map +1 -0
- package/dist/cli/prune-sessions-ERL6B4G5.js +42 -0
- package/dist/cli/prune-sessions-ERL6B4G5.js.map +1 -0
- package/dist/cli/replay-TMJASRC4.js +273 -0
- package/dist/cli/replay-TMJASRC4.js.map +1 -0
- package/dist/cli/run-JMEOTQCG.js +215 -0
- package/dist/cli/run-JMEOTQCG.js.map +1 -0
- package/dist/cli/server-SYC3OVOP.js +2967 -0
- package/dist/cli/server-SYC3OVOP.js.map +1 -0
- package/dist/cli/sessions-MOJAALJI.js +102 -0
- package/dist/cli/sessions-MOJAALJI.js.map +1 -0
- package/dist/cli/setup-CCJZAWTY.js +404 -0
- package/dist/cli/setup-CCJZAWTY.js.map +1 -0
- package/dist/cli/stats-5RJCATCE.js +12 -0
- package/dist/cli/stats-5RJCATCE.js.map +1 -0
- package/dist/cli/update-4TJWRUIN.js +90 -0
- package/dist/cli/update-4TJWRUIN.js.map +1 -0
- package/dist/cli/version-3MYFE4G6.js +29 -0
- package/dist/cli/version-3MYFE4G6.js.map +1 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.js +493 -89
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/dist/cli/chunk-VWFJNLIK.js +0 -1031
- package/dist/cli/chunk-VWFJNLIK.js.map +0 -1
- /package/dist/cli/{prompt-XHICFAYN.js.map → chat-EIFLHBZ6.js.map} +0 -0
|
@@ -0,0 +1,2967 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
readEventLogFile,
|
|
4
|
+
recentEventFiles
|
|
5
|
+
} from "./chunk-FWGEHRB7.js";
|
|
6
|
+
import {
|
|
7
|
+
registerSemanticSearchTool
|
|
8
|
+
} from "./chunk-J5VLP23S.js";
|
|
9
|
+
import {
|
|
10
|
+
SLASH_COMMANDS,
|
|
11
|
+
listPlanArchives
|
|
12
|
+
} from "./chunk-FXGQ5NHE.js";
|
|
13
|
+
import "./chunk-G3XNWSFN.js";
|
|
14
|
+
import {
|
|
15
|
+
fetchSmitheryDetail,
|
|
16
|
+
handleToFetchResult,
|
|
17
|
+
loadMorePages,
|
|
18
|
+
openRegistry,
|
|
19
|
+
specStringFor
|
|
20
|
+
} from "./chunk-SOZE7V7V.js";
|
|
21
|
+
import {
|
|
22
|
+
BUILTIN_ALLOWLIST
|
|
23
|
+
} from "./chunk-W4LDFAZ6.js";
|
|
24
|
+
import {
|
|
25
|
+
PROJECT_MEMORY_FILE,
|
|
26
|
+
SKILLS_DIRNAME,
|
|
27
|
+
SKILL_FILE
|
|
28
|
+
} from "./chunk-U3V2ZQ5J.js";
|
|
29
|
+
import "./chunk-FM57FNPJ.js";
|
|
30
|
+
import {
|
|
31
|
+
INDEX_DIR_NAME,
|
|
32
|
+
buildIndex,
|
|
33
|
+
checkOllamaStatus,
|
|
34
|
+
compareIndexIdentity,
|
|
35
|
+
indexExists,
|
|
36
|
+
pullOllamaModel,
|
|
37
|
+
querySemantic,
|
|
38
|
+
readIndexMeta,
|
|
39
|
+
startOllamaDaemon,
|
|
40
|
+
walkChunks
|
|
41
|
+
} from "./chunk-RZILUXUC.js";
|
|
42
|
+
import {
|
|
43
|
+
HOOK_EVENTS,
|
|
44
|
+
globalSettingsPath,
|
|
45
|
+
loadHooks,
|
|
46
|
+
projectSettingsPath
|
|
47
|
+
} from "./chunk-WBDE4IRI.js";
|
|
48
|
+
import {
|
|
49
|
+
VERSION
|
|
50
|
+
} from "./chunk-2AWTGJ2C.js";
|
|
51
|
+
import "./chunk-5X7LZJDE.js";
|
|
52
|
+
import {
|
|
53
|
+
listSessions,
|
|
54
|
+
sessionPath,
|
|
55
|
+
sessionsDir
|
|
56
|
+
} from "./chunk-DFP4YSVM.js";
|
|
57
|
+
import {
|
|
58
|
+
getLanguage,
|
|
59
|
+
getSupportedLanguages,
|
|
60
|
+
setLanguage
|
|
61
|
+
} from "./chunk-QGE6AF76.js";
|
|
62
|
+
import {
|
|
63
|
+
DEFAULT_INDEX_EXCLUDES,
|
|
64
|
+
DEFAULT_MAX_FILE_BYTES,
|
|
65
|
+
DEFAULT_RESPECT_GITIGNORE,
|
|
66
|
+
addProjectShellAllowed,
|
|
67
|
+
clearProjectShellAllowed,
|
|
68
|
+
isPlausibleKey,
|
|
69
|
+
loadIndexConfig,
|
|
70
|
+
loadIndexUserConfig,
|
|
71
|
+
loadProjectShellAllowed,
|
|
72
|
+
loadSemanticEmbeddingUserConfig,
|
|
73
|
+
readConfig,
|
|
74
|
+
redactKey,
|
|
75
|
+
redactSemanticEmbeddingConfig,
|
|
76
|
+
removeProjectShellAllowed,
|
|
77
|
+
resolveIndexConfig,
|
|
78
|
+
resolveSemanticEmbeddingConfig,
|
|
79
|
+
saveSemanticEmbeddingConfig,
|
|
80
|
+
writeConfig
|
|
81
|
+
} from "./chunk-DULSP7JH.js";
|
|
82
|
+
import {
|
|
83
|
+
aggregateUsage,
|
|
84
|
+
bucketCacheHitRatio,
|
|
85
|
+
formatLogSize,
|
|
86
|
+
readUsageLog
|
|
87
|
+
} from "./chunk-ZTLZO42A.js";
|
|
88
|
+
import {
|
|
89
|
+
DEEPSEEK_PRICING,
|
|
90
|
+
cacheSavingsUsd
|
|
91
|
+
} from "./chunk-ORM6PK57.js";
|
|
92
|
+
|
|
93
|
+
// src/server/index.ts
|
|
94
|
+
import { randomBytes } from "crypto";
|
|
95
|
+
import { createServer } from "http";
|
|
96
|
+
|
|
97
|
+
// src/server/api/events.ts
|
|
98
|
+
var PING_INTERVAL_MS = 25e3;
|
|
99
|
+
function handleEvents(req, res, ctx) {
|
|
100
|
+
if (!ctx.subscribeEvents) {
|
|
101
|
+
res.writeHead(503, { "content-type": "application/json" });
|
|
102
|
+
res.end(JSON.stringify({ error: "event stream requires an attached dashboard session." }));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
res.writeHead(200, {
|
|
106
|
+
"content-type": "text/event-stream",
|
|
107
|
+
"cache-control": "no-cache",
|
|
108
|
+
connection: "keep-alive",
|
|
109
|
+
"x-accel-buffering": "no"
|
|
110
|
+
// disable Nginx-style buffering if anything proxies us
|
|
111
|
+
});
|
|
112
|
+
const writeEvent = (event) => {
|
|
113
|
+
if (res.writableEnded) return;
|
|
114
|
+
try {
|
|
115
|
+
res.write(`data: ${JSON.stringify(event)}
|
|
116
|
+
|
|
117
|
+
`);
|
|
118
|
+
} catch {
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
if (ctx.isBusy) writeEvent({ kind: "busy-change", busy: ctx.isBusy() });
|
|
122
|
+
const unsubscribe = ctx.subscribeEvents(writeEvent);
|
|
123
|
+
const ping = setInterval(() => writeEvent({ kind: "ping" }), PING_INTERVAL_MS);
|
|
124
|
+
ping.unref?.();
|
|
125
|
+
const cleanup = () => {
|
|
126
|
+
clearInterval(ping);
|
|
127
|
+
try {
|
|
128
|
+
unsubscribe();
|
|
129
|
+
} catch {
|
|
130
|
+
}
|
|
131
|
+
if (!res.writableEnded) {
|
|
132
|
+
try {
|
|
133
|
+
res.end();
|
|
134
|
+
} catch {
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
req.on("close", cleanup);
|
|
139
|
+
req.on("error", cleanup);
|
|
140
|
+
res.on("close", cleanup);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/server/assets.ts
|
|
144
|
+
import { closeSync, fstatSync, openSync, readFileSync, readSync } from "fs";
|
|
145
|
+
import { dirname, join } from "path";
|
|
146
|
+
import { fileURLToPath } from "url";
|
|
147
|
+
function resolveAssetDir() {
|
|
148
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
149
|
+
const candidates = [
|
|
150
|
+
join(here, "..", "..", "dashboard"),
|
|
151
|
+
join(here, "..", "dashboard"),
|
|
152
|
+
join(here, "dashboard")
|
|
153
|
+
];
|
|
154
|
+
for (const c of candidates) {
|
|
155
|
+
try {
|
|
156
|
+
readFileSync(join(c, "index.html"), "utf8");
|
|
157
|
+
return c;
|
|
158
|
+
} catch {
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return candidates[0];
|
|
162
|
+
}
|
|
163
|
+
var ASSET_DIR = resolveAssetDir();
|
|
164
|
+
var fileCache = /* @__PURE__ */ new Map();
|
|
165
|
+
function loadCachedFile(path) {
|
|
166
|
+
const fd = openSync(path, "r");
|
|
167
|
+
try {
|
|
168
|
+
const stat = fstatSync(fd);
|
|
169
|
+
const cached = fileCache.get(path);
|
|
170
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) return cached.body;
|
|
171
|
+
const buf = Buffer.alloc(stat.size);
|
|
172
|
+
let read = 0;
|
|
173
|
+
while (read < stat.size) {
|
|
174
|
+
const n = readSync(fd, buf, read, stat.size - read, read);
|
|
175
|
+
if (n <= 0) break;
|
|
176
|
+
read += n;
|
|
177
|
+
}
|
|
178
|
+
const body = buf.toString("utf8", 0, read);
|
|
179
|
+
fileCache.set(path, { body, mtimeMs: stat.mtimeMs });
|
|
180
|
+
return body;
|
|
181
|
+
} finally {
|
|
182
|
+
closeSync(fd);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function loadIndexTemplate() {
|
|
186
|
+
return loadCachedFile(join(ASSET_DIR, "index.html"));
|
|
187
|
+
}
|
|
188
|
+
function loadApp() {
|
|
189
|
+
return loadCachedFile(join(ASSET_DIR, "dist", "app.js"));
|
|
190
|
+
}
|
|
191
|
+
function loadAppMap() {
|
|
192
|
+
try {
|
|
193
|
+
return loadCachedFile(join(ASSET_DIR, "dist", "app.js.map"));
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function loadCss() {
|
|
199
|
+
return loadCachedFile(join(ASSET_DIR, "app.css"));
|
|
200
|
+
}
|
|
201
|
+
function renderIndexHtml(token, mode) {
|
|
202
|
+
const tpl = loadIndexTemplate();
|
|
203
|
+
const safeToken = token.replace(/[^a-zA-Z0-9]/g, "");
|
|
204
|
+
return tpl.replaceAll("__REASONIX_TOKEN__", safeToken).replaceAll("__REASONIX_MODE__", mode);
|
|
205
|
+
}
|
|
206
|
+
var VENDOR_CSS_NAMES = /* @__PURE__ */ new Set(["vendor-hljs.css", "vendor-uplot.css"]);
|
|
207
|
+
function loadVendorCss(name) {
|
|
208
|
+
return loadCachedFile(join(ASSET_DIR, "dist", name));
|
|
209
|
+
}
|
|
210
|
+
function serveAsset(name) {
|
|
211
|
+
if (name === "app.js") {
|
|
212
|
+
return { body: loadApp(), contentType: "application/javascript; charset=utf-8" };
|
|
213
|
+
}
|
|
214
|
+
if (name === "app.js.map") {
|
|
215
|
+
const body = loadAppMap();
|
|
216
|
+
return body == null ? null : { body, contentType: "application/json; charset=utf-8" };
|
|
217
|
+
}
|
|
218
|
+
if (name === "app.css") {
|
|
219
|
+
return { body: loadCss(), contentType: "text/css; charset=utf-8" };
|
|
220
|
+
}
|
|
221
|
+
if (VENDOR_CSS_NAMES.has(name)) {
|
|
222
|
+
return { body: loadVendorCss(name), contentType: "text/css; charset=utf-8" };
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/server/api/abort.ts
|
|
228
|
+
async function handleAbort(method, _rest, _body, ctx) {
|
|
229
|
+
if (method !== "POST") {
|
|
230
|
+
return { status: 405, body: { error: "POST only" } };
|
|
231
|
+
}
|
|
232
|
+
if (!ctx.abortTurn) {
|
|
233
|
+
return {
|
|
234
|
+
status: 503,
|
|
235
|
+
body: { error: "abort requires an attached dashboard session." }
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
ctx.abortTurn();
|
|
239
|
+
ctx.audit?.({ ts: Date.now(), action: "abort-turn" });
|
|
240
|
+
return { status: 202, body: { aborted: true } };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/server/api/edit-mode.ts
|
|
244
|
+
function parseBody(raw) {
|
|
245
|
+
if (!raw) return {};
|
|
246
|
+
try {
|
|
247
|
+
const parsed = JSON.parse(raw);
|
|
248
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
249
|
+
} catch {
|
|
250
|
+
return {};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
var VALID = /* @__PURE__ */ new Set(["review", "auto", "yolo"]);
|
|
254
|
+
async function handleEditMode(method, _rest, body, ctx) {
|
|
255
|
+
if (method === "GET") {
|
|
256
|
+
return {
|
|
257
|
+
status: 200,
|
|
258
|
+
body: { mode: ctx.getEditMode?.() ?? null }
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (method === "POST") {
|
|
262
|
+
if (!ctx.setEditMode) {
|
|
263
|
+
return {
|
|
264
|
+
status: 503,
|
|
265
|
+
body: { error: "edit-mode mutation requires an attached `reasonix code` session." }
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const { mode } = parseBody(body);
|
|
269
|
+
if (typeof mode !== "string" || !VALID.has(mode)) {
|
|
270
|
+
return { status: 400, body: { error: "mode must be review | auto | yolo" } };
|
|
271
|
+
}
|
|
272
|
+
const resolved = ctx.setEditMode(mode);
|
|
273
|
+
ctx.audit?.({ ts: Date.now(), action: "set-edit-mode", payload: { mode: resolved } });
|
|
274
|
+
return { status: 200, body: { mode: resolved } };
|
|
275
|
+
}
|
|
276
|
+
return { status: 405, body: { error: "GET or POST only" } };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/server/api/files.ts
|
|
280
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
281
|
+
import { extname, join as join2, relative, sep } from "path";
|
|
282
|
+
var RESULT_CAP = 50;
|
|
283
|
+
var MAX_DEPTH = 4;
|
|
284
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
285
|
+
"node_modules",
|
|
286
|
+
".git",
|
|
287
|
+
".reasonix",
|
|
288
|
+
"dist",
|
|
289
|
+
"build",
|
|
290
|
+
"out",
|
|
291
|
+
".next",
|
|
292
|
+
"coverage",
|
|
293
|
+
".cache",
|
|
294
|
+
"__pycache__",
|
|
295
|
+
".venv",
|
|
296
|
+
".pytest_cache"
|
|
297
|
+
]);
|
|
298
|
+
var SKIP_EXTS = /* @__PURE__ */ new Set([
|
|
299
|
+
".png",
|
|
300
|
+
".jpg",
|
|
301
|
+
".jpeg",
|
|
302
|
+
".gif",
|
|
303
|
+
".webp",
|
|
304
|
+
".ico",
|
|
305
|
+
".pdf",
|
|
306
|
+
".zip",
|
|
307
|
+
".tar",
|
|
308
|
+
".gz",
|
|
309
|
+
".lock",
|
|
310
|
+
".woff",
|
|
311
|
+
".woff2",
|
|
312
|
+
".ttf"
|
|
313
|
+
]);
|
|
314
|
+
async function handleFiles(method, _rest, body, ctx) {
|
|
315
|
+
if (method !== "POST") return { status: 405, body: { error: "POST only" } };
|
|
316
|
+
const cwd = ctx.getCurrentCwd?.();
|
|
317
|
+
if (!cwd || !existsSync(cwd)) {
|
|
318
|
+
return { status: 503, body: { error: "@-mention picker requires a code-mode session" } };
|
|
319
|
+
}
|
|
320
|
+
let parsed;
|
|
321
|
+
try {
|
|
322
|
+
parsed = JSON.parse(body || "{}");
|
|
323
|
+
} catch {
|
|
324
|
+
return { status: 400, body: { error: "body must be JSON" } };
|
|
325
|
+
}
|
|
326
|
+
const prefix = typeof parsed.prefix === "string" ? parsed.prefix.trim().toLowerCase() : "";
|
|
327
|
+
const matches = walk(cwd, prefix);
|
|
328
|
+
return { status: 200, body: { files: matches } };
|
|
329
|
+
}
|
|
330
|
+
function walk(root, prefix) {
|
|
331
|
+
const out = [];
|
|
332
|
+
const stack = [{ path: root, depth: 0 }];
|
|
333
|
+
while (stack.length > 0 && out.length < RESULT_CAP) {
|
|
334
|
+
const { path, depth } = stack.pop();
|
|
335
|
+
if (depth > MAX_DEPTH) continue;
|
|
336
|
+
let names;
|
|
337
|
+
try {
|
|
338
|
+
names = readdirSync(path);
|
|
339
|
+
} catch {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
for (const name of names) {
|
|
343
|
+
if (out.length >= RESULT_CAP) break;
|
|
344
|
+
if (name.startsWith(".") && depth === 0) continue;
|
|
345
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
346
|
+
const full = join2(path, name);
|
|
347
|
+
let st;
|
|
348
|
+
try {
|
|
349
|
+
st = statSync(full);
|
|
350
|
+
} catch {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (st.isDirectory()) {
|
|
354
|
+
stack.push({ path: full, depth: depth + 1 });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (!st.isFile()) continue;
|
|
358
|
+
if (SKIP_EXTS.has(extname(name).toLowerCase())) continue;
|
|
359
|
+
const rel = relative(root, full).split(sep).join("/");
|
|
360
|
+
if (prefix && !rel.toLowerCase().includes(prefix)) continue;
|
|
361
|
+
out.push(rel);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return out.sort((a, b) => a.localeCompare(b));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/server/api/health.ts
|
|
368
|
+
import { existsSync as existsSync2, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
369
|
+
import { homedir } from "os";
|
|
370
|
+
import { join as join3 } from "path";
|
|
371
|
+
function dirSize(path) {
|
|
372
|
+
if (!existsSync2(path)) return { path, exists: false, fileCount: 0, totalBytes: 0 };
|
|
373
|
+
let fileCount = 0;
|
|
374
|
+
let totalBytes = 0;
|
|
375
|
+
try {
|
|
376
|
+
const entries = readdirSync2(path);
|
|
377
|
+
for (const name of entries) {
|
|
378
|
+
const full = join3(path, name);
|
|
379
|
+
try {
|
|
380
|
+
const s = statSync2(full);
|
|
381
|
+
if (s.isFile()) {
|
|
382
|
+
fileCount++;
|
|
383
|
+
totalBytes += s.size;
|
|
384
|
+
} else if (s.isDirectory()) {
|
|
385
|
+
try {
|
|
386
|
+
const inner = readdirSync2(full);
|
|
387
|
+
for (const child of inner) {
|
|
388
|
+
try {
|
|
389
|
+
const cs = statSync2(join3(full, child));
|
|
390
|
+
if (cs.isFile()) {
|
|
391
|
+
fileCount++;
|
|
392
|
+
totalBytes += cs.size;
|
|
393
|
+
}
|
|
394
|
+
} catch {
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch {
|
|
404
|
+
return { path, exists: true, fileCount: 0, totalBytes: 0 };
|
|
405
|
+
}
|
|
406
|
+
return { path, exists: true, fileCount, totalBytes };
|
|
407
|
+
}
|
|
408
|
+
async function handleHealth(method, _rest, _body, ctx) {
|
|
409
|
+
if (method !== "GET") {
|
|
410
|
+
return { status: 405, body: { error: "GET only" } };
|
|
411
|
+
}
|
|
412
|
+
const home = homedir();
|
|
413
|
+
const reasonixHome = join3(home, ".reasonix");
|
|
414
|
+
const sessionsStat = dirSize(join3(reasonixHome, "sessions"));
|
|
415
|
+
const memoryStat = dirSize(join3(reasonixHome, "memory"));
|
|
416
|
+
const semanticStat = dirSize(join3(reasonixHome, "semantic"));
|
|
417
|
+
let usageBytes = 0;
|
|
418
|
+
if (existsSync2(ctx.usageLogPath)) {
|
|
419
|
+
try {
|
|
420
|
+
usageBytes = statSync2(ctx.usageLogPath).size;
|
|
421
|
+
} catch {
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const sessions = listSessions();
|
|
425
|
+
return {
|
|
426
|
+
status: 200,
|
|
427
|
+
body: {
|
|
428
|
+
version: VERSION,
|
|
429
|
+
latestVersion: ctx.getLatestVersion?.() ?? null,
|
|
430
|
+
reasonixHome,
|
|
431
|
+
sessions: {
|
|
432
|
+
path: sessionsStat.path,
|
|
433
|
+
count: sessions.length,
|
|
434
|
+
totalBytes: sessionsStat.totalBytes
|
|
435
|
+
},
|
|
436
|
+
memory: {
|
|
437
|
+
path: memoryStat.path,
|
|
438
|
+
fileCount: memoryStat.fileCount,
|
|
439
|
+
totalBytes: memoryStat.totalBytes
|
|
440
|
+
},
|
|
441
|
+
semantic: {
|
|
442
|
+
path: semanticStat.path,
|
|
443
|
+
exists: semanticStat.exists,
|
|
444
|
+
fileCount: semanticStat.fileCount,
|
|
445
|
+
totalBytes: semanticStat.totalBytes
|
|
446
|
+
},
|
|
447
|
+
usageLog: {
|
|
448
|
+
path: ctx.usageLogPath,
|
|
449
|
+
bytes: usageBytes
|
|
450
|
+
},
|
|
451
|
+
jobs: ctx.jobs ? ctx.jobs.list().length : null
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// src/server/api/hooks.ts
|
|
457
|
+
import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
458
|
+
import { dirname as dirname2 } from "path";
|
|
459
|
+
|
|
460
|
+
// src/server/api/hooks-events.ts
|
|
461
|
+
import { existsSync as existsSync3 } from "fs";
|
|
462
|
+
var HOOK_LOG_CAP = 12;
|
|
463
|
+
function readRecentHookRuns(now = Date.now(), sessionsDirOverride) {
|
|
464
|
+
const dir = sessionsDirOverride ?? sessionsDir();
|
|
465
|
+
if (!existsSync3(dir)) return null;
|
|
466
|
+
const files = recentEventFiles(dir, now);
|
|
467
|
+
if (files.length === 0) return null;
|
|
468
|
+
const rows = [];
|
|
469
|
+
for (const file of files) {
|
|
470
|
+
const events = readEventLogFile(file);
|
|
471
|
+
for (const ev of events) {
|
|
472
|
+
if (ev.type !== "hook.fired") continue;
|
|
473
|
+
const ts = Date.parse(ev.ts);
|
|
474
|
+
if (!Number.isFinite(ts)) continue;
|
|
475
|
+
rows.push({
|
|
476
|
+
hookName: ev.hookName,
|
|
477
|
+
phase: ev.phase,
|
|
478
|
+
outcome: ev.outcome,
|
|
479
|
+
whenMs: ts
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
rows.sort((a, b) => b.whenMs - a.whenMs);
|
|
484
|
+
return rows.slice(0, HOOK_LOG_CAP);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/server/api/hooks.ts
|
|
488
|
+
function parseBody2(raw) {
|
|
489
|
+
if (!raw) return {};
|
|
490
|
+
try {
|
|
491
|
+
const parsed = JSON.parse(raw);
|
|
492
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
493
|
+
} catch {
|
|
494
|
+
return {};
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function readSettingsFile(path) {
|
|
498
|
+
if (!existsSync4(path)) return {};
|
|
499
|
+
try {
|
|
500
|
+
const raw = readFileSync2(path, "utf8");
|
|
501
|
+
const parsed = JSON.parse(raw);
|
|
502
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
503
|
+
} catch {
|
|
504
|
+
return {};
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function writeSettingsFile(path, hooksBlock) {
|
|
508
|
+
const existing = readSettingsFile(path);
|
|
509
|
+
existing.hooks = hooksBlock;
|
|
510
|
+
mkdirSync(dirname2(path), { recursive: true });
|
|
511
|
+
writeFileSync(path, `${JSON.stringify(existing, null, 2)}
|
|
512
|
+
`, "utf8");
|
|
513
|
+
}
|
|
514
|
+
async function handleHooks(method, rest, body, ctx) {
|
|
515
|
+
if (method === "GET" && rest.length === 0) {
|
|
516
|
+
const projectPath = ctx.getCurrentCwd ? projectSettingsPath(ctx.getCurrentCwd() ?? "") : null;
|
|
517
|
+
const globalPath = globalSettingsPath();
|
|
518
|
+
const projectFile = projectPath ? readSettingsFile(projectPath) : {};
|
|
519
|
+
const globalFile = readSettingsFile(globalPath);
|
|
520
|
+
const resolved = loadHooks({ projectRoot: ctx.getCurrentCwd?.() });
|
|
521
|
+
return {
|
|
522
|
+
status: 200,
|
|
523
|
+
body: {
|
|
524
|
+
project: {
|
|
525
|
+
path: projectPath,
|
|
526
|
+
hooks: projectFile.hooks ?? {}
|
|
527
|
+
},
|
|
528
|
+
global: {
|
|
529
|
+
path: globalPath,
|
|
530
|
+
hooks: globalFile.hooks ?? {}
|
|
531
|
+
},
|
|
532
|
+
resolved,
|
|
533
|
+
events: HOOK_EVENTS,
|
|
534
|
+
recentRuns: readRecentHookRuns(void 0, ctx.sessionsDir)
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (method === "POST" && rest[0] === "save") {
|
|
539
|
+
const { scope, hooks } = parseBody2(body);
|
|
540
|
+
if (scope !== "project" && scope !== "global") {
|
|
541
|
+
return { status: 400, body: { error: "scope must be project | global" } };
|
|
542
|
+
}
|
|
543
|
+
if (typeof hooks !== "object" || hooks === null) {
|
|
544
|
+
return { status: 400, body: { error: "hooks must be an object keyed by event name" } };
|
|
545
|
+
}
|
|
546
|
+
let path;
|
|
547
|
+
if (scope === "project") {
|
|
548
|
+
const cwd = ctx.getCurrentCwd?.();
|
|
549
|
+
if (!cwd) {
|
|
550
|
+
return {
|
|
551
|
+
status: 503,
|
|
552
|
+
body: { error: "no active project \u2014 open `/dashboard` from inside `reasonix code`" }
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
path = projectSettingsPath(cwd);
|
|
556
|
+
} else {
|
|
557
|
+
path = globalSettingsPath();
|
|
558
|
+
}
|
|
559
|
+
if (!path) {
|
|
560
|
+
return { status: 500, body: { error: "could not resolve settings path" } };
|
|
561
|
+
}
|
|
562
|
+
writeSettingsFile(path, hooks);
|
|
563
|
+
ctx.audit?.({ ts: Date.now(), action: "save-hooks", payload: { scope, path } });
|
|
564
|
+
return { status: 200, body: { saved: true, path } };
|
|
565
|
+
}
|
|
566
|
+
if (method === "POST" && rest[0] === "reload") {
|
|
567
|
+
if (!ctx.reloadHooks) {
|
|
568
|
+
return {
|
|
569
|
+
status: 503,
|
|
570
|
+
body: { error: "reload requires an attached session \u2014 App.tsx wires the callback" }
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
const count = ctx.reloadHooks();
|
|
574
|
+
ctx.audit?.({ ts: Date.now(), action: "reload-hooks", payload: { count } });
|
|
575
|
+
return { status: 200, body: { reloaded: true, count } };
|
|
576
|
+
}
|
|
577
|
+
return { status: 405, body: { error: `method ${method} not supported on this path` } };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// src/server/api/index-config.ts
|
|
581
|
+
var PREVIEW_INCLUDED_CAP = 50;
|
|
582
|
+
var PREVIEW_PER_REASON_CAP = 10;
|
|
583
|
+
function parseBody3(raw) {
|
|
584
|
+
if (!raw) return {};
|
|
585
|
+
try {
|
|
586
|
+
const parsed = JSON.parse(raw);
|
|
587
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
588
|
+
} catch {
|
|
589
|
+
return {};
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function isStringArray(v) {
|
|
593
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
594
|
+
}
|
|
595
|
+
async function handleIndexConfig(method, rest, body, ctx) {
|
|
596
|
+
if (rest[0] === "preview" && method === "POST") {
|
|
597
|
+
return await handlePreview(body, ctx);
|
|
598
|
+
}
|
|
599
|
+
if (method === "GET") {
|
|
600
|
+
const user = loadIndexUserConfig(ctx.configPath);
|
|
601
|
+
const resolved = resolveIndexConfig(user);
|
|
602
|
+
return {
|
|
603
|
+
status: 200,
|
|
604
|
+
body: {
|
|
605
|
+
user,
|
|
606
|
+
resolved,
|
|
607
|
+
defaults: {
|
|
608
|
+
excludeDirs: [...DEFAULT_INDEX_EXCLUDES.dirs],
|
|
609
|
+
excludeFiles: [...DEFAULT_INDEX_EXCLUDES.files],
|
|
610
|
+
excludeExts: [...DEFAULT_INDEX_EXCLUDES.exts],
|
|
611
|
+
excludePatterns: [],
|
|
612
|
+
respectGitignore: DEFAULT_RESPECT_GITIGNORE,
|
|
613
|
+
maxFileBytes: DEFAULT_MAX_FILE_BYTES
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
if (method === "POST") {
|
|
619
|
+
const fields = parseBody3(body);
|
|
620
|
+
const next = {};
|
|
621
|
+
const changed = [];
|
|
622
|
+
if (fields.excludeDirs !== void 0) {
|
|
623
|
+
if (!isStringArray(fields.excludeDirs)) {
|
|
624
|
+
return { status: 400, body: { error: "excludeDirs must be string[]" } };
|
|
625
|
+
}
|
|
626
|
+
next.excludeDirs = fields.excludeDirs;
|
|
627
|
+
changed.push("excludeDirs");
|
|
628
|
+
}
|
|
629
|
+
if (fields.excludeFiles !== void 0) {
|
|
630
|
+
if (!isStringArray(fields.excludeFiles)) {
|
|
631
|
+
return { status: 400, body: { error: "excludeFiles must be string[]" } };
|
|
632
|
+
}
|
|
633
|
+
next.excludeFiles = fields.excludeFiles;
|
|
634
|
+
changed.push("excludeFiles");
|
|
635
|
+
}
|
|
636
|
+
if (fields.excludeExts !== void 0) {
|
|
637
|
+
if (!isStringArray(fields.excludeExts)) {
|
|
638
|
+
return { status: 400, body: { error: "excludeExts must be string[]" } };
|
|
639
|
+
}
|
|
640
|
+
next.excludeExts = fields.excludeExts;
|
|
641
|
+
changed.push("excludeExts");
|
|
642
|
+
}
|
|
643
|
+
if (fields.excludePatterns !== void 0) {
|
|
644
|
+
if (!isStringArray(fields.excludePatterns)) {
|
|
645
|
+
return { status: 400, body: { error: "excludePatterns must be string[]" } };
|
|
646
|
+
}
|
|
647
|
+
next.excludePatterns = fields.excludePatterns;
|
|
648
|
+
changed.push("excludePatterns");
|
|
649
|
+
}
|
|
650
|
+
if (fields.respectGitignore !== void 0) {
|
|
651
|
+
if (typeof fields.respectGitignore !== "boolean") {
|
|
652
|
+
return { status: 400, body: { error: "respectGitignore must be boolean" } };
|
|
653
|
+
}
|
|
654
|
+
next.respectGitignore = fields.respectGitignore;
|
|
655
|
+
changed.push("respectGitignore");
|
|
656
|
+
}
|
|
657
|
+
if (fields.maxFileBytes !== void 0) {
|
|
658
|
+
if (typeof fields.maxFileBytes !== "number" || fields.maxFileBytes <= 0) {
|
|
659
|
+
return { status: 400, body: { error: "maxFileBytes must be a positive number" } };
|
|
660
|
+
}
|
|
661
|
+
next.maxFileBytes = fields.maxFileBytes;
|
|
662
|
+
changed.push("maxFileBytes");
|
|
663
|
+
}
|
|
664
|
+
const cfg = readConfig(ctx.configPath);
|
|
665
|
+
cfg.index = { ...cfg.index ?? {}, ...next };
|
|
666
|
+
writeConfig(cfg, ctx.configPath);
|
|
667
|
+
if (changed.length > 0) {
|
|
668
|
+
ctx.audit?.({ ts: Date.now(), action: "set-index-config", payload: { fields: changed } });
|
|
669
|
+
}
|
|
670
|
+
return { status: 200, body: { changed, resolved: resolveIndexConfig(cfg.index) } };
|
|
671
|
+
}
|
|
672
|
+
return { status: 405, body: { error: "GET or POST only" } };
|
|
673
|
+
}
|
|
674
|
+
async function handlePreview(body, ctx) {
|
|
675
|
+
const root = ctx.getCurrentCwd?.();
|
|
676
|
+
if (!root) {
|
|
677
|
+
return {
|
|
678
|
+
status: 400,
|
|
679
|
+
body: { error: "preview requires a code-mode session (no project root attached)" }
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const fields = parseBody3(body);
|
|
683
|
+
const draft = {};
|
|
684
|
+
if (isStringArray(fields.excludeDirs)) draft.excludeDirs = fields.excludeDirs;
|
|
685
|
+
if (isStringArray(fields.excludeFiles)) draft.excludeFiles = fields.excludeFiles;
|
|
686
|
+
if (isStringArray(fields.excludeExts)) draft.excludeExts = fields.excludeExts;
|
|
687
|
+
if (isStringArray(fields.excludePatterns)) draft.excludePatterns = fields.excludePatterns;
|
|
688
|
+
if (typeof fields.respectGitignore === "boolean")
|
|
689
|
+
draft.respectGitignore = fields.respectGitignore;
|
|
690
|
+
if (typeof fields.maxFileBytes === "number" && fields.maxFileBytes > 0) {
|
|
691
|
+
draft.maxFileBytes = fields.maxFileBytes;
|
|
692
|
+
}
|
|
693
|
+
const resolved = resolveIndexConfig(draft);
|
|
694
|
+
const skipBuckets = {
|
|
695
|
+
defaultDir: 0,
|
|
696
|
+
defaultFile: 0,
|
|
697
|
+
binaryExt: 0,
|
|
698
|
+
binaryContent: 0,
|
|
699
|
+
tooLarge: 0,
|
|
700
|
+
gitignore: 0,
|
|
701
|
+
pattern: 0,
|
|
702
|
+
readError: 0
|
|
703
|
+
};
|
|
704
|
+
const skipSamples = {
|
|
705
|
+
defaultDir: [],
|
|
706
|
+
defaultFile: [],
|
|
707
|
+
binaryExt: [],
|
|
708
|
+
binaryContent: [],
|
|
709
|
+
tooLarge: [],
|
|
710
|
+
gitignore: [],
|
|
711
|
+
pattern: [],
|
|
712
|
+
readError: []
|
|
713
|
+
};
|
|
714
|
+
const includedFiles = /* @__PURE__ */ new Set();
|
|
715
|
+
const sampleIncluded = [];
|
|
716
|
+
for await (const chunk of walkChunks(root, {
|
|
717
|
+
config: resolved,
|
|
718
|
+
onSkip: (rel, reason) => {
|
|
719
|
+
skipBuckets[reason]++;
|
|
720
|
+
const bucket = skipSamples[reason];
|
|
721
|
+
if (bucket.length < PREVIEW_PER_REASON_CAP) bucket.push(rel);
|
|
722
|
+
}
|
|
723
|
+
})) {
|
|
724
|
+
if (!includedFiles.has(chunk.path)) {
|
|
725
|
+
includedFiles.add(chunk.path);
|
|
726
|
+
if (sampleIncluded.length < PREVIEW_INCLUDED_CAP) sampleIncluded.push(chunk.path);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
status: 200,
|
|
731
|
+
body: {
|
|
732
|
+
filesIncluded: includedFiles.size,
|
|
733
|
+
sampleIncluded,
|
|
734
|
+
skipBuckets,
|
|
735
|
+
skipSamples,
|
|
736
|
+
resolved
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// src/server/api/loop.ts
|
|
742
|
+
function parseBody4(raw) {
|
|
743
|
+
if (!raw) return {};
|
|
744
|
+
try {
|
|
745
|
+
const parsed = JSON.parse(raw);
|
|
746
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
747
|
+
} catch {
|
|
748
|
+
return {};
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
var MIN_INTERVAL_MS = 5e3;
|
|
752
|
+
var MAX_INTERVAL_MS = 6 * 60 * 60 * 1e3;
|
|
753
|
+
async function handleLoop(method, rest, body, ctx) {
|
|
754
|
+
if (method === "GET" && rest[0] === "status") {
|
|
755
|
+
if (!ctx.getLoopRunStatus) {
|
|
756
|
+
return { status: 503, body: { error: "auto-loop not available \u2014 attach to a chat session" } };
|
|
757
|
+
}
|
|
758
|
+
return { status: 200, body: { status: ctx.getLoopRunStatus() } };
|
|
759
|
+
}
|
|
760
|
+
if (method === "POST" && rest[0] === "start") {
|
|
761
|
+
if (!ctx.startAutoLoop) {
|
|
762
|
+
return { status: 503, body: { error: "auto-loop start not wired" } };
|
|
763
|
+
}
|
|
764
|
+
const { intervalMs, prompt } = parseBody4(body);
|
|
765
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
766
|
+
return { status: 400, body: { error: "prompt must be a non-empty string" } };
|
|
767
|
+
}
|
|
768
|
+
if (typeof intervalMs !== "number" || !Number.isFinite(intervalMs) || intervalMs < MIN_INTERVAL_MS || intervalMs > MAX_INTERVAL_MS) {
|
|
769
|
+
return {
|
|
770
|
+
status: 400,
|
|
771
|
+
body: {
|
|
772
|
+
error: `intervalMs must be a number in [${MIN_INTERVAL_MS}, ${MAX_INTERVAL_MS}] (5s..6h)`
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
ctx.startAutoLoop(intervalMs, prompt.trim());
|
|
777
|
+
ctx.audit?.({ ts: Date.now(), action: "auto-loop-start", payload: { intervalMs } });
|
|
778
|
+
return { status: 200, body: { started: true } };
|
|
779
|
+
}
|
|
780
|
+
if (method === "POST" && rest[0] === "stop") {
|
|
781
|
+
if (!ctx.stopAutoLoop) {
|
|
782
|
+
return { status: 503, body: { error: "auto-loop stop not wired" } };
|
|
783
|
+
}
|
|
784
|
+
ctx.stopAutoLoop();
|
|
785
|
+
ctx.audit?.({ ts: Date.now(), action: "auto-loop-stop" });
|
|
786
|
+
return { status: 200, body: { stopped: true } };
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
status: 405,
|
|
790
|
+
body: { error: `method ${method} not supported on /api/loop/${rest[0] ?? ""}` }
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// src/server/api/mcp.ts
|
|
795
|
+
function parseBody5(raw) {
|
|
796
|
+
if (!raw) return {};
|
|
797
|
+
try {
|
|
798
|
+
const parsed = JSON.parse(raw);
|
|
799
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
800
|
+
} catch {
|
|
801
|
+
return {};
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
function clampInt(raw, min, max, fallback) {
|
|
805
|
+
if (raw == null) return fallback;
|
|
806
|
+
const n = Number.parseInt(raw, 10);
|
|
807
|
+
if (!Number.isFinite(n)) return fallback;
|
|
808
|
+
return Math.max(min, Math.min(max, n));
|
|
809
|
+
}
|
|
810
|
+
function findRegistryEntry(entries, name) {
|
|
811
|
+
const exact = entries.find((e) => e.name === name);
|
|
812
|
+
if (exact) return exact;
|
|
813
|
+
const lower = name.toLowerCase();
|
|
814
|
+
const ci = entries.find((e) => e.name.toLowerCase() === lower);
|
|
815
|
+
if (ci) return ci;
|
|
816
|
+
const tail = entries.find((e) => e.name.toLowerCase().endsWith(`/${lower}`));
|
|
817
|
+
if (tail) return tail;
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
async function handleMcp(method, rest, body, ctx, query = new URLSearchParams()) {
|
|
821
|
+
if (method === "GET" && rest.length === 0) {
|
|
822
|
+
const servers = (ctx.mcpServers ?? []).map((s) => ({
|
|
823
|
+
label: s.label,
|
|
824
|
+
spec: s.spec,
|
|
825
|
+
toolCount: s.toolCount,
|
|
826
|
+
protocolVersion: s.report.protocolVersion,
|
|
827
|
+
serverInfo: s.report.serverInfo,
|
|
828
|
+
capabilities: s.report.capabilities,
|
|
829
|
+
tools: s.report.tools.supported ? s.report.tools.items : [],
|
|
830
|
+
resources: s.report.resources.supported ? s.report.resources.items : [],
|
|
831
|
+
prompts: s.report.prompts.supported ? s.report.prompts.items : [],
|
|
832
|
+
instructions: s.report.instructions ?? null
|
|
833
|
+
}));
|
|
834
|
+
return {
|
|
835
|
+
status: 200,
|
|
836
|
+
body: {
|
|
837
|
+
servers,
|
|
838
|
+
canHotReload: Boolean(ctx.reloadMcp),
|
|
839
|
+
canInvoke: Boolean(ctx.invokeMcpTool)
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
if (method === "GET" && rest[0] === "specs") {
|
|
844
|
+
const cfg = readConfig(ctx.configPath);
|
|
845
|
+
return { status: 200, body: { specs: cfg.mcp ?? [] } };
|
|
846
|
+
}
|
|
847
|
+
if (method === "POST" && rest[0] === "specs") {
|
|
848
|
+
const { spec } = parseBody5(body);
|
|
849
|
+
if (typeof spec !== "string" || !spec.trim()) {
|
|
850
|
+
return { status: 400, body: { error: "spec (non-empty string) required" } };
|
|
851
|
+
}
|
|
852
|
+
const cfg = readConfig(ctx.configPath);
|
|
853
|
+
const list = cfg.mcp ?? [];
|
|
854
|
+
if (list.includes(spec)) {
|
|
855
|
+
return { status: 200, body: { added: false, alreadyPresent: true } };
|
|
856
|
+
}
|
|
857
|
+
cfg.mcp = [...list, spec.trim()];
|
|
858
|
+
writeConfig(cfg, ctx.configPath);
|
|
859
|
+
ctx.audit?.({ ts: Date.now(), action: "add-mcp-spec", payload: { spec } });
|
|
860
|
+
let bridged = false;
|
|
861
|
+
if (ctx.reloadMcp) {
|
|
862
|
+
try {
|
|
863
|
+
await ctx.reloadMcp();
|
|
864
|
+
bridged = true;
|
|
865
|
+
} catch {
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return { status: 200, body: { added: true, requiresRestart: !bridged, bridged } };
|
|
869
|
+
}
|
|
870
|
+
if (method === "DELETE" && rest[0] === "specs") {
|
|
871
|
+
const { spec } = parseBody5(body);
|
|
872
|
+
if (typeof spec !== "string") {
|
|
873
|
+
return { status: 400, body: { error: "spec (string) required" } };
|
|
874
|
+
}
|
|
875
|
+
const cfg = readConfig(ctx.configPath);
|
|
876
|
+
const list = cfg.mcp ?? [];
|
|
877
|
+
if (!list.includes(spec)) {
|
|
878
|
+
return { status: 200, body: { removed: false } };
|
|
879
|
+
}
|
|
880
|
+
cfg.mcp = list.filter((s) => s !== spec);
|
|
881
|
+
writeConfig(cfg, ctx.configPath);
|
|
882
|
+
ctx.audit?.({ ts: Date.now(), action: "remove-mcp-spec", payload: { spec } });
|
|
883
|
+
let bridged = false;
|
|
884
|
+
if (ctx.reloadMcp) {
|
|
885
|
+
try {
|
|
886
|
+
await ctx.reloadMcp();
|
|
887
|
+
bridged = true;
|
|
888
|
+
} catch {
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return { status: 200, body: { removed: true, requiresRestart: !bridged, bridged } };
|
|
892
|
+
}
|
|
893
|
+
if (method === "POST" && rest[0] === "reload") {
|
|
894
|
+
if (!ctx.reloadMcp) {
|
|
895
|
+
return {
|
|
896
|
+
status: 503,
|
|
897
|
+
body: {
|
|
898
|
+
error: "live MCP reload not wired in this session \u2014 restart `reasonix code` to apply spec edits."
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
const count = await ctx.reloadMcp();
|
|
903
|
+
return { status: 200, body: { reloaded: true, count } };
|
|
904
|
+
}
|
|
905
|
+
if (method === "GET" && rest[0] === "registry" && (rest[1] === void 0 || rest[1] === "list")) {
|
|
906
|
+
const pagesWanted = clampInt(query.get("pages"), 1, 200, 1);
|
|
907
|
+
const maxPages = clampInt(query.get("maxPages"), 1, 200, 20);
|
|
908
|
+
const limit = clampInt(query.get("limit"), 1, 1e3, 30);
|
|
909
|
+
const refreshRaw = query.get("refresh");
|
|
910
|
+
const refresh = refreshRaw === "1" || refreshRaw === "true";
|
|
911
|
+
const q = (query.get("q") ?? "").trim().toLowerCase();
|
|
912
|
+
try {
|
|
913
|
+
const handle = await openRegistry({ noCache: refresh });
|
|
914
|
+
const target = q ? maxPages : pagesWanted;
|
|
915
|
+
const additional = Math.max(0, target - handle.cache.pagination.pagesLoaded);
|
|
916
|
+
if (additional > 0) {
|
|
917
|
+
await loadMorePages(handle, {
|
|
918
|
+
pages: additional,
|
|
919
|
+
matchTarget: q ? limit : void 0,
|
|
920
|
+
filter: q ? (e) => `${e.name} ${e.title} ${e.description}`.toLowerCase().includes(q) : void 0
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
const result = handleToFetchResult(handle);
|
|
924
|
+
const matched = q ? result.entries.filter(
|
|
925
|
+
(e) => `${e.name} ${e.title} ${e.description}`.toLowerCase().includes(q)
|
|
926
|
+
) : result.entries;
|
|
927
|
+
const ranked = matched.slice().sort((a, b) => {
|
|
928
|
+
const ap = a.popularity ?? -1;
|
|
929
|
+
const bp = b.popularity ?? -1;
|
|
930
|
+
if (ap !== bp) return bp - ap;
|
|
931
|
+
return a.name.localeCompare(b.name);
|
|
932
|
+
});
|
|
933
|
+
return {
|
|
934
|
+
status: 200,
|
|
935
|
+
body: {
|
|
936
|
+
source: result.source,
|
|
937
|
+
fromCache: result.fromCache,
|
|
938
|
+
fetchedAt: result.fetchedAt,
|
|
939
|
+
loaded: result.entries.length,
|
|
940
|
+
hasMore: result.hasMore,
|
|
941
|
+
matched: matched.length,
|
|
942
|
+
entries: ranked.slice(0, limit),
|
|
943
|
+
errors: result.errors
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
} catch (err) {
|
|
947
|
+
return { status: 500, body: { error: err.message } };
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (method === "POST" && rest[0] === "registry" && rest[1] === "install") {
|
|
951
|
+
const { name, maxPages } = parseBody5(body);
|
|
952
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
953
|
+
return { status: 400, body: { error: "name (string) required" } };
|
|
954
|
+
}
|
|
955
|
+
const cap = typeof maxPages === "number" && maxPages > 0 ? maxPages : 30;
|
|
956
|
+
try {
|
|
957
|
+
const handle = await openRegistry({});
|
|
958
|
+
const target = name.trim();
|
|
959
|
+
const lower = target.toLowerCase();
|
|
960
|
+
const filter = (e) => {
|
|
961
|
+
const n = e.name.toLowerCase();
|
|
962
|
+
return n === lower || n.endsWith(`/${lower}`) || n.includes(lower);
|
|
963
|
+
};
|
|
964
|
+
const additional = Math.max(0, cap - handle.cache.pagination.pagesLoaded);
|
|
965
|
+
if (additional > 0) {
|
|
966
|
+
await loadMorePages(handle, { pages: additional, matchTarget: 1, filter });
|
|
967
|
+
}
|
|
968
|
+
const entry = findRegistryEntry(handle.cache.entries, target);
|
|
969
|
+
if (!entry) {
|
|
970
|
+
return {
|
|
971
|
+
status: 404,
|
|
972
|
+
body: {
|
|
973
|
+
error: `no MCP server named "${target}" found in ${handle.cache.pagination.pagesLoaded} page(s)`
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
if (!entry.install && entry.source === "smithery") {
|
|
978
|
+
const fetched = await fetchSmitheryDetail(entry.name);
|
|
979
|
+
if (fetched) entry.install = fetched;
|
|
980
|
+
}
|
|
981
|
+
if (!entry.install) {
|
|
982
|
+
return {
|
|
983
|
+
status: 422,
|
|
984
|
+
body: {
|
|
985
|
+
error: `Could not derive install metadata for ${entry.name}`,
|
|
986
|
+
hint: `npx -y @smithery/cli install ${entry.name}`
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
const spec = specStringFor(entry.name, entry.install);
|
|
991
|
+
const cfg = readConfig(ctx.configPath);
|
|
992
|
+
const existing = cfg.mcp ?? [];
|
|
993
|
+
if (existing.includes(spec)) {
|
|
994
|
+
return { status: 200, body: { added: false, alreadyPresent: true, spec, entry } };
|
|
995
|
+
}
|
|
996
|
+
cfg.mcp = [...existing, spec];
|
|
997
|
+
writeConfig(cfg, ctx.configPath);
|
|
998
|
+
ctx.audit?.({
|
|
999
|
+
ts: Date.now(),
|
|
1000
|
+
action: "install-mcp-from-registry",
|
|
1001
|
+
payload: { name: entry.name, spec }
|
|
1002
|
+
});
|
|
1003
|
+
let bridged = false;
|
|
1004
|
+
let bridgeError;
|
|
1005
|
+
if (ctx.reloadMcp) {
|
|
1006
|
+
try {
|
|
1007
|
+
await ctx.reloadMcp();
|
|
1008
|
+
bridged = true;
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
bridgeError = err.message;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
status: 200,
|
|
1015
|
+
body: {
|
|
1016
|
+
added: true,
|
|
1017
|
+
requiresRestart: !ctx.reloadMcp || !!bridgeError,
|
|
1018
|
+
bridged,
|
|
1019
|
+
bridgeError,
|
|
1020
|
+
spec,
|
|
1021
|
+
entry
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
} catch (err) {
|
|
1025
|
+
return { status: 500, body: { error: err.message } };
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
if (method === "POST" && rest[0] === "invoke") {
|
|
1029
|
+
if (!ctx.invokeMcpTool) {
|
|
1030
|
+
return {
|
|
1031
|
+
status: 503,
|
|
1032
|
+
body: { error: "MCP invocation requires an attached session." }
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
const { server, tool, args } = parseBody5(body);
|
|
1036
|
+
if (typeof server !== "string" || typeof tool !== "string") {
|
|
1037
|
+
return { status: 400, body: { error: "server + tool (strings) required" } };
|
|
1038
|
+
}
|
|
1039
|
+
try {
|
|
1040
|
+
const result = await ctx.invokeMcpTool(
|
|
1041
|
+
server,
|
|
1042
|
+
tool,
|
|
1043
|
+
typeof args === "object" && args !== null ? args : {}
|
|
1044
|
+
);
|
|
1045
|
+
return { status: 200, body: { result } };
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
return { status: 500, body: { error: err.message } };
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return { status: 405, body: { error: `method ${method} not supported on this path` } };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// src/server/api/memory.ts
|
|
1054
|
+
import { createHash } from "crypto";
|
|
1055
|
+
import {
|
|
1056
|
+
existsSync as existsSync5,
|
|
1057
|
+
mkdirSync as mkdirSync2,
|
|
1058
|
+
readFileSync as readFileSync3,
|
|
1059
|
+
readdirSync as readdirSync3,
|
|
1060
|
+
statSync as statSync3,
|
|
1061
|
+
unlinkSync,
|
|
1062
|
+
writeFileSync as writeFileSync2
|
|
1063
|
+
} from "fs";
|
|
1064
|
+
import { homedir as homedir2 } from "os";
|
|
1065
|
+
import { dirname as dirname3, join as join4, resolve as resolvePath } from "path";
|
|
1066
|
+
function projectHash(rootDir) {
|
|
1067
|
+
return createHash("sha1").update(resolvePath(rootDir)).digest("hex").slice(0, 16);
|
|
1068
|
+
}
|
|
1069
|
+
function globalMemoryDir() {
|
|
1070
|
+
return join4(homedir2(), ".reasonix", "memory", "global");
|
|
1071
|
+
}
|
|
1072
|
+
function projectMemoryDir(rootDir) {
|
|
1073
|
+
return join4(homedir2(), ".reasonix", "memory", projectHash(rootDir));
|
|
1074
|
+
}
|
|
1075
|
+
function parseBody6(raw) {
|
|
1076
|
+
if (!raw) return {};
|
|
1077
|
+
try {
|
|
1078
|
+
const parsed = JSON.parse(raw);
|
|
1079
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
1080
|
+
} catch {
|
|
1081
|
+
return {};
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
var SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
1085
|
+
function listMemoryFiles(dir) {
|
|
1086
|
+
if (!existsSync5(dir)) return [];
|
|
1087
|
+
try {
|
|
1088
|
+
return readdirSync3(dir).filter((f) => f.endsWith(".md")).map((f) => {
|
|
1089
|
+
const stat = statSync3(join4(dir, f));
|
|
1090
|
+
return {
|
|
1091
|
+
name: f.replace(/\.md$/, ""),
|
|
1092
|
+
size: stat.size,
|
|
1093
|
+
mtime: stat.mtime.getTime()
|
|
1094
|
+
};
|
|
1095
|
+
}).sort((a, b) => b.mtime - a.mtime);
|
|
1096
|
+
} catch {
|
|
1097
|
+
return [];
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
async function handleMemory(method, rest, body, ctx) {
|
|
1101
|
+
const cwd = ctx.getCurrentCwd?.();
|
|
1102
|
+
const globalDir = globalMemoryDir();
|
|
1103
|
+
const projectMemDir = cwd ? projectMemoryDir(cwd) : "";
|
|
1104
|
+
if (method === "GET" && rest.length === 0) {
|
|
1105
|
+
const projectMemoryPath = cwd ? join4(cwd, PROJECT_MEMORY_FILE) : null;
|
|
1106
|
+
const projectMemoryExists = projectMemoryPath ? existsSync5(projectMemoryPath) : false;
|
|
1107
|
+
return {
|
|
1108
|
+
status: 200,
|
|
1109
|
+
body: {
|
|
1110
|
+
project: {
|
|
1111
|
+
path: projectMemoryPath,
|
|
1112
|
+
exists: projectMemoryExists,
|
|
1113
|
+
file: PROJECT_MEMORY_FILE
|
|
1114
|
+
},
|
|
1115
|
+
global: {
|
|
1116
|
+
path: globalDir,
|
|
1117
|
+
files: listMemoryFiles(globalDir)
|
|
1118
|
+
},
|
|
1119
|
+
projectMem: {
|
|
1120
|
+
path: projectMemDir,
|
|
1121
|
+
files: projectMemDir ? listMemoryFiles(projectMemDir) : []
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
const [scope, ...nameParts] = rest;
|
|
1127
|
+
const name = nameParts.join("/");
|
|
1128
|
+
if (method === "GET") {
|
|
1129
|
+
if (scope === "project") {
|
|
1130
|
+
if (!cwd) return { status: 503, body: { error: "no active project" } };
|
|
1131
|
+
const path = join4(cwd, PROJECT_MEMORY_FILE);
|
|
1132
|
+
if (!existsSync5(path)) return { status: 404, body: { error: "REASONIX.md not found" } };
|
|
1133
|
+
return { status: 200, body: { path, body: readFileSync3(path, "utf8") } };
|
|
1134
|
+
}
|
|
1135
|
+
if ((scope === "global" || scope === "project-mem") && name && SAFE_NAME.test(name)) {
|
|
1136
|
+
const dir = scope === "global" ? globalDir : projectMemDir;
|
|
1137
|
+
if (!dir) return { status: 503, body: { error: "no project root for project-mem" } };
|
|
1138
|
+
const path = join4(dir, `${name}.md`);
|
|
1139
|
+
if (!existsSync5(path)) return { status: 404, body: { error: "not found" } };
|
|
1140
|
+
return { status: 200, body: { path, body: readFileSync3(path, "utf8") } };
|
|
1141
|
+
}
|
|
1142
|
+
return { status: 400, body: { error: "bad scope or name" } };
|
|
1143
|
+
}
|
|
1144
|
+
if (method === "POST") {
|
|
1145
|
+
const { body: contents } = parseBody6(body);
|
|
1146
|
+
if (typeof contents !== "string") {
|
|
1147
|
+
return { status: 400, body: { error: "body (string) required" } };
|
|
1148
|
+
}
|
|
1149
|
+
if (scope === "project") {
|
|
1150
|
+
if (!cwd) return { status: 503, body: { error: "no active project" } };
|
|
1151
|
+
const path = join4(cwd, PROJECT_MEMORY_FILE);
|
|
1152
|
+
mkdirSync2(dirname3(path), { recursive: true });
|
|
1153
|
+
writeFileSync2(path, contents, "utf8");
|
|
1154
|
+
ctx.audit?.({ ts: Date.now(), action: "save-memory", payload: { scope, path } });
|
|
1155
|
+
return { status: 200, body: { saved: true, path } };
|
|
1156
|
+
}
|
|
1157
|
+
if ((scope === "global" || scope === "project-mem") && name && SAFE_NAME.test(name)) {
|
|
1158
|
+
const dir = scope === "global" ? globalDir : projectMemDir;
|
|
1159
|
+
if (!dir) return { status: 503, body: { error: "no project root for project-mem" } };
|
|
1160
|
+
mkdirSync2(dir, { recursive: true });
|
|
1161
|
+
const path = join4(dir, `${name}.md`);
|
|
1162
|
+
writeFileSync2(path, contents, "utf8");
|
|
1163
|
+
ctx.audit?.({ ts: Date.now(), action: "save-memory", payload: { scope, name, path } });
|
|
1164
|
+
return { status: 200, body: { saved: true, path } };
|
|
1165
|
+
}
|
|
1166
|
+
return { status: 400, body: { error: "bad scope or name" } };
|
|
1167
|
+
}
|
|
1168
|
+
if (method === "DELETE") {
|
|
1169
|
+
if ((scope === "global" || scope === "project-mem") && name && SAFE_NAME.test(name)) {
|
|
1170
|
+
const dir = scope === "global" ? globalDir : projectMemDir;
|
|
1171
|
+
if (!dir) return { status: 503, body: { error: "no project root for project-mem" } };
|
|
1172
|
+
const path = join4(dir, `${name}.md`);
|
|
1173
|
+
if (existsSync5(path)) {
|
|
1174
|
+
unlinkSync(path);
|
|
1175
|
+
ctx.audit?.({ ts: Date.now(), action: "delete-memory", payload: { scope, name, path } });
|
|
1176
|
+
return { status: 200, body: { deleted: true } };
|
|
1177
|
+
}
|
|
1178
|
+
return { status: 404, body: { error: "not found" } };
|
|
1179
|
+
}
|
|
1180
|
+
if (scope === "project") {
|
|
1181
|
+
if (!cwd) return { status: 503, body: { error: "no active project" } };
|
|
1182
|
+
const path = join4(cwd, PROJECT_MEMORY_FILE);
|
|
1183
|
+
if (existsSync5(path)) {
|
|
1184
|
+
unlinkSync(path);
|
|
1185
|
+
ctx.audit?.({ ts: Date.now(), action: "delete-memory", payload: { scope, path } });
|
|
1186
|
+
return { status: 200, body: { deleted: true } };
|
|
1187
|
+
}
|
|
1188
|
+
return { status: 404, body: { error: "not found" } };
|
|
1189
|
+
}
|
|
1190
|
+
return { status: 400, body: { error: "bad scope or name" } };
|
|
1191
|
+
}
|
|
1192
|
+
return { status: 405, body: { error: `method ${method} not supported` } };
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// src/server/api/messages.ts
|
|
1196
|
+
async function handleMessages(method, _rest, _body, ctx) {
|
|
1197
|
+
if (method !== "GET") {
|
|
1198
|
+
return { status: 405, body: { error: "GET only" } };
|
|
1199
|
+
}
|
|
1200
|
+
const messages = ctx.getMessages ? ctx.getMessages() : [];
|
|
1201
|
+
return {
|
|
1202
|
+
status: 200,
|
|
1203
|
+
body: {
|
|
1204
|
+
messages,
|
|
1205
|
+
busy: ctx.isBusy ? ctx.isBusy() : false
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// src/server/api/modal.ts
|
|
1211
|
+
function parsePickerResolution(body) {
|
|
1212
|
+
const { action, id, text, query } = body;
|
|
1213
|
+
if (typeof action !== "string") return { error: "picker action required" };
|
|
1214
|
+
switch (action) {
|
|
1215
|
+
case "pick":
|
|
1216
|
+
case "delete":
|
|
1217
|
+
case "install":
|
|
1218
|
+
case "uninstall":
|
|
1219
|
+
if (typeof id !== "string" || !id) return { error: `picker ${action} requires id` };
|
|
1220
|
+
return { action, id };
|
|
1221
|
+
case "rename":
|
|
1222
|
+
if (typeof id !== "string" || !id) return { error: "picker rename requires id" };
|
|
1223
|
+
if (typeof text !== "string") return { error: "picker rename requires text" };
|
|
1224
|
+
return { action: "rename", id, text };
|
|
1225
|
+
case "new":
|
|
1226
|
+
return typeof text === "string" && text ? { action: "new", text } : { action: "new" };
|
|
1227
|
+
case "load-more":
|
|
1228
|
+
return { action: "load-more" };
|
|
1229
|
+
case "refine":
|
|
1230
|
+
if (typeof query !== "string") return { error: "picker refine requires query" };
|
|
1231
|
+
return { action: "refine", query };
|
|
1232
|
+
case "cancel":
|
|
1233
|
+
return { action: "cancel" };
|
|
1234
|
+
default:
|
|
1235
|
+
return { error: `unknown picker action: ${action}` };
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
function parseBody7(raw) {
|
|
1239
|
+
if (!raw) return {};
|
|
1240
|
+
try {
|
|
1241
|
+
const parsed = JSON.parse(raw);
|
|
1242
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
1243
|
+
} catch {
|
|
1244
|
+
return {};
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
async function handleModal(method, rest, body, ctx) {
|
|
1248
|
+
if (method === "GET" && rest.length === 0) {
|
|
1249
|
+
return {
|
|
1250
|
+
status: 200,
|
|
1251
|
+
body: { modal: ctx.getActiveModal ? ctx.getActiveModal() : null }
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
if (method === "POST" && rest[0] === "resolve") {
|
|
1255
|
+
const parsed = parseBody7(body);
|
|
1256
|
+
const { kind, choice, text } = parsed;
|
|
1257
|
+
if (kind === "shell") {
|
|
1258
|
+
if (!ctx.resolveShellConfirm) {
|
|
1259
|
+
return { status: 503, body: { error: "shell modal resolution not wired" } };
|
|
1260
|
+
}
|
|
1261
|
+
if (choice !== "run_once" && choice !== "always_allow" && choice !== "deny") {
|
|
1262
|
+
return {
|
|
1263
|
+
status: 400,
|
|
1264
|
+
body: { error: "shell choice must be run_once / always_allow / deny" }
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
ctx.resolveShellConfirm(choice);
|
|
1268
|
+
return { status: 200, body: { resolved: true } };
|
|
1269
|
+
}
|
|
1270
|
+
if (kind === "choice") {
|
|
1271
|
+
if (!ctx.resolveChoiceConfirm) {
|
|
1272
|
+
return { status: 503, body: { error: "choice modal resolution not wired" } };
|
|
1273
|
+
}
|
|
1274
|
+
const c = choice;
|
|
1275
|
+
if (!c || typeof c !== "object") {
|
|
1276
|
+
return { status: 400, body: { error: "choice must be an object with a kind field" } };
|
|
1277
|
+
}
|
|
1278
|
+
if (c.kind === "pick" && typeof c.optionId === "string") {
|
|
1279
|
+
ctx.resolveChoiceConfirm({ kind: "pick", optionId: c.optionId });
|
|
1280
|
+
return { status: 200, body: { resolved: true } };
|
|
1281
|
+
}
|
|
1282
|
+
if (c.kind === "custom" && typeof c.text === "string") {
|
|
1283
|
+
ctx.resolveChoiceConfirm({ kind: "custom", text: c.text });
|
|
1284
|
+
return { status: 200, body: { resolved: true } };
|
|
1285
|
+
}
|
|
1286
|
+
if (c.kind === "cancel") {
|
|
1287
|
+
ctx.resolveChoiceConfirm({ kind: "cancel" });
|
|
1288
|
+
return { status: 200, body: { resolved: true } };
|
|
1289
|
+
}
|
|
1290
|
+
return { status: 400, body: { error: "unknown choice resolution shape" } };
|
|
1291
|
+
}
|
|
1292
|
+
if (kind === "plan") {
|
|
1293
|
+
if (!ctx.resolvePlanConfirm) {
|
|
1294
|
+
return { status: 503, body: { error: "plan modal resolution not wired" } };
|
|
1295
|
+
}
|
|
1296
|
+
if (choice !== "approve" && choice !== "refine" && choice !== "cancel") {
|
|
1297
|
+
return { status: 400, body: { error: "plan choice must be approve / refine / cancel" } };
|
|
1298
|
+
}
|
|
1299
|
+
ctx.resolvePlanConfirm(choice, typeof text === "string" && text.trim() ? text : void 0);
|
|
1300
|
+
return { status: 200, body: { resolved: true } };
|
|
1301
|
+
}
|
|
1302
|
+
if (kind === "edit-review") {
|
|
1303
|
+
if (!ctx.resolveEditReview) {
|
|
1304
|
+
return { status: 503, body: { error: "edit-review modal resolution not wired" } };
|
|
1305
|
+
}
|
|
1306
|
+
if (choice !== "apply" && choice !== "reject" && choice !== "apply-rest-of-turn" && choice !== "flip-to-auto") {
|
|
1307
|
+
return { status: 400, body: { error: "edit-review choice invalid" } };
|
|
1308
|
+
}
|
|
1309
|
+
ctx.resolveEditReview(choice);
|
|
1310
|
+
return { status: 200, body: { resolved: true } };
|
|
1311
|
+
}
|
|
1312
|
+
if (kind === "checkpoint") {
|
|
1313
|
+
if (!ctx.resolveCheckpointConfirm) {
|
|
1314
|
+
return { status: 503, body: { error: "checkpoint modal resolution not wired" } };
|
|
1315
|
+
}
|
|
1316
|
+
if (choice !== "continue" && choice !== "revise" && choice !== "stop") {
|
|
1317
|
+
return {
|
|
1318
|
+
status: 400,
|
|
1319
|
+
body: { error: "checkpoint choice must be continue / revise / stop" }
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
ctx.resolveCheckpointConfirm(
|
|
1323
|
+
choice,
|
|
1324
|
+
typeof text === "string" && text.trim() ? text : void 0
|
|
1325
|
+
);
|
|
1326
|
+
return { status: 200, body: { resolved: true } };
|
|
1327
|
+
}
|
|
1328
|
+
if (kind === "revision") {
|
|
1329
|
+
if (!ctx.resolveReviseConfirm) {
|
|
1330
|
+
return { status: 503, body: { error: "revision modal resolution not wired" } };
|
|
1331
|
+
}
|
|
1332
|
+
if (choice !== "accept" && choice !== "reject") {
|
|
1333
|
+
return { status: 400, body: { error: "revision choice must be accept / reject" } };
|
|
1334
|
+
}
|
|
1335
|
+
ctx.resolveReviseConfirm(choice);
|
|
1336
|
+
return { status: 200, body: { resolved: true } };
|
|
1337
|
+
}
|
|
1338
|
+
if (kind === "picker") {
|
|
1339
|
+
if (!ctx.resolvePicker) {
|
|
1340
|
+
return { status: 503, body: { error: "picker modal resolution not wired" } };
|
|
1341
|
+
}
|
|
1342
|
+
const resolution = parsePickerResolution(parsed);
|
|
1343
|
+
if ("error" in resolution) {
|
|
1344
|
+
return { status: 400, body: { error: resolution.error } };
|
|
1345
|
+
}
|
|
1346
|
+
ctx.resolvePicker(resolution);
|
|
1347
|
+
return { status: 200, body: { resolved: true } };
|
|
1348
|
+
}
|
|
1349
|
+
if (kind === "viewer") {
|
|
1350
|
+
if (!ctx.resolveViewer) {
|
|
1351
|
+
return { status: 503, body: { error: "viewer modal resolution not wired" } };
|
|
1352
|
+
}
|
|
1353
|
+
if (parsed.action !== "close") {
|
|
1354
|
+
return { status: 400, body: { error: "viewer action must be close" } };
|
|
1355
|
+
}
|
|
1356
|
+
ctx.resolveViewer({ action: "close" });
|
|
1357
|
+
return { status: 200, body: { resolved: true } };
|
|
1358
|
+
}
|
|
1359
|
+
return { status: 400, body: { error: `unknown modal kind: ${String(kind)}` } };
|
|
1360
|
+
}
|
|
1361
|
+
return { status: 405, body: { error: `method ${method} not supported on this path` } };
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// src/server/api/models.ts
|
|
1365
|
+
async function handleModels(method, _rest, _body, ctx) {
|
|
1366
|
+
if (method !== "GET") return { status: 405, body: { error: "GET only" } };
|
|
1367
|
+
const models = ctx.getModels?.() ?? null;
|
|
1368
|
+
return {
|
|
1369
|
+
status: 200,
|
|
1370
|
+
body: {
|
|
1371
|
+
models,
|
|
1372
|
+
current: ctx.loop?.model ?? null,
|
|
1373
|
+
/** USD per 1M tokens — same table the cost gauge uses. */
|
|
1374
|
+
pricing: DEEPSEEK_PRICING
|
|
1375
|
+
}
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// src/server/api/cockpit-events.ts
|
|
1380
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1381
|
+
var DAY_MS = 864e5;
|
|
1382
|
+
var RECENT_FILES_CAP = 8;
|
|
1383
|
+
var PLAN_FEED_CAP = 4;
|
|
1384
|
+
var TOOL_FEED_CAP = 6;
|
|
1385
|
+
function computeEventsCockpit(now = Date.now(), sessionsDirOverride) {
|
|
1386
|
+
const dir = sessionsDirOverride ?? sessionsDir();
|
|
1387
|
+
if (!existsSync6(dir)) {
|
|
1388
|
+
return { toolCalls24h: null, recentPlans: null, toolActivity: null };
|
|
1389
|
+
}
|
|
1390
|
+
const files = recentEventFiles(dir, now, RECENT_FILES_CAP);
|
|
1391
|
+
if (files.length === 0) {
|
|
1392
|
+
return { toolCalls24h: null, recentPlans: null, toolActivity: null };
|
|
1393
|
+
}
|
|
1394
|
+
let calls24h = 0;
|
|
1395
|
+
let callsPrior24h = 0;
|
|
1396
|
+
const cutoff24h = now - DAY_MS;
|
|
1397
|
+
const cutoff48h = now - 2 * DAY_MS;
|
|
1398
|
+
const allTools = [];
|
|
1399
|
+
const allPlans = [];
|
|
1400
|
+
for (const file of files) {
|
|
1401
|
+
const events = readEventLogFile(file);
|
|
1402
|
+
if (events.length === 0) continue;
|
|
1403
|
+
countToolCalls(events, cutoff24h, cutoff48h, (in24h) => {
|
|
1404
|
+
if (in24h) calls24h++;
|
|
1405
|
+
else callsPrior24h++;
|
|
1406
|
+
});
|
|
1407
|
+
collectToolActivity(events, allTools);
|
|
1408
|
+
collectPlans(events, allPlans);
|
|
1409
|
+
}
|
|
1410
|
+
allTools.sort((a, b) => b.whenMs - a.whenMs);
|
|
1411
|
+
allPlans.sort((a, b) => b.whenMs - a.whenMs);
|
|
1412
|
+
return {
|
|
1413
|
+
toolCalls24h: { total: calls24h, delta: calls24h - callsPrior24h },
|
|
1414
|
+
recentPlans: allPlans.slice(0, PLAN_FEED_CAP),
|
|
1415
|
+
toolActivity: allTools.slice(0, TOOL_FEED_CAP)
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
function countToolCalls(events, cutoff24h, cutoff48h, onCall) {
|
|
1419
|
+
for (const ev of events) {
|
|
1420
|
+
if (ev.type !== "tool.intent") continue;
|
|
1421
|
+
const ts = parseTs(ev.ts);
|
|
1422
|
+
if (ts === null) continue;
|
|
1423
|
+
if (ts >= cutoff24h) onCall(true);
|
|
1424
|
+
else if (ts >= cutoff48h) onCall(false);
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
function collectToolActivity(events, into) {
|
|
1428
|
+
const intentByCallId = /* @__PURE__ */ new Map();
|
|
1429
|
+
for (const ev of events) {
|
|
1430
|
+
if (ev.type === "tool.intent") {
|
|
1431
|
+
const ts = parseTs(ev.ts);
|
|
1432
|
+
if (ts !== null) intentByCallId.set(ev.callId, { name: ev.name, args: ev.args, ts });
|
|
1433
|
+
} else if (ev.type === "tool.result") {
|
|
1434
|
+
const intent = intentByCallId.get(ev.callId);
|
|
1435
|
+
if (!intent) continue;
|
|
1436
|
+
into.push({
|
|
1437
|
+
name: intent.name,
|
|
1438
|
+
args: summarizeArgs(intent.args),
|
|
1439
|
+
level: ev.ok ? "ok" : "err",
|
|
1440
|
+
whenMs: intent.ts
|
|
1441
|
+
});
|
|
1442
|
+
} else if (ev.type === "tool.denied") {
|
|
1443
|
+
const intent = intentByCallId.get(ev.callId);
|
|
1444
|
+
if (!intent) continue;
|
|
1445
|
+
into.push({
|
|
1446
|
+
name: intent.name,
|
|
1447
|
+
args: summarizeArgs(intent.args),
|
|
1448
|
+
level: "warn",
|
|
1449
|
+
whenMs: intent.ts
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
function collectPlans(events, into) {
|
|
1455
|
+
let current = null;
|
|
1456
|
+
let completed = /* @__PURE__ */ new Set();
|
|
1457
|
+
for (const ev of events) {
|
|
1458
|
+
if (ev.type === "plan.submitted") {
|
|
1459
|
+
if (current) {
|
|
1460
|
+
into.push(buildPlan(current, completed));
|
|
1461
|
+
}
|
|
1462
|
+
const ts = parseTs(ev.ts);
|
|
1463
|
+
if (ts === null) {
|
|
1464
|
+
current = null;
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
current = {
|
|
1468
|
+
id: `${ev.id}`,
|
|
1469
|
+
title: planTitle(ev.body, ev.steps),
|
|
1470
|
+
totalSteps: ev.steps.length,
|
|
1471
|
+
whenMs: ts
|
|
1472
|
+
};
|
|
1473
|
+
completed = /* @__PURE__ */ new Set();
|
|
1474
|
+
} else if (ev.type === "plan.step.completed") {
|
|
1475
|
+
if (!current) continue;
|
|
1476
|
+
completed.add(ev.stepId);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
if (current) into.push(buildPlan(current, completed));
|
|
1480
|
+
}
|
|
1481
|
+
function buildPlan(current, completed) {
|
|
1482
|
+
return {
|
|
1483
|
+
id: current.id,
|
|
1484
|
+
title: current.title,
|
|
1485
|
+
totalSteps: current.totalSteps,
|
|
1486
|
+
completedSteps: completed.size,
|
|
1487
|
+
status: completed.size >= current.totalSteps && current.totalSteps > 0 ? "done" : "active",
|
|
1488
|
+
whenMs: current.whenMs
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
function planTitle(body, steps) {
|
|
1492
|
+
const firstBodyLine = body.split(/\r?\n/).find((l) => l.trim().length > 0);
|
|
1493
|
+
if (firstBodyLine)
|
|
1494
|
+
return firstBodyLine.replace(/^#+\s*/, "").trim().slice(0, 80);
|
|
1495
|
+
if (steps.length > 0 && steps[0]) return steps[0].title.slice(0, 80);
|
|
1496
|
+
return "(plan)";
|
|
1497
|
+
}
|
|
1498
|
+
function summarizeArgs(args) {
|
|
1499
|
+
if (!args) return "";
|
|
1500
|
+
let parsed;
|
|
1501
|
+
try {
|
|
1502
|
+
parsed = JSON.parse(args);
|
|
1503
|
+
} catch {
|
|
1504
|
+
return args.slice(0, 60);
|
|
1505
|
+
}
|
|
1506
|
+
if (parsed && typeof parsed === "object") {
|
|
1507
|
+
const obj = parsed;
|
|
1508
|
+
const path = obj.path ?? obj.file_path ?? obj.filename;
|
|
1509
|
+
const command = obj.command;
|
|
1510
|
+
if (typeof command === "string")
|
|
1511
|
+
return command.length > 60 ? `${command.slice(0, 60)}\u2026` : command;
|
|
1512
|
+
if (typeof path === "string") return path;
|
|
1513
|
+
}
|
|
1514
|
+
return args.slice(0, 60);
|
|
1515
|
+
}
|
|
1516
|
+
function parseTs(ts) {
|
|
1517
|
+
const n = Date.parse(ts);
|
|
1518
|
+
return Number.isFinite(n) ? n : null;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// src/server/api/cockpit.ts
|
|
1522
|
+
var TTL_MS = 3e4;
|
|
1523
|
+
var cache = /* @__PURE__ */ new Map();
|
|
1524
|
+
function computeCockpit(ctx, now = Date.now()) {
|
|
1525
|
+
return {
|
|
1526
|
+
balance: extractBalance(ctx.getStats?.() ?? null),
|
|
1527
|
+
currentSession: extractCurrentSession(ctx),
|
|
1528
|
+
...readWarmCached(ctx.usageLogPath, now, ctx.sessionsDir)
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
function extractBalance(stats) {
|
|
1532
|
+
const first = stats?.balance?.[0];
|
|
1533
|
+
if (!first) return null;
|
|
1534
|
+
return { currency: first.currency, total: first.total_balance };
|
|
1535
|
+
}
|
|
1536
|
+
function extractCurrentSession(ctx) {
|
|
1537
|
+
const id = ctx.getSessionName?.() ?? null;
|
|
1538
|
+
const stats = ctx.getStats?.() ?? null;
|
|
1539
|
+
const loop = ctx.loop;
|
|
1540
|
+
if (!id || !stats || !loop) return null;
|
|
1541
|
+
let completion = 0;
|
|
1542
|
+
for (const t of loop.stats.turns) completion += t.usage.completionTokens;
|
|
1543
|
+
return {
|
|
1544
|
+
id,
|
|
1545
|
+
turns: stats.turns,
|
|
1546
|
+
totalCostUsd: stats.totalCostUsd,
|
|
1547
|
+
lastPromptTokens: stats.lastPromptTokens,
|
|
1548
|
+
completionTokens: completion
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
function readWarmCached(usageLogPath, now, sessionsDir2) {
|
|
1552
|
+
const cacheKey = `${usageLogPath}::${sessionsDir2 ?? ""}`;
|
|
1553
|
+
const hit = cache.get(cacheKey);
|
|
1554
|
+
if (hit && now - hit.ts < TTL_MS) return hit.data;
|
|
1555
|
+
const data = computeWarm(usageLogPath, now, sessionsDir2);
|
|
1556
|
+
cache.set(cacheKey, { ts: now, data });
|
|
1557
|
+
return data;
|
|
1558
|
+
}
|
|
1559
|
+
function computeWarm(usageLogPath, now, sessionsDir2) {
|
|
1560
|
+
const events = computeEventsCockpit(now, sessionsDir2);
|
|
1561
|
+
const records = readUsageLog(usageLogPath);
|
|
1562
|
+
if (records.length === 0) {
|
|
1563
|
+
return { tokens7d: null, cacheHit7d: null, costTrend14d: null, ...events };
|
|
1564
|
+
}
|
|
1565
|
+
const week = aggregateUsage(records, { now }).buckets[1];
|
|
1566
|
+
const priorWeekRecords = records.filter(
|
|
1567
|
+
(r) => r.ts < week.since && r.ts >= week.since - 7 * 864e5
|
|
1568
|
+
);
|
|
1569
|
+
const priorWeek = aggregateUsage(priorWeekRecords, { now: week.since }).buckets[1];
|
|
1570
|
+
const tokens7dTotal = week.promptTokens + week.completionTokens;
|
|
1571
|
+
const tokens7dPrior = priorWeek.promptTokens + priorWeek.completionTokens;
|
|
1572
|
+
const tokens7d = {
|
|
1573
|
+
total: tokens7dTotal,
|
|
1574
|
+
deltaPct: tokens7dPrior > 0 ? (tokens7dTotal - tokens7dPrior) / tokens7dPrior * 100 : null
|
|
1575
|
+
};
|
|
1576
|
+
const cacheHitRatio = bucketCacheHitRatio(week);
|
|
1577
|
+
const cacheHit7d = {
|
|
1578
|
+
ratio: cacheHitRatio,
|
|
1579
|
+
deltaPp: priorWeek.cacheHitTokens + priorWeek.cacheMissTokens > 0 ? (cacheHitRatio - bucketCacheHitRatio(priorWeek)) * 100 : null
|
|
1580
|
+
};
|
|
1581
|
+
return {
|
|
1582
|
+
tokens7d,
|
|
1583
|
+
cacheHit7d,
|
|
1584
|
+
costTrend14d: rollupDailyCost(records, now, 14),
|
|
1585
|
+
...events
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
function rollupDailyCost(records, now, days) {
|
|
1589
|
+
const since = now - days * 864e5;
|
|
1590
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
1591
|
+
for (let i = 0; i < days; i++) {
|
|
1592
|
+
buckets.set(localDateKey(now - i * 864e5), 0);
|
|
1593
|
+
}
|
|
1594
|
+
for (const r of records) {
|
|
1595
|
+
if (r.ts < since) continue;
|
|
1596
|
+
const key = localDateKey(r.ts);
|
|
1597
|
+
if (!buckets.has(key)) continue;
|
|
1598
|
+
buckets.set(key, (buckets.get(key) ?? 0) + r.costUsd);
|
|
1599
|
+
}
|
|
1600
|
+
return Array.from(buckets.entries()).map(([date, usd]) => ({ date, usd })).sort((a, b) => a.date < b.date ? -1 : 1);
|
|
1601
|
+
}
|
|
1602
|
+
function localDateKey(ts) {
|
|
1603
|
+
const d = new Date(ts);
|
|
1604
|
+
const y = d.getFullYear();
|
|
1605
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
1606
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
1607
|
+
return `${y}-${m}-${day}`;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// src/server/api/overview.ts
|
|
1611
|
+
async function handleOverview(method, _rest, _body, ctx) {
|
|
1612
|
+
if (method !== "GET") {
|
|
1613
|
+
return { status: 405, body: { error: "GET only" } };
|
|
1614
|
+
}
|
|
1615
|
+
const cfg = readConfig(ctx.configPath);
|
|
1616
|
+
const cwd = ctx.getCurrentCwd?.() ?? null;
|
|
1617
|
+
const semanticIndexExists = cwd ? await indexExists(cwd).catch(() => false) : null;
|
|
1618
|
+
const overview = {
|
|
1619
|
+
version: VERSION,
|
|
1620
|
+
mode: ctx.mode,
|
|
1621
|
+
latestVersion: ctx.getLatestVersion?.() ?? null,
|
|
1622
|
+
session: ctx.getSessionName?.() ?? null,
|
|
1623
|
+
cwd,
|
|
1624
|
+
model: ctx.loop?.model ?? null,
|
|
1625
|
+
editMode: ctx.getEditMode?.() ?? null,
|
|
1626
|
+
planMode: ctx.getPlanMode?.() ?? null,
|
|
1627
|
+
pendingEdits: ctx.getPendingEditCount?.() ?? null,
|
|
1628
|
+
mcpServerCount: ctx.mcpServers?.length ?? null,
|
|
1629
|
+
toolCount: ctx.tools ? ctx.tools.size : null,
|
|
1630
|
+
preset: cfg.preset ?? "auto",
|
|
1631
|
+
reasoningEffort: cfg.reasoningEffort ?? "max",
|
|
1632
|
+
budgetUsd: ctx.loop?.budgetUsd ?? null,
|
|
1633
|
+
stats: ctx.getStats?.() ?? null,
|
|
1634
|
+
semanticIndexExists,
|
|
1635
|
+
cockpit: computeCockpit(ctx)
|
|
1636
|
+
};
|
|
1637
|
+
return { status: 200, body: overview };
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// src/server/api/permissions.ts
|
|
1641
|
+
function parseBody8(raw) {
|
|
1642
|
+
if (!raw) return {};
|
|
1643
|
+
try {
|
|
1644
|
+
const parsed = JSON.parse(raw);
|
|
1645
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
1646
|
+
} catch {
|
|
1647
|
+
return {};
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
async function handlePermissions(method, rest, body, ctx) {
|
|
1651
|
+
if (method === "GET" && rest.length === 0) {
|
|
1652
|
+
const cwd2 = ctx.getCurrentCwd?.();
|
|
1653
|
+
return {
|
|
1654
|
+
status: 200,
|
|
1655
|
+
body: {
|
|
1656
|
+
currentCwd: cwd2 ?? null,
|
|
1657
|
+
editMode: ctx.getEditMode?.() ?? null,
|
|
1658
|
+
builtin: [...BUILTIN_ALLOWLIST],
|
|
1659
|
+
project: cwd2 ? loadProjectShellAllowed(cwd2, ctx.configPath) : []
|
|
1660
|
+
}
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
const cwd = ctx.getCurrentCwd?.();
|
|
1664
|
+
if (!cwd) {
|
|
1665
|
+
return {
|
|
1666
|
+
status: 503,
|
|
1667
|
+
body: {
|
|
1668
|
+
error: "no active project \u2014 mutations require an attached dashboard session (run `/dashboard` from inside `reasonix code`)."
|
|
1669
|
+
}
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
if (method === "POST" && rest.length === 0) {
|
|
1673
|
+
const { prefix } = parseBody8(body);
|
|
1674
|
+
if (typeof prefix !== "string" || !prefix.trim()) {
|
|
1675
|
+
return { status: 400, body: { error: "prefix (string) required" } };
|
|
1676
|
+
}
|
|
1677
|
+
const trimmed = prefix.trim();
|
|
1678
|
+
if (BUILTIN_ALLOWLIST.includes(trimmed)) {
|
|
1679
|
+
return {
|
|
1680
|
+
status: 409,
|
|
1681
|
+
body: {
|
|
1682
|
+
error: `\`${trimmed}\` is already in the builtin allowlist \u2014 no project entry needed.`
|
|
1683
|
+
}
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
const before = loadProjectShellAllowed(cwd, ctx.configPath);
|
|
1687
|
+
if (before.includes(trimmed)) {
|
|
1688
|
+
return { status: 200, body: { added: false, prefix: trimmed, alreadyPresent: true } };
|
|
1689
|
+
}
|
|
1690
|
+
addProjectShellAllowed(cwd, trimmed, ctx.configPath);
|
|
1691
|
+
ctx.audit?.({
|
|
1692
|
+
ts: Date.now(),
|
|
1693
|
+
action: "add-allowlist",
|
|
1694
|
+
payload: { prefix: trimmed, project: cwd }
|
|
1695
|
+
});
|
|
1696
|
+
return { status: 200, body: { added: true, prefix: trimmed } };
|
|
1697
|
+
}
|
|
1698
|
+
if (method === "DELETE" && rest.length === 0) {
|
|
1699
|
+
const { prefix } = parseBody8(body);
|
|
1700
|
+
if (typeof prefix !== "string" || !prefix.trim()) {
|
|
1701
|
+
return { status: 400, body: { error: "prefix (string) required" } };
|
|
1702
|
+
}
|
|
1703
|
+
const trimmed = prefix.trim();
|
|
1704
|
+
if (BUILTIN_ALLOWLIST.includes(trimmed)) {
|
|
1705
|
+
return {
|
|
1706
|
+
status: 409,
|
|
1707
|
+
body: {
|
|
1708
|
+
error: `\`${trimmed}\` is in the builtin allowlist (read-only); builtin entries can't be removed at runtime.`
|
|
1709
|
+
}
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
const removed = removeProjectShellAllowed(cwd, trimmed, ctx.configPath);
|
|
1713
|
+
if (removed) {
|
|
1714
|
+
ctx.audit?.({
|
|
1715
|
+
ts: Date.now(),
|
|
1716
|
+
action: "remove-allowlist",
|
|
1717
|
+
payload: { prefix: trimmed, project: cwd }
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
return { status: 200, body: { removed, prefix: trimmed } };
|
|
1721
|
+
}
|
|
1722
|
+
if (method === "POST" && rest[0] === "clear") {
|
|
1723
|
+
const { confirm } = parseBody8(body);
|
|
1724
|
+
if (confirm !== true) {
|
|
1725
|
+
return {
|
|
1726
|
+
status: 400,
|
|
1727
|
+
body: {
|
|
1728
|
+
error: "clear requires { confirm: true } in the body \u2014 guards against accidental wipe."
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
const dropped = clearProjectShellAllowed(cwd, ctx.configPath);
|
|
1733
|
+
if (dropped > 0) {
|
|
1734
|
+
ctx.audit?.({
|
|
1735
|
+
ts: Date.now(),
|
|
1736
|
+
action: "clear-allowlist",
|
|
1737
|
+
payload: { dropped, project: cwd }
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
return { status: 200, body: { dropped } };
|
|
1741
|
+
}
|
|
1742
|
+
return { status: 405, body: { error: `method ${method} not supported on this path` } };
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// src/server/api/plans.ts
|
|
1746
|
+
async function handlePlans(method, _rest, _body, _ctx) {
|
|
1747
|
+
if (method !== "GET") {
|
|
1748
|
+
return { status: 405, body: { error: "GET only" } };
|
|
1749
|
+
}
|
|
1750
|
+
const out = [];
|
|
1751
|
+
for (const session of listSessions()) {
|
|
1752
|
+
const archives = listPlanArchives(session.name);
|
|
1753
|
+
for (const a of archives) {
|
|
1754
|
+
const total = a.steps.length;
|
|
1755
|
+
const done = a.completedStepIds.length;
|
|
1756
|
+
const row = {
|
|
1757
|
+
session: session.name,
|
|
1758
|
+
path: a.path,
|
|
1759
|
+
completedAt: a.completedAt,
|
|
1760
|
+
totalSteps: total,
|
|
1761
|
+
completedSteps: done,
|
|
1762
|
+
completionRatio: total > 0 ? done / total : 0,
|
|
1763
|
+
steps: a.steps,
|
|
1764
|
+
completedStepIds: a.completedStepIds
|
|
1765
|
+
};
|
|
1766
|
+
if (a.summary) row.summary = a.summary;
|
|
1767
|
+
out.push(row);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
out.sort((a, b) => b.completedAt.localeCompare(a.completedAt));
|
|
1771
|
+
return { status: 200, body: { plans: out } };
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// src/server/api/semantic.ts
|
|
1775
|
+
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readSync as readSync2 } from "fs";
|
|
1776
|
+
import { join as join5 } from "path";
|
|
1777
|
+
var JOBS = /* @__PURE__ */ new Map();
|
|
1778
|
+
var PULLS = /* @__PURE__ */ new Map();
|
|
1779
|
+
function getRoot(ctx) {
|
|
1780
|
+
const cwd = ctx.getCurrentCwd?.();
|
|
1781
|
+
return cwd ?? null;
|
|
1782
|
+
}
|
|
1783
|
+
async function handleSemantic(method, rest, body, ctx) {
|
|
1784
|
+
const sub = rest[0] ?? "";
|
|
1785
|
+
if (sub === "" && method === "GET") return await getStatus(ctx);
|
|
1786
|
+
if (sub === "config" && method === "GET") return getSemanticConfig(ctx);
|
|
1787
|
+
if (sub === "config" && method === "POST") return saveSemanticConfigApi(body, ctx);
|
|
1788
|
+
if (sub === "start" && method === "POST") return await startJob(body, ctx);
|
|
1789
|
+
if (sub === "stop" && method === "POST") return await stopJob(ctx);
|
|
1790
|
+
if (sub === "ollama" && method === "POST") {
|
|
1791
|
+
const action = rest[1] ?? "";
|
|
1792
|
+
if (action === "start") return await startDaemon(ctx);
|
|
1793
|
+
if (action === "pull") return await startPull(body, ctx);
|
|
1794
|
+
}
|
|
1795
|
+
if (sub === "search" && method === "POST") return await runSearch(body, ctx);
|
|
1796
|
+
return { status: 404, body: { error: "no such semantic endpoint" } };
|
|
1797
|
+
}
|
|
1798
|
+
async function runSearch(rawBody, ctx) {
|
|
1799
|
+
const root = getRoot(ctx);
|
|
1800
|
+
if (!root) {
|
|
1801
|
+
return { status: 503, body: { error: "search requires an attached code-mode session" } };
|
|
1802
|
+
}
|
|
1803
|
+
let parsed;
|
|
1804
|
+
try {
|
|
1805
|
+
parsed = JSON.parse(rawBody || "{}");
|
|
1806
|
+
} catch {
|
|
1807
|
+
return { status: 400, body: { error: "body must be JSON" } };
|
|
1808
|
+
}
|
|
1809
|
+
const query = typeof parsed.query === "string" ? parsed.query.trim() : "";
|
|
1810
|
+
if (!query) return { status: 400, body: { error: "query required" } };
|
|
1811
|
+
const topK = typeof parsed.topK === "number" && Number.isFinite(parsed.topK) ? Math.max(1, Math.min(16, Math.floor(parsed.topK))) : 8;
|
|
1812
|
+
const minScore = typeof parsed.minScore === "number" && Number.isFinite(parsed.minScore) ? Math.max(0, Math.min(1, parsed.minScore)) : 0.3;
|
|
1813
|
+
const startedAt = Date.now();
|
|
1814
|
+
const embedding = resolveSemanticEmbeddingConfig(ctx.configPath);
|
|
1815
|
+
try {
|
|
1816
|
+
const hits = await querySemantic(root, query, {
|
|
1817
|
+
topK,
|
|
1818
|
+
minScore,
|
|
1819
|
+
configPath: ctx.configPath
|
|
1820
|
+
});
|
|
1821
|
+
if (hits === null) {
|
|
1822
|
+
return { status: 404, body: { error: "no semantic index for this project" } };
|
|
1823
|
+
}
|
|
1824
|
+
return {
|
|
1825
|
+
status: 200,
|
|
1826
|
+
body: {
|
|
1827
|
+
hits: hits.map((h) => ({
|
|
1828
|
+
path: h.entry.path,
|
|
1829
|
+
startLine: h.entry.startLine,
|
|
1830
|
+
endLine: h.entry.endLine,
|
|
1831
|
+
score: h.score,
|
|
1832
|
+
snippet: h.entry.text
|
|
1833
|
+
})),
|
|
1834
|
+
elapsedMs: Date.now() - startedAt,
|
|
1835
|
+
provider: embedding.provider,
|
|
1836
|
+
model: embedding.model
|
|
1837
|
+
}
|
|
1838
|
+
};
|
|
1839
|
+
} catch (err) {
|
|
1840
|
+
return { status: 500, body: { error: err.message } };
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
async function getStatus(ctx) {
|
|
1844
|
+
const root = getRoot(ctx);
|
|
1845
|
+
if (!root) {
|
|
1846
|
+
return {
|
|
1847
|
+
status: 200,
|
|
1848
|
+
body: {
|
|
1849
|
+
attached: false,
|
|
1850
|
+
reason: "Semantic indexing requires a code-mode session \u2014 run `/dashboard` from inside `reasonix code` instead of standalone `reasonix dashboard`."
|
|
1851
|
+
}
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
const config = loadSemanticEmbeddingUserConfig(ctx.configPath);
|
|
1855
|
+
const configView = redactSemanticEmbeddingConfig(config);
|
|
1856
|
+
const resolved = resolveSemanticEmbeddingConfig(ctx.configPath);
|
|
1857
|
+
const [hasIndex, providerStatus, index] = await Promise.all([
|
|
1858
|
+
indexExists(root),
|
|
1859
|
+
getProviderStatusFromConfig(configView),
|
|
1860
|
+
readIndexMeta2(root, { provider: resolved.provider, model: resolved.model })
|
|
1861
|
+
]);
|
|
1862
|
+
const job = JOBS.get(root) ?? null;
|
|
1863
|
+
const pull = providerStatus.kind === "ollama" ? PULLS.get(providerStatus.modelName) ?? null : null;
|
|
1864
|
+
return {
|
|
1865
|
+
status: 200,
|
|
1866
|
+
body: {
|
|
1867
|
+
attached: true,
|
|
1868
|
+
root,
|
|
1869
|
+
provider: configView.provider,
|
|
1870
|
+
providerConfig: configView,
|
|
1871
|
+
providerStatus,
|
|
1872
|
+
index: hasIndex ? index : { exists: false },
|
|
1873
|
+
ollama: providerStatus.kind === "ollama" ? providerStatus : void 0,
|
|
1874
|
+
job: job ? snapshotJob(job) : null,
|
|
1875
|
+
pull: pull ? snapshotPull(pull) : null
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
async function readIndexMeta2(root, current) {
|
|
1880
|
+
const dir = join5(root, INDEX_DIR_NAME);
|
|
1881
|
+
const dataPath = join5(dir, "index.jsonl");
|
|
1882
|
+
const diskMeta = await readIndexMeta(dir);
|
|
1883
|
+
if (!diskMeta) return { exists: false };
|
|
1884
|
+
let chunks = 0;
|
|
1885
|
+
const files = /* @__PURE__ */ new Set();
|
|
1886
|
+
let sizeBytes = 0;
|
|
1887
|
+
try {
|
|
1888
|
+
const fd = openSync2(dataPath, "r");
|
|
1889
|
+
let raw;
|
|
1890
|
+
try {
|
|
1891
|
+
const stat = fstatSync2(fd);
|
|
1892
|
+
sizeBytes = stat.size;
|
|
1893
|
+
const buf = Buffer.alloc(stat.size);
|
|
1894
|
+
let read = 0;
|
|
1895
|
+
while (read < stat.size) {
|
|
1896
|
+
const n = readSync2(fd, buf, read, stat.size - read, read);
|
|
1897
|
+
if (n <= 0) break;
|
|
1898
|
+
read += n;
|
|
1899
|
+
}
|
|
1900
|
+
raw = buf.toString("utf8", 0, read);
|
|
1901
|
+
} finally {
|
|
1902
|
+
closeSync2(fd);
|
|
1903
|
+
}
|
|
1904
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1905
|
+
if (!line.trim()) continue;
|
|
1906
|
+
chunks++;
|
|
1907
|
+
try {
|
|
1908
|
+
const rec = JSON.parse(line);
|
|
1909
|
+
if (typeof rec.p === "string") files.add(rec.p);
|
|
1910
|
+
} catch {
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
} catch {
|
|
1914
|
+
}
|
|
1915
|
+
const mismatch = compareIndexIdentity(diskMeta, current);
|
|
1916
|
+
return {
|
|
1917
|
+
exists: true,
|
|
1918
|
+
provider: diskMeta.provider,
|
|
1919
|
+
chunks,
|
|
1920
|
+
files: files.size,
|
|
1921
|
+
dim: diskMeta.dim ?? 0,
|
|
1922
|
+
sizeBytes,
|
|
1923
|
+
lastBuiltMs: diskMeta.updatedAt ? Date.parse(diskMeta.updatedAt) || 0 : 0,
|
|
1924
|
+
model: diskMeta.model ?? "",
|
|
1925
|
+
builtWith: { provider: diskMeta.provider, model: diskMeta.model },
|
|
1926
|
+
current,
|
|
1927
|
+
compatible: mismatch === null,
|
|
1928
|
+
mismatch
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
function snapshotPull(p) {
|
|
1932
|
+
return {
|
|
1933
|
+
startedAt: p.startedAt,
|
|
1934
|
+
status: p.status,
|
|
1935
|
+
lastLine: p.lastLine,
|
|
1936
|
+
exitCode: p.exitCode
|
|
1937
|
+
};
|
|
1938
|
+
}
|
|
1939
|
+
async function startDaemon(ctx) {
|
|
1940
|
+
const resolved = resolveSemanticEmbeddingConfig(ctx.configPath);
|
|
1941
|
+
if (resolved.provider !== "ollama") {
|
|
1942
|
+
return { status: 409, body: { error: "ollama actions require provider=ollama" } };
|
|
1943
|
+
}
|
|
1944
|
+
const r = await startOllamaDaemon({ baseUrl: resolved.baseUrl, timeoutMs: 15e3 }).catch(
|
|
1945
|
+
(err) => ({
|
|
1946
|
+
ready: false,
|
|
1947
|
+
pid: null,
|
|
1948
|
+
error: err.message
|
|
1949
|
+
})
|
|
1950
|
+
);
|
|
1951
|
+
if ("error" in r) return { status: 500, body: { ready: false, error: r.error } };
|
|
1952
|
+
return { status: r.ready ? 200 : 504, body: r };
|
|
1953
|
+
}
|
|
1954
|
+
async function startPull(body, ctx) {
|
|
1955
|
+
const resolved = resolveSemanticEmbeddingConfig(ctx.configPath);
|
|
1956
|
+
if (resolved.provider !== "ollama") {
|
|
1957
|
+
return { status: 409, body: { error: "ollama actions require provider=ollama" } };
|
|
1958
|
+
}
|
|
1959
|
+
let parsed = {};
|
|
1960
|
+
if (body) {
|
|
1961
|
+
try {
|
|
1962
|
+
parsed = JSON.parse(body);
|
|
1963
|
+
} catch {
|
|
1964
|
+
return { status: 400, body: { error: "invalid JSON body" } };
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
const model = typeof parsed.model === "string" && parsed.model ? parsed.model : resolved.model;
|
|
1968
|
+
const existing = PULLS.get(model);
|
|
1969
|
+
if (existing && existing.status === "pulling") {
|
|
1970
|
+
return {
|
|
1971
|
+
status: 409,
|
|
1972
|
+
body: { error: `${model} is already pulling`, pull: snapshotPull(existing) }
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
const rec = {
|
|
1976
|
+
startedAt: Date.now(),
|
|
1977
|
+
status: "pulling",
|
|
1978
|
+
lastLine: `pulling ${model}\u2026`,
|
|
1979
|
+
exitCode: null
|
|
1980
|
+
};
|
|
1981
|
+
PULLS.set(model, rec);
|
|
1982
|
+
void pullOllamaModel(model, {
|
|
1983
|
+
onLine: (line) => {
|
|
1984
|
+
if (line.trim().length > 0) rec.lastLine = line.trim();
|
|
1985
|
+
}
|
|
1986
|
+
}).then((code) => {
|
|
1987
|
+
rec.exitCode = code;
|
|
1988
|
+
rec.status = code === 0 ? "done" : "error";
|
|
1989
|
+
if (code !== 0 && (!rec.lastLine || !rec.lastLine.toLowerCase().includes("error"))) {
|
|
1990
|
+
rec.lastLine = `ollama pull exited with code ${code}`;
|
|
1991
|
+
}
|
|
1992
|
+
}).catch((err) => {
|
|
1993
|
+
rec.status = "error";
|
|
1994
|
+
rec.lastLine = err.message;
|
|
1995
|
+
});
|
|
1996
|
+
return { status: 202, body: { started: true, pull: snapshotPull(rec) } };
|
|
1997
|
+
}
|
|
1998
|
+
function snapshotJob(j) {
|
|
1999
|
+
return {
|
|
2000
|
+
startedAt: j.startedAt,
|
|
2001
|
+
finishedAt: j.finishedAt ?? null,
|
|
2002
|
+
cancelledAt: j.cancelledAt ?? null,
|
|
2003
|
+
phase: j.phase,
|
|
2004
|
+
lastPhase: j.lastPhase ?? null,
|
|
2005
|
+
rebuild: j.rebuild,
|
|
2006
|
+
filesScanned: j.filesScanned ?? null,
|
|
2007
|
+
filesChanged: j.filesChanged ?? null,
|
|
2008
|
+
filesSkipped: j.filesSkipped ?? null,
|
|
2009
|
+
chunksTotal: j.chunksTotal ?? null,
|
|
2010
|
+
chunksDone: j.chunksDone ?? null,
|
|
2011
|
+
aborted: j.aborted,
|
|
2012
|
+
result: j.result ?? null,
|
|
2013
|
+
error: j.error ?? null
|
|
2014
|
+
};
|
|
2015
|
+
}
|
|
2016
|
+
async function startJob(body, ctx) {
|
|
2017
|
+
const root = getRoot(ctx);
|
|
2018
|
+
if (!root) {
|
|
2019
|
+
return {
|
|
2020
|
+
status: 400,
|
|
2021
|
+
body: { error: "no project root \u2014 only available in attached (code-mode) dashboards" }
|
|
2022
|
+
};
|
|
2023
|
+
}
|
|
2024
|
+
const existing = JOBS.get(root);
|
|
2025
|
+
if (existing && (existing.phase === "setup" || existing.phase === "scan" || existing.phase === "embed" || existing.phase === "write")) {
|
|
2026
|
+
return {
|
|
2027
|
+
status: 409,
|
|
2028
|
+
body: { error: "an indexing job is already running", job: snapshotJob(existing) }
|
|
2029
|
+
};
|
|
2030
|
+
}
|
|
2031
|
+
let parsed = {};
|
|
2032
|
+
if (body) {
|
|
2033
|
+
try {
|
|
2034
|
+
parsed = JSON.parse(body);
|
|
2035
|
+
} catch {
|
|
2036
|
+
return { status: 400, body: { error: "invalid JSON body" } };
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
const rebuild = parsed.rebuild === true;
|
|
2040
|
+
const job = {
|
|
2041
|
+
startedAt: Date.now(),
|
|
2042
|
+
phase: "setup",
|
|
2043
|
+
lastPhase: "setup",
|
|
2044
|
+
rebuild,
|
|
2045
|
+
aborted: false,
|
|
2046
|
+
controller: new AbortController()
|
|
2047
|
+
};
|
|
2048
|
+
JOBS.set(root, job);
|
|
2049
|
+
void runIndex(root, job, ctx).catch((err) => {
|
|
2050
|
+
job.phase = "error";
|
|
2051
|
+
job.finishedAt = Date.now();
|
|
2052
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
2053
|
+
});
|
|
2054
|
+
const resolved = resolveSemanticEmbeddingConfig(ctx.configPath);
|
|
2055
|
+
return {
|
|
2056
|
+
status: 202,
|
|
2057
|
+
body: {
|
|
2058
|
+
started: true,
|
|
2059
|
+
provider: resolved.provider,
|
|
2060
|
+
model: resolved.model,
|
|
2061
|
+
job: snapshotJob(job)
|
|
2062
|
+
}
|
|
2063
|
+
};
|
|
2064
|
+
}
|
|
2065
|
+
async function runIndex(root, job, ctx) {
|
|
2066
|
+
try {
|
|
2067
|
+
const resolved = resolveSemanticEmbeddingConfig(ctx.configPath);
|
|
2068
|
+
const result = await buildIndex(root, {
|
|
2069
|
+
rebuild: job.rebuild,
|
|
2070
|
+
configPath: ctx.configPath,
|
|
2071
|
+
signal: job.controller.signal,
|
|
2072
|
+
indexConfig: loadIndexConfig(ctx.configPath),
|
|
2073
|
+
onProgress: (p) => {
|
|
2074
|
+
job.phase = p.phase;
|
|
2075
|
+
if (p.phase !== "done") job.lastPhase = p.phase;
|
|
2076
|
+
if (p.filesScanned !== void 0) job.filesScanned = p.filesScanned;
|
|
2077
|
+
if (p.filesChanged !== void 0) job.filesChanged = p.filesChanged;
|
|
2078
|
+
if (p.filesSkipped !== void 0) job.filesSkipped = p.filesSkipped;
|
|
2079
|
+
if (p.chunksTotal !== void 0) job.chunksTotal = p.chunksTotal;
|
|
2080
|
+
if (p.chunksDone !== void 0) job.chunksDone = p.chunksDone;
|
|
2081
|
+
}
|
|
2082
|
+
});
|
|
2083
|
+
job.phase = "done";
|
|
2084
|
+
job.finishedAt = Date.now();
|
|
2085
|
+
job.result = result;
|
|
2086
|
+
if (ctx.tools && ctx.addToolToPrefix) {
|
|
2087
|
+
try {
|
|
2088
|
+
const added = await registerSemanticSearchTool(ctx.tools, { root, ...resolved });
|
|
2089
|
+
if (added) {
|
|
2090
|
+
const spec = ctx.tools.specs().find((s) => s.function.name === "semantic_search");
|
|
2091
|
+
if (spec) ctx.addToolToPrefix(spec);
|
|
2092
|
+
}
|
|
2093
|
+
} catch {
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
} catch (err) {
|
|
2097
|
+
if (isAbortError(err)) {
|
|
2098
|
+
job.phase = "cancelled";
|
|
2099
|
+
job.cancelledAt = Date.now();
|
|
2100
|
+
job.finishedAt = job.cancelledAt;
|
|
2101
|
+
job.error = void 0;
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
job.phase = "error";
|
|
2105
|
+
job.finishedAt = Date.now();
|
|
2106
|
+
job.error = err instanceof Error ? err.message : String(err);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
async function stopJob(ctx) {
|
|
2110
|
+
const root = getRoot(ctx);
|
|
2111
|
+
if (!root) return { status: 400, body: { error: "no project root" } };
|
|
2112
|
+
const job = JOBS.get(root);
|
|
2113
|
+
if (!job || job.phase === "done" || job.phase === "error" || job.phase === "cancelled") {
|
|
2114
|
+
return { status: 404, body: { error: "no running job" } };
|
|
2115
|
+
}
|
|
2116
|
+
job.aborted = true;
|
|
2117
|
+
job.controller.abort(new Error("semantic indexing aborted"));
|
|
2118
|
+
return { status: 202, body: { stopping: true, job: snapshotJob(job) } };
|
|
2119
|
+
}
|
|
2120
|
+
function getSemanticConfig(ctx) {
|
|
2121
|
+
return {
|
|
2122
|
+
status: 200,
|
|
2123
|
+
body: redactSemanticEmbeddingConfig(loadSemanticEmbeddingUserConfig(ctx.configPath))
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
function saveSemanticConfigApi(rawBody, ctx) {
|
|
2127
|
+
let parsed;
|
|
2128
|
+
try {
|
|
2129
|
+
parsed = JSON.parse(rawBody || "{}");
|
|
2130
|
+
} catch {
|
|
2131
|
+
return { status: 400, body: { error: "body must be JSON" } };
|
|
2132
|
+
}
|
|
2133
|
+
const existing = loadSemanticEmbeddingUserConfig(ctx.configPath);
|
|
2134
|
+
const next = {
|
|
2135
|
+
provider: parsed.provider === "openai-compat" ? "openai-compat" : "ollama",
|
|
2136
|
+
ollama: {
|
|
2137
|
+
baseUrl: typeof parsed.ollama?.baseUrl === "string" ? parsed.ollama.baseUrl : existing.ollama?.baseUrl,
|
|
2138
|
+
model: typeof parsed.ollama?.model === "string" ? parsed.ollama.model : existing.ollama?.model
|
|
2139
|
+
},
|
|
2140
|
+
openaiCompat: {
|
|
2141
|
+
baseUrl: typeof parsed.openaiCompat?.baseUrl === "string" ? parsed.openaiCompat.baseUrl : existing.openaiCompat?.baseUrl,
|
|
2142
|
+
apiKey: typeof parsed.openaiCompat?.apiKey === "string" ? parsed.openaiCompat.apiKey.trim() || existing.openaiCompat?.apiKey : existing.openaiCompat?.apiKey,
|
|
2143
|
+
model: typeof parsed.openaiCompat?.model === "string" ? parsed.openaiCompat.model : existing.openaiCompat?.model,
|
|
2144
|
+
extraBody: parsed.openaiCompat?.extraBody === void 0 ? existing.openaiCompat?.extraBody : parsed.openaiCompat.extraBody
|
|
2145
|
+
}
|
|
2146
|
+
};
|
|
2147
|
+
try {
|
|
2148
|
+
saveSemanticEmbeddingConfig(next, ctx.configPath);
|
|
2149
|
+
} catch (err) {
|
|
2150
|
+
return { status: 400, body: { error: err.message } };
|
|
2151
|
+
}
|
|
2152
|
+
ctx.audit?.({
|
|
2153
|
+
ts: Date.now(),
|
|
2154
|
+
action: "set-semantic-config",
|
|
2155
|
+
payload: { provider: next.provider }
|
|
2156
|
+
});
|
|
2157
|
+
return {
|
|
2158
|
+
status: 200,
|
|
2159
|
+
body: {
|
|
2160
|
+
changed: collectSemanticConfigChanges(existing, next),
|
|
2161
|
+
config: redactSemanticEmbeddingConfig(loadSemanticEmbeddingUserConfig(ctx.configPath))
|
|
2162
|
+
}
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
function collectSemanticConfigChanges(before, after) {
|
|
2166
|
+
const left = JSON.stringify(before);
|
|
2167
|
+
const right = JSON.stringify(after);
|
|
2168
|
+
if (left === right) return [];
|
|
2169
|
+
return ["semantic"];
|
|
2170
|
+
}
|
|
2171
|
+
async function getProviderStatusFromConfig(config) {
|
|
2172
|
+
if (config.provider === "openai-compat") {
|
|
2173
|
+
return {
|
|
2174
|
+
kind: "openai-compat",
|
|
2175
|
+
ready: Boolean(
|
|
2176
|
+
config.openaiCompat.baseUrl && config.openaiCompat.apiKeySet && config.openaiCompat.model
|
|
2177
|
+
),
|
|
2178
|
+
baseUrl: config.openaiCompat.baseUrl,
|
|
2179
|
+
apiKeySet: config.openaiCompat.apiKeySet,
|
|
2180
|
+
model: config.openaiCompat.model,
|
|
2181
|
+
extraBodyKeys: Object.keys(config.openaiCompat.extraBody)
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
const ollama = await checkOllamaStatus(config.ollama.model, config.ollama.baseUrl).catch(
|
|
2185
|
+
(err) => ({
|
|
2186
|
+
binaryFound: false,
|
|
2187
|
+
daemonRunning: false,
|
|
2188
|
+
modelPulled: false,
|
|
2189
|
+
modelName: config.ollama.model,
|
|
2190
|
+
installedModels: [],
|
|
2191
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2192
|
+
})
|
|
2193
|
+
);
|
|
2194
|
+
return {
|
|
2195
|
+
kind: "ollama",
|
|
2196
|
+
ready: ollama.binaryFound && ollama.daemonRunning && ollama.modelPulled,
|
|
2197
|
+
baseUrl: config.ollama.baseUrl,
|
|
2198
|
+
...ollama
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
function isAbortError(err) {
|
|
2202
|
+
if (err instanceof Error) {
|
|
2203
|
+
if (err.name === "AbortError") return true;
|
|
2204
|
+
if (/aborted/i.test(err.message)) return true;
|
|
2205
|
+
}
|
|
2206
|
+
return false;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// src/server/api/sessions.ts
|
|
2210
|
+
import { existsSync as existsSync7, readFileSync as readFileSync4 } from "fs";
|
|
2211
|
+
function parseTranscript(path, maxBytes = 4 * 1024 * 1024) {
|
|
2212
|
+
let raw;
|
|
2213
|
+
try {
|
|
2214
|
+
raw = readFileSync4(path, "utf8");
|
|
2215
|
+
} catch {
|
|
2216
|
+
return [];
|
|
2217
|
+
}
|
|
2218
|
+
if (raw.length > maxBytes) raw = raw.slice(0, maxBytes);
|
|
2219
|
+
const out = [];
|
|
2220
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
2221
|
+
if (!line.trim()) continue;
|
|
2222
|
+
try {
|
|
2223
|
+
const rec = JSON.parse(line);
|
|
2224
|
+
const role = typeof rec.role === "string" ? rec.role : "unknown";
|
|
2225
|
+
const msg = { role };
|
|
2226
|
+
if (typeof rec.content === "string") msg.content = rec.content;
|
|
2227
|
+
else if (rec.content !== void 0) msg.content = JSON.stringify(rec.content);
|
|
2228
|
+
if (typeof rec.tool_name === "string") msg.toolName = rec.tool_name;
|
|
2229
|
+
if (typeof rec.toolName === "string") msg.toolName = rec.toolName;
|
|
2230
|
+
out.push(msg);
|
|
2231
|
+
} catch {
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
return out;
|
|
2235
|
+
}
|
|
2236
|
+
async function handleSessions(method, rest, _body, _ctx) {
|
|
2237
|
+
if (method !== "GET") {
|
|
2238
|
+
return { status: 405, body: { error: "GET only" } };
|
|
2239
|
+
}
|
|
2240
|
+
if (rest.length === 0) {
|
|
2241
|
+
const sessions = listSessions();
|
|
2242
|
+
return {
|
|
2243
|
+
status: 200,
|
|
2244
|
+
body: {
|
|
2245
|
+
sessions: sessions.map((s) => ({
|
|
2246
|
+
name: s.name,
|
|
2247
|
+
path: s.path,
|
|
2248
|
+
size: s.size,
|
|
2249
|
+
messageCount: s.messageCount,
|
|
2250
|
+
mtime: s.mtime.getTime()
|
|
2251
|
+
}))
|
|
2252
|
+
}
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
const name = decodeURIComponent(rest[0]);
|
|
2256
|
+
const path = sessionPath(name);
|
|
2257
|
+
if (!existsSync7(path)) {
|
|
2258
|
+
return { status: 404, body: { error: `no such session: ${name}` } };
|
|
2259
|
+
}
|
|
2260
|
+
const messages = parseTranscript(path);
|
|
2261
|
+
return {
|
|
2262
|
+
status: 200,
|
|
2263
|
+
body: {
|
|
2264
|
+
name,
|
|
2265
|
+
path,
|
|
2266
|
+
messages,
|
|
2267
|
+
messageCount: messages.length
|
|
2268
|
+
}
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
// src/server/api/settings.ts
|
|
2273
|
+
function parseBody9(raw) {
|
|
2274
|
+
if (!raw) return {};
|
|
2275
|
+
try {
|
|
2276
|
+
const parsed = JSON.parse(raw);
|
|
2277
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
2278
|
+
} catch {
|
|
2279
|
+
return {};
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
var VALID_PRESETS = /* @__PURE__ */ new Set(["auto", "flash", "pro", "fast", "smart", "max"]);
|
|
2283
|
+
var VALID_EFFORTS = /* @__PURE__ */ new Set(["high", "max"]);
|
|
2284
|
+
async function handleSettings(method, _rest, body, ctx) {
|
|
2285
|
+
if (method === "GET") {
|
|
2286
|
+
const cfg = readConfig(ctx.configPath);
|
|
2287
|
+
const live = ctx.loop;
|
|
2288
|
+
return {
|
|
2289
|
+
status: 200,
|
|
2290
|
+
body: {
|
|
2291
|
+
apiKey: cfg.apiKey ? redactKey(cfg.apiKey) : null,
|
|
2292
|
+
apiKeySet: Boolean(cfg.apiKey),
|
|
2293
|
+
baseUrl: cfg.baseUrl ?? null,
|
|
2294
|
+
lang: getLanguage(),
|
|
2295
|
+
preset: cfg.preset ?? "auto",
|
|
2296
|
+
reasoningEffort: cfg.reasoningEffort ?? "max",
|
|
2297
|
+
search: cfg.search !== false,
|
|
2298
|
+
editMode: cfg.editMode ?? "review",
|
|
2299
|
+
session: cfg.session ?? null,
|
|
2300
|
+
model: live?.model ?? null,
|
|
2301
|
+
proNext: live?.proArmed ?? false,
|
|
2302
|
+
budgetUsd: live?.budgetUsd ?? null,
|
|
2303
|
+
sessionSpendUsd: ctx.getStats?.()?.totalCostUsd ?? null,
|
|
2304
|
+
// Hint to the SPA which fields require restart.
|
|
2305
|
+
appliesAt: {
|
|
2306
|
+
apiKey: "next-session",
|
|
2307
|
+
baseUrl: "next-session",
|
|
2308
|
+
preset: "next-session",
|
|
2309
|
+
reasoningEffort: "next-turn",
|
|
2310
|
+
search: "next-session",
|
|
2311
|
+
model: "next-turn",
|
|
2312
|
+
proNext: "next-turn",
|
|
2313
|
+
budgetUsd: "live"
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
};
|
|
2317
|
+
}
|
|
2318
|
+
if (method === "POST") {
|
|
2319
|
+
const fields = parseBody9(body);
|
|
2320
|
+
const cfg = readConfig(ctx.configPath);
|
|
2321
|
+
const changed = [];
|
|
2322
|
+
let langPending = null;
|
|
2323
|
+
let presetPendingLive = null;
|
|
2324
|
+
let effortPendingLive = null;
|
|
2325
|
+
if (fields.lang !== void 0) {
|
|
2326
|
+
const raw = String(fields.lang);
|
|
2327
|
+
const supported = getSupportedLanguages();
|
|
2328
|
+
const langCode = supported.find((l) => l.toLowerCase() === raw.toLowerCase());
|
|
2329
|
+
if (!langCode) {
|
|
2330
|
+
return { status: 400, body: { error: `lang must be one of: ${supported.join(", ")}` } };
|
|
2331
|
+
}
|
|
2332
|
+
cfg.lang = langCode;
|
|
2333
|
+
langPending = langCode;
|
|
2334
|
+
changed.push("lang");
|
|
2335
|
+
}
|
|
2336
|
+
if (fields.apiKey !== void 0) {
|
|
2337
|
+
if (typeof fields.apiKey !== "string" || !isPlausibleKey(fields.apiKey)) {
|
|
2338
|
+
return { status: 400, body: { error: "apiKey must be a plausible sk- token" } };
|
|
2339
|
+
}
|
|
2340
|
+
cfg.apiKey = fields.apiKey.trim();
|
|
2341
|
+
changed.push("apiKey");
|
|
2342
|
+
}
|
|
2343
|
+
if (fields.baseUrl !== void 0) {
|
|
2344
|
+
if (typeof fields.baseUrl !== "string" || !fields.baseUrl.trim()) {
|
|
2345
|
+
return { status: 400, body: { error: "baseUrl must be a non-empty string" } };
|
|
2346
|
+
}
|
|
2347
|
+
cfg.baseUrl = fields.baseUrl.trim();
|
|
2348
|
+
changed.push("baseUrl");
|
|
2349
|
+
}
|
|
2350
|
+
if (fields.preset !== void 0) {
|
|
2351
|
+
if (typeof fields.preset !== "string" || !VALID_PRESETS.has(fields.preset)) {
|
|
2352
|
+
return { status: 400, body: { error: "preset must be auto | flash | pro" } };
|
|
2353
|
+
}
|
|
2354
|
+
cfg.preset = fields.preset;
|
|
2355
|
+
presetPendingLive = fields.preset;
|
|
2356
|
+
changed.push("preset");
|
|
2357
|
+
}
|
|
2358
|
+
if (fields.reasoningEffort !== void 0) {
|
|
2359
|
+
if (typeof fields.reasoningEffort !== "string" || !VALID_EFFORTS.has(fields.reasoningEffort)) {
|
|
2360
|
+
return { status: 400, body: { error: "reasoningEffort must be high | max" } };
|
|
2361
|
+
}
|
|
2362
|
+
cfg.reasoningEffort = fields.reasoningEffort;
|
|
2363
|
+
effortPendingLive = fields.reasoningEffort;
|
|
2364
|
+
changed.push("reasoningEffort");
|
|
2365
|
+
}
|
|
2366
|
+
if (fields.search !== void 0) {
|
|
2367
|
+
if (typeof fields.search !== "boolean") {
|
|
2368
|
+
return { status: 400, body: { error: "search must be a boolean" } };
|
|
2369
|
+
}
|
|
2370
|
+
cfg.search = fields.search;
|
|
2371
|
+
changed.push("search");
|
|
2372
|
+
}
|
|
2373
|
+
let modelPendingLive = null;
|
|
2374
|
+
let proNextPending = null;
|
|
2375
|
+
let budgetPending;
|
|
2376
|
+
if (fields.model !== void 0) {
|
|
2377
|
+
if (typeof fields.model !== "string" || !fields.model.trim()) {
|
|
2378
|
+
return { status: 400, body: { error: "model must be a non-empty string" } };
|
|
2379
|
+
}
|
|
2380
|
+
modelPendingLive = fields.model.trim();
|
|
2381
|
+
changed.push("model");
|
|
2382
|
+
}
|
|
2383
|
+
if (fields.proNext !== void 0) {
|
|
2384
|
+
if (typeof fields.proNext !== "boolean") {
|
|
2385
|
+
return { status: 400, body: { error: "proNext must be a boolean" } };
|
|
2386
|
+
}
|
|
2387
|
+
proNextPending = fields.proNext;
|
|
2388
|
+
changed.push("proNext");
|
|
2389
|
+
}
|
|
2390
|
+
if (fields.budgetUsd !== void 0) {
|
|
2391
|
+
if (fields.budgetUsd === null) {
|
|
2392
|
+
budgetPending = null;
|
|
2393
|
+
} else if (typeof fields.budgetUsd === "number" && fields.budgetUsd > 0 && Number.isFinite(fields.budgetUsd)) {
|
|
2394
|
+
budgetPending = fields.budgetUsd;
|
|
2395
|
+
} else {
|
|
2396
|
+
return {
|
|
2397
|
+
status: 400,
|
|
2398
|
+
body: { error: "budgetUsd must be null or a positive finite number" }
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
changed.push("budgetUsd");
|
|
2402
|
+
}
|
|
2403
|
+
if (changed.length > 0) {
|
|
2404
|
+
writeConfig(cfg, ctx.configPath);
|
|
2405
|
+
if (langPending) setLanguage(langPending);
|
|
2406
|
+
if (presetPendingLive) ctx.applyPresetLive?.(presetPendingLive);
|
|
2407
|
+
if (effortPendingLive) ctx.applyEffortLive?.(effortPendingLive);
|
|
2408
|
+
if (modelPendingLive) ctx.applyModelLive?.(modelPendingLive);
|
|
2409
|
+
if (proNextPending !== null) ctx.setProNextLive?.(proNextPending);
|
|
2410
|
+
if (budgetPending !== void 0) ctx.setBudgetUsdLive?.(budgetPending);
|
|
2411
|
+
ctx.audit?.({ ts: Date.now(), action: "set-settings", payload: { fields: changed } });
|
|
2412
|
+
}
|
|
2413
|
+
return { status: 200, body: { changed } };
|
|
2414
|
+
}
|
|
2415
|
+
return { status: 405, body: { error: "GET or POST only" } };
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// src/server/api/skills.ts
|
|
2419
|
+
import {
|
|
2420
|
+
closeSync as closeSync3,
|
|
2421
|
+
existsSync as existsSync8,
|
|
2422
|
+
fstatSync as fstatSync3,
|
|
2423
|
+
mkdirSync as mkdirSync3,
|
|
2424
|
+
openSync as openSync3,
|
|
2425
|
+
readFileSync as readFileSync5,
|
|
2426
|
+
readSync as readSync3,
|
|
2427
|
+
readdirSync as readdirSync4,
|
|
2428
|
+
rmSync,
|
|
2429
|
+
writeFileSync as writeFileSync3
|
|
2430
|
+
} from "fs";
|
|
2431
|
+
import { homedir as homedir3 } from "os";
|
|
2432
|
+
import { dirname as dirname4, join as join6 } from "path";
|
|
2433
|
+
function parseBody10(raw) {
|
|
2434
|
+
if (!raw) return {};
|
|
2435
|
+
try {
|
|
2436
|
+
const parsed = JSON.parse(raw);
|
|
2437
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
2438
|
+
} catch {
|
|
2439
|
+
return {};
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
var SAFE_NAME2 = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
2443
|
+
function globalSkillsDir() {
|
|
2444
|
+
return join6(homedir3(), ".reasonix", SKILLS_DIRNAME);
|
|
2445
|
+
}
|
|
2446
|
+
function projectSkillsDir(rootDir) {
|
|
2447
|
+
return join6(rootDir, ".reasonix", SKILLS_DIRNAME);
|
|
2448
|
+
}
|
|
2449
|
+
function parseFrontmatterDescription(raw) {
|
|
2450
|
+
const lines = raw.split(/\r?\n/);
|
|
2451
|
+
if (lines[0] !== "---") return void 0;
|
|
2452
|
+
for (let i = 1; i < lines.length; i++) {
|
|
2453
|
+
if (lines[i] === "---") break;
|
|
2454
|
+
const m = lines[i].match(/^description:\s*(.*)$/);
|
|
2455
|
+
if (m) return m[1].trim();
|
|
2456
|
+
}
|
|
2457
|
+
return void 0;
|
|
2458
|
+
}
|
|
2459
|
+
function listSkills(dir, scope) {
|
|
2460
|
+
if (!existsSync8(dir)) return [];
|
|
2461
|
+
const out = [];
|
|
2462
|
+
try {
|
|
2463
|
+
for (const entry of readdirSync4(dir)) {
|
|
2464
|
+
if (!SAFE_NAME2.test(entry)) continue;
|
|
2465
|
+
const skillPath = join6(dir, entry, SKILL_FILE);
|
|
2466
|
+
try {
|
|
2467
|
+
const fd = openSync3(skillPath, "r");
|
|
2468
|
+
let stat;
|
|
2469
|
+
let raw;
|
|
2470
|
+
try {
|
|
2471
|
+
stat = fstatSync3(fd);
|
|
2472
|
+
const buf = Buffer.alloc(stat.size);
|
|
2473
|
+
let read = 0;
|
|
2474
|
+
while (read < stat.size) {
|
|
2475
|
+
const n = readSync3(fd, buf, read, stat.size - read, read);
|
|
2476
|
+
if (n <= 0) break;
|
|
2477
|
+
read += n;
|
|
2478
|
+
}
|
|
2479
|
+
raw = buf.toString("utf8", 0, read);
|
|
2480
|
+
} finally {
|
|
2481
|
+
closeSync3(fd);
|
|
2482
|
+
}
|
|
2483
|
+
const item = {
|
|
2484
|
+
name: entry,
|
|
2485
|
+
scope,
|
|
2486
|
+
path: skillPath,
|
|
2487
|
+
size: stat.size,
|
|
2488
|
+
mtime: stat.mtime.getTime()
|
|
2489
|
+
};
|
|
2490
|
+
const desc = parseFrontmatterDescription(raw);
|
|
2491
|
+
if (desc) item.description = desc;
|
|
2492
|
+
out.push(item);
|
|
2493
|
+
} catch {
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
} catch {
|
|
2497
|
+
}
|
|
2498
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
2499
|
+
}
|
|
2500
|
+
function countSubagentRuns(usageLogPath) {
|
|
2501
|
+
const cutoff = Date.now() - 7 * 864e5;
|
|
2502
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2503
|
+
for (const r of readUsageLog(usageLogPath)) {
|
|
2504
|
+
if (r.kind !== "subagent" || r.ts < cutoff) continue;
|
|
2505
|
+
const skill = r.subagent?.skillName?.trim();
|
|
2506
|
+
if (!skill) continue;
|
|
2507
|
+
counts.set(skill, (counts.get(skill) ?? 0) + 1);
|
|
2508
|
+
}
|
|
2509
|
+
return counts;
|
|
2510
|
+
}
|
|
2511
|
+
async function handleSkills(method, rest, body, ctx) {
|
|
2512
|
+
const cwd = ctx.getCurrentCwd?.();
|
|
2513
|
+
if (method === "GET" && rest.length === 0) {
|
|
2514
|
+
const runs7d = countSubagentRuns(ctx.usageLogPath);
|
|
2515
|
+
const tag = (rows) => rows.map((r) => ({ ...r, runs7d: runs7d.get(r.name) ?? 0 }));
|
|
2516
|
+
return {
|
|
2517
|
+
status: 200,
|
|
2518
|
+
body: {
|
|
2519
|
+
global: tag(listSkills(globalSkillsDir(), "global")),
|
|
2520
|
+
project: cwd ? tag(listSkills(projectSkillsDir(cwd), "project")) : [],
|
|
2521
|
+
builtin: [
|
|
2522
|
+
{
|
|
2523
|
+
name: "explore",
|
|
2524
|
+
scope: "builtin",
|
|
2525
|
+
description: "subagent \u2014 broad codebase survey",
|
|
2526
|
+
runs7d: runs7d.get("explore") ?? 0
|
|
2527
|
+
},
|
|
2528
|
+
{
|
|
2529
|
+
name: "research",
|
|
2530
|
+
scope: "builtin",
|
|
2531
|
+
description: "subagent \u2014 deep web + repo research",
|
|
2532
|
+
runs7d: runs7d.get("research") ?? 0
|
|
2533
|
+
}
|
|
2534
|
+
],
|
|
2535
|
+
paths: {
|
|
2536
|
+
global: globalSkillsDir(),
|
|
2537
|
+
project: cwd ? projectSkillsDir(cwd) : null
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
const [scope, ...nameParts] = rest;
|
|
2543
|
+
const name = nameParts.join("/");
|
|
2544
|
+
if (!scope || !name || !SAFE_NAME2.test(name)) {
|
|
2545
|
+
return { status: 400, body: { error: "expected /api/skills/<scope>/<name>" } };
|
|
2546
|
+
}
|
|
2547
|
+
if (scope !== "project" && scope !== "global") {
|
|
2548
|
+
return {
|
|
2549
|
+
status: 400,
|
|
2550
|
+
body: { error: "scope must be project | global (builtin is read-only)" }
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
let dir;
|
|
2554
|
+
if (scope === "project") {
|
|
2555
|
+
if (!cwd) {
|
|
2556
|
+
return {
|
|
2557
|
+
status: 503,
|
|
2558
|
+
body: { error: "no active project \u2014 open `/dashboard` from `reasonix code`" }
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
dir = projectSkillsDir(cwd);
|
|
2562
|
+
} else {
|
|
2563
|
+
dir = globalSkillsDir();
|
|
2564
|
+
}
|
|
2565
|
+
const skillPath = join6(dir, name, SKILL_FILE);
|
|
2566
|
+
if (method === "GET") {
|
|
2567
|
+
if (!existsSync8(skillPath)) return { status: 404, body: { error: "skill not found" } };
|
|
2568
|
+
return { status: 200, body: { path: skillPath, body: readFileSync5(skillPath, "utf8") } };
|
|
2569
|
+
}
|
|
2570
|
+
if (method === "POST") {
|
|
2571
|
+
const { body: contents } = parseBody10(body);
|
|
2572
|
+
if (typeof contents !== "string") {
|
|
2573
|
+
return { status: 400, body: { error: "body (string) required" } };
|
|
2574
|
+
}
|
|
2575
|
+
mkdirSync3(dirname4(skillPath), { recursive: true });
|
|
2576
|
+
writeFileSync3(skillPath, contents, "utf8");
|
|
2577
|
+
ctx.audit?.({
|
|
2578
|
+
ts: Date.now(),
|
|
2579
|
+
action: "save-skill",
|
|
2580
|
+
payload: { scope, name, path: skillPath }
|
|
2581
|
+
});
|
|
2582
|
+
return { status: 200, body: { saved: true, path: skillPath } };
|
|
2583
|
+
}
|
|
2584
|
+
if (method === "DELETE") {
|
|
2585
|
+
if (!existsSync8(skillPath)) return { status: 404, body: { error: "skill not found" } };
|
|
2586
|
+
rmSync(dirname4(skillPath), { recursive: true, force: true });
|
|
2587
|
+
ctx.audit?.({ ts: Date.now(), action: "delete-skill", payload: { scope, name } });
|
|
2588
|
+
return { status: 200, body: { deleted: true } };
|
|
2589
|
+
}
|
|
2590
|
+
return { status: 405, body: { error: `method ${method} not supported` } };
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// src/server/api/slash.ts
|
|
2594
|
+
async function handleSlash(method, _rest, _body, ctx) {
|
|
2595
|
+
if (method !== "GET") return { status: 405, body: { error: "GET only" } };
|
|
2596
|
+
const codeMode = ctx.getCurrentCwd?.() != null;
|
|
2597
|
+
const commands = SLASH_COMMANDS.filter((c) => c.contextual !== "code" || codeMode).map((c) => ({
|
|
2598
|
+
cmd: c.cmd,
|
|
2599
|
+
summary: c.summary,
|
|
2600
|
+
argsHint: c.argsHint,
|
|
2601
|
+
contextual: c.contextual,
|
|
2602
|
+
aliases: c.aliases
|
|
2603
|
+
}));
|
|
2604
|
+
return { status: 200, body: { commands, codeMode } };
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// src/server/api/submit.ts
|
|
2608
|
+
function parseBody11(raw) {
|
|
2609
|
+
if (!raw) return {};
|
|
2610
|
+
try {
|
|
2611
|
+
const parsed = JSON.parse(raw);
|
|
2612
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
2613
|
+
} catch {
|
|
2614
|
+
return {};
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
async function handleSubmit(method, _rest, body, ctx) {
|
|
2618
|
+
if (method !== "POST") {
|
|
2619
|
+
return { status: 405, body: { error: "POST only" } };
|
|
2620
|
+
}
|
|
2621
|
+
if (!ctx.submitPrompt) {
|
|
2622
|
+
return {
|
|
2623
|
+
status: 503,
|
|
2624
|
+
body: {
|
|
2625
|
+
error: "submit requires an attached dashboard session \u2014 open `/dashboard` from inside `reasonix code` or `reasonix chat`."
|
|
2626
|
+
}
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
const { prompt } = parseBody11(body);
|
|
2630
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
2631
|
+
return { status: 400, body: { error: "prompt (non-empty string) required" } };
|
|
2632
|
+
}
|
|
2633
|
+
const result = ctx.submitPrompt(prompt);
|
|
2634
|
+
if (!result.accepted) {
|
|
2635
|
+
return {
|
|
2636
|
+
status: 409,
|
|
2637
|
+
body: { accepted: false, reason: result.reason ?? "loop is busy" }
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
ctx.audit?.({
|
|
2641
|
+
ts: Date.now(),
|
|
2642
|
+
action: "submit-prompt",
|
|
2643
|
+
payload: { length: prompt.length }
|
|
2644
|
+
});
|
|
2645
|
+
return { status: 202, body: { accepted: true } };
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
// src/server/api/tools.ts
|
|
2649
|
+
async function handleTools(method, _rest, _body, ctx) {
|
|
2650
|
+
if (method !== "GET") {
|
|
2651
|
+
return { status: 405, body: { error: "GET only" } };
|
|
2652
|
+
}
|
|
2653
|
+
if (!ctx.tools) {
|
|
2654
|
+
return {
|
|
2655
|
+
status: 503,
|
|
2656
|
+
body: {
|
|
2657
|
+
error: "live tools view requires an attached session \u2014 run `/dashboard` from inside `reasonix code` instead of standalone `reasonix dashboard`.",
|
|
2658
|
+
available: false
|
|
2659
|
+
}
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2662
|
+
const specs = ctx.tools.specs();
|
|
2663
|
+
const items = specs.map((s) => {
|
|
2664
|
+
const def = ctx.tools.get(s.function.name);
|
|
2665
|
+
return {
|
|
2666
|
+
name: s.function.name,
|
|
2667
|
+
description: s.function.description,
|
|
2668
|
+
schema: s.function.parameters,
|
|
2669
|
+
readOnly: Boolean(def?.readOnly),
|
|
2670
|
+
flattened: ctx.tools.wasFlattened(s.function.name)
|
|
2671
|
+
};
|
|
2672
|
+
});
|
|
2673
|
+
return {
|
|
2674
|
+
status: 200,
|
|
2675
|
+
body: {
|
|
2676
|
+
planMode: ctx.tools.planMode,
|
|
2677
|
+
total: items.length,
|
|
2678
|
+
tools: items
|
|
2679
|
+
}
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
// src/server/api/usage.ts
|
|
2684
|
+
function dayKey(ts) {
|
|
2685
|
+
return new Date(ts).toISOString().slice(0, 10);
|
|
2686
|
+
}
|
|
2687
|
+
function buildSeries(records) {
|
|
2688
|
+
const map = /* @__PURE__ */ new Map();
|
|
2689
|
+
for (const r of records) {
|
|
2690
|
+
const day = dayKey(r.ts);
|
|
2691
|
+
let b = map.get(day);
|
|
2692
|
+
if (!b) {
|
|
2693
|
+
b = {
|
|
2694
|
+
day,
|
|
2695
|
+
turns: 0,
|
|
2696
|
+
promptTokens: 0,
|
|
2697
|
+
completionTokens: 0,
|
|
2698
|
+
cacheHitTokens: 0,
|
|
2699
|
+
cacheMissTokens: 0,
|
|
2700
|
+
costUsd: 0,
|
|
2701
|
+
cacheSavingsUsd: 0
|
|
2702
|
+
};
|
|
2703
|
+
map.set(day, b);
|
|
2704
|
+
}
|
|
2705
|
+
b.turns += 1;
|
|
2706
|
+
b.promptTokens += r.promptTokens;
|
|
2707
|
+
b.completionTokens += r.completionTokens;
|
|
2708
|
+
b.cacheHitTokens += r.cacheHitTokens;
|
|
2709
|
+
b.cacheMissTokens += r.cacheMissTokens;
|
|
2710
|
+
b.costUsd += r.costUsd;
|
|
2711
|
+
b.cacheSavingsUsd += cacheSavingsUsd(r.model, r.cacheHitTokens);
|
|
2712
|
+
}
|
|
2713
|
+
return Array.from(map.values()).sort((a, b) => a.day.localeCompare(b.day));
|
|
2714
|
+
}
|
|
2715
|
+
async function handleUsage(method, rest, _body, ctx) {
|
|
2716
|
+
if (method !== "GET") {
|
|
2717
|
+
return { status: 405, body: { error: "GET only" } };
|
|
2718
|
+
}
|
|
2719
|
+
const records = readUsageLog(ctx.usageLogPath);
|
|
2720
|
+
if (rest[0] === "series") {
|
|
2721
|
+
return {
|
|
2722
|
+
status: 200,
|
|
2723
|
+
body: {
|
|
2724
|
+
days: buildSeries(records),
|
|
2725
|
+
recordCount: records.length
|
|
2726
|
+
}
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
const agg = aggregateUsage(records);
|
|
2730
|
+
return {
|
|
2731
|
+
status: 200,
|
|
2732
|
+
body: {
|
|
2733
|
+
logPath: ctx.usageLogPath,
|
|
2734
|
+
logSize: formatLogSize(ctx.usageLogPath),
|
|
2735
|
+
recordCount: records.length,
|
|
2736
|
+
buckets: agg.buckets,
|
|
2737
|
+
byModel: agg.byModel,
|
|
2738
|
+
bySession: agg.bySession,
|
|
2739
|
+
firstSeen: agg.firstSeen,
|
|
2740
|
+
lastSeen: agg.lastSeen,
|
|
2741
|
+
subagents: agg.subagents ?? null
|
|
2742
|
+
}
|
|
2743
|
+
};
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// src/server/router.ts
|
|
2747
|
+
async function handleApi(pathTail, method, body, ctx, query = new URLSearchParams()) {
|
|
2748
|
+
const normalized = pathTail.replace(/\/+$/, "");
|
|
2749
|
+
const [head, ...rest] = normalized.split("/");
|
|
2750
|
+
try {
|
|
2751
|
+
switch (head) {
|
|
2752
|
+
case "overview":
|
|
2753
|
+
return await handleOverview(method, rest, body, ctx);
|
|
2754
|
+
case "usage":
|
|
2755
|
+
return await handleUsage(method, rest, body, ctx);
|
|
2756
|
+
case "tools":
|
|
2757
|
+
return await handleTools(method, rest, body, ctx);
|
|
2758
|
+
case "permissions":
|
|
2759
|
+
return await handlePermissions(method, rest, body, ctx);
|
|
2760
|
+
case "messages":
|
|
2761
|
+
return await handleMessages(method, rest, body, ctx);
|
|
2762
|
+
case "submit":
|
|
2763
|
+
return await handleSubmit(method, rest, body, ctx);
|
|
2764
|
+
case "abort":
|
|
2765
|
+
return await handleAbort(method, rest, body, ctx);
|
|
2766
|
+
case "health":
|
|
2767
|
+
return await handleHealth(method, rest, body, ctx);
|
|
2768
|
+
case "sessions":
|
|
2769
|
+
return await handleSessions(method, rest, body, ctx);
|
|
2770
|
+
case "plans":
|
|
2771
|
+
return await handlePlans(method, rest, body, ctx);
|
|
2772
|
+
case "modal":
|
|
2773
|
+
return await handleModal(method, rest, body, ctx);
|
|
2774
|
+
case "edit-mode":
|
|
2775
|
+
return await handleEditMode(method, rest, body, ctx);
|
|
2776
|
+
case "settings":
|
|
2777
|
+
return await handleSettings(method, rest, body, ctx);
|
|
2778
|
+
case "hooks":
|
|
2779
|
+
return await handleHooks(method, rest, body, ctx);
|
|
2780
|
+
case "memory":
|
|
2781
|
+
return await handleMemory(method, rest, body, ctx);
|
|
2782
|
+
case "skills":
|
|
2783
|
+
return await handleSkills(method, rest, body, ctx);
|
|
2784
|
+
case "mcp":
|
|
2785
|
+
return await handleMcp(method, rest, body, ctx, query);
|
|
2786
|
+
case "semantic":
|
|
2787
|
+
return await handleSemantic(method, rest, body, ctx);
|
|
2788
|
+
case "index-config":
|
|
2789
|
+
return await handleIndexConfig(method, rest, body, ctx);
|
|
2790
|
+
case "slash":
|
|
2791
|
+
return await handleSlash(method, rest, body, ctx);
|
|
2792
|
+
case "files":
|
|
2793
|
+
return await handleFiles(method, rest, body, ctx);
|
|
2794
|
+
case "loop":
|
|
2795
|
+
return await handleLoop(method, rest, body, ctx);
|
|
2796
|
+
case "models":
|
|
2797
|
+
return await handleModels(method, rest, body, ctx);
|
|
2798
|
+
default:
|
|
2799
|
+
return { status: 404, body: { error: `no such endpoint: /${head}` } };
|
|
2800
|
+
}
|
|
2801
|
+
} catch (err) {
|
|
2802
|
+
return {
|
|
2803
|
+
status: 500,
|
|
2804
|
+
body: { error: `handler crashed: ${err.message}` }
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
// src/server/index.ts
|
|
2810
|
+
function mintToken() {
|
|
2811
|
+
return randomBytes(32).toString("hex");
|
|
2812
|
+
}
|
|
2813
|
+
function constantTimeEquals(a, b) {
|
|
2814
|
+
if (a.length !== b.length) return false;
|
|
2815
|
+
let mismatch = 0;
|
|
2816
|
+
for (let i = 0; i < a.length; i++) {
|
|
2817
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
2818
|
+
}
|
|
2819
|
+
return mismatch === 0;
|
|
2820
|
+
}
|
|
2821
|
+
function checkAuth(req, expectedToken, isMutation) {
|
|
2822
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2823
|
+
const queryToken = url.searchParams.get("token") ?? "";
|
|
2824
|
+
const headerToken = typeof req.headers["x-reasonix-token"] === "string" ? req.headers["x-reasonix-token"] : "";
|
|
2825
|
+
if (isMutation) {
|
|
2826
|
+
if (!headerToken || !constantTimeEquals(headerToken, expectedToken)) {
|
|
2827
|
+
return {
|
|
2828
|
+
status: 403,
|
|
2829
|
+
body: JSON.stringify({
|
|
2830
|
+
error: "mutation requires X-Reasonix-Token header (CSRF defence \u2014 query token alone is rejected for POST/DELETE)."
|
|
2831
|
+
})
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2834
|
+
return null;
|
|
2835
|
+
}
|
|
2836
|
+
if (queryToken && constantTimeEquals(queryToken, expectedToken) || headerToken && constantTimeEquals(headerToken, expectedToken)) {
|
|
2837
|
+
return null;
|
|
2838
|
+
}
|
|
2839
|
+
return {
|
|
2840
|
+
status: 401,
|
|
2841
|
+
body: JSON.stringify({ error: "missing or invalid token" })
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
var MAX_BODY_BYTES = 256 * 1024;
|
|
2845
|
+
async function readBody(req) {
|
|
2846
|
+
let total = 0;
|
|
2847
|
+
const chunks = [];
|
|
2848
|
+
return new Promise((resolve, reject) => {
|
|
2849
|
+
req.on("data", (chunk) => {
|
|
2850
|
+
total += chunk.length;
|
|
2851
|
+
if (total > MAX_BODY_BYTES) {
|
|
2852
|
+
reject(new Error(`body exceeds ${MAX_BODY_BYTES} bytes`));
|
|
2853
|
+
req.destroy();
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
chunks.push(chunk);
|
|
2857
|
+
});
|
|
2858
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
2859
|
+
req.on("error", reject);
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2862
|
+
async function dispatch(req, res, ctx, expectedToken) {
|
|
2863
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2864
|
+
const path = url.pathname;
|
|
2865
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
2866
|
+
const isMutation = method === "POST" || method === "DELETE" || method === "PUT";
|
|
2867
|
+
if (path === "/" || path === "/index.html") {
|
|
2868
|
+
const fail = checkAuth(req, expectedToken, false);
|
|
2869
|
+
if (fail) {
|
|
2870
|
+
res.writeHead(fail.status, { "content-type": "text/plain" });
|
|
2871
|
+
res.end("unauthorized \u2014 open the URL printed by /dashboard, including ?token=\u2026");
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
const html = renderIndexHtml(expectedToken, ctx.mode);
|
|
2875
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
2876
|
+
res.end(html);
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
if (path.startsWith("/assets/")) {
|
|
2880
|
+
const fail = checkAuth(req, expectedToken, false);
|
|
2881
|
+
if (fail) {
|
|
2882
|
+
res.writeHead(fail.status);
|
|
2883
|
+
res.end();
|
|
2884
|
+
return;
|
|
2885
|
+
}
|
|
2886
|
+
const asset = serveAsset(path.slice("/assets/".length));
|
|
2887
|
+
if (!asset) {
|
|
2888
|
+
res.writeHead(404);
|
|
2889
|
+
res.end("not found");
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
2892
|
+
res.writeHead(200, { "content-type": asset.contentType });
|
|
2893
|
+
res.end(asset.body);
|
|
2894
|
+
return;
|
|
2895
|
+
}
|
|
2896
|
+
if (path === "/api/events") {
|
|
2897
|
+
const fail = checkAuth(req, expectedToken, false);
|
|
2898
|
+
if (fail) {
|
|
2899
|
+
res.writeHead(fail.status, { "content-type": "application/json" });
|
|
2900
|
+
res.end(fail.body);
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
handleEvents(req, res, ctx);
|
|
2904
|
+
return;
|
|
2905
|
+
}
|
|
2906
|
+
if (path.startsWith("/api/")) {
|
|
2907
|
+
const fail = checkAuth(req, expectedToken, isMutation);
|
|
2908
|
+
if (fail) {
|
|
2909
|
+
res.writeHead(fail.status, { "content-type": "application/json" });
|
|
2910
|
+
res.end(fail.body);
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2913
|
+
let body = "";
|
|
2914
|
+
if (isMutation) {
|
|
2915
|
+
try {
|
|
2916
|
+
body = await readBody(req);
|
|
2917
|
+
} catch (err) {
|
|
2918
|
+
res.writeHead(413, { "content-type": "application/json" });
|
|
2919
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
const result = await handleApi(path.slice("/api/".length), method, body, ctx, url.searchParams);
|
|
2924
|
+
res.writeHead(result.status, { "content-type": "application/json" });
|
|
2925
|
+
res.end(JSON.stringify(result.body));
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
2929
|
+
res.end("not found");
|
|
2930
|
+
}
|
|
2931
|
+
function startDashboardServer(ctx, opts = {}) {
|
|
2932
|
+
const token = opts.token ?? mintToken();
|
|
2933
|
+
const host = opts.host ?? "127.0.0.1";
|
|
2934
|
+
const port = opts.port ?? 0;
|
|
2935
|
+
return new Promise((resolve, reject) => {
|
|
2936
|
+
const server = createServer((req, res) => {
|
|
2937
|
+
dispatch(req, res, ctx, token).catch((err) => {
|
|
2938
|
+
if (!res.headersSent) {
|
|
2939
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
2940
|
+
}
|
|
2941
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2942
|
+
});
|
|
2943
|
+
});
|
|
2944
|
+
server.on("error", reject);
|
|
2945
|
+
server.listen(port, host, () => {
|
|
2946
|
+
const addr = server.address();
|
|
2947
|
+
const finalPort = addr.port;
|
|
2948
|
+
const url = `http://${host}:${finalPort}/?token=${token}`;
|
|
2949
|
+
let closed = false;
|
|
2950
|
+
const close = () => new Promise((doneResolve) => {
|
|
2951
|
+
if (closed) return doneResolve();
|
|
2952
|
+
closed = true;
|
|
2953
|
+
server.close(() => doneResolve());
|
|
2954
|
+
setTimeout(() => server.closeAllConnections?.(), 1e3).unref();
|
|
2955
|
+
});
|
|
2956
|
+
resolve({ url, token, port: finalPort, close });
|
|
2957
|
+
});
|
|
2958
|
+
});
|
|
2959
|
+
}
|
|
2960
|
+
export {
|
|
2961
|
+
checkAuth,
|
|
2962
|
+
constantTimeEquals,
|
|
2963
|
+
dispatch,
|
|
2964
|
+
readBody,
|
|
2965
|
+
startDashboardServer
|
|
2966
|
+
};
|
|
2967
|
+
//# sourceMappingURL=server-SYC3OVOP.js.map
|