dsh-remote 0.8.72 → 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.
@@ -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
+ }