feat: initial commit

This commit is contained in:
kmou424
2026-06-09 00:38:02 +08:00
commit 04f0755dbf
43 changed files with 2220 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
{
"name": "wow-contexts",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"wow-core": "workspace:*"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
}
}

View File

@@ -0,0 +1,61 @@
import {
type ContextFileEntry,
resolveContextPath,
scanDir,
tryRead,
} from "./files";
import { resolvePaths } from "./resolver";
export interface BuildResult {
content: string | null;
loadedPaths: Map<string, string>;
}
export async function build(cwd: string): Promise<BuildResult> {
const paths = resolvePaths(cwd);
const files: ContextFileEntry[] = [];
const loadedPaths = new Map<string, string>();
for (const entry of paths) {
if (entry.endsWith("/*.md")) {
const dir = resolveContextPath(entry.slice(0, -4), cwd);
const before = files.length;
await scanDir(dir, files);
for (let index = before; index < files.length; index++) {
const file = files[index];
if (file) loadedPaths.set(file.rel, fileHash(file.content));
}
continue;
}
const abs = resolveContextPath(entry, cwd);
const before = files.length;
await tryRead(abs, entry, files);
if (files.length > before) {
const file = files[files.length - 1];
if (file) loadedPaths.set(abs, fileHash(file.content));
}
}
const nonEmpty = files.filter((file) => file.content.length > 0);
return {
content:
nonEmpty.length > 0
? nonEmpty
.map(
(file) => `<file path="${file.rel}">\n${file.content}\n</file>`,
)
.join("\n\n")
: null,
loadedPaths,
};
}
export function fileHash(content: string): string {
let hash = 0;
for (let index = 0; index < content.length; index++) {
hash = ((hash << 5) - hash + content.charCodeAt(index)) | 0;
}
return hash.toString(16);
}

View File

@@ -0,0 +1,187 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import { registerWowCommand, show, showInfo, showWarn } from "wow-core";
import { fileHash } from "./builder";
import { resolveContextPath } from "./files";
import { buildInitPrompt } from "./init-prompt";
import { resolvePaths } from "./resolver";
import { contextState } from "./state";
interface FileEntry {
abs: string;
display: string;
}
export function registerCommands(pi: ExtensionAPI): void {
registerWowCommand(pi, "contexts:list", {
description: "List all wow context files with load status",
handler: handleList,
});
registerWowCommand(pi, "contexts:reload", {
description: "Reload wow context files on the next message",
handler: handleReload,
});
registerWowCommand(pi, "init", {
description:
"Generate AGENTS.md and docs/contexts with a pi-optimized prompt",
handler: (args, ctx) => handleInit(args, ctx, pi),
});
}
async function handleList(
_args: string,
ctx: ExtensionCommandContext,
): Promise<void> {
if (!contextState.cwd) {
showWarn(ctx, "No context files scanned yet. Send a message first.");
return;
}
const found = await findCandidateFiles(contextState.cwd);
if (found.length === 0) {
showInfo(ctx, "No context files found in configured paths.");
return;
}
const loaded: FileEntry[] = [];
const modified: FileEntry[] = [];
const unloaded: FileEntry[] = [];
for (const file of found) {
const storedHash = contextState.wowPaths.get(file.abs);
if (storedHash === undefined) {
unloaded.push(file);
continue;
}
const currentHash = await computeHash(file.abs);
if (currentHash !== null && currentHash === storedHash) {
loaded.push(file);
} else {
modified.push(file);
}
}
const hasChanges = modified.length > 0 || unloaded.length > 0;
show(
ctx,
"info",
"Context Files",
{
type: "list",
sections: [
{
summary: `${loaded.length} loaded, ○ ${modified.length} modified, ○ ${unloaded.length} unloaded`,
items: [
...loaded.map((file) => `${file.display}`),
...modified.map((file) => `${file.display} (modified)`),
...unloaded.map((file) => `${file.display} (new)`),
],
},
],
},
...(hasChanges
? [
{
type: "text" as const,
content: "Use /wow:contexts:reload to pick up changes",
},
]
: []),
);
}
async function handleReload(
_args: string,
ctx: ExtensionCommandContext,
): Promise<void> {
if (!contextState.cwd) {
showWarn(ctx, "No context files scanned yet. Send a message first.");
return;
}
const hadFiles = contextState.wowPaths.size > 0;
contextState.rebuild = true;
showInfo(
ctx,
`Context reload queued${hadFiles ? "" : " — no files were loaded previously"}`,
["New context files will be picked up on your next message."],
);
}
async function handleInit(
args: string,
ctx: ExtensionCommandContext,
pi: ExtensionAPI,
): Promise<void> {
const prompt = buildInitPrompt(args);
if (ctx.isIdle()) {
pi.sendUserMessage(prompt);
} else {
pi.sendUserMessage(prompt, { deliverAs: "followUp" });
}
showInfo(ctx, "Repository init prompt sent", [
"The agent will generate AGENTS.md and docs/contexts using pi-native guidance.",
"If pi-subagents is available, it is instructed to use read-only scout/context-builder fanout before synthesis.",
]);
}
async function findCandidateFiles(cwd: string): Promise<FileEntry[]> {
const found: FileEntry[] = [];
for (const entry of resolvePaths(cwd)) {
if (entry.endsWith("/*.md")) {
const dir = resolveContextPath(entry.slice(0, -4), cwd);
const names = await readdirSafe(dir);
for (const name of names.sort()) {
if (!name.endsWith(".md")) continue;
const abs = path.resolve(dir, name);
found.push({ abs, display: path.relative(cwd, abs) });
}
continue;
}
const abs = resolveContextPath(entry, cwd);
if (await existsSafe(abs)) found.push({ abs, display: entry });
}
const deduped: FileEntry[] = [];
const seen = new Set<string>();
for (const file of found) {
if (seen.has(file.abs)) continue;
seen.add(file.abs);
deduped.push(file);
}
return deduped;
}
async function readdirSafe(dir: string): Promise<string[]> {
try {
return await fs.readdir(dir);
} catch {
return [];
}
}
async function existsSafe(filePath: string): Promise<boolean> {
try {
return (await fs.stat(filePath)).isFile();
} catch {
return false;
}
}
async function computeHash(absPath: string): Promise<string | null> {
try {
const content = await fs.readFile(absPath, "utf-8");
return fileHash(content.trim());
} catch {
return null;
}
}

View File

@@ -0,0 +1,44 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { resolvePath } from "wow-core";
export interface ContextFileEntry {
rel: string;
content: string;
}
export function resolveContextPath(input: string, cwd: string): string {
return resolvePath(input, cwd);
}
export async function scanDir(
dir: string,
out: ContextFileEntry[],
): Promise<void> {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
const filePath = path.join(dir, entry.name);
const content = await fs.readFile(filePath, "utf-8");
out.push({ rel: filePath, content: content.trim() });
}
} catch {
// Missing/unreadable context directories are ignored.
}
}
export async function tryRead(
absPath: string,
relHint: string,
out: ContextFileEntry[],
): Promise<void> {
try {
const stat = await fs.stat(absPath);
if (!stat.isFile()) return;
const content = await fs.readFile(absPath, "utf-8");
out.push({ rel: relHint, content: content.trim() });
} catch {
// Missing/unreadable context files are ignored.
}
}

View File

@@ -0,0 +1,4 @@
import { createLogger } from "wow-core";
export const TAG = "contexts";
export const log = createLogger(TAG);

View File

@@ -0,0 +1,60 @@
import type { BeforeAgentStartEventResult } from "@earendil-works/pi-coding-agent";
import type { ModuleSetup } from "wow-core";
import { registerModule, resetWowConfigCache } from "wow-core";
import { build } from "./builder";
import { registerCommands } from "./commands";
import { log, TAG } from "./global";
import { contextState } from "./state";
export type { ContextFileEntry } from "./files";
const setup: ModuleSetup = (pi) => {
let builtContent: string | null = null;
registerCommands(pi);
contextState.cwd = process.cwd();
void rebuild(contextState.cwd).then((content) => {
builtContent = content;
});
pi.on(
"before_agent_start",
async (event, ctx): Promise<BeforeAgentStartEventResult | undefined> => {
const forceRebuild = contextState.rebuild;
const cwdChanged = ctx.cwd !== contextState.cwd;
if (forceRebuild || cwdChanged || builtContent === null) {
resetWowConfigCache();
contextState.rebuild = false;
contextState.cwd = ctx.cwd;
builtContent = await rebuild(ctx.cwd);
}
if (!builtContent) return undefined;
return {
systemPrompt: `${event.systemPrompt}\n\n${builtContent}`,
};
},
);
};
registerModule({ name: TAG, register: setup });
async function rebuild(cwd: string): Promise<string> {
try {
const result = await build(cwd);
contextState.wowPaths = result.loadedPaths;
if (result.content) {
log.debug("context files rebuilt", {
paths: [...contextState.wowPaths.keys()],
});
return result.content;
}
} catch (error) {
contextState.wowPaths = new Map();
log.error("context build failed", { error: String(error) });
}
return "";
}

View File

@@ -0,0 +1,99 @@
const INIT_PROMPT_LINES = [
"# Generate AGENTS.md by launching multiple explore agents in parallel when available, first identifying the project ecosystem, then scanning evidence-based areas and synthesizing findings into a single file.",
"",
"This prompt must work for repositories written in any programming language or technology stack, including application code, libraries, services, mobile apps, infrastructure/IaC, docs-only repos, monorepos, polyglot workspaces, and mixed tooling.",
"",
"Start with an ecosystem discovery pass before assigning deeper scans. Identify languages, runtimes, package/build systems, frameworks, test tools, entry points, deployment shape, and whether the repo is a monorepo or single project. Use concrete files as evidence, not assumptions.",
"",
"If pi-subagents is installed and available, use it for parallel read-only exploration after the ecosystem pass. Prefer `scout` or `context-builder` agents with distinct evidence-based scopes. If pi-subagents is not available, perform the same exploration sequentially with normal read/search tools and continue without asking for permission just because parallelism is unavailable.",
"",
"Keep the parent agent responsible for final synthesis and all repository writes. Parallel agents should gather context and produce findings only.",
"",
"<structure>",
"- **Project Overview**: Brief description of project purpose",
"- **Architecture & Data Flow**: High-level structure, key modules, data flow",
"- **Key Directories**: Main source directories, purposes",
"- **Development Commands**: Build, test, lint, run commands",
"- **Code Conventions & Common Patterns**: Formatting, naming, error handling, async patterns, dependency injection, state management",
"- **Important Files**: Entry points, config files, key modules",
"- **Runtime/Tooling Preferences**: Required runtime(s), package/build tools, environment setup, tooling constraints, pi-specific notes when relevant",
"- **Testing & QA**: Test frameworks, running tests, coverage expectations",
"- **Module Contexts** (`docs/contexts/`): One `.md` per subsystem",
"</structure>",
"",
"<directives>",
"- You MUST title the document `Repository Guidelines`",
"- You MUST use Markdown headings for structure",
"- You MUST be concise and practical",
"- You MUST focus on what an AI assistant needs to help with the codebase",
"- You SHOULD include examples where helpful (commands, paths, naming patterns)",
"- You SHOULD include file paths where relevant",
"- You MUST call out architecture and code patterns explicitly",
"- You SHOULD omit information obvious from code structure",
"- You MUST NOT paste concrete implementation code (function bodies, struct fields, line-by-line logic); signatures or interface shapes are the maximum level of detail",
"- You MUST keep every file concise; shorter is better",
"- When uncertain, prefer less detail over speculative detail",
"- You MUST preserve existing useful guidance from `AGENTS.md`, `CLAUDE.md`, `.pi/`, README files, and docs instead of overwriting it blindly",
"- You SHOULD note installed pi helper capabilities when they materially affect future agents, especially `pi-subagents` orchestration patterns if available",
"</directives>",
"",
"<exploration-workflow>",
"1. Ecosystem discovery (parent or one scout): identify repo shape and evidence files before deeper work. Look for language/tool markers such as `package.json`, `bun.lock`, `pnpm-lock.yaml`, `pyproject.toml`, `requirements.txt`, `go.mod`, `Cargo.toml`, `pom.xml`, `build.gradle`, `settings.gradle`, `*.sln`, `*.csproj`, `Package.swift`, `Gemfile`, `composer.json`, `mix.exs`, `Makefile`, `CMakeLists.txt`, `Dockerfile`, `docker-compose.yml`, Terraform/OpenTofu files, Kubernetes manifests, CI workflows, and docs.",
"2. Parallel or sequential evidence scans: choose scopes based on the discovered ecosystem. Do not force JavaScript-style categories on non-JS repos.",
"3. Parent synthesis: verify high-impact claims, resolve contradictions, decide module context files, then write final outputs.",
"</exploration-workflow>",
"",
"<scan-matrix>",
"Use these language-agnostic scan targets, adapting names and examples to the actual repo:",
"- **Entry Points & Lifecycle**: executable entry points, library exports, service startup, CLI commands, web/mobile app bootstrap, extension hooks, background jobs, migrations, deployment entrypoints.",
"- **Architecture & Module Boundaries**: major packages/modules/services, dependency direction, public APIs, data/control flow, storage/network boundaries, generated code boundaries.",
"- **Configuration & Environment**: config files, env vars, secrets conventions, profiles/flavors, feature flags, local dev setup, runtime prerequisites.",
"- **Build, Install & Release**: build graph, package manager, scripts/tasks, generated artifacts, install/deploy scripts, containers, CI/CD, versioning/release notes.",
"- **Testing & Validation**: unit/integration/e2e tests, fixtures, linters, formatters, type checks, static analysis, manual validation flows, known gaps.",
"- **Docs & Existing Guidance**: README, AGENTS/CLAUDE files, docs, examples, ADRs/design notes, changelogs, existing `docs/contexts` content to preserve or update.",
"- **Operational Risks & Gotchas**: platform assumptions, concurrency/state, migrations, external services, security/privacy concerns, performance-sensitive paths, flaky tests, generated files not to edit.",
"</scan-matrix>",
"",
"<parallel-exploration>",
"When pi-subagents is available, launch a read-only parallel context pass after ecosystem discovery. Use fresh context unless inherited conversation decisions are required. Pick 2-4 agents based on repo size and shape. Recommended generic scopes:",
"",
"1. `scout` or `context-builder`: Entry points, architecture, module boundaries, and data/control flow.",
"2. `scout` or `context-builder`: Configuration, environment, build/install/release tooling, generated artifacts, and deployment shape.",
"3. `scout` or `context-builder`: Tests, QA, validation commands, fixtures, static analysis, and reliability gaps.",
"4. `scout` or `context-builder`: Existing docs/guidance, examples, module context candidates, stale or conflicting instructions.",
"",
"Each child must return:",
"- `Files Retrieved`: exact files and line ranges inspected.",
"- `Facts With Evidence`: concrete findings tied to file paths.",
"- `Commands`: real commands discovered from the repo, not generic guesses.",
"- `Module Candidates`: recommended `docs/contexts/<name>.md` files from this scan area.",
"- `Gotchas`: risks or caveats backed by evidence.",
"- `Unknowns`: unresolved facts; do not speculate.",
"",
"Children must not edit project files. The parent must synthesize child outputs, verify important claims by reading high-value files when needed, then write final files.",
"</parallel-exploration>",
"",
"<module-format>",
"- No paragraphs, tables, code blocks, or ASCII diagrams",
"- Write each file as a reference card: purpose, API surface, key types, file paths only. No explanations or walkthroughs.",
"- Include commands/tests/gotchas only when they are specific to that subsystem",
"- Keep module context files short enough to be loaded as context",
"</module-format>",
"",
"<output>",
"After analysis, you MUST write `AGENTS.md` to the project root.",
"You MUST also write all module-related files (`<module-name>.md`) to the `docs/contexts` directory.",
"Create `docs/contexts` if it does not exist.",
"If existing module context files already exist, update them instead of duplicating them.",
"",
"$@",
"</output>",
];
export const INIT_PROMPT = INIT_PROMPT_LINES.join("\n");
export function buildInitPrompt(args: string): string {
const trimmed = args.trim();
if (!trimmed) return INIT_PROMPT;
return INIT_PROMPT.replace("$@", trimmed);
}

View File

@@ -0,0 +1,16 @@
import { getWowSettingSync } from "wow-core";
import { TAG } from "./global";
const DEFAULT_PATHS: string[] = [
"~/.pi/agent/contexts/*.md",
".pi/contexts/*.md",
"docs/contexts/*.md",
];
export function resolvePaths(cwd = process.cwd()): string[] {
const configured = getWowSettingSync<string[]>(TAG, cwd);
if (Array.isArray(configured) && configured.length > 0) {
return configured.map(String);
}
return DEFAULT_PATHS;
}

View File

@@ -0,0 +1,11 @@
export interface ContextState {
cwd: string;
wowPaths: Map<string, string>;
rebuild: boolean;
}
export const contextState: ContextState = {
cwd: "",
wowPaths: new Map<string, string>(),
rebuild: false,
};

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,17 @@
{
"name": "wow-core",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"boxen": "^8.0.1",
"yaml": "^2.8.2"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
}
}

View File

@@ -0,0 +1,23 @@
import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
const WOW_NS = "wow";
export function registerWowCommand(
pi: Pick<ExtensionAPI, "registerCommand">,
module: string,
options: {
description?: string;
getArgumentCompletions?: (
prefix: string,
) => unknown[] | null | Promise<unknown[] | null>;
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
},
): void {
pi.registerCommand(
`${WOW_NS}:${module}`,
options as Parameters<ExtensionAPI["registerCommand"]>[1],
);
}

View File

@@ -0,0 +1,184 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import * as path from "node:path";
import YAML from "yaml";
export interface WowConfig {
contexts?: string[];
logger?: { level?: string };
inject?: {
enabled?: boolean;
envFiles?: string[];
env?: Record<string, string> | Array<Record<string, string>>;
overrideExisting?: boolean;
};
[key: string]: unknown;
}
const AGENT_DIR_VAR = "PI_CODING_AGENT_DIR";
const CONFIG_FILES = ["wow.json", "wow.yaml", "wow.yml"];
let cachedConfig: WowConfig | undefined;
let cachedCwd = "";
export function getAgentDir(): string {
return process.env[AGENT_DIR_VAR] || path.join(homedir(), ".pi", "agent");
}
export function resolveConfigPaths(cwd = process.cwd()): string[] {
const globalConfigs = CONFIG_FILES.map((file) =>
path.join(getAgentDir(), file),
);
const projectConfigs = CONFIG_FILES.map((file) =>
path.join(cwd, ".pi", file),
);
return [...globalConfigs, ...projectConfigs].filter(
(filePath, index, all) => all.indexOf(filePath) === index,
);
}
export function resetWowConfigCache(): void {
cachedConfig = undefined;
cachedCwd = "";
}
export function getWowSettings(cwd = process.cwd()): WowConfig | undefined {
if (cachedConfig !== undefined && cachedCwd === cwd) return cachedConfig;
cachedCwd = cwd;
cachedConfig = loadWowConfig(cwd);
return cachedConfig;
}
export function getWowSettingSync<T = unknown>(
key: string,
cwd = process.cwd(),
): T | undefined {
const config = getWowSettings(cwd);
return config?.[key] as T | undefined;
}
export async function readWowConfig<T = unknown>(
key: string,
cwd = process.cwd(),
): Promise<T | undefined> {
return getWowSettingSync<T>(key, cwd);
}
function loadWowConfig(cwd: string): WowConfig | undefined {
let merged: WowConfig | undefined;
for (const filePath of resolveConfigPaths(cwd)) {
const config = readConfigFile(filePath);
if (!config) continue;
merged = mergeWowConfig(merged ?? {}, config);
}
return merged;
}
function readConfigFile(filePath: string): WowConfig | undefined {
try {
if (!existsSync(filePath)) return undefined;
const content = readFileSync(filePath, "utf-8");
const parsed = parseConfigContent(filePath, content);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
return undefined;
return parsed as WowConfig;
} catch {
return undefined;
}
}
function parseConfigContent(filePath: string, content: string): unknown {
if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) {
return YAML.parse(content);
}
return JSON.parse(content);
}
function mergeWowConfig(base: WowConfig, override: WowConfig): WowConfig {
const merged: WowConfig = { ...base, ...override };
if (base.logger || override.logger) {
merged.logger = { ...(base.logger ?? {}), ...(override.logger ?? {}) };
}
if (base.inject || override.inject) {
merged.inject = mergeInjectConfig(base.inject, override.inject);
}
if (Array.isArray(base.contexts) || Array.isArray(override.contexts)) {
merged.contexts = mergeStringArrays(base.contexts, override.contexts);
}
return merged;
}
function mergeInjectConfig(
base: WowConfig["inject"],
override: WowConfig["inject"],
): WowConfig["inject"] {
const merged = { ...(base ?? {}), ...(override ?? {}) };
merged.envFiles = mergeStringArrays(base?.envFiles, override?.envFiles);
merged.env = mergeEnvConfig(base?.env, override?.env);
return merged;
}
type EnvConfig = NonNullable<WowConfig["inject"]>["env"];
function mergeEnvConfig(
base: EnvConfig | undefined,
override: EnvConfig | undefined,
): Record<string, string> | undefined {
const merged: Record<string, string> = {};
for (const source of [base, override]) {
for (const [key, value] of Object.entries(normaliseEnvMap(source))) {
merged[key] = value;
}
}
return Object.keys(merged).length > 0 ? merged : undefined;
}
function normaliseEnvMap(input: unknown): Record<string, string> {
if (!input) return {};
const entries = Array.isArray(input)
? Object.assign(
{},
...input.filter(
(item) => item && typeof item === "object" && !Array.isArray(item),
),
)
: input;
if (!entries || typeof entries !== "object" || Array.isArray(entries))
return {};
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(entries)) {
if (typeof value === "string" || value instanceof String) {
result[key] = String(value);
}
}
return result;
}
function mergeStringArrays(
base?: string[],
override?: string[],
): string[] | undefined {
const merged: string[] = [];
const seen = new Set<string>();
for (const list of [base, override]) {
if (!Array.isArray(list)) continue;
for (const item of list) {
if (typeof item !== "string" || seen.has(item)) continue;
seen.add(item);
merged.push(item);
}
}
return merged.length > 0 ? merged : undefined;
}

View File

@@ -0,0 +1,7 @@
export * from "./commands";
export * from "./config";
export * from "./logger";
export * from "./registry";
export * from "./ui";
export * from "./utils";
export * from "./utils/ref-resolver";

View File

@@ -0,0 +1,45 @@
import type { WowConfig } from "../config";
import { getWowSettingSync } from "../config";
import { parseLevel } from "./levels";
import { WowLogger } from "./logger";
export type { LogLevel } from "./levels";
export { WowLogger } from "./logger";
export type TaggedLogger = {
debug: (message: string, data?: Record<string, unknown>) => void;
info: (message: string, data?: Record<string, unknown>) => void;
warn: (message: string, data?: Record<string, unknown>) => void;
error: (message: string, data?: Record<string, unknown>) => void;
};
let instance: WowLogger | null = null;
export function createLogger(module: string): TaggedLogger {
const logger = getLogger();
return {
debug: (message, data) => logger.debug(module, message, data),
info: (message, data) => logger.info(module, message, data),
warn: (message, data) => logger.warn(module, message, data),
error: (message, data) => logger.error(module, message, data),
};
}
export function initLogger(cwd = process.cwd()): void {
const base = resolveDefaultLevel();
let finalLevel = base;
const cfg = getWowSettingSync<WowConfig["logger"]>("logger", cwd);
if (typeof cfg?.level === "string") {
finalLevel = parseLevel(cfg.level) ?? finalLevel;
}
getLogger().setMinLevel(finalLevel);
}
function getLogger(): WowLogger {
if (!instance) instance = new WowLogger(resolveDefaultLevel());
return instance;
}
function resolveDefaultLevel(): "debug" | "error" | "info" | "warn" {
return parseLevel(process.env.WOW_LOG_LEVEL) ?? "info";
}

View File

@@ -0,0 +1,28 @@
/**
* Log level definitions for the wow logger.
*/
export const LEVELS = {
debug: 0,
info: 1,
warn: 2,
error: 3,
} as const;
export type LogLevel = keyof typeof LEVELS;
/**
* Parse a string to a `LogLevel`. Returns `undefined` for unknown values.
*/
export function parseLevel(s: string | undefined): LogLevel | undefined {
if (!s) return undefined;
const lower = s.toLowerCase() as string;
for (const level of Object.keys(LEVELS)) {
if (lower === level) return level as LogLevel;
}
return undefined;
}
export function levelToNumber(level: LogLevel): number {
return LEVELS[level];
}

View File

@@ -0,0 +1,118 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { getAgentDir } from "../config";
import type { LogLevel } from "./levels";
import { levelToNumber } from "./levels";
const LOG_DIR = path.join(getAgentDir(), "wow", "logs");
const MAX_BYTES = 10 * 1024 * 1024;
const MAX_FILES = 5;
export class WowLogger {
#minLevel: number;
#dirEnsured = false;
constructor(level: LogLevel) {
this.#minLevel = levelToNumber(level);
}
setMinLevel(level: LogLevel): void {
this.#minLevel = levelToNumber(level);
}
debug(module: string, message: string, data?: Record<string, unknown>): void {
void this.#write("debug", module, message, data);
}
info(module: string, message: string, data?: Record<string, unknown>): void {
void this.#write("info", module, message, data);
}
warn(module: string, message: string, data?: Record<string, unknown>): void {
void this.#write("warn", module, message, data);
}
error(module: string, message: string, data?: Record<string, unknown>): void {
void this.#write("error", module, message, data);
}
async #write(
level: LogLevel,
module: string,
message: string,
data?: Record<string, unknown>,
): Promise<void> {
if (levelToNumber(level) < this.#minLevel) return;
try {
await this.#ensureDir();
const date = dateStr();
const filePath = logPath(date);
await this.#rotateIfNeeded(filePath, date);
await fs.appendFile(
filePath,
`${JSON.stringify({
timestamp: new Date().toISOString(),
level,
pid: process.pid,
module,
message,
...data,
})}\n`,
);
} catch {
// Logging must never break the extension.
}
}
async #ensureDir(): Promise<void> {
if (this.#dirEnsured) return;
await fs.mkdir(LOG_DIR, { recursive: true });
this.#dirEnsured = true;
}
async #rotateIfNeeded(filePath: string, date: string): Promise<void> {
try {
const stat = await fs.stat(filePath);
if (stat.size < MAX_BYTES) return;
} catch {
return;
}
try {
await fs.unlink(rotatedPath(date, MAX_FILES));
} catch {
// Rotated file may not exist.
}
for (let i = MAX_FILES - 1; i >= 1; i--) {
try {
await fs.rename(rotatedPath(date, i), rotatedPath(date, i + 1));
} catch {
// Older rotation slot may not exist.
}
}
try {
await fs.rename(filePath, rotatedPath(date, 1));
} catch {
// File may have been moved by another process.
}
}
}
function logPath(date: string): string {
return path.join(LOG_DIR, `wow.${date}.log`);
}
function rotatedPath(date: string, index: number): string {
return path.join(LOG_DIR, `wow.${date}.${index}.log`);
}
function dateStr(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}

View File

@@ -0,0 +1,18 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export type ModuleSetup = (pi: ExtensionAPI) => void | Promise<void>;
export interface WowModule {
name: string;
register: ModuleSetup;
}
const modules: WowModule[] = [];
export function registerModule(mod: WowModule): void {
modules.push(mod);
}
export function getModules(): readonly WowModule[] {
return [...modules].sort((a, b) => a.name.localeCompare(b.name));
}

View File

@@ -0,0 +1,50 @@
/**
* Component types and rendering for the wow-pi display system.
*
* Each component type knows how to append its visual lines to a buffer.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ListSection {
summary: string;
items?: string[];
emptyText?: string;
}
export interface ShowText {
type: "text";
content: string;
}
export interface ShowList {
type: "list";
sections: ListSection[];
}
export type ShowComponent = ShowText | ShowList;
// ---------------------------------------------------------------------------
// Render helpers
// ---------------------------------------------------------------------------
/** Append lines for a single component into `buf`. */
export function renderComponent(comp: ShowComponent, buf: string[]): void {
if (comp.type === "text") {
buf.push(comp.content);
} else if (comp.type === "list") {
for (let si = 0; si < comp.sections.length; si++) {
const section = comp.sections[si]!;
if (si > 0) buf.push("");
buf.push(section.summary);
const items = section.items;
if (items && items.length > 0) {
for (const item of items) buf.push(` ${item}`);
} else {
buf.push(` ${section.emptyText ?? "(empty)"}`);
}
}
}
}

View File

@@ -0,0 +1,2 @@
export * from "./component";
export * from "./render";

View File

@@ -0,0 +1,71 @@
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import boxen from "boxen";
import type { ShowComponent } from "./component";
import { renderComponent } from "./component";
type Level = "info" | "warning" | "error";
export function show(
ctx: { ui: ExtensionCommandContext["ui"] },
level: Level,
title: string,
...components: ShowComponent[]
): void {
if (components.length === 0) {
ctx.ui.notify(title, level);
return;
}
const lines: string[] = [];
for (const [index, component] of components.entries()) {
if (index > 0) lines.push("");
renderComponent(component, lines);
}
ctx.ui.notify(
boxen(lines.join("\n"), {
borderStyle: "single",
padding: 1,
title,
titleAlignment: "left",
}),
level,
);
}
export function showInfo(
ctx: { ui: ExtensionCommandContext["ui"] },
title: string,
body?: string[],
): void {
showText(ctx, "info", title, body);
}
export function showWarn(
ctx: { ui: ExtensionCommandContext["ui"] },
title: string,
body?: string[],
): void {
showText(ctx, "warning", title, body);
}
export function showError(
ctx: { ui: ExtensionCommandContext["ui"] },
title: string,
body?: string[],
): void {
showText(ctx, "error", title, body);
}
function showText(
ctx: { ui: ExtensionCommandContext["ui"] },
level: Level,
title: string,
body?: string[],
): void {
if (!body || body.length === 0) {
show(ctx, level, title);
return;
}
show(ctx, level, title, { type: "text", content: body.join("\n") });
}

View File

@@ -0,0 +1,12 @@
import { homedir } from "node:os";
import * as path from "node:path";
export function expandHome(input: string): string {
if (input === "~") return homedir();
if (input.startsWith("~/")) return path.join(homedir(), input.slice(2));
return input;
}
export function resolvePath(input: string, cwd = process.cwd()): string {
return path.resolve(cwd, expandHome(input));
}

View File

@@ -0,0 +1,60 @@
import * as fs from "node:fs";
import type { TaggedLogger } from "../logger";
import { expandHome } from ".";
export interface KeyResolver {
protocol: string;
resolve(value: string): string | undefined;
}
export const resolvers: KeyResolver[] = [];
export function registerKeyResolver(resolver: KeyResolver): void {
resolvers.push(resolver);
}
registerKeyResolver({
protocol: "file",
resolve(value: string): string | undefined {
const content = fs.readFileSync(expandHome(value), "utf-8").trim();
return content.length > 0 ? content : undefined;
},
});
registerKeyResolver({
protocol: "env",
resolve(value: string): string | undefined {
return process.env[value];
},
});
const REF_RE = /^\$\{([a-zA-Z_][a-zA-Z0-9_]*):/;
export function resolveRef(ref: string, log: TaggedLogger): string | undefined {
const match = REF_RE.exec(ref);
if (!match) return ref;
if (!ref.endsWith("}")) return undefined;
const protocol = match[1];
const value = ref.slice(match[0].length, -1);
if (!protocol || !value) return undefined;
for (const resolver of resolvers) {
if (resolver.protocol !== protocol) continue;
try {
const resolved = resolver.resolve(value);
if (resolved !== undefined) {
log.debug(`ref "${protocol}" resolved`, {
length: resolved.length,
value,
});
return resolved;
}
} catch (error) {
log.warn(`ref "${protocol}" error`, { value, error: String(error) });
}
break;
}
return undefined;
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,16 @@
{
"name": "wow-inject",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"wow-core": "workspace:*"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
}
}

View File

@@ -0,0 +1,68 @@
import { existsSync, readFileSync } from "node:fs";
import { resolvePath } from "wow-core";
export interface EnvLoadResult {
path: string;
loaded: string[];
skipped: string[];
missing: boolean;
}
export function loadEnvFile(
rawPath: string,
options: { cwd: string; overrideExisting: boolean },
): EnvLoadResult {
const filePath = resolvePath(rawPath, options.cwd);
const result: EnvLoadResult = {
path: filePath,
loaded: [],
skipped: [],
missing: false,
};
if (!existsSync(filePath)) {
result.missing = true;
return result;
}
const content = readFileSync(filePath, "utf-8");
for (const line of content.split(/\r?\n/)) {
const entry = parseEnvLine(line);
if (!entry) continue;
if (!options.overrideExisting && entry.key in process.env) {
result.skipped.push(entry.key);
continue;
}
process.env[entry.key] = entry.value;
result.loaded.push(entry.key);
}
return result;
}
function parseEnvLine(line: string): { key: string; value: string } | null {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return null;
const cleaned = trimmed.startsWith("export ")
? trimmed.slice(7).trimStart()
: trimmed;
const eqIndex = cleaned.indexOf("=");
if (eqIndex === -1) return null;
const key = cleaned.slice(0, eqIndex).trim();
if (!key) return null;
return { key, value: unwrapValue(cleaned.slice(eqIndex + 1).trim()) };
}
function unwrapValue(value: string): string {
if (value.length < 2) return value;
const quote = value[0];
if ((quote === '"' || quote === "'") && value.endsWith(quote)) {
return value.slice(1, -1);
}
return value;
}

View File

@@ -0,0 +1,88 @@
import type { WowConfig } from "wow-core";
import { getWowSettingSync, resolveRef } from "wow-core";
import { loadEnvFile } from "./env-file";
import { log, resolvedEnv } from "./global";
interface InjectStats {
envFilesLoaded: number;
envVarsLoaded: number;
skippedExisting: number;
missingFiles: number;
}
export function injectProcessEnv(cwd = process.cwd()): InjectStats {
const cfg = getWowSettingSync<WowConfig["inject"]>("inject", cwd);
const stats: InjectStats = {
envFilesLoaded: 0,
envVarsLoaded: 0,
skippedExisting: 0,
missingFiles: 0,
};
if (!cfg || cfg.enabled === false) return stats;
const overrideExisting = cfg.overrideExisting ?? false;
for (const rawPath of cfg.envFiles ?? []) {
const result = loadEnvFile(rawPath, { cwd, overrideExisting });
if (result.missing) {
stats.missingFiles++;
log.debug("env file missing", { path: result.path });
continue;
}
stats.envFilesLoaded++;
stats.envVarsLoaded += result.loaded.length;
stats.skippedExisting += result.skipped.length;
log.debug("env file loaded", {
path: result.path,
loaded: result.loaded,
skipped: result.skipped,
});
}
for (const [name, rawValue] of Object.entries(normaliseEnvMap(cfg.env))) {
if (!overrideExisting && name in process.env) {
stats.skippedExisting++;
continue;
}
const resolved = resolveRef(rawValue, log);
if (resolved === undefined) {
log.warn("env ref unresolved", { name });
continue;
}
process.env[name] = resolved;
resolvedEnv.set(name, resolved);
stats.envVarsLoaded++;
}
if (stats.envFilesLoaded > 0 || stats.envVarsLoaded > 0) {
log.info("env injected", { ...stats });
}
return stats;
}
function normaliseEnvMap(input: unknown): Record<string, string> {
if (!input) return {};
const entries = Array.isArray(input)
? Object.assign(
{},
...input.filter(
(item) => item && typeof item === "object" && !Array.isArray(item),
),
)
: input;
if (!entries || typeof entries !== "object" || Array.isArray(entries))
return {};
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(entries)) {
if (typeof value === "string" || value instanceof String) {
result[key] = String(value);
}
}
return result;
}

View File

@@ -0,0 +1,5 @@
import { createLogger } from "wow-core";
export const TAG = "inject";
export const log = createLogger(TAG);
export const resolvedEnv = new Map<string, string>();

View File

@@ -0,0 +1,28 @@
import type { ModuleSetup, WowConfig } from "wow-core";
import {
getWowSettingSync,
registerModule,
resetWowConfigCache,
} from "wow-core";
import { injectProcessEnv } from "./env";
import { log, TAG } from "./global";
const setup: ModuleSetup = (pi) => {
const load = (cwd = process.cwd()) => {
resetWowConfigCache();
const cfg = getWowSettingSync<WowConfig["inject"]>(TAG, cwd);
if (cfg?.enabled === false) {
log.debug("inject disabled — skipping module");
return;
}
injectProcessEnv(cwd);
};
load();
pi.on("session_start", (_event, ctx) => {
load(ctx.cwd);
});
};
registerModule({ name: TAG, register: setup });

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,23 @@
{
"name": "wow-pi",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"clean": "rm -rf ./dist",
"build:debug": "bun run clean && bun build ./src/index.ts --outdir ./dist --target node --external @earendil-works/pi-coding-agent --external @earendil-works/pi-ai --external @earendil-works/pi-tui --external typebox --define 'process.env.WOW_LOG_LEVEL=\"debug\"'",
"build:release": "bun run clean && bun build ./src/index.ts --outdir ./dist --target node --external @earendil-works/pi-coding-agent --external @earendil-works/pi-ai --external @earendil-works/pi-tui --external typebox --define 'process.env.WOW_LOG_LEVEL=\"info\"' --minify"
},
"dependencies": {
"wow-contexts": "workspace:*",
"wow-core": "workspace:*",
"wow-inject": "workspace:*"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
}
}

View File

@@ -0,0 +1,23 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import "wow-contexts";
import "wow-inject";
import {
createLogger,
getModules,
initLogger,
resetWowConfigCache,
} from "wow-core";
export default async function wowPi(pi: ExtensionAPI): Promise<void> {
resetWowConfigCache();
initLogger();
const log = createLogger("main");
for (const mod of getModules()) {
await mod.register(pi);
log.debug("module registered", { module: mod.name });
}
log.info("wow-pi loaded", { modules: getModules().map((mod) => mod.name) });
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["bun"]
},
"include": ["src"]
}