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
package/dist/api/app.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP API 工厂(无模块级副作用,便于测试)。
|
|
3
|
+
* 入口是 src/api/server.ts;测试直接 import createApiServer 并用随机端口监听。
|
|
4
|
+
*/
|
|
5
|
+
import { createServer } from 'node:http';
|
|
6
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
7
|
+
import { join, dirname, extname, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { makeCtx } from '../mcp/ctx.js';
|
|
10
|
+
import { ok, err, DomainError } from '../shared/result.js';
|
|
11
|
+
import { handleDeviceRequest } from '../device/http.js';
|
|
12
|
+
import { growthStatus } from '../core/status.js';
|
|
13
|
+
import { getTodayPlan, completeAction, skipAction, delayAction } from '../core/actions.js';
|
|
14
|
+
import { getActiveGoals, listStages, getCurrentStage, listGoals } from '../core/goals.js';
|
|
15
|
+
import { listGrowthEvents, listEvidence } from '../core/evidence.js';
|
|
16
|
+
import { latestSnapshot } from '../passport/state.js';
|
|
17
|
+
import { getSetting, setSetting, todayIn, resolveTimezone } from '../core/profile.js';
|
|
18
|
+
import { localDateKey } from '../shared/time.js';
|
|
19
|
+
import { runDailyRollover } from '../core/rollover.js';
|
|
20
|
+
import { listRemindersForDate } from '../reminder/service.js';
|
|
21
|
+
import { getActiveLifeContext } from '../core/lifeContext.js';
|
|
22
|
+
import { computeMetrics } from '../core/metrics.js';
|
|
23
|
+
/** 静态资源目录:与产物同目录(src/ui ↔ dist/ui) */
|
|
24
|
+
const uiDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'ui');
|
|
25
|
+
const MIME = {
|
|
26
|
+
'.html': 'text/html; charset=utf-8',
|
|
27
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
28
|
+
'.css': 'text/css; charset=utf-8',
|
|
29
|
+
'.svg': 'image/svg+xml',
|
|
30
|
+
'.json': 'application/json',
|
|
31
|
+
};
|
|
32
|
+
function send(res, status, body, contentType) {
|
|
33
|
+
const isString = typeof body === 'string';
|
|
34
|
+
res.writeHead(status, {
|
|
35
|
+
'Content-Type': contentType ?? (isString ? 'text/html; charset=utf-8' : 'application/json'),
|
|
36
|
+
});
|
|
37
|
+
res.end(isString ? body : JSON.stringify(body, null, 2));
|
|
38
|
+
}
|
|
39
|
+
async function readBody(req) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
let data = '';
|
|
42
|
+
req.on('data', (c) => { data += c; });
|
|
43
|
+
req.on('end', () => {
|
|
44
|
+
try {
|
|
45
|
+
resolve(data ? JSON.parse(data) : {});
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
resolve({});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Host 白名单校验:只允许本机地址。
|
|
55
|
+
* 本系统没有账号体系(本地进程 + Agent 工具),安全边界不是"登录"而是 loopback:
|
|
56
|
+
* 这条校验阻止浏览器里的任意网页通过 DNS rebinding / CSRF 驱动本地 Growth 数据。
|
|
57
|
+
*/
|
|
58
|
+
export function isLocalHost(host) {
|
|
59
|
+
if (!host)
|
|
60
|
+
return false; // HTTP/1.1 必须有 Host
|
|
61
|
+
const name = host.replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
62
|
+
return ['127.0.0.1', 'localhost', '::1', '0.0.0.0'].includes(name);
|
|
63
|
+
}
|
|
64
|
+
/** 计数辅助:只读,用于面板展示可核对事实。from 为表名或 "表名 WHERE ..." 片段 */
|
|
65
|
+
function countRows(ctx, from) {
|
|
66
|
+
return ctx.db.prepare(`SELECT COUNT(*) AS n FROM ${from}`).get().n;
|
|
67
|
+
}
|
|
68
|
+
export function createApiServer(db, cfg) {
|
|
69
|
+
return createServer(async (req, res) => {
|
|
70
|
+
const url = new URL(req.url ?? '/', `http://localhost:${cfg.apiPort}`);
|
|
71
|
+
const path = url.pathname;
|
|
72
|
+
const ctx = () => makeCtx(db, cfg, 'web');
|
|
73
|
+
try {
|
|
74
|
+
// 设备接口优先:它自带令牌校验,且局域网开启时**不能**受 Host 头限制
|
|
75
|
+
// (否则局域网客户端伪造 Host: 127.0.0.1 就能绕过令牌)。
|
|
76
|
+
// 详见 src/device/token.ts 的安全模型说明。
|
|
77
|
+
if (await handleDeviceRequest({ ctx, req, res, path, url, cfg }))
|
|
78
|
+
return;
|
|
79
|
+
if (!isLocalHost(req.headers.host)) {
|
|
80
|
+
return send(res, 403, err('FORBIDDEN_HOST', 'This service only accepts requests addressed to localhost.'));
|
|
81
|
+
}
|
|
82
|
+
// ---------- static ----------
|
|
83
|
+
if (req.method === 'GET' && !path.startsWith('/api/')) {
|
|
84
|
+
const rel = path === '/' ? 'index.html' : path.slice(1);
|
|
85
|
+
const file = join(uiDir, rel);
|
|
86
|
+
// 防目录穿越
|
|
87
|
+
if (!resolve(file).startsWith(resolve(uiDir)) || !existsSync(file)) {
|
|
88
|
+
return send(res, 404, 'Not found');
|
|
89
|
+
}
|
|
90
|
+
// 必须按扩展名给出正确 MIME:以 text/html 提供 .js/.css 会被浏览器拒绝执行
|
|
91
|
+
const mime = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream';
|
|
92
|
+
return send(res, 200, readFileSync(file, 'utf8'), mime);
|
|
93
|
+
}
|
|
94
|
+
// ---------- API ----------
|
|
95
|
+
if (path === '/api/health' && req.method === 'GET') {
|
|
96
|
+
const c = ctx();
|
|
97
|
+
return send(res, 200, ok({ status: 'ok', today: todayIn(c), timezone: c.tz }));
|
|
98
|
+
}
|
|
99
|
+
if (path === '/api/today' && req.method === 'GET') {
|
|
100
|
+
const c = ctx();
|
|
101
|
+
// 幂等执行 rollover:daemon 未运行时,用户第二天打开界面也不该看到昨日残留任务。
|
|
102
|
+
// 与 Agent 每次进入时调用 run_daily_rollover 是同一保证。
|
|
103
|
+
runDailyRollover(c);
|
|
104
|
+
const { plan, actions } = getTodayPlan(c);
|
|
105
|
+
const goals = getActiveGoals(c);
|
|
106
|
+
const primary = goals[0];
|
|
107
|
+
const stages = primary ? listStages(c, primary.id) : [];
|
|
108
|
+
const current = primary ? getCurrentStage(c, primary.id) : null;
|
|
109
|
+
const recentEvents = listGrowthEvents(c, undefined, 5);
|
|
110
|
+
const mainActions = actions.filter((a) => a.kind === 'MAIN_QUEST');
|
|
111
|
+
const dailyActions = actions.filter((a) => a.kind === 'DAILY');
|
|
112
|
+
const done = (list) => list.filter((a) => a.status === 'COMPLETED').length;
|
|
113
|
+
return send(res, 200, ok({
|
|
114
|
+
today: todayIn(c),
|
|
115
|
+
status: growthStatus(c),
|
|
116
|
+
plan, actions,
|
|
117
|
+
primaryGoal: primary ? { id: primary.id, title: primary.title, why: primary.why, targetDate: primary.targetDate } : null,
|
|
118
|
+
stageProgress: current && stages.length ? { title: current.title, order: current.order, total: stages.length } : null,
|
|
119
|
+
// 阶段轨道(节点式进度):只表达"第几个 / 共几个"与真实状态,不做百分比
|
|
120
|
+
stages: stages.map((s) => ({ order: s.order, title: s.title, status: s.status, end: s.plannedEndDate ?? null })),
|
|
121
|
+
recentEvents: recentEvents.map((e) => ({ date: e.dateKey, title: e.title, domain: e.domain, evidenceCount: e.evidenceIds.length })),
|
|
122
|
+
reminders: listRemindersForDate(c),
|
|
123
|
+
lifeContext: getActiveLifeContext(c),
|
|
124
|
+
// 面板数值一律为可核对事实(原则 6:不产生虚构属性值 / 掌握度 / 自律分)
|
|
125
|
+
facts: {
|
|
126
|
+
todayMain: { done: done(mainActions), total: mainActions.length },
|
|
127
|
+
todayDaily: { done: done(dailyActions), total: dailyActions.length },
|
|
128
|
+
evidenceTotal: countRows(c, 'evidence'),
|
|
129
|
+
growthEventTotal: countRows(c, 'growth_events'),
|
|
130
|
+
mainQuestCompletedTotal: countRows(c, `actions WHERE kind='MAIN_QUEST' AND status='COMPLETED' AND deleted_at IS NULL`),
|
|
131
|
+
},
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
const m = path.match(/^\/api\/action\/([^/]+)\/(complete|skip|delay)$/);
|
|
135
|
+
if (m && req.method === 'POST') {
|
|
136
|
+
const input = await readBody(req);
|
|
137
|
+
const c = ctx();
|
|
138
|
+
const a = m[2] === 'complete' ? completeAction(c, m[1])
|
|
139
|
+
: m[2] === 'skip' ? skipAction(c, m[1], String(input.reason ?? ''))
|
|
140
|
+
: delayAction(c, m[1], String(input.reason ?? ''));
|
|
141
|
+
return send(res, 200, ok(a));
|
|
142
|
+
}
|
|
143
|
+
if (path === '/api/goals' && req.method === 'GET') {
|
|
144
|
+
const c = ctx();
|
|
145
|
+
return send(res, 200, ok(listGoals(c).map((g) => ({
|
|
146
|
+
...g,
|
|
147
|
+
stages: listStages(c, g.id),
|
|
148
|
+
currentStage: getCurrentStage(c, g.id),
|
|
149
|
+
}))));
|
|
150
|
+
}
|
|
151
|
+
if (path === '/api/timeline' && req.method === 'GET') {
|
|
152
|
+
const c = ctx();
|
|
153
|
+
const since = url.searchParams.get('since') ?? undefined;
|
|
154
|
+
// dateKey 一律由服务端按配置时区计算(铁律 8:绝不按 UTC 截断)。
|
|
155
|
+
// 客户端若自己 slice(0,10) ISO 串,在东八区会把当天记录算成前一天。
|
|
156
|
+
const tz = resolveTimezone(c);
|
|
157
|
+
return send(res, 200, ok({
|
|
158
|
+
today: todayIn(c),
|
|
159
|
+
events: listGrowthEvents(c, since ?? undefined, 200).map((e) => ({
|
|
160
|
+
...e,
|
|
161
|
+
evidenceCount: e.evidenceIds.length,
|
|
162
|
+
})),
|
|
163
|
+
evidence: listEvidence(c, { limit: 200 }).map((e) => ({
|
|
164
|
+
...e,
|
|
165
|
+
dateKey: localDateKey(new Date(e.createdAt), tz),
|
|
166
|
+
})),
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
if (path === '/api/passport' && req.method === 'GET') {
|
|
170
|
+
const snap = latestSnapshot(ctx());
|
|
171
|
+
return send(res, 200, ok({ snapshot: snap?.passport ?? null, generatedAt: snap?.generatedAt, dirty: snap?.dirty }));
|
|
172
|
+
}
|
|
173
|
+
if (path === '/api/settings' && req.method === 'GET') {
|
|
174
|
+
const c = ctx();
|
|
175
|
+
return send(res, 200, ok({
|
|
176
|
+
timezone: getSetting(c, 'timezone') ?? c.tz,
|
|
177
|
+
reminderChannel: getSetting(c, 'reminder_channel') ?? 'macos-local',
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
if (path === '/api/settings' && req.method === 'POST') {
|
|
181
|
+
const input = await readBody(req);
|
|
182
|
+
const c = ctx();
|
|
183
|
+
if (typeof input.timezone === 'string')
|
|
184
|
+
setSetting(c, 'timezone', input.timezone);
|
|
185
|
+
if (typeof input.reminderChannel === 'string')
|
|
186
|
+
setSetting(c, 'reminder_channel', input.reminderChannel);
|
|
187
|
+
return send(res, 200, ok({ saved: true }));
|
|
188
|
+
}
|
|
189
|
+
if (path === '/api/rollover' && req.method === 'POST') {
|
|
190
|
+
return send(res, 200, ok(runDailyRollover(ctx())));
|
|
191
|
+
}
|
|
192
|
+
if (path === '/api/metrics' && req.method === 'GET') {
|
|
193
|
+
return send(res, 200, ok(computeMetrics(ctx(), 14)));
|
|
194
|
+
}
|
|
195
|
+
return send(res, 404, err('NOT_FOUND', `Unknown path: ${path}`));
|
|
196
|
+
}
|
|
197
|
+
catch (e) {
|
|
198
|
+
// 领域错误按其错误码返回(便于界面/日志定位),非领域错误才是 500
|
|
199
|
+
if (e instanceof DomainError) {
|
|
200
|
+
const status = e.code === 'NOT_FOUND' ? 404 : e.code === 'ALREADY_EXISTS' ? 409 : 400;
|
|
201
|
+
return send(res, status, err(e.code, e.message, e.retryable));
|
|
202
|
+
}
|
|
203
|
+
return send(res, 500, err('INTERNAL', e instanceof Error ? e.message : String(e)));
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { networkInterfaces } from 'node:os';
|
|
3
|
+
import { loadConfig } from '../shared/config.js';
|
|
4
|
+
import { openDb } from '../db/client.js';
|
|
5
|
+
import { createApiServer } from './app.js';
|
|
6
|
+
import { isDeviceLanEnabled, getOrCreateDeviceToken } from '../device/token.js';
|
|
7
|
+
import { makeCtx } from '../mcp/ctx.js';
|
|
8
|
+
const cfg = loadConfig();
|
|
9
|
+
const db = openDb(cfg.dataDir);
|
|
10
|
+
const server = createApiServer(db, cfg);
|
|
11
|
+
const lan = isDeviceLanEnabled(cfg, makeCtx(db, cfg, 'api'));
|
|
12
|
+
// 局域网关:只绑回环,物理上只有本机连得上(默认,符合铁律 10 的安全模型)
|
|
13
|
+
// 局域网开:绑 0.0.0.0,此时 /api/device/* 对**所有**来源都强制校验令牌
|
|
14
|
+
const host = lan ? '0.0.0.0' : '127.0.0.1';
|
|
15
|
+
function lanAddress() {
|
|
16
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
17
|
+
for (const ni of list ?? []) {
|
|
18
|
+
if (ni.family === 'IPv4' && !ni.internal)
|
|
19
|
+
return ni.address;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
server.listen(cfg.apiPort, host, () => {
|
|
25
|
+
process.stderr.write(`[ai-growth] web UI: http://127.0.0.1:${cfg.apiPort}\n`);
|
|
26
|
+
if (!lan) {
|
|
27
|
+
process.stderr.write('[ai-growth] 设备局域网访问:关(默认)。要开:ai-growth device --lan on\n');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const ip = lanAddress();
|
|
31
|
+
const token = getOrCreateDeviceToken(makeCtx(db, cfg, 'api'));
|
|
32
|
+
process.stderr.write(`[ai-growth] 设备局域网访问:开(绑定 ${host})\n` +
|
|
33
|
+
`[ai-growth] 设备访问地址:${ip ? `http://${ip}:${cfg.apiPort}` : `http://<本机IP>:${cfg.apiPort}`}\n` +
|
|
34
|
+
`[ai-growth] 设备令牌:${token}\n`);
|
|
35
|
+
});
|
|
36
|
+
// 退出时优雅收尾,便于 launchd 重启
|
|
37
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
38
|
+
process.on(sig, () => {
|
|
39
|
+
server.close(() => process.exit(0));
|
|
40
|
+
setTimeout(() => process.exit(0), 1500).unref();
|
|
41
|
+
});
|
|
42
|
+
}
|