Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion docs/context-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,32 @@ The local context service is an incremental, repository-scoped wrapper around Co

The service serializes rebuilds, coalesces watcher events, exposes invalidated paths and reasons, and labels every query as `current`, `refreshing`, or `stale`. A bounded query may wait for an active update, but old context is never returned as current. Corrupted or incompatible service state is quarantined and rebuilt without changing repository source files.

The initial implementation deliberately does not claim million-line scale. Benchmark fixtures and budgets, process locking, crash-safe atomic writes, and CLI/MCP transports remain required before the long-lived service is considered complete.
The initial implementation uses Chokidar for watching, inspectable JSON graph
artifacts, process locking via `.codedecay/local/context-service.lock`, and
atomic state writes to `.codedecay/local/context-service.json`.

## CLI

```bash
codedecay context serve --format json
codedecay context health --format json
codedecay context query --session-id agent-a --task "fix payouts" --format json
codedecay context rebuild --format json
codedecay context reset --format json
codedecay context stop
```

`serve` is local-only (no network bind by default). It watches the repo,
coalesces invalidations, and updates only invalidated path-linked nodes for
ordinary file edits. Git HEAD/index changes force a full rebuild.

## MCP

Tool: `context_service` with `operation=health|query|rebuild|start`.

## Remaining scale work

Benchmark fixtures and measured 1M+ LOC results remain recommended before
claiming large-monorepo scale. Documented path to 20M LOC: shard by package
workspace, persist per-package incremental graphs, and keep the current
service as the orchestration layer.
127 changes: 123 additions & 4 deletions packages/cli/src/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
createEngineeringTaskContext,
loadImpactGraphArtifact,
persistEngineeringTaskContext,
renderEngineeringTaskContextMarkdown
renderEngineeringTaskContextMarkdown,
startContextService,
stopContextService,
getOrCreateContextService,
writeContextServiceMarker
} from "@submuxhq/codedecay-knowledge";
import { parseContextArgs } from "../parsers/args";
import { loadNormalizedRequirementContext } from "../requirements/context";
Expand All @@ -23,14 +27,19 @@ export interface RunContextCommandDependencies {
}): void;
}

export function runContextCommand(
export async function runContextCommand(
context: CliCommandContext,
dependencies: RunContextCommandDependencies
): void {
): Promise<void> {
const options = parseContextArgs(context.args);
if (options.serviceAction) {
await runContextServiceCommand(context, dependencies, options);
return;
}

const task = options.task?.trim();
if (!task) {
throw new Error("context requires --task <description>.");
throw new Error('context requires --task <description>, or a service subcommand such as "serve".');
}

const cwd = resolve(context.runtimeCwd, options.cwd ?? ".");
Expand Down Expand Up @@ -71,3 +80,113 @@ export function runContextCommand(
runtime: context.runtime
});
}

async function runContextServiceCommand(
context: CliCommandContext,
dependencies: RunContextCommandDependencies,
options: ContextOptions
): Promise<void> {
const cwd = resolve(context.runtimeCwd, options.cwd ?? ".");
const rootDir = dependencies.resolveRepoRoot(cwd, options);
const action = options.serviceAction!;

if (action === "serve") {
const service = await startContextService(rootDir);
const health = service.health();
writeContextServiceMarker(rootDir, health);
dependencies.writeOutput({
cwd,
output: options.output,
rendered:
options.format === "json"
? `${JSON.stringify({ status: "started", health }, null, 2)}\n`
: renderServiceMarkdown("started", health),
runtime: context.runtime
});
// Long-lived local serve: keep the process until interrupt.
await new Promise<void>((resolvePromise) => {
const stop = async () => {
await stopContextService(rootDir);
resolvePromise();
};
process.once("SIGINT", () => void stop());
process.once("SIGTERM", () => void stop());
});
return;
}

if (action === "stop") {
await stopContextService(rootDir);
dependencies.writeOutput({
cwd,
output: options.output,
rendered: options.format === "json" ? `${JSON.stringify({ status: "stopped" }, null, 2)}\n` : "## Context Service\n\nStopped.\n",
runtime: context.runtime
});
return;
}

const service = getOrCreateContextService(rootDir, { acquireLock: action === "rebuild" || action === "reset" });
if (action === "rebuild") {
await service.rebuild("manual-rebuild");
} else if (action === "reset") {
await service.reset();
} else if (action === "query") {
if (service.health().cacheGeneration === 0) {
await service.rebuild("initial");
}
const result = await service.query({
waitBudgetMs: options.waitBudgetMs ?? 250,
sessionId: options.sessionId,
task: options.task
});
writeContextServiceMarker(rootDir, service.health());
dependencies.writeOutput({
cwd,
output: options.output,
rendered: `${JSON.stringify(result, null, 2)}\n`,
runtime: context.runtime
});
return;
}

if (service.health().cacheGeneration === 0 && action === "health") {
await service.rebuild("initial");
}
const health = service.health();
writeContextServiceMarker(rootDir, health);
dependencies.writeOutput({
cwd,
output: options.output,
rendered: options.format === "json" ? `${JSON.stringify(health, null, 2)}\n` : renderServiceMarkdown(action, health),
runtime: context.runtime
});
}

function renderServiceMarkdown(action: string, health: {
repositoryId: string;
freshness: string;
treeFingerprint: string;
cacheGeneration: number;
indexedRevision: string;
lastBuild?: { mode: string; durationMs: number } | undefined;
activeSessions: number;
}): string {
return [
"## CodeDecay Context Service",
"",
`**Action:** ${action}`,
`**Repository:** \`${health.repositoryId}\``,
`**Freshness:** ${health.freshness}`,
`**Revision:** \`${health.indexedRevision}\``,
`**Tree fingerprint:** \`${health.treeFingerprint}\``,
`**Cache generation:** ${health.cacheGeneration}`,
`**Active sessions:** ${health.activeSessions}`,
health.lastBuild
? `**Last build:** ${health.lastBuild.mode} (${health.lastBuild.durationMs}ms)`
: "**Last build:** none",
"",
"Local-only. No model, network, telemetry, install, or project-command calls.",
""
].join("\n");
}
22 changes: 14 additions & 8 deletions packages/cli/src/docs/command-docs/orchestration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,20 @@ export const ORCHESTRATION_COMMAND_DOCS: Record<string, CommandDoc> = {
},
context: {
name: "context",
summary: "Retrieve bounded task-scoped engineering context for user-owned agents.",
usage: ["codedecay context --task <description> [options]"],
summary: "Retrieve bounded task-scoped engineering context or run the local incremental context service.",
usage: [
"codedecay context --task <description> [options]",
"codedecay context serve|health|query|rebuild|reset|stop [options]"
],
description: [
"Build an inspectable task-scoped context packet from existing CodeDecay evidence: requirements, impact graph nodes, route/API evidence, symbols, tests, memory, docs/ADRs, CODEOWNERS, config, package manifests, and verification evidence.",
"Use this before or during implementation when an agent needs the smallest relevant set of repository facts and historical context instead of a whole-repo dump."
"Service subcommands start/query a long-lived local incremental context index (no network bind, no model/network/telemetry calls)."
],
options: [
{ flag: "--task <text>", description: "Required task/change description used for deterministic retrieval" },
{ flag: "--task <text>", description: "Required for one-shot retrieval; optional for service query sessions" },
{ flag: "serve|health|query|rebuild|reset|stop", description: "Local context service operations" },
{ flag: "--session-id <id>", description: "Isolate task state for concurrent agent sessions over a shared index" },
{ flag: "--wait-budget-ms <n>", description: "Max wait for an in-flight index update during query" },
{ flag: "--requirements <path>", description: "Optional repo-local JSON, YAML, or Markdown requirements artifact" },
{ flag: "--base <ref>", description: "Base git ref to compare from when a diff should influence context" },
{ flag: "--head <ref>", description: "Head git ref to compare to when a diff should influence context" },
Expand All @@ -60,13 +66,13 @@ export const ORCHESTRATION_COMMAND_DOCS: Record<string, CommandDoc> = {
],
examples: [
"codedecay context --task \"Allow finance admins to retry failed payouts\" --format markdown",
"codedecay context --task \"Change payout retry formatting\" --base main --head HEAD --format json",
"codedecay context --task \"Add billing export\" --requirements .codedecay/requirements.yml --max-nodes 16"
"codedecay context serve --format json",
"codedecay context query --session-id agent-a --task \"fix payouts\" --format json",
"codedecay context health --format json"
],
notes: [
"Context retrieval is deterministic lexical plus graph-neighbor ranking. It does not call models, embeddings, hosted services, network APIs, or telemetry.",
"The command refreshes local analysis artifacts but does not execute configured project commands or tool adapters.",
"Memory and documents are shown with trust class and limitations; they cannot become trusted proof without current-revision evidence.",
"The local context service reuses knowledge-graph artifacts, process-locks updates, and never returns stale evidence labeled as current.",
"The inspectable artifact is written to `.codedecay/local/task-context.json`."
]
},
Expand Down
21 changes: 20 additions & 1 deletion packages/cli/src/parsers/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,21 @@ export function parseContextArgs(args: string[]): ContextOptions {
format: "markdown"
};
const valueParsers = createContextValueParsers(options);
let index = 0;
const first = args[0];
if (
first === "serve" ||
first === "health" ||
first === "query" ||
first === "rebuild" ||
first === "reset" ||
first === "stop"
) {
options.serviceAction = first;
index = 1;
}

for (let index = 0; index < args.length; index += 1) {
for (; index < args.length; index += 1) {
const arg = args[index];

if (!arg) {
Expand Down Expand Up @@ -62,6 +75,12 @@ function createContextValueParsers(options: ContextOptions): Record<string, (val
},
"--task": (value) => {
options.task = value;
},
"--session-id": (value) => {
options.sessionId = value;
},
"--wait-budget-ms": (value) => {
options.waitBudgetMs = parsePositiveInteger(value, "--wait-budget-ms");
}
};
}
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,7 @@ export interface ContextOptions {
task?: string | undefined;
requirements?: string | undefined;
maxNodes?: number | undefined;
serviceAction?: "serve" | "health" | "query" | "rebuild" | "reset" | "stop" | undefined;
sessionId?: string | undefined;
waitBudgetMs?: number | undefined;
}
20 changes: 20 additions & 0 deletions packages/cli/test/context-service-args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { parseContextArgs } from "../src/parsers/context";

describe("parseContextArgs service subcommands", () => {
it("parses serve/health/query with session options", () => {
expect(parseContextArgs(["serve", "--format", "json"])).toMatchObject({
serviceAction: "serve",
format: "json"
});
expect(
parseContextArgs(["query", "--session-id", "agent-a", "--task", "fix payouts", "--wait-budget-ms", "100"])
).toMatchObject({
serviceAction: "query",
sessionId: "agent-a",
task: "fix payouts",
waitBudgetMs: 100
});
expect(parseContextArgs(["health"])).toMatchObject({ serviceAction: "health", format: "markdown" });
});
});
13 changes: 13 additions & 0 deletions packages/knowledge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ export {
CONTEXT_SERVICE_STATE_PATH,
LocalContextService
} from "./service";
export { CONTEXT_SERVICE_LOCK_PATH, acquireContextServiceLock } from "./service-lock";
export { createDefaultContextServiceBuild } from "./service-build";
export type { ContextServiceBuildMode, ContextServiceBuildStats, DefaultContextServiceBuild } from "./service-build";
export type { ContextServiceLockHandle } from "./service-lock";
export {
clearContextServiceMarker,
getContextService,
getOrCreateContextService,
readContextServiceMarker,
startContextService,
stopContextService,
writeContextServiceMarker
} from "./service-runtime";
export {
SERVICE_TOPOLOGY_ARTIFACT_PATH,
loadServiceTopologyManifest,
Expand Down
Loading
Loading