@huicai/cli 2026.710.97950 → 2026.711.24616
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/LICENSE +43 -21
- package/bin/huicai.js +93 -2
- package/package.json +4 -3
- package/src/advisor.js +1 -0
- package/src/agent.js +1 -385
- package/src/api.js +1 -326
- package/src/autofix.js +1 -0
- package/src/banner.js +1 -83
- package/src/checkpoints.js +1 -0
- package/src/clipboard.js +1 -0
- package/src/codeindex.js +1 -0
- package/src/colors.js +1 -22
- package/src/commands.js +1 -339
- package/src/compaction.js +1 -140
- package/src/config.js +1 -66
- package/src/consult.js +1 -65
- package/src/context-report.js +1 -0
- package/src/context.js +1 -144
- package/src/cron.js +1 -0
- package/src/daemon.js +1 -0
- package/src/device.js +1 -88
- package/src/diffview.js +1 -0
- package/src/doctor.js +1 -0
- package/src/executors.js +1 -391
- package/src/forks.js +1 -0
- package/src/gitflow.js +1 -0
- package/src/hooks.js +1 -0
- package/src/ide.js +1 -53
- package/src/input.js +1 -182
- package/src/insights.js +1 -0
- package/src/kbsync.js +1 -0
- package/src/markdown.js +0 -0
- package/src/markup-filter.js +1 -85
- package/src/mcp.js +1 -207
- package/src/memory.js +1 -110
- package/src/permissions.js +1 -82
- package/src/render.js +1 -134
- package/src/repl.js +1 -205
- package/src/scan.js +1 -0
- package/src/schedule.js +1 -0
- package/src/session.js +1 -140
- package/src/settings.js +0 -0
- package/src/skills.js +1 -248
- package/src/subagent.js +1 -82
- package/src/todo.js +1 -75
- package/src/tools.js +1 -132
- package/src/transcript.js +1 -0
- package/src/tui/app.js +1 -566
- package/src/usage.js +1 -43
- package/src/watch.js +1 -0
package/src/input.js
CHANGED
|
@@ -1,182 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// - @文件 引用:消息中的 @路径 自动展开为文件内容(附在消息末尾),目录则展开为清单;
|
|
3
|
-
// - !命令 直通:以 ! 开头的输入直接在本机执行 shell 命令,输出加入对话上下文(不消耗模型调用);
|
|
4
|
-
// - 输入历史:按项目目录持久化到 ~/.huicai/history.json,供 REPL/TUI 上下键翻阅。
|
|
5
|
-
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import os from 'node:os';
|
|
9
|
-
import { CONFIG_DIR } from './config.js';
|
|
10
|
-
import { runBash } from './executors.js';
|
|
11
|
-
import { extractDocument } from './api.js';
|
|
12
|
-
|
|
13
|
-
const MAX_MENTIONS = 5;
|
|
14
|
-
const MENTION_FILE_CHARS = 12_000;
|
|
15
|
-
const HISTORY_PATH = path.join(CONFIG_DIR, 'history.json');
|
|
16
|
-
const HISTORY_CAP = 1000;
|
|
17
|
-
|
|
18
|
-
/** 图片扩展名 → 作为多模态 image_url 附加。 */
|
|
19
|
-
const IMAGE_MIME = {
|
|
20
|
-
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
21
|
-
webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml',
|
|
22
|
-
};
|
|
23
|
-
/** 文档扩展名 → 经平台 /v1/files/extract 解析为纯文本(与 VSCode 扩展附件同源)。 */
|
|
24
|
-
const DOC_EXT = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'rtf', 'odt', 'ods', 'odp', 'csv']);
|
|
25
|
-
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
26
|
-
const MAX_DOC_BYTES = 20 * 1024 * 1024;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 展开消息中的 @文件/@目录 引用:原文保留,内容以引用块追加在末尾(模型可见全文)。
|
|
30
|
-
* @returns {{text:string, mentions:string[]}}
|
|
31
|
-
*/
|
|
32
|
-
export async function expandAtMentions(text, cwd, config = null) {
|
|
33
|
-
const re = /(^|[\s("'`(【,,::])@([~A-Za-z0-9_./\\-]+)/g;
|
|
34
|
-
const seen = new Set();
|
|
35
|
-
const candidates = [];
|
|
36
|
-
let m;
|
|
37
|
-
while ((m = re.exec(text)) && candidates.length < MAX_MENTIONS) {
|
|
38
|
-
const raw = m[2].replace(/[.,;:。,;]+$/, ''); // 去句尾标点
|
|
39
|
-
if (raw && !seen.has(raw)) {
|
|
40
|
-
seen.add(raw);
|
|
41
|
-
candidates.push(raw);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
const blocks = [];
|
|
45
|
-
const images = []; // 多模态图片附件 {label, dataUri}
|
|
46
|
-
const mentions = [];
|
|
47
|
-
for (const raw of candidates) {
|
|
48
|
-
const abs = raw.startsWith('~') ? path.join(os.homedir(), raw.slice(1)) : path.resolve(cwd, raw);
|
|
49
|
-
let st;
|
|
50
|
-
try {
|
|
51
|
-
st = fs.statSync(abs);
|
|
52
|
-
} catch {
|
|
53
|
-
continue; // 不存在的路径按普通文本处理
|
|
54
|
-
}
|
|
55
|
-
mentions.push(raw);
|
|
56
|
-
if (st.isDirectory()) {
|
|
57
|
-
try {
|
|
58
|
-
const entries = fs.readdirSync(abs).slice(0, 100).join('\n');
|
|
59
|
-
blocks.push(`【引用目录 @${raw}】\n${entries || '(空目录)'}`);
|
|
60
|
-
} catch {
|
|
61
|
-
/* ignore */
|
|
62
|
-
}
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
const ext = path.extname(abs).slice(1).toLowerCase();
|
|
66
|
-
// 图片:作为多模态 image_url 附加(需当前模型支持视觉,或走平台伪模型的服务端识图)
|
|
67
|
-
if (IMAGE_MIME[ext]) {
|
|
68
|
-
if (st.size > MAX_IMAGE_BYTES) {
|
|
69
|
-
blocks.push(`【图片 @${raw}】(超过 ${Math.round(MAX_IMAGE_BYTES / 1048576)}MB,未附加)`);
|
|
70
|
-
} else {
|
|
71
|
-
try {
|
|
72
|
-
const b64 = fs.readFileSync(abs).toString('base64');
|
|
73
|
-
images.push({ label: raw, dataUri: `data:${IMAGE_MIME[ext]};base64,${b64}` });
|
|
74
|
-
blocks.push(`【已附加图片 @${raw}】`);
|
|
75
|
-
} catch {
|
|
76
|
-
/* ignore */
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
// 文档(PDF/Word/Excel/PPT/RTF…):经平台 /v1/files/extract 解析为纯文本
|
|
82
|
-
if (DOC_EXT.has(ext)) {
|
|
83
|
-
if (!config || !config.baseUrl || !config.apiKey) {
|
|
84
|
-
blocks.push(`【文档 @${raw}】(需登录后才能解析为文本)`);
|
|
85
|
-
} else if (st.size > MAX_DOC_BYTES) {
|
|
86
|
-
blocks.push(`【文档 @${raw}】(超过 ${Math.round(MAX_DOC_BYTES / 1048576)}MB,未解析)`);
|
|
87
|
-
} else {
|
|
88
|
-
try {
|
|
89
|
-
const b64 = fs.readFileSync(abs).toString('base64');
|
|
90
|
-
const r = await extractDocument(config.baseUrl, config.apiKey,
|
|
91
|
-
{ filename: path.basename(abs), data: b64, maxChars: 60_000 });
|
|
92
|
-
const meta = `${r.pages ? r.pages + '页·' : ''}${r.chars ?? 0}字${r.truncated ? '·已截断' : ''}`;
|
|
93
|
-
blocks.push(`【引用文档 @${raw}(${meta})】\n${r.text || '(空)'}`);
|
|
94
|
-
} catch (e) {
|
|
95
|
-
blocks.push(`【文档 @${raw}】(解析失败:${e.message})`);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
// 其余:文本文件展开,二进制跳过
|
|
101
|
-
if (st.size > 512 * 1024) {
|
|
102
|
-
blocks.push(`【引用文件 @${raw}】\n(文件过大:${Math.round(st.size / 1024)}KB,未展开;可让我用 read_file 分段读取)`);
|
|
103
|
-
continue;
|
|
104
|
-
}
|
|
105
|
-
try {
|
|
106
|
-
const buf = fs.readFileSync(abs);
|
|
107
|
-
if (isBinary(buf)) {
|
|
108
|
-
blocks.push(`【引用文件 @${raw}】\n(二进制文件,未展开)`);
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
let content = buf.toString('utf8');
|
|
112
|
-
if (content.length > MENTION_FILE_CHARS) content = content.slice(0, MENTION_FILE_CHARS) + '\n…(已截断)';
|
|
113
|
-
blocks.push(`【引用文件 @${raw}】\n\`\`\`\n${content}\n\`\`\``);
|
|
114
|
-
} catch {
|
|
115
|
-
/* ignore */
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
const outText = blocks.length ? text + '\n\n' + blocks.join('\n\n') : text;
|
|
119
|
-
return { text: outText, mentions, images };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function isBinary(buf) {
|
|
123
|
-
const n = Math.min(buf.length, 4096);
|
|
124
|
-
for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** 组装用户消息 content:有图片附件则用 OpenAI 多模态数组,否则纯文本字符串。 */
|
|
129
|
-
export function buildUserContent(text, images) {
|
|
130
|
-
if (!images || !images.length) return text;
|
|
131
|
-
return [
|
|
132
|
-
{ type: 'text', text },
|
|
133
|
-
...images.map((i) => ({ type: 'image_url', image_url: { url: i.dataUri } })),
|
|
134
|
-
];
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* 执行 !命令 直通:在 cwd 下跑 shell 命令,返回展示文本与写入对话的记录文本。
|
|
139
|
-
* @returns {Promise<{output:string, record:string}>}
|
|
140
|
-
*/
|
|
141
|
-
export async function runBangCommand(command, cwd) {
|
|
142
|
-
const output = await runBash(cwd, { command });
|
|
143
|
-
const capped = output.length > 8000 ? output.slice(0, 8000) + '\n…(已截断)' : output;
|
|
144
|
-
return {
|
|
145
|
-
output,
|
|
146
|
-
record: `我在终端直接执行了命令:$ ${command}\n${capped}`,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function readHistoryFile() {
|
|
151
|
-
try {
|
|
152
|
-
const j = JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf8'));
|
|
153
|
-
return Array.isArray(j?.entries) ? j.entries : [];
|
|
154
|
-
} catch {
|
|
155
|
-
return [];
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** 读取某目录的输入历史(旧→新)。 */
|
|
160
|
-
export function loadHistory(cwd, limit = 200) {
|
|
161
|
-
return readHistoryFile()
|
|
162
|
-
.filter((e) => e && e.cwd === cwd && typeof e.line === 'string')
|
|
163
|
-
.slice(-limit)
|
|
164
|
-
.map((e) => e.line);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** 追加一条输入历史(连续重复不记)。best-effort,失败静默。 */
|
|
168
|
-
export function appendHistory(cwd, line) {
|
|
169
|
-
const t = String(line || '').trim();
|
|
170
|
-
if (!t) return;
|
|
171
|
-
try {
|
|
172
|
-
let entries = readHistoryFile();
|
|
173
|
-
const last = [...entries].reverse().find((e) => e.cwd === cwd);
|
|
174
|
-
if (last && last.line === t) return;
|
|
175
|
-
entries.push({ cwd, line: t, ts: Date.now() });
|
|
176
|
-
if (entries.length > HISTORY_CAP) entries = entries.slice(-HISTORY_CAP);
|
|
177
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
178
|
-
fs.writeFileSync(HISTORY_PATH, JSON.stringify({ entries }), { mode: 0o600 });
|
|
179
|
-
} catch {
|
|
180
|
-
/* ignore */
|
|
181
|
-
}
|
|
182
|
-
}
|
|
1
|
+
import e from"node:fs";import t from"node:path";import n from"node:os";import{CONFIG_DIR as r}from"./config.js";import{runBash as i}from"./executors.js";import{extractDocument as s}from"./api.js";const o=t.join(r,"history.json"),a={png:"image/png",jpg:"image/jpeg",jpeg:"image/jpeg",gif:"image/gif",webp:"image/webp",bmp:"image/bmp",svg:"image/svg+xml"},c=new Set(["pdf","doc","docx","xls","xlsx","ppt","pptx","rtf","odt","ods","odp","csv"]),p=8388608,l=20971520;export async function expandAtMentions(r,i,o=null){const f=/(^|[\s("'`(【,,::])@([~A-Za-z0-9_./\\-]+)/g,m=new Set,d=[];let h;for(;(h=f.exec(r))&&d.length<5;){const e=h[2].replace(/[.,;:。,;]+$/,"");e&&!m.has(e)&&(m.add(e),d.push(e))}const g=[],y=[],$=[];for(const r of d){const f=r.startsWith("~")?t.join(n.homedir(),r.slice(1)):t.resolve(i,r);let m;try{m=e.statSync(f)}catch{continue}if($.push(r),m.isDirectory()){try{const t=e.readdirSync(f).slice(0,100).join("\n");g.push(`【引用目录 @${r}】\n${t||"(空目录)"}`)}catch{}continue}const d=t.extname(f).slice(1).toLowerCase();if(a[d])if(m.size>p)g.push(`【图片 @${r}】(超过 ${Math.round(8)}MB,未附加)`);else try{const t=e.readFileSync(f).toString("base64");y.push({label:r,dataUri:`data:${a[d]};base64,${t}`}),g.push(`【已附加图片 @${r}】`)}catch{}else if(c.has(d))if(o&&o.baseUrl&&o.apiKey)if(m.size>l)g.push(`【文档 @${r}】(超过 ${Math.round(20)}MB,未解析)`);else try{const n=e.readFileSync(f).toString("base64"),i=await s(o.baseUrl,o.apiKey,{filename:t.basename(f),data:n,maxChars:6e4}),a=`${i.pages?i.pages+"页·":""}${i.chars??0}字${i.truncated?"·已截断":""}`;g.push(`【引用文档 @${r}(${a})】\n${i.text||"(空)"}`)}catch(e){g.push(`【文档 @${r}】(解析失败:${e.message})`)}else g.push(`【文档 @${r}】(需登录后才能解析为文本)`);else if(m.size>524288)g.push(`【引用文件 @${r}】\n(文件过大:${Math.round(m.size/1024)}KB,未展开;可让我用 read_file 分段读取)`);else try{const t=e.readFileSync(f);if(u(t)){g.push(`【引用文件 @${r}】\n(二进制文件,未展开)`);continue}let n=t.toString("utf8");n.length>12e3&&(n=n.slice(0,12e3)+"\n…(已截断)"),g.push(`【引用文件 @${r}】\n\`\`\`\n${n}\n\`\`\``)}catch{}}return{text:g.length?r+"\n\n"+g.join("\n\n"):r,mentions:$,images:y}}function u(e){const t=Math.min(e.length,4096);for(let n=0;n<t;n++)if(0===e[n])return!0;return!1}export function buildUserContent(e,t){return t&&t.length?[{type:"text",text:e},...t.map(e=>({type:"image_url",image_url:{url:e.dataUri}}))]:e}export async function runBangCommand(e,t){const n=await i(t,{command:e}),r=n.length>8e3?n.slice(0,8e3)+"\n…(已截断)":n;return{output:n,record:`我在终端直接执行了命令:$ ${e}\n${r}`}}function f(){try{const t=JSON.parse(e.readFileSync(o,"utf8"));return Array.isArray(t?.entries)?t.entries:[]}catch{return[]}}export function loadHistory(e,t=200){return f().filter(t=>t&&t.cwd===e&&"string"==typeof t.line).slice(-t).map(e=>e.line)}export function appendHistory(t,n){const i=String(n||"").trim();if(i)try{let n=f();const s=[...n].reverse().find(e=>e.cwd===t);if(s&&s.line===i)return;n.push({cwd:t,line:i,ts:Date.now()}),n.length>1e3&&(n=n.slice(-1e3)),e.mkdirSync(r,{recursive:!0}),e.writeFileSync(o,JSON.stringify({entries:n}),{mode:384})}catch{}}
|
package/src/insights.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import t from"node:path";import{c as e}from"./colors.js";import{relTime as n}from"./session.js";const s=864e5;function o(t,e){return[...t.entries()].sort((t,e)=>e[1]-t[1]).slice(0,e)}export function computeInsights(e,n={}){const r=n.now||Date.now(),l=Array.isArray(e)?e:[],a=new Map,c=new Map;let u=0,i=0,d=0,m=0,h=null,g=0;for(const e of l){const n=Number(e.turns)||0;u+=n;const o=e.cwd?t.basename(e.cwd)||e.cwd:"(未知)";a.set(o,(a.get(o)||0)+1);const l=e.model||"(未知)";c.set(l,(c.get(l)||0)+1);const p=r-(e.updatedAt||0);p<=s&&i++,p<=7*s&&d++,p<=30*s&&m++,(!h||n>h.turns)&&(h={turns:n,title:e.title||""}),(e.updatedAt||0)>g&&(g=e.updatedAt||0)}const p=new Array(14).fill(0),f=new Date(r);f.setHours(0,0,0,0);for(const t of l){const e=new Date(t.updatedAt||0);e.setHours(0,0,0,0);const n=Math.floor((f.getTime()-e.getTime())/s);n>=0&&n<14&&p[13-n]++}const $=n.cwd?t.basename(n.cwd)||n.cwd:null,w=$?{name:$,sessions:l.filter(t=>t.cwd===n.cwd).length,turns:l.filter(t=>t.cwd===n.cwd).reduce((t,e)=>t+(Number(e.turns)||0),0)}:null;return{total:l.length,totalTurns:u,avgTurns:l.length?u/l.length:0,last24:i,last7:d,last30:m,mostRecent:g,longest:h,byProject:o(a,5),byModel:o(c,5),currentProject:w,daily:p}}export function formatInsights(t){if(!t.total)return"本机还没有历史会话(对话发生后即可 /insights 查看统计)。";const s=[e.bold("会话洞察")+e.gray("(本机 ~/.huicai/sessions)")];return s.push(` 总计 ${t.total} 个会话 · ${t.totalTurns} 轮对话`+(t.mostRecent?` · 最近活跃 ${n(t.mostRecent)}`:"")),s.push(` 近况 24h ${t.last24} 个 · 7天 ${t.last7} 个 · 30天 ${t.last30} 个`),t.daily&&t.daily.some(t=>t>0)&&s.push(` 近14天 ${function(t){const e=Math.max(1,...t);return t.map(t=>0===t?"·":"▁▂▃▄▅▆▇█"[Math.min(7,Math.floor(t/e*7))]).join("")}(t.daily)} ${e.gray("(峰值 "+Math.max(...t.daily)+"/天)")}`),s.push(` 平均 ${t.avgTurns.toFixed(1)} 轮/会话`+(t.longest?` · 最长 ${t.longest.turns} 轮${t.longest.title?"("+t.longest.title.slice(0,24)+")":""}`:"")),t.byProject.length&&s.push(" 按项目 "+t.byProject.map(([t,e])=>`${t} ${e}`).join(" · ")),t.byModel.length&&s.push(" 按模型 "+t.byModel.map(([t,e])=>`${t.length>20?t.slice(0,20)+"…":t} ${e}`).join(" · ")),t.currentProject&&s.push(e.gray(` 当前项目 ${t.currentProject.name}:${t.currentProject.sessions} 个会话 · ${t.currentProject.turns} 轮`)),s.join("\n")}
|
package/src/kbsync.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import e from"node:fs";import t from"node:path";import{execFileSync as n}from"node:child_process";import{syncKnowledge as i}from"./api.js";import{IGNORE as o,MAX_FILE_BYTES as s}from"./codeindex.js";function r(e,t){return n("git",t,{cwd:e,encoding:"utf8",maxBuffer:67108864}).trim()}function d(e){return e.split("/").some(e=>o.has(e))}function c(e){const t=Math.min(e.length,4096);for(let n=0;n<t;n++)if(0===e[n])return!0;return!1}export function detectChanges(e,t){const n=new Set,i=new Set,o=e=>{for(const t of e.split("\n")){if(!t.trim())continue;const e=t.split("\t"),o=e[0][0];if("D"===o)i.add(e[1]);else if("R"===o||"C"===o){const t=e[1],s=e[2]||e[1];"R"===o&&i.add(t),n.add(s)}else n.add(e[e.length-1])}};if(t)o(r(e,["diff","--name-status",t]));else{o(r(e,["diff","--name-status","HEAD"]));const t=r(e,["ls-files","--others","--exclude-standard"]);for(const e of t.split("\n"))e.trim()&&n.add(e.trim())}for(const e of n)i.delete(e);return{changed:[...n],deleted:[...i]}}export function buildEntries(n,i,o,r=()=>{}){const f=[];let a=0;for(const o of i){if(d(o)){a++;continue}const i=t.join(n,o);let l;try{l=e.statSync(i)}catch{a++;continue}if(!l.isFile()){a++;continue}if(l.size>s){r(`跳过(过大 ${(l.size/1024).toFixed(0)}KB):${o}`),a++;continue}const p=e.readFileSync(i);c(p)?a++:f.push({path:o,content:p.toString("utf8"),deleted:!1})}for(const e of o)d(e)||f.push({path:e,deleted:!0});return{files:f,skipped:a}}export async function runKbSync({cwd:e,config:t,kbId:n,base:o,dry:s=!1,emit:d=()=>{}}){if(!function(e){try{return r(e,["rev-parse","--is-inside-work-tree"]),!0}catch{return!1}}(e))throw new Error("当前目录不是 git 仓库;kb-sync 依赖 git diff 检测变更");const{changed:c,deleted:f}=detectChanges(e,o),{files:a,skipped:l}=buildEntries(e,c,f,d),p=a.filter(e=>!e.deleted).length,u=a.filter(e=>e.deleted).length;if(!a.length)return{ok:!0,empty:!0,added:0,removed:0,skipped:l,message:o?`相对 ${o} 无可同步的变更文件`:"工作区无可同步的变更文件"};if(d(`检测到 ${p} 个新增/修改、${u} 个删除${l?`(跳过 ${l})`:""}${o?`(相对 ${o})`:""}`),s)return{ok:!0,dry:!0,added:p,removed:u,skipped:l,message:"试运行(未提交)",preview:a.map(e=>({path:e.path,deleted:!!e.deleted}))};let m=0;const h=[];for(let e=0;e<a.length;e+=20){const o=a.slice(e,e+20);a.length>20&&d(`同步中… ${Math.min(e+20,a.length)}/${a.length}`);const s=await i(t.baseUrl,t.apiKey,n,o);m+=s?.accepted||0,s?.message&&h.push(s.message)}return{ok:!0,added:p,removed:u,skipped:l,accepted:m,message:h.join(";")||"同步完成"}}
|
package/src/markdown.js
CHANGED
|
Binary file
|
package/src/markup-filter.js
CHANGED
|
@@ -1,85 +1 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
3
|
-
// 部分模型(如 DeepSeek 系)在长上下文/上下文压缩后会格式退化:不走 function calling 特殊通道,
|
|
4
|
-
// 而把工具调用以纯文本标记写进正文(如 <||DSML||tool_calls>…、<|tool▁calls▁begin|>…)。
|
|
5
|
-
// 这种「调用」不会出现在 tool_calls 里、也不会被执行;若照原样显示,终端/扩展面板会出现成片协议乱码,
|
|
6
|
-
// 且答非所问。CLI 直连原始模型(平台 /v1 为透明代理,不代过滤),须在客户端做同样的拦截。
|
|
7
|
-
//
|
|
8
|
-
// 流式状态机:正常文本原样放行;遇疑似起始符 <| / <| 先持有,在确认窗口内命中工具标记特征
|
|
9
|
-
// (dsml / tool_call / tool▁call / function_call…)→ 进入吞噬模式,本轮剩余内容全部丢弃
|
|
10
|
-
// (此类输出一旦开始,其后即整段协议块,无用户价值);窗口内未命中 → 原样放行
|
|
11
|
-
// (不误杀正文中的 <|x|> 字面量)。每轮模型输出使用独立实例。
|
|
12
|
-
|
|
13
|
-
/** 起始符确认窗口:起始符后至多累计这么多字符仍未命中特征则放行。 */
|
|
14
|
-
const CONFIRM_WINDOW = 48;
|
|
15
|
-
|
|
16
|
-
/** 工具标记特征(起始符后窗口内匹配):DSML / tool_calls / tool▁calls / function_call 等变体。 */
|
|
17
|
-
const MARKUP_RE = /dsml|tool[_▁]?call|function[_▁]?call/i;
|
|
18
|
-
|
|
19
|
-
/** 找到最早的疑似起始符(<| 或 <|)下标,无则 -1。 */
|
|
20
|
-
function markerStart(s) {
|
|
21
|
-
for (let i = 0; i < s.length - 1; i++) {
|
|
22
|
-
if (s[i] === '<' && (s[i + 1] === '|' || s[i + 1] === '|')) return i;
|
|
23
|
-
}
|
|
24
|
-
return -1;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export class MarkupFilter {
|
|
28
|
-
constructor() {
|
|
29
|
-
this.held = '';
|
|
30
|
-
this.swallowing = false;
|
|
31
|
-
/** 本轮是否检出过文本化工具标记。 */
|
|
32
|
-
this.sawMarkup = false;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** 喂入一段增量,返回可安全下发的文本(可能为空串)。 */
|
|
36
|
-
feed(delta) {
|
|
37
|
-
if (!delta || this.swallowing) return '';
|
|
38
|
-
this.held += delta;
|
|
39
|
-
let out = '';
|
|
40
|
-
for (;;) {
|
|
41
|
-
const idx = markerStart(this.held);
|
|
42
|
-
if (idx < 0) {
|
|
43
|
-
// 无疑似起始:保留可能被增量边界截断的尾部 '<',其余放行
|
|
44
|
-
const keep = this.held.endsWith('<') ? 1 : 0;
|
|
45
|
-
out += this.held.slice(0, this.held.length - keep);
|
|
46
|
-
this.held = keep ? '<' : '';
|
|
47
|
-
return out;
|
|
48
|
-
}
|
|
49
|
-
out += this.held.slice(0, idx);
|
|
50
|
-
this.held = this.held.slice(idx);
|
|
51
|
-
// held 现以 <| 或 <| 开头:在确认窗口内判定
|
|
52
|
-
if (MARKUP_RE.test(this.held.slice(0, CONFIRM_WINDOW))) {
|
|
53
|
-
this.swallowing = true;
|
|
54
|
-
this.sawMarkup = true;
|
|
55
|
-
this.held = '';
|
|
56
|
-
return out;
|
|
57
|
-
}
|
|
58
|
-
if (this.held.length < CONFIRM_WINDOW) return out; // 证据不足:持有等待更多增量
|
|
59
|
-
// 窗口已满仍非标记:放行起始两字符,继续扫描其后内容
|
|
60
|
-
out += this.held.slice(0, 2);
|
|
61
|
-
this.held = this.held.slice(2);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** 流结束:放行仍在持有的未确认内容(吞噬中则丢弃)。 */
|
|
66
|
-
flush() {
|
|
67
|
-
if (this.swallowing) {
|
|
68
|
-
this.held = '';
|
|
69
|
-
return '';
|
|
70
|
-
}
|
|
71
|
-
const rest = this.held;
|
|
72
|
-
this.held = '';
|
|
73
|
-
return rest;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* 一次性清洗全量文本(等价于流式 feed+flush)。
|
|
79
|
-
* @returns {{text:string, sawMarkup:boolean}} text 为清洗后的安全文本(null/空入参返回空串)
|
|
80
|
-
*/
|
|
81
|
-
export function stripMarkup(content) {
|
|
82
|
-
if (!content) return { text: '', sawMarkup: false };
|
|
83
|
-
const f = new MarkupFilter();
|
|
84
|
-
return { text: f.feed(content) + f.flush(), sawMarkup: f.sawMarkup };
|
|
85
|
-
}
|
|
1
|
+
const t=/dsml|tool[_▁]?call|function[_▁]?call/i;function s(t){for(let s=0;s<t.length-1;s++)if("<"===t[s]&&("|"===t[s+1]||"|"===t[s+1]))return s;return-1}export class MarkupFilter{constructor(){this.held="",this.swallowing=!1,this.sawMarkup=!1}feed(e){if(!e||this.swallowing)return"";this.held+=e;let h="";for(;;){const e=s(this.held);if(e<0){const t=this.held.endsWith("<")?1:0;return h+=this.held.slice(0,this.held.length-t),this.held=t?"<":"",h}if(h+=this.held.slice(0,e),this.held=this.held.slice(e),t.test(this.held.slice(0,48)))return this.swallowing=!0,this.sawMarkup=!0,this.held="",h;if(this.held.length<48)return h;h+=this.held.slice(0,2),this.held=this.held.slice(2)}}flush(){if(this.swallowing)return this.held="","";const t=this.held;return this.held="",t}}export function stripMarkup(t){if(!t)return{text:"",sawMarkup:!1};const s=new MarkupFilter;return{text:s.feed(t)+s.flush(),sawMarkup:s.sawMarkup}}
|
package/src/mcp.js
CHANGED
|
@@ -1,207 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// 换行分隔),握手后拉取其工具,暴露给 Agent 循环调用。配置形状为通用 mcpServers 格式:
|
|
3
|
-
// { "mcpServers": { "<名>": { "command": "npx", "args": ["-y","@modelcontextprotocol/server-filesystem","<路径>"], "env": {} } } }
|
|
4
|
-
// 未配置该文件时完全无副作用(返回空工具集)。
|
|
5
|
-
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import { spawn } from 'node:child_process';
|
|
9
|
-
import { CONFIG_DIR } from './config.js';
|
|
10
|
-
import { c } from './colors.js';
|
|
11
|
-
|
|
12
|
-
export const MCP_CONFIG_PATH = path.join(CONFIG_DIR, 'mcp.json');
|
|
13
|
-
|
|
14
|
-
const INIT_TIMEOUT = 20_000;
|
|
15
|
-
const CALL_TIMEOUT = 120_000;
|
|
16
|
-
const PROTOCOL_VERSION = '2024-11-05';
|
|
17
|
-
|
|
18
|
-
/** 单个 MCP 服务的 stdio JSON-RPC 客户端。 */
|
|
19
|
-
class McpStdioClient {
|
|
20
|
-
constructor(name, spec) {
|
|
21
|
-
this.name = name;
|
|
22
|
-
this.spec = spec;
|
|
23
|
-
this.child = null;
|
|
24
|
-
this.buffer = '';
|
|
25
|
-
this.nextId = 1;
|
|
26
|
-
this.pending = new Map(); // id -> {resolve, reject, timer}
|
|
27
|
-
this.closed = false;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async start() {
|
|
31
|
-
const env = { ...process.env, ...(this.spec.env || {}) };
|
|
32
|
-
this.child = spawn(this.spec.command, this.spec.args || [], {
|
|
33
|
-
env,
|
|
34
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
-
});
|
|
36
|
-
this.child.on('error', (e) => this._failAll(new Error(`MCP[${this.name}] 进程错误:${e.message}`)));
|
|
37
|
-
this.child.on('exit', () => {
|
|
38
|
-
this.closed = true;
|
|
39
|
-
this._failAll(new Error(`MCP[${this.name}] 进程已退出`));
|
|
40
|
-
});
|
|
41
|
-
this.child.stdout.setEncoding('utf8');
|
|
42
|
-
this.child.stdout.on('data', (chunk) => this._onData(chunk));
|
|
43
|
-
// stderr 仅调试用,静默丢弃避免污染 TUI
|
|
44
|
-
this.child.stderr.on('data', () => {});
|
|
45
|
-
|
|
46
|
-
// 握手:initialize -> notifications/initialized
|
|
47
|
-
await this._request('initialize', {
|
|
48
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
49
|
-
capabilities: {},
|
|
50
|
-
clientInfo: { name: 'huicai-cli', version: '0.2.0' },
|
|
51
|
-
}, INIT_TIMEOUT);
|
|
52
|
-
this._notify('notifications/initialized', {});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async listTools() {
|
|
56
|
-
const res = await this._request('tools/list', {}, INIT_TIMEOUT);
|
|
57
|
-
return Array.isArray(res?.tools) ? res.tools : [];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async callTool(toolName, args) {
|
|
61
|
-
const res = await this._request('tools/call', { name: toolName, arguments: args || {} }, CALL_TIMEOUT);
|
|
62
|
-
// 结果内容为 content 数组(text/其它);拼接 text 回填模型
|
|
63
|
-
const parts = Array.isArray(res?.content) ? res.content : [];
|
|
64
|
-
const text = parts
|
|
65
|
-
.map((p) => (p?.type === 'text' ? p.text : p?.text || JSON.stringify(p)))
|
|
66
|
-
.filter(Boolean)
|
|
67
|
-
.join('\n');
|
|
68
|
-
if (res?.isError) return `工具报错:${text || '(无详情)'}`;
|
|
69
|
-
return text || '(无输出)';
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
close() {
|
|
73
|
-
this.closed = true;
|
|
74
|
-
try {
|
|
75
|
-
this.child?.kill('SIGTERM');
|
|
76
|
-
} catch {
|
|
77
|
-
/* ignore */
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
_onData(chunk) {
|
|
82
|
-
this.buffer += chunk;
|
|
83
|
-
let idx;
|
|
84
|
-
while ((idx = this.buffer.indexOf('\n')) >= 0) {
|
|
85
|
-
const line = this.buffer.slice(0, idx).trim();
|
|
86
|
-
this.buffer = this.buffer.slice(idx + 1);
|
|
87
|
-
if (!line) continue;
|
|
88
|
-
let msg;
|
|
89
|
-
try {
|
|
90
|
-
msg = JSON.parse(line);
|
|
91
|
-
} catch {
|
|
92
|
-
continue; // 非 JSON 行(部分服务打印日志到 stdout)忽略
|
|
93
|
-
}
|
|
94
|
-
if (msg.id != null && this.pending.has(msg.id)) {
|
|
95
|
-
const { resolve, reject, timer } = this.pending.get(msg.id);
|
|
96
|
-
clearTimeout(timer);
|
|
97
|
-
this.pending.delete(msg.id);
|
|
98
|
-
if (msg.error) reject(new Error(msg.error.message || 'MCP 错误'));
|
|
99
|
-
else resolve(msg.result);
|
|
100
|
-
}
|
|
101
|
-
// 服务端通知(无匹配 id)忽略
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
_request(method, params, timeoutMs) {
|
|
106
|
-
if (this.closed) return Promise.reject(new Error(`MCP[${this.name}] 已关闭`));
|
|
107
|
-
const id = this.nextId++;
|
|
108
|
-
const payload = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
|
|
109
|
-
return new Promise((resolve, reject) => {
|
|
110
|
-
const timer = setTimeout(() => {
|
|
111
|
-
this.pending.delete(id);
|
|
112
|
-
reject(new Error(`MCP[${this.name}] ${method} 超时`));
|
|
113
|
-
}, timeoutMs);
|
|
114
|
-
this.pending.set(id, { resolve, reject, timer });
|
|
115
|
-
try {
|
|
116
|
-
this.child.stdin.write(payload);
|
|
117
|
-
} catch (e) {
|
|
118
|
-
clearTimeout(timer);
|
|
119
|
-
this.pending.delete(id);
|
|
120
|
-
reject(e);
|
|
121
|
-
}
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
_notify(method, params) {
|
|
126
|
-
try {
|
|
127
|
-
this.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
|
|
128
|
-
} catch {
|
|
129
|
-
/* ignore */
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
_failAll(err) {
|
|
134
|
-
for (const { reject, timer } of this.pending.values()) {
|
|
135
|
-
clearTimeout(timer);
|
|
136
|
-
reject(err);
|
|
137
|
-
}
|
|
138
|
-
this.pending.clear();
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** 规整为合法 OpenAI 函数名。 */
|
|
143
|
-
function sanitize(name) {
|
|
144
|
-
const s = String(name).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
145
|
-
return s.length > 60 ? s.slice(0, 60) : s;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function readConfig() {
|
|
149
|
-
try {
|
|
150
|
-
const j = JSON.parse(fs.readFileSync(MCP_CONFIG_PATH, 'utf8'));
|
|
151
|
-
return j?.mcpServers && typeof j.mcpServers === 'object' ? j.mcpServers : {};
|
|
152
|
-
} catch {
|
|
153
|
-
return {};
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* 启动所有已配置的 MCP 服务并汇集其工具。
|
|
159
|
-
* @returns {Promise<{tools:{spec:object,run:Function,readOnly:boolean}[], clients:McpStdioClient[]}>}
|
|
160
|
-
*/
|
|
161
|
-
export async function loadMcpTools() {
|
|
162
|
-
const servers = readConfig();
|
|
163
|
-
const names = Object.keys(servers);
|
|
164
|
-
const tools = [];
|
|
165
|
-
const clients = [];
|
|
166
|
-
if (names.length === 0) return { tools, clients };
|
|
167
|
-
|
|
168
|
-
for (const name of names) {
|
|
169
|
-
const spec = servers[name] || {};
|
|
170
|
-
if (!spec.command) continue;
|
|
171
|
-
const client = new McpStdioClient(name, spec);
|
|
172
|
-
try {
|
|
173
|
-
await client.start();
|
|
174
|
-
const mcpTools = await client.listTools();
|
|
175
|
-
clients.push(client);
|
|
176
|
-
for (const t of mcpTools) {
|
|
177
|
-
const realName = t.name;
|
|
178
|
-
const fnName = sanitize(`mcp__${name}__${realName}`);
|
|
179
|
-
const readOnly = t.annotations?.readOnlyHint === true;
|
|
180
|
-
tools.push({
|
|
181
|
-
readOnly,
|
|
182
|
-
spec: {
|
|
183
|
-
type: 'function',
|
|
184
|
-
function: {
|
|
185
|
-
name: fnName,
|
|
186
|
-
description: `[MCP:${name}] ${t.description || realName}`,
|
|
187
|
-
parameters: t.inputSchema && typeof t.inputSchema === 'object'
|
|
188
|
-
? t.inputSchema
|
|
189
|
-
: { type: 'object', properties: {} },
|
|
190
|
-
},
|
|
191
|
-
},
|
|
192
|
-
run: (args) => client.callTool(realName, args),
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
process.stdout.write(c.gray(` · MCP[${name}] 已连接,工具 ${mcpTools.length} 个`) + '\n');
|
|
196
|
-
} catch (e) {
|
|
197
|
-
client.close();
|
|
198
|
-
process.stdout.write(c.yellow(` · MCP[${name}] 加载失败,已跳过:${e.message}`) + '\n');
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
return { tools, clients };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** 关闭全部 MCP 子进程。 */
|
|
205
|
-
export function closeMcp(clients) {
|
|
206
|
-
for (const c2 of clients || []) c2.close();
|
|
207
|
-
}
|
|
1
|
+
import t from"node:fs";import e from"node:path";import{spawn as i}from"node:child_process";import{CONFIG_DIR as s}from"./config.js";import{c as n}from"./colors.js";export const MCP_CONFIG_PATH=e.join(s,"mcp.json");class o{constructor(t,e){this.name=t,this.spec=e,this.child=null,this.buffer="",this.nextId=1,this.pending=new Map,this.closed=!1}async start(){const t={...process.env,...this.spec.env||{}};this.child=i(this.spec.command,this.spec.args||[],{env:t,stdio:["pipe","pipe","pipe"]}),this.child.on("error",t=>this._failAll(new Error(`MCP[${this.name}] 进程错误:${t.message}`))),this.child.on("exit",()=>{this.closed=!0,this._failAll(new Error(`MCP[${this.name}] 进程已退出`))}),this.child.stdout.setEncoding("utf8"),this.child.stdout.on("data",t=>this._onData(t)),this.child.stderr.on("data",()=>{}),await this._request("initialize",{protocolVersion:"2024-11-05",capabilities:{},clientInfo:{name:"huicai-cli",version:"0.2.0"}},2e4),this._notify("notifications/initialized",{})}async listTools(){const t=await this._request("tools/list",{},2e4);return Array.isArray(t?.tools)?t.tools:[]}async callTool(t,e){const i=await this._request("tools/call",{name:t,arguments:e||{}},12e4),s=(Array.isArray(i?.content)?i.content:[]).map(t=>"text"===t?.type?t.text:t?.text||JSON.stringify(t)).filter(Boolean).join("\n");return i?.isError?`工具报错:${s||"(无详情)"}`:s||"(无输出)"}close(){this.closed=!0;try{this.child?.kill("SIGTERM")}catch{}}_onData(t){let e;for(this.buffer+=t;(e=this.buffer.indexOf("\n"))>=0;){const t=this.buffer.slice(0,e).trim();if(this.buffer=this.buffer.slice(e+1),!t)continue;let i;try{i=JSON.parse(t)}catch{continue}if(null!=i.id&&this.pending.has(i.id)){const{resolve:t,reject:e,timer:s}=this.pending.get(i.id);clearTimeout(s),this.pending.delete(i.id),i.error?e(new Error(i.error.message||"MCP 错误")):t(i.result)}}}_request(t,e,i){if(this.closed)return Promise.reject(new Error(`MCP[${this.name}] 已关闭`));const s=this.nextId++,n=JSON.stringify({jsonrpc:"2.0",id:s,method:t,params:e})+"\n";return new Promise((e,o)=>{const r=setTimeout(()=>{this.pending.delete(s),o(new Error(`MCP[${this.name}] ${t} 超时`))},i);this.pending.set(s,{resolve:e,reject:o,timer:r});try{this.child.stdin.write(n)}catch(t){clearTimeout(r),this.pending.delete(s),o(t)}})}_notify(t,e){try{this.child.stdin.write(JSON.stringify({jsonrpc:"2.0",method:t,params:e})+"\n")}catch{}}_failAll(t){for(const{reject:e,timer:i}of this.pending.values())clearTimeout(i),e(t);this.pending.clear()}}function r(t){const e=String(t).replace(/[^a-zA-Z0-9_-]/g,"_");return e.length>60?e.slice(0,60):e}export async function loadMcpTools(){const e=function(){try{const e=JSON.parse(t.readFileSync(MCP_CONFIG_PATH,"utf8"));return e?.mcpServers&&"object"==typeof e.mcpServers?e.mcpServers:{}}catch{return{}}}(),i=Object.keys(e),s=[],c=[];if(0===i.length)return{tools:s,clients:c};for(const t of i){const i=e[t]||{};if(!i.command)continue;const a=new o(t,i);try{await a.start();const e=await a.listTools();c.push(a);for(const i of e){const e=i.name,n=r(`mcp__${t}__${e}`),o=!0===i.annotations?.readOnlyHint;s.push({readOnly:o,spec:{type:"function",function:{name:n,description:`[MCP:${t}] ${i.description||e}`,parameters:i.inputSchema&&"object"==typeof i.inputSchema?i.inputSchema:{type:"object",properties:{}}}},run:t=>a.callTool(e,t)})}process.stdout.write(n.gray(` · MCP[${t}] 已连接,工具 ${e.length} 个`)+"\n")}catch(e){a.close(),process.stdout.write(n.yellow(` · MCP[${t}] 加载失败,已跳过:${e.message}`)+"\n")}}return{tools:s,clients:c}}export function closeMcp(t){for(const e of t||[])e.close()}
|
package/src/memory.js
CHANGED
|
@@ -1,110 +1 @@
|
|
|
1
|
-
|
|
2
|
-
// 模型经内置 memory_write 工具主动记录「值得跨会话记住的事实」(用户偏好、项目结论、约定等),
|
|
3
|
-
// 每次新会话由 context.js 注入系统提示词——即「长期记忆」;「短期记忆」= 会话消息 + 非破坏式上下文压缩摘要。
|
|
4
|
-
// 开关:config.memoryEnabled(默认开,配置 memory:false 或环境变量 HUICAI_MEMORY=0 关闭)。
|
|
5
|
-
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import path from 'node:path';
|
|
8
|
-
import crypto from 'node:crypto';
|
|
9
|
-
import { CONFIG_DIR } from './config.js';
|
|
10
|
-
|
|
11
|
-
export const MEMORY_DIR = path.join(CONFIG_DIR, 'memory');
|
|
12
|
-
const MAX_MEMORY_CHARS = 6000; // 注入系统提示词的上限(超出保留最新条目)
|
|
13
|
-
const MAX_ENTRY_CHARS = 1000;
|
|
14
|
-
|
|
15
|
-
export function memoryPath(cwd) {
|
|
16
|
-
const h = crypto.createHash('sha1').update(String(cwd || '')).digest('hex').slice(0, 12);
|
|
17
|
-
return path.join(MEMORY_DIR, h + '.md');
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** 读取该项目的长期记忆(超限保尾部=最新条目)。无则空串。 */
|
|
21
|
-
export function loadMemory(cwd) {
|
|
22
|
-
try {
|
|
23
|
-
const s = fs.readFileSync(memoryPath(cwd), 'utf8');
|
|
24
|
-
if (s.length <= MAX_MEMORY_CHARS) return s.trim();
|
|
25
|
-
const tail = s.slice(-MAX_MEMORY_CHARS);
|
|
26
|
-
const nl = tail.indexOf('\n');
|
|
27
|
-
return ('…(较早记忆已省略)\n' + (nl >= 0 ? tail.slice(nl + 1) : tail)).trim();
|
|
28
|
-
} catch {
|
|
29
|
-
return '';
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** 追加一条记忆(带日期,best-effort)。返回是否成功。 */
|
|
34
|
-
export function appendMemory(cwd, content) {
|
|
35
|
-
const t = String(content || '').trim();
|
|
36
|
-
if (!t) return false;
|
|
37
|
-
try {
|
|
38
|
-
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|
39
|
-
const entry = `- [${new Date().toISOString().slice(0, 10)}] ${t.slice(0, MAX_ENTRY_CHARS)}\n`;
|
|
40
|
-
const p = memoryPath(cwd);
|
|
41
|
-
if (!fs.existsSync(p)) {
|
|
42
|
-
fs.writeFileSync(p, `# 长期记忆(${cwd})\n\n${entry}`, { mode: 0o600 });
|
|
43
|
-
} else {
|
|
44
|
-
fs.appendFileSync(p, entry);
|
|
45
|
-
}
|
|
46
|
-
return true;
|
|
47
|
-
} catch {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** 整体重写记忆(模型做记忆整理时用)。 */
|
|
53
|
-
export function replaceMemory(cwd, content) {
|
|
54
|
-
try {
|
|
55
|
-
fs.mkdirSync(MEMORY_DIR, { recursive: true });
|
|
56
|
-
fs.writeFileSync(memoryPath(cwd), String(content || '').slice(0, MAX_MEMORY_CHARS * 2), { mode: 0o600 });
|
|
57
|
-
return true;
|
|
58
|
-
} catch {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function clearMemory(cwd) {
|
|
64
|
-
try {
|
|
65
|
-
fs.unlinkSync(memoryPath(cwd));
|
|
66
|
-
return true;
|
|
67
|
-
} catch {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* 构造 memory_write 内置工具(顶层注入;记忆是机制本身,自动放行)。
|
|
74
|
-
* @param {{cwd:string, emit?:(text:string)=>void}} p
|
|
75
|
-
*/
|
|
76
|
-
export function makeMemoryTool({ cwd, emit }) {
|
|
77
|
-
const spec = {
|
|
78
|
-
type: 'function',
|
|
79
|
-
function: {
|
|
80
|
-
name: 'memory_write',
|
|
81
|
-
description:
|
|
82
|
-
'把值得跨会话记住的信息写入本项目的长期记忆(用户偏好、项目关键事实/约定、踩坑结论等)。'
|
|
83
|
-
+ '新会话会自动注入这些记忆。仅记「以后仍然有用」的稳定信息;不要记一次性细节或大段代码。'
|
|
84
|
-
+ 'mode=append 追加一条(默认);mode=replace 用整理后的全文替换(做记忆瘦身时用)。',
|
|
85
|
-
parameters: {
|
|
86
|
-
type: 'object',
|
|
87
|
-
properties: {
|
|
88
|
-
content: { type: 'string', description: '要记住的内容(简洁的一条事实/偏好/结论)' },
|
|
89
|
-
mode: { type: 'string', enum: ['append', 'replace'], description: '追加或整体替换(默认 append)' },
|
|
90
|
-
},
|
|
91
|
-
required: ['content'],
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
};
|
|
95
|
-
const run = async (args) => {
|
|
96
|
-
const content = String(args?.content || '').trim();
|
|
97
|
-
if (!content) return 'memory_write 需要非空 content';
|
|
98
|
-
const ok = args?.mode === 'replace' ? replaceMemory(cwd, content) : appendMemory(cwd, content);
|
|
99
|
-
if (ok) {
|
|
100
|
-
try {
|
|
101
|
-
emit?.(content);
|
|
102
|
-
} catch {
|
|
103
|
-
/* ignore */
|
|
104
|
-
}
|
|
105
|
-
return `已写入长期记忆(${args?.mode === 'replace' ? '整体替换' : '追加'})。`;
|
|
106
|
-
}
|
|
107
|
-
return '长期记忆写入失败(磁盘不可写?),可忽略并继续任务。';
|
|
108
|
-
};
|
|
109
|
-
return { readOnly: true, spec, run };
|
|
110
|
-
}
|
|
1
|
+
import e from"node:fs";import r from"node:path";import t from"node:crypto";import{CONFIG_DIR as n}from"./config.js";import{tokenize as o}from"./codeindex.js";export const MEMORY_DIR=r.join(n,"memory");const i=6e3;export function memoryPath(e){const n=t.createHash("sha1").update(String(e||"")).digest("hex").slice(0,12);return r.join(MEMORY_DIR,n+".md")}export function loadMemory(r){try{const t=e.readFileSync(memoryPath(r),"utf8");if(t.length<=i)return t.trim();const n=t.slice(-6e3),o=n.indexOf("\n");return("…(较早记忆已省略)\n"+(o>=0?n.slice(o+1):n)).trim()}catch{return""}}export function appendMemory(r,t){const n=String(t||"").trim();if(!n)return!1;try{e.mkdirSync(MEMORY_DIR,{recursive:!0});const t=`- [${(new Date).toISOString().slice(0,10)}] ${n.slice(0,1e3)}\n`,o=memoryPath(r);return e.existsSync(o)?e.appendFileSync(o,t):e.writeFileSync(o,`# 长期记忆(${r})\n\n${t}`,{mode:384}),!0}catch{return!1}}export function replaceMemory(r,t){try{return e.mkdirSync(MEMORY_DIR,{recursive:!0}),e.writeFileSync(memoryPath(r),String(t||"").slice(0,12e3),{mode:384}),!0}catch{return!1}}export function clearMemory(r){try{return e.unlinkSync(memoryPath(r)),!0}catch{return!1}}export function readMemoryRaw(r){try{return e.readFileSync(memoryPath(r),"utf8")}catch{return""}}export function makeRecallTool({cwd:e}){return{readOnly:!0,spec:{type:"function",function:{name:"recall_memory",description:"在本项目的长期记忆里按关键词检索相关的过往结论/踩坑/约定(连超出常驻注入预算的历史也能查到)。开始一个任务前,若怀疑「以前处理过类似问题」,可先 recall 一下,少走弯路。",parameters:{type:"object",properties:{query:{type:"string",description:"关键词/概念"}},required:["query"]}}},run:async r=>{const t=String(r?.query||"").trim();if(!t)return"缺少 query";const n=readMemoryRaw(e).split("\n").map(e=>e.trim()).filter(e=>e.startsWith("- "));if(!n.length)return"本项目暂无可检索的长期记忆。";const i=new Set(o(t));if(!i.size)return"查询词无有效 token。";const c=n.map(e=>{let r=0;for(const t of new Set(o(e)))i.has(t)&&r++;return{e:e,s:r}}).filter(e=>e.s>0).sort((e,r)=>r.s-e.s).slice(0,6);return c.length?"相关的历史记忆:\n"+c.map(e=>e.e).join("\n"):`未在长期记忆中找到与「${t}」相关的条目。`}}}export function makeMemoryTool({cwd:e,emit:r}){return{readOnly:!0,spec:{type:"function",function:{name:"memory_write",description:"把值得跨会话记住的信息写入本项目的长期记忆(用户偏好、项目关键事实/约定、踩坑结论等)。新会话会自动注入这些记忆。仅记「以后仍然有用」的稳定信息;不要记一次性细节或大段代码。mode=append 追加一条(默认);mode=replace 用整理后的全文替换(做记忆瘦身时用)。",parameters:{type:"object",properties:{content:{type:"string",description:"要记住的内容(简洁的一条事实/偏好/结论)"},mode:{type:"string",enum:["append","replace"],description:"追加或整体替换(默认 append)"}},required:["content"]}}},run:async t=>{const n=String(t?.content||"").trim();if(!n)return"memory_write 需要非空 content";if("replace"===t?.mode?replaceMemory(e,n):appendMemory(e,n)){try{r?.(n)}catch{}return`已写入长期记忆(${"replace"===t?.mode?"整体替换":"追加"})。`}return"长期记忆写入失败(磁盘不可写?),可忽略并继续任务。"}}}
|