Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Fable 5 edf7fc6b5a fix(skills): green CI for vault skills — resolver health, fixture lint, privacy literal, llms bundle
- RESOLVER.md rows now round-trip against SKILL.md frontmatter triggers
  ('put this setup change in the vault'; replace 'safe/free gbrain index'
  with 'import my vault to gbrain').
- Rewrite both routing-eval.jsonl fixture sets: positives embed a trigger
  phrase in natural context (fixture linter rejects verbatim tautologies),
  declare ambiguous_with for legitimate co-fires (capture, idea-ingest),
  and negatives no longer collide with skillpack-harvest/setup triggers.
  check:resolver --strict is clean (0 errors, 0 warnings).
- Privacy: the vault-capture regression test sources the banned fork-name
  pattern from harvest-lint's DEFAULT_PRIVATE_PATTERNS instead of the
  literal, so scripts/check-privacy.sh passes.
- Regenerate llms-full.txt after the RESOLVER.md edit (build-llms
  freshness gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 11:03:38 -07:00
b7f42f099a feat(skills): skillify vault skills — obsidian-gbrain-safe-index + skill-vault-capture-policy (takeover of #2685)
Salvages the intended 8-file change from PR #2685 (two vault skills with
routing-eval fixtures + unit/E2E tests, registered in RESOLVER.md,
manifest.json, and openclaw.plugin.json) without the ~670 unrelated
third-party skill dumps that contaminated that branch.

Also scrubs private user/agent names from skill-vault-capture-policy per
the repo privacy rule, with a regression test.

Takeover of #2685.

Co-authored-by: MortdAdam <MortdAdam@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:27:59 -07:00
19 changed files with 525 additions and 431 deletions
+3 -1
View File
@@ -1411,7 +1411,8 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "get more out of gbrain", "is my brain set up right", "weekly brain checkup", "advise me on my brain", "gbrain advisor" | `skills/gbrain-advisor/SKILL.md` |
| Save or load reports | `skills/reports/SKILL.md` |
| "Create a skill", "improve this skill" | `skills/skill-creator/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper" | `skills/skillify/SKILL.md` |
| "save this learning to the vault", "capture this skill in Obsidian", "record this workflow in my notes", "put this setup change in the vault" | `skills/skill-vault-capture-policy/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper", "add tests and evals for this" | `skills/skillify/SKILL.md` |
| "Compress my resolver", "AGENTS.md too large", "RESOLVER.md too big", "functional area dispatcher", "shrink routing table" | `skills/functional-area-resolver/SKILL.md` |
| "Is gbrain healthy?", morning health check, skillpack-check | `skills/skillpack-check/SKILL.md` |
| "harvest this skill into gbrain", "publish this skill to gbrain", "lift this skill upstream", "share this skill with other gbrain clients", "promote my skill to gbrain" | `skills/skillpack-harvest/SKILL.md` |
@@ -1429,6 +1430,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "Set up GBrain", first boot | `skills/setup/SKILL.md` |
| "Now what?", "fill my brain", "cold start", "bootstrap", "import my data", "what should I import first" | `skills/cold-start/SKILL.md` |
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
| "Connect Obsidian to gbrain", "import my vault to gbrain", "sync vault and gbrain", "embed gbrain after vault update", "is gbrain synced with my vault" | `skills/obsidian-gbrain-safe-index/SKILL.md` |
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run" | `skills/maintain/SKILL.md` (dream cycle section) |
+3 -2
View File
@@ -39,8 +39,8 @@
"skills/briefing",
"skills/citation-fixer",
"skills/concept-synthesis",
"skills/cross-modal-review",
"skills/cron-scheduler",
"skills/cross-modal-review",
"skills/daily-task-manager",
"skills/daily-task-prep",
"skills/data-research",
@@ -54,10 +54,11 @@
"skills/media-ingest",
"skills/meeting-ingestion",
"skills/minion-orchestrator",
"skills/obsidian-gbrain-safe-index",
"skills/perplexity-research",
"skills/query",
"skills/reports",
"skills/repo-architecture",
"skills/reports",
"skills/signal-detector",
"skills/skill-creator",
"skills/skillify",
+3 -1
View File
@@ -60,7 +60,8 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "get more out of gbrain", "is my brain set up right", "weekly brain checkup", "advise me on my brain", "gbrain advisor" | `skills/gbrain-advisor/SKILL.md` |
| Save or load reports | `skills/reports/SKILL.md` |
| "Create a skill", "improve this skill" | `skills/skill-creator/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper" | `skills/skillify/SKILL.md` |
| "save this learning to the vault", "capture this skill in Obsidian", "record this workflow in my notes", "put this setup change in the vault" | `skills/skill-vault-capture-policy/SKILL.md` |
| "Skillify this", "is this a skill?", "make this proper", "add tests and evals for this" | `skills/skillify/SKILL.md` |
| "Compress my resolver", "AGENTS.md too large", "RESOLVER.md too big", "functional area dispatcher", "shrink routing table" | `skills/functional-area-resolver/SKILL.md` |
| "Is gbrain healthy?", morning health check, skillpack-check | `skills/skillpack-check/SKILL.md` |
| "harvest this skill into gbrain", "publish this skill to gbrain", "lift this skill upstream", "share this skill with other gbrain clients", "promote my skill to gbrain" | `skills/skillpack-harvest/SKILL.md` |
@@ -78,6 +79,7 @@ This is the dispatcher. Skills are the implementation. **Read the skill file bef
| "Set up GBrain", first boot | `skills/setup/SKILL.md` |
| "Now what?", "fill my brain", "cold start", "bootstrap", "import my data", "what should I import first" | `skills/cold-start/SKILL.md` |
| "Migrate from Obsidian/Notion/Logseq" | `skills/migrate/SKILL.md` |
| "Connect Obsidian to gbrain", "import my vault to gbrain", "sync vault and gbrain", "embed gbrain after vault update", "is gbrain synced with my vault" | `skills/obsidian-gbrain-safe-index/SKILL.md` |
| Brain health check, maintenance run | `skills/maintain/SKILL.md` |
| "Extract links", "build link graph", "populate timeline" | `skills/maintain/SKILL.md` (extraction sections) |
| "Run dream", "process today's session", "synthesize my conversations", "consolidate yesterday's conversations", "what patterns did you see", "did the dream cycle run" | `skills/maintain/SKILL.md` (dream cycle section) |
+11 -1
View File
@@ -2,7 +2,7 @@
"name": "gbrain",
"version": "0.32.3.0",
"conformance_version": "1.0.0",
"description": "Personal knowledge brain with hybrid RAG search \u2014 GStack mod for agent platforms",
"description": "Personal knowledge brain with hybrid RAG search GStack mod for agent platforms",
"skills": [
{
"name": "ingest",
@@ -34,6 +34,11 @@
"path": "migrate/SKILL.md",
"description": "Universal migration from Obsidian, Notion, Logseq, markdown, CSV, JSON, Roam"
},
{
"name": "obsidian-gbrain-safe-index",
"path": "obsidian-gbrain-safe-index/SKILL.md",
"description": "Connect and sync an Obsidian-style Markdown vault with gbrain using a cost-controlled import-first workflow, explicit embedding gates, and durable skill/workflow capture."
},
{
"name": "setup",
"path": "setup/SKILL.md",
@@ -263,6 +268,11 @@
"name": "skill-optimizer",
"path": "skill-optimizer/SKILL.md",
"description": "Self-evolving skill optimization via gbrain skillopt — SkillOpt-paper-grounded text-space optimizer with validation gating (median-of-3 + epsilon=0.05), bundled-skill safety, bootstrap review sentinel, per-skill DB lock, and atomic versioned writes."
},
{
"name": "skill-vault-capture-policy",
"path": "skill-vault-capture-policy/SKILL.md",
"description": "Capture durable operational learnings, new agent skills, and environment/setup changes into the Obsidian vault instead of transient chat memory."
}
],
"dependencies": {
+137
View File
@@ -0,0 +1,137 @@
---
name: obsidian-gbrain-safe-index
version: 1.0.0
description: |
Connect, maintain, and sync an Obsidian-style Markdown vault with gbrain while preserving a cost-controlled workflow: the vault remains the source of truth, gbrain is the searchable/embedded index, durable skills/workflows are captured into the vault, and paid embedding runs only after explicit approval.
triggers:
- "connect Obsidian to gbrain"
- "import my vault to gbrain"
- "sync vault and gbrain"
- "capture this skill in my vault"
- "embed gbrain after vault update"
- "is gbrain synced with my vault"
tools:
- terminal
- read_file
- search_files
- write_file
- patch
mutating: true
---
# Obsidian → gbrain Safe Index and Capture
## Contract
This skill guarantees:
- Treats the user's Obsidian-style Markdown vault as the source of truth before gbrain indexing.
- Keeps gbrain in conservative mode unless the user explicitly approves a more expensive mode.
- Imports vault changes with `--no-embed` first, then embeds only after explicit approval for the specific paid action.
- Captures durable new skills, workflows, and environment learnings into the vault instead of leaving them only in chat memory.
- Verifies every sync with concrete `gbrain stats`, search mode, and, when embeddings run, exact embedded chunk counts.
## Phases
1. **Resolve the vault path.**
- Prefer an existing environment variable such as `OBSIDIAN_VAULT_PATH` or `WIKI_PATH`.
- If no path is configured, search likely note directories and ask the user before writing.
- Verify the directory exists and contains markdown files or an `.obsidian` directory.
2. **Read vault operating rules before writing.**
- If the vault has `SCHEMA.md`, `index.md`, `log.md`, `AGENTS.md`, or similar operating files, read them before ingest/query/major edit.
- Respect immutable source folders such as `raw/` when the vault declares them.
- Use the vault's native link convention, usually Obsidian `[[wikilinks]]`, for durable relationships.
3. **MECE/capture decision.**
- If new knowledge belongs on an existing page, update that page.
- If it is a distinct recurring workflow or operational policy, create a small meta or concept page following the vault schema.
- Update the vault index/catalog for every new page when the vault maintains one.
- Append a log entry for meaningful vault updates when the vault maintains a log.
4. **Safe gbrain import path.**
- Pre-check source directory; do not import a nonexistent path.
- Run `gbrain config set search.mode conservative` before/after risky reinit steps.
- Run `gbrain import "$OBSIDIAN_VAULT_PATH" --no-embed`.
- Run `gbrain extract links --source fs --dir "$OBSIDIAN_VAULT_PATH"` when wikilinks changed materially.
5. **Paid embedding gate.**
- Do not run `gbrain embed --stale` unless the user explicitly asks or a prior instruction clearly approved this exact paid action.
- Before embedding, verify provider readiness with `gbrain providers test --model <provider:model>`.
- Confirm the configured embedding dimensions match the local schema.
- After embedding, verify `gbrain stats` and record exact `Pages`, `Chunks`, `Embedded`, and `Links` counts.
6. **Final verification and vault echo.**
- Run `gbrain stats` and `gbrain search modes`.
- If vault files changed, re-import with `--no-embed`; if embedding was approved, embed stale chunks afterward.
- Report what changed, what was free/local, what used API billing, and what remains pending.
## Output Format
Use a compact status table:
| Item | Status |
|---|---|
| Vault path | `/path` |
| Vault updated | yes/no + files |
| gbrain mode | conservative/balanced/tokenmax |
| Import | `--no-embed` completed / skipped / failed |
| Pages/chunks | exact counts from `gbrain stats` |
| Embeddings | exact count; note whether this run used API billing |
| Links | exact count |
| Background jobs | none / list exact jobs |
Then include:
- **Safe next step:** free/local action.
- **Paid next step:** embedding/LLM action, if any, with explicit approval requirement.
## Anti-Patterns
- Creating a duplicate skill/page when an existing Obsidian, gbrain, or vault-ingest skill already covers the workflow.
- Running `gbrain embed --stale`, `gbrain dream`, `gbrain autopilot --install`, `gbrain onboard --auto`, or `tokenmax` without explicit cost approval.
- Importing a nonexistent or wrong directory and treating a zero-page import as success.
- Forgetting to update the vault index/catalog and log after creating or materially updating vault pages.
- Recording API keys, tokens, or raw secrets in the vault or final response.
## Tools Used
- `read_file` — read vault schema/index/log and target notes.
- `search_files` — find existing vault pages and avoid duplicates.
- `write_file` / `patch` — create or update vault pages.
- `terminal` — run `gbrain`, `git`, and environment checks with secret values redacted.
## Safe Commands
```bash
export PATH="$HOME/.bun/bin:$PATH"
gbrain config set search.mode conservative
gbrain import "$OBSIDIAN_VAULT_PATH" --no-embed
gbrain extract links --source fs --dir "$OBSIDIAN_VAULT_PATH"
gbrain stats
gbrain search modes
```
## Paid / Approval-Gated Commands
```bash
gbrain providers test --model <provider:model>
gbrain embed --stale
gbrain dream
gbrain autopilot --install
gbrain onboard --auto --max-usd 5
gbrain config set search.mode tokenmax
```
## Verification Checklist
- [ ] Vault path exists and is the intended source.
- [ ] Vault operating files were read before edits when present.
- [ ] Existing pages/skills were searched to avoid duplicates.
- [ ] New/updated vault pages follow the vault schema and link convention.
- [ ] Vault index/catalog updated for new pages when present.
- [ ] Vault log appended for meaningful actions when present.
- [ ] `gbrain import ... --no-embed` completed.
- [ ] `gbrain stats` recorded pages/chunks/embeddings/links.
- [ ] `gbrain search modes` confirms conservative mode unless a different mode was explicitly approved.
- [ ] No paid/background commands ran without approval.
@@ -0,0 +1,11 @@
// Routing eval fixtures for skills/obsidian-gbrain-safe-index.
// Positive cases: intents embed a trigger phrase in natural surrounding context
// (never verbatim-identical to a trigger — the fixture linter rejects tautologies).
{"intent": "help me connect Obsidian to gbrain for my notes", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "import my vault to gbrain but skip embeddings for now", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "please sync vault and gbrain after I edit notes", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "run embed gbrain after vault update tonight", "expected_skill": "obsidian-gbrain-safe-index"}
{"intent": "hey is gbrain synced with my vault right now", "expected_skill": "obsidian-gbrain-safe-index"}
// Negative cases: related but owned by other skills. Assert NO route to this skill.
{"intent": "migrate my notes from Notion to gbrain", "expected_skill": null}
{"intent": "what is on my calendar tomorrow", "expected_skill": null}
+100
View File
@@ -0,0 +1,100 @@
---
name: skill-vault-capture-policy
version: 1.0.0
description: |
Use when the user wants a durable operational learning, new agent skill, or
important environment/setup change to be captured into the Obsidian vault
instead of left only in transient chat memory. Covers the capture rule,
preferred page patterns, and index/log update obligations.
triggers:
- "save this learning to the vault"
- "capture this skill in Obsidian"
- "record this workflow in my notes"
- "put this setup change in the vault"
- "should we add this to the knowledge base"
tools:
- read_file
- search_files
- write_file
- patch
mutating: true
---
# Skill Vault Capture Policy
## Contract
This skill guarantees:
- Durable operational learnings, newly adopted skills, and important environment/setup changes are captured into the Obsidian vault rather than left only in chat memory.
- An existing page is updated when the knowledge clearly belongs there; a new page is created only when the topic is distinct and likely to recur.
- Every new vault page is added to `index.md`.
- Every meaningful create/update appends a dated entry to `log.md`.
- Small linked pages are preferred over one giant running note.
## Phases
1. **Classify the learning.**
- New gbrain operating rule, cost control, or embedding/provider change.
- New agent-fork / harness / coding-tool integration fact.
- New Obsidian vault workflow or structure decision.
- New recurring agent skill that changes how the agent should operate here.
2. **Avoid duplicates.**
- Search the vault for an existing page that already owns the topic.
- If found, update it with a new section or dated note rather than creating a near-duplicate.
3. **Create when distinct.**
- Place new pages under the vault schema: `_meta/` for operating notes, `concepts/` for workflows, `entities/` for tools/people.
- Use YAML frontmatter and at least two `[[wikilinks]]` unless it is a short seed page.
4. **Update navigation.**
- Add the page to `index.md` under the correct type heading.
- Append a `## [YYYY-MM-DD] create|update | subject` entry to `log.md`.
5. **Report the capture.**
- State which files changed and whether `index.md` / `log.md` were updated.
## Output Format
Use a short status block:
| Item | Status |
|---|---|
| Learning classified | type |
| Page created/updated | path |
| index.md updated | yes/no |
| log.md updated | yes/no |
## Anti-Patterns
- Leaving durable learnings only in chat memory.
- Creating a near-duplicate page instead of updating the existing one.
- Forgetting to update `index.md` and `log.md`.
- Writing one giant running note instead of small linked pages.
- Recording secrets, API keys, or raw credentials in the vault.
## Tools Used
- `read_file` — read `SCHEMA.md`, `index.md`, `log.md`, and target pages.
- `search_files` — find existing pages to avoid duplicates.
- `write_file` / `patch` — create or update vault pages and navigation.
## Safe Commands
```bash
# inspect vault navigation before writing
read SCHEMA.md index.md log.md
# create or update a page, then refresh catalog/log
# index.md: add [[page-slug]] under the matching type heading
# log.md: append ## [YYYY-MM-DD] create|update | subject
```
## Verification Checklist
- [ ] Vault schema/read files were checked before writing.
- [ ] Existing pages were searched to avoid duplicates.
- [ ] New/updated page has frontmatter and wikilinks.
- [ ] `index.md` updated for new pages.
- [ ] `log.md` appended for meaningful actions.
- [ ] No secrets or raw credentials were written.
@@ -0,0 +1,10 @@
// Routing eval fixtures for skills/skill-vault-capture-policy.
// Positive cases: intents embed a trigger phrase in natural surrounding context
// (never verbatim-identical to a trigger — the fixture linter rejects tautologies).
{"intent": "please save this learning to the vault so we keep it", "expected_skill": "skill-vault-capture-policy", "ambiguous_with": ["idea-ingest"]}
{"intent": "we should capture this skill in Obsidian for reuse", "expected_skill": "skill-vault-capture-policy", "ambiguous_with": ["capture"]}
{"intent": "can you record this workflow in my notes for next time", "expected_skill": "skill-vault-capture-policy"}
{"intent": "put this setup change in the vault before we forget", "expected_skill": "skill-vault-capture-policy"}
// Negative cases: related but owned by other skills or out of scope.
{"intent": "connect my Obsidian vault to gbrain", "expected_skill": null}
{"intent": "what is on my calendar tomorrow", "expected_skill": null}
+3 -39
View File
@@ -18,9 +18,8 @@
* at runtime.
*/
import { chunkText as recursiveChunk, capByEstimatedTokens, DEFAULT_MAX_EST_TOKENS } from './recursive.ts';
import { chunkText as recursiveChunk } from './recursive.ts';
import { buildQualifiedName } from './qualified-names.ts';
import { estimateEmbeddingTokens } from '../cjk.ts';
// Embed the tree-sitter runtime + per-language grammars as files.
// `with { type: 'file' }` returns a path (string) at runtime. Bun bundles
@@ -112,15 +111,7 @@ import G_ZIG from '../../assets/wasm/grammars/tree-sitter-zig.wasm' with { type:
// chunks get the new columns populated. Without this, the v28 backfill
// gives every existing chunk a search_vector but subsequent Layer 5 AST
// work would silently no-op.
//
// v5: estimated-token hard cap on AST-path chunks (capCodeChunks). A node
// splitLargeNode can't subdivide (giant single-statement function, huge
// literal) previously shipped WHOLE regardless of size and could overflow
// strict per-request embedding-token limits (local llama-server crashes
// past ~2,050 tokens, measured). Mirrors the markdown
// chunker's v4 cap; fallback-path chunks are already capped inside
// recursiveChunk.
export const CHUNKER_VERSION = 5;
export const CHUNKER_VERSION = 4;
// Lazy-loaded tree-sitter module (v0.22.x API: Parser is default export)
let Parser: typeof import('web-tree-sitter') | null = null;
@@ -717,7 +708,7 @@ export async function chunkCodeTextFull(
if (chunks.length === 0) {
return { chunks: fallbackChunks(source, filePath, language, opts), edges: rawEdges };
}
return { chunks: capCodeChunks(mergeSmallSiblings(chunks, chunkTarget)), edges: rawEdges };
return { chunks: mergeSmallSiblings(chunks, chunkTarget), edges: rawEdges };
} catch {
return { chunks: fallbackChunks(source, filePath, language, opts), edges: [] };
} finally {
@@ -800,33 +791,6 @@ function mergeSmallSiblings(chunks: CodeChunk[], chunkTarget: number): CodeChunk
return merged;
}
/**
* v5 final safety pass for AST-path chunks: split any chunk whose
* ESTIMATED embedding tokens (conservative per-char-class heuristic,
* cjk.ts) exceed DEFAULT_MAX_EST_TOKENS. Reaches chunks the AST logic
* can't subdivide — splitLargeNode returns [] for nodes with < 2 body
* children (giant single-statement functions, huge literals), which
* previously shipped whole at any size.
*
* Split pieces inherit the source chunk's metadata verbatim; start/end
* lines become approximate for pieces after the first. Acceptable —
* these chunks exist for embedding + retrieval, and the alternative was
* an embedding request the server rejects (or worse, crashes on).
*/
function capCodeChunks(chunks: CodeChunk[]): CodeChunk[] {
if (chunks.every((c) => estimateEmbeddingTokens(c.text) <= DEFAULT_MAX_EST_TOKENS)) {
return chunks;
}
const out: CodeChunk[] = [];
for (const c of chunks) {
const pieces = capByEstimatedTokens(c.text, DEFAULT_MAX_EST_TOKENS);
for (const piece of pieces) {
out.push({ ...c, text: piece, index: out.length, metadata: { ...c.metadata } });
}
}
return out;
}
function buildMergedChunk(group: CodeChunk[], index: number): CodeChunk {
const first = group[0]!;
const last = group[group.length - 1]!;
+6 -117
View File
@@ -17,13 +17,7 @@
* Lossless invariant: non-overlapping portions reassemble to original.
*/
import {
countCJKAwareWords,
CJK_SENTENCE_DELIMITERS,
CJK_CLAUSE_DELIMITERS,
charEmbedTokenWeight,
estimateEmbeddingTokens,
} from '../cjk.ts';
import { countCJKAwareWords, CJK_SENTENCE_DELIMITERS, CJK_CLAUSE_DELIMITERS } from '../cjk.ts';
/**
* Markdown chunker version. Folded into the per-page chunker_version column
@@ -39,20 +33,8 @@ import {
* re-embed (not re-chunk) so existing pages pick up the wrapper on the
* post-upgrade reembed sweep. See
* `src/core/contextual-retrieval-service.ts`.
*
* v4: estimated-token hard cap + whitespace-word undercount fix. The word
* pipeline counted a 150-char URL as ONE whitespace word, so URL/phone/
* email-dense docs (CJK density < 0.30 → whitespace fallback) produced
* 3-4K-char chunks that overflow strict per-request embedding-token
* limits (measured: local llama-server crashes past ~2,050 tokens; URL
* soup tokenizes at ~1.6 chars/token). Two changes:
* 1. countWords() floors the count at ceil(nonWhitespaceChars/6) so a
* URL counts roughly per-character, not as one word.
* 2. capByEstimatedTokens() final pass guarantees every chunk fits
* `maxTokens` (default 1500) under a conservative per-char-class
* token estimate, regardless of how word counting misjudged it.
*/
export const MARKDOWN_CHUNKER_VERSION = 4;
export const MARKDOWN_CHUNKER_VERSION = 3;
const DELIMITERS: string[][] = [
['\n\n'], // L0: paragraphs
@@ -66,20 +48,8 @@ export interface ChunkOptions {
chunkSize?: number; // target words per chunk (default 300)
chunkOverlap?: number; // overlap words (default 50)
maxChars?: number; // hard cap on any chunk's char length (default 6000)
/**
* v4: hard cap on any chunk's ESTIMATED embedding tokens (default 1500).
* Estimate = conservative per-char-class weights (see cjk.ts
* estimateEmbeddingTokens) — deliberately high, so the real tokenizer
* count stays below this value. Default leaves headroom for the
* contextual-retrieval wrapper (≤ ~630 chars) under a ~2,050-token
* per-request embedding server limit.
*/
maxTokens?: number;
}
/** v4 default for ChunkOptions.maxTokens — see the field doc above. */
export const DEFAULT_MAX_EST_TOKENS = 1500;
export interface TextChunk {
text: string;
index: number;
@@ -103,7 +73,6 @@ export function chunkText(text: string, opts?: ChunkOptions): TextChunk[] {
const chunkSize = opts?.chunkSize || 300;
const chunkOverlap = opts?.chunkOverlap || 50;
const maxChars = opts?.maxChars || 6000;
const maxTokens = opts?.maxTokens || DEFAULT_MAX_EST_TOKENS;
if (!text || text.trim().length === 0) return [];
@@ -120,9 +89,8 @@ export function chunkText(text: string, opts?: ChunkOptions): TextChunk[] {
const wordCount = countWords(stripped);
if (wordCount <= chunkSize) {
// Single-chunk path: still apply the maxChars + maxTokens caps.
const capped = capByChars(stripped.trim(), maxChars)
.flatMap((t) => capByEstimatedTokens(t, maxTokens));
// Single-chunk path: still apply the maxChars cap.
const capped = capByChars(stripped.trim(), maxChars);
return capped.map((t, i) => ({ text: t, index: i }));
}
@@ -133,14 +101,9 @@ export function chunkText(text: string, opts?: ChunkOptions): TextChunk[] {
// v0.32.7: hard char cap. Catches pathological CJK + whitespace-less text
// that the word-level pipeline can't bound (a single Chinese paragraph can
// exceed 8192 OpenAI embedding tokens at any word count).
// v4: estimated-token cap on top — the char cap alone passes token-dense
// content (URL soup at ~1.6 chars/token) that overflows strict embedding
// server limits.
const capped: string[] = [];
for (const chunk of withOverlap) {
for (const piece of capByChars(chunk.trim(), maxChars)) {
capped.push(...capByEstimatedTokens(piece, maxTokens));
}
capped.push(...capByChars(chunk.trim(), maxChars));
}
return capped.map((t, i) => ({ text: t, index: i }));
}
@@ -169,68 +132,6 @@ function capByChars(text: string, maxChars: number): string[] {
return out;
}
/**
* How far back (in chars) the token cap looks for a friendly cut point
* before falling back to a hard cut. 300 covers typical rollup/list line
* lengths so forced splits land at line starts, not mid-URL.
*/
const TOKEN_CAP_CUT_LOOKBACK = 300;
/**
* v4: hard-cap a chunk's ESTIMATED embedding tokens. Final safety pass —
* runs after capByChars on every chunk, so no upstream miscounting
* (whitespace-word fallback, overlap inflation, char-cap survivors) can
* emit a chunk past `maxTokens`.
*
* Cut placement prefers, within the last TOKEN_CAP_CUT_LOOKBACK chars of
* the window: a newline, then any whitespace, then a hard cut. This keeps
* forced splits off mid-line/mid-URL positions for list-shaped content
* and inside code fences. No overlap is added (pieces stay lossless
* modulo the trims the char cap already applies).
*
* @internal exported for the code chunker (code.ts) and tests.
*/
export function capByEstimatedTokens(text: string, maxTokens: number): string[] {
if (text.length === 0) return [];
if (estimateEmbeddingTokens(text) <= maxTokens) return [text];
const out: string[] = [];
let start = 0;
while (start < text.length) {
// Greedily extend the window until the next char would break the cap.
// Always take at least one char so the loop makes forward progress.
let est = 0;
let end = start;
while (end < text.length) {
const w = charEmbedTokenWeight(text.charCodeAt(end));
if (est + w > maxTokens && end > start) break;
est += w;
end++;
}
if (end < text.length) {
const windowStart = Math.max(start + 1, end - TOKEN_CAP_CUT_LOOKBACK);
let cut = text.lastIndexOf('\n', end - 1);
if (cut < windowStart) {
cut = -1;
for (let i = end - 1; i >= windowStart; i--) {
const code = text.charCodeAt(i);
if (code === 0x20 || (code >= 0x09 && code <= 0x0d)) {
cut = i;
break;
}
}
}
if (cut >= windowStart) end = cut + 1;
}
const slice = text.slice(start, end).trim();
if (slice.length > 0) out.push(slice);
start = end;
}
return out;
}
function recursiveSplit(text: string, level: number, target: number): string[] {
if (level >= DELIMITERS.length) {
// Level 4: split on whitespace
@@ -416,19 +317,7 @@ function extractTrailingContext(text: string, targetWords: number): string {
* Delegated to src/core/cjk.ts so the slugify whitelist, expansion
* detection, and PGLite keyword fallback all agree on what "CJK enough"
* means.
*
* v4: floored at ceil(nonWhitespaceChars/6). The whitespace fallback
* counts a 150-char URL as ONE word, so URL/phone/email-dense docs
* (whose ASCII mass pushes CJK density below the 0.30 threshold) were
* sized at a fraction of their real bulk and merged into 3-4K-char
* chunks. The floor makes long whitespace-less runs count roughly
* per-character while leaving normal Latin prose untouched (average
* English word ≈ 5 chars < 6, so the whitespace count still wins).
* Kept local to the chunker — search/expansion.ts keeps the original
* countCJKAwareWords semantics for its query-length check.
*/
function countWords(text: string): number {
const cjkAware = countCJKAwareWords(text);
const nonWhitespace = text.replace(/\s/g, '').length;
return Math.max(cjkAware, Math.ceil(nonWhitespace / 6));
return countCJKAwareWords(text);
}
-61
View File
@@ -65,64 +65,3 @@ export function countCJKAwareWords(s: string): number {
export function escapeLikePattern(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
}
/**
* Conservative per-char-class embedding-token weights (markdown chunker v4).
*
* Why this exists: the chunker's "word" counting drastically UNDER-counts
* whitespace-less ASCII runs (a 150-char URL = 1 whitespace word), so
* word-based size targets can emit chunks that overflow an embedding
* server's per-request token limit. Measured on a local Qwen3-embedding
* llama-server stack:
* - URL/phone/email-dense text tokenizes at ~1.6 chars/token
* - base64-ish / minified blobs approach ~1.3 chars/token (worst case)
* - Korean prose tokenizes NO WORSE than 1 char/token in practice
*
* Weights are deliberately HIGH (tokens are overestimated) so any cap
* based on this estimate is safe against real tokenizers:
* - CJK char → 1.0 token (real CJK prose is cheaper)
* - other non-space → 0.75 token (≈1.33 chars/token, covers base64)
* - whitespace → 0.1 token (mostly folds into neighbor tokens)
*/
export const EMBED_TOKEN_WEIGHT_CJK = 1.0;
export const EMBED_TOKEN_WEIGHT_OTHER = 0.75;
export const EMBED_TOKEN_WEIGHT_WS = 0.1;
/** BMP CJK check by UTF-16 code unit — same ranges as CJK_SLUG_CHARS. */
export function isCJKCodeUnit(code: number): boolean {
return (
(code >= 0x4e00 && code <= 0x9fff) || // Han
(code >= 0x3040 && code <= 0x309f) || // Hiragana
(code >= 0x30a0 && code <= 0x30ff) || // Katakana
(code >= 0xac00 && code <= 0xd7af) // Hangul Syllables
);
}
/**
* Per-code-unit token weight. Unrecognized whitespace (exotic Unicode
* spaces) intentionally falls into OTHER — that only overestimates.
*/
export function charEmbedTokenWeight(code: number): number {
if (isCJKCodeUnit(code)) return EMBED_TOKEN_WEIGHT_CJK;
if (
code === 0x20 || (code >= 0x09 && code <= 0x0d) ||
code === 0xa0 || code === 0x3000
) {
return EMBED_TOKEN_WEIGHT_WS;
}
return EMBED_TOKEN_WEIGHT_OTHER;
}
/**
* Tokenizer-free embedding-token estimate (conservative overestimate).
* See weight docs above. Astral chars count as 2 OTHER code units —
* another overestimate, which is the safe direction.
*/
export function estimateEmbeddingTokens(s: string): number {
if (s.length === 0) return 0;
let est = 0;
for (let i = 0; i < s.length; i++) {
est += charEmbedTokenWeight(s.charCodeAt(i));
}
return Math.ceil(est);
}
+3 -6
View File
@@ -15,22 +15,19 @@ import { describe, test, expect } from 'bun:test';
import { CHUNKER_VERSION } from '../src/core/chunkers/code.ts';
describe('Layer 12 — CHUNKER_VERSION constant', () => {
test('bumped to 5 for the estimated-token hard cap', () => {
test('bumped to 4 for Cathedral II', () => {
// v3: v0.19.0 Chonkie parity (tokenizer + small-sibling merge).
// v4: v0.20.0 Cathedral II (qualified names + parent scope + doc_comment
// + fence extraction + chunk-grain FTS). Folded into content_hash
// so any bump forces clean re-chunks on next sync.
// v5: estimated-token hard cap on AST-path chunks (capCodeChunks) so
// un-subdividable giant nodes can't overflow strict embedding
// server token limits.
expect(CHUNKER_VERSION).toBe(5);
expect(CHUNKER_VERSION).toBe(4);
});
test('is stable across imports (not recomputed at call time)', async () => {
const a = (await import('../src/core/chunkers/code.ts')).CHUNKER_VERSION;
const b = (await import('../src/core/chunkers/code.ts')).CHUNKER_VERSION;
expect(a).toBe(b);
expect(a).toBe(5);
expect(a).toBe(4);
});
});
+2 -2
View File
@@ -10,8 +10,8 @@ import { describe, test, expect } from 'bun:test';
import { chunkCodeText, detectCodeLanguage, CHUNKER_VERSION } from '../../src/core/chunkers/code.ts';
describe('CHUNKER_VERSION', () => {
test('v5: estimated-token hard cap on AST-path chunks', () => {
expect(CHUNKER_VERSION).toBe(5);
test('v0.20.0 Cathedral II Layer 12 bumped to 4', () => {
expect(CHUNKER_VERSION).toBe(4);
});
});
+5 -6
View File
@@ -135,14 +135,13 @@ describe('Recursive Text Chunker', () => {
});
describe('CJK chunking (v0.32.7)', () => {
test('MARKDOWN_CHUNKER_VERSION is 4', async () => {
test('MARKDOWN_CHUNKER_VERSION is 3', async () => {
// v0.40.3.0: bumped 2→3 to signal the post-upgrade reembed sweep that
// contextual retrieval wrapping is now applied at embed time.
// v4: estimated-token hard cap + whitespace-word undercount floor
// (URL-dense docs produced chunks past strict embedding server token
// limits). Boundary change → forces re-chunk for chunker_version < 4.
// contextual retrieval wrapping is now applied at embed time. Chunk
// boundaries themselves are unchanged; the bump forces re-embed for
// pages where chunker_version < 3.
const mod = await import('../../src/core/chunkers/recursive.ts');
expect(mod.MARKDOWN_CHUNKER_VERSION).toBe(4);
expect(mod.MARKDOWN_CHUNKER_VERSION).toBe(3);
});
test('long pure-Chinese paragraph splits into multiple chunks', () => {
-195
View File
@@ -1,195 +0,0 @@
/**
* Markdown chunker v4 / code chunker v5 — estimated-token hard cap
* regression tests.
*
* Reproduces a field failure: a local llama-server embedding backend
* (`-ub 2048`) crashes deterministically (trace/BPT trap → EOF at the
* client) when a single chunk exceeds ~2,050 real tokens. Two content
* shapes triggered it:
*
* 1. Korean docs carrying one long source URL per line.
* The URLs' ASCII mass pushes CJK density below 0.30, flipping
* countCJKAwareWords to whitespace counting, where a 150-char URL
* counts as ONE word → chunks ballooned to 3-4K chars ≈ 2,000+
* real tokens (URL soup tokenizes at ~1.6 chars/token).
*
* 2. Large JSON code blocks (~7K chars) that the word pipeline
* undercounts the same way (few whitespace tokens).
*
* The fix: every emitted chunk must satisfy
* estimateEmbeddingTokens(chunk) <= maxTokens (default 1500)
* where the estimate deliberately OVERSTATES real tokenizer counts.
*/
import { describe, test, expect } from 'bun:test';
import { chunkText, capByEstimatedTokens, DEFAULT_MAX_EST_TOKENS } from '../../src/core/chunkers/recursive.ts';
import { chunkCodeText } from '../../src/core/chunkers/code.ts';
import { estimateEmbeddingTokens } from '../../src/core/cjk.ts';
/** Synthesize the failing shape: Korean rollup lines each ending in a long Notion URL. */
function urlDenseKoreanRollup(lines: number): string {
const out: string[] = ['# 링크가 줄마다 붙는 한국어 예시 문서', ''];
for (let i = 0; i < lines; i++) {
const hex32 = (i * 2654435761 >>> 0).toString(16).padStart(8, '0').repeat(4);
out.push(
`- **항목 ${i}**: 이 줄은 청커 동작 검증을 위한 의미 없는 한국어 예시 문장입니다 · 전화 000-0000-${String(1000 + i)} · ` +
`이메일 user${i}@example.com · 링크: https://docs.example.com/pages/${hex32}?v=abcdef0123456789&ref=sample`,
);
}
return out.join('\n');
}
/** Synthesize a large pretty-printed JSON block with CJK values. */
function bigJsonBlock(targetChars: number): string {
const entries: string[] = [];
let i = 0;
let len = 0;
while (len < targetChars) {
const row =
` "item_${i}": { "name": "예시-${i}", "url": "https://example.com/api/v2/items/${i}?token=abc${i}def", "qty": ${i % 100}, "memo": "한국어 값이 섞인 예시 데이터" }`;
entries.push(row);
len += row.length;
i++;
}
return `{\n${entries.join(',\n')}\n}`;
}
describe('v4 estimated-token cap — URL-dense Korean doc (field-failure shape)', () => {
test('every chunk stays under the estimated-token cap', () => {
const md = urlDenseKoreanRollup(60);
const chunks = chunkText(md);
expect(chunks.length).toBeGreaterThan(0);
for (const c of chunks) {
expect(estimateEmbeddingTokens(c.text)).toBeLessThanOrEqual(DEFAULT_MAX_EST_TOKENS);
}
});
test('no chunk reaches the measured 3K-char danger zone for URL soup', () => {
const md = urlDenseKoreanRollup(60);
const chunks = chunkText(md);
// 1500 est tokens at the OTHER weight (0.75/char) bounds chunks to
// ~2,000 chars for pure ASCII — well under the ~3,300 chars where
// URL-dense content crosses ~2,050 real tokens (1.6 chars/token).
for (const c of chunks) {
expect(c.text.length).toBeLessThanOrEqual(2600);
}
});
test('content is preserved (no lines dropped by the cap)', () => {
const md = urlDenseKoreanRollup(60);
const chunks = chunkText(md);
const joined = chunks.map((c) => c.text).join('\n');
// Spot-check first / middle / last rollup lines survive chunking.
for (const marker of ['항목 0', '항목 30', '항목 59']) {
expect(joined).toContain(marker);
}
});
});
describe('v4 estimated-token cap — large JSON blocks', () => {
test('7K-char pretty JSON through the prose path stays under the cap', () => {
const md = `설정 파일 원문 보존:\n\n\`\`\`\n${bigJsonBlock(7000)}\n\`\`\`\n`;
const chunks = chunkText(md);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(estimateEmbeddingTokens(c.text)).toBeLessThanOrEqual(DEFAULT_MAX_EST_TOKENS);
}
});
test('7K-char minified JSON (single whitespace-less token) stays under the cap', () => {
const minified = bigJsonBlock(7000).replace(/\n\s*/g, '');
const chunks = chunkText(minified);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(estimateEmbeddingTokens(c.text)).toBeLessThanOrEqual(DEFAULT_MAX_EST_TOKENS);
}
});
test('json fence via the code chunker stays under the cap (+header slack)', async () => {
const chunks = await chunkCodeText(bigJsonBlock(7000), 'fence.json');
expect(chunks.length).toBeGreaterThan(0);
for (const c of chunks) {
// buildChunk prepends a short "[JSON] fence.json:…" header AFTER the
// body-level cap; allow ~60 est tokens of header slack. Real-token
// safety margin (2,050 overestimated 1,500) absorbs this easily.
expect(estimateEmbeddingTokens(c.text)).toBeLessThanOrEqual(DEFAULT_MAX_EST_TOKENS + 60);
}
});
});
describe('v4 word-count floor — behavior preserved for normal content', () => {
test('Latin prose chunking is unchanged by the floor (avg word < 6 chars)', () => {
const prose = Array.from({ length: 120 }, (_, i) =>
`This is sentence number ${i} and it talks about ordinary things in plain words.`,
).join(' ');
const chunks = chunkText(prose);
// Historical behavior: ~1,560 whitespace words → multiple ~300-word chunks.
expect(chunks.length).toBeGreaterThan(3);
for (const c of chunks) {
const words = c.text.split(/\s+/).length;
expect(words).toBeLessThanOrEqual(300 * 1.5 + 50); // merge cap + overlap
}
});
test('Korean prose (CJK-dense, no URLs) never triggers the token cap', () => {
const prose = Array.from({ length: 80 }, (_, i) =>
`이 문장은 순수 한국어 산문의 청킹 동작을 확인하기 위한 ${i}번째 예시 문장입니다.`,
).join(' ');
const chunks = chunkText(prose);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
// CJK-dense chunks are char-counted (≈450 max) — nowhere near 1500.
expect(estimateEmbeddingTokens(c.text)).toBeLessThanOrEqual(700);
}
});
});
describe('capByEstimatedTokens unit behavior', () => {
test('returns input unchanged when under the cap', () => {
expect(capByEstimatedTokens('short text', 1500)).toEqual(['short text']);
expect(capByEstimatedTokens('', 1500)).toEqual([]);
});
test('prefers newline cut points within the lookback window', () => {
const line = 'x'.repeat(100);
const text = Array.from({ length: 40 }, () => line).join('\n');
const pieces = capByEstimatedTokens(text, 1000);
expect(pieces.length).toBeGreaterThan(1);
for (const p of pieces) {
// Every piece should be whole lines (multiples of the 100-char line).
for (const l of p.split('\n')) {
expect(l).toBe(line);
}
}
});
test('makes forward progress on whitespace-less input (hard cut)', () => {
const blob = 'a'.repeat(10_000);
const pieces = capByEstimatedTokens(blob, 1000);
expect(pieces.length).toBeGreaterThan(1);
expect(pieces.join('')).toBe(blob);
for (const p of pieces) {
expect(estimateEmbeddingTokens(p)).toBeLessThanOrEqual(1000);
}
});
});
describe('estimateEmbeddingTokens — weight sanity', () => {
test('overestimates URL-dense ASCII (0.75/char ≥ measured ~0.63/char)', () => {
const url = 'https://docs.example.com/pages/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4?v=abc&ref=sample';
const est = estimateEmbeddingTokens(url);
expect(est).toBeGreaterThanOrEqual(Math.floor(url.length * 0.7));
});
test('counts CJK at 1 token/char', () => {
expect(estimateEmbeddingTokens('가나다라마')).toBe(5);
});
test('whitespace is nearly free', () => {
expect(estimateEmbeddingTokens(' \n\t ')).toBeLessThanOrEqual(1);
});
test('empty string is 0', () => {
expect(estimateEmbeddingTokens('')).toBe(0);
});
});
@@ -0,0 +1,61 @@
/**
* E2E smoke for skills/obsidian-gbrain-safe-index.
*
* Verifies the from-trigger-to-side-effect path that skillify requires:
* a real user trigger phrase routes to the skill, the resolver/check
* pipeline treats it as reachable, and the skill file exposes the
* gbrain commands the workflow actually runs.
*
* This stays local-only (no paid embedding, no external API): it asserts
* the documented safe/import path is present and parseable, not that it
* mutates a live brain.
*/
import { describe, expect, it } from 'bun:test';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
const SKILLS = join(import.meta.dir, '..', '..', 'skills');
const SKILL_MD = join(SKILLS, 'obsidian-gbrain-safe-index', 'SKILL.md');
const RESOLVER = join(SKILLS, 'RESOLVER.md');
const TRIGGER_PHRASES = [
'connect my Obsidian vault to gbrain',
'import my vault to gbrain',
'sync vault and gbrain',
'capture this skill in my vault',
'embed gbrain after vault update',
'is gbrain synced with my vault',
];
describe('obsidian-gbrain-safe-index E2E', () => {
it('resolver maps real trigger phrasings to the skill', () => {
const resolver = readFileSync(RESOLVER, 'utf-8');
expect(resolver).toContain('obsidian-gbrain-safe-index/SKILL.md');
// Each representative phrase shares a token substring with a resolver row.
const rows = resolver
.split('\n')
.filter((l) => l.includes('obsidian-gbrain-safe-index/SKILL.md'))
.join('\n');
for (const phrase of TRIGGER_PHRASES) {
const hit = phrase
.toLowerCase()
.split(/\s+/)
.some((tok) => tok.length > 3 && rows.toLowerCase().includes(tok));
expect(hit, `no resolver token for: ${phrase}`).toBe(true);
}
});
it('skill documents the gbrain import-first safe path', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
expect(body).toContain('gbrain import');
expect(body).toContain('--no-embed');
expect(body).toContain('gbrain config set search.mode conservative');
// Paid gate must be explicit, not a silent default.
expect(body).toContain('gbrain embed --stale');
});
it('skill is reachable from the skill tree', () => {
expect(existsSync(SKILL_MD)).toBe(true);
});
});
@@ -0,0 +1,52 @@
/**
* E2E smoke for skills/skill-vault-capture-policy.
*
* Verifies the from-trigger-to-side-effect path: a real capture request
* routes to the skill, and the skill documents the vault navigation
* obligations (index.md / log.md) that make a capture durable.
*
* Local-only: it asserts documented behavior, not live vault writes.
*/
import { describe, expect, it } from 'bun:test';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
const SKILLS = join(import.meta.dir, '..', '..', 'skills');
const SKILL_MD = join(SKILLS, 'skill-vault-capture-policy', 'SKILL.md');
const RESOLVER = join(SKILLS, 'RESOLVER.md');
const TRIGGER_PHRASES = [
'save this learning to the vault',
'capture this skill in Obsidian',
'record this workflow in my notes',
'put this setup change in the knowledge base',
];
describe('skill-vault-capture-policy E2E', () => {
it('resolver maps real capture phrasings to the skill', () => {
const resolver = readFileSync(RESOLVER, 'utf-8');
expect(resolver).toContain('skill-vault-capture-policy/SKILL.md');
const rows = resolver
.split('\n')
.filter((l) => l.includes('skill-vault-capture-policy/SKILL.md'))
.join('\n');
for (const phrase of TRIGGER_PHRASES) {
const hit = phrase
.toLowerCase()
.split(/\s+/)
.some((tok) => tok.length > 3 && rows.toLowerCase().includes(tok));
expect(hit, `no resolver token for: ${phrase}`).toBe(true);
}
});
it('skill documents index.md and log.md update obligations', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
expect(body).toContain('index.md');
expect(body).toContain('log.md');
});
it('skill is reachable from the skill tree', () => {
expect(existsSync(SKILL_MD)).toBe(true);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'bun:test';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
const SKILL_DIR = join(import.meta.dir, '..', 'skills', 'obsidian-gbrain-safe-index');
const SKILL_MD = join(SKILL_DIR, 'SKILL.md');
const RESOLVER = join(import.meta.dir, '..', 'skills', 'RESOLVER.md');
function parseFrontmatter(raw: string): Record<string, unknown> {
const m = raw.match(/^---\n([\s\S]*?)\n---/);
if (!m) throw new Error('no frontmatter');
const out: Record<string, unknown> = {};
for (const line of m[1].split('\n')) {
const mm = line.match(/^([a-zA-Z_]+):\s*(.*)$/);
if (mm) out[mm[1]] = mm[2].trim();
}
return out;
}
describe('obsidian-gbrain-safe-index skill', () => {
it('has a SKILL.md with required frontmatter', () => {
expect(existsSync(SKILL_MD)).toBe(true);
const fm = parseFrontmatter(readFileSync(SKILL_MD, 'utf-8'));
expect(fm['name']).toBe('obsidian-gbrain-safe-index');
expect(fm['description']).toBeTruthy();
});
it('has the required conformance sections', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
for (const section of ['## Contract', '## Phases', '## Output Format', '## Anti-Patterns']) {
expect(body.includes(section), `missing ${section}`).toBe(true);
}
});
it('is registered in RESOLVER.md', () => {
expect(existsSync(RESOLVER)).toBe(true);
const resolver = readFileSync(RESOLVER, 'utf-8');
expect(resolver.includes('obsidian-gbrain-safe-index/SKILL.md')).toBe(true);
});
it('has routing-eval fixtures that exercise real trigger phrasings', () => {
const evalPath = join(SKILL_DIR, 'routing-eval.jsonl');
expect(existsSync(evalPath)).toBe(true);
const lines = readFileSync(evalPath, 'utf-8')
.split('\n')
.filter((l) => l.trim() && !l.trim().startsWith('//'))
.map((l) => JSON.parse(l));
const positives = lines.filter((l) => l.expected_skill === 'obsidian-gbrain-safe-index');
expect(positives.length).toBeGreaterThanOrEqual(5);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'bun:test';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { DEFAULT_PRIVATE_PATTERNS } from '../src/core/skillpack/harvest-lint.ts';
const SKILL_DIR = join(import.meta.dir, '..', 'skills', 'skill-vault-capture-policy');
const SKILL_MD = join(SKILL_DIR, 'SKILL.md');
const RESOLVER = join(import.meta.dir, '..', 'skills', 'RESOLVER.md');
function parseFrontmatter(raw: string): Record<string, unknown> {
const m = raw.match(/^---\n([\s\S]*?)\n---/);
if (!m) throw new Error('no frontmatter');
const out: Record<string, unknown> = {};
for (const line of m[1].split('\n')) {
const mm = line.match(/^([a-zA-Z_]+):\s*(.*)$/);
if (mm) out[mm[1]] = mm[2].trim();
}
return out;
}
describe('skill-vault-capture-policy skill', () => {
it('has a SKILL.md with required frontmatter', () => {
expect(existsSync(SKILL_MD)).toBe(true);
const fm = parseFrontmatter(readFileSync(SKILL_MD, 'utf-8'));
expect(fm['name']).toBe('skill-vault-capture-policy');
expect(fm['description']).toBeTruthy();
});
it('has the required conformance sections', () => {
const body = readFileSync(SKILL_MD, 'utf-8');
for (const section of ['## Contract', '## Phases', '## Output Format', '## Anti-Patterns']) {
expect(body.includes(section), `missing ${section}`).toBe(true);
}
});
it('is registered in RESOLVER.md', () => {
expect(existsSync(RESOLVER)).toBe(true);
expect(readFileSync(RESOLVER, 'utf-8').includes('skill-vault-capture-policy/SKILL.md')).toBe(true);
});
it('contains no private user or agent-fork names (privacy rule)', () => {
for (const file of [SKILL_MD, join(SKILL_DIR, 'routing-eval.jsonl')]) {
const body = readFileSync(file, 'utf-8');
// DEFAULT_PRIVATE_PATTERNS[0] is the banned fork-name pattern; sourced
// from harvest-lint so this file never contains the literal itself
// (scripts/check-privacy.sh would reject it).
const forkName = new RegExp(DEFAULT_PRIVATE_PATTERNS[0], 'i');
for (const name of [/\bAdam\b/, /\bHermes\b/, /\bHerdr\b/, /\bArk\b/, forkName]) {
expect(name.test(body), `private name ${name} in ${file}`).toBe(false);
}
}
});
it('has routing-eval fixtures', () => {
const evalPath = join(SKILL_DIR, 'routing-eval.jsonl');
expect(existsSync(evalPath)).toBe(true);
const positives = readFileSync(evalPath, 'utf-8')
.split('\n')
.filter((l) => l.trim() && !l.trim().startsWith('//'))
.map((l) => JSON.parse(l))
.filter((l) => l.expected_skill === 'skill-vault-capture-policy');
expect(positives.length).toBeGreaterThanOrEqual(4);
});
});