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
10 changes: 8 additions & 2 deletions bin/knowledge-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -17248,6 +17248,12 @@ import {
} from "fs";
import { randomUUID } from "crypto";
import { basename, dirname as dirname2, join as join3 } from "path";
function itemMatchesSearch(item, needle) {
if (!needle)
return true;
const q = needle.toLowerCase();
return item.id.toLowerCase().includes(q) || item.title.toLowerCase().includes(q) || item.content.toLowerCase().includes(q);
}
function defaultStorePath() {
return workspaceForHome(globalKnowledgeHome()).jsonStorePath;
}
Expand Down Expand Up @@ -32913,7 +32919,7 @@ function buildServer() {
return jsonText({ ok: true, item, message: `Added ${item.id}` });
});
registerTool(server, "ok_list", "List knowledge items", "List compact item summaries with pagination, search, tag filtering, and sorting", {
search: exports_external.string().optional().describe("Search text for title/content"),
search: exports_external.string().optional().describe("Case-insensitive literal substring filter over id, title and content. Not a semantic search \u2014 use ok_search for that."),
tag: exports_external.array(exports_external.string()).optional().describe("Filter by tags; item must match all tags"),
include_archived: exports_external.boolean().optional().describe("Include archived items"),
include_content: exports_external.boolean().optional().describe("Include full item content in each list row; default false to keep agent output compact"),
Expand All @@ -32931,7 +32937,7 @@ function buildServer() {
const requiredTags = (tag ?? []).map((entry) => entry.toLowerCase());
let items = activeItems(all, include_archived);
if (q)
items = items.filter((item) => item.title.toLowerCase().includes(q) || item.content.toLowerCase().includes(q));
items = items.filter((item) => itemMatchesSearch(item, q));
if (requiredTags.length > 0) {
items = items.filter((item) => {
const itemTags = (item.tags ?? []).map((entry) => entry.toLowerCase());
Expand Down
332 changes: 168 additions & 164 deletions bin/knowledge.js

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions dist/store.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,33 @@ export interface KnowledgeItem {
*/
version?: number;
}
/**
* The one predicate behind every `--search` / `search:` free-text filter over stored
* items — `knowledge list --search` and the `ok_list` MCP tool.
*
* It is a CASE-INSENSITIVE LITERAL SUBSTRING test, not a tokenised or semantic search.
* `knowledge search` is the semantic verb and is a different code path entirely; this
* one deliberately stays a cheap filter, because it has to compose with tag filtering,
* sorting and pagination over a fully materialised list.
*
* IT MATCHES `id` AS WELL AS `title` AND `content`, and the id is the reason this
* function exists. The filter used to read title and content only, so resolving an item
* by its own slug — the dominant instructed use of the flag across the skill corpus, and
* the DEDUPE path that decides whether an artefact already exists — returned `total: 0`
* at exit 0 for an item that was demonstrably present. A false zero there reads as "no
* existing item, safe to create", so the omission manufactured duplicate knowledge items.
*
* What hid it: an item whose CONTENT happens to quote its own slug matched anyway, so a
* spot check could pass for entirely the wrong reason. Measured on the fleet store before
* the fix — `hasna-loop-naming-convention` and `hasna-knowledge-taxonomy` both existed and
* were both unfindable by their own ids, while `hasna-agent-identity-convention` was found
* only because its body cites its own slug.
*
* `short_id` is deliberately NOT matched. It is an opaque short handle rather than the
* slug agents are instructed to resolve, and widening identity matching further is a
* separate decision from repairing the one that was broken.
*/
export declare function itemMatchesSearch(item: Pick<KnowledgeItem, 'id' | 'title' | 'content'>, needle: string): boolean;
/**
* An immutable snapshot of an entry as it stood BEFORE the edit that produced
* the next version. Written only by the database trigger (see
Expand Down
12 changes: 8 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Copyright 2026 Hasna Inc.
* Licensed under the Apache License, Version 2.0
*/
import { defaultStorePath, ensureStore, importLegacyGlobalStore, type KnowledgeItem } from './store';
import { defaultStorePath, ensureStore, importLegacyGlobalStore, itemMatchesSearch, type KnowledgeItem } from './store';
import { resolveItemStore, type ItemStore } from './item-store';
import { isKnowledgeApiMode } from './cloud-store';
import { diffEntries, formatEntryDiff, type EntrySnapshot } from './entry-diff';
Expand Down Expand Up @@ -441,7 +441,7 @@ List Options:
--format table|json Output format (default: table if TTY, json otherwise)
-p, --page <n> Page number (default: 1)
-l, --limit <n> Items per page (default: 20)
-s, --search <text> Filter by title/content
-s, --search <text> Filter by id/title/content (case-insensitive substring; use 'knowledge search' for semantic)
-t, --tag <tag> Filter by tag; repeatable/comma-separated, item must match ALL
--sort <created|title> Sort field (default: created)
--desc Sort descending
Expand Down Expand Up @@ -472,7 +472,7 @@ Prune Options:

function printCommandHelp(command: string): void {
if (command === 'add') { console.log('Usage: knowledge add <title> <content> [--url <url>] [-t <tag>]... [--json]\n -t/--tag is repeatable and accepts comma-separated values: -t a -t b == -t "a,b"'); return; }
if (command === 'list' || command === 'ls') { console.log('Usage: knowledge list|ls [--format table|json] [-p <page>] [-l <limit>] [-s <search>] [-t <tag>]... [--sort created|title] [--desc] [--archived] [--include-archived] [--verbose] [--json]\n -t/--tag is repeatable and accepts comma-separated values; repeated -t narrows (an item must carry every tag).\n Each value matches an item carrying the whole value OR all of its comma-split names — a union, so\n `-t "a,b,c"` finds items carrying a legacy literal "a,b,c" tag as well as items carrying the three\n names separately. (`untag` differs on purpose: it stops at the whole-value match.)\n Use --json to tell those two shapes apart; the table renders them near-identically.\n Archived items are excluded by default; add --include-archived to sweep both.\n If both --archived and --include-archived are passed, --archived wins (archived items only).'); return; }
if (command === 'list' || command === 'ls') { console.log('Usage: knowledge list|ls [--format table|json] [-p <page>] [-l <limit>] [-s <search>] [-t <tag>]... [--sort created|title] [--desc] [--archived] [--include-archived] [--verbose] [--json]\n -s/--search is a CASE-INSENSITIVE LITERAL SUBSTRING filter over id, title and content — not a\n tokenised or semantic search, so a word order that never appears verbatim matches nothing. It\n resolves an item by its slug because the id is included. For meaning-based lookup use `knowledge\n search <query>`, which is a different index and will find items this filter cannot.\n -t/--tag is repeatable and accepts comma-separated values; repeated -t narrows (an item must carry every tag).\n Each value matches an item carrying the whole value OR all of its comma-split names — a union, so\n `-t "a,b,c"` finds items carrying a legacy literal "a,b,c" tag as well as items carrying the three\n names separately. (`untag` differs on purpose: it stops at the whole-value match.)\n Use --json to tell those two shapes apart; the table renders them near-identically.\n Archived items are excluded by default; add --include-archived to sweep both.\n If both --archived and --include-archived are passed, --archived wins (archived items only).'); return; }
if (command === 'get') { console.log('Usage: knowledge get --id <id> [--json]'); return; }
if (command === 'update' || command === 'edit') { console.log('Usage: knowledge update|edit --id <id> [--title <title>] [--content <content>] [--url <url>] [-t <tag>]... [--json]\n -t/--tag is repeatable and accepts comma-separated values; tags are added, never replaced.\n With -t the output reports how many tags were actually added, so 0 added is not read as 3.'); return; }
if (command === 'archive') { console.log('Usage: knowledge archive --id <id> [--json]'); return; }
Expand Down Expand Up @@ -1925,7 +1925,11 @@ async function run(argv: string[]): Promise<void> {
// win, so this is documented rather than changed — flipping it would alter behaviour.
if (flags.archived) filtered = filtered.filter((x) => x.archived === true);
else if (!flags.includeArchived) filtered = filtered.filter((x) => !x.archived);
if (search) filtered = filtered.filter((x) => x.title.toLowerCase().includes(search) || x.content.toLowerCase().includes(search));
// Matches id, title and content — see itemMatchesSearch in store.ts for why the id is
// in there. Keep this delegating rather than inlining the predicate again: the id was
// missing here and in the two mcp.js copies simultaneously, which is what a duplicated
// one-liner buys you.
if (search) filtered = filtered.filter((x) => itemMatchesSearch(x, search));
if (tagFilters.length > 0) filtered = filtered.filter((x) => {
const itemTags = new Set((x.tags ?? []).map((t) => t.toLowerCase()));
return tagFilters.every(({ whole, parts }) => (whole.length > 0 && itemTags.has(whole)) || parts.every((wanted) => itemTags.has(wanted)));
Expand Down
17 changes: 14 additions & 3 deletions src/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { z } from 'zod';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import pkg from '../package.json' with { type: 'json' };
import { migrateKnowledgeDb, openKnowledgeDb } from './knowledge-db.ts';
import { defaultStorePath } from './store.ts';
import { defaultStorePath, itemMatchesSearch } from './store.ts';
import { resolveItemStore } from './item-store.ts';
import { isKnowledgeApiMode } from './cloud-store.ts';
import { assertKnowledgeModeSelected } from './knowledge-mode.ts';
Expand Down Expand Up @@ -1537,7 +1537,7 @@ export function buildServer() {
});

registerTool(server, 'ok_list', 'List knowledge items', 'List compact item summaries with pagination, search, tag filtering, and sorting', {
search: z.string().optional().describe('Search text for title/content'),
search: z.string().optional().describe('Case-insensitive literal substring filter over id, title and content. Not a semantic search — use ok_search for that.'),
tag: z.array(z.string()).optional().describe('Filter by tags; item must match all tags'),
include_archived: z.boolean().optional().describe('Include archived items'),
include_content: z.boolean().optional().describe('Include full item content in each list row; default false to keep agent output compact'),
Expand All @@ -1554,7 +1554,7 @@ export function buildServer() {
const q = search ? search.toLowerCase() : '';
const requiredTags = (tag ?? []).map((entry) => entry.toLowerCase());
let items = activeItems(all, include_archived);
if (q) items = items.filter((item) => item.title.toLowerCase().includes(q) || item.content.toLowerCase().includes(q));
if (q) items = items.filter((item) => itemMatchesSearch(item, q));
if (requiredTags.length > 0) {
items = items.filter((item) => {
const itemTags = (item.tags ?? []).map((entry) => entry.toLowerCase());
Expand Down Expand Up @@ -1762,6 +1762,17 @@ export function buildServer() {
const q = search ? search.toLowerCase() : '';
const tags = (tag ?? []).map((entry) => entry.toLowerCase());
const deleteIds = all.filter((item) => {
// DELIBERATELY NARROWER THAN ok_list / `knowledge list --search`, which also match the
// item's `id`. This is the destructive verb, so it is not widened along with them: an
// agent that passes a slug-shaped query here would otherwise start deleting items it
// had never previewed, and "the read filter was repaired" is not a reason to enlarge
// what a delete removes.
//
// The divergence is safe in one direction only, which is why it is acceptable at all:
// this predicate matches a SUBSET of what ok_list shows for the same query, so a
// preview can over-report what a delete will remove but can never under-report it.
// Do not "align" these by widening this one — align them by narrowing, or not at all.
// Deleting by id is what ok_delete is for.
const matchesSearch = q ? item.title.toLowerCase().includes(q) || item.content.toLowerCase().includes(q) : false;
const itemTags = (item.tags ?? []).map((entry) => entry.toLowerCase());
const matchesTag = tags.length > 0 ? tags.some((entry) => itemTags.includes(entry)) : false;
Expand Down
39 changes: 39 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,45 @@ export interface KnowledgeItem {
version?: number;
}

/**
* The one predicate behind every `--search` / `search:` free-text filter over stored
* items — `knowledge list --search` and the `ok_list` MCP tool.
*
* It is a CASE-INSENSITIVE LITERAL SUBSTRING test, not a tokenised or semantic search.
* `knowledge search` is the semantic verb and is a different code path entirely; this
* one deliberately stays a cheap filter, because it has to compose with tag filtering,
* sorting and pagination over a fully materialised list.
*
* IT MATCHES `id` AS WELL AS `title` AND `content`, and the id is the reason this
* function exists. The filter used to read title and content only, so resolving an item
* by its own slug — the dominant instructed use of the flag across the skill corpus, and
* the DEDUPE path that decides whether an artefact already exists — returned `total: 0`
* at exit 0 for an item that was demonstrably present. A false zero there reads as "no
* existing item, safe to create", so the omission manufactured duplicate knowledge items.
*
* What hid it: an item whose CONTENT happens to quote its own slug matched anyway, so a
* spot check could pass for entirely the wrong reason. Measured on the fleet store before
* the fix — `hasna-loop-naming-convention` and `hasna-knowledge-taxonomy` both existed and
* were both unfindable by their own ids, while `hasna-agent-identity-convention` was found
* only because its body cites its own slug.
*
* `short_id` is deliberately NOT matched. It is an opaque short handle rather than the
* slug agents are instructed to resolve, and widening identity matching further is a
* separate decision from repairing the one that was broken.
*/
export function itemMatchesSearch(
item: Pick<KnowledgeItem, 'id' | 'title' | 'content'>,
needle: string,
): boolean {
if (!needle) return true;
const q = needle.toLowerCase();
return (
item.id.toLowerCase().includes(q) ||
item.title.toLowerCase().includes(q) ||
item.content.toLowerCase().includes(q)
);
}

/**
* An immutable snapshot of an entry as it stood BEFORE the edit that produced
* the next version. Written only by the database trigger (see
Expand Down
70 changes: 70 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,76 @@ describe('knowledge cli', () => {
]);
});

// Regression guard for the slug-resolution defect in `list --search`.
//
// `--search` is a case-insensitive literal substring filter, and it matched ONLY
// `title` and `content` — never the item's `id`. The dominant instructed use of the
// flag across the skill corpus is SLUG RESOLUTION ("resolve knowledge slugs via
// `knowledge list --search <slug>`"), and that use failed silently: an existing item
// queried by its own id returned `total: 0` at exit 0, with nothing to distinguish it
// from a genuinely absent item. Because this is the dedupe path, that false zero reads
// as "no existing item, safe to create" and manufactures duplicate knowledge items.
//
// What made it survive: an item whose CONTENT happens to quote its own slug matched
// anyway, so a spot check could pass for the wrong reason. `id-not-in-body` below is
// the case that cannot pass by coincidence — its slug appears in neither field — and
// `id-quoted-in-body` pins the coincidence so a future reader cannot mistake it for
// evidence that ids were being searched all along.
test('list --search resolves an item by its id, not only title and content', () => {
const dir = mkdtempSync(join(tmpdir(), 'ok-list-search-id-'));
const store = join(dir, 'db.json');
const decode = (buf: Uint8Array) => new TextDecoder().decode(buf);
const total = (args: string[]): number => {
const res = runCli(['list', '--store', store, '--json', '-l', '50', ...args]);
expect(res.exitCode).toBe(0);
return JSON.parse(decode(res.stdout)).total;
};
const ids = (args: string[]): string[] => {
const res = runCli(['list', '--store', store, '--json', '-l', '50', ...args]);
expect(res.exitCode).toBe(0);
return JSON.parse(decode(res.stdout)).items.map((item: { id: string }) => item.id);
};

// The defect case: the slug is in NEITHER the title nor the content, so nothing but
// an id-aware filter can return it.
const seeded = runCli([
'upsert', 'Loop labelling taxonomy', 'Body about labelling and classification.',
'--id', 'id-not-in-body', '--store', store, '--json',
]);
expect(seeded.exitCode).toBe(0);

// The coincidence case, pinned so it is never read as evidence about ids.
const quoted = runCli([
'upsert', 'Quoted slug', 'This body mentions id-quoted-in-body verbatim.',
'--id', 'id-quoted-in-body', '--store', store, '--json',
]);
expect(quoted.exitCode).toBe(0);

// Positive control on the STORE, not just the query: both items really are present,
// so a zero below is a statement about the filter and not about an empty fixture.
expect(total([])).toBe(2);

// The regression itself.
expect(ids(['--search', 'id-not-in-body'])).toEqual(['id-not-in-body']);

// Case-insensitive on the id, matching how the flag already treats title and content.
expect(ids(['--search', 'ID-NOT-IN-BODY'])).toEqual(['id-not-in-body']);

// Substring of an id must match, since this is a substring filter and not a token search.
expect(ids(['--search', 'not-in-body'])).toEqual(['id-not-in-body']);

// No regression: title and content matching still work and still return only their match.
expect(ids(['--search', 'labelling taxonomy'])).toEqual(['id-not-in-body']);
expect(ids(['--search', 'mentions'])).toEqual(['id-quoted-in-body']);

// NEGATIVE CONTROL — the check must be able to report zero. Without this the
// assertions above would also pass against a filter that returned everything.
expect(total(['--search', 'no-item-carries-this-string'])).toBe(0);
// Ten CLI spawns at roughly half a second each sit right on the 5s default, so the
// budget is explicit rather than left to chance. It was measured under a full-suite
// run, not in isolation — see the 20000 used by the other multi-spawn tests here.
}, 20000);

// Regression guard for the silent multi-tag data-loss defect: `add -t a -t b -t c`
// exited 0, logged "Item added", and persisted ONLY the last tag; `-t "a,b,c"`
// stored one literal comma string. Every assertion below reads the PERSISTED item
Expand Down
Loading
Loading