nine-hundred 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -0
- package/dist/agent/config/index.js +85 -0
- package/dist/agent/context/compress.js +145 -0
- package/dist/agent/context/index.js +68 -0
- package/dist/agent/context/token.js +30 -0
- package/dist/agent/hooks/config.js +41 -0
- package/dist/agent/hooks/core.js +177 -0
- package/dist/agent/hooks/events/index.js +9 -0
- package/dist/agent/hooks/events/post-tool-use-hook.js +7 -0
- package/dist/agent/hooks/events/pre-tool-use-hook.js +8 -0
- package/dist/agent/hooks/events/session-start-hook.js +8 -0
- package/dist/agent/hooks/events/stop-hook.js +10 -0
- package/dist/agent/hooks/events/user-prompt-submit-hook.js +7 -0
- package/dist/agent/hooks/index.js +7 -0
- package/dist/agent/hooks/types.js +32 -0
- package/dist/agent/index.js +465 -0
- package/dist/agent/llm/index.js +35 -0
- package/dist/agent/mcp/client.js +87 -0
- package/dist/agent/mcp/config.js +51 -0
- package/dist/agent/mcp/index.js +90 -0
- package/dist/agent/mcp/schema.js +56 -0
- package/dist/agent/permission/exec.js +71 -0
- package/dist/agent/permission/network.js +37 -0
- package/dist/agent/permission/read.js +27 -0
- package/dist/agent/permission/util/command-changes-directory.js +29 -0
- package/dist/agent/permission/util/dangerous-path.json +116 -0
- package/dist/agent/permission/util/detect-dangerous-operation.js +230 -0
- package/dist/agent/permission/util/detect-language-interpreter.js +66 -0
- package/dist/agent/permission/util/detect-safe-command.js +61 -0
- package/dist/agent/permission/util/index.js +16 -0
- package/dist/agent/permission/util/is-dangerous-path.js +98 -0
- package/dist/agent/permission/util/is-inside-cwd.js +83 -0
- package/dist/agent/permission/util/is-safe-domains.js +47 -0
- package/dist/agent/permission/write.js +31 -0
- package/dist/agent/prompt/index.js +42 -0
- package/dist/agent/skills/planner/SKILL.md +90 -0
- package/dist/agent/skills/programmer-resume/SKILL.md +113 -0
- package/dist/agent/skills/skill-creator/LICENSE.txt +202 -0
- package/dist/agent/skills/skill-creator/SKILL.md +495 -0
- package/dist/agent/skills/skill-creator/agents/analyzer.md +274 -0
- package/dist/agent/skills/skill-creator/agents/comparator.md +202 -0
- package/dist/agent/skills/skill-creator/agents/grader.md +223 -0
- package/dist/agent/skills/skill-creator/assets/eval_review.html +146 -0
- package/dist/agent/skills/skill-creator/eval-viewer/generate_review.py +471 -0
- package/dist/agent/skills/skill-creator/eval-viewer/viewer.html +1325 -0
- package/dist/agent/skills/skill-creator/references/schemas.md +430 -0
- package/dist/agent/skills/skill-creator/scripts/__init__.py +0 -0
- package/dist/agent/skills/skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/dist/agent/skills/skill-creator/scripts/generate_report.py +326 -0
- package/dist/agent/skills/skill-creator/scripts/improve_description.py +247 -0
- package/dist/agent/skills/skill-creator/scripts/package_skill.py +136 -0
- package/dist/agent/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/dist/agent/skills/skill-creator/scripts/run_eval.py +310 -0
- package/dist/agent/skills/skill-creator/scripts/run_loop.py +328 -0
- package/dist/agent/skills/skill-creator/scripts/utils.py +47 -0
- package/dist/agent/skills.js +129 -0
- package/dist/agent/tools/agent_tool/agent_tool.test.js +64 -0
- package/dist/agent/tools/agent_tool/index.js +33 -0
- package/dist/agent/tools/exec_tool/exec_tool.test.js +48 -0
- package/dist/agent/tools/exec_tool/index.js +44 -0
- package/dist/agent/tools/load_skill_tool/index.js +5 -0
- package/dist/agent/tools/load_skill_tool/load_skill_tool.test.js +122 -0
- package/dist/agent/tools/memory_create_tool/index.js +8 -0
- package/dist/agent/tools/memory_create_tool/memory_create_tool.test.js +54 -0
- package/dist/agent/tools/memory_delete_tool/index.js +10 -0
- package/dist/agent/tools/memory_delete_tool/memory_delete_tool.test.js +39 -0
- package/dist/agent/tools/memory_retrieve_tool/index.js +61 -0
- package/dist/agent/tools/memory_retrieve_tool/memory_retrieve_tool.test.js +102 -0
- package/dist/agent/tools/profile_update_tool/index.js +30 -0
- package/dist/agent/tools/profile_update_tool/profile_update_tool.test.js +49 -0
- package/dist/agent/tools/read_file_tool/index.js +24 -0
- package/dist/agent/tools/read_file_tool/read_file_tool.test.js +43 -0
- package/dist/agent/tools/run_js_tool/index.js +48 -0
- package/dist/agent/tools/run_js_tool/run_js_tool.test.js +67 -0
- package/dist/agent/tools/run_py_tool/index.js +48 -0
- package/dist/agent/tools/run_py_tool/run_py_tool.test.js +67 -0
- package/dist/agent/tools/tool_logger.js +16 -0
- package/dist/agent/tools/tool_logger.test.js +22 -0
- package/dist/agent/tools/web_fetch_url/index.js +76 -0
- package/dist/agent/tools/web_fetch_url/web_fetch_url.test.js +102 -0
- package/dist/agent/tools/web_search_tool/index.js +26 -0
- package/dist/agent/tools/web_search_tool/web_search_tool.test.js +61 -0
- package/dist/agent/tools/write_file_tool/index.js +22 -0
- package/dist/agent/tools/write_file_tool/write_file_tool.test.js +46 -0
- package/dist/agent/tools.js +218 -0
- package/dist/cli/command/compact/index.js +14 -0
- package/dist/cli/command/index.js +62 -0
- package/dist/cli/command/invalid/index.js +4 -0
- package/dist/cli/command/new/chat-session.js +10 -0
- package/dist/cli/command/new/index.js +8 -0
- package/dist/cli/command/rewind/index.js +19 -0
- package/dist/cli/command/rewind/rewind-command.test.js +22 -0
- package/dist/cli/command/session/format.js +32 -0
- package/dist/cli/command/session/index.js +32 -0
- package/dist/cli/command/session/session-command.test.js +49 -0
- package/dist/cli/command/unknown/index.js +4 -0
- package/dist/cli/index.js +144 -0
- package/dist/db/checkpointer.js +15 -0
- package/dist/db/index.js +2 -0
- package/dist/db/path.js +8 -0
- package/dist/db/sessions.js +81 -0
- package/dist/db/tables/memory.js +12 -0
- package/dist/db/tables/memory_fts.js +29 -0
- package/dist/index.js +87 -0
- package/dist/install.js +154 -0
- package/package.json +51 -0
- package/pnpm-workspace.yaml +3 -0
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# 900
|
|
2
|
+
|
|
3
|
+
A personal AI Agent like OpenClaw, run in the terminal.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js
|
|
8
|
+
- pnpm 11.5.2
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pnpm install
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Scripts
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm typecheck
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Run TypeScript checks with `tsc`.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pnpm test
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Run unit tests once with Vitest.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pnpm test:watch
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Run Vitest in watch mode.
|
|
35
|
+
|
|
36
|
+
## Project structure
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
src/ Source files
|
|
40
|
+
test/ Unit tests
|
|
41
|
+
tsconfig.json TypeScript configuration
|
|
42
|
+
vitest.config.ts Vitest configuration
|
|
43
|
+
```
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
export const CONFIG_DIR = path.join(os.homedir(), '.900');
|
|
5
|
+
export const CONFIG_FILE = path.join(CONFIG_DIR, '900.json');
|
|
6
|
+
let cachedConfig;
|
|
7
|
+
export function readConfig() {
|
|
8
|
+
if (cachedConfig)
|
|
9
|
+
return cachedConfig;
|
|
10
|
+
try {
|
|
11
|
+
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
12
|
+
cachedConfig = JSON.parse(raw);
|
|
13
|
+
return cachedConfig;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
cachedConfig = {};
|
|
17
|
+
return cachedConfig;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function getModelConfig() {
|
|
21
|
+
const config = readConfig();
|
|
22
|
+
const model = config.model;
|
|
23
|
+
if (!model) {
|
|
24
|
+
throw new Error(`找不到 LLM 配置。请在 ${CONFIG_FILE} 中添加 model 配置,例如:\n` +
|
|
25
|
+
JSON.stringify({
|
|
26
|
+
model: {
|
|
27
|
+
model: 'kimi-k2.6',
|
|
28
|
+
apiKey: 'your-api-key',
|
|
29
|
+
baseURL: 'https://api.moonshot.cn/v1',
|
|
30
|
+
},
|
|
31
|
+
}, null, 2));
|
|
32
|
+
}
|
|
33
|
+
if (!model.model || !model.apiKey || !model.baseURL) {
|
|
34
|
+
throw new Error(`LLM 配置不完整。${CONFIG_FILE} 中的 model 必须包含 model、apiKey 和 baseURL 字段。`);
|
|
35
|
+
}
|
|
36
|
+
return model;
|
|
37
|
+
}
|
|
38
|
+
async function validateApiKey(config) {
|
|
39
|
+
const url = `${config.baseURL.replace(/\/$/, '')}/chat/completions`;
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(url, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
45
|
+
'Content-Type': 'application/json',
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({
|
|
48
|
+
model: config.model,
|
|
49
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
50
|
+
max_tokens: 1,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const body = await response.text().catch(() => '');
|
|
55
|
+
throw new Error(`API Key 验证失败 (${response.status})。\n` +
|
|
56
|
+
`请检查 ${CONFIG_FILE} 中的 apiKey 是否正确。\n` +
|
|
57
|
+
(body ? `服务器返回: ${body.slice(0, 200)}` : ''));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err instanceof Error && err.message.includes('API Key 验证失败')) {
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`无法连接到 LLM 服务 (${url})。\n` +
|
|
65
|
+
`请检查网络或 ${CONFIG_FILE} 中的 baseURL 配置。\n` +
|
|
66
|
+
`原始错误: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function ensureModelConfig() {
|
|
70
|
+
const config = getModelConfig();
|
|
71
|
+
if (!config.apiKey || config.apiKey.length === 0) {
|
|
72
|
+
throw new Error(`apiKey 为空。请在 ${CONFIG_FILE} 中填写有效的 apiKey。`);
|
|
73
|
+
}
|
|
74
|
+
if (config.apiKey.length < 20) {
|
|
75
|
+
await validateApiKey(config);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function getEnvValue(key) {
|
|
79
|
+
const config = readConfig();
|
|
80
|
+
return config.env?.[key] ?? process.env[key];
|
|
81
|
+
}
|
|
82
|
+
export function getMCPServerConfig() {
|
|
83
|
+
const config = readConfig();
|
|
84
|
+
return { mcpServers: config.mcpServers ?? {} };
|
|
85
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { AIMessage, HumanMessage, SystemMessage, ToolMessage, } from '@langchain/core/messages';
|
|
2
|
+
import { getCompressModel } from '../llm/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* 每轮对话保留的最近消息条数,这些消息不会被压缩,
|
|
5
|
+
* 始终原样进入模型输入,保证短期上下文连贯。
|
|
6
|
+
*/
|
|
7
|
+
export const KEEP_RECENT = 6;
|
|
8
|
+
/**
|
|
9
|
+
* 将单条消息格式化为纯文本,用于拼接成给 AI 总结的长文本。
|
|
10
|
+
* 不同类型消息加上角色前缀,方便摘要模型理解对话结构。
|
|
11
|
+
* @param m 任意 BaseMessage
|
|
12
|
+
* @returns 格式化后的字符串
|
|
13
|
+
*/
|
|
14
|
+
function formatMessage(m) {
|
|
15
|
+
if (m instanceof HumanMessage)
|
|
16
|
+
return `User: ${m.content}`;
|
|
17
|
+
if (m instanceof AIMessage)
|
|
18
|
+
return `AI: ${m.content}`;
|
|
19
|
+
if (m instanceof ToolMessage)
|
|
20
|
+
return `Tool (${m.name}): ${m.content}`;
|
|
21
|
+
if (m instanceof SystemMessage)
|
|
22
|
+
return `System: ${m.content}`;
|
|
23
|
+
return `Unknown: ${m.content}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 将一组消息拼接为供 AI 摘要的长文本。
|
|
27
|
+
* @param messages 需要格式化的消息数组
|
|
28
|
+
* @returns 以双换行分隔的格式化字符串
|
|
29
|
+
*/
|
|
30
|
+
export function formatMessagesForSummary(messages) {
|
|
31
|
+
return messages.map(formatMessage).join('\n\n');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 调用 AI 接口对一批历史消息生成摘要。
|
|
35
|
+
* 提示词要求保留关键决策、结论、待办事项和重要上下文,并使用中文输出。
|
|
36
|
+
* @param messages 需要被摘要的消息数组
|
|
37
|
+
* @returns 摘要文本;若数组为空返回空字符串
|
|
38
|
+
*/
|
|
39
|
+
export async function summarizeMessages(messages) {
|
|
40
|
+
if (messages.length === 0)
|
|
41
|
+
return '';
|
|
42
|
+
const text = formatMessagesForSummary(messages);
|
|
43
|
+
const prompt = `请用中文总结以下对话历史,保留关键决策、结论、待办事项和重要上下文信息。尽量简洁:\n\n${text}`;
|
|
44
|
+
const response = await getCompressModel().invoke([new HumanMessage(prompt)]);
|
|
45
|
+
const content = typeof response.content === 'string' ? response.content : JSON.stringify(response.content);
|
|
46
|
+
return content.trim();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 判断当前 token 使用量是否已达到压缩阈值(≥ 80%)。
|
|
50
|
+
* 若上下文窗口未知或非法,直接返回 false,避免误触发。
|
|
51
|
+
* @param usage 当前 token 使用量
|
|
52
|
+
* @param contextWindow 模型上下文窗口大小,可能为 null
|
|
53
|
+
* @returns true 表示需要触发压缩
|
|
54
|
+
*/
|
|
55
|
+
export function shouldCompress(usage, contextWindow) {
|
|
56
|
+
if (contextWindow == null || contextWindow <= 0)
|
|
57
|
+
return false;
|
|
58
|
+
return (usage.total_tokens / contextWindow) * 100 >= 80;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* 简化历史消息中的 ToolMessage:将旧的 tool 结果替换为占位符,
|
|
62
|
+
* 减少上下文臃肿,同时保留最近 3 条和 read_file_tool 的完整内容。
|
|
63
|
+
*
|
|
64
|
+
* 规则:
|
|
65
|
+
* - 只处理 ToolMessage,其他类型原样返回;
|
|
66
|
+
* - 保留数组中最近 3 条 ToolMessage(按索引);
|
|
67
|
+
* - read_file_tool 无论新旧都保留原内容;
|
|
68
|
+
* - 通过 map 返回新数组,不修改原始 messages。
|
|
69
|
+
*/
|
|
70
|
+
function simplifyToolMessages(messages) {
|
|
71
|
+
const toolIndices = [];
|
|
72
|
+
for (let i = 0; i < messages.length; i++) {
|
|
73
|
+
if (messages[i] instanceof ToolMessage) {
|
|
74
|
+
toolIndices.push(i);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const keepIndices = new Set(toolIndices.slice(-3));
|
|
78
|
+
return messages.map((m, i) => {
|
|
79
|
+
if (!(m instanceof ToolMessage))
|
|
80
|
+
return m;
|
|
81
|
+
if (keepIndices.has(i))
|
|
82
|
+
return m;
|
|
83
|
+
if (m.name === 'read_file_tool')
|
|
84
|
+
return m;
|
|
85
|
+
return new ToolMessage({
|
|
86
|
+
content: `[Previous: used ${m.name}]`,
|
|
87
|
+
tool_call_id: m.tool_call_id,
|
|
88
|
+
name: m.name,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 构建本轮模型实际看到的输入消息列表。
|
|
94
|
+
*
|
|
95
|
+
* 策略:
|
|
96
|
+
* - 先对 ToolMessage 做简化,减少上下文冗余;
|
|
97
|
+
* - 若存在历史摘要,将其包装为 SystemMessage 放在最前面,
|
|
98
|
+
* 后面紧跟最近 `KEEP_RECENT` 条原始消息;
|
|
99
|
+
* - 若不存在摘要,直接返回全部简化后的消息。
|
|
100
|
+
*
|
|
101
|
+
* 这样既能压缩远历史,又保留近期上下文。
|
|
102
|
+
* @param options.messages 当前会话的完整消息列表
|
|
103
|
+
* @param options.summary 已生成的历史摘要,null 表示无摘要
|
|
104
|
+
* @returns 本轮应输入给模型的消息数组
|
|
105
|
+
*/
|
|
106
|
+
export function buildLlmInputMessages(options) {
|
|
107
|
+
const { messages, summary } = options;
|
|
108
|
+
// compress tool messages
|
|
109
|
+
let simplified = simplifyToolMessages(messages);
|
|
110
|
+
// compact context
|
|
111
|
+
const recent = simplified.slice(-KEEP_RECENT);
|
|
112
|
+
if (summary) {
|
|
113
|
+
const summaryMessage = new SystemMessage(`以下是对话历史摘要(由历史消息压缩生成):\n\n${summary}`);
|
|
114
|
+
return [summaryMessage, ...recent];
|
|
115
|
+
}
|
|
116
|
+
// 暴力保留最近 300 条记录
|
|
117
|
+
simplified = simplified.slice(0, 300);
|
|
118
|
+
// 一共 4 层压缩机制,保障 context 不会膨胀不会爆炸。
|
|
119
|
+
// 1. 压缩 tool 输出, persist tool output
|
|
120
|
+
// 2. 压缩 tool message
|
|
121
|
+
// 3. 超过 80% 压缩整个 messages
|
|
122
|
+
// 4. 超过 300 messages 自动裁剪
|
|
123
|
+
return simplified;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* 计算本次需要被压缩的消息范围。
|
|
127
|
+
*
|
|
128
|
+
* 规则:
|
|
129
|
+
* 1. 始终保留最后 `KEEP_RECENT` 条消息不压缩;
|
|
130
|
+
* 2. 从 `summarizedIndex` 位置开始向后取,跳过已压缩过的消息;
|
|
131
|
+
* 3. 若消息总数不足 `KEEP_RECENT`,或已全部压缩完,返回空数组。
|
|
132
|
+
*
|
|
133
|
+
* @param options.messages 当前会话的完整消息列表
|
|
134
|
+
* @param options.summarizedIndex 已摘要覆盖到的索引(从数组开头算起)
|
|
135
|
+
* @returns 本次应被送入 summarizeMessages 的消息子数组
|
|
136
|
+
*/
|
|
137
|
+
export function getMessagesToCompress(options) {
|
|
138
|
+
const { messages, summarizedIndex } = options;
|
|
139
|
+
// 计算「可压缩区域」的结束索引(即保留区起点)
|
|
140
|
+
const endIndex = Math.max(0, messages.length - KEEP_RECENT);
|
|
141
|
+
// 已压缩的部分已经覆盖到保留区,无需再压缩
|
|
142
|
+
if (summarizedIndex >= endIndex)
|
|
143
|
+
return [];
|
|
144
|
+
return messages.slice(summarizedIndex, endIndex);
|
|
145
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 模型 → context window 大小映射。
|
|
2
|
+
// 没有 SDK API 能动态查 context window,只能维护静态表 + 环境变量覆盖。
|
|
3
|
+
// 用户切换 baseURL 到自托管模型时可设 MODEL_CONTEXT_WINDOW 手动指定。
|
|
4
|
+
// DeepSeek: https://api-docs.deepseek.com/zh-cn/
|
|
5
|
+
// - deepseek-chat (DeepSeek-V3): 64K
|
|
6
|
+
// - deepseek-reasoner (DeepSeek-R1): 64K
|
|
7
|
+
// Minimax: https://platform.minimaxi.com/
|
|
8
|
+
// - MiniMax-Text-01 / M1: 1M
|
|
9
|
+
// - abab6.5s: 245K
|
|
10
|
+
// - abab6: 8K
|
|
11
|
+
// GLM (智谱): https://docs.bigmodel.cn/
|
|
12
|
+
// - GLM-4 / GLM-4-Plus / GLM-4-Flash / GLM-4-9B: 128K
|
|
13
|
+
// - GLM-4V: 8K (多模态)
|
|
14
|
+
// - GLM-Z1: 32K
|
|
15
|
+
// - ChatGLM 系列: 32K
|
|
16
|
+
// Qwen (阿里云): https://bailian.console.aliyun.com/
|
|
17
|
+
// - qwen-long: 10M
|
|
18
|
+
// - qwen-max 新版 / qwen-plus / qwen-turbo / qwen-coder-plus / qwen2.5 系列: 128K
|
|
19
|
+
// - qwen-max 旧版 / qwen1.5 系列: 32K
|
|
20
|
+
// 小米 mimo: https://mimo.mi.com/
|
|
21
|
+
// - mimo 系列: 128K
|
|
22
|
+
const KNOWN = [
|
|
23
|
+
// --- Anthropic ---
|
|
24
|
+
[/^claude-/, 200_000],
|
|
25
|
+
// --- OpenAI ---
|
|
26
|
+
[/^gpt-4o/, 128_000],
|
|
27
|
+
[/^gpt-4-turbo/, 128_000],
|
|
28
|
+
[/^o1/, 200_000],
|
|
29
|
+
[/^gpt-3\.5-turbo/, 16_000],
|
|
30
|
+
// --- DeepSeek ---
|
|
31
|
+
[/^deepseek-/, 64_000],
|
|
32
|
+
// --- Minimax ---
|
|
33
|
+
[/^minimax-text-01/i, 1_000_000],
|
|
34
|
+
[/^minimax-m1/i, 1_000_000],
|
|
35
|
+
[/^abab6\.5/i, 245_000],
|
|
36
|
+
[/^abab6/i, 8_000],
|
|
37
|
+
[/^minimax-/i, 1_000_000], // fallback
|
|
38
|
+
// --- GLM (智谱) ---
|
|
39
|
+
[/^glm-4v/i, 8_000],
|
|
40
|
+
[/^glm-z1/i, 32_000],
|
|
41
|
+
[/^chatglm/i, 32_000],
|
|
42
|
+
[/^glm-/i, 128_000],
|
|
43
|
+
// --- Qwen (阿里云) ---
|
|
44
|
+
[/^qwen-long/i, 10_000_000],
|
|
45
|
+
[/^qwen2\.5/i, 128_000],
|
|
46
|
+
[/^qwen-coder-plus/i, 128_000],
|
|
47
|
+
[/^qwen-plus/i, 128_000],
|
|
48
|
+
[/^qwen-turbo/i, 128_000],
|
|
49
|
+
[/^qwen-max/i, 32_000],
|
|
50
|
+
[/^qwen1\.5/i, 32_000],
|
|
51
|
+
[/^qwen/i, 128_000], // fallback
|
|
52
|
+
// --- 小米 mimo ---
|
|
53
|
+
[/^mimo/i, 128_000],
|
|
54
|
+
// --- Moonshot (Kimi) ---
|
|
55
|
+
[/^kimi-/i, 256_000], // Kimi K2 系列
|
|
56
|
+
[/^moonshot-/i, 128_000], // moonshot-v1-{8k,32k,128k}
|
|
57
|
+
];
|
|
58
|
+
export function getModelContextWindow(modelName) {
|
|
59
|
+
const override = process.env.MODEL_CONTEXT_WINDOW;
|
|
60
|
+
if (override && /^\d+$/.test(override))
|
|
61
|
+
return Number(override);
|
|
62
|
+
if (!modelName)
|
|
63
|
+
return null;
|
|
64
|
+
for (const [re, n] of KNOWN)
|
|
65
|
+
if (re.test(modelName))
|
|
66
|
+
return n;
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
const WARN_THRESHOLD = 80;
|
|
3
|
+
const DANGER_THRESHOLD = 90;
|
|
4
|
+
function ratioOf(usage, contextWindow) {
|
|
5
|
+
if (contextWindow == null || contextWindow <= 0)
|
|
6
|
+
return null;
|
|
7
|
+
return (usage.total_tokens / contextWindow) * 100;
|
|
8
|
+
}
|
|
9
|
+
// 渲染一行 footer:[tokens: <total> / <max> · <ratio>%]
|
|
10
|
+
// ratio: < 70% dim,70~90% yellow,> 90% red;window 未知时整体 dim 且占比/上限均显示 ?
|
|
11
|
+
export function formatTokenFooter(usage, contextWindow) {
|
|
12
|
+
const used = usage.total_tokens;
|
|
13
|
+
const max = contextWindow != null ? contextWindow.toLocaleString() : '?';
|
|
14
|
+
const ratio = ratioOf(usage, contextWindow);
|
|
15
|
+
const ratioStr = ratio != null ? `${ratio.toFixed(1)}%` : '?';
|
|
16
|
+
const color = ratio == null ? chalk.dim : ratio > 90 ? chalk.red : ratio > 70 ? chalk.yellow : chalk.dim;
|
|
17
|
+
return color(`[tokens: ${used.toLocaleString()} / ${max} · ${ratioStr}]`);
|
|
18
|
+
}
|
|
19
|
+
// 占比超过 80% 时返回两行警告,否则返回 null。
|
|
20
|
+
// > 90% 用红色加粗加重,80~90% 用黄色加粗。
|
|
21
|
+
export function formatTokenWarning(usage, contextWindow) {
|
|
22
|
+
const ratio = ratioOf(usage, contextWindow);
|
|
23
|
+
if (ratio == null || ratio <= WARN_THRESHOLD)
|
|
24
|
+
return null;
|
|
25
|
+
const color = ratio > DANGER_THRESHOLD ? chalk.red.bold : chalk.yellow.bold;
|
|
26
|
+
return [
|
|
27
|
+
color('[警告] Context window 接近大模型接口上限,即将压缩 Context,可能会丢失信息'),
|
|
28
|
+
color(' 建议输入 /new 命令开启新会话'),
|
|
29
|
+
].join('\n');
|
|
30
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { HookConfigSchema } from './types.js';
|
|
5
|
+
export const HOOKS_CONFIG_PATH = path.join(os.homedir(), '.900', '900.json');
|
|
6
|
+
// 从 ~/.900/900.json 读取 hooks 配置。永不抛错:
|
|
7
|
+
// - 文件不存在 → 返回零配置(首次启动/无 hooks 时静默);
|
|
8
|
+
// - JSON 解析失败 → warn-log + 返回零配置;
|
|
9
|
+
// - 缺少 hooks 字段 → 返回零配置;
|
|
10
|
+
// - Zod 校验失败 → warn-log + 返回零配置。
|
|
11
|
+
// 配置每次调用都重读一遍,换"编辑即生效"。
|
|
12
|
+
export function loadHooksConfig() {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = fs.readFileSync(HOOKS_CONFIG_PATH, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
const code = err.code;
|
|
19
|
+
if (code === 'ENOENT')
|
|
20
|
+
return { hooks: {} };
|
|
21
|
+
console.warn(`[hooks] failed to read ${HOOKS_CONFIG_PATH}: ${err.message}; treating as no hooks`);
|
|
22
|
+
return { hooks: {} };
|
|
23
|
+
}
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
parsed = JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
console.warn(`[hooks] invalid JSON in ${HOOKS_CONFIG_PATH}: ${err.message}; treating as no hooks`);
|
|
30
|
+
return { hooks: {} };
|
|
31
|
+
}
|
|
32
|
+
const hooks = parsed?.hooks;
|
|
33
|
+
if (!hooks)
|
|
34
|
+
return { hooks: {} };
|
|
35
|
+
const result = HookConfigSchema.safeParse({ hooks });
|
|
36
|
+
if (!result.success) {
|
|
37
|
+
console.warn(`[hooks] ${HOOKS_CONFIG_PATH} hooks field does not match the expected schema: ${result.error.message}; treating as no hooks`);
|
|
38
|
+
return { hooks: {} };
|
|
39
|
+
}
|
|
40
|
+
return result.data;
|
|
41
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// core.ts — 5 个 hook event 共用的 fork / 缓冲 / exit code 映射 / 调度逻辑。
|
|
2
|
+
// 各 event 的语义包装见 pre-tool-use.ts / post-tool-use.ts / session-start.ts /
|
|
3
|
+
// user-prompt-submit.ts / stop.ts;公共 API 全部从 index.ts 桶导出。
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { loadHooksConfig } from './config.js';
|
|
7
|
+
let DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
|
+
const MAX_BUFFER = 1024 * 1024;
|
|
9
|
+
const ENV_EVENT = 'ARGOS_HOOK_EVENT';
|
|
10
|
+
const ENV_THREAD_ID = 'ARGOS_HOOK_THREAD_ID';
|
|
11
|
+
export function __setDefaultTimeoutMsForTesting(ms) {
|
|
12
|
+
DEFAULT_TIMEOUT_MS = ms;
|
|
13
|
+
}
|
|
14
|
+
function matches(matcher, name) {
|
|
15
|
+
if (!matcher || matcher === '*')
|
|
16
|
+
return true;
|
|
17
|
+
return name.includes(matcher);
|
|
18
|
+
}
|
|
19
|
+
function resolveEntries(config, event, toolName) {
|
|
20
|
+
const entries = config.hooks[event] ?? [];
|
|
21
|
+
if (event === 'PreToolUse' || event === 'PostToolUse') {
|
|
22
|
+
return entries.filter((e) => matches(e.matcher, toolName ?? ''));
|
|
23
|
+
}
|
|
24
|
+
return entries;
|
|
25
|
+
}
|
|
26
|
+
function runOneCommand(command, stdinPayload, timeoutMs, event, threadId) {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const child = spawn('/bin/sh', ['-c', command], {
|
|
29
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
+
env: {
|
|
31
|
+
...process.env,
|
|
32
|
+
[ENV_EVENT]: event,
|
|
33
|
+
[ENV_THREAD_ID]: threadId,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
let stdout = '';
|
|
37
|
+
let stdoutTruncated = false;
|
|
38
|
+
let stderr = '';
|
|
39
|
+
let stderrTruncated = false;
|
|
40
|
+
// stdout / stderr 同样缓冲。block 语义只用 stderr,但 verbose hook 要看两者。
|
|
41
|
+
child.stdout.on('data', (chunk) => {
|
|
42
|
+
if (stdoutTruncated)
|
|
43
|
+
return;
|
|
44
|
+
const s = chunk.toString();
|
|
45
|
+
const remaining = MAX_BUFFER - stdout.length;
|
|
46
|
+
if (remaining <= 0) {
|
|
47
|
+
stdoutTruncated = true;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (s.length > remaining) {
|
|
51
|
+
stdout += s.slice(0, remaining) + '…';
|
|
52
|
+
stdoutTruncated = true;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
stdout += s;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
child.stderr.on('data', (chunk) => {
|
|
59
|
+
if (stderrTruncated)
|
|
60
|
+
return;
|
|
61
|
+
const s = chunk.toString();
|
|
62
|
+
const remaining = MAX_BUFFER - stderr.length;
|
|
63
|
+
if (remaining <= 0) {
|
|
64
|
+
stderrTruncated = true;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (s.length > remaining) {
|
|
68
|
+
stderr += s.slice(0, remaining) + '…';
|
|
69
|
+
stderrTruncated = true;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
stderr += s;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
let timedOut = false;
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
timedOut = true;
|
|
78
|
+
child.kill('SIGKILL');
|
|
79
|
+
}, timeoutMs);
|
|
80
|
+
child.on('error', (err) => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
resolve({ exitCode: null, stdout, stderr: stderr + err.message, signal: null, timedOut: false });
|
|
83
|
+
});
|
|
84
|
+
child.on('close', (code, signal) => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
if (timedOut) {
|
|
87
|
+
resolve({
|
|
88
|
+
exitCode: null,
|
|
89
|
+
stdout,
|
|
90
|
+
stderr: (stderr ? stderr + '\n' : '') + 'hook timed out after ' + timeoutMs + 'ms',
|
|
91
|
+
signal: 'SIGKILL',
|
|
92
|
+
timedOut: true,
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
resolve({ exitCode: code, stdout, stderr, signal, timedOut: false });
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
child.stdin.write(stdinPayload);
|
|
100
|
+
child.stdin.end();
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// EPIPE / closed stream
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function mapExitCode(result, command) {
|
|
108
|
+
// mapExitCode 只用 stderr;stdout 留给 verbose 路径自己打印。
|
|
109
|
+
const code = result.exitCode;
|
|
110
|
+
const trimmedStderr = result.stderr.trim();
|
|
111
|
+
if (result.timedOut) {
|
|
112
|
+
return { error: { message: 'hook timed out: ' + command } };
|
|
113
|
+
}
|
|
114
|
+
if (code === 0)
|
|
115
|
+
return {};
|
|
116
|
+
if (code === 1) {
|
|
117
|
+
return { block: { message: trimmedStderr || 'Blocked by hook' } };
|
|
118
|
+
}
|
|
119
|
+
if (code === 2) {
|
|
120
|
+
return { additionalContext: trimmedStderr };
|
|
121
|
+
}
|
|
122
|
+
if (code === null) {
|
|
123
|
+
return { error: { message: 'hook failed to spawn: ' + command } };
|
|
124
|
+
}
|
|
125
|
+
return { error: { message: 'hook exited with code ' + code + ': ' + command } };
|
|
126
|
+
}
|
|
127
|
+
// 公共入口:跑一个 event 的所有匹配 hook,合并结果。
|
|
128
|
+
// - 第一个 exit 1 → block 短路,后面的 hook 不再跑
|
|
129
|
+
// - exit 2 的 additionalContext 累加(多 hook 都能注入)
|
|
130
|
+
// - error 不短路,但聚合到结果里(runner 自己 warn-log)
|
|
131
|
+
export async function runHook(event, input) {
|
|
132
|
+
const config = loadHooksConfig();
|
|
133
|
+
const toolName = input.event === 'PreToolUse' || input.event === 'PostToolUse' ? input.tool.name : undefined;
|
|
134
|
+
const entries = resolveEntries(config, event, toolName);
|
|
135
|
+
if (entries.length === 0)
|
|
136
|
+
return {};
|
|
137
|
+
const stdinPayload = JSON.stringify(input);
|
|
138
|
+
let blockMessage;
|
|
139
|
+
const contexts = [];
|
|
140
|
+
const errors = [];
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
if (blockMessage)
|
|
143
|
+
break;
|
|
144
|
+
const raw = await runOneCommand(entry.command, stdinPayload, entry.timeoutMs ?? DEFAULT_TIMEOUT_MS, event, input.threadId);
|
|
145
|
+
// 每次 hook 跑完都把捕获的 stdout/stderr 打到主进程 console,
|
|
146
|
+
// 让用户能在终端直接看到 hook 在跑。command 自身不重复打(hooks.json 里已写明)。
|
|
147
|
+
// 格式对齐 tool_logger:`\n[Hook] <event> output: <content>`,独立成行。
|
|
148
|
+
// 颜色用 magenta 与 tool 的 cyan 区分——hook 是用户配置的扩展点,
|
|
149
|
+
// 同 cyan tool/blue AI 视觉上属于"系统元动作"一类,不抢眼也不警示。
|
|
150
|
+
const combined = (raw.stdout + raw.stderr).trim();
|
|
151
|
+
if (combined) {
|
|
152
|
+
console.log(`\n${chalk.dim.magenta('[Hook]')} ${chalk.magenta(event)} ${chalk.dim('output:')} ${chalk.yellow(combined)}`);
|
|
153
|
+
}
|
|
154
|
+
const mapped = mapExitCode(raw, entry.command);
|
|
155
|
+
if (mapped.error) {
|
|
156
|
+
console.warn('[hooks] ' + event + ' hook error: ' + mapped.error.message);
|
|
157
|
+
errors.push(mapped.error.message);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (mapped.block) {
|
|
161
|
+
blockMessage = mapped.block.message;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
if (mapped.additionalContext) {
|
|
165
|
+
contexts.push(mapped.additionalContext);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const out = {};
|
|
169
|
+
if (blockMessage)
|
|
170
|
+
out.block = { message: blockMessage };
|
|
171
|
+
if (contexts.length > 0)
|
|
172
|
+
out.additionalContext = contexts.join('\n\n');
|
|
173
|
+
if (errors.length > 0 && !blockMessage) {
|
|
174
|
+
out.error = { message: errors.join('; ') };
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// 5 个 per-event hook wrapper 的公共 API 桶。
|
|
2
|
+
// 每个 event 的语义说明见各自的文件:
|
|
3
|
+
// pre-tool-use-hook.ts / post-tool-use-hook.ts / session-start-hook.ts /
|
|
4
|
+
// user-prompt-submit-hook.ts / stop-hook.ts
|
|
5
|
+
export { runPreToolUseHooks } from './pre-tool-use-hook.js';
|
|
6
|
+
export { runPostToolUseHooks } from './post-tool-use-hook.js';
|
|
7
|
+
export { runSessionStartHook } from './session-start-hook.js';
|
|
8
|
+
export { runUserPromptSubmitHook } from './user-prompt-submit-hook.js';
|
|
9
|
+
export { runStopHook } from './stop-hook.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// PostToolUse hook — 跑在 tool 成功返回之后、ToolMessage 写入之前。
|
|
2
|
+
// 退出码:
|
|
3
|
+
// 0 继续;1 此时无意义(tool 已跑,runner 内部 warn-log,不写入);2 把 stderr 注入到 ToolMessage 之后。
|
|
4
|
+
import { runHook } from '../core.js';
|
|
5
|
+
export async function runPostToolUseHooks(input) {
|
|
6
|
+
return runHook('PostToolUse', input);
|
|
7
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// PreToolUse hook — 跑在工具调用通过硬编码 permission 之后、真正 invoke 之前。
|
|
2
|
+
// 退出码:
|
|
3
|
+
// 0 继续;1 拦下当前 tool(stderr 作为 block message,并停止当前 agent turn);2 把 stderr 注入到 ToolMessage 之后。
|
|
4
|
+
// 位置:硬编码 permission 之后,hook 永远不能解除硬编码 block——这是安全设计。
|
|
5
|
+
import { runHook } from '../core.js';
|
|
6
|
+
export async function runPreToolUseHooks(input) {
|
|
7
|
+
return runHook('PreToolUse', input);
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// SessionStart hook — 会话开始时跑一次(仅在 runAgentStream 非 resume 路径)。
|
|
2
|
+
// 退出码:
|
|
3
|
+
// 0 继续;2 把 stderr 作为 additionalContext 拼到 userMessage 顶部。
|
|
4
|
+
// 1 拦下当前 session 启动——目前未用,留作未来。
|
|
5
|
+
import { runHook } from '../core.js';
|
|
6
|
+
export async function runSessionStartHook(input) {
|
|
7
|
+
return runHook('SessionStart', input);
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Stop hook — 会话结束时跑一次。触发时机:
|
|
2
|
+
// - runAgentStream 的 finally 块(reason: 'completed' | 'aborted',由 ESC 中断切换)
|
|
3
|
+
// - CLI 的 exit 分支(reason: 'exit')
|
|
4
|
+
// - CLI 的 /new 或 /rewind 切到新 session 之前(reason: 'new-session',用旧 threadId)
|
|
5
|
+
// 退出码:
|
|
6
|
+
// 0 继续(其它值 runner 会 warn-log,不影响主流程)。
|
|
7
|
+
import { runHook } from '../core.js';
|
|
8
|
+
export async function runStopHook(input) {
|
|
9
|
+
return runHook('Stop', input);
|
|
10
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// UserPromptSubmit hook — 跑在 readline 收到用户输入之后、slash dispatch / agent 之前。
|
|
2
|
+
// 退出码:
|
|
3
|
+
// 0 继续;1 拦下当前 prompt(stderr 打印到 stdout 给用户看,不进 agent);2 注入上下文。
|
|
4
|
+
import { runHook } from '../core.js';
|
|
5
|
+
export async function runUserPromptSubmitHook(input) {
|
|
6
|
+
return runHook('UserPromptSubmit', input);
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// 公共 API 桶。caller 应从这里 import:
|
|
2
|
+
// import { runPreToolUseHooks, type PreToolUseInput } from '../../agent/hooks/index.js';
|
|
3
|
+
// 5 个 event 的 wrapper 在 events/ 子目录里,公共 API 转发自那里;
|
|
4
|
+
// 每个 wrapper 的语义说明见 events/<name>-hook.ts。
|
|
5
|
+
export { runPreToolUseHooks, runPostToolUseHooks, runSessionStartHook, runUserPromptSubmitHook, runStopHook, } from './events/index.js';
|
|
6
|
+
export { runHook, __setDefaultTimeoutMsForTesting } from './core.js';
|
|
7
|
+
export { loadHooksConfig, HOOKS_CONFIG_PATH } from './config.js';
|