mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 10:22:34 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58683eb9ac |
+1
-22
@@ -365,12 +365,6 @@ async function main() {
|
||||
if (def.required && params[key] === undefined) {
|
||||
if (queryHasAlt && key === 'query') continue;
|
||||
const cliName = op.cliHints?.name || op.name;
|
||||
// #2822: when the missing param is the op's stdin-fed one, the usage
|
||||
// line alone is misleading (the positionals may all be present — the
|
||||
// pipe was just empty). Name the real problem.
|
||||
if (op.cliHints?.stdin === key) {
|
||||
console.error(`Error: required "${key}" is missing — stdin was empty or not piped. Pipe content on stdin or pass --${key.replace(/_/g, '-')}.`);
|
||||
}
|
||||
const positional = op.cliHints?.positional || [];
|
||||
const usage = positional.map(p => `<${p}>`).join(' ');
|
||||
console.error(`Usage: gbrain ${cliName} ${usage}`);
|
||||
@@ -767,10 +761,6 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
|
||||
const params: Record<string, unknown> = {};
|
||||
const positional = op.cliHints?.positional || [];
|
||||
let posIdx = 0;
|
||||
// #2822: track which params came from positionals so a later flag that
|
||||
// silently discards one (`gbrain put CONTENT --slug foo` — CONTENT was
|
||||
// parsed as the slug) gets a stderr warning instead of vanishing.
|
||||
const positionallySet = new Set<string>();
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
@@ -788,20 +778,13 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
|
||||
if (paramDef?.type === 'boolean') {
|
||||
params[key] = true;
|
||||
} else if (i + 1 < args.length) {
|
||||
if (positionallySet.has(key) && params[key] !== args[i + 1]) {
|
||||
console.error(`Warning: ${arg} overrides the positional <${key}> value ${JSON.stringify(params[key])}.`);
|
||||
}
|
||||
params[key] = args[++i];
|
||||
if (paramDef?.type === 'number') params[key] = Number(params[key]);
|
||||
}
|
||||
} else if (posIdx < positional.length) {
|
||||
const key = positional[posIdx++];
|
||||
const paramDef = op.params[key];
|
||||
if (params[key] !== undefined && params[key] !== (paramDef?.type === 'number' ? Number(arg) : arg)) {
|
||||
console.error(`Warning: positional <${key}> overrides the earlier --${key.replace(/_/g, '-')} value ${JSON.stringify(params[key])}.`);
|
||||
}
|
||||
params[key] = paramDef?.type === 'number' ? Number(arg) : arg;
|
||||
positionallySet.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,11 +796,7 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
|
||||
console.error(`Error: stdin content exceeds ${MAX_STDIN} bytes. Split into smaller inputs.`);
|
||||
process.exit(1);
|
||||
}
|
||||
// #2822: empty/whitespace-only stdin (cron with no input, broken pipe)
|
||||
// stays UNSET so the required-param check rejects the call instead of
|
||||
// silently writing an empty page (0 chunks, invisible to search and
|
||||
// embed --stale).
|
||||
if (stdinContent.trim().length > 0) params[op.cliHints.stdin] = stdinContent;
|
||||
params[op.cliHints.stdin] = stdinContent;
|
||||
}
|
||||
|
||||
return params;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { runPhaseCalibrationProfile } from '../core/cycle/calibration-profile.ts';
|
||||
import { resolveCalibrationHolder, runPhaseCalibrationProfile } from '../core/cycle/calibration-profile.ts';
|
||||
import { sourceScopeOpts, type OperationContext } from '../core/operations.ts';
|
||||
import type { GBrainConfig } from '../core/config.ts';
|
||||
import { GBrainError } from '../core/types.ts';
|
||||
@@ -167,7 +167,7 @@ export async function runCalibration(
|
||||
config: GBrainConfig,
|
||||
): Promise<void> {
|
||||
const { opts } = parseArgs(args);
|
||||
const holder = opts.holder ?? 'garry';
|
||||
const holder = await resolveCalibrationHolder(engine, opts.holder);
|
||||
// Resolve --source / GBRAIN_SOURCE / .gbrain-source so the (now reachable, #2035)
|
||||
// calibration command targets the right source in a multi-source brain instead
|
||||
// of always reading `default`. No signal → 'default' (prior behavior).
|
||||
@@ -253,14 +253,14 @@ export async function getCalibrationProfileOp(
|
||||
ctx: OperationContext,
|
||||
params: { holder?: string },
|
||||
): Promise<CalibrationProfileRow | null> {
|
||||
const holder = params.holder ?? 'garry';
|
||||
if (typeof holder !== 'string' || holder.length === 0) {
|
||||
if (params.holder !== undefined && (typeof params.holder !== 'string' || params.holder.length === 0)) {
|
||||
throw new GBrainError(
|
||||
'INVALID_HOLDER',
|
||||
'get_calibration_profile.holder must be a non-empty string',
|
||||
'pass holder="<slug>" or omit to default to "garry"',
|
||||
'pass holder="<slug>" or omit to default to the calibration.user_holder config (then "garry")',
|
||||
);
|
||||
}
|
||||
const holder = await resolveCalibrationHolder(ctx.engine, params.holder);
|
||||
const scope = sourceScopeOpts(ctx);
|
||||
return getLatestProfile(ctx.engine, { holder, ...scope });
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ const FRONTMATTER_RULE_NAMES: Record<ParseValidationCode, string> = {
|
||||
NESTED_QUOTES: 'frontmatter-nested-quotes',
|
||||
NON_STRING_FIELD: 'frontmatter-non-string-field',
|
||||
EMPTY_FRONTMATTER: 'frontmatter-empty',
|
||||
MULTI_FRONTMATTER: 'frontmatter-multi',
|
||||
};
|
||||
|
||||
/** Codes whose lint findings are fixable by `gbrain frontmatter validate --fix`. */
|
||||
|
||||
@@ -928,6 +928,10 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [
|
||||
// Emotional weight (v0.29)
|
||||
'emotional_weight.high_tags',
|
||||
'emotional_weight.user_holder',
|
||||
// Calibration holder (#1726): persistent default for the nightly
|
||||
// calibration_profile phase + `gbrain calibration`, symmetric with
|
||||
// emotional_weight.user_holder. Falls back to 'garry' when unset.
|
||||
'calibration.user_holder',
|
||||
// Cycle phase config
|
||||
'cycle.grade_takes.write_gstack_learnings',
|
||||
// Content sanity (v0.41)
|
||||
|
||||
@@ -96,7 +96,7 @@ export type PatternStatementsGenerator = (input: {
|
||||
export type BiasTagsGenerator = (patterns: string[]) => Promise<string[]>;
|
||||
|
||||
export interface CalibrationProfileOpts extends BasePhaseOpts {
|
||||
/** Holder to generate the profile for. Default 'garry'. */
|
||||
/** Holder to generate the profile for. Default: `calibration.user_holder` config, then 'garry'. */
|
||||
holder?: string;
|
||||
/** Inject the patterns generator (tests). */
|
||||
patternsGenerator?: PatternStatementsGenerator;
|
||||
@@ -194,6 +194,26 @@ export function parseBiasTagsOutput(raw: string): string[] {
|
||||
.slice(0, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* #1726: resolve the calibration holder. Explicit param wins, then the
|
||||
* persistent `calibration.user_holder` config key (symmetric with
|
||||
* emotional_weight.user_holder), then the legacy 'garry' default. Fail-open:
|
||||
* a missing config table / mock engine without getConfig falls through.
|
||||
*/
|
||||
export async function resolveCalibrationHolder(
|
||||
engine: BrainEngine,
|
||||
explicit?: string,
|
||||
): Promise<string> {
|
||||
if (explicit) return explicit;
|
||||
try {
|
||||
const configured = await engine.getConfig('calibration.user_holder');
|
||||
if (configured && configured.trim().length > 0) return configured.trim();
|
||||
} catch {
|
||||
// Config unavailable — use the legacy default.
|
||||
}
|
||||
return 'garry';
|
||||
}
|
||||
|
||||
/** Pick the "loudest" pattern slot for the template fallback. */
|
||||
function pickFallbackSlots(scorecard: TakesScorecard): PatternStatementSlots {
|
||||
if (!scorecard || scorecard.resolved === 0) {
|
||||
@@ -227,7 +247,7 @@ class CalibrationProfilePhase extends BaseCyclePhase {
|
||||
_ctx: OperationContext,
|
||||
opts: CalibrationProfileOpts,
|
||||
): Promise<{ summary: string; details: Record<string, unknown>; status?: PhaseStatus }> {
|
||||
const holder = opts.holder ?? 'garry';
|
||||
const holder = await resolveCalibrationHolder(engine, opts.holder);
|
||||
const promptVersion = opts.promptVersion ?? CALIBRATION_PROFILE_PROMPT_VERSION;
|
||||
const modelId = opts.model ?? TIER_DEFAULTS.reasoning;
|
||||
const gradeCompletion = opts.gradeCompletion ?? 1.0;
|
||||
|
||||
@@ -68,11 +68,14 @@ export async function extractTimelineFromMeetings(
|
||||
// 1. Fetch all meeting pages (one round-trip).
|
||||
const sourceFilter = opts.sourceIdFilter ? `AND source_id = $1` : '';
|
||||
const meetingParams = opts.sourceIdFilter ? [opts.sourceIdFilter] : [];
|
||||
// #2109: gbrain-base-v2's unify-types catch-all retypes meeting pages to
|
||||
// `note` with frontmatter.legacy_type = 'meeting'. Match both spellings so
|
||||
// the extractor keeps working on migrated (v2) brains, not just v1 ones.
|
||||
const meetings = await engine.executeRaw<MeetingRow>(
|
||||
`SELECT slug, source_id, title, effective_date, updated_at,
|
||||
compiled_truth, COALESCE(timeline, '') AS timeline
|
||||
FROM pages
|
||||
WHERE type = 'meeting'
|
||||
WHERE (type = 'meeting' OR frontmatter ->> 'legacy_type' = 'meeting')
|
||||
AND deleted_at IS NULL
|
||||
${sourceFilter}
|
||||
ORDER BY effective_date DESC NULLS LAST, slug`,
|
||||
@@ -94,7 +97,7 @@ export async function extractTimelineFromMeetings(
|
||||
JOIN pages pf ON pf.id = l.from_page_id
|
||||
JOIN pages pt ON pt.id = l.to_page_id
|
||||
WHERE l.link_type = 'attended'
|
||||
AND pf.type = 'meeting'
|
||||
AND (pf.type = 'meeting' OR pf.frontmatter ->> 'legacy_type' = 'meeting')
|
||||
AND pf.deleted_at IS NULL
|
||||
AND pt.deleted_at IS NULL`,
|
||||
);
|
||||
|
||||
+1
-22
@@ -301,17 +301,6 @@ export async function importFromContent(
|
||||
// silently fabricated a duplicate at (default, slug) — causing later
|
||||
// bare-slug subqueries (getTags, deleteChunks, etc.) to crash with 21000.
|
||||
const sourceId = opts.sourceId;
|
||||
// #2822: reject empty/whitespace-only content before any work happens. An
|
||||
// empty page writes 0 chunks — invisible to search AND to `embed --stale`
|
||||
// (nothing to embed), so the mistake never surfaces. Empty content is
|
||||
// always a caller bug (empty piped stdin, bad shell substitution). Thrown
|
||||
// (not returned) so every wrapper site surfaces the message, matching the
|
||||
// ContentSanityBlockError flow.
|
||||
if (content.trim().length === 0) {
|
||||
throw new Error(
|
||||
`Content for "${slug}" is empty; refusing to write an empty page (0 chunks would be invisible to search and embed --stale).`,
|
||||
);
|
||||
}
|
||||
// Reject oversized payloads before any parsing, chunking, or embedding happens.
|
||||
// Uses Buffer.byteLength to count UTF-8 bytes the same way disk size would,
|
||||
// so the network path behaves identically to the file path.
|
||||
@@ -325,17 +314,7 @@ export async function importFromContent(
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseMarkdown(content, slug + '.md', { activePack: opts.activePack, validate: true });
|
||||
|
||||
// #2743: reject stacked frontmatter (the double-put corruption class —
|
||||
// already-serialized markdown re-wrapped in fresh frontmatter). gray-matter
|
||||
// parses only the first block; the second would land verbatim in the body
|
||||
// and poison every subsequent round-trip. Only MULTI_FRONTMATTER rejects
|
||||
// here — the other validation codes keep their lint-only semantics.
|
||||
const multiFm = parsed.errors?.find(e => e.code === 'MULTI_FRONTMATTER');
|
||||
if (multiFm) {
|
||||
throw new Error(`MULTI_FRONTMATTER: ${multiFm.message} (slug "${slug}", line ${multiFm.line})`);
|
||||
}
|
||||
const parsed = parseMarkdown(content, slug + '.md', { activePack: opts.activePack });
|
||||
|
||||
// v0.42 (#1699 trust boundary): strip gate-owned markers from UNTRUSTED
|
||||
// input. parseMarkdown preserves every frontmatter key except type/title/
|
||||
|
||||
+1
-46
@@ -11,8 +11,7 @@ export type ParseValidationCode =
|
||||
| 'NULL_BYTES'
|
||||
| 'NESTED_QUOTES'
|
||||
| 'NON_STRING_FIELD'
|
||||
| 'EMPTY_FRONTMATTER'
|
||||
| 'MULTI_FRONTMATTER';
|
||||
| 'EMPTY_FRONTMATTER';
|
||||
|
||||
export interface ParseValidationError {
|
||||
code: ParseValidationCode;
|
||||
@@ -332,50 +331,6 @@ function collectValidationErrors(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 9. MULTI_FRONTMATTER (#2743) — a second ---…--- block right after the
|
||||
// closing fence is stacked frontmatter (the double-put corruption class:
|
||||
// already-serialized markdown re-wrapped in fresh frontmatter).
|
||||
// gray-matter parses only the first block and silently leaves the second
|
||||
// in the body. Heuristic: first non-empty line after the close is `---`,
|
||||
// a later `---` closes it, EVERY line between is frontmatter-shaped
|
||||
// (YAML `key:`, `- ` list item, `#` comment, indented continuation, or
|
||||
// blank — the issue's "stop at the first non-frontmatter character"
|
||||
// spec), and at least one is a `key:` line. A lone `---` stays a
|
||||
// markdown horizontal rule, and an hrule followed by prose — even
|
||||
// colon-prefixed prose like `Note: …` mixed with plain lines — is body
|
||||
// content, not a stacked block.
|
||||
let afterClose = closeLine + 1;
|
||||
while (afterClose < lines.length && lines[afterClose].trim().length === 0) afterClose++;
|
||||
if (afterClose < lines.length && lines[afterClose].trim() === '---') {
|
||||
let secondClose = -1;
|
||||
for (let i = afterClose + 1; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed === '---') {
|
||||
secondClose = i;
|
||||
break;
|
||||
}
|
||||
const yamlShaped =
|
||||
trimmed.length === 0 ||
|
||||
/^[A-Za-z_][\w-]*\s*:/.test(trimmed) ||
|
||||
trimmed.startsWith('- ') ||
|
||||
trimmed === '-' ||
|
||||
trimmed.startsWith('#') ||
|
||||
/^\s/.test(lines[i]);
|
||||
if (!yamlShaped) break; // first non-frontmatter line → body prose, not a stacked block
|
||||
}
|
||||
if (
|
||||
secondClose > afterClose + 1 &&
|
||||
lines.slice(afterClose + 1, secondClose).some(l => /^\s*[A-Za-z_][\w-]*\s*:/.test(l))
|
||||
) {
|
||||
errors.push({
|
||||
code: 'MULTI_FRONTMATTER',
|
||||
message:
|
||||
'Stacked frontmatter: a second ---…--- block follows the frontmatter (double-put corruption); merge into a single frontmatter block',
|
||||
line: afterClose + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4562,7 +4562,10 @@ const list_schema_packs: Operation = {
|
||||
const { existsSync, readdirSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
const { gbrainPath } = await import('./config.ts');
|
||||
const bundled = ['gbrain-base', 'gbrain-recommended'];
|
||||
// #1726: derive from the locator's registry instead of a hand-copied
|
||||
// subset (which had frozen at 2 of 7 bundled packs).
|
||||
const { BUNDLED_PACKS } = await import('./schema-pack/load-active.ts');
|
||||
const bundled = [...BUNDLED_PACKS];
|
||||
const installedDir = gbrainPath('schema-packs');
|
||||
const installed: string[] = [];
|
||||
if (existsSync(installedDir)) {
|
||||
|
||||
@@ -41,6 +41,12 @@ migration_from:
|
||||
pack: gbrain-base
|
||||
version: "1.x"
|
||||
|
||||
# #2117 — cycle-phase participation. `phases:` is additive and pack-gated;
|
||||
# without this key extract_atoms is silently off on v2 brains even though
|
||||
# onboard + doctor recommend it (v2 declares the `atom` type it writes).
|
||||
phases:
|
||||
- extract_atoms
|
||||
|
||||
page_types:
|
||||
- name: person
|
||||
primitive: entity
|
||||
@@ -319,6 +325,10 @@ page_types:
|
||||
extractable: false
|
||||
expert_routing: false
|
||||
|
||||
# #2117 — inference rules ported from gbrain-base v1 so extract-ner keeps
|
||||
# working on v2 brains (it hard-skips with pack_unavailable when no
|
||||
# link_type declares an inference.regex). Same ReDoS-guarded sketch
|
||||
# regexes v1 ships; production matchers in link-extraction.ts still apply.
|
||||
link_types:
|
||||
- name: partner_of
|
||||
inverse: partner_of
|
||||
@@ -328,14 +338,24 @@ link_types:
|
||||
- name: discusses
|
||||
- name: founded
|
||||
inverse: founded_by
|
||||
inference:
|
||||
regex: \b(founded|founder of|co-?founded|started)\b
|
||||
- name: works_at
|
||||
inverse: employs
|
||||
inference:
|
||||
regex: \b(works? at|employed by|works? for|joined|hired by|ceo of|cto of|cmo of)\b
|
||||
- name: invested_in
|
||||
inverse: investor_of
|
||||
inference:
|
||||
regex: \b(invested in|backed|seeded|funded|wrote a check)\b
|
||||
- name: sourced_from
|
||||
- name: derived_from
|
||||
- name: supersedes
|
||||
- name: redirects_to
|
||||
# NOTE: v1's `attended` inference is page_type-bound to `meeting`, which
|
||||
# v2 does not declare (lint: link_types_undeclared_page_type). Meeting
|
||||
# pages retyped by unify-types are matched via frontmatter.legacy_type
|
||||
# in extract-timeline-from-meetings (#2109) instead.
|
||||
- name: attended
|
||||
inverse: attended_by
|
||||
- name: authored
|
||||
|
||||
@@ -91,29 +91,33 @@ export function _resetPackLocatorForTests(): void {
|
||||
* Returns null when the pack is not found. Callers handle null by
|
||||
* throwing UnknownPackError with a paste-ready install hint.
|
||||
*/
|
||||
// v0.39 T8 — bundled packs registry. gbrain-base + gbrain-recommended
|
||||
// ship in src/core/schema-pack/base/. Add a new entry here to bundle
|
||||
// additional canonical packs.
|
||||
//
|
||||
// v0.41 T4 — lens packs join the bundle: creator (atoms + concepts +
|
||||
// extract_atoms/synthesize_concepts phases), investor (theses + bet
|
||||
// resolution + 3 calibration domains), engineer (gstack-learnings bridge
|
||||
// + 3 calibration domains), everything (meta-pack stacking all three
|
||||
// via extends + borrow_from). Each ships as a real YAML at base/<name>.yaml.
|
||||
//
|
||||
// #1726: exported so reporting surfaces (list_schema_packs) derive from the
|
||||
// same list the locator resolves — no more hand-copied 2-of-7 subsets.
|
||||
export const BUNDLED_PACKS: ReadonlyArray<string> = [
|
||||
'gbrain-base',
|
||||
'gbrain-recommended',
|
||||
'gbrain-creator',
|
||||
'gbrain-investor',
|
||||
'gbrain-engineer',
|
||||
'gbrain-everything',
|
||||
// v0.42 type-unification: 15-type canonical successor to gbrain-base.
|
||||
// Ships as install default (Lane E T17) + via gbrain onboard pack
|
||||
// upgrade flow (the unify-types Minion handler).
|
||||
'gbrain-base-v2',
|
||||
];
|
||||
|
||||
function defaultPackLocator(name: string): string | null {
|
||||
// v0.39 T8 — bundled packs registry. gbrain-base + gbrain-recommended
|
||||
// ship in src/core/schema-pack/base/. Add a new entry here to bundle
|
||||
// additional canonical packs.
|
||||
//
|
||||
// v0.41 T4 — lens packs join the bundle: creator (atoms + concepts +
|
||||
// extract_atoms/synthesize_concepts phases), investor (theses + bet
|
||||
// resolution + 3 calibration domains), engineer (gstack-learnings bridge
|
||||
// + 3 calibration domains), everything (meta-pack stacking all three
|
||||
// via extends + borrow_from). Each ships as a real YAML at base/<name>.yaml.
|
||||
const BUNDLED: ReadonlyArray<string> = [
|
||||
'gbrain-base',
|
||||
'gbrain-recommended',
|
||||
'gbrain-creator',
|
||||
'gbrain-investor',
|
||||
'gbrain-engineer',
|
||||
'gbrain-everything',
|
||||
// v0.42 type-unification: 15-type canonical successor to gbrain-base.
|
||||
// Ships as install default (Lane E T17) + via gbrain onboard pack
|
||||
// upgrade flow (the unify-types Minion handler).
|
||||
'gbrain-base-v2',
|
||||
];
|
||||
if (BUNDLED.includes(name)) {
|
||||
if (BUNDLED_PACKS.includes(name)) {
|
||||
// Resolve bundled YAML relative to this source file. Works in both
|
||||
// direct-bun execution and bun --compile binaries.
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -32,7 +32,7 @@ interface CapturedSql {
|
||||
params: unknown[];
|
||||
}
|
||||
|
||||
function buildMockEngine(opts: { scorecard: TakesScorecard }): {
|
||||
function buildMockEngine(opts: { scorecard: TakesScorecard; config?: Record<string, string> }): {
|
||||
engine: BrainEngine;
|
||||
captured: CapturedSql[];
|
||||
} {
|
||||
@@ -42,6 +42,9 @@ function buildMockEngine(opts: { scorecard: TakesScorecard }): {
|
||||
async getScorecard() {
|
||||
return opts.scorecard;
|
||||
},
|
||||
async getConfig(key: string) {
|
||||
return opts.config?.[key] ?? null;
|
||||
},
|
||||
async executeRaw<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
||||
captured.push({ sql, params: params ?? [] });
|
||||
return [];
|
||||
@@ -241,6 +244,36 @@ describe('runPhaseCalibrationProfile — phase integration', () => {
|
||||
expect(insert!.params[11]).toEqual(['over-confident-geography']); // active_bias_tags
|
||||
});
|
||||
|
||||
test('#1726: calibration.user_holder config drives the holder when no explicit opt', async () => {
|
||||
const { engine, captured } = buildMockEngine({
|
||||
scorecard: ENOUGH_RESOLVED_SCORECARD,
|
||||
config: { 'calibration.user_holder': 'alice-example' },
|
||||
});
|
||||
await runPhaseCalibrationProfile(buildCtx(engine), {
|
||||
patternsGenerator: async () => ['You call early-stage tactics well — 8 of 10 held up.'],
|
||||
biasTagsGenerator: async () => [],
|
||||
voiceGateJudge: passJudge,
|
||||
});
|
||||
const insert = captured.find(c => c.sql.includes('INSERT INTO calibration_profiles'));
|
||||
expect(insert).toBeDefined();
|
||||
expect(insert!.params[1]).toBe('alice-example'); // holder from config
|
||||
});
|
||||
|
||||
test('#1726: explicit holder opt wins over calibration.user_holder config', async () => {
|
||||
const { engine, captured } = buildMockEngine({
|
||||
scorecard: ENOUGH_RESOLVED_SCORECARD,
|
||||
config: { 'calibration.user_holder': 'alice-example' },
|
||||
});
|
||||
await runPhaseCalibrationProfile(buildCtx(engine), {
|
||||
holder: 'charlie-example',
|
||||
patternsGenerator: async () => ['You call early-stage tactics well — 8 of 10 held up.'],
|
||||
biasTagsGenerator: async () => [],
|
||||
voiceGateJudge: passJudge,
|
||||
});
|
||||
const insert = captured.find(c => c.sql.includes('INSERT INTO calibration_profiles'));
|
||||
expect(insert!.params[1]).toBe('charlie-example');
|
||||
});
|
||||
|
||||
test('default model is a provider-prefixed id, persisted to model_id (#2451)', async () => {
|
||||
const { engine, captured } = buildMockEngine({ scorecard: ENOUGH_RESOLVED_SCORECARD });
|
||||
const patternsGenerator: PatternStatementsGenerator = async () => [
|
||||
|
||||
+1
-70
@@ -1,8 +1,4 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { join, resolve } from 'path';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseOpArgs } from '../src/cli.ts';
|
||||
import { operationsByName } from '../src/core/operations.ts';
|
||||
|
||||
@@ -24,70 +20,5 @@ describe('parseOpArgs', () => {
|
||||
source_id: 'gstack-code-repo-0e4763c9',
|
||||
});
|
||||
});
|
||||
|
||||
describe('positional/flag overwrite warning (#2822)', () => {
|
||||
const errors: string[] = [];
|
||||
const origError = console.error;
|
||||
const captureErrors = () => {
|
||||
console.error = (...args: unknown[]) => errors.push(args.join(' '));
|
||||
};
|
||||
afterEach(() => {
|
||||
console.error = origError;
|
||||
errors.length = 0;
|
||||
});
|
||||
|
||||
test('a flag that overwrites a positional value warns to stderr', () => {
|
||||
captureErrors();
|
||||
const params = parseOpArgs(operationsByName.query, ['positional text', '--query', 'flag text']);
|
||||
expect(params.query).toBe('flag text');
|
||||
expect(errors.some(e => e.includes('Warning') && e.includes('--query'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a positional that overwrites an earlier flag value warns to stderr', () => {
|
||||
captureErrors();
|
||||
const params = parseOpArgs(operationsByName.query, ['--query', 'flag text', 'positional text']);
|
||||
expect(params.query).toBe('positional text');
|
||||
expect(errors.some(e => e.includes('Warning') && e.includes('<query>'))).toBe(true);
|
||||
});
|
||||
|
||||
test('no warning when flag and positional agree', () => {
|
||||
captureErrors();
|
||||
parseOpArgs(operationsByName.query, ['same', '--query', 'same']);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('gbrain put — empty non-TTY stdin rejects (#2822)', () => {
|
||||
const REPO = resolve(import.meta.dir, '..');
|
||||
const CLI = join(REPO, 'src', 'cli.ts');
|
||||
|
||||
const runPut = (input: string) => {
|
||||
// Isolated HOME so a regression can never write into a real brain.
|
||||
const home = mkdtempSync(join(tmpdir(), 'gbrain-put-empty-'));
|
||||
try {
|
||||
return spawnSync('bun', [CLI, 'put', 'inbox/empty-stdin-test'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
input,
|
||||
encoding: 'utf-8',
|
||||
timeout: 60_000,
|
||||
env: { ...process.env, HOME: home, GBRAIN_SKIP_STARTUP_HOOKS: '1' },
|
||||
});
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
test('empty stdin exits 1 and names the missing content param', () => {
|
||||
const res = runPut('');
|
||||
expect(res.status).toBe(1);
|
||||
expect(res.stderr).toContain('content');
|
||||
expect(res.stderr).toContain('stdin');
|
||||
}, 90_000);
|
||||
|
||||
test('whitespace-only stdin also exits 1', () => {
|
||||
const res = runPut(' \n\t\n');
|
||||
expect(res.status).toBe(1);
|
||||
expect(res.stderr).toContain('stdin');
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// #2109 — gbrain-base-v2's unify-types retypes meeting pages to `note`
|
||||
// with frontmatter.legacy_type='meeting'. extract-timeline-from-meetings
|
||||
// used to hardcode type='meeting' and silently scan 0 meetings on migrated
|
||||
// brains. These tests fail without the legacy_type fallback in both SQL
|
||||
// sites (meeting walk + attended-edge join).
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
import { extractTimelineFromMeetings } from '../src/core/extract-timeline-from-meetings.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
async function insertPage(opts: {
|
||||
slug: string;
|
||||
type: string;
|
||||
title: string;
|
||||
effectiveDate?: string;
|
||||
legacyType?: string;
|
||||
}): Promise<number> {
|
||||
const frontmatterLiteral = opts.legacyType
|
||||
? `'{"legacy_type": "${opts.legacyType}"}'::jsonb`
|
||||
: `'{}'::jsonb`;
|
||||
const rows = await engine.executeRaw<{ id: number }>(
|
||||
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline, effective_date, frontmatter)
|
||||
VALUES ($1, 'default', $2, $3, '', '', $4, ${frontmatterLiteral})
|
||||
RETURNING id`,
|
||||
[opts.slug, opts.type, opts.title, opts.effectiveDate ?? null],
|
||||
);
|
||||
return rows[0]!.id;
|
||||
}
|
||||
|
||||
describe('extractTimelineFromMeetings — legacy_type fallback (#2109)', () => {
|
||||
it('scans pages retyped to note with legacy_type=meeting and walks their attended edges', async () => {
|
||||
const meetingId = await insertPage({
|
||||
slug: 'meetings/2026-01-05',
|
||||
type: 'note', // post-unify-types shape on a gbrain-base-v2 brain
|
||||
legacyType: 'meeting',
|
||||
title: 'Weekly sync',
|
||||
effectiveDate: '2026-01-05',
|
||||
});
|
||||
const personId = await insertPage({
|
||||
slug: 'people/alice-example',
|
||||
type: 'person',
|
||||
title: 'Alice Example',
|
||||
});
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'attended')`,
|
||||
[meetingId, personId],
|
||||
);
|
||||
|
||||
const result = await extractTimelineFromMeetings(engine);
|
||||
expect(result.meetings_scanned).toBe(1);
|
||||
expect(result.entries_created).toBe(1);
|
||||
expect(result.entities_touched).toBe(1);
|
||||
expect(result.batch_errors).toBe(0);
|
||||
});
|
||||
|
||||
it('still scans pre-unify pages with type=meeting (v1 behavior preserved)', async () => {
|
||||
const meetingId = await insertPage({
|
||||
slug: 'meetings/2026-02-01',
|
||||
type: 'meeting',
|
||||
title: 'Board prep',
|
||||
effectiveDate: '2026-02-01',
|
||||
});
|
||||
const personId = await insertPage({
|
||||
slug: 'people/charlie-example',
|
||||
type: 'person',
|
||||
title: 'Charlie Example',
|
||||
});
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO links (from_page_id, to_page_id, link_type) VALUES ($1, $2, 'attended')`,
|
||||
[meetingId, personId],
|
||||
);
|
||||
|
||||
const result = await extractTimelineFromMeetings(engine);
|
||||
expect(result.meetings_scanned).toBe(1);
|
||||
expect(result.entries_created).toBe(1);
|
||||
});
|
||||
|
||||
it('does not scan unrelated note pages without legacy_type=meeting', async () => {
|
||||
await insertPage({
|
||||
slug: 'notes/random',
|
||||
type: 'note',
|
||||
title: 'Random note',
|
||||
effectiveDate: '2026-03-01',
|
||||
});
|
||||
const result = await extractTimelineFromMeetings(engine);
|
||||
expect(result.meetings_scanned).toBe(0);
|
||||
expect(result.entries_created).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -708,32 +708,3 @@ body unchanged
|
||||
expect(shortCircuited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFromContent — empty content guard (#2822)', () => {
|
||||
test('empty string throws instead of writing an invisible 0-chunk page', async () => {
|
||||
const engine = mockEngine();
|
||||
await expect(importFromContent(engine, 'inbox/empty', '', { noEmbed: true })).rejects.toThrow(/empty/i);
|
||||
expect((engine as any)._calls.find((c: any) => c.method === 'putPage')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('whitespace-only content throws', async () => {
|
||||
const engine = mockEngine();
|
||||
await expect(importFromContent(engine, 'inbox/ws', ' \n\t \n', { noEmbed: true })).rejects.toThrow(/empty/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importFromContent — stacked frontmatter rejection (#2743)', () => {
|
||||
test('double-put shaped content (two ---…--- blocks) throws MULTI_FRONTMATTER', async () => {
|
||||
const engine = mockEngine();
|
||||
const md = '---\ntitle: outer\n---\n\n---\ntitle: inner\ntype: concept\n---\n\nreal body';
|
||||
await expect(importFromContent(engine, 'inbox/double', md, { noEmbed: true })).rejects.toThrow(/MULTI_FRONTMATTER/);
|
||||
expect((engine as any)._calls.find((c: any) => c.method === 'putPage')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('normal content with horizontal rules in the body still imports', async () => {
|
||||
const engine = mockEngine();
|
||||
const md = '---\ntitle: ok\ntype: concept\n---\n\nprose before\n\n---\n\nprose after the rule';
|
||||
const result = await importFromContent(engine, 'inbox/hrule', md, { noEmbed: true });
|
||||
expect(result.status).toBe('imported');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -256,56 +256,6 @@ body`;
|
||||
});
|
||||
});
|
||||
|
||||
describe('MULTI_FRONTMATTER (#2743)', () => {
|
||||
test('stacked frontmatter immediately after the close fence', () => {
|
||||
const md = `${fence}\ntitle: outer\n${fence}\n${fence}\ntitle: inner\ntype: concept\n${fence}\n\nbody`;
|
||||
const parsed = parseMarkdown(md, undefined, { validate: true });
|
||||
expect(parsed.errors!.map(e => e.code)).toContain('MULTI_FRONTMATTER');
|
||||
});
|
||||
|
||||
test('stacked frontmatter with a blank line between blocks (serializeMarkdown shape)', () => {
|
||||
const md = `${fence}\ntitle: outer\n${fence}\n\n${fence}\ntitle: inner\n${fence}\n\nbody`;
|
||||
const parsed = parseMarkdown(md, undefined, { validate: true });
|
||||
expect(parsed.errors!.map(e => e.code)).toContain('MULTI_FRONTMATTER');
|
||||
});
|
||||
|
||||
test('horizontal rules in the body are NOT flagged', () => {
|
||||
const md = `${fence}\ntitle: ok\n${fence}\n\nsome prose\n\n${fence}\n\nmore prose\n\n${fence}\n\nend`;
|
||||
const parsed = parseMarkdown(md, undefined, { validate: true });
|
||||
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
|
||||
});
|
||||
|
||||
test('hrule pair at body start without YAML-shaped lines is NOT flagged', () => {
|
||||
const md = `${fence}\ntitle: ok\n${fence}\n\n${fence}\n\nplain prose between rules\n\n${fence}\n\nend`;
|
||||
const parsed = parseMarkdown(md, undefined, { validate: true });
|
||||
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
|
||||
});
|
||||
|
||||
test('timeline sentinel form is NOT flagged', () => {
|
||||
const md = `${fence}\ntitle: ok\n${fence}\n\nbody text\n\n${fence}\n\n## Timeline\n- 2024-01-01: thing`;
|
||||
const parsed = parseMarkdown(md, undefined, { validate: true });
|
||||
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
|
||||
});
|
||||
|
||||
test('body hrule + colon-prefixed prose (`Note: …`) mixed with plain lines is NOT flagged', () => {
|
||||
const md = `${fence}\ntitle: ok\ntype: concept\n${fence}\n\n${fence}\n\nNote: remember to follow up\n\nlots of plain prose here\n\n${fence}\n\nmore prose`;
|
||||
const parsed = parseMarkdown(md, undefined, { validate: true });
|
||||
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
|
||||
});
|
||||
|
||||
test('fence pairing stops at the first non-frontmatter line (no far-fence pairing across prose)', () => {
|
||||
const md = `${fence}\ntitle: ok\n${fence}\n\n${fence}\n\n${'plain prose line\n'.repeat(40)}TODO: fix the widget\n${'more prose\n'.repeat(40)}${fence}\nend`;
|
||||
const parsed = parseMarkdown(md, undefined, { validate: true });
|
||||
expect(parsed.errors!.map(e => e.code)).not.toContain('MULTI_FRONTMATTER');
|
||||
});
|
||||
|
||||
test('stacked block with list-valued keys is still flagged', () => {
|
||||
const md = `${fence}\ntitle: outer\n${fence}\n\n${fence}\ntitle: inner\ntags:\n - a\n - b\n${fence}\n\nbody`;
|
||||
const parsed = parseMarkdown(md, undefined, { validate: true });
|
||||
expect(parsed.errors!.map(e => e.code)).toContain('MULTI_FRONTMATTER');
|
||||
});
|
||||
});
|
||||
|
||||
test('error.line is set for line-bearing errors', () => {
|
||||
const md = `${fence}\ntype: concept\n${fence}\n# Heading inline\n\nbody\x00drop`;
|
||||
const parsed = parseMarkdown(md, undefined, { validate: true });
|
||||
|
||||
@@ -152,6 +152,19 @@ describe('list_schema_packs', () => {
|
||||
expect(result.installed).toContain('mine');
|
||||
});
|
||||
});
|
||||
|
||||
it('reports the full bundled registry, not a hand-copied subset (#1726)', async () => {
|
||||
await withEnv({ GBRAIN_HOME: tmpDir }, async () => {
|
||||
const { BUNDLED_PACKS } = await import('../src/core/schema-pack/load-active.ts');
|
||||
const result = await operationsByName.list_schema_packs!.handler(ctxOf(), {}) as { bundled: string[] };
|
||||
expect(result.bundled.slice().sort()).toEqual([...BUNDLED_PACKS].sort());
|
||||
// The lens packs that declare extract_atoms/synthesize_concepts phases
|
||||
// were the ones dropped by the frozen 2-pack literal.
|
||||
for (const name of ['gbrain-creator', 'gbrain-everything', 'gbrain-base-v2']) {
|
||||
expect(result.bundled).toContain(name);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── schema_stats ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// #2117 — gbrain-base-v2 shipped with no `phases:` declaration and zero
|
||||
// link_types[].inference regexes, so extract_atoms was silently pack-gated
|
||||
// off and extract-ner returned pack_unavailable on the bundled default pack.
|
||||
// These assertions fail against the pre-fix yaml.
|
||||
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { join } from 'node:path';
|
||||
import { loadPackFromFile } from '../src/core/schema-pack/loader.ts';
|
||||
import { linkTypesUndeclared } from '../src/core/schema-pack/lint-rules.ts';
|
||||
|
||||
const V2_PATH = join(import.meta.dir, '..', 'src', 'core', 'schema-pack', 'base', 'gbrain-base-v2.yaml');
|
||||
|
||||
describe('gbrain-base-v2 capability parity (#2117)', () => {
|
||||
const manifest = loadPackFromFile(V2_PATH);
|
||||
|
||||
it('declares the extract_atoms cycle phase', () => {
|
||||
expect(manifest.phases ?? []).toContain('extract_atoms');
|
||||
});
|
||||
|
||||
it('ships at least one link_type inference regex so extract-ner is not pack_unavailable', () => {
|
||||
// Mirrors the extract-ner hasRegex predicate exactly.
|
||||
const hasRegex = manifest.link_types.some(
|
||||
(lt) => lt.inference && typeof lt.inference === 'object' && 'regex' in lt.inference,
|
||||
);
|
||||
expect(hasRegex).toBe(true);
|
||||
});
|
||||
|
||||
it('ports the v1 inference verbs it declares link types for', () => {
|
||||
const withRegex = manifest.link_types
|
||||
.filter((lt) => lt.inference?.regex)
|
||||
.map((lt) => lt.name)
|
||||
.sort();
|
||||
expect(withRegex).toEqual(['founded', 'invested_in', 'works_at']);
|
||||
});
|
||||
|
||||
it('inference rules pass the undeclared-page-type lint (no meeting-bound inference)', async () => {
|
||||
const issues = await linkTypesUndeclared(manifest);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user