Add v0.8.x design docs and expand roadmap through v1.0

Land two design documents from planning sessions:
- DESIGN-storage-primitives.md (files module, workspace, capability
  negotiation, vector columns)
- DESIGN-extension-composability.md (slots/contributes manifest fields,
  lib.require relaxation, SDK helpers)

Expand ROADMAP.md with detailed v0.8.x-v1.0 plan: storage primitives,
reference extensions, sidecar tier, stable release gate criteria.
Merge design decisions from both planning docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 15:18:56 +00:00
parent a7e38bc72a
commit 55b83baa4c
3 changed files with 1571 additions and 16 deletions

View File

@@ -1,14 +1,15 @@
# Armature — Roadmap # Armature — Roadmap
## Current: v0.7.4Documentation + Deferred Surface Work ## Current: v0.7.5Headless E2E + CI Gate
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox, Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
storage, realtime, and ops are kernel primitives. Everything else is an extension. sandbox, storage, realtime, and ops are kernel primitives. Everything else
is an extension.
**Kernel capabilities:** Auth (builtin/mTLS/OIDC) · Users/teams/groups/RBAC · **Kernel capabilities:** Auth (builtin/mTLS/OIDC) · Users/teams/groups/RBAC ·
Surfaces/extensions/libraries/workflows · Starlark sandbox (capability-gated) · Surfaces/extensions/libraries/workflows · Starlark sandbox (capability-gated) ·
Object storage (PVC/S3) + ext_data tables · WebSocket hub + realtime pub/sub · Object storage (PVC/S3) + ext_data tables · WebSocket hub + realtime pub/sub ·
Audit log · Notifications · Scheduled tasks Audit log · Notifications · Scheduled tasks · Cluster registry + HA
**Completed history:** v0.2.xv0.5.x fully documented in `CHANGELOG.md`. **Completed history:** v0.2.xv0.5.x fully documented in `CHANGELOG.md`.
Highlights: RBAC + settings cascade, event bus + triggers, SDK stabilization, Highlights: RBAC + settings cascade, event bus + triggers, SDK stabilization,
@@ -184,22 +185,212 @@ same pattern as the kernel surface migrations.
| Step | Status | Description | | Step | Status | Description |
|------|--------|-------------| |------|--------|-------------|
| Playwright test harness | | `ci/e2e-surface-test.sh` — docker-compose, chromium, run-all, assert. | | CI gating redesign | | Fix path rules: `VERSION`/`scripts/*` deploy-only, `docs/*` no longer skips deploy, test-runners trigger on any code change. |
| Screenshot-on-failure | | Full-page screenshot + console log. CI artifacts. | | E2E smoke test harness | | `ci/e2e-smoke-test.sh` + `ci/e2e-smoke-driver.js` — Playwright visits every surface, asserts topbar, no JS errors, home link. |
| Navigation smoke test | | Playwright visits every surface. Asserts topbar, no JS errors, home link works. | | Screenshot-on-failure | | Full-page screenshot + console log saved as CI artifacts. |
| Visual regression baseline | | Optional screenshot diff. Not a gate — report for review. | | CI pipeline integration | | Re-enable `test-runners` stage, add `e2e-smoke` stage. Failure blocks merge. |
| CI pipeline integration | | Enable `test-runners` stage, add `e2e-smoke` stage. Failure blocks merge. | | Design docs landed | | `docs/DESIGN-storage-primitives.md`, `docs/DESIGN-extension-composability.md`. Roadmap expanded through v1.0. |
--- ---
## Post-v0.7.x ## Planned
- **LLM participation** (`llm-bridge` extension) ### v0.8.x — Storage Primitives
- **Rich media extensions:** image generation, code sandbox, STT/TTS
- **Desktop app** (Tauri or Electron) Design doc: `docs/DESIGN-storage-primitives.md`
- **Sidecar tier:** container-based extensions
- **Federation:** cross-instance package sharing The last major kernel expansion. Completes the primitive set that the
- **Plugin marketplace** with signing and review entire extension ecosystem builds on. After v0.8.x, the kernel is
feature-complete for 1.0 — all new capabilities are extensions.
**v0.8.0 — `files` Module**
Bridge `ObjectStore` (PVC/S3) into the Starlark sandbox. Extension-scoped
key namespacing (`ext/{packageID}/`). Permissions: `files.read`,
`files.write`. Metadata companions stored as sibling objects.
New: `sandbox/files_module.go`. Modified: runner wiring, permission
constants.
**v0.8.1 — `workspace` Module**
Managed disk directories for tools that need a real filesystem (git,
compilers, media tools). `workspace.create(name)` → scoped path.
Permission: `workspace.manage`. Quota enforcement via `WORKSPACE_QUOTA_MB`.
New: `sandbox/workspace_module.go`. Modified: runner wiring, permission
constants.
**v0.8.2 — Capability Negotiation**
Extensions declare environment requirements in manifest (`capabilities.required`,
`capabilities.optional`). Kernel validates at install time. Runtime query
via `settings.has_capability()`. Admin endpoint: `GET /admin/capabilities`.
Detected capabilities: `pgvector`, `workspace`, `object_storage`, `s3`,
`postgres`.
New: `handlers/capabilities.go`. Modified: install handler, settings module.
**v0.8.3 — Vector Column Type**
`"vector(N)"` in `db_tables` manifest. Maps to native `vector(N)` on
PG+pgvector, `JSONB` on PG without, `TEXT` on SQLite. New `db.query_similar()`
with dual-path dispatch (native operator vs Go-side brute force).
Modified: `ext_db_schema.go`, `db_module.go`.
**v0.8.4 — Extension Composability**
Design doc: `docs/DESIGN-extension-composability.md`
Manifest declarations for `slots` (host surfaces declare injection points)
and `contributes` (extensions declare UI contributions to other packages'
slots). `lib.require()` relaxed from library-only to any package with
`exports` — enables full packages (with surfaces, settings, UI) to also
expose callable backend functions. `sw.slots.renderAll()` SDK helper.
Admin slot aggregation endpoint.
The composability primitive that enables: LLM tool registration, UI
contribution (toolbar buttons, image actions), and cross-extension
function calls. No new tables, migrations, or permissions.
Modified: `sandbox/lib_module.go` (~1 line), `handlers/extensions.go`,
`src/js/sw/sdk/slots.js`. New: `handlers/admin_slots.go`.
---
### v0.9.x — Reference Extensions
The kernel is complete. This series proves the platform by shipping
first-party extensions that exercise every primitive. These are
**extensions, not kernel code** — they ship as `.pkg` files and can be
uninstalled.
**v0.9.0 — `vector-store` Library**
Library extension: document ingestion, chunk storage, embedding persistence,
semantic similarity search. Three-tier vector strategy (pgvector → JSONB
fallback → TEXT fallback). Consumes: `db.write`, `files.read`.
**v0.9.1 — `llm-bridge` Library**
Library extension: model abstraction, tool-use routing, provider BYOK via
connections. Exposes `complete()`, `embed()`, `classify()`, `register_tool()`
to consumer extensions. Tool extensions (image-gen, code-exec, web-search)
register at load time — LLM discovers available tools automatically.
Consumes: `connections.read`, `api.http`, `db.write`.
**v0.9.2 — `file-share` Extension**
File upload via `api_routes`, storage via `files` module, download links,
optional team/group ACLs via resource grants. First extension to exercise
the full `files` primitive end-to-end.
**v0.9.3 — `code-workspace` Extension**
Managed code repositories via `workspace` module. Git clone/pull via
`api.http` to Gitea/GitHub API. File browser surface. Structural indexing
(AST-aware) for code navigation. Consumes: `workspace.manage`, `files.write`,
`api.http`.
**v0.9.4 — `image-gen` + `image-edit` Extensions**
Image generation via external APIs (DALL-E, Stable Diffusion). Registers
as LLM tool with `llm-bridge`. Contributes regen/edit buttons to
`chat:image-actions` slot. Image-edit extends with inpaint, outpaint,
upscale, and style transfer — each contributing action buttons to the
same slot independently. First full demonstration of the composability
pattern: three extensions contributing UI to a fourth's surface.
**v0.9.5 — Chat System**
Generic 1-to-N human messaging. `chat-core` library (conversation/message
CRUD) + `chat` surface. Declares `chat:message-actions`, `chat:image-actions`,
`chat:composer-tools` slots for extension contribution. LLM participation
as a follow-on consuming `llm-bridge`. Consumes: `db.write`,
`realtime.publish`, `files.write` (attachments).
---
### v0.10.x — Sidecar Tier + Polish
**v0.10.0 — Sidecar Tier**
Autonomous out-of-process extensions for workloads that can't run in
Starlark: ML inference, media transcoding, language servers, local git
operations. The kernel does NOT manage container lifecycle — sidecars
are independent processes (Docker, systemd, bare binary) that connect
inward to the kernel, following the cluster registry pattern:
- Sidecar boots with cluster config (instance URLs, credentials).
- Connects and self-registers, declaring capabilities ("sklearn",
"gpu", "ffmpeg", "sentence-transformers").
- Goes inert until an admin authorizes (same flow as extension permissions).
- Kernel dispatches work; sidecar returns results.
- Heartbeat keeps registration alive; missed heartbeat = eviction.
Communication channel TBD (WebSocket over existing event bus, gRPC, or
HTTP callback model — design doc required). Authentication via mTLS
certs or registration tokens.
This model eliminates k8s RBAC dependency for local deployments. The
kernel stays thin — it accepts registrations and dispatches work. The
sidecar brings its own compute and manages its own lifecycle.
**v0.10.1 — Native Dialog Audit**
Replace `prompt()`/`confirm()`/`alert()` with `sw.dialog` SDK primitives.
Accessible, themed, promise-based. Small kernel SDK change that improves
every surface.
**v0.10.2 — Stability + Migration Tooling**
Proper versioned migrations (post-MVP discipline). `armature migrate`
CLI command. Backup/restore validation against reference dataset.
Pre-1.0 schema freeze.
---
### v1.0.0 — Stable Release
The kernel API surface is frozen. Extensions are the product.
Gate criteria:
- All kernel Starlark modules documented with examples
- All `api_routes` covered by OpenAPI spec
- Upgrade path tested from v0.8.0 → v1.0.0
- At least 3 reference extensions shipped and tested (vector-store, llm-bridge, chat)
- At least 1 cross-extension composability demo (image-gen → chat:image-actions)
- Headless E2E green on PG + SQLite
- `armature-ca.sh` + mTLS deployment guide
- Single-binary + Docker + K8s deployment paths documented
---
## Post-1.0 Horizon
These are candidates, not commitments. Each requires a design doc.
- **Federation** — cross-instance package sharing, identity federation
- **Package marketplace** — signing, review, discovery registry
- **Desktop app** — Tauri wrapper for local-first deployment
- **Offline/sync** — SQLite-first with PG sync for field deployments
- **Multi-tenant SaaS mode** — tenant isolation at the team boundary
---
## Design Principles
| Principle | Implication |
|-----------|-------------|
| Extensions are the product | Chat, tasks, LLM, vector search, file sharing — all extensions. Zero kernel awareness of domain logic. |
| Kernel stays thin | New kernel primitives require justification. If it can be an extension, it must be. |
| Progressive enhancement | Every feature works on SQLite. PG adds performance. pgvector adds native vectors. S3 adds scalable storage. |
| KISS-first | No unnecessary dependencies. Preact+htm (3KB), single binary, dual-DB from one codebase. |
| Changeset discipline | Each CS independently CI-green. Design docs as implementation contracts. |
| Pre-1.0 migration freedom | Schema changes fold into existing migrations. Post-1.0: proper versioned migrations. |
--- ---
@@ -228,3 +419,12 @@ same pattern as the kernel surface migrations.
| Docs is the reference surface | Only surface with shell Topbar, bell, user menu, inline errors. Others converge. | | Docs is the reference surface | Only surface with shell Topbar, bell, user menu, inline errors. Others converge. |
| Surface runners over expanding ICD | Different test tier, different failure class. | | Surface runners over expanding ICD | Different test tier, different failure class. |
| Headless E2E via Playwright | Runners produce structured JSON; Playwright navigates and reads output. | | Headless E2E via Playwright | Runners produce structured JSON; Playwright navigates and reads output. |
| `db` module is the structured store | No separate KV primitive. Extensions declare tables — infrastructure exists. |
| `files` module rides ObjectStore | No new kernel tables. Metadata as companion objects. Works on PVC and S3 identically. |
| `workspace` for real filesystem | Flat blob store can't serve git/compilers/ffmpeg. Managed disk paths are the explicit escape hatch. |
| Capability negotiation at install | Fail loud with actionable message, not silently at runtime. |
| Vector column with three-tier fallback | Works everywhere, works fast with pgvector. Documentation > hard gates. |
| Sidecar deferred to v0.10.x | HTTP module covers external APIs. Local compute is a v0.10.x concern. Sidecars connect inward (like cluster nodes), not spawned outward (no k8s RBAC needed). |
| No second scripting runtime | Starlark for glue, HTTP for external compute, sidecars for local compute. Three tiers with clear boundaries. Lua/WASM middle tier rejected — Turing-complete enough to be dangerous, not isolated enough to be trustworthy. |
| Reference extensions prove the platform | First-party `.pkg` files, not kernel code. Uninstallable. Dogfooding. |
| Composability via slots + exports, not kernel | `sw.slots` + `sw.actions` + `lib.require()` are the composition primitives. Manifest declarations add admin visibility. No inter-extension message bus — too complex, too fragile. |

View 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.

View 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.