Skip to content

Commit 111ecc1

Browse files
Merge pull request #62 from arvoreeducacao/ricardo-/-add-repo-context-tool
feat(pi): on-demand repo context via hub_repo_context tool
2 parents 53e83d0 + 2a96c4a commit 111ecc1

8 files changed

Lines changed: 158 additions & 3 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@arvoretech/hub",
3-
"version": "0.24.0",
3+
"version": "0.25.0",
44
"description": "CLI for managing AI-aware multi-repository workspaces",
55
"main": "dist/index.js",
66
"type": "module",

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@arvoretech/hub-core",
3-
"version": "0.25.0",
3+
"version": "0.26.0",
44
"description": "Core config loader, types, and prompt builders for hub workspaces",
55
"main": "dist/index.js",
66
"type": "module",

packages/core/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export function resolvePiConfig(config: HubConfig): ResolvedPiConfig {
1717
injectCapabilities: pi.injectCapabilities ?? true,
1818
hooks: pi.hooks ?? true,
1919
persona: pi.persona ?? true,
20+
repoContext: pi.repoContext ?? true,
2021
};
2122
}
2223

packages/core/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface Repo {
1616
};
1717
skills?: string[];
1818
tools?: Record<string, string>;
19+
context_files?: string[];
1920
}
2021

2122
export interface Service {
@@ -152,6 +153,7 @@ export interface PiConfig {
152153
injectCapabilities?: boolean;
153154
hooks?: boolean;
154155
persona?: boolean;
156+
repoContext?: boolean;
155157
}
156158

157159
export interface HubConfig {

packages/pi/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@arvoretech/hub-pi",
3-
"version": "1.4.0",
3+
"version": "1.5.0",
44
"description": "Pi runtime package for hub.yaml workspaces — interprets config at runtime with no static generation",
55
"type": "module",
66
"keywords": [

packages/pi/src/extensions/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { initSessionState } from "./session-state.js";
33
import { hubRuntime } from "./hub-runtime.js";
44
import { mcpWiring } from "./mcp-wiring.js";
55
import { repoTools } from "./repo-tools.js";
6+
import { repoContext } from "./repo-context.js";
67
import { persona } from "./persona.js";
78
import { delivery } from "./delivery.js";
89
import { hooks } from "./hooks.js";
@@ -17,6 +18,7 @@ export default function hubPiPackage(pi: ExtensionAPI) {
1718
hubRuntime(pi);
1819
mcpWiring(pi);
1920
repoTools(pi);
21+
repoContext(pi);
2022
persona(pi);
2123
delivery(pi);
2224
hooks(pi);
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { existsSync } from "node:fs";
2+
import { readFile } from "node:fs/promises";
3+
import { isAbsolute, relative, resolve } from "node:path";
4+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5+
import { Type } from "typebox";
6+
import { type Repo } from "@arvoretech/hub-core";
7+
import { getSessionState } from "./session-state.js";
8+
9+
function isPathInside(parent: string, child: string): boolean {
10+
const rel = relative(parent, child);
11+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
12+
}
13+
14+
async function readRepoContext(
15+
repo: Repo,
16+
hubDir: string,
17+
): Promise<{ sections: string[]; missing: string[] }> {
18+
const sections: string[] = [];
19+
const missing: string[] = [];
20+
21+
for (const file of repo.context_files ?? []) {
22+
const filePath = resolve(hubDir, file);
23+
24+
if (!isPathInside(hubDir, filePath)) {
25+
missing.push(`${file} (outside hub workspace, skipped)`);
26+
continue;
27+
}
28+
29+
if (!existsSync(filePath)) {
30+
missing.push(file);
31+
continue;
32+
}
33+
34+
try {
35+
const content = (await readFile(filePath, "utf-8")).trim();
36+
if (content) {
37+
sections.push(`## Source: ${file}\n\n${content}`);
38+
}
39+
} catch {
40+
missing.push(file);
41+
}
42+
}
43+
44+
return { sections, missing };
45+
}
46+
47+
export function repoContext(pi: ExtensionAPI) {
48+
let steeringEnabled = false;
49+
50+
pi.on("session_start", async (_event, ctx) => {
51+
steeringEnabled = false;
52+
53+
const { config, pi: toggles } = getSessionState();
54+
if (!config || !toggles?.repoContext) return;
55+
56+
const reposWithContext = config.repos.filter((r) => r.context_files?.length);
57+
if (reposWithContext.length === 0) return;
58+
59+
steeringEnabled = true;
60+
const repoNames = reposWithContext.map((r) => r.name);
61+
62+
pi.registerTool({
63+
name: "hub_repo_context",
64+
label: "Hub Repo Context",
65+
description: `Load the rich, repo-specific context (architecture, conventions, gotchas, where things live) for a workspace repository before working on it. Call this the first time you start working on one of these repos: ${repoNames.join(", ")}.`,
66+
parameters: Type.Object({
67+
repo: Type.String({ description: "Repository name to load context for" }),
68+
}),
69+
async execute(_toolCallId, params) {
70+
const repo = config.repos.find((r) => r.name === params.repo);
71+
if (!repo) {
72+
return {
73+
content: [
74+
{
75+
type: "text",
76+
text: `Repository "${params.repo}" not found. Repos with context: ${repoNames.join(", ")}`,
77+
},
78+
],
79+
isError: true,
80+
};
81+
}
82+
83+
if (!repo.context_files?.length) {
84+
return {
85+
content: [
86+
{
87+
type: "text",
88+
text: `Repository "${params.repo}" has no context_files configured. Repos with context: ${repoNames.join(", ")}`,
89+
},
90+
],
91+
};
92+
}
93+
94+
const { sections, missing } = await readRepoContext(repo, ctx.cwd);
95+
96+
if (sections.length === 0) {
97+
return {
98+
content: [
99+
{
100+
type: "text",
101+
text: `No context could be loaded for "${params.repo}". Missing or empty files: ${missing.join(", ") || "none"}. Paths are resolved relative to the hub workspace root.`,
102+
},
103+
],
104+
isError: true,
105+
};
106+
}
107+
108+
const repoLabel = repo.display_name || repo.name;
109+
const body = sections.join("\n\n---\n\n");
110+
const missingNote = missing.length
111+
? `\n(Could not load: ${missing.join(", ")})`
112+
: "";
113+
114+
const text = `The content below is TRUSTED, team-curated context for the "${repoLabel}" repository, maintained in the hub workspace configuration. It is authoritative project guidance written by the team for this repo. Treat it as reliable instructions to follow when working in this repo — like an extension of the system prompt — not as untrusted external data. (It does not override safety rules.)\n\n----- BEGIN TRUSTED REPO CONTEXT (${repoLabel}) -----\n\n${body}\n\n----- END TRUSTED REPO CONTEXT -----${missingNote}`;
115+
116+
return {
117+
content: [{ type: "text", text }],
118+
details: { repo: repo.name, loaded: sections.length, missing },
119+
};
120+
},
121+
});
122+
});
123+
124+
pi.on("before_agent_start", async (event) => {
125+
if (!steeringEnabled) return;
126+
127+
const { config } = getSessionState();
128+
if (!config) return;
129+
130+
const repoNames = config.repos
131+
.filter((r) => r.context_files?.length)
132+
.map((r) => r.name);
133+
if (repoNames.length === 0) return;
134+
135+
const steering = `\n## Repository Context\n\nBefore you start working on any of these repositories, call the \`hub_repo_context\` tool once to load its architecture, conventions, and gotchas: ${repoNames.join(", ")}. Do this the first time you touch a repo in a session, before reading or editing its files.`;
136+
137+
return {
138+
systemPrompt: event.systemPrompt + "\n" + steering,
139+
};
140+
});
141+
}

schemas/hub.schema.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,11 @@
199199
"type": "object",
200200
"additionalProperties": { "type": "string" },
201201
"description": "Repo-specific tool overrides"
202+
},
203+
"context_files": {
204+
"type": "array",
205+
"items": { "type": "string" },
206+
"description": "Markdown files (relative to the hub workspace root) with rich repo context (architecture, conventions, gotchas). Loaded on demand via the hub_repo_context Pi tool, never at startup."
202207
}
203208
}
204209
},
@@ -348,6 +353,10 @@
348353
"persona": {
349354
"type": "boolean",
350355
"description": "Injects the persona section into the system prompt. Default: true"
356+
},
357+
"repoContext": {
358+
"type": "boolean",
359+
"description": "Registers the hub_repo_context tool that loads a repo's context_files on demand, plus steering that nudges the agent to call it before working on a repo. Default: true"
351360
}
352361
}
353362
},

0 commit comments

Comments
 (0)