dsh-remote 0.8.66 → 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.
- package/README.en.md +389 -389
- package/README.md +462 -463
- package/lib/client.js +3990 -3954
- package/lib/exec-job.js +14 -0
- package/lib/index.js +4548 -4494
- package/lib/instructions-dynamic.js +81 -2
- package/lib/instructions.js +288 -288
- package/lib/openssh.js +40 -8
- package/lib/paths.js +27 -0
- package/package.json +2 -2
package/lib/instructions.js
CHANGED
|
@@ -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
|
+
}
|