Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/agents/content/skills/kb-retrieve/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ By default the helper searches up to two knowledge bases: the one discovered by

`--store <name>` (alias `--kb <name>`) narrows the search to a single registered knowledge base, resolved by registry name alone — no `.kb/` discovery walk runs, so a project-local `.kb/` the helper happened to be invoked near never enters scope. Use it to query a named store directly, such as the `codeassembly` event store. A name that matches no registry entry yields an empty result with an explanatory diagnostic.

Within each knowledge base, recall is limited to the notes the store declares — the files matching its configured `targets`/`exclude` (the same note set `kb check` enforces; `content/**/*.md` by default). Markdown outside that set, such as a root `README.md` or an excluded draft, is not recalled even when it contains the query terms.

## Runtime dependencies

- **`node` ≥ 24** — the bundled helper inherits the Node version floor of `@codeassembly/kb`.
Expand Down
39 changes: 39 additions & 0 deletions packages/agents/src/kb-retrieve/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const MIXED_REGISTRY = join(FIXTURES, 'mixed-registry');
const CUSTOM_SCHEMA_VAULT = join(FIXTURES, 'custom-schema-vault');
const MALFORMED_SCHEMA_VAULT = join(FIXTURES, 'malformed-schema-vault');
const MULTI_SCHEMA_REGISTRY = join(FIXTURES, 'multi-schema-registry');
const SCOPED_VAULT = join(FIXTURES, 'scoped-vault');
const DEFAULT_SCOPE_VAULT = join(FIXTURES, 'default-scope-vault');
const MALFORMED_CONFIG_VAULT = join(FIXTURES, 'malformed-config-vault');
const NOW = new Date('2026-05-01T00:00:00Z');

describe(parseArgs, () => {
Expand Down Expand Up @@ -271,4 +274,40 @@ describe(runRetrieve, () => {
expect(plain).toBeDefined();
expect(result.warnings.filter((warning) => /schema invalid/.test(warning))).toHaveLength(1);
});

it('recalls only notes inside the configured targets, skipping root and excluded markdown', async () => {
// scoped-vault stores `zephyrquux` in README.md (root), content/in-scope.md, and content/drafts/excluded.md;
// its config targets `content/**/*.md` and excludes `content/drafts/**`, so only in-scope.md is a note.
const result = await runRetrieve({ argv: ['zephyrquux'], startDir: SCOPED_VAULT, now: NOW, home: FIXTURES });

expect(result.candidates.map((candidate) => candidate.path.split('/').at(-1))).toEqual(['in-scope.md']);
expect(result.warnings).toEqual([]);
});

it('scopes to content/ under the default config when no config.yaml is present', async () => {
// default-scope-vault has no `.kb/config.yaml`, so the default `content/**/*.md` applies: the root README is skipped.
const result = await runRetrieve({
argv: ['wibblefrazz'],
startDir: DEFAULT_SCOPE_VAULT,
now: NOW,
home: FIXTURES,
});

expect(result.candidates.map((candidate) => candidate.path.split('/').at(-1))).toEqual(['note.md']);
});

it('degrades a malformed config to the default and warns instead of failing the search', async () => {
const result = await runRetrieve({
argv: ['splonktastic'],
startDir: MALFORMED_CONFIG_VAULT,
now: NOW,
home: FIXTURES,
});

// The content/ note still recalls under the degraded default config, and the defect surfaces as one warning.
expect(result.candidates.map((candidate) => candidate.path.split('/').at(-1))).toEqual(['note.md']);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toMatch(/config invalid/);
expect(result.diagnostic).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# This fixture deliberately stores notes outside `content/`, so recall must search the whole tree.
targets:
- '**/*.md'
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Default-scope vault

This README mentions wibblefrazz but lives outside `content/`; with the default config recall must skip it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: Default-scope note
recordType: assertion
created: 2026-05-01T08:00:00Z
updated: 2026-05-01T08:00:00Z
tags: [default-scope]
---

A note about wibblefrazz under content/ that the default config recalls.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# `targets` must be a list of globs; a scalar is a type error that `loadKbConfig` rejects,
# so recall degrades to the default config.
targets: not-a-list
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: Malformed-config note
recordType: assertion
created: 2026-05-01T08:00:00Z
updated: 2026-05-01T08:00:00Z
tags: [degraded]
---

A note about splonktastic under content/ that survives because recall degrades to the default config.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# This fixture deliberately stores notes outside `content/`, so recall must search the whole tree.
targets:
- '**/*.md'
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# This fixture deliberately stores notes outside `content/`, so recall must search the whole tree.
targets:
- '**/*.md'
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# This fixture deliberately stores notes outside `content/`, so recall must search the whole tree.
targets:
- '**/*.md'
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
targets:
- 'content/**/*.md'
exclude:
- 'content/drafts/**'
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Scoped vault

This README mentions zephyrquux but lives outside `content/`, so recall must not surface it.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: Excluded draft
recordType: assertion
created: 2026-05-01T08:00:00Z
updated: 2026-05-01T08:00:00Z
tags: [scoped]
---

A draft about zephyrquux under content/drafts/, excluded from the note set.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
title: In-scope note
recordType: assertion
created: 2026-05-01T08:00:00Z
updated: 2026-05-01T08:00:00Z
tags: [scoped]
---

A note about zephyrquux that lives under content/ and must be recalled.
66 changes: 63 additions & 3 deletions packages/agents/src/kb-retrieve/cli.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
/* eslint n/no-process-exit: off */
/* eslint unicorn/no-process-exit: off */
import { realpathSync } from 'node:fs';
import { join } from 'node:path';
import { join, relative, sep } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';

import type { NoteScopeMatcher } from '@codeassembly/kb/config';
import { createNoteScopeMatcher, defaultKbConfig, loadKbConfig } from '@codeassembly/kb/config';
import type { Schema } from '@codeassembly/kb/schema';
import { defaultSchema, loadSchema } from '@codeassembly/kb/schema';

Expand Down Expand Up @@ -187,7 +189,13 @@ export async function runRetrieve(input: {
return { candidates: [], scopedKbs: [], warnings: composeWarnings({ registryError, missingKbs: [] }), diagnostic };
}

const { hits, missingKbs } = await recallNotes({ query, scopedKbs: inScopeKbs });
const { hits: rawHits, missingKbs } = await recallNotes({ query, scopedKbs: inScopeKbs });

// Scope ripgrep's raw hits to each KB's configured note set — the same `targets`/`exclude` definition `kb check`
// enforces — so non-note markdown under the root and excluded paths never reach the candidate table.
const { matchers, warnings: configWarnings } = await loadMatchersForHits({ hits: rawHits, scopedKbs: inScopeKbs });
const hits = rawHits.filter((hit) => isNoteHit(hit, matchers));

const { schemas, warnings: schemaWarnings } = await loadSchemasForHits({ hits, scopedKbs: inScopeKbs });
const unreadableHitWarnings: string[] = [];
const candidates = await normalizeHits({ hits, filters, now: input.now, warnings: unreadableHitWarnings, schemas });
Expand All @@ -199,7 +207,12 @@ export async function runRetrieve(input: {
const result: RetrieveResult = {
candidates,
scopedKbs: searchedKbs,
warnings: [...composeWarnings({ registryError, missingKbs }), ...schemaWarnings, ...unreadableHitWarnings],
warnings: [
...composeWarnings({ registryError, missingKbs }),
...configWarnings,
...schemaWarnings,
...unreadableHitWarnings,
],
};
if (candidates.length === 0) {
// Distinguish a query that found nothing from a query that found hits which were then excluded by
Expand All @@ -209,6 +222,53 @@ export async function runRetrieve(input: {
return result;
}

/** Returns true when a hit's path falls inside its KB's configured note set; a KB with no matcher keeps all hits. */
function isNoteHit(hit: RawHit, matchers: Map<string, NoteScopeMatcher>): boolean {
const matcher = matchers.get(hit.kbPath);
return matcher === undefined || matcher.isNote(toRelativePath(hit.kbPath, hit.path));
}

/** Renders a hit's absolute path as the slash-separated, KB-root-relative path the note-scope matcher expects. */
function toRelativePath(kbPath: string, notePath: string): string {
return relative(kbPath, notePath).split(sep).join('/');
}

/**
* Builds a note-scope matcher for every KB that produced a hit, keyed by KB root path, so recall can drop hits that
* fall outside the KB's configured `targets`/`exclude` — the same definition `kb check` enforces. A KB whose
* `.kb/config.yaml` is malformed degrades to {@link defaultKbConfig} and contributes a config-health warning, so one
* bad config never fails a multi-store search (mirroring {@link loadSchemasForHits}).
*/
async function loadMatchersForHits(input: {
hits: RawHit[];
scopedKbs: ScopedKb[];
}): Promise<{ matchers: Map<string, NoteScopeMatcher>; warnings: string[] }> {
const matchers = new Map<string, NoteScopeMatcher>();
const warnings: string[] = [];
for (const kbPath of new Set(input.hits.map((hit) => hit.kbPath))) {
let config = defaultKbConfig;
try {
config = await loadKbConfig({ kbRoot: { path: kbPath, kbDir: join(kbPath, '.kb'), via: 'ancestor-walk' } });
} catch (error) {
warnings.push(formatConfigInvalid({ kbPath, scopedKbs: input.scopedKbs, error }));
}
matchers.set(kbPath, createNoteScopeMatcher(config));
}
return { matchers, warnings };
}

/**
* Phrases the config-health warning for a KB whose `.kb/config.yaml` could not be loaded. A named registry entry
* reports its name; a `.kb/`-discovered KB (no registry name) reports its path.
*/
function formatConfigInvalid(input: { kbPath: string; scopedKbs: ScopedKb[]; error: unknown }): string {
const name = input.scopedKbs.find((kb) => kb.path === input.kbPath)?.name ?? null;
const message = input.error instanceof Error ? input.error.message : String(input.error);
return name === null
? `discovered KB config invalid at ${input.kbPath}: ${message}`
: `registry KB "${name}" config invalid: ${message}`;
}

/**
* Loads the effective schema for every KB that produced a hit, keyed by KB root path, so normalization can drive each
* note's ranking signals from its record type's declared `recall` policy. Only KBs with hits are read. A KB whose
Expand Down
19 changes: 8 additions & 11 deletions packages/kb/src/check/enumerate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import { readdir, readFile } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
import process from 'node:process';

import picomatch from 'picomatch';

import type { KbConfig } from '../config/config-schema.ts';
import { createNoteScopeMatcher, type NoteScopeMatcher } from '../config/note-scope.ts';
import { parseNoteContent } from '../frontmatter/parse-note.ts';
import type { ParsedNote } from '../types.ts';
import { isGlobSegment } from './glob-segments.ts';
Expand Down Expand Up @@ -33,12 +32,11 @@ export interface EnumeratedNote {
*/
export async function enumerateNotes(input: { kbRoot: string; config: KbConfig }): Promise<EnumeratedNote[]> {
const { kbRoot, config } = input;
const isTarget = picomatch([...config.targets], { dot: false });
const isExcluded = picomatch([...config.exclude], { dot: false });
const matcher = createNoteScopeMatcher(config);
const topLevelDirs = leadingLiteralSegments(config.targets);

const notes: EnumeratedNote[] = [];
await walk({ root: kbRoot, dir: kbRoot, isTarget, isExcluded, topLevelDirs, out: notes });
await walk({ root: kbRoot, dir: kbRoot, matcher, topLevelDirs, out: notes });
return notes;
}

Expand All @@ -64,13 +62,12 @@ function leadingLiteralSegments(targets: readonly string[]): ReadonlySet<string>
async function walk(input: {
root: string;
dir: string;
isTarget: (test: string) => boolean;
isExcluded: (test: string) => boolean;
matcher: NoteScopeMatcher;
/** Top-level directory names to descend into, or `null` to walk the entire tree. */
topLevelDirs: ReadonlySet<string> | null;
out: EnumeratedNote[];
}): Promise<void> {
const { root, dir, isTarget, isExcluded, topLevelDirs, out } = input;
const { root, dir, matcher, topLevelDirs, out } = input;

let entries;
try {
Expand All @@ -89,13 +86,13 @@ async function walk(input: {
if (entry.isDirectory()) {
// Prune to the targets' leading literal segments at the top level; deeper levels always descend.
if (atRoot && topLevelDirs !== null && !topLevelDirs.has(entry.name)) continue;
if (isExcluded(relativePath)) continue;
await walk({ root, dir: absolutePath, isTarget, isExcluded, topLevelDirs, out });
if (matcher.isExcluded(relativePath)) continue;
await walk({ root, dir: absolutePath, matcher, topLevelDirs, out });
continue;
}

if (!entry.name.endsWith('.md')) continue;
if (!isTarget(relativePath) || isExcluded(relativePath)) continue;
if (!matcher.isNote(relativePath)) continue;

try {
const content = await readFile(absolutePath, 'utf8');
Expand Down
40 changes: 40 additions & 0 deletions packages/kb/src/config/__tests__/note-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';

import type { KbConfig } from '../config-schema.ts';
import { defaultKbConfig } from '../config-schema.ts';
import { createNoteScopeMatcher } from '../note-scope.ts';

describe(createNoteScopeMatcher, () => {
it('classifies a note under the default targets and rejects a root non-note', () => {
const matcher = createNoteScopeMatcher(defaultKbConfig);

expect(matcher.isNote('content/howto/deploy.md')).toBe(true);
expect(matcher.isNote('README.md')).toBe(false);
});

it('treats a dot-directory path as outside the note set', () => {
const matcher = createNoteScopeMatcher(defaultKbConfig);

// `dot:false` keeps `**`/`*` from matching dot-segments, so a path under `.kb` is never a note.
expect(matcher.isNote('.kb/config.md')).toBe(false);
});

it('lets an exclude override a matching target', () => {
const config: KbConfig = { targets: ['content/**/*.md'], exclude: ['content/drafts/**'] };
const matcher = createNoteScopeMatcher(config);

expect(matcher.isTarget('content/drafts/wip.md')).toBe(true);
expect(matcher.isExcluded('content/drafts/wip.md')).toBe(true);
expect(matcher.isNote('content/drafts/wip.md')).toBe(false);
expect(matcher.isNote('content/published.md')).toBe(true);
});

it('honors a custom whole-tree target', () => {
const config: KbConfig = { targets: ['**/*.md'], exclude: ['**/node_modules/**'] };
const matcher = createNoteScopeMatcher(config);

expect(matcher.isNote('notes/2024-archive/runbook.md')).toBe(true);
expect(matcher.isNote('root-note.md')).toBe(true);
expect(matcher.isNote('vendor/node_modules/pkg/readme.md')).toBe(false);
});
});
1 change: 1 addition & 0 deletions packages/kb/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { configFileShape, defaultKbConfig, type KbConfig } from './config-schema.ts';
export { isKbLoaderError, KbLoaderError } from './kb-loader-error.ts';
export { CONFIG_FILE, loadKbConfig } from './load-config.ts';
export { createNoteScopeMatcher, type NoteScopeMatcher } from './note-scope.ts';
32 changes: 32 additions & 0 deletions packages/kb/src/config/note-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import picomatch from 'picomatch';

import type { KbConfig } from './config-schema.ts';

/** The note-membership predicates a {@link KbConfig} defines, all matching KB-root-relative, slash-separated paths. */
export interface NoteScopeMatcher {
/** True when the path matches a `targets` glob. */
isTarget(relativePath: string): boolean;
/** True when the path matches an `exclude` glob. */
isExcluded(relativePath: string): boolean;
/** True when the path is a note: matched by `targets` and not `exclude`d. The `.md` gate is the caller's. */
isNote(relativePath: string): boolean;
}

/**
* Builds the note-membership predicates for a KB config — the single definition of "a note" shared by
* `enumerateNotes` (which `kb check`/`kb-curate` drive) and `kb-retrieve`. Both axes match with `picomatch`'s
* `dot:false`, so dot-directories (`.kb`, `.git`, `.agents`) are excluded implicitly without naming them in `exclude`.
*
* The `.md` extension gate is deliberately left to the caller: `enumerateNotes` applies its own `.endsWith('.md')`
* during the walk, and `kb-retrieve` constrains ripgrep with `--glob '*.md'`. Keeping it out of `isNote` lets this
* matcher govern only the `targets`/`exclude` dimension, the one place the two tools previously disagreed.
*/
export function createNoteScopeMatcher(config: KbConfig): NoteScopeMatcher {
const isTarget = picomatch([...config.targets], { dot: false });
const isExcluded = picomatch([...config.exclude], { dot: false });
return {
isTarget,
isExcluded,
isNote: (relativePath) => isTarget(relativePath) && !isExcluded(relativePath),
};
}
Loading