thincoder 0.12.69 → 0.12.71
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/CHANGELOG.md +37 -0
- package/README.md +11 -3
- package/bin/node-version-gate.cjs +28 -0
- package/bin/thincoder.cjs +2 -0
- package/bin/thincoder.mjs +2 -0
- package/package.json +2 -2
- package/src/acp/bridge.mjs +54 -12
- package/src/acp/handlers-session.mjs +95 -44
- package/src/acp/handlers-slots.mjs +41 -14
- package/src/acp/read-data.mjs +127 -0
- package/src/acp/resource-link.mjs +124 -0
- package/src/acp/session.mjs +1 -1
- package/src/acp.mjs +15 -6
- package/src/command-interactive.mjs +10 -1
- package/src/command-table.mjs +8 -1
- package/src/completions.mjs +7 -4
- package/src/prompt-injections.mjs +21 -4
- package/src/tui/cmd-model.mjs +3 -2
- package/src/tui/cmd-reindex.mjs +10 -0
- package/src/tui/cmd-undo.mjs +16 -38
- package/src/tui/config-helpers.mjs +34 -1
- package/src/tui/index.mjs +5 -5
- package/src/tui/ledger-surface.mjs +5 -7
- package/src/tui/lifecycle-records.mjs +12 -4
- package/src/tui/model-picker.mjs +15 -6
- package/src/tui/startup.mjs +14 -2
- package/src/tui/suspension-drive.mjs +9 -2
- package/src/tui/wizard.mjs +1 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read-data.mjs — ACP read-only data interfaces (READ-DATA-INTERFACE.md §2.6 / §3.3 / §3.4):
|
|
3
|
+
* `ledger/list` · `ledger/count` · `batch/list` + change notifications
|
|
4
|
+
* (`ledger/changed` / `batch/changed`, params `{ cwd }` — 信号面,数据经拉取方法取).
|
|
5
|
+
*
|
|
6
|
+
* 核模块一律**动态 import**(W8 契约②——台账链静态入 node:sqlite;且装载期零开销)。
|
|
7
|
+
* 观察器 = 5s 指纹轮询(注入面 `intervalMs`;测试直驱 `watcher.check()`——零定时器依赖):
|
|
8
|
+
* 观察域 = 服务进程 cwd(每轮 `ctx.getCwd()` 求值——与 `params.cwd` 缺省同取值;非观察项目不通知);
|
|
9
|
+
* 首查立基线(静默);此后差分 ⇒ 对应通知;`start()` 挂 unref 定时器(先例 = 核 ledger-surface.mjs)。
|
|
10
|
+
* 凭据门 = `ctx.requireConfigured`(与既有四族同门——D-8;只读性不改通道门)。
|
|
11
|
+
*/
|
|
12
|
+
import { statSync } from "node:fs"
|
|
13
|
+
|
|
14
|
+
import { ACP_ERRORS } from "./transport.mjs"
|
|
15
|
+
|
|
16
|
+
/** 观察周期(D-7——5s;注入面 = `createReadDataHandlers(ctx, { intervalMs })`)。 */
|
|
17
|
+
export const WATCH_INTERVAL_MS = 5000
|
|
18
|
+
|
|
19
|
+
/** 核模块惰性装载(单次——W8 契约②:消费侧动态 import)。 */
|
|
20
|
+
let corePromise = null
|
|
21
|
+
function core() {
|
|
22
|
+
corePromise ??= Promise.all([
|
|
23
|
+
import("@thincoder/core/ledger-read.mjs"),
|
|
24
|
+
import("@thincoder/core/ledger-cmd.mjs"),
|
|
25
|
+
import("@thincoder/core/ledger-db.mjs"),
|
|
26
|
+
import("@thincoder/core/agent-tools/batch-read.mjs"),
|
|
27
|
+
]).then(([ledgerRead, ledgerCmd, ledgerDb, batchRead]) => ({ ledgerRead, ledgerCmd, ledgerDb, batchRead }))
|
|
28
|
+
return corePromise
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const badParams = (message) => ({ error: { ...ACP_ERRORS.INVALID_PARAMS, message } })
|
|
32
|
+
|
|
33
|
+
/** `params.cwd` → 取值(缺省 = 服务进程 cwd,`ctx.getCwd()`);非字符串 ⇒ 错误对象。 */
|
|
34
|
+
function resolveCwd(params, ctx) {
|
|
35
|
+
const cwd = params?.cwd
|
|
36
|
+
if (cwd === undefined) return { cwd: ctx.getCwd() }
|
|
37
|
+
if (typeof cwd !== "string") return { error: badParams(`cwd must be a string (got ${typeof cwd})`) }
|
|
38
|
+
return { cwd }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 指纹(台账库单档 / 批次档逐档)——`exists:mtimeMs:size`;缺失 ⇒ `0`。 */
|
|
42
|
+
function fileSig(file) {
|
|
43
|
+
try { const st = statSync(file); return `1:${st.mtimeMs}:${st.size}` } catch { return "0" }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the read-data handlers + the change watcher. Shared state arrives via the single
|
|
48
|
+
* `ctx` (§3.5); `intervalMs` is the injection face for the watch period.
|
|
49
|
+
* @returns {{ handlers: Record<string, (params: object) => Promise<object>>,
|
|
50
|
+
* watcher: { check: () => Promise<string[]>, start: () => void, stop: () => void } }}
|
|
51
|
+
*/
|
|
52
|
+
export function createReadDataHandlers(ctx, { intervalMs = WATCH_INTERVAL_MS } = {}) {
|
|
53
|
+
const { requireConfigured } = ctx
|
|
54
|
+
|
|
55
|
+
const handlers = {
|
|
56
|
+
"ledger/list": async (params) => {
|
|
57
|
+
const gate = requireConfigured()
|
|
58
|
+
if (gate.error) return gate
|
|
59
|
+
const c = resolveCwd(params, ctx)
|
|
60
|
+
if (c.error) return c.error
|
|
61
|
+
for (const f of ["family", "full"]) {
|
|
62
|
+
if (params?.[f] !== undefined && typeof params[f] !== "boolean") return badParams(`${f} must be a boolean (got ${typeof params[f]})`)
|
|
63
|
+
}
|
|
64
|
+
const { ledgerRead } = await core()
|
|
65
|
+
try {
|
|
66
|
+
return ledgerRead.ledgerExport({ cwd: c.cwd, family: params?.family === true, full: params?.full === true })
|
|
67
|
+
} catch (e) { return badParams(e?.message ?? String(e)) }
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
"ledger/count": async (params) => {
|
|
71
|
+
const gate = requireConfigured()
|
|
72
|
+
if (gate.error) return gate
|
|
73
|
+
const c = resolveCwd(params, ctx)
|
|
74
|
+
if (c.error) return c.error
|
|
75
|
+
const { ledgerCmd } = await core()
|
|
76
|
+
try { return { count: ledgerCmd.ledgerCount({ cwd: c.cwd }) } } catch (e) { return badParams(e?.message ?? String(e)) }
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
"batch/list": async (params) => {
|
|
80
|
+
const gate = requireConfigured()
|
|
81
|
+
if (gate.error) return gate
|
|
82
|
+
const c = resolveCwd(params, ctx)
|
|
83
|
+
if (c.error) return c.error
|
|
84
|
+
const { batchRead } = await core()
|
|
85
|
+
try { return batchRead.listBatchRecords({ cwd: c.cwd }) } catch (e) { return badParams(e?.message ?? String(e)) }
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── 变更观察器(轮询指纹;起停 = runAcpServer 启、进程终灭)─────────────────
|
|
90
|
+
let last = null
|
|
91
|
+
let timer = null
|
|
92
|
+
let inflight = false // 重叠防衛(先例 = 核 ledger-surface.mjs):上一拍未落定 ⇒ 跳过本拍
|
|
93
|
+
|
|
94
|
+
async function check() {
|
|
95
|
+
if (inflight) return []
|
|
96
|
+
inflight = true
|
|
97
|
+
try {
|
|
98
|
+
const cwd = ctx.getCwd()
|
|
99
|
+
const { ledgerDb, batchRead } = await core()
|
|
100
|
+
const ledgerSig = fileSig(ledgerDb.ledgerDbPath(cwd))
|
|
101
|
+
const batchSig = batchRead.listBatchRecords({ cwd }).batches.map((b) => `${b.path}:${fileSig(b.path)}`).join("|")
|
|
102
|
+
if (!last || last.cwd !== cwd) { last = { cwd, ledgerSig, batchSig }; return [] } // 首查 / 观察域切换 ⇒ 立基线(静默)
|
|
103
|
+
const fired = []
|
|
104
|
+
if (ledgerSig !== last.ledgerSig) fired.push("ledger/changed")
|
|
105
|
+
if (batchSig !== last.batchSig) fired.push("batch/changed")
|
|
106
|
+
last = { cwd, ledgerSig, batchSig }
|
|
107
|
+
for (const method of fired) ctx.notifyRef.current(method, { cwd })
|
|
108
|
+
return fired
|
|
109
|
+
} finally { inflight = false }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 启动:立即首查(立基线——静默)+ 周期轮询;unref 定时器(不阻塞进程退出)。 */
|
|
113
|
+
function start() {
|
|
114
|
+
if (timer) return
|
|
115
|
+
void check().catch(() => {})
|
|
116
|
+
timer = setInterval(() => { void check().catch(() => {}) }, intervalMs)
|
|
117
|
+
timer.unref?.()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 停表(进程终灭即止的显式面——测试 / 收尾用)。 */
|
|
121
|
+
function stop() {
|
|
122
|
+
if (timer) clearInterval(timer)
|
|
123
|
+
timer = null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { handlers, watcher: { check, start, stop } }
|
|
127
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resource-link.mjs — `session/prompt` 的 `resource_link` 内容块(ACP v1 基线 MUST:
|
|
3
|
+
* 「All agents MUST support resource links in prompts」)。
|
|
4
|
+
*
|
|
5
|
+
* 判定树与验收 = `docs/cli/design/ACP-PROTOCOL-COMPLIANCE.md` §2.5(逐态可机检):
|
|
6
|
+
* uri 解析(file: ⇒ 解码 + UNC/盘符收正;非 file ⇒ `unsupported scheme`;解析失败 ⇒ 纯路径
|
|
7
|
+
* 按 cwd)→ 选区(`#L<a>` / `#L<a>-<b>` / `#L<a>:<b>`——1 基闭区间)→ 读面(`unreadable` /
|
|
8
|
+
* `not a regular file` / `too large (>10MB)`——文件级先于读取)→ NUL(`binary (NUL byte)`)→
|
|
9
|
+
* 载荷上限(`too many lines (>2000)` / `too long (>100000 chars)`)→ 成功形(`[File: …]` +
|
|
10
|
+
* 无语言标记围栏)。
|
|
11
|
+
*
|
|
12
|
+
* 语义裁定(设计档 §2.5 三条):**无 cwd confine**(用户显式 @ 引用可读任意绝对路径——与
|
|
13
|
+
* `read` 工具同界:confine 已撤除,读操作只读、不触发审批);**零能力门控**(resource_link 属
|
|
14
|
+
* 基线 MUST,非 `PromptCapabilities` 项——`embeddedContext` 维持 false);**上限量级与 `read`
|
|
15
|
+
* 同源**(行上限直引核 `MAX_READ_LINES`——零副本)。
|
|
16
|
+
*
|
|
17
|
+
* 降级标记 = 单行固定词表(11 词——机检面锚;块间独立,单块失败不影响他块)。
|
|
18
|
+
*/
|
|
19
|
+
import { readFile, stat } from "node:fs/promises"
|
|
20
|
+
import { resolve as resolvePath } from "node:path"
|
|
21
|
+
// 行上限与 read 工具同源(设计档 §2.5「上限量级与 read 同源」——直引,零副本)。
|
|
22
|
+
import { MAX_READ_LINES } from "@thincoder/core/tools/shared.mjs"
|
|
23
|
+
|
|
24
|
+
/** 文件级字节闸(10MB——先于读取;D-9:字节闸 = 文件级 ∥ 行/字符闸 = 载荷级)。 */
|
|
25
|
+
export const MAX_INLINE_BYTES = 10 * 1024 * 1024
|
|
26
|
+
/** 载荷级行闸(直引 `read` 工具同源值)。 */
|
|
27
|
+
export const MAX_INLINE_LINES = MAX_READ_LINES
|
|
28
|
+
/** 载荷级字符闸(100k 字符)。 */
|
|
29
|
+
export const MAX_INLINE_CHARS = 100_000
|
|
30
|
+
|
|
31
|
+
/** 降级标记形:单行、无围栏——`<reason>` ∈ 固定词表(设计档 §2.5 第 7 段)。 */
|
|
32
|
+
const marker = (path, reason) => `[File reference: ${path} — ${reason}]`
|
|
33
|
+
|
|
34
|
+
/** 选区文法:`#L<a>` ∥ `#L<a>-<b>` ∥ `#L<a>:<b>`(`b` 可带 `L` 前缀——含 `#L5:15` / `#L10-L20` 两观察形)。 */
|
|
35
|
+
const SELECTION_RE = /^#L(\d+)(?:[-:]L?(\d+))?$/
|
|
36
|
+
|
|
37
|
+
/** 成功形:头行 + 无语言标记围栏(内容原样;缺尾换行时补一终止换行,围栏不粘连正文)。 */
|
|
38
|
+
function fenced(header, content) {
|
|
39
|
+
const body = content.endsWith("\n") ? content : `${content}\n`
|
|
40
|
+
return `${header}\n\`\`\`\n${body}\`\`\``
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 单块 → 内联段或降级标记(块间独立)。 */
|
|
44
|
+
export async function resolveResourceLink(block, { cwd } = {}) {
|
|
45
|
+
// ① uri 非字符串 ⇒ missing uri(路径位 = block.name ?? "unknown")
|
|
46
|
+
const raw = block?.uri
|
|
47
|
+
if (typeof raw !== "string") return marker(block?.name ?? "unknown", "missing uri")
|
|
48
|
+
|
|
49
|
+
// ② uri 解析:file: ⇒ 解码 + UNC/盘符收正;非 file ⇒ unsupported scheme;解析失败 ⇒ 纯路径
|
|
50
|
+
let path = null
|
|
51
|
+
let hash = ""
|
|
52
|
+
let url = null
|
|
53
|
+
try { url = new URL(raw) } catch { url = null }
|
|
54
|
+
if (url) {
|
|
55
|
+
if (url.protocol !== "file:") return marker(raw, "unsupported scheme")
|
|
56
|
+
try { path = decodeURIComponent(url.pathname) } catch { return marker(raw, "invalid percent-encoding") }
|
|
57
|
+
if (url.hostname && url.hostname !== "localhost") path = `//${url.hostname}${path}` // UNC 主机
|
|
58
|
+
else if (/^\/[A-Za-z]:/.test(path)) path = path.slice(1) // Windows 盘符(/C:/… → C:/…)
|
|
59
|
+
hash = url.hash
|
|
60
|
+
} else {
|
|
61
|
+
try { path = decodeURIComponent(raw) } catch { return marker(raw, "invalid percent-encoding") }
|
|
62
|
+
}
|
|
63
|
+
path = resolvePath(cwd ?? process.cwd(), path) // 相对路径按 cwd;已在盘面形则归一平台原生分隔符
|
|
64
|
+
|
|
65
|
+
// ③ 选区解析(仅 url.hash;非选区哈希 = 锚点 ⇒ 整文)
|
|
66
|
+
let sel = null
|
|
67
|
+
if (hash) {
|
|
68
|
+
const m = SELECTION_RE.exec(hash)
|
|
69
|
+
if (m) {
|
|
70
|
+
const a = Number(m[1])
|
|
71
|
+
const b = m[2] === undefined ? a : Number(m[2])
|
|
72
|
+
if (a < 1 || b < 1 || b < a) return marker(path, "invalid selection")
|
|
73
|
+
sel = { a, b }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ④ 读面:stat(unreadable / not a regular file / too large——文件级先于读取)→ 读取 → NUL
|
|
78
|
+
let st
|
|
79
|
+
try { st = await stat(path) } catch { return marker(path, "unreadable") }
|
|
80
|
+
if (!st.isFile()) return marker(path, "not a regular file")
|
|
81
|
+
if (st.size > MAX_INLINE_BYTES) return marker(path, "too large (>10MB)")
|
|
82
|
+
let buf
|
|
83
|
+
try { buf = await readFile(path) } catch { return marker(path, "unreadable") }
|
|
84
|
+
if (buf.includes(0)) return marker(path, "binary (NUL byte)")
|
|
85
|
+
const content = buf.toString("utf8")
|
|
86
|
+
|
|
87
|
+
// ⑤ 选区内容检查:a > 总行数 ⇒ lines out of range;b 超尾 ⇒ 截到总行数。
|
|
88
|
+
// 行模型 = `\n` 分界;文末换行符 = 终止符(不计行)——编辑器行号语义。
|
|
89
|
+
const lines = content.split("\n")
|
|
90
|
+
if (content.endsWith("\n")) lines.pop()
|
|
91
|
+
let slice = content
|
|
92
|
+
let lineCount = lines.length
|
|
93
|
+
let header = `[File: ${path}]`
|
|
94
|
+
if (sel) {
|
|
95
|
+
if (sel.a > lines.length) return marker(path, "lines out of range")
|
|
96
|
+
const b = Math.min(sel.b, lines.length)
|
|
97
|
+
slice = lines.slice(sel.a - 1, b).join("\n")
|
|
98
|
+
lineCount = b - sel.a + 1
|
|
99
|
+
header = `[File: ${path} lines ${sel.a}–${b}]`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ⑥ 载荷级上限(选区时作用于选区切片,整文时作用于全篇)
|
|
103
|
+
if (lineCount > MAX_INLINE_LINES) return marker(path, "too many lines (>2000)")
|
|
104
|
+
if (slice.length > MAX_INLINE_CHARS) return marker(path, "too long (>100000 chars)")
|
|
105
|
+
|
|
106
|
+
// ⑦ 成功形
|
|
107
|
+
return fenced(header, slice)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 聚合(设计档 §2.5 目标形):文本段 = 首个 `type:"text"` 且 `text` 为字符串的块(首块策略;
|
|
111
|
+
* 「首块」判据 = 首个**合法** text 块——畸形首块不再吞掉后续合法块);资源段按数组序逐
|
|
112
|
+
* `resource_link` 块解析;段间 `\n\n` 连接;文本段在前、资源段随后(保序)。
|
|
113
|
+
* 空结果 ⇒ 调用方走既有 `-32602` 通道。 */
|
|
114
|
+
export async function buildPromptText(blocks, { cwd } = {}) {
|
|
115
|
+
const list = Array.isArray(blocks) ? blocks : []
|
|
116
|
+
const segments = []
|
|
117
|
+
const text = list.find((b) => b?.type === "text" && typeof b?.text === "string")?.text
|
|
118
|
+
if (text) segments.push(text)
|
|
119
|
+
for (const b of list) {
|
|
120
|
+
if (b?.type !== "resource_link") continue
|
|
121
|
+
segments.push(await resolveResourceLink(b, { cwd }))
|
|
122
|
+
}
|
|
123
|
+
return segments.join("\n\n")
|
|
124
|
+
}
|
package/src/acp/session.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { buildAcpCallbacks } from "./bridge.mjs"
|
|
|
19
19
|
|
|
20
20
|
export function createAcpSession({ id, agent, notify, request = async () => { throw new Error("no request channel") }, log = () => {}, run = runAgent, save = saveSession, clientCaps = {} }) {
|
|
21
21
|
let controller = new AbortController()
|
|
22
|
-
const callbacks = buildAcpCallbacks({ sessionId: id, notify, request, log, clientCaps })
|
|
22
|
+
const callbacks = buildAcpCallbacks({ sessionId: id, agent, notify, request, log, clientCaps })
|
|
23
23
|
let queue = Promise.resolve()
|
|
24
24
|
let busy = false
|
|
25
25
|
|
package/src/acp.mjs
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
* Agent Client Protocol (schema v1) on stdio, so ACP clients (Zed, JetBrains
|
|
4
4
|
* AI Chat, Paseo) can drive sessions directly.
|
|
5
5
|
*
|
|
6
|
-
* Handler bodies live in
|
|
6
|
+
* Handler bodies live in five modules (division + criteria = docs/cli/design/ACP-CLIENT.md
|
|
7
7
|
* §3.5 — the former 368-line single-function `buildAcpHandlers` split by method family):
|
|
8
8
|
* acp/client-caps.mjs initialize / authenticate(版本协商 · 能力快照 · authMethods 构造)
|
|
9
9
|
* acp/handlers-session.mjs session/{new,prompt,cancel,close,set_mode,set_config_option} + 门助手
|
|
10
10
|
* acp/handlers-slots.mjs session/{list,load,resume,delete}(持久化槽族)
|
|
11
11
|
* acp/ext.mjs checkpoint/* · memory/*
|
|
12
|
+
* acp/read-data.mjs ledger/list · ledger/count · batch/list + ledger/changed · batch/changed(只读数据接口)
|
|
12
13
|
* acp/login.mjs `--login` terminal auth flow (non-stdio)
|
|
13
14
|
* This file keeps constants, the injectable defaults and the assembly — the seam
|
|
14
|
-
* `buildAcpHandlers(deps)` (signature + `{ handlers, sessions, notifyRef, requestRef }`)
|
|
15
|
+
* `buildAcpHandlers(deps)` (signature + `{ handlers, sessions, notifyRef, requestRef, watcher }`)
|
|
15
16
|
* is the only external contract (both existing ACP test files drive it directly).
|
|
16
17
|
*
|
|
17
18
|
* #41(2026-09-18):`session/load` / `session/resume` 两路工程模式会话改「先判后装载」
|
|
@@ -29,9 +30,10 @@ import { assembleAgent } from "./cli/make-agent.mjs"
|
|
|
29
30
|
import { createAcpServer } from "./acp/transport.mjs"
|
|
30
31
|
import { createAcpSession } from "./acp/session.mjs"
|
|
31
32
|
import { createCapsHandlers } from "./acp/client-caps.mjs"
|
|
32
|
-
import { createRequireConfigured, createSessionHandlers } from "./acp/handlers-session.mjs"
|
|
33
|
+
import { createRequireConfigured, createSessionHandlers, createSlotReleaser } from "./acp/handlers-session.mjs"
|
|
33
34
|
import { createSlotsHandlers } from "./acp/handlers-slots.mjs"
|
|
34
35
|
import { createExtHandlers } from "./acp/ext.mjs"
|
|
36
|
+
import { createReadDataHandlers } from "./acp/read-data.mjs"
|
|
35
37
|
|
|
36
38
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version
|
|
37
39
|
|
|
@@ -96,33 +98,39 @@ export function buildAcpHandlers({
|
|
|
96
98
|
const notifyRef = { current: notify }
|
|
97
99
|
const requestRef = { current: async () => { throw new Error("no request channel") } }
|
|
98
100
|
const sessions = new Map()
|
|
99
|
-
let nextId = 1
|
|
100
101
|
/** §3.4 客户端能力快照:`initialize` 单次写入,之后只读(快照丢失 ⇒ 全取 false ⇒ 本地兜底)。 */
|
|
101
102
|
const clientCaps = { current: {} }
|
|
102
|
-
/** 共享态经单一 `ctx` 传入(§3.5
|
|
103
|
+
/** 共享态经单一 `ctx` 传入(§3.5)——会话 id = 持久槽位号串(load/new 同命名空间——G5 收正);
|
|
104
|
+
* `releaseClosedSlot` = 认领释放单点(close ∥ load/resume 替换 三处同源消费)。 */
|
|
103
105
|
const ctx = {
|
|
104
106
|
getCwd: cwd,
|
|
105
107
|
sessions,
|
|
106
|
-
allocSessionId: () => String(nextId++),
|
|
107
108
|
notifyRef,
|
|
108
109
|
requestRef,
|
|
109
110
|
// 能力位按「session/new 时刻取值」注入每个会话(§11.4——会话生命周期内不变)
|
|
110
111
|
createSession: (opts) => createSession({ ...opts, clientCaps: clientCaps.current }),
|
|
111
112
|
// 门助手由入口建一次、按引用传(§11.5——`session/new` 是门链首个触点)
|
|
112
113
|
requireConfigured: createRequireConfigured({ isConfigured, providerStatus }),
|
|
114
|
+
// 认领释放单点由入口建一次、按引用传(§2.4——close ∥ load 替换 ∥ resume 替换同源)
|
|
115
|
+
releaseClosedSlot: createSlotReleaser({ getCwd: cwd, sessions }),
|
|
113
116
|
log,
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
// 只读数据接口族(read-data-interface 批):三方法 + 变更观察器(`runAcpServer` 启动)。
|
|
120
|
+
const readData = createReadDataHandlers(ctx)
|
|
121
|
+
|
|
116
122
|
return {
|
|
117
123
|
handlers: {
|
|
118
124
|
...createCapsHandlers({ version, clientCaps, requireConfigured: ctx.requireConfigured, log }),
|
|
119
125
|
...createSessionHandlers(ctx),
|
|
120
126
|
...createSlotsHandlers(ctx),
|
|
121
127
|
...createExtHandlers(ctx),
|
|
128
|
+
...readData.handlers,
|
|
122
129
|
},
|
|
123
130
|
sessions,
|
|
124
131
|
notifyRef,
|
|
125
132
|
requestRef,
|
|
133
|
+
watcher: readData.watcher,
|
|
126
134
|
}
|
|
127
135
|
}
|
|
128
136
|
|
|
@@ -138,6 +146,7 @@ export async function runAcpServer() {
|
|
|
138
146
|
const server = createAcpServer(built.handlers, { log })
|
|
139
147
|
built.notifyRef.current = server.notify
|
|
140
148
|
built.requestRef.current = server.request
|
|
149
|
+
built.watcher.start() // 变更观察器:首查立基线(静默)+ 5s 指纹轮询(进程终灭即止)
|
|
141
150
|
log(`[acp] thincoder ${VERSION} — ACP v1 over stdio, waiting for initialize`)
|
|
142
151
|
server.start()
|
|
143
152
|
}
|
|
@@ -45,7 +45,7 @@ export async function chatCommand(args, ctx) {
|
|
|
45
45
|
}
|
|
46
46
|
if (headlessCfg.providerState === "fallback" && agent.provider?.name) {
|
|
47
47
|
const m = typeof agent.provider.model === "string" && agent.provider.model ? `:${agent.provider.model}` : ""
|
|
48
|
-
console.error(`尚未设置默认模型:本次使用 \`${agent.provider.name}${m}\`——/config → 默认模型
|
|
48
|
+
console.error(`尚未设置默认模型:本次使用 \`${agent.provider.name}${m}\`——/config → 默认模型 设置一次(或 /model 选定即成为默认模型)`)
|
|
49
49
|
}
|
|
50
50
|
if (!agent.provider.apiKey) {
|
|
51
51
|
if (!process.stdin.isTTY) {
|
|
@@ -167,6 +167,13 @@ export async function tuiCommand(ctx) {
|
|
|
167
167
|
)
|
|
168
168
|
}
|
|
169
169
|
const { startTUI } = await import("./tui.mjs")
|
|
170
|
+
// F-LX3(批 2026-10-05-ledger-variant-db-notice · 台账 #935 · LEDGER.md §12.2):变体键库首跑检测提示——
|
|
171
|
+
// 动态 import(新档静态链入 node:sqlite——W8 契约②)+ import ∥ 调用整段 try(静默降级:失败 ⇒ 不传 opts)
|
|
172
|
+
let ledgerVariantNotice
|
|
173
|
+
try {
|
|
174
|
+
const { ledgerVariantNotice: detect } = await import("@thincoder/core/ledger-variant-notice.mjs")
|
|
175
|
+
ledgerVariantNotice = detect({ cwd: agent.cwd, locale: agent.config?.locale }) ?? undefined
|
|
176
|
+
} catch { /* 静默降级——绝不阻塞启动 */ }
|
|
170
177
|
try {
|
|
171
178
|
await startTUI(agent, {
|
|
172
179
|
projectDir: config.memory.projectDir ? (isAbsolute(config.memory.projectDir) ? config.memory.projectDir : join(process.cwd(), config.memory.projectDir)) : null,
|
|
@@ -175,6 +182,8 @@ export async function tuiCommand(ctx) {
|
|
|
175
182
|
restored: data ? sessionDescriptor(agent, data) : data,
|
|
176
183
|
// R25(F-R25c):TUI 启动显示一行"上次运行异常终止"提示(showStartup 渲染——无匹配不传)
|
|
177
184
|
crashNotice: recentCrashHint() ?? undefined,
|
|
185
|
+
// F-LX3(同上):变体键库首跑提示行(showStartup 渲染——无命中不传)
|
|
186
|
+
ledgerVariantNotice,
|
|
178
187
|
})
|
|
179
188
|
} catch (error) {
|
|
180
189
|
console.error(`[error] ${error.message}`)
|
package/src/command-table.mjs
CHANGED
|
@@ -159,7 +159,14 @@ export async function runCommandTable(command, args, ctx) {
|
|
|
159
159
|
process.exitCode = await (args[0] === "migrate" ? m.runLedgerMigrate(args) : m.runLedgerAudit(args))
|
|
160
160
|
break
|
|
161
161
|
}
|
|
162
|
-
|
|
162
|
+
// 只读数据接口(read-data-interface 批):`ledger list --json [--full] [--family] [--cwd <dir>]`——
|
|
163
|
+
// 核内 runner(ledger-read.mjs)严格解析(缺 --json / 未知参 ⇒ usage + exit 1);壳侧只分发。
|
|
164
|
+
if (args[0] === "list") {
|
|
165
|
+
const m = await import("@thincoder/core/ledger-read.mjs")
|
|
166
|
+
process.exitCode = m.runLedgerList(args.slice(1))
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
console.error("Usage: thincoder ledger migrate --dry-run | --confirm [--from <key>] | thincoder ledger audit [--root <dir>] | thincoder ledger list --json [--full] [--family] [--cwd <dir>]")
|
|
163
170
|
exitSoon(1)
|
|
164
171
|
break
|
|
165
172
|
}
|
package/src/completions.mjs
CHANGED
|
@@ -24,9 +24,10 @@ export function printCompletion(shell) {
|
|
|
24
24
|
sweep) COMPREPLY=( $(compgen -W "--origin= --dry-run --confirm" -- "$cur") ) ;;
|
|
25
25
|
esac ;;
|
|
26
26
|
ledger) case "$prev" in
|
|
27
|
-
ledger) COMPREPLY=( $(compgen -W "migrate audit" -- "$cur") ) ;;
|
|
27
|
+
ledger) COMPREPLY=( $(compgen -W "migrate audit list" -- "$cur") ) ;;
|
|
28
28
|
migrate) COMPREPLY=( $(compgen -W "--dry-run --confirm --from" -- "$cur") ) ;;
|
|
29
29
|
audit) COMPREPLY=( $(compgen -W "--root" -- "$cur") ) ;;
|
|
30
|
+
list) COMPREPLY=( $(compgen -W "--json --full --family --cwd" -- "$cur") ) ;;
|
|
30
31
|
esac ;;
|
|
31
32
|
distill) COMPREPLY=( $(compgen -W "--yes --layer=" -- "$cur") ) ;;
|
|
32
33
|
completion) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) ;;
|
|
@@ -59,7 +60,7 @@ _thincoder() {
|
|
|
59
60
|
'upgrade[Update to latest version from npm]' \\
|
|
60
61
|
'completion[Generate shell completion script]' \\
|
|
61
62
|
'session[Session dir GC: session gc --dry-run|--confirm]' \\
|
|
62
|
-
'ledger[Ledger variants: migrate / audit]'
|
|
63
|
+
'ledger[Ledger variants: migrate / audit / list]'
|
|
63
64
|
;;
|
|
64
65
|
args)
|
|
65
66
|
case "$words[1]" in
|
|
@@ -72,6 +73,7 @@ _thincoder() {
|
|
|
72
73
|
esac ;;
|
|
73
74
|
ledger) case "$words[2]" in
|
|
74
75
|
migrate) _arguments '--dry-run[Report only]' '--confirm[Apply migration]' '--from:Source key:' ;;
|
|
76
|
+
list) _arguments '--json[Single-segment JSON output]' '--full[Include evidence]' '--family[Family scope]' '--cwd:Project anchor:' ;;
|
|
75
77
|
audit) _arguments '--root:Scan root dir:' ;; esac ;;
|
|
76
78
|
distill) _arguments '--yes[Skip confirmation]' '--layer=[Layer filter]' ;;
|
|
77
79
|
completion) _values 'shell' 'bash' 'zsh' 'fish' ;;
|
|
@@ -94,7 +96,7 @@ complete -c thincoder -a distill -d 'Extract knowledge from session'
|
|
|
94
96
|
complete -c thincoder -a upgrade -d 'Update to latest version'
|
|
95
97
|
complete -c thincoder -a completion -d 'Shell completion'
|
|
96
98
|
complete -c thincoder -a session -d 'Session dir GC (session gc --dry-run|--confirm)'
|
|
97
|
-
complete -c thincoder -a ledger -d 'Ledger variants: migrate / audit'
|
|
99
|
+
complete -c thincoder -a ledger -d 'Ledger variants: migrate / audit / list'
|
|
98
100
|
complete -c thincoder -a acp -d 'Agent Client Protocol server for IDEs'
|
|
99
101
|
|
|
100
102
|
# Flags
|
|
@@ -133,8 +135,9 @@ complete -c thincoder -n '__fish_seen_subcommand_from distill' -l layer -d 'Laye
|
|
|
133
135
|
complete -c thincoder -n '__fish_seen_subcommand_from completion' -a bash -d 'Bash completions'
|
|
134
136
|
complete -c thincoder -n '__fish_seen_subcommand_from completion' -a zsh -d 'Zsh completions'
|
|
135
137
|
complete -c thincoder -n '__fish_seen_subcommand_from completion' -a fish -d 'Fish completions'
|
|
136
|
-
complete -c thincoder -n '__fish_seen_subcommand_from ledger' -a 'migrate audit' -d 'Ledger variants: migrate / audit'
|
|
138
|
+
complete -c thincoder -n '__fish_seen_subcommand_from ledger' -a 'migrate audit list' -d 'Ledger variants: migrate / audit / list'
|
|
137
139
|
complete -c thincoder -n '__fish_seen_subcommand_from ledger' -l dry-run -l confirm -l from -l root -d 'Ledger flags'
|
|
140
|
+
complete -c thincoder -n '__fish_seen_subcommand_from ledger; and __fish_seen_subcommand_from list' -l json -l full -l family -l cwd -d 'Ledger list flags'
|
|
138
141
|
`)
|
|
139
142
|
}
|
|
140
143
|
}
|
|
@@ -8,13 +8,30 @@
|
|
|
8
8
|
*
|
|
9
9
|
* 2026-09-17 消端差:提示词面(persona / discipline / common)注入锚全消——导航注记删
|
|
10
10
|
* (正文自足)、端特有段通用化并入核内正文、文档地图路径统一写死 `docs/README.md`。
|
|
11
|
-
* 剩 = 工具面 2 锚(bash-terminal-face / question-ui-face
|
|
11
|
+
* 剩 = 工具面 2 锚(bash-terminal-face / question-ui-face)。
|
|
12
|
+
*
|
|
13
|
+
* bash-executor-face 批(台账 #922):`bash-terminal-face` 激活 = 运行时执行器身份声明行
|
|
14
|
+
* (核心单源 `resolveShellIdentity`——spawn 同链探测 + memo 按配置值键控)。
|
|
15
|
+
* 求值形态 = **import 期求值**(端装配层求值路径——核心缝 `applyPromptInjections` 为纯字符串
|
|
16
|
+
* 替换、不支持函数值键:函数值会被 String.replace 字符串化)。求值时机 = 注册前、任何装配前;
|
|
17
|
+
* **描述面值随进程启动固定**——进程内改 `shell` 配置不刷新本行(重启生效);hint 门
|
|
18
|
+
* (核 `tools/bash.mjs` 逐执行调用同一单源)按配置值 memo 键控重探。
|
|
19
|
+
* 偏差登记 = 批次档 §5(设计终值「值函数 / 重装配期求值」于纯字符串缝不可达——取端装配层前置求值)。
|
|
20
|
+
* 机制权威 = `docs/core/design/BASH-EXECUTOR-FACE.md` §2 值面 + §3.2。
|
|
12
21
|
*/
|
|
13
22
|
|
|
14
|
-
|
|
23
|
+
import { resolveShellIdentity, executorLine } from "@thincoder/core/shell-identity.mjs"
|
|
24
|
+
import { loadConfig } from "@thincoder/core/config.mjs"
|
|
25
|
+
|
|
26
|
+
// 配置读容错(先例 = `bin/thincoder.mjs:54` 同式「配置缺失/损坏 ⇒ 各向默认」):读取失败不阻断进程,
|
|
27
|
+
// 读失败 ⇒ null ⇒ 默认链(fail-open——探测面不猜)。
|
|
28
|
+
function configShell() {
|
|
29
|
+
try { return loadConfig()?.shell ?? null } catch { return null }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// CLI 无 terminal 参数(工具面 review 再统一)⇒ 执行器声明行单值。
|
|
15
33
|
export const CLI_PROMPT_INJECTIONS = {
|
|
16
|
-
|
|
17
|
-
"bash-terminal-face": "",
|
|
34
|
+
"bash-terminal-face": executorLine(resolveShellIdentity(configShell())),
|
|
18
35
|
// question 工具可用性行(核内正文该行已移出正文、锚原位替换——本端填回 CLI 措辞)
|
|
19
36
|
"question-ui-face": "- Availability: this tool needs an interactive UI — in contexts without one (headless runs) it returns an error instead of asking; subagent children (depth>0) never get it (excluded from their tool tables); put the question in your reply text instead.",
|
|
20
37
|
}
|
package/src/tui/cmd-model.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import { parseModelRef } from "@thincoder/core/config.mjs"
|
|
|
5
5
|
* `/model <provider>:<model>`(MODEL-MERGE-SESSION——裁定③:裸 provider 拒——显式 p:m;
|
|
6
6
|
* MODEL-SELECTION v2:显式复合一律放行——仅[空值/裸值/未知 provider]无效(M4),候选清单
|
|
7
7
|
* 由运行期拉取决定——不再有成员校验)。
|
|
8
|
-
* /model
|
|
8
|
+
* /model 是会话级操作(写槽)——选定实变即同拍写回 config.defaultModel(判据句 6——新会话
|
|
9
|
+
* 起点随动)。config 默认模型显式设置走 /config → 默认模型。
|
|
9
10
|
* ctx: { agent, openModelPicker, selectModel, pushLine } */
|
|
10
11
|
export async function handleModelCommand(ctx, args = []) {
|
|
11
12
|
const raw = args[0]?.trim()
|
|
@@ -18,7 +19,7 @@ export async function handleModelCommand(ctx, args = []) {
|
|
|
18
19
|
const parsed = parseModelRef(raw, ctx.agent.providers)
|
|
19
20
|
if (!parsed.ok) {
|
|
20
21
|
ctx.pushLine(`[error] ${parsed.reason}`, C.error)
|
|
21
|
-
ctx.pushLine(`/model 用法: /model provider:model
|
|
22
|
+
ctx.pushLine(`/model 用法: /model provider:model(会话级——选定即成为默认模型)`, C.dim)
|
|
22
23
|
return
|
|
23
24
|
}
|
|
24
25
|
await ctx.selectModel({ provider: parsed.provider.name, model: parsed.model }).catch((e) => ctx.pushLine(`[error] ${e.message}`, C.error))
|
package/src/tui/cmd-reindex.mjs
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { resolve } from "node:path"
|
|
1
3
|
import { C } from "./ansi.mjs"
|
|
2
4
|
|
|
5
|
+
/** home 根防护(#867 · `MEMORY.md` §6.14 L-④ · 同 startup.mjs):`cwd` = 用户主目录(resolve 后;win32 大小写不敏感)。 */
|
|
6
|
+
const isHomeDir = (dir) => typeof dir === "string" && dir !== "" &&
|
|
7
|
+
(process.platform === "win32" ? resolve(dir).toLowerCase() === resolve(homedir()).toLowerCase() : resolve(dir) === resolve(homedir()))
|
|
8
|
+
|
|
3
9
|
/** /reindex command: rebuild memory index (files + code_chunks + doc_chunks).
|
|
4
10
|
* ctx: { agent, distillOpts, pushLine } */
|
|
5
11
|
export async function handleReindexCommand(ctx) {
|
|
6
12
|
const { agent, distillOpts, pushLine } = ctx
|
|
13
|
+
if (isHomeDir(agent.cwd)) { // #867:home 根 ⇒ 跳过(零表删 ∥ 三 sync 零调用;含出路提示)
|
|
14
|
+
pushLine("[reindex] Skipped: working directory is the home directory — start in a project dir", C.warn)
|
|
15
|
+
return
|
|
16
|
+
}
|
|
7
17
|
const { syncDir, codeSync, docSync } = await import("@thincoder/core/memory.mjs")
|
|
8
18
|
pushLine("[reindex] Rebuilding index...", C.tool)
|
|
9
19
|
agent.memory.db.prepare("DELETE FROM files").run()
|
package/src/tui/cmd-undo.mjs
CHANGED
|
@@ -1,46 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* cmd-undo.mjs — /undo command: revert recent file modifications
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Reads the snapshot stack (`agent._undoStack`) — snapshot single point = `thincoder-core/undo-stack.mjs`
|
|
5
|
+
* (`snapshotForUndo`;CLI 侧旧副本已删 —— 单源 = 核档 · CORE-UNIFICATION §2.5 #149)。
|
|
6
|
+
* /undo opens a picker to select and revert an operation; **oversize 占位**(无快照 —— 备份超
|
|
7
|
+
* `MAX_UNDO_BYTES`)**列表可见 · 不可回退**(#863 · `docs/cli/design/TUI-COMMANDS.md` §5.3)。
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
|
-
import { existsSync, writeFileSync, unlinkSync
|
|
10
|
+
import { existsSync, writeFileSync, unlinkSync } from "node:fs"
|
|
9
11
|
import { isAbsolute, join } from "node:path"
|
|
12
|
+
import { MAX_UNDO_BYTES } from "@thincoder/core/undo-stack.mjs"
|
|
10
13
|
import { ansi, C } from "./ansi.mjs"
|
|
11
14
|
|
|
12
|
-
const MAX_UNDO = 50
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Snapshot a file before a side-effect tool modifies it.
|
|
16
|
-
* Called from dispatch.mjs before each write/edit/delete/apply_patch/hashline_edit.
|
|
17
|
-
*/
|
|
18
|
-
export function snapshotForUndo(agent, toolName, args, cwd) {
|
|
19
|
-
if (!agent._undoStack) agent._undoStack = []
|
|
20
|
-
const path = args.path ?? args.file
|
|
21
|
-
if (!path || typeof path !== "string") return
|
|
22
|
-
|
|
23
|
-
// 绝对路径直接用(path.join 对绝对段不重置——Windows 反斜杠路径也不按 "/" 切分);相对路径整段 join(cwd)。
|
|
24
|
-
const abs = isAbsolute(path) ? path : join(cwd, path)
|
|
25
|
-
let backup = null
|
|
26
|
-
try {
|
|
27
|
-
if (existsSync(abs)) {
|
|
28
|
-
backup = readFileSync(abs, "utf8")
|
|
29
|
-
}
|
|
30
|
-
} catch {
|
|
31
|
-
// can't read — maybe binary, skip
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
agent._undoStack.push({
|
|
36
|
-
tool: toolName,
|
|
37
|
-
path,
|
|
38
|
-
backup,
|
|
39
|
-
timestamp: Date.now(),
|
|
40
|
-
})
|
|
41
|
-
if (agent._undoStack.length > MAX_UNDO) agent._undoStack.shift()
|
|
42
|
-
}
|
|
43
|
-
|
|
44
15
|
export async function handleUndoCommand(ctx) {
|
|
45
16
|
const { agent, pushLine, showPicker } = ctx
|
|
46
17
|
const stack = agent._undoStack ?? []
|
|
@@ -55,9 +26,11 @@ export async function handleUndoCommand(ctx) {
|
|
|
55
26
|
...stack.map((item, i) => {
|
|
56
27
|
const relIdx = stack.length - i
|
|
57
28
|
const time = new Date(item.timestamp).toLocaleTimeString()
|
|
58
|
-
const preview = item.
|
|
59
|
-
? "(
|
|
60
|
-
:
|
|
29
|
+
const preview = item.oversize === true
|
|
30
|
+
? "(no snapshot — too large to undo)"
|
|
31
|
+
: item.backup === null
|
|
32
|
+
? "(was created — undo will delete)"
|
|
33
|
+
: `(${item.backup.split("\n").length} lines — undo will restore)`
|
|
61
34
|
return {
|
|
62
35
|
type: "item",
|
|
63
36
|
text: `#${relIdx} ${item.tool}: ${item.path} ${preview} — ${time}`,
|
|
@@ -69,6 +42,11 @@ export async function handleUndoCommand(ctx) {
|
|
|
69
42
|
const e = await showPicker("Undo", entries)
|
|
70
43
|
if (!e) return
|
|
71
44
|
const item = stack[e.idx]
|
|
45
|
+
if (item.oversize === true) {
|
|
46
|
+
// #863:oversize 占位(无快照)——须先判 `oversize` 再判 `backup === null`,否则被当「文件创建」态误删档。
|
|
47
|
+
pushLine(`[undo] Cannot revert ${item.tool} ${item.path} — no snapshot (file larger than ${MAX_UNDO_BYTES / 1_000_000} MB)`, C.warn)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
72
50
|
const abs = isAbsolute(item.path) ? item.path : join(agent.cwd, item.path)
|
|
73
51
|
|
|
74
52
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** persistRaw / syncProviderField / maskKey: config read/write helpers.
|
|
2
2
|
* Extracted from index.mjs for shared use by slash-commands, wizard, and pickers.
|
|
3
|
-
* createConfigHelpers(agent) returns { persistRaw, syncProviderField, maskKey }
|
|
3
|
+
* createConfigHelpers(agent) returns { persistRaw, syncProviderField, maskKey };模块级另导出
|
|
4
|
+
* carryoverDefaultModel(会话选定写回单点——SESSION.md §6.21 判据句 6;见下方定义)。
|
|
4
5
|
* R10 F5(D-F5b):persistRaw 全链(磁盘新鲜读→mutate→写)收口进 config.mjs
|
|
5
6
|
* writeConfigAtomic——mtime 门控 + .bak + 冲突放弃(见 MULTI-INSTANCE-COLLAB.md §2a.2)。
|
|
6
7
|
* createConfigHelpers(agent, opts):opts.configPath 为测试注入缝(默认 configPath——
|
|
@@ -44,3 +45,35 @@ export function createConfigHelpers(agent, opts = {}) {
|
|
|
44
45
|
|
|
45
46
|
return { persistRaw, syncProviderField, maskKey }
|
|
46
47
|
}
|
|
48
|
+
|
|
49
|
+
/** 会话选定写回单点(SESSION.md §6.21 判据句 6——用户显式选定 ⇒ 同拍写回 config.defaultModel
|
|
50
|
+
* = "<provider>:<model>";新会话起点随用户最后一次显式选择)。门内聚于本单点(KD-883-1——
|
|
51
|
+
* call-site 零判据、零复合串):
|
|
52
|
+
* ① 槽面实变门:slotBefore 复合串 = 选定复合 ⇒ 零写(回声 ∕ 重选——系统同步不劫持全局默认);
|
|
53
|
+
* ② 等值门:现值 `loadConfig().defaultModel` 同串 ⇒ 零写(防盘面抖动 ∥ 探针空转)。
|
|
54
|
+
* 写经核 `writeConfigAtomic` + `_configPath()`(mtime 门控 + `.bak` 现场);失败不反扑会话写、
|
|
55
|
+
* 绝不抛出——畸形档 ∥ 写错误 ⇒ `{ ok:false, reason }`。返回 `{ ok:true, written:boolean }`。 */
|
|
56
|
+
export async function carryoverDefaultModel({ provider, model, slotBefore = null } = {}) {
|
|
57
|
+
const composite = compositeOf(provider, model)
|
|
58
|
+
if (composite === null) return { ok: false, reason: "invalid model reference — expected provider:model" }
|
|
59
|
+
try {
|
|
60
|
+
const { loadConfig } = await import("@thincoder/core/config.mjs")
|
|
61
|
+
const { writeConfigAtomic, _configPath } = await import("@thincoder/core/config-io.mjs")
|
|
62
|
+
const before = compositeOf(slotBefore?.activeProvider, slotBefore?.activeModel)
|
|
63
|
+
if (before === composite) return { ok: true, written: false } // ① 槽面实变门(档缺 ⇒ null ⇒ 判真)
|
|
64
|
+
if (loadConfig().defaultModel === composite) return { ok: true, written: false } // ② 等值门
|
|
65
|
+
const r = writeConfigAtomic(_configPath(), (raw) => { raw.defaultModel = composite })
|
|
66
|
+
if (!r.ok) return { ok: false, reason: r.reason }
|
|
67
|
+
return { ok: true, written: true }
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { ok: false, reason: error?.message ?? String(error) }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 复合串(比较单元构造单源——首冒号分割语义 = parseModelRef 同族):双段非空 ⇒
|
|
74
|
+
* "provider:model";档缺 ∕ 半缺 ⇒ null(无基线——不参与相等判定)。 */
|
|
75
|
+
function compositeOf(provider, model) {
|
|
76
|
+
const p = typeof provider === "string" && provider ? provider : null
|
|
77
|
+
const m = typeof model === "string" && model ? model : null
|
|
78
|
+
return p && m ? `${p}:${m}` : null
|
|
79
|
+
}
|