pi-soly 1.9.3 → 1.11.0
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/ask/index.ts +12 -11
- package/ask/picker.ts +356 -76
- package/ask/prompt.ts +6 -2
- package/ask/tests/picker.test.ts +273 -82
- package/codemap.ts +276 -0
- package/hotreload.ts +239 -0
- package/init.ts +302 -0
- package/mcp/CHANGELOG.md +384 -0
- package/mcp/LICENSE +21 -0
- package/mcp/OAUTH.md +355 -0
- package/mcp/README.md +410 -0
- package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
- package/mcp/__tests__/cli.upstream-test.ts +97 -0
- package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
- package/mcp/__tests__/config.upstream-test.ts +303 -0
- package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
- package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
- package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
- package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
- package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
- package/mcp/__tests__/errors.upstream-test.ts +218 -0
- package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
- package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
- package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
- package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
- package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
- package/mcp/__tests__/logger.upstream-test.ts +175 -0
- package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
- package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
- package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
- package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
- package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
- package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
- package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
- package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
- package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
- package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
- package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
- package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
- package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
- package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
- package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
- package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
- package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
- package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
- package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
- package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
- package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
- package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
- package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
- package/mcp/agent-dir.ts +20 -0
- package/mcp/app-bridge.bundle.js +67 -0
- package/mcp/cli.js +184 -0
- package/mcp/commands.ts +422 -0
- package/mcp/config.ts +666 -0
- package/mcp/consent-manager.ts +64 -0
- package/mcp/direct-tools.ts +439 -0
- package/mcp/elicitation-handler.ts +347 -0
- package/mcp/errors.ts +219 -0
- package/mcp/glimpse-ui.ts +80 -0
- package/mcp/host-html-template.ts +427 -0
- package/mcp/index.ts +362 -0
- package/mcp/init.ts +362 -0
- package/mcp/lifecycle.ts +93 -0
- package/mcp/logger.ts +169 -0
- package/mcp/mcp-auth-flow.ts +559 -0
- package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
- package/mcp/mcp-auth.ts +302 -0
- package/mcp/mcp-auth.upstream-test.ts +373 -0
- package/mcp/mcp-callback-server.ts +372 -0
- package/mcp/mcp-callback-server.upstream-test.ts +416 -0
- package/mcp/mcp-oauth-provider.ts +369 -0
- package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
- package/mcp/mcp-panel.ts +829 -0
- package/mcp/mcp-setup-panel.ts +580 -0
- package/mcp/metadata-cache.ts +201 -0
- package/mcp/notify.ts +111 -0
- package/mcp/npx-resolver.ts +424 -0
- package/mcp/oauth-handler.ts +57 -0
- package/mcp/onboarding-state.ts +68 -0
- package/mcp/package.json +106 -0
- package/mcp/panel-keys.ts +37 -0
- package/mcp/proxy-modes.ts +949 -0
- package/mcp/resource-tools.ts +17 -0
- package/mcp/sampling-handler.ts +268 -0
- package/mcp/server-manager.ts +545 -0
- package/mcp/state.ts +41 -0
- package/mcp/tool-metadata.ts +216 -0
- package/mcp/tool-registrar.ts +46 -0
- package/mcp/tool-result-renderer.ts +161 -0
- package/mcp/types.ts +448 -0
- package/mcp/ui-resource-handler.ts +146 -0
- package/mcp/ui-server.ts +623 -0
- package/mcp/ui-session.ts +386 -0
- package/mcp/ui-stream-types.ts +89 -0
- package/mcp/utils.ts +129 -0
- package/mcp/vitest.config.ts +14 -0
- package/migrate.ts +258 -0
- package/notification.ts +218 -0
- package/notifications-log.ts +83 -0
- package/package.json +20 -3
- package/status.ts +140 -0
package/codemap.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// codemap.ts — Project layout map for the soly extension
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Walks cwd once at session_start, builds a compact "## project layout" map
|
|
6
|
+
// of top-level directories, key config files, and code file counts. Injected
|
|
7
|
+
// into the system prompt so the model has immediate awareness of where things
|
|
8
|
+
// live without having to `ls` its way around.
|
|
9
|
+
//
|
|
10
|
+
// Heuristics:
|
|
11
|
+
// - skip noise: node_modules, .git, dist, build, .soly, .next, out, target,
|
|
12
|
+
// coverage, .venv, __pycache__
|
|
13
|
+
// - depth: walk 2 levels deep by default (cwd → dirs → subdirs)
|
|
14
|
+
// - key files: package.json, tsconfig.json, README.md, Cargo.toml, go.mod,
|
|
15
|
+
// pyproject.toml, .env.example, Dockerfile, docker-compose.yml/yaml,
|
|
16
|
+
// Makefile, .github/workflows
|
|
17
|
+
// - code counts: count files by extension at each level
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
|
|
23
|
+
const IGNORE_DIRS = new Set([
|
|
24
|
+
"node_modules",
|
|
25
|
+
".git",
|
|
26
|
+
".soly",
|
|
27
|
+
"dist",
|
|
28
|
+
"build",
|
|
29
|
+
"out",
|
|
30
|
+
"target",
|
|
31
|
+
"coverage",
|
|
32
|
+
".next",
|
|
33
|
+
".nuxt",
|
|
34
|
+
".venv",
|
|
35
|
+
"venv",
|
|
36
|
+
"__pycache__",
|
|
37
|
+
".cache",
|
|
38
|
+
".parcel-cache",
|
|
39
|
+
".turbo",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const KEY_FILES = new Set([
|
|
43
|
+
"package.json",
|
|
44
|
+
"tsconfig.json",
|
|
45
|
+
"tsconfig.base.json",
|
|
46
|
+
"jsconfig.json",
|
|
47
|
+
"README.md",
|
|
48
|
+
"README.rst",
|
|
49
|
+
"Cargo.toml",
|
|
50
|
+
"go.mod",
|
|
51
|
+
"pyproject.toml",
|
|
52
|
+
"setup.py",
|
|
53
|
+
"requirements.txt",
|
|
54
|
+
"Pipfile",
|
|
55
|
+
"Gemfile",
|
|
56
|
+
"pom.xml",
|
|
57
|
+
"build.gradle",
|
|
58
|
+
"build.sbt",
|
|
59
|
+
"Makefile",
|
|
60
|
+
"Dockerfile",
|
|
61
|
+
"docker-compose.yml",
|
|
62
|
+
"docker-compose.yaml",
|
|
63
|
+
".env.example",
|
|
64
|
+
".nvmrc",
|
|
65
|
+
".node-version",
|
|
66
|
+
".tool-versions",
|
|
67
|
+
".editorconfig",
|
|
68
|
+
".prettierrc",
|
|
69
|
+
".eslintrc",
|
|
70
|
+
".eslintrc.js",
|
|
71
|
+
".eslintrc.json",
|
|
72
|
+
"biome.json",
|
|
73
|
+
"vitest.config.ts",
|
|
74
|
+
"jest.config.js",
|
|
75
|
+
"jest.config.ts",
|
|
76
|
+
"vite.config.ts",
|
|
77
|
+
"webpack.config.js",
|
|
78
|
+
"rollup.config.js",
|
|
79
|
+
"index.html",
|
|
80
|
+
".github",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const CODE_EXTS = new Set([
|
|
84
|
+
"ts", "tsx", "js", "jsx", "mjs", "cjs",
|
|
85
|
+
"py", "pyx",
|
|
86
|
+
"go",
|
|
87
|
+
"rs",
|
|
88
|
+
"java", "kt", "scala",
|
|
89
|
+
"rb",
|
|
90
|
+
"php",
|
|
91
|
+
"cs", "fs", "vb",
|
|
92
|
+
"swift", "m",
|
|
93
|
+
"c", "cc", "cpp", "h", "hpp",
|
|
94
|
+
"sh", "bash", "zsh",
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
export interface CodeMap {
|
|
98
|
+
root: string;
|
|
99
|
+
topLevel: { dirs: string[]; files: string[] };
|
|
100
|
+
/** Sub-tree at depth 2: parent → children (dirs only, capped). */
|
|
101
|
+
tree: Map<string, string[]>;
|
|
102
|
+
/** Key files with brief summaries (first line). */
|
|
103
|
+
keyFiles: { relPath: string; summary: string }[];
|
|
104
|
+
/** Code file counts per top-level dir. */
|
|
105
|
+
codeCounts: Record<string, number>;
|
|
106
|
+
hasGithubWorkflows: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function summarizeKeyFile(absPath: string, maxLen = 100): string {
|
|
110
|
+
try {
|
|
111
|
+
const content = fs.readFileSync(absPath, "utf-8");
|
|
112
|
+
// First non-empty, non-frontmatter line
|
|
113
|
+
const lines = content.split(/\r?\n/);
|
|
114
|
+
let inFrontmatter = false;
|
|
115
|
+
for (const l of lines) {
|
|
116
|
+
const t = l.trim();
|
|
117
|
+
if (!t) continue;
|
|
118
|
+
if (t === "---") {
|
|
119
|
+
inFrontmatter = !inFrontmatter;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (inFrontmatter) continue;
|
|
123
|
+
return t.slice(0, maxLen);
|
|
124
|
+
}
|
|
125
|
+
return "";
|
|
126
|
+
} catch {
|
|
127
|
+
return "";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function listChildren(dir: string): { dirs: string[]; files: string[] } {
|
|
132
|
+
try {
|
|
133
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
134
|
+
const dirs: string[] = [];
|
|
135
|
+
const files: string[] = [];
|
|
136
|
+
for (const e of entries) {
|
|
137
|
+
if (e.name.startsWith(".") && e.name !== ".github") continue;
|
|
138
|
+
if (IGNORE_DIRS.has(e.name)) continue;
|
|
139
|
+
if (e.isDirectory()) dirs.push(e.name);
|
|
140
|
+
else if (e.isFile()) files.push(e.name);
|
|
141
|
+
}
|
|
142
|
+
dirs.sort();
|
|
143
|
+
files.sort();
|
|
144
|
+
return { dirs, files };
|
|
145
|
+
} catch {
|
|
146
|
+
return { dirs: [], files: [] };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function buildCodeMap(cwd: string, maxDepth = 2): CodeMap {
|
|
151
|
+
const top = listChildren(cwd);
|
|
152
|
+
const tree = new Map<string, string[]>();
|
|
153
|
+
const codeCounts: Record<string, number> = {};
|
|
154
|
+
const keyFiles: { relPath: string; summary: string }[] = [];
|
|
155
|
+
let hasGithubWorkflows = false;
|
|
156
|
+
|
|
157
|
+
// Top-level key files
|
|
158
|
+
for (const f of top.files) {
|
|
159
|
+
if (KEY_FILES.has(f) || KEY_FILES.has(`-${f}`)) {
|
|
160
|
+
const summary = summarizeKeyFile(path.join(cwd, f));
|
|
161
|
+
keyFiles.push({ relPath: f, summary });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Walk dirs at depth 1
|
|
166
|
+
for (const d of top.dirs) {
|
|
167
|
+
const subPath = path.join(cwd, d);
|
|
168
|
+
const sub = listChildren(subPath);
|
|
169
|
+
tree.set(d, sub.dirs);
|
|
170
|
+
|
|
171
|
+
// Key files inside top-level dirs (e.g. src/index.ts, src/package.json)
|
|
172
|
+
for (const f of sub.files) {
|
|
173
|
+
if (KEY_FILES.has(f)) {
|
|
174
|
+
keyFiles.push({
|
|
175
|
+
relPath: `${d}/${f}`,
|
|
176
|
+
summary: summarizeKeyFile(path.join(subPath, f)),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Code count: walk this dir, count by extension
|
|
182
|
+
let count = 0;
|
|
183
|
+
try {
|
|
184
|
+
const stack = [subPath];
|
|
185
|
+
let depth = 0;
|
|
186
|
+
while (stack.length > 0 && depth <= maxDepth) {
|
|
187
|
+
const cur = stack.pop()!;
|
|
188
|
+
const entries = fs.readdirSync(cur, { withFileTypes: true });
|
|
189
|
+
for (const e of entries) {
|
|
190
|
+
if (e.name.startsWith(".")) continue;
|
|
191
|
+
if (IGNORE_DIRS.has(e.name)) continue;
|
|
192
|
+
if (e.isDirectory()) {
|
|
193
|
+
stack.push(path.join(cur, e.name));
|
|
194
|
+
} else if (e.isFile()) {
|
|
195
|
+
const ext = e.name.split(".").pop()?.toLowerCase();
|
|
196
|
+
if (ext && CODE_EXTS.has(ext)) count++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
depth++;
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// best effort
|
|
203
|
+
}
|
|
204
|
+
codeCounts[d] = count;
|
|
205
|
+
|
|
206
|
+
// .github/workflows detection
|
|
207
|
+
if (d === ".github") {
|
|
208
|
+
const wfDir = path.join(subPath, "workflows");
|
|
209
|
+
if (fs.existsSync(wfDir)) {
|
|
210
|
+
try {
|
|
211
|
+
const wfEntries = fs.readdirSync(wfDir);
|
|
212
|
+
if (wfEntries.length > 0) hasGithubWorkflows = true;
|
|
213
|
+
} catch {
|
|
214
|
+
// ignore
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
root: cwd,
|
|
222
|
+
topLevel: top,
|
|
223
|
+
tree,
|
|
224
|
+
keyFiles,
|
|
225
|
+
codeCounts,
|
|
226
|
+
hasGithubWorkflows,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Render a compact "## project layout" section for the system prompt. */
|
|
231
|
+
export function buildCodeMapSection(map: CodeMap): string {
|
|
232
|
+
if (map.topLevel.dirs.length === 0 && map.topLevel.files.length === 0) {
|
|
233
|
+
return "";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const lines: string[] = ["", "## project layout", ""];
|
|
237
|
+
|
|
238
|
+
// Top-level tree (1-line ASCII)
|
|
239
|
+
const allTop = [...map.topLevel.dirs, ...map.topLevel.files].slice(0, 20);
|
|
240
|
+
lines.push(`\`\`\``);
|
|
241
|
+
lines.push(`${path.basename(map.root)}/`);
|
|
242
|
+
for (const name of allTop) {
|
|
243
|
+
const isDir = map.topLevel.dirs.includes(name);
|
|
244
|
+
const count = map.codeCounts[name];
|
|
245
|
+
const suffix = isDir && count ? ` (${count} code file${count === 1 ? "" : "s"})` : "";
|
|
246
|
+
lines.push(`├── ${name}${isDir ? "/" : ""}${suffix}`);
|
|
247
|
+
}
|
|
248
|
+
if (map.topLevel.dirs.length + map.topLevel.files.length > 20) {
|
|
249
|
+
lines.push(`└── … (${map.topLevel.dirs.length + map.topLevel.files.length - 20} more)`);
|
|
250
|
+
}
|
|
251
|
+
lines.push(`\`\`\``);
|
|
252
|
+
lines.push("");
|
|
253
|
+
|
|
254
|
+
// Key files
|
|
255
|
+
if (map.keyFiles.length > 0) {
|
|
256
|
+
lines.push("**Key files:**");
|
|
257
|
+
for (const kf of map.keyFiles.slice(0, 8)) {
|
|
258
|
+
lines.push(`- \`${kf.relPath}\`${kf.summary ? ` — ${kf.summary}` : ""}`);
|
|
259
|
+
}
|
|
260
|
+
if (map.keyFiles.length > 8) {
|
|
261
|
+
lines.push(`- … and ${map.keyFiles.length - 8} more`);
|
|
262
|
+
}
|
|
263
|
+
lines.push("");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// CI hint
|
|
267
|
+
if (map.hasGithubWorkflows) {
|
|
268
|
+
lines.push("- **CI**: GitHub Actions workflows present");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
lines.push(
|
|
272
|
+
"Use `soly_snippet(path, offset, limit)` or `soly_doc_search(query)` to read specific files without loading the whole tree.",
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
return lines.join("\n");
|
|
276
|
+
}
|
package/hotreload.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// hotreload.ts — Live file watcher for soly rules
|
|
3
|
+
// =============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Watches all rule source directories (project-soly, global-soly) for
|
|
6
|
+
// changes. When a .md file in any of them is created, modified, or
|
|
7
|
+
// deleted, the extension reloads its rule set and re-renders the status
|
|
8
|
+
// bar — all within the same session, no /reload.
|
|
9
|
+
//
|
|
10
|
+
// Why this matters: editing rules is a tight feedback loop. Without hot
|
|
11
|
+
// reload, you have to wait for the next turn_end (or restart) to see
|
|
12
|
+
// effects. With it, saving a rule file is instantly reflected in the
|
|
13
|
+
// "rules N" counter in the status bar.
|
|
14
|
+
//
|
|
15
|
+
// Two reload strategies in parallel:
|
|
16
|
+
// 1. fs.watch with debounce 100ms — fast, but unreliable on Windows
|
|
17
|
+
// (recursive watch is buggy, some editor save patterns miss events).
|
|
18
|
+
// 2. Polling fallback every `pollMs` ms — checks mtime of every rule
|
|
19
|
+
// file. Slower (constant disk I/O) but reliable everywhere.
|
|
20
|
+
// Both feed into the same debounced onChange callback. If fs.watch
|
|
21
|
+
// fires, it preempts the next poll tick (no double reload).
|
|
22
|
+
//
|
|
23
|
+
// Watchers are cleaned up on session_shutdown.
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import type { SourceSpec } from "./core.ts";
|
|
29
|
+
|
|
30
|
+
export interface HotReloadOptions {
|
|
31
|
+
/** Called when any watched file changes (debounced). The `reason` argument
|
|
32
|
+
* carries a human-readable description of what triggered the reload
|
|
33
|
+
* (e.g. `poll:soly/code-style.md`). */
|
|
34
|
+
onChange: (reason: string) => void;
|
|
35
|
+
/** Debounce window in ms — coalesce burst writes from editors. */
|
|
36
|
+
debounceMs?: number;
|
|
37
|
+
/** Polling interval in ms — fallback when fs.watch is unreliable. */
|
|
38
|
+
pollMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Set of FSWatchers + cleanup helper. */
|
|
42
|
+
export interface HotReloadHandle {
|
|
43
|
+
stop: () => void;
|
|
44
|
+
/** Manually trigger a refresh (e.g. from turn_end fallback). */
|
|
45
|
+
tick: (reason: string) => void;
|
|
46
|
+
/** Install a user-facing notifier that gets coalesced rapid-fire
|
|
47
|
+
* changes into a single message. Optional — the raw onChange still
|
|
48
|
+
* fires per-change for status updates. */
|
|
49
|
+
setNotifyHandler: (handler: (reason: string) => void) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Walk a source dir and return every .md file with its mtime. */
|
|
53
|
+
function snapshotMtimes(dir: string): Map<string, number> {
|
|
54
|
+
const out = new Map<string, number>();
|
|
55
|
+
if (!fs.existsSync(dir)) return out;
|
|
56
|
+
const walk = (d: string) => {
|
|
57
|
+
let entries: fs.Dirent[];
|
|
58
|
+
try {
|
|
59
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
60
|
+
} catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const e of entries) {
|
|
64
|
+
if (e.name.startsWith(".")) continue;
|
|
65
|
+
if (e.name === "node_modules") continue;
|
|
66
|
+
const full = path.join(d, e.name);
|
|
67
|
+
if (e.isDirectory()) {
|
|
68
|
+
walk(full);
|
|
69
|
+
} else if (e.isFile() && e.name.endsWith(".md")) {
|
|
70
|
+
try {
|
|
71
|
+
const stat = fs.statSync(full);
|
|
72
|
+
out.set(full, stat.mtimeMs);
|
|
73
|
+
} catch {
|
|
74
|
+
// skip unreadable
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
walk(dir);
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function startHotReload(
|
|
84
|
+
sources: SourceSpec[],
|
|
85
|
+
opts: HotReloadOptions,
|
|
86
|
+
): HotReloadHandle {
|
|
87
|
+
const debounceMs = opts.debounceMs ?? 100;
|
|
88
|
+
const pollMs = opts.pollMs ?? 2000;
|
|
89
|
+
|
|
90
|
+
const watchers: fs.FSWatcher[] = [];
|
|
91
|
+
let pendingTimer: NodeJS.Timeout | null = null;
|
|
92
|
+
let pollTimer: NodeJS.Timeout | null = null;
|
|
93
|
+
let stopped = false;
|
|
94
|
+
|
|
95
|
+
// Notification coalescer — editors often save in 2–3 quick bursts
|
|
96
|
+
// (write to .tmp, rename, touch), which would each fire onChange.
|
|
97
|
+
// We batch them into a single user-visible notify by accumulating the
|
|
98
|
+
// most recent reason until the debounce window expires.
|
|
99
|
+
let pendingNotify: { reasons: string[]; timer: NodeJS.Timeout | null } = {
|
|
100
|
+
reasons: [],
|
|
101
|
+
timer: null,
|
|
102
|
+
};
|
|
103
|
+
const NOTIFY_COALESCE_MS = 500;
|
|
104
|
+
let notifyHandler: ((reason: string) => void) | null = null;
|
|
105
|
+
|
|
106
|
+
// Wrap user's onChange so that the actual callback still fires per-change
|
|
107
|
+
// (we can't break that contract — index.ts updates status synchronously),
|
|
108
|
+
// but a user-facing notify is coalesced. The wrapper just buffers reasons.
|
|
109
|
+
const wrappedOnChange = (reason: string) => {
|
|
110
|
+
opts.onChange(reason);
|
|
111
|
+
if (notifyHandler) {
|
|
112
|
+
pendingNotify.reasons.push(reason);
|
|
113
|
+
if (pendingNotify.timer) clearTimeout(pendingNotify.timer);
|
|
114
|
+
pendingNotify.timer = setTimeout(() => {
|
|
115
|
+
const reasons = pendingNotify.reasons;
|
|
116
|
+
pendingNotify = { reasons: [], timer: null };
|
|
117
|
+
if (reasons.length === 1) {
|
|
118
|
+
notifyHandler!(reasons[0]!);
|
|
119
|
+
} else if (reasons.length > 1) {
|
|
120
|
+
notifyHandler!(
|
|
121
|
+
`${reasons.length} rapid change(s) (last: ${reasons[reasons.length - 1]})`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}, NOTIFY_COALESCE_MS);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const schedule = (reason: string) => {
|
|
129
|
+
if (stopped) return;
|
|
130
|
+
if (pendingTimer) clearTimeout(pendingTimer);
|
|
131
|
+
pendingTimer = setTimeout(() => {
|
|
132
|
+
pendingTimer = null;
|
|
133
|
+
if (stopped) return;
|
|
134
|
+
try {
|
|
135
|
+
wrappedOnChange(reason);
|
|
136
|
+
// After reload, update snapshot so polling doesn't re-fire
|
|
137
|
+
// on the same state until files change again.
|
|
138
|
+
lastMtimes = new Map();
|
|
139
|
+
for (const spec of sources) {
|
|
140
|
+
for (const [p, t] of snapshotMtimes(spec.dir)) {
|
|
141
|
+
lastMtimes.set(p, t);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Swallow — the consumer's onChange is best-effort
|
|
146
|
+
}
|
|
147
|
+
}, debounceMs);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Snapshot of file mtimes for polling. Resets after each reload so we
|
|
151
|
+
// don't repeatedly fire on the same state.
|
|
152
|
+
let lastMtimes: Map<string, number> = new Map();
|
|
153
|
+
for (const spec of sources) {
|
|
154
|
+
for (const [p, t] of snapshotMtimes(spec.dir)) {
|
|
155
|
+
lastMtimes.set(p, t);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const spec of sources) {
|
|
160
|
+
if (!fs.existsSync(spec.dir)) continue;
|
|
161
|
+
try {
|
|
162
|
+
// recursive: true walks subdirs so nested rule directories work
|
|
163
|
+
const watcher = fs.watch(
|
|
164
|
+
spec.dir,
|
|
165
|
+
{ recursive: true },
|
|
166
|
+
(_event, filename) => {
|
|
167
|
+
if (!filename) return;
|
|
168
|
+
// Ignore non-markdown noise (.swp, .tmp, lock files)
|
|
169
|
+
const name = String(filename);
|
|
170
|
+
if (!name.endsWith(".md")) return;
|
|
171
|
+
schedule(`${spec.sourceLabel}:${name}`);
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
watchers.push(watcher);
|
|
175
|
+
} catch {
|
|
176
|
+
// Some filesystems don't support recursive watch — polling
|
|
177
|
+
// fallback below still catches changes.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Polling fallback — reliable on Windows / network mounts / editors
|
|
182
|
+
// that don't trigger fs.watch. Coalesces into the same debounce.
|
|
183
|
+
pollTimer = setInterval(() => {
|
|
184
|
+
if (stopped) return;
|
|
185
|
+
let changed: string | null = null;
|
|
186
|
+
const current = new Map<string, number>();
|
|
187
|
+
for (const spec of sources) {
|
|
188
|
+
const snap = snapshotMtimes(spec.dir);
|
|
189
|
+
for (const [p, t] of snap) {
|
|
190
|
+
current.set(p, t);
|
|
191
|
+
const prev = lastMtimes.get(p);
|
|
192
|
+
if (prev !== t) {
|
|
193
|
+
changed = `${spec.sourceLabel}:${path.basename(p)}`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Detect deletions (file present in lastMtimes but missing in current)
|
|
198
|
+
if (!changed) {
|
|
199
|
+
for (const p of lastMtimes.keys()) {
|
|
200
|
+
if (!current.has(p)) {
|
|
201
|
+
changed = `${path.basename(p)}:deleted`;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (changed) schedule(`poll:${changed}`);
|
|
207
|
+
}, pollMs);
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
stop: () => {
|
|
211
|
+
stopped = true;
|
|
212
|
+
if (pendingTimer) {
|
|
213
|
+
clearTimeout(pendingTimer);
|
|
214
|
+
pendingTimer = null;
|
|
215
|
+
}
|
|
216
|
+
if (pendingNotify.timer) {
|
|
217
|
+
clearTimeout(pendingNotify.timer);
|
|
218
|
+
pendingNotify = { reasons: [], timer: null };
|
|
219
|
+
}
|
|
220
|
+
if (pollTimer) {
|
|
221
|
+
clearInterval(pollTimer);
|
|
222
|
+
pollTimer = null;
|
|
223
|
+
}
|
|
224
|
+
for (const w of watchers) {
|
|
225
|
+
try {
|
|
226
|
+
w.close();
|
|
227
|
+
} catch {
|
|
228
|
+
// best effort
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
tick: (reason) => schedule(reason),
|
|
233
|
+
/** Install a user-facing notifier that gets coalesced rapid-fire
|
|
234
|
+
* changes into a single message. Call once from the consumer. */
|
|
235
|
+
setNotifyHandler: (handler) => {
|
|
236
|
+
notifyHandler = handler;
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|