nx-ce 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -51
- package/package.json +3 -2
- package/src/cli.js +16 -2
- package/src/index.js +10 -1
- package/src/serve.js +553 -198
- package/src/session-store.js +56 -2
- package/src/skills.js +56 -0
- package/src/util.js +124 -0
package/src/session-store.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 会话存储 — 磁盘上的持久化状态
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 参考 happy 的 sessions.json 设计:
|
|
5
|
+
* - 每个命名实例存储完整元数据
|
|
6
|
+
* - lifecycleState 追踪会话生命周期
|
|
7
|
+
* - machineId/host 标识运行环境
|
|
8
|
+
* - usage 追踪 token 消耗
|
|
9
|
+
*
|
|
5
10
|
* 目录:$HOME/.nx-ce/instances/{name}.json
|
|
6
11
|
*/
|
|
7
12
|
|
|
@@ -17,6 +22,51 @@ function ensureDir() {
|
|
|
17
22
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
18
23
|
}
|
|
19
24
|
|
|
25
|
+
/**
|
|
26
|
+
* 会话生命周期状态枚举。
|
|
27
|
+
* 参考 happy 的 lifecycleState: "running" | "stopped" | "crashed"
|
|
28
|
+
*/
|
|
29
|
+
export const LifecycleState = Object.freeze({
|
|
30
|
+
RUNNING: 'running',
|
|
31
|
+
STOPPED: 'stopped',
|
|
32
|
+
CRASHED: 'crashed',
|
|
33
|
+
RESUMING: 'resuming',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 创建一个新的状态对象(含默认值)。
|
|
38
|
+
*
|
|
39
|
+
* @param {string} name - 实例名称
|
|
40
|
+
* @param {object} [overrides] - 覆盖字段
|
|
41
|
+
* @returns {object}
|
|
42
|
+
*/
|
|
43
|
+
export function createState(name, overrides = {}) {
|
|
44
|
+
return {
|
|
45
|
+
name,
|
|
46
|
+
pid: process.pid,
|
|
47
|
+
startedAt: new Date().toISOString(),
|
|
48
|
+
updatedAt: new Date().toISOString(),
|
|
49
|
+
sessionId: null,
|
|
50
|
+
model: 'claude-sonnet-4-6',
|
|
51
|
+
host: '',
|
|
52
|
+
machineId: '',
|
|
53
|
+
claudeVersion: '',
|
|
54
|
+
lifecycleState: LifecycleState.RUNNING,
|
|
55
|
+
lifecycleStateSince: Date.now(),
|
|
56
|
+
startedBy: 'serve',
|
|
57
|
+
port: null,
|
|
58
|
+
usage: {
|
|
59
|
+
inputTokens: 0,
|
|
60
|
+
cacheCreationInputTokens: 0,
|
|
61
|
+
cacheReadInputTokens: 0,
|
|
62
|
+
outputTokens: 0,
|
|
63
|
+
contextWindow: 200000,
|
|
64
|
+
contextTokens: 0,
|
|
65
|
+
},
|
|
66
|
+
...overrides,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
20
70
|
/**
|
|
21
71
|
* 读取指定实例的持久化状态。
|
|
22
72
|
*
|
|
@@ -42,7 +92,11 @@ export function readState(name) {
|
|
|
42
92
|
export function writeState(name, state) {
|
|
43
93
|
ensureDir();
|
|
44
94
|
const path = join(STATE_DIR, sanitize(name));
|
|
45
|
-
|
|
95
|
+
const enriched = {
|
|
96
|
+
...state,
|
|
97
|
+
updatedAt: new Date().toISOString(),
|
|
98
|
+
};
|
|
99
|
+
writeFileSync(path, JSON.stringify(enriched, null, 2), 'utf8');
|
|
46
100
|
}
|
|
47
101
|
|
|
48
102
|
/**
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skills — 查询 SDK 可用的 skill / tool / agent 列表
|
|
3
|
+
*
|
|
4
|
+
* 使用一次超轻量 agentQuery() 获取 init 元数据。
|
|
5
|
+
* 不发起真正对话,不持久化 session。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 获取 SDK 可用的 skills/tools/slashCommands/agents 列表。
|
|
12
|
+
*
|
|
13
|
+
* @param {object} [options]
|
|
14
|
+
* @param {string} [options.cwd] - 工作目录
|
|
15
|
+
* @param {string} [options.claudePath] - Claude CLI 路径
|
|
16
|
+
* @param {object} [options.env] - 额外环境变量
|
|
17
|
+
* @returns {Promise<{ skills: string[], tools: string[], slashCommands: string[], agents: string[] }>}
|
|
18
|
+
*/
|
|
19
|
+
export async function listSkills(options = {}) {
|
|
20
|
+
const sdkOptions = {
|
|
21
|
+
cwd: options.cwd || process.cwd(),
|
|
22
|
+
model: 'claude-haiku-4-5',
|
|
23
|
+
permissionMode: 'bypassPermissions',
|
|
24
|
+
allowDangerouslySkipPermissions: true,
|
|
25
|
+
persistSession: false,
|
|
26
|
+
env: { ...process.env, ...options.env },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (options.claudePath) {
|
|
30
|
+
sdkOptions.pathToClaudeCodeExecutable = options.claudePath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 用空 prompt 做一次超轻量 init
|
|
34
|
+
const response = agentQuery({ prompt: ' ', options: sdkOptions });
|
|
35
|
+
|
|
36
|
+
const result = {
|
|
37
|
+
skills: [],
|
|
38
|
+
tools: [],
|
|
39
|
+
slashCommands: [],
|
|
40
|
+
agents: [],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
for await (const message of response) {
|
|
44
|
+
if (message.type === 'system' && message.subtype === 'init') {
|
|
45
|
+
if (Array.isArray(message.skills)) result.skills = message.skills;
|
|
46
|
+
if (Array.isArray(message.tools)) result.tools = message.tools;
|
|
47
|
+
if (Array.isArray(message.slash_commands)) result.slashCommands = message.slash_commands;
|
|
48
|
+
if (Array.isArray(message.agents)) result.agents = message.agents;
|
|
49
|
+
// init 消息后立即中断,不继续消耗资源
|
|
50
|
+
await response.interrupt().catch(() => {});
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工具函数 — 加密 ID 生成、单调时钟、机器标识
|
|
3
|
+
*
|
|
4
|
+
* 参考 happy 的 cuid2 + monotonic clock + machineId 设计。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
8
|
+
import { hostname, machine, platform, release } from 'node:os';
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
// =================================================================
|
|
14
|
+
// ID 生成
|
|
15
|
+
// =================================================================
|
|
16
|
+
|
|
17
|
+
/** 会话 ID 前缀 */
|
|
18
|
+
const SESSION_PREFIX = 'nxce';
|
|
19
|
+
/** 消息 / turn ID 前缀 */
|
|
20
|
+
const MSG_PREFIX = 'msg';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 生成可排序的唯一 ID。
|
|
24
|
+
* 格式: {prefix}_{timestamp}-{randomUUID片段}
|
|
25
|
+
* 前缀保证可读性,时间戳保证近似排序,UUID 保证全局唯一。
|
|
26
|
+
*
|
|
27
|
+
* @param {string} prefix - ID 前缀
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function generateId(prefix = SESSION_PREFIX) {
|
|
31
|
+
const ts = Date.now().toString(36);
|
|
32
|
+
const rand = randomUUID().replace(/-/g, '').slice(0, 12);
|
|
33
|
+
return `${prefix}_${ts}_${rand}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =================================================================
|
|
37
|
+
// 单调时钟
|
|
38
|
+
// =================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 单调时钟 — 保证消息 time 字段严格递增。
|
|
42
|
+
*
|
|
43
|
+
* 参考 happy 的 AcpSessionManager.nextTime():
|
|
44
|
+
* time = max(lastTime + 1, Date.now())
|
|
45
|
+
*
|
|
46
|
+
* 即使是同一毫秒内的多条消息也能保持正确顺序。
|
|
47
|
+
*/
|
|
48
|
+
export class MonotonicClock {
|
|
49
|
+
/** @type {number} */
|
|
50
|
+
#lastTime = 0;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 获取下一个单调递增的时间戳(毫秒级)。
|
|
54
|
+
* @returns {number}
|
|
55
|
+
*/
|
|
56
|
+
next() {
|
|
57
|
+
this.#lastTime = Math.max(this.#lastTime + 1, Date.now());
|
|
58
|
+
return this.#lastTime;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 重置时钟(会话重新初始化时使用)。
|
|
63
|
+
*/
|
|
64
|
+
reset() {
|
|
65
|
+
this.#lastTime = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// =================================================================
|
|
70
|
+
// 机器标识
|
|
71
|
+
// =================================================================
|
|
72
|
+
|
|
73
|
+
/** 持久化机器 ID 文件路径 */
|
|
74
|
+
const MACHINE_ID_FILE = join(homedir(), '.nx-ce', 'machine-id');
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 获取或生成持久的机器 ID。
|
|
78
|
+
* 参考 happy 的 machineId: "418aa05c-377a-4577-b100-fd36ab54c641"
|
|
79
|
+
*
|
|
80
|
+
* 持久化到 ~/.nx-ce/machine-id,首次生成后固定不变。
|
|
81
|
+
*
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
export function getMachineId() {
|
|
85
|
+
try {
|
|
86
|
+
if (existsSync(MACHINE_ID_FILE)) {
|
|
87
|
+
return readFileSync(MACHINE_ID_FILE, 'utf8').trim();
|
|
88
|
+
}
|
|
89
|
+
} catch { /* 首次运行,文件不存在 */ }
|
|
90
|
+
|
|
91
|
+
// 基于机器特征生成稳定 ID
|
|
92
|
+
const raw = `${hostname()}-${machine()}-${platform()}-${release()}`;
|
|
93
|
+
const id = createHash('sha256').update(raw).digest('hex').slice(0, 24);
|
|
94
|
+
const formatted = [
|
|
95
|
+
id.slice(0, 8),
|
|
96
|
+
id.slice(8, 12),
|
|
97
|
+
id.slice(12, 16),
|
|
98
|
+
id.slice(16, 20),
|
|
99
|
+
id.slice(20, 24),
|
|
100
|
+
].join('-');
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(join(homedir(), '.nx-ce'), { recursive: true });
|
|
104
|
+
writeFileSync(MACHINE_ID_FILE, formatted, 'utf8');
|
|
105
|
+
} catch { /* 持久化失败不阻塞 */ }
|
|
106
|
+
|
|
107
|
+
return formatted;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// =================================================================
|
|
111
|
+
// 编码 / 序列化辅助
|
|
112
|
+
// =================================================================
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 将字节大小格式化为可读字符串。
|
|
116
|
+
* @param {number} bytes
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function formatBytes(bytes) {
|
|
120
|
+
if (bytes === 0 || !bytes) return '0 B';
|
|
121
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
122
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
123
|
+
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
|
124
|
+
}
|