sync: update from private repo (ddadfd71)
CI / build-and-test (push) Waiting to run

This commit is contained in:
oss-sync
2026-07-08 23:35:00 +00:00
parent b1292e34b2
commit 77ee3bc426
187 changed files with 19918 additions and 10938 deletions
+48
View File
@@ -1339,3 +1339,51 @@ describe('Read: document formats vs text-only range params', () => {
expect(result?.output ?? '').not.toContain('offset/limit');
});
});
// Livelock fix follow-up: Grep was the only core output tool without a token
// budget — Read and Bash truncate, Grep returned every matching line raw. A
// broad pattern over a large workspace injects an unbounded blob into the
// conversation in one step, which is exactly what pushes the prompt into the
// preflight danger band.
describe('Grep output budget', () => {
let ws = '';
afterEach(() => {
if (ws) {
fs.rmSync(ws, { recursive: true, force: true });
ws = '';
}
});
it('truncates oversized match lists to the context budget with a notice', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
// ~40k matching lines × ~30 chars ≈ 1.2MB — far over the 60k-token
// absolute cap that applies when no contextManager is present.
const lines: string[] = [];
for (let i = 0; i < 40_000; i++) {
lines.push(`needle match line ${i} padding-padding`);
}
fs.writeFileSync(path.join(ws, 'output', 'big.txt'), lines.join('\n'), 'utf-8');
const result = await executeCoreTools('Grep', { pattern: 'needle', path: 'output' }, makeContext(ws));
expect(result?.isError).toBe(false);
expect(result?.output).toContain('[自動切り詰め]');
expect(result?.output.length ?? 0).toBeLessThan(1_000_000);
// The head of the match list is still returned.
expect(result?.output).toContain('big.txt:1:');
});
it('returns small match lists untouched', async () => {
ws = makeWorkspace();
fs.mkdirSync(path.join(ws, 'output'), { recursive: true });
fs.writeFileSync(path.join(ws, 'output', 'small.txt'), 'one needle here\n', 'utf-8');
const result = await executeCoreTools('Grep', { pattern: 'needle', path: 'output' }, makeContext(ws));
expect(result?.isError).toBe(false);
expect(result?.output).not.toContain('[自動切り詰め]');
expect(result?.output).toContain('small.txt:1:');
});
});
+52 -3
View File
@@ -261,6 +261,41 @@ export interface ToolContext {
* then resumes the SAME movement. Mirrors pendingToolApproval.
*/
pendingSubtaskWait?: boolean;
/**
* Package-request mechanism: recorder for agent-declared Python package
* requests (RequestPackage). Bound by the worker (task/job/space) ONLY when
* the feature is enabled AND the run has a resolvable space. Undefined =>
* feature unavailable for this run (RequestPackage records nothing and tells
* the agent to proceed). piece-runner wraps it to inject piece + movement.
*/
recordPackageRequest?: (req: {
spec: string;
normalizedName: string;
reason?: string | null;
status?: 'pending' | 'approved' | 'denied' | 'auto_denied';
}) => void;
/**
* Package-request mechanism: packages already installed in this workspace's
* overlay (name→canonical spec, from the space desired-state at job start).
* Version-aware: a pinned request (`name==version`) is only "already
* installed" on an exact spec match; a bare-name request matches any version.
* Lets RequestPackage short-circuit (and after a resume, recognise the
* just-approved one). Undefined when the feature is off.
*/
installedPackages?: Array<{ name: string; spec: string }>;
/**
* Package-request mechanism: PEP 503 normalized names already declined
* (denied / auto_denied) for THIS job. RequestPackage short-circuits a
* re-request for one of these instead of re-parking — otherwise a deny would
* loop (deny → movement re-runs → re-request → park). Undefined => none.
*/
declinedPackages?: string[];
/**
* Set by RequestPackage to signal the movement loop to pause (waiting_human)
* for inline approval of the named package. Read + reset by agent-loop.
* Mirrors pendingToolApproval.
*/
pendingPackageApproval?: { spec: string } | null;
/** Per-task option: when true, MCP tools are not loaded/dispatched. */
mcpDisabled?: boolean;
/** Per-task option: when true, skill index is not injected into the system prompt. */
@@ -920,8 +955,8 @@ export function checkBlockedInstallPatterns(command: string): void {
throw new Error(
`Package installation is not available in the sandbox. ` +
`These are preinstalled and importable: ${PREINSTALLED_HINT}. ` +
`If you need another package, ask an admin to add it to ` +
`runtime/python-requirements.txt and rebuild — do not retry the install.`
`If you need another package, request it with RequestPackage({ name: "<pkg>", reason: "..." }) ` +
`— an approver installs it into this workspace and the run resumes. Do not retry the install here.`
);
}
}
@@ -1876,7 +1911,21 @@ function executeGrep(input: Record<string, unknown>, ctx: ToolContext): ToolResu
if (results.length === 0) {
return { output: '(no matches)', isError: false };
}
return { output: results.join('\n'), isError: false };
// Read と Bash は予算切り詰めがあるのに Grep だけ無制限だった。緩いパターン
// 1回で数十万文字が会話に流れ込み、プロンプトを preflight 上限際まで押し込む
// 起爆剤になっていたので、同じトークン予算で切り詰める。
const budgetTokens = getToolOutputBudgetTokens(ctx);
const raw = results.join('\n');
const { text, truncated } = truncateToBudget(raw, budgetTokens, {
sourceLabel: `Grep 結果 (pattern: ${pattern})`,
totalLines: results.length,
continuationHint:
'マッチが多すぎます。pattern をより具体的にする、glob でファイルを絞る、path でディレクトリを絞る、のいずれかで検索範囲を狭めてください',
});
if (truncated) {
logger.info(`[grep] truncated matches=${results.length} original_chars=${raw.length} budget_tokens=${budgetTokens}`);
}
return { output: text, isError: false };
}
// コアツールを実行 (Read, Write, Edit, Bash, Glob, Grep)
+2
View File
@@ -275,6 +275,8 @@ export async function callVisionModel(
const toolsConfig = ctx.toolsConfig ?? {};
const visionModel = toolsConfig.visionModel ?? 'qwen2-vl:8b-instruct';
// Neutral placeholder — set tools.vision_base_url in config.yaml for a real endpoint.
// (Workers usually override this with their own endpoint; see worker.ts)
const visionBaseUrl = toolsConfig.visionBaseUrl ?? 'http://10.0.0.10:11434/v1';
const visionTimeout = (toolsConfig.visionTimeout ?? 60) * 1000;
const visionMaxTokens = toolsConfig.visionMaxTokens ?? 1024;
+22 -1
View File
@@ -245,6 +245,15 @@ async function getToolRequestModule(): Promise<ToolModule | null> {
return _toolRequestModule;
}
let _packageRequestModule: ToolModule | null | undefined;
async function getPackageRequestModule(): Promise<ToolModule | null> {
if (_packageRequestModule === undefined) {
_packageRequestModule = await tryLoadModule('./package-request.js');
if (_packageRequestModule) logger.debug('[tools/index] package-request module loaded');
}
return _packageRequestModule;
}
/**
* Snapshot of all known static tool names, refreshed every getToolDefs() call
* (which runs at movement start, before any tool executes). Injected into
@@ -418,6 +427,9 @@ export async function getToolDefs(
const toolRequestMod = await getToolRequestModule();
if (toolRequestMod) Object.assign(allDefs, toolRequestMod.TOOL_DEFS);
const packageRequestMod = await getPackageRequestModule();
if (packageRequestMod) Object.assign(allDefs, packageRequestMod.TOOL_DEFS);
// Refresh the known-tool snapshot for RequestTool classification.
//
// CONCURRENCY INVARIANT (must hold): this is taken HERE — after every static
@@ -444,7 +456,8 @@ export async function getToolDefs(
// - ReadAppDoc / ListAppDocs / GetMyOrchestratorState: Help アシスタント用 (#help piece)
// ただし他の piece からも参照できるようにメタ扱い
// - RequestTool: 足りないツールの要求を記録(タスク詳細/ピース集計で可視化)
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'SearchTaskConversation', 'ReadTaskConversation', 'SearchWorkspaceTasks', 'GetFileProvenance', 'ListWorkspaceFiles', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill', 'RequestTool'];
// - RequestPackage: 足りない Python パッケージ(wheel)を申請(承認でスペース overlay へ install)
const META_TOOLS = ['ReadToolDoc', 'CreateChecklist', 'CheckItem', 'GetChecklist', 'MissionUpdate', 'SearchTaskConversation', 'ReadTaskConversation', 'SearchWorkspaceTasks', 'GetFileProvenance', 'ListWorkspaceFiles', 'ListUserAssets', 'RunUserScript', 'UpdateUserMemory', 'ReadUserMemory', 'ReadUserAgents', 'UpdateUserAgents', 'WriteUserScript', 'Brainstorm', 'ReadAppDoc', 'ListAppDocs', 'GetMyOrchestratorState', 'ReadSkill', 'ListSkills', 'InstallSkill', 'RequestTool', 'RequestPackage'];
const effectiveAllowed = [...allowedTools];
for (const meta of META_TOOLS) {
if (!effectiveAllowed.includes(meta) && meta in allDefs) {
@@ -674,6 +687,14 @@ async function executeToolInner(
if (toolRequestResult !== null) return toolRequestResult;
}
// RequestPackage: 足りない Python パッケージ(wheel)を申請。承認でスペース
// overlay へ install され、ジョブ再開で import 可能になる。
const packageRequestMod = await getPackageRequestModule();
if (packageRequestMod) {
const packageRequestResult = await packageRequestMod.executeTool(name, input, ctx);
if (packageRequestResult !== null) return packageRequestResult;
}
// user-folder ツール (ListUserAssets / RunUserScript)
const userFolderMod = await getUserFolderModule();
if (userFolderMod) {
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest';
import type { ToolContext } from './core.js';
import { executeTool } from './package-request.js';
const ctx = (over: Partial<ToolContext> = {}): ToolContext =>
({ workspacePath: '/ws', ...over } as ToolContext);
describe('RequestPackage tool', () => {
it('ignores non-RequestPackage names (returns null)', async () => {
expect(await executeTool('Bash', { name: 'x', reason: 'y' }, ctx())).toBeNull();
});
it('requires name and reason', async () => {
expect((await executeTool('RequestPackage', { reason: 'r' }, ctx()))?.isError).toBe(true);
expect((await executeTool('RequestPackage', { name: 'requests' }, ctx()))?.isError).toBe(true);
});
it('rejects an invalid / shadowing spec with a hard error (records nothing)', async () => {
const rec = vi.fn();
// "os" shadows the stdlib → assertNotShadowing throws.
const r = await executeTool('RequestPackage', { name: 'os', reason: 'need it' }, ctx({ recordPackageRequest: rec, toolApprovalInteractive: true }));
expect(r?.isError).toBe(true);
expect(rec).not.toHaveBeenCalled();
// a range operator is also rejected.
const r2 = await executeTool('RequestPackage', { name: 'requests>=2', reason: 'x' }, ctx({ recordPackageRequest: rec, toolApprovalInteractive: true }));
expect(r2?.isError).toBe(true);
});
it('reports feature unavailable when no recorder is bound (records nothing)', async () => {
const r = await executeTool('RequestPackage', { name: 'requests', reason: 'http' }, ctx());
expect(r?.isError).toBe(false);
expect(String(r?.output)).toContain('利用できません');
});
it('short-circuits an already-installed package (exact spec match)', async () => {
const rec = vi.fn();
const c = ctx({ recordPackageRequest: rec, installedPackages: [{ name: 'requests', spec: 'requests==2.32.3' }], toolApprovalInteractive: true });
const r = await executeTool('RequestPackage', { name: 'requests==2.32.3', reason: 'http' }, c);
expect(r?.isError).toBe(false);
expect(String(r?.output)).toContain('インストール済み');
expect(rec).not.toHaveBeenCalled();
expect(c.pendingPackageApproval).toBeUndefined(); // no park
});
it('short-circuits a bare-name request when any version is installed', async () => {
const rec = vi.fn();
const c = ctx({ recordPackageRequest: rec, installedPackages: [{ name: 'requests', spec: 'requests==2.32.3' }], toolApprovalInteractive: true });
const r = await executeTool('RequestPackage', { name: 'requests', reason: 'http' }, c);
expect(String(r?.output)).toContain('インストール済み');
expect(c.pendingPackageApproval).toBeUndefined();
});
it('does NOT park a pinned request for a different version than the one installed', async () => {
const rec = vi.fn();
const c = ctx({ recordPackageRequest: rec, installedPackages: [{ name: 'httpx', spec: 'httpx==0.27.0' }], toolApprovalInteractive: true });
const r = await executeTool('RequestPackage', { name: 'httpx==0.28.0', reason: 'need newer' }, c);
expect(r?.isError).toBe(false);
expect(String(r?.output)).toContain('固定');
expect(rec).not.toHaveBeenCalled();
expect(c.pendingPackageApproval).toBeUndefined(); // no dead-end park
});
it('does NOT re-park a package already declined for this job (deny loop guard)', async () => {
const rec = vi.fn();
const c = ctx({ recordPackageRequest: rec, declinedPackages: ['httpx'], toolApprovalInteractive: true });
const r = await executeTool('RequestPackage', { name: 'httpx==0.28.0', reason: 'retry' }, c);
expect(r?.isError).toBe(false);
expect(String(r?.output)).toContain('却下');
expect(rec).not.toHaveBeenCalled();
expect(c.pendingPackageApproval).toBeUndefined();
});
it('parks for approval on an interactive run (records pending)', async () => {
const rec = vi.fn();
const c = ctx({ recordPackageRequest: rec, toolApprovalInteractive: true });
const r = await executeTool('RequestPackage', { name: 'Requests==2.32.3', reason: 'http calls' }, c);
expect(r?.isError).toBe(false);
expect(c.pendingPackageApproval).toEqual({ spec: 'requests==2.32.3' });
expect(rec).toHaveBeenCalledWith(
expect.objectContaining({ spec: 'requests==2.32.3', normalizedName: 'requests', status: 'pending', reason: 'http calls' }),
);
});
it('records auto_denied and does NOT park on a non-interactive run', async () => {
const rec = vi.fn();
const c = ctx({ recordPackageRequest: rec, toolApprovalInteractive: false });
// httpx is neither stdlib nor preinstalled, so it passes the shadowing guard.
const r = await executeTool('RequestPackage', { name: 'httpx', reason: 'async http' }, c);
expect(r?.isError).toBe(false);
expect(c.pendingPackageApproval).toBeUndefined();
expect(rec).toHaveBeenCalledWith(expect.objectContaining({ normalizedName: 'httpx', status: 'auto_denied' }));
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* RequestPackage — META_TOOL for the agent-initiated Python package request.
*
* When an agent needs a Python wheel that is neither preinstalled nor already
* in the workspace overlay, it calls RequestPackage({ name, reason }). The
* request is recorded so it surfaces in the task detail; when the run can reach
* a user (interactive) the movement PARKS for approval. A task-write approver
* (owner / admin / space editor) then approves — which installs the wheel into
* THIS workspace's per-space overlay (reusing PR2's installSpacePackages:
* bwrap-isolated, wheels-only, fixed index, stdlib/preinstalled shadowing
* guards) and resumes the parked job so the movement re-runs with the package
* importable. The agent never installs anything itself.
*
* Gating (all runtime, so the tool def stays unconditional — see the
* concurrency invariant in tools/index.ts):
* - `ctx.recordPackageRequest` unset → feature unavailable for this run
* (disabled globally, or no resolvable space) → record nothing, proceed.
* - name already in `ctx.installedPackages` → already installed, just import.
* - `ctx.toolApprovalInteractive` → park (pendingPackageApproval).
* - otherwise (subtask / headless) → record auto_denied, proceed without.
*
* The spec is validated (parseAndValidateSpec + assertNotShadowing) HERE and
* re-validated at approval time — the DB stores the canonical spec string.
*/
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { parseAndValidateSpec, assertNotShadowing } from '../python-packages.js';
const REQUEST_PACKAGE_DEF: ToolDef = {
type: 'function',
function: {
name: 'RequestPackage',
description:
'preinstalled に無い Python ライブラリ(wheel)が必要なとき name(例: requests または requests==2.32.3)と理由(reason)を渡して申請する。ユーザーが応答できる実行では承認を求め、承認されればこのワークスペースに入り import できる。詳細は ReadToolDoc({ name: "RequestPackage" })。',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'パッケージ名。厳密固定は "name==version"(例: pandas==2.2.2)。バージョン省略可。' },
reason: { type: 'string', description: 'なぜそのパッケージが必要か(具体的に)。' },
},
required: ['name', 'reason'],
},
},
};
export const TOOL_DEFS: Record<string, ToolDef> = {
RequestPackage: REQUEST_PACKAGE_DEF,
};
export async function executeTool(
name: string,
input: Record<string, unknown>,
ctx: ToolContext,
): Promise<ToolResult | null> {
if (name !== 'RequestPackage') return null;
const pkgName = typeof input['name'] === 'string' ? input['name'].trim() : '';
const reason = typeof input['reason'] === 'string' ? input['reason'].trim() : '';
if (!pkgName) return { output: 'RequestPackage: name は必須です。', isError: true };
if (!reason) return { output: 'RequestPackage: reason(なぜ必要か)は必須です。', isError: true };
// Validate the spec (grammar + shadowing) up front. A HARD ERROR so the agent
// does not mistake a rejected spec for a pending approval.
let parsed;
try {
parsed = parseAndValidateSpec(pkgName);
assertNotShadowing(parsed.normalizedName);
} catch (e) {
return {
output:
`"${pkgName}" は申請できません: ${(e as Error).message}。` +
` 厳密固定 "name==version" のみ・標準ライブラリや同梱パッケージと同名は不可です。`,
isError: true,
};
}
// Feature unavailable for this run (disabled globally, or no resolvable space).
// recordPackageRequest is bound by the worker only when both hold. `?.` would
// be a silent no-op, so gate explicitly and DON'T claim it was recorded.
if (!ctx.recordPackageRequest) {
return {
output:
`Python パッケージの追加はこの実行では利用できません。` +
` 標準ライブラリや同梱パッケージで代替するか、達成できない場合は complete({status:"needs_user_input", missing_info:"..."}) でユーザーに依頼してください。`,
isError: false,
};
}
// Already declined for THIS job → don't re-park (deny loop guard). The user
// already said no; proceed without it.
if (ctx.declinedPackages?.includes(parsed.normalizedName)) {
return {
output:
`"${parsed.normalizedName}" の追加は既にこのタスクで却下されています。再申請せず、このパッケージ無しで進めてください(達成できない場合は complete({status:"needs_user_input"}))。`,
isError: false,
};
}
// Already installed in this workspace overlay → nothing to approve.
// Version-aware: a pinned request needs an exact spec match; a bare-name
// request is satisfied by any installed version. A pinned request for a
// DIFFERENT version than the one already pinned cannot be satisfied (only one
// version per package), so short-circuit WITHOUT parking (the approve path
// couldn't install it anyway) and tell the agent to use the existing one.
const sameName = (ctx.installedPackages ?? []).filter((p) => p.name === parsed.normalizedName);
if (sameName.length > 0) {
const requestIsPinned = parsed.spec.includes('==');
const exact = sameName.some((p) => p.spec === parsed.spec);
if (exact || !requestIsPinned) {
return {
output: `"${parsed.normalizedName}" は既にこのワークスペースにインストール済みです(${sameName[0].spec})。そのまま import して使ってください。`,
isError: false,
};
}
return {
output:
`"${parsed.normalizedName}" は既に ${sameName[0].spec} でこのワークスペースに固定されています。` +
` 1 パッケージにつき 1 バージョンのみです。別バージョンが必要なら管理者が 設定 → Python で入れ替える必要があります。今は既存版で進めてください。`,
isError: false,
};
}
// Interactive run: pause for inline approval. On approve the wheel is
// installed into the space overlay and the job resumes.
if (ctx.toolApprovalInteractive) {
ctx.recordPackageRequest({ spec: parsed.spec, normalizedName: parsed.normalizedName, reason, status: 'pending' });
ctx.pendingPackageApproval = { spec: parsed.spec };
return {
output:
`"${parsed.spec}" のインストール承認を待っています。` +
` 承認されればこのワークスペースに追加され import して続行でき、拒否されればそのパッケージ無しで進めることになります。`,
isError: false,
};
}
// Non-interactive (subtask / headless): record so the user finds it later,
// and proceed without the package.
ctx.recordPackageRequest({ spec: parsed.spec, normalizedName: parsed.normalizedName, reason, status: 'auto_denied' });
return {
output:
`"${parsed.spec}" のインストール要求を記録しました(この実行では自動で承認できません)。` +
` このパッケージ無しで進めるか、達成できない場合は complete({status:"needs_user_input", missing_info:"..."}) でユーザーに依頼してください。`,
isError: false,
};
}
@@ -0,0 +1,79 @@
import * as fs from 'fs';
import * as path from 'path';
import { tmpdir } from 'os';
import { describe, expect, it, afterEach, vi } from 'vitest';
import { SkillCatalog } from '../skills.js';
import { executeSkillTool } from './skills.js';
import type { ToolContext } from './core.js';
// Failure injection for the swap step: renameSync throws once when the
// destination matches, everything else passes through to the real fs.
const swapState = vi.hoisted(() => ({ failRenameToSuffix: null as string | null }));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
renameSync: (src: fs.PathLike, dst: fs.PathLike) => {
if (swapState.failRenameToSuffix && String(dst).endsWith(swapState.failRenameToSuffix)) {
swapState.failRenameToSuffix = null;
throw new Error('simulated EIO on rename');
}
return actual.renameSync(src, dst);
},
};
});
function makeTempDir(): string {
return fs.mkdtempSync(path.join(tmpdir(), 'skill-swap-test-'));
}
function writeSkill(dir: string, filename: string, content: string): void {
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), content, 'utf-8');
}
const DIR_SKILL = `---
name: tdd
description: TDD workflow
---
## Steps
`;
describe('materializeSkill swap failure', () => {
const dirs: string[] = [];
afterEach(() => {
swapState.failRenameToSuffix = null;
for (const d of dirs) fs.rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});
it('restores the previous copy when the swap rename fails', () => {
const systemDir = makeTempDir();
const userRoot = makeTempDir();
const workspace = makeTempDir();
dirs.push(systemDir, userRoot, workspace);
const skillDir = path.join(systemDir, 'tdd');
writeSkill(skillDir, 'SKILL.md', DIR_SKILL);
writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v1\n');
const catalog = new SkillCatalog(systemDir, userRoot);
const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext;
const first = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(first!.isError).toBe(false);
// Source updated; the tmp→dest swap will fail once (e.g. transient FS error).
writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v2\n');
swapState.failRenameToSuffix = `${path.sep}skills${path.sep}tdd`;
const second = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(second!.isError).toBe(false);
expect(second!.output).toContain('コピーできませんでした');
// The previous copy must be back in place, not lost.
const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8');
expect(copied).toContain('v1');
});
});
+372
View File
@@ -90,6 +90,210 @@ describe('ReadSkill tool', () => {
expect(fs.existsSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'))).toBe(true);
});
it('re-materializes when the source skill changed since the copy', () => {
const systemDir = makeTempDir();
const userRoot = makeTempDir();
const workspace = makeTempDir();
dirs.push(systemDir, userRoot, workspace);
const skillDir = path.join(systemDir, 'tdd');
writeSkill(skillDir, 'SKILL.md', DIR_SKILL);
writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v1\n');
writeSkill(path.join(skillDir, 'scripts'), 'removed-later.sh', '#!/bin/sh\necho old\n');
const catalog = new SkillCatalog(systemDir, userRoot);
const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext;
const first = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(first!.isError).toBe(false);
// Another task updates the skill in the store: one file rewritten, one removed.
writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v2 updated\n');
fs.rmSync(path.join(skillDir, 'scripts', 'removed-later.sh'));
const second = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(second!.isError).toBe(false);
const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8');
expect(copied).toContain('v2 updated');
// Files deleted at the source must not linger in the refreshed copy.
expect(fs.existsSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'removed-later.sh'))).toBe(false);
});
it('keeps the existing copy (including local edits) when the source is unchanged', () => {
const systemDir = makeTempDir();
const userRoot = makeTempDir();
const workspace = makeTempDir();
dirs.push(systemDir, userRoot, workspace);
const skillDir = path.join(systemDir, 'tdd');
writeSkill(skillDir, 'SKILL.md', DIR_SKILL);
writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho hi\n');
const catalog = new SkillCatalog(systemDir, userRoot);
const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext;
executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
// The agent tweaks its workspace copy; an unchanged source must not clobber it.
fs.writeFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), '#!/bin/sh\necho local edit\n');
const second = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(second!.isError).toBe(false);
const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8');
expect(copied).toContain('local edit');
});
it('keeps the copy when only source mtimes changed (content identical)', () => {
const systemDir = makeTempDir();
const userRoot = makeTempDir();
const workspace = makeTempDir();
dirs.push(systemDir, userRoot, workspace);
const skillDir = path.join(systemDir, 'tdd');
writeSkill(skillDir, 'SKILL.md', DIR_SKILL);
writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho hi\n');
const catalog = new SkillCatalog(systemDir, userRoot);
const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext;
executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
fs.writeFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), '#!/bin/sh\necho local edit\n');
// e.g. the skill store was re-synced: identical bytes, new timestamps.
const later = new Date(Date.now() + 60_000);
fs.utimesSync(path.join(skillDir, 'scripts', 'run.sh'), later, later);
fs.utimesSync(path.join(skillDir, 'SKILL.md'), later, later);
const second = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(second!.isError).toBe(false);
const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8');
expect(copied).toContain('local edit');
});
it('rejects an over-cap skill with the copy-limit note before touching the workspace', () => {
const systemDir = makeTempDir();
const userRoot = makeTempDir();
const workspace = makeTempDir();
dirs.push(systemDir, userRoot, workspace);
const skillDir = path.join(systemDir, 'tdd');
writeSkill(skillDir, 'SKILL.md', DIR_SKILL);
// Sparse 51MB file: over the 50MB cap without hitting the disk for real.
writeSkill(path.join(skillDir, 'scripts'), 'big.bin', '');
fs.truncateSync(path.join(skillDir, 'scripts', 'big.bin'), 51 * 1024 * 1024);
const catalog = new SkillCatalog(systemDir, userRoot);
const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext;
const result = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(result!.isError).toBe(false);
expect(result!.output).toContain('50MB');
expect(fs.existsSync(path.join(workspace, 'skills', 'tdd'))).toBe(false);
});
it('keeps the previous copy intact when refreshing fails mid-copy', () => {
const systemDir = makeTempDir();
const userRoot = makeTempDir();
const workspace = makeTempDir();
dirs.push(systemDir, userRoot, workspace);
const skillDir = path.join(systemDir, 'tdd');
writeSkill(skillDir, 'SKILL.md', DIR_SKILL);
writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v1\n');
const catalog = new SkillCatalog(systemDir, userRoot);
const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext;
executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
// Source changes, but one file becomes unreadable → the copy itself fails.
writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho v2\n');
fs.chmodSync(path.join(skillDir, 'scripts', 'run.sh'), 0o000);
try {
const second = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(second!.isError).toBe(false);
expect(second!.output).toContain('コピーできませんでした');
// The previously materialized copy must survive a failed refresh.
const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8');
expect(copied).toContain('v1');
} finally {
fs.chmodSync(path.join(skillDir, 'scripts', 'run.sh'), 0o644);
}
});
it('does not clobber a skill-owned .skill-src-signature file', () => {
const systemDir = makeTempDir();
const userRoot = makeTempDir();
const workspace = makeTempDir();
dirs.push(systemDir, userRoot, workspace);
const skillDir = path.join(systemDir, 'tdd');
writeSkill(skillDir, 'SKILL.md', DIR_SKILL);
writeSkill(skillDir, '.skill-src-signature', 'skill-owned data\n');
const catalog = new SkillCatalog(systemDir, userRoot);
const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext;
const result = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(result!.isError).toBe(false);
const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', '.skill-src-signature'), 'utf-8');
expect(copied).toBe('skill-owned data\n');
// And the up-to-date check still holds on the second read (no refresh loop).
fs.writeFileSync(path.join(workspace, 'skills', 'tdd', 'marker.txt'), 'local\n');
executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(fs.existsSync(path.join(workspace, 'skills', 'tdd', 'marker.txt'))).toBe(true);
});
it('sweeps abandoned tmp dirs but leaves fresh ones (concurrent copy in flight)', () => {
const systemDir = makeTempDir();
const userRoot = makeTempDir();
const workspace = makeTempDir();
dirs.push(systemDir, userRoot, workspace);
const skillDir = path.join(systemDir, 'tdd');
writeSkill(skillDir, 'SKILL.md', DIR_SKILL);
writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho hi\n');
// Leftover from a crashed run (old) and one from a copy in flight (fresh).
const staleTmp = path.join(workspace, 'skills', '.tdd.tmp-dead');
const freshTmp = path.join(workspace, 'skills', '.tdd.tmp-live');
writeSkill(staleTmp, 'SKILL.md', 'stale');
writeSkill(freshTmp, 'SKILL.md', 'fresh');
const old = new Date(Date.now() - 60 * 60_000);
fs.utimesSync(staleTmp, old, old);
const catalog = new SkillCatalog(systemDir, userRoot);
const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext;
const result = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(result!.isError).toBe(false);
expect(fs.existsSync(path.join(workspace, 'skills', 'tdd', 'SKILL.md'))).toBe(true);
expect(fs.existsSync(staleTmp)).toBe(false);
expect(fs.existsSync(freshTmp)).toBe(true);
});
it('refreshes a legacy copy that predates signature tracking', () => {
const systemDir = makeTempDir();
const userRoot = makeTempDir();
const workspace = makeTempDir();
dirs.push(systemDir, userRoot, workspace);
const skillDir = path.join(systemDir, 'tdd');
writeSkill(skillDir, 'SKILL.md', DIR_SKILL);
writeSkill(path.join(skillDir, 'scripts'), 'run.sh', '#!/bin/sh\necho fresh\n');
// Simulate a copy left behind by an older build (no signature file).
writeSkill(path.join(workspace, 'skills', 'tdd'), 'SKILL.md', DIR_SKILL);
writeSkill(path.join(workspace, 'skills', 'tdd', 'scripts'), 'run.sh', '#!/bin/sh\necho stale\n');
const catalog = new SkillCatalog(systemDir, userRoot);
const ctx = { workspacePath: workspace, editAllowed: false, skillCatalog: catalog, userId: 'user1' } as ToolContext;
const result = executeSkillTool('ReadSkill', { name: 'tdd' }, ctx);
expect(result!.isError).toBe(false);
const copied = fs.readFileSync(path.join(workspace, 'skills', 'tdd', 'scripts', 'run.sh'), 'utf-8');
expect(copied).toContain('fresh');
});
it('does NOT prepend path for single-file skills', () => {
const systemDir = makeTempDir();
const userRoot = makeTempDir();
@@ -417,6 +621,70 @@ describe('InstallSkill tool', () => {
expect(fs.existsSync(path.join(targetDir, 'scripts', 'run.sh'))).toBe(true);
});
// Folder/frontmatter name mismatch guard: installing under a name that
// differs from the SKILL.md frontmatter `name` produces a skill that is
// listed under the frontmatter name but stored under the install name —
// undeletable/uneditable from the UI. InstallSkill must reject the mismatch.
it('rejects a sourcePath install when the frontmatter name differs from the install name', () => {
const sourceDir = makeSourceSkill('fm-name');
const ctx = makeDirCtx();
const result = executeSkillTool('InstallSkill', {
sourcePath: sourceDir,
name: 'other-name',
scope: 'user',
}, ctx);
expect(result!.isError).toBe(true);
expect(result!.output).toContain('fm-name');
expect(result!.output).toContain('other-name');
expect(fs.existsSync(path.join(userRoot, 'user1', 'skills', 'other-name'))).toBe(false);
});
it('rejects a sourcePath install when SKILL.md has no valid frontmatter name', () => {
const skillDir = path.join(workspace, 'no-fm');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# just a heading\nno frontmatter here', 'utf-8');
const ctx = makeDirCtx();
const result = executeSkillTool('InstallSkill', {
sourcePath: skillDir,
name: 'no-fm',
scope: 'user',
}, ctx);
expect(result!.isError).toBe(true);
expect(result!.output).toContain('frontmatter');
expect(fs.existsSync(path.join(userRoot, 'user1', 'skills', 'no-fm'))).toBe(false);
});
it('rejects a content install when the frontmatter name differs from the install name', () => {
const ctx = makeDirCtx();
const result = executeSkillTool('InstallSkill', {
name: 'param-name',
content: `---\nname: content-name\ndescription: mismatch\n---\n\nbody`,
scope: 'user',
}, ctx);
expect(result!.isError).toBe(true);
expect(result!.output).toContain('content-name');
expect(result!.output).toContain('param-name');
expect(fs.existsSync(path.join(userRoot, 'user1', 'skills', 'param-name'))).toBe(false);
});
it('rejects a content install when the content has no valid frontmatter name', () => {
const ctx = makeDirCtx();
const result = executeSkillTool('InstallSkill', {
name: 'no-fm-content',
content: '# heading only\nno frontmatter',
scope: 'user',
}, ctx);
expect(result!.isError).toBe(true);
expect(result!.output).toContain('frontmatter');
expect(fs.existsSync(path.join(userRoot, 'user1', 'skills', 'no-fm-content'))).toBe(false);
});
it('resolves a workspace-relative sourcePath against the workspace root', () => {
makeSourceSkill('rel-skill', { scripts: true });
const ctx = makeDirCtx();
@@ -521,6 +789,110 @@ describe('InstallSkill tool', () => {
expect(result!.output).toContain('admin');
});
// --- sourcePath 再アンカー ---
// エージェントは workspace のホスト上の絶対パスを知らないため、スペースID等から
// 「data/space/{id}/files/output/xxx」のようなフルパス風の相対パスを組み立てがち。
// workspace 末尾セグメント(2つ以上)がパス中に現れたら、その続きを workspace
// 相対として再解決する。
/** workspace をスペース実配置と同じ `.../space/{id}/files` 形にした ctx を作る */
function makeSpaceLikeWorkspace(): { spaceWs: string; spaceId: string } {
const base = makeTempDir();
dirs.push(base);
const spaceId = 'c3fef194-44d7-4cd8-8dc0-b41a5f92a3f3';
const spaceWs = path.join(base, 'data', 'workspaces', 'space', spaceId, 'files');
fs.mkdirSync(spaceWs, { recursive: true });
return { spaceWs, spaceId };
}
function makeCtxForWorkspace(ws: string, opts?: { role?: 'admin' | 'user' }): ToolContext {
const catalog = new SkillCatalog(systemDir, userRoot);
return {
workspacePath: ws,
editAllowed: true,
skillCatalog: catalog,
userId: 'user1',
userRole: opts?.role ?? 'user',
} as ToolContext;
}
it('re-anchors a sourcePath that embeds the workspace tail (space layout)', () => {
// Given: スペース workspace 直下 output/skill-template にスキル一式
const { spaceWs, spaceId } = makeSpaceLikeWorkspace();
const skillDir = path.join(spaceWs, 'output', 'skill-template');
fs.mkdirSync(path.join(skillDir, 'templates'), { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: reanchor-skill\ndescription: A test skill\n---\nbody', 'utf-8');
fs.writeFileSync(path.join(skillDir, 'templates', 'a.html'), '<html></html>', 'utf-8');
// When: 実障害と同形の「フルパス風」相対パスを渡す
const result = executeSkillTool('InstallSkill', {
sourcePath: `data/space/${spaceId}/files/output/skill-template`,
name: 'reanchor-skill',
scope: 'user',
}, makeCtxForWorkspace(spaceWs));
// Then: 再アンカーされてフォルダごとインストールされる
expect(result!.isError).toBe(false);
const targetDir = path.join(userRoot, 'user1', 'skills', 'reanchor-skill');
expect(fs.existsSync(path.join(targetDir, 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(targetDir, 'templates', 'a.html'))).toBe(true);
});
it('re-anchors an absolute-looking sourcePath with a wrong prefix', () => {
const { spaceWs, spaceId } = makeSpaceLikeWorkspace();
const skillDir = path.join(spaceWs, 'output', 'my-skill');
fs.mkdirSync(skillDir, { recursive: true });
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '---\nname: reanchor-abs\ndescription: A test skill\n---\nbody', 'utf-8');
const result = executeSkillTool('InstallSkill', {
sourcePath: `/srv/app/data/space/${spaceId}/files/output/my-skill`,
name: 'reanchor-abs',
scope: 'user',
}, makeCtxForWorkspace(spaceWs));
expect(result!.isError).toBe(false);
expect(fs.existsSync(path.join(userRoot, 'user1', 'skills', 'reanchor-abs', 'SKILL.md'))).toBe(true);
});
it('does not re-anchor on a single ambiguous segment match', () => {
// workspace 末尾の "files" 1 セグメントだけ一致しても再アンカーしない
const { spaceWs } = makeSpaceLikeWorkspace();
const decoy = path.join(spaceWs, 'x');
fs.mkdirSync(decoy, { recursive: true });
fs.writeFileSync(path.join(decoy, 'SKILL.md'), '---\nname: t\ndescription: A test skill\n---\nbody', 'utf-8');
const result = executeSkillTool('InstallSkill', {
sourcePath: 'nope/files/x',
name: 'ambiguous',
scope: 'user',
}, makeCtxForWorkspace(spaceWs));
expect(result!.isError).toBe(true);
});
it('hints workspace-relative paths when sourcePath cannot be resolved', () => {
const ctx = makeDirCtx();
const result = executeSkillTool('InstallSkill', {
sourcePath: 'no/such/dir',
name: 'missing',
scope: 'user',
}, ctx);
expect(result!.isError).toBe(true);
expect(result!.output).toContain('workspace-relative');
});
it('does not re-anchor a path containing ".."', () => {
const { spaceWs, spaceId } = makeSpaceLikeWorkspace();
const result = executeSkillTool('InstallSkill', {
sourcePath: `data/space/${spaceId}/files/../../../../evil`,
name: 'evil-dots',
scope: 'user',
}, makeCtxForWorkspace(spaceWs));
expect(result!.isError).toBe(true);
});
it('overwrites existing skill on reinstall', () => {
const sourceDir = makeSourceSkill('overwrite-me', { scripts: true });
const ctx = makeDirCtx();
+195 -13
View File
@@ -1,10 +1,26 @@
import { mkdirSync, writeFileSync, renameSync, unlinkSync, realpathSync, cpSync, rmSync, existsSync, readdirSync, lstatSync } from 'fs';
import { join, isAbsolute, resolve } from 'path';
import { mkdirSync, writeFileSync, renameSync, unlinkSync, realpathSync, cpSync, rmSync, existsSync, readdirSync, lstatSync, readFileSync } from 'fs';
import { createHash, randomUUID } from 'crypto';
import { join, isAbsolute, resolve, relative } from 'path';
import type { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { VALID_SKILL_NAME } from '../skills.js';
import { scanSkillContent, scanSkillDirectory, maxSeverity } from '../skills-scanner.js';
import { logger } from '../../logger.js';
import matter from 'gray-matter';
/**
* Extract the frontmatter `name` from SKILL.md text ('' when missing or
* unparseable). The skill catalog lists skills by this name, so installs must
* keep it in sync with the folder name (see the mismatch guard in InstallSkill).
*/
function frontmatterNameOf(raw: string): string {
try {
const { data } = matter(raw);
return typeof data?.name === 'string' ? data.name : '';
} catch {
return '';
}
}
// ── Injected deps (server.ts / worker.ts call setSkillToolDeps) ─────────────
@@ -38,13 +54,40 @@ function skillFolderTarget(ctx: ToolContext): { root: string; leaf: string } {
return { root: personalRoot, leaf: ctx.userId ?? 'local' };
}
/**
* `sourcePath` の中に workspace 実パスの末尾セグメント列(2 つ以上)が現れたら、
* その直後からを workspace 相対パスとして返す。見つからなければ null。
* 例: workspace=…/space/{id}/files, sourcePath="data/space/{id}/files/output/x"
* → "output/x"。1 セグメントだけの一致(例: "files")は誤アンカーしやすいので
* 対象外。".." を含むパスは再解釈しない(通常の解決に任せて拒否させる)。
*/
function reanchorToWorkspace(workspacePath: string, sourcePath: string): string | null {
const wsSegs = resolve(workspacePath).split('/').filter(Boolean);
const srcSegs = sourcePath.split(/[\\/]+/).filter(s => s && s !== '.');
if (srcSegs.includes('..')) return null;
for (let len = Math.min(wsSegs.length, srcSegs.length); len >= 2; len--) {
const tail = wsSegs.slice(wsSegs.length - len);
for (let i = 0; i + len <= srcSegs.length; i++) {
if (tail.every((seg, j) => seg === srcSegs[i + j])) {
const rest = srcSegs.slice(i + len);
return rest.length > 0 ? rest.join('/') : null;
}
}
}
return null;
}
// ── Skill materialization ───────────────────────────────────────────────────
// When the agent ReadSkill's a directory-based skill, copy its files into the
// task workspace (`{workspace}/skills/{name}/`) so its scripts are usable in
// every Bash sandbox mode (the skill store lives outside the workspace and is
// only bind-mounted in the bwrap path). The copy is rw and idempotent per task.
// only bind-mounted in the bwrap path). The copy is rw. A signature of the
// source dir is stored alongside the copy; when the source changes (e.g. the
// skill was updated by another task) the next ReadSkill replaces the copy —
// including any local edits. An unchanged source leaves the copy untouched.
const SKILL_MATERIALIZE_MAX_BYTES = 50 * 1024 * 1024; // 50MB
const SKILL_SIGNATURE_SUFFIX = '.skill-src-signature';
/** Total size of `dir` (skipping symlinks), or null once it exceeds `cap`. */
function dirSizeCapped(dir: string, cap: number): number | null {
@@ -69,28 +112,127 @@ function dirSizeCapped(dir: string, cap: number): number | null {
interface MaterializeResult { ok: boolean; relPath: string; note?: string }
/** Copy a skill's source dir into `{workspace}/skills/{name}/` (idempotent). */
/**
* Signature of a skill source dir: sha1 over each file's relative path and
* content bytes, symlinks skipped — the same file set `materializeSkill`
* copies. Content-based (not size/mtime) so a re-synced store with identical
* bytes does not count as a change. Source dirs are capped at 50MB and
* ReadSkill is infrequent, so hashing the bytes is cheap enough.
*/
function skillSourceSignature(srcDir: string): string {
const files: string[] = [];
const stack = [srcDir];
while (stack.length > 0) {
const d = stack.pop()!;
let entries: string[];
try { entries = readdirSync(d); } catch { continue; }
for (const e of entries) {
const p = join(d, e);
let st;
try { st = lstatSync(p); } catch { continue; }
if (st.isSymbolicLink()) continue;
if (st.isDirectory()) { stack.push(p); continue; }
files.push(relative(srcDir, p));
}
}
files.sort();
const hash = createHash('sha1');
for (const rel of files) {
// Length-prefix each field so path/content boundaries stay unambiguous.
hash.update(`${rel.length}:${rel}:`);
let content: Buffer;
try { content = readFileSync(join(srcDir, rel)); } catch { content = Buffer.from('<unreadable>'); }
hash.update(`${content.length}:`);
hash.update(content);
}
return hash.digest('hex');
}
/**
* Copy a skill's source dir into `{workspace}/skills/{name}/`. A stale copy
* (source changed since it was made, or made before signature tracking) is
* replaced wholesale; an up-to-date copy is left untouched. The copy is staged
* in a temp dir first so a failed refresh keeps the previous copy usable, and
* the signature lives in a sidecar (`skills/.{name}.skill-src-signature`) so
* it can never clobber a file the skill itself owns. Skill names are
* `[a-z0-9_-]` only, so the dot-prefixed sidecar/tmp names cannot collide
* with a real skill dir.
*/
export function materializeSkill(srcDir: string, workspacePath: string, skillName: string): MaterializeResult {
const relPath = `skills/${skillName}`;
const dest = join(workspacePath, 'skills', skillName);
if (existsSync(dest)) return { ok: true, relPath }; // already materialized this task
const skillsRoot = join(workspacePath, 'skills');
const dest = join(skillsRoot, skillName);
const sigPath = join(skillsRoot, `.${skillName}${SKILL_SIGNATURE_SUFFIX}`);
// Cap check first: the signature reads every source byte, so an oversized
// dir must be rejected before it gets loaded into memory.
if (dirSizeCapped(srcDir, SKILL_MATERIALIZE_MAX_BYTES) === null) {
return { ok: false, relPath, note: 'skill exceeds 50MB copy limit' };
}
const signature = skillSourceSignature(srcDir);
if (existsSync(dest)) {
let existing: string | null = null;
try { existing = readFileSync(sigPath, 'utf-8').trim(); } catch { /* legacy copy without signature → refresh */ }
if (existing === signature) return { ok: true, relPath }; // copy is up to date
}
// Per-call unique tmp: within one process ReadSkill runs synchronously, but
// another process sharing the workspace (dev + prod, multi-instance) may be
// materializing the same skill concurrently — never touch its tmp.
const tmp = join(skillsRoot, `.${skillName}.tmp-${randomUUID()}`);
try {
mkdirSync(join(workspacePath, 'skills'), { recursive: true });
cpSync(srcDir, dest, {
mkdirSync(skillsRoot, { recursive: true });
sweepStaleTmpDirs(skillsRoot, skillName);
cpSync(srcDir, tmp, {
recursive: true,
dereference: false,
// Skip symlinks so a link inside the skill cannot point outside the workspace.
filter: (src) => { try { return !lstatSync(src).isSymbolicLink(); } catch { return false; } },
});
// Swap in only after the whole copy succeeded. The old copy is moved
// aside (not deleted) so a failed swap can put it back.
const backup = join(skillsRoot, `.${skillName}.tmp-bak-${randomUUID()}`);
let haveBackup = false;
if (existsSync(dest)) { renameSync(dest, backup); haveBackup = true; }
try {
renameSync(tmp, dest);
} catch (e) {
if (haveBackup && !existsSync(dest)) {
try { renameSync(backup, dest); } catch { /* leave the backup for the sweep */ }
}
throw e;
}
if (haveBackup) { try { rmSync(backup, { recursive: true, force: true }); } catch { /* sweep will get it */ } }
writeFileSync(sigPath, `${signature}\n`, 'utf-8');
return { ok: true, relPath };
} catch (e) {
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best effort */ }
// A concurrent materialize may have won the swap — then the copy is fine.
try {
if (existsSync(dest) && readFileSync(sigPath, 'utf-8').trim() === signature) {
return { ok: true, relPath };
}
} catch { /* fall through to the failure result */ }
return { ok: false, relPath, note: `copy failed: ${(e as Error).message}` };
}
}
const SKILL_TMP_STALE_MS = 15 * 60_000;
/** Remove `.{name}.tmp-*` leftovers from crashed runs; keep recent ones (a copy may be in flight). */
function sweepStaleTmpDirs(skillsRoot: string, skillName: string): void {
let entries: string[];
try { entries = readdirSync(skillsRoot); } catch { return; }
const prefix = `.${skillName}.tmp-`;
for (const e of entries) {
if (!e.startsWith(prefix)) continue;
const p = join(skillsRoot, e);
try {
if (Date.now() - lstatSync(p).mtimeMs > SKILL_TMP_STALE_MS) {
rmSync(p, { recursive: true, force: true });
}
} catch { /* best effort */ }
}
}
// ---------------------------------------------------------------------------
// Tool definitions
// ---------------------------------------------------------------------------
@@ -103,9 +245,9 @@ export const TOOL_DEFS: Record<string, ToolDef> = {
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'スキル名 ([a-z0-9_-] のみ)' },
name: { type: 'string', description: 'スキル名 ([a-z0-9_-] のみ)。SKILL.md frontmatter の name: と完全一致必須' },
content: { type: 'string', description: 'SKILL.md の全文 (YAML frontmatter + 本文)。通常はこちらを使用' },
sourcePath: { type: 'string', description: 'workspace 内のスキルディレクトリの絶対パス (SKILL.md + scripts/ 等を含む場合のみ)。workspace 外のパスは拒否される' },
sourcePath: { type: 'string', description: 'workspace 相対のスキルディレクトリパス(例: "output/my-skill"。SKILL.md + scripts/ 等を含むフォルダごと登録する場合のみ)。workspace 外のパスは拒否される' },
scope: { type: 'string', enum: ['system', 'user'], description: 'system=全ユーザー共有 (admin only), user=個人' },
},
required: ['name', 'scope'],
@@ -195,8 +337,13 @@ function executeInstallSkill(
if (typeof sourcePath !== 'string') {
return { output: 'InstallSkill: "sourcePath" must be a string', isError: true };
}
let realSource: string;
let realWorkspace: string;
try {
realWorkspace = realpathSync(ctx.workspacePath);
} catch (e) {
return { output: `InstallSkill: sourcePath does not exist or is not accessible. Use "content" parameter instead to pass SKILL.md text directly.`, isError: true };
}
let realSource: string;
try {
// Resolve relative paths against the workspace root, mirroring
// resolveAndGuard() used by Read/Write/Edit. Without this, a natural
@@ -206,9 +353,18 @@ function executeInstallSkill(
// friendly result instead of crashing the task.
const absSource = isAbsolute(sourcePath) ? sourcePath : resolve(ctx.workspacePath, sourcePath);
realSource = realpathSync(absSource);
realWorkspace = realpathSync(ctx.workspacePath);
} catch (e) {
return { output: `InstallSkill: sourcePath does not exist or is not accessible. Use "content" parameter instead to pass SKILL.md text directly.`, isError: true };
// Agents don't know the workspace's host-side absolute path, so they
// often reconstruct a plausible-looking one (e.g. "data/space/{id}/files/
// output/x"). If the given path embeds the workspace's trailing segments,
// re-anchor: treat everything after that run as workspace-relative.
const reanchored = reanchorToWorkspace(ctx.workspacePath, sourcePath);
try {
if (!reanchored) throw new Error('no re-anchor candidate');
realSource = realpathSync(resolve(ctx.workspacePath, reanchored));
} catch {
return { output: `InstallSkill: sourcePath does not exist or is not accessible. Specify a workspace-relative path (e.g. "output/my-skill"), or use "content" to pass SKILL.md text directly.`, isError: true };
}
}
if (!realSource.startsWith(realWorkspace + '/')) {
return { output: 'InstallSkill: sourcePath must be inside the task workspace. Use "content" parameter to pass SKILL.md text directly.', isError: true };
@@ -217,6 +373,22 @@ function executeInstallSkill(
return { output: `InstallSkill: SKILL.md not found in ${sourcePath}`, isError: true };
}
// The catalog lists skills by their SKILL.md frontmatter `name`. If it
// differs from the install name the skill gets stored under one name but
// listed under another — the UI can then never edit or delete it.
let srcFmName = '';
try {
srcFmName = frontmatterNameOf(readFileSync(join(realSource, 'SKILL.md'), 'utf-8'));
} catch {
srcFmName = '';
}
if (!srcFmName || !VALID_SKILL_NAME.test(srcFmName)) {
return { output: 'InstallSkill: SKILL.md must start with YAML frontmatter containing a valid "name:" ([a-z0-9_-]). Without it the skill will not appear in the skill list.', isError: true };
}
if (srcFmName !== skillName) {
return { output: `InstallSkill: SKILL.md frontmatter name "${srcFmName}" does not match the install name "${skillName}". Pass name: "${srcFmName}" (or fix the frontmatter) so they match.`, isError: true };
}
const stats = getDirStats(realSource);
if (stats.fileCount > MAX_FILE_COUNT) {
return { output: `InstallSkill: directory contains ${stats.fileCount} files (max ${MAX_FILE_COUNT})`, isError: true };
@@ -269,6 +441,16 @@ function executeInstallSkill(
return { output: `InstallSkill: content exceeds 64 KB limit`, isError: true };
}
// Same mismatch guard as sourcePath mode: the frontmatter name must exist
// and match the install name, or the skill becomes unmanageable from the UI.
const contentFmName = frontmatterNameOf(content!);
if (!contentFmName || !VALID_SKILL_NAME.test(contentFmName)) {
return { output: 'InstallSkill: content must start with YAML frontmatter containing a valid "name:" ([a-z0-9_-]). Without it the skill will not appear in the skill list.', isError: true };
}
if (contentFmName !== skillName) {
return { output: `InstallSkill: content frontmatter name "${contentFmName}" does not match the install name "${skillName}". Pass name: "${contentFmName}" (or fix the frontmatter) so they match.`, isError: true };
}
const findings = scanSkillContent(content!);
const severity = maxSeverity(findings);
if (severity === 'high') {
+12 -12
View File
@@ -26,6 +26,7 @@ import { createGrantsRepo } from '../../ssh/grants-repo.js';
import { createAuditRepo } from '../../ssh/audit-repo.js';
import { createAbuseRepo } from '../../ssh/abuse-repo.js';
import { createAccessResolver } from '../../ssh/access.js';
import { SessionRegistry } from '../../ssh/console-registry.js';
import { SSH_DEFAULTS } from '../../ssh/config.js';
import { preflight, setSshSubsystem, type SshSubsystem } from './ssh.js';
import { openConsoleSession } from './ssh-console.js';
@@ -69,18 +70,17 @@ function makeSubsystem(db: Database.Database): { sub: SshSubsystem; conn: SshCon
maintenance: { isActive: () => false, snapshot: () => ({ active: false }), enter: () => undefined, exit: () => undefined },
// Enable the console (disabled by default) so the agent-tool wrapper reaches preflight.
config: { ...SSH_DEFAULTS, console: { ...SSH_DEFAULTS.console, enabled: true } },
sessionRegistry: {
register: () => undefined,
get: () => null,
listAll: () => [],
listForConnection: () => [],
closeForTask: async () => undefined,
enforceCap: () => [],
sweep: async () => undefined,
startSweepTimer: () => undefined,
stopSweepTimer: () => undefined,
shutdown: async () => undefined,
},
// REAL registry, not a stub: every test here fails preflight or dies at
// the openShellChannel sentinel, so no session is ever registered and the
// registry stays empty (no sweep timer is started without an explicit
// startSweepTimer call). A previous hand-rolled stub silently broke when
// multi-session (#732) added listForTask/closeSession to the registry API;
// the real class cannot drift like that.
sessionRegistry: new SessionRegistry({
idleTimeoutMs: 60_000,
maxSessionDurationMs: 600_000,
maxSessionsPerConnection: 2,
}),
// A cleared-preflight session reaches this and fails here (open_shell_failed).
openShellChannel: async () => {
throw new Error(SHELL_SENTINEL);
@@ -143,6 +143,51 @@ describe('SearchTaskConversation', () => {
});
});
describe('SearchTaskConversation — AND search (space-separated tokens)', () => {
it('matches when all tokens appear even if non-contiguous', async () => {
// comment:10 = 'Please keep the existing auth flow unchanged.'
// 'auth unchanged' is not a contiguous substring, but both tokens appear.
const ctx = makeCtx(makeIO(commentsA));
const res = await executeTool('SearchTaskConversation', { query: 'auth unchanged', source: 'comments' }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('comment:10');
});
it('does not match when tokens are split across different entries (AND, not OR)', async () => {
// 'login' appears only in comment:11, 'components' only in comment:12.
const ctx = makeCtx(makeIO(commentsA));
const res = await executeTool('SearchTaskConversation', { query: 'login components', source: 'comments' }, ctx);
expect(res!.output).not.toContain('comment:11');
expect(res!.output).not.toContain('comment:12');
expect(res!.output).toContain('見つかりませんでした');
});
it('applies AND matching to the transcript source too', async () => {
const tp = writeTranscript('transcript.jsonl', [
{ role: 'user', content: 'Constraint: never touch the payment code.' },
{ role: 'assistant', content: 'I refactored the payment module docs.' },
]);
const ctx = makeCtx(makeIO([], tp));
const res = await executeTool('SearchTaskConversation', { query: 'payment constraint', source: 'transcript' }, ctx);
expect(res!.output).toContain('transcript:0');
expect(res!.output).not.toContain('transcript:1');
});
it('treats a full-width (ideographic) space as a token separator', async () => {
const ctx = makeCtx(makeIO(commentsA));
const res = await executeTool('SearchTaskConversation', { query: 'auth unchanged', source: 'comments' }, ctx);
expect(res!.output).toContain('comment:10');
});
it('centers the excerpt on the first token hit when the full query never appears contiguously', async () => {
const body = 'x'.repeat(300) + ' alpha middle beta ' + 'y'.repeat(300);
const ctx = makeCtx(makeIO([{ id: 1, author: 'user', kind: 'comment', body, createdAt: '2026-06-30T10:00:00Z' }]));
const res = await executeTool('SearchTaskConversation', { query: 'alpha beta' }, ctx);
expect(res!.output).toContain('comment:1');
expect(res!.output).toContain('alpha'); // excerpt must show the hit, not the head of the body
});
});
// P0/P1 (review): the REAL scoping guarantee comes from
// repo.makeTaskConversationIO(taskId) → listLocalTaskComments(taskId) WHERE
// task_id = ?. Exercise it end-to-end with a real temp-DB Repository and two
@@ -202,3 +247,25 @@ describe('ReadTaskConversation', () => {
expect(res!.output.length).toBeGreaterThan(0);
});
});
describe('sub-task transcript-only recall', () => {
it('searches the transcript while comments stay empty', async () => {
const tp = writeTranscript('transcript.jsonl', [
{ role: 'user', content: 'Sub-task brief: summarize the quarterly churn drivers.' },
{ role: 'assistant', content: 'Noted churn driver: onboarding friction.' },
]);
const ctx = makeCtx(makeIO([], tp)); // transcript-only shape (no comments)
const res = await executeTool('SearchTaskConversation', { query: 'churn', source: 'both' }, ctx);
expect(res!.isError).toBe(false);
expect(res!.output).toContain('transcript:0');
expect(res!.output).toContain('transcript:1');
});
it('degrades gracefully when a comment ref is read but no comments exist', async () => {
const tp = writeTranscript('transcript.jsonl', [{ role: 'user', content: 'hello' }]);
const ctx = makeCtx(makeIO([], tp));
const res = await executeTool('ReadTaskConversation', { ref: 'comment:1' }, ctx);
expect(res!.isError).toBe(true);
expect(res!.output).toContain('見つかりません');
});
});
+20 -29
View File
@@ -12,16 +12,20 @@
*
* Scoping is enforced by the bound `ctx.taskConversation` IO: comments come from
* `listLocalTaskComments(taskId)` (WHERE task_id = ?), and the transcript path is
* bound by the worker ONLY to a per-task `runtimeDir` — never the space-shared
* `workspacePath/logs` fallback — so a tool call can never read another task's
* logs. When the transcript path is unset the transcript source yields nothing
* (comments still work); when the whole IO is unset (subtasks / gitea-issue runs)
* the tools degrade to a friendly non-error message.
* bound by the worker to a location a tool call can never use to read another
* task's logs. For a top-level local task that is the per-task `runtimeDir`
* (never the space-shared `workspacePath/logs` fallback). A sub-task run instead
* gets a transcript-only IO (no comments — see `makeTranscriptOnlyConversationIO`)
* bound to the sub-task's OWN isolated workspace transcript. When the transcript
* path is unset the transcript source yields nothing (comments still work); when
* the whole IO is unset (gitea-issue runs) the tools degrade to a friendly
* non-error message.
*/
import { existsSync, readFileSync } from 'node:fs';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult, TaskConversationComment } from './core.js';
import { excerpt, tokenize } from './text-excerpt.js';
const SEARCH_DEF: ToolDef = {
type: 'function',
@@ -32,7 +36,7 @@ const SEARCH_DEF: ToolDef = {
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: '検索キーワード (部分一致・大文字小文字無視)。' },
query: { type: 'string', description: '検索キーワード。スペース区切りで複数指定すると AND 検索 (すべての語を含む発言にヒット)。部分一致・大文字小文字無視。' },
source: { type: 'string', enum: ['comments', 'transcript', 'both'], description: '検索対象。既定 both。' },
author: { type: 'string', enum: ['user', 'agent', 'system'], description: '発言者で絞り込む。' },
kind: { type: 'string', description: 'コメント種別で絞り込む (request/comment/interjection/result/ask/progress/handoff)。transcript には適用されない。' },
@@ -105,23 +109,6 @@ function loadTranscript(path: string | undefined): TranscriptEntry[] {
return out;
}
function collapse(s: string): string {
return s.replace(/\s+/g, ' ').trim();
}
/** Compact excerpt centered on the first query hit, bounded to `max` chars. */
function excerpt(body: string, query: string, max: number): string {
const flat = collapse(body);
if (flat.length <= max && !query) return flat;
const idx = query ? flat.toLowerCase().indexOf(query.toLowerCase()) : 0;
if (idx < 0 || flat.length <= max) {
return flat.length <= max ? flat : flat.slice(0, max - 1) + '…';
}
const start = Math.max(0, idx - 40);
const end = Math.min(flat.length, start + max);
return (start > 0 ? '…' : '') + flat.slice(start, end) + (end < flat.length ? '…' : '');
}
function clampLimit(v: unknown, def: number, max: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : def;
return Math.min(Math.max(n, 1), max);
@@ -144,7 +131,11 @@ async function search(input: Record<string, unknown>, ctx: ToolContext): Promise
const author = input['author'] as Author | undefined;
const kind = typeof input['kind'] === 'string' ? (input['kind'] as string) : undefined;
const limit = clampLimit(input['limit'], 10, 50);
const q = query.toLowerCase();
const tokens = tokenize(query);
const matches = (body: string): boolean => {
const lower = body.toLowerCase();
return tokens.every((t) => lower.includes(t));
};
type Hit = { ref: string; header: string; body: string };
const hits: Hit[] = [];
@@ -154,7 +145,7 @@ async function search(input: Record<string, unknown>, ctx: ToolContext): Promise
for (const c of comments) {
if (author && c.author !== author) continue;
if (kind && c.kind !== kind) continue;
if (!c.body.toLowerCase().includes(q)) continue;
if (!matches(c.body)) continue;
hits.push({ ref: `comment:${c.id}`, header: `${c.author}/${c.kind} ${c.createdAt}`, body: c.body });
}
}
@@ -165,7 +156,7 @@ async function search(input: Record<string, unknown>, ctx: ToolContext): Promise
if (!kind) {
for (const e of loadTranscript(io.transcriptPath)) {
if (author && e.author !== author) continue;
if (!e.content.toLowerCase().includes(q)) continue;
if (!matches(e.content)) continue;
hits.push({ ref: `transcript:${e.index}`, header: e.author, body: e.content });
}
}
@@ -179,7 +170,7 @@ async function search(input: Record<string, unknown>, ctx: ToolContext): Promise
const lines = [`## Conversation Search Results (query: "${query}", ${hits.length} hit(s)${hits.length > limit ? `, showing ${limit}` : ''})`, ''];
for (const h of shown) {
lines.push(`- ${h.ref} ${h.header}`);
lines.push(` ${excerpt(h.body, query, EXCERPT_MAX)}`);
lines.push(` ${excerpt(h.body, tokens, EXCERPT_MAX)}`);
lines.push('');
}
lines.push('前後の文脈は ReadTaskConversation({ ref }) で確認できます。');
@@ -209,7 +200,7 @@ async function read(input: Record<string, unknown>, ctx: ToolContext): Promise<T
for (const c of slice) {
const marker = c.id === id ? ' ←' : '';
lines.push(`- comment:${c.id} ${c.author}/${c.kind} ${c.createdAt}${marker}`);
lines.push(` ${excerpt(c.body, '', READ_EXCERPT_MAX)}`);
lines.push(` ${excerpt(c.body, [], READ_EXCERPT_MAX)}`);
}
return { output: lines.join('\n'), isError: false };
}
@@ -222,7 +213,7 @@ async function read(input: Record<string, unknown>, ctx: ToolContext): Promise<T
for (const e of slice) {
const marker = e.index === id ? ' ←' : '';
lines.push(`- transcript:${e.index} ${e.author}${marker}`);
lines.push(` ${excerpt(e.content, '', READ_EXCERPT_MAX)}`);
lines.push(` ${excerpt(e.content, [], READ_EXCERPT_MAX)}`);
}
return { output: lines.join('\n'), isError: false };
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Shared query-tokenization / excerpt helpers for the conversation-search
* tools (SearchTaskConversation / SearchWorkspaceTasks).
*
* Extracted into one module so the semantics cannot drift between the two
* tools again — the "whole query as one literal substring" bug lived in two
* independent copies of this code.
*/
/** Collapse all whitespace runs into single spaces. */
export function collapse(s: string): string {
return s.replace(/\s+/g, ' ').trim();
}
/** Lowercase AND tokens split on any whitespace (incl. full-width U+3000). */
export function tokenize(query: string): string[] {
return query.toLowerCase().split(/\s+/).filter(Boolean);
}
/** Compact excerpt centered on the earliest token hit, bounded to `max` chars. */
export function excerpt(body: string, tokens: string[], max: number): string {
const flat = collapse(body);
if (flat.length <= max) return flat;
const lower = flat.toLowerCase();
let idx = -1;
for (const t of tokens) {
const i = lower.indexOf(t);
if (i >= 0 && (idx < 0 || i < idx)) idx = i;
}
if (idx < 0) return flat.slice(0, max - 1) + '…';
const start = Math.max(0, idx - 40);
const end = Math.min(flat.length, start + max);
return (start > 0 ? '…' : '') + flat.slice(start, end) + (end < flat.length ? '…' : '');
}
+1
View File
@@ -45,6 +45,7 @@ export const META_TOOLS = new Set<string>([
'ListSkills',
'InstallSkill',
'RequestTool',
'RequestPackage',
]);
// ─────────────────────────────────────────────────────────────────────────────
@@ -87,6 +87,23 @@ describe('SearchWorkspaceTasks tool', () => {
expect(res!.output).toMatch(/見つからない|スコープ外/);
});
it('centers the excerpt on the first token hit for multi-word (AND) queries', async () => {
// FTS5 matches tokens non-contiguously, so the excerpt must center on the
// first token occurrence — not require the whole query as one substring.
const text = 'x'.repeat(300) + ' 認証エラー が発生、その後 再ログイン で解消 ' + 'y'.repeat(300);
const io = {
fts5Available: true,
search: async () => [{ taskId: 1, taskTitle: 'T', commentId: 2, author: 'agent', kind: 'result', createdAt: 'd', text }],
around: async () => null,
};
const res = await executeTool('SearchWorkspaceTasks', { query: '認証エラー 再ログイン' }, ctxWith(io));
expect(res!.isError).toBe(false);
// The excerpt line (2-space indent) must show the hit, not the head of the body.
const excerptLine = res!.output.split('\n').find((l) => l.startsWith(' ') && !l.startsWith(' '));
expect(excerptLine).toBeDefined();
expect(excerptLine!).toContain('認証エラー');
});
it('context>0 attaches neighbor comments under each hit', async () => {
let aroundCalledWith: { id: number; before: number; after: number } | null = null;
const io = {
+4 -20
View File
@@ -18,6 +18,7 @@
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { excerpt, tokenize } from './text-excerpt.js';
const SEARCH_DEF: ToolDef = {
type: 'function',
@@ -52,23 +53,6 @@ const FTS5_UNAVAILABLE_MSG =
const EXCERPT_MAX = 200;
function collapse(s: string): string {
return s.replace(/\s+/g, ' ').trim();
}
/** Compact excerpt centered on the first query hit, bounded to `max` chars. */
function excerpt(body: string, query: string, max: number): string {
const flat = collapse(body);
if (flat.length <= max && !query) return flat;
const idx = query ? flat.toLowerCase().indexOf(query.toLowerCase()) : 0;
if (idx < 0 || flat.length <= max) {
return flat.length <= max ? flat : flat.slice(0, max - 1) + '…';
}
const start = Math.max(0, idx - 40);
const end = Math.min(flat.length, start + max);
return (start > 0 ? '…' : '') + flat.slice(start, end) + (end < flat.length ? '…' : '');
}
function clampLimit(v: unknown, def: number, max: number): number {
const n = typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : def;
return Math.min(Math.max(n, 1), max);
@@ -125,14 +109,14 @@ async function search(input: Record<string, unknown>, ctx: ToolContext): Promise
const lines = [`## Workspace Task Search Results (query: "${query}", ${hits.length} hit(s))`, ''];
for (const h of hits) {
lines.push(`- task:${h.taskId} "${h.taskTitle}" / comment:${h.commentId} ${h.author}/${h.kind} ${h.createdAt}`);
lines.push(` ${excerpt(h.text, query!, EXCERPT_MAX)}`);
lines.push(` ${excerpt(h.text, tokenize(query!), EXCERPT_MAX)}`);
if (context > 0) {
const around = await io.around(h.commentId, context, context);
if (around) {
for (const c of around.comments) {
const marker = c.isCenter ? ' ←' : '';
lines.push(` - comment:${c.commentId} ${c.author}/${c.kind} ${c.createdAt}${marker}`);
lines.push(` ${excerpt(c.body, '', EXCERPT_MAX)}`);
lines.push(` ${excerpt(c.body, [], EXCERPT_MAX)}`);
}
}
}
@@ -159,7 +143,7 @@ async function searchAroundRef(ref: string, context: number, io: NonNullable<Too
for (const c of around.comments) {
const marker = c.isCenter ? ' ←' : '';
lines.push(`- comment:${c.commentId} ${c.author}/${c.kind} ${c.createdAt}${marker}`);
lines.push(` ${excerpt(c.body, '', EXCERPT_MAX)}`);
lines.push(` ${excerpt(c.body, [], EXCERPT_MAX)}`);
}
return { output: lines.join('\n'), isError: false };
}
+3 -3
View File
@@ -99,12 +99,12 @@ const XPOSTDETAIL_DEF: ToolDef = {
type: 'function',
function: {
name: 'XPostDetail',
description: 'twitter-cli を使って投稿 URL または tweet ID から詳細と reply を取得する。read-only。',
description: '投稿 URL・記事 URL・tweet ID から詳細を取得する(X 長文記事は article.plainText に本文が入る)。read-only。詳細は ReadToolDoc({ name: "XPostDetail" })。',
parameters: {
type: 'object',
properties: {
tweet: { type: 'string', description: 'tweet ID または https://x.com/.../status/... URL' },
full_text: { type: 'boolean', description: '長文の省略を避ける' },
tweet: { type: 'string', description: 'tweet ID または https://x.com/.../status/... / .../article/... URL' },
full_text: { type: 'boolean', description: '長文の省略を避ける(長文記事本文の切り詰めも解除)' },
compact: { type: 'boolean', description: 'token 節約向けの compact 出力' },
output_path: { type: 'string', description: '任意: output/x/ 配下に保存する相対パス' },
},