diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 74b11a9..7ee9f86 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -33,7 +33,7 @@ - [ ] Runtime source set is `FPF-spec.md` only — no additional corpora added - [ ] No vector database or remote indexing introduced - [ ] No Python code added -- [ ] MCP tool contracts stay in `src/mcp/tool-contracts.ts` +- [ ] MCP tool contracts stay in `src/mcp/public-contracts.ts` and `src/mcp/expert-contracts.ts` ## Agent metadata diff --git a/README.md b/README.md index 7eba43a..f7eda67 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,8 @@ Smoke-test the local full-surface runtime before using expert tools or deploying ```bash bun run cli -- status -bun run cli -- lm-check --timeout-ms 60000 -bun run cli -- lm-check --base-url http://localhost:1234 --api-style chat --api-key "$FPF_LOCAL_LLM_API_KEY" --timeout-ms 60000 +bun run src/runtime/synthesizer/lm-check.ts --timeout-ms 60000 +bun run src/runtime/synthesizer/lm-check.ts --base-url http://localhost:1234 --api-style chat --api-key "$FPF_LOCAL_LLM_API_KEY" --timeout-ms 60000 bun run cli -- refresh bun run cli -- query --question "What is U.BoundedContext?" --mode verbose bun run cli -- trace --question "How do U.RoleAssignment and U.BoundedContext connect?" --mode proof @@ -221,7 +221,8 @@ Call trace_fpf_path with: ## Runtime surfaces -- `src/mcp/tool-contracts.ts`: Zod-authored MCP input and output contracts +- `src/mcp/public-contracts.ts`: Zod-authored MCP input/output contracts for the 3 public tools +- `src/mcp/expert-contracts.ts`: Zod-authored MCP input/output contracts for the 6 expert tools - `src/mcp/tools.ts`: canonical snake_case MCP tools and `ask_fpf` - `src/mastra/mcp/server.ts`: MCPServer definitions (public and full surfaces) - `src/mastra/index.ts`: Mastra instance registration diff --git a/src/cli.ts b/src/cli.ts index 1ee9329..c78b250 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,8 +1,4 @@ import { getRuntimeLogger } from './logging/runtime-logger.js'; -import { - normalizeLmStudioApiStyle, - runLmStudioHealthCheck, -} from './runtime/lm-studio-synthesizer.js'; import { FpfRuntime } from './runtime/runtime.js'; import type { AnswerMode } from './runtime/types.js'; @@ -34,9 +30,6 @@ try { case 'trace': await runTrace(args.slice(1)); break; - case 'lm-check': - await runLmCheck(args.slice(1)); - break; default: printHelp(); process.exitCode = command === 'help' ? 0 : 1; @@ -109,24 +102,6 @@ async function runTrace(commandArgs: string[]): Promise { await print(runtime.trace(question, mode, forceRefresh, sessionId)); } -async function runLmCheck(commandArgs: string[]): Promise { - const timeoutMsRaw = value(commandArgs, '--timeout-ms'); - const timeoutMs = timeoutMsRaw ? Number(timeoutMsRaw) : undefined; - const apiStyle = normalizeLmStudioApiStyle(value(commandArgs, '--api-style')); - - await print( - runLmStudioHealthCheck({ - baseUrl: value(commandArgs, '--base-url'), - model: value(commandArgs, '--model'), - apiStyle, - apiKey: value(commandArgs, '--api-key') ?? process.env.FPF_LOCAL_LLM_API_KEY, - timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined, - systemPrompt: value(commandArgs, '--system-prompt'), - input: value(commandArgs, '--input'), - }), - ); -} - function flag(argsList: string[], flagName: string): boolean { return argsList.includes(flagName); } @@ -152,6 +127,6 @@ function printHelp(): void { bun run cli -- inspect --selector "A.1.1" [--kind auto|id|route|lexeme] [--force] bun run cli -- read-doc --selector "A.1.1" [--kind auto|id|route|lexeme] [--force] bun run cli -- trace --question "How do routes work?" [--mode compact|verbose|proof] [--session s1] [--force] - bun run cli -- lm-check [--base-url http://localhost:1234/v1] [--model google/gemma-4-31b] [--api-style responses|chat|lmstudio_chat] [--api-key ] [--timeout-ms 60000] + bun run src/runtime/synthesizer/lm-check.ts [--base-url http://localhost:1234/v1] [--model google/gemma-4-31b] [--api-style responses|chat|chat_completions|lmstudio_chat] [--api-key ] [--timeout-ms 60000] `); } diff --git a/src/mcp/tool-contracts.ts b/src/mcp/expert-contracts.ts similarity index 68% rename from src/mcp/tool-contracts.ts rename to src/mcp/expert-contracts.ts index 3cbe35f..4d0e9f2 100644 --- a/src/mcp/tool-contracts.ts +++ b/src/mcp/expert-contracts.ts @@ -1,15 +1,14 @@ import { z } from 'zod'; -export const answerModeSchema = z.enum(['compact', 'verbose', 'proof']); +import { + answerModeSchema, + answerStatusSchema, + baseQueryInputSchema, + snapshotWithRebuildSchema, +} from './public-contracts.js'; + export const nodeKindSchema = z.enum(['pattern', 'route', 'lexeme']); export const selectorKindSchema = z.enum(['auto', 'id', 'route', 'lexeme']); -export const answerStatusSchema = z.enum([ - 'ok', - 'not_found', - 'ambiguous', - 'unsupported', - 'stale_snapshot_prevented', -]); export const anchorRoleSchema = z.enum([ 'definition', 'solution', @@ -26,14 +25,6 @@ export const buildReasonSchema = z.enum([ 'source_hash_changed', 'snapshot_current', ]); -export const observabilityFormatSchema = z.enum(['flat', 'tree', 'normalized']); -export const observabilityLogLevelSchema = z.enum([ - 'debug', - 'info', - 'warn', - 'error', - 'fatal', -]); export const resolvedAsSchema = z.enum(['id', 'route', 'lexeme', 'not_found']); export const inspectStatusSchema = z.enum(['ok', 'not_found']); export const frontierOriginSchema = z.enum([ @@ -45,15 +36,6 @@ export const frontierOriginSchema = z.enum([ 'session_context', ]); export const expandedCitationStatusSchema = z.enum(['ok', 'not_found']); -export const lmStudioApiStyleSchema = z.enum(['responses', 'lmstudio_chat', 'chat_completions']); - -export const relationEdgeSchema = z - .object({ - from: z.string(), - relation: z.string(), - to: z.string(), - }) - .strict(); export const inspectNeighborSchema = z .object({ @@ -110,14 +92,6 @@ export const compiledNodeSchema = z }) .strict(); -export const snapshotWithRebuildSchema = z - .object({ - sourceHash: z.string(), - builtAt: z.string(), - rebuilt: z.boolean(), - }) - .strict(); - export const buildAuditSchema = z .object({ sourcePath: z.string(), @@ -154,78 +128,6 @@ export const buildAuditSchema = z }) .strict(); -export const queryResultSchema = z - .object({ - mode: answerModeSchema, - question: z.string(), - answer: z.string(), - ids: z.array(z.string()), - relations: z.array(relationEdgeSchema), - constraints: z.array(z.string()), - citations: z.array(z.string()), - confidence: z.number(), - gaps: z.array(z.string()), - snapshot: snapshotWithRebuildSchema, - status: answerStatusSchema, - groundingChain: z.array(z.string()).optional(), - }) - .strict(); - -export const askFpfResultSchema = z - .object({ - question: z.string(), - mode: answerModeSchema, - markdown: z.string(), - ids: z.array(z.string()), - citations: z.array(z.string()), - constraints: z.array(z.string()), - gaps: z.array(z.string()), - confidence: z.number(), - status: answerStatusSchema, - snapshot: snapshotWithRebuildSchema, - groundingChain: z.array(z.string()).optional(), - }) - .strict(); - -export const runtimeStatusSchema = z - .object({ - sourcePath: z.string(), - sourceHash: z.string().optional(), - builtAt: z.string().optional(), - snapshotExists: z.boolean(), - currentSourceHash: z.string(), - fresh: z.boolean(), - compilerMode: z.literal('local_vectorless'), - artifacts: z.record(z.string(), z.boolean()), - synthesizer: z - .object({ - configured: z.boolean(), - provider: z.string().optional(), - model: z.string().optional(), - baseUrl: z.string().optional(), - apiStyle: lmStudioApiStyleSchema.optional(), - }) - .strict(), - observability: z - .object({ - configured: z.boolean(), - filePath: z.string(), - format: observabilityFormatSchema, - includeInternalSpans: z.boolean(), - logLevel: observabilityLogLevelSchema, - excludeModelChunks: z.boolean(), - }) - .strict(), - sessionCache: z - .object({ - enabled: z.boolean(), - maxSessions: z.number(), - activeSessions: z.number(), - }) - .strict(), - }) - .strict(); - export const traceDetectedSchema = z .object({ ids: z.array(z.string()), @@ -387,25 +289,7 @@ export const refreshFpfIndexInputSchema = z }) .strict(); -export const queryFpfSpecInputSchema = z - .object({ - question: z.string().min(1), - mode: answerModeSchema.optional(), - forceRefresh: z.boolean().optional(), - sessionId: z.string().min(1).optional(), - }) - .strict(); - -export const askFpfInputSchema = z - .object({ - question: z.string().min(1), - mode: answerModeSchema.optional(), - forceRefresh: z.boolean().optional(), - sessionId: z.string().min(1).optional(), - }) - .strict(); - -export const getFpfIndexStatusInputSchema = z.object({}).strict(); +export const traceFpfPathInputSchema = baseQueryInputSchema; export const inspectFpfNodeInputSchema = z .object({ @@ -437,19 +321,7 @@ export const expandFpfCitationsInputSchema = z }) .strict(); -export const traceFpfPathInputSchema = z - .object({ - question: z.string().min(1), - mode: answerModeSchema.optional(), - forceRefresh: z.boolean().optional(), - sessionId: z.string().min(1).optional(), - }) - .strict(); - export type RefreshFpfIndexInput = z.infer; -export type QueryFpfSpecInput = z.infer; -export type AskFpfInput = z.infer; -export type GetFpfIndexStatusInput = z.infer; export type InspectFpfNodeInput = z.infer; export type ReadFpfDocInput = z.infer; export type InspectFpfAnchorInput = z.infer; diff --git a/src/mcp/public-contracts.ts b/src/mcp/public-contracts.ts new file mode 100644 index 0000000..7e25371 --- /dev/null +++ b/src/mcp/public-contracts.ts @@ -0,0 +1,126 @@ +import { z } from 'zod'; + +export const answerModeSchema = z.enum(['compact', 'verbose', 'proof']); +export const answerStatusSchema = z.enum([ + 'ok', + 'not_found', + 'ambiguous', + 'unsupported', + 'stale_snapshot_prevented', +]); +export const lmStudioApiStyleSchema = z.enum(['responses', 'lmstudio_chat', 'chat_completions']); +export const observabilityFormatSchema = z.enum(['flat', 'tree', 'normalized']); +export const observabilityLogLevelSchema = z.enum([ + 'debug', + 'info', + 'warn', + 'error', + 'fatal', +]); + +export const relationEdgeSchema = z + .object({ + from: z.string(), + relation: z.string(), + to: z.string(), + }) + .strict(); + +export const snapshotWithRebuildSchema = z + .object({ + sourceHash: z.string(), + builtAt: z.string(), + rebuilt: z.boolean(), + }) + .strict(); + +export const queryResultSchema = z + .object({ + mode: answerModeSchema, + question: z.string(), + answer: z.string(), + ids: z.array(z.string()), + relations: z.array(relationEdgeSchema), + constraints: z.array(z.string()), + citations: z.array(z.string()), + confidence: z.number(), + gaps: z.array(z.string()), + snapshot: snapshotWithRebuildSchema, + status: answerStatusSchema, + groundingChain: z.array(z.string()).optional(), + }) + .strict(); + +export const askFpfResultSchema = z + .object({ + question: z.string(), + mode: answerModeSchema, + markdown: z.string(), + ids: z.array(z.string()), + citations: z.array(z.string()), + constraints: z.array(z.string()), + gaps: z.array(z.string()), + confidence: z.number(), + status: answerStatusSchema, + snapshot: snapshotWithRebuildSchema, + groundingChain: z.array(z.string()).optional(), + }) + .strict(); + +export const runtimeStatusSchema = z + .object({ + sourcePath: z.string(), + sourceHash: z.string().optional(), + builtAt: z.string().optional(), + snapshotExists: z.boolean(), + currentSourceHash: z.string(), + fresh: z.boolean(), + compilerMode: z.literal('local_vectorless'), + artifacts: z.record(z.string(), z.boolean()), + synthesizer: z + .object({ + configured: z.boolean(), + provider: z.string().optional(), + model: z.string().optional(), + baseUrl: z.string().optional(), + apiStyle: lmStudioApiStyleSchema.optional(), + }) + .strict(), + observability: z + .object({ + configured: z.boolean(), + filePath: z.string(), + format: observabilityFormatSchema, + includeInternalSpans: z.boolean(), + logLevel: observabilityLogLevelSchema, + excludeModelChunks: z.boolean(), + }) + .strict(), + sessionCache: z + .object({ + enabled: z.boolean(), + maxSessions: z.number(), + activeSessions: z.number(), + }) + .strict(), + }) + .strict(); + +export const baseQueryInputSchema = z + .object({ + question: z.string().min(1), + mode: answerModeSchema.optional(), + forceRefresh: z.boolean().optional(), + sessionId: z.string().min(1).optional(), + }) + .strict(); + +export const queryFpfSpecInputSchema = baseQueryInputSchema; + +export const askFpfInputSchema = baseQueryInputSchema; + +export const getFpfIndexStatusInputSchema = z.object({}).strict(); + +export type QueryFpfSpecInput = z.infer; +export type AskFpfInput = z.infer; +export type GetFpfIndexStatusInput = z.infer; diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 9375b7b..4af0eed 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -5,23 +5,25 @@ import type { AnswerMode, AskFpfResult, QueryResult } from '../runtime/types.js' import { askFpfInputSchema, askFpfResultSchema, + getFpfIndexStatusInputSchema, + queryFpfSpecInputSchema, + queryResultSchema, + runtimeStatusSchema, +} from './public-contracts.js'; +import { + buildAuditSchema, expandCitationsResultSchema, expandFpfCitationsInputSchema, - getFpfIndexStatusInputSchema, inspectAnchorResultSchema, inspectFpfAnchorInputSchema, inspectFpfNodeInputSchema, inspectResultSchema, - queryFpfSpecInputSchema, - queryResultSchema, readDocResultSchema, readFpfDocInputSchema, refreshFpfIndexInputSchema, - runtimeStatusSchema, - buildAuditSchema, traceFpfPathInputSchema, traceResultSchema, -} from './tool-contracts.js'; +} from './expert-contracts.js'; const runtime = new FpfRuntime(); const DEFAULT_QUERY_MODE: AnswerMode = 'verbose'; diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index 54c1db7..2528079 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -8,7 +8,7 @@ import { DEFAULT_SOURCE_PATH, } from './constants.js'; import { compileFpfSource } from './compiler.js'; -import { createSynthesizerFromEnv } from './lm-studio-synthesizer.js'; +import { createSynthesizerFromEnv } from './synthesizer/index.js'; import { QueryEngine } from './query-engine.js'; import { resolveRuntimePath } from './path-resolution.js'; import { diff --git a/src/runtime/ai-trace-log.ts b/src/runtime/synthesizer/ai-trace-log.ts similarity index 94% rename from src/runtime/ai-trace-log.ts rename to src/runtime/synthesizer/ai-trace-log.ts index a2cd770..623f489 100644 --- a/src/runtime/ai-trace-log.ts +++ b/src/runtime/synthesizer/ai-trace-log.ts @@ -1,8 +1,8 @@ import { randomUUID } from 'node:crypto'; import { appendFile } from 'node:fs/promises'; -import { resolveLogPath } from '../logging/file-paths.js'; -import type { AnswerMode, TraceResult } from './types.js'; +import { resolveLogPath } from '../../logging/file-paths.js'; +import type { AnswerMode, TraceResult } from '../types.js'; import type { LmStudioApiStyle } from './lm-studio-synthesizer.js'; export interface AiTraceRequestLog { diff --git a/src/runtime/synthesizer/index.ts b/src/runtime/synthesizer/index.ts new file mode 100644 index 0000000..d96375c --- /dev/null +++ b/src/runtime/synthesizer/index.ts @@ -0,0 +1 @@ +export { createSynthesizerFromEnv } from './lm-studio-synthesizer.js'; diff --git a/src/runtime/synthesizer/lm-check.ts b/src/runtime/synthesizer/lm-check.ts new file mode 100644 index 0000000..3128353 --- /dev/null +++ b/src/runtime/synthesizer/lm-check.ts @@ -0,0 +1,36 @@ +import { + normalizeLmStudioApiStyle, + runLmStudioHealthCheck, +} from './lm-studio-synthesizer.js'; + +const args = process.argv.slice(2); + +try { + const timeoutMsRaw = value(args, '--timeout-ms'); + const timeoutMs = timeoutMsRaw ? Number(timeoutMsRaw) : undefined; + const apiStyle = normalizeLmStudioApiStyle(value(args, '--api-style')); + + const result = await runLmStudioHealthCheck({ + baseUrl: value(args, '--base-url'), + model: value(args, '--model'), + apiStyle, + apiKey: value(args, '--api-key') ?? process.env.FPF_LOCAL_LLM_API_KEY, + timeoutMs: Number.isFinite(timeoutMs) ? timeoutMs : undefined, + systemPrompt: value(args, '--system-prompt'), + input: value(args, '--input'), + }); + + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`lm-check failed: ${message}\n`); + process.exitCode = 1; +} + +function value(argsList: string[], flagName: string): string | undefined { + const index = argsList.indexOf(flagName); + if (index < 0) { + return undefined; + } + return argsList[index + 1]; +} diff --git a/src/runtime/lm-studio-synthesizer.ts b/src/runtime/synthesizer/lm-studio-synthesizer.ts similarity index 99% rename from src/runtime/lm-studio-synthesizer.ts rename to src/runtime/synthesizer/lm-studio-synthesizer.ts index 729f672..e990d64 100644 --- a/src/runtime/lm-studio-synthesizer.ts +++ b/src/runtime/synthesizer/lm-studio-synthesizer.ts @@ -1,13 +1,13 @@ import { SpanType } from '@mastra/core/observability'; -import { withRuntimeSpan } from '../observability/runtime-observability.js'; +import { withRuntimeSpan } from '../../observability/runtime-observability.js'; import type { AnswerSlice, AnswerSynthesizerInput, AnswerSynthesizerOutput, LocalAnswerSynthesizer, LocalAnswerSynthesizerInfo, -} from './types.js'; +} from '../types.js'; import { createAiTraceRecorder } from './ai-trace-log.js'; export type LmStudioApiStyle = 'responses' | 'lmstudio_chat' | 'chat_completions'; diff --git a/tests/fpf-spec-runtime.test.ts b/tests/fpf-spec-runtime.test.ts index e0e0c7f..baeb8c5 100644 --- a/tests/fpf-spec-runtime.test.ts +++ b/tests/fpf-spec-runtime.test.ts @@ -10,10 +10,12 @@ import { } from '../src/mcp/tools.js'; import { askFpfInputSchema, - expandFpfCitationsInputSchema, getFpfIndexStatusInputSchema, queryFpfSpecInputSchema, -} from '../src/mcp/tool-contracts.js'; +} from '../src/mcp/public-contracts.js'; +import { + expandFpfCitationsInputSchema, +} from '../src/mcp/expert-contracts.js'; import { ARTIFACT_FILENAMES } from '../src/runtime/constants.js'; import { FpfRuntime } from '../src/runtime/runtime.js'; diff --git a/tests/lm-studio-synthesizer.test.ts b/tests/lm-studio-synthesizer.test.ts index 382d976..ff827ca 100644 --- a/tests/lm-studio-synthesizer.test.ts +++ b/tests/lm-studio-synthesizer.test.ts @@ -11,7 +11,7 @@ import { DEFAULT_LM_STUDIO_MODEL, LmStudioSynthesizer, runLmStudioHealthCheck, -} from '../src/runtime/lm-studio-synthesizer.js'; +} from '../src/runtime/synthesizer/lm-studio-synthesizer.js'; import { FpfRuntime } from '../src/runtime/runtime.js'; describe('LmStudioSynthesizer', () => {