LongPi: turns Claude Code into your personal longevity coach.

简体中文 | English
LongPi 把 Claude Code 变成每个人的长寿教练。它是一个 Claude Code mod(函数钩子插件),装上就能用: 体检报告交给它,它按原样录进本机的健康档案,算出身体年龄和十年心血管风险,起草方案、陪你打卡、提醒复查; 健康页、长寿图鉴、拆包翻牌的动画、通往 120 岁的路,都直接画在 Claude Code 里,不打开网页。
它是 dsh-plugin-longpi 的完整移植,并且自成一体:
~/.longpi),报告由 Claude 自己读、按原样录入;Apple 健康导出一步导入。需要 Claude Code 2.1.287 或更新版本。
/plugin marketplace add zwbao/longpi-mod
/plugin install longpi@longpi-mod
/reload-plugins
然后输入 /longpi。第一次用时,LongPi 会在后台准备好计算环境(几分钟,一次):大多数方法只要系统自带的 Python 3.9+; 少数要用 numpy 的方法,它会用 uv 在 ~/.longpi/.venv 里装好,不动你的系统和 shell 配置。
LongPi 的工具都叫 mcp__longpi__*。不想每次都确认:/permissions → 允许 mcp__longpi__*。
| 把体检报告拖进对话 | 「帮我录入这份报告」:Claude 读 PDF 或照片,按原样录入,然后讲给你听 |
/longpi | 打开健康页:总览、化验、睡眠、运动、日程、方案、长寿图鉴,m 看更多(通往 120、深度分析、档案、设置……) |
/longpi <你想问的> | 以你的口吻问 Pi,带上你的档案(只给模型看) |
/longpi 图鉴 · /longpi 通往120 · /longpi 方案 | 直接打开某一页 |
/longpi 新研究 | 现在就去找本周的新研究 |
/longpi setup | 更新方法库,重新准备计算环境 |
/longpi 演示模式 | 屏幕分享时隐藏所有个人数字 |
| Apple 健康 | iPhone「健康」→ 头像 → 导出所有健康数据,把 export.zip 的路径告诉 Claude |
提示行(输入框上方)一次只说一件事:久坐提醒(打开了才有)、实验卡可以翻了、本周新研究上架了。
LongPi 想让健康管理像游戏一样让人想参与,但游戏不能骗人:
档案、方案、打卡都在本机:~/.longpi(可用 LONGPI_HOME 换地方)。 你交给 Claude 的报告、照片和问题会像平时用 Claude 一样发给模型;LongPi 会附上一段档案摘要,作为给模型看的上下文。 每周新研究只向 PubMed 发送检索词。「档案 → 隐私与数据」里可以撤回同意、删除全部数据。
claude plugin validate .
claude plugin test .
tsc -p . # 需要 Claude Code 写入的 .claude-plugin/types
hooks/core 是 dsh-plugin-longpi 的服务端代码,跑在 hooks/sys 的 Node 形状垫片上;hooks/ui 是各页; hooks/app 是 mod 自己的部分(录入、Apple 健康、每周新研究、通往 120、运行环境)。
hooks/register.tsx 1267 lines1// LongPi in Claude Code. This file holds the engine ($): it boots the ported LongPi core (app/runtime.ts),
2// serves the core's tools to Claude, opens the LongPi pane, keeps the pane's data, and runs what a press asks.
3// Everything it draws comes from the pure modules under ui/.
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register, RenderSurface } from 'claude-code'
7
8import type { Celebration, CodexOverlay, LongPiView, PrivacyState, RouteCache, Tab } from '../types'
9import type { Io } from './sys/host.ts'
10import { boot, flushPending, op, reloadLibrary, route, runtime, runTool, toolSpecs, TOOL_PREFIX } from './app/runtime.ts'
11import { ensurePython, hasPackages, updateLibrary } from './app/setup.ts'
12import { isoWeek, scoutLiterature } from './app/literature.ts'
13import { fullBrief, SHORT_BRIEF } from './app/brief.ts'
14import { snapshotText } from './core/agents/orchestrator.ts'
15import { isoDay } from './core/interventions.ts'
16import { WRITE_TOOLS } from './core/agents/orchestrator.ts'
17import { recordChanged } from './core/engage/engine.ts'
18import { CONSENT_VERSION } from './core/profile.ts'
19import { resolveDataDir } from './core/paths.ts'
20import { pageOf, PAGES } from './ui/pages/index.ts'
21import { pageWidth, paneTree } from './ui/pane.tsx'
22import type { Actions, CodexActions, Ctx, PostResult } from './ui/types.ts'
23import { isAnimated, stageAt, stageCols } from './ui/codex/stage.ts'
24import { cardTree, type CardState } from './ui/cards.tsx'
25import type { GameView } from './app/game.ts'
26import { markdownToHtml } from './ui/printable.ts'
27import { CELEBRATE_MS, celebrateFrame, IDLE_MS, moodAt, piFrame } from './ui/journey/anim.ts'
28import type { PiForm } from './ui/journey/sprites.ts'
29import { bandTree, nextBand, noteInput, sittingMinutes, STANDUP_VISIBLE_MS, type Activity, type BandPrompt, type SlotView } from './ui/band.tsx'
30
31type Engine = EngineInterface
32
33const PANE = 'longpi'
34const FRESH_MS = 90_000
35const NO_OVERLAY: CodexOverlay = { kind: 'none', id: '', phase: '', since: 0, flipped: [], chosen: '', payload: null, note: '' }
36
37// The session's values, held by the host so a hot reload keeps them; drawing subscribes to them.
38const view = atom({ plugin: 'longpi', key: 'view' } as const, { tab: 'overview', sub: {}, detail: null } as LongPiView)
39const data = atom({ plugin: 'longpi', key: 'data' } as const, {} as Record<string, RouteCache>)
40const privacy = atom({ plugin: 'longpi', key: 'privacy' } as const, { showUntil: 0, presentation: false } as PrivacyState)
41const overlay = atom({ plugin: 'longpi', key: 'overlay' } as const, NO_OVERLAY)
42const notice = atom({ plugin: 'longpi', key: 'notice' } as const, null as { text: string; tone: 'info' | 'good' | 'warn'; at: number } | null)
43const tick = atom({ plugin: 'longpi', key: 'tick' } as const, 0)
44const booted = atom({ plugin: 'longpi', key: 'booted' } as const, false)
45const coach = atom({ plugin: 'longpi', key: 'coach' } as const, false)
46const healthTurn = atom({ plugin: 'longpi', key: 'healthTurn' } as const, false)
47const band = atom({ plugin: 'longpi', key: 'band' } as const, null as BandPrompt | null)
48const cards = atom({ plugin: 'longpi', key: 'cards' } as const, {} as Record<string, CardState>)
49const celebrate = atom({ plugin: 'longpi', key: 'celebrate' } as const, null as Celebration)
50
51// --- the engine as the core's I/O -------------------------------------------------------------------
52
53function ioOf($: Engine): Io {
54 return {
55 read: (path) => $.fs.read(path),
56 readBase64: async (path) => (await $.fs.read(path, { as: 'bytes' })).base64,
57 write: (path, text) => $.fs.write(path, text),
58 list: async (path) => (await $.fs.list(path)).map((entry) => ({ name: entry.name, kind: entry.kind === 'dir' ? 'directory' as const : entry.kind === 'file' ? 'file' as const : 'other' as const, size: entry.size, mtimeMs: entry.mtimeMs })),
59 stat: async (path) => {
60 try {
61 const stat = await $.fs.stat(path)
62 return { kind: stat.kind === 'dir' ? 'directory' as const : stat.kind === 'file' ? 'file' as const : 'other' as const, size: stat.size, mtimeMs: stat.mtimeMs }
63 } catch {
64 return null
65 }
66 },
67 run: async (argv, init) => {
68 const out = await $.process.run(argv, { ...(init?.cwd ? { cwd: init.cwd } : {}), ...(init?.env ? { env: init.env } : {}), ...(init?.stdin !== undefined ? { stdin: init.stdin } : {}), timeoutMs: init?.timeoutMs ?? 30_000 })
69 return { exitCode: out.exitCode, stdout: out.stdout, stderr: out.stderr }
70 },
71 fetch: async (url, init) => {
72 const out = await $.http.fetch(url, { method: init?.method ?? 'GET', ...(init?.headers ? { headers: init.headers } : {}), ...(init?.body !== undefined ? { body: init.body } : {}) })
73 return { status: out.status, ok: out.ok, text: out.text, headers: out.headers }
74 },
75 complete: async (prompt, options) => {
76 // A call that does not come back is cut (one once took 18 minutes); the callers fall back to their templates.
77 const stop = new AbortController()
78 const timer = $.clock.after(options?.timeoutMs ?? 90_000, () => stop.abort())
79 const out = await $.model.complete({ model: options?.model && options.model !== 'session' ? options.model : 'haiku', prompt, ...(options?.system ? { system: options.system } : {}), maxTokens: options?.maxTokens ?? 1500 }, { signal: stop.signal }).finally(() => timer.cancel())
80 return out.isAnswered ? { ok: true as const, text: out.text } : { ok: false as const, reason: out.reason }
81 },
82 now: () => $.clock.now(),
83 log: (line) => $.ui.log(`longpi: ${line}`, { to: 'debug' }),
84 }
85}
86
87/**
88 * The file and process calls of each tool call in progress. The engine gives a tool call 10 s of its own time
89 * and stops that clock only while one of its own `$` calls is in flight; LongPi's file and process work goes
90 * through the newest tool call's own calls while there is one, so waiting on it is not counted against the call.
91 * Model calls stay the session's (one a tool call started could be cut when that call is interrupted).
92 */
93const toolIos: Io[] = []
94
95function liveIo(session: Io): Io {
96 const io = (): Io => toolIos[toolIos.length - 1] ?? session
97 return {
98 read: (path) => io().read(path),
99 readBase64: (path) => io().readBase64(path),
100 write: (path, text) => io().write(path, text),
101 list: (path) => io().list(path),
102 stat: (path) => io().stat(path),
103 run: (argv, init) => io().run(argv, init),
104 fetch: (url, init) => io().fetch(url, init),
105 complete: (prompt, options) => session.complete(prompt, options),
106 now: () => session.now(),
107 log: (line) => session.log(line),
108 }
109}
110
111async function envOf($: Engine): Promise<Record<string, string>> {
112 const pairs: Array<[string, string | undefined]> = [
113 ['HOME', await $.env.get('HOME')],
114 ['PATH', await $.env.get('PATH')],
115 ['LANG', await $.env.get('LANG')],
116 ['LC_ALL', await $.env.get('LC_ALL')],
117 ['LC_MESSAGES', await $.env.get('LC_MESSAGES')],
118 ['TMPDIR', await $.env.get('TMPDIR')],
119 ['USER', await $.env.get('USER')],
120 ['SHELL', await $.env.get('SHELL')],
121 ['LONGPI_HOME', await $.env.get('LONGPI_HOME')],
122 ['LONGEVITY_SKILLS_HOME', await $.env.get('LONGEVITY_SKILLS_HOME')],
123 ['LONGPI_COACH_SKILL', await $.env.get('LONGPI_COACH_SKILL')],
124 ['LONGPI_ANALYST_SKILL', await $.env.get('LONGPI_ANALYST_SKILL')],
125 ['LONGPI_ANALYSES_HOME', await $.env.get('LONGPI_ANALYSES_HOME')],
126 ]
127 const out: Record<string, string> = {}
128 for (const [key, value] of pairs) if (typeof value === 'string' && value !== '') out[key] = value
129 return out
130}
131
132async function startCore($: Engine): Promise<void> {
133 if (runtime()) return
134 const env = await envOf($)
135 const uname = await $.process.run(['uname', '-s']).catch(() => null)
136 const platform = uname?.stdout.trim() === 'Linux' ? 'linux' : 'darwin'
137 const own = ((await $.store.get('config')) ?? {}) as Record<string, unknown>
138 await boot({
139 io: liveIo(ioOf($)),
140 pluginRoot: $.plugin.root,
141 home: env.HOME ?? '/',
142 env,
143 platform,
144 schedule: (ms, fn) => {
145 const timer = $.clock.after(ms, fn)
146 return () => timer.cancel()
147 },
148 config: { ...(env.LONGPI_HOME ? { dataDir: env.LONGPI_HOME } : {}), ...own },
149 })
150}
151
152// --- the pane's data --------------------------------------------------------------------------------
153
154const EMPTY: RouteCache = { at: 0, status: 0, json: null, loading: false, error: '' }
155
156/** Tools that put values into the record. */
157const RECORD_TOOLS: readonly string[] = ['record_measurements', 'import_measurements_csv', 'import_apple_health', 'save_self_measurement']
158/** Tools that store health information: the consent is asked before the first of them. */
159const CONSENT_TOOLS: readonly string[] = [...RECORD_TOOLS, 'read_narrative_findings', 'record_condition', 'record_medication_statement', 'forward_report', 'save_personal_profile', 'import_genetic_report']
160
161/** Routes asked to reload while a read of them was in flight, with the path to fetch. */
162const reloadWanted = new Map<string, string>()
163
164/** Read one route into the cache. `fetchPath` may carry ?refresh=1 while the cache key stays the bare path. */
165async function loadRoute($: Engine, path: string, force = false, fetchPath = path): Promise<void> {
166 try {
167 await loadRouteNow($, path, force, fetchPath)
168 } catch {
169 // the module unloaded under a background read: nothing to keep
170 }
171}
172
173async function loadRouteNow($: Engine, path: string, force: boolean, fetchPath: string): Promise<void> {
174 const rt = runtime()
175 if (!rt) return
176 const now = await $.clock.now()
177 const held = (await read($, data))[path]
178 // A load caught by a reload (the state outlives the module) is stale after 30 s, never stuck.
179 const inFlight = Boolean(held?.loading) && now - (held?.at ?? 0) < 30_000
180 if (held && !force && (inFlight || (held.status === 200 && now - held.at < FRESH_MS))) return
181 // A forced read while another is in flight: that one may predate the write; read again once it lands.
182 if (held && force && inFlight) {
183 reloadWanted.set(path, fetchPath)
184 return
185 }
186 await update($, data, (all) => ({ ...all, [path]: { ...(all[path] ?? EMPTY), loading: true, at: now } }))
187 let next: RouteCache
188 try {
189 const out = await route(rt, 'GET', `/api/longpi/${fetchPath}`)
190 const json = out.json as { error?: unknown } | null
191 next = {
192 at: await $.clock.now(), status: out.status, json: out.json, loading: false,
193 error: out.status === 200 ? '' : typeof json?.error === 'string' ? json.error : `HTTP ${out.status}`,
194 ...(out.text ? { text: out.text.slice(0, 600_000) } : {}),
195 }
196 } catch (error) {
197 next = { at: await $.clock.now(), status: 0, json: null, loading: false, error: error instanceof Error ? error.message : String(error) }
198 }
199 await update($, data, (all) => ({ ...all, [path]: next }))
200 const again = reloadWanted.get(path)
201 if (again !== undefined) {
202 reloadWanted.delete(path)
203 void loadRoute($, path, true, again)
204 }
205 if (path === 'game' && next.status === 200) void onGame($, next.json as GameView).catch(() => undefined)
206}
207
208async function loadRoutes($: Engine, paths: readonly string[], force = false): Promise<void> {
209 for (const path of paths) await loadRoute($, path, force)
210}
211
212async function routesOfView($: Engine): Promise<string[]> {
213 const v = await read($, view)
214 const cache = await read($, data)
215 const json = <T,>(path: string) => {
216 const held = cache[path]
217 return (held && held.status === 200 ? held.json : null) as T | null
218 }
219 return ['journey', 'people', 'game', ...pageOf(v.tab).routes(v, json)]
220}
221
222/** Read what the page shows now; a second pass picks up routes the first answers named. */
223async function loadView($: Engine): Promise<void> {
224 await loadRoutes($, await routesOfView($))
225 await loadRoutes($, await routesOfView($))
226}
227
228/** The 刷新 button: journey and tracking rebuilt from the record, every route of the page read again. */
229async function refreshAll($: Engine): Promise<void> {
230 await loadRoute($, 'journey', true, 'journey?refresh=1')
231 const paths = (await routesOfView($)).filter((path) => path !== 'journey')
232 for (const path of paths) await loadRoute($, path, true, path === 'tracking' ? 'tracking?refresh=1' : path)
233}
234
235/** After a write: what the page named, and the journey (every page's header reads it). */
236async function reloadAfter($: Engine, paths: readonly string[]): Promise<void> {
237 try {
238 await reloadNow($, paths)
239 } catch {
240 // the module unloaded under a background read
241 }
242}
243
244async function reloadNow($: Engine, paths: readonly string[]): Promise<void> {
245 const all = [...new Set(['journey', ...paths])]
246 await update($, data, (cache) => {
247 const out = { ...cache }
248 for (const key of Object.keys(out)) if (all.some((path) => key === path || key.startsWith(`${path}?`))) out[key] = { ...(out[key] as RouteCache), at: 0 }
249 return out
250 })
251 for (const path of all) await loadRoute($, path, true)
252}
253
254async function toastNotice($: Engine, text: string, tone: 'info' | 'good' | 'warn' = 'info'): Promise<void> {
255 $.ui.toast(text)
256 await update($, notice, () => ({ text, tone, at: Date.now() }))
257}
258
259async function post($: Engine, path: string, body: unknown, options: { reload?: readonly string[]; done?: string; quiet?: boolean; method?: 'POST' | 'PUT' | 'DELETE' } = {}): Promise<PostResult> {
260 const rt = runtime()
261 if (!rt) return { ok: false, status: 503, json: { error: 'LongPi 还在启动' } }
262 let out: { status: number; json: unknown }
263 try {
264 out = await route(rt, options.method ?? 'POST', `/api/longpi/${path}`, body)
265 } catch (error) {
266 out = { status: 500, json: { ok: false, error: error instanceof Error ? error.message : String(error) } }
267 }
268 const json = (out.json && typeof out.json === 'object' ? out.json : {}) as Record<string, unknown>
269 const ok = out.status === 200 && json.ok !== false
270 if (ok && options.done) await toastNotice($, options.done, 'good')
271 if (!ok && !options.quiet) await toastNotice($, typeof json.error === 'string' ? json.error : `没有保存(${out.status})`, 'warn')
272 // Another person is shown now: nothing read for the last one may stay on any page.
273 if (ok && path.startsWith('people')) await update($, data, () => ({}))
274 // A write can change what any page shows (an age saved opens the Codex): every page reads again when shown.
275 if (ok) await update($, data, (cache) => Object.fromEntries(Object.entries(cache).map(([key, row]) => [key, row.loading ? row : { ...row, at: 0 }])))
276 // The page's data is read again behind the answer, and quick writes in a row share one rebuild: a press never
277 // waits on a queue of journey rebuilds (each reruns the methods).
278 scheduleReload($, options.reload ?? [])
279 return { ok, status: out.status, json }
280}
281
282/** Paths waiting for the next reload, and its timer: writes within 700 ms of each other share one. */
283const pendingReload = new Set<string>()
284let reloadTimer: { cancel: () => void } | null = null
285
286function scheduleReload($: Engine, paths: readonly string[]): void {
287 for (const path of paths) pendingReload.add(path)
288 pendingReload.add('journey')
289 reloadTimer?.cancel()
290 reloadTimer = $.clock.after(700, () => {
291 reloadTimer = null
292 const all = [...pendingReload]
293 pendingReload.clear()
294 void reloadAfter($, all)
295 })
296}
297
298// --- talking to Pi ----------------------------------------------------------------------------------
299
300/** The LongPi snapshot (what the pane shows now, the top fact, what the person told LongPi), or ''. */
301async function snapshotNow(): Promise<string> {
302 const rt = runtime()
303 if (!rt) return ''
304 try {
305 const input = await op(() => rt.app.snapshot(4_000))
306 return input ? snapshotText(input) : ''
307 } catch {
308 return ''
309 }
310}
311
312/** Coach mode from here on: Pi's rules join the system prompt; the first time, they ride this message too. */
313async function enterCoach($: Engine): Promise<string[]> {
314 const rt = runtime()
315 const was = await read($, coach)
316 if (!was) await update($, coach, () => true)
317 const extra: string[] = []
318 if (!was && rt) extra.push(fullBrief(rt.app.mount))
319 const snapshot = await snapshotNow()
320 if (snapshot) extra.push(snapshot)
321 return extra
322}
323
324const PERSON_FRAME = '[LongPi] The person opened this from LongPi. Speak as Pi, their longevity coach; the LongPi snapshot below is from the plugin, not their words.'
325
326/**
327 * A turn in the person's words, with Pi's rules and the LongPi snapshot ahead of it as a message only the model
328 * reads (the engine runs no plugin's own prompt.submit hook on the prompts it submits). Never from inside the
329 * hook that holds the current dispatch: a moment later.
330 */
331function sayAsPerson($: Engine, text: string): void {
332 $.clock.after(0, () => {
333 void (async () => {
334 const extra = await enterCoach($).catch(() => [] as string[])
335 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: [PERSON_FRAME, ...extra].join('\n\n') }] } })
336 .catch((error: unknown) => $.ui.log(`longpi: snapshot not attached: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' }))
337 await $.prompt.submit({ text, asUser: true })
338 })().catch(() => undefined)
339 })
340}
341
342// --- the actions a page's press runs ------------------------------------------------------------------
343
344async function go($: Engine, tab: Tab, sub?: Record<string, string>): Promise<void> {
345 await update($, view, (v) => ({ tab, sub: { ...v.sub, ...(sub ?? {}) }, detail: null }))
346 await loadView($)
347}
348
349// --- the Codex stage -------------------------------------------------------------------------------
350
351/** The stage's width as last drawn (the driver must blit frames of the size the page drew). */
352let stageColsNow = 80
353let stageTimer: { cancel: () => void } | null = null
354
355function stopStage(): void {
356 stageTimer?.cancel()
357 stageTimer = null
358}
359
360async function codexStill($: Engine): Promise<boolean> {
361 const p = await read($, privacy)
362 const view = (await read($, data)).codex?.json as { prefs?: { simple?: boolean; presentation?: boolean } } | null | undefined
363 return p.presentation || Boolean(view?.prefs?.simple) || Boolean(view?.prefs?.presentation)
364}
365
366/** Blit the stage's frames while its phase moves, then move the overlay on to the next phase. */
367function playStage($: Engine): void {
368 stopStage()
369 let busy = false
370 let denied = 0
371 let lastBlit = 0
372 const timer = $.clock.every(45, async () => {
373 if (busy) return
374 busy = true
375 try {
376 const o = await read($, overlay)
377 if (!isAnimated(o)) {
378 timer.cancel()
379 return
380 }
381 const still = await codexStill($)
382 const now = Date.now()
383 const looping = o.phase === 'idle' || o.phase === 'back'
384 if (still) {
385 // No motion: jump to the end of each phase.
386 const at = stageAt(o, stageColsNow, now + 60_000, true)
387 if (at?.next) await update($, overlay, (cur) => (cur.since === o.since && cur.phase === o.phase ? { ...cur, phase: at.next as string, since: Date.now() } : cur))
388 else timer.cancel()
389 return
390 }
391 if (looping && now - lastBlit < 110) return
392 const at = stageAt(o, stageColsNow, now, false)
393 if (!at) {
394 timer.cancel()
395 return
396 }
397 const c = at.frame.cells()
398 const res = await $.ui.blit({ requestId: PANE, key: 'codex-stage', cells: c.cells, columns: c.columns, rows: c.rows })
399 lastBlit = now
400 if (res && 'deny' in res && res.deny) {
401 denied += 1
402 if (denied > 60) timer.cancel()
403 } else denied = 0
404 if (at.done && at.next) {
405 await update($, overlay, (cur) => (cur.since === o.since && cur.phase === o.phase ? { ...cur, phase: at.next as string, since: Date.now() } : cur))
406 } else if (at.done) timer.cancel()
407 } finally {
408 busy = false
409 }
410 })
411 stageTimer = timer
412}
413
414async function setOverlay($: Engine, fn: (o: CodexOverlay) => CodexOverlay): Promise<void> {
415 await update($, overlay, fn)
416 if (isAnimated(await read($, overlay))) playStage($)
417}
418
419function speciesNames($: Engine, keys: readonly string[], cache: Record<string, RouteCache>): string {
420 const lib = cache['codex/library']?.json as { species?: Array<{ key: string; name_zh: string }> } | null | undefined
421 return keys.map((key) => lib?.species?.find((row) => row.key === key)?.name_zh ?? key).join('、')
422}
423
424function codexActions($: Engine): CodexActions {
425 const act = (body: Record<string, unknown>) => post($, 'codex', body, { reload: ['codex', 'codex/slot', 'codex/library', 'game'] })
426 const payloadPatch = (patch: Record<string, unknown>) => setOverlay($, (o) => ({ ...o, payload: { ...((o.payload as Record<string, unknown> | null) ?? {}), ...patch } }))
427 return {
428 open: (kind, id, payload, phase = 'front') => {
429 void setOverlay($, () => ({ ...NO_OVERLAY, kind, id, phase, since: Date.now(), payload }))
430 if (kind === 'study') {
431 void post($, 'codex', { action: 'read', card_id: id }, { quiet: true, reload: ['codex/library', 'game'] }).then(async (res) => {
432 const met = Array.isArray(res.json.met) ? (res.json.met as string[]) : []
433 if (met.length === 0) return
434 await update($, overlay, (o) => (o.kind === 'study' && o.id === id ? { ...o, payload: { ...((o.payload as Record<string, unknown> | null) ?? {}), met } } : o))
435 await toastNotice($, `遇见了 ${speciesNames($, met, await read($, data))},已放进物种志。`, 'good')
436 })
437 }
438 },
439 tear: () => {
440 void (async () => {
441 const o = await read($, overlay)
442 if (o.kind !== 'pack' || o.phase !== 'idle') return
443 const started = Date.now()
444 await setOverlay($, (cur) => ({ ...cur, phase: 'shake', since: started }))
445 const res = await post($, 'codex', { action: 'open_pack', pack_id: o.id }, { quiet: true, reload: ['codex', 'codex/slot'] })
446 const wait = 560 - (Date.now() - started)
447 if (wait > 0 && !(await codexStill($))) await $.clock.sleep(wait)
448 if (!res.ok) {
449 await setOverlay($, (cur) => ({ ...cur, phase: 'idle', since: Date.now() }))
450 await toastNotice($, typeof res.json.error === 'string' ? res.json.error : '没有拆开,请再试一次。', 'warn')
451 return
452 }
453 const view = res.json.view as { packs?: Array<{ id: string; options?: unknown[] }> } | undefined
454 const payload = (o.payload ?? {}) as { packKind?: string }
455 if (payload.packKind === 'retest') {
456 const results = ((res.json.pack as { results?: unknown[] } | undefined)?.results ?? []) as unknown[]
457 await setOverlay($, (cur) => ({ ...cur, phase: 'burst', since: Date.now(), payload: { ...(cur.payload as object), results } }))
458 return
459 }
460 const options = view?.packs?.find((row) => row.id === o.id)?.options ?? []
461 if (options.length === 0) {
462 await setOverlay($, (cur) => ({ ...cur, phase: 'empty', since: Date.now(), note: typeof res.json.note_zh === 'string' ? res.json.note_zh : '现在没有适合你的实验。包会一直留着。' }))
463 return
464 }
465 await setOverlay($, (cur) => ({ ...cur, phase: 'burst', since: Date.now(), payload: { ...(cur.payload as object), options } }))
466 })()
467 },
468 turn: () => {
469 void (async () => {
470 const o = await read($, overlay)
471 if (o.kind !== 'reveal' || o.phase !== 'back') return
472 await payloadPatch({ busy: true })
473 const res = await post($, 'codex', { action: 'reveal', run_id: o.id }, { quiet: true, reload: ['codex', 'codex/slot'] })
474 if (!res.ok) {
475 await payloadPatch({ busy: false })
476 await toastNotice($, typeof res.json.error === 'string' ? res.json.error : '没有翻开,请再试一次。', 'warn')
477 return
478 }
479 const view = res.json.view as { deck?: unknown[] } | undefined
480 const run = res.json.run ?? view?.deck?.[0] ?? (o.payload as { run?: unknown } | null)?.run
481 await setOverlay($, (cur) => ({ ...cur, phase: 'turning', since: Date.now(), payload: { ...(cur.payload as object), run, busy: false } }))
482 })()
483 },
484 pick: (optionId) => void setOverlay($, (o) => ({ ...o, chosen: optionId ?? '', payload: { ...((o.payload as Record<string, unknown> | null) ?? {}), answers: {}, randomized: false } })),
485 answer: (questionId, yes) => void setOverlay($, (o) => {
486 const payload = ((o.payload as Record<string, unknown> | null) ?? {}) as { answers?: Record<string, boolean> }
487 return { ...o, payload: { ...payload, answers: { ...(payload.answers ?? {}), [questionId]: yes } } }
488 }),
489 randomize: (on) => void payloadPatch({ randomized: on }),
490 begin: () => {
491 void (async () => {
492 const o = await read($, overlay)
493 const payload = (o.payload ?? {}) as { options?: Array<{ id: string; title_zh: string }>; option?: { id: string; title_zh: string }; answers?: Record<string, boolean>; randomized?: boolean }
494 const option = o.kind === 'pack' ? payload.options?.find((row) => row.id === o.chosen) : payload.option
495 if (!option) return
496 const res = await act({ action: 'begin', experiment_id: option.id, ...(o.kind === 'pack' ? { pack_id: o.id } : {}), answers: payload.answers ?? {}, randomized: payload.randomized === true })
497 if (!res.ok) return
498 const run = res.json.run as { title_zh?: string } | undefined
499 await toastNotice($, typeof res.json.note_zh === 'string' && res.json.note_zh ? res.json.note_zh : `开始了「${run?.title_zh ?? option.title_zh}」。从今天算第 1 天。`, 'good')
500 stopStage()
501 await update($, overlay, () => NO_OVERLAY)
502 await update($, view, (v) => ({ ...v, sub: { ...v.sub, 'codex.tab': 'exp' } }))
503 })()
504 },
505 close: () => {
506 stopStage()
507 void update($, overlay, () => NO_OVERLAY).then(() => loadRoutes($, ['codex', 'codex/library'], true))
508 },
509 act,
510 }
511}
512
513function actionsFor($: Engine, surface: RenderSurface): Actions {
514 return {
515 go: (tab, sub) => void go($, tab, sub),
516 setSub: (key, value) => void update($, view, (v) => ({ ...v, sub: { ...v.sub, [key]: value } })).then(() => loadView($)),
517 detail: (id) => void update($, view, (v) => ({ ...v, detail: id })).then(() => loadView($)),
518 load: (paths, force) => void loadRoutes($, paths, force),
519 refresh: () => void refreshAll($),
520 post: (path, body, options) => post($, path, body, options),
521 ask: async (question, options, header) => {
522 try {
523 return await $.ui.ask(question, { options, ...(header ? { header: header.slice(0, 12) } : {}) })
524 } catch {
525 return null
526 }
527 },
528 say: (text) => sayAsPerson($, text),
529 fill: (text) => void $.prompt.fill({ text, mode: 'replace' }),
530 toast: (text) => void toastNotice($, text),
531 copy: (text) => void $.ui.copy({ text, surface }).then((res) => { if (res.isCopied) $.ui.toast('已复制') }),
532 reveal: () => void $.clock.now().then((now) => update($, privacy, (p) => ({ ...p, showUntil: now + 60_000 }))),
533 setPresentation: (on) => void setPresentation($, on),
534 codex: codexActions($),
535 celebrated: (toRoad) => void celebrated($, toRoad),
536 save: async (path, fileName) => {
537 await loadRoute($, path, true)
538 const held = (await read($, data))[path]
539 const text = held?.text ?? (held?.json !== undefined && held?.json !== null ? JSON.stringify(held.json, null, 2) : '')
540 if (!text) {
541 await toastNotice($, '没有可以保存的内容。', 'warn')
542 return null
543 }
544 const home = (await $.env.get('HOME')) ?? ''
545 const target = `${home}/Downloads/${fileName.replace(/[\\/]/g, '-')}`
546 try {
547 await $.fs.write(target, text)
548 } catch {
549 await toastNotice($, '没能写入下载文件夹。', 'warn')
550 return null
551 }
552 await toastNotice($, `已保存到 ${target}`, 'good')
553 return target
554 },
555 saveText: async (text, fileName, open) => {
556 const home = (await $.env.get('HOME')) ?? ''
557 const target = `${home}/Downloads/${fileName.replace(/[\\/]/g, '-')}`
558 try {
559 await $.fs.write(target, text)
560 } catch {
561 await toastNotice($, '没能写入下载文件夹。', 'warn')
562 return null
563 }
564 if (open) await $.process.run(['open', target]).catch(() => null)
565 await toastNotice($, open ? `已保存并打开:${target}` : `已保存到 ${target}`, 'good')
566 return target
567 },
568 saveFile: async (path, fileName) => {
569 const saved = await actionsFor($, surface).save(path, fileName)
570 return saved ? { ok: true, path: saved } : { ok: false, error: '没有可以保存的内容' }
571 },
572 close: () => void $.ui.close({ id: PANE }),
573 }
574}
575
576// --- the prompt slot above the prompt -------------------------------------------------------------------
577
578/** When the main conversation's current turn began (a Claude turn running ≥60 s is when a stand-up fits). */
579let turnSince: number | null = null
580
581async function noteActivity($: Engine): Promise<void> {
582 const now = await $.clock.now()
583 const prev = ((await $.store.get('activity')) ?? null) as Activity | null
584 await $.store.set('activity', noteInput(now, prev))
585}
586
587/** 演示模式: LongPi's own switch and the Codex's, together. */
588async function setPresentation($: Engine, on: boolean): Promise<void> {
589 await update($, privacy, (p) => ({ ...p, presentation: on, showUntil: 0 }))
590 await update($, band, () => null)
591 $.ui.status(undefined)
592 void post($, 'codex', { action: 'prefs', presentation: on }, { quiet: true, reload: ['codex', 'codex/slot'] })
593}
594
595/** Every few seconds: whether the slot should show something now, and the neutral line in the status bar. */
596async function decideBand($: Engine): Promise<void> {
597 const rt = runtime()
598 if (!rt) return
599 const now = await $.clock.now()
600 const p = await read($, privacy)
601 const current = await read($, band)
602 if (current) {
603 const filed = current.kind === 'welcome' && Boolean(await $.fs.stat(`${rt.rootDir}/record.json`).catch(() => null))
604 if (p.presentation || (current.kind === 'standup' && now - current.at > STANDUP_VISIBLE_MS) || (current.kind === 'today' && now - current.at > 10 * 60_000) || filed) await update($, band, () => null)
605 return
606 }
607 const slotJson = (await read($, data))['codex/slot']?.json as { enabled?: boolean; presentation?: boolean; slot?: SlotView; pane_neutral_zh?: string | null } | null | undefined
608 const presentation = p.presentation || Boolean(slotJson?.presentation)
609 // No standing status line: the engine draws a plugin's status as a warning (⚠), which a calm line is not.
610 // A record on disk (an upgrade, or values filed from the chat): the person found their way in already.
611 const welcomed = Boolean(await $.store.get(perHome('welcomed')))
612 const onDisk = welcomed ? true : Boolean(await $.fs.stat(`${rt.rootDir}/record.json`).catch(() => null))
613 if (!welcomed && onDisk) await $.store.set(perHome('welcomed'), true)
614 if (!presentation && !onDisk) {
615 await update($, band, () => ({ kind: 'welcome', ref: '', text: 'LongPi 长寿教练已装好 · 把体检报告拖进对话,或输入 /longpi 打开健康页', at: now }))
616 return
617 }
618 const news = ((await $.store.get(perHome('news'))) ?? null) as { week?: string; cards?: number; shown?: boolean } | null
619 if (!presentation && news && !news.shown && news.cards) {
620 await $.store.set(perHome('news'), { ...news, shown: true })
621 await update($, band, () => ({ kind: 'news', ref: news.week ?? '', text: `长寿图鉴 · 本周上架了 ${news.cards} 张新研究卡`, at: now }))
622 return
623 }
624 const activity = ((await $.store.get('activity')) ?? null) as Activity | null
625 const next = nextBand({
626 enabled: Boolean(slotJson?.enabled),
627 presentation,
628 slot: slotJson?.slot ?? null,
629 sitting: sittingMinutes(activity, now),
630 turnMs: turnSince === null ? 0 : now - turnSince,
631 now,
632 })
633 if (!next) {
634 // Once a day, on a quiet slot: the first open thing of today's three, in one line.
635 if (presentation) return
636 const dayKey = perHome(`today:${isoDay(new Date(now))}`)
637 if (await $.store.get(dayKey)) return
638 if (!(await read($, data)).game) await loadRoute($, 'game')
639 const game = (await read($, data)).game?.json as GameView | null | undefined
640 const open = game && !game.demo && !game.member ? game.things.find((row) => !row.done && row.id !== 'ask') : undefined
641 if (!open) return
642 await $.store.set(dayKey, true)
643 await update($, band, () => ({ kind: 'today', ref: open.id, text: `LongPi · 今天:${open.text_zh}`, at: now }))
644 return
645 }
646 await update($, band, () => next)
647 void post($, 'codex', next.kind === 'standup' ? { action: 'nudge', event: 'shown' } : { action: 'nudge', event: 'reveal_shown', ref: next.ref }, { quiet: true, reload: ['codex/slot'] })
648}
649
650async function revealFromBand($: Engine, ref: string): Promise<void> {
651 await update($, band, () => null)
652 void post($, 'codex', { action: 'nudge', event: 'reveal_open', ref }, { quiet: true, reload: ['codex/slot'] })
653 await update($, view, (v) => ({ ...v, tab: 'codex' as const, detail: null, sub: { ...v.sub, 'codex.tab': 'exp' } }))
654 await $.ui.open({ id: PANE, title: 'LongPi', focus: true, columns: 92 })
655 await loadRoutes($, ['codex', 'codex/library'], true)
656 const codexView = (await read($, data)).codex?.json as { ready?: Array<{ id: string }>; packs?: Array<{ id: string; kind: string; source_zh: string; options: unknown[]; opened: string | null }> } | null | undefined
657 const run = codexView?.ready?.find((row) => row.id === ref)
658 if (run) {
659 await setOverlay($, () => ({ ...NO_OVERLAY, kind: 'reveal', id: run.id, phase: 'back', since: Date.now(), payload: { run } }))
660 return
661 }
662 const pack = codexView?.packs?.find((row) => row.id === ref)
663 if (pack) await setOverlay($, () => ({ ...NO_OVERLAY, kind: 'pack', id: pack.id, phase: 'idle', since: Date.now(), payload: { packKind: pack.kind, sourceZh: pack.source_zh, options: [], results: [] } }))
664}
665
666// --- what LongPi sets up by itself ---------------------------------------------------------------------
667
668let settingUp = false
669
670/** A store key for this LongPi home: the weekly run and its notice belong to one data folder, not the mod. */
671function perHome(key: string): string {
672 return `${key}:${runtime()?.rootDir ?? ''}`
673}
674const WEEK_MS = 7 * 24 * 3_600_000
675
676/**
677 * The full calculation environment, built quietly in the background the first time (and checked weekly);
678 * `manual` (/longpi setup) also updates the method library and says what it did.
679 */
680async function setupLongPi($: Engine, manual: boolean): Promise<void> {
681 const rt = runtime()
682 if (!rt || settingUp) return
683 settingUp = true
684 const say = (line: string) => $.ui.status(`LongPi · ${line}`)
685 try {
686 const home = (await $.env.get('HOME')) ?? '/'
687 const io = ioOf($)
688 const lines: string[] = []
689 if (manual) {
690 const lib = await updateLibrary(io, home, say)
691 lines.push(lib.line)
692 }
693 const env = await ensurePython(io, home, rt.python, say)
694 lines.push(...env.lines)
695 await $.store.set('env', { ok: env.ok, python: env.python, at: await $.clock.now() })
696 await reloadLibrary()
697 rt.app.invalidate()
698 $.ui.status(undefined)
699 const done = runtime()
700 if (manual) {
701 $.ui.log(`LongPi:${lines.join(' ')}${done ? ` 方法库 ${done.skillsHome || '未找到'};Python ${done.python || '未找到'}` : ''}`)
702 await toastNotice($, env.ok ? 'LongPi 已就绪:方法库和计算环境都准备好了。' : '大多数方法已经能算;完整的计算环境这次没准备好,详情见上面的记录。', env.ok ? 'good' : 'warn')
703 } else if (env.ok && lines.length > 0) {
704 await toastNotice($, 'LongPi 的计算环境准备好了:所有方法都能算了。', 'good')
705 }
706 } catch (error) {
707 $.ui.status(undefined)
708 if (manual) await toastNotice($, `没有完成:${error instanceof Error ? error.message : String(error)}`, 'warn')
709 } finally {
710 settingUp = false
711 }
712}
713
714/**
715 * Once a week, in the background: a fresh copy of the method library, and the literature scout's new research
716 * cards for the Codex. The first session of the week that finds new cards says so once, above the prompt.
717 */
718async function weekly($: Engine, force = false): Promise<{ cards: number; reason: string } | null> {
719 const rt = runtime()
720 if (!rt) return null
721 const now = await $.clock.now()
722 const last = ((await $.store.get(perHome('weekly'))) ?? null) as { at?: number; week?: string } | null
723 if (!force && last?.at && now - last.at < WEEK_MS) return null
724 // Held for an hour while it runs (another window starting meanwhile does not run it twice); a run that found
725 // nothing to read (no network, PubMed down, no model) is tried again the next day, not in a week.
726 await $.store.set(perHome('weekly'), { at: now - WEEK_MS + 3_600_000, week: last?.week ?? '' })
727 const home = (await $.env.get('HOME')) ?? '/'
728 const io = ioOf($)
729 const lib = await updateLibrary(io, home, () => undefined).catch(() => null)
730 if (lib?.changed) {
731 await reloadLibrary()
732 rt.app.invalidate()
733 }
734 const found = await scoutLiterature(io, rt.rootDir, new Date(now)).catch(() => null)
735 $.ui.log(`longpi: weekly — ${lib?.line ?? 'library unchanged'}; literature ${found ? `${found.cards} cards from ${found.candidates} papers${found.reason ? ` (${found.reason})` : ''}` : 'not run'}`, { to: 'debug' })
736 const ran = Boolean(found?.finished)
737 await $.store.set(perHome('weekly'), ran ? { at: now, week: isoWeek(new Date(now)) } : { at: now - WEEK_MS + 24 * 3_600_000, week: last?.week ?? '' })
738 if (found && found.cards > 0) await $.store.set(perHome('news'), { week: found.week, cards: found.cards, shown: false })
739 await update($, data, (all) => {
740 const out = { ...all }
741 delete out['codex/library']
742 delete out.game
743 return out
744 })
745 return found ? { cards: found.cards, reason: found.reason } : { cards: 0, reason: '没有运行' }
746}
747
748/** At session start: build the environment once in the background if it is missing, re-check weekly. */
749async function maintain($: Engine): Promise<void> {
750 const rt = runtime()
751 if (!rt) return
752 await weekly($).catch(() => undefined)
753 const env = ((await $.store.get('env')) ?? null) as { ok?: boolean; python?: string; at?: number } | null
754 const now = await $.clock.now()
755 if (env?.ok && env.at && now - env.at < WEEK_MS) return
756 if (env && !env.ok && env.at && now - env.at < WEEK_MS) return
757 if (await hasPackages(ioOf($), rt.python)) {
758 await $.store.set('env', { ok: true, python: rt.python, at: now })
759 return
760 }
761 await setupLongPi($, false)
762}
763
764/** The product notice and the health-information consent, for the person shown now. */
765/** Whether the person shown now agreed to LongPi keeping their values: read from their profile, not the journey (no rebuild). */
766/** What a tool call says when its work outlasts the engine's budget and goes on in the background. */
767const LATE_ANY = 'LongPi is still working on this and will finish in the background (the panel updates when it is done). Do not call the same tool again with the same input; wait a moment, then call longpi_status or read_personal_situation to see the result.'
768const LATE: Record<string, string> = {
769 record_measurements: 'LongPi is still saving these values and will finish in the background (the panel updates when it is done). Do not send the same items again. In a moment, call read_personal_situation to confirm they are on file; if a report path was given, the check for items not yet filed runs on the next record_measurements call for that report.',
770}
771
772async function consentGiven($: Engine, rt: NonNullable<ReturnType<typeof runtime>>): Promise<boolean> {
773 const text = await $.fs.read(`${resolveDataDir(rt.config.dataDir)}/profile.json`).catch(() => '')
774 try {
775 return (JSON.parse(text) as { consent?: { version?: string } }).consent?.version === CONSENT_VERSION
776 } catch {
777 return false
778 }
779}
780
781/** The family member shown now ('' for the holder). */
782async function activeLabel(rt: NonNullable<ReturnType<typeof runtime>>): Promise<string> {
783 const people = (await route<{ active?: string; people?: Array<{ id: string; label_zh: string; demo?: boolean }> }>(rt, 'GET', '/api/longpi/people').catch(() => null))?.json
784 return people?.people?.find((row) => row.id === people.active && row.id !== 'self' && !row.demo)?.label_zh ?? ''
785}
786
787/** A brief the model prepared, also saved as a page to print in ~/Downloads; the answer says where. */
788async function printableBrief($: Engine, text: string): Promise<string> {
789 try {
790 const answer = JSON.parse(text) as { ok?: boolean; markdown?: string; where_zh?: string }
791 if (!answer.ok || typeof answer.markdown !== 'string' || !answer.markdown) return text
792 const rt = runtime()
793 const people = rt ? (await route<{ active?: string; people?: Array<{ id: string; label_zh: string }> }>(rt, 'GET', '/api/longpi/people').catch(() => null))?.json : null
794 const who = people?.people?.find((row) => row.id === people.active && row.id !== 'self')?.label_zh ?? ''
795 const day = isoDay(new Date(await $.clock.now()))
796 const home = (await $.env.get('HOME')) ?? ''
797 const target = `${home}/Downloads/LongPi 医生简报${who ? ` ${who}` : ''} ${day}.html`
798 await $.fs.write(target, markdownToHtml(answer.markdown, `LongPi 医生简报${who ? ` · ${who}` : ''}`))
799 return JSON.stringify({ ...answer, file: target, where_zh: `已存成可打印的网页:${target}。双击用浏览器打开,按 ⌘P 打印;健康页「总览」最上面的「最重要的一步」里也能打开。` })
800 } catch {
801 return text
802 }
803}
804
805// --- Pi and the road to 120 --------------------------------------------------------------------------
806
807/** When the pane last drew, and how wide its body was: a celebration plays only on a pane that is showing. */
808let paneDrawnAt = 0
809let paneWidthNow = 88
810let celebrateTimer: { cancel: () => void } | null = null
811let idleTimer: { cancel: () => void } | null = null
812/** Fresh milestones already toasted this session while the pane was closed (celebrated when it opens). */
813const toasted = new Set<string>()
814
815async function stillPictures($: Engine): Promise<boolean> {
816 return (await read($, privacy)).presentation || (await codexStill($))
817}
818
819function celebrationLines(game: GameView): string[] {
820 const lines: string[] = []
821 const stations = game.stations.filter((row) => game.fresh.stations.includes(row.id))
822 const medals = game.medals.filter((row) => game.fresh.medals.includes(row.id))
823 const last = stations[stations.length - 1]
824 if (last) lines.push(stations.length > 1 ? `走过了 ${stations.length} 站,最新一站:${last.title_zh}` : `新的一站:${last.title_zh}`)
825 if (game.fresh.form != null) lines.push(`Pi 长成了「${game.form.zh}」。${game.form.line_zh}`)
826 for (const row of medals) lines.push(`新奖章:${row.title_zh}`)
827 if (lines.length > 0 && game.form.next_at != null && game.form.next_zh) lines.push(`通往 120 · 第 ${game.reached}/12 站`)
828 return lines
829}
830
831/** A game reading with something fresh: celebrate on a showing pane, otherwise one toast and wait for the pane. */
832async function onGame($: Engine, game: GameView): Promise<void> {
833 if (!game?.fresh || game.demo || game.member) return
834 const fresh = [...game.fresh.stations, ...game.fresh.medals, ...(game.fresh.form != null ? [`form${game.fresh.form}`] : [])]
835 if (fresh.length === 0) return
836 // Not in the middle of setting up: the celebration waits for the page after the setup steps.
837 const stage = ((await read($, data)).journey?.json as { stage?: string } | null | undefined)?.stage ?? ''
838 const v = await read($, view)
839 if (['consent', 'profile', 'records'].includes(stage) || (v.tab === 'overview' && v.sub['overview.onboarding'] === '1')) return
840 const lines = celebrationLines(game)
841 // One showing already: what is new joins it rather than queuing a second one.
842 const showingNow = await read($, celebrate)
843 if (showingNow) {
844 const sameSet = game.fresh.stations.every((id) => showingNow.stations.includes(id)) && game.fresh.medals.every((id) => showingNow.medals.includes(id)) && (game.fresh.form == null || showingNow.form === game.fresh.form)
845 if (sameSet) return
846 await update($, celebrate, (c) => c ? { ...c, stations: [...new Set([...c.stations, ...game.fresh.stations])], medals: [...new Set([...c.medals, ...game.fresh.medals])], form: game.fresh.form ?? c.form, from: c.from ?? (game.fresh.form != null ? Math.max(0, game.fresh.form - 1) : null), lines } : c)
847 return
848 }
849 const showing = Date.now() - paneDrawnAt < 4_000
850 if (!showing) {
851 const key = fresh.join(',')
852 if (toasted.has(key)) return
853 toasted.add(key)
854 $.ui.toast(`Pi:${lines[0] ?? '有新的进展'}。输入 /longpi 看看。`)
855 return
856 }
857 const from = game.fresh.form != null ? Math.max(0, game.fresh.form - 1) : null
858 await update($, celebrate, () => ({ since: Date.now(), stations: game.fresh.stations, medals: game.fresh.medals, form: game.fresh.form, from, lines }))
859 playCelebration($, game.form.no as PiForm, from as PiForm | null)
860}
861
862function playCelebration($: Engine, form: PiForm, from: PiForm | null): void {
863 celebrateTimer?.cancel()
864 let busy = false
865 const timer = $.clock.every(60, async () => {
866 if (busy) return
867 busy = true
868 try {
869 const c = await read($, celebrate)
870 if (!c || (await stillPictures($))) {
871 timer.cancel()
872 if (c) await update($, tick, (n) => n + 1)
873 return
874 }
875 const t = Date.now() - c.since
876 const f = celebrateFrame(Math.min(paneWidthNow, 72), t, form, from)
877 const cells = f.cells()
878 await $.ui.blit({ requestId: PANE, key: 'pi-celebrate', cells: cells.cells, columns: cells.columns, rows: cells.rows })
879 if (t > CELEBRATE_MS) {
880 timer.cancel()
881 await update($, tick, (n) => n + 1)
882 }
883 } finally {
884 busy = false
885 }
886 })
887 celebrateTimer = timer
888}
889
890/** 好 / 看看这条路: each milestone is celebrated once, across sessions. */
891async function celebrated($: Engine, toRoad: boolean): Promise<void> {
892 const c = await read($, celebrate)
893 celebrateTimer?.cancel()
894 celebrateTimer = null
895 await update($, celebrate, () => null)
896 if (c) await post($, 'game/seen', { stations: c.stations, medals: c.medals, form: c.form ?? 0 }, { quiet: true, reload: ['game'] })
897 if (toRoad) await go($, 'journey')
898}
899
900/** Pi breathing on 总览: a frame every IDLE_MS while the card is on screen; stops once it is not. */
901function startIdle($: Engine): void {
902 if (idleTimer) return
903 let n = 0
904 let denied = 0
905 let busy = false
906 const timer = $.clock.every(IDLE_MS, async () => {
907 if (busy) return
908 busy = true
909 try {
910 const v = await read($, view)
911 const game = (await read($, data)).game?.json as GameView | null | undefined
912 if (v.tab !== 'overview' || !game || (await read($, celebrate)) || (await read($, overlay)).kind !== 'none' || (await stillPictures($))) {
913 denied += 1
914 } else {
915 n += 1
916 const f = piFrame(game.form.no as PiForm, moodAt(Date.now()), n)
917 const cells = f.cells()
918 const res = await $.ui.blit({ requestId: PANE, key: 'pi-card', cells: cells.cells, columns: cells.columns, rows: cells.rows })
919 denied = res && 'deny' in res && res.deny ? denied + 1 : 0
920 }
921 if (denied > 6) {
922 timer.cancel()
923 if (idleTimer === timer) idleTimer = null
924 }
925 } catch {
926 timer.cancel()
927 if (idleTimer === timer) idleTimer = null
928 } finally {
929 busy = false
930 }
931 })
932 idleTimer = timer
933}
934
935// --- /longpi --------------------------------------------------------------------------------------
936
937const TAB_WORDS: Record<string, Tab> = {
938 总览: 'overview', 概览: 'overview', overview: 'overview', home: 'overview', 首页: 'overview',
939 化验: 'labs', 指标: 'labs', labs: 'labs', indicators: 'labs',
940 睡眠: 'sleep', sleep: 'sleep',
941 运动: 'training', training: 'training',
942 日程: 'calendar', calendar: 'calendar',
943 方案: 'plan', plan: 'plan',
944 图鉴: 'codex', 长寿图鉴: 'codex', codex: 'codex', 卡片: 'codex',
945 深度分析: 'analysis', analysis: 'analysis',
946 问: 'ask', ask: 'ask',
947 档案: 'profile', profile: 'profile', 家人: 'profile',
948 研究: 'science', science: 'science',
949 设置: 'settings', settings: 'settings', 提醒: 'settings',
950 pi: 'journey', Pi: 'journey', 旅程: 'journey', 通往120: 'journey', '120': 'journey', 路线: 'journey', 奖章: 'journey', journey: 'journey',
951}
952
953async function openPane($: Engine, tab?: Tab): Promise<void> {
954 if (tab) await update($, view, (v) => ({ ...v, tab, detail: null }))
955 // Opened once: the welcome line above the prompt has done its job.
956 void $.store.set(perHome('welcomed'), true).catch(() => undefined)
957 const shownBand = await read($, band)
958 if (shownBand?.kind === 'welcome') await update($, band, () => null)
959 await $.ui.open({ id: PANE, title: 'LongPi', focus: true, columns: 92 })
960 void loadView($)
961}
962
963/** A LongPi tool call: the consent asked the first time, the tool run, the pane told to read again. */
964async function serveTool($: Engine, e: { tool: string }, rt: NonNullable<ReturnType<typeof runtime>>, name: string): Promise<{ result: string; context?: string[] } | { deny: string }> {
965 const { tool: _tool, tool_use_id: callId, ...args } = e as unknown as Record<string, unknown> & { tool: string; tool_use_id: string }
966 const session = await $.session.id()
967 // Values go into the record only with the person's consent: the first time, ask in a dialog.
968 if (CONSENT_TOOLS.includes(name) && !(await consentGiven($, rt))) {
969 const who = await activeLabel(rt)
970 let answer: string | null = null
971 try {
972 answer = await $.ui.ask(who
973 ? `LongPi 要把${who}的检查数值存进这台电脑上的健康档案,用来算结果、定方案。你已经告诉${who},${who}也同意了吗?`
974 : 'LongPi 要把报告里的数值存进这台电脑上的健康档案,用来算身体年龄、心血管风险和方案。同意 LongPi 这样使用你的体检、化验、血压、体重和用药等健康信息吗?可以随时在「设置」里撤回。', { options: ['同意', '不同意'], header: 'LongPi' })
975 } catch {
976 answer = null
977 }
978 if (answer !== '同意') return { deny: 'The person did not agree to LongPi keeping their health information. Nothing was saved. Say so in one line; they can agree later in /longpi.' }
979 await post($, 'consent', { accept: true }, { quiet: true })
980 await post($, 'privacy/consent', { scope: 'pipl_sensitive', decision: 'granted' }, { quiet: true })
981 await post($, 'privacy/consent', { scope: 'data_flow_deepseek', decision: 'granted' }, { quiet: true, reload: ['privacy', 'journey'] })
982 }
983 const out = await runTool(rt, name, args, async (reason) => {
984 try {
985 return (await $.ui.ask(reason, { options: ['同意', '不同意'], header: 'LongPi' })) === '同意'
986 } catch {
987 return false
988 }
989 }, session, String(callId ?? ''))
990 if (out.denied) return { deny: out.denied }
991 if (name === 'prepare_doctor_brief') out.text = await printableBrief($, out.text)
992 await update($, healthTurn, () => true)
993 const firstTime = !(await read($, coach))
994 const context = firstTime ? await enterCoach($) : []
995 // A write the pane shows: read its data again.
996 if ((WRITE_TOOLS as readonly string[]).includes(name) || RECORD_TOOLS.includes(name)) {
997 // The Codex series are rebuilt from the new values first, so a pack opened next can use them.
998 void (RECORD_TOOLS.includes(name) ? op(() => recordChanged()) : Promise.resolve())
999 .catch(() => undefined)
1000 .then(() => reloadAfter($, ['tracking', 'codex', 'codex/slot', 'indicators?area=labs', 'game']))
1001 }
1002 return context.length > 0 ? { result: out.text, context } : { result: out.text }
1003}
1004
1005export const register: Register = (on) => {
1006 on('session.start', async ($, e, next) => {
1007 await startCore($)
1008 const rt = runtime()
1009 if (rt) for (const spec of toolSpecs(rt)) await $.tool.register(spec)
1010 await $.command.register({ name: 'longpi', description: 'LongPi 长寿教练:健康页、长寿图鉴、方案与打卡', argumentHint: '[总览|图鉴|通往120|方案|档案|设置|新研究|setup|演示模式|你想问的话]' })
1011 await update($, booted, () => true)
1012 // The environment builds itself in the background a minute in, never in the way of the first prompt.
1013 $.clock.after(60_000, () => {
1014 void maintain($).catch(() => undefined)
1015 })
1016 // Background work: a failure (the module unloading under it) is never an unhandled rejection.
1017 const quietly = (work: Promise<unknown>) => void work.catch(() => undefined)
1018 $.clock.every(3_000, () => {
1019 quietly(flushPending())
1020 })
1021 $.clock.every(60_000, () => {
1022 quietly(update($, tick, (t) => t + 1))
1023 quietly(loadRoute($, 'codex/slot', true))
1024 if (turnSince !== null) quietly(noteActivity($))
1025 })
1026 $.clock.every(15_000, () => {
1027 quietly(decideBand($))
1028 })
1029 $.clock.after(3_000, () => {
1030 quietly(decideBand($))
1031 })
1032 void loadRoute($, 'codex/slot')
1033 return next(e)
1034 })
1035
1036 on('prompt.compose', async ($, e, next) => {
1037 const composed = await next(e)
1038 const rt = runtime()
1039 const sections = [{ id: 'longpi:brief', text: SHORT_BRIEF, scope: 'session' as const }]
1040 if (rt && (await read($, coach))) sections.push({ id: 'longpi:coach', text: fullBrief(rt.app.mount), scope: 'session' as const })
1041 return { sections: [...composed.sections, ...sections] }
1042 })
1043
1044 on('prompt.submit', async ($, e, next) => {
1045 const isOwn = e.origin.kind === 'plugin' && e.origin.name === 'longpi'
1046 if (e.origin.kind !== 'composer' && !isOwn) return next(e)
1047 await noteActivity($)
1048 // A prompt from /longpi, or the next one after a turn that used LongPi: the snapshot rides along.
1049 if (!isOwn && !(await read($, healthTurn))) return next(e)
1050 await update($, healthTurn, () => false)
1051 let extra: string[] = []
1052 try {
1053 extra = isOwn ? await enterCoach($) : [await snapshotNow()].filter(Boolean)
1054 } catch (error) {
1055 $.ui.log(`longpi: snapshot failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
1056 }
1057 if (extra.length === 0 && !isOwn) return next(e)
1058 return next({ ...e, context: [...(e.context ?? []), ...(isOwn ? [PERSON_FRAME] : []), ...extra] })
1059 })
1060
1061 on('turn.start', async ($, e, next) => {
1062 turnSince = await $.clock.now()
1063 await noteActivity($)
1064 return next(e)
1065 })
1066
1067 on('turn.complete', async ($, e, next) => {
1068 const done = await next(e)
1069 if (!e.agentId) {
1070 turnSince = null
1071 await noteActivity($)
1072 }
1073 return done
1074 })
1075
1076 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1077 if (e.props.hasSurvey || e.props.view.agentId) return next(e)
1078 const prompt = await read($, band)
1079 if (!prompt || (await read($, privacy)).presentation) return next(e)
1080 return bandTree($.ui.resolve(e), prompt, {
1081 standupOk: () => {
1082 void update($, band, () => null)
1083 void post($, 'codex', { action: 'nudge', event: 'ok' }, { quiet: true, reload: ['codex/slot'] })
1084 },
1085 standupOff: () => {
1086 void update($, band, () => null)
1087 void post($, 'codex', { action: 'nudge', event: 'dismiss_today' }, { quiet: true, reload: ['codex/slot'] })
1088 },
1089 revealOpen: (ref) => void revealFromBand($, ref),
1090 newsOpen: () => {
1091 void (async () => {
1092 await update($, band, () => null)
1093 await update($, view, (v) => ({ ...v, tab: 'codex' as const, detail: null, sub: { ...v.sub, 'codex.tab': 'library', 'codex.chapter': 'new', 'codex.tier': '' } }))
1094 await openPane($)
1095 })().catch(() => undefined)
1096 },
1097 newsLater: () => void update($, band, () => null),
1098 welcomeOpen: () => {
1099 void (async () => {
1100 await $.store.set(perHome('welcomed'), true)
1101 await update($, band, () => null)
1102 await openPane($, 'overview')
1103 })().catch(() => undefined)
1104 },
1105 todayOpen: () => {
1106 void (async () => {
1107 await update($, band, () => null)
1108 await openPane($, 'overview')
1109 })().catch(() => undefined)
1110 },
1111 todayLater: () => void update($, band, () => null),
1112 welcomeLater: () => {
1113 void $.store.set(perHome('welcomed'), true).then(() => update($, band, () => null)).catch(() => undefined)
1114 },
1115 revealLater: (ref) => {
1116 void update($, band, () => null)
1117 void post($, 'codex', { action: 'nudge', event: 'reveal_later', ref }, { quiet: true, reload: ['codex/slot'] })
1118 },
1119 })
1120 })
1121
1122 on('ui.render', { component: 'ToolUse', props: { tool: /^mcp__longpi__/ } }, async ($, e, next) => {
1123 const id = e.props.tool_use_id
1124 const state = (await read($, cards))[id] ?? {}
1125 const setCard = (patch: CardState) => update($, cards, (all) => ({ ...all, [id]: { ...(all[id] ?? {}), ...patch } }))
1126 const tree = cardTree($.ui.resolve(e), {
1127 tool: e.props.tool,
1128 input: (e.props.input && typeof e.props.input === 'object' ? e.props.input : {}) as Record<string, unknown>,
1129 output: e.props.output,
1130 isRunning: e.props.isRunning,
1131 isErrored: e.props.isErrored,
1132 }, state, {
1133 adoptDraft: (draft, source) => {
1134 void (async () => {
1135 await setCard({ busy: true, error: '' })
1136 const res = await post($, 'plan-draft/accept', { draft, ...(source.focus.length ? { focus: source.focus } : {}), ...(source.markers.length ? { markers: source.markers } : {}) }, { quiet: true, reload: ['tracking', 'plan-draft'] })
1137 const plan = res.json.plan as { version?: number } | undefined
1138 if (res.ok && plan?.version) await setCard({ busy: false, adopted: plan.version })
1139 else await setCard({ busy: false, error: Array.isArray(res.json.problems) ? (res.json.problems as string[]).join(' ') : typeof res.json.error === 'string' ? res.json.error : '没有采用,请稍后再试' })
1140 })()
1141 },
1142 undoCheckins: (items) => {
1143 void (async () => {
1144 await setCard({ busy: true })
1145 for (const item of items) await post($, 'checkin', { item, done: null }, { quiet: true, reload: ['tracking'] })
1146 await setCard({ busy: false, undone: true })
1147 })()
1148 },
1149 fill: (text) => void $.prompt.fill({ text, mode: 'replace' }),
1150 open: (tab) => void openPane($, (TAB_WORDS[tab] ?? tab) as Tab),
1151 }, Math.max(40, (e.viewport?.columns ?? 100) - 4))
1152 return tree ?? next(e)
1153 })
1154
1155 // The card above says what the call did; the raw JSON under it stays out of the transcript (ctrl+o shows it).
1156 on('ui.render', { component: 'ToolResult', props: { tool: /^mcp__longpi__/ } }, async ($, e, next) => {
1157 if (e.props.isErrored) return next(e)
1158 const { Box } = $.ui.resolve(e)
1159 return <Box />
1160 })
1161
1162 on('tool.call', { tool: /^mcp__longpi__/ }, async ($, e, next) => {
1163 const rt = runtime()
1164 if (!rt) return { deny: 'LongPi is still starting.' }
1165 const name = String(e.tool).slice(TOOL_PREFIX.length)
1166 const mine = ioOf($)
1167 toolIos.push(mine)
1168 // A safety net under the budget: work still running near its end goes on in the background, and the call
1169 // answers with what to do instead of the engine's "no hook answered".
1170 let watch: { cancel: () => void } | null = null
1171 const late = new Promise<{ result: string }>((resolve) => {
1172 watch = $.clock.every(400, () => {
1173 if (next.budget.remainingMs > 2_000) return
1174 watch?.cancel()
1175 void $.ui.log(`longpi: ${name} answered early, its work goes on in the background`, { to: 'debug' })
1176 resolve({ result: LATE[name] ?? LATE_ANY })
1177 })
1178 })
1179 try {
1180 return await Promise.race([serveTool($, e, rt, name), late])
1181 } finally {
1182 ;(watch as { cancel: () => void } | null)?.cancel()
1183 const at = toolIos.lastIndexOf(mine)
1184 if (at >= 0) toolIos.splice(at, 1)
1185 }
1186 })
1187
1188 on('command.run', { command: 'longpi' }, async ($, e) => {
1189 const rt = runtime()
1190 const arg = e.args.trim()
1191 const [first = '', ...rest] = arg.split(/\s+/)
1192 if (!rt) return { text: 'LongPi 还在启动,请稍等几秒再试。' }
1193 if (first === 'status') {
1194 return { text: `LongPi:${rt.ctx.toolDefs.size} 个工具,方法库 ${rt.skillsHome || '未安装'},Python ${rt.python || '未找到'}` }
1195 }
1196 if (first === 'setup' || first === '安装' || first === '更新') {
1197 void setupLongPi($, true)
1198 return { text: '正在安装或更新 LongPi 的方法库和计算环境,进度在状态栏,完成后会提示。' }
1199 }
1200 if (first === '新研究' || first === '文献' || first === 'research') {hooks/sys/host.ts 49 lines1// The one seam between the ported LongPi core and Claude Code. The core was written for Node inside
2// DeepSeek Harness; here it runs inside a Claude Code mod, which has no Node and reaches the machine only
3// through the engine. register.tsx builds an Io from the engine at session start and installs it here;
4// the Node-shaped shims beside this file (fs, child_process, process, os, url) read it.
5
6export type ListEntry = { name: string; kind: 'file' | 'directory' | 'other'; size: number; mtimeMs: number }
7
8export type RunResult = { exitCode: number; stdout: string; stderr: string }
9
10export type CompleteResult = { ok: true; text: string } | { ok: false; reason: string }
11
12export type Io = {
13 read: (path: string) => Promise<string>
14 readBase64: (path: string) => Promise<string>
15 write: (path: string, text: string) => Promise<void>
16 list: (path: string) => Promise<ListEntry[]>
17 stat: (path: string) => Promise<{ kind: 'file' | 'directory' | 'other'; size: number; mtimeMs: number } | null>
18 run: (argv: readonly string[], init?: { cwd?: string; env?: Record<string, string>; stdin?: string; timeoutMs?: number }) => Promise<RunResult>
19 fetch: (url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }) => Promise<{ status: number; ok: boolean; text: string; headers?: Record<string, string> }>
20 complete: (prompt: string, options?: { system?: string; maxTokens?: number; model?: string; timeoutMs?: number }) => Promise<CompleteResult>
21 now: () => Promise<number>
22 log: (line: string) => void
23}
24
25export type Host = {
26 io: Io
27 pluginRoot: string
28 home: string
29 env: Record<string, string>
30 platform: string
31 /** The clock the sync core reads (refreshed before each operation). */
32 nowMs: number
33}
34
35let current: Host | null = null
36
37export function installHost(host: Host): void {
38 current = host
39}
40
41export function host(): Host {
42 if (!current) throw new Error('LongPi host is not installed yet')
43 return current
44}
45
46export function hasHost(): boolean {
47 return current !== null
48}
49hooks/app/runtime.ts 303 lines1// The LongPi core inside Claude Code: boots the ported plugin against the engine's I/O (sys/host.ts),
2// keeps its file copy in step with the disk (sys/vfs.ts), and serves its tools and routes to register.tsx.
3// Nothing here touches the engine directly: register.tsx hands in an Io and the callbacks it needs.
4
5import { installHost, host, type Io } from '../sys/host.ts'
6import { installGlobals } from '../sys/globals.ts'
7import { installScheduler } from '../sys/timers.ts'
8import { vfs } from '../sys/vfs.ts'
9import { join } from '../sys/path.ts'
10import { setCwd } from '../sys/path.ts'
11import { HostContext, type ParamSpec, type ToolDef } from '../sys/cordis.ts'
12import { apply, configFrom, type Config, type LongPiApp } from '../core/index.ts'
13import { resolveDataDir, resolveRootDir, skillsHomeCandidates } from '../core/paths.ts'
14import { setRecordDir } from '../core/mcp.ts'
15import { setRevision } from '../core/catalog.ts'
16import { registerRecordTools } from './record-tool.ts'
17import { newer } from './setup.ts'
18import { registerGame } from './game.ts'
19
20function localDay(at: Date): string {
21 return `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, '0')}-${String(at.getDate()).padStart(2, '0')}`
22}
23
24export type Runtime = {
25 ctx: HostContext
26 app: LongPiApp
27 config: Config
28 rootDir: string
29 skillsHome: string
30 python: string
31 booted: number
32}
33
34let current: Runtime | null = null
35
36export function runtime(): Runtime | null {
37 return current
38}
39
40/** One core operation at a time, on a copy freshly read from disk, written back when it ends. */
41export function op<T>(fn: () => Promise<T> | T, label = ''): Promise<T> {
42 const asked = Date.now()
43 const where = label || (new Error().stack ?? '').split('\n').slice(2, 4).map((line) => line.trim().replace(/^at /, '').replace(/\(.*\/hooks\//, '(')).join(' < ')
44 return vfs.exclusive(async () => {
45 const io = host().io
46 const got = Date.now()
47 await vfs.sync(io)
48 const synced = Date.now()
49 try {
50 return await fn()
51 } finally {
52 await vfs.flush(io)
53 // A slow operation holds everything else up: say which, in the debug log.
54 const done = Date.now()
55 if (done - asked > 1500) io.log(`slow op ${done - asked} ms (waited ${got - asked}, sync ${synced - got}, ran ${done - synced}): ${where}`)
56 }
57 })
58}
59
60/** Write out what background work (timers, a model call finishing late) left in the copy. */
61export async function flushPending(): Promise<void> {
62 if (!vfs.hasPending()) return
63 await vfs.exclusive(async () => vfs.flush(host().io))
64}
65
66async function firstFile(io: Io, paths: readonly string[]): Promise<string> {
67 for (const path of paths) {
68 if (!path) continue
69 const stat = await io.stat(path).catch(() => null)
70 if (stat && stat.kind === 'file') return path
71 }
72 return ''
73}
74
75/** The Python the method scripts run with: LongPi's own environment first, else one of the system's (3.9+). */
76export async function findPython(io: Io, home: string, configured: string): Promise<string> {
77 const venvs = [configured, join(home, '.longpi', '.venv', 'bin', 'python')]
78 const found = await firstFile(io, venvs)
79 if (found) return found
80 for (const name of ['python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3']) {
81 const probe = await io.run([name, '-c', 'import sys;print(sys.version_info[:2] >= (3, 9))'], { timeoutMs: 10_000 }).catch(() => null)
82 if (probe && probe.exitCode === 0 && probe.stdout.trim() === 'True') return name
83 }
84 return ''
85}
86
87async function versionAt(io: Io, dir: string): Promise<string | null> {
88 const stat = await io.stat(join(dir, 'catalog.json')).catch(() => null)
89 if (!stat || stat.kind !== 'file') return null
90 return (await io.read(join(dir, 'VERSION')).catch(() => '0')).trim() || '0'
91}
92
93/**
94 * The method library: one the person named (config, LONGEVITY_SKILLS_HOME) as they named it; otherwise the copy that
95 * ships in the mod, or the weekly update in ~/.longpi when that one is newer.
96 */
97export async function findSkillsHome(io: Io, configured: string, pluginRoot = '', home = ''): Promise<string> {
98 const explicit = [configured, host().env.LONGEVITY_SKILLS_HOME ?? ''].map((dir) => dir.trim()).filter(Boolean)
99 for (const dir of explicit) if (await versionAt(io, dir)) return dir
100 const bundled = pluginRoot ? join(pluginRoot, 'library') : ''
101 const updated = home ? join(home, '.longpi', 'longevity-skills') : ''
102 const vb = bundled ? await versionAt(io, bundled) : null
103 const vu = updated ? await versionAt(io, updated) : null
104 // The weekly copy is pulled from the library's main branch: at the same version it is at least as new.
105 if (vu && (!vb || !newer(vb, vu))) return updated
106 if (vb) return bundled
107 for (const dir of skillsHomeCandidates(configured)) if (await versionAt(io, dir)) return dir
108 return ''
109}
110
111/** Load the method library's tables and every method's instructions into the copy, once. */
112async function loadLibrary(io: Io, home: string, python: string): Promise<void> {
113 for (const file of ['catalog.json', 'intents.json', 'README.md', 'VERSION', '.git/HEAD']) {
114 vfs.addRoot({ path: join(home, file), once: true })
115 }
116 vfs.addRoot({ path: join(home, 'data'), once: true })
117 vfs.addRoot({ path: join(home, 'skills', 'longevity-evidence', 'data', 'claims.jsonl'), once: true, maxBytes: 4 * 1024 * 1024 })
118 const bulk = python ? await vfs.bulkLoad(io, python, join(home, 'skills'), '^(SKILL\\.md|skill\\.json)$') : false
119 if (!bulk) vfs.addRoot({ path: join(home, 'skills'), once: true, maxBytes: 512 * 1024 })
120 const rev = await io.run(['git', '-C', home, 'rev-parse', '--short', 'HEAD'], { timeoutMs: 10_000 }).catch(() => null)
121 setRevision(home, rev && rev.exitCode === 0 ? rev.stdout.trim() : '')
122}
123
124export type BootOptions = {
125 io: Io
126 pluginRoot: string
127 home: string
128 env: Record<string, string>
129 platform: string
130 /** The engine's clock, for the core's timers. */
131 schedule: (ms: number, fn: () => void) => () => void
132 /** The person's own settings over the defaults. */
133 config: Partial<Config>
134}
135
136export async function boot(options: BootOptions): Promise<Runtime> {
137 installHost({ io: options.io, pluginRoot: options.pluginRoot, home: options.home, env: options.env, platform: options.platform, nowMs: Date.now() })
138 installGlobals()
139 // A timer that fires while an operation holds the copy runs inside it (the operation writes its changes
140 // back when it ends); otherwise it gets an operation of its own. Never queued behind the one running,
141 // which may be waiting for this very timer.
142 installScheduler((ms, fn) => options.schedule(ms, () => {
143 if (vfs.busy) {
144 try {
145 fn()
146 } catch (error) {
147 options.io.log(`timer: ${error instanceof Error ? error.message : String(error)}`)
148 }
149 return
150 }
151 void op(async () => {
152 const out = fn() as unknown
153 if (out && typeof (out as Promise<unknown>).then === 'function') await out
154 }).catch((error: unknown) => options.io.log(`timer: ${error instanceof Error ? error.message : String(error)}`))
155 }))
156 setCwd(() => options.home)
157
158 const config = configFrom(options.config)
159 const rootDir = resolveRootDir(config.dataDir)
160 setRecordDir(() => resolveDataDir(config.dataDir))
161 const skillsHome = await findSkillsHome(options.io, config.skillsHome, options.pluginRoot, options.home)
162 const python = await findPython(options.io, options.home, config.skillPython)
163 if (python && !config.skillPython) config.skillPython = python
164 if (skillsHome && !config.skillsHome) config.skillsHome = skillsHome
165
166 // Every operation rescans LongPi's folder for changes; the Python environment, the weekly copy of the library
167 // (read once as the library's own root), the installer's tools and the notifier app are not the person's data
168 // and were 1,300 of its 1,400 folders.
169 vfs.addRoot({ path: rootDir, skipDirs: ['.venv', 'longevity-skills', 'bin', 'notifier'] })
170 vfs.addRoot({ path: join(options.pluginRoot, 'data'), once: true })
171 vfs.addRoot({ path: join(options.pluginRoot, 'skills'), once: true })
172 vfs.addRoot({ path: join(options.home, 'longpi', 'analyses'), skipDirs: [] })
173 if (skillsHome) await loadLibrary(options.io, skillsHome, python)
174
175 const ctx = new HostContext()
176 const app = await op(() => apply(ctx, config))
177 registerRecordTools(ctx, () => config.dataDir, () => app.invalidate(), () => config.skillPython || python)
178 registerGame(ctx, () => rootDir, () => localDay(new Date()))
179 current = { ctx, app, config, rootDir, skillsHome, python, booted: Date.now() }
180 if (options.platform === 'darwin') void ensureNotifier(options.io, rootDir)
181 return current
182}
183
184/**
185 * macOS names a notification's sender after the app that posts it: a tiny LongPi.app, compiled once from the
186 * script the core writes, makes reminders read 「LongPi」 rather than 「脚本编辑器」.
187 */
188async function ensureNotifier(io: Io, rootDir: string): Promise<void> {
189 const dir = join(rootDir, 'notifier')
190 const app = join(dir, 'LongPi.app')
191 if (await io.stat(join(app, 'Contents', 'MacOS', 'applet')).catch(() => null)) return
192 if (!(await io.stat(join(dir, 'notify.applescript')).catch(() => null))) return
193 await io.run(['osacompile', '-o', app, join(dir, 'notify.applescript')], { timeoutMs: 30_000 }).catch(() => undefined)
194}
195
196/** Re-read the method library after setup installed or updated it. */
197export async function reloadLibrary(): Promise<Runtime | null> {
198 const rt = current
199 if (!rt) return null
200 const io = host().io
201 const skillsHome = await findSkillsHome(io, '', host().pluginRoot, host().home)
202 const python = await findPython(io, host().home, '')
203 if (skillsHome) {
204 await loadLibrary(io, skillsHome, python)
205 await op(async () => undefined)
206 rt.config.skillsHome = skillsHome
207 }
208 if (python) rt.config.skillPython = python
209 rt.skillsHome = skillsHome
210 rt.python = python
211 rt.app.invalidate()
212 return rt
213}
214
215// --- tools ---------------------------------------------------------------------------------------------
216
217function schemaOf(spec: ParamSpec): Record<string, unknown> {
218 const out: Record<string, unknown> = {}
219 for (const [key, value] of Object.entries(spec)) {
220 if (key === 'required' && typeof value === 'boolean') continue
221 if (key === 'properties' && value && typeof value === 'object') {
222 const props = value as Record<string, ParamSpec>
223 out.properties = Object.fromEntries(Object.entries(props).map(([name, child]) => [name, schemaOf(child)]))
224 const required = Object.entries(props).filter(([, child]) => child.required === true).map(([name]) => name)
225 if (required.length > 0) out.required = required
226 continue
227 }
228 if (key === 'items' && value && typeof value === 'object') {
229 out.items = schemaOf(value as ParamSpec)
230 continue
231 }
232 out[key] = value
233 }
234 return out
235}
236
237export function inputSchemaOf(def: ToolDef): Record<string, unknown> {
238 return schemaOf({ type: 'object', properties: def.parameters ?? {} })
239}
240
241export const TOOL_PREFIX = 'mcp__longpi__'
242
243export function toolSpecs(rt: Runtime): Array<{ name: string; description: string; inputSchema: Record<string, unknown> }> {
244 return [...rt.ctx.toolDefs.values()].map((def) => ({ name: def.name, description: def.description, inputSchema: inputSchemaOf(def) }))
245}
246
247export type ToolOutcome = { text: string; value: unknown; isError: boolean; denied?: string }
248
249function textOf(def: ToolDef, args: unknown, value: unknown): string {
250 try {
251 const rendered = def.output?.render?.(args, value)
252 if (Array.isArray(rendered)) return rendered.map((part) => part.text).join('\n')
253 } catch {
254 // fall through to JSON
255 }
256 return typeof value === 'string' ? value : JSON.stringify(value, null, 2)
257}
258
259/**
260 * Run one core tool as the model asked: the core's own approval hooks decide first ('ask' becomes the
261 * person's yes or no in a Claude Code dialog), then the tool, then the core's after-hooks (events, read-backs).
262 */
263export async function runTool(rt: Runtime, name: string, args: Record<string, unknown>, ask: (reason: string) => Promise<boolean>, session: string, callId: string): Promise<ToolOutcome> {
264 const def = rt.ctx.toolDefs.get(name)
265 if (!def) return { text: `LongPi has no tool ${name}.`, value: null, isError: true }
266 const exec = { name, arguments: args, agent: { id: session, sessionId: session, session: { id: session } }, id: callId }
267 // A tool given a file outside LongPi's own folders (a CSV, a member file, an analysis export) reads it
268 // from the copy: load it first.
269 for (const [key, value] of Object.entries(args)) {
270 if (typeof value === 'string' && /(^|_)(path|dir|file)$/.test(key) && value.startsWith('/')) {
271 await vfs.ensure(host().io, value, { recursive: true, maxBytes: 4 * 1024 * 1024 }).catch(() => undefined)
272 }
273 }
274 const decision = await op(() => rt.ctx.decide(exec))
275 if (decision.kind === 'deny') return { text: decision.reason, value: null, isError: true, denied: decision.reason }
276 if (decision.kind === 'ask') {
277 const yes = await ask(decision.reason)
278 if (!yes) return { text: '用户没有同意。', value: null, isError: true, denied: '用户没有同意。' }
279 }
280 try {
281 const value = await op(async () => {
282 const out = await def.execute(args, exec)
283 await rt.ctx.afterExecute(exec, { isError: false, value: out })
284 return out
285 })
286 return { text: textOf(def, args, value), value, isError: false }
287 } catch (error) {
288 const message = error instanceof Error ? error.message : String(error)
289 return { text: `LongPi tool ${name} failed: ${message}`, value: null, isError: true }
290 }
291}
292
293/** Call one of the core's routes the way the health page did, inside an operation. */
294export async function route<T = unknown>(rt: Runtime, method: string, path: string, body?: unknown): Promise<{ status: number; json: T; text: string }> {
295 const answer = await op(() => rt.ctx.call(method, path, body))
296 return { status: answer.status, json: answer.json as T, text: answer.json === null ? answer.text : '' }
297}
298
299/** A route answer that does not need the copy refreshed first (a second read in the same pass). */
300export function rootDirOf(rt: Runtime): string {
301 return rt.rootDir
302}
303hooks/app/setup.ts 124 lines1// What LongPi needs besides the mod, set up by itself. The method library ships inside the mod (library/), and
2// the weekly update puts a newer copy in ~/.longpi/longevity-skills. Most method scripts need only Python 3.9+;
3// a dozen use numpy/pandas/scipy, which go into ~/.longpi/.venv in the background, through uv (fetched into
4// ~/.longpi/bin when the machine has none). Nothing touches the person's shell profile.
5
6import type { Io } from '../sys/host.ts'
7import { join } from '../sys/path.ts'
8
9export const LIBRARY_URL = 'https://github.com/zwbao/longevity-skills.git'
10const PACKAGES = ['numpy>=1.21', 'pandas>=1.5', 'scipy>=1.7', 'openpyxl>=3.1']
11
12export type Say = (line: string) => void
13
14async function exists(io: Io, path: string): Promise<boolean> {
15 return Boolean(await io.stat(path).catch(() => null))
16}
17
18export async function hasPackages(io: Io, python: string): Promise<boolean> {
19 if (!python) return false
20 const probe = await io.run([python, '-c', 'import sys, numpy, pandas, scipy; sys.exit(0 if sys.version_info >= (3, 9) else 1)'], { timeoutMs: 60_000 }).catch(() => null)
21 return probe?.exitCode === 0
22}
23
24async function onPath(io: Io, name: string): Promise<string> {
25 const out = await io.run(['/usr/bin/env', 'which', name], { timeoutMs: 10_000 }).catch(() => null)
26 return out?.exitCode === 0 ? out.stdout.trim().split('\n')[0] ?? '' : ''
27}
28
29/** uv on PATH, or the copy LongPi keeps; fetched from its GitHub release when neither is there. */
30async function findUv(io: Io, home: string, say: Say): Promise<string> {
31 const found = await onPath(io, 'uv')
32 if (found) return found
33 const own = join(home, '.longpi', 'bin', 'uv')
34 if (await exists(io, own)) return own
35 const os = (await io.run(['uname', '-s']).catch(() => null))?.stdout.trim()
36 const arch = (await io.run(['uname', '-m']).catch(() => null))?.stdout.trim()
37 const target = os === 'Darwin'
38 ? arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'
39 : arch === 'aarch64' || arch === 'arm64' ? 'aarch64-unknown-linux-gnu' : 'x86_64-unknown-linux-gnu'
40 say('正在下载 Python 工具 uv…')
41 const dir = join(home, '.longpi', 'bin')
42 const archive = join(dir, 'uv.tar.gz')
43 await io.run(['mkdir', '-p', dir]).catch(() => undefined)
44 const got = await io.run(['curl', '-fsSL', '--retry', '2', '-o', archive, `https://github.com/astral-sh/uv/releases/latest/download/uv-${target}.tar.gz`], { timeoutMs: 300_000 }).catch(() => null)
45 if (got?.exitCode !== 0) return ''
46 const unpacked = await io.run(['tar', '-xzf', archive, '-C', dir, '--strip-components', '1'], { timeoutMs: 60_000 }).catch(() => null)
47 await io.run(['rm', '-f', archive]).catch(() => undefined)
48 return unpacked?.exitCode === 0 && (await exists(io, own)) ? own : ''
49}
50
51/** A Python with the packages the heavier method scripts use, in ~/.longpi/.venv. */
52export async function ensurePython(io: Io, home: string, current: string, say: Say): Promise<{ ok: boolean; python: string; lines: string[] }> {
53 const lines: string[] = []
54 const note = (line: string) => {
55 lines.push(line)
56 say(line)
57 }
58 if (await hasPackages(io, current)) return { ok: true, python: current, lines }
59 const venv = join(home, '.longpi', '.venv')
60 const python = join(venv, 'bin', 'python')
61 if (await hasPackages(io, python)) return { ok: true, python, lines }
62 note('正在准备计算环境(一次,几分钟)…')
63 const uv = await findUv(io, home, note)
64 if (uv) {
65 if (!(await exists(io, python))) {
66 const made = await io.run([uv, 'venv', '--quiet', '--python', '3.12', venv], { timeoutMs: 600_000 }).catch(() => null)
67 if (made?.exitCode !== 0) {
68 note('没能准备 Python 3.12。')
69 return { ok: false, python: '', lines }
70 }
71 }
72 const installed = await io.run([uv, 'pip', 'install', '--quiet', '--python', python, ...PACKAGES], { timeoutMs: 600_000 }).catch(() => null)
73 if (installed?.exitCode === 0) {
74 note('计算环境已就绪。')
75 return { ok: true, python, lines }
76 }
77 note(`计算用的软件包没有装好:${installed?.stderr.trim().slice(0, 160) || '网络问题'}`)
78 return { ok: false, python: '', lines }
79 }
80 // No uv and no network for it: the system's own Python, when it is new enough to make a venv.
81 for (const base of ['python3.13', 'python3.12', 'python3.11', 'python3.10']) {
82 const made = await io.run([base, '-m', 'venv', venv], { timeoutMs: 300_000 }).catch(() => null)
83 if (made?.exitCode !== 0) continue
84 const installed = await io.run([python, '-m', 'pip', 'install', '--quiet', ...PACKAGES], { timeoutMs: 600_000 }).catch(() => null)
85 if (installed?.exitCode === 0) {
86 note('计算环境已就绪。')
87 return { ok: true, python, lines }
88 }
89 }
90 note('没能准备完整的计算环境:大多数方法照常能算,少数要用 numpy 的方法暂时算不了。联网后输入 /longpi setup 再试。')
91 return { ok: false, python: '', lines }
92}
93
94/** The weekly library update: a fresh copy of the method library in ~/.longpi/longevity-skills. */
95export async function updateLibrary(io: Io, home: string, say: Say): Promise<{ ok: boolean; changed: boolean; line: string }> {
96 const library = join(home, '.longpi', 'longevity-skills')
97 if (await exists(io, join(library, '.git'))) {
98 const before = (await io.run(['git', '-C', library, 'rev-parse', 'HEAD']).catch(() => null))?.stdout.trim()
99 const pull = await io.run(['git', '-C', library, 'pull', '--ff-only', '--quiet'], { timeoutMs: 300_000 }).catch(() => null)
100 const after = (await io.run(['git', '-C', library, 'rev-parse', 'HEAD']).catch(() => null))?.stdout.trim()
101 if (pull?.exitCode !== 0) return { ok: false, changed: false, line: '方法库这次没有更新成功,先用现有版本。' }
102 return { ok: true, changed: before !== after, line: before !== after ? '方法库已更新到最新。' : '方法库已是最新。' }
103 }
104 say('正在下载最新的方法库…')
105 await io.run(['mkdir', '-p', join(home, '.longpi')]).catch(() => undefined)
106 const clone = await io.run(['git', 'clone', '--depth', '1', '--quiet', LIBRARY_URL, library], { timeoutMs: 600_000 }).catch(() => null)
107 if (clone?.exitCode !== 0) return { ok: false, changed: false, line: '最新的方法库没有下载成功,先用 LongPi 自带的版本。' }
108 return { ok: true, changed: true, line: '已下载最新的方法库。' }
109}
110
111/** A library version (2026.40.0) as numbers, for picking the newer of two copies. */
112export function versionKey(text: string): number[] {
113 return text.trim().split('.').map((part) => Number(part) || 0)
114}
115
116export function newer(a: string, b: string): boolean {
117 const x = versionKey(a)
118 const y = versionKey(b)
119 for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
120 if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0)
121 }
122 return false
123}
124hooks/app/literature.ts 273 lines1// The weekly literature scout. Once a week it asks PubMed for the past week's aging papers that are trials,
2// meta-analyses, Mendelian randomization, large cohorts or lifespan studies, ranks them by design and journal,
3// and has Claude write the best few as 长寿图鉴 research cards under the Codex copy rules
4// (docs/codex-design.md §4, §6). The citation (authors, journal, year, DOI, PMID) always comes from PubMed,
5// never from the model; a card is gold only when PubMed calls the paper a randomized controlled trial.
6
7import type { Io } from '../sys/host.ts'
8import { join } from '../sys/path.ts'
9import type { LiteratureCard, LiteratureWeek } from '../core/engage/literature.ts'
10
11const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils'
12const TOPIC = '(aging[tiab] OR ageing[tiab] OR longevity[tiab] OR lifespan[tiab] OR healthspan[tiab] OR "biological age"[tiab] OR "epigenetic clock"[tiab] OR senolytic*[tiab] OR "cellular senescence"[tiab] OR frailty[tiab] OR centenarian*[tiab])'
13const DESIGN = '(randomized controlled trial[pt] OR meta-analysis[pt] OR systematic review[pt] OR randomized[tiab] OR "mendelian randomization"[tiab] OR "prospective cohort"[tiab] OR "extends lifespan"[tiab] OR "extended lifespan"[tiab] OR "lifespan extension"[tiab])'
14
15const STRONG_JOURNALS = [
16 'n engl j med', 'lancet', 'jama', 'bmj', 'nature', 'science', 'cell', 'nat med', 'nature medicine', 'nat aging', 'nature aging',
17 'cell metab', 'cell metabolism', 'lancet healthy longev', 'nat commun', 'nature communications', 'aging cell', 'geroscience',
18 'elife', 'proc natl acad sci', 'j gerontol', 'age ageing', 'circulation', 'eur heart j', 'diabetes care', 'ann intern med',
19 'plos med', 'nat metab', 'nature metabolism', 'sci transl med', 'cell rep', 'aging (albany ny)', 'nat genet',
20]
21
22const CHAPTERS = ['clock', 'organ', 'brain', 'immune', 'repair', 'tissue', 'gene', 'span'] as const
23const SPECIES = ['mouse', 'c_elegans', 'drosophila', 'killifish', 'zebrafish', 'planarian', 'butterfly', 'naked_mole_rat', 'bowhead_whale'] as const
24
25type Summary = { pmid: string; title: string; journal: string; journalShort: string; year: number | null; authors: string[]; doi: string; pubtypes: string[] }
26
27export function isoWeek(date: Date): string {
28 const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
29 const day = d.getUTCDay() || 7
30 d.setUTCDate(d.getUTCDate() + 4 - day)
31 const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
32 const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7)
33 return `${d.getUTCFullYear()}-W${String(week).padStart(2, '0')}`
34}
35
36function query(params: Record<string, string>): string {
37 return Object.entries(params).map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join('&')
38}
39
40async function getJson(io: Io, url: string): Promise<Record<string, unknown> | null> {
41 try {
42 const res = await io.fetch(url, { method: 'GET', headers: { 'user-agent': 'LongPi-mod (longevity coach; contact via github.com/zwbao)' } })
43 return res.ok ? (JSON.parse(res.text) as Record<string, unknown>) : null
44 } catch {
45 return null
46 }
47}
48
49async function search(io: Io, days: number): Promise<string[]> {
50 const url = `${EUTILS}/esearch.fcgi?${query({ db: 'pubmed', retmode: 'json', retmax: '80', datetype: 'edat', reldate: String(days), sort: 'relevance', term: `${TOPIC} AND ${DESIGN}` })}`
51 const json = await getJson(io, url)
52 const ids = (json?.esearchresult as { idlist?: unknown } | undefined)?.idlist
53 return Array.isArray(ids) ? ids.map(String) : []
54}
55
56async function summaries(io: Io, ids: readonly string[]): Promise<Summary[]> {
57 if (ids.length === 0) return []
58 const json = await getJson(io, `${EUTILS}/esummary.fcgi?${query({ db: 'pubmed', retmode: 'json', id: ids.join(',') })}`)
59 const result = (json?.result ?? {}) as Record<string, unknown>
60 const out: Summary[] = []
61 for (const id of ids) {
62 const row = result[id] as Record<string, unknown> | undefined
63 if (!row) continue
64 const ids2 = Array.isArray(row.articleids) ? (row.articleids as Array<{ idtype?: string; value?: string }>) : []
65 const year = Number(String(row.pubdate ?? '').slice(0, 4))
66 out.push({
67 pmid: id,
68 title: String(row.title ?? '').replace(/<[^>]+>/g, ''),
69 journal: String(row.fulljournalname ?? row.source ?? ''),
70 journalShort: String(row.source ?? ''),
71 year: Number.isFinite(year) && year > 1900 ? year : null,
72 authors: (Array.isArray(row.authors) ? (row.authors as Array<{ name?: string }>) : []).map((a) => String(a.name ?? '')).filter(Boolean),
73 doi: ids2.find((a) => a.idtype === 'doi')?.value ?? '',
74 pubtypes: Array.isArray(row.pubtype) ? (row.pubtype as unknown[]).map(String) : [],
75 })
76 }
77 return out
78}
79
80async function abstracts(io: Io, ids: readonly string[]): Promise<Map<string, string>> {
81 const out = new Map<string, string>()
82 if (ids.length === 0) return out
83 try {
84 const res = await io.fetch(`${EUTILS}/efetch.fcgi?${query({ db: 'pubmed', rettype: 'abstract', retmode: 'xml', id: ids.join(',') })}`, { method: 'GET' })
85 if (!res.ok) return out
86 for (const article of res.text.split('<PubmedArticle>').slice(1)) {
87 const pmid = /<PMID[^>]*>(\d+)<\/PMID>/.exec(article)?.[1]
88 if (!pmid) continue
89 const parts = [...article.matchAll(/<AbstractText([^>]*)>([\s\S]*?)<\/AbstractText>/g)].map((m) => {
90 const label = /Label="([^"]+)"/.exec(m[1] ?? '')?.[1]
91 return `${label ? `${label}: ` : ''}${(m[2] ?? '').replace(/<[^>]+>/g, '')}`
92 })
93 out.set(pmid, parts.join(' ').replace(/\s+/g, ' ').trim())
94 }
95 } catch {
96 // abstracts are a help, not a requirement
97 }
98 return out
99}
100
101/** Design and journal first: a trial or a meta-analysis in a strong journal leads. */
102export function rank(row: Summary): number {
103 const types = row.pubtypes.join(' ').toLowerCase()
104 const title = row.title.toLowerCase()
105 let score = 0
106 if (types.includes('randomized controlled trial')) score += 6
107 if (types.includes('meta-analysis')) score += 5
108 if (types.includes('systematic review')) score += 3
109 if (/mendelian randomi[sz]ation/.test(title)) score += 3
110 if (/lifespan|life span|longevity/.test(title)) score += 2
111 if (/cohort|participants|adults|older/.test(title)) score += 1
112 if (types.includes('review') && !types.includes('systematic')) score -= 3
113 if (/protocol|study design|rationale/.test(title)) score -= 5
114 const journal = `${row.journal} ${row.journalShort}`.toLowerCase()
115 if (STRONG_JOURNALS.some((name) => journal === name || journal.startsWith(`${name} `) || journal.includes(` ${name}`) || row.journalShort.toLowerCase() === name)) score += 4
116 return score
117}
118
119const SYSTEM = `你是长寿研究编辑,为「长寿图鉴」写研究卡。读者是普通中国成年人(比如一位 68 岁的退休教师)。
120规则:
1211. 客观转述:谁、做了什么、发现了什么。不在句尾加否定或免责。
1222. line_zh 是一句话,不超过 45 个字(这是硬性上限,超过的卡不能上架)。关键限定写进 line_zh:物种、研究类型(随机试验、队列、事后分析)、自报还是测量。关联研究写成「……的人更常见/偏大」,不写成因果。
1233. 标题 title_zh 不超过 12 个字,可以有梗,但不误导,不只挑好听的一半。
1244. about_zh 两三句白话,不超过 120 个字:怎么做的、看到了什么、一个具体数字(只用摘要里写明的数字,并说明是什么尺度)。不出现没解释的英文缩写。
1255. 阴性结果照写。
1266. 不写「逆龄」「年轻了」「逆转」「保证」「治愈」,不推荐补剂、药物、剂量或任何产品。
1277. tier 按主结论的证据:cell(细胞实验)、animal(动物实验)、human(人群研究、队列、孟德尔随机化,或随机试验的事后/次要分析)、trial(人体随机试验的主要结局)。tier_reason_zh 一句话说明为什么。
1288. chapter 从 clock(身体的钟)、organ(器官与代谢)、brain(大脑与心理)、immune(免疫与炎症)、repair(细胞的维修)、tissue(组织与再生)、gene(生殖与基因)、span(寿命与节律)里选一个。
1299. species 只能从 human, cell_line, mouse, c_elegans, drosophila, killifish, zebrafish, planarian, butterfly, naked_mole_rat, bowhead_whale 里选。
130只回答一个 JSON 对象,不要代码块,不要别的文字。`
131
132function promptOf(rows: ReadonlyArray<Summary & { abstract: string }>, max: number): string {
133 const items = rows.map((row, i) => [
134 `#${i + 1} PMID ${row.pmid}`,
135 `题目:${row.title}`,
136 `期刊:${row.journal}(${row.year ?? ''})`,
137 `文献类型:${row.pubtypes.join(', ')}`,
138 `摘要:${row.abstract.slice(0, 1800) || '(无摘要)'}`,
139 ].join('\n')).join('\n\n')
140 return `下面是过去一周新发表的衰老和长寿相关论文。挑出最值得普通人知道的 2 到 ${max} 篇:优先人体随机试验、荟萃分析和大型队列,方法扎实、结论清楚、和健康长寿直接相关;动物或细胞研究只有在发现很重要时才选,最多 1 篇。方法弱、只是综述、只是方案、或者和健康长寿关系不大的不要。通常总能挑出两篇;只有候选里真的一篇都不够格时,才返回空列表。
141
142${items}
143
144回答格式:
145{"cards":[{"pmid":"…","title_zh":"…","line_zh":"…","about_zh":"…","tier":"cell|animal|human|trial","tier_reason_zh":"…","chapter":"…","species":["human"]}]}`
146}
147
148function firstJson(text: string): Record<string, unknown> | null {
149 const start = text.indexOf('{')
150 const end = text.lastIndexOf('}')
151 if (start < 0 || end <= start) return null
152 try {
153 return JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
154 } catch {
155 return null
156 }
157}
158
159function cells(text: string): number {
160 let n = 0
161 for (const ch of text) n += /[\u0000-ɏ\s]/.test(ch) ? 0.5 : 1
162 return n
163}
164
165// Codex copy rule 11: no 逆龄 / 年轻了 / 逆转你的 / 保证有效 / 治愈 and nothing to buy. Describing a trial's dose is fine.
166const BANNED = /逆龄|年轻了|逆转你的|保证有效|治愈|购买|推荐服用|建议服用/
167
168function hashSeed(text: string): number {
169 let h = 2166136261
170 for (const ch of text) h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0
171 return h
172}
173
174/** `finished`: the week was looked at to the end (a failed search or model call is not finished, and is retried). */
175export type ScoutResult = { ok: boolean; finished: boolean; week: string; cards: number; candidates: number; reason: string }
176
177/** One weekly run: search, rank, write, check, save. Never throws; says why when nothing came of it. */
178export async function scoutLiterature(io: Io, root: string, now: Date, opts: { days?: number; max?: number } = {}): Promise<ScoutResult> {
179 const week = isoWeek(now)
180 const days = opts.days ?? 7
181 const max = opts.max ?? 4
182 const ids = await search(io, days)
183 if (ids.length === 0) return { ok: false, finished: false, week, cards: 0, candidates: 0, reason: '这周没有检索到符合条件的新论文,或者 PubMed 暂时连不上。' }
184 const all = await summaries(io, ids)
185 const ranked = all.filter((row) => row.title && rank(row) > 0).sort((a, b) => rank(b) - rank(a)).slice(0, 18)
186 const texts = await abstracts(io, ranked.map((row) => row.pmid))
187 const withText = ranked.map((row) => ({ ...row, abstract: texts.get(row.pmid) ?? '' })).filter((row) => row.abstract.length > 200)
188 if (withText.length === 0) return { ok: false, finished: true, week, cards: 0, candidates: all.length, reason: '这周的候选论文都没有摘要,跳过。' }
189 const answer = await io.complete(promptOf(withText, max), { system: SYSTEM, maxTokens: 4000, model: 'sonnet', timeoutMs: 300_000 })
190 if (!answer.ok) return { ok: false, finished: false, week, cards: 0, candidates: all.length, reason: `没有写成研究卡(${answer.reason})。` }
191 const parsed = firstJson(answer.text)
192 const rows = Array.isArray(parsed?.cards) ? (parsed?.cards as Array<Record<string, unknown>>) : []
193 const byId = new Map(withText.map((row) => [row.pmid, row]))
194 const cards: LiteratureCard[] = []
195 const rejected: Array<{ pmid: string; why: string }> = []
196 const tooLong: Array<Record<string, unknown>> = []
197 const check = (row: Record<string, unknown>, i: number, repairing: boolean) => {
198 const pmid = String(row.pmid ?? '').replace(/\D/g, '')
199 const source = byId.get(pmid)
200 if (!source) { rejected.push({ pmid, why: 'not among the candidates' }); return }
201 if (cards.some((card) => card.pmid === pmid)) return
202 const title = String(row.title_zh ?? '').trim()
203 const line = String(row.line_zh ?? '').trim()
204 const about = String(row.about_zh ?? '').trim()
205 const banned = BANNED.exec(`${title}${line}${about}`)
206 const long = cells(title) > 14 || cells(line) > 50 || cells(about) > 150
207 if (long && !repairing) { tooLong.push(row); return }
208 const why = !title || !line || !about ? 'a field is empty' : long ? `too long (title ${cells(title)}, line ${cells(line)}, about ${cells(about)})` : banned ? `uses 「${banned[0]}」` : ''
209 if (why) { rejected.push({ pmid, why }); return }
210 const isTrial = source.pubtypes.some((type) => /randomized controlled trial/i.test(type))
211 let tier = String(row.tier ?? '') as LiteratureCard['tier']
212 if (!['cell', 'animal', 'human', 'trial'].includes(tier)) { rejected.push({ pmid, why: `tier ${tier}` }); return }
213 if (tier === 'trial' && !isTrial) tier = 'human'
214 const chapter = CHAPTERS.includes(String(row.chapter) as (typeof CHAPTERS)[number]) ? String(row.chapter) : 'span'
215 const species = (Array.isArray(row.species) ? row.species.map(String) : ['human']).filter((key) => key === 'human' || key === 'cell_line' || (SPECIES as readonly string[]).includes(key))
216 cards.push({
217 id: `lit-${source.pmid}`,
218 skill: '',
219 no: `${week.slice(2).replace('-W', 'W')}-${i + 1}`,
220 chapter,
221 tier,
222 tier_reason_zh: String(row.tier_reason_zh ?? '').trim() || '按论文的研究类型定的颜色。',
223 title_zh: title,
224 line_zh: line,
225 about_zh: about,
226 species: species.length > 0 ? species : ['human'],
227 meet: species.filter((key) => (SPECIES as readonly string[]).includes(key)),
228 source: {
229 first_author: (source.authors[0] ?? '').split(' ')[0] ?? '',
230 et_al: source.authors.length > 1,
231 journal: source.journalShort || source.journal,
232 year: source.year,
233 ...(source.doi ? { doi: source.doi } : {}),
234 preprint: false,
235 coi_zh: null,
236 },
237 research_assay: false,
238 feature_zh: null,
239 art: { motif: chapter, seed: hashSeed(source.pmid) },
240 week,
241 added: now.toISOString(),
242 pmid: source.pmid,
243 pubtypes: source.pubtypes,
244 })
245 }
246 rows.forEach((row, i) => check(row, i, false))
247 // One pass to shorten what ran long, keeping the qualifiers; whatever is still too long stays off the shelf.
248 if (tooLong.length > 0) {
249 const ask = `下面几张研究卡太长了。把每张的 title_zh 缩到 12 个字以内、line_zh 缩到 45 个字以内、about_zh 缩到 120 个字以内。保留关键限定(物种、研究类型、关联还是因果)和数字,不加新内容。只回答 JSON:{"cards":[{"pmid":"…","title_zh":"…","line_zh":"…","about_zh":"…"}]}\n\n${JSON.stringify(tooLong.map((row) => ({ pmid: row.pmid, title_zh: row.title_zh, line_zh: row.line_zh, about_zh: row.about_zh })), null, 1)}`
250 const shorter = await io.complete(ask, { system: SYSTEM, maxTokens: 3000, model: 'sonnet', timeoutMs: 300_000 })
251 const fixed = shorter.ok ? firstJson(shorter.text) : null
252 const fixedRows = Array.isArray(fixed?.cards) ? (fixed?.cards as Array<Record<string, unknown>>) : []
253 tooLong.forEach((row) => {
254 const pmid = String(row.pmid ?? '').replace(/\D/g, '')
255 const again = fixedRows.find((candidate) => String(candidate.pmid ?? '').replace(/\D/g, '') === pmid)
256 check({ ...row, ...(again ? { title_zh: again.title_zh, line_zh: again.line_zh, about_zh: again.about_zh } : {}) }, rows.indexOf(row), true)
257 })
258 }
259 // A week already on the shelf keeps its cards: a later run only adds papers it did not have.
260 const file = join(root, 'literature', `${week}.json`)
261 const before = await io.read(file).then((text) => JSON.parse(text) as LiteratureWeek).catch(() => null)
262 const kept = Array.isArray(before?.cards) ? before.cards : []
263 const merged = [...kept, ...cards.filter((card) => !kept.some((old) => old.pmid === card.pmid))]
264 const record: LiteratureWeek & { rejected: typeof rejected; answered: number } = { week, at: now.toISOString(), candidates: all.length, cards: merged, rejected, answered: rows.length }
265 if (merged.length > 0 || !before) await io.write(file, `${JSON.stringify(record, null, 1)}\n`)
266 // Nothing passed the checks although the model answered: try again another day rather than lose the week.
267 // An empty answer is retried another day rather than closing the week with nothing on the shelf.
268 const finished = cards.length > 0 || kept.length > 0
269 // What is new on the shelf (a second run in the same week may find the same papers).
270 const added = merged.length - kept.length
271 return { ok: cards.length > 0, finished, week, cards: added, candidates: all.length, reason: added > 0 ? '' : cards.length > 0 ? '这些论文已经在图书馆里了。' : rows.length > 0 ? `写出的 ${rows.length} 张卡都没有通过检查(${rejected.map((row) => row.why).join(';')})。` : '这周的论文都没有达到上架的标准。' }
272}
273hooks/app/brief.ts 48 lines1// What Claude knows about being Pi, LongPi's longevity coach, inside Claude Code. A short brief rides every
2// request; Pi's full persona and the orchestrator rules (the plugin's own, reviewed over many rounds) join
3// once the conversation is about health, with the LongPi snapshot attached to each health turn.
4
5import { orchestratorPrompt } from '../core/agents/orchestrator.ts'
6import type { MountState } from '../core/mirobody.ts'
7
8export const SHORT_BRIEF = `# LongPi longevity coach (longpi plugin)
9You are also this person's longevity coach, Pi, through LongPi: their checkups, labs, wearables and home measurements on this computer, 170+ published aging methods (biological age, cardiovascular risk and more, each reproduced from its paper), intervention plans with check-ins and retests, and the 长寿图鉴 (Codex).
10Switch into coach mode when they talk about their health, a checkup or lab report, biological age, a biomarker, longevity, supplements or drugs for aging, sleep/exercise/diet as health, their plan or check-ins, or when a prompt arrives through /longpi. While they are coding, do not bring health up.
11In coach mode:
12- First call mcp__longpi__read_personal_situation (its answer carries the LongPi snapshot and the coaching rules). LongPi's tools are mcp__longpi__<name>; the rules name them without the prefix.
13- A checkup report, lab sheet or photo they give you: read it yourself (Read tool), then save every value with mcp__longpi__record_measurements exactly as printed (name, value, unit, the report date, the lab's reference range), exam conclusions (彩超, CT, X线, 心电图, 眼底…) as text values too, and pass report = the file's absolute path: LongPi checks the pages and lists anything not yet filed; file those as well. An Apple Health export (export.zip or 导出.zip): mcp__longpi__import_apple_health with its path. A consumer genetic-test report (a WeGene-style PDF, often thousands of pages): mcp__longpi__import_genetic_report with its path; do not read it in full yourself. Any other wearable or app export: turn it into the CSV that mcp__longpi__import_measurements_csv takes. Never invent a value.
14- The LongPi pane (/longpi) shows their health page, plan, check-ins and the Codex; point them there instead of pasting long tables.
15- In this conversation: offer the deep analysis at most once (again only if they ask, or a new report or export arrives). Keep 「这周就一件事」 the same across your replies until it is done or they change it. Read remembered preferences back only right after you save them.
16- Every message to the person is in their language, including the short notes between tool calls.
17- If the life-coach plugin is also present, LongPi answers anything about health records, biomarkers, biological age, plans and longevity research; life-coach keeps work rhythm and everyday habits.`
18
19/** Pi's persona and the orchestrator rules, with the host's words changed from DeepSeek Harness to Claude Code. */
20export function fullBrief(mount: MountState): string {
21 const rules = orchestratorPrompt(mount)
22 const swaps: Array<[RegExp | string, string]> = [
23 ['inside DeepSeek Harness', 'inside Claude Code'],
24 ['The product notice is accepted on the LongPi page (健康 in the sidebar).', 'The product notice is accepted in the LongPi pane (/longpi, the first step on 总览).'],
25 [
26 'When the stage is records, ask them to add a checkup report (PDF or photo) on the health page (健康 in the sidebar, then 档案). LongPi connects to the health data service on this computer by itself: never ask for an address, an email, a password or a token. Do not invent records.',
27 'When the stage is records, ask them for a checkup report: the path of a PDF or a photo on this computer, or the file dragged into the chat. Read it yourself (Read tool), then save every value with record_measurements as printed: the name, the value, the unit, the report date, and the lab\'s reference range when the sheet has one; add the LOINC code only when you are sure of it. Read the saved values back in one short list. Never ask for an address, an email, a password or a token. Do not invent records.',
28 ],
29 ['DeepSeek Harness asks them to approve once.', 'Claude Code asks them to approve once.'],
30 ['also ask the person to approve in DeepSeek Harness', 'also ask the person to approve in Claude Code'],
31 ['Explain that reminders are sent only while DeepSeek Harness is running.', 'Explain that reminders are sent only while Claude Code is running.'],
32 [/Mirobody is not mounted \([^)]*\)\. Do not invent records\./, 'The record is LongPi\'s own, kept on this computer: values from the reports the person gave you, their own measurements and wearable exports. Absence is not normal and not a negative genotype. Do not invent records.'],
33 ['tell them to switch to 我 on the health page first.', 'tell them to switch to 我 in the LongPi pane (/longpi 总览, 在看) first.'],
34 ['6. Write only Chinese to the person.', '6. Reply in the person\'s language (Chinese unless they write in another language).'],
35 [/the health page/g, 'the LongPi pane (/longpi)'],
36 [/the LongPi page/g, 'the LongPi pane'],
37 [/the settings page/g, 'the LongPi settings (/longpi 设置)'],
38 ]
39 let text = rules
40 for (const [from, to] of swaps) text = typeof from === 'string' ? text.split(from).join(to) : text.replace(from, to)
41 return [
42 '# LongPi coach mode (longpi plugin)',
43 'LongPi\'s tools are listed as mcp__longpi__<name>; the rules below name them without that prefix.',
44 'A message marked 【LongPi 健康页快照】 arrives with health turns: it comes from the plugin, not the person.',
45 text,
46 ].join('\n')
47}
48hooks/core/agents/orchestrator.ts 228 lines1// The chat side of AA §2.2: LongPi's persona and orchestrator rules only for agents in LongPi's own workspace
2// (健康对话, D5), LongPi's write tools hidden from every other agent, and at the first step of each turn there a
3// snapshot of what the LongPi page shows now, so the chat and the page tell the same story. Sessions in other
4// workspaces get nothing from LongPi, whatever they talk about; neither do sub-agents.
5
6import { randomUUID } from '../../sys/crypto.ts'
7import type { Context } from '../../sys/cordis.ts'
8import type { PageState } from '../contracts/surfaces.ts'
9import type { MountState } from '../mirobody.ts'
10import { insideWorkspace } from '../guard-scope.ts'
11import { rememberPersonText } from '../core/turn-text.ts'
12import { sessionKey } from '../plan-hold.ts'
13import { personaLines } from '../prompt.ts'
14import { PROMPT_SECTIONS } from '../version.ts'
15
16export const PLUGIN_SOURCE = 'dsh-plugin-longpi'
17/** Write tools a non-health agent does not see (D5). Read tools stay global. */
18export const WRITE_TOOLS = [
19 'save_personal_profile', 'save_intervention_plan', 'log_intervention_checkin', 'save_self_measurement', 'record_medication_statement',
20 'set_followup', 'send_followup_message', 'remember_for_me', 'log_care_visit', 'note_page_issue',
21 'forward_report', 'record_condition', 'log_life_event', 'run_deep_analysis', 'import_analysis', 'import_member_file',
22] as const
23
24export const ORCHESTRATOR_RULES = [
25 'Orchestrator rules (0.5.3):',
26 '1. At the start of a health turn a message marked LongPi 健康页快照 says what the LongPi page shows now, the most important fact, and what the person told LongPi before. It comes from the plugin, not from the person; never quote it as their words.',
27 '2. Not in an emergency turn (then only the emergency answer). Otherwise, if the snapshot has a line 最重要的事(必须先说)and the person asks anything about their health, open with that fact in ONE short sentence with its key numbers (a low value is 偏低) and who to see — not the whole paragraph again unless they ask about it — then answer what they asked, in full. Never contradict the page silently: if you think the page is wrong, say why and call note_page_issue.',
28 '3. When the snapshot says 就医跟进, ask once in this session, in one short question: 约了吗?医生怎么说? — not in every reply. When they answer, record it with log_care_visit in their words. When they want to prepare for the visit, offer the one-page brief (prepare_doctor_brief).',
29 '4. When the person states a goal, something they do not want (不要…), a condition, a medicine they take or stopped, a sick or travel day, or whether they drink, call remember_for_me with their exact words as quote. read_person_memory shows what is already kept; do not ask again.',
30 '5. Use the job skills for the job at hand. Never send the person\'s name anywhere; call them 你.',
31 '6. Write only Chinese to the person. Do not narrate what you are about to do (no "I\'ll …", "Let me …" before a tool call): call the tool, then answer.',
32 '7. Deep analysis: follow the snapshot line 深度分析. With automatic deep analysis switched on and the line saying it can start, start it yourself with run_deep_analysis (trigger "ai", reason_zh naming the new data) and say in one sentence why and that it runs in the background of this chat. With the switch off, at the key moment the line names, ask once whether to do one, saying how many tokens and how long it takes; start it with trigger "member" only on a yes. Then follow the longevity-analyst skill, import the result with import_analysis when it is done, and read the plan back for them to adopt.',
33 '8. The snapshot line 当前查看 names whose record the page shows. When it is a family member, every record, plan, memory and analysis you read or write is theirs: speak about them (你爸爸 / 你妈妈), not about the person chatting, and never mix the two. When the person tells you something about themselves while a family member is shown, do not record it (remember_for_me, save_*): tell them to switch to 我 on the health page first.',
34]
35
36export function orchestratorPrompt(mount: MountState): string {
37 return [...personaLines(mount), ...ORCHESTRATOR_RULES].join('\n')
38}
39
40interface AgentLike {
41 ctx?: unknown
42 session?: { id?: unknown; header?: { cwd?: unknown; origin?: unknown; delegationDepth?: unknown } }
43}
44
45function freeze<T>(value: T): T {
46 if (value && typeof value === 'object') {
47 for (const inner of Object.values(value as Record<string, unknown>)) freeze(inner)
48 Object.freeze(value)
49 }
50 return value
51}
52
53export function pluginMessage(text: string, form: 'snapshot' | 'instructions', name: string): never {
54 return freeze({ id: randomUUID(), role: 'user', content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: PLUGIN_SOURCE, form, sections: [{ name, text }] } }) as never
55}
56
57function clip(text: string, max: number): string {
58 const chars = [...text]
59 return chars.length <= max ? text : `${chars.slice(0, max - 1).join('')}…`
60}
61
62export interface SnapshotInput {
63 page: PageState
64 memory_zh: string
65 care_due_zh: string
66 /** Items the distiller noted from the chat, not yet confirmed. */
67 noted_zh?: string
68 /** The deep-analysis facts (M12): new data since the last analysis, a run going, what the AI may do. */
69 analysis_zh?: string
70 /** Whose record the page shows now (M13), when it is a family member rather than the person chatting. */
71 person_zh?: string
72}
73
74/** The snapshot text (Chinese), at most about 1.5k tokens. */
75export function snapshotText(input: SnapshotInput): string {
76 const { page } = input
77 const lines = ['【LongPi 健康页快照】以下是 LongPi 健康页此刻显示的内容,由插件提供,不是用户说的话。']
78 if (input.person_zh) lines.push(input.person_zh)
79 const top = page.top_facts[0]
80 const urgent = (row: PageState['top_facts'][number] | undefined) => Boolean(row) && (row?.priority === 'must_surface' || row?.priority === 'emergency')
81 // Only a doctor-first finding or a screening topic is said first; a medicine or condition that changes
82 // what is safe is context for food, supplement, exercise and plan questions, not an opener for every reply.
83 if (top && urgent(top) && (top.kind === 'triage' || top.kind === 'screening')) lines.push(`最重要的事(必须先说):${top.text_zh}`)
84 const safety = page.top_facts.filter((row) => urgent(row) && (row.kind === 'safety_med' || row.kind === 'safety_condition')).slice(0, 2)
85 if (safety.length > 0) lines.push(`用药和身体状况(谈到饮食、补剂、运动、方案或这个药时必须考虑;不必每次开头都说):${safety.map((row) => row.text_zh).join(';')}`)
86 lines.push(`页面状态:${clip(page.status_zh, 120)}`)
87 lines.push(`下一步:${page.next.title_zh}${page.next.detail_zh ? `——${clip(page.next.detail_zh, 260)}` : ''}${page.next.mandatory ? '(必须先做)' : ''}`)
88 const others = page.top_facts.slice(1, 4).filter((row) => row.priority !== 'context' && !safety.includes(row) && row !== top).slice(0, 2)
89 if (others.length > 0) lines.push(`也要留意:${others.map((row) => row.text_zh).join(';')}`)
90 if (input.care_due_zh) lines.push(`就医跟进:${input.care_due_zh}`)
91 if (input.analysis_zh) lines.push(input.analysis_zh)
92 if (input.memory_zh) lines.push(`你之前记下:${clip(input.memory_zh.replace(/\n/g, ';'), 800)}`)
93 if (input.noted_zh) lines.push(`刚从对话里记下(未确认):${clip(input.noted_zh, 200)}。回答时顺带说一句「已记录:…(如有误,可以说「撤销」)」。`)
94 if (page.suggestions_zh.length > 0) lines.push(`页面建议的问题:${page.suggestions_zh.slice(0, 3).join(' / ')}`)
95 return lines.join('\n')
96}
97
98export interface OrchestratorOptions {
99 mount: MountState
100 healthWorkspaces: () => string[]
101 /** The page state and what goes with it, built within the deadline; null when it is not ready. */
102 snapshot: (deadlineMs: number) => Promise<SnapshotInput | null>
103 log?: (message: string) => void
104}
105
106export interface Orchestrator {
107 isHealth(agent: unknown): boolean
108 /** Sessions that got the snapshot, with the fp they got (for tests). */
109 injected: Map<string, string>
110}
111
112/** A sub-agent DSH spawned for a task (dsh-subagent writes origin "subagent" and a depth). A session the person
113 * forked from another carries parentSession too, but it is theirs: it is not a sub-agent. */
114export function isSubAgent(agent: unknown): boolean {
115 const header = (agent as AgentLike | undefined)?.session?.header
116 return header?.origin === 'subagent' || Number(header?.delegationDepth ?? 0) > 0
117}
118
119/** The person's latest message in a step's messages (not a plugin note, not a sub-agent's task text). */
120export function latestPersonText(messages: readonly unknown[]): string {
121 for (let index = messages.length - 1; index >= 0; index -= 1) {
122 const row = messages[index] as { role?: string; source?: { kind?: string }; content?: unknown }
123 if (row?.role !== 'user' || (row.source && row.source.kind && row.source.kind !== 'user')) continue
124 if (typeof row.content === 'string') return row.content
125 if (Array.isArray(row.content)) return row.content.map((part) => (part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string' ? (part as { text: string }).text : '')).join('')
126 }
127 return ''
128}
129
130/** The vendored Mirobody plugin's own rule notice (its guard). LongPi has no guard since 0.8.0, and drops this one too. */
131export function isMirobodyNotice(message: unknown): boolean {
132 const source = (message as { source?: { kind?: string; plugin?: string; form?: string } } | undefined)?.source
133 return source?.kind === 'plugin' && source.plugin === 'dsh-plugin-mirobody' && source.form === 'notice'
134}
135
136function sessionId(agent: AgentLike | undefined): string {
137 return typeof agent?.session?.id === 'string' ? agent.session.id : ''
138}
139
140export function registerOrchestrator(ctx: Context, options: OrchestratorOptions): Orchestrator {
141 const healthAgents = new WeakSet<object>()
142 const healthSessions = new Set<string>()
143 const injected = new Map<string, string>()
144 const inHealthWorkspace = (agent: AgentLike | undefined) => {
145 const cwd = typeof agent?.session?.header?.cwd === 'string' ? agent.session.header.cwd : ''
146 return Boolean(cwd) && options.healthWorkspaces().some((root) => insideWorkspace(cwd, root))
147 }
148 const isHealth = (agent: unknown) => {
149 const row = agent as AgentLike | undefined
150 if (isSubAgent(row)) return false
151 if (row && typeof row === 'object' && healthAgents.has(row)) return true
152 const id = sessionId(row)
153 return (id && healthSessions.has(id)) || inHealthWorkspace(row)
154 }
155
156 ctx.on('agent/created', (payload) => {
157 // A listener failure would veto the agent: everything here is best effort.
158 try {
159 const agent = (payload as unknown as { agent?: AgentLike })?.agent
160 if (!agent || typeof agent !== 'object') return
161 const scoped = agent.ctx as { systemPrompt?: { section?: (row: unknown) => unknown }; tools?: { restrict?: (filter: unknown) => unknown }; get?: (name: string) => unknown } | undefined
162 if (inHealthWorkspace(agent) && !isSubAgent(agent)) {
163 healthAgents.add(agent)
164 const id = sessionId(agent)
165 if (id) healthSessions.add(id)
166 const prompt = scoped?.systemPrompt ?? (scoped?.get?.('systemPrompt') as typeof scoped extends { systemPrompt?: infer S } ? S : never)
167 prompt?.section?.({ name: PROMPT_SECTIONS.orchestrator.name, order: PROMPT_SECTIONS.orchestrator.order, text: () => orchestratorPrompt(options.mount) })
168 return
169 }
170 const tools = scoped?.tools ?? (scoped?.get?.('tools') as { restrict?: (filter: unknown) => unknown } | undefined)
171 try {
172 tools?.restrict?.({ deny: [...WRITE_TOOLS] })
173 } catch {
174 // one unknown name fails the whole filter: hide them one by one
175 for (const name of WRITE_TOOLS) {
176 try {
177 tools?.restrict?.({ deny: [name] })
178 } catch {
179 // not registered in this process
180 }
181 }
182 }
183 } catch (error) {
184 options.log?.(`orchestrator scope: ${error instanceof Error ? error.message : String(error)}`)
185 }
186 })
187
188 // Outermost: the vendored Mirobody plugin appends its own rule notice (its guard, with a US-only crisis number) in
189 // every workspace. LongPi removed its guard in 0.8.0 and drops that notice as well.
190 ctx.on('agent/pre-step', async (_raw, next) => {
191 const decision = await next()
192 if (decision.kind !== 'enter') return decision
193 const kept = decision.messages.filter((message: unknown) => !isMirobodyNotice(message as never))
194 return kept.length === decision.messages.length ? decision : { ...decision, messages: kept }
195 }, { prepend: true })
196
197 ctx.on('agent/pre-step', async (raw, next) => {
198 const decision = await next()
199 try {
200 const payload = raw as unknown as { agent?: AgentLike; messages: unknown[]; step?: number; signal?: AbortSignal }
201 if (decision.kind !== 'enter' || payload.signal?.aborted) return decision
202 const agent = payload.agent
203 const id = sessionId(agent)
204 // Only LongPi's own workspace, and only the person's sessions there (a fork included): a sub-agent gets its
205 // task from its parent.
206 if (!id || !isHealth(agent) || isSubAgent(agent)) return decision
207 // What the person just said, for the tools that check a quote against their words (remember_for_me,
208 // log_care_visit) and for the memory distiller after the turn.
209 const said = latestPersonText(payload.messages)
210 if (said.trim()) rememberPersonText(sessionKey(agent), said)
211 if (payload.step !== 1) return decision
212 const extra: never[] = []
213 const snap = await options.snapshot(8_000).catch(() => null)
214 if (snap && id && injected.get(id) !== snap.page.inputs_fp) {
215 injected.set(id, snap.page.inputs_fp)
216 if (injected.size > 500) injected.delete(injected.keys().next().value as string)
217 extra.push(pluginMessage(snapshotText(snap), 'snapshot', 'longpi-page'))
218 }
219 if (extra.length === 0 || payload.signal?.aborted) return decision
220 return { ...decision, messages: [...decision.messages, ...extra] }
221 } catch {
222 return decision
223 }
224 })
225
226 return { isHealth, injected }
227}
228hooks/core/interventions.ts 357 lines1// A person's own intervention plan and check-ins, kept in dataDir next to
2// history.jsonl. Nothing here is uploaded, and nothing here is a prescription:
3// the plan is what the person (or their doctor or coach) decided, saved as they
4// confirmed it. Medicines and supplements are referenced by name only; their
5// schedule, dose and dose log stay in Mirobody, which is where the person
6// records them.
7
8import { appendFileSync, existsSync, mkdirSync, readFileSync } from '../sys/fs.ts'
9import { join } from '../sys/path.ts'
10import { stripDoses } from './dose.ts'
11import { foldName } from './units.ts'
12
13export const CATEGORIES = ['diet', 'exercise', 'sleep', 'supplement', 'drug', 'behavior', 'weight', 'other'] as const
14export type Category = typeof CATEGORIES[number]
15export const CATEGORY_ZH: Record<Category, string> = {
16 diet: '饮食', exercise: '运动', sleep: '睡眠', supplement: '补剂', drug: '药物', behavior: '行为', weight: '体重', other: '其他',
17}
18export const CHECKIN_TAGS = ['illness', 'travel', 'lab_change', 'stress', 'other'] as const
19export const TAG_ZH: Record<string, string> = { illness: '生病', travel: '出差旅行', lab_change: '更换检测机构', stress: '压力大', other: '其他' }
20
21export interface Target {
22 /** A Mirobody indicator measured daily, such as dailySteps or dailyTotalSleepTime. */
23 metric: string
24 op: '>=' | '<='
25 value: number
26 unit: string
27}
28
29export interface PlanItem {
30 id: string
31 category: Category
32 title: string
33 detail: string
34 start: string
35 end: string | null
36 frequency: { times: number; per: 'day' | 'week' } | null
37 target: Target | null
38 markers: string[]
39 mirobody: { medication: string; plan_id?: string } | null
40}
41
42export interface PlanGoal {
43 marker: string
44 value: number
45 unit: string
46}
47
48export interface PlanVersion {
49 schema: 'longpi-plan/1'
50 version: number
51 saved_at: string
52 title: string
53 source: 'chat' | 'file' | 'board' | 'analysis'
54 note: string
55 items: PlanItem[]
56 goals: PlanGoal[]
57}
58
59export interface CheckIn {
60 at: string
61 date: string
62 item: string
63 /** true done, false an explicit miss (没做到), null a note or tag alone, or an undo. */
64 done: boolean | null
65 amount: number | null
66 unit: string
67 note: string
68 tags: string[]
69 source: 'chat' | 'board'
70 /** Set on the row that takes back that day's check-in: the day is unknown again. */
71 undo?: true
72}
73
74const DATE = /^\d{4}-\d{2}-\d{2}$/
75
76function dir(dataDir: string): string {
77 return join(dataDir, 'interventions')
78}
79
80function readLines<T>(path: string, keep: (row: T) => boolean): T[] {
81 if (!existsSync(path)) return []
82 const rows: T[] = []
83 for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
84 if (!line.trim()) continue
85 try {
86 const row = JSON.parse(line) as T
87 if (row && keep(row)) rows.push(row)
88 } catch {
89 /* skip a torn line */
90 }
91 }
92 return rows
93}
94
95function append(dataDir: string, file: string, row: unknown): void {
96 mkdirSync(dir(dataDir), { recursive: true, mode: 0o700 })
97 appendFileSync(join(dir(dataDir), file), `${JSON.stringify(row)}\n`, { mode: 0o600 })
98}
99
100export function readPlans(dataDir: string): PlanVersion[] {
101 return readLines<PlanVersion>(join(dir(dataDir), 'plan.jsonl'), (row) => row.schema === 'longpi-plan/1' && Array.isArray(row.items))
102}
103
104export function currentPlan(dataDir: string): PlanVersion | null {
105 const plans = readPlans(dataDir)
106 return plans.length > 0 ? plans[plans.length - 1] ?? null : null
107}
108
109export function readCheckIns(dataDir: string): CheckIn[] {
110 return readLines<CheckIn>(join(dir(dataDir), 'adherence.jsonl'), (row) => typeof row.item === 'string' && DATE.test(row.date))
111}
112
113/**
114 * Whether each item was done on each day: the latest check-in that says done (true), not done (false) or
115 * takes the day back (undo) wins, in the order they were recorded. A day with no such row, or whose latest
116 * is an undo, is absent: unknown, never a miss. A note or tag alone says nothing about it.
117 */
118export function checkinStatus(rows: readonly CheckIn[]): Map<string, Map<string, boolean>> {
119 const out = new Map<string, Map<string, boolean>>()
120 for (const row of rows) {
121 if (typeof row.done !== 'boolean' && !row.undo) continue
122 const days = out.get(row.item) ?? new Map<string, boolean>()
123 if (typeof row.done === 'boolean') days.set(row.date, row.done)
124 else days.delete(row.date)
125 out.set(row.item, days)
126 }
127 return out
128}
129
130/** Days each item was done, counted cumulatively: a missed day never takes one away. */
131export function doneCounts(dataDir: string): Map<string, number> {
132 const out = new Map<string, number>()
133 for (const [item, days] of checkinStatus(readCheckIns(dataDir))) out.set(item, [...days.values()].filter(Boolean).length)
134 return out
135}
136
137/** Civil clock for check-ins, streaks and reminders. The person is in China; the process zone is not. */
138export const CIVIL_TZ = 'Asia/Shanghai'
139
140const WEEKDAY_INDEX: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
141
142/** Wall-clock parts in Asia/Shanghai. Hour 24 (some engines at midnight) is 0. */
143export function civilParts(at: Date = new Date()): { year: number; month: number; day: number; hour: number; minute: number; weekday: number } {
144 const fmt = new Intl.DateTimeFormat('en-US', {
145 timeZone: CIVIL_TZ,
146 year: 'numeric',
147 month: '2-digit',
148 day: '2-digit',
149 hour: '2-digit',
150 minute: '2-digit',
151 hourCycle: 'h23',
152 weekday: 'short',
153 })
154 const map = Object.fromEntries(fmt.formatToParts(at).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]))
155 let hour = Number(map.hour)
156 if (hour === 24) hour = 0
157 return {
158 year: Number(map.year),
159 month: Number(map.month),
160 day: Number(map.day),
161 hour,
162 minute: Number(map.minute),
163 weekday: WEEKDAY_INDEX[map.weekday ?? ''] ?? 0,
164 }
165}
166
167/** YYYY-MM-DD in Asia/Shanghai, not UTC and not the process zone. */
168export function isoDay(at: Date = new Date()): string {
169 const parts = civilParts(at)
170 const pad = (value: number) => String(value).padStart(2, '0')
171 return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}`
172}
173
174export function addDays(iso: string, days: number): string {
175 const at = new Date(`${iso}T00:00:00Z`)
176 at.setUTCDate(at.getUTCDate() + days)
177 return at.toISOString().slice(0, 10)
178}
179
180export function daysBetween(from: string, to: string): number {
181 return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / 86_400_000)
182}
183
184function text(value: unknown, max: number): string {
185 return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ').slice(0, max) : ''
186}
187
188const DOSE_NOT_SAVED = '方案只记做什么,不记剂量;药物和补剂的剂量与服用记录在用药计划里。'
189
190export interface NormalizeContext {
191 today: string
192 /** Names on the person's Mirobody medication plan, with plan ids. */
193 medications: Array<{ name: string; plan_id?: string }>
194 previous: PlanVersion | null
195}
196
197export interface Normalized {
198 plan: Omit<PlanVersion, 'version' | 'saved_at'>
199 warnings: string[]
200 errors: string[]
201}
202
203/**
204 * Check a plan the person described or uploaded and put it in the stored shape.
205 * Returns errors that stop saving and warnings to read back before confirming.
206 */
207export function normalizePlan(raw: unknown, context: NormalizeContext): Normalized {
208 const input = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
209 const errors: string[] = []
210 const warnings: string[] = []
211 const itemsIn = Array.isArray(input.items) ? input.items : []
212 if (itemsIn.length === 0) errors.push('方案里没有任何一项干预。')
213 if (itemsIn.length > 30) errors.push('一次最多保存 30 项干预。')
214 const previousByTitle = new Map((context.previous?.items ?? []).map((item) => [foldName(item.title), item]))
215 const usedIds = new Set<string>()
216 const items: PlanItem[] = []
217 itemsIn.slice(0, 30).forEach((value, index) => {
218 const row = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>
219 // A dose is never saved, whatever the category: the plan keeps what to do, Mirobody keeps doses.
220 const titleIn = text(row.title, 60)
221 const cleanedTitle = stripDoses(titleIn)
222 const cleanedDetail = stripDoses(text(row.detail, 300))
223 const title = cleanedTitle.text
224 const where = title || `第 ${index + 1} 项`
225 if (!titleIn) errors.push(`${where}没有名称。`)
226 else if (!title) errors.push(`${where}:标题仅包含剂量,请填写具体做法。`)
227 if (cleanedTitle.stripped || cleanedDetail.stripped) warnings.push(`${where}的剂量没有保存:${DOSE_NOT_SAVED}`)
228 const category = (CATEGORIES as readonly string[]).includes(String(row.category)) ? row.category as Category : 'other'
229 if (category === 'other' && row.category && row.category !== 'other') warnings.push(`${where}的类别「${String(row.category)}」无法识别,已归为「其他」。`)
230 const detail = cleanedDetail.text
231 const start = text(row.start, 10)
232 if (!DATE.test(start)) errors.push(`${where}缺少开始日期(YYYY-MM-DD)。判断效果要靠它找基线。`)
233 const endText = text(row.end, 10)
234 const end = DATE.test(endText) ? endText : null
235 if (end && DATE.test(start) && end < start) errors.push(`${where}的结束日期早于开始日期。`)
236 if (DATE.test(start) && start > addDays(context.today, 60)) warnings.push(`${where}的开始日期在两个月以后。`)
237
238 let mirobody: PlanItem['mirobody'] = null
239 if ((category === 'drug' || category === 'supplement') && title) {
240 const medication = stripDoses(text(row.medication, 60))
241 if (medication.stripped && !cleanedTitle.stripped && !cleanedDetail.stripped) warnings.push(`${where}的剂量没有保存:${DOSE_NOT_SAVED}`)
242 const name = medication.text || title
243 // Linked by the name as written first (a dose stripped out may have taken part of it), then as stored.
244 const hit = [...new Set([text(row.medication, 60) || titleIn, name])].map(foldName).map((folded) => context.medications.find((med) => {
245 const other = foldName(med.name)
246 return other && folded && (other.includes(folded) || folded.includes(other))
247 })).find(Boolean)
248 if (hit) mirobody = { medication: hit.name, ...(hit.plan_id ? { plan_id: hit.plan_id } : {}) }
249 else {
250 mirobody = { medication: name }
251 warnings.push(`用药计划中未找到「${name}」。请在健康数据服务中建立用药计划并打卡,之后才能跟踪服用情况。`)
252 }
253 }
254
255 let frequency: PlanItem['frequency'] = null
256 const freq = row.frequency && typeof row.frequency === 'object' ? row.frequency as Record<string, unknown> : null
257 if (freq && Number.isFinite(Number(freq.times)) && Number(freq.times) > 0 && (freq.per === 'day' || freq.per === 'week')) {
258 frequency = { times: Math.min(50, Math.round(Number(freq.times))), per: freq.per }
259 }
260
261 let target: Target | null = null
262 const tgt = row.target && typeof row.target === 'object' ? row.target as Record<string, unknown> : null
263 if (tgt && text(tgt.metric, 80) && Number.isFinite(Number(tgt.value))) {
264 target = { metric: text(tgt.metric, 80), op: tgt.op === '<=' ? '<=' : '>=', value: Number(tgt.value), unit: text(tgt.unit, 20) }
265 }
266
267 const markers = [...new Set((Array.isArray(row.markers) ? row.markers : []).map((item) => text(item, 60)).filter(Boolean))].slice(0, 12)
268 const previous = title ? previousByTitle.get(foldName(title)) : undefined
269 let id = text(row.id, 40) || previous?.id || ''
270 if (!/^[a-z0-9-]{2,40}$/.test(id) || usedIds.has(id)) id = `i${Date.now().toString(36)}${index.toString(36)}`
271 usedIds.add(id)
272 items.push({ id, category, title, detail, start, end, frequency, target, markers, mirobody })
273 })
274
275 const goals: PlanGoal[] = []
276 for (const value of Array.isArray(input.goals) ? input.goals.slice(0, 20) : []) {
277 const row = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>
278 const marker = text(row.marker, 60)
279 const number = Number(row.value)
280 if (!marker || !Number.isFinite(number)) {
281 warnings.push(`目标「${marker || '未命名'}」没有数值,没有保存。`)
282 continue
283 }
284 goals.push({ marker, value: number, unit: text(row.unit, 20) })
285 }
286
287 const source = input.source === 'file' || input.source === 'board' || input.source === 'analysis' ? input.source : 'chat'
288 const planTitle = stripDoses(text(input.title, 60))
289 const note = stripDoses(text(input.note, 500))
290 if (planTitle.stripped || note.stripped) warnings.push(`方案${planTitle.stripped ? '标题' : '备注'}里的剂量没有保存:${DOSE_NOT_SAVED}`)
291 return {
292 plan: { schema: 'longpi-plan/1', title: planTitle.text || '我的干预方案', source, note: note.text, items, goals },
293 warnings,
294 errors,
295 }
296}
297
298export function savePlan(dataDir: string, plan: Normalized['plan']): PlanVersion {
299 const previous = currentPlan(dataDir)
300 const saved: PlanVersion = { ...plan, version: (previous?.version ?? 0) + 1, saved_at: new Date().toISOString() }
301 append(dataDir, 'plan.jsonl', saved)
302 return saved
303}
304
305export interface CheckInResult {
306 saved: CheckIn[]
307 problems: string[]
308}
309
310/** Record check-ins against items of the current plan. `item` may be an id or a title. */
311export function addCheckIns(dataDir: string, entries: unknown[], context: { today: string; source: 'chat' | 'board' }): CheckInResult {
312 const plan = currentPlan(dataDir)
313 const problems: string[] = []
314 const saved: CheckIn[] = []
315 if (!plan) return { saved, problems: ['还没有保存的干预方案。先保存方案再打卡。'] }
316 for (const value of entries.slice(0, 40)) {
317 const row = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>
318 const ref = text(row.item, 60)
319 const folded = foldName(ref)
320 const item = plan.items.find((candidate) => candidate.id === ref)
321 ?? plan.items.find((candidate) => foldName(candidate.title) === folded)
322 ?? plan.items.find((candidate) => folded.length >= 2 && foldName(candidate.title).includes(folded))
323 if (!item) {
324 problems.push(`方案里没有「${ref}」。`)
325 continue
326 }
327 if (item.mirobody) {
328 problems.push(`「${item.title}」是药物或补剂,服用情况记录在用药计划中,此处仅可查看。`)
329 continue
330 }
331 const date = DATE.test(text(row.date, 10)) ? text(row.date, 10) : context.today
332 if (date > context.today) {
333 problems.push(`「${item.title}」的打卡日期 ${date} 在未来。`)
334 continue
335 }
336 // done null, given as such, takes that day's check-in back; left out, a note or tag says nothing about it.
337 const undo = 'done' in row && row.done === null
338 const amount = !undo && Number.isFinite(Number(row.amount)) && row.amount !== null && row.amount !== '' ? Number(row.amount) : null
339 const tags = undo ? [] : (Array.isArray(row.tags) ? row.tags : []).map((tag) => String(tag)).filter((tag) => (CHECKIN_TAGS as readonly string[]).includes(tag))
340 const checkIn: CheckIn = {
341 at: new Date().toISOString(),
342 date,
343 item: item.id,
344 done: typeof row.done === 'boolean' ? row.done : (amount != null ? true : null),
345 amount,
346 unit: undo ? '' : text(row.unit, 20),
347 note: undo ? '' : text(row.note, 200),
348 tags,
349 source: context.source,
350 ...(undo ? { undo: true as const } : {}),
351 }
352 append(dataDir, 'adherence.jsonl', checkIn)
353 saved.push(checkIn)
354 }
355 return { saved, problems }
356}
357hooks/core/engage/engine.ts 1108 lines1// 长寿图鉴 (docs/codex-design.md 1.2). The main line is a two-week personal experiment: three to choose from, do
2// it, reveal. The reveal is the only pack opening. The library is free to read. Packs come only from a season
3// start, a finished experiment, or the holder's own retest; a family member's visit or checkup is a footprint.
4// State lives in the account holder's LongPi home (engage/state.json), whoever is being looked at.
5
6import { readFileSync } from '../../sys/fs.ts'
7import { join } from '../../sys/path.ts'
8import type { IsoDay, IsoTime } from '../contracts/common.ts'
9import type { Config } from '../config.ts'
10import type { ExperimentRun, ExperimentSpec, Footprint, FootprintKind, MetricKey, Pack, ResultCard, RunResult, Tier } from '../contracts/codex.ts'
11import type { Bus, HealthEventPayloads, HealthEventType } from '../contracts/events.ts'
12import type { FactPack } from '../contracts/factpack.ts'
13import type { ActionKind } from '../contracts/surfaces.ts'
14import { addDays, civilParts, daysBetween, isoDay } from '../interventions.ts'
15import { minorView } from '../privacy/consents.ts'
16import { readProfile } from '../profile.ts'
17import { loadReference, type Biovar } from '../reference.ts'
18import { careItems } from '../triage/care.ts'
19import { codexBlock, codexBlockZh, experimentById, loadCatalog, loadLibrary, studyById, type CodexBlock } from './data.ts'
20import { blockedBy, devices, eligible, leadInFor, pickThree, primaryFor, randomSchedule, type EligibilityContext } from './eligibility.ts'
21import { DEFAULT_MY_DAY, laterReveal, markRevealShown, settleAcks, slotView, standupDays, validClock, type SlotView } from './nudge.ts'
22import { readSeriesCache, refreshSeries, stepsOnDay, valuesIn, type SeriesCache } from './series.ts'
23import { emptyState, newId, readState, saveState, type CodexContext, type State } from './state.ts'
24import { doneZh, judgeMetric, MIN_BASELINE_DAYS, MIN_TRIAL_DAYS, OUTCOME_ZH, praiseZh, thresholdZh } from './verdict.ts'
25
26export const SEASON_DAYS = 56
27const MAX_RUNNING = 2
28const EXTEND_DAYS = 7
29const RETEST_SEASON_CAP_DAYS = 112
30
31export const FOOTPRINT_NOTE_ZH = '只记你做了什么,不代表指标好坏。'
32
33interface Runtime {
34 /** The person being looked at (a family member's folder when one is chosen). */
35 dataDir: () => string
36 /** The account holder's LongPi home: the Codex lives here. */
37 rootDir: () => string
38 skillsHome: () => string
39 codexOn: () => boolean
40 bus: Bus | null
41 /** Re-read the holder's record and the Codex series (bound by register(); absent in tests). */
42 refresh: ((force?: boolean) => Promise<void>) | null
43}
44
45let runtime: Runtime = { dataDir: () => '', rootDir: () => '', skillsHome: () => '', codexOn: () => true, bus: null, refresh: null }
46let lastKick = 0
47
48/** From the slot poll: refresh the series in the background at most every 15 minutes, so a day away from the page still counts. */
49export function kickRefresh(now: number = Date.now()): void {
50 if (!runtime.refresh || now - lastKick < 15 * 60_000) return
51 lastKick = now
52 void runtime.refresh().catch(() => undefined)
53}
54
55/** The record changed (a report, an import, a measurement): rebuild the Codex series now, not in 15 minutes. */
56export function recordChanged(): Promise<void> {
57 lastKick = Date.now()
58 return runtime.refresh ? runtime.refresh(true).catch(() => undefined) : Promise.resolve()
59}
60
61export function bindRuntime(next: Partial<Runtime>): void {
62 runtime = { ...runtime, ...next }
63}
64
65export function boundDataDir(): string {
66 return runtime.dataDir()
67}
68
69/** The holder's home; falls back to the active folder when only that is bound (tests, bare hosts). */
70export function boundRootDir(): string {
71 return runtime.rootDir() || runtime.dataDir()
72}
73
74function emit<T extends HealthEventType>(type: T, payload: HealthEventPayloads[T]): void {
75 try { runtime.bus?.emit(type, payload, { module: 'M6', via: 'route' }) } catch { /* the bus is optional */ }
76}
77
78function biovar(): Biovar | null {
79 try {
80 const home = runtime.skillsHome()
81 return home ? loadReference(home).biovar : null
82 } catch {
83 return null
84 }
85}
86
87// ---- who may use it -----------------------------------------------------------
88
89interface World { today: IsoDay; now: Date; age: number | null; minor: boolean; consent: boolean; memoryOptOut: boolean; memoryStandup: boolean }
90
91function memoryFlags(dataDir: string): { minor: boolean; optOut: boolean; standup: boolean } {
92 const out = { minor: false, optOut: false, standup: false }
93 try {
94 const raw = JSON.parse(readFileSync(join(dataDir, 'memory.json'), 'utf8')) as { items?: unknown[] }
95 for (const item of raw.items ?? []) {
96 if (!item || typeof item !== 'object') continue
97 const row = item as Record<string, unknown>
98 if (row.status && row.status !== 'active') continue
99 if (row.kind === 'condition' && Array.isArray(row.flags) && row.flags.includes('minor')) out.minor = true
100 if (row.kind === 'preference' && row.key === 'codex_enabled' && row.value === false) out.optOut = true
101 if (row.kind === 'preference' && row.key === 'nudge_in_workflow' && row.value === true) out.standup = true
102 }
103 } catch { /* memory is M0's file; missing is normal */ }
104 return out
105}
106
107function worldOf(root: string, now: Date): World {
108 const profile = readProfile(root)
109 const minor = minorView(profile, now)
110 const memory = memoryFlags(root)
111 return {
112 today: isoDay(now),
113 now,
114 age: minor.age,
115 minor: minor.minor || memory.minor,
116 consent: Boolean(profile.consent?.accepted_at),
117 memoryOptOut: memory.optOut,
118 memoryStandup: memory.standup,
119 }
120}
121
122function blockOf(state: State, world: World): CodexBlock {
123 const optOut = state.choice === 'off' || (state.choice == null && world.memoryOptOut)
124 return codexBlock({ age: world.age, minorFlag: world.minor, optOut, configOn: runtime.codexOn() })
125}
126
127// ---- the person's data, from the cached series ---------------------------------
128
129function lifeDays(state: State): Set<IsoDay> {
130 return new Set(state.life.map((row) => row.day))
131}
132
133function isWeekend(day: IsoDay): boolean {
134 const weekday = new Date(`${day}T12:00:00Z`).getUTCDay()
135 return weekday === 0 || weekday === 6
136}
137
138function runDays(run: ExperimentRun, through: IsoDay): IsoDay[] {
139 const last = [run.extended_to ?? run.end, through].sort()[0] as IsoDay
140 const out: IsoDay[] = []
141 for (let day = run.start; day <= last; day = addDays(day, 1)) out.push(day)
142 return out
143}
144
145function countable(run: ExperimentRun, state: State): (day: IsoDay) => boolean {
146 const life = lifeDays(state)
147 return (day) => !life.has(day) && (run.scope !== 'weekdays' || !isWeekend(day))
148}
149
150function median(values: number[]): number | null {
151 if (values.length === 0) return null
152 const sorted = [...values].sort((a, b) => a - b)
153 const mid = Math.floor(sorted.length / 2)
154 return sorted.length % 2 ? sorted[mid] as number : ((sorted[mid - 1] as number) + (sorted[mid] as number)) / 2
155}
156
157/** Sleep onset, as distance from the window's own median (minutes): the regularity measure. */
158function onsetSpread(series: Record<IsoDay, number> | undefined, days: IsoDay[]): Record<IsoDay, number> {
159 const values = days.flatMap((day) => series?.[day] != null ? [{ day, value: series[day] as number }] : [])
160 const mid = median(values.map((row) => row.value))
161 if (mid == null) return {}
162 return Object.fromEntries(values.map((row) => [row.day, Math.abs(row.value - mid)]))
163}
164
165function metricValues(key: MetricKey, cache: SeriesCache, days: IsoDay[]): Array<{ day: IsoDay; value: number }> {
166 if (key === 'onset') {
167 const spread = onsetSpread(cache.days.onset, days)
168 return days.flatMap((day) => spread[day] != null ? [{ day, value: spread[day] as number }] : [])
169 }
170 const series = cache.days[key]
171 return days.flatMap((day) => series?.[day] != null ? [{ day, value: series[day] as number }] : [])
172}
173
174function baselineDays(run: ExperimentRun): IsoDay[] {
175 const out: IsoDay[] = []
176 for (let day = run.baseline.from; day <= run.baseline.to; day = addDays(day, 1)) out.push(day)
177 return out
178}
179
180/** 做到 days LongPi can see itself; check-ins add the ones the person ticked. */
181function autoDone(run: ExperimentRun, state: State, cache: SeriesCache, days: IsoDay[]): Set<IsoDay> {
182 const out = new Set<IsoDay>()
183 const spec = experimentById(run.experiment_id)
184 if (!spec) return out
185 if (spec.adherence === 'standup') {
186 const stood = standupDays(state.nudge)
187 for (const day of days) if (stood.has(day)) out.add(day)
188 } else if (spec.adherence === 'bp_reading') {
189 for (const day of days) if (cache.days.sbp?.[day] != null) out.add(day)
190 } else if (spec.adherence === 'sleep_longer') {
191 const base = valuesIn(cache.days.sleep, run.baseline.from, run.baseline.to).map((row) => row.value)
192 const avg = base.length ? base.reduce((a, b) => a + b, 0) / base.length : null
193 if (avg != null) for (const day of days) if ((cache.days.sleep?.[day] ?? -1) >= avg + 0.33) out.add(day)
194 } else if (spec.adherence === 'wake_fixed') {
195 const key = run.variant === 'fixed-bed' ? 'onset' : 'wake'
196 const series = cache.days[key]
197 const target = median(valuesIn(series, run.baseline.from, run.baseline.to).map((row) => row.value))
198 if (target != null) {
199 for (const day of days) {
200 const value = series?.[day]
201 if (value == null) continue
202 const tolerance = isWeekend(day) && run.scope !== 'weekdays' && run.variant !== 'fixed-bed' ? 60 : 30
203 if (Math.abs(value - target) <= tolerance) out.add(day)
204 }
205 }
206 }
207 return out
208}
209
210function doneDays(run: ExperimentRun, state: State, cache: SeriesCache, days: IsoDay[]): Set<IsoDay> {
211 const out = autoDone(run, state, cache, days)
212 for (const day of run.done) if (days.includes(day)) out.add(day)
213 return out
214}
215
216/** The 14 cells of the progress bar: d done, m missed, f future. */
217function cells(run: ExperimentRun, state: State, cache: SeriesCache, today: IsoDay): Array<'d' | 'm' | 'f'> {
218 const total = Math.min(14, daysBetween(run.start, run.end) + 1)
219 const days = Array.from({ length: total }, (_, i) => addDays(run.start, Math.round(i * (daysBetween(run.start, run.end) + 1) / total)))
220 const done = doneDays(run, state, cache, runDays(run, today))
221 return days.map((day) => day >= today && !done.has(day) ? 'f' : done.has(day) ? 'd' : 'm')
222}
223
224// ---- judging a run -------------------------------------------------------------
225
226function judgeRun(run: ExperimentRun, state: State, cache: SeriesCache, today: IsoDay, now: Date): RunResult {
227 const { metrics } = loadCatalog()
228 const keep = countable(run, state)
229 const trialDays = runDays(run, addDays(today, -1)).filter(keep)
230 const done = doneDays(run, state, cache, trialDays)
231 const bv = biovar()
232 const judged = (key: MetricKey, role: 'primary' | 'also') => {
233 const metric = metrics[key]
234 if (run.randomized && run.schedule) {
235 const values = metricValues(key, cache, trialDays)
236 const on = values.filter((row) => run.schedule?.[row.day] === true).map((row) => row.value)
237 const off = values.filter((row) => run.schedule?.[row.day] === false).map((row) => row.value)
238 return judgeMetric(metric, off, on, bv, { role, compare: 'off_on', minFirst: role === 'primary' ? 5 : 4, minSecond: role === 'primary' ? 5 : 4 })
239 }
240 const base = metricValues(key, cache, baselineDays(run).filter((day) => !lifeDays(state).has(day))).map((row) => row.value)
241 const trial = metricValues(key, cache, trialDays).map((row) => row.value)
242 // A run that measured 7 days first has those days as its baseline.
243 const reference = run.baseline.from === addDays(run.start, -7) ? '你先量的 7 天里' : undefined
244 return judgeMetric(metric, base, trial, bv, role === 'primary'
245 ? { role, minFirst: metric.method === 'personal' ? MIN_BASELINE_DAYS : 3, minSecond: MIN_TRIAL_DAYS, reference }
246 : { role, reference })
247 }
248 const primary = judged(run.primary, 'primary')
249 const also = run.also.filter((key) => metrics[key] && metrics[key].method !== 'lab').map((key) => judged(key, 'also'))
250 const effective = metricValues(run.primary, cache, trialDays).length
251 const { how_zh: how, ...first } = primary
252 return {
253 outcome: primary.outcome,
254 primary: first,
255 also: also.map(({ how_zh: _how, ...rest }) => rest),
256 done_days: done.size,
257 done_zh: doneZh(done.size, trialDays.length),
258 effective_days: effective,
259 window_days: trialDays.length,
260 how_zh: how,
261 praise_zh: praiseZh(primary),
262 at: now.toISOString() as IsoTime,
263 }
264}
265
266function judgeLab(run: ExperimentRun, after: { value: number; date: IsoDay }, now: Date): RunResult {
267 const { metrics } = loadCatalog()
268 const metric = metrics[run.primary]
269 const before = run.lab_before
270 const judged = before ? judgeMetric(metric, [before.value], [after.value], biovar(), { role: 'primary', minFirst: 1, minSecond: 1 }) : judgeMetric(metric, [], [after.value], biovar(), { role: 'primary' })
271 const { how_zh: how, ...first } = judged
272 const text = before
273 ? `主要结果 · ${metric.label_zh}:${before.value.toFixed(metric.decimals)} → ${after.value.toFixed(metric.decimals)} ${metric.unit_zh},${judged.outcome === 'outside' ? '超出你的平时波动' : judged.outcome === 'inside' ? '在你的平时波动内' : '数据不够,没法判断'}。`
274 : first.text_zh
275 return {
276 outcome: judged.outcome,
277 primary: { ...first, text_zh: text },
278 also: [],
279 done_days: run.done.length,
280 done_zh: doneZh(run.done.length, daysBetween(run.start, run.end) + 1),
281 effective_days: 1,
282 window_days: daysBetween(run.start, run.end) + 1,
283 how_zh: how,
284 praise_zh: praiseZh(judged),
285 at: now.toISOString() as IsoTime,
286 }
287}
288
289// ---- footprints and packs ------------------------------------------------------
290
291const FOOTPRINT_TITLES: Record<FootprintKind, string> = {
292 care_brief: '带着简报就诊',
293 retest: '按时复测',
294 addon: '补上一项检查',
295 first_experiment: '做完第一个实验',
296 season: '走完一个赛季',
297 family: '陪家人复查',
298}
299
300function addFootprint(state: State, kind: FootprintKind, day: IsoDay, text: string, title?: string): void {
301 const footprint: Footprint = { id: newId('fp'), kind, day, title_zh: title ?? FOOTPRINT_TITLES[kind], text_zh: text }
302 state.footprints.push(footprint)
303 emit('codex.footprint', { footprint_id: footprint.id, kind })
304}
305
306function grantPack(state: State, kind: Pack['kind'], day: IsoDay, source: string, results: ResultCard[] = []): Pack {
307 const pack: Pack = { id: newId('pk'), kind, source_zh: source, granted: day, opened: null, options: [], chosen: null, results }
308 state.packs.push(pack)
309 emit('codex.pack_granted', { pack_id: pack.id, kind, source: kind === 'experiment' ? 'season_or_experiment' : 'retest' })
310 return pack
311}
312
313function ensureSeed(state: State): void {
314 if (!state.seed_hex) state.seed_hex = newId('')
315}
316
317function startSeason(state: State, day: IsoDay): void {
318 const id = newId('sn')
319 const end = addDays(day, (state.prefs.season_mode === 'retest' ? RETEST_SEASON_CAP_DAYS : SEASON_DAYS) - 1)
320 state.season = { id, mode: state.prefs.season_mode, start: day, end, status: 'active', closed: null }
321 state.seasons.push({ id, start: day, closed: null, runs: [] })
322 grantPack(state, 'experiment', day, '赛季开始')
323 emit('season.started', { season_id: id })
324}
325
326function closeSeason(state: State, day: IsoDay): void {
327 const season = state.season
328 if (!season || season.status === 'closed') return
329 season.status = 'closed'
330 season.closed = day
331 const row = state.seasons.find((item) => item.id === season.id)
332 if (row) row.closed = day
333 const finished = state.runs.filter((run) => run.status === 'revealed' && run.start >= season.start).length
334 addFootprint(state, 'season', day, finished > 0 ? `这个赛季走完了,一共做了 ${finished} 个实验。` : '这个赛季走完了。')
335 emit('season.ended', { season_id: season.id, experiments_done: finished })
336}
337
338function resultCards(context: CodexContext): ResultCard[] {
339 const out: ResultCard[] = []
340 const bio = context.results.bioage
341 if (bio) {
342 const diff = bio.prev != null ? bio.now - bio.prev : null
343 const beyond = diff != null && bio.band != null && Math.abs(diff) > bio.band
344 out.push({
345 id: newId('rc'),
346 key: 'bioage',
347 title_zh: '表型年龄(用 9 项化验算的生物年龄)',
348 value_zh: `${bio.now.toFixed(1)} 岁`,
349 compare_zh: bio.prev == null ? '第一次算出这一项。' : `和上次(${bio.prev_date ?? ''})比:${bio.prev.toFixed(1)} → ${bio.now.toFixed(1)} 岁,${beyond ? OUTCOME_ZH.outside : OUTCOME_ZH.inside}。`,
350 outcome: bio.prev == null ? null : beyond ? 'outside' : 'inside',
351 tier: 'human',
352 plain: diff != null && diff > 0,
353 note_zh: null,
354 })
355 }
356 const risk = context.results.risk
357 if (risk) {
358 out.push({
359 id: newId('rc'),
360 key: 'risk',
361 title_zh: '十年心血管风险(China-PAR)',
362 value_zh: risk.applicable && risk.pct != null ? `${risk.pct.toFixed(1)}%` : null,
363 compare_zh: risk.applicable && risk.pct != null ? `${risk.category_zh || '本次的估算'}。` : risk.reason_zh,
364 outcome: null,
365 tier: 'human',
366 plain: true,
367 note_zh: risk.applicable ? null : risk.reason_zh,
368 })
369 }
370 for (const change of context.results.changes.filter((row) => !row.ask_doctor).slice(0, 4)) {
371 out.push({
372 id: newId('rc'),
373 key: change.key,
374 title_zh: change.label_zh,
375 value_zh: `${change.to} ${change.unit}`,
376 compare_zh: `和上次(${change.from_date})比:${change.from} → ${change.to} ${change.unit},${change.beyond ? OUTCOME_ZH.outside : OUTCOME_ZH.inside}。`,
377 outcome: change.beyond ? 'outside' : 'inside',
378 tier: 'human' as Tier,
379 plain: change.verdict !== 'better',
380 note_zh: null,
381 })
382 }
383 return out
384}
385
386function seasonExperimentIds(state: State, back: number): Set<string> {
387 const seasons = state.seasons.slice(-back)
388 const starts = seasons.map((row) => row.start).sort()
389 const from = starts[0] ?? '0000-00-00'
390 return new Set(state.runs.filter((run) => run.start >= from && run.status !== 'stopped').map((run) => run.experiment_id))
391}
392
393function eligibilityContext(state: State, today: IsoDay, cache: SeriesCache): EligibilityContext {
394 const ctx = state.context
395 const retestIn8to12 = ctx.retests.some((row) => {
396 const gap = daysBetween(today, row.date)
397 return gap >= 56 && gap <= 84
398 })
399 return {
400 today,
401 drugClasses: new Set(ctx.drug_classes),
402 conditions: new Set(ctx.conditions),
403 pregnant: ctx.pregnant,
404 openFindings: new Set(ctx.open_findings),
405 series: cache,
406 ldlOnFile: Boolean(ctx.ldl),
407 retestIn8to12Weeks: retestIn8to12,
408 personalText: ctx.personal_text,
409 focus: new Set(ctx.focus),
410 standupOn: state.nudge.standup === 'on',
411 recent: seasonExperimentIds(state, 2),
412 running: new Set(state.runs.filter((run) => run.status === 'running' || run.status === 'retest_wait').map((run) => run.experiment_id)),
413 }
414}
415
416// ---- the reducer ---------------------------------------------------------------
417
418function reduce(state: State, world: World, cache: SeriesCache): void {
419 const today = world.today
420 if (!world.consent || blockOf(state, world) || !state.started) return
421 ensureSeed(state)
422 const ctx = state.context
423 // A season: 8 weeks, or until the next retest (capped).
424 if (!state.season) startSeason(state, today)
425 const season = state.season
426 if (season && season.status === 'active' && today > season.end) closeSeason(state, today)
427 // A new checkup of the holder: a retest pack and a footprint; a season in retest mode ends with it.
428 const latest = ctx.latest_checkup
429 if (latest && !state.seen.checkups.includes(latest)) {
430 const first = state.seen.checkups.length === 0
431 state.seen.checkups.push(latest)
432 // Any checkup that arrives after the Codex started counts, the very first one included.
433 if (state.started && latest >= state.started) {
434 const results = resultCards(ctx)
435 for (const run of state.runs) {
436 if (run.status !== 'retest_wait' || !ctx.ldl || ctx.ldl.date < addDays(run.start, 42)) continue
437 run.result = judgeLab(run, ctx.ldl, world.now)
438 run.status = 'ready'
439 results.unshift({ id: newId('rc'), key: `run:${run.id}`, title_zh: run.title_zh, value_zh: null, compare_zh: run.result.primary.text_zh, outcome: run.result.outcome, tier: 'human', plain: run.result.primary.direction !== 'better', note_zh: null })
440 }
441 grantPack(state, 'retest', today, first ? '第一次体检的结果到了' : '新的体检结果到了', results)
442 const due = ctx.retests.some((row) => Math.abs(daysBetween(row.date, latest)) <= 30)
443 addFootprint(state, 'retest', today, due ? '到了该复查的时候,按时去复查了。' : '做了一次复查,新的结果进了档案。', due ? '按时复测' : '做了一次复查')
444 if (state.season?.status === 'active' && state.season.mode === 'retest') closeSeason(state, today)
445 }
446 }
447 // Footprints from the holder's own care visits (with a brief) and add-on tests.
448 for (const item of careItems(boundRootDir())) {
449 if (item.care_status !== 'visited' || !item.brief_id || state.seen.care.includes(item.id)) continue
450 state.seen.care.push(item.id)
451 if (!state.footprints.some((row) => row.kind === 'care_brief')) addFootprint(state, 'care_brief', item.visit_date ?? today, '拿着整理好的问题去见了医生。')
452 }
453 for (const key of ['hscrp', 'waist'] as const) {
454 if (ctx.labs[key] && !state.seen.labs[key]) {
455 state.seen.labs[key] = true
456 addFootprint(state, 'addon', today, key === 'hscrp' ? '补查了超敏 C 反应蛋白。' : '量了腰围,这一项补上了。')
457 }
458 }
459 // Experiments: a 14-day run becomes ready when 10 days had data; else it runs on for up to 7 more days.
460 const { metrics } = loadCatalog()
461 for (const run of state.runs) {
462 if (run.status !== 'running') continue
463 if (metrics[run.primary]?.method === 'lab') {
464 if (today > run.end) run.status = 'retest_wait'
465 continue
466 }
467 if (today <= run.end) continue
468 const keep = countable(run, state)
469 const effective = metricValues(run.primary, cache, runDays(run, addDays(today, -1)).filter(keep)).length
470 const hardEnd = addDays(run.end, EXTEND_DAYS)
471 if (effective >= MIN_TRIAL_DAYS || run.randomized || today > hardEnd) {
472 run.result = judgeRun(run, state, cache, today, world.now)
473 run.status = 'ready'
474 } else if (!run.extended_to) {
475 run.extended_to = hardEnd
476 }
477 }
478}
479
480// ---- views ---------------------------------------------------------------------
481
482export interface ExperimentOption {
483 id: string
484 title_zh: string
485 do_zh: string
486 icon: ExperimentSpec['icon']
487 days: number
488 primary_zh: string
489 also_zh: string[]
490 randomizable: boolean
491 questions: Array<{ id: string; text_zh: string }>
492 source_zh: string
493 needs_retest: boolean
494 /** 7 days of measuring first, because the two weeks before have too few readings. */
495 lead_in: boolean
496}
497
498export interface RunView {
499 id: string
500 experiment_id: string
501 title_zh: string
502 do_zh: string
503 icon: ExperimentSpec['icon']
504 checkin_zh: string
505 start: IsoDay
506 end: IsoDay
507 extended_to: IsoDay | null
508 day: number
509 days: number
510 cells: Array<'d' | 'm' | 'f'>
511 done_today: boolean
512 done_count: number
513 status: ExperimentRun['status']
514 randomized: boolean
515 today_zh: string | null
516 threshold_zh: string
517 primary_zh: string
518 progress_zh: string
519 result: RunResult | null
520 /** Today, for the folded pane's line. */
521 today_day: IsoDay
522}
523
524export interface CodexView {
525 ok: true
526 today: IsoDay
527 needs_consent: boolean
528 enabled: boolean
529 reason: CodexBlock
530 reason_zh: string
531 member: null | { label_zh: string; note_zh: string }
532 started: boolean
533 intro: { my_day: { start: string; end: string }; season_mode: '8w' | 'retest'; standup: boolean; wristband: boolean }
534 season: null | { id: string; mode: '8w' | 'retest'; start: IsoDay; end: IsoDay; week: number; weeks: number; status: 'active' | 'closed' }
535 packs: Array<{ id: string; kind: Pack['kind']; source_zh: string; granted: IsoDay; opened: IsoDay | null; options: ExperimentOption[]; results: ResultCard[]; empty_zh: string | null }>
536 reserve: ExperimentOption[]
537 running: RunView[]
538 ready: RunView[]
539 deck: RunView[]
540 library: { size: number; read: number; pending: number; chapters: Array<{ id: string; no: number; title_zh: string; size: number; read: number }> }
541 species: { met: number; total: number }
542 footprints: Footprint[]
543 footprints_note_zh: string
544 prefs: { simple: boolean; presentation: boolean; my_day: { start: string; end: string }; season_mode: '8w' | 'retest'; standup: boolean }
545 devices: { wristband: boolean; bp_cuff: boolean; scale: boolean }
546 slot: SlotView
547 /** The page's in-flow line: 「饭后走 10 分钟 · 第 9/14 天」. No result, no metric value. */
548 pane_zh: string | null
549 /** The folded pane's line: no experiment name either (a name like 降脂 or 量血压 says too much on a shared screen). */
550 pane_neutral_zh: string | null
551 rules_zh: string[]
552}
553
554const RULES_ZH = [
555 '长寿图鉴免费,没有付费,不能交易。',
556 '未满 18 岁不开放,可以随时关闭。',
557 '卡包只来自真实发生的事:赛季开始、做完一个实验、你自己复查。',
558 '颜色表示研究是怎么做的,不表示和你多相关。',
559 '结果只用三种说法:「超出平时波动」「在平时波动内」「数据不够」。',
560]
561
562function optionOf(spec: ExperimentSpec, primary: MetricKey | null, leadIn = false): ExperimentOption {
563 const { metrics } = loadCatalog()
564 const main = primary ?? spec.primary[0]
565 const also = [...spec.primary.filter((key) => key !== main), ...spec.also].filter((key, i, all) => all.indexOf(key) === i)
566 return {
567 id: spec.id,
568 title_zh: spec.title_zh,
569 do_zh: spec.do_zh,
570 icon: spec.icon,
571 days: spec.days,
572 primary_zh: main ? metrics[main]?.label_zh ?? '' : '',
573 also_zh: also.map((key) => metrics[key]?.label_zh ?? '').filter(Boolean),
574 randomizable: spec.randomizable,
575 questions: spec.questions.map((row) => ({ id: row.id, text_zh: row.text_zh })),
576 source_zh: spec.source_zh,
577 needs_retest: Boolean(spec.retest_markers?.length) && spec.days > 14,
578 lead_in: leadIn,
579 }
580}
581
582function runView(run: ExperimentRun, state: State, cache: SeriesCache, today: IsoDay): RunView {
583 const { metrics } = loadCatalog()
584 const total = daysBetween(run.start, run.end) + 1
585 const day = Math.min(total, Math.max(1, daysBetween(run.start, today) + 1))
586 const done = doneDays(run, state, cache, runDays(run, today))
587 const assignment = run.randomized && run.schedule ? run.schedule[today] : undefined
588 const waiting = run.status === 'retest_wait'
589 return {
590 id: run.id,
591 experiment_id: run.experiment_id,
592 title_zh: run.title_zh,
593 do_zh: run.do_zh,
594 icon: run.icon,
595 checkin_zh: run.checkin_zh,
596 start: run.start,
597 end: run.end,
598 extended_to: run.extended_to,
599 day,
600 days: total,
601 cells: cells(run, state, cache, today),
602 done_today: done.has(today),
603 done_count: done.size,
604 status: run.status,
605 randomized: run.randomized,
606 today_zh: assignment === undefined ? null : assignment ? '今天:做' : '今天:不做(照常生活)',
607 threshold_zh: run.threshold_zh,
608 primary_zh: metrics[run.primary]?.label_zh ?? '',
609 progress_zh: waiting ? `${run.title_zh} · 等复查` : run.status === 'ready' ? `${run.title_zh} · 可以翻了`
610 : today < run.start ? `${run.title_zh} · 先量 7 天 · 第 ${Math.max(1, daysBetween(run.baseline.from, today) + 1)}/7 天`
611 : `${run.title_zh} · 第 ${day}/${total} 天`,
612 result: run.status === 'revealed' ? run.result : null,
613 today_day: today,
614 }
615}
616
617function libraryCounts(state: State): CodexView['library'] {
618 const lib = loadLibrary()
619 return {
620 size: lib.studies.length,
621 read: lib.studies.filter((card) => state.read[card.id]).length,
622 pending: lib.pending.length,
623 chapters: lib.chapters.map((chapter) => ({
624 id: chapter.id,
625 no: chapter.no,
626 title_zh: chapter.title_zh,
627 size: lib.studies.filter((card) => card.chapter === chapter.id).length,
628 read: lib.studies.filter((card) => card.chapter === chapter.id && state.read[card.id]).length,
629 })),
630 }
631}
632
633function memberNote(): CodexView['member'] {
634 const root = boundRootDir()
635 const active = runtime.dataDir()
636 if (!root || !active || active === root) return null
637 let label = '家人'
638 try {
639 const reg = JSON.parse(readFileSync(join(root, 'people.json'), 'utf8')) as { active?: string; people?: Array<{ id: string; label_zh?: string }> }
640 label = reg.people?.find((row) => row.id === reg.active)?.label_zh || label
641 } catch { /* the default label */ }
642 return { label_zh: label, note_zh: `长寿图鉴是你自己的。陪${label}看医生或复查,会记一张足迹卡。` }
643}
644
645function viewOf(state: State, world: World, cache: SeriesCache): CodexView {
646 const block = blockOf(state, world)
647 const today = world.today
648 const have = devices(cache, today)
649 const ctx = eligibilityContext(state, today, cache)
650 const options = (ids: string[]) => ids.flatMap((id) => {
651 const spec = experimentById(id)
652 if (!spec) return []
653 const ready = primaryFor(spec, ctx)
654 return [optionOf(spec, ready ?? leadInFor(spec, ctx), !ready && Boolean(leadInFor(spec, ctx)))]
655 })
656 const runs = state.runs.map((run) => runView(run, state, cache, today))
657 const season = state.season
658 const ready = runs.filter((run) => run.status === 'ready')
659 const packsReady = state.packs.filter((pack) => pack.kind === 'retest' && !pack.opened)
660 const slot = slotView({
661 now: world.now,
662 enabled: !block && Boolean(state.started),
663 wristband: have.wristband,
664 presentation: state.prefs.presentation,
665 myDay: state.prefs.my_day,
666 nudge: state.nudge,
667 ready: [
668 ...state.runs.filter((run) => run.status === 'ready').map((run) => ({ ref: run.id, since: run.result?.at?.slice(0, 10) ?? today })),
669 ...packsReady.map((pack) => ({ ref: pack.id, since: pack.granted })),
670 ],
671 })
672 const active = runs.filter((run) => run.status === 'running' || run.status === 'retest_wait' || run.status === 'ready')
673 const shown = !block && world.consent
674 return {
675 ok: true,
676 today,
677 needs_consent: !world.consent,
678 enabled: shown,
679 reason: block,
680 reason_zh: !world.consent ? '先完成使用说明里的同意,再打开长寿图鉴。' : codexBlockZh(block),
681 member: memberNote(),
682 started: Boolean(state.started),
683 intro: { my_day: { start: state.prefs.my_day.start, end: state.prefs.my_day.end }, season_mode: state.prefs.season_mode, standup: state.nudge.standup === 'on', wristband: have.wristband },
684 season: shown && season ? {
685 id: season.id,
686 mode: season.mode,
687 start: season.start,
688 end: season.end,
689 week: Math.min(Math.ceil((daysBetween(season.start, season.end) + 1) / 7), Math.floor(daysBetween(season.start, today) / 7) + 1),
690 weeks: Math.ceil((daysBetween(season.start, season.end) + 1) / 7),
691 status: season.status,
692 } : null,
693 packs: shown ? state.packs.filter((pack) => !pack.opened || (pack.kind === 'experiment' && !pack.chosen)).map((pack) => ({
694 id: pack.id,
695 kind: pack.kind,
696 source_zh: pack.source_zh,
697 granted: pack.granted,
698 opened: pack.opened,
699 options: pack.opened ? options(pack.options) : [],
700 results: pack.opened ? pack.results : [],
701 empty_zh: pack.opened && pack.kind === 'experiment' && pack.options.length === 0 ? emptyPackZh(ctx) : null,
702 })) : [],
703 reserve: shown ? options(state.reserve.filter((id) => {
704 const spec = experimentById(id)
705 return spec ? !blockedBy(spec, ctx) : false
706 })) : [],
707 running: shown ? active.filter((run) => run.status !== 'ready') : [],
708 ready: shown ? ready : [],
709 deck: shown ? runs.filter((run) => run.status === 'revealed').reverse() : [],
710 library: libraryCounts(state),
711 species: { met: Object.keys(state.met).length, total: loadLibrary().species.length },
712 footprints: shown ? [...state.footprints].reverse() : [],
713 footprints_note_zh: FOOTPRINT_NOTE_ZH,
714 prefs: { simple: state.prefs.simple, presentation: state.prefs.presentation, my_day: { start: state.prefs.my_day.start, end: state.prefs.my_day.end }, season_mode: state.prefs.season_mode, standup: state.nudge.standup === 'on' },
715 devices: have,
716 slot,
717 pane_zh: shown && active.length > 0 ? active.map((run) => run.progress_zh).join(';') : null,
718 pane_neutral_zh: shown ? neutralLine(active) : null,
719 rules_zh: RULES_ZH,
720 }
721}
722
723function neutralLine(active: RunView[]): string | null {
724 if (active.length === 0) return null
725 const ready = active.filter((run) => run.status === 'ready').length
726 const running = active.filter((run) => run.status === 'running')
727 const parts: string[] = []
728 if (running.length === 1) parts.push(running[0]!.start > running[0]!.today_day ? '1 个实验正在量对照' : `1 个实验进行中 · 第 ${running[0]!.day}/${running[0]!.days} 天`)
729 else if (running.length > 1) parts.push(`${running.length} 个实验进行中`)
730 if (ready > 0) parts.push('有一张卡可以翻了')
731 if (active.some((run) => run.status === 'retest_wait')) parts.push('1 个实验等复查')
732 return parts.join(' · ') || null
733}
734
735function emptyPackZh(ctx: EligibilityContext): string {
736 const have = devices(ctx.series, ctx.today)
737 if (ctx.pregnant) return '孕期不出现实验。图书馆照常可以读。'
738 if (!have.wristband && !have.bp_cuff && !have.scale) return '实验要用手环、血压计或体重秤的数据来看结果,现在还没有。把 Apple 健康的导出(iPhone「健康」→ 头像 → 导出所有健康数据)或其他手环的导出文件交给 Claude,这个包里就会有实验。包会一直留着。'
739 return '现在没有适合你的实验。包会一直留着,数据多了再来拆。'
740}
741
742// ---- reading and acting --------------------------------------------------------
743
744function load(now: Date): { root: string; state: State; world: World; cache: SeriesCache } {
745 const root = boundRootDir()
746 const world = worldOf(root, now)
747 const state = root ? readState(root, now, world.today) : emptyState()
748 const cache = readSeriesCache(root)
749 return { root, state, world, cache }
750}
751
752export function syncCodex(now: Date = new Date()): CodexView {
753 const { root, state, world, cache } = load(now)
754 if (!root) return viewOf(state, world, cache)
755 reduce(state, world, cache)
756 saveState(root, state)
757 return viewOf(state, world, cache)
758}
759
760export type CodexAction =
761 | { action: 'start'; my_day?: { start?: string; end?: string }; season_mode?: '8w' | 'retest'; standup?: boolean }
762 | { action: 'prefs'; simple?: boolean; presentation?: boolean; my_day?: { start?: string; end?: string }; season_mode?: '8w' | 'retest'; standup?: boolean; codex?: boolean }
763 | { action: 'open_pack'; pack_id: string }
764 | { action: 'begin'; experiment_id: string; pack_id?: string; answers?: Record<string, boolean>; randomized?: boolean }
765 | { action: 'checkin'; run_id: string; done?: boolean }
766 | { action: 'reveal'; run_id: string }
767 | { action: 'stop'; run_id: string }
768 | { action: 'read'; card_id: string }
769 | { action: 'next_season' }
770 | { action: 'nudge'; event: 'shown' | 'ok' | 'dismiss_today' | 'reveal_shown' | 'reveal_later' | 'reveal_open'; ref?: string }
771
772export interface ActResult { ok: boolean; error?: string; note_zh?: string; met?: string[]; run?: RunView; pack?: { id: string; kind: Pack['kind']; source_zh: string; results: ResultCard[] }; view: CodexView }
773
774function setMyDay(state: State, input: { start?: string; end?: string } | undefined): void {
775 if (!input) return
776 if (validClock(input.start)) state.prefs.my_day.start = input.start
777 if (validClock(input.end)) state.prefs.my_day.end = input.end
778 state.prefs.my_day.asked = true
779}
780
781export function actCodex(input: CodexAction, now: Date = new Date()): ActResult {
782 const { root, state, world, cache } = load(now)
783 const done = (extra: Partial<ActResult> = {}): ActResult => {
784 reduce(state, world, cache)
785 if (root) saveState(root, state)
786 return { ok: true, ...extra, view: viewOf(state, world, cache) }
787 }
788 const fail = (error: string): ActResult => ({ ok: false, error, view: viewOf(state, world, cache) })
789 if (!root) return fail('还没有 LongPi 档案。')
790 if (input.action === 'prefs') {
791 if (typeof input.codex === 'boolean') state.choice = input.codex ? 'on' : 'off'
792 if (typeof input.simple === 'boolean') state.prefs.simple = input.simple
793 if (typeof input.presentation === 'boolean') state.prefs.presentation = input.presentation
794 if (input.season_mode === '8w' || input.season_mode === 'retest') state.prefs.season_mode = input.season_mode
795 if (typeof input.standup === 'boolean') state.nudge.standup = input.standup ? 'on' : 'off'
796 setMyDay(state, input.my_day)
797 return done()
798 }
799 if (input.action === 'nudge') return nudgeEvent(state, input, now, done)
800 if (!world.consent) return fail('先完成使用说明里的同意,再打开长寿图鉴。')
801 const block = blockOf(state, world)
802 if (block) return fail(codexBlockZh(block))
803 if (input.action === 'start') {
804 setMyDay(state, input.my_day ?? DEFAULT_MY_DAY)
805 if (input.season_mode === '8w' || input.season_mode === 'retest') state.prefs.season_mode = input.season_mode
806 state.nudge.standup = input.standup === true ? 'on' : input.standup === false ? 'off' : (world.memoryStandup ? 'on' : 'off')
807 if (!state.started) {
808 state.started = world.today
809 state.pressure = 'on'
810 if (state.context.latest_checkup && !state.seen.checkups.includes(state.context.latest_checkup)) state.seen.checkups.push(state.context.latest_checkup)
811 state.seen.labs = { ...state.context.labs }
812 state.seen.care = careItems(root).map((item) => item.id)
813 }
814 return done()
815 }
816 if (!state.started) return fail('先打开长寿图鉴,回答两个小问题。')
817 if (input.action === 'read') {
818 const card = studyById(input.card_id)
819 if (!card || card.id !== input.card_id) return fail('没有这张卡。')
820 const met: string[] = []
821 if (!state.read[card.id]) state.read[card.id] = world.today
822 for (const key of card.meet) {
823 if (!state.met[key]) { state.met[key] = world.today; met.push(key) }
824 }
825 return done({ met })
826 }
827 if (input.action === 'next_season') {
828 if (state.season?.status !== 'closed') return fail('这个赛季还没有结束。')
829 state.season = null
830 return done()
831 }
832 if (input.action === 'open_pack') {
833 const pack = state.packs.find((row) => row.id === input.pack_id)
834 if (!pack) return fail('没有这个包。')
835 if (pack.kind === 'experiment' && (!pack.opened || pack.options.length === 0)) {
836 const candidates = eligible(eligibilityContext(state, world.today, cache))
837 pack.options = pickThree(candidates, `${state.seed_hex}:${pack.id}`, new Set(state.reserve))
838 if (pack.options.length === 0) {
839 pack.opened = null
840 reduce(state, world, cache)
841 saveState(root, state)
842 return { ok: true, note_zh: emptyPackZh(eligibilityContext(state, world.today, cache)), view: viewOf(state, world, cache) }
843 }
844 }
845 pack.opened = pack.opened ?? world.today
846 if (pack.kind === 'retest') {
847 state.nudge.reveal[pack.id] = { first: state.nudge.reveal[pack.id]?.first ?? world.today, later: null, done: true }
848 for (const card of pack.results) {
849 const runId = card.key.startsWith('run:') ? card.key.slice(4) : null
850 const run = runId ? state.runs.find((row) => row.id === runId) : null
851 if (run && run.status === 'ready') revealRun(state, run, world)
852 }
853 return done({ pack: { id: pack.id, kind: pack.kind, source_zh: pack.source_zh, results: pack.results } })
854 }
855 return done()
856 }
857 if (input.action === 'begin') return begin(state, world, cache, input, done, fail)
858 const run = 'run_id' in input ? state.runs.find((row) => row.id === input.run_id) : undefined
859 if (!run) return fail('没有这个实验。')
860 if (input.action === 'checkin') {
861 if (run.status !== 'running' || world.today < run.start || world.today > (run.extended_to ?? run.end)) return fail('这个实验今天不能打卡。')
862 const on = input.done !== false
863 run.done = on ? [...new Set([...run.done, world.today])] : run.done.filter((day) => day !== world.today)
864 return done()
865 }
866 if (input.action === 'stop') {
867 if (run.status !== 'running' && run.status !== 'retest_wait') return fail('这个实验已经结束。')
868 run.status = 'stopped'
869 return done({ note_zh: '已停下,不算失败,也不扣任何东西。' })
870 }
871 if (input.action === 'reveal') {
872 if (run.status !== 'ready') return fail(run.status === 'revealed' ? '这张卡已经翻开了。' : '还没到揭晓的时候。')
873 revealRun(state, run, world)
874 return done({ run: runView(run, state, cache, world.today) })
875 }
876 return fail('不能识别的操作。')
877}
878
879function revealRun(state: State, run: ExperimentRun, world: World): void {
880 run.status = 'revealed'
881 run.revealed_at = world.now.toISOString() as IsoTime
882 state.nudge.reveal[run.id] = { first: state.nudge.reveal[run.id]?.first ?? world.today, later: null, done: true }
883 emit('codex.experiment_revealed', { run_id: run.id, experiment_id: run.experiment_id, outcome: run.result?.outcome ?? 'insufficient' })
884 if (state.runs.filter((row) => row.status === 'revealed').length === 1) addFootprint(state, 'first_experiment', world.today, `做完了第一个小实验:${run.title_zh}。`)
885 if (state.season?.status === 'active') grantPack(state, 'experiment', world.today, `做完「${run.title_zh}」`)
886}
887
888function begin(state: State, world: World, cache: SeriesCache, input: Extract<CodexAction, { action: 'begin' }>, done: (extra?: Partial<ActResult>) => ActResult, fail: (error: string) => ActResult): ActResult {
889 const pack = input.pack_id ? state.packs.find((row) => row.id === input.pack_id) : null
890 const fromPack = pack && pack.opened && pack.options.includes(input.experiment_id)
891 const fromReserve = state.reserve.includes(input.experiment_id)
892 if (!fromPack && !fromReserve) return fail('只能从拆开的实验包或待选里开始。')
893 if (state.runs.filter((run) => run.status === 'running').length >= MAX_RUNNING) return fail('同时最多做 2 个实验。先做完一个,或者停下一个。')
894 let spec = experimentById(input.experiment_id)
895 if (!spec) return fail('没有这个实验。')
896 const answers = input.answers ?? {}
897 let variant: string | null = null
898 for (const question of spec.questions) {
899 if (answers[question.id] !== true) continue
900 if (question.yes.swap) {
901 const swapped = experimentById(question.yes.swap)
902 if (swapped) { spec = swapped; break }
903 }
904 if (question.yes.variant && !variant) variant = question.yes.variant
905 }
906 const ctx = eligibilityContext(state, world.today, cache)
907 const blocked = blockedBy(spec, ctx)
908 if (blocked) return fail(blocked === 'no_data' ? '这个实验要用到开始前两周的数据,现在还不够。' : '这个实验现在不适合你。')
909 const ready = primaryFor(spec, ctx)
910 const primary = (ready ?? leadInFor(spec, ctx)) as MetricKey
911 const leadIn = !ready
912 const { metrics } = loadCatalog()
913 const randomized = input.randomized === true && spec.randomizable
914 const days = spec.days
915 // Too few readings before today: measure for 7 days first; those days are the baseline.
916 const start = leadIn ? addDays(world.today, 7) : world.today
917 const v = variant ? spec.variants[variant] : null
918 const run: ExperimentRun = {
919 id: newId('rn'),
920 experiment_id: spec.id,
921 variant,
922 title_zh: v?.title_zh ?? spec.title_zh,
923 do_zh: v?.do_zh ?? spec.do_zh,
924 checkin_zh: spec.checkin_zh,
925 icon: spec.icon,
926 primary,
927 also: [...spec.primary.filter((key) => key !== primary), ...spec.also].filter((key, i, all) => all.indexOf(key) === i && key !== primary),
928 threshold_zh: thresholdZh(metrics[primary], biovar(), { randomized, lab: metrics[primary].method === 'lab', leadIn }),
929 start,
930 end: addDays(start, days - 1),
931 extended_to: null,
932 baseline: leadIn ? { from: world.today, to: addDays(world.today, 6) } : { from: addDays(start, -14), to: addDays(start, -1) },
933 randomized,
934 schedule: randomized ? randomSchedule(start, days, `${state.seed_hex}:${start}:${spec.id}`) : null,
935 scope: v?.scope === 'weekdays' ? 'weekdays' : 'all',
936 answers,
937 done: [],
938 lab_before: metrics[primary].method === 'lab' ? state.context.ldl : null,
939 status: 'running',
940 result: null,
941 revealed_at: null,
942 }
943 state.runs.push(run)
944 const season = state.seasons.find((row) => row.id === state.season?.id)
945 if (season) season.runs.push(run.id)
946 if (fromPack && pack) {
947 pack.chosen = input.experiment_id
948 for (const other of pack.options) if (other !== input.experiment_id && !state.reserve.includes(other)) state.reserve.push(other)
949 }
950 state.reserve = state.reserve.filter((id) => id !== input.experiment_id && id !== spec!.id).slice(-6)
951 emit('codex.experiment_started', { run_id: run.id, experiment_id: spec.id, randomized })
952 const notes = [
953 spec.id !== input.experiment_id ? `按你的回答,换成了「${spec.title_zh}」。` : '',
954 leadIn ? '开始前两周量得还不多,先量 7 天当对照,再开始这 14 天。' : '',
955 ].filter(Boolean).join('')
956 return done({ run: runView(run, state, cache, world.today), note_zh: notes || undefined })
957}
958
959function nudgeEvent(state: State, input: Extract<CodexAction, { action: 'nudge' }>, now: Date, done: (extra?: Partial<ActResult>) => ActResult): ActResult {
960 const today = isoDay(now)
961 if (input.event === 'shown') {
962 state.nudge.shown.push({ at: now.toISOString() as IsoTime, kind: 'standup' })
963 emit('nudge.shown', { nudge_id: newId('ng'), where: 'overlay', kind: 'standup' })
964 } else if (input.event === 'ok') {
965 state.nudge.acks.push({ at: now.toISOString() as IsoTime, status: 'pending' })
966 } else if (input.event === 'dismiss_today') {
967 state.nudge.dismissed_day = today
968 } else if (input.event === 'reveal_shown' && input.ref) {
969 markRevealShown(state.nudge, input.ref, now)
970 emit('nudge.shown', { nudge_id: newId('ng'), where: 'overlay', kind: 'reveal' })
971 } else if (input.event === 'reveal_later' && input.ref) {
972 laterReveal(state.nudge, input.ref, today)
973 } else if (input.event === 'reveal_open' && input.ref) {
974 const prev = state.nudge.reveal[input.ref]
975 state.nudge.reveal[input.ref] = { first: prev?.first ?? today, later: null, done: true }
976 }
977 return done()
978}
979
980// ---- seams for the rest of LongPi ---------------------------------------------
981
982/** From the journey: what the Codex needs of the holder's record, or a family member's visits and checkups. */
983export function noteCodexContext(dataDir: string, context: Omit<CodexContext, 'at'> & { care_visits?: Array<{ id: string; date: IsoDay | null; with_brief: boolean }> }, now: Date = new Date()): void {
984 const root = boundRootDir()
985 if (!root || !dataDir) return
986 const world = worldOf(root, now)
987 const state = readState(root, now, world.today)
988 if (dataDir !== root) {
989 // A family member: footprints only, never a pack.
990 const member = memberNote()
991 const label = member?.label_zh ?? '家人'
992 let changed = false
993 const memberId = dataDir.split('/').filter(Boolean).at(-1) ?? 'member'
994 if (state.started && !blockOf(state, world)) {
995 for (const visit of context.care_visits ?? []) {
996 const key = `${memberId}:care:${visit.id}`
997 if (!visit.with_brief || state.seen.member.includes(key)) continue
998 state.seen.member.push(key)
999 if ((visit.date ?? world.today) >= state.started) addFootprint(state, 'family', visit.date ?? world.today, `带着简报陪${label}看了医生。`, `陪${label}复查`)
1000 changed = true
1001 }
1002 if (context.latest_checkup) {
1003 const key = `${memberId}:checkup:${context.latest_checkup}`
1004 const known = state.seen.member.some((row) => row.startsWith(`${memberId}:checkup:`))
1005 if (!state.seen.member.includes(key)) {
1006 state.seen.member.push(key)
1007 if (known && context.latest_checkup >= state.started) addFootprint(state, 'family', world.today, `${label}的新检查结果进了档案。`, `陪${label}复查`)
1008 changed = true
1009 }
1010 }
1011 }
1012 if (changed) saveState(root, state)
1013 return
1014 }
1015 const { care_visits: _visits, ...rest } = context
1016 state.context = { ...rest, at: now.toISOString() as IsoTime }
1017 if (!state.started) {
1018 // Before the first open, remember what is already on file so nothing back-dated becomes a pack or footprint.
1019 if (context.latest_checkup && !state.seen.checkups.includes(context.latest_checkup)) state.seen.checkups.push(context.latest_checkup)
1020 state.seen.labs = { ...context.labs }
1021 }
1022 reduce(state, world, readSeriesCache(root))
1023 saveState(root, state)
1024}
1025
1026/** A sick or travel day: not an experiment day, never a failure. */
1027export function logLifeCodex(input: { event: string; from: IsoDay; to: IsoDay | null }, now: Date = new Date()): { ok: boolean; error?: string; days: IsoDay[] } {
1028 const root = boundRootDir()
1029 if (!root) return { ok: false, error: '还没有 LongPi 档案。', days: [] }
1030 const to = input.to ?? input.from
1031 if (!/^\d{4}-\d{2}-\d{2}$/.test(input.from) || !/^\d{4}-\d{2}-\d{2}$/.test(to) || to < input.from) return { ok: false, error: '日期格式须为 YYYY-MM-DD,且结束日期不早于开始日期。', days: [] }
1032 if (daysBetween(input.from, to) > 13) return { ok: false, error: '一次最多记 14 天。', days: [] }
1033 const world = worldOf(root, now)
1034 const state = readState(root, now, world.today)
1035 const days: IsoDay[] = []
1036 for (let day = input.from; day <= to; day = addDays(day, 1)) {
1037 if (day > world.today) break
1038 if (!state.life.some((row) => row.day === day)) { state.life.push({ day, reason: input.event }); days.push(day) }
1039 }
1040 state.life = state.life.slice(-120)
1041 saveState(root, state)
1042 emit('life_event.logged', { memory_id: newId('lf'), event: input.event as HealthEventPayloads['life_event.logged']['event'], from: input.from, to: input.to })
1043 return { ok: true, days }
1044}
1045
1046export function engagementSummary(): FactPack['engagement'] {
1047 try {
1048 const view = syncCodex(new Date())
1049 if (!view.enabled || !view.started) return null
1050 return {
1051 season_week: view.season?.week ?? null,
1052 season_weeks: view.season?.weeks ?? null,
1053 experiments_running: view.running.map((run) => run.title_zh),
1054 reveal_ready: view.ready.length > 0 || view.packs.some((pack) => pack.kind === 'retest'),
1055 packs_waiting: view.packs.length,
1056 }
1057 } catch {
1058 return null
1059 }
1060}
1061
1062export function candidateSeeds(now: Date = new Date()): Array<{ id: string; kind: ActionKind; priority: number; title_zh: string; detail_zh: string; prompt_zh: string }> {
1063 let view: CodexView
1064 try { view = syncCodex(now) } catch { return [] }
1065 if (!view.enabled || !view.started) return []
1066 const out: Array<{ id: string; kind: ActionKind; priority: number; title_zh: string; detail_zh: string; prompt_zh: string }> = []
1067 if (view.ready.length > 0 || view.packs.some((pack) => pack.kind === 'retest')) {
1068 out.push({ id: 'nba-codex-reveal', kind: 'codex_reveal', priority: 40, title_zh: '有一张实验卡可以翻了', detail_zh: '在长寿图鉴里翻开它。', prompt_zh: '我的实验结果出来了吗?' })
1069 }
1070 if (view.packs.some((pack) => pack.kind === 'experiment' && !pack.opened)) {
1071 out.push({ id: 'nba-codex-pack', kind: 'codex_experiment', priority: 30, title_zh: '有一个实验包可以拆', detail_zh: '三选一,挑一个两周的小实验。', prompt_zh: '长寿图鉴里的三个实验,哪个适合我?' })
1072 }
1073 return out
1074}
1075
1076/** The weekly plain reminder had season quests; the Codex sends no reminders of its own (design §2). */
1077export function plainReminderOf(_dataDir: string): string | null {
1078 return null
1079}
1080
1081/**
1082 * After a journey build of the holder: re-read the daily series the experiments are judged on, settle 好 presses
1083 * against raw steps, and bring the Codex up to date. Never for a family member's record.
1084 */
1085export async function refreshCodex(input: { config: Config; dataDir: string; present: Iterable<string>; now?: Date; force?: boolean }): Promise<void> {
1086 const root = boundRootDir()
1087 if (!root || input.dataDir !== root) return
1088 const now = input.now ?? new Date()
1089 const state = readState(root, now, isoDay(now))
1090 if (!state.started) return
1091 await refreshSeries({ config: input.config, dataDir: root, present: input.present, now, force: input.force })
1092 const pending = state.nudge.acks.filter((row) => row.status === 'pending')
1093 if (pending.length > 0) {
1094 const fresh = readState(root, now, isoDay(now))
1095 for (const day of [...new Set(pending.map((row) => isoDay(new Date(row.at))))]) {
1096 const samples = await stepsOnDay(input.config, day)
1097 settleAcks(fresh.nudge, samples, now, day)
1098 }
1099 saveState(root, fresh)
1100 }
1101 syncCodex(now)
1102}
1103
1104/** Today's civil hour, for tests of 我的白天. */
1105export function civilHour(now: Date): number {
1106 return civilParts(now).hour
1107}
1108hooks/core/profile.ts 344 lines1import { process } from '../sys/process.ts'
2import { randomBytes } from '../sys/crypto.ts'
3import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '../sys/fs.ts'
4import { dirname, join } from '../sys/path.ts'
5
6export const SEXES = ['female', 'male', 'other', 'unknown'] as const
7export type Sex = (typeof SEXES)[number]
8
9/**
10 * Yes/no facts a risk equation needs and a record does not hold (China-PAR).
11 * The person states them; absent means not stated, never "no".
12 */
13export const RISK_FACTS = ['smoker', 'diabetes', 'bp_treated', 'north', 'urban', 'family_history'] as const
14export type RiskFact = (typeof RISK_FACTS)[number]
15export const RISK_FACT_ZH: Record<RiskFact, string> = {
16 smoker: '现在吸烟',
17 diabetes: '有糖尿病(空腹血糖 ≥7.0 mmol/L 或在用降糖药)',
18 bp_treated: '两周内用过降压药',
19 north: '住在北方(长江以北)',
20 urban: '住在城市',
21 family_history: '父母或兄弟姐妹有心梗或脑卒中',
22}
23
24/** Bump when the first-run notice changes, so the person reads the new one before it counts as accepted. */
25export const CONSENT_VERSION = '2026-09-24'
26
27/** What the person cares about most, in their order: used to order results and suggestions. */
28export const FOCUS = ['bioage', 'cardio', 'glucose', 'weight', 'sleep', 'plan'] as const
29export type Focus = (typeof FOCUS)[number]
30export const FOCUS_ZH: Record<Focus, string> = {
31 bioage: '身体年龄',
32 cardio: '心血管',
33 glucose: '血糖',
34 weight: '体重',
35 sleep: '睡眠',
36 plan: '方案效果',
37}
38
39export interface Consent {
40 version: string
41 accepted_at: string
42}
43
44/** One separate act (PIPL sensitive information, DeepSeek data flow, or session-log upload). */
45export interface PrivacyAct {
46 version: string
47 decision: 'granted' | 'declined' | 'withdrawn'
48 at: string
49}
50
51/** Mirrors of privacy/consents.jsonl. The log is the source of truth; this is what a profile read shows. */
52export interface ProfileConsents {
53 pipl_sensitive: PrivacyAct | null
54 data_flow_deepseek: PrivacyAct | null
55 session_log_upload: PrivacyAct | null
56}
57
58export function emptyConsents(): ProfileConsents {
59 return { pipl_sensitive: null, data_flow_deepseek: null, session_log_upload: null }
60}
61
62export interface Profile {
63 displayName: string
64 birthYear: number | null
65 age: number | null
66 sex: Sex
67 risk: Partial<Record<RiskFact, boolean>>
68 /** Facts they explicitly called 不确定. Missing means never asked, and is not stored as false. */
69 riskUnknown?: RiskFact[]
70 focus: Focus[]
71 /** The first-run product notice. Not the PIPL sensitive-information act. */
72 consent: Consent | null
73 /** Separate acts. Absent on a file written before 0.5.x privacy means none of them is decided. */
74 consents: ProfileConsents
75 /** Set when the labs belong to someone else (a parent). Calculators use this age and sex. */
76 subject?: { relationship_zh: string; age: number | null; sex: Sex } | null
77}
78
79export const EMPTY_PROFILE: Profile = {
80 displayName: '',
81 birthYear: null,
82 age: null,
83 sex: 'unknown',
84 risk: {},
85 focus: [],
86 consent: null,
87 consents: emptyConsents(),
88}
89
90function emptyProfile(): Profile {
91 return { ...EMPTY_PROFILE, risk: {}, focus: [], consents: emptyConsents() }
92}
93
94type Failure = { ok: false; error: string }
95type IntResult = { ok: true; value: number | null } | Failure
96
97function optionalInt(value: unknown, min: number, max: number, label: string): IntResult {
98 if (value == null || value === '') return { ok: true, value: null }
99 const number = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() ? Number(value) : Number.NaN)
100 if (!Number.isInteger(number) || number < min || number > max) {
101 return { ok: false, error: `${label} must be an integer from ${min} to ${max}` }
102 }
103 return { ok: true, value: number }
104}
105
106export function normalizeProfile(input: unknown): { ok: true; profile: Profile } | Failure {
107 if (!input || typeof input !== 'object' || Array.isArray(input)) {
108 return { ok: false, error: 'profile must be an object' }
109 }
110 const raw = input as Record<string, unknown>
111 for (const key of Object.keys(raw)) {
112 if (!['displayName', 'birthYear', 'age', 'sex', 'risk', 'riskUnknown', 'focus', 'consent', 'consents', 'subject'].includes(key)) {
113 return { ok: false, error: `unknown field ${key}` }
114 }
115 }
116 let displayName = ''
117 if (raw.displayName != null && raw.displayName !== '') {
118 if (typeof raw.displayName !== 'string') return { ok: false, error: 'displayName must be a string' }
119 displayName = raw.displayName.trim()
120 if (displayName.length > 40) return { ok: false, error: 'displayName is longer than 40 characters' }
121 if (/[\u0000-\u001f]/.test(displayName)) return { ok: false, error: 'displayName has control characters' }
122 }
123 const birthYear = optionalInt(raw.birthYear, 1900, 2100, 'birthYear')
124 if (!birthYear.ok) return birthYear
125 const age = optionalInt(raw.age, 0, 130, 'age')
126 if (!age.ok) return age
127 let sex: Sex = 'unknown'
128 if (raw.sex != null && raw.sex !== '') {
129 if (typeof raw.sex !== 'string' || !SEXES.includes(raw.sex as Sex)) {
130 return { ok: false, error: 'sex must be female, male, other, or unknown' }
131 }
132 sex = raw.sex as Sex
133 }
134 const risk: Profile['risk'] = {}
135 if (raw.risk != null) {
136 if (typeof raw.risk !== 'object' || Array.isArray(raw.risk)) return { ok: false, error: 'risk must be an object of yes/no facts' }
137 for (const [key, value] of Object.entries(raw.risk as Record<string, unknown>)) {
138 if (!(RISK_FACTS as readonly string[]).includes(key)) return { ok: false, error: `unknown risk fact ${key}` }
139 if (value == null || value === '') continue
140 if (typeof value !== 'boolean') return { ok: false, error: `${key} must be true, false, or empty` }
141 risk[key as RiskFact] = value
142 }
143 }
144 const riskUnknown = riskUnknownOf(raw.riskUnknown)
145 if (!riskUnknown.ok) return riskUnknown
146 const focus = focusOf(raw.focus)
147 if (!focus.ok) return focus
148 const consent = consentOf(raw.consent)
149 if (!consent.ok) return consent
150 const consents = consentsOf(raw.consents)
151 if (!consents.ok) return consents
152 // 0.8.0: whose record this is never comes from the profile any more (each family member has a record of their
153 // own). A subject written by 0.7 from a sentence about 我妈 / 我爸 is accepted and dropped, so it is gone on read.
154 const subject = subjectOf(raw.subject)
155 if (!subject.ok) return subject
156 return {
157 ok: true,
158 profile: {
159 displayName, birthYear: birthYear.value, age: age.value, sex, risk, focus: focus.value, consent: consent.value, consents: consents.value,
160 ...(riskUnknown.value.length > 0 ? { riskUnknown: riskUnknown.value } : {}),
161 },
162 }
163}
164
165function subjectOf(value: unknown): { ok: true; value: Profile['subject'] } | Failure {
166 if (value == null) return { ok: true, value: null }
167 if (typeof value !== 'object' || Array.isArray(value)) return { ok: false, error: 'subject must be an object' }
168 const raw = value as Record<string, unknown>
169 const relationship = typeof raw.relationship_zh === 'string' ? raw.relationship_zh.trim() : ''
170 if (!relationship || relationship.length > 20) return { ok: false, error: 'subject.relationship_zh is required' }
171 const age = optionalInt(raw.age, 0, 130, 'subject.age')
172 if (!age.ok) return age
173 let sex: Sex = 'unknown'
174 if (raw.sex != null && raw.sex !== '') {
175 if (typeof raw.sex !== 'string' || !SEXES.includes(raw.sex as Sex)) return { ok: false, error: 'subject.sex must be female, male, other, or unknown' }
176 sex = raw.sex as Sex
177 }
178 return { ok: true, value: { relationship_zh: relationship, age: age.value, sex } }
179}
180
181function riskUnknownOf(value: unknown): { ok: true; value: RiskFact[] } | Failure {
182 if (value == null || value === '') return { ok: true, value: [] }
183 if (!Array.isArray(value)) return { ok: false, error: 'riskUnknown must be a list of risk facts' }
184 const out: RiskFact[] = []
185 for (const item of value) {
186 if (typeof item !== 'string' || !(RISK_FACTS as readonly string[]).includes(item)) return { ok: false, error: `unknown risk fact ${String(item)}` }
187 if (!out.includes(item as RiskFact)) out.push(item as RiskFact)
188 }
189 return { ok: true, value: out }
190}
191
192function focusOf(value: unknown): { ok: true; value: Focus[] } | Failure {
193 if (value == null || value === '') return { ok: true, value: [] }
194 if (!Array.isArray(value)) return { ok: false, error: `focus must be a list of ${FOCUS.join(', ')}` }
195 const out: Focus[] = []
196 for (const item of value) {
197 if (typeof item !== 'string' || !(FOCUS as readonly string[]).includes(item)) {
198 return { ok: false, error: `unknown focus ${String(item)}; use ${FOCUS.join(', ')}` }
199 }
200 if (!out.includes(item as Focus)) out.push(item as Focus)
201 }
202 return { ok: true, value: out.slice(0, FOCUS.length) }
203}
204
205function consentOf(value: unknown): { ok: true; value: Consent | null } | Failure {
206 if (value == null) return { ok: true, value: null }
207 if (typeof value !== 'object' || Array.isArray(value)) return { ok: false, error: 'consent must be null or {version, accepted_at}' }
208 const { version, accepted_at: acceptedAt } = value as Record<string, unknown>
209 if (typeof version !== 'string' || !version.trim()) return { ok: false, error: 'consent.version must be a non-empty string' }
210 if (typeof acceptedAt !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(acceptedAt) || Number.isNaN(Date.parse(acceptedAt))) {
211 return { ok: false, error: 'consent.accepted_at must be an ISO date-time' }
212 }
213 return { ok: true, value: { version: version.trim(), accepted_at: acceptedAt } }
214}
215
216const PRIVACY_SCOPES = ['pipl_sensitive', 'data_flow_deepseek', 'session_log_upload'] as const
217const PRIVACY_DECISIONS = ['granted', 'declined', 'withdrawn'] as const
218
219function actOf(value: unknown, label: string): { ok: true; value: PrivacyAct | null } | Failure {
220 if (value == null) return { ok: true, value: null }
221 if (typeof value !== 'object' || Array.isArray(value)) return { ok: false, error: `${label} must be null or {version, decision, at}` }
222 const raw = value as Record<string, unknown>
223 if (typeof raw.version !== 'string' || !raw.version.trim()) return { ok: false, error: `${label}.version must be a non-empty string` }
224 if (typeof raw.decision !== 'string' || !(PRIVACY_DECISIONS as readonly string[]).includes(raw.decision)) {
225 return { ok: false, error: `${label}.decision must be granted, declined, or withdrawn` }
226 }
227 if (typeof raw.at !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(raw.at) || Number.isNaN(Date.parse(raw.at))) {
228 return { ok: false, error: `${label}.at must be an ISO date-time` }
229 }
230 return { ok: true, value: { version: raw.version.trim(), decision: raw.decision as PrivacyAct['decision'], at: raw.at } }
231}
232
233/** A missing or unreadable block is "not decided", so an older profile file still loads. */
234function consentsOf(value: unknown): { ok: true; value: ProfileConsents } | Failure {
235 const empty = emptyConsents()
236 if (value == null) return { ok: true, value: empty }
237 if (typeof value !== 'object' || Array.isArray(value)) return { ok: false, error: 'consents must be an object' }
238 const raw = value as Record<string, unknown>
239 for (const key of Object.keys(raw)) {
240 if (!(PRIVACY_SCOPES as readonly string[]).includes(key)) return { ok: false, error: `unknown consent scope ${key}` }
241 }
242 for (const scope of PRIVACY_SCOPES) {
243 const act = actOf(raw[scope], `consents.${scope}`)
244 if (!act.ok) return act
245 empty[scope] = act.value
246 }
247 return { ok: true, value: empty }
248}
249
250/**
251 * Apply a partial update: fields that are absent keep their saved value; a risk fact set to null is cleared.
252 * consent in the update is ignored: only the person accepts the notice, through setConsent.
253 */
254export function mergeProfile(current: Profile, update: Record<string, unknown>): Record<string, unknown> {
255 const merged: Record<string, unknown> = { ...current, risk: { ...current.risk }, focus: [...current.focus] }
256 const unknown = new Set<RiskFact>(current.riskUnknown ?? [])
257 for (const key of ['displayName', 'birthYear', 'age', 'sex', 'focus', 'subject'] as const) {
258 if (key in update) merged[key] = update[key]
259 }
260 if (update.risk && typeof update.risk === 'object' && !Array.isArray(update.risk)) {
261 const risk = merged.risk as Record<string, unknown>
262 for (const [key, value] of Object.entries(update.risk as Record<string, unknown>)) {
263 if (!(RISK_FACTS as readonly string[]).includes(key)) continue
264 const fact = key as RiskFact
265 if (value == null || value === '') {
266 delete risk[key]
267 unknown.add(fact)
268 } else {
269 risk[key] = value
270 unknown.delete(fact)
271 }
272 }
273 }
274 if (unknown.size > 0) merged.riskUnknown = [...unknown]
275 else delete merged.riskUnknown
276 return merged
277}
278
279export function estimatedAge(birthYear: number | null, nowYear: number): number | null {
280 if (birthYear == null) return null
281 const age = nowYear - birthYear
282 if (age < 0 || age > 130) return null
283 return age
284}
285
286function profilePath(dataDir: string): string {
287 return join(dataDir, 'profile.json')
288}
289
290export function readProfile(dataDir: string): Profile {
291 const path = profilePath(dataDir)
292 if (!existsSync(path)) return emptyProfile()
293 try {
294 const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
295 const normalized = normalizeProfile(parsed)
296 return normalized.ok ? normalized.profile : emptyProfile()
297 } catch {
298 return emptyProfile()
299 }
300}
301
302export const PROFILE_DAMAGED = 'profile.json is damaged and was not overwritten'
303
304/** True when a profile file exists but cannot be read back as a profile. */
305function profileDamaged(dataDir: string): boolean {
306 const path = profilePath(dataDir)
307 if (!existsSync(path)) return false
308 try {
309 const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
310 return !normalizeProfile(parsed).ok
311 } catch {
312 return true
313 }
314}
315
316export function writeProfile(dataDir: string, profile: Profile): void {
317 const path = profilePath(dataDir)
318 // A torn or rejected file still holds the last good bytes. Replacing it with an empty merge loses them.
319 if (profileDamaged(dataDir)) throw new Error(PROFILE_DAMAGED)
320 mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
321 const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
322 writeFileSync(tmp, `${JSON.stringify(profile, null, 2)}\n`, { mode: 0o600 })
323 chmodSync(tmp, 0o600)
324 renameSync(tmp, path)
325}
326
327/** Record that the person accepted (or withdrew from) the current first-run notice. Never called on the model's word. */
328export function setConsent(dataDir: string, accept: boolean, now: Date = new Date()): Consent | null {
329 const consent = accept ? { version: CONSENT_VERSION, accepted_at: now.toISOString() } : null
330 writeProfile(dataDir, { ...readProfile(dataDir), consent })
331 return consent
332}
333
334/** The privacy screen stated an age. Does not touch the first-run notice or the separate acts. */
335export function setStatedAge(dataDir: string, age: number): void {
336 writeProfile(dataDir, { ...readProfile(dataDir), age })
337}
338
339/** Mirror one separate act onto the profile. The append-only log remains the source of truth. */
340export function setPrivacyAct(dataDir: string, scope: keyof ProfileConsents, act: PrivacyAct | null): void {
341 const current = readProfile(dataDir)
342 writeProfile(dataDir, { ...current, consents: { ...current.consents, [scope]: act } })
343}
344hooks/core/paths.ts 80 lines1import { process } from '../sys/process.ts'
2import { existsSync, readFileSync } from '../sys/fs.ts'
3import { homedir } from '../sys/os.ts'
4import { basename, dirname, join, resolve } from '../sys/path.ts'
5import { fileURLToPath, libFile } from '../sys/url.ts'
6
7/** The mod's own folder (it holds data/ and skills/ as the npm package did). */
8function packageRoot(): string {
9 return dirname(dirname(libFile()))
10}
11
12/** Where LongPi looks for the method library, in order. The mod's session start loads the first that exists. */
13export function skillsHomeCandidates(configured: string): string[] {
14 return [
15 configured,
16 process.env.LONGEVITY_SKILLS_HOME ?? '',
17 join(homedir(), '.longpi', 'longevity-skills'),
18 join(homedir(), 'longpi', 'longevity-skills'),
19 join(homedir(), 'longevity-skills'),
20 join(homedir(), 'Projects', 'longevity-skills'),
21 ].map((item) => item.trim()).filter(Boolean)
22}
23
24function firstExisting(candidates: string[], marker: (dir: string) => boolean): string {
25 for (const candidate of candidates) {
26 const trimmed = candidate.trim()
27 if (!trimmed) continue
28 const dir = resolve(trimmed)
29 if (marker(dir)) return dir
30 }
31 return ''
32}
33
34export function resolveSkillsHome(configured: string): string {
35 return firstExisting(skillsHomeCandidates(configured), (dir) => existsSync(join(dir, 'skills')) && existsSync(join(dir, 'catalog.json')))
36}
37
38/**
39 * The dsh-plugin-mirobody release shipped in this package (`npm run vendor:mirobody`). Installed
40 * with `dsh plugin add`, it sits inside the DSH profile, where the host's packages resolve for it.
41 */
42export function vendoredMirobodyPlugin(): string {
43 return join(dirname(libFile()), '..', 'vendor', 'dsh-plugin-mirobody')
44}
45
46export function resolveMirobodyPlugin(_configured: string): string {
47 // The record lives on this computer (local-record.ts); there is no Mirobody plugin to mount in a mod.
48 return ''
49}
50
51/** The LongPi home: the account holder's own store, and the people registry (people.json, people/<id>/). */
52export function resolveRootDir(configured: string): string {
53 const trimmed = (configured ?? '').trim()
54 if (trimmed) return resolve(trimmed)
55 return join(homedir(), '.longpi')
56}
57
58const PERSON_ID = /^p[a-z0-9]{6,40}$/
59
60/**
61 * The store of the person being looked at now. The holder ("self") is the LongPi home itself, so an install from
62 * before people existed keeps its data; a family member is people/<id>/ under it. Every module reads its files through
63 * this, so profile, connection, records, plans, check-ins, memory and deep analyses all follow the person chosen.
64 */
65export function resolveDataDir(configured: string): string {
66 const root = resolveRootDir(configured)
67 try {
68 const active = (JSON.parse(readFileSync(join(root, 'people.json'), 'utf8')) as { active?: unknown }).active
69 if (typeof active === 'string' && PERSON_ID.test(active) && existsSync(join(root, 'people', active))) return join(root, 'people', active)
70 } catch {
71 /* no registry: the holder */
72 }
73 return root
74}
75
76export function clampMatches(value: number): number {
77 if (!Number.isFinite(value)) return 8
78 return Math.max(1, Math.min(20, Math.floor(value)))
79}
80hooks/ui/pages/index.ts 30 lines1// Every page of the pane, in tab order. Primary pages show in the tab row; the rest under 更多.
2import type { Page, Tab } from '../types.ts'
3import { page as overview } from './overview.tsx'
4import { page as labs } from './labs.tsx'
5import { page as sleep } from './sleep.tsx'
6import { page as training } from './training.tsx'
7import { page as calendar } from './calendar.tsx'
8import { page as analysis } from './analysis.tsx'
9import { page as ask } from './ask.tsx'
10import { page as plan } from './plan.tsx'
11import { page as profile } from './profile.tsx'
12import { page as science } from './science.tsx'
13import { page as settings } from './settings.tsx'
14import { page as codex } from '../codex/page.tsx'
15import { page as journey } from '../journey/view.tsx'
16
17export const PRIMARY: readonly Page[] = [overview, labs, sleep, training, calendar, plan, codex]
18export const SECONDARY: readonly Page[] = [journey, analysis, ask, profile, science, settings]
19export const PAGES: readonly Page[] = [...PRIMARY, ...SECONDARY]
20
21export function pageOf(tab: Tab): Page {
22 return PAGES.find((page) => page.tab === tab) ?? overview
23}
24
25/** Hotkeys for the pages, while the pane holds the keyboard. */
26export const HOTKEYS: Partial<Record<Tab, string>> = {
27 overview: '1', labs: '2', sleep: '3', training: '4', calendar: '5', plan: '6', codex: '7',
28 analysis: '8', ask: '9', profile: '0', science: 'r', settings: 's', journey: 'j',
29}
30