cc2im 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/LICENSE +21 -0
- package/README.en.md +120 -0
- package/README.md +120 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +314 -0
- package/dist/hub/agent-manager.d.ts +63 -0
- package/dist/hub/agent-manager.js +311 -0
- package/dist/hub/hub-context.d.ts +27 -0
- package/dist/hub/hub-context.js +57 -0
- package/dist/hub/index.d.ts +6 -0
- package/dist/hub/index.js +234 -0
- package/dist/hub/launchd.d.ts +7 -0
- package/dist/hub/launchd.js +151 -0
- package/dist/hub/plugin-manager.d.ts +7 -0
- package/dist/hub/plugin-manager.js +29 -0
- package/dist/hub/router.d.ts +21 -0
- package/dist/hub/router.js +35 -0
- package/dist/hub/socket-server.d.ts +23 -0
- package/dist/hub/socket-server.js +191 -0
- package/dist/plugins/channel-manager/index.d.ts +10 -0
- package/dist/plugins/channel-manager/index.js +387 -0
- package/dist/plugins/cron-scheduler/db.d.ts +12 -0
- package/dist/plugins/cron-scheduler/db.js +160 -0
- package/dist/plugins/cron-scheduler/index.d.ts +4 -0
- package/dist/plugins/cron-scheduler/index.js +22 -0
- package/dist/plugins/cron-scheduler/scheduler.d.ts +20 -0
- package/dist/plugins/cron-scheduler/scheduler.js +129 -0
- package/dist/plugins/persistence/db.d.ts +24 -0
- package/dist/plugins/persistence/db.js +121 -0
- package/dist/plugins/persistence/index.d.ts +2 -0
- package/dist/plugins/persistence/index.js +93 -0
- package/dist/plugins/web-monitor/api-routes.d.ts +33 -0
- package/dist/plugins/web-monitor/api-routes.js +474 -0
- package/dist/plugins/web-monitor/index.d.ts +2 -0
- package/dist/plugins/web-monitor/index.js +21 -0
- package/dist/plugins/web-monitor/log-tailer.d.ts +13 -0
- package/dist/plugins/web-monitor/log-tailer.js +74 -0
- package/dist/plugins/web-monitor/monitor-client.d.ts +17 -0
- package/dist/plugins/web-monitor/monitor-client.js +68 -0
- package/dist/plugins/web-monitor/server.d.ts +14 -0
- package/dist/plugins/web-monitor/server.js +205 -0
- package/dist/plugins/web-monitor/stats-reader.d.ts +22 -0
- package/dist/plugins/web-monitor/stats-reader.js +17 -0
- package/dist/plugins/web-monitor/token-stats.d.ts +19 -0
- package/dist/plugins/web-monitor/token-stats.js +86 -0
- package/dist/plugins/web-monitor/usage-stats.d.ts +13 -0
- package/dist/plugins/web-monitor/usage-stats.js +56 -0
- package/dist/plugins/weixin/chunker.d.ts +16 -0
- package/dist/plugins/weixin/chunker.js +142 -0
- package/dist/plugins/weixin/connection.d.ts +46 -0
- package/dist/plugins/weixin/connection.js +270 -0
- package/dist/plugins/weixin/index.d.ts +10 -0
- package/dist/plugins/weixin/index.js +198 -0
- package/dist/plugins/weixin/media-upload.d.ts +22 -0
- package/dist/plugins/weixin/media-upload.js +134 -0
- package/dist/plugins/weixin/media.d.ts +6 -0
- package/dist/plugins/weixin/media.js +83 -0
- package/dist/plugins/weixin/permission.d.ts +35 -0
- package/dist/plugins/weixin/permission.js +96 -0
- package/dist/plugins/weixin/qr-login.d.ts +23 -0
- package/dist/plugins/weixin/qr-login.js +77 -0
- package/dist/plugins/weixin/weixin-channel.d.ts +33 -0
- package/dist/plugins/weixin/weixin-channel.js +123 -0
- package/dist/shared/channel-config.d.ts +8 -0
- package/dist/shared/channel-config.js +14 -0
- package/dist/shared/channel.d.ts +37 -0
- package/dist/shared/channel.js +8 -0
- package/dist/shared/mcp-config.d.ts +5 -0
- package/dist/shared/mcp-config.js +44 -0
- package/dist/shared/plugin.d.ts +32 -0
- package/dist/shared/plugin.js +1 -0
- package/dist/shared/socket.d.ts +5 -0
- package/dist/shared/socket.js +31 -0
- package/dist/shared/types.d.ts +136 -0
- package/dist/shared/types.js +1 -0
- package/dist/spoke/channel-server.d.ts +48 -0
- package/dist/spoke/channel-server.js +383 -0
- package/dist/spoke/index.d.ts +13 -0
- package/dist/spoke/index.js +115 -0
- package/dist/spoke/permission.d.ts +28 -0
- package/dist/spoke/permission.js +142 -0
- package/dist/spoke/socket-client.d.ts +22 -0
- package/dist/spoke/socket-client.js +83 -0
- package/dist/web-frontend/assets/index-CU9vxw8F.js +9 -0
- package/dist/web-frontend/index.html +82 -0
- package/package.json +54 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cc2im Web — monitoring dashboard server
|
|
3
|
+
*
|
|
4
|
+
* Independent process: connects to hub.sock as monitor,
|
|
5
|
+
* serves React frontend + WebSocket events to browser.
|
|
6
|
+
*/
|
|
7
|
+
import type { HubContext } from '../../shared/plugin.js';
|
|
8
|
+
export { createApiHandler, type ApiHandlerDeps } from './api-routes.js';
|
|
9
|
+
export declare function startWeb(options: {
|
|
10
|
+
port: number;
|
|
11
|
+
ctx?: HubContext;
|
|
12
|
+
}): Promise<{
|
|
13
|
+
shutdown: () => void;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cc2im Web — monitoring dashboard server
|
|
3
|
+
*
|
|
4
|
+
* Independent process: connects to hub.sock as monitor,
|
|
5
|
+
* serves React frontend + WebSocket events to browser.
|
|
6
|
+
*/
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
11
|
+
import { MonitorClient } from './monitor-client.js';
|
|
12
|
+
import { LogTailer } from './log-tailer.js';
|
|
13
|
+
import { SOCKET_DIR } from '../../shared/socket.js';
|
|
14
|
+
import { getNicknames } from '../persistence/db.js';
|
|
15
|
+
import { listJobs, getRecentRuns } from '../cron-scheduler/db.js';
|
|
16
|
+
import { createApiHandler } from './api-routes.js';
|
|
17
|
+
// Re-export for test backward compat
|
|
18
|
+
export { createApiHandler } from './api-routes.js';
|
|
19
|
+
const AGENTS_JSON_PATH = join(SOCKET_DIR, 'agents.json');
|
|
20
|
+
export async function startWeb(options) {
|
|
21
|
+
const { port, ctx } = options;
|
|
22
|
+
const host = '127.0.0.1';
|
|
23
|
+
// --- Track agent state from monitor events ---
|
|
24
|
+
const agentState = new Map();
|
|
25
|
+
const MAX_HISTORY = 200;
|
|
26
|
+
const HISTORY_PATH = join(SOCKET_DIR, 'web-messages.json');
|
|
27
|
+
const activeQrPolls = new Map();
|
|
28
|
+
// Load persisted history on startup
|
|
29
|
+
let messageHistory = [];
|
|
30
|
+
try {
|
|
31
|
+
if (existsSync(HISTORY_PATH)) {
|
|
32
|
+
messageHistory = JSON.parse(readFileSync(HISTORY_PATH, 'utf8')).slice(-MAX_HISTORY);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
let historyDirty = false;
|
|
37
|
+
function pushHistory(event) {
|
|
38
|
+
messageHistory.push({ event, receivedAt: new Date().toISOString() });
|
|
39
|
+
if (messageHistory.length > MAX_HISTORY)
|
|
40
|
+
messageHistory.shift();
|
|
41
|
+
historyDirty = true;
|
|
42
|
+
}
|
|
43
|
+
// Persist to disk every 5s if changed
|
|
44
|
+
setInterval(() => {
|
|
45
|
+
if (!historyDirty)
|
|
46
|
+
return;
|
|
47
|
+
historyDirty = false;
|
|
48
|
+
try {
|
|
49
|
+
writeFileSync(HISTORY_PATH, JSON.stringify(messageHistory));
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
}, 5000);
|
|
53
|
+
// --- Monitor Client ---
|
|
54
|
+
const monitor = new MonitorClient((hubEvent) => {
|
|
55
|
+
const ev = hubEvent.event;
|
|
56
|
+
// Update local agent state
|
|
57
|
+
if (ev.kind === 'agent_online') {
|
|
58
|
+
agentState.set(ev.agentId, { status: 'connected', onlineSince: ev.timestamp });
|
|
59
|
+
}
|
|
60
|
+
else if (ev.kind === 'agent_offline') {
|
|
61
|
+
agentState.set(ev.agentId, { status: 'stopped' });
|
|
62
|
+
}
|
|
63
|
+
// Track message history
|
|
64
|
+
if (['message_in', 'message_out', 'permission_request', 'permission_verdict'].includes(ev.kind)) {
|
|
65
|
+
pushHistory(ev);
|
|
66
|
+
}
|
|
67
|
+
// Broadcast to all browser WebSocket clients
|
|
68
|
+
broadcastWs({ type: 'hub_event', event: ev });
|
|
69
|
+
});
|
|
70
|
+
// --- Log Tailer ---
|
|
71
|
+
const MAX_LOG_LINES = 200;
|
|
72
|
+
const logBuffer = [];
|
|
73
|
+
let wsReady = false;
|
|
74
|
+
const logTailer = new LogTailer((source, line) => {
|
|
75
|
+
logBuffer.push({ source, line });
|
|
76
|
+
if (logBuffer.length > MAX_LOG_LINES)
|
|
77
|
+
logBuffer.shift();
|
|
78
|
+
if (wsReady)
|
|
79
|
+
broadcastWs({ type: 'log', source, line });
|
|
80
|
+
});
|
|
81
|
+
// Tail hub log
|
|
82
|
+
const hubLogPath = join(SOCKET_DIR, 'hub.log');
|
|
83
|
+
logTailer.tail('hub', hubLogPath);
|
|
84
|
+
// Tail spoke logs for known agents
|
|
85
|
+
function tailAgentLogs() {
|
|
86
|
+
if (!existsSync(AGENTS_JSON_PATH))
|
|
87
|
+
return;
|
|
88
|
+
try {
|
|
89
|
+
const config = JSON.parse(readFileSync(AGENTS_JSON_PATH, 'utf8'));
|
|
90
|
+
for (const name of Object.keys(config.agents)) {
|
|
91
|
+
const spokeLog = join(SOCKET_DIR, 'agents', name, 'spoke.log');
|
|
92
|
+
logTailer.tail(name, spokeLog);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch { }
|
|
96
|
+
}
|
|
97
|
+
tailAgentLogs();
|
|
98
|
+
// --- WebSocket (declared early so handler can reference wsClients) ---
|
|
99
|
+
const wsClients = new Set();
|
|
100
|
+
function broadcastWs(msg) {
|
|
101
|
+
if (wsClients.size === 0)
|
|
102
|
+
return;
|
|
103
|
+
const data = JSON.stringify(msg);
|
|
104
|
+
for (const ws of wsClients) {
|
|
105
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
106
|
+
ws.send(data);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// --- HTTP Server ---
|
|
111
|
+
const frontendDir = join(import.meta.dirname, '..', '..', '..', 'dist', 'web-frontend');
|
|
112
|
+
const handler = createApiHandler({
|
|
113
|
+
agentsJsonPath: AGENTS_JSON_PATH,
|
|
114
|
+
mediaDir: join(SOCKET_DIR, 'media'),
|
|
115
|
+
messageHistory,
|
|
116
|
+
monitor,
|
|
117
|
+
wsClients,
|
|
118
|
+
ctx,
|
|
119
|
+
activeQrPolls,
|
|
120
|
+
broadcastWs,
|
|
121
|
+
frontendDir,
|
|
122
|
+
});
|
|
123
|
+
const httpServer = createServer(handler);
|
|
124
|
+
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
|
125
|
+
wsReady = true;
|
|
126
|
+
wss.on('connection', (ws) => {
|
|
127
|
+
wsClients.add(ws);
|
|
128
|
+
console.log(`[web] Browser connected (${wsClients.size} clients)`);
|
|
129
|
+
// Send snapshot on connect
|
|
130
|
+
const snapshot = getSnapshot();
|
|
131
|
+
ws.send(JSON.stringify({ type: 'snapshot', ...snapshot }));
|
|
132
|
+
ws.on('close', () => {
|
|
133
|
+
wsClients.delete(ws);
|
|
134
|
+
console.log(`[web] Browser disconnected (${wsClients.size} clients)`);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
function getSnapshot() {
|
|
138
|
+
let agents = [];
|
|
139
|
+
try {
|
|
140
|
+
if (existsSync(AGENTS_JSON_PATH)) {
|
|
141
|
+
const config = JSON.parse(readFileSync(AGENTS_JSON_PATH, 'utf8'));
|
|
142
|
+
agents = Object.entries(config.agents).map(([name, agent]) => {
|
|
143
|
+
const state = agentState.get(name);
|
|
144
|
+
return {
|
|
145
|
+
name,
|
|
146
|
+
status: (state?.status || 'stopped'),
|
|
147
|
+
cwd: agent.cwd,
|
|
148
|
+
autoStart: agent.autoStart ?? false,
|
|
149
|
+
isDefault: config.defaultAgent === name,
|
|
150
|
+
onlineSince: state?.onlineSince,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
let channelList = [];
|
|
157
|
+
if (ctx) {
|
|
158
|
+
channelList = ctx.getChannels().map(ch => ({
|
|
159
|
+
id: ch.id, type: ch.type, label: ch.label, status: ch.getStatus(),
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
let nicknameList = [];
|
|
163
|
+
try {
|
|
164
|
+
nicknameList = getNicknames();
|
|
165
|
+
}
|
|
166
|
+
catch { }
|
|
167
|
+
let cronJobList = [];
|
|
168
|
+
try {
|
|
169
|
+
cronJobList = listJobs().map(j => ({ ...j, recentRuns: getRecentRuns(j.id, 5) }));
|
|
170
|
+
}
|
|
171
|
+
catch { }
|
|
172
|
+
return {
|
|
173
|
+
agents,
|
|
174
|
+
hubConnected: monitor.isConnected(),
|
|
175
|
+
recentMessages: messageHistory.slice(-50),
|
|
176
|
+
recentLogs: logBuffer.slice(-100),
|
|
177
|
+
channels: channelList,
|
|
178
|
+
nicknames: nicknameList,
|
|
179
|
+
cronJobs: cronJobList,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// --- Start ---
|
|
183
|
+
monitor.connect();
|
|
184
|
+
httpServer.on('error', (err) => {
|
|
185
|
+
console.error(`[web] HTTP server error: ${err.message}`);
|
|
186
|
+
});
|
|
187
|
+
wss.on('error', (err) => {
|
|
188
|
+
console.error(`[web] WebSocket server error: ${err.message}`);
|
|
189
|
+
});
|
|
190
|
+
httpServer.listen(port, host, () => {
|
|
191
|
+
console.log(`[web] Dashboard: http://${host}:${port}`);
|
|
192
|
+
console.log(`[web] WebSocket: ws://${host}:${port}/ws`);
|
|
193
|
+
console.log(`[web] Hub: ${monitor.isConnected() ? 'connected' : 'connecting...'}`);
|
|
194
|
+
});
|
|
195
|
+
// Return shutdown handle — caller manages when to invoke
|
|
196
|
+
return {
|
|
197
|
+
shutdown() {
|
|
198
|
+
console.log('[web] Shutting down...');
|
|
199
|
+
logTailer.stop();
|
|
200
|
+
monitor.disconnect();
|
|
201
|
+
wss.close();
|
|
202
|
+
httpServer.close();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stats Reader — polls ~/.claude/stats-cache.json
|
|
3
|
+
*/
|
|
4
|
+
export interface StatsData {
|
|
5
|
+
dailyActivity: Array<{
|
|
6
|
+
date: string;
|
|
7
|
+
messageCount: number;
|
|
8
|
+
sessionCount: number;
|
|
9
|
+
toolCallCount: number;
|
|
10
|
+
}>;
|
|
11
|
+
dailyModelTokens: Array<{
|
|
12
|
+
date: string;
|
|
13
|
+
tokensByModel: Record<string, number>;
|
|
14
|
+
}>;
|
|
15
|
+
modelUsage: Record<string, {
|
|
16
|
+
inputTokens: number;
|
|
17
|
+
outputTokens: number;
|
|
18
|
+
cacheReadInputTokens: number;
|
|
19
|
+
cacheCreationInputTokens: number;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
export declare function readStats(): StatsData | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stats Reader — polls ~/.claude/stats-cache.json
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
const STATS_PATH = join(homedir(), '.claude', 'stats-cache.json');
|
|
8
|
+
export function readStats() {
|
|
9
|
+
if (!existsSync(STATS_PATH))
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(STATS_PATH, 'utf8'));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Stats — parse transcript JSONL files for accurate token usage
|
|
3
|
+
*/
|
|
4
|
+
export interface DailyTokens {
|
|
5
|
+
date: string;
|
|
6
|
+
input: number;
|
|
7
|
+
output: number;
|
|
8
|
+
cacheRead: number;
|
|
9
|
+
cacheCreate: number;
|
|
10
|
+
cost?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface TokenStats {
|
|
13
|
+
daily: DailyTokens[];
|
|
14
|
+
lastUpdated: string;
|
|
15
|
+
todayCost?: number;
|
|
16
|
+
avgDailyCost?: number;
|
|
17
|
+
pricingDate?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function getTokenStats(): TokenStats;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Stats — parse transcript JSONL files for accurate token usage
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
const pricingPath = join(import.meta.dirname, 'pricing.json');
|
|
8
|
+
const pricing = JSON.parse(readFileSync(pricingPath, 'utf8'));
|
|
9
|
+
const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
10
|
+
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // Only read files modified in last 30 days
|
|
11
|
+
let cachedStats = null;
|
|
12
|
+
let lastComputeTime = 0;
|
|
13
|
+
const CACHE_TTL_MS = 30_000; // Recompute at most every 30s (parsing ~800 files takes ~1.5s)
|
|
14
|
+
export function getTokenStats() {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
if (cachedStats && now - lastComputeTime < CACHE_TTL_MS) {
|
|
17
|
+
return cachedStats;
|
|
18
|
+
}
|
|
19
|
+
cachedStats = computeTokenStats();
|
|
20
|
+
lastComputeTime = now;
|
|
21
|
+
return cachedStats;
|
|
22
|
+
}
|
|
23
|
+
function computeTokenStats() {
|
|
24
|
+
const daily = new Map();
|
|
25
|
+
if (!existsSync(PROJECTS_DIR)) {
|
|
26
|
+
return { daily: [], lastUpdated: new Date().toISOString() };
|
|
27
|
+
}
|
|
28
|
+
const cutoff = Date.now() - MAX_AGE_MS;
|
|
29
|
+
function scanDir(dir) {
|
|
30
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
31
|
+
const full = join(dir, entry.name);
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
scanDir(full);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!entry.name.endsWith('.jsonl'))
|
|
37
|
+
continue;
|
|
38
|
+
try {
|
|
39
|
+
// Skip files not modified in the last 30 days
|
|
40
|
+
if (statSync(full).mtimeMs < cutoff)
|
|
41
|
+
continue;
|
|
42
|
+
const content = readFileSync(full, 'utf8');
|
|
43
|
+
for (const line of content.split('\n')) {
|
|
44
|
+
if (!line.includes('input_tokens'))
|
|
45
|
+
continue;
|
|
46
|
+
try {
|
|
47
|
+
const rec = JSON.parse(line);
|
|
48
|
+
const u = rec.message?.usage;
|
|
49
|
+
if (!u)
|
|
50
|
+
continue;
|
|
51
|
+
// Use local date (not UTC) so "today" matches user's midnight
|
|
52
|
+
const date = rec.timestamp ? new Date(rec.timestamp).toLocaleDateString('sv-SE') : null;
|
|
53
|
+
if (!date)
|
|
54
|
+
continue;
|
|
55
|
+
const d = daily.get(date) || { date, input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
|
|
56
|
+
d.input += u.input_tokens || 0;
|
|
57
|
+
d.output += u.output_tokens || 0;
|
|
58
|
+
d.cacheRead += u.cache_read_input_tokens || 0;
|
|
59
|
+
d.cacheCreate += u.cache_creation_input_tokens || 0;
|
|
60
|
+
daily.set(date, d);
|
|
61
|
+
}
|
|
62
|
+
catch { }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
scanDir(PROJECTS_DIR);
|
|
69
|
+
const sorted = [...daily.values()].sort((a, b) => a.date.localeCompare(b.date));
|
|
70
|
+
// Calculate cost per day using default Opus 4.6 pricing
|
|
71
|
+
const p = pricing.models['claude-opus-4-6'];
|
|
72
|
+
for (const d of sorted) {
|
|
73
|
+
d.cost = (d.input * p.input + d.output * p.output + d.cacheRead * p.cacheRead + d.cacheCreate * p.cacheCreate) / 1_000_000;
|
|
74
|
+
}
|
|
75
|
+
const today = new Date().toLocaleDateString('sv-SE');
|
|
76
|
+
const todayData = sorted.find(d => d.date === today);
|
|
77
|
+
const last30 = sorted.slice(-30);
|
|
78
|
+
const totalCost = last30.reduce((s, d) => s + (d.cost || 0), 0);
|
|
79
|
+
return {
|
|
80
|
+
daily: sorted,
|
|
81
|
+
lastUpdated: new Date().toISOString(),
|
|
82
|
+
todayCost: todayData?.cost,
|
|
83
|
+
avgDailyCost: last30.length > 0 ? totalCost / last30.length : undefined,
|
|
84
|
+
pricingDate: pricing.lastChecked,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface UsageStats {
|
|
2
|
+
fiveHour?: {
|
|
3
|
+
utilization: number;
|
|
4
|
+
resetsAt: string;
|
|
5
|
+
};
|
|
6
|
+
sevenDay?: {
|
|
7
|
+
utilization: number;
|
|
8
|
+
resetsAt: string;
|
|
9
|
+
};
|
|
10
|
+
lastUpdated: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function getUsageStats(): UsageStats;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
let cachedUsage = null;
|
|
3
|
+
let nextFetchAt = 0;
|
|
4
|
+
const CACHE_TTL_MS = 5 * 60_000; // 5 min normal interval
|
|
5
|
+
const BACKOFF_BASE_MS = 60_000; // 60s base on 429
|
|
6
|
+
const BACKOFF_MAX_MS = 5 * 60_000; // 5 min max backoff
|
|
7
|
+
let consecutiveErrors = 0;
|
|
8
|
+
export function getUsageStats() {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
if (now < nextFetchAt) {
|
|
11
|
+
return cachedUsage || { lastUpdated: '' };
|
|
12
|
+
}
|
|
13
|
+
const result = fetchUsage();
|
|
14
|
+
if (result.error) {
|
|
15
|
+
consecutiveErrors++;
|
|
16
|
+
// Exponential backoff: 60s, 120s, 240s, 300s (cap)
|
|
17
|
+
const backoff = Math.min(BACKOFF_BASE_MS * Math.pow(2, consecutiveErrors - 1), BACKOFF_MAX_MS);
|
|
18
|
+
nextFetchAt = now + backoff;
|
|
19
|
+
// Keep last successful data in memory
|
|
20
|
+
if (cachedUsage?.fiveHour)
|
|
21
|
+
return cachedUsage;
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
consecutiveErrors = 0;
|
|
25
|
+
nextFetchAt = now + CACHE_TTL_MS;
|
|
26
|
+
cachedUsage = result;
|
|
27
|
+
return cachedUsage;
|
|
28
|
+
}
|
|
29
|
+
function fetchUsage() {
|
|
30
|
+
try {
|
|
31
|
+
const creds = execSync('security find-generic-password -s "Claude Code-credentials" -w', { encoding: 'utf8', timeout: 3000 }).trim();
|
|
32
|
+
const { claudeAiOauth } = JSON.parse(creds);
|
|
33
|
+
if (!claudeAiOauth?.accessToken) {
|
|
34
|
+
return { lastUpdated: new Date().toISOString(), error: 'No OAuth token' };
|
|
35
|
+
}
|
|
36
|
+
const res = execSync(`curl -s -H "Authorization: Bearer ${claudeAiOauth.accessToken}" -H "anthropic-beta: oauth-2025-04-20" -H "User-Agent: claude-code/2.1" "https://api.anthropic.com/api/oauth/usage"`, { encoding: 'utf8', timeout: 10000 });
|
|
37
|
+
const data = JSON.parse(res);
|
|
38
|
+
if (data.error) {
|
|
39
|
+
return { lastUpdated: new Date().toISOString(), error: data.error.message || 'API error' };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
fiveHour: data.five_hour ? {
|
|
43
|
+
utilization: data.five_hour.utilization,
|
|
44
|
+
resetsAt: data.five_hour.resets_at,
|
|
45
|
+
} : undefined,
|
|
46
|
+
sevenDay: data.seven_day ? {
|
|
47
|
+
utilization: data.seven_day.utilization,
|
|
48
|
+
resetsAt: data.seven_day.resets_at,
|
|
49
|
+
} : undefined,
|
|
50
|
+
lastUpdated: new Date().toISOString(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
return { lastUpdated: new Date().toISOString(), error: err.message };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 结构感知的消息分段
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:
|
|
5
|
+
* 1. 以「块」为原子单位 — 代码围栏、表格、列表等结构不在内部切割
|
|
6
|
+
* 2. 安全长度低于 SDK 的 2000 硬切,避免双重分段
|
|
7
|
+
* 3. 对未知内容类型有兜底 — 不依赖穷举所有格式
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* 按块累积分段。
|
|
11
|
+
* - 尽量在块边界切割
|
|
12
|
+
* - 原子块超长时退化为行级切割
|
|
13
|
+
* - 单行超长时硬切(最终兜底)
|
|
14
|
+
*/
|
|
15
|
+
export declare function splitIntoChunks(text: string): string[];
|
|
16
|
+
export declare function formatChunks(chunks: string[]): string[];
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 结构感知的消息分段
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:
|
|
5
|
+
* 1. 以「块」为原子单位 — 代码围栏、表格、列表等结构不在内部切割
|
|
6
|
+
* 2. 安全长度低于 SDK 的 2000 硬切,避免双重分段
|
|
7
|
+
* 3. 对未知内容类型有兜底 — 不依赖穷举所有格式
|
|
8
|
+
*/
|
|
9
|
+
// SDK chunkText 硬切在 2000,[1/N]\n 前缀占约 10 字符,留足余量
|
|
10
|
+
const MAX_CHUNK = 1800;
|
|
11
|
+
/**
|
|
12
|
+
* 将文本解析为结构块。
|
|
13
|
+
* 识别:代码围栏 (```)、表格 (|--|)、空行分隔的段落。
|
|
14
|
+
* 不识别的内容按空行分段 — 兜底而非穷举。
|
|
15
|
+
*/
|
|
16
|
+
function parseBlocks(text) {
|
|
17
|
+
const lines = text.split('\n');
|
|
18
|
+
const blocks = [];
|
|
19
|
+
let current = [];
|
|
20
|
+
let inCodeFence = false;
|
|
21
|
+
let inTable = false;
|
|
22
|
+
function flush(atomic = false) {
|
|
23
|
+
if (current.length > 0) {
|
|
24
|
+
blocks.push({ text: current.join('\n'), atomic });
|
|
25
|
+
current = [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
// 代码围栏开关
|
|
30
|
+
if (/^```/.test(line.trimStart())) {
|
|
31
|
+
if (!inCodeFence) {
|
|
32
|
+
// 进入代码块:先 flush 之前的段落
|
|
33
|
+
flush();
|
|
34
|
+
inCodeFence = true;
|
|
35
|
+
current.push(line);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
// 代码块结束
|
|
39
|
+
current.push(line);
|
|
40
|
+
inCodeFence = false;
|
|
41
|
+
flush(true);
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (inCodeFence) {
|
|
46
|
+
current.push(line);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
// 表格检测:连续的 | 开头行
|
|
50
|
+
const isTableLine = /^\|/.test(line.trimStart());
|
|
51
|
+
if (isTableLine && !inTable) {
|
|
52
|
+
flush();
|
|
53
|
+
inTable = true;
|
|
54
|
+
current.push(line);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (inTable && isTableLine) {
|
|
58
|
+
current.push(line);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (inTable && !isTableLine) {
|
|
62
|
+
inTable = false;
|
|
63
|
+
flush(true);
|
|
64
|
+
// fall through to handle current line
|
|
65
|
+
}
|
|
66
|
+
// 空行 → 段落分隔
|
|
67
|
+
if (line.trim() === '') {
|
|
68
|
+
if (current.length > 0) {
|
|
69
|
+
// 保留空行作为段落间距
|
|
70
|
+
current.push(line);
|
|
71
|
+
flush();
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
current.push(line);
|
|
76
|
+
}
|
|
77
|
+
// 未闭合的代码围栏也要 flush(兜底)
|
|
78
|
+
flush(inCodeFence);
|
|
79
|
+
return blocks;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 按块累积分段。
|
|
83
|
+
* - 尽量在块边界切割
|
|
84
|
+
* - 原子块超长时退化为行级切割
|
|
85
|
+
* - 单行超长时硬切(最终兜底)
|
|
86
|
+
*/
|
|
87
|
+
export function splitIntoChunks(text) {
|
|
88
|
+
if (text.length <= MAX_CHUNK)
|
|
89
|
+
return [text];
|
|
90
|
+
const blocks = parseBlocks(text);
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let buffer = '';
|
|
93
|
+
function pushBuffer() {
|
|
94
|
+
const trimmed = buffer.replace(/\n+$/, '');
|
|
95
|
+
if (trimmed)
|
|
96
|
+
chunks.push(trimmed);
|
|
97
|
+
buffer = '';
|
|
98
|
+
}
|
|
99
|
+
for (const block of blocks) {
|
|
100
|
+
// 块能整体放入当前 buffer
|
|
101
|
+
if (buffer.length + block.text.length + 1 <= MAX_CHUNK) {
|
|
102
|
+
buffer += (buffer ? '\n' : '') + block.text;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// 放不下 — 先 flush buffer
|
|
106
|
+
pushBuffer();
|
|
107
|
+
// 块本身不超限 → 直接作为新 buffer
|
|
108
|
+
if (block.text.length <= MAX_CHUNK) {
|
|
109
|
+
buffer = block.text;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// 超长块 — 退化为行级切割
|
|
113
|
+
const lines = block.text.split('\n');
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
if (buffer.length + line.length + 1 <= MAX_CHUNK) {
|
|
116
|
+
buffer += (buffer ? '\n' : '') + line;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
pushBuffer();
|
|
120
|
+
// 单行超长 — 硬切兜底
|
|
121
|
+
if (line.length > MAX_CHUNK) {
|
|
122
|
+
let remaining = line;
|
|
123
|
+
while (remaining.length > MAX_CHUNK) {
|
|
124
|
+
chunks.push(remaining.slice(0, MAX_CHUNK));
|
|
125
|
+
remaining = remaining.slice(MAX_CHUNK);
|
|
126
|
+
}
|
|
127
|
+
buffer = remaining;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
buffer = line;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
pushBuffer();
|
|
136
|
+
return chunks;
|
|
137
|
+
}
|
|
138
|
+
export function formatChunks(chunks) {
|
|
139
|
+
if (chunks.length <= 1)
|
|
140
|
+
return chunks;
|
|
141
|
+
return chunks.map((chunk, i) => `[${i + 1}/${chunks.length}]\n${chunk}`);
|
|
142
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信连接 + 收发
|
|
3
|
+
* 从 cc2wx 搬迁,适配 hub 架构
|
|
4
|
+
*/
|
|
5
|
+
export type IncomingMessage = {
|
|
6
|
+
userId: string;
|
|
7
|
+
type: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
raw?: any;
|
|
10
|
+
timestamp?: Date;
|
|
11
|
+
};
|
|
12
|
+
export type OnMessageCallback = (msg: IncomingMessage & {
|
|
13
|
+
mediaPath?: string | null;
|
|
14
|
+
voiceText?: string | null;
|
|
15
|
+
}) => void;
|
|
16
|
+
export declare class WeixinConnection {
|
|
17
|
+
private bot;
|
|
18
|
+
private recentMessages;
|
|
19
|
+
private onIncoming;
|
|
20
|
+
private listening;
|
|
21
|
+
private cleanupTimer;
|
|
22
|
+
setMessageHandler(handler: OnMessageCallback): void;
|
|
23
|
+
/** Persist context tokens to disk so replies work after hub restart */
|
|
24
|
+
saveContextCache(channelId?: string): void;
|
|
25
|
+
/** Restore context tokens from disk. Call after login, before startListening. */
|
|
26
|
+
restoreContextCache(channelId?: string): void;
|
|
27
|
+
login(channelId?: string): Promise<string>;
|
|
28
|
+
startListening(): void;
|
|
29
|
+
startPolling(): Promise<void>;
|
|
30
|
+
/** Stop the polling loop and clear the message handler so reconnect starts clean. */
|
|
31
|
+
stop(): void;
|
|
32
|
+
startTyping(userId: string): Promise<void>;
|
|
33
|
+
stopTyping(userId: string): Promise<void>;
|
|
34
|
+
send(userId: string, text: string): Promise<void>;
|
|
35
|
+
/** Upload a local image and send it as an image message. */
|
|
36
|
+
sendImage(userId: string, filePath: string): Promise<void>;
|
|
37
|
+
/** Upload a local file and send it as a file message. */
|
|
38
|
+
sendFile(userId: string, filePath: string): Promise<void>;
|
|
39
|
+
/** Extract baseUrl, token, and contextToken from the SDK internals. */
|
|
40
|
+
private getBotCredentials;
|
|
41
|
+
/**
|
|
42
|
+
* POST /ilink/bot/sendmessage — mirrors the SDK's sendMessage()
|
|
43
|
+
* which is not re-exported from the package's main entry.
|
|
44
|
+
*/
|
|
45
|
+
private callSendMessage;
|
|
46
|
+
}
|