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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. See [standa

### 🐛 Bug Fixes

- Course-correction matching normalizes prompts and keywords to Unicode NFC, so composed and decomposed accents match. Stored prompt summaries and the 60-second correction window are unchanged. Fixes [#573](https://github.com/Tencent/teamai-cli/issues/573).
- Course-correction detection matches keywords in space-separated scripts as whole words, so Spanish "segundo" no longer counts as `undo` (for [#564](https://github.com/Tencent/teamai-cli/issues/564)).
- MCP `requires` is resolved from `PATH` (including Windows `PATHEXT`), so `teamai mcp inject` no longer skips servers such as `uvx` on Windows ([#540](https://github.com/Tencent/teamai-cli/pull/540), for [#539](https://github.com/Tencent/teamai-cli/issues/539)).
- The GitHub and CNB providers resolve their CLI to a launchable absolute path and start it through cross-spawn, so on Windows they no longer answer "installed" while every call fails silently ([#520](https://github.com/Tencent/teamai-cli/pull/520)).
Expand Down
2 changes: 2 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,8 @@ sharing:

The prompt is checked when the `UserPromptSubmit` hook captures it, so a change to the team keywords applies to new prompts after the next `teamai pull`; sessions recorded earlier are not re-evaluated.

Matching normalizes both the prompt and keywords to Unicode NFC. For example, `réessaye` matches `re\u0301essaye`, where `\u0301` is a combining acute accent. Accents remain significant, so `reessaye` does not match. Normalization applies only to matching and does not change the stored prompt summary or the 60-second correction window.

Intervention data is automatically aggregated and reported to the team's `stats/<user>.yaml` during `teamai pull`, and shown in the "Session Autonomy" leaderboard of `teamai digest`, with team averages and per-person intervention rate rankings — useful for verifying whether a skill/rule reduces intervention rates after rollout. Tools without a transcript (e.g. Cursor) degrade gracefully, tracking only `correction`.

#### Conversation Volume & Token Usage
Expand Down
2 changes: 2 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,8 @@ sharing:

匹配在 `UserPromptSubmit` hook 捕获 prompt 时完成,因此修改团队纠偏词后,下一次 `teamai pull` 之后的新 prompt 才会生效;之前记录的会话不会重新评估。

匹配时,prompt 和纠偏词都会转换为 Unicode NFC 形式。例如,`réessaye` 可以匹配 `re\u0301essaye`,其中 `\u0301` 是组合尖音符。重音符号仍有区别,因此 `reessaye` 不匹配。规范化仅用于匹配,不会改变已存储的 prompt 摘要或 60 秒的纠偏时间窗口。

干预数据会随 `teamai pull` 自动聚合上报到团队 `stats/<user>.yaml`,并在 `teamai digest` 的「会话自主性」榜单中给出团队均值与人均干预率排行,可用于验证某个 skill / rule 上线后干预率是否下降。无 transcript 的工具(如 Cursor)会优雅降级,只统计 `correction`。

#### 对话量与 Token 用量
Expand Down
59 changes: 59 additions & 0 deletions src/__tests__/dashboard-collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,37 @@ describe('parseHookEvent', () => {
}
});

it.each([
['NFC keyword and NFD prompt', 'r\u00e9essaye', 're\u0301essaye'],
['NFD keyword and NFC prompt', 're\u0301essaye', 'r\u00e9essaye'],
['NFD keyword and uppercase prompt', 're\u0301essaye', 'R\u00c9ESSAYE!'],
['NFD prompt past the summary limit', 'r\u00e9essaye', `${'x '.repeat(150)}re\u0301essaye`],
])('matches canonically equivalent text: %s', async (_label, keyword, prompt) => {
const event = await parseHookEvent(
JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }),
'claude',
{ correctionKeywords: [keyword] },
);
expect(event?.correction).toBe(true);
expect(event?.promptSummary).toBe(prompt.slice(0, 200));
});

it.each([
['missing accent', 'reessaye'],
['different accent', 're\u0300essaye'],
['letter prefix', 'pre\u0301essaye'],
['letter suffix', 're\u0301essayez'],
['underscore prefix', 'test_re\u0301essaye'],
['underscore suffix', 're\u0301essaye_it'],
])('does not match a team keyword with a %s', async (_label, prompt) => {
const event = await parseHookEvent(
JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }),
'claude',
{ correctionKeywords: ['r\u00e9essaye'] },
);
expect(event?.correction).toBe(false);
});

it('checks the full prompt, not only the 200-char summary', async () => {
const prompt = `${'x '.repeat(150)}wrong`;
const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt });
Expand Down Expand Up @@ -734,6 +765,26 @@ describe('parseHookEvent interventions', () => {
describe('rebuildSessions interventions', () => {
const now = new Date().toISOString();

it.each([
['NFC keyword and NFD prompt', 'r\u00e9essaye', 're\u0301essaye'],
['NFD keyword and NFC prompt', 're\u0301essaye', 'r\u00e9essaye'],
])('counts a Unicode correction within the time window: %s', async (_label, keyword, prompt) => {
const event = await parseHookEvent(
JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }),
'claude',
{ correctionKeywords: [keyword] },
);
if (!event) throw new Error('Expected a prompt-submit event');

for (const [gap, expected] of [[0, 1], [60_000, 1], [60_001, 0]]) {
const sessions = rebuildSessions([
{ type: 'stop', timestamp: now, sessionId: 's', tool: 'claude' },
{ ...event, timestamp: new Date(new Date(now).getTime() + gap).toISOString() },
]);
expect(sessions[0]?.interventions.correction, `gap ${gap}`).toBe(expected);
}
});

it('defaults to zero interventions', () => {
const sessions = rebuildSessions([
{ type: 'session_start', timestamp: now, sessionId: 's1', tool: 'claude', cwd: '/p' },
Expand All @@ -742,6 +793,14 @@ describe('rebuildSessions interventions', () => {
expect(sessions[0].interventionCount).toBe(0);
});

it('normalizes built-in keywords when a legacy event has no correction flag', () => {
const sessions = rebuildSessions([
{ type: 'stop', timestamp: now, sessionId: 's', tool: 'claude' },
{ type: 'prompt_submit', timestamp: now, sessionId: 's', tool: 'claude', promptSummary: '\u3061\u304b\u3099\u3046' },
]);
expect(sessions[0]?.interventions.correction).toBe(1);
});

it('takes interrupt/toolReject from the latest stop snapshot (idempotent)', () => {
const sessions = rebuildSessions([
{ type: 'session_start', timestamp: now, sessionId: 's1', tool: 'claude', cwd: '/p' },
Expand Down
4 changes: 2 additions & 2 deletions src/dashboard-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,7 @@ function wordBoundaryPattern(keyword: string): RegExp {

/** True when `lower` contains `keyword`, whole-word for spaced scripts, substring otherwise. */
function containsKeyword(lower: string, keyword: string): boolean {
const k = keyword.trim().toLowerCase();
const k = keyword.trim().normalize('NFC').toLowerCase();
if (!k) return false;
if (UNSPACED_SCRIPT_RE.test(k)) return lower.includes(k);
return wordBoundaryPattern(k).test(lower);
Expand All @@ -836,7 +836,7 @@ function containsKeyword(lower: string, keyword: string): boolean {
*/
function isCorrectionPrompt(text?: string, extraKeywords: readonly string[] = []): boolean {
if (!text) return false;
const lower = text.toLowerCase();
const lower = text.normalize('NFC').toLowerCase();
return [...CORRECTION_KEYWORDS, ...extraKeywords].some((k) => containsKeyword(lower, k));
}

Expand Down
Loading