SLOPSHOPPER

longpi

LongPi: turns Claude Code into your personal longevity coach.

new
v0.2.4MITupdated 2026-10-06zwbao/longpi-mod
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (471 | analystSkillPath, analystSkillVersion, abandon as abandonAnalysis, currentSummary, readiness as analysisReadiness, read
README

LongPi:住在 Claude Code 里的长寿教练

简体中文 | English

LongPi 把 Claude Code 变成每个人的长寿教练。它是一个 Claude Code mod(函数钩子插件),装上就能用: 体检报告交给它,它按原样录进本机的健康档案,算出身体年龄和十年心血管风险,起草方案、陪你打卡、提醒复查; 健康页、长寿图鉴、拆包翻牌的动画、通往 120 岁的路,都直接画在 Claude Code 里,不打开网页。

它是 dsh-plugin-longpi 的完整移植,并且自成一体:

  • 方法库(longevity-skills,180 个方法)、教练(longevity-coach)、 深度分析(longevity-analyst)都打包在 mod 里,不用另外安装。
  • 不需要 Mirobody:档案是本机的一个文件(~/.longpi),报告由 Claude 自己读、按原样录入;Apple 健康导出一步导入。
  • 每周新研究:每周自动从 PubMed 找过去一周高质量的衰老研究(随机试验、荟萃分析、大型队列),按长寿图鉴的文案规范写成研究卡上架。

安装

需要 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

提示行(输入框上方)一次只说一件事:久坐提醒(打开了才有)、实验卡可以翻了、本周新研究上架了。

里面有什么

  • 健康页:身体年龄(PhenoAge)和十年心血管风险(China-PAR),超出平时波动的变化,今天的打卡,复查日期,医生简报。 模型估计都标着「AI 预测」或「模型估计」。
  • 长寿图鉴:两周一个的小实验,装在实验包里;拆包、发牌、翻牌、揭晓都是终端里的像素动画。 图书馆里是研究卡,按证据分颜色:铜(细胞)、银(动物)、紫(人群)、金(人体随机试验)。
  • 通往 120 岁:你的伙伴 Pi 从一颗蛋长成「百岁的 Pi」;一条十二站的路;奖章墙;每天「今天的三件事」。 走到新的一站、Pi 长大、拿到奖章,都会放一次庆祝动画。
  • Pi 教练:在对话里,Claude 以 Pi 的身份给出分级的具体建议,需要看医生时直说。
  • 深度分析:让 Claude 用 longevity-analyst 对整个档案做一次分析,结果导回方案。

游戏的规矩

LongPi 想让健康管理像游戏一样让人想参与,但游戏不能骗人:

  • Pi 只跟着留下了记录的事长大:录入报告、在家自测、读研究卡、开始和揭晓小实验、复查、带简报看医生、加家人、做一次深度分析。LongPi 收到报告后自己算出的结果不算。
  • 打卡不加分:打卡无法核实,奖励打卡等于奖励说谎。化验数值的高低也不加分,奖励从不和指标好坏挂钩。
  • 没有抽奖、稀有度、连续天数、每日宝箱和上限;没有任何功能要「解锁」。累计只增不减。
  • 好的结果照实夸,但不说是哪个习惯带来的。

隐私

档案、方案、打卡都在本机:~/.longpi(可用 LONGPI_HOME 换地方)。 你交给 Claude 的报告、照片和问题会像平时用 Claude 一样发给模型;LongPi 会附上一段档案摘要,作为给模型看的上下文。 每周新研究只向 PubMed 发送检索词。「档案 → 隐私与数据」里可以撤回同意、删除全部数据。

局限

  • 这不是诊断,也不是用药建议。紧急情况请拨打 120。
  • 「一起研究」的实时上传在本机版里关闭。
  • 身体年龄需要同一天的九项常规血检;心血管风险需要 35–74 岁和六项病史。

开发

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、运行环境)。

许可

MIT,见 LICENSE。打包的方法库、教练和分析技能各有许可和第三方声明,见 NOTICE。

Source 91 files
hooks/register.tsx 1267 lines
1// 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 lines
1// 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}
49
hooks/app/runtime.ts 303 lines
1// 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}
303
hooks/app/setup.ts 124 lines
1// 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}
124
hooks/app/literature.ts 273 lines
1// 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}
273
hooks/app/brief.ts 48 lines
1// 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}
48
hooks/core/agents/orchestrator.ts 228 lines
1// 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}
228
hooks/core/interventions.ts 357 lines
1// 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}
357
hooks/core/engage/engine.ts 1108 lines
1// 长寿图鉴 (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}
1108
hooks/core/profile.ts 344 lines
1import { 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}
344
hooks/core/paths.ts 80 lines
1import { 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}
80
hooks/ui/pages/index.ts 30 lines
1// 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