Compare commits

..
Author SHA1 Message Date
78cc6d1e84 fix(slugs): CJK slug support in SlugRegistry and dream-cycle summary slug (takeover of #782, #738)
Master already widened slugifySegment (sync.ts) and validatePageSlug
(operations.ts) to CJK in v0.32.7, but the other two validators #782
targeted stayed ASCII-only: SlugRegistry's SLUG_RE rejected any CJK
desiredSlug from BrainWriter, and synthesize.ts's SUMMARY_SLUG_RE (whose
comment claimed it was kept in sync with validatePageSlug) rejected CJK
output roots.

Hoist the segment grammar into cjk.ts as PAGE_SLUG_SEG and compose all
three regex sites from it, so the four slug validators share one grammar.
Each site keeps its own shape (SlugRegistry's >=2-segment dir/name form,
validatePageSlug's case-insensitive flag).

Scope stays CJK (matching v0.32.7), not full \p{L} Unicode as #782
proposed — all-scripts slugs (lookalike/RTL spoofing) is a maintainer
policy call.

Co-authored-by: tamagodo-fu <tamagodo-fu@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:21:39 -07:00
11 changed files with 44 additions and 262 deletions
+3 -5
View File
@@ -223,16 +223,14 @@ export GBRAIN_REMOTE_CLIENT_ID=<Alice's client_id>
export GBRAIN_REMOTE_CLIENT_SECRET=<Alice's client_secret>
export GBRAIN_REMOTE_MCP_URL=https://brain.acme-co.com/mcp
gbrain search "performance review"
gbrain search "performance review" --remote
```
(On a thin-client install every shared op routes through the remote MCP server automatically — no flag needed. The env vars select whose credentials the call uses.)
Alice should see results only from `customers` and `shared`. The performance-review notes live in `internal`, which she's not scoped to read. She shouldn't see them.
```bash
# Terminal 2, as Bob (export his credentials similarly)
gbrain search "performance review"
gbrain search "performance review" --remote
```
Bob should see the performance-review notes from `internal`, plus anything related from `shared`. He shouldn't see anything that lives only in `customers`.
@@ -516,7 +514,7 @@ The first sync embeds every page, which takes time. Check `gbrain sources status
### "I see a page I shouldn't see"
This shouldn't happen, but if you suspect it, run `gbrain search <query> --json` as the constrained client (thin-client install, with the client's `GBRAIN_REMOTE_*` env exported) and inspect the `source_id` field on every returned result. Every row should be in the client's `--federated-read` set. If one isn't, file an issue with the exact slug and source IDs.
This shouldn't happen, but if you suspect it, run `gbrain search <query> --remote --json` as the constrained client and inspect the `source_id` field on every returned result. Every row should be in the client's `--federated-read` set. If one isn't, file an issue with the exact slug and source IDs.
### "The synthesized answer is wrong"
+5 -84
View File
@@ -464,11 +464,7 @@ async function main() {
// routed path. Date → ISO string; bigint → string (postgres.js shape);
// Buffer → object. Microsecond-cost; eliminates a whole drift bug class.
const result = JSON.parse(JSON.stringify(rawResult, bigintToStringReplacer));
// #380 pass-through: `--json` (undeclared on most ops, promised by docs)
// emits the raw op result instead of the human formatter.
const output = params.json === true
? JSON.stringify(result, null, 2) + '\n'
: formatResult(op.name, result);
const output = formatResult(op.name, result);
if (output) process.stdout.write(output);
} catch (e: unknown) {
// v0.42.20.0 (codex D4): on error, set exitCode + return so the `finally`
@@ -551,10 +547,7 @@ async function runThinClientRouted(
signal: sigintController.signal,
});
const result = unpackToolResult(raw);
// #380: same --json seam as the local-engine path (renderer parity).
const output = params.json === true
? JSON.stringify(result, null, 2) + '\n'
: formatResult(op.name, result);
const output = formatResult(op.name, result);
if (output) process.stdout.write(output);
} catch (e: unknown) {
if (e instanceof RemoteMcpError) {
@@ -764,28 +757,10 @@ export function resolveQueryImage(
return { path: imagePath, base64, mime };
}
/**
* #380: undeclared flags that are honored DOWNSTREAM of parseOpArgs and must
* keep passing through when unknown flags become hard errors:
* - source → makeContext's resolveSourceId (the --source axis)
* - brain → the mount/brain routing axis (docs promise the flag)
* - dry_run → makeContext's ctx.dryRun (ops without a declared dry_run)
* - json → raw-JSON output seam (local + thin-client paths)
*/
const PASSTHROUGH_VALUE_FLAGS = new Set(['source', 'brain']);
const PASSTHROUGH_BOOL_FLAGS = new Set(['dry_run', 'json']);
export function parseOpArgs(op: Operation, args: string[]): Record<string, unknown> {
const params: Record<string, unknown> = {};
const positional = op.cliHints?.positional || [];
let posIdx = 0;
const cliName = op.cliHints?.name || op.name;
const MAX_STDIN = 5_000_000; // 5MB cap, shared by stdin and --file
// #380: `--file <path>` fills the op's declared stdin param (put's `content`)
// from a file. Driven by cliHints.stdin — no per-op hard-coding — and
// disabled when the op declares a real `file` param of its own.
const fileParam = op.cliHints?.stdin && !op.params.file ? op.cliHints.stdin : undefined;
let filePath: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@@ -799,42 +774,12 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
}
}
const key = arg.slice(2).replace(/-/g, '_');
if (fileParam && key === 'file') {
if (i + 1 >= args.length) {
console.error(`Error: ${arg} requires a value.`);
process.exit(1);
}
filePath = args[++i];
continue;
}
const paramDef = op.params[key];
if (!paramDef) {
if (PASSTHROUGH_BOOL_FLAGS.has(key)) {
params[key] = true;
continue;
}
if (PASSTHROUGH_VALUE_FLAGS.has(key)) {
if (i + 1 >= args.length) {
console.error(`Error: ${arg} requires a value.`);
process.exit(1);
}
params[key] = args[++i];
continue;
}
// #380: unknown flags were silently swallowed into params, so typos
// like `put --file` created empty pages instead of erroring.
console.error(`Unknown option for gbrain ${cliName}: ${arg}`);
console.error(`Run 'gbrain ${cliName} --help' for valid flags.`);
process.exit(1);
}
if (paramDef.type === 'boolean') {
if (paramDef?.type === 'boolean') {
params[key] = true;
} else if (i + 1 < args.length) {
params[key] = args[++i];
if (paramDef.type === 'number') params[key] = Number(params[key]);
} else {
console.error(`Error: ${arg} requires a value.`);
process.exit(1);
if (paramDef?.type === 'number') params[key] = Number(params[key]);
}
} else if (posIdx < positional.length) {
const key = positional[posIdx++];
@@ -843,30 +788,10 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
}
}
// #380: resolve --file AFTER the loop so --file/--content conflicts are
// caught in either order.
if (filePath !== undefined && fileParam) {
if (params[fileParam] !== undefined) {
console.error(`Error: use only one of --file, --${fileParam}, or stdin for gbrain ${cliName}.`);
process.exit(1);
}
let fileContent: string;
try {
fileContent = readFileSync(filePath, 'utf-8');
} catch (e) {
console.error(`Error: cannot read --file ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
process.exit(1);
}
if (Buffer.byteLength(fileContent, 'utf-8') > MAX_STDIN) {
console.error(`Error: file content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
process.exit(1);
}
params[fileParam] = fileContent;
}
// Read stdin for content params
if (op.cliHints?.stdin && !params[op.cliHints.stdin] && !process.stdin.isTTY) {
const stdinContent = readFileSync(0, 'utf-8');
const MAX_STDIN = 5_000_000; // 5MB
if (Buffer.byteLength(stdinContent, 'utf-8') > MAX_STDIN) {
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
process.exit(1);
@@ -2328,10 +2253,6 @@ export function printOpHelp(op: Operation, invokedName?: string) {
const prefix = isPos ? ` <${key}>` : ` --${key.replace(/_/g, '-')}`;
console.log(`${prefix.padEnd(28)} ${def.description || ''}${req}`);
}
// #380: ops that read stdin also accept --file <path> (parseOpArgs).
if (op.cliHints?.stdin && !op.params.file) {
console.log(`${' --file <path>'.padEnd(28)} Read ${op.cliHints.stdin} from a file (alternative to --${op.cliHints.stdin} or stdin)`);
}
}
}
+8
View File
@@ -20,6 +20,14 @@ export const CJK_SLUG_CHARS = '一-鿿぀-ゟ゠-ヿ가-힯';
export const CJK_RANGES_REGEX = new RegExp(`[${CJK_SLUG_CHARS}]`);
/**
* Page-slug segment grammar (no anchors): alnum-or-CJK lead char, then
* alnum/CJK/hyphen continuation. Single source for validatePageSlug
* (operations.ts), SlugRegistry's SLUG_RE, and the dream-cycle
* SUMMARY_SLUG_RE so every slug validator shares one grammar (#738).
*/
export const PAGE_SLUG_SEG = `[a-z0-9${CJK_SLUG_CHARS}][a-z0-9${CJK_SLUG_CHARS}\\-]*`;
export const CJK_SENTENCE_DELIMITERS = ['。', '', '']; // 。!?
export const CJK_CLAUSE_DELIMITERS = ['', '', '', '、']; // ;:,、
+3 -2
View File
@@ -43,10 +43,11 @@ import { serializeMarkdown, serializePageToMarkdown } from '../markdown.ts';
import type { Page, PageType } from '../types.ts';
import { validateSourceId } from '../utils.ts';
import { safeSplitIndex } from '../text-safe.ts';
import { PAGE_SLUG_SEG } from '../cjk.ts';
// Slug regex from validatePageSlug — kept in sync.
// Slug grammar from validatePageSlug — shared via PAGE_SLUG_SEG (#738).
// Used for the orchestrator-written summary index slug.
const SUMMARY_SLUG_RE = /^[a-z0-9][a-z0-9\-]*(\/[a-z0-9][a-z0-9\-]*)*$/;
const SUMMARY_SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`);
// ── Model context budget (D1, D5, D7, D9) ─────────────────────────────
+3 -11
View File
@@ -25,7 +25,7 @@ import { bumpLastRetrievedAt } from './last-retrieved.ts';
import { isSearchMode } from './search/mode.ts';
import { stampEvidence } from './search/evidence.ts';
import type { SearchResult } from './types.ts';
import { CJK_SLUG_CHARS } from './cjk.ts';
import { CJK_SLUG_CHARS, PAGE_SLUG_SEG } from './cjk.ts';
import * as db from './db.ts';
import { VERSION } from '../version.ts';
import {
@@ -162,7 +162,6 @@ export function validatePageSlug(slug: string): void {
}
// v0.32.7: CJK ranges (Han / Hiragana / Katakana / Hangul Syllables) allowed
// in segments. ASCII shape rules (lead char, hyphen continuation) preserved.
const PAGE_SLUG_SEG = `[a-z0-9${CJK_SLUG_CHARS}][a-z0-9${CJK_SLUG_CHARS}\\-]*`;
if (!new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`, 'i').test(slug)) {
throw new OperationError('invalid_params', `Invalid page_slug: ${slug} (allowed: alphanumeric, CJK, hyphens, forward-slash separated segments)`);
}
@@ -769,7 +768,7 @@ const get_page: Operation = {
const put_page: Operation = {
name: 'put_page',
description: 'Write/update a page (markdown with frontmatter). Chunks, embeds, reconciles tags, and (when auto_link/auto_timeline are enabled) extracts + reconciles graph links and timeline entries. On the CLI, `gbrain put SLUG --file PATH` reads content from a file (also `--content` or stdin). For provenance write-through and a binary-NUL guard, prefer `gbrain capture --file PATH --slug SLUG` (v0.39.3.0).',
description: 'Write/update a page (markdown with frontmatter). Chunks, embeds, reconciles tags, and (when auto_link/auto_timeline are enabled) extracts + reconciles graph links and timeline entries. For large content on Windows (pipe-buffer limit ~45KB) or any file-as-input workflow, use `gbrain capture --file PATH --slug SLUG` — capture reads the file as a Buffer with a binary-NUL guard and adds provenance write-through (v0.39.3.0).',
params: {
slug: { type: 'string', required: true, description: 'Page slug' },
content: { type: 'string', required: true, description: 'Full markdown content with YAML frontmatter' },
@@ -1384,10 +1383,7 @@ const list_pages: Operation = {
params: {
type: { type: 'string', description: 'Filter by page type' },
tag: { type: 'string', description: 'Filter by tag' },
limit: { type: 'number', description: 'Max results (default 50, capped at 100 — use offset to paginate beyond)' },
// #2876: the 100-row cap was silent and there was no way past it even
// though both engines already support OFFSET on listPages.
offset: { type: 'number', description: 'Skip first N results (pagination; pair with limit)' },
limit: { type: 'number', description: 'Max results (default 50)' },
// v0.29 — surface filter that already exists on PageFilters.
updated_after: {
type: 'string',
@@ -1418,10 +1414,6 @@ const list_pages: Operation = {
type: p.type as any,
tag: p.tag as string,
limit: clampSearchLimit(p.limit as number | undefined, 50, 100),
// #2876: thread pagination through (engines already honor offset).
offset: Number.isFinite(p.offset as number) && (p.offset as number) > 0
? Math.floor(p.offset as number)
: undefined,
includeDeleted: (p.include_deleted as boolean) === true,
updated_after: typeof p.updated_after === 'string' ? p.updated_after : undefined,
sort,
+4 -1
View File
@@ -17,6 +17,7 @@
import type { BrainEngine } from '../engine.ts';
import type { PageType } from '../types.ts';
import { PAGE_SLUG_SEG } from '../cjk.ts';
export interface CreateSlugInput {
/**
@@ -71,7 +72,9 @@ export class SlugRegistryError extends Error {
// SlugRegistry
// ---------------------------------------------------------------------------
const SLUG_RE = /^[a-z0-9][a-z0-9\-]*(\/[a-z0-9][a-z0-9\-]*)+$/;
// Shares the page-slug segment grammar (incl. CJK ranges, #738) with
// validatePageSlug; keeps this site's dir/name shape (>= 2 segments).
const SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})+$`);
export class SlugRegistry {
constructor(private engine: BrainEngine) {}
-33
View File
@@ -1,41 +1,8 @@
import { describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { parseOpArgs } from '../src/cli.ts';
import { operationsByName } from '../src/core/operations.ts';
describe('parseOpArgs', () => {
// #380: `gbrain put SLUG --file PATH` reads content from the file instead
// of silently swallowing the flag and creating an empty page.
test('put --file reads the stdin param (content) from a file', () => {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-put-file-'));
try {
const pagePath = join(dir, 'page.md');
writeFileSync(pagePath, '# From file\n\nBody loaded from --file.\n');
const params = parseOpArgs(operationsByName.put_page, ['concepts/from-file', '--file', pagePath]);
expect(params.slug).toBe('concepts/from-file');
expect(params.content).toBe('# From file\n\nBody loaded from --file.\n');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// #380 regression guard: undeclared-but-honored flags must keep passing
// through when unknown flags become hard errors (--source is read by
// makeContext; --json by the output seam; --dry-run by ctx.dryRun).
test('pass-through allowlist flags survive on ops that do not declare them', () => {
const params = parseOpArgs(operationsByName.get_page, [
'people/alice-example', '--source', 'wiki', '--json', '--dry-run',
]);
expect(params).toEqual({
slug: 'people/alice-example',
source: 'wiki',
json: true,
dry_run: true,
});
});
test('--no-<boolean> maps to false without consuming the next flag', () => {
const params = parseOpArgs(operationsByName.query, [
'freshEmbedSourceScope code source',
+1 -71
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
@@ -120,76 +120,6 @@ describe('CLI dispatch integration', () => {
expect(exitCode).toBe(0);
});
// #380 / PR #856: put --help documents the --file input path.
test('put --help documents --file input', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'put', '--help'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const exitCode = await proc.exited;
expect(stdout).toContain('Usage: gbrain put');
expect(stdout).toContain('--file <path>');
expect(exitCode).toBe(0);
});
// #380: unknown flags on shared ops are a hard error (previously silently
// swallowed into params — `put --file` created empty pages). parseOpArgs
// runs BEFORE engine connect, so the error must fire without a brain.
test('unknown shared-op flags fail before DB connection', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-unknown-flag-'));
try {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'get', 'people/alice', '--bogus'], {
cwd: repoRoot,
stdout: 'pipe',
stderr: 'pipe',
env: isolatedEnv(home),
});
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stderr).toContain('Unknown option for gbrain get: --bogus');
expect(stderr).not.toContain('No brain configured');
expect(exitCode).toBe(1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('put rejects combining --file and --content', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-put-conflict-'));
try {
const pagePath = join(home, 'page.md');
writeFileSync(pagePath, 'file body\n');
const proc = Bun.spawn(
['bun', 'run', 'src/cli.ts', 'put', 'a/b', '--content', 'inline', '--file', pagePath],
{ cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', env: isolatedEnv(home) },
);
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stderr).toContain('use only one of --file, --content, or stdin');
expect(exitCode).toBe(1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('put --file with a missing path errors instead of writing an empty page', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-cli-put-missing-file-'));
try {
const proc = Bun.spawn(
['bun', 'run', 'src/cli.ts', 'put', 'a/b', '--file', join(home, 'nope.md')],
{ cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', env: isolatedEnv(home) },
);
const stderr = await new Response(proc.stderr).text();
const exitCode = await proc.exited;
expect(stderr).toContain('cannot read --file');
expect(exitCode).toBe(1);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('upgrade --help prints usage without running upgrade', async () => {
const proc = Bun.spawn(['bun', 'run', 'src/cli.ts', 'upgrade', '--help'], {
cwd: repoRoot,
+6
View File
@@ -86,6 +86,12 @@ describe('#2415: loadOutputRoot validation + patterns gather scope', () => {
expect(await loadOutputRoot(engine)).toBe('wiki');
});
test('CJK root passes the slug grammar (#738)', async () => {
await engine.setConfig('dream.synthesize.output_root', '知识/笔记');
expect(await loadOutputRoot(engine)).toBe('知识/笔记');
await engine.setConfig('dream.synthesize.output_root', '');
});
test('patterns phase gathers reflections under the configured root', async () => {
await engine.setConfig('dream.synthesize.output_root', 'notes');
for (let i = 0; i < 3; i++) {
-55
View File
@@ -1,55 +0,0 @@
import { describe, test, expect } from 'bun:test';
import { operationsByName } from '../src/core/operations.ts';
/**
* #2876: `gbrain list --limit` silently clamped at 100 with no pagination.
* list_pages now declares `offset` (both engines already supported it on
* PageFilters) and the limit description discloses the 100-row cap.
*/
describe('list_pages pagination (#2876)', () => {
const listPagesOp = operationsByName.list_pages;
function makeCtx(captured: unknown[]) {
return {
engine: {
listPages: async (filters: unknown) => {
captured.push(filters);
return [];
},
},
config: { engine: 'pglite' },
logger: { info() {}, warn() {}, error() {} },
dryRun: false,
remote: false,
sourceId: 'default',
} as any;
}
test('declares offset param and discloses the 100-row cap on limit', () => {
expect(listPagesOp.params.offset).toBeDefined();
expect(listPagesOp.params.offset.type).toBe('number');
expect(listPagesOp.params.limit.description).toContain('100');
});
test('threads offset through to engine.listPages', async () => {
const captured: any[] = [];
await listPagesOp.handler(makeCtx(captured), { limit: 10, offset: 30 });
expect(captured[0].offset).toBe(30);
expect(captured[0].limit).toBe(10);
});
test('drops negative, non-finite, and zero offsets', async () => {
const captured: any[] = [];
const ctx = makeCtx(captured);
await listPagesOp.handler(ctx, { offset: -5 });
await listPagesOp.handler(ctx, { offset: Infinity });
await listPagesOp.handler(ctx, { offset: 0 });
for (const f of captured) expect(f.offset).toBeUndefined();
});
test('floors fractional offsets', async () => {
const captured: any[] = [];
await listPagesOp.handler(makeCtx(captured), { offset: 7.9 });
expect(captured[0].offset).toBe(7);
});
});
+11
View File
@@ -203,6 +203,17 @@ describe('SlugRegistry', () => {
})).rejects.toThrow(SlugRegistryError);
});
test('create accepts CJK slugs (#738)', async () => {
const reg = new SlugRegistry(engine);
const r = await reg.create({
desiredSlug: '知识/品牌圣经',
displayName: '品牌圣经',
type: 'note',
});
expect(r.slug).toBe('知识/品牌圣经');
expect(r.exact).toBe(true);
});
test('create throws on invalid slug', async () => {
const reg = new SlugRegistry(engine);
await expect(reg.create({