verstak-docs/docs/superpowers/plans/2026-06-29-persistent-searc...

1071 lines
33 KiB
Markdown

# Persistent Search Runtime Hosting Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a persistent workspace search index for `verstak.search` and host runtime `searchProviders` through the existing plugin command bridge.
**Architecture:** Keep search as an official plugin. Add a generic plugin data storage surface to the SDK and desktop frontend bridge, then make `verstak.search` persist its own JSON index and fan out to registered provider command handlers with `api.commands.executeFor`.
**Tech Stack:** TypeScript SDK types/tests with Vitest, Wails desktop frontend bridge JavaScript smoke tests, official plugin browserless Node smoke tests, Markdown docs.
## Global Constraints
- Do not copy code or architecture from `~/git/verstak`; it is feature reference only.
- Search remains plugin functionality; do not move search semantics into desktop core.
- The primary visible search input belongs in the workspace header next to the workspace title.
- The standalone Search workspace item is at most an expanded results surface, not the primary entry point.
- Workspace tool tabs are ordered by expected usage frequency, not alphabetically.
- User files remain the source of truth; the index is an optimization and discovery layer.
- Use JSON plugin data named `search-index`; do not use SQLite FTS or a sidecar indexer.
- `searchProviders[].handler` is a command id declared by the same plugin.
- A provider is executable only when its command handler is registered in the current frontend runtime.
- Missing storage, corrupt index data, provider failures, and unreadable files must degrade Search without failing the whole view.
- Use TDD for production code changes: write the failing test, run it red, implement the minimal code, run it green.
- After each meaningful change, commit and push the affected repository.
---
## File Structure
### `verstak-sdk`
- Modify `src/plugin-api.ts`
- Add `PluginDataJSON` type.
- Add `storage.data.read/write` to `VerstakPluginAPI`.
- Modify `src/test-utils.ts`
- Store mock plugin data separately from mock settings.
- Expose `api.storage.data.read/write`.
- Modify `src/plugin-api.test.ts`
- Add a focused mock API test for plugin data storage isolation.
- Generated by build: `dist/*`.
### `verstak-desktop`
- Modify `frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Expose `api.storage.data.read/write`.
- Route to existing Wails methods `ReadPluginDataJSON` and `WritePluginDataJSON`.
- Modify `frontend/src/lib/shell/WorkspaceHost.svelte`
- Sort workspace tools by expected user value instead of alphabetically.
- Reserve the workspace header as the primary compact search entry point.
- Modify `frontend/tests/plugin-api-contributions-test.mjs`
- Extend the smoke mock and assertions to cover plugin data round-trip.
- Modify `frontend/src/lib/test/wails-mock.js`
- Add in-memory plugin data behavior for browser/e2e mocks.
### `verstak-official-plugins`
- Modify `plugins/search/plugin.json`
- Add `storage.namespace`, `events.subscribe`, and `commands.register`.
- Add a command contribution whose id is used by `searchProviders[].handler`.
- Modify `plugins/search/frontend/src/index.js`
- Add small index helpers.
- Register the local vault-text search provider command.
- Load/build/write `search-index`.
- Subscribe to `file.changed`.
- Fan out to other `searchProviders`.
- Modify `scripts/smoke-search-plugin.js`
- Cover persisted index reads/writes, provider fan-out, provider failures, and file event updates.
### `verstak-docs`
- Modify `05_Official_Plugins.md`
- Replace the future-work sentence with current behavior after implementation.
- Modify `07_Full_Implementation_Roadmap.md`
- Mark persistent search index and runtime hosting complete after code is verified.
---
### Task 1: SDK Plugin Data Storage Contract
**Files:**
- Modify: `/home/mirivlad/git/verstak2/verstak-sdk/src/plugin-api.ts`
- Modify: `/home/mirivlad/git/verstak2/verstak-sdk/src/test-utils.ts`
- Modify: `/home/mirivlad/git/verstak2/verstak-sdk/src/plugin-api.test.ts`
- Generated by build: `/home/mirivlad/git/verstak2/verstak-sdk/dist/*`
**Interfaces:**
- Produces:
- `export type PluginDataJSON = Record<string, unknown>;`
- `VerstakPluginAPI.storage.data.read(name: string): Promise<PluginDataJSON>`
- `VerstakPluginAPI.storage.data.write(name: string, data: PluginDataJSON): Promise<void>`
- Consumes:
- Existing `VerstakPluginAPI` and `createMockPluginAPI`.
- [ ] **Step 1: Write the failing SDK test**
Add this test after `settings persist in the mock API namespace` in `src/plugin-api.test.ts`:
```ts
test('plugin data persists separately from settings in the mock API namespace', async () => {
const api = createMockPluginAPI('storage.plugin');
await api.settings.write('search-index', { source: 'settings' });
await api.storage.data.write('search-index', {
version: 1,
workspaceRootPath: 'Project',
entries: [{ path: 'Project/Docs/case.md' }],
});
await expect(api.storage.data.read('search-index')).resolves.toEqual({
version: 1,
workspaceRootPath: 'Project',
entries: [{ path: 'Project/Docs/case.md' }],
});
await expect(api.settings.read('search-index')).resolves.toEqual({ source: 'settings' });
await expect(api.storage.data.read('missing')).resolves.toEqual({});
});
```
Also extend `mock API exposes the bundled runtime shape` with:
```ts
expect(typeof api.storage.data.read).toBe('function');
expect(typeof api.storage.data.write).toBe('function');
```
- [ ] **Step 2: Run RED**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-sdk
npx vitest run src/plugin-api.test.ts -t "plugin data persists separately from settings in the mock API namespace"
```
Expected: TypeScript/Vitest fails because `storage` does not exist on `VerstakPluginAPI`.
- [ ] **Step 3: Add the SDK type**
In `src/plugin-api.ts`, add this type near `PluginCommandArgs`:
```ts
export type PluginDataJSON = Record<string, unknown>;
```
Then add this block to `VerstakPluginAPI` immediately after `settings`:
```ts
storage: {
data: {
read(name: string): Promise<PluginDataJSON>;
write(name: string, data: PluginDataJSON): Promise<void>;
};
};
```
- [ ] **Step 4: Add mock storage data**
In `src/test-utils.ts`, add an in-memory data map near the existing `settings` map:
```ts
const pluginData = new Map<string, Record<string, unknown>>();
```
Then add this `storage` object immediately after the returned `settings` object:
```ts
storage: {
data: {
read: vi.fn(async (name: string) => ({ ...(pluginData.get(name) || {}) })),
write: vi.fn(async (name: string, data: Record<string, unknown>) => {
pluginData.set(name, { ...(data || {}) });
}),
},
},
```
- [ ] **Step 5: Run GREEN**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-sdk
npx vitest run src/plugin-api.test.ts -t "plugin data persists separately from settings in the mock API namespace"
```
Expected: one matching test passes.
- [ ] **Step 6: Run SDK verification**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-sdk
npm test
npm run build
```
Expected: Vitest exits 0; `tsc` exits 0 and updates `dist/`.
- [ ] **Step 7: Commit and push SDK**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-sdk
git status --short
git add src/plugin-api.ts src/test-utils.ts src/plugin-api.test.ts dist
git commit -m "feat: expose plugin data storage API"
git push
```
Expected: commit is created and pushed; `git status --short --branch` reports `## main...origin/main`.
---
### Task 2: Desktop Frontend Storage Bridge
**Files:**
- Modify: `/home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/plugin-host/VerstakPluginAPI.js`
- Modify: `/home/mirivlad/git/verstak2/verstak-desktop/frontend/tests/plugin-api-contributions-test.mjs`
- Modify: `/home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/test/wails-mock.js`
**Interfaces:**
- Consumes:
- Existing Wails methods `App.ReadPluginDataJSON(pluginId, name)` and `App.WritePluginDataJSON(pluginId, name, data)`.
- Produces:
- Runtime `api.storage.data.read(name)` and `api.storage.data.write(name, data)`.
- [ ] **Step 1: Write the failing bridge smoke test**
In `frontend/tests/plugin-api-contributions-test.mjs`, extend `window.go.api.App.App` with plugin data storage:
```js
const pluginData = {};
```
Place it before `globalThis.window = { ... }`.
Inside `App`, add:
```js
ReadPluginDataJSON: (pluginId, name) => Promise.resolve([
Object.assign({}, (pluginData[pluginId] && pluginData[pluginId][name]) || {}),
'',
]),
WritePluginDataJSON: (pluginId, name, data) => {
pluginData[pluginId] = pluginData[pluginId] || {};
pluginData[pluginId][name] = Object.assign({}, data || {});
return Promise.resolve('');
},
```
After the existing `executeFor` assertion, add:
```js
if (!api.storage || !api.storage.data || typeof api.storage.data.read !== 'function' || typeof api.storage.data.write !== 'function') {
throw new Error('api.storage.data read/write is missing');
}
await api.storage.data.write('search-index', { version: 1, workspaceRootPath: 'Project' });
const stored = await api.storage.data.read('search-index');
if (stored.version !== 1 || stored.workspaceRootPath !== 'Project') {
throw new Error(`unexpected storage data: ${JSON.stringify(stored)}`);
}
```
- [ ] **Step 2: Run RED**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-desktop
node frontend/tests/plugin-api-contributions-test.mjs
```
Expected: fails with `api.storage.data read/write is missing`.
- [ ] **Step 3: Implement bridge storage**
In `frontend/src/lib/plugin-host/VerstakPluginAPI.js`, add this block immediately after `settings` and before `files`:
```js
storage: {
data: {
read: function(name) {
assertActive('storage.data.read(' + name + ')');
if (!name) {
throw new Error('storage.data.read requires a name');
}
return callBackend(pluginId, 'storage.data.read(' + name + ')', function() {
return App.ReadPluginDataJSON(pluginId, name);
}).then(function(data) {
return data || {};
});
},
write: function(name, data) {
assertActive('storage.data.write(' + name + ')');
if (!name) {
throw new Error('storage.data.write requires a name');
}
return callBackendErrorString(pluginId, 'storage.data.write(' + name + ')', function() {
return App.WritePluginDataJSON(pluginId, name, data || {});
});
}
}
},
```
- [ ] **Step 4: Implement browser mock storage**
In `frontend/src/lib/test/wails-mock.js`, add a module-scope object near existing plugin mock state:
```js
var pluginData = {};
```
Replace the current `ReadPluginDataJSON` and `WritePluginDataJSON` stubs with:
```js
ReadPluginDataJSON: function (pluginId, name) {
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {};
return Promise.resolve([Object.assign({}, data), '']);
},
WritePluginDataJSON: function (pluginId, name, data) {
pluginData[pluginId] = pluginData[pluginId] || {};
pluginData[pluginId][name] = Object.assign({}, data || {});
return Promise.resolve('');
},
```
If `pluginData` already exists in another scope, reuse that exact object and do not create a duplicate.
- [ ] **Step 5: Run GREEN**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-desktop
node frontend/tests/plugin-api-contributions-test.mjs
```
Expected: `plugin api contributions smoke passed`.
- [ ] **Step 6: Run desktop frontend verification**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-desktop/frontend
npm run build
```
Expected: Vite build exits 0.
- [ ] **Step 7: Commit and push desktop**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-desktop
git status --short
git add frontend/src/lib/plugin-host/VerstakPluginAPI.js frontend/tests/plugin-api-contributions-test.mjs frontend/src/lib/test/wails-mock.js
git commit -m "feat: expose plugin data storage bridge"
git push
```
Expected: commit is created and pushed; `git status --short --branch` reports `## main...origin/main`.
---
### Task 2A: Workspace Header Search Slot And Usage-Ordered Tabs
**Files:**
- Modify: `/home/mirivlad/git/verstak2/verstak-desktop/frontend/src/lib/shell/WorkspaceHost.svelte`
- Test: add or extend a focused frontend smoke test under `/home/mirivlad/git/verstak2/verstak-desktop/frontend/tests/`
**Interfaces:**
- Consumes:
- Existing `contributions.workspaceItems`.
- Existing selected workspace props in `WorkspaceHost.svelte`.
- Produces:
- Stable workspace tab ordering by expected usage frequency.
- A workspace header search slot next to the workspace title for the future compact Search entry.
- [ ] **Step 1: Write failing frontend smoke coverage**
Add a focused test that mounts or exercises `WorkspaceHost` ordering with sample
tools named `Search`, `Activity`, `Files`, and `Notes`.
Expected order:
```text
Notes
Files
Activity
Browser Inbox
Search
```
The test should also assert that the workspace header exposes a search input or
search trigger container with a stable selector:
```text
data-workspace-search
```
- [ ] **Step 2: Run RED**
Run the focused test command added in Step 1.
Expected: it fails because workspace tools currently follow contribution order
and there is no header search slot.
- [ ] **Step 3: Implement ordering and header slot**
In `WorkspaceHost.svelte`, add a small local ordering table:
```js
const toolOrder = new Map([
['notes', 10],
['files', 20],
['activity', 40],
['browser', 50],
['inbox', 50],
['search', 90],
]);
```
Sort `workspaceTools` after filtering enabled plugins by the best matching
keyword in title/id/pluginId. Keep unknown tools after known tools and preserve
their relative title order.
Add a compact header search container next to the workspace title:
```svelte
<div class="workspace-search" data-workspace-search>
<input type="search" placeholder="Search workspace" aria-label="Search workspace" />
</div>
```
This task only creates the visible slot and stable ordering; later Search task
wires the input to the provider runtime.
- [ ] **Step 4: Run GREEN and build**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-desktop/frontend
npm run build
```
Expected: focused test and Vite build exit 0.
- [ ] **Step 5: Commit and push desktop**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-desktop
git status --short
git add frontend/src/lib/shell/WorkspaceHost.svelte frontend/tests
git commit -m "feat: add workspace header search slot"
git push
```
Expected: commit is created and pushed; unrelated pre-existing changes are not
included.
---
### Task 3: Search Plugin Persistent Index And Provider Hosting
**Files:**
- Modify: `/home/mirivlad/git/verstak2/verstak-official-plugins/plugins/search/plugin.json`
- Modify: `/home/mirivlad/git/verstak2/verstak-official-plugins/plugins/search/frontend/src/index.js`
- Modify: `/home/mirivlad/git/verstak2/verstak-official-plugins/scripts/smoke-search-plugin.js`
**Interfaces:**
- Consumes:
- `api.storage.data.read('search-index')`
- `api.storage.data.write('search-index', data)`
- `api.events.subscribe('file.changed', handler)`
- `api.contributions.list('searchProviders')`
- `api.commands.register(commandId, handler)`
- `api.commands.executeFor(pluginId, commandId, args)`
- Produces:
- Command `verstak.search.searchVaultText`.
- Search provider host behavior that merges local indexed results with external provider results.
- [ ] **Step 1: Write failing smoke coverage for storage and providers**
In `scripts/smoke-search-plugin.js`, extend the fake API with:
```js
const eventHandlers = {};
const storageWrites = [];
const providerCalls = [];
let caseText = '# Case\nTarget phrase is here.\n';
```
Add to `api`:
```js
storage: {
data: {
read: async (name) => {
if (name !== 'search-index') throw new Error('unexpected storage read ' + name);
return {};
},
write: async (name, data) => {
if (name !== 'search-index') throw new Error('unexpected storage write ' + name);
storageWrites.push(data);
},
},
},
events: {
subscribe: async (name, handler) => {
eventHandlers[name] = eventHandlers[name] || [];
eventHandlers[name].push(handler);
return () => {
eventHandlers[name] = (eventHandlers[name] || []).filter((item) => item !== handler);
};
},
},
contributions: {
list: async (point) => point === 'searchProviders' ? [{
pluginId: 'provider.search',
id: 'provider.search.activity',
label: 'Activity Provider',
handler: 'provider.search.activity',
}, {
pluginId: 'provider.search',
id: 'provider.search.fail',
label: 'Failing Provider',
handler: 'provider.search.fail',
}] : [],
},
commands: {
register: async () => () => {},
executeFor: async (pluginId, commandId, args) => {
providerCalls.push({ pluginId, commandId, args });
if (commandId === 'provider.search.fail') throw new Error('provider unavailable');
return {
status: 'handled',
pluginId,
commandId,
result: {
results: [{
title: 'Activity hit',
snippet: 'Target activity result',
matchType: 'Activity',
providerId: 'provider.search.activity',
providerLabel: 'Activity Provider',
type: 'activity',
openable: false,
score: 75,
}],
},
};
},
},
```
Change the `readText` branch for `Project/Docs/case.md` to:
```js
if (relativePath === 'Project/Docs/case.md') return caseText;
```
After the first `target` search assertions, add:
```js
if (storageWrites.length === 0) throw new Error('search index should be persisted after initial build');
if (!container.textContent.includes('Activity hit')) throw new Error('provider result should be rendered');
if (!container.textContent.includes('Activity Provider')) throw new Error('provider label should be rendered');
if (!container.textContent.includes('provider unavailable')) throw new Error('provider failure should be reported without hiding results');
if (providerCalls.length === 0 || providerCalls[0].args.query !== 'target') {
throw new Error(`provider was not called with search query: ${JSON.stringify(providerCalls)}`);
}
```
Then add this event refresh check before the `image` search:
```js
caseText = '# Case\nFresh event phrase is here.\n';
(eventHandlers['file.changed'] || []).forEach((handler) => handler({
name: 'file.changed',
payload: { path: 'Project/Docs/case.md', operation: 'update' },
timestamp: new Date().toISOString(),
}));
await flush();
input = queryInput();
input.value = 'fresh';
input.dispatchEvent('input');
await wait(360);
if (!container.textContent.includes('Fresh event phrase is here')) {
throw new Error('file.changed should refresh persisted search index content');
}
```
- [ ] **Step 2: Run RED**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-official-plugins
node scripts/smoke-search-plugin.js
```
Expected: fails because `verstak.search` does not call storage or provider APIs.
- [ ] **Step 3: Update the Search manifest**
Change `plugins/search/plugin.json`:
```json
"permissions": [
"files.read",
"workbench.open",
"storage.namespace",
"events.subscribe",
"commands.register",
"ui.register"
],
```
Add `commands` before `workspaceItems`:
```json
"commands": [
{
"id": "verstak.search.searchVaultText",
"title": "Search Vault Text",
"handler": "searchVaultText"
}
],
```
Change the local search provider handler:
```json
"handler": "verstak.search.searchVaultText"
```
- [ ] **Step 4: Add index helpers in Search frontend**
In `plugins/search/frontend/src/index.js`, add constants near existing limits:
```js
var INDEX_NAME = 'search-index';
var INDEX_VERSION = 1;
```
Add helper functions after `scanPath`:
```js
function entryToIndexEntry(entry, text) {
return {
path: entry.relativePath,
name: entry.name || entry.relativePath,
type: entry.type,
extension: extension(entry),
size: Number(entry.size || 0),
modifiedAt: String(entry.modifiedAt || ''),
text: String(text || '').replace(/\s+/g, ' ').slice(0, 4000)
};
}
function validIndex(index, rootPath) {
return index && index.version === INDEX_VERSION &&
cleanPath(index.workspaceRootPath) === cleanPath(rootPath) &&
Array.isArray(index.entries);
}
async function buildIndex(api, rootPath) {
var entries = await collectEntries(api, rootPath);
var indexed = [];
for (var i = 0; i < entries.length; i++) {
var entry = entries[i];
var text = '';
if (isTextFile(entry)) {
try {
text = await api.files.readText(entry.relativePath);
} catch (err) {
text = '';
}
}
indexed.push(entryToIndexEntry(entry, text));
}
return {
version: INDEX_VERSION,
workspaceRootPath: cleanPath(rootPath),
builtAt: new Date().toISOString(),
entries: indexed
};
}
function searchIndex(index, query) {
var results = [];
var needle = String(query || '').toLowerCase();
if (!validIndex(index, index && index.workspaceRootPath) || needle.length < 2) return results;
for (var i = 0; i < index.entries.length && results.length < MAX_RESULTS; i++) {
var entry = index.entries[i];
var path = String(entry.path || '');
var name = String(entry.name || '');
var text = String(entry.text || '');
var pathHit = path.toLowerCase().indexOf(needle) !== -1 || name.toLowerCase().indexOf(needle) !== -1;
var textIndex = text.toLowerCase().indexOf(needle);
if (pathHit) {
results.push({
path: path,
type: entry.type,
matchType: entry.type === 'folder' ? 'Folder name' : 'File name',
openable: entry.type !== 'folder',
line: 0,
snippet: entry.type === 'folder' ? 'Folder name/path match' : 'File name/path match',
providerLabel: 'Vault Text Search',
score: 100
});
} else if (textIndex !== -1) {
results.push({
path: path,
type: entry.type,
matchType: 'Content match',
openable: true,
line: 0,
snippet: snippet(text, textIndex, needle.length),
providerLabel: 'Vault Text Search',
score: 90
});
}
}
return results;
}
```
- [ ] **Step 5: Add provider fan-out helper**
Add this helper after `searchIndex`:
```js
async function runExternalProviders(api, rootPath, query) {
if (!api.contributions || typeof api.contributions.list !== 'function' ||
!api.commands || typeof api.commands.executeFor !== 'function') {
return { results: [], failed: 0 };
}
var providers = await api.contributions.list('searchProviders');
providers = Array.isArray(providers) ? providers : [];
var results = [];
var failed = 0;
for (var i = 0; i < providers.length; i++) {
var provider = providers[i];
if (!provider || !provider.pluginId || !provider.handler) continue;
if (provider.pluginId === 'verstak.search' && provider.handler === 'verstak.search.searchVaultText') continue;
try {
var response = await api.commands.executeFor(provider.pluginId, provider.handler, {
source: 'search',
providerId: provider.id,
query: query,
workspaceRootPath: cleanPath(rootPath),
limit: MAX_RESULTS
});
var payload = response && response.result;
var providerResults = payload && Array.isArray(payload.results) ? payload.results : [];
providerResults.forEach(function (result) {
results.push(Object.assign({}, result, {
providerId: result.providerId || provider.id,
providerLabel: result.providerLabel || provider.label || provider.id
}));
});
} catch (err) {
failed += 1;
}
}
return { results: results, failed: failed };
}
```
- [ ] **Step 6: Wire index lifecycle in SearchView**
Inside `SearchView.mount`, extend state:
```js
var state = { query: '', searching: false, results: [], status: 'Enter at least 2 characters.', error: '', providerFailures: 0 };
var index = null;
var cleanupFns = [];
```
Add these functions inside `mount` before `render`:
```js
async function loadOrBuildIndex() {
if (api.storage && api.storage.data && typeof api.storage.data.read === 'function') {
try {
var stored = await api.storage.data.read(INDEX_NAME);
if (validIndex(stored, rootPath)) {
index = stored;
return;
}
} catch (err) {
state.status = 'Search index unavailable; using live scan.';
}
}
index = await buildIndex(api, rootPath);
if (api.storage && api.storage.data && typeof api.storage.data.write === 'function') {
try {
await api.storage.data.write(INDEX_NAME, index);
} catch (err) {
state.status = 'Search index could not be saved.';
}
}
}
async function refreshIndexPath(path) {
var clean = cleanPath(path);
if (!clean || (rootPath && clean !== rootPath && clean.indexOf(rootPath + '/') !== 0)) return;
index = await buildIndex(api, rootPath);
if (api.storage && api.storage.data && typeof api.storage.data.write === 'function') {
await api.storage.data.write(INDEX_NAME, index);
}
}
function subscribeIndexEvents() {
if (!api.events || typeof api.events.subscribe !== 'function') return;
api.events.subscribe('file.changed', function (event) {
var payload = (event && event.payload) || {};
refreshIndexPath(payload.path || payload.toPath || '').catch(function (err) {
console.error('[search] index refresh:', err);
});
}).then(function (unsubscribe) {
if (typeof unsubscribe === 'function') cleanupFns.push(unsubscribe);
}).catch(function (err) {
console.error('[search] file.changed subscription:', err);
});
}
```
At the end of `mount`, before `render();`, add:
```js
if (api.commands && typeof api.commands.register === 'function') {
api.commands.register('verstak.search.searchVaultText', function (args) {
var q = String((args && args.query) || '');
return { results: searchIndex(index, q) };
}).then(function (unsubscribe) {
if (typeof unsubscribe === 'function') cleanupFns.push(unsubscribe);
}).catch(function (err) {
console.error('[search] command register:', err);
});
}
loadOrBuildIndex().then(function () {
subscribeIndexEvents();
}).catch(function (err) {
console.error('[search] initial index:', err);
});
```
In cleanup, add:
```js
while (cleanupFns.length) {
try { cleanupFns.pop()(); } catch (err) { console.error('[search] cleanup:', err); }
}
```
- [ ] **Step 7: Replace search execution with index plus provider results**
In `search()`, replace:
```js
var results = await runSearch(api, rootPath, state.query);
```
with:
```js
if (!index) await loadOrBuildIndex();
var localResults = searchIndex(index, state.query);
var providerResponse = await runExternalProviders(api, rootPath, state.query);
var results = localResults.concat(providerResponse.results)
.sort(function (a, b) {
return Number(b.score || 0) - Number(a.score || 0) ||
String(a.path || a.title || '').localeCompare(String(b.path || b.title || ''));
})
.slice(0, MAX_RESULTS);
state.providerFailures = providerResponse.failed;
```
Change status assignment to:
```js
state.status = state.results.length + ' result' + (state.results.length === 1 ? '' : 's') +
(state.providerFailures ? ' - ' + state.providerFailures + ' provider unavailable' + (state.providerFailures === 1 ? '' : 's') : '');
```
In `render`, change result title rendering to support provider results:
```js
el('div', { className: 'search-path' }, [result.path || result.title || 'Untitled result']),
el('div', { className: 'search-snippet' }, [result.snippet || '']),
el('div', { className: 'search-meta' }, [
(result.providerLabel ? result.providerLabel + ' - ' : '') +
(result.matchType || result.type || 'Search result') +
(result.line ? ' - Line ' + result.line : '')
])
```
Change openResource payload to use `result.resource` when present:
```js
var resource = result.resource || {
kind: 'vault-file',
path: result.path,
mode: 'view'
};
api.workbench.openResource(resource).catch(function (err) { console.error('[search] openResource:', err); });
```
- [ ] **Step 8: Run GREEN for focused Search smoke**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-official-plugins
node scripts/smoke-search-plugin.js
```
Expected: `search plugin smoke passed`.
- [ ] **Step 9: Run official plugin verification**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-official-plugins
./scripts/check.sh
```
Expected: check exits 0 and search frontend smoke passes.
- [ ] **Step 10: Commit and push official plugins**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-official-plugins
git status --short
git add plugins/search/plugin.json plugins/search/frontend/src/index.js scripts/smoke-search-plugin.js
git commit -m "feat: persist search index and host providers"
git push
```
Expected: commit is created and pushed; `git status --short --branch` reports `## main...origin/main`.
---
### Task 4: Documentation And Roadmap Update
**Files:**
- Modify: `/home/mirivlad/git/verstak2/verstak-docs/05_Official_Plugins.md`
- Modify: `/home/mirivlad/git/verstak2/verstak-docs/07_Full_Implementation_Roadmap.md`
**Interfaces:**
- Consumes:
- Verified commits from SDK, desktop, and official plugins.
- Produces:
- Docs that match implemented Search behavior.
- [ ] **Step 1: Update official plugin status text**
In `05_Official_Plugins.md`, replace the final Search status sentence:
```md
Persistent indexing and cross-provider runtime hosting are still future work.
```
with:
```md
It persists a workspace-scoped JSON search index in the plugin data namespace,
refreshes it from public file events, registers its own vault-text provider as
a command-backed `searchProviders` handler, and fans out to other registered
provider commands at runtime. Full-text ranking, typo/layout tolerant search,
and sidecar indexing remain later work.
```
- [ ] **Step 2: Update roadmap checkbox**
In `07_Full_Implementation_Roadmap.md`, change:
```md
- implement persistent search index and cross-provider runtime hosting;
```
to:
```md
- [x] implement persistent search index and cross-provider runtime hosting;
```
- [ ] **Step 3: Verify docs diff**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-docs
git diff --check
rg -n "Persistent indexing and cross-provider runtime hosting are still future work|implement persistent search index and cross-provider runtime hosting" 05_Official_Plugins.md 07_Full_Implementation_Roadmap.md
```
Expected: `git diff --check` exits 0. The `rg` command shows the checked roadmap line and does not show the old future-work sentence.
- [ ] **Step 4: Commit and push docs**
Run:
```bash
cd /home/mirivlad/git/verstak2/verstak-docs
git status --short
git add 05_Official_Plugins.md 07_Full_Implementation_Roadmap.md
git commit -m "docs: mark persistent search hosting complete"
git push
```
Expected: commit is created and pushed; `git status --short --branch` reports `## main...origin/main`.
---
## Final Verification Checklist
Run after all tasks are implemented:
```bash
cd /home/mirivlad/git/verstak2/verstak-sdk
npm test
npm run build
```
```bash
cd /home/mirivlad/git/verstak2/verstak-desktop
node frontend/tests/plugin-api-contributions-test.mjs
cd frontend
npm run build
```
```bash
cd /home/mirivlad/git/verstak2/verstak-official-plugins
node scripts/smoke-search-plugin.js
./scripts/check.sh
```
```bash
cd /home/mirivlad/git/verstak2/verstak-docs
git diff --check
```
Then inspect:
```bash
cd /home/mirivlad/git/verstak2
git -C verstak-sdk status --short --branch
git -C verstak-desktop status --short --branch
git -C verstak-official-plugins status --short --branch
git -C verstak-docs status --short --branch
```
Expected: each changed repository reports `## main...origin/main`.