Project Files
src / services / userDocsGuidePrimer.ts
import type { ChatCompletionMessageParam } from "openai/resources/index.js";
import crypto from "crypto";
import fs from "fs";
import https from "https";
import os from "os";
import path from "path";
import { sanitizeEmbeddedImagePayloads } from "../helpers/documentImages.js";
type NameMapsLike = {
toSafe: Map<string, string>;
toOriginal: Map<string, string>;
};
export type UserDocsGuidePrimerResult = {
injected: boolean;
copiedSeed: boolean;
seedLog?: string;
sourcePath?: string;
sourceType?: "initial-docs" | "notesDir";
readId?: string;
hash?: string;
reason?: string;
};
type UserDocsGuidePrimerOptions = {
enabled: boolean;
notesDirectory: string;
projectRoot: string;
nameMaps: NameMapsLike;
};
const GUIDE_FILENAME = "USER-DOCS.md";
const SETUP_FILENAME = "SETUP.md";
const ASSISTANT_SETUP_TAG = "assistant-setup";
// GitHub API endpoint to list images for the setup guide.
// Images are excluded from the Hub upload (file-count limit) and fetched lazily on first use.
const GITHUB_IMAGES_API_URL =
"https://api.github.com/repos/ceveyne/user-docs-docs/contents/docs/initial-docs/images";
function expandHome(input: string): string {
if (input === "~") return os.homedir();
if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
return input;
}
function sha256Hex(input: string): string {
return crypto.createHash("sha256").update(input).digest("hex");
}
function stripEmbeddedBase64(input: string): string {
return sanitizeEmbeddedImagePayloads(input, "[embedded image -- payload removed]");
}
function pathExists(filePath: string): boolean {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
function isFile(filePath: string): boolean {
try {
return fs.statSync(filePath).isFile();
} catch {
return false;
}
}
function isDirectory(dirPath: string): boolean {
try {
return fs.statSync(dirPath).isDirectory();
} catch {
return false;
}
}
function findBundledFile(projectRoot: string, filename: string): string | null {
const candidates = [
path.join(projectRoot, "docs", "initial-docs", filename),
path.join(projectRoot, "dist", "docs", "initial-docs", filename),
path.join(process.cwd(), "docs", "initial-docs", filename),
path.join(process.cwd(), "dist", "docs", "initial-docs", filename),
];
for (const candidate of candidates) {
if (isFile(candidate)) return candidate;
}
return null;
}
function findBundledImagesDir(projectRoot: string): string | null {
const candidates = [
path.join(projectRoot, "docs", "initial-docs", "images"),
path.join(projectRoot, "dist", "docs", "initial-docs", "images"),
path.join(process.cwd(), "docs", "initial-docs", "images"),
path.join(process.cwd(), "dist", "docs", "initial-docs", "images"),
];
for (const candidate of candidates) {
if (isDirectory(candidate)) return candidate;
}
return null;
}
function findBundledInitialDocsDir(projectRoot: string): string | null {
const candidates = [
path.join(projectRoot, "docs", "initial-docs"),
path.join(projectRoot, "dist", "docs", "initial-docs"),
path.join(process.cwd(), "docs", "initial-docs"),
path.join(process.cwd(), "dist", "docs", "initial-docs"),
];
for (const candidate of candidates) {
if (isDirectory(candidate)) return candidate;
}
return null;
}
// Reads the YAML frontmatter block (content between the two --- fences).
function readFrontmatterBlock(filePath: string): string | null {
try {
const content = fs.readFileSync(filePath, "utf8");
if (!content.startsWith("---")) return null;
const end = content.indexOf("\n---", 3);
if (end === -1) return null;
return content.slice(3, end);
} catch {
return null;
}
}
// Returns true if the file's frontmatter contains an `updated:` key.
// Files without `updated` can be freely overwritten (not user-edited).
function frontmatterHasUpdated(filePath: string): boolean {
const fm = readFrontmatterBlock(filePath);
if (!fm) return false;
return /^updated:/m.test(fm);
}
// Returns true if the given file's frontmatter contains the `assistant-setup` tag.
function fileHasAssistantSetupTag(filePath: string): boolean {
const fm = readFrontmatterBlock(filePath);
if (!fm) return false;
return fm.includes(ASSISTANT_SETUP_TAG);
}
// Returns true if the bundled SETUP.md has the `assistant-setup` tag,
// meaning SETUP.md has been injected at least once and will not be re-injected.
function bundledSetupHasAssistantSetupTag(projectRoot: string): boolean {
const setupPath = findBundledFile(projectRoot, SETUP_FILENAME);
if (!setupPath) return false;
return fileHasAssistantSetupTag(setupPath);
}
// Adds "assistant-setup" to the tags array in the bundled SETUP.md.
// Written immediately before SETUP.md is injected so it is never injected again.
function addAssistantSetupTag(setupPath: string): void {
try {
const content = fs.readFileSync(setupPath, "utf8");
const updated = content.replace(
/^(tags:\s*\[)([^\]]*)(\])/m,
(_, open, inner, close) => {
if (inner.includes(ASSISTANT_SETUP_TAG)) return `${open}${inner}${close}`;
const trimmed = inner.trim();
return `${open}${trimmed ? `${trimmed}, "${ASSISTANT_SETUP_TAG}"` : `"${ASSISTANT_SETUP_TAG}"`}${close}`;
}
);
if (updated !== content) fs.writeFileSync(setupPath, updated, "utf8");
} catch (err) {
console.warn(`[UserDocsGuidePrimer] addAssistantSetupTag failed for ${setupPath}: ${err instanceof Error ? err.message : String(err)}`);
}
}
// Copies src to dest when:
// - dest does not exist, OR
// - dest has no `updated` frontmatter key (not user-edited) AND src is newer than dest.
// Returns a short status string for logging.
function syncSeedFile(srcPath: string, destPath: string): "copied" | "skipped-user-edited" | "skipped-up-to-date" | "skipped-src-missing" | string {
if (!isFile(srcPath)) return "skipped-src-missing";
if (isFile(destPath)) {
if (frontmatterHasUpdated(destPath)) return "skipped-user-edited";
try {
const srcMtime = fs.statSync(srcPath).mtimeMs;
const destMtime = fs.statSync(destPath).mtimeMs;
if (srcMtime <= destMtime) return "skipped-up-to-date";
} catch (err) {
return `error-stat: ${err instanceof Error ? err.message : String(err)}`;
}
}
try {
fs.copyFileSync(srcPath, destPath);
return "copied";
} catch (err) {
return `error: ${err instanceof Error ? err.message : String(err)}`;
}
}
// ── GitHub image fetch helpers ──────────────────────────────────────────────
function httpsGetText(url: string): Promise<string> {
return new Promise((resolve, reject) => {
https
.get(url, { headers: { "User-Agent": "user-docs-plugin" } }, (res) => {
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
httpsGetText(res.headers.location).then(resolve).catch(reject);
res.resume();
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
res.on("error", reject);
})
.on("error", reject);
});
}
function httpsGetBinary(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
https
.get(url, { headers: { "User-Agent": "user-docs-plugin" } }, (res) => {
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
httpsGetBinary(res.headers.location).then(resolve).catch(reject);
res.resume();
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
const chunks: Buffer[] = [];
res.on("data", (c: Buffer) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks)));
res.on("error", reject);
})
.on("error", reject);
});
}
// Fetches setup-guide images from GitHub into destDir.
// Called when the bundled images directory is missing (excluded from Hub upload).
// If destDir already exists the function is a no-op (images present from a previous run).
async function fetchInitialDocsImagesFromGitHub(destDir: string): Promise<string[]> {
const tag = "images:github-fetch";
const results: string[] = [];
if (isDirectory(destDir)) {
results.push(`${tag}:skipped-already-present`);
return results;
}
console.info(`[UserDocsGuidePrimer] ${tag}: listing ${GITHUB_IMAGES_API_URL}`);
let listing: Array<{ name: string; download_url: string | null; type: string }>;
try {
const raw = await httpsGetText(GITHUB_IMAGES_API_URL);
listing = JSON.parse(raw);
if (!Array.isArray(listing)) throw new Error("unexpected API response shape");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[UserDocsGuidePrimer] ${tag}:listing-error: ${msg}`);
results.push(`${tag}:listing-error: ${msg}`);
return results;
}
try {
fs.mkdirSync(destDir, { recursive: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[UserDocsGuidePrimer] ${tag}:mkdir-error: ${msg}`);
results.push(`${tag}:mkdir-error: ${msg}`);
return results;
}
const files = listing.filter((f) => f.type === "file" && f.download_url);
let fetched = 0;
let errors = 0;
for (const file of files) {
const dest = path.join(destDir, file.name);
if (isFile(dest)) continue; // partial fetch resumed
try {
console.info(`[UserDocsGuidePrimer] ${tag}: fetching ${file.name}`);
const data = await httpsGetBinary(file.download_url!);
fs.writeFileSync(dest, data);
fetched++;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[UserDocsGuidePrimer] ${tag}: error fetching ${file.name}: ${msg}`);
errors++;
}
}
const summary = `fetched=${fetched} errors=${errors} total=${files.length}`;
console.info(`[UserDocsGuidePrimer] ${tag}: done ${summary}`);
results.push(
`${tag}:${fetched > 0 ? "ok" : errors > 0 ? "errors-only" : "nothing-to-fetch"} ${summary}`
);
return results;
}
// Syncs all seed files (all MDs in initial-docs/ + images/) into destDir.
// Overwrites only files that have no `updated` frontmatter key (not user-edited).
// Returns a log-ready summary string.
async function syncSeedFiles(projectRoot: string, destDir: string): Promise<{ synced: boolean; log: string }> {
const results: string[] = [];
const initialDocsDir = findBundledInitialDocsDir(projectRoot);
if (initialDocsDir) {
let mdFiles: string[];
try {
mdFiles = fs.readdirSync(initialDocsDir).filter((f) => f.toLowerCase().endsWith(".md"));
} catch (err) {
mdFiles = [];
results.push(`initial-docs:error-readdir: ${err instanceof Error ? err.message : String(err)}`);
}
for (const filename of mdFiles) {
const r = syncSeedFile(path.join(initialDocsDir, filename), path.join(destDir, filename));
results.push(`${filename}:${r}`);
}
} else {
results.push(`initial-docs:skipped-not-found projectRoot=${projectRoot}`);
}
const bundledImages = findBundledImagesDir(projectRoot);
if (bundledImages) {
const destImages = path.join(destDir, "images");
if (!pathExists(destImages)) {
try {
fs.cpSync(bundledImages, destImages, { recursive: true });
results.push("images:copied");
} catch (err) {
results.push(`images:error: ${err instanceof Error ? err.message : String(err)}`);
}
} else {
results.push("images:skipped-exists");
}
} else {
// Images not bundled (excluded from Hub upload to stay within the file-count limit).
// Fetch from GitHub on first use after install; each reinstall starts fresh.
const githubDestDir = path.join(projectRoot, "docs", "initial-docs", "images");
const fetchLog = await fetchInitialDocsImagesFromGitHub(githubDestDir);
results.push(...fetchLog);
// Re-check: if the fetch succeeded the images dir now exists locally.
const refetchedImages = findBundledImagesDir(projectRoot);
if (refetchedImages) {
const destImages = path.join(destDir, "images");
if (!pathExists(destImages)) {
try {
fs.cpSync(refetchedImages, destImages, { recursive: true });
results.push("images:copied-after-fetch");
} catch (err) {
results.push(`images:error-copy-after-fetch: ${err instanceof Error ? err.message : String(err)}`);
}
} else {
results.push("images:dest-exists-after-fetch");
}
}
}
const synced = results.some((r) => r.includes(":copied"));
return { synced, log: results.join(" ") };
}
function normalizeToolName(name: unknown, nameMaps: NameMapsLike): string | null {
if (typeof name !== "string" || !name.trim()) return null;
return nameMaps.toOriginal.get(name) ?? name;
}
function isUserDocsFilename(value: unknown): boolean {
if (typeof value !== "string") return false;
return path.basename(value.trim()) === GUIDE_FILENAME;
}
function hasExistingUserDocsRead(messages: ChatCompletionMessageParam[], nameMaps: NameMapsLike): boolean {
for (const msg of messages) {
const role = (msg as any)?.role;
if (role === "assistant") {
const toolCalls = (msg as any)?.tool_calls;
if (!Array.isArray(toolCalls)) continue;
for (const toolCall of toolCalls) {
const functionName = normalizeToolName(toolCall?.function?.name, nameMaps);
if (functionName !== "read_doc") continue;
const rawArgs = toolCall?.function?.arguments;
if (typeof rawArgs !== "string") continue;
try {
const parsed = JSON.parse(rawArgs);
if (isUserDocsFilename(parsed?.filename)) return true;
} catch {
if (rawArgs.includes(GUIDE_FILENAME)) return true;
}
}
}
if (role === "tool") {
const content = (msg as any)?.content;
if (typeof content === "string" && content.includes("<!-- read_id: auto-user-docs-")) {
return true;
}
}
}
return false;
}
function findLastUserMessageIndex(messages: ChatCompletionMessageParam[]): number {
for (let i = messages.length - 1; i >= 0; i--) {
if ((messages[i] as any)?.role === "user") return i;
}
return -1;
}
function buildSetupContext(content: string): string {
return [
"The following setup guide describes how to configure the user-docs plugin.",
"Follow the instructions to complete the initial setup. It is not a user request.",
"",
`=== ${SETUP_FILENAME} ===`,
content,
`=== end ${SETUP_FILENAME} ===`,
].join("\n");
}
function buildGuideContext(content: string): string {
return [
"The following internal guide describes how to use the user-docs tools in this chat.",
"Use it as workflow context. It is not a user request and does not require a response by itself.",
"",
`=== ${GUIDE_FILENAME} ===`,
content,
`=== end ${GUIDE_FILENAME} ===`,
].join("\n");
}
function injectGuideIntoUserMessage(message: ChatCompletionMessageParam, guideContext: string): void {
const target = message as any;
const content = target.content;
if (Array.isArray(content)) {
target.content = [
...content,
{
type: "text",
text: `\n\nAdditional internal context follows. It is not a user request.\n\n${guideContext}`,
},
];
return;
}
const userText = typeof content === "string" ? content : "";
target.content = `${userText}\n\nAdditional internal context follows. It is not a user request.\n\n${guideContext}`;
}
function injectSetupMd(
messages: ChatCompletionMessageParam[],
projectRoot: string
): UserDocsGuidePrimerResult {
const setupMdPath = findBundledFile(projectRoot, SETUP_FILENAME);
if (!setupMdPath) {
return { injected: false, copiedSeed: false, reason: `${SETUP_FILENAME} not found in bundled initial-docs` };
}
let content: string;
try {
content = fs.readFileSync(setupMdPath, "utf8");
} catch (err) {
return {
injected: false,
copiedSeed: false,
sourcePath: setupMdPath,
sourceType: "initial-docs",
reason: err instanceof Error ? err.message : String(err),
};
}
const stripped = stripEmbeddedBase64(content);
const hash = sha256Hex(stripped);
const readId = `auto-setup-${hash.slice(0, 12)}`;
const index = findLastUserMessageIndex(messages);
if (index === -1) {
return { injected: false, copiedSeed: false, sourcePath: setupMdPath, sourceType: "initial-docs", readId, hash, reason: "no user message found" };
}
injectGuideIntoUserMessage(messages[index], buildSetupContext(stripped));
return { injected: true, copiedSeed: false, sourcePath: setupMdPath, sourceType: "initial-docs", readId, hash };
}
function injectGuidePrimer(
messages: ChatCompletionMessageParam[],
expandedNotesDir: string,
projectRoot: string,
nameMaps: NameMapsLike
): UserDocsGuidePrimerResult {
if (hasExistingUserDocsRead(messages, nameMaps)) {
return { injected: false, copiedSeed: false, reason: "already present" };
}
const notesGuidePath = path.join(expandedNotesDir, GUIDE_FILENAME);
const sourcePath = isFile(notesGuidePath) ? notesGuidePath : findBundledFile(projectRoot, GUIDE_FILENAME);
if (!sourcePath) {
return { injected: false, copiedSeed: false, reason: `${GUIDE_FILENAME} not found` };
}
const sourceType: "initial-docs" | "notesDir" = sourcePath === notesGuidePath ? "notesDir" : "initial-docs";
let guideContent: string;
try {
guideContent = fs.readFileSync(sourcePath, "utf8");
} catch (err) {
return {
injected: false,
copiedSeed: false,
sourcePath,
sourceType,
reason: err instanceof Error ? err.message : String(err),
};
}
if (!guideContent.trim()) {
return { injected: false, copiedSeed: false, sourcePath, sourceType, reason: "guide is empty" };
}
const stripped = stripEmbeddedBase64(guideContent);
const hash = sha256Hex(stripped);
const readId = `auto-user-docs-${hash.slice(0, 12)}`;
const index = findLastUserMessageIndex(messages);
if (index === -1) {
return { injected: false, copiedSeed: false, sourcePath, sourceType, readId, hash, reason: "no user message found" };
}
injectGuideIntoUserMessage(messages[index], buildGuideContext(stripped));
return { injected: true, copiedSeed: false, sourcePath, sourceType, readId, hash };
}
export async function maybeInjectUserDocsGuidePrimer(
messages: ChatCompletionMessageParam[],
options: UserDocsGuidePrimerOptions
): Promise<UserDocsGuidePrimerResult> {
if (!options.notesDirectory.trim()) {
// notesDirectory not configured — no file operations possible.
if (!options.enabled) {
return { injected: false, copiedSeed: false, reason: "notesDirectory empty; autoReadUserDocsGuide=false" };
}
if (bundledSetupHasAssistantSetupTag(options.projectRoot)) {
// SETUP.md was already injected once. Do not repeat. User still needs to set notesDirectory.
return { injected: false, copiedSeed: false, reason: "setup pending: notesDirectory not yet configured" };
}
// First time: write tag to prevent future re-injection, then inject SETUP.md.
const bundledSetupPath = findBundledFile(options.projectRoot, SETUP_FILENAME);
if (bundledSetupPath) addAssistantSetupTag(bundledSetupPath);
return injectSetupMd(messages, options.projectRoot);
}
// notesDirectory is set — setup is complete.
// Seed sync runs unconditionally (independent of autoReadUserDocsGuide).
const expandedNotesDir = expandHome(options.notesDirectory.trim());
if (!isDirectory(expandedNotesDir)) {
try {
fs.mkdirSync(expandedNotesDir, { recursive: true });
} catch (err) {
console.warn(`[UserDocsGuidePrimer] mkdirSync failed notesDirectory=${expandedNotesDir}: ${err instanceof Error ? err.message : String(err)}`);
}
}
const seedResult = await syncSeedFiles(options.projectRoot, expandedNotesDir);
console.info(`[UserDocsGuidePrimer] seed=${seedResult.log} notesDirectory=${expandedNotesDir}`);
// Injection of USER-DOCS.md is gated on autoReadUserDocsGuide.
if (!options.enabled) {
return { injected: false, copiedSeed: seedResult.synced, seedLog: seedResult.log, reason: "autoReadUserDocsGuide=false" };
}
const guideResult = injectGuidePrimer(messages, expandedNotesDir, options.projectRoot, options.nameMaps);
return { ...guideResult, copiedSeed: seedResult.synced || guideResult.copiedSeed, seedLog: seedResult.log };
}