mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-15 01:12:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06eeb2890b |
@@ -233,13 +233,14 @@ 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/best.md` instead, so an optimization pass can never
|
||||
silently mutate a skill other people depend on. Two ways to handle that:
|
||||
`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:
|
||||
|
||||
```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/best.md (the proposed rewrite), prints its path. Copy what you want.
|
||||
# → writes skills/meeting-prep/skillopt/proposed.md, updates best.md, and prints the proposal path.
|
||||
|
||||
# Actually rewrite a bundled skill (explicit opt-in + an independent held-out set):
|
||||
gbrain skillopt brain-ops --split 1:1:1 --allow-mutate-bundled \
|
||||
|
||||
+5
-15
@@ -43,7 +43,7 @@ import {
|
||||
} from '../core/link-extraction.ts';
|
||||
import { createProgress } from '../core/progress.ts';
|
||||
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
|
||||
import { pathToSlug, slugifyPath, pruneDir, isSyncable } from '../core/sync.ts';
|
||||
import { pathToSlug, pruneDir, isSyncable } from '../core/sync.ts';
|
||||
// v0.41.18.0: withRetry + isRetryableConnError + WithRetryOpts moved to
|
||||
// src/core/retry.ts as the canonical primitive. Engine methods
|
||||
// (addLinksBatch/addTimelineEntriesBatch/upsertChunks) now self-retry via
|
||||
@@ -269,24 +269,14 @@ export function extractMarkdownLinks(content: string): { name: string; relTarget
|
||||
export function resolveSlug(fileDir: string, relTarget: string, allSlugs: Set<string>): string | null {
|
||||
const targetNoExt = relTarget.endsWith('.md') ? relTarget.slice(0, -3) : relTarget;
|
||||
|
||||
// Issue #1964: wikilinks carry raw Obsidian paths (`[[llm-wiki/entities/AI 3.0]]`)
|
||||
// but allSlugs holds sync-slugified slugs (`llm-wiki/entities/ai-3.0`). Try the
|
||||
// raw candidate first (back-compat), then the sync-consistent slugified form.
|
||||
const hit = (candidate: string): string | null => {
|
||||
if (allSlugs.has(candidate)) return candidate;
|
||||
const slugified = slugifyPath(candidate);
|
||||
if (slugified !== candidate && allSlugs.has(slugified)) return slugified;
|
||||
return null;
|
||||
};
|
||||
|
||||
const s1 = hit(join(fileDir, targetNoExt));
|
||||
if (s1) return s1;
|
||||
const s1 = join(fileDir, targetNoExt);
|
||||
if (allSlugs.has(s1)) return s1;
|
||||
|
||||
const parts = fileDir.split('/').filter(Boolean);
|
||||
for (let strip = 1; strip <= parts.length; strip++) {
|
||||
const ancestor = parts.slice(0, parts.length - strip).join('/');
|
||||
const candidate = hit(ancestor ? join(ancestor, targetNoExt) : targetNoExt);
|
||||
if (candidate) return candidate;
|
||||
const candidate = ancestor ? join(ancestor, targetNoExt) : targetNoExt;
|
||||
if (allSlugs.has(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import type { PageType } from './types.ts';
|
||||
import { ensureWellFormed } from './text-safe.ts';
|
||||
import { slugifyPath } from './sync.ts';
|
||||
|
||||
/**
|
||||
* v0.42.7 — link-extraction version stamp. Bump this ISO timestamp whenever the
|
||||
@@ -483,29 +482,14 @@ export async function extractPageLinks(
|
||||
// pre-v0.40.8.2 behavior of dropping bare wikilinks outside
|
||||
// DIR_PATTERN.
|
||||
if (ref.needsResolution) {
|
||||
if (typeof resolver.resolveBasenameMatches !== 'function') continue;
|
||||
if (!opts.globalBasename || typeof resolver.resolveBasenameMatches !== 'function') {
|
||||
continue;
|
||||
}
|
||||
// Issue #972 (codex): resolve by the wikilink TARGET (ref.slug — the
|
||||
// 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.
|
||||
//
|
||||
// Issue #1964: a dir-qualified wikilink (`[[llm-wiki/entities/AI 3.0]]`)
|
||||
// carries a raw Obsidian path while page slugs are sync-slugified
|
||||
// (`llm-wiki/entities/ai-3.0`). Slugify the path the same way sync does,
|
||||
// then match by exact slug or path-suffix (wiki-root-relative authoring).
|
||||
// This runs regardless of global_basename — it's dir-qualified, so the
|
||||
// cross-dir false-positive risk the flag guards against doesn't apply.
|
||||
// Mirrors the FS path's resolveSlug ancestor walk. Bare `[[name]]`
|
||||
// wikilinks still require the global_basename flag.
|
||||
let matches: string[] = [];
|
||||
const slugified = ref.slug.includes('/') ? slugifyPath(ref.slug) : '';
|
||||
if (slugified.includes('/')) {
|
||||
const tail = slugified.slice(slugified.lastIndexOf('/') + 1);
|
||||
matches = (await resolver.resolveBasenameMatches(tail))
|
||||
.filter(m => m === slugified || m.endsWith(`/${slugified}`));
|
||||
} else if (opts.globalBasename) {
|
||||
matches = await resolver.resolveBasenameMatches(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;
|
||||
|
||||
@@ -93,7 +93,13 @@ 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, bestPath, revertAllPending, skillPath, writeProposed } from './version-store.ts';
|
||||
import {
|
||||
acceptCandidate,
|
||||
proposedPath as proposedFilePath,
|
||||
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';
|
||||
@@ -702,9 +708,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') {
|
||||
// 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);
|
||||
// writeProposed() emitted both the best pointer and the stable review
|
||||
// artifact in the accept branch. SKILL.md remains untouched.
|
||||
proposedPath = proposedFilePath(skillsDir, skillName);
|
||||
} else if (mutateDecision.mutate) {
|
||||
mutatedSkillFile = finalOutcome === 'accepted';
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
*
|
||||
* history.json
|
||||
* best.md
|
||||
* proposed.md
|
||||
* versions/
|
||||
* v0001_e1_s1.md
|
||||
* v0002_e1_s2.md
|
||||
@@ -52,6 +53,10 @@ 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');
|
||||
}
|
||||
@@ -171,17 +176,18 @@ export function acceptCandidate(input: AcceptInput): AcceptResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* 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).
|
||||
*/
|
||||
export function writeProposed(skillsDir: string, skillName: string, candidateText: string): string {
|
||||
const p = bestPath(skillsDir, skillName);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
atomicWrite(p, candidateText);
|
||||
return p;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,6 +39,7 @@ 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';
|
||||
@@ -741,7 +742,7 @@ describe('skillopt T3 — F11 held-out gate, ablation opts, no-DB-pollution', ()
|
||||
} finally { fixture.cleanup(); }
|
||||
});
|
||||
|
||||
test('--no-mutate writes proposed.md (best.md), leaves SKILL.md untouched', async () => {
|
||||
test('--no-mutate writes proposed.md and best.md, leaves SKILL.md untouched', async () => {
|
||||
const fixture = setupFixture(SKILL_PEOPLE_ONLY, CITATIONS_BENCHMARK);
|
||||
try {
|
||||
installStub({
|
||||
@@ -753,10 +754,9 @@ 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).toBeDefined();
|
||||
// proposed.md (best.md) exists and carries the improvement.
|
||||
expect(fs.existsSync(result.proposedPath!)).toBe(true);
|
||||
expect(result.proposedPath).toBe(proposedPath(fixture.skillsDir, SKILL));
|
||||
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');
|
||||
|
||||
@@ -389,33 +389,6 @@ describe('resolveSlugAll', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── issue #1964: cross-directory wikilinks — slug/path mismatch ──────────
|
||||
|
||||
describe('issue #1964: raw Obsidian wikilink paths resolve to sync-slugified slugs', () => {
|
||||
test('resolveSlug slugifies the candidate (sync-consistent), no flag needed', () => {
|
||||
const all = new Set(['llm-wiki/entities/ai-3.0']);
|
||||
// Wikilink literal `[[llm-wiki/entities/AI 3.0]]` — spaces + uppercase.
|
||||
expect(resolveSlug('llm-wiki/notes', 'llm-wiki/entities/AI 3.0.md', all))
|
||||
.toBe('llm-wiki/entities/ai-3.0');
|
||||
});
|
||||
|
||||
test('resolveSlug slugifies raw (unslugified) fileDir too', () => {
|
||||
const all = new Set(['llm-wiki/entities/ai-3.0']);
|
||||
// fileDir comes from dirname(relPath) — the raw on-disk directory.
|
||||
expect(resolveSlug('LLM Wiki/Notes', 'entities/AI 3.0.md', all))
|
||||
.toBe('llm-wiki/entities/ai-3.0');
|
||||
});
|
||||
|
||||
test('extractLinksFromFile resolves cross-directory wikilink with flag OFF as a typed edge', async () => {
|
||||
const allSlugs = new Set(['llm-wiki/entities/ai-3.0', 'llm-wiki/notes/roadmap']);
|
||||
const content = '---\ntitle: Roadmap\ntype: concept\n---\n\nSee [[llm-wiki/entities/AI 3.0]].\n';
|
||||
const links = await extractLinksFromFile(content, 'llm-wiki/notes/roadmap.md', allSlugs);
|
||||
expect(links.map(l => l.to_slug)).toEqual(['llm-wiki/entities/ai-3.0']);
|
||||
// Dir-qualified path resolution is exact, NOT the basename fallback.
|
||||
expect(links[0].link_type).not.toBe('wikilink_basename');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue #972 repro: bare wikilinks resolve when flag is on', () => {
|
||||
// End-to-end: reproduces the issue's exact repro inside a tempdir +
|
||||
// PGLite, then asserts edge count under both flag states.
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
parseTimelineEntries,
|
||||
isAutoLinkEnabled,
|
||||
FRONTMATTER_LINK_MAP,
|
||||
buildBasenameIndex,
|
||||
queryBasenameIndex,
|
||||
type SlugResolver,
|
||||
} from '../src/core/link-extraction.ts';
|
||||
import type { BrainEngine } from '../src/core/engine.ts';
|
||||
@@ -426,46 +424,6 @@ describe('extractPageLinks', () => {
|
||||
expect(strk!.linkType).toBe('wikilink_basename');
|
||||
});
|
||||
|
||||
// ─── issue #1964: dir-qualified wikilinks with raw Obsidian paths ────────
|
||||
|
||||
test('#1964: dir-qualified wikilink resolves via sync-consistent slugification (flag OFF)', async () => {
|
||||
// `[[llm-wiki/entities/AI 3.0]]` is a raw Obsidian path; the page slug
|
||||
// is the sync-slugified `llm-wiki/entities/ai-3.0`. Must resolve WITHOUT
|
||||
// global_basename (it's dir-qualified) and must NOT leak to a same-tail
|
||||
// page in a different directory.
|
||||
const resolver: SlugResolver = {
|
||||
resolve: async () => null,
|
||||
resolveBasenameMatches: async (name) =>
|
||||
name === 'ai-3.0' ? ['other/ai-3.0', 'llm-wiki/entities/ai-3.0'] : [],
|
||||
};
|
||||
const { candidates } = await extractPageLinks(
|
||||
'llm-wiki/notes/roadmap',
|
||||
'See [[llm-wiki/entities/AI 3.0]] for the model.',
|
||||
{}, 'concept', resolver,
|
||||
// opts.globalBasename omitted (= false) — path is dir-qualified
|
||||
);
|
||||
expect(candidates.map(c => c.targetSlug)).toEqual(['llm-wiki/entities/ai-3.0']);
|
||||
expect(candidates[0].linkType).toBe('wikilink_basename');
|
||||
expect(candidates[0].linkSource).toBe('wikilink-resolved');
|
||||
});
|
||||
|
||||
test('#1964: path-suffix match resolves wiki-root-relative paths against a real index', async () => {
|
||||
// Author writes `[[llm-wiki/entities/AI 3.0]]` but the brain nests the
|
||||
// wiki under a vault dir. Suffix match rescues it; queried through the
|
||||
// REAL basename index so the tail-key lookup is exercised end to end.
|
||||
const idx = buildBasenameIndex(['vault/llm-wiki/entities/ai-3.0', 'people/ai-3.0']);
|
||||
const resolver: SlugResolver = {
|
||||
resolve: async () => null,
|
||||
resolveBasenameMatches: async (name) => queryBasenameIndex(idx, name),
|
||||
};
|
||||
const { candidates } = await extractPageLinks(
|
||||
'vault/llm-wiki/notes/roadmap',
|
||||
'See [[llm-wiki/entities/AI 3.0]].',
|
||||
{}, 'concept', resolver,
|
||||
);
|
||||
expect(candidates.map(c => c.targetSlug)).toEqual(['vault/llm-wiki/entities/ai-3.0']);
|
||||
});
|
||||
|
||||
test('opts.skipFrontmatter suppresses the frontmatter pass', async () => {
|
||||
// Real resolver shape that WOULD resolve frontmatter source: too,
|
||||
// but skipFrontmatter blocks the path entirely.
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
bestPath,
|
||||
historyPath,
|
||||
loadHistory,
|
||||
proposedPath,
|
||||
revertAllPending,
|
||||
skillPath,
|
||||
versionsDir,
|
||||
writeProposed,
|
||||
} from '../../src/core/skillopt/version-store.ts';
|
||||
|
||||
let tmpDir: string;
|
||||
@@ -79,6 +81,19 @@ 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