diff --git a/agents/teamai-recall.md b/agents/teamai-recall.md index 4a45e5de..28442a5e 100644 --- a/agents/teamai-recall.md +++ b/agents/teamai-recall.md @@ -32,10 +32,40 @@ teamai recall --check "<3-6 keywords from the task>" meaningful coverage for this task. Emit exactly one line `No relevant team knowledge found for: ` and **stop** — do not proceed to Step 1–5, do not read any files, do not run a full recall. -- If the output starts with `RELEVANT`: continue to Step 1. +- If the output starts with `RELEVANT`: check complexity (see below), + then continue to Step 1 or take the LOW shortcut. - If the command fails or `teamai` is not on PATH: skip the precheck and continue to Step 1 (do not block on precheck failure). +#### Complexity quick-judge (after RELEVANT) + +> **Format dependency**: The LOW shortcut parses `title=` and `sources=` from `--check` stdout. If `emitCheckVerdict` output format changes, update this section. + +Scan the original task description for complexity signals: + +**LOW signals** (all must hold): +- Task targets a single file or a single field/parameter change +- Keywords present: 改名/rename/修改名称/修改字段/add parameter/加参数/ + 改配置/change config/update constant/修改常量/加个字段/加一个参数 +- No multi-module interaction, no new flow/controller/class creation + +**If LOW and `--check` output includes `title=` and `sources=`**: use them +directly to construct a short response (≤500 chars): + +``` +Relevant knowledge: +Suggested files: <sources from --check output> +<!-- teamai:recalled-doc-ids: [] --> +``` + +**Stop here** — skip Steps 1–5 entirely. + +**If LOW but `--check` output lacks `sources=`**: run +`teamai recall <keywords> --depth context`, take only the top-1 result's +title + Sources, return the same short format above, and skip Steps 1–5. + +**If not LOW**: continue to Step 1 as normal. + ### Step 1 — Classify question type and choose retrieval depth Determine if the query matches a G-document category: @@ -57,6 +87,19 @@ corresponding file and extract relevant sections. Skip BM25 search. > - `--depth lookup`: searches ALL evidence pages including raw symbol lists (for precise file:line lookups) > - `--depth route`: returns the router table only (use when you need to discover what projects exist) +**Task complexity heuristic — choose depth by task type:** + +| Signal in query | Task type | Depth | Rationale | +|-----------------|-----------|-------|-----------| +| feature/新功能/新增功能/大功能/redesign/重构整个/multi-file | Feature (large) | `--depth lookup` | Need full file coverage to avoid missing files | +| 添加/修改/如何改/实现/implement/refactor | Edit (medium) | `--depth lookup` | Need symbol-level anchors | +| bugfix/修复/fix/patch/typo/单文件/one-file | Bugfix (small) | `--depth context` | Fast pass; skip graph-index drill-down | + +For **bugfix/small** tasks: use `--depth context` only, skip the +graph-index.json deep read in the edit/change section below, and keep +output ≤ 1500 characters. The main conversation already knows which +file to fix. + **Edit/change queries** (keywords: 新增/添加/修改/如何改/重构/实现; how to add/change/modify/implement): use `--depth lookup` in Step 3 so facts/relation pages are visible. After BM25 recall, also read these directly (bypassing BM25 ranking uncertainty): 1. `teamwiki/evidence/code/<project>/.indices/graph-index.json` (priority; fall back to `teamwiki/.indices/graph-index.json` if absent) — when surfacing edges, pick 1–3 entry files most relevant to the task and read only their forward direct-dep edges (`from` == entry file); skip reverse expansion (each edge: `{from, to, relation}` — from/to are file paths, relation is type e.g. DEPENDS_ON) 2. `Sources:` file anchors listed in any matching facts pages (component.md / interface.md) @@ -149,6 +192,16 @@ Suggested reading order: <contract/types first> → <impl> → ... > Edges capped at 10; see graph-index.json for full graph. Keep this section ≤ 300 characters. Omit this section for non-edit queries. +### Candidate change files + +If the `teamai recall` output contains a +`--- Candidate change files ---` section, reproduce it here verbatim. +These are source files and their forward dependencies from the code +graph — the main conversation should check whether its planned +changes cover all of them. + +If no candidate files section was returned, omit this heading entirely. + ### Gaps (if relevant) ⚠️ <gap description> — do not guess answers for this area. diff --git a/src/__tests__/extract-source-descriptions.test.ts b/src/__tests__/extract-source-descriptions.test.ts new file mode 100644 index 00000000..e1acda86 --- /dev/null +++ b/src/__tests__/extract-source-descriptions.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { extractSourceDescriptions } from '../code-knowledge-recall.js'; + +describe('extractSourceDescriptions', () => { + const PAGE_CONTENT = [ + '### RestartController', + 'Handles restart logic for inference services', + '- `file:hai_flow/service/k8s/api/restart_infer_workload_controller.py:15` | refs: 3', + '', + '### DescribeController', + 'Describes workload pod status and health', + '- `file:hai_flow/service/k8s/api/describe_infer_workload_controller.py:8` | refs: 2', + '', + '### UtilsHelper', + 'Common utility functions for K8s operations', + '- `file:hai_flow/service/k8s/utils.py:1` | refs: 10', + '- `file:hai_flow/service/common/utils.py:1` | refs: 5', + ].join('\n'); + + it('full path match extracts correct description', () => { + const result = extractSourceDescriptions( + ['hai_flow/service/k8s/api/restart_infer_workload_controller.py'], + PAGE_CONTENT, + ); + expect(result).toEqual([{ + path: 'hai_flow/service/k8s/api/restart_infer_workload_controller.py', + desc: 'Handles restart logic for inference serv', + }]); + }); + + it('basename collision resolved by full path priority', () => { + // Both share basename "utils.py" but full path distinguishes them + const result = extractSourceDescriptions( + ['hai_flow/service/k8s/utils.py', 'hai_flow/service/common/utils.py'], + PAGE_CONTENT, + ); + // Both should map to UtilsHelper since content has both paths under same h3 + expect(result[0].desc).toBe('Common utility functions for K8s operati'); + expect(result[1].desc).toBe('Common utility functions for K8s operati'); + }); + + it('no match returns path only', () => { + const result = extractSourceDescriptions( + ['nonexistent/file.py'], + PAGE_CONTENT, + ); + expect(result).toEqual([{ path: 'nonexistent/file.py' }]); + }); + + it('h3 found but no description line returns path only', () => { + const content = [ + '### EmptySection', + '- `file:src/empty.py:1` | refs: 1', + ].join('\n'); + const result = extractSourceDescriptions(['src/empty.py'], content); + expect(result).toEqual([{ path: 'src/empty.py' }]); + }); + + it('description truncated to 40 chars', () => { + const content = [ + '### LongDesc', + 'This is a very long description that exceeds the forty character limit significantly', + '- `file:src/long.py:1` | refs: 1', + ].join('\n'); + const result = extractSourceDescriptions(['src/long.py'], content); + expect(result[0].desc).toBe('This is a very long description that exc'); + expect(result[0].desc!.length).toBe(40); + }); + + it('multiple sources each get correct description', () => { + const result = extractSourceDescriptions( + [ + 'hai_flow/service/k8s/api/restart_infer_workload_controller.py', + 'hai_flow/service/k8s/api/describe_infer_workload_controller.py', + ], + PAGE_CONTENT, + ); + expect(result[0].desc).toBe('Handles restart logic for inference serv'); + expect(result[1].desc).toBe('Describes workload pod status and health'); + }); + + it('empty sources array returns empty array', () => { + const result = extractSourceDescriptions([], PAGE_CONTENT); + expect(result).toEqual([]); + }); +}); diff --git a/src/__tests__/recall-check.test.ts b/src/__tests__/recall-check.test.ts index 0e618469..2df9f249 100644 --- a/src/__tests__/recall-check.test.ts +++ b/src/__tests__/recall-check.test.ts @@ -90,8 +90,8 @@ describe('recall --check precheck mode', () => { await recall('deployment timeout retry', { check: true }); - expect(captured).toMatch(/^RELEVANT score=\d+\.\d+\n$/); - expect(captured).not.toContain(CHECK_LEARNING_TITLE); + expect(captured).toMatch(/^RELEVANT score=\d+\.\d+/); + expect(captured).toContain(`title="${CHECK_LEARNING_TITLE}"`); expect(Number(captured.match(/score=([\d.]+)/)![1])).toBeGreaterThanOrEqual(4.0); }); diff --git a/src/__tests__/recall-format.test.ts b/src/__tests__/recall-format.test.ts index 6c5b8fa2..65a72b31 100644 --- a/src/__tests__/recall-format.test.ts +++ b/src/__tests__/recall-format.test.ts @@ -44,7 +44,7 @@ describe('formatResults — Sources line', () => { entry: makeEntry({ title: 'Code Page', path: '/wiki/evidence/code/proj/foo.md' }), score: 7.5, scope: 'project', - sources: ['src/a.ts', 'src/b.ts'], + sources: [{ path: 'src/a.ts' }, { path: 'src/b.ts' }], }, ]); diff --git a/src/__tests__/recall-progressive.test.ts b/src/__tests__/recall-progressive.test.ts index 7bd4a851..2ddad42d 100644 --- a/src/__tests__/recall-progressive.test.ts +++ b/src/__tests__/recall-progressive.test.ts @@ -262,7 +262,7 @@ describe('queryCodeKnowledge — progressive depth retrieval', () => { const hit = results.find(r => r.page.includes('source-array.md')); expect(hit).toBeDefined(); - expect(hit?.sources).toEqual(['src/foo.ts', 'src/bar.ts']); + expect(hit?.sources).toEqual([{ path: 'src/foo.ts' }, { path: 'src/bar.ts' }]); }); // ------------------------------------------------------------------------- @@ -277,7 +277,7 @@ describe('queryCodeKnowledge — progressive depth retrieval', () => { const hit = results.find(r => r.page.includes('source-string.md')); expect(hit).toBeDefined(); - expect(hit?.sources).toEqual(['src/only.ts']); + expect(hit?.sources).toEqual([{ path: 'src/only.ts' }]); }); // ------------------------------------------------------------------------- @@ -307,7 +307,7 @@ describe('queryCodeKnowledge — progressive depth retrieval', () => { const hit = results.find(r => r.page.includes('source-mixed.md')); expect(hit).toBeDefined(); - expect(hit?.sources).toEqual(['src/real-file.ts']); + expect(hit?.sources).toEqual([{ path: 'src/real-file.ts' }]); }); // ------------------------------------------------------------------------- @@ -323,7 +323,7 @@ describe('queryCodeKnowledge — progressive depth retrieval', () => { const hit = results.find(r => r.page.includes('source-no-ext.md')); expect(hit).toBeDefined(); // URL entry must be dropped; Makefile and src/Dockerfile must survive - expect(hit?.sources).toEqual(['Makefile', 'src/Dockerfile']); + expect(hit?.sources).toEqual([{ path: 'Makefile' }, { path: 'src/Dockerfile' }]); }); // ------------------------------------------------------------------------- diff --git a/src/code-knowledge-recall.ts b/src/code-knowledge-recall.ts index 2e99f9d1..0753eb19 100644 --- a/src/code-knowledge-recall.ts +++ b/src/code-knowledge-recall.ts @@ -13,13 +13,19 @@ import matter from 'gray-matter'; import type { GraphIndex } from './wiki-engine/core/graph-index.schema.js'; import { tokenize, tokenCount, MAX_TOKENIZE_CHARS } from './utils/tokenizer.js'; +export interface SourceAnchor { + path: string; + desc?: string; +} + export interface CodeKnowledgeResult { page: string; title: string; score: number; snippet: string; kind: 'codebase'; - sources?: string[]; + sources?: SourceAnchor[]; + relatedFiles?: string[]; } interface CorpusStats { @@ -294,6 +300,65 @@ function sanitizeSources(sources: string[] | undefined): string[] | undefined { return cleaned.length > 0 ? cleaned : undefined; } +export function extractSourceDescriptions(sources: string[], pageContent: string): SourceAnchor[] { + const lines = pageContent.split('\n'); + return sources.map((source) => { + const slashIdx = source.lastIndexOf('/'); + const basename = slashIdx >= 0 ? source.slice(slashIdx + 1) : source; + + let matchLineIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(source)) { + matchLineIdx = i; + break; + } + } + if (matchLineIdx === -1) { + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('`file:') && lines[i].includes(basename)) { + matchLineIdx = i; + break; + } + } + } + if (matchLineIdx === -1) { + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(basename)) { + matchLineIdx = i; + break; + } + } + } + + if (matchLineIdx === -1) return { path: source }; + + let h3Idx = -1; + for (let i = matchLineIdx; i >= 0; i--) { + if (lines[i].startsWith('### ')) { + h3Idx = i; + break; + } + } + + if (h3Idx === -1) return { path: source }; + + let descIdx = -1; + for (let i = h3Idx + 1; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === '') continue; + if (line.startsWith('#') || line.startsWith('-') || line.startsWith('`')) break; + descIdx = i; + break; + } + + if (descIdx === -1) return { path: source }; + + const chars = Array.from(lines[descIdx]); + const desc = chars.length > 40 ? chars.slice(0, 40).join('') : lines[descIdx]; + return { path: source, desc }; + }); +} + async function loadPagesRecursive( dir: string, relativePath: string, @@ -389,7 +454,9 @@ export async function queryCodeKnowledge( score: 10, snippet: pages[0].content.slice(0, 800), kind: 'codebase', - sources: pages[0].sources, + sources: pages[0].sources + ? extractSourceDescriptions(pages[0].sources, pages[0].content) + : undefined, }]; } @@ -418,6 +485,15 @@ export async function queryCodeKnowledge( const budget = TOKEN_BUDGET[depth] ?? 5000; const estimateTokens = (text: string) => Math.ceil(text.length / 3.5); + const forwardDeps = new Map<string, string[]>(); + if (graph && depth === 'lookup') { + for (const edge of graph.edges) { + if (edge.from === edge.to) continue; + const list = forwardDeps.get(edge.from); + if (list) { list.push(edge.to); } else { forwardDeps.set(edge.from, [edge.to]); } + } + } + const results: CodeKnowledgeResult[] = []; let tokenUsed = 0; @@ -436,13 +512,29 @@ export async function queryCodeKnowledge( if (tokenUsed + cost > budget && results.length > 0) break; tokenUsed += cost; + let relatedFiles: string[] | undefined; + if (page.sources && page.sources.length > 0) { + const sourceSet = new Set<string>(page.sources); + const neighbors = new Set<string>(); + for (const src of page.sources) { + const deps = forwardDeps.get(src); + if (deps) { for (const d of deps) { if (!sourceSet.has(d)) { neighbors.add(d); } } } + } + if (neighbors.size > 0) { + relatedFiles = [...neighbors].slice(0, 15); + } + } + results.push({ page: page.path, title: page.title, score, snippet, kind: 'codebase', - sources: page.sources, + sources: page.sources + ? extractSourceDescriptions(page.sources, page.content) + : undefined, + relatedFiles, }); } diff --git a/src/pull.ts b/src/pull.ts index 9a796456..59a5635d 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -969,7 +969,10 @@ export function compileRecallRulesBlock(): string { '', 'The subagent will return a compact summary of relevant team knowledge', '(skills, learnings, docs, rules) without polluting this conversation', - 'with raw content.', + 'with raw content. For **feature/large tasks**, recall returns a', + '"Candidate change files" list — check your planned changes cover all', + 'listed files before starting. For **bugfix/small tasks**, recall runs', + 'a lighter pass and you may skip it entirely per condition 2–3 above.', '', '**Important constraints on agent sequencing (when recall is invoked):**', '1. Invoke `teamai-recall` subagent **first and alone** — never', diff --git a/src/recall.ts b/src/recall.ts index 1cc0d3bb..646528bc 100644 --- a/src/recall.ts +++ b/src/recall.ts @@ -7,7 +7,7 @@ import { log } from './utils/logger.js'; import type { GlobalOptions, SearchIndex, LocalConfig } from './types.js'; import { getTeamaiHome } from './types.js'; import { queryCodeKnowledge } from './code-knowledge-recall.js'; -import type { CodeKnowledgeResult } from './code-knowledge-recall.js'; +import type { CodeKnowledgeResult, SourceAnchor } from './code-knowledge-recall.js'; import { recordRecallQuality } from './recall-quality.js'; import { deriveSessionId } from './utils/session-id.js'; @@ -28,7 +28,9 @@ interface ScopedSearchResult extends SearchResult { /** Base path for learnings files (so AI can read the correct path). */ learningsBase?: string; /** Source file anchors from codebase wiki frontmatter (codebase results only). */ - sources?: string[]; + sources?: SourceAnchor[]; + /** Forward-dependency neighbor files from graph (candidate change files). */ + relatedFiles?: string[]; } // ─── Recall data flow ──────────────────────────────────── @@ -86,7 +88,7 @@ export function formatResults(results: ScopedSearchResult[]): string { : `~/.teamai/learnings/${entry.filename}`; lines.push(`File: ${filePath}`); if (sources && sources.length > 0) { - lines.push(`Sources: ${sources.join(', ')}`); + lines.push(`Sources: ${sources.map((s) => s.desc ? `${s.path} (${s.desc})` : s.path).join(', ')}`); } if (entry.snippet) { lines.push(`Snippet: ${entry.snippet}`); @@ -94,6 +96,26 @@ export function formatResults(results: ScopedSearchResult[]): string { lines.push(''); } + const allRelated = new Set<string>(); + for (const r of results) { + if (r.relatedFiles) { + for (const f of r.relatedFiles) { + allRelated.add(f); + } + } + } + if (allRelated.size > 0) { + const capped = [...allRelated].slice(0, 10); + lines.push('--- Candidate change files ---'); + for (const f of capped) { + lines.push(`- ${f}`); + } + if (allRelated.size > 10) { + lines.push(` (${allRelated.size - 10} more omitted)`); + } + lines.push(''); + } + lines.push('--- [teamai:recall:end] ---'); lines.push(''); lines.push('以上内容来自团队知识库,仅供参考。如需详细信息,请用 Read 工具读取对应文件。'); @@ -203,10 +225,18 @@ export async function recall( query: string, options: GlobalOptions & { depth?: 'route' | 'context' | 'lookup'; check?: boolean }, ): Promise<void> { - const emitCheckVerdict = (score: number): void => { + const emitCheckVerdict = (score: number, topResult?: ScopedSearchResult): void => { const rounded = Math.round(score * 10) / 10; const verdict = rounded >= RECALL_RELEVANCE_THRESHOLD ? 'RELEVANT' : 'NOT_RELEVANT'; - process.stdout.write(`${verdict} score=${rounded.toFixed(1)}\n`); + let line = `${verdict} score=${rounded.toFixed(1)}`; + if (verdict === 'RELEVANT' && topResult) { + line += ` title="${topResult.entry.title}"`; + if (topResult.sources && topResult.sources.length > 0) { + const srcStr = topResult.sources.map((s) => s.desc ? `${s.path}(${s.desc})` : s.path).join(','); + line += ` sources=${srcStr}`; + } + } + process.stdout.write(`${line}\n`); }; if (!query || !query.trim()) { @@ -314,6 +344,7 @@ export async function recall( scope: 'project', learningsBase: wikiRoot, sources: cr.sources, + relatedFiles: cr.relatedFiles, }); } } catch { @@ -327,7 +358,7 @@ export async function recall( }); if (options.check) { - emitCheckVerdict(allResults.length > 0 ? allResults[0].score : 0); + emitCheckVerdict(allResults.length > 0 ? allResults[0].score : 0, allResults[0]); return; }