Feat v0.7.5 e2e ci gate (#59)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m50s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 35s
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m50s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 35s
This commit was merged in pull request #59.
This commit is contained in:
687
docs/DESIGN-extension-composability.md
Normal file
687
docs/DESIGN-extension-composability.md
Normal file
@@ -0,0 +1,687 @@
|
||||
# DESIGN — Extension Composability
|
||||
|
||||
**Version:** v0.8.4
|
||||
**Status:** Draft
|
||||
**Author:** Jeff / Claude session 2026-04-02
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Extensions cannot meaningfully compose with each other. A Notes surface
|
||||
can't accept toolbar buttons from an STT extension. An LLM bridge can't
|
||||
invoke an image generator's functions. A chat surface can't display action
|
||||
buttons contributed by an image-editing extension.
|
||||
|
||||
The runtime primitives for contribution exist — `sw.slots`, `sw.actions`,
|
||||
and `sw.renderers` are live in the SDK. But there is no manifest layer
|
||||
to declare the relationships, no backend mechanism for non-library packages
|
||||
to call each other's exported functions, and no conventions for slot
|
||||
naming or context contracts.
|
||||
|
||||
This blocks the entire "extensions extending extensions" pattern that
|
||||
makes the platform an ecosystem rather than a collection of packages.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Arbitrary inter-extension communication (message passing, shared memory).
|
||||
Extensions compose through declared slots, exported functions, and events.
|
||||
- Runtime dependency injection. Dependencies are declared in manifests and
|
||||
resolved at load time.
|
||||
- Extension sandboxing on the frontend. Browser-tier JS runs in the same
|
||||
page context. Isolation is by convention and code review, not enforcement.
|
||||
|
||||
---
|
||||
|
||||
## What Already Exists
|
||||
|
||||
Three SDK registries are live and functional:
|
||||
|
||||
**`sw.slots`** — Named UI injection points. `register(name, {id, component, priority})`
|
||||
adds a Preact component to a named slot. `get(name)` returns sorted entries.
|
||||
Emits `slots.changed` events. Dedup by id. Priority ordering.
|
||||
|
||||
**`sw.actions`** — Named callable actions. `register(id, {handler, label, icon})`
|
||||
exposes a function by name. `run(id, ...args)` invokes it. Cross-extension
|
||||
function calls on the frontend.
|
||||
|
||||
**`sw.renderers`** — Block and post renderers for markdown content.
|
||||
`register(name, {type, pattern, render})`. Already used by mermaid, KaTeX,
|
||||
CSV, and diff extensions.
|
||||
|
||||
**`lib.require()`** — Backend cross-package function calls. Starlark
|
||||
packages call exported functions from library packages with the library's
|
||||
own permission context.
|
||||
|
||||
The gap: `sw.slots` has no manifest awareness — the admin can't see which
|
||||
extensions contribute where, install/uninstall can't warn about orphaned
|
||||
contributions. `lib.require()` is restricted to `type: "library"` packages,
|
||||
so a full package like an image generator can't export callable functions.
|
||||
There are no conventions for slot naming or context contracts.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Three additions: manifest declarations, backend relaxation, and SDK helpers.
|
||||
|
||||
### 1. Manifest Declarations
|
||||
|
||||
#### Host Surfaces: `slots` Field
|
||||
|
||||
Surfaces declare named injection points with context descriptions:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "notes",
|
||||
"slots": {
|
||||
"toolbar-actions": {
|
||||
"description": "Toolbar action buttons",
|
||||
"context": {
|
||||
"noteId": "string — current note ID",
|
||||
"getContent": "function — returns note body text",
|
||||
"setContent": "function — replaces note body text"
|
||||
}
|
||||
},
|
||||
"note-footer": {
|
||||
"description": "Content rendered below note body",
|
||||
"context": {
|
||||
"noteId": "string",
|
||||
"content": "string — rendered HTML content"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chat",
|
||||
"slots": {
|
||||
"message-actions": {
|
||||
"description": "Action buttons on individual messages",
|
||||
"context": {
|
||||
"messageId": "string",
|
||||
"content": "string — message text",
|
||||
"attachments": "array — [{url, type, name}]"
|
||||
}
|
||||
},
|
||||
"image-actions": {
|
||||
"description": "Action buttons overlaid on rendered images",
|
||||
"context": {
|
||||
"imageUrl": "string",
|
||||
"messageId": "string",
|
||||
"metadata": "object — generation params if available"
|
||||
}
|
||||
},
|
||||
"composer-tools": {
|
||||
"description": "Tool buttons in the message composer area",
|
||||
"context": {
|
||||
"conversationId": "string",
|
||||
"insertText": "function — appends to composer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `context` field is documentation, not enforcement. It tells extension
|
||||
authors what data the slot provides. The kernel parses and stores slot
|
||||
declarations but does not validate context shapes at runtime.
|
||||
|
||||
#### Contributing Extensions: `contributes` Field
|
||||
|
||||
Extensions declare which slots they inject into:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "note-dictate",
|
||||
"title": "Note Dictation",
|
||||
"type": "extension",
|
||||
"tier": "browser",
|
||||
"contributes": {
|
||||
"notes:toolbar-actions": {
|
||||
"label": "Dictate",
|
||||
"icon": "🎤",
|
||||
"description": "Voice-to-text note dictation"
|
||||
}
|
||||
},
|
||||
"permissions": ["api.http"]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "image-gen",
|
||||
"title": "Image Generator",
|
||||
"type": "full",
|
||||
"tier": "starlark",
|
||||
"contributes": {
|
||||
"chat:composer-tools": {
|
||||
"label": "Generate Image",
|
||||
"icon": "🎨",
|
||||
"description": "AI image generation from text prompt"
|
||||
}
|
||||
},
|
||||
"exports": ["generate", "list_models"],
|
||||
"permissions": ["api.http", "connections.read", "files.write", "db.write"]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "image-edit",
|
||||
"title": "Image Editor",
|
||||
"type": "extension",
|
||||
"tier": "starlark",
|
||||
"contributes": {
|
||||
"chat:image-actions": {
|
||||
"label": "Edit Image",
|
||||
"icon": "✏️",
|
||||
"description": "Inpaint, outpaint, upscale, style transfer"
|
||||
}
|
||||
},
|
||||
"exports": ["inpaint", "outpaint", "upscale", "restyle"],
|
||||
"permissions": ["api.http", "connections.read", "files.write"]
|
||||
}
|
||||
```
|
||||
|
||||
**Slot naming convention:** `{host-package-id}:{slot-name}`. The colon
|
||||
separates namespace from slot. Extensions contributing to `notes:toolbar-actions`
|
||||
are declaring a relationship with the `notes` package.
|
||||
|
||||
#### What the Kernel Does with Declarations
|
||||
|
||||
**At install time:**
|
||||
- Parse `slots` field → store in manifest (no separate table needed).
|
||||
- Parse `contributes` field → validate that each target slot name follows
|
||||
the `{pkg}:{slot}` convention. Do NOT validate that the host package
|
||||
exists — contributing extensions can be installed before or after their
|
||||
host. Soft coupling.
|
||||
- Parse `exports` field → already stored in manifest. No changes needed.
|
||||
|
||||
**At admin display time:**
|
||||
- `GET /api/v1/admin/packages/:id` includes `slots` and `contributes`
|
||||
from manifest. Admin UI shows "This package provides X slots" and
|
||||
"This package contributes to Y slots."
|
||||
- New endpoint: `GET /api/v1/admin/slots` → returns a map of all declared
|
||||
slots across installed packages with their contributors. Built by
|
||||
scanning all package manifests — no new table.
|
||||
|
||||
**At uninstall time:**
|
||||
- If uninstalling a package that declares `slots`, check if any enabled
|
||||
packages declare `contributes` targeting those slots. Warn (not block):
|
||||
"Uninstalling 'notes' will orphan contributions from: note-dictate,
|
||||
note-ai." The admin decides.
|
||||
- If uninstalling a contributing package, no warning needed — the slot
|
||||
just has fewer entries.
|
||||
|
||||
---
|
||||
|
||||
### 2. Backend: `lib.require()` Relaxation
|
||||
|
||||
Currently `lib.require()` enforces `libPkg.Type == "library"`. This
|
||||
prevents full packages (which have surfaces, settings, UI) from also
|
||||
exporting callable functions.
|
||||
|
||||
**Change:** Replace the type check with an exports check.
|
||||
|
||||
In `sandbox/lib_module.go`, the current guard:
|
||||
|
||||
```go
|
||||
if libPkg.Type != "library" {
|
||||
return nil, fmt.Errorf("lib.require: package %q is type %q, not library", libraryID, libPkg.Type)
|
||||
}
|
||||
```
|
||||
|
||||
Becomes:
|
||||
|
||||
```go
|
||||
exports := extractExports(libPkg.Manifest)
|
||||
if len(exports) == 0 {
|
||||
return nil, fmt.Errorf("lib.require: package %q declares no exports", libraryID)
|
||||
}
|
||||
```
|
||||
|
||||
Any package that declares `"exports": [...]` is callable via `lib.require()`,
|
||||
regardless of type. The dependency check (`ListByConsumer`) remains
|
||||
enforced — the consumer must still declare `"depends": ["image-gen"]`
|
||||
in its manifest.
|
||||
|
||||
The `depends` field already accepts any package ID, not just libraries.
|
||||
The `DependencyStore` has no type check. This change is one line in
|
||||
`lib_module.go`.
|
||||
|
||||
**Security implication:** A full package's exported functions run with
|
||||
that package's own permissions, same as libraries today. An image-gen
|
||||
package with `api.http` + `files.write` permissions exposes `generate()`
|
||||
— when llm-bridge calls it, it runs with image-gen's permissions, not
|
||||
llm-bridge's. This is correct and already how `lib.require()` works.
|
||||
|
||||
---
|
||||
|
||||
### 3. SDK Additions
|
||||
|
||||
#### `sw.slots.renderAll(name, context)` Helper
|
||||
|
||||
Currently host surfaces must manually iterate slot entries:
|
||||
|
||||
```javascript
|
||||
const entries = sw.slots.get('notes:toolbar-actions');
|
||||
return entries.map(e => html`<${e.component} ...${ctx} />`);
|
||||
```
|
||||
|
||||
Add a convenience helper:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Render all components registered in a slot.
|
||||
* @param {string} name — slot name
|
||||
* @param {object} context — props passed to each component
|
||||
* @returns {Array<VNode>} — array of rendered vnodes
|
||||
*/
|
||||
renderAll(name, context = {}) {
|
||||
return this.get(name).map(e => {
|
||||
try {
|
||||
return e.component(context);
|
||||
} catch (err) {
|
||||
console.error(`[sw.slots] Error rendering "${e.id}" in slot "${name}":`, err);
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
}
|
||||
```
|
||||
|
||||
Host surface usage becomes:
|
||||
|
||||
```javascript
|
||||
// In Notes toolbar:
|
||||
html`<div class="notes-toolbar">
|
||||
<button onclick=${save}>Save</button>
|
||||
${sw.slots.renderAll('notes:toolbar-actions', {
|
||||
noteId: currentNote.id,
|
||||
getContent: () => editor.getValue(),
|
||||
setContent: (text) => editor.setValue(text)
|
||||
})}
|
||||
</div>`
|
||||
```
|
||||
|
||||
#### `sw.slots.declare(name, description)` (Optional)
|
||||
|
||||
Runtime declaration for slots that don't appear in the manifest (e.g.,
|
||||
dynamically created slots). Mostly for discoverability — the debug panel
|
||||
can list all active slots whether manifest-declared or runtime-declared.
|
||||
|
||||
```javascript
|
||||
// In chat surface, when rendering an image:
|
||||
sw.slots.declare('chat:image-actions', 'Action buttons on images');
|
||||
```
|
||||
|
||||
Not enforced — `sw.slots.register()` works on any name regardless.
|
||||
This is a documentation/debugging aid only.
|
||||
|
||||
---
|
||||
|
||||
## Composition Patterns
|
||||
|
||||
### Pattern A: LLM Tool Registration
|
||||
|
||||
LLM bridge exposes a tool registry. Tool extensions register at load time
|
||||
via `lib.require()` on the backend and `sw.actions` on the frontend.
|
||||
|
||||
**llm-bridge (library) — script.star:**
|
||||
```python
|
||||
_tools = {}
|
||||
|
||||
def register_tool(name, description, parameters, handler):
|
||||
"""Register a callable tool for LLM function calling."""
|
||||
_tools[name] = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"parameters": parameters,
|
||||
"handler": handler,
|
||||
}
|
||||
|
||||
def complete(messages, tools=None):
|
||||
"""Send messages to LLM with registered tools available."""
|
||||
tool_schemas = []
|
||||
for t in _tools.values():
|
||||
tool_schemas.append({
|
||||
"name": t["name"],
|
||||
"description": t["description"],
|
||||
"parameters": t["parameters"],
|
||||
})
|
||||
# ... call LLM API with tool_schemas, handle tool_use responses
|
||||
# by dispatching to _tools[name]["handler"]
|
||||
```
|
||||
|
||||
**image-gen (full package) — script.star:**
|
||||
```python
|
||||
llm = lib.require("llm-bridge")
|
||||
|
||||
def generate(prompt, model="dall-e-3", size="1024x1024", seed=None):
|
||||
conn = connections.get("openai")
|
||||
# ... call API, store result in files module ...
|
||||
return {"image_url": url, "prompt": prompt, "seed": actual_seed}
|
||||
|
||||
# Register as LLM tool at load time
|
||||
llm.register_tool(
|
||||
name="generate_image",
|
||||
description="Generate an image from a text description",
|
||||
parameters={"prompt": "string", "size": "string"},
|
||||
handler=generate,
|
||||
)
|
||||
```
|
||||
|
||||
### Pattern B: UI Contribution (Toolbar Buttons)
|
||||
|
||||
**note-dictate (extension) — js/index.js:**
|
||||
```javascript
|
||||
sw.slots.register('notes:toolbar-actions', {
|
||||
id: 'note-dictate',
|
||||
priority: 200,
|
||||
component: ({ noteId, setContent, getContent }) => {
|
||||
const [recording, setRecording] = preact.useState(false);
|
||||
|
||||
const toggle = async () => {
|
||||
if (recording) {
|
||||
// Stop recording, send audio to STT api_route
|
||||
const text = await sw.api.ext('note-dictate').post('/transcribe', {
|
||||
audio: audioBlob
|
||||
});
|
||||
setContent(getContent() + '\n' + text.transcription);
|
||||
setRecording(false);
|
||||
} else {
|
||||
// Start recording
|
||||
setRecording(true);
|
||||
}
|
||||
};
|
||||
|
||||
return html`<button class="sw-btn sw-btn--ghost"
|
||||
onclick=${toggle}
|
||||
title="Dictate">
|
||||
${recording ? '⏹' : '🎤'}
|
||||
</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**notes (surface) — toolbar rendering:**
|
||||
```javascript
|
||||
html`<div class="notes-toolbar__actions">
|
||||
<button onclick=${() => saveNote()}>💾</button>
|
||||
<button onclick=${() => togglePin()}>📌</button>
|
||||
${sw.slots.renderAll('notes:toolbar-actions', {
|
||||
noteId: note.id,
|
||||
getContent: () => editor.getValue(),
|
||||
setContent: (text) => editor.setValue(text),
|
||||
})}
|
||||
</div>`
|
||||
```
|
||||
|
||||
Notes doesn't know dictation exists. Dictation doesn't know Notes'
|
||||
internals — just the slot contract (noteId, getContent, setContent).
|
||||
|
||||
### Pattern C: Image Actions (Generate + Edit + Upscale)
|
||||
|
||||
Three independent extensions contribute to the same image display slot:
|
||||
|
||||
**chat surface — image rendering:**
|
||||
```javascript
|
||||
function ChatImage({ src, messageId, metadata }) {
|
||||
return html`<div class="chat-image">
|
||||
<img src=${src} />
|
||||
<div class="chat-image__actions">
|
||||
${sw.slots.renderAll('chat:image-actions', {
|
||||
imageUrl: src,
|
||||
messageId,
|
||||
metadata,
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
**image-gen — contributes regen button:**
|
||||
```javascript
|
||||
sw.slots.register('chat:image-actions', {
|
||||
id: 'image-gen:regenerate',
|
||||
priority: 100,
|
||||
component: ({ imageUrl, metadata }) => {
|
||||
const regen = async () => {
|
||||
// Open prompt editor with original params
|
||||
const params = await sw.prompt('Edit prompt', {
|
||||
default: metadata?.prompt || '',
|
||||
multiline: true,
|
||||
});
|
||||
if (!params) return;
|
||||
const result = await sw.api.ext('image-gen').post('/generate', {
|
||||
prompt: params,
|
||||
seed: metadata?.seed,
|
||||
negative_prompt: metadata?.negative_prompt,
|
||||
});
|
||||
// result triggers a new chat message with the generated image
|
||||
};
|
||||
|
||||
return html`<button class="sw-btn sw-btn--sm" onclick=${regen} title="Regenerate">
|
||||
🔄
|
||||
</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**image-edit — contributes edit drawer:**
|
||||
```javascript
|
||||
sw.slots.register('chat:image-actions', {
|
||||
id: 'image-edit:edit',
|
||||
priority: 200,
|
||||
component: ({ imageUrl, metadata }) => {
|
||||
const openEditor = () => {
|
||||
// Open a drawer with editing options
|
||||
sw.emit('drawer.open', {
|
||||
title: 'Edit Image',
|
||||
component: ImageEditPanel,
|
||||
props: { imageUrl, metadata },
|
||||
});
|
||||
};
|
||||
|
||||
return html`<button class="sw-btn sw-btn--sm" onclick=${openEditor} title="Edit">
|
||||
✏️
|
||||
</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**image-upscale — contributes upscale button:**
|
||||
```javascript
|
||||
sw.slots.register('chat:image-actions', {
|
||||
id: 'image-upscale:upscale',
|
||||
priority: 300,
|
||||
component: ({ imageUrl }) => {
|
||||
const upscale = async () => {
|
||||
const result = await sw.api.ext('image-upscale').post('/upscale', {
|
||||
image_url: imageUrl,
|
||||
scale: 2,
|
||||
});
|
||||
// Display or replace with upscaled image
|
||||
};
|
||||
|
||||
return html`<button class="sw-btn sw-btn--sm" onclick=${upscale} title="Upscale 2×">
|
||||
🔍
|
||||
</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Result: an image in chat gets three action buttons from three separate
|
||||
extensions. Install one, get one button. Install all three, get all three.
|
||||
Uninstall one, the others keep working. The chat surface has no knowledge
|
||||
of any of them.
|
||||
|
||||
### Pattern D: LLM Note Restructuring
|
||||
|
||||
Combines backend composability (lib.require) with frontend contribution
|
||||
(slots):
|
||||
|
||||
**note-ai (extension) — manifest.json:**
|
||||
```json
|
||||
{
|
||||
"id": "note-ai",
|
||||
"type": "extension",
|
||||
"tier": "starlark",
|
||||
"depends": ["llm-bridge"],
|
||||
"contributes": {
|
||||
"notes:toolbar-actions": {
|
||||
"label": "AI Restructure",
|
||||
"icon": "✨"
|
||||
}
|
||||
},
|
||||
"permissions": ["api.http"],
|
||||
"settings": {
|
||||
"restructure_prompt": {
|
||||
"type": "string",
|
||||
"label": "Restructure Prompt",
|
||||
"description": "System prompt for AI note restructuring",
|
||||
"default": "Restructure the following note into clear sections with headers. Preserve all information. Use markdown formatting.",
|
||||
"user_overridable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**note-ai — script.star (api_route handler):**
|
||||
```python
|
||||
llm = lib.require("llm-bridge")
|
||||
|
||||
def on_request(req):
|
||||
if req["method"] == "POST" and req["path"] == "/restructure":
|
||||
content = req["body"]["content"]
|
||||
prompt = settings.get("restructure_prompt")
|
||||
result = llm.complete([
|
||||
{"role": "system", "content": prompt},
|
||||
{"role": "user", "content": content},
|
||||
])
|
||||
return {"status": 200, "body": {"restructured": result["text"]}}
|
||||
```
|
||||
|
||||
**note-ai — js/index.js:**
|
||||
```javascript
|
||||
sw.slots.register('notes:toolbar-actions', {
|
||||
id: 'note-ai:restructure',
|
||||
priority: 500,
|
||||
component: ({ noteId, getContent, setContent }) => {
|
||||
const [loading, setLoading] = preact.useState(false);
|
||||
|
||||
const restructure = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await sw.api.ext('note-ai').post('/restructure', {
|
||||
content: getContent()
|
||||
});
|
||||
setContent(result.restructured);
|
||||
sw.toast('Note restructured', 'success');
|
||||
} catch (e) {
|
||||
sw.toast('Restructure failed: ' + e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return html`<button class="sw-btn sw-btn--ghost"
|
||||
onclick=${restructure}
|
||||
disabled=${loading}
|
||||
title="AI Restructure">
|
||||
${loading ? '⏳' : '✨'}
|
||||
</button>`;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The user configures their restructuring prompt in Settings. The button
|
||||
appears in Notes toolbar. Clicking it sends the note content to the
|
||||
api_route, which calls llm-bridge, which calls the configured LLM provider.
|
||||
Four packages involved (notes, note-ai, llm-bridge, the LLM connection),
|
||||
zero hardcoded dependencies between surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Kernel Changes
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `sandbox/lib_module.go` | Replace type check with exports check (~1 line) |
|
||||
| `handlers/extensions.go` | Parse `contributes` and `slots` manifest fields (validation) |
|
||||
| `handlers/packages.go` | Uninstall warning for orphaned contributions |
|
||||
| `src/js/sw/sdk/slots.js` | Add `renderAll(name, context)` and `declare(name, desc)` |
|
||||
| `docs/PACKAGE-FORMAT.md` | Document `slots`, `contributes`, `exports` fields |
|
||||
| `docs/EXTENSION-GUIDE.md` | Composability patterns section |
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `handlers/admin_slots.go` | `GET /admin/slots` aggregation endpoint |
|
||||
|
||||
### No New:
|
||||
|
||||
- Database tables
|
||||
- Migrations
|
||||
- Starlark modules
|
||||
- Permission constants
|
||||
- Config env vars
|
||||
|
||||
This is deliberately small. The runtime infrastructure exists. The
|
||||
design adds manifest-level visibility, one backend guard relaxation,
|
||||
and one SDK helper. The composability comes from conventions and
|
||||
documentation, not kernel complexity.
|
||||
|
||||
---
|
||||
|
||||
## Slot Catalog (Initial)
|
||||
|
||||
These are the recommended slots for first-party packages. Extension
|
||||
authors may define additional slots following the naming convention.
|
||||
|
||||
| Slot | Host | Context | Use Case |
|
||||
|------|------|---------|----------|
|
||||
| `notes:toolbar-actions` | notes | noteId, getContent, setContent | Dictation, AI tools, formatting |
|
||||
| `notes:note-footer` | notes | noteId, content | Related items, AI summary, metadata |
|
||||
| `chat:composer-tools` | chat | conversationId, insertText | Image gen, file attach, slash commands |
|
||||
| `chat:message-actions` | chat | messageId, content, attachments | Reactions, translate, bookmark |
|
||||
| `chat:image-actions` | chat | imageUrl, messageId, metadata | Regen, edit, upscale, style transfer |
|
||||
| `schedules:event-actions` | schedules | eventId, event | Add to calendar, share, convert to task |
|
||||
| `admin:package-actions` | admin | packageId, package | Custom admin tools per package |
|
||||
|
||||
Each host surface adds `sw.slots.renderAll()` calls at the appropriate
|
||||
locations. Extensions contribute via `sw.slots.register()` in their JS.
|
||||
The manifest `contributes` field makes the relationship visible to admins.
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Slot schema validation.** Currently context contracts are documentation
|
||||
only. A runtime assertion mode (dev only) could warn when a contributor
|
||||
receives unexpected props. Deferred — convention is sufficient pre-1.0.
|
||||
|
||||
- **Slot visibility controls.** An admin might want to disable a specific
|
||||
contribution without disabling the entire contributing extension. A
|
||||
per-contribution enable/disable toggle in the admin UI. Deferred —
|
||||
extension enable/disable is the current granularity.
|
||||
|
||||
- **Cross-surface slot contributions.** An extension contributing to
|
||||
`notes:toolbar-actions` AND `chat:message-actions` with the same
|
||||
underlying logic but different UI. The `contributes` field already
|
||||
supports multiple entries. The extension's JS registers into both
|
||||
slots with slot-appropriate components.
|
||||
|
||||
- **Backend event subscriptions between extensions.** Currently extensions
|
||||
react to kernel events via `hooks`. Extension-to-extension events
|
||||
(e.g., "image-gen completed" → "chat refreshes") flow through the
|
||||
existing event bus — the generating extension's api_route publishes
|
||||
via `realtime.publish()`, the consuming surface listens via
|
||||
`sw.realtime.subscribe()`. No new primitive needed.
|
||||
668
docs/DESIGN-storage-primitives.md
Normal file
668
docs/DESIGN-storage-primitives.md
Normal file
@@ -0,0 +1,668 @@
|
||||
# DESIGN — Storage Primitives
|
||||
|
||||
**Version:** v0.8.0
|
||||
**Status:** Draft
|
||||
**Author:** Jeff / Claude session 2026-04-02
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Extensions cannot access blob storage or managed disk paths. The existing
|
||||
`ObjectStore` interface (PVC + S3 backends) is fully implemented but only
|
||||
exposed to the admin status endpoint — no Starlark bridge exists. The `db`
|
||||
module provides structured data storage via extension-scoped tables, but
|
||||
there is no equivalent for binary files, no managed filesystem for tools
|
||||
like `git`, and no mechanism for extensions to declare environment
|
||||
requirements (pgvector, workspace root, S3) that the kernel validates at
|
||||
install time.
|
||||
|
||||
This blocks every future capability that depends on file handling:
|
||||
RAG/vector search, LLM bridge, code workspaces, file upload/sharing,
|
||||
media processing, and document indexing.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replacing the `db` module. Extension-scoped tables (`ext_{pkg}_{table}`)
|
||||
are the structured data primitive and remain unchanged.
|
||||
- Building a KV store. Extensions that need key-value semantics declare a
|
||||
table with `key TEXT, value TEXT` columns — the infrastructure exists.
|
||||
- Multi-tenant file isolation beyond package scoping. Team/user-level
|
||||
file ACLs are extension-layer concerns built on top of these primitives.
|
||||
- Streaming / chunked upload through Starlark. Large file ingestion goes
|
||||
through HTTP routes; the `files` module handles storage after receipt.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Three new primitives plus one extension to the existing `db` module.
|
||||
|
||||
### Primitive 1: `files` Starlark Module
|
||||
|
||||
Bridges the existing `ObjectStore` into the sandbox. Follows the same
|
||||
pattern as `db_module.go`: a `Build*Module` factory, permission-gated,
|
||||
package-scoped key namespacing.
|
||||
|
||||
**Permissions:** `files.read`, `files.write`
|
||||
|
||||
**Starlark API:**
|
||||
|
||||
```python
|
||||
# Write a file. content is string (UTF-8) or bytes.
|
||||
# metadata is an optional dict stored alongside (JSON-serialized).
|
||||
files.put(name, content, content_type="application/octet-stream", metadata={})
|
||||
|
||||
# Read a file. Returns dict: {"content": <bytes>, "content_type": "...", "size": N, "metadata": {...}}
|
||||
# Returns None if not found.
|
||||
result = files.get(name)
|
||||
|
||||
# Read metadata only (no content transfer). Returns dict or None.
|
||||
meta = files.meta(name)
|
||||
|
||||
# List files by prefix. Returns list of dicts: [{"name": "...", "size": N, "content_type": "..."}]
|
||||
entries = files.list(prefix="", limit=100)
|
||||
|
||||
# Delete a file. Idempotent — no error if missing.
|
||||
files.delete(name)
|
||||
|
||||
# Delete all files under a prefix. Use with caution.
|
||||
files.delete_prefix(prefix)
|
||||
|
||||
# Check existence without reading.
|
||||
exists = files.exists(name)
|
||||
```
|
||||
|
||||
**Key namespacing:**
|
||||
|
||||
All keys are automatically prefixed with `ext/{packageID}/`. An extension
|
||||
calling `files.put("models/v1.bin", data)` writes to the ObjectStore key
|
||||
`ext/my-extension/models/v1.bin`. Extensions cannot escape their namespace.
|
||||
|
||||
Name validation rejects `..`, absolute paths, and control characters —
|
||||
same sanitization rules as `physicalTable()` in `db_module.go`.
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- New file: `sandbox/files_module.go`
|
||||
- `FilesModuleConfig` struct mirrors `DBModuleConfig`:
|
||||
```go
|
||||
type FilesModuleConfig struct {
|
||||
PackageID string
|
||||
CanWrite bool
|
||||
Store storage.ObjectStore
|
||||
}
|
||||
```
|
||||
- Metadata is stored as a companion JSON object at key
|
||||
`ext/{packageID}/_meta/{name}`. This avoids schema changes — the
|
||||
ObjectStore interface is unchanged. The `files` module manages the
|
||||
companion transparently.
|
||||
- Size limit per `files.put()` call: 50MB (configurable via
|
||||
`EXT_FILES_MAX_SIZE`). Enforced in the builtin before calling
|
||||
`Store.Put()`.
|
||||
- `files.get()` returns content as `starlark.Bytes` for binary safety.
|
||||
Starlark's `Bytes` type was added in go.starlark.net v0.0.0-20240725214946
|
||||
and handles non-UTF-8 content correctly.
|
||||
- If `ObjectStore` is nil (storage not configured), the module is not
|
||||
injected — same pattern as `db` module when `r.db == nil`.
|
||||
|
||||
**Runner wiring:**
|
||||
|
||||
```go
|
||||
// In buildModulesWithLibCtx, add cases:
|
||||
case models.ExtPermFilesRead:
|
||||
if filesLevel < 1 { filesLevel = 1 }
|
||||
case models.ExtPermFilesWrite:
|
||||
filesLevel = 2
|
||||
|
||||
// After permission loop:
|
||||
if filesLevel > 0 && r.objectStore != nil {
|
||||
modules["files"] = BuildFilesModule(ctx, FilesModuleConfig{
|
||||
PackageID: packageID,
|
||||
CanWrite: filesLevel == 2,
|
||||
Store: r.objectStore,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Runner gains `SetObjectStore(s storage.ObjectStore)` setter, called from
|
||||
`main.go` after `storage.Init()`.
|
||||
|
||||
---
|
||||
|
||||
### Primitive 2: `workspace` Starlark Module
|
||||
|
||||
Managed disk directories for extensions that need a real filesystem —
|
||||
git repos, compilers, ffmpeg, pandoc, code analysis tools. These tools
|
||||
cannot operate through put/get blob semantics; they need paths.
|
||||
|
||||
**Permission:** `workspace.manage`
|
||||
|
||||
**Starlark API:**
|
||||
|
||||
```python
|
||||
# Create a named workspace directory. Returns the absolute path.
|
||||
# Idempotent — returns existing path if already created.
|
||||
path = workspace.create(name)
|
||||
|
||||
# Get the path for an existing workspace. Returns string or None.
|
||||
path = workspace.path(name)
|
||||
|
||||
# List workspace names for this extension.
|
||||
names = workspace.list()
|
||||
|
||||
# Delete a workspace and all its contents.
|
||||
workspace.delete(name)
|
||||
|
||||
# Disk usage in bytes for a workspace.
|
||||
size = workspace.usage(name)
|
||||
```
|
||||
|
||||
**Directory layout:**
|
||||
|
||||
```
|
||||
{WORKSPACE_ROOT}/
|
||||
{packageID}/
|
||||
{name}/
|
||||
... (extension-managed contents)
|
||||
```
|
||||
|
||||
`WORKSPACE_ROOT` defaults to `/data/workspaces` (configurable via
|
||||
`WORKSPACE_ROOT` env var). The kernel creates the package subdirectory
|
||||
on first `workspace.create()`. Extensions own everything below their
|
||||
directory — the kernel does not inspect contents.
|
||||
|
||||
**Implementation notes:**
|
||||
|
||||
- New file: `sandbox/workspace_module.go`
|
||||
- `WorkspaceModuleConfig`:
|
||||
```go
|
||||
type WorkspaceModuleConfig struct {
|
||||
PackageID string
|
||||
WorkspaceRoot string
|
||||
}
|
||||
```
|
||||
- Name validation: same rules as table names (`^[a-z][a-z0-9_]{0,62}$`).
|
||||
No path separators, no `..`, no spaces.
|
||||
- `workspace.create()` calls `os.MkdirAll` for the scoped path.
|
||||
- `workspace.delete()` calls `os.RemoveAll` — destructive by design.
|
||||
Extensions must handle confirmation in their own UX.
|
||||
- `workspace.usage()` walks the directory tree and sums file sizes.
|
||||
Bounded by a 10-second context timeout to prevent hangs on huge trees.
|
||||
- **Quota enforcement** (optional): `WORKSPACE_QUOTA_MB` env var. When
|
||||
set, `workspace.create()` checks cumulative usage for the package
|
||||
before creating. Returns error if quota exceeded. Default: unlimited.
|
||||
- If `WORKSPACE_ROOT` is empty or not writable, the module is not
|
||||
injected. Extensions that declare `workspace.manage` without the
|
||||
root configured will have their permission granted but the module
|
||||
absent — same degradation pattern as `db` when no DB is available.
|
||||
|
||||
**Runner wiring:**
|
||||
|
||||
```go
|
||||
case models.ExtPermWorkspaceManage:
|
||||
if r.workspaceRoot != "" {
|
||||
modules["workspace"] = BuildWorkspaceModule(ctx, WorkspaceModuleConfig{
|
||||
PackageID: packageID,
|
||||
WorkspaceRoot: r.workspaceRoot,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Runner gains `SetWorkspaceRoot(path string)` setter.
|
||||
|
||||
**Security considerations:**
|
||||
|
||||
- The returned path is an absolute filesystem path. Extensions can pass
|
||||
this to `http` module calls (e.g., POST a file to an API) or use it
|
||||
in `db` records as a reference. They cannot execute arbitrary binaries —
|
||||
the Starlark sandbox has no `os.exec`. Execution requires a sidecar
|
||||
tier package or an `api_route` handler that shells out server-side.
|
||||
- For sidecar-tier packages that _can_ execute binaries, the workspace
|
||||
path is the designated scratch space. The kernel ensures the path is
|
||||
within the scoped directory via `filepath.Clean` + prefix check.
|
||||
|
||||
---
|
||||
|
||||
### Primitive 3: Capability Negotiation
|
||||
|
||||
Extensions declare environment requirements in their manifest. The kernel
|
||||
validates these at install time and reports failures with actionable
|
||||
messages. This is what makes progressive enhancement strategies (e.g.,
|
||||
three-tier vector search) work.
|
||||
|
||||
**Manifest field:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "vector-store",
|
||||
"capabilities": {
|
||||
"required": ["files.read", "files.write"],
|
||||
"optional": ["pgvector"]
|
||||
},
|
||||
"permissions": ["db.write", "files.write"],
|
||||
"db_tables": {
|
||||
"embeddings": {
|
||||
"columns": {
|
||||
"source_id": "text",
|
||||
"chunk_text": "text",
|
||||
"embedding": "vector(384)"
|
||||
},
|
||||
"indexes": [["source_id"]]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`capabilities.required` — install fails if any are missing. Admin gets
|
||||
a clear error: "Package 'vector-store' requires capability 'pgvector'
|
||||
which is not available. Run `CREATE EXTENSION vector;` in your PostgreSQL
|
||||
database to enable it."
|
||||
|
||||
`capabilities.optional` — install succeeds regardless. The capability
|
||||
state is queryable at runtime so extensions can degrade gracefully.
|
||||
|
||||
**Runtime query (Starlark):**
|
||||
|
||||
Settings module is the natural home since it's always available:
|
||||
|
||||
```python
|
||||
# Returns True/False for a capability name.
|
||||
has_pgvector = settings.has_capability("pgvector")
|
||||
```
|
||||
|
||||
**Kernel capability registry:**
|
||||
|
||||
A simple function in the `handlers` package that probes the environment:
|
||||
|
||||
```go
|
||||
func DetectCapabilities(db *sql.DB, isPostgres bool, workspaceRoot string, objStore storage.ObjectStore) map[string]bool {
|
||||
caps := make(map[string]bool)
|
||||
|
||||
// pgvector: check pg_extension
|
||||
if isPostgres && db != nil {
|
||||
var exists bool
|
||||
row := db.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname='vector')")
|
||||
if row.Scan(&exists) == nil && exists {
|
||||
caps["pgvector"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// workspace: check root is writable
|
||||
if workspaceRoot != "" && storage.IsPathWritable(workspaceRoot) {
|
||||
caps["workspace"] = true
|
||||
}
|
||||
|
||||
// object storage: check configured and healthy
|
||||
if objStore != nil {
|
||||
if err := objStore.Healthy(context.Background()); err == nil {
|
||||
caps["object_storage"] = true
|
||||
}
|
||||
}
|
||||
|
||||
// s3: specific backend check
|
||||
if objStore != nil && objStore.Backend() == "s3" {
|
||||
caps["s3"] = true
|
||||
}
|
||||
|
||||
// postgres: dialect check
|
||||
if isPostgres {
|
||||
caps["postgres"] = true
|
||||
}
|
||||
|
||||
return caps
|
||||
}
|
||||
```
|
||||
|
||||
Called once at startup, stored on the `Runner` (or a shared config
|
||||
struct). Re-probed on admin request for the capabilities endpoint.
|
||||
|
||||
**Install-time validation:**
|
||||
|
||||
In the package install handler (`handlers/extensions.go`), after
|
||||
`ParseDBTables` and before `SyncManifestPermissions`:
|
||||
|
||||
```go
|
||||
if caps, ok := ParseCapabilities(manifestMap); ok {
|
||||
missing := CheckRequiredCapabilities(caps.Required, detectedCaps)
|
||||
if len(missing) > 0 {
|
||||
// Roll back: delete the just-created package row
|
||||
h.stores.Packages.Delete(c.Request.Context(), pkg.ID)
|
||||
c.JSON(422, gin.H{
|
||||
"error": "missing required capabilities",
|
||||
"missing": missing,
|
||||
"help": capabilityHelpText(missing),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Admin API:**
|
||||
|
||||
```
|
||||
GET /api/v1/admin/capabilities
|
||||
→ {"pgvector": true, "workspace": true, "object_storage": true, "s3": false, "postgres": true}
|
||||
```
|
||||
|
||||
Displayed in the Admin UI alongside storage status. Gives operators
|
||||
visibility into what their deployment supports.
|
||||
|
||||
---
|
||||
|
||||
### Extension to `db` Module: Vector Column Type
|
||||
|
||||
The existing `mapColType()` in `ext_db_schema.go` gains a `vector` type
|
||||
with progressive enhancement across backends.
|
||||
|
||||
**Manifest declaration:**
|
||||
|
||||
```json
|
||||
"db_tables": {
|
||||
"embeddings": {
|
||||
"columns": {
|
||||
"embedding": "vector(384)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Column type mapping:**
|
||||
|
||||
| Manifest type | PG + pgvector | PG without pgvector | SQLite |
|
||||
|------------------|-------------------------|---------------------|--------------|
|
||||
| `vector(N)` | `vector(N)` | `JSONB` | `TEXT` |
|
||||
|
||||
Implementation in `mapColType`:
|
||||
|
||||
```go
|
||||
case "vector":
|
||||
// vector or vector(384) — extract dimension if present
|
||||
dim := extractVectorDim(typStr) // returns "384" or ""
|
||||
if isPostgres && hasPgVector {
|
||||
if dim != "" {
|
||||
return fmt.Sprintf("vector(%s)", dim)
|
||||
}
|
||||
return "vector"
|
||||
}
|
||||
if isPostgres {
|
||||
return "JSONB" // store as JSON array, brute-force search
|
||||
}
|
||||
return "TEXT" // SQLite: JSON array as text
|
||||
```
|
||||
|
||||
`hasPgVector` is passed through a new field on a `SchemaConfig` struct
|
||||
(or detected inline — the capability registry result is available at
|
||||
table creation time).
|
||||
|
||||
**New `db` module function — `db.query_similar()`:**
|
||||
|
||||
```python
|
||||
# Find rows with embeddings closest to the query vector.
|
||||
# Returns list of row dicts with _distance appended.
|
||||
results = db.query_similar(
|
||||
table="embeddings",
|
||||
column="embedding",
|
||||
vector=[0.1, 0.2, ...], # query vector (list of floats)
|
||||
limit=10,
|
||||
filters={"source_id": "doc-123"} # optional WHERE clause
|
||||
)
|
||||
```
|
||||
|
||||
**Backend dispatch:**
|
||||
|
||||
- **PG + pgvector:** Uses `ORDER BY embedding <=> $1 LIMIT $2` with
|
||||
native vector distance operator.
|
||||
- **PG without pgvector / SQLite:** Loads candidate rows (respecting
|
||||
`filters`), deserializes JSON arrays, computes cosine similarity in
|
||||
Go, sorts, returns top-N. This is the "it works but slowly" fallback —
|
||||
acceptable for small corpora (<10k rows), documented as such.
|
||||
|
||||
Implementation: new function `dbQuerySimilar()` in `db_module.go`,
|
||||
gated on `db.read` permission (read-only operation). The function
|
||||
checks `cfg.HasPgVector` to choose the fast or slow path.
|
||||
|
||||
`DBModuleConfig` gains:
|
||||
|
||||
```go
|
||||
type DBModuleConfig struct {
|
||||
PackageID string
|
||||
CanWrite bool
|
||||
DB *sql.DB
|
||||
IsPostgres bool
|
||||
HasPgVector bool // NEW: enables native vector ops
|
||||
}
|
||||
```
|
||||
|
||||
Wired from the capability registry at module build time.
|
||||
|
||||
---
|
||||
|
||||
## New Permission Constants
|
||||
|
||||
```go
|
||||
// In models/models_extension_perm.go:
|
||||
const (
|
||||
ExtPermFilesRead = "files.read"
|
||||
ExtPermFilesWrite = "files.write"
|
||||
ExtPermWorkspaceManage = "workspace.manage"
|
||||
)
|
||||
```
|
||||
|
||||
Added to `ValidExtensionPermissions` map.
|
||||
|
||||
---
|
||||
|
||||
## Schema Changes
|
||||
|
||||
**Migration 015 — none required for kernel tables.**
|
||||
|
||||
The `files` module uses the existing `ObjectStore` interface — no new
|
||||
kernel tables. Metadata companions are stored as ObjectStore objects.
|
||||
|
||||
The `workspace` module uses the filesystem — no tables.
|
||||
|
||||
Capabilities are detected at runtime — no tables.
|
||||
|
||||
The `vector` column type is handled by extension DDL generation in
|
||||
`ext_db_schema.go` — no kernel migration.
|
||||
|
||||
The new permission constants are code-only changes.
|
||||
|
||||
---
|
||||
|
||||
## New Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `sandbox/files_module.go` | `files` Starlark module |
|
||||
| `sandbox/files_module_test.go` | Tests using mock ObjectStore |
|
||||
| `sandbox/workspace_module.go` | `workspace` Starlark module |
|
||||
| `sandbox/workspace_module_test.go` | Tests using temp directory |
|
||||
| `handlers/capabilities.go` | `DetectCapabilities()`, `ParseCapabilities()`, admin endpoint |
|
||||
| `handlers/capabilities_test.go` | Tests for capability detection and validation |
|
||||
|
||||
## Modified Files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `models/models_extension_perm.go` | Add `files.read`, `files.write`, `workspace.manage` |
|
||||
| `sandbox/runner.go` | Add `SetObjectStore()`, `SetWorkspaceRoot()`, wire new modules |
|
||||
| `sandbox/db_module.go` | Add `dbQuerySimilar()`, `HasPgVector` field |
|
||||
| `handlers/ext_db_schema.go` | Extend `mapColType()` for `vector(N)`, pass `hasPgVector` |
|
||||
| `handlers/extensions.go` | Add capability validation in install handler |
|
||||
| `sandbox/settings_module.go` | Add `settings.has_capability()` |
|
||||
| `server/main.go` | Call `DetectCapabilities()`, wire to runner |
|
||||
| `docs/PACKAGE-FORMAT.md` | Document `capabilities` manifest field |
|
||||
| `docs/EXTENSION-GUIDE.md` | Document new modules and vector column type |
|
||||
|
||||
---
|
||||
|
||||
## Changeset Plan
|
||||
|
||||
**CS1 — `files` module + permissions**
|
||||
|
||||
New files: `files_module.go`, `files_module_test.go`.
|
||||
Modified: `models_extension_perm.go`, `runner.go`, `main.go`.
|
||||
Scope: ObjectStore bridge, permission constants, runner wiring.
|
||||
CI-green independently — no schema changes, no existing behavior affected.
|
||||
|
||||
**CS2 — `workspace` module**
|
||||
|
||||
New files: `workspace_module.go`, `workspace_module_test.go`.
|
||||
Modified: `models_extension_perm.go`, `runner.go`, `main.go`.
|
||||
Scope: Managed disk directories, quota enforcement.
|
||||
CI-green independently.
|
||||
|
||||
**CS3 — Capability negotiation**
|
||||
|
||||
New files: `capabilities.go`, `capabilities_test.go`.
|
||||
Modified: `extensions.go` (install validation), `settings_module.go`
|
||||
(`has_capability`), `main.go`.
|
||||
Scope: Detection, install-time validation, runtime query, admin endpoint.
|
||||
CI-green independently.
|
||||
|
||||
**CS4 — Vector column type + `db.query_similar()`**
|
||||
|
||||
Modified: `ext_db_schema.go`, `db_module.go`, `db_module_test.go`.
|
||||
Scope: Column type mapping, similarity query with dual-path dispatch.
|
||||
Depends on CS3 (needs `HasPgVector` from capability registry).
|
||||
|
||||
---
|
||||
|
||||
## Configuration Summary
|
||||
|
||||
| Env Var | Default | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `WORKSPACE_ROOT` | `/data/workspaces` | Root directory for workspace module |
|
||||
| `WORKSPACE_QUOTA_MB` | `0` (unlimited) | Per-extension disk quota |
|
||||
| `EXT_FILES_MAX_SIZE` | `52428800` (50MB) | Max single file size via `files.put()` |
|
||||
|
||||
---
|
||||
|
||||
## Example: Vector Store Extension
|
||||
|
||||
A `vector-store` library extension consuming all four primitives:
|
||||
|
||||
**manifest.json:**
|
||||
```json
|
||||
{
|
||||
"id": "vector-store",
|
||||
"title": "Vector Store",
|
||||
"type": "library",
|
||||
"tier": "starlark",
|
||||
"version": "0.1.0",
|
||||
"permissions": ["db.write", "files.read", "connections.read"],
|
||||
"capabilities": {
|
||||
"required": [],
|
||||
"optional": ["pgvector"]
|
||||
},
|
||||
"db_tables": {
|
||||
"documents": {
|
||||
"columns": {
|
||||
"source": "text",
|
||||
"chunk_text": "text",
|
||||
"page": "int",
|
||||
"metadata": "text"
|
||||
},
|
||||
"indexes": [["source"]]
|
||||
},
|
||||
"embeddings": {
|
||||
"columns": {
|
||||
"document_id": "text",
|
||||
"embedding": "vector(384)",
|
||||
"chunk_index": "int"
|
||||
},
|
||||
"indexes": [["document_id"]]
|
||||
}
|
||||
},
|
||||
"exports": ["ingest", "search", "search_clustered"]
|
||||
}
|
||||
```
|
||||
|
||||
**script.star:**
|
||||
```python
|
||||
def ingest(source_name, chunks):
|
||||
"""Store document chunks and generate embeddings."""
|
||||
for i, chunk in enumerate(chunks):
|
||||
row = db.insert("documents", {
|
||||
"source": source_name,
|
||||
"chunk_text": chunk["text"],
|
||||
"page": chunk.get("page", 0),
|
||||
"metadata": json.encode(chunk.get("metadata", {})),
|
||||
})
|
||||
# Embedding generation delegated to caller (llm-bridge)
|
||||
# Caller passes pre-computed vectors
|
||||
if "embedding" in chunk:
|
||||
db.insert("embeddings", {
|
||||
"document_id": row["id"],
|
||||
"embedding": json.encode(chunk["embedding"]),
|
||||
"chunk_index": i,
|
||||
})
|
||||
|
||||
def search(query_vector, limit=10, source_filter=None):
|
||||
"""Semantic similarity search — dispatches to native or brute-force."""
|
||||
filters = {}
|
||||
if source_filter:
|
||||
filters["source"] = source_filter
|
||||
# query_similar handles pgvector vs fallback transparently
|
||||
results = db.query_similar(
|
||||
table="embeddings",
|
||||
column="embedding",
|
||||
vector=query_vector,
|
||||
limit=limit,
|
||||
)
|
||||
# Hydrate with document text
|
||||
enriched = []
|
||||
for r in results:
|
||||
docs = db.query("documents", filters={"id": r["document_id"]}, limit=1)
|
||||
if docs:
|
||||
enriched.append({
|
||||
"text": docs[0]["chunk_text"],
|
||||
"source": docs[0]["source"],
|
||||
"page": docs[0]["page"],
|
||||
"distance": r["_distance"],
|
||||
})
|
||||
return enriched
|
||||
|
||||
def search_clustered(embeddings_list, k):
|
||||
"""K-means clustering for query-free thematic selection.
|
||||
Returns k representative chunks. Clustering runs in the
|
||||
extension — the kernel provides the data, not the algorithm."""
|
||||
# This would be implemented by a consumer extension with
|
||||
# http access to call a clustering API, or by a sidecar
|
||||
# that runs sklearn. The vector-store library provides
|
||||
# the data access pattern; clustering logic lives elsewhere.
|
||||
pass
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **Content-addressed deduplication.** If multiple extensions store the
|
||||
same PDF, the ObjectStore holds duplicate bytes. A SHA256-keyed blob
|
||||
layer with refcounting would deduplicate transparently. Deferred —
|
||||
adds complexity without clear need pre-1.0.
|
||||
|
||||
- **Extension-to-extension file sharing.** The current design isolates
|
||||
file namespaces per extension. A `files.grant(name, target_pkg_id)`
|
||||
primitive could enable controlled sharing. Deferred — composition
|
||||
through API routes is sufficient initially.
|
||||
|
||||
- **Streaming upload for large files.** Starlark `files.put()` buffers
|
||||
in memory. For files >50MB, extensions should use HTTP `api_routes`
|
||||
with Go handlers that stream directly to ObjectStore. The `files`
|
||||
module is for extension-internal storage, not user-facing upload.
|
||||
|
||||
- **Workspace snapshots.** `workspace.snapshot(name)` → creates a
|
||||
tarball in the `files` store. Useful for backup/reproducibility.
|
||||
Deferred — extensions can implement this themselves.
|
||||
|
||||
- **Rate limiting / quota on `db.query_similar()` fallback.** The
|
||||
brute-force path loads all candidate rows into Go memory. For large
|
||||
tables this is dangerous. A row-count guard (e.g., refuse if >50k
|
||||
candidates) with a clear error message pointing to pgvector is the
|
||||
right safety valve.
|
||||
Reference in New Issue
Block a user