Compare commits
5 Commits
feat/wave1
...
feat/wave2
| Author | SHA1 | Date | |
|---|---|---|---|
| c8db943d85 | |||
| 04d13e510c | |||
| d7f200edd6 | |||
| 2828a83b66 | |||
| 8a2fb6c1ec |
@@ -5,26 +5,31 @@ steps:
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
|
||||
- name: lint
|
||||
image: node:22-alpine
|
||||
commands:
|
||||
- pnpm turbo lint
|
||||
|
||||
- name: typecheck
|
||||
image: node:22-alpine
|
||||
depends_on: [install]
|
||||
commands:
|
||||
- pnpm turbo typecheck
|
||||
|
||||
- name: lint
|
||||
image: node:22-alpine
|
||||
depends_on: [install]
|
||||
commands:
|
||||
- pnpm turbo lint
|
||||
|
||||
- name: build
|
||||
image: node:22-alpine
|
||||
depends_on: [typecheck]
|
||||
commands:
|
||||
- pnpm turbo build
|
||||
|
||||
- name: test
|
||||
image: node:22-alpine
|
||||
depends_on: [build]
|
||||
commands:
|
||||
- pnpm turbo test
|
||||
services:
|
||||
- name: valkey
|
||||
image: valkey/valkey:8-alpine
|
||||
ports: ["6379:6379"]
|
||||
environment:
|
||||
- ALLOW_EMPTY_PASSWORD=yes
|
||||
|
||||
340
packages/mosaic/README.md
Normal file
340
packages/mosaic/README.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Mosaic Agent Framework
|
||||
|
||||
Universal agent standards layer for Claude Code, Codex, and OpenCode.
|
||||
|
||||
One config, every runtime, same standards.
|
||||
|
||||
> **This repository is a generic framework baseline.** No personal data, credentials, user-specific preferences, or machine-specific paths should be committed. All personalization happens at install time via `mosaic init` or by editing files in `~/.config/mosaic/` after installation.
|
||||
|
||||
## Quick Install
|
||||
|
||||
### Mac / Linux
|
||||
|
||||
```bash
|
||||
curl -sL https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.sh | sh
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.ps1 | iex
|
||||
```
|
||||
|
||||
### From Source (any platform)
|
||||
|
||||
```bash
|
||||
git clone https://git.mosaicstack.dev/mosaic/bootstrap.git ~/src/mosaic-bootstrap
|
||||
cd ~/src/mosaic-bootstrap && bash install.sh
|
||||
```
|
||||
|
||||
If Node.js 18+ is available, the remote installer automatically uses the TypeScript wizard instead of the bash installer for a richer setup experience.
|
||||
|
||||
The installer will:
|
||||
- Install the framework to `~/.config/mosaic/`
|
||||
- Add `~/.config/mosaic/bin` to your PATH
|
||||
- Sync runtime adapters and skills
|
||||
- Install and configure sequential-thinking MCP (hard requirement)
|
||||
- Run a health audit
|
||||
- Detect existing installs and prompt to keep or overwrite local files
|
||||
- Prompt you to run `mosaic init` to set up your agent identity
|
||||
|
||||
## First Run
|
||||
|
||||
After install, open a new terminal (or `source ~/.bashrc`) and run:
|
||||
|
||||
```bash
|
||||
mosaic init
|
||||
```
|
||||
|
||||
If Node.js 18+ is installed, this launches an interactive wizard with two modes:
|
||||
|
||||
- **Quick Start** (~2 min): agent name + communication style, sensible defaults for everything else
|
||||
- **Advanced**: full customization of identity, user profile, tools, runtimes, and skills
|
||||
|
||||
The wizard configures three files loaded into every agent session:
|
||||
- `SOUL.md` — agent identity contract (name, style, guardrails)
|
||||
- `USER.md` — your user profile (name, timezone, accessibility, preferences)
|
||||
- `TOOLS.md` — machine-level tool reference (git providers, credentials, CLI patterns)
|
||||
|
||||
It also detects installed runtimes (Claude, Codex, OpenCode), configures sequential-thinking MCP, and offers curated skill selection from 8 categories.
|
||||
|
||||
### Non-Interactive Mode
|
||||
|
||||
For CI or scripted installs:
|
||||
|
||||
```bash
|
||||
mosaic init --non-interactive --name Jarvis --style direct --user-name Jason --timezone America/Chicago
|
||||
```
|
||||
|
||||
All flags: `--name`, `--role`, `--style`, `--user-name`, `--pronouns`, `--timezone`, `--mosaic-home`, `--source-dir`.
|
||||
|
||||
### Legacy Fallback
|
||||
|
||||
If Node.js is unavailable, `mosaic init` falls back to the bash-based `mosaic-init` script.
|
||||
|
||||
## Launching Agent Sessions
|
||||
|
||||
```bash
|
||||
mosaic claude # Launch Claude Code with full Mosaic injection
|
||||
mosaic codex # Launch Codex with full Mosaic injection
|
||||
mosaic opencode # Launch OpenCode with full Mosaic injection
|
||||
```
|
||||
|
||||
The launcher:
|
||||
1. Verifies `~/.config/mosaic` exists
|
||||
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
||||
3. Injects `AGENTS.md` into the runtime
|
||||
4. Forwards all arguments to the runtime CLI
|
||||
|
||||
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
~/.config/mosaic/
|
||||
├── AGENTS.md ← THE source of truth (all standards, all runtimes)
|
||||
├── SOUL.md ← Agent identity (generated by mosaic init)
|
||||
├── USER.md ← User profile and accessibility (generated by mosaic init)
|
||||
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
|
||||
├── STANDARDS.md ← Machine-wide standards
|
||||
├── guides/E2E-DELIVERY.md ← Mandatory E2E software delivery procedure
|
||||
├── guides/PRD.md ← Mandatory PRD requirements gate before coding
|
||||
├── guides/DOCUMENTATION.md ← Mandatory documentation standard and gates
|
||||
├── bin/ ← CLI tools (mosaic, mosaic-init, mosaic-doctor, etc.)
|
||||
├── dist/ ← Bundled wizard (mosaic-wizard.mjs)
|
||||
├── guides/ ← Operational guides
|
||||
├── tools/ ← Tool suites: git, portainer, authentik, coolify, codex, etc.
|
||||
├── runtime/ ← Runtime adapters + runtime-specific references
|
||||
│ ├── claude/CLAUDE.md
|
||||
│ ├── claude/RUNTIME.md
|
||||
│ ├── opencode/AGENTS.md
|
||||
│ ├── opencode/RUNTIME.md
|
||||
│ ├── codex/instructions.md
|
||||
│ ├── codex/RUNTIME.md
|
||||
│ └── mcp/SEQUENTIAL-THINKING.json
|
||||
├── skills/ ← Universal skills (synced from mosaic/agent-skills)
|
||||
├── skills-local/ ← Local cross-runtime skills
|
||||
└── templates/ ← SOUL.md template, project templates
|
||||
```
|
||||
|
||||
### How AGENTS.md Gets Loaded
|
||||
|
||||
| Launch method | Injection mechanism |
|
||||
|--------------|-------------------|
|
||||
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
|
||||
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
|
||||
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
||||
| `claude` (direct) | `~/.claude/CLAUDE.md` thin pointer → load AGENTS + runtime reference |
|
||||
| `codex` (direct) | `~/.codex/instructions.md` thin pointer → load AGENTS + runtime reference |
|
||||
| `opencode` (direct) | `~/.config/opencode/AGENTS.md` thin pointer → load AGENTS + runtime reference |
|
||||
|
||||
Mosaic `AGENTS.md` enforces loading `guides/E2E-DELIVERY.md` before execution and
|
||||
requires `guides/PRD.md` before coding and `guides/DOCUMENTATION.md` for code/API/auth/infra documentation gates.
|
||||
|
||||
## Management Commands
|
||||
|
||||
```bash
|
||||
mosaic help # Show all commands
|
||||
mosaic init # Interactive wizard (or legacy init)
|
||||
mosaic doctor # Health audit — detect drift and missing files
|
||||
mosaic sync # Sync skills from canonical source
|
||||
mosaic bootstrap <path> # Bootstrap a repo with Mosaic standards
|
||||
mosaic upgrade check # Check release upgrade status (no changes)
|
||||
mosaic upgrade # Upgrade installed Mosaic release (keeps SOUL.md by default)
|
||||
mosaic upgrade --dry-run # Preview release upgrade without changes
|
||||
mosaic upgrade --ref main # Upgrade from a specific branch/tag/commit ref
|
||||
mosaic upgrade --overwrite # Upgrade release and overwrite local files
|
||||
mosaic upgrade project ... # Project file cleanup mode (see below)
|
||||
```
|
||||
|
||||
## Upgrading Mosaic Release
|
||||
|
||||
Upgrade the installed framework in place:
|
||||
|
||||
```bash
|
||||
# Default (safe): keep local SOUL.md, USER.md, TOOLS.md + memory
|
||||
mosaic upgrade
|
||||
|
||||
# Check current/target release info without changing files
|
||||
mosaic upgrade check
|
||||
|
||||
# Non-interactive
|
||||
mosaic upgrade --yes
|
||||
|
||||
# Pull a specific ref
|
||||
mosaic upgrade --ref main
|
||||
|
||||
# Force full overwrite (fresh install semantics)
|
||||
mosaic upgrade --overwrite --yes
|
||||
```
|
||||
|
||||
`mosaic upgrade` re-runs the remote installer and passes install mode controls (`keep`/`overwrite`).
|
||||
This is the manual upgrade path today and is suitable for future app-driven update checks.
|
||||
|
||||
## Upgrading Projects
|
||||
|
||||
After centralizing AGENTS.md and SOUL.md, existing projects may have stale files:
|
||||
|
||||
```bash
|
||||
# Preview what would change across all projects
|
||||
mosaic upgrade project --all --dry-run
|
||||
|
||||
# Apply to all projects
|
||||
mosaic upgrade project --all
|
||||
|
||||
# Apply to a specific project
|
||||
mosaic upgrade project ~/src/my-project
|
||||
```
|
||||
|
||||
Backward compatibility is preserved for historical usage:
|
||||
|
||||
```bash
|
||||
mosaic upgrade --all # still routes to project-upgrade
|
||||
mosaic upgrade ~/src/my-repo # still routes to project-upgrade
|
||||
```
|
||||
|
||||
What it does per project:
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `SOUL.md` | Removed — now global at `~/.config/mosaic/SOUL.md` |
|
||||
| `CLAUDE.md` | Replaced with thin pointer to global AGENTS.md |
|
||||
| `AGENTS.md` | Stale load-order sections stripped; project content preserved |
|
||||
|
||||
Backups (`.mosaic-bak`) are created before any modification.
|
||||
|
||||
## Universal Skills
|
||||
|
||||
The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/skills/`, then links each skill into runtime directories (`~/.claude/skills`, `~/.codex/skills`, `~/.config/opencode/skills`).
|
||||
|
||||
```bash
|
||||
mosaic sync # Full sync (clone + link)
|
||||
~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
|
||||
```
|
||||
|
||||
## Runtime Compatibility
|
||||
|
||||
The installer pushes thin runtime adapters as regular files (not symlinks):
|
||||
|
||||
- `~/.claude/CLAUDE.md` — pointer to `~/.config/mosaic/AGENTS.md`
|
||||
- `~/.claude/settings.json`, `hooks-config.json`, `context7-integration.md`
|
||||
- `~/.config/opencode/AGENTS.md` — pointer to `~/.config/mosaic/AGENTS.md`
|
||||
- `~/.codex/instructions.md` — pointer to `~/.config/mosaic/AGENTS.md`
|
||||
- `~/.claude/settings.json`, `~/.codex/config.toml`, and `~/.config/opencode/config.json` include sequential-thinking MCP config
|
||||
|
||||
Re-sync manually:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/bin/mosaic-link-runtime-assets
|
||||
```
|
||||
|
||||
## MCP Registration
|
||||
|
||||
### How MCPs Are Configured in Claude Code
|
||||
|
||||
**MCPs must be registered via `claude mcp add` — not by hand-editing `~/.claude/settings.json`.**
|
||||
|
||||
`settings.json` controls hooks, model, plugins, and allowed commands. The `mcpServers` key in
|
||||
`settings.json` is silently ignored by Claude Code's MCP loader. The correct file is `~/.claude.json`,
|
||||
which is managed by the `claude mcp` CLI.
|
||||
|
||||
```bash
|
||||
# Register a stdio MCP (user scope = all projects, persists across sessions)
|
||||
claude mcp add --scope user <name> -- npx -y <package>
|
||||
|
||||
# Register an HTTP MCP (e.g. OpenBrain)
|
||||
claude mcp add --scope user --transport http <name> <url> \
|
||||
--header "Authorization: Bearer <token>"
|
||||
|
||||
# List registered MCPs
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
**Scope options:**
|
||||
- `--scope user` — writes to `~/.claude.json`, available in all projects (recommended for shared tools)
|
||||
- `--scope project` — writes to `.claude/settings.json` in the project root, committed to the repo
|
||||
- `--scope local` — default, machine-local only, not committed
|
||||
|
||||
**Transport for HTTP MCPs must be `http`** — not `sse`. `type: "sse"` is a deprecated protocol
|
||||
that silently fails to connect against FastMCP streamable HTTP servers.
|
||||
|
||||
### sequential-thinking MCP (Hard Requirement)
|
||||
|
||||
sequential-thinking MCP is required for Mosaic Stack. The installer registers it automatically.
|
||||
To verify or re-register manually:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
|
||||
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
|
||||
```
|
||||
|
||||
### OpenBrain Semantic Memory (Recommended)
|
||||
|
||||
OpenBrain is the shared cross-agent memory layer. Register once per machine:
|
||||
|
||||
```bash
|
||||
claude mcp add --scope user --transport http openbrain https://your-openbrain-host/mcp \
|
||||
--header "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
See [mosaic/openbrain](https://git.mosaicstack.dev/mosaic/openbrain) for setup and API docs.
|
||||
|
||||
## Bootstrap Any Repo
|
||||
|
||||
Attach any repository to the Mosaic standards layer:
|
||||
|
||||
```bash
|
||||
mosaic bootstrap /path/to/repo
|
||||
```
|
||||
|
||||
This creates `.mosaic/`, `scripts/agent/`, and an `AGENTS.md` if missing.
|
||||
|
||||
## Quality Rails
|
||||
|
||||
Apply and verify quality templates:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/bin/mosaic-quality-apply --template typescript-node --target /path/to/repo
|
||||
~/.config/mosaic/bin/mosaic-quality-verify --target /path/to/repo
|
||||
```
|
||||
|
||||
Templates: `typescript-node`, `typescript-nextjs`, `monorepo`
|
||||
|
||||
## Health Audit
|
||||
|
||||
```bash
|
||||
mosaic doctor # Standard audit
|
||||
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
|
||||
```
|
||||
|
||||
## Wizard Development
|
||||
|
||||
The installation wizard is a TypeScript project in the root of this repo.
|
||||
|
||||
```bash
|
||||
pnpm install # Install dependencies
|
||||
pnpm dev # Run wizard from source (tsx)
|
||||
pnpm build # Bundle to dist/mosaic-wizard.mjs
|
||||
pnpm test # Run tests (30 tests, vitest)
|
||||
pnpm typecheck # TypeScript type checking
|
||||
```
|
||||
|
||||
The wizard uses `@clack/prompts` for the interactive TUI and supports `--non-interactive` mode via `HeadlessPrompter` for CI and scripted installs. The bundled output (`dist/mosaic-wizard.mjs`) is committed to the repo so installs work without `node_modules`.
|
||||
|
||||
## Re-installing / Updating
|
||||
|
||||
Pull the latest and re-run the installer:
|
||||
|
||||
```bash
|
||||
cd ~/src/mosaic-bootstrap && git pull && bash install.sh
|
||||
```
|
||||
|
||||
If an existing install is detected, the installer prompts for:
|
||||
- `keep` (recommended): preserve local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/`
|
||||
- `overwrite`: replace everything in `~/.config/mosaic`
|
||||
|
||||
Or use the one-liner again — it always pulls the latest:
|
||||
|
||||
```bash
|
||||
curl -sL https://git.mosaicstack.dev/mosaic/bootstrap/raw/branch/main/remote-install.sh | sh
|
||||
```
|
||||
39
packages/mosaic/package.json
Normal file
39
packages/mosaic/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@mosaic/mosaic",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Mosaic installation wizard and meta-package entry point",
|
||||
"bin": {
|
||||
"mosaic-wizard": "./dist/mosaic-wizard.mjs"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.9",
|
||||
"commander": "^13",
|
||||
"picocolors": "^1.1",
|
||||
"yaml": "^2.7",
|
||||
"zod": "^3.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"tsdown": "^0.12",
|
||||
"tsx": "^4",
|
||||
"typescript": "^5",
|
||||
"vitest": "^2"
|
||||
}
|
||||
}
|
||||
26
packages/mosaic/src/config/config-service.ts
Normal file
26
packages/mosaic/src/config/config-service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
||||
import { FileConfigAdapter } from './file-adapter.js';
|
||||
|
||||
/**
|
||||
* ConfigService interface — abstracts config read/write operations.
|
||||
* Currently backed by FileConfigAdapter (writes .md files from templates).
|
||||
* Designed for future swap to SqliteConfigAdapter or PostgresConfigAdapter.
|
||||
*/
|
||||
export interface ConfigService {
|
||||
readSoul(): Promise<SoulConfig>;
|
||||
readUser(): Promise<UserConfig>;
|
||||
readTools(): Promise<ToolsConfig>;
|
||||
|
||||
writeSoul(config: SoulConfig): Promise<void>;
|
||||
writeUser(config: UserConfig): Promise<void>;
|
||||
writeTools(config: ToolsConfig): Promise<void>;
|
||||
|
||||
syncFramework(action: InstallAction): Promise<void>;
|
||||
}
|
||||
|
||||
export function createConfigService(
|
||||
mosaicHome: string,
|
||||
sourceDir: string,
|
||||
): ConfigService {
|
||||
return new FileConfigAdapter(mosaicHome, sourceDir);
|
||||
}
|
||||
163
packages/mosaic/src/config/file-adapter.ts
Normal file
163
packages/mosaic/src/config/file-adapter.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { ConfigService } from './config-service.js';
|
||||
import type {
|
||||
SoulConfig,
|
||||
UserConfig,
|
||||
ToolsConfig,
|
||||
InstallAction,
|
||||
} from '../types.js';
|
||||
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
|
||||
import { renderTemplate } from '../template/engine.js';
|
||||
import {
|
||||
buildSoulTemplateVars,
|
||||
buildUserTemplateVars,
|
||||
buildToolsTemplateVars,
|
||||
} from '../template/builders.js';
|
||||
import { atomicWrite, backupFile, syncDirectory } from '../platform/file-ops.js';
|
||||
|
||||
/**
|
||||
* Parse a SoulConfig from an existing SOUL.md file.
|
||||
*/
|
||||
function parseSoulFromMarkdown(content: string): SoulConfig {
|
||||
const config: SoulConfig = {};
|
||||
|
||||
const nameMatch = content.match(/You are \*\*(.+?)\*\*/);
|
||||
if (nameMatch) config.agentName = nameMatch[1];
|
||||
|
||||
const roleMatch = content.match(/Role identity: (.+)/);
|
||||
if (roleMatch) config.roleDescription = roleMatch[1];
|
||||
|
||||
if (content.includes('Be direct, concise')) {
|
||||
config.communicationStyle = 'direct';
|
||||
} else if (content.includes('Be warm and conversational')) {
|
||||
config.communicationStyle = 'friendly';
|
||||
} else if (content.includes('Use professional, structured')) {
|
||||
config.communicationStyle = 'formal';
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a UserConfig from an existing USER.md file.
|
||||
*/
|
||||
function parseUserFromMarkdown(content: string): UserConfig {
|
||||
const config: UserConfig = {};
|
||||
|
||||
const nameMatch = content.match(/\*\*Name:\*\* (.+)/);
|
||||
if (nameMatch) config.userName = nameMatch[1];
|
||||
|
||||
const pronounsMatch = content.match(/\*\*Pronouns:\*\* (.+)/);
|
||||
if (pronounsMatch) config.pronouns = pronounsMatch[1];
|
||||
|
||||
const tzMatch = content.match(/\*\*Timezone:\*\* (.+)/);
|
||||
if (tzMatch) config.timezone = tzMatch[1];
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ToolsConfig from an existing TOOLS.md file.
|
||||
*/
|
||||
function parseToolsFromMarkdown(content: string): ToolsConfig {
|
||||
const config: ToolsConfig = {};
|
||||
|
||||
const credsMatch = content.match(/\*\*Location:\*\* (.+)/);
|
||||
if (credsMatch) config.credentialsLocation = credsMatch[1];
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export class FileConfigAdapter implements ConfigService {
|
||||
constructor(
|
||||
private mosaicHome: string,
|
||||
private sourceDir: string,
|
||||
) {}
|
||||
|
||||
async readSoul(): Promise<SoulConfig> {
|
||||
const path = join(this.mosaicHome, 'SOUL.md');
|
||||
if (!existsSync(path)) return {};
|
||||
return parseSoulFromMarkdown(readFileSync(path, 'utf-8'));
|
||||
}
|
||||
|
||||
async readUser(): Promise<UserConfig> {
|
||||
const path = join(this.mosaicHome, 'USER.md');
|
||||
if (!existsSync(path)) return {};
|
||||
return parseUserFromMarkdown(readFileSync(path, 'utf-8'));
|
||||
}
|
||||
|
||||
async readTools(): Promise<ToolsConfig> {
|
||||
const path = join(this.mosaicHome, 'TOOLS.md');
|
||||
if (!existsSync(path)) return {};
|
||||
return parseToolsFromMarkdown(readFileSync(path, 'utf-8'));
|
||||
}
|
||||
|
||||
async writeSoul(config: SoulConfig): Promise<void> {
|
||||
const validated = soulSchema.parse(config);
|
||||
const templatePath = this.findTemplate('SOUL.md.template');
|
||||
if (!templatePath) return;
|
||||
|
||||
const template = readFileSync(templatePath, 'utf-8');
|
||||
const vars = buildSoulTemplateVars(validated);
|
||||
const output = renderTemplate(template, vars);
|
||||
|
||||
const outPath = join(this.mosaicHome, 'SOUL.md');
|
||||
backupFile(outPath);
|
||||
atomicWrite(outPath, output);
|
||||
}
|
||||
|
||||
async writeUser(config: UserConfig): Promise<void> {
|
||||
const validated = userSchema.parse(config);
|
||||
const templatePath = this.findTemplate('USER.md.template');
|
||||
if (!templatePath) return;
|
||||
|
||||
const template = readFileSync(templatePath, 'utf-8');
|
||||
const vars = buildUserTemplateVars(validated);
|
||||
const output = renderTemplate(template, vars);
|
||||
|
||||
const outPath = join(this.mosaicHome, 'USER.md');
|
||||
backupFile(outPath);
|
||||
atomicWrite(outPath, output);
|
||||
}
|
||||
|
||||
async writeTools(config: ToolsConfig): Promise<void> {
|
||||
const validated = toolsSchema.parse(config);
|
||||
const templatePath = this.findTemplate('TOOLS.md.template');
|
||||
if (!templatePath) return;
|
||||
|
||||
const template = readFileSync(templatePath, 'utf-8');
|
||||
const vars = buildToolsTemplateVars(validated);
|
||||
const output = renderTemplate(template, vars);
|
||||
|
||||
const outPath = join(this.mosaicHome, 'TOOLS.md');
|
||||
backupFile(outPath);
|
||||
atomicWrite(outPath, output);
|
||||
}
|
||||
|
||||
async syncFramework(action: InstallAction): Promise<void> {
|
||||
const preservePaths =
|
||||
action === 'keep' || action === 'reconfigure'
|
||||
? ['SOUL.md', 'USER.md', 'TOOLS.md', 'memory']
|
||||
: [];
|
||||
|
||||
syncDirectory(this.sourceDir, this.mosaicHome, {
|
||||
preserve: preservePaths,
|
||||
excludeGit: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for template in source dir first, then mosaic home.
|
||||
*/
|
||||
private findTemplate(name: string): string | null {
|
||||
const candidates = [
|
||||
join(this.sourceDir, 'templates', name),
|
||||
join(this.mosaicHome, 'templates', name),
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
51
packages/mosaic/src/config/schemas.ts
Normal file
51
packages/mosaic/src/config/schemas.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const communicationStyleSchema = z
|
||||
.enum(['direct', 'friendly', 'formal'])
|
||||
.default('direct');
|
||||
|
||||
export const soulSchema = z
|
||||
.object({
|
||||
agentName: z.string().min(1).max(50).default('Assistant'),
|
||||
roleDescription: z
|
||||
.string()
|
||||
.default('execution partner and visibility engine'),
|
||||
communicationStyle: communicationStyleSchema,
|
||||
accessibility: z.string().default('none'),
|
||||
customGuardrails: z.string().default(''),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const gitProviderSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
url: z.string().min(1),
|
||||
cli: z.string().min(1),
|
||||
purpose: z.string().min(1),
|
||||
});
|
||||
|
||||
export const userSchema = z
|
||||
.object({
|
||||
userName: z.string().default(''),
|
||||
pronouns: z.string().default('They/Them'),
|
||||
timezone: z.string().default('UTC'),
|
||||
background: z.string().default('(not configured)'),
|
||||
accessibilitySection: z
|
||||
.string()
|
||||
.default(
|
||||
'(No specific accommodations configured. Edit this section to add any.)',
|
||||
),
|
||||
communicationPrefs: z.string().default(''),
|
||||
personalBoundaries: z
|
||||
.string()
|
||||
.default('(Edit this section to add any personal boundaries.)'),
|
||||
projectsTable: z.string().default(''),
|
||||
})
|
||||
.partial();
|
||||
|
||||
export const toolsSchema = z
|
||||
.object({
|
||||
gitProviders: z.array(gitProviderSchema).default([]),
|
||||
credentialsLocation: z.string().default('none'),
|
||||
customToolsSection: z.string().default(''),
|
||||
})
|
||||
.partial();
|
||||
38
packages/mosaic/src/constants.ts
Normal file
38
packages/mosaic/src/constants.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const VERSION = '0.2.0';
|
||||
|
||||
export const DEFAULT_MOSAIC_HOME = join(homedir(), '.config', 'mosaic');
|
||||
|
||||
export const DEFAULTS = {
|
||||
agentName: 'Assistant',
|
||||
roleDescription: 'execution partner and visibility engine',
|
||||
communicationStyle: 'direct' as const,
|
||||
pronouns: 'They/Them',
|
||||
timezone: 'UTC',
|
||||
background: '(not configured)',
|
||||
accessibilitySection: '(No specific accommodations configured. Edit this section to add any.)',
|
||||
personalBoundaries: '(Edit this section to add any personal boundaries.)',
|
||||
projectsTable: `| Project | Stack | Registry |
|
||||
|---------|-------|----------|
|
||||
| (none configured) | | |`,
|
||||
credentialsLocation: 'none',
|
||||
customToolsSection: `## Custom Tools
|
||||
|
||||
(Add any machine-specific tools, scripts, or workflows here.)`,
|
||||
gitProvidersTable: `| Instance | URL | CLI | Purpose |
|
||||
|----------|-----|-----|---------|
|
||||
| (add your git providers here) | | | |`,
|
||||
};
|
||||
|
||||
export const RECOMMENDED_SKILLS = new Set([
|
||||
'brainstorming',
|
||||
'code-review-excellence',
|
||||
'lint',
|
||||
'systematic-debugging',
|
||||
'verification-before-completion',
|
||||
'writing-plans',
|
||||
'executing-plans',
|
||||
'architecture-patterns',
|
||||
]);
|
||||
20
packages/mosaic/src/errors.ts
Normal file
20
packages/mosaic/src/errors.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export class WizardCancelledError extends Error {
|
||||
override name = 'WizardCancelledError';
|
||||
constructor() {
|
||||
super('Wizard cancelled by user');
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
override name = 'ValidationError';
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class TemplateError extends Error {
|
||||
override name = 'TemplateError';
|
||||
constructor(templatePath: string, message: string) {
|
||||
super(`Template error in ${templatePath}: ${message}`);
|
||||
}
|
||||
}
|
||||
81
packages/mosaic/src/index.ts
Normal file
81
packages/mosaic/src/index.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Command } from 'commander';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { ClackPrompter } from './prompter/clack-prompter.js';
|
||||
import { HeadlessPrompter } from './prompter/headless-prompter.js';
|
||||
import { createConfigService } from './config/config-service.js';
|
||||
import { runWizard } from './wizard.js';
|
||||
import { WizardCancelledError } from './errors.js';
|
||||
import { VERSION, DEFAULT_MOSAIC_HOME } from './constants.js';
|
||||
import type { CommunicationStyle } from './types.js';
|
||||
|
||||
const program = new Command()
|
||||
.name('mosaic-wizard')
|
||||
.description('Mosaic Installation Wizard')
|
||||
.version(VERSION);
|
||||
|
||||
program
|
||||
.option('--non-interactive', 'Run without prompts (uses defaults + flags)')
|
||||
.option(
|
||||
'--source-dir <path>',
|
||||
'Source directory for framework files',
|
||||
)
|
||||
.option(
|
||||
'--mosaic-home <path>',
|
||||
'Target config directory',
|
||||
DEFAULT_MOSAIC_HOME,
|
||||
)
|
||||
// SOUL.md overrides
|
||||
.option('--name <name>', 'Agent name')
|
||||
.option('--role <description>', 'Agent role description')
|
||||
.option('--style <style>', 'Communication style: direct|friendly|formal')
|
||||
.option('--accessibility <prefs>', 'Accessibility preferences')
|
||||
.option('--guardrails <rules>', 'Custom guardrails')
|
||||
// USER.md overrides
|
||||
.option('--user-name <name>', 'Your name')
|
||||
.option('--pronouns <pronouns>', 'Your pronouns')
|
||||
.option('--timezone <tz>', 'Your timezone')
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const mosaicHome: string = opts.mosaicHome;
|
||||
const sourceDir: string = opts.sourceDir ?? mosaicHome;
|
||||
|
||||
const prompter = opts.nonInteractive
|
||||
? new HeadlessPrompter()
|
||||
: new ClackPrompter();
|
||||
|
||||
const configService = createConfigService(mosaicHome, sourceDir);
|
||||
|
||||
const style = opts.style as CommunicationStyle | undefined;
|
||||
|
||||
await runWizard({
|
||||
mosaicHome,
|
||||
sourceDir,
|
||||
prompter,
|
||||
configService,
|
||||
cliOverrides: {
|
||||
soul: {
|
||||
agentName: opts.name,
|
||||
roleDescription: opts.role,
|
||||
communicationStyle: style,
|
||||
accessibility: opts.accessibility,
|
||||
customGuardrails: opts.guardrails,
|
||||
},
|
||||
user: {
|
||||
userName: opts.userName,
|
||||
pronouns: opts.pronouns,
|
||||
timezone: opts.timezone,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof WizardCancelledError) {
|
||||
console.log('\nWizard cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
console.error('Wizard failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
44
packages/mosaic/src/platform/detect.ts
Normal file
44
packages/mosaic/src/platform/detect.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir, platform } from 'node:os';
|
||||
|
||||
export type ShellType = 'zsh' | 'bash' | 'fish' | 'powershell' | 'unknown';
|
||||
|
||||
export function detectShell(): ShellType {
|
||||
const shell = process.env.SHELL ?? '';
|
||||
if (shell.includes('zsh')) return 'zsh';
|
||||
if (shell.includes('bash')) return 'bash';
|
||||
if (shell.includes('fish')) return 'fish';
|
||||
if (platform() === 'win32') return 'powershell';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function getShellProfilePath(): string | null {
|
||||
const home = homedir();
|
||||
|
||||
if (platform() === 'win32') {
|
||||
return join(
|
||||
home,
|
||||
'Documents',
|
||||
'PowerShell',
|
||||
'Microsoft.PowerShell_profile.ps1',
|
||||
);
|
||||
}
|
||||
|
||||
const shell = detectShell();
|
||||
switch (shell) {
|
||||
case 'zsh': {
|
||||
const zdotdir = process.env.ZDOTDIR ?? home;
|
||||
return join(zdotdir, '.zshrc');
|
||||
}
|
||||
case 'bash': {
|
||||
const bashrc = join(home, '.bashrc');
|
||||
if (existsSync(bashrc)) return bashrc;
|
||||
return join(home, '.profile');
|
||||
}
|
||||
case 'fish':
|
||||
return join(home, '.config', 'fish', 'config.fish');
|
||||
default:
|
||||
return join(home, '.profile');
|
||||
}
|
||||
}
|
||||
116
packages/mosaic/src/platform/file-ops.ts
Normal file
116
packages/mosaic/src/platform/file-ops.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
copyFileSync,
|
||||
renameSync,
|
||||
readdirSync,
|
||||
unlinkSync,
|
||||
cpSync,
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
|
||||
const MAX_BACKUPS = 3;
|
||||
|
||||
/**
|
||||
* Atomic write: write to temp file, then rename.
|
||||
* Creates parent directories as needed.
|
||||
*/
|
||||
export function atomicWrite(filePath: string, content: string): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const tmpPath = `${filePath}.tmp-${process.pid}`;
|
||||
writeFileSync(tmpPath, content, 'utf-8');
|
||||
renameSync(tmpPath, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup of a file before overwriting.
|
||||
* Rotates backups to keep at most MAX_BACKUPS.
|
||||
*/
|
||||
export function backupFile(filePath: string): string | null {
|
||||
if (!existsSync(filePath)) return null;
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, '')
|
||||
.replace('T', '-')
|
||||
.slice(0, 19);
|
||||
const backupPath = `${filePath}.bak-${timestamp}`;
|
||||
copyFileSync(filePath, backupPath);
|
||||
rotateBackups(filePath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
function rotateBackups(filePath: string): void {
|
||||
const dir = dirname(filePath);
|
||||
const baseName = filePath.split('/').pop()!;
|
||||
const prefix = `${baseName}.bak-`;
|
||||
|
||||
try {
|
||||
const backups = readdirSync(dir)
|
||||
.filter((f) => f.startsWith(prefix))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
for (let i = MAX_BACKUPS; i < backups.length; i++) {
|
||||
unlinkSync(join(dir, backups[i]));
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: backup rotation failure doesn't block writes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a source directory to a target, with optional preserve paths.
|
||||
* Replaces the rsync/cp logic from install.sh.
|
||||
*/
|
||||
export function syncDirectory(
|
||||
source: string,
|
||||
target: string,
|
||||
options: { preserve?: string[]; excludeGit?: boolean } = {},
|
||||
): void {
|
||||
const preserveSet = new Set(options.preserve ?? []);
|
||||
|
||||
// Collect files from source
|
||||
function copyRecursive(src: string, dest: string, relBase: string): void {
|
||||
if (!existsSync(src)) return;
|
||||
|
||||
const stat = statSync(src);
|
||||
if (stat.isDirectory()) {
|
||||
const relPath = relative(relBase, src);
|
||||
|
||||
// Skip .git
|
||||
if (options.excludeGit && relPath === '.git') return;
|
||||
|
||||
// Skip preserved paths at top level
|
||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
|
||||
mkdirSync(dest, { recursive: true });
|
||||
for (const entry of readdirSync(src)) {
|
||||
copyRecursive(join(src, entry), join(dest, entry), relBase);
|
||||
}
|
||||
} else {
|
||||
const relPath = relative(relBase, src);
|
||||
|
||||
// Skip preserved files at top level
|
||||
if (preserveSet.has(relPath) && existsSync(dest)) return;
|
||||
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
copyFileSync(src, dest);
|
||||
}
|
||||
}
|
||||
|
||||
copyRecursive(source, target, source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely read a file, returning null if it doesn't exist.
|
||||
*/
|
||||
export function safeReadFile(filePath: string): string | null {
|
||||
try {
|
||||
return readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
157
packages/mosaic/src/prompter/clack-prompter.ts
Normal file
157
packages/mosaic/src/prompter/clack-prompter.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import * as p from '@clack/prompts';
|
||||
import { WizardCancelledError } from '../errors.js';
|
||||
import type {
|
||||
WizardPrompter,
|
||||
SelectOption,
|
||||
MultiSelectOption,
|
||||
ProgressHandle,
|
||||
} from './interface.js';
|
||||
|
||||
function guardCancel<T>(value: T | symbol): T {
|
||||
if (p.isCancel(value)) {
|
||||
throw new WizardCancelledError();
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export class ClackPrompter implements WizardPrompter {
|
||||
intro(message: string): void {
|
||||
p.intro(message);
|
||||
}
|
||||
|
||||
outro(message: string): void {
|
||||
p.outro(message);
|
||||
}
|
||||
|
||||
note(message: string, title?: string): void {
|
||||
p.note(message, title);
|
||||
}
|
||||
|
||||
log(message: string): void {
|
||||
p.log.info(message);
|
||||
}
|
||||
|
||||
warn(message: string): void {
|
||||
p.log.warn(message);
|
||||
}
|
||||
|
||||
async text(opts: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string> {
|
||||
const validate = opts.validate
|
||||
? (v: string) => {
|
||||
const r = opts.validate!(v);
|
||||
return r === undefined ? undefined : r;
|
||||
}
|
||||
: undefined;
|
||||
const result = await p.text({
|
||||
message: opts.message,
|
||||
placeholder: opts.placeholder,
|
||||
defaultValue: opts.defaultValue,
|
||||
validate,
|
||||
});
|
||||
return guardCancel(result);
|
||||
}
|
||||
|
||||
async confirm(opts: {
|
||||
message: string;
|
||||
initialValue?: boolean;
|
||||
}): Promise<boolean> {
|
||||
const result = await p.confirm({
|
||||
message: opts.message,
|
||||
initialValue: opts.initialValue,
|
||||
});
|
||||
return guardCancel(result);
|
||||
}
|
||||
|
||||
async select<T>(opts: {
|
||||
message: string;
|
||||
options: SelectOption<T>[];
|
||||
initialValue?: T;
|
||||
}): Promise<T> {
|
||||
const clackOptions = opts.options.map((o) => ({
|
||||
value: o.value as T,
|
||||
label: o.label,
|
||||
hint: o.hint,
|
||||
}));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- clack Option conditional type needs concrete Primitive
|
||||
const result = await p.select({
|
||||
message: opts.message,
|
||||
options: clackOptions as any,
|
||||
initialValue: opts.initialValue,
|
||||
});
|
||||
return guardCancel(result) as T;
|
||||
}
|
||||
|
||||
async multiselect<T>(opts: {
|
||||
message: string;
|
||||
options: MultiSelectOption<T>[];
|
||||
required?: boolean;
|
||||
}): Promise<T[]> {
|
||||
const clackOptions = opts.options.map((o) => ({
|
||||
value: o.value as T,
|
||||
label: o.label,
|
||||
hint: o.hint,
|
||||
}));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await p.multiselect({
|
||||
message: opts.message,
|
||||
options: clackOptions as any,
|
||||
required: opts.required,
|
||||
initialValues: opts.options
|
||||
.filter((o) => o.selected)
|
||||
.map((o) => o.value),
|
||||
});
|
||||
return guardCancel(result) as T[];
|
||||
}
|
||||
|
||||
async groupMultiselect<T>(opts: {
|
||||
message: string;
|
||||
options: Record<string, MultiSelectOption<T>[]>;
|
||||
required?: boolean;
|
||||
}): Promise<T[]> {
|
||||
const grouped: Record<string, { value: T; label: string; hint?: string }[]> = {};
|
||||
for (const [group, items] of Object.entries(opts.options)) {
|
||||
grouped[group] = items.map((o) => ({
|
||||
value: o.value as T,
|
||||
label: o.label,
|
||||
hint: o.hint,
|
||||
}));
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await p.groupMultiselect({
|
||||
message: opts.message,
|
||||
options: grouped as any,
|
||||
required: opts.required,
|
||||
});
|
||||
return guardCancel(result) as T[];
|
||||
}
|
||||
|
||||
spinner(): ProgressHandle {
|
||||
const s = p.spinner();
|
||||
let started = false;
|
||||
return {
|
||||
update(message: string) {
|
||||
if (!started) {
|
||||
s.start(message);
|
||||
started = true;
|
||||
} else {
|
||||
s.message(message);
|
||||
}
|
||||
},
|
||||
stop(message?: string) {
|
||||
if (started) {
|
||||
s.stop(message);
|
||||
started = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
separator(): void {
|
||||
p.log.info('');
|
||||
}
|
||||
}
|
||||
133
packages/mosaic/src/prompter/headless-prompter.ts
Normal file
133
packages/mosaic/src/prompter/headless-prompter.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
WizardPrompter,
|
||||
SelectOption,
|
||||
MultiSelectOption,
|
||||
ProgressHandle,
|
||||
} from './interface.js';
|
||||
|
||||
export type AnswerValue = string | boolean | string[];
|
||||
|
||||
export class HeadlessPrompter implements WizardPrompter {
|
||||
private answers: Map<string, AnswerValue>;
|
||||
private logs: string[] = [];
|
||||
|
||||
constructor(answers: Record<string, AnswerValue> = {}) {
|
||||
this.answers = new Map(Object.entries(answers));
|
||||
}
|
||||
|
||||
intro(message: string): void {
|
||||
this.logs.push(`[intro] ${message}`);
|
||||
}
|
||||
outro(message: string): void {
|
||||
this.logs.push(`[outro] ${message}`);
|
||||
}
|
||||
note(message: string, title?: string): void {
|
||||
this.logs.push(`[note] ${title ?? ''}: ${message}`);
|
||||
}
|
||||
log(message: string): void {
|
||||
this.logs.push(`[log] ${message}`);
|
||||
}
|
||||
warn(message: string): void {
|
||||
this.logs.push(`[warn] ${message}`);
|
||||
}
|
||||
|
||||
async text(opts: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
const value =
|
||||
typeof answer === 'string'
|
||||
? answer
|
||||
: opts.defaultValue !== undefined
|
||||
? opts.defaultValue
|
||||
: undefined;
|
||||
|
||||
if (value === undefined) {
|
||||
throw new Error(`HeadlessPrompter: no answer for "${opts.message}"`);
|
||||
}
|
||||
|
||||
if (opts.validate) {
|
||||
const error = opts.validate(value);
|
||||
if (error) throw new Error(`HeadlessPrompter validation failed for "${opts.message}": ${error}`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
async confirm(opts: {
|
||||
message: string;
|
||||
initialValue?: boolean;
|
||||
}): Promise<boolean> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
if (typeof answer === 'boolean') return answer;
|
||||
return opts.initialValue ?? true;
|
||||
}
|
||||
|
||||
async select<T>(opts: {
|
||||
message: string;
|
||||
options: SelectOption<T>[];
|
||||
initialValue?: T;
|
||||
}): Promise<T> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
if (answer !== undefined) {
|
||||
// Find matching option by value string comparison
|
||||
const match = opts.options.find(
|
||||
(o) => String(o.value) === String(answer),
|
||||
);
|
||||
if (match) return match.value;
|
||||
}
|
||||
if (opts.initialValue !== undefined) return opts.initialValue;
|
||||
if (opts.options.length === 0) {
|
||||
throw new Error(`HeadlessPrompter: no options for "${opts.message}"`);
|
||||
}
|
||||
return opts.options[0].value;
|
||||
}
|
||||
|
||||
async multiselect<T>(opts: {
|
||||
message: string;
|
||||
options: MultiSelectOption<T>[];
|
||||
required?: boolean;
|
||||
}): Promise<T[]> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
if (Array.isArray(answer)) {
|
||||
return opts.options
|
||||
.filter((o) => answer.includes(String(o.value)))
|
||||
.map((o) => o.value);
|
||||
}
|
||||
return opts.options.filter((o) => o.selected).map((o) => o.value);
|
||||
}
|
||||
|
||||
async groupMultiselect<T>(opts: {
|
||||
message: string;
|
||||
options: Record<string, MultiSelectOption<T>[]>;
|
||||
required?: boolean;
|
||||
}): Promise<T[]> {
|
||||
const answer = this.answers.get(opts.message);
|
||||
if (Array.isArray(answer)) {
|
||||
const all = Object.values(opts.options).flat();
|
||||
return all
|
||||
.filter((o) => answer.includes(String(o.value)))
|
||||
.map((o) => o.value);
|
||||
}
|
||||
return Object.values(opts.options)
|
||||
.flat()
|
||||
.filter((o) => o.selected)
|
||||
.map((o) => o.value);
|
||||
}
|
||||
|
||||
spinner(): ProgressHandle {
|
||||
return {
|
||||
update(_message: string) {},
|
||||
stop(_message?: string) {},
|
||||
};
|
||||
}
|
||||
|
||||
separator(): void {}
|
||||
|
||||
getLogs(): string[] {
|
||||
return [...this.logs];
|
||||
}
|
||||
}
|
||||
56
packages/mosaic/src/prompter/interface.ts
Normal file
56
packages/mosaic/src/prompter/interface.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export interface SelectOption<T = string> {
|
||||
value: T;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface MultiSelectOption<T = string> extends SelectOption<T> {
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export interface ProgressHandle {
|
||||
update(message: string): void;
|
||||
stop(message?: string): void;
|
||||
}
|
||||
|
||||
export interface WizardPrompter {
|
||||
intro(message: string): void;
|
||||
outro(message: string): void;
|
||||
note(message: string, title?: string): void;
|
||||
log(message: string): void;
|
||||
warn(message: string): void;
|
||||
|
||||
text(opts: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
validate?: (value: string) => string | void;
|
||||
}): Promise<string>;
|
||||
|
||||
confirm(opts: {
|
||||
message: string;
|
||||
initialValue?: boolean;
|
||||
}): Promise<boolean>;
|
||||
|
||||
select<T>(opts: {
|
||||
message: string;
|
||||
options: SelectOption<T>[];
|
||||
initialValue?: T;
|
||||
}): Promise<T>;
|
||||
|
||||
multiselect<T>(opts: {
|
||||
message: string;
|
||||
options: MultiSelectOption<T>[];
|
||||
required?: boolean;
|
||||
}): Promise<T[]>;
|
||||
|
||||
groupMultiselect<T>(opts: {
|
||||
message: string;
|
||||
options: Record<string, MultiSelectOption<T>[]>;
|
||||
required?: boolean;
|
||||
}): Promise<T[]>;
|
||||
|
||||
spinner(): ProgressHandle;
|
||||
|
||||
separator(): void;
|
||||
}
|
||||
83
packages/mosaic/src/runtime/detector.ts
Normal file
83
packages/mosaic/src/runtime/detector.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { platform } from 'node:os';
|
||||
import type { RuntimeName } from '../types.js';
|
||||
|
||||
export interface RuntimeInfo {
|
||||
name: RuntimeName;
|
||||
label: string;
|
||||
installed: boolean;
|
||||
path?: string;
|
||||
version?: string;
|
||||
installHint: string;
|
||||
}
|
||||
|
||||
const RUNTIME_DEFS: Record<
|
||||
RuntimeName,
|
||||
{ label: string; command: string; versionFlag: string; installHint: string }
|
||||
> = {
|
||||
claude: {
|
||||
label: 'Claude Code',
|
||||
command: 'claude',
|
||||
versionFlag: '--version',
|
||||
installHint: 'npm install -g @anthropic-ai/claude-code',
|
||||
},
|
||||
codex: {
|
||||
label: 'Codex',
|
||||
command: 'codex',
|
||||
versionFlag: '--version',
|
||||
installHint: 'npm install -g @openai/codex',
|
||||
},
|
||||
opencode: {
|
||||
label: 'OpenCode',
|
||||
command: 'opencode',
|
||||
versionFlag: 'version',
|
||||
installHint: 'See https://opencode.ai for install instructions',
|
||||
},
|
||||
};
|
||||
|
||||
export function detectRuntime(name: RuntimeName): RuntimeInfo {
|
||||
const def = RUNTIME_DEFS[name];
|
||||
const isWindows = platform() === 'win32';
|
||||
const whichCmd = isWindows
|
||||
? `where ${def.command} 2>nul`
|
||||
: `which ${def.command} 2>/dev/null`;
|
||||
|
||||
try {
|
||||
const path = execSync(whichCmd, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
})
|
||||
.trim()
|
||||
.split('\n')[0];
|
||||
|
||||
let version: string | undefined;
|
||||
try {
|
||||
version = execSync(`${def.command} ${def.versionFlag} 2>/dev/null`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
} catch {
|
||||
// Version detection is optional
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
label: def.label,
|
||||
installed: true,
|
||||
path,
|
||||
version,
|
||||
installHint: def.installHint,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name,
|
||||
label: def.label,
|
||||
installed: false,
|
||||
installHint: def.installHint,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getInstallInstructions(name: RuntimeName): string {
|
||||
return RUNTIME_DEFS[name].installHint;
|
||||
}
|
||||
12
packages/mosaic/src/runtime/installer.ts
Normal file
12
packages/mosaic/src/runtime/installer.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { RuntimeName } from '../types.js';
|
||||
import { getInstallInstructions } from './detector.js';
|
||||
|
||||
export function formatInstallInstructions(name: RuntimeName): string {
|
||||
const hint = getInstallInstructions(name);
|
||||
const labels: Record<RuntimeName, string> = {
|
||||
claude: 'Claude Code',
|
||||
codex: 'Codex',
|
||||
opencode: 'OpenCode',
|
||||
};
|
||||
return `To install ${labels[name]}:\n ${hint}`;
|
||||
}
|
||||
112
packages/mosaic/src/runtime/mcp-config.ts
Normal file
112
packages/mosaic/src/runtime/mcp-config.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { RuntimeName } from '../types.js';
|
||||
|
||||
const MCP_ENTRY = {
|
||||
command: 'npx',
|
||||
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||
};
|
||||
|
||||
export function configureMcpForRuntime(runtime: RuntimeName): void {
|
||||
switch (runtime) {
|
||||
case 'claude':
|
||||
return configureClaudeMcp();
|
||||
case 'codex':
|
||||
return configureCodexMcp();
|
||||
case 'opencode':
|
||||
return configureOpenCodeMcp();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(filePath: string): void {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
function configureClaudeMcp(): void {
|
||||
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||
ensureDir(settingsPath);
|
||||
|
||||
let data: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
data = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
// Start fresh if corrupt
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!data.mcpServers ||
|
||||
typeof data.mcpServers !== 'object' ||
|
||||
Array.isArray(data.mcpServers)
|
||||
) {
|
||||
data.mcpServers = {};
|
||||
}
|
||||
(data.mcpServers as Record<string, unknown>)['sequential-thinking'] =
|
||||
MCP_ENTRY;
|
||||
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(data, null, 2) + '\n',
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
function configureCodexMcp(): void {
|
||||
const configPath = join(homedir(), '.codex', 'config.toml');
|
||||
ensureDir(configPath);
|
||||
|
||||
let content = '';
|
||||
if (existsSync(configPath)) {
|
||||
content = readFileSync(configPath, 'utf-8');
|
||||
// Remove existing sequential-thinking section
|
||||
content = content
|
||||
.replace(
|
||||
/\[mcp_servers\.(sequential-thinking|sequential_thinking)\][\s\S]*?(?=\n\[|$)/g,
|
||||
'',
|
||||
)
|
||||
.trim();
|
||||
}
|
||||
|
||||
content +=
|
||||
'\n\n[mcp_servers.sequential-thinking]\n' +
|
||||
'command = "npx"\n' +
|
||||
'args = ["-y", "@modelcontextprotocol/server-sequential-thinking"]\n';
|
||||
|
||||
writeFileSync(configPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
function configureOpenCodeMcp(): void {
|
||||
const configPath = join(
|
||||
homedir(),
|
||||
'.config',
|
||||
'opencode',
|
||||
'config.json',
|
||||
);
|
||||
ensureDir(configPath);
|
||||
|
||||
let data: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
data = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
} catch {
|
||||
// Start fresh
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.mcp || typeof data.mcp !== 'object' || Array.isArray(data.mcp)) {
|
||||
data.mcp = {};
|
||||
}
|
||||
(data.mcp as Record<string, unknown>)['sequential-thinking'] = {
|
||||
type: 'local',
|
||||
command: ['npx', '-y', '@modelcontextprotocol/server-sequential-thinking'],
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(data, null, 2) + '\n',
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
99
packages/mosaic/src/skills/catalog.ts
Normal file
99
packages/mosaic/src/skills/catalog.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { RECOMMENDED_SKILLS } from '../constants.js';
|
||||
|
||||
export interface SkillEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
version?: string;
|
||||
recommended: boolean;
|
||||
source: 'canonical' | 'local';
|
||||
}
|
||||
|
||||
export function loadSkillsCatalog(mosaicHome: string): SkillEntry[] {
|
||||
const skills: SkillEntry[] = [];
|
||||
|
||||
// Load canonical skills
|
||||
const canonicalDir = join(mosaicHome, 'skills');
|
||||
if (existsSync(canonicalDir)) {
|
||||
skills.push(...loadSkillsFromDir(canonicalDir, 'canonical'));
|
||||
}
|
||||
|
||||
// Fallback to source repo
|
||||
const sourceDir = join(mosaicHome, 'sources', 'agent-skills', 'skills');
|
||||
if (skills.length === 0 && existsSync(sourceDir)) {
|
||||
skills.push(...loadSkillsFromDir(sourceDir, 'canonical'));
|
||||
}
|
||||
|
||||
// Load local skills
|
||||
const localDir = join(mosaicHome, 'skills-local');
|
||||
if (existsSync(localDir)) {
|
||||
skills.push(...loadSkillsFromDir(localDir, 'local'));
|
||||
}
|
||||
|
||||
return skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function loadSkillsFromDir(
|
||||
dir: string,
|
||||
source: 'canonical' | 'local',
|
||||
): SkillEntry[] {
|
||||
const entries: SkillEntry[] = [];
|
||||
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return entries;
|
||||
}
|
||||
|
||||
for (const entry of dirEntries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
||||
|
||||
const skillMdPath = join(dir, entry.name, 'SKILL.md');
|
||||
if (!existsSync(skillMdPath)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, 'utf-8');
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
|
||||
entries.push({
|
||||
name: (frontmatter.name as string) ?? entry.name,
|
||||
description: (frontmatter.description as string) ?? '',
|
||||
version: frontmatter.version as string | undefined,
|
||||
recommended: RECOMMENDED_SKILLS.has(entry.name),
|
||||
source,
|
||||
});
|
||||
} catch {
|
||||
// Skip malformed skills
|
||||
entries.push({
|
||||
name: entry.name,
|
||||
description: '',
|
||||
recommended: RECOMMENDED_SKILLS.has(entry.name),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): Record<string, unknown> {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return {};
|
||||
|
||||
try {
|
||||
return (parseYaml(match[1]) as Record<string, unknown>) ?? {};
|
||||
} catch {
|
||||
// Fallback: simple key-value parsing
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of match[1].split('\n')) {
|
||||
const kv = line.match(/^(\w[\w-]*)\s*:\s*(.+)/);
|
||||
if (kv) {
|
||||
result[kv[1]] = kv[2].replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
86
packages/mosaic/src/skills/categories.ts
Normal file
86
packages/mosaic/src/skills/categories.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Skill category definitions and mapping.
|
||||
* Skills are assigned to categories by name, with keyword fallback.
|
||||
*/
|
||||
|
||||
export const SKILL_CATEGORIES: Record<string, string[]> = {
|
||||
'Frontend & UI': [
|
||||
'ai-sdk', 'algorithmic-art', 'antfu', 'canvas-design', 'frontend-design',
|
||||
'next-best-practices', 'nuxt', 'pinia', 'shadcn-ui', 'slidev',
|
||||
'tailwind-design-system', 'theme-factory', 'ui-animation', 'unocss',
|
||||
'vercel-composition-patterns', 'vercel-react-best-practices',
|
||||
'vercel-react-native-skills', 'vue', 'vue-best-practices',
|
||||
'vue-router-best-practices', 'vueuse-functions', 'web-artifacts-builder',
|
||||
'web-design-guidelines', 'vite', 'vitepress',
|
||||
],
|
||||
'Backend & Infrastructure': [
|
||||
'architecture-patterns', 'fastapi', 'mcp-builder', 'nestjs-best-practices',
|
||||
'python-performance-optimization', 'tsdown', 'turborepo', 'pnpm',
|
||||
'dispatching-parallel-agents', 'subagent-driven-development', 'create-agent',
|
||||
'proactive-agent', 'using-superpowers', 'kickstart', 'executing-plans',
|
||||
],
|
||||
'Testing & Quality': [
|
||||
'code-review-excellence', 'lint', 'pr-reviewer', 'receiving-code-review',
|
||||
'requesting-code-review', 'systematic-debugging', 'test-driven-development',
|
||||
'verification-before-completion', 'vitest', 'vue-testing-best-practices',
|
||||
'webapp-testing',
|
||||
],
|
||||
'Marketing & Growth': [
|
||||
'ab-test-setup', 'analytics-tracking', 'competitor-alternatives',
|
||||
'copy-editing', 'copywriting', 'email-sequence', 'form-cro',
|
||||
'free-tool-strategy', 'launch-strategy', 'marketing-ideas',
|
||||
'marketing-psychology', 'onboarding-cro', 'page-cro', 'paid-ads',
|
||||
'paywall-upgrade-cro', 'popup-cro', 'pricing-strategy',
|
||||
'product-marketing-context', 'programmatic-seo', 'referral-program',
|
||||
'schema-markup', 'seo-audit', 'signup-flow-cro', 'social-content',
|
||||
],
|
||||
'Product & Strategy': [
|
||||
'brainstorming', 'brand-guidelines', 'content-strategy',
|
||||
'writing-plans', 'skill-creator', 'writing-skills', 'prd',
|
||||
],
|
||||
'Developer Practices': [
|
||||
'finishing-a-development-branch', 'using-git-worktrees',
|
||||
],
|
||||
'Auth & Security': [
|
||||
'better-auth-best-practices', 'create-auth-skill',
|
||||
'email-and-password-best-practices', 'organization-best-practices',
|
||||
'two-factor-authentication-best-practices',
|
||||
],
|
||||
'Content & Documentation': [
|
||||
'doc-coauthoring', 'docx', 'internal-comms', 'pdf', 'pptx',
|
||||
'slack-gif-creator', 'xlsx',
|
||||
],
|
||||
};
|
||||
|
||||
// Reverse lookup: skill name -> category
|
||||
const SKILL_TO_CATEGORY = new Map<string, string>();
|
||||
for (const [category, skills] of Object.entries(SKILL_CATEGORIES)) {
|
||||
for (const skill of skills) {
|
||||
SKILL_TO_CATEGORY.set(skill, category);
|
||||
}
|
||||
}
|
||||
|
||||
export function categorizeSkill(name: string, description: string): string {
|
||||
const mapped = SKILL_TO_CATEGORY.get(name);
|
||||
if (mapped) return mapped;
|
||||
return inferCategoryFromDescription(description);
|
||||
}
|
||||
|
||||
function inferCategoryFromDescription(desc: string): string {
|
||||
const lower = desc.toLowerCase();
|
||||
if (/\b(react|vue|css|frontend|ui|component|tailwind|design)\b/.test(lower))
|
||||
return 'Frontend & UI';
|
||||
if (/\b(api|backend|server|docker|infra|deploy)\b/.test(lower))
|
||||
return 'Backend & Infrastructure';
|
||||
if (/\b(test|lint|review|debug|quality)\b/.test(lower))
|
||||
return 'Testing & Quality';
|
||||
if (/\b(marketing|seo|copy|ads|cro|conversion|email)\b/.test(lower))
|
||||
return 'Marketing & Growth';
|
||||
if (/\b(auth|security|2fa|password|credential)\b/.test(lower))
|
||||
return 'Auth & Security';
|
||||
if (/\b(doc|pdf|word|sheet|writing|comms)\b/.test(lower))
|
||||
return 'Content & Documentation';
|
||||
if (/\b(product|strategy|brainstorm|plan|prd)\b/.test(lower))
|
||||
return 'Product & Strategy';
|
||||
return 'Developer Practices';
|
||||
}
|
||||
95
packages/mosaic/src/stages/detect-install.ts
Normal file
95
packages/mosaic/src/stages/detect-install.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState, InstallAction } from '../types.js';
|
||||
|
||||
function detectExistingInstall(mosaicHome: string): boolean {
|
||||
if (!existsSync(mosaicHome)) return false;
|
||||
return (
|
||||
existsSync(join(mosaicHome, 'bin/mosaic')) ||
|
||||
existsSync(join(mosaicHome, 'AGENTS.md')) ||
|
||||
existsSync(join(mosaicHome, 'SOUL.md'))
|
||||
);
|
||||
}
|
||||
|
||||
function detectExistingIdentity(mosaicHome: string): {
|
||||
hasSoul: boolean;
|
||||
hasUser: boolean;
|
||||
hasTools: boolean;
|
||||
agentName?: string;
|
||||
} {
|
||||
const soulPath = join(mosaicHome, 'SOUL.md');
|
||||
const hasSoul = existsSync(soulPath);
|
||||
let agentName: string | undefined;
|
||||
|
||||
if (hasSoul) {
|
||||
try {
|
||||
const content = readFileSync(soulPath, 'utf-8');
|
||||
const match = content.match(/You are \*\*(.+?)\*\*/);
|
||||
agentName = match?.[1];
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasSoul,
|
||||
hasUser: existsSync(join(mosaicHome, 'USER.md')),
|
||||
hasTools: existsSync(join(mosaicHome, 'TOOLS.md')),
|
||||
agentName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectInstallStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
config: ConfigService,
|
||||
): Promise<void> {
|
||||
const existing = detectExistingInstall(state.mosaicHome);
|
||||
if (!existing) {
|
||||
state.installAction = 'fresh';
|
||||
return;
|
||||
}
|
||||
|
||||
const identity = detectExistingIdentity(state.mosaicHome);
|
||||
const identitySummary = identity.agentName
|
||||
? `Agent: ${identity.agentName}`
|
||||
: 'No identity configured';
|
||||
|
||||
p.note(
|
||||
`Found existing Mosaic installation at:\n${state.mosaicHome}\n\n` +
|
||||
`${identitySummary}\n` +
|
||||
`SOUL.md: ${identity.hasSoul ? 'yes' : 'no'}\n` +
|
||||
`USER.md: ${identity.hasUser ? 'yes' : 'no'}\n` +
|
||||
`TOOLS.md: ${identity.hasTools ? 'yes' : 'no'}`,
|
||||
'Existing Installation Detected',
|
||||
);
|
||||
|
||||
state.installAction = await p.select<InstallAction>({
|
||||
message: 'What would you like to do?',
|
||||
options: [
|
||||
{
|
||||
value: 'keep',
|
||||
label: 'Keep identity, update framework',
|
||||
hint: 'Preserves SOUL.md, USER.md, TOOLS.md, memory/',
|
||||
},
|
||||
{
|
||||
value: 'reconfigure',
|
||||
label: 'Reconfigure identity',
|
||||
hint: 'Re-run identity setup, update framework',
|
||||
},
|
||||
{
|
||||
value: 'reset',
|
||||
label: 'Fresh install',
|
||||
hint: 'Replace everything',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (state.installAction === 'keep') {
|
||||
state.soul = await config.readSoul();
|
||||
state.user = await config.readUser();
|
||||
state.tools = await config.readTools();
|
||||
}
|
||||
}
|
||||
177
packages/mosaic/src/stages/finalize.ts
Normal file
177
packages/mosaic/src/stages/finalize.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { platform } from 'node:os';
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { ConfigService } from '../config/config-service.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { getShellProfilePath } from '../platform/detect.js';
|
||||
|
||||
function linkRuntimeAssets(mosaicHome: string): void {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
||||
if (existsSync(script)) {
|
||||
try {
|
||||
spawnSync('bash', [script], { timeout: 30000, stdio: 'pipe' });
|
||||
} catch {
|
||||
// Non-fatal: wizard continues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncSkills(mosaicHome: string): void {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
|
||||
if (existsSync(script)) {
|
||||
try {
|
||||
spawnSync('bash', [script], { timeout: 60000, stdio: 'pipe' });
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface DoctorResult {
|
||||
warnings: number;
|
||||
output: string;
|
||||
}
|
||||
|
||||
function runDoctor(mosaicHome: string): DoctorResult {
|
||||
const script = join(mosaicHome, 'bin', 'mosaic-doctor');
|
||||
if (!existsSync(script)) {
|
||||
return { warnings: 0, output: 'mosaic-doctor not found' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync('bash', [script], {
|
||||
timeout: 30000,
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const output = result.stdout ?? '';
|
||||
const warnings = (output.match(/WARN/g) ?? []).length;
|
||||
return { warnings, output };
|
||||
} catch {
|
||||
return { warnings: 1, output: 'Doctor check failed' };
|
||||
}
|
||||
}
|
||||
|
||||
type PathAction = 'already' | 'added' | 'skipped';
|
||||
|
||||
function setupPath(
|
||||
mosaicHome: string,
|
||||
p: WizardPrompter,
|
||||
): PathAction {
|
||||
const binDir = join(mosaicHome, 'bin');
|
||||
const currentPath = process.env.PATH ?? '';
|
||||
|
||||
if (currentPath.includes(binDir)) {
|
||||
return 'already';
|
||||
}
|
||||
|
||||
const profilePath = getShellProfilePath();
|
||||
if (!profilePath) return 'skipped';
|
||||
|
||||
const isWindows = platform() === 'win32';
|
||||
const exportLine = isWindows
|
||||
? `\n# Mosaic\n$env:Path = "${binDir};$env:Path"\n`
|
||||
: `\n# Mosaic\nexport PATH="${binDir}:$PATH"\n`;
|
||||
|
||||
// Check if already in profile
|
||||
if (existsSync(profilePath)) {
|
||||
const content = readFileSync(profilePath, 'utf-8');
|
||||
if (content.includes(binDir)) {
|
||||
return 'already';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
appendFileSync(profilePath, exportLine, 'utf-8');
|
||||
return 'added';
|
||||
} catch {
|
||||
return 'skipped';
|
||||
}
|
||||
}
|
||||
|
||||
export async function finalizeStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
config: ConfigService,
|
||||
): Promise<void> {
|
||||
p.separator();
|
||||
|
||||
const spin = p.spinner();
|
||||
|
||||
// 1. Sync framework files (before config writes so identity files aren't overwritten)
|
||||
spin.update('Syncing framework files...');
|
||||
await config.syncFramework(state.installAction);
|
||||
|
||||
// 2. Write config files (after sync so they aren't overwritten by source templates)
|
||||
if (state.installAction !== 'keep') {
|
||||
spin.update('Writing configuration files...');
|
||||
await config.writeSoul(state.soul);
|
||||
await config.writeUser(state.user);
|
||||
await config.writeTools(state.tools);
|
||||
}
|
||||
|
||||
// 3. Link runtime assets
|
||||
spin.update('Linking runtime assets...');
|
||||
linkRuntimeAssets(state.mosaicHome);
|
||||
|
||||
// 4. Sync skills
|
||||
if (state.selectedSkills.length > 0) {
|
||||
spin.update('Syncing skills...');
|
||||
syncSkills(state.mosaicHome);
|
||||
}
|
||||
|
||||
// 5. Run doctor
|
||||
spin.update('Running health audit...');
|
||||
const doctorResult = runDoctor(state.mosaicHome);
|
||||
|
||||
spin.stop('Installation complete');
|
||||
|
||||
// 6. PATH setup
|
||||
const pathAction = setupPath(state.mosaicHome, p);
|
||||
|
||||
// 7. Summary
|
||||
const summary: string[] = [
|
||||
`Agent: ${state.soul.agentName ?? 'Assistant'}`,
|
||||
`Style: ${state.soul.communicationStyle ?? 'direct'}`,
|
||||
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
|
||||
`Skills: ${state.selectedSkills.length} selected`,
|
||||
`Config: ${state.mosaicHome}`,
|
||||
];
|
||||
|
||||
if (doctorResult.warnings > 0) {
|
||||
summary.push(
|
||||
`Health: ${doctorResult.warnings} warning(s) — run 'mosaic doctor' for details`,
|
||||
);
|
||||
} else {
|
||||
summary.push('Health: all checks passed');
|
||||
}
|
||||
|
||||
p.note(summary.join('\n'), 'Installation Summary');
|
||||
|
||||
// 8. Next steps
|
||||
const nextSteps: string[] = [];
|
||||
if (pathAction === 'added') {
|
||||
const profilePath = getShellProfilePath();
|
||||
nextSteps.push(
|
||||
`Reload shell: source ${profilePath ?? '~/.profile'}`,
|
||||
);
|
||||
}
|
||||
if (state.runtimes.detected.length === 0) {
|
||||
nextSteps.push(
|
||||
'Install at least one runtime (claude, codex, or opencode)',
|
||||
);
|
||||
}
|
||||
nextSteps.push("Launch with 'mosaic claude' (or codex/opencode)");
|
||||
nextSteps.push(
|
||||
'Edit identity files directly in ~/.config/mosaic/ for fine-tuning',
|
||||
);
|
||||
|
||||
p.note(
|
||||
nextSteps.map((s, i) => `${i + 1}. ${s}`).join('\n'),
|
||||
'Next Steps',
|
||||
);
|
||||
|
||||
p.outro('Mosaic is ready.');
|
||||
}
|
||||
23
packages/mosaic/src/stages/mode-select.ts
Normal file
23
packages/mosaic/src/stages/mode-select.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState, WizardMode } from '../types.js';
|
||||
|
||||
export async function modeSelectStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
): Promise<void> {
|
||||
state.mode = await p.select<WizardMode>({
|
||||
message: 'Installation mode',
|
||||
options: [
|
||||
{
|
||||
value: 'quick',
|
||||
label: 'Quick Start',
|
||||
hint: 'Sensible defaults, minimal questions (~2 min)',
|
||||
},
|
||||
{
|
||||
value: 'advanced',
|
||||
label: 'Advanced',
|
||||
hint: 'Full customization of identity, runtimes, and skills',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
70
packages/mosaic/src/stages/runtime-setup.ts
Normal file
70
packages/mosaic/src/stages/runtime-setup.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState, RuntimeName } from '../types.js';
|
||||
import { detectRuntime, type RuntimeInfo } from '../runtime/detector.js';
|
||||
import { formatInstallInstructions } from '../runtime/installer.js';
|
||||
import { configureMcpForRuntime } from '../runtime/mcp-config.js';
|
||||
|
||||
const RUNTIME_NAMES: RuntimeName[] = ['claude', 'codex', 'opencode'];
|
||||
|
||||
export async function runtimeSetupStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
): Promise<void> {
|
||||
p.separator();
|
||||
|
||||
const spin = p.spinner();
|
||||
spin.update('Detecting installed runtimes...');
|
||||
|
||||
const runtimes: RuntimeInfo[] = RUNTIME_NAMES.map(detectRuntime);
|
||||
|
||||
spin.stop('Runtime detection complete');
|
||||
|
||||
const detected = runtimes.filter((r) => r.installed);
|
||||
const notDetected = runtimes.filter((r) => !r.installed);
|
||||
|
||||
if (detected.length > 0) {
|
||||
const summary = detected
|
||||
.map(
|
||||
(r) =>
|
||||
` ${r.label}: ${r.version ?? 'installed'} (${r.path})`,
|
||||
)
|
||||
.join('\n');
|
||||
p.note(summary, 'Detected Runtimes');
|
||||
} else {
|
||||
p.warn('No runtimes detected. Install at least one to use Mosaic.');
|
||||
}
|
||||
|
||||
state.runtimes.detected = detected.map((r) => r.name);
|
||||
|
||||
// Offer installation info for missing runtimes in advanced mode
|
||||
if (state.mode === 'advanced' && notDetected.length > 0) {
|
||||
const showInstall = await p.confirm({
|
||||
message: `${notDetected.length} runtime(s) not found. Show install instructions?`,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
if (showInstall) {
|
||||
for (const rt of notDetected) {
|
||||
p.note(formatInstallInstructions(rt.name), `Install ${rt.label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configure MCP sequential-thinking for detected runtimes
|
||||
if (detected.length > 0) {
|
||||
const spin2 = p.spinner();
|
||||
spin2.update('Configuring sequential-thinking MCP...');
|
||||
try {
|
||||
for (const rt of detected) {
|
||||
configureMcpForRuntime(rt.name);
|
||||
}
|
||||
spin2.stop('MCP sequential-thinking configured');
|
||||
state.runtimes.mcpConfigured = true;
|
||||
} catch (err) {
|
||||
spin2.stop('MCP configuration failed (non-fatal)');
|
||||
p.warn(
|
||||
`MCP setup failed: ${err instanceof Error ? err.message : String(err)}. Run 'mosaic seq fix' later.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
84
packages/mosaic/src/stages/skills-select.ts
Normal file
84
packages/mosaic/src/stages/skills-select.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { loadSkillsCatalog } from '../skills/catalog.js';
|
||||
import { SKILL_CATEGORIES, categorizeSkill } from '../skills/categories.js';
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
if (str.length <= max) return str;
|
||||
return str.slice(0, max - 1) + '\u2026';
|
||||
}
|
||||
|
||||
export async function skillsSelectStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
): Promise<void> {
|
||||
p.separator();
|
||||
|
||||
const spin = p.spinner();
|
||||
spin.update('Loading skills catalog...');
|
||||
|
||||
const catalog = loadSkillsCatalog(state.mosaicHome);
|
||||
spin.stop(`Found ${catalog.length} available skills`);
|
||||
|
||||
if (catalog.length === 0) {
|
||||
p.warn(
|
||||
"No skills found. Run 'mosaic sync' after installation to fetch skills.",
|
||||
);
|
||||
state.selectedSkills = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.mode === 'quick') {
|
||||
const defaults = catalog
|
||||
.filter((s) => s.recommended)
|
||||
.map((s) => s.name);
|
||||
state.selectedSkills = defaults;
|
||||
p.note(
|
||||
`Selected ${defaults.length} recommended skills.\n` +
|
||||
`Run 'mosaic sync' later to browse the full catalog.`,
|
||||
'Skills',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Advanced mode: categorized browsing
|
||||
p.note(
|
||||
'Skills give agents domain expertise for specific tasks.\n' +
|
||||
'Browse by category and select the ones you want.\n' +
|
||||
"You can always change this later with 'mosaic sync'.",
|
||||
'Skills Selection',
|
||||
);
|
||||
|
||||
// Build grouped options
|
||||
const grouped: Record<
|
||||
string,
|
||||
{ value: string; label: string; hint?: string; selected?: boolean }[]
|
||||
> = {};
|
||||
|
||||
// Initialize all categories
|
||||
for (const categoryName of Object.keys(SKILL_CATEGORIES)) {
|
||||
grouped[categoryName] = [];
|
||||
}
|
||||
|
||||
for (const skill of catalog) {
|
||||
const category = categorizeSkill(skill.name, skill.description);
|
||||
if (!grouped[category]) grouped[category] = [];
|
||||
grouped[category].push({
|
||||
value: skill.name,
|
||||
label: skill.name,
|
||||
hint: truncate(skill.description, 60),
|
||||
selected: skill.recommended,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove empty categories
|
||||
for (const key of Object.keys(grouped)) {
|
||||
if (grouped[key].length === 0) delete grouped[key];
|
||||
}
|
||||
|
||||
state.selectedSkills = await p.groupMultiselect({
|
||||
message: 'Select skills (space to toggle)',
|
||||
options: grouped,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
73
packages/mosaic/src/stages/soul-setup.ts
Normal file
73
packages/mosaic/src/stages/soul-setup.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState, CommunicationStyle } from '../types.js';
|
||||
import { DEFAULTS } from '../constants.js';
|
||||
|
||||
export async function soulSetupStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
): Promise<void> {
|
||||
if (state.installAction === 'keep') return;
|
||||
|
||||
p.separator();
|
||||
p.note(
|
||||
'Your agent identity defines how AI assistants behave,\n' +
|
||||
'their principles, and communication style.\n' +
|
||||
'This creates SOUL.md.',
|
||||
'Agent Identity',
|
||||
);
|
||||
|
||||
if (!state.soul.agentName) {
|
||||
state.soul.agentName = await p.text({
|
||||
message: 'What name should agents use?',
|
||||
placeholder: 'e.g., Jarvis, Assistant, Mosaic',
|
||||
defaultValue: DEFAULTS.agentName,
|
||||
validate: (v) => {
|
||||
if (v.length === 0) return 'Name cannot be empty';
|
||||
if (v.length > 50) return 'Name must be under 50 characters';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (state.mode === 'advanced') {
|
||||
if (!state.soul.roleDescription) {
|
||||
state.soul.roleDescription = await p.text({
|
||||
message: 'Agent role description',
|
||||
placeholder: 'e.g., execution partner and visibility engine',
|
||||
defaultValue: DEFAULTS.roleDescription,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
||||
}
|
||||
|
||||
if (!state.soul.communicationStyle) {
|
||||
state.soul.communicationStyle = await p.select<CommunicationStyle>({
|
||||
message: 'Communication style',
|
||||
options: [
|
||||
{ value: 'direct', label: 'Direct', hint: 'Concise, no fluff, actionable' },
|
||||
{ value: 'friendly', label: 'Friendly', hint: 'Warm but efficient, conversational' },
|
||||
{ value: 'formal', label: 'Formal', hint: 'Professional, structured, thorough' },
|
||||
],
|
||||
initialValue: 'direct',
|
||||
});
|
||||
}
|
||||
|
||||
if (state.mode === 'advanced') {
|
||||
if (!state.soul.accessibility) {
|
||||
state.soul.accessibility = await p.text({
|
||||
message: 'Accessibility preferences',
|
||||
placeholder:
|
||||
"e.g., ADHD-friendly chunking, dyslexia-aware formatting, or 'none'",
|
||||
defaultValue: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.soul.customGuardrails) {
|
||||
state.soul.customGuardrails = await p.text({
|
||||
message: 'Custom guardrails (optional)',
|
||||
placeholder: 'e.g., Never auto-commit to main',
|
||||
defaultValue: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
76
packages/mosaic/src/stages/tools-setup.ts
Normal file
76
packages/mosaic/src/stages/tools-setup.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState, GitProvider } from '../types.js';
|
||||
import { DEFAULTS } from '../constants.js';
|
||||
|
||||
export async function toolsSetupStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
): Promise<void> {
|
||||
if (state.installAction === 'keep') return;
|
||||
|
||||
if (state.mode === 'quick') {
|
||||
state.tools.gitProviders = [];
|
||||
state.tools.credentialsLocation = DEFAULTS.credentialsLocation;
|
||||
state.tools.customToolsSection = DEFAULTS.customToolsSection;
|
||||
return;
|
||||
}
|
||||
|
||||
p.separator();
|
||||
p.note(
|
||||
'Tool configuration tells agents about your git providers,\n' +
|
||||
'credential locations, and custom tools.\n' +
|
||||
'This creates TOOLS.md.',
|
||||
'Tool Reference',
|
||||
);
|
||||
|
||||
const addProviders = await p.confirm({
|
||||
message: 'Configure git providers?',
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
state.tools.gitProviders = [];
|
||||
if (addProviders) {
|
||||
let addMore = true;
|
||||
while (addMore) {
|
||||
const name = await p.text({
|
||||
message: 'Provider name',
|
||||
placeholder: 'e.g., Gitea, GitHub',
|
||||
});
|
||||
const url = await p.text({
|
||||
message: 'Provider URL',
|
||||
placeholder: 'e.g., https://github.com',
|
||||
});
|
||||
const cli = await p.select<string>({
|
||||
message: 'CLI tool',
|
||||
options: [
|
||||
{ value: 'gh', label: 'gh (GitHub CLI)' },
|
||||
{ value: 'tea', label: 'tea (Gitea CLI)' },
|
||||
{ value: 'glab', label: 'glab (GitLab CLI)' },
|
||||
],
|
||||
});
|
||||
const purpose = await p.text({
|
||||
message: 'Purpose',
|
||||
placeholder: 'e.g., Primary code hosting',
|
||||
defaultValue: 'Code hosting',
|
||||
});
|
||||
|
||||
state.tools.gitProviders.push({
|
||||
name,
|
||||
url,
|
||||
cli,
|
||||
purpose,
|
||||
} satisfies GitProvider);
|
||||
|
||||
addMore = await p.confirm({
|
||||
message: 'Add another provider?',
|
||||
initialValue: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.tools.credentialsLocation = await p.text({
|
||||
message: 'Credential file path',
|
||||
placeholder: "e.g., ~/.secrets/credentials.env, or 'none'",
|
||||
defaultValue: DEFAULTS.credentialsLocation,
|
||||
});
|
||||
}
|
||||
80
packages/mosaic/src/stages/user-setup.ts
Normal file
80
packages/mosaic/src/stages/user-setup.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { DEFAULTS } from '../constants.js';
|
||||
import { buildCommunicationPrefs } from '../template/builders.js';
|
||||
|
||||
export async function userSetupStage(
|
||||
p: WizardPrompter,
|
||||
state: WizardState,
|
||||
): Promise<void> {
|
||||
if (state.installAction === 'keep') return;
|
||||
|
||||
p.separator();
|
||||
p.note(
|
||||
'Your user profile helps agents understand your context,\n' +
|
||||
'accessibility needs, and communication preferences.\n' +
|
||||
'This creates USER.md.',
|
||||
'User Profile',
|
||||
);
|
||||
|
||||
if (!state.user.userName) {
|
||||
state.user.userName = await p.text({
|
||||
message: 'Your name',
|
||||
placeholder: 'How agents should address you',
|
||||
defaultValue: '',
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.user.pronouns) {
|
||||
state.user.pronouns = await p.text({
|
||||
message: 'Your pronouns',
|
||||
placeholder: 'e.g., He/Him, She/Her, They/Them',
|
||||
defaultValue: DEFAULTS.pronouns,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-detect timezone
|
||||
let detectedTz: string;
|
||||
try {
|
||||
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
detectedTz = DEFAULTS.timezone;
|
||||
}
|
||||
|
||||
if (!state.user.timezone) {
|
||||
state.user.timezone = await p.text({
|
||||
message: 'Your timezone',
|
||||
placeholder: `e.g., ${detectedTz}`,
|
||||
defaultValue: detectedTz,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.mode === 'advanced') {
|
||||
state.user.background = await p.text({
|
||||
message: 'Professional background (brief)',
|
||||
placeholder: 'e.g., Full-stack developer, 10 years TypeScript/React',
|
||||
defaultValue: DEFAULTS.background,
|
||||
});
|
||||
|
||||
state.user.accessibilitySection = await p.text({
|
||||
message: 'Neurodivergence / accessibility accommodations',
|
||||
placeholder: 'e.g., ADHD-friendly chunking, or press Enter to skip',
|
||||
defaultValue: DEFAULTS.accessibilitySection,
|
||||
});
|
||||
|
||||
state.user.personalBoundaries = await p.text({
|
||||
message: 'Personal boundaries for agents',
|
||||
placeholder: 'e.g., No unsolicited career advice, or press Enter to skip',
|
||||
defaultValue: DEFAULTS.personalBoundaries,
|
||||
});
|
||||
} else {
|
||||
state.user.background = DEFAULTS.background;
|
||||
state.user.accessibilitySection = DEFAULTS.accessibilitySection;
|
||||
state.user.personalBoundaries = DEFAULTS.personalBoundaries;
|
||||
}
|
||||
|
||||
// Derive communication preferences from SOUL style
|
||||
state.user.communicationPrefs = buildCommunicationPrefs(
|
||||
state.soul.communicationStyle ?? 'direct',
|
||||
);
|
||||
}
|
||||
18
packages/mosaic/src/stages/welcome.ts
Normal file
18
packages/mosaic/src/stages/welcome.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { WizardPrompter } from '../prompter/interface.js';
|
||||
import type { WizardState } from '../types.js';
|
||||
import { VERSION } from '../constants.js';
|
||||
|
||||
export async function welcomeStage(
|
||||
p: WizardPrompter,
|
||||
_state: WizardState,
|
||||
): Promise<void> {
|
||||
p.intro(`Mosaic Installation Wizard v${VERSION}`);
|
||||
p.note(
|
||||
`Mosaic is an agent framework that gives AI coding assistants\n` +
|
||||
`a persistent identity, shared skills, and structured workflows.\n\n` +
|
||||
`It works with Claude Code, Codex, and OpenCode.\n\n` +
|
||||
`All config is stored locally in ~/.config/mosaic/.\n` +
|
||||
`No data is sent anywhere. No accounts required.`,
|
||||
'What is Mosaic?',
|
||||
);
|
||||
}
|
||||
145
packages/mosaic/src/template/builders.ts
Normal file
145
packages/mosaic/src/template/builders.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { CommunicationStyle, SoulConfig, UserConfig, ToolsConfig, GitProvider } from '../types.js';
|
||||
import { DEFAULTS } from '../constants.js';
|
||||
import type { TemplateVars } from './engine.js';
|
||||
|
||||
/**
|
||||
* Build behavioral principles text based on communication style.
|
||||
* Replicates mosaic-init lines 177-204 exactly.
|
||||
*/
|
||||
function buildBehavioralPrinciples(
|
||||
style: CommunicationStyle,
|
||||
accessibility?: string,
|
||||
): string {
|
||||
let principles: string;
|
||||
|
||||
switch (style) {
|
||||
case 'direct':
|
||||
principles = `1. Clarity over performance theater.
|
||||
2. Practical execution over abstract planning.
|
||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
||||
4. Visible state over hidden assumptions.
|
||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
|
||||
break;
|
||||
case 'friendly':
|
||||
principles = `1. Be helpful and approachable while staying efficient.
|
||||
2. Provide context and explain reasoning when helpful.
|
||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
||||
4. Visible state over hidden assumptions.
|
||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
|
||||
break;
|
||||
case 'formal':
|
||||
principles = `1. Maintain professional, structured communication.
|
||||
2. Provide thorough analysis with explicit tradeoffs.
|
||||
3. Truthfulness over confidence: state uncertainty explicitly.
|
||||
4. Document decisions and rationale clearly.
|
||||
5. Accessibility-aware — see \`~/.config/mosaic/USER.md\` for user-specific accommodations.`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (accessibility && accessibility !== 'none' && accessibility.length > 0) {
|
||||
principles += `\n6. ${accessibility}.`;
|
||||
}
|
||||
|
||||
return principles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build communication style text based on style choice.
|
||||
* Replicates mosaic-init lines 208-227 exactly.
|
||||
*/
|
||||
function buildCommunicationStyleText(style: CommunicationStyle): string {
|
||||
switch (style) {
|
||||
case 'direct':
|
||||
return `- Be direct, concise, and concrete.
|
||||
- Avoid fluff, hype, and anthropomorphic roleplay.
|
||||
- Do not simulate certainty when facts are missing.
|
||||
- Prefer actionable next steps and explicit tradeoffs.`;
|
||||
case 'friendly':
|
||||
return `- Be warm and conversational while staying focused.
|
||||
- Explain your reasoning when it helps the user.
|
||||
- Do not simulate certainty when facts are missing.
|
||||
- Prefer actionable next steps with clear context.`;
|
||||
case 'formal':
|
||||
return `- Use professional, structured language.
|
||||
- Provide thorough explanations with supporting detail.
|
||||
- Do not simulate certainty when facts are missing.
|
||||
- Present options with explicit tradeoffs and recommendations.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build communication preferences for USER.md based on style.
|
||||
* Replicates mosaic-init lines 299-316 exactly.
|
||||
*/
|
||||
function buildCommunicationPrefs(style: CommunicationStyle): string {
|
||||
switch (style) {
|
||||
case 'direct':
|
||||
return `- Direct and concise
|
||||
- No sycophancy
|
||||
- Executive summaries and tables for overview`;
|
||||
case 'friendly':
|
||||
return `- Warm and conversational
|
||||
- Explain reasoning when helpful
|
||||
- Balance thoroughness with brevity`;
|
||||
case 'formal':
|
||||
return `- Professional and structured
|
||||
- Thorough explanations with supporting detail
|
||||
- Formal tone with explicit recommendations`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build git providers markdown table from provider list.
|
||||
* Replicates mosaic-init lines 362-384.
|
||||
*/
|
||||
function buildGitProvidersTable(providers?: GitProvider[]): string {
|
||||
if (!providers || providers.length === 0) {
|
||||
return DEFAULTS.gitProvidersTable;
|
||||
}
|
||||
|
||||
const rows = providers
|
||||
.map((p) => `| ${p.name} | ${p.url} | \`${p.cli}\` | ${p.purpose} |`)
|
||||
.join('\n');
|
||||
|
||||
return `| Instance | URL | CLI | Purpose |
|
||||
|----------|-----|-----|---------|
|
||||
${rows}`;
|
||||
}
|
||||
|
||||
export function buildSoulTemplateVars(config: SoulConfig): TemplateVars {
|
||||
const style = config.communicationStyle ?? 'direct';
|
||||
const guardrails = config.customGuardrails
|
||||
? `- ${config.customGuardrails}`
|
||||
: '';
|
||||
|
||||
return {
|
||||
AGENT_NAME: config.agentName ?? DEFAULTS.agentName,
|
||||
ROLE_DESCRIPTION: config.roleDescription ?? DEFAULTS.roleDescription,
|
||||
BEHAVIORAL_PRINCIPLES: buildBehavioralPrinciples(style, config.accessibility),
|
||||
COMMUNICATION_STYLE: buildCommunicationStyleText(style),
|
||||
CUSTOM_GUARDRAILS: guardrails,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUserTemplateVars(config: UserConfig): TemplateVars {
|
||||
return {
|
||||
USER_NAME: config.userName ?? '',
|
||||
PRONOUNS: config.pronouns ?? DEFAULTS.pronouns,
|
||||
TIMEZONE: config.timezone ?? DEFAULTS.timezone,
|
||||
BACKGROUND: config.background ?? DEFAULTS.background,
|
||||
ACCESSIBILITY_SECTION: config.accessibilitySection ?? DEFAULTS.accessibilitySection,
|
||||
COMMUNICATION_PREFS: config.communicationPrefs ?? buildCommunicationPrefs('direct'),
|
||||
PERSONAL_BOUNDARIES: config.personalBoundaries ?? DEFAULTS.personalBoundaries,
|
||||
PROJECTS_TABLE: config.projectsTable ?? DEFAULTS.projectsTable,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildToolsTemplateVars(config: ToolsConfig): TemplateVars {
|
||||
return {
|
||||
GIT_PROVIDERS_TABLE: buildGitProvidersTable(config.gitProviders),
|
||||
CREDENTIALS_LOCATION: config.credentialsLocation ?? DEFAULTS.credentialsLocation,
|
||||
CUSTOM_TOOLS_SECTION: config.customToolsSection ?? DEFAULTS.customToolsSection,
|
||||
};
|
||||
}
|
||||
|
||||
export { buildCommunicationPrefs };
|
||||
26
packages/mosaic/src/template/engine.ts
Normal file
26
packages/mosaic/src/template/engine.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export interface TemplateVars {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces {{PLACEHOLDER}} tokens with provided values.
|
||||
* Does NOT expand ${ENV_VAR} syntax — those pass through for shell resolution.
|
||||
*/
|
||||
export function renderTemplate(
|
||||
template: string,
|
||||
vars: TemplateVars,
|
||||
options: { strict?: boolean } = {},
|
||||
): string {
|
||||
return template.replace(
|
||||
/\{\{([A-Z_][A-Z0-9_]*)\}\}/g,
|
||||
(match, varName: string) => {
|
||||
if (varName in vars) {
|
||||
return vars[varName];
|
||||
}
|
||||
if (options.strict) {
|
||||
throw new Error(`Template variable not provided: {{${varName}}}`);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
);
|
||||
}
|
||||
53
packages/mosaic/src/types.ts
Normal file
53
packages/mosaic/src/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type WizardMode = 'quick' | 'advanced';
|
||||
export type InstallAction = 'fresh' | 'keep' | 'reconfigure' | 'reset';
|
||||
export type CommunicationStyle = 'direct' | 'friendly' | 'formal';
|
||||
export type RuntimeName = 'claude' | 'codex' | 'opencode';
|
||||
|
||||
export interface SoulConfig {
|
||||
agentName?: string;
|
||||
roleDescription?: string;
|
||||
communicationStyle?: CommunicationStyle;
|
||||
accessibility?: string;
|
||||
customGuardrails?: string;
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
userName?: string;
|
||||
pronouns?: string;
|
||||
timezone?: string;
|
||||
background?: string;
|
||||
accessibilitySection?: string;
|
||||
communicationPrefs?: string;
|
||||
personalBoundaries?: string;
|
||||
projectsTable?: string;
|
||||
}
|
||||
|
||||
export interface GitProvider {
|
||||
name: string;
|
||||
url: string;
|
||||
cli: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
export interface ToolsConfig {
|
||||
gitProviders?: GitProvider[];
|
||||
credentialsLocation?: string;
|
||||
customToolsSection?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeState {
|
||||
detected: RuntimeName[];
|
||||
mcpConfigured: boolean;
|
||||
}
|
||||
|
||||
export interface WizardState {
|
||||
mosaicHome: string;
|
||||
sourceDir: string;
|
||||
mode: WizardMode;
|
||||
installAction: InstallAction;
|
||||
soul: SoulConfig;
|
||||
user: UserConfig;
|
||||
tools: ToolsConfig;
|
||||
runtimes: RuntimeState;
|
||||
selectedSkills: string[];
|
||||
}
|
||||
96
packages/mosaic/src/wizard.ts
Normal file
96
packages/mosaic/src/wizard.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { WizardPrompter } from './prompter/interface.js';
|
||||
import type { ConfigService } from './config/config-service.js';
|
||||
import type { WizardState } from './types.js';
|
||||
import { welcomeStage } from './stages/welcome.js';
|
||||
import { detectInstallStage } from './stages/detect-install.js';
|
||||
import { modeSelectStage } from './stages/mode-select.js';
|
||||
import { soulSetupStage } from './stages/soul-setup.js';
|
||||
import { userSetupStage } from './stages/user-setup.js';
|
||||
import { toolsSetupStage } from './stages/tools-setup.js';
|
||||
import { runtimeSetupStage } from './stages/runtime-setup.js';
|
||||
import { skillsSelectStage } from './stages/skills-select.js';
|
||||
import { finalizeStage } from './stages/finalize.js';
|
||||
|
||||
export interface WizardOptions {
|
||||
mosaicHome: string;
|
||||
sourceDir: string;
|
||||
prompter: WizardPrompter;
|
||||
configService: ConfigService;
|
||||
cliOverrides?: Partial<WizardState>;
|
||||
}
|
||||
|
||||
export async function runWizard(options: WizardOptions): Promise<void> {
|
||||
const { prompter, configService, mosaicHome, sourceDir } = options;
|
||||
|
||||
const state: WizardState = {
|
||||
mosaicHome,
|
||||
sourceDir,
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
|
||||
// Apply CLI overrides (strip undefined values)
|
||||
if (options.cliOverrides) {
|
||||
if (options.cliOverrides.soul) {
|
||||
for (const [k, v] of Object.entries(options.cliOverrides.soul)) {
|
||||
if (v !== undefined) {
|
||||
(state.soul as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.cliOverrides.user) {
|
||||
for (const [k, v] of Object.entries(options.cliOverrides.user)) {
|
||||
if (v !== undefined) {
|
||||
(state.user as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.cliOverrides.tools) {
|
||||
for (const [k, v] of Object.entries(options.cliOverrides.tools)) {
|
||||
if (v !== undefined) {
|
||||
(state.tools as Record<string, unknown>)[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.cliOverrides.mode) {
|
||||
state.mode = options.cliOverrides.mode;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 1: Welcome
|
||||
await welcomeStage(prompter, state);
|
||||
|
||||
// Stage 2: Existing Install Detection
|
||||
await detectInstallStage(prompter, state, configService);
|
||||
|
||||
// Stage 3: Quick Start vs Advanced (skip if keeping existing)
|
||||
if (state.installAction === 'fresh' || state.installAction === 'reset') {
|
||||
await modeSelectStage(prompter, state);
|
||||
} else if (state.installAction === 'reconfigure') {
|
||||
state.mode = 'advanced';
|
||||
}
|
||||
|
||||
// Stage 4: SOUL.md
|
||||
await soulSetupStage(prompter, state);
|
||||
|
||||
// Stage 5: USER.md
|
||||
await userSetupStage(prompter, state);
|
||||
|
||||
// Stage 6: TOOLS.md
|
||||
await toolsSetupStage(prompter, state);
|
||||
|
||||
// Stage 7: Runtime Detection & Installation
|
||||
await runtimeSetupStage(prompter, state);
|
||||
|
||||
// Stage 8: Skills Selection
|
||||
await skillsSelectStage(prompter, state);
|
||||
|
||||
// Stage 9: Finalize
|
||||
await finalizeStage(prompter, state, configService);
|
||||
}
|
||||
|
||||
109
packages/mosaic/tests/integration/full-wizard.test.ts
Normal file
109
packages/mosaic/tests/integration/full-wizard.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
cpSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { createConfigService } from '../../src/config/config-service.js';
|
||||
import { runWizard } from '../../src/wizard.js';
|
||||
|
||||
describe('Full Wizard (headless)', () => {
|
||||
let tmpDir: string;
|
||||
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
|
||||
// Copy templates to tmp dir
|
||||
const templatesDir = join(repoRoot, 'templates');
|
||||
if (existsSync(templatesDir)) {
|
||||
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('quick start produces valid SOUL.md', async () => {
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
});
|
||||
|
||||
const soulPath = join(tmpDir, 'SOUL.md');
|
||||
expect(existsSync(soulPath)).toBe(true);
|
||||
|
||||
const soul = readFileSync(soulPath, 'utf-8');
|
||||
expect(soul).toContain('You are **TestBot**');
|
||||
expect(soul).toContain('Be direct, concise, and concrete');
|
||||
expect(soul).toContain('execution partner and visibility engine');
|
||||
});
|
||||
|
||||
it('quick start produces valid USER.md', async () => {
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'He/Him',
|
||||
'Your timezone': 'America/Chicago',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
});
|
||||
|
||||
const userPath = join(tmpDir, 'USER.md');
|
||||
expect(existsSync(userPath)).toBe(true);
|
||||
|
||||
const user = readFileSync(userPath, 'utf-8');
|
||||
expect(user).toContain('**Name:** Tester');
|
||||
expect(user).toContain('**Pronouns:** He/Him');
|
||||
expect(user).toContain('**Timezone:** America/Chicago');
|
||||
});
|
||||
|
||||
it('applies CLI overrides', async () => {
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'Your name': 'FromPrompt',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
cliOverrides: {
|
||||
soul: {
|
||||
agentName: 'FromCLI',
|
||||
communicationStyle: 'formal',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const soul = readFileSync(join(tmpDir, 'SOUL.md'), 'utf-8');
|
||||
expect(soul).toContain('You are **FromCLI**');
|
||||
expect(soul).toContain('Use professional, structured language');
|
||||
});
|
||||
});
|
||||
71
packages/mosaic/tests/stages/detect-install.test.ts
Normal file
71
packages/mosaic/tests/stages/detect-install.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { detectInstallStage } from '../../src/stages/detect-install.js';
|
||||
import type { WizardState } from '../../src/types.js';
|
||||
import type { ConfigService } from '../../src/config/config-service.js';
|
||||
|
||||
function createState(mosaicHome: string): WizardState {
|
||||
return {
|
||||
mosaicHome,
|
||||
sourceDir: mosaicHome,
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
const mockConfig: ConfigService = {
|
||||
readSoul: async () => ({ agentName: 'TestAgent' }),
|
||||
readUser: async () => ({ userName: 'TestUser' }),
|
||||
readTools: async () => ({}),
|
||||
writeSoul: async () => {},
|
||||
writeUser: async () => {},
|
||||
writeTools: async () => {},
|
||||
syncFramework: async () => {},
|
||||
};
|
||||
|
||||
describe('detectInstallStage', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('sets fresh for empty directory', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState(join(tmpDir, 'nonexistent'));
|
||||
await detectInstallStage(p, state, mockConfig);
|
||||
|
||||
expect(state.installAction).toBe('fresh');
|
||||
});
|
||||
|
||||
it('detects existing install and offers choices', async () => {
|
||||
// Create a mock existing install
|
||||
mkdirSync(join(tmpDir, 'bin'), { recursive: true });
|
||||
writeFileSync(join(tmpDir, 'AGENTS.md'), '# Test');
|
||||
writeFileSync(
|
||||
join(tmpDir, 'SOUL.md'),
|
||||
'You are **Jarvis** in this session.',
|
||||
);
|
||||
|
||||
const p = new HeadlessPrompter({
|
||||
'What would you like to do?': 'keep',
|
||||
});
|
||||
const state = createState(tmpDir);
|
||||
await detectInstallStage(p, state, mockConfig);
|
||||
|
||||
expect(state.installAction).toBe('keep');
|
||||
expect(state.soul.agentName).toBe('TestAgent');
|
||||
});
|
||||
});
|
||||
74
packages/mosaic/tests/stages/soul-setup.test.ts
Normal file
74
packages/mosaic/tests/stages/soul-setup.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { soulSetupStage } from '../../src/stages/soul-setup.js';
|
||||
import type { WizardState } from '../../src/types.js';
|
||||
|
||||
function createState(overrides: Partial<WizardState> = {}): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/test-mosaic',
|
||||
sourceDir: '/tmp/test-mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('soulSetupStage', () => {
|
||||
it('sets agent name and style in quick mode', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'What name should agents use?': 'Jarvis',
|
||||
'Communication style': 'friendly',
|
||||
});
|
||||
|
||||
const state = createState({ mode: 'quick' });
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Jarvis');
|
||||
expect(state.soul.communicationStyle).toBe('friendly');
|
||||
expect(state.soul.roleDescription).toBe(
|
||||
'execution partner and visibility engine',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses defaults in quick mode with no answers', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState({ mode: 'quick' });
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Assistant');
|
||||
expect(state.soul.communicationStyle).toBe('direct');
|
||||
});
|
||||
|
||||
it('skips when install action is keep', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState({ installAction: 'keep' });
|
||||
state.soul.agentName = 'Existing';
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Existing');
|
||||
});
|
||||
|
||||
it('asks for all fields in advanced mode', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'What name should agents use?': 'Atlas',
|
||||
'Agent role description': 'memory keeper',
|
||||
'Communication style': 'formal',
|
||||
'Accessibility preferences': 'ADHD-friendly',
|
||||
'Custom guardrails (optional)': 'Never push to main',
|
||||
});
|
||||
|
||||
const state = createState({ mode: 'advanced' });
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Atlas');
|
||||
expect(state.soul.roleDescription).toBe('memory keeper');
|
||||
expect(state.soul.communicationStyle).toBe('formal');
|
||||
expect(state.soul.accessibility).toBe('ADHD-friendly');
|
||||
expect(state.soul.customGuardrails).toBe('Never push to main');
|
||||
});
|
||||
});
|
||||
60
packages/mosaic/tests/stages/user-setup.test.ts
Normal file
60
packages/mosaic/tests/stages/user-setup.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { userSetupStage } from '../../src/stages/user-setup.js';
|
||||
import type { WizardState } from '../../src/types.js';
|
||||
|
||||
function createState(overrides: Partial<WizardState> = {}): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/test-mosaic',
|
||||
sourceDir: '/tmp/test-mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: { communicationStyle: 'direct' },
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('userSetupStage', () => {
|
||||
it('collects basic info in quick mode', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'Your name': 'Jason',
|
||||
'Your pronouns': 'He/Him',
|
||||
'Your timezone': 'America/Chicago',
|
||||
});
|
||||
|
||||
const state = createState({ mode: 'quick' });
|
||||
await userSetupStage(p, state);
|
||||
|
||||
expect(state.user.userName).toBe('Jason');
|
||||
expect(state.user.pronouns).toBe('He/Him');
|
||||
expect(state.user.timezone).toBe('America/Chicago');
|
||||
expect(state.user.communicationPrefs).toContain('Direct and concise');
|
||||
});
|
||||
|
||||
it('skips when install action is keep', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState({ installAction: 'keep' });
|
||||
state.user.userName = 'Existing';
|
||||
await userSetupStage(p, state);
|
||||
|
||||
expect(state.user.userName).toBe('Existing');
|
||||
});
|
||||
|
||||
it('derives communication prefs from soul style', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'Your name': 'Test',
|
||||
});
|
||||
|
||||
const state = createState({
|
||||
mode: 'quick',
|
||||
soul: { communicationStyle: 'friendly' },
|
||||
});
|
||||
await userSetupStage(p, state);
|
||||
|
||||
expect(state.user.communicationPrefs).toContain('Warm and conversational');
|
||||
});
|
||||
});
|
||||
99
packages/mosaic/tests/template/builders.test.ts
Normal file
99
packages/mosaic/tests/template/builders.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildSoulTemplateVars,
|
||||
buildUserTemplateVars,
|
||||
buildToolsTemplateVars,
|
||||
} from '../../src/template/builders.js';
|
||||
|
||||
describe('buildSoulTemplateVars', () => {
|
||||
it('builds direct style correctly', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
agentName: 'Jarvis',
|
||||
communicationStyle: 'direct',
|
||||
});
|
||||
expect(vars.AGENT_NAME).toBe('Jarvis');
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Clarity over performance theater');
|
||||
expect(vars.COMMUNICATION_STYLE).toContain('Be direct, concise, and concrete');
|
||||
});
|
||||
|
||||
it('builds friendly style correctly', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'friendly',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Be helpful and approachable');
|
||||
expect(vars.COMMUNICATION_STYLE).toContain('Be warm and conversational');
|
||||
});
|
||||
|
||||
it('builds formal style correctly', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'formal',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Maintain professional, structured');
|
||||
expect(vars.COMMUNICATION_STYLE).toContain('Use professional, structured language');
|
||||
});
|
||||
|
||||
it('appends accessibility to principles', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'direct',
|
||||
accessibility: 'ADHD-friendly chunking',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('6. ADHD-friendly chunking.');
|
||||
});
|
||||
|
||||
it('does not append accessibility when "none"', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'direct',
|
||||
accessibility: 'none',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).not.toContain('6.');
|
||||
});
|
||||
|
||||
it('formats custom guardrails', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
customGuardrails: 'Never auto-commit',
|
||||
});
|
||||
expect(vars.CUSTOM_GUARDRAILS).toBe('- Never auto-commit');
|
||||
});
|
||||
|
||||
it('uses defaults when config is empty', () => {
|
||||
const vars = buildSoulTemplateVars({});
|
||||
expect(vars.AGENT_NAME).toBe('Assistant');
|
||||
expect(vars.ROLE_DESCRIPTION).toBe('execution partner and visibility engine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserTemplateVars', () => {
|
||||
it('maps all fields', () => {
|
||||
const vars = buildUserTemplateVars({
|
||||
userName: 'Jason',
|
||||
pronouns: 'He/Him',
|
||||
timezone: 'America/Chicago',
|
||||
});
|
||||
expect(vars.USER_NAME).toBe('Jason');
|
||||
expect(vars.PRONOUNS).toBe('He/Him');
|
||||
expect(vars.TIMEZONE).toBe('America/Chicago');
|
||||
});
|
||||
|
||||
it('uses defaults for missing fields', () => {
|
||||
const vars = buildUserTemplateVars({});
|
||||
expect(vars.PRONOUNS).toBe('They/Them');
|
||||
expect(vars.TIMEZONE).toBe('UTC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildToolsTemplateVars', () => {
|
||||
it('builds git providers table', () => {
|
||||
const vars = buildToolsTemplateVars({
|
||||
gitProviders: [
|
||||
{ name: 'GitHub', url: 'https://github.com', cli: 'gh', purpose: 'OSS' },
|
||||
],
|
||||
});
|
||||
expect(vars.GIT_PROVIDERS_TABLE).toContain('| GitHub |');
|
||||
expect(vars.GIT_PROVIDERS_TABLE).toContain('`gh`');
|
||||
});
|
||||
|
||||
it('uses default table when no providers', () => {
|
||||
const vars = buildToolsTemplateVars({});
|
||||
expect(vars.GIT_PROVIDERS_TABLE).toContain('add your git providers here');
|
||||
});
|
||||
});
|
||||
52
packages/mosaic/tests/template/engine.test.ts
Normal file
52
packages/mosaic/tests/template/engine.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderTemplate } from '../../src/template/engine.js';
|
||||
|
||||
describe('renderTemplate', () => {
|
||||
it('replaces all placeholders', () => {
|
||||
const template = 'You are **{{AGENT_NAME}}**, role: {{ROLE_DESCRIPTION}}';
|
||||
const result = renderTemplate(template, {
|
||||
AGENT_NAME: 'Jarvis',
|
||||
ROLE_DESCRIPTION: 'steward',
|
||||
});
|
||||
expect(result).toBe('You are **Jarvis**, role: steward');
|
||||
});
|
||||
|
||||
it('preserves ${ENV_VAR} references', () => {
|
||||
const template = 'Path: ${HOME}/.config, Agent: {{AGENT_NAME}}';
|
||||
const result = renderTemplate(template, { AGENT_NAME: 'Test' });
|
||||
expect(result).toBe('Path: ${HOME}/.config, Agent: Test');
|
||||
});
|
||||
|
||||
it('handles multi-line values', () => {
|
||||
const template = '{{PRINCIPLES}}';
|
||||
const result = renderTemplate(template, {
|
||||
PRINCIPLES: '1. First\n2. Second\n3. Third',
|
||||
});
|
||||
expect(result).toBe('1. First\n2. Second\n3. Third');
|
||||
});
|
||||
|
||||
it('replaces unset vars with empty string by default', () => {
|
||||
const template = 'Before {{MISSING}} After';
|
||||
const result = renderTemplate(template, {});
|
||||
expect(result).toBe('Before After');
|
||||
});
|
||||
|
||||
it('throws in strict mode for missing vars', () => {
|
||||
const template = '{{MISSING}}';
|
||||
expect(() => renderTemplate(template, {}, { strict: true })).toThrow(
|
||||
'Template variable not provided: {{MISSING}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple occurrences of same placeholder', () => {
|
||||
const template = '{{NAME}} says hello, {{NAME}}!';
|
||||
const result = renderTemplate(template, { NAME: 'Jarvis' });
|
||||
expect(result).toBe('Jarvis says hello, Jarvis!');
|
||||
});
|
||||
|
||||
it('preserves non-placeholder curly braces', () => {
|
||||
const template = 'const x = { foo: {{VALUE}} }';
|
||||
const result = renderTemplate(template, { VALUE: '"bar"' });
|
||||
expect(result).toBe('const x = { foo: "bar" }');
|
||||
});
|
||||
});
|
||||
8
packages/mosaic/tsconfig.json
Normal file
8
packages/mosaic/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
14
packages/queue/README.md
Normal file
14
packages/queue/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# @mosaic/queue
|
||||
|
||||
Valkey-backed task queue package for Mosaic monorepo migration.
|
||||
|
||||
## Exports
|
||||
|
||||
- Queue repository logic and Redis connection helpers
|
||||
- CLI runner (`mosaic-queue`)
|
||||
- MCP server runner (`mosaic-queue-mcp`)
|
||||
- Runtime task constants (`TASK_STATUSES`, `TASK_PRIORITIES`, `TASK_LANES`)
|
||||
|
||||
## Note
|
||||
|
||||
Task type definitions are expected to come from `@mosaic/types`.
|
||||
11
packages/queue/bin/mosaic-queue-mcp.ts
Normal file
11
packages/queue/bin/mosaic-queue-mcp.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { startQueueMcpServer } from '../src/mcp-server.js';
|
||||
|
||||
try {
|
||||
await startQueueMcpServer();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
6
packages/queue/bin/mosaic-queue.ts
Normal file
6
packages/queue/bin/mosaic-queue.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { runQueueCli } from '../src/cli.js';
|
||||
|
||||
const exitCode = await runQueueCli(process.argv);
|
||||
process.exitCode = exitCode;
|
||||
45
packages/queue/package.json
Normal file
45
packages/queue/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@mosaic/queue",
|
||||
"version": "0.1.0",
|
||||
"description": "Valkey-backed task queue exposed via CLI and MCP",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
"types": "dist/src/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/src/index.d.ts",
|
||||
"import": "./dist/src/index.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"mosaic-queue": "dist/bin/mosaic-queue.js",
|
||||
"mosaic-queue-mcp": "dist/bin/mosaic-queue-mcp.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" \"bin/**/*.ts\" \"vitest.config.ts\"",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"prepublishOnly": "pnpm lint && pnpm test && pnpm build"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaic/types": "workspace:*",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"commander": "^14.0.3",
|
||||
"ioredis": "^5.10.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3"
|
||||
}
|
||||
}
|
||||
344
packages/queue/src/cli.ts
Normal file
344
packages/queue/src/cli.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
Command,
|
||||
CommanderError,
|
||||
InvalidArgumentError,
|
||||
Option,
|
||||
} from 'commander';
|
||||
|
||||
import { assertRedisHealthy, createRedisClient } from './redis-connection.js';
|
||||
import {
|
||||
RedisTaskRepository,
|
||||
type ClaimTaskInput,
|
||||
type CompleteTaskInput,
|
||||
type RedisTaskClient,
|
||||
} from './task-repository.js';
|
||||
import { TASK_LANES, TASK_PRIORITIES, TASK_STATUSES } from './task.js';
|
||||
import type {
|
||||
CreateTaskInput,
|
||||
TaskLane,
|
||||
TaskListFilters,
|
||||
TaskPriority,
|
||||
TaskStatus,
|
||||
} from '@mosaic/types';
|
||||
|
||||
export type QueueRepository = Pick<
|
||||
RedisTaskRepository,
|
||||
'create' | 'list' | 'get' | 'claim' | 'release' | 'complete'
|
||||
>;
|
||||
|
||||
export interface QueueRepositorySession {
|
||||
readonly repository: QueueRepository;
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface QueueCliDependencies {
|
||||
readonly openSession: () => Promise<QueueRepositorySession>;
|
||||
readonly stdout: (line: string) => void;
|
||||
readonly stderr: (line: string) => void;
|
||||
}
|
||||
|
||||
interface CreateCommandOptions {
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly priority?: TaskPriority;
|
||||
readonly lane?: TaskLane;
|
||||
readonly dependency?: string[];
|
||||
}
|
||||
|
||||
interface ListCommandOptions {
|
||||
readonly project?: string;
|
||||
readonly mission?: string;
|
||||
readonly status?: TaskStatus;
|
||||
}
|
||||
|
||||
interface ClaimCommandOptions {
|
||||
readonly agent: string;
|
||||
readonly ttl: number;
|
||||
}
|
||||
|
||||
interface ReleaseCommandOptions {
|
||||
readonly agent?: string;
|
||||
}
|
||||
|
||||
interface CompleteCommandOptions {
|
||||
readonly agent?: string;
|
||||
readonly summary?: string;
|
||||
}
|
||||
|
||||
interface ClosableRedisTaskClient extends RedisTaskClient {
|
||||
ping(): Promise<string>;
|
||||
quit(): Promise<string>;
|
||||
}
|
||||
|
||||
const DEFAULT_DEPENDENCIES: QueueCliDependencies = {
|
||||
openSession: openRedisSession,
|
||||
stdout: (line: string) => {
|
||||
console.log(line);
|
||||
},
|
||||
stderr: (line: string) => {
|
||||
console.error(line);
|
||||
},
|
||||
};
|
||||
|
||||
const PRIORITY_SET = new Set<TaskPriority>(TASK_PRIORITIES);
|
||||
const LANE_SET = new Set<TaskLane>(TASK_LANES);
|
||||
const STATUS_SET = new Set<TaskStatus>(TASK_STATUSES);
|
||||
|
||||
export function buildQueueCli(
|
||||
dependencyOverrides: Partial<QueueCliDependencies> = {},
|
||||
): Command {
|
||||
const dependencies = resolveDependencies(dependencyOverrides);
|
||||
const program = new Command();
|
||||
program
|
||||
.name('mosaic')
|
||||
.description('mosaic queue command line interface')
|
||||
.exitOverride();
|
||||
|
||||
program.configureOutput({
|
||||
writeOut: (output: string) => dependencies.stdout(output.trimEnd()),
|
||||
writeErr: (output: string) => dependencies.stderr(output.trimEnd()),
|
||||
});
|
||||
|
||||
const queue = program.command('queue').description('Manage queue tasks');
|
||||
|
||||
queue
|
||||
.command('create <project> <mission> <taskId>')
|
||||
.description('Create a queue task')
|
||||
.requiredOption('--title <title>', 'Task title')
|
||||
.option('--description <description>', 'Task description')
|
||||
.addOption(
|
||||
new Option('--priority <priority>', 'Task priority')
|
||||
.choices(TASK_PRIORITIES)
|
||||
.argParser(parsePriority),
|
||||
)
|
||||
.addOption(
|
||||
new Option('--lane <lane>', 'Task lane').choices(TASK_LANES).argParser(parseLane),
|
||||
)
|
||||
.option('--dependency <taskIds...>', 'Task dependencies')
|
||||
.action(
|
||||
async (
|
||||
project: string,
|
||||
mission: string,
|
||||
taskId: string,
|
||||
options: CreateCommandOptions,
|
||||
) => {
|
||||
await withSession(dependencies, async (repository) => {
|
||||
const payload: CreateTaskInput = {
|
||||
project,
|
||||
mission,
|
||||
taskId,
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
priority: options.priority,
|
||||
dependencies: options.dependency,
|
||||
lane: options.lane,
|
||||
};
|
||||
const task = await repository.create(payload);
|
||||
dependencies.stdout(JSON.stringify(task, null, 2));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
queue
|
||||
.command('list')
|
||||
.description('List queue tasks')
|
||||
.option('--project <project>', 'Filter by project')
|
||||
.option('--mission <mission>', 'Filter by mission')
|
||||
.addOption(
|
||||
new Option('--status <status>', 'Filter by status')
|
||||
.choices(TASK_STATUSES)
|
||||
.argParser(parseStatus),
|
||||
)
|
||||
.action(async (options: ListCommandOptions) => {
|
||||
await withSession(dependencies, async (repository) => {
|
||||
const filters: TaskListFilters = {
|
||||
project: options.project,
|
||||
mission: options.mission,
|
||||
status: options.status,
|
||||
};
|
||||
const tasks = await repository.list(filters);
|
||||
dependencies.stdout(JSON.stringify(tasks, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
queue
|
||||
.command('show <taskId>')
|
||||
.description('Show a single queue task')
|
||||
.action(async (taskId: string) => {
|
||||
await withSession(dependencies, async (repository) => {
|
||||
const task = await repository.get(taskId);
|
||||
|
||||
if (task === null) {
|
||||
throw new Error(`Task ${taskId} was not found.`);
|
||||
}
|
||||
|
||||
dependencies.stdout(JSON.stringify(task, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
queue
|
||||
.command('claim <taskId>')
|
||||
.description('Claim a pending task')
|
||||
.requiredOption('--agent <agentId>', 'Agent identifier')
|
||||
.requiredOption('--ttl <seconds>', 'Claim TTL in seconds', parsePositiveInteger)
|
||||
.action(async (taskId: string, options: ClaimCommandOptions) => {
|
||||
await withSession(dependencies, async (repository) => {
|
||||
const claimInput: ClaimTaskInput = {
|
||||
agentId: options.agent,
|
||||
ttlSeconds: options.ttl,
|
||||
};
|
||||
const task = await repository.claim(taskId, claimInput);
|
||||
dependencies.stdout(JSON.stringify(task, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
queue
|
||||
.command('release <taskId>')
|
||||
.description('Release a claimed task back to pending')
|
||||
.option('--agent <agentId>', 'Expected owner agent id')
|
||||
.action(async (taskId: string, options: ReleaseCommandOptions) => {
|
||||
await withSession(dependencies, async (repository) => {
|
||||
const task = await repository.release(taskId, {
|
||||
agentId: options.agent,
|
||||
});
|
||||
dependencies.stdout(JSON.stringify(task, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
queue
|
||||
.command('complete <taskId>')
|
||||
.description('Complete a claimed task')
|
||||
.option('--agent <agentId>', 'Expected owner agent id')
|
||||
.option('--summary <summary>', 'Optional completion summary')
|
||||
.action(async (taskId: string, options: CompleteCommandOptions) => {
|
||||
await withSession(dependencies, async (repository) => {
|
||||
const completeInput: CompleteTaskInput = {
|
||||
agentId: options.agent,
|
||||
summary: options.summary,
|
||||
};
|
||||
const task = await repository.complete(taskId, completeInput);
|
||||
dependencies.stdout(JSON.stringify(task, null, 2));
|
||||
});
|
||||
});
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
export async function runQueueCli(
|
||||
argv: string[] = process.argv,
|
||||
dependencyOverrides: Partial<QueueCliDependencies> = {},
|
||||
): Promise<number> {
|
||||
const dependencies = resolveDependencies(dependencyOverrides);
|
||||
const program = buildQueueCli(dependencies);
|
||||
|
||||
try {
|
||||
await program.parseAsync(argv, {
|
||||
from: 'node',
|
||||
});
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (error instanceof CommanderError) {
|
||||
if (error.code === 'commander.helpDisplayed') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (error.code.startsWith('commander.')) {
|
||||
return error.exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
dependencies.stderr(formatError(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function openRedisSession(): Promise<QueueRepositorySession> {
|
||||
const redisClient = createRedisClient<ClosableRedisTaskClient>();
|
||||
|
||||
try {
|
||||
await assertRedisHealthy(redisClient);
|
||||
|
||||
return {
|
||||
repository: new RedisTaskRepository({
|
||||
client: redisClient,
|
||||
}),
|
||||
close: async () => {
|
||||
await redisClient.quit();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await redisClient.quit();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function withSession(
|
||||
dependencies: QueueCliDependencies,
|
||||
action: (repository: QueueRepository) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const session = await dependencies.openSession();
|
||||
|
||||
try {
|
||||
await action(session.repository);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDependencies(
|
||||
overrides: Partial<QueueCliDependencies>,
|
||||
): QueueCliDependencies {
|
||||
const openSession = overrides.openSession ?? DEFAULT_DEPENDENCIES.openSession;
|
||||
const stdout = overrides.stdout ?? DEFAULT_DEPENDENCIES.stdout;
|
||||
const stderr = overrides.stderr ?? DEFAULT_DEPENDENCIES.stderr;
|
||||
|
||||
return {
|
||||
openSession: () => openSession(),
|
||||
stdout: (line: string) => stdout(line),
|
||||
stderr: (line: string) => stderr(line),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string): number {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new InvalidArgumentError(`Expected a positive integer, received "${value}"`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePriority(value: string): TaskPriority {
|
||||
if (!PRIORITY_SET.has(value as TaskPriority)) {
|
||||
throw new InvalidArgumentError(
|
||||
`Expected one of ${TASK_PRIORITIES.join(', ')}, received "${value}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return value as TaskPriority;
|
||||
}
|
||||
|
||||
function parseLane(value: string): TaskLane {
|
||||
if (!LANE_SET.has(value as TaskLane)) {
|
||||
throw new InvalidArgumentError(
|
||||
`Expected one of ${TASK_LANES.join(', ')}, received "${value}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return value as TaskLane;
|
||||
}
|
||||
|
||||
function parseStatus(value: string): TaskStatus {
|
||||
if (!STATUS_SET.has(value as TaskStatus)) {
|
||||
throw new InvalidArgumentError(
|
||||
`Expected one of ${TASK_STATUSES.join(', ')}, received "${value}"`,
|
||||
);
|
||||
}
|
||||
|
||||
return value as TaskStatus;
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
69
packages/queue/src/index.ts
Normal file
69
packages/queue/src/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export const packageVersion = '0.1.0';
|
||||
|
||||
export {
|
||||
assertRedisHealthy,
|
||||
createRedisClient,
|
||||
resolveRedisUrl,
|
||||
runRedisHealthCheck,
|
||||
} from './redis-connection.js';
|
||||
export type {
|
||||
CreateRedisClientOptions,
|
||||
RedisClientConstructor,
|
||||
RedisHealthCheck,
|
||||
RedisPingClient,
|
||||
} from './redis-connection.js';
|
||||
export {
|
||||
RedisTaskRepository,
|
||||
TaskAlreadyExistsError,
|
||||
TaskAtomicConflictError,
|
||||
TaskNotFoundError,
|
||||
TaskOwnershipError,
|
||||
TaskSerializationError,
|
||||
TaskTransitionError,
|
||||
} from './task-repository.js';
|
||||
export type {
|
||||
ClaimTaskInput,
|
||||
CompleteTaskInput,
|
||||
FailTaskInput,
|
||||
HeartbeatTaskInput,
|
||||
RedisTaskClient,
|
||||
RedisTaskRepositoryOptions,
|
||||
RedisTaskTransaction,
|
||||
ReleaseTaskInput,
|
||||
} from './task-repository.js';
|
||||
export { TASK_LANES, TASK_PRIORITIES, TASK_STATUSES } from './task.js';
|
||||
export type {
|
||||
CreateTaskInput,
|
||||
Task,
|
||||
TaskLane,
|
||||
TaskListFilters,
|
||||
TaskPriority,
|
||||
TaskStatus,
|
||||
TaskUpdateInput,
|
||||
} from '@mosaic/types';
|
||||
export { buildQueueCli, runQueueCli } from './cli.js';
|
||||
export type {
|
||||
QueueCliDependencies,
|
||||
QueueRepository,
|
||||
QueueRepositorySession,
|
||||
} from './cli.js';
|
||||
export {
|
||||
QUEUE_MCP_TOOL_DEFINITIONS,
|
||||
buildQueueMcpServer,
|
||||
startQueueMcpServer,
|
||||
} from './mcp-server.js';
|
||||
export type {
|
||||
QueueMcpDependencies,
|
||||
QueueMcpRepository,
|
||||
QueueMcpSession,
|
||||
} from './mcp-server.js';
|
||||
export {
|
||||
queueClaimToolInputSchema,
|
||||
queueCompleteToolInputSchema,
|
||||
queueFailToolInputSchema,
|
||||
queueGetToolInputSchema,
|
||||
queueHeartbeatToolInputSchema,
|
||||
queueListToolInputSchema,
|
||||
queueReleaseToolInputSchema,
|
||||
queueStatusToolInputSchema,
|
||||
} from './mcp-tool-schemas.js';
|
||||
310
packages/queue/src/mcp-server.ts
Normal file
310
packages/queue/src/mcp-server.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import type { CallToolResult, Implementation } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import {
|
||||
assertRedisHealthy,
|
||||
createRedisClient,
|
||||
runRedisHealthCheck,
|
||||
type RedisHealthCheck,
|
||||
} from './redis-connection.js';
|
||||
import {
|
||||
queueClaimToolInputSchema,
|
||||
queueCompleteToolInputSchema,
|
||||
queueFailToolInputSchema,
|
||||
queueGetToolInputSchema,
|
||||
queueHeartbeatToolInputSchema,
|
||||
queueListToolInputSchema,
|
||||
queueReleaseToolInputSchema,
|
||||
queueStatusToolInputSchema,
|
||||
} from './mcp-tool-schemas.js';
|
||||
import {
|
||||
RedisTaskRepository,
|
||||
type RedisTaskClient,
|
||||
} from './task-repository.js';
|
||||
import { TASK_STATUSES } from './task.js';
|
||||
import type { Task, TaskStatus } from '@mosaic/types';
|
||||
|
||||
export type QueueMcpRepository = Pick<
|
||||
RedisTaskRepository,
|
||||
'list' | 'get' | 'claim' | 'heartbeat' | 'release' | 'complete' | 'fail'
|
||||
>;
|
||||
|
||||
export interface QueueMcpSession {
|
||||
readonly repository: QueueMcpRepository;
|
||||
readonly checkHealth: () => Promise<RedisHealthCheck>;
|
||||
readonly close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface QueueMcpDependencies {
|
||||
readonly openSession: () => Promise<QueueMcpSession>;
|
||||
readonly serverInfo: Implementation;
|
||||
}
|
||||
|
||||
type ToolSchema = z.ZodTypeAny;
|
||||
|
||||
interface QueueMcpToolDefinition<TArgs extends z.ZodTypeAny> {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly inputSchema: TArgs;
|
||||
readonly execute: (
|
||||
session: QueueMcpSession,
|
||||
input: z.output<TArgs>,
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ClosableRedisTaskClient extends RedisTaskClient {
|
||||
ping(): Promise<string>;
|
||||
quit(): Promise<string>;
|
||||
}
|
||||
|
||||
const DEFAULT_SERVER_INFO: Implementation = {
|
||||
name: 'mosaic-queue',
|
||||
version: '0.1.0',
|
||||
};
|
||||
|
||||
const DEFAULT_DEPENDENCIES: QueueMcpDependencies = {
|
||||
openSession: openRedisMcpSession,
|
||||
serverInfo: DEFAULT_SERVER_INFO,
|
||||
};
|
||||
|
||||
export const QUEUE_MCP_TOOL_DEFINITIONS = [
|
||||
{
|
||||
name: 'queue_list',
|
||||
description: 'List queue tasks with optional project/mission/status filters',
|
||||
inputSchema: queueListToolInputSchema,
|
||||
execute: async (session: QueueMcpSession, input: z.output<typeof queueListToolInputSchema>) => {
|
||||
const tasks = await session.repository.list(input);
|
||||
return {
|
||||
tasks,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'queue_get',
|
||||
description: 'Get a single queue task by taskId',
|
||||
inputSchema: queueGetToolInputSchema,
|
||||
execute: async (session: QueueMcpSession, input: z.output<typeof queueGetToolInputSchema>) => {
|
||||
const task = await session.repository.get(input.taskId);
|
||||
return {
|
||||
task,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'queue_claim',
|
||||
description: 'Atomically claim a task for an agent',
|
||||
inputSchema: queueClaimToolInputSchema,
|
||||
execute: async (session: QueueMcpSession, input: z.output<typeof queueClaimToolInputSchema>) => {
|
||||
const task = await session.repository.claim(input.taskId, {
|
||||
agentId: input.agentId,
|
||||
ttlSeconds: input.ttlSeconds,
|
||||
});
|
||||
|
||||
return {
|
||||
task,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'queue_heartbeat',
|
||||
description: 'Refresh claim ownership TTL for a task',
|
||||
inputSchema: queueHeartbeatToolInputSchema,
|
||||
execute: async (session: QueueMcpSession, input: z.output<typeof queueHeartbeatToolInputSchema>) => {
|
||||
const task = await session.repository.heartbeat(input.taskId, {
|
||||
agentId: input.agentId,
|
||||
ttlSeconds: input.ttlSeconds,
|
||||
});
|
||||
|
||||
return {
|
||||
task,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'queue_release',
|
||||
description: 'Release a claimed task back to pending',
|
||||
inputSchema: queueReleaseToolInputSchema,
|
||||
execute: async (session: QueueMcpSession, input: z.output<typeof queueReleaseToolInputSchema>) => {
|
||||
const task = await session.repository.release(input.taskId, {
|
||||
agentId: input.agentId,
|
||||
});
|
||||
|
||||
return {
|
||||
task,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'queue_complete',
|
||||
description: 'Mark a claimed task as completed',
|
||||
inputSchema: queueCompleteToolInputSchema,
|
||||
execute: async (session: QueueMcpSession, input: z.output<typeof queueCompleteToolInputSchema>) => {
|
||||
const task = await session.repository.complete(input.taskId, {
|
||||
agentId: input.agentId,
|
||||
summary: input.summary,
|
||||
});
|
||||
|
||||
return {
|
||||
task,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'queue_fail',
|
||||
description: 'Mark a claimed task as failed with a reason',
|
||||
inputSchema: queueFailToolInputSchema,
|
||||
execute: async (session: QueueMcpSession, input: z.output<typeof queueFailToolInputSchema>) => {
|
||||
const task = await session.repository.fail(input.taskId, {
|
||||
agentId: input.agentId,
|
||||
reason: input.reason,
|
||||
});
|
||||
|
||||
return {
|
||||
task,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'queue_status',
|
||||
description: 'Return queue health and task status counters',
|
||||
inputSchema: queueStatusToolInputSchema,
|
||||
execute: async (session: QueueMcpSession) => {
|
||||
const tasks = await session.repository.list({});
|
||||
const health = await session.checkHealth();
|
||||
const counts = countStatuses(tasks);
|
||||
|
||||
return {
|
||||
health,
|
||||
counts,
|
||||
total: tasks.length,
|
||||
};
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function buildQueueMcpServer(
|
||||
dependencyOverrides: Partial<QueueMcpDependencies> = {},
|
||||
): McpServer {
|
||||
const dependencies = resolveDependencies(dependencyOverrides);
|
||||
const server = new McpServer(dependencies.serverInfo);
|
||||
|
||||
for (const definition of QUEUE_MCP_TOOL_DEFINITIONS) {
|
||||
server.registerTool(
|
||||
definition.name,
|
||||
{
|
||||
description: definition.description,
|
||||
inputSchema: definition.inputSchema,
|
||||
},
|
||||
async (args: unknown) => {
|
||||
return withSession(dependencies, async (session) => {
|
||||
try {
|
||||
const parsedArgs = definition.inputSchema.parse(args);
|
||||
const response = await definition.execute(session, parsedArgs as never);
|
||||
return toToolResult(response);
|
||||
} catch (error) {
|
||||
return toToolErrorResult(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function startQueueMcpServer(
|
||||
dependencyOverrides: Partial<QueueMcpDependencies> = {},
|
||||
): Promise<McpServer> {
|
||||
const server = buildQueueMcpServer(dependencyOverrides);
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
return server;
|
||||
}
|
||||
|
||||
function resolveDependencies(
|
||||
overrides: Partial<QueueMcpDependencies>,
|
||||
): QueueMcpDependencies {
|
||||
const openSession = overrides.openSession ?? DEFAULT_DEPENDENCIES.openSession;
|
||||
const serverInfo = overrides.serverInfo ?? DEFAULT_DEPENDENCIES.serverInfo;
|
||||
|
||||
return {
|
||||
openSession: () => openSession(),
|
||||
serverInfo,
|
||||
};
|
||||
}
|
||||
|
||||
async function withSession(
|
||||
dependencies: QueueMcpDependencies,
|
||||
handler: (session: QueueMcpSession) => Promise<CallToolResult>,
|
||||
): Promise<CallToolResult> {
|
||||
const session = await dependencies.openSession();
|
||||
|
||||
try {
|
||||
return await handler(session);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function openRedisMcpSession(): Promise<QueueMcpSession> {
|
||||
const redisClient = createRedisClient<ClosableRedisTaskClient>();
|
||||
|
||||
try {
|
||||
await assertRedisHealthy(redisClient);
|
||||
|
||||
return {
|
||||
repository: new RedisTaskRepository({
|
||||
client: redisClient,
|
||||
}),
|
||||
checkHealth: async () => runRedisHealthCheck(redisClient),
|
||||
close: async () => {
|
||||
await redisClient.quit();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await redisClient.quit();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function toToolResult(payload: unknown): CallToolResult {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(payload, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function toToolErrorResult(error: unknown): CallToolResult {
|
||||
return {
|
||||
isError: true,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: formatError(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function countStatuses(tasks: readonly Task[]): Record<TaskStatus, number> {
|
||||
const counts = Object.fromEntries(TASK_STATUSES.map((status) => [status, 0])) as Record<
|
||||
TaskStatus,
|
||||
number
|
||||
>;
|
||||
|
||||
for (const task of tasks) {
|
||||
counts[task.status] += 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
44
packages/queue/src/mcp-tool-schemas.ts
Normal file
44
packages/queue/src/mcp-tool-schemas.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { TASK_STATUSES } from './task.js';
|
||||
|
||||
export const queueListToolInputSchema = z.object({
|
||||
project: z.string().min(1).optional(),
|
||||
mission: z.string().min(1).optional(),
|
||||
status: z.enum(TASK_STATUSES).optional(),
|
||||
});
|
||||
|
||||
export const queueGetToolInputSchema = z.object({
|
||||
taskId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const queueClaimToolInputSchema = z.object({
|
||||
taskId: z.string().min(1),
|
||||
agentId: z.string().min(1),
|
||||
ttlSeconds: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const queueHeartbeatToolInputSchema = z.object({
|
||||
taskId: z.string().min(1),
|
||||
agentId: z.string().min(1).optional(),
|
||||
ttlSeconds: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export const queueReleaseToolInputSchema = z.object({
|
||||
taskId: z.string().min(1),
|
||||
agentId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const queueCompleteToolInputSchema = z.object({
|
||||
taskId: z.string().min(1),
|
||||
agentId: z.string().min(1).optional(),
|
||||
summary: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const queueFailToolInputSchema = z.object({
|
||||
taskId: z.string().min(1),
|
||||
agentId: z.string().min(1).optional(),
|
||||
reason: z.string().min(1),
|
||||
});
|
||||
|
||||
export const queueStatusToolInputSchema = z.object({});
|
||||
95
packages/queue/src/redis-connection.ts
Normal file
95
packages/queue/src/redis-connection.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import Redis, { type Redis as RedisClient, type RedisOptions } from 'ioredis';
|
||||
|
||||
const ERR_MISSING_REDIS_URL =
|
||||
'Missing required Valkey/Redis connection URL. Set VALKEY_URL or REDIS_URL.';
|
||||
|
||||
export interface RedisHealthCheck {
|
||||
readonly checkedAt: number;
|
||||
readonly latencyMs: number;
|
||||
readonly ok: boolean;
|
||||
readonly response?: string;
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
export interface RedisPingClient {
|
||||
ping(): Promise<string>;
|
||||
}
|
||||
|
||||
export type RedisClientConstructor<TClient> = new (
|
||||
url: string,
|
||||
options?: RedisOptions,
|
||||
) => TClient;
|
||||
|
||||
export interface CreateRedisClientOptions<TClient> {
|
||||
readonly env?: NodeJS.ProcessEnv;
|
||||
readonly redisConstructor?: RedisClientConstructor<TClient>;
|
||||
readonly redisOptions?: RedisOptions;
|
||||
}
|
||||
|
||||
export function resolveRedisUrl(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const resolvedUrl = env.VALKEY_URL ?? env.REDIS_URL;
|
||||
|
||||
if (typeof resolvedUrl !== 'string' || resolvedUrl.trim().length === 0) {
|
||||
throw new Error(ERR_MISSING_REDIS_URL);
|
||||
}
|
||||
|
||||
return resolvedUrl;
|
||||
}
|
||||
|
||||
export function createRedisClient<TClient = RedisClient>(
|
||||
options: CreateRedisClientOptions<TClient> = {},
|
||||
): TClient {
|
||||
const redisUrl = resolveRedisUrl(options.env);
|
||||
|
||||
const RedisCtor =
|
||||
options.redisConstructor ??
|
||||
(Redis as unknown as RedisClientConstructor<TClient>);
|
||||
|
||||
return new RedisCtor(redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
...options.redisOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runRedisHealthCheck(
|
||||
client: RedisPingClient,
|
||||
): Promise<RedisHealthCheck> {
|
||||
const startedAt = process.hrtime.bigint();
|
||||
|
||||
try {
|
||||
const response = await client.ping();
|
||||
const elapsedMs = Number((process.hrtime.bigint() - startedAt) / 1_000_000n);
|
||||
|
||||
return {
|
||||
checkedAt: Date.now(),
|
||||
latencyMs: elapsedMs,
|
||||
ok: true,
|
||||
response,
|
||||
};
|
||||
} catch (error) {
|
||||
const elapsedMs = Number((process.hrtime.bigint() - startedAt) / 1_000_000n);
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unknown redis health check error';
|
||||
|
||||
return {
|
||||
checkedAt: Date.now(),
|
||||
latencyMs: elapsedMs,
|
||||
ok: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertRedisHealthy(
|
||||
client: RedisPingClient,
|
||||
): Promise<RedisHealthCheck> {
|
||||
const health = await runRedisHealthCheck(client);
|
||||
|
||||
if (!health.ok) {
|
||||
throw new Error(
|
||||
`Redis health check failed after ${health.latencyMs}ms: ${health.error ?? 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
638
packages/queue/src/task-repository.ts
Normal file
638
packages/queue/src/task-repository.ts
Normal file
@@ -0,0 +1,638 @@
|
||||
import { TASK_LANES, TASK_PRIORITIES, TASK_STATUSES } from './task.js';
|
||||
import type {
|
||||
CreateTaskInput,
|
||||
Task,
|
||||
TaskLane,
|
||||
TaskListFilters,
|
||||
TaskPriority,
|
||||
TaskStatus,
|
||||
TaskUpdateInput,
|
||||
} from '@mosaic/types';
|
||||
|
||||
const STATUS_SET = new Set<TaskStatus>(TASK_STATUSES);
|
||||
const PRIORITY_SET = new Set<TaskPriority>(TASK_PRIORITIES);
|
||||
const LANE_SET = new Set<TaskLane>(TASK_LANES);
|
||||
|
||||
const DEFAULT_KEY_PREFIX = 'mosaic:queue';
|
||||
const MAX_ATOMIC_RETRIES = 8;
|
||||
const UPDATE_ALLOWED_STATUS_TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = {
|
||||
pending: ['blocked'],
|
||||
blocked: ['pending'],
|
||||
claimed: ['in-progress'],
|
||||
'in-progress': ['claimed'],
|
||||
completed: [],
|
||||
failed: [],
|
||||
};
|
||||
|
||||
interface RepositoryKeys {
|
||||
readonly taskIds: string;
|
||||
task(taskId: string): string;
|
||||
}
|
||||
|
||||
export interface RedisTaskClient {
|
||||
get(key: string): Promise<string | null>;
|
||||
mget(...keys: string[]): Promise<(string | null)[]>;
|
||||
set(key: string, value: string, mode?: 'NX' | 'XX'): Promise<'OK' | null>;
|
||||
smembers(key: string): Promise<string[]>;
|
||||
sadd(key: string, member: string): Promise<number>;
|
||||
watch(...keys: string[]): Promise<'OK'>;
|
||||
unwatch(): Promise<'OK'>;
|
||||
multi(): RedisTaskTransaction;
|
||||
}
|
||||
|
||||
export interface RedisTaskTransaction {
|
||||
set(key: string, value: string, mode?: 'NX' | 'XX'): RedisTaskTransaction;
|
||||
sadd(key: string, member: string): RedisTaskTransaction;
|
||||
exec(): Promise<readonly (readonly [Error | null, unknown])[] | null>;
|
||||
}
|
||||
|
||||
export interface RedisTaskRepositoryOptions {
|
||||
readonly client: RedisTaskClient;
|
||||
readonly keyPrefix?: string;
|
||||
readonly now?: () => number;
|
||||
}
|
||||
|
||||
export interface ClaimTaskInput {
|
||||
readonly agentId: string;
|
||||
readonly ttlSeconds: number;
|
||||
}
|
||||
|
||||
export interface ReleaseTaskInput {
|
||||
readonly agentId?: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatTaskInput {
|
||||
readonly agentId?: string;
|
||||
readonly ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface CompleteTaskInput {
|
||||
readonly agentId?: string;
|
||||
readonly summary?: string;
|
||||
}
|
||||
|
||||
export interface FailTaskInput {
|
||||
readonly agentId?: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export class TaskAlreadyExistsError extends Error {
|
||||
public constructor(taskId: string) {
|
||||
super(`Task ${taskId} already exists.`);
|
||||
this.name = 'TaskAlreadyExistsError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskNotFoundError extends Error {
|
||||
public constructor(taskId: string) {
|
||||
super(`Task ${taskId} was not found.`);
|
||||
this.name = 'TaskNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskSerializationError extends Error {
|
||||
public constructor(taskId: string, message: string) {
|
||||
super(`Unable to deserialize task ${taskId}: ${message}`);
|
||||
this.name = 'TaskSerializationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskTransitionError extends Error {
|
||||
public constructor(taskId: string, status: TaskStatus, action: string) {
|
||||
super(`Task ${taskId} cannot transition from ${status} via ${action}.`);
|
||||
this.name = 'TaskTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskOwnershipError extends Error {
|
||||
public constructor(taskId: string, expectedAgentId: string, actualAgentId: string) {
|
||||
super(
|
||||
`Task ${taskId} is owned by ${actualAgentId}, not ${expectedAgentId}.`,
|
||||
);
|
||||
this.name = 'TaskOwnershipError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskAtomicConflictError extends Error {
|
||||
public constructor(taskId: string) {
|
||||
super(`Task ${taskId} could not be updated atomically after multiple retries.`);
|
||||
this.name = 'TaskAtomicConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RedisTaskRepository {
|
||||
private readonly client: RedisTaskClient;
|
||||
private readonly keys: RepositoryKeys;
|
||||
private readonly now: () => number;
|
||||
|
||||
public constructor(options: RedisTaskRepositoryOptions) {
|
||||
this.client = options.client;
|
||||
this.keys = buildRepositoryKeys(options.keyPrefix ?? DEFAULT_KEY_PREFIX);
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
public async create(input: CreateTaskInput): Promise<Task> {
|
||||
const timestamp = this.now();
|
||||
|
||||
const task: Task = {
|
||||
id: input.taskId,
|
||||
project: input.project,
|
||||
mission: input.mission,
|
||||
taskId: input.taskId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: 'pending',
|
||||
priority: input.priority ?? 'medium',
|
||||
dependencies: [...(input.dependencies ?? [])],
|
||||
lane: input.lane ?? 'any',
|
||||
retryCount: 0,
|
||||
metadata: input.metadata,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
const taskKey = this.keys.task(task.taskId);
|
||||
const serializedTask = JSON.stringify(task);
|
||||
|
||||
for (let attempt = 0; attempt < MAX_ATOMIC_RETRIES; attempt += 1) {
|
||||
await this.client.watch(taskKey);
|
||||
|
||||
try {
|
||||
const transaction = this.client.multi();
|
||||
transaction.set(taskKey, serializedTask, 'NX');
|
||||
transaction.sadd(this.keys.taskIds, task.taskId);
|
||||
const execResult = await transaction.exec();
|
||||
|
||||
if (execResult === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const setResult = execResult[0];
|
||||
|
||||
if (setResult === undefined) {
|
||||
throw new TaskAtomicConflictError(task.taskId);
|
||||
}
|
||||
|
||||
const [setError, setReply] = setResult;
|
||||
|
||||
if (setError !== null) {
|
||||
throw setError;
|
||||
}
|
||||
|
||||
if (setReply !== 'OK') {
|
||||
throw new TaskAlreadyExistsError(task.taskId);
|
||||
}
|
||||
|
||||
const saddResult = execResult[1];
|
||||
if (saddResult !== undefined && saddResult[0] !== null) {
|
||||
throw saddResult[0];
|
||||
}
|
||||
|
||||
return task;
|
||||
} finally {
|
||||
await this.client.unwatch();
|
||||
}
|
||||
}
|
||||
|
||||
throw new TaskAlreadyExistsError(task.taskId);
|
||||
}
|
||||
|
||||
public async get(taskId: string): Promise<Task | null> {
|
||||
const raw = await this.client.get(this.keys.task(taskId));
|
||||
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return deserializeTask(taskId, raw);
|
||||
}
|
||||
|
||||
public async list(filters: TaskListFilters = {}): Promise<Task[]> {
|
||||
const taskIds = await this.client.smembers(this.keys.taskIds);
|
||||
|
||||
if (taskIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const taskKeys = taskIds.map((taskId) => this.keys.task(taskId));
|
||||
const records = await this.client.mget(...taskKeys);
|
||||
const tasks: Task[] = [];
|
||||
|
||||
for (const [index, rawTask] of records.entries()) {
|
||||
if (rawTask === null || rawTask === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const taskId = taskIds[index];
|
||||
|
||||
if (taskId === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tasks.push(deserializeTask(taskId, rawTask));
|
||||
}
|
||||
|
||||
return tasks
|
||||
.filter((task) =>
|
||||
matchesFilters(task, {
|
||||
project: filters.project,
|
||||
mission: filters.mission,
|
||||
status: filters.status,
|
||||
}),
|
||||
)
|
||||
.sort((left, right) => left.createdAt - right.createdAt);
|
||||
}
|
||||
|
||||
public async update(taskId: string, patch: TaskUpdateInput): Promise<Task> {
|
||||
return this.mutateTaskAtomically(taskId, (existing, now) => {
|
||||
assertUpdatePatchIsAllowed(taskId, existing, patch);
|
||||
|
||||
return {
|
||||
...existing,
|
||||
...patch,
|
||||
dependencies:
|
||||
patch.dependencies === undefined ? existing.dependencies : [...patch.dependencies],
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async claim(taskId: string, input: ClaimTaskInput): Promise<Task> {
|
||||
if (input.ttlSeconds <= 0) {
|
||||
throw new Error(`Task ${taskId} claim ttl must be greater than 0 seconds.`);
|
||||
}
|
||||
|
||||
return this.mutateTaskAtomically(taskId, (existing, now) => {
|
||||
if (!canClaimTask(existing, now)) {
|
||||
throw new TaskTransitionError(taskId, existing.status, 'claim');
|
||||
}
|
||||
|
||||
const base = withoutCompletionAndFailureFields(withoutClaimFields(existing));
|
||||
|
||||
return {
|
||||
...base,
|
||||
status: 'claimed',
|
||||
claimedBy: input.agentId,
|
||||
claimedAt: now,
|
||||
claimTTL: input.ttlSeconds,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async release(taskId: string, input: ReleaseTaskInput = {}): Promise<Task> {
|
||||
return this.mutateTaskAtomically(taskId, (existing, now) => {
|
||||
if (!isClaimedLikeStatus(existing.status)) {
|
||||
throw new TaskTransitionError(taskId, existing.status, 'release');
|
||||
}
|
||||
|
||||
assertTaskOwnership(taskId, existing, input.agentId);
|
||||
|
||||
const base = withoutClaimFields(existing);
|
||||
|
||||
return {
|
||||
...base,
|
||||
status: 'pending',
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async heartbeat(
|
||||
taskId: string,
|
||||
input: HeartbeatTaskInput = {},
|
||||
): Promise<Task> {
|
||||
return this.mutateTaskAtomically(taskId, (existing, now) => {
|
||||
if (!isClaimedLikeStatus(existing.status)) {
|
||||
throw new TaskTransitionError(taskId, existing.status, 'heartbeat');
|
||||
}
|
||||
|
||||
if (isClaimExpired(existing, now)) {
|
||||
throw new TaskTransitionError(taskId, existing.status, 'heartbeat');
|
||||
}
|
||||
|
||||
assertTaskOwnership(taskId, existing, input.agentId);
|
||||
|
||||
const ttl = input.ttlSeconds ?? existing.claimTTL;
|
||||
|
||||
if (ttl === undefined || ttl <= 0) {
|
||||
throw new TaskTransitionError(taskId, existing.status, 'heartbeat');
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
claimedAt: now,
|
||||
claimTTL: ttl,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async complete(
|
||||
taskId: string,
|
||||
input: CompleteTaskInput = {},
|
||||
): Promise<Task> {
|
||||
return this.mutateTaskAtomically(taskId, (existing, now) => {
|
||||
if (!isClaimedLikeStatus(existing.status)) {
|
||||
throw new TaskTransitionError(taskId, existing.status, 'complete');
|
||||
}
|
||||
|
||||
assertTaskOwnership(taskId, existing, input.agentId);
|
||||
|
||||
const base = withoutCompletionAndFailureFields(withoutClaimFields(existing));
|
||||
|
||||
return {
|
||||
...base,
|
||||
status: 'completed',
|
||||
completedAt: now,
|
||||
...(input.summary === undefined ? {} : { completionSummary: input.summary }),
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async fail(taskId: string, input: FailTaskInput): Promise<Task> {
|
||||
return this.mutateTaskAtomically(taskId, (existing, now) => {
|
||||
if (!isClaimedLikeStatus(existing.status)) {
|
||||
throw new TaskTransitionError(taskId, existing.status, 'fail');
|
||||
}
|
||||
|
||||
assertTaskOwnership(taskId, existing, input.agentId);
|
||||
|
||||
const base = withoutCompletionAndFailureFields(withoutClaimFields(existing));
|
||||
|
||||
return {
|
||||
...base,
|
||||
status: 'failed',
|
||||
failedAt: now,
|
||||
failureReason: input.reason,
|
||||
retryCount: existing.retryCount + 1,
|
||||
updatedAt: now,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async mutateTaskAtomically(
|
||||
taskId: string,
|
||||
mutation: (existing: Task, now: number) => Task,
|
||||
): Promise<Task> {
|
||||
const taskKey = this.keys.task(taskId);
|
||||
|
||||
for (let attempt = 0; attempt < MAX_ATOMIC_RETRIES; attempt += 1) {
|
||||
await this.client.watch(taskKey);
|
||||
|
||||
try {
|
||||
const raw = await this.client.get(taskKey);
|
||||
|
||||
if (raw === null) {
|
||||
throw new TaskNotFoundError(taskId);
|
||||
}
|
||||
|
||||
const existing = deserializeTask(taskId, raw);
|
||||
const updated = mutation(existing, this.now());
|
||||
|
||||
const transaction = this.client.multi();
|
||||
transaction.set(taskKey, JSON.stringify(updated), 'XX');
|
||||
transaction.sadd(this.keys.taskIds, taskId);
|
||||
const execResult = await transaction.exec();
|
||||
|
||||
if (execResult === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const setResult = execResult[0];
|
||||
if (setResult === undefined) {
|
||||
throw new TaskAtomicConflictError(taskId);
|
||||
}
|
||||
|
||||
const [setError, setReply] = setResult;
|
||||
|
||||
if (setError !== null) {
|
||||
throw setError;
|
||||
}
|
||||
|
||||
if (setReply !== 'OK') {
|
||||
throw new TaskNotFoundError(taskId);
|
||||
}
|
||||
|
||||
const saddResult = execResult[1];
|
||||
if (saddResult !== undefined && saddResult[0] !== null) {
|
||||
throw saddResult[0];
|
||||
}
|
||||
|
||||
return updated;
|
||||
} finally {
|
||||
await this.client.unwatch();
|
||||
}
|
||||
}
|
||||
|
||||
throw new TaskAtomicConflictError(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
function matchesFilters(task: Task, filters: TaskListFilters): boolean {
|
||||
if (filters.project !== undefined && task.project !== filters.project) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.mission !== undefined && task.mission !== filters.mission) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filters.status !== undefined && task.status !== filters.status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function assertUpdatePatchIsAllowed(taskId: string, task: Task, patch: TaskUpdateInput): void {
|
||||
if (patch.status !== undefined && !canTransitionStatusViaUpdate(task.status, patch.status)) {
|
||||
throw new TaskTransitionError(taskId, task.status, 'update');
|
||||
}
|
||||
|
||||
if (
|
||||
patch.claimedBy !== undefined ||
|
||||
patch.claimedAt !== undefined ||
|
||||
patch.claimTTL !== undefined ||
|
||||
patch.completedAt !== undefined ||
|
||||
patch.failedAt !== undefined ||
|
||||
patch.failureReason !== undefined ||
|
||||
patch.completionSummary !== undefined ||
|
||||
patch.retryCount !== undefined
|
||||
) {
|
||||
throw new TaskTransitionError(taskId, task.status, 'update');
|
||||
}
|
||||
}
|
||||
|
||||
function canTransitionStatusViaUpdate(from: TaskStatus, to: TaskStatus): boolean {
|
||||
if (from === to) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return UPDATE_ALLOWED_STATUS_TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
function canClaimTask(task: Task, now: number): boolean {
|
||||
if (task.status === 'pending') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isClaimedLikeStatus(task.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isClaimExpired(task, now);
|
||||
}
|
||||
|
||||
function isClaimedLikeStatus(status: TaskStatus): boolean {
|
||||
return status === 'claimed' || status === 'in-progress';
|
||||
}
|
||||
|
||||
function isClaimExpired(task: Task, now: number): boolean {
|
||||
if (task.claimedAt === undefined || task.claimTTL === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return task.claimedAt + task.claimTTL * 1000 <= now;
|
||||
}
|
||||
|
||||
function assertTaskOwnership(
|
||||
taskId: string,
|
||||
task: Task,
|
||||
expectedAgentId: string | undefined,
|
||||
): void {
|
||||
if (expectedAgentId === undefined || task.claimedBy === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.claimedBy !== expectedAgentId) {
|
||||
throw new TaskOwnershipError(taskId, expectedAgentId, task.claimedBy);
|
||||
}
|
||||
}
|
||||
|
||||
type TaskWithoutClaimFields = Omit<Task, 'claimedBy' | 'claimedAt' | 'claimTTL'>;
|
||||
type TaskWithoutCompletionAndFailureFields = Omit<
|
||||
Task,
|
||||
'completedAt' | 'failedAt' | 'failureReason' | 'completionSummary'
|
||||
>;
|
||||
|
||||
function withoutClaimFields(task: Task): TaskWithoutClaimFields {
|
||||
const {
|
||||
claimedBy: _claimedBy,
|
||||
claimedAt: _claimedAt,
|
||||
claimTTL: _claimTTL,
|
||||
...taskWithoutClaimFields
|
||||
} = task;
|
||||
|
||||
return taskWithoutClaimFields;
|
||||
}
|
||||
|
||||
function withoutCompletionAndFailureFields(
|
||||
task: TaskWithoutClaimFields,
|
||||
): TaskWithoutCompletionAndFailureFields {
|
||||
const {
|
||||
completedAt: _completedAt,
|
||||
failedAt: _failedAt,
|
||||
failureReason: _failureReason,
|
||||
completionSummary: _completionSummary,
|
||||
...taskWithoutCompletionAndFailureFields
|
||||
} = task;
|
||||
|
||||
return taskWithoutCompletionAndFailureFields;
|
||||
}
|
||||
|
||||
function deserializeTask(taskId: string, raw: string): Task {
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new TaskSerializationError(
|
||||
taskId,
|
||||
error instanceof Error ? error.message : 'invalid JSON',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
throw new TaskSerializationError(taskId, 'task payload is not an object');
|
||||
}
|
||||
|
||||
const requiredStringKeys = ['id', 'project', 'mission', 'taskId', 'title'] as const;
|
||||
const requiredNumberKeys = ['retryCount', 'createdAt', 'updatedAt'] as const;
|
||||
|
||||
for (const key of requiredStringKeys) {
|
||||
if (typeof parsed[key] !== 'string') {
|
||||
throw new TaskSerializationError(taskId, `missing string field "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of requiredNumberKeys) {
|
||||
if (typeof parsed[key] !== 'number') {
|
||||
throw new TaskSerializationError(taskId, `missing numeric field "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!STATUS_SET.has(parsed.status as TaskStatus)) {
|
||||
throw new TaskSerializationError(taskId, 'invalid status value');
|
||||
}
|
||||
|
||||
if (!PRIORITY_SET.has(parsed.priority as TaskPriority)) {
|
||||
throw new TaskSerializationError(taskId, 'invalid priority value');
|
||||
}
|
||||
|
||||
if (!LANE_SET.has(parsed.lane as TaskLane)) {
|
||||
throw new TaskSerializationError(taskId, 'invalid lane value');
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed.dependencies)) {
|
||||
throw new TaskSerializationError(taskId, 'dependencies must be an array');
|
||||
}
|
||||
|
||||
if (!parsed.dependencies.every((dependency): dependency is string => typeof dependency === 'string')) {
|
||||
throw new TaskSerializationError(taskId, 'dependencies must contain only strings');
|
||||
}
|
||||
|
||||
return {
|
||||
id: parsed.id as string,
|
||||
project: parsed.project as string,
|
||||
mission: parsed.mission as string,
|
||||
taskId: parsed.taskId as string,
|
||||
title: parsed.title as string,
|
||||
status: parsed.status as TaskStatus,
|
||||
priority: parsed.priority as TaskPriority,
|
||||
dependencies: parsed.dependencies as string[],
|
||||
lane: parsed.lane as TaskLane,
|
||||
retryCount: parsed.retryCount as number,
|
||||
createdAt: parsed.createdAt as number,
|
||||
updatedAt: parsed.updatedAt as number,
|
||||
...(typeof parsed.description === 'string'
|
||||
? { description: parsed.description }
|
||||
: {}),
|
||||
...(typeof parsed.claimedBy === 'string' ? { claimedBy: parsed.claimedBy } : {}),
|
||||
...(typeof parsed.claimedAt === 'number' ? { claimedAt: parsed.claimedAt } : {}),
|
||||
...(typeof parsed.claimTTL === 'number' ? { claimTTL: parsed.claimTTL } : {}),
|
||||
...(typeof parsed.completedAt === 'number'
|
||||
? { completedAt: parsed.completedAt }
|
||||
: {}),
|
||||
...(typeof parsed.failedAt === 'number' ? { failedAt: parsed.failedAt } : {}),
|
||||
...(typeof parsed.failureReason === 'string'
|
||||
? { failureReason: parsed.failureReason }
|
||||
: {}),
|
||||
...(typeof parsed.completionSummary === 'string'
|
||||
? { completionSummary: parsed.completionSummary }
|
||||
: {}),
|
||||
...(isRecord(parsed.metadata) ? { metadata: parsed.metadata } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRepositoryKeys(keyPrefix: string): RepositoryKeys {
|
||||
return {
|
||||
taskIds: `${keyPrefix}:task-ids`,
|
||||
task: (taskId: string) => `${keyPrefix}:task:${taskId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
12
packages/queue/src/task.ts
Normal file
12
packages/queue/src/task.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const TASK_STATUSES = [
|
||||
'pending',
|
||||
'claimed',
|
||||
'in-progress',
|
||||
'completed',
|
||||
'failed',
|
||||
'blocked',
|
||||
] as const;
|
||||
|
||||
export const TASK_PRIORITIES = ['critical', 'high', 'medium', 'low'] as const;
|
||||
|
||||
export const TASK_LANES = ['planning', 'coding', 'any'] as const;
|
||||
219
packages/queue/tests/cli.test.ts
Normal file
219
packages/queue/tests/cli.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { runQueueCli, type QueueCliDependencies, type QueueRepository } from '../src/cli.js';
|
||||
import type { Task } from '@mosaic/types';
|
||||
|
||||
function createRepositoryMock(): QueueRepository {
|
||||
return {
|
||||
create: vi.fn(() =>
|
||||
Promise.resolve<Task>({
|
||||
id: 'MQ-005',
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-005',
|
||||
title: 'Build CLI',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
lane: 'any',
|
||||
retryCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
}),
|
||||
),
|
||||
list: vi.fn(() => Promise.resolve([])),
|
||||
get: vi.fn(() => Promise.resolve(null)),
|
||||
claim: vi.fn(() =>
|
||||
Promise.resolve<Task>({
|
||||
id: 'MQ-005',
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-005',
|
||||
title: 'Build CLI',
|
||||
status: 'claimed',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
lane: 'any',
|
||||
claimedBy: 'agent-a',
|
||||
claimedAt: 2,
|
||||
claimTTL: 60,
|
||||
retryCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
}),
|
||||
),
|
||||
release: vi.fn(() =>
|
||||
Promise.resolve<Task>({
|
||||
id: 'MQ-005',
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-005',
|
||||
title: 'Build CLI',
|
||||
status: 'pending',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
lane: 'any',
|
||||
retryCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 3,
|
||||
}),
|
||||
),
|
||||
complete: vi.fn(() =>
|
||||
Promise.resolve<Task>({
|
||||
id: 'MQ-005',
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-005',
|
||||
title: 'Build CLI',
|
||||
status: 'completed',
|
||||
priority: 'medium',
|
||||
dependencies: [],
|
||||
lane: 'any',
|
||||
completionSummary: 'done',
|
||||
retryCount: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 4,
|
||||
completedAt: 4,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createDependencies(
|
||||
repository: QueueRepository,
|
||||
): QueueCliDependencies & { outputs: string[]; errors: string[] } {
|
||||
const outputs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const close = vi.fn(() => Promise.resolve(undefined));
|
||||
|
||||
return {
|
||||
openSession: () =>
|
||||
Promise.resolve({
|
||||
repository,
|
||||
close,
|
||||
}),
|
||||
stdout: (line) => {
|
||||
outputs.push(line);
|
||||
},
|
||||
stderr: (line) => {
|
||||
errors.push(line);
|
||||
},
|
||||
outputs,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runQueueCli', () => {
|
||||
it('creates a task from command options', async () => {
|
||||
const repository = createRepositoryMock();
|
||||
const dependencies = createDependencies(repository);
|
||||
|
||||
const exitCode = await runQueueCli(
|
||||
[
|
||||
'node',
|
||||
'mosaic',
|
||||
'queue',
|
||||
'create',
|
||||
'queue',
|
||||
'phase1',
|
||||
'MQ-005',
|
||||
'--title',
|
||||
'Build CLI',
|
||||
'--priority',
|
||||
'high',
|
||||
'--lane',
|
||||
'coding',
|
||||
'--dependency',
|
||||
'MQ-002',
|
||||
'MQ-003',
|
||||
],
|
||||
dependencies,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(repository.create).toHaveBeenCalledWith({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-005',
|
||||
title: 'Build CLI',
|
||||
description: undefined,
|
||||
priority: 'high',
|
||||
dependencies: ['MQ-002', 'MQ-003'],
|
||||
lane: 'coding',
|
||||
});
|
||||
});
|
||||
|
||||
it('lists tasks with filters', async () => {
|
||||
const repository = createRepositoryMock();
|
||||
const dependencies = createDependencies(repository);
|
||||
|
||||
const exitCode = await runQueueCli(
|
||||
[
|
||||
'node',
|
||||
'mosaic',
|
||||
'queue',
|
||||
'list',
|
||||
'--project',
|
||||
'queue',
|
||||
'--mission',
|
||||
'phase1',
|
||||
'--status',
|
||||
'pending',
|
||||
],
|
||||
dependencies,
|
||||
);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(repository.list).toHaveBeenCalledWith({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
status: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('claims and completes tasks with typed options', async () => {
|
||||
const repository = createRepositoryMock();
|
||||
const dependencies = createDependencies(repository);
|
||||
|
||||
const claimExitCode = await runQueueCli(
|
||||
[
|
||||
'node',
|
||||
'mosaic',
|
||||
'queue',
|
||||
'claim',
|
||||
'MQ-005',
|
||||
'--agent',
|
||||
'agent-a',
|
||||
'--ttl',
|
||||
'60',
|
||||
],
|
||||
dependencies,
|
||||
);
|
||||
|
||||
const completeExitCode = await runQueueCli(
|
||||
[
|
||||
'node',
|
||||
'mosaic',
|
||||
'queue',
|
||||
'complete',
|
||||
'MQ-005',
|
||||
'--agent',
|
||||
'agent-a',
|
||||
'--summary',
|
||||
'done',
|
||||
],
|
||||
dependencies,
|
||||
);
|
||||
|
||||
expect(claimExitCode).toBe(0);
|
||||
expect(completeExitCode).toBe(0);
|
||||
expect(repository.claim).toHaveBeenCalledWith('MQ-005', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
expect(repository.complete).toHaveBeenCalledWith('MQ-005', {
|
||||
agentId: 'agent-a',
|
||||
summary: 'done',
|
||||
});
|
||||
});
|
||||
});
|
||||
50
packages/queue/tests/mcp-server.test.ts
Normal file
50
packages/queue/tests/mcp-server.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
QUEUE_MCP_TOOL_DEFINITIONS,
|
||||
buildQueueMcpServer,
|
||||
} from '../src/mcp-server.js';
|
||||
|
||||
describe('queue MCP server', () => {
|
||||
it('declares all required phase-1 tools', () => {
|
||||
const toolNames = QUEUE_MCP_TOOL_DEFINITIONS.map((tool) => tool.name).sort();
|
||||
|
||||
expect(toolNames).toEqual([
|
||||
'queue_claim',
|
||||
'queue_complete',
|
||||
'queue_fail',
|
||||
'queue_get',
|
||||
'queue_heartbeat',
|
||||
'queue_list',
|
||||
'queue_release',
|
||||
'queue_status',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds an MCP server instance', () => {
|
||||
const server = buildQueueMcpServer({
|
||||
openSession: () =>
|
||||
Promise.resolve({
|
||||
repository: {
|
||||
list: () => Promise.resolve([]),
|
||||
get: () => Promise.resolve(null),
|
||||
claim: () => Promise.reject(new Error('not implemented')),
|
||||
heartbeat: () => Promise.reject(new Error('not implemented')),
|
||||
release: () => Promise.reject(new Error('not implemented')),
|
||||
complete: () => Promise.reject(new Error('not implemented')),
|
||||
fail: () => Promise.reject(new Error('not implemented')),
|
||||
},
|
||||
checkHealth: () =>
|
||||
Promise.resolve({
|
||||
checkedAt: 1,
|
||||
latencyMs: 0,
|
||||
ok: true,
|
||||
response: 'PONG',
|
||||
}),
|
||||
close: () => Promise.resolve(),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(server).toBeDefined();
|
||||
});
|
||||
});
|
||||
90
packages/queue/tests/mcp-tool-schemas.test.ts
Normal file
90
packages/queue/tests/mcp-tool-schemas.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
queueClaimToolInputSchema,
|
||||
queueCompleteToolInputSchema,
|
||||
queueFailToolInputSchema,
|
||||
queueGetToolInputSchema,
|
||||
queueHeartbeatToolInputSchema,
|
||||
queueListToolInputSchema,
|
||||
queueReleaseToolInputSchema,
|
||||
queueStatusToolInputSchema,
|
||||
} from '../src/mcp-tool-schemas.js';
|
||||
|
||||
describe('MCP tool schemas', () => {
|
||||
it('validates queue_list filters', () => {
|
||||
const parsed = queueListToolInputSchema.parse({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
expect(parsed).toEqual({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
status: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('requires a taskId for queue_get', () => {
|
||||
expect(() => queueGetToolInputSchema.parse({})).toThrowError();
|
||||
});
|
||||
|
||||
it('requires positive ttlSeconds for queue_claim', () => {
|
||||
expect(() =>
|
||||
queueClaimToolInputSchema.parse({
|
||||
taskId: 'MQ-007',
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 0,
|
||||
}),
|
||||
).toThrowError();
|
||||
});
|
||||
|
||||
it('accepts optional fields for queue_heartbeat and queue_release', () => {
|
||||
const heartbeat = queueHeartbeatToolInputSchema.parse({
|
||||
taskId: 'MQ-007',
|
||||
ttlSeconds: 30,
|
||||
});
|
||||
|
||||
const release = queueReleaseToolInputSchema.parse({
|
||||
taskId: 'MQ-007',
|
||||
});
|
||||
|
||||
expect(heartbeat).toEqual({
|
||||
taskId: 'MQ-007',
|
||||
ttlSeconds: 30,
|
||||
});
|
||||
expect(release).toEqual({
|
||||
taskId: 'MQ-007',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates queue_complete and queue_fail payloads', () => {
|
||||
const complete = queueCompleteToolInputSchema.parse({
|
||||
taskId: 'MQ-007',
|
||||
agentId: 'agent-a',
|
||||
summary: 'done',
|
||||
});
|
||||
|
||||
const fail = queueFailToolInputSchema.parse({
|
||||
taskId: 'MQ-007',
|
||||
reason: 'boom',
|
||||
});
|
||||
|
||||
expect(complete).toEqual({
|
||||
taskId: 'MQ-007',
|
||||
agentId: 'agent-a',
|
||||
summary: 'done',
|
||||
});
|
||||
expect(fail).toEqual({
|
||||
taskId: 'MQ-007',
|
||||
reason: 'boom',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts an empty payload for queue_status', () => {
|
||||
const parsed = queueStatusToolInputSchema.parse({});
|
||||
|
||||
expect(parsed).toEqual({});
|
||||
});
|
||||
});
|
||||
76
packages/queue/tests/redis-connection.test.ts
Normal file
76
packages/queue/tests/redis-connection.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createRedisClient,
|
||||
resolveRedisUrl,
|
||||
runRedisHealthCheck,
|
||||
} from '../src/redis-connection.js';
|
||||
|
||||
describe('resolveRedisUrl', () => {
|
||||
it('prefers VALKEY_URL when both env vars are present', () => {
|
||||
const url = resolveRedisUrl({
|
||||
VALKEY_URL: 'redis://valkey.local:6379',
|
||||
REDIS_URL: 'redis://redis.local:6379',
|
||||
});
|
||||
|
||||
expect(url).toBe('redis://valkey.local:6379');
|
||||
});
|
||||
|
||||
it('falls back to REDIS_URL when VALKEY_URL is missing', () => {
|
||||
const url = resolveRedisUrl({
|
||||
REDIS_URL: 'redis://redis.local:6379',
|
||||
});
|
||||
|
||||
expect(url).toBe('redis://redis.local:6379');
|
||||
});
|
||||
|
||||
it('throws loudly when no redis environment variable exists', () => {
|
||||
expect(() => resolveRedisUrl({})).toThrowError(
|
||||
/Missing required Valkey\/Redis connection URL/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRedisClient', () => {
|
||||
it('uses env URL for client creation with no hardcoded defaults', () => {
|
||||
class FakeRedis {
|
||||
public readonly url: string;
|
||||
|
||||
public constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
const client = createRedisClient({
|
||||
env: {
|
||||
VALKEY_URL: 'redis://queue.local:6379',
|
||||
},
|
||||
redisConstructor: FakeRedis,
|
||||
});
|
||||
|
||||
expect(client.url).toBe('redis://queue.local:6379');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runRedisHealthCheck', () => {
|
||||
it('returns healthy status when ping succeeds', async () => {
|
||||
const health = await runRedisHealthCheck({
|
||||
ping: () => Promise.resolve('PONG'),
|
||||
});
|
||||
|
||||
expect(health.ok).toBe(true);
|
||||
expect(health.response).toBe('PONG');
|
||||
expect(health.latencyMs).toBeTypeOf('number');
|
||||
expect(health.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('returns unhealthy status when ping fails', async () => {
|
||||
const health = await runRedisHealthCheck({
|
||||
ping: () => Promise.reject(new Error('connection refused')),
|
||||
});
|
||||
|
||||
expect(health.ok).toBe(false);
|
||||
expect(health.error).toMatch(/connection refused/i);
|
||||
expect(health.latencyMs).toBeTypeOf('number');
|
||||
});
|
||||
});
|
||||
9
packages/queue/tests/smoke.test.ts
Normal file
9
packages/queue/tests/smoke.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { packageVersion } from '../src/index.js';
|
||||
|
||||
describe('package bootstrap', () => {
|
||||
it('exposes package version constant', () => {
|
||||
expect(packageVersion).toBe('0.1.0');
|
||||
});
|
||||
});
|
||||
459
packages/queue/tests/task-atomic.test.ts
Normal file
459
packages/queue/tests/task-atomic.test.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
RedisTaskRepository,
|
||||
TaskAlreadyExistsError,
|
||||
TaskOwnershipError,
|
||||
TaskTransitionError,
|
||||
type RedisTaskClient,
|
||||
type RedisTaskTransaction,
|
||||
} from '../src/task-repository.js';
|
||||
|
||||
type QueuedOperation =
|
||||
| {
|
||||
readonly type: 'set';
|
||||
readonly key: string;
|
||||
readonly value: string;
|
||||
readonly mode?: 'NX' | 'XX';
|
||||
}
|
||||
| {
|
||||
readonly type: 'sadd';
|
||||
readonly key: string;
|
||||
readonly member: string;
|
||||
};
|
||||
|
||||
class InMemoryRedisBackend {
|
||||
public readonly kv = new Map<string, string>();
|
||||
public readonly sets = new Map<string, Set<string>>();
|
||||
public readonly revisions = new Map<string, number>();
|
||||
|
||||
public getRevision(key: string): number {
|
||||
return this.revisions.get(key) ?? 0;
|
||||
}
|
||||
|
||||
public bumpRevision(key: string): void {
|
||||
this.revisions.set(key, this.getRevision(key) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryRedisTransaction implements RedisTaskTransaction {
|
||||
private readonly operations: QueuedOperation[] = [];
|
||||
|
||||
public constructor(
|
||||
private readonly backend: InMemoryRedisBackend,
|
||||
private readonly watchedRevisions: ReadonlyMap<string, number>,
|
||||
) {}
|
||||
|
||||
public set(key: string, value: string, mode?: 'NX' | 'XX'): RedisTaskTransaction {
|
||||
this.operations.push({
|
||||
type: 'set',
|
||||
key,
|
||||
value,
|
||||
mode,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public sadd(key: string, member: string): RedisTaskTransaction {
|
||||
this.operations.push({
|
||||
type: 'sadd',
|
||||
key,
|
||||
member,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public exec(): Promise<readonly (readonly [Error | null, unknown])[] | null> {
|
||||
for (const [key, revision] of this.watchedRevisions.entries()) {
|
||||
if (this.backend.getRevision(key) !== revision) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
const results: (readonly [Error | null, unknown])[] = [];
|
||||
|
||||
for (const operation of this.operations) {
|
||||
if (operation.type === 'set') {
|
||||
const exists = this.backend.kv.has(operation.key);
|
||||
if (operation.mode === 'NX' && exists) {
|
||||
results.push([null, null]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (operation.mode === 'XX' && !exists) {
|
||||
results.push([null, null]);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.backend.kv.set(operation.key, operation.value);
|
||||
this.backend.bumpRevision(operation.key);
|
||||
results.push([null, 'OK']);
|
||||
continue;
|
||||
}
|
||||
|
||||
const set = this.backend.sets.get(operation.key) ?? new Set<string>();
|
||||
const before = set.size;
|
||||
|
||||
set.add(operation.member);
|
||||
this.backend.sets.set(operation.key, set);
|
||||
this.backend.bumpRevision(operation.key);
|
||||
results.push([null, set.size === before ? 0 : 1]);
|
||||
}
|
||||
|
||||
return Promise.resolve(results);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryAtomicRedisClient implements RedisTaskClient {
|
||||
private watchedRevisions = new Map<string, number>();
|
||||
|
||||
public constructor(private readonly backend: InMemoryRedisBackend) {}
|
||||
|
||||
public get(key: string): Promise<string | null> {
|
||||
return Promise.resolve(this.backend.kv.get(key) ?? null);
|
||||
}
|
||||
|
||||
public mget(...keys: string[]): Promise<(string | null)[]> {
|
||||
return Promise.resolve(keys.map((key) => this.backend.kv.get(key) ?? null));
|
||||
}
|
||||
|
||||
public set(
|
||||
key: string,
|
||||
value: string,
|
||||
mode?: 'NX' | 'XX',
|
||||
): Promise<'OK' | null> {
|
||||
const exists = this.backend.kv.has(key);
|
||||
|
||||
if (mode === 'NX' && exists) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (mode === 'XX' && !exists) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
this.backend.kv.set(key, value);
|
||||
this.backend.bumpRevision(key);
|
||||
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public smembers(key: string): Promise<string[]> {
|
||||
return Promise.resolve([...(this.backend.sets.get(key) ?? new Set<string>())]);
|
||||
}
|
||||
|
||||
public sadd(key: string, member: string): Promise<number> {
|
||||
const values = this.backend.sets.get(key) ?? new Set<string>();
|
||||
const before = values.size;
|
||||
|
||||
values.add(member);
|
||||
this.backend.sets.set(key, values);
|
||||
this.backend.bumpRevision(key);
|
||||
|
||||
return Promise.resolve(values.size === before ? 0 : 1);
|
||||
}
|
||||
|
||||
public watch(...keys: string[]): Promise<'OK'> {
|
||||
this.watchedRevisions = new Map(
|
||||
keys.map((key) => [key, this.backend.getRevision(key)]),
|
||||
);
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public unwatch(): Promise<'OK'> {
|
||||
this.watchedRevisions.clear();
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public multi(): RedisTaskTransaction {
|
||||
const watchedSnapshot = new Map(this.watchedRevisions);
|
||||
this.watchedRevisions.clear();
|
||||
return new InMemoryRedisTransaction(this.backend, watchedSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
class StrictAtomicRedisClient extends InMemoryAtomicRedisClient {
|
||||
public override set(
|
||||
key: string,
|
||||
value: string,
|
||||
mode?: 'NX' | 'XX',
|
||||
): Promise<'OK' | null> {
|
||||
void key;
|
||||
void value;
|
||||
void mode;
|
||||
throw new Error('Direct set() is not allowed in strict atomic tests.');
|
||||
}
|
||||
|
||||
public override sadd(key: string, member: string): Promise<number> {
|
||||
void key;
|
||||
void member;
|
||||
throw new Error('Direct sadd() is not allowed in strict atomic tests.');
|
||||
}
|
||||
}
|
||||
|
||||
function createRepositoryPair(now: () => number): [RedisTaskRepository, RedisTaskRepository] {
|
||||
const backend = new InMemoryRedisBackend();
|
||||
|
||||
return [
|
||||
new RedisTaskRepository({
|
||||
client: new InMemoryAtomicRedisClient(backend),
|
||||
now,
|
||||
}),
|
||||
new RedisTaskRepository({
|
||||
client: new InMemoryAtomicRedisClient(backend),
|
||||
now,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function createStrictRepositoryPair(
|
||||
now: () => number,
|
||||
): [RedisTaskRepository, RedisTaskRepository] {
|
||||
const backend = new InMemoryRedisBackend();
|
||||
|
||||
return [
|
||||
new RedisTaskRepository({
|
||||
client: new StrictAtomicRedisClient(backend),
|
||||
now,
|
||||
}),
|
||||
new RedisTaskRepository({
|
||||
client: new StrictAtomicRedisClient(backend),
|
||||
now,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
describe('RedisTaskRepository atomic transitions', () => {
|
||||
it('creates atomically under concurrent create race', async () => {
|
||||
const [repositoryA, repositoryB] = createStrictRepositoryPair(
|
||||
() => 1_700_000_000_000,
|
||||
);
|
||||
|
||||
const [createA, createB] = await Promise.allSettled([
|
||||
repositoryA.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-CREATE',
|
||||
title: 'create race',
|
||||
}),
|
||||
repositoryB.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-CREATE',
|
||||
title: 'create race duplicate',
|
||||
}),
|
||||
]);
|
||||
|
||||
const fulfilled = [createA, createB].filter(
|
||||
(result) => result.status === 'fulfilled',
|
||||
);
|
||||
const rejected = [createA, createB].filter(
|
||||
(result) => result.status === 'rejected',
|
||||
);
|
||||
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect(rejected[0]?.reason).toBeInstanceOf(TaskAlreadyExistsError);
|
||||
});
|
||||
|
||||
it('claims a pending task once and blocks concurrent double-claim', async () => {
|
||||
let timestamp = 1_700_000_000_000;
|
||||
const now = (): number => timestamp;
|
||||
const [repositoryA, repositoryB] = createRepositoryPair(now);
|
||||
|
||||
await repositoryA.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004',
|
||||
title: 'Atomic claim',
|
||||
});
|
||||
|
||||
const [claimA, claimB] = await Promise.allSettled([
|
||||
repositoryA.claim('MQ-004', { agentId: 'agent-a', ttlSeconds: 60 }),
|
||||
repositoryB.claim('MQ-004', { agentId: 'agent-b', ttlSeconds: 60 }),
|
||||
]);
|
||||
|
||||
const fulfilled = [claimA, claimB].filter((result) => result.status === 'fulfilled');
|
||||
const rejected = [claimA, claimB].filter((result) => result.status === 'rejected');
|
||||
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('allows claim takeover after TTL expiry', async () => {
|
||||
let timestamp = 1_700_000_000_000;
|
||||
const now = (): number => timestamp;
|
||||
const [repositoryA, repositoryB] = createRepositoryPair(now);
|
||||
|
||||
await repositoryA.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-EXP',
|
||||
title: 'TTL expiry',
|
||||
});
|
||||
|
||||
await repositoryA.claim('MQ-004-EXP', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 1,
|
||||
});
|
||||
|
||||
timestamp += 2_000;
|
||||
|
||||
const takeover = await repositoryB.claim('MQ-004-EXP', {
|
||||
agentId: 'agent-b',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
expect(takeover.claimedBy).toBe('agent-b');
|
||||
});
|
||||
|
||||
it('releases a claimed task back to pending', async () => {
|
||||
const [repository] = createRepositoryPair(() => 1_700_000_000_000);
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-REL',
|
||||
title: 'Release test',
|
||||
});
|
||||
|
||||
await repository.claim('MQ-004-REL', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
const released = await repository.release('MQ-004-REL', {
|
||||
agentId: 'agent-a',
|
||||
});
|
||||
|
||||
expect(released.status).toBe('pending');
|
||||
expect(released.claimedBy).toBeUndefined();
|
||||
expect(released.claimedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('heartbeats, completes, and fails with valid transitions', async () => {
|
||||
let timestamp = 1_700_000_000_000;
|
||||
const now = (): number => timestamp;
|
||||
const [repository] = createRepositoryPair(now);
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-HCF',
|
||||
title: 'Transition test',
|
||||
});
|
||||
|
||||
await repository.claim('MQ-004-HCF', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
timestamp += 1_000;
|
||||
const heartbeat = await repository.heartbeat('MQ-004-HCF', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 120,
|
||||
});
|
||||
expect(heartbeat.claimTTL).toBe(120);
|
||||
expect(heartbeat.claimedAt).toBe(1_700_000_001_000);
|
||||
|
||||
const completed = await repository.complete('MQ-004-HCF', {
|
||||
agentId: 'agent-a',
|
||||
summary: 'done',
|
||||
});
|
||||
expect(completed.status).toBe('completed');
|
||||
expect(completed.completionSummary).toBe('done');
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-FAIL',
|
||||
title: 'Failure test',
|
||||
});
|
||||
|
||||
await repository.claim('MQ-004-FAIL', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
const failed = await repository.fail('MQ-004-FAIL', {
|
||||
agentId: 'agent-a',
|
||||
reason: 'boom',
|
||||
});
|
||||
|
||||
expect(failed.status).toBe('failed');
|
||||
expect(failed.failureReason).toBe('boom');
|
||||
expect(failed.retryCount).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects invalid transitions', async () => {
|
||||
const [repository] = createRepositoryPair(() => 1_700_000_000_000);
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-INV',
|
||||
title: 'Invalid transitions',
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.complete('MQ-004-INV', {
|
||||
agentId: 'agent-a',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TaskTransitionError);
|
||||
});
|
||||
|
||||
it('enforces claim ownership for release and complete', async () => {
|
||||
const [repository] = createRepositoryPair(() => 1_700_000_000_000);
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-OWN',
|
||||
title: 'Ownership checks',
|
||||
});
|
||||
|
||||
await repository.claim('MQ-004-OWN', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.release('MQ-004-OWN', {
|
||||
agentId: 'agent-b',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TaskOwnershipError);
|
||||
|
||||
await expect(
|
||||
repository.complete('MQ-004-OWN', {
|
||||
agentId: 'agent-b',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TaskOwnershipError);
|
||||
});
|
||||
|
||||
it('merges concurrent non-conflicting update patches atomically', async () => {
|
||||
const [repositoryA, repositoryB] = createRepositoryPair(() => 1_700_000_000_000);
|
||||
|
||||
await repositoryA.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-004-UPD',
|
||||
title: 'Original title',
|
||||
description: 'Original description',
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
repositoryA.update('MQ-004-UPD', {
|
||||
title: 'Updated title',
|
||||
}),
|
||||
repositoryB.update('MQ-004-UPD', {
|
||||
description: 'Updated description',
|
||||
}),
|
||||
]);
|
||||
|
||||
const latest = await repositoryA.get('MQ-004-UPD');
|
||||
|
||||
expect(latest).not.toBeNull();
|
||||
expect(latest?.title).toBe('Updated title');
|
||||
expect(latest?.description).toBe('Updated description');
|
||||
});
|
||||
});
|
||||
332
packages/queue/tests/task-repository.test.ts
Normal file
332
packages/queue/tests/task-repository.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
RedisTaskRepository,
|
||||
TaskAlreadyExistsError,
|
||||
TaskTransitionError,
|
||||
type RedisTaskClient,
|
||||
type RedisTaskTransaction,
|
||||
} from '../src/task-repository.js';
|
||||
|
||||
class NoopRedisTransaction implements RedisTaskTransaction {
|
||||
private readonly operations: (
|
||||
| {
|
||||
readonly type: 'set';
|
||||
readonly key: string;
|
||||
readonly value: string;
|
||||
readonly mode?: 'NX' | 'XX';
|
||||
}
|
||||
| {
|
||||
readonly type: 'sadd';
|
||||
readonly key: string;
|
||||
readonly member: string;
|
||||
}
|
||||
)[] = [];
|
||||
|
||||
public constructor(
|
||||
private readonly kv: Map<string, string>,
|
||||
private readonly sets: Map<string, Set<string>>,
|
||||
) {}
|
||||
|
||||
public set(key: string, value: string, mode?: 'NX' | 'XX'): RedisTaskTransaction {
|
||||
this.operations.push({
|
||||
type: 'set',
|
||||
key,
|
||||
value,
|
||||
mode,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public sadd(key: string, member: string): RedisTaskTransaction {
|
||||
this.operations.push({
|
||||
type: 'sadd',
|
||||
key,
|
||||
member,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public exec(): Promise<readonly (readonly [Error | null, unknown])[] | null> {
|
||||
const results: (readonly [Error | null, unknown])[] = [];
|
||||
|
||||
for (const operation of this.operations) {
|
||||
if (operation.type === 'set') {
|
||||
const exists = this.kv.has(operation.key);
|
||||
|
||||
if (operation.mode === 'NX' && exists) {
|
||||
results.push([null, null]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (operation.mode === 'XX' && !exists) {
|
||||
results.push([null, null]);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.kv.set(operation.key, operation.value);
|
||||
results.push([null, 'OK']);
|
||||
continue;
|
||||
}
|
||||
|
||||
const values = this.sets.get(operation.key) ?? new Set<string>();
|
||||
const beforeSize = values.size;
|
||||
|
||||
values.add(operation.member);
|
||||
this.sets.set(operation.key, values);
|
||||
results.push([null, values.size === beforeSize ? 0 : 1]);
|
||||
}
|
||||
|
||||
return Promise.resolve(results);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryRedisClient implements RedisTaskClient {
|
||||
private readonly kv = new Map<string, string>();
|
||||
private readonly sets = new Map<string, Set<string>>();
|
||||
|
||||
public get(key: string): Promise<string | null> {
|
||||
return Promise.resolve(this.kv.get(key) ?? null);
|
||||
}
|
||||
|
||||
public mget(...keys: string[]): Promise<(string | null)[]> {
|
||||
return Promise.resolve(keys.map((key) => this.kv.get(key) ?? null));
|
||||
}
|
||||
|
||||
public set(
|
||||
key: string,
|
||||
value: string,
|
||||
mode?: 'NX' | 'XX',
|
||||
): Promise<'OK' | null> {
|
||||
const exists = this.kv.has(key);
|
||||
|
||||
if (mode === 'NX' && exists) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (mode === 'XX' && !exists) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
this.kv.set(key, value);
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public smembers(key: string): Promise<string[]> {
|
||||
return Promise.resolve([...(this.sets.get(key) ?? new Set<string>())]);
|
||||
}
|
||||
|
||||
public sadd(key: string, member: string): Promise<number> {
|
||||
const values = this.sets.get(key) ?? new Set<string>();
|
||||
const beforeSize = values.size;
|
||||
|
||||
values.add(member);
|
||||
this.sets.set(key, values);
|
||||
|
||||
return Promise.resolve(values.size === beforeSize ? 0 : 1);
|
||||
}
|
||||
|
||||
public watch(): Promise<'OK'> {
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public unwatch(): Promise<'OK'> {
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
|
||||
public multi(): RedisTaskTransaction {
|
||||
return new NoopRedisTransaction(this.kv, this.sets);
|
||||
}
|
||||
}
|
||||
|
||||
class MgetTrackingRedisClient extends InMemoryRedisClient {
|
||||
public getCalls = 0;
|
||||
public mgetCalls = 0;
|
||||
public lastMgetKeys: string[] = [];
|
||||
|
||||
public override get(key: string): Promise<string | null> {
|
||||
this.getCalls += 1;
|
||||
return super.get(key);
|
||||
}
|
||||
|
||||
public override mget(...keys: string[]): Promise<(string | null)[]> {
|
||||
this.mgetCalls += 1;
|
||||
this.lastMgetKeys = [...keys];
|
||||
return super.mget(...keys);
|
||||
}
|
||||
}
|
||||
|
||||
describe('RedisTaskRepository CRUD', () => {
|
||||
it('creates and fetches a task with defaults', async () => {
|
||||
const repository = new RedisTaskRepository({
|
||||
client: new InMemoryRedisClient(),
|
||||
now: () => 1_700_000_000_000,
|
||||
});
|
||||
|
||||
const created = await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003',
|
||||
title: 'Implement task CRUD',
|
||||
});
|
||||
|
||||
const fetched = await repository.get('MQ-003');
|
||||
|
||||
expect(created.id).toBe('MQ-003');
|
||||
expect(created.status).toBe('pending');
|
||||
expect(created.priority).toBe('medium');
|
||||
expect(created.lane).toBe('any');
|
||||
expect(created.dependencies).toEqual([]);
|
||||
expect(created.createdAt).toBe(1_700_000_000_000);
|
||||
expect(fetched).toEqual(created);
|
||||
});
|
||||
|
||||
it('throws when creating a duplicate task id', async () => {
|
||||
const repository = new RedisTaskRepository({
|
||||
client: new InMemoryRedisClient(),
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003',
|
||||
title: 'First task',
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003',
|
||||
title: 'Duplicate',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TaskAlreadyExistsError);
|
||||
});
|
||||
|
||||
it('lists tasks and filters by project, mission, and status', async () => {
|
||||
const repository = new RedisTaskRepository({
|
||||
client: new InMemoryRedisClient(),
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003A',
|
||||
title: 'Pending task',
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase2',
|
||||
taskId: 'MQ-003B',
|
||||
title: 'Claimed task',
|
||||
});
|
||||
|
||||
await repository.claim('MQ-003B', {
|
||||
agentId: 'agent-a',
|
||||
ttlSeconds: 60,
|
||||
});
|
||||
|
||||
const byProject = await repository.list({
|
||||
project: 'queue',
|
||||
});
|
||||
const byMission = await repository.list({
|
||||
mission: 'phase2',
|
||||
});
|
||||
const byStatus = await repository.list({
|
||||
status: 'claimed',
|
||||
});
|
||||
|
||||
expect(byProject).toHaveLength(2);
|
||||
expect(byMission.map((task) => task.taskId)).toEqual(['MQ-003B']);
|
||||
expect(byStatus.map((task) => task.taskId)).toEqual(['MQ-003B']);
|
||||
});
|
||||
|
||||
it('lists 3+ tasks with a single mget call', async () => {
|
||||
const client = new MgetTrackingRedisClient();
|
||||
const repository = new RedisTaskRepository({
|
||||
client,
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase-list',
|
||||
taskId: 'MQ-MGET-001',
|
||||
title: 'Task one',
|
||||
});
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase-list',
|
||||
taskId: 'MQ-MGET-002',
|
||||
title: 'Task two',
|
||||
});
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase-list',
|
||||
taskId: 'MQ-MGET-003',
|
||||
title: 'Task three',
|
||||
});
|
||||
|
||||
const tasks = await repository.list();
|
||||
|
||||
expect(tasks).toHaveLength(3);
|
||||
expect(client.mgetCalls).toBe(1);
|
||||
expect(client.getCalls).toBe(0);
|
||||
expect(client.lastMgetKeys).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('updates mutable fields and preserves immutable fields', async () => {
|
||||
const repository = new RedisTaskRepository({
|
||||
client: new InMemoryRedisClient(),
|
||||
now: () => 1_700_000_000_001,
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003',
|
||||
title: 'Original title',
|
||||
description: 'Original description',
|
||||
});
|
||||
|
||||
const updated = await repository.update('MQ-003', {
|
||||
title: 'Updated title',
|
||||
description: 'Updated description',
|
||||
priority: 'high',
|
||||
lane: 'coding',
|
||||
dependencies: ['MQ-002'],
|
||||
metadata: {
|
||||
source: 'unit-test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(updated.title).toBe('Updated title');
|
||||
expect(updated.description).toBe('Updated description');
|
||||
expect(updated.priority).toBe('high');
|
||||
expect(updated.lane).toBe('coding');
|
||||
expect(updated.dependencies).toEqual(['MQ-002']);
|
||||
expect(updated.metadata).toEqual({ source: 'unit-test' });
|
||||
expect(updated.project).toBe('queue');
|
||||
expect(updated.taskId).toBe('MQ-003');
|
||||
expect(updated.updatedAt).toBe(1_700_000_000_001);
|
||||
});
|
||||
|
||||
it('rejects status transitions through update()', async () => {
|
||||
const repository = new RedisTaskRepository({
|
||||
client: new InMemoryRedisClient(),
|
||||
});
|
||||
|
||||
await repository.create({
|
||||
project: 'queue',
|
||||
mission: 'phase1',
|
||||
taskId: 'MQ-003-TRANSITION',
|
||||
title: 'Transition guard',
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.update('MQ-003-TRANSITION', {
|
||||
status: 'completed',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(TaskTransitionError);
|
||||
});
|
||||
});
|
||||
10
packages/queue/tsconfig.build.json
Normal file
10
packages/queue/tsconfig.build.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"rootDir": ".",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*.ts", "bin/**/*.ts"],
|
||||
"exclude": ["tests/**/*"]
|
||||
}
|
||||
8
packages/queue/tsconfig.json
Normal file
8
packages/queue/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts", "bin/**/*.ts", "vitest.config.ts"]
|
||||
}
|
||||
8
packages/queue/vitest.config.ts
Normal file
8
packages/queue/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -1,18 +1,23 @@
|
||||
{
|
||||
"name": "@mosaic/types",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "echo \"No tests for @mosaic/types\"",
|
||||
"lint": "echo \"No lint configured for @mosaic/types\"",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "echo 'ok'",
|
||||
"test": "echo 'ok'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,342 @@
|
||||
// @mosaic/types - shared type definitions
|
||||
export type Placeholder = Record<string, never>;
|
||||
// @mosaic/types
|
||||
// Shared foundational types extracted from queue, bootstrap, and context packages.
|
||||
|
||||
// === Queue ===
|
||||
|
||||
export type TaskStatus =
|
||||
| 'pending'
|
||||
| 'claimed'
|
||||
| 'in-progress'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'blocked';
|
||||
|
||||
export type TaskPriority = 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
export type TaskLane = 'planning' | 'coding' | 'any';
|
||||
|
||||
export interface Task {
|
||||
readonly id: string;
|
||||
readonly project: string;
|
||||
readonly mission: string;
|
||||
readonly taskId: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly status: TaskStatus;
|
||||
readonly priority: TaskPriority;
|
||||
readonly dependencies: readonly string[];
|
||||
readonly lane: TaskLane;
|
||||
readonly claimedBy?: string;
|
||||
readonly claimedAt?: number;
|
||||
readonly claimTTL?: number;
|
||||
readonly completedAt?: number;
|
||||
readonly failedAt?: number;
|
||||
readonly failureReason?: string;
|
||||
readonly completionSummary?: string;
|
||||
readonly retryCount: number;
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
readonly createdAt: number;
|
||||
readonly updatedAt: number;
|
||||
}
|
||||
|
||||
export interface CreateTaskInput {
|
||||
readonly project: string;
|
||||
readonly mission: string;
|
||||
readonly taskId: string;
|
||||
readonly title: string;
|
||||
readonly description?: string;
|
||||
readonly priority?: TaskPriority;
|
||||
readonly dependencies?: readonly string[];
|
||||
readonly lane?: TaskLane;
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface TaskListFilters {
|
||||
readonly project?: string;
|
||||
readonly mission?: string;
|
||||
readonly status?: TaskStatus;
|
||||
}
|
||||
|
||||
export interface TaskUpdateInput {
|
||||
readonly title?: string;
|
||||
readonly description?: string;
|
||||
readonly status?: TaskStatus;
|
||||
readonly priority?: TaskPriority;
|
||||
readonly dependencies?: readonly string[];
|
||||
readonly lane?: TaskLane;
|
||||
readonly claimedBy?: string;
|
||||
readonly claimedAt?: number;
|
||||
readonly claimTTL?: number;
|
||||
readonly completedAt?: number;
|
||||
readonly failedAt?: number;
|
||||
readonly failureReason?: string;
|
||||
readonly completionSummary?: string;
|
||||
readonly retryCount?: number;
|
||||
readonly metadata?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface ClaimTaskInput {
|
||||
readonly agentId: string;
|
||||
readonly ttlSeconds: number;
|
||||
}
|
||||
|
||||
export interface ReleaseTaskInput {
|
||||
readonly agentId?: string;
|
||||
}
|
||||
|
||||
export interface HeartbeatTaskInput {
|
||||
readonly agentId?: string;
|
||||
readonly ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface CompleteTaskInput {
|
||||
readonly agentId?: string;
|
||||
readonly summary?: string;
|
||||
}
|
||||
|
||||
export interface FailTaskInput {
|
||||
readonly agentId?: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
// === Agent ===
|
||||
|
||||
export type AgentMessageRole =
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'tool'
|
||||
| 'system'
|
||||
| (string & {});
|
||||
|
||||
export interface AgentMessage {
|
||||
readonly role: AgentMessageRole;
|
||||
readonly content: unknown;
|
||||
readonly timestamp?: number;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export type OpenBrainThoughtMetadata = Readonly<
|
||||
Record<string, unknown> & {
|
||||
sessionId?: string;
|
||||
turn?: number;
|
||||
role?: string;
|
||||
type?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface OpenBrainThought {
|
||||
readonly id: string;
|
||||
readonly content: string;
|
||||
readonly source: string;
|
||||
readonly metadata?: OpenBrainThoughtMetadata;
|
||||
readonly createdAt?: string;
|
||||
readonly updatedAt?: string;
|
||||
readonly score?: number;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface OpenBrainThoughtInput {
|
||||
readonly content: string;
|
||||
readonly source: string;
|
||||
readonly metadata?: OpenBrainThoughtMetadata;
|
||||
}
|
||||
|
||||
export interface OpenBrainSearchInput {
|
||||
readonly query: string;
|
||||
readonly limit: number;
|
||||
readonly source?: string;
|
||||
}
|
||||
|
||||
// === Context Engine ===
|
||||
|
||||
export interface AssembleResult {
|
||||
readonly messages: readonly AgentMessage[];
|
||||
readonly estimatedTokens: number;
|
||||
readonly systemPromptAddition?: string;
|
||||
}
|
||||
|
||||
export interface CompactResultData {
|
||||
readonly summary?: string;
|
||||
readonly firstKeptEntryId?: string;
|
||||
readonly tokensBefore: number;
|
||||
readonly tokensAfter?: number;
|
||||
readonly details?: unknown;
|
||||
}
|
||||
|
||||
export interface CompactResult {
|
||||
readonly ok: boolean;
|
||||
readonly compacted: boolean;
|
||||
readonly reason?: string;
|
||||
readonly result?: CompactResultData;
|
||||
}
|
||||
|
||||
export interface IngestResult {
|
||||
readonly ingested: boolean;
|
||||
}
|
||||
|
||||
export interface IngestBatchResult {
|
||||
readonly ingestedCount: number;
|
||||
}
|
||||
|
||||
export interface BootstrapResult {
|
||||
readonly bootstrapped: boolean;
|
||||
readonly importedMessages?: number;
|
||||
readonly reason?: string;
|
||||
}
|
||||
|
||||
export interface ContextEngineInfo {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly version?: string;
|
||||
readonly ownsCompaction?: boolean;
|
||||
}
|
||||
|
||||
export interface SubagentSpawnPreparation {
|
||||
readonly rollback: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type SubagentEndReason = 'deleted' | 'completed' | 'swept' | 'released';
|
||||
|
||||
export interface ContextEngineBootstrapParams {
|
||||
readonly sessionId: string;
|
||||
readonly sessionFile: string;
|
||||
}
|
||||
|
||||
export interface ContextEngineIngestParams {
|
||||
readonly sessionId: string;
|
||||
readonly message: AgentMessage;
|
||||
readonly isHeartbeat?: boolean;
|
||||
}
|
||||
|
||||
export interface ContextEngineIngestBatchParams {
|
||||
readonly sessionId: string;
|
||||
readonly messages: readonly AgentMessage[];
|
||||
readonly isHeartbeat?: boolean;
|
||||
}
|
||||
|
||||
export interface ContextEngineAfterTurnParams {
|
||||
readonly sessionId: string;
|
||||
readonly sessionFile: string;
|
||||
readonly messages: readonly AgentMessage[];
|
||||
readonly prePromptMessageCount: number;
|
||||
readonly autoCompactionSummary?: string;
|
||||
readonly isHeartbeat?: boolean;
|
||||
readonly tokenBudget?: number;
|
||||
readonly legacyCompactionParams?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface ContextEngineAssembleParams {
|
||||
readonly sessionId: string;
|
||||
readonly messages: readonly AgentMessage[];
|
||||
readonly tokenBudget?: number;
|
||||
}
|
||||
|
||||
export interface ContextEngineCompactParams {
|
||||
readonly sessionId: string;
|
||||
readonly sessionFile: string;
|
||||
readonly tokenBudget?: number;
|
||||
readonly force?: boolean;
|
||||
readonly currentTokenCount?: number;
|
||||
readonly compactionTarget?: 'budget' | 'threshold';
|
||||
readonly customInstructions?: string;
|
||||
readonly legacyParams?: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface ContextEnginePrepareSubagentSpawnParams {
|
||||
readonly parentSessionKey: string;
|
||||
readonly childSessionKey: string;
|
||||
readonly ttlMs?: number;
|
||||
}
|
||||
|
||||
export interface ContextEngineSubagentEndedParams {
|
||||
readonly childSessionKey: string;
|
||||
readonly reason: SubagentEndReason;
|
||||
}
|
||||
|
||||
export interface ContextEngine {
|
||||
readonly info: ContextEngineInfo;
|
||||
bootstrap?(params: ContextEngineBootstrapParams): Promise<BootstrapResult>;
|
||||
ingest(params: ContextEngineIngestParams): Promise<IngestResult>;
|
||||
ingestBatch?(params: ContextEngineIngestBatchParams): Promise<IngestBatchResult>;
|
||||
afterTurn?(params: ContextEngineAfterTurnParams): Promise<void>;
|
||||
assemble(params: ContextEngineAssembleParams): Promise<AssembleResult>;
|
||||
compact(params: ContextEngineCompactParams): Promise<CompactResult>;
|
||||
prepareSubagentSpawn?(
|
||||
params: ContextEnginePrepareSubagentSpawnParams,
|
||||
): Promise<SubagentSpawnPreparation | undefined>;
|
||||
onSubagentEnded?(params: ContextEngineSubagentEndedParams): Promise<void>;
|
||||
dispose?(): Promise<void>;
|
||||
}
|
||||
|
||||
export type ContextEngineFactory = () => ContextEngine | Promise<ContextEngine>;
|
||||
|
||||
export interface PluginLogger {
|
||||
readonly debug?: (...args: unknown[]) => void;
|
||||
readonly info?: (...args: unknown[]) => void;
|
||||
readonly warn?: (...args: unknown[]) => void;
|
||||
readonly error?: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export interface OpenClawPluginApi {
|
||||
readonly pluginConfig?: Readonly<Record<string, unknown>>;
|
||||
readonly logger?: PluginLogger;
|
||||
readonly registerContextEngine: (id: string, factory: ContextEngineFactory) => void;
|
||||
}
|
||||
|
||||
// === Wizard ===
|
||||
// Wizard bootstrap/setup types are package-specific but intentionally exported
|
||||
// for cross-package tooling and install orchestration.
|
||||
|
||||
export type WizardMode = 'quick' | 'advanced';
|
||||
export type InstallAction = 'fresh' | 'keep' | 'reconfigure' | 'reset';
|
||||
export type CommunicationStyle = 'direct' | 'friendly' | 'formal';
|
||||
export type RuntimeName = 'claude' | 'codex' | 'opencode';
|
||||
|
||||
export interface SoulConfig {
|
||||
readonly agentName?: string;
|
||||
readonly roleDescription?: string;
|
||||
readonly communicationStyle?: CommunicationStyle;
|
||||
readonly accessibility?: string;
|
||||
readonly customGuardrails?: string;
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
readonly userName?: string;
|
||||
readonly pronouns?: string;
|
||||
readonly timezone?: string;
|
||||
readonly background?: string;
|
||||
readonly accessibilitySection?: string;
|
||||
readonly communicationPrefs?: string;
|
||||
readonly personalBoundaries?: string;
|
||||
readonly projectsTable?: string;
|
||||
}
|
||||
|
||||
export interface GitProvider {
|
||||
readonly name: string;
|
||||
readonly url: string;
|
||||
readonly cli: string;
|
||||
readonly purpose: string;
|
||||
}
|
||||
|
||||
export interface ToolsConfig {
|
||||
readonly gitProviders?: readonly GitProvider[];
|
||||
readonly credentialsLocation?: string;
|
||||
readonly customToolsSection?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeState {
|
||||
readonly detected: readonly RuntimeName[];
|
||||
readonly mcpConfigured: boolean;
|
||||
}
|
||||
|
||||
export interface WizardState {
|
||||
readonly mosaicHome: string;
|
||||
readonly sourceDir: string;
|
||||
readonly mode: WizardMode;
|
||||
readonly installAction: InstallAction;
|
||||
readonly soul: SoulConfig;
|
||||
readonly user: UserConfig;
|
||||
readonly tools: ToolsConfig;
|
||||
readonly runtimes: RuntimeState;
|
||||
readonly selectedSkills: readonly string[];
|
||||
}
|
||||
|
||||
97
plugins/openclaw-context/README.md
Normal file
97
plugins/openclaw-context/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# @mosaic/openclaw-context
|
||||
|
||||
OpenBrain-backed `ContextEngine` plugin for OpenClaw.
|
||||
|
||||
This plugin stores session context in OpenBrain over REST so context can be reassembled from recent history plus semantic matches instead of relying only on in-session compaction state.
|
||||
|
||||
## Features
|
||||
|
||||
- Registers context engine id: `openbrain`
|
||||
- Typed OpenBrain REST client with Bearer auth
|
||||
- Session-aware ingest + batch ingest
|
||||
- Context assembly from recent + semantic search under token budget
|
||||
- Compaction summaries archived to OpenBrain
|
||||
- Subagent seed/result handoff helpers
|
||||
|
||||
## Requirements
|
||||
|
||||
- OpenClaw with plugin/context-engine support (`openclaw >= 2026.3.2`)
|
||||
- Reachable OpenBrain REST API
|
||||
- OpenBrain API key
|
||||
|
||||
## Install (local workspace plugin)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Then reference this plugin in your OpenClaw config.
|
||||
|
||||
## OpenBrain Setup (self-host or hosted)
|
||||
|
||||
You must provide both of these in plugin config:
|
||||
|
||||
- `baseUrl`: your OpenBrain API root (example: `https://brain.your-domain.com`)
|
||||
- `apiKey`: Bearer token for your OpenBrain instance
|
||||
|
||||
No host or key fallback is built in. Missing `baseUrl` or `apiKey` throws `OpenBrainConfigError` at `bootstrap()`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Plugin entry id: `openclaw-openbrain-context`
|
||||
Context engine slot id: `openbrain`
|
||||
|
||||
### Config fields
|
||||
|
||||
- `baseUrl` (required, string): OpenBrain API base URL
|
||||
- `apiKey` (required, string): OpenBrain Bearer token
|
||||
- `source` (optional, string, default `openclaw`): source prefix; engine stores thoughts under `<source>:<sessionId>`
|
||||
- `recentMessages` (optional, integer, default `20`): recent thoughts to fetch for bootstrap/assemble
|
||||
- `semanticSearchLimit` (optional, integer, default `10`): semantic matches fetched in assemble
|
||||
- `subagentRecentMessages` (optional, integer, default `8`): context lines used for subagent seed/result exchange
|
||||
|
||||
## Environment Variable Pattern
|
||||
|
||||
Use OpenClaw variable interpolation in `openclaw.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKey": "${OPENBRAIN_API_KEY}"
|
||||
}
|
||||
```
|
||||
|
||||
Then set it in your shell/runtime environment before starting OpenClaw.
|
||||
|
||||
## Example `openclaw.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"slots": {
|
||||
"contextEngine": "openbrain"
|
||||
},
|
||||
"entries": {
|
||||
"openclaw-openbrain-context": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"baseUrl": "https://brain.example.com",
|
||||
"apiKey": "${OPENBRAIN_API_KEY}",
|
||||
"source": "openclaw",
|
||||
"recentMessages": 20,
|
||||
"semanticSearchLimit": 10,
|
||||
"subagentRecentMessages": 8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
pnpm build
|
||||
pnpm test
|
||||
```
|
||||
58
plugins/openclaw-context/openclaw.plugin.json
Normal file
58
plugins/openclaw-context/openclaw.plugin.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"id": "openclaw-openbrain-context",
|
||||
"name": "OpenBrain Context Engine",
|
||||
"description": "OpenBrain-backed ContextEngine plugin for OpenClaw",
|
||||
"version": "0.0.1",
|
||||
"kind": "context-engine",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["baseUrl", "apiKey"],
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Base URL of your OpenBrain REST API"
|
||||
},
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Bearer token used to authenticate against OpenBrain"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"default": "openclaw",
|
||||
"description": "Source prefix stored in OpenBrain (session id is appended)"
|
||||
},
|
||||
"recentMessages": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 20,
|
||||
"description": "How many recent thoughts to fetch during assemble/bootstrap"
|
||||
},
|
||||
"semanticSearchLimit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 10,
|
||||
"description": "How many semantic matches to request during assemble"
|
||||
},
|
||||
"subagentRecentMessages": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 8,
|
||||
"description": "How many thoughts to use when seeding/summarizing subagents"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"baseUrl": {
|
||||
"label": "OpenBrain Base URL",
|
||||
"placeholder": "https://brain.example.com"
|
||||
},
|
||||
"apiKey": {
|
||||
"label": "OpenBrain API Key",
|
||||
"sensitive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
38
plugins/openclaw-context/package.json
Normal file
38
plugins/openclaw-context/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@mosaic/openclaw-context",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "OpenClaw \u2192 OpenBrain context engine plugin",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"openclaw.plugin.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaic/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"vitest": "^2",
|
||||
"@types/node": "^22"
|
||||
},
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
"openbrain",
|
||||
"context-engine",
|
||||
"plugin"
|
||||
]
|
||||
}
|
||||
3
plugins/openclaw-context/src/constants.ts
Normal file
3
plugins/openclaw-context/src/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const OPENBRAIN_CONTEXT_ENGINE_ID = "openbrain";
|
||||
export const OPENBRAIN_PLUGIN_ID = "openclaw-openbrain-context";
|
||||
export const OPENBRAIN_PLUGIN_VERSION = "0.0.1";
|
||||
774
plugins/openclaw-context/src/engine.ts
Normal file
774
plugins/openclaw-context/src/engine.ts
Normal file
@@ -0,0 +1,774 @@
|
||||
import { OPENBRAIN_CONTEXT_ENGINE_ID, OPENBRAIN_PLUGIN_VERSION } from "./constants.js";
|
||||
import { OpenBrainConfigError } from "./errors.js";
|
||||
import type {
|
||||
AgentMessage,
|
||||
AssembleResult,
|
||||
BootstrapResult,
|
||||
CompactResult,
|
||||
ContextEngine,
|
||||
ContextEngineInfo,
|
||||
IngestBatchResult,
|
||||
IngestResult,
|
||||
PluginLogger,
|
||||
SubagentEndReason,
|
||||
SubagentSpawnPreparation,
|
||||
} from "./openclaw-types.js";
|
||||
import {
|
||||
OpenBrainClient,
|
||||
type OpenBrainClientLike,
|
||||
type OpenBrainSearchInput,
|
||||
type OpenBrainThought,
|
||||
type OpenBrainThoughtMetadata,
|
||||
} from "./openbrain-client.js";
|
||||
|
||||
export type OpenBrainContextEngineConfig = {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
recentMessages?: number;
|
||||
semanticSearchLimit?: number;
|
||||
source?: string;
|
||||
subagentRecentMessages?: number;
|
||||
};
|
||||
|
||||
type ResolvedOpenBrainContextEngineConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
recentMessages: number;
|
||||
semanticSearchLimit: number;
|
||||
source: string;
|
||||
subagentRecentMessages: number;
|
||||
};
|
||||
|
||||
export type OpenBrainContextEngineDeps = {
|
||||
createClient?: (config: ResolvedOpenBrainContextEngineConfig) => OpenBrainClientLike;
|
||||
now?: () => number;
|
||||
logger?: PluginLogger;
|
||||
};
|
||||
|
||||
type SubagentState = {
|
||||
parentSessionKey: string;
|
||||
seedThoughtId?: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const rounded = Math.floor(value);
|
||||
return rounded > 0 ? rounded : fallback;
|
||||
}
|
||||
|
||||
function normalizeRole(role: unknown): string {
|
||||
if (typeof role !== "string" || role.length === 0) {
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
if (role === "user" || role === "assistant" || role === "tool" || role === "system") {
|
||||
return role;
|
||||
}
|
||||
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
function serializeContent(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((part) => serializeContent(part))
|
||||
.filter((part) => part.length > 0)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
if (isRecord(value) && typeof value.text === "string") {
|
||||
return value.text;
|
||||
}
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function estimateTextTokens(text: string): number {
|
||||
const normalized = text.trim();
|
||||
if (normalized.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.ceil(normalized.length / 4) + 4);
|
||||
}
|
||||
|
||||
function thoughtTimestamp(thought: OpenBrainThought, fallbackTimestamp: number): number {
|
||||
const createdAt =
|
||||
thought.createdAt ??
|
||||
(typeof thought.created_at === "string" ? thought.created_at : undefined);
|
||||
|
||||
if (createdAt === undefined) {
|
||||
return fallbackTimestamp;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(createdAt);
|
||||
return Number.isFinite(parsed) ? parsed : fallbackTimestamp;
|
||||
}
|
||||
|
||||
function thoughtFingerprint(thought: OpenBrainThought): string {
|
||||
const role = typeof thought.metadata?.role === "string" ? thought.metadata.role : "assistant";
|
||||
return `${role}\n${thought.content}`;
|
||||
}
|
||||
|
||||
function truncateLine(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
export class OpenBrainContextEngine implements ContextEngine {
|
||||
readonly info: ContextEngineInfo = {
|
||||
id: OPENBRAIN_CONTEXT_ENGINE_ID,
|
||||
name: "OpenBrain Context Engine",
|
||||
version: OPENBRAIN_PLUGIN_VERSION,
|
||||
ownsCompaction: true,
|
||||
};
|
||||
|
||||
private readonly rawConfig: unknown;
|
||||
private readonly createClientFn:
|
||||
| ((config: ResolvedOpenBrainContextEngineConfig) => OpenBrainClientLike)
|
||||
| undefined;
|
||||
private readonly now: () => number;
|
||||
private readonly logger: PluginLogger | undefined;
|
||||
|
||||
private config: ResolvedOpenBrainContextEngineConfig | undefined;
|
||||
private client: OpenBrainClientLike | undefined;
|
||||
private readonly sessionTurns = new Map<string, number>();
|
||||
private readonly subagentState = new Map<string, SubagentState>();
|
||||
private disposed = false;
|
||||
|
||||
constructor(rawConfig: unknown, deps?: OpenBrainContextEngineDeps) {
|
||||
this.rawConfig = rawConfig;
|
||||
this.createClientFn = deps?.createClient;
|
||||
this.now = deps?.now ?? (() => Date.now());
|
||||
this.logger = deps?.logger;
|
||||
}
|
||||
|
||||
async bootstrap(params: { sessionId: string; sessionFile: string }): Promise<BootstrapResult> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const config = this.getConfig();
|
||||
const client = this.getClient();
|
||||
const source = this.sourceForSession(params.sessionId);
|
||||
|
||||
const recentThoughts = await client.listRecent({
|
||||
limit: config.recentMessages,
|
||||
source,
|
||||
});
|
||||
|
||||
const sessionThoughts = this.filterSessionThoughts(recentThoughts, params.sessionId);
|
||||
|
||||
let maxTurn = -1;
|
||||
for (const thought of sessionThoughts) {
|
||||
const turn = thought.metadata?.turn;
|
||||
if (typeof turn === "number" && Number.isFinite(turn) && turn > maxTurn) {
|
||||
maxTurn = turn;
|
||||
}
|
||||
}
|
||||
|
||||
this.sessionTurns.set(params.sessionId, maxTurn + 1);
|
||||
|
||||
return {
|
||||
bootstrapped: true,
|
||||
importedMessages: sessionThoughts.length,
|
||||
};
|
||||
}
|
||||
|
||||
async ingest(params: {
|
||||
sessionId: string;
|
||||
message: AgentMessage;
|
||||
isHeartbeat?: boolean;
|
||||
}): Promise<IngestResult> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const client = this.getClient();
|
||||
const content = serializeContent(params.message.content).trim();
|
||||
if (content.length === 0) {
|
||||
return { ingested: false };
|
||||
}
|
||||
|
||||
const metadata: OpenBrainThoughtMetadata = {
|
||||
sessionId: params.sessionId,
|
||||
turn: this.nextTurn(params.sessionId),
|
||||
role: normalizeRole(params.message.role),
|
||||
type: "message",
|
||||
};
|
||||
|
||||
if (params.isHeartbeat === true) {
|
||||
metadata.isHeartbeat = true;
|
||||
}
|
||||
|
||||
await client.createThought({
|
||||
content,
|
||||
source: this.sourceForSession(params.sessionId),
|
||||
metadata,
|
||||
});
|
||||
|
||||
return { ingested: true };
|
||||
}
|
||||
|
||||
async ingestBatch(params: {
|
||||
sessionId: string;
|
||||
messages: AgentMessage[];
|
||||
isHeartbeat?: boolean;
|
||||
}): Promise<IngestBatchResult> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const maxConcurrency = 5;
|
||||
let ingestedCount = 0;
|
||||
for (let i = 0; i < params.messages.length; i += maxConcurrency) {
|
||||
const chunk = params.messages.slice(i, i + maxConcurrency);
|
||||
const results = await Promise.all(
|
||||
chunk.map((message) => {
|
||||
const ingestParams: {
|
||||
sessionId: string;
|
||||
message: AgentMessage;
|
||||
isHeartbeat?: boolean;
|
||||
} = {
|
||||
sessionId: params.sessionId,
|
||||
message,
|
||||
};
|
||||
if (params.isHeartbeat !== undefined) {
|
||||
ingestParams.isHeartbeat = params.isHeartbeat;
|
||||
}
|
||||
return this.ingest(ingestParams);
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.ingested) {
|
||||
ingestedCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ingestedCount };
|
||||
}
|
||||
|
||||
async assemble(params: {
|
||||
sessionId: string;
|
||||
messages: AgentMessage[];
|
||||
tokenBudget?: number;
|
||||
}): Promise<AssembleResult> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const config = this.getConfig();
|
||||
const client = this.getClient();
|
||||
const source = this.sourceForSession(params.sessionId);
|
||||
|
||||
const recentThoughts = this.filterSessionThoughts(
|
||||
await client.listRecent({
|
||||
limit: config.recentMessages,
|
||||
source,
|
||||
}),
|
||||
params.sessionId,
|
||||
);
|
||||
|
||||
const semanticThoughts = await this.searchSemanticThoughts({
|
||||
client,
|
||||
source,
|
||||
config,
|
||||
sessionId: params.sessionId,
|
||||
messages: params.messages,
|
||||
});
|
||||
|
||||
const mergedThoughts = this.mergeThoughts(recentThoughts, semanticThoughts);
|
||||
const mergedMessages =
|
||||
mergedThoughts.length > 0
|
||||
? mergedThoughts.map((thought, index) => this.toAgentMessage(thought, index))
|
||||
: params.messages;
|
||||
|
||||
const tokenBudget = params.tokenBudget;
|
||||
const budgetedMessages =
|
||||
typeof tokenBudget === "number" && tokenBudget > 0
|
||||
? this.trimToBudget(mergedMessages, tokenBudget)
|
||||
: mergedMessages;
|
||||
|
||||
return {
|
||||
messages: budgetedMessages,
|
||||
estimatedTokens: this.estimateTokensForMessages(budgetedMessages),
|
||||
};
|
||||
}
|
||||
|
||||
async compact(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
tokenBudget?: number;
|
||||
force?: boolean;
|
||||
currentTokenCount?: number;
|
||||
compactionTarget?: "budget" | "threshold";
|
||||
customInstructions?: string;
|
||||
legacyParams?: Record<string, unknown>;
|
||||
}): Promise<CompactResult> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const config = this.getConfig();
|
||||
const client = this.getClient();
|
||||
const source = this.sourceForSession(params.sessionId);
|
||||
|
||||
const recentThoughts = this.filterSessionThoughts(
|
||||
await client.listRecent({
|
||||
limit: Math.max(config.recentMessages, config.subagentRecentMessages),
|
||||
source,
|
||||
}),
|
||||
params.sessionId,
|
||||
);
|
||||
|
||||
if (recentThoughts.length === 0) {
|
||||
return {
|
||||
ok: true,
|
||||
compacted: false,
|
||||
reason: "no-session-context",
|
||||
result: {
|
||||
tokensBefore: 0,
|
||||
tokensAfter: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const summarizedThoughts = this.selectSummaryThoughts(recentThoughts);
|
||||
const summary = this.buildSummary(
|
||||
params.customInstructions !== undefined
|
||||
? {
|
||||
sessionId: params.sessionId,
|
||||
thoughts: summarizedThoughts,
|
||||
customInstructions: params.customInstructions,
|
||||
}
|
||||
: {
|
||||
sessionId: params.sessionId,
|
||||
thoughts: summarizedThoughts,
|
||||
},
|
||||
);
|
||||
|
||||
const summaryTokens = estimateTextTokens(summary);
|
||||
const tokensBefore = this.estimateTokensForThoughts(summarizedThoughts);
|
||||
|
||||
await client.createThought({
|
||||
content: summary,
|
||||
source,
|
||||
metadata: {
|
||||
sessionId: params.sessionId,
|
||||
turn: this.nextTurn(params.sessionId),
|
||||
role: "assistant",
|
||||
type: "summary",
|
||||
},
|
||||
});
|
||||
|
||||
const summaryThoughtIds = Array.from(
|
||||
new Set(
|
||||
summarizedThoughts
|
||||
.map((thought) => thought.id.trim())
|
||||
.filter((id) => id.length > 0),
|
||||
),
|
||||
);
|
||||
await Promise.all(summaryThoughtIds.map((thoughtId) => client.deleteThought(thoughtId)));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
reason: "summary-archived",
|
||||
result: {
|
||||
summary,
|
||||
tokensBefore,
|
||||
tokensAfter: summaryTokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async prepareSubagentSpawn(params: {
|
||||
parentSessionKey: string;
|
||||
childSessionKey: string;
|
||||
ttlMs?: number;
|
||||
}): Promise<SubagentSpawnPreparation | undefined> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const config = this.getConfig();
|
||||
const client = this.getClient();
|
||||
|
||||
const parentThoughts = this.filterSessionThoughts(
|
||||
await client.listRecent({
|
||||
limit: config.subagentRecentMessages,
|
||||
source: this.sourceForSession(params.parentSessionKey),
|
||||
}),
|
||||
params.parentSessionKey,
|
||||
);
|
||||
|
||||
const seedContent = this.buildSubagentSeedContent({
|
||||
parentSessionKey: params.parentSessionKey,
|
||||
childSessionKey: params.childSessionKey,
|
||||
thoughts: parentThoughts,
|
||||
});
|
||||
|
||||
const createdThought = await client.createThought({
|
||||
content: seedContent,
|
||||
source: this.sourceForSession(params.childSessionKey),
|
||||
metadata: {
|
||||
sessionId: params.childSessionKey,
|
||||
role: "assistant",
|
||||
type: "summary",
|
||||
parentSessionId: params.parentSessionKey,
|
||||
ttlMs: params.ttlMs,
|
||||
},
|
||||
});
|
||||
|
||||
this.subagentState.set(params.childSessionKey, {
|
||||
parentSessionKey: params.parentSessionKey,
|
||||
seedThoughtId: createdThought.id,
|
||||
});
|
||||
|
||||
return {
|
||||
rollback: async () => {
|
||||
const state = this.subagentState.get(params.childSessionKey);
|
||||
this.subagentState.delete(params.childSessionKey);
|
||||
|
||||
if (state?.seedThoughtId !== undefined && state.seedThoughtId.length > 0) {
|
||||
await client.deleteThought(state.seedThoughtId);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async onSubagentEnded(params: {
|
||||
childSessionKey: string;
|
||||
reason: SubagentEndReason;
|
||||
}): Promise<void> {
|
||||
this.assertNotDisposed();
|
||||
|
||||
const state = this.subagentState.get(params.childSessionKey);
|
||||
if (state === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.getClient();
|
||||
const config = this.getConfig();
|
||||
|
||||
const childThoughts = this.filterSessionThoughts(
|
||||
await client.listRecent({
|
||||
limit: config.subagentRecentMessages,
|
||||
source: this.sourceForSession(params.childSessionKey),
|
||||
}),
|
||||
params.childSessionKey,
|
||||
);
|
||||
|
||||
const summary = this.buildSubagentResultSummary({
|
||||
childSessionKey: params.childSessionKey,
|
||||
reason: params.reason,
|
||||
thoughts: childThoughts,
|
||||
});
|
||||
|
||||
await client.createThought({
|
||||
content: summary,
|
||||
source: this.sourceForSession(state.parentSessionKey),
|
||||
metadata: {
|
||||
sessionId: state.parentSessionKey,
|
||||
turn: this.nextTurn(state.parentSessionKey),
|
||||
role: "tool",
|
||||
type: "subagent-result",
|
||||
childSessionId: params.childSessionKey,
|
||||
reason: params.reason,
|
||||
},
|
||||
});
|
||||
|
||||
this.subagentState.delete(params.childSessionKey);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.sessionTurns.clear();
|
||||
this.subagentState.clear();
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
private searchSemanticThoughts(params: {
|
||||
client: OpenBrainClientLike;
|
||||
source: string;
|
||||
config: ResolvedOpenBrainContextEngineConfig;
|
||||
sessionId: string;
|
||||
messages: AgentMessage[];
|
||||
}): Promise<OpenBrainThought[]> {
|
||||
const query = this.pickSemanticQuery(params.messages);
|
||||
if (query === undefined || query.length === 0 || params.config.semanticSearchLimit <= 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const request: OpenBrainSearchInput = {
|
||||
query,
|
||||
limit: params.config.semanticSearchLimit,
|
||||
source: params.source,
|
||||
};
|
||||
|
||||
return params.client
|
||||
.search(request)
|
||||
.then((results) => this.filterSessionThoughts(results, params.sessionId))
|
||||
.catch((error) => {
|
||||
this.logger?.warn?.("OpenBrain semantic search failed", error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
private pickSemanticQuery(messages: AgentMessage[]): string | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i];
|
||||
if (message === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (normalizeRole(message.role) !== "user") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = serializeContent(message.content).trim();
|
||||
if (content.length > 0) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i];
|
||||
if (message === undefined) {
|
||||
continue;
|
||||
}
|
||||
const content = serializeContent(message.content).trim();
|
||||
if (content.length > 0) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private mergeThoughts(recentThoughts: OpenBrainThought[], semanticThoughts: OpenBrainThought[]): OpenBrainThought[] {
|
||||
const merged: OpenBrainThought[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const seenFingerprints = new Set<string>();
|
||||
|
||||
for (const thought of [...recentThoughts, ...semanticThoughts]) {
|
||||
const id = thought.id.trim();
|
||||
const fingerprint = thoughtFingerprint(thought);
|
||||
|
||||
if (id.length > 0 && seenIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenFingerprints.has(fingerprint)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (id.length > 0) {
|
||||
seenIds.add(id);
|
||||
}
|
||||
seenFingerprints.add(fingerprint);
|
||||
merged.push(thought);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private filterSessionThoughts(thoughts: OpenBrainThought[], sessionId: string): OpenBrainThought[] {
|
||||
return thoughts.filter((thought) => {
|
||||
const thoughtSessionId = thought.metadata?.sessionId;
|
||||
if (typeof thoughtSessionId === "string" && thoughtSessionId.length > 0) {
|
||||
return thoughtSessionId === sessionId;
|
||||
}
|
||||
|
||||
return thought.source === this.sourceForSession(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
private toAgentMessage(thought: OpenBrainThought, index: number): AgentMessage {
|
||||
return {
|
||||
role: normalizeRole(thought.metadata?.role),
|
||||
content: thought.content,
|
||||
timestamp: thoughtTimestamp(thought, this.now() + index),
|
||||
};
|
||||
}
|
||||
|
||||
private trimToBudget(messages: AgentMessage[], tokenBudget: number): AgentMessage[] {
|
||||
if (messages.length === 0 || tokenBudget <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
const budgeted: AgentMessage[] = [];
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const message = messages[i];
|
||||
if (message === undefined) {
|
||||
continue;
|
||||
}
|
||||
const tokens = estimateTextTokens(serializeContent(message.content));
|
||||
if (total + tokens > tokenBudget) {
|
||||
break;
|
||||
}
|
||||
|
||||
total += tokens;
|
||||
budgeted.unshift(message);
|
||||
}
|
||||
|
||||
if (budgeted.length === 0) {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
return lastMessage === undefined ? [] : [lastMessage];
|
||||
}
|
||||
|
||||
return budgeted;
|
||||
}
|
||||
|
||||
private estimateTokensForMessages(messages: AgentMessage[]): number {
|
||||
return messages.reduce((total, message) => {
|
||||
return total + estimateTextTokens(serializeContent(message.content));
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private estimateTokensForThoughts(thoughts: OpenBrainThought[]): number {
|
||||
return thoughts.reduce((total, thought) => total + estimateTextTokens(thought.content), 0);
|
||||
}
|
||||
|
||||
private buildSummary(params: {
|
||||
sessionId: string;
|
||||
thoughts: OpenBrainThought[];
|
||||
customInstructions?: string;
|
||||
}): string {
|
||||
const lines = params.thoughts.map((thought) => {
|
||||
const role = normalizeRole(thought.metadata?.role);
|
||||
const content = truncateLine(thought.content.replace(/\s+/g, " ").trim(), 180);
|
||||
return `- ${role}: ${content}`;
|
||||
});
|
||||
|
||||
const header = `Context summary for session ${params.sessionId}`;
|
||||
const instruction =
|
||||
params.customInstructions !== undefined && params.customInstructions.trim().length > 0
|
||||
? `Custom instructions: ${params.customInstructions.trim()}\n`
|
||||
: "";
|
||||
|
||||
return `${header}\n${instruction}${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
private selectSummaryThoughts(thoughts: OpenBrainThought[]): OpenBrainThought[] {
|
||||
const ordered = [...thoughts].sort((a, b) => {
|
||||
return thoughtTimestamp(a, 0) - thoughtTimestamp(b, 0);
|
||||
});
|
||||
|
||||
const maxLines = Math.min(ordered.length, 10);
|
||||
return ordered.slice(Math.max(ordered.length - maxLines, 0));
|
||||
}
|
||||
|
||||
private buildSubagentSeedContent(params: {
|
||||
parentSessionKey: string;
|
||||
childSessionKey: string;
|
||||
thoughts: OpenBrainThought[];
|
||||
}): string {
|
||||
const lines = params.thoughts.slice(-5).map((thought) => {
|
||||
const role = normalizeRole(thought.metadata?.role);
|
||||
return `- ${role}: ${truncateLine(thought.content.replace(/\s+/g, " ").trim(), 160)}`;
|
||||
});
|
||||
|
||||
const contextBlock = lines.length > 0 ? lines.join("\n") : "- (no prior context found)";
|
||||
|
||||
return [
|
||||
`Subagent context seed`,
|
||||
`Parent session: ${params.parentSessionKey}`,
|
||||
`Child session: ${params.childSessionKey}`,
|
||||
contextBlock,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private buildSubagentResultSummary(params: {
|
||||
childSessionKey: string;
|
||||
reason: SubagentEndReason;
|
||||
thoughts: OpenBrainThought[];
|
||||
}): string {
|
||||
const lines = params.thoughts.slice(-5).map((thought) => {
|
||||
const role = normalizeRole(thought.metadata?.role);
|
||||
return `- ${role}: ${truncateLine(thought.content.replace(/\s+/g, " ").trim(), 160)}`;
|
||||
});
|
||||
|
||||
const contextBlock = lines.length > 0 ? lines.join("\n") : "- (no child messages found)";
|
||||
|
||||
return [
|
||||
`Subagent ended (${params.reason})`,
|
||||
`Child session: ${params.childSessionKey}`,
|
||||
contextBlock,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
private sourceForSession(sessionId: string): string {
|
||||
return `${this.getConfig().source}:${sessionId}`;
|
||||
}
|
||||
|
||||
private nextTurn(sessionId: string): number {
|
||||
const next = this.sessionTurns.get(sessionId) ?? 0;
|
||||
this.sessionTurns.set(sessionId, next + 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
private getClient(): OpenBrainClientLike {
|
||||
if (this.client !== undefined) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
const config = this.getConfig();
|
||||
this.client =
|
||||
this.createClientFn?.(config) ??
|
||||
new OpenBrainClient({
|
||||
baseUrl: config.baseUrl,
|
||||
apiKey: config.apiKey,
|
||||
});
|
||||
|
||||
return this.client;
|
||||
}
|
||||
|
||||
private getConfig(): ResolvedOpenBrainContextEngineConfig {
|
||||
if (this.config !== undefined) {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
const raw = isRecord(this.rawConfig) ? this.rawConfig : {};
|
||||
|
||||
const baseUrl = typeof raw.baseUrl === "string" ? raw.baseUrl.trim() : "";
|
||||
if (baseUrl.length === 0) {
|
||||
throw new OpenBrainConfigError("Missing required OpenBrain config: baseUrl");
|
||||
}
|
||||
|
||||
const apiKey = typeof raw.apiKey === "string" ? raw.apiKey.trim() : "";
|
||||
if (apiKey.length === 0) {
|
||||
throw new OpenBrainConfigError("Missing required OpenBrain config: apiKey");
|
||||
}
|
||||
|
||||
this.config = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
recentMessages: parsePositiveInteger(raw.recentMessages, 20),
|
||||
semanticSearchLimit: parsePositiveInteger(raw.semanticSearchLimit, 10),
|
||||
source: typeof raw.source === "string" && raw.source.trim().length > 0 ? raw.source.trim() : "openclaw",
|
||||
subagentRecentMessages: parsePositiveInteger(raw.subagentRecentMessages, 8),
|
||||
};
|
||||
|
||||
return this.config;
|
||||
}
|
||||
|
||||
private assertNotDisposed(): void {
|
||||
if (this.disposed) {
|
||||
throw new Error("OpenBrainContextEngine has already been disposed");
|
||||
}
|
||||
}
|
||||
}
|
||||
40
plugins/openclaw-context/src/errors.ts
Normal file
40
plugins/openclaw-context/src/errors.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export class OpenBrainError extends Error {
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message);
|
||||
this.name = "OpenBrainError";
|
||||
if (cause !== undefined) {
|
||||
(this as Error & { cause?: unknown }).cause = cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenBrainConfigError extends OpenBrainError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "OpenBrainConfigError";
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenBrainHttpError extends OpenBrainError {
|
||||
readonly status: number;
|
||||
readonly endpoint: string;
|
||||
readonly responseBody: string | undefined;
|
||||
|
||||
constructor(params: { endpoint: string; status: number; responseBody: string | undefined }) {
|
||||
super(`OpenBrain request failed (${params.status}) for ${params.endpoint}`);
|
||||
this.name = "OpenBrainHttpError";
|
||||
this.status = params.status;
|
||||
this.endpoint = params.endpoint;
|
||||
this.responseBody = params.responseBody;
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenBrainRequestError extends OpenBrainError {
|
||||
readonly endpoint: string;
|
||||
|
||||
constructor(params: { endpoint: string; cause: unknown }) {
|
||||
super(`OpenBrain request failed for ${params.endpoint}`, params.cause);
|
||||
this.name = "OpenBrainRequestError";
|
||||
this.endpoint = params.endpoint;
|
||||
}
|
||||
}
|
||||
31
plugins/openclaw-context/src/index.ts
Normal file
31
plugins/openclaw-context/src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
OPENBRAIN_CONTEXT_ENGINE_ID,
|
||||
OPENBRAIN_PLUGIN_ID,
|
||||
OPENBRAIN_PLUGIN_VERSION,
|
||||
} from "./constants.js";
|
||||
import { OpenBrainContextEngine } from "./engine.js";
|
||||
import type { OpenClawPluginApi } from "./openclaw-types.js";
|
||||
|
||||
export { OPENBRAIN_CONTEXT_ENGINE_ID } from "./constants.js";
|
||||
export { OpenBrainContextEngine } from "./engine.js";
|
||||
export { OpenBrainConfigError, OpenBrainHttpError, OpenBrainRequestError } from "./errors.js";
|
||||
export { OpenBrainClient } from "./openbrain-client.js";
|
||||
export type { OpenBrainContextEngineConfig } from "./engine.js";
|
||||
export type { OpenClawPluginApi } from "./openclaw-types.js";
|
||||
|
||||
export function register(api: OpenClawPluginApi): void {
|
||||
api.registerContextEngine(OPENBRAIN_CONTEXT_ENGINE_ID, () => {
|
||||
const deps = api.logger !== undefined ? { logger: api.logger } : undefined;
|
||||
return new OpenBrainContextEngine(api.pluginConfig, deps);
|
||||
});
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
id: OPENBRAIN_PLUGIN_ID,
|
||||
name: "OpenBrain Context Engine",
|
||||
version: OPENBRAIN_PLUGIN_VERSION,
|
||||
kind: "context-engine",
|
||||
register,
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
333
plugins/openclaw-context/src/openbrain-client.ts
Normal file
333
plugins/openclaw-context/src/openbrain-client.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
import { OpenBrainConfigError, OpenBrainHttpError, OpenBrainRequestError } from "./errors.js";
|
||||
|
||||
export type OpenBrainThoughtMetadata = Record<string, unknown> & {
|
||||
sessionId?: string;
|
||||
turn?: number;
|
||||
role?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export type OpenBrainThought = {
|
||||
id: string;
|
||||
content: string;
|
||||
source: string;
|
||||
metadata: OpenBrainThoughtMetadata | undefined;
|
||||
createdAt: string | undefined;
|
||||
updatedAt: string | undefined;
|
||||
score: number | undefined;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type OpenBrainThoughtInput = {
|
||||
content: string;
|
||||
source: string;
|
||||
metadata?: OpenBrainThoughtMetadata;
|
||||
};
|
||||
|
||||
export type OpenBrainSearchInput = {
|
||||
query: string;
|
||||
limit: number;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type OpenBrainClientOptions = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
};
|
||||
|
||||
export interface OpenBrainClientLike {
|
||||
createThought(input: OpenBrainThoughtInput): Promise<OpenBrainThought>;
|
||||
search(input: OpenBrainSearchInput): Promise<OpenBrainThought[]>;
|
||||
listRecent(input: { limit: number; source?: string }): Promise<OpenBrainThought[]>;
|
||||
updateThought(
|
||||
id: string,
|
||||
payload: { content?: string; metadata?: OpenBrainThoughtMetadata },
|
||||
): Promise<OpenBrainThought>;
|
||||
deleteThought(id: string): Promise<void>;
|
||||
deleteThoughts(params: { source?: string; metadataId?: string }): Promise<void>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
const normalized = baseUrl.trim().replace(/\/+$/, "");
|
||||
if (normalized.length === 0) {
|
||||
throw new OpenBrainConfigError("Missing required OpenBrain config: baseUrl");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeApiKey(apiKey: string): string {
|
||||
const normalized = apiKey.trim();
|
||||
if (normalized.length === 0) {
|
||||
throw new OpenBrainConfigError("Missing required OpenBrain config: apiKey");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: unknown): Record<string, string> {
|
||||
if (headers === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const pair of headers) {
|
||||
if (!Array.isArray(pair) || pair.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = pair[0];
|
||||
const value = pair[1];
|
||||
if (typeof key !== "string" || typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[key] = value;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, value] of headers.entries()) {
|
||||
normalized[key] = value;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (!isRecord(headers)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (typeof value === "string") {
|
||||
normalized[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
normalized[key] = value.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function readResponseBody(response: Response): Promise<string | undefined> {
|
||||
try {
|
||||
const body = await response.text();
|
||||
return body.length > 0 ? body : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenBrainClient implements OpenBrainClientLike {
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(options: OpenBrainClientOptions) {
|
||||
this.baseUrl = normalizeBaseUrl(options.baseUrl);
|
||||
this.apiKey = normalizeApiKey(options.apiKey);
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
async createThought(input: OpenBrainThoughtInput): Promise<OpenBrainThought> {
|
||||
const payload = await this.request<unknown>("/v1/thoughts", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return this.extractThought(payload);
|
||||
}
|
||||
|
||||
async search(input: OpenBrainSearchInput): Promise<OpenBrainThought[]> {
|
||||
const payload = await this.request<unknown>("/v1/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return this.extractThoughtArray(payload);
|
||||
}
|
||||
|
||||
async listRecent(input: { limit: number; source?: string }): Promise<OpenBrainThought[]> {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(input.limit),
|
||||
});
|
||||
|
||||
if (input.source !== undefined && input.source.length > 0) {
|
||||
params.set("source", input.source);
|
||||
}
|
||||
|
||||
const payload = await this.request<unknown>(`/v1/thoughts/recent?${params.toString()}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
return this.extractThoughtArray(payload);
|
||||
}
|
||||
|
||||
async updateThought(
|
||||
id: string,
|
||||
payload: { content?: string; metadata?: OpenBrainThoughtMetadata },
|
||||
): Promise<OpenBrainThought> {
|
||||
const responsePayload = await this.request<unknown>(`/v1/thoughts/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return this.extractThought(responsePayload);
|
||||
}
|
||||
|
||||
async deleteThought(id: string): Promise<void> {
|
||||
await this.request<unknown>(`/v1/thoughts/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async deleteThoughts(params: { source?: string; metadataId?: string }): Promise<void> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.source !== undefined && params.source.length > 0) {
|
||||
query.set("source", params.source);
|
||||
}
|
||||
if (params.metadataId !== undefined && params.metadataId.length > 0) {
|
||||
query.set("metadata_id", params.metadataId);
|
||||
}
|
||||
|
||||
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
||||
await this.request<unknown>(`/v1/thoughts${suffix}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, init: RequestInit): Promise<T> {
|
||||
const headers = normalizeHeaders(init.headers);
|
||||
headers.Authorization = `Bearer ${this.apiKey}`;
|
||||
|
||||
if (init.body !== undefined && headers["Content-Type"] === undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImpl(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new OpenBrainRequestError({ endpoint, cause: error });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new OpenBrainHttpError({
|
||||
endpoint,
|
||||
status: response.status,
|
||||
responseBody: await readResponseBody(response),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
private extractThoughtArray(payload: unknown): OpenBrainThought[] {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map((item) => this.normalizeThought(item));
|
||||
}
|
||||
|
||||
if (!isRecord(payload)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = [payload.thoughts, payload.data, payload.results, payload.items];
|
||||
for (const candidate of candidates) {
|
||||
if (Array.isArray(candidate)) {
|
||||
return candidate.map((item) => this.normalizeThought(item));
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private extractThought(payload: unknown): OpenBrainThought {
|
||||
if (isRecord(payload)) {
|
||||
const nested = payload.thought;
|
||||
if (nested !== undefined) {
|
||||
return this.normalizeThought(nested);
|
||||
}
|
||||
|
||||
const data = payload.data;
|
||||
if (data !== undefined && !Array.isArray(data)) {
|
||||
return this.normalizeThought(data);
|
||||
}
|
||||
}
|
||||
|
||||
return this.normalizeThought(payload);
|
||||
}
|
||||
|
||||
private normalizeThought(value: unknown): OpenBrainThought {
|
||||
if (!isRecord(value)) {
|
||||
return {
|
||||
id: "",
|
||||
content: "",
|
||||
source: "",
|
||||
metadata: undefined,
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
score: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const metadataValue = value.metadata;
|
||||
const metadata = isRecord(metadataValue)
|
||||
? ({ ...metadataValue } as OpenBrainThoughtMetadata)
|
||||
: undefined;
|
||||
|
||||
const id = readString(value, "id") ?? readString(value, "thought_id") ?? "";
|
||||
const content =
|
||||
readString(value, "content") ??
|
||||
readString(value, "text") ??
|
||||
(value.content === undefined ? "" : String(value.content));
|
||||
const source = readString(value, "source") ?? "";
|
||||
|
||||
const createdAt = readString(value, "createdAt") ?? readString(value, "created_at");
|
||||
const updatedAt = readString(value, "updatedAt") ?? readString(value, "updated_at");
|
||||
const score = readNumber(value, "score");
|
||||
|
||||
return {
|
||||
...value,
|
||||
id,
|
||||
content,
|
||||
source,
|
||||
metadata,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
score,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { normalizeApiKey, normalizeBaseUrl };
|
||||
128
plugins/openclaw-context/src/openclaw-types.ts
Normal file
128
plugins/openclaw-context/src/openclaw-types.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export type AgentMessageRole = "user" | "assistant" | "tool" | "system" | string;
|
||||
|
||||
export type AgentMessage = {
|
||||
role: AgentMessageRole;
|
||||
content: unknown;
|
||||
timestamp?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AssembleResult = {
|
||||
messages: AgentMessage[];
|
||||
estimatedTokens: number;
|
||||
systemPromptAddition?: string;
|
||||
};
|
||||
|
||||
export type CompactResult = {
|
||||
ok: boolean;
|
||||
compacted: boolean;
|
||||
reason?: string;
|
||||
result?: {
|
||||
summary?: string;
|
||||
firstKeptEntryId?: string;
|
||||
tokensBefore: number;
|
||||
tokensAfter?: number;
|
||||
details?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type IngestResult = {
|
||||
ingested: boolean;
|
||||
};
|
||||
|
||||
export type IngestBatchResult = {
|
||||
ingestedCount: number;
|
||||
};
|
||||
|
||||
export type BootstrapResult = {
|
||||
bootstrapped: boolean;
|
||||
importedMessages?: number;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type ContextEngineInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
ownsCompaction?: boolean;
|
||||
};
|
||||
|
||||
export type SubagentSpawnPreparation = {
|
||||
rollback: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export type SubagentEndReason = "deleted" | "completed" | "swept" | "released";
|
||||
|
||||
export interface ContextEngine {
|
||||
readonly info: ContextEngineInfo;
|
||||
|
||||
bootstrap?(params: { sessionId: string; sessionFile: string }): Promise<BootstrapResult>;
|
||||
|
||||
ingest(params: {
|
||||
sessionId: string;
|
||||
message: AgentMessage;
|
||||
isHeartbeat?: boolean;
|
||||
}): Promise<IngestResult>;
|
||||
|
||||
ingestBatch?(params: {
|
||||
sessionId: string;
|
||||
messages: AgentMessage[];
|
||||
isHeartbeat?: boolean;
|
||||
}): Promise<IngestBatchResult>;
|
||||
|
||||
afterTurn?(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
messages: AgentMessage[];
|
||||
prePromptMessageCount: number;
|
||||
autoCompactionSummary?: string;
|
||||
isHeartbeat?: boolean;
|
||||
tokenBudget?: number;
|
||||
legacyCompactionParams?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
|
||||
assemble(params: {
|
||||
sessionId: string;
|
||||
messages: AgentMessage[];
|
||||
tokenBudget?: number;
|
||||
}): Promise<AssembleResult>;
|
||||
|
||||
compact(params: {
|
||||
sessionId: string;
|
||||
sessionFile: string;
|
||||
tokenBudget?: number;
|
||||
force?: boolean;
|
||||
currentTokenCount?: number;
|
||||
compactionTarget?: "budget" | "threshold";
|
||||
customInstructions?: string;
|
||||
legacyParams?: Record<string, unknown>;
|
||||
}): Promise<CompactResult>;
|
||||
|
||||
prepareSubagentSpawn?(params: {
|
||||
parentSessionKey: string;
|
||||
childSessionKey: string;
|
||||
ttlMs?: number;
|
||||
}): Promise<SubagentSpawnPreparation | undefined>;
|
||||
|
||||
onSubagentEnded?(params: {
|
||||
childSessionKey: string;
|
||||
reason: SubagentEndReason;
|
||||
}): Promise<void>;
|
||||
|
||||
dispose?(): Promise<void>;
|
||||
}
|
||||
|
||||
export type ContextEngineFactory = () => ContextEngine | Promise<ContextEngine>;
|
||||
|
||||
export type PluginLogger = {
|
||||
debug?: (...args: unknown[]) => void;
|
||||
info?: (...args: unknown[]) => void;
|
||||
warn?: (...args: unknown[]) => void;
|
||||
error?: (...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
export type OpenClawPluginApi = {
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
logger?: PluginLogger;
|
||||
registerContextEngine: (id: string, factory: ContextEngineFactory) => void;
|
||||
};
|
||||
414
plugins/openclaw-context/tests/engine.test.ts
Normal file
414
plugins/openclaw-context/tests/engine.test.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { OpenBrainConfigError } from "../src/errors.js";
|
||||
import { OpenBrainContextEngine } from "../src/engine.js";
|
||||
import type { AgentMessage } from "../src/openclaw-types.js";
|
||||
import type {
|
||||
OpenBrainClientLike,
|
||||
OpenBrainThought,
|
||||
OpenBrainThoughtInput,
|
||||
} from "../src/openbrain-client.js";
|
||||
|
||||
function makeThought(
|
||||
id: string,
|
||||
content: string,
|
||||
sessionId: string,
|
||||
role: string,
|
||||
createdAt: string,
|
||||
): OpenBrainThought {
|
||||
return {
|
||||
id,
|
||||
content,
|
||||
source: `openclaw:${sessionId}`,
|
||||
metadata: {
|
||||
sessionId,
|
||||
role,
|
||||
type: "message",
|
||||
},
|
||||
createdAt,
|
||||
updatedAt: undefined,
|
||||
score: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockClient(): OpenBrainClientLike {
|
||||
return {
|
||||
createThought: vi.fn(async (input: OpenBrainThoughtInput) => ({
|
||||
id: `thought-${Math.random().toString(36).slice(2)}`,
|
||||
content: input.content,
|
||||
source: input.source,
|
||||
metadata: input.metadata,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: undefined,
|
||||
score: undefined,
|
||||
})),
|
||||
search: vi.fn(async () => []),
|
||||
listRecent: vi.fn(async () => []),
|
||||
updateThought: vi.fn(async (id, payload) => ({
|
||||
id,
|
||||
content: payload.content ?? "",
|
||||
source: "openclaw:session",
|
||||
metadata: payload.metadata,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: undefined,
|
||||
score: undefined,
|
||||
})),
|
||||
deleteThought: vi.fn(async () => undefined),
|
||||
deleteThoughts: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const sessionId = "session-main";
|
||||
|
||||
const userMessage: AgentMessage = {
|
||||
role: "user",
|
||||
content: "What did we decide yesterday?",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
describe("OpenBrainContextEngine", () => {
|
||||
it("throws OpenBrainConfigError at bootstrap when baseUrl/apiKey are missing", async () => {
|
||||
const engine = new OpenBrainContextEngine({});
|
||||
|
||||
await expect(
|
||||
engine.bootstrap({
|
||||
sessionId,
|
||||
sessionFile: "/tmp/session.json",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(OpenBrainConfigError);
|
||||
});
|
||||
|
||||
it("ingests messages with session metadata", async () => {
|
||||
const mockClient = makeMockClient();
|
||||
const engine = new OpenBrainContextEngine(
|
||||
{
|
||||
baseUrl: "https://brain.example.com",
|
||||
apiKey: "secret",
|
||||
source: "openclaw",
|
||||
},
|
||||
{
|
||||
createClient: () => mockClient,
|
||||
},
|
||||
);
|
||||
|
||||
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
|
||||
const result = await engine.ingest({ sessionId, message: userMessage });
|
||||
|
||||
expect(result.ingested).toBe(true);
|
||||
expect(mockClient.createThought).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "openclaw:session-main",
|
||||
metadata: expect.objectContaining({
|
||||
sessionId,
|
||||
role: "user",
|
||||
type: "message",
|
||||
turn: 0,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("ingests batches and returns ingested count", async () => {
|
||||
const mockClient = makeMockClient();
|
||||
const engine = new OpenBrainContextEngine(
|
||||
{
|
||||
baseUrl: "https://brain.example.com",
|
||||
apiKey: "secret",
|
||||
},
|
||||
{ createClient: () => mockClient },
|
||||
);
|
||||
|
||||
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
|
||||
const result = await engine.ingestBatch({
|
||||
sessionId,
|
||||
messages: [
|
||||
{ role: "user", content: "one", timestamp: 1 },
|
||||
{ role: "assistant", content: "two", timestamp: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.ingestedCount).toBe(2);
|
||||
expect(mockClient.createThought).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("ingests batches in parallel chunks of five", async () => {
|
||||
const mockClient = makeMockClient();
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
let createdCount = 0;
|
||||
|
||||
vi.mocked(mockClient.createThought).mockImplementation(async (input: OpenBrainThoughtInput) => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
inFlight -= 1;
|
||||
createdCount += 1;
|
||||
return {
|
||||
id: `thought-${createdCount}`,
|
||||
content: input.content,
|
||||
source: input.source,
|
||||
metadata: input.metadata,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: undefined,
|
||||
score: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const engine = new OpenBrainContextEngine(
|
||||
{
|
||||
baseUrl: "https://brain.example.com",
|
||||
apiKey: "secret",
|
||||
},
|
||||
{ createClient: () => mockClient },
|
||||
);
|
||||
|
||||
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
|
||||
const result = await engine.ingestBatch({
|
||||
sessionId,
|
||||
messages: Array.from({ length: 10 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? "user" : "assistant",
|
||||
content: `message-${index + 1}`,
|
||||
timestamp: index + 1,
|
||||
})),
|
||||
});
|
||||
|
||||
expect(result.ingestedCount).toBe(10);
|
||||
expect(maxInFlight).toBe(5);
|
||||
expect(mockClient.createThought).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
it("assembles context from recent + semantic search, deduped and budget-aware", async () => {
|
||||
const mockClient = makeMockClient();
|
||||
vi.mocked(mockClient.listRecent).mockResolvedValue([
|
||||
makeThought("t1", "recent user context", sessionId, "user", "2026-03-06T12:00:00.000Z"),
|
||||
makeThought(
|
||||
"t2",
|
||||
"recent assistant context",
|
||||
sessionId,
|
||||
"assistant",
|
||||
"2026-03-06T12:01:00.000Z",
|
||||
),
|
||||
]);
|
||||
vi.mocked(mockClient.search).mockResolvedValue([
|
||||
makeThought(
|
||||
"t2",
|
||||
"recent assistant context",
|
||||
sessionId,
|
||||
"assistant",
|
||||
"2026-03-06T12:01:00.000Z",
|
||||
),
|
||||
makeThought("t3", "semantic match", sessionId, "assistant", "2026-03-06T12:02:00.000Z"),
|
||||
]);
|
||||
|
||||
const engine = new OpenBrainContextEngine(
|
||||
{
|
||||
baseUrl: "https://brain.example.com",
|
||||
apiKey: "secret",
|
||||
recentMessages: 10,
|
||||
semanticSearchLimit: 10,
|
||||
},
|
||||
{ createClient: () => mockClient },
|
||||
);
|
||||
|
||||
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
|
||||
|
||||
const result = await engine.assemble({
|
||||
sessionId,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Find the semantic context",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tokenBudget: 40,
|
||||
});
|
||||
|
||||
expect(mockClient.search).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: "Find the semantic context",
|
||||
limit: 10,
|
||||
}),
|
||||
);
|
||||
expect(result.estimatedTokens).toBeLessThanOrEqual(40);
|
||||
expect(result.messages.map((message) => String(message.content))).toEqual([
|
||||
"recent user context",
|
||||
"recent assistant context",
|
||||
"semantic match",
|
||||
]);
|
||||
});
|
||||
|
||||
it("compact archives a summary thought and deletes summarized inputs", async () => {
|
||||
const mockClient = makeMockClient();
|
||||
vi.mocked(mockClient.listRecent).mockResolvedValue(
|
||||
Array.from({ length: 12 }, (_, index) => {
|
||||
return makeThought(
|
||||
`t${index + 1}`,
|
||||
`message ${index + 1}`,
|
||||
sessionId,
|
||||
index % 2 === 0 ? "user" : "assistant",
|
||||
`2026-03-06T12:${String(index).padStart(2, "0")}:00.000Z`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const engine = new OpenBrainContextEngine(
|
||||
{
|
||||
baseUrl: "https://brain.example.com",
|
||||
apiKey: "secret",
|
||||
},
|
||||
{ createClient: () => mockClient },
|
||||
);
|
||||
|
||||
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
|
||||
|
||||
const result = await engine.compact({
|
||||
sessionId,
|
||||
sessionFile: "/tmp/session.json",
|
||||
tokenBudget: 128,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.compacted).toBe(true);
|
||||
expect(mockClient.createThought).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "openclaw:session-main",
|
||||
metadata: expect.objectContaining({
|
||||
sessionId,
|
||||
type: "summary",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const deletedIds = vi
|
||||
.mocked(mockClient.deleteThought)
|
||||
.mock.calls.map(([id]) => id)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
expect(deletedIds).toEqual([
|
||||
"t10",
|
||||
"t11",
|
||||
"t12",
|
||||
"t3",
|
||||
"t4",
|
||||
"t5",
|
||||
"t6",
|
||||
"t7",
|
||||
"t8",
|
||||
"t9",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops trimming once the newest message exceeds budget", async () => {
|
||||
const mockClient = makeMockClient();
|
||||
const oversizedNewest = "z".repeat(400);
|
||||
vi.mocked(mockClient.listRecent).mockResolvedValue([
|
||||
makeThought("t1", "small older message", sessionId, "assistant", "2026-03-06T12:00:00.000Z"),
|
||||
makeThought("t2", oversizedNewest, sessionId, "assistant", "2026-03-06T12:01:00.000Z"),
|
||||
]);
|
||||
|
||||
const engine = new OpenBrainContextEngine(
|
||||
{
|
||||
baseUrl: "https://brain.example.com",
|
||||
apiKey: "secret",
|
||||
},
|
||||
{ createClient: () => mockClient },
|
||||
);
|
||||
|
||||
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
|
||||
const result = await engine.assemble({
|
||||
sessionId,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "query",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tokenBudget: 12,
|
||||
});
|
||||
|
||||
expect(result.messages.map((message) => String(message.content))).toEqual([oversizedNewest]);
|
||||
});
|
||||
|
||||
it("prepares subagent spawn and rollback deletes seeded context", async () => {
|
||||
const mockClient = makeMockClient();
|
||||
vi.mocked(mockClient.listRecent).mockResolvedValue([
|
||||
makeThought("t1", "parent context", sessionId, "assistant", "2026-03-06T12:00:00.000Z"),
|
||||
]);
|
||||
vi.mocked(mockClient.createThought).mockResolvedValue({
|
||||
id: "seed-thought",
|
||||
content: "seed",
|
||||
source: "openclaw:child",
|
||||
metadata: undefined,
|
||||
createdAt: "2026-03-06T12:01:00.000Z",
|
||||
updatedAt: undefined,
|
||||
score: undefined,
|
||||
});
|
||||
|
||||
const engine = new OpenBrainContextEngine(
|
||||
{
|
||||
baseUrl: "https://brain.example.com",
|
||||
apiKey: "secret",
|
||||
},
|
||||
{ createClient: () => mockClient },
|
||||
);
|
||||
|
||||
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
|
||||
|
||||
const prep = await engine.prepareSubagentSpawn({
|
||||
parentSessionKey: sessionId,
|
||||
childSessionKey: "child-session",
|
||||
});
|
||||
|
||||
expect(prep).toBeDefined();
|
||||
expect(mockClient.createThought).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "openclaw:child-session",
|
||||
}),
|
||||
);
|
||||
|
||||
await prep?.rollback();
|
||||
expect(mockClient.deleteThought).toHaveBeenCalledWith("seed-thought");
|
||||
});
|
||||
|
||||
it("stores child outcome back into parent on subagent end", async () => {
|
||||
const mockClient = makeMockClient();
|
||||
vi.mocked(mockClient.listRecent)
|
||||
.mockResolvedValueOnce([
|
||||
makeThought("p1", "parent context", sessionId, "assistant", "2026-03-06T12:00:00.000Z"),
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
makeThought("c1", "child result detail", "child-session", "assistant", "2026-03-06T12:05:00.000Z"),
|
||||
]);
|
||||
|
||||
const engine = new OpenBrainContextEngine(
|
||||
{
|
||||
baseUrl: "https://brain.example.com",
|
||||
apiKey: "secret",
|
||||
},
|
||||
{ createClient: () => mockClient },
|
||||
);
|
||||
|
||||
await engine.bootstrap({ sessionId, sessionFile: "/tmp/session.json" });
|
||||
await engine.prepareSubagentSpawn({
|
||||
parentSessionKey: sessionId,
|
||||
childSessionKey: "child-session",
|
||||
});
|
||||
|
||||
await engine.onSubagentEnded({
|
||||
childSessionKey: "child-session",
|
||||
reason: "completed",
|
||||
});
|
||||
|
||||
expect(mockClient.createThought).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
source: "openclaw:session-main",
|
||||
metadata: expect.objectContaining({
|
||||
type: "subagent-result",
|
||||
sessionId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
81
plugins/openclaw-context/tests/openbrain-client.test.ts
Normal file
81
plugins/openclaw-context/tests/openbrain-client.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { OpenBrainConfigError, OpenBrainHttpError } from "../src/errors.js";
|
||||
import { OpenBrainClient } from "../src/openbrain-client.js";
|
||||
|
||||
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: init?.status ?? 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("OpenBrainClient", () => {
|
||||
it("sends bearer auth and normalized URL for createThought", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
id: "thought-1",
|
||||
content: "hello",
|
||||
source: "openclaw:main",
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new OpenBrainClient({
|
||||
baseUrl: "https://brain.example.com/",
|
||||
apiKey: "secret",
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
await client.createThought({
|
||||
content: "hello",
|
||||
source: "openclaw:main",
|
||||
metadata: { sessionId: "session-1" },
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const firstCall = fetchMock.mock.calls[0];
|
||||
expect(firstCall).toBeDefined();
|
||||
if (firstCall === undefined) {
|
||||
throw new Error("Expected fetch call arguments");
|
||||
}
|
||||
const [url, init] = firstCall as unknown as [string, RequestInit];
|
||||
expect(url).toBe("https://brain.example.com/v1/thoughts");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toMatchObject({
|
||||
Authorization: "Bearer secret",
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws OpenBrainHttpError on non-2xx responses", async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
jsonResponse({ error: "unauthorized" }, { status: 401 }),
|
||||
);
|
||||
|
||||
const client = new OpenBrainClient({
|
||||
baseUrl: "https://brain.example.com",
|
||||
apiKey: "secret",
|
||||
fetchImpl: fetchMock as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
await expect(client.listRecent({ limit: 5, source: "openclaw:main" })).rejects.toBeInstanceOf(
|
||||
OpenBrainHttpError,
|
||||
);
|
||||
|
||||
await expect(client.listRecent({ limit: 5, source: "openclaw:main" })).rejects.toMatchObject({
|
||||
status: 401,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws OpenBrainConfigError when initialized without baseUrl or apiKey", () => {
|
||||
expect(
|
||||
() => new OpenBrainClient({ baseUrl: "", apiKey: "secret", fetchImpl: fetch }),
|
||||
).toThrow(OpenBrainConfigError);
|
||||
expect(
|
||||
() => new OpenBrainClient({ baseUrl: "https://brain.example.com", apiKey: "", fetchImpl: fetch }),
|
||||
).toThrow(OpenBrainConfigError);
|
||||
});
|
||||
});
|
||||
30
plugins/openclaw-context/tests/register.test.ts
Normal file
30
plugins/openclaw-context/tests/register.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { OPENBRAIN_CONTEXT_ENGINE_ID, register } from "../src/index.js";
|
||||
|
||||
describe("plugin register()", () => {
|
||||
it("registers the openbrain context engine factory", async () => {
|
||||
const registerContextEngine = vi.fn();
|
||||
|
||||
register({
|
||||
registerContextEngine,
|
||||
pluginConfig: {
|
||||
baseUrl: "https://brain.example.com",
|
||||
apiKey: "secret",
|
||||
},
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(registerContextEngine).toHaveBeenCalledTimes(1);
|
||||
const [id, factory] = registerContextEngine.mock.calls[0] as [string, () => Promise<unknown> | unknown];
|
||||
expect(id).toBe(OPENBRAIN_CONTEXT_ENGINE_ID);
|
||||
|
||||
const engine = await factory();
|
||||
expect(engine).toHaveProperty("info.id", OPENBRAIN_CONTEXT_ENGINE_ID);
|
||||
});
|
||||
});
|
||||
9
plugins/openclaw-context/tests/smoke.test.ts
Normal file
9
plugins/openclaw-context/tests/smoke.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { OPENBRAIN_CONTEXT_ENGINE_ID } from "../src/index.js";
|
||||
|
||||
describe("project scaffold", () => {
|
||||
it("exports openbrain context engine id", () => {
|
||||
expect(OPENBRAIN_CONTEXT_ENGINE_ID).toBe("openbrain");
|
||||
});
|
||||
});
|
||||
24
plugins/openclaw-context/tsconfig.json
Normal file
24
plugins/openclaw-context/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"types": ["node", "vitest/globals"],
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
2810
pnpm-lock.yaml
generated
2810
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user