@capagents/forgeqe 0.0.0-stage → 1.0.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/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +781 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +146 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { CodeEvent, Usage } from "../types.ts"
|
|
5
|
+
import { findOpencode, openSession, type OpencodeSession } from "../code/runtime.ts"
|
|
6
|
+
import { privateEnvNames } from "../childEnv.ts"
|
|
7
|
+
import type { AppConfig } from "../config.ts"
|
|
8
|
+
import { externalMcp, externalNotes, type ExternalPlan } from "../mcp/external.ts"
|
|
9
|
+
import { MCP_NAME, TOOL_TIMEOUT_MS, localToolServer } from "../mcp/server.ts"
|
|
10
|
+
import { TOOLS, environmentNotes, type ToolContext } from "../mcp/tools.ts"
|
|
11
|
+
import { brainPrompt, BRAIN_SCHEMA, type BrainReport } from "./prompt.ts"
|
|
12
|
+
import { redactSecrets } from "../sessionLogins.ts"
|
|
13
|
+
import { logEvent } from "../sessionLog.ts"
|
|
14
|
+
import { loadSkills } from "../skills/registry.ts"
|
|
15
|
+
import { domainNotes } from "../domain.ts"
|
|
16
|
+
|
|
17
|
+
export const BRAIN_AGENT = "forgeqe"
|
|
18
|
+
|
|
19
|
+
export type BrainAnswer = { reply: string; outcome: "done" | "failed" | "needs_input" | "stopped"; error?: string }
|
|
20
|
+
|
|
21
|
+
export type Brain = {
|
|
22
|
+
model: string
|
|
23
|
+
ask(text: string): Promise<BrainAnswer>
|
|
24
|
+
abort(): Promise<void>
|
|
25
|
+
close(): Promise<void>
|
|
26
|
+
usage: Usage
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type BrainOptions = {
|
|
30
|
+
config: AppConfig
|
|
31
|
+
/** The repository or folder the agent works in. */
|
|
32
|
+
cwd: string
|
|
33
|
+
/** The tools' context; read on every call so /profile and /headed apply. */
|
|
34
|
+
context: () => Omit<ToolContext, "config" | "cwd">
|
|
35
|
+
/** Extra lines for the agent about this session, e.g. whether passed runs become tests. */
|
|
36
|
+
notes?: string[]
|
|
37
|
+
signal?: AbortSignal
|
|
38
|
+
onEvent?: (e: CodeEvent) => void
|
|
39
|
+
/** Replaces openSession in tests. */
|
|
40
|
+
open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession<BrainReport>>
|
|
41
|
+
/** Replaces the MCP servers derived from forgeqe.yaml (tests). */
|
|
42
|
+
external?: ExternalPlan
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Why the brain cannot run with this config, or null when it can. */
|
|
46
|
+
export function brainUnavailable(cfg: AppConfig): string | null {
|
|
47
|
+
if (cfg.llm.provider === "mock" && !cfg.code.model) return "the mock profile has no model"
|
|
48
|
+
if (cfg.llm.provider === "none" && !cfg.code.model) return "no model is set up"
|
|
49
|
+
if (!findOpencode(cfg.code.opencode)) return "the agent runtime is not installed"
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where the agent works and whether passed runs become tests: `repo` is the --repo flag (null for --no-code),
|
|
55
|
+
* falling back to code.repo in forgeqe.yaml, then the current folder.
|
|
56
|
+
*/
|
|
57
|
+
export function sessionSetup(cfg: AppConfig, repo: string | null | undefined): { cwd: string; notes: string[]; writeTests: boolean } {
|
|
58
|
+
if (repo === null) return { cwd: process.cwd(), notes: ["Do not write tests unless the user asks for one."], writeTests: false }
|
|
59
|
+
return {
|
|
60
|
+
cwd: path.resolve(repo || cfg.code.repo || process.cwd()),
|
|
61
|
+
notes: [
|
|
62
|
+
"Passed browser runs become tests: after a browser_run with purpose test passes and returns a next_step, call write_test with its run_dir before you reply, unless the user says not to. " +
|
|
63
|
+
"A browser_run with purpose task (looking up or linking work items, reading a tool) never gets a test. " +
|
|
64
|
+
"run_test_cases does this itself, case by case: each passed case is coded right after its browser run, so do not call write_test for those. " +
|
|
65
|
+
"In the reply, name the test file ForgeQE's coding agent wrote and whether ForgeQE's own run of it passed.",
|
|
66
|
+
],
|
|
67
|
+
writeTests: true,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One line per configured system: its MCP server connected, or ForgeQE's API tools cover it and why. */
|
|
72
|
+
async function reportExternal(session: OpencodeSession<BrainReport>, plan: ExternalPlan, emit: (e: CodeEvent) => void) {
|
|
73
|
+
for (const s of plan.skipped) emit({ type: "code", kind: "info", text: `${s.label} not started: ${s.why}. ForgeQE's API tools cover it.` })
|
|
74
|
+
if (!plan.servers.length || !session.mcpStatus) return
|
|
75
|
+
const status = await session.mcpStatus().catch(() => ({}) as Record<string, { status: string; error?: string }>)
|
|
76
|
+
for (const s of plan.servers) {
|
|
77
|
+
const st = status[s.name]
|
|
78
|
+
if (st?.status === "connected") emit({ type: "code", kind: "test", ok: true, attempt: 0, text: `${s.label} connected` })
|
|
79
|
+
else {
|
|
80
|
+
const why = st?.status === "failed" ? (st.error || "failed").replace(/\s+/g, " ").slice(0, 200) : st?.status === "needs_auth" || st?.status === "needs_client_registration" ? "it needs a browser sign-in, which ForgeQE cannot do" : st?.status || "no status"
|
|
81
|
+
emit({ type: "code", kind: "test", ok: false, attempt: 0, text: `${s.label} did not connect (${why}). ForgeQE's API tools cover it.` })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function realpathOr(dir: string): string {
|
|
87
|
+
try {
|
|
88
|
+
return realpathSync(dir)
|
|
89
|
+
} catch {
|
|
90
|
+
return path.resolve(dir)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Start ForgeQE's agent: an agent runtime session with the ForgeQE prompt, the active profile's model, and ForgeQE's
|
|
96
|
+
* tools served over MCP from this process, so browser runs and suites report to the caller while they run.
|
|
97
|
+
*/
|
|
98
|
+
const CARRY_TURNS = 20
|
|
99
|
+
const CARRY_CHARS = 3_000
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The first prompt of a new agent session that continues an earlier conversation (the user switched profiles):
|
|
103
|
+
* the earlier turns, with remembered secrets redacted, then the new message.
|
|
104
|
+
*/
|
|
105
|
+
export function carryOver(turns: { user: string; reply: string }[], text: string): string {
|
|
106
|
+
if (!turns.length) return text
|
|
107
|
+
const clip = (s: string) => (s.length > CARRY_CHARS ? `${s.slice(0, CARRY_CHARS)}…` : s)
|
|
108
|
+
const earlier = turns.slice(-CARRY_TURNS).flatMap((t) => [`User: ${clip(redactSecrets(t.user))}`, `You: ${clip(redactSecrets(t.reply || "(no reply)"))}`])
|
|
109
|
+
return [
|
|
110
|
+
"The user switched model profiles, so this is a new session continuing the conversation below. Nothing from those turns is still running; files and saved logins they produced are still there.",
|
|
111
|
+
"",
|
|
112
|
+
...earlier,
|
|
113
|
+
"",
|
|
114
|
+
`User: ${text}`,
|
|
115
|
+
].join("\n")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function openBrain(opts: BrainOptions): Promise<Brain> {
|
|
119
|
+
const { config } = opts
|
|
120
|
+
const cwd = realpathOr(opts.cwd)
|
|
121
|
+
const context = (): ToolContext => ({ ...opts.context(), config, cwd })
|
|
122
|
+
const tools = localToolServer({ tools: TOOLS, context, instructions: () => environmentNotes(context()), timeoutMs: TOOL_TIMEOUT_MS })
|
|
123
|
+
const external = opts.external || externalMcp(config)
|
|
124
|
+
const notes = [environmentNotes(context()), ...externalNotes(external), ...(opts.notes || [])].filter(Boolean).join("\n")
|
|
125
|
+
const readDirs = [...new Set([config.export.outDir, config.memory.dir, cwd].flatMap((d) => [path.resolve(d), realpathOr(d)]))]
|
|
126
|
+
const onEvent = (e: CodeEvent) => {
|
|
127
|
+
logEvent("agent.event", e)
|
|
128
|
+
opts.onEvent?.(e)
|
|
129
|
+
}
|
|
130
|
+
const emit = onEvent
|
|
131
|
+
if (external.servers.length) emit({ type: "code", kind: "info", text: `Connecting to ${external.servers.map((s) => s.label).join(", ")}…` })
|
|
132
|
+
|
|
133
|
+
let session: OpencodeSession<BrainReport>
|
|
134
|
+
try {
|
|
135
|
+
session = await (opts.open || openSession<BrainReport>)({
|
|
136
|
+
repo: cwd,
|
|
137
|
+
profile: config.llm,
|
|
138
|
+
domain: domainNotes(config),
|
|
139
|
+
model: config.code.model,
|
|
140
|
+
bin: config.code.opencode,
|
|
141
|
+
readDirs,
|
|
142
|
+
hide: privateEnvNames(config),
|
|
143
|
+
role: "ForgeQE's agent",
|
|
144
|
+
secrets: Object.assign({}, ...external.servers.map((s) => s.secrets)),
|
|
145
|
+
signal: opts.signal,
|
|
146
|
+
onEvent,
|
|
147
|
+
agent: { name: BRAIN_AGENT, description: "ForgeQE: browser runs, test cases, tests, and CI pipelines", prompt: `${brainPrompt()}\n\nThis session:\n${notes}`, skills: "forgeqe" },
|
|
148
|
+
skills: loadSkills(config.skills, cwd).skills,
|
|
149
|
+
schema: BRAIN_SCHEMA,
|
|
150
|
+
mcp: {
|
|
151
|
+
...Object.fromEntries(external.servers.map((s) => [s.name, s.config])),
|
|
152
|
+
[MCP_NAME]: tools.entry,
|
|
153
|
+
},
|
|
154
|
+
hideTool: (tool) => tool.startsWith(`${MCP_NAME}_`),
|
|
155
|
+
title: "ForgeQE",
|
|
156
|
+
})
|
|
157
|
+
} catch (err) {
|
|
158
|
+
tools.stop()
|
|
159
|
+
throw err
|
|
160
|
+
}
|
|
161
|
+
await reportExternal(session, external, emit)
|
|
162
|
+
|
|
163
|
+
let stopped = false
|
|
164
|
+
return {
|
|
165
|
+
model: session.model,
|
|
166
|
+
usage: session.usage,
|
|
167
|
+
async ask(text) {
|
|
168
|
+
stopped = false
|
|
169
|
+
logEvent("agent.ask", { model: session.model, characters: text.length })
|
|
170
|
+
const out = await session.prompt(text)
|
|
171
|
+
// Written after the turn, when credentials the message gave are remembered and so redacted.
|
|
172
|
+
logEvent("agent.turn", { ask: text, error: out.error, report: out.report, text: out.text })
|
|
173
|
+
if (stopped || out.error === "stopped" || opts.signal?.aborted) return { reply: "", outcome: "stopped" }
|
|
174
|
+
const reply = out.report?.reply?.trim() || out.text.trim()
|
|
175
|
+
if (out.error && !out.report) return { reply, outcome: "failed", error: out.error }
|
|
176
|
+
return { reply, outcome: out.report?.outcome || (out.error ? "failed" : "done"), error: out.error }
|
|
177
|
+
},
|
|
178
|
+
abort: () => {
|
|
179
|
+
stopped = true
|
|
180
|
+
tools.server.abortAll()
|
|
181
|
+
return session.abort()
|
|
182
|
+
},
|
|
183
|
+
async close() {
|
|
184
|
+
tools.server.abortAll()
|
|
185
|
+
await session.close()
|
|
186
|
+
tools.stop()
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { prompt } from "../prompts.ts"
|
|
2
|
+
|
|
3
|
+
/** System prompt of ForgeQE's agent, which reads every request in the shell and `forgeqe run`. */
|
|
4
|
+
export const brainPrompt = () => prompt("brain")
|
|
5
|
+
|
|
6
|
+
export const BRAIN_SCHEMA = {
|
|
7
|
+
type: "object",
|
|
8
|
+
properties: {
|
|
9
|
+
reply: { type: "string", description: "What you did and found, or the question you need answered. Plain text, short." },
|
|
10
|
+
outcome: {
|
|
11
|
+
type: "string",
|
|
12
|
+
enum: ["done", "failed", "needs_input"],
|
|
13
|
+
description: "done: the request was carried out and everything checked passed. failed: a run, test, or pipeline failed or could not be done. needs_input: you asked the user something.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
required: ["reply", "outcome"],
|
|
17
|
+
} as const
|
|
18
|
+
|
|
19
|
+
export type BrainReport = { reply?: string; outcome?: "done" | "failed" | "needs_input" }
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
import type { DocRef } from "./registry.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* README.md and HOW_TO_USE.md as addressable sections. The agent reads the contents, picks sections by id, and gets
|
|
8
|
+
* their exact text: nothing is ranked or guessed by keyword. Ids are the docs' own GitHub anchors (doc#anchor), so
|
|
9
|
+
* they are stable across releases while a heading keeps its name.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const DOCS: DocRef["doc"][] = ["README.md", "HOW_TO_USE.md"]
|
|
13
|
+
|
|
14
|
+
export type DocSection = {
|
|
15
|
+
id: string
|
|
16
|
+
doc: DocRef["doc"]
|
|
17
|
+
heading: string
|
|
18
|
+
/** 2 for ##, 3 for ###. */
|
|
19
|
+
level: number
|
|
20
|
+
/** The ## section a ### belongs to. */
|
|
21
|
+
parent?: string
|
|
22
|
+
text: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** GitHub's heading anchor: lowercase, punctuation dropped, spaces to hyphens. */
|
|
26
|
+
export function anchor(heading: string): string {
|
|
27
|
+
return heading
|
|
28
|
+
.trim()
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
|
31
|
+
.replace(/\s/g, "-")
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const root = path.resolve(import.meta.dir, "..", "..")
|
|
35
|
+
|
|
36
|
+
export function parseSections(doc: DocRef["doc"], markdown: string): DocSection[] {
|
|
37
|
+
const out: DocSection[] = []
|
|
38
|
+
const seen = new Map<string, number>()
|
|
39
|
+
let parent: DocSection | undefined
|
|
40
|
+
let current: DocSection | undefined
|
|
41
|
+
let fence = false
|
|
42
|
+
for (const line of markdown.split("\n")) {
|
|
43
|
+
if (/^\s*(```|~~~)/.test(line)) fence = !fence
|
|
44
|
+
const h = fence ? null : line.match(/^(#{2,3}) (.+?)\s*$/)
|
|
45
|
+
if (!h) {
|
|
46
|
+
if (current) current.text += `${line}\n`
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
const base = anchor(h[2]!)
|
|
50
|
+
const n = seen.get(base) ?? 0
|
|
51
|
+
seen.set(base, n + 1)
|
|
52
|
+
current = { id: `${doc}#${n ? `${base}-${n}` : base}`, doc, heading: h[2]!, level: h[1]!.length, text: "" }
|
|
53
|
+
if (current.level === 2) parent = current
|
|
54
|
+
else if (parent) current.parent = parent.id
|
|
55
|
+
out.push(current)
|
|
56
|
+
}
|
|
57
|
+
for (const s of out) s.text = s.text.trim()
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let cache: DocSection[] | null = null
|
|
62
|
+
|
|
63
|
+
export function docSections(): DocSection[] {
|
|
64
|
+
if (cache) return cache
|
|
65
|
+
cache = DOCS.flatMap((doc) => {
|
|
66
|
+
const file = path.join(root, doc)
|
|
67
|
+
return existsSync(file) ? parseSections(doc, readFileSync(file, "utf8")) : []
|
|
68
|
+
})
|
|
69
|
+
return cache
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The section a DocRef names: the first heading in that doc with exactly that text. */
|
|
73
|
+
export function resolveDoc(ref: DocRef, sections = docSections()): DocSection | undefined {
|
|
74
|
+
return sections.find((s) => s.doc === ref.doc && s.heading === ref.heading)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The table of contents: every section's id and heading, nested under its ## section. */
|
|
78
|
+
export function docsContents(sections = docSections()): { id: string; heading: string; sections?: { id: string; heading: string }[] }[] {
|
|
79
|
+
return sections
|
|
80
|
+
.filter((s) => s.level === 2)
|
|
81
|
+
.map((s) => {
|
|
82
|
+
const children = sections.filter((c) => c.parent === s.id).map((c) => ({ id: c.id, heading: c.heading }))
|
|
83
|
+
return { id: s.id, heading: s.heading, ...(children.length ? { sections: children } : {}) }
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The sections with these ids, exactly; a ## section also lists its subsections to read next. Unknown ids are named. */
|
|
88
|
+
export function readDocs(ids: string[], sections = docSections()): { sections: { id: string; heading: string; text: string; subsections?: string[] }[]; unknown?: string[] } {
|
|
89
|
+
const found = []
|
|
90
|
+
const unknown = []
|
|
91
|
+
for (const id of ids) {
|
|
92
|
+
const s = sections.find((x) => x.id === id)
|
|
93
|
+
if (!s) {
|
|
94
|
+
unknown.push(id)
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
const children = sections.filter((c) => c.parent === s.id).map((c) => c.id)
|
|
98
|
+
found.push({ id: s.id, heading: s.heading, text: s.text, ...(children.length ? { subsections: children } : {}) })
|
|
99
|
+
}
|
|
100
|
+
return { sections: found, ...(unknown.length ? { unknown } : {}) }
|
|
101
|
+
}
|