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.
- package/.env.example +19 -0
- package/AGENTS.md +232 -0
- package/IMPLEMENTATION_PLAN.md +207 -0
- package/LICENSE +21 -0
- package/PRODUCT_SPEC.md +1776 -0
- package/README.md +384 -0
- package/SKILL.md +121 -0
- package/START-HERE.md +156 -0
- package/dist/api/app.js +206 -0
- package/dist/api/server.js +42 -0
- package/dist/cli/main.js +799 -0
- package/dist/core/actions.js +264 -0
- package/dist/core/context.js +7 -0
- package/dist/core/directions.js +26 -0
- package/dist/core/evidence.js +155 -0
- package/dist/core/goals.js +188 -0
- package/dist/core/lifeContext.js +41 -0
- package/dist/core/memory.js +35 -0
- package/dist/core/metrics.js +74 -0
- package/dist/core/profile.js +95 -0
- package/dist/core/progress.js +244 -0
- package/dist/core/prompts/index.js +40 -0
- package/dist/core/replan.js +100 -0
- package/dist/core/rollover.js +68 -0
- package/dist/core/rolloverTypes.js +1 -0
- package/dist/core/stateMachine.js +61 -0
- package/dist/core/status.js +57 -0
- package/dist/core/types.js +9 -0
- package/dist/daemon/main.js +64 -0
- package/dist/db/cli.js +19 -0
- package/dist/db/client.js +36 -0
- package/dist/db/migrate.js +34 -0
- package/dist/db/migrations/V1__init.sql +249 -0
- package/dist/device/card.js +257 -0
- package/dist/device/deck.js +157 -0
- package/dist/device/fontCoverage.js +162 -0
- package/dist/device/http.js +170 -0
- package/dist/device/png.js +111 -0
- package/dist/device/provision.js +185 -0
- package/dist/device/render.js +194 -0
- package/dist/device/renderCard.js +48 -0
- package/dist/device/report.js +146 -0
- package/dist/device/serial.js +201 -0
- package/dist/device/token.js +78 -0
- package/dist/device/types.js +2 -0
- package/dist/mcp/ctx.js +48 -0
- package/dist/mcp/server.js +15 -0
- package/dist/mcp/tools.js +586 -0
- package/dist/passport/builder.js +83 -0
- package/dist/passport/state.js +81 -0
- package/dist/passport/sync.js +61 -0
- package/dist/reminder/notify.js +30 -0
- package/dist/reminder/scheduler.js +87 -0
- package/dist/reminder/service.js +102 -0
- package/dist/shared/config.js +50 -0
- package/dist/shared/result.js +27 -0
- package/dist/shared/time.js +67 -0
- package/dist/shared/util.js +21 -0
- package/dist/ui/app.js +500 -0
- package/dist/ui/index.html +38 -0
- package/dist/ui/style.css +367 -0
- package/docs/images/cards.png +0 -0
- package/docs/images/cover.png +0 -0
- package/launchd/com.lairey.ai-growth.daemon.plist +45 -0
- package/llms.txt +52 -0
- package/package.json +79 -0
- package/scripts/check-device-cards.mjs +177 -0
- package/scripts/doctor.sh +194 -0
- package/scripts/gen-device-assets.mjs +188 -0
- package/scripts/install.sh +291 -0
- package/scripts/preview-cards.mjs +120 -0
- package/scripts/reset-dev-data.sh +92 -0
- package/scripts/smoke-device.mjs +401 -0
- package/scripts/uninstall.sh +63 -0
- package/server.json +56 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* AI Passport 设备接口的端到端冒烟。
|
|
4
|
+
*
|
|
5
|
+
* 为什么要有这个脚本(而不是只靠 vitest):
|
|
6
|
+
* vitest 里的设备测试大多直接调函数,测不到「真实 socket + 真实 HTTP 头 + 真实二进制载荷」
|
|
7
|
+
* 这一层。而设备上跑的是**已经编译好的固件**,它看到的只有 HTTP 响应。中间任何一处
|
|
8
|
+
* 契约漂移(字段改名、battery 变成字符串、URL 少了 &bat=)都不会被单测发现,
|
|
9
|
+
* 但会让真机上出现「能连上、画面不动」或「按 OK 没反应」——最难查的那种故障。
|
|
10
|
+
*
|
|
11
|
+
* 所以这里的核心不是「服务端自测」,而是 **G 段:与固件的协议一致性**。
|
|
12
|
+
* G 段直接去读固件源码(main/growth_protocol.h / .c):
|
|
13
|
+
* - 从 growth_parse_meta() 里抽出固件真正读取的 meta 字段名与类型
|
|
14
|
+
* - 从 #define 里抽出卡宽高、头长、分带行数、各字段缓冲上限
|
|
15
|
+
* 然后拿服务端**真实的响应**去对。固件改了字段名 / 服务端改了字段名,这里都会红。
|
|
16
|
+
* 固件源码不在旁边时,退回内置清单并明确标注「非真 parity」。
|
|
17
|
+
*
|
|
18
|
+
* 用法:
|
|
19
|
+
* node scripts/smoke-device.mjs # 临时库 + 临时输出目录
|
|
20
|
+
* SMOKE_DATA_DIR=/tmp/x SMOKE_OUT_DIR=/tmp/y node scripts/smoke-device.mjs
|
|
21
|
+
* AI_PASSPORT_DIR=~/path/to/ai-passport node scripts/smoke-device.mjs
|
|
22
|
+
*
|
|
23
|
+
* 需要先 `npm run build`(脚本跑的是 dist/)。
|
|
24
|
+
*/
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import http from 'node:http';
|
|
30
|
+
|
|
31
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const ROOT = path.resolve(HERE, '..');
|
|
33
|
+
const D = path.join(ROOT, 'dist');
|
|
34
|
+
|
|
35
|
+
if (!fs.existsSync(path.join(D, 'device', 'deck.js'))) {
|
|
36
|
+
console.error(`找不到编译产物 ${D}。先跑 \`npm run build\`。`);
|
|
37
|
+
process.exit(2);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const dataDir = process.env.SMOKE_DATA_DIR || fs.mkdtempSync(path.join(os.tmpdir(), 'ai-growth-smoke-'));
|
|
41
|
+
const outDir = process.env.SMOKE_OUT_DIR || fs.mkdtempSync(path.join(os.tmpdir(), 'ai-growth-cards-'));
|
|
42
|
+
|
|
43
|
+
// 固件仓库位置:默认取同级目录的 ai-passport(本机就是这样摆的),可用环境变量覆盖。
|
|
44
|
+
const FW_DIR = path.resolve(
|
|
45
|
+
(process.env.AI_PASSPORT_DIR || path.join(ROOT, '..', 'ai-passport')).replace(/^~(?=\/)/, os.homedir())
|
|
46
|
+
);
|
|
47
|
+
const FW_HEADER = path.join(FW_DIR, 'main', 'growth_protocol.h');
|
|
48
|
+
const FW_PROTO = path.join(FW_DIR, 'main', 'growth_protocol.c');
|
|
49
|
+
|
|
50
|
+
const { openDb } = await import(path.join(D, 'db/client.js'));
|
|
51
|
+
const { makeCtx } = await import(path.join(D, 'mcp/ctx.js'));
|
|
52
|
+
const { createApiServer } = await import(path.join(D, 'api/app.js'));
|
|
53
|
+
const G = await import(path.join(D, 'core/goals.js'));
|
|
54
|
+
const A = await import(path.join(D, 'core/actions.js'));
|
|
55
|
+
const P = await import(path.join(D, 'core/profile.js'));
|
|
56
|
+
const { decodeCardPayload, CARD_W, CARD_H, HEADER_SIZE, PAYLOAD_VERSION, PIXEL_FORMAT_RGB565_LE } =
|
|
57
|
+
await import(path.join(D, 'device/deck.js'));
|
|
58
|
+
const { deviceToken } = await import(path.join(D, 'device/token.js'));
|
|
59
|
+
|
|
60
|
+
const base = {
|
|
61
|
+
dataDir, timezone: 'Asia/Shanghai', logLevel: 'error', apiPort: 0,
|
|
62
|
+
passportAdapter: 'local-json', passportFile: 'passport.json', deviceLan: '0',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ───────────────────────── 断言框架 ─────────────────────────
|
|
66
|
+
const results = [];
|
|
67
|
+
let section = '';
|
|
68
|
+
function begin(name) { section = name; console.log(`\n═══ ${name} ═══`); }
|
|
69
|
+
function check(name, cond, detail = '') {
|
|
70
|
+
const pass = !!cond;
|
|
71
|
+
results.push({ section, name, pass });
|
|
72
|
+
console.log(` ${pass ? '✓' : '✗'} ${name}${detail ? ` ${detail}` : ''}`);
|
|
73
|
+
return pass;
|
|
74
|
+
}
|
|
75
|
+
function note(text) { console.log(` ${text}`); }
|
|
76
|
+
|
|
77
|
+
// ───────────────────── 从固件源码提取契约 ─────────────────────
|
|
78
|
+
/**
|
|
79
|
+
* 抽 growth_parse_meta() 里真正被读取的 key 及其类型。
|
|
80
|
+
* 只扫函数体,避免把别处的字符串误判成 meta 字段。
|
|
81
|
+
*/
|
|
82
|
+
function firmwareMetaKeys() {
|
|
83
|
+
if (!fs.existsSync(FW_PROTO)) return null;
|
|
84
|
+
const src = fs.readFileSync(FW_PROTO, 'utf8');
|
|
85
|
+
const at = src.indexOf('void growth_parse_meta');
|
|
86
|
+
if (at < 0) return null;
|
|
87
|
+
const body = src.slice(at, src.indexOf('\n}', at));
|
|
88
|
+
const str = [];
|
|
89
|
+
const int = [];
|
|
90
|
+
for (const m of body.matchAll(/growth_meta_(str|int)\(json,\s*"([A-Za-z0-9_]+)"/g)) {
|
|
91
|
+
(m[1] === 'str' ? str : int).push(m[2]);
|
|
92
|
+
}
|
|
93
|
+
if (!str.length && !int.length) return null;
|
|
94
|
+
return { str, int };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 抽固件侧的尺寸/上限常量 */
|
|
98
|
+
function firmwareConsts() {
|
|
99
|
+
if (!fs.existsSync(FW_HEADER)) return null;
|
|
100
|
+
const src = fs.readFileSync(FW_HEADER, 'utf8');
|
|
101
|
+
const num = (n) => {
|
|
102
|
+
const m = src.match(new RegExp(`#define\\s+${n}\\s+(\\d+)`));
|
|
103
|
+
return m ? Number(m[1]) : null;
|
|
104
|
+
};
|
|
105
|
+
const out = {
|
|
106
|
+
W: num('GROWTH_CARD_W'), H: num('GROWTH_CARD_H'),
|
|
107
|
+
HEADER: num('GROWTH_HEADER_SIZE'), VERSION: num('GROWTH_PAYLOAD_VERSION'),
|
|
108
|
+
FORMAT: num('GROWTH_PIXEL_FORMAT_RGB565_LE'),
|
|
109
|
+
BAND_ROWS: num('GROWTH_BAND_ROWS'),
|
|
110
|
+
META_MAX: num('GROWTH_META_MAX'),
|
|
111
|
+
MAX: {
|
|
112
|
+
deckId: num('GROWTH_DECK_ID_MAX'), kind: num('GROWTH_KIND_MAX'),
|
|
113
|
+
actionId: num('GROWTH_ACTION_ID_MAX'), dateKey: num('GROWTH_DATE_KEY_MAX'),
|
|
114
|
+
dateLabel: num('GROWTH_DATE_LABEL_MAX'),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
return Object.values(out).some((v) => v === null) ? null : out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 固件能识别的上报裁决字符串 */
|
|
121
|
+
function firmwareOutcomes() {
|
|
122
|
+
if (!fs.existsSync(FW_PROTO)) return null;
|
|
123
|
+
const src = fs.readFileSync(FW_PROTO, 'utf8');
|
|
124
|
+
const set = new Set();
|
|
125
|
+
for (const m of src.matchAll(/strcmp\(outcome,\s*"([A-Z_]+)"\)/g)) set.add(m[1]);
|
|
126
|
+
return set.size ? set : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ───────────────────────── 造数据(走 Core)─────────────────────────
|
|
130
|
+
const db = openDb(dataDir);
|
|
131
|
+
const ctx = makeCtx(db, base, 'seed');
|
|
132
|
+
P.ensureProfile(ctx, 'Asia/Shanghai');
|
|
133
|
+
P.updateProfile(ctx, { onboardingStatus: 'COMPLETED' });
|
|
134
|
+
const goal = G.createGoalDraft(ctx, {
|
|
135
|
+
title: '30 天掌握 Agent Engineering', why: '想独立做出能交付的 Agent 产品',
|
|
136
|
+
desiredOutcome: '能独立设计并交付', successCriteria: ['产出 1 个可运行项目'],
|
|
137
|
+
targetDate: '2026-10-19', dailyTimeBudgetMinutes: 60,
|
|
138
|
+
});
|
|
139
|
+
G.confirmGoal(ctx, goal.id);
|
|
140
|
+
G.createStagePlan(ctx, {
|
|
141
|
+
goalId: goal.id,
|
|
142
|
+
stages: [
|
|
143
|
+
{ title: 'Tool Calling 基础', plannedStartDate: '2026-09-15', plannedEndDate: '2026-09-21' },
|
|
144
|
+
{ title: 'Memory', plannedStartDate: '2026-09-22', plannedEndDate: '2026-09-28' },
|
|
145
|
+
{ title: 'MCP 集成', plannedStartDate: '2026-09-29', plannedEndDate: '2026-10-05' },
|
|
146
|
+
{ title: '端到端项目', plannedStartDate: '2026-10-06', plannedEndDate: '2026-10-12' },
|
|
147
|
+
{ title: '打磨与交付', plannedStartDate: '2026-10-13', plannedEndDate: '2026-10-19' },
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
G.activateGoal(ctx, goal.id);
|
|
151
|
+
const stages = G.listStages(ctx, goal.id);
|
|
152
|
+
db.prepare(`UPDATE stages SET status='COMPLETED' WHERE id=?`).run(stages[0].id);
|
|
153
|
+
db.prepare(`UPDATE stages SET status='ACTIVE' WHERE id=?`).run(stages[1].id);
|
|
154
|
+
|
|
155
|
+
const { plan, actions: todayActions } = A.createTodayPlan(ctx, {
|
|
156
|
+
rationale: '进入 Memory 阶段的第一步',
|
|
157
|
+
mainQuest: {
|
|
158
|
+
title: '实现最小 Agent Memory Demo', whyToday: 'Memory 是当前阶段的出口标准',
|
|
159
|
+
estimatedMinutes: 60, completionCriteria: 'demo 能写入并召回',
|
|
160
|
+
basisGoalId: goal.id, basisStageId: stages[1].id,
|
|
161
|
+
},
|
|
162
|
+
dailyActions: [
|
|
163
|
+
{ title: '散步 30 分钟', estimatedMinutes: 30, basis: '用户确认的运动方向', domain: 'TRAIN' },
|
|
164
|
+
{ title: '读 20 页书', estimatedMinutes: 20, basis: '用户确认的阅读方向', domain: 'LEARN' },
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
A.confirmTodayPlan(ctx, plan.id);
|
|
168
|
+
const dailies = todayActions.filter((a) => a.kind === 'DAILY');
|
|
169
|
+
const daily = dailies[0];
|
|
170
|
+
const spare = dailies[1]; // 留给「跨天补记」用,全程不完成
|
|
171
|
+
const main = todayActions.find((a) => a.kind === 'MAIN_QUEST');
|
|
172
|
+
|
|
173
|
+
// ───────────────────────── 起服务 ─────────────────────────
|
|
174
|
+
const server = createApiServer(db, base);
|
|
175
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
176
|
+
const port = server.address().port;
|
|
177
|
+
|
|
178
|
+
function req(method, path, body, headers = {}, _port = port) {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
181
|
+
const r = http.request({
|
|
182
|
+
host: '127.0.0.1', port: _port, path, method,
|
|
183
|
+
headers: {
|
|
184
|
+
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}),
|
|
185
|
+
...headers,
|
|
186
|
+
},
|
|
187
|
+
}, (res) => {
|
|
188
|
+
const chunks = [];
|
|
189
|
+
res.on('data', (c) => chunks.push(c));
|
|
190
|
+
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, buf: Buffer.concat(chunks) }));
|
|
191
|
+
});
|
|
192
|
+
r.on('error', (e) => resolve({ status: 0, error: e.message, buf: Buffer.alloc(0) }));
|
|
193
|
+
if (payload) r.write(payload);
|
|
194
|
+
r.end();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const parse = (r) => { try { return JSON.parse(r.buf.toString('utf8')); } catch { return null; } };
|
|
198
|
+
const save = (name, pixels) => { fs.mkdirSync(outDir, { recursive: true }); fs.writeFileSync(path.join(outDir, `${name}.rgb565`), pixels); };
|
|
199
|
+
|
|
200
|
+
// ═════════════════════ A) 局域网关(默认)· 本机免令牌 ═════════════════════
|
|
201
|
+
begin('A) 局域网关(默认)· 本机免令牌');
|
|
202
|
+
let r = await req('GET', '/api/device/ping');
|
|
203
|
+
check('ping 200', r.status === 200, JSON.stringify(parse(r)?.data));
|
|
204
|
+
r = await req('GET', '/api/device/status');
|
|
205
|
+
check('status 200 且带 today/deckId', r.status === 200 && !!parse(r)?.data?.today, `count=${parse(r)?.data?.count}`);
|
|
206
|
+
r = await req('GET', '/api/device/deck');
|
|
207
|
+
const deck = parse(r)?.data;
|
|
208
|
+
check('deck 200 且非空', r.status === 200 && (deck?.count ?? 0) > 0, `count=${deck?.count} deckId=${deck?.deckId}`);
|
|
209
|
+
for (const c of deck?.cards ?? []) note(`[${c.index}] ${String(c.kind).padEnd(9)} actionId=${c.actionId ?? '-'} ${c.title}`);
|
|
210
|
+
|
|
211
|
+
// ═════════════════════ B) 取图并校验载荷 ═════════════════════
|
|
212
|
+
begin('B) 取图并校验载荷(像素真的渲染出来了)');
|
|
213
|
+
for (let i = 0; i < (deck?.count ?? 0); i++) {
|
|
214
|
+
const cr = await req('GET', `/api/device/card?i=${i}&bat=72`);
|
|
215
|
+
let parsed = null;
|
|
216
|
+
try { parsed = decodeCardPayload(cr.buf); } catch (e) { check(`card ${i} 可解码`, false, e.message); continue; }
|
|
217
|
+
const at = (x, y) => parsed.pixels.readUInt16LE((y * CARD_W + x) * 2);
|
|
218
|
+
const corner = at(1, 1);
|
|
219
|
+
const center = at(120, 200);
|
|
220
|
+
check(`card ${i} 200 / octet-stream / 153600B`, cr.status === 200 && cr.headers['content-type'] === 'application/octet-stream' && cr.buf.length === HEADER_SIZE + Buffer.byteLength(JSON.stringify(parsed.meta), 'utf8') + CARD_W * CARD_H * 2,
|
|
221
|
+
`kind=${parsed.meta.kind} 角=0x${corner.toString(16)} 中=0x${center.toString(16)}`);
|
|
222
|
+
check(`card ${i} 角像素为 0(圆角遮黑)且中心非 0`, corner === 0 && center !== 0);
|
|
223
|
+
save(`card${i}_${parsed.meta.kind}`, parsed.pixels);
|
|
224
|
+
}
|
|
225
|
+
r = await req('GET', '/api/device/card?i=999');
|
|
226
|
+
check('越界 index 返回 404', r.status === 404, parse(r)?.error?.code);
|
|
227
|
+
r = await req('GET', '/api/device/card');
|
|
228
|
+
check('缺 index 返回 400', r.status === 400, parse(r)?.error?.code);
|
|
229
|
+
|
|
230
|
+
// ═════════════════════ C) 上报完成 + 幂等 + 牌组变化 ═════════════════════
|
|
231
|
+
begin('C) 上报完成 + 幂等 + 牌组变化');
|
|
232
|
+
r = await req('POST', '/api/device/action', { actionId: main.id, op: 'complete' });
|
|
233
|
+
check('第一次完成 → COMPLETED', parse(r)?.data?.outcome === 'COMPLETED', `status=${r.status}`);
|
|
234
|
+
r = await req('POST', '/api/device/action', { actionId: main.id, op: 'complete' });
|
|
235
|
+
check('重复上报 → ALREADY_IN_STATE + duplicate', parse(r)?.data?.outcome === 'ALREADY_IN_STATE' && parse(r)?.data?.duplicate === true);
|
|
236
|
+
r = await req('POST', '/api/device/action', { actionId: daily.id, op: 'complete' });
|
|
237
|
+
check('完成日常 → COMPLETED', parse(r)?.data?.outcome === 'COMPLETED');
|
|
238
|
+
r = await req('GET', '/api/device/deck');
|
|
239
|
+
const deck2 = parse(r)?.data;
|
|
240
|
+
check('牌组指纹已变化', deck2?.deckId !== deck?.deckId, `${deck?.deckId} → ${deck2?.deckId}`);
|
|
241
|
+
for (const c of deck2?.cards ?? []) note(`[${c.index}] ${String(c.kind).padEnd(9)} ${c.title}`);
|
|
242
|
+
|
|
243
|
+
// ═════════════════════ D) 令牌校验(局域网开启时)═════════════════════
|
|
244
|
+
begin('D) 令牌校验(局域网开启时)');
|
|
245
|
+
P.setSetting(ctx, 'device_token', 'smoke-token-1234');
|
|
246
|
+
const server2 = createApiServer(db, { ...base, deviceLan: '1' });
|
|
247
|
+
await new Promise((res) => server2.listen(0, '127.0.0.1', res));
|
|
248
|
+
const port2 = server2.address().port;
|
|
249
|
+
const q2 = (method, path, headers = {}) => req(method, path, null, headers, port2);
|
|
250
|
+
// 带 body 的版本:q2 只发 header,POST 必须用这个,否则 body 会被当成 header 丢掉
|
|
251
|
+
const post2 = (path, body, headers = {}) => req('POST', path, body, headers, port2);
|
|
252
|
+
check('无令牌 → 401', (await q2('GET', '/api/device/ping')).status === 401);
|
|
253
|
+
check('错误令牌 → 401', (await q2('GET', '/api/device/ping', { 'X-Device-Token': 'nope' })).status === 401);
|
|
254
|
+
check('正确令牌(header)→ 200', (await q2('GET', '/api/device/ping', { 'X-Device-Token': 'smoke-token-1234' })).status === 200);
|
|
255
|
+
check('正确令牌(query,固件走这条)→ 200', (await q2('GET', '/api/device/ping?token=smoke-token-1234')).status === 200);
|
|
256
|
+
check('伪造 Host: 127.0.0.1 不能绕过 → 401', (await q2('GET', '/api/device/ping', { Host: '127.0.0.1' })).status === 401);
|
|
257
|
+
check('面板接口仍锁回环(Host 127.0.0.1 → 200)', (await q2('GET', '/api/today', { Host: '127.0.0.1' })).status === 200);
|
|
258
|
+
check('面板接口拒绝局域网 Host → 403', (await q2('GET', '/api/today', { Host: '192.168.3.46' })).status === 403);
|
|
259
|
+
note(`当前令牌 = ${deviceToken(ctx)}`);
|
|
260
|
+
|
|
261
|
+
// ═════════════════════ E) 跨天降级为迟记 ═════════════════════
|
|
262
|
+
begin('E) 跨天降级为迟记');
|
|
263
|
+
note(`把未完成的「${spare.title}」摆成昨天的 fixture(不伪造时钟)`);
|
|
264
|
+
db.prepare(`UPDATE actions SET date_key='2026-09-19' WHERE id=?`).run(spare.id);
|
|
265
|
+
db.prepare(`UPDATE actions SET status='EXPIRED' WHERE id=?`).run(spare.id);
|
|
266
|
+
const before = db.prepare('SELECT status FROM actions WHERE id=?').get(spare.id).status;
|
|
267
|
+
check('fixture:该动作已 EXPIRED', before === 'EXPIRED', `dateKey=2026-09-19 status=${before}`);
|
|
268
|
+
r = await req('POST', '/api/device/action', { actionId: spare.id, op: 'complete', occurredDateKey: '2026-09-19' });
|
|
269
|
+
check('跨天补交 → LATE_RECORDED', parse(r)?.data?.outcome === 'LATE_RECORDED', `status=${r.status}`);
|
|
270
|
+
note(parse(r)?.data?.note ?? '');
|
|
271
|
+
const after = db.prepare('SELECT status FROM actions WHERE id=?').get(spare.id).status;
|
|
272
|
+
check('动作状态不变(铁律 3:不产生跨天债务)', after === 'EXPIRED', `${before} → ${after}`);
|
|
273
|
+
const evDate = db.prepare('SELECT date_key FROM growth_events ORDER BY rowid DESC LIMIT 1').get()?.date_key;
|
|
274
|
+
check('补记的进展落在 2026-09-19 而非今天', evDate === '2026-09-19', `date_key=${evDate}`);
|
|
275
|
+
const evBefore = db.prepare('SELECT COUNT(*) n FROM growth_events').get().n;
|
|
276
|
+
r = await req('POST', '/api/device/action', { actionId: spare.id, op: 'complete', occurredDateKey: '2026-09-19' });
|
|
277
|
+
check('重复补记幂等(不再新增 growth_event)',
|
|
278
|
+
parse(r)?.data?.duplicate === true && db.prepare('SELECT COUNT(*) n FROM growth_events').get().n === evBefore);
|
|
279
|
+
r = await req('POST', '/api/device/action', { actionId: spare.id, op: 'complete', occurredDateKey: '2030-01-01' });
|
|
280
|
+
check('未来日期被拒', r.status >= 400, `${r.status} ${parse(r)?.error?.code}`);
|
|
281
|
+
r = await req('POST', '/api/device/action', { actionId: spare.id, op: 'complete', occurredDateKey: '2026-01-01' });
|
|
282
|
+
check('早于动作日期被拒', r.status >= 400, `${r.status} ${parse(r)?.error?.code}`);
|
|
283
|
+
r = await req('POST', '/api/device/action', { actionId: 'no-such-action', op: 'complete' });
|
|
284
|
+
check('未知 actionId → NOT_FOUND', parse(r)?.error?.code === 'NOT_FOUND', `${r.status}`);
|
|
285
|
+
r = await req('POST', '/api/device/action', { actionId: spare.id, op: 'teleport' });
|
|
286
|
+
check('非法 op → VALIDATION_ERROR', parse(r)?.error?.code === 'VALIDATION_ERROR', `${r.status}`);
|
|
287
|
+
|
|
288
|
+
// ═════════════════════ F) 今天没排计划 ═════════════════════
|
|
289
|
+
begin('F) 今天没排计划');
|
|
290
|
+
r = await req('GET', '/api/device/deck');
|
|
291
|
+
const kinds = new Set((parse(r)?.data?.cards ?? []).map((c) => c.kind));
|
|
292
|
+
check('牌组里的 kind 都在固件已知集合内(固件不 switch kind,但日志/兜底卡要对得上)',
|
|
293
|
+
[...kinds].every((k) => ['overview', 'task', 'alldone', 'noplan'].includes(k)), [...kinds].join(','));
|
|
294
|
+
|
|
295
|
+
// ═════════════════════ G) 与固件的协议一致性(真 parity)═════════════════════
|
|
296
|
+
begin('G) 与固件的协议一致性(读固件源码来对,不是手抄清单)');
|
|
297
|
+
const fwKeys = firmwareMetaKeys();
|
|
298
|
+
const fwC = firmwareConsts();
|
|
299
|
+
const fwOut = firmwareOutcomes();
|
|
300
|
+
const haveFw = !!(fwKeys && fwC);
|
|
301
|
+
check('找到固件源码,走真 parity 提取', haveFw, haveFw ? FW_DIR : `未找到 ${FW_HEADER} → 退回内置清单`);
|
|
302
|
+
if (!haveFw) note('⚠ 固件源码不在预期位置,下面的字段清单是内置的,仅供参考(设 AI_PASSPORT_DIR 可恢复真 parity)');
|
|
303
|
+
|
|
304
|
+
const strKeys = fwKeys?.str ?? ['deckId', 'kind', 'actionId', 'dateKey', 'dateLabel'];
|
|
305
|
+
const intKeys = fwKeys?.int ?? ['index', 'count', 'doneCount', 'total', 'battery'];
|
|
306
|
+
note(`固件读取的字符串字段:${strKeys.join(', ')}`);
|
|
307
|
+
note(`固件读取的数值字段:${intKeys.join(', ')}`);
|
|
308
|
+
|
|
309
|
+
// 取一张「有 actionId」和一张「actionId:null」的卡,两种形态都要对
|
|
310
|
+
const withAction = await req('GET', '/api/device/card?i=1&bat=72');
|
|
311
|
+
const noAction = await req('GET', '/api/device/card?i=0'); // 总览页:actionId=null、battery=null
|
|
312
|
+
const mWith = decodeCardPayload(withAction.buf).meta;
|
|
313
|
+
const mNone = decodeCardPayload(noAction.buf).meta;
|
|
314
|
+
|
|
315
|
+
check('存在 actionId 为 null 的卡(固件据此只刷新、不上报)', mNone.actionId === null, `kind=${mNone.kind}`);
|
|
316
|
+
check('该卡同时带 battery:null(固件应保持 -1,不是 0)', mNone.battery === null);
|
|
317
|
+
|
|
318
|
+
for (const [label, meta] of [['有动作卡', mWith], ['无动作卡', mNone]]) {
|
|
319
|
+
const missStr = strKeys.filter((k) => !(k in meta));
|
|
320
|
+
const missInt = intKeys.filter((k) => !(k in meta));
|
|
321
|
+
check(`${label}:固件要读的字段一个都不缺`, missStr.length === 0 && missInt.length === 0,
|
|
322
|
+
[...missStr, ...missInt].join(',') || 'ok');
|
|
323
|
+
const badType = [
|
|
324
|
+
...strKeys.filter((k) => !(meta[k] === null || typeof meta[k] === 'string')),
|
|
325
|
+
...intKeys.filter((k) => !(meta[k] === null || typeof meta[k] === 'number')),
|
|
326
|
+
];
|
|
327
|
+
check(`${label}:字段类型固件能解析(str|int,允许 null)`, badType.length === 0, badType.join(',') || 'ok');
|
|
328
|
+
check(`${label}:meta.v === GROWTH_PAYLOAD_VERSION`, meta.v === (fwC?.VERSION ?? PAYLOAD_VERSION), `${meta.v}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 头与分带
|
|
332
|
+
const cr0 = await req('GET', '/api/device/card?i=0');
|
|
333
|
+
const hdr = {
|
|
334
|
+
magic: cr0.buf.subarray(0, 4).toString('ascii'),
|
|
335
|
+
version: cr0.buf.readUInt8(4),
|
|
336
|
+
format: cr0.buf.readUInt8(5),
|
|
337
|
+
w: cr0.buf.readUInt16LE(6),
|
|
338
|
+
h: cr0.buf.readUInt16LE(8),
|
|
339
|
+
metaLen: cr0.buf.readUInt32LE(10),
|
|
340
|
+
};
|
|
341
|
+
check('magic = AGCD', hdr.magic === 'AGCD', hdr.magic);
|
|
342
|
+
check('头长与固件 GROWTH_HEADER_SIZE 一致', HEADER_SIZE === (fwC?.HEADER ?? HEADER_SIZE), `${HEADER_SIZE}`);
|
|
343
|
+
check('宽高与固件 GROWTH_CARD_W/H 一致', hdr.w === fwC.W && hdr.h === fwC.H && hdr.w === CARD_W && hdr.h === CARD_H, `${hdr.w}x${hdr.h}`);
|
|
344
|
+
check('像素格式 = RGB565 小端', hdr.format === (fwC?.FORMAT ?? PIXEL_FORMAT_RGB565_LE), `${hdr.format}`);
|
|
345
|
+
check('像素段长度 = 固件 GROWTH_CARD_PIXEL_BYTES', cr0.buf.length - HEADER_SIZE - hdr.metaLen === fwC.W * fwC.H * 2,
|
|
346
|
+
`${cr0.buf.length - HEADER_SIZE - hdr.metaLen}B`);
|
|
347
|
+
|
|
348
|
+
const bandCount = Math.ceil(fwC.H / fwC.BAND_ROWS);
|
|
349
|
+
const lastRows = fwC.H - (bandCount - 1) * fwC.BAND_ROWS;
|
|
350
|
+
const bandBytes = fwC.W * fwC.BAND_ROWS * 2;
|
|
351
|
+
check('分带整除:每带字节数恰好等于带宽*行数', bandBytes === fwC.W * fwC.BAND_ROWS * 2, `${bandBytes}B/带 × ${bandCount}带`);
|
|
352
|
+
check('分带覆盖整屏不缺行', (bandCount - 1) * fwC.BAND_ROWS + lastRows === fwC.H, `末带 ${lastRows} 行`);
|
|
353
|
+
check('每带字节数不超 SPI max_transfer_sz(240*80*2)', bandBytes <= 240 * 80 * 2, `${bandBytes} <= ${240 * 80 * 2}`);
|
|
354
|
+
|
|
355
|
+
// 缓冲余量:字段超长会被固件静默截断 → 屏幕上是残缺文字,最难查
|
|
356
|
+
const over = [];
|
|
357
|
+
for (const [label, meta] of [['有动作卡', mWith], ['无动作卡', mNone]]) {
|
|
358
|
+
for (const k of ['deckId', 'kind', 'actionId', 'dateKey', 'dateLabel']) {
|
|
359
|
+
const v = meta[k];
|
|
360
|
+
if (typeof v !== 'string') continue;
|
|
361
|
+
const limit = fwC.MAX[k];
|
|
362
|
+
if (limit && v.length >= limit) over.push(`${label}.${k}=${v.length}>=${limit}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
check('各字段长度都在固件缓冲内(不会被静默截断)', over.length === 0, over.join(' ') || 'ok');
|
|
366
|
+
check('meta JSON 不超固件 GROWTH_META_MAX', Buffer.byteLength(JSON.stringify(mWith), 'utf8') < fwC.META_MAX,
|
|
367
|
+
`${Buffer.byteLength(JSON.stringify(mWith), 'utf8')} < ${fwC.META_MAX}`);
|
|
368
|
+
|
|
369
|
+
// 固件实际会拼出来的 URL 形态,逐个打一遍(这是真机最容易翻车的地方)
|
|
370
|
+
check('固件形态① /card?i=N(无电量无令牌)', (await req('GET', '/api/device/card?i=0')).status === 200);
|
|
371
|
+
check('固件形态② /card?i=N&bat=72', (await req('GET', '/api/device/card?i=0&bat=72')).status === 200);
|
|
372
|
+
check('固件形态③ /card?i=N&bat=72&token=T(局域网)', (await q2('GET', '/api/device/card?i=0&bat=72&token=smoke-token-1234')).status === 200);
|
|
373
|
+
const actUrl = await post2('/api/device/action?token=smoke-token-1234', { actionId: daily.id, op: 'complete' });
|
|
374
|
+
check('固件形态④ POST /action?token=T(body {actionId,op})', actUrl.status === 200 && !!parse(actUrl)?.data?.outcome,
|
|
375
|
+
parse(actUrl)?.data?.outcome ?? JSON.stringify(parse(actUrl)?.error));
|
|
376
|
+
check('固件形态⑤ /deck?bat=N(固件带电量取牌组)', (await req('GET', '/api/device/deck?bat=72')).status === 200);
|
|
377
|
+
|
|
378
|
+
// 裁决字符串:服务端产出的,固件必须都认得
|
|
379
|
+
const serverOutcomes = new Set(
|
|
380
|
+
['COMPLETED', 'SKIPPED', 'DELAYED', 'ALREADY_IN_STATE', 'LATE_RECORDED', 'CONFLICT']
|
|
381
|
+
);
|
|
382
|
+
const unknown = fwOut ? [...serverOutcomes].filter((o) => !fwOut.has(o)) : [];
|
|
383
|
+
check('服务端的所有 outcome 固件都认得', unknown.length === 0, unknown.join(',') || [...serverOutcomes].join(','));
|
|
384
|
+
check('幂等/迟记被固件判为成功(不弹错误)', !fwOut ? false : fwOut.has('ALREADY_IN_STATE') && fwOut.has('LATE_RECORDED'));
|
|
385
|
+
|
|
386
|
+
// ═════════════════════ 收尾 ═════════════════════
|
|
387
|
+
server.close();
|
|
388
|
+
server2.close();
|
|
389
|
+
db.close();
|
|
390
|
+
|
|
391
|
+
const failed = results.filter((x) => !x.pass);
|
|
392
|
+
console.log('\n──────────────────────────────────');
|
|
393
|
+
console.log(`断言 ${results.length} 项:通过 ${results.length - failed.length} / 失败 ${failed.length}`);
|
|
394
|
+
if (failed.length) {
|
|
395
|
+
for (const f of failed) console.log(` ✗ [${f.section}] ${f.name}`);
|
|
396
|
+
console.log(`\n卡图输出:${outDir}`);
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
console.log(`✓ 设备接口冒烟全部通过(真 parity${haveFw ? '已启用' : '未启用'})`);
|
|
400
|
+
console.log(` 数据目录:${dataDir}`);
|
|
401
|
+
console.log(` 卡图输出:${outDir}`);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# AI Growth — 卸载
|
|
3
|
+
# 停止并移除 launchd 常驻服务。默认**不删除数据**。
|
|
4
|
+
#
|
|
5
|
+
# 用法:
|
|
6
|
+
# ./scripts/uninstall.sh # 仅移除服务(数据保留)
|
|
7
|
+
# ./scripts/uninstall.sh --purge # 移除服务并删除数据目录(需二次确认)
|
|
8
|
+
|
|
9
|
+
set -uo pipefail
|
|
10
|
+
|
|
11
|
+
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
12
|
+
DATA_DIR="${AI_GROWTH_DATA_DIR:-$HOME/.ai-growth}"
|
|
13
|
+
PLIST_LABEL="com.lairey.ai-growth.daemon"
|
|
14
|
+
PLIST_DEST="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist"
|
|
15
|
+
|
|
16
|
+
PURGE=0
|
|
17
|
+
[ "${1:-}" = "--purge" ] && PURGE=1
|
|
18
|
+
|
|
19
|
+
ok() { printf " \033[0;32m✓\033[0m %s\n" "$1"; }
|
|
20
|
+
warn() { printf " \033[0;33m!\033[0m %s\n" "$1"; }
|
|
21
|
+
|
|
22
|
+
printf "\033[1mAI Growth 卸载\033[0m\n"
|
|
23
|
+
|
|
24
|
+
# 停止并移除 launchd 服务
|
|
25
|
+
if [ -f "$PLIST_DEST" ]; then
|
|
26
|
+
launchctl unload "$PLIST_DEST" >/dev/null 2>&1 || true
|
|
27
|
+
rm -f "$PLIST_DEST"
|
|
28
|
+
ok "已停止并移除 launchd 服务:$PLIST_LABEL"
|
|
29
|
+
else
|
|
30
|
+
warn "launchd 服务未安装,跳过"
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# 兜底:结束残留进程
|
|
34
|
+
if pgrep -f "dist/daemon/main.js" >/dev/null 2>&1; then
|
|
35
|
+
pkill -f "dist/daemon/main.js" || true
|
|
36
|
+
ok "已结束残留 daemon 进程"
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# 数据
|
|
40
|
+
if [ "$PURGE" -eq 1 ]; then
|
|
41
|
+
printf "\n\033[1;31m⚠️ 此操作非常危险,可能导致不可逆的数据丢失!\033[0m\n"
|
|
42
|
+
printf "将删除整个数据目录:%s\n" "$DATA_DIR"
|
|
43
|
+
if [ -f "$DATA_DIR/growth.db" ]; then
|
|
44
|
+
CNT="$(command -v node >/dev/null 2>&1 && node -e "
|
|
45
|
+
const D=require('$PROJECT_DIR/node_modules/better-sqlite3');
|
|
46
|
+
try{const db=new D('$DATA_DIR/growth.db',{readonly:true});
|
|
47
|
+
console.log(db.prepare('SELECT COUNT(*) n FROM actions').get().n);}catch(e){console.log('?')}
|
|
48
|
+
" 2>/dev/null || echo '?')"
|
|
49
|
+
printf "其中包含 %s 条 action 记录。\n" "$CNT"
|
|
50
|
+
fi
|
|
51
|
+
printf "确认删除请输入 yes(其他任何输入都会取消):"
|
|
52
|
+
read -r ANSWER
|
|
53
|
+
if [ "$ANSWER" = "yes" ]; then
|
|
54
|
+
rm -rf "$DATA_DIR"
|
|
55
|
+
ok "已删除 $DATA_DIR"
|
|
56
|
+
else
|
|
57
|
+
warn "已取消删除,数据保留在 $DATA_DIR"
|
|
58
|
+
fi
|
|
59
|
+
else
|
|
60
|
+
warn "数据保留在:${DATA_DIR}(如需删除请显式运行 ./scripts/uninstall.sh --purge)"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
printf "\n卸载完成。项目源码仍在:%s\n" "$PROJECT_DIR"
|
package/server.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.lairey-ai/ai-growth",
|
|
4
|
+
"title": "AI Growth",
|
|
5
|
+
"description": "Agent-native 的个人成长执行系统:访谈定目标、动态生成每日少量任务、跨天自动作废旧任务、用真实证据积累成长记录,并同步为可迁移的 AI Passport。",
|
|
6
|
+
"version": "1.1.0",
|
|
7
|
+
"websiteUrl": "https://github.com/lairey-ai/ai-growth",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/lairey-ai/ai-growth",
|
|
10
|
+
"source": "github"
|
|
11
|
+
},
|
|
12
|
+
"packages": [
|
|
13
|
+
{
|
|
14
|
+
"registryType": "npm",
|
|
15
|
+
"registryBaseUrl": "https://registry.npmjs.org",
|
|
16
|
+
"identifier": "ai-growth",
|
|
17
|
+
"version": "1.1.0",
|
|
18
|
+
"transport": {
|
|
19
|
+
"type": "stdio"
|
|
20
|
+
},
|
|
21
|
+
"environmentVariables": [
|
|
22
|
+
{
|
|
23
|
+
"name": "AI_GROWTH_DATA_DIR",
|
|
24
|
+
"description": "数据目录(SQLite 与 Passport 输出位置)。默认 ~/.ai-growth",
|
|
25
|
+
"format": "string",
|
|
26
|
+
"isRequired": false,
|
|
27
|
+
"default": "~/.ai-growth"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "AI_GROWTH_TZ",
|
|
31
|
+
"description": "本地时区,用于判断「哪一天」。默认取系统时区,例如 Asia/Shanghai",
|
|
32
|
+
"format": "string",
|
|
33
|
+
"isRequired": false
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "AI_GROWTH_API_PORT",
|
|
37
|
+
"description": "面板 HTTP 端口(仅监听 127.0.0.1)。默认 4580",
|
|
38
|
+
"format": "string",
|
|
39
|
+
"isRequired": false,
|
|
40
|
+
"default": "4580"
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"_meta": {
|
|
46
|
+
"io.modelcontextprotocol.registry/publisher-provided": {
|
|
47
|
+
"tool": "manual",
|
|
48
|
+
"skill": "SKILL.md",
|
|
49
|
+
"agentEntry": "AGENTS.md",
|
|
50
|
+
"cli": "npx ai-growth help --json",
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"requirements": "Node.js >= 20",
|
|
53
|
+
"notes": "本服务为 Local-first,只读写本机 ~/.ai-growth;面板仅监听 127.0.0.1,无账号体系。"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|