pigpig-agent 1.0.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.
Files changed (64) hide show
  1. package/.skills/code-review/SKILL.md +42 -0
  2. package/dist/agent/loop-detection.js +135 -0
  3. package/dist/agent/loop.js +123 -0
  4. package/dist/agent/retry.js +39 -0
  5. package/dist/agents/registry.js +59 -0
  6. package/dist/agents/spawn.js +124 -0
  7. package/dist/agents/types.js +5 -0
  8. package/dist/commands/agent.js +33 -0
  9. package/dist/commands/context.js +27 -0
  10. package/dist/commands/cron.js +38 -0
  11. package/dist/commands/debug.js +44 -0
  12. package/dist/commands/dream.js +36 -0
  13. package/dist/commands/index.js +10 -0
  14. package/dist/commands/memory.js +40 -0
  15. package/dist/commands/plugin.js +73 -0
  16. package/dist/commands/rag.js +25 -0
  17. package/dist/commands/security.js +44 -0
  18. package/dist/commands/skill.js +84 -0
  19. package/dist/config/init.js +69 -0
  20. package/dist/config/loader.js +52 -0
  21. package/dist/config/schema.js +55 -0
  22. package/dist/context/compressor.js +165 -0
  23. package/dist/context/defense.js +201 -0
  24. package/dist/context/prompt-builder.js +56 -0
  25. package/dist/context/prompt-pipes.js +14 -0
  26. package/dist/context/tool-result-output.js +25 -0
  27. package/dist/context/view.js +185 -0
  28. package/dist/cron/parser.js +27 -0
  29. package/dist/cron/service.js +211 -0
  30. package/dist/cron/store.js +53 -0
  31. package/dist/cron/types.js +1 -0
  32. package/dist/index.js +9 -0
  33. package/dist/main.js +270 -0
  34. package/dist/memory/store.js +175 -0
  35. package/dist/memory/validator.js +62 -0
  36. package/dist/mock-model.js +534 -0
  37. package/dist/plugins/manager.js +97 -0
  38. package/dist/plugins/supabase-plugin.js +111 -0
  39. package/dist/plugins/types.js +1 -0
  40. package/dist/rag/chunker.js +54 -0
  41. package/dist/rag/embedder.js +53 -0
  42. package/dist/rag/search.js +123 -0
  43. package/dist/rag/sqlite-store.js +195 -0
  44. package/dist/rag/store.js +29 -0
  45. package/dist/security/bash-classifier.js +39 -0
  46. package/dist/security/hook.js +53 -0
  47. package/dist/security/roles.js +16 -0
  48. package/dist/session/store.js +60 -0
  49. package/dist/skills/loader.js +100 -0
  50. package/dist/tools/cron-tools.js +94 -0
  51. package/dist/tools/file-tools.js +115 -0
  52. package/dist/tools/index.js +17 -0
  53. package/dist/tools/mcp-client.js +100 -0
  54. package/dist/tools/memory-tools.js +81 -0
  55. package/dist/tools/rag-tools.js +54 -0
  56. package/dist/tools/registry.js +270 -0
  57. package/dist/tools/search-tools.js +116 -0
  58. package/dist/tools/shell-tools.js +39 -0
  59. package/dist/tools/spawn-tools.js +35 -0
  60. package/dist/tools/tool-search.js +17 -0
  61. package/dist/tools/web-search.js +150 -0
  62. package/dist/usage/tracker.js +83 -0
  63. package/package.json +55 -0
  64. package/readme.md +131 -0
@@ -0,0 +1,201 @@
1
+ import { toolResultOutputToText, textToolResultOutput } from './tool-result-output.js';
2
+ export class TokenTracker {
3
+ lastPreciseCount = 0; // 上一次API返回的token用量
4
+ pendingChars = 0; // 新增消息的字符数
5
+ updateFromAPI(promptTokens) {
6
+ this.lastPreciseCount = promptTokens;
7
+ this.pendingChars = 0; // 重置
8
+ }
9
+ addMessage(message) {
10
+ this.pendingChars += countMessageChars(message);
11
+ }
12
+ addMessages(messages) {
13
+ for (const message of messages) {
14
+ this.addMessage(message);
15
+ }
16
+ }
17
+ replaceMessages(before, after) {
18
+ this.pendingChars += countMessagesChars(after) - countMessagesChars(before);
19
+ }
20
+ get estimateTokens() {
21
+ return Math.max(0, this.lastPreciseCount + Math.ceil(this.pendingChars / 4));
22
+ }
23
+ get status() {
24
+ const tokens = this.estimateTokens;
25
+ const percent = Math.round((tokens / CONTEXT_WINDOW) * 100);
26
+ return {
27
+ tokens,
28
+ percent,
29
+ needsAction: percent >= 75,
30
+ };
31
+ }
32
+ }
33
+ const CONTEXT_WINDOW = 200_000;
34
+ function countMessageChars(message) {
35
+ let chars = 0;
36
+ if (typeof message.content === 'string') {
37
+ return message.content.length;
38
+ }
39
+ if (!Array.isArray(message.content))
40
+ return chars;
41
+ for (const part of message.content) {
42
+ if ('text' in part && typeof part.text === 'string') {
43
+ chars += part.text.length;
44
+ }
45
+ else if ('output' in part) {
46
+ chars += toolResultOutputToText(part.output).length;
47
+ }
48
+ else if ('input' in part) {
49
+ chars += JSON.stringify(part.input)?.length || 0;
50
+ }
51
+ }
52
+ return chars;
53
+ }
54
+ function countMessagesChars(messages) {
55
+ let chars = 0;
56
+ for (const message of messages) {
57
+ chars += countMessageChars(message);
58
+ }
59
+ return chars;
60
+ }
61
+ export function estimateMessageTokens(messages) {
62
+ const chars = countMessagesChars(messages); // 所有消息的字符数
63
+ return Math.ceil((chars / 4) * 1.2); // 1.2x 中文安全系数
64
+ }
65
+ // 2 ----------------------------------
66
+ export function truncateToolResult(messages, config = {
67
+ maxSingleResult: CONTEXT_WINDOW * 0.5 * 2, // 50%窗口,2倍安全系数
68
+ contextBudgetChars: CONTEXT_WINDOW * 0.75 * 4, // 75%窗口,4倍安全系数
69
+ }) {
70
+ let truncated = 0;
71
+ let compacted = 0;
72
+ // 1. 单条截断 -- 超过窗口50%的工具结果做 head/tail 分割
73
+ let result = messages.map(msg => {
74
+ if (msg.role !== 'tool' || !Array.isArray(msg.content))
75
+ return msg;
76
+ const newContent = msg.content.map((part) => {
77
+ if (!part.output)
78
+ return part;
79
+ const outputText = toolResultOutputToText(part.output);
80
+ if (outputText.length <= config.maxSingleResult)
81
+ return part;
82
+ truncated++;
83
+ const maxChars = config.maxSingleResult;
84
+ const headSize = Math.floor(maxChars * 0.6);
85
+ const tailSize = Math.floor(maxChars * 0.4);
86
+ const head = outputText.slice(0, headSize);
87
+ const tail = outputText.slice(-tailSize);
88
+ return {
89
+ ...part,
90
+ output: textToolResultOutput(`${head}\n\n[truncated: ${outputText.length} → ${maxChars} chars]\n\n${tail}`),
91
+ };
92
+ });
93
+ return { ...msg, content: newContent };
94
+ });
95
+ // 2. 总上下文超过窗口75%, 从最老的 tool result 开始清理 (将 output 替换为 占位符)
96
+ let totalChars = result.reduce((sum, msg) => {
97
+ if (typeof msg.content === 'string')
98
+ return sum + msg.content.length;
99
+ if (Array.isArray(msg.content)) {
100
+ return sum + msg.content.reduce((s, p) => s + (p.output ? toolResultOutputToText(p.output).length : p.text?.length || 0), 0);
101
+ }
102
+ return sum;
103
+ }, 0);
104
+ if (totalChars > config.contextBudgetChars) {
105
+ for (let i = 0; i < result.length && totalChars > config.contextBudgetChars; i++) {
106
+ const msg = result[i];
107
+ if (msg.role !== 'tool' || !Array.isArray(msg.content))
108
+ continue;
109
+ const toolName = (msg.content[0])?.toolName || 'unknown';
110
+ const oldSize = msg.content.reduce((s, p) => s + (p.output ? toolResultOutputToText(p.output).length : 0), 0);
111
+ result[i] = {
112
+ ...msg,
113
+ content: msg.content.map((p) => ({
114
+ ...p,
115
+ output: textToolResultOutput(`[compacted: ${toolName} output removed to free context]`),
116
+ })),
117
+ };
118
+ totalChars -= oldSize;
119
+ compacted++;
120
+ }
121
+ }
122
+ return { messages: result, truncated, compacted };
123
+ }
124
+ const DEFAULT_TTL = {
125
+ softTTLMs: 5 * 60 * 1000,
126
+ hardTTLMs: 10 * 60 * 1000,
127
+ keepHeadTail: 1500,
128
+ };
129
+ // 5分钟之前的工具结果做软修剪,10分钟之前的工具结果做硬修剪
130
+ export function ttlPrune(messages, timestamp, config = DEFAULT_TTL) {
131
+ const now = Date.now();
132
+ let softPruned = 0;
133
+ let hardPruned = 0;
134
+ const result = messages.map((msg, idx) => {
135
+ // 只修剪角色为 tool 的消息
136
+ if (msg.role !== 'tool' || !Array.isArray(msg.content))
137
+ return msg;
138
+ const ts = timestamp.get(idx); // 这条消息的 timestamp
139
+ if (!ts)
140
+ return msg;
141
+ const age = now - ts;
142
+ // 出错的工具调用也不修剪
143
+ const outputText = msg.content
144
+ .map((p) => p.output ? toolResultOutputToText(p.output) : '')
145
+ .join('');
146
+ const isError = /error|失败|不存在|denied|refused|timeout/i.test(outputText);
147
+ if (isError)
148
+ return msg;
149
+ // 10分钟之前....
150
+ if (age >= config.hardTTLMs) {
151
+ hardPruned++;
152
+ const toolName = msg.content[0]?.toolName || 'unknown';
153
+ return {
154
+ ...msg,
155
+ content: msg.content.map((p) => ({
156
+ ...p,
157
+ output: textToolResultOutput(`[ tool result expired: ${toolName} ]`),
158
+ })),
159
+ };
160
+ }
161
+ // 5分钟之前....
162
+ if (age >= config.softTTLMs) {
163
+ const newContent = msg.content.map((part) => {
164
+ if (!part.output)
165
+ return part;
166
+ const outputText = toolResultOutputToText(part.output);
167
+ if (outputText.length <= config.keepHeadTail * 2)
168
+ return part;
169
+ softPruned++;
170
+ const head = outputText.slice(0, config.keepHeadTail);
171
+ const tail = outputText.slice(-config.keepHeadTail);
172
+ const removed = outputText.length - config.keepHeadTail * 2;
173
+ return {
174
+ ...part,
175
+ output: textToolResultOutput(`${head}\n\n[soft pruned: ${removed} chars removed, content older than ${Math.round(config.softTTLMs / 60000)} min]\n\n${tail}`),
176
+ };
177
+ });
178
+ return { ...msg, content: newContent };
179
+ }
180
+ return msg;
181
+ });
182
+ return { messages: result, softPruned, hardPruned };
183
+ }
184
+ export function applyDefense(messages, timestamps) {
185
+ // Layer 2: truncate oversized tool results
186
+ const trunc = truncateToolResult(messages);
187
+ let result = trunc.messages;
188
+ // Layer 3: TTL prune old tool results
189
+ const prune = ttlPrune(result, timestamps);
190
+ result = prune.messages;
191
+ // Layer 1: estimate final token count
192
+ const tokenEstimate = estimateMessageTokens(result);
193
+ return {
194
+ messages: result,
195
+ tokenEstimate,
196
+ truncated: trunc.truncated,
197
+ compacted: trunc.compacted,
198
+ softPruned: prune.softPruned,
199
+ hardPruned: prune.hardPruned,
200
+ };
201
+ }
@@ -0,0 +1,56 @@
1
+ export class PromptBuilder {
2
+ pipes = []; // 存放所有需要的系统提示词
3
+ pipe(name, fn) {
4
+ this.pipes.push({ name, fn });
5
+ return this;
6
+ }
7
+ build(ctx) {
8
+ const sections = [];
9
+ for (const { fn } of this.pipes) {
10
+ const result = fn(ctx);
11
+ if (result !== null) {
12
+ sections.push(result);
13
+ }
14
+ }
15
+ return sections.join('\n\n');
16
+ }
17
+ debug(ctx) {
18
+ console.log(`\n=== Prompt Pipe Debug ===\n`);
19
+ for (const { name, fn } of this.pipes) {
20
+ const result = fn(ctx);
21
+ const status = result !== null ? `[ON] ${result.length} chars` : `[OFF]`;
22
+ console.log(`${name}: ${status}`);
23
+ }
24
+ console.log(`\n=== Prompt Debug End ===\n`);
25
+ }
26
+ }
27
+ // 预定义 pipe
28
+ export function coreRules() {
29
+ return (ctx) => `你是 PIGPIG Agent,一个有工具调用能力的 AI 助手。
30
+ 你的行为准则:
31
+ - 先读文件再修改,不要凭记忆编辑
32
+ - 不要加没被要求的功能
33
+ - 工具调用失败时,换一个思路而不是重复同样的操作
34
+ - 回答要简洁直接`;
35
+ }
36
+ export function toolGuide() {
37
+ return (ctx) => {
38
+ if (ctx.toolCount === 0)
39
+ return null;
40
+ return `你有 ${ctx.toolCount} 个工具可用。需要操作本地文件时使用内置工具,需要访问外部服务时使用 MCP 工具。`;
41
+ };
42
+ }
43
+ export function deferredTools() {
44
+ return (ctx) => {
45
+ if (!ctx.deferredToolSummary)
46
+ return null;
47
+ return `如果你需要的工具不在当前列表中,使用 tool_search 工具搜索。${ctx.deferredToolSummary}`;
48
+ };
49
+ }
50
+ export function sessionContext() {
51
+ return (ctx) => {
52
+ if (ctx.sessionMessageCount === 0)
53
+ return null;
54
+ return `[会话信息] 当前会话 ${ctx.sessionId},已有 ${ctx.sessionMessageCount} 条历史消息。`;
55
+ };
56
+ }
@@ -0,0 +1,14 @@
1
+ // .memory 记忆系统中的上下文
2
+ export function memoryContext(memoryStore) {
3
+ return () => memoryStore.buildPromptSection(); // 跟记忆系统相关的那一截提示词
4
+ }
5
+ // .rag RAG系统中的上下文
6
+ export function ragContext(vectorStore) {
7
+ return () => {
8
+ const size = vectorStore.size();
9
+ if (size === 0)
10
+ return null;
11
+ const sources = vectorStore.sources();
12
+ return `[知识库] 已导入 ${size} 条文档片段(来源:${sources.join(', ')})。使用 rag_search 工具搜索知识库。`;
13
+ };
14
+ }
@@ -0,0 +1,25 @@
1
+ export function textToolResultOutput(value) {
2
+ return {
3
+ type: 'text',
4
+ value: value,
5
+ };
6
+ }
7
+ export function toolResultOutputToText(output) {
8
+ switch (output.type) {
9
+ case 'text':
10
+ case 'error-text':
11
+ return output.value;
12
+ case 'json':
13
+ case 'error-json':
14
+ return JSON.stringify(output.value);
15
+ case 'content':
16
+ return output.value
17
+ .map(part => {
18
+ if (part.type === 'text')
19
+ return part.text;
20
+ const mediaType = 'mediaType' in part ? part.mediaType : undefined;
21
+ return `[media:${mediaType ?? part.type}]`;
22
+ })
23
+ .join('\n');
24
+ }
25
+ }
@@ -0,0 +1,185 @@
1
+ const COLORS = {
2
+ system: 63, // 紫
3
+ tools: 99, // 紫粉
4
+ memory: 220, // 黄
5
+ skills: 36, // 青
6
+ messages: 111, // 蓝
7
+ free: 240, // 灰(空格子)
8
+ buffer: 244, // 灰(autocompact buffer)
9
+ text: 255, // 白文字
10
+ dim: 244, // 暗灰
11
+ };
12
+ function fg(code, s) {
13
+ return `\x1b[38;5;${code}m${s}\x1b[0m`;
14
+ }
15
+ function pct(n, total) {
16
+ if (total === 0)
17
+ return '0.0%';
18
+ return `${((n / total) * 100).toFixed(1)}%`;
19
+ }
20
+ function fmtTokens(n) {
21
+ if (n >= 1_000_000)
22
+ return `${(n / 1_000_000).toFixed(1)}M`;
23
+ if (n >= 1000)
24
+ return `${(n / 1000).toFixed(1)}k`;
25
+ return String(n);
26
+ }
27
+ /**
28
+ * 画一个 16×16 = 256 格的矩阵,每格代表 window/256 个 tokens。
29
+ * 已用部分按 slices 顺序填彩色 ●,free 用 ○,autocompact buffer 用 ▢。
30
+ */
31
+ export function renderContextMatrix(snapshot) {
32
+ const { windowTokens, slices, autocompactBufferTokens } = snapshot;
33
+ const TOTAL_CELLS = 256;
34
+ const tokensPerCell = windowTokens / TOTAL_CELLS;
35
+ // 把每个 slice 的 token 数转成"格子数"(向上取整避免 0 格丢失)
36
+ const cells = []; // ANSI color for each cell, or -1 for free, -2 for buffer
37
+ let used = 0;
38
+ for (const s of slices) {
39
+ if (s.tokens <= 0)
40
+ continue;
41
+ const n = Math.max(1, Math.round(s.tokens / tokensPerCell));
42
+ for (let i = 0; i < n && cells.length < TOTAL_CELLS; i++) {
43
+ cells.push(s.color);
44
+ }
45
+ used += n;
46
+ }
47
+ const bufferCells = Math.max(0, Math.round(autocompactBufferTokens / tokensPerCell));
48
+ const freeCells = TOTAL_CELLS - cells.length - bufferCells;
49
+ for (let i = 0; i < freeCells; i++)
50
+ cells.push(-1);
51
+ for (let i = 0; i < bufferCells && cells.length < TOTAL_CELLS; i++)
52
+ cells.push(-2);
53
+ const lines = [];
54
+ for (let row = 0; row < 16; row++) {
55
+ const rowCells = [];
56
+ for (let col = 0; col < 16; col++) {
57
+ const idx = row * 16 + col;
58
+ const c = cells[idx];
59
+ if (c === -1)
60
+ rowCells.push(fg(COLORS.free, '○'));
61
+ else if (c === -2)
62
+ rowCells.push(fg(COLORS.buffer, '▢'));
63
+ else
64
+ rowCells.push(fg(c, '●'));
65
+ }
66
+ lines.push(rowCells.join(' '));
67
+ }
68
+ return lines.join('\n');
69
+ }
70
+ export function renderContextLegend(snapshot) {
71
+ const { slices, autocompactBufferTokens, windowTokens, usedTokens } = snapshot;
72
+ const lines = [];
73
+ lines.push(fg(COLORS.text, fg(255, '\x1b[1m') + snapshot.modelName + '\x1b[0m'));
74
+ lines.push(fg(COLORS.dim, snapshot.modelId));
75
+ lines.push(`${fmtTokens(usedTokens)}/${fmtTokens(windowTokens)} tokens (${pct(usedTokens, windowTokens)})`);
76
+ lines.push('');
77
+ lines.push(fg(COLORS.dim, '\x1b[3mEstimated usage by category\x1b[0m'));
78
+ for (const s of slices) {
79
+ if (s.tokens <= 0)
80
+ continue;
81
+ const dot = fg(s.color, '●');
82
+ const label = `${s.icon} ${s.name}`;
83
+ const value = `${fmtTokens(s.tokens)} tokens (${pct(s.tokens, windowTokens)})`;
84
+ lines.push(`${dot} ${label}: ${value}`);
85
+ }
86
+ const free = windowTokens - usedTokens - autocompactBufferTokens;
87
+ lines.push(`${fg(COLORS.free, '○')} Free space: ${fmtTokens(Math.max(0, free))} (${pct(Math.max(0, free), windowTokens)})`);
88
+ lines.push(`${fg(COLORS.buffer, '▢')} Autocompact buffer: ${fmtTokens(autocompactBufferTokens)} (${pct(autocompactBufferTokens, windowTokens)})`);
89
+ return lines.join('\n');
90
+ }
91
+ /**
92
+ * 并排显示矩阵 + 图例。简单按行拼接,矩阵在左、图例在右。
93
+ */
94
+ export function renderContextView(snapshot) {
95
+ const matrix = renderContextMatrix(snapshot).split('\n');
96
+ const legend = renderContextLegend(snapshot).split('\n');
97
+ const rows = Math.max(matrix.length, legend.length);
98
+ const out = [];
99
+ for (let i = 0; i < rows; i++) {
100
+ const left = (matrix[i] || '').padEnd(80, ' ');
101
+ const right = legend[i] || '';
102
+ out.push(` ${left} ${right}`);
103
+ }
104
+ return '\n' + out.join('\n') + '\n';
105
+ }
106
+ const CHARS_PER_TOKEN = 3.5;
107
+ function approxTokensFromChars(chars) {
108
+ return Math.ceil(chars / CHARS_PER_TOKEN);
109
+ }
110
+ function approxMessageTokens(messages) {
111
+ let chars = 0;
112
+ for (const m of messages) {
113
+ if (typeof m.content === 'string')
114
+ chars += m.content.length;
115
+ else if (Array.isArray(m.content)) {
116
+ for (const part of m.content) {
117
+ if (part.type === 'text')
118
+ chars += (part.text || '').length;
119
+ else if (part.type === 'tool-call')
120
+ chars += JSON.stringify(part.input || {}).length + 80;
121
+ else if (part.type === 'tool-result') {
122
+ const out = part.output;
123
+ if (typeof out === 'string')
124
+ chars += out.length;
125
+ else if (out?.value)
126
+ chars += String(out.value).length;
127
+ else
128
+ chars += JSON.stringify(out || {}).length;
129
+ chars += 80;
130
+ }
131
+ }
132
+ }
133
+ }
134
+ return approxTokensFromChars(chars);
135
+ }
136
+ export function buildContextSnapshot(input) {
137
+ const slices = [
138
+ { name: 'System prompt', tokens: approxTokensFromChars(input.systemPromptChars), color: COLORS.system, icon: '◆' },
139
+ { name: 'System tools', tokens: approxTokensFromChars(input.toolDescriptionChars), color: COLORS.tools, icon: '◇' },
140
+ { name: 'Memory', tokens: approxTokensFromChars(input.memoryChars), color: COLORS.memory, icon: '◈' },
141
+ { name: 'Skills', tokens: approxTokensFromChars(input.skillsChars), color: COLORS.skills, icon: '◉' },
142
+ { name: 'Messages', tokens: approxMessageTokens(input.messages), color: COLORS.messages, icon: '◎' },
143
+ ];
144
+ const usedTokens = slices.reduce((a, s) => a + s.tokens, 0);
145
+ return {
146
+ modelName: input.modelName,
147
+ modelId: input.modelId,
148
+ windowTokens: input.windowTokens,
149
+ usedTokens,
150
+ slices,
151
+ autocompactBufferTokens: input.autocompactBufferTokens ?? Math.round(input.windowTokens * 0.05),
152
+ };
153
+ }
154
+ // ── /usage 视图:累计成本 + cache 命中率 ─────────────────────────
155
+ export function renderUsageView(tracker) {
156
+ const t = tracker.totals();
157
+ const lines = [];
158
+ const C = (n, s) => fg(n, s);
159
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
160
+ const totalCacheable = t.cacheReadTokens + t.cacheWriteTokens + t.inputTokens;
161
+ lines.push(bold(C(255, ' Usage Summary')));
162
+ lines.push(C(244, ` ${t.steps} 步累计 · ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`));
163
+ lines.push('');
164
+ lines.push(` ${C(111, '◎')} Input ${fmtTokens(t.inputTokens).padStart(8)} tokens`);
165
+ lines.push(` ${C(220, '◈')} Cache write ${fmtTokens(t.cacheWriteTokens).padStart(8)} tokens`);
166
+ lines.push(` ${C(36, '◉')} Cache read ${fmtTokens(t.cacheReadTokens).padStart(8)} tokens (${(t.hitRate * 100).toFixed(1)}% hit)`);
167
+ lines.push(` ${C(99, '◇')} Output ${fmtTokens(t.outputTokens).padStart(8)} tokens`);
168
+ lines.push('');
169
+ // Cache 命中率条
170
+ const barWidth = 30;
171
+ const filled = Math.round(t.hitRate * barWidth);
172
+ const bar = C(36, '█'.repeat(filled)) + C(240, '░'.repeat(barWidth - filled));
173
+ lines.push(` Cache hit rate ${bar} ${(t.hitRate * 100).toFixed(1)}%`);
174
+ lines.push('');
175
+ lines.push(` ${bold('Cost')} ${C(220, '$' + t.cost.toFixed(4))}`);
176
+ lines.push(` ${C(244, 'Without cache')} ${C(244, '$' + t.baselineCost.toFixed(4))}`);
177
+ const savedPct = t.baselineCost > 0 ? (t.savedCost / t.baselineCost) * 100 : 0;
178
+ if (t.savedCost > 0) {
179
+ lines.push(` ${bold(C(36, 'Saved'))} ${C(36, '$' + t.savedCost.toFixed(4))} (${savedPct.toFixed(1)}% off)`);
180
+ }
181
+ if (totalCacheable === 0) {
182
+ lines.push(' ' + C(244, '尚无可缓存的 input,多聊几轮再看 :)'));
183
+ }
184
+ return '\n' + lines.join('\n') + '\n';
185
+ }
@@ -0,0 +1,27 @@
1
+ import { Cron } from 'croner';
2
+ const INTERVAL_RE = /^every\s+(\d+)\s*(s|sec|m|min|h|hour)s?$/i;
3
+ export function parseSchedule(expr) {
4
+ // 固定间隔
5
+ const intervalMatch = expr.match(INTERVAL_RE);
6
+ if (intervalMatch) {
7
+ const value = parseInt(intervalMatch[1]);
8
+ const unit = intervalMatch[2].toLowerCase();
9
+ const multiplier = unit.startsWith('h') ? 3600000
10
+ : unit.startsWith('m') ? 60000
11
+ : 1000;
12
+ return { type: 'interval', intervalMs: value * multiplier };
13
+ }
14
+ // ISO 时间戳
15
+ if (/^\d{4}-\d{2}-\d{2}/.test(expr)) {
16
+ const date = new Date(expr);
17
+ if (!isNaN(date.getTime())) {
18
+ return { type: 'once', onceAt: date };
19
+ }
20
+ }
21
+ // Cron 表达式
22
+ const cronInstance = new Cron(expr);
23
+ return { type: 'cron', cronInstance };
24
+ }
25
+ export function getNextCronTime(cron) {
26
+ return cron.msToNext() ?? 60000;
27
+ }