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,81 @@
|
|
|
1
|
+
import { newId, ts } from '../shared/util.js';
|
|
2
|
+
import { setSetting } from '../core/profile.js';
|
|
3
|
+
/** 标记 Passport dirty(§8:每日计划生成、Daily Review、重要 Evidence、Goal/Stage 变化后) */
|
|
4
|
+
export function flagPassportDirty(ctx, reason) {
|
|
5
|
+
const latest = ctx.db
|
|
6
|
+
.prepare('SELECT id FROM passport_snapshots ORDER BY generated_at DESC LIMIT 1')
|
|
7
|
+
.get();
|
|
8
|
+
if (latest) {
|
|
9
|
+
ctx.db.prepare('UPDATE passport_snapshots SET dirty=1 WHERE id=?').run(latest.id);
|
|
10
|
+
}
|
|
11
|
+
setSetting(ctx, 'passport_dirty_reason', reason);
|
|
12
|
+
}
|
|
13
|
+
export function isPassportDirty(ctx) {
|
|
14
|
+
const latest = ctx.db
|
|
15
|
+
.prepare('SELECT dirty FROM passport_snapshots ORDER BY generated_at DESC LIMIT 1')
|
|
16
|
+
.get();
|
|
17
|
+
if (latest)
|
|
18
|
+
return !!latest.dirty;
|
|
19
|
+
// 尚无快照:若已有 growth 数据(evidence / growth event / goal),视为待生成
|
|
20
|
+
const n = ctx.db
|
|
21
|
+
.prepare(`SELECT (SELECT COUNT(*) FROM evidence) + (SELECT COUNT(*) FROM growth_events)
|
|
22
|
+
+ (SELECT COUNT(*) FROM goals WHERE deleted_at IS NULL) AS n`)
|
|
23
|
+
.get();
|
|
24
|
+
return n.n > 0;
|
|
25
|
+
}
|
|
26
|
+
export function saveSnapshot(ctx, passportJson) {
|
|
27
|
+
const id = newId();
|
|
28
|
+
ctx.db
|
|
29
|
+
.prepare('INSERT INTO passport_snapshots (id, passport, generated_at, dirty) VALUES (?,?,?,0)')
|
|
30
|
+
.run(id, passportJson, ts());
|
|
31
|
+
setSetting(ctx, 'passport_last_generated_at', ts());
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
export function latestSnapshot(ctx) {
|
|
35
|
+
const row = ctx.db
|
|
36
|
+
.prepare('SELECT * FROM passport_snapshots ORDER BY generated_at DESC LIMIT 1')
|
|
37
|
+
.get();
|
|
38
|
+
if (!row)
|
|
39
|
+
return null;
|
|
40
|
+
return { id: row.id, passport: JSON.parse(row.passport), generatedAt: row.generated_at, dirty: !!row.dirty };
|
|
41
|
+
}
|
|
42
|
+
// ---------- sync jobs(§29.1 失败进重试队列)----------
|
|
43
|
+
export function enqueueSyncJob(ctx, payload) {
|
|
44
|
+
// 幂等:已有 PENDING 同类 job 不重复创建
|
|
45
|
+
const existing = ctx.db
|
|
46
|
+
.prepare(`SELECT id FROM sync_jobs WHERE kind='PASSPORT_SYNC' AND status='PENDING'`)
|
|
47
|
+
.get();
|
|
48
|
+
if (existing)
|
|
49
|
+
return existing.id;
|
|
50
|
+
const id = newId();
|
|
51
|
+
const now = ts();
|
|
52
|
+
ctx.db
|
|
53
|
+
.prepare(`INSERT INTO sync_jobs (id, kind, payload, status, attempts, created_at, updated_at) VALUES (?,?,?,'PENDING',0,?,?)`)
|
|
54
|
+
.run(id, 'PASSPORT_SYNC', JSON.stringify(payload), now, now);
|
|
55
|
+
return id;
|
|
56
|
+
}
|
|
57
|
+
export function listSyncJobs(ctx, status, limit = 20) {
|
|
58
|
+
const rows = status
|
|
59
|
+
? ctx.db.prepare('SELECT * FROM sync_jobs WHERE status=? ORDER BY created_at DESC LIMIT ?').all(status, limit)
|
|
60
|
+
: ctx.db.prepare('SELECT * FROM sync_jobs ORDER BY created_at DESC LIMIT ?').all(limit);
|
|
61
|
+
return rows.map((r) => ({
|
|
62
|
+
id: r.id,
|
|
63
|
+
kind: r.kind,
|
|
64
|
+
status: r.status,
|
|
65
|
+
attempts: r.attempts,
|
|
66
|
+
lastError: r.last_error ?? undefined,
|
|
67
|
+
createdAt: r.created_at,
|
|
68
|
+
updatedAt: r.updated_at,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 更新 sync job 状态。
|
|
73
|
+
* attempts 表示「实际发起同步的次数」,因此只在终态(DONE / FAILED)累加;
|
|
74
|
+
* RUNNING 只是过程中的状态转移,不构成一次尝试。
|
|
75
|
+
*/
|
|
76
|
+
export function markSyncJob(ctx, id, status, error) {
|
|
77
|
+
const countsAsAttempt = status === 'DONE' || status === 'FAILED' ? 1 : 0;
|
|
78
|
+
ctx.db
|
|
79
|
+
.prepare(`UPDATE sync_jobs SET status=?, attempts=attempts + ?, last_error=?, updated_at=? WHERE id=?`)
|
|
80
|
+
.run(status, countsAsAttempt, error ?? null, ts(), id);
|
|
81
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { enqueueSyncJob, listSyncJobs, markSyncJob } from './state.js';
|
|
4
|
+
import { generatePassport } from './builder.js';
|
|
5
|
+
export class LocalJsonPassportAdapter {
|
|
6
|
+
cfg;
|
|
7
|
+
name = 'local-json';
|
|
8
|
+
constructor(cfg) {
|
|
9
|
+
this.cfg = cfg;
|
|
10
|
+
}
|
|
11
|
+
async sync(passport) {
|
|
12
|
+
mkdirSync(this.cfg.dataDir, { recursive: true });
|
|
13
|
+
const file = join(this.cfg.dataDir, this.cfg.passportFile);
|
|
14
|
+
writeFileSync(file, JSON.stringify(passport, null, 2));
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function createPassportAdapter(cfg) {
|
|
19
|
+
switch (cfg.passportAdapter) {
|
|
20
|
+
case 'local-json':
|
|
21
|
+
default:
|
|
22
|
+
return new LocalJsonPassportAdapter(cfg);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** 同步(幂等):生成 → enqueue job → 尝试同步;失败不回滚本地状态(Scenario K) */
|
|
26
|
+
export async function syncPassport(ctx, adapter) {
|
|
27
|
+
const passport = generatePassport(ctx);
|
|
28
|
+
const jobId = enqueueSyncJob(ctx, { schemaVersion: passport.schemaVersion, generatedAt: passport.generatedAt });
|
|
29
|
+
try {
|
|
30
|
+
markSyncJob(ctx, jobId, 'RUNNING');
|
|
31
|
+
const okSync = await adapter.sync(passport);
|
|
32
|
+
if (okSync) {
|
|
33
|
+
markSyncJob(ctx, jobId, 'DONE');
|
|
34
|
+
return { ok: true, jobId };
|
|
35
|
+
}
|
|
36
|
+
markSyncJob(ctx, jobId, 'FAILED', 'adapter returned false');
|
|
37
|
+
return { ok: false, jobId };
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
markSyncJob(ctx, jobId, 'FAILED', e instanceof Error ? e.message : String(e));
|
|
41
|
+
return { ok: false, jobId };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** 重试队列中 PENDING/FAILED 的 job */
|
|
45
|
+
export async function retryPendingSyncs(ctx, adapter) {
|
|
46
|
+
const jobs = listSyncJobs(ctx, 'PENDING').concat(listSyncJobs(ctx, 'FAILED'));
|
|
47
|
+
let done = 0;
|
|
48
|
+
for (const job of jobs) {
|
|
49
|
+
const passport = generatePassport(ctx);
|
|
50
|
+
try {
|
|
51
|
+
const ok = await adapter.sync(passport);
|
|
52
|
+
markSyncJob(ctx, job.id, ok ? 'DONE' : 'FAILED', ok ? undefined : 'adapter returned false');
|
|
53
|
+
if (ok)
|
|
54
|
+
done++;
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
markSyncJob(ctx, job.id, 'FAILED', e instanceof Error ? e.message : String(e));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return done;
|
|
61
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
/** macOS 本地通知:osascript display notification */
|
|
5
|
+
export class MacOsNotificationAdapter {
|
|
6
|
+
name = 'macos-local';
|
|
7
|
+
async notify(payload) {
|
|
8
|
+
const script = `display notification ${appleEscape(payload.body)} with title ${appleEscape(payload.title)}`;
|
|
9
|
+
await execFileAsync('osascript', ['-e', script], { timeout: 10_000 });
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** 静默适配器(测试 / 关闭提醒时用) */
|
|
13
|
+
export class NullNotificationAdapter {
|
|
14
|
+
name = 'null';
|
|
15
|
+
async notify() { }
|
|
16
|
+
}
|
|
17
|
+
function appleEscape(s) {
|
|
18
|
+
// AppleScript 字符串字面量转义:反斜杠和双引号
|
|
19
|
+
const escaped = s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
20
|
+
return `"${escaped}"`;
|
|
21
|
+
}
|
|
22
|
+
export function createNotificationAdapter(kind = 'macos-local') {
|
|
23
|
+
switch (kind) {
|
|
24
|
+
case 'macos-local':
|
|
25
|
+
return new MacOsNotificationAdapter();
|
|
26
|
+
case 'none':
|
|
27
|
+
default:
|
|
28
|
+
return new NullNotificationAdapter();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { loadConfig } from '../shared/config.js';
|
|
2
|
+
import { openDb } from '../db/client.js';
|
|
3
|
+
import { makeCtx } from '../mcp/ctx.js';
|
|
4
|
+
import { runDailyRollover } from '../core/rollover.js';
|
|
5
|
+
import { deactivateExpiredLifeContexts } from '../core/lifeContext.js';
|
|
6
|
+
import { listDueReminders, markReminderSent } from './service.js';
|
|
7
|
+
import { createNotificationAdapter } from './notify.js';
|
|
8
|
+
import { retryPendingSyncs, createPassportAdapter } from '../passport/sync.js';
|
|
9
|
+
import { getAction } from '../core/actions.js';
|
|
10
|
+
import { nowIso } from '../shared/time.js';
|
|
11
|
+
const CHECK_INTERVAL_MS = 30_000; // 30s:提醒精度 & rollover 检测
|
|
12
|
+
const SYNC_RETRY_INTERVAL_MS = 10 * 60_000;
|
|
13
|
+
/**
|
|
14
|
+
* 常驻 Scheduler(§12):
|
|
15
|
+
* - 每 tick:rollover(幂等)→ 到期提醒触发(幂等)→ 过期 LifeContext 清理
|
|
16
|
+
* - 定期重试 Passport 同步
|
|
17
|
+
* - 崩溃/重启后自动恢复(所有状态在 DB,进程无状态)
|
|
18
|
+
*/
|
|
19
|
+
export class Scheduler {
|
|
20
|
+
timer;
|
|
21
|
+
syncTimer;
|
|
22
|
+
running = false;
|
|
23
|
+
db;
|
|
24
|
+
cfg;
|
|
25
|
+
notifier;
|
|
26
|
+
constructor(db, cfg, notifier) {
|
|
27
|
+
this.cfg = cfg ?? loadConfig();
|
|
28
|
+
this.db = db ?? openDb(this.cfg.dataDir);
|
|
29
|
+
this.notifier = notifier ?? createNotificationAdapter('macos-local');
|
|
30
|
+
}
|
|
31
|
+
start() {
|
|
32
|
+
this.tick(); // 启动立即跑一次(覆盖开机错过的时间)
|
|
33
|
+
this.timer = setInterval(() => this.tick(), CHECK_INTERVAL_MS);
|
|
34
|
+
this.syncTimer = setInterval(() => this.syncTick(), SYNC_RETRY_INTERVAL_MS);
|
|
35
|
+
}
|
|
36
|
+
stop() {
|
|
37
|
+
if (this.timer)
|
|
38
|
+
clearInterval(this.timer);
|
|
39
|
+
if (this.syncTimer)
|
|
40
|
+
clearInterval(this.syncTimer);
|
|
41
|
+
}
|
|
42
|
+
/** 单次 tick:可独立调用(测试 / API 触发) */
|
|
43
|
+
async tick() {
|
|
44
|
+
if (this.running)
|
|
45
|
+
return { rolloverRan: false, remindersFired: 0, lifeContextsExpired: 0 }; // 防重入
|
|
46
|
+
this.running = true;
|
|
47
|
+
try {
|
|
48
|
+
const ctx = makeCtx(this.db, this.cfg, 'daemon');
|
|
49
|
+
const rollover = runDailyRollover(ctx);
|
|
50
|
+
const lifeExpired = deactivateExpiredLifeContexts(ctx);
|
|
51
|
+
const fired = await this.fireDueReminders(ctx);
|
|
52
|
+
return { rolloverRan: !rollover.alreadyRan, remindersFired: fired, lifeContextsExpired: lifeExpired };
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
this.running = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async fireDueReminders(ctx) {
|
|
59
|
+
const due = listDueReminders(ctx, nowIso());
|
|
60
|
+
let fired = 0;
|
|
61
|
+
for (const rem of due) {
|
|
62
|
+
// 幂等标记先做;通知失败不回滚状态(下次不会重复发)
|
|
63
|
+
markReminderSent(ctx, rem.id);
|
|
64
|
+
const action = getAction(ctx, rem.actionId);
|
|
65
|
+
try {
|
|
66
|
+
await this.notifier.notify({
|
|
67
|
+
title: action ? `AI Growth: ${action.title}` : 'AI Growth 提醒',
|
|
68
|
+
body: action?.completionCriteria ?? '该做今天的任务了',
|
|
69
|
+
});
|
|
70
|
+
fired++;
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
process.stderr.write(`[ai-growth] notify failed: ${e instanceof Error ? e.message : e}\n`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return fired;
|
|
77
|
+
}
|
|
78
|
+
async syncTick() {
|
|
79
|
+
const ctx = makeCtx(this.db, this.cfg, 'daemon');
|
|
80
|
+
try {
|
|
81
|
+
await retryPendingSyncs(ctx, createPassportAdapter(this.cfg));
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
process.stderr.write(`[ai-growth] passport sync retry failed: ${e instanceof Error ? e.message : e}\n`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { writeAudit } from '../core/context.js';
|
|
2
|
+
import { newId, ts } from '../shared/util.js';
|
|
3
|
+
import { DomainError, ErrorCodes } from '../shared/result.js';
|
|
4
|
+
import { todayIn } from '../core/profile.js';
|
|
5
|
+
function rowToReminder(r) {
|
|
6
|
+
return {
|
|
7
|
+
id: r.id, actionId: r.action_id, scheduledAt: r.scheduled_at,
|
|
8
|
+
status: r.status, channel: r.channel,
|
|
9
|
+
firedAt: r.fired_at ?? undefined, cancelledAt: r.cancelled_at ?? undefined,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* 提醒创建(V1.1 §24):
|
|
14
|
+
* - Reminder 与 Action 生命周期绑定
|
|
15
|
+
* - 默认每个 DAILY action 最多 1 个未触发提醒
|
|
16
|
+
*/
|
|
17
|
+
export function createReminder(ctx, input) {
|
|
18
|
+
const action = ctx.db.prepare('SELECT id, date_key, status FROM actions WHERE id=?').get(input.actionId);
|
|
19
|
+
if (!action)
|
|
20
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Action not found: ${input.actionId}`);
|
|
21
|
+
if (['COMPLETED', 'SKIPPED', 'CANCELLED', 'EXPIRED'].includes(action.status)) {
|
|
22
|
+
throw new DomainError(ErrorCodes.INVALID_STATE, `Cannot create reminder for action in status ${action.status}`);
|
|
23
|
+
}
|
|
24
|
+
const kind = ctx.db.prepare('SELECT kind FROM actions WHERE id=?').get(input.actionId);
|
|
25
|
+
if (kind.kind === 'DAILY' && !input.allowMultiple) {
|
|
26
|
+
const existing = ctx.db
|
|
27
|
+
.prepare(`SELECT COUNT(*) AS n FROM reminders WHERE action_id=? AND status='SCHEDULED'`)
|
|
28
|
+
.get(input.actionId);
|
|
29
|
+
if (existing.n >= 1) {
|
|
30
|
+
throw new DomainError(ErrorCodes.INVALID_STATE, 'Daily action already has 1 scheduled reminder (V1.1 §24 max rule). Pass allowMultiple or update the existing one.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const id = newId();
|
|
34
|
+
const now = ts();
|
|
35
|
+
ctx.db
|
|
36
|
+
.prepare(`INSERT INTO reminders (id, action_id, scheduled_at, status, channel, created_at, updated_at)
|
|
37
|
+
VALUES (?,?,?,'SCHEDULED',?,?,?)`)
|
|
38
|
+
.run(id, input.actionId, input.scheduledAt, input.channel ?? 'LOCAL_NOTIFICATION', now, now);
|
|
39
|
+
const rem = getReminder(ctx, id);
|
|
40
|
+
writeAudit(ctx, { entityType: 'reminder', entityId: id, after: rem, reason: 'create reminder' });
|
|
41
|
+
return rem;
|
|
42
|
+
}
|
|
43
|
+
export function getReminder(ctx, id) {
|
|
44
|
+
const row = ctx.db.prepare('SELECT * FROM reminders WHERE id=?').get(id);
|
|
45
|
+
return row ? rowToReminder(row) : null;
|
|
46
|
+
}
|
|
47
|
+
export function updateReminder(ctx, id, scheduledAt) {
|
|
48
|
+
const before = getReminder(ctx, id);
|
|
49
|
+
if (!before)
|
|
50
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Reminder not found: ${id}`);
|
|
51
|
+
if (before.status !== 'SCHEDULED')
|
|
52
|
+
throw new DomainError(ErrorCodes.INVALID_STATE, `Cannot update reminder in status ${before.status}`);
|
|
53
|
+
ctx.db.prepare('UPDATE reminders SET scheduled_at=?, updated_at=? WHERE id=?').run(scheduledAt, ts(), id);
|
|
54
|
+
return getReminder(ctx, id);
|
|
55
|
+
}
|
|
56
|
+
export function cancelReminder(ctx, id, reason = 'cancelled') {
|
|
57
|
+
const before = getReminder(ctx, id);
|
|
58
|
+
if (!before)
|
|
59
|
+
throw new DomainError(ErrorCodes.NOT_FOUND, `Reminder not found: ${id}`);
|
|
60
|
+
if (before.status !== 'SCHEDULED')
|
|
61
|
+
return before; // 幂等
|
|
62
|
+
ctx.db
|
|
63
|
+
.prepare(`UPDATE reminders SET status='CANCELLED', cancelled_at=?, updated_at=? WHERE id=?`)
|
|
64
|
+
.run(ts(), ts(), id);
|
|
65
|
+
const after = getReminder(ctx, id);
|
|
66
|
+
writeAudit(ctx, { entityType: 'reminder', entityId: id, before, after, reason });
|
|
67
|
+
return after;
|
|
68
|
+
}
|
|
69
|
+
/** 取消某 action 全部未触发提醒(幂等) */
|
|
70
|
+
export function cancelRemindersByAction(ctx, actionId, reason) {
|
|
71
|
+
const now = ts();
|
|
72
|
+
const res = ctx.db
|
|
73
|
+
.prepare(`UPDATE reminders SET status='CANCELLED', cancelled_at=?, updated_at=? WHERE action_id=? AND status='SCHEDULED'`)
|
|
74
|
+
.run(now, now, actionId);
|
|
75
|
+
if (res.changes > 0) {
|
|
76
|
+
writeAudit(ctx, { entityType: 'reminder', entityId: actionId, after: { cancelled: res.changes }, reason });
|
|
77
|
+
}
|
|
78
|
+
return res.changes;
|
|
79
|
+
}
|
|
80
|
+
export function listRemindersForDate(ctx, dateKey) {
|
|
81
|
+
const key = dateKey ?? todayIn(ctx);
|
|
82
|
+
// dateKey 是自然日;scheduled_at 是 ISO。取 [key 00:00, key+1 00:00) 区间在 tz 下的提醒
|
|
83
|
+
const rows = ctx.db
|
|
84
|
+
.prepare(`SELECT r.* FROM reminders r JOIN actions a ON a.id = r.action_id
|
|
85
|
+
WHERE a.date_key = ? AND r.status != 'CANCELLED' ORDER BY r.scheduled_at`)
|
|
86
|
+
.all(key);
|
|
87
|
+
return rows.map(rowToReminder);
|
|
88
|
+
}
|
|
89
|
+
/** 到期待触发提醒(Scheduler 轮询用,幂等:SCHEDULED 且 scheduled_at <= now) */
|
|
90
|
+
export function listDueReminders(ctx, nowIsoStr = ts()) {
|
|
91
|
+
const rows = ctx.db
|
|
92
|
+
.prepare(`SELECT * FROM reminders WHERE status='SCHEDULED' AND scheduled_at <= ?
|
|
93
|
+
ORDER BY scheduled_at LIMIT 50`)
|
|
94
|
+
.all(nowIsoStr);
|
|
95
|
+
return rows.map(rowToReminder);
|
|
96
|
+
}
|
|
97
|
+
export function markReminderSent(ctx, id, firedAt) {
|
|
98
|
+
ctx.db
|
|
99
|
+
.prepare(`UPDATE reminders SET status='SENT', fired_at=?, updated_at=? WHERE id=? AND status='SCHEDULED'`)
|
|
100
|
+
.run(firedAt ?? ts(), ts(), id);
|
|
101
|
+
return getReminder(ctx, id);
|
|
102
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
function loadEnvFile() {
|
|
5
|
+
// 依次查找:项目根 .env(开发)、数据目录 config.env
|
|
6
|
+
const candidates = [join(process.cwd(), '.env'), join(homedir(), '.ai-growth', 'config.env')];
|
|
7
|
+
for (const p of candidates) {
|
|
8
|
+
if (!existsSync(p))
|
|
9
|
+
continue;
|
|
10
|
+
try {
|
|
11
|
+
for (const line of readFileSync(p, 'utf8').split('\n')) {
|
|
12
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
13
|
+
if (m && process.env[m[1]] === undefined) {
|
|
14
|
+
process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* ignore malformed env files */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function defaultTz() {
|
|
24
|
+
try {
|
|
25
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai';
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return 'Asia/Shanghai';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function loadConfig(overrides = {}) {
|
|
32
|
+
loadEnvFile();
|
|
33
|
+
const dataDir = (overrides.dataDir ??
|
|
34
|
+
process.env.AI_GROWTH_DATA_DIR ??
|
|
35
|
+
join(homedir(), '.ai-growth')).replace(/^~(?=\/)/, homedir());
|
|
36
|
+
return {
|
|
37
|
+
dataDir,
|
|
38
|
+
timezone: overrides.timezone ?? process.env.AI_GROWTH_TZ ?? defaultTz(),
|
|
39
|
+
logLevel: process.env.AI_GROWTH_LOG_LEVEL ?? 'info',
|
|
40
|
+
apiPort: Number(process.env.AI_GROWTH_API_PORT ?? 4580),
|
|
41
|
+
passportAdapter: process.env.AI_GROWTH_PASSPORT_ADAPTER ?? 'local-json',
|
|
42
|
+
passportFile: process.env.AI_GROWTH_PASSPORT_FILE ?? 'passport.json',
|
|
43
|
+
// ⚠ 默认值必须是空串,不能是 '0':这个字段用来判断"用户有没有显式设置过环境变量"。
|
|
44
|
+
// 默认值若为 '0',它就永远非空,于是 isDeviceLanEnabled 永远走环境变量分支,
|
|
45
|
+
// 数据库里持久化过的那个开关再也读不到("开启局域网"就存不住了)。
|
|
46
|
+
// 语义:'' = 没设,交给设置表;'0'/'1' = 显式指定,环境变量优先。
|
|
47
|
+
deviceLan: process.env.AI_GROWTH_DEVICE_LAN ?? '',
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const ErrorCodes = {
|
|
2
|
+
ONBOARDING_REQUIRED: 'ONBOARDING_REQUIRED',
|
|
3
|
+
CONFIRMATION_REQUIRED: 'CONFIRMATION_REQUIRED',
|
|
4
|
+
REPLAN_REQUIRED: 'REPLAN_REQUIRED',
|
|
5
|
+
INVALID_STATE: 'INVALID_STATE',
|
|
6
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
7
|
+
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
8
|
+
ALREADY_EXISTS: 'ALREADY_EXISTS',
|
|
9
|
+
INTERNAL: 'INTERNAL',
|
|
10
|
+
};
|
|
11
|
+
export function ok(data, suggestedNextOperation) {
|
|
12
|
+
return { ok: true, data, ...(suggestedNextOperation ? { suggestedNextOperation } : {}) };
|
|
13
|
+
}
|
|
14
|
+
export function err(code, message, retryable = false) {
|
|
15
|
+
return { ok: false, error: { code, message, retryable } };
|
|
16
|
+
}
|
|
17
|
+
/** Core 层业务错误:携带错误码,可被 MCP/API 层转换为 ToolResult */
|
|
18
|
+
export class DomainError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
retryable;
|
|
21
|
+
constructor(code, message, retryable = false) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.retryable = retryable;
|
|
25
|
+
this.name = 'DomainError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 时间处理。核心规则:
|
|
3
|
+
* - DB 存 ISO timestamp(UTC)。
|
|
4
|
+
* - "哪一天" 的判断必须基于配置的本地 timezone,不能按 UTC 截断。
|
|
5
|
+
*/
|
|
6
|
+
const dateKeyFormatterCache = new Map();
|
|
7
|
+
function fmt(tz) {
|
|
8
|
+
let f = dateKeyFormatterCache.get(tz);
|
|
9
|
+
if (!f) {
|
|
10
|
+
// en-CA gives YYYY-MM-DD shape
|
|
11
|
+
f = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
12
|
+
dateKeyFormatterCache.set(tz, f);
|
|
13
|
+
}
|
|
14
|
+
return f;
|
|
15
|
+
}
|
|
16
|
+
/** 本地自然日 key:YYYY-MM-DD(按 tz) */
|
|
17
|
+
export function localDateKey(date = new Date(), tz = 'Asia/Shanghai') {
|
|
18
|
+
return fmt(tz).format(date);
|
|
19
|
+
}
|
|
20
|
+
/** 当前 ISO timestamp */
|
|
21
|
+
export function nowIso() {
|
|
22
|
+
return new Date().toISOString();
|
|
23
|
+
}
|
|
24
|
+
/** 该时刻在 tz 下的偏移量(毫秒)。东八区为 +8h,纽约夏令时为 -4h */
|
|
25
|
+
function tzOffsetMs(at, tz) {
|
|
26
|
+
const dtf = new Intl.DateTimeFormat('en-US', {
|
|
27
|
+
timeZone: tz,
|
|
28
|
+
hour12: false,
|
|
29
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
30
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
31
|
+
});
|
|
32
|
+
const parts = dtf.formatToParts(at);
|
|
33
|
+
const get = (t) => Number(parts.find((p) => p.type === t)?.value ?? '0');
|
|
34
|
+
// 把"该 tz 的墙上时间"当成 UTC 读出来,与实际 UTC 的差就是 offset
|
|
35
|
+
const asIfUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour') % 24, get('minute'), get('second'));
|
|
36
|
+
return asIfUtc - at.getTime();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 把 "YYYY-MM-DD" + tz 解析为该自然日起始时刻的 UTC Date。
|
|
40
|
+
* 用 offset 反解,而不是"迭代加天"——后者对负偏移时区(美洲)会直接失败。
|
|
41
|
+
* 二次校正用于跨越 DST 边界的情况。
|
|
42
|
+
*/
|
|
43
|
+
export function startOfDateKey(dateKey, tz) {
|
|
44
|
+
const guess = new Date(`${dateKey}T00:00:00Z`);
|
|
45
|
+
const offset = tzOffsetMs(guess, tz);
|
|
46
|
+
const candidate = new Date(guess.getTime() - offset);
|
|
47
|
+
// DST 边界:候选时刻的 offset 可能与 guess 时刻不同,需要用正确的 offset 再解一次
|
|
48
|
+
const candidateOffset = tzOffsetMs(candidate, tz);
|
|
49
|
+
if (candidateOffset !== offset) {
|
|
50
|
+
return new Date(guess.getTime() - candidateOffset);
|
|
51
|
+
}
|
|
52
|
+
return candidate;
|
|
53
|
+
}
|
|
54
|
+
/** 给定 ISO timestamp 在指定 tz 是否早于某自然日(严格) */
|
|
55
|
+
export function isBeforeDateKey(iso, dateKey, tz) {
|
|
56
|
+
return localDateKey(new Date(iso), tz) < dateKey;
|
|
57
|
+
}
|
|
58
|
+
/** 加/减自然日,返回新 key */
|
|
59
|
+
export function addDays(dateKey, days) {
|
|
60
|
+
const d = new Date(`${dateKey}T00:00:00Z`);
|
|
61
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
62
|
+
return d.toISOString().slice(0, 10);
|
|
63
|
+
}
|
|
64
|
+
/** 今天在 tz 下的 key */
|
|
65
|
+
export function todayKey(tz) {
|
|
66
|
+
return localDateKey(new Date(), tz);
|
|
67
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { nowIso } from '../shared/time.js';
|
|
3
|
+
export function newId() {
|
|
4
|
+
return randomUUID();
|
|
5
|
+
}
|
|
6
|
+
export function ts() {
|
|
7
|
+
return nowIso();
|
|
8
|
+
}
|
|
9
|
+
export function j(v) {
|
|
10
|
+
return JSON.stringify(v);
|
|
11
|
+
}
|
|
12
|
+
export function pj(s, fallback) {
|
|
13
|
+
if (!s)
|
|
14
|
+
return fallback;
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(s);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
}
|