mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
* feat: v0.19.0 — skillify loop + AGENTS.md compat + brain-first convention This is the v0.19.0 release. The branch ships four new CLI commands, a refactor to check-resolvable, and an expansion of the brain-first convention for sub-agent tool discovery. The original commit message described only the convention expansion, undercounting the scope by ~5x; this amend captures the full release. NEW COMMANDS - gbrain skillify scaffold <name> — 4 stub files + idempotent resolver row - gbrain skillify check [path] — 10-item post-task audit (promoted) - gbrain skillpack list / install — curated 25-skill bundle, atomic install - gbrain skillpack diff <name> — per-file diff preview - gbrain routing-eval — dedicated CI verb for Check 5 fixtures CHECK-RESOLVABLE REFACTOR - Accepts AGENTS.md as a resolver file alongside RESOLVER.md, at either the skills directory or one level up (workspace root layout). - Auto-derives the skill manifest by walking skills/*/SKILL.md when manifest.json is missing. - Splits ResolvableReport into errors[] + warnings[] so advisory checks (filing audit, routing gaps, DRY violations) don't break CI by default. - New --strict opt-in flag promotes warnings to exit 1. BRAIN-FIRST CONVENTION - skills/conventions/brain-first.md expanded from 5-step lookup guide to full sub-agent reference: tool inventory, lookup chain, score thresholds, authority hierarchy, sync rules, entity page conventions, sub-agent propagation rule. PRODUCTION-READINESS HARDENING (this branch's review pass) - routing-eval --llm: emits stderr placeholder notice + runs structural layer only. README, CHANGELOG, CLI help all rewritten consistently. Was a silent no-op against documented contract. - skillpack installer: receipt comment in fence (cumulative-slugs="...") preserves single-skill-install accumulation while letting install --all prune removed bundle skills cleanly. Unknown rows preserved + stderr warning for the operating agent. Pre-v0.19 fences upgrade silently. - skillify scaffold: resolver-row regex broadened to detect backticked, quoted, and bare path forms. No duplicate row on --force after the user normalizes formatting. - scripts/check-privacy.sh: now wired into package.json test chain so the wintermute-ban rule is actually enforced. New regression test. - E2E Tier 2 (LLM skills) promoted from schedule-only to required per-PR CI. Local Tier 1 + Tier 2 verified clean. - Stale v0.17/v0.18 version labels rewritten across new files. TESTS - test/routing-eval-cli.test.ts: 4 cases covering --llm warn semantics - test/privacy-script-wired.test.ts: regression guard for CI wiring - test/skillpack-install.test.ts: 4 new cases for receipt + cumulative + unknown-row preserve+warn + pre-v0.19 upgrade path - test/skillify-scaffold.test.ts: 4 new cases for broadened regex VERIFICATION - bun test: 2237 pass / 18 known PGLite-contention flakes (CI green; documented as P3 dev-experience in TODOS.md) - bun run typecheck: clean - bun run test:e2e: 18/19 files green (1 pre-existing flake on master, not caused by this branch — verified via git stash) - llms.txt + llms-full.txt regenerated to match README + CHANGELOG Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: scrub banned fork name from public artifacts The privacy guard wired into the test chain in this branch caught 5 pre-existing references to the banned OpenClaw fork name in CHANGELOG.md (2x), skills/migrations/v0.19.0.md (1x), src/cli.ts (1x), and src/commands/sync.ts (1x). All originated in master's v0.19.0 release notes and migration doc when the privacy script existed but wasn't wired into CI yet. Replacements per CLAUDE.md privacy mapping: - Origin-story copy (CHANGELOG layer narratives, code comments naming the production deployment that drove the feature) → "Garry's OpenClaw" - Reader-facing migration step → "your OpenClaw" No code semantics changed. Comments + headings only. Verification: scripts/check-privacy.sh exits 0, full CI guard chain green (privacy + jsonb + progress + wasm + typecheck). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump VERSION to 0.24.0 + new CHANGELOG entry Bump branch version above master's v0.21.0 per CLAUDE.md "CHANGELOG + VERSION are branch-scoped" rule. The new v0.24.0 entry at the top of CHANGELOG covers what THIS branch adds vs master: - routing-eval --llm honesty pass (4-surface contract drift fix) - skillpack installer cumulative-receipt + unknown-row preserve+warn (the Codex-caught regression that would have shipped in master if the original v0.19.0 had landed without this branch's review pass) - skillify scaffold resolver-row regex broadening (backtick + quoted + bare forms; idempotency contract preserved under hand-editing) - 5 banned-name leaks scrubbed from public artifacts - check-privacy.sh wired into CI test chain + regression guard test - 7 stale v0.17/v0.18 version labels rewritten across 5 files - Tier 2 (LLM-skills E2E) promoted from schedule-only to required per-PR VERSION 0.21.0 → 0.24.0 package.json version field synced. llms.txt + llms-full.txt regenerated (no content drift; sizes match). Test suite: 62/62 green across the 5 test files this branch added or extended (routing-eval-cli, privacy-script-wired, skillpack-install, skillify-scaffold, build-llms). CI guards: privacy + jsonb + progress + wasm + typecheck all clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: update project documentation for v0.24.0 Auto-discovered drift via /document-release after the v0.24.0 hardening pass landed. All factual corrections clearly warranted by the diff. CLAUDE.md: - Skillpack installer: documented the cumulative-slugs receipt comment, install --all prune semantics, unknown-row preserve+warn behavior, and pre-v0.24 silent upgrade. Was previously vague about "tracks a skill manifest so install --update diffs cleanly" without explaining what the receipt is or why it matters. - routing-eval: replaced the false claim that --llm "opts into a Haiku tie-break layer for CI." Now correctly describes the placeholder semantic landed in v0.24.0 (stderr notice + structural-only run). README.md: - Skillpack section: added one paragraph on the receipt comment + the user-visible stderr message for hand-added rows. Connects the safe rerun promise to the v0.24.0 implementation that actually enforces it. CONTRIBUTING.md: - Running tests section: now recommends `bun run test` (full CI guard chain + typecheck + tests) before pushing. Names each guard so new contributors understand what catches what. The privacy guard (newly wired in v0.24.0) is one of these — without `bun run test` you'd skip it locally and find out from CI. llms-full.txt: regenerated to reflect CLAUDE.md changes. Verification: full guard chain green locally (privacy + jsonb + progress + wasm + typecheck). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garry@ycombinator.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
228 lines
8.5 KiB
TypeScript
228 lines
8.5 KiB
TypeScript
/**
|
|
* Engine Parity E2E
|
|
*
|
|
* Codex flagged that searchKeyword behavior differs structurally between
|
|
* the two engines (Postgres uses a CTE that ranks pages then picks best
|
|
* chunk; PGLite returns chunks directly). Without verification, source-aware
|
|
* ranking could pass on PGLite and silently fail on Postgres.
|
|
*
|
|
* Strategy: seed identical corpora into both engines, run identical queries,
|
|
* assert top-5 slug ordering matches.
|
|
*
|
|
* Gated by DATABASE_URL — skips gracefully if no real Postgres. Always runs
|
|
* the PGLite half so the seed/query path is at least exercised.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import type { ChunkInput, SearchResult } from '../../src/core/types.ts';
|
|
import type { BrainEngine } from '../../src/core/engine.ts';
|
|
import { hasDatabase, setupDB, teardownDB, getEngine } from './helpers.ts';
|
|
|
|
const SKIP_PG = !hasDatabase();
|
|
const describeBoth = SKIP_PG ? describe.skip : describe;
|
|
|
|
function basisEmbedding(idx: number, dim = 1536): Float32Array {
|
|
const emb = new Float32Array(dim);
|
|
emb[idx % dim] = 1.0;
|
|
return emb;
|
|
}
|
|
|
|
interface SeedPage {
|
|
slug: string;
|
|
type: 'writing' | 'concept' | 'note' | 'person' | 'company';
|
|
title: string;
|
|
body: string;
|
|
embeddingDim: number;
|
|
}
|
|
|
|
const SEED_PAGES: SeedPage[] = [
|
|
{
|
|
slug: 'originals/talks/article-outline-fat-code',
|
|
type: 'writing',
|
|
title: 'Fat Code Thin Harness — Part 3',
|
|
body: 'fat code thin harness pattern part 3 production case studies',
|
|
embeddingDim: 7,
|
|
},
|
|
{
|
|
slug: 'concepts/fat-code-thin-harness',
|
|
type: 'concept',
|
|
title: 'Fat Code Thin Harness',
|
|
body: 'reusable concept fat code thin harness architecture',
|
|
embeddingDim: 14,
|
|
},
|
|
{
|
|
slug: 'openclaw/chat/2026-04-15',
|
|
type: 'note',
|
|
title: '2026-04-15 chat',
|
|
body:
|
|
'fat code thin harness fat code thin harness discussion went on at length, ' +
|
|
'fat code thin harness came up again and again, fat code thin harness fat code thin harness.',
|
|
embeddingDim: 8,
|
|
},
|
|
{
|
|
slug: 'openclaw/chat/2026-04-16',
|
|
type: 'note',
|
|
title: '2026-04-16 chat',
|
|
body:
|
|
'fat code thin harness once more, fat code thin harness fat code thin harness, ' +
|
|
'still talking about fat code thin harness fat code thin harness.',
|
|
embeddingDim: 9,
|
|
},
|
|
{
|
|
slug: 'people/example-founder',
|
|
type: 'person',
|
|
title: 'Example Founder',
|
|
body: 'example founder unrelated content for distraction',
|
|
embeddingDim: 50,
|
|
},
|
|
];
|
|
|
|
async function seedEngine(eng: BrainEngine) {
|
|
for (const p of SEED_PAGES) {
|
|
await eng.putPage(p.slug, {
|
|
type: p.type,
|
|
title: p.title,
|
|
compiled_truth: p.body,
|
|
timeline: '',
|
|
});
|
|
const chunks: ChunkInput[] = [
|
|
{
|
|
chunk_index: 0,
|
|
chunk_text: p.body,
|
|
chunk_source: 'compiled_truth',
|
|
embedding: basisEmbedding(p.embeddingDim),
|
|
token_count: p.body.split(/\s+/).length,
|
|
},
|
|
];
|
|
await eng.upsertChunks(p.slug, chunks);
|
|
}
|
|
}
|
|
|
|
const QUERIES = [
|
|
'fat code thin harness',
|
|
'fat code thin harness part 3',
|
|
'fat code production',
|
|
];
|
|
|
|
describeBoth('Engine parity — Postgres vs PGLite', () => {
|
|
let pgEngine: BrainEngine;
|
|
let pgliteEngine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
pgEngine = await setupDB();
|
|
await seedEngine(pgEngine);
|
|
|
|
pgliteEngine = new PGLiteEngine();
|
|
await pgliteEngine.connect({});
|
|
await pgliteEngine.initSchema();
|
|
await seedEngine(pgliteEngine);
|
|
}, 90_000);
|
|
|
|
afterAll(async () => {
|
|
await pgliteEngine.disconnect();
|
|
await teardownDB();
|
|
});
|
|
|
|
for (const q of QUERIES) {
|
|
test(`searchKeyword: top-5 slugs match for "${q}"`, async () => {
|
|
const pgResults = await pgEngine.searchKeyword(q, { limit: 5 });
|
|
const pgliteResults = await pgliteEngine.searchKeyword(q, { limit: 5 });
|
|
|
|
const pgSlugs = pgResults.map((r: SearchResult) => r.slug);
|
|
const pgliteSlugs = pgliteResults.map((r: SearchResult) => r.slug);
|
|
|
|
// Top result MUST match (the swamp-resistance guarantee).
|
|
expect(pgSlugs[0]).toBe(pgliteSlugs[0]);
|
|
// Sets should match (allowing some ordering drift on lower-ranked
|
|
// results since FTS rank function differences between engines are
|
|
// out of scope for this fix).
|
|
expect(new Set(pgSlugs)).toEqual(new Set(pgliteSlugs));
|
|
});
|
|
}
|
|
|
|
test('searchVector: top result matches between engines', async () => {
|
|
const queryVec = basisEmbedding(7); // article direction
|
|
const pgResults = await pgEngine.searchVector(queryVec, { limit: 5 });
|
|
const pgliteResults = await pgliteEngine.searchVector(queryVec, { limit: 5 });
|
|
|
|
expect(pgResults[0]?.slug).toBe(pgliteResults[0]?.slug);
|
|
});
|
|
|
|
test('hard-exclude is consistent across engines', async () => {
|
|
// Both engines should hide test/ pages by default; both should opt
|
|
// them back in via include_slug_prefixes.
|
|
await pgEngine.putPage('test/parity-fixture', {
|
|
type: 'note',
|
|
title: 'parity test fixture',
|
|
compiled_truth: 'parity test fixture content',
|
|
timeline: '',
|
|
});
|
|
await pgEngine.upsertChunks('test/parity-fixture', [{
|
|
chunk_index: 0,
|
|
chunk_text: 'parity test fixture content',
|
|
chunk_source: 'compiled_truth',
|
|
embedding: basisEmbedding(20),
|
|
token_count: 5,
|
|
}] satisfies ChunkInput[]);
|
|
|
|
await pgliteEngine.putPage('test/parity-fixture', {
|
|
type: 'note',
|
|
title: 'parity test fixture',
|
|
compiled_truth: 'parity test fixture content',
|
|
timeline: '',
|
|
});
|
|
await pgliteEngine.upsertChunks('test/parity-fixture', [{
|
|
chunk_index: 0,
|
|
chunk_text: 'parity test fixture content',
|
|
chunk_source: 'compiled_truth',
|
|
embedding: basisEmbedding(20),
|
|
token_count: 5,
|
|
}] satisfies ChunkInput[]);
|
|
|
|
const pgDefault = await pgEngine.searchKeyword('parity test fixture');
|
|
const pgliteDefault = await pgliteEngine.searchKeyword('parity test fixture');
|
|
expect(pgDefault.map((r: SearchResult) => r.slug)).not.toContain('test/parity-fixture');
|
|
expect(pgliteDefault.map((r: SearchResult) => r.slug)).not.toContain('test/parity-fixture');
|
|
|
|
const pgOptIn = await pgEngine.searchKeyword('parity test fixture', {
|
|
include_slug_prefixes: ['test/'],
|
|
});
|
|
const pgliteOptIn = await pgliteEngine.searchKeyword('parity test fixture', {
|
|
include_slug_prefixes: ['test/'],
|
|
});
|
|
expect(pgOptIn.map((r: SearchResult) => r.slug)).toContain('test/parity-fixture');
|
|
expect(pgliteOptIn.map((r: SearchResult) => r.slug)).toContain('test/parity-fixture');
|
|
});
|
|
|
|
test('detail=high produces a different ranking than default on at least one engine', async () => {
|
|
// Source-boost gates on `detail !== 'high'`. If the gate works on both
|
|
// engines, the ordering for `detail=high` should differ from default in
|
|
// any case where the swamp / curated pages have different raw scores.
|
|
//
|
|
// Postgres's CTE ranks pages then picks best chunk; ts_rank normalizes
|
|
// by doc length so chat pages don't always swamp at the page level.
|
|
// PGLite scores chunks directly — chat chunks beat article chunks on
|
|
// raw ts_rank. The two engines need different parity contracts here.
|
|
//
|
|
// Common assertion that holds on both: detail=high must include the
|
|
// chat pages in its result set (they're not filtered by detail), and
|
|
// the result set should not be identical to default-detail (the boost
|
|
// must be doing _something_ visible).
|
|
const pgDefault = await pgEngine.searchKeyword('fat code thin harness', { limit: 5 });
|
|
const pgHigh = await pgEngine.searchKeyword('fat code thin harness', { detail: 'high', limit: 5 });
|
|
const pgliteDefault = await pgliteEngine.searchKeyword('fat code thin harness', { limit: 5 });
|
|
const pgliteHigh = await pgliteEngine.searchKeyword('fat code thin harness', { detail: 'high', limit: 5 });
|
|
|
|
// Chat pages must be present in detail=high results on both engines.
|
|
expect(pgHigh.some((r: SearchResult) => r.slug.startsWith('openclaw/chat/'))).toBe(true);
|
|
expect(pgliteHigh.some((r: SearchResult) => r.slug.startsWith('openclaw/chat/'))).toBe(true);
|
|
|
|
// The boost must be doing something — at least one engine's ordering
|
|
// should change between default and detail=high.
|
|
const pgChanged = pgDefault.map((r: SearchResult) => r.slug).join(',') !== pgHigh.map((r: SearchResult) => r.slug).join(',');
|
|
const pgliteChanged = pgliteDefault.map((r: SearchResult) => r.slug).join(',') !== pgliteHigh.map((r: SearchResult) => r.slug).join(',');
|
|
expect(pgChanged || pgliteChanged).toBe(true);
|
|
});
|
|
});
|