@praneeth_54/agentdoctor 3.0.2 → 3.0.3

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.
@@ -0,0 +1,296 @@
1
+ import path from "node:path";
2
+ import { loadLatestBrain, searchBrain } from "../../core/brain-cli/service.js";
3
+ const QUERY_STOPWORDS = new Set([
4
+ "a",
5
+ "an",
6
+ "the",
7
+ "is",
8
+ "are",
9
+ "was",
10
+ "were",
11
+ "what",
12
+ "which",
13
+ "who",
14
+ "whom",
15
+ "whose",
16
+ "where",
17
+ "when",
18
+ "why",
19
+ "how",
20
+ "does",
21
+ "do",
22
+ "did",
23
+ "can",
24
+ "could",
25
+ "should",
26
+ "would",
27
+ "will",
28
+ "of",
29
+ "to",
30
+ "for",
31
+ "in",
32
+ "on",
33
+ "at",
34
+ "by",
35
+ "with",
36
+ "from",
37
+ "about",
38
+ "into",
39
+ "this",
40
+ "that",
41
+ "these",
42
+ "those",
43
+ "my",
44
+ "our",
45
+ "your",
46
+ "its",
47
+ "project",
48
+ "projects",
49
+ "module",
50
+ "modules",
51
+ "component",
52
+ "components",
53
+ "belong",
54
+ "belongs",
55
+ "provide",
56
+ "provides",
57
+ "explain",
58
+ "architecture",
59
+ "structure",
60
+ "overview",
61
+ "system",
62
+ "please",
63
+ "tell",
64
+ "me",
65
+ "describe",
66
+ ]);
67
+ const PATHISH = /(?:^|[\s(])((?:[\w.-]+\/)+[\w.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|php|go|rs|java|kt|dart|rb|cs|md|json|yml|yaml|toml))(?:$|[\s),:;])/i;
68
+ function scoreBrainHitText(text, kind) {
69
+ let score = 0;
70
+ if (/PROVIDES|CONTAINS/i.test(text))
71
+ score += 0.45;
72
+ if (/\bmodule\b/i.test(text) || kind === "component")
73
+ score += 0.25;
74
+ if (/is-domain/i.test(text))
75
+ score += 0.1;
76
+ if (/has-risk/i.test(text))
77
+ score -= 0.35;
78
+ if (/unclear-ownership/i.test(text))
79
+ score -= 0.15;
80
+ return score;
81
+ }
82
+ /**
83
+ * AgentDoctor control-plane under `.agentdoctor/**` — usable internally,
84
+ * never as ordinary VERIFIED application source evidence.
85
+ */
86
+ export function isControlPlaneRelativePath(relativePath) {
87
+ const norm = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
88
+ return norm === ".agentdoctor" || norm.startsWith(".agentdoctor/");
89
+ }
90
+ /** Significant tokens from a natural-language project question. */
91
+ export function extractQueryTerms(query) {
92
+ const raw = query
93
+ .split(/[^a-zA-Z0-9_./-]+/)
94
+ .map((t) => t.trim())
95
+ .filter((t) => t.length >= 2 && !QUERY_STOPWORDS.has(t.toLowerCase()));
96
+ const seen = new Set();
97
+ const out = [];
98
+ for (const t of raw) {
99
+ const key = t.toLowerCase();
100
+ if (seen.has(key))
101
+ continue;
102
+ seen.add(key);
103
+ out.push(t);
104
+ }
105
+ return out;
106
+ }
107
+ function looksLikeRepoRelativePath(locator) {
108
+ const n = locator.replace(/\\/g, "/");
109
+ if (!n || n.includes("\0") || path.isAbsolute(n) || n.split("/").includes(".."))
110
+ return false;
111
+ if (isControlPlaneRelativePath(n))
112
+ return false;
113
+ if (n.includes(".private/") || n.startsWith("AgentDoctorOS/"))
114
+ return false;
115
+ // Prefer file-like or directory module paths (contain a slash or extension).
116
+ return n.includes("/") || /\.\w{1,8}$/.test(n);
117
+ }
118
+ function collectPathsFromHitText(text, into) {
119
+ let match;
120
+ const re = new RegExp(PATHISH.source, "gi");
121
+ while ((match = re.exec(text)) !== null) {
122
+ const p = match[1]?.replace(/\\/g, "/");
123
+ if (p && looksLikeRepoRelativePath(p))
124
+ into.add(p);
125
+ }
126
+ }
127
+ /**
128
+ * Search the latest usable Project Brain for query-relevant claims/components.
129
+ * Brain hits are INFERRED structural evidence — never auto-upgraded to VERIFIED.
130
+ * Stale ownership-boundary brains load as null (same as CLI brain status).
131
+ */
132
+ export async function collectBrainEvidenceForQuery(root, query, options) {
133
+ const maxHits = options?.maxHits ?? 24;
134
+ const limitations = [];
135
+ const brain = await loadLatestBrain(root);
136
+ if (!brain) {
137
+ return {
138
+ brain: null,
139
+ hits: [],
140
+ citations: [],
141
+ candidateSourcePaths: [],
142
+ limitations: ["No usable project brain snapshot; brain evidence skipped."],
143
+ };
144
+ }
145
+ const terms = extractQueryTerms(query);
146
+ const hitMap = new Map();
147
+ const absorb = (hits, boost = 0) => {
148
+ for (const h of hits) {
149
+ const ranked = {
150
+ ...h,
151
+ score: h.score + boost + scoreBrainHitText(h.text, h.kind),
152
+ };
153
+ const prev = hitMap.get(h.id);
154
+ if (!prev || ranked.score > prev.score)
155
+ hitMap.set(h.id, ranked);
156
+ }
157
+ };
158
+ // Full query first (matches CLI `brain search`), then significant terms.
159
+ absorb(searchBrain(brain, query), 0.3);
160
+ for (const term of terms) {
161
+ absorb(searchBrain(brain, term), 0.2);
162
+ }
163
+ // Architecture / overview with weak terms: surface top modules + provides/contains claims.
164
+ if (terms.length === 0 || /architecture|structure|overview|explain (the )?project/i.test(query)) {
165
+ const seenNames = new Set();
166
+ for (const c of brain.components) {
167
+ if (c.type === "domain-surface") {
168
+ const key = c.name.toLowerCase();
169
+ if (seenNames.has(key))
170
+ continue;
171
+ seenNames.add(key);
172
+ }
173
+ if (c.type !== "module" && c.type !== "domain-surface" && c.type !== "entrypoint")
174
+ continue;
175
+ const id = c.id;
176
+ const score = c.type === "module" ? 0.85 : c.type === "entrypoint" ? 0.75 : 0.55;
177
+ const prev = hitMap.get(id);
178
+ if (!prev || score > prev.score) {
179
+ hitMap.set(id, {
180
+ kind: "component",
181
+ id,
182
+ text: `${c.name} (${c.type})`.slice(0, 240),
183
+ score,
184
+ });
185
+ }
186
+ if (hitMap.size >= maxHits * 2)
187
+ break;
188
+ }
189
+ for (const claim of brain.claims) {
190
+ if (!/PROVIDES|CONTAINS|is-domain/i.test(claim.predicate))
191
+ continue;
192
+ const text = `${claim.subject} ${claim.predicate} ${claim.object}`;
193
+ const score = /PROVIDES|CONTAINS/i.test(claim.predicate) ? 0.9 : 0.6;
194
+ if (!hitMap.has(claim.id) || (hitMap.get(claim.id)?.score ?? 0) < score) {
195
+ hitMap.set(claim.id, {
196
+ kind: "claim",
197
+ id: claim.id,
198
+ text: text.slice(0, 240),
199
+ score,
200
+ });
201
+ }
202
+ if (hitMap.size >= maxHits * 3)
203
+ break;
204
+ }
205
+ }
206
+ const hitsRaw = [...hitMap.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
207
+ // Collapse duplicate domain-surface/module labels (same name, many component ids).
208
+ const hits = [];
209
+ const seenLabels = new Set();
210
+ for (const h of hitsRaw) {
211
+ if (h.kind === "component") {
212
+ const labelKey = h.text.replace(/^comp_[a-f0-9]+\s+/i, "").toLowerCase();
213
+ if (seenLabels.has(labelKey))
214
+ continue;
215
+ seenLabels.add(labelKey);
216
+ }
217
+ hits.push(h);
218
+ if (hits.length >= maxHits)
219
+ break;
220
+ }
221
+ const candidateSourcePaths = new Set();
222
+ const evidenceById = new Map(brain.evidence.map((e) => [e.id, e]));
223
+ const componentById = new Map(brain.components.map((c) => [c.id, c]));
224
+ const claimById = new Map(brain.claims.map((c) => [c.id, c]));
225
+ for (const hit of hits) {
226
+ collectPathsFromHitText(hit.text, candidateSourcePaths);
227
+ if (hit.kind === "component") {
228
+ const comp = componentById.get(hit.id);
229
+ if (comp?.path && looksLikeRepoRelativePath(comp.path)) {
230
+ candidateSourcePaths.add(comp.path.replace(/\\/g, "/"));
231
+ }
232
+ for (const eid of comp?.evidenceIds ?? []) {
233
+ const ev = evidenceById.get(eid);
234
+ if (ev &&
235
+ (ev.kind === "path" || ev.kind === "file-presence") &&
236
+ looksLikeRepoRelativePath(ev.locator)) {
237
+ candidateSourcePaths.add(ev.locator.replace(/\\/g, "/"));
238
+ }
239
+ }
240
+ }
241
+ if (hit.kind === "claim") {
242
+ const claim = claimById.get(hit.id);
243
+ for (const eid of claim?.evidenceIds ?? []) {
244
+ const ev = evidenceById.get(eid);
245
+ if (ev &&
246
+ (ev.kind === "path" || ev.kind === "file-presence") &&
247
+ looksLikeRepoRelativePath(ev.locator)) {
248
+ candidateSourcePaths.add(ev.locator.replace(/\\/g, "/"));
249
+ }
250
+ }
251
+ // Subject/object may themselves be module directory paths.
252
+ if (claim && looksLikeRepoRelativePath(claim.subject)) {
253
+ candidateSourcePaths.add(claim.subject.replace(/\\/g, "/"));
254
+ }
255
+ if (claim && looksLikeRepoRelativePath(claim.object)) {
256
+ candidateSourcePaths.add(claim.object.replace(/\\/g, "/"));
257
+ }
258
+ }
259
+ }
260
+ // Prefer source paths that match query terms (e.g. Proxyshield → proxyshield/...).
261
+ // Deprioritize editor/control noise and has-risk-only paths when better options exist.
262
+ const termLower = terms.map((t) => t.toLowerCase());
263
+ const rankedPaths = [...candidateSourcePaths].sort((a, b) => {
264
+ const score = (p) => {
265
+ let s = 0;
266
+ const lower = p.toLowerCase();
267
+ if (termLower.some((t) => lower.includes(t)))
268
+ s += 5;
269
+ if (/\.(php|ts|tsx|js|py)$/i.test(p))
270
+ s += 2;
271
+ if (p.startsWith(".cursor/") || p.startsWith(".agentdoctor/"))
272
+ s -= 6;
273
+ if (/\.htaccess$/i.test(p))
274
+ s -= 1;
275
+ return s;
276
+ };
277
+ return score(b) - score(a) || a.localeCompare(b);
278
+ });
279
+ const citations = hits.map((h) => ({
280
+ source: "brain",
281
+ evidenceType: "project-brain",
282
+ confidence: "INFERRED",
283
+ excerpt: h.text,
284
+ note: `brain:${h.kind}:${h.id}`,
285
+ }));
286
+ if (hits.length === 0) {
287
+ limitations.push("Project brain loaded but no claims/components matched this query.");
288
+ }
289
+ return {
290
+ brain,
291
+ hits,
292
+ citations,
293
+ candidateSourcePaths: rankedPaths.slice(0, 20),
294
+ limitations,
295
+ };
296
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ContextBundle } from "./types.js";
2
2
  import type { RepositoryGraph } from "../../platform/types.js";
3
+ import { type AskProgressReporter } from "../progress.js";
3
4
  export interface RetrieveContextOptions {
4
5
  root: string;
5
6
  query: string;
@@ -12,11 +13,16 @@ export interface RetrieveContextOptions {
12
13
  builder?: string;
13
14
  astFilesParsed?: number;
14
15
  };
16
+ /** Max owned source files to excerpt (after brain + includePaths). */
17
+ maxSourceFiles?: number;
18
+ /** Optional progress reporter (real operation boundaries only). */
19
+ progress?: AskProgressReporter;
15
20
  }
16
21
  /**
17
22
  * Retrieve a budgeted project context pack for the agent.
18
- * Reuses graph + planContext. Does not invent facts.
23
+ * Pipeline: Brain claims → owned source paths → graph plan → ranked excerpts.
19
24
  * Hostile paths are rejected via resolveSafeRepoPath + project ownership
20
25
  * (containment alone must not promote .private / AgentDoctorOS / nested repos).
26
+ * `.agentdoctor/**` is control-plane and must not become VERIFIED application source evidence.
21
27
  */
22
28
  export declare function retrieveProjectContext(options: RetrieveContextOptions): Promise<ContextBundle>;
@@ -5,22 +5,112 @@ import { buildIntelligenceGraph } from "../../intelligence/graph/build.js";
5
5
  import { assertProjectOwnedRepoPath, ProjectOwnershipError } from "../../project/ownership.js";
6
6
  import { resolveRepoRoot } from "../../utils/path.js";
7
7
  import { resolveSafeRepoPath, PathEscapeError } from "../../security/paths.js";
8
+ import { collectBrainEvidenceForQuery, extractQueryTerms, isControlPlaneRelativePath, } from "./brain-evidence.js";
9
+ import { noopAskProgress } from "../progress.js";
8
10
  function estimateTokens(text) {
9
11
  return Math.ceil(text.length / 4);
10
12
  }
13
+ async function tryReadOwnedExcerpt(root, rel, maxExcerpt) {
14
+ const norm = rel.split(path.sep).join("/");
15
+ if (isControlPlaneRelativePath(norm)) {
16
+ return {
17
+ source: "repository",
18
+ path: norm,
19
+ evidenceType: "metadata",
20
+ confidence: "UNKNOWN",
21
+ note: "control_plane: not application source evidence",
22
+ };
23
+ }
24
+ try {
25
+ const abs = resolveSafeRepoPath(root, rel);
26
+ await assertProjectOwnedRepoPath(root, abs, rel);
27
+ // Directories (module roots) are not source excerpts.
28
+ const st = await fs.stat(abs);
29
+ if (st.isDirectory()) {
30
+ return {
31
+ source: "repository",
32
+ path: norm,
33
+ evidenceType: "metadata",
34
+ confidence: "INFERRED",
35
+ note: "brain-referenced directory (no file excerpt)",
36
+ };
37
+ }
38
+ const text = await fs.readFile(abs, "utf8");
39
+ const excerpt = text.slice(0, maxExcerpt);
40
+ return {
41
+ source: "repository",
42
+ path: norm,
43
+ evidenceType: "source-code",
44
+ confidence: "VERIFIED",
45
+ excerpt,
46
+ };
47
+ }
48
+ catch (error) {
49
+ if (error instanceof PathEscapeError) {
50
+ return {
51
+ source: "repository",
52
+ path: norm,
53
+ evidenceType: "metadata",
54
+ confidence: "UNKNOWN",
55
+ note: "path_escape: rejected",
56
+ };
57
+ }
58
+ if (error instanceof ProjectOwnershipError) {
59
+ return {
60
+ source: "repository",
61
+ path: norm,
62
+ evidenceType: "metadata",
63
+ confidence: "UNKNOWN",
64
+ note: `ownership_denied: ${error.ownership}`,
65
+ };
66
+ }
67
+ return {
68
+ source: "repository",
69
+ path: norm,
70
+ evidenceType: "metadata",
71
+ confidence: "UNKNOWN",
72
+ note: error instanceof Error ? error.message : String(error),
73
+ };
74
+ }
75
+ }
11
76
  /**
12
77
  * Retrieve a budgeted project context pack for the agent.
13
- * Reuses graph + planContext. Does not invent facts.
78
+ * Pipeline: Brain claims → owned source paths → graph plan → ranked excerpts.
14
79
  * Hostile paths are rejected via resolveSafeRepoPath + project ownership
15
80
  * (containment alone must not promote .private / AgentDoctorOS / nested repos).
81
+ * `.agentdoctor/**` is control-plane and must not become VERIFIED application source evidence.
16
82
  */
17
83
  export async function retrieveProjectContext(options) {
18
84
  const root = resolveRepoRoot(options.root);
19
85
  const query = options.query.trim() || "project overview";
20
86
  const maxExcerpt = options.maxExcerptChars ?? 1_200;
87
+ const maxSourceFiles = options.maxSourceFiles ?? 10;
21
88
  const citations = [];
22
89
  const limitations = [];
23
90
  const chunks = [];
91
+ const terms = extractQueryTerms(query);
92
+ const progress = options.progress ?? noopAskProgress;
93
+ progress.stage("search", "start");
94
+ let brainPack;
95
+ try {
96
+ brainPack = await collectBrainEvidenceForQuery(root, query);
97
+ }
98
+ catch (error) {
99
+ progress.stage("search", "fail", error instanceof Error ? error.message : String(error));
100
+ throw error;
101
+ }
102
+ const brainDetail = brainPack.hits.length > 0
103
+ ? `${brainPack.hits.length} relevant claim${brainPack.hits.length === 1 ? "" : "s"}`
104
+ : "no matching claims";
105
+ progress.stage("search", "ok", brainDetail);
106
+ limitations.push(...brainPack.limitations);
107
+ for (const c of brainPack.citations) {
108
+ citations.push(c);
109
+ if (c.excerpt) {
110
+ chunks.push(`--- BRAIN (${c.confidence}) ---\n${c.excerpt}`);
111
+ }
112
+ }
113
+ progress.stage("retrieve", "start");
24
114
  let graph = options.graph;
25
115
  try {
26
116
  if (!graph) {
@@ -28,94 +118,145 @@ export async function retrieveProjectContext(options) {
28
118
  }
29
119
  }
30
120
  catch (error) {
121
+ progress.stage("retrieve", "fail", error instanceof Error ? error.message : String(error));
31
122
  limitations.push(`Graph unavailable: ${error instanceof Error ? error.message : String(error)}`);
123
+ if (citations.length === 0) {
124
+ citations.push({
125
+ source: "repository",
126
+ evidenceType: "metadata",
127
+ confidence: "UNKNOWN",
128
+ note: "Repository graph could not be built and no brain evidence matched.",
129
+ });
130
+ }
131
+ const rendered = [
132
+ `Project root: ${root}`,
133
+ `Query: ${query}`,
134
+ `Brain hits: ${brainPack.hits.length}`,
135
+ "",
136
+ ...chunks,
137
+ ].join("\n");
32
138
  return {
33
139
  root,
34
140
  query,
35
- citations: [
36
- {
37
- source: "repository",
38
- evidenceType: "metadata",
39
- confidence: "UNKNOWN",
40
- note: "Repository graph could not be built for this query.",
41
- },
42
- ],
43
- rendered: `Query: ${query}\n(No graph context available.)`,
44
- estimatedTokens: 32,
141
+ citations,
142
+ rendered,
143
+ estimatedTokens: estimateTokens(rendered),
45
144
  limitations,
46
145
  };
47
146
  }
147
+ // Prefer graph nodes that match query terms; deprioritize vendor/minified noise.
48
148
  const plan = await planContext({
49
149
  root,
50
150
  graph,
51
- query,
52
- budgetTokens: options.budgetTokens ?? 6_000,
151
+ query: terms.length ? terms.join(" ") : query,
152
+ budgetTokens: Math.min(options.budgetTokens ?? 6_000, 4_000),
53
153
  });
54
154
  limitations.push(...plan.limitations);
55
- const paths = new Set(plan.selected.map((s) => s.path));
56
- for (const p of options.includePaths ?? []) {
57
- paths.add(p);
58
- }
59
- for (const rel of paths) {
60
- try {
61
- const abs = resolveSafeRepoPath(root, rel);
62
- await assertProjectOwnedRepoPath(root, abs, rel);
63
- const text = await fs.readFile(abs, "utf8");
64
- const excerpt = text.slice(0, maxExcerpt);
65
- const citation = {
66
- source: "repository",
67
- path: rel.split(path.sep).join("/"),
68
- evidenceType: "source-code",
69
- confidence: "VERIFIED",
70
- excerpt,
71
- };
72
- citations.push(citation);
73
- chunks.push(`--- FILE ${citation.path} (VERIFIED excerpt) ---\n${excerpt}`);
74
- }
75
- catch (error) {
76
- if (error instanceof PathEscapeError) {
77
- citations.push({
78
- source: "repository",
79
- path: rel,
80
- evidenceType: "metadata",
81
- confidence: "UNKNOWN",
82
- note: "path_escape: rejected",
83
- });
84
- continue;
85
- }
86
- if (error instanceof ProjectOwnershipError) {
87
- citations.push({
88
- source: "repository",
89
- path: rel,
90
- evidenceType: "metadata",
91
- confidence: "UNKNOWN",
92
- note: `ownership_denied: ${error.ownership}`,
93
- });
155
+ const orderedPaths = [];
156
+ const seenPaths = new Set();
157
+ const pushPath = (p) => {
158
+ const norm = p.replace(/\\/g, "/");
159
+ if (!norm || seenPaths.has(norm) || isControlPlaneRelativePath(norm))
160
+ return;
161
+ seenPaths.add(norm);
162
+ orderedPaths.push(norm);
163
+ };
164
+ // 1) Brain-derived source paths (highest priority for module questions)
165
+ for (const p of brainPack.candidateSourcePaths)
166
+ pushPath(p);
167
+ // 2) Caller include paths
168
+ for (const p of options.includePaths ?? [])
169
+ pushPath(p);
170
+ // 3) Graph plan — prefer term matches, skip minified/vendor-ish
171
+ const planSorted = [...plan.selected].sort((a, b) => {
172
+ const score = (p) => {
173
+ let s = 0;
174
+ const lower = p.toLowerCase();
175
+ if (terms.some((t) => lower.includes(t.toLowerCase())))
176
+ s += 5;
177
+ if (/\.min\.js$/i.test(p) || /node_modules|vendor|assets\/.*\.umd/i.test(p))
178
+ s -= 4;
179
+ if (/\.(ts|tsx|js|jsx|py|php|go|rs)$/i.test(p))
180
+ s += 1;
181
+ return s;
182
+ };
183
+ return score(b.path) - score(a.path) || b.relevance - a.relevance;
184
+ });
185
+ for (const s of planSorted)
186
+ pushPath(s.path);
187
+ progress.stage("retrieve", "ok", `${Math.min(orderedPaths.length, maxSourceFiles)} candidate source${orderedPaths.length === 1 ? "" : "s"}`);
188
+ progress.stage("verify", "start");
189
+ let sourceExcerpts = 0;
190
+ let ownedVerified = 0;
191
+ try {
192
+ for (const rel of orderedPaths) {
193
+ if (sourceExcerpts >= maxSourceFiles)
194
+ break;
195
+ const citation = await tryReadOwnedExcerpt(root, rel, maxExcerpt);
196
+ if (!citation)
94
197
  continue;
198
+ citations.push(citation);
199
+ if (citation.confidence === "VERIFIED" && citation.excerpt && citation.path) {
200
+ chunks.push(`--- FILE ${citation.path} (VERIFIED excerpt) ---\n${citation.excerpt}`);
201
+ sourceExcerpts += 1;
202
+ ownedVerified += 1;
95
203
  }
96
- citations.push({
97
- source: "repository",
98
- path: rel,
99
- evidenceType: "metadata",
100
- confidence: "UNKNOWN",
101
- note: error instanceof Error ? error.message : String(error),
102
- });
103
204
  }
104
205
  }
105
- if (citations.length === 0) {
206
+ catch (error) {
207
+ progress.stage("verify", "fail", error instanceof Error ? error.message : String(error));
208
+ throw error;
209
+ }
210
+ progress.stage("verify", "ok", ownedVerified > 0
211
+ ? `${ownedVerified} owned source${ownedVerified === 1 ? "" : "s"}`
212
+ : "no owned source excerpts");
213
+ const hasBrain = brainPack.citations.length > 0;
214
+ const hasVerifiedSource = citations.some((c) => c.confidence === "VERIFIED" && c.evidenceType === "source-code");
215
+ if (!hasBrain && !hasVerifiedSource) {
106
216
  citations.push({
107
217
  source: "repository",
108
218
  evidenceType: "metadata",
109
219
  confidence: "UNKNOWN",
110
- note: "No files selected for this query within the token budget.",
220
+ note: "No brain claims or owned source excerpts matched this query within the context budget.",
111
221
  });
112
222
  limitations.push("No file excerpts retrieved — answer must use UNKNOWN where evidence is missing.");
113
223
  }
224
+ else if (hasBrain && !hasVerifiedSource) {
225
+ limitations.push("Brain structural evidence available; no owned source excerpts resolved for this query.");
226
+ }
227
+ // Compact graph hint: only term-matching nodes (not raw first-N noise).
228
+ const graphHints = [];
229
+ if (terms.length > 0) {
230
+ const termLower = terms.map((t) => t.toLowerCase());
231
+ for (const n of graph.nodes) {
232
+ if (!n.path || !n.label)
233
+ continue;
234
+ const blob = `${n.label} ${n.path}`.toLowerCase();
235
+ if (!termLower.some((t) => blob.includes(t)))
236
+ continue;
237
+ if (/\.min\.js$/i.test(n.path) || /chart\.umd|node_modules/i.test(n.path))
238
+ continue;
239
+ graphHints.push(`${n.kind}: ${n.label} @ ${n.path}`);
240
+ if (graphHints.length >= 6)
241
+ break;
242
+ }
243
+ }
244
+ if (graphHints.length) {
245
+ chunks.push(`--- GRAPH (INFERRED, query-filtered) ---\n${graphHints.join("\n")}`);
246
+ citations.push({
247
+ source: "graph",
248
+ evidenceType: "graph",
249
+ confidence: "INFERRED",
250
+ excerpt: graphHints.join("\n"),
251
+ note: "query-filtered graph nodes",
252
+ });
253
+ }
114
254
  const rendered = [
115
255
  `Project root: ${root}`,
116
256
  `Query: ${query}`,
117
- `Selected files: ${plan.selected.length}`,
118
- `Budget tokens: ${plan.budgetTokens} (est. used ${plan.estimatedTokens})`,
257
+ `Brain hits: ${brainPack.hits.length}`,
258
+ `Selected source files: ${sourceExcerpts}`,
259
+ `Budget tokens: ${plan.budgetTokens} (plan est. ${plan.estimatedTokens})`,
119
260
  "",
120
261
  ...chunks,
121
262
  ].join("\n");
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Progress hooks for ask/context retrieval.
3
+ * Presentation lives in CLI; agent code only reports real operation boundaries.
4
+ */
5
+ export type AskProgressStageId = "understand" | "search" | "retrieve" | "verify" | "prepare";
6
+ export interface AskProgressReporter {
7
+ stage(id: AskProgressStageId, state: "start" | "ok" | "fail", detail?: string): void;
8
+ stop(): void;
9
+ }
10
+ export declare const noopAskProgress: AskProgressReporter;
@@ -0,0 +1,4 @@
1
+ export const noopAskProgress = {
2
+ stage() { },
3
+ stop() { },
4
+ };
@@ -0,0 +1,17 @@
1
+ import type { AskProgressReporter, AskProgressStageId } from "../agent/progress.js";
2
+ export type { AskProgressReporter, AskProgressStageId };
3
+ export interface CreateAskProgressOptions {
4
+ /** When false, all methods are no-ops (JSON / quiet / tests). */
5
+ enabled?: boolean;
6
+ /** Defaults to stderr — keeps stdout free for answers / JSON. */
7
+ stream?: NodeJS.WritableStream;
8
+ /** Override TTY detection (tests). Defaults to stream.isTTY. */
9
+ isTTY?: boolean;
10
+ }
11
+ /**
12
+ * Human-readable ask progress. Stages must be driven by real operations.
13
+ * Animation uses setInterval only for frame updates while work is in flight —
14
+ * never to invent delay.
15
+ */
16
+ export declare function createAskProgress(options?: CreateAskProgressOptions): AskProgressReporter;
17
+ export declare function silentAskProgress(): AskProgressReporter;