@wangchenglai/dsh-acp-session 0.1.0 → 0.1.1

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/lib/replay.js CHANGED
@@ -1,94 +1,94 @@
1
- /**
2
- * 把 dsh 的会话事件翻译成 ACP 的 session/update 通知。
3
- *
4
- * 这对应 ACP 里 session/load 的语义:"把历史按标准更新回放给客户端"。
5
- * 形状与 net-agent 的 AcpClientBridge.handleSessionUpdate 对得上:
6
- * user_message_chunk / agent_message_chunk / agent_thought_chunk /
7
- * tool_call / tool_call_update。
8
- */
9
-
10
- import { reasoningOf, textOf, isRealUserMessage } from './store.js';
11
-
12
- function parseMaybeJson(value) {
13
- if (typeof value !== 'string') return value;
14
- try {
15
- return JSON.parse(value);
16
- } catch {
17
- return value;
18
- }
19
- }
20
-
21
- /**
22
- * @param events - dsh 的 SessionEvent 数组
23
- * @returns ACP SessionUpdate 数组(按原顺序)
24
- */
25
- export function replayUpdates(events) {
26
- const updates = [];
27
-
28
- for (const event of events) {
29
- const data = event?.data ?? {};
30
-
31
- switch (event?.type) {
32
- case 'user/message': {
33
- if (!isRealUserMessage(event)) break;
34
- const text = textOf(data.content);
35
- if (text) {
36
- updates.push({
37
- sessionUpdate: 'user_message_chunk',
38
- content: { type: 'text', text },
39
- });
40
- }
41
- break;
42
- }
43
-
44
- case 'assistant/message': {
45
- const content = data?.message?.content;
46
- const reasoning = reasoningOf(content);
47
- if (reasoning) {
48
- updates.push({
49
- sessionUpdate: 'agent_thought_chunk',
50
- content: { type: 'text', text: reasoning },
51
- });
52
- }
53
- const text = textOf(content);
54
- if (text) {
55
- updates.push({
56
- sessionUpdate: 'agent_message_chunk',
57
- content: { type: 'text', text },
58
- });
59
- }
60
- break;
61
- }
62
-
63
- case 'tool/call': {
64
- if (!data.callId) break;
65
- updates.push({
66
- sessionUpdate: 'tool_call',
67
- toolCallId: String(data.callId),
68
- title: typeof data.name === 'string' && data.name ? data.name : 'tool',
69
- kind: 'other',
70
- status: 'completed',
71
- rawInput: parseMaybeJson(data.arguments),
72
- });
73
- break;
74
- }
75
-
76
- case 'tool/result': {
77
- const message = data?.message ?? {};
78
- const callId = message.toolCallId ?? message?.source?.callId;
79
- if (!callId) break;
80
- updates.push({
81
- sessionUpdate: 'tool_call_update',
82
- toolCallId: String(callId),
83
- status: message.isError ? 'failed' : 'completed',
84
- });
85
- break;
86
- }
87
-
88
- default:
89
- break;
90
- }
91
- }
92
-
93
- return updates;
94
- }
1
+ /**
2
+ * 把 dsh 的会话事件翻译成 ACP 的 session/update 通知。
3
+ *
4
+ * 这对应 ACP 里 session/load 的语义:"把历史按标准更新回放给客户端"。
5
+ * 形状与 net-agent 的 AcpClientBridge.handleSessionUpdate 对得上:
6
+ * user_message_chunk / agent_message_chunk / agent_thought_chunk /
7
+ * tool_call / tool_call_update。
8
+ */
9
+
10
+ import { reasoningOf, textOf, isRealUserMessage } from './store.js';
11
+
12
+ function parseMaybeJson(value) {
13
+ if (typeof value !== 'string') return value;
14
+ try {
15
+ return JSON.parse(value);
16
+ } catch {
17
+ return value;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * @param events - dsh 的 SessionEvent 数组
23
+ * @returns ACP SessionUpdate 数组(按原顺序)
24
+ */
25
+ export function replayUpdates(events) {
26
+ const updates = [];
27
+
28
+ for (const event of events) {
29
+ const data = event?.data ?? {};
30
+
31
+ switch (event?.type) {
32
+ case 'user/message': {
33
+ if (!isRealUserMessage(event)) break;
34
+ const text = textOf(data.content);
35
+ if (text) {
36
+ updates.push({
37
+ sessionUpdate: 'user_message_chunk',
38
+ content: { type: 'text', text },
39
+ });
40
+ }
41
+ break;
42
+ }
43
+
44
+ case 'assistant/message': {
45
+ const content = data?.message?.content;
46
+ const reasoning = reasoningOf(content);
47
+ if (reasoning) {
48
+ updates.push({
49
+ sessionUpdate: 'agent_thought_chunk',
50
+ content: { type: 'text', text: reasoning },
51
+ });
52
+ }
53
+ const text = textOf(content);
54
+ if (text) {
55
+ updates.push({
56
+ sessionUpdate: 'agent_message_chunk',
57
+ content: { type: 'text', text },
58
+ });
59
+ }
60
+ break;
61
+ }
62
+
63
+ case 'tool/call': {
64
+ if (!data.callId) break;
65
+ updates.push({
66
+ sessionUpdate: 'tool_call',
67
+ toolCallId: String(data.callId),
68
+ title: typeof data.name === 'string' && data.name ? data.name : 'tool',
69
+ kind: 'other',
70
+ status: 'completed',
71
+ rawInput: parseMaybeJson(data.arguments),
72
+ });
73
+ break;
74
+ }
75
+
76
+ case 'tool/result': {
77
+ const message = data?.message ?? {};
78
+ const callId = message.toolCallId ?? message?.source?.callId;
79
+ if (!callId) break;
80
+ updates.push({
81
+ sessionUpdate: 'tool_call_update',
82
+ toolCallId: String(callId),
83
+ status: message.isError ? 'failed' : 'completed',
84
+ });
85
+ break;
86
+ }
87
+
88
+ default:
89
+ break;
90
+ }
91
+ }
92
+
93
+ return updates;
94
+ }
package/lib/store.js CHANGED
@@ -1,156 +1,156 @@
1
- /**
2
- * 通过 dsh 自己的 sessionPersistence 服务读取会话。
3
- *
4
- * 不直接解压 session.v4.jsonl.zstd:持久化后端已经做了分帧解压和格式校验,
5
- * 用它既少一份解析代码,也不会跟着 dsh 的内部格式变动而失效。
6
- */
7
-
8
- import { rm } from 'node:fs/promises';
9
- import { dirname } from 'node:path';
10
-
11
- /** 取 content 数组里的文本片段。 */
12
- export function textOf(content) {
13
- if (!Array.isArray(content)) return '';
14
- let text = '';
15
- for (const item of content) {
16
- if (item && item.type === 'text' && typeof item.text === 'string') text += item.text;
17
- }
18
- return text;
19
- }
20
-
21
- /** 取 content 数组里的推理片段。 */
22
- export function reasoningOf(content) {
23
- if (!Array.isArray(content)) return '';
24
- let text = '';
25
- for (const item of content) {
26
- if (item && item.type === 'reasoning' && typeof item.text === 'string') text += item.text;
27
- }
28
- return text;
29
- }
30
-
31
- /**
32
- * 是否是真人输入的消息。
33
- *
34
- * dsh 会把运行时注入的上下文也写成 user/message,只有 source.kind === 'user'
35
- * 才是用户在聊天框里真的说的话。
36
- */
37
- export function isRealUserMessage(event) {
38
- return event?.data?.source?.kind === 'user';
39
- }
40
-
41
- function firstUserText(events, limit) {
42
- for (const event of events) {
43
- if (event?.type !== 'user/message' || !isRealUserMessage(event)) continue;
44
- const text = textOf(event?.data?.content).trim();
45
- if (text) return text.slice(0, limit);
46
- }
47
- return '';
48
- }
49
-
50
- /**
51
- * 从会话头的元数据和事件流里提炼列表页需要的字段。
52
- *
53
- * @param header - sessionPersistence.list()/stat() 给出的 header(可缺省)
54
- * @param events - sessionPersistence.readStoredLog() 解出的事件
55
- * @returns {{id: string|undefined, cwd: string, title: string, updatedAt: string|null, messageCount: number}}
56
- */
57
- export function summarize(header, events) {
58
- let cwd = header?.cwd ?? '';
59
- let title = '';
60
- let updatedAt = typeof header?.createdAt === 'number' ? header.createdAt : null;
61
- let messageCount = 0;
62
-
63
- for (const event of events) {
64
- const time = event?.time;
65
- if (typeof time === 'number' && (updatedAt === null || time > updatedAt)) updatedAt = time;
66
-
67
- switch (event?.type) {
68
- case 'session':
69
- if (!cwd && typeof event?.data?.cwd === 'string') cwd = event.data.cwd;
70
- if (updatedAt === null && typeof event?.createdAt === 'number') updatedAt = event.createdAt;
71
- break;
72
- case 'session/title':
73
- if (typeof event?.data?.title === 'string') title = event.data.title;
74
- break;
75
- case 'user/message':
76
- if (isRealUserMessage(event)) messageCount += 1;
77
- break;
78
- case 'assistant/message':
79
- messageCount += 1;
80
- break;
81
- default:
82
- break;
83
- }
84
- }
85
-
86
- if (!title) title = firstUserText(events, 20);
87
-
88
- return {
89
- id: header?.id,
90
- cwd,
91
- title,
92
- updatedAt: updatedAt === null ? null : new Date(updatedAt).toISOString(),
93
- messageCount,
94
- };
95
- }
96
-
97
- /**
98
- * 读出某个会话的全部事件。
99
- *
100
- * @returns 事件数组;**读不到日志时返回 null**(不是空数组)。
101
- *
102
- * 读不到有两种情况,必须和"确实是空会话"区分开:
103
- * - 会话不存在
104
- * - 会话正被当前 dsh 进程持有(正在聊天),此时 resolveCurrentLog 拿不到路径
105
- * 后者如果当成空会话,正在用的那条就会从列表里消失。
106
- */
107
- export async function readSessionEvents(persistence, sessionId) {
108
- const path = await persistence.resolveCurrentLog(sessionId);
109
- if (!path) return null;
110
- const stored = await persistence.readStoredLog(path, sessionId);
111
- return Array.isArray(stored?.events) ? stored.events : null;
112
- }
113
-
114
- /**
115
- * 提炼单个会话的摘要(标题、更新时间、消息数)。
116
- *
117
- * @returns messageCount 为 null 表示"读不到日志、内容未知"——调用方不应据此过滤。
118
- */
119
- export async function describeSession(persistence, sessionId) {
120
- const events = await readSessionEvents(persistence, sessionId);
121
- if (events !== null) return summarize({ id: sessionId }, events);
122
-
123
- // 读不到日志时用元数据兜底:至少把 cwd 和创建时间填上
124
- let header = { id: sessionId };
125
- try {
126
- const snapshot = await persistence.stat(sessionId);
127
- if (snapshot?.header) header = snapshot.header;
128
- } catch {
129
- // 元数据也拿不到就保持最小信息
130
- }
131
-
132
- return {
133
- id: sessionId,
134
- cwd: header.cwd ?? '',
135
- title: '',
136
- updatedAt: typeof header.createdAt === 'number'
137
- ? new Date(header.createdAt).toISOString()
138
- : null,
139
- messageCount: null,
140
- };
141
- }
142
-
143
- /**
144
- * 删除一个会话。
145
- *
146
- * dsh 的 SessionPersistence 没有 delete,但提供了 resolveCurrentLog 找到
147
- * 当前产物路径;一个会话目录里只有它的日志,整目录删掉即可。
148
- *
149
- * @returns 是否真的删掉了(会话不存在时返回 false)
150
- */
151
- export async function removeSession(persistence, sessionId) {
152
- const path = await persistence.resolveCurrentLog(sessionId);
153
- if (!path) return false;
154
- await rm(dirname(path), { recursive: true, force: true });
155
- return true;
156
- }
1
+ /**
2
+ * 通过 dsh 自己的 sessionPersistence 服务读取会话。
3
+ *
4
+ * 不直接解压 session.v4.jsonl.zstd:持久化后端已经做了分帧解压和格式校验,
5
+ * 用它既少一份解析代码,也不会跟着 dsh 的内部格式变动而失效。
6
+ */
7
+
8
+ import { rm } from 'node:fs/promises';
9
+ import { dirname } from 'node:path';
10
+
11
+ /** 取 content 数组里的文本片段。 */
12
+ export function textOf(content) {
13
+ if (!Array.isArray(content)) return '';
14
+ let text = '';
15
+ for (const item of content) {
16
+ if (item && item.type === 'text' && typeof item.text === 'string') text += item.text;
17
+ }
18
+ return text;
19
+ }
20
+
21
+ /** 取 content 数组里的推理片段。 */
22
+ export function reasoningOf(content) {
23
+ if (!Array.isArray(content)) return '';
24
+ let text = '';
25
+ for (const item of content) {
26
+ if (item && item.type === 'reasoning' && typeof item.text === 'string') text += item.text;
27
+ }
28
+ return text;
29
+ }
30
+
31
+ /**
32
+ * 是否是真人输入的消息。
33
+ *
34
+ * dsh 会把运行时注入的上下文也写成 user/message,只有 source.kind === 'user'
35
+ * 才是用户在聊天框里真的说的话。
36
+ */
37
+ export function isRealUserMessage(event) {
38
+ return event?.data?.source?.kind === 'user';
39
+ }
40
+
41
+ function firstUserText(events, limit) {
42
+ for (const event of events) {
43
+ if (event?.type !== 'user/message' || !isRealUserMessage(event)) continue;
44
+ const text = textOf(event?.data?.content).trim();
45
+ if (text) return text.slice(0, limit);
46
+ }
47
+ return '';
48
+ }
49
+
50
+ /**
51
+ * 从会话头的元数据和事件流里提炼列表页需要的字段。
52
+ *
53
+ * @param header - sessionPersistence.list()/stat() 给出的 header(可缺省)
54
+ * @param events - sessionPersistence.readStoredLog() 解出的事件
55
+ * @returns {{id: string|undefined, cwd: string, title: string, updatedAt: string|null, messageCount: number}}
56
+ */
57
+ export function summarize(header, events) {
58
+ let cwd = header?.cwd ?? '';
59
+ let title = '';
60
+ let updatedAt = typeof header?.createdAt === 'number' ? header.createdAt : null;
61
+ let messageCount = 0;
62
+
63
+ for (const event of events) {
64
+ const time = event?.time;
65
+ if (typeof time === 'number' && (updatedAt === null || time > updatedAt)) updatedAt = time;
66
+
67
+ switch (event?.type) {
68
+ case 'session':
69
+ if (!cwd && typeof event?.data?.cwd === 'string') cwd = event.data.cwd;
70
+ if (updatedAt === null && typeof event?.createdAt === 'number') updatedAt = event.createdAt;
71
+ break;
72
+ case 'session/title':
73
+ if (typeof event?.data?.title === 'string') title = event.data.title;
74
+ break;
75
+ case 'user/message':
76
+ if (isRealUserMessage(event)) messageCount += 1;
77
+ break;
78
+ case 'assistant/message':
79
+ messageCount += 1;
80
+ break;
81
+ default:
82
+ break;
83
+ }
84
+ }
85
+
86
+ if (!title) title = firstUserText(events, 20);
87
+
88
+ return {
89
+ id: header?.id,
90
+ cwd,
91
+ title,
92
+ updatedAt: updatedAt === null ? null : new Date(updatedAt).toISOString(),
93
+ messageCount,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * 读出某个会话的全部事件。
99
+ *
100
+ * @returns 事件数组;**读不到日志时返回 null**(不是空数组)。
101
+ *
102
+ * 读不到有两种情况,必须和"确实是空会话"区分开:
103
+ * - 会话不存在
104
+ * - 会话正被当前 dsh 进程持有(正在聊天),此时 resolveCurrentLog 拿不到路径
105
+ * 后者如果当成空会话,正在用的那条就会从列表里消失。
106
+ */
107
+ export async function readSessionEvents(persistence, sessionId) {
108
+ const path = await persistence.resolveCurrentLog(sessionId);
109
+ if (!path) return null;
110
+ const stored = await persistence.readStoredLog(path, sessionId);
111
+ return Array.isArray(stored?.events) ? stored.events : null;
112
+ }
113
+
114
+ /**
115
+ * 提炼单个会话的摘要(标题、更新时间、消息数)。
116
+ *
117
+ * @returns messageCount 为 null 表示"读不到日志、内容未知"——调用方不应据此过滤。
118
+ */
119
+ export async function describeSession(persistence, sessionId) {
120
+ const events = await readSessionEvents(persistence, sessionId);
121
+ if (events !== null) return summarize({ id: sessionId }, events);
122
+
123
+ // 读不到日志时用元数据兜底:至少把 cwd 和创建时间填上
124
+ let header = { id: sessionId };
125
+ try {
126
+ const snapshot = await persistence.stat(sessionId);
127
+ if (snapshot?.header) header = snapshot.header;
128
+ } catch {
129
+ // 元数据也拿不到就保持最小信息
130
+ }
131
+
132
+ return {
133
+ id: sessionId,
134
+ cwd: header.cwd ?? '',
135
+ title: '',
136
+ updatedAt: typeof header.createdAt === 'number'
137
+ ? new Date(header.createdAt).toISOString()
138
+ : null,
139
+ messageCount: null,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * 删除一个会话。
145
+ *
146
+ * dsh 的 SessionPersistence 没有 delete,但提供了 resolveCurrentLog 找到
147
+ * 当前产物路径;一个会话目录里只有它的日志,整目录删掉即可。
148
+ *
149
+ * @returns 是否真的删掉了(会话不存在时返回 false)
150
+ */
151
+ export async function removeSession(persistence, sessionId) {
152
+ const path = await persistence.resolveCurrentLog(sessionId);
153
+ if (!path) return false;
154
+ await rm(dirname(path), { recursive: true, force: true });
155
+ return true;
156
+ }
package/package.json CHANGED
@@ -1,39 +1,38 @@
1
- {
2
- "name": "@wangchenglai/dsh-acp-session",
3
- "version": "0.1.0",
4
- "description": "为 DeepSeek Harness 的 ACP 补上 session/load、session/delete,并让 session/list 带标题与更新时间",
5
- "keywords": [
6
- "acp",
7
- "deepseek",
8
- "dsh",
9
- "agent"
10
- ],
11
- "repository": {
12
- "type": "git",
13
- "url": "https://gitservice.cstcloud.cn/jfli/agent-backend.git",
14
- "directory": "jupyter/dsh-plugins/acp-session"
15
- },
16
- "type": "module",
17
- "main": "index.js",
18
- "exports": {
19
- ".": "./index.js"
20
- },
21
- "files": [
22
- "index.js",
23
- "lib",
24
- "profile-patch.yml",
25
- "start-acp.sh",
26
- "README.md"
27
- ],
28
- "engines": {
29
- "node": ">=20"
30
- },
31
- "publishConfig": {
32
- "access": "public"
33
- },
34
- "license": "MIT",
35
- "dependencies": {
36
- "@agentclientprotocol/sdk": "1.4.0",
37
- "@deepseek-ai/dsh-acp": "0.2.0-rc.2"
38
- }
39
- }
1
+ {
2
+ "name": "@wangchenglai/dsh-acp-session",
3
+ "version": "0.1.1",
4
+ "description": "为 DeepSeek Harness 的 ACP 补上 session/load、session/delete,并让 session/list 带标题与更新时间",
5
+ "keywords": [
6
+ "acp",
7
+ "deepseek",
8
+ "dsh",
9
+ "agent"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://gitservice.cstcloud.cn/WangChengLai/dsh-acp-session.git"
14
+ },
15
+ "type": "module",
16
+ "main": "index.js",
17
+ "exports": {
18
+ ".": "./index.js"
19
+ },
20
+ "files": [
21
+ "index.js",
22
+ "lib",
23
+ "profile-patch.yml",
24
+ "start-acp.sh",
25
+ "README.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "license": "MIT",
34
+ "dependencies": {
35
+ "@agentclientprotocol/sdk": "1.4.0",
36
+ "@deepseek-ai/dsh-acp": "0.2.0-rc.2"
37
+ }
38
+ }
package/profile-patch.yml CHANGED
@@ -1,15 +1,15 @@
1
- # 叠加到 acp profile 上的一层 patch(用 dsh --profile acp --patch <本文件> 加载)。
2
- #
3
- # 1) 关掉 dsh 自带的 ACP 插件——stdio 只能由一个插件接管;
4
- # 2) 挂载本插件,config 里的 provider/model 与原来的 acp 行保持一致。
5
-
6
- - id: acp
7
- disabled: true
8
-
9
- - insert:
10
- - id: acp-session
11
- name: '@wangchenglai/dsh-acp-session'
12
- inject: [acpAppStartup]
13
- config:
14
- provider: deepseek-official
15
- model: deepseek-v4-flash
1
+ # 叠加到 acp profile 上的一层 patch(用 dsh --profile acp --patch <本文件> 加载)。
2
+ #
3
+ # 1) 关掉 dsh 自带的 ACP 插件——stdio 只能由一个插件接管;
4
+ # 2) 挂载本插件,config 里的 provider/model 与原来的 acp 行保持一致。
5
+
6
+ - id: acp
7
+ disabled: true
8
+
9
+ - insert:
10
+ - id: acp-session
11
+ name: '@wangchenglai/dsh-acp-session'
12
+ inject: [acpAppStartup]
13
+ config:
14
+ provider: deepseek-official
15
+ model: deepseek-v4-flash