diff --git a/README.md b/README.md index 236c5d5..559e3f9 100644 --- a/README.md +++ b/README.md @@ -138,36 +138,71 @@ Plugin Playground supports AI-assisted extension prototyping in both JupyterLite ### Commands for AI Agents and Automation -Plugin Playground now exposes command APIs that mirror sidebar data and support optional `query` filtering: +Plugin Playground now exposes command APIs that mirror sidebar data. +Discovery commands support optional `query` filtering: - `plugin-playground:list-tokens` - `plugin-playground:list-commands` - `plugin-playground:list-extension-examples` +- `plugin-playground:discover-plugin-docs` (supports optional `{ query?: string, package?: string, detailLevel?: 1 | 2 | 3 }`) - `plugin-playground:export-as-extension` (supports optional `{ path: string }`) +- `plugin-playground:fetch-plugin-doc` (requires `{ path: string }`, supports optional `{ maxChars: number }`) Example: ```typescript -await app.commands.execute('plugin-playground:list-tokens', { - query: 'notebook' -}); +const docs = await app.commands.execute( + 'plugin-playground:discover-plugin-docs', + { query: 'skill', detailLevel: 1 } +); + +const docContent = await app.commands.execute( + 'plugin-playground:fetch-plugin-doc', + { + path: docs.items[0].path, + maxChars: 8000 + } +); await app.commands.execute('plugin-playground:export-as-extension', { path: 'my-extension/src/index.ts' }); ``` -Each command returns a JSON object with: +Discovery commands (`list-*`, `discover-*`) return: - `query`: the filter text that was applied - `total`: total number of available records - `count`: number of records returned after filtering - `items`: matching records +`discover-plugin-docs` also returns: + +- `package`: the package filter text that was applied +- `detailLevel`: resolved detail level used for discovery +- `remaining`: how many matches were omitted at current detail level +- `hasMore`: whether additional matches are available +- `hint`: guidance for fetching more context (for low detail responses) + +For `discover-plugin-docs`, `package` matches inferred package context for each doc (for example `plugin-playground`, extension example name, or skill name). + +`plugin-playground:fetch-plugin-doc` returns: + +- `ok`: whether the fetch succeeded +- `path`: fetched documentation file path +- `title`: human-readable document title +- `source`: where the document came from +- `content`: fetched text content (possibly truncated) +- `contentLength`: original full text length +- `truncated`: whether `content` was truncated by `maxChars` + +By default, `fetch-plugin-doc` uses the `docFetchMaxChars` setting (default `20000`) and also caps any larger `maxChars` argument to that value. + ## Advanced Settings -The Advanced Settings for the Plugin Playground enable you to configure plugins to load every time JupyterLab starts up. Automatically loaded plugins can be configured in two ways: +The Advanced Settings for the Plugin Playground enable you to configure plugins to load every time JupyterLab starts up. Automatically loaded plugins can be configured in several ways: +- `docFetchMaxChars` sets the default and maximum character budget for `plugin-playground:fetch-plugin-doc`. - `urls` is a list of URLs that will be fetched and loaded as plugins automatically when JupyterLab starts up. For example, you can point to a GitHub gist or a file you host on a local server that serves text files like the above examples. - `plugins` is a list of strings of plugin text, like the examples above, that are loaded automatically when JupyterLab starts up. Since JSON strings cannot have multiple lines, you will need to encode any newlines in your plugin text directly as `\n\` (the second backslash is to allow the string to continue on the next line). For example, here is a user setting to encode a small plugin to run at startup: ```json5 diff --git a/_agents/skills/plugin-authoring/SKILL.md b/_agents/skills/plugin-authoring/SKILL.md index 41b8217..b1f4dda 100644 --- a/_agents/skills/plugin-authoring/SKILL.md +++ b/_agents/skills/plugin-authoring/SKILL.md @@ -44,11 +44,15 @@ Produce working plugin code that can be loaded with `plugin-playground:load-as-e - `app.commands.execute('plugin-playground:list-tokens', { query: 'status' })` - `app.commands.execute('plugin-playground:list-commands', { query: 'notebook' })` -3. Discover reference examples +3. Discover reference examples and docs - Run `plugin-playground:list-extension-examples`. - Filter by topic with `query` (for example `toolbar`, `commands`, `widget`, `notebook`). - Open selected example source/README from the sidebar for implementation details. +- Run `plugin-playground:discover-plugin-docs` to find relevant docs (`README`, skill docs, example READMEs). +- Use `query`, `package`, and `detailLevel` (`1`/`2`/`3`) to control precision and context size. +- Run `plugin-playground:fetch-plugin-doc` with a discovered `path` to retrieve doc text for grounded implementation details. +- Use the `docFetchMaxChars` setting to control the default/maximum doc context size. 4. Implement plugin code diff --git a/schema/plugin.json b/schema/plugin.json index 806b398..32e3e2c 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -37,6 +37,14 @@ "default": false, "type": "boolean" }, + "docFetchMaxChars": { + "title": "Max characters for doc fetch", + "description": "Maximum number of characters returned by plugin-playground:fetch-plugin-doc. Also used as the default when maxChars is omitted.", + "default": 20000, + "type": "integer", + "minimum": 1, + "maximum": 200000 + }, "plugins": { "title": "Plugins", "description": "List of strings of plugin text to load automatically. Line breaks are encoded as '\\n'", diff --git a/src/index.ts b/src/index.ts index c18e6f0..dbf92c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -84,6 +84,7 @@ import { IFileModel, normalizeExternalUrl, normalizeContentsPath, + normalizeQuery, openExternalLink } from './contents'; import { @@ -110,6 +111,8 @@ namespace CommandIDs { export const listCommands = 'plugin-playground:list-commands'; export const listExtensionExamples = 'plugin-playground:list-extension-examples'; + export const discoverPluginDocs = 'plugin-playground:discover-plugin-docs'; + export const fetchPluginDoc = 'plugin-playground:fetch-plugin-doc'; } type PluginLoadStatus = @@ -149,6 +152,34 @@ interface IResolvedExportContext { usedTemplate: boolean; } +/** + * Metadata for a documentation entry returned by + * `plugin-playground:discover-plugin-docs`. + */ +interface IPluginDocRecord { + title: string; + path: string; + source: string; + description: string; +} + +/** + * Result payload returned by `plugin-playground:fetch-plugin-doc`. + * + * When `ok` is `false`, `message` describes the read failure. + * When `ok` is `true`, `content` may still be truncated depending on `maxChars`. + */ +interface IPluginDocFetchResult { + ok: boolean; + path: string | null; + title: string | null; + source: string | null; + content: string | null; + contentLength: number; + truncated: boolean; + message?: string; +} + const PLUGIN_TEMPLATE = `import { JupyterFrontEnd, JupyterFrontEndPlugin, @@ -194,6 +225,13 @@ interface IPrivatePluginData { } const EXTENSION_EXAMPLES_ROOT = 'extension-examples'; +const ROOT_README_PATH = 'README.md'; +const DOCS_INDEX_PATH = 'docs/index.md'; +const PLUGIN_AUTHORING_SKILL_PATH = '_agents/skills/plugin-authoring/SKILL.md'; +const DOC_FETCH_MAX_CHARS_SETTING = 'docFetchMaxChars'; +const DOC_DISCOVERY_DETAIL_DEFAULT = 2; +const DOC_DISCOVERY_DETAIL_MIN = 1; +const DOC_DISCOVERY_DETAIL_MAX = 3; const LIST_QUERY_ARGS_SCHEMA = { type: 'object', additionalProperties: false, @@ -206,6 +244,29 @@ const LIST_QUERY_ARGS_SCHEMA = { } }; +const DISCOVER_PLUGIN_DOCS_ARGS_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + query: { + type: 'string', + description: + 'Optional filter text. Matches records case-insensitively by doc title, path, source, and description.' + }, + package: { + type: 'string', + description: + 'Optional package filter text. Matches inferred package context for each doc (for example plugin-playground, extension example name, or skill name).' + }, + detailLevel: { + type: 'integer', + minimum: DOC_DISCOVERY_DETAIL_MIN, + maximum: DOC_DISCOVERY_DETAIL_MAX, + description: `Optional detail level. 1 returns a small preview with a "hasMore" hint, 2 returns a larger slice, and 3 returns all matches. Defaults to ${DOC_DISCOVERY_DETAIL_DEFAULT}.` + } + } +}; + const EXPORT_AS_EXTENSION_ARGS_SCHEMA = { type: 'object', additionalProperties: false, @@ -229,6 +290,24 @@ const CREATE_PLUGIN_ARGS_SCHEMA = { } } }; + +const FETCH_PLUGIN_DOC_ARGS_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['path'], + properties: { + path: { + type: 'string', + description: + 'Contents path of a discovered documentation file. Use plugin-playground:discover-plugin-docs to list available paths.' + }, + maxChars: { + type: 'integer', + minimum: 1, + description: `Optional maximum number of characters to return. Defaults to and is capped by the "${DOC_FETCH_MAX_CHARS_SETTING}" setting.` + } + } +}; const LOAD_ON_SAVE_TOGGLE_TOOLBAR_ITEM = 'plugin-playground-load-on-save'; const LOAD_ON_SAVE_CHECKBOX_LABEL = 'Auto Load on Save'; const LOAD_ON_SAVE_SETTING = 'loadOnSave'; @@ -499,6 +578,141 @@ class PluginPlayground { } }); + app.commands.addCommand(CommandIDs.discoverPluginDocs, { + label: 'Discover Plugin Docs (Playground)', + caption: 'List plugin documentation files available to AI tooling', + describedBy: { args: DISCOVER_PLUGIN_DOCS_ARGS_SCHEMA }, + execute: async args => { + const query = typeof args.query === 'string' ? args.query.trim() : ''; + const packageQuery = + typeof args.package === 'string' ? args.package.trim() : ''; + const normalizedQuery = normalizeQuery(query); + const normalizedPackageQuery = normalizeQuery(packageQuery); + const detailLevelRaw = + typeof args.detailLevel === 'number' && + Number.isFinite(args.detailLevel) + ? Math.floor(args.detailLevel) + : DOC_DISCOVERY_DETAIL_DEFAULT; + const detailLevel = Math.max( + DOC_DISCOVERY_DETAIL_MIN, + Math.min(DOC_DISCOVERY_DETAIL_MAX, detailLevelRaw) + ); + const docs = await this._discoverPluginDocs(); + const matching = docs.filter(doc => { + if (normalizedQuery) { + const searchableText = + `${doc.title} ${doc.path} ${doc.source} ${doc.description}`.toLowerCase(); + if (!searchableText.includes(normalizedQuery)) { + return false; + } + } + if (normalizedPackageQuery) { + const pathSegments = doc.path.split('/'); + let packageContext = + 'plugin-playground @jupyterlab/plugin-playground'; + if (pathSegments[0] === 'extension-examples' && pathSegments[1]) { + packageContext = `${pathSegments[1]} @jupyterlab-examples/${pathSegments[1]}`; + } else if ( + pathSegments[0] === '_agents' && + pathSegments[1] === 'skills' && + pathSegments[2] + ) { + packageContext = `${pathSegments[2]} skill`; + } + if ( + !normalizeQuery(packageContext).includes(normalizedPackageQuery) + ) { + return false; + } + } + return true; + }); + const maxItems = + detailLevel === 1 ? 2 : detailLevel === 2 ? 10 : matching.length; + const items = matching.slice(0, maxItems); + const remaining = Math.max(0, matching.length - items.length); + return { + query, + package: packageQuery, + detailLevel, + total: docs.length, + count: items.length, + remaining, + hasMore: remaining > 0, + hint: + remaining > 0 + ? `Increase detailLevel or narrow query/package to inspect ${remaining} additional matching reference(s).` + : null, + items: [...items] + }; + } + }); + + app.commands.addCommand(CommandIDs.fetchPluginDoc, { + label: 'Fetch Plugin Doc (Playground)', + caption: 'Fetch text content from a discovered plugin documentation file', + describedBy: { args: FETCH_PLUGIN_DOC_ARGS_SCHEMA }, + execute: async args => { + const rawPath = typeof args.path === 'string' ? args.path.trim() : ''; + const path = normalizeContentsPath(rawPath); + if (!path) { + return { + ok: false, + path: null, + title: null, + source: null, + content: null, + contentLength: 0, + truncated: false, + message: + 'Path argument is required. Use plugin-playground:discover-plugin-docs to list valid documentation paths.' + } as IPluginDocFetchResult; + } + const discoveredDocs = await this._discoverPluginDocs(); + const isDiscoveredPath = discoveredDocs.some(doc => doc.path === path); + if (!isDiscoveredPath) { + return { + ok: false, + path, + title: null, + source: null, + content: null, + contentLength: 0, + truncated: false, + message: `Path "${path}" is not a discoverable plugin doc. Use plugin-playground:discover-plugin-docs first.` + } as IPluginDocFetchResult; + } + const rawConfiguredMaxChars = this.settings.get( + DOC_FETCH_MAX_CHARS_SETTING + ).composite; + if ( + typeof rawConfiguredMaxChars !== 'number' || + !Number.isFinite(rawConfiguredMaxChars) || + rawConfiguredMaxChars < 1 + ) { + return { + ok: false, + path, + title: null, + source: null, + content: null, + contentLength: 0, + truncated: false, + message: `Invalid "${DOC_FETCH_MAX_CHARS_SETTING}" setting value. Expected a positive integer.` + } as IPluginDocFetchResult; + } + const configuredMaxChars = Math.floor(rawConfiguredMaxChars); + const requestedMaxChars = + typeof args.maxChars === 'number' && + Number.isFinite(args.maxChars) && + args.maxChars > 0 + ? Math.floor(args.maxChars) + : configuredMaxChars; + const maxChars = Math.min(requestedMaxChars, configuredMaxChars); + return this._fetchPluginDoc(path, maxChars); + } + }); + app.restored.then(async () => { const settings = this.settings; this._updateSettings(requirejs, settings); @@ -1527,6 +1741,131 @@ class PluginPlayground { ); } + private async _discoverPluginDocs(): Promise< + ReadonlyArray + > { + const discovered: IPluginDocRecord[] = []; + const seenPaths = new Set(); + + const addIfAvailable = async (record: IPluginDocRecord): Promise => { + const normalizedPath = normalizeContentsPath(record.path); + if (!normalizedPath || seenPaths.has(normalizedPath)) { + return; + } + const fileModel = await getFileModel( + this.app.serviceManager, + normalizedPath + ); + if (!fileModel || fileModelToText(fileModel) === null) { + return; + } + seenPaths.add(normalizedPath); + discovered.push({ + ...record, + path: normalizedPath + }); + }; + + await addIfAvailable({ + title: 'Plugin Playground README', + path: ROOT_README_PATH, + source: 'project-readme', + description: 'Overview and usage guide for Plugin Playground.' + }); + + await addIfAvailable({ + title: 'Plugin Playground Docs Index', + path: DOCS_INDEX_PATH, + source: 'project-docs', + description: 'Published documentation landing page for this project.' + }); + + await addIfAvailable({ + title: 'Plugin Authoring Skill', + path: PLUGIN_AUTHORING_SKILL_PATH, + source: 'agent-skill', + description: 'AI agent workflow reference for authoring plugins.' + }); + + const examples = await this._discoverExtensionExamples(); + for (const example of examples) { + await addIfAvailable({ + title: `${example.name} README`, + path: example.readmePath, + source: 'extension-example', + description: example.description || this._fallbackExampleDescription + }); + } + + return discovered.sort( + (left, right) => + left.title.localeCompare(right.title) || + left.path.localeCompare(right.path) + ); + } + + private async _fetchPluginDoc( + path: string, + maxChars: number + ): Promise { + const normalizedPath = normalizeContentsPath(path); + if (!normalizedPath) { + return { + ok: false, + path: null, + title: null, + source: null, + content: null, + contentLength: 0, + truncated: false, + message: 'Documentation path is empty.' + }; + } + + const fileModel = await getFileModel( + this.app.serviceManager, + normalizedPath + ); + if (!fileModel) { + return { + ok: false, + path: normalizedPath, + title: null, + source: null, + content: null, + contentLength: 0, + truncated: false, + message: `Could not read documentation file "${normalizedPath}".` + }; + } + + const rawContent = fileModelToText(fileModel); + if (rawContent === null) { + return { + ok: false, + path: normalizedPath, + title: null, + source: null, + content: null, + contentLength: 0, + truncated: false, + message: `Documentation file "${normalizedPath}" is not readable as text.` + }; + } + + const content = rawContent.slice(0, maxChars); + const truncated = content.length < rawContent.length; + return { + ok: true, + path: normalizedPath, + title: this._basename(normalizedPath) || normalizedPath, + source: 'file', + content, + contentLength: rawContent.length, + truncated + }; + } + private async _findExampleEntrypoint( directoryPath: string ): Promise { diff --git a/ui-tests/tests/plugin-playground.spec.ts b/ui-tests/tests/plugin-playground.spec.ts index ee9391d..9326f3f 100644 --- a/ui-tests/tests/plugin-playground.spec.ts +++ b/ui-tests/tests/plugin-playground.spec.ts @@ -12,6 +12,8 @@ const PLAYGROUND_PLUGIN_ID = '@jupyterlab/plugin-playground:plugin'; const LIST_TOKENS_COMMAND = 'plugin-playground:list-tokens'; const LIST_COMMANDS_COMMAND = 'plugin-playground:list-commands'; const LIST_EXAMPLES_COMMAND = 'plugin-playground:list-extension-examples'; +const DISCOVER_DOCS_COMMAND = 'plugin-playground:discover-plugin-docs'; +const FETCH_DOC_COMMAND = 'plugin-playground:fetch-plugin-doc'; const TEST_PLUGIN_ID = 'playground-integration-test:plugin'; const TEST_TOGGLE_COMMAND = 'playground-integration-test:toggle'; const TEST_FILE = 'playground-integration-test.ts'; @@ -21,6 +23,8 @@ const PLAYGROUND_SIDEBAR_ID = 'jp-plugin-playground-sidebar'; const TOKEN_SECTION_ID = 'jp-plugin-token-sidebar'; const EXAMPLE_SECTION_ID = 'jp-plugin-example-sidebar'; const LOAD_ON_SAVE_CHECKBOX_LABEL = 'Auto Load on Save'; +const DOC_FETCH_MAX_CHARS_SETTING = 'docFetchMaxChars'; +const DOC_FETCH_MAX_CHARS_TEST_LIMIT = 24; test.use({ autoGoto: false }); @@ -145,6 +149,16 @@ test('registers plugin playground commands', async ({ page }) => { return window.jupyterapp.commands.hasCommand(id); }, LIST_EXAMPLES_COMMAND) ); + await page.waitForCondition(() => + page.evaluate((id: string) => { + return window.jupyterapp.commands.hasCommand(id); + }, DISCOVER_DOCS_COMMAND) + ); + await page.waitForCondition(() => + page.evaluate((id: string) => { + return window.jupyterapp.commands.hasCommand(id); + }, FETCH_DOC_COMMAND) + ); await expect( page.evaluate((id: string) => { @@ -177,6 +191,16 @@ test('registers plugin playground commands', async ({ page }) => { return window.jupyterapp.commands.hasCommand(id); }, LIST_EXAMPLES_COMMAND) ).resolves.toBe(true); + await expect( + page.evaluate((id: string) => { + return window.jupyterapp.commands.hasCommand(id); + }, DISCOVER_DOCS_COMMAND) + ).resolves.toBe(true); + await expect( + page.evaluate((id: string) => { + return window.jupyterapp.commands.hasCommand(id); + }, FETCH_DOC_COMMAND) + ).resolves.toBe(true); }); test('opens a dummy extension example from the sidebar', async ({ page }) => { @@ -333,6 +357,138 @@ test('lists tokens and searches commands via command APIs', async ({ ).toBe(true); }); +test.describe('doc command APIs', () => { + test.use({ + mockSettings: { + ...galata.DEFAULT_SETTINGS, + [PLAYGROUND_PLUGIN_ID]: { + [DOC_FETCH_MAX_CHARS_SETTING]: DOC_FETCH_MAX_CHARS_TEST_LIMIT + } + } + }); + + test('discovers and fetches plugin docs via command APIs', async ({ + page + }) => { + const content = '# Playground Docs\n\nLocal test documentation.\n'; + await page.contents.uploadContent(content, 'text', 'README.md'); + await page.contents.uploadContent( + '# Docs Index\n\nNavigation page.\n', + 'text', + 'docs/index.md' + ); + await page.contents.uploadContent( + '# Plugin Authoring Skill\n\nGuidance.\n', + 'text', + '_agents/skills/plugin-authoring/SKILL.md' + ); + await page.contents.uploadContent( + '# Private Notes\n\nNot part of plugin docs discovery.\n', + 'text', + 'notes.md' + ); + await page.goto(); + await page.waitForCondition(() => + page.evaluate((id: string) => { + return window.jupyterapp.commands.hasCommand(id); + }, DISCOVER_DOCS_COMMAND) + ); + await page.waitForCondition(() => + page.evaluate((id: string) => { + return window.jupyterapp.commands.hasCommand(id); + }, FETCH_DOC_COMMAND) + ); + + const lowDetailResult = await page.evaluate((id: string) => { + return window.jupyterapp.commands.execute(id, { + detailLevel: 1 + }); + }, DISCOVER_DOCS_COMMAND); + expect(lowDetailResult.count).toBe(2); + expect(lowDetailResult.hasMore).toBe(true); + expect(lowDetailResult.remaining).toBeGreaterThan(0); + expect(lowDetailResult.count + lowDetailResult.remaining).toBeGreaterThan( + 2 + ); + expect(typeof lowDetailResult.hint).toBe('string'); + + const packageFilteredResult = await page.evaluate((id: string) => { + return window.jupyterapp.commands.execute(id, { + package: 'plugin-authoring', + detailLevel: 3 + }); + }, DISCOVER_DOCS_COMMAND); + expect(packageFilteredResult.count).toBeGreaterThanOrEqual(1); + expect( + packageFilteredResult.items.some((item: { path: string }) => + item.path.includes('plugin-authoring/SKILL.md') + ) + ).toBe(true); + + const docsResult = await page.evaluate( + ({ id, query }) => { + return window.jupyterapp.commands.execute(id, { query }); + }, + { + id: DISCOVER_DOCS_COMMAND, + query: 'readme' + } + ); + expect(docsResult.count).toBeGreaterThan(0); + expect(docsResult.total).toBeGreaterThan(0); + + const readmeDoc = docsResult.items.find( + (item: { path: string }) => item.path === 'README.md' + ) as { path: string } | undefined; + expect(readmeDoc).toBeDefined(); + const docToFetch = readmeDoc as { path: string }; + + const defaultFetchResult = await page.evaluate((id: string) => { + return window.jupyterapp.commands.execute(id, { path: 'README.md' }); + }, FETCH_DOC_COMMAND); + expect(defaultFetchResult.ok).toBe(true); + expect(defaultFetchResult.path).toBe('README.md'); + expect(defaultFetchResult.contentLength).toBe(content.length); + expect(defaultFetchResult.content).toBe( + content.slice(0, DOC_FETCH_MAX_CHARS_TEST_LIMIT) + ); + expect(defaultFetchResult.truncated).toBe( + content.length > DOC_FETCH_MAX_CHARS_TEST_LIMIT + ); + + const cappedFetchResult = await page.evaluate( + ({ id, path, maxChars }) => { + return window.jupyterapp.commands.execute(id, { path, maxChars }); + }, + { + id: FETCH_DOC_COMMAND, + path: docToFetch.path, + maxChars: 128 + } + ); + expect(cappedFetchResult.ok).toBe(true); + expect(cappedFetchResult.path).toBe(docToFetch.path); + expect(typeof cappedFetchResult.content).toBe('string'); + expect(cappedFetchResult.content).toContain('Playground Docs'); + expect(cappedFetchResult.content.length).toBeLessThanOrEqual(128); + expect(cappedFetchResult.content.length).toBeLessThanOrEqual( + DOC_FETCH_MAX_CHARS_TEST_LIMIT + ); + expect(cappedFetchResult.contentLength).toBeGreaterThan(0); + expect(cappedFetchResult.truncated).toBe( + cappedFetchResult.contentLength > cappedFetchResult.content.length + ); + + const missingResult = await page.evaluate((id: string) => { + return window.jupyterapp.commands.execute(id, { + path: 'notes.md' + }); + }, FETCH_DOC_COMMAND); + expect(missingResult.ok).toBe(false); + expect(missingResult.message).toContain('not a discoverable plugin doc'); + }); +}); + test('open packages reference command switches to packages view', async ({ page }) => {