@everme/dsh 0.6.4 → 0.6.5

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 (3) hide show
  1. package/README.md +47 -0
  2. package/index.js +117 -18
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -10,6 +10,53 @@ The Cordis plugin complements `@everme/memory-mcp`:
10
10
  - Recall and save failures degrade open: DSH continues without blocking the user turn.
11
11
  - The MCP server remains available through `npx -y @everme/memory-mcp@latest` for explicit read-only `mem_context` and `mem_search` tool calls (saving is automatic via the hooks).
12
12
 
13
+ ## Message conversion
14
+
15
+ The native turn-end writer uses task-aware batching: preserve fitting tasks,
16
+ compress oversized tool results with 4/3/2 KiB budgets, then split only if
17
+ necessary. This does not alter the existing flush-on-turn-end contract.
18
+ Native child sessions without genuine human input are skipped at the writer.
19
+ After a native browser-authored follow-up, later assistant-only turns remain
20
+ eligible; root sessions are not subject to this child-only admission rule.
21
+
22
+ The native turn collector uses `turn/start` and `turn/end` from the session log.
23
+ Only `user/message` records whose source is `user` become human messages;
24
+ plugin context, reasoning, and control events do not become conversation text.
25
+ For native child sessions (`origin: subagent` with a v3 one-shot or continuable
26
+ descriptor), initial delegated tasks use a bare user source and are excluded.
27
+ Browser-authored follow-ups retain their native `source.rpcId` and are preserved.
28
+ This distinction does not depend on message position, text, or configurable
29
+ provider names. Root-session user messages and child assistant/tool activity
30
+ are unchanged; inherited history is not replayed as new turn content.
31
+ Trusted user text is extracted without channel-metadata pattern stripping, so
32
+ quoted Sender blocks, message IDs, and timestamps remain part of the dialogue.
33
+ Recall query cleaning is unchanged; ordinary write limits still apply.
34
+ Assistant tool-call blocks carry the provider's string ID and name. Arguments
35
+ must be a valid JSON string; the native runtime's explicit empty-string argument
36
+ form means no parameters and is encoded as `{}`. Missing fields are not filled in.
37
+ The separate `tool/call` log record is not uploaded a second time.
38
+
39
+ Tool results retain their native `toolCallId` and content array. An explicit
40
+ empty array remains one empty result; a missing content field is rejected.
41
+ Long tool results have no additional 8000-character cap. Normal text extraction,
42
+ ordinary-message limits, and the SDK's request budgets still apply. Invalid
43
+ native IDs, names, arguments, or result fields are counted in bounded diagnostics;
44
+ valid neighboring text is retained. A missing real session ID prevents the write.
45
+ When an invalid call has a unique native ID within the turn, its corresponding
46
+ result is excluded as well. Duplicate native IDs are ambiguous, so they do not
47
+ authorize guessing which result belongs to the rejected call.
48
+
49
+ Only an actual final assistant text message in a `completed` turn, without tool
50
+ calls or interruption, carries the internal completion marker. A tool can itself
51
+ conclude a native DSH turn, so `turn/end` does not authorize inventing a final
52
+ assistant message. Other assistant messages explicitly remain non-final. The
53
+ marker reaches the BFF for batching but is not sent to EverOS.
54
+
55
+ `collectTurnMessages` accepts an optional fourth logger argument for conversion
56
+ diagnostics; existing three-argument callers remain compatible. Historical
57
+ non-native object arguments, string result containers, and coerced IDs are not
58
+ part of the native DSH contract and are rejected rather than repaired.
59
+
13
60
  ## Install
14
61
 
15
62
  Use EverCLI so the native plugin, MCP server, Cordis patch, and credentials stay in sync:
package/index.js CHANGED
@@ -2,6 +2,7 @@ import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
2
  import {
3
3
  assertConfigUsable,
4
4
  createClient,
5
+ extractText,
5
6
  redactError,
6
7
  resolveConfig,
7
8
  runInject,
@@ -64,13 +65,22 @@ export function installEverMeHooks(ctx, config = {}, dependencies = {}) {
64
65
 
65
66
  ctx.on("session/event", (session, event) => {
66
67
  if (event?.type !== "turn/end") return;
68
+ if (!validId(session?.id)) {
69
+ log.warn("[everme] conversion reason=invalid_session_id dropped=1");
70
+ return;
71
+ }
67
72
  const turn = event.data?.turn;
68
- const messages = collectTurnMessages(session, turn, event.seq);
73
+ const messages = collectTurnMessages(session, turn, event.seq, log);
69
74
  if (!messages.length) return;
75
+ if (isNativeSubagent(session) && !hasHumanInput(session, event.seq)) {
76
+ log.warn("[everme] conversion reason=subagent_without_user skipped=1");
77
+ return;
78
+ }
70
79
  enqueue(pending, session, async () => {
71
80
  await save(client, {
72
- conversationId: String(session.id),
81
+ conversationId: session.id,
73
82
  messages,
83
+ taskBatching: true,
74
84
  flush: true,
75
85
  // The host numbers turns from 1; the gate's whole-turn ordinals
76
86
  // start at 0. Claiming the hook channel lets the gate advance its
@@ -88,7 +98,7 @@ export function installEverMeHooks(ctx, config = {}, dependencies = {}) {
88
98
  return { enabled: true, pending };
89
99
  }
90
100
 
91
- export function collectTurnMessages(session, turn, endSeq = Number.POSITIVE_INFINITY) {
101
+ export function collectTurnMessages(session, turn, endSeq = Number.POSITIVE_INFINITY, log = { warn() {} }) {
92
102
  if (!session || !Number.isSafeInteger(turn) || !Array.isArray(session.events)) return [];
93
103
  const events = session.events;
94
104
  let startIndex = -1;
@@ -105,60 +115,148 @@ export function collectTurnMessages(session, turn, endSeq = Number.POSITIVE_INFI
105
115
  break;
106
116
  }
107
117
  }
108
- if (startIndex < 0) return [];
118
+ if (startIndex < 0) {
119
+ log.warn("[everme] conversion reason=missing_turn_start dropped=1");
120
+ return [];
121
+ }
109
122
 
110
123
  const messages = [];
111
- for (const event of events.slice(startIndex + 1, endIndex)) {
112
- const converted = convertSessionEvent(event);
124
+ const rejected = new Map();
125
+ const reject = (reason) => rejected.set(reason, (rejected.get(reason) || 0) + 1);
126
+ const turnEvents = events.slice(startIndex + 1, endIndex);
127
+ const invalidResults = rejectedResultIDs(turnEvents);
128
+ const nativeSubagent = isNativeSubagent(session);
129
+ const lastSurface = turnEvents.findLast(event => ["user/message", "assistant/message", "tool/result"].includes(event.type));
130
+ const completed = turnEvents.at(-1)?.type === "turn/end" && turnEvents.at(-1).data?.reason?.kind === "completed";
131
+ for (const event of turnEvents) {
132
+ const converted = convertSessionEvent(event, reject, nativeSubagent);
133
+ if (converted?.role === "tool" && invalidResults.has(converted.toolCallId)) {
134
+ reject(invalidResults.get(converted.toolCallId));
135
+ continue;
136
+ }
137
+ if (converted?.role === "assistant") {
138
+ const original = event.data?.message?.content || [];
139
+ converted.turnComplete = completed && event === lastSurface && !event.data?.interrupted
140
+ && !original.some(block => block.type === "tool-call")
141
+ && converted.content.some(block => block.type === "text" && block.text.trim());
142
+ }
113
143
  if (converted) messages.push(converted);
114
144
  }
145
+ for (const [reason, count] of rejected) log.warn(`[everme] conversion reason=${reason} dropped=${count}`);
115
146
  return messages;
116
147
  }
117
148
 
118
- function convertSessionEvent(event) {
149
+ function isNativeSubagent(session) {
150
+ if (session.header?.origin !== "subagent") return false;
151
+ const descriptor = session.events.find(event => event.type === "subagent/descriptor")?.data;
152
+ return descriptor?.version === 3 && ["one-shot", "continuable"].includes(descriptor.mode);
153
+ }
154
+
155
+ function hasHumanInput(session, endSeq) {
156
+ return session.events.some(event => event.seq <= endSeq && event.type === "user/message"
157
+ && convertSessionEvent(event, () => {}, true)?.role === "user");
158
+ }
159
+
160
+ function convertSessionEvent(event, reject, nativeSubagent) {
119
161
  if (event?.type === "user/message") {
120
162
  const message = event.data;
121
- if (message?.source?.kind !== "user") return null;
122
- const content = toText(message.content);
163
+ if (message?.source?.kind !== "user") {
164
+ reject("non_user_source");
165
+ return null;
166
+ }
167
+ if (nativeSubagent && !("rpcId" in message.source)) {
168
+ reject("delegated_task");
169
+ return null;
170
+ }
171
+ const content = extractText(message.content).trim();
172
+ if (!content) reject("empty_message");
123
173
  return content ? { role: "user", content, timestamp: event.time } : null;
124
174
  }
125
175
 
126
176
  if (event?.type === "assistant/message") {
127
- const content = normalizeAssistantContent(event.data?.message?.content);
177
+ const content = normalizeAssistantContent(event.data?.message?.content, reject);
178
+ if (!content.length) reject("empty_message");
128
179
  return content.length ? { role: "assistant", content, timestamp: event.time } : null;
129
180
  }
130
181
 
131
182
  if (event?.type === "tool/result") {
132
183
  const block = event.data?.message?.content?.find((item) => item?.type === "tool-result");
133
- if (!block?.toolCallId) return null;
184
+ if (!validId(block?.toolCallId)) {
185
+ reject("invalid_tool_result_id");
186
+ return null;
187
+ }
188
+ if (!Array.isArray(block.content)) {
189
+ reject("invalid_tool_result_content");
190
+ return null;
191
+ }
134
192
  return {
135
193
  role: "tool",
136
- toolCallId: String(block.toolCallId),
137
- content: block.content || [],
194
+ toolCallId: block.toolCallId,
195
+ content: block.content,
138
196
  timestamp: event.time,
139
197
  };
140
198
  }
141
199
 
200
+ if (!["turn/start", "turn/end", "step/start", "step/end", "tool/call"].includes(event?.type)) reject("non_dialogue_event");
142
201
  return null;
143
202
  }
144
203
 
145
- function normalizeAssistantContent(content) {
204
+ function normalizeAssistantContent(content, reject) {
146
205
  const normalized = [];
147
206
  for (const block of Array.isArray(content) ? content : []) {
148
207
  if (block?.type === "text" && block.text) {
149
208
  normalized.push({ type: "text", text: block.text });
150
- } else if (block?.type === "tool-call" && block.id) {
209
+ } else if (block?.type === "tool-call") {
210
+ const reason = invalidCallReason(block);
211
+ if (reason) {
212
+ reject(reason);
213
+ continue;
214
+ }
151
215
  normalized.push({
152
216
  type: "toolCall",
153
- id: String(block.id),
154
- name: block.name || "unknown",
217
+ id: block.id,
218
+ name: block.name,
155
219
  arguments: block.arguments || "{}",
156
220
  });
221
+ } else if (block?.type !== "text") {
222
+ reject("non_dialogue_content");
157
223
  }
158
224
  }
159
225
  return normalized;
160
226
  }
161
227
 
228
+ function validId(value) {
229
+ return typeof value === "string" && value.trim().length > 0;
230
+ }
231
+
232
+ function rejectedResultIDs(events) {
233
+ const counts = new Map();
234
+ const invalid = new Map();
235
+ for (const event of events) {
236
+ if (event.type !== "assistant/message") continue;
237
+ for (const block of event.data?.message?.content || []) {
238
+ if (block.type !== "tool-call" || !validId(block.id)) continue;
239
+ counts.set(block.id, (counts.get(block.id) || 0) + 1);
240
+ const reason = invalidCallReason(block);
241
+ if (reason) invalid.set(block.id, reason);
242
+ }
243
+ }
244
+ // Duplicate native IDs do not identify which call owns a result.
245
+ return new Map([...invalid].filter(([id]) => counts.get(id) === 1));
246
+ }
247
+
248
+ function invalidCallReason(block) {
249
+ if (!validId(block.id)) return "invalid_tool_call_id";
250
+ if (!validId(block.name)) return "invalid_tool_name";
251
+ if (typeof block.arguments !== "string") return "invalid_tool_arguments";
252
+ try {
253
+ JSON.parse(block.arguments || "{}");
254
+ } catch {
255
+ return "invalid_tool_arguments";
256
+ }
257
+ return "";
258
+ }
259
+
162
260
  function humanPrompt(messages) {
163
261
  return (Array.isArray(messages) ? messages : [])
164
262
  .filter((message) => message?.source?.kind === "user")
@@ -192,5 +290,6 @@ function createLogger(ctx, override) {
192
290
  }
193
291
 
194
292
  function safeError(error) {
195
- return redactError(error instanceof Error ? error.message : String(error));
293
+ const text = redactError(error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim();
294
+ return text.length > 240 ? `${text.slice(0, 240)}…` : text;
196
295
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everme/dsh",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "description": "Native EverMe lifecycle hooks for DeepSeek Harness.",
6
6
  "license": "Apache-2.0",
@@ -43,7 +43,7 @@
43
43
  "registry": "https://registry.npmjs.org"
44
44
  },
45
45
  "dependencies": {
46
- "@everme/agent-sdk": "^0.6.4"
46
+ "@everme/agent-sdk": "^0.6.5"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0"