mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 09:22:18 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c275fa3fab | ||
|
|
ee095f1ca2 | ||
|
|
5f123c1404 | ||
|
|
948ccc7b4f |
@@ -23,7 +23,7 @@ health_checks:
|
||||
label: "Auth provider"
|
||||
checks:
|
||||
- type: http
|
||||
url: "$CLAWVISOR_URL/ready"
|
||||
url: "$CLAWVISOR_URL/health"
|
||||
label: "ClawVisor"
|
||||
- type: env_exists
|
||||
name: GOOGLE_CLIENT_ID
|
||||
@@ -135,7 +135,7 @@ Tell the user:
|
||||
|
||||
Validate:
|
||||
```bash
|
||||
curl -sf "$CLAWVISOR_URL/ready" && echo "PASS: ClawVisor reachable" || echo "FAIL"
|
||||
curl -sf "$CLAWVISOR_URL/health" && echo "PASS: ClawVisor reachable" || echo "FAIL"
|
||||
```
|
||||
|
||||
**STOP until ClawVisor validates.**
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
---
|
||||
id: contacts-to-brain
|
||||
name: Contacts-to-Brain
|
||||
version: 0.1.0
|
||||
description: Google Contacts become canonical people/ pages, enriching brain entities with ground-truth name/email/phone/org data.
|
||||
category: sense
|
||||
requires: [credential-gateway]
|
||||
secrets:
|
||||
- name: CLAWVISOR_URL
|
||||
description: ClawVisor gateway URL (Option A — recommended, handles OAuth for you)
|
||||
where: https://clawvisor.com — create an agent, activate Google Contacts service
|
||||
- name: CLAWVISOR_AGENT_TOKEN
|
||||
description: ClawVisor agent token (Option A)
|
||||
where: https://clawvisor.com — agent settings, copy the agent token
|
||||
- name: GOOGLE_CLIENT_ID
|
||||
description: Google OAuth2 client ID (Option B — direct API access, you manage tokens)
|
||||
where: https://console.cloud.google.com/apis/credentials — create OAuth 2.0 Client ID
|
||||
- name: GOOGLE_CLIENT_SECRET
|
||||
description: Google OAuth2 client secret (Option B)
|
||||
where: https://console.cloud.google.com/apis/credentials — same page as client ID
|
||||
health_checks:
|
||||
- type: any_of
|
||||
label: "Auth provider"
|
||||
checks:
|
||||
- type: http
|
||||
url: "$CLAWVISOR_URL/ready"
|
||||
label: "ClawVisor"
|
||||
- type: env_exists
|
||||
name: GOOGLE_CLIENT_ID
|
||||
label: "Google OAuth"
|
||||
setup_time: 15 min
|
||||
cost_estimate: "$0 (both options are free)"
|
||||
---
|
||||
|
||||
# Contacts-to-Brain: Your Address Book Becomes Ground Truth
|
||||
|
||||
Calendar attendees and email senders are the bulk of `people/<slug>` brain pages.
|
||||
Your Google Contacts is the ground-truth directory — canonical name, email, phone,
|
||||
organization — for those same entities. Syncing it closes the "who is this person"
|
||||
loop automatically and feeds enrichment.
|
||||
|
||||
## IMPORTANT: Instructions for the Agent
|
||||
|
||||
**You are the installer.** Follow these steps precisely.
|
||||
|
||||
**Why this matters:** email-to-brain and calendar-to-brain create people pages
|
||||
from whatever name string the API returned — sometimes an email prefix, sometimes
|
||||
a nickname. Contacts carries the authoritative record. After this recipe runs,
|
||||
"j.smith@acme-example.com" and "Jon S" resolve to the same person page with the
|
||||
right display name, phone, and company.
|
||||
|
||||
**The output is staging files, not direct writes:** the deterministic collector
|
||||
dumps contact records to `brain/contacts/.staging/`; YOU (the agent) merge them
|
||||
into `people/<slug>` pages using judgment — the Notability Gate in
|
||||
`skills/_brain-filing-rules.md` applies. Not every contact deserves a page.
|
||||
|
||||
**Do not skip steps. Verify after each step.**
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Google Contacts (People API, paginated)
|
||||
↓ (ClawVisor credential gateway: list_contacts / get_contact / search_contacts)
|
||||
Contacts Sync Script (deterministic Node.js)
|
||||
↓ Outputs:
|
||||
├── brain/contacts/.raw/contacts-{date}.json (raw API responses, provenance)
|
||||
└── brain/contacts/.staging/{slug}.md (one markdown record per contact)
|
||||
↓
|
||||
Agent reads staging files
|
||||
↓ Judgment calls (Notability Gate):
|
||||
├── Merge into existing people/<slug> pages (name/email/phone/org enrichment)
|
||||
├── Create new pages ONLY for notable contacts not already in brain
|
||||
└── Skip the rest (staging is not the brain)
|
||||
```
|
||||
|
||||
## Opinionated Defaults
|
||||
|
||||
**Staging record format** (one file per contact, deterministic):
|
||||
```markdown
|
||||
# Alice Example
|
||||
|
||||
- **Emails:** alice@acme-example.com, alice@gmail.com
|
||||
- **Phone:** +1 555 0100
|
||||
- **Organization:** Acme Example — VP Engineering
|
||||
- **Source:** Google Contacts (resourceName people/c123, synced 2026-07-21)
|
||||
```
|
||||
|
||||
**Enrichment, not duplication:** if `people/alice-example.md` already exists,
|
||||
append missing fields to it with a `[Source: Google Contacts]` citation. Do NOT
|
||||
create a second page. Slug-match by normalized name, then by email against
|
||||
existing page content.
|
||||
|
||||
**Notability Gate (from `skills/_brain-filing-rules.md`):** a contact with no
|
||||
brain presence gets a new page only if they appear elsewhere in the brain
|
||||
(calendar attendee, email correspondent) or the user confirms they matter.
|
||||
When in doubt, DON'T create — a junk page degrades search quality.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **GBrain installed and configured** (`gbrain doctor` passes)
|
||||
2. **Node.js 18+** (for the sync script)
|
||||
3. **Google Contacts access** via ONE of:
|
||||
- **Option A: ClawVisor** (recommended, handles OAuth for you, no token management)
|
||||
- **Option B: Google OAuth2 directly** (you manage tokens, no extra service needed)
|
||||
|
||||
## Setup Flow
|
||||
|
||||
### Step 1: Choose and Configure Contacts Access
|
||||
|
||||
Ask the user: "How do you want to connect to Google Contacts?
|
||||
|
||||
**Option A: ClawVisor (recommended)**
|
||||
ClawVisor handles OAuth, token refresh, and encryption. If you already use
|
||||
ClawVisor for email-to-brain or calendar-to-brain, this uses the same setup —
|
||||
just activate the Google Contacts service on your existing agent.
|
||||
|
||||
**Option B: Google OAuth2 directly**
|
||||
Connect to the Google People API directly. No extra service needed, but you
|
||||
manage OAuth tokens yourself."
|
||||
|
||||
#### Option A: ClawVisor Setup
|
||||
|
||||
Tell the user:
|
||||
"I need your ClawVisor URL and agent token.
|
||||
1. Go to https://clawvisor.com
|
||||
2. Create an agent (or use existing)
|
||||
3. Activate the **Google Contacts** service
|
||||
4. Create a standing task with purpose: 'Full contacts access for people
|
||||
enrichment: list contacts, read contact details, search contacts across
|
||||
all connected Google accounts.'
|
||||
IMPORTANT: Be EXPANSIVE in the task purpose. Narrow purposes block requests.
|
||||
5. Copy the gateway URL and agent token"
|
||||
|
||||
Validate:
|
||||
```bash
|
||||
curl -sf "$CLAWVISOR_URL/ready" && echo "PASS: ClawVisor reachable" || echo "FAIL"
|
||||
```
|
||||
|
||||
**STOP until ClawVisor validates.**
|
||||
|
||||
#### Option B: Google OAuth2 Setup
|
||||
|
||||
Same flow as `recipes/credential-gateway.md` Option B, with the contacts scope:
|
||||
|
||||
1. https://console.cloud.google.com/apis/credentials — create an OAuth client ID
|
||||
(Desktop app), consent screen scope: `https://www.googleapis.com/auth/contacts.readonly`
|
||||
2. Enable the People API: https://console.cloud.google.com/apis/library/people.googleapis.com
|
||||
3. Run the OAuth flow; store tokens in `~/.gbrain/google-tokens.json` (auto-refresh on expiry)
|
||||
|
||||
Validate:
|
||||
```bash
|
||||
[ -n "$GOOGLE_CLIENT_ID" ] && [ -n "$GOOGLE_CLIENT_SECRET" ] \
|
||||
&& echo "PASS: Google OAuth credentials set" \
|
||||
|| echo "FAIL: Missing GOOGLE_CLIENT_ID or GOOGLE_CLIENT_SECRET"
|
||||
```
|
||||
|
||||
**STOP until OAuth flow completes and tokens are stored.**
|
||||
|
||||
### Step 2: Set Up the Contacts Sync Script
|
||||
|
||||
```bash
|
||||
mkdir -p contacts-sync
|
||||
cd contacts-sync
|
||||
npm init -y
|
||||
```
|
||||
|
||||
The sync script needs these capabilities:
|
||||
|
||||
1. **Paginated retrieval** — `list_contacts` (People API `people.connections.list`)
|
||||
returns pages of up to 100; follow `nextPageToken` until exhausted. Request
|
||||
fields: names, emailAddresses, phoneNumbers, organizations, metadata.
|
||||
2. **Deterministic staging output** — one markdown file per contact at
|
||||
`brain/contacts/.staging/{slug}.md`, slug from normalized display name
|
||||
(fall back to email prefix). Same contact = same file on every run (idempotent).
|
||||
3. **Raw JSON preservation** — save raw API responses to
|
||||
`brain/contacts/.raw/contacts-{date}.json` for provenance.
|
||||
4. **Skip empty records** — contacts with no name AND no email are noise; drop them.
|
||||
|
||||
### Step 3: Run the Full Sync
|
||||
|
||||
```bash
|
||||
node contacts-sync.mjs
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
ls brain/contacts/.staging/ | head -10
|
||||
```
|
||||
|
||||
Should show one file per contact, e.g. `alice-example.md`, `charlie-example.md`.
|
||||
|
||||
### Step 4: Enrich People Pages (Agent Judgment)
|
||||
|
||||
This is YOUR job (the agent). For each staging record:
|
||||
|
||||
1. **Check brain**: `gbrain search "contact name"` — do they have a
|
||||
`people/<slug>` page? Also search by email address.
|
||||
2. **Existing page** → merge the ground-truth fields (canonical name, emails,
|
||||
phone, organization) into the page, each with a
|
||||
`[Source: Google Contacts]` citation. Fix a wrong/partial display name.
|
||||
3. **No page** → apply the Notability Gate: create a page only if the contact
|
||||
already appears in the brain (calendar, email) or is clearly relevant.
|
||||
Otherwise skip.
|
||||
4. **Back-link** per the Iron Law in `skills/_brain-filing-rules.md`: an
|
||||
organization with a brain page gets a link from the person's page and back.
|
||||
|
||||
After enrichment, import and embed:
|
||||
```bash
|
||||
gbrain sync --no-pull --no-embed && gbrain embed --stale
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
gbrain search "alice-example" --limit 3
|
||||
```
|
||||
|
||||
Should return the enriched people page with contact details.
|
||||
|
||||
### Step 5: Set Up Weekly Sync
|
||||
|
||||
Contacts change slowly; once a week is plenty:
|
||||
```bash
|
||||
# Cron: every Sunday at 9 AM
|
||||
0 9 * * 0 cd /path/to/contacts-sync && node contacts-sync.mjs
|
||||
```
|
||||
|
||||
After each sync, re-run the Step 4 enrichment pass over CHANGED staging files
|
||||
only (compare mtime or diff against git), then:
|
||||
```bash
|
||||
gbrain sync --no-pull --no-embed && gbrain embed --stale
|
||||
```
|
||||
|
||||
### Step 6: Log Setup Completion
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.gbrain/integrations/contacts-to-brain
|
||||
echo '{"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","event":"setup_complete","source_version":"0.1.0","status":"ok","details":{"contacts":"CONTACT_COUNT"}}' >> ~/.gbrain/integrations/contacts-to-brain/heartbeat.jsonl
|
||||
```
|
||||
|
||||
Tell the user: "Contacts-to-brain is set up. [N] contacts staged; [M] people
|
||||
pages enriched with ground-truth contact data. Weekly sync keeps it current."
|
||||
|
||||
## Implementation Guide
|
||||
|
||||
### Pagination
|
||||
|
||||
```
|
||||
list_all_contacts():
|
||||
contacts = []
|
||||
token = null
|
||||
do:
|
||||
page = list_contacts({ pageSize: 100, pageToken: token,
|
||||
personFields: 'names,emailAddresses,phoneNumbers,organizations,metadata' })
|
||||
contacts += page.connections
|
||||
token = page.nextPageToken
|
||||
while token
|
||||
return contacts
|
||||
```
|
||||
|
||||
### Slug Normalization
|
||||
|
||||
```
|
||||
slugify(contact):
|
||||
name = contact.names?[0]?.displayName
|
||||
if not name:
|
||||
name = contact.emailAddresses?[0]?.value.split('@')[0]
|
||||
return name.toLowerCase().normalize('NFD')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
```
|
||||
|
||||
Collision (two contacts, same slug): suffix with the email domain
|
||||
(`alice-example-acme-example`) rather than overwrite.
|
||||
|
||||
### What the Agent Should Test After Setup
|
||||
|
||||
1. **Idempotency:** run the sync twice. `git status` on `brain/contacts/.staging/`
|
||||
shows no changes on the second run.
|
||||
2. **Pagination:** with 250+ contacts, verify the staging count matches the
|
||||
Google Contacts count (not capped at 100).
|
||||
3. **Notability Gate:** verify a one-off contact with no brain presence did NOT
|
||||
get a `people/` page.
|
||||
4. **Enrichment merge:** verify an existing people page gained contact fields
|
||||
without losing its prior content, each with a `[Source: Google Contacts]`
|
||||
citation.
|
||||
|
||||
## Cost Estimate
|
||||
|
||||
| Component | Monthly Cost |
|
||||
|-----------|-------------|
|
||||
| ClawVisor (free tier) | $0 |
|
||||
| Google People API | $0 (within free quota) |
|
||||
| **Total** | **$0** |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**No contacts returned:**
|
||||
- Check ClawVisor has the Google Contacts service activated
|
||||
- Check the standing task purpose is expansive enough
|
||||
- Option B: verify the People API is enabled and the token carries
|
||||
`contacts.readonly`
|
||||
|
||||
**Duplicate people pages after enrichment:**
|
||||
- The agent matched by name but the brain page slug differs — search by email
|
||||
address too before creating, then merge and delete the duplicate
|
||||
|
||||
**Contacts with no name:**
|
||||
- The sync script falls back to the email prefix; records with neither name
|
||||
nor email are dropped as noise
|
||||
@@ -23,7 +23,7 @@ health_checks:
|
||||
label: "Auth provider"
|
||||
checks:
|
||||
- type: http
|
||||
url: "$CLAWVISOR_URL/ready"
|
||||
url: "$CLAWVISOR_URL/health"
|
||||
label: "ClawVisor"
|
||||
- type: env_exists
|
||||
name: GOOGLE_CLIENT_ID
|
||||
@@ -75,7 +75,7 @@ Tell the user:
|
||||
3. Activate the services you need:
|
||||
- **Gmail** (for email-to-brain)
|
||||
- **Google Calendar** (for calendar-to-brain)
|
||||
- **Google Contacts** (for contacts-to-brain)
|
||||
- **Google Contacts** (for enrichment)
|
||||
4. Create a standing task with a broad purpose. CRITICAL: be EXPANSIVE.
|
||||
|
||||
Good purpose: 'Full executive assistant access to Gmail, Calendar, and
|
||||
@@ -88,7 +88,7 @@ Tell the user:
|
||||
|
||||
Validate:
|
||||
```bash
|
||||
curl -sf "$CLAWVISOR_URL/ready" \
|
||||
curl -sf "$CLAWVISOR_URL/health" \
|
||||
&& echo "PASS: ClawVisor reachable" \
|
||||
|| echo "FAIL: ClawVisor not reachable — check the URL"
|
||||
```
|
||||
@@ -171,7 +171,7 @@ can now access your Google services."
|
||||
|
||||
## How to Verify
|
||||
|
||||
1. **ClawVisor:** `curl $CLAWVISOR_URL/ready` returns OK.
|
||||
1. **ClawVisor:** `curl $CLAWVISOR_URL/health` returns OK.
|
||||
2. **Google OAuth:** Tokens exist at `~/.gbrain/google-tokens.json`.
|
||||
3. **Gmail access:** Run the email collector — it should pull recent messages.
|
||||
4. **Calendar access:** Run the calendar sync — it should pull today's events.
|
||||
|
||||
@@ -23,7 +23,7 @@ health_checks:
|
||||
label: "Auth provider"
|
||||
checks:
|
||||
- type: http
|
||||
url: "$CLAWVISOR_URL/ready"
|
||||
url: "$CLAWVISOR_URL/health"
|
||||
label: "ClawVisor"
|
||||
- type: env_exists
|
||||
name: GOOGLE_CLIENT_ID
|
||||
@@ -130,7 +130,7 @@ Tell the user:
|
||||
|
||||
Validate:
|
||||
```bash
|
||||
curl -sf "$CLAWVISOR_URL/ready" && echo "PASS: ClawVisor reachable" || echo "FAIL"
|
||||
curl -sf "$CLAWVISOR_URL/health" && echo "PASS: ClawVisor reachable" || echo "FAIL"
|
||||
```
|
||||
|
||||
**STOP until ClawVisor validates.**
|
||||
@@ -328,7 +328,7 @@ threads you already replied to. Sent mail acts as a negative filter.
|
||||
## Troubleshooting
|
||||
|
||||
**No emails collected:**
|
||||
- Check ClawVisor health: `curl $CLAWVISOR_URL/ready`
|
||||
- Check ClawVisor health: `curl $CLAWVISOR_URL/health`
|
||||
- Check standing task is active and has Gmail service enabled
|
||||
- Check task purpose is expansive enough (narrow purposes block requests)
|
||||
|
||||
|
||||
+43
-1
@@ -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', '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', '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']);
|
||||
// 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.
|
||||
@@ -106,6 +106,9 @@ 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
|
||||
@@ -1429,6 +1432,29 @@ 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') {
|
||||
@@ -2224,6 +2250,22 @@ 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
|
||||
|
||||
@@ -334,10 +334,6 @@ export async function scanIntegrity(
|
||||
if (!page) continue;
|
||||
// Skip grandfathered pages (opted out of brain-integrity enforcement)
|
||||
if ((page.frontmatter as Record<string, unknown> | undefined)?.validate === false) continue;
|
||||
// Skip code pages: indexed source files aren't prose. 'tweet' in an
|
||||
// identifier or comment is not a bare-tweet citation gap, and auto-repair
|
||||
// would inject wikilink brackets into source code.
|
||||
if (page.type === 'code') continue;
|
||||
pagesScanned++;
|
||||
bareHits.push(...findBareTweetHits(page.compiled_truth, slug));
|
||||
externalHits.push(...findExternalLinks(page.compiled_truth, slug));
|
||||
@@ -367,9 +363,6 @@ async function scanIntegrityBatch(
|
||||
// YAML) diverges from the sequential path's strict === false check. Intentional
|
||||
// — gbrain lint should reject stringly-typed validate at write time.
|
||||
const validateCondition = sql`AND (frontmatter->>'validate' IS NULL OR frontmatter->>'validate' != 'false')`;
|
||||
// Mirror of the sequential path's `page.type === 'code'` skip: code pages
|
||||
// (indexed source files) are never prose-integrity candidates.
|
||||
const codeCondition = sql`AND type IS DISTINCT FROM 'code'`;
|
||||
|
||||
// v0.32.8: scan ONE row per (source_id, slug) pair, not one per slug.
|
||||
// Pre-fix used DISTINCT ON (slug) which collapsed multi-source rows into
|
||||
@@ -379,7 +372,7 @@ async function scanIntegrityBatch(
|
||||
const rows = await sql`
|
||||
SELECT slug, compiled_truth, frontmatter
|
||||
FROM pages
|
||||
WHERE 1=1 ${typeCondition} ${validateCondition} ${codeCondition}
|
||||
WHERE 1=1 ${typeCondition} ${validateCondition}
|
||||
ORDER BY source_id, slug
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
@@ -468,9 +461,6 @@ async function cmdAuto(args: string[]): Promise<void> {
|
||||
|
||||
const page = await engine.getPage(slug, { sourceId: source_id });
|
||||
if (!page) continue;
|
||||
// Never auto-repair code pages — injecting tweet citations into
|
||||
// indexed source files corrupts them. Same gate as scanIntegrity.
|
||||
if (page.type === 'code') continue;
|
||||
|
||||
pagesProcessed++;
|
||||
progress.tick(1, slug);
|
||||
|
||||
@@ -816,6 +816,25 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -251,6 +251,14 @@ 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';
|
||||
}
|
||||
|
||||
@@ -263,5 +271,8 @@ export function isEvalCaptureEnabled(config: GBrainConfig | null | undefined): b
|
||||
* have explicit `capture: true`.
|
||||
*/
|
||||
export function isEvalScrubEnabled(config: GBrainConfig | null | undefined): boolean {
|
||||
return config?.eval?.scrub_pii !== false;
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -5208,18 +5208,9 @@ export class PGLiteEngine implements BrainEngine {
|
||||
const { rows: [h] } = await this.db.query(`
|
||||
WITH entity_pages AS (
|
||||
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
|
||||
),
|
||||
narrative_pages AS (
|
||||
-- Composition-aware score: code source files and calendar daily
|
||||
-- files are orphans-by-design (no inbound wikilinks, no Timeline
|
||||
-- fence). Excluding them from the orphan/link-density/timeline
|
||||
-- denominators keeps a bulk code/calendar import from cratering
|
||||
-- brain_score.
|
||||
SELECT id FROM pages WHERE type IS NULL OR type NOT IN ('code', 'calendar-index')
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM pages) as page_count,
|
||||
(SELECT count(*) FROM narrative_pages) as narrative_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,
|
||||
@@ -5231,8 +5222,7 @@ export class PGLiteEngine implements BrainEngine {
|
||||
) as dead_links,
|
||||
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NULL) as missing_embeddings,
|
||||
(SELECT count(*) FROM links) as link_count,
|
||||
(SELECT count(DISTINCT te.page_id) FROM timeline_entries te
|
||||
WHERE te.page_id IN (SELECT id FROM narrative_pages)) as pages_with_timeline,
|
||||
(SELECT count(DISTINCT page_id) FROM timeline_entries) as pages_with_timeline,
|
||||
(SELECT count(*) FROM entity_pages e
|
||||
WHERE EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = e.id))::float /
|
||||
GREATEST((SELECT count(*) FROM entity_pages), 1)::float as link_coverage,
|
||||
@@ -5254,19 +5244,12 @@ export class PGLiteEngine implements BrainEngine {
|
||||
const { rows: islandedRows } = await this.db.query(`
|
||||
SELECT p.slug
|
||||
FROM pages p
|
||||
-- Narrative pages only (same type filter as the narrative_pages CTE):
|
||||
-- code/calendar-index pages are orphans-by-design and must not count
|
||||
-- against the noOrphans component (#1144).
|
||||
WHERE (p.type IS NULL OR p.type NOT IN ('code', 'calendar-index'))
|
||||
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
|
||||
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);
|
||||
// Composition-aware denominators (excludes code/calendar-index pages);
|
||||
// a code-only brain has nothing narrative to penalize → full marks.
|
||||
const narrativePageCount = Number(r.narrative_page_count);
|
||||
const embedCoverage = Number(r.embed_coverage);
|
||||
const stalePages = await this.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS });
|
||||
const orphanOverrides = await loadOrphanPolicyOverrides(this);
|
||||
@@ -5276,9 +5259,9 @@ export class PGLiteEngine implements BrainEngine {
|
||||
const linkCount = Number(r.link_count);
|
||||
const pagesWithTimeline = Number(r.pages_with_timeline);
|
||||
|
||||
const linkDensity = narrativePageCount > 0 ? Math.min(linkCount / narrativePageCount, 1) : 1;
|
||||
const timelineCoverageDensity = narrativePageCount > 0 ? Math.min(pagesWithTimeline / narrativePageCount, 1) : 1;
|
||||
const noOrphans = narrativePageCount > 0 ? 1 - (orphanPages / narrativePageCount) : 1;
|
||||
const linkDensity = pageCount > 0 ? Math.min(linkCount / pageCount, 1) : 0;
|
||||
const timelineCoverageDensity = pageCount > 0 ? Math.min(pagesWithTimeline / pageCount, 1) : 0;
|
||||
const noOrphans = pageCount > 0 ? 1 - (orphanPages / pageCount) : 1;
|
||||
const noDeadLinks = pageCount > 0 ? 1 - Math.min(deadLinks / pageCount, 1) : 1;
|
||||
// Bug 11 — per-component points. Sum equals brainScore by construction
|
||||
// so `doctor` can render a breakdown that adds up to the total.
|
||||
|
||||
@@ -5328,18 +5328,9 @@ export class PostgresEngine implements BrainEngine {
|
||||
const [h] = await sql`
|
||||
WITH entity_pages AS (
|
||||
SELECT id, slug FROM pages WHERE type IN ('person', 'company')
|
||||
),
|
||||
narrative_pages AS (
|
||||
-- Composition-aware score: code source files and calendar daily
|
||||
-- files are orphans-by-design (no inbound wikilinks, no Timeline
|
||||
-- fence). Excluding them from the orphan/link-density/timeline
|
||||
-- denominators keeps a bulk code/calendar import from cratering
|
||||
-- brain_score.
|
||||
SELECT id FROM pages WHERE type IS NULL OR type NOT IN ('code', 'calendar-index')
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*) FROM pages) as page_count,
|
||||
(SELECT count(*) FROM narrative_pages) as narrative_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,
|
||||
@@ -5349,8 +5340,7 @@ export class PostgresEngine implements BrainEngine {
|
||||
) as dead_links,
|
||||
(SELECT count(*) FROM content_chunks WHERE embedded_at IS NULL) as missing_embeddings,
|
||||
(SELECT count(*) FROM links) as link_count,
|
||||
(SELECT count(DISTINCT te.page_id) FROM timeline_entries te
|
||||
WHERE te.page_id IN (SELECT id FROM narrative_pages)) as pages_with_timeline,
|
||||
(SELECT count(DISTINCT page_id) FROM timeline_entries) as pages_with_timeline,
|
||||
(SELECT count(*) FROM entity_pages e
|
||||
WHERE EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = e.id))::float /
|
||||
GREATEST((SELECT count(*) FROM entity_pages), 1)::float as link_coverage,
|
||||
@@ -5371,18 +5361,11 @@ export class PostgresEngine implements BrainEngine {
|
||||
const islandedRows = await sql<{ slug: string }[]>`
|
||||
SELECT p.slug
|
||||
FROM pages p
|
||||
-- Narrative pages only (same type filter as the narrative_pages CTE):
|
||||
-- code/calendar-index pages are orphans-by-design and must not count
|
||||
-- against the noOrphans component (#1144).
|
||||
WHERE (p.type IS NULL OR p.type NOT IN ('code', 'calendar-index'))
|
||||
AND NOT EXISTS (SELECT 1 FROM links l WHERE l.to_page_id = p.id)
|
||||
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);
|
||||
// Composition-aware denominators (excludes code/calendar-index pages);
|
||||
// a code-only brain has nothing narrative to penalize → full marks.
|
||||
const narrativePageCount = Number(h.narrative_page_count);
|
||||
const embedCoverage = Number(h.embed_coverage);
|
||||
const stalePages = await this.countStalePagesForExtraction({ versionTs: LINK_EXTRACTOR_VERSION_TS });
|
||||
const orphanOverrides = await loadOrphanPolicyOverrides(this);
|
||||
@@ -5392,9 +5375,9 @@ export class PostgresEngine implements BrainEngine {
|
||||
const pagesWithTimeline = Number(h.pages_with_timeline);
|
||||
|
||||
// brain_score: 0-100 weighted average
|
||||
const linkDensity = narrativePageCount > 0 ? Math.min(linkCount / narrativePageCount, 1) : 1;
|
||||
const timelineCoverageWhole = narrativePageCount > 0 ? Math.min(pagesWithTimeline / narrativePageCount, 1) : 1;
|
||||
const noOrphans = narrativePageCount > 0 ? 1 - (orphanPages / narrativePageCount) : 1;
|
||||
const linkDensity = pageCount > 0 ? Math.min(linkCount / pageCount, 1) : 0;
|
||||
const timelineCoverageWhole = pageCount > 0 ? Math.min(pagesWithTimeline / pageCount, 1) : 0;
|
||||
const noOrphans = pageCount > 0 ? 1 - (orphanPages / pageCount) : 1;
|
||||
const noDeadLinks = pageCount > 0 ? 1 - Math.min(deadLinks / pageCount, 1) : 1;
|
||||
// Per-component points. Sum equals brainScore by construction.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// #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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -119,40 +119,6 @@ describe('Bug 11 — orphan_pages is "no inbound links"', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1144 — brain_score is composition-aware', () => {
|
||||
test('code and calendar-index pages do not count as orphans or dilute density metrics', async () => {
|
||||
// Two linked narrative pages + a flood of orphan-by-design pages.
|
||||
await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: 'a', frontmatter: {} });
|
||||
await engine.putPage('people/bob', { type: 'person', title: 'Bob', compiled_truth: 'b', frontmatter: {} });
|
||||
const aId = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug='people/alice'`)).rows[0].id;
|
||||
const bId = (await (engine as any).db.query(`SELECT id FROM pages WHERE slug='people/bob'`)).rows[0].id;
|
||||
await (engine as any).db.query(
|
||||
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'mentions')`,
|
||||
[aId, bId],
|
||||
);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await engine.putPage(`code/src/f${i}.py`, { type: 'code', title: `f${i}.py`, compiled_truth: 'def f(): pass', frontmatter: {} });
|
||||
}
|
||||
await engine.putPage('daily/calendar/2026-01-01', { type: 'calendar-index', title: '2026-01-01', compiled_truth: 'events', frontmatter: {} });
|
||||
|
||||
const h = await engine.getHealth();
|
||||
// 11 unlinked code/calendar pages exist, but both narrative pages are
|
||||
// linked → 0 orphans, full no-orphans marks.
|
||||
expect(h.orphan_pages).toBe(0);
|
||||
expect(h.no_orphans_score).toBe(15);
|
||||
// Link density: 1 link / 2 narrative pages, not 1 / 13 total pages.
|
||||
expect(h.link_density_score).toBe(Math.round(0.5 * 25));
|
||||
});
|
||||
|
||||
test('a brain with ONLY non-narrative pages gets full composition marks', async () => {
|
||||
await engine.putPage('code/src/only.py', { type: 'code', title: 'only.py', compiled_truth: 'x = 1', frontmatter: {} });
|
||||
const h = await engine.getHealth();
|
||||
expect(h.no_orphans_score).toBe(15);
|
||||
expect(h.link_density_score).toBe(25);
|
||||
expect(h.timeline_coverage_score).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug 11 — doctor renders brain_score breakdown', () => {
|
||||
test('doctor source contains brain_score breakdown rendering', async () => {
|
||||
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
||||
|
||||
@@ -763,48 +763,3 @@ describeBoth('Engine parity — federated sourceIds[] secondary reads (#2200)',
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #1144 — brain_score is composition-aware: code / calendar-index pages are
|
||||
// orphans-by-design and must not feed the orphan/link-density/timeline
|
||||
// denominators. Both engines must agree.
|
||||
async function seedComposition(eng: BrainEngine) {
|
||||
await eng.putPage('people/comp-alice', { type: 'person', title: 'Alice', compiled_truth: 'a', timeline: '' });
|
||||
await eng.putPage('people/comp-bob', { type: 'person', title: 'Bob', compiled_truth: 'b', timeline: '' });
|
||||
await eng.addLink('people/comp-alice', 'people/comp-bob', 'knows', 'mentions', 'markdown');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await eng.putPage(`code/src/comp-f${i}.py`, { type: 'code', title: `f${i}.py`, compiled_truth: 'def f(): pass', timeline: '' });
|
||||
}
|
||||
await eng.putPage('daily/calendar/2026-01-01', { type: 'calendar-index', title: '2026-01-01', compiled_truth: 'events', timeline: '' });
|
||||
}
|
||||
|
||||
describeBoth('Engine parity — getHealth composition-aware brain_score (#1144)', () => {
|
||||
let pgEngine: BrainEngine;
|
||||
let pgliteEngine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
pgEngine = await setupDB();
|
||||
await seedComposition(pgEngine);
|
||||
pgliteEngine = new PGLiteEngine();
|
||||
await pgliteEngine.connect({});
|
||||
await pgliteEngine.initSchema();
|
||||
await seedComposition(pgliteEngine);
|
||||
}, 90_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await pgliteEngine.disconnect();
|
||||
await teardownDB();
|
||||
}, 30_000);
|
||||
|
||||
test('orphan/link-density/timeline components identical and exclude code + calendar-index pages', async () => {
|
||||
const pg = await pgEngine.getHealth();
|
||||
const pglite = await pgliteEngine.getHealth();
|
||||
for (const k of ['orphan_pages', 'no_orphans_score', 'link_density_score', 'timeline_coverage_score', 'brain_score'] as const) {
|
||||
expect(pg[k]).toBe(pglite[k]);
|
||||
}
|
||||
// 6 non-narrative pages exist but both narrative pages are linked → 0 orphans.
|
||||
expect(pg.orphan_pages).toBe(0);
|
||||
expect(pg.no_orphans_score).toBe(15);
|
||||
// 1 link / 2 narrative pages, not / 8 total pages.
|
||||
expect(pg.link_density_score).toBe(Math.round(0.5 * 25));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,35 +140,6 @@ describeE2E('scanIntegrity batch parity (E2E, Postgres-only)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('code pages', () => {
|
||||
test('type:code page is skipped on both paths (#1143)', async () => {
|
||||
const engine = getEngine();
|
||||
|
||||
await engine.putPage('people/alice', {
|
||||
type: 'person',
|
||||
title: 'Alice',
|
||||
compiled_truth: 'Alice tweeted about something.',
|
||||
timeline: '',
|
||||
frontmatter: {},
|
||||
});
|
||||
await engine.putPage('code/src/bot.py', {
|
||||
type: 'code',
|
||||
title: 'src/bot.py (python)',
|
||||
compiled_truth: '# the user tweeted about this feature\ndef send_tweet():\n pass',
|
||||
timeline: '',
|
||||
frontmatter: { language: 'python', file: 'src/bot.py' },
|
||||
});
|
||||
|
||||
const batchResult = await scanIntegrity(engine, { limit: 100, batchLoad: true });
|
||||
const seqResult = await scanIntegrity(engine, { limit: 100, batchLoad: false });
|
||||
|
||||
expect(batchResult.pagesScanned).toBe(seqResult.pagesScanned);
|
||||
expect(batchResult.pagesScanned).toBe(1);
|
||||
expect(batchResult.bareHits.map(h => h.slug)).not.toContain('code/src/bot.py');
|
||||
expect(seqResult.bareHits.map(h => h.slug)).not.toContain('code/src/bot.py');
|
||||
});
|
||||
});
|
||||
|
||||
describe('topPages', () => {
|
||||
test('topPages ordering matches between paths', async () => {
|
||||
const engine = getEngine();
|
||||
|
||||
@@ -309,3 +309,62 @@ 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(); }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -201,15 +201,6 @@ describe('scanIntegrity', () => {
|
||||
timeline: '',
|
||||
frontmatter: { validate: false },
|
||||
});
|
||||
// Indexed source file — 'tweeted about' in a comment must NOT be flagged
|
||||
// as a bare-tweet citation gap (#1143: auto-repair would corrupt source).
|
||||
await engine.putPage('code/src/bot.py', {
|
||||
type: 'code',
|
||||
title: 'src/bot.py (python)',
|
||||
compiled_truth: '# the user tweeted about this feature\ndef send_tweet():\n pass',
|
||||
timeline: '',
|
||||
frontmatter: { language: 'python', file: 'src/bot.py' },
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -232,13 +223,6 @@ describe('scanIntegrity', () => {
|
||||
expect(slugs).not.toContain('people/legacy');
|
||||
});
|
||||
|
||||
test('skips type:code pages (#1143 — bare-tweet false positives on source files)', async () => {
|
||||
const res = await scanIntegrity(engine);
|
||||
const slugs = res.bareHits.map(h => h.slug);
|
||||
expect(slugs).not.toContain('code/src/bot.py');
|
||||
expect(res.pagesScanned).toBe(2);
|
||||
});
|
||||
|
||||
test('honors limit', async () => {
|
||||
const res = await scanIntegrity(engine, { limit: 1 });
|
||||
expect(res.pagesScanned).toBe(1);
|
||||
|
||||
@@ -302,4 +302,29 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,15 +218,21 @@ 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 leaked live entries.
|
||||
// After 50 reporter lifecycles, still exactly one handler and zero NEWLY leaked live entries.
|
||||
expect(__signalHandlerInstalledForTest()).toBe(installedBefore || true);
|
||||
expect(__liveReporterCountForTest()).toBe(0);
|
||||
expect(__liveReporterCountForTest()).toBe(liveBefore);
|
||||
});
|
||||
|
||||
test('startHeartbeat() fires heartbeats and stop() clears', async () => {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* #1098 — ClawVisor health checks must hit /ready (surfaces db/vault
|
||||
* degradation), never /health (bare liveness, masks those failure modes).
|
||||
* #1099 — the credential-gateway recipe tells users to activate Google
|
||||
* Contacts; the canonical consumer recipe must actually ship.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const RECIPES = join(import.meta.dir, '../recipes');
|
||||
|
||||
describe('#1098 — ClawVisor health checks use /ready', () => {
|
||||
for (const f of ['email-to-brain.md', 'calendar-to-brain.md', 'credential-gateway.md', 'contacts-to-brain.md']) {
|
||||
test(`${f} references $CLAWVISOR_URL/ready, never /health`, () => {
|
||||
const text = readFileSync(join(RECIPES, f), 'utf-8');
|
||||
expect(text).not.toContain('$CLAWVISOR_URL/health');
|
||||
expect(text).toContain('$CLAWVISOR_URL/ready');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#1099 — canonical contacts-to-brain recipe ships', () => {
|
||||
test('recipes/contacts-to-brain.md exists with the sibling frontmatter shape', () => {
|
||||
const p = join(RECIPES, 'contacts-to-brain.md');
|
||||
expect(existsSync(p)).toBe(true);
|
||||
const text = readFileSync(p, 'utf-8');
|
||||
expect(text).toContain('id: contacts-to-brain');
|
||||
expect(text).toContain('requires: [credential-gateway]');
|
||||
expect(text).toContain('health_checks:');
|
||||
expect(text).toContain('contacts.readonly');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user