@wonanut/dsh-agent-lab 0.0.0-stage → 0.2.0

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/src/00-core.js ADDED
@@ -0,0 +1,209 @@
1
+ /* ── 00-core.js · 常量、token 估算与通用工具 ──
2
+ *
3
+ * 本文件是普通脚本,会被 build.mjs 按文件名顺序拼进同一个 Client factory
4
+ * 作用域。这里只放常量与纯函数:
5
+ * - 不访问 document / window;
6
+ * - 不依赖 React;
7
+ * - 唯一的外部交互是 globalThis.localStorage(已用 try/catch 保护,见 10-engine)。
8
+ */
9
+
10
+ /* ── 上下文预算与会话限制(契约 v1 数字,不要随意改) ── */
11
+ const LAB_BUDGET = 1000; /* 教学上下文预算默认值(可在页面中调整;估算 token) */
12
+ const LAB_RECENT_KEEP = 4; /* 压缩时保留的最近消息条数 */
13
+ const LAB_MAX_STEPS = 4; /* 单轮最多模型步数 */
14
+ const LAB_STORAGE_KEY = 'dsh.agent-lab.v1';
15
+
16
+ /* ── 内部容量上限 ── */
17
+ const LAB_MAX_SESSIONS = 20; /* 最多保留 20 个会话 */
18
+ const LAB_MAX_MESSAGES = 120; /* 每会话消息上限 */
19
+ const LAB_MAX_TRANSCRIPT = 200; /* 每会话 transcript 行数上限 */
20
+ const LAB_MEMORY_LIMIT = 60; /* 长期记忆条数上限 */
21
+ const LAB_MAX_SUMMARY_LINES = 30; /* 摘要行数上限(累计式压缩) */
22
+ const LAB_MAX_INPUT_CHARS = 600; /* 单条输入最多保留的字符数 */
23
+ const LAB_COMPRESS_RATIO = 0.9; /* 超过 limit * 0.9 时触发自动压缩 */
24
+ const LAB_COMPRESS_TARGET_RATIO = 0.88; /* 压缩后尽量回到 limit * 0.88 以下,避免连续触发 */
25
+ const LAB_SUMMARY_TOKEN_RATIO = 0.16; /* 摘要最多占预算的 16%(累计式但按 token 裁剪) */
26
+ const LAB_MSG_OVERHEAD = 4; /* 每条消息在上下文里额外占用的估算 token(角色/分隔) */
27
+
28
+ /* ── pacing:模型的流式节奏(毫秒)与阶段停顿 ── */
29
+ const LAB_PACE_NAMES = ['fast', 'normal', 'slow'];
30
+ const LAB_PACE_DELAYS = { fast: 3, normal: 14, slow: 30 };
31
+ const LAB_STAGE_DELAYS = { fast: 6, normal: 40, slow: 90 };
32
+
33
+ /* ── 首次运行 / 新会话的首行 banner ── */
34
+ const LAB_BANNER = 'Agent Lab v0.1 · 模拟 / 真实 DeepSeek 双模式 · 输入 /help 查看命令';
35
+
36
+ /* ── 迷你 Agent 的 system prompt(中文,约 105 tok,留足上下文预算) ── */
37
+ const LAB_SYSTEM_PROMPT =
38
+ '你是「Agent 实验室」的教学用迷你 Agent,由规则驱动的模拟模型扮演:不联网、可复现。' +
39
+ '你演示最小 agent 循环——理解输入、组装上下文、决定回答或调用工具、读取真实工具结果、' +
40
+ '再继续下一步,直到给出最终回答。请用简体中文简洁回答。';
41
+
42
+ /* ─────────────────────────────────────────────────────────────
43
+ * token 估算
44
+ * ─────────────────────────────────────────────────────────────
45
+ * 规则(契约):CJK 约 1 token/字,其他约 4 字符/token,向上取整。
46
+ * 用码点遍历,避免把 emoji 的代理对拆开。
47
+ */
48
+ function estimateTokens(text) {
49
+ if (text === null || text === undefined) return 0;
50
+ const s = String(text);
51
+ if (!s) return 0;
52
+ let cjk = 0;
53
+ let other = 0;
54
+ for (const ch of s) {
55
+ if (labIsCjkChar(ch)) cjk += 1;
56
+ else other += 1;
57
+ }
58
+ return Math.ceil(cjk + other / 4);
59
+ }
60
+
61
+ /* CJK / 全角标点按 1 字 ≈ 1 token 估算 */
62
+ function labIsCjkChar(ch) {
63
+ const code = ch.codePointAt(0);
64
+ if (code >= 0x2e80 && code <= 0x9fff) return true; /* CJK 部首、汉字、假名、CJK 标点 */
65
+ if (code >= 0xf900 && code <= 0xfaff) return true; /* CJK 兼容汉字 */
66
+ if (code >= 0xff00 && code <= 0xffef) return true; /* 全角字符与标点 */
67
+ return false;
68
+ }
69
+
70
+ /* 字符数(按码点计,用于输入阶段的 meta) */
71
+ function labCharCount(text) {
72
+ if (text === null || text === undefined) return 0;
73
+ let n = 0;
74
+ for (const ch of String(text)) n += 1;
75
+ return n;
76
+ }
77
+
78
+ /* ─────────────────────────────────────────────────────────────
79
+ * 时间格式化
80
+ * ─────────────────────────────────────────────────────────────
81
+ */
82
+ function labPad2(n) {
83
+ return n < 10 ? '0' + n : String(n);
84
+ }
85
+
86
+ /* HH:MM:SS */
87
+ function fmtClock(ts) {
88
+ const d = new Date(typeof ts === 'number' ? ts : Date.now());
89
+ if (isNaN(d.getTime())) return '--:--:--';
90
+ return labPad2(d.getHours()) + ':' + labPad2(d.getMinutes()) + ':' + labPad2(d.getSeconds());
91
+ }
92
+
93
+ /* YYYY-MM-DD HH:MM */
94
+ function fmtTime(ts) {
95
+ const d = new Date(typeof ts === 'number' ? ts : Date.now());
96
+ if (isNaN(d.getTime())) return '----/--/-- --:--';
97
+ return (
98
+ d.getFullYear() +
99
+ '-' +
100
+ labPad2(d.getMonth() + 1) +
101
+ '-' +
102
+ labPad2(d.getDate()) +
103
+ ' ' +
104
+ labPad2(d.getHours()) +
105
+ ':' +
106
+ labPad2(d.getMinutes())
107
+ );
108
+ }
109
+
110
+ /* ─────────────────────────────────────────────────────────────
111
+ * 文本工具
112
+ * ─────────────────────────────────────────────────────────────
113
+ * truncate(text, n):最多 n 个字符(含省略号),按码点裁剪。
114
+ */
115
+ function truncate(text, n) {
116
+ const s = text === null || text === undefined ? '' : String(text);
117
+ if (typeof n !== 'number' || n <= 0) return '';
118
+ const chars = Array.from(s);
119
+ if (chars.length <= n) return chars.join('');
120
+ if (n <= 1) return '…';
121
+ return chars.slice(0, n - 1).join('') + '…';
122
+ }
123
+
124
+ /* 折叠空白并裁剪到最多 max 个字符;用于输入/记忆等用户文本 */
125
+ function labCleanText(text, max) {
126
+ const s = (text === null || text === undefined ? '' : String(text)).replace(/\s+/g, ' ').trim();
127
+ if (typeof max === 'number' && max > 0) return truncate(s, max);
128
+ return s;
129
+ }
130
+
131
+ /* 按 token 预算裁剪文本:保证 estimateTokens(结果) <= maxTokens */
132
+ function labTruncateToTokens(text, maxTokens) {
133
+ const s = text === null || text === undefined ? '' : String(text);
134
+ if (typeof maxTokens !== 'number' || maxTokens <= 0) return '';
135
+ if (estimateTokens(s) <= maxTokens) return s;
136
+ const chars = Array.from(s);
137
+ let lo = 0;
138
+ let hi = chars.length;
139
+ while (lo < hi) {
140
+ const mid = Math.ceil((lo + hi) / 2);
141
+ const candidate = chars.slice(0, mid).join('') + '…';
142
+ if (estimateTokens(candidate) <= maxTokens) lo = mid;
143
+ else hi = mid - 1;
144
+ }
145
+ return lo > 0 ? chars.slice(0, lo).join('') + '…' : '';
146
+ }
147
+
148
+ /* ─────────────────────────────────────────────────────────────
149
+ * id 生成(可用 options.id 覆盖,便于测试/可复现)
150
+ * ─────────────────────────────────────────────────────────────
151
+ */
152
+ let LAB_UID_SEQ = 0;
153
+
154
+ function uid(prefix) {
155
+ LAB_UID_SEQ += 1;
156
+ const tail = Math.floor(Math.random() * 0x1000000)
157
+ .toString(36)
158
+ .slice(-4);
159
+ return String(prefix || 'id') + '_' + LAB_UID_SEQ.toString(36) + tail;
160
+ }
161
+
162
+ /* ─────────────────────────────────────────────────────────────
163
+ * 其他纯工具
164
+ * ─────────────────────────────────────────────────────────────
165
+ */
166
+
167
+ /* 可 JSON 序列化的深拷贝;只用于纯数据(transcript detail / snapshot) */
168
+ function labClone(value) {
169
+ if (value === null || typeof value !== 'object') return value;
170
+ if (Array.isArray(value)) {
171
+ const list = [];
172
+ for (let i = 0; i < value.length; i += 1) list.push(labClone(value[i]));
173
+ return list;
174
+ }
175
+ const out = {};
176
+ const keys = Object.keys(value);
177
+ for (let i = 0; i < keys.length; i += 1) {
178
+ const key = keys[i];
179
+ out[key] = labClone(value[key]);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ /* JSON.stringify 的安全版本(工具参数展示用) */
185
+ function labJson(value) {
186
+ try {
187
+ const text = JSON.stringify(value);
188
+ return typeof text === 'string' ? text : '{}';
189
+ } catch (err) {
190
+ return '{}';
191
+ }
192
+ }
193
+
194
+ /* 把任意异常转成一行中文可读的错误信息 */
195
+ function labErrorMessage(err) {
196
+ if (err && typeof err.message === 'string' && err.message) return err.message;
197
+ if (typeof err === 'string' && err) return err;
198
+ return '未知错误';
199
+ }
200
+
201
+ /* pace 归一化 */
202
+ function labNormalizePace(pace) {
203
+ return LAB_PACE_NAMES.indexOf(pace) >= 0 ? pace : 'normal';
204
+ }
205
+
206
+ /* 阶段停顿(毫秒);测试注入 no-op sleep 后不影响速度 */
207
+ function labStageDelayMs(pace) {
208
+ return LAB_STAGE_DELAYS[labNormalizePace(pace)] || LAB_STAGE_DELAYS.normal;
209
+ }