@maintainer-pro/ai-cli 0.1.4 → 0.1.6
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/index.cjs +2397 -115
- package/dist/index.d.cts +382 -11
- package/dist/index.d.ts +382 -11
- package/dist/index.js +2350 -121
- package/package.json +5 -3
package/dist/index.js
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
import { execa as execa2 } from "execa";
|
|
3
3
|
|
|
4
4
|
// src/prompt.ts
|
|
5
|
+
var PROMPT_SECTION = {
|
|
6
|
+
system: { begin: "<<<SYSTEM_BEGIN>>>", end: "<<<SYSTEM_END>>>" },
|
|
7
|
+
clientContext: {
|
|
8
|
+
begin: "<<<CLIENT_CONTEXT_BEGIN>>>",
|
|
9
|
+
end: "<<<CLIENT_CONTEXT_END>>>"
|
|
10
|
+
},
|
|
11
|
+
priorConversations: {
|
|
12
|
+
begin: "<<<PRIOR_CONVERSATIONS_BEGIN>>>",
|
|
13
|
+
end: "<<<PRIOR_CONVERSATIONS_END>>>"
|
|
14
|
+
},
|
|
15
|
+
parentChain: {
|
|
16
|
+
begin: "<<<PARENT_CHAIN_BEGIN>>>",
|
|
17
|
+
end: "<<<PARENT_CHAIN_END>>>"
|
|
18
|
+
},
|
|
19
|
+
history: {
|
|
20
|
+
begin: "<<<CONVERSATION_HISTORY_BEGIN>>>",
|
|
21
|
+
end: "<<<CONVERSATION_HISTORY_END>>>"
|
|
22
|
+
},
|
|
23
|
+
attachments: {
|
|
24
|
+
begin: "<<<ATTACHMENTS_BEGIN>>>",
|
|
25
|
+
end: "<<<ATTACHMENTS_END>>>"
|
|
26
|
+
},
|
|
27
|
+
currentRequest: {
|
|
28
|
+
begin: "<<<CURRENT_REQUEST_BEGIN>>>",
|
|
29
|
+
end: "<<<CURRENT_REQUEST_END>>>"
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
function wrapSection(tag, body, title) {
|
|
33
|
+
const trimmed = body.trim();
|
|
34
|
+
if (!trimmed) return "";
|
|
35
|
+
const header = title ? `${tag.begin} ${title}` : tag.begin;
|
|
36
|
+
return `${header}
|
|
37
|
+
${trimmed}
|
|
38
|
+
${tag.end}
|
|
39
|
+
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
5
42
|
function buildConversationPrompt(messages) {
|
|
6
43
|
if (messages.length === 0) return "";
|
|
7
44
|
const lines = messages.map(
|
|
@@ -43,50 +80,67 @@ function splitMessages(messages) {
|
|
|
43
80
|
const history = latestUserIndex > 0 ? messages.slice(0, latestUserIndex) : [];
|
|
44
81
|
return { request, history };
|
|
45
82
|
}
|
|
46
|
-
function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical) {
|
|
83
|
+
function buildUserFacingPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical, parentChainContext) {
|
|
47
84
|
const { request, history } = splitMessages(messages);
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const contextSection =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
85
|
+
const systemSection = wrapSection(
|
|
86
|
+
PROMPT_SECTION.system,
|
|
87
|
+
systemPrompt ?? "",
|
|
88
|
+
"instructions"
|
|
89
|
+
);
|
|
90
|
+
const contextSection = wrapSection(
|
|
91
|
+
PROMPT_SECTION.clientContext,
|
|
92
|
+
formatClientContext(context),
|
|
93
|
+
"live UI snapshot"
|
|
94
|
+
);
|
|
95
|
+
const priorSection = wrapSection(
|
|
96
|
+
PROMPT_SECTION.priorConversations,
|
|
97
|
+
priorConversationsContext ?? "",
|
|
98
|
+
"other chats (secondary)"
|
|
99
|
+
);
|
|
100
|
+
const parentSection = wrapSection(
|
|
101
|
+
PROMPT_SECTION.parentChain,
|
|
102
|
+
parentChainContext ?? "",
|
|
103
|
+
"reply-to parents \u2014 primary thread context"
|
|
104
|
+
);
|
|
105
|
+
const historySection = wrapSection(
|
|
106
|
+
PROMPT_SECTION.history,
|
|
107
|
+
history.length > 0 ? buildConversationPrompt(history) : "",
|
|
108
|
+
"earlier turns in this conversation"
|
|
109
|
+
);
|
|
110
|
+
const attachmentsSection = wrapSection(
|
|
111
|
+
PROMPT_SECTION.attachments,
|
|
112
|
+
attachmentPaths && attachmentPaths.length > 0 ? `Screenshots / images attached by the user (open and inspect these image files; they are stored under ~/.maintainer-pro for this project, not in the host app, and you are allowed to read them):
|
|
113
|
+
${attachmentPaths.map((p) => `- ${p}`).join("\n")}` : "",
|
|
114
|
+
"user attachments"
|
|
115
|
+
);
|
|
116
|
+
const currentSection = wrapSection(
|
|
117
|
+
PROMPT_SECTION.currentRequest,
|
|
118
|
+
request || "(See the attached screenshot(s).)",
|
|
119
|
+
"answer this message"
|
|
120
|
+
);
|
|
66
121
|
const closer = technical ? "Do the work now. Follow the requested output format exactly." : "Do the work now (or ask one short clarifying question if needed). Reply in English, non-technical and user-facing. If it's not resolving, offer a quick call.";
|
|
67
|
-
return `${systemSection}${contextSection}${priorSection}${
|
|
68
|
-
${request || "(See the attached screenshot(s).)"}
|
|
69
|
-
|
|
70
|
-
${closer}`;
|
|
122
|
+
return `${systemSection}${contextSection}${priorSection}${parentSection}${historySection}${attachmentsSection}${currentSection}${closer}`;
|
|
71
123
|
}
|
|
72
|
-
function buildCursorPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical) {
|
|
124
|
+
function buildCursorPrompt(systemPrompt, messages, context, attachmentPaths, priorConversationsContext, technical, parentChainContext) {
|
|
73
125
|
return buildUserFacingPrompt(
|
|
74
126
|
systemPrompt,
|
|
75
127
|
messages,
|
|
76
128
|
context,
|
|
77
129
|
attachmentPaths,
|
|
78
130
|
priorConversationsContext,
|
|
79
|
-
technical
|
|
131
|
+
technical,
|
|
132
|
+
parentChainContext
|
|
80
133
|
);
|
|
81
134
|
}
|
|
82
|
-
function buildClaudeUserPrompt(messages, context, attachmentPaths, priorConversationsContext, technical) {
|
|
135
|
+
function buildClaudeUserPrompt(messages, context, attachmentPaths, priorConversationsContext, technical, parentChainContext) {
|
|
83
136
|
return buildUserFacingPrompt(
|
|
84
137
|
null,
|
|
85
138
|
messages,
|
|
86
139
|
context,
|
|
87
140
|
attachmentPaths,
|
|
88
141
|
priorConversationsContext,
|
|
89
|
-
technical
|
|
142
|
+
technical,
|
|
143
|
+
parentChainContext
|
|
90
144
|
);
|
|
91
145
|
}
|
|
92
146
|
|
|
@@ -200,7 +254,8 @@ async function callClaudeCli(command, messages, context, options) {
|
|
|
200
254
|
context,
|
|
201
255
|
options.attachmentPaths,
|
|
202
256
|
options.priorConversationsContext,
|
|
203
|
-
options.technical
|
|
257
|
+
options.technical,
|
|
258
|
+
options.parentChainContext
|
|
204
259
|
);
|
|
205
260
|
const resolved = resolveCliCommand("claude", command);
|
|
206
261
|
const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
|
|
@@ -240,7 +295,8 @@ async function callCursorCli(command, messages, context, options) {
|
|
|
240
295
|
context,
|
|
241
296
|
options.attachmentPaths,
|
|
242
297
|
options.priorConversationsContext,
|
|
243
|
-
options.technical
|
|
298
|
+
options.technical,
|
|
299
|
+
options.parentChainContext
|
|
244
300
|
);
|
|
245
301
|
const resolved = resolveCliCommand("cursor", command);
|
|
246
302
|
const workspace = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
|
|
@@ -282,7 +338,8 @@ async function callAntigravityCli(command, messages, context, options) {
|
|
|
282
338
|
context,
|
|
283
339
|
options.attachmentPaths,
|
|
284
340
|
options.priorConversationsContext,
|
|
285
|
-
options.technical
|
|
341
|
+
options.technical,
|
|
342
|
+
options.parentChainContext
|
|
286
343
|
);
|
|
287
344
|
const prompt = `You are operating as a coding agent with full permission to read and edit files in this workspace. Do not introduce yourself. Do not ask what you are. Execute the user's latest request now (edit files and/or emit runtime tool JSON as instructed).
|
|
288
345
|
|
|
@@ -323,8 +380,8 @@ async function resolveCommandPath(command) {
|
|
|
323
380
|
}
|
|
324
381
|
const result = await execa2("which", [command], { reject: false });
|
|
325
382
|
if (result.exitCode !== 0) return null;
|
|
326
|
-
const
|
|
327
|
-
return
|
|
383
|
+
const path9 = result.stdout.trim().split(/\r?\n/)[0]?.trim();
|
|
384
|
+
return path9 || null;
|
|
328
385
|
} catch {
|
|
329
386
|
return null;
|
|
330
387
|
}
|
|
@@ -410,6 +467,51 @@ async function callAi(messages, context, options) {
|
|
|
410
467
|
return provider.call(messages, context, options);
|
|
411
468
|
}
|
|
412
469
|
|
|
470
|
+
// src/parent-chain.ts
|
|
471
|
+
function collectParentChain(rows, startId) {
|
|
472
|
+
if (!startId) return [];
|
|
473
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
474
|
+
const chain = [];
|
|
475
|
+
const seen = /* @__PURE__ */ new Set();
|
|
476
|
+
let current = byId.get(startId);
|
|
477
|
+
while (current && !seen.has(current.id)) {
|
|
478
|
+
seen.add(current.id);
|
|
479
|
+
const content = String(current.content ?? "").trim();
|
|
480
|
+
if (content && current.provider !== "working") {
|
|
481
|
+
const role = current.role === "assistant" || current.role === "system" ? current.role : "user";
|
|
482
|
+
chain.unshift({
|
|
483
|
+
id: current.id,
|
|
484
|
+
role,
|
|
485
|
+
content,
|
|
486
|
+
...current.senderName?.trim() ? { senderName: current.senderName.trim() } : {}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const nextId = current.parentMessageId?.trim();
|
|
490
|
+
current = nextId ? byId.get(nextId) : void 0;
|
|
491
|
+
}
|
|
492
|
+
return chain;
|
|
493
|
+
}
|
|
494
|
+
function parentChainForTurn(rows, currentMessageId, fallbackParentId) {
|
|
495
|
+
if (!currentMessageId && !fallbackParentId) return [];
|
|
496
|
+
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
497
|
+
const current = currentMessageId ? byId.get(currentMessageId) : void 0;
|
|
498
|
+
const replyToId = current?.parentMessageId && String(current.parentMessageId).trim() || fallbackParentId && String(fallbackParentId).trim() || null;
|
|
499
|
+
return collectParentChain(rows, replyToId);
|
|
500
|
+
}
|
|
501
|
+
function formatParentChainContext(chain) {
|
|
502
|
+
if (chain.length === 0) return "";
|
|
503
|
+
const lines = [
|
|
504
|
+
"context: this message has parents hierarchy linked with it",
|
|
505
|
+
"Prefer this chain over unrelated queued or later messages when interpreting the current request.",
|
|
506
|
+
""
|
|
507
|
+
];
|
|
508
|
+
chain.forEach((entry, index) => {
|
|
509
|
+
const who = entry.senderName || (entry.role === "assistant" ? "Assistant" : entry.role === "system" ? "System" : "Human");
|
|
510
|
+
lines.push(`${index + 1}. [${who}] ${entry.content}`);
|
|
511
|
+
});
|
|
512
|
+
return lines.join("\n");
|
|
513
|
+
}
|
|
514
|
+
|
|
413
515
|
// src/system-prompt.ts
|
|
414
516
|
function createDefaultSystemPrompt(input) {
|
|
415
517
|
const files = input.relevantFilesHint ? `Key UI files (use internally only; never name them in your reply):
|
|
@@ -420,6 +522,14 @@ Only when the user wants to change live in-app data (not source code), return a
|
|
|
420
522
|
${input.runtimeToolsHint}
|
|
421
523
|
|
|
422
524
|
Only use those runtime tools for live state. Never invent runtime tools.` : "";
|
|
525
|
+
const access = input.ignorePaths !== void 0 ? `
|
|
526
|
+
|
|
527
|
+
## File access boundaries (mandatory)
|
|
528
|
+
- You may only read or edit files inside the current project workspace directory.
|
|
529
|
+
- Never read, write, move, or delete files outside that workspace.
|
|
530
|
+
- Never touch paths that match this ignore list (relative to the workspace):
|
|
531
|
+
${(input.ignorePaths ?? []).filter((p) => !p.startsWith("!")).length ? (input.ignorePaths ?? []).filter((p) => !p.startsWith("!")).map((p) => `- ${p}`).join("\n") : "- (none beyond staying inside the workspace)"}
|
|
532
|
+
- If a request requires something outside the workspace or on the ignore list, refuse that part and explain you can only change allowed project files.` : "";
|
|
423
533
|
return `You are a helpful product assistant for an app the user is looking at right now.
|
|
424
534
|
|
|
425
535
|
${input.productDescription}
|
|
@@ -440,6 +550,7 @@ Behind the scenes you can edit this repository and update live app data, but the
|
|
|
440
550
|
## When to edit the codebase
|
|
441
551
|
Edit source files when the user asks to change labels, layout, styling, copy, or behavior in the app.
|
|
442
552
|
${files}
|
|
553
|
+
${access}
|
|
443
554
|
|
|
444
555
|
${tools}
|
|
445
556
|
|
|
@@ -447,14 +558,257 @@ Do not refuse UI/label changes \u2014 implement them in source files.
|
|
|
447
558
|
Answer the user's latest request directly \u2014 never reply with a generic greeting.`;
|
|
448
559
|
}
|
|
449
560
|
|
|
450
|
-
// src/
|
|
451
|
-
import fs2 from "fs/promises";
|
|
561
|
+
// src/access-policy.ts
|
|
452
562
|
import path2 from "path";
|
|
563
|
+
var DEFAULT_AI_IGNORE_PATHS = [
|
|
564
|
+
".env",
|
|
565
|
+
".env.*",
|
|
566
|
+
"**/.env",
|
|
567
|
+
"**/.env.*"
|
|
568
|
+
];
|
|
569
|
+
function parseIgnorePathsEnv(raw) {
|
|
570
|
+
if (!raw?.trim()) return [];
|
|
571
|
+
const trimmed = raw.trim();
|
|
572
|
+
try {
|
|
573
|
+
const parsed = JSON.parse(trimmed);
|
|
574
|
+
if (Array.isArray(parsed)) {
|
|
575
|
+
return normalizeIgnorePaths(parsed.map(String));
|
|
576
|
+
}
|
|
577
|
+
} catch {
|
|
578
|
+
}
|
|
579
|
+
return normalizeIgnorePaths(trimmed.split(/[\n,]+/));
|
|
580
|
+
}
|
|
581
|
+
function normalizeIgnorePaths(paths) {
|
|
582
|
+
const out = [];
|
|
583
|
+
const seen = /* @__PURE__ */ new Set();
|
|
584
|
+
for (const raw of paths) {
|
|
585
|
+
const p = raw.trim().replace(/\\/g, "/");
|
|
586
|
+
if (!p || p.startsWith("/") || p.includes("..")) continue;
|
|
587
|
+
if (seen.has(p)) continue;
|
|
588
|
+
seen.add(p);
|
|
589
|
+
out.push(p);
|
|
590
|
+
}
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
function resolveIgnorePaths(partnerPaths) {
|
|
594
|
+
return normalizeIgnorePaths([
|
|
595
|
+
...DEFAULT_AI_IGNORE_PATHS,
|
|
596
|
+
...partnerPaths
|
|
597
|
+
]);
|
|
598
|
+
}
|
|
599
|
+
function isInsideWorkspace(workspaceDir, targetPath) {
|
|
600
|
+
const root = path2.resolve(workspaceDir);
|
|
601
|
+
const target = path2.resolve(targetPath);
|
|
602
|
+
const rel = path2.relative(root, target);
|
|
603
|
+
return rel === "" || !rel.startsWith("..") && !path2.isAbsolute(rel);
|
|
604
|
+
}
|
|
605
|
+
function globToRegExp(pattern) {
|
|
606
|
+
let p = pattern.replace(/\\/g, "/");
|
|
607
|
+
if (p.endsWith("/")) p = `${p}**`;
|
|
608
|
+
let i = 0;
|
|
609
|
+
let out = "^";
|
|
610
|
+
while (i < p.length) {
|
|
611
|
+
if (p.startsWith("**/", i)) {
|
|
612
|
+
out += "(?:.*/)?";
|
|
613
|
+
i += 3;
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (p[i] === "*" && p[i + 1] !== "*") {
|
|
617
|
+
out += "[^/]*";
|
|
618
|
+
i += 1;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
if (p.startsWith("**", i)) {
|
|
622
|
+
out += ".*";
|
|
623
|
+
i += 2;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (p[i] === "?") {
|
|
627
|
+
out += "[^/]";
|
|
628
|
+
i += 1;
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const ch = p[i];
|
|
632
|
+
if (/[.+^${}()|[\]\\]/.test(ch)) out += `\\${ch}`;
|
|
633
|
+
else out += ch;
|
|
634
|
+
i += 1;
|
|
635
|
+
}
|
|
636
|
+
out += "$";
|
|
637
|
+
return new RegExp(out, "i");
|
|
638
|
+
}
|
|
639
|
+
function isIgnoredRelative(relativePath, patterns) {
|
|
640
|
+
const rel = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
641
|
+
if (!rel) return false;
|
|
642
|
+
return patterns.some((pattern) => {
|
|
643
|
+
const re = globToRegExp(pattern);
|
|
644
|
+
if (re.test(rel)) return true;
|
|
645
|
+
if (!pattern.includes("*") && !pattern.endsWith("/")) {
|
|
646
|
+
return rel === pattern || rel.startsWith(`${pattern}/`);
|
|
647
|
+
}
|
|
648
|
+
return false;
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
function isPathAllowed(workspaceDir, targetPath, ignorePaths) {
|
|
652
|
+
if (!isInsideWorkspace(workspaceDir, targetPath)) return false;
|
|
653
|
+
const rel = path2.relative(path2.resolve(workspaceDir), path2.resolve(targetPath));
|
|
654
|
+
return !isIgnoredRelative(rel, ignorePaths);
|
|
655
|
+
}
|
|
656
|
+
function formatAccessPolicyPromptSection(input) {
|
|
657
|
+
const ignores = input.ignorePaths.filter((p) => !p.startsWith("!")).length ? input.ignorePaths.filter((p) => !p.startsWith("!")).map((p) => `- ${p}`).join("\n") : "- (none beyond staying inside the workspace)";
|
|
658
|
+
return `## File access boundaries (mandatory)
|
|
659
|
+
- You may only read or edit files inside the current project workspace directory.
|
|
660
|
+
- Never write, move, or delete files outside that workspace.
|
|
661
|
+
- You may read screenshot files listed in the attachments section (those live in the Maintainer Pro data folder, not in the project).
|
|
662
|
+
- Never touch paths that match this ignore list (relative to the workspace):
|
|
663
|
+
${ignores}
|
|
664
|
+
- If a request requires something outside the workspace or on the ignore list, refuse that part and explain you can only change allowed project files.`;
|
|
665
|
+
}
|
|
666
|
+
var ACCESS_IGNORE_BEGIN = "# maintainer-pro:access-begin";
|
|
667
|
+
var ACCESS_IGNORE_END = "# maintainer-pro:access-end";
|
|
668
|
+
function renderManagedIgnoreBlock(ignorePaths) {
|
|
669
|
+
const lines = [
|
|
670
|
+
ACCESS_IGNORE_BEGIN,
|
|
671
|
+
"# Managed by Maintainer Pro \u2014 do not edit this block by hand.",
|
|
672
|
+
...ignorePaths,
|
|
673
|
+
ACCESS_IGNORE_END
|
|
674
|
+
];
|
|
675
|
+
return `${lines.join("\n")}
|
|
676
|
+
`;
|
|
677
|
+
}
|
|
678
|
+
function upsertManagedIgnoreFile(existing, ignorePaths) {
|
|
679
|
+
const block = renderManagedIgnoreBlock(ignorePaths);
|
|
680
|
+
const begin = existing.indexOf(ACCESS_IGNORE_BEGIN);
|
|
681
|
+
const end = existing.indexOf(ACCESS_IGNORE_END);
|
|
682
|
+
if (begin >= 0 && end > begin) {
|
|
683
|
+
const afterEnd = end + ACCESS_IGNORE_END.length;
|
|
684
|
+
const before = existing.slice(0, begin).replace(/\s+$/, "");
|
|
685
|
+
let after = existing.slice(afterEnd).replace(/^\r?\n/, "");
|
|
686
|
+
const parts = [before, block.trimEnd(), after.trimStart()].filter(
|
|
687
|
+
(s) => s.length > 0
|
|
688
|
+
);
|
|
689
|
+
return `${parts.join("\n\n")}
|
|
690
|
+
`;
|
|
691
|
+
}
|
|
692
|
+
const trimmed = existing.replace(/\s+$/, "");
|
|
693
|
+
return trimmed ? `${trimmed}
|
|
694
|
+
|
|
695
|
+
${block}` : block;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// src/http/handler.ts
|
|
699
|
+
import { randomUUID } from "crypto";
|
|
700
|
+
|
|
701
|
+
// src/log.ts
|
|
702
|
+
import pino from "pino";
|
|
703
|
+
function isDevMode() {
|
|
704
|
+
if (process.env.AI_DEV === "1" || process.env.AI_DEV === "true") return true;
|
|
705
|
+
const env = (process.env.NODE_ENV || process.env.AI_ENV || "").trim().toLowerCase();
|
|
706
|
+
return env === "development" || env === "dev" || env === "test";
|
|
707
|
+
}
|
|
708
|
+
function resolveLogLevel() {
|
|
709
|
+
const explicit = process.env.LOG_LEVEL?.trim() || process.env.AI_LOG_LEVEL?.trim();
|
|
710
|
+
if (explicit) return explicit;
|
|
711
|
+
return isDevMode() ? "debug" : "info";
|
|
712
|
+
}
|
|
713
|
+
function createLogger(name) {
|
|
714
|
+
const level = resolveLogLevel();
|
|
715
|
+
const pretty = process.env.LOG_PRETTY !== "0" && typeof process.stdout?.isTTY === "boolean" && process.stdout.isTTY;
|
|
716
|
+
if (pretty) {
|
|
717
|
+
return pino({
|
|
718
|
+
name,
|
|
719
|
+
level,
|
|
720
|
+
transport: {
|
|
721
|
+
target: "pino-pretty",
|
|
722
|
+
options: {
|
|
723
|
+
colorize: true,
|
|
724
|
+
translateTime: "HH:MM:ss",
|
|
725
|
+
ignore: "pid,hostname"
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
return pino({ name, level });
|
|
731
|
+
}
|
|
732
|
+
function createInfoLogger(name) {
|
|
733
|
+
const log = createLogger(name);
|
|
734
|
+
return (msg) => {
|
|
735
|
+
log.info(msg);
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
function previewText(text, max = 160) {
|
|
739
|
+
const t = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
740
|
+
if (!t) return "";
|
|
741
|
+
return t.length <= max ? t : `${t.slice(0, max)}\u2026`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// src/http/attachments.ts
|
|
745
|
+
import fs3 from "fs/promises";
|
|
746
|
+
import path4 from "path";
|
|
747
|
+
|
|
748
|
+
// src/project-data.ts
|
|
749
|
+
import { createHash } from "crypto";
|
|
750
|
+
import fs2 from "fs";
|
|
751
|
+
import os from "os";
|
|
752
|
+
import path3 from "path";
|
|
753
|
+
var MAINTAINER_PRO_HOME_DIR = ".maintainer-pro";
|
|
754
|
+
var HOST_APPS_FILE = "apps.json";
|
|
755
|
+
function maintainerProHome() {
|
|
756
|
+
const override = process.env.MAINTAINER_PRO_HOME?.trim();
|
|
757
|
+
if (override) return path3.resolve(override);
|
|
758
|
+
return path3.join(os.homedir(), MAINTAINER_PRO_HOME_DIR);
|
|
759
|
+
}
|
|
760
|
+
function projectIdForFolder(folder) {
|
|
761
|
+
const resolved = path3.resolve(folder || "");
|
|
762
|
+
const key = process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
763
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
764
|
+
}
|
|
765
|
+
function sanitizeProjectId(id) {
|
|
766
|
+
return String(id || "").trim().replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80);
|
|
767
|
+
}
|
|
768
|
+
function resolveProjectDataDir(input) {
|
|
769
|
+
const override = String(input.dataDir || "").trim();
|
|
770
|
+
if (override) return path3.resolve(override);
|
|
771
|
+
const sandbox = sanitizeProjectId(String(input.sandboxId || ""));
|
|
772
|
+
const id = sandbox || projectIdForFolder(input.workspaceDir);
|
|
773
|
+
return path3.join(maintainerProHome(), "projects", id);
|
|
774
|
+
}
|
|
775
|
+
function projectUploadsDir(input) {
|
|
776
|
+
return path3.join(resolveProjectDataDir(input), "uploads");
|
|
777
|
+
}
|
|
778
|
+
function hostAppsCachePath(input) {
|
|
779
|
+
return path3.join(resolveProjectDataDir(input), HOST_APPS_FILE);
|
|
780
|
+
}
|
|
781
|
+
function ensureProjectDataDir(input) {
|
|
782
|
+
const dir = resolveProjectDataDir(input);
|
|
783
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
784
|
+
fs2.mkdirSync(path3.join(dir, "uploads"), { recursive: true });
|
|
785
|
+
try {
|
|
786
|
+
fs2.writeFileSync(
|
|
787
|
+
path3.join(dir, "workspace.json"),
|
|
788
|
+
`${JSON.stringify(
|
|
789
|
+
{
|
|
790
|
+
workspaceDir: path3.resolve(input.workspaceDir || ""),
|
|
791
|
+
sandboxId: input.sandboxId ? String(input.sandboxId) : null,
|
|
792
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
793
|
+
},
|
|
794
|
+
null,
|
|
795
|
+
2
|
|
796
|
+
)}
|
|
797
|
+
`,
|
|
798
|
+
"utf8"
|
|
799
|
+
);
|
|
800
|
+
} catch {
|
|
801
|
+
}
|
|
802
|
+
return dir;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// src/http/attachments.ts
|
|
453
806
|
var MAX_ATTACHMENTS = 5;
|
|
454
807
|
var MAX_BYTES = 4 * 1024 * 1024;
|
|
455
808
|
var ALLOWED = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]);
|
|
456
809
|
function extForMime(mime) {
|
|
457
|
-
|
|
810
|
+
const normalized = mime.split(";")[0]?.trim().toLowerCase() || "";
|
|
811
|
+
switch (normalized) {
|
|
458
812
|
case "image/jpeg":
|
|
459
813
|
case "image/jpg":
|
|
460
814
|
return ".jpg";
|
|
@@ -466,11 +820,31 @@ function extForMime(mime) {
|
|
|
466
820
|
return ".png";
|
|
467
821
|
}
|
|
468
822
|
}
|
|
469
|
-
|
|
823
|
+
function attachmentIdFromRef(ref) {
|
|
824
|
+
const trimmed = String(ref || "").trim();
|
|
825
|
+
if (!trimmed) return null;
|
|
826
|
+
const m = /^maintainer-pro:\/\/(.+)$/i.exec(trimmed);
|
|
827
|
+
if (m?.[1]) return m[1];
|
|
828
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
829
|
+
trimmed
|
|
830
|
+
)) {
|
|
831
|
+
return trimmed;
|
|
832
|
+
}
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
function uploadsRoot(input) {
|
|
836
|
+
ensureProjectDataDir(input);
|
|
837
|
+
return projectUploadsDir(input);
|
|
838
|
+
}
|
|
839
|
+
async function saveChatAttachments(attachments, workspaceDir, extra) {
|
|
470
840
|
if (!attachments?.length) return [];
|
|
471
841
|
const selected = attachments.slice(0, MAX_ATTACHMENTS);
|
|
472
|
-
const
|
|
473
|
-
|
|
842
|
+
const loc = {
|
|
843
|
+
workspaceDir,
|
|
844
|
+
dataDir: extra?.dataDir,
|
|
845
|
+
sandboxId: extra?.sandboxId
|
|
846
|
+
};
|
|
847
|
+
const dir = uploadsRoot(loc);
|
|
474
848
|
const paths = [];
|
|
475
849
|
const stamp = Date.now();
|
|
476
850
|
for (let i = 0; i < selected.length; i++) {
|
|
@@ -488,8 +862,11 @@ async function saveChatAttachments(attachments, workspaceDir) {
|
|
|
488
862
|
}
|
|
489
863
|
const safeBase = (item.name || `screenshot-${i + 1}`).replace(/[^\w.\-]+/g, "_").slice(0, 64);
|
|
490
864
|
const fileName = `${stamp}-${i + 1}-${safeBase}${extForMime(mime)}`;
|
|
491
|
-
const filePath =
|
|
492
|
-
|
|
865
|
+
const filePath = path4.resolve(dir, fileName);
|
|
866
|
+
if (!isInsideWorkspace(dir, filePath)) {
|
|
867
|
+
throw new Error("Attachment path escaped the project data folder");
|
|
868
|
+
}
|
|
869
|
+
await fs3.writeFile(filePath, buffer);
|
|
493
870
|
paths.push(filePath);
|
|
494
871
|
}
|
|
495
872
|
return paths;
|
|
@@ -575,7 +952,7 @@ function createToolValidator(schemas) {
|
|
|
575
952
|
|
|
576
953
|
// src/http/handler.ts
|
|
577
954
|
var WORKING_PROVIDER = "working";
|
|
578
|
-
async function setWorkingMessage(db, conversationId) {
|
|
955
|
+
async function setWorkingMessage(db, conversationId, parentMessageId) {
|
|
579
956
|
await db.ensureConversation(conversationId);
|
|
580
957
|
await db.clearWorkingMessages?.(conversationId);
|
|
581
958
|
await db.saveMessage({
|
|
@@ -583,9 +960,26 @@ async function setWorkingMessage(db, conversationId) {
|
|
|
583
960
|
role: "assistant",
|
|
584
961
|
content: "",
|
|
585
962
|
provider: WORKING_PROVIDER,
|
|
586
|
-
senderType: "ai"
|
|
963
|
+
senderType: "ai",
|
|
964
|
+
parentMessageId: parentMessageId ?? void 0
|
|
587
965
|
});
|
|
588
966
|
}
|
|
967
|
+
function savedMessageFields(result) {
|
|
968
|
+
if (!result || typeof result !== "object") return {};
|
|
969
|
+
const row = result;
|
|
970
|
+
const next = row.nextQueued && typeof row.nextQueued === "object" && typeof row.nextQueued.id === "string" ? {
|
|
971
|
+
id: row.nextQueued.id,
|
|
972
|
+
content: String(
|
|
973
|
+
row.nextQueued.content ?? ""
|
|
974
|
+
)
|
|
975
|
+
} : null;
|
|
976
|
+
return {
|
|
977
|
+
id: typeof row.id === "string" ? row.id : void 0,
|
|
978
|
+
queueStatus: typeof row.queueStatus === "string" || row.queueStatus === null ? row.queueStatus : void 0,
|
|
979
|
+
queuePosition: typeof row.queuePosition === "number" || row.queuePosition === null ? row.queuePosition : void 0,
|
|
980
|
+
nextQueued: next
|
|
981
|
+
};
|
|
982
|
+
}
|
|
589
983
|
var SHARED_CONVERSATION_ID = "shared";
|
|
590
984
|
var conversationTurnSeq = /* @__PURE__ */ new Map();
|
|
591
985
|
function beginConversationTurn(conversationId) {
|
|
@@ -620,14 +1014,18 @@ async function resolveSharedConversationId(request, options, bodyId) {
|
|
|
620
1014
|
return SHARED_CONVERSATION_ID;
|
|
621
1015
|
}
|
|
622
1016
|
function createChatHandler(options) {
|
|
1017
|
+
const logger = options.logger ?? createLogger("ai-cli:chat");
|
|
623
1018
|
const validate = options.tools ? createToolValidator(options.tools) : null;
|
|
624
1019
|
const workspaceDir = options.workspaceDir ?? process.env.AI_CLI_WORKSPACE ?? process.cwd();
|
|
1020
|
+
const dataDir = options.dataDir;
|
|
1021
|
+
const sandboxId = options.sandboxId;
|
|
625
1022
|
const baseCallOptions = {
|
|
626
1023
|
systemPrompt: options.systemPrompt,
|
|
627
1024
|
workspaceDir,
|
|
628
1025
|
providerPreference: options.providerPreference,
|
|
629
1026
|
providers: options.providers
|
|
630
1027
|
};
|
|
1028
|
+
logger.debug({ workspaceDir }, "chat handler ready");
|
|
631
1029
|
return {
|
|
632
1030
|
async GET(request) {
|
|
633
1031
|
try {
|
|
@@ -643,6 +1041,14 @@ function createChatHandler(options) {
|
|
|
643
1041
|
if (options.db?.listMessages) {
|
|
644
1042
|
messages = await options.db.listMessages(conversationId);
|
|
645
1043
|
}
|
|
1044
|
+
logger.debug(
|
|
1045
|
+
{
|
|
1046
|
+
conversationId,
|
|
1047
|
+
provider: provider.id,
|
|
1048
|
+
messageCount: messages?.length ?? 0
|
|
1049
|
+
},
|
|
1050
|
+
"GET /api/chat"
|
|
1051
|
+
);
|
|
646
1052
|
return Response.json({
|
|
647
1053
|
provider: provider.id,
|
|
648
1054
|
providerLabel: provider.label || providerLabel(provider.id),
|
|
@@ -650,6 +1056,7 @@ function createChatHandler(options) {
|
|
|
650
1056
|
messages: messages ?? []
|
|
651
1057
|
});
|
|
652
1058
|
} catch (err) {
|
|
1059
|
+
logger.error({ err }, "GET /api/chat failed");
|
|
653
1060
|
const message = err instanceof Error ? err.message : "No AI CLI provider available";
|
|
654
1061
|
return Response.json(
|
|
655
1062
|
{ error: message, provider: null },
|
|
@@ -659,7 +1066,9 @@ function createChatHandler(options) {
|
|
|
659
1066
|
},
|
|
660
1067
|
async POST(request) {
|
|
661
1068
|
let trackedConversationId;
|
|
1069
|
+
let trackedParentUserMessageId;
|
|
662
1070
|
let trackedTurn = 0;
|
|
1071
|
+
const startedAt = Date.now();
|
|
663
1072
|
try {
|
|
664
1073
|
const body = await request.json();
|
|
665
1074
|
const conversationId = await resolveSharedConversationId(
|
|
@@ -668,6 +1077,19 @@ function createChatHandler(options) {
|
|
|
668
1077
|
body.conversationId
|
|
669
1078
|
);
|
|
670
1079
|
trackedConversationId = conversationId;
|
|
1080
|
+
logger.debug(
|
|
1081
|
+
{
|
|
1082
|
+
conversationId,
|
|
1083
|
+
type: body.type ?? "turn",
|
|
1084
|
+
skipPersistUser: Boolean(body.skipPersistUser),
|
|
1085
|
+
userMessageId: body.userMessageId,
|
|
1086
|
+
messageCount: body.messages?.length ?? 0,
|
|
1087
|
+
attachmentCount: body.attachments?.length ?? 0,
|
|
1088
|
+
queueStatus: body.queueStatus,
|
|
1089
|
+
preview: previewText(body.userMessage || body.content)
|
|
1090
|
+
},
|
|
1091
|
+
"POST /api/chat"
|
|
1092
|
+
);
|
|
671
1093
|
if (body.type === "working-clear") {
|
|
672
1094
|
if (!conversationId) {
|
|
673
1095
|
return Response.json(
|
|
@@ -692,11 +1114,54 @@ function createChatHandler(options) {
|
|
|
692
1114
|
await options.db.ensureConversation(conversationId);
|
|
693
1115
|
await options.db.saveMessage({
|
|
694
1116
|
conversationId,
|
|
1117
|
+
id: body.messageId,
|
|
695
1118
|
role: "assistant",
|
|
696
1119
|
content,
|
|
697
1120
|
provider: "developer",
|
|
698
1121
|
senderType: "developer",
|
|
699
|
-
senderName: body.senderName?.trim() || void 0
|
|
1122
|
+
senderName: body.senderName?.trim() || void 0,
|
|
1123
|
+
parentMessageId: body.parentMessageId
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
return Response.json({ ok: true, conversationId });
|
|
1127
|
+
}
|
|
1128
|
+
if (body.type === "user") {
|
|
1129
|
+
const content = body.content?.trim();
|
|
1130
|
+
if (!conversationId || !content) {
|
|
1131
|
+
return Response.json(
|
|
1132
|
+
{ error: "conversationId and content are required" },
|
|
1133
|
+
{ status: 400 }
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
if (options.db) {
|
|
1137
|
+
await options.db.ensureConversation(conversationId);
|
|
1138
|
+
let persistAttachmentPaths2 = [];
|
|
1139
|
+
if (options.db.uploadAttachments && body.attachments?.length) {
|
|
1140
|
+
const uploaded = await options.db.uploadAttachments(
|
|
1141
|
+
conversationId,
|
|
1142
|
+
body.attachments
|
|
1143
|
+
);
|
|
1144
|
+
persistAttachmentPaths2 = uploaded.refs;
|
|
1145
|
+
}
|
|
1146
|
+
const saved = await options.db.saveMessage({
|
|
1147
|
+
conversationId,
|
|
1148
|
+
id: body.messageId,
|
|
1149
|
+
role: "user",
|
|
1150
|
+
content,
|
|
1151
|
+
attachmentPaths: persistAttachmentPaths2.length > 0 ? persistAttachmentPaths2 : void 0,
|
|
1152
|
+
senderType: body.senderType === "client" ? "client" : void 0,
|
|
1153
|
+
senderName: body.senderName?.trim() || void 0,
|
|
1154
|
+
parentMessageId: body.parentMessageId,
|
|
1155
|
+
intent: "queue",
|
|
1156
|
+
queueStatus: body.queueStatus === "queued" || body.queueStatus === "working" ? body.queueStatus : void 0
|
|
1157
|
+
});
|
|
1158
|
+
const fields = savedMessageFields(saved);
|
|
1159
|
+
return Response.json({
|
|
1160
|
+
ok: true,
|
|
1161
|
+
conversationId,
|
|
1162
|
+
message: saved,
|
|
1163
|
+
queueStatus: fields.queueStatus ?? null,
|
|
1164
|
+
queuePosition: fields.queuePosition ?? null
|
|
700
1165
|
});
|
|
701
1166
|
}
|
|
702
1167
|
return Response.json({ ok: true, conversationId });
|
|
@@ -710,44 +1175,99 @@ function createChatHandler(options) {
|
|
|
710
1175
|
}
|
|
711
1176
|
let attachmentPaths = [];
|
|
712
1177
|
let persistAttachmentPaths = [];
|
|
713
|
-
if (options.db?.uploadAttachments) {
|
|
1178
|
+
if (options.db?.uploadAttachments && attachments?.length) {
|
|
714
1179
|
const uploaded = await options.db.uploadAttachments(
|
|
715
1180
|
conversationId,
|
|
716
1181
|
attachments
|
|
717
1182
|
);
|
|
718
1183
|
attachmentPaths = uploaded.localPaths;
|
|
719
1184
|
persistAttachmentPaths = uploaded.refs;
|
|
720
|
-
} else {
|
|
1185
|
+
} else if (attachments?.length) {
|
|
721
1186
|
attachmentPaths = await saveChatAttachments(
|
|
722
1187
|
attachments,
|
|
723
|
-
workspaceDir
|
|
1188
|
+
workspaceDir,
|
|
1189
|
+
{ dataDir, sandboxId }
|
|
724
1190
|
);
|
|
725
1191
|
persistAttachmentPaths = attachmentPaths;
|
|
726
1192
|
}
|
|
1193
|
+
const storedUserId = typeof body.userMessageId === "string" && body.userMessageId ? body.userMessageId : void 0;
|
|
1194
|
+
if (!attachmentPaths.length && conversationId && options.db?.materializeMessageAttachments) {
|
|
1195
|
+
attachmentPaths = await options.db.materializeMessageAttachments(
|
|
1196
|
+
conversationId,
|
|
1197
|
+
storedUserId,
|
|
1198
|
+
workspaceDir
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
logger.debug(
|
|
1202
|
+
{
|
|
1203
|
+
conversationId,
|
|
1204
|
+
userMessageId: storedUserId,
|
|
1205
|
+
attachmentCount: attachmentPaths.length
|
|
1206
|
+
},
|
|
1207
|
+
"cli attachments"
|
|
1208
|
+
);
|
|
727
1209
|
const turn = beginConversationTurn(conversationId ?? SHARED_CONVERSATION_ID);
|
|
728
1210
|
trackedTurn = turn;
|
|
729
1211
|
const signal = request.signal;
|
|
1212
|
+
let parentUserMessageId = typeof body.userMessageId === "string" && body.userMessageId ? body.userMessageId : void 0;
|
|
1213
|
+
trackedParentUserMessageId = parentUserMessageId;
|
|
730
1214
|
if (options.db && conversationId) {
|
|
731
1215
|
const latest = messages[messages.length - 1];
|
|
732
1216
|
const persistContent = body.userMessage?.trim() || (latest?.role === "user" ? latest.content : "");
|
|
733
|
-
if (persistContent) {
|
|
1217
|
+
if (persistContent && !body.skipPersistUser && !parentUserMessageId) {
|
|
734
1218
|
await options.db.ensureConversation(conversationId);
|
|
735
|
-
await options.db.saveMessage({
|
|
1219
|
+
const saved = await options.db.saveMessage({
|
|
736
1220
|
conversationId,
|
|
737
1221
|
role: "user",
|
|
738
1222
|
content: persistContent,
|
|
739
1223
|
attachmentPaths: persistAttachmentPaths.length > 0 ? persistAttachmentPaths : void 0,
|
|
740
1224
|
senderType: body.senderType === "client" ? "client" : void 0,
|
|
741
|
-
senderName: body.senderName?.trim() || void 0
|
|
1225
|
+
senderName: body.senderName?.trim() || void 0,
|
|
1226
|
+
intent: "run"
|
|
742
1227
|
});
|
|
1228
|
+
parentUserMessageId = savedMessageFields(saved).id ?? parentUserMessageId;
|
|
1229
|
+
trackedParentUserMessageId = parentUserMessageId;
|
|
1230
|
+
}
|
|
1231
|
+
if (!body.skipPersistUser) {
|
|
1232
|
+
await setWorkingMessage(
|
|
1233
|
+
options.db,
|
|
1234
|
+
conversationId,
|
|
1235
|
+
parentUserMessageId
|
|
1236
|
+
);
|
|
743
1237
|
}
|
|
744
|
-
await setWorkingMessage(options.db, conversationId);
|
|
745
1238
|
}
|
|
746
1239
|
const latestUser = [...messages].reverse().find((m) => m.role === "user");
|
|
747
1240
|
const priorConversationsContext = options.db ? await buildPriorConversationsContext(options.db, {
|
|
748
1241
|
excludeConversationId: conversationId,
|
|
749
1242
|
currentRequest: latestUser?.content ?? ""
|
|
750
1243
|
}) : "";
|
|
1244
|
+
let parentChainContext = "";
|
|
1245
|
+
if (options.db?.listMessages && conversationId) {
|
|
1246
|
+
const stored = await options.db.listMessages(conversationId);
|
|
1247
|
+
const chain = parentChainForTurn(
|
|
1248
|
+
stored.map((row) => ({
|
|
1249
|
+
id: row.id,
|
|
1250
|
+
role: row.role,
|
|
1251
|
+
content: row.content,
|
|
1252
|
+
parentMessageId: row.parentMessageId,
|
|
1253
|
+
senderName: row.senderName,
|
|
1254
|
+
provider: row.provider
|
|
1255
|
+
})),
|
|
1256
|
+
parentUserMessageId,
|
|
1257
|
+
body.parentMessageId
|
|
1258
|
+
);
|
|
1259
|
+
parentChainContext = formatParentChainContext(chain);
|
|
1260
|
+
if (parentChainContext) {
|
|
1261
|
+
logger.debug(
|
|
1262
|
+
{
|
|
1263
|
+
conversationId,
|
|
1264
|
+
parentUserMessageId,
|
|
1265
|
+
chainLength: chain.length
|
|
1266
|
+
},
|
|
1267
|
+
"parent chain context"
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
751
1271
|
if (!isActiveConversationTurn(
|
|
752
1272
|
conversationId ?? SHARED_CONVERSATION_ID,
|
|
753
1273
|
turn
|
|
@@ -758,23 +1278,59 @@ function createChatHandler(options) {
|
|
|
758
1278
|
});
|
|
759
1279
|
}
|
|
760
1280
|
if (signal.aborted) {
|
|
761
|
-
|
|
762
|
-
|
|
1281
|
+
logger.debug({ conversationId, turn }, "aborted before AI");
|
|
1282
|
+
if (conversationId && options.db) {
|
|
1283
|
+
if (options.db.releaseWorkingTurn) {
|
|
1284
|
+
await options.db.releaseWorkingTurn(
|
|
1285
|
+
conversationId,
|
|
1286
|
+
parentUserMessageId
|
|
1287
|
+
);
|
|
1288
|
+
} else {
|
|
1289
|
+
await options.db.clearWorkingMessages?.(conversationId);
|
|
1290
|
+
}
|
|
763
1291
|
}
|
|
764
1292
|
return Response.json({
|
|
765
1293
|
superseded: true,
|
|
766
1294
|
conversationId: conversationId ?? null
|
|
767
1295
|
});
|
|
768
1296
|
}
|
|
1297
|
+
logger.debug(
|
|
1298
|
+
{
|
|
1299
|
+
conversationId,
|
|
1300
|
+
turn,
|
|
1301
|
+
parentUserMessageId,
|
|
1302
|
+
historyLength: messages.length,
|
|
1303
|
+
attachmentCount: attachmentPaths.length,
|
|
1304
|
+
hasParentChain: Boolean(parentChainContext),
|
|
1305
|
+
preview: previewText(
|
|
1306
|
+
[...messages].reverse().find((m) => m.role === "user")?.content
|
|
1307
|
+
)
|
|
1308
|
+
},
|
|
1309
|
+
"calling AI provider"
|
|
1310
|
+
);
|
|
1311
|
+
const aiStarted = Date.now();
|
|
769
1312
|
const aiResponse = await callAi(messages, context, {
|
|
770
1313
|
...baseCallOptions,
|
|
771
1314
|
attachmentPaths,
|
|
772
|
-
priorConversationsContext: priorConversationsContext || void 0
|
|
1315
|
+
priorConversationsContext: priorConversationsContext || void 0,
|
|
1316
|
+
parentChainContext: parentChainContext || void 0
|
|
773
1317
|
});
|
|
1318
|
+
logger.debug(
|
|
1319
|
+
{
|
|
1320
|
+
conversationId,
|
|
1321
|
+
turn,
|
|
1322
|
+
provider: aiResponse.provider,
|
|
1323
|
+
toolCalls: aiResponse.toolCalls.length,
|
|
1324
|
+
ms: Date.now() - aiStarted,
|
|
1325
|
+
preview: previewText(aiResponse.text)
|
|
1326
|
+
},
|
|
1327
|
+
"AI provider returned"
|
|
1328
|
+
);
|
|
774
1329
|
if (!isActiveConversationTurn(
|
|
775
1330
|
conversationId ?? SHARED_CONVERSATION_ID,
|
|
776
1331
|
turn
|
|
777
1332
|
)) {
|
|
1333
|
+
logger.debug({ conversationId, turn }, "superseded after AI");
|
|
778
1334
|
return Response.json({
|
|
779
1335
|
superseded: true,
|
|
780
1336
|
conversationId: conversationId ?? null
|
|
@@ -782,38 +1338,108 @@ function createChatHandler(options) {
|
|
|
782
1338
|
}
|
|
783
1339
|
const validatedToolCalls = validate ? aiResponse.toolCalls.filter((tc) => validate(tc).valid) : aiResponse.toolCalls;
|
|
784
1340
|
if (options.onToolCalls && validatedToolCalls.length > 0) {
|
|
1341
|
+
logger.debug(
|
|
1342
|
+
{ conversationId, count: validatedToolCalls.length },
|
|
1343
|
+
"applying tool calls"
|
|
1344
|
+
);
|
|
785
1345
|
await options.onToolCalls(validatedToolCalls, context);
|
|
786
1346
|
}
|
|
1347
|
+
let nextQueued = null;
|
|
1348
|
+
const assistantMessageId = typeof body.assistantMessageId === "string" && body.assistantMessageId ? body.assistantMessageId : randomUUID();
|
|
1349
|
+
let replyId = assistantMessageId;
|
|
787
1350
|
if (options.db && conversationId) {
|
|
788
1351
|
await options.db.ensureConversation(conversationId);
|
|
789
|
-
await options.db.
|
|
790
|
-
await options.db.saveMessage({
|
|
1352
|
+
const savedReply = await options.db.saveMessage({
|
|
791
1353
|
conversationId,
|
|
1354
|
+
id: assistantMessageId,
|
|
792
1355
|
role: "assistant",
|
|
793
1356
|
content: aiResponse.text,
|
|
794
1357
|
provider: aiResponse.provider,
|
|
795
|
-
senderType: "ai"
|
|
1358
|
+
senderType: "ai",
|
|
1359
|
+
parentMessageId: parentUserMessageId
|
|
796
1360
|
});
|
|
1361
|
+
replyId = savedMessageFields(savedReply).id ?? assistantMessageId;
|
|
1362
|
+
if (options.db.releaseWorkingTurn) {
|
|
1363
|
+
let released = void 0;
|
|
1364
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1365
|
+
try {
|
|
1366
|
+
released = await options.db.releaseWorkingTurn(
|
|
1367
|
+
conversationId,
|
|
1368
|
+
parentUserMessageId
|
|
1369
|
+
);
|
|
1370
|
+
break;
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
logger.warn(
|
|
1373
|
+
{ err, conversationId, parentUserMessageId, attempt },
|
|
1374
|
+
"releaseWorkingTurn failed; retrying"
|
|
1375
|
+
);
|
|
1376
|
+
if (attempt === 2) throw err;
|
|
1377
|
+
await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
nextQueued = released && typeof released === "object" && released.nextQueued && typeof released.nextQueued.id === "string" ? {
|
|
1381
|
+
id: released.nextQueued.id,
|
|
1382
|
+
content: String(released.nextQueued.content ?? "")
|
|
1383
|
+
} : null;
|
|
1384
|
+
logger.debug(
|
|
1385
|
+
{ conversationId, parentUserMessageId, nextQueued: nextQueued?.id },
|
|
1386
|
+
"released working turn"
|
|
1387
|
+
);
|
|
1388
|
+
} else {
|
|
1389
|
+
await options.db.clearWorkingMessages?.(conversationId);
|
|
1390
|
+
nextQueued = savedMessageFields(savedReply).nextQueued ?? null;
|
|
1391
|
+
}
|
|
797
1392
|
if (validatedToolCalls.length > 0) {
|
|
798
1393
|
await options.db.saveToolEvents?.(
|
|
799
1394
|
conversationId,
|
|
800
1395
|
validatedToolCalls
|
|
801
1396
|
);
|
|
802
1397
|
}
|
|
1398
|
+
logger.debug(
|
|
1399
|
+
{
|
|
1400
|
+
conversationId,
|
|
1401
|
+
replyId,
|
|
1402
|
+
nextQueued: nextQueued?.id
|
|
1403
|
+
},
|
|
1404
|
+
"persisted AI reply"
|
|
1405
|
+
);
|
|
803
1406
|
}
|
|
1407
|
+
logger.debug(
|
|
1408
|
+
{ conversationId, turn, ms: Date.now() - startedAt },
|
|
1409
|
+
"POST /api/chat done"
|
|
1410
|
+
);
|
|
804
1411
|
return Response.json({
|
|
805
1412
|
text: aiResponse.text,
|
|
806
1413
|
toolCalls: validatedToolCalls,
|
|
807
1414
|
provider: aiResponse.provider,
|
|
808
1415
|
providerLabel: providerLabel(aiResponse.provider),
|
|
809
|
-
conversationId: conversationId ?? null
|
|
1416
|
+
conversationId: conversationId ?? null,
|
|
1417
|
+
messageId: replyId,
|
|
1418
|
+
nextQueued,
|
|
1419
|
+
parentMessageId: parentUserMessageId ?? null
|
|
810
1420
|
});
|
|
811
1421
|
} catch (err) {
|
|
812
1422
|
console.error("Chat API error:", err);
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1423
|
+
logger.error(
|
|
1424
|
+
{
|
|
1425
|
+
err,
|
|
1426
|
+
conversationId: trackedConversationId,
|
|
1427
|
+
turn: trackedTurn,
|
|
1428
|
+
ms: Date.now() - startedAt
|
|
1429
|
+
},
|
|
1430
|
+
"POST /api/chat failed"
|
|
1431
|
+
);
|
|
1432
|
+
if (trackedConversationId && options.db && isActiveConversationTurn(trackedConversationId, trackedTurn)) {
|
|
1433
|
+
if (options.db.releaseWorkingTurn) {
|
|
1434
|
+
await options.db.releaseWorkingTurn(
|
|
1435
|
+
trackedConversationId,
|
|
1436
|
+
trackedParentUserMessageId
|
|
1437
|
+
).catch(() => void 0);
|
|
1438
|
+
} else {
|
|
1439
|
+
await options.db.clearWorkingMessages?.(trackedConversationId).catch(
|
|
1440
|
+
() => void 0
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
817
1443
|
}
|
|
818
1444
|
return Response.json(
|
|
819
1445
|
{
|
|
@@ -833,12 +1459,17 @@ function toNextRoute(handlers) {
|
|
|
833
1459
|
}
|
|
834
1460
|
|
|
835
1461
|
// src/http/local-store.ts
|
|
836
|
-
import
|
|
837
|
-
import
|
|
838
|
-
import { randomUUID } from "crypto";
|
|
1462
|
+
import fs4 from "fs/promises";
|
|
1463
|
+
import path5 from "path";
|
|
1464
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1465
|
+
function userHasAiReply(rows, workingId) {
|
|
1466
|
+
return rows.some(
|
|
1467
|
+
(m) => m.role === "assistant" && m.parentMessageId === workingId && m.provider !== "working" && Boolean(m.content?.trim())
|
|
1468
|
+
);
|
|
1469
|
+
}
|
|
839
1470
|
async function readConversation(filePath) {
|
|
840
1471
|
try {
|
|
841
|
-
const raw = await
|
|
1472
|
+
const raw = await fs4.readFile(filePath, "utf8");
|
|
842
1473
|
return JSON.parse(raw);
|
|
843
1474
|
} catch (err) {
|
|
844
1475
|
if (err.code === "ENOENT") return null;
|
|
@@ -846,11 +1477,11 @@ async function readConversation(filePath) {
|
|
|
846
1477
|
}
|
|
847
1478
|
}
|
|
848
1479
|
async function writeConversation(filePath, data) {
|
|
849
|
-
await
|
|
850
|
-
await
|
|
1480
|
+
await fs4.mkdir(path5.dirname(filePath), { recursive: true });
|
|
1481
|
+
await fs4.writeFile(filePath, JSON.stringify(data, null, 2), "utf8");
|
|
851
1482
|
}
|
|
852
1483
|
function createLocalDirectoryStore(baseDir) {
|
|
853
|
-
const fileFor = (id) =>
|
|
1484
|
+
const fileFor = (id) => path5.join(baseDir, `${id}.json`);
|
|
854
1485
|
return {
|
|
855
1486
|
async ensureConversation(id) {
|
|
856
1487
|
const existing = await readConversation(fileFor(id));
|
|
@@ -868,21 +1499,273 @@ function createLocalDirectoryStore(baseDir) {
|
|
|
868
1499
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
869
1500
|
messages: []
|
|
870
1501
|
};
|
|
1502
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1503
|
+
const isClientUser = input.role === "user" && input.senderType !== "developer" && input.provider !== "developer";
|
|
1504
|
+
const isWorking = input.provider === "working";
|
|
1505
|
+
const isAi = input.role === "assistant" && input.provider !== "working" && input.senderType !== "developer" && input.provider !== "developer";
|
|
1506
|
+
const busy = existing.messages.some((m) => m.queueStatus === "working");
|
|
1507
|
+
const makeWorking = (parentMessageId) => ({
|
|
1508
|
+
id: randomUUID2(),
|
|
1509
|
+
conversationId: input.conversationId,
|
|
1510
|
+
role: "assistant",
|
|
1511
|
+
content: "",
|
|
1512
|
+
provider: "working",
|
|
1513
|
+
createdAt: now,
|
|
1514
|
+
senderType: "ai",
|
|
1515
|
+
parentMessageId
|
|
1516
|
+
});
|
|
1517
|
+
const promoteNext = () => {
|
|
1518
|
+
const waiting = existing.messages.filter((m) => m.queueStatus === "queued").sort(
|
|
1519
|
+
(a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
|
|
1520
|
+
);
|
|
1521
|
+
const next = waiting[0];
|
|
1522
|
+
if (!next) return null;
|
|
1523
|
+
next.queueStatus = "working";
|
|
1524
|
+
next.queuePosition = null;
|
|
1525
|
+
existing.messages.push(makeWorking(next.id));
|
|
1526
|
+
return next;
|
|
1527
|
+
};
|
|
1528
|
+
if (isClientUser) {
|
|
1529
|
+
if (input.intent === "run") {
|
|
1530
|
+
for (const row of existing.messages) {
|
|
1531
|
+
if (row.queueStatus === "working") {
|
|
1532
|
+
row.queueStatus = null;
|
|
1533
|
+
row.queuePosition = null;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
existing.messages = existing.messages.filter(
|
|
1537
|
+
(m) => m.provider !== "working"
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1540
|
+
const queueStatus = input.intent === "run" ? "working" : busy ? "queued" : "working";
|
|
1541
|
+
const existingById2 = input.id ? existing.messages.find((m) => m.id === input.id) : void 0;
|
|
1542
|
+
if (existingById2) {
|
|
1543
|
+
return { ...existingById2, nextQueued: null };
|
|
1544
|
+
}
|
|
1545
|
+
const message2 = {
|
|
1546
|
+
id: input.id ?? randomUUID2(),
|
|
1547
|
+
conversationId: input.conversationId,
|
|
1548
|
+
role: input.role,
|
|
1549
|
+
content: input.content,
|
|
1550
|
+
provider: input.provider ?? null,
|
|
1551
|
+
createdAt: now,
|
|
1552
|
+
attachmentPaths: input.attachmentPaths,
|
|
1553
|
+
senderType: input.senderType ?? null,
|
|
1554
|
+
senderName: input.senderName ?? null,
|
|
1555
|
+
parentMessageId: input.parentMessageId ?? null,
|
|
1556
|
+
queueStatus,
|
|
1557
|
+
queuePosition: null
|
|
1558
|
+
};
|
|
1559
|
+
existing.messages.push(message2);
|
|
1560
|
+
existing.updatedAt = now;
|
|
1561
|
+
await writeConversation(filePath, existing);
|
|
1562
|
+
return { ...message2, nextQueued: null };
|
|
1563
|
+
}
|
|
1564
|
+
if (isWorking) {
|
|
1565
|
+
existing.messages = existing.messages.filter(
|
|
1566
|
+
(m) => m.provider !== "working"
|
|
1567
|
+
);
|
|
1568
|
+
if (input.parentMessageId) {
|
|
1569
|
+
const parent = existing.messages.find(
|
|
1570
|
+
(m) => m.id === input.parentMessageId
|
|
1571
|
+
);
|
|
1572
|
+
if (parent && parent.queueStatus !== "working") {
|
|
1573
|
+
parent.queueStatus = "working";
|
|
1574
|
+
parent.queuePosition = null;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
const message2 = {
|
|
1578
|
+
id: input.id ?? randomUUID2(),
|
|
1579
|
+
conversationId: input.conversationId,
|
|
1580
|
+
role: "assistant",
|
|
1581
|
+
content: "",
|
|
1582
|
+
provider: "working",
|
|
1583
|
+
createdAt: now,
|
|
1584
|
+
senderType: "ai",
|
|
1585
|
+
parentMessageId: input.parentMessageId ?? null
|
|
1586
|
+
};
|
|
1587
|
+
existing.messages.push(message2);
|
|
1588
|
+
existing.updatedAt = now;
|
|
1589
|
+
await writeConversation(filePath, existing);
|
|
1590
|
+
return { ...message2, nextQueued: null };
|
|
1591
|
+
}
|
|
1592
|
+
const existingById = input.id ? existing.messages.find((m) => m.id === input.id) : void 0;
|
|
1593
|
+
if (existingById) {
|
|
1594
|
+
return { ...existingById, nextQueued: null };
|
|
1595
|
+
}
|
|
871
1596
|
const message = {
|
|
872
|
-
id:
|
|
1597
|
+
id: input.id ?? randomUUID2(),
|
|
873
1598
|
conversationId: input.conversationId,
|
|
874
1599
|
role: input.role,
|
|
875
1600
|
content: input.content,
|
|
876
1601
|
provider: input.provider ?? null,
|
|
877
|
-
createdAt:
|
|
1602
|
+
createdAt: now,
|
|
878
1603
|
attachmentPaths: input.attachmentPaths,
|
|
879
1604
|
senderType: input.senderType ?? null,
|
|
880
|
-
senderName: input.senderName ?? null
|
|
1605
|
+
senderName: input.senderName ?? null,
|
|
1606
|
+
parentMessageId: input.parentMessageId ?? null
|
|
881
1607
|
};
|
|
882
1608
|
existing.messages.push(message);
|
|
883
|
-
|
|
1609
|
+
let nextQueued = null;
|
|
1610
|
+
if (isAi) {
|
|
1611
|
+
existing.messages = existing.messages.filter(
|
|
1612
|
+
(m) => m.provider !== "working"
|
|
1613
|
+
);
|
|
1614
|
+
if (input.parentMessageId) {
|
|
1615
|
+
const parent = existing.messages.find(
|
|
1616
|
+
(m) => m.id === input.parentMessageId
|
|
1617
|
+
);
|
|
1618
|
+
if (parent) {
|
|
1619
|
+
parent.queueStatus = null;
|
|
1620
|
+
parent.queuePosition = null;
|
|
1621
|
+
}
|
|
1622
|
+
} else {
|
|
1623
|
+
for (const row of existing.messages) {
|
|
1624
|
+
if (row.queueStatus === "working") {
|
|
1625
|
+
row.queueStatus = null;
|
|
1626
|
+
row.queuePosition = null;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
nextQueued = promoteNext();
|
|
1631
|
+
}
|
|
1632
|
+
existing.updatedAt = now;
|
|
1633
|
+
await writeConversation(filePath, existing);
|
|
1634
|
+
return {
|
|
1635
|
+
...message,
|
|
1636
|
+
nextQueued: nextQueued ? { id: nextQueued.id, content: nextQueued.content } : null
|
|
1637
|
+
};
|
|
1638
|
+
},
|
|
1639
|
+
async recoverQueue(conversationId) {
|
|
1640
|
+
const filePath = fileFor(conversationId);
|
|
1641
|
+
const existing = await readConversation(filePath);
|
|
1642
|
+
if (!existing) {
|
|
1643
|
+
return {
|
|
1644
|
+
action: "noop",
|
|
1645
|
+
workingId: null,
|
|
1646
|
+
promotedId: null,
|
|
1647
|
+
nextQueued: null
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
const working = existing.messages.find(
|
|
1651
|
+
(m) => m.role === "user" && m.queueStatus === "working"
|
|
1652
|
+
);
|
|
1653
|
+
if (working) {
|
|
1654
|
+
if (userHasAiReply(existing.messages, working.id)) {
|
|
1655
|
+
existing.messages = existing.messages.filter(
|
|
1656
|
+
(m) => m.provider !== "working"
|
|
1657
|
+
);
|
|
1658
|
+
working.queueStatus = null;
|
|
1659
|
+
working.queuePosition = null;
|
|
1660
|
+
const waiting2 = existing.messages.filter((m) => m.queueStatus === "queued").sort(
|
|
1661
|
+
(a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
|
|
1662
|
+
);
|
|
1663
|
+
const next2 = waiting2[0] ?? null;
|
|
1664
|
+
if (next2) {
|
|
1665
|
+
next2.queueStatus = "working";
|
|
1666
|
+
next2.queuePosition = null;
|
|
1667
|
+
existing.messages.push({
|
|
1668
|
+
id: randomUUID2(),
|
|
1669
|
+
conversationId,
|
|
1670
|
+
role: "assistant",
|
|
1671
|
+
content: "",
|
|
1672
|
+
provider: "working",
|
|
1673
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1674
|
+
senderType: "ai",
|
|
1675
|
+
parentMessageId: next2.id
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1679
|
+
await writeConversation(filePath, existing);
|
|
1680
|
+
return {
|
|
1681
|
+
action: "completed_and_promoted",
|
|
1682
|
+
workingId: working.id,
|
|
1683
|
+
promotedId: next2?.id ?? null,
|
|
1684
|
+
nextQueued: next2 ? { id: next2.id, content: next2.content } : null
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
return {
|
|
1688
|
+
action: "redispatched_working",
|
|
1689
|
+
workingId: working.id,
|
|
1690
|
+
promotedId: null,
|
|
1691
|
+
nextQueued: null
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
const waiting = existing.messages.filter((m) => m.queueStatus === "queued").sort(
|
|
1695
|
+
(a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
|
|
1696
|
+
);
|
|
1697
|
+
const next = waiting[0] ?? null;
|
|
1698
|
+
if (!next) {
|
|
1699
|
+
return {
|
|
1700
|
+
action: "noop",
|
|
1701
|
+
workingId: null,
|
|
1702
|
+
promotedId: null,
|
|
1703
|
+
nextQueued: null
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
next.queueStatus = "working";
|
|
1707
|
+
next.queuePosition = null;
|
|
1708
|
+
existing.messages.push({
|
|
1709
|
+
id: randomUUID2(),
|
|
1710
|
+
conversationId,
|
|
1711
|
+
role: "assistant",
|
|
1712
|
+
content: "",
|
|
1713
|
+
provider: "working",
|
|
1714
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1715
|
+
senderType: "ai",
|
|
1716
|
+
parentMessageId: next.id
|
|
1717
|
+
});
|
|
1718
|
+
existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1719
|
+
await writeConversation(filePath, existing);
|
|
1720
|
+
return {
|
|
1721
|
+
action: "promoted_queued",
|
|
1722
|
+
workingId: null,
|
|
1723
|
+
promotedId: next.id,
|
|
1724
|
+
nextQueued: { id: next.id, content: next.content }
|
|
1725
|
+
};
|
|
1726
|
+
},
|
|
1727
|
+
async releaseWorkingTurn(conversationId, parentMessageId) {
|
|
1728
|
+
const filePath = fileFor(conversationId);
|
|
1729
|
+
const existing = await readConversation(filePath);
|
|
1730
|
+
if (!existing) return { nextQueued: null };
|
|
1731
|
+
existing.messages = existing.messages.filter(
|
|
1732
|
+
(m) => m.provider !== "working"
|
|
1733
|
+
);
|
|
1734
|
+
if (parentMessageId) {
|
|
1735
|
+
const parent = existing.messages.find((m) => m.id === parentMessageId);
|
|
1736
|
+
if (parent) {
|
|
1737
|
+
parent.queueStatus = null;
|
|
1738
|
+
parent.queuePosition = null;
|
|
1739
|
+
}
|
|
1740
|
+
} else {
|
|
1741
|
+
for (const row of existing.messages) {
|
|
1742
|
+
if (row.queueStatus === "working") {
|
|
1743
|
+
row.queueStatus = null;
|
|
1744
|
+
row.queuePosition = null;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
const waiting = existing.messages.filter((m) => m.queueStatus === "queued").sort(
|
|
1749
|
+
(a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)
|
|
1750
|
+
);
|
|
1751
|
+
const next = waiting[0] ?? null;
|
|
1752
|
+
if (next) {
|
|
1753
|
+
next.queueStatus = "working";
|
|
1754
|
+
next.queuePosition = null;
|
|
1755
|
+
existing.messages.push({
|
|
1756
|
+
id: randomUUID2(),
|
|
1757
|
+
conversationId,
|
|
1758
|
+
role: "assistant",
|
|
1759
|
+
content: "",
|
|
1760
|
+
provider: "working",
|
|
1761
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1762
|
+
senderType: "ai",
|
|
1763
|
+
parentMessageId: next.id
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
884
1767
|
await writeConversation(filePath, existing);
|
|
885
|
-
return
|
|
1768
|
+
return next ? { nextQueued: { id: next.id, content: next.content } } : { nextQueued: null };
|
|
886
1769
|
},
|
|
887
1770
|
async clearWorkingMessages(conversationId) {
|
|
888
1771
|
const filePath = fileFor(conversationId);
|
|
@@ -899,12 +1782,12 @@ function createLocalDirectoryStore(baseDir) {
|
|
|
899
1782
|
return existing?.messages ?? [];
|
|
900
1783
|
},
|
|
901
1784
|
async listConversations() {
|
|
902
|
-
await
|
|
903
|
-
const entries = await
|
|
1785
|
+
await fs4.mkdir(baseDir, { recursive: true });
|
|
1786
|
+
const entries = await fs4.readdir(baseDir);
|
|
904
1787
|
const conversations = [];
|
|
905
1788
|
for (const entry of entries) {
|
|
906
1789
|
if (!entry.endsWith(".json")) continue;
|
|
907
|
-
const filePath =
|
|
1790
|
+
const filePath = path5.join(baseDir, entry);
|
|
908
1791
|
const existing = await readConversation(filePath);
|
|
909
1792
|
if (!existing?.id) continue;
|
|
910
1793
|
conversations.push({
|
|
@@ -921,7 +1804,7 @@ function createLocalDirectoryStore(baseDir) {
|
|
|
921
1804
|
if (events.length === 0) return;
|
|
922
1805
|
const existing = await readConversation(fileFor(conversationId));
|
|
923
1806
|
if (!existing) return;
|
|
924
|
-
const toolFile =
|
|
1807
|
+
const toolFile = path5.join(baseDir, `${conversationId}.tools.jsonl`);
|
|
925
1808
|
const lines = events.map(
|
|
926
1809
|
(e) => JSON.stringify({
|
|
927
1810
|
conversationId,
|
|
@@ -929,33 +1812,386 @@ function createLocalDirectoryStore(baseDir) {
|
|
|
929
1812
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
930
1813
|
})
|
|
931
1814
|
).join("\n");
|
|
932
|
-
await
|
|
1815
|
+
await fs4.appendFile(toolFile, `${lines}
|
|
933
1816
|
`, "utf8");
|
|
934
1817
|
}
|
|
935
1818
|
};
|
|
936
1819
|
}
|
|
937
1820
|
|
|
938
1821
|
// src/http/maintainer-pro-store.ts
|
|
939
|
-
import
|
|
940
|
-
import
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
const
|
|
1822
|
+
import fs5 from "fs/promises";
|
|
1823
|
+
import path6 from "path";
|
|
1824
|
+
|
|
1825
|
+
// src/http/synced-store.ts
|
|
1826
|
+
function userHasAiReply2(rows, workingId) {
|
|
1827
|
+
return rows.some(
|
|
1828
|
+
(row) => row.role === "assistant" && row.parentMessageId === workingId && row.provider !== "working" && row.senderType !== "developer" && Boolean(row.content?.trim())
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
function nowIso() {
|
|
1832
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1833
|
+
}
|
|
1834
|
+
function toWsUrl(baseUrl, apiKey) {
|
|
1835
|
+
const u = new URL(`${baseUrl.replace(/\/$/, "")}/api/v1/ws`);
|
|
1836
|
+
u.protocol = u.protocol === "https:" ? "wss:" : "ws:";
|
|
1837
|
+
u.searchParams.set("apiKey", apiKey);
|
|
1838
|
+
return u.toString();
|
|
1839
|
+
}
|
|
1840
|
+
function asCachedMessage(raw) {
|
|
1841
|
+
const id = typeof raw.id === "string" ? raw.id : "";
|
|
1842
|
+
const role = typeof raw.role === "string" ? raw.role : "";
|
|
1843
|
+
if (!id || !role) return null;
|
|
1844
|
+
return {
|
|
1845
|
+
id,
|
|
1846
|
+
role,
|
|
1847
|
+
content: typeof raw.content === "string" ? raw.content : "",
|
|
1848
|
+
provider: typeof raw.provider === "string" ? raw.provider : null,
|
|
1849
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : void 0,
|
|
1850
|
+
attachmentPaths: Array.isArray(raw.attachmentPaths) ? raw.attachmentPaths.filter((p) => typeof p === "string") : Array.isArray(raw.attachmentIds) ? raw.attachmentIds.filter((id2) => typeof id2 === "string").map((id2) => `maintainer-pro://${id2}`) : void 0,
|
|
1851
|
+
senderType: raw.senderType === "client" || raw.senderType === "developer" || raw.senderType === "ai" ? raw.senderType : null,
|
|
1852
|
+
senderName: typeof raw.senderName === "string" ? raw.senderName : null,
|
|
1853
|
+
parentMessageId: typeof raw.parentMessageId === "string" ? raw.parentMessageId : null,
|
|
1854
|
+
queueStatus: raw.queueStatus === "working" || raw.queueStatus === "queued" ? raw.queueStatus : raw.queueStatus === null ? null : void 0,
|
|
1855
|
+
queuePosition: typeof raw.queuePosition === "number" ? raw.queuePosition : null
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
function cloneMessages(messages) {
|
|
1859
|
+
return messages.map((m) => ({ ...m }));
|
|
1860
|
+
}
|
|
1861
|
+
function createSyncedChatStore(options) {
|
|
1862
|
+
const { remote, maintainerProUrl, apiKey, onWorkingTurn } = options;
|
|
1863
|
+
const logger = options.logger ?? createLogger("ai-cli");
|
|
1864
|
+
const info = (msg) => {
|
|
1865
|
+
if (options.log) options.log(msg);
|
|
1866
|
+
else logger.info(msg);
|
|
1867
|
+
};
|
|
1868
|
+
const conversations = /* @__PURE__ */ new Map();
|
|
1869
|
+
const inflightRefresh = /* @__PURE__ */ new Map();
|
|
1870
|
+
let stopped = false;
|
|
1871
|
+
let socket = null;
|
|
1872
|
+
let reconnectAttempt = 0;
|
|
1873
|
+
let reconnectTimer = null;
|
|
1874
|
+
let pingTimer = null;
|
|
1875
|
+
const stats = () => {
|
|
1876
|
+
let messages = 0;
|
|
1877
|
+
for (const conv of conversations.values()) messages += conv.messages.length;
|
|
1878
|
+
return { conversations: conversations.size, messages };
|
|
1879
|
+
};
|
|
1880
|
+
const putConversation = (id, messages, updatedAt = nowIso()) => {
|
|
1881
|
+
conversations.set(id, { id, updatedAt, messages: cloneMessages(messages) });
|
|
1882
|
+
};
|
|
1883
|
+
const refreshConversation = (id) => {
|
|
1884
|
+
const existing = inflightRefresh.get(id);
|
|
1885
|
+
if (existing) return existing;
|
|
1886
|
+
const pending = (async () => {
|
|
1887
|
+
const messages = remote.listMessages ? await remote.listMessages(id) : [];
|
|
1888
|
+
putConversation(id, messages);
|
|
1889
|
+
return cloneMessages(messages);
|
|
1890
|
+
})().finally(() => {
|
|
1891
|
+
inflightRefresh.delete(id);
|
|
1892
|
+
});
|
|
1893
|
+
inflightRefresh.set(id, pending);
|
|
1894
|
+
return pending;
|
|
1895
|
+
};
|
|
1896
|
+
const loadSnapshot = async () => {
|
|
1897
|
+
if (remote.listSnapshot) {
|
|
1898
|
+
try {
|
|
1899
|
+
return await remote.listSnapshot();
|
|
1900
|
+
} catch {
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
if (!remote.listConversations || !remote.listMessages) return [];
|
|
1904
|
+
const summaries = await remote.listConversations();
|
|
1905
|
+
const loaded = [];
|
|
1906
|
+
for (const row of summaries) {
|
|
1907
|
+
loaded.push({
|
|
1908
|
+
id: row.id,
|
|
1909
|
+
updatedAt: row.updatedAt,
|
|
1910
|
+
messages: await remote.listMessages(row.id)
|
|
1911
|
+
});
|
|
1912
|
+
}
|
|
1913
|
+
return loaded;
|
|
1914
|
+
};
|
|
1915
|
+
const hydrate = async () => {
|
|
1916
|
+
const snapshot = await loadSnapshot();
|
|
1917
|
+
conversations.clear();
|
|
1918
|
+
for (const row of snapshot) {
|
|
1919
|
+
putConversation(row.id, row.messages, row.updatedAt);
|
|
1920
|
+
}
|
|
1921
|
+
const next = stats();
|
|
1922
|
+
info(
|
|
1923
|
+
`cache hydrated (${next.conversations} conversations, ${next.messages} messages)`
|
|
1924
|
+
);
|
|
1925
|
+
for (const conv of conversations.values()) {
|
|
1926
|
+
const working = conv.messages.find(
|
|
1927
|
+
(row) => row.role === "user" && row.queueStatus === "working" && row.senderType !== "developer"
|
|
1928
|
+
);
|
|
1929
|
+
if (!working) continue;
|
|
1930
|
+
if (!userHasAiReply2(conv.messages, working.id)) {
|
|
1931
|
+
logger.debug(
|
|
1932
|
+
{ conversationId: conv.id, messageId: working.id },
|
|
1933
|
+
"resume working turn"
|
|
1934
|
+
);
|
|
1935
|
+
onWorkingTurn?.({ conversationId: conv.id, message: working });
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
return next;
|
|
1939
|
+
};
|
|
1940
|
+
const upsertMessage = (conversationId, message) => {
|
|
1941
|
+
const conv = conversations.get(conversationId) ?? {
|
|
1942
|
+
id: conversationId,
|
|
1943
|
+
updatedAt: nowIso(),
|
|
1944
|
+
messages: []
|
|
1945
|
+
};
|
|
1946
|
+
const index = conv.messages.findIndex((row) => row.id === message.id);
|
|
1947
|
+
const wasWorking = index >= 0 && conv.messages[index]?.queueStatus === "working";
|
|
1948
|
+
if (index >= 0) {
|
|
1949
|
+
conv.messages[index] = { ...conv.messages[index], ...message };
|
|
1950
|
+
} else {
|
|
1951
|
+
conv.messages.push(message);
|
|
1952
|
+
}
|
|
1953
|
+
conv.updatedAt = nowIso();
|
|
1954
|
+
conversations.set(conversationId, conv);
|
|
1955
|
+
const becameWorking = message.role === "user" && message.queueStatus === "working" && message.senderType !== "developer" && !wasWorking;
|
|
1956
|
+
if (becameWorking) {
|
|
1957
|
+
logger.debug(
|
|
1958
|
+
{ conversationId, messageId: message.id },
|
|
1959
|
+
"working turn"
|
|
1960
|
+
);
|
|
1961
|
+
onWorkingTurn?.({ conversationId, message });
|
|
1962
|
+
}
|
|
1963
|
+
};
|
|
1964
|
+
const deleteMessages = (conversationId, ids) => {
|
|
1965
|
+
const conv = conversations.get(conversationId);
|
|
1966
|
+
if (!conv || ids.length === 0) return;
|
|
1967
|
+
const drop = new Set(ids);
|
|
1968
|
+
conv.messages = conv.messages.filter((row) => !drop.has(row.id));
|
|
1969
|
+
conv.updatedAt = nowIso();
|
|
1970
|
+
};
|
|
1971
|
+
const applyEvent = (msg) => {
|
|
1972
|
+
const externalId = typeof msg.externalId === "string" && msg.externalId ? msg.externalId : typeof msg.conversationId === "string" ? msg.conversationId : "";
|
|
1973
|
+
if (!externalId) return;
|
|
1974
|
+
if (msg.type === "message.created") {
|
|
1975
|
+
const raw = msg.message && typeof msg.message === "object" ? msg.message : null;
|
|
1976
|
+
const cached = raw ? asCachedMessage(raw) : null;
|
|
1977
|
+
if (cached) {
|
|
1978
|
+
upsertMessage(externalId, cached);
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
void refreshConversation(externalId).catch(() => void 0);
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
if (msg.type === "message.updated") {
|
|
1985
|
+
const raw = msg.message && typeof msg.message === "object" ? msg.message : null;
|
|
1986
|
+
const cached = raw ? asCachedMessage(raw) : null;
|
|
1987
|
+
if (cached) {
|
|
1988
|
+
upsertMessage(externalId, cached);
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
void refreshConversation(externalId).catch(() => void 0);
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
if (msg.type === "message.deleted") {
|
|
1995
|
+
const ids = Array.isArray(msg.ids) ? msg.ids.filter((id) => typeof id === "string") : [];
|
|
1996
|
+
deleteMessages(externalId, ids);
|
|
1997
|
+
}
|
|
1998
|
+
};
|
|
1999
|
+
const stopSocket = () => {
|
|
2000
|
+
if (pingTimer) {
|
|
2001
|
+
clearInterval(pingTimer);
|
|
2002
|
+
pingTimer = null;
|
|
2003
|
+
}
|
|
2004
|
+
if (reconnectTimer) {
|
|
2005
|
+
clearTimeout(reconnectTimer);
|
|
2006
|
+
reconnectTimer = null;
|
|
2007
|
+
}
|
|
2008
|
+
try {
|
|
2009
|
+
socket?.close();
|
|
2010
|
+
} catch {
|
|
2011
|
+
}
|
|
2012
|
+
socket = null;
|
|
2013
|
+
};
|
|
2014
|
+
const connectRealtime = () => {
|
|
2015
|
+
if (stopped) return;
|
|
2016
|
+
const WebSocketCtor = globalThis.WebSocket;
|
|
2017
|
+
if (!WebSocketCtor) {
|
|
2018
|
+
info("WebSocket unavailable; cache will refresh after local writes only");
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
const ws = new WebSocketCtor(toWsUrl(maintainerProUrl, apiKey));
|
|
2022
|
+
socket = ws;
|
|
2023
|
+
ws.addEventListener("open", () => {
|
|
2024
|
+
reconnectAttempt = 0;
|
|
2025
|
+
logger.debug("store websocket open");
|
|
2026
|
+
if (pingTimer) clearInterval(pingTimer);
|
|
2027
|
+
pingTimer = setInterval(() => {
|
|
2028
|
+
try {
|
|
2029
|
+
if (ws.readyState === 1) ws.send(JSON.stringify({ type: "ping" }));
|
|
2030
|
+
} catch {
|
|
2031
|
+
}
|
|
2032
|
+
}, 2e4);
|
|
2033
|
+
});
|
|
2034
|
+
ws.addEventListener("message", (event) => {
|
|
2035
|
+
let payload;
|
|
2036
|
+
try {
|
|
2037
|
+
payload = JSON.parse(String(event.data));
|
|
2038
|
+
} catch {
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
if (payload.type === "hello") {
|
|
2042
|
+
const meta = payload.meta && typeof payload.meta === "object" ? payload.meta : null;
|
|
2043
|
+
const sandboxId = typeof meta?.sandboxId === "string" ? meta.sandboxId : "";
|
|
2044
|
+
if (sandboxId && ws.readyState === 1) {
|
|
2045
|
+
ws.send(
|
|
2046
|
+
JSON.stringify({ type: "subscribe", channels: [`sandbox:${sandboxId}`] })
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
logger.debug({ sandboxId }, "store websocket hello");
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
if (payload.type === "message.created" || payload.type === "message.updated" || payload.type === "message.deleted") {
|
|
2053
|
+
logger.debug({ type: payload.type }, "store websocket event");
|
|
2054
|
+
applyEvent(payload);
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
ws.addEventListener("close", () => {
|
|
2058
|
+
if (socket === ws) socket = null;
|
|
2059
|
+
if (pingTimer) {
|
|
2060
|
+
clearInterval(pingTimer);
|
|
2061
|
+
pingTimer = null;
|
|
2062
|
+
}
|
|
2063
|
+
if (stopped) return;
|
|
2064
|
+
const delay = Math.min(3e4, 1e3 * 2 ** Math.min(reconnectAttempt, 5));
|
|
2065
|
+
reconnectAttempt += 1;
|
|
2066
|
+
logger.debug({ delay, reconnectAttempt }, "store websocket reconnect");
|
|
2067
|
+
reconnectTimer = setTimeout(() => {
|
|
2068
|
+
reconnectTimer = null;
|
|
2069
|
+
void hydrate().catch((err) => {
|
|
2070
|
+
logger.warn(
|
|
2071
|
+
`cache rehydrate failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2072
|
+
);
|
|
2073
|
+
}).finally(() => {
|
|
2074
|
+
connectRealtime();
|
|
2075
|
+
});
|
|
2076
|
+
}, delay);
|
|
2077
|
+
});
|
|
2078
|
+
};
|
|
2079
|
+
const ready = hydrate().catch((err) => {
|
|
2080
|
+
logger.warn(
|
|
2081
|
+
`cache hydrate failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2082
|
+
);
|
|
2083
|
+
return stats();
|
|
2084
|
+
});
|
|
2085
|
+
void ready.then(() => {
|
|
2086
|
+
if (!stopped) connectRealtime();
|
|
2087
|
+
});
|
|
2088
|
+
const store = {
|
|
2089
|
+
ready,
|
|
2090
|
+
stop: () => {
|
|
2091
|
+
stopped = true;
|
|
2092
|
+
stopSocket();
|
|
2093
|
+
},
|
|
2094
|
+
async ensureConversation(id) {
|
|
2095
|
+
await remote.ensureConversation(id);
|
|
2096
|
+
if (!conversations.has(id)) {
|
|
2097
|
+
putConversation(id, []);
|
|
2098
|
+
}
|
|
2099
|
+
},
|
|
2100
|
+
async saveMessage(input) {
|
|
2101
|
+
const saved = await remote.saveMessage(input);
|
|
2102
|
+
await refreshConversation(input.conversationId).catch(() => void 0);
|
|
2103
|
+
return saved;
|
|
2104
|
+
},
|
|
2105
|
+
async listMessages(conversationId) {
|
|
2106
|
+
await ready.catch(() => void 0);
|
|
2107
|
+
const hit = conversations.get(conversationId);
|
|
2108
|
+
if (hit) return cloneMessages(hit.messages);
|
|
2109
|
+
return refreshConversation(conversationId);
|
|
2110
|
+
},
|
|
2111
|
+
async clearWorkingMessages(conversationId) {
|
|
2112
|
+
await remote.clearWorkingMessages?.(conversationId);
|
|
2113
|
+
await refreshConversation(conversationId).catch(() => void 0);
|
|
2114
|
+
},
|
|
2115
|
+
async releaseWorkingTurn(conversationId, parentMessageId) {
|
|
2116
|
+
const result = await remote.releaseWorkingTurn?.(
|
|
2117
|
+
conversationId,
|
|
2118
|
+
parentMessageId
|
|
2119
|
+
);
|
|
2120
|
+
await refreshConversation(conversationId).catch(() => void 0);
|
|
2121
|
+
return result ?? { nextQueued: null };
|
|
2122
|
+
},
|
|
2123
|
+
async recoverQueue(conversationId) {
|
|
2124
|
+
const result = await remote.recoverQueue?.(conversationId);
|
|
2125
|
+
await refreshConversation(conversationId).catch(() => void 0);
|
|
2126
|
+
const conv = conversations.get(conversationId);
|
|
2127
|
+
if (conv) {
|
|
2128
|
+
const working = conv.messages.find(
|
|
2129
|
+
(row) => row.role === "user" && row.queueStatus === "working" && row.senderType !== "developer"
|
|
2130
|
+
);
|
|
2131
|
+
if (working && !userHasAiReply2(conv.messages, working.id)) {
|
|
2132
|
+
logger.info(
|
|
2133
|
+
{ conversationId, messageId: working.id, action: result?.action },
|
|
2134
|
+
"recoverQueue \u2192 resume working turn"
|
|
2135
|
+
);
|
|
2136
|
+
onWorkingTurn?.({ conversationId, message: working });
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
return result ?? {
|
|
2140
|
+
action: "noop",
|
|
2141
|
+
workingId: null,
|
|
2142
|
+
promotedId: null,
|
|
2143
|
+
nextQueued: null
|
|
2144
|
+
};
|
|
2145
|
+
},
|
|
2146
|
+
async listConversations() {
|
|
2147
|
+
await ready.catch(() => void 0);
|
|
2148
|
+
return [...conversations.values()].map((conv) => ({
|
|
2149
|
+
id: conv.id,
|
|
2150
|
+
updatedAt: conv.updatedAt,
|
|
2151
|
+
messages: cloneMessages(conv.messages)
|
|
2152
|
+
})).sort(
|
|
2153
|
+
(a, b) => (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0)
|
|
2154
|
+
);
|
|
2155
|
+
},
|
|
2156
|
+
async saveToolEvents(conversationId, events) {
|
|
2157
|
+
await remote.saveToolEvents?.(conversationId, events);
|
|
2158
|
+
},
|
|
2159
|
+
async uploadAttachments(conversationId, attachments) {
|
|
2160
|
+
if (!remote.uploadAttachments) {
|
|
2161
|
+
return { refs: [], localPaths: [] };
|
|
2162
|
+
}
|
|
2163
|
+
return remote.uploadAttachments(conversationId, attachments);
|
|
2164
|
+
},
|
|
2165
|
+
async materializeMessageAttachments(conversationId, messageId, workspaceDir) {
|
|
2166
|
+
if (!remote.materializeMessageAttachments) return [];
|
|
2167
|
+
return remote.materializeMessageAttachments(
|
|
2168
|
+
conversationId,
|
|
2169
|
+
messageId,
|
|
2170
|
+
workspaceDir
|
|
2171
|
+
);
|
|
2172
|
+
}
|
|
2173
|
+
};
|
|
2174
|
+
return store;
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
// src/http/maintainer-pro-store.ts
|
|
2178
|
+
async function mpFetch(baseUrl, apiKey, pathName, init, fetchImpl) {
|
|
2179
|
+
const url = `${baseUrl.replace(/\/$/, "")}${pathName}`;
|
|
2180
|
+
const res = await fetchImpl(url, {
|
|
2181
|
+
...init,
|
|
2182
|
+
headers: {
|
|
2183
|
+
Authorization: `Bearer ${apiKey}`,
|
|
2184
|
+
"Content-Type": "application/json",
|
|
2185
|
+
...init?.headers ?? {}
|
|
2186
|
+
}
|
|
2187
|
+
});
|
|
2188
|
+
if (!res.ok) {
|
|
2189
|
+
const text = await res.text().catch(() => "");
|
|
2190
|
+
throw new Error(
|
|
2191
|
+
`Maintainer Pro ${pathName} failed (${res.status}): ${text || res.statusText}`
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
959
2195
|
if (contentType.includes("application/json")) {
|
|
960
2196
|
return res.json();
|
|
961
2197
|
}
|
|
@@ -965,7 +2201,8 @@ function createMaintainerProStore(options) {
|
|
|
965
2201
|
const baseUrl = options.baseUrl;
|
|
966
2202
|
const apiKey = options.apiKey;
|
|
967
2203
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
968
|
-
const
|
|
2204
|
+
const logger = options.logger;
|
|
2205
|
+
const tempDir = options.tempDir ?? projectUploadsDir({ workspaceDir: process.cwd() });
|
|
969
2206
|
const store = {
|
|
970
2207
|
async ensureConversation(id) {
|
|
971
2208
|
await mpFetch(
|
|
@@ -988,17 +2225,54 @@ function createMaintainerProStore(options) {
|
|
|
988
2225
|
{
|
|
989
2226
|
method: "POST",
|
|
990
2227
|
body: JSON.stringify({
|
|
2228
|
+
id: input.id,
|
|
991
2229
|
role: input.role,
|
|
992
2230
|
content: input.content,
|
|
993
2231
|
provider: input.provider,
|
|
994
2232
|
senderType: input.senderType,
|
|
995
2233
|
senderName: input.senderName,
|
|
996
|
-
|
|
2234
|
+
parentMessageId: input.parentMessageId ?? void 0,
|
|
2235
|
+
attachmentIds,
|
|
2236
|
+
intent: input.intent,
|
|
2237
|
+
queueStatus: input.queueStatus ?? void 0
|
|
997
2238
|
})
|
|
998
2239
|
},
|
|
999
2240
|
fetchImpl
|
|
1000
2241
|
);
|
|
1001
|
-
return
|
|
2242
|
+
return {
|
|
2243
|
+
...data.message,
|
|
2244
|
+
nextQueued: data.nextQueued ?? null
|
|
2245
|
+
};
|
|
2246
|
+
},
|
|
2247
|
+
async releaseWorkingTurn(conversationId, parentMessageId) {
|
|
2248
|
+
const data = await mpFetch(
|
|
2249
|
+
baseUrl,
|
|
2250
|
+
apiKey,
|
|
2251
|
+
`/api/v1/store/conversations/${encodeURIComponent(conversationId)}/complete-turn`,
|
|
2252
|
+
{
|
|
2253
|
+
method: "POST",
|
|
2254
|
+
body: JSON.stringify({
|
|
2255
|
+
parentMessageId: parentMessageId ?? void 0
|
|
2256
|
+
})
|
|
2257
|
+
},
|
|
2258
|
+
fetchImpl
|
|
2259
|
+
);
|
|
2260
|
+
return { nextQueued: data.nextQueued ?? null };
|
|
2261
|
+
},
|
|
2262
|
+
async recoverQueue(conversationId) {
|
|
2263
|
+
const data = await mpFetch(
|
|
2264
|
+
baseUrl,
|
|
2265
|
+
apiKey,
|
|
2266
|
+
`/api/v1/store/conversations/${encodeURIComponent(conversationId)}/recover-queue`,
|
|
2267
|
+
{ method: "POST", body: "{}" },
|
|
2268
|
+
fetchImpl
|
|
2269
|
+
);
|
|
2270
|
+
return {
|
|
2271
|
+
action: data.action ?? "noop",
|
|
2272
|
+
workingId: data.workingId ?? null,
|
|
2273
|
+
promotedId: data.promotedId ?? null,
|
|
2274
|
+
nextQueued: data.nextQueued ?? null
|
|
2275
|
+
};
|
|
1002
2276
|
},
|
|
1003
2277
|
async clearWorkingMessages(conversationId) {
|
|
1004
2278
|
await mpFetch(
|
|
@@ -1029,6 +2303,16 @@ function createMaintainerProStore(options) {
|
|
|
1029
2303
|
);
|
|
1030
2304
|
return data.conversations ?? [];
|
|
1031
2305
|
},
|
|
2306
|
+
async listSnapshot() {
|
|
2307
|
+
const data = await mpFetch(
|
|
2308
|
+
baseUrl,
|
|
2309
|
+
apiKey,
|
|
2310
|
+
`/api/v1/store/snapshot`,
|
|
2311
|
+
{ method: "GET" },
|
|
2312
|
+
fetchImpl
|
|
2313
|
+
);
|
|
2314
|
+
return data.conversations ?? [];
|
|
2315
|
+
},
|
|
1032
2316
|
async saveToolEvents(conversationId, events) {
|
|
1033
2317
|
if (!events.length) return;
|
|
1034
2318
|
await mpFetch(
|
|
@@ -1046,7 +2330,7 @@ function createMaintainerProStore(options) {
|
|
|
1046
2330
|
if (!attachments?.length) {
|
|
1047
2331
|
return { refs: [], localPaths: [], attachmentIds: [] };
|
|
1048
2332
|
}
|
|
1049
|
-
await
|
|
2333
|
+
await fs5.mkdir(tempDir, { recursive: true });
|
|
1050
2334
|
const refs = [];
|
|
1051
2335
|
const localPaths = [];
|
|
1052
2336
|
const attachmentIds = [];
|
|
@@ -1069,28 +2353,131 @@ function createMaintainerProStore(options) {
|
|
|
1069
2353
|
const id = data.attachment.id;
|
|
1070
2354
|
attachmentIds.push(id);
|
|
1071
2355
|
refs.push(data.attachment.ref || `maintainer-pro://${id}`);
|
|
1072
|
-
const
|
|
1073
|
-
const localPath = path4.join(
|
|
2356
|
+
const localPath = path6.join(
|
|
1074
2357
|
tempDir,
|
|
1075
|
-
`${conversationId}-${id}${
|
|
2358
|
+
`${conversationId}-${id}${extForMime(item.mimeType)}`
|
|
1076
2359
|
);
|
|
1077
|
-
await
|
|
2360
|
+
await fs5.mkdir(path6.dirname(localPath), { recursive: true });
|
|
2361
|
+
await fs5.writeFile(localPath, Buffer.from(item.data, "base64"));
|
|
1078
2362
|
localPaths.push(localPath);
|
|
1079
2363
|
}
|
|
1080
2364
|
return { refs, localPaths, attachmentIds };
|
|
2365
|
+
},
|
|
2366
|
+
async materializeMessageAttachments(conversationId, messageId, workspaceDir) {
|
|
2367
|
+
if (!conversationId || !workspaceDir) return [];
|
|
2368
|
+
const data = await mpFetch(
|
|
2369
|
+
baseUrl,
|
|
2370
|
+
apiKey,
|
|
2371
|
+
`/api/v1/store/conversations/${encodeURIComponent(conversationId)}/messages`,
|
|
2372
|
+
{ method: "GET" },
|
|
2373
|
+
fetchImpl
|
|
2374
|
+
);
|
|
2375
|
+
const messages = data.messages ?? [];
|
|
2376
|
+
const byId = new Map(messages.map((row) => [row.id, row]));
|
|
2377
|
+
const refs = [];
|
|
2378
|
+
const fromMessageIds = [];
|
|
2379
|
+
const seenMsg = /* @__PURE__ */ new Set();
|
|
2380
|
+
const pushRow = (row) => {
|
|
2381
|
+
if (!row || seenMsg.has(row.id)) return;
|
|
2382
|
+
seenMsg.add(row.id);
|
|
2383
|
+
const before = refs.length;
|
|
2384
|
+
refs.push(...row.attachmentPaths ?? []);
|
|
2385
|
+
refs.push(
|
|
2386
|
+
...(row.attachmentIds ?? []).map((id) => `maintainer-pro://${id}`)
|
|
2387
|
+
);
|
|
2388
|
+
if (refs.length > before) fromMessageIds.push(row.id);
|
|
2389
|
+
};
|
|
2390
|
+
let current = messageId ? byId.get(messageId) : void 0;
|
|
2391
|
+
while (current && !seenMsg.has(current.id)) {
|
|
2392
|
+
pushRow(current);
|
|
2393
|
+
const parentId = current.parentMessageId?.trim();
|
|
2394
|
+
current = parentId ? byId.get(parentId) : void 0;
|
|
2395
|
+
}
|
|
2396
|
+
for (const row of messages) {
|
|
2397
|
+
if (row.role && row.role !== "user") continue;
|
|
2398
|
+
pushRow(row);
|
|
2399
|
+
}
|
|
2400
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2401
|
+
const ids = [];
|
|
2402
|
+
for (const ref of refs) {
|
|
2403
|
+
const id = attachmentIdFromRef(ref);
|
|
2404
|
+
if (!id || seen.has(id)) continue;
|
|
2405
|
+
seen.add(id);
|
|
2406
|
+
ids.push(id);
|
|
2407
|
+
}
|
|
2408
|
+
const localPaths = [];
|
|
2409
|
+
for (const id of ids.slice(0, 5)) {
|
|
2410
|
+
const url = `${baseUrl.replace(/\/$/, "")}/api/v1/store/attachments/${encodeURIComponent(id)}`;
|
|
2411
|
+
const res = await fetchImpl(url, {
|
|
2412
|
+
headers: { Authorization: `Bearer ${apiKey}` }
|
|
2413
|
+
});
|
|
2414
|
+
if (!res.ok) {
|
|
2415
|
+
continue;
|
|
2416
|
+
}
|
|
2417
|
+
const mime = res.headers.get("content-type") || "image/png";
|
|
2418
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
2419
|
+
if (!buffer.byteLength) continue;
|
|
2420
|
+
const localPath = path6.join(
|
|
2421
|
+
tempDir,
|
|
2422
|
+
`${id}${extForMime(mime)}`
|
|
2423
|
+
);
|
|
2424
|
+
await fs5.mkdir(tempDir, { recursive: true });
|
|
2425
|
+
await fs5.writeFile(localPath, buffer);
|
|
2426
|
+
localPaths.push(localPath);
|
|
2427
|
+
}
|
|
2428
|
+
logger?.debug(
|
|
2429
|
+
{
|
|
2430
|
+
conversationId,
|
|
2431
|
+
messageId,
|
|
2432
|
+
fromMessageIds,
|
|
2433
|
+
stored: ids.length,
|
|
2434
|
+
written: localPaths.length
|
|
2435
|
+
},
|
|
2436
|
+
"materialize attachments"
|
|
2437
|
+
);
|
|
2438
|
+
return localPaths;
|
|
1081
2439
|
}
|
|
1082
2440
|
};
|
|
1083
|
-
|
|
2441
|
+
if (options.sync === false) {
|
|
2442
|
+
return Object.assign(store, {
|
|
2443
|
+
ready: Promise.resolve({ conversations: 0, messages: 0 }),
|
|
2444
|
+
stop() {
|
|
2445
|
+
}
|
|
2446
|
+
});
|
|
2447
|
+
}
|
|
2448
|
+
return createSyncedChatStore({
|
|
2449
|
+
remote: store,
|
|
2450
|
+
maintainerProUrl: baseUrl,
|
|
2451
|
+
apiKey,
|
|
2452
|
+
logger: options.logger,
|
|
2453
|
+
log: options.log,
|
|
2454
|
+
onWorkingTurn: options.onWorkingTurn
|
|
2455
|
+
});
|
|
1084
2456
|
}
|
|
1085
|
-
function createMaintainerProStoreFromEnv() {
|
|
2457
|
+
function createMaintainerProStoreFromEnv(options) {
|
|
1086
2458
|
const baseUrl = process.env.MAINTAINER_PRO_URL?.trim();
|
|
1087
2459
|
const apiKey = process.env.MAINTAINER_PRO_API_KEY?.trim();
|
|
1088
2460
|
if (!baseUrl || !apiKey) return null;
|
|
1089
|
-
return createMaintainerProStore({
|
|
2461
|
+
return createMaintainerProStore({
|
|
2462
|
+
baseUrl,
|
|
2463
|
+
apiKey,
|
|
2464
|
+
tempDir: projectUploadsDir({
|
|
2465
|
+
workspaceDir: process.env.AI_CLI_WORKSPACE || process.cwd(),
|
|
2466
|
+
sandboxId: process.env.MAINTAINER_PRO_SANDBOX_ID,
|
|
2467
|
+
dataDir: process.env.MAINTAINER_PRO_DATA_DIR
|
|
2468
|
+
}),
|
|
2469
|
+
logger: options?.logger,
|
|
2470
|
+
log: options?.log,
|
|
2471
|
+
sync: options?.sync,
|
|
2472
|
+
onWorkingTurn: options?.onWorkingTurn
|
|
2473
|
+
});
|
|
1090
2474
|
}
|
|
1091
2475
|
|
|
1092
2476
|
// src/workspace-inspect.ts
|
|
2477
|
+
import fs6 from "fs";
|
|
2478
|
+
import path7 from "path";
|
|
1093
2479
|
import { z } from "zod";
|
|
2480
|
+
var CONFIG_INSPECT_TIMEOUT_MS = 18e3;
|
|
1094
2481
|
var inspectJsonSchema = z.object({
|
|
1095
2482
|
kind: z.enum(["next", "vite", "html", "empty", "other"]).optional(),
|
|
1096
2483
|
name: z.string().max(200).optional(),
|
|
@@ -1160,6 +2547,154 @@ ${input.extraContext ? `${input.extraContext.trim()}
|
|
|
1160
2547
|
|
|
1161
2548
|
` : ""}Inspect this project, fix any setup gaps you can, and return the JSON report.`;
|
|
1162
2549
|
}
|
|
2550
|
+
function readSnippet(file, max = 4e3) {
|
|
2551
|
+
try {
|
|
2552
|
+
const text = fs6.readFileSync(file, "utf8");
|
|
2553
|
+
return text.length > max ? `${text.slice(0, max)}
|
|
2554
|
+
\u2026` : text;
|
|
2555
|
+
} catch {
|
|
2556
|
+
return null;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
function envKeysOnly(file) {
|
|
2560
|
+
try {
|
|
2561
|
+
const keys = fs6.readFileSync(file, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#") && line.includes("=")).map((line) => line.slice(0, line.indexOf("=")).trim()).filter(Boolean);
|
|
2562
|
+
return keys.length ? keys.join("\n") : null;
|
|
2563
|
+
} catch {
|
|
2564
|
+
return null;
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
function gatherConfigSnippets(workspaceDir) {
|
|
2568
|
+
const folder = path7.resolve(workspaceDir);
|
|
2569
|
+
const parts = [];
|
|
2570
|
+
const pkg = readSnippet(path7.join(folder, "package.json"));
|
|
2571
|
+
if (pkg) parts.push(`### package.json
|
|
2572
|
+
\`\`\`json
|
|
2573
|
+
${pkg}
|
|
2574
|
+
\`\`\``);
|
|
2575
|
+
for (const lock of [
|
|
2576
|
+
"package-lock.json",
|
|
2577
|
+
"pnpm-lock.yaml",
|
|
2578
|
+
"yarn.lock",
|
|
2579
|
+
"bun.lockb"
|
|
2580
|
+
]) {
|
|
2581
|
+
if (fs6.existsSync(path7.join(folder, lock))) {
|
|
2582
|
+
parts.push(`### lockfile
|
|
2583
|
+
${lock}`);
|
|
2584
|
+
break;
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
for (const rel of [
|
|
2588
|
+
"vite.config.ts",
|
|
2589
|
+
"vite.config.js",
|
|
2590
|
+
"vite.config.mjs",
|
|
2591
|
+
"next.config.js",
|
|
2592
|
+
"next.config.mjs",
|
|
2593
|
+
"next.config.ts"
|
|
2594
|
+
]) {
|
|
2595
|
+
const body = readSnippet(path7.join(folder, rel), 2500);
|
|
2596
|
+
if (body) parts.push(`### ${rel}
|
|
2597
|
+
\`\`\`
|
|
2598
|
+
${body}
|
|
2599
|
+
\`\`\``);
|
|
2600
|
+
}
|
|
2601
|
+
for (const rel of [".env.example", ".env", ".env.local", ".env.development"]) {
|
|
2602
|
+
const keys = envKeysOnly(path7.join(folder, rel));
|
|
2603
|
+
if (keys) parts.push(`### ${rel} keys (values omitted)
|
|
2604
|
+
\`\`\`
|
|
2605
|
+
${keys}
|
|
2606
|
+
\`\`\``);
|
|
2607
|
+
}
|
|
2608
|
+
return parts.join("\n\n") || "(no config files found)";
|
|
2609
|
+
}
|
|
2610
|
+
function configOnlySystemPrompt(input) {
|
|
2611
|
+
return `You are Maintainer Pro's read-only setup advisor.
|
|
2612
|
+
|
|
2613
|
+
Workspace: ${input.workspaceDir}
|
|
2614
|
+
App name: ${input.appName || "the app"}
|
|
2615
|
+
|
|
2616
|
+
You are given configuration file excerpts only.
|
|
2617
|
+
DO NOT edit, create, or delete any files.
|
|
2618
|
+
DO NOT run shell commands, install packages, or start servers.
|
|
2619
|
+
Only infer how this project runs locally from the excerpts.
|
|
2620
|
+
|
|
2621
|
+
Script names you report MUST appear in package.json scripts. Omit chat/ai-server scripts.
|
|
2622
|
+
|
|
2623
|
+
Reply with a short human summary AND one \`\`\`json fence with:
|
|
2624
|
+
|
|
2625
|
+
{
|
|
2626
|
+
"kind": "next" | "vite" | "html" | "empty" | "other",
|
|
2627
|
+
"name": "package or folder name",
|
|
2628
|
+
"summary": "one sentence about this project",
|
|
2629
|
+
"scripts": { "ui": "dev:client", "backend": "dev:server" },
|
|
2630
|
+
"ports": { "ui": 5173, "backend": 4100 },
|
|
2631
|
+
"issues": ["uncertainties"],
|
|
2632
|
+
"fixes": [],
|
|
2633
|
+
"ready": true
|
|
2634
|
+
}`;
|
|
2635
|
+
}
|
|
2636
|
+
function configOnlyUserMessage(input, snippets) {
|
|
2637
|
+
return `${input.extraContext ? `${input.extraContext.trim()}
|
|
2638
|
+
|
|
2639
|
+
` : ""}Analyze these config excerpts and suggest local apps/ports/scripts. Do not change anything on disk.
|
|
2640
|
+
|
|
2641
|
+
${snippets}`;
|
|
2642
|
+
}
|
|
2643
|
+
function toInspectResult(input, text, providerId) {
|
|
2644
|
+
const parsed = extractInspectJson(text);
|
|
2645
|
+
return {
|
|
2646
|
+
kind: parsed?.kind ?? "other",
|
|
2647
|
+
name: parsed?.name?.trim() || input.appName || "",
|
|
2648
|
+
summary: parsed?.summary?.trim() || text.replace(/```json[\s\S]*```/i, "").trim().slice(0, 400),
|
|
2649
|
+
scripts: parsed?.scripts ?? {},
|
|
2650
|
+
ports: parsed?.ports ?? {},
|
|
2651
|
+
issues: parsed?.issues ?? [],
|
|
2652
|
+
fixes: parsed?.fixes ?? [],
|
|
2653
|
+
ready: parsed?.ready ?? parsed?.issues?.length === 0,
|
|
2654
|
+
provider: providerId,
|
|
2655
|
+
rawText: text
|
|
2656
|
+
};
|
|
2657
|
+
}
|
|
2658
|
+
async function inspectConfigOnly(input, opts = {}) {
|
|
2659
|
+
const workspaceDir = path7.resolve(input.workspaceDir);
|
|
2660
|
+
const timeoutMs = opts.timeoutMs ?? CONFIG_INSPECT_TIMEOUT_MS;
|
|
2661
|
+
const provider = await resolveProvider({ preference: "auto" });
|
|
2662
|
+
const snippets = gatherConfigSnippets(workspaceDir);
|
|
2663
|
+
const call = callAi(
|
|
2664
|
+
[{ role: "user", content: configOnlyUserMessage(input, snippets) }],
|
|
2665
|
+
{
|
|
2666
|
+
route: "/local-setup-propose",
|
|
2667
|
+
pageTitle: input.appName || "Setup suggestions",
|
|
2668
|
+
relevantFiles: ["package.json", ".env.example"],
|
|
2669
|
+
data: {
|
|
2670
|
+
workspaceDir,
|
|
2671
|
+
readOnly: true
|
|
2672
|
+
}
|
|
2673
|
+
},
|
|
2674
|
+
{
|
|
2675
|
+
systemPrompt: configOnlySystemPrompt(input),
|
|
2676
|
+
workspaceDir,
|
|
2677
|
+
technical: true
|
|
2678
|
+
}
|
|
2679
|
+
);
|
|
2680
|
+
const timed = await Promise.race([
|
|
2681
|
+
call.then((response) => ({ ok: true, response })),
|
|
2682
|
+
new Promise(
|
|
2683
|
+
(resolve) => setTimeout(
|
|
2684
|
+
() => resolve({ ok: false, error: `Config inspect timed out after ${timeoutMs}ms` }),
|
|
2685
|
+
timeoutMs
|
|
2686
|
+
)
|
|
2687
|
+
)
|
|
2688
|
+
]);
|
|
2689
|
+
if (!timed.ok) {
|
|
2690
|
+
throw new Error(timed.error);
|
|
2691
|
+
}
|
|
2692
|
+
return toInspectResult(
|
|
2693
|
+
input,
|
|
2694
|
+
timed.response.text,
|
|
2695
|
+
timed.response.provider || provider.id
|
|
2696
|
+
);
|
|
2697
|
+
}
|
|
1163
2698
|
async function inspectAndRepairWorkspace(input) {
|
|
1164
2699
|
const workspaceDir = input.workspaceDir;
|
|
1165
2700
|
const provider = await resolveProvider({ preference: "auto" });
|
|
@@ -1180,27 +2715,678 @@ async function inspectAndRepairWorkspace(input) {
|
|
|
1180
2715
|
technical: true
|
|
1181
2716
|
}
|
|
1182
2717
|
);
|
|
1183
|
-
|
|
2718
|
+
return toInspectResult(
|
|
2719
|
+
input,
|
|
2720
|
+
response.text,
|
|
2721
|
+
response.provider || provider.id
|
|
2722
|
+
);
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
// src/host-apps.ts
|
|
2726
|
+
import fs7 from "fs";
|
|
2727
|
+
import path8 from "path";
|
|
2728
|
+
import { createHash as createHash2 } from "crypto";
|
|
2729
|
+
var COLLABORATER_DIR = ".collaborater";
|
|
2730
|
+
var AI_SERVER_APP_ID = "ai-server";
|
|
2731
|
+
var AI_SERVER_DEFAULT_PORT = 3100;
|
|
2732
|
+
var ENV_FILES = [".env", ".env.local", ".env.development", ".env.example"];
|
|
2733
|
+
function isPort(value) {
|
|
2734
|
+
return Number.isInteger(value) && Number(value) >= 1024 && Number(value) <= 65535;
|
|
2735
|
+
}
|
|
2736
|
+
function parsePort(value, fallback = 0) {
|
|
2737
|
+
if (typeof value === "number" && isPort(value)) return value;
|
|
2738
|
+
const text = String(value ?? "").trim();
|
|
2739
|
+
if (!text) return fallback;
|
|
2740
|
+
if (/^\d{2,5}$/.test(text)) {
|
|
2741
|
+
const n = Number(text);
|
|
2742
|
+
return isPort(n) ? n : fallback;
|
|
2743
|
+
}
|
|
2744
|
+
try {
|
|
2745
|
+
const port = Number(new URL(text).port);
|
|
2746
|
+
return isPort(port) ? port : fallback;
|
|
2747
|
+
} catch {
|
|
2748
|
+
const match = text.match(/--port(?:\s+|=)(\d{2,5})/i) || text.match(/\bPORT[=:]\s*(\d{2,5})/i) || text.match(/:(\d{2,5})\b/);
|
|
2749
|
+
const n = match ? Number(match[1]) : 0;
|
|
2750
|
+
return isPort(n) ? n : fallback;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
function dataInput(folder, extra) {
|
|
1184
2754
|
return {
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
2755
|
+
workspaceDir: folder,
|
|
2756
|
+
sandboxId: extra?.sandboxId,
|
|
2757
|
+
dataDir: extra?.dataDir
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2760
|
+
function hostAppsCachePath2(folder, extra) {
|
|
2761
|
+
return hostAppsCachePath(dataInput(folder, extra));
|
|
2762
|
+
}
|
|
2763
|
+
function defaultAiServerApp(port = AI_SERVER_DEFAULT_PORT) {
|
|
2764
|
+
return {
|
|
2765
|
+
id: AI_SERVER_APP_ID,
|
|
2766
|
+
name: "AI server",
|
|
2767
|
+
role: "ai-server",
|
|
2768
|
+
port: parsePort(port, AI_SERVER_DEFAULT_PORT),
|
|
2769
|
+
startCommand: null,
|
|
2770
|
+
source: "default",
|
|
2771
|
+
locked: true
|
|
2772
|
+
};
|
|
2773
|
+
}
|
|
2774
|
+
function normalizeHostApp(raw) {
|
|
2775
|
+
if (!raw || typeof raw !== "object") return null;
|
|
2776
|
+
const row = raw;
|
|
2777
|
+
const port = parsePort(row.port);
|
|
2778
|
+
if (!port) return null;
|
|
2779
|
+
const role = String(row.role || "custom");
|
|
2780
|
+
const allowed = ["ai-server", "ui", "backend", "app", "custom"];
|
|
2781
|
+
const id = String(row.id || "").trim() || (role === "ai-server" ? AI_SERVER_APP_ID : "");
|
|
2782
|
+
if (!id) return null;
|
|
2783
|
+
return {
|
|
2784
|
+
id: id.slice(0, 64),
|
|
2785
|
+
name: String(row.name || id).trim().slice(0, 80) || id,
|
|
2786
|
+
role: allowed.includes(role) ? role : "custom",
|
|
2787
|
+
port,
|
|
2788
|
+
startCommand: typeof row.startCommand === "string" && row.startCommand.trim() ? row.startCommand.trim().slice(0, 200) : null,
|
|
2789
|
+
source: ["default", "env", "package", "ai", "manual"].includes(
|
|
2790
|
+
row.source
|
|
2791
|
+
) ? row.source : "manual",
|
|
2792
|
+
locked: row.locked === true || id === AI_SERVER_APP_ID || role === "ai-server",
|
|
2793
|
+
host: role !== "ai-server" && id !== AI_SERVER_APP_ID && row.host === true,
|
|
2794
|
+
envMaps: normalizeEnvMaps(row.envMaps)
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
function normalizeEnvMaps(raw) {
|
|
2798
|
+
if (!Array.isArray(raw)) return [];
|
|
2799
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2800
|
+
const out = [];
|
|
2801
|
+
for (const item of raw) {
|
|
2802
|
+
if (!item || typeof item !== "object") continue;
|
|
2803
|
+
const row = item;
|
|
2804
|
+
const key = String(row.key || "").trim();
|
|
2805
|
+
const sourceAppId = String(row.sourceAppId || "").trim().slice(0, 64);
|
|
2806
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || key.length > 80) continue;
|
|
2807
|
+
if (!sourceAppId || seen.has(key)) continue;
|
|
2808
|
+
seen.add(key);
|
|
2809
|
+
out.push({ key, sourceAppId });
|
|
2810
|
+
}
|
|
2811
|
+
return out.slice(0, 30);
|
|
2812
|
+
}
|
|
2813
|
+
function ensureSingleHost(apps) {
|
|
2814
|
+
const next = apps.map((app) => ({
|
|
2815
|
+
...app,
|
|
2816
|
+
host: app.role !== "ai-server" && app.id !== AI_SERVER_APP_ID && app.host === true
|
|
2817
|
+
}));
|
|
2818
|
+
const marked = next.filter((app) => app.host);
|
|
2819
|
+
if (marked.length === 1) return next;
|
|
2820
|
+
if (marked.length > 1) {
|
|
2821
|
+
let kept = false;
|
|
2822
|
+
return next.map((app) => {
|
|
2823
|
+
if (!app.host) return app;
|
|
2824
|
+
if (kept) return { ...app, host: false };
|
|
2825
|
+
kept = true;
|
|
2826
|
+
return app;
|
|
2827
|
+
});
|
|
2828
|
+
}
|
|
2829
|
+
const fallback = next.find((app) => app.role === "ui" || app.role === "app") || next.find((app) => app.role !== "ai-server");
|
|
2830
|
+
return next.map(
|
|
2831
|
+
(app) => fallback && app.id === fallback.id ? { ...app, host: true } : app
|
|
2832
|
+
);
|
|
2833
|
+
}
|
|
2834
|
+
function normalizeHostApps(raw) {
|
|
2835
|
+
if (!Array.isArray(raw)) return [];
|
|
2836
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2837
|
+
const apps = [];
|
|
2838
|
+
for (const item of raw) {
|
|
2839
|
+
const app = normalizeHostApp(item);
|
|
2840
|
+
if (!app || seen.has(app.id)) continue;
|
|
2841
|
+
seen.add(app.id);
|
|
2842
|
+
apps.push(app);
|
|
2843
|
+
}
|
|
2844
|
+
return apps;
|
|
2845
|
+
}
|
|
2846
|
+
function ensureAiServerApp(apps, preferredPort = AI_SERVER_DEFAULT_PORT) {
|
|
2847
|
+
const next = normalizeHostApps(apps);
|
|
2848
|
+
const existing = next.find((app) => app.id === AI_SERVER_APP_ID || app.role === "ai-server");
|
|
2849
|
+
if (existing) {
|
|
2850
|
+
existing.id = AI_SERVER_APP_ID;
|
|
2851
|
+
existing.role = "ai-server";
|
|
2852
|
+
existing.locked = true;
|
|
2853
|
+
existing.host = false;
|
|
2854
|
+
existing.name = existing.name || "AI server";
|
|
2855
|
+
return ensureSingleHost([existing, ...next.filter((app) => app !== existing)]);
|
|
2856
|
+
}
|
|
2857
|
+
return ensureSingleHost([defaultAiServerApp(preferredPort), ...next]);
|
|
2858
|
+
}
|
|
2859
|
+
function readText(file) {
|
|
2860
|
+
try {
|
|
2861
|
+
return fs7.readFileSync(file, "utf8");
|
|
2862
|
+
} catch {
|
|
2863
|
+
return null;
|
|
2864
|
+
}
|
|
2865
|
+
}
|
|
2866
|
+
function readEnvFileMap(file) {
|
|
2867
|
+
const text = readText(file);
|
|
2868
|
+
if (!text) return {};
|
|
2869
|
+
const map = {};
|
|
2870
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
2871
|
+
const line = raw.trim();
|
|
2872
|
+
if (!line || line.startsWith("#")) continue;
|
|
2873
|
+
const eq = line.indexOf("=");
|
|
2874
|
+
if (eq < 1) continue;
|
|
2875
|
+
const key = line.slice(0, eq).trim();
|
|
2876
|
+
let value = line.slice(eq + 1).trim();
|
|
2877
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
2878
|
+
value = value.slice(1, -1);
|
|
2879
|
+
}
|
|
2880
|
+
if (key) map[key] = value;
|
|
2881
|
+
}
|
|
2882
|
+
return map;
|
|
2883
|
+
}
|
|
2884
|
+
function readProjectEnvLayers(folder) {
|
|
2885
|
+
const resolved = path8.resolve(folder);
|
|
2886
|
+
const layers = [];
|
|
2887
|
+
const merged = {};
|
|
2888
|
+
for (const name of ENV_FILES) {
|
|
2889
|
+
const file = path8.join(resolved, name);
|
|
2890
|
+
const values = readEnvFileMap(file);
|
|
2891
|
+
if (!Object.keys(values).length) continue;
|
|
2892
|
+
layers.push({ file: name, values });
|
|
2893
|
+
if (name === ".env.example") continue;
|
|
2894
|
+
Object.assign(merged, values);
|
|
2895
|
+
}
|
|
2896
|
+
return { merged, layers };
|
|
2897
|
+
}
|
|
2898
|
+
function readPackageJson(folder) {
|
|
2899
|
+
try {
|
|
2900
|
+
return JSON.parse(fs7.readFileSync(path8.join(folder, "package.json"), "utf8"));
|
|
2901
|
+
} catch {
|
|
2902
|
+
return null;
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
function isChatScript(name, command) {
|
|
2906
|
+
return /\b(ai-server|ai-cli|dev:chat|start:chat)\b/i.test(`${name} ${command}`);
|
|
2907
|
+
}
|
|
2908
|
+
function isUiCommand(command) {
|
|
2909
|
+
return /\b(vite|next|nuxt|astro|remix|react-scripts|webpack-dev-server|parcel)\b/i.test(
|
|
2910
|
+
command
|
|
2911
|
+
);
|
|
2912
|
+
}
|
|
2913
|
+
function configPort(folder, files) {
|
|
2914
|
+
for (const rel of files) {
|
|
2915
|
+
const text = readText(path8.join(folder, rel));
|
|
2916
|
+
if (!text) continue;
|
|
2917
|
+
const match = text.match(/\bport\s*[:=]\s*(\d{2,5})/i) || text.match(/--port(?:\s+|=)(\d{2,5})/i);
|
|
2918
|
+
const port = parsePort(match?.[1]);
|
|
2919
|
+
if (port) return port;
|
|
2920
|
+
}
|
|
2921
|
+
return 0;
|
|
2922
|
+
}
|
|
2923
|
+
function hostAppsFingerprint(folder) {
|
|
2924
|
+
const resolved = path8.resolve(folder);
|
|
2925
|
+
const parts = [];
|
|
2926
|
+
for (const rel of [
|
|
2927
|
+
"package.json",
|
|
2928
|
+
...ENV_FILES,
|
|
2929
|
+
"vite.config.ts",
|
|
2930
|
+
"vite.config.js",
|
|
2931
|
+
"vite.config.mjs",
|
|
2932
|
+
"next.config.js",
|
|
2933
|
+
"next.config.mjs",
|
|
2934
|
+
"next.config.ts"
|
|
2935
|
+
]) {
|
|
2936
|
+
const file = path8.join(resolved, rel);
|
|
2937
|
+
if (!fs7.existsSync(file)) continue;
|
|
2938
|
+
try {
|
|
2939
|
+
const st = fs7.statSync(file);
|
|
2940
|
+
parts.push(`${rel}:${st.size}:${Math.floor(st.mtimeMs)}`);
|
|
2941
|
+
} catch {
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
return createHash2("sha256").update(parts.join("|") || resolved).digest("hex").slice(0, 32);
|
|
2945
|
+
}
|
|
2946
|
+
function pickScript(scripts, names) {
|
|
2947
|
+
return names.find((name) => scripts[name] && !isChatScript(name, scripts[name])) || null;
|
|
2948
|
+
}
|
|
2949
|
+
function detectHostAppsFromFiles(folder, opts = {}) {
|
|
2950
|
+
const resolved = path8.resolve(folder);
|
|
2951
|
+
const reasons = [];
|
|
2952
|
+
const { merged, layers } = readProjectEnvLayers(resolved);
|
|
2953
|
+
const pkg = readPackageJson(resolved);
|
|
2954
|
+
const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
|
|
2955
|
+
const apps = [];
|
|
2956
|
+
const envAi = parsePort(merged.AI_SERVER_PORT) || parsePort(merged.AI_SERVER_URL);
|
|
2957
|
+
const aiPort = envAi || parsePort(opts.preferredAiPort, AI_SERVER_DEFAULT_PORT);
|
|
2958
|
+
apps.push({
|
|
2959
|
+
...defaultAiServerApp(aiPort),
|
|
2960
|
+
source: envAi ? "env" : "default"
|
|
2961
|
+
});
|
|
2962
|
+
const envPorts = {
|
|
2963
|
+
PORT: layers.filter((layer) => layer.file !== ".env.example").map((layer) => ({ file: layer.file, port: parsePort(layer.values.PORT) })).filter((row) => row.port),
|
|
2964
|
+
APP_URL: parsePort(merged.APP_URL || merged.NEXT_PUBLIC_APP_URL || merged.PUBLIC_URL),
|
|
2965
|
+
API_PORT: parsePort(merged.API_PORT || merged.API_URL || merged.VITE_API_URL)
|
|
2966
|
+
};
|
|
2967
|
+
const portValues = [...new Set(envPorts.PORT.map((row) => row.port))];
|
|
2968
|
+
if (portValues.length > 1) {
|
|
2969
|
+
reasons.push(
|
|
2970
|
+
`PORT differs across env files: ${envPorts.PORT.map((row) => `${row.file}=${row.port}`).join(", ")}`
|
|
2971
|
+
);
|
|
2972
|
+
}
|
|
2973
|
+
if (envPorts.PORT[0] && envPorts.APP_URL && envPorts.PORT[0].port !== envPorts.APP_URL) {
|
|
2974
|
+
reasons.push(
|
|
2975
|
+
`PORT=${envPorts.PORT[0].port} does not match APP_URL port ${envPorts.APP_URL}`
|
|
2976
|
+
);
|
|
2977
|
+
}
|
|
2978
|
+
const uiScript = pickScript(scripts, [
|
|
2979
|
+
"dev:client",
|
|
2980
|
+
"dev:ui",
|
|
2981
|
+
"dev:web",
|
|
2982
|
+
"dev:frontend",
|
|
2983
|
+
"client",
|
|
2984
|
+
"start:client"
|
|
2985
|
+
]) || (scripts.dev && !isChatScript("dev", scripts.dev) ? "dev" : null);
|
|
2986
|
+
const apiScript = pickScript(scripts, [
|
|
2987
|
+
"dev:server",
|
|
2988
|
+
"dev:backend",
|
|
2989
|
+
"dev:api",
|
|
2990
|
+
"server",
|
|
2991
|
+
"start:server"
|
|
2992
|
+
]);
|
|
2993
|
+
const vitePort = configPort(resolved, ["vite.config.ts", "vite.config.js", "vite.config.mjs"]) || 5173;
|
|
2994
|
+
const nextPort = configPort(resolved, ["next.config.js", "next.config.mjs", "next.config.ts"]) || 3e3;
|
|
2995
|
+
const portFromEnv = parsePort(merged.PORT);
|
|
2996
|
+
const hostFromEnv = parsePort(merged.HOST_PORT) || parsePort(merged.HOST_URL) || parsePort(merged.CORS_ORIGIN);
|
|
2997
|
+
const uiFromEnv = hostFromEnv || envPorts.APP_URL || (portFromEnv && portFromEnv !== aiPort ? portFromEnv : 0);
|
|
2998
|
+
if (uiScript) {
|
|
2999
|
+
const command = scripts[uiScript] || "";
|
|
3000
|
+
const scriptPort = parsePort(command);
|
|
3001
|
+
const fallback = isUiCommand(command) && /vite/i.test(command) ? vitePort : nextPort;
|
|
3002
|
+
const port = uiFromEnv || scriptPort || fallback;
|
|
3003
|
+
apps.push({
|
|
3004
|
+
id: "ui",
|
|
3005
|
+
name: opts.appName || pkg?.name || "App",
|
|
3006
|
+
role: isUiCommand(command) ? "ui" : "app",
|
|
3007
|
+
port,
|
|
3008
|
+
startCommand: `npm run ${uiScript}`,
|
|
3009
|
+
source: uiFromEnv ? "env" : scriptPort ? "package" : "package",
|
|
3010
|
+
host: true
|
|
3011
|
+
});
|
|
3012
|
+
} else if (uiFromEnv) {
|
|
3013
|
+
apps.push({
|
|
3014
|
+
id: "ui",
|
|
3015
|
+
name: opts.appName || pkg?.name || "App",
|
|
3016
|
+
role: "ui",
|
|
3017
|
+
port: uiFromEnv,
|
|
3018
|
+
startCommand: scripts.dev ? "npm run dev" : null,
|
|
3019
|
+
source: "env",
|
|
3020
|
+
host: true
|
|
3021
|
+
});
|
|
3022
|
+
}
|
|
3023
|
+
if (apiScript && apiScript !== uiScript) {
|
|
3024
|
+
const command = scripts[apiScript] || "";
|
|
3025
|
+
const port = envPorts.API_PORT || parsePort(command) || configPort(resolved, []) || 4100;
|
|
3026
|
+
apps.push({
|
|
3027
|
+
id: "backend",
|
|
3028
|
+
name: "Backend",
|
|
3029
|
+
role: "backend",
|
|
3030
|
+
port,
|
|
3031
|
+
startCommand: `npm run ${apiScript}`,
|
|
3032
|
+
source: envPorts.API_PORT ? "env" : "package"
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
const hostApps = apps.filter((app) => app.role !== "ai-server");
|
|
3036
|
+
if (pkg && Object.keys(scripts).length && hostApps.length === 0) {
|
|
3037
|
+
reasons.push("Found package.json scripts but no host app port in env or config");
|
|
3038
|
+
}
|
|
3039
|
+
return {
|
|
3040
|
+
apps: ensureAiServerApp(apps, aiPort),
|
|
3041
|
+
reasons,
|
|
3042
|
+
confused: reasons.length > 0
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
function readHostAppsCache(folder, extra) {
|
|
3046
|
+
try {
|
|
3047
|
+
const raw = JSON.parse(
|
|
3048
|
+
fs7.readFileSync(hostAppsCachePath2(folder, extra), "utf8")
|
|
3049
|
+
);
|
|
3050
|
+
const apps = ensureAiServerApp(normalizeHostApps(raw.apps));
|
|
3051
|
+
if (!apps.length) return null;
|
|
3052
|
+
return {
|
|
3053
|
+
apps,
|
|
3054
|
+
fingerprint: typeof raw.fingerprint === "string" ? raw.fingerprint : void 0,
|
|
3055
|
+
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : void 0
|
|
3056
|
+
};
|
|
3057
|
+
} catch {
|
|
3058
|
+
return null;
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
var readCollaboraterApps = readHostAppsCache;
|
|
3062
|
+
function writeHostAppsCache(folder, apps, extra = {}, loc) {
|
|
3063
|
+
const resolved = path8.resolve(folder);
|
|
3064
|
+
ensureProjectDataDir(dataInput(resolved, loc));
|
|
3065
|
+
const payload = {
|
|
3066
|
+
version: 1,
|
|
3067
|
+
fingerprint: hostAppsFingerprint(resolved),
|
|
3068
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3069
|
+
apps: ensureAiServerApp(apps),
|
|
3070
|
+
...extra
|
|
3071
|
+
};
|
|
3072
|
+
fs7.writeFileSync(
|
|
3073
|
+
hostAppsCachePath2(resolved, loc),
|
|
3074
|
+
`${JSON.stringify(payload, null, 2)}
|
|
3075
|
+
`,
|
|
3076
|
+
"utf8"
|
|
3077
|
+
);
|
|
3078
|
+
}
|
|
3079
|
+
var writeCollaboraterApps = writeHostAppsCache;
|
|
3080
|
+
function mergeDesiredHostApps(desired, detected) {
|
|
3081
|
+
const byId = new Map(detected.map((app) => [app.id, app]));
|
|
3082
|
+
const byRole = new Map(detected.map((app) => [app.role, app]));
|
|
3083
|
+
const merged = desired.map((app) => {
|
|
3084
|
+
const local = byId.get(app.id) || (app.role !== "custom" ? byRole.get(app.role) : void 0);
|
|
3085
|
+
return {
|
|
3086
|
+
...local,
|
|
3087
|
+
...app,
|
|
3088
|
+
startCommand: app.startCommand || local?.startCommand || null,
|
|
3089
|
+
locked: app.locked || app.id === AI_SERVER_APP_ID || app.role === "ai-server",
|
|
3090
|
+
host: app.role === "ai-server" || app.id === AI_SERVER_APP_ID ? false : app.host === true || app.host !== false && local?.host === true,
|
|
3091
|
+
envMaps: app.envMaps && app.envMaps.length ? app.envMaps : local?.envMaps || []
|
|
3092
|
+
};
|
|
3093
|
+
});
|
|
3094
|
+
return ensureAiServerApp(merged);
|
|
3095
|
+
}
|
|
3096
|
+
function appsFromInspectPorts(ports, scripts, name) {
|
|
3097
|
+
const apps = [];
|
|
3098
|
+
if (ports.ui || scripts.ui) {
|
|
3099
|
+
apps.push({
|
|
3100
|
+
id: "ui",
|
|
3101
|
+
name,
|
|
3102
|
+
role: "ui",
|
|
3103
|
+
port: parsePort(ports.ui, 5173),
|
|
3104
|
+
startCommand: scripts.ui ? `npm run ${scripts.ui}` : null,
|
|
3105
|
+
source: "ai"
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
if (ports.backend || scripts.backend) {
|
|
3109
|
+
apps.push({
|
|
3110
|
+
id: "backend",
|
|
3111
|
+
name: "Backend",
|
|
3112
|
+
role: "backend",
|
|
3113
|
+
port: parsePort(ports.backend, 4100),
|
|
3114
|
+
startCommand: scripts.backend ? `npm run ${scripts.backend}` : null,
|
|
3115
|
+
source: "ai"
|
|
3116
|
+
});
|
|
3117
|
+
}
|
|
3118
|
+
if (!apps.length && (ports.app || scripts.app)) {
|
|
3119
|
+
apps.push({
|
|
3120
|
+
id: "app",
|
|
3121
|
+
name,
|
|
3122
|
+
role: "app",
|
|
3123
|
+
port: parsePort(ports.app, 3e3),
|
|
3124
|
+
startCommand: scripts.app ? `npm run ${scripts.app}` : null,
|
|
3125
|
+
source: "ai"
|
|
3126
|
+
});
|
|
3127
|
+
}
|
|
3128
|
+
return apps;
|
|
3129
|
+
}
|
|
3130
|
+
function collectScriptAlternatives(folder, primary, opts = {}) {
|
|
3131
|
+
const pkg = readPackageJson(folder);
|
|
3132
|
+
const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
|
|
3133
|
+
const usedCommands = new Set(
|
|
3134
|
+
primary.map((app) => app.startCommand?.trim()).filter((cmd) => Boolean(cmd))
|
|
3135
|
+
);
|
|
3136
|
+
const alternatives = [];
|
|
3137
|
+
const hostApp = primary.find((app) => app.role === "ui" || app.role === "app") || primary.find((app) => app.role !== "ai-server");
|
|
3138
|
+
const backendApp = primary.find((app) => app.role === "backend");
|
|
3139
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
3140
|
+
if (isChatScript(name, command)) continue;
|
|
3141
|
+
if (!/^(dev|start|serve)/i.test(name) && !isUiCommand(command)) continue;
|
|
3142
|
+
const startCommand = `npm run ${name}`;
|
|
3143
|
+
if (usedCommands.has(startCommand)) continue;
|
|
3144
|
+
const scriptPort = parsePort(command);
|
|
3145
|
+
const looksBackend = /\b(server|api|backend)\b/i.test(`${name} ${command}`) && !isUiCommand(command);
|
|
3146
|
+
const target = looksBackend ? backendApp || hostApp : hostApp;
|
|
3147
|
+
if (!target) continue;
|
|
3148
|
+
const fallbackPort = scriptPort || (looksBackend ? 4100 : isUiCommand(command) && /vite/i.test(command) ? 5173 : 3e3);
|
|
3149
|
+
alternatives.push({
|
|
3150
|
+
appId: target.id,
|
|
3151
|
+
port: fallbackPort,
|
|
3152
|
+
startCommand,
|
|
3153
|
+
label: `${name} \u2192 ${fallbackPort}`,
|
|
3154
|
+
source: "package"
|
|
3155
|
+
});
|
|
3156
|
+
if (alternatives.length >= 8) break;
|
|
3157
|
+
}
|
|
3158
|
+
const { layers } = readProjectEnvLayers(folder);
|
|
3159
|
+
if (hostApp) {
|
|
3160
|
+
for (const layer of layers) {
|
|
3161
|
+
if (layer.file === ".env.example") continue;
|
|
3162
|
+
const port = parsePort(layer.values.PORT);
|
|
3163
|
+
if (!port || port === hostApp.port) continue;
|
|
3164
|
+
if (alternatives.some(
|
|
3165
|
+
(alt) => alt.appId === hostApp.id && alt.port === port && !alt.startCommand
|
|
3166
|
+
)) {
|
|
3167
|
+
continue;
|
|
3168
|
+
}
|
|
3169
|
+
alternatives.push({
|
|
3170
|
+
appId: hostApp.id,
|
|
3171
|
+
port,
|
|
3172
|
+
startCommand: hostApp.startCommand || null,
|
|
3173
|
+
label: `${layer.file} PORT=${port}`,
|
|
3174
|
+
source: "env"
|
|
3175
|
+
});
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
void opts;
|
|
3179
|
+
return alternatives;
|
|
3180
|
+
}
|
|
3181
|
+
function proposalConfidence(apps, reasons, usedAi) {
|
|
3182
|
+
const hostApps = apps.filter((app) => app.role !== "ai-server");
|
|
3183
|
+
if (!hostApps.length) return "low";
|
|
3184
|
+
if (reasons.length === 0 && hostApps.every((app) => app.startCommand)) {
|
|
3185
|
+
return usedAi ? "medium" : "high";
|
|
3186
|
+
}
|
|
3187
|
+
if (reasons.some((reason) => /differ|does not match|no host app/i.test(reason))) {
|
|
3188
|
+
return "low";
|
|
3189
|
+
}
|
|
3190
|
+
return "medium";
|
|
3191
|
+
}
|
|
3192
|
+
function projectSummaryFromDetect(folder, apps, opts = {}) {
|
|
3193
|
+
const pkg = readPackageJson(folder);
|
|
3194
|
+
const name = opts.appName || pkg?.name || path8.basename(path8.resolve(folder));
|
|
3195
|
+
const hostApps = apps.filter((app) => app.role !== "ai-server");
|
|
3196
|
+
if (!hostApps.length) {
|
|
3197
|
+
return `${name}: no UI/backend ports detected from config yet.`;
|
|
3198
|
+
}
|
|
3199
|
+
return `${name}: ${hostApps.map((app) => `${app.name} on ${app.port}${app.startCommand ? ` (${app.startCommand})` : ""}`).join(", ")}.`;
|
|
3200
|
+
}
|
|
3201
|
+
async function proposeHostAppsFromConfig(input) {
|
|
3202
|
+
const folder = path8.resolve(input.workspaceDir);
|
|
3203
|
+
const preferredAi = parsePort(input.preferredAiPort, AI_SERVER_DEFAULT_PORT);
|
|
3204
|
+
const fingerprint = hostAppsFingerprint(folder);
|
|
3205
|
+
const allowAi = input.allowAi !== false;
|
|
3206
|
+
const detected = detectHostAppsFromFiles(folder, {
|
|
3207
|
+
preferredAiPort: preferredAi,
|
|
3208
|
+
appName: input.appName
|
|
3209
|
+
});
|
|
3210
|
+
let apps = ensureAiServerApp(detected.apps, preferredAi);
|
|
3211
|
+
let reasons = [...detected.reasons];
|
|
3212
|
+
let usedAi = false;
|
|
3213
|
+
let projectSummary = projectSummaryFromDetect(folder, apps, {
|
|
3214
|
+
appName: input.appName
|
|
3215
|
+
});
|
|
3216
|
+
const hostCount = apps.filter((app) => app.role !== "ai-server").length;
|
|
3217
|
+
const needsAi = allowAi && (detected.confused || hostCount === 0 || reasons.some((reason) => /differ|does not match/i.test(reason)));
|
|
3218
|
+
if (needsAi) {
|
|
3219
|
+
try {
|
|
3220
|
+
const inspected = await inspectConfigOnly({
|
|
3221
|
+
workspaceDir: folder,
|
|
3222
|
+
appName: input.appName,
|
|
3223
|
+
extraContext: [
|
|
3224
|
+
"File-based detection was uncertain or incomplete.",
|
|
3225
|
+
...reasons
|
|
3226
|
+
].join("\n")
|
|
3227
|
+
});
|
|
3228
|
+
usedAi = true;
|
|
3229
|
+
const fromAi = appsFromInspectPorts(
|
|
3230
|
+
inspected.ports,
|
|
3231
|
+
inspected.scripts,
|
|
3232
|
+
inspected.name || input.appName || "App"
|
|
3233
|
+
);
|
|
3234
|
+
if (fromAi.length) {
|
|
3235
|
+
apps = ensureAiServerApp(
|
|
3236
|
+
[defaultAiServerApp(preferredAi), ...fromAi],
|
|
3237
|
+
preferredAi
|
|
3238
|
+
);
|
|
3239
|
+
}
|
|
3240
|
+
if (inspected.summary) projectSummary = inspected.summary;
|
|
3241
|
+
if (inspected.issues?.length) {
|
|
3242
|
+
reasons = [...reasons, ...inspected.issues];
|
|
3243
|
+
}
|
|
3244
|
+
} catch (err) {
|
|
3245
|
+
reasons.push(
|
|
3246
|
+
`AI suggest skipped: ${err instanceof Error ? err.message : String(err)}`
|
|
3247
|
+
);
|
|
3248
|
+
}
|
|
3249
|
+
}
|
|
3250
|
+
const alternatives = collectScriptAlternatives(folder, apps, {
|
|
3251
|
+
appName: input.appName
|
|
3252
|
+
});
|
|
3253
|
+
const confidence = proposalConfidence(apps, reasons, usedAi);
|
|
3254
|
+
const needsReview = confidence !== "high" || detected.confused || apps.filter((app) => app.role !== "ai-server").length === 0;
|
|
3255
|
+
return {
|
|
3256
|
+
apps,
|
|
3257
|
+
alternatives,
|
|
3258
|
+
reasons,
|
|
3259
|
+
confidence,
|
|
3260
|
+
projectSummary,
|
|
3261
|
+
usedAi,
|
|
3262
|
+
needsReview,
|
|
3263
|
+
fingerprint
|
|
3264
|
+
};
|
|
3265
|
+
}
|
|
3266
|
+
async function resolveHostApps(input) {
|
|
3267
|
+
const folder = path8.resolve(input.workspaceDir);
|
|
3268
|
+
const loc = { sandboxId: input.sandboxId, dataDir: input.dataDir };
|
|
3269
|
+
const fingerprint = hostAppsFingerprint(folder);
|
|
3270
|
+
const preferredAi = parsePort(input.preferredAiPort, AI_SERVER_DEFAULT_PORT);
|
|
3271
|
+
const detected = detectHostAppsFromFiles(folder, {
|
|
3272
|
+
preferredAiPort: preferredAi,
|
|
3273
|
+
appName: input.appName
|
|
3274
|
+
});
|
|
3275
|
+
const desired = normalizeHostApps(input.desired);
|
|
3276
|
+
if (desired.length) {
|
|
3277
|
+
const apps2 = mergeDesiredHostApps(desired, detected.apps);
|
|
3278
|
+
try {
|
|
3279
|
+
writeHostAppsCache(folder, apps2, { source: "desired" }, loc);
|
|
3280
|
+
} catch {
|
|
3281
|
+
}
|
|
3282
|
+
return {
|
|
3283
|
+
apps: apps2,
|
|
3284
|
+
source: "desired",
|
|
3285
|
+
cached: false,
|
|
3286
|
+
usedAi: false,
|
|
3287
|
+
confused: false,
|
|
3288
|
+
reasons: [],
|
|
3289
|
+
fingerprint
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
3292
|
+
if (!input.force) {
|
|
3293
|
+
const cached = readHostAppsCache(folder, loc);
|
|
3294
|
+
if (cached && cached.fingerprint === fingerprint) {
|
|
3295
|
+
return {
|
|
3296
|
+
apps: ensureAiServerApp(cached.apps, preferredAi),
|
|
3297
|
+
source: "cache",
|
|
3298
|
+
cached: true,
|
|
3299
|
+
usedAi: false,
|
|
3300
|
+
confused: false,
|
|
3301
|
+
reasons: [],
|
|
3302
|
+
fingerprint
|
|
3303
|
+
};
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
const needsAi = Boolean(input.allowAi) && (Boolean(input.force && detected.confused) || detected.confused && detected.apps.filter((app) => app.role !== "ai-server").length === 0 || detected.reasons.some((reason) => /differ|does not match/i.test(reason)));
|
|
3307
|
+
if (needsAi) {
|
|
3308
|
+
try {
|
|
3309
|
+
const inspected = await inspectConfigOnly({
|
|
3310
|
+
workspaceDir: folder,
|
|
3311
|
+
appName: input.appName,
|
|
3312
|
+
extraContext: `Port detection was uncertain:
|
|
3313
|
+
${detected.reasons.join("\n")}
|
|
3314
|
+
Return the real local ports and npm script names.`
|
|
3315
|
+
});
|
|
3316
|
+
const fromAi = appsFromInspectPorts(
|
|
3317
|
+
inspected.ports,
|
|
3318
|
+
inspected.scripts,
|
|
3319
|
+
inspected.name || input.appName || "App"
|
|
3320
|
+
);
|
|
3321
|
+
const apps2 = ensureAiServerApp(
|
|
3322
|
+
[defaultAiServerApp(preferredAi), ...fromAi],
|
|
3323
|
+
preferredAi
|
|
3324
|
+
);
|
|
3325
|
+
writeHostAppsCache(
|
|
3326
|
+
folder,
|
|
3327
|
+
apps2,
|
|
3328
|
+
{
|
|
3329
|
+
source: "ai",
|
|
3330
|
+
reasons: detected.reasons,
|
|
3331
|
+
summary: inspected.summary
|
|
3332
|
+
},
|
|
3333
|
+
loc
|
|
3334
|
+
);
|
|
3335
|
+
return {
|
|
3336
|
+
apps: apps2,
|
|
3337
|
+
source: "ai",
|
|
3338
|
+
cached: false,
|
|
3339
|
+
usedAi: true,
|
|
3340
|
+
confused: detected.confused,
|
|
3341
|
+
reasons: detected.reasons,
|
|
3342
|
+
fingerprint
|
|
3343
|
+
};
|
|
3344
|
+
} catch (err) {
|
|
3345
|
+
detected.reasons.push(
|
|
3346
|
+
`AI inspect skipped: ${err instanceof Error ? err.message : String(err)}`
|
|
3347
|
+
);
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
const apps = ensureAiServerApp(detected.apps, preferredAi);
|
|
3351
|
+
try {
|
|
3352
|
+
writeHostAppsCache(
|
|
3353
|
+
folder,
|
|
3354
|
+
apps,
|
|
3355
|
+
{
|
|
3356
|
+
source: detected.confused ? "env" : "env",
|
|
3357
|
+
reasons: detected.reasons
|
|
3358
|
+
},
|
|
3359
|
+
loc
|
|
3360
|
+
);
|
|
3361
|
+
} catch {
|
|
3362
|
+
}
|
|
3363
|
+
return {
|
|
3364
|
+
apps,
|
|
3365
|
+
source: apps.some((app) => app.source === "env") ? "env" : "default",
|
|
3366
|
+
cached: false,
|
|
3367
|
+
usedAi: false,
|
|
3368
|
+
confused: detected.confused,
|
|
3369
|
+
reasons: detected.reasons,
|
|
3370
|
+
fingerprint
|
|
1195
3371
|
};
|
|
1196
3372
|
}
|
|
1197
3373
|
export {
|
|
3374
|
+
ACCESS_IGNORE_BEGIN,
|
|
3375
|
+
ACCESS_IGNORE_END,
|
|
3376
|
+
AI_SERVER_APP_ID,
|
|
3377
|
+
AI_SERVER_DEFAULT_PORT,
|
|
3378
|
+
COLLABORATER_DIR,
|
|
3379
|
+
DEFAULT_AI_IGNORE_PATHS,
|
|
3380
|
+
HOST_APPS_FILE,
|
|
3381
|
+
MAINTAINER_PRO_HOME_DIR,
|
|
3382
|
+
PROMPT_SECTION,
|
|
1198
3383
|
WORKING_PROVIDER,
|
|
1199
3384
|
buildClaudeUserPrompt,
|
|
1200
3385
|
buildConversationPrompt,
|
|
1201
3386
|
buildCursorPrompt,
|
|
1202
3387
|
buildPriorConversationsContext,
|
|
1203
3388
|
callAi,
|
|
3389
|
+
collectParentChain,
|
|
1204
3390
|
commandExists,
|
|
1205
3391
|
createAntigravityProvider,
|
|
1206
3392
|
createBuiltinProviders,
|
|
@@ -1208,17 +3394,60 @@ export {
|
|
|
1208
3394
|
createClaudeProvider,
|
|
1209
3395
|
createCursorProvider,
|
|
1210
3396
|
createDefaultSystemPrompt,
|
|
3397
|
+
createInfoLogger,
|
|
1211
3398
|
createLocalDirectoryStore,
|
|
3399
|
+
createLogger,
|
|
1212
3400
|
createMaintainerProStore,
|
|
1213
3401
|
createMaintainerProStoreFromEnv,
|
|
3402
|
+
createSyncedChatStore,
|
|
1214
3403
|
createToolValidator,
|
|
3404
|
+
defaultAiServerApp,
|
|
3405
|
+
detectHostAppsFromFiles,
|
|
3406
|
+
ensureAiServerApp,
|
|
3407
|
+
ensureProjectDataDir,
|
|
3408
|
+
ensureSingleHost,
|
|
3409
|
+
formatAccessPolicyPromptSection,
|
|
1215
3410
|
formatClientContext,
|
|
3411
|
+
formatParentChainContext,
|
|
1216
3412
|
getProviderPreference,
|
|
3413
|
+
hostAppsCachePath2 as hostAppsCachePath,
|
|
3414
|
+
hostAppsFingerprint,
|
|
1217
3415
|
inspectAndRepairWorkspace,
|
|
3416
|
+
inspectConfigOnly,
|
|
3417
|
+
isDevMode,
|
|
3418
|
+
isIgnoredRelative,
|
|
3419
|
+
isInsideWorkspace,
|
|
3420
|
+
isPathAllowed,
|
|
3421
|
+
maintainerProHome,
|
|
3422
|
+
mergeDesiredHostApps,
|
|
3423
|
+
normalizeEnvMaps,
|
|
3424
|
+
normalizeHostApp,
|
|
3425
|
+
normalizeHostApps,
|
|
3426
|
+
normalizeIgnorePaths,
|
|
3427
|
+
parentChainForTurn,
|
|
1218
3428
|
parseAiResponse,
|
|
3429
|
+
parseIgnorePathsEnv,
|
|
3430
|
+
parsePort,
|
|
3431
|
+
previewText,
|
|
3432
|
+
hostAppsCachePath as projectHostAppsPath,
|
|
3433
|
+
projectIdForFolder,
|
|
3434
|
+
projectUploadsDir,
|
|
3435
|
+
proposeHostAppsFromConfig,
|
|
1219
3436
|
providerLabel,
|
|
3437
|
+
readCollaboraterApps,
|
|
3438
|
+
readHostAppsCache,
|
|
3439
|
+
readProjectEnvLayers,
|
|
3440
|
+
renderManagedIgnoreBlock,
|
|
1220
3441
|
resolveCliBinary,
|
|
3442
|
+
resolveHostApps,
|
|
3443
|
+
resolveIgnorePaths,
|
|
3444
|
+
resolveLogLevel,
|
|
3445
|
+
resolveProjectDataDir,
|
|
1221
3446
|
resolveProvider,
|
|
3447
|
+
sanitizeProjectId,
|
|
1222
3448
|
saveChatAttachments,
|
|
1223
|
-
toNextRoute
|
|
3449
|
+
toNextRoute,
|
|
3450
|
+
upsertManagedIgnoreFile,
|
|
3451
|
+
writeCollaboraterApps,
|
|
3452
|
+
writeHostAppsCache
|
|
1224
3453
|
};
|