mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 17:02:19 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60fb33c0d9 | ||
|
|
11ed0871c2 | ||
|
|
595eeb7d6f |
@@ -71,8 +71,8 @@ GBrain is designed to be installed and operated by an AI agent. The fastest path
|
||||
|
||||
If you don't already have an AI agent platform running, start with one of these. Both are designed to read GBrain's install protocol and execute it:
|
||||
|
||||
- **[OpenClaw](https://github.com/openclaw/openclaw)** — deploy [AlphaClaw on Render](https://render.com/deploy?repo=https://github.com/chrysb/alphaclaw) (one click, 8GB+ RAM)
|
||||
- **[Hermes](https://github.com/NousResearch/hermes-agent)** — deploy on [Railway](https://github.com/praveen-ks-2001/hermes-agent-template) (one click)
|
||||
- **[OpenClaw](https://github.com/openclawagents/openclaw)** — deploy [AlphaClaw on Render](https://render.com/deploy?repo=https://github.com/chrysb/alphaclaw) (one click, 8GB+ RAM)
|
||||
- **[Hermes](https://github.com/openclawagents/hermes)** — deploy on [Railway](https://github.com/praveen-ks-2001/hermes-agent-template) (one click)
|
||||
|
||||
Then paste this into your agent:
|
||||
|
||||
|
||||
@@ -131,9 +131,7 @@ into gbrain so other clients can scaffold it. Default behavior:
|
||||
`~/.gbrain/harvest-private-patterns.txt` plus built-in defaults
|
||||
(canonical private fork name, common email regex, Slack channel pattern). Any
|
||||
match → rollback (delete the harvested files) and exit non-zero.
|
||||
- `openclaw.plugin.json` updated with the new slug, sorted. Harvest must preserve
|
||||
the top-level OpenClaw-native plugin fields (`id`, `configSchema`, `contracts`)
|
||||
because OpenClaw validates those before it can install the package.
|
||||
- `openclaw.plugin.json` updated with the new slug, sorted.
|
||||
- `--no-lint` bypasses the linter (after a manual editorial scrub).
|
||||
|
||||
Use the `skillpack-harvest` skill (its companion editorial workflow)
|
||||
|
||||
@@ -233,14 +233,13 @@ keep it or `git checkout` to throw it away. Nothing is committed for you.
|
||||
|
||||
**For a skill that ships with gbrain** (anything under the gbrain repo's own
|
||||
`skills/`): SkillOpt refuses to overwrite it by default and writes the winner to
|
||||
`skills/<name>/skillopt/proposed.md` instead (while keeping `best.md` as the
|
||||
optimizer's current-best pointer), so an optimization pass can never silently
|
||||
mutate a skill other people depend on. Two ways to handle that:
|
||||
`skills/<name>/skillopt/best.md` instead, so an optimization pass can never
|
||||
silently mutate a skill other people depend on. Two ways to handle that:
|
||||
|
||||
```bash
|
||||
# See the proposed improvement without touching SKILL.md (works for ANY skill):
|
||||
gbrain skillopt meeting-prep --split 1:1:1 --no-mutate
|
||||
# → writes skills/meeting-prep/skillopt/proposed.md, updates best.md, and prints the proposal path.
|
||||
# → writes skills/meeting-prep/skillopt/best.md (the proposed rewrite), prints its path. Copy what you want.
|
||||
|
||||
# Actually rewrite a bundled skill (explicit opt-in + an independent held-out set):
|
||||
gbrain skillopt brain-ops --split 1:1:1 --allow-mutate-bundled \
|
||||
|
||||
+2
-2
@@ -1565,8 +1565,8 @@ GBrain is designed to be installed and operated by an AI agent. The fastest path
|
||||
|
||||
If you don't already have an AI agent platform running, start with one of these. Both are designed to read GBrain's install protocol and execute it:
|
||||
|
||||
- **[OpenClaw](https://github.com/openclaw/openclaw)** — deploy [AlphaClaw on Render](https://render.com/deploy?repo=https://github.com/chrysb/alphaclaw) (one click, 8GB+ RAM)
|
||||
- **[Hermes](https://github.com/NousResearch/hermes-agent)** — deploy on [Railway](https://github.com/praveen-ks-2001/hermes-agent-template) (one click)
|
||||
- **[OpenClaw](https://github.com/openclawagents/openclaw)** — deploy [AlphaClaw on Render](https://render.com/deploy?repo=https://github.com/chrysb/alphaclaw) (one click, 8GB+ RAM)
|
||||
- **[Hermes](https://github.com/openclawagents/hermes)** — deploy on [Railway](https://github.com/praveen-ks-2001/hermes-agent-template) (one click)
|
||||
|
||||
Then paste this into your agent:
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"id": "gbrain-context-engine",
|
||||
"name": "gbrain",
|
||||
"version": "0.32.3.0",
|
||||
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
|
||||
|
||||
@@ -266,5 +266,4 @@ editorial pass.
|
||||
(e.g. `src/commands/<slug>.ts` if the host SKILL.md declares it
|
||||
in frontmatter)
|
||||
- gbrain's `openclaw.plugin.json` — adds the slug to `skills:`
|
||||
array, sorted alphabetically, without removing OpenClaw-native plugin fields
|
||||
like `id`, `configSchema`, or `contracts`
|
||||
array, sorted alphabetically
|
||||
|
||||
@@ -57,8 +57,6 @@ This mode guarantees:
|
||||
- `skills/manifest.json` lists every skill directory
|
||||
- `skills/RESOLVER.md` references every skill in the manifest
|
||||
- `openclaw.plugin.json` `skills[]` round-trips with both
|
||||
- `openclaw.plugin.json` keeps OpenClaw install-required native plugin fields
|
||||
(`id`, object `configSchema`, and `contracts.contextEngines` when applicable)
|
||||
- No MECE violations (duplicate triggers across skills)
|
||||
|
||||
### Phases
|
||||
@@ -74,7 +72,7 @@ This mode guarantees:
|
||||
### Automation
|
||||
|
||||
```bash
|
||||
bun test test/skills-conformance.test.ts test/resolver.test.ts test/openclaw-plugin-manifest.test.ts
|
||||
bun test test/skills-conformance.test.ts test/resolver.test.ts
|
||||
```
|
||||
|
||||
The CI-gated check is the package.json `test` script.
|
||||
|
||||
+1
-50
@@ -54,7 +54,7 @@ export function bigintToStringReplacer(_key: string, value: unknown): unknown {
|
||||
}
|
||||
|
||||
// CLI-only commands that bypass the operation layer
|
||||
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'bench', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'maintain', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
|
||||
export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'enrich', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'reconcile-links', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'calibration', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt', 'quarantine', 'self-upgrade', 'advisor', 'watch', 'reindex-search-vector']);
|
||||
// CLI-only commands whose handlers print their own --help text. These are
|
||||
// excluded from the generic short-circuit so detailed per-command and
|
||||
// per-subcommand usage stays reachable.
|
||||
@@ -78,8 +78,6 @@ const CLI_ONLY_SELF_HELP = new Set([
|
||||
'capture',
|
||||
// v0.42 self-upgrade ships its own usage (flags + the agent-skill story).
|
||||
'self-upgrade',
|
||||
// maintain (#3015) prints its own usage block (modes + not-auto-applied list).
|
||||
'maintain',
|
||||
// v0.43 (#2095): watch ships WATCH_HELP (flags + the stdin-turn protocol).
|
||||
'watch',
|
||||
// v0.37 fix wave (Lane D.4 + CDX2-12): sync's --no-embed flag was
|
||||
@@ -106,9 +104,6 @@ const CLI_ONLY_SELF_HELP = new Set([
|
||||
// `gbrain connect --help` prints its own usage (flags + examples) from
|
||||
// runConnect; route around the generic one-line short-circuit.
|
||||
'connect',
|
||||
// #1474: bench-publish ships its own detailed HELP (flags, exit codes,
|
||||
// the export → publish → gate loop). Route around the generic stub.
|
||||
'bench',
|
||||
]);
|
||||
|
||||
// v114 (#1941): alias -> operation lookup, kept separate from `cliOps` so
|
||||
@@ -1432,29 +1427,6 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// #1474: `gbrain bench publish` is pure file I/O (reads a captured
|
||||
// eval-candidates NDJSON from `gbrain eval export`, writes a baseline
|
||||
// NDJSON). No DB access; bypass connectEngine entirely so the documented
|
||||
// export → publish → gate loop works on machines without a brain.
|
||||
// The v0.41.1 wave shipped bench-publish.ts + docs/eval-bench.md but this
|
||||
// dispatcher case was never added, so the command hit 'Unknown command'.
|
||||
if (command === 'bench') {
|
||||
if (args[0] === 'publish') {
|
||||
const { runBenchPublish } = await import('./commands/bench-publish.ts');
|
||||
await runBenchPublish(args.slice(1));
|
||||
return;
|
||||
}
|
||||
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
||||
const { runBenchPublish } = await import('./commands/bench-publish.ts');
|
||||
await runBenchPublish(['--help']);
|
||||
return;
|
||||
}
|
||||
console.error(`Unknown bench subcommand: ${args[0]}`);
|
||||
console.error('Usage: gbrain bench publish --from <captured.ndjson> --to <baseline.ndjson> [flags]');
|
||||
console.error(' See docs/eval-bench.md for the full loop: eval export → bench publish → eval gate');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// v0.42.x (#2390): `gbrain eval chronicle` is deterministic — brings its own
|
||||
// in-memory PGLite, no DB/gateway. CI fixture gate runs anywhere.
|
||||
if (command === 'eval' && args[0] === 'chronicle') {
|
||||
@@ -1785,11 +1757,6 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
await runOrphans(engine, args);
|
||||
break;
|
||||
}
|
||||
case 'maintain': {
|
||||
const { runMaintain } = await import('./commands/maintain.ts');
|
||||
await runMaintain(engine, args);
|
||||
break;
|
||||
}
|
||||
// v0.32.7 CJK wave — post-upgrade markdown re-chunk sweep.
|
||||
// v0.36 Phase 3 wave — `gbrain reindex --multimodal` re-embeds content_chunks
|
||||
// into the unified Voyage multimodal-3 column.
|
||||
@@ -2250,22 +2217,6 @@ async function connectEngine(opts?: { probeOnly?: boolean }): Promise<BrainEngin
|
||||
if (merged.embedding_image_ocr_model !== undefined) {
|
||||
process.env.GBRAIN_EMBEDDING_IMAGE_OCR_MODEL = merged.embedding_image_ocr_model;
|
||||
}
|
||||
// #1475: stash the merged eval.* flags the same way. The capture gate
|
||||
// (isEvalCaptureEnabled / isEvalScrubEnabled) runs against ctx.config,
|
||||
// which is built from the sync file-plane loadConfig() in both the CLI
|
||||
// op path and MCP dispatch — it never sees the DB plane directly. The
|
||||
// gates consult this stash when the file plane is silent, so
|
||||
// `gbrain config set eval.capture true` actually turns capture on.
|
||||
// A pre-set env value wins over the DB plane (env-above-config, the
|
||||
// incident escape hatch) — unlike GBRAIN_EMBEDDING_MULTIMODAL these
|
||||
// keys have no loadConfig() env mapping, so without this guard the
|
||||
// DB stash would silently clobber an operator's export.
|
||||
if (process.env.GBRAIN_EVAL_CAPTURE === undefined && merged.eval?.capture !== undefined) {
|
||||
process.env.GBRAIN_EVAL_CAPTURE = String(merged.eval.capture);
|
||||
}
|
||||
if (process.env.GBRAIN_EVAL_SCRUB_PII === undefined && merged.eval?.scrub_pii !== undefined) {
|
||||
process.env.GBRAIN_EVAL_SCRUB_PII = String(merged.eval.scrub_pii);
|
||||
}
|
||||
// Always re-configure with merged values when DB merge succeeded. The
|
||||
// trigger used to be field-name-gated (only when embedding_multimodal_model
|
||||
// was set); that coupled the gate to the field set and would silently
|
||||
|
||||
+4
-34
@@ -581,7 +581,7 @@ async function embedPage(
|
||||
for (let j = 0; j < toEmbed.length; j++) {
|
||||
embeddingMap.set(toEmbed[j].chunk_index, embeddings[j]);
|
||||
}
|
||||
const updated: ChunkInput[] = chunks.map(c => preserveCodeMetadata(c, {
|
||||
const updated: ChunkInput[] = chunks.map(c => ({
|
||||
chunk_index: c.chunk_index,
|
||||
chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source,
|
||||
@@ -605,31 +605,6 @@ async function embedPage(
|
||||
slog(`${slug}: embedded ${toEmbed.length} chunks`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry code-chunk metadata (language, symbol_name, symbol_type, line range,
|
||||
* parent scope, doc comment, qualified name) from a loaded Chunk back into a
|
||||
* ChunkInput destined for upsertChunks.
|
||||
*
|
||||
* Issue #769: every re-embed used to strip these fields, and upsertChunks
|
||||
* overwrites (does not COALESCE) the metadata columns from EXCLUDED, so
|
||||
* each pass clobbered code-def's primary index to NULL. Pulling the
|
||||
* preservation into one helper keeps the three re-embed call sites
|
||||
* (embedPage, embedAll non-stale, embedAllStale) in lock-step.
|
||||
*/
|
||||
function preserveCodeMetadata(loaded: any, base: ChunkInput): ChunkInput {
|
||||
return {
|
||||
...base,
|
||||
language: loaded.language ?? undefined,
|
||||
symbol_name: loaded.symbol_name ?? undefined,
|
||||
symbol_type: loaded.symbol_type ?? undefined,
|
||||
start_line: loaded.start_line ?? undefined,
|
||||
end_line: loaded.end_line ?? undefined,
|
||||
parent_symbol_path: loaded.parent_symbol_path ?? undefined,
|
||||
doc_comment: loaded.doc_comment ?? undefined,
|
||||
symbol_name_qualified: loaded.symbol_name_qualified ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function embedAll(
|
||||
engine: BrainEngine,
|
||||
staleOnly: boolean,
|
||||
@@ -742,10 +717,8 @@ async function embedAll(
|
||||
for (let j = 0; j < toEmbed.length; j++) {
|
||||
embeddingMap.set(toEmbed[j].chunk_index, embeddings[j]);
|
||||
}
|
||||
// Preserve ALL chunks, only update embeddings for stale ones.
|
||||
// preserveCodeMetadata threads code-chunk metadata (#769) so re-embed
|
||||
// doesn't clobber language/symbol_name/symbol_type to NULL.
|
||||
const updated: ChunkInput[] = chunks.map(c => preserveCodeMetadata(c, {
|
||||
// Preserve ALL chunks, only update embeddings for stale ones
|
||||
const updated: ChunkInput[] = chunks.map(c => ({
|
||||
chunk_index: c.chunk_index,
|
||||
chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source,
|
||||
@@ -1039,10 +1012,7 @@ async function embedAllStale(
|
||||
for (let j = 0; j < stale.length; j++) {
|
||||
staleIdxToEmbedding.set(stale[j].chunk_index, embeddings[j]);
|
||||
}
|
||||
// preserveCodeMetadata threads code-chunk metadata (#769) so the
|
||||
// autopilot --stale path doesn't clobber language/symbol_name/etc
|
||||
// to NULL on every cycle.
|
||||
const merged: ChunkInput[] = existing.map(c => preserveCodeMetadata(c, {
|
||||
const merged: ChunkInput[] = existing.map(c => ({
|
||||
chunk_index: c.chunk_index,
|
||||
chunk_text: c.chunk_text,
|
||||
chunk_source: c.chunk_source,
|
||||
|
||||
@@ -1651,7 +1651,7 @@ async function extractTimelineFromDB(
|
||||
* make re-extraction idempotent). EVERY processed page is stamped, including
|
||||
* zero-link pages — they WERE processed.
|
||||
*/
|
||||
export async function extractStaleFromDB(
|
||||
async function extractStaleFromDB(
|
||||
engine: BrainEngine,
|
||||
opts: {
|
||||
dryRun: boolean;
|
||||
|
||||
+10
-5
@@ -170,10 +170,14 @@ export async function runImport(
|
||||
// v0.22.13 (PR #490 Q2): shared parseWorkers helper rejects bad input
|
||||
// (--workers 0, -3, "foo") with a loud error instead of silently falling
|
||||
// through to 1. Mirrors sync.ts's flag handling.
|
||||
const { parseWorkers } = await import('../core/sync-concurrency.ts');
|
||||
let workerCount: number;
|
||||
const { parseWorkers, autoConcurrency } = await import('../core/sync-concurrency.ts');
|
||||
// #1207: undefined (no --workers flag) defers to autoConcurrency below —
|
||||
// the shared sync/import policy (PGLite → 1, >100 files → 4) — instead of
|
||||
// hardcoding serial. Large Postgres imports stop paying one embedding
|
||||
// round-trip per file in sequence.
|
||||
let workerCount: number | undefined;
|
||||
try {
|
||||
workerCount = parseWorkers(workersArg ?? undefined) ?? 1;
|
||||
workerCount = parseWorkers(workersArg ?? undefined);
|
||||
} catch (e) {
|
||||
console.error(e instanceof Error ? e.message : String(e));
|
||||
process.exit(1);
|
||||
@@ -252,8 +256,9 @@ export async function runImport(
|
||||
}
|
||||
const files = resumeFilter(allFiles, dir, completed);
|
||||
|
||||
// Determine actual worker count
|
||||
const actualWorkers = workerCount > 1 ? workerCount : 1;
|
||||
// Determine actual worker count. Explicit --workers wins; otherwise the
|
||||
// shared autoConcurrency policy decides from engine kind + file count.
|
||||
const actualWorkers = autoConcurrency(engine, files.length, workerCount);
|
||||
if (actualWorkers > 1) {
|
||||
console.log(`Using ${actualWorkers} parallel workers`);
|
||||
}
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
/**
|
||||
* gbrain maintain — conservative self-healing maintenance.
|
||||
*
|
||||
* This command automates the safe parts of the operator runbook:
|
||||
* - stale link/timeline extraction
|
||||
* - stale per-source dream cycles when doctor reports cycle_freshness
|
||||
*
|
||||
* It deliberately does NOT mutate source files, apply schema-pack upgrades, or
|
||||
* invent semantic hub links. Those need review or a separate command with an
|
||||
* auditable proposal surface.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import type { BrainHealth } from '../core/types.ts';
|
||||
import { buildChecks, computeDoctorReport, type DoctorReport, type Check } from './doctor.ts';
|
||||
import { extractStaleFromDB } from './extract.ts';
|
||||
import { runCycle, type CycleReport } from '../core/cycle.ts';
|
||||
|
||||
type ActionStatus = 'ok' | 'would_apply' | 'applied' | 'blocked' | 'skipped';
|
||||
|
||||
export interface MaintenanceAction {
|
||||
name: string;
|
||||
status: ActionStatus;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MaintainOptions {
|
||||
json: boolean;
|
||||
safe: boolean;
|
||||
dryRun: boolean;
|
||||
help: boolean;
|
||||
}
|
||||
|
||||
export interface MaintainReport {
|
||||
mode: 'dry-run' | 'safe';
|
||||
before: {
|
||||
health: BrainHealth;
|
||||
doctor: DoctorReport;
|
||||
};
|
||||
actions: MaintenanceAction[];
|
||||
after: {
|
||||
health: BrainHealth;
|
||||
doctor: DoctorReport;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMaintainArgs(args: string[]): MaintainOptions {
|
||||
const safe = args.includes('--safe');
|
||||
return {
|
||||
json: args.includes('--json'),
|
||||
safe,
|
||||
dryRun: args.includes('--dry-run') || !safe,
|
||||
help: args.includes('--help') || args.includes('-h'),
|
||||
};
|
||||
}
|
||||
|
||||
export function extractCycleFreshnessSourceIds(checks: Check[]): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const check of checks) {
|
||||
if (check.name !== 'cycle_freshness' || check.status === 'ok') continue;
|
||||
const re = /Source '([^']+)' last cycled/g;
|
||||
for (const match of check.message.matchAll(re)) {
|
||||
const id = match[1]?.trim();
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
}
|
||||
return [...ids].sort();
|
||||
}
|
||||
|
||||
async function buildDoctorReport(engine: BrainEngine): Promise<DoctorReport> {
|
||||
const checks = await buildChecks(engine, ['--json', '--scope=brain']);
|
||||
return computeDoctorReport(checks);
|
||||
}
|
||||
|
||||
async function runStaleExtraction(
|
||||
engine: BrainEngine,
|
||||
beforeHealth: BrainHealth,
|
||||
dryRun: boolean,
|
||||
): Promise<MaintenanceAction> {
|
||||
if (beforeHealth.stale_pages <= 0) {
|
||||
return { name: 'extract_stale', status: 'ok', message: 'No stale pages.' };
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
name: 'extract_stale',
|
||||
status: 'would_apply',
|
||||
message: `Would run DB-backed stale extraction for ${beforeHealth.stale_pages} page(s).`,
|
||||
details: { stale_pages: beforeHealth.stale_pages },
|
||||
};
|
||||
}
|
||||
|
||||
const result = await extractStaleFromDB(engine, {
|
||||
dryRun: false,
|
||||
jsonMode: false,
|
||||
includeFrontmatter: false,
|
||||
catchUp: false,
|
||||
});
|
||||
|
||||
return {
|
||||
name: 'extract_stale',
|
||||
status: 'applied',
|
||||
message: `Processed ${result.pagesProcessed} stale page(s); ${result.staleRemaining} remain.`,
|
||||
details: {
|
||||
links_created: result.linksCreated,
|
||||
timeline_created: result.timelineCreated,
|
||||
pages_processed: result.pagesProcessed,
|
||||
stale_remaining: result.staleRemaining,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runCycleFreshnessMaintenance(
|
||||
engine: BrainEngine,
|
||||
beforeDoctor: DoctorReport,
|
||||
dryRun: boolean,
|
||||
): Promise<MaintenanceAction[]> {
|
||||
const sourceIds = extractCycleFreshnessSourceIds(beforeDoctor.checks);
|
||||
if (sourceIds.length === 0) {
|
||||
return [{ name: 'cycle_freshness', status: 'ok', message: 'All sources cycled recently.' }];
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return sourceIds.map((sourceId) => ({
|
||||
name: 'cycle_freshness',
|
||||
status: 'would_apply',
|
||||
message: `Would run source-scoped dream cycle for ${sourceId}.`,
|
||||
details: { source_id: sourceId },
|
||||
}));
|
||||
}
|
||||
|
||||
const sources = await engine.listAllSources();
|
||||
const actions: MaintenanceAction[] = [];
|
||||
|
||||
for (const sourceId of sourceIds) {
|
||||
const source = sources.find((s) => s.id === sourceId);
|
||||
const localPath = source?.local_path ?? null;
|
||||
const brainDir = localPath && existsSync(localPath) ? localPath : null;
|
||||
const report: CycleReport = await runCycle(engine, {
|
||||
brainDir,
|
||||
dryRun: false,
|
||||
pull: false,
|
||||
sourceId,
|
||||
});
|
||||
actions.push({
|
||||
name: 'cycle_freshness',
|
||||
status: report.status === 'failed' ? 'blocked' : 'applied',
|
||||
message: `Ran source-scoped dream cycle for ${sourceId}: ${report.status}.`,
|
||||
details: {
|
||||
source_id: sourceId,
|
||||
brain_dir: brainDir,
|
||||
cycle_status: report.status,
|
||||
phases: report.phases.map((p) => ({ phase: p.phase, status: p.status })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function runMaintain(engine: BrainEngine, args: string[]): Promise<MaintainReport | void> {
|
||||
const opts = parseMaintainArgs(args);
|
||||
if (opts.help) {
|
||||
console.log(`Usage: gbrain maintain [--safe] [--dry-run] [--json]
|
||||
|
||||
Conservative self-healing maintenance.
|
||||
|
||||
Modes:
|
||||
--dry-run Preview safe actions without writes. Default when --safe is absent.
|
||||
--safe Apply safe actions: stale extraction and source cycle freshness.
|
||||
--json Emit a structured before/action/after report.
|
||||
|
||||
Not auto-applied:
|
||||
source-file frontmatter fixes, schema-pack upgrades, atom-pack changes,
|
||||
semantic hub-link guesses, and destructive cleanup.
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeHealth = await engine.getHealth();
|
||||
const beforeDoctor = await buildDoctorReport(engine);
|
||||
const actions: MaintenanceAction[] = [];
|
||||
|
||||
actions.push(await runStaleExtraction(engine, beforeHealth, opts.dryRun));
|
||||
actions.push(...await runCycleFreshnessMaintenance(engine, beforeDoctor, opts.dryRun));
|
||||
|
||||
const afterHealth = await engine.getHealth();
|
||||
const afterDoctor = await buildDoctorReport(engine);
|
||||
const report: MaintainReport = {
|
||||
mode: opts.dryRun ? 'dry-run' : 'safe',
|
||||
before: { health: beforeHealth, doctor: beforeDoctor },
|
||||
actions,
|
||||
after: { health: afterHealth, doctor: afterDoctor },
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
printMaintainReport(report);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
function printMaintainReport(report: MaintainReport): void {
|
||||
console.log(`GBrain maintain (${report.mode})`);
|
||||
console.log(
|
||||
`Before: brain_score=${Math.round(report.before.health.brain_score)}/100 ` +
|
||||
`stale=${report.before.health.stale_pages} islands=${report.before.health.orphan_pages} ` +
|
||||
`doctor=${report.before.doctor.status}`,
|
||||
);
|
||||
for (const action of report.actions) {
|
||||
console.log(` ${action.status}: ${action.name} — ${action.message}`);
|
||||
}
|
||||
console.log(
|
||||
`After: brain_score=${Math.round(report.after.health.brain_score)}/100 ` +
|
||||
`stale=${report.after.health.stale_pages} islands=${report.after.health.orphan_pages} ` +
|
||||
`doctor=${report.after.doctor.status}`,
|
||||
);
|
||||
if (report.mode === 'dry-run') {
|
||||
console.log('Run `gbrain maintain --safe` to apply safe actions.');
|
||||
}
|
||||
}
|
||||
+55
-10
@@ -15,11 +15,6 @@
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { createProgress, startHeartbeat } from '../core/progress.ts';
|
||||
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
||||
import {
|
||||
shouldExcludeFromOrphanReporting,
|
||||
loadOrphanPolicyOverrides,
|
||||
type OrphanPolicyOverrides,
|
||||
} from '../core/orphan-policy.ts';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -37,14 +32,65 @@ export interface OrphanResult {
|
||||
excluded: number;
|
||||
}
|
||||
|
||||
// --- Filter constants ---
|
||||
|
||||
/** Slug suffixes that are always auto-generated root files */
|
||||
const AUTO_SUFFIX_PATTERNS = ['/_index', '/log'];
|
||||
|
||||
/** Page slugs that are pseudo-pages by convention */
|
||||
const PSEUDO_SLUGS = new Set(['_atlas', '_index', '_stats', '_orphans', '_scratch', 'claude']);
|
||||
|
||||
/** Slug segment that marks raw sources */
|
||||
const RAW_SEGMENT = '/raw/';
|
||||
|
||||
/** Slug prefixes where no inbound links is expected */
|
||||
const DENY_PREFIXES = [
|
||||
'output/',
|
||||
'dashboards/',
|
||||
'scripts/',
|
||||
'templates/',
|
||||
'openclaw/config/',
|
||||
];
|
||||
|
||||
/** First slug segments where no inbound links is expected */
|
||||
const FIRST_SEGMENT_EXCLUSIONS = new Set([
|
||||
'scratch',
|
||||
'thoughts',
|
||||
'catalog',
|
||||
'entities',
|
||||
'raw',
|
||||
'atoms',
|
||||
'skills',
|
||||
]);
|
||||
|
||||
// --- Filter logic ---
|
||||
|
||||
/**
|
||||
* Returns true if a slug should be excluded from orphan reporting by default.
|
||||
* These are pages where having no inbound links is expected / not a content problem.
|
||||
*/
|
||||
export function shouldExclude(slug: string, overrides?: OrphanPolicyOverrides): boolean {
|
||||
return shouldExcludeFromOrphanReporting(slug, overrides);
|
||||
export function shouldExclude(slug: string): boolean {
|
||||
// Pseudo-pages (exact match)
|
||||
if (PSEUDO_SLUGS.has(slug)) return true;
|
||||
|
||||
// Auto-generated suffix patterns
|
||||
for (const suffix of AUTO_SUFFIX_PATTERNS) {
|
||||
if (slug.endsWith(suffix)) return true;
|
||||
}
|
||||
|
||||
// Raw source slugs
|
||||
if (slug.includes(RAW_SEGMENT)) return true;
|
||||
|
||||
// Deny-prefix slugs
|
||||
for (const prefix of DENY_PREFIXES) {
|
||||
if (slug.startsWith(prefix)) return true;
|
||||
}
|
||||
|
||||
// First-segment exclusions
|
||||
const firstSegment = slug.split('/')[0];
|
||||
if (FIRST_SEGMENT_EXCLUSIONS.has(firstSegment)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +156,6 @@ export async function findOrphans(
|
||||
let allOrphans: { slug: string; title: string; domain: string | null }[];
|
||||
let total: number;
|
||||
let excludedAll: number;
|
||||
const overrides = includePseudo ? undefined : await loadOrphanPolicyOverrides(engine);
|
||||
try {
|
||||
allOrphans = await engine.findOrphanPages(
|
||||
sourceIds ? { sourceIds } : sourceId ? { sourceId } : undefined,
|
||||
@@ -139,7 +184,7 @@ export async function findOrphans(
|
||||
total = liveRows.length;
|
||||
excludedAll = includePseudo
|
||||
? 0
|
||||
: liveRows.reduce((n, r) => n + (shouldExclude(r.slug, overrides) ? 1 : 0), 0);
|
||||
: liveRows.reduce((n, r) => n + (shouldExclude(r.slug) ? 1 : 0), 0);
|
||||
} finally {
|
||||
stopHb();
|
||||
progress.finish();
|
||||
@@ -147,7 +192,7 @@ export async function findOrphans(
|
||||
|
||||
const filtered = includePseudo
|
||||
? allOrphans
|
||||
: allOrphans.filter(row => !shouldExclude(row.slug, overrides));
|
||||
: allOrphans.filter(row => !shouldExclude(row.slug));
|
||||
|
||||
const orphans: OrphanPage[] = filtered.map(row => ({
|
||||
slug: row.slug,
|
||||
|
||||
+24
-6
@@ -1513,12 +1513,21 @@ export async function embed(texts: string[], opts?: EmbedOpts): Promise<Float32A
|
||||
|
||||
const embedding = recipe.touchpoints?.embedding;
|
||||
const maxBatchTokens = embedding?.max_batch_tokens;
|
||||
const maxBatchCount = embedding?.max_batch_count;
|
||||
const charsPerToken = embedding?.chars_per_token ?? DEFAULT_CHARS_PER_TOKEN;
|
||||
|
||||
// Pre-split is gated on max_batch_tokens. Recipes without it (e.g. OpenAI)
|
||||
// ride the fast path: one embedMany call, no recursion safety net.
|
||||
const batches = maxBatchTokens
|
||||
? splitByTokenBudget(truncated, Math.floor(maxBatchTokens * effectiveSafetyFactor(recipe)), charsPerToken)
|
||||
// Pre-split is gated on max_batch_tokens / max_batch_count. Recipes with
|
||||
// neither (e.g. OpenAI) ride the fast path: one embedMany call, no
|
||||
// recursion safety net.
|
||||
const batches = (maxBatchTokens || maxBatchCount)
|
||||
? splitByTokenBudget(
|
||||
truncated,
|
||||
maxBatchTokens
|
||||
? Math.floor(maxBatchTokens * effectiveSafetyFactor(recipe))
|
||||
: Number.MAX_SAFE_INTEGER,
|
||||
charsPerToken,
|
||||
maxBatchCount,
|
||||
)
|
||||
: [truncated];
|
||||
|
||||
const allEmbeddings: Float32Array[] = [];
|
||||
@@ -1568,6 +1577,9 @@ export async function embed(texts: string[], opts?: EmbedOpts): Promise<Float32A
|
||||
* responsible for applying any safety-factor shrink before passing in.
|
||||
* @param charsPerToken - Provider-specific character density. Defaults to
|
||||
* `DEFAULT_CHARS_PER_TOKEN` (4) when omitted, matching OpenAI tiktoken.
|
||||
* @param maxBatchCount - #1199: optional cap on INPUTS per sub-batch, for
|
||||
* providers that reject batches by count (DashScope: 10). When omitted,
|
||||
* only the token budget governs.
|
||||
*
|
||||
* @internal exported for tests; not part of the public gateway API.
|
||||
*/
|
||||
@@ -1575,15 +1587,17 @@ export function splitByTokenBudget(
|
||||
texts: string[],
|
||||
budgetTokens: number,
|
||||
charsPerToken: number = DEFAULT_CHARS_PER_TOKEN,
|
||||
maxBatchCount?: number,
|
||||
): string[][] {
|
||||
const ratio = charsPerToken > 0 ? charsPerToken : DEFAULT_CHARS_PER_TOKEN;
|
||||
const maxCount = maxBatchCount !== undefined && maxBatchCount > 0 ? maxBatchCount : Infinity;
|
||||
const batches: string[][] = [];
|
||||
let current: string[] = [];
|
||||
let currentTokens = 0;
|
||||
|
||||
for (const text of texts) {
|
||||
const estTokens = Math.ceil(text.length / ratio);
|
||||
if (current.length > 0 && currentTokens + estTokens > budgetTokens) {
|
||||
if (current.length > 0 && (currentTokens + estTokens > budgetTokens || current.length >= maxCount)) {
|
||||
batches.push(current);
|
||||
current = [];
|
||||
currentTokens = 0;
|
||||
@@ -1609,7 +1623,11 @@ export function isTokenLimitError(err: unknown): boolean {
|
||||
/token.*limit.*exceeded/i.test(msg) ||
|
||||
// OpenAI embeddings: "Invalid 'input': maximum request size is 300000 tokens per request."
|
||||
/maximum request size.*tokens/i.test(msg) ||
|
||||
/max.*tokens.*per.*request/i.test(msg)
|
||||
/max.*tokens.*per.*request/i.test(msg) ||
|
||||
// DashScope: "batch size is invalid, it should not be larger than 10." (#1199)
|
||||
// Count-cap error, but recursive halving shrinks count too, so the same
|
||||
// safety net converges.
|
||||
/batch size is invalid/i.test(msg)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ export const dashscope: Recipe = {
|
||||
// path. Conservative declaration so the gateway pre-splits before
|
||||
// hitting whatever undocumented server-side limit exists.
|
||||
max_batch_tokens: 8192,
|
||||
// #1199: DashScope hard-caps embeddings at 10 inputs per request
|
||||
// ("batch size is invalid, it should not be larger than 10"). The
|
||||
// token budget alone admits far more than 10 short chunks per batch.
|
||||
max_batch_count: 10,
|
||||
// text-embedding-v3 mixes English + CJK heavily; the tokenizer is
|
||||
// closer to Voyage density than OpenAI tiktoken for CJK-dominant
|
||||
// content. Conservative chars_per_token=2 leaves headroom.
|
||||
|
||||
@@ -16,6 +16,15 @@ export const google: Recipe = {
|
||||
dims_options: [768, 1536, 3072],
|
||||
cost_per_1m_tokens_usd: 0.15,
|
||||
price_last_verified: '2026-04-20',
|
||||
// #970: Gemini's documented limits are per-INPUT (2048 tokens,
|
||||
// silently truncated beyond) and per-REQUEST count (batchEmbedContents
|
||||
// caps at 100 inputs). There is no separate per-request token cap, so
|
||||
// the token budget is derived: 100 inputs × 2048 tokens. The count cap
|
||||
// binds first for typical chunk sizes. Do NOT copy the 2048 per-input
|
||||
// limit into max_batch_tokens — that would over-split 50×.
|
||||
max_batch_tokens: 204_800,
|
||||
chars_per_token: 4,
|
||||
max_batch_count: 100,
|
||||
},
|
||||
expansion: {
|
||||
models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite'],
|
||||
|
||||
@@ -58,5 +58,8 @@ export function getRecipe(id: string): Recipe | undefined {
|
||||
}
|
||||
|
||||
export function listRecipes(): Recipe[] {
|
||||
return [...ALL];
|
||||
// Read the map (not ALL) so there is one source of truth — getRecipe,
|
||||
// model-resolver, and listRecipes all see the same registry, and tests
|
||||
// can inject a synthetic recipe via RECIPES to exercise registry walks.
|
||||
return [...RECIPES.values()];
|
||||
}
|
||||
|
||||
@@ -46,6 +46,16 @@ export interface EmbeddingTouchpoint {
|
||||
* Only consulted when `max_batch_tokens` is also set.
|
||||
*/
|
||||
chars_per_token?: number;
|
||||
/**
|
||||
* #1199: maximum number of INPUTS per embedding request, for providers
|
||||
* that hard-cap batch size by count rather than (or in addition to)
|
||||
* tokens — DashScope text-embedding-v3 rejects batches > 10 with
|
||||
* `InvalidParameter`, Gemini batchEmbedContents caps at 100 requests.
|
||||
* When set, the gateway's pre-split flushes a sub-batch at this count
|
||||
* even if the token budget still has room. Independent of
|
||||
* `max_batch_tokens`; either alone triggers the pre-split.
|
||||
*/
|
||||
max_batch_count?: number;
|
||||
/**
|
||||
* Budget-utilization ceiling in (0, 1]. The gateway pre-splits at
|
||||
* `safety_factor × max_batch_tokens` to leave headroom for tokenizer
|
||||
|
||||
@@ -816,25 +816,6 @@ export async function loadConfigWithEngine(
|
||||
merged.dream = mergedDream;
|
||||
}
|
||||
|
||||
// #1475: eval.* DB-plane merge. `gbrain config set eval.capture true`
|
||||
// writes the DB plane (both keys are in KNOWN_CONFIG_KEYS, so `set`
|
||||
// accepts them silently), but the capture gate (isEvalCaptureEnabled)
|
||||
// reads the merged config. Without this merge the DB value was written
|
||||
// and never read — capture only fired via GBRAIN_CONTRIBUTOR_MODE=1.
|
||||
// Sparse per-key merge: file/env wins per key, DB fills the gaps.
|
||||
const dbEvalCapture = await dbBool('eval.capture');
|
||||
const dbEvalScrub = await dbBool('eval.scrub_pii');
|
||||
const mergedEval: NonNullable<GBrainConfig['eval']> = { ...(merged.eval ?? {}) };
|
||||
if (mergedEval.capture === undefined && dbEvalCapture !== undefined) {
|
||||
mergedEval.capture = dbEvalCapture;
|
||||
}
|
||||
if (mergedEval.scrub_pii === undefined && dbEvalScrub !== undefined) {
|
||||
mergedEval.scrub_pii = dbEvalScrub;
|
||||
}
|
||||
if (Object.keys(mergedEval).length > 0) {
|
||||
merged.eval = mergedEval;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ import {
|
||||
import {
|
||||
generatePerChunkSynopsis,
|
||||
SYNOPSIS_PROMPT_VERSION,
|
||||
SYNOPSIS_DOC_MAX_CHARS,
|
||||
type GeneratePerChunkSynopsisResult,
|
||||
} from './page-summary.ts';
|
||||
import {
|
||||
@@ -104,17 +103,8 @@ function getEmbeddingModelTag(): string {
|
||||
export function computeCorpusGeneration(args: {
|
||||
crMode: CRMode;
|
||||
haikuModel: string;
|
||||
/**
|
||||
* Resolved `SYNOPSIS_DOC_MAX_CHARS` for per_chunk_synopsis runs. When
|
||||
* present, folded into the hash so changes to
|
||||
* `GBRAIN_SYNOPSIS_DOC_MAX_CHARS` invalidate the prior cache cleanly.
|
||||
* Omit for `crMode !== 'per_chunk_synopsis'` — title / none modes
|
||||
* don't consult the cap and the field stays out of the hash for
|
||||
* back-compat with pre-cap embeddings.
|
||||
*/
|
||||
synopsisDocMaxChars?: number;
|
||||
}): string {
|
||||
const h = createHash('sha256')
|
||||
return createHash('sha256')
|
||||
.update(args.crMode)
|
||||
.update('|')
|
||||
.update(String(SYNOPSIS_PROMPT_VERSION))
|
||||
@@ -123,11 +113,9 @@ export function computeCorpusGeneration(args: {
|
||||
.update('|')
|
||||
.update(String(TITLE_WRAPPER_VERSION))
|
||||
.update('|')
|
||||
.update(getEmbeddingModelTag());
|
||||
if (args.synopsisDocMaxChars !== undefined) {
|
||||
h.update('|doc_cap=').update(String(args.synopsisDocMaxChars));
|
||||
}
|
||||
return h.digest('hex').slice(0, 16);
|
||||
.update(getEmbeddingModelTag())
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -265,11 +253,7 @@ export async function reembedPageWithContextualRetrieval(
|
||||
args.pageSlug,
|
||||
args.sourceId,
|
||||
resolution.mode,
|
||||
computeCorpusGeneration({
|
||||
crMode: resolution.mode,
|
||||
haikuModel: args.haikuModel ?? DEFAULT_HAIKU_MODEL,
|
||||
synopsisDocMaxChars: resolution.mode === 'per_chunk_synopsis' ? SYNOPSIS_DOC_MAX_CHARS : undefined,
|
||||
}),
|
||||
computeCorpusGeneration({ crMode: resolution.mode, haikuModel: args.haikuModel ?? DEFAULT_HAIKU_MODEL }),
|
||||
);
|
||||
return { kind: 'skipped', reason: 'no_chunks' };
|
||||
}
|
||||
@@ -298,7 +282,6 @@ export async function reembedPageWithContextualRetrieval(
|
||||
const corpus_generation = computeCorpusGeneration({
|
||||
crMode: attemptMode,
|
||||
haikuModel,
|
||||
synopsisDocMaxChars: attemptMode === 'per_chunk_synopsis' ? SYNOPSIS_DOC_MAX_CHARS : undefined,
|
||||
});
|
||||
|
||||
// ── PHASE 2: single DB transaction ───────────────────────────
|
||||
|
||||
+56
-6
@@ -79,15 +79,34 @@ export interface EmbedBatchOptions {
|
||||
* and amplify rate-limit pressure.
|
||||
*/
|
||||
maxRetries?: number;
|
||||
/**
|
||||
* #1818: bounded parallelism across BATCH_SIZE sub-batches. Defaults to
|
||||
* `GBRAIN_EMBED_BATCH_CONCURRENCY` env, else 4. Results are
|
||||
* index-addressed so output order always matches input order. Set 1 to
|
||||
* force the pre-v0.42 serial dispatch.
|
||||
*/
|
||||
concurrency?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed a batch of texts via the gateway. Sub-batches of 100 so upstream
|
||||
* progress callbacks fire incrementally on large imports. The gateway owns
|
||||
* adaptive batch splitting and per-recipe token-budget logic; this paginator
|
||||
* is purely about progress-callback granularity.
|
||||
* owns progress-callback granularity and (#1818) bounded parallel dispatch
|
||||
* of the sub-batches — the embed-stale.ts worker-pool pattern, scoped down.
|
||||
*/
|
||||
const BATCH_SIZE = 100;
|
||||
const DEFAULT_EMBED_BATCH_CONCURRENCY = 4;
|
||||
|
||||
function resolveEmbedBatchConcurrency(options: EmbedBatchOptions): number {
|
||||
if (options.concurrency !== undefined) {
|
||||
return Math.max(1, Math.floor(options.concurrency));
|
||||
}
|
||||
const env = Number(process.env.GBRAIN_EMBED_BATCH_CONCURRENCY);
|
||||
if (Number.isFinite(env) && env >= 1) return Math.floor(env);
|
||||
return DEFAULT_EMBED_BATCH_CONCURRENCY;
|
||||
}
|
||||
|
||||
export async function embedBatch(
|
||||
texts: string[],
|
||||
options: EmbedBatchOptions = {},
|
||||
@@ -103,13 +122,44 @@ export async function embedBatch(
|
||||
if (texts.length <= BATCH_SIZE && !options.onBatchComplete) {
|
||||
return gatewayEmbed(texts, gwOpts);
|
||||
}
|
||||
const results: Float32Array[] = [];
|
||||
// #1818: dispatch sub-batches through a bounded worker pool instead of a
|
||||
// serial loop. Results are written into a preallocated index-addressed
|
||||
// array so output order matches input order regardless of completion
|
||||
// order; onBatchComplete reports a monotonic completed-embedding count.
|
||||
const slices: Array<{ start: number; texts: string[] }> = [];
|
||||
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
|
||||
const slice = texts.slice(i, i + BATCH_SIZE);
|
||||
const out = await gatewayEmbed(slice, gwOpts);
|
||||
results.push(...out);
|
||||
options.onBatchComplete?.(results.length, texts.length);
|
||||
slices.push({ start: i, texts: texts.slice(i, i + BATCH_SIZE) });
|
||||
}
|
||||
const results = new Array<Float32Array>(texts.length);
|
||||
let next = 0;
|
||||
let done = 0;
|
||||
const numWorkers = Math.min(resolveEmbedBatchConcurrency(options), slices.length);
|
||||
// Once any sub-batch fails, `failed` stops the surviving workers from
|
||||
// dispatching FURTHER slices — the whole call is rejecting anyway, so
|
||||
// continuing would burn real provider spend in the background and fire
|
||||
// onBatchComplete after the caller already saw the failure (worst with
|
||||
// embedBatchWithBackoff, whose 429 backoff assumes nothing is in flight).
|
||||
// In-flight sibling calls still run to completion (bounded by numWorkers-1).
|
||||
let failed = false;
|
||||
const worker = async (): Promise<void> => {
|
||||
while (!failed && next < slices.length) {
|
||||
// NOTE: no local aborted-check here — an aborted signal makes the next
|
||||
// gatewayEmbed call throw (SDK-side), which rejects the pool. Returning
|
||||
// silently instead would resolve with holes in `results`.
|
||||
const slice = slices[next++];
|
||||
let out: Float32Array[];
|
||||
try {
|
||||
out = await gatewayEmbed(slice.texts, gwOpts);
|
||||
} catch (err) {
|
||||
failed = true;
|
||||
throw err;
|
||||
}
|
||||
for (let j = 0; j < out.length; j++) results[slice.start + j] = out[j];
|
||||
done += out.length;
|
||||
if (!failed) options.onBatchComplete?.(done, texts.length);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: numWorkers }, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
@@ -251,14 +251,6 @@ registerBackgroundWorkDrainer({
|
||||
export function isEvalCaptureEnabled(config: GBrainConfig | null | undefined): boolean {
|
||||
if (config?.eval?.capture === true) return true;
|
||||
if (config?.eval?.capture === false) return false;
|
||||
// #1475: DB-plane stash. `gbrain config set eval.capture true` lands in the
|
||||
// config table; connectEngine stamps the merged value here because
|
||||
// ctx.config is the sync file-plane load and never sees the DB plane.
|
||||
// Explicit per-key setting (file above, DB here) beats the broad
|
||||
// CONTRIBUTOR_MODE flag, matching how file-plane `false` already wins.
|
||||
// Doubles as a direct operator env knob.
|
||||
if (process.env.GBRAIN_EVAL_CAPTURE === 'true') return true;
|
||||
if (process.env.GBRAIN_EVAL_CAPTURE === 'false') return false;
|
||||
return process.env.GBRAIN_CONTRIBUTOR_MODE === '1';
|
||||
}
|
||||
|
||||
@@ -271,8 +263,5 @@ export function isEvalCaptureEnabled(config: GBrainConfig | null | undefined): b
|
||||
* have explicit `capture: true`.
|
||||
*/
|
||||
export function isEvalScrubEnabled(config: GBrainConfig | null | undefined): boolean {
|
||||
if (config?.eval?.scrub_pii === false) return false;
|
||||
if (config?.eval?.scrub_pii === true) return true;
|
||||
// #1475: DB-plane stash — see isEvalCaptureEnabled. Default stays true.
|
||||
return process.env.GBRAIN_EVAL_SCRUB_PII !== 'false';
|
||||
return config?.eval?.scrub_pii !== false;
|
||||
}
|
||||
|
||||
@@ -733,11 +733,6 @@ export async function importFromContent(
|
||||
: computeCorpusGeneration({
|
||||
crMode: effectiveCRMode,
|
||||
haikuModel: 'anthropic:claude-haiku-4-5-20251001',
|
||||
// Inline import-file path never uses per_chunk_synopsis (refuses
|
||||
// upstream); pass undefined so the doc-cap field stays out of
|
||||
// the hash here. Per_chunk_synopsis runs through the Minion
|
||||
// backfill handler which threads SYNOPSIS_DOC_MAX_CHARS through
|
||||
// the service layer.
|
||||
});
|
||||
|
||||
// Transaction wraps all DB writes. Every per-page tx call carries the
|
||||
|
||||
@@ -489,22 +489,7 @@ export async function extractPageLinks(
|
||||
// text inside `[[...]]` before any `|`), NOT the display alias
|
||||
// (ref.name = match[2]). `[[struktura|the project]]` must resolve
|
||||
// `struktura`, not "the project". The display text is for context only.
|
||||
//
|
||||
// The literal may be path-qualified (`[[notes/struktura]]`). The FS
|
||||
// path (resolveSlugAll) strips the dirname before its basename lookup,
|
||||
// but this path passed the raw literal to an index keyed by final
|
||||
// segments only — so every slash-containing wikilink outside
|
||||
// DIR_PATTERN silently resolved to nothing. Query by the final
|
||||
// segment, then use the written path as a disambiguation filter
|
||||
// (the analogue of the FS ancestor walk honoring the written path):
|
||||
// a match must end with the literal, so `[[notes/struktura]]` can
|
||||
// resolve to `vault/notes/struktura` but never to `wiki/struktura`.
|
||||
const slashIdx = ref.slug.lastIndexOf('/');
|
||||
const basename = slashIdx === -1 ? ref.slug : ref.slug.slice(slashIdx + 1);
|
||||
let matches = await resolver.resolveBasenameMatches(basename);
|
||||
if (slashIdx !== -1) {
|
||||
matches = matches.filter(m => m === ref.slug || m.endsWith(`/${ref.slug}`));
|
||||
}
|
||||
const matches = await resolver.resolveBasenameMatches(ref.slug);
|
||||
if (matches.length === 0) continue;
|
||||
const idx = content.indexOf(ref.slug);
|
||||
const context = idx >= 0 ? excerpt(content, idx, 240) : ref.name;
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* Shared orphan-reporting exclusion policy.
|
||||
*
|
||||
* These are pages where "no inbound links" is expected and should not count
|
||||
* against health. Keep this in core so the CLI orphan report and engine health
|
||||
* dashboard cannot drift.
|
||||
*
|
||||
* Defaults are GBrain-wide conventions only. Brain-specific exclusions
|
||||
* (private folder names, one-off fixture slugs) belong in the brain's own
|
||||
* config, not here:
|
||||
*
|
||||
* gbrain config set orphans.exclude_prefixes "my-private-folder/,archive/"
|
||||
* gbrain config set orphans.exclude_slugs "some-one-off-page"
|
||||
*/
|
||||
|
||||
const AUTO_SUFFIX_PATTERNS = ['/_index', '/log'];
|
||||
|
||||
const PSEUDO_SLUGS = new Set(['_atlas', '_index', '_stats', '_orphans', '_scratch', 'claude']);
|
||||
|
||||
const RAW_SEGMENT = '/raw/';
|
||||
|
||||
const DENY_PREFIXES = [
|
||||
'output/',
|
||||
'dashboards/',
|
||||
'scripts/',
|
||||
'templates/',
|
||||
'_templates/',
|
||||
'openclaw/config/',
|
||||
'extracts/',
|
||||
];
|
||||
|
||||
const FIRST_SEGMENT_EXCLUSIONS = new Set([
|
||||
'scratch',
|
||||
'thoughts',
|
||||
'catalog',
|
||||
'entities',
|
||||
'raw',
|
||||
'atoms',
|
||||
'skills',
|
||||
'dreaming',
|
||||
'daily',
|
||||
]);
|
||||
|
||||
const ROOT_DATE_SLUG = /^\d{4}-\d{2}-\d{2}(?:-.+)?$/;
|
||||
|
||||
function isAgentWorkspaceConvention(slug: string): boolean {
|
||||
if (!slug.startsWith('agents/')) return false;
|
||||
if (slug.includes('/memory/dreaming/')) return true;
|
||||
return /^agents\/[^/]+\/(?:agents|identity|soul|tools|user|heartbeat|dreams|dormant)$/.test(slug);
|
||||
}
|
||||
|
||||
/** Per-brain additions to the convention defaults (from config). */
|
||||
export interface OrphanPolicyOverrides {
|
||||
excludePrefixes?: string[];
|
||||
excludeSlugs?: string[];
|
||||
}
|
||||
|
||||
/** Config keys for per-brain orphan exclusions (comma-separated values). */
|
||||
export const ORPHAN_EXCLUDE_PREFIXES_KEY = 'orphans.exclude_prefixes';
|
||||
export const ORPHAN_EXCLUDE_SLUGS_KEY = 'orphans.exclude_slugs';
|
||||
|
||||
function parseList(value: string | null): string[] {
|
||||
if (!value) return [];
|
||||
return value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load per-brain orphan exclusions from the brain config table. Callers with
|
||||
* an engine in hand (getHealth, `gbrain orphans`) pass the result as the
|
||||
* second argument to shouldExcludeFromOrphanReporting.
|
||||
*/
|
||||
export async function loadOrphanPolicyOverrides(
|
||||
engine: { getConfig(key: string): Promise<string | null> },
|
||||
): Promise<OrphanPolicyOverrides> {
|
||||
const [prefixes, slugs] = await Promise.all([
|
||||
engine.getConfig(ORPHAN_EXCLUDE_PREFIXES_KEY),
|
||||
engine.getConfig(ORPHAN_EXCLUDE_SLUGS_KEY),
|
||||
]);
|
||||
return { excludePrefixes: parseList(prefixes), excludeSlugs: parseList(slugs) };
|
||||
}
|
||||
|
||||
export function shouldExcludeFromOrphanReporting(
|
||||
slug: string,
|
||||
overrides?: OrphanPolicyOverrides,
|
||||
): boolean {
|
||||
if (PSEUDO_SLUGS.has(slug)) return true;
|
||||
|
||||
for (const suffix of AUTO_SUFFIX_PATTERNS) {
|
||||
if (slug.endsWith(suffix)) return true;
|
||||
}
|
||||
|
||||
if (slug.includes(RAW_SEGMENT)) return true;
|
||||
if (slug.includes('/daily/')) return true;
|
||||
|
||||
for (const prefix of DENY_PREFIXES) {
|
||||
if (slug.startsWith(prefix)) return true;
|
||||
}
|
||||
|
||||
const firstSegment = slug.split('/')[0];
|
||||
if (FIRST_SEGMENT_EXCLUSIONS.has(firstSegment)) return true;
|
||||
|
||||
if (ROOT_DATE_SLUG.test(slug)) return true;
|
||||
|
||||
if (slug.startsWith('_brain-')) return true;
|
||||
|
||||
if (isAgentWorkspaceConvention(slug)) return true;
|
||||
|
||||
if (overrides) {
|
||||
if (overrides.excludeSlugs?.includes(slug)) return true;
|
||||
for (const prefix of overrides.excludePrefixes ?? []) {
|
||||
if (slug.startsWith(prefix)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -44,33 +44,6 @@ const HAIKU_MAX_TOKENS = 200;
|
||||
/** Default model when caller doesn't override. Resolves through the gateway. */
|
||||
const DEFAULT_SYNOPSIS_MODEL = 'anthropic:claude-haiku-4-5-20251001';
|
||||
|
||||
/**
|
||||
* Hard cap on `documentText` length (chars) before send.
|
||||
*
|
||||
* 2026-05-25 fix wave: small local chat models (Gemma 4 E2B, Qwen3 4B) get
|
||||
* dramatically slower on long contexts even with 131K-token windows declared.
|
||||
* A 73K-char page synopsis on Gemma 4 E2B takes 60-120s, exceeding the
|
||||
* worker's default 30s `lockDuration` and tripping `lock-lost` errors.
|
||||
*
|
||||
* Truncate to a budget that fits a small model's effective throughput while
|
||||
* preserving enough document context for the synopsis to be useful. Truncates
|
||||
* the TAIL because the head (title, frontmatter, intro) carries the
|
||||
* document-level anchor the synopsis needs.
|
||||
*
|
||||
* Override per workload via `GBRAIN_SYNOPSIS_DOC_MAX_CHARS`. Default 32768
|
||||
* (~8K tokens at 4 chars/tok) keeps small-model synopsis under ~30s.
|
||||
* Anthropic Haiku is unaffected at this cap; bump higher when running
|
||||
* frontier models if you want richer document anchoring.
|
||||
*/
|
||||
export const SYNOPSIS_DOC_MAX_CHARS = (() => {
|
||||
const env = process.env.GBRAIN_SYNOPSIS_DOC_MAX_CHARS;
|
||||
if (env && /^\d+$/.test(env)) {
|
||||
const n = parseInt(env, 10);
|
||||
if (n >= 512 && n <= 1_048_576) return n;
|
||||
}
|
||||
return 32768;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Synopsis prompt version. Folded into corpus_generation so prompt edits
|
||||
* invalidate prior embeddings via the v0.40.3.0 query_cache.page_generations
|
||||
@@ -215,19 +188,11 @@ function buildUserPrompt(
|
||||
documentText: string,
|
||||
chunkText: string,
|
||||
): string {
|
||||
// Tail-truncate `documentText` to `SYNOPSIS_DOC_MAX_CHARS` so small local
|
||||
// chat models don't stall on >100KB pages. Head preserved (title block,
|
||||
// frontmatter, intro paragraphs carry the document-level anchor).
|
||||
let trimmedDoc = documentText;
|
||||
if (documentText.length > SYNOPSIS_DOC_MAX_CHARS) {
|
||||
trimmedDoc = documentText.slice(0, SYNOPSIS_DOC_MAX_CHARS) +
|
||||
`\n\n[... ${documentText.length - SYNOPSIS_DOC_MAX_CHARS} chars truncated for synopsis budget ...]`;
|
||||
}
|
||||
return [
|
||||
`<page_title>${pageTitle}</page_title>`,
|
||||
'',
|
||||
'<full_document>',
|
||||
trimmedDoc,
|
||||
documentText,
|
||||
'</full_document>',
|
||||
'',
|
||||
'<chunk>',
|
||||
|
||||
+19
-30
@@ -57,8 +57,6 @@ import { finalizeLastSeen } from './chronicle/last-seen.ts';
|
||||
import { computeAnomaliesFromBuckets } from './cycle/anomaly.ts';
|
||||
import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts';
|
||||
import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql, buildBestPerPagePoolCte, buildOrFallbackWebsearchQuery } from './search/sql-ranking.ts';
|
||||
import { shouldExcludeFromOrphanReporting, loadOrphanPolicyOverrides } from './orphan-policy.ts';
|
||||
import { LINK_EXTRACTOR_VERSION_TS } from './link-extraction.ts';
|
||||
import {
|
||||
normalizeEngineColumn,
|
||||
buildVectorCastFragment,
|
||||
@@ -2324,10 +2322,6 @@ export class PGLiteEngine implements BrainEngine {
|
||||
// v0.40.3.0 D24 NULL→non-NULL race fix mirrors postgres-engine.ts. Two writers
|
||||
// racing on the same chunk previously raced last-write-wins; the fix lets the
|
||||
// fresher `embedded_at` win in the text-unchanged branch.
|
||||
//
|
||||
// Code-chunk metadata columns follow the same chunk_text-gated CASE pattern as `embedding`
|
||||
// (#769). Re-chunk trusts EXCLUDED outright; pure re-embed COALESCEs so a caller carrying
|
||||
// only embedding-shaped fields doesn't clobber metadata to NULL.
|
||||
await this.db.query(
|
||||
`INSERT INTO content_chunks ${cols} VALUES ${rowParts.join(', ')}
|
||||
ON CONFLICT (page_id, chunk_index) DO UPDATE SET
|
||||
@@ -2351,14 +2345,14 @@ export class PGLiteEngine implements BrainEngine {
|
||||
THEN EXCLUDED.embedded_at
|
||||
ELSE content_chunks.embedded_at
|
||||
END,
|
||||
language = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.language ELSE COALESCE(EXCLUDED.language, content_chunks.language) END,
|
||||
symbol_name = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.symbol_name ELSE COALESCE(EXCLUDED.symbol_name, content_chunks.symbol_name) END,
|
||||
symbol_type = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.symbol_type ELSE COALESCE(EXCLUDED.symbol_type, content_chunks.symbol_type) END,
|
||||
start_line = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.start_line ELSE COALESCE(EXCLUDED.start_line, content_chunks.start_line) END,
|
||||
end_line = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.end_line ELSE COALESCE(EXCLUDED.end_line, content_chunks.end_line) END,
|
||||
parent_symbol_path = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.parent_symbol_path ELSE COALESCE(EXCLUDED.parent_symbol_path, content_chunks.parent_symbol_path) END,
|
||||
doc_comment = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.doc_comment ELSE COALESCE(EXCLUDED.doc_comment, content_chunks.doc_comment) END,
|
||||
symbol_name_qualified = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.symbol_name_qualified ELSE COALESCE(EXCLUDED.symbol_name_qualified, content_chunks.symbol_name_qualified) END,
|
||||
language = EXCLUDED.language,
|
||||
symbol_name = EXCLUDED.symbol_name,
|
||||
symbol_type = EXCLUDED.symbol_type,
|
||||
start_line = EXCLUDED.start_line,
|
||||
end_line = EXCLUDED.end_line,
|
||||
parent_symbol_path = EXCLUDED.parent_symbol_path,
|
||||
doc_comment = EXCLUDED.doc_comment,
|
||||
symbol_name_qualified = EXCLUDED.symbol_name_qualified,
|
||||
modality = EXCLUDED.modality,
|
||||
embedding_image = COALESCE(EXCLUDED.embedding_image, content_chunks.embedding_image)`,
|
||||
params
|
||||
@@ -5213,10 +5207,15 @@ export class PGLiteEngine implements BrainEngine {
|
||||
(SELECT count(*) FROM pages) as page_count,
|
||||
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NOT NULL)::float /
|
||||
GREATEST((SELECT count(*) FROM content_chunks), 1)::float as embed_coverage,
|
||||
0 as stale_pages,
|
||||
-- Bug 11 — orphan = islanded (no inbound AND no outbound). The raw
|
||||
-- list is filtered in TS using the shared orphan-reporting policy.
|
||||
0 as orphan_pages,
|
||||
(SELECT count(*) FROM pages p
|
||||
WHERE p.updated_at < (SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = p.id)
|
||||
) as stale_pages,
|
||||
-- Bug 11 — orphan = islanded (no inbound AND no outbound).
|
||||
-- See BrainHealth.orphan_pages docstring; docs updated to match this.
|
||||
(SELECT count(*) FROM pages p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id)
|
||||
) as orphan_pages,
|
||||
(SELECT count(*) FROM links l
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pages p WHERE p.id = l.to_page_id)
|
||||
) as dead_links,
|
||||
@@ -5241,20 +5240,10 @@ export class PGLiteEngine implements BrainEngine {
|
||||
LIMIT 5
|
||||
`);
|
||||
|
||||
const { rows: islandedRows } = await this.db.query(`
|
||||
SELECT p.slug
|
||||
FROM pages p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id)
|
||||
`);
|
||||
|
||||
const r = h as Record<string, unknown>;
|
||||
const pageCount = Number(r.page_count);
|
||||
const embedCoverage = Number(r.embed_coverage);
|
||||
const stalePages = await this.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS });
|
||||
const orphanOverrides = await loadOrphanPolicyOverrides(this);
|
||||
const orphanPages = (islandedRows as { slug: string }[])
|
||||
.filter(row => !shouldExcludeFromOrphanReporting(row.slug, orphanOverrides)).length;
|
||||
const orphanPages = Number(r.orphan_pages);
|
||||
const deadLinks = Number(r.dead_links);
|
||||
const linkCount = Number(r.link_count);
|
||||
const pagesWithTimeline = Number(r.pages_with_timeline);
|
||||
@@ -5282,7 +5271,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
return {
|
||||
page_count: pageCount,
|
||||
embed_coverage: embedCoverage,
|
||||
stale_pages: stalePages,
|
||||
stale_pages: Number(r.stale_pages),
|
||||
orphan_pages: orphanPages,
|
||||
missing_embeddings: Number(r.missing_embeddings),
|
||||
brain_score: brainScore,
|
||||
|
||||
+22
-33
@@ -67,8 +67,6 @@ import { resolveBoostMap, resolveHardExcludes } from './search/source-boost.ts';
|
||||
import { buildSourceFactorCase, buildHardExcludeClause, buildVisibilityClause, buildRecencyComponentSql, buildBestPerPagePoolCte, buildOrFallbackWebsearchQuery } from './search/sql-ranking.ts';
|
||||
import { DEFAULT_EMBEDDING_MODEL, DEFAULT_EMBEDDING_DIMENSIONS } from './ai/defaults.ts';
|
||||
import { DELETE_BATCH_SIZE } from './engine-constants.ts';
|
||||
import { shouldExcludeFromOrphanReporting, loadOrphanPolicyOverrides } from './orphan-policy.ts';
|
||||
import { LINK_EXTRACTOR_VERSION_TS } from './link-extraction.ts';
|
||||
|
||||
function escapeSqlStringLiteral(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
@@ -2475,13 +2473,6 @@ export class PostgresEngine implements BrainEngine {
|
||||
// - new is fresher (embedded_at > existing.embedded_at) → take new
|
||||
// - otherwise → keep existing (slower writer with stale embedding loses)
|
||||
// Mirrored in pglite-engine.ts; pinned by test/e2e/concurrent-embed-race.test.ts.
|
||||
//
|
||||
// Code-chunk metadata columns (language / symbol_name / symbol_type / line range /
|
||||
// parent_symbol_path / doc_comment / symbol_name_qualified) follow the SAME chunk_text-gated
|
||||
// CASE pattern as `embedding` (#769). Re-chunk (chunk_text changed) trusts EXCLUDED outright;
|
||||
// pure re-embed (chunk_text unchanged) COALESCEs so a caller that only carries embedding
|
||||
// doesn't clobber metadata to NULL. Without this, every embed --stale pass nuked code-def's
|
||||
// primary index for thousands of chunks at once.
|
||||
await sql.unsafe(
|
||||
`INSERT INTO content_chunks ${cols} VALUES ${rows.join(', ')}
|
||||
ON CONFLICT (page_id, chunk_index) DO UPDATE SET
|
||||
@@ -2505,14 +2496,14 @@ export class PostgresEngine implements BrainEngine {
|
||||
THEN EXCLUDED.embedded_at
|
||||
ELSE content_chunks.embedded_at
|
||||
END,
|
||||
language = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.language ELSE COALESCE(EXCLUDED.language, content_chunks.language) END,
|
||||
symbol_name = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.symbol_name ELSE COALESCE(EXCLUDED.symbol_name, content_chunks.symbol_name) END,
|
||||
symbol_type = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.symbol_type ELSE COALESCE(EXCLUDED.symbol_type, content_chunks.symbol_type) END,
|
||||
start_line = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.start_line ELSE COALESCE(EXCLUDED.start_line, content_chunks.start_line) END,
|
||||
end_line = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.end_line ELSE COALESCE(EXCLUDED.end_line, content_chunks.end_line) END,
|
||||
parent_symbol_path = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.parent_symbol_path ELSE COALESCE(EXCLUDED.parent_symbol_path, content_chunks.parent_symbol_path) END,
|
||||
doc_comment = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.doc_comment ELSE COALESCE(EXCLUDED.doc_comment, content_chunks.doc_comment) END,
|
||||
symbol_name_qualified = CASE WHEN EXCLUDED.chunk_text != content_chunks.chunk_text THEN EXCLUDED.symbol_name_qualified ELSE COALESCE(EXCLUDED.symbol_name_qualified, content_chunks.symbol_name_qualified) END,
|
||||
language = EXCLUDED.language,
|
||||
symbol_name = EXCLUDED.symbol_name,
|
||||
symbol_type = EXCLUDED.symbol_type,
|
||||
start_line = EXCLUDED.start_line,
|
||||
end_line = EXCLUDED.end_line,
|
||||
parent_symbol_path = EXCLUDED.parent_symbol_path,
|
||||
doc_comment = EXCLUDED.doc_comment,
|
||||
symbol_name_qualified = EXCLUDED.symbol_name_qualified,
|
||||
modality = EXCLUDED.modality,
|
||||
embedding_image = COALESCE(EXCLUDED.embedding_image, content_chunks.embedding_image)`,
|
||||
params as Parameters<typeof sql.unsafe>[1],
|
||||
@@ -5322,9 +5313,11 @@ export class PostgresEngine implements BrainEngine {
|
||||
async getHealth(): Promise<BrainHealth> {
|
||||
const sql = this.sql;
|
||||
// Bug 11 doc-drift fix — orphan_pages means "islanded" (no inbound AND
|
||||
// no outbound links). The raw islanded list is filtered through the same
|
||||
// policy as `gbrain orphans` so convention pages do not count against
|
||||
// dashboard health.
|
||||
// no outbound links), aligning both engines with the user-facing
|
||||
// definition. The type comment previously said "no inbound" but the
|
||||
// SQL required both — docs now match code so users can trust the
|
||||
// number. A hub page that links out to many but has no back-references
|
||||
// is working as intended, not an orphan.
|
||||
const [h] = await sql`
|
||||
WITH entity_pages AS (
|
||||
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
|
||||
@@ -5333,8 +5326,13 @@ export class PostgresEngine implements BrainEngine {
|
||||
(SELECT count(*) FROM pages) as page_count,
|
||||
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NOT NULL)::float /
|
||||
GREATEST((SELECT count(*) FROM content_chunks), 1)::float as embed_coverage,
|
||||
0 as stale_pages,
|
||||
0 as orphan_pages,
|
||||
(SELECT count(*) FROM pages p
|
||||
WHERE p.updated_at < (SELECT MAX(te.created_at) FROM timeline_entries te WHERE te.page_id = p.id)
|
||||
) as stale_pages,
|
||||
(SELECT count(*) FROM pages p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id)
|
||||
) as orphan_pages,
|
||||
(SELECT count(*) FROM links l
|
||||
WHERE NOT EXISTS (SELECT 1 FROM pages p WHERE p.id = l.to_page_id)
|
||||
) as dead_links,
|
||||
@@ -5358,18 +5356,9 @@ export class PostgresEngine implements BrainEngine {
|
||||
LIMIT 5
|
||||
`;
|
||||
|
||||
const islandedRows = await sql<{ slug: string }[]>`
|
||||
SELECT p.slug
|
||||
FROM pages p
|
||||
WHERE NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.from_page_id = p.id)
|
||||
`;
|
||||
|
||||
const pageCount = Number(h.page_count);
|
||||
const embedCoverage = Number(h.embed_coverage);
|
||||
const stalePages = await this.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS });
|
||||
const orphanOverrides = await loadOrphanPolicyOverrides(this);
|
||||
const orphanPages = islandedRows.filter(row => !shouldExcludeFromOrphanReporting(row.slug, orphanOverrides)).length;
|
||||
const orphanPages = Number(h.orphan_pages);
|
||||
const deadLinks = Number(h.dead_links);
|
||||
const linkCount = Number(h.link_count);
|
||||
const pagesWithTimeline = Number(h.pages_with_timeline);
|
||||
@@ -5397,7 +5386,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
return {
|
||||
page_count: pageCount,
|
||||
embed_coverage: embedCoverage,
|
||||
stale_pages: stalePages,
|
||||
stale_pages: Number(h.stale_pages),
|
||||
orphan_pages: orphanPages,
|
||||
missing_embeddings: Number(h.missing_embeddings),
|
||||
brain_score: brainScore,
|
||||
|
||||
@@ -93,13 +93,7 @@ import { resolveLrSchedule } from './lr-schedule.ts';
|
||||
import { preflight, formatPreflightReport } from './preflight.ts';
|
||||
import { isRejected, loadRejectedBuffer, makeRejectedEntry, saveRejectedBuffer } from './rejected-buffer.ts';
|
||||
import { runReflect, runOneShotRewrite, describeJudges } from './reflect.ts';
|
||||
import {
|
||||
acceptCandidate,
|
||||
proposedPath as proposedFilePath,
|
||||
revertAllPending,
|
||||
skillPath,
|
||||
writeProposed,
|
||||
} from './version-store.ts';
|
||||
import { acceptCandidate, bestPath, revertAllPending, skillPath, writeProposed } from './version-store.ts';
|
||||
import { runValidationGate, scoreSkillOnTasks } from './validate-gate.ts';
|
||||
import { ROLLOUT_SUCCESS_THRESHOLD } from './types.ts';
|
||||
import type { SkillOptOpts, EditOp, RunReceipt, BenchmarkTask } from './types.ts';
|
||||
@@ -708,9 +702,9 @@ async function runOptimizationLoop(
|
||||
// to the catch's assignment values only (it can't prove the async callback ran).
|
||||
const finalOutcome = outcome as 'accepted' | 'no_improvement' | 'aborted' | 'errored';
|
||||
if (!mutateDecision.mutate && finalOutcome === 'accepted') {
|
||||
// writeProposed() emitted both the best pointer and the stable review
|
||||
// artifact in the accept branch. SKILL.md remains untouched.
|
||||
proposedPath = proposedFilePath(skillsDir, skillName);
|
||||
// best.md was written by writeProposed() in the accept branch (no-mutate
|
||||
// path); it doubles as proposed.md for human review. SKILL.md untouched.
|
||||
proposedPath = bestPath(skillsDir, skillName);
|
||||
} else if (mutateDecision.mutate) {
|
||||
mutatedSkillFile = finalOutcome === 'accepted';
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
*
|
||||
* history.json
|
||||
* best.md
|
||||
* proposed.md
|
||||
* versions/
|
||||
* v0001_e1_s1.md
|
||||
* v0002_e1_s2.md
|
||||
@@ -53,10 +52,6 @@ export function bestPath(skillsDir: string, skillName: string): string {
|
||||
return path.join(skilloptDir(skillsDir, skillName), 'best.md');
|
||||
}
|
||||
|
||||
export function proposedPath(skillsDir: string, skillName: string): string {
|
||||
return path.join(skilloptDir(skillsDir, skillName), 'proposed.md');
|
||||
}
|
||||
|
||||
export function skillPath(skillsDir: string, skillName: string): string {
|
||||
return path.join(skillsDir, skillName, 'SKILL.md');
|
||||
}
|
||||
@@ -176,18 +171,17 @@ export function acceptCandidate(input: AcceptInput): AcceptResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the candidate to both `best.md` and `proposed.md` WITHOUT touching
|
||||
* SKILL.md or the history ledger. `best.md` remains the optimizer's current
|
||||
* best pointer; `proposed.md` is the stable human-review artifact promised by
|
||||
* `--no-mutate`. Returns the proposal path. Each write is atomic (.tmp + rename).
|
||||
* Write the candidate to `best.md` (which doubles as `proposed.md`) WITHOUT
|
||||
* touching SKILL.md or the history ledger. Used by the `--no-mutate` /
|
||||
* bundled-without-allow paths: the optimizer found a better candidate but the
|
||||
* caller opted out of in-place mutation, so we surface it for human review.
|
||||
* Returns the path written. Atomic (.tmp + rename).
|
||||
*/
|
||||
export function writeProposed(skillsDir: string, skillName: string, candidateText: string): string {
|
||||
const best = bestPath(skillsDir, skillName);
|
||||
const proposed = proposedPath(skillsDir, skillName);
|
||||
fs.mkdirSync(path.dirname(best), { recursive: true });
|
||||
atomicWrite(best, candidateText);
|
||||
atomicWrite(proposed, candidateText);
|
||||
return proposed;
|
||||
const p = bestPath(skillsDir, skillName);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
atomicWrite(p, candidateText);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,26 +63,25 @@ interface PluginCtx {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function register(api: PluginApi) {
|
||||
api.registerContextEngine(ENGINE_ID, (ctx: PluginCtx) => {
|
||||
const hostResolver =
|
||||
typeof ctx.resolveEntities === 'function'
|
||||
? ctx.resolveEntities
|
||||
: typeof ctx.brainQuery === 'function'
|
||||
? ctx.brainQuery
|
||||
: undefined;
|
||||
return createGBrainContextEngine({
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
resolveEntities: hostResolver,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const entry: PluginEntry = {
|
||||
id: 'gbrain-context-engine',
|
||||
name: 'GBrain Context Engine',
|
||||
description: 'Deterministic temporal/spatial context injection on every turn',
|
||||
register,
|
||||
|
||||
register(api: PluginApi) {
|
||||
api.registerContextEngine(ENGINE_ID, (ctx: PluginCtx) => {
|
||||
const hostResolver =
|
||||
typeof ctx.resolveEntities === 'function'
|
||||
? ctx.resolveEntities
|
||||
: typeof ctx.brainQuery === 'function'
|
||||
? ctx.brainQuery
|
||||
: undefined;
|
||||
return createGBrainContextEngine({
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
resolveEntities: hostResolver,
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default entry;
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
__getShrinkStateForTests,
|
||||
} from '../../src/core/ai/gateway.ts';
|
||||
import { AIConfigError, AITransientError } from '../../src/core/ai/errors.ts';
|
||||
import { RECIPES } from '../../src/core/ai/recipes/index.ts';
|
||||
import type { Recipe } from '../../src/core/ai/types.ts';
|
||||
|
||||
// The last test in this file leaves the gateway configured with a remote
|
||||
// provider + fake key and a REAL embed transport. Without a final reset,
|
||||
@@ -93,6 +95,14 @@ function configureGoogle(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function configureDashscope(): void {
|
||||
configureGateway({
|
||||
embedding_model: 'dashscope:text-embedding-v3',
|
||||
embedding_dimensions: 1024,
|
||||
env: { DASHSCOPE_API_KEY: 'sk-fake' },
|
||||
});
|
||||
}
|
||||
|
||||
// --------- 1. Pure helpers ---------
|
||||
|
||||
describe('splitByTokenBudget (pure helper)', () => {
|
||||
@@ -149,6 +159,27 @@ describe('splitByTokenBudget (pure helper)', () => {
|
||||
expect(splitByTokenBudget(texts, 96_000, 0)).toEqual(splitByTokenBudget(texts, 96_000, 4));
|
||||
expect(splitByTokenBudget(texts, 96_000, -1)).toEqual(splitByTokenBudget(texts, 96_000, 4));
|
||||
});
|
||||
|
||||
// #1199: count cap for providers that reject batches by input count.
|
||||
test('max_batch_count flushes even when token budget has room', () => {
|
||||
const texts = Array.from({ length: 25 }, (_, i) => `t${i}`);
|
||||
const result = splitByTokenBudget(texts, 1_000_000, 4, 10);
|
||||
expect(result.map(b => b.length)).toEqual([10, 10, 5]);
|
||||
expect(result.flat()).toEqual(texts);
|
||||
});
|
||||
|
||||
test('token budget still governs alongside max_batch_count', () => {
|
||||
const texts = ['a'.repeat(50_000), 'b'.repeat(50_000), 'c'.repeat(50_000)];
|
||||
const result = splitByTokenBudget(texts, 96_000, 1, 10);
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('undefined / zero / negative max_batch_count is ignored', () => {
|
||||
const texts = Array.from({ length: 25 }, () => 'x');
|
||||
expect(splitByTokenBudget(texts, 1_000_000, 4, undefined)).toHaveLength(1);
|
||||
expect(splitByTokenBudget(texts, 1_000_000, 4, 0)).toHaveLength(1);
|
||||
expect(splitByTokenBudget(texts, 1_000_000, 4, -5)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTokenLimitError (pure helper)', () => {
|
||||
@@ -179,6 +210,12 @@ describe('isTokenLimitError (pure helper)', () => {
|
||||
expect(isTokenLimitError(new Error('Exceeded 300000 max tokens per request'))).toBe(true);
|
||||
});
|
||||
|
||||
test('matches DashScope batch-count error (#1199)', () => {
|
||||
expect(isTokenLimitError(new Error(
|
||||
'InvalidParameter: batch size is invalid, it should not be larger than 10.',
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
test('does not match unrelated errors', () => {
|
||||
expect(isTokenLimitError(new Error('Connection refused'))).toBe(false);
|
||||
expect(isTokenLimitError(new Error('Invalid API key'))).toBe(false);
|
||||
@@ -387,26 +424,92 @@ describe('shrink-on-miss adaptive cache', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --------- 8. Pre-split count cap through public embed() (#1199 / #970) ---------
|
||||
|
||||
describe('embed() pre-split honors max_batch_count', () => {
|
||||
beforeEach(() => resetGateway());
|
||||
afterEach(() => __setEmbedTransportForTests(null));
|
||||
|
||||
test('dashscope never dispatches more than 10 inputs per call (#1199)', async () => {
|
||||
configureDashscope();
|
||||
const stub = mock(async ({ values }: { values: string[] }) => fakeEmbeddings(values, 1024));
|
||||
__setEmbedTransportForTests(stub as any);
|
||||
|
||||
// 25 short texts fit trivially in the 8192-token budget; without the
|
||||
// count cap they'd ship as ONE batch and DashScope would reject it.
|
||||
const texts = Array.from({ length: 25 }, (_, i) => `short-${i}`);
|
||||
const result = await embed(texts);
|
||||
|
||||
expect(result).toHaveLength(25);
|
||||
const callLengths = stub.mock.calls.map(([arg]) => (arg as { values: string[] }).values.length);
|
||||
expect(Math.max(...callLengths)).toBeLessThanOrEqual(10);
|
||||
expect(callLengths.reduce((a, b) => a + b, 0)).toBe(25);
|
||||
// Order preserved across sub-batches.
|
||||
expect((stub.mock.calls[0][0] as { values: string[] }).values[0]).toBe('short-0');
|
||||
});
|
||||
|
||||
test('google pre-splits at 100 inputs per batchEmbedContents call (#970)', async () => {
|
||||
configureGoogle();
|
||||
const stub = mock(async ({ values }: { values: string[] }) => fakeEmbeddings(values, 768));
|
||||
__setEmbedTransportForTests(stub as any);
|
||||
|
||||
const texts = Array.from({ length: 250 }, (_, i) => `g${i}`);
|
||||
const result = await embed(texts);
|
||||
|
||||
expect(result).toHaveLength(250);
|
||||
const callLengths = stub.mock.calls.map(([arg]) => (arg as { values: string[] }).values.length);
|
||||
expect(callLengths).toEqual([100, 100, 50]);
|
||||
});
|
||||
});
|
||||
|
||||
// --------- 7. Startup warning (D9-B) ---------
|
||||
|
||||
describe('startup warning for recipes missing max_batch_tokens', () => {
|
||||
beforeEach(() => resetGateway());
|
||||
|
||||
// #970 closed google's missing cap, so no registered recipe is capless
|
||||
// anymore. Inject a synthetic capless recipe to keep the warning path
|
||||
// covered for the NEXT recipe that forgets the field.
|
||||
const caplessRecipe: Recipe = {
|
||||
id: 'capless-test',
|
||||
name: 'Capless Test Provider',
|
||||
tier: 'openai-compat',
|
||||
implementation: 'openai-compatible',
|
||||
base_url_default: 'https://example.invalid/v1',
|
||||
auth_env: { required: [] },
|
||||
touchpoints: {
|
||||
embedding: { models: ['capless-embed-1'], default_dims: 768 },
|
||||
},
|
||||
};
|
||||
|
||||
function configureCapless(): void {
|
||||
configureGateway({
|
||||
embedding_model: 'capless-test:capless-embed-1',
|
||||
embedding_dimensions: 768,
|
||||
env: {},
|
||||
});
|
||||
}
|
||||
|
||||
test('configured missing-cap recipe warns once; unrelated recipes stay quiet', () => {
|
||||
const warnings: string[] = [];
|
||||
const original = console.warn;
|
||||
console.warn = (msg: string) => warnings.push(String(msg));
|
||||
RECIPES.set(caplessRecipe.id, caplessRecipe);
|
||||
try {
|
||||
configureOpenAI();
|
||||
expect(warnings.length).toBe(0);
|
||||
// #970 regression: google now declares max_batch_tokens → quiet.
|
||||
configureGoogle();
|
||||
expect(warnings.length).toBe(0);
|
||||
configureCapless();
|
||||
const firstCallCount = warnings.length;
|
||||
// Reconfigure: the warning should NOT re-fire for the same recipes
|
||||
// within one process (we already told the operator).
|
||||
configureGoogle();
|
||||
configureCapless();
|
||||
expect(warnings.length).toBe(firstCallCount);
|
||||
} finally {
|
||||
console.warn = original;
|
||||
RECIPES.delete(caplessRecipe.id);
|
||||
}
|
||||
|
||||
// The warning text should match the documented contract.
|
||||
@@ -415,11 +518,12 @@ describe('startup warning for recipes missing max_batch_tokens', () => {
|
||||
);
|
||||
expect(contractMatch.length).toBe(1);
|
||||
|
||||
// Voyage declares max_batch_tokens → suppressed. OpenAI is the
|
||||
// canonical fast-path recipe → also suppressed by id. Both must be
|
||||
// absent from the warnings.
|
||||
// Voyage + google declare max_batch_tokens → suppressed. OpenAI is the
|
||||
// canonical fast-path recipe → also suppressed by id. All must be
|
||||
// absent from the warnings; only the synthetic capless recipe fires.
|
||||
expect(warnings.find(w => w.includes('"voyage"'))).toBeUndefined();
|
||||
expect(warnings.find(w => w.includes('"openai"'))).toBeUndefined();
|
||||
expect(warnings.find(w => w.includes('"google"'))).toBeDefined();
|
||||
expect(warnings.find(w => w.includes('"google"'))).toBeUndefined();
|
||||
expect(warnings.find(w => w.includes('"capless-test"'))).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,16 +52,7 @@ describe('v0.32 #779: no_batch_cap suppresses the missing-max_batch_tokens warni
|
||||
}
|
||||
});
|
||||
|
||||
test('configureGateway warns for google only when google embedding is configured', () => {
|
||||
warnSpy.mockClear();
|
||||
resetGateway();
|
||||
configureGateway({ env: {} });
|
||||
let messages = warnSpy.mock.calls.map(c => String(c[0] ?? ''));
|
||||
expect(
|
||||
messages.some(m => m.includes('"google"') && m.includes('without max_batch_tokens')),
|
||||
'google should not warn while OpenAI default is configured',
|
||||
).toBe(false);
|
||||
|
||||
test('configureGateway does NOT warn for google now that it declares batch caps (#970)', () => {
|
||||
warnSpy.mockClear();
|
||||
resetGateway();
|
||||
configureGateway({
|
||||
@@ -69,11 +60,20 @@ describe('v0.32 #779: no_batch_cap suppresses the missing-max_batch_tokens warni
|
||||
embedding_dimensions: 768,
|
||||
env: { GOOGLE_GENERATIVE_AI_API_KEY: 'fake' },
|
||||
});
|
||||
messages = warnSpy.mock.calls.map(c => String(c[0] ?? ''));
|
||||
const messages = warnSpy.mock.calls.map(c => String(c[0] ?? ''));
|
||||
expect(
|
||||
messages.some(m => m.includes('"google"') && m.includes('without max_batch_tokens')),
|
||||
'google should warn when configured because it has fixed-cap models',
|
||||
).toBe(true);
|
||||
'google declares max_batch_tokens/max_batch_count since #970 — no warning',
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('google recipe declares its derived batch caps (#970)', () => {
|
||||
const e = getRecipe('google')!.touchpoints.embedding!;
|
||||
// Count cap is the REAL Gemini limit (batchEmbedContents: 100 inputs);
|
||||
// the token budget is derived (100 × 2048 per-input tokens), NOT the
|
||||
// 2048 per-input limit — copying that verbatim would over-split 50×.
|
||||
expect(e.max_batch_count).toBe(100);
|
||||
expect(e.max_batch_tokens).toBe(204_800);
|
||||
});
|
||||
|
||||
test('every recipe with empty models[] declares user_provided_models OR has openai-fast-path', () => {
|
||||
|
||||
@@ -55,6 +55,11 @@ describe('recipe: dashscope', () => {
|
||||
expect(r.touchpoints.embedding!.chars_per_token).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('declares max_batch_count: 10 — DashScope rejects larger batches (#1199)', () => {
|
||||
const r = getRecipe('dashscope')!;
|
||||
expect(r.touchpoints.embedding!.max_batch_count).toBe(10);
|
||||
});
|
||||
|
||||
test('dimsProviderOptions threads dimensions for text-embedding-v3 (Matryoshka)', async () => {
|
||||
// Codex finding #1: DashScope text-embedding-v3 is Matryoshka 64-1024.
|
||||
// Without `dimensions` on the wire, user-selected non-default dims are
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// #1474: the v0.41.1 wave shipped bench-publish.ts + docs/eval-bench.md
|
||||
// advertising `gbrain bench publish`, but the cli.ts dispatcher case was never
|
||||
// added — the documented command hit 'Unknown command'. These tests spawn the
|
||||
// real CLI (no DB needed; bench publish is pure file I/O) and fail on any
|
||||
// regression of the dispatcher wiring.
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
function runCli(args: string[]): { stdout: string; stderr: string; code: number } {
|
||||
const result = spawnSync(process.execPath, ['run', 'src/cli.ts', 'bench', ...args], {
|
||||
encoding: 'utf8',
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env },
|
||||
});
|
||||
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', code: result.status ?? -1 };
|
||||
}
|
||||
|
||||
describe('gbrain bench dispatcher (#1474)', () => {
|
||||
test('bench --help reaches bench-publish help without a DB (was: Unknown command)', () => {
|
||||
const { stdout, stderr, code } = runCli(['--help']);
|
||||
expect(stderr).not.toContain('Unknown command');
|
||||
expect(code).toBe(0);
|
||||
expect(stdout).toContain('gbrain bench publish');
|
||||
expect(stdout).toContain('--from');
|
||||
});
|
||||
|
||||
test('unknown bench subcommand exits 2 with usage', () => {
|
||||
const { stderr, code } = runCli(['bogus']);
|
||||
expect(code).toBe(2);
|
||||
expect(stderr).toContain('Unknown bench subcommand');
|
||||
expect(stderr).toContain('bench publish');
|
||||
});
|
||||
|
||||
test('bench publish roundtrip: captured NDJSON in, baseline file out', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'bench-cli-'));
|
||||
try {
|
||||
const row = {
|
||||
tool_name: 'query',
|
||||
query: 'hello world',
|
||||
retrieved_slugs: ['slug-a'],
|
||||
retrieved_chunk_ids: [1],
|
||||
source_ids: ['default'],
|
||||
expand_enabled: false,
|
||||
detail: 'medium',
|
||||
detail_resolved: 'medium',
|
||||
vector_enabled: true,
|
||||
expansion_applied: false,
|
||||
latency_ms: 100,
|
||||
remote: false,
|
||||
job_id: null,
|
||||
subagent_id: null,
|
||||
};
|
||||
const from = join(tmp, 'captured.ndjson');
|
||||
const to = join(tmp, 'personal.baseline.ndjson');
|
||||
writeFileSync(from, `${JSON.stringify(row)}\n`);
|
||||
const { code, stderr } = runCli(['publish', '--from', from, '--to', to]);
|
||||
expect(stderr).not.toContain('Unknown command');
|
||||
expect(code).toBe(0);
|
||||
expect(existsSync(to)).toBe(true);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -172,59 +172,6 @@ describe('issue #972 — DB-source (gbrain extract links --source db)', () => {
|
||||
expect(strk!.link_type).toBe('wikilink_basename');
|
||||
});
|
||||
|
||||
test('flag ON → path-qualified wikilink outside DIR_PATTERN resolves via DB path', async () => {
|
||||
// `[[notes/struktura]]` — `notes` is not in DIR_PATTERN, so the ref
|
||||
// reaches the generic pass with its dirname intact. Regression: the DB
|
||||
// path queried the basename index with the raw literal (which is keyed
|
||||
// by final segments only), so path-qualified wikilinks outside
|
||||
// DIR_PATTERN silently produced zero edges while the FS path resolved
|
||||
// the identical content.
|
||||
await engine.putPage('notes/struktura', {
|
||||
type: 'concept' as any, title: 'Struktura Notes',
|
||||
compiled_truth: '', timeline: '',
|
||||
});
|
||||
await engine.putPage('concepts/knowledge-graph', {
|
||||
type: 'concept', title: 'Knowledge Graph',
|
||||
compiled_truth: 'Background in [[notes/struktura]].', timeline: '',
|
||||
});
|
||||
await engine.setConfig('link_resolution.global_basename', 'true');
|
||||
|
||||
await runExtract(engine, ['links', '--source', 'db']);
|
||||
|
||||
const outLinks = await engine.getLinks('concepts/knowledge-graph');
|
||||
const strk = outLinks.find(l => l.to_slug === 'notes/struktura');
|
||||
expect(strk).toBeDefined();
|
||||
expect(strk!.link_type).toBe('wikilink_basename');
|
||||
expect(strk!.link_source).toBe('wikilink-resolved');
|
||||
});
|
||||
|
||||
test('path-qualified wikilink never attaches to a basename-only sibling', async () => {
|
||||
// Both notes/struktura and wiki/struktura exist. The author wrote
|
||||
// `[[notes/struktura]]` — the written path must exclude wiki/struktura
|
||||
// (a bare `[[struktura]]` would legitimately match both).
|
||||
await engine.putPage('notes/struktura', {
|
||||
type: 'concept' as any, title: 'Struktura Notes',
|
||||
compiled_truth: '', timeline: '',
|
||||
});
|
||||
await engine.putPage('wiki/struktura', {
|
||||
type: 'concept' as any, title: 'Struktura Wiki',
|
||||
compiled_truth: '', timeline: '',
|
||||
});
|
||||
await engine.putPage('concepts/x', {
|
||||
type: 'concept', title: 'X',
|
||||
compiled_truth: 'See [[notes/struktura]].', timeline: '',
|
||||
});
|
||||
await engine.setConfig('link_resolution.global_basename', 'true');
|
||||
|
||||
await runExtract(engine, ['links', '--source', 'db']);
|
||||
|
||||
const outLinks = await engine.getLinks('concepts/x');
|
||||
const basenameLinks = outLinks
|
||||
.filter(l => l.link_type === 'wikilink_basename')
|
||||
.map(l => l.to_slug);
|
||||
expect(basenameLinks).toEqual(['notes/struktura']);
|
||||
});
|
||||
|
||||
test('flag OFF → no basename edges via DB path (back-compat)', async () => {
|
||||
await engine.putPage('projects/struktura', {
|
||||
type: 'project', title: 'Struktura',
|
||||
|
||||
@@ -39,7 +39,6 @@ import { runSkillOpt } from '../../src/core/skillopt/orchestrator.ts';
|
||||
import {
|
||||
bestPath,
|
||||
loadHistory,
|
||||
proposedPath,
|
||||
skillPath,
|
||||
} from '../../src/core/skillopt/version-store.ts';
|
||||
import { loadRejectedBuffer } from '../../src/core/skillopt/rejected-buffer.ts';
|
||||
@@ -742,7 +741,7 @@ describe('skillopt T3 — F11 held-out gate, ablation opts, no-DB-pollution', ()
|
||||
} finally { fixture.cleanup(); }
|
||||
});
|
||||
|
||||
test('--no-mutate writes proposed.md and best.md, leaves SKILL.md untouched', async () => {
|
||||
test('--no-mutate writes proposed.md (best.md), leaves SKILL.md untouched', async () => {
|
||||
const fixture = setupFixture(SKILL_PEOPLE_ONLY, CITATIONS_BENCHMARK);
|
||||
try {
|
||||
installStub({
|
||||
@@ -754,9 +753,10 @@ describe('skillopt T3 — F11 held-out gate, ablation opts, no-DB-pollution', ()
|
||||
const result = await runOnce(fixture, { noMutate: true });
|
||||
expect(result.outcome).toBe('accepted');
|
||||
expect(result.mutatedSkillFile).toBe(false);
|
||||
expect(result.proposedPath).toBe(proposedPath(fixture.skillsDir, SKILL));
|
||||
expect(result.proposedPath).toBeDefined();
|
||||
// proposed.md (best.md) exists and carries the improvement.
|
||||
expect(fs.existsSync(result.proposedPath!)).toBe(true);
|
||||
expect(fs.readFileSync(result.proposedPath!, 'utf8')).toContain('## Citations');
|
||||
expect(fs.readFileSync(bestPath(fixture.skillsDir, SKILL), 'utf8')).toContain('## Citations');
|
||||
// SKILL.md on disk is UNCHANGED (still People-only).
|
||||
const skill = fs.readFileSync(skillPath(fixture.skillsDir, SKILL), 'utf8');
|
||||
expect(skill).not.toContain('## Citations');
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* #1818: embedBatch dispatches its 100-input sub-batches through a bounded
|
||||
* worker pool (the embed-stale.ts concurrency pattern) instead of a serial
|
||||
* `for` loop. This file pins:
|
||||
*
|
||||
* - output order matches input order regardless of completion order
|
||||
* (index-addressed results)
|
||||
* - parallelism actually happens (max in-flight > 1) and stays bounded
|
||||
* (max in-flight <= configured concurrency)
|
||||
* - concurrency: 1 restores the serial pre-#1818 dispatch
|
||||
* - GBRAIN_EMBED_BATCH_CONCURRENCY env is honored when the option is unset
|
||||
* - onBatchComplete reports a monotonic completed count ending at total
|
||||
*
|
||||
* Transport is stubbed via the gateway's __setEmbedTransportForTests seam
|
||||
* (same pattern as test/ai/adaptive-embed-batch.test.ts). OpenAI recipe =
|
||||
* fast path (no pre-split), so each embedBatch sub-batch is exactly one
|
||||
* transport call.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
configureGateway,
|
||||
resetGateway,
|
||||
__setEmbedTransportForTests,
|
||||
} from '../src/core/ai/gateway.ts';
|
||||
import { embedBatch } from '../src/core/embedding.ts';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
|
||||
const DIMS = 1536;
|
||||
|
||||
function configureOpenAI(): void {
|
||||
configureGateway({
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
embedding_dimensions: DIMS,
|
||||
env: { OPENAI_API_KEY: 'sk-fake' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a transport whose returned embedding encodes the GLOBAL input
|
||||
* index in dim 0 (texts are `t<N>`), so order can be asserted end-to-end.
|
||||
* Tracks the max number of concurrently in-flight transport calls.
|
||||
*/
|
||||
function installTrackingTransport(delayMs = 5): { maxInFlight: () => number } {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
__setEmbedTransportForTests((async ({ values }: { values: string[] }) => {
|
||||
inFlight++;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise(r => setTimeout(r, delayMs));
|
||||
inFlight--;
|
||||
return {
|
||||
embeddings: values.map(v => {
|
||||
const idx = Number(v.slice(1));
|
||||
return Array.from({ length: DIMS }, (_, j) => (j === 0 ? idx : 0.1));
|
||||
}),
|
||||
};
|
||||
}) as any);
|
||||
return { maxInFlight: () => maxInFlight };
|
||||
}
|
||||
|
||||
const texts = Array.from({ length: 250 }, (_, i) => `t${i}`);
|
||||
|
||||
afterAll(() => resetGateway());
|
||||
|
||||
describe('embedBatch bounded parallelism (#1818)', () => {
|
||||
beforeEach(() => {
|
||||
resetGateway();
|
||||
configureOpenAI();
|
||||
});
|
||||
afterEach(() => {
|
||||
__setEmbedTransportForTests(null);
|
||||
});
|
||||
|
||||
test('default pool dispatches sub-batches in parallel, order preserved', async () => {
|
||||
const tracker = installTrackingTransport();
|
||||
const result = await embedBatch(texts, { onBatchComplete: () => {} });
|
||||
expect(result).toHaveLength(250);
|
||||
for (let i = 0; i < 250; i++) {
|
||||
expect(result[i][0]).toBe(i);
|
||||
}
|
||||
// 250 texts → 3 sub-batches; default concurrency 4 → all 3 in flight.
|
||||
expect(tracker.maxInFlight()).toBeGreaterThan(1);
|
||||
expect(tracker.maxInFlight()).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
test('concurrency: 1 keeps the serial dispatch', async () => {
|
||||
const tracker = installTrackingTransport();
|
||||
const result = await embedBatch(texts, { concurrency: 1, onBatchComplete: () => {} });
|
||||
expect(result).toHaveLength(250);
|
||||
expect(tracker.maxInFlight()).toBe(1);
|
||||
});
|
||||
|
||||
test('GBRAIN_EMBED_BATCH_CONCURRENCY env bounds the pool when option unset', async () => {
|
||||
const tracker = installTrackingTransport();
|
||||
await withEnv({ GBRAIN_EMBED_BATCH_CONCURRENCY: '2' }, async () => {
|
||||
await embedBatch(texts, { onBatchComplete: () => {} });
|
||||
});
|
||||
expect(tracker.maxInFlight()).toBeGreaterThan(1);
|
||||
expect(tracker.maxInFlight()).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('onBatchComplete reports a monotonic count ending at total', async () => {
|
||||
installTrackingTransport();
|
||||
const seen: number[] = [];
|
||||
await embedBatch(texts, {
|
||||
onBatchComplete: (done, total) => {
|
||||
expect(total).toBe(250);
|
||||
seen.push(done);
|
||||
},
|
||||
});
|
||||
expect(seen).toHaveLength(3); // 100 + 100 + 50 sub-batches
|
||||
for (let i = 1; i < seen.length; i++) {
|
||||
expect(seen[i]).toBeGreaterThan(seen[i - 1]);
|
||||
}
|
||||
expect(seen[seen.length - 1]).toBe(250);
|
||||
});
|
||||
|
||||
test('a failing sub-batch rejects the whole call', async () => {
|
||||
let call = 0;
|
||||
__setEmbedTransportForTests((async ({ values }: { values: string[] }) => {
|
||||
call++;
|
||||
if (call === 2) throw new Error('boom');
|
||||
await new Promise(r => setTimeout(r, 2));
|
||||
return { embeddings: values.map(() => Array.from({ length: DIMS }, () => 0.1)) };
|
||||
}) as any);
|
||||
await expect(embedBatch(texts, { onBatchComplete: () => {} })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('after a failure, surviving workers stop dispatching new slices', async () => {
|
||||
// 1000 texts → 10 slices, concurrency 2. First call fails immediately;
|
||||
// without the `failed` flag the second worker would keep draining all
|
||||
// 10 slices in the background AFTER embedBatch already rejected —
|
||||
// burning provider spend and firing onBatchComplete post-rejection.
|
||||
let calls = 0;
|
||||
const completions: number[] = [];
|
||||
__setEmbedTransportForTests((async ({ values }: { values: string[] }) => {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error('boom');
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
return { embeddings: values.map(() => Array.from({ length: DIMS }, () => 0.1)) };
|
||||
}) as any);
|
||||
const many = Array.from({ length: 1000 }, (_, i) => `t${i}`);
|
||||
await expect(
|
||||
embedBatch(many, { concurrency: 2, onBatchComplete: d => completions.push(d) }),
|
||||
).rejects.toThrow('boom');
|
||||
const callsAtRejection = calls;
|
||||
await new Promise(r => setTimeout(r, 50)); // would-be background drain window
|
||||
expect(calls).toBe(callsAtRejection); // no new dispatch after rejection
|
||||
expect(calls).toBeLessThanOrEqual(2); // only the in-flight sibling ran
|
||||
expect(completions).toHaveLength(0); // no progress reported after failure
|
||||
});
|
||||
|
||||
test('single small batch without callback stays on the one-call fast path', async () => {
|
||||
const tracker = installTrackingTransport(1);
|
||||
const result = await embedBatch(['t0', 't1', 't2']);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[1][0]).toBe(1);
|
||||
expect(tracker.maxInFlight()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -803,107 +803,3 @@ describe('embedAllStale --source threading (D7)', () => {
|
||||
expect((firstCallOpts as { sourceId?: string }).sourceId).toBe('media-corpus');
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Code metadata preservation across re-embed (regression for #769)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// gbrain v0.30.1 and earlier silently clobbered code-chunk metadata
|
||||
// (language, symbol_name, symbol_type, start_line, end_line,
|
||||
// parent_symbol_path, doc_comment, symbol_name_qualified) on every
|
||||
// re-embed pass. The chunker populated those columns at import time,
|
||||
// but embed.ts loaded chunks via getChunks then mapped them to a
|
||||
// stripped ChunkInput carrying only 5 fields. upsertChunks then
|
||||
// OVERWROTE (not COALESCEd) the metadata columns from EXCLUDED, so
|
||||
// re-embed wiped them to NULL. End result on a real brain: 4875 code
|
||||
// pages, 47866 chunks, all with NULL language/symbol_name/symbol_type;
|
||||
// code-def returned 0 hits across every indexed repo.
|
||||
//
|
||||
// All three runEmbed paths (--stale autopilot, --all, --slugs) must
|
||||
// thread metadata through the re-upsert. Tests below assert that the
|
||||
// engine.upsertChunks call carries the same metadata it loaded.
|
||||
|
||||
describe('runEmbed preserves code-chunk metadata across re-embed (regression for #769)', () => {
|
||||
const fullCodeChunk = {
|
||||
chunk_index: 0,
|
||||
chunk_text: '[Java] foo/Bar.java:10-20 method baz',
|
||||
chunk_source: 'compiled_truth' as const,
|
||||
embedded_at: null,
|
||||
token_count: 12,
|
||||
language: 'java',
|
||||
symbol_name: 'baz',
|
||||
symbol_type: 'function',
|
||||
start_line: 10,
|
||||
end_line: 20,
|
||||
parent_symbol_path: ['Bar'],
|
||||
doc_comment: 'does the thing',
|
||||
symbol_name_qualified: 'Bar.baz',
|
||||
};
|
||||
|
||||
function metadataOf(chunk: any) {
|
||||
return {
|
||||
language: chunk.language,
|
||||
symbol_name: chunk.symbol_name,
|
||||
symbol_type: chunk.symbol_type,
|
||||
start_line: chunk.start_line,
|
||||
end_line: chunk.end_line,
|
||||
parent_symbol_path: chunk.parent_symbol_path,
|
||||
doc_comment: chunk.doc_comment,
|
||||
symbol_name_qualified: chunk.symbol_name_qualified,
|
||||
};
|
||||
}
|
||||
|
||||
test('--stale (autopilot path) carries code metadata into upsertChunks', async () => {
|
||||
const stale = [{
|
||||
slug: 'code-page',
|
||||
chunk_index: 0,
|
||||
chunk_text: fullCodeChunk.chunk_text,
|
||||
chunk_source: 'compiled_truth',
|
||||
model: null,
|
||||
token_count: 12,
|
||||
}];
|
||||
let upsertChunkArgs: any[] | null = null;
|
||||
const engine = mockEngine({
|
||||
countStaleChunks: async () => 1,
|
||||
listStaleChunks: async () => stale,
|
||||
getChunks: async () => [fullCodeChunk],
|
||||
upsertChunks: async (_slug: string, chunks: any[]) => { upsertChunkArgs = chunks; },
|
||||
});
|
||||
|
||||
await runEmbed(engine, ['--stale']);
|
||||
|
||||
expect(upsertChunkArgs).not.toBeNull();
|
||||
expect(upsertChunkArgs!).toHaveLength(1);
|
||||
expect(metadataOf(upsertChunkArgs![0])).toEqual(metadataOf(fullCodeChunk));
|
||||
});
|
||||
|
||||
test('--all (full re-embed) carries code metadata into upsertChunks', async () => {
|
||||
let upsertChunkArgs: any[] | null = null;
|
||||
const engine = mockEngine({
|
||||
listPages: async () => [{ slug: 'code-page' }],
|
||||
getChunks: async () => [fullCodeChunk],
|
||||
upsertChunks: async (_slug: string, chunks: any[]) => { upsertChunkArgs = chunks; },
|
||||
});
|
||||
|
||||
await runEmbed(engine, ['--all']);
|
||||
|
||||
expect(upsertChunkArgs).not.toBeNull();
|
||||
expect(upsertChunkArgs!).toHaveLength(1);
|
||||
expect(metadataOf(upsertChunkArgs![0])).toEqual(metadataOf(fullCodeChunk));
|
||||
});
|
||||
|
||||
test('--slugs (per-page embed) carries code metadata into upsertChunks', async () => {
|
||||
let upsertChunkArgs: any[] | null = null;
|
||||
const engine = mockEngine({
|
||||
getPage: async () => ({ slug: 'code-page', compiled_truth: 'x', timeline: '' }),
|
||||
getChunks: async () => [fullCodeChunk],
|
||||
upsertChunks: async (_slug: string, chunks: any[]) => { upsertChunkArgs = chunks; },
|
||||
});
|
||||
|
||||
await runEmbed(engine, ['--slugs', 'code-page']);
|
||||
|
||||
expect(upsertChunkArgs).not.toBeNull();
|
||||
expect(upsertChunkArgs!).toHaveLength(1);
|
||||
expect(metadataOf(upsertChunkArgs![0])).toEqual(metadataOf(fullCodeChunk));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,62 +309,3 @@ describe('isEvalCaptureEnabled / isEvalScrubEnabled (CONTRIBUTOR_MODE-gated)', (
|
||||
} finally { restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('DB-plane stash (#1475): GBRAIN_EVAL_CAPTURE / GBRAIN_EVAL_SCRUB_PII', () => {
|
||||
// connectEngine stamps `gbrain config set eval.capture` (DB plane) onto
|
||||
// these env vars because ctx.config is the sync file-plane load. Without
|
||||
// the stash check the DB value was written and never read.
|
||||
const origCapture = process.env.GBRAIN_EVAL_CAPTURE;
|
||||
const origScrub = process.env.GBRAIN_EVAL_SCRUB_PII;
|
||||
const origMode = process.env.GBRAIN_CONTRIBUTOR_MODE;
|
||||
const restore = () => {
|
||||
if (origCapture === undefined) delete process.env.GBRAIN_EVAL_CAPTURE;
|
||||
else process.env.GBRAIN_EVAL_CAPTURE = origCapture;
|
||||
if (origScrub === undefined) delete process.env.GBRAIN_EVAL_SCRUB_PII;
|
||||
else process.env.GBRAIN_EVAL_SCRUB_PII = origScrub;
|
||||
if (origMode === undefined) delete process.env.GBRAIN_CONTRIBUTOR_MODE;
|
||||
else process.env.GBRAIN_CONTRIBUTOR_MODE = origMode;
|
||||
};
|
||||
|
||||
test('stash=true turns capture on when file plane is silent (the #1475 repro)', () => {
|
||||
delete process.env.GBRAIN_CONTRIBUTOR_MODE;
|
||||
process.env.GBRAIN_EVAL_CAPTURE = 'true';
|
||||
try {
|
||||
expect(isEvalCaptureEnabled(null)).toBe(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const noEval: any = { engine: 'pglite' };
|
||||
expect(isEvalCaptureEnabled(noEval)).toBe(true);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('stash=false wins over CONTRIBUTOR_MODE=1 (explicit per-key beats broad flag)', () => {
|
||||
process.env.GBRAIN_CONTRIBUTOR_MODE = '1';
|
||||
process.env.GBRAIN_EVAL_CAPTURE = 'false';
|
||||
try {
|
||||
expect(isEvalCaptureEnabled(null)).toBe(false);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('file-plane explicit value still wins over the stash', () => {
|
||||
process.env.GBRAIN_EVAL_CAPTURE = 'true';
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const disabled: any = { engine: 'pglite', eval: { capture: false } };
|
||||
expect(isEvalCaptureEnabled(disabled)).toBe(false);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('scrub stash: false disables, file plane wins, default stays true', () => {
|
||||
process.env.GBRAIN_EVAL_SCRUB_PII = 'false';
|
||||
try {
|
||||
expect(isEvalScrubEnabled(null)).toBe(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fileWins: any = { engine: 'pglite', eval: { scrub_pii: true } };
|
||||
expect(isEvalScrubEnabled(fileWins)).toBe(true);
|
||||
} finally { restore(); }
|
||||
delete process.env.GBRAIN_EVAL_SCRUB_PII;
|
||||
try {
|
||||
expect(isEvalScrubEnabled(null)).toBe(true);
|
||||
} finally { restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* overwrites this preload.
|
||||
*/
|
||||
import { configureGateway, getEmbeddingDimensions } from '../../src/core/ai/gateway.ts';
|
||||
import { beforeEach } from 'bun:test';
|
||||
import { afterEach, beforeEach } from 'bun:test';
|
||||
|
||||
const LEGACY_CONFIG = {
|
||||
embedding_model: 'openai:text-embedding-3-large',
|
||||
@@ -52,7 +52,7 @@ applyLegacy();
|
||||
// 2. file-local beforeAll → may overwrite to ZE/1280
|
||||
// Since beforeAll runs once per file BEFORE the first beforeEach,
|
||||
// file-local beforeAll wins for that file's tests. ✓
|
||||
beforeEach(() => {
|
||||
function applyLegacyIfEmpty() {
|
||||
try {
|
||||
// Only re-apply if the gateway was reset (or never configured).
|
||||
// Tests that explicitly configured a different model in their
|
||||
@@ -62,4 +62,28 @@ beforeEach(() => {
|
||||
} catch {
|
||||
applyLegacy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(applyLegacyIfEmpty);
|
||||
|
||||
// PR #3130 shard-order fix: beforeEach alone leaves ONE window open — a file
|
||||
// whose LAST afterEach calls resetGateway() poisons the NEXT file's
|
||||
// beforeAll, which runs BEFORE any beforeEach fires. A beforeAll there that
|
||||
// does engine.initSchema() then sizes the embedding column from the gateway
|
||||
// DEFAULTS (zembed-1/1280d) instead of the pinned legacy 1536, and every
|
||||
// 1536-d Float32Array fixture in that file dies with
|
||||
// "expected 1280 dimensions, not 1536". Which file pair collides is a
|
||||
// function of shard composition, so adding/removing ANY test file can
|
||||
// surface it (that is exactly how it bit shard 9).
|
||||
//
|
||||
// Preload hooks are registered before any file-local hooks, and bun runs
|
||||
// after-hooks inside-out (file-local afterEach first, then this one), so
|
||||
// this repairs the empty slot immediately after the poisoning reset —
|
||||
// before the next file's beforeAll can observe it.
|
||||
//
|
||||
// Known remaining window: a file whose afterAll() resets the gateway (no
|
||||
// hook runs between its afterAll and the next file's beforeAll). Files
|
||||
// that reset in afterAll and can precede a schema-creating file should
|
||||
// re-apply their own config, or the victim file should configureGateway()
|
||||
// explicitly in its beforeAll.
|
||||
afterEach(applyLegacyIfEmpty);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* #1207: `gbrain import` without `--workers` used to hardcode workerCount=1,
|
||||
* so a large Postgres import paid one serial embedding round-trip per file.
|
||||
* runImport now routes the default through the shared autoConcurrency policy
|
||||
* (PGLite → 1, >100 files on Postgres → DEFAULT_PARALLEL_WORKERS), while an
|
||||
* explicit `--workers N` still wins.
|
||||
*
|
||||
* The engine here is a minimal postgres-kind stub with no database_url in
|
||||
* config — runImport's parallel branch then falls back to serial processing
|
||||
* (its PR #490 guard) but the WORKER-COUNT DECISION (the thing #1207 fixes)
|
||||
* is still observable via the "Using N parallel workers" log line. Per-file
|
||||
* imports fail against the stub engine and are swallowed by runImport's
|
||||
* per-file catch; that's fine — this test pins the policy, not the import.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, realpathSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { withEnv } from './helpers/with-env.ts';
|
||||
import { runImport } from '../src/commands/import.ts';
|
||||
|
||||
const fakePostgresEngine = {
|
||||
kind: 'postgres',
|
||||
executeRaw: async () => [],
|
||||
logIngest: async () => {},
|
||||
setConfig: async () => {},
|
||||
getConfig: async () => null,
|
||||
} as any;
|
||||
|
||||
let workspace: string;
|
||||
let brainDir: string;
|
||||
let logs: string[];
|
||||
const realLog = console.log;
|
||||
|
||||
beforeEach(() => {
|
||||
workspace = mkdtempSync(join(tmpdir(), 'gbrain-import-workers-home-'));
|
||||
mkdirSync(join(workspace, '.gbrain'), { recursive: true });
|
||||
brainDir = realpathSync(mkdtempSync(join(tmpdir(), 'gbrain-import-workers-brain-')));
|
||||
// 101 files: one past AUTO_CONCURRENCY_FILE_THRESHOLD (100).
|
||||
for (let i = 0; i < 101; i++) {
|
||||
writeFileSync(join(brainDir, `page-${i}.md`), `# Page ${i}\n\nbody ${i}\n`);
|
||||
}
|
||||
logs = [];
|
||||
console.log = (msg?: unknown) => logs.push(String(msg));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = realLog;
|
||||
rmSync(workspace, { recursive: true, force: true });
|
||||
rmSync(brainDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('import default worker count (#1207)', () => {
|
||||
test('no --workers flag → autoConcurrency picks 4 for >100 files on Postgres', async () => {
|
||||
await withEnv({ GBRAIN_HOME: join(workspace, '.gbrain'), GBRAIN_SOURCE: undefined }, async () => {
|
||||
await runImport(fakePostgresEngine, [brainDir, '--no-embed'], { sourceId: 'default' });
|
||||
});
|
||||
expect(logs.some(l => l.includes('Using 4 parallel workers'))).toBe(true);
|
||||
});
|
||||
|
||||
test('explicit --workers 2 still wins over the auto policy', async () => {
|
||||
await withEnv({ GBRAIN_HOME: join(workspace, '.gbrain'), GBRAIN_SOURCE: undefined }, async () => {
|
||||
await runImport(fakePostgresEngine, [brainDir, '--no-embed', '--workers', '2'], { sourceId: 'default' });
|
||||
});
|
||||
expect(logs.some(l => l.includes('Using 2 parallel workers'))).toBe(true);
|
||||
expect(logs.some(l => l.includes('Using 4 parallel workers'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -403,77 +403,6 @@ describe('extractPageLinks', () => {
|
||||
expect(candidates).toEqual([]);
|
||||
});
|
||||
|
||||
test('path-qualified wikilink outside DIR_PATTERN queries by final segment', async () => {
|
||||
// `[[notes/struktura]]` (dir not in DIR_PATTERN) falls to the generic
|
||||
// pass. The resolver's basename index is keyed by final path segments,
|
||||
// so the lookup must strip the dirname — mirroring the FS path
|
||||
// (resolveSlugAll). Regression: the raw literal was passed through,
|
||||
// which never matched, so these links silently dropped.
|
||||
const seen: string[] = [];
|
||||
const resolver: SlugResolver = {
|
||||
resolve: async () => null,
|
||||
resolveBasenameMatches: async (name) => {
|
||||
seen.push(name);
|
||||
return name === 'struktura' ? ['notes/struktura'] : [];
|
||||
},
|
||||
};
|
||||
const { candidates } = await extractPageLinks(
|
||||
'concepts/x', 'See [[notes/struktura]].',
|
||||
{}, 'concept', resolver, { globalBasename: true },
|
||||
);
|
||||
expect(seen).toContain('struktura');
|
||||
expect(seen).not.toContain('notes/struktura');
|
||||
expect(candidates.map(c => c.targetSlug)).toEqual(['notes/struktura']);
|
||||
expect(candidates[0].linkType).toBe('wikilink_basename');
|
||||
expect(candidates[0].linkSource).toBe('wikilink-resolved');
|
||||
});
|
||||
|
||||
test('path-qualified wikilink keeps only matches ending with the written path', async () => {
|
||||
// The written path disambiguates: `[[notes/struktura]]` must never
|
||||
// attach to `wiki/struktura` even though both share the basename.
|
||||
const resolver: SlugResolver = {
|
||||
resolve: async () => null,
|
||||
resolveBasenameMatches: async (name) =>
|
||||
name === 'struktura' ? ['notes/struktura', 'wiki/struktura'] : [],
|
||||
};
|
||||
const { candidates } = await extractPageLinks(
|
||||
'concepts/x', 'See [[notes/struktura]].',
|
||||
{}, 'concept', resolver, { globalBasename: true },
|
||||
);
|
||||
expect(candidates.map(c => c.targetSlug)).toEqual(['notes/struktura']);
|
||||
});
|
||||
|
||||
test('path-qualified wikilink matches a deeper real slug by path suffix', async () => {
|
||||
// The page lives at vault/notes/struktura; the author wrote the shorter
|
||||
// tail `[[notes/struktura]]`. Suffix matching connects them, while the
|
||||
// basename-only sibling `wiki/struktura` stays excluded.
|
||||
const resolver: SlugResolver = {
|
||||
resolve: async () => null,
|
||||
resolveBasenameMatches: async (name) =>
|
||||
name === 'struktura' ? ['vault/notes/struktura', 'wiki/struktura'] : [],
|
||||
};
|
||||
const { candidates } = await extractPageLinks(
|
||||
'concepts/x', 'See [[notes/struktura]].',
|
||||
{}, 'concept', resolver, { globalBasename: true },
|
||||
);
|
||||
expect(candidates.map(c => c.targetSlug)).toEqual(['vault/notes/struktura']);
|
||||
});
|
||||
|
||||
test('path-qualified self-link is dropped like the bare form', async () => {
|
||||
// `[[notes/struktura]]` written on notes/struktura itself must not
|
||||
// produce a self-loop (same guard as the bare `[[own-tail]]` case).
|
||||
const resolver: SlugResolver = {
|
||||
resolve: async () => null,
|
||||
resolveBasenameMatches: async (name) =>
|
||||
name === 'struktura' ? ['notes/struktura'] : [],
|
||||
};
|
||||
const { candidates } = await extractPageLinks(
|
||||
'notes/struktura', 'See [[notes/struktura]].',
|
||||
{}, 'concept', resolver, { globalBasename: true },
|
||||
);
|
||||
expect(candidates).toEqual([]);
|
||||
});
|
||||
|
||||
test('bare wikilink resolution does not interfere with DIR_PATTERN wikilinks', async () => {
|
||||
// 2b refs (people/alice) take the verb-inferred type;
|
||||
// 2c refs (struktura) take wikilink_basename. Same call.
|
||||
|
||||
@@ -302,29 +302,4 @@ describe('loadConfigWithEngine (Phase 4 / F3)', () => {
|
||||
expect(merged?.engine).toBe('pglite');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eval.* DB-plane merge (#1475)', () => {
|
||||
test('gbrain config set eval.capture true reaches the merged config', async () => {
|
||||
// The #1475 repro: DB plane has eval.capture=true, file plane silent.
|
||||
// Pre-fix the merge skipped eval.* entirely and capture never fired.
|
||||
const base: GBrainConfig = { engine: 'pglite' };
|
||||
const engine = makeEngine({ 'eval.capture': 'true', 'eval.scrub_pii': 'false' });
|
||||
const merged = await loadConfigWithEngine(engine, base);
|
||||
expect(merged?.eval?.capture).toBe(true);
|
||||
expect(merged?.eval?.scrub_pii).toBe(false);
|
||||
});
|
||||
|
||||
test('file plane wins per key; DB fills only the gaps', async () => {
|
||||
const base: GBrainConfig = { engine: 'pglite', eval: { capture: false } };
|
||||
const engine = makeEngine({ 'eval.capture': 'true', 'eval.scrub_pii': 'false' });
|
||||
const merged = await loadConfigWithEngine(engine, base);
|
||||
expect(merged?.eval?.capture).toBe(false); // file wins
|
||||
expect(merged?.eval?.scrub_pii).toBe(false); // DB fills the gap
|
||||
});
|
||||
|
||||
test('no eval keys anywhere leaves cfg.eval undefined', async () => {
|
||||
const merged = await loadConfigWithEngine(makeEngine({}), { engine: 'pglite' });
|
||||
expect(merged?.eval).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
extractCycleFreshnessSourceIds,
|
||||
parseMaintainArgs,
|
||||
} from '../src/commands/maintain.ts';
|
||||
import type { Check } from '../src/commands/doctor.ts';
|
||||
|
||||
describe('maintain args', () => {
|
||||
test('defaults to dry-run unless --safe is explicit', () => {
|
||||
expect(parseMaintainArgs([])).toMatchObject({
|
||||
safe: false,
|
||||
dryRun: true,
|
||||
json: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('--safe enables mutating safe mode', () => {
|
||||
expect(parseMaintainArgs(['--safe', '--json'])).toMatchObject({
|
||||
safe: true,
|
||||
dryRun: false,
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('--dry-run wins over --safe', () => {
|
||||
expect(parseMaintainArgs(['--safe', '--dry-run'])).toMatchObject({
|
||||
safe: true,
|
||||
dryRun: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycle freshness source extraction', () => {
|
||||
test('extracts stale source ids from doctor messages', () => {
|
||||
const checks: Check[] = [
|
||||
{
|
||||
name: 'cycle_freshness',
|
||||
status: 'fail',
|
||||
message: "Source 'brain-sync-remote-teffur' last cycled 40h ago. Run `gbrain dream --source <id>`.",
|
||||
},
|
||||
{
|
||||
name: 'cycle_freshness',
|
||||
status: 'fail',
|
||||
message: "Source 'wiki' last cycled 25h ago. Source 'wiki' last cycled 25h ago.",
|
||||
},
|
||||
];
|
||||
|
||||
expect(extractCycleFreshnessSourceIds(checks)).toEqual([
|
||||
'brain-sync-remote-teffur',
|
||||
'wiki',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores ok and unrelated checks', () => {
|
||||
const checks: Check[] = [
|
||||
{ name: 'cycle_freshness', status: 'ok', message: "Source 'fresh' last cycled recently." },
|
||||
{ name: 'frontmatter_integrity', status: 'warn', message: "Source 'wiki' has frontmatter issues." },
|
||||
];
|
||||
|
||||
expect(extractCycleFreshnessSourceIds(checks)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('root OpenClaw plugin manifest', () => {
|
||||
it('declares the id required by OpenClaw plugin installs', () => {
|
||||
const manifest = JSON.parse(readFileSync(join(import.meta.dir, '..', 'openclaw.plugin.json'), 'utf8'));
|
||||
const entrySource = readFileSync(join(import.meta.dir, '..', 'src', 'openclaw-context-engine.ts'), 'utf8');
|
||||
const entryId = entrySource.match(/id:\s*'([^']+)'/)?.[1];
|
||||
|
||||
expect(manifest.id).toBe(entryId);
|
||||
expect(manifest.configSchema).toBeDefined();
|
||||
expect(typeof manifest.configSchema).toBe('object');
|
||||
expect(manifest.contracts?.contextEngines).toContain('gbrain-context');
|
||||
expect(entrySource).toContain('export function register');
|
||||
});
|
||||
});
|
||||
@@ -186,67 +186,11 @@ describe('shouldExclude — orphan filter regression (preserve curation)', () =>
|
||||
expect(shouldExclude('entities/anonymous')).toBe(true);
|
||||
expect(shouldExclude('atoms/fact-123')).toBe(true);
|
||||
expect(shouldExclude('skills/gbrain-operations')).toBe(true);
|
||||
expect(shouldExclude('dreaming/light/2026-07-20')).toBe(true);
|
||||
expect(shouldExclude('daily/2026-07-20')).toBe(true);
|
||||
expect(shouldExclude('agent-openclaw/daily/2026-07-20')).toBe(true);
|
||||
});
|
||||
|
||||
test('workspace convention slugs are excluded', () => {
|
||||
expect(shouldExclude('_brain-conventions')).toBe(true);
|
||||
expect(shouldExclude('_templates/decision')).toBe(true);
|
||||
expect(shouldExclude('extracts/2026-06-30/takes.proposed/round-single')).toBe(true);
|
||||
expect(shouldExclude('2026-07-20')).toBe(true);
|
||||
expect(shouldExclude('2026-07-20-qa-sweep')).toBe(true);
|
||||
expect(shouldExclude('agents/arya/identity')).toBe(true);
|
||||
expect(shouldExclude('agents/arya/memory/dreaming/deep/2026-07-20')).toBe(true);
|
||||
});
|
||||
|
||||
test('regular slugs are NOT excluded', () => {
|
||||
expect(shouldExclude('people/alice')).toBe(false);
|
||||
expect(shouldExclude('companies/acme')).toBe(false);
|
||||
expect(shouldExclude('writing/post-1')).toBe(false);
|
||||
expect(shouldExclude('agents/arya/qa-reports/launch-review')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHealth orphan_pages uses shared exclusion policy', () => {
|
||||
test('excluded convention islands do not count against health', async () => {
|
||||
await engine.putPage('_templates/decision', {
|
||||
type: 'template', title: 'Decision', compiled_truth: 'template', timeline: '', frontmatter: {},
|
||||
});
|
||||
await engine.putPage('skills/arya/source-check', {
|
||||
type: 'concept', title: 'Skill', compiled_truth: 'skill', timeline: '', frontmatter: {},
|
||||
});
|
||||
await engine.putPage('agents/arya/identity', {
|
||||
type: 'note', title: 'Identity', compiled_truth: 'identity', timeline: '', frontmatter: {},
|
||||
});
|
||||
await engine.putPage('people/alice', {
|
||||
type: 'person', title: 'Alice', compiled_truth: 'real island', timeline: '', frontmatter: {},
|
||||
});
|
||||
|
||||
const health = await engine.getHealth();
|
||||
|
||||
expect(health.orphan_pages).toBe(1);
|
||||
});
|
||||
|
||||
test('per-brain config overrides (orphans.exclude_*) also apply to health', async () => {
|
||||
await engine.putPage('my-private-folder/secret-ref', {
|
||||
type: 'note', title: 'Ref', compiled_truth: 'ref', timeline: '', frontmatter: {},
|
||||
});
|
||||
await engine.putPage('one-off-fixture-page', {
|
||||
type: 'note', title: 'Fixture', compiled_truth: 'fixture', timeline: '', frontmatter: {},
|
||||
});
|
||||
await engine.putPage('people/alice', {
|
||||
type: 'person', title: 'Alice', compiled_truth: 'real island', timeline: '', frontmatter: {},
|
||||
});
|
||||
|
||||
expect((await engine.getHealth()).orphan_pages).toBe(3);
|
||||
|
||||
await engine.setConfig('orphans.exclude_prefixes', 'my-private-folder/');
|
||||
await engine.setConfig('orphans.exclude_slugs', 'one-off-fixture-page');
|
||||
expect((await engine.getHealth()).orphan_pages).toBe(1);
|
||||
|
||||
await engine.unsetConfig('orphans.exclude_prefixes');
|
||||
await engine.unsetConfig('orphans.exclude_slugs');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,10 +66,6 @@ describe('shouldExclude', () => {
|
||||
expect(shouldExclude('templates/meeting-note')).toBe(true);
|
||||
});
|
||||
|
||||
test('excludes deny-prefix: _templates/', () => {
|
||||
expect(shouldExclude('_templates/meeting-note')).toBe(true);
|
||||
});
|
||||
|
||||
test('excludes deny-prefix: openclaw/config/', () => {
|
||||
expect(shouldExclude('openclaw/config/agent')).toBe(true);
|
||||
});
|
||||
@@ -90,44 +86,10 @@ describe('shouldExclude', () => {
|
||||
expect(shouldExclude('entities/product-hunt')).toBe(true);
|
||||
});
|
||||
|
||||
test('excludes first-segment: skills, dreaming, and daily', () => {
|
||||
expect(shouldExclude('skills/arya/source-check')).toBe(true);
|
||||
expect(shouldExclude('dreaming/light/2026-07-20')).toBe(true);
|
||||
expect(shouldExclude('daily/2026-07-20')).toBe(true);
|
||||
expect(shouldExclude('agent-openclaw/daily/2026-07-20')).toBe(true);
|
||||
});
|
||||
|
||||
test('excludes root date logs and agent workspace conventions', () => {
|
||||
expect(shouldExclude('_brain-conventions')).toBe(true);
|
||||
expect(shouldExclude('2026-07-20')).toBe(true);
|
||||
expect(shouldExclude('2026-07-20-qa-sweep')).toBe(true);
|
||||
expect(shouldExclude('agents/arya/identity')).toBe(true);
|
||||
expect(shouldExclude('agents/arya/memory/dreaming/deep/2026-07-20')).toBe(true);
|
||||
});
|
||||
|
||||
test('excludes generated extracts', () => {
|
||||
expect(shouldExclude('extracts/2026-06-30/takes.proposed/round-single')).toBe(true);
|
||||
});
|
||||
|
||||
test('brain-specific exclusions come from config overrides, not global defaults', () => {
|
||||
// No baked-in defaults for these:
|
||||
expect(shouldExclude('my-private-folder/some-secret-ref.md')).toBe(false);
|
||||
expect(shouldExclude('one-off-fixture-page')).toBe(false);
|
||||
// The per-brain config plane (orphans.exclude_prefixes / exclude_slugs):
|
||||
const overrides = {
|
||||
excludePrefixes: ['my-private-folder/'],
|
||||
excludeSlugs: ['one-off-fixture-page'],
|
||||
};
|
||||
expect(shouldExclude('my-private-folder/some-secret-ref.md', overrides)).toBe(true);
|
||||
expect(shouldExclude('one-off-fixture-page', overrides)).toBe(true);
|
||||
expect(shouldExclude('people/jane-doe', overrides)).toBe(false);
|
||||
});
|
||||
|
||||
test('does NOT exclude a normal content page', () => {
|
||||
expect(shouldExclude('companies/acme')).toBe(false);
|
||||
expect(shouldExclude('people/jane-doe')).toBe(false);
|
||||
expect(shouldExclude('projects/gbrain')).toBe(false);
|
||||
expect(shouldExclude('agents/arya/qa-reports/launch-review')).toBe(false);
|
||||
});
|
||||
|
||||
test('does NOT exclude a page ending with log-like text that is not /log', () => {
|
||||
|
||||
@@ -218,21 +218,15 @@ describe('progress reporter', () => {
|
||||
test('only one process-level signal handler installed across many reporters', () => {
|
||||
// Baseline: one handler already installed by prior tests in this file.
|
||||
const installedBefore = __signalHandlerInstalledForTest();
|
||||
// liveReporters is process-global: earlier test files in the same shard
|
||||
// can leave a live entry behind (e.g. a production path that skips
|
||||
// finish() on an error branch). Assert NET-zero leak from THIS test's
|
||||
// lifecycles, not an absolute zero we don't control — same tolerance
|
||||
// the handler assertion below already applies via `installedBefore`.
|
||||
const liveBefore = __liveReporterCountForTest();
|
||||
const { stream } = sink(false);
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const p = createProgress({ mode: 'json', stream, minIntervalMs: 0, minItems: 1 });
|
||||
p.start(`phase_${i}`, 1);
|
||||
p.finish();
|
||||
}
|
||||
// After 50 reporter lifecycles, still exactly one handler and zero NEWLY leaked live entries.
|
||||
// After 50 reporter lifecycles, still exactly one handler and zero leaked live entries.
|
||||
expect(__signalHandlerInstalledForTest()).toBe(installedBefore || true);
|
||||
expect(__liveReporterCountForTest()).toBe(liveBefore);
|
||||
expect(__liveReporterCountForTest()).toBe(0);
|
||||
});
|
||||
|
||||
test('startHeartbeat() fires heartbeats and stop() clears', async () => {
|
||||
|
||||
@@ -12,11 +12,9 @@ import {
|
||||
bestPath,
|
||||
historyPath,
|
||||
loadHistory,
|
||||
proposedPath,
|
||||
revertAllPending,
|
||||
skillPath,
|
||||
versionsDir,
|
||||
writeProposed,
|
||||
} from '../../src/core/skillopt/version-store.ts';
|
||||
|
||||
let tmpDir: string;
|
||||
@@ -81,19 +79,6 @@ describe('acceptCandidate (D8 two-phase commit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeProposed', () => {
|
||||
test('writes distinct best and proposed artifacts without mutating SKILL.md (#2635)', () => {
|
||||
const candidate = '---\nname: test\n---\nproposed body\n';
|
||||
|
||||
const written = writeProposed(tmpDir, SKILL, candidate);
|
||||
|
||||
expect(written).toBe(proposedPath(tmpDir, SKILL));
|
||||
expect(fs.readFileSync(bestPath(tmpDir, SKILL), 'utf8')).toBe(candidate);
|
||||
expect(fs.readFileSync(proposedPath(tmpDir, SKILL), 'utf8')).toBe(candidate);
|
||||
expect(fs.readFileSync(skillPath(tmpDir, SKILL), 'utf8')).toContain('baseline body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revertAllPending (D8 crash recovery)', () => {
|
||||
test('no-op when no pending rows', () => {
|
||||
const reverted = revertAllPending(tmpDir, SKILL);
|
||||
|
||||
Reference in New Issue
Block a user