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
55 changes: 54 additions & 1 deletion agents/teamai-recall.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <query>` 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: <title from --check output>
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:
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions src/__tests__/extract-source-descriptions.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
4 changes: 2 additions & 2 deletions src/__tests__/recall-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/recall-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
},
]);

Expand Down
8 changes: 4 additions & 4 deletions src/__tests__/recall-progressive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }]);
});

// -------------------------------------------------------------------------
Expand All @@ -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' }]);
});

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -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' }]);
});

// -------------------------------------------------------------------------
Expand All @@ -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' }]);
});

// -------------------------------------------------------------------------
Expand Down
98 changes: 95 additions & 3 deletions src/code-knowledge-recall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}];
}

Expand Down Expand Up @@ -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;

Expand All @@ -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,
});
}

Expand Down
5 changes: 4 additions & 1 deletion src/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading