ai-growth 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.env.example +19 -0
  2. package/AGENTS.md +232 -0
  3. package/IMPLEMENTATION_PLAN.md +207 -0
  4. package/LICENSE +21 -0
  5. package/PRODUCT_SPEC.md +1776 -0
  6. package/README.md +384 -0
  7. package/SKILL.md +121 -0
  8. package/START-HERE.md +156 -0
  9. package/dist/api/app.js +206 -0
  10. package/dist/api/server.js +42 -0
  11. package/dist/cli/main.js +799 -0
  12. package/dist/core/actions.js +264 -0
  13. package/dist/core/context.js +7 -0
  14. package/dist/core/directions.js +26 -0
  15. package/dist/core/evidence.js +155 -0
  16. package/dist/core/goals.js +188 -0
  17. package/dist/core/lifeContext.js +41 -0
  18. package/dist/core/memory.js +35 -0
  19. package/dist/core/metrics.js +74 -0
  20. package/dist/core/profile.js +95 -0
  21. package/dist/core/progress.js +244 -0
  22. package/dist/core/prompts/index.js +40 -0
  23. package/dist/core/replan.js +100 -0
  24. package/dist/core/rollover.js +68 -0
  25. package/dist/core/rolloverTypes.js +1 -0
  26. package/dist/core/stateMachine.js +61 -0
  27. package/dist/core/status.js +57 -0
  28. package/dist/core/types.js +9 -0
  29. package/dist/daemon/main.js +64 -0
  30. package/dist/db/cli.js +19 -0
  31. package/dist/db/client.js +36 -0
  32. package/dist/db/migrate.js +34 -0
  33. package/dist/db/migrations/V1__init.sql +249 -0
  34. package/dist/device/card.js +257 -0
  35. package/dist/device/deck.js +157 -0
  36. package/dist/device/fontCoverage.js +162 -0
  37. package/dist/device/http.js +170 -0
  38. package/dist/device/png.js +111 -0
  39. package/dist/device/provision.js +185 -0
  40. package/dist/device/render.js +194 -0
  41. package/dist/device/renderCard.js +48 -0
  42. package/dist/device/report.js +146 -0
  43. package/dist/device/serial.js +201 -0
  44. package/dist/device/token.js +78 -0
  45. package/dist/device/types.js +2 -0
  46. package/dist/mcp/ctx.js +48 -0
  47. package/dist/mcp/server.js +15 -0
  48. package/dist/mcp/tools.js +586 -0
  49. package/dist/passport/builder.js +83 -0
  50. package/dist/passport/state.js +81 -0
  51. package/dist/passport/sync.js +61 -0
  52. package/dist/reminder/notify.js +30 -0
  53. package/dist/reminder/scheduler.js +87 -0
  54. package/dist/reminder/service.js +102 -0
  55. package/dist/shared/config.js +50 -0
  56. package/dist/shared/result.js +27 -0
  57. package/dist/shared/time.js +67 -0
  58. package/dist/shared/util.js +21 -0
  59. package/dist/ui/app.js +500 -0
  60. package/dist/ui/index.html +38 -0
  61. package/dist/ui/style.css +367 -0
  62. package/docs/images/cards.png +0 -0
  63. package/docs/images/cover.png +0 -0
  64. package/launchd/com.lairey.ai-growth.daemon.plist +45 -0
  65. package/llms.txt +52 -0
  66. package/package.json +79 -0
  67. package/scripts/check-device-cards.mjs +177 -0
  68. package/scripts/doctor.sh +194 -0
  69. package/scripts/gen-device-assets.mjs +188 -0
  70. package/scripts/install.sh +291 -0
  71. package/scripts/preview-cards.mjs +120 -0
  72. package/scripts/reset-dev-data.sh +92 -0
  73. package/scripts/smoke-device.mjs +401 -0
  74. package/scripts/uninstall.sh +63 -0
  75. package/server.json +56 -0
@@ -0,0 +1,48 @@
1
+ import { buildCardState, buildDeck, buildCardMeta, encodeCardPayload, } from './deck.js';
2
+ import { buildCardHtml } from './card.js';
3
+ import { renderCardCached } from './render.js';
4
+ /** 用给定的状态与卡规格渲染(构建期生成兜底卡走这条) */
5
+ export async function renderSpec(state, spec, index, count, dataDir) {
6
+ const meta = buildCardMeta(state, spec, index, count);
7
+ const html = buildCardHtml(state, spec);
8
+ const rendered = await renderCardCached(html, meta.deckId, dataDir);
9
+ return {
10
+ payload: encodeCardPayload(meta, rendered.pixels),
11
+ meta,
12
+ pixels: rendered.pixels,
13
+ html,
14
+ cached: rendered.cached,
15
+ };
16
+ }
17
+ /**
18
+ * 读取 Core 当前状态,渲染第 index 张卡。
19
+ * 越界返回 null(由 HTTP 层翻成 404),不抛错。
20
+ */
21
+ export async function renderCardAt(ctx, index, dataDir, opts = {}) {
22
+ const state = buildCardState(ctx, { battery: opts.battery ?? null, offlineSince: opts.offlineSince });
23
+ const cards = buildDeck(state);
24
+ if (!Number.isInteger(index) || index < 0 || index >= cards.length)
25
+ return null;
26
+ return renderSpec(state, cards[index], index, cards.length, dataDir);
27
+ }
28
+ /** 给模拟器/调试用的牌组摘要(不是设备必需的,设备只看每张卡的 meta) */
29
+ export function deckSummary(ctx, opts = {}) {
30
+ const state = buildCardState(ctx, { battery: opts.battery ?? null });
31
+ const cards = buildDeck(state);
32
+ return {
33
+ deckId: buildCardMeta(state, cards[0], 0, cards.length).deckId,
34
+ count: cards.length,
35
+ cards: cards.map((c, i) => ({
36
+ index: i,
37
+ kind: c.kind,
38
+ title: c.title,
39
+ actionId: buildCardMeta(state, c, i, cards.length).actionId,
40
+ })),
41
+ today: state.dateKey,
42
+ dateLabel: state.dateLabel,
43
+ doneCount: state.doneCount,
44
+ total: state.total,
45
+ stage: state.stage,
46
+ items: state.items,
47
+ };
48
+ }
@@ -0,0 +1,146 @@
1
+ import { writeAudit } from '../core/context.js';
2
+ import { DomainError, ErrorCodes } from '../shared/result.js';
3
+ import { getAction, completeAction, skipAction, delayAction } from '../core/actions.js';
4
+ import { addEvidence, createGrowthEvent } from '../core/evidence.js';
5
+ import { getSystemState, setSystemState, todayIn } from '../core/profile.js';
6
+ import { nowIso } from '../shared/time.js';
7
+ const TARGET = {
8
+ complete: 'COMPLETED',
9
+ skip: 'SKIPPED',
10
+ delay: 'DELAYED',
11
+ };
12
+ /** 已经走到头、不可能再变成目标状态的动作状态 */
13
+ const TERMINAL = new Set(['COMPLETED', 'SKIPPED', 'CANCELLED', 'EXPIRED']);
14
+ export function applyDeviceReport(ctx, input) {
15
+ if (!input?.actionId)
16
+ throw new DomainError(ErrorCodes.VALIDATION_ERROR, 'actionId is required');
17
+ if (!Object.prototype.hasOwnProperty.call(TARGET, input.op)) {
18
+ throw new DomainError(ErrorCodes.VALIDATION_ERROR, `unknown op: ${String(input.op)}`);
19
+ }
20
+ const today = todayIn(ctx);
21
+ const action = getAction(ctx, input.actionId);
22
+ if (!action)
23
+ throw new DomainError(ErrorCodes.NOT_FOUND, `unknown action: ${input.actionId}`);
24
+ const base = {
25
+ actionId: action.id,
26
+ actionTitle: action.title,
27
+ actionDateKey: action.dateKey,
28
+ today,
29
+ };
30
+ // 设备给的日期只做校验,绝不当时间戳用
31
+ const occurred = input.occurredDateKey;
32
+ if (occurred && (occurred > today || occurred < action.dateKey)) {
33
+ writeAudit(ctx, {
34
+ entityType: 'action',
35
+ entityId: action.id,
36
+ after: { occurredDateKey: occurred, today, actionDateKey: action.dateKey },
37
+ reason: 'device reported an out-of-range occurred date; rejected',
38
+ });
39
+ throw new DomainError(ErrorCodes.VALIDATION_ERROR, `occurredDateKey ${occurred} 不在 [${action.dateKey}, ${today}] 区间内`);
40
+ }
41
+ // 该动作所属的自然日已经过去 → 状态不可再改(铁律 3)
42
+ if (today > action.dateKey) {
43
+ if (input.op !== 'complete') {
44
+ return {
45
+ ...base,
46
+ outcome: 'CONFLICT',
47
+ status: action.status,
48
+ note: `${action.dateKey} 已经过去,${input.op} 无处可施`,
49
+ };
50
+ }
51
+ return lateRecord(ctx, action, today);
52
+ }
53
+ const target = TARGET[input.op];
54
+ if (action.status === target) {
55
+ return { ...base, outcome: 'ALREADY_IN_STATE', status: action.status, duplicate: true };
56
+ }
57
+ if (TERMINAL.has(action.status)) {
58
+ return {
59
+ ...base,
60
+ outcome: 'CONFLICT',
61
+ status: action.status,
62
+ note: `当前状态是 ${action.status},设备视图已过期,刷新即可`,
63
+ };
64
+ }
65
+ try {
66
+ const updated = input.op === 'complete'
67
+ ? completeAction(ctx, action.id)
68
+ : input.op === 'skip'
69
+ ? skipAction(ctx, action.id, input.reason ?? '设备跳过')
70
+ : delayAction(ctx, action.id, input.reason ?? '设备顺延');
71
+ const outcome = input.op === 'complete' ? 'COMPLETED' : input.op === 'skip' ? 'SKIPPED' : 'DELAYED';
72
+ return { ...base, outcome, status: updated.status };
73
+ }
74
+ catch (e) {
75
+ if (e instanceof DomainError && e.code === ErrorCodes.INVALID_STATE) {
76
+ const after = getAction(ctx, action.id);
77
+ if (after && after.status === target) {
78
+ return { ...base, outcome: 'ALREADY_IN_STATE', status: after.status, duplicate: true };
79
+ }
80
+ return {
81
+ ...base,
82
+ outcome: 'CONFLICT',
83
+ status: after?.status ?? action.status,
84
+ note: e.message,
85
+ };
86
+ }
87
+ throw e;
88
+ }
89
+ }
90
+ /** 跨天提交:补一条带真实日期的进展,动作状态一动不动 */
91
+ function lateRecord(ctx, action, today) {
92
+ const key = `late_record:${action.id}`;
93
+ const base = {
94
+ actionId: action.id,
95
+ actionTitle: action.title,
96
+ actionDateKey: action.dateKey,
97
+ status: action.status,
98
+ today,
99
+ };
100
+ const prev = getSystemState(ctx, key);
101
+ if (prev) {
102
+ const parsed = JSON.parse(prev);
103
+ return {
104
+ ...base,
105
+ outcome: 'LATE_RECORDED',
106
+ duplicate: true,
107
+ evidenceId: parsed.evidenceId,
108
+ growthEventId: parsed.growthEventId,
109
+ note: '此前已经补记过,未重复写入',
110
+ };
111
+ }
112
+ const ev = addEvidence(ctx, {
113
+ actionId: action.id,
114
+ type: 'TEXT',
115
+ strength: 'USER_CONFIRMED',
116
+ content: `[设备] ${action.dateKey} 完成「${action.title}」,${today} 同步`,
117
+ metadata: {
118
+ source: 'ai-passport-device',
119
+ actionId: action.id,
120
+ actionDateKey: action.dateKey,
121
+ syncedAt: nowIso(),
122
+ },
123
+ });
124
+ const ge = createGrowthEvent(ctx, {
125
+ title: action.title,
126
+ domain: action.domain,
127
+ evidenceIds: [ev.id],
128
+ dateKey: action.dateKey,
129
+ goalId: action.basisGoalId,
130
+ stageId: action.basisStageId,
131
+ });
132
+ setSystemState(ctx, key, JSON.stringify({ evidenceId: ev.id, growthEventId: ge.id, recordedAt: nowIso() }));
133
+ writeAudit(ctx, {
134
+ entityType: 'action',
135
+ entityId: action.id,
136
+ after: { evidenceId: ev.id, growthEventId: ge.id, dateKey: action.dateKey, status: action.status },
137
+ reason: 'device reported completion after the action day expired; recorded as a dated growth event instead of mutating state',
138
+ });
139
+ return {
140
+ ...base,
141
+ outcome: 'LATE_RECORDED',
142
+ evidenceId: ev.id,
143
+ growthEventId: ge.id,
144
+ note: '跨天提交,已降级为迟记:动作状态保持不变,只补一条带真实日期的进展',
145
+ };
146
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * 串口控制台会话(AI Passport 的 USB-Serial-JTAG)。
3
+ *
4
+ * 为什么不用 npm 的 serialport:它是**原生模块**,用户机器上装它就要编译工具链;
5
+ * 而这件事只需要「以 raw 打开一个字符设备、写一行、读一段」三件事,
6
+ * Node 自带的 fs / child_process 就够 —— 装完软件就能用,不要额外依赖。
7
+ *
8
+ * ⚠ 三个现实问题,全部在下面处理掉了(都是实测踩出来的):
9
+ *
10
+ * 1. **Node 没有 termios**,不能自己把 tty 设成 raw,只能借系统的 `stty`。
11
+ * 不设 raw,主机的行规程会改写字节(\n → \r\n、回显、流控),设备端解析会乱。
12
+ * macOS 用 `stty -f`,Linux 用 `stty -F`,两边都要试。
13
+ *
14
+ * 2. **打开端口会让设备复位**(USB-Serial-JTAG 的行为),于是会先涌出一整段启动日志,
15
+ * 这期间写进去的命令会被**直接丢掉**。所以判断"能不能发"的唯一可靠依据是
16
+ * **响应本身**,而不是等某个提示符 —— 不复位时提示符根本不会重新打印,
17
+ * 等它必然误判成"没就绪"。
18
+ *
19
+ * 3. 读数必须用 StringDecoder:UTF-8 汉字可能被切在两个 chunk 之间,
20
+ * 直接按 chunk 解码会得到乱码(而设备的中文日志正是我们判断成败的依据)。
21
+ */
22
+ import { execFileSync } from 'node:child_process';
23
+ import { closeSync, createReadStream, existsSync, openSync, readdirSync, writeSync } from 'node:fs';
24
+ import { StringDecoder } from 'node:string_decoder';
25
+ import { setTimeout as delay } from 'node:timers/promises';
26
+ /**
27
+ * 列出可能是 AI Passport 的串口。
28
+ *
29
+ * 只看这几类名字:设备走原生 USB-Serial-JTAG(macOS 是 /dev/cu.usbmodem*,
30
+ * Linux 是 /dev/ttyACM*);也带上常见的 USB-UART 桥(ch34x/cp210x/ftdi),
31
+ * 免得个别批次用了外置桥就找不到设备。
32
+ * ⚠ 不返回 /dev/cu.Bluetooth-* 之类:那是蓝牙虚拟口,连上去只会浪费时间。
33
+ */
34
+ /**
35
+ * 从设备名列表里挑出可能是 AI Passport 的串口(**纯函数,便于单测**)。
36
+ *
37
+ * 只看这几类名字:设备走原生 USB-Serial-JTAG(macOS 是 /dev/cu.usbmodem*,
38
+ * Linux 是 /dev/ttyACM*);也带上常见的 USB-UART 桥(ch34x/cp210x/ftdi),
39
+ * 免得个别批次用了外置桥就找不到设备。
40
+ *
41
+ * ⚠ macOS 会给同一个物理设备**两个**节点:/dev/cu.*(拨出,callout)和
42
+ * /dev/tty.*(拨入,dialin)。只收 cu.*,否则永远会"找到多个串口"而走不下去 ——
43
+ * 这个 bug 会让 device setup 在所有 macOS 上直接失败。Linux 那边只有 ttyACM
44
+ * 和 ttyUSB 系列,没有这种孪生节点。
45
+ * ⚠ 不返回 /dev/cu.Bluetooth-* 之类:那是蓝牙虚拟口,连上去只会浪费时间。
46
+ * ⚠ 注释里别出现"星号紧跟斜杠"的字符序列:它会把块注释提前闭合(我已经踩过两次)。
47
+ */
48
+ export function pickSerialPorts(names, platform) {
49
+ const out = [];
50
+ const isDarwin = platform === 'darwin';
51
+ for (const name of names) {
52
+ if (/^cu\.usbmodem/.test(name))
53
+ out.push({ path: `/dev/${name}`, kind: 'usbmodem' });
54
+ else if (/^cu\.(usbserial|wchusbserial|SLAB_USBtoUART)/.test(name))
55
+ out.push({ path: `/dev/${name}`, kind: 'usbserial' });
56
+ else if (!isDarwin && /^ttyACM\d+$/.test(name))
57
+ out.push({ path: `/dev/${name}`, kind: 'acm' });
58
+ else if (!isDarwin && /^ttyUSB\d+$/.test(name))
59
+ out.push({ path: `/dev/${name}`, kind: 'usbserial' });
60
+ }
61
+ // 原生 USB-Serial-JTAG 优先;其余按路径排序保证稳定
62
+ const rank = { usbmodem: 0, acm: 1, usbserial: 2 };
63
+ return out.sort((a, b) => rank[a.kind] - rank[b.kind] || a.path.localeCompare(b.path));
64
+ }
65
+ /** 列出本机上可能是 AI Passport 的串口(pickSerialPorts 的 io 外壳)。 */
66
+ export function findSerialPorts() {
67
+ if (!existsSync('/dev'))
68
+ return [];
69
+ try {
70
+ return pickSerialPorts(readdirSync('/dev'), process.platform);
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ }
76
+ /** 把 tty 设成 raw 且关回显。macOS/Linux 的参数名不同,都试一遍。 */
77
+ function makeRaw(path) {
78
+ const attempts = process.platform === 'darwin'
79
+ ? [['-f', path, 'raw', '-echo'], ['-F', path, 'raw', '-echo']]
80
+ : [['-F', path, 'raw', '-echo'], ['-f', path, 'raw', '-echo']];
81
+ let lastErr = null;
82
+ for (const args of attempts) {
83
+ try {
84
+ execFileSync('stty', args, { stdio: 'ignore' });
85
+ return;
86
+ }
87
+ catch (e) {
88
+ lastErr = e;
89
+ }
90
+ }
91
+ throw new Error(`stty 无法把 ${path} 设为 raw 模式:${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
92
+ }
93
+ export class SerialSession {
94
+ path;
95
+ fd;
96
+ stream;
97
+ decoder = new StringDecoder('utf8');
98
+ buf = '';
99
+ waiter = null;
100
+ constructor(path) {
101
+ this.path = path;
102
+ makeRaw(path);
103
+ this.fd = openSync(path, 'r+');
104
+ this.stream = createReadStream(path, { fd: this.fd, autoClose: false });
105
+ this.stream.on('data', (chunk) => {
106
+ this.buf += typeof chunk === 'string' ? chunk : this.decoder.write(chunk);
107
+ this.checkWaiter();
108
+ });
109
+ // 串口读不到 EOF;出错时别抛到全局(例如设备被拔掉)
110
+ this.stream.on('error', () => { });
111
+ }
112
+ static open(path) {
113
+ return new SerialSession(path);
114
+ }
115
+ checkWaiter() {
116
+ const w = this.waiter;
117
+ if (!w)
118
+ return;
119
+ for (const p of w.patterns) {
120
+ if (this.buf.includes(p)) {
121
+ this.waiter = null;
122
+ clearTimeout(w.timer);
123
+ const text = this.buf;
124
+ this.buf = '';
125
+ w.resolve({ matched: p, text });
126
+ return;
127
+ }
128
+ }
129
+ }
130
+ /** 写一行(自动补 \n,UTF-8 编码)。命令本身是 ASCII,无需转义。 */
131
+ writeLine(line) {
132
+ writeSync(this.fd, Buffer.from(line + '\n', 'utf8'));
133
+ }
134
+ /** 等到出现任一 pattern 或超时。同一时刻只允许一个等待者。 */
135
+ waitFor(patterns, timeoutMs) {
136
+ const hit = patterns.find((p) => this.buf.includes(p));
137
+ if (hit) {
138
+ const text = this.buf;
139
+ this.buf = '';
140
+ return Promise.resolve({ matched: hit, text });
141
+ }
142
+ return new Promise((resolve) => {
143
+ const timer = setTimeout(() => {
144
+ if (this.waiter?.timer === timer)
145
+ this.waiter = null;
146
+ const text = this.buf;
147
+ this.buf = '';
148
+ resolve({ matched: null, text });
149
+ }, timeoutMs);
150
+ this.waiter = { patterns, resolve, timer };
151
+ });
152
+ }
153
+ /** 丢弃当前累积的读取内容(例如启动日志),只保留"从现在起"的输出 */
154
+ clear() {
155
+ this.buf = '';
156
+ }
157
+ /**
158
+ * 发一条命令并等响应。返回是否命中,以及这次读到的文本。
159
+ * 为什么要重试:开端口会复位设备,头一两条命令可能正好落在启动过程中被丢掉。
160
+ */
161
+ async sendAndWait(line, patterns, timeoutMs, tries = 3) {
162
+ for (let i = 1; i <= tries; i++) {
163
+ this.clear();
164
+ this.writeLine(line);
165
+ const r = await this.waitFor(patterns, timeoutMs);
166
+ if (r.matched)
167
+ return { ...r, tries: i };
168
+ }
169
+ return { matched: null, text: this.buf, tries };
170
+ }
171
+ /**
172
+ * 只读地收集一段时间。用在**没有终止标记**的输出上(启动日志、取卡结论)——
173
+ * 这类只能按时间收,不能等某个关键词。
174
+ * ⚠ 不要与 waitFor 同时用:两者都会消费内部缓冲。
175
+ */
176
+ drain(ms) {
177
+ this.clear();
178
+ return new Promise((resolve) => {
179
+ setTimeout(() => {
180
+ const text = this.buf;
181
+ this.buf = '';
182
+ resolve(text);
183
+ }, ms);
184
+ });
185
+ }
186
+ async sleep(ms) { await delay(ms); }
187
+ close() {
188
+ if (this.waiter) {
189
+ clearTimeout(this.waiter.timer);
190
+ this.waiter = null;
191
+ }
192
+ try {
193
+ this.stream.destroy();
194
+ }
195
+ catch { /* ignore */ }
196
+ try {
197
+ closeSync(this.fd);
198
+ }
199
+ catch { /* ignore */ }
200
+ }
201
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 设备访问令牌 —— 唯一需要触碰铁律 10(不做账号体系、安全边界是 loopback)的地方。
3
+ *
4
+ * 设计原则:**默认关**。令牌只在显式开启局域网访问后才被强制校验,
5
+ * 且校验对**所有**来源一律生效(含 localhost)—— 否则局域网客户端只要伪造
6
+ * `Host: 127.0.0.1` 就能绕过校验,那就等于没锁。
7
+ *
8
+ * - 局域网关:socket 绑在 127.0.0.1,物理上只有本机能连 → 不校验令牌
9
+ * - 局域网开:socket 绑 0.0.0.0,任何来源(含本机)都必须带正确令牌
10
+ *
11
+ * 这不是"登录",是一把写进设备 NVS 的共享密钥,只为挡住同一个 Wi-Fi 下的其他人。
12
+ */
13
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
14
+ import { getSetting, setSetting } from '../core/profile.js';
15
+ const TOKEN_KEY = 'device_token';
16
+ const LAN_KEY = 'device_lan';
17
+ /**
18
+ * 局域网访问开关(device_lan)。
19
+ *
20
+ * 取值顺序:**环境变量优先,其次数据库里持久化过的开关,最后默认关**。
21
+ *
22
+ * 为什么要落库:这个开关以前只认环境变量,于是「开一次」这个动作**根本存不下来** ——
23
+ * 本机 launchd 起不来(全局包是指向 ~/Desktop 的符号链接,TCC 拦着),
24
+ * 而项目里又没有别的可写配置文件。结果是用户每次启动都得手动带环境变量,
25
+ * 对一个要发布出去的应用来说不能接受。令牌本来就存在 settings 表里(见 TOKEN_KEY),
26
+ * 这里沿用同一套:环境变量仍然可以临时覆盖,但"开启"这个决定能被记住。
27
+ */
28
+ export function isDeviceLanEnabled(cfg, ctx) {
29
+ const truthy = (v) => v === '1' || v === 'true' || v === 'yes' || v === 'on';
30
+ const env = (cfg.deviceLan ?? '').trim().toLowerCase();
31
+ if (env)
32
+ return truthy(env); // 显式设置的环境变量说了算
33
+ if (!ctx)
34
+ return false; // 没有数据库上下文时只能用环境变量
35
+ return truthy((getSetting(ctx, LAN_KEY) ?? '').trim().toLowerCase());
36
+ }
37
+ /** 把"开/关局域网访问"这个决定记下来(下次启动生效)。 */
38
+ export function setDeviceLanEnabled(ctx, on) {
39
+ setSetting(ctx, LAN_KEY, on ? '1' : '0');
40
+ }
41
+ export function deviceToken(ctx) {
42
+ return getSetting(ctx, TOKEN_KEY);
43
+ }
44
+ export function getOrCreateDeviceToken(ctx) {
45
+ const cur = deviceToken(ctx);
46
+ if (cur)
47
+ return cur;
48
+ const t = randomBytes(16).toString('hex');
49
+ setSetting(ctx, TOKEN_KEY, t);
50
+ return t;
51
+ }
52
+ export function rotateDeviceToken(ctx) {
53
+ const t = randomBytes(16).toString('hex');
54
+ setSetting(ctx, TOKEN_KEY, t);
55
+ return t;
56
+ }
57
+ function safeEqual(a, b) {
58
+ const ba = Buffer.from(a);
59
+ const bb = Buffer.from(b);
60
+ if (ba.length !== bb.length)
61
+ return false;
62
+ return timingSafeEqual(ba, bb);
63
+ }
64
+ /** 从请求里取令牌:优先请求头(不落日志),其次查询串(方便浏览器直接开) */
65
+ export function extractToken(headerValue, queryValue) {
66
+ if (headerValue && headerValue.trim())
67
+ return headerValue.trim();
68
+ return (queryValue ?? '').trim();
69
+ }
70
+ export function checkDeviceAccess(input) {
71
+ if (!input.lanEnabled)
72
+ return 'ALLOW';
73
+ if (!input.expected)
74
+ return 'NOT_CONFIGURED';
75
+ if (!input.provided)
76
+ return 'TOKEN_REQUIRED';
77
+ return safeEqual(input.expected, input.provided) ? 'ALLOW' : 'TOKEN_INVALID';
78
+ }
@@ -0,0 +1,2 @@
1
+ /** 设备卡的数据契约。固件按这套字段解析(meta 段为 JSON)。 */
2
+ export {};
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 每次操作构造 ctx(timezone 实时解析:settings > profile > 默认)。
3
+ * 所有状态变更必须通过 Core Service(§23.9),MCP 层只做参数校验与转发。
4
+ */
5
+ export function makeCtx(db, cfg, actor = 'agent') {
6
+ // resolve timezone: settings.timezone > profiles.timezone > cfg.timezone
7
+ let tz = cfg.timezone;
8
+ try {
9
+ const s = db.prepare("SELECT value FROM settings WHERE key='timezone'").get();
10
+ if (s?.value)
11
+ tz = s.value;
12
+ else {
13
+ const p = db.prepare('SELECT timezone FROM profiles WHERE deleted_at IS NULL LIMIT 1').get();
14
+ if (p?.timezone)
15
+ tz = p.timezone;
16
+ }
17
+ }
18
+ catch {
19
+ /* table may not exist during early init */
20
+ }
21
+ return { db, tz, actor };
22
+ }
23
+ import { ok, err } from '../shared/result.js';
24
+ import { DomainError } from '../shared/result.js';
25
+ export function toResult(fn) {
26
+ try {
27
+ return ok(fn());
28
+ }
29
+ catch (e) {
30
+ if (e instanceof DomainError) {
31
+ return err(e.code, e.message, e.retryable);
32
+ }
33
+ const message = e instanceof Error ? e.message : String(e);
34
+ return err('INTERNAL', message);
35
+ }
36
+ }
37
+ export async function toResultAsync(fn) {
38
+ try {
39
+ return ok(await fn());
40
+ }
41
+ catch (e) {
42
+ if (e instanceof DomainError) {
43
+ return err(e.code, e.message, e.retryable);
44
+ }
45
+ const message = e instanceof Error ? e.message : String(e);
46
+ return err('INTERNAL', message);
47
+ }
48
+ }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { loadConfig } from '../shared/config.js';
5
+ import { openDb } from '../db/client.js';
6
+ import { registerTools } from './tools.js';
7
+ const cfg = loadConfig();
8
+ const db = openDb(cfg.dataDir);
9
+ const server = new McpServer({ name: 'ai-growth', version: '1.1.0' }, {
10
+ instructions: 'AI Growth — agent-driven personal growth system. Call growth_status first; it tells you the suggested next operation (onboarding / replan / daily planning / continue).',
11
+ });
12
+ registerTools(server, db, cfg);
13
+ const transport = new StdioServerTransport();
14
+ await server.connect(transport);
15
+ process.stderr.write(`[ai-growth] MCP server ready (data: ${cfg.dataDir})\n`);