pi-doc-injector 0.1.0 → 0.1.1

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.md CHANGED
@@ -57,19 +57,23 @@ keywords:
57
57
 
58
58
  ## Configuration
59
59
 
60
- Create `.pi/doc-injector.json` to customize behavior:
60
+ Create `.pi/doc-injector.json` in your project root to customize behavior:
61
61
 
62
62
  ```json
63
63
  {
64
64
  "docsPath": "./docs",
65
- "matchThreshold": 2
65
+ "matchThreshold": 2,
66
+ "contextThreshold": 80,
67
+ "recursive": true
66
68
  }
67
69
  ```
68
70
 
69
71
  | Option | Default | Description |
70
72
  |--------|---------|-------------|
71
- | `docsPath` | `./docs` | Path to your documentation folder |
72
- | `matchThreshold` | `2` | Minimum keyword matches before injecting |
73
+ | `docsPath` | `"./docs"` | Path to docs folder (relative to project root) |
74
+ | `matchThreshold` | `2` | Minimum keyword matches required to inject a doc |
75
+ | `contextThreshold` | `80` | Skip injection when context usage exceeds this % (0–100) |
76
+ | `recursive` | `true` | Scan docs subdirectories recursively |
73
77
 
74
78
  ### Keyword Matching
75
79
 
@@ -81,13 +85,22 @@ Injection is also skipped if the current context usage exceeds 80% of the token
81
85
 
82
86
  | Command | Description |
83
87
  |---------|-------------|
84
- | `/doc-inject on` | Enable auto-injection |
85
- | `/doc-inject off` | Disable auto-injection |
86
- | `/doc-inject toggle` | Toggle on/off |
87
- | `/doc-inject status` | Show injector status, doc count, keyword count |
88
- | `/doc-inject list` | List all registered documents |
89
- | `/doc-inject reset` | Reset injection state (allows re-matching docs) |
90
- | `/doc-reload` | Re-scan docs folder |
88
+ | `/doc-inject on` | Enable doc injection |
89
+ | `/doc-inject off` | Disable doc injection |
90
+ | `/doc-inject toggle` | Toggle doc injection on/off |
91
+ | `/doc-inject list` | List all registered docs and their injection status |
92
+ | `/doc-inject reset` | Reset all injected flags (docs become re-injectable) |
93
+ | `/doc-inject status` | Show current injection status and config |
94
+ | `/doc-reload` | Re-scan docs folder and rebuild registry |
95
+
96
+ ## Injection Lifecycle
97
+
98
+ The extension uses a per-session injection model:
99
+
100
+ - On `session_start`, the registry is rebuilt from scratch, resetting all `injected` flags.
101
+ - Within a session, once a document is injected, it won't be re-injected automatically.
102
+ - Use `/doc-inject reset` to manually reset all flags and allow docs to be injected again.
103
+ - Use `/doc-inject list` to see which docs have been injected (✅) and which are pending (⬜).
91
104
 
92
105
  ## Development
93
106
 
package/commands.ts CHANGED
@@ -4,38 +4,40 @@
4
4
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
5
  import type { DocRegistry } from "./registry";
6
6
 
7
- export function registerCommands(
8
- pi: ExtensionAPI,
9
- getRegistry: () => DocRegistry | null,
10
- getEnabled: () => boolean,
11
- setEnabled: (v: boolean) => void,
12
- ): void {
13
- const cmd = (name: string, desc: string, handler: (args: string, ctx: ExtensionContext) => void) => {
7
+ export interface CommandDeps {
8
+ getRegistry: () => DocRegistry | null;
9
+ getEnabled: () => boolean;
10
+ setEnabled: (v: boolean) => void;
11
+ reloadRegistry: () => Promise<number>;
12
+ }
13
+
14
+ export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
15
+ const cmd = (name: string, desc: string, handler: (args: string, ctx: ExtensionContext) => Promise<void>) => {
14
16
  pi.registerCommand(name, { description: desc, handler });
15
17
  };
16
18
 
17
- cmd("doc-inject", "Doc injector: on|off|toggle|list|reset|status", (args, ctx) => {
19
+ cmd("doc-inject", "Doc injector: on|off|toggle|list|reset|status", async (args, ctx) => {
18
20
  const a = args.trim().toLowerCase();
19
21
  if (a === "on") {
20
- setEnabled(true);
21
- ctx.ui.notify("📄 Doc injection enabled", "success");
22
+ deps.setEnabled(true);
23
+ ctx.ui.notify("📄 Doc injection enabled", "info");
22
24
  } else if (a === "off") {
23
- setEnabled(false);
25
+ deps.setEnabled(false);
24
26
  ctx.ui.notify("📄 Doc injection disabled", "warning");
25
27
  } else if (a === "toggle") {
26
- const next = !getEnabled();
27
- setEnabled(next);
28
+ const next = !deps.getEnabled();
29
+ deps.setEnabled(next);
28
30
  ctx.ui.notify(`📄 Doc injection ${next ? "enabled" : "disabled"}`, "info");
29
31
  } else if (a === "reset") {
30
- const reg = getRegistry();
32
+ const reg = deps.getRegistry();
31
33
  if (reg) {
32
34
  reg.reset();
33
- ctx.ui.notify("📄 Injection state reset", "success");
35
+ ctx.ui.notify("📄 Injection state reset", "info");
34
36
  } else {
35
37
  ctx.ui.notify("📄 No registry loaded", "warning");
36
38
  }
37
39
  } else if (a === "list") {
38
- const reg = getRegistry();
40
+ const reg = deps.getRegistry();
39
41
  if (!reg) {
40
42
  ctx.ui.notify("📄 No docs loaded", "warning");
41
43
  return;
@@ -47,12 +49,12 @@ export function registerCommands(
47
49
  }
48
50
  const lines = entries.map((e) => {
49
51
  const status = e.injected ? "✅" : "⬜";
50
- return `${status} ${e.fileName}: "${e.title}" — keywords: [${e.keywords.join(", ")}]`;
52
+ return `${status} ${e.relativePath}: "${e.title}" — keywords: [${e.keywords.join(", ")}]`;
51
53
  });
52
54
  ctx.ui.notify(`📄 Registered docs:\n${lines.join("\n")}`, "info");
53
55
  } else {
54
56
  // status (default)
55
- const reg = getRegistry();
57
+ const reg = deps.getRegistry();
56
58
  if (!reg) {
57
59
  ctx.ui.notify("📄 Status: No registry loaded", "warning");
58
60
  return;
@@ -62,7 +64,7 @@ export function registerCommands(
62
64
  const kwCount = entries.reduce((sum, e) => sum + e.keywords.length, 0);
63
65
  ctx.ui.notify(
64
66
  `📄 Doc Injector Status:\n` +
65
- ` Enabled: ${getEnabled() ? "✅" : "❌"}\n` +
67
+ ` Enabled: ${deps.getEnabled() ? "✅" : "❌"}\n` +
66
68
  ` Docs: ${entries.length}\n` +
67
69
  ` Keywords: ${kwCount}\n` +
68
70
  ` Injected: ${injected}`,
@@ -71,21 +73,12 @@ export function registerCommands(
71
73
  }
72
74
  });
73
75
 
74
- cmd("doc-reload", "Re-scan docs folder and rebuild registry", (_args, ctx) => {
75
- const reg = getRegistry();
76
- if (!reg) {
77
- ctx.ui.notify("📄 No registry to reload", "warning");
78
- return;
79
- }
80
- // We can't call rebuild() async from a command handler easily,
81
- // so we notify and trigger via the event system
82
- ctx.ui.notify("📄 Triggering docs reload…", "info");
83
- // Trigger a resources_discover-like reload by rebuilding directly
84
- reg.rebuild().then(() => {
85
- const count = reg.getEntries().length;
86
- ctx.ui.notify(`📄 Reloaded: ${count} documents found`, "success");
87
- }).catch((err) => {
76
+ cmd("doc-reload", "Re-scan docs folder and rebuild registry", async (_args, ctx) => {
77
+ try {
78
+ const count = await deps.reloadRegistry();
79
+ ctx.ui.notify(`📄 Reloaded: ${count} documents found`, "info");
80
+ } catch (err) {
88
81
  ctx.ui.notify(`📄 Reload failed: ${err instanceof Error ? err.message : String(err)}`, "error");
89
- });
82
+ }
90
83
  });
91
84
  }
package/config.ts CHANGED
@@ -21,9 +21,25 @@ export function loadConfig(cwd: string): DocInjectorConfig {
21
21
  const raw = readFileSync(configPath, "utf-8");
22
22
  const parsed = JSON.parse(raw) as Partial<DocInjectorConfig>;
23
23
 
24
+ // Clamp contextThreshold to 0-100 range
25
+ let contextThreshold = parsed.contextThreshold ?? DEFAULT_CONFIG.contextThreshold;
26
+ if (typeof contextThreshold === "number" && (contextThreshold < 0 || contextThreshold > 100)) {
27
+ console.warn(`[doc-injector] contextThreshold must be 0-100, got ${contextThreshold}. Clamping.`);
28
+ contextThreshold = Math.max(0, Math.min(100, contextThreshold));
29
+ }
30
+
31
+ // Clamp matchThreshold to positive integers
32
+ let matchThreshold = parsed.matchThreshold ?? DEFAULT_CONFIG.matchThreshold;
33
+ if (typeof matchThreshold === "number" && matchThreshold < 1) {
34
+ console.warn(`[doc-injector] matchThreshold must be >= 1, got ${matchThreshold}. Using 1.`);
35
+ matchThreshold = 1;
36
+ }
37
+
24
38
  return {
25
39
  docsPath: parsed.docsPath ?? DEFAULT_CONFIG.docsPath,
26
- matchThreshold: parsed.matchThreshold ?? DEFAULT_CONFIG.matchThreshold,
40
+ matchThreshold,
41
+ contextThreshold,
42
+ recursive: parsed.recursive ?? DEFAULT_CONFIG.recursive,
27
43
  };
28
44
  } catch (err) {
29
45
  console.warn(
@@ -0,0 +1,80 @@
1
+ ---
2
+ title: "Publishing Workflow"
3
+ keywords: [publish, release, npm, version, tag, semantic versioning]
4
+ ---
5
+
6
+ # Publishing Workflow
7
+
8
+ ## Versioning
9
+
10
+ We follow [Semantic Versioning](https://semver.org/):
11
+ - **MAJOR** — incompatible API changes
12
+ - **MINOR** — backwards-compatible functionality additions
13
+ - **PATCH** — backwards-compatible bug fixes
14
+
15
+ ## Publishing a New Version
16
+
17
+ ### 1. Bump the version
18
+
19
+ ```bash
20
+ npm version patch # 0.1.1 → 0.1.2
21
+ npm version minor # 0.1.1 → 0.2.0
22
+ npm version major # 0.1.1 → 1.0.0
23
+ ```
24
+
25
+ This updates `package.json` and creates a local tag.
26
+
27
+ ### 2. Push the tag to trigger the workflow
28
+
29
+ ```bash
30
+ git push origin v0.1.1
31
+ ```
32
+
33
+ Or push all tags:
34
+
35
+ ```bash
36
+ git push origin --tags
37
+ ```
38
+
39
+ **Important:** The publish workflow triggers on tags pushed to remote, not just locally created tags. The tag must match the pattern `v*` (e.g., `v0.1.1`, `v0.2.0`).
40
+
41
+ ### 3. GitHub Actions Publish workflow
42
+
43
+ Once the tag is pushed, the `Publish` workflow automatically:
44
+ - Runs tests
45
+ - Publishes to npm registry
46
+
47
+ Monitor the workflow at: `https://github.com/lmn451/pi-docs/actions`
48
+
49
+ ## Verify the Publish
50
+
51
+ Check if the package was published:
52
+
53
+ ```bash
54
+ npm view pi-doc-injector
55
+ ```
56
+
57
+ ## Manual Publish
58
+
59
+ If needed, you can publish manually:
60
+
61
+ ```bash
62
+ npm publish
63
+ ```
64
+
65
+ ## Setup
66
+
67
+ Ensure your npm token is configured as a GitHub secret:
68
+ - Go to repository Settings → Secrets and variables → Actions
69
+ - Add a new secret named `NPM_TOKEN` with your npm access token
70
+
71
+ ## Troubleshooting
72
+
73
+ **Workflow didn't run?**
74
+ - Verify the tag exists remotely: `git ls-remote origin refs/tags/v0.1.1`
75
+ - Check that the tag matches the pattern `v*`
76
+ - Check GitHub Actions runs at: `https://github.com/lmn451/pi-docs/actions`
77
+
78
+ **npm publish failed?**
79
+ - Ensure `NPM_TOKEN` secret is set
80
+ - Verify the version hasn't already been published
package/index.ts CHANGED
@@ -3,6 +3,32 @@
3
3
  *
4
4
  * Automatically injects relevant project documentation into the LLM context
5
5
  * by monitoring streaming output for keyword matches.
6
+ *
7
+ * ## Streaming Model
8
+ *
9
+ * This extension relies on Pi's streaming event contract:
10
+ * - `message_update`: Fires with the FULL accumulated assistant content on each
11
+ * streaming chunk. The extension replaces (not appends to) its text buffer
12
+ * on each update.
13
+ * - `message_end`: Fires once when the assistant's response is complete.
14
+ * The extension finalizes matches and notifies the user.
15
+ * - `before_agent_start`: Fires before the next agent turn. The extension
16
+ * injects matched docs into the system prompt, then marks them as injected.
17
+ *
18
+ * ## Injection Lifecycle
19
+ *
20
+ * The `injected` flag is per-session: when `session_start` fires, the registry
21
+ * is recreated from scratch (via initRegistry), resetting all flags. Within a
22
+ * session, once a doc is injected, it won't be re-injected unless the user
23
+ * manually runs `/doc-inject reset`.
24
+ *
25
+ * ## Race Condition Note
26
+ *
27
+ * If `resources_discover` (rebuild) fires while `before_agent_start` is running,
28
+ * `registry.entries` gets replaced. The `matchedEntries` array would hold stale
29
+ * references. The current code is safe because `pendingMatches` (a Map by filePath)
30
+ * is cleared after injection, and `markInjected()` operates on the registry's
31
+ * current entries, not the stale array.
6
32
  */
7
33
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
34
  import { resolve } from "node:path";
@@ -31,7 +57,7 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
31
57
  const initRegistry = async (cwd: string) => {
32
58
  config = loadConfig(cwd);
33
59
  const docsPath = resolve(cwd, config.docsPath);
34
- registry = await DocRegistry.create(docsPath);
60
+ registry = await DocRegistry.create(docsPath, config.recursive);
35
61
  const count = registry.getEntries().length;
36
62
  if (count > 0) {
37
63
  console.log(`[doc-injector] Loaded ${count} documents from ${docsPath}`);
@@ -53,25 +79,34 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
53
79
  await initRegistry(ctx.cwd);
54
80
  });
55
81
 
82
+ const reloadRegistry = async (): Promise<number> => {
83
+ if (!registry) throw new Error("No registry loaded");
84
+ await registry.rebuild();
85
+ const count = registry.getEntries().length;
86
+ console.log(`[doc-injector] Reloaded: ${count} documents`);
87
+ return count;
88
+ };
89
+
56
90
  // ---- Event: resources_discover (reload) ----
57
- pi.on("resources_discover", async (_event, ctx) => {
58
- if (registry) {
59
- await registry.rebuild();
60
- const count = registry.getEntries().length;
61
- console.log(`[doc-injector] Reloaded: ${count} documents`);
62
- }
91
+ pi.on("resources_discover", async (_event, _ctx) => {
92
+ await reloadRegistry();
63
93
  });
64
94
 
65
95
  // ---- Event: message_update (streaming detection) ----
96
+ // NOTE: Pi's message_update event sends the full accumulated content of the
97
+ // assistant message on each update, not just the delta. We therefore REPLACE
98
+ // (not append to) the text buffer on each event, ensuring we always match
99
+ // against the complete message text.
66
100
  pi.on("message_update", async (event, _ctx) => {
67
101
  if (!enabled || !registry) return;
68
102
 
69
103
  // Only process assistant messages
70
- const msg = event.message as Record<string, unknown> | undefined;
71
- if (!msg || msg.role !== "assistant") return;
104
+ const msg = event.message;
105
+ if (msg.role !== "assistant") return;
72
106
 
73
107
  // Replace buffer with full message text (message_update contains full content)
74
- textBuffer = extractText(msg.content);
108
+ const content = (msg as unknown as { content: unknown }).content;
109
+ textBuffer = extractText(content);
75
110
  if (!textBuffer) return;
76
111
 
77
112
  // Run matcher
@@ -90,8 +125,8 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
90
125
  pi.on("message_end", async (event, ctx) => {
91
126
  if (!enabled || !registry) return;
92
127
 
93
- const msg = event.message as Record<string, unknown> | undefined;
94
- if (!msg || msg.role !== "assistant") return;
128
+ const msg = event.message;
129
+ if (msg.role !== "assistant") return;
95
130
 
96
131
  // Clear buffer
97
132
  textBuffer = "";
@@ -122,10 +157,12 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
122
157
  return;
123
158
  }
124
159
 
125
- // Check context budget before injecting
160
+ // Skip injection if context usage exceeds the configured threshold
161
+ // (default: 80%). This prevents doc injection from pushing the context
162
+ // past the model's limit.
126
163
  const usage = _ctx.getContextUsage();
127
- if (usage && usage.tokens > 0 && usage.percentage && usage.percentage > 80) {
128
- console.warn("[doc-injector] Skipping injection: context usage > 80%");
164
+ if (usage && usage.tokens && usage.tokens > 0 && usage.percent && usage.percent > config.contextThreshold) {
165
+ console.warn(`[doc-injector] Skipping injection: context usage > ${config.contextThreshold}%`);
129
166
  pendingMatches.clear();
130
167
  return;
131
168
  }
@@ -133,9 +170,7 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
133
170
  const append = buildSystemPromptAppend(matchedEntries, pendingMatches);
134
171
 
135
172
  // Mark as injected only after confirming injection will happen
136
- for (const entry of matchedEntries) {
137
- entry.injected = true;
138
- }
173
+ registry.markInjected(matchedEntries.map((e) => e.filePath));
139
174
  pendingMatches.clear();
140
175
 
141
176
  return {
@@ -144,5 +179,10 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
144
179
  });
145
180
 
146
181
  // ---- Commands ----
147
- registerCommands(pi, getRegistry, getEnabled, setEnabled);
182
+ registerCommands(pi, {
183
+ getRegistry,
184
+ getEnabled,
185
+ setEnabled,
186
+ reloadRegistry,
187
+ });
148
188
  }
package/injector.ts CHANGED
@@ -5,6 +5,14 @@
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
6
  import type { DocEntry } from "./types";
7
7
 
8
+ /**
9
+ * Interface for the UI notification capability needed by the injector.
10
+ * Matches Pi's ExtensionContext['ui'] notify signature.
11
+ */
12
+ export interface NotifyCapability {
13
+ notify: (msg: string, type?: "info" | "warning" | "error") => void;
14
+ }
15
+
8
16
  /**
9
17
  * Build a system prompt append string from matched documents.
10
18
  */
@@ -23,7 +31,7 @@ export function buildSystemPromptAppend(
23
31
  for (const entry of entries) {
24
32
  const keywords = matchedKeywords.get(entry.filePath) ?? [];
25
33
  sections.push(`### ${entry.title}`);
26
- sections.push(`Source: \`${entry.fileName}\``);
34
+ sections.push(`Source: \`${entry.relativePath}\``);
27
35
  if (keywords.length > 0) {
28
36
  sections.push(`Matched keywords: ${keywords.join(", ")}`);
29
37
  }
@@ -39,13 +47,13 @@ export function buildSystemPromptAppend(
39
47
  * Notify the user via TUI when documents are injected.
40
48
  */
41
49
  export function notifyInjection(
42
- ui: { notify: (msg: string, type?: "info" | "warning" | "error" | "success") => void },
50
+ ui: NotifyCapability,
43
51
  entries: DocEntry[],
44
52
  matchedKeywords: Map<string, string[]>,
45
53
  ): void {
46
54
  for (const entry of entries) {
47
55
  const keywords = matchedKeywords.get(entry.filePath) ?? [];
48
56
  const kwList = keywords.join(", ");
49
- ui.notify(`📄 Injected: ${entry.fileName} (matched: ${kwList})`, "info");
57
+ ui.notify(`📄 Injected: ${entry.relativePath} (matched: ${kwList})`, "info");
50
58
  }
51
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-doc-injector",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Auto-inject relevant project documentation into Pi's LLM context based on keyword matching",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -16,12 +16,31 @@
16
16
  "test": "bun test",
17
17
  "test:watch": "bun test --watch"
18
18
  },
19
- "keywords": ["pi-package", "pi-extension", "docs", "context", "llm"],
19
+ "keywords": [
20
+ "pi-package",
21
+ "pi-extension",
22
+ "docs",
23
+ "context",
24
+ "llm"
25
+ ],
20
26
  "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/lmn451/pi-docs"
30
+ },
21
31
  "pi": {
22
- "extensions": ["./index.ts"]
32
+ "extensions": [
33
+ "./index.ts"
34
+ ]
23
35
  },
24
36
  "peerDependencies": {
25
37
  "@mariozechner/pi-coding-agent": "*"
26
- }
38
+ },
39
+ "devDependencies": {
40
+ "@types/bun": "^1.3.13"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
27
46
  }
package/registry.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Document Registry — scans a docs folder, parses frontmatter, maintains index.
3
3
  */
4
- import { readdirSync, readFileSync } from "node:fs";
5
- import { basename, join, resolve } from "node:path";
4
+ import { type Dirent, readdirSync, readFileSync } from "node:fs";
5
+ import { basename, join, relative, resolve } from "node:path";
6
6
  import type { DocEntry } from "./types";
7
7
 
8
8
  /**
@@ -65,14 +65,16 @@ export function parseFrontmatter(
65
65
  export class DocRegistry {
66
66
  private entries: DocEntry[] = [];
67
67
  private docsPath: string;
68
+ private recursive: boolean;
68
69
 
69
- private constructor(docsPath: string) {
70
+ private constructor(docsPath: string, recursive: boolean = true) {
70
71
  this.docsPath = docsPath;
72
+ this.recursive = recursive;
71
73
  }
72
74
 
73
75
  /** Create a registry by scanning the docs folder. */
74
- static async create(docsPath: string): Promise<DocRegistry> {
75
- const registry = new DocRegistry(docsPath);
76
+ static async create(docsPath: string, recursive: boolean = true): Promise<DocRegistry> {
77
+ const registry = new DocRegistry(docsPath, recursive);
76
78
  await registry.rebuild();
77
79
  return registry;
78
80
  }
@@ -86,28 +88,30 @@ export class DocRegistry {
86
88
  }
87
89
 
88
90
  try {
89
- const files = readdirSync(resolved).filter((f) => f.endsWith(".md"));
91
+ const scanResults = this.recursive
92
+ ? this.scanRecursive(resolved)
93
+ : this.scanFlat(resolved);
90
94
 
91
95
  const newEntries: DocEntry[] = [];
92
- for (const file of files) {
93
- const filePath = join(resolved, file);
96
+ for (const { filePath, relativePath, fileName } of scanResults) {
94
97
  try {
95
98
  const raw = readFileSync(filePath, "utf-8");
96
99
  const parsed = parseFrontmatter(raw);
97
100
  if (!parsed) {
98
- console.warn(`[doc-injector] Skipping ${file}: no valid frontmatter with keywords`);
101
+ console.warn(`[doc-injector] Skipping ${relativePath}: no valid frontmatter with keywords`);
99
102
  continue;
100
103
  }
101
104
  newEntries.push({
102
105
  filePath,
103
- fileName: file,
106
+ fileName,
107
+ relativePath,
104
108
  title: parsed.title,
105
109
  keywords: parsed.keywords,
106
110
  content: raw,
107
111
  injected: preserved.get(filePath) ?? false,
108
112
  });
109
113
  } catch (err) {
110
- console.warn(`[doc-injector] Error reading ${file}:`, err);
114
+ console.warn(`[doc-injector] Error reading ${relativePath}:`, err);
111
115
  }
112
116
  }
113
117
 
@@ -118,7 +122,48 @@ export class DocRegistry {
118
122
  }
119
123
  }
120
124
 
121
- /** Get all registered entries. */
125
+ /** Scan top-level .md files only (non-recursive). */
126
+ private scanFlat(dir: string): Array<{ filePath: string; relativePath: string; fileName: string }> {
127
+ return readdirSync(dir)
128
+ .filter((f) => f.endsWith(".md"))
129
+ .map((f) => ({
130
+ filePath: join(dir, f),
131
+ relativePath: f,
132
+ fileName: f,
133
+ }));
134
+ }
135
+
136
+ /** Scan .md files recursively, including subdirectories. */
137
+ private scanRecursive(dir: string): Array<{ filePath: string; relativePath: string; fileName: string }> {
138
+ const results: Array<{ filePath: string; relativePath: string; fileName: string }> = [];
139
+ const dirents = readdirSync(dir, { recursive: true, withFileTypes: true }) as Dirent[];
140
+
141
+ for (const dirent of dirents) {
142
+ if (!dirent.isFile() || !dirent.name.endsWith(".md")) continue;
143
+
144
+ // Build relative path from the directory tree
145
+ const parentPath = (dirent as Dirent & { path?: string }).path ?? "";
146
+ const relPath = parentPath
147
+ ? relative(dir, join(parentPath, dirent.name))
148
+ : dirent.name;
149
+
150
+ results.push({
151
+ filePath: join(dir, relPath),
152
+ relativePath: relPath,
153
+ fileName: dirent.name,
154
+ });
155
+ }
156
+
157
+ return results;
158
+ }
159
+
160
+ /**
161
+ * Get all registered entries.
162
+ *
163
+ * NOTE: Returned DocEntry objects share references with the internal registry.
164
+ * Mutating `injected` on returned objects will affect the registry's internal state.
165
+ * Prefer using markInjected() / markAllNotInjected() for explicit state changes.
166
+ */
122
167
  getEntries(): DocEntry[] {
123
168
  return [...this.entries];
124
169
  }
@@ -128,10 +173,25 @@ export class DocRegistry {
128
173
  return this.entries.filter((e) => !e.injected);
129
174
  }
130
175
 
131
- /** Reset all injected flags. */
132
- reset(): void {
176
+ /** Mark entries matching the given file paths as injected. */
177
+ markInjected(filePaths: string[]): void {
178
+ const pathSet = new Set(filePaths);
179
+ for (const e of this.entries) {
180
+ if (pathSet.has(e.filePath)) {
181
+ e.injected = true;
182
+ }
183
+ }
184
+ }
185
+
186
+ /** Reset all entries to not-injected state. */
187
+ markAllNotInjected(): void {
133
188
  for (const e of this.entries) {
134
189
  e.injected = false;
135
190
  }
136
191
  }
192
+
193
+ /** @deprecated Use markAllNotInjected() for clarity. */
194
+ reset(): void {
195
+ this.markAllNotInjected();
196
+ }
137
197
  }
package/types.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  export interface DocEntry {
7
7
  filePath: string;
8
8
  fileName: string;
9
+ relativePath: string;
9
10
  title: string;
10
11
  keywords: string[];
11
12
  content: string;
@@ -30,12 +31,16 @@ export interface MatchResult {
30
31
  export interface DocInjectorConfig {
31
32
  docsPath: string;
32
33
  matchThreshold: number;
34
+ contextThreshold: number;
35
+ recursive: boolean;
33
36
  }
34
37
 
35
38
  /** Default configuration values. */
36
39
  export const DEFAULT_CONFIG: DocInjectorConfig = {
37
40
  docsPath: "./docs",
38
41
  matchThreshold: 2,
42
+ contextThreshold: 80,
43
+ recursive: true,
39
44
  };
40
45
 
41
46
  /** Default matcher options derived from config. */
@@ -1,55 +0,0 @@
1
- ---
2
- title: "Publishing Workflow"
3
- keywords: [publish, release, deploy, version, npm, changelog, tag, semantic versioning, production, staging]
4
- ---
5
-
6
- # Publishing Workflow
7
-
8
- ## Overview
9
-
10
- This document covers the process for publishing releases and deploying to production.
11
-
12
- ## Versioning
13
-
14
- We follow [Semantic Versioning](https://semver.org/):
15
- - **MAJOR** — incompatible API changes
16
- - **MINOR** — backwards-compatible functionality additions
17
- - **PATCH** — backwards-compatible bug fixes
18
-
19
- ## Release Process
20
-
21
- 1. Update `CHANGELOG.md` with all changes since last release
22
- 2. Bump version in `package.json`
23
- 3. Create a git tag: `git tag -a v1.2.3 -m "Release v1.2.3"`
24
- 4. Push tags: `git push origin --tags`
25
- 5. CI will automatically build and publish
26
-
27
- ## Publishing to npm
28
-
29
- ```bash
30
- # Dry run first
31
- npm publish --dry-run
32
-
33
- # Actual publish
34
- npm publish
35
- ```
36
-
37
- ## Deployment
38
-
39
- ### Staging
40
- - Deployed automatically on merge to `main`
41
- - URL: staging.example.com
42
- - Used for QA and integration testing
43
-
44
- ### Production
45
- - Deployed from release tags only
46
- - URL: app.example.com
47
- - Requires manual approval in CI pipeline
48
-
49
- ## Rollback Procedure
50
-
51
- If a release causes issues:
52
- 1. Identify the problematic version
53
- 2. Revert the git tag
54
- 3. Re-deploy the previous version
55
- 4. Post-mortem within 48 hours