dsh-edit-turn 0.2.21 → 0.3.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/index.js CHANGED
@@ -51,6 +51,17 @@ import { randomUUID } from 'node:crypto'
51
51
  import { readFile } from 'node:fs/promises'
52
52
  import { createRequire } from 'node:module'
53
53
  import { defineTool } from '@deepseek-ai/dsh-tools'
54
+ import * as sessionApi from '@deepseek-ai/dsh-session'
55
+ import {
56
+ PATCH_TYPE,
57
+ buildPatchData,
58
+ editableBlocks,
59
+ kindOfBlock,
60
+ patchList,
61
+ patchProjection,
62
+ patchesOf,
63
+ projectedBlocks,
64
+ } from './patch.js'
54
65
 
55
66
  const nodeRequire = createRequire(import.meta.url)
56
67
  const schemaPackage = (() => {
@@ -69,7 +80,7 @@ export const PLUGIN_ID = 'dsh-edit-turn'
69
80
  export const name = PLUGIN_ID
70
81
 
71
82
  /** Keep in sync with package.json and lib/client.js. */
72
- export const PLUGIN_VERSION = '0.2.21'
83
+ export const PLUGIN_VERSION = '0.3.1'
73
84
 
74
85
  // A ring of the last few requests, answering "did the browser half even ask, and
75
86
  // what did it get?" without needing its console. Read-only diagnostics: the
@@ -145,6 +156,14 @@ export const Config =
145
156
  * step.
146
157
  */
147
158
  confirm: z.boolean().default(false),
159
+ /**
160
+ * How a reply edit lands. `auto` uses the in-place plugin-record
161
+ * projection when the host exposes it and falls back to the historical
162
+ * replay splice otherwise; `patch` refuses to run without the
163
+ * projection; `replay` always uses the old path. Prompt edits always use
164
+ * the official surface replacement.
165
+ */
166
+ replyMode: z.string().default('auto'),
148
167
  })
149
168
 
150
169
  // ---------------------------------------------------------------------------
@@ -768,7 +787,7 @@ function resolveTarget(events, request) {
768
787
  * @returns `{ targetSeq, messageId, startSeq, endSeq, shadowed, mode, turn, step, provider, model, original, attachments, replayFrom, logTo, turnStart, turnEnd }`.
769
788
  * @throws {EditPlanError} with a stable code when the target cannot be planned.
770
789
  */
771
- export function planRollback(events, surfaceNodes, request) {
790
+ export function planRollback(events, surfaceNodes, request, options = {}) {
772
791
  const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
773
792
  const turnOf = turnIndex(events)
774
793
  const target = resolveTarget(events, request, turnOf)
@@ -781,12 +800,56 @@ export function planRollback(events, surfaceNodes, request) {
781
800
  throw new EditPlanError('not-editable', 'the system prompt head cannot be edited')
782
801
  }
783
802
  // Two editable kinds. A prompt is rewritten in place and the conversation
784
- // continues; a reply is replaced by an appended correction and the tail is
785
- // replayed behind it.
803
+ // continues; a reply is either patched in place through the message projection
804
+ // or, on a host without it, replaced by an appended correction and a replay.
786
805
  const mode = isHumanPrompt(target) ? 'prompt' : isAssistantReply(target) ? 'reply' : null
787
806
  if (mode === null) {
788
807
  throw new EditPlanError('not-editable', 'only your own messages and the model replies can be edited')
789
808
  }
809
+ const turn = turnOf.get(target.seq)
810
+ const data = target.data && typeof target.data === 'object' ? target.data : {}
811
+ const message = data.message && typeof data.message === 'object' ? data.message : data
812
+ const source = message.source && typeof message.source === 'object' ? message.source : null
813
+ const step = typeof data.step === 'number' ? data.step : 1
814
+ const contentBlocks = readMessageBlocks(target)
815
+
816
+ // The in-place path: one block of the reply, rewritten by a plugin record and
817
+ // its projection. Nothing leaves the surface, so there is no window and no
818
+ // replay; the tool calls and the other blocks stay exactly as recorded.
819
+ if (mode === 'reply' && options.patch === true) {
820
+ const patches = options.patches instanceof Map ? options.patches : new Map()
821
+ const blocks = projectedBlocks(message, target.seq, patches)
822
+ if (blocks.length === 0) {
823
+ throw new EditPlanError('not-editable', 'this reply has no editable block')
824
+ }
825
+ const wanted = typeof request.blockIndex === 'number'
826
+ ? blocks.find((block) => block.index === request.blockIndex)
827
+ : blocks.find((block) => block.kind === 'text') ?? blocks[0]
828
+ if (wanted === undefined) throw new EditPlanError('not-editable', 'the requested block does not exist')
829
+ if (typeof request.kind === 'string' && request.kind !== wanted.kind) {
830
+ throw new EditPlanError('not-editable', 'the requested block changed kind')
831
+ }
832
+ return {
833
+ targetSeq: target.seq,
834
+ messageId: messageIdOf(target),
835
+ mode: 'reply',
836
+ patch: true,
837
+ startSeq: null,
838
+ endSeq: null,
839
+ shadowed: [],
840
+ blockIndex: wanted.index,
841
+ kind: wanted.kind,
842
+ original: wanted.text,
843
+ // `blocks` here are the EDITOR's blocks (index/kind/text/original), not
844
+ // the raw content array; patch mode never rebuilds a message carrier.
845
+ blocks,
846
+ turn: typeof turn === 'number' ? turn : null,
847
+ step,
848
+ provider: source && typeof source.provider === 'string' ? source.provider : null,
849
+ model: source && typeof source.model === 'string' ? source.model : null,
850
+ attachments: contentBlocks.filter((block) => kindOfBlock(block) === null).length,
851
+ }
852
+ }
790
853
  const startIdx = nodeIndex.get(target.seq)
791
854
  // Two different windows, because the two edits mean different things.
792
855
  //
@@ -842,11 +905,7 @@ export function planRollback(events, surfaceNodes, request) {
842
905
  const { text, attachments } = readMessageText(target)
843
906
  // The blocks themselves travel with the plan: the carrier is rebuilt from
844
907
  // them, so no caller has to re-read the message to know what it carries.
845
- const blocks = readMessageBlocks(target)
846
- const turn = turnOf.get(target.seq)
847
- const data = target.data && typeof target.data === 'object' ? target.data : {}
848
- const message = data.message && typeof data.message === 'object' ? data.message : data
849
- const source = message.source && typeof message.source === 'object' ? message.source : null
908
+ const blocks = contentBlocks
850
909
  return {
851
910
  targetSeq: target.seq,
852
911
  messageId: messageIdOf(target),
@@ -854,10 +913,11 @@ export function planRollback(events, surfaceNodes, request) {
854
913
  endSeq: shadowed[shadowed.length - 1],
855
914
  shadowed,
856
915
  mode,
916
+ patch: false,
857
917
  turn: typeof turn === 'number' ? turn : null,
858
918
  // The appended correction is keyed by turn:step in the client's reducer, so a
859
919
  // missing step would make two corrections collide on "undefined:undefined".
860
- step: typeof data.step === 'number' ? data.step : 1,
920
+ step,
861
921
  provider: source && typeof source.provider === 'string' ? source.provider : null,
862
922
  model: source && typeof source.model === 'string' ? source.model : null,
863
923
  original: text,
@@ -1179,7 +1239,20 @@ export function editableTurns(events, surfaceNodes, options = {}) {
1179
1239
  if (!event || !isHumanPrompt(event)) continue
1180
1240
  if (seq === head) continue
1181
1241
  const { text, attachments } = readMessageText(event)
1182
- const turn = turnOf.get(seq)
1242
+ // A replacement carrier is appended after its original turn's bracket has
1243
+ // closed, so turnIndex cannot name its turn. It still stands at that
1244
+ // prompt's position in the conversation: inherit the turn from the event
1245
+ // it replaced, or the editor's turn list loses the row (turn: null).
1246
+ let turn = turnOf.get(seq)
1247
+ if (typeof turn !== 'number' && Array.isArray(event.sourceEventSeqs)) {
1248
+ for (const source of event.sourceEventSeqs) {
1249
+ const inherited = turnOf.get(source)
1250
+ if (typeof inherited === 'number') {
1251
+ turn = inherited
1252
+ break
1253
+ }
1254
+ }
1255
+ }
1183
1256
  out.push({
1184
1257
  seq,
1185
1258
  turn: typeof turn === 'number' ? turn : null,
@@ -1197,34 +1270,48 @@ export function editableTurns(events, surfaceNodes, options = {}) {
1197
1270
  /**
1198
1271
  * Every model reply that can currently be rewritten.
1199
1272
  *
1200
- * Replies with no text at all are skipped: a step that only produced tool calls
1201
- * has nothing to put in a text editor. A reply that carries tool calls *and*
1202
- * text is offered, because replacing it drops those calls along with it - the
1203
- * client warns about that, and the calls cannot survive a rewrite anyway.
1273
+ * In patch mode (the in-place projection path) a reply is offered when it has
1274
+ * any editable block - output text or a reasoning/thinking block - and the
1275
+ * blocks travel with the entry so the editor can pick one. In the legacy replay
1276
+ * mode only text is editable, and a reply without text is skipped.
1204
1277
  *
1205
1278
  * @param events - complete contiguous raw event log.
1206
1279
  * @param surfaceNodes - current surface seqs in model order.
1280
+ * @param options - `{ patches, patch }`: landed patch overlays and whether the
1281
+ * deployment can write a patch record.
1207
1282
  * @returns one entry per editable reply, in conversation order.
1208
1283
  */
1209
- export function editableReplies(events, surfaceNodes) {
1284
+ export function editableReplies(events, surfaceNodes, options = {}) {
1210
1285
  const nodeIndex = new Map(surfaceNodes.map((seq, index) => [seq, index]))
1211
1286
  const turnOf = turnIndex(events)
1212
1287
  const head = surfaceNodes[0]
1288
+ const patches = options.patches instanceof Map ? options.patches : new Map()
1289
+ const patch = options.patch === true
1213
1290
  const out = []
1214
1291
  for (const event of events) {
1215
1292
  if (!isAssistantReply(event)) continue
1216
1293
  if (!nodeIndex.has(event.seq)) continue
1217
1294
  if (event.seq === head) continue
1218
- const { text, attachments } = readMessageText(event)
1219
- if (text.trim() === '') continue
1295
+ const data = event.data && typeof event.data === 'object' ? event.data : {}
1296
+ const message = data.message && typeof data.message === 'object' ? data.message : data
1297
+ const blocks = projectedBlocks(message, event.seq, patches)
1298
+ if (blocks.length === 0) continue
1299
+ const text = blocks.filter((block) => block.kind === 'text').map((block) => block.text).join('\n')
1300
+ if (!patch && text.trim() === '') continue
1301
+ if (patch && blocks.every((block) => block.text.trim() === '')) continue
1220
1302
  const turn = turnOf.get(event.seq)
1221
1303
  out.push({
1222
1304
  seq: event.seq,
1223
1305
  turn: typeof turn === 'number' ? turn : null,
1224
1306
  messageId: messageIdOf(event) ?? null,
1225
1307
  text,
1226
- // For a reply these are the non-text blocks: tool calls and reasoning.
1227
- attachments,
1308
+ // For a reply these are the blocks a text-only rewrite would drop:
1309
+ // tool calls and any other non-editable content.
1310
+ attachments: readMessageBlocks(event).filter((block) => patch ? kindOfBlock(block) === null : !isTextBlock(block)).length,
1311
+ patched: blocks.some((block) => block.patched),
1312
+ // Present only where the editor can target one: the replay path can only
1313
+ // replace the whole reply, so it has no block picker.
1314
+ ...(patch ? { blocks } : {}),
1228
1315
  })
1229
1316
  }
1230
1317
  return out
@@ -1570,13 +1657,21 @@ async function stateOf(ctx, sessionId, config) {
1570
1657
  }
1571
1658
  const folded = foldSurface(events)
1572
1659
  const ledger = rollbackLedger(events)
1660
+ const patches = patchesOf(events)
1661
+ // A patch whose target has been retired (a later rerun shadowed it) is inert:
1662
+ // the projection skips it and no row can show it. Reporting it in /state made
1663
+ // the browser draw a patch that could never land, and made "which patches are
1664
+ // live" ambiguous. Keep the internal map for the replies; publish the active
1665
+ // subset only.
1666
+ const surfaceSet = new Set(folded.nodes)
1667
+ const activePatches = new Map([...patches].filter(([seq]) => surfaceSet.has(seq)))
1573
1668
  const live = findLiveSession(ctx, sessionId)
1574
1669
  // Probed per request: the editor offers what THIS deployment can do right
1575
1670
  // now, so a store that is unmounted (or not yet mounted) cannot leave the
1576
1671
  // editor promising to keep blocks the host would have to drop.
1577
1672
  const store = resolveAttachmentStore(ctx)
1578
1673
  const turns = editableTurns(events, folded.nodes, { preview: canPreviewAttachments(store) })
1579
- const replies = editableReplies(events, folded.nodes)
1674
+ const replies = editableReplies(events, folded.nodes, { patches, patch: config.patch === true })
1580
1675
  noteRequest({
1581
1676
  kind: 'state',
1582
1677
  sessionId,
@@ -1598,6 +1693,11 @@ async function stateOf(ctx, sessionId, config) {
1598
1693
  surface: folded.nodes,
1599
1694
  turns,
1600
1695
  replies,
1696
+ // The landed in-place patches, latest per block. The browser half uses them
1697
+ // to draw the projected text in place of the recorded one; the model already
1698
+ // sees them through the host projection.
1699
+ patches: patchList(activePatches),
1700
+ mode: config.patch === true ? 'patch' : 'replay',
1601
1701
  live: Boolean(live),
1602
1702
  busy: isBusy(events),
1603
1703
  lastSeq: events.length > 0 ? events[events.length - 1].seq : -1,
@@ -1606,7 +1706,7 @@ async function stateOf(ctx, sessionId, config) {
1606
1706
  // path (a chip that may draw its own thumbnail); both are answered from a
1607
1707
  // live service probe, so the editor never promises what the host cannot do.
1608
1708
  capabilities: { attachments: store !== null, preview: canPreviewAttachments(store) },
1609
- config: { confirm: config.confirm },
1709
+ config: { confirm: config.confirm, replyMode: config.replyMode, patch: config.patch === true },
1610
1710
  version: PLUGIN_VERSION,
1611
1711
  }
1612
1712
  }
@@ -1723,13 +1823,17 @@ async function applyEdit(ctx, sessionId, body, config) {
1723
1823
  if (isBusy(events)) throw new HttpError(409, 'busy', 'the session is still working')
1724
1824
 
1725
1825
  const surfaceNodes = surfaceOf(ctx, sessionId, events)
1826
+ const patches = patchesOf(events)
1827
+ const patchMode = config.patch === true
1726
1828
  let plan
1727
1829
  try {
1728
1830
  plan = planRollback(events, surfaceNodes, {
1729
1831
  seq: typeof body.seq === 'number' ? body.seq : undefined,
1730
1832
  messageId: typeof body.messageId === 'string' ? body.messageId : undefined,
1731
1833
  turn: typeof body.turn === 'number' ? body.turn : undefined,
1732
- })
1834
+ blockIndex: typeof body.blockIndex === 'number' ? body.blockIndex : undefined,
1835
+ kind: typeof body.kind === 'string' ? body.kind : undefined,
1836
+ }, { patches, patch: patchMode })
1733
1837
  } catch (error) {
1734
1838
  if (error instanceof EditPlanError) {
1735
1839
  const status = error.code === 'not-editable' ? 400 : 409
@@ -1738,6 +1842,56 @@ async function applyEdit(ctx, sessionId, body, config) {
1738
1842
  throw error
1739
1843
  }
1740
1844
 
1845
+ // The in-place reply path: one small plugin record, interpreted by this
1846
+ // plugin's projection. Nothing leaves the surface, so there is no window to
1847
+ // check, no turn to open and no tail to replay. The record is append-only and
1848
+ // the original event keeps its bytes; the model sees the patched block on the
1849
+ // next fold.
1850
+ if (plan.patch === true) {
1851
+ if (typeof sessionApi.appendPluginRecord !== 'function') {
1852
+ throw new HttpError(409, 'unsupported', 'this harness does not expose plugin records; reply edits need the replay path')
1853
+ }
1854
+ if (text === plan.original) {
1855
+ return {
1856
+ kind: 'reply',
1857
+ applied: false,
1858
+ unchanged: true,
1859
+ patch: true,
1860
+ shadowed: [],
1861
+ blockIndex: plan.blockIndex,
1862
+ kindOfBlock: plan.kind,
1863
+ original: plan.original,
1864
+ }
1865
+ }
1866
+ let recordSeq
1867
+ try {
1868
+ recordSeq = sessionApi.appendPluginRecord(
1869
+ session,
1870
+ PATCH_TYPE,
1871
+ buildPatchData([{ seq: plan.targetSeq, blockIndex: plan.blockIndex, kind: plan.kind, text }]),
1872
+ )
1873
+ } catch (error) {
1874
+ throw new HttpError(409, 'stale', `the session refused the patch record: ${String((error && error.message) || error)}`)
1875
+ }
1876
+ const flush = await flushSession(ctx, session)
1877
+ return {
1878
+ kind: 'reply',
1879
+ applied: true,
1880
+ mode: 'patch',
1881
+ patch: true,
1882
+ recordSeq,
1883
+ target: {
1884
+ seq: plan.targetSeq,
1885
+ blockIndex: plan.blockIndex,
1886
+ kind: plan.kind,
1887
+ text,
1888
+ original: plan.original,
1889
+ },
1890
+ shadowed: [],
1891
+ ...flush,
1892
+ }
1893
+ }
1894
+
1741
1895
  // The live surface is the append authority; a node that vanished between the
1742
1896
  // read and this check means another writer landed first.
1743
1897
  for (const seq of plan.shadowed) {
@@ -2008,14 +2162,14 @@ function sessionIdFromExec(exec) {
2008
2162
  return undefined
2009
2163
  }
2010
2164
 
2011
- function registerTool(ctx) {
2165
+ function registerTool(ctx, config) {
2012
2166
  const tools = ctx.get('tools')
2013
2167
  if (!tools || typeof tools.register !== 'function') return
2014
2168
  tools.register(
2015
2169
  defineTool({
2016
2170
  name: 'edit_turn_targets',
2017
2171
  description:
2018
- 'List the user turns and model replies of a DeepSeek Harness session that the edit-turn plugin can rewrite, with each message\'s current text. A prompt is replaced where it stands; a reply correction is appended and every later turn is replayed back behind it, with no model call. Read-only: it changes nothing.',
2172
+ 'List the user turns and model replies of a DeepSeek Harness session that the edit-turn plugin can rewrite, with each message\'s current text and, for a reply, every editable block (output text and reasoning). A prompt is replaced where it stands; a reply block is patched in place when the host supports projections, otherwise the reply correction is appended and the later turns are replayed behind it. Read-only: it changes nothing.',
2019
2173
  parameters: {
2020
2174
  sessionId: {
2021
2175
  type: 'string',
@@ -2035,8 +2189,9 @@ function registerTool(ctx) {
2035
2189
  const events = await readEvents(ctx, sessionId)
2036
2190
  if (!events) throw new Error(`no session log for ${sessionId}`)
2037
2191
  const nodes = surfaceOf(ctx, sessionId, events)
2192
+ const patches = patchesOf(events)
2038
2193
  const turns = editableTurns(events, nodes)
2039
- const replies = editableReplies(events, nodes)
2194
+ const replies = editableReplies(events, nodes, { patches, patch: config && config.patch === true })
2040
2195
  const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.floor(args.limit) : 20
2041
2196
  const shown = turns.slice(-limit)
2042
2197
  const shownReplies = replies.slice(-limit)
@@ -2050,10 +2205,13 @@ function registerTool(ctx) {
2050
2205
  if (turns.length > shown.length) lines.push(`(${turns.length - shown.length} earlier turn(s) omitted)`)
2051
2206
  for (const reply of shownReplies) {
2052
2207
  const preview = reply.text.length > 160 ? `${reply.text.slice(0, 160)}...` : reply.text
2053
- lines.push(`- reply (seq ${reply.seq}, ${reply.attachments} non-text part(s)): ${preview}`)
2208
+ const blocks = Array.isArray(reply.blocks)
2209
+ ? ` [${reply.blocks.map((block) => `${block.index}:${block.kind}${block.patched ? '*' : ''}`).join(', ')}]`
2210
+ : ''
2211
+ lines.push(`- reply (seq ${reply.seq}, ${reply.attachments} non-text part(s))${blocks}: ${preview}`)
2054
2212
  }
2055
2213
  if (replies.length > shownReplies.length) lines.push(`(${replies.length - shownReplies.length} earlier reply/replies omitted)`)
2056
- lines.push('editing a turn replaces its wording in place (no model call); editing a reply replaces its text and discards what follows. Re-running a turn is dsh-rerun-turn\'s button.')
2214
+ lines.push('editing a turn replaces its wording in place (no model call); editing a reply block patches it in place when the host supports plugin-record projections, otherwise it replaces the reply and replays the tail. Re-running a turn is dsh-rerun-turn\'s button.')
2057
2215
  return lines.join('\n')
2058
2216
  },
2059
2217
  }),
@@ -2182,7 +2340,38 @@ export function apply(ctx, config) {
2182
2340
  : DEFAULT_MARKER,
2183
2341
  carrier: config && config.carrier === 'user/message' ? 'user/message' : 'system/message',
2184
2342
  confirm: config && typeof config.confirm === 'boolean' ? config.confirm : false,
2343
+ replyMode: config && (config.replyMode === 'replay' || config.replyMode === 'patch') ? config.replyMode : 'auto',
2344
+ }
2345
+
2346
+ // The in-place reply path needs two host seams: a plugin record with the
2347
+ // `ignorable` envelope, and a message projection registry. Both are probed at
2348
+ // load; when either is missing the reply path keeps the official replay
2349
+ // splice, and `replyMode: 'patch'` turns that silence into a load error.
2350
+ const sessionsService = ctx.get('sessions')
2351
+ let patch = false
2352
+ if (
2353
+ resolved.replyMode !== 'replay' &&
2354
+ typeof sessionApi.appendPluginRecord === 'function' &&
2355
+ sessionsService &&
2356
+ typeof sessionsService.registerMessageProjection === 'function'
2357
+ ) {
2358
+ try {
2359
+ // A live reload (HMR, a re-applied bundle) leaves the previous
2360
+ // registration in place: the registry rejects a second definition of the
2361
+ // same type. Reuse ours instead of failing back to replay - the
2362
+ // projection is a stateless pure function, so the old instance is exactly
2363
+ // as good as a fresh one.
2364
+ const registry = sessionsService.messageProjections
2365
+ const existing = Array.isArray(registry)
2366
+ ? registry.find((entry) => entry && entry.type === PATCH_TYPE)
2367
+ : undefined
2368
+ if (existing === undefined) sessionsService.registerMessageProjection(patchProjection())
2369
+ patch = true
2370
+ } catch (error) {
2371
+ if (resolved.replyMode === 'patch') throw error
2372
+ }
2185
2373
  }
2374
+ resolved.patch = patch
2186
2375
 
2187
2376
  const registerRoutes = (webServer, fiber) => {
2188
2377
  fiber.effect(() =>
@@ -2261,7 +2450,19 @@ export function apply(ctx, config) {
2261
2450
  sendJson(res, 405, { ok: false, code: 'method', error: 'GET only' })
2262
2451
  return
2263
2452
  }
2264
- sendJson(res, 200, { ok: true, version: PLUGIN_VERSION, requests: recentRequests() })
2453
+ sendJson(res, 200, {
2454
+ ok: true,
2455
+ version: PLUGIN_VERSION,
2456
+ // How this deployment resolves a reply edit. `patch` means both
2457
+ // host seams were found and the projection registered; `replay`
2458
+ // means the historical splice is what a save will use.
2459
+ mode: resolved.patch === true ? 'patch' : 'replay',
2460
+ capabilities: {
2461
+ pluginRecords: typeof sessionApi.appendPluginRecord === 'function',
2462
+ projection: resolved.patch === true,
2463
+ },
2464
+ requests: recentRequests(),
2465
+ })
2265
2466
  },
2266
2467
  }),
2267
2468
  )
@@ -2342,9 +2543,9 @@ export function apply(ctx, config) {
2342
2543
 
2343
2544
  const tools = ctx.get('tools')
2344
2545
  if (tools) {
2345
- registerTool(ctx)
2546
+ registerTool(ctx, resolved)
2346
2547
  } else {
2347
- ctx.inject(['tools'], (sub) => registerTool(sub))
2548
+ ctx.inject(['tools'], (sub) => registerTool(sub, resolved))
2348
2549
  }
2349
2550
  }
2350
2551