From 6bc121e383b24105fb0ab368cf45f9b0910cb56d Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Mon, 13 Jul 2026 16:05:09 -0600 Subject: [PATCH 1/2] Hide packages from search by default Add a user-scoped visibility control so saved packages can be removed from discovery without disabling their runtime or management surfaces. Co-authored-by: Cursor --- .../contributing/architecture/data-storage.md | 4 +- docs/contributing/packages-and-manifests.md | 8 + docs/use/packages.md | 16 ++ docs/use/search.md | 7 + .../migrations/0058-saved-packages-hidden.sql | 2 + ...unt-package-invocation-tokens.node.test.ts | 1 + .../app/handlers/account-secrets.node.test.ts | 2 + .../src/app/handlers/package-app.node.test.ts | 1 + .../community/community-flow-test-schema.ts | 1 + .../community/community-service.node.test.ts | 2 + .../worker/src/email/inbound.workers.test.ts | 1 + ...system-email-subscriptions.workers.test.ts | 1 + .../meta/search-include-hidden.node.test.ts | 121 ++++++++++ .../src/mcp/capabilities/meta/search.ts | 19 +- .../operation-request.node.test.ts | 1 + .../packages/create-stub-package.node.test.ts | 1 + .../packages/create-stub-package.ts | 1 + .../src/mcp/capabilities/packages/domain.ts | 2 + .../packages/get-package.node.test.ts | 2 + .../mcp/capabilities/packages/get-package.ts | 1 + .../list-package-subscriptions.node.test.ts | 6 + .../capabilities/packages/list-packages.ts | 1 + .../save-package-entitlements.node.test.ts | 6 +- ...ve-package-private-visibility.node.test.ts | 1 + .../mcp/capabilities/packages/save-package.ts | 2 + .../packages/set-package-hidden.node.test.ts | 86 +++++++ .../packages/set-package-hidden.ts | 47 ++++ .../src/mcp/capabilities/packages/shared.ts | 1 + .../repo/repo-list-sessions.node.test.ts | 2 + .../repo/repo-open-session.node.test.ts | 1 + .../repo/repo-workflow.node.test.ts | 1 + .../services/service-start.node.test.ts | 1 + .../services/services-domain.node.test.ts | 1 + .../worker/src/mcp/fetch-gateway.node.test.ts | 1 + .../src/mcp/tools/search-format.node.test.ts | 8 + .../worker/src/mcp/tools/search-format.ts | 5 + .../src/mcp/tools/search-handler.node.test.ts | 168 +++++++++++--- .../tools/search-hidden-packages.node.test.ts | 213 ++++++++++++++++++ .../worker/src/mcp/tools/search.node.test.ts | 12 + packages/worker/src/mcp/tools/search.ts | 16 +- .../package-invocations/service.node.test.ts | 3 + .../package-reindex.node.test.ts | 4 + packages/worker/src/package-registry/repo.ts | 21 +- ...ved-packages-hidden-migration.node.test.ts | 85 +++++++ .../src/package-registry/service.node.test.ts | 62 +++++ .../worker/src/package-registry/service.ts | 19 +- packages/worker/src/package-registry/types.ts | 2 + .../manifest-cache.node.test.ts | 1 + .../worker/src/package-retrievers/service.ts | 13 ++ .../package-runtime/module-graph.node.test.ts | 4 + .../module-graph.workers.test.ts | 1 + .../published-bundle-artifacts.node.test.ts | 3 + ...shed-bundle-artifacts-repo.workers.test.ts | 1 + 53 files changed, 939 insertions(+), 53 deletions(-) create mode 100644 packages/worker/migrations/0058-saved-packages-hidden.sql create mode 100644 packages/worker/src/mcp/capabilities/meta/search-include-hidden.node.test.ts create mode 100644 packages/worker/src/mcp/capabilities/packages/set-package-hidden.node.test.ts create mode 100644 packages/worker/src/mcp/capabilities/packages/set-package-hidden.ts create mode 100644 packages/worker/src/mcp/tools/search-hidden-packages.node.test.ts create mode 100644 packages/worker/src/package-registry/saved-packages-hidden-migration.node.test.ts diff --git a/docs/contributing/architecture/data-storage.md b/docs/contributing/architecture/data-storage.md index 52562204a9..13ef114ca3 100644 --- a/docs/contributing/architecture/data-storage.md +++ b/docs/contributing/architecture/data-storage.md @@ -182,7 +182,9 @@ The schema is defined by migrations in `packages/worker/migrations/`: - `entity_sources`: durable mapping from user-facing entities to Artifacts repos and their latest published commit - `saved_packages`: package metadata/search projection derived from published - `package.json` source + `package.json` source, plus a user-scoped `hidden` flag (0/1) that excludes + the package from default ranked search while leaving list/get/execute paths + intact - `secret_buckets`: encrypted-secret ownership buckets scoped to `user`, `package`, or `session`. Package buckets bind directly to `saved_packages.id`; package runtimes may use their own package secrets, while user secrets require diff --git a/docs/contributing/packages-and-manifests.md b/docs/contributing/packages-and-manifests.md index 994e5e833d..264ef299e6 100644 --- a/docs/contributing/packages-and-manifests.md +++ b/docs/contributing/packages-and-manifests.md @@ -415,3 +415,11 @@ Search returns packages as the saved-entity unit. Package detail should expose nested exports, nested jobs, tags, and app presence. Search should not frame exports or jobs as separate top-level saved entities. + +Saved packages carry a user-scoped **`hidden`** flag in `saved_packages` (set +via **`package_set_hidden`**). Ranked search excludes hidden packages by +default. The public MCP **search** tool accepts **`includeHiddenPackages`**; the +**meta** domain **search** capability accepts **`include_hidden`**. Known-id +entity lookup, **`package_list`**, **`package_get`**, and context-scope package +retrievers are unaffected. Hiding is not deletion, community delisting, or +entitlement exclusion. diff --git a/docs/use/packages.md b/docs/use/packages.md index 6fcde85bf6..825f5016fa 100644 --- a/docs/use/packages.md +++ b/docs/use/packages.md @@ -374,9 +374,25 @@ Use: - `package_save` to create or replace a saved package from a complete UTF-8 text file set when no local git client is available - `package_get` and `package_list` to inspect saved packages +- `package_set_hidden` to hide or unhide a package from default search discovery - `repo_run_commands` to edit, check, and publish repo-backed package source after it exists using parsed, git-only command forms rather than shell +## Hidden packages + +Use **`package_set_hidden`** with a saved **`package_id`** and +**`hidden: true`** to hide a package from ordinary ranked search. Set +**`hidden: false`** to show it again. + +Hiding is a discovery preference, not deletion. The package stays saved, +executable, and editable. Hiding is separate from **`package.json#private`** +(community publishing) and from entitlement or access grants. + +**`package_list`** and **`package_get`** return a **`hidden`** boolean on each +package summary. Ranked **search** excludes hidden packages unless the caller +passes **`includeHiddenPackages: true`**. Known-id **`entity`** lookups still +resolve hidden packages. + ## Author a saved package via direct git push Saved package source is backed by a Cloudflare Artifacts git repository. You can diff --git a/docs/use/search.md b/docs/use/search.md index 7e2a21a1fb..80886defce 100644 --- a/docs/use/search.md +++ b/docs/use/search.md @@ -8,6 +8,13 @@ The **search** tool finds **built-in capabilities**, **saved packages**, (`community_search`, `community_get`) or the public `/community` pages. See [Community packages](./community-packages.md). +**Hidden saved packages** are excluded from ranked **query** results by default. +Pass **`includeHiddenPackages: true`** to include them. Hiding is not deletion: +known-id **`entity`** lookups (for example `my-package:package`), +**`package_list`**, and **`package_get`** still work. Use +**`package_set_hidden`** to hide or unhide a package. See +[Packages](./packages.md#hidden-packages). + ## Queries and ranking Pass a **`query`** string that describes what you want to do. Results are diff --git a/packages/worker/migrations/0058-saved-packages-hidden.sql b/packages/worker/migrations/0058-saved-packages-hidden.sql new file mode 100644 index 0000000000..3d72326696 --- /dev/null +++ b/packages/worker/migrations/0058-saved-packages-hidden.sql @@ -0,0 +1,2 @@ +ALTER TABLE saved_packages +ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1)); diff --git a/packages/worker/src/app/handlers/account-package-invocation-tokens.node.test.ts b/packages/worker/src/app/handlers/account-package-invocation-tokens.node.test.ts index 57b6f1b7d2..9318829007 100644 --- a/packages/worker/src/app/handlers/account-package-invocation-tokens.node.test.ts +++ b/packages/worker/src/app/handlers/account-package-invocation-tokens.node.test.ts @@ -56,6 +56,7 @@ const mockModule = vi.hoisted(() => ({ searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), }, diff --git a/packages/worker/src/app/handlers/account-secrets.node.test.ts b/packages/worker/src/app/handlers/account-secrets.node.test.ts index d2621c9dd8..eaa47a9524 100644 --- a/packages/worker/src/app/handlers/account-secrets.node.test.ts +++ b/packages/worker/src/app/handlers/account-secrets.node.test.ts @@ -488,6 +488,7 @@ test('account secrets payload includes all packages and package titles and allow searchText: null, sourceId: 'source-1', hasApp: true, + hidden: false, createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), }, @@ -501,6 +502,7 @@ test('account secrets payload includes all packages and package titles and allow searchText: null, sourceId: 'source-2', hasApp: false, + hidden: false, createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), }, diff --git a/packages/worker/src/app/handlers/package-app.node.test.ts b/packages/worker/src/app/handlers/package-app.node.test.ts index 9b385c06a2..d12cf4a7bd 100644 --- a/packages/worker/src/app/handlers/package-app.node.test.ts +++ b/packages/worker/src/app/handlers/package-app.node.test.ts @@ -35,6 +35,7 @@ const mockModule = vi.hoisted(() => ({ searchText: null, sourceId: 'source-1', hasApp: true, + hidden: false, createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), })), diff --git a/packages/worker/src/community/community-flow-test-schema.ts b/packages/worker/src/community/community-flow-test-schema.ts index 94eabada1a..7eaf68dc21 100644 --- a/packages/worker/src/community/community-flow-test-schema.ts +++ b/packages/worker/src/community/community-flow-test-schema.ts @@ -18,6 +18,7 @@ export async function ensureCommunityFlowSchema(db: D1Database) { search_text TEXT, source_id TEXT NOT NULL, has_app INTEGER NOT NULL DEFAULT 0 CHECK (has_app IN (0, 1)), + hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1)), created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) )`, diff --git a/packages/worker/src/community/community-service.node.test.ts b/packages/worker/src/community/community-service.node.test.ts index 8716ae5ae2..13664c5497 100644 --- a/packages/worker/src/community/community-service.node.test.ts +++ b/packages/worker/src/community/community-service.node.test.ts @@ -250,6 +250,7 @@ function validSavedPackage() { searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-07-01T00:00:00.000Z', updatedAt: '2026-07-01T00:00:00.000Z', } @@ -555,6 +556,7 @@ test('publishCommunityListing requires MIT license and Intent heading', async () searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-07-01T00:00:00.000Z', updatedAt: '2026-07-01T00:00:00.000Z', }) diff --git a/packages/worker/src/email/inbound.workers.test.ts b/packages/worker/src/email/inbound.workers.test.ts index c31489c2da..6f4bc0e4dd 100644 --- a/packages/worker/src/email/inbound.workers.test.ts +++ b/packages/worker/src/email/inbound.workers.test.ts @@ -824,6 +824,7 @@ test('inbound email handler dispatches package subscriptions for stored inbound search_text TEXT, source_id TEXT NOT NULL, has_app INTEGER NOT NULL DEFAULT 0, + hidden INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, diff --git a/packages/worker/src/email/system-email-subscriptions.workers.test.ts b/packages/worker/src/email/system-email-subscriptions.workers.test.ts index 79828e338b..0f5366ccf1 100644 --- a/packages/worker/src/email/system-email-subscriptions.workers.test.ts +++ b/packages/worker/src/email/system-email-subscriptions.workers.test.ts @@ -30,6 +30,7 @@ async function ensurePackageSubscriptionTestSchema(db: D1Database) { search_text TEXT, source_id TEXT NOT NULL, has_app INTEGER NOT NULL DEFAULT 0, + hidden INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, diff --git a/packages/worker/src/mcp/capabilities/meta/search-include-hidden.node.test.ts b/packages/worker/src/mcp/capabilities/meta/search-include-hidden.node.test.ts new file mode 100644 index 0000000000..7d9803bc38 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/meta/search-include-hidden.node.test.ts @@ -0,0 +1,121 @@ +import { expect, test, vi } from 'vitest' +import { createMcpCallerContext } from '#mcp/context.ts' + +const mockModule = vi.hoisted(() => ({ + loadSearchRowsAndRegistry: vi.fn(), + resolveSearchMemoryContext: vi.fn( + ({ query, memoryContext }: { query: string; memoryContext?: unknown }) => + memoryContext ?? { query }, + ), + searchUnified: vi.fn(), + loadDownRemoteConnectorStatuses: vi.fn(async () => []), + serializeRemoteConnectorStatus: vi.fn((status: unknown) => status), + runPackageRetrievers: vi.fn(), + loadRelevantMemoriesForTool: vi.fn(async () => null), + toSlimStructuredMatches: vi.fn( + ({ matches }: { matches: unknown }) => matches, + ), +})) + +vi.mock('#mcp/tools/search.ts', () => ({ + loadSearchRowsAndRegistry: (...args: Array) => + mockModule.loadSearchRowsAndRegistry(...args), + resolveSearchMemoryContext: (...args: Array) => + mockModule.resolveSearchMemoryContext(...args), + searchUnified: (...args: Array) => mockModule.searchUnified(...args), + loadDownRemoteConnectorStatuses: (...args: Array) => + mockModule.loadDownRemoteConnectorStatuses(...args), + serializeRemoteConnectorStatus: (...args: Array) => + mockModule.serializeRemoteConnectorStatus(...args), +})) + +vi.mock('#worker/package-retrievers/service.ts', () => ({ + runPackageRetrievers: (...args: Array) => + mockModule.runPackageRetrievers(...args), +})) + +vi.mock('#mcp/tools/memory-tool-context.ts', () => ({ + loadRelevantMemoriesForTool: (...args: Array) => + mockModule.loadRelevantMemoriesForTool(...args), +})) + +vi.mock('#mcp/tools/search-format.ts', () => ({ + toSlimStructuredMatches: (...args: Array) => + mockModule.toSlimStructuredMatches(...args), +})) + +const { searchCapability } = await import('./search.ts') + +function createCtx() { + return { + env: { APP_DB: {} } as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-1', + email: 'user@example.com', + displayName: 'User', + }, + }), + } +} + +function resetMocks() { + mockModule.loadSearchRowsAndRegistry.mockReset() + mockModule.resolveSearchMemoryContext.mockClear() + mockModule.searchUnified.mockReset() + mockModule.loadDownRemoteConnectorStatuses.mockClear() + mockModule.runPackageRetrievers.mockReset() + mockModule.loadRelevantMemoriesForTool.mockClear() + mockModule.toSlimStructuredMatches.mockClear() + + mockModule.loadSearchRowsAndRegistry.mockResolvedValue({ + registry: { capabilitySpecs: {} }, + packageRows: [], + userSecretRows: [], + userValueRows: [], + warnings: [], + }) + mockModule.runPackageRetrievers.mockResolvedValue({ + results: [], + warnings: [], + }) + mockModule.searchUnified.mockResolvedValue({ + matches: [], + offline: true, + }) +} + +test('meta search remaps include_hidden through to package rows and search-scope retrievers', async () => { + resetMocks() + const ctx = createCtx() + + await searchCapability.handler({ query: 'notes' }, ctx) + + expect(mockModule.loadSearchRowsAndRegistry).toHaveBeenCalledWith( + expect.objectContaining({ + includeHiddenPackages: false, + }), + ) + expect(mockModule.runPackageRetrievers).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'search', + includeHiddenPackages: false, + }), + ) + + resetMocks() + await searchCapability.handler({ query: 'notes', include_hidden: true }, ctx) + + expect(mockModule.loadSearchRowsAndRegistry).toHaveBeenCalledWith( + expect.objectContaining({ + includeHiddenPackages: true, + }), + ) + expect(mockModule.runPackageRetrievers).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'search', + includeHiddenPackages: true, + }), + ) +}) diff --git a/packages/worker/src/mcp/capabilities/meta/search.ts b/packages/worker/src/mcp/capabilities/meta/search.ts index baab9da690..94e2055d10 100644 --- a/packages/worker/src/mcp/capabilities/meta/search.ts +++ b/packages/worker/src/mcp/capabilities/meta/search.ts @@ -49,12 +49,14 @@ function normalizeLimit(limit: number | undefined) { async function loadSearchRows(input: { ctx: CapabilityContext userId: string | null + includeHiddenPackages: boolean }) { const { loadSearchRowsAndRegistry } = await import('#mcp/tools/search.ts') return await loadSearchRowsAndRegistry({ env: input.ctx.env, callerContext: input.ctx.callerContext, userId: input.userId, + includeHiddenPackages: input.includeHiddenPackages, }) } @@ -63,6 +65,7 @@ async function runPackageRetrieverSearch(input: { userId: string | null query: string conversationId: string + includeHiddenPackages: boolean memoryContext?: z.infer }) { if (!input.userId || !input.query) { @@ -77,6 +80,7 @@ async function runPackageRetrieverSearch(input: { userId: input.userId, scope: 'search', query: input.query, + includeHiddenPackages: input.includeHiddenPackages, memoryContext: resolveSearchMemoryContext({ query: input.query, memoryContext: input.memoryContext, @@ -117,6 +121,12 @@ export const searchCapability = defineDomainCapability( .describe('Max number of ranked results to return. Defaults to 15.'), conversationId: conversationIdInputField, memoryContext: memoryContextInputField, + include_hidden: z + .boolean() + .optional() + .describe( + 'Include hidden packages in search results (hidden packages are excluded by default).', + ), }), outputSchema: searchOutputSchema, async handler( @@ -125,6 +135,7 @@ export const searchCapability = defineDomainCapability( limit?: number conversationId?: string memoryContext?: z.infer + include_hidden?: boolean }, ctx: CapabilityContext, ) { @@ -134,13 +145,19 @@ export const searchCapability = defineDomainCapability( } const conversationId = resolveConversationId(args.conversationId) const userId = ctx.callerContext.user?.userId ?? null + const includeHiddenPackages = !!args.include_hidden const [searchRows, retrieverRun] = await Promise.all([ - loadSearchRows({ ctx, userId }), + loadSearchRows({ + ctx, + userId, + includeHiddenPackages, + }), runPackageRetrieverSearch({ ctx, userId, query, conversationId, + includeHiddenPackages, memoryContext: args.memoryContext, }), ]) diff --git a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts index 21f6084415..39c71154e0 100644 --- a/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts +++ b/packages/worker/src/mcp/capabilities/openapi-provider/operation-request.node.test.ts @@ -339,6 +339,7 @@ test('requires package approval before OpenAPI resolves a user secret', async () searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-07-09T00:00:00.000Z', updatedAt: '2026-07-09T00:00:00.000Z', }) diff --git a/packages/worker/src/mcp/capabilities/packages/create-stub-package.node.test.ts b/packages/worker/src/mcp/capabilities/packages/create-stub-package.node.test.ts index 69b1dfee2e..c5fd92c7cc 100644 --- a/packages/worker/src/mcp/capabilities/packages/create-stub-package.node.test.ts +++ b/packages/worker/src/mcp/capabilities/packages/create-stub-package.node.test.ts @@ -115,6 +115,7 @@ test('createStubSavedPackage rejects invalid kody ids and registers valid stubs description: 'Does the thing.', source_id: 'source-new', has_app: 0, + hidden: 0, }), ) expect(mockModule.upsertSavedPackageVector).toHaveBeenCalled() diff --git a/packages/worker/src/mcp/capabilities/packages/create-stub-package.ts b/packages/worker/src/mcp/capabilities/packages/create-stub-package.ts index fb4fc0a90a..83f7e34c66 100644 --- a/packages/worker/src/mcp/capabilities/packages/create-stub-package.ts +++ b/packages/worker/src/mcp/capabilities/packages/create-stub-package.ts @@ -115,6 +115,7 @@ export async function createStubSavedPackage(input: { search_text: manifest.kody.searchText ?? null, source_id: ensuredSource.id, has_app: 0, + hidden: 0, created_at: now, updated_at: now, }) diff --git a/packages/worker/src/mcp/capabilities/packages/domain.ts b/packages/worker/src/mcp/capabilities/packages/domain.ts index cc2623d8e8..08dbfaab97 100644 --- a/packages/worker/src/mcp/capabilities/packages/domain.ts +++ b/packages/worker/src/mcp/capabilities/packages/domain.ts @@ -11,6 +11,7 @@ import { packageInvocationTokenGetCapability } from './package-invocation-token- import { packageInvocationTokenListCapability } from './package-invocation-token-list.ts' import { publishExternalPushCapability } from './publish-external-push.ts' import { savePackageCapability } from './save-package.ts' +import { setPackageHiddenCapability } from './set-package-hidden.ts' export const packagesDomain = defineDomain({ name: capabilityDomainNames.packages, @@ -33,6 +34,7 @@ export const packagesDomain = defineDomain({ getGitRemoteCapability, listPackagesCapability, listPackageSubscriptionsCapability, + setPackageHiddenCapability, packageInvocationTokenListCapability, packageInvocationTokenGetCapability, packageDebugListRunsCapability, diff --git a/packages/worker/src/mcp/capabilities/packages/get-package.node.test.ts b/packages/worker/src/mcp/capabilities/packages/get-package.node.test.ts index 11b3606a72..ae920c038b 100644 --- a/packages/worker/src/mcp/capabilities/packages/get-package.node.test.ts +++ b/packages/worker/src/mcp/capabilities/packages/get-package.node.test.ts @@ -71,6 +71,7 @@ test('getPackageCapability returns export metadata and omits external invocation searchText: null, sourceId: 'source-1', hasApp: true, + hidden: false, createdAt: '2026-04-25T00:00:00.000Z', updatedAt: '2026-04-26T00:00:00.000Z', }) @@ -168,6 +169,7 @@ test('getPackageCapability returns export metadata and omits external invocation searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-25T00:00:00.000Z', updatedAt: '2026-04-26T00:00:00.000Z', }) diff --git a/packages/worker/src/mcp/capabilities/packages/get-package.ts b/packages/worker/src/mcp/capabilities/packages/get-package.ts index 4dc1c420bd..25eef32cb7 100644 --- a/packages/worker/src/mcp/capabilities/packages/get-package.ts +++ b/packages/worker/src/mcp/capabilities/packages/get-package.ts @@ -61,6 +61,7 @@ export const getPackageCapability = defineDomainCapability( description: saved.description, tags: saved.tags, has_app: saved.hasApp, + hidden: saved.hidden, source_id: saved.sourceId, created_at: saved.createdAt, updated_at: saved.updatedAt, diff --git a/packages/worker/src/mcp/capabilities/packages/list-package-subscriptions.node.test.ts b/packages/worker/src/mcp/capabilities/packages/list-package-subscriptions.node.test.ts index d924c98931..b8a72d2d6d 100644 --- a/packages/worker/src/mcp/capabilities/packages/list-package-subscriptions.node.test.ts +++ b/packages/worker/src/mcp/capabilities/packages/list-package-subscriptions.node.test.ts @@ -50,6 +50,7 @@ test('listPackageSubscriptionsCapability filters, sorts, and skips broken manife searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-25T00:00:00.000Z', updatedAt: '2026-04-25T00:00:00.000Z', }, @@ -63,6 +64,7 @@ test('listPackageSubscriptionsCapability filters, sorts, and skips broken manife searchText: null, sourceId: 'source-2', hasApp: false, + hidden: false, createdAt: '2026-04-25T00:00:00.000Z', updatedAt: '2026-04-25T00:00:00.000Z', }, @@ -136,6 +138,7 @@ test('listPackageSubscriptionsCapability filters, sorts, and skips broken manife searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-25T00:00:00.000Z', updatedAt: '2026-04-25T00:00:00.000Z', }, @@ -149,6 +152,7 @@ test('listPackageSubscriptionsCapability filters, sorts, and skips broken manife searchText: null, sourceId: 'source-2', hasApp: false, + hidden: false, createdAt: '2026-04-25T00:00:00.000Z', updatedAt: '2026-04-25T00:00:00.000Z', }, @@ -229,6 +233,7 @@ test('listPackageSubscriptionsCapability filters, sorts, and skips broken manife searchText: null, sourceId: 'source-ok', hasApp: false, + hidden: false, createdAt: '2026-04-25T00:00:00.000Z', updatedAt: '2026-04-25T00:00:00.000Z', }, @@ -242,6 +247,7 @@ test('listPackageSubscriptionsCapability filters, sorts, and skips broken manife searchText: null, sourceId: 'source-bad', hasApp: false, + hidden: false, createdAt: '2026-04-25T00:00:00.000Z', updatedAt: '2026-04-25T00:00:00.000Z', }, diff --git a/packages/worker/src/mcp/capabilities/packages/list-packages.ts b/packages/worker/src/mcp/capabilities/packages/list-packages.ts index be5ce5ed4b..4a404cb4f5 100644 --- a/packages/worker/src/mcp/capabilities/packages/list-packages.ts +++ b/packages/worker/src/mcp/capabilities/packages/list-packages.ts @@ -33,6 +33,7 @@ export const listPackagesCapability = defineDomainCapability( description: savedPackage.description, tags: savedPackage.tags, has_app: savedPackage.hasApp, + hidden: savedPackage.hidden, source_id: savedPackage.sourceId, created_at: savedPackage.createdAt, updated_at: savedPackage.updatedAt, diff --git a/packages/worker/src/mcp/capabilities/packages/save-package-entitlements.node.test.ts b/packages/worker/src/mcp/capabilities/packages/save-package-entitlements.node.test.ts index cc9d0d8813..72daa12796 100644 --- a/packages/worker/src/mcp/capabilities/packages/save-package-entitlements.node.test.ts +++ b/packages/worker/src/mcp/capabilities/packages/save-package-entitlements.node.test.ts @@ -169,8 +169,9 @@ function createDatabase( search_text: params[6], source_id: params[7], has_app: params[8], - created_at: params[9], - updated_at: params[10], + hidden: params[9], + created_at: params[10], + updated_at: params[11], }) return { meta: { changes: 1 } } } @@ -240,6 +241,7 @@ function setupPersistenceMocks() { searchText: null, sourceId: `source-${packageId}`, hasApp: false, + hidden: false, createdAt: '2026-04-18T00:00:00.000Z', updatedAt: '2026-04-18T00:00:00.000Z', }, diff --git a/packages/worker/src/mcp/capabilities/packages/save-package-private-visibility.node.test.ts b/packages/worker/src/mcp/capabilities/packages/save-package-private-visibility.node.test.ts index dad40fd1c9..d1a3d25cdc 100644 --- a/packages/worker/src/mcp/capabilities/packages/save-package-private-visibility.node.test.ts +++ b/packages/worker/src/mcp/capabilities/packages/save-package-private-visibility.node.test.ts @@ -131,6 +131,7 @@ function setupPersistenceMocks() { searchText: null, sourceId: `source-${packageId}`, hasApp: false, + hidden: false, createdAt: '2026-07-10T00:00:00.000Z', updatedAt: '2026-07-10T00:00:00.000Z', }, diff --git a/packages/worker/src/mcp/capabilities/packages/save-package.ts b/packages/worker/src/mcp/capabilities/packages/save-package.ts index 4df9a18167..6b54bedae2 100644 --- a/packages/worker/src/mcp/capabilities/packages/save-package.ts +++ b/packages/worker/src/mcp/capabilities/packages/save-package.ts @@ -225,6 +225,7 @@ export const savePackageCapability = defineDomainCapability( search_text: manifest.kody.searchText ?? null, source_id: ensuredSource.id, has_app: manifest.kody.app ? 1 : 0, + hidden: 0, created_at: now, updated_at: now, }) @@ -249,6 +250,7 @@ export const savePackageCapability = defineDomainCapability( description: saved.description, tags: saved.tags, has_app: saved.hasApp, + hidden: saved.hidden, source_id: saved.sourceId, created_at: saved.createdAt, updated_at: saved.updatedAt, diff --git a/packages/worker/src/mcp/capabilities/packages/set-package-hidden.node.test.ts b/packages/worker/src/mcp/capabilities/packages/set-package-hidden.node.test.ts new file mode 100644 index 0000000000..f009d85a0f --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/set-package-hidden.node.test.ts @@ -0,0 +1,86 @@ +import { expect, test, vi } from 'vitest' + +const mockModule = vi.hoisted(() => ({ + updateSavedPackage: vi.fn(), +})) + +vi.mock('#worker/package-registry/repo.ts', () => ({ + updateSavedPackage: (...args: Array) => + mockModule.updateSavedPackage(...args), +})) + +const { setPackageHiddenCapability } = await import('./set-package-hidden.ts') + +function createCtx(userId = 'user-1') { + return { + env: { APP_DB: {} } as Env, + callerContext: { + baseUrl: 'https://heykody.dev', + user: { + userId, + email: 'user@example.com', + displayName: 'User', + }, + remoteConnectors: null, + storageContext: null, + repoContext: null, + }, + } +} + +test('package_set_hidden scopes updates to the authenticated user and rejects missing packages', async () => { + mockModule.updateSavedPackage.mockResolvedValueOnce(true) + + await expect( + setPackageHiddenCapability.handler( + { package_id: 'pkg-1', hidden: true }, + createCtx('user-1'), + ), + ).resolves.toEqual({ + ok: true, + package_id: 'pkg-1', + hidden: true, + }) + + expect(mockModule.updateSavedPackage).toHaveBeenCalledWith( + {}, + { + userId: 'user-1', + packageId: 'pkg-1', + hidden: true, + }, + ) + + mockModule.updateSavedPackage.mockResolvedValueOnce(false) + await expect( + setPackageHiddenCapability.handler( + { package_id: 'missing', hidden: false }, + createCtx('user-2'), + ), + ).rejects.toThrow('Saved package not found for this user.') + + expect(mockModule.updateSavedPackage).toHaveBeenLastCalledWith( + {}, + { + userId: 'user-2', + packageId: 'missing', + hidden: false, + }, + ) + + await expect( + setPackageHiddenCapability.handler( + { package_id: 'pkg-1', hidden: true }, + { + env: { APP_DB: {} } as Env, + callerContext: { + baseUrl: 'https://heykody.dev', + user: null, + remoteConnectors: null, + storageContext: null, + repoContext: null, + }, + }, + ), + ).rejects.toThrow() +}) diff --git a/packages/worker/src/mcp/capabilities/packages/set-package-hidden.ts b/packages/worker/src/mcp/capabilities/packages/set-package-hidden.ts new file mode 100644 index 0000000000..66cf1755fe --- /dev/null +++ b/packages/worker/src/mcp/capabilities/packages/set-package-hidden.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { updateSavedPackage } from '#worker/package-registry/repo.ts' + +export const setPackageHiddenCapability = defineDomainCapability( + capabilityDomainNames.packages, + { + name: 'package_set_hidden', + description: + 'Set whether a saved package is hidden from discovery/search by default. Hidden packages are excluded from search results unless the caller opts in (includeHiddenPackages on the public search tool, or include_hidden on the meta search capability).', + keywords: ['package', 'hidden', 'disable', 'visibility', 'search'], + readOnly: false, + idempotent: true, + destructive: false, + inputSchema: z.object({ + package_id: z.string().min(1).describe('Saved package id.'), + hidden: z + .boolean() + .describe( + 'When true, the package is hidden from search discovery by default.', + ), + }), + outputSchema: z.object({ + ok: z.literal(true), + package_id: z.string(), + hidden: z.boolean(), + }), + async handler(args, ctx) { + const user = requireMcpUser(ctx.callerContext) + const changed = await updateSavedPackage(ctx.env.APP_DB, { + userId: user.userId, + packageId: args.package_id, + hidden: args.hidden, + }) + if (!changed) { + throw new Error('Saved package not found for this user.') + } + return { + ok: true as const, + package_id: args.package_id, + hidden: args.hidden, + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/packages/shared.ts b/packages/worker/src/mcp/capabilities/packages/shared.ts index 5ec368ff52..cb9fd94635 100644 --- a/packages/worker/src/mcp/capabilities/packages/shared.ts +++ b/packages/worker/src/mcp/capabilities/packages/shared.ts @@ -21,6 +21,7 @@ export const packageSummarySchema = z.object({ description: z.string(), tags: z.array(z.string()), has_app: z.boolean(), + hidden: z.boolean(), source_id: z.string(), created_at: z.string(), updated_at: z.string(), diff --git a/packages/worker/src/mcp/capabilities/repo/repo-list-sessions.node.test.ts b/packages/worker/src/mcp/capabilities/repo/repo-list-sessions.node.test.ts index b95ba8753b..b2bdaedc43 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-list-sessions.node.test.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-list-sessions.node.test.ts @@ -112,6 +112,7 @@ function mockSourceAndPackage() { searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-28T00:00:00.000Z', updatedAt: '2026-04-28T00:00:00.000Z', }) @@ -257,6 +258,7 @@ test('repo_list_sessions applies limit after dropping sessions with missing sour searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-28T00:00:00.000Z', updatedAt: '2026-04-28T00:00:00.000Z', }) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-open-session.node.test.ts b/packages/worker/src/mcp/capabilities/repo/repo-open-session.node.test.ts index e15e4be255..28f997ae7c 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-open-session.node.test.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-open-session.node.test.ts @@ -98,6 +98,7 @@ function createSavedPackageRow(userId: string) { searchText: null, sourceId: 'source-package-1', hasApp: false, + hidden: false, createdAt: '2026-04-18T00:00:00.000Z', updatedAt: '2026-04-18T00:00:00.000Z', } diff --git a/packages/worker/src/mcp/capabilities/repo/repo-workflow.node.test.ts b/packages/worker/src/mcp/capabilities/repo/repo-workflow.node.test.ts index 2e74d33f3f..5334cef2f0 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-workflow.node.test.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-workflow.node.test.ts @@ -94,6 +94,7 @@ function createSavedPackageRow() { searchText: null, sourceId: 'source-package-1', hasApp: false, + hidden: false, createdAt: '2026-04-18T00:00:00.000Z', updatedAt: '2026-04-18T00:00:00.000Z', } diff --git a/packages/worker/src/mcp/capabilities/services/service-start.node.test.ts b/packages/worker/src/mcp/capabilities/services/service-start.node.test.ts index e37ddd4969..1ba6410fbf 100644 --- a/packages/worker/src/mcp/capabilities/services/service-start.node.test.ts +++ b/packages/worker/src/mcp/capabilities/services/service-start.node.test.ts @@ -84,6 +84,7 @@ function createSavedPackage(input: { userId: string }) { searchText: null, sourceId: 'source-123', hasApp: true, + hidden: false, createdAt: '2026-04-24T00:00:00.000Z', updatedAt: '2026-04-24T00:00:00.000Z', } diff --git a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts index 137b1313ba..20c5a541fa 100644 --- a/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts +++ b/packages/worker/src/mcp/capabilities/services/services-domain.node.test.ts @@ -71,6 +71,7 @@ function createSavedPackage() { searchText: null, sourceId: 'source-123', hasApp: true, + hidden: false, createdAt: '2026-04-24T00:00:00.000Z', updatedAt: '2026-04-24T00:00:00.000Z', } diff --git a/packages/worker/src/mcp/fetch-gateway.node.test.ts b/packages/worker/src/mcp/fetch-gateway.node.test.ts index 5f7268064f..ea84cb97df 100644 --- a/packages/worker/src/mcp/fetch-gateway.node.test.ts +++ b/packages/worker/src/mcp/fetch-gateway.node.test.ts @@ -117,6 +117,7 @@ test('fetch gateway requires package approval before resolving user secrets', as tags: [], searchText: null, hasApp: false, + hidden: false, sourceId: 'source-1', createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', diff --git a/packages/worker/src/mcp/tools/search-format.node.test.ts b/packages/worker/src/mcp/tools/search-format.node.test.ts index 25c870fd72..c1b7271bf7 100644 --- a/packages/worker/src/mcp/tools/search-format.node.test.ts +++ b/packages/worker/src/mcp/tools/search-format.node.test.ts @@ -489,6 +489,7 @@ test('package entity detail includes exports, jobs, and referenced local types', searchText: null, sourceId: 'source-package-123', hasApp: true, + hidden: false, createdAt: '2026-03-20T00:00:00.000Z', updatedAt: '2026-03-20T00:00:00.000Z', }, @@ -541,6 +542,7 @@ export declare function fetch(request: Request): Promise type: 'package', entityRef: 'observed-package:package', hasApp: true, + hidden: false, hostedUrl: 'http://localhost/@test-user/packages/observed-package', appEntry: './src/app.ts', exports: [ @@ -596,6 +598,7 @@ test('package search formatting keeps runnable actions and hosted URLs in struct description: 'Saved package for Spotify playback controls.', tags: ['spotify', 'playback'], hasApp: true, + hidden: false, readmeSnippet: { path: 'README.md', snippet: @@ -610,6 +613,7 @@ test('package search formatting keeps runnable actions and hosted URLs in struct id: 'spotify-playback', entityRef: 'spotify-playback:package', hasApp: true, + hidden: false, hostedUrl: 'http://localhost/@test-user/packages/spotify-playback', }) @@ -625,6 +629,7 @@ test('package search formatting keeps runnable actions and hosted URLs in struct description: 'Saved package for Spotify playback controls.', tags: ['spotify', 'playback'], hasApp: true, + hidden: false, readmeSnippet: null, }, ], @@ -632,6 +637,7 @@ test('package search formatting keeps runnable actions and hosted URLs in struct expect(anonymousPackageMatch).toMatchObject({ type: 'package', hasApp: true, + hidden: false, hostedUrl: null, }) @@ -648,6 +654,7 @@ test('package search formatting keeps runnable actions and hosted URLs in struct description: 'Google product helpers.', tags: ['google', 'calendar'], hasApp: true, + hidden: false, actionMatches: [ { subpath: './calendar', @@ -702,6 +709,7 @@ test('search markdown summarizes broad results safely and only suggests entity d description: 'Observed package with an app surface.', tags: ['observed'], hasApp: false, + hidden: false, readmeSnippet: { path: 'README.md', snippet: truncatedReadmeSnippet, diff --git a/packages/worker/src/mcp/tools/search-format.ts b/packages/worker/src/mcp/tools/search-format.ts index c26ceef515..c5faf8ec49 100644 --- a/packages/worker/src/mcp/tools/search-format.ts +++ b/packages/worker/src/mcp/tools/search-format.ts @@ -127,6 +127,7 @@ export type SlimSearchMatch = rootImportUsage: string tags: Array hasApp: boolean + hidden: boolean hostedUrl: string | null readmeSnippet: { path: string @@ -236,6 +237,7 @@ export type SearchEntityDetailStructured = name: string tags: Array hasApp: boolean + hidden: boolean hostedUrl: string | null appEntry: string | null exports: Array<{ @@ -394,6 +396,7 @@ export type SearchMatch = description: string tags: Array hasApp: boolean + hidden: boolean readmeSnippet?: { path: string snippet: string @@ -756,6 +759,7 @@ export function toSlimStructuredMatches(input: { rootImportUsage, tags: match.tags, hasApp: match.hasApp, + hidden: match.hidden, hostedUrl: match.hasApp && input.username ? buildPackageHostedUrl(input.baseUrl, input.username, match.kodyId) @@ -1047,6 +1051,7 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { name: detail.record.name, tags: detail.record.tags, hasApp: detail.record.hasApp, + hidden: detail.record.hidden, hostedUrl: detail.hostedUrl, appEntry, exports: exportDetails, diff --git a/packages/worker/src/mcp/tools/search-handler.node.test.ts b/packages/worker/src/mcp/tools/search-handler.node.test.ts index df21e3f27e..c6716beff9 100644 --- a/packages/worker/src/mcp/tools/search-handler.node.test.ts +++ b/packages/worker/src/mcp/tools/search-handler.node.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest' +import { consoleWarn } from '#worker/test-support/console-spies.ts' const mockModule = vi.hoisted(() => ({ getCapabilityRegistryForContext: vi.fn(async () => ({ @@ -22,6 +23,10 @@ const mockModule = vi.hoisted(() => ({ listUserSecretsForSearch: vi.fn(async () => []), listValues: vi.fn(async () => []), loadRelevantMemoriesForTool: vi.fn(async () => null), + runPackageRetrievers: vi.fn(async () => ({ + results: [], + warnings: [], + })), getRemoteConnectorStatus: vi.fn(async () => ({ connectorId: 'home', state: 'connected', @@ -63,6 +68,11 @@ vi.mock('./memory-tool-context.ts', async () => { } }) +vi.mock('#worker/package-retrievers/service.ts', () => ({ + runPackageRetrievers: (...args: Array) => + mockModule.runPackageRetrievers(...args), +})) + vi.mock('#worker/remote-connector/status.ts', () => ({ getRemoteConnectorStatus: (...args: Array) => mockModule.getRemoteConnectorStatus(...args), @@ -72,7 +82,39 @@ const { registerSearchTool } = await import('./search.ts') const mockPerformanceNow = vi.spyOn(performance, 'now') -async function getSearchRegistration() { +type SearchHandler = (input: { + query?: string + entity?: string + limit?: number + maxResponseSize?: number + conversationId?: string + includeHiddenPackages?: boolean +}) => Promise<{ + content: Array<{ + type: 'text' + text: string + }> + structuredContent: { + conversationId: string + timing: { + startedAt: string + endedAt: string + durationMs: number + } + error?: string + result?: unknown + } + isError?: boolean +}> + +async function getSearchRegistration(input?: { + user?: { + userId: string + email: string + displayName: string + username?: string + } | null +}) { const registerTool = vi.fn() await registerSearchTool({ @@ -82,7 +124,7 @@ async function getSearchRegistration() { getEnv: vi.fn(() => ({})), getCallerContext: vi.fn(() => ({ baseUrl: 'https://example.com', - user: null, + user: input?.user === undefined ? null : input.user, remoteConnectors: [{ instanceId: 'home' }], })), } as never) @@ -90,34 +132,45 @@ async function getSearchRegistration() { expect(registerTool).toHaveBeenCalledTimes(1) const [name, , handler] = registerTool.mock.calls[0] ?? [] expect(name).toBe('search') - return { handler } + return { handler: handler as SearchHandler } } async function getSearchHandler() { const { handler } = await getSearchRegistration() - return handler as (input: { - query?: string - entity?: string - limit?: number - maxResponseSize?: number - conversationId?: string - }) => Promise<{ - content: Array<{ - type: 'text' - text: string - }> - structuredContent: { - conversationId: string - timing: { - startedAt: string - endedAt: string - durationMs: number - } - error?: string - result?: unknown - } - isError?: boolean - }> + return handler +} + +function createSavedPackages() { + return [ + { + id: 'pkg-hidden', + userId: 'user-1', + name: 'hidden-notes-pkg', + kodyId: 'hidden-notes-pkg', + description: 'hidden notes package', + tags: [], + searchText: 'hidden notes package', + sourceId: 'source-hidden', + hasApp: false, + hidden: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'pkg-visible', + userId: 'user-1', + name: 'visible-notes-pkg', + kodyId: 'visible-notes-pkg', + description: 'visible notes package', + tags: [], + searchText: 'visible notes package', + sourceId: 'source-visible', + hasApp: false, + hidden: false, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ] } test('search tool returns compact query markdown while preserving structured auxiliary detail', async () => { @@ -211,3 +264,66 @@ test('search tool returns compact query markdown while preserving structured aux 'Registry unavailable', ) }) + +test('search tool excludes hidden packages by default and includes them with includeHiddenPackages', async () => { + vi.clearAllMocks() + consoleWarn.mockImplementation(() => {}) + mockModule.runPackageRetrievers.mockResolvedValue({ + results: [], + warnings: [], + }) + mockModule.listSavedPackagesByUserId.mockResolvedValue(createSavedPackages()) + + const { handler } = await getSearchRegistration({ + user: { + userId: 'user-1', + email: 'user@example.com', + displayName: 'User', + username: 'user', + }, + }) + + mockPerformanceNow.mockReturnValueOnce(100).mockReturnValueOnce(110) + const defaultResponse = await handler({ + query: 'notes package', + conversationId: 'conv-hidden-default', + }) + expect(defaultResponse.isError).toBeUndefined() + const defaultResult = defaultResponse.structuredContent.result as { + matches: Array<{ type: string; kodyId?: string }> + } + const defaultPackageIds = defaultResult.matches + .filter((match) => match.type === 'package') + .map((match) => match.kodyId) + expect(defaultPackageIds).toContain('visible-notes-pkg') + expect(defaultPackageIds).not.toContain('hidden-notes-pkg') + expect(mockModule.runPackageRetrievers).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'search', + includeHiddenPackages: false, + }), + ) + + mockModule.listSavedPackagesByUserId.mockResolvedValue(createSavedPackages()) + mockPerformanceNow.mockReturnValueOnce(200).mockReturnValueOnce(210) + const includeResponse = await handler({ + query: 'notes package', + conversationId: 'conv-hidden-include', + includeHiddenPackages: true, + }) + expect(includeResponse.isError).toBeUndefined() + const includeResult = includeResponse.structuredContent.result as { + matches: Array<{ type: string; kodyId?: string }> + } + const includePackageIds = includeResult.matches + .filter((match) => match.type === 'package') + .map((match) => match.kodyId) + .sort() + expect(includePackageIds).toEqual(['hidden-notes-pkg', 'visible-notes-pkg']) + expect(mockModule.runPackageRetrievers).toHaveBeenCalledWith( + expect.objectContaining({ + scope: 'search', + includeHiddenPackages: true, + }), + ) +}) diff --git a/packages/worker/src/mcp/tools/search-hidden-packages.node.test.ts b/packages/worker/src/mcp/tools/search-hidden-packages.node.test.ts new file mode 100644 index 0000000000..ca389fd0e9 --- /dev/null +++ b/packages/worker/src/mcp/tools/search-hidden-packages.node.test.ts @@ -0,0 +1,213 @@ +import { expect, test, vi } from 'vitest' +import { createMcpCallerContext } from '#mcp/context.ts' + +const mockFns = vi.hoisted(() => ({ + listSavedPackagesByUserId: vi.fn(), + getCapabilityRegistryForContext: vi.fn(async () => ({ + capabilitySpecs: {}, + })), + listUserSecretsForSearch: vi.fn(async () => []), + listValues: vi.fn(async () => []), + loadRelevantMemoriesForTool: vi.fn(async () => null), + getSavedPackageById: vi.fn(), + getEntitySourceById: vi.fn(), + listPackageRetrieversForScope: vi.fn(), +})) + +vi.mock('#mcp/capabilities/registry.ts', () => ({ + getCapabilityRegistryForContext: mockFns.getCapabilityRegistryForContext, +})) + +vi.mock('#worker/package-registry/repo.ts', () => ({ + listSavedPackagesByUserId: mockFns.listSavedPackagesByUserId, + getSavedPackageById: mockFns.getSavedPackageById, +})) + +vi.mock('#worker/repo/entity-sources.ts', () => ({ + getEntitySourceById: mockFns.getEntitySourceById, +})) + +vi.mock('#mcp/secrets/service.ts', () => ({ + listUserSecretsForSearch: mockFns.listUserSecretsForSearch, +})) + +vi.mock('#mcp/values/service.ts', () => ({ + listValues: mockFns.listValues, +})) + +vi.mock('#mcp/tools/memory-tool-context.ts', () => ({ + loadRelevantMemoriesForTool: mockFns.loadRelevantMemoriesForTool, +})) + +vi.mock('#worker/package-retrievers/manifest-cache.ts', () => ({ + listPackageRetrieversForScope: mockFns.listPackageRetrieversForScope, +})) + +function createPackages() { + return [ + { + id: 'pkg-hidden', + userId: 'user-1', + name: 'hidden-pkg', + kodyId: 'hidden-pkg', + description: 'hidden package', + tags: [], + searchText: 'hidden', + sourceId: 'source-hidden', + hasApp: false, + hidden: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'pkg-visible', + userId: 'user-1', + name: 'visible-pkg', + kodyId: 'visible-pkg', + description: 'visible package', + tags: [], + searchText: 'visible', + sourceId: 'source-visible', + hasApp: false, + hidden: false, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ] +} + +test('search default excludes hidden packages; includeHiddenPackages includes them', async () => { + mockFns.listSavedPackagesByUserId.mockResolvedValueOnce(createPackages()) + + const { loadSearchRowsAndRegistry } = await import('#mcp/tools/search.ts') + + const callerContext = createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-1', + email: 'user@example.com', + displayName: 'User', + }, + remoteConnectors: [], + }) + const env = { APP_DB: {} } as Env + + const rowsDefault = await loadSearchRowsAndRegistry({ + env, + callerContext, + userId: 'user-1', + }) + + expect(rowsDefault.packageRows.map((r) => r.record.kodyId)).toEqual([ + 'visible-pkg', + ]) + + mockFns.listSavedPackagesByUserId.mockResolvedValueOnce(createPackages()) + + const rowsInclude = await loadSearchRowsAndRegistry({ + env, + callerContext, + userId: 'user-1', + includeHiddenPackages: true, + }) + + expect(rowsInclude.packageRows.map((r) => r.record.kodyId).sort()).toEqual([ + 'hidden-pkg', + 'visible-pkg', + ]) +}) + +function createHiddenRetrieverFixture(scopes: Array<'search' | 'context'>) { + return { + entry: { + userId: 'user-1', + packageId: 'pkg-hidden', + kodyId: 'hidden-pkg', + sourceId: 'source-hidden', + revision: 'commit-1', + retrieverKey: 'notes', + exportName: './notes', + entryPoint: './notes.ts', + name: 'Notes', + description: 'Notes retriever', + scopes, + timeoutMs: null, + maxResults: null, + }, + hiddenPackage: { + id: 'pkg-hidden', + userId: 'user-1', + name: 'hidden-pkg', + kodyId: 'hidden-pkg', + description: 'hidden package', + tags: [], + searchText: 'hidden', + sourceId: 'source-hidden', + hasApp: false, + hidden: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + } +} + +test('hidden package retrievers are skipped by default for search and allowed when includeHiddenPackages is set', async () => { + const { entry, hiddenPackage } = createHiddenRetrieverFixture(['search']) + mockFns.listPackageRetrieversForScope.mockResolvedValue([entry]) + mockFns.getSavedPackageById.mockResolvedValue(hiddenPackage) + mockFns.getEntitySourceById.mockResolvedValue(null) + + const { runPackageRetrievers } = + await import('#worker/package-retrievers/service.ts') + + const env = { APP_DB: {}, BUNDLE_ARTIFACTS_KV: {} } as Env + + const excluded = await runPackageRetrievers({ + env, + baseUrl: 'https://example.com', + userId: 'user-1', + scope: 'search', + query: 'notes', + }) + expect(excluded.results).toEqual([]) + expect(mockFns.getEntitySourceById).not.toHaveBeenCalled() + + const included = await runPackageRetrievers({ + env, + baseUrl: 'https://example.com', + userId: 'user-1', + scope: 'search', + query: 'notes', + includeHiddenPackages: true, + }) + expect(included.results).toEqual([]) + expect(mockFns.getEntitySourceById).toHaveBeenCalledWith( + env.APP_DB, + 'source-hidden', + ) +}) + +test('context-scope package retrievers still run for hidden packages without includeHiddenPackages', async () => { + const { entry, hiddenPackage } = createHiddenRetrieverFixture(['context']) + mockFns.listPackageRetrieversForScope.mockResolvedValue([entry]) + mockFns.getSavedPackageById.mockResolvedValue(hiddenPackage) + mockFns.getEntitySourceById.mockResolvedValue(null) + + const { runPackageRetrievers } = + await import('#worker/package-retrievers/service.ts') + + const env = { APP_DB: {}, BUNDLE_ARTIFACTS_KV: {} } as Env + + const contextRun = await runPackageRetrievers({ + env, + baseUrl: 'https://example.com', + userId: 'user-1', + scope: 'context', + query: 'notes', + }) + expect(contextRun.results).toEqual([]) + expect(mockFns.getEntitySourceById).toHaveBeenCalledWith( + env.APP_DB, + 'source-hidden', + ) +}) diff --git a/packages/worker/src/mcp/tools/search.node.test.ts b/packages/worker/src/mcp/tools/search.node.test.ts index 7bf4b97b53..ff31c7a30c 100644 --- a/packages/worker/src/mcp/tools/search.node.test.ts +++ b/packages/worker/src/mcp/tools/search.node.test.ts @@ -163,6 +163,7 @@ test('searchUnified ranks mixed search rows through one shared pipeline', async searchText: 'epsilon', sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-20T00:00:00.000Z', updatedAt: '2026-04-20T00:00:00.000Z', }, @@ -173,6 +174,7 @@ test('searchUnified ranks mixed search rows through one shared pipeline', async tags: ['delta'], searchText: 'epsilon', hasApp: false, + hidden: false, appEntry: null, exports: [], jobs: [], @@ -499,6 +501,7 @@ test('optional search rows load packages and values without partial fallbacks', searchText: null, sourceId: 'source-package-123', hasApp: true, + hidden: false, createdAt: '2026-03-24T00:00:00.000Z', updatedAt: '2026-03-24T00:00:00.000Z', }, @@ -509,6 +512,7 @@ test('optional search rows load packages and values without partial fallbacks', tags: ['roku'], searchText: null, hasApp: true, + hidden: false, appEntry: 'src/app.ts', exports: [createPackageExportProjection('.')], jobs: [], @@ -567,6 +571,7 @@ test('searchUnified annotates high-confidence package action matches', async () searchText: 'module-a module-b helpers', sourceId: 'source-alpha', hasApp: false, + hidden: false, createdAt: '2026-04-20T00:00:00.000Z', updatedAt: '2026-04-20T00:00:00.000Z', }, @@ -577,6 +582,7 @@ test('searchUnified annotates high-confidence package action matches', async () tags: ['alpha', 'module-a'], searchText: 'module-a module-b helpers', hasApp: false, + hidden: false, appEntry: null, exports: [ createPackageExportProjection('./module-a', { @@ -712,6 +718,7 @@ export declare function traceProcessorFailure(messageId: string): Promise searchText: null, sourceId: 'source-trace', hasApp: false, + hidden: false, createdAt: '2026-04-20T00:00:00.000Z', updatedAt: '2026-04-20T00:00:00.000Z', }, @@ -786,6 +793,7 @@ export declare function traceProcessorFailure(messageId: string): Promise searchText: 'search text', sourceId: 'missing-source', hasApp: true, + hidden: false, createdAt: '2026-03-24T00:00:00.000Z', updatedAt: '2026-03-24T00:00:00.000Z', }, @@ -849,6 +857,7 @@ test('searchUnified ranks packages via user-filtered package vectors when Vector searchText: null, sourceId: `source-${id}`, hasApp: false, + hidden: false, createdAt: '2026-04-20T00:00:00.000Z', updatedAt: '2026-04-20T00:00:00.000Z', }, @@ -859,6 +868,7 @@ test('searchUnified ranks packages via user-filtered package vectors when Vector tags: [], searchText: null, hasApp: false, + hidden: false, appEntry: null, exports: [], jobs: [], @@ -936,6 +946,7 @@ test('searchUnified degrades to lexical package ranking when the vector query th searchText: null, sourceId: 'source-pkg-inbox', hasApp: false, + hidden: false, createdAt: '2026-04-20T00:00:00.000Z', updatedAt: '2026-04-20T00:00:00.000Z', }, @@ -946,6 +957,7 @@ test('searchUnified degrades to lexical package ranking when the vector query th tags: [], searchText: null, hasApp: false, + hidden: false, appEntry: null, exports: [], jobs: [], diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index b5c8066e33..a678cc10d1 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -1059,6 +1059,7 @@ async function buildPackageCandidates(input: { description: entry.record.description, tags: entry.record.tags, hasApp: entry.record.hasApp, + hidden: entry.record.hidden, readmeSnippet: entry.readmeSnippet ?? null, actionMatches, }, @@ -1743,6 +1744,7 @@ export async function loadSearchRowsAndRegistry(input: { env: Env callerContext: McpCallerContext userId: string | null + includeHiddenPackages?: boolean }) { const [registry, optionalRows] = await Promise.all([ getCapabilityRegistryForContext({ @@ -1762,7 +1764,9 @@ export async function loadSearchRowsAndRegistry(input: { env: input.env, baseUrl: input.callerContext.baseUrl, userId: input.userId!, - records: savedPackages, + records: savedPackages.filter((pkg) => + input.includeHiddenPackages ? true : !pkg.hidden, + ), }) return packageRows }, @@ -1969,6 +1973,12 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { ), conversationId: conversationIdInputField, memoryContext: memoryContextInputField, + includeHiddenPackages: z + .boolean() + .optional() + .describe( + 'Include hidden packages in search results (hidden packages are excluded by default).', + ), }, annotations: searchTool.annotations, }, @@ -1979,12 +1989,14 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { maxResponseSize?: number conversationId?: string memoryContext?: z.infer + includeHiddenPackages?: boolean }) => { const timingStart = startToolTiming() const conversationId = resolveConversationId(args.conversationId) const callerContext = agent.getCallerContext() const { baseUrl, hasUser } = callerContextFields(callerContext) const userId = callerContext.user?.userId ?? null + const includeHiddenPackages = !!args.includeHiddenPackages if (!args.query && !args.entity) { const timing = finishToolTiming(timingStart) logMcpEvent({ @@ -2042,6 +2054,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { userId, scope: 'search', query, + includeHiddenPackages, memoryContext: resolveSearchMemoryContext({ query, memoryContext: args.memoryContext, @@ -2055,6 +2068,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { env: agent.getEnv(), callerContext, userId, + includeHiddenPackages, }), retrieverRunPromise, ]) diff --git a/packages/worker/src/package-invocations/service.node.test.ts b/packages/worker/src/package-invocations/service.node.test.ts index c67c8b7cdc..a936f63b2a 100644 --- a/packages/worker/src/package-invocations/service.node.test.ts +++ b/packages/worker/src/package-invocations/service.node.test.ts @@ -215,6 +215,7 @@ function seedPackageResolution() { searchText: null, sourceId: 'source-1', hasApp: true, + hidden: false, createdAt: '2026-04-27T00:00:00.000Z', updatedAt: '2026-04-27T00:00:00.000Z', }) @@ -354,6 +355,7 @@ function createSavedPackage(input: { searchText: null, sourceId: input.sourceId, hasApp: false, + hidden: false, createdAt: '2026-05-10T00:00:00.000Z', updatedAt: '2026-05-10T00:00:00.000Z', } @@ -1871,6 +1873,7 @@ test('invokePackageSubscription uses the normal capability registry with package searchText: null, sourceId: 'source-1', hasApp: true, + hidden: false, createdAt: '2026-04-27T00:00:00.000Z', updatedAt: '2026-04-27T00:00:00.000Z', } diff --git a/packages/worker/src/package-registry/package-reindex.node.test.ts b/packages/worker/src/package-registry/package-reindex.node.test.ts index 028bd891ff..89e180b308 100644 --- a/packages/worker/src/package-registry/package-reindex.node.test.ts +++ b/packages/worker/src/package-registry/package-reindex.node.test.ts @@ -56,6 +56,7 @@ function buildSavedPackage(id: string) { searchText: null, sourceId: `source-${id}`, hasApp: false, + hidden: false, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', } @@ -90,6 +91,7 @@ test('saved package reindex embeds full manifests with user-scoped metadata', as searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, @@ -156,6 +158,7 @@ test('saved package reindex skips failed manifest loads and continues the batch' searchText: null, sourceId: 'source-bad', hasApp: false, + hidden: false, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, @@ -169,6 +172,7 @@ test('saved package reindex skips failed manifest loads and continues the batch' searchText: null, sourceId: 'source-good', hasApp: false, + hidden: false, createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, diff --git a/packages/worker/src/package-registry/repo.ts b/packages/worker/src/package-registry/repo.ts index 5f08e92f5f..f1e82b0f15 100644 --- a/packages/worker/src/package-registry/repo.ts +++ b/packages/worker/src/package-registry/repo.ts @@ -19,6 +19,8 @@ function mapSavedPackageRow(row: Record): SavedPackageRecord { sourceId: String(row['source_id']), hasApp: row['has_app'] === 1 || row['has_app'] === '1' || row['has_app'] === true, + hidden: + row['hidden'] === 1 || row['hidden'] === '1' || row['hidden'] === true, createdAt: String(row['created_at']), updatedAt: String(row['updated_at']), } @@ -35,8 +37,8 @@ export async function insertSavedPackage( .prepare( `INSERT INTO saved_packages ( id, user_id, name, kody_id, description, tags_json, search_text, - source_id, has_app, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + source_id, has_app, hidden, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( row.id, @@ -48,6 +50,7 @@ export async function insertSavedPackage( row.search_text ?? null, row.source_id, row.has_app, + row.hidden ?? 0, row.created_at ?? now, row.updated_at ?? now, ) @@ -66,6 +69,7 @@ export async function updateSavedPackage( searchText?: string | null sourceId?: string hasApp?: boolean + hidden?: boolean }, ) { const assignments: Array = [] @@ -97,6 +101,9 @@ export async function updateSavedPackage( if (input.hasApp !== undefined) { addAssignment('has_app', input.hasApp ? 1 : 0) } + if (input.hidden !== undefined) { + addAssignment('hidden', input.hidden ? 1 : 0) + } addAssignment('updated_at', new Date().toISOString()) @@ -136,7 +143,7 @@ export async function getSavedPackageById( const row = await db .prepare( `SELECT id, user_id, name, kody_id, description, tags_json, search_text, - source_id, has_app, created_at, updated_at + source_id, has_app, hidden, created_at, updated_at FROM saved_packages WHERE id = ? AND user_id = ?`, ) @@ -155,7 +162,7 @@ export async function getSavedPackageByKodyId( const row = await db .prepare( `SELECT id, user_id, name, kody_id, description, tags_json, search_text, - source_id, has_app, created_at, updated_at + source_id, has_app, hidden, created_at, updated_at FROM saved_packages WHERE kody_id = ? AND user_id = ?`, ) @@ -174,7 +181,7 @@ export async function getSavedPackageByName( const row = await db .prepare( `SELECT id, user_id, name, kody_id, description, tags_json, search_text, - source_id, has_app, created_at, updated_at + source_id, has_app, hidden, created_at, updated_at FROM saved_packages WHERE name = ? AND user_id = ?`, ) @@ -192,7 +199,7 @@ export async function listSavedPackagesByUserId( const rows = await db .prepare( `SELECT id, user_id, name, kody_id, description, tags_json, search_text, - source_id, has_app, created_at, updated_at + source_id, has_app, hidden, created_at, updated_at FROM saved_packages WHERE user_id = ? ORDER BY updated_at DESC`, @@ -215,7 +222,7 @@ export async function listSavedPackagesPage( const rows = await db .prepare( `SELECT id, user_id, name, kody_id, description, tags_json, search_text, - source_id, has_app, created_at, updated_at + source_id, has_app, hidden, created_at, updated_at FROM saved_packages WHERE id > ? ORDER BY id diff --git a/packages/worker/src/package-registry/saved-packages-hidden-migration.node.test.ts b/packages/worker/src/package-registry/saved-packages-hidden-migration.node.test.ts new file mode 100644 index 0000000000..4ea64feca6 --- /dev/null +++ b/packages/worker/src/package-registry/saved-packages-hidden-migration.node.test.ts @@ -0,0 +1,85 @@ +import { readFileSync } from 'node:fs' +import { DatabaseSync } from 'node:sqlite' +import { expect, test } from 'vitest' + +const migrationsDirectory = new URL('../../migrations/', import.meta.url) + +function applyMigration(db: DatabaseSync, fileName: string) { + db.exec(readFileSync(new URL(fileName, migrationsDirectory), 'utf8')) +} + +function createD1FromSqlite(db: DatabaseSync) { + return { + prepare(query: string) { + return { + bind(...params: Array) { + return { + async all() { + const statement = db.prepare(query) + const rows = statement.all(...params) as Array + return { results: rows, meta: { changes: 0 } } + }, + async first() { + const statement = db.prepare(query) + return (statement.get(...params) ?? null) as T | null + }, + async run() { + const statement = db.prepare(query) + const result = statement.run(...params) + return { meta: { changes: result.changes } } + }, + } + }, + } + }, + } as unknown as D1Database +} + +test('saved packages hidden migration defaults existing rows to visible and hydrates reads', async () => { + const db = new DatabaseSync(':memory:') + applyMigration(db, '0027-saved-packages.sql') + db.prepare( + `INSERT INTO saved_packages ( + id, user_id, name, kody_id, description, tags_json, search_text, + source_id, has_app + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + 'pkg-1', + 'user-1', + '@kentcdodds/notes', + 'notes', + 'Notes package', + '[]', + null, + 'source-1', + 0, + ) + + applyMigration(db, '0058-saved-packages-hidden.sql') + + expect( + db.prepare(`SELECT hidden FROM saved_packages WHERE id = 'pkg-1'`).get(), + ).toEqual({ hidden: 0 }) + + db.prepare(`UPDATE saved_packages SET hidden = 1 WHERE id = 'pkg-1'`).run() + + const { getSavedPackageById, listSavedPackagesByUserId } = + await import('./repo.ts') + const d1 = createD1FromSqlite(db) + + const loaded = await getSavedPackageById(d1, { + userId: 'user-1', + packageId: 'pkg-1', + }) + expect(loaded).toMatchObject({ + id: 'pkg-1', + userId: 'user-1', + hidden: true, + }) + + const listed = await listSavedPackagesByUserId(d1, { userId: 'user-1' }) + expect(listed).toEqual([ + expect.objectContaining({ id: 'pkg-1', hidden: true }), + ]) + expect(listed.filter((row) => !row.hidden)).toEqual([]) +}) diff --git a/packages/worker/src/package-registry/service.node.test.ts b/packages/worker/src/package-registry/service.node.test.ts index 9958cc5fea..817909c4c5 100644 --- a/packages/worker/src/package-registry/service.node.test.ts +++ b/packages/worker/src/package-registry/service.node.test.ts @@ -158,6 +158,7 @@ function createProjection() { tags: ['home', 'shades'], searchText: 'shade automation', hasApp: false, + hidden: false, } } @@ -232,6 +233,7 @@ test('refreshSavedPackageProjection resyncs the job manager after syncing packag searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-20T00:00:00.000Z', updatedAt: '2026-04-20T00:00:00.000Z', }) @@ -266,6 +268,7 @@ test('refreshSavedPackageProjection resyncs the job manager after syncing packag searchText: 'shade automation', sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-20T00:00:00.000Z', }), manifest, @@ -330,6 +333,7 @@ test('refreshSavedPackageProjection omits files when artifact rebuild is skipped searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-20T00:00:00.000Z', updatedAt: '2026-04-20T00:00:00.000Z', }) @@ -376,6 +380,7 @@ test('refreshSavedPackageProjection continues best-effort cleanup when dependent searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-20T00:00:00.000Z', updatedAt: '2026-04-20T00:00:00.000Z', } @@ -780,6 +785,62 @@ test('refreshSavedPackageProjection enforces the saved packages entitlement on i expect(mockModule.insertSavedPackage).not.toHaveBeenCalled() }) +test('refreshSavedPackageProjection preserves hidden across projection refresh', async () => { + setupDefaultMocks() + const env = createEnv() + mockModule.buildPackageSearchProjection.mockReturnValue({ + ...createProjection(), + description: 'Updated description', + }) + mockModule.loadPackageSourceBySourceId.mockResolvedValue({ + manifest: { + name: '@kentcdodds/shade-automation', + kody: { + id: 'shade-automation', + description: 'Updated description', + tags: ['home'], + }, + }, + files: { 'package.json': '{}' }, + }) + mockModule.getSavedPackageById.mockResolvedValue({ + id: 'package-1', + userId: 'user-1', + name: '@kentcdodds/shade-automation', + kodyId: 'shade-automation', + description: 'Old description', + tags: ['home'], + searchText: null, + sourceId: 'source-1', + hasApp: false, + hidden: true, + createdAt: '2026-04-20T00:00:00.000Z', + updatedAt: '2026-04-20T00:00:00.000Z', + }) + + const refreshed = await refreshSavedPackageProjection({ + env, + baseUrl: 'https://heykody.dev', + userId: 'user-1', + packageId: 'package-1', + sourceId: 'source-1', + }) + + expect(mockModule.updateSavedPackage).toHaveBeenCalled() + const updateArg = mockModule.updateSavedPackage.mock.calls[0]?.[1] as Record< + string, + unknown + > + expect(updateArg).not.toHaveProperty('hidden') + expect(updateArg).toMatchObject({ + userId: 'user-1', + packageId: 'package-1', + description: 'Updated description', + }) + expect(refreshed.record.hidden).toBe(true) + expect(refreshed.record.description).toBe('Updated description') +}) + test('refreshSavedPackageProjection does not gate the update branch at the limit', async () => { setupDefaultMocks() const email = 'planned@example.com' @@ -815,6 +876,7 @@ test('refreshSavedPackageProjection does not gate the update branch at the limit searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-20T00:00:00.000Z', updatedAt: '2026-04-20T00:00:00.000Z', }) diff --git a/packages/worker/src/package-registry/service.ts b/packages/worker/src/package-registry/service.ts index f68d1879c4..5993651fda 100644 --- a/packages/worker/src/package-registry/service.ts +++ b/packages/worker/src/package-registry/service.ts @@ -89,6 +89,7 @@ function toSavedPackageInsertRow(input: { search_text: projection.searchText, source_id: input.sourceId, has_app: projection.hasApp ? 1 : 0, + hidden: 0, } } @@ -193,6 +194,8 @@ export async function refreshSavedPackageProjection(input: { searchText: row.search_text ?? null, sourceId: row.source_id, hasApp: row.has_app === 1, + // Preserve visibility across projection refresh / re-save. + hidden: existing?.hidden ?? false, createdAt: existing?.createdAt ?? refreshedAt, updatedAt: refreshedAt, } satisfies SavedPackageRecord @@ -291,21 +294,7 @@ export async function refreshSavedPackageProjection(input: { userId: input.userId, }) return { - record: - existing ?? - ({ - id: row.id, - userId: row.user_id, - name: row.name, - kodyId: row.kody_id, - description: row.description, - tags: parseTagsJson(row.tags_json), - searchText: row.search_text ?? null, - sourceId: row.source_id, - hasApp: row.has_app === 1, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } satisfies SavedPackageRecord), + record: savedPackage, manifest: loaded.manifest, ...(loadedFiles ? { files: loadedFiles } : {}), } diff --git a/packages/worker/src/package-registry/types.ts b/packages/worker/src/package-registry/types.ts index eb5e922b05..fe56609258 100644 --- a/packages/worker/src/package-registry/types.ts +++ b/packages/worker/src/package-registry/types.ts @@ -210,6 +210,7 @@ export type SavedPackageRow = { search_text: string | null source_id: string has_app: 0 | 1 + hidden: 0 | 1 created_at: string updated_at: string } @@ -224,6 +225,7 @@ export type SavedPackageRecord = { searchText: string | null sourceId: string hasApp: boolean + hidden: boolean createdAt: string updatedAt: string } diff --git a/packages/worker/src/package-retrievers/manifest-cache.node.test.ts b/packages/worker/src/package-retrievers/manifest-cache.node.test.ts index 9c1b812d4b..63daf54426 100644 --- a/packages/worker/src/package-retrievers/manifest-cache.node.test.ts +++ b/packages/worker/src/package-retrievers/manifest-cache.node.test.ts @@ -76,6 +76,7 @@ function createSavedPackage( searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-20T00:00:00.000Z', updatedAt: '2026-04-20T00:00:00.000Z', ...overrides, diff --git a/packages/worker/src/package-retrievers/service.ts b/packages/worker/src/package-retrievers/service.ts index 34ec1eb518..5b18679ef0 100644 --- a/packages/worker/src/package-retrievers/service.ts +++ b/packages/worker/src/package-retrievers/service.ts @@ -83,6 +83,7 @@ async function invokeRetriever(input: { scope: PackageRetrieverScope entry: PackageRetrieverManifestCacheEntry query: string + includeHiddenPackages: boolean memoryContext?: { task?: string query?: string @@ -98,6 +99,15 @@ async function invokeRetriever(input: { if (!savedPackage) { return [] } + // Hidden gating is a search-discovery preference only. Context retrievers + // (memory/tool context) must still run for hidden packages. + if ( + input.scope === 'search' && + savedPackage.hidden && + !input.includeHiddenPackages + ) { + return [] + } const source = await getEntitySourceById( input.env.APP_DB, input.entry.sourceId, @@ -231,6 +241,7 @@ export async function runPackageRetrievers(input: { userId: string | null scope: PackageRetrieverScope query: string + includeHiddenPackages?: boolean memoryContext?: { task?: string query?: string @@ -242,6 +253,7 @@ export async function runPackageRetrievers(input: { }) { const userId = input.userId?.trim() const query = input.query.trim() + const includeHiddenPackages = !!input.includeHiddenPackages if (!userId || !query) { return { results: [], @@ -270,6 +282,7 @@ export async function runPackageRetrievers(input: { scope: input.scope, entry, query, + includeHiddenPackages, memoryContext: input.memoryContext, conversationId: input.conversationId, }), diff --git a/packages/worker/src/package-runtime/module-graph.node.test.ts b/packages/worker/src/package-runtime/module-graph.node.test.ts index 8cc57f0384..097ed0e843 100644 --- a/packages/worker/src/package-runtime/module-graph.node.test.ts +++ b/packages/worker/src/package-runtime/module-graph.node.test.ts @@ -164,6 +164,7 @@ function createSavedPackageRecord(input?: { searchText: null, sourceId: input?.sourceId ?? 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-24T00:00:00.000Z', updatedAt: '2026-04-24T00:00:00.000Z', } @@ -2289,6 +2290,7 @@ test('buildKodyModuleBundle records only entrypoint-reachable kody package depen searchText: null, sourceId: 'source-reachable', hasApp: false, + hidden: false, createdAt: '2026-05-10T00:00:00.000Z', updatedAt: '2026-05-10T00:00:00.000Z', } @@ -2304,6 +2306,7 @@ test('buildKodyModuleBundle records only entrypoint-reachable kody package depen searchText: null, sourceId: 'source-unreachable', hasApp: false, + hidden: false, createdAt: '2026-05-10T00:00:00.000Z', updatedAt: '2026-05-10T00:00:00.000Z', } @@ -2413,6 +2416,7 @@ test('buildKodyModuleBundle follows self kody imports when recording reachable d searchText: null, sourceId: 'source-reachable', hasApp: false, + hidden: false, createdAt: '2026-05-10T00:00:00.000Z', updatedAt: '2026-05-10T00:00:00.000Z', } diff --git a/packages/worker/src/package-runtime/module-graph.workers.test.ts b/packages/worker/src/package-runtime/module-graph.workers.test.ts index 7f3a148fe1..78b5eb2e16 100644 --- a/packages/worker/src/package-runtime/module-graph.workers.test.ts +++ b/packages/worker/src/package-runtime/module-graph.workers.test.ts @@ -41,6 +41,7 @@ async function ensureSavedPackageArtifactSchema() { search_text TEXT, source_id TEXT NOT NULL, has_app INTEGER NOT NULL DEFAULT 0 CHECK (has_app IN (0, 1)), + hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1)), created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) )`) diff --git a/packages/worker/src/package-runtime/published-bundle-artifacts.node.test.ts b/packages/worker/src/package-runtime/published-bundle-artifacts.node.test.ts index 135d3d7a41..a315cbb2bc 100644 --- a/packages/worker/src/package-runtime/published-bundle-artifacts.node.test.ts +++ b/packages/worker/src/package-runtime/published-bundle-artifacts.node.test.ts @@ -211,6 +211,7 @@ test('rebuildPublishedPackageArtifacts bundles declared subscription handlers', searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-30T00:00:00.000Z', updatedAt: '2026-04-30T00:00:00.000Z', }, @@ -319,6 +320,7 @@ test('rebuildPublishedPackageArtifacts stores app bundles with artifactName null searchText: null, sourceId: 'source-1', hasApp: true, + hidden: false, createdAt: '2026-04-30T00:00:00.000Z', updatedAt: '2026-04-30T00:00:00.000Z', }, @@ -416,6 +418,7 @@ test('rebuildPublishedPackageArtifacts uses builder dependency metadata instead searchText: null, sourceId: 'source-1', hasApp: false, + hidden: false, createdAt: '2026-04-30T00:00:00.000Z', updatedAt: '2026-04-30T00:00:00.000Z', }, diff --git a/packages/worker/src/repo/published-bundle-artifacts-repo.workers.test.ts b/packages/worker/src/repo/published-bundle-artifacts-repo.workers.test.ts index e823666ee8..0911a6644d 100644 --- a/packages/worker/src/repo/published-bundle-artifacts-repo.workers.test.ts +++ b/packages/worker/src/repo/published-bundle-artifacts-repo.workers.test.ts @@ -32,6 +32,7 @@ async function ensurePublishedBundleArtifactDependencySchema() { search_text TEXT, source_id TEXT NOT NULL, has_app INTEGER NOT NULL DEFAULT 0 CHECK (has_app IN (0, 1)), + hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1)), created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP) )`) From c026c3667811af51551be6d7ce1e04376f4b5fcc Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Mon, 13 Jul 2026 16:26:59 -0600 Subject: [PATCH 2/2] Align hidden package search flags Use one include-hidden contract across search surfaces and expose visibility in human-readable package details. Co-authored-by: Cursor --- docs/contributing/packages-and-manifests.md | 10 ++--- .../meta/search-include-hidden.node.test.ts | 7 ++- .../src/mcp/capabilities/meta/search.ts | 6 +-- .../packages/set-package-hidden.ts | 2 +- .../src/mcp/tools/search-format.node.test.ts | 44 +++++++++++++++++++ .../worker/src/mcp/tools/search-format.ts | 1 + 6 files changed, 59 insertions(+), 11 deletions(-) diff --git a/docs/contributing/packages-and-manifests.md b/docs/contributing/packages-and-manifests.md index 264ef299e6..e61c90c27c 100644 --- a/docs/contributing/packages-and-manifests.md +++ b/docs/contributing/packages-and-manifests.md @@ -418,8 +418,8 @@ entities. Saved packages carry a user-scoped **`hidden`** flag in `saved_packages` (set via **`package_set_hidden`**). Ranked search excludes hidden packages by -default. The public MCP **search** tool accepts **`includeHiddenPackages`**; the -**meta** domain **search** capability accepts **`include_hidden`**. Known-id -entity lookup, **`package_list`**, **`package_get`**, and context-scope package -retrievers are unaffected. Hiding is not deletion, community delisting, or -entitlement exclusion. +default. The public MCP **search** tool and the **meta** domain **search** +capability both accept **`includeHiddenPackages`**. Known-id entity lookup, +**`package_list`**, **`package_get`**, and context-scope package retrievers are +unaffected. Hiding is not deletion, community delisting, or entitlement +exclusion. diff --git a/packages/worker/src/mcp/capabilities/meta/search-include-hidden.node.test.ts b/packages/worker/src/mcp/capabilities/meta/search-include-hidden.node.test.ts index 7d9803bc38..169a263ae5 100644 --- a/packages/worker/src/mcp/capabilities/meta/search-include-hidden.node.test.ts +++ b/packages/worker/src/mcp/capabilities/meta/search-include-hidden.node.test.ts @@ -86,7 +86,7 @@ function resetMocks() { }) } -test('meta search remaps include_hidden through to package rows and search-scope retrievers', async () => { +test('meta search remaps includeHiddenPackages through to package rows and search-scope retrievers', async () => { resetMocks() const ctx = createCtx() @@ -105,7 +105,10 @@ test('meta search remaps include_hidden through to package rows and search-scope ) resetMocks() - await searchCapability.handler({ query: 'notes', include_hidden: true }, ctx) + await searchCapability.handler( + { query: 'notes', includeHiddenPackages: true }, + ctx, + ) expect(mockModule.loadSearchRowsAndRegistry).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/worker/src/mcp/capabilities/meta/search.ts b/packages/worker/src/mcp/capabilities/meta/search.ts index 94e2055d10..35ed5f5011 100644 --- a/packages/worker/src/mcp/capabilities/meta/search.ts +++ b/packages/worker/src/mcp/capabilities/meta/search.ts @@ -121,7 +121,7 @@ export const searchCapability = defineDomainCapability( .describe('Max number of ranked results to return. Defaults to 15.'), conversationId: conversationIdInputField, memoryContext: memoryContextInputField, - include_hidden: z + includeHiddenPackages: z .boolean() .optional() .describe( @@ -135,7 +135,7 @@ export const searchCapability = defineDomainCapability( limit?: number conversationId?: string memoryContext?: z.infer - include_hidden?: boolean + includeHiddenPackages?: boolean }, ctx: CapabilityContext, ) { @@ -145,7 +145,7 @@ export const searchCapability = defineDomainCapability( } const conversationId = resolveConversationId(args.conversationId) const userId = ctx.callerContext.user?.userId ?? null - const includeHiddenPackages = !!args.include_hidden + const includeHiddenPackages = !!args.includeHiddenPackages const [searchRows, retrieverRun] = await Promise.all([ loadSearchRows({ ctx, diff --git a/packages/worker/src/mcp/capabilities/packages/set-package-hidden.ts b/packages/worker/src/mcp/capabilities/packages/set-package-hidden.ts index 66cf1755fe..2872475a40 100644 --- a/packages/worker/src/mcp/capabilities/packages/set-package-hidden.ts +++ b/packages/worker/src/mcp/capabilities/packages/set-package-hidden.ts @@ -9,7 +9,7 @@ export const setPackageHiddenCapability = defineDomainCapability( { name: 'package_set_hidden', description: - 'Set whether a saved package is hidden from discovery/search by default. Hidden packages are excluded from search results unless the caller opts in (includeHiddenPackages on the public search tool, or include_hidden on the meta search capability).', + 'Set whether a saved package is hidden from discovery/search by default. Hidden packages are excluded from search results unless the caller opts in with includeHiddenPackages on the public search tool or the meta search capability.', keywords: ['package', 'hidden', 'disable', 'visibility', 'search'], readOnly: false, idempotent: true, diff --git a/packages/worker/src/mcp/tools/search-format.node.test.ts b/packages/worker/src/mcp/tools/search-format.node.test.ts index c1b7271bf7..650a880b4e 100644 --- a/packages/worker/src/mcp/tools/search-format.node.test.ts +++ b/packages/worker/src/mcp/tools/search-format.node.test.ts @@ -538,6 +538,8 @@ export declare function fetch(request: Request): Promise `, }, }) + expect(observedPackageDetail.markdown).toContain('- Has app: yes') + expect(observedPackageDetail.markdown).toContain('- Hidden: no') expect(observedPackageDetail.structured).toMatchObject({ type: 'package', entityRef: 'observed-package:package', @@ -584,6 +586,48 @@ export declare function fetch(request: Request): Promise }) }) +test('package entity detail markdown includes hidden flag next to has app', () => { + const hiddenPackageDetail = formatEntityDetailMarkdown({ + type: 'package', + id: 'hidden-package', + title: '@kody/hidden-package', + description: 'Hidden package.', + baseUrl: 'http://localhost', + ownerUsername: 'test-user', + hostedUrl: null, + record: { + id: 'package-hidden', + userId: 'user-123', + name: '@kody/hidden-package', + kodyId: 'hidden-package', + description: 'Hidden package.', + tags: [], + searchText: null, + sourceId: 'source-package-hidden', + hasApp: false, + hidden: true, + createdAt: '2026-03-20T00:00:00.000Z', + updatedAt: '2026-03-20T00:00:00.000Z', + }, + manifest: { + name: '@kody/hidden-package', + exports: { + '.': './src/index.ts', + }, + kody: { + id: 'hidden-package', + description: 'Hidden package.', + tags: [], + }, + }, + files: { + 'package.json': '{}', + }, + }) + expect(hiddenPackageDetail.markdown).toContain('- Has app: no') + expect(hiddenPackageDetail.markdown).toContain('- Hidden: yes') +}) + test('package search formatting keeps runnable actions and hosted URLs in structured output', () => { const [hostedPackageMatch] = toSlimStructuredMatches({ baseUrl: 'http://localhost', diff --git a/packages/worker/src/mcp/tools/search-format.ts b/packages/worker/src/mcp/tools/search-format.ts index c5faf8ec49..fef683ab87 100644 --- a/packages/worker/src/mcp/tools/search-format.ts +++ b/packages/worker/src/mcp/tools/search-format.ts @@ -958,6 +958,7 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { `- Kody id: \`${detail.record.kodyId}\``, `- Tags: ${detail.record.tags.length > 0 ? detail.record.tags.map((tag) => `\`${tag}\``).join(', ') : 'none'}`, `- Has app: ${detail.record.hasApp ? 'yes' : 'no'}`, + `- Hidden: ${detail.record.hidden ? 'yes' : 'no'}`, ...(detail.hostedUrl ? [`- Hosted URL: \`${detail.hostedUrl}\``] : []), ] if (appEntry) {