hiwork-core 0.0.18 → 0.0.21

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.
@@ -0,0 +1,266 @@
1
+ /**
2
+ * 记忆系统配置下发的只读 host 服务(`hiworkMemory`)。
3
+ *
4
+ * 背景:HiWork 给员工接了一套「记忆系统」(腾讯 TencentDB Agent Memory)。规范是
5
+ * **员工零操作**——记忆的服务地址、实例 ID、团队 ID、凭据**全部由网关下发**,插件
6
+ * 不得让员工填任何技术字段。本模块是这条链中间的那一环:
7
+ *
8
+ * ```
9
+ * hiwork-admin GET /api/me/memory-config ← 网关侧(provision + 下发)
10
+ * ↑ 本文件(hiwork-core,用登录会话调它)
11
+ * hiwork-memory ctx.get('hiworkMemory').config() ← 插件侧
12
+ * ```
13
+ *
14
+ * 设计要点(形状照 `src/identity.ts`,出网照 `src/ai-router-client.ts`):
15
+ * - **只读、失败不抛异常**:一律返回 `{ ok: false, reason, message }`,reason 四取值
16
+ * `not-configured` / `unauthenticated` / `unreachable` / `error`。失败是**数据**不是
17
+ * 异常:插件侧按 `ok` 判断,绝不会因为宿主的一次网络抖动把异常漏进会话。
18
+ * - **字段名分层只在本文件做**:网关那层是 **snake_case**(`panel_url` /
19
+ * `service_id` / `team_ids` / `api_key`,跨仓冻结契约),本文件把它转成 camelCase 的
20
+ * {@link HiworkMemoryConfigView} 再对外暴露——插件侧**永远不感知上游命名**。
21
+ * - **凭据只在内存里活**:`apiKey` 不写 settings、不写任何 storage、不写任何缓存文件
22
+ * (与 `src/office-credentials.ts` 同口径),也**不进任何日志**(含调试日志与错误
23
+ * 日志)——本模块只记事件与异常类型名,绝不记字段值。
24
+ * - **5 分钟内存缓存**:命中即不出网(省一次往返,也少一次网关侧的 provision 幂等压力);
25
+ * `force: true` 绕过缓存(「重新获取配置」与 401 自愈走这条)。缓存与**会话 cookie
26
+ * 绑定**:换了登录账号(登出再登入)时旧缓存直接失效,绝不把上一个员工的凭据发给
27
+ * 下一个员工(粘错 = 串号)。
28
+ * - **失败不进缓存**:拉不到时下一次调用重新出网——否则一次网关抖动会把员工锁在
29
+ * 「记忆不可用」里整整一个 TTL。
30
+ * - **message 是给非技术员工看的中文**:不含凭据、不含 URL 原文、不含 HTTP 状态码。
31
+ *
32
+ * 本模块不 import cordis 运行时:provide 走结构化的 {@link MemoryConfigContext} 端口,
33
+ * 调用方(index.ts)传真实 ctx;单测直接伪造。
34
+ */
35
+ import { PKG_VERSION } from "./version.js";
36
+ /** cordis 服务名(hiwork-memory 的 Host 半边按此名 `ctx.get` 探测)。 */
37
+ export const HIWORK_MEMORY_SERVICE = 'hiworkMemory';
38
+ /** 网关侧端点(跨仓冻结;与 `/api/me/office-config` 同一条会话鉴权)。 */
39
+ export const ME_MEMORY_CONFIG_PATH = '/api/me/memory-config';
40
+ /** 成功结果的缓存 TTL:5 分钟。 */
41
+ export const MEMORY_CONFIG_TTL_MS = 5 * 60 * 1000;
42
+ /** 出网超时(与 `AiRouterClient` 的缺省一致)。 */
43
+ export const MEMORY_CONFIG_TIMEOUT_MS = 10_000;
44
+ /** 失败文案(给非技术员工看的中文;不含凭据、URL 原文、HTTP 状态码)。 */
45
+ const MESSAGE_NOT_CONFIGURED = '管理员还没开启团队记忆,请联系管理员开通';
46
+ const MESSAGE_UNAUTHENTICATED = '登录状态已过期,请重新登录';
47
+ const MESSAGE_UNREACHABLE = '记忆服务暂时不可用,请稍后重试';
48
+ const MESSAGE_NO_GATEWAY = '还没连上企业网关,请先登录后再试';
49
+ const MESSAGE_GENERIC = '记忆配置暂时无法获取,请稍后重试';
50
+ /** 按 reason 取文案(未登录的两条路径共用同一句,避免措辞分叉)。 */
51
+ function messageFor(reason) {
52
+ switch (reason) {
53
+ case 'not-configured': return MESSAGE_NOT_CONFIGURED;
54
+ case 'unauthenticated': return MESSAGE_UNAUTHENTICATED;
55
+ case 'unreachable': return MESSAGE_UNREACHABLE;
56
+ case 'error': return MESSAGE_GENERIC;
57
+ }
58
+ }
59
+ /** 失败视图(**只带 reason 与 message**,绝不带半个字段)。 */
60
+ function failure(reason, message) {
61
+ return { ok: false, reason, message: message ?? messageFor(reason) };
62
+ }
63
+ /** 裁掉两侧空白;非字符串或空白串一律视同缺省。 */
64
+ function textOrUndefined(value) {
65
+ if (typeof value !== 'string')
66
+ return undefined;
67
+ const text = value.trim();
68
+ return text === '' ? undefined : text;
69
+ }
70
+ /**
71
+ * 读团队列表:只保留非空字符串,**保持网关下发的顺序**(按优先级排好的,插件取第一个)。
72
+ * `[]` / 缺省 / 形状非法一律视同「只用个人域」——**不是错误**(spec §4.4 消费规则 3)。
73
+ */
74
+ function teamIdsOf(value) {
75
+ if (!Array.isArray(value))
76
+ return [];
77
+ const ids = [];
78
+ for (const item of value) {
79
+ const text = textOrUndefined(item);
80
+ if (text !== undefined)
81
+ ids.push(text);
82
+ }
83
+ return ids;
84
+ }
85
+ /**
86
+ * snake_case → camelCase 投影(**全仓唯一做这层转换的地方**)。
87
+ *
88
+ * 三个必需字段(`panel_url` / `service_id` / `api_key`)任一缺失即返回 undefined:
89
+ * 网关 200 却缺字段 = 契约破坏,宁可报「暂时无法获取」,也绝不把半份配置交给终端
90
+ * (半份配置的下场是插件拿空地址去打请求,错误信息离真正的原因十万八千里)。
91
+ *
92
+ * 同时宽容接受 camelCase 别名(`panelUrl` / `serviceId` / `apiKey` / `userKey`):
93
+ * 兄弟仓的 spec 里这两个名字都出现过(`hiwork-admin` 的
94
+ * `docs/superpowers/specs/2026-09-18-memory-provisioning-design.md` §4.1 写的是 camelCase
95
+ * 且字段名为 `userKey`),读取端宽容不会掩盖错误,却能让本服务在两种落地之间都能工作。
96
+ * **写出永远是 camelCase 视图**——插件侧不感知任何一种上游命名。
97
+ */
98
+ function projectMemoryConfig(body) {
99
+ const panelUrl = textOrUndefined(body['panel_url'] ?? body['panelUrl']);
100
+ const serviceId = textOrUndefined(body['service_id'] ?? body['serviceId']);
101
+ const apiKey = textOrUndefined(body['api_key'] ?? body['apiKey'] ?? body['userKey']);
102
+ if (panelUrl === undefined || serviceId === undefined || apiKey === undefined)
103
+ return undefined;
104
+ return {
105
+ ok: true,
106
+ panelUrl,
107
+ serviceId,
108
+ teamIds: teamIdsOf(body['team_ids'] ?? body['teamIds']),
109
+ apiKey,
110
+ };
111
+ }
112
+ /** 异常类型名(日志只记类型,不记消息,避免把凭据/URL 带进日志)。 */
113
+ function errorName(error) {
114
+ return error instanceof Error ? error.name : typeof error;
115
+ }
116
+ /**
117
+ * 把「会抛错的网关实现」的稳定 code 映射成 reason。
118
+ *
119
+ * 本模块自带的出网端口({@link createMemoryConfigGateway})**从不抛错**,这一支是为了
120
+ * 容忍接线侧传进来的其它实现(例如将来把本端点加进 `AiRouterClient` 后直接复用
121
+ * `gatewayFor(baseURL)`):按结构化 `code` 字段判别,不 import 对方的类,也不读 message
122
+ * (message 里可能带 URL 原文)。
123
+ */
124
+ function reasonOfErrorCode(error) {
125
+ const code = error?.code;
126
+ if (typeof code !== 'string')
127
+ return undefined;
128
+ switch (code) {
129
+ case 'SESSION_EXPIRED': return 'unauthenticated';
130
+ case 'MEMORY_NOT_CONFIGURED': return 'not-configured';
131
+ case 'MEMORY_UNREACHABLE':
132
+ case 'GATEWAY_UNREACHABLE': return 'unreachable';
133
+ default: return undefined;
134
+ }
135
+ }
136
+ /** 非 200 状态码 → reason(逐字对应契约:401 / 503 / 504 各有确定语义)。 */
137
+ function reasonOfStatus(status) {
138
+ if (status === 401)
139
+ return 'unauthenticated';
140
+ // 网关 503 `MEMORY_NOT_CONFIGURED`:管理员还没开启团队记忆(部署状态,不是故障)。
141
+ if (status === 503)
142
+ return 'not-configured';
143
+ // 网关 504 `MEMORY_UNREACHABLE`:记忆服务自己暂时不可用——语义上就是「够不着」。
144
+ if (status === 504)
145
+ return 'unreachable';
146
+ return 'error';
147
+ }
148
+ /** 视图投影 + 5 分钟内存缓存(无状态依赖,只有一条内存缓存)。 */
149
+ class MemoryConfigProvider {
150
+ deps;
151
+ cached;
152
+ constructor(deps) {
153
+ this.deps = deps;
154
+ }
155
+ async config(opts) {
156
+ const force = opts?.force === true;
157
+ let sessionCookie;
158
+ try {
159
+ sessionCookie = await this.deps.resolveSession();
160
+ }
161
+ catch (error) {
162
+ // 凭据服务读取失败是宿主故障,不是「没登录」:如实报 error,不诱导用户反复重登。
163
+ this.deps.log?.(`hiwork-core: 记忆配置获取失败(${errorName(error)})`);
164
+ return failure('error');
165
+ }
166
+ if (sessionCookie === undefined || sessionCookie === '')
167
+ return failure('unauthenticated');
168
+ if (!force) {
169
+ const hit = this.cached;
170
+ if (hit !== undefined && hit.sessionCookie === sessionCookie && this.now() - hit.at < MEMORY_CONFIG_TTL_MS) {
171
+ return hit.view;
172
+ }
173
+ }
174
+ const gateway = this.deps.gateway();
175
+ // 没有网关地址 = 还没完成登录(baseURL 是登录时写下的),与网络不通归同一档。
176
+ if (gateway === undefined)
177
+ return failure('unreachable', MESSAGE_NO_GATEWAY);
178
+ let outcome;
179
+ try {
180
+ outcome = await gateway.getMyMemoryConfig(sessionCookie);
181
+ }
182
+ catch (error) {
183
+ const mapped = reasonOfErrorCode(error);
184
+ if (mapped !== undefined)
185
+ return failure(mapped);
186
+ this.deps.log?.(`hiwork-core: 记忆配置获取失败(${errorName(error)})`);
187
+ return failure('error');
188
+ }
189
+ if (!outcome.ok) {
190
+ if (outcome.kind === 'network')
191
+ return failure('unreachable');
192
+ if (outcome.kind === 'http')
193
+ return failure(reasonOfStatus(outcome.status));
194
+ return failure('error');
195
+ }
196
+ const view = projectMemoryConfig(outcome.body);
197
+ if (view === undefined) {
198
+ // 只记事件与字段名,不记任何值(响应体里带凭据)。
199
+ this.deps.log?.('hiwork-core: 网关返回的 memory-config 不完整(缺 panel_url / service_id / api_key 之一)');
200
+ return failure('error');
201
+ }
202
+ // 只缓存成功结果:拉不到时下一次调用重新出网(一次抖动不该锁住一个 TTL)。
203
+ this.cached = { sessionCookie, view, at: this.now() };
204
+ return view;
205
+ }
206
+ now() {
207
+ return (this.deps.now ?? Date.now)();
208
+ }
209
+ }
210
+ /**
211
+ * 提供 host 侧服务 `hiworkMemory`;返回 disposer(交给 `ctx.effect` 管理生命周期,
212
+ * 插件卸载时服务随 fiber 一并注销)。
213
+ */
214
+ export function provideMemoryConfig(ctx, deps) {
215
+ const service = new MemoryConfigProvider(deps);
216
+ const dispose = ctx.provide(HIWORK_MEMORY_SERVICE, service);
217
+ return typeof dispose === 'function' ? dispose : () => { };
218
+ }
219
+ /**
220
+ * 本端点的出网实现(形状与 `AiRouterClient` 的 `request()` 同款:user-agent 带包版本、
221
+ * 会话 cookie 走 `cookie` 头、超时用 `AbortSignal.timeout`)。
222
+ *
223
+ * **不新造鉴权**:只复用 Host 侧已有的登录会话 cookie(由 `resolveSession` 从凭据库取),
224
+ * 没有任何密钥/令牌在这一层生成或保存。
225
+ *
226
+ * **永不抛异常、绝不回显响应原文**:失败收敛成 {@link MemoryConfigOutcome},日志与
227
+ * 异常里都不会出现 URL、状态码或响应体——响应体里带的是该员工自己的记忆凭据。
228
+ *
229
+ * `fetchImpl` / `timeoutMs` 仅用于测试注入。
230
+ */
231
+ export function createMemoryConfigGateway(baseURL, opts = {}) {
232
+ const root = baseURL.replace(/\/+$/, '');
233
+ const timeoutMs = opts.timeoutMs ?? MEMORY_CONFIG_TIMEOUT_MS;
234
+ const doFetch = opts.fetchImpl ?? fetch;
235
+ return {
236
+ async getMyMemoryConfig(sessionCookie) {
237
+ try {
238
+ const res = await doFetch(`${root}${ME_MEMORY_CONFIG_PATH}`, {
239
+ method: 'GET',
240
+ headers: {
241
+ 'user-agent': `hiwork-core/${PKG_VERSION}`,
242
+ cookie: sessionCookie,
243
+ },
244
+ signal: AbortSignal.timeout(timeoutMs),
245
+ });
246
+ if (!res.ok)
247
+ return { ok: false, kind: 'http', status: res.status };
248
+ let body;
249
+ try {
250
+ body = await res.json();
251
+ }
252
+ catch {
253
+ // 非 JSON 响应:只报「不可解析」,不把响应片段带进任何返回值或日志。
254
+ return { ok: false, kind: 'invalid' };
255
+ }
256
+ if (body === null || typeof body !== 'object')
257
+ return { ok: false, kind: 'invalid' };
258
+ return { ok: true, body };
259
+ }
260
+ catch {
261
+ // 超时(AbortSignal.timeout → TimeoutError)/ 连接失败 / DNS:一律按不可达。
262
+ return { ok: false, kind: 'network' };
263
+ }
264
+ },
265
+ };
266
+ }