dsh-remote 0.8.67 → 0.8.70

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,288 +1,288 @@
1
- // dsh-remote — remote instruction-file index (issue #68).
2
- //
3
- // The core instruction loader (@deepseek-ai/dsh-agent-instructions) discovers
4
- // AGENTS.md through the LOCAL filesystem only: from two user-global roots plus
5
- // the projectRoot→cwd ancestor chain. A remote session's cwd is the local
6
- // MIRROR of the remote workspace, and that mirror stays empty until rw_sync —
7
- // so a 20 KB AGENTS.md that governs the remote repo never enters the model's
8
- // context, while the same file is auto-injected in a local session. File
9
- // ACCESS was never the problem (rw_read_file reads it fine); what is missing
10
- // is the model knowing it should.
11
- //
12
- // This module closes the gap on the plugin side, along the two cheapest axes
13
- // the issue lists (the third — a remote ctx.fs provider — belongs to core):
14
- //
15
- // 1. INDEX in the system prompt: a shallow scan of the remote workspace
16
- // (root + at most two directory levels down, AGENTS.md / CLAUDE.md only)
17
- // is rendered into the dsh-remote prompt section as a path list with
18
- // headings, telling every new session the files exist and to read them
19
- // with rw_read_file before editing that tree. This is an INDEX, not a
20
- // second injection pipeline — the full text stays out of the prompt and
21
- // out of any parallel baseline, exactly as the issue demands.
22
- // 2. MIRROR the found files into the local mirror at their relative paths
23
- // (three-way safe — a local edit is never clobbered, see below). A
24
- // root-level AGENTS.md then sits exactly where the CORE loader looks in
25
- // a local session, so the normal baseline/incremental machinery picks it
26
- // up unchanged; no plugin-side injection logic runs in parallel with it.
27
- //
28
- // Deliberate non-goals (the issue's "请不要做"):
29
- // - NO unbounded recursion of the remote root (multi-project roots like
30
- // /root would scan forever): the walk is depth- and count-capped.
31
- // - NO full-text injection from the plugin.
32
-
33
- import { existsSync, mkdirSync, statSync, writeFileSync, utimesSync } from 'node:fs'
34
- import path from 'node:path'
35
- import { joinRemotePath, relPathUnder } from './paths.js'
36
- import { loadSyncState, saveSyncState } from './sync.js'
37
-
38
- /** File names treated as agent instruction files (compared case-insensitively;
39
- * the mirrored copy keeps the remote's exact casing). */
40
- export const INSTRUCTION_NAMES = ['agents.md', 'claude.md']
41
-
42
- /** An instruction file bigger than this is indexed (path + note) but not read. */
43
- export const INSTRUCTION_MAX_BYTES = 256 * 1024
44
-
45
- /** Caps that keep the shallow scan shallow. */
46
- export const INSTRUCTION_MAX_FILES = 20
47
- export const INSTRUCTION_MAX_DIRS = 60
48
- export const INSTRUCTION_MAX_DEPTH = 2 // 0 = workspace root, 1 = sub/, 2 = sub/sub/
49
-
50
- /** Directories that never contain a project's governing instructions. */
51
- const SKIP_DIRS = new Set([
52
- 'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out',
53
- '.next', '.nuxt', '.cache', '.turbo', 'coverage', 'target', 'vendor',
54
- ])
55
-
56
- export const isInstructionName = (name) => INSTRUCTION_NAMES.includes(String(name || '').toLowerCase())
57
-
58
- /** Pull the first Markdown headings out of an instruction file, for the index. */
59
- export function extractHeadings(text, max = 8) {
60
- const out = []
61
- for (const line of String(text || '').split('\n')) {
62
- const m = /^#{1,3}\s+(.+?)\s*#*\s*$/.exec(line)
63
- if (!m) continue
64
- out.push(m[1].slice(0, 60))
65
- if (out.length >= max) break
66
- }
67
- return out
68
- }
69
-
70
- /**
71
- * Shallow-scan a remote workspace for instruction files: the root directory
72
- * plus up to INSTRUCTION_MAX_DEPTH levels of subdirectories, matching
73
- * AGENTS.md / CLAUDE.md basenames. Bounded by INSTRUCTION_MAX_DIRS readdirs
74
- * and INSTRUCTION_MAX_FILES files; anything past the caps is silently
75
- * skipped (the index is a hint, not an inventory).
76
- *
77
- * @param {object} sftp - the promisified sftp surface (readdir/stat/readFile).
78
- * @param {string} remoteRoot - absolute remote workspace path (normalized).
79
- * @returns {Promise<Array<{rel: string, remote: string, size: number, mtime: number, content: Buffer|null, headings: string[]}>>}
80
- * `content`/`headings` are null/empty for files over INSTRUCTION_MAX_BYTES.
81
- */
82
- export async function scanInstructionFiles(sftp, remoteRoot, opts = {}) {
83
- const maxDepth = Math.max(0, Math.min(Number(opts.maxDepth ?? INSTRUCTION_MAX_DEPTH) || 0, 4))
84
- const maxDirs = Math.max(1, Number(opts.maxDirs ?? INSTRUCTION_MAX_DIRS) || INSTRUCTION_MAX_DIRS)
85
- const maxFiles = Math.max(1, Number(opts.maxFiles ?? INSTRUCTION_MAX_FILES) || INSTRUCTION_MAX_FILES)
86
- const files = []
87
- // Breadth-first so a wide root cannot push the walk deep before the shallow
88
- // (more commonly governing) files are seen.
89
- const queue = [{ dir: remoteRoot, depth: 0 }]
90
- let dirsScanned = 0
91
- while (queue.length && files.length < maxFiles && dirsScanned < maxDirs) {
92
- const { dir, depth } = queue.shift()
93
- dirsScanned++
94
- let entries = []
95
- try { entries = (await sftp.readdir(dir)) || [] } catch { continue }
96
- for (const e of entries) {
97
- const name = String(e.filename || '')
98
- if (!name || name === '.' || name === '..' || name.startsWith('.')) {
99
- // Hidden entries are skipped wholesale EXCEPT the names themselves
100
- // never start with a dot, so nothing is lost.
101
- continue
102
- }
103
- const isDir = !!(e.attrs && typeof e.attrs.isDirectory === 'function' && e.attrs.isDirectory())
104
- const isLink = !!(e.attrs && typeof e.attrs.isSymbolicLink === 'function' && e.attrs.isSymbolicLink())
105
- if (isDir) {
106
- // Symlinked dirs are not followed: the walk stays inside the workspace
107
- // tree and cannot loop through a link back to an ancestor.
108
- if (isLink) continue
109
- if (depth < maxDepth && !SKIP_DIRS.has(name.toLowerCase()) && queue.length + dirsScanned < maxDirs) {
110
- queue.push({ dir: joinRemotePath(dir, name), depth: depth + 1 })
111
- }
112
- continue
113
- }
114
- if (!isInstructionName(name)) continue
115
- const remote = joinRemotePath(dir, name)
116
- const rel = relPathUnder(remoteRoot, remote) || name
117
- let st = null
118
- try { st = await sftp.stat(remote) } catch { continue }
119
- const size = st && typeof st.size === 'number' ? st.size : 0
120
- const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0
121
- let content = null
122
- let headings = []
123
- if (size <= INSTRUCTION_MAX_BYTES) {
124
- try {
125
- content = await sftp.readFile(remote)
126
- headings = extractHeadings(content.toString('utf8'))
127
- } catch { content = null }
128
- }
129
- files.push({ rel, remote, size, mtime, content, headings })
130
- if (files.length >= maxFiles) break
131
- }
132
- }
133
- return files
134
- }
135
-
136
- // The three-way keys mirror lib/sync.js exactly (size + whole-second mtime):
137
- // a file this module places into the mirror must be indistinguishable from
138
- // one rw_sync pulled, or the next sync would report a phantom conflict.
139
- const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
140
- const localKeyOf = (st) => (st ? keyOf(st.size, st.mtimeMs / 1000) : null)
141
-
142
- /**
143
- * Write scanned instruction files into the local mirror at their workspace-
144
- * relative paths, three-way safe against `.dsh-remote-sync-state.json`:
145
- *
146
- * - local missing → write (+ align mtime, record state)
147
- * - local identical to remote → keep (record state if absent)
148
- * - remote changed, local untouched (L==S) → write (a plain pull)
149
- * - local edited (L!=S), remote unchanged → SKIP — a local edit is never
150
- * clobbered; rw_sync surfaces it as a conflict like any other file
151
- * - both changed / unknown local content → SKIP (rw_sync arbitrates)
152
- *
153
- * @returns {{written: string[], present: Set<string>}} rel paths written now,
154
- * and every rel that exists locally afterwards (for the index's "mirrored"
155
- * badge).
156
- */
157
- export function syncInstructionsToMirror(files, mirrorDir) {
158
- const written = []
159
- const present = new Set()
160
- if (!mirrorDir || !Array.isArray(files) || !files.length) return { written, present }
161
- const state = loadSyncState(mirrorDir)
162
- let stateDirty = false
163
- for (const f of files) {
164
- if (!f || !f.rel || !f.content) continue
165
- const lp = path.join(mirrorDir, ...String(f.rel).split('/'))
166
- const rk = keyOf(f.size, f.mtime)
167
- const sk = state[f.rel] ? keyOf(state[f.rel].size, state[f.rel].mtime) : null
168
- let lk = null
169
- try { lk = existsSync(lp) ? localKeyOf(statSync(lp)) : null } catch { lk = null }
170
- if (lk === rk) {
171
- // Identical bytes in place already (same size AND mtime second) — only
172
- // make sure the snapshot knows about it.
173
- present.add(f.rel)
174
- if (!sk) { state[f.rel] = { size: f.size, mtime: f.mtime }; stateDirty = true }
175
- continue
176
- }
177
- const canWrite = lk === null || (sk !== null && rk !== sk && lk === sk)
178
- if (!canWrite) continue
179
- try {
180
- mkdirSync(path.dirname(lp), { recursive: true })
181
- writeFileSync(lp, f.content)
182
- // Align the local mtime to the remote's so the next rw_sync sees
183
- // L==R==S and skips the file instead of flagging a local modification.
184
- try { utimesSync(lp, new Date(f.mtime * 1000), new Date(f.mtime * 1000)) } catch { /* cosmetic */ }
185
- state[f.rel] = { size: f.size, mtime: f.mtime }
186
- stateDirty = true
187
- written.push(f.rel)
188
- present.add(f.rel)
189
- } catch { /* a mirror write failure must not break the index */ }
190
- }
191
- if (stateDirty) {
192
- try { saveSyncState(mirrorDir, state) } catch { /* best effort */ }
193
- }
194
- return { written, present }
195
- }
196
-
197
- /**
198
- * Render the prompt-section index of remote instruction files. Paths are
199
- * shown through `display` (the platform-aware display formatter), headings
200
- * give the model a content hint without injecting the full text.
201
- */
202
- export function formatInstructionIndex(files, display = (p) => p) {
203
- const lines = files.slice(0, 10).map((f) => {
204
- const h = f.headings && f.headings.length ? ` (headings: ${f.headings.slice(0, 6).join(' / ')})` : ''
205
- const over = f.size > INSTRUCTION_MAX_BYTES ? ` (${Math.round(f.size / 1024)} KB)` : ''
206
- const mirrored = f.mirrored ? '; synced into the local mirror' : ''
207
- return `- ${display(f.remote)}${h}${over}${mirrored}`
208
- })
209
- return (
210
- 'Remote instruction files found in this workspace (they govern how to work in their directories; '
211
- + 'root-level copies were synced into the local mirror, but nested ones are NOT auto-injected — '
212
- + 'read them with rw_read_file before editing that tree):\n'
213
- + lines.join('\n')
214
- )
215
- }
216
-
217
- /**
218
- * Per-process index of remote instruction scans, keyed by mirror directory.
219
- * Deduplicates in-flight scans, negative-caches failures for a minute (an
220
- * offline machine must not turn every prompt assembly into a connect
221
- * attempt), and never throws — the index is strictly best-effort.
222
- */
223
- export function createInstructionIndex() {
224
- const cache = new Map() // resolvedMirrorDir → { scannedAt, files } | { failedAt }
225
- const inflight = new Map() // resolvedMirrorDir → Promise
226
- const RETRY_AFTER_MS = 60 * 1000
227
-
228
- const keyOfDir = (mirrorDir) => {
229
- try { return path.resolve(String(mirrorDir)) } catch { return String(mirrorDir || '') }
230
- }
231
-
232
- /**
233
- * Scan (or return the cached scan of) one mirror's remote workspace.
234
- * @param {{pool: {sftp: Function}, ws: string, mirrorDir: string, force?: boolean}} arg
235
- * @returns {Promise<{scannedAt: number, files: Array}|null>}
236
- */
237
- const refresh = ({ pool, ws, mirrorDir, force = false }) => {
238
- if (!pool || typeof pool.sftp !== 'function' || !ws || !mirrorDir) return Promise.resolve(null)
239
- const key = keyOfDir(mirrorDir)
240
- const cur = cache.get(key)
241
- if (!force && cur && cur.files) return Promise.resolve(cur)
242
- if (!force && cur && cur.failedAt && Date.now() - cur.failedAt < RETRY_AFTER_MS) return Promise.resolve(null)
243
- if (inflight.has(key)) return inflight.get(key)
244
- const job = (async () => {
245
- try {
246
- const sftp = await pool.sftp()
247
- const files = await scanInstructionFiles(sftp, ws)
248
- const synced = syncInstructionsToMirror(files, mirrorDir)
249
- const entry = {
250
- scannedAt: Date.now(),
251
- files: files.map((f) => ({
252
- rel: f.rel,
253
- remote: f.remote,
254
- size: f.size,
255
- headings: f.headings,
256
- mirrored: synced.present.has(f.rel),
257
- })),
258
- }
259
- cache.set(key, entry)
260
- return entry
261
- } catch {
262
- // Negative-cache: the machine may simply be offline; retry at most
263
- // once a minute, not once per prompt assembly.
264
- cache.set(key, { failedAt: Date.now() })
265
- return null
266
- } finally {
267
- inflight.delete(key)
268
- }
269
- })()
270
- inflight.set(key, job)
271
- return job
272
- }
273
-
274
- /** The cached index for a mirror, or null when unknown/failed. */
275
- const get = (mirrorDir) => {
276
- const e = cache.get(keyOfDir(mirrorDir))
277
- return e && e.files ? e : null
278
- }
279
-
280
- /** Fire-and-forget refresh for prompt-assembly time: starts a scan only
281
- * when nothing is known about the mirror yet (recent failures included). */
282
- const note = ({ pool, ws, mirrorDir }) => {
283
- if (cache.has(keyOfDir(mirrorDir))) return
284
- refresh({ pool, ws, mirrorDir }).catch(() => { /* guarded inside */ })
285
- }
286
-
287
- return { refresh, get, note, _cache: cache }
288
- }
1
+ // dsh-remote — remote instruction-file index (issue #68).
2
+ //
3
+ // The core instruction loader (@deepseek-ai/dsh-agent-instructions) discovers
4
+ // AGENTS.md through the LOCAL filesystem only: from two user-global roots plus
5
+ // the projectRoot→cwd ancestor chain. A remote session's cwd is the local
6
+ // MIRROR of the remote workspace, and that mirror stays empty until rw_sync —
7
+ // so a 20 KB AGENTS.md that governs the remote repo never enters the model's
8
+ // context, while the same file is auto-injected in a local session. File
9
+ // ACCESS was never the problem (rw_read_file reads it fine); what is missing
10
+ // is the model knowing it should.
11
+ //
12
+ // This module closes the gap on the plugin side, along the two cheapest axes
13
+ // the issue lists (the third — a remote ctx.fs provider — belongs to core):
14
+ //
15
+ // 1. INDEX in the system prompt: a shallow scan of the remote workspace
16
+ // (root + at most two directory levels down, AGENTS.md / CLAUDE.md only)
17
+ // is rendered into the dsh-remote prompt section as a path list with
18
+ // headings, telling every new session the files exist and to read them
19
+ // with rw_read_file before editing that tree. This is an INDEX, not a
20
+ // second injection pipeline — the full text stays out of the prompt and
21
+ // out of any parallel baseline, exactly as the issue demands.
22
+ // 2. MIRROR the found files into the local mirror at their relative paths
23
+ // (three-way safe — a local edit is never clobbered, see below). A
24
+ // root-level AGENTS.md then sits exactly where the CORE loader looks in
25
+ // a local session, so the normal baseline/incremental machinery picks it
26
+ // up unchanged; no plugin-side injection logic runs in parallel with it.
27
+ //
28
+ // Deliberate non-goals (the issue's "请不要做"):
29
+ // - NO unbounded recursion of the remote root (multi-project roots like
30
+ // /root would scan forever): the walk is depth- and count-capped.
31
+ // - NO full-text injection from the plugin.
32
+
33
+ import { existsSync, mkdirSync, statSync, writeFileSync, utimesSync } from 'node:fs'
34
+ import path from 'node:path'
35
+ import { joinRemotePath, relPathUnder } from './paths.js'
36
+ import { loadSyncState, saveSyncState } from './sync.js'
37
+
38
+ /** File names treated as agent instruction files (compared case-insensitively;
39
+ * the mirrored copy keeps the remote's exact casing). */
40
+ export const INSTRUCTION_NAMES = ['agents.md', 'claude.md']
41
+
42
+ /** An instruction file bigger than this is indexed (path + note) but not read. */
43
+ export const INSTRUCTION_MAX_BYTES = 256 * 1024
44
+
45
+ /** Caps that keep the shallow scan shallow. */
46
+ export const INSTRUCTION_MAX_FILES = 20
47
+ export const INSTRUCTION_MAX_DIRS = 60
48
+ export const INSTRUCTION_MAX_DEPTH = 2 // 0 = workspace root, 1 = sub/, 2 = sub/sub/
49
+
50
+ /** Directories that never contain a project's governing instructions. */
51
+ const SKIP_DIRS = new Set([
52
+ 'node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'out',
53
+ '.next', '.nuxt', '.cache', '.turbo', 'coverage', 'target', 'vendor',
54
+ ])
55
+
56
+ export const isInstructionName = (name) => INSTRUCTION_NAMES.includes(String(name || '').toLowerCase())
57
+
58
+ /** Pull the first Markdown headings out of an instruction file, for the index. */
59
+ export function extractHeadings(text, max = 8) {
60
+ const out = []
61
+ for (const line of String(text || '').split('\n')) {
62
+ const m = /^#{1,3}\s+(.+?)\s*#*\s*$/.exec(line)
63
+ if (!m) continue
64
+ out.push(m[1].slice(0, 60))
65
+ if (out.length >= max) break
66
+ }
67
+ return out
68
+ }
69
+
70
+ /**
71
+ * Shallow-scan a remote workspace for instruction files: the root directory
72
+ * plus up to INSTRUCTION_MAX_DEPTH levels of subdirectories, matching
73
+ * AGENTS.md / CLAUDE.md basenames. Bounded by INSTRUCTION_MAX_DIRS readdirs
74
+ * and INSTRUCTION_MAX_FILES files; anything past the caps is silently
75
+ * skipped (the index is a hint, not an inventory).
76
+ *
77
+ * @param {object} sftp - the promisified sftp surface (readdir/stat/readFile).
78
+ * @param {string} remoteRoot - absolute remote workspace path (normalized).
79
+ * @returns {Promise<Array<{rel: string, remote: string, size: number, mtime: number, content: Buffer|null, headings: string[]}>>}
80
+ * `content`/`headings` are null/empty for files over INSTRUCTION_MAX_BYTES.
81
+ */
82
+ export async function scanInstructionFiles(sftp, remoteRoot, opts = {}) {
83
+ const maxDepth = Math.max(0, Math.min(Number(opts.maxDepth ?? INSTRUCTION_MAX_DEPTH) || 0, 4))
84
+ const maxDirs = Math.max(1, Number(opts.maxDirs ?? INSTRUCTION_MAX_DIRS) || INSTRUCTION_MAX_DIRS)
85
+ const maxFiles = Math.max(1, Number(opts.maxFiles ?? INSTRUCTION_MAX_FILES) || INSTRUCTION_MAX_FILES)
86
+ const files = []
87
+ // Breadth-first so a wide root cannot push the walk deep before the shallow
88
+ // (more commonly governing) files are seen.
89
+ const queue = [{ dir: remoteRoot, depth: 0 }]
90
+ let dirsScanned = 0
91
+ while (queue.length && files.length < maxFiles && dirsScanned < maxDirs) {
92
+ const { dir, depth } = queue.shift()
93
+ dirsScanned++
94
+ let entries = []
95
+ try { entries = (await sftp.readdir(dir)) || [] } catch { continue }
96
+ for (const e of entries) {
97
+ const name = String(e.filename || '')
98
+ if (!name || name === '.' || name === '..' || name.startsWith('.')) {
99
+ // Hidden entries are skipped wholesale EXCEPT the names themselves
100
+ // never start with a dot, so nothing is lost.
101
+ continue
102
+ }
103
+ const isDir = !!(e.attrs && typeof e.attrs.isDirectory === 'function' && e.attrs.isDirectory())
104
+ const isLink = !!(e.attrs && typeof e.attrs.isSymbolicLink === 'function' && e.attrs.isSymbolicLink())
105
+ if (isDir) {
106
+ // Symlinked dirs are not followed: the walk stays inside the workspace
107
+ // tree and cannot loop through a link back to an ancestor.
108
+ if (isLink) continue
109
+ if (depth < maxDepth && !SKIP_DIRS.has(name.toLowerCase()) && queue.length + dirsScanned < maxDirs) {
110
+ queue.push({ dir: joinRemotePath(dir, name), depth: depth + 1 })
111
+ }
112
+ continue
113
+ }
114
+ if (!isInstructionName(name)) continue
115
+ const remote = joinRemotePath(dir, name)
116
+ const rel = relPathUnder(remoteRoot, remote) || name
117
+ let st = null
118
+ try { st = await sftp.stat(remote) } catch { continue }
119
+ const size = st && typeof st.size === 'number' ? st.size : 0
120
+ const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0
121
+ let content = null
122
+ let headings = []
123
+ if (size <= INSTRUCTION_MAX_BYTES) {
124
+ try {
125
+ content = await sftp.readFile(remote)
126
+ headings = extractHeadings(content.toString('utf8'))
127
+ } catch { content = null }
128
+ }
129
+ files.push({ rel, remote, size, mtime, content, headings })
130
+ if (files.length >= maxFiles) break
131
+ }
132
+ }
133
+ return files
134
+ }
135
+
136
+ // The three-way keys mirror lib/sync.js exactly (size + whole-second mtime):
137
+ // a file this module places into the mirror must be indistinguishable from
138
+ // one rw_sync pulled, or the next sync would report a phantom conflict.
139
+ const keyOf = (size, mtime) => `${size}:${Math.floor(mtime || 0)}`
140
+ const localKeyOf = (st) => (st ? keyOf(st.size, st.mtimeMs / 1000) : null)
141
+
142
+ /**
143
+ * Write scanned instruction files into the local mirror at their workspace-
144
+ * relative paths, three-way safe against `.dsh-remote-sync-state.json`:
145
+ *
146
+ * - local missing → write (+ align mtime, record state)
147
+ * - local identical to remote → keep (record state if absent)
148
+ * - remote changed, local untouched (L==S) → write (a plain pull)
149
+ * - local edited (L!=S), remote unchanged → SKIP — a local edit is never
150
+ * clobbered; rw_sync surfaces it as a conflict like any other file
151
+ * - both changed / unknown local content → SKIP (rw_sync arbitrates)
152
+ *
153
+ * @returns {{written: string[], present: Set<string>}} rel paths written now,
154
+ * and every rel that exists locally afterwards (for the index's "mirrored"
155
+ * badge).
156
+ */
157
+ export function syncInstructionsToMirror(files, mirrorDir) {
158
+ const written = []
159
+ const present = new Set()
160
+ if (!mirrorDir || !Array.isArray(files) || !files.length) return { written, present }
161
+ const state = loadSyncState(mirrorDir)
162
+ let stateDirty = false
163
+ for (const f of files) {
164
+ if (!f || !f.rel || !f.content) continue
165
+ const lp = path.join(mirrorDir, ...String(f.rel).split('/'))
166
+ const rk = keyOf(f.size, f.mtime)
167
+ const sk = state[f.rel] ? keyOf(state[f.rel].size, state[f.rel].mtime) : null
168
+ let lk = null
169
+ try { lk = existsSync(lp) ? localKeyOf(statSync(lp)) : null } catch { lk = null }
170
+ if (lk === rk) {
171
+ // Identical bytes in place already (same size AND mtime second) — only
172
+ // make sure the snapshot knows about it.
173
+ present.add(f.rel)
174
+ if (!sk) { state[f.rel] = { size: f.size, mtime: f.mtime }; stateDirty = true }
175
+ continue
176
+ }
177
+ const canWrite = lk === null || (sk !== null && rk !== sk && lk === sk)
178
+ if (!canWrite) continue
179
+ try {
180
+ mkdirSync(path.dirname(lp), { recursive: true })
181
+ writeFileSync(lp, f.content)
182
+ // Align the local mtime to the remote's so the next rw_sync sees
183
+ // L==R==S and skips the file instead of flagging a local modification.
184
+ try { utimesSync(lp, new Date(f.mtime * 1000), new Date(f.mtime * 1000)) } catch { /* cosmetic */ }
185
+ state[f.rel] = { size: f.size, mtime: f.mtime }
186
+ stateDirty = true
187
+ written.push(f.rel)
188
+ present.add(f.rel)
189
+ } catch { /* a mirror write failure must not break the index */ }
190
+ }
191
+ if (stateDirty) {
192
+ try { saveSyncState(mirrorDir, state) } catch { /* best effort */ }
193
+ }
194
+ return { written, present }
195
+ }
196
+
197
+ /**
198
+ * Render the prompt-section index of remote instruction files. Paths are
199
+ * shown through `display` (the platform-aware display formatter), headings
200
+ * give the model a content hint without injecting the full text.
201
+ */
202
+ export function formatInstructionIndex(files, display = (p) => p) {
203
+ const lines = files.slice(0, 10).map((f) => {
204
+ const h = f.headings && f.headings.length ? ` (headings: ${f.headings.slice(0, 6).join(' / ')})` : ''
205
+ const over = f.size > INSTRUCTION_MAX_BYTES ? ` (${Math.round(f.size / 1024)} KB)` : ''
206
+ const mirrored = f.mirrored ? '; synced into the local mirror' : ''
207
+ return `- ${display(f.remote)}${h}${over}${mirrored}`
208
+ })
209
+ return (
210
+ 'Remote instruction files found in this workspace (they govern how to work in their directories; '
211
+ + 'root-level copies were synced into the local mirror, but nested ones are NOT auto-injected — '
212
+ + 'read them with rw_read_file before editing that tree):\n'
213
+ + lines.join('\n')
214
+ )
215
+ }
216
+
217
+ /**
218
+ * Per-process index of remote instruction scans, keyed by mirror directory.
219
+ * Deduplicates in-flight scans, negative-caches failures for a minute (an
220
+ * offline machine must not turn every prompt assembly into a connect
221
+ * attempt), and never throws — the index is strictly best-effort.
222
+ */
223
+ export function createInstructionIndex() {
224
+ const cache = new Map() // resolvedMirrorDir → { scannedAt, files } | { failedAt }
225
+ const inflight = new Map() // resolvedMirrorDir → Promise
226
+ const RETRY_AFTER_MS = 60 * 1000
227
+
228
+ const keyOfDir = (mirrorDir) => {
229
+ try { return path.resolve(String(mirrorDir)) } catch { return String(mirrorDir || '') }
230
+ }
231
+
232
+ /**
233
+ * Scan (or return the cached scan of) one mirror's remote workspace.
234
+ * @param {{pool: {sftp: Function}, ws: string, mirrorDir: string, force?: boolean}} arg
235
+ * @returns {Promise<{scannedAt: number, files: Array}|null>}
236
+ */
237
+ const refresh = ({ pool, ws, mirrorDir, force = false }) => {
238
+ if (!pool || typeof pool.sftp !== 'function' || !ws || !mirrorDir) return Promise.resolve(null)
239
+ const key = keyOfDir(mirrorDir)
240
+ const cur = cache.get(key)
241
+ if (!force && cur && cur.files) return Promise.resolve(cur)
242
+ if (!force && cur && cur.failedAt && Date.now() - cur.failedAt < RETRY_AFTER_MS) return Promise.resolve(null)
243
+ if (inflight.has(key)) return inflight.get(key)
244
+ const job = (async () => {
245
+ try {
246
+ const sftp = await pool.sftp()
247
+ const files = await scanInstructionFiles(sftp, ws)
248
+ const synced = syncInstructionsToMirror(files, mirrorDir)
249
+ const entry = {
250
+ scannedAt: Date.now(),
251
+ files: files.map((f) => ({
252
+ rel: f.rel,
253
+ remote: f.remote,
254
+ size: f.size,
255
+ headings: f.headings,
256
+ mirrored: synced.present.has(f.rel),
257
+ })),
258
+ }
259
+ cache.set(key, entry)
260
+ return entry
261
+ } catch {
262
+ // Negative-cache: the machine may simply be offline; retry at most
263
+ // once a minute, not once per prompt assembly.
264
+ cache.set(key, { failedAt: Date.now() })
265
+ return null
266
+ } finally {
267
+ inflight.delete(key)
268
+ }
269
+ })()
270
+ inflight.set(key, job)
271
+ return job
272
+ }
273
+
274
+ /** The cached index for a mirror, or null when unknown/failed. */
275
+ const get = (mirrorDir) => {
276
+ const e = cache.get(keyOfDir(mirrorDir))
277
+ return e && e.files ? e : null
278
+ }
279
+
280
+ /** Fire-and-forget refresh for prompt-assembly time: starts a scan only
281
+ * when nothing is known about the mirror yet (recent failures included). */
282
+ const note = ({ pool, ws, mirrorDir }) => {
283
+ if (cache.has(keyOfDir(mirrorDir))) return
284
+ refresh({ pool, ws, mirrorDir }).catch(() => { /* guarded inside */ })
285
+ }
286
+
287
+ return { refresh, get, note, _cache: cache }
288
+ }