magic-agent 2.4.3 → 2.6.3
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/CLIENT-CONTRACT.md +467 -41
- package/README.md +15 -14
- package/client-ui/pc/app.css +74 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +40 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +247 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +72 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +217 -0
- package/client-ui/shared/conv/conv.css +329 -174
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +373 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/apps.css +16 -0
- package/client-ui-mobile/apps.js +406 -0
- package/client-ui-mobile/index.html +5554 -639
- package/client-ui-mobile/reqs.css +110 -1
- package/client-ui-mobile/reqs.js +533 -74
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +67 -0
- package/client-ui-mobile/scripts.js +599 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +69 -10
- package/cmd/magic-client/main.go +210 -32
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_caps_cache_test.go +184 -0
- package/internal/agent/dsh_resume.go +157 -7
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/env.go +173 -1
- package/internal/agent/env_test.go +114 -0
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/limitnotice.go +207 -1
- package/internal/agent/limitnotice_test.go +140 -0
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +166 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +119 -7
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +168 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/engines_test.go +103 -0
- package/internal/cli/events.go +15 -0
- package/internal/cli/events_test.go +50 -0
- package/internal/cli/judge.go +104 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +157 -11
- package/internal/cli/session.go +75 -3
- package/internal/cli/session_test.go +82 -3
- package/internal/cli/sessionlog.go +6 -0
- package/internal/cli/sessionlog_test.go +22 -2
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/apk.go +8 -0
- package/internal/client/apk_test.go +59 -0
- package/internal/client/apps.go +1078 -0
- package/internal/client/apps_test.go +516 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/coreexe.go +196 -0
- package/internal/client/coreexe_test.go +161 -0
- package/internal/client/coreupgrade.go +866 -0
- package/internal/client/coreupgrade_test.go +561 -0
- package/internal/client/daemon.go +297 -33
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/feishu.go +1259 -0
- package/internal/client/feishu_test.go +649 -0
- package/internal/client/health_identity_test.go +84 -0
- package/internal/client/hostenv.go +68 -0
- package/internal/client/hostenv_test.go +37 -0
- package/internal/client/jobs.go +518 -24
- package/internal/client/jobs_auto.go +571 -0
- package/internal/client/jobs_auto_test.go +431 -0
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +3 -3
- package/internal/client/plugins.go +5 -1
- package/internal/client/prefs.go +67 -0
- package/internal/client/procstart_linux.go +59 -0
- package/internal/client/publicurl.go +82 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +207 -2
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/requirements_test.go +103 -0
- package/internal/client/restart.go +288 -0
- package/internal/client/restart_handoff_test.go +230 -0
- package/internal/client/restart_test.go +290 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_implausible.go +38 -0
- package/internal/client/scripts_decode_other.go +31 -0
- package/internal/client/scripts_decode_test.go +130 -0
- package/internal/client/scripts_decode_windows.go +182 -0
- package/internal/client/scripts_test.go +739 -0
- package/internal/client/service.go +717 -97
- package/internal/client/service_test.go +217 -1
- package/internal/client/sessions_cache.go +148 -0
- package/internal/client/sessions_cache_test.go +163 -0
- package/internal/client/sessions_fold.go +165 -0
- package/internal/client/sessions_fold_test.go +204 -0
- package/internal/client/singleinstance.go +470 -0
- package/internal/client/singleinstance_test.go +607 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/client/testmain_test.go +88 -0
- package/internal/client/workspace_default_test.go +158 -0
- package/internal/client/workspacelockenv_test.go +54 -0
- package/internal/config/config.go +48 -0
- package/internal/judge/judge.go +315 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/multiclient_test.go +368 -0
- package/internal/session/session.go +175 -11
- package/internal/session/session_test.go +7 -0
- package/internal/session/status.go +21 -0
- package/internal/session/status_test.go +28 -0
- package/internal/session/title.go +16 -0
- package/internal/session/title_test.go +26 -0
- package/internal/session/workspace_lock.go +265 -28
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +6 -3
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/npm/install.js +11 -0
- package/package.json +14 -3
|
@@ -71,6 +71,12 @@ const (
|
|
|
71
71
|
EnvCBGWPassword = "MAGIC_AGENT_CBGW_PASSWORD"
|
|
72
72
|
)
|
|
73
73
|
|
|
74
|
+
// feishu 一节支持的环境变量覆盖名(魔法值与其他节一致,都是 MAGIC_AGENT_ 前缀)。
|
|
75
|
+
const (
|
|
76
|
+
EnvFeishuAppID = "MAGIC_AGENT_FEISHU_APP_ID"
|
|
77
|
+
EnvFeishuAppSecret = "MAGIC_AGENT_FEISHU_APP_SECRET"
|
|
78
|
+
)
|
|
79
|
+
|
|
74
80
|
// EnvSystemPrompt 默认系统提示词的环境变量覆盖名。
|
|
75
81
|
const EnvSystemPrompt = "MAGIC_AGENT_SYSTEM_PROMPT"
|
|
76
82
|
|
|
@@ -98,6 +104,40 @@ type Config struct {
|
|
|
98
104
|
* ⚠️ 与 `arkclaw` 节的分工:`arkclaw` 是**历史保留的单实例**(老配置照旧生效,
|
|
99
105
|
* 行为一个字不改);`agents` 是新增的**多实例**通道。两者互不影响。 */
|
|
100
106
|
Agents []AgentConfig `json:"agents"`
|
|
107
|
+
|
|
108
|
+
/* Feishu 飞书推送(2026-10-26)的自建应用凭据:magic-client 用它把「任务完成 /
|
|
109
|
+
* 待确认」推到用户本人的飞书。只有 magic-client 读它,core 引擎不用;
|
|
110
|
+
* 移动端「我的 → 通知 → 飞书」也可以直接录(落 ~/.magic-agent/feishu-notify.json,0600),
|
|
111
|
+
* 那边优先级更高。环境变量 MAGIC_AGENT_FEISHU_APP_ID / _APP_SECRET 也认(见 EnvFeishu*)。 */
|
|
112
|
+
Feishu FeishuConfig `json:"feishu"`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* FeishuConfig 飞书自建应用(机器人)凭据。
|
|
116
|
+
*
|
|
117
|
+
* { "feishu": { "app_id": "cli_xxxx", "app_secret": "xxxx" } }
|
|
118
|
+
*
|
|
119
|
+
* 别名:app_id | appId | appId ;app_secret | appSecret | secret。 */
|
|
120
|
+
type FeishuConfig struct {
|
|
121
|
+
AppID string `json:"app_id"`
|
|
122
|
+
AppSecret string `json:"app_secret"`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// UnmarshalJSON 宽松解析:三个 app id 键名、三个 secret 键名任选其一。
|
|
126
|
+
func (f *FeishuConfig) UnmarshalJSON(b []byte) error {
|
|
127
|
+
var raw struct {
|
|
128
|
+
AppID string `json:"app_id"`
|
|
129
|
+
AppIDAlt string `json:"appId"`
|
|
130
|
+
AppIDAlt2 string `json:"appid"`
|
|
131
|
+
Secret string `json:"app_secret"`
|
|
132
|
+
SecretAlt string `json:"appSecret"`
|
|
133
|
+
SecretA3 string `json:"secret"`
|
|
134
|
+
}
|
|
135
|
+
if err := json.Unmarshal(b, &raw); err != nil {
|
|
136
|
+
return err
|
|
137
|
+
}
|
|
138
|
+
f.AppID = firstNonEmpty(raw.AppID, raw.AppIDAlt, raw.AppIDAlt2)
|
|
139
|
+
f.AppSecret = firstNonEmpty(raw.Secret, raw.SecretAlt, raw.SecretA3)
|
|
140
|
+
return nil
|
|
101
141
|
}
|
|
102
142
|
|
|
103
143
|
// UnmarshalJSON 宽松解析顶层键:systemPrompt / system_prompt / system 任选其一。
|
|
@@ -112,10 +152,12 @@ func (c *Config) UnmarshalJSON(b []byte) error {
|
|
|
112
152
|
SystemPromptAlt string `json:"system_prompt"`
|
|
113
153
|
SystemPromptAlt2 string `json:"system"`
|
|
114
154
|
Agents []AgentConfig `json:"agents"`
|
|
155
|
+
Feishu FeishuConfig `json:"feishu"`
|
|
115
156
|
}
|
|
116
157
|
if err := json.Unmarshal(b, &raw); err != nil {
|
|
117
158
|
return err
|
|
118
159
|
}
|
|
160
|
+
c.Feishu = raw.Feishu
|
|
119
161
|
c.ArkClaw = raw.ArkClaw
|
|
120
162
|
// 四个键名任选其一(同一语义的多种写法;都写时按上面的优先级取第一个非空节)。
|
|
121
163
|
c.CodeBuddyGateway = firstNonEmptySection(raw.CBGW, raw.CBGWAlt, raw.CBGWAlt2, raw.CBGWAlt3)
|
|
@@ -407,6 +449,12 @@ func applyEnv(c Config) Config {
|
|
|
407
449
|
if v := strings.TrimSpace(os.Getenv(EnvCBGWPassword)); v != "" {
|
|
408
450
|
c.CodeBuddyGateway.Password = v
|
|
409
451
|
}
|
|
452
|
+
if v := strings.TrimSpace(os.Getenv(EnvFeishuAppID)); v != "" {
|
|
453
|
+
c.Feishu.AppID = v
|
|
454
|
+
}
|
|
455
|
+
if v := strings.TrimSpace(os.Getenv(EnvFeishuAppSecret)); v != "" {
|
|
456
|
+
c.Feishu.AppSecret = v
|
|
457
|
+
}
|
|
410
458
|
return c
|
|
411
459
|
}
|
|
412
460
|
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// Package judge - 判断「本轮任务」与「同一工作空间里正在跑的任务」会不会互相破坏。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它(2026-10-26 用户:「任务模式增加自动 在新会话的时候判断是否可能与其他
|
|
4
|
+
// 运行中会话冲突 如果冲突走串行 否则默认走并行」):锁与进程信号只能回答「有没有人在跑」,
|
|
5
|
+
// 回答不了「在跑什么」—— 同一空间里一个会话在改底部栏、另一个在写这个功能的文档,
|
|
6
|
+
// 两者并行完全安全;而两个都要改 client-ui-mobile/index.html 就必须排队。
|
|
7
|
+
// 这个判断是**语义**的,只能由模型来做(结构化的文件交集在插件侧先用规则筛一遍)。
|
|
8
|
+
//
|
|
9
|
+
// ── 为什么放在 core 而不是各客户端 ──
|
|
10
|
+
// 提示词、输出解析、引擎调用只有一份;客户端(magic-client 的作业队列)exec 这一条即可。
|
|
11
|
+
// 与 `--suggest-followups` / `--summarize-title` 同一套旁路范式:独立进程、ToolsOff、
|
|
12
|
+
// 不带 SessionID,不续接也不污染用户正在聊的上下文,不写会话登记 / 日志。
|
|
13
|
+
//
|
|
14
|
+
// ── 三条纪律 ──
|
|
15
|
+
// 1. **必须快**:调用方只给 2.5s 预算(判定挡在任务开跑前)。所以不思考、不跑工具、
|
|
16
|
+
// 输出限 200 token;超了一律由调用方按「默认并行」放行,绝不拖住用户。
|
|
17
|
+
// 2. **只说结论**:conflict 布尔 + 一句中文理由(必须落到具体文件/功能点,便于界面
|
|
18
|
+
// 解释「为什么排队」)。理由不许编造输入里没有的东西。
|
|
19
|
+
// 3. **判不出来不算冲突**:模型失败/超时返回 error,调用方走 undecided → 并行。
|
|
20
|
+
package judge
|
|
21
|
+
|
|
22
|
+
import (
|
|
23
|
+
"context"
|
|
24
|
+
"encoding/json"
|
|
25
|
+
"fmt"
|
|
26
|
+
"strings"
|
|
27
|
+
"time"
|
|
28
|
+
"unicode/utf8"
|
|
29
|
+
|
|
30
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
// 默认参数。判定是「开跑前的闸门」,宁可放行也不能让用户干等。
|
|
34
|
+
const (
|
|
35
|
+
// DefaultEngine 默认 llm:纯文本、无工具、启动最快(CLI 系引擎单是启动就要 2-4s,
|
|
36
|
+
// 塞不进判定预算)。
|
|
37
|
+
DefaultEngine = "llm"
|
|
38
|
+
// DefaultTimeout core 侧兜底;插件侧给 2.5s,到点直接放弃(按并行放行)。
|
|
39
|
+
/* DefaultTimeout 内层上限(core 自己的判定预算)。外层(magic-client 的异步判定)
|
|
40
|
+
给 4.5s,这里留 1s 给「起进程 + 收尾」,别让两层同时到点。 */
|
|
41
|
+
DefaultTimeout = 3500 * time.Millisecond
|
|
42
|
+
// MaxPromptRunes 本轮提示词上限(判定只需要「要动什么」,不需要全文)。
|
|
43
|
+
MaxPromptRunes = 2000
|
|
44
|
+
// MaxCandidates 最多同时比较几个运行中会话:多了提示词放不下,也判不准。
|
|
45
|
+
MaxCandidates = 6
|
|
46
|
+
// MaxFilesPerCandidate 每个候选最多带多少条已改文件(按最近优先)。
|
|
47
|
+
MaxFilesPerCandidate = 60
|
|
48
|
+
// MaxRecentRunes 候选「近况」上限。
|
|
49
|
+
MaxRecentRunes = 800
|
|
50
|
+
// MaxReasonRunes 理由上限(界面一行放得下)。
|
|
51
|
+
MaxReasonRunes = 120
|
|
52
|
+
// maxTokens 只够输出一个短 JSON。
|
|
53
|
+
maxTokens = 200
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// Candidate 一个「正在运行、可能冲突」的会话。
|
|
57
|
+
type Candidate struct {
|
|
58
|
+
// ID 会话 id(session_id 优先,没有则 run_id):只用于回传给调用方做归属判断。
|
|
59
|
+
ID string `json:"id"`
|
|
60
|
+
// Title 会话标题(core 归纳过的那份,没有就是首问)。
|
|
61
|
+
Title string `json:"title,omitempty"`
|
|
62
|
+
// Recent 近况:最近几条用户诉求或助手动作的要点,由调用方从会话日志抽取。
|
|
63
|
+
Recent string `json:"recent,omitempty"`
|
|
64
|
+
// Files 这个会话**已经改动过**的文件(日志里 tool_kind=file_change 的参数路径)。
|
|
65
|
+
// 这是最硬的冲突证据:两边文件集合有交集时,调用方可以跳过模型直接判冲突。
|
|
66
|
+
Files []string `json:"files,omitempty"`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// RequestBody `--judge-conflict` 的 stdin/文件形状。
|
|
70
|
+
type RequestBody struct {
|
|
71
|
+
Prompt string `json:"prompt"`
|
|
72
|
+
Candidates []Candidate `json:"candidates"`
|
|
73
|
+
// Engine / Model 用哪个引擎判(空 = DefaultEngine)。由调用方传用户配的「建议引擎」。
|
|
74
|
+
Engine string `json:"engine,omitempty"`
|
|
75
|
+
Model string `json:"model,omitempty"`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Request 一次判定的输入(引擎与超时由 CLI 层填)。
|
|
79
|
+
type Request struct {
|
|
80
|
+
Prompt string
|
|
81
|
+
Candidates []Candidate
|
|
82
|
+
Engine string
|
|
83
|
+
Model string
|
|
84
|
+
Timeout time.Duration
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Result 判定结论(也是 `--judge-conflict` 的 stdout 形状)。
|
|
88
|
+
type Result struct {
|
|
89
|
+
// Conflict 是否冲突。true = 调用方应把本轮改为串行等待。
|
|
90
|
+
Conflict bool `json:"conflict"`
|
|
91
|
+
// Level hard | soft | none:severity 只用于文案(hard=文件/功能直接重叠,
|
|
92
|
+
// soft=相关但不确定,none=不相干)。**是否排队只看 Conflict**,两者不一致时以 Conflict 为准。
|
|
93
|
+
Level string `json:"level"`
|
|
94
|
+
// Reason 一句中文理由,必须提到具体文件或功能点(界面直接显示这句)。
|
|
95
|
+
Reason string `json:"reason"`
|
|
96
|
+
// Overlaps 冲突点(文件名 / 功能名),界面据此高亮。
|
|
97
|
+
Overlaps []string `json:"overlaps,omitempty"`
|
|
98
|
+
// Engine / Model 实际判定的引擎与模型(排查「为什么每次都 undecided」用)。
|
|
99
|
+
Engine string `json:"engine"`
|
|
100
|
+
Model string `json:"model,omitempty"`
|
|
101
|
+
// ElapsedMS 这次判定花了多久。
|
|
102
|
+
ElapsedMS int64 `json:"elapsedMs"`
|
|
103
|
+
// By 判定依据:model = 模型给的结论;rule = 调用方用文件交集直接定的(judge 包不产出);
|
|
104
|
+
// undecided = 没判出来(调用方补的)。这里恒为 "model"。
|
|
105
|
+
By string `json:"by"`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Prompt 拼判定提示词。刻意短:每个候选只给「标题 + 近况 + 文件」,不给整段历史。
|
|
109
|
+
func Prompt(prompt string, cands []Candidate) string {
|
|
110
|
+
var b strings.Builder
|
|
111
|
+
b.WriteString("判断「本轮任务」与「正在运行的任务」并行时会不会互相破坏。\n")
|
|
112
|
+
b.WriteString("冲突 = 动同一批文件 / 同一功能点 / 同一份产物(同一个页面、同一个接口、同一份配置、同一篇文章)。\n")
|
|
113
|
+
b.WriteString("不冲突 = 同仓库里改彼此独立的模块;一边改代码一边写该代码的文档;跑构建与改另一个模块。\n\n")
|
|
114
|
+
b.WriteString("本轮任务:\n")
|
|
115
|
+
b.WriteString(clipRunes(strings.TrimSpace(prompt), MaxPromptRunes))
|
|
116
|
+
b.WriteString("\n\n正在运行:\n")
|
|
117
|
+
for i, c := range cands {
|
|
118
|
+
if i >= MaxCandidates {
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
b.WriteString("- ")
|
|
122
|
+
if t := strings.TrimSpace(c.Title); t != "" {
|
|
123
|
+
b.WriteString(clipRunes(t, 80))
|
|
124
|
+
} else {
|
|
125
|
+
b.WriteString("(未命名会话)")
|
|
126
|
+
}
|
|
127
|
+
if r := strings.TrimSpace(c.Recent); r != "" {
|
|
128
|
+
b.WriteString("|近况:")
|
|
129
|
+
b.WriteString(clipRunes(r, MaxRecentRunes))
|
|
130
|
+
}
|
|
131
|
+
if len(c.Files) > 0 {
|
|
132
|
+
b.WriteString("|已改:")
|
|
133
|
+
b.WriteString(strings.Join(tailStrings(c.Files, MaxFilesPerCandidate), ", "))
|
|
134
|
+
}
|
|
135
|
+
b.WriteString("\n")
|
|
136
|
+
}
|
|
137
|
+
b.WriteString("\n只输出 JSON:{\"conflict\":true|false,\"level\":\"hard|soft|none\",\"reason\":\"中文一句话\",\"overlaps\":[\"…\"]}\n")
|
|
138
|
+
b.WriteString("reason 必须点出具体的文件或功能点(例如「都要改 index.html 的底部栏」),不许笼统;")
|
|
139
|
+
b.WriteString("不冲突就写「互不相干」并说明各自动的模块;不许编造输入里没有的文件或功能。")
|
|
140
|
+
return b.String()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Schema 结构化输出(llm / dsh / arkclaw 等支持 --json-schema 的引擎走它)。
|
|
144
|
+
func Schema() *agent.JSONSchema {
|
|
145
|
+
return &agent.JSONSchema{
|
|
146
|
+
Type: "object",
|
|
147
|
+
Properties: map[string]map[string]any{
|
|
148
|
+
"conflict": {"type": "boolean"},
|
|
149
|
+
"level": {"type": "string", "enum": []string{"hard", "soft", "none"}},
|
|
150
|
+
"reason": {"type": "string"},
|
|
151
|
+
"overlaps": {"type": "array", "items": map[string]any{"type": "string"}},
|
|
152
|
+
},
|
|
153
|
+
Required: []string{"conflict", "level", "reason"},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Judge 跑一次判定。调用方给的预算到了就返回 error(由调用方按并行放行)。
|
|
158
|
+
func Judge(ctx context.Context, in Request) (Result, error) {
|
|
159
|
+
prompt := strings.TrimSpace(in.Prompt)
|
|
160
|
+
if prompt == "" {
|
|
161
|
+
return Result{}, fmt.Errorf("冲突判定:缺少本轮提示词")
|
|
162
|
+
}
|
|
163
|
+
cands := make([]Candidate, 0, len(in.Candidates))
|
|
164
|
+
for _, c := range in.Candidates {
|
|
165
|
+
if strings.TrimSpace(c.Title) == "" && strings.TrimSpace(c.Recent) == "" && len(c.Files) == 0 {
|
|
166
|
+
continue // 三条信息全空:这个候选帮不上任何忙,别占提示词
|
|
167
|
+
}
|
|
168
|
+
cands = append(cands, c)
|
|
169
|
+
if len(cands) >= MaxCandidates {
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if len(cands) == 0 {
|
|
174
|
+
// 没有可比对象 = 不可能冲突。这不是错误,直接给结论,省一次模型调用。
|
|
175
|
+
return Result{Conflict: false, Level: "none", Reason: "没有正在运行的同空间任务", By: "rule"}, nil
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
name := strings.TrimSpace(in.Engine)
|
|
179
|
+
if name == "" {
|
|
180
|
+
name = DefaultEngine
|
|
181
|
+
}
|
|
182
|
+
if agent.Lookup(name) == nil {
|
|
183
|
+
return Result{}, fmt.Errorf("冲突判定:未知引擎 %q", name)
|
|
184
|
+
}
|
|
185
|
+
timeout := in.Timeout
|
|
186
|
+
if timeout <= 0 {
|
|
187
|
+
timeout = DefaultTimeout
|
|
188
|
+
}
|
|
189
|
+
cctx, cancel := context.WithTimeout(ctx, timeout)
|
|
190
|
+
defer cancel()
|
|
191
|
+
start := time.Now()
|
|
192
|
+
|
|
193
|
+
msg := Prompt(prompt, cands)
|
|
194
|
+
res, err := runOnce(cctx, name, strings.TrimSpace(in.Model), msg, timeout, start)
|
|
195
|
+
if err == nil {
|
|
196
|
+
return res, nil
|
|
197
|
+
}
|
|
198
|
+
/* 主引擎失败:预算还够就落一次 llm(1-2s,判定用的小模型足够)。
|
|
199
|
+
不再试用户配的「建议备用引擎」—— 那是给生成建议用的,判定多一跳只会把 2.5s 吃光。 */
|
|
200
|
+
if name == DefaultEngine || cctx.Err() != nil {
|
|
201
|
+
return Result{}, err
|
|
202
|
+
}
|
|
203
|
+
remain := timeout - time.Since(start)
|
|
204
|
+
if remain < 800*time.Millisecond {
|
|
205
|
+
return Result{}, err
|
|
206
|
+
}
|
|
207
|
+
return runOnce(cctx, DefaultEngine, "", msg, remain, start)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// runOnce 用指定引擎跑一次,并解析成 Result。
|
|
211
|
+
func runOnce(ctx context.Context, name, model, msg string, budget time.Duration, start time.Time) (Result, error) {
|
|
212
|
+
engine := agent.Lookup(name)
|
|
213
|
+
if engine == nil {
|
|
214
|
+
return Result{}, fmt.Errorf("冲突判定:未知引擎 %q", name)
|
|
215
|
+
}
|
|
216
|
+
attemptCtx, cancel := context.WithTimeout(ctx, budget)
|
|
217
|
+
defer cancel()
|
|
218
|
+
runner := &agent.Runner{Engine: engine, Timeout: budget}
|
|
219
|
+
resp, err := runner.Run(attemptCtx, agent.Request{
|
|
220
|
+
Model: model,
|
|
221
|
+
// 判定纯文本:不给工具、不思考(与 --suggest-followups 同口径)。
|
|
222
|
+
Tools: agent.ToolsOff,
|
|
223
|
+
NoThinking: true,
|
|
224
|
+
Reasoning: "minimal",
|
|
225
|
+
MaxTokens: maxTokens,
|
|
226
|
+
Messages: []agent.Message{{Role: "user", Content: msg}},
|
|
227
|
+
JSONSchema: Schema(),
|
|
228
|
+
})
|
|
229
|
+
if err != nil {
|
|
230
|
+
return Result{}, err
|
|
231
|
+
}
|
|
232
|
+
out, perr := Parse(resp.Text)
|
|
233
|
+
if perr != nil {
|
|
234
|
+
return Result{}, perr
|
|
235
|
+
}
|
|
236
|
+
if out.Level == "" {
|
|
237
|
+
out.Level = "none"
|
|
238
|
+
}
|
|
239
|
+
if out.Conflict && out.Level == "none" {
|
|
240
|
+
out.Level = "soft" // 模型说冲突但没给档位:按最轻的冲突算,别把「不冲突」写在冲突旁边
|
|
241
|
+
}
|
|
242
|
+
out.Engine = engine.Name()
|
|
243
|
+
out.Model = resp.Model
|
|
244
|
+
out.ElapsedMS = time.Since(start).Milliseconds()
|
|
245
|
+
out.By = "model"
|
|
246
|
+
return out, nil
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Parse 从模型输出里抠出 JSON(容忍 ```json 围栏、前后解释文字)。
|
|
250
|
+
func Parse(text string) (Result, error) {
|
|
251
|
+
raw := strings.TrimSpace(text)
|
|
252
|
+
if raw == "" {
|
|
253
|
+
return Result{}, fmt.Errorf("冲突判定:模型没有输出")
|
|
254
|
+
}
|
|
255
|
+
if i := strings.Index(raw, "{"); i > 0 {
|
|
256
|
+
raw = raw[i:]
|
|
257
|
+
}
|
|
258
|
+
if j := strings.LastIndex(raw, "}"); j >= 0 && j+1 < len(raw) {
|
|
259
|
+
raw = raw[:j+1]
|
|
260
|
+
}
|
|
261
|
+
var out Result
|
|
262
|
+
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
|
263
|
+
return Result{}, fmt.Errorf("冲突判定:输出不是 JSON(%v):%s", err, clipRunes(raw, 120))
|
|
264
|
+
}
|
|
265
|
+
out.Level = normalizeLevel(out.Level)
|
|
266
|
+
out.Reason = clipRunes(strings.TrimSpace(out.Reason), MaxReasonRunes)
|
|
267
|
+
out.Overlaps = cleanList(out.Overlaps)
|
|
268
|
+
return out, nil
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func normalizeLevel(s string) string {
|
|
272
|
+
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
273
|
+
case "hard":
|
|
274
|
+
return "hard"
|
|
275
|
+
case "soft":
|
|
276
|
+
return "soft"
|
|
277
|
+
case "none", "":
|
|
278
|
+
return "none"
|
|
279
|
+
default:
|
|
280
|
+
return "soft" // 认不出的档位按「可能相关」处理,不放大成 hard
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// cleanList 去空、去重、限长(模型常把同一个文件名重复列两遍)。
|
|
285
|
+
func cleanList(in []string) []string {
|
|
286
|
+
out := make([]string, 0, len(in))
|
|
287
|
+
seen := map[string]bool{}
|
|
288
|
+
for _, s := range in {
|
|
289
|
+
v := strings.TrimSpace(s)
|
|
290
|
+
if v == "" || seen[v] {
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
seen[v] = true
|
|
294
|
+
out = append(out, clipRunes(v, 80))
|
|
295
|
+
if len(out) >= 8 {
|
|
296
|
+
break
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
func clipRunes(s string, max int) string {
|
|
303
|
+
if max <= 0 || utf8.RuneCountInString(s) <= max {
|
|
304
|
+
return s
|
|
305
|
+
}
|
|
306
|
+
return string([]rune(s)[:max])
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// tailStrings 取最后 n 条(日志按时间追加,越靠后越新)。
|
|
310
|
+
func tailStrings(in []string, n int) []string {
|
|
311
|
+
if n <= 0 || len(in) <= n {
|
|
312
|
+
return in
|
|
313
|
+
}
|
|
314
|
+
return in[len(in)-n:]
|
|
315
|
+
}
|
|
@@ -187,6 +187,10 @@ type bootstrapResult struct {
|
|
|
187
187
|
Created int
|
|
188
188
|
Points int
|
|
189
189
|
Conclusions int
|
|
190
|
+
// Merged / MergedModules 模块去重合并掉的模块(目录基线自己也可能造近义模块,
|
|
191
|
+
// 之前 auto 归纳攒下的也顺手合并;见 modules.go)。
|
|
192
|
+
Merged int
|
|
193
|
+
MergedModules []string
|
|
190
194
|
}
|
|
191
195
|
|
|
192
196
|
// bootstrapWorkspace 按工作区目录反推一版需求基线。
|
|
@@ -275,6 +279,18 @@ func bootstrapWorkspace(ctx context.Context, ws workspaceEntry, opts Options) (b
|
|
|
275
279
|
与 features:[] 同样处理:标记初始化完成,回落到会话增量,不要把旧会话吞掉。 */
|
|
276
280
|
res.Empty = true
|
|
277
281
|
}
|
|
282
|
+
/* 模块去重:目录基线自己也可能造出近义模块(模型一次输出 5-20 个功能点),
|
|
283
|
+
之前 auto 归纳攒下的相似模块也顺手合并。用初始化引擎包一个文本调用器 ——
|
|
284
|
+
不能走 newCaller(测试钉死:首次初始化不碰文本归纳器)。 */
|
|
285
|
+
if mods := listModules(ws.ID); len(mods) >= 2 && len(similarModulePairs(mods)) > 0 {
|
|
286
|
+
mergeCall := func(prompt string, schema *agent.JSONSchema) (string, error) {
|
|
287
|
+
return runBootstrapAgent(ctx, engineName, bootstrapModel, ws.Dir, prompt, timeout)
|
|
288
|
+
}
|
|
289
|
+
if merged := dedupeModules(ws.ID, mergeCall); len(merged) > 0 {
|
|
290
|
+
res.Merged = len(merged)
|
|
291
|
+
res.MergedModules = merged
|
|
292
|
+
}
|
|
293
|
+
}
|
|
278
294
|
return res, nil
|
|
279
295
|
}
|
|
280
296
|
|
|
@@ -60,6 +60,9 @@ var outSchema = &agent.JSONSchema{
|
|
|
60
60
|
"points": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
61
61
|
"conclusions": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
|
62
62
|
"sources": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}},
|
|
63
|
+
// page_url:该功能点对应的页面地址(**可选**)。
|
|
64
|
+
// 不进 required:多数功能点没有页面,硬要求会让模型编一个出来。
|
|
65
|
+
"page_url": map[string]any{"type": "string"},
|
|
63
66
|
},
|
|
64
67
|
"required": []string{"module", "name", "desc", "points", "conclusions", "sources"},
|
|
65
68
|
},
|
|
@@ -76,6 +79,8 @@ type featureOut struct {
|
|
|
76
79
|
Points []string
|
|
77
80
|
Conclusions []string
|
|
78
81
|
Sources []int
|
|
82
|
+
// PageURL 该功能点对应的页面地址(记忆里有才填,空 = 没有)。
|
|
83
|
+
PageURL string `json:"page_url"`
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
// UnmarshalJSON 容忍 sources 里混进字符串编号(模型偶尔给 "#12")。
|
|
@@ -87,12 +92,14 @@ func (f *featureOut) UnmarshalJSON(b []byte) error {
|
|
|
87
92
|
Points []string `json:"points"`
|
|
88
93
|
Conclusions []string `json:"conclusions"`
|
|
89
94
|
Sources []json.RawMessage `json:"sources"`
|
|
95
|
+
PageURL string `json:"page_url"`
|
|
90
96
|
}
|
|
91
97
|
if err := json.Unmarshal(b, &raw); err != nil {
|
|
92
98
|
return err
|
|
93
99
|
}
|
|
94
100
|
f.Module, f.Name, f.Desc = raw.Module, raw.Name, raw.Desc
|
|
95
101
|
f.Points, f.Conclusions = raw.Points, raw.Conclusions
|
|
102
|
+
f.PageURL = strings.TrimSpace(raw.PageURL)
|
|
96
103
|
for _, s := range raw.Sources {
|
|
97
104
|
var n int
|
|
98
105
|
if json.Unmarshal(s, &n) == nil {
|
|
@@ -181,6 +188,8 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
|
|
|
181
188
|
if boot.Called {
|
|
182
189
|
res.Calls++ // 反推是一次带工具的 agent 调用(与下面的归纳共用 calls 口径)
|
|
183
190
|
}
|
|
191
|
+
res.Merged += boot.Merged
|
|
192
|
+
res.MergedModules = append(res.MergedModules, boot.MergedModules...)
|
|
184
193
|
if !boot.Empty {
|
|
185
194
|
res.Updated += boot.Updated
|
|
186
195
|
res.Created += boot.Created
|
|
@@ -222,7 +231,30 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
|
|
|
222
231
|
if replay {
|
|
223
232
|
entries = mem
|
|
224
233
|
}
|
|
234
|
+
engineName := strings.TrimSpace(opts.Engine)
|
|
235
|
+
if engineName == "" {
|
|
236
|
+
engineName = "llm"
|
|
237
|
+
}
|
|
238
|
+
timeout := opts.Timeout
|
|
239
|
+
if timeout <= 0 {
|
|
240
|
+
timeout = DefaultTimeout
|
|
241
|
+
}
|
|
225
242
|
if len(entries) == 0 && !opts.Force {
|
|
243
|
+
/* 手动空间级「整理」:没有新记忆也顺手做一次模块去重 —— 用户点「整理」
|
|
244
|
+
就是想清理列表,只回一句 noop 等于没修(2026-10-25 报障:magic-agent
|
|
245
|
+
空间 12 个模块里躺着多组近义,点整理却只听到「没有新的对话需要整理」)。
|
|
246
|
+
构造不出 caller(引擎不存在等)就静默回落 noop,不改变现有行为。 */
|
|
247
|
+
if mods := listModules(ws.ID); moduleDedupeWanted(opts, res, mods) {
|
|
248
|
+
if call, cerr := newCaller(ctx, engineName, opts.Model, timeout); cerr == nil {
|
|
249
|
+
if merged := dedupeModules(ws.ID, call); len(merged) > 0 {
|
|
250
|
+
res.Merged = len(merged)
|
|
251
|
+
res.MergedModules = merged
|
|
252
|
+
res.OK = true
|
|
253
|
+
res.Pending = max(0, len(mem)-cursor)
|
|
254
|
+
return res, nil
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
226
258
|
res.OK = true
|
|
227
259
|
res.Noop = true
|
|
228
260
|
res.Pending = 0
|
|
@@ -238,14 +270,6 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
|
|
|
238
270
|
use = use[:MaxEntriesPerRun]
|
|
239
271
|
}
|
|
240
272
|
|
|
241
|
-
engineName := strings.TrimSpace(opts.Engine)
|
|
242
|
-
if engineName == "" {
|
|
243
|
-
engineName = "llm"
|
|
244
|
-
}
|
|
245
|
-
timeout := opts.Timeout
|
|
246
|
-
if timeout <= 0 {
|
|
247
|
-
timeout = DefaultTimeout
|
|
248
|
-
}
|
|
249
273
|
call, callErr := newCaller(ctx, engineName, opts.Model, timeout)
|
|
250
274
|
if callErr != nil {
|
|
251
275
|
res.Error = callErr.Error()
|
|
@@ -335,6 +359,24 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
|
|
|
335
359
|
}
|
|
336
360
|
return out
|
|
337
361
|
},
|
|
362
|
+
onWrite: func(module, feature string, sources []int) {
|
|
363
|
+
rels := make([]Relation, 0, len(sources))
|
|
364
|
+
for _, n := range sources {
|
|
365
|
+
e, ok := byN[n]
|
|
366
|
+
if !ok || e.Session == "" {
|
|
367
|
+
continue
|
|
368
|
+
}
|
|
369
|
+
rels = append(rels, Relation{
|
|
370
|
+
Ts: time.Now().UnixMilli(),
|
|
371
|
+
Session: e.Session,
|
|
372
|
+
STitle: e.Title,
|
|
373
|
+
Module: module,
|
|
374
|
+
Feature: feature,
|
|
375
|
+
N: n,
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
_ = appendRelations(ws.ID, rels)
|
|
379
|
+
},
|
|
338
380
|
})
|
|
339
381
|
if !w.ok {
|
|
340
382
|
continue
|
|
@@ -349,6 +391,18 @@ func Consolidate(ctx context.Context, where string, opts Options) (Result, error
|
|
|
349
391
|
}
|
|
350
392
|
}
|
|
351
393
|
|
|
394
|
+
/* 模块去重:把跨轮堆积的相似模块合并掉(「引擎管理 / 引擎调用」这种近义一级分组,
|
|
395
|
+
单靠循环里的「新模块二审」挡不住 —— 那只能管本轮新出现的名字)。模块级整理
|
|
396
|
+
不做(moduleDedupeWanted 里挡着),自动路径只在本轮真写了新文档时顺手做。 */
|
|
397
|
+
if mods := listModules(ws.ID); moduleDedupeWanted(opts, res, mods) {
|
|
398
|
+
if merged := dedupeModules(ws.ID, call); len(merged) > 0 {
|
|
399
|
+
res.Merged = len(merged)
|
|
400
|
+
res.MergedModules = merged
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
_ = compactRelations(ws.ID) // 归纳收尾去重一次(写入期只 append)
|
|
405
|
+
|
|
352
406
|
consumed := cursor
|
|
353
407
|
if !replay && moduleScope == "" {
|
|
354
408
|
consumed = cursor + len(use)
|
|
@@ -573,6 +627,9 @@ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []str
|
|
|
573
627
|
"不要当结论写进去;结论必须是「已经定下来的做法/取舍」。",
|
|
574
628
|
" 与「已有结论」语义相同的同样逐字照抄;**已有结论为空但记忆里有明确结论的必须补上**;",
|
|
575
629
|
" · sources:本条目引用的记忆编号(整数数组,取自上面 `#编号`);",
|
|
630
|
+
" · page_url:**该功能点对应的页面地址**(可选)。只有当记忆里明确出现了这个页面" +
|
|
631
|
+
"(http/https 开头)才填,并**原样照抄**那个地址;记忆里没有就填空串 \"\"," +
|
|
632
|
+
"**绝不猜、绝不编**(编出来的地址会让人点开一个不存在的页面);",
|
|
576
633
|
"5. 只归纳记忆里真实出现的内容,不要发明未提到的功能,**不要写任何日期**;",
|
|
577
634
|
"6. 没有任何新信息时输出空数组 features: []。",
|
|
578
635
|
}
|
|
@@ -610,10 +667,23 @@ func buildPrompt(wsName string, docs []existsDoc, entries []Entry, modules []str
|
|
|
610
667
|
b.WriteString(strings.Join(rules, "\n"))
|
|
611
668
|
b.WriteString("\n\n只输出 JSON:{\"features\":[{\"module\":\"≤" + strconv.Itoa(ModuleMaxChars) +
|
|
612
669
|
"字大功能点\",\"name\":\"≤" + strconv.Itoa(TitleMaxChars) +
|
|
613
|
-
"字要点\",\"desc\":\"\",\"points\":[\"≤30字\"],\"conclusions\":[\"≤30字\"],\"sources\":[12]}]}")
|
|
670
|
+
"字要点\",\"desc\":\"\",\"points\":[\"≤30字\"],\"conclusions\":[\"≤30字\"],\"sources\":[12],\"page_url\":\"\"}]}")
|
|
614
671
|
return b.String()
|
|
615
672
|
}
|
|
616
673
|
|
|
674
|
+
// validPageURL 页面地址是否可用:只认 http/https(其余一律丢弃)。
|
|
675
|
+
//
|
|
676
|
+
// 为什么这么严:模型偶尔会填「无」「待定」「见上文」,或把本地路径当地址。
|
|
677
|
+
// 这些进了文档会变成一个点不开的链接 —— 比没有更糟。
|
|
678
|
+
func validPageURL(u string) bool {
|
|
679
|
+
v := strings.TrimSpace(u)
|
|
680
|
+
if v == "" || isPlaceholder(v) {
|
|
681
|
+
return false
|
|
682
|
+
}
|
|
683
|
+
low := strings.ToLower(v)
|
|
684
|
+
return strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://")
|
|
685
|
+
}
|
|
686
|
+
|
|
617
687
|
// aliasSchema 模块同义二审的输出契约。
|
|
618
688
|
var aliasSchema = &agent.JSONSchema{
|
|
619
689
|
Type: "object",
|
|
@@ -769,6 +839,11 @@ type writeCtx struct {
|
|
|
769
839
|
kind string
|
|
770
840
|
all bool
|
|
771
841
|
sourcesOf func(featureOut) []string
|
|
842
|
+
/* onWrite 文档落盘后的关系回调:module/feature 是**改写后**的最终归属
|
|
843
|
+
(alias、hit 沿用都算过了),sources 是模型给的记忆编号 —— 调用方据此
|
|
844
|
+
记「会话↔需求」关系(relations.jsonl)。事后用 featureOut 重算会记错
|
|
845
|
+
模块名(归属在这函数里才定),必须回调。 */
|
|
846
|
+
onWrite func(module, feature string, sources []int)
|
|
772
847
|
}
|
|
773
848
|
|
|
774
849
|
// writeResult 一条增量的落盘结果。
|
|
@@ -845,6 +920,17 @@ func writeFeatureItem(id string, out featureOut, ctx *writeCtx) writeResult {
|
|
|
845
920
|
}
|
|
846
921
|
sources := mergeUnique(prev.Sources, srcLines, MaxSources, false, sourceKey)
|
|
847
922
|
|
|
923
|
+
/* 页面地址(2026-10-25 用户:「涉及页面改造时截图标注放进文档」):
|
|
924
|
+
新值合法才覆盖;本轮没给(或给了非法值)就**保留旧的** ——
|
|
925
|
+
页面地址是文档里长期有效的事实,不该被某一轮的空值抹掉。 */
|
|
926
|
+
pageURL := prev.PageURL
|
|
927
|
+
if u := strings.TrimSpace(out.PageURL); u != "" && validPageURL(u) {
|
|
928
|
+
pageURL = u
|
|
929
|
+
}
|
|
930
|
+
/* 截图段必须**原样带回**:RenderDoc 是全量重写文档,不带回就会把用户
|
|
931
|
+
逐张攒的完成情况截图全部抹掉。 */
|
|
932
|
+
shots := TrimShots(prev.Shots)
|
|
933
|
+
|
|
848
934
|
dPts := len(points) - len(prev.Points)
|
|
849
935
|
if dPts < 0 {
|
|
850
936
|
dPts = 0
|
|
@@ -874,6 +960,8 @@ func writeFeatureItem(id string, out featureOut, ctx *writeCtx) writeResult {
|
|
|
874
960
|
Conclusions: conclusions,
|
|
875
961
|
Sources: sources,
|
|
876
962
|
Log: logLines,
|
|
963
|
+
PageURL: pageURL,
|
|
964
|
+
Shots: shots,
|
|
877
965
|
}
|
|
878
966
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
879
967
|
return writeResult{}
|
|
@@ -881,6 +969,9 @@ func writeFeatureItem(id string, out featureOut, ctx *writeCtx) writeResult {
|
|
|
881
969
|
if err := os.WriteFile(full, []byte(RenderDoc(name, doc)), 0o600); err != nil {
|
|
882
970
|
return writeResult{}
|
|
883
971
|
}
|
|
972
|
+
if ctx.onWrite != nil && len(out.Sources) > 0 {
|
|
973
|
+
ctx.onWrite(rawMod, name, out.Sources)
|
|
974
|
+
}
|
|
884
975
|
if !has {
|
|
885
976
|
ctx.merger.add(featureFile{Module: rawMod, Name: name, File: full})
|
|
886
977
|
}
|