@wangmo-bit/ds-link 0.0.0-stage → 0.8.18

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -2
  3. package/TESTING.md +59 -0
  4. package/THIRD_PARTY_NOTICES.md +313 -0
  5. package/assets/icon.svg +7 -0
  6. package/cordis.patch.yml +3 -0
  7. package/dist/client.js +6012 -0
  8. package/package.json +102 -4
  9. package/src/core/constants.js +188 -0
  10. package/src/core/downgrade.js +80 -0
  11. package/src/core/engine.js +4669 -0
  12. package/src/core/errors.js +51 -0
  13. package/src/core/ledger.js +1174 -0
  14. package/src/core/migration.js +741 -0
  15. package/src/core/render.js +61 -0
  16. package/src/core/settings.js +147 -0
  17. package/src/core/validation.js +454 -0
  18. package/src/host/adapter.js +1152 -0
  19. package/src/host/connection.js +113 -0
  20. package/src/host/entry-0.2.0.js +13 -0
  21. package/src/host/entry-0.3.0.js +13 -0
  22. package/src/host/entry-0.4.0.js +13 -0
  23. package/src/host/entry-0.8.10.js +13 -0
  24. package/src/host/entry-0.8.11.js +13 -0
  25. package/src/host/entry-0.8.12.js +13 -0
  26. package/src/host/entry-0.8.13.js +13 -0
  27. package/src/host/entry-0.8.14.js +13 -0
  28. package/src/host/entry-0.8.15.js +13 -0
  29. package/src/host/entry-0.8.16-pre.1.js +13 -0
  30. package/src/host/entry-0.8.16-pre.2.js +13 -0
  31. package/src/host/entry-0.8.16-pre.3.js +13 -0
  32. package/src/host/entry-0.8.16-pre.4.js +13 -0
  33. package/src/host/entry-0.8.16-pre.5.js +13 -0
  34. package/src/host/entry-0.8.18.js +13 -0
  35. package/src/host/entry-0.8.5.js +13 -0
  36. package/src/host/entry-0.8.6.js +13 -0
  37. package/src/host/entry-0.8.7.js +13 -0
  38. package/src/host/entry-0.8.8.js +13 -0
  39. package/src/host/entry-0.8.9.js +13 -0
  40. package/src/host/index.js +74 -0
  41. package/src/host/plugin.js +403 -0
  42. package/src/host/prompt.js +22 -0
  43. package/src/host/tools.js +312 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Model-facing tools. Identity always comes from exec.agent; no tool accepts
3
+ * `from`, source, hop, budget, or settings parameters. Arguments are strict
4
+ * objects and unknown keys reject before the engine runs.
5
+ */
6
+
7
+ import { LIMITS, TOOL_NAMES } from '../core/constants.js'
8
+ import { fail, errorCode } from '../core/errors.js'
9
+ import { assertAllowedKeys, assertPlainObject } from '../core/validation.js'
10
+
11
+ const OUTPUT_SCHEMA = Object.freeze({ type: 'object', additionalProperties: true })
12
+
13
+ const SUBJECT_HINT =
14
+ 'The optional one-line subject should be filled by the sender whenever it adds meaning; no extra model call is needed and it stays optional. When it is omitted, a readable short excerpt of the body is used instead, and the subject shares the existing byte limits (no new quota).'
15
+
16
+ /**
17
+ * @param {{ ctx: Record<string, any>, engine: Record<string, any>, adapter: Record<string, any> }} options
18
+ * @returns {Array<Record<string, any>>}
19
+ */
20
+ export function createToolDefinitions(options) {
21
+ const { ctx, engine, adapter } = options
22
+
23
+ /** @param {Record<string, any>} exec */
24
+ const requireCaller = async (exec) => {
25
+ const agent = exec?.agent
26
+ if (agent === undefined || agent === null || typeof agent.id !== 'string' || agent.id.length === 0) {
27
+ throw fail('identity_required', 'session-link tools require exec.agent; identity cannot be passed as an argument')
28
+ }
29
+ let registered
30
+ try {
31
+ registered = ctx.agents.get(agent.id)
32
+ } catch (error) {
33
+ throw fail('identity_unavailable', `agent registry is unavailable: ${errorCode(error)}`)
34
+ }
35
+ if (registered !== agent) {
36
+ throw fail('identity_mismatch', 'exec.agent is not the registered instance for this session')
37
+ }
38
+ const info = await adapter.getSession(agent.id, { fresh: true })
39
+ if (info === undefined || info === null || info.registered !== true) {
40
+ throw fail('identity_unregistered', 'caller is not a registered ordinary session')
41
+ }
42
+ if (info.subagent === true) throw fail('identity_subagent', 'subagent sessions cannot use session-link tools')
43
+ if (info.archived === true) throw fail('identity_archived', 'archived sessions cannot use session-link tools')
44
+ if (info.formatStatus === 'migration-required') {
45
+ throw fail('identity_migration_required', 'caller session requires migration')
46
+ }
47
+ if (info.eligible !== true) throw fail('identity_unavailable', 'caller is not an eligible ordinary session')
48
+ return { id: agent.id, title: info.title }
49
+ }
50
+
51
+ const operationTool = {
52
+ name: TOOL_NAMES.operation,
53
+ description:
54
+ 'Mint a unique session-link operationId. Call this before session_link_send, session_link_reply, or session_link_create and reuse the returned id for the exact same call; reusing an operationId with different content, targets, or parent is rejected.',
55
+ parameters: { type: 'object', properties: {}, required: [], additionalProperties: false },
56
+ output: { schema: OUTPUT_SCHEMA, render: (_args, value) => renderValue(value) },
57
+ async execute(args, exec) {
58
+ requireEmptyArgs(args)
59
+ await requireCaller(exec)
60
+ return engine.mintOperation()
61
+ },
62
+ }
63
+
64
+ const listTool = {
65
+ name: TOOL_NAMES.list,
66
+ description:
67
+ 'List ordinary, non-archived sessions registered in this Harness, or with scope=workspaces the registered workspaces (including empty ones) as {id,title,path}. Filter by workspace, title, or a literal keyword. Returns bounded pages with unique ids and nextCursor; workspace pages follow registry order only within one snapshot, so re-check by id after paging. Subagent sessions are never listed.',
68
+ parameters: {
69
+ type: 'object',
70
+ properties: {
71
+ workspace: { type: 'string' },
72
+ title: { type: 'string' },
73
+ query: { type: 'string' },
74
+ cursor: { type: 'string' },
75
+ limit: { type: 'integer' },
76
+ scope: { type: 'string', enum: ['sessions', 'workspaces'] },
77
+ },
78
+ required: [],
79
+ additionalProperties: false,
80
+ },
81
+ output: { schema: OUTPUT_SCHEMA, render: (_args, value) => renderValue(value) },
82
+ async execute(args, exec) {
83
+ const object = assertObject(args)
84
+ assertAllowedKeys(
85
+ object,
86
+ ['workspace', 'title', 'query', 'cursor', 'limit', 'scope'],
87
+ 'session_link_list arguments',
88
+ )
89
+ await requireCaller(exec)
90
+ return engine.list(object)
91
+ },
92
+ }
93
+
94
+ const historyTool = {
95
+ name: TOOL_NAMES.history,
96
+ description:
97
+ 'Read a bounded, read-only slice of another ordinary session history, including archived sessions. Recent mode returns the latest messages with seq provenance; search mode finds a literal keyword and returns cited seq snippets. Never returns a complete log.',
98
+ parameters: {
99
+ type: 'object',
100
+ properties: {
101
+ sessionId: { type: 'string' },
102
+ mode: { type: 'string', enum: ['recent', 'search'] },
103
+ query: { type: 'string' },
104
+ limit: { type: 'integer' },
105
+ beforeSeq: { type: 'integer' },
106
+ },
107
+ required: ['sessionId'],
108
+ additionalProperties: false,
109
+ },
110
+ output: { schema: OUTPUT_SCHEMA, render: (_args, value) => renderValue(value) },
111
+ async execute(args, exec) {
112
+ const object = assertObject(args)
113
+ assertAllowedKeys(object, ['sessionId', 'mode', 'query', 'limit', 'beforeSeq'], 'session_link_history arguments')
114
+ await requireCaller(exec)
115
+ return engine.history(object)
116
+ },
117
+ }
118
+
119
+ const sendTool = {
120
+ name: TOOL_NAMES.send,
121
+ description:
122
+ 'Send a notification or collaboration request to one or more ordinary sessions. Use session_link_operation first and pass its operationId. Targets are session ids. There is no per-call target-count quota; the whole call is bounded by the technical 1 MiB envelope. This does not authorize sensitive work for the recipient, and request messages must be answered explicitly with session_link_reply. ' +
123
+ SUBJECT_HINT,
124
+ parameters: {
125
+ type: 'object',
126
+ properties: {
127
+ targets: { type: 'array', items: { type: 'string' } },
128
+ kind: { type: 'string', enum: ['notification', 'request'] },
129
+ text: { type: 'string' },
130
+ operationId: { type: 'string' },
131
+ parentMessageId: { type: 'string' },
132
+ references: { type: 'array', items: { type: 'object', additionalProperties: true } },
133
+ paths: { type: 'array', items: { type: 'string' } },
134
+ subject: { type: 'string' },
135
+ },
136
+ required: ['targets', 'kind', 'text', 'operationId'],
137
+ additionalProperties: false,
138
+ },
139
+ output: { schema: OUTPUT_SCHEMA, render: (_args, value) => renderValue(value) },
140
+ async execute(args, exec) {
141
+ const object = assertObject(args)
142
+ assertAllowedKeys(
143
+ object,
144
+ ['targets', 'kind', 'text', 'operationId', 'parentMessageId', 'references', 'paths', 'subject'],
145
+ 'session_link_send arguments',
146
+ )
147
+ const caller = await requireCaller(exec)
148
+ return engine.send({
149
+ ...object,
150
+ from: caller.id,
151
+ fromTitle: caller.title,
152
+ })
153
+ },
154
+ }
155
+
156
+ const replyTool = {
157
+ name: TOOL_NAMES.reply,
158
+ description:
159
+ 'Explicitly reply to a request received through session-link. Only the original recipient may reply, the target is forced to the original sender, and the reply is linked by messageId. Use session_link_operation first and pass its operationId. ' +
160
+ SUBJECT_HINT,
161
+ parameters: {
162
+ type: 'object',
163
+ properties: {
164
+ messageId: { type: 'string' },
165
+ text: { type: 'string' },
166
+ operationId: { type: 'string' },
167
+ references: { type: 'array', items: { type: 'object', additionalProperties: true } },
168
+ paths: { type: 'array', items: { type: 'string' } },
169
+ subject: { type: 'string' },
170
+ },
171
+ required: ['messageId', 'text', 'operationId'],
172
+ additionalProperties: false,
173
+ },
174
+ output: { schema: OUTPUT_SCHEMA, render: (_args, value) => renderValue(value) },
175
+ async execute(args, exec) {
176
+ const object = assertObject(args)
177
+ assertAllowedKeys(object, ['messageId', 'text', 'operationId', 'references', 'paths', 'subject'], 'session_link_reply arguments')
178
+ const caller = await requireCaller(exec)
179
+ return engine.reply({
180
+ ...object,
181
+ from: caller.id,
182
+ fromTitle: caller.title,
183
+ })
184
+ },
185
+ }
186
+
187
+ const statusTool = {
188
+ name: TOOL_NAMES.status,
189
+ description:
190
+ 'Read bounded delivery and reply state for messages involving this session. Pass messageId or chainId to narrow it. No answer means waiting, not success. Use this instead of treating a recipient\'s last assistant message as a reply.',
191
+ parameters: {
192
+ type: 'object',
193
+ properties: {
194
+ messageId: { type: 'string' },
195
+ chainId: { type: 'string' },
196
+ },
197
+ required: [],
198
+ additionalProperties: false,
199
+ },
200
+ output: { schema: OUTPUT_SCHEMA, render: (_args, value) => renderValue(value) },
201
+ async execute(args, exec) {
202
+ const object = assertObject(args)
203
+ assertAllowedKeys(object, ['messageId', 'chainId'], 'session_link_status arguments')
204
+ const caller = await requireCaller(exec)
205
+ return engine.status({ ...object, from: caller.id })
206
+ },
207
+ }
208
+
209
+ const controlTool = {
210
+ name: TOOL_NAMES.control,
211
+ description:
212
+ 'Control a chain you own: pause, close, or cancel your own pending/held messages. Only the root owner may pause or close; a model cannot resume, change global settings, or resolve unknown records. Closing cancels undelivered pending/held messages but cannot withdraw an in-flight delivery.',
213
+ parameters: {
214
+ type: 'object',
215
+ properties: {
216
+ action: { type: 'string', enum: ['pause', 'cancel', 'close'] },
217
+ id: { type: 'string' },
218
+ },
219
+ required: ['action', 'id'],
220
+ additionalProperties: false,
221
+ },
222
+ output: { schema: OUTPUT_SCHEMA, render: (_args, value) => renderValue(value) },
223
+ async execute(args, exec) {
224
+ const object = assertObject(args)
225
+ assertAllowedKeys(object, ['action', 'id'], 'session_link_control arguments')
226
+ const caller = await requireCaller(exec)
227
+ return engine.modelControl({ ...object, from: caller.id })
228
+ },
229
+ }
230
+
231
+ const createTool = {
232
+ name: TOOL_NAMES.create,
233
+ description:
234
+ 'Create a new ordinary session in an explicit location and immediately send it its first task as a request through the standard agent-message relay; empty sessions are not offered. Call session_link_operation first and pass its operationId. Provide exactly one of workspaceId (an existing registered workspace, discoverable with session_link_list scope=workspaces), newProject:{parentDir,directoryName,title?} (creates a single-level directory under an existing parent without overwriting), or existingProject:{path,title?} (registers an already existing project directory via the Host workspace registry; it never creates, moves, reads, or modifies directory contents). Never pass a bare cwd. A retry with the same operationId continues the same created session and first message instead of creating another. In the result, phase "sent" only means a first-message record exists — firstMessage.status is authoritative and may be held, pending, or unknown; never treat "sent" as accepted, read, processed, or task started. accepted on the first message only means the Host durably took it, never that the task ran. ' +
235
+ SUBJECT_HINT,
236
+ parameters: {
237
+ type: 'object',
238
+ properties: {
239
+ operationId: { type: 'string' },
240
+ task: { type: 'string' },
241
+ workspaceId: { type: 'string' },
242
+ newProject: {
243
+ type: 'object',
244
+ properties: {
245
+ parentDir: { type: 'string' },
246
+ directoryName: { type: 'string' },
247
+ title: { type: 'string' },
248
+ },
249
+ required: ['parentDir', 'directoryName'],
250
+ additionalProperties: false,
251
+ },
252
+ existingProject: {
253
+ type: 'object',
254
+ properties: {
255
+ path: { type: 'string' },
256
+ title: { type: 'string' },
257
+ },
258
+ required: ['path'],
259
+ additionalProperties: false,
260
+ },
261
+ title: { type: 'string' },
262
+ subject: { type: 'string' },
263
+ },
264
+ required: ['operationId', 'task'],
265
+ additionalProperties: false,
266
+ },
267
+ output: { schema: OUTPUT_SCHEMA, render: (_args, value) => renderValue(value) },
268
+ async execute(args, exec) {
269
+ const object = assertObject(args)
270
+ assertAllowedKeys(
271
+ object,
272
+ ['operationId', 'task', 'workspaceId', 'newProject', 'existingProject', 'title', 'subject'],
273
+ 'session_link_create arguments',
274
+ )
275
+ const caller = await requireCaller(exec)
276
+ return engine.create({
277
+ ...object,
278
+ from: caller.id,
279
+ })
280
+ },
281
+ }
282
+
283
+ return [operationTool, listTool, historyTool, sendTool, replyTool, statusTool, controlTool, createTool]
284
+ }
285
+
286
+ /** @param {unknown} args @returns {Record<string, any>} */
287
+ function assertObject(args) {
288
+ return assertPlainObject(args ?? {}, 'arguments', 'tool_args')
289
+ }
290
+
291
+ /** @param {unknown} args */
292
+ function requireEmptyArgs(args) {
293
+ const object = assertObject(args)
294
+ assertAllowedKeys(object, [], 'session_link_operation arguments')
295
+ }
296
+
297
+ /**
298
+ * Render one bounded JSON value as text. Tool values are small by construction;
299
+ * the cap protects the model context if an unexpected host title is huge.
300
+ * @param {unknown} value
301
+ * @returns {Array<{ type: 'text', text: string }>}
302
+ */
303
+ function renderValue(value) {
304
+ const text = JSON.stringify(value, null, 2)
305
+ const bounded = typeof text === 'string' ? text : '{}'
306
+ return [
307
+ {
308
+ type: 'text',
309
+ text: bounded.length > LIMITS.HISTORY_MAX_CHARS ? `${bounded.slice(0, LIMITS.HISTORY_MAX_CHARS - 40)}\n… result truncated` : bounded,
310
+ },
311
+ ]
312
+ }