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
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Changelog

## 0.17.0 (unreleased)
## 0.17.1 (2026-10-17)

- Share one request-local source scan by default for up to four local-text keywords in `prepare_context`, preserving query order, diagnostics and scoped/ranged bypasses. Bounded scans or any query reaching 500 matches fall back to sequential queries under the existing deadline and can cost more. Add opt-in internal phase/work metrics and a model-free paired `benchmark:context` command with an explicit sequential baseline. No new MCP arguments, dependencies, persistent source cache or UI behavior changes.
- Update the managed Skill to explain multi-name context requests, existing scan limits, fallback cost and incomplete-result handling.

## 0.17.0

- Add an explicit name + CheckBox selector mode to checkbox-audit for controls without AutomationIds. Validate only task-relevant known properties while retaining raw gaps and refusing uncertain identity, ambiguity, unknown state and incomplete coverage. Preserve the existing ID-based parameter, result and validation contracts; named results return exact selectors in request order. No UI actions or default configuration changes.
- Reorganize the four managed Skill documents by task. Keep the entry short, place executable UI recipe examples inside the installed Skill, and remove duplicated instructions and historical iteration notes.
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,16 +174,19 @@ Use actual paths and line numbers from the search result. `lineRanges` selects k

### Development and documentation

Current source version: **0.17.0**. See [CHANGELOG](CHANGELOG.md) for version history and migration notes. Windows 11 x64 is the reference platform; ports to other operating systems require adaptation and separate validation.
Current source version: **0.17.1**. See [CHANGELOG](CHANGELOG.md) for version history and migration notes. Windows 11 x64 is the reference platform; ports to other operating systems require adaptation and separate validation.

```powershell
npm run check # Builds, core regression, stdio integration and delivery verification
npm run check:desktop # WPF and UI-to-source tests; requires an interactive Windows desktop
npm run delivery:verify # Verify Gateway, native components and managed Skill artifacts
npm run test:inventory # Verify automated-test suite registration
npm run benchmark:agent -- 1 # Run one iteration of the optional scripted benchmark
npm run benchmark:context -- compare 8 # Model-free, paired sequential/batch context experiment
```

`benchmark:context` accepts `baseline|compare`, 1–100 repetitions, and an optional existing WinCode checkout path. Without a path it creates an isolated fixture under ignored `test-tmp/context-work/`; reports remain there. It exercises real MCP handlers over in-memory transport, records request-local inclusive phase times and application-level scan/read/parse counters, checks paired response equality and source evidence, and verifies input hashes. `first` means a fresh application cache, not cold OS disk cache. Initialization, prewarm and cleanup are excluded; no model, UIA, or network is used. Production local-text context requests batch up to four extracted keywords by default; the benchmark explicitly selects sequential queries as its baseline. Truncated/capped batches fall back to sequential queries and may cost more; no P95 or general Agent speedup is inferred.

[CI](https://github.com/linnnn89/WinCode/actions/workflows/ci.yml) runs on Windows with Node.js 22 and 24 and the pinned .NET SDK. The Node.js 22 job includes additional native and integration checks; desktop tests run separately. Benchmark reports measure scripted scenarios, including calls, response size and execution time. See the linked records for test conditions and results.

- [Contributing](CONTRIBUTING.md): builds, test suites and delivery requirements.
Expand Down Expand Up @@ -346,16 +349,19 @@ npm run delivery:verify

### 开发与文档

当前源码版本为 **0.17.0**。版本历史和迁移说明见 [CHANGELOG](CHANGELOG.md)。项目以 Windows 11 x64 为基准平台,移植至其他操作系统需要适配并单独验证。
当前源码版本为 **0.17.1**。版本历史和迁移说明见 [CHANGELOG](CHANGELOG.md)。项目以 Windows 11 x64 为基准平台,移植至其他操作系统需要适配并单独验证。

```powershell
npm run check # 构建、核心回归、stdio 集成和交付校验
npm run check:desktop # WPF 与 UI 源码关联测试,需要交互式 Windows 桌面
npm run delivery:verify # 校验 Gateway、原生组件和交付清单中的 Skill 文件
npm run test:inventory # 核对自动化测试的套件注册情况
npm run benchmark:agent -- 1 # 执行一轮可选的脚本化基准测试
npm run benchmark:context -- compare 8 # 不调用模型,对照逐关键词和批量上下文查询
```

`benchmark:context` 接受 `baseline|compare`、1–100 次重复及可选的现有 WinCode 工作区路径。省略路径时,在已忽略的 `test-tmp/context-work/` 创建隔离样本并保留 JSON 报告。通过内存内 MCP transport 调用真实 handler,记录请求级包含子阶段的耗时与应用层扫描、读取、解析计数,核对配对响应、源码证据和输入哈希。`first` 仅表示应用缓存未预热,不代表冷磁盘缓存;计时排除初始化、预热和清理,不调用模型、UIA 或网络。生产 local-text 上下文请求默认合并最多四个提取关键词的扫描;基准显式选择逐关键词路径作为对照。截断或达到结果上限时回退逐查询,可能更慢;报告不推断 P95 或通用 Agent 提速。

[CI](https://github.com/linnnn89/WinCode/actions/workflows/ci.yml) 在 Windows 环境中使用 Node.js 22、24 和固定版本的 .NET SDK 运行。在 Node.js 22 的任务中,还会额外执行原生组件与集成检查;桌面测试单独运行。基准报告记录脚本化场景中的调用次数、响应大小和执行时间,具体测试条件及结果见以下文档。

- [贡献指南](CONTRIBUTING.md):构建、测试套件和交付要求。
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "wincode-mcp",
"version": "0.17.0",
"version": "0.17.1",
"description": "Windows-first MCP gateway: .NET project graph, evidence-bounded context, honest change-impact, long-running process hygiene",
"main": "dist/index.js",
"type": "module",
Expand All @@ -15,6 +15,7 @@
"test": "tsx --test tests/core-cache.test.ts tests/workspace-files.test.ts tests/text-symbols.test.ts tests/context-packing.test.ts tests/composite-tools.test.ts tests/mcp-stdio.test.ts tests/stability-lifecycle.test.ts tests/cache-budgets.test.ts tests/process-failures.test.ts tests/request-concurrency.test.ts tests/evidence-confidence.test.ts tests/watch-invalidation.test.ts tests/resource-cleanup.test.ts tests/stage1-cleanup.test.ts tests/ui-hardening.test.ts tests/ui-source-review.test.ts tests/v071-acceptance.test.ts tests/ui-background.test.ts tests/ui-audit.test.ts tests/ui-query.test.ts tests/context-efficiency.test.ts tests/agent-efficiency-benchmark.test.ts tests/workspace-summary.test.ts tests/runtime-identity.test.ts tests/runtime-contract.test.ts tests/context-coverage.test.ts tests/semantic-identity.test.ts tests/ui-code-candidates.test.ts tests/skill-sync.test.ts tests/local-text.test.ts tests/repomix-disabled.test.ts tests/workspace-watch-close.test.ts tests/workspace-lifecycle.test.ts tests/failure-recovery.test.ts tests/tool-contracts.test.ts tests/architecture-boundaries.test.ts tests/lifecycle-cancellation.test.ts tests/delivery-contract.test.ts tests/roslyn-contracts.test.ts tests/gateway-exit.test.ts tests/owner-process-guard.test.ts tests/manual-release.test.ts tests/tray-client.test.ts tests/runtime-cache-regressions.test.ts tests/architecture-safety.test.ts tests/resource-identity.test.ts tests/check-reporting.test.ts tests/fixed-workspace.test.ts tests/request-admission.test.ts tests/design-time-artifacts.test.ts tests/skill-session.test.ts tests/hybrid-ui-workflow.test.ts tests/hybrid-model.test.ts",
"test:verify": "tsx tests/verify.ts",
"benchmark:agent": "tsx scripts/benchmark-agent-efficiency.ts",
"benchmark:context": "tsx scripts/benchmark-context-work.ts",
"benchmark:hybrid": "tsx scripts/benchmark-hybrid-ui.ts",
"build:hybrid-fixture": "node scripts/build-hybrid-fixture.mjs",
"test:benchmark": "tsx --test tests/agent-efficiency-benchmark.test.ts",
Expand Down
140 changes: 140 additions & 0 deletions scripts/benchmark-context-work.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/** Local, model-free paired experiment through real MCP handlers and in-memory transport. */
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { getDefaultConfig } from '../src/Core/Config.js';
import { ToolRouter } from '../src/Core/ToolRouter.js';
import { ContextManager } from '../src/Core/Context.js';
import { OperationMetrics } from '../src/Core/OperationMetrics.js';
import { WinCodeMcpServer } from '../src/Gateway/McpServer.js';

const root = path.resolve(import.meta.dirname, '..');
const mode = process.argv[2] ?? 'baseline';
const repetitions = Number(process.argv[3] ?? 5);
if (!['baseline', 'compare'].includes(mode) || !Number.isInteger(repetitions) || repetitions < 1 || repetitions > 100 || process.argv.length > 5)
throw new Error('Usage: tsx scripts/benchmark-context-work.ts [baseline|compare] [1-100 repetitions] [existing workspace]');
const output = path.join(root, 'test-tmp', 'context-work', `${Date.now()}-${process.pid}`);
await fs.mkdir(output, { recursive: true });
const workspace = process.argv[4] ? path.resolve(process.argv[4]) : path.join(output, 'fixture');
if (!process.argv[4]) {
await fs.mkdir(workspace);
await fs.writeFile(path.join(workspace, 'package.json'), '{"name":"context-work-fixture"}');
for (let i = 0; i < 96; i++) {
await fs.writeFile(path.join(workspace, `Service${String(i).padStart(3, '0')}.ts`),
['Alpha', 'Bravo', 'Charlie', 'Delta'].map(name => `export function ${name}${i}() { return '${name}-${i}'; }`).join('\n') +
'\n' + Array.from({ length: 32 }, (_, n) => `// padding ${n} ${'x'.repeat(64)}`).join('\n'));
}
await fs.writeFile(path.join(workspace, 'Overflow.ts'), Array.from({ length: 510 }, (_, i) => `export function Overflow${i}() {}`).join('\n'));
}
const words = process.argv[4] ? ['ContextManager', 'LocalTextAdapter', 'OperationContext', 'scanLocalFiles'] : ['Alpha', 'Bravo', 'Charlie', 'Delta'];
const cases = [1, 2, 4].map(n => ({ id: `${n}-keywords`, args: { task: words.slice(0, n).join(' '), maxTokens: 8000 } as Record<string, unknown> }));
cases.push({ id: 'known-range', args: { task: 'Read exact source', lineRanges: [{ file: process.argv[4] ? 'src/Core/OperationContext.ts' : 'Service000.ts', startLine: 1, endLine: 4 }], maxTokens: 8000 } });
cases.push({ id: '4-keywords-fulltext', args: { task: words.join(' '), includeFullText: true, maxTokens: 16000 } });
if (!process.argv[4]) cases.push({ id: 'result-limit', args: { task: 'Overflow NoSuchSymbol', maxTokens: 8000 } });
const variants = mode === 'compare' ? ['sequential', 'batch'] : ['sequential'];
const samples: Array<Record<string, any>> = [];
const sourceFiles = ['scripts/benchmark-context-work.ts', 'src/Core/Context.ts', 'src/Core/LocalTextScanner.ts',
'src/Core/OperationContext.ts', 'src/Core/OperationMetrics.ts', 'src/Adapters/LocalTextAdapter.ts', 'src/Adapters/RepomixAdapter.ts', 'src/Core/CodeQueries.ts'];
const hashes = async () => Object.fromEntries(await Promise.all(sourceFiles.map(async file =>
[file, createHash('sha256').update(await fs.readFile(path.join(root, file))).digest('hex')])));
const sourceHashes = await hashes();
const listInputs = async () => process.argv[4] ? execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'],
{ cwd: workspace, encoding: 'utf8', windowsHide: true }).split('\0').filter(Boolean) : await fs.readdir(workspace);
const inputHash = async () => {
const hash = createHash('sha256');
for (const file of (await listInputs()).sort()) { hash.update(file + '\0'); hash.update(createHash('sha256').update(await fs.readFile(path.join(workspace, file))).digest()); }
return hash.digest('hex');
};
const workspaceHash = await inputHash();
const revision = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', windowsHide: true }).trim();

async function run(variant: string, state: string, args: Record<string, unknown>) {
const config = getDefaultConfig(workspace);
config.cacheDir = path.join(output, `cache-${samples.length}-${variant}`);
config.adapters.flaui.enabled = false;
config.adapters.repomix.useCli = false;
if (config.adapters.roslyn) config.adapters.roslyn.enabled = false;
const router = new ToolRouter(config);
if (variant === 'sequential') router.context = new ContextManager(config, router.workspace, router.repomix, router.text, { batchSymbols: false });
let metrics: OperationMetrics | undefined;
const prepare = router.context.prepareContext.bind(router.context);
router.context.prepareContext = async (options, operation) => {
const end = metrics?.start('context.total');
try { return await prepare(options, { ...operation, metrics }); } finally { end?.(); }
};
const server = new WinCodeMcpServer(router);
const client = new Client({ name: 'context-work-benchmark', version: '1' });
const [ct, st] = InMemoryTransport.createLinkedPair();
try {
await router.initialize();
await Promise.all([client.connect(ct), (server as any).server.connect(st)]);
if (state === 'warm') assert.notEqual((await client.callTool({ name: 'wincode_prepare_context', arguments: args })).isError, true);
metrics = new OperationMetrics();
const begin = performance.now();
const response = await client.callTool({ name: 'wincode_prepare_context', arguments: args });
const elapsedMs = performance.now() - begin;
const responseFile = `response-${samples.length}-${variant}.json`;
await fs.writeFile(path.join(output, responseFile), JSON.stringify(response, null, 2));
assert.notEqual(response.isError, true);
const text = (response.content as { type: string; text: string }[]).find(block => block.type === 'text')!.text;
const data = JSON.parse(text);
assert.ok(data.evidence.length > 0, 'Expected usable source evidence.');
for (const item of data.evidence) {
assert.ok(!path.isAbsolute(item.file) && !item.file.split(/[\\/]/).includes('..'));
const source = await fs.readFile(path.join(workspace, item.file), 'utf8');
if (args.includeFullText) {
if (item.bodyStatus === 'complete') assert.ok(data.packedContent.includes(source), 'Complete packed file must contain the independently read source.');
else if (item.bodyStatus === 'partial') {
const marker = `File: ${item.file}\n${'='.repeat(48)}\n`;
const index = data.packedContent.indexOf(marker);
assert.ok(index >= 0, 'Partial packed file must have an observed boundary.');
assert.ok(source.startsWith(data.packedContent.slice(index + marker.length)), 'Partial packed body must be a source prefix.');
} else assert.equal(item.bodyStatus, 'omitted');
continue;
}
const lines = source.split(/\r?\n/);
const actual = lines.slice(item.startLine - 1, item.endLine).join('\n');
assert.ok(item.snippet === actual || (item.truncated && actual.startsWith(item.snippet)), 'Evidence must match independent file read.');
}
if (!process.argv[4]) assert.equal(data.queryComplete, args.task !== 'Overflow NoSuchSymbol');
const keywordCount = String(args.task).split(' ').length;
if (!args.lineRanges && variant === 'sequential') assert.equal(metrics.counters['scan.invocations'], keywordCount, 'Benchmark keywords must survive production stopword filtering.');
return { elapsedMs, responseFile, responseBytes: Buffer.byteLength(text), responseHash: createHash('sha256').update(text).digest('hex'),
queryComplete: data.queryComplete, truncated: data.truncated, counters: metrics.counters, phases: metrics.phases };
} finally { await client.close(); await server.stop(); }
}

const median = (values: number[]) => { const sorted = [...values].sort((a, b) => a - b); return sorted.length % 2 ? sorted[(sorted.length - 1) / 2] : (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2; };
let failure: string | undefined;
try {
for (let repeat = 0; repeat < repetitions; repeat++) for (const testCase of cases) for (const state of ['first', 'warm']) {
const pair: typeof samples = [];
for (const variant of repeat % 2 ? [...variants].reverse() : variants) {
const sample = { case: testCase.id, state, variant, repeat, ...await run(variant, state, testCase.args) };
samples.push(sample); pair.push(sample);
}
if (pair.length === 2) assert.equal(pair[0].responseHash, pair[1].responseHash, `Response mismatch: ${testCase.id}/${state}`);
}
} catch (error) { failure = error instanceof Error ? error.stack : String(error); }
const summaries = cases.flatMap(c => ['first', 'warm'].flatMap(state => variants.map(variant => {
const rows = samples.filter(s => s.case === c.id && s.state === state && s.variant === variant);
const names = [...new Set(rows.flatMap(s => Object.keys(s.counters)))];
const phases = [...new Set(rows.flatMap(s => Object.keys(s.phases)))];
return { case: c.id, state, variant, samples: rows.length, medianMs: rows.length ? median(rows.map(s => s.elapsedMs)) : null,
counters: Object.fromEntries(names.map(n => [n, median(rows.map(s => s.counters[n] ?? 0))])),
phaseMs: Object.fromEntries(phases.map(n => [n, median(rows.map(s => s.phases[n]?.elapsedMs ?? 0))])) };
})));
const sourcesUnchanged = JSON.stringify(sourceHashes) === JSON.stringify(await hashes());
const inputsUnchanged = workspaceHash === await inputHash();
const report = { revision, sourceHashes, sourcesUnchanged, workspaceHash, inputsUnchanged, mode, repetitions, workspace, node: process.version, failure,
methodology: { transport: 'in-memory MCP; actual router, handlers, local-text and serialization; no model, UIA or network',
timing: 'callTool wall time; initialization, prewarm, oracle reads and cleanup excluded. first means fresh application cache, not cold OS disk cache.',
counters: 'scan.* covers LocalTextScanner only; bytesRead are bytes returned by FileHandle.read, not physical disk IO. context decoded bytes exclude project discovery and packer IO.',
phases: 'Inclusive and potentially nested; do not sum. Response hashes must match within each pair; fixture evidence checked against independent source reads.',
samples: 'Repeated fixed tasks; no population or P95 claim. Existing workspace input must remain unchanged during measurement.' }, summaries, samples };
await fs.writeFile(path.join(output, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ report: path.join(output, 'report.json'), sourcesUnchanged, inputsUnchanged, failure, summaries }, null, 2));
if (failure || !sourcesUnchanged || !inputsUnchanged) process.exitCode = 1;
Loading
Loading