Compare commits
6 Commits
8dc02b6b35
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c14f82830f | |||
| a9b4251158 | |||
| 33155181a1 | |||
| d03a9f462c | |||
| 2d4df45acb | |||
| cb9db666ba |
@@ -20,6 +20,7 @@
|
||||
!/plugins/
|
||||
!/memory/
|
||||
!/.gitignore
|
||||
!/AGENTS.md
|
||||
|
||||
# Per-memory hit counters. Derived data: regenerating it costs one recall, and
|
||||
# committing it would rewrite the file on every retrieval and create merge
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# OpenCode 指令配置
|
||||
|
||||
## 1. 称呼规范
|
||||
|
||||
每次回复时必须称呼用户为 "小帅"。
|
||||
|
||||
## 2. 代码文件存放规范
|
||||
|
||||
没有既有项目结构时,新生成的辅助代码放到项目根目录的 `.code/` 文件夹,保持项目整洁;已有项目结构及框架要求的文件位置优先。
|
||||
|
||||
## 3. 第一性原理与双轨回复
|
||||
|
||||
**思维方式**:从原始需求出发,拒绝经验主义和路径盲从,不预设用户目标完全清晰。
|
||||
|
||||
- 仅当缺失信息会实质改变目标、范围或结果时提问;其他细节说明合理假设后继续,可独立完成的工作先推进。
|
||||
|
||||
**回复结构**:
|
||||
|
||||
**直接执行**:始终提供,按当前要求与逻辑给出任务结果。
|
||||
|
||||
**深度交互**:涉及方案设计、路径选择或目标定义时,仅当发现目标与方案存在明显错位,或有显著更省时省钱的路径时,简短指出并建议替代方案。纯执行类任务(改变量名、格式调整等)省略此部分。
|
||||
|
||||
以上是行为要求,无需在每次回复中固定列出 "直接执行" 和 "深度交互" 两个栏目。
|
||||
@@ -19,6 +19,7 @@
|
||||
| 目录 | 概述 | 条目 |
|
||||
|---|---|---|
|
||||
| decisions/ | 选 Markdown 因为人类可纠正性和可同步性,检索由插件做而非模型。 | 1 |
|
||||
| facts/ | 该目录自身是 git 仓库,只跟踪 skills/ plugins/ memor | 1 |
|
||||
| pitfalls/ | unicode61 把整段中文当一个 token,trigram 漏掉两字词,必 | 1 |
|
||||
| rules/ | 不可违反的硬规则,常驻 | 1 (1 钉住) |
|
||||
<!-- auto:end
|
||||
@@ -29,8 +30,12 @@
|
||||
|
||||
| 场景 / 触发 | 读哪个文件 | 类型 |
|
||||
|---|---|---|
|
||||
| ~/.config/opencode 改动 | facts/opencode-config-oc-repo.md | fact |
|
||||
| FTS5 | pitfalls/sqlite-cjk-tokenizer.md | fact |
|
||||
| memory | decisions/memory-store-format.md | fact |
|
||||
| oc 仓库 | facts/opencode-config-oc-repo.md | fact |
|
||||
| opencode 配置提交 | facts/opencode-config-oc-repo.md | fact |
|
||||
| skills plugins memory 推送 | facts/opencode-config-oc-repo.md | fact |
|
||||
| SQLite | pitfalls/sqlite-cjk-tokenizer.md | fact |
|
||||
| 中文检索 | pitfalls/sqlite-cjk-tokenizer.md | fact |
|
||||
| 为什么用 markdown | decisions/memory-store-format.md | fact |
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# facts 索引
|
||||
|
||||
<!-- auto:begin facts
|
||||
|
||||
| 文件 | 概述 | 重点 |
|
||||
|---|---|---|
|
||||
| opencode-config-oc-repo.md | 该目录自身是 git 仓库,只跟踪 skills/ plugins/ memory/ 三棵树,推送走 SSH 到 xiaoshuai/oc.git。 | 改动 skills/ plugins/ memory/ 后需单独在该目录 commit + push |
|
||||
<!-- auto:end
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: facts/opencode-config-oc-repo.md
|
||||
kind: fact
|
||||
summary: 该目录自身是 git 仓库,只跟踪 skills/ plugins/ memory/ 三棵树,推送走 SSH 到 xiaoshuai/oc.git。
|
||||
highlight: 改动 skills/ plugins/ memory/ 后需单独在该目录 commit + push
|
||||
created: 1791799869835
|
||||
updated: 1791799869835
|
||||
load_when:
|
||||
- ~/.config/opencode 改动
|
||||
- oc 仓库
|
||||
- opencode 配置提交
|
||||
- skills plugins memory 推送
|
||||
---
|
||||
|
||||
`~/.config/opencode` 与任何项目仓库无关,是独立仓库:origin = git@git.xsinfo.vip:xiaoshuai/oc.git,分支 main,gitignore 用 `/*` 全忽略再 `!` 放行 skills/ plugins/ memory/(外加 .gitignore、pwsh.cmd、package.json),node_modules 与 memory/**/.state.json 不跟踪。在项目目录里跑 git status 看不到它的改动,必须 cd 过去。HTTPS 推送无凭据必失败,用 SSH。当前有效的提交准则见该仓库 memory/rules/only-commit-fully-tested.md:端到端跑通才提交、禁止 mock 冒充验证。
|
||||
@@ -4,5 +4,5 @@
|
||||
|
||||
| 文件 | 概述 | 重点 |
|
||||
|---|---|---|
|
||||
| plugin-push-on-change.md | 改动这三棵目录后立即 commit 并 push,不询问;改插件代码后必须重启服务。 | 本目录是 git 仓库;.gitignore 只放行 skills/ plugins/ memory/ 三棵树 |
|
||||
| only-commit-fully-tested.md | 端到端跑通才算通过;禁止用 mock 冒充验证;已知缺陷不许推。 | 辅助函数单测通过 ≠ 功能可用;commit message 的 Verified 必须来自真实调用 |
|
||||
<!-- auto:end
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
id: rules/only-commit-fully-tested.md
|
||||
kind: rule
|
||||
summary: 端到端跑通才算通过;禁止用 mock 冒充验证;已知缺陷不许推。
|
||||
highlight: 辅助函数单测通过 ≠ 功能可用;commit message 的 Verified 必须来自真实调用
|
||||
created: 1791513150284
|
||||
updated: 1791513150284
|
||||
pinned: true
|
||||
---
|
||||
|
||||
**只有完全通过测试的代码才提交。**
|
||||
|
||||
没有跑通就不许 commit。部分验证通过、靠 mock 构造的结果、只测了辅助函数没测真实链路 —— 这些都不算通过。
|
||||
|
||||
具体要求:
|
||||
|
||||
1. **端到端跑通才算**。辅助函数的单测通过不代表功能可用。写完先在真实环境跑一遍,确认输出符合预期。
|
||||
2. **禁止用 mock 结果冒充真实验证**。commit message 里的 Verified 必须来自真实调用,不是构造的返回值。
|
||||
3. **已知缺陷的代码不许推**。发现问题时先修,修完重新验证;修不好就别提交,别用「改动本身是对的」当借口先推上去。
|
||||
4. **如实写验证范围**。commit message 里写清测了什么、没测什么,不要用笼统的「已测试」盖过去。
|
||||
5. **不确定就说不确定**。宁可回复「还没验证完」,也不要让用户以为可以放心用。
|
||||
|
||||
反面教材(我自己犯过):自动抽取的触发器能跑通、能报告状态,就宣称「抽取已打通」,实际上 `generate.text` 从来没成功调用过 —— 一个永远写不进记忆的抽取器,触发得再准时也是坏的。真实链路没验证就提交,还把 mock 结果写进 commit message 冒充 Verified。
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
id: rules/plugin-push-on-change.md
|
||||
kind: rule
|
||||
summary: 改动这三棵目录后立即 commit 并 push,不询问;改插件代码后必须重启服务。
|
||||
highlight: 本目录是 git 仓库;.gitignore 只放行 skills/ plugins/ memory/ 三棵树
|
||||
created: 1791512032262
|
||||
updated: 1791512893909
|
||||
pinned: true
|
||||
---
|
||||
|
||||
`~/.config/opencode` 自身是 git 仓库,`.gitignore` 只放行三棵树:`skills/`、`plugins/`、`memory/`(外加 .gitignore、pwsh.cmd、package.json)。
|
||||
|
||||
在这三棵树下创建、修改、删除任何文件后,**提交并推送后再回复用户**,不要问是否要 push。
|
||||
|
||||
推送用 SSH(origin 已是 SSH 地址)。HTTPS 推送会提示密码且无 TTY,必然失败。
|
||||
|
||||
推送失败就报错并停下,不要静默重试,也不要 `git reset`。提交信息要写清楚改了什么、为什么改。
|
||||
|
||||
**改完插件代码必须 `opencode service restart`**:热重载会应用新工具名,但已缓存的模块改动不一定生效。踩过两次「改了没生效」的坑。
|
||||
+165
-61
@@ -12,7 +12,7 @@
|
||||
// one, but only a human confirms it. Letting a model write "iron rules"
|
||||
// would let a hallucination become permanent policy.
|
||||
|
||||
import type { Context } from "@opencode/plugin"
|
||||
type Context = import("@opencode/plugin").Plugin.Context
|
||||
import { tokenize } from "./recall.ts"
|
||||
|
||||
export interface ExtractOptions {
|
||||
@@ -72,9 +72,13 @@ If nothing durable was learned, return [].`
|
||||
* Pull plain text out of a session message without depending on the exact
|
||||
* message shape: the plugin package ships types for a schema the host may
|
||||
* evolve, so this walks defensively and collects `text` fields.
|
||||
*
|
||||
* The 400-part cap was removed: it silently truncated long transcripts, so a
|
||||
* 300-turn session would only contribute its first ~50 turns to extraction.
|
||||
* Depth still bounds recursion on malformed input.
|
||||
*/
|
||||
export function collectText(value: unknown, depth = 0, out: string[] = []): string[] {
|
||||
if (depth > 6 || out.length > 400) return out
|
||||
if (depth > 6) return out
|
||||
if (typeof value === "string") {
|
||||
if (value.trim()) out.push(value)
|
||||
return out
|
||||
@@ -101,9 +105,19 @@ export function userAndAssistantText(messages: readonly unknown[]): string {
|
||||
for (const message of messages) {
|
||||
const record = message as Record<string, unknown> | null
|
||||
if (!record) continue
|
||||
const role = record.role
|
||||
// The role sits at the top level or under `message`, depending on whether
|
||||
// the host returned `Session.Message.Info` or its inner message. Reading
|
||||
// only `.role` produced an empty transcript, which then failed the length
|
||||
// gate and made extraction look permanently inert.
|
||||
const nested = record.message as Record<string, unknown> | undefined
|
||||
const role =
|
||||
typeof record.role === "string"
|
||||
? record.role
|
||||
: typeof nested?.role === "string"
|
||||
? nested.role
|
||||
: undefined
|
||||
if (role !== "user" && role !== "assistant") continue
|
||||
const texts = collectText(record.content)
|
||||
const texts = collectText(record.content ?? nested)
|
||||
const joined = texts.join("\n").trim()
|
||||
if (joined) parts.push(`${role === "user" ? "USER" : "ASSISTANT"}: ${joined}`)
|
||||
}
|
||||
@@ -187,7 +201,8 @@ export function slugify(input: string): string {
|
||||
.slice(0, 48)
|
||||
if (ascii) return ascii
|
||||
// CJK titles have no ASCII to work with; fall back to a hash-free stable id.
|
||||
return [...input].map((ch) => ch.codePointAt(0)!.toString(36)).join("").slice(0, 24)
|
||||
// `codePointAt(0)` returns undefined for an empty string, so guard with ?? 0.
|
||||
return [...input].map((ch) => (ch.codePointAt(0) ?? 0).toString(36)).join("").slice(0, 24)
|
||||
}
|
||||
|
||||
/** Secrets that must never reach a git-tracked memory file. */
|
||||
@@ -199,6 +214,9 @@ const SECRET_PATTERNS: RegExp[] = [
|
||||
/\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
||||
/\b(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*\S+/gi,
|
||||
/-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g,
|
||||
// CJK variants: \b does not work on Chinese text, so match the keyword
|
||||
// directly with a separator and a non-space value.
|
||||
/(?:密码|口令|密钥|秘钥|api[_-]?key|access[_-]?token|auth[_-]?token|secret)\s*[:=:]\s*\S+/gi,
|
||||
]
|
||||
|
||||
export function hasSecret(text: string): string | undefined {
|
||||
@@ -219,7 +237,7 @@ export interface ExtractionOutcome {
|
||||
candidates: Candidate[]
|
||||
rejected: { candidate: Candidate; reason: string }[]
|
||||
/**
|
||||
* Set when the round-trip itself failed. Absent means the model answered.
|
||||
* Set when every model call failed. Absent means one answered.
|
||||
* Without this, a broken call and a conversation with nothing worth keeping
|
||||
* both come back as zero candidates — which is how a permanently broken
|
||||
* extractor can look like a healthy one that simply rarely fires.
|
||||
@@ -227,6 +245,10 @@ export interface ExtractionOutcome {
|
||||
error?: string
|
||||
/** Raw model text, truncated, for diagnosing a surprising empty result. */
|
||||
rawText?: string
|
||||
/** The model that actually answered, when not the first candidate. */
|
||||
modelUsed?: string
|
||||
/** Models that failed before one succeeded. */
|
||||
notes?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,12 +261,15 @@ export interface ExtractionOutcome {
|
||||
* Coverage answers the question we actually care about: "does an existing
|
||||
* memory already say this?"
|
||||
*/
|
||||
/** Floor on matched signals, so coincidental overlap cannot reject a memory. */
|
||||
const MIN_SHARED_TERMS = 6
|
||||
|
||||
export function screenCandidates(
|
||||
candidates: Candidate[],
|
||||
existing: readonly import("./store.ts").MemoryFile[],
|
||||
options: { coverage?: number } = {},
|
||||
): ExtractionOutcome {
|
||||
const threshold = options.coverage ?? 0.55
|
||||
const threshold = options.coverage ?? 0.5
|
||||
const accepted: Candidate[] = []
|
||||
const rejected: { candidate: Candidate; reason: string }[] = []
|
||||
|
||||
@@ -262,12 +287,14 @@ export function screenCandidates(
|
||||
candidate.kind = "rule-proposal"
|
||||
}
|
||||
|
||||
const probe = `${candidate.title}\n${candidate.summary}\n${candidate.body}`
|
||||
const best = bestCoverage(probe, existing)
|
||||
if (best.file && best.coverage >= threshold) {
|
||||
const best = bestCoverage(candidate, existing)
|
||||
// Both conditions: most of the candidate's declared subjects are already
|
||||
// covered by an existing memory, AND enough terms matched in total that the
|
||||
// overlap is not a coincidence.
|
||||
if (best.file && best.coverage >= threshold && best.shared >= MIN_SHARED_TERMS) {
|
||||
rejected.push({
|
||||
candidate,
|
||||
reason: `与已有记忆重复 ${(best.coverage * 100).toFixed(0)}% (${best.file.rel})`,
|
||||
reason: `与已有记忆主题重复 ${(best.coverage * 100).toFixed(0)}%(${best.shared} 项重合,${best.file.rel})`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -278,27 +305,83 @@ export function screenCandidates(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fraction of the candidate's distinctive terms that an existing memory also
|
||||
* contains. The candidate's own terms are the query; the best match wins.
|
||||
* How much of a candidate is already said elsewhere, 0..1. Best match wins.
|
||||
*
|
||||
* Measured against the real library, plain bigram overlap does NOT work, and no
|
||||
* threshold fixes it. A genuine restatement scored only 0.43 because the model
|
||||
* writes in different words than a human does: existing "走两阶段、禁止 drop
|
||||
* 列" versus extracted "遵循 expand/contract:先以向后兼容方式扩展结构…收缩
|
||||
* 变更". Nearly every non-topic bigram differs; the topic terms are identical.
|
||||
*
|
||||
* So overlap is measured over KEY TERMS only — latin words and the terms the
|
||||
* extractor itself declared in `loadWhen`. Those are the vocabulary both sides
|
||||
* are forced to share, which is why they survive paraphrasing. Bigrams are
|
||||
* still used to find an existing memory's key terms, just not as the score.
|
||||
*/
|
||||
function bestCoverage(
|
||||
probe: string,
|
||||
existing: readonly import("./store.ts").MemoryFile[],
|
||||
): { file?: import("./store.ts").MemoryFile; coverage: number } {
|
||||
const probeTerms = new Set(tokenize(probe).filter((t) => t.length >= 2))
|
||||
if (probeTerms.size === 0) return { coverage: 0 }
|
||||
function keyTerms(text: string): Set<string> {
|
||||
return new Set(tokenize(text).filter((t) => t.length >= 2 && !/[㐀-鿿]/.test(t[0])))
|
||||
}
|
||||
|
||||
function bestCoverage(
|
||||
candidate: Candidate,
|
||||
existing: readonly import("./store.ts").MemoryFile[],
|
||||
): { file?: import("./store.ts").MemoryFile; coverage: number; shared: number } {
|
||||
// The topic vocabulary: what the extractor said this memory is about, plus
|
||||
// the technical terms in its text.
|
||||
const subject = new Set<string>([
|
||||
...(candidate.loadWhen ?? []).map((s) => s.trim().toLowerCase()),
|
||||
...candidate.title.split(/[\s、,,。::()()]+/).filter((s) => /[A-Za-z0-9]/.test(s)),
|
||||
])
|
||||
// Every term in the candidate, used only to look a document up.
|
||||
const probeTerms = new Set(tokenize(`${candidate.title}\n${candidate.summary}\n${candidate.body}`))
|
||||
|
||||
if (subject.size === 0) return { coverage: 0, shared: 0 }
|
||||
|
||||
let best: { file?: import("./store.ts").MemoryFile; coverage: number; shared: number } = {
|
||||
coverage: 0,
|
||||
shared: 0,
|
||||
}
|
||||
|
||||
let best: { file?: import("./store.ts").MemoryFile; coverage: number } = { coverage: 0 }
|
||||
for (const file of existing) {
|
||||
if (file.fm.superseded_by) continue
|
||||
// Same slug in the same root is the same memory, whatever the wording.
|
||||
const doc = new Set(
|
||||
tokenize([file.title, file.summary, file.highlight, file.body].join("\n")).filter((t) => t.length >= 2),
|
||||
)
|
||||
|
||||
// A document's subject vocabulary: its own declared triggers plus its
|
||||
// technical terms. Human-written and model-written memories land here on
|
||||
// the same footing, which is the whole point.
|
||||
const docSubject = new Set<string>([
|
||||
...file.loadWhen.map((s) => s.trim().toLowerCase()),
|
||||
...keyTerms(`${file.title}\n${file.summary}\n${file.highlight}\n${file.body}`),
|
||||
])
|
||||
|
||||
// Overlap on the candidate's declared subjects first: two memories about
|
||||
// "数据库迁移" and "删除列" are the same subject even if worded apart.
|
||||
let shared = 0
|
||||
for (const term of probeTerms) if (doc.has(term)) shared++
|
||||
const coverage = shared / probeTerms.size
|
||||
if (coverage > best.coverage) best = { file, coverage }
|
||||
for (const term of subject) {
|
||||
if (!term) continue
|
||||
if (docSubject.has(term)) {
|
||||
shared++
|
||||
continue
|
||||
}
|
||||
// Fall back to a partial hit, so 迁移 matches 数据库迁移.
|
||||
for (const docTerm of docSubject) {
|
||||
if (docTerm.length >= 2 && term.length >= 2 && (term.includes(docTerm) || docTerm.includes(term))) {
|
||||
shared++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second signal: technical terms the two share (expand/contract, drop,
|
||||
// rollback). Requires any lexical overlap so unrelated topics never merge.
|
||||
const docAll = new Set(tokenize(`${file.title}\n${file.summary}\n${file.body}`))
|
||||
let lexical = 0
|
||||
for (const term of probeTerms) if (docAll.has(term)) lexical++
|
||||
|
||||
const coverage = shared / subject.size
|
||||
const total = shared + lexical
|
||||
if (total > best.shared || (total === best.shared && coverage > best.coverage)) {
|
||||
best = { file, coverage, shared: total }
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -306,6 +389,8 @@ function bestCoverage(
|
||||
export interface RunExtractionInput {
|
||||
ctx: Context
|
||||
model: { providerID: string; id: string }
|
||||
/** Tried in order after `model` fails. */
|
||||
fallbackModels?: readonly { providerID: string; id: string }[]
|
||||
transcript: string
|
||||
existing: readonly import("./store.ts").MemoryFile[]
|
||||
signal?: AbortSignal
|
||||
@@ -320,43 +405,62 @@ export interface RunExtractionInput {
|
||||
*/
|
||||
export async function extract(input: RunExtractionInput): Promise<ExtractionOutcome> {
|
||||
const prompt = `${SYSTEM}\n\n---\n\n${input.transcript}`
|
||||
let result: unknown
|
||||
try {
|
||||
// `generate.text` has no cancellation documented on this surface, so the
|
||||
// signal is forwarded when accepted and simply ignored when not.
|
||||
result = await (input.ctx.generate as { text: (input: unknown) => Promise<unknown> }).text({
|
||||
model: input.model,
|
||||
prompt,
|
||||
...(input.signal ? { signal: input.signal } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
// An extraction failure must never disturb the user's session, but it must
|
||||
// not be indistinguishable from success either.
|
||||
|
||||
// Walk the candidates on failure. The model registry is not authoritative:
|
||||
// it lists `opencode/exo-free` as active while the service refuses the call
|
||||
// with "has been deprecated". No amount of reading metadata catches that, so
|
||||
// the only reliable signal is trying the next one.
|
||||
const primary = input.model
|
||||
const alternates = (input.fallbackModels ?? []).filter(
|
||||
(m) => m.providerID !== primary.providerID || m.id !== primary.id,
|
||||
)
|
||||
const attempts = [primary, ...alternates]
|
||||
|
||||
const failures: string[] = []
|
||||
|
||||
for (let i = 0; i < attempts.length; i++) {
|
||||
const model = attempts[i]
|
||||
let result: unknown
|
||||
try {
|
||||
// `generate.text` has no cancellation documented on this surface, so the
|
||||
// signal is forwarded when accepted and simply ignored when not.
|
||||
result = await (input.ctx.generate as { text: (i: unknown) => Promise<unknown> }).text({
|
||||
model,
|
||||
prompt,
|
||||
...(input.signal ? { signal: input.signal } : {}),
|
||||
})
|
||||
} catch (error) {
|
||||
failures.push(`${model.providerID}/${model.id}: ${(error as Error).message.slice(0, 90)}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const text = coerceText(result)
|
||||
// An empty string means the call "succeeded" but returned nothing usable.
|
||||
// An auth or provider error can arrive as a JSON error body rather than a
|
||||
// thrown error, so surface what came back instead of reporting it as
|
||||
// "nothing worth remembering" — and try another model before giving up.
|
||||
if (!text.trim()) {
|
||||
const preview =
|
||||
result && typeof result === "object"
|
||||
? JSON.stringify(result).slice(0, 120)
|
||||
: String(result ?? "").slice(0, 120)
|
||||
failures.push(`${model.providerID}/${model.id}: 空内容(${preview || "空"})`)
|
||||
continue
|
||||
}
|
||||
|
||||
const candidates = parseCandidates(text)
|
||||
const screened = screenCandidates(candidates, input.existing)
|
||||
return {
|
||||
candidates: [],
|
||||
rejected: [],
|
||||
error: `模型调用失败:${(error as Error).message.slice(0, 160)}`,
|
||||
...screened,
|
||||
rawText: text.slice(0, 400),
|
||||
modelUsed: `${model.providerID}/${model.id}`,
|
||||
...(failures.length ? { notes: `已跳过不可用模型:${failures.join("; ")}` } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const text = coerceText(result)
|
||||
// An empty string here means the call "succeeded" but returned nothing usable.
|
||||
// An auth or provider error can arrive as a JSON error body rather than a
|
||||
// thrown error, so surface what actually came back instead of reporting it
|
||||
// as "nothing worth remembering".
|
||||
if (!text.trim()) {
|
||||
const preview =
|
||||
result && typeof result === "object"
|
||||
? JSON.stringify(result).slice(0, 160)
|
||||
: String(result ?? "").slice(0, 160)
|
||||
return {
|
||||
candidates: [],
|
||||
rejected: [],
|
||||
error: `模型返回空内容(响应:${preview || "空"})`,
|
||||
}
|
||||
return {
|
||||
candidates: [],
|
||||
rejected: [],
|
||||
error: `所有候选模型都调用失败:${failures.join("; ") || "无可用模型"}`,
|
||||
}
|
||||
|
||||
const candidates = parseCandidates(text)
|
||||
const screened = screenCandidates(candidates, input.existing)
|
||||
return { ...screened, rawText: text.slice(0, 400) }
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function parseFrontmatter(raw: string): { data: Frontmatter; body: string
|
||||
if (item && currentKey) {
|
||||
const prev = data[currentKey]
|
||||
const list = Array.isArray(prev) ? prev : []
|
||||
list.push(scalar(item[1]))
|
||||
list.push(String(scalar(item[1])))
|
||||
data[currentKey] = list
|
||||
continue
|
||||
}
|
||||
|
||||
+468
-105
@@ -24,7 +24,11 @@
|
||||
// Prerequisite: npm i @opencode/plugin@$(opencode --version | sed 's/^v//')
|
||||
// then `opencode service restart`.
|
||||
|
||||
import { Plugin, type Context } from "@opencode/plugin"
|
||||
import { Plugin } from "@opencode/plugin"
|
||||
// `Context` lives on the Plugin namespace, not as a top-level export. Importing
|
||||
// it from "@opencode/plugin" directly fails to typecheck, which silently
|
||||
// degraded every tool's `input` to `unknown`.
|
||||
type Context = Plugin.Context
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
@@ -49,9 +53,16 @@ import {
|
||||
} from "./store.ts"
|
||||
|
||||
interface PluginConfig {
|
||||
/** Hard cap on injected characters per model request. Default 1200. */
|
||||
/** Hard cap on injected characters per turn. Default 600. */
|
||||
maxInjectChars?: number
|
||||
/** How many memory bodies to inline per request. Default 4. */
|
||||
/**
|
||||
* How many memory bodies to inline per turn. Default 0: only hard rules and
|
||||
* the outline ride along, and full text is fetched with `memory_recall`.
|
||||
*
|
||||
* Inlining bodies was costing more than it saved. The outline already says
|
||||
* what exists and what each entry is about, so the model can fetch exactly
|
||||
* what it needs — once — instead of paying for every body on every request.
|
||||
*/
|
||||
maxInjectEntries?: number
|
||||
/** Half-life in days for the recency decay. Default 14; 0 disables. */
|
||||
halfLifeDays?: number
|
||||
@@ -65,7 +76,9 @@ interface PluginConfig {
|
||||
}
|
||||
|
||||
const CATEGORIES = ["rules", "preferences", "decisions", "pitfalls", "facts"]
|
||||
const DEFAULT_MAX_INJECT = 1200
|
||||
// 600 characters ≈ 350 tokens per turn. The block is re-sent on every model
|
||||
// request in the turn, so its size is multiplied by the number of tool calls.
|
||||
const DEFAULT_MAX_INJECT = 600
|
||||
|
||||
/**
|
||||
* Events that mark turn activity. Collected from what this stream actually
|
||||
@@ -84,7 +97,7 @@ export default Plugin.define({
|
||||
async setup(ctx) {
|
||||
const config = ctx.options as PluginConfig
|
||||
const maxChars = config.maxInjectChars ?? DEFAULT_MAX_INJECT
|
||||
const maxEntries = config.maxInjectEntries ?? 4
|
||||
const maxEntries = config.maxInjectEntries ?? 0
|
||||
const halfLifeDays = config.halfLifeDays ?? 14
|
||||
const categories = config.categories ?? CATEGORIES
|
||||
const extractOptions: ExtractOptions = {
|
||||
@@ -123,10 +136,13 @@ export default Plugin.define({
|
||||
}
|
||||
|
||||
// -- in-memory view, refreshed when files change on disk ----------------
|
||||
// Cache is short (500ms) so a write followed immediately by a read sees
|
||||
// the new file. The previous 2s window meant `memory_remember` →
|
||||
// `memory_recall` in the same tool chain could miss the write.
|
||||
let cache: { filesByRoot: Map<string, MemoryFile[]>; at: number } | undefined
|
||||
const filesByRoot = (): Map<string, MemoryFile[]> => {
|
||||
const now = Date.now()
|
||||
if (cache && now - cache.at < 2000) return cache.filesByRoot
|
||||
if (cache && now - cache.at < 500) return cache.filesByRoot
|
||||
const map = new Map<string, MemoryFile[]>()
|
||||
for (const root of roots()) map.set(root.key, scanRoot(root))
|
||||
cache = { filesByRoot: map, at: now }
|
||||
@@ -149,12 +165,53 @@ export default Plugin.define({
|
||||
}
|
||||
|
||||
// -- injection into the agent loop --------------------------------------
|
||||
// `context` covers the agent loop including tool continuations, and edits
|
||||
// only the outgoing request — the conversation transcript stays clean.
|
||||
// `context` fires on EVERY model request, including tool continuations, so
|
||||
// one turn with n tool calls used to pay for n memory blocks. The agent
|
||||
// loop is unchanged by later continuations, so injecting again is pure
|
||||
// waste. Keyed by session + the latest user message: a new user message
|
||||
// re-injects, everything in between is skipped.
|
||||
// Per-session so two sessions with the same query do not share debounce state.
|
||||
const injectedFor = new Map<string, Set<string>>()
|
||||
|
||||
await ctx.session.hook("context", async (event) => {
|
||||
try {
|
||||
const query = latestUserText(event.messages)
|
||||
if (!query) return
|
||||
|
||||
let sessionKeys = injectedFor.get(event.sessionID)
|
||||
if (!sessionKeys) {
|
||||
sessionKeys = new Set<string>()
|
||||
injectedFor.set(event.sessionID, sessionKeys)
|
||||
}
|
||||
const key = fingerprint(query)
|
||||
if (sessionKeys.has(key)) {
|
||||
// Count skips separately so the debounce can be proven to work.
|
||||
// Without this, "timestamp unchanged" is ambiguous: it could mean
|
||||
// the debounce fired, or that the diagnostic layer's own 2s debounce
|
||||
// hid a second injection.
|
||||
const statsKey = `diag.${project ? "project" : "global"}.injectionStats`
|
||||
const prev = ((await ctx.storage.get(statsKey)) as Record<string, number> | undefined) ?? {
|
||||
fires: 0,
|
||||
injected: 0,
|
||||
skipped: 0,
|
||||
chars: 0,
|
||||
}
|
||||
await ctx.storage.set(statsKey, {
|
||||
...prev,
|
||||
fires: prev.fires + 1,
|
||||
skipped: prev.skipped + 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
sessionKeys.add(key)
|
||||
// Bound the set: sessions are long-lived and this grows with each turn.
|
||||
if (sessionKeys.size > 64) {
|
||||
for (const oldest of sessionKeys) {
|
||||
sessionKeys.delete(oldest)
|
||||
if (sessionKeys.size <= 48) break
|
||||
}
|
||||
}
|
||||
|
||||
const text = buildInjection({
|
||||
roots: roots(),
|
||||
filesByRoot: filesByRoot(),
|
||||
@@ -163,16 +220,40 @@ export default Plugin.define({
|
||||
options: { maxChars, maxEntries, halfLifeDays },
|
||||
})
|
||||
if (text) event.system.push({ type: "text", text })
|
||||
// Count real injections too. fires vs injected is the only number that
|
||||
// proves the debounce works: if they are equal, every model request
|
||||
// is paying for a memory block.
|
||||
const diagScope = project ? "project" : "global"
|
||||
const statsKey = `diag.${diagScope}.injectionStats`
|
||||
const prevStats = ((await ctx.storage.get(statsKey)) as Record<string, number> | undefined) ?? {
|
||||
fires: 0,
|
||||
injected: 0,
|
||||
skipped: 0,
|
||||
chars: 0,
|
||||
}
|
||||
await ctx.storage.set(statsKey, {
|
||||
...prevStats,
|
||||
fires: prevStats.fires + 1,
|
||||
injected: prevStats.injected + 1,
|
||||
chars: prevStats.chars + text.length,
|
||||
})
|
||||
// Record what the model actually received. Without this there is no way
|
||||
// to tell "injected nothing because there is nothing" apart from
|
||||
// "the hook is not firing at all".
|
||||
await noteDiag(ctx, "lastInjection", {
|
||||
at: Date.now(),
|
||||
chars: text.length,
|
||||
withinBudget: text.length <= maxChars,
|
||||
bodies: (text.match(/^### /gm) ?? []).length,
|
||||
query: query.slice(0, 80),
|
||||
})
|
||||
await noteDiag(
|
||||
ctx,
|
||||
"lastInjection",
|
||||
{
|
||||
at: Date.now(),
|
||||
scope: project ? "project" : "global",
|
||||
roots: roots().map((r) => r.label),
|
||||
chars: text.length,
|
||||
withinBudget: text.length <= maxChars,
|
||||
bodies: (text.match(/^### /gm) ?? []).length,
|
||||
query: query.slice(0, 80),
|
||||
},
|
||||
project ? "project" : "global",
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`memory: injection failed: ${(error as Error).message}`)
|
||||
}
|
||||
@@ -215,7 +296,8 @@ export default Plugin.define({
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (input: { query: string; scope?: string; limit?: number }) => {
|
||||
execute: async (raw: unknown) => {
|
||||
const input = raw as { query: string; scope?: string; limit?: number }
|
||||
const pool = pickRoots(roots(), input.scope)
|
||||
const scored = pool
|
||||
.flatMap((root) =>
|
||||
@@ -330,18 +412,19 @@ export default Plugin.define({
|
||||
required: ["category", "slug", "title", "summary", "body"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (input: {
|
||||
category: string
|
||||
slug: string
|
||||
title: string
|
||||
summary: string
|
||||
highlight?: string
|
||||
body: string
|
||||
scope?: string
|
||||
loadWhen?: string[]
|
||||
pinned?: boolean
|
||||
supersedes?: string
|
||||
}) => {
|
||||
execute: async (raw: unknown) => {
|
||||
const input = raw as {
|
||||
category: string
|
||||
slug: string
|
||||
title: string
|
||||
summary: string
|
||||
highlight?: string
|
||||
body: string
|
||||
scope?: string
|
||||
loadWhen?: string[]
|
||||
pinned?: boolean
|
||||
supersedes?: string
|
||||
}
|
||||
const categoryError = validateCategory(input.category)
|
||||
if (categoryError) return { content: `拒绝写入:${categoryError}` }
|
||||
const slugError = validateSlug(input.slug)
|
||||
@@ -419,7 +502,8 @@ export default Plugin.define({
|
||||
required: ["path", "reason"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (input: { path: string; scope?: string; reason: string }) => {
|
||||
execute: async (raw: unknown) => {
|
||||
const input = raw as { path: string; scope?: string; reason: string }
|
||||
const target = input.path.replace(/^\/+/, "")
|
||||
if (!target.endsWith(".md") || target.includes("..")) {
|
||||
return { content: `拒绝:path 必须是根下的相对 .md 路径(收到 ${JSON.stringify(input.path)})` }
|
||||
@@ -436,11 +520,81 @@ export default Plugin.define({
|
||||
},
|
||||
})
|
||||
|
||||
// memory_extract_dryrun ----------------------------------------------
|
||||
// Exists so the extraction pipeline can be exercised against the REAL
|
||||
// model without lowering `minTurns` or waiting for a long session. The
|
||||
// previous attempts to verify it both failed — once by editing the
|
||||
// shipping default, which proved nothing about the real configuration.
|
||||
editor.add({
|
||||
name: "extract_dryrun",
|
||||
description: [
|
||||
"Run the extraction pipeline over a transcript you supply and show what it WOULD write, without writing anything.",
|
||||
"Use to check whether extraction is working: it makes a real model call and reports the model's raw answer, the parsed candidates, and which ones were filtered as duplicates or rejected for secrets.",
|
||||
].join(" "),
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
transcript: {
|
||||
type: "string",
|
||||
description: "The conversation to extract from, as plain text.",
|
||||
},
|
||||
},
|
||||
required: ["transcript"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (raw: unknown) => {
|
||||
const input = raw as { transcript: string }
|
||||
const model = extractOptions.model ?? (await pickCheapModel(ctx))
|
||||
if (!model) {
|
||||
return { content: "无法选取模型:autoExtract 实际上不可用。" }
|
||||
}
|
||||
const existing = [...filesByRoot().values()].flat()
|
||||
const fallbackModels = await extractionCandidates(ctx)
|
||||
const outcome = await extract({
|
||||
ctx,
|
||||
model,
|
||||
transcript: input.transcript,
|
||||
existing,
|
||||
fallbackModels,
|
||||
})
|
||||
|
||||
const lines: string[] = [`首选模型:${model.providerID}/${model.id}`]
|
||||
if (outcome.modelUsed) lines.push(`实际应答:${outcome.modelUsed}`)
|
||||
lines.push(
|
||||
`候选序列(失败则顺延):${
|
||||
fallbackModels.map((m) => `${m.providerID}/${m.id}`).join(", ") || "无"
|
||||
}`,
|
||||
)
|
||||
lines.push("")
|
||||
if (outcome.error) lines.push(`⚠ 调用失败:${outcome.error}`)
|
||||
if (outcome.notes) lines.push(`备注:${outcome.notes}`)
|
||||
if (outcome.rawText) lines.push(`模型原始返回(前 600 字):\n${outcome.rawText.slice(0, 600)}`)
|
||||
lines.push("")
|
||||
lines.push(`通过筛选:${outcome.candidates.length} 条`)
|
||||
for (const candidate of outcome.candidates) {
|
||||
lines.push(` + ${candidate.category}/${candidate.slug} — ${candidate.title}`)
|
||||
lines.push(` 概述:${candidate.summary}`)
|
||||
if (candidate.loadWhen?.length) lines.push(` 触发:${candidate.loadWhen.join("、")}`)
|
||||
}
|
||||
lines.push(`被过滤:${outcome.rejected.length} 条`)
|
||||
for (const rejected of outcome.rejected) {
|
||||
lines.push(` - ${rejected.candidate.slug} — ${rejected.reason}`)
|
||||
}
|
||||
if (outcome.candidates.length === 0 && !outcome.error && outcome.rejected.length === 0) {
|
||||
lines.push("\n(模型没有返回任何候选)")
|
||||
} else if (outcome.candidates.length === 0 && !outcome.error) {
|
||||
lines.push("\n(候选全部被判为重复或含密钥,因此未写入)")
|
||||
}
|
||||
lines.push("\n注意:dry-run 不会写入任何文件。")
|
||||
return { content: lines.join("\n") }
|
||||
},
|
||||
})
|
||||
|
||||
// memory_inspect ------------------------------------------------------
|
||||
editor.add({
|
||||
name: "inspect",
|
||||
description:
|
||||
"Inspect the memory library: every root, every memory with its summary and trigger, plus index consistency checks. Use when tuning memory or explaining what is remembered.",
|
||||
"Inspect the memory library: every root, every memory with its summary and trigger, runtime health of injection and extraction, plus index consistency checks. Use when tuning memory or explaining what is remembered.",
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -448,10 +602,15 @@ export default Plugin.define({
|
||||
type: "boolean",
|
||||
description: "Include per-memory hit counts and scores. Default false.",
|
||||
},
|
||||
history: {
|
||||
type: "boolean",
|
||||
description: "Include the full extraction run history. Default true.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
execute: async (input: { detail?: boolean }) => {
|
||||
execute: async (raw: unknown) => {
|
||||
const input = raw as { detail?: boolean; history?: boolean }
|
||||
const lines: string[] = []
|
||||
for (const root of roots()) {
|
||||
const files = filesByRoot().get(root.key) ?? []
|
||||
@@ -513,18 +672,43 @@ export default Plugin.define({
|
||||
|
||||
// Runtime health of the two paths that have no other visible effect.
|
||||
lines.push("")
|
||||
const injection = (await ctx.storage.get("diag.lastInjection")) as Record<string, unknown> | undefined
|
||||
const extraction = (await ctx.storage.get("diag.lastExtraction")) as Record<string, unknown> | undefined
|
||||
lines.push("运行状态:")
|
||||
// Read the scope that matches this location, never the other one. The keys
|
||||
// are scoped, so a report can only ever describe this project.
|
||||
const diagScope = project ? "project" : "global"
|
||||
const injection = (await ctx.storage.get(`diag.${diagScope}.lastInjection`)) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
const extraction = (await ctx.storage.get(`diag.${diagScope}.lastExtraction`)) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
const extractionHistory = (await ctx.storage.get(`diag.${diagScope}.lastExtraction.history`)) as
|
||||
| Record<string, unknown>[]
|
||||
| undefined
|
||||
lines.push(`运行状态(${diagScope === "project" ? "项目" : "全局"}作用域):`)
|
||||
if (injection) {
|
||||
const when = new Date(Number(injection.at)).toISOString().slice(0, 19).replace("T", " ")
|
||||
lines.push(
|
||||
` 注入:最近一次 ${when},${String(injection.chars)} 字符,内联 ${String(injection.bodies)} 条正文${injection.withinBudget === false ? " ⚠ 超出预算" : ""}`,
|
||||
` 注入:${when},${String(injection.chars)} 字符,内联 ${String(injection.bodies)} 条正文${injection.withinBudget === false ? " ⚠ 超出预算" : ""}`,
|
||||
)
|
||||
lines.push(` 检索词:${String(injection.query)}`)
|
||||
} else {
|
||||
lines.push(" 注入:尚无记录(context hook 还没触发过)")
|
||||
}
|
||||
// The debounce counter. fires vs injected is the only number that
|
||||
// proves the debounce works — a single "last injection" timestamp
|
||||
// cannot distinguish "injected once" from "injected twice, second
|
||||
// write suppressed by the diagnostic layer's own debounce".
|
||||
const stats = (await ctx.storage.get(`diag.${diagScope}.injectionStats`)) as
|
||||
| Record<string, number>
|
||||
| undefined
|
||||
if (stats) {
|
||||
const ratio = stats.fires > 0 ? (stats.injected / stats.fires).toFixed(2) : "—"
|
||||
lines.push(
|
||||
` 注入统计:触发 ${stats.fires} 次,实际注入 ${stats.injected} 次,跳过 ${stats.skipped} 次,累计 ${stats.chars} 字符(注入/触发比 ${ratio})`,
|
||||
)
|
||||
} else {
|
||||
lines.push(" 注入统计:尚无记录")
|
||||
}
|
||||
if (extraction) {
|
||||
const when = new Date(Number(extraction.at)).toISOString().slice(0, 19).replace("T", " ")
|
||||
lines.push(` 抽取:${when} → ${String(extraction.outcome)}`)
|
||||
@@ -543,6 +727,29 @@ export default Plugin.define({
|
||||
if (extraction.rawText) lines.push(` 原始返回:${String(extraction.rawText).slice(0, 160)}`)
|
||||
}
|
||||
|
||||
// The full run history, so "the last attempt was skipped" cannot hide
|
||||
// an earlier success. Reading only the newest record is what led to
|
||||
// reporting a working extractor as broken.
|
||||
if (input.history !== false && extractionHistory && extractionHistory.length > 0) {
|
||||
const tally = extractionHistory.reduce<Record<string, number>>((acc, entry) => {
|
||||
const key = String(entry.outcome ?? "?")
|
||||
acc[key] = (acc[key] ?? 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
const summary = Object.entries(tally)
|
||||
.map(([k, v]) => `${k}×${v}`)
|
||||
.join(", ")
|
||||
lines.push(
|
||||
` 抽取历史(共 ${extractionHistory.length} 次,最近 5 次):${summary}`,
|
||||
)
|
||||
for (const entry of extractionHistory.slice(-5).reverse()) {
|
||||
const when = new Date(Number(entry.at)).toISOString().slice(5, 19).replace("T", " ")
|
||||
const detail = entry.reason ? ` — ${String(entry.reason).slice(0, 70)}` : ""
|
||||
const paths = Array.isArray(entry.paths) && entry.paths.length ? ` → ${entry.paths.join(", ")}` : ""
|
||||
lines.push(` ${when} ${String(entry.outcome)}${detail}${paths}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { content: lines.join("\n") }
|
||||
},
|
||||
})
|
||||
@@ -630,16 +837,40 @@ function pickRoots(roots: MemoryRoot[], scope: string | undefined): MemoryRoot[]
|
||||
* other observable effect. Without this, "working" and "silently dead" look
|
||||
* identical from the outside. Throttled because the context hook runs on every
|
||||
* model request, including tool continuations.
|
||||
*
|
||||
* The scope is part of the storage key on purpose. `ctx.storage` is one global
|
||||
* namespace for the whole plugin instance, so a project-scoped record stored
|
||||
* under a bare key would be read back by whichever project runs next and
|
||||
* reported as if it were this project's result.
|
||||
*/
|
||||
const lastDiagAt = new Map<string, number>()
|
||||
|
||||
async function noteDiag(ctx: Context, key: string, value: Record<string, unknown>): Promise<void> {
|
||||
/** How many past events of each kind `memory_inspect` keeps. */
|
||||
const DIAG_HISTORY = 10
|
||||
|
||||
async function noteDiag(
|
||||
ctx: Context,
|
||||
key: string,
|
||||
value: Record<string, unknown>,
|
||||
scope = "global",
|
||||
): Promise<void> {
|
||||
const now = Date.now()
|
||||
const previous = lastDiagAt.get(key) ?? 0
|
||||
const storageKey = `diag.${scope}.${key}`
|
||||
const previous = lastDiagAt.get(storageKey) ?? 0
|
||||
if (now - previous < 2000) return
|
||||
lastDiagAt.set(key, now)
|
||||
lastDiagAt.set(storageKey, now)
|
||||
try {
|
||||
await ctx.storage.set(`diag.${key}`, value)
|
||||
// `storage.set` is typed as JSON; the diagnostics payload is plain data,
|
||||
// so narrow at the boundary rather than loosening the call.
|
||||
await ctx.storage.set(storageKey, value as Record<string, never>)
|
||||
// Keep a short history too. A single record only ever describes the LAST
|
||||
// attempt, so one later skip can hide an earlier success — which is exactly
|
||||
// how a working extractor gets reported as broken.
|
||||
const historyKey = `diag.${scope}.${key}.history`
|
||||
const existing = (await ctx.storage.get(historyKey)) as unknown
|
||||
const history = Array.isArray(existing) ? existing.slice(-(DIAG_HISTORY - 1)) : []
|
||||
history.push(value)
|
||||
await ctx.storage.set(historyKey, history)
|
||||
} catch {
|
||||
// Diagnostics must never break the hook that reports them.
|
||||
}
|
||||
@@ -657,8 +888,46 @@ function outlineText(filesByRoot: Map<string, MemoryFile[]>, roots: MemoryRoot[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest real user text in a request — the retrieval query.
|
||||
* A stable short key for a user message.
|
||||
*
|
||||
* Two different turns rarely produce identical text, so this distinguishes
|
||||
* "new turn" from "continuation of the current one" without holding whole
|
||||
* messages in memory.
|
||||
*/
|
||||
function fingerprint(text: string): string {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash = ((hash << 5) + hash + text.charCodeAt(i)) >>> 0
|
||||
}
|
||||
return `${text.length}:${hash.toString(36)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The role of a session message.
|
||||
*
|
||||
* `session.context()` returns `Session.Message.Info` entries, a tagged union
|
||||
* rather than a flat `{ role }`. Reading `.role` directly found nothing, so
|
||||
* every session counted zero turns and extraction never passed its own gate.
|
||||
* The role is accepted at the top level or nested one level down, with `type`
|
||||
* as a fallback since the union is tagged by it.
|
||||
*/
|
||||
function messageRole(message: unknown): string | undefined {
|
||||
if (!message || typeof message !== "object") return undefined
|
||||
const record = message as Record<string, unknown>
|
||||
if (typeof record.role === "string") return record.role
|
||||
const nested = record.message
|
||||
if (nested && typeof nested === "object") {
|
||||
const inner = nested as Record<string, unknown>
|
||||
if (typeof inner.role === "string") return inner.role
|
||||
}
|
||||
if (typeof record.type === "string" && /^(user|assistant|system|synthetic|shell)$/i.test(record.type)) {
|
||||
return record.type
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest real user text in a request — the retrieval query.
|
||||
* Harness-injected user messages ("<system-reminder>", "The server restarted…")
|
||||
* are technically user turns but are not about the task. Retrieving on them
|
||||
* injects memories chosen by noise, so they are skipped.
|
||||
@@ -709,18 +978,30 @@ async function runExtraction(
|
||||
|
||||
const messages = (await ctx.session.context({ sessionID })) as readonly unknown[]
|
||||
const turns = messages.filter((m) => {
|
||||
const role = (m as { role?: string }).role
|
||||
const role = messageRole(m)
|
||||
return role === "user" || role === "assistant"
|
||||
})
|
||||
// Long enough to have learned something, short enough to stay cheap.
|
||||
// Diagnostics for extraction are stored under the scope the session's content
|
||||
// actually belongs to, so a run inside a project never reports into — or
|
||||
// reads from — the global namespace.
|
||||
const diagScope = input.project ? "project" : "global"
|
||||
|
||||
if (turns.length < extractOptions.minTurns) {
|
||||
await noteDiag(ctx, "lastExtraction", {
|
||||
at: Date.now(),
|
||||
outcome: "skipped",
|
||||
reason: "会话轮数不足",
|
||||
turns: turns.length,
|
||||
minTurns: extractOptions.minTurns,
|
||||
})
|
||||
await noteDiag(
|
||||
ctx,
|
||||
"lastExtraction",
|
||||
{
|
||||
at: Date.now(),
|
||||
scope: diagScope,
|
||||
root: diagScope === "project" ? input.project?.label : input.global.label,
|
||||
outcome: "skipped",
|
||||
reason: "会话轮数不足",
|
||||
turns: turns.length,
|
||||
minTurns: extractOptions.minTurns,
|
||||
},
|
||||
diagScope,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -728,29 +1009,42 @@ async function runExtraction(
|
||||
if (!model) {
|
||||
// pickCheapModel failing is the difference between a working feature and
|
||||
// a silent no-op, so it is reported rather than swallowed.
|
||||
await noteDiag(ctx, "lastExtraction", {
|
||||
at: Date.now(),
|
||||
outcome: "failed",
|
||||
reason: "选不到可用模型(autoExtract 实际上处于关闭状态)",
|
||||
turns: turns.length,
|
||||
})
|
||||
await noteDiag(
|
||||
ctx,
|
||||
"lastExtraction",
|
||||
{
|
||||
at: Date.now(),
|
||||
scope: diagScope,
|
||||
outcome: "failed",
|
||||
reason: "选不到可用模型(autoExtract 实际处于关闭状态)",
|
||||
turns: turns.length,
|
||||
},
|
||||
diagScope,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const transcript = userAndAssistantText(turns.slice(-extractOptions.maxMessages))
|
||||
if (transcript.length < 200) {
|
||||
await noteDiag(ctx, "lastExtraction", {
|
||||
at: Date.now(),
|
||||
outcome: "skipped",
|
||||
reason: "对话内容太短",
|
||||
turns: turns.length,
|
||||
transcriptChars: transcript.length,
|
||||
})
|
||||
await noteDiag(
|
||||
ctx,
|
||||
"lastExtraction",
|
||||
{
|
||||
at: Date.now(),
|
||||
scope: diagScope,
|
||||
outcome: "skipped",
|
||||
reason: "对话内容太短",
|
||||
turns: turns.length,
|
||||
transcriptChars: transcript.length,
|
||||
},
|
||||
diagScope,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const existing = [...input.filesByRoot().values()].flat()
|
||||
const outcome = await extract({ ctx, model, transcript, existing })
|
||||
const candidates = await extractionCandidates(ctx)
|
||||
const outcome = await extract({ ctx, model, transcript, existing, fallbackModels: candidates })
|
||||
|
||||
if (outcome.candidates.length === 0) {
|
||||
for (const rejected of outcome.rejected) {
|
||||
@@ -758,17 +1052,23 @@ async function runExtraction(
|
||||
`memory: skipped ${rejected.candidate.category}/${rejected.candidate.slug} — ${rejected.reason}`,
|
||||
)
|
||||
}
|
||||
await noteDiag(ctx, "lastExtraction", {
|
||||
at: Date.now(),
|
||||
outcome: outcome.error ? "failed" : "no-candidates",
|
||||
...(outcome.error ? { reason: outcome.error } : {}),
|
||||
reasonDetail: outcome.rejected.length
|
||||
? "候选全部被去重或密钥拦截"
|
||||
: "抽取器认为没有值得记的东西",
|
||||
rejected: outcome.rejected.length,
|
||||
model: `${model.providerID}/${model.id}`,
|
||||
...(outcome.rawText ? { rawText: outcome.rawText } : {}),
|
||||
})
|
||||
await noteDiag(
|
||||
ctx,
|
||||
"lastExtraction",
|
||||
{
|
||||
at: Date.now(),
|
||||
scope: diagScope,
|
||||
outcome: outcome.error ? "failed" : "no-candidates",
|
||||
...(outcome.error ? { reason: outcome.error } : {}),
|
||||
reasonDetail: outcome.rejected.length
|
||||
? "候选全部被去重或密钥拦截"
|
||||
: "抽取器认为没有值得记的东西",
|
||||
rejected: outcome.rejected.length,
|
||||
model: `${model.providerID}/${model.id}`,
|
||||
...(outcome.rawText ? { rawText: outcome.rawText } : {}),
|
||||
},
|
||||
diagScope,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -802,42 +1102,105 @@ async function runExtraction(
|
||||
console.log(`memory: extracted ${written.length} memor${written.length === 1 ? "y" : "ies"} → ${written.join(", ")}`)
|
||||
}
|
||||
|
||||
await noteDiag(ctx, "lastExtraction", {
|
||||
at: Date.now(),
|
||||
outcome: written.length ? "extracted" : "all-filtered",
|
||||
model: `${model.providerID}/${model.id}`,
|
||||
turns: turns.length,
|
||||
written: written.length,
|
||||
rejected: outcome.rejected.length,
|
||||
paths: written,
|
||||
})
|
||||
await noteDiag(
|
||||
ctx,
|
||||
"lastExtraction",
|
||||
{
|
||||
at: Date.now(),
|
||||
scope: diagScope,
|
||||
outcome: written.length ? "extracted" : "all-filtered",
|
||||
model: `${model.providerID}/${model.id}`,
|
||||
turns: turns.length,
|
||||
written: written.length,
|
||||
rejected: outcome.rejected.length,
|
||||
paths: written,
|
||||
},
|
||||
diagScope,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the cheapest available model for extraction. Falls back to the session
|
||||
* default when cost metadata is unavailable, and disables extraction entirely
|
||||
* when neither is available.
|
||||
* Pick the cheapest available model for extraction.
|
||||
*
|
||||
* Three shapes here were wrong in the first version, and each one alone made
|
||||
* extraction permanently inert:
|
||||
* - `ctx.model.list()` resolves to `{ location, data }`, not a bare array
|
||||
* - `ctx.model.default()` resolves to `{ location, data }` too
|
||||
* - the availability flag is `enabled`, not `disabled`
|
||||
* Both are read through `unwrapData`, which tolerates either shape so a host
|
||||
* change cannot turn this into a silent no-op again.
|
||||
*
|
||||
* Every model on this machine reports `cost: 0`, so "cheapest" degenerates to
|
||||
* "first available". That is acceptable; it must never mean "none".
|
||||
*/
|
||||
async function pickCheapModel(ctx: Context): Promise<{ providerID: string; id: string } | undefined> {
|
||||
try {
|
||||
const models = await ctx.model.list()
|
||||
let best: { ref: { providerID: string; id: string }; cost: number } | undefined
|
||||
for (const model of models) {
|
||||
if (model.disabled) continue
|
||||
// A reasoning-only or tiny-context model cannot do the job reliably.
|
||||
if ((model.limit?.context ?? 0) > 0 && model.limit.context < 8_000) continue
|
||||
const output = model.cost?.[0]?.output ?? 0
|
||||
const input = model.cost?.[0]?.input ?? 0
|
||||
const cost = Number(output) + Number(input)
|
||||
if (!best || cost < best.cost) {
|
||||
best = { ref: { providerID: model.providerID, id: model.id }, cost }
|
||||
}
|
||||
}
|
||||
if (best) return best.ref
|
||||
const fallback = await ctx.model.default()
|
||||
if (fallback) return { providerID: fallback.providerID, id: fallback.modelID }
|
||||
} catch {
|
||||
return undefined
|
||||
function unwrapData<T>(result: unknown): T[] | undefined {
|
||||
if (Array.isArray(result)) return result as T[]
|
||||
if (result && typeof result === "object") {
|
||||
const data = (result as { data?: unknown }).data
|
||||
if (Array.isArray(data)) return data as T[]
|
||||
if (data && typeof data === "object") return [data as T]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function pickCheapModel(
|
||||
ctx: Context,
|
||||
): Promise<{ providerID: string; id: string } | undefined> {
|
||||
const models = unwrapData<any>(await ctx.model.list().catch(() => undefined)) ?? []
|
||||
|
||||
const usable = models.filter((model) => {
|
||||
// `enabled` is the availability flag; `status` may also say disabled or
|
||||
// deprecated. The registry is not authoritative — a model listed as active
|
||||
// can still be refused at call time, which is why extraction retries below.
|
||||
if (model.enabled === false) return false
|
||||
const status = String(model.status ?? "").toLowerCase()
|
||||
if (status === "disabled" || status === "deprecated") return false
|
||||
const context = Number(model.limit?.context ?? 0)
|
||||
// A tiny context window cannot hold the transcript plus the prompt.
|
||||
return !(context > 0 && context < 8_000)
|
||||
})
|
||||
|
||||
const costOf = (model: any) =>
|
||||
Number(model.cost?.[0]?.output ?? 0) + Number(model.cost?.[0]?.input ?? 0)
|
||||
const refOf = (model: any) => ({ providerID: model.providerID, id: model.id ?? model.modelID })
|
||||
|
||||
// Every model here reports cost 0, so "cheapest" is "first available".
|
||||
// Keep the candidates ordered so retries walk them cheapest-first.
|
||||
const ranked = [...usable].sort((a, b) => costOf(a) - costOf(b))
|
||||
if (ranked[0]) return refOf(ranked[0])
|
||||
|
||||
const fallback = unwrapData<any>(await ctx.model.default().catch(() => undefined))?.[0]
|
||||
if (fallback?.providerID) return refOf(fallback)
|
||||
|
||||
// Last resort: any listed model at all. Failing loudly beats extracting
|
||||
// nothing silently.
|
||||
const any = models[0]
|
||||
if (any?.providerID) return refOf(any)
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidates extraction should try, cheapest first.
|
||||
*
|
||||
* Exposed so a failed call can walk to the next model instead of giving up:
|
||||
* the registry reports `opencode/exo-free` as active while the service refuses
|
||||
* it with "has been deprecated". Metadata cannot catch that; trying can.
|
||||
*/
|
||||
async function extractionCandidates(ctx: Context): Promise<{ providerID: string; id: string }[]> {
|
||||
const models = unwrapData<any>(await ctx.model.list().catch(() => undefined)) ?? []
|
||||
const costOf = (model: any) =>
|
||||
Number(model.cost?.[0]?.output ?? 0) + Number(model.cost?.[0]?.input ?? 0)
|
||||
const usable = models.filter((model) => {
|
||||
if (model.enabled === false) return false
|
||||
const status = String(model.status ?? "").toLowerCase()
|
||||
if (status === "disabled" || status === "deprecated") return false
|
||||
const context = Number(model.limit?.context ?? 0)
|
||||
return !(context > 0 && context < 8_000)
|
||||
})
|
||||
const ranked = [...usable].sort((a, b) => costOf(a) - costOf(b))
|
||||
const refs = ranked.map((m) => ({ providerID: m.providerID, id: m.id ?? m.modelID }))
|
||||
if (refs.length) return refs
|
||||
const first = await pickCheapModel(ctx)
|
||||
return first ? [first] : []
|
||||
}
|
||||
+51
-29
@@ -47,7 +47,7 @@ export function tokenize(text: string): string[] {
|
||||
|
||||
// CJK: unigrams plus bigrams. Bigrams carry the matching power; unigrams
|
||||
// rescue one-character queries that have no bigram at all.
|
||||
const runs = text.match(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]+/g) ?? []
|
||||
const runs = text.match(new RegExp(CJK.source, "g")) ?? []
|
||||
for (const run of runs) {
|
||||
const chars = [...run]
|
||||
for (let i = 0; i < chars.length - 1; i++) {
|
||||
@@ -84,19 +84,33 @@ export function scoreFiles(
|
||||
const halfLife = (options.halfLifeDays ?? 14) * 86_400_000
|
||||
const queryTokens = tokenize(query)
|
||||
if (queryTokens.length === 0) return []
|
||||
if (files.length === 0) return []
|
||||
|
||||
const querySet = new Map<string, number>()
|
||||
for (const token of queryTokens) querySet.set(token, (querySet.get(token) ?? 0) + 1)
|
||||
|
||||
// Document frequency for idf: how many files contain each term.
|
||||
// The previous version passed a per-file match count into a total-files
|
||||
// denominator, which gave every file the same idf and reduced ranking to
|
||||
// raw term frequency.
|
||||
const docFreq = new Map<string, number>()
|
||||
const docTokenList: Set<string>[] = []
|
||||
for (const file of files) {
|
||||
const haystack = [file.title, file.summary, file.highlight, file.loadWhen.join(" "), file.body].join("\n")
|
||||
const docTokens = new Set(tokenize(haystack))
|
||||
docTokenList.push(docTokens)
|
||||
for (const token of docTokens) docFreq.set(token, (docFreq.get(token) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const results: ScoredFile[] = []
|
||||
|
||||
for (const file of files) {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
// A superseded memory stays on disk for audit but must never be injected.
|
||||
if (file.fm.superseded_by) continue
|
||||
|
||||
const state = states[file.rel]
|
||||
const haystack = [file.title, file.summary, file.highlight, file.loadWhen.join(" "), file.body].join("\n")
|
||||
const docTokens = new Set(tokenize(haystack))
|
||||
const docTokens = docTokenList[i]
|
||||
|
||||
let overlap = 0
|
||||
const matched: string[] = []
|
||||
@@ -108,8 +122,18 @@ export function scoreFiles(
|
||||
}
|
||||
if (overlap <= 0) continue
|
||||
|
||||
// Relative term rarity across the corpus: a term in every file is useless.
|
||||
const idf = idfFor(tokenScore(querySet, docTokens), files.length)
|
||||
// Inverse document frequency: a term appearing in every file is useless.
|
||||
let idfSum = 0
|
||||
let idfCount = 0
|
||||
for (const token of querySet.keys()) {
|
||||
if (!docTokens.has(token)) continue
|
||||
const df = docFreq.get(token) ?? 1
|
||||
idfSum += Math.log(1 + files.length / df)
|
||||
idfCount++
|
||||
}
|
||||
if (idfCount === 0) continue
|
||||
const idf = idfSum / idfCount
|
||||
|
||||
let score = overlap * idf
|
||||
|
||||
// Scope prior.
|
||||
@@ -137,23 +161,18 @@ export function scoreFiles(
|
||||
return results.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
function tokenScore(querySet: Map<string, number>, docTokens: Set<string>): number {
|
||||
let n = 0
|
||||
for (const token of querySet.keys()) if (docTokens.has(token)) n++
|
||||
return n
|
||||
}
|
||||
|
||||
/** Inverse document frequency over the candidate set. */
|
||||
function idfFor(matchedTerms: number, total: number): number {
|
||||
if (matchedTerms <= 0 || total <= 0) return 0
|
||||
return Math.log(1 + total / matchedTerms)
|
||||
}
|
||||
|
||||
export interface InjectionOptions {
|
||||
/** Hard cap on the injected block, in characters. */
|
||||
maxChars?: number
|
||||
/** Max number of memory bodies to inline. */
|
||||
maxEntries?: number
|
||||
/**
|
||||
* Half-life in days for the recency decay. 0 disables decay.
|
||||
* Declared here because the caller passes it: it was silently dropped when
|
||||
* this option was missing, so injection ranked with the default 14-day
|
||||
* half-life regardless of configuration.
|
||||
*/
|
||||
halfLifeDays?: number
|
||||
now?: number
|
||||
}
|
||||
|
||||
@@ -175,36 +194,36 @@ export interface InjectionInput {
|
||||
*/
|
||||
export function buildInjection(input: InjectionInput): string {
|
||||
const { roots, filesByRoot, statesByRoot, query } = input
|
||||
const maxChars = input.options?.maxChars ?? 1200
|
||||
const maxEntries = input.options?.maxEntries ?? 4
|
||||
const maxChars = input.options?.maxChars ?? 600
|
||||
const maxEntries = input.options?.maxEntries ?? 0
|
||||
const now = input.options?.now ?? Date.now()
|
||||
|
||||
const blocks: string[] = []
|
||||
|
||||
// Tier 1 — hard rules. Resident regardless of query: these are the user's
|
||||
// non-negotiables and must never be outranked by relevance.
|
||||
// Body first line only: the title usually restates it, and this block is
|
||||
// re-sent on every model request.
|
||||
const ruleLines: string[] = []
|
||||
for (const root of roots) {
|
||||
for (const file of filesByRoot.get(root.key) ?? []) {
|
||||
const isRule = file.category === "rules" || String(file.fm.kind ?? "").toLowerCase() === "rule"
|
||||
if (!isRule || file.fm.superseded_by) continue
|
||||
ruleLines.push(`- [${root.label}] ${file.title}${file.summary ? ` — ${file.summary}` : ""}`)
|
||||
const firstLine = file.body.split(/\r?\n/).find((l) => l.trim())?.trim() ?? ""
|
||||
if (firstLine) ruleLines.push(`- [${root.label}] ${firstLine}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2 — the outline: what exists, and when each part is worth loading.
|
||||
// This is the ROOT.md idea: one small file answers "what do I know and when
|
||||
// do I need it", instead of dumping every memory into every request.
|
||||
//
|
||||
// Goals only. The slug list was the single most expensive part of the block
|
||||
// and the model does not need it: `memory_recall` discovers files by query.
|
||||
const outline: string[] = []
|
||||
for (const root of roots) {
|
||||
const files = filesByRoot.get(root.key) ?? []
|
||||
if (files.length === 0) continue
|
||||
const goals = readGoal(root.dir)
|
||||
if (goals) outline.push(`目标(${root.label}): ${goals}`)
|
||||
for (const [category, list] of groupCategories(files)) {
|
||||
const bits = list.map((f) => `${f.slug}(${f.summary.slice(0, 28)})`)
|
||||
outline.push(`${root.label}/${category}: ${bits.join(", ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 3 — full bodies for the top matches.
|
||||
@@ -266,9 +285,12 @@ function groupCategories(files: readonly import("./store.ts").MemoryFile[]) {
|
||||
export function readGoal(rootDir: string): string | undefined {
|
||||
try {
|
||||
const text = fs.readFileSync(path.join(rootDir, "ROOT.md"), "utf8")
|
||||
const m = /^##\s*当前目标\s*\n([\s\S]*?)(?=\n##\s|\n<!--|$)/m.exec(text)
|
||||
// Match until the next section header or auto marker. The previous regex
|
||||
// stopped at any `##` or `<!--`, which truncated content containing
|
||||
// sub-headings or HTML comments inside the goals section.
|
||||
const m = /^##\s*当前目标\s*\n([\s\S]*?)(?=\n##\s|\n<!-- auto:begin|$)/m.exec(text)
|
||||
const body = m?.[1]
|
||||
?.replace(/<!--[\s\S]*?-->/g, "")
|
||||
?.replace(/<!--(?!auto:)[\s\S]*?-->/g, "")
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("<!--"))
|
||||
|
||||
+17
-24
@@ -45,8 +45,10 @@ export interface MemoryFile {
|
||||
readonly title: string
|
||||
/** One-line description shown in indexes and injected summaries. */
|
||||
readonly summary: string
|
||||
/** The "重点" column in index.md. */
|
||||
/** Highlight column in index.md. */
|
||||
readonly highlight: string
|
||||
/** `kind` from frontmatter, e.g. "fact", "rule", "rule-proposal". */
|
||||
readonly kind?: string
|
||||
/** Scenarios that should pull this file in. Drives ROOT's routing table. */
|
||||
readonly loadWhen: string[]
|
||||
readonly pinned: boolean
|
||||
@@ -170,6 +172,7 @@ function readMemoryFile(root: MemoryRoot, abs: string, category: string, name: s
|
||||
title: String(data.title ?? firstHeading(body) ?? slug),
|
||||
summary: String(data.summary ?? deriveSummary(body, slug)),
|
||||
highlight: String(data.highlight ?? ""),
|
||||
kind: data.kind === undefined ? undefined : String(data.kind),
|
||||
loadWhen: toStringArray(data.load_when ?? data.loadWhen),
|
||||
pinned: data.pinned === true || data.pinned === "true",
|
||||
created: toNumber(data.created) ?? stat.mtimeMs,
|
||||
@@ -431,12 +434,21 @@ function pruneEmptyIndexes(root: MemoryRoot, files: MemoryFile[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
export interface CompiledRoot {
|
||||
registry: string
|
||||
routing: string
|
||||
function describeCategory(category: string, files: MemoryFile[]): string {
|
||||
const pinned = files.find((f) => isHardRule(f))
|
||||
if (pinned && category === HARD_RULES_CATEGORY) return "不可违反的硬规则,常驻"
|
||||
const first = files[0]
|
||||
if (first?.fm.category_desc) return String(first.fm.category_desc)
|
||||
return first ? first.summary.slice(0, 40) : "(空)"
|
||||
}
|
||||
|
||||
export function compileRootTables(root: MemoryRoot, files: MemoryFile[]): CompiledRoot {
|
||||
/** Recompile ROOT.md's generated regions and write it if it does not exist. */
|
||||
export function compileRoot(root: MemoryRoot, files: MemoryFile[]): string {
|
||||
// Same reason as the category index: ROOT.md may be the first thing written
|
||||
// into a root that does not exist yet.
|
||||
fs.mkdirSync(root.dir, { recursive: true })
|
||||
const target = path.join(root.dir, "ROOT.md")
|
||||
|
||||
const categories = groupByCategory(files)
|
||||
|
||||
const registryRows = categories.map(({ category, files: list }) => {
|
||||
@@ -461,25 +473,6 @@ export function compileRootTables(root: MemoryRoot, files: MemoryFile[]): Compil
|
||||
? `\n| 场景 / 触发 | 读哪个文件 | 类型 |\n|---|---|---|\n${routingRows.join("\n")}\n`
|
||||
: `\n_暂无 load_when 标记的记忆。给文件加 frontmatter 的 \`load_when:\` 即可自动进入路由表。_\n`
|
||||
|
||||
return { registry, routing }
|
||||
}
|
||||
|
||||
function describeCategory(category: string, files: MemoryFile[]): string {
|
||||
const pinned = files.find((f) => isHardRule(f))
|
||||
if (pinned && category === HARD_RULES_CATEGORY) return "不可违反的硬规则,常驻"
|
||||
const first = files[0]
|
||||
if (first?.fm.category_desc) return String(first.fm.category_desc)
|
||||
return first ? first.summary.slice(0, 40) : "(空)"
|
||||
}
|
||||
|
||||
/** Recompile ROOT.md's generated regions and write it if it does not exist. */
|
||||
export function compileRoot(root: MemoryRoot, files: MemoryFile[]): string {
|
||||
// Same reason as the category index: ROOT.md may be the first thing written
|
||||
// into a root that does not exist yet.
|
||||
fs.mkdirSync(root.dir, { recursive: true })
|
||||
const target = path.join(root.dir, "ROOT.md")
|
||||
const { registry, routing } = compileRootTables(root, files)
|
||||
|
||||
let existing: string | undefined
|
||||
try {
|
||||
existing = fs.readFileSync(target, "utf8")
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: find-skills
|
||||
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
|
||||
---
|
||||
|
||||
# Find Skills
|
||||
|
||||
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when the user:
|
||||
|
||||
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||
- Says "find a skill for X" or "is there a skill for X"
|
||||
- Asks "can you do X" where X is a specialized capability
|
||||
- Expresses interest in extending agent capabilities
|
||||
- Wants to search for tools, templates, or workflows
|
||||
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||
|
||||
## What is the Skills CLI?
|
||||
|
||||
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||
|
||||
**Key commands:**
|
||||
|
||||
- `npx skills find [query]` - Search for skills interactively or by keyword
|
||||
- `npx skills add <package>` - Install a skill from GitHub or other sources
|
||||
- `npx skills check` - Check for skill updates
|
||||
- `npx skills update` - Update all installed skills
|
||||
|
||||
**Browse skills at:** https://skills.sh/
|
||||
|
||||
## How to Help Users Find Skills
|
||||
|
||||
### Step 1: Understand What They Need
|
||||
|
||||
When a user asks for help with something, identify:
|
||||
|
||||
1. The domain (e.g., React, testing, design, deployment)
|
||||
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||
3. Whether this is a common enough task that a skill likely exists
|
||||
|
||||
### Step 2: Check the Leaderboard First
|
||||
|
||||
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
|
||||
|
||||
For example, top skills for web development include:
|
||||
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
|
||||
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
|
||||
|
||||
### Step 3: Search for Skills
|
||||
|
||||
If the leaderboard doesn't cover the user's need, run the find command:
|
||||
|
||||
```bash
|
||||
npx skills find [query]
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||
|
||||
### Step 4: Verify Quality Before Recommending
|
||||
|
||||
**Do not recommend a skill based solely on search results.** Always verify:
|
||||
|
||||
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
|
||||
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
|
||||
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
|
||||
|
||||
### Step 5: Present Options to the User
|
||||
|
||||
When you find relevant skills, present them to the user with:
|
||||
|
||||
1. The skill name and what it does
|
||||
2. The install count and source
|
||||
3. The install command they can run
|
||||
4. A link to learn more at skills.sh
|
||||
|
||||
Example response:
|
||||
|
||||
```
|
||||
I found a skill that might help! The "react-best-practices" skill provides
|
||||
React and Next.js performance optimization guidelines from Vercel Engineering.
|
||||
(185K installs)
|
||||
|
||||
To install it:
|
||||
npx skills add vercel-labs/agent-skills@react-best-practices
|
||||
|
||||
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
|
||||
```
|
||||
|
||||
### Step 6: Offer to Install
|
||||
|
||||
If the user wants to proceed, you can install the skill for them:
|
||||
|
||||
```bash
|
||||
npx skills add <owner/repo@skill> -g -y
|
||||
```
|
||||
|
||||
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
|
||||
|
||||
## Common Skill Categories
|
||||
|
||||
When searching, consider these common categories:
|
||||
|
||||
| Category | Example Queries |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| Web Development | react, nextjs, typescript, css, tailwind |
|
||||
| Testing | testing, jest, playwright, e2e |
|
||||
| DevOps | deploy, docker, kubernetes, ci-cd |
|
||||
| Documentation | docs, readme, changelog, api-docs |
|
||||
| Code Quality | review, lint, refactor, best-practices |
|
||||
| Design | ui, ux, design-system, accessibility |
|
||||
| Productivity | workflow, automation, git |
|
||||
|
||||
## Tips for Effective Searches
|
||||
|
||||
1. **Use specific keywords**: "react testing" is better than just "testing"
|
||||
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
|
||||
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
|
||||
|
||||
## When No Skills Are Found
|
||||
|
||||
If no relevant skills exist:
|
||||
|
||||
1. Acknowledge that no existing skill was found
|
||||
2. Offer to help with the task directly using your general capabilities
|
||||
3. Suggest the user could create their own skill with `npx skills init`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
I searched for skills related to "xyz" but didn't find any matches.
|
||||
I can still help you with this task directly! Would you like me to proceed?
|
||||
|
||||
If this is something you do often, you could create your own skill:
|
||||
npx skills init my-xyz-skill
|
||||
```
|
||||
@@ -0,0 +1,637 @@
|
||||
---
|
||||
name: mmx
|
||||
description: MiniMax CLI (mmx) 技能索引。提供文本生成、语音合成、图像生成、视频生成、音乐生成、网络搜索、视觉理解等 AI 能力的命令行接口。当任务涉及 MiniMax API 调用、内容生成、或需要 AI 多模态能力时使用此技能。
|
||||
---
|
||||
|
||||
# MiniMax CLI (mmx) Skill
|
||||
|
||||
MiniMax AI 多模态命令行工具,整合了 Text Generation、Speech Synthesis、Image Generation、Video Generation、Music Generation、Web Search、Vision 等能力。
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--api-key <key>` | API Key (覆盖其他认证方式) |
|
||||
| `--region <region>` | API 区域: `global` (默认) / `cn` |
|
||||
| `--base-url <url>` | API Base URL (覆盖 region) |
|
||||
| `--output <format>` | 输出格式: `text` / `json` |
|
||||
| `--quiet` | 抑制非必要输出 |
|
||||
| `--verbose` | 打印 HTTP 请求/响应详情 |
|
||||
| `--timeout <seconds>` | 请求超时 (默认: 300) |
|
||||
| `--no-color` | 禁用 ANSI 颜色和动画 |
|
||||
| `--dry-run` | 显示将要执行的操作但不实际执行 |
|
||||
| `--non-interactive` | 禁用交互式提示 (CI/agent 模式) |
|
||||
|
||||
## Resource Index
|
||||
|
||||
### 1. auth - 认证
|
||||
|
||||
| Command | 说明 |
|
||||
|---------|------|
|
||||
| `auth login` | 通过 OAuth 或 API Key 认证 |
|
||||
| `auth status` | 显示当前认证状态和配额快照 |
|
||||
| `auth refresh` | 手动刷新 OAuth Token |
|
||||
| `auth logout` | 撤销 Token 并清除存储的凭据 |
|
||||
|
||||
#### auth login
|
||||
|
||||
```
|
||||
mmx auth login [--api-key <key>] [--recommend] [--region=global|cn]
|
||||
```
|
||||
|
||||
- `--api-key <key>` 直接保存 API Key
|
||||
- `--recommend` 直接进入 OAuth (可配合 `--region=` 跳过区域选择)
|
||||
|
||||
#### auth status
|
||||
|
||||
```
|
||||
mmx auth status
|
||||
```
|
||||
|
||||
#### auth refresh
|
||||
|
||||
```
|
||||
mmx auth refresh
|
||||
```
|
||||
|
||||
#### auth logout
|
||||
|
||||
```
|
||||
mmx auth logout [--yes] [--dry-run]
|
||||
```
|
||||
|
||||
- `--yes` 跳过确认提示
|
||||
|
||||
---
|
||||
|
||||
### 2. text - 文本生成
|
||||
|
||||
| Command | 说明 |
|
||||
|---------|------|
|
||||
| `text chat` | 发送聊天补全 (MiniMax Messages API) |
|
||||
| `text repl` | 启动交互式多轮对话会话 |
|
||||
|
||||
#### text chat
|
||||
|
||||
```
|
||||
mmx text chat --message <text> [flags]
|
||||
```
|
||||
|
||||
| Flag | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `--model <model>` | 模型 ID | `MiniMax-M2.7` |
|
||||
| `--message <text>` | 消息文本 (可重复, 前缀 `role:` 设置角色) | - |
|
||||
| `--messages-file <path>` | JSON 文件包含消息数组 (`-` 表示 stdin) | - |
|
||||
| `--system <text>` | 系统提示词 | - |
|
||||
| `--max-tokens <n>` | 最大生成 Token 数 | 4096 |
|
||||
| `--temperature <n>` | 采样温度 (0.0, 1.0] | - |
|
||||
| `--top-p <n>` | 核采样阈值 | - |
|
||||
| `--stream` | 流式响应 (TTY 中默认开启) | - |
|
||||
| `--tool <json-or-path>` | 工具定义 (可重复) | - |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx text chat --message "What is MiniMax?"
|
||||
mmx text chat --model MiniMax-M2.7-highspeed --system "You are a coding assistant." --message "Write fizzbuzz in Python"
|
||||
cat conversation.json | mmx text chat --messages-file - --stream
|
||||
```
|
||||
|
||||
#### text repl
|
||||
|
||||
```
|
||||
mmx text repl [flags]
|
||||
```
|
||||
|
||||
| Flag | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `--model <model>` | 模型 ID | `MiniMax-M2.7` |
|
||||
| `--system <text>` | 系统提示词 | - |
|
||||
| `--max-tokens <n>` | 每响应最大 Token 数 | 4096 |
|
||||
| `--temperature <n>` | 采样温度 (0.0, 1.0] | - |
|
||||
| `--top-p <n>` | 核采样阈值 | - |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx text repl
|
||||
mmx text repl --model MiniMax-M2.7-highspeed --system "You are a coding assistant."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. speech - 语音合成
|
||||
|
||||
| Command | 说明 |
|
||||
|---------|------|
|
||||
| `speech synthesize` | 同步 TTS, 最多 10k 字符 (speech-2.8-hd / 2.6 / 02) |
|
||||
| `speech generate` | synthesize 的别名 |
|
||||
| `speech voices` | 列出可用的系统声音 |
|
||||
|
||||
#### speech synthesize
|
||||
|
||||
```
|
||||
mmx speech synthesize --text <text> [--out <path>] [flags]
|
||||
```
|
||||
|
||||
| Flag | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `--model <model>` | 模型 ID | `speech-2.8-hd` |
|
||||
| `--text <text>` | 要合成的文本 | - |
|
||||
| `--text-file <path>` | 从文件读取文本 (`-` 表示 stdin) | - |
|
||||
| `--voice <id>` | 声音 ID | `English_expressive_narrator` |
|
||||
| `--speed <n>` | 语速倍数 | - |
|
||||
| `--volume <n>` | 音量 | - |
|
||||
| `--pitch <n>` | 音调调整 | - |
|
||||
| `--format <fmt>` | 音频格式: `mp3`, `pcm`, `flac`, `wav`, `pcmu_raw`, `pcmu_wav`, `opus` | `mp3` |
|
||||
| `--sample-rate <hz>` | 采样率 | `32000` |
|
||||
| `--bitrate <bps>` | 比特率 | `128000` |
|
||||
| `--channels <n>` | 声道数 | `1` |
|
||||
| `--language <code>` | 语言增强 | - |
|
||||
| `--subtitles` | 包含字幕时间数据 | - |
|
||||
| `--pronunciation <from/to>` | 自定义发音 (可重复) | - |
|
||||
| `--out <path>` | 保存音频到文件 | - |
|
||||
| `--stream` | 流式输出原始音频到 stdout | - |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx speech synthesize --text "Hello, world!"
|
||||
mmx speech synthesize --text "Hello, world!" --out hello.mp3
|
||||
echo "Breaking news." | mmx speech synthesize --text-file - --out news.mp3
|
||||
mmx speech synthesize --text "Stream" --stream | mpv --no-terminal -
|
||||
```
|
||||
|
||||
#### speech voices
|
||||
|
||||
```
|
||||
mmx speech voices [--language <lang>]
|
||||
```
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--language <lang>` | 按语言过滤声音 (如 `english`, `korean`, `japanese`) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx speech voices
|
||||
mmx speech voices --language english
|
||||
mmx speech voices --output json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. image - 图像生成
|
||||
|
||||
| Command | 说明 |
|
||||
|---------|------|
|
||||
| `image generate` | 生成图像 (image-01 / image-01-live) |
|
||||
|
||||
#### image generate
|
||||
|
||||
```
|
||||
mmx image generate --prompt <text> [flags]
|
||||
```
|
||||
|
||||
| Flag | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `--prompt <text>` | 图像描述 | - |
|
||||
| `--aspect-ratio <ratio>` | 宽高比 (如 `16:9`, `1:1`) | - |
|
||||
| `--n <count>` | 生成图像数量 | `1` |
|
||||
| `--seed <n>` | 随机种子 (相同 seed + prompt = 相同输出) | - |
|
||||
| `--width <px>` | 自定义宽度 [512, 2048], 必须是 8 的倍数 | - |
|
||||
| `--height <px>` | 自定义高度 [512, 2048], 必须是 8 的倍数 | - |
|
||||
| `--prompt-optimizer` | 自动优化提示词 | - |
|
||||
| `--aigc-watermark` | 在输出图像中嵌入 AI 生成内容水印 | - |
|
||||
| `--subject-ref <params>` | 主体参考 (角色一致性): `type=character,image=path-or-url` | - |
|
||||
| `--out <path>` | 保存图像到指定路径 (仅单张) | - |
|
||||
| `--response-format <format>` | 响应格式: `url` (下载), `base64` (嵌入) | `url` |
|
||||
| `--out-dir <dir>` | 下载图像到目录 | - |
|
||||
| `--out-prefix <prefix>` | 文件名前缀 | `image` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx image generate --prompt "A cat in a spacesuit on Mars" --aspect-ratio 16:9
|
||||
mmx image generate --prompt "Logo design" --n 3 --out-dir ./generated/
|
||||
mmx image generate --prompt "Mountain landscape" --quiet
|
||||
mmx image generate --prompt "A castle" --seed 42
|
||||
mmx image generate --prompt "Wide landscape" --width 1920 --height 1080
|
||||
mmx image generate --prompt "sunset" --prompt-optimizer --aigc-watermark
|
||||
mmx image generate --prompt "A cat" --out /tmp/cat.jpg
|
||||
mmx image generate --prompt "A cat" --response-format base64
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. video - 视频生成
|
||||
|
||||
| Command | 说明 |
|
||||
|---------|------|
|
||||
| `video generate` | 生成视频 (T2V/I2V/SEF/S2V) |
|
||||
| `video task get` | 查询视频任务状态 |
|
||||
| `video download` | 通过 File ID 下载完成的视频 |
|
||||
|
||||
#### video generate
|
||||
|
||||
```
|
||||
mmx video generate --prompt <text> [flags]
|
||||
```
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--model <model>` | 模型: T2V 用 `MiniMax-Hailuo-2.3`; I2V 用 `MiniMax-Hailuo-2.3` (默认) 或 `MiniMax-Hailuo-2.3-Fast` (需要 `--first-frame`); 有 `--last-frame` 切换到 `Hailuo-02`; 有 `--subject-image` 切换到 `S2V-01` |
|
||||
| `--prompt <text>` | 视频描述 |
|
||||
| `--first-frame <path-or-url>` | 首帧图像 (本地路径或 URL) |
|
||||
| `--last-frame <path-or-url>` | 末帧图像 (需要 `--first-frame`, 启用 SEF 模式) |
|
||||
| `--subject-image <path-or-url>` | 主体参考图像 (角色一致性) |
|
||||
| `--callback-url <url>` | 完成通知的 Webhook URL |
|
||||
| `--download <path>` | 完成后保存视频到文件 |
|
||||
| `--no-wait` / `--async` | 立即返回任务 ID,不等待 |
|
||||
| `--poll-interval <seconds>` | 轮询间隔 (默认: 5) |
|
||||
|
||||
**Video Modes:**
|
||||
|
||||
| Mode | 说明 | 模型 |
|
||||
|------|------|------|
|
||||
| T2V | 文本转视频 | Hailuo-2.3 |
|
||||
| I2V | 图像转视频 | Hailuo-2.3 (默认) / Hailuo-2.3-Fast |
|
||||
| SEF | 首尾帧插值 | Hailuo-02 |
|
||||
| S2V | 主体一致性视频 | S2V-01 |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx video generate --prompt "A man reads a book. Static shot."
|
||||
mmx video generate --prompt "Ocean waves at sunset." --download sunset.mp4
|
||||
mmx video generate --prompt "A robot painting." --async --quiet
|
||||
mmx video generate --prompt "Walk forward" --first-frame start.jpg --last-frame end.jpg
|
||||
mmx video generate --prompt "A detective walking" --subject-image character.jpg
|
||||
```
|
||||
|
||||
#### video task get
|
||||
|
||||
```
|
||||
mmx video task get --task-id <id>
|
||||
```
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--task-id <id>` | 视频生成任务 ID |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx video task get --task-id 106916112212032
|
||||
mmx video task get --task-id 106916112212032 --output json
|
||||
```
|
||||
|
||||
#### video download
|
||||
|
||||
```
|
||||
mmx video download --file-id <id> --out <path>
|
||||
```
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--file-id <id>` | 要下载的文件 ID |
|
||||
| `--out <path>` | 输出文件路径 |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx video download --file-id 176844028768320 --out video.mp4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. music - 音乐生成
|
||||
|
||||
| Command | 说明 |
|
||||
|---------|------|
|
||||
| `music generate` | 生成歌曲 (music-2.6 / music-2.5+ / music-2.5) |
|
||||
| `music cover` | 基于参考音频生成歌曲的翻唱版本 (music-cover) |
|
||||
|
||||
#### music generate
|
||||
|
||||
```
|
||||
mmx music generate --prompt <text> (--lyrics <text> | --instrumental | --lyrics-optimizer) [--out <path>] [flags]
|
||||
```
|
||||
|
||||
| Flag | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `--prompt <text>` | 音乐风格描述 (最多 2000 字符) | - |
|
||||
| `--lyrics <text>` | 带结构标签的歌词 | - |
|
||||
| `--lyrics-file <path>` | 从文件读取歌词 (`-` 表示 stdin) | - |
|
||||
| `--lyrics-optimizer` | 从 prompt 自动生成歌词 (不能与 `--lyrics` 或 `--instrumental` 共用) | - |
|
||||
| `--instrumental` | 生成器乐音乐 | - |
|
||||
| `--vocals <text>` | 人声风格, 如 "warm male baritone" | - |
|
||||
| `--genre <text>` | 音乐流派, 如 folk, pop, jazz, electronic | - |
|
||||
| `--mood <text>` | 情绪, 如 warm, melancholic, uplifting | - |
|
||||
| `--instruments <text>` | 要突出的乐器 | - |
|
||||
| `--tempo <text>` | 节奏描述, 如 fast, slow, moderate | - |
|
||||
| `--bpm <number>` | 精确 BPM | - |
|
||||
| `--key <text>` | 调性, 如 C major, A minor | - |
|
||||
| `--avoid <text>` | 要避免的元素 | - |
|
||||
| `--use-case <text>` | 使用场景, 如 "background music for video" | - |
|
||||
| `--structure <text>` | 歌曲结构 | - |
|
||||
| `--references <text>` | 参考曲目或艺术家 | - |
|
||||
| `--extra <text>` | 额外细化需求 | - |
|
||||
| `--model <model>` | 模型: `music-2.6` (默认), `music-2.5+`, `music-2.5` | `music-2.6` |
|
||||
| `--output-format <fmt>` | 返回格式: `hex` (默认, 保存到文件) 或 `url` (24h 过期) | `hex` |
|
||||
| `--aigc-watermark` | 嵌入 AI 生成内容水印 | - |
|
||||
| `--format <fmt>` | 音频格式: `mp3`, `wav`, `pcm` | `mp3` |
|
||||
| `--sample-rate <hz>` | 采样率 | `44100` |
|
||||
| `--bitrate <bps>` | 比特率 | `256000` |
|
||||
| `--stream` | 流式输出原始音频到 stdout | - |
|
||||
| `--out <path>` | 保存音频到文件 | - |
|
||||
|
||||
**Lyrics 结构标签:**
|
||||
|
||||
`[Intro]`, `[Verse]`, `[Pre Chorus]`, `[Chorus]`, `[Interlude]`, `[Bridge]`, `[Outro]`, `[Post Chorus]`, `[Transition]`, `[Break]`, `[Hook]`, `[Build Up]`, `[Inst]`, `[Solo]`
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx music generate --prompt "Upbeat pop" --lyrics "La la la..." --out summer.mp3
|
||||
mmx music generate --prompt "Indie folk, melancholic" --lyrics-file song.txt --out my_song.mp3
|
||||
mmx music generate --prompt "Upbeat pop about summer" --lyrics-optimizer --out summer.mp3
|
||||
mmx music generate --prompt "Cinematic orchestral, building tension" --instrumental --out bgm.mp3
|
||||
mmx music generate --prompt "Upbeat pop" --lyrics "La la la..." --output-format url
|
||||
mmx music generate --prompt "Warm morning folk" --vocals "male and female duet, harmonies in chorus" --instruments "acoustic guitar, piano" --bpm 95 --out duet.mp3
|
||||
```
|
||||
|
||||
#### music cover
|
||||
|
||||
```
|
||||
mmx music cover --prompt <text> (--audio <url> | --audio-file <path>) [--lyrics <text>] [--out <path>] [flags]
|
||||
```
|
||||
|
||||
| Flag | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `--model <model>` | 模型: `music-cover` (默认) | `music-cover` |
|
||||
| `--prompt <text>` | 目标翻唱风格, 如 "Indie folk, acoustic guitar, warm male vocal" | - |
|
||||
| `--audio <url>` | 参考音频 URL (mp3/wav/flac, 6s-6min, 最大 50MB) | - |
|
||||
| `--audio-file <path>` | 本地参考音频文件 (自动 Base64 编码) | - |
|
||||
| `--lyrics <text>` | 翻唱歌词 (省略则通过 ASR 从参考音频提取) | - |
|
||||
| `--lyrics-file <path>` | 从文件读取歌词 (`-` 表示 stdin) | - |
|
||||
| `--seed <number>` | 随机种子 0-1000000 | - |
|
||||
| `--format <fmt>` | 音频格式: `mp3`, `wav`, `pcm` | `mp3` |
|
||||
| `--sample-rate <hz>` | 采样率 | `44100` |
|
||||
| `--bitrate <bps>` | 比特率 | `256000` |
|
||||
| `--channel <n>` | 声道: `1` (单声道) / `2` (立体声, 默认) | `2` |
|
||||
| `--stream` | 流式输出原始音频到 stdout | - |
|
||||
| `--out <path>` | 保存音频到文件 | - |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx music cover --prompt "Indie folk, acoustic guitar, warm male vocal" --audio https://example.com/song.mp3 --out cover.mp3
|
||||
mmx music cover --prompt "Jazz, piano, slow" --audio-file original.mp3 --lyrics-file lyrics.txt --out jazz_cover.mp3
|
||||
mmx music cover --prompt "Pop, upbeat" --audio https://example.com/ref.mp3 --seed 42 --out reproducible.mp3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. search - 网络搜索
|
||||
|
||||
| Command | 说明 |
|
||||
|---------|------|
|
||||
| `search query` | 通过 MiniMax 搜索网页 |
|
||||
| `search web` | `search query` 的别名 |
|
||||
|
||||
#### search query
|
||||
|
||||
```
|
||||
mmx search query --q <query>
|
||||
```
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--q <query>` | 搜索查询字符串 |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx search query --q "MiniMax AI"
|
||||
mmx search query --q "latest news" --output json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. vision - 视觉理解
|
||||
|
||||
| Command | 说明 |
|
||||
|---------|------|
|
||||
| `vision describe` | 使用 MiniMax VLM 描述图像 |
|
||||
|
||||
#### vision describe
|
||||
|
||||
```
|
||||
mmx vision describe (--image <path-or-url> | --file-id <id>) [--prompt <text>]
|
||||
```
|
||||
|
||||
| Flag | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `--image <path-or-url>` | 本地图像路径或 URL (自动 Base64 编码) | - |
|
||||
| `--file-id <id>` | 预上传的文件 ID (跳过 Base64 转换) | - |
|
||||
| `--prompt <text>` | 关于图像的问题 | `"Describe the image."` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx vision describe --image photo.jpg
|
||||
mmx vision describe --image https://example.com/photo.jpg --prompt "What breed is this dog?"
|
||||
mmx vision describe --file-id file-123456789 --prompt "Extract the text"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. quota - 使用配额
|
||||
|
||||
| Command | 说明 |
|
||||
|---------|------|
|
||||
| `quota show` | 显示 Token Plan 使用情况和剩余配额 |
|
||||
|
||||
#### quota show
|
||||
|
||||
```
|
||||
mmx quota show
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx quota show
|
||||
mmx quota show --output json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. config - CLI 配置
|
||||
|
||||
| Command | 说明 |
|
||||
|---------|------|
|
||||
| `config show` | 显示当前配置 |
|
||||
| `config set` | 设置配置值 |
|
||||
| `config export-schema` | 导出 CLI 命令为 Anthropic/OpenAI 兼容的 JSON 工具模式 |
|
||||
|
||||
#### config show
|
||||
|
||||
```
|
||||
mmx config show
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx config show
|
||||
mmx config show --output json
|
||||
```
|
||||
|
||||
#### config set
|
||||
|
||||
```
|
||||
mmx config set --key <key> --value <value>
|
||||
```
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--key <key>` | 配置键: `region`, `base_url`, `output`, `timeout`, `api_key`, `proxy`, `default_text_model`, `default_speech_model`, `default_video_model`, `default_music_model` |
|
||||
| `--value <value>` | 要设置的值 |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx config set --key output --value json
|
||||
mmx config set --key timeout --value 600
|
||||
mmx config set --key proxy --value http://127.0.0.1:7890
|
||||
mmx config set --key base_url --value https://api-uw.minimax.io
|
||||
```
|
||||
|
||||
#### config export-schema
|
||||
|
||||
```
|
||||
mmx config export-schema [--command "<name>"]
|
||||
```
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--command <name>` | 仅导出特定命令的模式 (如 `video generate`) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx config export-schema
|
||||
mmx config export-schema --command "video generate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 11. file - 文件存储
|
||||
|
||||
| Command | 说明 |
|
||||
|---------|------|
|
||||
| `file upload` | 上传文件到 MiniMax 存储 |
|
||||
| `file list` | 列出已上传的文件 |
|
||||
| `file delete` | 删除已上传的文件 |
|
||||
|
||||
#### file upload
|
||||
|
||||
```
|
||||
mmx file upload --file <path> [--purpose <purpose>]
|
||||
```
|
||||
|
||||
| Flag | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `--file <path>` | 文件的本地路径 | - |
|
||||
| `--purpose <string>` | 文件用途 (默认: `retrieval`) | `retrieval` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx file upload --file doc.pdf
|
||||
mmx file upload --file image.png --purpose vision
|
||||
```
|
||||
|
||||
#### file list
|
||||
|
||||
```
|
||||
mmx file list
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx file list
|
||||
mmx file list --output json
|
||||
```
|
||||
|
||||
#### file delete
|
||||
|
||||
```
|
||||
mmx file delete --file-id <id>
|
||||
```
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--file-id <id>` | 要删除的文件 ID |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
mmx file delete --file-id 123456789
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12. update - 更新
|
||||
|
||||
```
|
||||
mmx update
|
||||
```
|
||||
|
||||
更新 mmx 到最新版本。
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# 认证
|
||||
mmx auth login --api-key sk-cp-xxxxx
|
||||
mmx auth status
|
||||
|
||||
# 文本生成
|
||||
mmx text chat --message "Hello"
|
||||
|
||||
# 语音合成
|
||||
mmx speech synthesize --text "Hello" --out hello.mp3
|
||||
|
||||
# 图像生成
|
||||
mmx image generate --prompt "A sunset" --aspect-ratio 16:9
|
||||
|
||||
# 视频生成
|
||||
mmx video generate --prompt "A man walking" --async --quiet
|
||||
|
||||
# 音乐生成
|
||||
mmx music generate --prompt "Upbeat pop" --lyrics "La la" --out song.mp3
|
||||
|
||||
# 网络搜索
|
||||
mmx search query --q "MiniMax"
|
||||
|
||||
# 视觉理解
|
||||
mmx vision describe --image photo.jpg
|
||||
|
||||
# 查看配额
|
||||
mmx quota show
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- Text: https://platform.minimaxi.com/docs/api-reference/text-post
|
||||
- Speech: https://platform.minimaxi.com/docs/api-reference/speech-t2a-http
|
||||
- Image: https://platform.minimaxi.com/docs/api-reference/image-generation-t2i
|
||||
- Video: https://platform.minimaxi.com/docs/api-reference/video-generation
|
||||
- Music: https://platform.minimaxi.com/docs/api-reference/music-generation
|
||||
Reference in New Issue
Block a user