@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.29
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +56 -12
- package/data/yunnan-pack.json +30 -27
- package/dist/capabilities/anything.js +26 -0
- package/dist/capabilities/channel-registry.js +4 -4
- package/dist/capabilities/doctor.js +14 -7
- package/dist/capabilities/effect.js +1 -1
- package/dist/capabilities/ground-transfer.js +168 -1
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/capabilities/session/extension-bridge.js +3 -3
- package/dist/capabilities/session-search.js +1 -1
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +136 -2
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
- package/dist/scripts/bootstrap-tests.js +77 -7
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/doctor-tests.js +39 -3
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/g5-guard-tests.js +20 -1
- package/dist/scripts/g5-guard.js +5 -1
- package/dist/scripts/gotry-backend-kernel-tests.js +1 -1
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/nightly-evidence-tests.js +44 -2
- package/dist/scripts/nightly-evidence.js +21 -4
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +621 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/replay-async.js +2 -0
- package/dist/scripts/replay.js +2 -0
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-dida-live-e2e.js +1 -1
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +17 -1
- package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
- package/dist/scripts/state-cli.js +16 -0
- package/dist/scripts/time-eval-tests.js +2 -0
- package/dist/scripts/unified-tests.js +79 -2
- package/dist/scripts/z3-race-tests.js +6 -1
- package/dist/src/backend/modules/session-search.js +1 -1
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/dsh-llm.js +84 -14
- package/dist/src/index.js +166 -7
- package/dist/src/loop.js +16 -5
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +7 -4
- package/dist/src/state-ledger.js +293 -40
- package/dist/src/unified.js +69 -5
- package/extension/README.md +1 -1
- package/extension/README.zh-CN.md +1 -1
- package/extension/background.js +1 -1
- package/extension/content-bridge.js +1 -1
- package/extension/content-main.js +1 -1
- package/extension/manifest.json +2 -2
- package/package.json +1 -1
- package/ts/capabilities/anything.ts +27 -0
- package/ts/capabilities/session/extension-bridge.ts +3 -3
- package/ts/capabilities/session-search.ts +1 -1
- package/ts/scripts/state-cli.ts +18 -0
- package/ts/src/contracts.ts +4 -0
- package/ts/src/dsh-llm.ts +111 -9
- package/ts/src/index.ts +181 -8
- package/ts/src/loop.ts +15 -5
- package/ts/src/state-ledger.ts +294 -40
- package/ts/src/unified.ts +91 -18
package/dist/src/dsh-llm.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 真 LlmPort:OpenAI 兼容接口的 provider 中立适配器(DeepSeek/MiniMax-M2 实测)。
|
|
3
3
|
* 零新依赖(node 内建 fetch)。环境变量:LLM_API_KEY/LLM_BASE_URL/LLM_MODEL
|
|
4
|
-
* (兼容旧 DEEPSEEK_* 别名)
|
|
4
|
+
* (兼容旧 DEEPSEEK_* 别名);GOTRY_LLM_TIMEOUT_MS = 单次请求总预算(默认 300000ms,
|
|
5
|
+
* 非正整数忽略)。MiniMax-M2 是推理模型:输出带 <think> 块,
|
|
5
6
|
* 必须先剥离再解析——JSON 藏在 think 里是常见失败模式。
|
|
6
7
|
* 无 key 时抛出明确错误,replay-real 自动回退 mock(ADR-8)。
|
|
7
8
|
* 责任铁律不变:本适配器只做翻译/润色/解释,判定与算术在确定性组件。
|
|
@@ -17,12 +18,73 @@ const base = () => (process.env['LLM_BASE_URL'] ?? process.env['DEEPSEEK_BASE_UR
|
|
|
17
18
|
function emptyUsage() {
|
|
18
19
|
return { calls: 0, inputTokens: 0, outputTokens: 0, inputCacheHitTokens: 0, inputCacheMissTokens: 0, responsesMissingUsage: 0 };
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
/** 单次请求(连接→响应头→正文读完)的默认总预算:对推理模型足够宽松,只兜「连上了却永不应答」的卡死。 */
|
|
22
|
+
export const DEFAULT_LLM_TIMEOUT_MS = 300_000;
|
|
23
|
+
/**
|
|
24
|
+
* 超时上限 = 2^31-1 ms(≈24.8 天)。Node 对更大的延迟会溢出并**悄悄改成 1ms**
|
|
25
|
+
* (TimeoutOverflowWarning),即「配得越大越立刻超时」——所以必须夹住,绝不原样透传。
|
|
26
|
+
*/
|
|
27
|
+
const MAX_LLM_TIMEOUT_MS = 2_147_483_647;
|
|
28
|
+
/** 只认十进制正整数(数值或纯数字串);其余(0/负数/小数/NaN/带单位/空串)一律 undefined——绝不退化成「无超时」。 */
|
|
29
|
+
function parseTimeoutMs(raw) {
|
|
30
|
+
const n = typeof raw === 'string' ? (/^\d+$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN) : raw;
|
|
31
|
+
return typeof n === 'number' && Number.isSafeInteger(n) && n > 0 ? Math.min(n, MAX_LLM_TIMEOUT_MS) : undefined;
|
|
32
|
+
}
|
|
33
|
+
/** 解析顺序:本次调用显式值 > 环境变量 GOTRY_LLM_TIMEOUT_MS(调用时取值,同 base()/model())> 默认值。 */
|
|
34
|
+
export function resolveLlmTimeoutMs(callMs) {
|
|
35
|
+
return parseTimeoutMs(callMs) ?? parseTimeoutMs(process.env['GOTRY_LLM_TIMEOUT_MS']) ?? DEFAULT_LLM_TIMEOUT_MS;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* chat() 请求被截断(超时 / 调用方取消)的类型化失败——不是挂起,也不会被吞。
|
|
39
|
+
* 与既有错误形态一致,而非另起一套:
|
|
40
|
+
* - message 沿用本模块 `llm <kind>: <detail>` 约定(同 `llm 429: …`),只读 .message 的调用方
|
|
41
|
+
* (nightly-evidence / persona-sim / time-eval --real)原样可用;
|
|
42
|
+
* - name 取平台既有名 TimeoutError / AbortError(AbortSignal.timeout 与手动 abort 抛的同名),
|
|
43
|
+
* persona-sim、channel-probe 等按 name 分类的代码零改动即可识别;
|
|
44
|
+
* - cause 保留 fetch 抛出的原始错误(调用方自定义的 abort reason 也在其中)。
|
|
45
|
+
* HTTP 非 2xx(`llm <status>`)、DNS/连接重置、坏 JSON 仍是原样的普通 Error——只改写「被我们的 signal 终止」的失败。
|
|
46
|
+
*/
|
|
47
|
+
export class LlmRequestError extends Error {
|
|
48
|
+
code;
|
|
49
|
+
/** 本次生效的超时预算;仅 code === 'timeout' 有值 */
|
|
50
|
+
timeoutMs;
|
|
51
|
+
constructor(code, detail, options = {}) {
|
|
52
|
+
super(`llm ${code}: ${detail}`, options.cause === undefined ? undefined : { cause: options.cause });
|
|
53
|
+
this.name = code === 'timeout' ? 'TimeoutError' : 'AbortError';
|
|
54
|
+
this.code = code;
|
|
55
|
+
this.timeoutMs = options.timeoutMs;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 发一次请求并读完正文。**同一个 signal 贯穿 fetch 与 res.json()/res.text()**,所以超时覆盖整个请求
|
|
60
|
+
* (含「响应头已到、正文卡住」与「非 2xx 的错误正文卡住」),调用方取消同理。
|
|
61
|
+
* 只有「被我们的 signal 终止」才改写成 LlmRequestError;其余错误原样上抛。
|
|
62
|
+
*/
|
|
63
|
+
async function requestCompletion(url, init, request) {
|
|
64
|
+
const timeoutMs = resolveLlmTimeoutMs(request.timeoutMs);
|
|
65
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
66
|
+
const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(url, { method: 'POST', headers: init.headers, body: init.body, signal });
|
|
69
|
+
if (!res.ok)
|
|
70
|
+
return { ok: false, status: res.status, text: await res.text() };
|
|
71
|
+
return { ok: true, data: await res.json() };
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (!signal.aborted)
|
|
75
|
+
throw error;
|
|
76
|
+
// AbortSignal.any 的 reason 是最先触发的那个源的 reason:等于超时信号的 reason 即超时先到,否则是调用方取消
|
|
77
|
+
if (timeout.aborted && signal.reason === timeout.reason) {
|
|
78
|
+
throw new LlmRequestError('timeout', `no complete response within ${timeoutMs}ms (GOTRY_LLM_TIMEOUT_MS)`, { cause: error, timeoutMs });
|
|
79
|
+
}
|
|
80
|
+
throw new LlmRequestError('aborted', "request cancelled by the caller's AbortSignal", { cause: error });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function chat(messages, json, usage, request = {}) {
|
|
21
84
|
const key = process.env['LLM_API_KEY'] ?? process.env['DEEPSEEK_API_KEY'];
|
|
22
85
|
if (!key)
|
|
23
86
|
throw new Error('LLM_API_KEY 未设置(兼容 DEEPSEEK_API_KEY 别名)——真 LLM 路径不可用,请回退 mock(ADR-8)');
|
|
24
|
-
const
|
|
25
|
-
method: 'POST',
|
|
87
|
+
const reply = await requestCompletion(`${base()}/chat/completions`, {
|
|
26
88
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
27
89
|
body: JSON.stringify({
|
|
28
90
|
model: model(),
|
|
@@ -30,10 +92,10 @@ async function chat(messages, json, usage) {
|
|
|
30
92
|
...(json ? { response_format: { type: 'json_object' } } : {}),
|
|
31
93
|
temperature: json ? 0 : 0.7,
|
|
32
94
|
}),
|
|
33
|
-
});
|
|
34
|
-
if (!
|
|
35
|
-
throw new Error(`llm ${
|
|
36
|
-
const data =
|
|
95
|
+
}, request);
|
|
96
|
+
if (!reply.ok)
|
|
97
|
+
throw new Error(`llm ${reply.status}: ${reply.text.slice(0, 300)}`);
|
|
98
|
+
const data = reply.data;
|
|
37
99
|
if (usage) {
|
|
38
100
|
usage.calls += 1;
|
|
39
101
|
const u = data.usage;
|
|
@@ -75,7 +137,11 @@ const SKELETON_SYSTEM = `你是行程骨架抽取器。从对话中抽取行程
|
|
|
75
137
|
输出 JSON:{"scenario":"erhai|workation|yunnan|generic","segments":[{"id":"f1","role":"choice|fixed","route":"HKG->HKT","dateHint":"2026-07-18","anchors":{"arriveByMin":885}}]}
|
|
76
138
|
规则:每个跨城移动一段;锚点只放用户明说或必然的(如"当天到"→arriveByMin 23:59=1439);时刻用当日分钟。
|
|
77
139
|
scenario 判定:「洱海/大理/千岛湖/太湖+选目的地」→erhai(候选集);「普吉/workation/远程办公+多城链」→workation(五段链);「云南/大理丽江」→yunnan;不确定→generic。只输出 JSON。`;
|
|
78
|
-
|
|
140
|
+
/**
|
|
141
|
+
* request:本端口每次 chat() 共用的请求级选项(timeoutMs / signal)。signal 是端口级取消——
|
|
142
|
+
* 调用方可借它在会话/批次截止时真正掐断在途请求,而不是放着它继续挂在 provider 上。
|
|
143
|
+
*/
|
|
144
|
+
export function createOpenAICompatLlm(flightPackPath, clock = () => new Date(), request = {}) {
|
|
79
145
|
const pack = flightPackPath;
|
|
80
146
|
const usage = emptyUsage();
|
|
81
147
|
const historyText = (h) => h.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.text}`).join('\n');
|
|
@@ -84,7 +150,7 @@ export function createOpenAICompatLlm(flightPackPath, clock = () => new Date())
|
|
|
84
150
|
const anchorContext = () => `时间锚点卡:\n${buildTimeAnchor(clock()).card}`;
|
|
85
151
|
return {
|
|
86
152
|
async extractFacts(history) {
|
|
87
|
-
const out = await chat([{ role: 'system', content: FACTS_SYSTEM }, { role: 'user', content: `${anchorContext()}\n\n${historyText(history)}` }], true, usage);
|
|
153
|
+
const out = await chat([{ role: 'system', content: FACTS_SYSTEM }, { role: 'user', content: `${anchorContext()}\n\n${historyText(history)}` }], true, usage, request);
|
|
88
154
|
const obj = parseJsonBlock(out);
|
|
89
155
|
if (!obj)
|
|
90
156
|
return { assumptions: [] };
|
|
@@ -96,7 +162,7 @@ export function createOpenAICompatLlm(flightPackPath, clock = () => new Date())
|
|
|
96
162
|
async extractSpec(history, state) {
|
|
97
163
|
// 架构(ADR-10):LLM 只产骨架与锚点;班次数据永远来自能力层(数据包/未来实时API)
|
|
98
164
|
const context = `${anchorContext()}\n已断言日历:${JSON.stringify(state.calendar.assertedWeekdays)}\nprofile:${JSON.stringify(state.profile)}`;
|
|
99
|
-
const out = await chat([{ role: 'system', content: SKELETON_SYSTEM }, { role: 'user', content: `${context}\n\n${historyText(history)}` }], true, usage);
|
|
165
|
+
const out = await chat([{ role: 'system', content: SKELETON_SYSTEM }, { role: 'user', content: `${context}\n\n${historyText(history)}` }], true, usage, request);
|
|
100
166
|
const skeleton = parseJsonBlock(out);
|
|
101
167
|
if (!skeleton || !Array.isArray(skeleton['segments']) || skeleton['segments'].length === 0)
|
|
102
168
|
return null;
|
|
@@ -126,7 +192,11 @@ export function createOpenAICompatLlm(flightPackPath, clock = () => new Date())
|
|
|
126
192
|
}
|
|
127
193
|
packSpec = parseFlightPackToSpec(JSON.parse(await readFile(packPath, 'utf-8')));
|
|
128
194
|
}
|
|
129
|
-
catch {
|
|
195
|
+
catch (e) {
|
|
196
|
+
// issue #620:数据包被 parse 边界拒收时,原因必须留痕——否则用户只看到
|
|
197
|
+
// 「骨架还不完整」,运维看不到是哪个包的哪个字段把求解面挡住了。
|
|
198
|
+
const err = e;
|
|
199
|
+
console.error(`[gotry] extractSpec: 数据包不可用 ${packPath}(${err.code ?? 'parse_failed'}):`, (err.message ?? '').slice(0, 200));
|
|
130
200
|
return null;
|
|
131
201
|
}
|
|
132
202
|
const anchorsById = new Map(skeleton['segments'].map(s => [String(s['id'] ?? ''), (s['anchors'] ?? {})]));
|
|
@@ -142,7 +212,7 @@ export function createOpenAICompatLlm(flightPackPath, clock = () => new Date())
|
|
|
142
212
|
async extractSlots(history, now) {
|
|
143
213
|
// now 可注入(评测固定锚点);过期判定在 flagExpiredSlots(代码层),模型只管逐字抽取
|
|
144
214
|
const anchor = buildTimeAnchor(now ?? clock());
|
|
145
|
-
const out = await chat([{ role: 'system', content: buildSlotSystem(anchor) }, { role: 'user', content: historyText(history) }], true, usage);
|
|
215
|
+
const out = await chat([{ role: 'system', content: buildSlotSystem(anchor) }, { role: 'user', content: historyText(history) }], true, usage, request);
|
|
146
216
|
const obj = parseJsonBlock(out);
|
|
147
217
|
if (!obj)
|
|
148
218
|
return null;
|
|
@@ -152,7 +222,7 @@ export function createOpenAICompatLlm(flightPackPath, clock = () => new Date())
|
|
|
152
222
|
},
|
|
153
223
|
async polishQuestion(q) {
|
|
154
224
|
const out = await chat([{ role: 'system', content: '把旅行规划的追问润色得更自然,保留全部信息(含为什么问),一两句话,不要加表情。' },
|
|
155
|
-
{ role: 'user', content: `【${q.key}】${q.text}(为什么问:${q.why})` }], false, usage);
|
|
225
|
+
{ role: 'user', content: `【${q.key}】${q.text}(为什么问:${q.why})` }], false, usage, request);
|
|
156
226
|
return out.trim() || `【${q.key}】${q.text}`;
|
|
157
227
|
},
|
|
158
228
|
usage,
|
package/dist/src/index.js
CHANGED
|
@@ -24,7 +24,8 @@ import { interpretArgs } from './tool-packet.js';
|
|
|
24
24
|
import { projectUtility } from './memory-utility.js';
|
|
25
25
|
import { pickNudgeWish } from './wish-pool.js';
|
|
26
26
|
import { resolveTimelineDate } from './travel-timeline.js';
|
|
27
|
-
import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback } from './state-ledger.js';
|
|
27
|
+
import { ensureLedger, openLedgerIfExists, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback } from './state-ledger.js';
|
|
28
|
+
import { applyZoneNote, bindZoneSession, boundZoneSession, isZoneObservableTool, observeToolResult, promoteWithRouting, renderSessionZoneBrief, resolveZoneSwitch, zoneScopeOfExec, zoneSessionRefFromExec, } from './session-zone-wiring.js';
|
|
28
29
|
import { isValidHomeCityPreference, resolveDefaultOrigin } from './memory-capture.js';
|
|
29
30
|
import { applyPlanningWindow } from './loop.js';
|
|
30
31
|
import { buildTimeAnchor } from './time-anchor.js';
|
|
@@ -84,6 +85,7 @@ export const Config = z.object({
|
|
|
84
85
|
timeoutMs: z.number().default(30_000),
|
|
85
86
|
hbcliBin: z.string().default('hbcli'),
|
|
86
87
|
sessionAccess: z.string().default('ask'),
|
|
88
|
+
sessionZones: z.string().default('off'),
|
|
87
89
|
benchmarkEnvironmentConfigPath: z.string().default(''),
|
|
88
90
|
lavishAxiPackageRoot: z.string().default(''),
|
|
89
91
|
});
|
|
@@ -450,6 +452,9 @@ export function apply(ctx, config, seams = {}) {
|
|
|
450
452
|
}
|
|
451
453
|
// 时间感知:注册动态变量,persona 里用 {{current_date}} 引用。
|
|
452
454
|
// 每次 assemble 时取系统时钟——LLM 始终知道「今天是几号」。
|
|
455
|
+
// provider 收到本次组装的 AssembleContext(dsh-system-prompt 契约:
|
|
456
|
+
// `variable(name, provider: (context: AssembleContext) => string | undefined)`);
|
|
457
|
+
// `scope` 是不透明、按身份比较的 ScopeKey —— 分区读回据此分辨会话。
|
|
453
458
|
const sp = ctx['systemPrompt'];
|
|
454
459
|
sp?.variable?.('current_date', () => {
|
|
455
460
|
const d = new Date();
|
|
@@ -464,6 +469,32 @@ export function apply(ctx, config, seams = {}) {
|
|
|
464
469
|
// 模型都是盲的、重新访谈——「回访规划时长降 ≥50%」不可能成立。这里把画像
|
|
465
470
|
// 渲染成紧凑 brief 注入 persona;为空 = 首访。与当轮说法冲突时以用户为准。
|
|
466
471
|
sp?.variable?.('motivation_brief', () => renderMotivationBrief(config.stateRoot ?? '.'));
|
|
472
|
+
// 会话双区记忆读回(issue #255 P4-3,design/session-dual-zone-memory-design.md §5):
|
|
473
|
+
// 变量恒注册(persona 侧可随时引用),但总闸 off(默认)时恒返回空串 —— 接线惰性,
|
|
474
|
+
// 不建库、不读库、零事件;persona 模板本身不在本 PR 改动,因此开关 off 时注入面
|
|
475
|
+
// 输出逐字节不变。空串 = 首访/无活笔记(与 motivation_brief 同纪律)。
|
|
476
|
+
// 会话双区读回(issue #255 P4-3):**只在总闸 on 时注册**。
|
|
477
|
+
// - 为什么是条件注册:默认关必须与 main **不可区分**——注入面多出一个变量名
|
|
478
|
+
// 就已经是可观测差异(§48 benchmark-environment-bridge-tests 的
|
|
479
|
+
// 「normal product mode keeps its prompt variables」逐项断言会红)。
|
|
480
|
+
// 出厂 persona(cordis.gotry-patch.yml)今天不引用本变量,所以条件注册安全。
|
|
481
|
+
// - **后续约束**:创始人一旦把 `{{session_zone_brief}}` 写进出厂 persona,
|
|
482
|
+
// 本变量必须改为**无条件注册**(关闸时返回空串)——strict 插值下未注册的
|
|
483
|
+
// 引用会让整段 persona 渲染失败。那一步同时要改 §48 的变量清单断言、
|
|
484
|
+
// persona-surface-guard-tests 的注入面清单与 benchmark-environment-bridge-e2e
|
|
485
|
+
// 的 CANONICAL_RAW_VARIABLES。
|
|
486
|
+
// - 读回按**组装 scope** 取会话身份(dsh agent-loop 以 agent 对象为 ScopeKey,
|
|
487
|
+
// 与工具侧 exec.agent 同一身份):同一宿主进程里的两个会话因此读不到对方的
|
|
488
|
+
// resource 层笔记(设计 §1.1);取不到 scope → 不出 resource 段(fail-closed)。
|
|
489
|
+
const zoneSwitch = resolveZoneSwitch(config.sessionZones);
|
|
490
|
+
if (zoneSwitch === 'on') {
|
|
491
|
+
sp?.variable?.('session_zone_brief', assembleContext => renderSessionZoneBrief({
|
|
492
|
+
ledger: openLedgerIfExists(config.stateRoot ?? '.'),
|
|
493
|
+
now: new Date().toISOString(),
|
|
494
|
+
sessionRef: boundZoneSession(assembleContext?.scope),
|
|
495
|
+
zoneSwitch,
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
467
498
|
// 通道路由卡(通道注册表生成,docs/design/tool-orchestration-design.md §2.1/D-8):
|
|
468
499
|
// persona 检索条款只留行为契约,机/火/酒通道顺位与额度口径查卡——prose 教义
|
|
469
500
|
// 变查表教义,注册表加通道卡片自动一致。
|
|
@@ -537,6 +568,30 @@ export function apply(ctx, config, seams = {}) {
|
|
|
537
568
|
if (typeof t.execute === 'function') {
|
|
538
569
|
t.execute = guardToolExecute(String(t.name), config.stateRoot ?? '.', t.execute);
|
|
539
570
|
}
|
|
571
|
+
// 工具观察捕获缝(issue #255 P4-3):总闸 on 且工具在闭集白名单内才包裹——
|
|
572
|
+
// off(默认)时 execute 一层不多包,行为逐字节不变。捕获只投影形状字段
|
|
573
|
+
// (verdict/条数/价格带),证据只走指针;捕获失败永不影响工具返回值。
|
|
574
|
+
if (zoneSwitch === 'on' && typeof t.execute === 'function' && isZoneObservableTool(t.name)) {
|
|
575
|
+
const inner = t.execute;
|
|
576
|
+
const toolName = String(t.name);
|
|
577
|
+
t.execute = async (args, exec) => {
|
|
578
|
+
const result = await inner(args, exec);
|
|
579
|
+
try {
|
|
580
|
+
const sessionRef = zoneSessionRefFromExec(exec);
|
|
581
|
+
if (sessionRef) {
|
|
582
|
+
bindZoneSession(zoneScopeOfExec(exec), sessionRef);
|
|
583
|
+
observeToolResult(openLedgerIfExists(config.stateRoot ?? '.') ?? ensureLedger(config.stateRoot ?? '.'), {
|
|
584
|
+
tool: toolName,
|
|
585
|
+
result,
|
|
586
|
+
session_ref: sessionRef,
|
|
587
|
+
ts: new Date().toISOString(),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
catch { /* 记忆面永不把产品主路径带红 */ }
|
|
592
|
+
return result;
|
|
593
|
+
};
|
|
594
|
+
}
|
|
540
595
|
ctx.tools.register(t);
|
|
541
596
|
};
|
|
542
597
|
// Issue #443: register the five local editor tools only from trusted host
|
|
@@ -1055,6 +1110,110 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1055
1110
|
},
|
|
1056
1111
|
presentCall: args => ({ card: 'generic', title: '记录旅行', kind: 'edit', rawInput: args }),
|
|
1057
1112
|
}));
|
|
1113
|
+
// 会话双区记忆工具面(issue #255 P4-3):**只在总闸 on 时注册**——off(默认)时
|
|
1114
|
+
// 工具清单逐项不变,模型看不到任何分区入口(接线惰性的第一道)。
|
|
1115
|
+
if (zoneSwitch === 'on') {
|
|
1116
|
+
registerGuarded(defineTool({
|
|
1117
|
+
name: 'gotry_session_zone_note',
|
|
1118
|
+
description: 'Session working-zone memory (hot context, issue #255): capture a STRUCTURED fragment of what is still in play this session '
|
|
1119
|
+
+ '(destination / date window / party size / budget stance = intent tier, 24h; search shapes = resource tier, 30min), '
|
|
1120
|
+
+ 'revise it with optimistic CAS when the user corrects a detail, or drop it when it stops being true. '
|
|
1121
|
+
+ 'Payload must be typed slot-spec-family fields — NEVER raw conversation text, IDs, phone numbers, URLs or credentials '
|
|
1122
|
+
+ '(the write gate rejects those shapes). Evidence is a pointer (session + observation index), never content. '
|
|
1123
|
+
+ 'action="propose" only PROPOSES moving a note into the durable notebook: it writes NOTHING; the owner must confirm in their own words, '
|
|
1124
|
+
+ 'after which you call gotry_session_zone_promote with that quote. The model may never self-promote. '
|
|
1125
|
+
+ 'action="deny" records that the owner declined to remember it long-term (writes nothing; stop re-proposing it this session).',
|
|
1126
|
+
parameters: {
|
|
1127
|
+
action: { type: 'string', required: true, enum: ['capture', 'revise', 'drop', 'propose', 'deny'], description: 'capture=新片段入工作区;revise=CAS 修订(需 noteId+expectedRev);drop=更正即弃;propose=提议晋升(零写入);deny=owner 否决提议(零写入)' },
|
|
1128
|
+
tier: { type: 'string', enum: ['resource', 'intent'], description: 'capture 必填:intent(目的地/日期/人数/预算立场,24h)或 resource(检索形态,30min)' },
|
|
1129
|
+
kind: { type: 'string', enum: ['availability', 'price_band', 'destination', 'date_window', 'party_size', 'budget_stance'], description: 'capture 必填;revise 可选(改 kind)' },
|
|
1130
|
+
payload: { type: 'object', additionalProperties: true, description: '结构化片段,如 { city: "大理" } / { start: "2026-11-01", end: "2026-11-05" };禁放原文/证件/电话/URL/凭证' },
|
|
1131
|
+
noteId: { type: 'string', description: 'revise/drop/propose 的目标笔记 id' },
|
|
1132
|
+
expectedRev: { type: 'integer', description: 'revise 的父 rev(必须等于当前 rev,否则 stale_rev 拒绝)' },
|
|
1133
|
+
promoteKind: { type: 'string', enum: ['trip_fact', 'preference', 'constraint', 'lesson'], description: 'propose 时提议的持久条目类别' },
|
|
1134
|
+
},
|
|
1135
|
+
output: {
|
|
1136
|
+
schema: { type: 'json' },
|
|
1137
|
+
render: (_args, value) => [{ type: 'text', text: String(value.summary ?? '') }],
|
|
1138
|
+
},
|
|
1139
|
+
async execute(args, exec) {
|
|
1140
|
+
const sessionRef = zoneSessionRefFromExec(exec);
|
|
1141
|
+
if (!sessionRef) {
|
|
1142
|
+
return { ok: false, code: 'bad_session', summary: '无会话身份(exec.agent.session.id 缺失):分区不记忆无主体的片段' };
|
|
1143
|
+
}
|
|
1144
|
+
bindZoneSession(zoneScopeOfExec(exec), sessionRef);
|
|
1145
|
+
const r = applyZoneNote(ensureLedger(config.stateRoot), {
|
|
1146
|
+
action: args.action,
|
|
1147
|
+
...(args.tier !== undefined ? { tier: args.tier } : {}),
|
|
1148
|
+
...(args.kind !== undefined ? { kind: args.kind } : {}),
|
|
1149
|
+
...(args.payload !== undefined ? { payload: args.payload } : {}),
|
|
1150
|
+
...(args.noteId !== undefined ? { noteId: args.noteId } : {}),
|
|
1151
|
+
...(args.expectedRev !== undefined ? { expectedRev: args.expectedRev } : {}),
|
|
1152
|
+
...(args.promoteKind !== undefined ? { promoteKind: args.promoteKind } : {}),
|
|
1153
|
+
session_ref: sessionRef,
|
|
1154
|
+
ts: new Date().toISOString(),
|
|
1155
|
+
});
|
|
1156
|
+
if (!r.ok)
|
|
1157
|
+
return { ok: false, code: r.code, summary: r.detail };
|
|
1158
|
+
const summary = r.proposed
|
|
1159
|
+
? `已提议(未写入):${r.ask ?? ''}`
|
|
1160
|
+
: `${r.action}${r.appended ? ' 已落账' : ' 幂等跳过'}${r.noteId ? `:${r.noteId.slice(0, 60)}` : ''}`;
|
|
1161
|
+
return { ok: true, action: r.action, appended: r.appended, note_id: r.noteId, requires_owner_confirm: r.requiresOwnerConfirm === true, summary };
|
|
1162
|
+
},
|
|
1163
|
+
presentCall: args => ({ card: 'generic', title: `会话记忆:${String(args.action ?? '')}`, kind: 'edit', rawInput: args }),
|
|
1164
|
+
}));
|
|
1165
|
+
registerGuarded(defineTool({
|
|
1166
|
+
name: 'gotry_session_zone_promote',
|
|
1167
|
+
description: 'Owner-confirmed promotion into the durable trip notebook (issue #255) — the ONLY write path into durable memory. '
|
|
1168
|
+
+ 'Requires ownerQuote: the user\'s OWN words confirming they want this remembered long-term; your own summary is not a confirmation. '
|
|
1169
|
+
+ 'Routing rule: a preference ALSO goes through the motivation-profile gate (pass motivation.{weights|hard|homeCity}), '
|
|
1170
|
+
+ 'a trip fact through the timeline gate (pass trip.{destination,start}), a companion constraint through the companion gate (pass companion.{label,constraints}). '
|
|
1171
|
+
+ 'Without the required routed payload the promotion is REJECTED — the notebook never becomes a parallel store for a fact that already has an owner. '
|
|
1172
|
+
+ 'Generic constraints and lessons live in the notebook itself. The notebook write and the routed gate write are one transaction: they cannot diverge.',
|
|
1173
|
+
parameters: {
|
|
1174
|
+
noteId: { type: 'string', required: true, description: '源工作区笔记 id(必须未过期)' },
|
|
1175
|
+
kind: { type: 'string', required: true, enum: ['trip_fact', 'preference', 'constraint', 'lesson'], description: '持久条目类别' },
|
|
1176
|
+
ownerQuote: { type: 'string', required: true, description: '用户本人确认要长期记住的原话(≤200 字;模型自述不算确认)' },
|
|
1177
|
+
surface: { type: 'string', required: true, enum: ['user_reply', 'approval_card'], description: '确认表面:用户回复 或 审批卡' },
|
|
1178
|
+
payload: { type: 'object', additionalProperties: true, description: '可选:覆盖源笔记载荷的结构化断言' },
|
|
1179
|
+
motivation: { type: 'object', additionalProperties: true, description: 'preference 必填:{ weights?, hard?, homeCity? } 至少一项(经动机画像闸)' },
|
|
1180
|
+
trip: { type: 'object', additionalProperties: true, description: 'trip_fact 必填:{ destination, start(YYYY-MM-DD), end?, companions? }(经时间线闸)' },
|
|
1181
|
+
companion: { type: 'object', additionalProperties: true, description: '同行人约束必填:{ label, constraints:{ mobility?, health?, prefs? } }(经同行人闸)' },
|
|
1182
|
+
},
|
|
1183
|
+
output: {
|
|
1184
|
+
schema: { type: 'json' },
|
|
1185
|
+
render: (_args, value) => [{ type: 'text', text: String(value.summary ?? '') }],
|
|
1186
|
+
},
|
|
1187
|
+
async execute(args, exec) {
|
|
1188
|
+
const sessionRef = zoneSessionRefFromExec(exec);
|
|
1189
|
+
if (sessionRef)
|
|
1190
|
+
bindZoneSession(zoneScopeOfExec(exec), sessionRef);
|
|
1191
|
+
const r = promoteWithRouting(ensureLedger(config.stateRoot), {
|
|
1192
|
+
noteId: String(args.noteId ?? ''),
|
|
1193
|
+
kind: String(args.kind ?? ''),
|
|
1194
|
+
ownerQuote: String(args.ownerQuote ?? ''),
|
|
1195
|
+
surface: String(args.surface ?? ''),
|
|
1196
|
+
...(args.payload !== undefined ? { payload: args.payload } : {}),
|
|
1197
|
+
...(args.motivation !== undefined ? { motivation: args.motivation } : {}),
|
|
1198
|
+
...(args.trip !== undefined ? { trip: args.trip } : {}),
|
|
1199
|
+
...(args.companion !== undefined ? { companion: args.companion } : {}),
|
|
1200
|
+
ts: new Date().toISOString(),
|
|
1201
|
+
});
|
|
1202
|
+
if (!r.ok)
|
|
1203
|
+
return { ok: false, code: r.code, summary: r.detail };
|
|
1204
|
+
const routed = r.routed ? `;并经 ${r.routed.authority} 闸${r.routed.applied ? '落地' : '幂等跳过'}` : '';
|
|
1205
|
+
return {
|
|
1206
|
+
ok: true,
|
|
1207
|
+
appended: r.appended,
|
|
1208
|
+
entry_id: r.entryId,
|
|
1209
|
+
authority: r.authority,
|
|
1210
|
+
routed: r.routed ? JSON.parse(JSON.stringify(r.routed)) : null,
|
|
1211
|
+
summary: `${r.appended ? '已写入笔记本' : '笔记本幂等跳过'}${routed}`,
|
|
1212
|
+
};
|
|
1213
|
+
},
|
|
1214
|
+
presentCall: args => ({ card: 'generic', title: '笔记本晋升(owner 确认)', kind: 'edit', rawInput: args }),
|
|
1215
|
+
}));
|
|
1216
|
+
}
|
|
1058
1217
|
registerGuarded(defineTool({
|
|
1059
1218
|
name: 'gotry_hotel_search',
|
|
1060
1219
|
description: routed('gotry_hotel_search', 'Search hotels via hotelbyte-cli (real-time when hbcli credentials exist, falls back to the static pack with explicit evidence tagging). '
|
|
@@ -1501,7 +1660,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1501
1660
|
description: 'Productized login bootstrap for the account session channel (call this when gotry_session_search returns needs-login — the user never needs a terminal). ' + 'AUTO-DETECTION FIRST: it reads ticket-cookie NAMES before anything else — if the user already logged in (on the external site) it confirms instantly WITHOUT opening any page. '
|
|
1502
1661
|
+ 'OPENS the site login entry in the USER\'S OWN Chrome (foreground tab, left open for the user) and waits for the user to finish logging in on the external site. '
|
|
1503
1662
|
+ 'GoTry NEVER collects, stores, or transmits credentials: no passwords, no SMS codes, no cookie values — it only checks the boolean fact "already logged in" (reads cookie NAMES only, zero values). '
|
|
1504
|
-
+ 'Transport: the
|
|
1663
|
+
+ 'Transport: the Stai Travel Bridge browser extension (one-time install, ZERO Chrome system dialogs). '
|
|
1505
1664
|
+ `verdict logged-in (tickets detected) | pending (login tab opened, user not done yet — offer to re-check later) | needs-extension (one-time extension install: the verdict surfaces the Chrome Web Store installUrl as a clickable link for dsh UI to render — installation is a browser concern, not gotry's). `
|
|
1506
1665
|
+ 'Evidence [会话:<site>-login@ts].',
|
|
1507
1666
|
// D-30 第三刀(issue #112):query blob → 平铺 typed;全字段可选 → interpretArgs 容忍层
|
|
@@ -1521,7 +1680,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1521
1680
|
: r.verdict === 'pending'
|
|
1522
1681
|
? `登录入口已在你的 Chrome 打开;请在弹出的标签页里正常登录携程(登录由你在官网完成,不属于 gotry)。完成后说一声"继续",我再确认。gotry 只检查"是否已登录",永不收集你的账号信息。${r.evidence}`
|
|
1523
1682
|
: r.verdict === 'needs-extension'
|
|
1524
|
-
? `需要一次性安装
|
|
1683
|
+
? `需要一次性安装 Stai Travel Bridge 浏览器扩展:这是浏览器的事,gotry 不弹面板不开剪贴板——请直接在 Chrome 应用商店一键装(add-to-chrome 自动更新)。storeUrl=${EXTENSION_STORE_URL}. 装好即生效,装完后告诉我「重试」即可。${r.evidence}`
|
|
1525
1684
|
: r.verdict === 'needs-attach'
|
|
1526
1685
|
? `cdp 车道需要一次性开启你 Chrome 的远程调试开关:在你的 Chrome 地址栏打开 chrome://inspect/#remote-debugging 并打开开关,然后说一声"重试"(默认走扩展车道,无需此步)。${r.evidence}`
|
|
1527
1686
|
: `登录引导未完成:${r.error ?? '未知原因'} ${r.evidence}`;
|
|
@@ -1536,7 +1695,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1536
1695
|
: r.verdict === 'pending'
|
|
1537
1696
|
? '等待你在携程页面完成登录'
|
|
1538
1697
|
: needsExt
|
|
1539
|
-
? '🧩 需装
|
|
1698
|
+
? '🧩 需装 Stai Travel Bridge 扩展(浏览器商店一键装,自动更新)'
|
|
1540
1699
|
: r.verdict ?? '降级';
|
|
1541
1700
|
const content = [{ type: 'text', text: String(value.summary ?? '') }];
|
|
1542
1701
|
// needs-extension:把商店 URL 渲成可点链接(浏览器自己当安装器,gotry 不插手)
|
|
@@ -1550,7 +1709,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1550
1709
|
name: 'gotry_session_search',
|
|
1551
1710
|
description: routed('gotry_session_search', 'Search on the USER\'S OWN browser session (Ctrip kind="flight"(default)/"hotel"; 12306 kind="train" — public query face, no login needed). '
|
|
1552
1711
|
+ 'Consent gate: the FIRST call in a session asks the user via the runtime approval card; once granted it holds for the session, a refusal revokes it for the session (no repeat prompting). '
|
|
1553
|
-
+ 'Transport:
|
|
1712
|
+
+ 'Transport: Stai Travel Bridge browser extension (one-time install) — the agent side never talks to Chrome debugging, ZERO system dialogs; read-only by construction (the extension never issues requests; it only passively forwards the site\'s own search responses; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
|
|
1554
1713
|
+ 'kind="flight": from/to 中文城市名 + date YYYY-MM-DD — sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
|
|
1555
1714
|
+ 'kind="hotel": to=目的地中文, cityId? = the numeric city= in a hotels.ctrip.com list URL (web-search it when the destination is outside the built-in city table), checkIn?/checkOut? (YYYY-MM-DD), adults?; hotel prices are the user\'s real logged-in prices. Evidence [会话:ctrip-hotel@ts]. '
|
|
1556
1715
|
+ 'kind="dida": Dida supplier-portal realtime hotel rates on the employee\'s own logged-in session (hotel-be portal integration line) — no from/to needed; the portal find page\'s own requests are sniffed passively. Rates carry ratePlanId/referenceNo for the server-side booking chain. Evidence [会话:dida-portal@ts]. '
|
|
@@ -1729,7 +1888,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
1729
1888
|
: r.verdict === 'needs-login'
|
|
1730
1889
|
? '需登录'
|
|
1731
1890
|
: needsExt
|
|
1732
|
-
? '🧩 需装
|
|
1891
|
+
? '🧩 需装 Stai Travel Bridge 扩展(浏览器商店一键装,自动更新)'
|
|
1733
1892
|
: r.verdict ?? '降级';
|
|
1734
1893
|
const content = [{ type: 'text', text: String(value.summary ?? '') }];
|
|
1735
1894
|
if (needsExt && r.installUrl)
|
|
@@ -2013,7 +2172,7 @@ export function apply(ctx, config, seams = {}) {
|
|
|
2013
2172
|
// 统一的状态入口来「显示状态 + 引导补装」,而不是在 not-installed 里各自摸索)----
|
|
2014
2173
|
registerGuarded(defineTool({
|
|
2015
2174
|
name: 'gotry_doctor',
|
|
2016
|
-
description: 'Check optional-dependency health for ALL gotry tools (read-only by default). Coverage:
|
|
2175
|
+
description: 'Check optional-dependency health for ALL gotry tools (read-only by default). Coverage: Stai Travel Bridge extension / Agent Reach (.venv) / hbcli (hotel realtime) / FlyAI key + recent trial-quota exhaustion time / dsh-calendar mount state / dsh-better-sidebar / dsh-map-tools / dsh-tool-ask-user. '
|
|
2017
2176
|
+ 'Call this when ANY gotry tool returns not-installed / needs-setup, when the user asks 「体检/依赖状态/工具为什么不可用」, or BEFORE leaning on a channel for a plan. '
|
|
2018
2177
|
+ 'Returns per-item status (ok/degraded/missing) with exact fix commands. '
|
|
2019
2178
|
+ 'Action=diagnose (default): pure read-only — never installs anything, only renders the report. '
|
package/dist/src/loop.js
CHANGED
|
@@ -161,13 +161,18 @@ function terminalOutcomeFromState(ticketId, state) {
|
|
|
161
161
|
'4_做不到的诚实说': false,
|
|
162
162
|
});
|
|
163
163
|
}
|
|
164
|
+
// issue #620:求解器自身失败时既没有产物也没有判定——2 条不成立;但「做不到诚实说」
|
|
165
|
+
// 恰恰成立(我们明说了是引擎故障而非不可行),不靠 unsat_core 冒充自检通过。
|
|
166
|
+
const solverFailed = Boolean(state.solve.solver_error);
|
|
164
167
|
return asyncTerminalOutcome(ticketId, {
|
|
165
168
|
'1_承诺时间后必有明确产物': Boolean(state.solve.legs?.length || state.solve.verdicts?.length),
|
|
166
|
-
'2_产物通过自检清单':
|
|
167
|
-
?
|
|
168
|
-
:
|
|
169
|
+
'2_产物通过自检清单': solverFailed
|
|
170
|
+
? false
|
|
171
|
+
: state.solve.feasible
|
|
172
|
+
? (state.solve.legs?.every(l => l['energy_pct'] !== undefined) ?? false)
|
|
173
|
+
: Boolean(state.solve.unsat_core?.length),
|
|
169
174
|
'3_待决问题全部是简单选择题': state.gates.every(g => g.id === 'budget' || g.options.length >= 2),
|
|
170
|
-
'4_做不到的诚实说': state.solve.feasible || Boolean(state.solve.suggestions?.length),
|
|
175
|
+
'4_做不到的诚实说': solverFailed || state.solve.feasible || Boolean(state.solve.suggestions?.length),
|
|
171
176
|
});
|
|
172
177
|
}
|
|
173
178
|
export function isComplex(state) {
|
|
@@ -207,7 +212,13 @@ export function renderSolve(state) {
|
|
|
207
212
|
if (!s)
|
|
208
213
|
return '(无求解结果)';
|
|
209
214
|
const lines = [];
|
|
210
|
-
if (s.
|
|
215
|
+
if (s.solver_error) {
|
|
216
|
+
// issue #620:求解器自己失败 ≠ 行程不可行。这里**不**说「不可行」、不列「冲突」,
|
|
217
|
+
// 也不把机器 token(solver_error.code)渲染给用户——它只在日志/证据里。
|
|
218
|
+
lines.push('**这轮没算出结论——求解器自身出错了,不是你的行程不可行**');
|
|
219
|
+
lines.push('- 我不把引擎故障说成「不可行」:这条已记录,修好或换候选形态(枚举)后重算');
|
|
220
|
+
}
|
|
221
|
+
else if (s.feasible) {
|
|
211
222
|
lines.push(`**方案可行,机票合计 ¥${s.money_cny}**`);
|
|
212
223
|
// §7-1 issue #559 卡点 4:从 s.skeleton_notes 字符串编码里推断 unscaffolded 的 seg.id,
|
|
213
224
|
// 不动 unified.ts/journey.ts(内核冻结面 #234),用户面给 ⚠ 前缀与「枢纽对未在骨架覆盖」补充。
|