@danceiny/gotry 0.0.1-rc.8 → 0.0.1-rc.9
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/README.md +6 -4
- package/cordis.gotry-patch.yml +7 -0
- package/dist/capabilities/flyai.js +131 -0
- package/dist/capabilities/session/action-cache.js +120 -0
- package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
- package/dist/capabilities/session/adapters/meituan-local.js +73 -0
- package/dist/capabilities/session/extract.js +40 -0
- package/dist/capabilities/session/read-guard.js +56 -0
- package/dist/capabilities/session/transport.js +95 -0
- package/dist/capabilities/session-search.js +95 -0
- package/dist/scripts/action-cache-tests.js +107 -0
- package/dist/scripts/async-collect.js +26 -7
- package/dist/scripts/companion-tests.js +112 -0
- package/dist/scripts/ledger-tests.js +344 -0
- package/dist/scripts/ledger-workflow-crash.js +42 -0
- package/dist/scripts/memory-decay-tests.js +83 -0
- package/dist/scripts/memory-metrics.js +6 -20
- package/dist/scripts/nudge-digest.js +4 -14
- package/dist/scripts/session-attach-diagnose.js +31 -0
- package/dist/scripts/session-attach-poc.js +65 -0
- package/dist/scripts/session-attach-wait.js +41 -0
- package/dist/scripts/session-extract-tests.js +81 -0
- package/dist/scripts/session-login.js +48 -0
- package/dist/scripts/session-tests.js +162 -0
- package/dist/scripts/smoke.js +41 -1
- package/dist/scripts/state-cli-tests.js +136 -0
- package/dist/scripts/state-cli.js +234 -0
- package/dist/scripts/travel-timeline-tests.js +123 -0
- package/dist/scripts/unified-tests.js +1 -1
- package/dist/src/companions.js +112 -0
- package/dist/src/index.js +346 -117
- package/dist/src/loop.js +52 -15
- package/dist/src/memory-decay.js +31 -0
- package/dist/src/state-ledger.js +848 -0
- package/dist/src/travel-timeline.js +78 -0
- package/dist/src/unified.js +3 -1
- package/package.json +1 -1
- package/ts/package.json +3 -0
- package/ts/src/index.ts +216 -83
- package/ts/src/loop.ts +57 -17
- package/ts/src/unified.ts +6 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { parseAbsoluteDate, ymd } from './time-anchor.js';
|
|
2
|
+
import { resolveSlotDate } from './slot-spec.js';
|
|
3
|
+
export function resolveTimelineDate(expr, anchor) {
|
|
4
|
+
return resolveSlotDate(expr, anchor).date;
|
|
5
|
+
}
|
|
6
|
+
export function makeTripId(ev) {
|
|
7
|
+
return `${ev.destination}|${ev.start}|${ev.source}`.replace(/\s+/g, '_');
|
|
8
|
+
}
|
|
9
|
+
export function appendTrip(events, ev) {
|
|
10
|
+
if (!ev.destination?.trim()) return {
|
|
11
|
+
events,
|
|
12
|
+
appended: false,
|
|
13
|
+
reason: 'destination 必填'
|
|
14
|
+
};
|
|
15
|
+
if (!ev.evidence?.trim()) return {
|
|
16
|
+
events,
|
|
17
|
+
appended: false,
|
|
18
|
+
reason: 'evidence 必填(用户原话或 wish 确认指针)'
|
|
19
|
+
};
|
|
20
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(ev.start)) return {
|
|
21
|
+
events,
|
|
22
|
+
appended: false,
|
|
23
|
+
reason: 'start 必须为 YYYY-MM-DD(词表外日期由上游解析,不猜)'
|
|
24
|
+
};
|
|
25
|
+
if (ev.end && ev.end < ev.start) return {
|
|
26
|
+
events,
|
|
27
|
+
appended: false,
|
|
28
|
+
reason: 'end 早于 start'
|
|
29
|
+
};
|
|
30
|
+
const tripId = makeTripId(ev);
|
|
31
|
+
if (events.some((e)=>e.trip_id === tripId)) return {
|
|
32
|
+
events,
|
|
33
|
+
appended: false,
|
|
34
|
+
tripId
|
|
35
|
+
};
|
|
36
|
+
const overlap = events.find((e)=>e.destination === ev.destination && !(e.end && e.end < ev.start) && !(ev.end && ev.end < e.start));
|
|
37
|
+
if (overlap) return {
|
|
38
|
+
events,
|
|
39
|
+
appended: false,
|
|
40
|
+
tripId: overlap.trip_id,
|
|
41
|
+
reason: `与已有行程 ${overlap.trip_id} 日期重叠,冲突即停`
|
|
42
|
+
};
|
|
43
|
+
const full = {
|
|
44
|
+
schema: 'travel_timeline.v1',
|
|
45
|
+
trip_id: tripId,
|
|
46
|
+
ts: new Date().toISOString(),
|
|
47
|
+
...ev
|
|
48
|
+
};
|
|
49
|
+
return {
|
|
50
|
+
events: [
|
|
51
|
+
...events,
|
|
52
|
+
full
|
|
53
|
+
],
|
|
54
|
+
appended: true,
|
|
55
|
+
tripId
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function projectTimeline(events) {
|
|
59
|
+
return [
|
|
60
|
+
...events
|
|
61
|
+
].sort((a, b)=>a.start < b.start ? 1 : -1).map((e)=>({
|
|
62
|
+
tripId: e.trip_id,
|
|
63
|
+
destination: e.destination,
|
|
64
|
+
start: e.start,
|
|
65
|
+
end: e.end,
|
|
66
|
+
companions: e.companions
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
export function timelineGapsForVerified(timeline, verifiedWishes) {
|
|
70
|
+
const dests = new Set(timeline.map((t)=>t.destination));
|
|
71
|
+
return verifiedWishes.filter((w)=>w.destination && !dests.has(w.destination)).map((w)=>({
|
|
72
|
+
wishId: w.wishId
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
export { ymd };
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
//# sourceURL=/Users/bytedance/work/gotry/ts/src/travel-timeline.ts
|
package/dist/src/unified.js
CHANGED
|
@@ -179,7 +179,9 @@ function evaluateOptionMove(segId, mv) {
|
|
|
179
179
|
let energy;
|
|
180
180
|
if (mv.redEye && (mv.redEyeDurationMin ?? 0) > 0) {
|
|
181
181
|
const sleepH = ((mv.redEyeDurationMin ?? 0) - 60) / 60;
|
|
182
|
-
|
|
182
|
+
const groundH = (mv.groundRecoveryMin ?? mv.destTransferMin ?? 0) / 60;
|
|
183
|
+
const recovery = Math.min(5, 5 * groundH);
|
|
184
|
+
energy = Math.max(30, Math.min(75 + recovery, 80, 30 + 8 * sleepH + recovery));
|
|
183
185
|
} else {
|
|
184
186
|
energy = 100 - 2 * 8;
|
|
185
187
|
if (wake < 5 * 60) energy -= 30;
|
package/package.json
CHANGED
package/ts/package.json
CHANGED
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
"@deepseek-ai/cordis": "*",
|
|
14
14
|
"@deepseek-ai/dsh-tools": "*",
|
|
15
15
|
"@deepseek-ai/schemastery": "*",
|
|
16
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
17
|
+
"better-sqlite3": "^13.0.3",
|
|
16
18
|
"z3-solver": "^5.2.0"
|
|
17
19
|
},
|
|
18
20
|
"devDependencies": {
|
|
19
21
|
"@types/node": "^22.0.0",
|
|
22
|
+
"playwright-core": "^1.62.1",
|
|
20
23
|
"tsx": "^4.19.0",
|
|
21
24
|
"typescript": "^5.6.0"
|
|
22
25
|
}
|
package/ts/src/index.ts
CHANGED
|
@@ -14,21 +14,21 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { join } from 'node:path'
|
|
17
|
-
|
|
18
|
-
import { readFileSync } from 'node:fs'
|
|
17
|
+
|
|
19
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
20
19
|
import z from '@deepseek-ai/schemastery'
|
|
21
20
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
22
|
-
import { ensureStateDir,
|
|
21
|
+
import { ensureStateDir, recordLatency } from './bridge.ts'
|
|
23
22
|
import { segmentsFromCandidate, solveChoiceSegment } from './unified.ts'
|
|
24
23
|
import { checkConnectivity } from '../scripts/skeleton-check.ts'
|
|
25
24
|
import { parseCandidate, parseRequest } from './model.ts'
|
|
26
25
|
import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.ts'
|
|
27
26
|
import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.ts'
|
|
28
27
|
import { interpretArgs, type GotryObservation } from './tool-packet.ts'
|
|
29
|
-
import {
|
|
28
|
+
import { projectUtility } from './memory-utility.ts'
|
|
30
29
|
import { pickNudgeWish, type WishPoolEntry } from './wish-pool.ts'
|
|
31
|
-
import {
|
|
30
|
+
import { resolveTimelineDate } from './travel-timeline.ts'
|
|
31
|
+
import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.ts'
|
|
32
32
|
import { buildTimeAnchor } from './time-anchor.ts'
|
|
33
33
|
import { resolveSlotDate } from './slot-spec.ts'
|
|
34
34
|
import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.ts'
|
|
@@ -36,6 +36,8 @@ import { verifyFlight } from '../capabilities/opensky.ts'
|
|
|
36
36
|
import { anythingSearch } from '../capabilities/anything.ts'
|
|
37
37
|
import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.ts'
|
|
38
38
|
import { videoSubtitle, githubSearch } from '../capabilities/agent-reach-deep.ts'
|
|
39
|
+
import { flyaiSearch } from '../capabilities/flyai.ts'
|
|
40
|
+
import { sessionFlightSearch } from '../capabilities/session-search.ts'
|
|
39
41
|
|
|
40
42
|
export const name = 'gotry-tools'
|
|
41
43
|
export const inject = ['tools', 'systemPrompt']
|
|
@@ -81,24 +83,48 @@ interface WishPoolEntryInput {
|
|
|
81
83
|
const unwrapQuery = interpretArgs
|
|
82
84
|
|
|
83
85
|
/**
|
|
84
|
-
* 动机画像 → persona 紧凑 brief(M4 T1 读回路径)
|
|
86
|
+
* 动机画像 → persona 紧凑 brief(M4 T1 读回路径):空画像返回 ''(首访),
|
|
85
87
|
* persona 据此决定是否访谈。只读,不猜——画像里没有的字段不编。
|
|
88
|
+
* ADR-15:读经账本(未迁移 root 回退旧文件,只读)。
|
|
86
89
|
*/
|
|
87
90
|
function renderMotivationBrief(stateRoot: string): string {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
91
|
+
const p = readMotivationWithFallback(stateRoot)
|
|
92
|
+
if (!p) return '' // 首访:无画像
|
|
93
|
+
const lines: string[] = ['## 用户记忆(跨会话画像;与用户当轮说法冲突时以用户为准,更新经 gotry_motivation_save)']
|
|
94
|
+
const weights = Object.entries(p.weights ?? {})
|
|
95
|
+
if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v]) => `${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`)
|
|
96
|
+
const hard = Object.entries(p.hard ?? {})
|
|
97
|
+
if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
|
|
98
|
+
// 旅行时间线(memory-design P1):去过的地方不再主动推荐,除非用户点名
|
|
99
|
+
const trips = readTimelineTrips(stateRoot)
|
|
100
|
+
if (trips.length) {
|
|
101
|
+
lines.push(`- 去过: ${trips.map(t => `${t.destination}(${t.start.slice(0, 7)})`).join('、')}——去过的地方不再主动推荐,除非用户点名`)
|
|
102
|
+
}
|
|
103
|
+
// 同行人档案(memory-design P2):约束只进排序与行程结构建议,永不硬过滤
|
|
104
|
+
const companions = readCompanions(stateRoot)
|
|
105
|
+
if (companions.length) {
|
|
106
|
+
lines.push(`- 同行人: ${companions.map(c => `${c.label}(${c.brief})`).join('、')}——约束进结构与排序建议,不硬过滤;引用时带当初的原话依据`)
|
|
101
107
|
}
|
|
108
|
+
lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`)
|
|
109
|
+
if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`)
|
|
110
|
+
return weights.length || hard.length ? lines.join('\n') : ''
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 时间线摘要(brief 用):最近 3 次行程(目的地+年月);账本优先,未迁移回退文件 */
|
|
114
|
+
/** 同行人摘要(brief 用):label + 约束串 */
|
|
115
|
+
function readCompanions(stateRoot: string): Array<{ label: string; brief: string }> {
|
|
116
|
+
return readCompanionsWithFallback(stateRoot)
|
|
117
|
+
.map(c => {
|
|
118
|
+
const bits = [c.constraints.mobility, ...(c.constraints.health ?? []), ...(c.constraints.prefs ?? [])].filter(Boolean)
|
|
119
|
+
return { label: c.label, brief: bits.join('/') }
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readTimelineTrips(stateRoot: string): Array<{ destination: string; start: string }> {
|
|
124
|
+
return readTripsWithFallback(stateRoot)
|
|
125
|
+
.map(t => ({ destination: t.destination, start: t.start }))
|
|
126
|
+
.sort((a, b) => (a.start < b.start ? 1 : -1))
|
|
127
|
+
.slice(0, 3)
|
|
102
128
|
}
|
|
103
129
|
|
|
104
130
|
type Json = string | number | boolean | null | Json[] | { [k: string]: Json }
|
|
@@ -224,25 +250,17 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
224
250
|
// T1 接线:本工具是增量补丁语义(契约 18——模型每轮把对话新事实带进来),
|
|
225
251
|
// 经 mergeProfile 守门合并进既有画像(追加不删史/幂等/权重变更须伴新证据),
|
|
226
252
|
// 不再整档覆盖。首次调用(无档案)= 全量建立。
|
|
253
|
+
// ADR-15:守门+事件+投影在账本单事务内完成,evidence 红线拒绝即回滚,账本无痕。
|
|
227
254
|
const incoming = (args.profile ?? {}) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> }
|
|
228
255
|
if (!incoming.evidence?.length) {
|
|
229
256
|
throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)')
|
|
230
257
|
}
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const merged = mergeProfile(existing, { weights: incoming.weights, evidence: incoming.evidence, hard: incoming.hard })
|
|
238
|
-
if (!merged) {
|
|
239
|
-
// 幂等:补丁与现有画像完全一致,不落盘
|
|
240
|
-
const currentJson = JSON.parse(JSON.stringify({ ...(existing ?? {}), updated_at: new Date().toISOString() })) as JsonObject
|
|
241
|
-
return { ok: true, saved: false, path, profile: currentJson, summary: '无新内容(幂等跳过)' }
|
|
242
|
-
}
|
|
243
|
-
const saved = JSON.parse(JSON.stringify({ ...merged, updated_at: new Date().toISOString() })) as JsonObject
|
|
244
|
-
await writeJson(path, saved)
|
|
245
|
-
return { ok: true, saved: true, path, profile: saved, summary: '画像已合并落盘' }
|
|
258
|
+
const ledger = ensureLedger(config.stateRoot)
|
|
259
|
+
const res = ledger.appendMotivationPatch({ weights: incoming.weights, evidence: incoming.evidence, hard: incoming.hard })
|
|
260
|
+
const profileJson = JSON.parse(JSON.stringify(res.profile)) as JsonObject
|
|
261
|
+
return res.saved
|
|
262
|
+
? { ok: true, saved: true, path: ledger.dbPath, profile: profileJson, summary: '画像已合并入账本(单事务)' }
|
|
263
|
+
: { ok: true, saved: false, path: ledger.dbPath, profile: profileJson, summary: '无新内容(幂等跳过)' }
|
|
246
264
|
},
|
|
247
265
|
presentCall: args => ({ card: 'generic', title: '保存动机画像', kind: 'edit', rawInput: args.profile }),
|
|
248
266
|
}))
|
|
@@ -271,30 +289,11 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
271
289
|
},
|
|
272
290
|
async execute(args: { entry: unknown }, _exec: unknown) {
|
|
273
291
|
const entry = (args.entry ?? {}) as WishPoolEntryInput & { muted?: boolean }
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
const pool = (await readJson(path, [])) as Array<Record<string, unknown>>
|
|
280
|
-
// 同名憧憬幂等更新(刷新理由与成行条件),不重复落盘;wish_id 稳定(存量补签)
|
|
281
|
-
const existing = pool.findIndex(e => e['name'] === entry.name)
|
|
282
|
-
if (existing >= 0) {
|
|
283
|
-
const prev = pool[existing] ?? {}
|
|
284
|
-
pool[existing] = {
|
|
285
|
-
...prev,
|
|
286
|
-
wish_id: prev['wish_id'] ?? `w${Date.now().toString(36)}`,
|
|
287
|
-
reason: entry.reason ?? prev['reason'],
|
|
288
|
-
conditions: entry.conditions,
|
|
289
|
-
...(entry.muted !== undefined ? { muted: entry.muted } : {}),
|
|
290
|
-
}
|
|
291
|
-
await writeJson(path, pool)
|
|
292
|
-
return { ok: true, added: false, wish_id: String(pool[existing]?.['wish_id']), total: pool.length, path }
|
|
293
|
-
}
|
|
294
|
-
const created = { wish_id: `w${Date.now().toString(36)}`, reason: '', ...entry, added_at: new Date().toISOString() }
|
|
295
|
-
pool.push(created)
|
|
296
|
-
await writeJson(path, pool)
|
|
297
|
-
return { ok: true, added: true, wish_id: created.wish_id, total: pool.length, path }
|
|
292
|
+
// 同名憧憬幂等更新(刷新理由与成行条件),不重复入池;wish_id 语义派生自名称,稳定可重放。
|
|
293
|
+
// ADR-15:conditions 红线在账本事务内校验拒绝。
|
|
294
|
+
const ledger = ensureLedger(config.stateRoot)
|
|
295
|
+
const r = ledger.appendWish({ name: entry.name, reason: entry.reason, conditions: entry.conditions, muted: entry.muted })
|
|
296
|
+
return { ok: true, added: r.added, wish_id: r.wish_id, total: r.total, path: ledger.dbPath }
|
|
298
297
|
},
|
|
299
298
|
presentCall: args => ({ card: 'generic', title: '加入「下一次出发」清单', kind: 'edit', rawInput: args.entry }),
|
|
300
299
|
}))
|
|
@@ -311,7 +310,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
311
310
|
query: {
|
|
312
311
|
type: 'json',
|
|
313
312
|
required: true,
|
|
314
|
-
description: '{ action?: "recall"|"confirm-outcome", days
|
|
313
|
+
description: '{ action?: "recall"|"confirm-outcome", days?, budgetCny?, month?, wishId?, attribution?: "helpful"|"harmful"|"neutral", detail?, tripStart?: "行程起始(确认成行时挂时间线)" }',
|
|
315
314
|
},
|
|
316
315
|
},
|
|
317
316
|
output: {
|
|
@@ -319,44 +318,40 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
319
318
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
320
319
|
},
|
|
321
320
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
322
|
-
const q = unwrapQuery<{ action?: string; days?: number; budgetCny?: number; month?: number; wishId?: string; attribution?: 'helpful' | 'harmful' | 'neutral'; detail?: string }>(args, 'action')
|
|
323
|
-
const
|
|
324
|
-
const pool = (await readJson(join(dir, 'wish-pool.json'), [])) as Array<Record<string, unknown>>
|
|
325
|
-
const sidecarPath = join(dir, 'memory-utility.jsonl')
|
|
326
|
-
const loadSidecar = async (): Promise<MemoryUtilityEvent[]> => {
|
|
327
|
-
try {
|
|
328
|
-
const raw = await readFile(sidecarPath, 'utf-8')
|
|
329
|
-
return raw.split('\n').filter(Boolean).map(l => JSON.parse(l) as MemoryUtilityEvent)
|
|
330
|
-
} catch { return [] } // fail-open:sidecar 缺失/损坏不阻塞召回
|
|
331
|
-
}
|
|
332
|
-
const saveSidecar = async (events: MemoryUtilityEvent[]) => {
|
|
333
|
-
await writeFile(sidecarPath, events.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf-8')
|
|
334
|
-
}
|
|
321
|
+
const q = unwrapQuery<{ action?: string; days?: number; budgetCny?: number; month?: number; wishId?: string; attribution?: 'helpful' | 'harmful' | 'neutral'; detail?: string; tripStart?: string }>(args, 'action')
|
|
322
|
+
const ledger = ensureLedger(config.stateRoot)
|
|
335
323
|
const now = new Date().toISOString()
|
|
336
324
|
if (q.action === 'confirm-outcome') {
|
|
337
325
|
if (!q.wishId || !q.attribution) {
|
|
338
326
|
return { ok: false, summary: 'confirm-outcome 需要 wishId + attribution(helpful|harmful|neutral)' } as never
|
|
339
327
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
328
|
+
// 成行确认 = 效用事件 + 可选时间线行程,账本单事务(ADR-15:原两文件写在崩溃时分叉)
|
|
329
|
+
let tripInput: { destination: string; start: string; end?: string; companions?: string[]; source: 'wish-confirmed'; evidence: string } | undefined
|
|
330
|
+
const wish = ledger.readWishPool().find(e => String(e.wish_id) === q.wishId) as { name?: unknown } | undefined
|
|
331
|
+
if (q.tripStart && wish?.name) {
|
|
332
|
+
const anchor = buildTimeAnchor(new Date())
|
|
333
|
+
const start = resolveTimelineDate(q.tripStart, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.tripStart) ? q.tripStart : undefined)
|
|
334
|
+
if (start) {
|
|
335
|
+
tripInput = {
|
|
336
|
+
destination: String(wish.name), start, companions: undefined,
|
|
337
|
+
source: 'wish-confirmed', evidence: `wish ${q.wishId} confirm-outcome(${q.attribution})`,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const r = ledger.confirmOutcome({ wishId: q.wishId, attribution: q.attribution, detail: q.detail, trip: tripInput })
|
|
342
|
+
return { ok: true, recorded: r.recorded, wish_id: q.wishId, status: q.attribution, ...(r.trip ? { trip: r.trip } : {}) } as never
|
|
347
343
|
}
|
|
348
344
|
// recall:0..1 条件匹配(判定归 wish-pool 纯函数),muted 永不召回,无命中不硬推
|
|
349
|
-
const
|
|
345
|
+
const pool = ledger.readWishPool()
|
|
346
|
+
const candidates = pool.filter(e => !e.muted && typeof e.wish_id === 'string')
|
|
350
347
|
const month = q.month ?? new Date().getMonth() + 1
|
|
351
348
|
const match = pickNudgeWish(candidates as WishPoolEntry[], { days: q.days, budgetCny: q.budgetCny, month })
|
|
352
349
|
if (!match) {
|
|
353
350
|
return { ok: true, suggestion: null, summary: `无可成行的憧憬匹配当前窗口(${candidates.length} 条在册,0..1 纪律:不硬推)` } as never
|
|
354
351
|
}
|
|
355
|
-
const events =
|
|
356
|
-
const { events: next, appended } = appendEvent(events, {
|
|
352
|
+
const { events: next } = ledger.appendUtilityEvent({
|
|
357
353
|
wish_id: match.wishId, kind: 'recalled', ts: now, ctx: 'gotry_wish_pool_list.recall',
|
|
358
354
|
})
|
|
359
|
-
if (appended) await saveSidecar(next)
|
|
360
355
|
const utility = projectUtility(next)[match.wishId]
|
|
361
356
|
return {
|
|
362
357
|
ok: true,
|
|
@@ -368,6 +363,78 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
368
363
|
presentCall: args => ({ card: 'generic', title: '「下一次出发」召回', kind: 'search', rawInput: args.query }),
|
|
369
364
|
}))
|
|
370
365
|
|
|
366
|
+
registerGuarded(defineTool({
|
|
367
|
+
name: 'gotry_companion_save',
|
|
368
|
+
description:
|
|
369
|
+
'Save/update a travel companion profile (memory-design M5 layer): constraints that shape itinerary STRUCTURE and ranking — '
|
|
370
|
+
+ 'never a hard filter (「爸爸65轻度高血压」→ 不排高海拔/控制步行量;「晕车」→ 优先火车/备提示). '
|
|
371
|
+
+ 'evidence MUST be the user\'s verbatim words (append-only, traceable). '
|
|
372
|
+
+ 'NEGATIVE LIST: passport/ID/phone numbers are rejected on sight — such fields never enter storage.',
|
|
373
|
+
parameters: {
|
|
374
|
+
companion: {
|
|
375
|
+
type: 'json',
|
|
376
|
+
required: true,
|
|
377
|
+
description: '{ label: "爸爸", constraints: { mobility?: "步行≤4h", health?: ["轻度高血压"], prefs?: ["怕吵"] }, evidence: "<用户原话>" }',
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
output: {
|
|
381
|
+
schema: { type: 'json' },
|
|
382
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
383
|
+
},
|
|
384
|
+
async execute(args: { companion: unknown }, _exec: unknown) {
|
|
385
|
+
const c = (args.companion ?? {}) as { label?: string; constraints?: { mobility?: string; health?: string[]; prefs?: string[] }; evidence?: string }
|
|
386
|
+
if (!c.evidence) return { ok: false, summary: 'evidence 必填(用户原话,溯源 P0)' } as never
|
|
387
|
+
// ADR-15:负面清单守卫(upsertCompanion)在账本事务内执行,拒收即回滚
|
|
388
|
+
const ledger = ensureLedger(config.stateRoot)
|
|
389
|
+
const res = ledger.appendCompanion({
|
|
390
|
+
label: String(c.label ?? ''),
|
|
391
|
+
constraints: { mobility: c.constraints?.mobility, health: c.constraints?.health, prefs: c.constraints?.prefs },
|
|
392
|
+
evidence: c.evidence,
|
|
393
|
+
})
|
|
394
|
+
if (!res.appended && res.reason) return { ok: false, summary: res.reason } as never
|
|
395
|
+
return { ok: true, appended: res.appended, companion_id: res.companionId, total: res.total, summary: res.appended ? `同行人已保存:${res.companionId}` : `无新内容(幂等跳过):${res.companionId}` } as never
|
|
396
|
+
},
|
|
397
|
+
presentCall: args => ({ card: 'generic', title: '保存同行人', kind: 'edit', rawInput: args.companion }),
|
|
398
|
+
}))
|
|
399
|
+
|
|
400
|
+
registerGuarded(defineTool({
|
|
401
|
+
name: 'gotry_trip_log',
|
|
402
|
+
description:
|
|
403
|
+
'Record a PAST trip into the travel timeline (memory-design M4 layer). Use when the user mentions a trip they already took '
|
|
404
|
+
+ '(「去年国庆去了大理」) — dates resolve via the time anchor (词表外日期会拒绝,请用户给绝对日期), evidence MUST be the user\'s verbatim words. '
|
|
405
|
+
+ 'Append-only and idempotent (same destination+start+source = same trip); overlapping same-destination trips are rejected for human adjudication. '
|
|
406
|
+
+ 'Past trips power origin resolution and 「去过不再推」 ranking — never a hard filter.',
|
|
407
|
+
parameters: {
|
|
408
|
+
query: {
|
|
409
|
+
type: 'json',
|
|
410
|
+
required: true,
|
|
411
|
+
description: '{ destination: "大理", start: "2025-10-01 或绝对日期表达", end?: 同上, companions?: ["爸爸"], evidence: "<用户原话>" }',
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
output: {
|
|
415
|
+
schema: { type: 'json' },
|
|
416
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
417
|
+
},
|
|
418
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
419
|
+
const q = unwrapQuery<{ destination?: string; start?: string; end?: string; companions?: string[]; evidence?: string }>(args, 'destination')
|
|
420
|
+
if (!q.destination) return { ok: false, summary: 'destination 必填' } as never
|
|
421
|
+
if (!q.evidence) return { ok: false, summary: 'evidence 必填(用户原话,溯源 P0)' } as never
|
|
422
|
+
const anchor = buildTimeAnchor(new Date())
|
|
423
|
+
const start = resolveTimelineDate(q.start ?? '', anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.start ?? '') ? q.start : undefined)
|
|
424
|
+
if (!start) return { ok: false, summary: `start 无法解析为绝对日期(${q.start ?? '缺'})——请向用户要具体日期,不猜` } as never
|
|
425
|
+
const end = q.end ? (resolveTimelineDate(q.end, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.end) ? q.end : undefined)) : undefined
|
|
426
|
+
// ADR-15:appendTrip 守门(必填/绝对日期/重叠冲突即停)在账本事务内执行
|
|
427
|
+
const ledger = ensureLedger(config.stateRoot)
|
|
428
|
+
const res = ledger.appendTripEvent({
|
|
429
|
+
destination: q.destination.trim(), start, end, companions: q.companions,
|
|
430
|
+
source: 'user-verbatim', evidence: q.evidence,
|
|
431
|
+
})
|
|
432
|
+
if (!res.appended && res.reason) return { ok: false, summary: res.reason } as never
|
|
433
|
+
return { ok: true, appended: res.appended, trip_id: res.tripId, total: res.total, summary: res.appended ? `已入时间线:${q.destination} @ ${start}` : `已存在(幂等跳过):${res.tripId}` } as never
|
|
434
|
+
},
|
|
435
|
+
presentCall: args => ({ card: 'generic', title: '记录旅行', kind: 'edit', rawInput: args.query }),
|
|
436
|
+
}))
|
|
437
|
+
|
|
371
438
|
registerGuarded(defineTool({
|
|
372
439
|
name: 'gotry_hotel_search',
|
|
373
440
|
description:
|
|
@@ -573,6 +640,72 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
573
640
|
presentCall: args => ({ card: 'generic', title: `飞行校验:${String((args.query as { callsign?: string })?.callsign ?? '')}`, kind: 'fetch', rawInput: args.query }),
|
|
574
641
|
}))
|
|
575
642
|
|
|
643
|
+
registerGuarded(defineTool({
|
|
644
|
+
name: 'gotry_flyai_search',
|
|
645
|
+
description:
|
|
646
|
+
'Live flight/train search via Fliggy official FlyAI channel (read-only, no key, booking only via jumpUrl by the human). '
|
|
647
|
+
+ 'PRIMARY source for real schedules & prices (RFC user-session-data-rfc). Input: kind=flight|train, from/to city names (Chinese), date YYYY-MM-DD. '
|
|
648
|
+
+ 'Evidence [实时API:flyai@ts]. Cross-validate expensive claims against gotry_session_search when it matters.',
|
|
649
|
+
parameters: {
|
|
650
|
+
query: { type: 'json', required: true, description: '{ kind: "flight"|"train", from: "上海", to: "丽江", date: "2026-10-01" }' },
|
|
651
|
+
},
|
|
652
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
653
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
654
|
+
const q = unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string }>(args, 'from')
|
|
655
|
+
const kind = q.kind === 'train' ? 'train' : 'flight'
|
|
656
|
+
if (!q.from || !q.to || !q.date) {
|
|
657
|
+
return { ok: false, summary: '需要 from/to(中文城市名)与 date(YYYY-MM-DD)' } as const
|
|
658
|
+
}
|
|
659
|
+
const r = await flyaiSearch({ kind, origin: q.from, destination: q.to, depDate: q.date })
|
|
660
|
+
const top = (r.options ?? []).slice(0, 8).map(o => `${o.no} ${o.name} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`)
|
|
661
|
+
const summary = r.verdict === 'hit'
|
|
662
|
+
? `${q.from}→${q.to} ${q.date} ${kind === 'flight' ? '机票' : '火车票'}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
|
|
663
|
+
: `${q.from}→${q.to} ${q.date} 无结果或失败:${r.error ?? 'miss'} ${r.evidence}`
|
|
664
|
+
return JSON.parse(JSON.stringify({ ...r, kind, summary })) as Record<string, never>
|
|
665
|
+
},
|
|
666
|
+
presentCall: args => ({ card: 'generic', title: `官方检索:${String((args.query as { kind?: string })?.kind ?? 'flight')}`, kind: 'fetch', rawInput: args.query }),
|
|
667
|
+
presentResult: (args, value) => {
|
|
668
|
+
const r = value as { ok?: boolean; options?: unknown[] }
|
|
669
|
+
return { card: 'generic', title: `飞猪检索:${r.ok && (r.options?.length ?? 0) > 0 ? `${r.options!.length} 条` : '降级'}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
|
|
670
|
+
},
|
|
671
|
+
}))
|
|
672
|
+
|
|
673
|
+
registerGuarded(defineTool({
|
|
674
|
+
name: 'gotry_session_search',
|
|
675
|
+
description:
|
|
676
|
+
'Cross-validation search on the user\'s OWN logged-in browser session (dedicated persistent profile, ReadGuard = physically read-only: '
|
|
677
|
+
+ 'write requests are aborted at network layer; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
|
|
678
|
+
+ 'Currently ctrip-flight: sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
|
|
679
|
+
+ 'verdict needs-login = run scripts/session-login.ts once with the human logging in. Rate-limited (≥30s between same-site calls). '
|
|
680
|
+
+ 'Use to verify/double-check prices from gotry_flyai_search on the same itinerary (split by 直达/中转).',
|
|
681
|
+
parameters: {
|
|
682
|
+
query: { type: 'json', required: true, description: '{ from: "上海", to: "丽江", date: "2026-10-01" }' },
|
|
683
|
+
},
|
|
684
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
685
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
686
|
+
const q = unwrapQuery<{ from?: string; to?: string; date?: string }>(args, 'from')
|
|
687
|
+
if (!q.from || !q.to || !q.date) {
|
|
688
|
+
return { ok: false, summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD)' } as const
|
|
689
|
+
}
|
|
690
|
+
const r = await sessionFlightSearch({
|
|
691
|
+
from: q.from, to: q.to, date: q.date,
|
|
692
|
+
// ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
|
|
693
|
+
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
694
|
+
})
|
|
695
|
+
const top = (r.options ?? []).slice(0, 8).map(o => `${o.flightNo} ${o.airline} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`)
|
|
696
|
+
const summary = r.verdict === 'hit'
|
|
697
|
+
? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
|
|
698
|
+
: `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`
|
|
699
|
+
return JSON.parse(JSON.stringify({ ...r, summary })) as Record<string, never>
|
|
700
|
+
},
|
|
701
|
+
presentCall: args => ({ card: 'generic', title: `会话检索:${String((args.query as { from?: string })?.from ?? '')}`, kind: 'fetch', rawInput: args.query }),
|
|
702
|
+
presentResult: (_args, value) => {
|
|
703
|
+
const r = value as { verdict?: string; options?: unknown[] }
|
|
704
|
+
const label = r.verdict === 'hit' ? `会话 ${r.options!.length} 条` : (r.verdict === 'needs-login' ? '需登录' : r.verdict ?? '降级')
|
|
705
|
+
return { card: 'generic', title: `会话检索:${label}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
|
|
706
|
+
},
|
|
707
|
+
}))
|
|
708
|
+
|
|
576
709
|
registerGuarded(defineTool({
|
|
577
710
|
name: 'gotry_anything_search',
|
|
578
711
|
description:
|
package/ts/src/loop.ts
CHANGED
|
@@ -292,42 +292,82 @@ export async function runTurn(
|
|
|
292
292
|
return { reply: parts.join('\n\n'), state }
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
// ---- 异步工单持久化(S5 编排半段):真正的「一小时后」必须跨进程存续 ----
|
|
296
|
-
//
|
|
297
|
-
//
|
|
295
|
+
// ---- 异步工单持久化(S5 编排半段 → ADR-15 durable 工单):真正的「一小时后」必须跨进程存续 ----
|
|
296
|
+
// 权威 = 账本 workflow_runs/workflow_steps(单事务;崩溃后 done 步骤不重执行);
|
|
297
|
+
// {id}.json / {id}.deliverable.md 降级为视图(AGENTS.md 清扫合同 + 人工检视),
|
|
298
|
+
// tmp+rename 原子写——此前裸 writeFile 的非原子缺口(RFC §1.1)就此关闭。
|
|
298
299
|
|
|
299
300
|
import { join } from 'node:path'
|
|
300
|
-
import { mkdir } from 'node:fs/promises'
|
|
301
|
-
import {
|
|
301
|
+
import { mkdir, rename, writeFile, readFile } from 'node:fs/promises'
|
|
302
|
+
import { ensureLedger, openLedgerIfExists, type StateLedger } from './state-ledger.ts'
|
|
302
303
|
|
|
303
|
-
|
|
304
|
+
async function asyncDir(stateRoot: string): Promise<string> {
|
|
305
|
+
const root = stateRoot === '.' ? process.cwd() : stateRoot
|
|
306
|
+
const dir = join(root, 'gotry-state', 'async')
|
|
307
|
+
await mkdir(dir, { recursive: true })
|
|
308
|
+
return dir
|
|
309
|
+
}
|
|
304
310
|
|
|
305
|
-
async function
|
|
306
|
-
|
|
307
|
-
|
|
311
|
+
async function atomicWrite(path: string, text: string): Promise<void> {
|
|
312
|
+
const tmp = `${path}.tmp`
|
|
313
|
+
await writeFile(tmp, text, 'utf-8')
|
|
314
|
+
await rename(tmp, path)
|
|
308
315
|
}
|
|
309
316
|
|
|
310
|
-
export async function persistAsyncTicket(ticket: AsyncTicket, state: TripState): Promise<string> {
|
|
311
|
-
const
|
|
312
|
-
|
|
317
|
+
export async function persistAsyncTicket(ticket: AsyncTicket, state: TripState, stateRoot = '.'): Promise<string> {
|
|
318
|
+
const ledger = ensureLedger(stateRoot)
|
|
319
|
+
ledger.createWorkflowRun({ id: ticket.id, goal: ticket.objective, ticket, state })
|
|
320
|
+
const dir = await asyncDir(stateRoot)
|
|
321
|
+
const p = join(dir, `${ticket.id}.json`)
|
|
322
|
+
await atomicWrite(p, JSON.stringify({ ticket, state }, null, 2))
|
|
313
323
|
return p
|
|
314
324
|
}
|
|
315
325
|
|
|
316
|
-
export async function loadAsyncTicket(ticketId: string): Promise<{ ticket: AsyncTicket; state: TripState } | null> {
|
|
326
|
+
export async function loadAsyncTicket(ticketId: string, stateRoot = '.'): Promise<{ ticket: AsyncTicket; state: TripState } | null> {
|
|
327
|
+
// 账本优先;无账本的 root 回退旧 json 文件(只读,兼容存量已交付工单)
|
|
328
|
+
const ledger = openLedgerIfExists(stateRoot)
|
|
329
|
+
const run = ledger?.getWorkflowRun(ticketId)
|
|
330
|
+
if (run) {
|
|
331
|
+
return {
|
|
332
|
+
ticket: JSON.parse(run.ticket_json) as AsyncTicket,
|
|
333
|
+
state: JSON.parse(run.state_json) as TripState,
|
|
334
|
+
}
|
|
335
|
+
}
|
|
317
336
|
try {
|
|
318
|
-
const p = await
|
|
337
|
+
const p = join(await asyncDir(stateRoot), `${ticketId}.json`)
|
|
319
338
|
return JSON.parse(await readFile(p, 'utf-8')) as { ticket: AsyncTicket; state: TripState }
|
|
320
339
|
} catch {
|
|
321
340
|
return null
|
|
322
341
|
}
|
|
323
342
|
}
|
|
324
343
|
|
|
325
|
-
export async function settleAsyncTicket(ticketId: string, reply: string): Promise<string> {
|
|
326
|
-
|
|
327
|
-
await
|
|
344
|
+
export async function settleAsyncTicket(ticketId: string, reply: string, stateRoot = '.'): Promise<string> {
|
|
345
|
+
openLedgerIfExists(stateRoot)?.settleWorkflowRun(ticketId, reply)
|
|
346
|
+
const dir = await asyncDir(stateRoot)
|
|
347
|
+
const p = join(dir, `${ticketId}.deliverable.md`)
|
|
348
|
+
await atomicWrite(p, reply)
|
|
328
349
|
return p
|
|
329
350
|
}
|
|
330
351
|
|
|
352
|
+
/**
|
|
353
|
+
* 可日志化 solve 端口(ADR-15 步骤日志,intent-before-execute):
|
|
354
|
+
* 先记 intent 再执行,done 后结果落账本——任意进程恢复时 done 的步骤直接复用
|
|
355
|
+
* 结果不重执行(exactly-once:LLM/求解调用不重复花钱),intent 悬挂的重试。
|
|
356
|
+
*/
|
|
357
|
+
export function makeJournaledSolvePort(ledger: StateLedger, runId: string, solve: SolvePort, opts?: { onRealSolve?: () => void }): SolvePort {
|
|
358
|
+
return async spec => {
|
|
359
|
+
const step = ledger.getWorkflowStep(runId, 'solve')
|
|
360
|
+
if (step?.status === 'done' && step.result) {
|
|
361
|
+
return JSON.parse(step.result) as Awaited<ReturnType<SolvePort>>
|
|
362
|
+
}
|
|
363
|
+
ledger.markStepIntent(runId, 'solve')
|
|
364
|
+
opts?.onRealSolve?.()
|
|
365
|
+
const r = await solve(spec)
|
|
366
|
+
ledger.markStepDone(runId, 'solve', r)
|
|
367
|
+
return r
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
331
371
|
/**
|
|
332
372
|
* probePoi:从 user msg 探测"查 POI/酒店" 信号,返回关键词。
|
|
333
373
|
* 触发的不是关键词,是**结构**;关键词抓取有方向性——
|
package/ts/src/unified.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface MoveSpecTS {
|
|
|
28
28
|
destTransferMin: number
|
|
29
29
|
redEye?: boolean
|
|
30
30
|
redEyeDurationMin?: number
|
|
31
|
+
/** D-6:红眼航段落地后接驳(机场→住处/办公室)的乘车补眠时长(分钟);机上已算,落地接驳未算 */
|
|
32
|
+
groundRecoveryMin?: number
|
|
31
33
|
/** D-5:目的地相对出发地的时差(KMG-BKK=+60,DXB-SZX=-240) */
|
|
32
34
|
tzOffsetMin?: number
|
|
33
35
|
/** M-1:出发地 UTC 偏移(工作窗口换算用) */
|
|
@@ -227,7 +229,10 @@ function evaluateOptionMove(segId: string, mv: MoveSpecTS): LegReport & { d2d_mi
|
|
|
227
229
|
let energy: number
|
|
228
230
|
if (mv.redEye && (mv.redEyeDurationMin ?? 0) > 0) {
|
|
229
231
|
const sleepH = ((mv.redEyeDurationMin ?? 0) - 60) / 60
|
|
230
|
-
|
|
232
|
+
// D-6 校准:机上睡眠上限 75;落地接驳(destTransferMin)乘车补眠回血(1h≈+5%,上限 80)
|
|
233
|
+
const groundH = (mv.groundRecoveryMin ?? mv.destTransferMin ?? 0) / 60
|
|
234
|
+
const recovery = Math.min(5, 5 * groundH)
|
|
235
|
+
energy = Math.max(30, Math.min(75 + recovery, 80, 30 + 8 * sleepH + recovery))
|
|
231
236
|
} else {
|
|
232
237
|
energy = 100 - 2 * 8
|
|
233
238
|
if (wake < 5 * 60) energy -= 30
|