@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.
Files changed (146) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +21 -0
  3. package/README.md +759 -2
  4. package/dist/app.d.ts +384 -0
  5. package/dist/app.js +1148 -0
  6. package/dist/cli.d.ts +21 -0
  7. package/dist/cli.js +353 -0
  8. package/dist/config.d.ts +97 -0
  9. package/dist/config.js +171 -0
  10. package/dist/handlers/agentHandlers.d.ts +18 -0
  11. package/dist/handlers/agentHandlers.js +20 -0
  12. package/dist/handlers/assetHandlers.d.ts +29 -0
  13. package/dist/handlers/assetHandlers.js +35 -0
  14. package/dist/handlers/capabilities.d.ts +26 -0
  15. package/dist/handlers/capabilities.js +138 -0
  16. package/dist/handlers/completionHandlers.d.ts +18 -0
  17. package/dist/handlers/completionHandlers.js +13 -0
  18. package/dist/handlers/configHandlers.d.ts +31 -0
  19. package/dist/handlers/configHandlers.js +59 -0
  20. package/dist/handlers/conversationHandlers.d.ts +118 -0
  21. package/dist/handlers/conversationHandlers.js +314 -0
  22. package/dist/handlers/handoffHandlers.d.ts +17 -0
  23. package/dist/handlers/handoffHandlers.js +14 -0
  24. package/dist/handlers/hostHandlers.d.ts +39 -0
  25. package/dist/handlers/hostHandlers.js +56 -0
  26. package/dist/handlers/index.d.ts +128 -0
  27. package/dist/handlers/index.js +92 -0
  28. package/dist/handlers/messageHandlers.d.ts +11 -0
  29. package/dist/handlers/messageHandlers.js +10 -0
  30. package/dist/handlers/modelHandlers.d.ts +17 -0
  31. package/dist/handlers/modelHandlers.js +19 -0
  32. package/dist/handlers/noticeHandlers.d.ts +24 -0
  33. package/dist/handlers/noticeHandlers.js +24 -0
  34. package/dist/handlers/questionHandlers.d.ts +16 -0
  35. package/dist/handlers/questionHandlers.js +16 -0
  36. package/dist/handlers/searchHandlers.d.ts +12 -0
  37. package/dist/handlers/searchHandlers.js +12 -0
  38. package/dist/handlers/workspaceHandlers.d.ts +29 -0
  39. package/dist/handlers/workspaceHandlers.js +47 -0
  40. package/dist/i18n/en.d.ts +243 -0
  41. package/dist/i18n/en.js +270 -0
  42. package/dist/i18n/index.d.ts +43 -0
  43. package/dist/i18n/index.js +72 -0
  44. package/dist/i18n/zh.d.ts +240 -0
  45. package/dist/i18n/zh.js +257 -0
  46. package/dist/index.d.ts +17 -0
  47. package/dist/index.js +15 -0
  48. package/dist/net/UploadReceiver.d.ts +186 -0
  49. package/dist/net/UploadReceiver.js +494 -0
  50. package/dist/net/WsHub.d.ts +231 -0
  51. package/dist/net/WsHub.js +689 -0
  52. package/dist/net/auth.d.ts +73 -0
  53. package/dist/net/auth.js +74 -0
  54. package/dist/net/discovery.d.ts +72 -0
  55. package/dist/net/discovery.js +171 -0
  56. package/dist/net/index.d.ts +14 -0
  57. package/dist/net/index.js +10 -0
  58. package/dist/runtime/ChatRunner.d.ts +374 -0
  59. package/dist/runtime/ChatRunner.js +1204 -0
  60. package/dist/runtime/ConversationDrafts.d.ts +43 -0
  61. package/dist/runtime/ConversationDrafts.js +47 -0
  62. package/dist/runtime/RunRegistry.d.ts +79 -0
  63. package/dist/runtime/RunRegistry.js +108 -0
  64. package/dist/runtime/ScopeRegistry.d.ts +153 -0
  65. package/dist/runtime/ScopeRegistry.js +259 -0
  66. package/dist/runtime/TransientRunner.d.ts +90 -0
  67. package/dist/runtime/TransientRunner.js +179 -0
  68. package/dist/runtime/index.d.ts +14 -0
  69. package/dist/runtime/index.js +10 -0
  70. package/dist/services/AgentService.d.ts +150 -0
  71. package/dist/services/AgentService.js +718 -0
  72. package/dist/services/AssetService.d.ts +338 -0
  73. package/dist/services/AssetService.js +739 -0
  74. package/dist/services/AutoNaming.d.ts +92 -0
  75. package/dist/services/AutoNaming.js +235 -0
  76. package/dist/services/CompletionService.d.ts +122 -0
  77. package/dist/services/CompletionService.js +442 -0
  78. package/dist/services/ConfigService.d.ts +171 -0
  79. package/dist/services/ConfigService.js +695 -0
  80. package/dist/services/ConversationService.d.ts +274 -0
  81. package/dist/services/ConversationService.js +902 -0
  82. package/dist/services/CredentialService.d.ts +33 -0
  83. package/dist/services/CredentialService.js +86 -0
  84. package/dist/services/DiscoveryService.d.ts +182 -0
  85. package/dist/services/DiscoveryService.js +998 -0
  86. package/dist/services/HandoffService.d.ts +189 -0
  87. package/dist/services/HandoffService.js +414 -0
  88. package/dist/services/Logger.d.ts +130 -0
  89. package/dist/services/Logger.js +256 -0
  90. package/dist/services/MessageService.d.ts +88 -0
  91. package/dist/services/MessageService.js +280 -0
  92. package/dist/services/ModelService.d.ts +89 -0
  93. package/dist/services/ModelService.js +489 -0
  94. package/dist/services/NoticeService.d.ts +93 -0
  95. package/dist/services/NoticeService.js +217 -0
  96. package/dist/services/QuestionService.d.ts +184 -0
  97. package/dist/services/QuestionService.js +457 -0
  98. package/dist/services/SearchService.d.ts +54 -0
  99. package/dist/services/SearchService.js +117 -0
  100. package/dist/services/WorkspaceService.d.ts +96 -0
  101. package/dist/services/WorkspaceService.js +213 -0
  102. package/dist/services/imageSize.d.ts +41 -0
  103. package/dist/services/imageSize.js +146 -0
  104. package/dist/services/index.d.ts +20 -0
  105. package/dist/services/index.js +15 -0
  106. package/dist/services/secretMask.d.ts +24 -0
  107. package/dist/services/secretMask.js +34 -0
  108. package/dist/storage/AssetRepository.d.ts +87 -0
  109. package/dist/storage/AssetRepository.js +180 -0
  110. package/dist/storage/ConversationRepository.d.ts +55 -0
  111. package/dist/storage/ConversationRepository.js +128 -0
  112. package/dist/storage/CredentialRepository.d.ts +28 -0
  113. package/dist/storage/CredentialRepository.js +47 -0
  114. package/dist/storage/Database.d.ts +95 -0
  115. package/dist/storage/Database.js +180 -0
  116. package/dist/storage/MessageRepository.d.ts +104 -0
  117. package/dist/storage/MessageRepository.js +323 -0
  118. package/dist/storage/MetaRepository.d.ts +21 -0
  119. package/dist/storage/MetaRepository.js +33 -0
  120. package/dist/storage/NoticeRepository.d.ts +31 -0
  121. package/dist/storage/NoticeRepository.js +89 -0
  122. package/dist/storage/QuestionRepository.d.ts +34 -0
  123. package/dist/storage/QuestionRepository.js +96 -0
  124. package/dist/storage/WorkspaceRepository.d.ts +25 -0
  125. package/dist/storage/WorkspaceRepository.js +44 -0
  126. package/dist/storage/dbWorker.d.ts +10 -0
  127. package/dist/storage/dbWorker.js +132 -0
  128. package/dist/storage/index.d.ts +20 -0
  129. package/dist/storage/index.js +16 -0
  130. package/dist/storage/migrations.d.ts +26 -0
  131. package/dist/storage/migrations.js +207 -0
  132. package/dist/storage/types.d.ts +298 -0
  133. package/dist/storage/types.js +14 -0
  134. package/dist/wire/errors.d.ts +105 -0
  135. package/dist/wire/errors.js +143 -0
  136. package/dist/wire/events.d.ts +102 -0
  137. package/dist/wire/events.js +13 -0
  138. package/dist/wire/frames.d.ts +106 -0
  139. package/dist/wire/frames.js +156 -0
  140. package/dist/wire/index.d.ts +31 -0
  141. package/dist/wire/index.js +31 -0
  142. package/dist/wire/methods.d.ts +1896 -0
  143. package/dist/wire/methods.js +99 -0
  144. package/dist/wire/states.d.ts +487 -0
  145. package/dist/wire/states.js +61 -0
  146. package/package.json +63 -3
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Workspace 服务:项目(一个工作目录)的登记、查看、改名、删除。
3
+ *
4
+ * 对应能力编号:F-C1 的前置(项目中承载对话)、D9 / D10 / D12(一等公民、cwd 不可变、
5
+ * 目录不存在照常可看)、D11(删项目连带删名下对话,目录本身不碰)、D17(删除前给代价)、
6
+ * F-F9(不可撤销动作由客户端二次确认,宿主负责算账)、R14(cwd 唯一)、R20(列表顺序)。
7
+ *
8
+ * 这个服务只做「一条项目记录怎么变」与「代价是多少」;文件系统方面只回答一件事 ——
9
+ * 目录还在不在(`FolderFact.exists`),是否只读由客户端判断(D12)。
10
+ */
11
+ import type { AssetRepository } from "../storage/AssetRepository.js";
12
+ import type { ConversationRepository } from "../storage/ConversationRepository.js";
13
+ import type { MessageRepository } from "../storage/MessageRepository.js";
14
+ import type { WorkspaceRepository } from "../storage/WorkspaceRepository.js";
15
+ import type { FolderFact, WorkspaceCreateParams, WorkspaceCreateResult, WorkspaceGetParams, WorkspaceGetResult, WorkspaceListParams, WorkspaceListResult, WorkspaceRemoveParams, WorkspaceRemoveResult, WorkspaceRenameParams, WorkspaceRenameResult } from "../wire/index.js";
16
+ import type { ConversationRuntimePort, AssetSweeper } from "./ConversationService.js";
17
+ /**
18
+ * `workspace.remove` 的结果。
19
+ *
20
+ * wire 的 `WorkspaceRemoveResult` 只有「删了没 + 代价」;真正把 `assets/` 下的文件
21
+ * 清掉是 asset 那一层的事(PRD D17「图片按引用清理」),所以这里多带一份这些对话引用过的
22
+ * 素材 id(删完之后由 asset 层问「还剩几张引用」,归零才删)。
23
+ */
24
+ export interface WorkspaceRemovalReport extends WorkspaceRemoveResult {
25
+ /** 这些对话引用过的素材 id(可能还有别的对话在用) */
26
+ assetIds: string[];
27
+ }
28
+ /** Workspace 服务的依赖;由单根组装后注入,服务自己不取任何全局路径 */
29
+ export interface WorkspaceServiceDeps {
30
+ workspaces: WorkspaceRepository;
31
+ conversations: ConversationRepository;
32
+ messages: MessageRepository;
33
+ assets: AssetRepository;
34
+ /** 引用归零的素材交给它删行删文件;不注入时字节会留在盘上(引用行已经没了) */
35
+ sweepAssets?: AssetSweeper;
36
+ /**
37
+ * 运行时钩子:删项目会连带删掉它名下的对话,这些对话在运行时里可能还留着
38
+ * 临时覆盖、空闲计时之类的东西,删之前一并请它扔掉。
39
+ */
40
+ runtime: ConversationRuntimePort;
41
+ /** 时钟,测试注入用;缺省 `Date.now` */
42
+ now?: () => number;
43
+ }
44
+ /** 目录是否存在;取不到(权限、悬空链接等)按「不存在」回报事实(D12) */
45
+ export declare function pathExists(path: string): Promise<boolean>;
46
+ /**
47
+ * 目录事实:路径 + 还在不在(PRD D12)。
48
+ *
49
+ * 只给事实,不给结论 —— 目录没了对话照样能看,是否禁掉发送由客户端判断。
50
+ */
51
+ export declare function toFolderFact(cwd: string): Promise<FolderFact>;
52
+ export declare class WorkspaceService {
53
+ private readonly workspaces;
54
+ private readonly conversations;
55
+ private readonly messages;
56
+ private readonly assets;
57
+ private readonly sweepAssets?;
58
+ private readonly runtime;
59
+ private readonly now;
60
+ constructor(deps: WorkspaceServiceDeps);
61
+ /**
62
+ * 列出项目(PRD D9 / D12 / R20)。
63
+ *
64
+ * 顺序按创建时间倒序(仓储里定死)。目录已不存在的项目默认也列出来 —— 它名下的对话
65
+ * 与历史仍然可用(D12),藏起来只会让人以为数据丢了;显式传 `includeMissingFolders: false`
66
+ * 时才过滤掉。
67
+ */
68
+ list(params?: WorkspaceListParams): Promise<WorkspaceListResult>;
69
+ /**
70
+ * 登记一个目录为项目(PRD D9 / D10)。
71
+ *
72
+ * 同一个 cwd 只登记一次(R14:两次登记会让两个 Scope 抢同一个目录)。名字不传就用
73
+ * 目录名 —— 这个名字之后可改,cwd 不可改。
74
+ */
75
+ create(params: WorkspaceCreateParams): Promise<WorkspaceCreateResult>;
76
+ get(params: WorkspaceGetParams): Promise<WorkspaceGetResult>;
77
+ /** 改名:id 与 cwd 不动(PRD D9),只换显示名 */
78
+ rename(params: WorkspaceRenameParams): Promise<WorkspaceRenameResult>;
79
+ /**
80
+ * 删项目(PRD D11 / D17 / F-F9 / R7)。
81
+ *
82
+ * `dryRun` 只算账:名下对话数、消息数、素材数,客户端拿去向用户说明代价。真删时按外键
83
+ * 级联把对话、消息、问题行、素材的引用关系一起删掉;`assets/` 下的文件不在服务的删除范围里
84
+ * (D11:目录本身宿主不碰),但素材的字节要按引用清 —— 那张图可能还有别的项目的对话在用,
85
+ * 所以只把「这些对话引用过哪几份素材」交给 asset 层,删完之后由它问「还剩几张引用」。
86
+ */
87
+ remove(params: WorkspaceRemoveParams): Promise<WorkspaceRemovalReport>;
88
+ private requireRow;
89
+ /**
90
+ * 组装一个项目的对外形状。
91
+ *
92
+ * `updatedAt` 在 `workspaces` 表里没有对应列,这里取「最近一次对话活动」当它;一条对话
93
+ * 都没有时就是创建时间。`lastActivityAt` 同理来自对话的 `updatedAt`(新增消息会推进它)。
94
+ */
95
+ private toView;
96
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Workspace 服务:项目(一个工作目录)的登记、查看、改名、删除。
3
+ *
4
+ * 对应能力编号:F-C1 的前置(项目中承载对话)、D9 / D10 / D12(一等公民、cwd 不可变、
5
+ * 目录不存在照常可看)、D11(删项目连带删名下对话,目录本身不碰)、D17(删除前给代价)、
6
+ * F-F9(不可撤销动作由客户端二次确认,宿主负责算账)、R14(cwd 唯一)、R20(列表顺序)。
7
+ *
8
+ * 这个服务只做「一条项目记录怎么变」与「代价是多少」;文件系统方面只回答一件事 ——
9
+ * 目录还在不在(`FolderFact.exists`),是否只读由客户端判断(D12)。
10
+ */
11
+ import { randomUUID } from "node:crypto";
12
+ import { stat } from "node:fs/promises";
13
+ import { basename, isAbsolute, resolve } from "node:path";
14
+ import { HostError } from "../wire/errors.js";
15
+ /** 时间戳(毫秒)转 wire 上的 ISO 8601 字符串 */
16
+ function iso(ms) {
17
+ return new Date(ms).toISOString();
18
+ }
19
+ /** 目录是否存在;取不到(权限、悬空链接等)按「不存在」回报事实(D12) */
20
+ export async function pathExists(path) {
21
+ try {
22
+ const info = await stat(path);
23
+ return info.isDirectory();
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ /**
30
+ * 目录事实:路径 + 还在不在(PRD D12)。
31
+ *
32
+ * 只给事实,不给结论 —— 目录没了对话照样能看,是否禁掉发送由客户端判断。
33
+ */
34
+ export async function toFolderFact(cwd) {
35
+ return { cwd, exists: cwd.length > 0 && (await pathExists(cwd)) };
36
+ }
37
+ /** 名字不能是空白串;`field` 用于把「哪个字段不对」结构化地告诉客户端 */
38
+ function requireName(value, field) {
39
+ const name = typeof value === "string" ? value.trim() : "";
40
+ if (name.length === 0) {
41
+ throw new HostError("invalid_params", {
42
+ params: { field },
43
+ detail: { field, reason: "empty" },
44
+ });
45
+ }
46
+ return name;
47
+ }
48
+ /**
49
+ * cwd 归一化成绝对路径。
50
+ *
51
+ * 相对路径按宿主进程的工作目录解析会得到「随启动方式漂移」的路径(D9 的 cwd 一旦
52
+ * 定死就不再变),所以这里要求调用方给绝对路径,再由 `resolve` 把 `..`、尾斜杠归一。
53
+ */
54
+ function normalizeCwd(value) {
55
+ const raw = typeof value === "string" ? value.trim() : "";
56
+ if (raw.length === 0) {
57
+ throw new HostError("invalid_params", {
58
+ params: { field: "cwd" },
59
+ detail: { field: "cwd", reason: "empty" },
60
+ });
61
+ }
62
+ if (!isAbsolute(raw)) {
63
+ throw new HostError("invalid_params", {
64
+ params: { field: "cwd" },
65
+ detail: { field: "cwd", reason: "not_absolute", cwd: value },
66
+ });
67
+ }
68
+ return resolve(raw);
69
+ }
70
+ export class WorkspaceService {
71
+ workspaces;
72
+ conversations;
73
+ messages;
74
+ assets;
75
+ sweepAssets;
76
+ runtime;
77
+ now;
78
+ constructor(deps) {
79
+ this.workspaces = deps.workspaces;
80
+ this.conversations = deps.conversations;
81
+ this.messages = deps.messages;
82
+ this.assets = deps.assets;
83
+ this.sweepAssets = deps.sweepAssets;
84
+ this.runtime = deps.runtime;
85
+ this.now = deps.now ?? Date.now;
86
+ }
87
+ /**
88
+ * 列出项目(PRD D9 / D12 / R20)。
89
+ *
90
+ * 顺序按创建时间倒序(仓储里定死)。目录已不存在的项目默认也列出来 —— 它名下的对话
91
+ * 与历史仍然可用(D12),藏起来只会让人以为数据丢了;显式传 `includeMissingFolders: false`
92
+ * 时才过滤掉。
93
+ */
94
+ async list(params = {}) {
95
+ const rows = await this.workspaces.list();
96
+ const views = [];
97
+ for (const row of rows) {
98
+ const view = await this.toView(row);
99
+ if (params.includeMissingFolders === false && !view.folder.exists)
100
+ continue;
101
+ views.push(view);
102
+ }
103
+ return { workspaces: views };
104
+ }
105
+ /**
106
+ * 登记一个目录为项目(PRD D9 / D10)。
107
+ *
108
+ * 同一个 cwd 只登记一次(R14:两次登记会让两个 Scope 抢同一个目录)。名字不传就用
109
+ * 目录名 —— 这个名字之后可改,cwd 不可改。
110
+ */
111
+ async create(params) {
112
+ const cwd = normalizeCwd(params.cwd);
113
+ const existing = await this.workspaces.getByCwd(cwd);
114
+ if (existing) {
115
+ throw new HostError("invalid_params", {
116
+ params: { field: "cwd" },
117
+ detail: { field: "cwd", reason: "already_registered", cwd, workspaceId: existing.id },
118
+ });
119
+ }
120
+ const name = params.name === undefined || params.name.trim().length === 0
121
+ ? basename(cwd) || cwd
122
+ : params.name.trim();
123
+ const id = randomUUID();
124
+ await this.workspaces.create({ id, name, cwd, createdAt: this.now() });
125
+ const row = await this.workspaces.get(id);
126
+ if (!row) {
127
+ throw new HostError("internal", { detail: { reason: "workspace vanished after insert", id } });
128
+ }
129
+ return { workspace: await this.toView(row) };
130
+ }
131
+ async get(params) {
132
+ return { workspace: await this.toView(await this.requireRow(params.workspaceId)) };
133
+ }
134
+ /** 改名:id 与 cwd 不动(PRD D9),只换显示名 */
135
+ async rename(params) {
136
+ const row = await this.requireRow(params.workspaceId);
137
+ const name = requireName(params.name, "name");
138
+ await this.workspaces.rename(row.id, name);
139
+ return { workspace: await this.toView({ ...row, name }) };
140
+ }
141
+ /**
142
+ * 删项目(PRD D11 / D17 / F-F9 / R7)。
143
+ *
144
+ * `dryRun` 只算账:名下对话数、消息数、素材数,客户端拿去向用户说明代价。真删时按外键
145
+ * 级联把对话、消息、问题行、素材的引用关系一起删掉;`assets/` 下的文件不在服务的删除范围里
146
+ * (D11:目录本身宿主不碰),但素材的字节要按引用清 —— 那张图可能还有别的项目的对话在用,
147
+ * 所以只把「这些对话引用过哪几份素材」交给 asset 层,删完之后由它问「还剩几张引用」。
148
+ */
149
+ async remove(params) {
150
+ const row = await this.requireRow(params.workspaceId);
151
+ const conversations = await this.conversations.listByWorkspace(row.id);
152
+ const consequences = {
153
+ conversations: conversations.length,
154
+ messages: 0,
155
+ assets: 0,
156
+ };
157
+ const assetIds = new Set();
158
+ for (const conversation of conversations) {
159
+ // 这个数来自 `countByConversation`:每段对话在 `messages` 表里的全部行,**含子 Agent 的
160
+ // 消息**(它们落在同一张表、`parentToolCallId` 非空)。侧栏摘要的 `messageCount` 是另一条
161
+ // 读数(`listByConversation`,只数主线),所以「删这个项目会少多少条消息」通常比列表里那些
162
+ // 对话的条数加起来大——两个数在问两件事(删表代价 / 侧栏显示),不互相抵消。
163
+ consequences.messages += await this.messages.countByConversation(conversation.id);
164
+ const assets = await this.assets.listByConversation(conversation.id);
165
+ consequences.assets += assets.length;
166
+ for (const asset of assets)
167
+ assetIds.add(asset.id);
168
+ }
169
+ if (params.dryRun === true) {
170
+ return { removed: false, consequences, assetIds: [...assetIds] };
171
+ }
172
+ // 先请运行时忘掉这些对话,再删行:反过来会在运行时里留下指向已删记录的覆盖项
173
+ for (const conversation of conversations) {
174
+ await this.runtime.forget(conversation.id);
175
+ }
176
+ const changes = await this.workspaces.delete(row.id);
177
+ // 关系行已经随外键没了:归零的那几份素材才删行删文件
178
+ if (changes > 0)
179
+ await this.sweepAssets?.([...assetIds]);
180
+ return { removed: changes > 0, consequences, assetIds: [...assetIds] };
181
+ }
182
+ async requireRow(workspaceId) {
183
+ const row = typeof workspaceId === "string" && workspaceId.length > 0
184
+ ? await this.workspaces.get(workspaceId)
185
+ : undefined;
186
+ if (!row) {
187
+ throw new HostError("not_found", {
188
+ params: { entity: "workspace" },
189
+ detail: { workspaceId },
190
+ });
191
+ }
192
+ return row;
193
+ }
194
+ /**
195
+ * 组装一个项目的对外形状。
196
+ *
197
+ * `updatedAt` 在 `workspaces` 表里没有对应列,这里取「最近一次对话活动」当它;一条对话
198
+ * 都没有时就是创建时间。`lastActivityAt` 同理来自对话的 `updatedAt`(新增消息会推进它)。
199
+ */
200
+ async toView(row) {
201
+ const conversations = await this.conversations.listByWorkspace(row.id);
202
+ const lastActivityAt = conversations.reduce((latest, conversation) => latest === null || conversation.updatedAt > latest ? conversation.updatedAt : latest, null);
203
+ return {
204
+ id: row.id,
205
+ name: row.name,
206
+ folder: await toFolderFact(row.cwd),
207
+ createdAt: iso(row.createdAt),
208
+ updatedAt: iso(lastActivityAt ?? row.createdAt),
209
+ conversationCount: conversations.length,
210
+ lastActivityAt: lastActivityAt === null ? null : iso(lastActivityAt),
211
+ };
212
+ }
213
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 图片的像素尺寸:从文件头解出 `width` / `height`(PRD F-H8 / 裁定 R76)。
3
+ *
4
+ * 客户端拿到这两个数就能先摆好占位框,不必等字节下载完再量,图片加载时界面不会跳。
5
+ *
6
+ * 认的容器只有两种,各读一处现成的字段:
7
+ *
8
+ * - **PNG**:8 字节签名之后第一个块就是 IHDR,它的第 4–11 字节是大端宽高;
9
+ * - **JPEG**:`SOI` 之后按段长逐段往后跳,碰到 `SOF0` / `SOF1` / `SOF2` 等帧头段就读它的高宽。
10
+ *
11
+ * 其它容器(以及截断、认不出的字节)回 `null`,尺寸这一栏就留空。
12
+ *
13
+ * {@link readImageSize} 是纯函数(Buffer 进、尺寸出),{@link readImageSizeFromFile} 负责把
14
+ * 文件头读进来再交给它 —— 原图可能有 20MB(D5),这里只读开头一小段。
15
+ */
16
+ /** 像素尺寸 */
17
+ export interface ImageSize {
18
+ width: number;
19
+ height: number;
20
+ }
21
+ /**
22
+ * 读文件头时取多少字节。
23
+ *
24
+ * PNG 的尺寸在头 24 字节里;JPEG 的帧头段前面可能排着 APP1(EXIF,里面还常带一张缩略图)
25
+ * 这类段,几十 KB 的也见过。64 KiB 覆盖常见情况,又不会为了量尺寸把整份原图读进内存。
26
+ */
27
+ export declare const IMAGE_HEAD_BYTES: number;
28
+ /**
29
+ * 从文件头(前若干字节)解出像素尺寸;解不出来回 `null`。
30
+ *
31
+ * 入参可以是文件开头的一段,不要求是整份文件:两份解码都只往后读固定的几处字节,
32
+ * 不够长(截断)时按「解不出来」处理。
33
+ */
34
+ export declare function readImageSize(header: Buffer): ImageSize | null;
35
+ /**
36
+ * 读一个文件的头部字节,解出像素尺寸;打不开或解不出来都回 `null`。
37
+ *
38
+ * 这一步发生在素材已经改名落盘、登记入库之前,它的结论只影响 `assets.width` / `height`
39
+ * 两列,所以读失败不往外抛:素材该落的照样落。
40
+ */
41
+ export declare function readImageSizeFromFile(absolutePath: string): Promise<ImageSize | null>;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * 图片的像素尺寸:从文件头解出 `width` / `height`(PRD F-H8 / 裁定 R76)。
3
+ *
4
+ * 客户端拿到这两个数就能先摆好占位框,不必等字节下载完再量,图片加载时界面不会跳。
5
+ *
6
+ * 认的容器只有两种,各读一处现成的字段:
7
+ *
8
+ * - **PNG**:8 字节签名之后第一个块就是 IHDR,它的第 4–11 字节是大端宽高;
9
+ * - **JPEG**:`SOI` 之后按段长逐段往后跳,碰到 `SOF0` / `SOF1` / `SOF2` 等帧头段就读它的高宽。
10
+ *
11
+ * 其它容器(以及截断、认不出的字节)回 `null`,尺寸这一栏就留空。
12
+ *
13
+ * {@link readImageSize} 是纯函数(Buffer 进、尺寸出),{@link readImageSizeFromFile} 负责把
14
+ * 文件头读进来再交给它 —— 原图可能有 20MB(D5),这里只读开头一小段。
15
+ */
16
+ import { promises as fs } from "node:fs";
17
+ /** PNG 的 8 字节签名 */
18
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
19
+ /** PNG 读到 IHDR 末尾需要的字节数(8 签名 + 4 块长 + 4 块类型 + 4 宽 + 4 高) */
20
+ const PNG_HEADER_BYTES = 24;
21
+ /**
22
+ * 读文件头时取多少字节。
23
+ *
24
+ * PNG 的尺寸在头 24 字节里;JPEG 的帧头段前面可能排着 APP1(EXIF,里面还常带一张缩略图)
25
+ * 这类段,几十 KB 的也见过。64 KiB 覆盖常见情况,又不会为了量尺寸把整份原图读进内存。
26
+ */
27
+ export const IMAGE_HEAD_BYTES = 64 * 1024;
28
+ /**
29
+ * 从文件头(前若干字节)解出像素尺寸;解不出来回 `null`。
30
+ *
31
+ * 入参可以是文件开头的一段,不要求是整份文件:两份解码都只往后读固定的几处字节,
32
+ * 不够长(截断)时按「解不出来」处理。
33
+ */
34
+ export function readImageSize(header) {
35
+ return pngSize(header) ?? jpegSize(header);
36
+ }
37
+ /**
38
+ * 读一个文件的头部字节,解出像素尺寸;打不开或解不出来都回 `null`。
39
+ *
40
+ * 这一步发生在素材已经改名落盘、登记入库之前,它的结论只影响 `assets.width` / `height`
41
+ * 两列,所以读失败不往外抛:素材该落的照样落。
42
+ */
43
+ export async function readImageSizeFromFile(absolutePath) {
44
+ let handle;
45
+ try {
46
+ handle = await fs.open(absolutePath, "r");
47
+ const head = Buffer.alloc(IMAGE_HEAD_BYTES);
48
+ const { bytesRead } = await handle.read(head, 0, IMAGE_HEAD_BYTES, 0);
49
+ return readImageSize(head.subarray(0, bytesRead));
50
+ }
51
+ catch {
52
+ // 文件不存在 / 读不了:当作量不到,尺寸留空
53
+ return null;
54
+ }
55
+ finally {
56
+ await handle?.close().catch(() => undefined);
57
+ }
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // PNG
61
+ // ---------------------------------------------------------------------------
62
+ /**
63
+ * PNG:签名 + 第一个块 IHDR 里的宽高(大端 uint32)。
64
+ *
65
+ * 签名对不上、第一块不是 IHDR(块长固定 13)、字节不够长,都回 `null`。
66
+ */
67
+ function pngSize(buffer) {
68
+ if (buffer.length < PNG_HEADER_BYTES)
69
+ return null;
70
+ for (let index = 0; index < PNG_SIGNATURE.length; index += 1) {
71
+ if (buffer[index] !== PNG_SIGNATURE[index])
72
+ return null;
73
+ }
74
+ // 块长恒为 13、块类型恒为 "IHDR":两处都对上才说明布局是 PNG 那一种
75
+ if (buffer.readUInt32BE(8) !== 13)
76
+ return null;
77
+ if (buffer.toString("latin1", 12, 16) !== "IHDR")
78
+ return null;
79
+ return positiveSize(buffer.readUInt32BE(16), buffer.readUInt32BE(20));
80
+ }
81
+ // ---------------------------------------------------------------------------
82
+ // JPEG
83
+ // ---------------------------------------------------------------------------
84
+ /** 一个标记占两字节:`FF` + 标记值 */
85
+ const MARKER_BYTES = 2;
86
+ /** 段长字段本身占两字节,所以合法的段长至少是 2 */
87
+ const MIN_SEGMENT_LENGTH = 2;
88
+ /** 帧头段(SOF):段内顺序是精度(1) + 高(2) + 宽(2) + 分量数(1)… */
89
+ const SOF_DATA_BYTES = 5;
90
+ /** 帧头标记:C0–CF 里除了 C4(DHT)、C8(JPG)、CC(DAC)这三个非 SOF 的 */
91
+ function isFrameHeader(marker) {
92
+ return marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
93
+ }
94
+ /**
95
+ * JPEG:从 `SOI` 开始按段长往后走,走到帧头段就取高宽。
96
+ *
97
+ * 段长让这里不必认每一个标记是什么意思(APPn / DQT / DHT 大小各不相同),跳过去就行。
98
+ * 走上 `SOS`(扫描数据开始)说明帧头段已经错过,字节不够长说明文件被截断 —— 两种都回 `null`。
99
+ */
100
+ function jpegSize(buffer) {
101
+ if (buffer.length < 4)
102
+ return null;
103
+ if (buffer[0] !== 0xff || buffer[1] !== 0xd8)
104
+ return null;
105
+ let offset = 2;
106
+ while (offset + 1 < buffer.length) {
107
+ if (buffer[offset] !== 0xff)
108
+ return null;
109
+ let marker = buffer[offset + 1];
110
+ // 标记前面允许重复填 0xFF(编码器会这么写)
111
+ while (marker === 0xff) {
112
+ offset += 1;
113
+ if (offset + 1 >= buffer.length)
114
+ return null;
115
+ marker = buffer[offset + 1];
116
+ }
117
+ // 不带长度字段的独立标记:TEM(01) / RSTn(D0–D7) / SOI(D8) / EOI(D9)
118
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) {
119
+ offset += MARKER_BYTES;
120
+ continue;
121
+ }
122
+ // SOS(DA):图像数据从这里开始,尺寸只能在前面的帧头段里
123
+ if (marker === 0xda)
124
+ return null;
125
+ if (offset + 4 > buffer.length)
126
+ return null;
127
+ const segmentLength = buffer.readUInt16BE(offset + 2);
128
+ if (segmentLength < MIN_SEGMENT_LENGTH)
129
+ return null;
130
+ if (isFrameHeader(marker)) {
131
+ const dataOffset = offset + 4;
132
+ if (dataOffset + SOF_DATA_BYTES > buffer.length)
133
+ return null;
134
+ const height = buffer.readUInt16BE(dataOffset + 1);
135
+ const width = buffer.readUInt16BE(dataOffset + 3);
136
+ return positiveSize(width, height);
137
+ }
138
+ // 下一段 = 这一段的标记两字节 + 段长(段长从它自己那两字节算起)
139
+ offset += MARKER_BYTES + segmentLength;
140
+ }
141
+ return null;
142
+ }
143
+ /** 宽高都要是正数;0 或负值只可能来自读错了位置,按「解不出来」处理 */
144
+ function positiveSize(width, height) {
145
+ return width > 0 && height > 0 ? { width, height } : null;
146
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 服务层出口:一服务一文件,全部按构造参数注入依赖(单根组装,PRD architecture §3)。
3
+ *
4
+ * 这里只做再导出。除了五个服务类与它们的依赖形状,还把两类**跨层要用的东西**一并导出:
5
+ *
6
+ * 1. {@link ConversationRuntimePort}:运行时(W2-D)要实现的窄接口。服务层靠它读「在不在跑」
7
+ * 与「删掉之后中止在跑的轮次」,自己不存这些状态(D28)。
8
+ * 2. `toAgentMessage` / `readUsage` / `toCoreRole`:存储行 → core 消息的唯一一份转换。
9
+ * 运行时要接着往下跑一轮时也得用同一份,否则同一行会读出两个样子(R21)。
10
+ */
11
+ export { WorkspaceService, pathExists, toFolderFact } from "./WorkspaceService.js";
12
+ export type { WorkspaceServiceDeps, WorkspaceRemovalReport } from "./WorkspaceService.js";
13
+ export { ConversationService } from "./ConversationService.js";
14
+ export type { ConversationRuntimePort, ConversationServiceDeps, ConversationRemovalReport, } from "./ConversationService.js";
15
+ export { MessageService, readUsage, toAgentMessage, toCoreRole } from "./MessageService.js";
16
+ export type { MessageDraftLookup, MessageServiceDeps } from "./MessageService.js";
17
+ export { SearchService } from "./SearchService.js";
18
+ export type { ConversationSummarizer, SearchServiceDeps } from "./SearchService.js";
19
+ export { CredentialService } from "./CredentialService.js";
20
+ export type { CredentialServiceDeps } from "./CredentialService.js";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 服务层出口:一服务一文件,全部按构造参数注入依赖(单根组装,PRD architecture §3)。
3
+ *
4
+ * 这里只做再导出。除了五个服务类与它们的依赖形状,还把两类**跨层要用的东西**一并导出:
5
+ *
6
+ * 1. {@link ConversationRuntimePort}:运行时(W2-D)要实现的窄接口。服务层靠它读「在不在跑」
7
+ * 与「删掉之后中止在跑的轮次」,自己不存这些状态(D28)。
8
+ * 2. `toAgentMessage` / `readUsage` / `toCoreRole`:存储行 → core 消息的唯一一份转换。
9
+ * 运行时要接着往下跑一轮时也得用同一份,否则同一行会读出两个样子(R21)。
10
+ */
11
+ export { WorkspaceService, pathExists, toFolderFact } from "./WorkspaceService.js";
12
+ export { ConversationService } from "./ConversationService.js";
13
+ export { MessageService, readUsage, toAgentMessage, toCoreRole } from "./MessageService.js";
14
+ export { SearchService } from "./SearchService.js";
15
+ export { CredentialService } from "./CredentialService.js";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 密钥掩码 —— 端点密钥与 MCP 的 oauth `clientSecret` 共用这一份(F-F2:明文不出网)。
3
+ *
4
+ * 两处密钥的读写口径完全一样,所以生成掩码与「识别入参是不是掩码」都只在这里写一次:
5
+ *
6
+ * 1. **读**:宿主返回给客户端的永远是 {@link maskSecret} 的结果,明文一个字节都不出网;
7
+ * 2. **写**:客户端「没改」用省略表达;万一它把读到的掩码原样写回来了,
8
+ * {@link isSecretMask} 认得出来,宿主按磁盘原文保留,不会把掩码当密钥存下去。
9
+ *
10
+ * 单独成文件是因为服务的依赖方向是单向的(配置服务 → 发现服务),发现服务不能反向 import
11
+ * 配置服务;掩码这种纯函数放中间,两边都引它。
12
+ */
13
+ /**
14
+ * 密钥的掩码(F-F2:明文不出网)。
15
+ *
16
+ * 短密钥整串都掩掉:`sk-1` 这种长度的密钥,露头露尾就等于把整串给人看了。
17
+ */
18
+ export declare function maskSecret(secret: string): string;
19
+ /**
20
+ * 入参是不是「当前那把密钥的掩码」(客户端把读回来的整行原样写回来时的情形)。
21
+ *
22
+ * 是掩码就当「这一项没改」,写盘时保留原值。当前没有密钥时不存在「掩码」这回事,恒为 false。
23
+ */
24
+ export declare function isSecretMask(incoming: string, current: string): boolean;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 密钥掩码 —— 端点密钥与 MCP 的 oauth `clientSecret` 共用这一份(F-F2:明文不出网)。
3
+ *
4
+ * 两处密钥的读写口径完全一样,所以生成掩码与「识别入参是不是掩码」都只在这里写一次:
5
+ *
6
+ * 1. **读**:宿主返回给客户端的永远是 {@link maskSecret} 的结果,明文一个字节都不出网;
7
+ * 2. **写**:客户端「没改」用省略表达;万一它把读到的掩码原样写回来了,
8
+ * {@link isSecretMask} 认得出来,宿主按磁盘原文保留,不会把掩码当密钥存下去。
9
+ *
10
+ * 单独成文件是因为服务的依赖方向是单向的(配置服务 → 发现服务),发现服务不能反向 import
11
+ * 配置服务;掩码这种纯函数放中间,两边都引它。
12
+ */
13
+ /**
14
+ * 密钥的掩码(F-F2:明文不出网)。
15
+ *
16
+ * 短密钥整串都掩掉:`sk-1` 这种长度的密钥,露头露尾就等于把整串给人看了。
17
+ */
18
+ export function maskSecret(secret) {
19
+ const key = secret.trim();
20
+ if (key === "")
21
+ return "";
22
+ if (key.length <= 8)
23
+ return "…";
24
+ return `${key.slice(0, 3)}…${key.slice(-4)}`;
25
+ }
26
+ /**
27
+ * 入参是不是「当前那把密钥的掩码」(客户端把读回来的整行原样写回来时的情形)。
28
+ *
29
+ * 是掩码就当「这一项没改」,写盘时保留原值。当前没有密钥时不存在「掩码」这回事,恒为 false。
30
+ */
31
+ export function isSecretMask(incoming, current) {
32
+ const secret = current.trim();
33
+ return secret !== "" && incoming.trim() === maskSecret(secret);
34
+ }