dsh-remote 0.8.71 → 0.8.73
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/LICENSE +21 -21
- package/README.en.md +389 -389
- package/README.md +462 -462
- package/cordis.patch.yml +9 -9
- package/lib/audit.js +45 -0
- package/lib/binding.js +172 -172
- package/lib/client.js +4022 -3990
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -118
- package/lib/exec-job.js +235 -235
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -202
- package/lib/index.js +4550 -4548
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -886
- package/lib/registry.js +296 -293
- package/lib/remote-fs.js +173 -173
- package/lib/remote-sandbox.js +183 -183
- package/lib/remote-shell-child.cjs +344 -305
- package/lib/remote-shell.js +542 -518
- package/lib/routes-fs.js +245 -245
- package/lib/routes-term.js +169 -169
- package/lib/search.js +235 -235
- package/lib/sftp-surface.js +119 -119
- package/lib/sshconfig.js +779 -705
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/terminal.js +205 -205
- package/lib/update.js +472 -472
- package/lib/web-attach.js +850 -850
- package/package.json +100 -99
|
@@ -1,415 +1,415 @@
|
|
|
1
|
-
// dsh-remote — dynamic remote instruction injection (issue #71).
|
|
2
|
-
//
|
|
3
|
-
// Issue #68 gave a remote workspace a STATIC instruction index: the workspace
|
|
4
|
-
// is shallow-scanned at pick/sync time, the files are mirrored locally, and the
|
|
5
|
-
// prompt lists them. What remained missing is the DYNAMIC half the core does
|
|
6
|
-
// for local sessions — three gaps, all plugin-side fixable:
|
|
7
|
-
//
|
|
8
|
-
// 1. The mirrored copies never refreshed, so a mid-session edit to a remote
|
|
9
|
-
// AGENTS.md was invisible until a manual rw_sync.
|
|
10
|
-
// 2. The core's incremental injection only recognizes read/write/edit with a
|
|
11
|
-
// `file_path` argument. `rw_read_file` is named differently AND takes
|
|
12
|
-
// `path`, so a remote file touch never registered.
|
|
13
|
-
// 3. The core expands scopes from cwd to the TOUCHED path — both local. A
|
|
14
|
-
// remote absolute path (`/root/proj/x.md`) can never map onto a local
|
|
15
|
-
// mirror cwd, so even a "touch" would expand nothing.
|
|
16
|
-
//
|
|
17
|
-
// This module closes all three without a single line of core change:
|
|
18
|
-
// • `noteTouchedPath` turns an rw_* file touch into the FULL ancestor chain
|
|
19
|
-
// below the workspace root (the same shape core's descendantDirsBetween
|
|
20
|
-
// produces — every level, not just the deepest);
|
|
21
|
-
// • `refreshTouched` stats each level's AGENTS.md/CLAUDE.md, pulls the ones
|
|
22
|
-
// that changed, and returns the chain's current contents;
|
|
23
|
-
// • `buildInstructionMessage` renders that into ONE user message whose source
|
|
24
|
-
// is our OWN kind, so the core's `agent-instructions` state machine never
|
|
25
|
-
// confuses it for its own (that is what keeps the two paths from fighting).
|
|
26
|
-
//
|
|
27
|
-
// Everything here is best-effort and synchronous-safe: an offline machine must
|
|
28
|
-
// never hang a tool call or a prompt assembly, so every remote step is guarded
|
|
29
|
-
// and every caller is expected to treat a rejection as "no injection".
|
|
30
|
-
|
|
31
|
-
import { statSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs'
|
|
32
|
-
import { createHash, randomUUID } from 'node:crypto'
|
|
33
|
-
import { createRequire } from 'node:module'
|
|
34
|
-
import path from 'node:path'
|
|
35
|
-
import { joinRemotePath, relPathUnder } from './paths.js'
|
|
36
|
-
import { INSTRUCTION_MAX_BYTES } from './instructions.js'
|
|
37
|
-
|
|
38
|
-
/** The message source kind this plugin owns. Deliberately NOT the core's
|
|
39
|
-
* `agent-instructions`: consumers fall through unknown kinds, and the core's
|
|
40
|
-
* reconcile only claims messages carrying its own kind. */
|
|
41
|
-
export const REMOTE_INSTRUCTIONS_KIND = 'dsh-remote-instructions'
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* The user-message factory hanging off the `llm` service, when that service
|
|
45
|
-
* actually exposes one.
|
|
46
|
-
*
|
|
47
|
-
* DSH 0.2.1 keeps `createUserMessage` as a module export of
|
|
48
|
-
* `@deepseek-ai/dsh-llm`, not a method of the `LlmRuntime` service. Probing
|
|
49
|
-
* `ctx.get('llm')` therefore returns null on current Desktop builds, and the
|
|
50
|
-
* caller must fall through to {@link loadBundledUserMessageFactory}.
|
|
51
|
-
*
|
|
52
|
-
* @param {object|null|undefined} llm - `ctx.get('llm')`, or a test double.
|
|
53
|
-
* @returns {Function|null}
|
|
54
|
-
*/
|
|
55
|
-
export function pickUserMessageFactory(llm) {
|
|
56
|
-
if (llm && typeof llm.createUserMessage === 'function') {
|
|
57
|
-
return (input) => llm.createUserMessage(input)
|
|
58
|
-
}
|
|
59
|
-
const nested = llm && llm.default
|
|
60
|
-
if (nested && typeof nested.createUserMessage === 'function') {
|
|
61
|
-
return (input) => nested.createUserMessage(input)
|
|
62
|
-
}
|
|
63
|
-
return null
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Module-level `createUserMessage` from the harness's dsh-llm, if resolvable. */
|
|
67
|
-
let bundledUserMessageFactory
|
|
68
|
-
/**
|
|
69
|
-
* Load `@deepseek-ai/dsh-llm`'s `createUserMessage`.
|
|
70
|
-
*
|
|
71
|
-
* The package is not a declared dependency of this plugin (the compatibility
|
|
72
|
-
* gate would then have to peer-range it). Inside a DSH profile it is a sibling
|
|
73
|
-
* in `node_modules`, and Node walks up from this file to find it. A missing
|
|
74
|
-
* package returns null so the built-in identified message is used instead.
|
|
75
|
-
*
|
|
76
|
-
* @returns {Function|null}
|
|
77
|
-
*/
|
|
78
|
-
export function loadBundledUserMessageFactory() {
|
|
79
|
-
if (bundledUserMessageFactory !== undefined) return bundledUserMessageFactory
|
|
80
|
-
try {
|
|
81
|
-
const req = createRequire(import.meta.url)
|
|
82
|
-
const mod = req('@deepseek-ai/dsh-llm')
|
|
83
|
-
bundledUserMessageFactory = mod && typeof mod.createUserMessage === 'function'
|
|
84
|
-
? mod.createUserMessage
|
|
85
|
-
: null
|
|
86
|
-
} catch {
|
|
87
|
-
bundledUserMessageFactory = null
|
|
88
|
-
}
|
|
89
|
-
return bundledUserMessageFactory
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Guarantee the session-restore invariant: `message.id` is a non-empty string.
|
|
94
|
-
*
|
|
95
|
-
* DSH 0.2.1 (`dsh-session` `assertMessageEventShape`) rejects the whole log
|
|
96
|
-
* with "lacks an identified message" when any user message has no id. A
|
|
97
|
-
* factory that already stamped one is returned unchanged.
|
|
98
|
-
*
|
|
99
|
-
* @param {object|null} message - a factory result, or null to use `fallback`.
|
|
100
|
-
* @param {object} fallback - `{ role, content, source }` used when `message` is empty.
|
|
101
|
-
* @returns {object}
|
|
102
|
-
*/
|
|
103
|
-
export function ensureMessageId(message, fallback) {
|
|
104
|
-
const base = message && typeof message === 'object' ? message : fallback
|
|
105
|
-
if (typeof base.id === 'string' && base.id !== '') return base
|
|
106
|
-
return {
|
|
107
|
-
...fallback,
|
|
108
|
-
...base,
|
|
109
|
-
role: base.role || fallback.role,
|
|
110
|
-
id: randomUUID(),
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Total byte budget for one injected batch (the core's own budget is similar). */
|
|
115
|
-
export const INSTRUCTION_MESSAGE_MAX_BYTES = 16 * 1024
|
|
116
|
-
|
|
117
|
-
/** Per-session throttle for the stat-and-refresh sweep, in milliseconds. */
|
|
118
|
-
export const REFRESH_THROTTLE_MS = 30 * 1000
|
|
119
|
-
|
|
120
|
-
const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Split a remote path into the ancestor directories strictly BELOW `root`,
|
|
124
|
-
* root-excluded — the core's `ancestorChain(cwd, dir).slice(1)` shape.
|
|
125
|
-
*
|
|
126
|
-
* Reading `<root>/a/b/c.ts` yields `['a', 'a/b']`, so `a/AGENTS.md` and
|
|
127
|
-
* `a/b/AGENTS.md` are both in scope while `<root>/AGENTS.md` (the baseline
|
|
128
|
-
* layer) is not re-scanned. A path at or above the root yields `[]`.
|
|
129
|
-
*
|
|
130
|
-
* @param {string} root - the workspace root (remote absolute path).
|
|
131
|
-
* @param {string} touched - the touched remote path.
|
|
132
|
-
* @returns {string[]} workspace-relative directory prefixes, shallow → deep.
|
|
133
|
-
*/
|
|
134
|
-
export function descendantDirs(root, touched) {
|
|
135
|
-
const rootRel = relPathUnder(String(root || ''), String(touched || ''))
|
|
136
|
-
if (!rootRel) return []
|
|
137
|
-
const parts = rootRel.split('/').filter(Boolean)
|
|
138
|
-
// The last part is the file itself; the chain stops at its directory.
|
|
139
|
-
parts.pop()
|
|
140
|
-
const out = []
|
|
141
|
-
let cur = ''
|
|
142
|
-
for (const part of parts) {
|
|
143
|
-
cur = cur ? `${cur}/${part}` : part
|
|
144
|
-
out.push(cur)
|
|
145
|
-
}
|
|
146
|
-
return out
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Which instruction files a touch puts in scope, as workspace-relative paths:
|
|
151
|
-
* each directory in the chain contributes `AGENTS.md` and `CLAUDE.md`.
|
|
152
|
-
*
|
|
153
|
-
* @param {string} root - workspace root.
|
|
154
|
-
* @param {string} touched - touched path.
|
|
155
|
-
* @param {number} depth - how many trailing chain levels to include (0 = none,
|
|
156
|
-
* which is what "touched a file directly in the root" means).
|
|
157
|
-
* @returns {string[]} candidate relative paths.
|
|
158
|
-
*/
|
|
159
|
-
export function instructionCandidates(root, touched, depth = Infinity) {
|
|
160
|
-
const dirs = descendantDirs(root, touched)
|
|
161
|
-
// `depth: 0` means "no chain level" (the touched file sits directly in the
|
|
162
|
-
// root): `slice(-0)` would return the WHOLE array, so this needs its own branch.
|
|
163
|
-
const chain = depth === 0 ? [] : (Number.isFinite(depth) ? dirs.slice(-depth) : dirs)
|
|
164
|
-
const out = []
|
|
165
|
-
for (const dir of chain) {
|
|
166
|
-
out.push(`${dir}/AGENTS.md`, `${dir}/CLAUDE.md`)
|
|
167
|
-
}
|
|
168
|
-
return out
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* A digest of an instruction's content, used to inject a given version once.
|
|
173
|
-
* Size + mtime is not enough: a same-size edit inside one second would slip
|
|
174
|
-
* through, and a spurious re-injection costs prompt budget.
|
|
175
|
-
*
|
|
176
|
-
* @param {Buffer|string} content
|
|
177
|
-
* @returns {string}
|
|
178
|
-
*/
|
|
179
|
-
export function contentDigest(content) {
|
|
180
|
-
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8')
|
|
181
|
-
// A 16-char prefix is plenty for "did this text change" within one session,
|
|
182
|
-
// and keeps the tracker's maps small.
|
|
183
|
-
return createHash('sha256').update(buf).digest('hex').slice(0, 16)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Per-session injection state: which (remote path → digest) pairs have already
|
|
188
|
-
* been delivered, plus the last sweep time for throttling.
|
|
189
|
-
*
|
|
190
|
-
* @returns {object} a tracker with `shouldInject`, `markInjected`, `removed`, and `throttled`.
|
|
191
|
-
*/
|
|
192
|
-
export function createInjectionTracker({ throttleMs = REFRESH_THROTTLE_MS } = {}) {
|
|
193
|
-
/** sessionId → { injected: Map<relPath, digest>, lastSweep: number } */
|
|
194
|
-
const sessions = new Map()
|
|
195
|
-
const stateFor = (sessionId) => {
|
|
196
|
-
const key = String(sessionId || 'unscoped')
|
|
197
|
-
let s = sessions.get(key)
|
|
198
|
-
if (!s) { s = { injected: new Map(), lastSweep: 0 }; sessions.set(key, s) }
|
|
199
|
-
return s
|
|
200
|
-
}
|
|
201
|
-
return {
|
|
202
|
-
/** Whether a sweep may run now (the throttle gate). */
|
|
203
|
-
throttled(sessionId, now = Date.now()) {
|
|
204
|
-
const s = stateFor(sessionId)
|
|
205
|
-
return now - s.lastSweep < throttleMs
|
|
206
|
-
},
|
|
207
|
-
/** Record that a sweep happened now. */
|
|
208
|
-
swept(sessionId, now = Date.now()) {
|
|
209
|
-
stateFor(sessionId).lastSweep = now
|
|
210
|
-
},
|
|
211
|
-
/** Whether this exact content has NOT been delivered to this session yet. */
|
|
212
|
-
shouldInject(sessionId, rel, digest) {
|
|
213
|
-
return stateFor(sessionId).injected.get(rel) !== digest
|
|
214
|
-
},
|
|
215
|
-
/** Mark one file's content as delivered. */
|
|
216
|
-
markInjected(sessionId, rel, digest) {
|
|
217
|
-
stateFor(sessionId).injected.set(rel, digest)
|
|
218
|
-
},
|
|
219
|
-
/** Every rel path this session has been given, for scoped reconciliation. */
|
|
220
|
-
injectedRels(sessionId) {
|
|
221
|
-
return stateFor(sessionId).injected.keys()
|
|
222
|
-
},
|
|
223
|
-
/**
|
|
224
|
-
* Files injected before that are gone from a sweep LIMITED to `covers`.
|
|
225
|
-
*
|
|
226
|
-
* Scoping matters in both directions: a file outside the swept chain was
|
|
227
|
-
* never examined, so it must keep its record (dropping it would re-inject
|
|
228
|
-
* live rules later and, worse, could report a live rule as removed).
|
|
229
|
-
*
|
|
230
|
-
* @param {string} sessionId
|
|
231
|
-
* @param {Set<string>} present - rel paths the sweep actually found.
|
|
232
|
-
* @param {(rel: string) => boolean} covers - whether the sweep covered a rel.
|
|
233
|
-
* @returns {string[]} rel paths to report as removed.
|
|
234
|
-
*/
|
|
235
|
-
removedInScope(sessionId, present, covers) {
|
|
236
|
-
const s = stateFor(sessionId)
|
|
237
|
-
const out = []
|
|
238
|
-
for (const rel of [...s.injected.keys()]) {
|
|
239
|
-
if (typeof covers === 'function' && !covers(rel)) continue
|
|
240
|
-
if (present.has(rel)) continue
|
|
241
|
-
out.push(rel)
|
|
242
|
-
s.injected.delete(rel)
|
|
243
|
-
}
|
|
244
|
-
return out
|
|
245
|
-
},
|
|
246
|
-
/**
|
|
247
|
-
* Files that were injected before but no longer exist remotely — the model
|
|
248
|
-
* must be told, or it keeps following rules that were deleted.
|
|
249
|
-
* @returns {string[]} rel paths to report as removed.
|
|
250
|
-
*/
|
|
251
|
-
removed(sessionId, stillPresent) {
|
|
252
|
-
return this.removedInScope(sessionId, stillPresent, null)
|
|
253
|
-
},
|
|
254
|
-
/** Forget a session entirely (its cwd changed, or it went away). */
|
|
255
|
-
reset(sessionId) {
|
|
256
|
-
sessions.delete(String(sessionId || 'unscoped'))
|
|
257
|
-
},
|
|
258
|
-
/** Every session currently tracked (diagnostics/tests). */
|
|
259
|
-
sessions() {
|
|
260
|
-
return [...sessions.keys()]
|
|
261
|
-
},
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Fetch the CURRENT content of one instruction candidate from the remote and
|
|
267
|
-
* mirror it locally, three-way safe (a local edit is never clobbered).
|
|
268
|
-
*
|
|
269
|
-
* Returns null when the file does not exist remotely — that is a normal answer,
|
|
270
|
-
* not an error, since most directories carry no instruction file.
|
|
271
|
-
*
|
|
272
|
-
* @param {object} sftp - promisified sftp surface.
|
|
273
|
-
* @param {string} remoteRoot - workspace root.
|
|
274
|
-
* @param {string} rel - workspace-relative candidate path.
|
|
275
|
-
* @param {string} mirrorDir - local mirror root.
|
|
276
|
-
* @returns {Promise<{rel: string, remote: string, content: string, digest: string, mirrored: boolean}|null>}
|
|
277
|
-
*/
|
|
278
|
-
export async function fetchInstruction(sftp, remoteRoot, rel, mirrorDir) {
|
|
279
|
-
const remote = joinRemotePath(remoteRoot, rel)
|
|
280
|
-
let st
|
|
281
|
-
try { st = await sftp.stat(remote) } catch { return null }
|
|
282
|
-
if (!st || (typeof st.isDirectory === 'function' && st.isDirectory())) return null
|
|
283
|
-
const size = typeof st.size === 'number' ? st.size : 0
|
|
284
|
-
if (size > INSTRUCTION_MAX_BYTES) {
|
|
285
|
-
// Too large to inject whole: report the file, not its text.
|
|
286
|
-
return { rel, remote, content: '', digest: `oversize:${size}`, mirrored: false, oversizeBytes: size }
|
|
287
|
-
}
|
|
288
|
-
let buf
|
|
289
|
-
try { buf = await sftp.readFile(remote) } catch { return null }
|
|
290
|
-
const digest = contentDigest(buf)
|
|
291
|
-
let mirrored = false
|
|
292
|
-
if (mirrorDir) {
|
|
293
|
-
try {
|
|
294
|
-
const lp = path.join(mirrorDir, ...String(rel).split('/'))
|
|
295
|
-
let same = false
|
|
296
|
-
try {
|
|
297
|
-
const lst = statSync(lp)
|
|
298
|
-
same = lst.size === size && keyOf(lst.size, lst.mtimeMs / 1000) === keyOf(size, st.mtime)
|
|
299
|
-
} catch { same = false }
|
|
300
|
-
if (!same) {
|
|
301
|
-
mkdirSync(path.dirname(lp), { recursive: true })
|
|
302
|
-
writeFileSync(lp, buf)
|
|
303
|
-
try { utimesSync(lp, new Date((st.mtime || 0) * 1000), new Date((st.mtime || 0) * 1000)) } catch { /* cosmetic */ }
|
|
304
|
-
}
|
|
305
|
-
mirrored = true
|
|
306
|
-
} catch { /* a mirror failure must not lose the instruction itself */ }
|
|
307
|
-
}
|
|
308
|
-
return { rel, remote, content: buf.toString('utf8'), digest, mirrored }
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Sweep the ancestor chain of a touched path and return the instructions that
|
|
313
|
-
* should enter the context now.
|
|
314
|
-
*
|
|
315
|
-
* @param {object} arg
|
|
316
|
-
* @param {object} arg.sftp
|
|
317
|
-
* @param {string} arg.remoteRoot - workspace root.
|
|
318
|
-
* @param {string} arg.touched - the touched remote path.
|
|
319
|
-
* @param {string} arg.mirrorDir - local mirror.
|
|
320
|
-
* @param {object} arg.tracker - from {@link createInjectionTracker}.
|
|
321
|
-
* @param {string} arg.sessionId
|
|
322
|
-
* @param {number} [arg.depth] - chain depth to consider (default: whole chain).
|
|
323
|
-
* @returns {Promise<{items: Array, removed: string[]}>} new/changed files and removals.
|
|
324
|
-
*/
|
|
325
|
-
export async function refreshTouched({ sftp, remoteRoot, touched, mirrorDir, tracker, sessionId, depth }) {
|
|
326
|
-
const candidates = instructionCandidates(remoteRoot, touched, depth)
|
|
327
|
-
if (!candidates.length) return { items: [], removed: [] }
|
|
328
|
-
const found = []
|
|
329
|
-
const present = new Set()
|
|
330
|
-
for (const rel of candidates) {
|
|
331
|
-
const got = await fetchInstruction(sftp, remoteRoot, rel, mirrorDir)
|
|
332
|
-
if (!got) continue
|
|
333
|
-
found.push(got)
|
|
334
|
-
present.add(got.rel)
|
|
335
|
-
}
|
|
336
|
-
const items = found.filter((f) => tracker.shouldInject(sessionId, f.rel, f.digest))
|
|
337
|
-
for (const f of items) tracker.markInjected(sessionId, f.rel, f.digest)
|
|
338
|
-
// Removal notices must be scoped to the directories THIS touch covers — both
|
|
339
|
-
// directions matter:
|
|
340
|
-
// • a file that no longer exists anywhere in the covered chain is gone;
|
|
341
|
-
// • a file outside the covered chain was simply never looked at, so its
|
|
342
|
-
// absence from `present` says nothing (and must not delete its record,
|
|
343
|
-
// or the model would be told a live rule vanished).
|
|
344
|
-
const dirs = descendantDirs(remoteRoot, touched)
|
|
345
|
-
if (!dirs.length) return { items, removed: [] }
|
|
346
|
-
const prefixes = dirs.map((d) => d + '/')
|
|
347
|
-
const covers = (rel) => prefixes.includes(rel.slice(0, rel.lastIndexOf('/') + 1))
|
|
348
|
-
// Only files INSIDE the covered chain can be judged by this sweep: a record
|
|
349
|
-
// for some other subtree was simply not examined, so its absence from
|
|
350
|
-
// `present` means nothing there.
|
|
351
|
-
const removed = tracker.removedInScope(sessionId, present, covers)
|
|
352
|
-
return { items, removed }
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Render the injection batch into ONE user message.
|
|
357
|
-
*
|
|
358
|
-
* The shape mirrors what the core produces for its own incremental batches —
|
|
359
|
-
* a system-reminder-framed body with per-file sections — so the model reads the
|
|
360
|
-
* two identically while the SOURCE kind keeps them distinguishable.
|
|
361
|
-
*
|
|
362
|
-
* @param {Array} items - from {@link refreshTouched}.
|
|
363
|
-
* @param {string[]} removed - rel paths that no longer exist.
|
|
364
|
-
* @param {object} [opts]
|
|
365
|
-
* @param {number} [opts.maxBytes]
|
|
366
|
-
* @param {(p: string) => string} [opts.display] - path formatter for sections.
|
|
367
|
-
* @param {(text: string, source: object) => object} [opts.createUserMessage] -
|
|
368
|
-
* the core message factory when available.
|
|
369
|
-
* @returns {object|null} a user message, or null when there is nothing to say.
|
|
370
|
-
*/
|
|
371
|
-
export function buildInstructionMessage(items, removed, opts = {}) {
|
|
372
|
-
const maxBytes = Number(opts.maxBytes) || INSTRUCTION_MESSAGE_MAX_BYTES
|
|
373
|
-
const display = typeof opts.display === 'function' ? opts.display : (p) => p
|
|
374
|
-
const factory = typeof opts.createUserMessage === 'function' ? opts.createUserMessage : null
|
|
375
|
-
if ((!items || !items.length) && (!removed || !removed.length)) return null
|
|
376
|
-
const blocks = []
|
|
377
|
-
let used = 0
|
|
378
|
-
const sent = []
|
|
379
|
-
const truncated = []
|
|
380
|
-
for (const item of items || []) {
|
|
381
|
-
const head = item.oversizeBytes
|
|
382
|
-
? `### ${display(item.remote)}\n(too large to inject: ${item.oversizeBytes} bytes — read it with rw_read_file if you need its rules)`
|
|
383
|
-
: `### ${display(item.remote)}\n${item.content}`
|
|
384
|
-
const bytes = Buffer.byteLength(head, 'utf8')
|
|
385
|
-
if (used + bytes > maxBytes) { truncated.push(display(item.remote)); continue }
|
|
386
|
-
used += bytes
|
|
387
|
-
blocks.push(head)
|
|
388
|
-
sent.push(item.rel)
|
|
389
|
-
}
|
|
390
|
-
if (removed && removed.length) {
|
|
391
|
-
const line = `Removed on the remote host (no longer in effect): ${removed.map((r) => display(r)).join(', ')}`
|
|
392
|
-
if (used + Buffer.byteLength(line, 'utf8') <= maxBytes) blocks.push(line)
|
|
393
|
-
}
|
|
394
|
-
if (truncated.length) {
|
|
395
|
-
blocks.push(`Workspace instruction budget ${maxBytes} bytes: truncated ${truncated.join(', ')}`)
|
|
396
|
-
}
|
|
397
|
-
if (!blocks.length) return null
|
|
398
|
-
const text = [
|
|
399
|
-
'<system-reminder>',
|
|
400
|
-
'Remote workspace instructions for the directories you just touched. These are the CURRENT contents from the remote host, delivered because you accessed files under them.',
|
|
401
|
-
'',
|
|
402
|
-
blocks.join('\n\n'),
|
|
403
|
-
'</system-reminder>',
|
|
404
|
-
].join('\n')
|
|
405
|
-
const source = { kind: REMOTE_INSTRUCTIONS_KIND, form: 'instructions', changes: sent.map((rel) => ({ path: rel, change: 'changed' })) }
|
|
406
|
-
const content = [{ type: 'text', text }]
|
|
407
|
-
const fallback = { role: 'user', content, source }
|
|
408
|
-
let built = null
|
|
409
|
-
if (factory) {
|
|
410
|
-
try { built = factory({ content, source }) } catch { built = null }
|
|
411
|
-
}
|
|
412
|
-
// Session restore requires message.id (issue #75). A factory that throws or
|
|
413
|
-
// omits id must not store a bare object — that rejects the whole log.
|
|
414
|
-
return ensureMessageId(built, fallback)
|
|
415
|
-
}
|
|
1
|
+
// dsh-remote — dynamic remote instruction injection (issue #71).
|
|
2
|
+
//
|
|
3
|
+
// Issue #68 gave a remote workspace a STATIC instruction index: the workspace
|
|
4
|
+
// is shallow-scanned at pick/sync time, the files are mirrored locally, and the
|
|
5
|
+
// prompt lists them. What remained missing is the DYNAMIC half the core does
|
|
6
|
+
// for local sessions — three gaps, all plugin-side fixable:
|
|
7
|
+
//
|
|
8
|
+
// 1. The mirrored copies never refreshed, so a mid-session edit to a remote
|
|
9
|
+
// AGENTS.md was invisible until a manual rw_sync.
|
|
10
|
+
// 2. The core's incremental injection only recognizes read/write/edit with a
|
|
11
|
+
// `file_path` argument. `rw_read_file` is named differently AND takes
|
|
12
|
+
// `path`, so a remote file touch never registered.
|
|
13
|
+
// 3. The core expands scopes from cwd to the TOUCHED path — both local. A
|
|
14
|
+
// remote absolute path (`/root/proj/x.md`) can never map onto a local
|
|
15
|
+
// mirror cwd, so even a "touch" would expand nothing.
|
|
16
|
+
//
|
|
17
|
+
// This module closes all three without a single line of core change:
|
|
18
|
+
// • `noteTouchedPath` turns an rw_* file touch into the FULL ancestor chain
|
|
19
|
+
// below the workspace root (the same shape core's descendantDirsBetween
|
|
20
|
+
// produces — every level, not just the deepest);
|
|
21
|
+
// • `refreshTouched` stats each level's AGENTS.md/CLAUDE.md, pulls the ones
|
|
22
|
+
// that changed, and returns the chain's current contents;
|
|
23
|
+
// • `buildInstructionMessage` renders that into ONE user message whose source
|
|
24
|
+
// is our OWN kind, so the core's `agent-instructions` state machine never
|
|
25
|
+
// confuses it for its own (that is what keeps the two paths from fighting).
|
|
26
|
+
//
|
|
27
|
+
// Everything here is best-effort and synchronous-safe: an offline machine must
|
|
28
|
+
// never hang a tool call or a prompt assembly, so every remote step is guarded
|
|
29
|
+
// and every caller is expected to treat a rejection as "no injection".
|
|
30
|
+
|
|
31
|
+
import { statSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs'
|
|
32
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
33
|
+
import { createRequire } from 'node:module'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
import { joinRemotePath, relPathUnder } from './paths.js'
|
|
36
|
+
import { INSTRUCTION_MAX_BYTES } from './instructions.js'
|
|
37
|
+
|
|
38
|
+
/** The message source kind this plugin owns. Deliberately NOT the core's
|
|
39
|
+
* `agent-instructions`: consumers fall through unknown kinds, and the core's
|
|
40
|
+
* reconcile only claims messages carrying its own kind. */
|
|
41
|
+
export const REMOTE_INSTRUCTIONS_KIND = 'dsh-remote-instructions'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The user-message factory hanging off the `llm` service, when that service
|
|
45
|
+
* actually exposes one.
|
|
46
|
+
*
|
|
47
|
+
* DSH 0.2.1 keeps `createUserMessage` as a module export of
|
|
48
|
+
* `@deepseek-ai/dsh-llm`, not a method of the `LlmRuntime` service. Probing
|
|
49
|
+
* `ctx.get('llm')` therefore returns null on current Desktop builds, and the
|
|
50
|
+
* caller must fall through to {@link loadBundledUserMessageFactory}.
|
|
51
|
+
*
|
|
52
|
+
* @param {object|null|undefined} llm - `ctx.get('llm')`, or a test double.
|
|
53
|
+
* @returns {Function|null}
|
|
54
|
+
*/
|
|
55
|
+
export function pickUserMessageFactory(llm) {
|
|
56
|
+
if (llm && typeof llm.createUserMessage === 'function') {
|
|
57
|
+
return (input) => llm.createUserMessage(input)
|
|
58
|
+
}
|
|
59
|
+
const nested = llm && llm.default
|
|
60
|
+
if (nested && typeof nested.createUserMessage === 'function') {
|
|
61
|
+
return (input) => nested.createUserMessage(input)
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Module-level `createUserMessage` from the harness's dsh-llm, if resolvable. */
|
|
67
|
+
let bundledUserMessageFactory
|
|
68
|
+
/**
|
|
69
|
+
* Load `@deepseek-ai/dsh-llm`'s `createUserMessage`.
|
|
70
|
+
*
|
|
71
|
+
* The package is not a declared dependency of this plugin (the compatibility
|
|
72
|
+
* gate would then have to peer-range it). Inside a DSH profile it is a sibling
|
|
73
|
+
* in `node_modules`, and Node walks up from this file to find it. A missing
|
|
74
|
+
* package returns null so the built-in identified message is used instead.
|
|
75
|
+
*
|
|
76
|
+
* @returns {Function|null}
|
|
77
|
+
*/
|
|
78
|
+
export function loadBundledUserMessageFactory() {
|
|
79
|
+
if (bundledUserMessageFactory !== undefined) return bundledUserMessageFactory
|
|
80
|
+
try {
|
|
81
|
+
const req = createRequire(import.meta.url)
|
|
82
|
+
const mod = req('@deepseek-ai/dsh-llm')
|
|
83
|
+
bundledUserMessageFactory = mod && typeof mod.createUserMessage === 'function'
|
|
84
|
+
? mod.createUserMessage
|
|
85
|
+
: null
|
|
86
|
+
} catch {
|
|
87
|
+
bundledUserMessageFactory = null
|
|
88
|
+
}
|
|
89
|
+
return bundledUserMessageFactory
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Guarantee the session-restore invariant: `message.id` is a non-empty string.
|
|
94
|
+
*
|
|
95
|
+
* DSH 0.2.1 (`dsh-session` `assertMessageEventShape`) rejects the whole log
|
|
96
|
+
* with "lacks an identified message" when any user message has no id. A
|
|
97
|
+
* factory that already stamped one is returned unchanged.
|
|
98
|
+
*
|
|
99
|
+
* @param {object|null} message - a factory result, or null to use `fallback`.
|
|
100
|
+
* @param {object} fallback - `{ role, content, source }` used when `message` is empty.
|
|
101
|
+
* @returns {object}
|
|
102
|
+
*/
|
|
103
|
+
export function ensureMessageId(message, fallback) {
|
|
104
|
+
const base = message && typeof message === 'object' ? message : fallback
|
|
105
|
+
if (typeof base.id === 'string' && base.id !== '') return base
|
|
106
|
+
return {
|
|
107
|
+
...fallback,
|
|
108
|
+
...base,
|
|
109
|
+
role: base.role || fallback.role,
|
|
110
|
+
id: randomUUID(),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Total byte budget for one injected batch (the core's own budget is similar). */
|
|
115
|
+
export const INSTRUCTION_MESSAGE_MAX_BYTES = 16 * 1024
|
|
116
|
+
|
|
117
|
+
/** Per-session throttle for the stat-and-refresh sweep, in milliseconds. */
|
|
118
|
+
export const REFRESH_THROTTLE_MS = 30 * 1000
|
|
119
|
+
|
|
120
|
+
const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Split a remote path into the ancestor directories strictly BELOW `root`,
|
|
124
|
+
* root-excluded — the core's `ancestorChain(cwd, dir).slice(1)` shape.
|
|
125
|
+
*
|
|
126
|
+
* Reading `<root>/a/b/c.ts` yields `['a', 'a/b']`, so `a/AGENTS.md` and
|
|
127
|
+
* `a/b/AGENTS.md` are both in scope while `<root>/AGENTS.md` (the baseline
|
|
128
|
+
* layer) is not re-scanned. A path at or above the root yields `[]`.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} root - the workspace root (remote absolute path).
|
|
131
|
+
* @param {string} touched - the touched remote path.
|
|
132
|
+
* @returns {string[]} workspace-relative directory prefixes, shallow → deep.
|
|
133
|
+
*/
|
|
134
|
+
export function descendantDirs(root, touched) {
|
|
135
|
+
const rootRel = relPathUnder(String(root || ''), String(touched || ''))
|
|
136
|
+
if (!rootRel) return []
|
|
137
|
+
const parts = rootRel.split('/').filter(Boolean)
|
|
138
|
+
// The last part is the file itself; the chain stops at its directory.
|
|
139
|
+
parts.pop()
|
|
140
|
+
const out = []
|
|
141
|
+
let cur = ''
|
|
142
|
+
for (const part of parts) {
|
|
143
|
+
cur = cur ? `${cur}/${part}` : part
|
|
144
|
+
out.push(cur)
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Which instruction files a touch puts in scope, as workspace-relative paths:
|
|
151
|
+
* each directory in the chain contributes `AGENTS.md` and `CLAUDE.md`.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} root - workspace root.
|
|
154
|
+
* @param {string} touched - touched path.
|
|
155
|
+
* @param {number} depth - how many trailing chain levels to include (0 = none,
|
|
156
|
+
* which is what "touched a file directly in the root" means).
|
|
157
|
+
* @returns {string[]} candidate relative paths.
|
|
158
|
+
*/
|
|
159
|
+
export function instructionCandidates(root, touched, depth = Infinity) {
|
|
160
|
+
const dirs = descendantDirs(root, touched)
|
|
161
|
+
// `depth: 0` means "no chain level" (the touched file sits directly in the
|
|
162
|
+
// root): `slice(-0)` would return the WHOLE array, so this needs its own branch.
|
|
163
|
+
const chain = depth === 0 ? [] : (Number.isFinite(depth) ? dirs.slice(-depth) : dirs)
|
|
164
|
+
const out = []
|
|
165
|
+
for (const dir of chain) {
|
|
166
|
+
out.push(`${dir}/AGENTS.md`, `${dir}/CLAUDE.md`)
|
|
167
|
+
}
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A digest of an instruction's content, used to inject a given version once.
|
|
173
|
+
* Size + mtime is not enough: a same-size edit inside one second would slip
|
|
174
|
+
* through, and a spurious re-injection costs prompt budget.
|
|
175
|
+
*
|
|
176
|
+
* @param {Buffer|string} content
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
export function contentDigest(content) {
|
|
180
|
+
const buf = Buffer.isBuffer(content) ? content : Buffer.from(String(content ?? ''), 'utf8')
|
|
181
|
+
// A 16-char prefix is plenty for "did this text change" within one session,
|
|
182
|
+
// and keeps the tracker's maps small.
|
|
183
|
+
return createHash('sha256').update(buf).digest('hex').slice(0, 16)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Per-session injection state: which (remote path → digest) pairs have already
|
|
188
|
+
* been delivered, plus the last sweep time for throttling.
|
|
189
|
+
*
|
|
190
|
+
* @returns {object} a tracker with `shouldInject`, `markInjected`, `removed`, and `throttled`.
|
|
191
|
+
*/
|
|
192
|
+
export function createInjectionTracker({ throttleMs = REFRESH_THROTTLE_MS } = {}) {
|
|
193
|
+
/** sessionId → { injected: Map<relPath, digest>, lastSweep: number } */
|
|
194
|
+
const sessions = new Map()
|
|
195
|
+
const stateFor = (sessionId) => {
|
|
196
|
+
const key = String(sessionId || 'unscoped')
|
|
197
|
+
let s = sessions.get(key)
|
|
198
|
+
if (!s) { s = { injected: new Map(), lastSweep: 0 }; sessions.set(key, s) }
|
|
199
|
+
return s
|
|
200
|
+
}
|
|
201
|
+
return {
|
|
202
|
+
/** Whether a sweep may run now (the throttle gate). */
|
|
203
|
+
throttled(sessionId, now = Date.now()) {
|
|
204
|
+
const s = stateFor(sessionId)
|
|
205
|
+
return now - s.lastSweep < throttleMs
|
|
206
|
+
},
|
|
207
|
+
/** Record that a sweep happened now. */
|
|
208
|
+
swept(sessionId, now = Date.now()) {
|
|
209
|
+
stateFor(sessionId).lastSweep = now
|
|
210
|
+
},
|
|
211
|
+
/** Whether this exact content has NOT been delivered to this session yet. */
|
|
212
|
+
shouldInject(sessionId, rel, digest) {
|
|
213
|
+
return stateFor(sessionId).injected.get(rel) !== digest
|
|
214
|
+
},
|
|
215
|
+
/** Mark one file's content as delivered. */
|
|
216
|
+
markInjected(sessionId, rel, digest) {
|
|
217
|
+
stateFor(sessionId).injected.set(rel, digest)
|
|
218
|
+
},
|
|
219
|
+
/** Every rel path this session has been given, for scoped reconciliation. */
|
|
220
|
+
injectedRels(sessionId) {
|
|
221
|
+
return stateFor(sessionId).injected.keys()
|
|
222
|
+
},
|
|
223
|
+
/**
|
|
224
|
+
* Files injected before that are gone from a sweep LIMITED to `covers`.
|
|
225
|
+
*
|
|
226
|
+
* Scoping matters in both directions: a file outside the swept chain was
|
|
227
|
+
* never examined, so it must keep its record (dropping it would re-inject
|
|
228
|
+
* live rules later and, worse, could report a live rule as removed).
|
|
229
|
+
*
|
|
230
|
+
* @param {string} sessionId
|
|
231
|
+
* @param {Set<string>} present - rel paths the sweep actually found.
|
|
232
|
+
* @param {(rel: string) => boolean} covers - whether the sweep covered a rel.
|
|
233
|
+
* @returns {string[]} rel paths to report as removed.
|
|
234
|
+
*/
|
|
235
|
+
removedInScope(sessionId, present, covers) {
|
|
236
|
+
const s = stateFor(sessionId)
|
|
237
|
+
const out = []
|
|
238
|
+
for (const rel of [...s.injected.keys()]) {
|
|
239
|
+
if (typeof covers === 'function' && !covers(rel)) continue
|
|
240
|
+
if (present.has(rel)) continue
|
|
241
|
+
out.push(rel)
|
|
242
|
+
s.injected.delete(rel)
|
|
243
|
+
}
|
|
244
|
+
return out
|
|
245
|
+
},
|
|
246
|
+
/**
|
|
247
|
+
* Files that were injected before but no longer exist remotely — the model
|
|
248
|
+
* must be told, or it keeps following rules that were deleted.
|
|
249
|
+
* @returns {string[]} rel paths to report as removed.
|
|
250
|
+
*/
|
|
251
|
+
removed(sessionId, stillPresent) {
|
|
252
|
+
return this.removedInScope(sessionId, stillPresent, null)
|
|
253
|
+
},
|
|
254
|
+
/** Forget a session entirely (its cwd changed, or it went away). */
|
|
255
|
+
reset(sessionId) {
|
|
256
|
+
sessions.delete(String(sessionId || 'unscoped'))
|
|
257
|
+
},
|
|
258
|
+
/** Every session currently tracked (diagnostics/tests). */
|
|
259
|
+
sessions() {
|
|
260
|
+
return [...sessions.keys()]
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Fetch the CURRENT content of one instruction candidate from the remote and
|
|
267
|
+
* mirror it locally, three-way safe (a local edit is never clobbered).
|
|
268
|
+
*
|
|
269
|
+
* Returns null when the file does not exist remotely — that is a normal answer,
|
|
270
|
+
* not an error, since most directories carry no instruction file.
|
|
271
|
+
*
|
|
272
|
+
* @param {object} sftp - promisified sftp surface.
|
|
273
|
+
* @param {string} remoteRoot - workspace root.
|
|
274
|
+
* @param {string} rel - workspace-relative candidate path.
|
|
275
|
+
* @param {string} mirrorDir - local mirror root.
|
|
276
|
+
* @returns {Promise<{rel: string, remote: string, content: string, digest: string, mirrored: boolean}|null>}
|
|
277
|
+
*/
|
|
278
|
+
export async function fetchInstruction(sftp, remoteRoot, rel, mirrorDir) {
|
|
279
|
+
const remote = joinRemotePath(remoteRoot, rel)
|
|
280
|
+
let st
|
|
281
|
+
try { st = await sftp.stat(remote) } catch { return null }
|
|
282
|
+
if (!st || (typeof st.isDirectory === 'function' && st.isDirectory())) return null
|
|
283
|
+
const size = typeof st.size === 'number' ? st.size : 0
|
|
284
|
+
if (size > INSTRUCTION_MAX_BYTES) {
|
|
285
|
+
// Too large to inject whole: report the file, not its text.
|
|
286
|
+
return { rel, remote, content: '', digest: `oversize:${size}`, mirrored: false, oversizeBytes: size }
|
|
287
|
+
}
|
|
288
|
+
let buf
|
|
289
|
+
try { buf = await sftp.readFile(remote) } catch { return null }
|
|
290
|
+
const digest = contentDigest(buf)
|
|
291
|
+
let mirrored = false
|
|
292
|
+
if (mirrorDir) {
|
|
293
|
+
try {
|
|
294
|
+
const lp = path.join(mirrorDir, ...String(rel).split('/'))
|
|
295
|
+
let same = false
|
|
296
|
+
try {
|
|
297
|
+
const lst = statSync(lp)
|
|
298
|
+
same = lst.size === size && keyOf(lst.size, lst.mtimeMs / 1000) === keyOf(size, st.mtime)
|
|
299
|
+
} catch { same = false }
|
|
300
|
+
if (!same) {
|
|
301
|
+
mkdirSync(path.dirname(lp), { recursive: true })
|
|
302
|
+
writeFileSync(lp, buf)
|
|
303
|
+
try { utimesSync(lp, new Date((st.mtime || 0) * 1000), new Date((st.mtime || 0) * 1000)) } catch { /* cosmetic */ }
|
|
304
|
+
}
|
|
305
|
+
mirrored = true
|
|
306
|
+
} catch { /* a mirror failure must not lose the instruction itself */ }
|
|
307
|
+
}
|
|
308
|
+
return { rel, remote, content: buf.toString('utf8'), digest, mirrored }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Sweep the ancestor chain of a touched path and return the instructions that
|
|
313
|
+
* should enter the context now.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} arg
|
|
316
|
+
* @param {object} arg.sftp
|
|
317
|
+
* @param {string} arg.remoteRoot - workspace root.
|
|
318
|
+
* @param {string} arg.touched - the touched remote path.
|
|
319
|
+
* @param {string} arg.mirrorDir - local mirror.
|
|
320
|
+
* @param {object} arg.tracker - from {@link createInjectionTracker}.
|
|
321
|
+
* @param {string} arg.sessionId
|
|
322
|
+
* @param {number} [arg.depth] - chain depth to consider (default: whole chain).
|
|
323
|
+
* @returns {Promise<{items: Array, removed: string[]}>} new/changed files and removals.
|
|
324
|
+
*/
|
|
325
|
+
export async function refreshTouched({ sftp, remoteRoot, touched, mirrorDir, tracker, sessionId, depth }) {
|
|
326
|
+
const candidates = instructionCandidates(remoteRoot, touched, depth)
|
|
327
|
+
if (!candidates.length) return { items: [], removed: [] }
|
|
328
|
+
const found = []
|
|
329
|
+
const present = new Set()
|
|
330
|
+
for (const rel of candidates) {
|
|
331
|
+
const got = await fetchInstruction(sftp, remoteRoot, rel, mirrorDir)
|
|
332
|
+
if (!got) continue
|
|
333
|
+
found.push(got)
|
|
334
|
+
present.add(got.rel)
|
|
335
|
+
}
|
|
336
|
+
const items = found.filter((f) => tracker.shouldInject(sessionId, f.rel, f.digest))
|
|
337
|
+
for (const f of items) tracker.markInjected(sessionId, f.rel, f.digest)
|
|
338
|
+
// Removal notices must be scoped to the directories THIS touch covers — both
|
|
339
|
+
// directions matter:
|
|
340
|
+
// • a file that no longer exists anywhere in the covered chain is gone;
|
|
341
|
+
// • a file outside the covered chain was simply never looked at, so its
|
|
342
|
+
// absence from `present` says nothing (and must not delete its record,
|
|
343
|
+
// or the model would be told a live rule vanished).
|
|
344
|
+
const dirs = descendantDirs(remoteRoot, touched)
|
|
345
|
+
if (!dirs.length) return { items, removed: [] }
|
|
346
|
+
const prefixes = dirs.map((d) => d + '/')
|
|
347
|
+
const covers = (rel) => prefixes.includes(rel.slice(0, rel.lastIndexOf('/') + 1))
|
|
348
|
+
// Only files INSIDE the covered chain can be judged by this sweep: a record
|
|
349
|
+
// for some other subtree was simply not examined, so its absence from
|
|
350
|
+
// `present` means nothing there.
|
|
351
|
+
const removed = tracker.removedInScope(sessionId, present, covers)
|
|
352
|
+
return { items, removed }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Render the injection batch into ONE user message.
|
|
357
|
+
*
|
|
358
|
+
* The shape mirrors what the core produces for its own incremental batches —
|
|
359
|
+
* a system-reminder-framed body with per-file sections — so the model reads the
|
|
360
|
+
* two identically while the SOURCE kind keeps them distinguishable.
|
|
361
|
+
*
|
|
362
|
+
* @param {Array} items - from {@link refreshTouched}.
|
|
363
|
+
* @param {string[]} removed - rel paths that no longer exist.
|
|
364
|
+
* @param {object} [opts]
|
|
365
|
+
* @param {number} [opts.maxBytes]
|
|
366
|
+
* @param {(p: string) => string} [opts.display] - path formatter for sections.
|
|
367
|
+
* @param {(text: string, source: object) => object} [opts.createUserMessage] -
|
|
368
|
+
* the core message factory when available.
|
|
369
|
+
* @returns {object|null} a user message, or null when there is nothing to say.
|
|
370
|
+
*/
|
|
371
|
+
export function buildInstructionMessage(items, removed, opts = {}) {
|
|
372
|
+
const maxBytes = Number(opts.maxBytes) || INSTRUCTION_MESSAGE_MAX_BYTES
|
|
373
|
+
const display = typeof opts.display === 'function' ? opts.display : (p) => p
|
|
374
|
+
const factory = typeof opts.createUserMessage === 'function' ? opts.createUserMessage : null
|
|
375
|
+
if ((!items || !items.length) && (!removed || !removed.length)) return null
|
|
376
|
+
const blocks = []
|
|
377
|
+
let used = 0
|
|
378
|
+
const sent = []
|
|
379
|
+
const truncated = []
|
|
380
|
+
for (const item of items || []) {
|
|
381
|
+
const head = item.oversizeBytes
|
|
382
|
+
? `### ${display(item.remote)}\n(too large to inject: ${item.oversizeBytes} bytes — read it with rw_read_file if you need its rules)`
|
|
383
|
+
: `### ${display(item.remote)}\n${item.content}`
|
|
384
|
+
const bytes = Buffer.byteLength(head, 'utf8')
|
|
385
|
+
if (used + bytes > maxBytes) { truncated.push(display(item.remote)); continue }
|
|
386
|
+
used += bytes
|
|
387
|
+
blocks.push(head)
|
|
388
|
+
sent.push(item.rel)
|
|
389
|
+
}
|
|
390
|
+
if (removed && removed.length) {
|
|
391
|
+
const line = `Removed on the remote host (no longer in effect): ${removed.map((r) => display(r)).join(', ')}`
|
|
392
|
+
if (used + Buffer.byteLength(line, 'utf8') <= maxBytes) blocks.push(line)
|
|
393
|
+
}
|
|
394
|
+
if (truncated.length) {
|
|
395
|
+
blocks.push(`Workspace instruction budget ${maxBytes} bytes: truncated ${truncated.join(', ')}`)
|
|
396
|
+
}
|
|
397
|
+
if (!blocks.length) return null
|
|
398
|
+
const text = [
|
|
399
|
+
'<system-reminder>',
|
|
400
|
+
'Remote workspace instructions for the directories you just touched. These are the CURRENT contents from the remote host, delivered because you accessed files under them.',
|
|
401
|
+
'',
|
|
402
|
+
blocks.join('\n\n'),
|
|
403
|
+
'</system-reminder>',
|
|
404
|
+
].join('\n')
|
|
405
|
+
const source = { kind: REMOTE_INSTRUCTIONS_KIND, form: 'instructions', changes: sent.map((rel) => ({ path: rel, change: 'changed' })) }
|
|
406
|
+
const content = [{ type: 'text', text }]
|
|
407
|
+
const fallback = { role: 'user', content, source }
|
|
408
|
+
let built = null
|
|
409
|
+
if (factory) {
|
|
410
|
+
try { built = factory({ content, source }) } catch { built = null }
|
|
411
|
+
}
|
|
412
|
+
// Session restore requires message.id (issue #75). A factory that throws or
|
|
413
|
+
// omits id must not store a bare object — that rejects the whole log.
|
|
414
|
+
return ensureMessageId(built, fallback)
|
|
415
|
+
}
|