@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +759 -2
- package/dist/app.d.ts +384 -0
- package/dist/app.js +1148 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +353 -0
- package/dist/config.d.ts +97 -0
- package/dist/config.js +171 -0
- package/dist/handlers/agentHandlers.d.ts +18 -0
- package/dist/handlers/agentHandlers.js +20 -0
- package/dist/handlers/assetHandlers.d.ts +29 -0
- package/dist/handlers/assetHandlers.js +35 -0
- package/dist/handlers/capabilities.d.ts +26 -0
- package/dist/handlers/capabilities.js +138 -0
- package/dist/handlers/completionHandlers.d.ts +18 -0
- package/dist/handlers/completionHandlers.js +13 -0
- package/dist/handlers/configHandlers.d.ts +31 -0
- package/dist/handlers/configHandlers.js +59 -0
- package/dist/handlers/conversationHandlers.d.ts +118 -0
- package/dist/handlers/conversationHandlers.js +314 -0
- package/dist/handlers/handoffHandlers.d.ts +17 -0
- package/dist/handlers/handoffHandlers.js +14 -0
- package/dist/handlers/hostHandlers.d.ts +39 -0
- package/dist/handlers/hostHandlers.js +56 -0
- package/dist/handlers/index.d.ts +128 -0
- package/dist/handlers/index.js +92 -0
- package/dist/handlers/messageHandlers.d.ts +11 -0
- package/dist/handlers/messageHandlers.js +10 -0
- package/dist/handlers/modelHandlers.d.ts +17 -0
- package/dist/handlers/modelHandlers.js +19 -0
- package/dist/handlers/noticeHandlers.d.ts +24 -0
- package/dist/handlers/noticeHandlers.js +24 -0
- package/dist/handlers/questionHandlers.d.ts +16 -0
- package/dist/handlers/questionHandlers.js +16 -0
- package/dist/handlers/searchHandlers.d.ts +12 -0
- package/dist/handlers/searchHandlers.js +12 -0
- package/dist/handlers/workspaceHandlers.d.ts +29 -0
- package/dist/handlers/workspaceHandlers.js +47 -0
- package/dist/i18n/en.d.ts +243 -0
- package/dist/i18n/en.js +270 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.js +72 -0
- package/dist/i18n/zh.d.ts +240 -0
- package/dist/i18n/zh.js +257 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/net/UploadReceiver.d.ts +186 -0
- package/dist/net/UploadReceiver.js +494 -0
- package/dist/net/WsHub.d.ts +231 -0
- package/dist/net/WsHub.js +689 -0
- package/dist/net/auth.d.ts +73 -0
- package/dist/net/auth.js +74 -0
- package/dist/net/discovery.d.ts +72 -0
- package/dist/net/discovery.js +171 -0
- package/dist/net/index.d.ts +14 -0
- package/dist/net/index.js +10 -0
- package/dist/runtime/ChatRunner.d.ts +374 -0
- package/dist/runtime/ChatRunner.js +1204 -0
- package/dist/runtime/ConversationDrafts.d.ts +43 -0
- package/dist/runtime/ConversationDrafts.js +47 -0
- package/dist/runtime/RunRegistry.d.ts +79 -0
- package/dist/runtime/RunRegistry.js +108 -0
- package/dist/runtime/ScopeRegistry.d.ts +153 -0
- package/dist/runtime/ScopeRegistry.js +259 -0
- package/dist/runtime/TransientRunner.d.ts +90 -0
- package/dist/runtime/TransientRunner.js +179 -0
- package/dist/runtime/index.d.ts +14 -0
- package/dist/runtime/index.js +10 -0
- package/dist/services/AgentService.d.ts +150 -0
- package/dist/services/AgentService.js +718 -0
- package/dist/services/AssetService.d.ts +338 -0
- package/dist/services/AssetService.js +739 -0
- package/dist/services/AutoNaming.d.ts +92 -0
- package/dist/services/AutoNaming.js +235 -0
- package/dist/services/CompletionService.d.ts +122 -0
- package/dist/services/CompletionService.js +442 -0
- package/dist/services/ConfigService.d.ts +171 -0
- package/dist/services/ConfigService.js +695 -0
- package/dist/services/ConversationService.d.ts +274 -0
- package/dist/services/ConversationService.js +902 -0
- package/dist/services/CredentialService.d.ts +33 -0
- package/dist/services/CredentialService.js +86 -0
- package/dist/services/DiscoveryService.d.ts +182 -0
- package/dist/services/DiscoveryService.js +998 -0
- package/dist/services/HandoffService.d.ts +189 -0
- package/dist/services/HandoffService.js +414 -0
- package/dist/services/Logger.d.ts +130 -0
- package/dist/services/Logger.js +256 -0
- package/dist/services/MessageService.d.ts +88 -0
- package/dist/services/MessageService.js +280 -0
- package/dist/services/ModelService.d.ts +89 -0
- package/dist/services/ModelService.js +489 -0
- package/dist/services/NoticeService.d.ts +93 -0
- package/dist/services/NoticeService.js +217 -0
- package/dist/services/QuestionService.d.ts +184 -0
- package/dist/services/QuestionService.js +457 -0
- package/dist/services/SearchService.d.ts +54 -0
- package/dist/services/SearchService.js +117 -0
- package/dist/services/WorkspaceService.d.ts +96 -0
- package/dist/services/WorkspaceService.js +213 -0
- package/dist/services/imageSize.d.ts +41 -0
- package/dist/services/imageSize.js +146 -0
- package/dist/services/index.d.ts +20 -0
- package/dist/services/index.js +15 -0
- package/dist/services/secretMask.d.ts +24 -0
- package/dist/services/secretMask.js +34 -0
- package/dist/storage/AssetRepository.d.ts +87 -0
- package/dist/storage/AssetRepository.js +180 -0
- package/dist/storage/ConversationRepository.d.ts +55 -0
- package/dist/storage/ConversationRepository.js +128 -0
- package/dist/storage/CredentialRepository.d.ts +28 -0
- package/dist/storage/CredentialRepository.js +47 -0
- package/dist/storage/Database.d.ts +95 -0
- package/dist/storage/Database.js +180 -0
- package/dist/storage/MessageRepository.d.ts +104 -0
- package/dist/storage/MessageRepository.js +323 -0
- package/dist/storage/MetaRepository.d.ts +21 -0
- package/dist/storage/MetaRepository.js +33 -0
- package/dist/storage/NoticeRepository.d.ts +31 -0
- package/dist/storage/NoticeRepository.js +89 -0
- package/dist/storage/QuestionRepository.d.ts +34 -0
- package/dist/storage/QuestionRepository.js +96 -0
- package/dist/storage/WorkspaceRepository.d.ts +25 -0
- package/dist/storage/WorkspaceRepository.js +44 -0
- package/dist/storage/dbWorker.d.ts +10 -0
- package/dist/storage/dbWorker.js +132 -0
- package/dist/storage/index.d.ts +20 -0
- package/dist/storage/index.js +16 -0
- package/dist/storage/migrations.d.ts +26 -0
- package/dist/storage/migrations.js +207 -0
- package/dist/storage/types.d.ts +298 -0
- package/dist/storage/types.js +14 -0
- package/dist/wire/errors.d.ts +105 -0
- package/dist/wire/errors.js +143 -0
- package/dist/wire/events.d.ts +102 -0
- package/dist/wire/events.js +13 -0
- package/dist/wire/frames.d.ts +106 -0
- package/dist/wire/frames.js +156 -0
- package/dist/wire/index.d.ts +31 -0
- package/dist/wire/index.js +31 -0
- package/dist/wire/methods.d.ts +1896 -0
- package/dist/wire/methods.js +99 -0
- package/dist/wire/states.d.ts +487 -0
- package/dist/wire/states.js +61 -0
- package/package.json +63 -3
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@` 补全的数据来源:一个项目的文件清单(F-H5 / D7)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么在宿主:远程前端看不到目录(PRD §3 尺子 ①),`@` 菜单列的是磁盘上的真实文件。
|
|
5
|
+
*
|
|
6
|
+
* 口径(D7 逐条):
|
|
7
|
+
* - **按需扫描**:第一次问才扫,扫完缓存一下;
|
|
8
|
+
* - **git 仓库优先 `git ls-files`**:只列 git 认的文件(含未跟踪但没被 ignore 的),天然排除了
|
|
9
|
+
* `node_modules`、构建产物这些不该出现在补全里的东西;
|
|
10
|
+
* - **非 git 用遍历**:跳过 `node_modules` / `.git` / `dist` 三个目录;
|
|
11
|
+
* - **20000 条上限**:到了上限就停,并如实说「被截断了」;
|
|
12
|
+
* - **5s 超时**:超时返回已经读到的那些(可能不完整),并说明它不完整;
|
|
13
|
+
* - **15s 缓存**:15 秒内重复问不重新扫;
|
|
14
|
+
* - **未就绪要说话**:还在扫就回 `scanning`,扫不出来回 `unavailable` + 原因。
|
|
15
|
+
* 假装空目录比慢更糟 —— 界面会以为这个项目里没有文件。
|
|
16
|
+
*
|
|
17
|
+
* 计时与「跑 git」都是注入点:用例用假时钟与假执行器验证超时 / 缓存 / 上限,
|
|
18
|
+
* 不需要真的等 5 秒,也不需要真的有 git。
|
|
19
|
+
*/
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
import { promises as fs } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { DEFAULT_LANGUAGE, render } from "../i18n/index.js";
|
|
25
|
+
import { HostError } from "../wire/errors.js";
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// 实现事实(D7 的几个数字)
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/** 清单条数上限(D7:20000 条) */
|
|
31
|
+
export const DEFAULT_MAX_ENTRIES = 20_000;
|
|
32
|
+
/** 扫描超时(D7:5s) */
|
|
33
|
+
export const DEFAULT_SCAN_TIMEOUT_MS = 5_000;
|
|
34
|
+
/** 缓存有效期(D7:15s) */
|
|
35
|
+
export const DEFAULT_CACHE_TTL_MS = 15_000;
|
|
36
|
+
/** 一次补全默认回多少条 */
|
|
37
|
+
export const DEFAULT_LIMIT = 50;
|
|
38
|
+
/** 一次补全最多回多少条 */
|
|
39
|
+
export const MAX_LIMIT = 500;
|
|
40
|
+
/** 遍历时要跳过的目录名(D7:`node_modules/.git/dist`) */
|
|
41
|
+
const EXCLUDED_DIRS = new Set(["node_modules", ".git", "dist"]);
|
|
42
|
+
/** `git ls-files` 的超时上限(比扫描预算略小,留出解析与遍历的时间) */
|
|
43
|
+
const GIT_TIMEOUT_MARGIN_MS = 500;
|
|
44
|
+
/** 真实时钟 */
|
|
45
|
+
export const systemCompletionClock = {
|
|
46
|
+
now: () => Date.now(),
|
|
47
|
+
setTimeout: (handler, ms) => {
|
|
48
|
+
const timer = setTimeout(handler, ms);
|
|
49
|
+
timer.unref?.();
|
|
50
|
+
return () => clearTimeout(timer);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// 文案
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
function text(messageKey, params = {}) {
|
|
57
|
+
return {
|
|
58
|
+
code: messageKey,
|
|
59
|
+
messageKey,
|
|
60
|
+
params,
|
|
61
|
+
message: render(DEFAULT_LANGUAGE, messageKey, params),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// 服务
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
export class CompletionService {
|
|
68
|
+
findWorkspace;
|
|
69
|
+
runGit;
|
|
70
|
+
clock;
|
|
71
|
+
maxEntries;
|
|
72
|
+
scanTimeoutMs;
|
|
73
|
+
cacheTtlMs;
|
|
74
|
+
/** workspaceId → 扫描状态(cwd 创建即定死,PRD D9,所以按项目缓存是安全的) */
|
|
75
|
+
states = new Map();
|
|
76
|
+
constructor(options = {}) {
|
|
77
|
+
this.findWorkspace = options.findWorkspace;
|
|
78
|
+
this.runGit = options.runGit ?? defaultGitLister;
|
|
79
|
+
this.clock = options.clock ?? systemCompletionClock;
|
|
80
|
+
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
81
|
+
this.scanTimeoutMs = options.scanTimeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS;
|
|
82
|
+
this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* `completion.files`(F-H5 / D7)。
|
|
86
|
+
*
|
|
87
|
+
* 三条出口,全部带 `state`:
|
|
88
|
+
* - 有现成清单 → `ready`(可能带「不完整 / 被截断」的说明);
|
|
89
|
+
* - 正在扫 → `scanning`(客户端继续打字时会再来问一次);
|
|
90
|
+
* - 扫不出来 → `unavailable` + 原因。
|
|
91
|
+
*/
|
|
92
|
+
async files(params) {
|
|
93
|
+
const workspaceId = requireWorkspaceId(params.workspaceId);
|
|
94
|
+
const cwd = await this.resolveCwd(workspaceId);
|
|
95
|
+
const state = this.stateOf(workspaceId);
|
|
96
|
+
const fresh = state.expiresAt !== null && state.expiresAt > this.clock.now();
|
|
97
|
+
if (fresh)
|
|
98
|
+
return this.respond(state, params);
|
|
99
|
+
if (state.pending === null) {
|
|
100
|
+
// 第一次问:起一次后台扫描,先如实说「还在扫」(D7:未就绪要说话)
|
|
101
|
+
state.pending = this.scan(cwd, state).finally(() => {
|
|
102
|
+
state.pending = null;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
state: "scanning",
|
|
107
|
+
entries: [],
|
|
108
|
+
truncated: false,
|
|
109
|
+
scannedAt: null,
|
|
110
|
+
problem: text("completion.scanning"),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 等一次扫描落地(预热与用例用)。
|
|
115
|
+
*
|
|
116
|
+
* 接线层可以在项目被打开时预取一次:这样用户第一次按 `@` 已经是 `ready`。
|
|
117
|
+
*/
|
|
118
|
+
async prefetch(workspaceId) {
|
|
119
|
+
const id = requireWorkspaceId(workspaceId);
|
|
120
|
+
const state = this.stateOf(id);
|
|
121
|
+
if (state.pending !== null) {
|
|
122
|
+
await state.pending;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (state.expiresAt !== null && state.expiresAt > this.clock.now())
|
|
126
|
+
return;
|
|
127
|
+
const cwd = await this.resolveCwd(id);
|
|
128
|
+
state.pending = this.scan(cwd, state).finally(() => {
|
|
129
|
+
state.pending = null;
|
|
130
|
+
});
|
|
131
|
+
await state.pending;
|
|
132
|
+
}
|
|
133
|
+
/** 丢掉一个项目的缓存(项目被删时用;下次问会重新扫) */
|
|
134
|
+
forget(workspaceId) {
|
|
135
|
+
this.states.delete(workspaceId);
|
|
136
|
+
}
|
|
137
|
+
// -------------------------------------------------------------------------
|
|
138
|
+
// 扫描
|
|
139
|
+
// -------------------------------------------------------------------------
|
|
140
|
+
/**
|
|
141
|
+
* 扫一次并写状态。
|
|
142
|
+
*
|
|
143
|
+
* 超时不是「失败」:已经读到的那些仍然是真话,所以照常给出去,只是把
|
|
144
|
+
* 「不完整」这件事写在 `problem` 里(D7:未就绪要说话,而不是假装齐了)。
|
|
145
|
+
*/
|
|
146
|
+
async scan(cwd, state) {
|
|
147
|
+
state.phase = "scanning";
|
|
148
|
+
state.paths = [];
|
|
149
|
+
state.truncated = false;
|
|
150
|
+
state.problem = null;
|
|
151
|
+
state.scannedAt = null;
|
|
152
|
+
state.expiresAt = null;
|
|
153
|
+
const bucket = [];
|
|
154
|
+
const flag = { truncated: false };
|
|
155
|
+
const work = this.collect(cwd, bucket, flag);
|
|
156
|
+
const outcome = await this.withTimeout(work);
|
|
157
|
+
if (outcome === "timeout") {
|
|
158
|
+
state.paths = bucket;
|
|
159
|
+
state.truncated = flag.truncated || bucket.length >= this.maxEntries;
|
|
160
|
+
state.scannedAt = this.clock.now();
|
|
161
|
+
state.problem = text("completion.timedOut", { seconds: this.scanTimeoutMs / 1000 });
|
|
162
|
+
// 读到了一点就还能用(哪怕不完整);一条都没读到才是「拿不到」
|
|
163
|
+
state.phase = bucket.length > 0 ? "ready" : "unavailable";
|
|
164
|
+
if (state.phase === "unavailable") {
|
|
165
|
+
state.problem = text("completion.unavailable", {
|
|
166
|
+
reason: render(DEFAULT_LANGUAGE, "completion.timedOut", {
|
|
167
|
+
seconds: this.scanTimeoutMs / 1000,
|
|
168
|
+
}),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
state.expiresAt = state.scannedAt + this.cacheTtlMs;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (outcome.failure !== null) {
|
|
175
|
+
state.phase = "unavailable";
|
|
176
|
+
state.paths = [];
|
|
177
|
+
state.truncated = false;
|
|
178
|
+
state.scannedAt = this.clock.now();
|
|
179
|
+
state.problem = outcome.failure;
|
|
180
|
+
state.expiresAt = state.scannedAt + this.cacheTtlMs;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
state.phase = "ready";
|
|
184
|
+
state.paths = bucket;
|
|
185
|
+
state.truncated = flag.truncated;
|
|
186
|
+
state.scannedAt = this.clock.now();
|
|
187
|
+
state.problem = flag.truncated
|
|
188
|
+
? text("completion.truncated", { limit: this.maxEntries })
|
|
189
|
+
: null;
|
|
190
|
+
state.expiresAt = state.scannedAt + this.cacheTtlMs;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* 收集文件路径:git 仓库走 `git ls-files`,否则自己遍历。
|
|
194
|
+
*
|
|
195
|
+
* 失败时把「为什么拿不到」作为 `failure` 回出去(`null` 表示正常拿到)。
|
|
196
|
+
*/
|
|
197
|
+
async collect(cwd, bucket, flag) {
|
|
198
|
+
let info;
|
|
199
|
+
try {
|
|
200
|
+
info = await fs.stat(cwd);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return {
|
|
204
|
+
failure: text("completion.unavailable", {
|
|
205
|
+
reason: render(DEFAULT_LANGUAGE, "completion.noFolder"),
|
|
206
|
+
}),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (!info.isDirectory()) {
|
|
210
|
+
return {
|
|
211
|
+
failure: text("completion.unavailable", {
|
|
212
|
+
reason: render(DEFAULT_LANGUAGE, "completion.noFolder"),
|
|
213
|
+
}),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (await this.isGitRepo(cwd)) {
|
|
217
|
+
const listing = await this.runGit(cwd, this.gitTimeout());
|
|
218
|
+
if (listing.ok) {
|
|
219
|
+
for (const file of listing.files) {
|
|
220
|
+
if (bucket.length >= this.maxEntries) {
|
|
221
|
+
flag.truncated = true;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
const normalized = normalizePath(file);
|
|
225
|
+
if (normalized !== "")
|
|
226
|
+
bucket.push(normalized);
|
|
227
|
+
}
|
|
228
|
+
return { failure: null };
|
|
229
|
+
}
|
|
230
|
+
// git 跑不动(不在 PATH、仓库坏掉)就退回遍历:拿得到清单比走哪条路重要
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await this.walk(cwd, "", bucket, flag, 0);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
return {
|
|
237
|
+
failure: text("completion.unavailable", {
|
|
238
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
239
|
+
}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return { failure: null };
|
|
243
|
+
}
|
|
244
|
+
/** 遍历目录(跳过 {@link EXCLUDED_DIRS};符号链接不跟,避免环) */
|
|
245
|
+
async walk(root, relative, bucket, flag, depth) {
|
|
246
|
+
if (bucket.length >= this.maxEntries) {
|
|
247
|
+
flag.truncated = true;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (depth > 32)
|
|
251
|
+
return;
|
|
252
|
+
const dir = relative === "" ? root : join(root, relative);
|
|
253
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
if (bucket.length >= this.maxEntries) {
|
|
256
|
+
flag.truncated = true;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (entry.isSymbolicLink())
|
|
260
|
+
continue;
|
|
261
|
+
const childRelative = relative === "" ? entry.name : `${relative}/${entry.name}`;
|
|
262
|
+
if (entry.isDirectory()) {
|
|
263
|
+
if (EXCLUDED_DIRS.has(entry.name))
|
|
264
|
+
continue;
|
|
265
|
+
await this.walk(root, childRelative, bucket, flag, depth + 1);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (entry.isFile())
|
|
269
|
+
bucket.push(childRelative);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** `<cwd>/.git` 在(目录或 worktree 的 `.git` 文件)就当它是 git 仓库 */
|
|
273
|
+
async isGitRepo(cwd) {
|
|
274
|
+
try {
|
|
275
|
+
await fs.stat(join(cwd, ".git"));
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/** 给 git 的超时:比整次扫描的预算略小 */
|
|
283
|
+
gitTimeout() {
|
|
284
|
+
return Math.max(this.scanTimeoutMs - GIT_TIMEOUT_MARGIN_MS, 1);
|
|
285
|
+
}
|
|
286
|
+
/** 跑 `work`,到点就放弃等它(返回 `"timeout"`) */
|
|
287
|
+
async withTimeout(work) {
|
|
288
|
+
// 取一个可变盒子:赋值发生在计时器回调里,TS 看不到那次赋值
|
|
289
|
+
const timer = { cancel: null };
|
|
290
|
+
const timeout = new Promise((resolve) => {
|
|
291
|
+
timer.cancel = this.clock.setTimeout(() => resolve("timeout"), this.scanTimeoutMs);
|
|
292
|
+
});
|
|
293
|
+
try {
|
|
294
|
+
return await Promise.race([work, timeout]);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
timer.cancel?.();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// -------------------------------------------------------------------------
|
|
301
|
+
// 出口
|
|
302
|
+
// -------------------------------------------------------------------------
|
|
303
|
+
/** 状态 → 出参(只切出这次要的那一页) */
|
|
304
|
+
respond(state, params) {
|
|
305
|
+
if (state.phase !== "ready") {
|
|
306
|
+
return {
|
|
307
|
+
state: state.phase,
|
|
308
|
+
entries: [],
|
|
309
|
+
truncated: false,
|
|
310
|
+
scannedAt: state.scannedAt === null ? null : new Date(state.scannedAt).toISOString(),
|
|
311
|
+
problem: state.problem,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
state: "ready",
|
|
316
|
+
entries: selectEntries(state.paths, params.prefix ?? "", clampLimit(params.limit)),
|
|
317
|
+
truncated: state.truncated,
|
|
318
|
+
scannedAt: state.scannedAt === null ? null : new Date(state.scannedAt).toISOString(),
|
|
319
|
+
problem: state.problem,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
stateOf(workspaceId) {
|
|
323
|
+
const existing = this.states.get(workspaceId);
|
|
324
|
+
if (existing)
|
|
325
|
+
return existing;
|
|
326
|
+
const created = {
|
|
327
|
+
phase: "scanning",
|
|
328
|
+
paths: [],
|
|
329
|
+
truncated: false,
|
|
330
|
+
scannedAt: null,
|
|
331
|
+
problem: null,
|
|
332
|
+
expiresAt: null,
|
|
333
|
+
pending: null,
|
|
334
|
+
};
|
|
335
|
+
this.states.set(workspaceId, created);
|
|
336
|
+
return created;
|
|
337
|
+
}
|
|
338
|
+
async resolveCwd(workspaceId) {
|
|
339
|
+
const found = this.findWorkspace ? await this.findWorkspace(workspaceId) : undefined;
|
|
340
|
+
if (!found) {
|
|
341
|
+
throw new HostError("not_found", {
|
|
342
|
+
params: { entity: "workspace" },
|
|
343
|
+
detail: {
|
|
344
|
+
workspaceId,
|
|
345
|
+
reason: this.findWorkspace ? "workspace_not_found" : "workspace_lookup_not_configured",
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return found.cwd;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// 默认的 git 执行器
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
/** 用子进程跑 `git ls-files`;不在仓库里或者没有 git 时回 `ok: false`(调用方退回遍历) */
|
|
356
|
+
export const defaultGitLister = async (cwd, timeoutMs) => {
|
|
357
|
+
try {
|
|
358
|
+
const { stdout } = await execFileAsync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, windowsHide: true });
|
|
359
|
+
return {
|
|
360
|
+
ok: true,
|
|
361
|
+
files: stdout.split("\u0000").filter((item) => item !== ""),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
files: [],
|
|
368
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// 纯函数
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
/** 入参的 workspaceId:空串按「参数不对」处理 */
|
|
376
|
+
function requireWorkspaceId(value) {
|
|
377
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
378
|
+
if (raw === "") {
|
|
379
|
+
throw new HostError("invalid_params", {
|
|
380
|
+
params: { field: "workspaceId" },
|
|
381
|
+
detail: { field: "workspaceId", reason: "empty" },
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return raw;
|
|
385
|
+
}
|
|
386
|
+
/** 路径分隔符统一成 `/`,去掉开头的 `./` */
|
|
387
|
+
function normalizePath(path) {
|
|
388
|
+
const unified = path.replace(/\\/g, "/");
|
|
389
|
+
return unified.startsWith("./") ? unified.slice(2) : unified;
|
|
390
|
+
}
|
|
391
|
+
/** 一次回多少条:缺省 {@link DEFAULT_LIMIT},上限 {@link MAX_LIMIT} */
|
|
392
|
+
function clampLimit(limit) {
|
|
393
|
+
if (limit === undefined || !Number.isFinite(limit))
|
|
394
|
+
return DEFAULT_LIMIT;
|
|
395
|
+
const value = Math.floor(limit);
|
|
396
|
+
if (value <= 0)
|
|
397
|
+
return DEFAULT_LIMIT;
|
|
398
|
+
return Math.min(value, MAX_LIMIT);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* 把清单按已输入的前缀切成「目录 + 文件」两类条目。
|
|
402
|
+
*
|
|
403
|
+
* 前缀可以是路径形式(`src/co`):目录部分当过滤基准,最后一段当「还差什么」;
|
|
404
|
+
* 没有斜杠时(`@READ`)同时按路径开头与文件名开头匹配,这样根目录里的文件也找得到。
|
|
405
|
+
*/
|
|
406
|
+
export function selectEntries(paths, prefix, limit) {
|
|
407
|
+
const normalized = normalizePath(prefix.trim());
|
|
408
|
+
// 前缀以 `/` 结尾表示「这一层全要」:目录部分就是它,剩下要匹配的那一段为空
|
|
409
|
+
const underDirectory = normalized.endsWith("/");
|
|
410
|
+
const trimmed = underDirectory ? normalized.slice(0, -1) : normalized;
|
|
411
|
+
const slash = trimmed.lastIndexOf("/");
|
|
412
|
+
const baseDir = slash === -1 ? (underDirectory ? `${trimmed}/` : "") : trimmed.slice(0, slash + 1);
|
|
413
|
+
const partial = underDirectory
|
|
414
|
+
? ""
|
|
415
|
+
: (slash === -1 ? trimmed : trimmed.slice(slash + 1)).toLowerCase();
|
|
416
|
+
const dirs = new Map();
|
|
417
|
+
const files = [];
|
|
418
|
+
for (const path of paths) {
|
|
419
|
+
if (baseDir !== "" && !path.toLowerCase().startsWith(baseDir.toLowerCase()))
|
|
420
|
+
continue;
|
|
421
|
+
const relative = baseDir === "" ? path : path.slice(baseDir.length);
|
|
422
|
+
if (relative === "")
|
|
423
|
+
continue;
|
|
424
|
+
const nextSlash = relative.indexOf("/");
|
|
425
|
+
if (nextSlash !== -1) {
|
|
426
|
+
const segment = relative.slice(0, nextSlash);
|
|
427
|
+
if (!segment.toLowerCase().startsWith(partial))
|
|
428
|
+
continue;
|
|
429
|
+
const dirPath = `${baseDir}${segment}`;
|
|
430
|
+
if (!dirs.has(dirPath)) {
|
|
431
|
+
dirs.set(dirPath, { path: dirPath, name: segment, kind: "directory" });
|
|
432
|
+
}
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (!relative.toLowerCase().startsWith(partial))
|
|
436
|
+
continue;
|
|
437
|
+
files.push({ path, name: relative, kind: "file" });
|
|
438
|
+
}
|
|
439
|
+
const orderedDirs = [...dirs.values()].sort((left, right) => left.name.localeCompare(right.name));
|
|
440
|
+
const orderedFiles = files.sort((left, right) => left.name.localeCompare(right.name));
|
|
441
|
+
return [...orderedDirs, ...orderedFiles].slice(0, limit);
|
|
442
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 配置服务:`config.*`(F-F1 / F-F2 / F-E2 / F-F6 / F-F7 / F-A8 / D21)。
|
|
3
|
+
*
|
|
4
|
+
* 配置只有一处真相:数据根下的 `config.json`,读写都经 SDK 的 `ConfigManager`(D21)。
|
|
5
|
+
* 这个服务负责三件事:
|
|
6
|
+
*
|
|
7
|
+
* 1. **读得安全**:端点密钥读出即掩码(F-F2),明文不出网;顺带把「这份配置哪里不对」
|
|
8
|
+
* 结构化地报出来(F-A8:配错了要能当场修,问题指到具体字段)。
|
|
9
|
+
* 2. **写得及时**:每次写都回报「应用了什么」—— 哪些能力被重载、哪些 Workspace 的 Scope
|
|
10
|
+
* 被回收(随之重连 MCP)。改完不用重启(F-E2),客户端据此提示「已生效」。
|
|
11
|
+
* 3. **删得干净**:被引用的端点拒删并列出引用者(F-F2),默认项指向的东西不存在时
|
|
12
|
+
* 要能指出来(F-F7)。
|
|
13
|
+
*
|
|
14
|
+
* 「保存」这个动作本身不是状态:每次写就是一次生效(F-F1 没有全局保存键)。
|
|
15
|
+
*/
|
|
16
|
+
import type { AgentRepository } from "@ai-zen/agents-sdk";
|
|
17
|
+
import type { AppConfig, ConfigManager } from "@ai-zen/agents-sdk";
|
|
18
|
+
import type { ConfigDeleteEndpointParams, ConfigDeleteEndpointResult, ConfigDeleteMcpParams, ConfigDeleteMcpResult, ConfigGetDiscoveryParams, ConfigGetDiscoveryResult, ConfigGetParams, ConfigGetResult, ConfigPatchParams, ConfigPatchResult, ConfigSaveEndpointParams, ConfigSaveEndpointResult, ConfigSaveMcpParams, ConfigSaveMcpResult, ConfigSetDefaultsParams, ConfigSetDefaultsResult, ConfigView, HostReloadParams, HostReloadResult } from "../wire/index.js";
|
|
19
|
+
import { DiscoveryService, type WorkspaceCwdLookup } from "./DiscoveryService.js";
|
|
20
|
+
import { ModelService } from "./ModelService.js";
|
|
21
|
+
/** 改配置时用到的几类「生效」信号(→ {@link ConfigApplyReport}) */
|
|
22
|
+
export interface ConfigApplyChange {
|
|
23
|
+
/** `config.json` 被改写了 */
|
|
24
|
+
config: boolean;
|
|
25
|
+
/** 某一层 Mcp 被改写了 */
|
|
26
|
+
mcp: boolean;
|
|
27
|
+
/** 助手定义被改写了(`agent.*` 域用得到,配置域自己不会置它) */
|
|
28
|
+
agents: boolean;
|
|
29
|
+
/** 具体改了哪些段落:`endpoints` / `models` / `imageModels` / `defaults` / `limits` / `behavior` / `mcp` */
|
|
30
|
+
sections: string[];
|
|
31
|
+
/** MCP 变更涉及哪些项目(user 作用域时为空) */
|
|
32
|
+
workspaceIds: string[];
|
|
33
|
+
}
|
|
34
|
+
/** 改动生效之后能从组装层问到的事实 */
|
|
35
|
+
export interface ConfigApplyOutcome {
|
|
36
|
+
/** 因为配置变化被回收(随后会重建)的 Workspace Scope */
|
|
37
|
+
recycledWorkspaceIds?: string[];
|
|
38
|
+
/** 这次改动合成的那一条通知(F-F9:一次改动合成一条通知) */
|
|
39
|
+
noticeId?: string | null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 「让改动生效」的注入点。
|
|
43
|
+
*
|
|
44
|
+
* 谁能回收 Scope、谁能发通知,都是组装层知道的事(`ScopeRegistry` / 通知中心);
|
|
45
|
+
* 配置服务只描述「变了什么」,由这一跳去动手、并回报真的回收了哪些。
|
|
46
|
+
*/
|
|
47
|
+
export interface ConfigApplyPort {
|
|
48
|
+
apply(change: ConfigApplyChange): Promise<ConfigApplyOutcome> | ConfigApplyOutcome;
|
|
49
|
+
}
|
|
50
|
+
/** 配置服务的依赖;由单根组装后注入 */
|
|
51
|
+
export interface ConfigServiceOptions {
|
|
52
|
+
/** 配置唯一入口(SDK) */
|
|
53
|
+
configManager: ConfigManager;
|
|
54
|
+
/** 模型视图(`ConfigView.models` 与 `model.*` 用同一份口径);缺省按本服务依赖造一个 */
|
|
55
|
+
models?: ModelService;
|
|
56
|
+
/** 能力发现(`config.getDiscovery` 与 `config.saveMcp` / `config.deleteMcp`);缺省按本服务依赖造一个 */
|
|
57
|
+
discovery?: DiscoveryService;
|
|
58
|
+
/** 助手定义仓储(检查默认助手还在不在) */
|
|
59
|
+
agents?: AgentRepository;
|
|
60
|
+
/** 按 workspaceId 取 cwd(MCP 的 workspace 作用域要落到项目里) */
|
|
61
|
+
findWorkspace?: WorkspaceCwdLookup;
|
|
62
|
+
/** 让改动生效的注入点;不注入时回报「已重载配置、没有 Scope 被回收」 */
|
|
63
|
+
apply?: ConfigApplyPort;
|
|
64
|
+
/** 时钟,测试注入用;缺省 `Date.now` */
|
|
65
|
+
now?: () => number;
|
|
66
|
+
}
|
|
67
|
+
export declare class ConfigService {
|
|
68
|
+
private readonly configManager;
|
|
69
|
+
private readonly modelService;
|
|
70
|
+
private readonly discoveryService;
|
|
71
|
+
private readonly agents?;
|
|
72
|
+
private readonly findWorkspace?;
|
|
73
|
+
private readonly applyPort?;
|
|
74
|
+
private readonly now;
|
|
75
|
+
constructor(options: ConfigServiceOptions);
|
|
76
|
+
/** 能力发现服务(组装层要 `scopeAbilityPaths` / `mcpPaths` 时从这里拿同一份) */
|
|
77
|
+
get discovery(): DiscoveryService;
|
|
78
|
+
/** `config.get`(F-F1 / D21:读也走宿主,密钥掩码,同时报出配置问题) */
|
|
79
|
+
get(_params?: ConfigGetParams): Promise<ConfigGetResult>;
|
|
80
|
+
/** `config.getDiscovery`(F-G1 / F-G2 / F-F6):转给发现服务,同一份口径 */
|
|
81
|
+
getDiscovery(params?: ConfigGetDiscoveryParams): Promise<ConfigGetDiscoveryResult>;
|
|
82
|
+
/**
|
|
83
|
+
* `config.patch`(F-F1 / F-E2):默认项、上限、行为开关这些散落的小设置。
|
|
84
|
+
*
|
|
85
|
+
* 端点与模型各有自己的写方法(要做引用检查与掩码处理),不从这里进。
|
|
86
|
+
*/
|
|
87
|
+
patch(params: ConfigPatchParams): Promise<ConfigPatchResult>;
|
|
88
|
+
/**
|
|
89
|
+
* `config.setDefaults`(F-F7 / D2 / D6 / R58)。
|
|
90
|
+
*
|
|
91
|
+
* 与 `config.patch` 的 `defaults` 是同一件事,只是入口更窄:默认助手、兜底模型、
|
|
92
|
+
* 迁移模型、图片模型、取名模型、自动命名开关,以及缺省语言(`hello` 没声明语言时用哪种)。
|
|
93
|
+
*/
|
|
94
|
+
setDefaults(params: ConfigSetDefaultsParams): Promise<ConfigSetDefaultsResult>;
|
|
95
|
+
/**
|
|
96
|
+
* `host.reload`(PRD F-E1 / 裁定 R63):从磁盘重读一遍,让改动生效。
|
|
97
|
+
*
|
|
98
|
+
* 不写盘:改的是磁盘上的东西(`config.json`、助手定义、技能 / 工具目录、MCP 文件),这里只
|
|
99
|
+
* 把「哪几类要重新装配」交给注入的生效一跳(回收受影响的 Scope + 合成一条通知),出参与
|
|
100
|
+
* `config.patch` 同一份形状,客户端于是可以用同一套提示。
|
|
101
|
+
*/
|
|
102
|
+
reload(params?: HostReloadParams): Promise<HostReloadResult>;
|
|
103
|
+
/**
|
|
104
|
+
* `config.saveEndpoint`(F-F2):同一个方法兼创建与修改。
|
|
105
|
+
*
|
|
106
|
+
* `apiKey` 省略 = 保留原值。把掩码当密钥存回去是这条路上最容易犯的错,所以:
|
|
107
|
+
* - 省略 → 保留;
|
|
108
|
+
* - 传回来的值正好等于当前掩码 → 当作「没改」(客户端把读到的整行原样写回来了);
|
|
109
|
+
* - 空串 → 把密钥清掉(这是唯一能清掉它的写法)。
|
|
110
|
+
*/
|
|
111
|
+
saveEndpoint(params: ConfigSaveEndpointParams): Promise<ConfigSaveEndpointResult>;
|
|
112
|
+
/**
|
|
113
|
+
* `config.deleteEndpoint`(F-F2):被模型引用时拒删并列出引用者。
|
|
114
|
+
*
|
|
115
|
+
* `dryRun` 只算引用:客户端先拿这份清单去问用户(F-F9 的二次确认)。
|
|
116
|
+
*/
|
|
117
|
+
deleteEndpoint(params: ConfigDeleteEndpointParams): Promise<ConfigDeleteEndpointResult>;
|
|
118
|
+
/**
|
|
119
|
+
* `config.saveMcp`(F-F6 / F-G1):有 id 则改、无则建,一次只动一条。
|
|
120
|
+
*
|
|
121
|
+
* `workspace` 作用域要落到某个项目自己的 `.ai-zen/mcp.json` 里,所以必须给 `workspaceId`。
|
|
122
|
+
* 合并与落盘的规则不在这里:发现服务的写入口只作一件事(把这一条叠到磁盘原文上),
|
|
123
|
+
* 于是这一版不认识的字段与 `oauth` 原样保留(F-F6)。
|
|
124
|
+
*/
|
|
125
|
+
saveMcp(params: ConfigSaveMcpParams): Promise<ConfigSaveMcpResult>;
|
|
126
|
+
/**
|
|
127
|
+
* `config.deleteMcp`(F-F6):删掉这一层里的一条。
|
|
128
|
+
*
|
|
129
|
+
* `id` 本来就不在这一层里时不是失败(`removed: false`):重试一次删除、或另一个客户端
|
|
130
|
+
* 先一步删掉了,都不该变成一个错误(与 `config.deleteEndpoint` 的 `removed` 同口径)。
|
|
131
|
+
*/
|
|
132
|
+
deleteMcp(params: ConfigDeleteMcpParams): Promise<ConfigDeleteMcpResult>;
|
|
133
|
+
/** MCP 写到哪:`user` 作用域与项目无关,`workspace` 作用域必须能拿到项目的 cwd */
|
|
134
|
+
private mcpWriteCwd;
|
|
135
|
+
/** 整个配置的只读形状(密钥掩码,F-F2) */
|
|
136
|
+
toView(config: AppConfig): Promise<ConfigView>;
|
|
137
|
+
private readConfig;
|
|
138
|
+
/** 端点的密钥:省略 / 等于当前掩码 → 保留原值;空串 → 清掉 */
|
|
139
|
+
private resolveApiKey;
|
|
140
|
+
/**
|
|
141
|
+
* 默认项合并(F-F7)。
|
|
142
|
+
*
|
|
143
|
+
* 每个 id 都要能落到实处:默认助手要真有定义文件,模型要在模型清单里,图片模型要在图片模型
|
|
144
|
+
* 清单里。传 `null` 表示清空(不是「不改」——不改就不传这个键)。
|
|
145
|
+
*/
|
|
146
|
+
private mergeDefaults;
|
|
147
|
+
/** 上限合并(F-F8:工具输出上限) */
|
|
148
|
+
private mergeLimits;
|
|
149
|
+
/** 行为开关合并(D15 的中断修复口径 + F-E2 的工具刷新时机) */
|
|
150
|
+
private mergeBehavior;
|
|
151
|
+
/** workspace 作用域的 MCP 要写到项目里,所以必须能拿到 cwd */
|
|
152
|
+
private requireWorkspaceCwd;
|
|
153
|
+
/** 附件上限(D5:20MB/张、4 张/条):与 wire 层的常量同源 */
|
|
154
|
+
private assetLimits;
|
|
155
|
+
/**
|
|
156
|
+
* 这份配置现在有什么问题。
|
|
157
|
+
*
|
|
158
|
+
* `error` 是「这么用会立刻失败」(模型指向不存在的端点、默认模型没了);
|
|
159
|
+
* `warning` 是「能跑但不对劲」(端点还没配密钥、交接线不低于硬线)。
|
|
160
|
+
* 每条都带 `path`,客户端据此把光标放到那一栏。
|
|
161
|
+
*/
|
|
162
|
+
private issuesOf;
|
|
163
|
+
/**
|
|
164
|
+
* 把一次改动「说出来」。
|
|
165
|
+
*
|
|
166
|
+
* `reloaded` 按改动涉及的段落推:端点 / 模型 / 图片模型 / 上限会改变工具与模型的可用性,
|
|
167
|
+
* MCP 会改变工具清单,所以这两种都算 `tools` 重载;`skills` 是每次装配现扫的目录,
|
|
168
|
+
* 配置改动不改变它。
|
|
169
|
+
*/
|
|
170
|
+
private apply;
|
|
171
|
+
}
|