@bolloon/bolloon-agent 0.5.0 → 0.5.1

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 (106) hide show
  1. package/dist/agents/execution-supervisor.js +87 -5
  2. package/dist/agents/model-policy.js +651 -0
  3. package/dist/agents/pi-sdk-tools.js +84 -33
  4. package/dist/agents/pi-sdk.js +83 -23
  5. package/dist/agents/run-store.js +53 -0
  6. package/dist/agents/task/task-runner.js +8 -1
  7. package/dist/agents/workflow-pivot-loop.js +19 -12
  8. package/dist/cli/clipboard.js +65 -0
  9. package/dist/cli/ink-app.js +145 -17
  10. package/dist/cli/log-gate.js +386 -0
  11. package/dist/cli/mention-data.js +2 -0
  12. package/dist/cli/model-selector.js +538 -0
  13. package/dist/cli/reply-hygiene.js +54 -0
  14. package/dist/cli/setup-wizard.js +686 -69
  15. package/dist/cli/startup-notice.js +160 -0
  16. package/dist/cli/stores.js +22 -1
  17. package/dist/cli/theme.js +49 -0
  18. package/dist/cli/tui-select.js +970 -0
  19. package/dist/cli-entry.js +125 -18
  20. package/dist/electron-build/agents/execution-supervisor.js +86 -4
  21. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  22. package/dist/electron-build/agents/goal-store.js.map +1 -1
  23. package/dist/electron-build/agents/model-policy.js +706 -0
  24. package/dist/electron-build/agents/model-policy.js.map +1 -0
  25. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  26. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  27. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  28. package/dist/electron-build/agents/pi-sdk.js +83 -23
  29. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  30. package/dist/electron-build/agents/run-store.js +55 -1
  31. package/dist/electron-build/agents/run-store.js.map +1 -1
  32. package/dist/electron-build/agents/task/task-runner.js +8 -1
  33. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  34. package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
  35. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  36. package/dist/electron-build/llm/config-store.js +146 -6
  37. package/dist/electron-build/llm/config-store.js.map +1 -1
  38. package/dist/electron-build/llm/connection-probe.js +764 -0
  39. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  40. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  41. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  42. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  43. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  44. package/dist/electron-build/llm/model-catalog.js +778 -0
  45. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  46. package/dist/electron-build/llm/model-discovery.js +1202 -0
  47. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  48. package/dist/electron-build/llm/model-selection.js +1315 -0
  49. package/dist/electron-build/llm/model-selection.js.map +1 -0
  50. package/dist/electron-build/llm/pi-ai.js +70 -11
  51. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  52. package/dist/electron-build/llm/provider-catalog.js +969 -0
  53. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  54. package/dist/electron-build/llm/provider-registry.js +950 -0
  55. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  56. package/dist/electron-build/llm/tool-name.js +266 -0
  57. package/dist/electron-build/llm/tool-name.js.map +1 -0
  58. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  59. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  60. package/dist/electron-build/utils/dual-source.js +32 -156
  61. package/dist/electron-build/utils/dual-source.js.map +1 -1
  62. package/dist/electron-build/utils/update-manager.js +22 -72
  63. package/dist/electron-build/utils/update-manager.js.map +1 -1
  64. package/dist/electron-build/utils/update-state.js +2 -19
  65. package/dist/electron-build/utils/update-state.js.map +1 -1
  66. package/dist/electron-build/utils/version-identity.js +193 -0
  67. package/dist/electron-build/utils/version-identity.js.map +1 -0
  68. package/dist/electron-build/utils/version-info.js +47 -101
  69. package/dist/electron-build/utils/version-info.js.map +1 -1
  70. package/dist/index.js +282 -96
  71. package/dist/ios/bolloon-web.json +10 -0
  72. package/dist/ios/mobile-contacts.js +44 -11
  73. package/dist/ios/mobile-core.js +1723 -84
  74. package/dist/ios/mobile-tasks.js +339 -0
  75. package/dist/ios/mobile-update.js +1442 -0
  76. package/dist/ios/mobile.js +308 -35
  77. package/dist/ios/routes-llm-config.js +344 -52
  78. package/dist/ios/routes-mobile-tasks.js +353 -0
  79. package/dist/ios/server.js +163 -4
  80. package/dist/ios/sw.js +1 -1
  81. package/dist/llm/config-store.js +146 -6
  82. package/dist/llm/connection-probe.js +744 -0
  83. package/dist/llm/custom-provider-store.js +224 -0
  84. package/dist/llm/data/provider-catalog-baked.js +19 -0
  85. package/dist/llm/model-catalog.js +717 -0
  86. package/dist/llm/model-discovery.js +1137 -0
  87. package/dist/llm/model-selection.js +1241 -0
  88. package/dist/llm/pi-ai.js +70 -11
  89. package/dist/llm/provider-catalog.js +898 -0
  90. package/dist/llm/provider-registry.js +917 -0
  91. package/dist/llm/tool-name.js +220 -0
  92. package/dist/setup/onboard.js +34 -8
  93. package/dist/utils/dual-source-facts.js +223 -0
  94. package/dist/utils/dual-source.js +12 -141
  95. package/dist/utils/update-manager.js +16 -59
  96. package/dist/utils/update-state.js +6 -18
  97. package/dist/utils/version-identity.js +176 -0
  98. package/dist/utils/version-info.js +9 -78
  99. package/dist/web/bolloon-web.json +10 -0
  100. package/dist/web/mobile-core.js +1721 -84
  101. package/dist/web/mobile-update.js +1442 -0
  102. package/dist/web/mobile.js +308 -35
  103. package/dist/web/routes-llm-config.js +344 -52
  104. package/dist/web/sw.js +1 -1
  105. package/package.json +1 -1
  106. package/scripts/build-web.ts +36 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * mobile-tasks.ts — 手机端「任务协作」能力层 (2026-09-25)
3
+ *
4
+ * 四项能力的手机侧实现 (桌面/CLI 同一套存储与协议, **手机不是第二个权威源**):
5
+ * ① 入群 → 桌面 `/api/mobile/tasks/groups*` → `gateway-group.ts` (与 CLI `bolloon task group …` 同一份 store)
6
+ * ② 发任务公告 → 桌面 `/api/mobile/tasks/announce|publish|post` → `task-group.ts` / `task-board.ts` (与 CLI 同函数)
7
+ * ③ 看飞轮进度 → 桌面 `/api/mobile/tasks/flywheel` → `goal-store` 只读 + `goal-flywheel` 冻结类型投影
8
+ * ④ 授权签名 → 每个**高风险**动作: 展示待发内容 → 设备私钥 (Ed25519) 签规范载荷 → 桌面验签才执行
9
+ *
10
+ * 纪律 (与本仓其它手机模块一致):
11
+ * · **浏览器安全**: 不 import 任何 node: 模块 (纯函数与 fetch)。
12
+ * · **不支持设备签名就明说**: 没有 WebCrypto/Ed25519 → 返回 device_signing_unavailable, **绝不发未签名请求**。
13
+ * · **桌面离线不假装成功**: 直接返回 desktop_offline (任务动作**不进离线队列** ——
14
+ * 一张几小时后突然生效的"发公告/入群"批准比失败更危险; 这条与 contacts 队列的取舍不同, 是刻意的)。
15
+ * · **本地先过隐私闸**: 群消息文本用桌面同一份规则表 (`task-public-text.ts`) 预检, 命中就本地拒发。
16
+ * · **只渲染脱敏视图**: 页面数据一律经 `mobile-task-views.ts` 投影 (id 只给缩短形式)。
17
+ */
18
+ import { canonicalTaskActionPayload, taskActionContentText, checkTaskActionFreshness, TASK_ACTION_DEFAULT_TTL_MS, MOBILE_TASK_ACTION_KINDS, } from '../agents/mobile-task-actions.js';
19
+ import { requirePublicText } from '../agents/task-public-text.js';
20
+ import { buildBoardView, buildTrailView, buildGroupsView, describeTaskActionForConfirm, } from '../agents/mobile-task-views.js';
21
+ import { findInternalFieldLeaks } from '../agents/mobile-flywheel-view.js';
22
+ import { sha256HexOf, ed25519Available } from './mobile-contacts.js';
23
+ const K_TASKS_HINT = 'bolloon.mobile.tasks.hint.v1';
24
+ function defaultStorage() {
25
+ try {
26
+ if (typeof localStorage !== 'undefined')
27
+ return localStorage;
28
+ }
29
+ catch { /* 隐私模式 */ }
30
+ const mem = new Map();
31
+ return { getItem: (k) => (mem.has(k) ? mem.get(k) : null), setItem: (k, v) => { mem.set(k, v); }, removeItem: (k) => { mem.delete(k); } };
32
+ }
33
+ async function baseOf(deps) {
34
+ if (deps.base && String(deps.base).trim())
35
+ return String(deps.base).replace(/\/+$/, '');
36
+ try {
37
+ if (typeof localStorage !== 'undefined') {
38
+ const v = localStorage.getItem('bolloon_desktop_base_url');
39
+ if (v)
40
+ return String(v).replace(/\/+$/, '');
41
+ }
42
+ }
43
+ catch { /* 忽略 */ }
44
+ return '';
45
+ }
46
+ function fetchOf(deps) {
47
+ return deps.fetchImpl || globalThis.fetch;
48
+ }
49
+ async function getJson(base, path, f) {
50
+ const res = await f(`${base}${path}`);
51
+ let json = null;
52
+ try {
53
+ json = await res.json();
54
+ }
55
+ catch { /* 空响应 */ }
56
+ return { status: res.status, json };
57
+ }
58
+ async function postJson(base, path, body, f) {
59
+ const res = await f(`${base}${path}`, {
60
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}),
61
+ });
62
+ let json = null;
63
+ try {
64
+ json = await res.json();
65
+ }
66
+ catch { /* 空响应 */ }
67
+ return { status: res.status, json };
68
+ }
69
+ const OFFLINE_NOTE = '地址在 我 → 设置 → 电脑端地址 里填; 手机不替桌面记账';
70
+ /** 读接口统一错误映射 (桌面不在线 → 明说, 不返回空壳当成功) */
71
+ function mapFail(r, what) {
72
+ const code = r.json?.code ? String(r.json.code) : (r.status ? `desktop_http_${r.status}` : 'desktop_unreachable');
73
+ return { ok: false, code, error: `${what} 失败: ${r.json?.error || r.json?.message || code}`, note: OFFLINE_NOTE };
74
+ }
75
+ // ── 请求构造 (空字段一律显式 null: 桌面据此重算内容摘要) ─────────────────────
76
+ export function emptyTaskActionRequest(kind) {
77
+ return {
78
+ kind, groupRef: null, announcementId: null, capability: null, instruction: null,
79
+ budgetHuman: null, currency: null, deadline: null, criteria: null, round: null, price: null,
80
+ hash: null, bytes: null, checks: null, verdict: null, trailKind: null,
81
+ };
82
+ }
83
+ /**
84
+ * 构造并签一个动作。**没有设备签名能力 → 直接失败** (没有"不签名也能过"的路径)。
85
+ * 群消息类动作在签名前先过本地隐私闸 (与桌面同一份规则表)。
86
+ */
87
+ export async function signTaskAction(req, deps = {}) {
88
+ const storage = deps.storage || defaultStorage();
89
+ const c = deps.cryptoObj || globalThis.crypto;
90
+ if (!ed25519Available(c)) {
91
+ return { ok: false, code: 'device_signing_unavailable', error: '本机 WebView 不支持 Ed25519 设备签名 —— 请在桌面端执行这个动作 (或升级系统 WebView)' };
92
+ }
93
+ if (!MOBILE_TASK_ACTION_KINDS.includes(String(req?.kind))) {
94
+ return { ok: false, code: 'unknown_kind', error: `未知动作: ${String(req?.kind || '')}` };
95
+ }
96
+ // 群消息类动作: 先本地过闸 (桌面还会再过一遍, 两道都要)
97
+ const text = taskActionContentText(req);
98
+ if (req.kind === 'trail_post' || req.kind === 'announce_publish' || req.kind === 'announce_to_group') {
99
+ for (const fieldText of [req.round, req.price, req.criteria, req.checks, req.verdict, req.capability, req.hash]) {
100
+ if (!fieldText)
101
+ continue;
102
+ const gate = requirePublicText(String(fieldText));
103
+ if (!gate.ok) {
104
+ return { ok: false, code: 'privacy_denied', error: `本地隐私闸拦下: ${gate.violations.map((v) => v.rule).join(', ')} (群里/公告里不放标识符)`, violations: gate.violations };
105
+ }
106
+ }
107
+ }
108
+ const now = deps.now ? deps.now() : Date.now();
109
+ const ttl = Math.min(Number(deps.ttlMs) || TASK_ACTION_DEFAULT_TTL_MS, TASK_ACTION_DEFAULT_TTL_MS);
110
+ let contentDigest;
111
+ try {
112
+ contentDigest = await sha256HexOf(text, c);
113
+ }
114
+ catch (e) {
115
+ return { ok: false, code: 'device_signing_unavailable', error: String(e?.message || e) };
116
+ }
117
+ const action = {
118
+ actionId: `act-${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
119
+ kind: req.kind,
120
+ deviceId: '', // 由 signPayloadOnDevice 填 (它知道设备 id)
121
+ ownerDid: String(deps.ownerDid || '').trim(),
122
+ targetRef: String(req.groupRef || req.announcementId || ''),
123
+ contentDigest,
124
+ createdAt: new Date(now).toISOString(),
125
+ expiresAt: new Date(now + ttl).toISOString(),
126
+ grantedBy: deps.grantedBy || 'leo',
127
+ via: 'mobile',
128
+ };
129
+ // 设备 id 必须先落到载荷里 (签名是对**含 deviceId 的载荷**做的), 且与签名同源
130
+ let signature;
131
+ try {
132
+ const { loadOrCreateDeviceKey, signPayloadWithMaterial } = await import('./mobile-contacts.js');
133
+ const material = await loadOrCreateDeviceKey(storage, c);
134
+ action.deviceId = material.deviceId;
135
+ signature = await signPayloadWithMaterial(canonicalTaskActionPayload(action), material, c);
136
+ if (signature.deviceId !== action.deviceId) {
137
+ return { ok: false, code: 'device_signing_unavailable', error: '设备密钥不一致 (签名与载荷设备对不上) → 不发送' };
138
+ }
139
+ const fresh = checkTaskActionFreshness(action, now);
140
+ if (!fresh.ok)
141
+ return { ok: false, code: fresh.reason, error: `动作时效不合法: ${fresh.reason}` };
142
+ }
143
+ catch (e) {
144
+ return { ok: false, code: 'device_signing_unavailable', error: String(e?.message || e).slice(0, 160) };
145
+ }
146
+ return {
147
+ ok: true,
148
+ data: { signed: { action, signature }, contentText: text, confirm: describeTaskActionForConfirm(req.kind, req) },
149
+ };
150
+ }
151
+ // ── 执行 (签名 → 桌面验签 → 桌面执行) ────────────────────────────────────────
152
+ export async function executeTaskAction(req, deps = {}) {
153
+ const storage = deps.storage || defaultStorage();
154
+ const base = await baseOf(deps);
155
+ if (!base)
156
+ return { ok: false, code: 'desktop_offline', error: '还没设置电脑端地址: 高风险动作必须由桌面执行 (手机签了名也要送到桌面)', note: OFFLINE_NOTE };
157
+ const signed = await signTaskAction(req, { ...deps, storage });
158
+ if (!signed.ok || !signed.data)
159
+ return { ok: false, code: signed.code, error: signed.error, violations: signed.violations };
160
+ const f = fetchOf(deps);
161
+ let r;
162
+ try {
163
+ r = await postJson(base, '/api/mobile/tasks/execute', { request: req, ...signed.data.signed }, f);
164
+ }
165
+ catch (e) {
166
+ return { ok: false, code: 'desktop_offline', error: `桌面不可达: ${String(e?.message || e).slice(0, 120)}`, note: OFFLINE_NOTE };
167
+ }
168
+ if (r.status !== 200 || !r.json?.ok)
169
+ return mapFail(r, '执行');
170
+ return { ok: true, data: r.json, note: '桌面已验签并执行 (同一套存储与协议)' };
171
+ }
172
+ /** 预览群消息正文 (由桌面用**将要发送的同一个构造器**生成; 只给要发进群的那一行) */
173
+ export async function previewGroupMessage(req, deps = {}) {
174
+ const base = await baseOf(deps);
175
+ if (!base)
176
+ return { ok: false, code: 'desktop_offline', error: '预览需要桌面在线 (群消息由桌面的构造器生成)', note: OFFLINE_NOTE };
177
+ const c = deps.cryptoObj || globalThis.crypto;
178
+ let digestPreview = '';
179
+ try {
180
+ digestPreview = await sha256HexOf(taskActionContentText(req), c);
181
+ }
182
+ catch {
183
+ digestPreview = '';
184
+ }
185
+ const f = fetchOf(deps);
186
+ try {
187
+ const r = await postJson(base, '/api/mobile/tasks/preview', { request: req }, f);
188
+ if (r.status !== 200 || !r.json?.ok)
189
+ return mapFail(r, '预览');
190
+ return { ok: true, data: { text: String(r.json.text || ''), contentText: taskActionContentText(req), digestPreview } };
191
+ }
192
+ catch (e) {
193
+ return { ok: false, code: 'desktop_offline', error: `桌面不可达: ${String(e?.message || e).slice(0, 120)}`, note: OFFLINE_NOTE };
194
+ }
195
+ }
196
+ // ── ① 入群 (与 CLI `task group list|join|leave|create` 同一份 store) ─────────
197
+ export async function listGroups(deps = {}) {
198
+ const base = await baseOf(deps);
199
+ if (!base)
200
+ return { ok: false, code: 'desktop_offline', error: '看群列表需要电脑端在线 (群存在于桌面节点的 OrbitDB)', note: OFFLINE_NOTE };
201
+ try {
202
+ const r = await getJson(base, '/api/mobile/tasks/groups', fetchOf(deps));
203
+ if (r.status !== 200 || !r.json?.ok)
204
+ return mapFail(r, '读群列表');
205
+ return { ok: true, data: buildGroupsView(r.json.groups || []) };
206
+ }
207
+ catch (e) {
208
+ return { ok: false, code: 'desktop_offline', error: `桌面不可达: ${String(e?.message || e).slice(0, 120)}`, note: OFFLINE_NOTE };
209
+ }
210
+ }
211
+ /** 入群 (高风险: 要签名) */
212
+ export function joinGroup(link, deps = {}) {
213
+ const req = { ...emptyTaskActionRequest('group_join'), groupRef: String(link || '').trim() };
214
+ return executeTaskAction(req, deps);
215
+ }
216
+ /** 退群 (高风险: 要签名) */
217
+ export function leaveGroup(groupRef, deps = {}) {
218
+ const req = { ...emptyTaskActionRequest('group_leave'), groupRef: String(groupRef || '').trim() };
219
+ return executeTaskAction(req, deps);
220
+ }
221
+ /** 建群 (高风险: 要签名; 建完自动入群) */
222
+ export function createGroup(name, deps = {}) {
223
+ const req = { ...emptyTaskActionRequest('group_create'), groupRef: String(name || '').trim() };
224
+ return executeTaskAction(req, deps);
225
+ }
226
+ // ── ② 发任务公告 ────────────────────────────────────────────────────────────
227
+ /** 看板 (只读): 与 CLI `bolloon task board` 同一份事实 */
228
+ export async function loadBoard(deps = {}) {
229
+ const base = await baseOf(deps);
230
+ if (!base)
231
+ return { ok: false, code: 'desktop_offline', error: '看板需要电脑端在线 (公告板在桌面节点上)', note: OFFLINE_NOTE };
232
+ const now = deps.now ? deps.now() : Date.now();
233
+ try {
234
+ const r = await getJson(base, '/api/mobile/tasks/board', fetchOf(deps));
235
+ if (r.status !== 200 || !r.json?.ok)
236
+ return mapFail(r, '读看板');
237
+ return { ok: true, data: buildBoardView(r.json, now) };
238
+ }
239
+ catch (e) {
240
+ return { ok: false, code: 'desktop_offline', error: `桌面不可达: ${String(e?.message || e).slice(0, 120)}`, note: OFFLINE_NOTE };
241
+ }
242
+ }
243
+ /** 发布一条待接单公告 (高风险: 要签名; 与 CLI `task publish` 同一函数) */
244
+ export function publishAnnouncement(input, deps = {}) {
245
+ const req = {
246
+ ...emptyTaskActionRequest('announce_publish'),
247
+ capability: String(input.capability || '').trim(),
248
+ instruction: String(input.instruction || ''),
249
+ budgetHuman: String(input.budgetHuman || '').trim(),
250
+ currency: String(input.currency || 'USDC').toUpperCase(),
251
+ deadline: input.deadline ? String(input.deadline).trim() : null,
252
+ };
253
+ return executeTaskAction(req, deps);
254
+ }
255
+ /** 把板上的一条公告发进群 (高风险: 要签名; 与 CLI `task announce` 同一函数) */
256
+ export function announceIntoGroup(input, deps = {}) {
257
+ const req = {
258
+ ...emptyTaskActionRequest('announce_to_group'),
259
+ groupRef: String(input.groupRef || '').trim(),
260
+ announcementId: String(input.announcementId || '').trim(),
261
+ round: input.round ? String(input.round) : null,
262
+ criteria: input.criteria ? String(input.criteria) : null,
263
+ };
264
+ return executeTaskAction(req, deps);
265
+ }
266
+ /** 过程留痕: 接单 / 交付 / 初筛 / 终审 (高风险: 要签名; 与 CLI `task post` 同一函数) */
267
+ export function postTrail(input, deps = {}) {
268
+ const req = {
269
+ ...emptyTaskActionRequest('trail_post'),
270
+ groupRef: String(input.groupRef || '').trim(),
271
+ announcementId: String(input.announcementId || '').trim(),
272
+ round: input.round ? String(input.round) : null,
273
+ price: input.price ? String(input.price) : null,
274
+ hash: input.hash ? String(input.hash) : null,
275
+ bytes: typeof input.bytes === 'number' ? input.bytes : null,
276
+ checks: input.checks ? String(input.checks) : null,
277
+ verdict: input.verdict ? String(input.verdict) : null,
278
+ trailKind: input.kind,
279
+ };
280
+ return executeTaskAction(req, deps);
281
+ }
282
+ /** 读群里的一期过程痕迹 (只读): 与 CLI `bolloon task trail --group …` 同一份汇总 */
283
+ export async function loadTrail(input, deps = {}) {
284
+ const base = await baseOf(deps);
285
+ if (!base)
286
+ return { ok: false, code: 'desktop_offline', error: '读群痕迹需要电脑端在线', note: OFFLINE_NOTE };
287
+ const q = new URLSearchParams();
288
+ q.set('group', String(input.groupRef || '').trim());
289
+ if (input.announcementId)
290
+ q.set('announcementId', String(input.announcementId));
291
+ if (input.limit)
292
+ q.set('limit', String(input.limit));
293
+ try {
294
+ const r = await getJson(base, `/api/mobile/tasks/trail?${q.toString()}`, fetchOf(deps));
295
+ if (r.status !== 200 || !r.json?.ok)
296
+ return mapFail(r, '读群痕迹');
297
+ return { ok: true, data: buildTrailView(r.json.summary || r.json) };
298
+ }
299
+ catch (e) {
300
+ return { ok: false, code: 'desktop_offline', error: `桌面不可达: ${String(e?.message || e).slice(0, 120)}`, note: OFFLINE_NOTE };
301
+ }
302
+ }
303
+ // ── ③ 看飞轮进度 (只读消费 goal-flywheel 冻结类型) ───────────────────────────
304
+ export async function loadFlywheel(deps = {}) {
305
+ const base = await baseOf(deps);
306
+ if (!base)
307
+ return { ok: false, code: 'desktop_offline', error: '看飞轮进度需要电脑端在线 (Goal 记录在桌面节点上)', note: OFFLINE_NOTE };
308
+ try {
309
+ const r = await getJson(base, '/api/mobile/tasks/flywheel', fetchOf(deps));
310
+ if (r.status !== 200 || !r.json?.ok)
311
+ return mapFail(r, '读飞轮进度');
312
+ const goals = Array.isArray(r.json.goals) ? r.json.goals : [];
313
+ // 桌面投影里若混进内部字段 → 手机**拒绝渲染** (宁可显示"读不到", 也不把内部状态摆给用户)
314
+ const leaks = findInternalFieldLeaks(JSON.stringify(goals));
315
+ if (leaks.length)
316
+ return { ok: false, code: 'view_leak', error: `视图里含内部字段 (${leaks.join(', ')}) → 拒绝渲染` };
317
+ return { ok: true, data: { goals, hint: r.json.hint ? String(r.json.hint) : null } };
318
+ }
319
+ catch (e) {
320
+ return { ok: false, code: 'desktop_offline', error: `桌面不可达: ${String(e?.message || e).slice(0, 120)}`, note: OFFLINE_NOTE };
321
+ }
322
+ }
323
+ // ── 小工具 (UI 直接用) ─────────────────────────────────────────────────────
324
+ /** 上一次操作的提示 (给 UI 跨页显示; 只存极短文本, 不含标识符) */
325
+ export function setTaskHint(text, storage = defaultStorage()) {
326
+ try {
327
+ storage.setItem(K_TASKS_HINT, String(text || '').slice(0, 160));
328
+ }
329
+ catch { /* 忽略 */ }
330
+ }
331
+ export function getTaskHint(storage = defaultStorage()) {
332
+ try {
333
+ return String(storage.getItem(K_TASKS_HINT) || '');
334
+ }
335
+ catch {
336
+ return '';
337
+ }
338
+ }
339
+ export { requirePublicText, describeTaskActionForConfirm, buildBoardView, buildTrailView, buildGroupsView };