sync: update from private repo (6fcb0d0)

This commit is contained in:
oss-sync
2026-06-04 03:03:12 +00:00
parent 21be01b699
commit 57685d995c
36 changed files with 2467 additions and 391 deletions
+49 -2
View File
@@ -396,9 +396,9 @@ movements:
expect(() => loadPiece('multi', 'pieces', tempDir)).not.toThrow();
});
it('all 13 bundled pieces load without validation errors', () => {
it('all 12 bundled pieces load without validation errors', () => {
const piecesDir = join(process.cwd(), 'pieces');
const names = ['brainstorming', 'chat', 'data-process', 'game-tweet-generator', 'general',
const names = ['brainstorming', 'chat', 'data-process', 'general',
'office-process', 'piece-builder', 'research', 'slide', 'sns-research',
'ssh-console', 'ssh-ops', 'x-ai-digest'];
for (const name of names) {
@@ -1111,3 +1111,50 @@ describe('allowed_ssh_connections validation (Phase 4)', () => {
expect(() => validatePieceDef(piece)).toThrow(/Piece "ssh-test" has invalid allowed_ssh_connections/);
});
});
// --- Task 1: loadPiece multi-dir support ---
describe('loadPiece multi-dir (string | string[])', () => {
it('resolves from a list of custom dirs (per-user wins over builtin name miss)', () => {
const dirA = mkdtempSync(join(tmpdir(), 'pa-')); // empty
const dirB = mkdtempSync(join(tmpdir(), 'pb-'));
writeFileSync(
join(dirB, 'mycustom.yaml'),
`name: mycustom\ndescription: d\nmax_movements: 1\ninitial_movement: go\nmovements:\n - name: go\n edit: false\n persona: w\n instruction: x\n allowed_tools: []\n rules: []\n default_next: COMPLETE\n`,
);
// array form: searches dirA then dirB then builtin
const p = loadPiece('mycustom', 'pieces', [dirA, dirB]);
expect(p.name).toBe('mycustom');
// builtin still resolvable when not in any custom dir
expect(() => loadPiece('chat', 'pieces', [dirA, dirB])).not.toThrow();
rmSync(dirA, { recursive: true });
rmSync(dirB, { recursive: true });
});
it('first dir wins when same name appears in two custom dirs', () => {
const dirA = mkdtempSync(join(tmpdir(), 'pa-'));
const dirB = mkdtempSync(join(tmpdir(), 'pb-'));
writeFileSync(
join(dirA, 'dup.yaml'),
`name: dup\ndescription: from-a\nmax_movements: 1\ninitial_movement: go\nmovements:\n - name: go\n edit: false\n persona: w\n instruction: x\n allowed_tools: []\n rules: []\n default_next: COMPLETE\n`,
);
writeFileSync(
join(dirB, 'dup.yaml'),
`name: dup\ndescription: from-b\nmax_movements: 1\ninitial_movement: go\nmovements:\n - name: go\n edit: false\n persona: w\n instruction: x\n allowed_tools: []\n rules: []\n default_next: COMPLETE\n`,
);
const p = loadPiece('dup', 'pieces', [dirA, dirB]);
expect(p.description).toBe('from-a');
rmSync(dirA, { recursive: true });
rmSync(dirB, { recursive: true });
});
it('string form still works (backward compat)', () => {
const dir = mkdtempSync(join(tmpdir(), 'pc-'));
writeFileSync(
join(dir, 'strcompat.yaml'),
`name: strcompat\ndescription: str\nmax_movements: 1\ninitial_movement: go\nmovements:\n - name: go\n edit: false\n persona: w\n instruction: x\n allowed_tools: []\n rules: []\n default_next: COMPLETE\n`,
);
const p = loadPiece('strcompat', 'pieces', dir);
expect(p.name).toBe('strcompat');
rmSync(dir, { recursive: true });
});
});
+19 -22
View File
@@ -209,29 +209,22 @@ export function validateAllowedSshConnections(piece: PieceDef): string[] {
}
// pieces/ ディレクトリから piece 定義を読み込む(customPiecesDir を優先探索)
export function loadPiece(pieceName: string, piecesDir: string = 'pieces', customPiecesDir?: string): PieceDef {
// customPiecesDir は string | string[] を受け付ける(配列の場合は先頭から順に探索)
export function loadPiece(pieceName: string, piecesDir: string = 'pieces', customPiecesDir?: string | string[]): PieceDef {
let raw: string;
let source: string;
if (customPiecesDir) {
const customPath = join(customPiecesDir, `${pieceName}.yaml`);
if (existsSync(customPath)) {
logger.debug(`[piece-runner] loadPiece piece=${pieceName} source=custom path=${customPath}`);
raw = readFileSync(customPath, 'utf-8');
source = 'custom';
} else {
const filePath = join(piecesDir, `${pieceName}.yaml`);
if (!existsSync(filePath)) {
logger.warn(`[piece-runner] loadPiece piece=${pieceName} not found dirs=[${customPiecesDir}, ${piecesDir}]`);
throw new Error(`Piece not found: ${pieceName}`);
}
logger.debug(`[piece-runner] loadPiece piece=${pieceName} source=builtin path=${filePath}`);
raw = readFileSync(filePath, 'utf-8');
source = 'builtin';
}
const customDirs = customPiecesDir == null ? [] : (Array.isArray(customPiecesDir) ? customPiecesDir : [customPiecesDir]);
const found = customDirs
.map((d) => join(d, `${pieceName}.yaml`))
.find((p) => existsSync(p));
if (found) {
logger.debug(`[piece-runner] loadPiece piece=${pieceName} source=custom path=${found}`);
raw = readFileSync(found, 'utf-8');
source = 'custom';
} else {
const filePath = join(piecesDir, `${pieceName}.yaml`);
if (!existsSync(filePath)) {
logger.warn(`[piece-runner] loadPiece piece=${pieceName} not found dirs=[${piecesDir}]`);
logger.warn(`[piece-runner] loadPiece piece=${pieceName} not found dirs=[${[...customDirs, piecesDir].join(', ')}]`);
throw new Error(`Piece not found: ${pieceName}`);
}
logger.debug(`[piece-runner] loadPiece piece=${pieceName} source=builtin path=${filePath}`);
@@ -252,11 +245,12 @@ export function loadPiece(pieceName: string, piecesDir: string = 'pieces', custo
/**
* pieces/ ディレクトリ内の全 Piece から triggers を読み込む(customPiecesDir があれば優先、同名は custom が勝つ)
*/
export function loadAllPieceTriggers(piecesDir: string = 'pieces', customPiecesDir?: string): Array<{ name: string; keywords: string[] }> {
export function loadAllPieceTriggers(piecesDir: string = 'pieces', customPiecesDir?: string | string[]): Array<{ name: string; keywords: string[] }> {
const triggers: Array<{ name: string; keywords: string[] }> = [];
const seen = new Set<string>();
const dirs = customPiecesDir ? [customPiecesDir, piecesDir] : [piecesDir];
const customDirsArr = Array.isArray(customPiecesDir) ? customPiecesDir : (customPiecesDir ? [customPiecesDir] : []);
const dirs = [...customDirsArr, piecesDir];
logger.info(`[piece-runner] loadAllPieceTriggers scanning dirs=[${dirs.join(', ')}]`);
for (const dir of dirs) {
if (!existsSync(dir)) {
@@ -299,7 +293,7 @@ export async function runPiece(
abortController?: AbortController;
safetyConfig?: { maxIterations?: number; maxRevisits?: number; bashUnrestricted?: boolean; bashSandbox?: 'auto' | 'always' | 'off' };
searchFilter?: SearchFilterConfig;
customPiecesDir?: string;
customPiecesDir?: string | string[];
contextManager?: ContextManager;
vlmEnabled?: boolean;
/** Phase 5: parent's job id, used to populate MemoryHandoff.parentJobId
@@ -657,7 +651,7 @@ function prepareMovementContext(
options?: {
spawnSubTask?: (params: { title: string; instruction: string; piece?: string }) => Promise<{ jobId: string; subtaskIndex: number; workspacePath: string }>;
searchFilter?: SearchFilterConfig;
customPiecesDir?: string;
customPiecesDir?: string | string[];
vlmEnabled?: boolean;
/** Traceability T-1: per-run event logger threaded into ToolContext. */
eventLogger?: EventLogger;
@@ -724,6 +718,9 @@ function prepareMovementContext(
toolsConfig,
eventLogger: options?.eventLogger,
searchFilter: options?.searchFilter,
// Pass the full array so in-agent reads (ListPieces/GetPiece) can see ALL
// custom dirs (per-user + global-custom). CreatePiece writes to dirs[0]
// (the per-user dir) — handled inside pieces.ts.
customPiecesDir: options?.customPiecesDir,
spawnSubTask: options?.spawnSubTask,
missionBrief: options?.missionBrief,
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest';
import ExcelJS from 'exceljs';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { resolveThemePalette, applyTint, extractSheetStyles } from './excel-styles.js';
async function roundTrip(build: (ws: ExcelJS.Worksheet) => void): Promise<ExcelJS.Worksheet> {
const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet('S'); build(ws);
const p = join(mkdtempSync(join(tmpdir(), 'xls-')), 't.xlsx');
await wb.xlsx.writeFile(p);
const wb2 = new ExcelJS.Workbook(); await wb2.xlsx.readFile(p);
return wb2.getWorksheet('S')!;
}
describe('resolveThemePalette', () => {
it('parses srgbClr + sysClr and applies the lt/dk swap', () => {
const xml = `<a:theme><a:themeElements><a:clrScheme name="x">
<a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1>
<a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1>
<a:dk2><a:srgbClr val="44546A"/></a:dk2>
<a:lt2><a:srgbClr val="E7E6E6"/></a:lt2>
<a:accent1><a:srgbClr val="4472C4"/></a:accent1>
<a:accent2><a:srgbClr val="ED7D31"/></a:accent2>
<a:accent3><a:srgbClr val="A5A5A5"/></a:accent3>
<a:accent4><a:srgbClr val="FFC000"/></a:accent4>
<a:accent5><a:srgbClr val="5B9BD5"/></a:accent5>
<a:accent6><a:srgbClr val="70AD47"/></a:accent6>
<a:hlink><a:srgbClr val="0563C1"/></a:hlink>
<a:folHlink><a:srgbClr val="954F72"/></a:folHlink>
</a:clrScheme></a:themeElements></a:theme>`;
const pal = resolveThemePalette(xml);
expect(pal[0]).toBe('#FFFFFF'); // theme idx 0 = Light1 = lt1
expect(pal[1]).toBe('#000000'); // theme idx 1 = Dark1 = dk1
expect(pal[4]).toBe('#4472C4'); // accent1
});
it('returns [] on missing scheme', () => { expect(resolveThemePalette(undefined)).toEqual([]); });
});
describe('applyTint', () => {
it('lightens with positive tint and darkens with negative', () => {
const lighter = applyTint('#4472C4', 0.4);
const darker = applyTint('#4472C4', -0.4);
expect(lighter).not.toBe('#4472C4');
expect(darker).not.toBe('#4472C4');
// positive tint raises luminance
const lum = (h: string) => parseInt(h.slice(1,3),16)+parseInt(h.slice(3,5),16)+parseInt(h.slice(5,7),16);
expect(lum(lighter)).toBeGreaterThan(lum('#4472C4'));
expect(lum(darker)).toBeLessThan(lum('#4472C4'));
});
});
describe('extractSheetStyles', () => {
it('dedups identical fills into one legend entry with a merged range', async () => {
const ws = await roundTrip((ws) => {
for (const a of ['A1','B1','C1','D1']) ws.getCell(a).fill = { type:'pattern', pattern:'solid', fgColor:{ argb:'FFFFF2CC' } };
});
const r = extractSheetStyles(ws, [], 250);
expect(r.legend.length).toBe(1);
expect(r.legend[0].sig).toContain('#FFF2CC');
expect(r.assignments[0].ranges).toEqual(['A1:D1']); // horizontal run merged
});
it('captures a decorated but EMPTY cell', async () => {
const ws = await roundTrip((ws) => { ws.getCell('B5').fill = { type:'pattern', pattern:'solid', fgColor:{ argb:'FFFF0000' } }; });
const r = extractSheetStyles(ws, [], 250);
expect(r.assignments.some(a => a.ranges.includes('B5'))).toBe(true);
});
it('reports font bold + color', async () => {
const ws = await roundTrip((ws) => { ws.getCell('A1').value='x'; ws.getCell('A1').font={ bold:true, color:{ argb:'FF9C0006' } }; });
const r = extractSheetStyles(ws, [], 250);
expect(r.legend[0].sig).toMatch(/font:.*bold/);
expect(r.legend[0].sig).toContain('#9C0006');
});
it('merges vertically-adjacent identical column-runs into a rectangle', async () => {
const ws = await roundTrip((ws) => {
for (let row=2; row<=20; row++) ws.getCell(`B${row}`).fill={ type:'pattern', pattern:'solid', fgColor:{ argb:'FF00FF00' } };
});
const r = extractSheetStyles(ws, [], 250);
expect(r.assignments[0].ranges).toEqual(['B2:B20']);
});
it('respects max_style_ranges cap', async () => {
const ws = await roundTrip((ws) => {
// 5 disjoint single cells with distinct fills (no merge possible)
const colors=['FFAA0000','FF00AA00','FF0000AA','FFAAAA00','FF00AAAA'];
colors.forEach((c,i)=>{ ws.getCell(`A${i*2+1}`).fill={type:'pattern',pattern:'solid',fgColor:{argb:c}}; });
});
const r = extractSheetStyles(ws, [], 2);
expect(r.truncated).toBe(true);
});
// Fix 4 (M-1): merges
it('includes merged cell range in result merges', async () => {
const ws = await roundTrip((ws) => {
ws.getCell('A1').value = 'merged';
ws.mergeCells('A1:C2');
});
const r = extractSheetStyles(ws, [], 250);
expect(r.merges).toContain('A1:C2');
});
// Fix 4 (M-2): conditional formatting — round-trip keeps conditionalFormattings
it('detects conditional formatting', async () => {
const ws = await roundTrip((ws) => {
ws.addConditionalFormatting({
ref: 'A1:A10',
rules: [{ type: 'expression', formulae: ['TRUE'], style: { fill: { type: 'pattern', pattern: 'solid', bgColor: { argb: 'FFFF0000' } } } }],
});
});
// ExcelJS preserves conditionalFormattings after round-trip (empirically verified)
const r = extractSheetStyles(ws, [], 250);
expect(r.conditionalFormatting).toBe(true);
});
// Fix 4 (M-3): border + numFmt + alignment
it('captures border, numFmt, and alignment in cell signature', async () => {
const ws = await roundTrip((ws) => {
const cell = ws.getCell('B2');
cell.value = 0.42;
cell.border = { bottom: { style: 'thin' } };
cell.numFmt = '0.00%';
cell.alignment = { horizontal: 'center' };
});
const r = extractSheetStyles(ws, [], 250);
expect(r.legend.length).toBeGreaterThan(0);
const sig = r.legend[0]!.sig;
expect(sig).toContain('border:');
expect(sig).toContain('numFmt:"0.00%"');
expect(sig).toContain('align:');
});
// Fix 4 (I-1 range honoring): rangeBounds filters cells
it('honors rangeBounds — includes A1 but excludes Z99', async () => {
const ws = await roundTrip((ws) => {
ws.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF0000FF' } };
ws.getCell('Z99').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF00FF00' } };
});
const r = extractSheetStyles(ws, [], 250, { minRow: 1, maxRow: 5, minCol: 1, maxCol: 5 });
const allRanges = r.assignments.flatMap(a => a.ranges);
expect(allRanges.some(rng => rng === 'A1' || rng.startsWith('A1:'))).toBe(true);
expect(allRanges.every(rng => !rng.includes('Z99') && !rng.startsWith('Z'))).toBe(true);
});
});
+515
View File
@@ -0,0 +1,515 @@
/**
* excel-styles.ts — Pure helper for ExcelJS style extraction.
* No dependency on office.ts internals; only imports exceljs types + stdlib.
*/
import type ExcelJS from 'exceljs';
// --- Public API ---
export interface SheetStyleResult {
legend: Array<{ id: string; sig: string }>; // s1 = "fill:#FFF2CC;font:bold,#9C0006"
assignments: Array<{ id: string; ranges: string[] }>; // s1: ['A1:D1','A10:D10']
merges: string[]; // ['A1:D1']
conditionalFormatting: boolean;
truncated: boolean; // true if range count hit the cap
}
/**
* Parse <a:clrScheme> from a theme1 XML string into a theme-index→'#RRGGBB' map.
* Returns [] if themeXml is missing/unparseable (caller then emits raw theme refs).
*/
export function resolveThemePalette(themeXml: string | undefined): string[] {
if (!themeXml) return [];
// Extract the clrScheme block
const schemeMatch = themeXml.match(/<a:clrScheme[^>]*>([\s\S]*?)<\/a:clrScheme>/);
if (!schemeMatch) return [];
const schemeXml = schemeMatch[1]!;
// The 12 entries in XML order: dk1, lt1, dk2, lt2, accent1-6, hlink, folHlink
const tagNames = ['dk1', 'lt1', 'dk2', 'lt2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'];
const scheme: string[] = [];
for (const tag of tagNames) {
const tagMatch = schemeXml.match(new RegExp(`<a:${tag}>[\\s\\S]*?</a:${tag}>`));
if (!tagMatch) {
// Missing entry
return [];
}
const block = tagMatch[0]!;
// Try srgbClr first
const srgbMatch = block.match(/<a:srgbClr[^>]*val="([0-9A-Fa-f]{6})"/);
if (srgbMatch) {
scheme.push('#' + srgbMatch[1]!.toUpperCase());
continue;
}
// Try sysClr with lastClr
const sysMatch = block.match(/<a:sysClr[^>]*lastClr="([0-9A-Fa-f]{6})"/);
if (sysMatch) {
scheme.push('#' + sysMatch[1]!.toUpperCase());
continue;
}
// Unrecognized
return [];
}
if (scheme.length < 12) return [];
// Apply the well-known lt/dk swap:
// themeIndex 0=Light1=lt1=scheme[1], 1=Dark1=dk1=scheme[0],
// 2=Light2=lt2=scheme[3], 3=Dark2=dk2=scheme[2],
// 4-9=accent1-6=scheme[4..9], 10=hlink=scheme[10], 11=folHlink=scheme[11]
return [
scheme[1]!, // 0: Light1 (lt1)
scheme[0]!, // 1: Dark1 (dk1)
scheme[3]!, // 2: Light2 (lt2)
scheme[2]!, // 3: Dark2 (dk2)
scheme[4]!, // 4: accent1
scheme[5]!, // 5: accent2
scheme[6]!, // 6: accent3
scheme[7]!, // 7: accent4
scheme[8]!, // 8: accent5
scheme[9]!, // 9: accent6
scheme[10]!, // 10: hlink
scheme[11]!, // 11: folHlink
];
}
/**
* OOXML tint applied to a '#RRGGBB' hex. tint in [-1,1]. Best-effort (HSL lum).
* if (tint < 0) L = L * (1 + tint); else L = L * (1 - tint) + tint
*/
export function applyTint(hexRgb: string, tint: number): string {
if (tint === 0) return hexRgb;
// Parse hex to [0,1]
const r = parseInt(hexRgb.slice(1, 3), 16) / 255;
const g = parseInt(hexRgb.slice(3, 5), 16) / 255;
const b = parseInt(hexRgb.slice(5, 7), 16) / 255;
// RGB to HSL
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
let l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
// Apply tint to luminance
if (tint < 0) {
l = l * (1 + tint);
} else {
l = l * (1 - tint) + tint;
}
l = Math.max(0, Math.min(1, l));
// HSL back to RGB
function hue2rgb(p: number, q: number, t: number): number {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
}
let rOut: number, gOut: number, bOut: number;
if (s === 0) {
rOut = gOut = bOut = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
rOut = hue2rgb(p, q, h + 1/3);
gOut = hue2rgb(p, q, h);
bOut = hue2rgb(p, q, h - 1/3);
}
const toHex = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0').toUpperCase();
return '#' + toHex(rOut) + toHex(gOut) + toHex(bOut);
}
// --- Internal helpers ---
// Standard 56-color indexed palette (BIFF8/OOXML standard colors)
// Index 64 = system fg, 65 = system bg → omit (return undefined)
const INDEXED_COLORS: (string | undefined)[] = [
'#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
'#000000', '#FFFFFF', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
'#800000', '#008000', '#000080', '#808000', '#800080', '#008080', '#C0C0C0', '#808080',
'#9999FF', '#993366', '#FFFFCC', '#CCFFFF', '#660066', '#FF8080', '#0066CC', '#CCCCFF',
'#000080', '#FF00FF', '#FFFF00', '#00FFFF', '#800080', '#800000', '#008080', '#0000FF',
'#00CCFF', '#CCFFFF', '#CCFFCC', '#FFFF99', '#99CCFF', '#FF99CC', '#CC99FF', '#FFCC99',
'#3366FF', '#33CCCC', '#99CC00', '#FFCC00', '#FF9900', '#FF6600', '#666699', '#969696',
'#003366', '#339966', '#003300', '#333300', '#993300', '#993366', '#333399', '#333333',
undefined, // 64 = system fg
undefined, // 65 = system bg
];
type ExcelJSColor = { argb?: string; theme?: number; tint?: number; indexed?: number };
function renderColor(color: ExcelJSColor | undefined, palette: string[]): string | null {
if (!color) return null;
if (color.argb) {
// ARGB: 'FFRRGGBB' → '#RRGGBB'
const argb = color.argb;
if (argb.length === 8) {
return '#' + argb.slice(2).toUpperCase();
}
return null;
}
if (color.theme !== undefined) {
const tint = color.tint ?? 0;
if (palette.length > 0 && palette[color.theme]) {
const resolved = applyTint(palette[color.theme]!, tint);
if (tint !== 0) {
return `theme(${color.theme},tint=${tint.toFixed(4)},resolved=${resolved})`;
}
return `theme(${color.theme},resolved=${resolved})`;
}
// Palette empty or missing this index — raw only
if (tint !== 0) {
return `theme(${color.theme},tint=${tint.toFixed(4)})`;
}
return `theme(${color.theme})`;
}
if (color.indexed !== undefined) {
const hex = INDEXED_COLORS[color.indexed];
if (!hex) return null; // system fg/bg
return `indexed(${color.indexed},${hex})`;
}
return null;
}
/**
* Returns true if a rendered color token represents the default color for the given theme index.
* Handles both `theme(N)` (no palette) and `theme(N,resolved=...)` (palette resolved) forms.
* Used to suppress the default font color (theme 1 = Dark1 ≈ black) and default border color
* (theme 0 = Light1 ≈ white/auto) from style signatures.
*/
function isDefaultColorToken(token: string, defaultThemeIndex: number): boolean {
// Exact bare form: "theme(N)"
if (token === `theme(${defaultThemeIndex})`) return true;
// Resolved form: "theme(N,resolved=#RRGGBB)" — tint variants are NOT default
if (token.startsWith(`theme(${defaultThemeIndex},resolved=`) && !token.includes(',tint=')) return true;
return false;
}
/**
* Build a canonical style signature string from non-default cell parts.
* Returns null if the cell has no non-default styling.
*/
function cellStyleSignature(
cell: ExcelJS.Cell,
palette: string[],
): string | null {
const parts: string[] = [];
// Fill: only pattern=solid (or other patterns) with fgColor
const fill = cell.fill as ExcelJS.Fill | undefined;
if (fill && (fill.type === 'pattern' || fill.type === 'gradient')) {
if (fill.type === 'pattern' && fill.pattern && fill.pattern !== 'none') {
const pf = fill as ExcelJS.FillPattern;
if (pf.fgColor) {
const colorStr = renderColor(pf.fgColor as ExcelJSColor, palette);
if (colorStr) {
parts.push(`fill:${colorStr}`);
}
}
} else if (fill.type === 'gradient') {
parts.push('fill:gradient');
}
}
// Font: only explicit flags + explicit color
const font = cell.font as ExcelJS.Font | undefined;
if (font) {
const fontParts: string[] = [];
if (font.bold === true) fontParts.push('bold');
if (font.italic === true) fontParts.push('italic');
if (font.underline) fontParts.push('underline');
// Color: only include if explicitly set (not the default theme 1 / auto-color)
const fontColor = renderColor(font.color as ExcelJSColor | undefined, palette);
if (fontColor && !isDefaultColorToken(fontColor, 1)) {
fontParts.push(fontColor);
}
if (fontParts.length > 0) {
parts.push(`font:${fontParts.join(',')}`);
}
}
// Border: for each side with a style
const border = cell.border as ExcelJS.Borders | undefined;
if (border) {
const sides: string[] = [];
for (const side of ['top', 'bottom', 'left', 'right', 'diagonal'] as const) {
const edge = border[side] as ExcelJS.Border | undefined;
if (edge?.style) {
const colorStr = renderColor(edge.color as ExcelJSColor | undefined, palette);
if (colorStr && !isDefaultColorToken(colorStr, 0)) {
sides.push(`${side}(${edge.style},${colorStr})`);
} else {
sides.push(`${side}(${edge.style})`);
}
}
}
if (sides.length > 0) {
parts.push(`border:${sides.join(',')}`);
}
}
// numFmt
const numFmt = cell.numFmt as string | undefined;
if (numFmt && numFmt !== 'General' && numFmt !== '') {
parts.push(`numFmt:"${numFmt}"`);
}
// Alignment
const alignment = cell.alignment as ExcelJS.Alignment | undefined;
if (alignment) {
const alignParts: string[] = [];
if (alignment.horizontal) alignParts.push(alignment.horizontal);
if (alignment.vertical) alignParts.push(alignment.vertical);
if (alignment.wrapText) alignParts.push('wrap');
if (alignParts.length > 0) {
parts.push(`align:${alignParts.join(',')}`);
}
}
if (parts.length === 0) return null;
return parts.join(';');
}
/** Parse an Excel range like "A1:C3" into 1-based row/col bounds. Returns null on parse failure. */
function parseRange(rangeStr: string): { minRow: number; maxRow: number; minCol: number; maxCol: number } | null {
const match = rangeStr.match(/^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i);
if (!match) return null;
function letterToCol(letters: string): number {
let col = 0;
for (const ch of letters.toUpperCase()) {
col = col * 26 + (ch.charCodeAt(0) - 64);
}
return col;
}
return {
minRow: parseInt(match[2]!, 10),
maxRow: parseInt(match[4]!, 10),
minCol: letterToCol(match[1]!),
maxCol: letterToCol(match[3]!),
};
}
/** Convert 1-based column index to Excel column letter(s). */
function colIndexToLetter(colIdx: number): string {
let s = '';
let n = colIdx;
while (n > 0) {
const rem = (n - 1) % 26;
s = String.fromCharCode(65 + rem) + s;
n = Math.floor((n - 1) / 26);
}
return s;
}
/**
* Scan a worksheet (includeEmpty:true so decorated-but-empty cells count) and
* produce the dedup legend + range map. `palette` from resolveThemePalette.
*
* @param rangeBounds - When provided, only cells within [minRow..maxRow] x [minCol..maxCol]
* are scanned, and merges are filtered to those intersecting this region. This must match
* the same range filter applied to the values table so styles and values cover the same cells.
*/
export function extractSheetStyles(
ws: ExcelJS.Worksheet,
palette: string[],
maxRanges: number,
rangeBounds?: { minRow: number; maxRow: number; minCol: number; maxCol: number },
): SheetStyleResult {
// Step 1: Scan all cells, build sig→id map and per-sig cell list
const sigToId = new Map<string, string>();
const cellsBySig = new Map<string, Array<[number, number]>>();
let counter = 0;
ws.eachRow({ includeEmpty: true }, (row, r) => {
if (rangeBounds && (r < rangeBounds.minRow || r > rangeBounds.maxRow)) return;
row.eachCell({ includeEmpty: true }, (cell, c) => {
if (rangeBounds && (c < rangeBounds.minCol || c > rangeBounds.maxCol)) return;
const sig = cellStyleSignature(cell, palette);
if (!sig) return;
if (!sigToId.has(sig)) {
sigToId.set(sig, `s${++counter}`);
cellsBySig.set(sig, []);
}
cellsBySig.get(sig)!.push([r, c]);
});
});
// Step 2: Build legend in id order
const idOrder: Array<{ id: string; sig: string }> = [];
for (const [sig, id] of sigToId) {
idOrder.push({ id, sig });
}
idOrder.sort((a, b) => {
const na = parseInt(a.id.slice(1), 10);
const nb = parseInt(b.id.slice(1), 10);
return na - nb;
});
// Step 3: For each sig, group cells into rectangles
const assignments: Array<{ id: string; ranges: string[] }> = [];
let totalRanges = 0;
let truncated = false;
for (const { id, sig } of idOrder) {
if (truncated) break;
const cells = cellsBySig.get(sig)!;
// Bucket by row
const byRow = new Map<number, number[]>();
for (const [r, c] of cells) {
if (!byRow.has(r)) byRow.set(r, []);
byRow.get(r)!.push(c);
}
// For each row, sort and split into contiguous horizontal runs
// A run is (row, colStart, colEnd)
const allRuns: Array<{ r: number; c1: number; c2: number }> = [];
for (const [r, cols] of byRow) {
cols.sort((a, b) => a - b);
let start = cols[0]!;
let prev = cols[0]!;
for (let i = 1; i < cols.length; i++) {
if (cols[i]! !== prev + 1) {
allRuns.push({ r, c1: start, c2: prev });
start = cols[i]!;
}
prev = cols[i]!;
}
allRuns.push({ r, c1: start, c2: prev });
}
// Sort runs by row, then c1
allRuns.sort((a, b) => a.r !== b.r ? a.r - b.r : a.c1 - b.c1);
// Merge vertically: greedy - open rectangles extended when next row has same c1,c2.
// O(K) implementation: openRects is keyed by (c1,c2); after finishing each row r,
// any rect not extended this row (r2 !== r) is flushed to closedRects immediately.
type Rect = { r1: number; r2: number; c1: number; c2: number };
// Key: `${c1},${c2}` → open rect
const openMap = new Map<string, Rect>();
const closedRects: Rect[] = [];
// Group runs by row so we can flush after each row
const runsByRow = new Map<number, Array<{ c1: number; c2: number }>>();
for (const run of allRuns) {
if (!runsByRow.has(run.r)) runsByRow.set(run.r, []);
runsByRow.get(run.r)!.push({ c1: run.c1, c2: run.c2 });
}
// Process rows in ascending order
const sortedRows = [...runsByRow.keys()].sort((a, b) => a - b);
for (const r of sortedRows) {
const runs = runsByRow.get(r)!;
const extendedKeys = new Set<string>();
for (const { c1, c2 } of runs) {
const key = `${c1},${c2}`;
const existing = openMap.get(key);
if (existing && existing.r2 === r - 1) {
// Extend the open rect into this row
existing.r2 = r;
extendedKeys.add(key);
} else {
// If there was a stale open rect with this key, close it first
if (existing) {
closedRects.push(existing);
}
// Open a new rect
openMap.set(key, { r1: r, r2: r, c1, c2 });
extendedKeys.add(key);
}
}
// Flush any open rects that were NOT extended this row (r2 !== r)
for (const [key, rect] of openMap) {
if (rect.r2 !== r) {
closedRects.push(rect);
openMap.delete(key);
}
}
}
// Close all remaining open rects
for (const rect of openMap.values()) {
closedRects.push(rect);
}
// Emit ranges
const ranges: string[] = [];
for (const rect of closedRects) {
if (totalRanges >= maxRanges) {
truncated = true;
break;
}
let rangeStr: string;
if (rect.r1 === rect.r2 && rect.c1 === rect.c2) {
rangeStr = `${colIndexToLetter(rect.c1)}${rect.r1}`;
} else {
rangeStr = `${colIndexToLetter(rect.c1)}${rect.r1}:${colIndexToLetter(rect.c2)}${rect.r2}`;
}
ranges.push(rangeStr);
totalRanges++;
}
if (ranges.length > 0) {
assignments.push({ id, ranges });
}
if (truncated) break;
}
// Step 4: Merges from ws.model.merges (filtered to rangeBounds when provided)
const allMerges: string[] = (ws.model.merges as string[] | undefined) ?? [];
const merges: string[] = rangeBounds
? allMerges.filter((m) => {
// Parse merge range "A1:C3" and check for intersection with rangeBounds
const mp = parseRange(m);
if (!mp) return true; // unparseable: keep it
// Two rectangles intersect if NOT (one is completely to the side/above/below the other)
return !(mp.maxRow < rangeBounds.minRow || mp.minRow > rangeBounds.maxRow ||
mp.maxCol < rangeBounds.minCol || mp.minCol > rangeBounds.maxCol);
})
: allMerges;
// Step 5: Conditional formatting
const conditionalFormatting = Boolean(
((ws as unknown) as { conditionalFormattings?: unknown[] }).conditionalFormattings?.length
);
return {
legend: idOrder,
assignments,
merges,
conditionalFormatting,
truncated,
};
}
+23
View File
@@ -358,4 +358,27 @@ describe('Read* tools — format mismatch rejection (issue #246)', () => {
expect(result?.isError).toBe(false);
expect(result?.output).toContain('Sheet1');
});
it('ReadExcel includes a Styles section only when include_styles=true', async () => {
workspacePath = makeWorkspace();
fs.mkdirSync(path.join(workspacePath, 'input'), { recursive: true });
const ExcelJS = (await import('exceljs')).default;
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Sheet1');
ws.getCell('A1').value = 'Header';
ws.getCell('A1').fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF2CC' } };
ws.getCell('A1').font = { bold: true };
await wb.xlsx.writeFile(path.join(workspacePath, 'input', 'styled.xlsx'));
// Without include_styles: no Styles section (backward compat)
const plain = await executeTool('ReadExcel', { path: 'input/styled.xlsx' }, makeContext(workspacePath));
expect(plain!.output).not.toContain('### Styles');
// With include_styles: Styles section present with fill color and font bold
const styled = await executeTool('ReadExcel', { path: 'input/styled.xlsx', include_styles: true }, makeContext(workspacePath));
expect(styled!.output).toContain('### Styles');
expect(styled!.output).toContain('#FFF2CC');
expect(styled!.output).toMatch(/bold/);
});
});
+29 -1
View File
@@ -9,6 +9,7 @@ import { PDFParse } from 'pdf-parse';
import { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { resolveAndGuard, resolveOutputPathWithin, truncateToBudget, getToolOutputBudgetTokens } from './core.js';
import { resolveThemePalette, extractSheetStyles } from './excel-styles.js';
import { logger } from '../../logger.js';
import { callVisionModel, resolveImagePath } from './image.js';
import type {
@@ -204,7 +205,7 @@ const READ_EXCEL_DEF: ToolDef = {
type: 'function',
function: {
name: 'ReadExcel',
description: 'Excel (.xlsx) を読み取りテキストで返す。詳細は ReadToolDoc({ name: "ReadExcel" })。',
description: 'Excel (.xlsx) を読み取りテキストで返す。include_styles:true でセル装飾も取得可。詳細は ReadToolDoc({ name: "ReadExcel" })。',
parameters: {
type: 'object',
properties: {
@@ -212,6 +213,8 @@ const READ_EXCEL_DEF: ToolDef = {
sheet: { type: 'string', description: 'シート名(省略時は全シート)' },
range: { type: 'string', description: 'セル範囲(例: A1:D10、省略時はシート全体)' },
max_cells: { type: 'number', description: '最大セル数(デフォルト: 1000' },
include_styles: { type: 'boolean', description: 'true でセル装飾(背景色/フォント/罫線/書式/結合)を ### Styles として追記。デフォルト false' },
max_style_ranges: { type: 'number', description: 'include_styles 時に出力する style range の上限(デフォルト 250' },
},
required: ['path'],
},
@@ -538,6 +541,8 @@ async function executeReadExcel(
const sheetFilter = typeof input['sheet'] === 'string' ? input['sheet'] : undefined;
const rangeFilter = typeof input['range'] === 'string' ? input['range'] : undefined;
const maxCells = typeof input['max_cells'] === 'number' ? input['max_cells'] : 1000;
const includeStyles = input['include_styles'] === true;
const maxStyleRanges = typeof input['max_style_ranges'] === 'number' ? input['max_style_ranges'] : 250;
let resolved: string;
try {
@@ -583,6 +588,9 @@ async function executeReadExcel(
return { output: `Failed to read Excel file: ${(e as Error).message}`, isError: true };
}
const themeXml = includeStyles ? ((wb.model as unknown as { themes?: { theme1?: string } }).themes?.theme1) : undefined;
const palette = includeStyles ? resolveThemePalette(themeXml) : [];
const filename = path.basename(resolved);
const sheetBlocks: ExcelSheetBlock[] = [];
let totalCells = 0;
@@ -697,6 +705,26 @@ async function executeReadExcel(
parts.push('');
}
if (includeStyles) {
let rangeBudget = maxStyleRanges;
for (const ws of wb.worksheets) {
if (sheetFilter && ws.name !== sheetFilter) continue;
const styleBounds = rangeFilter ? parseRange(rangeFilter) : null;
const styles = extractSheetStyles(ws, palette, rangeBudget, styleBounds ?? undefined);
rangeBudget -= styles.assignments.reduce((n, a) => n + a.ranges.length, 0);
if (styles.legend.length === 0 && styles.merges.length === 0 && !styles.conditionalFormatting) continue;
parts.push(`### Styles — ${ws.name}`);
for (const { id, sig } of styles.legend) parts.push(`${id} = ${sig}`);
parts.push('');
for (const { id, ranges } of styles.assignments) parts.push(`${id}: ${ranges.join(',')}`);
if (styles.merges.length) parts.push(`merges: ${styles.merges.join(',')}`);
if (styles.conditionalFormatting) parts.push('conditionalFormatting: present (effective styles not evaluated)');
if (styles.truncated || rangeBudget <= 0) parts.push(`[styles truncated: max_style_ranges=${maxStyleRanges} reached]`);
parts.push('');
if (rangeBudget <= 0) break;
}
}
if (doc.warnings.length > 0) {
parts.push('### Warnings');
for (const w of doc.warnings) {
+5 -2
View File
@@ -52,8 +52,11 @@ export async function executeTool(
}
const builtinPath = join('pieces', `${piece}.yaml`);
const customPath = ctx.customPiecesDir ? join(ctx.customPiecesDir, `${piece}.yaml`) : null;
if (!existsSync(builtinPath) && !(customPath && existsSync(customPath))) {
const customDirs = ctx.customPiecesDir
? (Array.isArray(ctx.customPiecesDir) ? ctx.customPiecesDir : [ctx.customPiecesDir])
: [];
const customExists = customDirs.some(d => existsSync(join(d, `${piece}.yaml`)));
if (!existsSync(builtinPath) && !customExists) {
return {
output: `指定されたピース "${piece}" が見つかりません。利用可能なピースを確認してください。`,
isError: true,
+106 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, cpSync } from 'fs';
import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync, cpSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { executeTool } from './pieces.js';
@@ -87,6 +87,111 @@ movements:
});
});
describe('customPiecesDir as array — P1-a regression', () => {
/**
* When worker passes customPiecesDir=[userDir, globalDir] to ToolContext,
* ListPieces and GetPiece must see pieces from BOTH dirs.
* CreatePiece must write to dirs[0] (per-user dir, not global).
*/
let userDir: string;
let globalDir: string;
beforeEach(() => {
userDir = mkdtempSync(join(tmpdir(), 'pieces-user-'));
globalDir = mkdtempSync(join(tmpdir(), 'pieces-global-'));
});
afterEach(() => {
rmSync(userDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
});
const userPiece = `name: user-only-piece
description: user piece
max_movements: 1
initial_movement: do
movements:
- name: do
edit: false
persona: p
instruction: i
allowed_tools: []
rules:
- condition: done
next: do
`;
const globalPiece = `name: global-only-piece
description: global piece
max_movements: 1
initial_movement: do
movements:
- name: do
edit: false
persona: p
instruction: i
allowed_tools: []
rules:
- condition: done
next: do
`;
function arrayCtx(): ToolContext {
return { workspacePath: '/tmp/dummy', editAllowed: false, customPiecesDir: [userDir, globalDir] };
}
it('ListPieces returns pieces from BOTH per-user and global-custom dirs', async () => {
writeFileSync(join(userDir, 'user-only-piece.yaml'), userPiece, 'utf-8');
writeFileSync(join(globalDir, 'global-only-piece.yaml'), globalPiece, 'utf-8');
const result = await executeTool('ListPieces', {}, arrayCtx());
expect(result?.isError).toBe(false);
expect(result?.output).toContain('user-only-piece');
expect(result?.output).toContain('global-only-piece');
});
it('GetPiece finds a piece in the second custom dir (global-custom)', async () => {
writeFileSync(join(globalDir, 'global-only-piece.yaml'), globalPiece, 'utf-8');
const result = await executeTool('GetPiece', { name: 'global-only-piece' }, arrayCtx());
expect(result?.isError).toBe(false);
expect(result?.output).toContain('global piece');
});
it('GetPiece prefers dirs[0] (per-user) over dirs[1] (global) for same name', async () => {
const userVersion = userPiece.replace('description: user piece', 'description: per-user version');
writeFileSync(join(userDir, 'global-only-piece.yaml'), userVersion, 'utf-8');
writeFileSync(join(globalDir, 'global-only-piece.yaml'), globalPiece, 'utf-8');
const result = await executeTool('GetPiece', { name: 'global-only-piece' }, arrayCtx());
expect(result?.isError).toBe(false);
expect(result?.output).toContain('per-user version');
});
it('CreatePiece writes to dirs[0] (per-user dir), not global dir', async () => {
const newPiece = `name: brand-new-piece
description: new
max_movements: 5
initial_movement: do
movements:
- name: do
edit: false
persona: p
instruction: i
allowed_tools: []
rules:
- condition: done
next: do
`;
const result = await executeTool('CreatePiece', { yaml_content: newPiece }, arrayCtx());
expect(result?.isError).toBe(false);
// Must land in userDir (dirs[0])
expect(existsSync(join(userDir, 'brand-new-piece.yaml'))).toBe(true);
// Must NOT land in globalDir (dirs[1])
expect(existsSync(join(globalDir, 'brand-new-piece.yaml'))).toBe(false);
});
});
describe('UpdatePiece', () => {
// Uses the real bundled `chat.yaml` to exercise the built-in guard. The
// file's pre-update content is captured up-front so we can detect any
+24 -12
View File
@@ -7,10 +7,20 @@ import type { ToolContext, ToolResult } from './core.js';
const BUILTIN_PIECES_DIR = resolve(process.cwd(), 'pieces');
const VALID_NAME = /^[a-z0-9-]+$/;
function findPiecePath(name: string, customDir: string | undefined): string | null {
if (customDir) {
const customPath = join(customDir, `${name}.yaml`);
if (existsSync(customPath)) return customPath;
/** Normalise `string | string[] | undefined` → `string[]` (may be empty). */
function toCustomDirs(customDir: string | string[] | undefined): string[] {
if (!customDir) return [];
return Array.isArray(customDir) ? customDir : [customDir];
}
/**
* Search all custom dirs in order, then fall back to built-in.
* Returns the first path that exists, or null.
*/
function findPiecePath(name: string, customDir: string | string[] | undefined): string | null {
for (const d of toCustomDirs(customDir)) {
const p = join(d, `${name}.yaml`);
if (existsSync(p)) return p;
}
const builtinPath = join(BUILTIN_PIECES_DIR, `${name}.yaml`);
if (existsSync(builtinPath)) return builtinPath;
@@ -19,17 +29,16 @@ function findPiecePath(name: string, customDir: string | undefined): string | nu
/**
* A piece is "built-in" when it lives only under the bundled BUILTIN_PIECES_DIR
* (no override in customDir). Built-ins are git-tracked and shipped with the
* (no override in any custom dir). Built-ins are git-tracked and shipped with the
* app — letting the LLM rewrite them in place corrupts the install (a real
* incident: the agent silently replaced game-tweet-generator with a version
* missing max_movements, making every subsequent run abort instantly). The
* LLM should use CreatePiece with a new name to derive a customized variant
* instead.
*/
function isBuiltinOnly(name: string, customDir: string | undefined): boolean {
if (customDir) {
const customPath = join(customDir, `${name}.yaml`);
if (existsSync(customPath)) return false;
function isBuiltinOnly(name: string, customDir: string | string[] | undefined): boolean {
for (const d of toCustomDirs(customDir)) {
if (existsSync(join(d, `${name}.yaml`))) return false;
}
const builtinPath = join(BUILTIN_PIECES_DIR, `${name}.yaml`);
return existsSync(builtinPath);
@@ -156,7 +165,9 @@ function executeListPieces(ctx: ToolContext): ToolResult {
const seen = new Set<string>();
const pieces: Array<{ name: string; description: string; keywords: string[]; custom: boolean }> = [];
const dirs: Array<{ dir: string; custom: boolean }> = [];
if (ctx.customPiecesDir && existsSync(ctx.customPiecesDir)) dirs.push({ dir: ctx.customPiecesDir, custom: true });
for (const d of toCustomDirs(ctx.customPiecesDir)) {
if (existsSync(d)) dirs.push({ dir: d, custom: true });
}
dirs.push({ dir: BUILTIN_PIECES_DIR, custom: false });
for (const { dir, custom } of dirs) {
@@ -231,8 +242,9 @@ function executeCreatePiece(input: Record<string, unknown>, ctx: ToolContext): T
return { output: `Piece "${piece.name}" already exists. Use UpdatePiece to modify it.`, isError: true };
}
// カスタムディレクトリがあればそこに、なければ builtin に書き込み
const targetDir = ctx.customPiecesDir ?? BUILTIN_PIECES_DIR;
// Write to the FIRST custom dir (per-user dir), or fall back to builtin if no custom dirs.
const customDirs = toCustomDirs(ctx.customPiecesDir);
const targetDir = customDirs[0] ?? BUILTIN_PIECES_DIR;
mkdirSync(targetDir, { recursive: true });
const filePath = join(targetDir, `${piece.name}.yaml`);
try {