Compare commits

...
This repository has been archived on 2026-03-09. You can view files and clone it, but cannot push or open issues or pull requests.

80 commits

Author SHA1 Message Date
Snider
841fc7f2ca feat: rename package to lthn/php-agentic for Packagist
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
2026-03-09 18:00:01 +00:00
Snider
da152fdd37 fix: rename core/php-framework dependency to core/php
Some checks failed
CI / PHP 8.3 (push) Failing after 1s
CI / PHP 8.4 (push) Failing after 1s
2026-03-09 17:38:57 +00:00
Snider
9a6aebd128 fix(api): correct route prefix comments — no prefix applied
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 18:40:34 +00:00
Snider
c47c23406f fix(api): use event-scoped route registration
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Routes must go through $event->routes() so they only load for API
requests, not globally. LifecycleEventProvider adds /api prefix,
so Go client sets BaseURL to .../api.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 18:39:56 +00:00
Snider
29625c462b fix: restore Routes/api.php after case-sensitive rebase conflict
Some checks failed
CI / PHP 8.3 (push) Failing after 1s
CI / PHP 8.4 (push) Failing after 1s
macOS case-insensitive filesystem caused routes/api.php removal
to also delete Routes/api.php during rebase.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 18:35:44 +00:00
Snider
f15093843b feat(api): add REST endpoints for go-agentic Client
16 endpoints matching the go-agentic Client contract:
- Plans: list, get, create, update status, archive
- Phases: get, update status, checkpoint, task update, task toggle
- Sessions: list, get, start, end, continue
- Health: /v1/health ping

Routes at /v1/* with AgentApiAuth Bearer token middleware.
Permission-scoped: plans.read, plans.write, phases.write, sessions.write.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 18:34:59 +00:00
Snider
2ce8a02ce6 feat: add agentic:prep-workspace command
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Automates agent workspace preparation — pulls repo wiki pages from
Forge, copies protocol specs, generates TODO.md from issues, and
queries the vector DB for relevant context.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 16:16:29 +00:00
Snider
b0ed221cfa feat(brain): add wiki source type — ingest Forge repo wikis via API
Some checks failed
CI / PHP 8.3 (push) Failing after 3s
CI / PHP 8.4 (push) Failing after 2s
Fetches wiki pages from all core/* repos on Forge, parses into
sections, and stores as type:service with repo/lang tags. Gives
the PHP orchestrator contextual knowledge about the Go services
it coordinates.

71+ pages across 22+ repos, ~770 vectorised sections.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 15:58:01 +00:00
Snider
01826bc5e9 feat(brain): add docs source type for framework documentation ingestion
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Discovers markdown files from core-php/docs/build/php/ and packages/
for vectorisation into OpenBrain. Tagged as source:docs with 0.85
confidence, typed as documentation.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 15:40:14 +00:00
Snider
b32d339a53 feat: add agentic:scan, agentic:dispatch, agentic:pr-manage commands
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 14:43:22 +00:00
Snider
6ac515d80e feat: add AssignAgent, ManagePullRequest, ReportToIssue actions
AssignAgent activates a plan and starts an agent session.
ManagePullRequest evaluates PR state/CI checks and merges when ready.
ReportToIssue posts progress comments on Forgejo issues.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 14:40:44 +00:00
Snider
08d397fbf6 feat: add CreatePlanFromIssue action
Converts Forgejo work items (from ScanForWork) into AgentPlans.
Extracts checklist tasks from issue body, creates a single-phase plan,
and deduplicates by matching issue metadata on existing plans.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 14:40:10 +00:00
Snider
b3cf2a4b7d feat: add ScanForWork action for Forgejo epic scanning
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:37:12 +00:00
Snider
e82d35c13d feat: add ForgejoService API client for agent orchestration
Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:34:20 +00:00
Snider
440ea340df fix: rename agent_sessions columns to match model expectations
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Renames uuid → session_id and last_activity_at → last_active_at,
and changes session_id column type from uuid to varchar to support
prefixed IDs (sess_...).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 14:06:06 +00:00
Snider
6f0618692a feat: add plan/session/phase/task Actions + slim MCP tools
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Extract business logic from MCP tool handlers into 15 Action classes
(Plan 5, Session 5, Phase 3, Task 2) following the Brain pattern.
MCP tools become thin wrappers calling Action::run(). Add framework-level
REST controllers and routes as sensible defaults for consumers.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 13:58:45 +00:00
Snider
8b8a9c26e5 feat: extract Brain operations into CorePHP Actions + API routes
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
- Create 4 Actions in Actions/Brain/ (RememberKnowledge, RecallKnowledge,
  ForgetKnowledge, ListKnowledge) using the Action trait pattern
- Slim MCP tool handlers to thin wrappers calling Actions
- Add BrainController with REST endpoints (remember, recall, forget, list)
- Add API route file with api.auth + api.scope.enforce middleware
- Wire ApiRoutesRegistering in Boot.php
- Rename routes/ → Routes/ to match CorePHP convention
- Remove empty database/migrations/ (legacy Laravel boilerplate)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-04 12:15:13 +00:00
Snider
8bc6e62f13 fix: bind AgentToolRegistry as singleton for in-process tool execution
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Without singleton binding, each app()->make() call creates a fresh
instance and tools registered via McpToolsRegistering are lost.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:38:23 +00:00
Snider
bad718da8d revert: remove domains array, keep single domain config
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Multi-domain handled at app level via config override.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 17:08:52 +00:00
Snider
b75fa0ba57 feat: add mcp.domains config for multi-domain portal support
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
New 'domains' key accepts comma-separated MCP_DOMAINS env var,
falling back to single MCP_DOMAIN value for backward compatibility.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 16:59:28 +00:00
Snider
8efd939ce4 fix: derive MCP portal domain from APP_DOMAIN
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Instead of hardcoding mcp.host.uk.com as the default, derive it from
APP_DOMAIN env var so it works automatically per environment without
needing a separate MCP_DOMAIN override.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 16:51:16 +00:00
Snider
1ef8157822 fix: truncate oversized sections and clear DB on fresh ingest
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
- Truncate content to 3800 chars before embedding (embeddinggemma has
  a 2048-token context, ~4K char limit). Eliminates all 73 Ollama 500
  errors from oversized plan sections.
- Clear brain_memories DB table when --fresh is used, keeping DB rows
  in sync with Qdrant vectors.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 16:02:11 +00:00
Snider
331796c1da feat: add dedicated brain database connection for remote MariaDB
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Brain memories can now be stored in a separate database, co-located
with Qdrant vectors on the homelab. Defaults to the app's main DB
when no BRAIN_DB_* env vars are set (zero-config for existing installs).

- Add brain.database config with BRAIN_DB_* env var support
- Register 'brain' database connection in Boot.php
- Set BrainMemory model to use 'brain' connection
- Update BrainService transactions to use brain connection
- Update migration to use brain connection, drop workspace FK
  (cross-database FKs not supported)
- Add migration to drop FK on existing installs
- Update default URLs from *.lthn.lan to *.lthn.sh

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 15:14:01 +00:00
Snider
ad0ee04b83 chore: make TLS skip detect any non-public TLD, not just .lan
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Prepares for lthn.lan → lthn.sh migration. Once real certs are
deployed, verifySsl will always be true and this logic becomes
a no-op safety net for .lan/.lab/.local/.test domains.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 13:56:06 +00:00
Snider
02cc11d2cf chore: update default brain URLs to *.lthn.lan convention
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Ollama and Qdrant defaults now use ollama.lthn.lan and
qdrant.lthn.lan to match the homelab service mesh naming.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 13:19:59 +00:00
Snider
9623e1e0b5 feat: add brain:ingest command for comprehensive knowledge archival
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Discovers markdown across 4 source types:
- memory: ~/.claude/projects memory files
- plans: docs/plans across repos + ~/.claude/plans
- claude-md: CLAUDE.md repo instructions
- tasks: core/tasks research and ideas

Supports --fresh to clear collection, --dry-run for preview,
and --source to target specific types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:10:37 +00:00
Snider
9d49fc601b refactor: build HTTP client in single call, not conditional mutation
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:54:57 +00:00
Snider
b6823538d5 feat: skip TLS verification for .lan domains in BrainService
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Adds a private http() helper that wraps Http::timeout() with
conditional withoutVerifying() for self-signed .lan certs behind
Traefik. Boot singleton auto-detects .lan URLs at construction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:53:00 +00:00
Snider
20a0b584ae fix: remove glob path from docblock that broke PHP tokenizer
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
The `*/` in `projects/*/memory/` was closing the docblock comment
early, causing PHP to see `for` as a keyword on the same line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:49:30 +00:00
Snider
31e2aae980 chore: rename package from host-uk/core-agentic to core/php-agentic
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Aligns composer package name with forge repo path
(forge.lthn.ai/core/php-agentic).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 10:37:04 +00:00
Snider
c697a6657f chore(brain): use HTTPS for Qdrant via Traefik
Some checks failed
CI / PHP 8.3 (push) Failing after 3s
CI / PHP 8.4 (push) Failing after 2s
Qdrant is also behind Traefik at qdrant.lan alongside ollama.lan.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 10:13:06 +00:00
Snider
f17e1a0b6c chore(brain): use HTTPS for Ollama via Traefik
Some checks are pending
CI / PHP 8.3 (push) Waiting to run
CI / PHP 8.4 (push) Waiting to run
Ollama is behind Traefik reverse proxy on ollama.lan, so no port
needed and TLS should be enforced by default.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 10:12:34 +00:00
Snider
43b470257b feat(brain): configurable embedding model, default to EmbeddingGemma
Some checks failed
CI / PHP 8.3 (push) Failing after 3s
CI / PHP 8.4 (push) Failing after 3s
Make embedding model swappable via BRAIN_EMBEDDING_MODEL env var.
Switch default from nomic-embed-text to embeddinggemma (Gemma 3
based, 2x better cluster separation in benchmarks).

Default Ollama URL now points to ollama.lan (Linux homelab GPU).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 10:10:02 +00:00
Snider
dfd3dde7b1 feat(brain): add brain:seed-memory artisan command
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
Scans ~/.claude/projects/*/memory/ for MEMORY.md and topic markdown
files, parses sections, infers memory types, and imports into
OpenBrain via BrainService::remember().

Supports --dry-run, --workspace, --agent, and --path options.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 09:53:28 +00:00
Snider
d82ad2b9b1 fix(brain): address code quality review findings
- Move BrainMemory::create() inside BrainService::remember() for
  full atomicity (DB + Qdrant in single transaction)
- Add forWorkspace() scope to recall() MariaDB query (tenant isolation)
- Wrap forget() in DB::transaction (MariaDB first, then Qdrant)
- Check qdrantDelete() response and log warnings on failure
- Validate embed() response is a non-empty array

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 09:47:42 +00:00
Snider
2c6a095a0e fix(brain): address spec review findings
- Move BrainForget DB lookup inside circuit breaker for consistency
- Check ensureCollection() PUT response for Qdrant errors
- Wrap remember() in DB::transaction for atomicity
- Align migration confidence default to 0.8 (matches PHP default)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 09:45:05 +00:00
Snider
eeb6927d8f feat(brain): add BrainService, MCP tools, and registration
- BrainService: Ollama embeddings + Qdrant vector upsert/search/delete
- brain_remember: store knowledge with type, tags, confidence, supersession
- brain_recall: semantic search with filter by project/type/agent/confidence
- brain_forget: workspace-scoped deletion from both stores
- brain_list: MariaDB query with model scopes, no vector search
- Config: brain.ollama_url, brain.qdrant_url, brain.collection
- Boot: BrainService singleton + tool registration via AgentToolRegistry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:39:19 +00:00
Snider
627813cc4d feat(brain): add BrainMemory model and migration
UUID-keyed brain_memories table with workspace scoping, self-referential
supersession chain, TTL expiry, and confidence scoring. Eloquent model
includes all scopes and helpers needed by the MCP tool layer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:33:46 +00:00
Snider
daa11bab39 docs: OpenBrain implementation plan — 8 tasks, TDD
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
8-task plan: migration, BrainService (Ollama+Qdrant), 4 MCP tools
(remember/recall/forget/list), Go bridge subsystem, MEMORY.md seed
command. 18 files across php-agentic and go-ai.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 09:28:31 +00:00
Snider
a6e4f865e4 docs: OpenBrain design — shared agent knowledge graph
Some checks failed
CI / PHP 8.3 (push) Failing after 3s
CI / PHP 8.4 (push) Failing after 2s
Shared vector-indexed knowledge store accessible by all agents via MCP.
MariaDB for relational metadata, Qdrant for semantic search, Ollama for
embeddings. Four MCP tools: brain_remember, brain_recall, brain_forget,
brain_list. Replaces scattered MEMORY.md files with singular state.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-03 09:22:56 +00:00
Snider
1ead364afe fix(ci): install zip in release workflow
Some checks failed
CI / PHP 8.3 (push) Failing after 3s
CI / PHP 8.4 (push) Failing after 4s
Forgejo Composer API requires zip format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:43:52 +00:00
Snider
647635fc6d fix(ci): simplify release workflow, use FORGEJO_REF_NAME
Some checks failed
CI / PHP 8.3 (push) Failing after 4s
CI / PHP 8.4 (push) Failing after 3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:36:18 +00:00
Snider
522433c019 fix(ci): use Forgejo-native variables in release workflow
Some checks failed
CI / PHP 8.3 (push) Failing after 5s
CI / PHP 8.4 (push) Failing after 3s
Replace github.server_url/GITHUB_REF_NAME with explicit forge URL
and GITEA_REF_NAME/GITEA_OUTPUT.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:13:12 +00:00
Snider
29656e3b92 feat: add Forgejo release workflow for Composer registry
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
On tag push (v*), zips the package and publishes to the
forge.lthn.ai Composer package registry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:00:19 +00:00
1bbc1336b7 Merge pull request 'refactor: consolidate duplicate state models (#18)' (#48) from refactor/consolidate-workspace-state-models into main
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
2026-02-24 13:26:37 +00:00
darbs-claude
7fadbcb96c
refactor: consolidate duplicate state models into WorkspaceState (#18)
Some checks failed
CI / PHP 8.3 (pull_request) Failing after 1s
CI / PHP 8.4 (pull_request) Failing after 1s
- Delete Models/AgentWorkspaceState.php (legacy port, no backing table)
- Rewrite Models/WorkspaceState.php as the single canonical state model
  backed by agent_workspace_states table with array value cast,
  type helpers, scopeForPlan/scopeOfType, static getValue/setValue,
  and toMcpContext() for MCP tool output
- Update AgentPlan::states() relation and setState() return type
- Update StateSet MCP tool import
- Update SecurityTest to use WorkspaceState
- Add WorkspaceStateTest covering table, casts, type helpers, scopes,
  static helpers, toMcpContext, and AgentPlan integration
- Mark CQ-001 done in TODO.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 13:26:23 +00:00
80c778cb08 Merge pull request 'feat: add template version management' (#63) from feat/template-version-management into main
Some checks failed
CI / PHP 8.3 (push) Failing after 2s
CI / PHP 8.4 (push) Failing after 2s
2026-02-24 13:25:33 +00:00
0e7b617551
feat: add template version management (#35)
Some checks failed
CI / PHP 8.3 (pull_request) Failing after 2s
CI / PHP 8.4 (pull_request) Failing after 1s
Snapshots YAML template content in a new `plan_template_versions` table
whenever a plan is created from a template. Plans reference their version
via `template_version_id` so existing plans are unaffected by future
template file edits.

Key changes:
- Migration 0006: create `plan_template_versions` table (slug, version,
  name, content JSON, content_hash SHA-256); add nullable FK
  `template_version_id` to `agent_plans`
- Model `PlanTemplateVersion`: `findOrCreateFromTemplate()` deduplicates
  identical content by hash; `historyFor()` returns versions newest-first
- `AgentPlan`: add `template_version_id` fillable and `templateVersion()`
  relationship
- `PlanTemplateService::createPlan()`: snapshot raw template before
  variable substitution; store version id and version number in metadata;
  add `getVersionHistory()` and `getVersion()` public methods
- Tests: `TemplateVersionManagementTest` covering model behaviour, plan
  creation snapshotting, deduplication, history ordering, and service
  methods

Closes #35

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 13:25:17 +00:00
ffc441f22a Merge pull request 'fix: improve template variable error messages' (#58) from fix/template-variable-error-messages into main
Some checks failed
CI / PHP 8.3 (push) Failing after 3s
CI / PHP 8.4 (push) Failing after 2s
2026-02-24 13:21:14 +00:00
a9a6e258e1 Merge pull request 'feat: add plan archival with retention policy' (#62) from feat/plan-retention-policy into main
Some checks failed
CI / PHP 8.3 (push) Failing after 3s
CI / PHP 8.4 (push) Failing after 2s
2026-02-24 13:20:38 +00:00
d26250fc12 Merge pull request 'docs: document MCP tool dependency system' (#60) from docs/doc-002-mcp-tool-dependency-system into main
Some checks failed
CI / PHP 8.3 (push) Has been cancelled
CI / PHP 8.4 (push) Has been cancelled
2026-02-24 13:20:33 +00:00
6a1709fca9 Merge pull request 'fix: audit UK/US spelling consistency (#36)' (#64) from fix/uk-spelling-consistency into main
Some checks failed
CI / PHP 8.3 (push) Failing after 4s
CI / PHP 8.4 (push) Failing after 3s
2026-02-24 13:20:24 +00:00
darbs-claude
7d6081bdd7 fix: use UK English spelling in MCP server docstring (#36)
Some checks failed
CI / PHP 8.3 (pull_request) Failing after 3s
CI / PHP 8.4 (pull_request) Failing after 2s
Audited all PHP files for US English spellings per CLAUDE.md convention.
Fixed "Organize" → "Organise" in Mcp/Servers/Marketing.php docstring.

CSS/JS identifiers (borderColor, backgroundColor, transition-colors) and
array keys that form interface contracts with the host-uk/core package are
unchanged as they are not prose.

Closes #36

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 15:41:16 +00:00
cc1c4c1adc feat: add plan archival with retention policy (#34)
Some checks failed
CI / PHP 8.3 (pull_request) Failing after 21s
CI / PHP 8.4 (pull_request) Failing after 19s
- Add `agentic.plan_retention_days` config (default 90 days via AGENTIC_PLAN_RETENTION_DAYS env)
- Add SoftDeletes and `archived_at` timestamp to AgentPlan model
- Add migration for `deleted_at` and `archived_at` columns on agent_plans
- Create `agentic:plan-cleanup` command with --dry-run and --days options
- Schedule retention cleanup to run daily via service provider
- Register PlanRetentionCommand in ConsoleBooting handler
- Add PlanRetentionTest feature test suite covering all retention scenarios
- Fix archive() to store archived_at as dedicated column (not metadata string)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 15:11:55 +00:00
b0e2be2633 fix(ci): correct container image expression
Some checks failed
CI / PHP 8.3 (push) Failing after 1s
CI / PHP 8.4 (push) Failing after 1s
2026-02-23 13:47:09 +00:00
a5da40a202 feat(ci): use lthn/build:php container image
Some checks failed
CI / PHP 8.3 (push) Failing after 0s
CI / PHP 8.4 (push) Failing after 0s
Replace setup-php action with pre-built container.
Eliminates ~50s setup overhead per matrix job.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:46:48 +00:00
Claude
be820fead8
fix: use Mockery mocks for ApiKey and fix named arg matching
All checks were successful
CI / PHP 8.4 (push) Successful in 1m41s
CI / PHP 8.3 (push) Successful in 1m44s
- Replace anonymous class extending ApiKey with Mockery mock to avoid
  requiring php-api package at load time
- Replace with() named args with withSomeOfArgs() for Mockery compat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:20:01 +00:00
Claude
5f016c6275
style: fix Pint violations in ProcessContentTask and AgentDetection
Some checks failed
CI / PHP 8.3 (push) Failing after 1m49s
CI / PHP 8.4 (push) Failing after 1m48s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:08:49 +00:00
Claude
ae4188c063
fix: template interpolation and Cache::fake() in tests
Some checks failed
CI / PHP 8.4 (push) Failing after 1m26s
CI / PHP 8.3 (push) Failing after 1m55s
- interpolateVariables: use string concatenation for triple-brace
  placeholders instead of PHP string interpolation which only
  produces single braces
- AgentToolRegistryTest: replace Cache::fake() (not available) with
  Cache::flush() since array driver is already in-memory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:05:54 +00:00
darbs-claude
eb6bc27a4e docs: document MCP tool dependency system
Some checks failed
CI / PHP 8.3 (pull_request) Failing after 1m44s
CI / PHP 8.4 (pull_request) Failing after 1m44s
Add README to Mcp/Tools/Agent/ explaining:
- How ToolDependency works (contextExists, sessionState, entityExists)
- Context requirements (workspace_id, session_id) and multi-tenant safety
- Step-by-step guide for creating new tools
- AgentTool base class property and method reference
- Dependency resolution order and recommended declaration sequence
- Troubleshooting guide for common dependency errors

Closes #32

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 12:05:35 +00:00
b86714db6e Merge pull request 'docs: add PHPDoc to AgentDetection patterns' (#59) from docs/agent-detection-phpdoc into main
Some checks failed
CI / PHP 8.3 (push) Failing after 1m19s
CI / PHP 8.4 (push) Failing after 1m20s
Reviewed-on: #59
2026-02-23 12:04:17 +00:00
Claude
6cd9ca09d7
style: fix pint issues in ContentService and AgentToolRegistryTest
Some checks failed
CI / PHP 8.3 (push) Failing after 1m50s
CI / PHP 8.4 (push) Failing after 1m50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:58:52 +00:00
darbs-claude
e47998bc15 docs: add PHPDoc to AgentDetection patterns
Some checks failed
CI / PHP 8.4 (pull_request) Failing after 1m21s
CI / PHP 8.3 (pull_request) Failing after 1m23s
Document each PROVIDER_PATTERNS entry with real User-Agent examples,
add inline comments to BROWSER_INDICATORS and NON_AGENT_BOTS with
categorised UA examples, document MCP_TOKEN_HEADER with token format
details, and add class-level usage examples and detection priority
ordering.

Closes #31
Refs: DOC-001

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 11:56:57 +00:00
Claude
938081f2f5
fix: resolve 14 test failures across 3 test files
Some checks failed
CI / PHP 8.4 (push) Failing after 1m33s
CI / PHP 8.3 (push) Failing after 1m35s
ProcessContentTaskTest: set mock properties directly instead of
shouldReceive('__get') which doesn't reliably intercept property
access on Mockery mocks of non-existent classes.

HasStreamParsing: fix parseJSONStream chunked read bug where the
inner parse loop restarted at position 0 with stale state from
a previous partial parse. Track scan position across chunks.

AgentDetection: fix Postman regex \bPostman\b → \bPostman/ so it
matches PostmanRuntime (no word boundary between n and R).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:48:29 +00:00
darbs-claude
91ee71b8a1 fix: improve template variable error messages (#30)
Some checks failed
CI / PHP 8.3 (pull_request) Failing after 1m37s
CI / PHP 8.4 (pull_request) Failing after 1m36s
Enhance `validateVariables()` in `PlanTemplateService` to produce
actionable errors instead of the generic "Required variable '...' is missing".

Changes:
- Extracted `buildVariableError()` helper that composes the message from
  the variable's `description`, `format`, `example`, and `examples` fields
- Added `naming_convention` key to the returned array so callers have
  a constant reminder that variable names use snake_case
- Added a `NAMING_CONVENTION` private const to avoid string duplication

Tests (6 new cases in `PlanTemplateServiceTest`):
- description included in error message
- single `example` value included
- `examples` list (first two) included
- `format` hint included alongside example
- `naming_convention` present in both valid and invalid results
- bare variable (no description) still produces useful "missing" message

Closes #30

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 11:48:27 +00:00
5fa46104f4 Merge pull request 'fix: validate API keys on AgenticManager init' (#57) from fix/validate-api-keys-on-init into main
Some checks failed
CI / PHP 8.3 (push) Failing after 1m25s
CI / PHP 8.4 (push) Failing after 1m28s
Reviewed-on: #57
2026-02-23 11:41:12 +00:00
6b7a7ade15 Merge pull request 'fix: improve workspace context error messages' (#56) from fix/workspace-context-errors into main
Some checks failed
CI / PHP 8.3 (push) Failing after 1m29s
CI / PHP 8.4 (push) Failing after 1m27s
Reviewed-on: #56
2026-02-23 11:39:41 +00:00
968cbcdd63 Merge pull request 'fix: add batch failure recovery to ContentService' (#55) from fix/batch-failure-recovery into main
Some checks failed
CI / PHP 8.4 (push) Has been cancelled
CI / PHP 8.3 (push) Has been cancelled
Reviewed-on: #55
2026-02-23 11:39:31 +00:00
f528f94d68 Merge pull request 'fix: add error handling to ClaudeService streaming' (#54) from fix/stream-error-handling into main
Some checks failed
CI / PHP 8.4 (push) Has been cancelled
CI / PHP 8.3 (push) Has been cancelled
Reviewed-on: #54
2026-02-23 11:39:17 +00:00
8ade82587d Merge pull request 'perf: cache permitted tools per API key' (#53) from perf/cache-permitted-tools-per-api-key into main
Some checks failed
CI / PHP 8.4 (push) Has been cancelled
CI / PHP 8.3 (push) Has been cancelled
Reviewed-on: #53
2026-02-23 11:39:06 +00:00
darbs-claude
c315fc43c6 fix: validate API keys on AgenticManager init (#29)
Some checks failed
CI / PHP 8.3 (pull_request) Failing after 1m47s
CI / PHP 8.4 (pull_request) Failing after 1m46s
Log a warning for each AI provider registered without an API key so
that misconfiguration is surfaced at boot time (not silently on the
first API call).  Each message names the environment variable to set:

  ANTHROPIC_API_KEY  – Claude
  GOOGLE_AI_API_KEY  – Gemini
  OPENAI_API_KEY     – OpenAI

Providers without a key remain registered but are marked unavailable
via isAvailable(), preserving backward compatibility.

- Add Log::warning() calls in registerProviders() for empty keys
- Extend AgenticManagerTest with a dedicated 'API key validation
  warnings' describe block (7 new test cases)
- Update DX-002 in TODO.md as resolved

Closes #29

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 11:39:01 +00:00
ff34ede167 Merge pull request 'perf: optimize AgentPhase dependency checking with batch query' (#52) from perf/optimize-agent-phase-dependency-checking into main
Some checks failed
CI / PHP 8.4 (push) Has been cancelled
CI / PHP 8.3 (push) Has been cancelled
Reviewed-on: #52
2026-02-23 11:38:54 +00:00
darbs-claude
6748e6cd84 fix: improve workspace context error messages (closes #28)
Some checks failed
CI / PHP 8.4 (pull_request) Failing after 1m39s
CI / PHP 8.3 (pull_request) Failing after 1m42s
Updated workspace_id error messages in all MCP tools to include
actionable guidance and a documentation link. Affected tools:
PlanCreate, PlanGet, PlanList, StateSet, StateGet, StateList,
SessionStart.

Resolves DX-001 from TODO.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 11:28:32 +00:00
darbs-claude
78bdebcaaa fix: add batch failure recovery to ContentService (#27)
Some checks failed
CI / PHP 8.3 (pull_request) Failing after 1m24s
CI / PHP 8.4 (pull_request) Failing after 1m27s
- Track progress in a per-batch JSON state file after each article so
  a mid-run crash leaves a recoverable checkpoint
- Add `maxRetries` parameter to generateBatch() with per-article retry
  loop (default: 1 extra attempt)
- Add `resumeBatch()` to re-process only failed/pending articles,
  skipping those already successfully generated in a prior run
- Add `loadBatchProgress()` public method for inspecting state
- State stores per-article status, attempt counts, error messages,
  and timestamps for full observability

Tests: 6 new scenarios covering state persistence, resume capability,
retry logic, and the no-state error case

Closes #27

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 11:17:56 +00:00
darbs-claude
77e4ae6bad fix: add error handling to ClaudeService streaming (#26)
Some checks failed
CI / PHP 8.3 (pull_request) Failing after 1m53s
CI / PHP 8.4 (pull_request) Failing after 1m52s
Wrap stream() in try/catch to prevent silent failures.
On exception, log the error and yield a structured error event:
  ['type' => 'error', 'message' => string]

Adds tests for connection errors, runtime exceptions, error event
format, and Log::error invocation. Closes ERR-001 in TODO.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 11:04:07 +00:00
darbs-claude
a352f697a9 perf: cache permitted tools per API key (closes #24)
Some checks failed
CI / PHP 8.4 (pull_request) Failing after 1m23s
CI / PHP 8.3 (pull_request) Failing after 1m27s
Cache the list of permitted tool names in `AgentToolRegistry::forApiKey()`
using a 1-hour TTL to avoid O(n) filtering on every request (PERF-002).

- Add `Cache::remember()` in `forApiKey()` storing tool names keyed by API
  key ID (`agent_tool_registry:api_key:{id}`)
- Add `flushCacheForApiKey(int|string $id)` for explicit invalidation
- Add `CACHE_TTL` constant (3600 s) for easy tuning
- Invalidate cache in `AgentApiKeyService::updatePermissions()` and `revoke()`
  so permission changes take effect immediately
- Add `tests/Unit/AgentToolRegistryTest.php` covering cache hit/miss,
  per-key isolation, scope filtering, TTL constant, and flush behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 10:54:12 +00:00
darbs-claude
909c2da6df perf: replace N+1 find() with whereIn batch lookup in checkDependencies()
Some checks failed
CI / PHP 8.4 (pull_request) Failing after 1m48s
CI / PHP 8.3 (pull_request) Failing after 2m0s
Resolves #23

- Replace per-dependency `AgentPhase::find()` loop with a single
  `AgentPhase::whereIn('id', $dependencies)->get()` call, reducing
  query count from N to 1 for any number of dependencies
- Short-circuit early when dependencies list is empty to avoid
  unnecessary query at all
- Add tests: empty deps, skipped-dep passthrough, single-query
  assertion, blocker shape validation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 10:40:00 +00:00
Claude
fcdeace290
ci: retrigger
Some checks failed
CI / PHP 8.3 (push) Failing after 2m2s
CI / PHP 8.4 (push) Failing after 2m1s
2026-02-23 07:09:43 +00:00
Claude
d88095780e
fix: use closure-based __get for mock property access
Some checks are pending
CI / PHP 8.3 (push) Waiting to run
CI / PHP 8.4 (push) Waiting to run
Replace individual __get expectations with a single closure that handles
all property access. Fixes ErrorException on undefined property access
with Mockery mocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 07:02:04 +00:00
Claude
f2f27ec766 style: fix pint code style issues
Some checks failed
CI / PHP 8.3 (push) Failing after 2m8s
CI / PHP 8.4 (push) Failing after 1m58s
2026-02-23 06:42:24 +00:00
119 changed files with 11828 additions and 881 deletions

View file

@ -10,6 +10,8 @@ jobs:
test:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
container:
image: lthn/build:php-${{ matrix.php }}
strategy:
fail-fast: true
@ -19,13 +21,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: https://github.com/shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite
coverage: pcov
- name: Clone sister packages
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -0,0 +1,38 @@
name: Publish Composer Package
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create package archive
run: |
apt-get update && apt-get install -y zip
zip -r package.zip . \
-x ".forgejo/*" \
-x ".git/*" \
-x "tests/*" \
-x "docker/*" \
-x "*.yaml" \
-x "infection.json5" \
-x "phpstan.neon" \
-x "phpunit.xml" \
-x "psalm.xml" \
-x "rector.php" \
-x "TODO.md" \
-x "ROADMAP.md" \
-x "CONTRIBUTING.md" \
-x "package.json" \
-x "package-lock.json"
- name: Publish to Forgejo Composer registry
run: |
curl --fail --user "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file package.zip \
"https://forge.lthn.ai/api/packages/core/composer?version=${FORGEJO_REF_NAME#v}"

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Support\Facades\Log;
/**
* Remove a memory from the shared OpenBrain knowledge store.
*
* Deletes from both MariaDB and Qdrant. Workspace-scoped.
*
* Usage:
* ForgetKnowledge::run('uuid-here', 1, 'virgil', 'outdated info');
*/
class ForgetKnowledge
{
use Action;
public function __construct(
private BrainService $brain,
) {}
/**
* @return array{forgotten: string, type: string}
*
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function handle(string $id, int $workspaceId, string $agentId = 'anonymous', ?string $reason = null): array
{
if ($id === '') {
throw new \InvalidArgumentException('id is required');
}
$memory = BrainMemory::where('id', $id)
->where('workspace_id', $workspaceId)
->first();
if (! $memory) {
throw new \InvalidArgumentException("Memory '{$id}' not found in this workspace");
}
Log::info('OpenBrain: memory forgotten', [
'id' => $id,
'type' => $memory->type,
'agent_id' => $agentId,
'reason' => $reason,
]);
$this->brain->forget($id);
return [
'forgotten' => $id,
'type' => $memory->type,
];
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
/**
* List memories in the shared OpenBrain knowledge store.
*
* Pure MariaDB query using model scopes no vector search.
* Use RecallKnowledge for semantic queries.
*
* Usage:
* $memories = ListKnowledge::run(1, ['type' => 'decision']);
*/
class ListKnowledge
{
use Action;
/**
* @param array{project?: string, type?: string, agent_id?: string, limit?: int} $filter
* @return array{memories: array, count: int}
*/
public function handle(int $workspaceId, array $filter = []): array
{
$limit = min(max((int) ($filter['limit'] ?? 20), 1), 100);
$query = BrainMemory::forWorkspace($workspaceId)
->active()
->latestVersions()
->forProject($filter['project'] ?? null)
->byAgent($filter['agent_id'] ?? null);
$type = $filter['type'] ?? null;
if ($type !== null) {
if (is_string($type) && ! in_array($type, BrainMemory::VALID_TYPES, true)) {
throw new \InvalidArgumentException(
sprintf('type must be one of: %s', implode(', ', BrainMemory::VALID_TYPES))
);
}
$query->ofType($type);
}
$memories = $query->orderByDesc('created_at')
->limit($limit)
->get();
return [
'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext())->all(),
'count' => $memories->count(),
];
}
}

View file

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
/**
* Semantic search across the shared OpenBrain knowledge store.
*
* Uses vector similarity to find memories relevant to a natural
* language query, with optional filtering by project, type, agent,
* or minimum confidence.
*
* Usage:
* $results = RecallKnowledge::run('how does auth work?', 1);
*/
class RecallKnowledge
{
use Action;
public function __construct(
private BrainService $brain,
) {}
/**
* @param array{project?: string, type?: string|array, agent_id?: string, min_confidence?: float} $filter
* @return array{memories: array, scores: array<string, float>, count: int}
*
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function handle(string $query, int $workspaceId, array $filter = [], int $topK = 5): array
{
if ($query === '') {
throw new \InvalidArgumentException('query is required and must be a non-empty string');
}
if (mb_strlen($query) > 2000) {
throw new \InvalidArgumentException('query must not exceed 2,000 characters');
}
if ($topK < 1 || $topK > 20) {
throw new \InvalidArgumentException('top_k must be between 1 and 20');
}
if (isset($filter['type'])) {
$typeValue = $filter['type'];
$validTypes = BrainMemory::VALID_TYPES;
if (is_string($typeValue)) {
if (! in_array($typeValue, $validTypes, true)) {
throw new \InvalidArgumentException(
sprintf('filter.type must be one of: %s', implode(', ', $validTypes))
);
}
} elseif (is_array($typeValue)) {
foreach ($typeValue as $t) {
if (! is_string($t) || ! in_array($t, $validTypes, true)) {
throw new \InvalidArgumentException(
sprintf('Each filter.type value must be one of: %s', implode(', ', $validTypes))
);
}
}
}
}
if (isset($filter['min_confidence'])) {
$mc = $filter['min_confidence'];
if (! is_numeric($mc) || $mc < 0.0 || $mc > 1.0) {
throw new \InvalidArgumentException('filter.min_confidence must be between 0.0 and 1.0');
}
}
$result = $this->brain->recall($query, $topK, $filter, $workspaceId);
return [
'memories' => $result['memories'],
'scores' => $result['scores'],
'count' => count($result['memories']),
];
}
}

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Brain;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\BrainMemory;
use Core\Mod\Agentic\Services\BrainService;
/**
* Store a memory in the shared OpenBrain knowledge store.
*
* Persists content with embeddings to both MariaDB and Qdrant.
* Handles supersession (replacing old memories) and expiry.
*
* Usage:
* $memory = RememberKnowledge::run($data, 1, 'virgil');
*/
class RememberKnowledge
{
use Action;
public function __construct(
private BrainService $brain,
) {}
/**
* @param array{content: string, type: string, tags?: array, project?: string, confidence?: float, supersedes?: string, expires_in?: int} $data
* @return BrainMemory The created memory
*
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function handle(array $data, int $workspaceId, string $agentId = 'anonymous'): BrainMemory
{
$content = $data['content'] ?? null;
if (! is_string($content) || $content === '') {
throw new \InvalidArgumentException('content is required and must be a non-empty string');
}
if (mb_strlen($content) > 50000) {
throw new \InvalidArgumentException('content must not exceed 50,000 characters');
}
$type = $data['type'] ?? null;
if (! is_string($type) || ! in_array($type, BrainMemory::VALID_TYPES, true)) {
throw new \InvalidArgumentException(
sprintf('type must be one of: %s', implode(', ', BrainMemory::VALID_TYPES))
);
}
$confidence = (float) ($data['confidence'] ?? 0.8);
if ($confidence < 0.0 || $confidence > 1.0) {
throw new \InvalidArgumentException('confidence must be between 0.0 and 1.0');
}
$tags = $data['tags'] ?? null;
if (is_array($tags)) {
foreach ($tags as $tag) {
if (! is_string($tag)) {
throw new \InvalidArgumentException('Each tag must be a string');
}
}
}
$supersedes = $data['supersedes'] ?? null;
if ($supersedes !== null) {
$existing = BrainMemory::where('id', $supersedes)
->where('workspace_id', $workspaceId)
->first();
if (! $existing) {
throw new \InvalidArgumentException("Memory '{$supersedes}' not found in this workspace");
}
}
$expiresIn = isset($data['expires_in']) ? (int) $data['expires_in'] : null;
if ($expiresIn !== null && $expiresIn < 1) {
throw new \InvalidArgumentException('expires_in must be at least 1 hour');
}
return $this->brain->remember([
'workspace_id' => $workspaceId,
'agent_id' => $agentId,
'type' => $type,
'content' => $content,
'tags' => $tags,
'project' => $data['project'] ?? null,
'confidence' => $confidence,
'supersedes_id' => $supersedes,
'expires_at' => $expiresIn ? now()->addHours($expiresIn) : null,
]);
}
}

View file

@ -0,0 +1,40 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Forge;
use Core\Actions\Action;
use Core\Mod\Agentic\Actions\Session\StartSession;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession;
/**
* Assign an agent to a plan and start a session.
*
* Activates the plan if it is still in draft status, then
* delegates to StartSession to create the working session.
*
* Usage:
* $session = AssignAgent::run($plan, 'opus', $workspaceId);
*/
class AssignAgent
{
use Action;
public function handle(AgentPlan $plan, string $agentType, int $workspaceId): AgentSession
{
if ($plan->status !== AgentPlan::STATUS_ACTIVE) {
$plan->activate();
}
return StartSession::run($agentType, $plan->slug, $workspaceId);
}
}

View file

@ -0,0 +1,102 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Forge;
use Core\Actions\Action;
use Core\Mod\Agentic\Actions\Plan\CreatePlan;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Convert a Forgejo work item into an AgentPlan.
*
* Accepts the structured work item array produced by ScanForWork,
* extracts checklist tasks from the issue body, and creates a plan
* with a single phase. Returns an existing plan if one already
* matches the same issue.
*
* Usage:
* $plan = CreatePlanFromIssue::run($workItem, $workspaceId);
*/
class CreatePlanFromIssue
{
use Action;
/**
* @param array{epic_number: int, issue_number: int, issue_title: string, issue_body: string, assignee: string|null, repo_owner: string, repo_name: string, needs_coding: bool, has_pr: bool} $workItem
*/
public function handle(array $workItem, int $workspaceId): AgentPlan
{
$issueNumber = (int) $workItem['issue_number'];
$owner = (string) $workItem['repo_owner'];
$repo = (string) $workItem['repo_name'];
// Check for an existing plan for this issue (not archived)
$existing = AgentPlan::where('status', '!=', AgentPlan::STATUS_ARCHIVED)
->whereJsonContains('metadata->issue_number', $issueNumber)
->whereJsonContains('metadata->repo_owner', $owner)
->whereJsonContains('metadata->repo_name', $repo)
->first();
if ($existing !== null) {
return $existing->load('agentPhases');
}
$tasks = $this->extractTasks((string) $workItem['issue_body']);
$plan = CreatePlan::run([
'title' => (string) $workItem['issue_title'],
'slug' => "forge-{$owner}-{$repo}-{$issueNumber}",
'description' => (string) $workItem['issue_body'],
'phases' => [
[
'name' => "Resolve issue #{$issueNumber}",
'description' => "Complete all tasks for issue #{$issueNumber}",
'tasks' => $tasks,
],
],
], $workspaceId);
$plan->update([
'metadata' => [
'source' => 'forgejo',
'epic_number' => (int) $workItem['epic_number'],
'issue_number' => $issueNumber,
'repo_owner' => $owner,
'repo_name' => $repo,
'assignee' => $workItem['assignee'] ?? null,
],
]);
return $plan->load('agentPhases');
}
/**
* Extract task names from markdown checklist items.
*
* Matches lines like `- [ ] Create picker UI` and returns
* just the task name portion.
*
* @return array<int, string>
*/
private function extractTasks(string $body): array
{
$tasks = [];
if (preg_match_all('/- \[[ xX]\] (.+)/', $body, $matches)) {
foreach ($matches[1] as $taskName) {
$tasks[] = trim($taskName);
}
}
return $tasks;
}
}

View file

@ -0,0 +1,59 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Forge;
use Core\Actions\Action;
use Core\Mod\Agentic\Services\ForgejoService;
/**
* Evaluate and merge a Forgejo pull request when ready.
*
* Checks the PR state, mergeability, and CI status before
* attempting the merge. Returns a result array describing
* the outcome.
*
* Usage:
* $result = ManagePullRequest::run('core', 'app', 10);
*/
class ManagePullRequest
{
use Action;
/**
* @return array{merged: bool, pr_number?: int, reason?: string}
*/
public function handle(string $owner, string $repo, int $prNumber): array
{
$forge = app(ForgejoService::class);
$pr = $forge->getPullRequest($owner, $repo, $prNumber);
if (($pr['state'] ?? '') !== 'open') {
return ['merged' => false, 'reason' => 'not_open'];
}
if (empty($pr['mergeable'])) {
return ['merged' => false, 'reason' => 'conflicts'];
}
$headSha = $pr['head']['sha'] ?? '';
$status = $forge->getCombinedStatus($owner, $repo, $headSha);
if (($status['state'] ?? '') !== 'success') {
return ['merged' => false, 'reason' => 'checks_pending'];
}
$forge->mergePullRequest($owner, $repo, $prNumber);
return ['merged' => true, 'pr_number' => $prNumber];
}
}

View file

@ -0,0 +1,34 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Forge;
use Core\Actions\Action;
use Core\Mod\Agentic\Services\ForgejoService;
/**
* Post a progress comment on a Forgejo issue.
*
* Wraps ForgejoService::createComment() for use as a
* standalone action within the orchestration pipeline.
*
* Usage:
* ReportToIssue::run('core', 'app', 5, 'Phase 1 complete.');
*/
class ReportToIssue
{
use Action;
public function handle(string $owner, string $repo, int $issueNumber, string $message): void
{
app(ForgejoService::class)->createComment($owner, $repo, $issueNumber, $message);
}
}

View file

@ -0,0 +1,145 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Forge;
use Core\Actions\Action;
use Core\Mod\Agentic\Services\ForgejoService;
/**
* Scan Forgejo for epic issues and identify unchecked children that need coding.
*
* Parses epic issue bodies for checklist syntax (`- [ ] #N` / `- [x] #N`),
* cross-references with open pull requests, and returns structured work items
* for any unchecked child issue that has no linked PR.
*
* Usage:
* $workItems = ScanForWork::run('core', 'app');
*/
class ScanForWork
{
use Action;
/**
* Scan a repository for actionable work from epic issues.
*
* @return array<int, array{
* epic_number: int,
* issue_number: int,
* issue_title: string,
* issue_body: string,
* assignee: string|null,
* repo_owner: string,
* repo_name: string,
* needs_coding: bool,
* has_pr: bool,
* }>
*/
public function handle(string $owner, string $repo): array
{
$forge = app(ForgejoService::class);
$epics = $forge->listIssues($owner, $repo, 'open', 'epic');
if ($epics === []) {
return [];
}
$pullRequests = $forge->listPullRequests($owner, $repo, 'all');
$linkedIssues = $this->extractLinkedIssues($pullRequests);
$workItems = [];
foreach ($epics as $epic) {
$checklist = $this->parseChecklist((string) ($epic['body'] ?? ''));
foreach ($checklist as $item) {
if ($item['checked']) {
continue;
}
if (in_array($item['number'], $linkedIssues, true)) {
continue;
}
$child = $forge->getIssue($owner, $repo, $item['number']);
$assignee = null;
if (! empty($child['assignees']) && is_array($child['assignees'])) {
$assignee = $child['assignees'][0]['login'] ?? null;
}
$workItems[] = [
'epic_number' => (int) $epic['number'],
'issue_number' => (int) $child['number'],
'issue_title' => (string) ($child['title'] ?? ''),
'issue_body' => (string) ($child['body'] ?? ''),
'assignee' => $assignee,
'repo_owner' => $owner,
'repo_name' => $repo,
'needs_coding' => true,
'has_pr' => false,
];
}
}
return $workItems;
}
/**
* Parse a checklist body into structured items.
*
* Matches lines like `- [ ] #2` (unchecked) and `- [x] #3` (checked).
*
* @return array<int, array{number: int, checked: bool}>
*/
private function parseChecklist(string $body): array
{
$items = [];
if (preg_match_all('/- \[([ xX])\] #(\d+)/', $body, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$items[] = [
'number' => (int) $match[2],
'checked' => $match[1] !== ' ',
];
}
}
return $items;
}
/**
* Extract issue numbers referenced in PR bodies.
*
* Matches common linking patterns: "Closes #N", "Fixes #N", "Resolves #N",
* and bare "#N" references.
*
* @param array<int, array<string, mixed>> $pullRequests
* @return array<int, int>
*/
private function extractLinkedIssues(array $pullRequests): array
{
$linked = [];
foreach ($pullRequests as $pr) {
$body = (string) ($pr['body'] ?? '');
if (preg_match_all('/#(\d+)/', $body, $matches)) {
foreach ($matches[1] as $number) {
$linked[] = (int) $number;
}
}
}
return array_unique($linked);
}
}

View file

@ -0,0 +1,69 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Phase;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Add a checkpoint note to a phase.
*
* Checkpoints record milestones, decisions, and progress notes
* within a phase's metadata for later review.
*
* Usage:
* $phase = AddCheckpoint::run('deploy-v2', '1', 'Tests passing', 1);
*/
class AddCheckpoint
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $planSlug, string|int $phase, string $note, int $workspaceId, array $context = []): AgentPhase
{
if ($note === '') {
throw new \InvalidArgumentException('note is required');
}
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $planSlug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
$resolved = $this->resolvePhase($plan, $phase);
if (! $resolved) {
throw new \InvalidArgumentException("Phase not found: {$phase}");
}
$resolved->addCheckpoint($note, $context);
return $resolved->fresh();
}
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where('name', $identifier)
->first();
}
}

View file

@ -0,0 +1,66 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Phase;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Get details of a specific phase within a plan.
*
* Resolves the phase by order number or name.
*
* Usage:
* $phase = GetPhase::run('deploy-v2', '1', 1);
* $phase = GetPhase::run('deploy-v2', 'Build', 1);
*/
class GetPhase
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $planSlug, string|int $phase, int $workspaceId): AgentPhase
{
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $planSlug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
$resolved = $this->resolvePhase($plan, $phase);
if (! $resolved) {
throw new \InvalidArgumentException("Phase not found: {$phase}");
}
return $resolved;
}
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -0,0 +1,79 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Phase;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update the status of a phase within a plan.
*
* Optionally adds a checkpoint note when the status changes.
*
* Usage:
* $phase = UpdatePhaseStatus::run('deploy-v2', '1', 'in_progress', 1);
* $phase = UpdatePhaseStatus::run('deploy-v2', 'Build', 'completed', 1, 'All tests pass');
*/
class UpdatePhaseStatus
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $planSlug, string|int $phase, string $status, int $workspaceId, ?string $notes = null): AgentPhase
{
$valid = ['pending', 'in_progress', 'completed', 'blocked', 'skipped'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $planSlug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
$resolved = $this->resolvePhase($plan, $phase);
if (! $resolved) {
throw new \InvalidArgumentException("Phase not found: {$phase}");
}
if ($notes !== null && $notes !== '') {
$resolved->addCheckpoint($notes, ['status_change' => $status]);
}
$resolved->update(['status' => $status]);
return $resolved->fresh();
}
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -0,0 +1,51 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Plan;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Archive a completed or abandoned plan.
*
* Sets the plan status to archived with an optional reason.
* Scoped to workspace for tenant isolation.
*
* Usage:
* $plan = ArchivePlan::run('deploy-v2', 1, 'Superseded by v3');
*/
class ArchivePlan
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $slug, int $workspaceId, ?string $reason = null): AgentPlan
{
if ($slug === '') {
throw new \InvalidArgumentException('slug is required');
}
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$slug}");
}
$plan->archive($reason);
return $plan->fresh();
}
}

View file

@ -0,0 +1,89 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Plan;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Support\Str;
/**
* Create a new work plan with phases and tasks.
*
* Validates input, generates a unique slug, creates the plan
* and any associated phases with their tasks.
*
* Usage:
* $plan = CreatePlan::run([
* 'title' => 'Deploy v2',
* 'phases' => [['name' => 'Build', 'tasks' => ['compile', 'test']]],
* ], 1);
*/
class CreatePlan
{
use Action;
/**
* @param array{title: string, slug?: string, description?: string, context?: array, phases?: array} $data
*
* @throws \InvalidArgumentException
*/
public function handle(array $data, int $workspaceId): AgentPlan
{
$title = $data['title'] ?? null;
if (! is_string($title) || $title === '' || mb_strlen($title) > 255) {
throw new \InvalidArgumentException('title is required and must be a non-empty string (max 255 characters)');
}
$slug = $data['slug'] ?? null;
if ($slug !== null) {
if (! is_string($slug) || mb_strlen($slug) > 255) {
throw new \InvalidArgumentException('slug must be a string (max 255 characters)');
}
} else {
$slug = Str::slug($title).'-'.Str::random(6);
}
if (AgentPlan::where('slug', $slug)->exists()) {
throw new \InvalidArgumentException("Plan with slug '{$slug}' already exists");
}
$plan = AgentPlan::create([
'slug' => $slug,
'title' => $title,
'description' => $data['description'] ?? null,
'status' => AgentPlan::STATUS_DRAFT,
'context' => $data['context'] ?? [],
'workspace_id' => $workspaceId,
]);
if (! empty($data['phases'])) {
foreach ($data['phases'] as $order => $phaseData) {
$tasks = collect($phaseData['tasks'] ?? [])->map(fn ($task) => [
'name' => $task,
'status' => 'pending',
])->all();
AgentPhase::create([
'agent_plan_id' => $plan->id,
'name' => $phaseData['name'] ?? 'Phase '.($order + 1),
'description' => $phaseData['description'] ?? null,
'order' => $order + 1,
'status' => AgentPhase::STATUS_PENDING,
'tasks' => $tasks,
]);
}
}
return $plan->load('agentPhases');
}
}

50
Actions/Plan/GetPlan.php Normal file
View file

@ -0,0 +1,50 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Plan;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Get detailed information about a specific plan.
*
* Returns the plan with all phases, progress, and context data.
* Scoped to workspace for tenant isolation.
*
* Usage:
* $plan = GetPlan::run('deploy-v2', 1);
*/
class GetPlan
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $slug, int $workspaceId): AgentPlan
{
if ($slug === '') {
throw new \InvalidArgumentException('slug is required');
}
$plan = AgentPlan::with('agentPhases')
->forWorkspace($workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$slug}");
}
return $plan;
}
}

View file

@ -0,0 +1,59 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Plan;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Support\Collection;
/**
* List work plans for a workspace with optional filtering.
*
* Returns plans ordered by most recently updated, with progress data.
*
* Usage:
* $plans = ListPlans::run(1);
* $plans = ListPlans::run(1, 'active');
*/
class ListPlans
{
use Action;
/**
* @return Collection<int, AgentPlan>
*/
public function handle(int $workspaceId, ?string $status = null, bool $includeArchived = false): Collection
{
if ($status !== null) {
$valid = ['draft', 'active', 'paused', 'completed', 'archived'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
}
$query = AgentPlan::with('agentPhases')
->forWorkspace($workspaceId)
->orderBy('updated_at', 'desc');
if (! $includeArchived && $status !== 'archived') {
$query->notArchived();
}
if ($status !== null) {
$query->where('status', $status);
}
return $query->get();
}
}

View file

@ -0,0 +1,54 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Plan;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update the status of a plan.
*
* Validates the transition and updates the plan status.
* Scoped to workspace for tenant isolation.
*
* Usage:
* $plan = UpdatePlanStatus::run('deploy-v2', 'active', 1);
*/
class UpdatePlanStatus
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $slug, string $status, int $workspaceId): AgentPlan
{
$valid = ['draft', 'active', 'paused', 'completed'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$slug}");
}
$plan->update(['status' => $status]);
return $plan->fresh();
}
}

View file

@ -0,0 +1,56 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Session;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* Continue from a previous session (multi-agent handoff).
*
* Creates a new session with context inherited from the previous one
* and marks the previous session as handed off.
*
* Usage:
* $session = ContinueSession::run('ses_abc123', 'opus');
*/
class ContinueSession
{
use Action;
public function __construct(
private AgentSessionService $sessionService,
) {}
/**
* @throws \InvalidArgumentException
*/
public function handle(string $previousSessionId, string $agentType): AgentSession
{
if ($previousSessionId === '') {
throw new \InvalidArgumentException('previous_session_id is required');
}
if ($agentType === '') {
throw new \InvalidArgumentException('agent_type is required');
}
$session = $this->sessionService->continueFrom($previousSessionId, $agentType);
if (! $session) {
throw new \InvalidArgumentException("Previous session not found: {$previousSessionId}");
}
return $session;
}
}

View file

@ -0,0 +1,56 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Session;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* End an agent session with a final status and optional summary.
*
* Usage:
* $session = EndSession::run('ses_abc123', 'completed', 'All phases done');
*/
class EndSession
{
use Action;
public function __construct(
private AgentSessionService $sessionService,
) {}
/**
* @throws \InvalidArgumentException
*/
public function handle(string $sessionId, string $status, ?string $summary = null): AgentSession
{
if ($sessionId === '') {
throw new \InvalidArgumentException('session_id is required');
}
$valid = ['completed', 'handed_off', 'paused', 'failed'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
$session = $this->sessionService->end($sessionId, $status, $summary);
if (! $session) {
throw new \InvalidArgumentException("Session not found: {$sessionId}");
}
return $session;
}
}

View file

@ -0,0 +1,49 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Session;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentSession;
/**
* Get detailed information about a specific session.
*
* Returns the session with plan context, scoped to workspace.
*
* Usage:
* $session = GetSession::run('ses_abc123', 1);
*/
class GetSession
{
use Action;
/**
* @throws \InvalidArgumentException
*/
public function handle(string $sessionId, int $workspaceId): AgentSession
{
if ($sessionId === '') {
throw new \InvalidArgumentException('session_id is required');
}
$session = AgentSession::with('plan')
->where('session_id', $sessionId)
->where('workspace_id', $workspaceId)
->first();
if (! $session) {
throw new \InvalidArgumentException("Session not found: {$sessionId}");
}
return $session;
}
}

View file

@ -0,0 +1,68 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Session;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Services\AgentSessionService;
use Illuminate\Support\Collection;
/**
* List sessions for a workspace, with optional filtering.
*
* Usage:
* $sessions = ListSessions::run(1);
* $sessions = ListSessions::run(1, 'active', 'deploy-v2', 20);
*/
class ListSessions
{
use Action;
public function __construct(
private AgentSessionService $sessionService,
) {}
/**
* @return Collection<int, AgentSession>
*/
public function handle(int $workspaceId, ?string $status = null, ?string $planSlug = null, ?int $limit = null): Collection
{
if ($status !== null) {
$valid = ['active', 'paused', 'completed', 'failed'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
}
// Active sessions use the optimised service method
if ($status === 'active' || $status === null) {
return $this->sessionService->getActiveSessions($workspaceId);
}
$query = AgentSession::query()
->where('workspace_id', $workspaceId)
->where('status', $status)
->orderBy('last_active_at', 'desc');
if ($planSlug !== null) {
$query->whereHas('plan', fn ($q) => $q->where('slug', $planSlug));
}
if ($limit !== null && $limit > 0) {
$query->limit(min($limit, 1000));
}
return $query->get();
}
}

View file

@ -0,0 +1,56 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Session;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* Start a new agent session, optionally linked to a plan.
*
* Creates an active session and caches it for fast lookup.
* Workspace can be provided directly or inferred from the plan.
*
* Usage:
* $session = StartSession::run('opus', null, 1);
* $session = StartSession::run('sonnet', 'deploy-v2', 1, ['goal' => 'testing']);
*/
class StartSession
{
use Action;
public function __construct(
private AgentSessionService $sessionService,
) {}
/**
* @throws \InvalidArgumentException
*/
public function handle(string $agentType, ?string $planSlug, int $workspaceId, array $context = []): AgentSession
{
if ($agentType === '') {
throw new \InvalidArgumentException('agent_type is required');
}
$plan = null;
if ($planSlug !== null && $planSlug !== '') {
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
}
return $this->sessionService->start($agentType, $plan, $workspaceId, $context);
}
}

View file

@ -0,0 +1,90 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Task;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Toggle a task's completion status (pending <-> completed).
*
* Quick convenience method for marking tasks done or undone.
*
* Usage:
* $result = ToggleTask::run('deploy-v2', '1', 0, 1);
*/
class ToggleTask
{
use Action;
/**
* @return array{task: array, plan_progress: array}
*
* @throws \InvalidArgumentException
*/
public function handle(string $planSlug, string|int $phase, int $taskIndex, int $workspaceId): array
{
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $planSlug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
$resolved = $this->resolvePhase($plan, $phase);
if (! $resolved) {
throw new \InvalidArgumentException("Phase not found: {$phase}");
}
$tasks = $resolved->tasks ?? [];
if (! isset($tasks[$taskIndex])) {
throw new \InvalidArgumentException("Task not found at index: {$taskIndex}");
}
$currentStatus = is_string($tasks[$taskIndex])
? 'pending'
: ($tasks[$taskIndex]['status'] ?? 'pending');
$newStatus = $currentStatus === 'completed' ? 'pending' : 'completed';
if (is_string($tasks[$taskIndex])) {
$tasks[$taskIndex] = [
'name' => $tasks[$taskIndex],
'status' => $newStatus,
];
} else {
$tasks[$taskIndex]['status'] = $newStatus;
}
$resolved->update(['tasks' => $tasks]);
return [
'task' => $tasks[$taskIndex],
'plan_progress' => $plan->fresh()->getProgress(),
];
}
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where('name', $identifier)
->first();
}
}

101
Actions/Task/UpdateTask.php Normal file
View file

@ -0,0 +1,101 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Actions\Task;
use Core\Actions\Action;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update a task's status or notes within a phase.
*
* Tasks are stored as a JSON array on the phase model.
* Handles legacy string-format tasks by normalising to {name, status}.
*
* Usage:
* $task = UpdateTask::run('deploy-v2', '1', 0, 1, 'in_progress', 'Started build');
*/
class UpdateTask
{
use Action;
/**
* @return array{task: array, plan_progress: array}
*
* @throws \InvalidArgumentException
*/
public function handle(string $planSlug, string|int $phase, int $taskIndex, int $workspaceId, ?string $status = null, ?string $notes = null): array
{
if ($status !== null) {
$valid = ['pending', 'in_progress', 'completed', 'blocked', 'skipped'];
if (! in_array($status, $valid, true)) {
throw new \InvalidArgumentException(
sprintf('status must be one of: %s', implode(', ', $valid))
);
}
}
$plan = AgentPlan::forWorkspace($workspaceId)
->where('slug', $planSlug)
->first();
if (! $plan) {
throw new \InvalidArgumentException("Plan not found: {$planSlug}");
}
$resolved = $this->resolvePhase($plan, $phase);
if (! $resolved) {
throw new \InvalidArgumentException("Phase not found: {$phase}");
}
$tasks = $resolved->tasks ?? [];
if (! isset($tasks[$taskIndex])) {
throw new \InvalidArgumentException("Task not found at index: {$taskIndex}");
}
// Normalise legacy string-format tasks
if (is_string($tasks[$taskIndex])) {
$tasks[$taskIndex] = ['name' => $tasks[$taskIndex], 'status' => 'pending'];
}
if ($status !== null) {
$tasks[$taskIndex]['status'] = $status;
}
if ($notes !== null) {
$tasks[$taskIndex]['notes'] = $notes;
}
$resolved->update(['tasks' => $tasks]);
return [
'task' => $tasks[$taskIndex],
'plan_progress' => $plan->fresh()->getProgress(),
];
}
private function resolvePhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

110
Boot.php
View file

@ -5,9 +5,11 @@ declare(strict_types=1);
namespace Core\Mod\Agentic;
use Core\Events\AdminPanelBooting;
use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
use Core\Events\McpToolsRegistering;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
@ -23,6 +25,7 @@ class Boot extends ServiceProvider
*/
public static array $listens = [
AdminPanelBooting::class => 'onAdminPanel',
ApiRoutesRegistering::class => 'onApiRoutes',
ConsoleBooting::class => 'onConsole',
McpToolsRegistering::class => 'onMcpTools',
];
@ -32,6 +35,25 @@ class Boot extends ServiceProvider
$this->loadMigrationsFrom(__DIR__.'/Migrations');
$this->loadTranslationsFrom(__DIR__.'/Lang', 'agentic');
$this->configureRateLimiting();
$this->scheduleCommands();
}
/**
* Register all scheduled commands.
*/
protected function scheduleCommands(): void
{
$this->app->booted(function (): void {
$schedule = $this->app->make(Schedule::class);
$schedule->command('agentic:plan-cleanup')->daily();
// Forgejo pipeline — only active when a token is configured
if (config('agentic.forge_token') !== '') {
$schedule->command('agentic:scan')->everyFiveMinutes();
$schedule->command('agentic:dispatch')->everyTwoMinutes();
$schedule->command('agentic:pr-manage')->everyFiveMinutes();
}
});
}
/**
@ -53,13 +75,71 @@ class Boot extends ServiceProvider
'mcp'
);
$this->mergeConfigFrom(
__DIR__.'/agentic.php',
'agentic'
);
// Register the dedicated brain database connection.
// Falls back to the app's default DB when no BRAIN_DB_* env vars are set.
$brainDb = config('mcp.brain.database');
if (is_array($brainDb) && ! empty($brainDb['host'])) {
config(['database.connections.brain' => $brainDb]);
}
$this->app->singleton(\Core\Mod\Agentic\Services\AgenticManager::class);
$this->app->singleton(\Core\Mod\Agentic\Services\AgentToolRegistry::class);
$this->app->singleton(\Core\Mod\Agentic\Services\ForgejoService::class, function ($app) {
return new \Core\Mod\Agentic\Services\ForgejoService(
baseUrl: (string) config('agentic.forge_url', 'https://forge.lthn.ai'),
token: (string) config('agentic.forge_token', ''),
);
});
$this->app->singleton(\Core\Mod\Agentic\Services\BrainService::class, function ($app) {
$ollamaUrl = config('mcp.brain.ollama_url', 'http://localhost:11434');
$qdrantUrl = config('mcp.brain.qdrant_url', 'http://localhost:6334');
// Skip TLS verification for non-public TLDs (self-signed certs behind Traefik)
$hasLocalTld = static fn (string $url): bool => (bool) preg_match(
'/\.(lan|lab|local|test)(?:[:\/]|$)/',
parse_url($url, PHP_URL_HOST) ?? ''
);
$verifySsl = ! ($hasLocalTld($ollamaUrl) || $hasLocalTld($qdrantUrl));
return new \Core\Mod\Agentic\Services\BrainService(
ollamaUrl: $ollamaUrl,
qdrantUrl: $qdrantUrl,
collection: config('mcp.brain.collection', 'openbrain'),
embeddingModel: config('mcp.brain.embedding_model', 'nomic-embed-text'),
verifySsl: $verifySsl,
);
});
}
// -------------------------------------------------------------------------
// Event-driven handlers (for lazy loading once event system is integrated)
// -------------------------------------------------------------------------
/**
* Handle API routes registration event.
*
* Registers REST API endpoints for go-agentic Client consumption.
* Routes at /v1/* Go client uses BaseURL + "/v1/plans" directly.
*/
public function onApiRoutes(ApiRoutesRegistering $event): void
{
// Register agent API auth middleware alias
$event->middleware('agent.auth', Middleware\AgentApiAuth::class);
// Scoped via event — only loaded for API requests
if (file_exists(__DIR__.'/Routes/api.php')) {
$event->routes(fn () => require __DIR__.'/Routes/api.php');
}
}
/**
* Handle admin panel booting event.
*/
@ -87,14 +167,32 @@ class Boot extends ServiceProvider
// in the existing boot() method until we migrate to pure event-driven nav.
}
/**
* Handle API routes registration event.
*/
public function onApiRoutes(ApiRoutesRegistering $event): void
{
$event->routes(fn () => require __DIR__.'/Routes/api.php');
}
/**
* Handle console booting event.
*/
public function onConsole(ConsoleBooting $event): void
{
// Register middleware alias for CLI context (artisan route:list)
$event->middleware('agent.auth', Middleware\AgentApiAuth::class);
$event->command(Console\Commands\TaskCommand::class);
$event->command(Console\Commands\PlanCommand::class);
$event->command(Console\Commands\GenerateCommand::class);
$event->command(Console\Commands\PlanRetentionCommand::class);
$event->command(Console\Commands\BrainSeedMemoryCommand::class);
$event->command(Console\Commands\BrainIngestCommand::class);
$event->command(Console\Commands\ScanCommand::class);
$event->command(Console\Commands\DispatchCommand::class);
$event->command(Console\Commands\PrManageCommand::class);
$event->command(Console\Commands\PrepWorkspaceCommand::class);
}
/**
@ -102,11 +200,17 @@ class Boot extends ServiceProvider
*
* Note: Agent tools (plan_create, session_start, etc.) are implemented in
* the Mcp module at Mod\Mcp\Tools\Agent\* and registered via AgentToolRegistry.
* This method is available for Agentic-specific MCP tools if needed in future.
* Brain tools are registered here as they belong to the Agentic module.
*/
public function onMcpTools(McpToolsRegistering $event): void
{
// Agent tools are registered in Mcp module via AgentToolRegistry
// No additional MCP tools needed from Agentic module at this time
$registry = $this->app->make(Services\AgentToolRegistry::class);
$registry->registerMany([
new Mcp\Tools\Agent\Brain\BrainRemember(),
new Mcp\Tools\Agent\Brain\BrainRecall(),
new Mcp\Tools\Agent\Brain\BrainForget(),
new Mcp\Tools\Agent\Brain\BrainList(),
]);
}
}

View file

@ -0,0 +1,699 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Finder\Finder;
/**
* Comprehensive knowledge ingestion into OpenBrain.
*
* Discovers markdown files across multiple source types and ingests
* them as sectioned memories with embedded vectors. Designed to
* archive scattered knowledge before filesystem cleanup.
*/
class BrainIngestCommand extends Command
{
protected $signature = 'brain:ingest
{--workspace= : Workspace ID to import into (required)}
{--agent=virgil : Agent ID to attribute memories to}
{--source=all : Source type: memory, plans, claude-md, tasks, docs, wiki, all}
{--code-path= : Root code directory (default: ~/Code)}
{--dry-run : Preview what would be imported without storing}
{--fresh : Clear the Qdrant collection before ingesting}';
protected $description = 'Ingest markdown knowledge from across the filesystem into OpenBrain';
/** @var array<string, int> */
private array $stats = ['imported' => 0, 'skipped' => 0, 'errors' => 0];
public function handle(BrainService $brain): int
{
$workspaceId = $this->option('workspace');
if (! $workspaceId) {
$this->error('--workspace is required.');
return self::FAILURE;
}
$source = $this->option('source') ?? 'all';
$codePath = $this->option('code-path') ?? $this->expandHome('~/Code');
$isDryRun = (bool) $this->option('dry-run');
$sources = $source === 'all'
? ['memory', 'plans', 'claude-md', 'tasks', 'docs', 'wiki']
: [strtolower($source)];
// Separate file-based and API-based sources
$fileSources = array_filter($sources, fn ($s) => $s !== 'wiki');
$apiSources = array_filter($sources, fn ($s) => $s === 'wiki');
// Gather file-based sources
$filesBySource = [];
foreach ($fileSources as $src) {
$files = match ($src) {
'memory' => $this->discoverMemoryFiles(),
'plans' => $this->discoverPlanFiles($codePath),
'claude-md' => $this->discoverClaudeMdFiles($codePath),
'tasks' => $this->discoverTaskFiles(),
'docs' => $this->discoverDocFiles($codePath),
default => [],
};
$filesBySource[$src] = $files;
$this->info(sprintf(' [%s] %d file(s)', $src, count($files)));
}
// Discover wiki pages from Forge API
$wikiPages = [];
if (in_array('wiki', $apiSources, true)) {
$wikiPages = $this->discoverWikiPages();
$this->info(sprintf(' [wiki] %d page(s) across %d repo(s)', count($wikiPages), count(array_unique(array_column($wikiPages, 'repo')))));
}
$totalFiles = array_sum(array_map('count', $filesBySource)) + count($wikiPages);
$this->newLine();
$this->info("Total: {$totalFiles} item(s) to process.");
if ($totalFiles === 0) {
return self::SUCCESS;
}
if (! $isDryRun) {
if ($this->option('fresh')) {
$this->warn('Clearing existing collection...');
$this->clearCollection($brain);
}
$brain->ensureCollection();
}
foreach ($filesBySource as $src => $files) {
$this->newLine();
$this->comment("--- {$src} ---");
foreach ($files as $file) {
$this->processFile($brain, $file, $src, (int) $workspaceId, $this->option('agent') ?? 'virgil', $isDryRun);
}
}
if (! empty($wikiPages)) {
$this->newLine();
$this->comment('--- wiki ---');
$this->processWikiPages($brain, $wikiPages, (int) $workspaceId, $this->option('agent') ?? 'virgil', $isDryRun);
}
$this->newLine();
$prefix = $isDryRun ? '[DRY RUN] ' : '';
$this->info("{$prefix}Done. Imported: {$this->stats['imported']}, Skipped: {$this->stats['skipped']}, Errors: {$this->stats['errors']}");
return self::SUCCESS;
}
/**
* Process a single file into sectioned memories.
*/
private function processFile(BrainService $brain, string $file, string $source, int $workspaceId, string $agentId, bool $isDryRun): void
{
$sections = $this->parseMarkdownSections($file);
$filename = basename($file, '.md');
$project = $this->extractProject($file, $source);
if (empty($sections)) {
$this->stats['skipped']++;
return;
}
foreach ($sections as $section) {
if (trim($section['content']) === '') {
$this->stats['skipped']++;
continue;
}
$type = $this->inferType($section['heading'], $section['content'], $source);
$tags = $this->buildTags($section['heading'], $filename, $source, $project);
if ($isDryRun) {
$this->line(sprintf(
' %s :: %s (%s) — %d chars [%s]',
$filename,
$section['heading'],
$type,
strlen($section['content']),
implode(', ', $tags),
));
$this->stats['imported']++;
continue;
}
try {
$text = $section['heading']."\n\n".$section['content'];
// embeddinggemma has a 2048-token context (~4K chars).
// Truncate oversized sections to avoid Ollama 500 errors.
if (strlen($text) > 3800) {
$text = mb_substr($text, 0, 3800).'…';
}
$brain->remember([
'workspace_id' => $workspaceId,
'agent_id' => $agentId,
'type' => $type,
'content' => $text,
'tags' => $tags,
'project' => $project,
'confidence' => $this->confidenceForSource($source),
]);
$this->stats['imported']++;
} catch (\Throwable $e) {
$this->warn(" Error: {$filename} :: {$section['heading']}{$e->getMessage()}");
$this->stats['errors']++;
}
}
}
// -------------------------------------------------------------------------
// File discovery
// -------------------------------------------------------------------------
/** @return array<string> */
private function discoverMemoryFiles(): array
{
$pattern = $this->expandHome('~/.claude/projects/*/memory/*.md');
return glob($pattern) ?: [];
}
/** @return array<string> */
private function discoverPlanFiles(string $codePath): array
{
$files = [];
// ~/.claude/plans (superpowers plans)
$claudePlans = $this->expandHome('~/.claude/plans');
if (is_dir($claudePlans)) {
$files = array_merge($files, $this->findMd($claudePlans));
}
// docs/plans across all repos in ~/Code
if (is_dir($codePath)) {
$finder = Finder::create()
->files()
->name('*.md')
->in($codePath)
->path('/docs\/plans\//')
->notPath('node_modules')
->notPath('vendor')
->sortByName();
foreach ($finder as $file) {
$files[] = $file->getRealPath();
}
}
return $files;
}
/** @return array<string> */
private function discoverClaudeMdFiles(string $codePath): array
{
if (! is_dir($codePath)) {
return [];
}
$finder = Finder::create()
->files()
->name('CLAUDE.md')
->in($codePath)
->depth('< 4')
->notPath('node_modules')
->notPath('vendor')
->notPath('.claude')
->sortByName();
$files = [];
foreach ($finder as $file) {
$files[] = $file->getRealPath();
}
return $files;
}
/** @return array<string> */
private function discoverTaskFiles(): array
{
$tasksDir = $this->expandHome('~/Code/host-uk/core/tasks');
if (! is_dir($tasksDir)) {
return [];
}
$finder = Finder::create()
->files()
->name('*.md')
->in($tasksDir)
->notPath('recovered-hostuk')
->notPath('recovered-root')
->sortByName();
$files = [];
foreach ($finder as $file) {
$files[] = $file->getRealPath();
}
return $files;
}
/** @return array<string> */
private function discoverDocFiles(string $codePath): array
{
$files = [];
// CorePHP framework docs (build/php + packages)
$docRoots = [
$codePath.'/host-uk/core-php/docs/build/php',
$codePath.'/host-uk/core-php/docs/packages',
];
foreach ($docRoots as $root) {
if (! is_dir($root)) {
continue;
}
$finder = Finder::create()
->files()
->name('*.md')
->in($root)
->sortByName();
foreach ($finder as $file) {
$files[] = $file->getRealPath();
}
}
return $files;
}
// -------------------------------------------------------------------------
// Wiki (Forge API)
// -------------------------------------------------------------------------
/**
* Discover wiki pages from all repos in the Forge org.
*
* Returns flat array of ['repo' => name, 'title' => title, 'content' => markdown].
*
* @return array<array{repo: string, title: string, content: string}>
*/
private function discoverWikiPages(): array
{
$baseUrl = config('upstream.gitea.url', 'https://forge.lthn.ai');
$token = config('upstream.gitea.token');
$org = config('upstream.gitea.org', 'core');
if (! $token) {
$this->warn('No Forge token — skipping wiki source.');
return [];
}
// Fetch all repos in org
$repos = [];
$page = 1;
do {
$response = Http::withHeaders(['Authorization' => 'token ' . $token])
->timeout(15)
->get("{$baseUrl}/api/v1/orgs/{$org}/repos", ['page' => $page, 'limit' => 50]);
if (! $response->successful()) {
$this->warn('Failed to fetch repos: ' . $response->status());
break;
}
$batch = $response->json();
if (empty($batch)) {
break;
}
foreach ($batch as $r) {
$repos[] = $r['name'];
}
$page++;
} while (count($batch) === 50);
// Fetch wiki pages for each repo
$pages = [];
foreach ($repos as $repo) {
$response = Http::withHeaders(['Authorization' => 'token ' . $token])
->timeout(10)
->get("{$baseUrl}/api/v1/repos/{$org}/{$repo}/wiki/pages");
if (! $response->successful() || $response->status() === 404) {
continue;
}
$wikiList = $response->json();
if (empty($wikiList)) {
continue;
}
foreach ($wikiList as $wiki) {
$title = $wiki['title'] ?? 'Untitled';
// Fetch full page content
$pageResponse = Http::withHeaders(['Authorization' => 'token ' . $token])
->timeout(10)
->get("{$baseUrl}/api/v1/repos/{$org}/{$repo}/wiki/page/{$title}");
if (! $pageResponse->successful()) {
continue;
}
$content = $pageResponse->json('content_base64');
if ($content) {
$content = base64_decode($content, true) ?: '';
} else {
$content = '';
}
if (trim($content) === '') {
continue;
}
$pages[] = [
'repo' => $repo,
'title' => $title,
'content' => $content,
];
}
}
return $pages;
}
/**
* Process wiki pages into contextual memories.
*
* Each page is tagged with its repo and language, typed as service
* documentation so the PHP orchestrator can reason about Go services.
*
* @param array<array{repo: string, title: string, content: string}> $pages
*/
private function processWikiPages(BrainService $brain, array $pages, int $workspaceId, string $agentId, bool $isDryRun): void
{
foreach ($pages as $page) {
$sections = $this->parseMarkdownFromString($page['content'], $page['title']);
$repo = $page['repo'];
// Detect language from repo name
$lang = str_starts_with($repo, 'php-') ? 'php' : (str_starts_with($repo, 'go-') || $repo === 'go' ? 'go' : 'mixed');
foreach ($sections as $section) {
if (trim($section['content']) === '') {
$this->stats['skipped']++;
continue;
}
$tags = [
'source:wiki',
'repo:' . $repo,
'lang:' . $lang,
str_replace(['-', '_'], ' ', $page['title']),
];
if ($isDryRun) {
$this->line(sprintf(
' %s/%s :: %s — %d chars [%s]',
$repo,
$page['title'],
$section['heading'],
strlen($section['content']),
implode(', ', $tags),
));
$this->stats['imported']++;
continue;
}
try {
// Prefix with repo context so embeddings understand the service
$text = "[{$repo}] {$section['heading']}\n\n{$section['content']}";
if (strlen($text) > 3800) {
$text = mb_substr($text, 0, 3800) . '…';
}
$brain->remember([
'workspace_id' => $workspaceId,
'agent_id' => $agentId,
'type' => 'service',
'content' => $text,
'tags' => $tags,
'project' => $repo,
'confidence' => 0.8,
]);
$this->stats['imported']++;
} catch (\Throwable $e) {
$this->warn(' Error: ' . $repo . '/' . $page['title'] . ' :: ' . $section['heading'] . ' — ' . $e->getMessage());
$this->stats['errors']++;
}
}
}
}
/**
* Parse markdown sections from a string (not a file).
*
* @return array<array{heading: string, content: string}>
*/
private function parseMarkdownFromString(string $content, string $fallbackHeading): array
{
if (trim($content) === '') {
return [];
}
$sections = [];
$lines = explode("\n", $content);
$currentHeading = '';
$currentContent = [];
foreach ($lines as $line) {
if (preg_match('/^#{1,3}\s+(.+)$/', $line, $matches)) {
if ($currentHeading !== '' && ! empty($currentContent)) {
$text = trim(implode("\n", $currentContent));
if ($text !== '') {
$sections[] = ['heading' => $currentHeading, 'content' => $text];
}
}
$currentHeading = trim($matches[1]);
$currentContent = [];
} else {
$currentContent[] = $line;
}
}
if ($currentHeading !== '' && ! empty($currentContent)) {
$text = trim(implode("\n", $currentContent));
if ($text !== '') {
$sections[] = ['heading' => $currentHeading, 'content' => $text];
}
}
if (empty($sections) && trim($content) !== '') {
$sections[] = ['heading' => $fallbackHeading, 'content' => trim($content)];
}
return $sections;
}
/** @return array<string> */
private function findMd(string $dir): array
{
$files = [];
foreach (glob("{$dir}/*.md") ?: [] as $f) {
$files[] = $f;
}
// Include subdirectories (e.g. completed/)
foreach (glob("{$dir}/*/*.md") ?: [] as $f) {
$files[] = $f;
}
return $files;
}
// -------------------------------------------------------------------------
// Parsing
// -------------------------------------------------------------------------
/** @return array<array{heading: string, content: string}> */
private function parseMarkdownSections(string $filePath): array
{
$content = file_get_contents($filePath);
if ($content === false || trim($content) === '') {
return [];
}
$sections = [];
$lines = explode("\n", $content);
$currentHeading = '';
$currentContent = [];
foreach ($lines as $line) {
if (preg_match('/^#{1,3}\s+(.+)$/', $line, $matches)) {
if ($currentHeading !== '' && ! empty($currentContent)) {
$text = trim(implode("\n", $currentContent));
if ($text !== '') {
$sections[] = ['heading' => $currentHeading, 'content' => $text];
}
}
$currentHeading = trim($matches[1]);
$currentContent = [];
} else {
$currentContent[] = $line;
}
}
// Flush last section
if ($currentHeading !== '' && ! empty($currentContent)) {
$text = trim(implode("\n", $currentContent));
if ($text !== '') {
$sections[] = ['heading' => $currentHeading, 'content' => $text];
}
}
// If no headings found, treat entire file as one section
if (empty($sections) && trim($content) !== '') {
$sections[] = [
'heading' => basename($filePath, '.md'),
'content' => trim($content),
];
}
return $sections;
}
// -------------------------------------------------------------------------
// Metadata
// -------------------------------------------------------------------------
private function extractProject(string $filePath, string $source): ?string
{
// Memory files: ~/.claude/projects/-Users-snider-Code-{project}/memory/
if (preg_match('/projects\/[^\/]*-([^-\/]+)\/memory\//', $filePath, $m)) {
return $m[1];
}
// Code repos: ~/Code/{project}/ or ~/Code/host-uk/{project}/
if (preg_match('#/Code/host-uk/([^/]+)/#', $filePath, $m)) {
return $m[1];
}
if (preg_match('#/Code/([^/]+)/#', $filePath, $m)) {
return $m[1];
}
return null;
}
private function inferType(string $heading, string $content, string $source): string
{
// Source-specific defaults
if ($source === 'plans') {
return 'plan';
}
if ($source === 'claude-md') {
return 'convention';
}
if ($source === 'docs') {
return 'documentation';
}
$lower = strtolower($heading.' '.$content);
$patterns = [
'architecture' => ['architecture', 'stack', 'infrastructure', 'layer', 'service mesh'],
'convention' => ['convention', 'standard', 'naming', 'pattern', 'rule', 'coding'],
'decision' => ['decision', 'chose', 'strategy', 'approach', 'domain'],
'bug' => ['bug', 'fix', 'broken', 'error', 'issue', 'lesson'],
'plan' => ['plan', 'todo', 'roadmap', 'milestone', 'phase', 'task'],
'research' => ['research', 'finding', 'discovery', 'analysis', 'rfc'],
];
foreach ($patterns as $type => $keywords) {
foreach ($keywords as $keyword) {
if (str_contains($lower, $keyword)) {
return $type;
}
}
}
return 'observation';
}
/** @return array<string> */
private function buildTags(string $heading, string $filename, string $source, ?string $project): array
{
$tags = ["source:{$source}"];
if ($project) {
$tags[] = "project:{$project}";
}
if ($filename !== 'MEMORY' && $filename !== 'CLAUDE') {
$tags[] = str_replace(['-', '_'], ' ', $filename);
}
return $tags;
}
private function confidenceForSource(string $source): float
{
return match ($source) {
'claude-md' => 0.9,
'docs' => 0.85,
'memory' => 0.8,
'plans' => 0.6,
'tasks' => 0.5,
default => 0.5,
};
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private function clearCollection(BrainService $brain): void
{
$reflection = new \ReflectionClass($brain);
$prop = $reflection->getProperty('qdrantUrl');
$qdrantUrl = $prop->getValue($brain);
$prop = $reflection->getProperty('collection');
$collection = $prop->getValue($brain);
// Clear Qdrant collection.
\Illuminate\Support\Facades\Http::withoutVerifying()
->timeout(10)
->delete("{$qdrantUrl}/collections/{$collection}");
// Truncate the DB table so rows stay in sync with Qdrant.
\Core\Mod\Agentic\Models\BrainMemory::query()->forceDelete();
}
private function expandHome(string $path): string
{
if (str_starts_with($path, '~/')) {
$home = getenv('HOME') ?: ('/Users/'.get_current_user());
return $home.substr($path, 1);
}
return $path;
}
}

View file

@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Console\Command;
/**
* Import MEMORY.md files from Claude Code project memory directories
* into the OpenBrain knowledge store.
*
* Scans Claude Code project memory directories (~/.claude/projects)
* for MEMORY.md and topic-specific markdown files, parses them into
* individual memories, and stores each via BrainService::remember().
*/
class BrainSeedMemoryCommand extends Command
{
protected $signature = 'brain:seed-memory
{--workspace= : Workspace ID to import into (required)}
{--agent=virgil : Agent ID to attribute memories to}
{--path= : Override scan path (default: ~/.claude/projects/*/memory/)}
{--dry-run : Preview what would be imported without storing}';
protected $description = 'Import MEMORY.md files from Claude Code project memory into OpenBrain';
public function handle(BrainService $brain): int
{
$workspaceId = $this->option('workspace');
if (! $workspaceId) {
$this->error('--workspace is required. Pass the workspace ID to import memories into.');
return self::FAILURE;
}
$agentId = $this->option('agent') ?? 'virgil';
$isDryRun = (bool) $this->option('dry-run');
$scanPath = $this->option('path')
?? $this->expandHome('~/.claude/projects/*/memory/');
$files = glob($scanPath.'*.md');
if (empty($files)) {
$this->info("No markdown files found in: {$scanPath}");
return self::SUCCESS;
}
$this->info(sprintf('Found %d markdown file(s) to process.', count($files)));
if (! $isDryRun) {
$brain->ensureCollection();
}
$imported = 0;
$skipped = 0;
foreach ($files as $file) {
$filename = basename($file, '.md');
$project = $this->extractProject($file);
$sections = $this->parseMarkdownSections($file);
if (empty($sections)) {
$this->line(" Skipped {$filename} (no sections found)");
$skipped++;
continue;
}
foreach ($sections as $section) {
$type = $this->inferType($section['heading'], $section['content']);
if ($isDryRun) {
$this->line(sprintf(
' [DRY RUN] %s :: %s (%s) — %d chars',
$filename,
$section['heading'],
$type,
strlen($section['content']),
));
$imported++;
continue;
}
try {
$brain->remember([
'workspace_id' => (int) $workspaceId,
'agent_id' => $agentId,
'type' => $type,
'content' => $section['heading']."\n\n".$section['content'],
'tags' => $this->extractTags($section['heading'], $filename),
'project' => $project,
'confidence' => 0.7,
]);
$imported++;
} catch (\Throwable $e) {
$this->warn(" Failed to import '{$section['heading']}': {$e->getMessage()}");
$skipped++;
}
}
}
$prefix = $isDryRun ? '[DRY RUN] ' : '';
$this->info("{$prefix}Imported {$imported} memories, skipped {$skipped}.");
return self::SUCCESS;
}
/**
* Parse a markdown file into sections based on ## headings.
*
* @return array<array{heading: string, content: string}>
*/
private function parseMarkdownSections(string $filePath): array
{
$content = file_get_contents($filePath);
if ($content === false || trim($content) === '') {
return [];
}
$sections = [];
$lines = explode("\n", $content);
$currentHeading = '';
$currentContent = [];
foreach ($lines as $line) {
if (preg_match('/^#{1,3}\s+(.+)$/', $line, $matches)) {
if ($currentHeading !== '' && ! empty($currentContent)) {
$sections[] = [
'heading' => $currentHeading,
'content' => trim(implode("\n", $currentContent)),
];
}
$currentHeading = trim($matches[1]);
$currentContent = [];
} else {
$currentContent[] = $line;
}
}
// Flush last section
if ($currentHeading !== '' && ! empty($currentContent)) {
$text = trim(implode("\n", $currentContent));
if ($text !== '') {
$sections[] = [
'heading' => $currentHeading,
'content' => $text,
];
}
}
return $sections;
}
/**
* Extract a project name from the file path.
*
* Paths like ~/.claude/projects/-Users-snider-Code-eaas/memory/MEMORY.md
* yield "eaas".
*/
private function extractProject(string $filePath): ?string
{
if (preg_match('/projects\/[^\/]*-([^-\/]+)\/memory\//', $filePath, $matches)) {
return $matches[1];
}
return null;
}
/**
* Infer the memory type from the heading and content.
*/
private function inferType(string $heading, string $content): string
{
$lower = strtolower($heading.' '.$content);
$patterns = [
'architecture' => ['architecture', 'stack', 'infrastructure', 'layer', 'service mesh'],
'convention' => ['convention', 'standard', 'naming', 'pattern', 'rule', 'coding'],
'decision' => ['decision', 'chose', 'strategy', 'approach', 'domain'],
'bug' => ['bug', 'fix', 'broken', 'error', 'issue', 'lesson'],
'plan' => ['plan', 'todo', 'roadmap', 'milestone', 'phase'],
'research' => ['research', 'finding', 'discovery', 'analysis', 'rfc'],
];
foreach ($patterns as $type => $keywords) {
foreach ($keywords as $keyword) {
if (str_contains($lower, $keyword)) {
return $type;
}
}
}
return 'observation';
}
/**
* Extract topic tags from the heading and filename.
*
* @return array<string>
*/
private function extractTags(string $heading, string $filename): array
{
$tags = [];
if ($filename !== 'MEMORY') {
$tags[] = str_replace(['-', '_'], ' ', $filename);
}
$tags[] = 'memory-import';
return $tags;
}
/**
* Expand ~ to the user's home directory.
*/
private function expandHome(string $path): string
{
if (str_starts_with($path, '~/')) {
$home = getenv('HOME') ?: ('/Users/'.get_current_user());
return $home.substr($path, 1);
}
return $path;
}
}

View file

@ -0,0 +1,80 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Actions\Forge\AssignAgent;
use Core\Mod\Agentic\Actions\Forge\ReportToIssue;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Console\Command;
class DispatchCommand extends Command
{
protected $signature = 'agentic:dispatch
{--workspace=1 : Workspace ID}
{--agent-type=opus : Default agent type}
{--dry-run : Show what would be dispatched}';
protected $description = 'Dispatch agents to draft plans sourced from Forgejo';
public function handle(): int
{
$workspaceId = (int) $this->option('workspace');
$defaultAgentType = (string) $this->option('agent-type');
$isDryRun = (bool) $this->option('dry-run');
$plans = AgentPlan::where('status', AgentPlan::STATUS_DRAFT)
->whereJsonContains('metadata->source', 'forgejo')
->whereDoesntHave('sessions')
->get();
if ($plans->isEmpty()) {
$this->info('No draft Forgejo plans awaiting dispatch.');
return self::SUCCESS;
}
$dispatched = 0;
foreach ($plans as $plan) {
$assignee = $plan->metadata['assignee'] ?? $defaultAgentType;
$issueNumber = $plan->metadata['issue_number'] ?? null;
$owner = $plan->metadata['repo_owner'] ?? null;
$repo = $plan->metadata['repo_name'] ?? null;
if ($isDryRun) {
$this->line("DRY RUN: Would dispatch '{$assignee}' to plan #{$plan->id}{$plan->title}");
$dispatched++;
continue;
}
$session = AssignAgent::run($plan, $assignee, $workspaceId);
if ($issueNumber !== null && $owner !== null && $repo !== null) {
ReportToIssue::run(
(string) $owner,
(string) $repo,
(int) $issueNumber,
"Agent **{$assignee}** dispatched. Session: #{$session->id}"
);
}
$this->line("Dispatched '{$assignee}' to plan #{$plan->id}: {$plan->title} (session #{$session->id})");
$dispatched++;
}
$action = $isDryRun ? 'would be dispatched' : 'dispatched';
$this->info("{$dispatched} plan(s) {$action}.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Console\Command;
class PlanRetentionCommand extends Command
{
protected $signature = 'agentic:plan-cleanup
{--dry-run : Preview deletions without making changes}
{--days= : Override retention period (overrides agentic.plan_retention_days config)}';
protected $description = 'Permanently delete archived plans past the retention period';
public function handle(): int
{
$days = (int) ($this->option('days') ?? config('agentic.plan_retention_days', 90));
if ($days <= 0) {
$this->info('Retention cleanup is disabled (plan_retention_days is 0).');
return self::SUCCESS;
}
$cutoff = now()->subDays($days);
$query = AgentPlan::where('status', AgentPlan::STATUS_ARCHIVED)
->whereNotNull('archived_at')
->where('archived_at', '<', $cutoff);
$count = $query->count();
if ($count === 0) {
$this->info('No archived plans found past the retention period.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
$this->info("DRY RUN: {$count} archived plan(s) would be permanently deleted (archived before {$cutoff->toDateString()}).");
return self::SUCCESS;
}
$deleted = 0;
$query->chunkById(100, function ($plans) use (&$deleted): void {
foreach ($plans as $plan) {
$plan->forceDelete();
$deleted++;
}
});
$this->info("Permanently deleted {$deleted} archived plan(s) archived before {$cutoff->toDateString()}.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,94 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Actions\Forge\ManagePullRequest;
use Core\Mod\Agentic\Services\ForgejoService;
use Illuminate\Console\Command;
class PrManageCommand extends Command
{
protected $signature = 'agentic:pr-manage
{--repos=* : Repos to manage (owner/name format)}
{--dry-run : Show what would be merged}';
protected $description = 'Review and merge ready pull requests on Forgejo repositories';
public function handle(): int
{
$repos = $this->option('repos');
if (empty($repos)) {
$repos = config('agentic.scan_repos', []);
}
$repos = array_filter($repos);
if (empty($repos)) {
$this->warn('No repositories configured. Pass --repos or set AGENTIC_SCAN_REPOS.');
return self::SUCCESS;
}
$isDryRun = (bool) $this->option('dry-run');
$forge = app(ForgejoService::class);
$totalProcessed = 0;
foreach ($repos as $repoSpec) {
$parts = explode('/', $repoSpec, 2);
if (count($parts) !== 2) {
$this->error("Invalid repo format: {$repoSpec} (expected owner/name)");
continue;
}
[$owner, $repo] = $parts;
$this->info("Checking PRs for {$owner}/{$repo}...");
$pullRequests = $forge->listPullRequests($owner, $repo, 'open');
if (empty($pullRequests)) {
$this->line(" No open PRs.");
continue;
}
foreach ($pullRequests as $pr) {
$prNumber = (int) $pr['number'];
$prTitle = (string) ($pr['title'] ?? '');
$totalProcessed++;
if ($isDryRun) {
$this->line(" DRY RUN: Would evaluate PR #{$prNumber}{$prTitle}");
continue;
}
$result = ManagePullRequest::run($owner, $repo, $prNumber);
if ($result['merged']) {
$this->line(" Merged PR #{$prNumber}: {$prTitle}");
} else {
$reason = $result['reason'] ?? 'unknown';
$this->line(" Skipped PR #{$prNumber}: {$prTitle} ({$reason})");
}
}
}
$action = $isDryRun ? 'found' : 'processed';
$this->info("PR management complete: {$totalProcessed} PR(s) {$action}.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,493 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Services\BrainService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
/**
* Prepare an agent workspace with KB, specs, TODO, and vector context.
*
* Automates the "domain expert" prep that was previously manual:
* pulls repo wiki pages, copies protocol specs, generates a task
* file from a Forge issue, and queries the vector DB for context.
*/
class PrepWorkspaceCommand extends Command
{
protected $signature = 'agentic:prep-workspace
{--workspace=1 : Workspace ID}
{--repo= : Forge repo (e.g. go-ai)}
{--issue= : Issue number to build TODO from}
{--org=core : Forge organisation}
{--output= : Output directory (default: ./workspace)}
{--specs-path= : Path to specs dir (default: ~/Code/host-uk/specs)}
{--dry-run : Preview without writing files}';
protected $description = 'Prepare an agent workspace with wiki KB, specs, TODO, and vector context';
private string $baseUrl;
private string $token;
private string $org;
private string $outputDir;
private bool $dryRun;
public function handle(): int
{
$this->baseUrl = rtrim((string) config('upstream.gitea.url', 'https://forge.lthn.ai'), '/');
$this->token = (string) config('upstream.gitea.token', config('agentic.forge_token', ''));
$this->org = (string) $this->option('org');
$this->outputDir = (string) ($this->option('output') ?? getcwd() . '/workspace');
$this->dryRun = (bool) $this->option('dry-run');
$repo = $this->option('repo');
$issueNumber = $this->option('issue') ? (int) $this->option('issue') : null;
$specsPath = (string) ($this->option('specs-path') ?? $this->expandHome('~/Code/host-uk/specs'));
$workspaceId = (int) $this->option('workspace');
if (! $this->token) {
$this->error('No Forge token configured. Set GITEA_TOKEN or FORGE_TOKEN in .env');
return self::FAILURE;
}
if (! $repo) {
$this->error('--repo is required (e.g. --repo=go-ai)');
return self::FAILURE;
}
$this->info('Preparing workspace for ' . $this->org . '/' . $repo);
$this->info('Output: ' . $this->outputDir);
if ($this->dryRun) {
$this->warn('[DRY RUN] No files will be written.');
}
$this->newLine();
// Create output directory structure
if (! $this->dryRun) {
File::ensureDirectoryExists($this->outputDir . '/kb');
File::ensureDirectoryExists($this->outputDir . '/specs');
}
// Step 1: Pull wiki pages
$wikiCount = $this->pullWiki($repo);
// Step 2: Copy spec files
$specsCount = $this->copySpecs($specsPath);
// Step 3: Generate TODO from issue
$issueTitle = null;
$issueBody = null;
if ($issueNumber) {
[$issueTitle, $issueBody] = $this->generateTodo($repo, $issueNumber);
} else {
$this->generateTodoSkeleton($repo);
}
// Step 4: Generate context from vector DB
$contextCount = $this->generateContext($repo, $workspaceId, $issueTitle, $issueBody);
// Summary
$this->newLine();
$prefix = $this->dryRun ? '[DRY RUN] ' : '';
$this->info($prefix . 'Workspace prep complete:');
$this->line(' Wiki pages: ' . $wikiCount);
$this->line(' Spec files: ' . $specsCount);
$this->line(' TODO: ' . ($issueTitle ? 'from issue #' . $issueNumber : 'skeleton'));
$this->line(' Context: ' . $contextCount . ' memories');
return self::SUCCESS;
}
/**
* Fetch wiki pages from Forge API and write to kb/ directory.
*/
private function pullWiki(string $repo): int
{
$this->info('Fetching wiki pages for ' . $this->org . '/' . $repo . '...');
$response = Http::withHeaders(['Authorization' => 'token ' . $this->token])
->timeout(30)
->get($this->baseUrl . '/api/v1/repos/' . $this->org . '/' . $repo . '/wiki/pages');
if (! $response->successful()) {
if ($response->status() === 404) {
$this->warn(' No wiki found for ' . $repo);
if (! $this->dryRun) {
File::put(
$this->outputDir . '/kb/README.md',
'# No wiki found for ' . $repo . "\n\nThis repo has no wiki pages on Forge.\n"
);
}
return 0;
}
$this->error(' Wiki API error: ' . $response->status());
return 0;
}
$pages = $response->json() ?? [];
if (empty($pages)) {
$this->warn(' Wiki exists but has no pages.');
if (! $this->dryRun) {
File::put(
$this->outputDir . '/kb/README.md',
'# No wiki found for ' . $repo . "\n\nThis repo has no wiki pages on Forge.\n"
);
}
return 0;
}
$count = 0;
foreach ($pages as $page) {
$title = $page['title'] ?? 'Untitled';
$subUrl = $page['sub_url'] ?? $title;
if ($this->dryRun) {
$this->line(' [would fetch] ' . $title);
$count++;
continue;
}
// Fetch individual page content using sub_url (Forgejo's internal page identifier)
$pageResponse = Http::withHeaders(['Authorization' => 'token ' . $this->token])
->timeout(30)
->get($this->baseUrl . '/api/v1/repos/' . $this->org . '/' . $repo . '/wiki/page/' . urlencode($subUrl));
if (! $pageResponse->successful()) {
$this->warn(' Failed to fetch: ' . $title);
continue;
}
$pageData = $pageResponse->json();
$contentBase64 = $pageData['content_base64'] ?? '';
if (empty($contentBase64)) {
continue;
}
$content = base64_decode($contentBase64);
$filename = preg_replace('/[^a-zA-Z0-9_\-.]/', '-', $title) . '.md';
File::put($this->outputDir . '/kb/' . $filename, $content);
$this->line(' ' . $title);
$count++;
}
$this->info(' ' . $count . ' wiki page(s) saved to kb/');
return $count;
}
/**
* Copy protocol spec files to specs/ directory.
*/
private function copySpecs(string $specsPath): int
{
$this->info('Copying spec files...');
$specFiles = ['AGENT_CONTEXT.md', 'TASK_PROTOCOL.md'];
$count = 0;
foreach ($specFiles as $file) {
$source = $specsPath . '/' . $file;
if (! File::exists($source)) {
$this->warn(' Not found: ' . $source);
continue;
}
if ($this->dryRun) {
$this->line(' [would copy] ' . $file);
$count++;
continue;
}
File::copy($source, $this->outputDir . '/specs/' . $file);
$this->line(' ' . $file);
$count++;
}
$this->info(' ' . $count . ' spec file(s) copied.');
return $count;
}
/**
* Fetch a Forge issue and generate TODO.md in TASK_PROTOCOL format.
*
* @return array{0: string|null, 1: string|null} [title, body]
*/
private function generateTodo(string $repo, int $issueNumber): array
{
$this->info('Generating TODO from issue #' . $issueNumber . '...');
$response = Http::withHeaders(['Authorization' => 'token ' . $this->token])
->timeout(30)
->get($this->baseUrl . '/api/v1/repos/' . $this->org . '/' . $repo . '/issues/' . $issueNumber);
if (! $response->successful()) {
$this->error(' Failed to fetch issue #' . $issueNumber . ': ' . $response->status());
$this->generateTodoSkeleton($repo);
return [null, null];
}
$issue = $response->json();
$title = $issue['title'] ?? 'Untitled';
$body = $issue['body'] ?? '';
// Extract objective (first paragraph or up to 500 chars)
$objective = $this->extractObjective($body);
// Extract checklist items
$checklistItems = $this->extractChecklist($body);
$todoContent = '# TASK: ' . $title . "\n\n";
$todoContent .= '**Status:** ready' . "\n";
$todoContent .= '**Source:** ' . $this->baseUrl . '/' . $this->org . '/' . $repo . '/issues/' . $issueNumber . "\n";
$todoContent .= '**Created:** ' . now()->toDateTimeString() . "\n";
$todoContent .= '**Repo:** ' . $this->org . '/' . $repo . "\n";
$todoContent .= "\n---\n\n";
$todoContent .= "## Objective\n\n" . $objective . "\n";
$todoContent .= "\n---\n\n";
$todoContent .= "## Acceptance Criteria\n\n";
if (! empty($checklistItems)) {
foreach ($checklistItems as $item) {
$todoContent .= '- [ ] ' . $item . "\n";
}
} else {
$todoContent .= "_No checklist items found in issue. Agent should define acceptance criteria._\n";
}
$todoContent .= "\n---\n\n";
$todoContent .= "## Implementation Checklist\n\n";
$todoContent .= "_To be filled by the agent during planning._\n";
$todoContent .= "\n---\n\n";
$todoContent .= "## Notes\n\n";
$todoContent .= "Full issue body preserved below for reference.\n\n";
$todoContent .= "<details>\n<summary>Original Issue</summary>\n\n";
$todoContent .= $body . "\n\n";
$todoContent .= "</details>\n";
if ($this->dryRun) {
$this->line(' [would write] TODO.md from: ' . $title);
if (! empty($checklistItems)) {
$this->line(' Checklist items: ' . count($checklistItems));
}
} else {
File::put($this->outputDir . '/TODO.md', $todoContent);
$this->line(' TODO.md generated from: ' . $title);
}
return [$title, $body];
}
/**
* Generate a minimal TODO.md skeleton when no issue is provided.
*/
private function generateTodoSkeleton(string $repo): void
{
$content = "# TASK: [Define task]\n\n";
$content .= '**Status:** ready' . "\n";
$content .= '**Created:** ' . now()->toDateTimeString() . "\n";
$content .= '**Repo:** ' . $this->org . '/' . $repo . "\n";
$content .= "\n---\n\n";
$content .= "## Objective\n\n_Define the objective._\n";
$content .= "\n---\n\n";
$content .= "## Acceptance Criteria\n\n- [ ] _Define criteria_\n";
$content .= "\n---\n\n";
$content .= "## Implementation Checklist\n\n_To be filled by the agent._\n";
if ($this->dryRun) {
$this->line(' [would write] TODO.md skeleton');
} else {
File::put($this->outputDir . '/TODO.md', $content);
$this->line(' TODO.md skeleton generated (no --issue provided)');
}
}
/**
* Query BrainService for relevant context and write CONTEXT.md.
*/
private function generateContext(string $repo, int $workspaceId, ?string $issueTitle, ?string $issueBody): int
{
$this->info('Querying vector DB for context...');
try {
$brain = app(BrainService::class);
// Query 1: Repo-specific knowledge
$repoResults = $brain->recall(
'How does ' . $repo . ' work? Architecture and key interfaces.',
10,
['project' => $repo],
$workspaceId
);
$repoMemories = $repoResults['memories'] ?? [];
$repoScoreMap = $repoResults['scores'] ?? [];
// Query 2: Issue-specific context
$issueMemories = [];
$issueScoreMap = [];
if ($issueTitle) {
$query = $issueTitle . ' ' . mb_substr((string) $issueBody, 0, 500);
$issueResults = $brain->recall($query, 5, [], $workspaceId);
$issueMemories = $issueResults['memories'] ?? [];
$issueScoreMap = $issueResults['scores'] ?? [];
}
$totalMemories = count($repoMemories) + count($issueMemories);
$content = '# Agent Context — ' . $repo . "\n\n";
$content .= '> Auto-generated by `agentic:prep-workspace`. Query the vector DB for more.' . "\n\n";
$content .= "## Repo Knowledge\n\n";
if (! empty($repoMemories)) {
foreach ($repoMemories as $i => $memory) {
$memId = $memory['id'] ?? '';
$score = $repoScoreMap[$memId] ?? 0;
$memContent = $memory['content'] ?? '';
$memProject = $memory['project'] ?? 'unknown';
$memType = $memory['type'] ?? 'memory';
$content .= '### ' . ($i + 1) . '. ' . $memProject . ' [' . $memType . '] (score: ' . round((float) $score, 3) . ")\n\n";
$content .= $memContent . "\n\n";
}
} else {
$content .= "_No repo-specific memories found. The vector DB may not have been seeded for this repo._\n\n";
}
$content .= "## Task-Relevant Context\n\n";
if (! empty($issueMemories)) {
foreach ($issueMemories as $i => $memory) {
$memId = $memory['id'] ?? '';
$score = $issueScoreMap[$memId] ?? 0;
$memContent = $memory['content'] ?? '';
$memProject = $memory['project'] ?? 'unknown';
$memType = $memory['type'] ?? 'memory';
$content .= '### ' . ($i + 1) . '. ' . $memProject . ' [' . $memType . '] (score: ' . round((float) $score, 3) . ")\n\n";
$content .= $memContent . "\n\n";
}
} elseif ($issueTitle) {
$content .= "_No task-relevant memories found._\n\n";
} else {
$content .= "_No issue provided — skipped task-specific recall._\n\n";
}
if ($this->dryRun) {
$this->line(' [would write] CONTEXT.md with ' . $totalMemories . ' memories');
} else {
File::put($this->outputDir . '/CONTEXT.md', $content);
$this->line(' CONTEXT.md generated with ' . $totalMemories . ' memories');
}
return $totalMemories;
} catch (\Throwable $e) {
$this->warn(' BrainService unavailable: ' . $e->getMessage());
$content = '# Agent Context — ' . $repo . "\n\n";
$content .= "> Vector DB was unavailable when this workspace was prepared.\n";
$content .= "> Run `agentic:prep-workspace` again once Ollama/Qdrant are reachable.\n";
if (! $this->dryRun) {
File::put($this->outputDir . '/CONTEXT.md', $content);
}
return 0;
}
}
/**
* Extract the first paragraph or up to 500 characters as the objective.
*/
private function extractObjective(string $body): string
{
if (empty($body)) {
return '_No description provided._';
}
// Find first paragraph (text before a blank line)
$paragraphs = preg_split('/\n\s*\n/', $body, 2);
$first = trim($paragraphs[0] ?? $body);
if (mb_strlen($first) > 500) {
return mb_substr($first, 0, 497) . '...';
}
return $first;
}
/**
* Extract checklist items from markdown body.
*
* Matches `- [ ] text` and `- [x] text` lines.
*
* @return array<int, string>
*/
private function extractChecklist(string $body): array
{
$items = [];
if (preg_match_all('/- \[[ xX]\] (.+)/', $body, $matches)) {
foreach ($matches[1] as $item) {
$items[] = trim($item);
}
}
return $items;
}
/**
* Truncate a string to a maximum length.
*/
private function truncate(string $text, int $length): string
{
if (mb_strlen($text) <= $length) {
return $text;
}
return mb_substr($text, 0, $length - 3) . '...';
}
/**
* Expand ~ to the user's home directory.
*/
private function expandHome(string $path): string
{
if (str_starts_with($path, '~/')) {
$home = $_SERVER['HOME'] ?? getenv('HOME') ?: '/tmp';
return $home . substr($path, 1);
}
return $path;
}
}

View file

@ -0,0 +1,98 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Console\Commands;
use Core\Mod\Agentic\Actions\Forge\CreatePlanFromIssue;
use Core\Mod\Agentic\Actions\Forge\ReportToIssue;
use Core\Mod\Agentic\Actions\Forge\ScanForWork;
use Illuminate\Console\Command;
class ScanCommand extends Command
{
protected $signature = 'agentic:scan
{--workspace=1 : Workspace ID}
{--repos=* : Repos to scan (owner/name format)}
{--dry-run : Show what would be created without acting}';
protected $description = 'Scan Forgejo repositories for actionable work from epic issues';
public function handle(): int
{
$workspaceId = (int) $this->option('workspace');
$repos = $this->option('repos');
if (empty($repos)) {
$repos = config('agentic.scan_repos', []);
}
$repos = array_filter($repos);
if (empty($repos)) {
$this->warn('No repositories configured. Pass --repos or set AGENTIC_SCAN_REPOS.');
return self::SUCCESS;
}
$isDryRun = (bool) $this->option('dry-run');
$totalItems = 0;
foreach ($repos as $repoSpec) {
$parts = explode('/', $repoSpec, 2);
if (count($parts) !== 2) {
$this->error("Invalid repo format: {$repoSpec} (expected owner/name)");
continue;
}
[$owner, $repo] = $parts;
$this->info("Scanning {$owner}/{$repo}...");
$workItems = ScanForWork::run($owner, $repo);
if (empty($workItems)) {
$this->line(" No actionable work found.");
continue;
}
foreach ($workItems as $item) {
$totalItems++;
$issueNumber = $item['issue_number'];
$title = $item['issue_title'];
if ($isDryRun) {
$this->line(" DRY RUN: Would create plan for #{$issueNumber}{$title}");
continue;
}
$plan = CreatePlanFromIssue::run($item, $workspaceId);
ReportToIssue::run(
$owner,
$repo,
$issueNumber,
"Plan created: **{$plan->title}** (#{$plan->id})"
);
$this->line(" Created plan #{$plan->id} for issue #{$issueNumber}: {$title}");
}
}
$action = $isDryRun ? 'found' : 'processed';
$this->info("Scan complete: {$totalItems} work item(s) {$action}.");
return self::SUCCESS;
}
}

View file

@ -0,0 +1,649 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentSession;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
/**
* Agent API Controller.
*
* REST endpoints consumed by the go-agentic Client (dispatch watch).
* All routes are protected by AgentApiAuth middleware with Bearer token.
*
* Prefix: /api/v1
*/
class AgentApiController extends Controller
{
// -------------------------------------------------------------------------
// Health
// -------------------------------------------------------------------------
public function health(): JsonResponse
{
return response()->json([
'status' => 'ok',
'service' => 'core-agentic',
'timestamp' => now()->toIso8601String(),
]);
}
// -------------------------------------------------------------------------
// Plans
// -------------------------------------------------------------------------
/**
* GET /v1/plans
*
* List plans with optional status filter.
* Query params: status, include_archived
*/
public function listPlans(Request $request): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$query = AgentPlan::where('workspace_id', $workspaceId);
if ($status = $request->query('status')) {
$query->where('status', $status);
}
if (! $request->boolean('include_archived')) {
$query->notArchived();
}
$plans = $query->orderByStatus()->latest()->get();
return response()->json([
'plans' => $plans->map(fn (AgentPlan $p) => $this->formatPlan($p)),
'total' => $plans->count(),
]);
}
/**
* GET /v1/plans/{slug}
*
* Get plan detail with phases.
*/
public function getPlan(Request $request, string $slug): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$plan = AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
return response()->json($this->formatPlanDetail($plan));
}
/**
* POST /v1/plans
*
* Create a new plan with optional phases.
*/
public function createPlan(Request $request): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$validated = $request->validate([
'title' => 'required|string|max:255',
'slug' => 'nullable|string|max:255',
'description' => 'nullable|string',
'context' => 'nullable|array',
'phases' => 'nullable|array',
'phases.*.name' => 'required|string',
'phases.*.description' => 'nullable|string',
'phases.*.tasks' => 'nullable|array',
]);
$slug = $validated['slug'] ?? AgentPlan::generateSlug($validated['title']);
$plan = AgentPlan::create([
'workspace_id' => $workspaceId,
'slug' => $slug,
'title' => $validated['title'],
'description' => $validated['description'] ?? null,
'context' => $validated['context'] ?? null,
'status' => AgentPlan::STATUS_DRAFT,
]);
// Create phases if provided
$phaseCount = 0;
if (! empty($validated['phases'])) {
foreach ($validated['phases'] as $order => $phaseData) {
$tasks = [];
foreach ($phaseData['tasks'] ?? [] as $taskName) {
$tasks[] = ['name' => $taskName, 'status' => 'pending'];
}
AgentPhase::create([
'agent_plan_id' => $plan->id,
'order' => $order,
'name' => $phaseData['name'],
'description' => $phaseData['description'] ?? null,
'tasks' => $tasks ?: null,
'status' => AgentPhase::STATUS_PENDING,
]);
$phaseCount++;
}
}
return response()->json([
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'phases' => $phaseCount,
], 201);
}
/**
* PATCH /v1/plans/{slug}
*
* Update plan status.
*/
public function updatePlan(Request $request, string $slug): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$plan = AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$validated = $request->validate([
'status' => 'required|string|in:draft,active,completed,archived',
]);
match ($validated['status']) {
'active' => $plan->activate(),
'completed' => $plan->complete(),
'archived' => $plan->archive(),
default => $plan->update(['status' => $validated['status']]),
};
return response()->json([
'slug' => $plan->slug,
'status' => $plan->fresh()->status,
]);
}
/**
* DELETE /v1/plans/{slug}
*
* Archive a plan with optional reason.
*/
public function archivePlan(Request $request, string $slug): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$plan = AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$reason = $request->input('reason');
$plan->archive($reason);
return response()->json([
'slug' => $plan->slug,
'status' => 'archived',
'archived_at' => now()->toIso8601String(),
]);
}
// -------------------------------------------------------------------------
// Phases
// -------------------------------------------------------------------------
/**
* GET /v1/plans/{slug}/phases/{phase}
*
* Get a phase by order number.
*/
public function getPhase(Request $request, string $slug, string $phase): JsonResponse
{
$plan = $this->findPlan($request, $slug);
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first();
if (! $agentPhase) {
return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404);
}
return response()->json($this->formatPhase($agentPhase));
}
/**
* PATCH /v1/plans/{slug}/phases/{phase}
*
* Update phase status and/or notes.
*/
public function updatePhase(Request $request, string $slug, string $phase): JsonResponse
{
$plan = $this->findPlan($request, $slug);
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first();
if (! $agentPhase) {
return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404);
}
$status = $request->input('status');
$notes = $request->input('notes');
if ($status) {
match ($status) {
'in_progress' => $agentPhase->start(),
'completed' => $agentPhase->complete(),
'blocked' => $agentPhase->block($notes),
'skipped' => $agentPhase->skip($notes),
'pending' => $agentPhase->reset(),
default => null,
};
}
if ($notes && ! in_array($status, ['blocked', 'skipped'])) {
$agentPhase->addCheckpoint($notes);
}
return response()->json([
'slug' => $slug,
'phase' => (int) $phase,
'status' => $agentPhase->fresh()->status,
]);
}
/**
* POST /v1/plans/{slug}/phases/{phase}/checkpoint
*
* Add a checkpoint to a phase.
*/
public function addCheckpoint(Request $request, string $slug, string $phase): JsonResponse
{
$plan = $this->findPlan($request, $slug);
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first();
if (! $agentPhase) {
return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404);
}
$validated = $request->validate([
'note' => 'required|string',
'context' => 'nullable|array',
]);
$agentPhase->addCheckpoint($validated['note'], $validated['context'] ?? []);
return response()->json([
'slug' => $slug,
'phase' => (int) $phase,
'checkpoints' => count($agentPhase->fresh()->getCheckpoints()),
]);
}
/**
* PATCH /v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}
*
* Update a task within a phase.
*/
public function updateTask(Request $request, string $slug, string $phase, int $taskIdx): JsonResponse
{
$plan = $this->findPlan($request, $slug);
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first();
if (! $agentPhase) {
return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404);
}
$tasks = $agentPhase->tasks ?? [];
if (! isset($tasks[$taskIdx])) {
return response()->json(['error' => 'not_found', 'message' => 'Task not found'], 404);
}
$status = $request->input('status');
$notes = $request->input('notes');
if (is_string($tasks[$taskIdx])) {
$tasks[$taskIdx] = ['name' => $tasks[$taskIdx], 'status' => $status ?? 'pending'];
} else {
if ($status) {
$tasks[$taskIdx]['status'] = $status;
}
}
if ($notes) {
$tasks[$taskIdx]['notes'] = $notes;
}
$agentPhase->update(['tasks' => $tasks]);
return response()->json([
'slug' => $slug,
'phase' => (int) $phase,
'task' => $taskIdx,
'status' => $tasks[$taskIdx]['status'] ?? 'pending',
]);
}
/**
* POST /v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}/toggle
*
* Toggle a task between pending and completed.
*/
public function toggleTask(Request $request, string $slug, string $phase, int $taskIdx): JsonResponse
{
$plan = $this->findPlan($request, $slug);
if (! $plan) {
return response()->json(['error' => 'not_found', 'message' => 'Plan not found'], 404);
}
$agentPhase = $plan->agentPhases()->where('order', (int) $phase)->first();
if (! $agentPhase) {
return response()->json(['error' => 'not_found', 'message' => 'Phase not found'], 404);
}
$tasks = $agentPhase->tasks ?? [];
if (! isset($tasks[$taskIdx])) {
return response()->json(['error' => 'not_found', 'message' => 'Task not found'], 404);
}
if (is_string($tasks[$taskIdx])) {
$tasks[$taskIdx] = ['name' => $tasks[$taskIdx], 'status' => 'completed'];
} else {
$current = $tasks[$taskIdx]['status'] ?? 'pending';
$tasks[$taskIdx]['status'] = $current === 'completed' ? 'pending' : 'completed';
}
$agentPhase->update(['tasks' => $tasks]);
return response()->json([
'slug' => $slug,
'phase' => (int) $phase,
'task' => $taskIdx,
'status' => $tasks[$taskIdx]['status'] ?? 'pending',
]);
}
// -------------------------------------------------------------------------
// Sessions
// -------------------------------------------------------------------------
/**
* GET /v1/sessions
*
* List sessions with optional filters.
* Query params: status, plan_slug, limit
*/
public function listSessions(Request $request): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$query = AgentSession::where('workspace_id', $workspaceId);
if ($status = $request->query('status')) {
$query->where('status', $status);
}
if ($planSlug = $request->query('plan_slug')) {
$plan = AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $planSlug)
->first();
if ($plan) {
$query->where('agent_plan_id', $plan->id);
} else {
return response()->json(['sessions' => [], 'total' => 0]);
}
}
$limit = (int) ($request->query('limit') ?: 50);
$sessions = $query->latest('started_at')->limit($limit)->get();
return response()->json([
'sessions' => $sessions->map(fn (AgentSession $s) => $this->formatSession($s)),
'total' => $sessions->count(),
]);
}
/**
* GET /v1/sessions/{sessionId}
*
* Get session detail.
*/
public function getSession(Request $request, string $sessionId): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$session = AgentSession::where('workspace_id', $workspaceId)
->where('session_id', $sessionId)
->first();
if (! $session) {
return response()->json(['error' => 'not_found', 'message' => 'Session not found'], 404);
}
return response()->json($this->formatSession($session));
}
/**
* POST /v1/sessions
*
* Start a new session.
*/
public function startSession(Request $request): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$apiKey = $request->attributes->get('agent_api_key');
$validated = $request->validate([
'agent_type' => 'required|string',
'plan_slug' => 'nullable|string',
'context' => 'nullable|array',
]);
$plan = null;
if (! empty($validated['plan_slug'])) {
$plan = AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $validated['plan_slug'])
->first();
}
$session = AgentSession::create([
'workspace_id' => $workspaceId,
'agent_api_key_id' => $apiKey?->id,
'agent_plan_id' => $plan?->id,
'session_id' => 'sess_' . \Ramsey\Uuid\Uuid::uuid4()->toString(),
'agent_type' => $validated['agent_type'],
'status' => AgentSession::STATUS_ACTIVE,
'context_summary' => $validated['context'] ?? [],
'work_log' => [],
'artifacts' => [],
'started_at' => now(),
'last_active_at' => now(),
]);
return response()->json([
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'plan' => $plan?->slug,
'status' => $session->status,
], 201);
}
/**
* POST /v1/sessions/{sessionId}/end
*
* End a session.
*/
public function endSession(Request $request, string $sessionId): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$session = AgentSession::where('workspace_id', $workspaceId)
->where('session_id', $sessionId)
->first();
if (! $session) {
return response()->json(['error' => 'not_found', 'message' => 'Session not found'], 404);
}
$validated = $request->validate([
'status' => 'required|string|in:completed,failed',
'summary' => 'nullable|string',
]);
$session->end($validated['status'], $validated['summary'] ?? null);
return response()->json([
'session_id' => $session->session_id,
'status' => $session->fresh()->status,
'duration' => $session->getDurationFormatted(),
]);
}
/**
* POST /v1/sessions/{sessionId}/continue
*
* Continue from a previous session (multi-agent handoff).
*/
public function continueSession(Request $request, string $sessionId): JsonResponse
{
$workspaceId = $request->attributes->get('workspace_id');
$previousSession = AgentSession::where('workspace_id', $workspaceId)
->where('session_id', $sessionId)
->first();
if (! $previousSession) {
return response()->json(['error' => 'not_found', 'message' => 'Session not found'], 404);
}
$validated = $request->validate([
'agent_type' => 'required|string',
]);
$newSession = $previousSession->createReplaySession($validated['agent_type']);
return response()->json([
'session_id' => $newSession->session_id,
'agent_type' => $newSession->agent_type,
'plan' => $newSession->plan?->slug,
'status' => $newSession->status,
'continued_from' => $previousSession->session_id,
], 201);
}
// -------------------------------------------------------------------------
// Formatters (match go-agentic JSON contract)
// -------------------------------------------------------------------------
private function formatPlan(AgentPlan $plan): array
{
$progress = $plan->getProgress();
return [
'slug' => $plan->slug,
'title' => $plan->title,
'description' => $plan->description,
'status' => $plan->status,
'current_phase' => $plan->current_phase !== null ? (int) $plan->current_phase : null,
'progress' => $progress,
'metadata' => $plan->metadata,
'created_at' => $plan->created_at?->toIso8601String(),
'updated_at' => $plan->updated_at?->toIso8601String(),
];
}
private function formatPlanDetail(AgentPlan $plan): array
{
$data = $this->formatPlan($plan);
$data['phases'] = $plan->agentPhases->map(fn (AgentPhase $p) => $this->formatPhase($p))->all();
return $data;
}
private function formatPhase(AgentPhase $phase): array
{
$taskProgress = $phase->getTaskProgress();
return [
'id' => $phase->id,
'order' => $phase->order,
'name' => $phase->name,
'description' => $phase->description,
'status' => $phase->status,
'tasks' => $phase->tasks,
'task_progress' => [
'total' => $taskProgress['total'],
'completed' => $taskProgress['completed'],
'pending' => $taskProgress['remaining'],
'percentage' => (int) $taskProgress['percentage'],
],
'remaining_tasks' => $phase->getRemainingTasks(),
'dependencies' => $phase->dependencies,
'dependency_blockers' => $phase->checkDependencies(),
'can_start' => $phase->canStart(),
'checkpoints' => $phase->getCheckpoints(),
'started_at' => $phase->started_at?->toIso8601String(),
'completed_at' => $phase->completed_at?->toIso8601String(),
'metadata' => $phase->metadata,
];
}
private function formatSession(AgentSession $session): array
{
return [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan_slug' => $session->plan?->slug,
'plan' => $session->plan?->slug,
'duration' => $session->getDurationFormatted(),
'started_at' => $session->started_at?->toIso8601String(),
'last_active_at' => $session->last_active_at?->toIso8601String(),
'ended_at' => $session->ended_at?->toIso8601String(),
'action_count' => count($session->work_log ?? []),
'artifact_count' => count($session->artifacts ?? []),
'context_summary' => $session->context_summary,
'handoff_notes' => $session->handoff_notes ? ($session->handoff_notes['summary'] ?? '') : null,
];
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private function findPlan(Request $request, string $slug): ?AgentPlan
{
$workspaceId = $request->attributes->get('workspace_id');
return AgentPlan::where('workspace_id', $workspaceId)
->where('slug', $slug)
->first();
}
}

View file

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Brain\ForgetKnowledge;
use Core\Mod\Agentic\Actions\Brain\ListKnowledge;
use Core\Mod\Agentic\Actions\Brain\RecallKnowledge;
use Core\Mod\Agentic\Actions\Brain\RememberKnowledge;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class BrainController extends Controller
{
/**
* POST /api/brain/remember
*
* Store a memory in OpenBrain.
*/
public function remember(Request $request): JsonResponse
{
$validated = $request->validate([
'content' => 'required|string|max:50000',
'type' => 'required|string',
'tags' => 'nullable|array',
'tags.*' => 'string',
'project' => 'nullable|string|max:255',
'confidence' => 'nullable|numeric|min:0|max:1',
'supersedes' => 'nullable|uuid',
'expires_in' => 'nullable|integer|min:1',
]);
$workspace = $request->attributes->get('workspace');
$apiKey = $request->attributes->get('api_key');
$agentId = $apiKey?->name ?? 'api';
try {
$memory = RememberKnowledge::run($validated, $workspace->id, $agentId);
return response()->json([
'data' => $memory->toMcpContext(),
], 201);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
} catch (\RuntimeException $e) {
return response()->json([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
], 503);
}
}
/**
* POST /api/brain/recall
*
* Semantic search across memories.
*/
public function recall(Request $request): JsonResponse
{
$validated = $request->validate([
'query' => 'required|string|max:2000',
'top_k' => 'nullable|integer|min:1|max:20',
'filter' => 'nullable|array',
'filter.project' => 'nullable|string',
'filter.type' => 'nullable',
'filter.agent_id' => 'nullable|string',
'filter.min_confidence' => 'nullable|numeric|min:0|max:1',
]);
$workspace = $request->attributes->get('workspace');
try {
$result = RecallKnowledge::run(
$validated['query'],
$workspace->id,
$validated['filter'] ?? [],
$validated['top_k'] ?? 5,
);
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
} catch (\RuntimeException $e) {
return response()->json([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
], 503);
}
}
/**
* DELETE /api/brain/forget/{id}
*
* Remove a memory.
*/
public function forget(Request $request, string $id): JsonResponse
{
$request->validate([
'reason' => 'nullable|string|max:500',
]);
$workspace = $request->attributes->get('workspace');
$apiKey = $request->attributes->get('api_key');
$agentId = $apiKey?->name ?? 'api';
try {
$result = ForgetKnowledge::run($id, $workspace->id, $agentId, $request->input('reason'));
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
} catch (\RuntimeException $e) {
return response()->json([
'error' => 'service_error',
'message' => 'Brain service temporarily unavailable.',
], 503);
}
}
/**
* GET /api/brain/list
*
* List memories with optional filters.
*/
public function list(Request $request): JsonResponse
{
$validated = $request->validate([
'project' => 'nullable|string',
'type' => 'nullable|string',
'agent_id' => 'nullable|string',
'limit' => 'nullable|integer|min:1|max:100',
]);
$workspace = $request->attributes->get('workspace');
try {
$result = ListKnowledge::run($workspace->id, $validated);
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
}
}
}

View file

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Phase\AddCheckpoint;
use Core\Mod\Agentic\Actions\Phase\GetPhase;
use Core\Mod\Agentic\Actions\Phase\UpdatePhaseStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PhaseController extends Controller
{
/**
* GET /api/plans/{slug}/phases/{phase}
*/
public function show(Request $request, string $slug, string $phase): JsonResponse
{
$workspace = $request->attributes->get('workspace');
try {
$resolved = GetPhase::run($slug, $phase, $workspace->id);
return response()->json([
'data' => [
'order' => $resolved->order,
'name' => $resolved->name,
'description' => $resolved->description,
'status' => $resolved->status,
'tasks' => $resolved->tasks,
'checkpoints' => $resolved->getCheckpoints(),
'dependencies' => $resolved->dependencies,
'task_progress' => $resolved->getTaskProgress(),
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* PATCH /api/plans/{slug}/phases/{phase}
*/
public function update(Request $request, string $slug, string $phase): JsonResponse
{
$validated = $request->validate([
'status' => 'required|string|in:pending,in_progress,completed,blocked,skipped',
'notes' => 'nullable|string|max:5000',
]);
$workspace = $request->attributes->get('workspace');
try {
$resolved = UpdatePhaseStatus::run(
$slug,
$phase,
$validated['status'],
$workspace->id,
$validated['notes'] ?? null,
);
return response()->json([
'data' => [
'order' => $resolved->order,
'name' => $resolved->name,
'status' => $resolved->status,
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* POST /api/plans/{slug}/phases/{phase}/checkpoint
*/
public function checkpoint(Request $request, string $slug, string $phase): JsonResponse
{
$validated = $request->validate([
'note' => 'required|string|max:5000',
'context' => 'nullable|array',
]);
$workspace = $request->attributes->get('workspace');
try {
$resolved = AddCheckpoint::run(
$slug,
$phase,
$validated['note'],
$workspace->id,
$validated['context'] ?? [],
);
return response()->json([
'data' => [
'checkpoints' => $resolved->getCheckpoints(),
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
}

View file

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Plan\ArchivePlan;
use Core\Mod\Agentic\Actions\Plan\CreatePlan;
use Core\Mod\Agentic\Actions\Plan\GetPlan;
use Core\Mod\Agentic\Actions\Plan\ListPlans;
use Core\Mod\Agentic\Actions\Plan\UpdatePlanStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PlanController extends Controller
{
/**
* GET /api/plans
*/
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'status' => 'nullable|string|in:draft,active,paused,completed,archived',
'include_archived' => 'nullable|boolean',
]);
$workspace = $request->attributes->get('workspace');
try {
$plans = ListPlans::run(
$workspace->id,
$validated['status'] ?? null,
(bool) ($validated['include_archived'] ?? false),
);
return response()->json([
'data' => $plans->map(fn ($plan) => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'progress' => $plan->getProgress(),
'updated_at' => $plan->updated_at->toIso8601String(),
])->values()->all(),
'total' => $plans->count(),
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
}
}
/**
* GET /api/plans/{slug}
*/
public function show(Request $request, string $slug): JsonResponse
{
$workspace = $request->attributes->get('workspace');
try {
$plan = GetPlan::run($slug, $workspace->id);
return response()->json([
'data' => $plan->toMcpContext(),
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* POST /api/plans
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'slug' => 'nullable|string|max:255',
'description' => 'nullable|string|max:10000',
'context' => 'nullable|array',
'phases' => 'nullable|array',
'phases.*.name' => 'required_with:phases|string',
'phases.*.description' => 'nullable|string',
'phases.*.tasks' => 'nullable|array',
'phases.*.tasks.*' => 'string',
]);
$workspace = $request->attributes->get('workspace');
try {
$plan = CreatePlan::run($validated, $workspace->id);
return response()->json([
'data' => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'phases' => $plan->agentPhases->count(),
],
], 201);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
}
}
/**
* PATCH /api/plans/{slug}
*/
public function update(Request $request, string $slug): JsonResponse
{
$validated = $request->validate([
'status' => 'required|string|in:draft,active,paused,completed',
]);
$workspace = $request->attributes->get('workspace');
try {
$plan = UpdatePlanStatus::run($slug, $validated['status'], $workspace->id);
return response()->json([
'data' => [
'slug' => $plan->slug,
'status' => $plan->status,
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* DELETE /api/plans/{slug}
*/
public function destroy(Request $request, string $slug): JsonResponse
{
$request->validate([
'reason' => 'nullable|string|max:500',
]);
$workspace = $request->attributes->get('workspace');
try {
$plan = ArchivePlan::run($slug, $workspace->id, $request->input('reason'));
return response()->json([
'data' => [
'slug' => $plan->slug,
'status' => 'archived',
'archived_at' => $plan->archived_at?->toIso8601String(),
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
}

View file

@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Session\ContinueSession;
use Core\Mod\Agentic\Actions\Session\EndSession;
use Core\Mod\Agentic\Actions\Session\GetSession;
use Core\Mod\Agentic\Actions\Session\ListSessions;
use Core\Mod\Agentic\Actions\Session\StartSession;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SessionController extends Controller
{
/**
* GET /api/sessions
*/
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'status' => 'nullable|string|in:active,paused,completed,failed',
'plan_slug' => 'nullable|string|max:255',
'limit' => 'nullable|integer|min:1|max:1000',
]);
$workspace = $request->attributes->get('workspace');
try {
$sessions = ListSessions::run(
$workspace->id,
$validated['status'] ?? null,
$validated['plan_slug'] ?? null,
$validated['limit'] ?? null,
);
return response()->json([
'data' => $sessions->map(fn ($session) => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan' => $session->plan?->slug,
'duration' => $session->getDurationFormatted(),
'started_at' => $session->started_at->toIso8601String(),
'last_active_at' => $session->last_active_at->toIso8601String(),
])->values()->all(),
'total' => $sessions->count(),
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
}
}
/**
* GET /api/sessions/{id}
*/
public function show(Request $request, string $id): JsonResponse
{
$workspace = $request->attributes->get('workspace');
try {
$session = GetSession::run($id, $workspace->id);
return response()->json([
'data' => $session->toMcpContext(),
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* POST /api/sessions
*/
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'agent_type' => 'required|string|max:50',
'plan_slug' => 'nullable|string|max:255',
'context' => 'nullable|array',
]);
$workspace = $request->attributes->get('workspace');
try {
$session = StartSession::run(
$validated['agent_type'],
$validated['plan_slug'] ?? null,
$workspace->id,
$validated['context'] ?? [],
);
return response()->json([
'data' => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'plan' => $session->plan?->slug,
'status' => $session->status,
],
], 201);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'validation_error',
'message' => $e->getMessage(),
], 422);
}
}
/**
* POST /api/sessions/{id}/end
*/
public function end(Request $request, string $id): JsonResponse
{
$validated = $request->validate([
'status' => 'required|string|in:completed,handed_off,paused,failed',
'summary' => 'nullable|string|max:10000',
]);
try {
$session = EndSession::run($id, $validated['status'], $validated['summary'] ?? null);
return response()->json([
'data' => [
'session_id' => $session->session_id,
'status' => $session->status,
'duration' => $session->getDurationFormatted(),
],
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* POST /api/sessions/{id}/continue
*/
public function continue(Request $request, string $id): JsonResponse
{
$validated = $request->validate([
'agent_type' => 'required|string|max:50',
]);
try {
$session = ContinueSession::run($id, $validated['agent_type']);
return response()->json([
'data' => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'plan' => $session->plan?->slug,
'status' => $session->status,
'continued_from' => $session->context_summary['continued_from'] ?? null,
],
], 201);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Controllers\Api;
use Core\Front\Controller;
use Core\Mod\Agentic\Actions\Task\ToggleTask;
use Core\Mod\Agentic\Actions\Task\UpdateTask;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TaskController extends Controller
{
/**
* PATCH /api/plans/{slug}/phases/{phase}/tasks/{index}
*/
public function update(Request $request, string $slug, string $phase, int $index): JsonResponse
{
$validated = $request->validate([
'status' => 'nullable|string|in:pending,in_progress,completed,blocked,skipped',
'notes' => 'nullable|string|max:5000',
]);
$workspace = $request->attributes->get('workspace');
try {
$result = UpdateTask::run(
$slug,
$phase,
$index,
$workspace->id,
$validated['status'] ?? null,
$validated['notes'] ?? null,
);
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
/**
* POST /api/plans/{slug}/phases/{phase}/tasks/{index}/toggle
*/
public function toggle(Request $request, string $slug, string $phase, int $index): JsonResponse
{
$workspace = $request->attributes->get('workspace');
try {
$result = ToggleTask::run($slug, $phase, $index, $workspace->id);
return response()->json([
'data' => $result,
]);
} catch (\InvalidArgumentException $e) {
return response()->json([
'error' => 'not_found',
'message' => $e->getMessage(),
], 404);
}
}
}

View file

@ -111,10 +111,12 @@ class ProcessContentTask implements ShouldQueue
private function interpolateVariables(string $template, array $data): string
{
foreach ($data as $key => $value) {
$placeholder = '{{{'.$key.'}}}';
if (is_string($value)) {
$template = str_replace("{{{$key}}}", $value, $template);
$template = str_replace($placeholder, $value, $template);
} elseif (is_array($value)) {
$template = str_replace("{{{$key}}}", json_encode($value), $template);
$template = str_replace($placeholder, json_encode($value), $template);
}
}

View file

@ -54,7 +54,7 @@ class Marketing extends Server
#### Other Bio Tools
- `qr_tools` - Generate QR codes
- `pixel_tools` - Manage tracking pixels
- `project_tools` - Organize into projects
- `project_tools` - Organise into projects
- `notification_tools` - Manage notification handlers
- `submission_tools` - Manage form submissions
- `pwa_tools` - Configure PWA

View file

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\ForgetKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
/**
* Remove a memory from the shared OpenBrain knowledge store.
*
* Deletes the memory from both MariaDB and Qdrant.
* Workspace-scoped: agents can only forget memories in their own workspace.
*/
class BrainForget extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['write'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to forget memories'),
];
}
public function name(): string
{
return 'brain_forget';
}
public function description(): string
{
return 'Remove a memory from the shared OpenBrain knowledge store. Permanently deletes from both database and vector index.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'id' => [
'type' => 'string',
'format' => 'uuid',
'description' => 'UUID of the memory to remove',
],
'reason' => [
'type' => 'string',
'description' => 'Optional reason for forgetting this memory',
'maxLength' => 500,
],
],
'required' => ['id'],
];
}
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$id = $args['id'] ?? '';
$reason = $this->optionalString($args, 'reason', null, 500);
$agentId = $context['agent_id'] ?? $context['session_id'] ?? 'anonymous';
return $this->withCircuitBreaker('brain', function () use ($id, $workspaceId, $agentId, $reason) {
$result = ForgetKnowledge::run($id, (int) $workspaceId, $agentId, $reason);
return $this->success($result);
}, fn () => $this->error('Brain service temporarily unavailable. Memory could not be removed.', 'service_unavailable'));
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\ListKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
/**
* List memories in the shared OpenBrain knowledge store.
*
* Pure MariaDB query using model scopes -- no vector search.
* Useful for browsing what an agent or project has stored.
*/
class BrainList extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['read'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to list memories'),
];
}
public function name(): string
{
return 'brain_list';
}
public function description(): string
{
return 'List memories in the shared OpenBrain knowledge store. Supports filtering by project, type, and agent. No vector search -- use brain_recall for semantic queries.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'project' => [
'type' => 'string',
'description' => 'Filter by project scope',
],
'type' => [
'type' => 'string',
'description' => 'Filter by memory type',
'enum' => BrainMemory::VALID_TYPES,
],
'agent_id' => [
'type' => 'string',
'description' => 'Filter by originating agent',
],
'limit' => [
'type' => 'integer',
'description' => 'Maximum results to return (default: 20, max: 100)',
'minimum' => 1,
'maximum' => 100,
'default' => 20,
],
],
];
}
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$result = ListKnowledge::run((int) $workspaceId, $args);
return $this->success($result);
}
}

View file

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\RecallKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
/**
* Semantic search across the shared OpenBrain knowledge store.
*
* Uses vector similarity to find memories relevant to a natural
* language query, with optional filtering by project, type, agent,
* or minimum confidence.
*/
class BrainRecall extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['read'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to recall memories'),
];
}
public function name(): string
{
return 'brain_recall';
}
public function description(): string
{
return 'Semantic search across the shared OpenBrain knowledge store. Returns memories ranked by similarity to your query, with optional filtering.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => 'Natural language search query (max 2,000 characters)',
'maxLength' => 2000,
],
'top_k' => [
'type' => 'integer',
'description' => 'Number of results to return (default: 5, max: 20)',
'minimum' => 1,
'maximum' => 20,
'default' => 5,
],
'filter' => [
'type' => 'object',
'description' => 'Optional filters to narrow results',
'properties' => [
'project' => [
'type' => 'string',
'description' => 'Filter by project scope',
],
'type' => [
'oneOf' => [
['type' => 'string', 'enum' => BrainMemory::VALID_TYPES],
[
'type' => 'array',
'items' => ['type' => 'string', 'enum' => BrainMemory::VALID_TYPES],
],
],
'description' => 'Filter by memory type (single or array)',
],
'agent_id' => [
'type' => 'string',
'description' => 'Filter by originating agent',
],
'min_confidence' => [
'type' => 'number',
'description' => 'Minimum confidence threshold (0.0-1.0)',
'minimum' => 0.0,
'maximum' => 1.0,
],
],
],
],
'required' => ['query'],
];
}
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$query = $args['query'] ?? '';
$topK = $this->optionalInt($args, 'top_k', 5, 1, 20);
$filter = $this->optional($args, 'filter', []);
if (! is_array($filter)) {
return $this->error('filter must be an object');
}
return $this->withCircuitBreaker('brain', function () use ($query, $workspaceId, $filter, $topK) {
$result = RecallKnowledge::run($query, (int) $workspaceId, $filter, $topK);
return $this->success([
'count' => $result['count'],
'memories' => $result['memories'],
'scores' => $result['scores'],
]);
}, fn () => $this->error('Brain service temporarily unavailable. Recall failed.', 'service_unavailable'));
}
}

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Brain;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Brain\RememberKnowledge;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\BrainMemory;
/**
* Store a memory in the shared OpenBrain knowledge store.
*
* Agents use this tool to persist decisions, observations, conventions,
* and other knowledge so that other agents can recall it later.
*/
class BrainRemember extends AgentTool
{
protected string $category = 'brain';
protected array $scopes = ['write'];
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required to store memories'),
];
}
public function name(): string
{
return 'brain_remember';
}
public function description(): string
{
return 'Store a memory in the shared OpenBrain knowledge store. Use this to persist decisions, observations, conventions, research, plans, bugs, or architecture knowledge for other agents.';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'content' => [
'type' => 'string',
'description' => 'The knowledge to remember (max 50,000 characters)',
'maxLength' => 50000,
],
'type' => [
'type' => 'string',
'description' => 'Memory type classification',
'enum' => BrainMemory::VALID_TYPES,
],
'tags' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Optional tags for categorisation',
],
'project' => [
'type' => 'string',
'description' => 'Optional project scope (e.g. repo name)',
],
'confidence' => [
'type' => 'number',
'description' => 'Confidence level from 0.0 to 1.0 (default: 0.8)',
'minimum' => 0.0,
'maximum' => 1.0,
],
'supersedes' => [
'type' => 'string',
'format' => 'uuid',
'description' => 'UUID of an older memory this one replaces',
],
'expires_in' => [
'type' => 'integer',
'description' => 'Hours until this memory expires (null = never)',
'minimum' => 1,
],
],
'required' => ['content', 'type'],
];
}
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$agentId = $context['agent_id'] ?? $context['session_id'] ?? 'anonymous';
return $this->withCircuitBreaker('brain', function () use ($args, $workspaceId, $agentId) {
$memory = RememberKnowledge::run($args, (int) $workspaceId, $agentId);
return $this->success([
'memory' => $memory->toMcpContext(),
]);
}, fn () => $this->error('Brain service temporarily unavailable. Memory could not be stored.', 'service_unavailable'));
}
}

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase;
use Core\Mod\Agentic\Actions\Phase\AddCheckpoint;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Add a checkpoint note to a phase.
@ -55,44 +54,25 @@ class PhaseAddCheckpoint extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$planSlug = $this->require($args, 'plan_slug');
$phaseIdentifier = $this->require($args, 'phase');
$note = $this->require($args, 'note');
$phase = AddCheckpoint::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
$args['note'] ?? '',
(int) $workspaceId,
$args['context'] ?? [],
);
return $this->success([
'checkpoints' => $phase->getCheckpoints(),
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
$phase->addCheckpoint($note, $args['context'] ?? []);
return $this->success([
'checkpoints' => $phase->fresh()->checkpoints,
]);
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where('name', $identifier)
->first();
}
}

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase;
use Core\Mod\Agentic\Actions\Phase\GetPhase;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Get details of a specific phase within a plan.
@ -47,52 +46,31 @@ class PhaseGet extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$planSlug = $this->require($args, 'plan_slug');
$phaseIdentifier = $this->require($args, 'phase');
$phase = GetPhase::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
(int) $workspaceId,
);
return $this->success([
'phase' => [
'order' => $phase->order,
'name' => $phase->name,
'description' => $phase->description,
'status' => $phase->status,
'tasks' => $phase->tasks,
'checkpoints' => $phase->getCheckpoints(),
'dependencies' => $phase->dependencies,
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
return [
'phase' => [
'order' => $phase->order,
'name' => $phase->name,
'description' => $phase->description,
'status' => $phase->status,
'tasks' => $phase->tasks,
'checkpoints' => $phase->checkpoints,
'dependencies' => $phase->dependencies,
],
];
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Phase;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Phase\UpdatePhaseStatus;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update the status of a phase.
@ -69,55 +68,29 @@ class PhaseUpdateStatus extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$planSlug = $this->require($args, 'plan_slug');
$phaseIdentifier = $this->require($args, 'phase');
$status = $this->require($args, 'status');
$phase = UpdatePhaseStatus::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
$args['status'] ?? '',
(int) $workspaceId,
$args['notes'] ?? null,
);
return $this->success([
'phase' => [
'order' => $phase->order,
'name' => $phase->name,
'status' => $phase->status,
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
if (! empty($args['notes'])) {
$phase->addCheckpoint($args['notes'], ['status_change' => $status]);
}
$phase->update(['status' => $status]);
return $this->success([
'phase' => [
'order' => $phase->order,
'name' => $phase->name,
'status' => $phase->fresh()->status,
],
]);
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mod\Agentic\Actions\Plan\ArchivePlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Archive a completed or abandoned plan.
@ -46,26 +46,27 @@ class PlanArchive extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$slug = $this->require($args, 'slug');
$plan = ArchivePlan::run(
$args['slug'] ?? '',
(int) $workspaceId,
$args['reason'] ?? null,
);
return $this->success([
'plan' => [
'slug' => $plan->slug,
'status' => 'archived',
'archived_at' => $plan->archived_at?->toIso8601String(),
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $slug)->first();
if (! $plan) {
return $this->error("Plan not found: {$slug}");
}
$plan->archive($args['reason'] ?? null);
return $this->success([
'plan' => [
'slug' => $plan->slug,
'status' => 'archived',
'archived_at' => $plan->archived_at?->toIso8601String(),
],
]);
}
}

View file

@ -5,10 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Plan\CreatePlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
use Illuminate\Support\Str;
/**
* Create a new work plan with phases and tasks.
@ -84,61 +82,24 @@ class PlanCreate extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
}
try {
$title = $this->requireString($args, 'title', 255);
$slug = $this->optionalString($args, 'slug', null, 255) ?? Str::slug($title).'-'.Str::random(6);
$description = $this->optionalString($args, 'description', null, 10000);
$plan = CreatePlan::run($args, (int) $workspaceId);
return $this->success([
'plan' => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'phases' => $plan->agentPhases->count(),
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
if (AgentPlan::where('slug', $slug)->exists()) {
return $this->error("Plan with slug '{$slug}' already exists");
}
// Determine workspace_id - never fall back to hardcoded value in multi-tenant environment
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required but could not be determined from context');
}
$plan = AgentPlan::create([
'slug' => $slug,
'title' => $title,
'description' => $description,
'status' => 'draft',
'context' => $args['context'] ?? [],
'workspace_id' => $workspaceId,
]);
// Create phases if provided
if (! empty($args['phases'])) {
foreach ($args['phases'] as $order => $phaseData) {
$tasks = collect($phaseData['tasks'] ?? [])->map(fn ($task) => [
'name' => $task,
'status' => 'pending',
])->all();
AgentPhase::create([
'agent_plan_id' => $plan->id,
'name' => $phaseData['name'],
'description' => $phaseData['description'] ?? null,
'order' => $order + 1,
'status' => 'pending',
'tasks' => $tasks,
]);
}
}
$plan->load('agentPhases');
return $this->success([
'plan' => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'phases' => $plan->agentPhases->count(),
],
]);
}
}

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Plan\GetPlan;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Get detailed information about a specific plan.
@ -62,56 +62,23 @@ class PlanGet extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
}
try {
$slug = $this->require($args, 'slug');
$plan = GetPlan::run($args['slug'] ?? '', (int) $workspaceId);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
// Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required for plan operations');
$format = $args['format'] ?? 'json';
if ($format === 'markdown') {
return $this->success(['markdown' => $plan->toMarkdown()]);
}
$format = $this->optional($args, 'format', 'json');
// Use circuit breaker for Agentic module database calls
return $this->withCircuitBreaker('agentic', function () use ($slug, $format, $workspaceId) {
// Query plan with workspace scope to prevent cross-tenant access
$plan = AgentPlan::with('agentPhases')
->forWorkspace($workspaceId)
->where('slug', $slug)
->first();
if (! $plan) {
return $this->error("Plan not found: {$slug}");
}
if ($format === 'markdown') {
return $this->success(['markdown' => $plan->toMarkdown()]);
}
return $this->success([
'plan' => [
'slug' => $plan->slug,
'title' => $plan->title,
'description' => $plan->description,
'status' => $plan->status,
'context' => $plan->context,
'progress' => $plan->getProgress(),
'phases' => $plan->agentPhases->map(fn ($phase) => [
'order' => $phase->order,
'name' => $phase->name,
'description' => $phase->description,
'status' => $phase->status,
'tasks' => $phase->tasks,
'checkpoints' => $phase->checkpoints,
])->all(),
'created_at' => $plan->created_at->toIso8601String(),
'updated_at' => $plan->updated_at->toIso8601String(),
],
]);
}, fn () => $this->error('Agentic service temporarily unavailable', 'service_unavailable'));
return $this->success(['plan' => $plan->toMcpContext()]);
}
}

View file

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Plan\ListPlans;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* List all work plans with their current status and progress.
@ -61,43 +61,30 @@ class PlanList extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
}
try {
$status = $this->optionalEnum($args, 'status', ['draft', 'active', 'paused', 'completed', 'archived']);
$includeArchived = (bool) ($args['include_archived'] ?? false);
$plans = ListPlans::run(
(int) $workspaceId,
$args['status'] ?? null,
(bool) ($args['include_archived'] ?? false),
);
return $this->success([
'plans' => $plans->map(fn ($plan) => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'progress' => $plan->getProgress(),
'updated_at' => $plan->updated_at->toIso8601String(),
])->all(),
'total' => $plans->count(),
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
// Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required for plan operations');
}
// Query plans with workspace scope to prevent cross-tenant access
$query = AgentPlan::with('agentPhases')
->forWorkspace($workspaceId)
->orderBy('updated_at', 'desc');
if (! $includeArchived && $status !== 'archived') {
$query->notArchived();
}
if ($status !== null) {
$query->where('status', $status);
}
$plans = $query->get();
return $this->success([
'plans' => $plans->map(fn ($plan) => [
'slug' => $plan->slug,
'title' => $plan->title,
'status' => $plan->status,
'progress' => $plan->getProgress(),
'updated_at' => $plan->updated_at->toIso8601String(),
])->all(),
'total' => $plans->count(),
]);
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mod\Agentic\Actions\Plan\UpdatePlanStatus;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update the status of a plan.
@ -47,26 +47,26 @@ class PlanUpdateStatus extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$slug = $this->require($args, 'slug');
$status = $this->require($args, 'status');
$plan = UpdatePlanStatus::run(
$args['slug'] ?? '',
$args['status'] ?? '',
(int) $workspaceId,
);
return $this->success([
'plan' => [
'slug' => $plan->slug,
'status' => $plan->status,
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $slug)->first();
if (! $plan) {
return $this->error("Plan not found: {$slug}");
}
$plan->update(['status' => $status]);
return $this->success([
'plan' => [
'slug' => $plan->slug,
'status' => $plan->fresh()->status,
],
]);
}
}

279
Mcp/Tools/Agent/README.md Normal file
View file

@ -0,0 +1,279 @@
# MCP Agent Tools
This directory contains MCP (Model Context Protocol) tool implementations for the agent orchestration system. All tools extend `AgentTool` and integrate with the `ToolDependency` system to declare and validate their execution prerequisites.
## Directory Structure
```
Mcp/Tools/Agent/
├── AgentTool.php # Base class — extend this for all new tools
├── Contracts/
│ └── AgentToolInterface.php # Tool contract
├── Content/ # Content generation tools
├── Phase/ # Plan phase management tools
├── Plan/ # Work plan CRUD tools
├── Session/ # Agent session lifecycle tools
├── State/ # Shared workspace state tools
├── Task/ # Task status and tracking tools
└── Template/ # Template listing and application tools
```
## ToolDependency System
`ToolDependency` (from `Core\Mcp\Dependencies\ToolDependency`) lets a tool declare what must be true in the execution context before it runs. The `AgentToolRegistry` validates these automatically — the tool's `handle()` method is never called if a dependency is unmet.
### How It Works
1. A tool declares its dependencies in a `dependencies()` method returning `ToolDependency[]`.
2. When the tool is registered, `AgentToolRegistry::register()` passes those dependencies to `ToolDependencyService`.
3. On each call, `AgentToolRegistry::execute()` calls `ToolDependencyService::validateDependencies()` before invoking `handle()`.
4. If any required dependency fails, a `MissingDependencyException` is thrown and the tool is never called.
5. After a successful call, `ToolDependencyService::recordToolCall()` logs the execution for audit purposes.
### Dependency Types
#### `contextExists` — Require a context field
Validates that a key is present in the `$context` array passed at execution time. Use this for multi-tenant isolation fields like `workspace_id` that come from API key authentication.
```php
ToolDependency::contextExists('workspace_id', 'Workspace context required')
```
Mark a dependency optional with `->asOptional()` when the tool can work without it (e.g. the value can be inferred from another argument):
```php
// SessionStart: workspace can be inferred from the plan if plan_slug is provided
ToolDependency::contextExists('workspace_id', 'Workspace context required (or provide plan_slug)')
->asOptional()
```
#### `sessionState` — Require an active session
Validates that a session is active. Use this for tools that must run within an established session context.
```php
ToolDependency::sessionState('session_id', 'Active session required. Call session_start first.')
```
#### `entityExists` — Require a database entity
Validates that an entity exists in the database before the tool runs. The `arg_key` maps to the tool argument that holds the entity identifier.
```php
ToolDependency::entityExists('plan', 'Plan must exist', ['arg_key' => 'plan_slug'])
```
## Context Requirements
The `$context` array is injected into every tool's `handle(array $args, array $context)` call. Context is set by API key authentication middleware — tools should never hardcode or fall back to default values.
| Key | Type | Set by | Used by |
|-----|------|--------|---------|
| `workspace_id` | `string\|int` | API key auth middleware | All workspace-scoped tools |
| `session_id` | `string` | Client (from `session_start` response) | Session-dependent tools |
**Multi-tenant safety:** Always validate `workspace_id` in `handle()` as a defence-in-depth measure, even when a `contextExists` dependency is declared. Use `forWorkspace($workspaceId)` scopes on all queries.
```php
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key. See: https://host.uk.com/ai');
}
$plan = AgentPlan::forWorkspace($workspaceId)->where('slug', $slug)->first();
```
## Creating a New Tool
### 1. Create the class
Place the file in the appropriate subdirectory and extend `AgentTool`:
```php
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Plan;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
class PlanPublish extends AgentTool
{
protected string $category = 'plan';
protected array $scopes = ['write']; // 'read' or 'write'
public function dependencies(): array
{
return [
ToolDependency::contextExists('workspace_id', 'Workspace context required'),
];
}
public function name(): string
{
return 'plan_publish'; // snake_case; must be unique across all tools
}
public function description(): string
{
return 'Publish a draft plan, making it active';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'plan_slug' => [
'type' => 'string',
'description' => 'Plan slug identifier',
],
],
'required' => ['plan_slug'],
];
}
public function handle(array $args, array $context = []): array
{
try {
$planSlug = $this->requireString($args, 'plan_slug', 255);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. See: https://host.uk.com/ai');
}
$plan = AgentPlan::forWorkspace($workspaceId)->where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$plan->update(['status' => 'active']);
return $this->success(['plan' => ['slug' => $plan->slug, 'status' => $plan->status]]);
}
}
```
### 2. Register the tool
Add it to the tool registration list in the package boot sequence (see `Boot.php` and the `McpToolsRegistering` event handler).
### 3. Write tests
Add a Pest test file under `Tests/` covering success and failure paths, including missing dependency scenarios.
## AgentTool Base Class Reference
### Properties
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `$category` | `string` | `'general'` | Groups tools in the registry |
| `$scopes` | `string[]` | `['read']` | API key scopes required to call this tool |
| `$timeout` | `?int` | `null` | Per-tool timeout override in seconds (null uses config default of 30s) |
### Argument Helpers
All helpers throw `\InvalidArgumentException` on failure. Catch it in `handle()` and return `$this->error()`.
| Method | Description |
|--------|-------------|
| `requireString($args, $key, $maxLength, $label)` | Required string with optional max length |
| `requireInt($args, $key, $min, $max, $label)` | Required integer with optional bounds |
| `requireArray($args, $key, $label)` | Required array |
| `requireEnum($args, $key, $allowed, $label)` | Required string constrained to allowed values |
| `optionalString($args, $key, $default, $maxLength)` | Optional string |
| `optionalInt($args, $key, $default, $min, $max)` | Optional integer |
| `optionalEnum($args, $key, $allowed, $default)` | Optional enum string |
| `optional($args, $key, $default)` | Optional value of any type |
### Response Helpers
```php
return $this->success(['key' => 'value']); // merges ['success' => true]
return $this->error('Something went wrong');
return $this->error('Resource locked', 'resource_locked'); // with error code
```
### Circuit Breaker
Wrap calls to external services with `withCircuitBreaker()` for fault tolerance:
```php
return $this->withCircuitBreaker(
'agentic', // service name
fn () => $this->doWork(), // operation
fn () => $this->error('Service unavailable', 'service_unavailable') // fallback
);
```
If no fallback is provided and the circuit is open, `error()` is returned automatically.
### Timeout Override
For long-running tools (e.g. content generation), override the timeout:
```php
protected ?int $timeout = 300; // 5 minutes
```
## Dependency Resolution Order
Dependencies are validated in the order they are returned from `dependencies()`. All required dependencies must pass before the tool runs. Optional dependencies are checked but do not block execution.
Recommended declaration order:
1. `contextExists('workspace_id', ...)` — tenant isolation first
2. `sessionState('session_id', ...)` — session presence second
3. `entityExists(...)` — entity existence last (may query DB)
## Troubleshooting
### "Workspace context required"
The `workspace_id` key is missing from the execution context. This is injected by the API key authentication middleware. Causes:
- Request is unauthenticated or the API key is invalid.
- The API key has no workspace association.
- Dependency validation was bypassed but the tool checks it internally.
**Fix:** Authenticate with a valid API key. See https://host.uk.com/ai.
### "Active session required. Call session_start first."
The `session_id` context key is missing. The tool requires an active session.
**Fix:** Call `session_start` before calling session-dependent tools. Pass the returned `session_id` in the context of all subsequent calls.
### "Plan must exist" / "Plan not found"
The `plan_slug` argument does not match any plan. Either the plan was never created, the slug is misspelled, or the plan belongs to a different workspace.
**Fix:** Call `plan_list` to find valid slugs, then retry.
### "Permission denied: API key missing scope"
The API key does not have the required scope (`read` or `write`) for the tool.
**Fix:** Issue a new API key with the correct scopes, or use an existing key that has the required permissions.
### "Unknown tool: {name}"
The tool name does not match any registered tool.
**Fix:** Check `plan_list` / MCP tool discovery endpoint for the exact tool name. Names are snake_case.
### `MissingDependencyException` in logs
A required dependency was not met and the framework threw before calling `handle()`. The exception message will identify which dependency failed.
**Fix:** Inspect the `context` passed to `execute()`. Ensure required keys are present and the relevant entity exists.

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Actions\Session\ContinueSession;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* Continue from a previous session (multi-agent handoff).
@ -47,32 +47,27 @@ class SessionContinue extends AgentTool
public function handle(array $args, array $context = []): array
{
try {
$previousSessionId = $this->require($args, 'previous_session_id');
$agentType = $this->require($args, 'agent_type');
$session = ContinueSession::run(
$args['previous_session_id'] ?? '',
$args['agent_type'] ?? '',
);
$inheritedContext = $session->context_summary ?? [];
return $this->success([
'session' => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan' => $session->plan?->slug,
],
'continued_from' => $inheritedContext['continued_from'] ?? null,
'previous_agent' => $inheritedContext['previous_agent'] ?? null,
'handoff_notes' => $inheritedContext['handoff_notes'] ?? null,
'inherited_context' => $inheritedContext['inherited_context'] ?? null,
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$sessionService = app(AgentSessionService::class);
$session = $sessionService->continueFrom($previousSessionId, $agentType);
if (! $session) {
return $this->error("Previous session not found: {$previousSessionId}");
}
$inheritedContext = $session->context_summary ?? [];
return $this->success([
'session' => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan' => $session->plan?->slug,
],
'continued_from' => $inheritedContext['continued_from'] ?? null,
'previous_agent' => $inheritedContext['previous_agent'] ?? null,
'handoff_notes' => $inheritedContext['handoff_notes'] ?? null,
'inherited_context' => $inheritedContext['inherited_context'] ?? null,
]);
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Actions\Session\EndSession;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentSession;
/**
* End the current session.
@ -47,32 +47,27 @@ class SessionEnd extends AgentTool
public function handle(array $args, array $context = []): array
{
try {
$status = $this->require($args, 'status');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$sessionId = $context['session_id'] ?? null;
if (! $sessionId) {
return $this->error('No active session');
}
$session = AgentSession::where('session_id', $sessionId)->first();
try {
$session = EndSession::run(
$sessionId,
$args['status'] ?? '',
$args['summary'] ?? null,
);
if (! $session) {
return $this->error('Session not found');
return $this->success([
'session' => [
'session_id' => $session->session_id,
'status' => $session->status,
'duration' => $session->getDurationFormatted(),
],
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$session->end($status, $this->optional($args, 'summary'));
return $this->success([
'session' => [
'session_id' => $session->session_id,
'status' => $session->status,
'duration' => $session->getDurationFormatted(),
],
]);
}
}

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mod\Agentic\Actions\Session\ListSessions;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Services\AgentSessionService;
/**
* List sessions, optionally filtered by status.
@ -50,54 +50,34 @@ class SessionList extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$status = $this->optionalEnum($args, 'status', ['active', 'paused', 'completed', 'failed']);
$planSlug = $this->optionalString($args, 'plan_slug', null, 255);
$limit = $this->optionalInt($args, 'limit', null, min: 1, max: 1000);
$sessions = ListSessions::run(
(int) $workspaceId,
$args['status'] ?? null,
$args['plan_slug'] ?? null,
isset($args['limit']) ? (int) $args['limit'] : null,
);
return $this->success([
'sessions' => $sessions->map(fn ($session) => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan' => $session->plan?->slug,
'duration' => $session->getDurationFormatted(),
'started_at' => $session->started_at->toIso8601String(),
'last_active_at' => $session->last_active_at->toIso8601String(),
'has_handoff' => ! empty($session->handoff_notes),
])->all(),
'total' => $sessions->count(),
]);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$sessionService = app(AgentSessionService::class);
// Get active sessions (default)
if ($status === 'active' || $status === null) {
$sessions = $sessionService->getActiveSessions($context['workspace_id'] ?? null);
} else {
// Query with filters
$query = \Core\Mod\Agentic\Models\AgentSession::query()
->orderBy('last_active_at', 'desc');
// Apply workspace filter if provided
if (! empty($context['workspace_id'])) {
$query->where('workspace_id', $context['workspace_id']);
}
$query->where('status', $status);
if ($planSlug !== null) {
$query->whereHas('plan', fn ($q) => $q->where('slug', $planSlug));
}
if ($limit !== null) {
$query->limit($limit);
}
$sessions = $query->get();
}
return [
'sessions' => $sessions->map(fn ($session) => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'status' => $session->status,
'plan' => $session->plan?->slug,
'duration' => $session->getDurationFormatted(),
'started_at' => $session->started_at->toIso8601String(),
'last_active_at' => $session->last_active_at->toIso8601String(),
'has_handoff' => ! empty($session->handoff_notes),
])->all(),
'total' => $sessions->count(),
];
}
}

View file

@ -5,10 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Session;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Session\StartSession;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentSession;
use Illuminate\Support\Str;
/**
* Start a new agent session for a plan.
@ -70,48 +68,29 @@ class SessionStart extends AgentTool
public function handle(array $args, array $context = []): array
{
try {
$agentType = $this->require($args, 'agent_type');
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session, or provide a valid plan_slug to infer workspace context. See: https://host.uk.com/ai');
}
// Use circuit breaker for Agentic module database calls
return $this->withCircuitBreaker('agentic', function () use ($args, $context, $agentType) {
$plan = null;
if (! empty($args['plan_slug'])) {
$plan = AgentPlan::where('slug', $args['plan_slug'])->first();
}
$sessionId = 'ses_'.Str::random(12);
// Determine workspace_id - never fall back to hardcoded value in multi-tenant environment
$workspaceId = $context['workspace_id'] ?? $plan?->workspace_id ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required but could not be determined from context or plan');
}
$session = AgentSession::create([
'session_id' => $sessionId,
'agent_plan_id' => $plan?->id,
'workspace_id' => $workspaceId,
'agent_type' => $agentType,
'status' => 'active',
'started_at' => now(),
'last_active_at' => now(),
'context_summary' => $args['context'] ?? [],
'work_log' => [],
'artifacts' => [],
]);
try {
$session = StartSession::run(
$args['agent_type'] ?? '',
$args['plan_slug'] ?? null,
(int) $workspaceId,
$args['context'] ?? [],
);
return $this->success([
'session' => [
'session_id' => $session->session_id,
'agent_type' => $session->agent_type,
'plan' => $plan?->slug,
'plan' => $session->plan?->slug,
'status' => $session->status,
],
]);
}, fn () => $this->error('Agentic service temporarily unavailable. Session cannot be created.', 'service_unavailable'));
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
}
}

View file

@ -71,7 +71,7 @@ class StateGet extends AgentTool
// Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required for state operations');
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
}
// Query plan with workspace scope to prevent cross-tenant access

View file

@ -70,7 +70,7 @@ class StateList extends AgentTool
// Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required for state operations');
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
}
// Query plan with workspace scope to prevent cross-tenant access

View file

@ -7,7 +7,7 @@ namespace Core\Mod\Agentic\Mcp\Tools\Agent\State;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\AgentWorkspaceState;
use Core\Mod\Agentic\Models\WorkspaceState;
/**
* Set a workspace state value.
@ -81,7 +81,7 @@ class StateSet extends AgentTool
// Validate workspace context for tenant isolation
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required for state operations');
return $this->error('workspace_id is required. Ensure you have authenticated with a valid API key and started a session. See: https://host.uk.com/ai');
}
// Query plan with workspace scope to prevent cross-tenant access
@ -93,7 +93,7 @@ class StateSet extends AgentTool
return $this->error("Plan not found: {$planSlug}");
}
$state = AgentWorkspaceState::updateOrCreate(
$state = WorkspaceState::updateOrCreate(
[
'agent_plan_id' => $plan->id,
'key' => $key,

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Task;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Task\ToggleTask;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Toggle a task completion status.
@ -64,66 +63,22 @@ class TaskToggle extends AgentTool
public function handle(array $args, array $context = []): array
{
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
try {
$planSlug = $this->requireString($args, 'plan_slug', 255);
$phaseIdentifier = $this->requireString($args, 'phase', 255);
$taskIndex = $this->requireInt($args, 'task_index', min: 0, max: 1000);
$result = ToggleTask::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
(int) ($args['task_index'] ?? 0),
(int) $workspaceId,
);
return $this->success($result);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
$tasks = $phase->tasks ?? [];
if (! isset($tasks[$taskIndex])) {
return $this->error("Task not found at index: {$taskIndex}");
}
$currentStatus = is_string($tasks[$taskIndex])
? 'pending'
: ($tasks[$taskIndex]['status'] ?? 'pending');
$newStatus = $currentStatus === 'completed' ? 'pending' : 'completed';
if (is_string($tasks[$taskIndex])) {
$tasks[$taskIndex] = [
'name' => $tasks[$taskIndex],
'status' => $newStatus,
];
} else {
$tasks[$taskIndex]['status'] = $newStatus;
}
$phase->update(['tasks' => $tasks]);
return $this->success([
'task' => $tasks[$taskIndex],
'plan_progress' => $plan->fresh()->getProgress(),
]);
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where('name', $identifier)
->first();
}
}

View file

@ -5,9 +5,8 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Mcp\Tools\Agent\Task;
use Core\Mcp\Dependencies\ToolDependency;
use Core\Mod\Agentic\Actions\Task\UpdateTask;
use Core\Mod\Agentic\Mcp\Tools\Agent\AgentTool;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Update task details (status, notes).
@ -73,71 +72,24 @@ class TaskUpdate extends AgentTool
public function handle(array $args, array $context = []): array
{
try {
$planSlug = $this->requireString($args, 'plan_slug', 255);
$phaseIdentifier = $this->requireString($args, 'phase', 255);
$taskIndex = $this->requireInt($args, 'task_index', min: 0, max: 1000);
$workspaceId = $context['workspace_id'] ?? null;
if ($workspaceId === null) {
return $this->error('workspace_id is required');
}
// Validate optional status enum
$status = $this->optionalEnum($args, 'status', ['pending', 'in_progress', 'completed', 'blocked', 'skipped']);
$notes = $this->optionalString($args, 'notes', null, 5000);
try {
$result = UpdateTask::run(
$args['plan_slug'] ?? '',
$args['phase'] ?? '',
(int) ($args['task_index'] ?? 0),
(int) $workspaceId,
$args['status'] ?? null,
$args['notes'] ?? null,
);
return $this->success($result);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage());
}
$plan = AgentPlan::where('slug', $planSlug)->first();
if (! $plan) {
return $this->error("Plan not found: {$planSlug}");
}
$phase = $this->findPhase($plan, $phaseIdentifier);
if (! $phase) {
return $this->error("Phase not found: {$phaseIdentifier}");
}
$tasks = $phase->tasks ?? [];
if (! isset($tasks[$taskIndex])) {
return $this->error("Task not found at index: {$taskIndex}");
}
// Normalise task to array format
if (is_string($tasks[$taskIndex])) {
$tasks[$taskIndex] = ['name' => $tasks[$taskIndex], 'status' => 'pending'];
}
// Update fields using pre-validated values
if ($status !== null) {
$tasks[$taskIndex]['status'] = $status;
}
if ($notes !== null) {
$tasks[$taskIndex]['notes'] = $notes;
}
$phase->update(['tasks' => $tasks]);
return $this->success([
'task' => $tasks[$taskIndex],
]);
}
/**
* Find a phase by order number or name.
*/
protected function findPhase(AgentPlan $plan, string|int $identifier): ?AgentPhase
{
if (is_numeric($identifier)) {
return $plan->agentPhases()->where('order', (int) $identifier)->first();
}
return $plan->agentPhases()
->where(function ($query) use ($identifier) {
$query->where('name', $identifier)
->orWhere('order', $identifier);
})
->first();
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Add soft delete support and archived_at timestamp to agent_plans.
*
* - archived_at: dedicated timestamp for when a plan was archived, used by
* the retention cleanup command to determine when to permanently delete.
* - deleted_at: standard Laravel soft-delete column.
*/
public function up(): void
{
Schema::table('agent_plans', function (Blueprint $table) {
$table->timestamp('archived_at')->nullable()->after('source_file');
$table->softDeletes();
});
}
public function down(): void
{
Schema::table('agent_plans', function (Blueprint $table) {
$table->dropColumn('archived_at');
$table->dropSoftDeletes();
});
}
};

View file

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Create plan_template_versions table and add template_version_id to agent_plans.
*
* Template versions snapshot YAML template content at plan-creation time so
* existing plans are never affected when a template file is updated.
*
* Deduplication: identical content reuses the same version row (same content_hash).
*
* Guarded with hasTable()/hasColumn() so this migration is idempotent and
* can coexist with a consolidated app-level migration.
*/
public function up(): void
{
Schema::disableForeignKeyConstraints();
if (! Schema::hasTable('plan_template_versions')) {
Schema::create('plan_template_versions', function (Blueprint $table) {
$table->id();
$table->string('slug');
$table->unsignedInteger('version');
$table->string('name');
$table->json('content');
$table->char('content_hash', 64);
$table->timestamps();
$table->unique(['slug', 'version']);
$table->index(['slug', 'content_hash']);
});
}
if (Schema::hasTable('agent_plans') && ! Schema::hasColumn('agent_plans', 'template_version_id')) {
Schema::table('agent_plans', function (Blueprint $table) {
$table->foreignId('template_version_id')
->nullable()
->constrained('plan_template_versions')
->nullOnDelete()
->after('source_file');
});
}
Schema::enableForeignKeyConstraints();
}
public function down(): void
{
Schema::disableForeignKeyConstraints();
if (Schema::hasTable('agent_plans') && Schema::hasColumn('agent_plans', 'template_version_id')) {
Schema::table('agent_plans', function (Blueprint $table) {
$table->dropForeign(['template_version_id']);
$table->dropColumn('template_version_id');
});
}
Schema::dropIfExists('plan_template_versions');
Schema::enableForeignKeyConstraints();
}
};

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/** Use the dedicated brain connection so the table lands in the right database. */
protected $connection = 'brain';
/**
* Create brain_memories table for OpenBrain shared knowledge store.
*
* Guarded with hasTable() so this migration is idempotent and
* can coexist with the consolidated app-level migration.
*
* No FK to workspaces the brain database may be remote and
* the workspaces table only exists in the app database.
*/
public function up(): void
{
$schema = Schema::connection($this->getConnection());
if (! $schema->hasTable('brain_memories')) {
$schema->create('brain_memories', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->unsignedBigInteger('workspace_id');
$table->string('agent_id', 64);
$table->string('type', 32)->index();
$table->text('content');
$table->json('tags')->nullable();
$table->string('project', 128)->nullable()->index();
$table->float('confidence')->default(0.8);
$table->uuid('supersedes_id')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index('workspace_id');
$table->index('agent_id');
$table->index(['workspace_id', 'type']);
$table->index(['workspace_id', 'project']);
$table->foreign('supersedes_id')
->references('id')
->on('brain_memories')
->nullOnDelete();
});
}
}
public function down(): void
{
Schema::connection($this->getConnection())->dropIfExists('brain_memories');
}
};

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Drop the workspace_id foreign key from brain_memories.
*
* The brain database may be remote (co-located with Qdrant on the homelab),
* so cross-database FK constraints to the app's workspaces table are not
* possible. The column stays as a plain indexed integer.
*/
return new class extends Migration
{
protected $connection = 'brain';
public function up(): void
{
$schema = Schema::connection($this->getConnection());
if (! $schema->hasTable('brain_memories')) {
return;
}
$schema->table('brain_memories', function (Blueprint $table) {
try {
$table->dropForeign(['workspace_id']);
} catch (\Throwable) {
// FK doesn't exist — fresh install, nothing to drop.
}
});
}
public function down(): void
{
// Not re-adding the FK — it was only valid when brain and app shared a database.
}
};

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('agent_sessions', function (Blueprint $table) {
$table->renameColumn('uuid', 'session_id');
$table->renameColumn('last_activity_at', 'last_active_at');
});
// Change column type from uuid to string to allow prefixed IDs (sess_...)
Schema::table('agent_sessions', function (Blueprint $table) {
$table->string('session_id')->unique()->change();
});
}
public function down(): void
{
Schema::table('agent_sessions', function (Blueprint $table) {
$table->renameColumn('session_id', 'uuid');
$table->renameColumn('last_active_at', 'last_activity_at');
});
}
};

View file

@ -317,11 +317,17 @@ class AgentPhase extends Model
public function checkDependencies(): array
{
$dependencies = $this->dependencies ?? [];
if (empty($dependencies)) {
return [];
}
$blockers = [];
foreach ($dependencies as $depId) {
$dep = AgentPhase::find($depId);
if ($dep && ! $dep->isCompleted() && ! $dep->isSkipped()) {
$deps = AgentPhase::whereIn('id', $dependencies)->get();
foreach ($deps as $dep) {
if (! $dep->isCompleted() && ! $dep->isSkipped()) {
$blockers[] = [
'phase_id' => $dep->id,
'phase_order' => $dep->order,

View file

@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
@ -33,6 +34,8 @@ use Spatie\Activitylog\Traits\LogsActivity;
* @property string|null $current_phase
* @property array|null $metadata
* @property string|null $source_file
* @property \Carbon\Carbon|null $archived_at
* @property \Carbon\Carbon|null $deleted_at
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
@ -44,6 +47,7 @@ class AgentPlan extends Model
use HasFactory;
use LogsActivity;
use SoftDeletes;
protected static function newFactory(): AgentPlanFactory
{
@ -61,12 +65,15 @@ class AgentPlan extends Model
'current_phase',
'metadata',
'source_file',
'archived_at',
'template_version_id',
];
protected $casts = [
'context' => 'array',
'phases' => 'array',
'metadata' => 'array',
'archived_at' => 'datetime',
];
// Status constants
@ -96,7 +103,12 @@ class AgentPlan extends Model
public function states(): HasMany
{
return $this->hasMany(AgentWorkspaceState::class);
return $this->hasMany(WorkspaceState::class);
}
public function templateVersion(): BelongsTo
{
return $this->belongsTo(PlanTemplateVersion::class, 'template_version_id');
}
// Scopes
@ -166,11 +178,11 @@ class AgentPlan extends Model
$metadata = $this->metadata ?? [];
if ($reason) {
$metadata['archive_reason'] = $reason;
$metadata['archived_at'] = now()->toIso8601String();
}
$this->update([
'status' => self::STATUS_ARCHIVED,
'archived_at' => now(),
'metadata' => $metadata,
]);
@ -228,7 +240,7 @@ class AgentPlan extends Model
return $state?->value;
}
public function setState(string $key, mixed $value, string $type = 'json', ?string $description = null): AgentWorkspaceState
public function setState(string $key, mixed $value, string $type = 'json', ?string $description = null): WorkspaceState
{
return $this->states()->updateOrCreate(
['key' => $key],

View file

@ -1,116 +0,0 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Agent Workspace State - shared context between sessions within a plan.
*
* Stores key-value data that persists across agent sessions,
* enabling context sharing and state recovery.
*
* @property int $id
* @property int $agent_plan_id
* @property string $key
* @property array $value
* @property string $type
* @property string|null $description
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class AgentWorkspaceState extends Model
{
protected $table = 'agent_workspace_states';
protected $fillable = [
'agent_plan_id',
'key',
'value',
'type',
'description',
];
protected $casts = [
'value' => 'array',
];
// Type constants
public const TYPE_JSON = 'json';
public const TYPE_MARKDOWN = 'markdown';
public const TYPE_CODE = 'code';
public const TYPE_REFERENCE = 'reference';
// Relationships
public function plan(): BelongsTo
{
return $this->belongsTo(AgentPlan::class, 'agent_plan_id');
}
// Scopes
public function scopeForPlan(Builder $query, AgentPlan|int $plan): Builder
{
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
return $query->where('agent_plan_id', $planId);
}
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
// Helpers
public function isJson(): bool
{
return $this->type === self::TYPE_JSON;
}
public function isMarkdown(): bool
{
return $this->type === self::TYPE_MARKDOWN;
}
public function isCode(): bool
{
return $this->type === self::TYPE_CODE;
}
public function isReference(): bool
{
return $this->type === self::TYPE_REFERENCE;
}
public function getValue(): mixed
{
return $this->value;
}
public function getFormattedValue(): string
{
if ($this->isMarkdown() || $this->isCode()) {
return is_string($this->value) ? $this->value : json_encode($this->value, JSON_PRETTY_PRINT);
}
return json_encode($this->value, JSON_PRETTY_PRINT);
}
// Output
public function toMcpContext(): array
{
return [
'key' => $this->key,
'type' => $this->type,
'description' => $this->description,
'value' => $this->value,
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

190
Models/BrainMemory.php Normal file
View file

@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Core\Tenant\Concerns\BelongsToWorkspace;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Brain Memory - a unit of shared knowledge in the OpenBrain store.
*
* Agents write observations, decisions, conventions, and research
* into the brain so that other agents (and future sessions) can
* recall organisational knowledge without re-discovering it.
*
* @property string $id
* @property int $workspace_id
* @property string $agent_id
* @property string $type
* @property string $content
* @property array|null $tags
* @property string|null $project
* @property float $confidence
* @property string|null $supersedes_id
* @property \Carbon\Carbon|null $expires_at
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
* @property \Carbon\Carbon|null $deleted_at
*/
class BrainMemory extends Model
{
use BelongsToWorkspace;
use HasUuids;
use SoftDeletes;
/** Valid memory types. */
public const VALID_TYPES = [
'decision',
'observation',
'convention',
'research',
'plan',
'bug',
'architecture',
];
protected $connection = 'brain';
protected $table = 'brain_memories';
protected $fillable = [
'workspace_id',
'agent_id',
'type',
'content',
'tags',
'project',
'confidence',
'supersedes_id',
'expires_at',
];
protected $casts = [
'tags' => 'array',
'confidence' => 'float',
'expires_at' => 'datetime',
];
// ----------------------------------------------------------------
// Relationships
// ----------------------------------------------------------------
public function workspace(): BelongsTo
{
return $this->belongsTo(Workspace::class);
}
/** The older memory this one replaces. */
public function supersedes(): BelongsTo
{
return $this->belongsTo(self::class, 'supersedes_id');
}
/** Newer memories that replaced this one. */
public function supersededBy(): HasMany
{
return $this->hasMany(self::class, 'supersedes_id');
}
// ----------------------------------------------------------------
// Scopes
// ----------------------------------------------------------------
public function scopeForWorkspace(Builder $query, int $workspaceId): Builder
{
return $query->where('workspace_id', $workspaceId);
}
public function scopeOfType(Builder $query, string|array $type): Builder
{
return is_array($type)
? $query->whereIn('type', $type)
: $query->where('type', $type);
}
public function scopeForProject(Builder $query, ?string $project): Builder
{
return $project
? $query->where('project', $project)
: $query;
}
public function scopeByAgent(Builder $query, ?string $agentId): Builder
{
return $agentId
? $query->where('agent_id', $agentId)
: $query;
}
/** Exclude memories whose TTL has passed. */
public function scopeActive(Builder $query): Builder
{
return $query->where(function (Builder $q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
}
/** Exclude memories that have been superseded by a newer version. */
public function scopeLatestVersions(Builder $query): Builder
{
return $query->whereDoesntHave('supersededBy', function (Builder $q) {
$q->whereNull('deleted_at');
});
}
// ----------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------
/**
* Walk the supersession chain and return its depth.
*
* A memory that supersedes nothing returns 0.
* Capped at 50 to prevent runaway loops.
*/
public function getSupersessionDepth(): int
{
$depth = 0;
$current = $this;
$maxDepth = 50;
while ($current->supersedes_id !== null && $depth < $maxDepth) {
$current = $current->supersedes;
if ($current === null) {
break;
}
$depth++;
}
return $depth;
}
/** Format the memory for MCP tool responses. */
public function toMcpContext(): array
{
return [
'id' => $this->id,
'agent_id' => $this->agent_id,
'type' => $this->type,
'content' => $this->content,
'tags' => $this->tags,
'project' => $this->project,
'confidence' => $this->confidence,
'supersedes_id' => $this->supersedes_id,
'expires_at' => $this->expires_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Models;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Plan Template Version - immutable snapshot of a YAML template's content.
*
* When a plan is created from a template, the template content is snapshotted
* here so future edits to the YAML file do not affect existing plans.
*
* Identical content is deduplicated via content_hash so no duplicate rows
* accumulate when the same (unchanged) template is used repeatedly.
*
* @property int $id
* @property string $slug Template file slug (filename without extension)
* @property int $version Sequential version number per slug
* @property string $name Template name at snapshot time
* @property array $content Full template content as JSON
* @property string $content_hash SHA-256 of json_encode($content)
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class PlanTemplateVersion extends Model
{
protected $fillable = [
'slug',
'version',
'name',
'content',
'content_hash',
];
protected $casts = [
'content' => 'array',
'version' => 'integer',
];
/**
* Plans that were created from this template version.
*/
public function plans(): HasMany
{
return $this->hasMany(AgentPlan::class, 'template_version_id');
}
/**
* Find an existing version by content hash, or create a new one.
*
* Deduplicates identical template content so we don't store redundant rows
* when the same (unchanged) template is used multiple times.
*/
public static function findOrCreateFromTemplate(string $slug, array $content): self
{
$hash = hash('sha256', json_encode($content, JSON_UNESCAPED_UNICODE));
$existing = static::where('slug', $slug)
->where('content_hash', $hash)
->first();
if ($existing) {
return $existing;
}
$nextVersion = (static::where('slug', $slug)->max('version') ?? 0) + 1;
return static::create([
'slug' => $slug,
'version' => $nextVersion,
'name' => $content['name'] ?? $slug,
'content' => $content,
'content_hash' => $hash,
]);
}
/**
* Get all recorded versions for a template slug, newest first.
*
* @return Collection<int, static>
*/
public static function historyFor(string $slug): Collection
{
return static::where('slug', $slug)
->orderByDesc('version')
->get();
}
}

View file

@ -12,12 +12,25 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Workspace State Model
*
* Key-value state storage for agent plans with typed content.
* Persistent key-value state storage for agent plans.
* Stores typed values shared across agent sessions within a plan,
* enabling context sharing and state recovery.
*
* @property int $id
* @property int $agent_plan_id
* @property string $key
* @property array $value
* @property string $type
* @property string|null $description
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class WorkspaceState extends Model
{
use HasFactory;
protected $table = 'agent_workspace_states';
public const TYPE_JSON = 'json';
public const TYPE_MARKDOWN = 'markdown';
@ -31,34 +44,27 @@ class WorkspaceState extends Model
'key',
'value',
'type',
'metadata',
'description',
];
protected $casts = [
'metadata' => 'array',
'value' => 'array',
];
protected $attributes = [
'type' => self::TYPE_JSON,
'metadata' => '{}',
];
// Relationships
public function plan(): BelongsTo
{
return $this->belongsTo(AgentPlan::class, 'agent_plan_id');
}
/**
* Get typed value.
*/
public function getTypedValue(): mixed
{
return match ($this->type) {
self::TYPE_JSON => json_decode($this->value, true),
default => $this->value,
};
}
// Scopes
public function scopeForPlan($query, AgentPlan|int $plan): mixed
{
$planId = $plan instanceof AgentPlan ? $plan->id : $plan;
<<<<<<< HEAD
/**
* Set typed value.
*/
@ -145,4 +151,71 @@ class WorkspaceState extends Model
{
return $query->where('type', $type);
}
// Type helpers
public function isJson(): bool
{
return $this->type === self::TYPE_JSON;
}
public function isMarkdown(): bool
{
return $this->type === self::TYPE_MARKDOWN;
}
public function isCode(): bool
{
return $this->type === self::TYPE_CODE;
}
public function isReference(): bool
{
return $this->type === self::TYPE_REFERENCE;
}
public function getFormattedValue(): string
{
if ($this->isMarkdown() || $this->isCode()) {
return is_string($this->value) ? $this->value : json_encode($this->value, JSON_PRETTY_PRINT);
}
return json_encode($this->value, JSON_PRETTY_PRINT);
}
// Static helpers
/**
* Get a state value for a plan, returning $default if not set.
*/
public static function getValue(AgentPlan $plan, string $key, mixed $default = null): mixed
{
$state = static::where('agent_plan_id', $plan->id)->where('key', $key)->first();
return $state !== null ? $state->value : $default;
}
/**
* Set (upsert) a state value for a plan.
*/
public static function setValue(AgentPlan $plan, string $key, mixed $value, string $type = self::TYPE_JSON): self
{
return static::updateOrCreate(
['agent_plan_id' => $plan->id, 'key' => $key],
['value' => $value, 'type' => $type]
);
}
// MCP output
public function toMcpContext(): array
{
return [
'key' => $this->key,
'type' => $this->type,
'description' => $this->description,
'value' => $this->value,
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

60
Routes/api.php Normal file
View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
use Core\Mod\Agentic\Controllers\AgentApiController;
use Core\Mod\Agentic\Middleware\AgentApiAuth;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Agent API Routes
|--------------------------------------------------------------------------
|
| REST endpoints for the go-agentic Client (dispatch watch).
| Protected by AgentApiAuth middleware with Bearer token.
|
| Routes at /v1/* (Go client uses BaseURL + "/v1/...")
|
*/
// Health check (no auth required)
Route::get('v1/health', [AgentApiController::class, 'health']);
// Authenticated agent endpoints
Route::middleware(AgentApiAuth::class.':plans.read')->group(function () {
// Plans (read)
Route::get('v1/plans', [AgentApiController::class, 'listPlans']);
Route::get('v1/plans/{slug}', [AgentApiController::class, 'getPlan']);
// Phases (read)
Route::get('v1/plans/{slug}/phases/{phase}', [AgentApiController::class, 'getPhase']);
// Sessions (read)
Route::get('v1/sessions', [AgentApiController::class, 'listSessions']);
Route::get('v1/sessions/{sessionId}', [AgentApiController::class, 'getSession']);
});
Route::middleware(AgentApiAuth::class.':plans.write')->group(function () {
// Plans (write)
Route::post('v1/plans', [AgentApiController::class, 'createPlan']);
Route::patch('v1/plans/{slug}', [AgentApiController::class, 'updatePlan']);
Route::delete('v1/plans/{slug}', [AgentApiController::class, 'archivePlan']);
});
Route::middleware(AgentApiAuth::class.':phases.write')->group(function () {
// Phases (write)
Route::patch('v1/plans/{slug}/phases/{phase}', [AgentApiController::class, 'updatePhase']);
Route::post('v1/plans/{slug}/phases/{phase}/checkpoint', [AgentApiController::class, 'addCheckpoint']);
Route::patch('v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}', [AgentApiController::class, 'updateTask'])
->whereNumber('taskIdx');
Route::post('v1/plans/{slug}/phases/{phase}/tasks/{taskIdx}/toggle', [AgentApiController::class, 'toggleTask'])
->whereNumber('taskIdx');
});
Route::middleware(AgentApiAuth::class.':sessions.write')->group(function () {
// Sessions (write)
Route::post('v1/sessions', [AgentApiController::class, 'startSession']);
Route::post('v1/sessions/{sessionId}/end', [AgentApiController::class, 'endSession']);
Route::post('v1/sessions/{sessionId}/continue', [AgentApiController::class, 'continueSession']);
});

View file

@ -156,6 +156,9 @@ class AgentApiKeyService
// Clear rate limit cache
Cache::forget($this->getRateLimitCacheKey($key));
// Clear permitted tools cache so the revoked key can no longer access tools
app(AgentToolRegistry::class)->flushCacheForApiKey($key->id);
}
/**
@ -164,6 +167,9 @@ class AgentApiKeyService
public function updatePermissions(AgentApiKey $key, array $permissions): void
{
$key->updatePermissions($permissions);
// Invalidate cached tool list so the new permissions take effect immediately
app(AgentToolRegistry::class)->flushCacheForApiKey($key->id);
}
/**

View file

@ -17,106 +17,221 @@ use Illuminate\Http\Request;
* - Absence of typical browser indicators
*
* Part of the Trees for Agents system for rewarding AI agent referrals.
*
* Detection priority (highest to lowest):
* 1. MCP token header (X-MCP-Token) registered agents with explicit identity
* 2. User-Agent provider patterns matches known AI client strings
* 3. Non-agent bot patterns rules out search crawlers and monitoring tools
* 4. Browser indicators rules out real browser traffic
* 5. Unknown agent fallback programmatic access with no identifying UA
*
* Usage:
* ```php
* $detection = app(AgentDetection::class);
*
* // From a full HTTP request (checks MCP token first, then User-Agent)
* $identity = $detection->identify($request);
*
* // From a User-Agent string directly
* $identity = $detection->identifyFromUserAgent('claude-code/1.0 anthropic-api');
*
* // Quick boolean check
* if ($detection->isAgent($request)) {
* // credit the referral tree
* }
*
* // Inspect the result
* echo $identity->provider; // e.g. "anthropic"
* echo $identity->model; // e.g. "claude-sonnet" or null
* echo $identity->confidence; // e.g. "high"
* echo $identity->isAgent(); // true / false
* ```
*/
class AgentDetection
{
/**
* User-Agent patterns for known AI providers.
*
* @var array<string, array{pattern: string, model_pattern: ?string}>
* Each entry maps a provider key to an array of detection patterns and optional
* model-specific sub-patterns. Patterns are tested in order; the first match wins.
*
* Provider patterns (case-insensitive):
*
* - anthropic:
* Examples: "claude-code/1.0", "Anthropic-API/2.0 claude-sonnet",
* "Claude AI Assistant/1.0", "claude code (agentic)"
*
* - openai:
* Examples: "ChatGPT-User/1.0", "OpenAI/1.0 python-httpx/0.26",
* "GPT-4-turbo/2024-04", "o1-preview/2024-09", "o1-mini/1.0"
*
* - google:
* Examples: "Google-AI/1.0", "Gemini/1.5-pro", "Google Bard/0.1",
* "PaLM API/1.0 google-generativeai/0.3"
*
* - meta:
* Examples: "Meta AI/1.0", "LLaMA/2.0 meta-ai", "Llama-3/2024-04",
* "Llama-2-chat/70B"
*
* - mistral:
* Examples: "Mistral/0.1.0 mistralai-python/0.1", "Mixtral-8x7B/1.0",
* "MistralAI-Large/latest"
*
* Model patterns narrow the detection to a specific model variant within a provider
* when the User-Agent includes version/model information.
*
* @var array<string, array{patterns: string[], model_patterns: array<string, string>}>
*/
protected const PROVIDER_PATTERNS = [
'anthropic' => [
'patterns' => [
'/claude[\s\-_]?code/i',
'/\banthopic\b/i',
'/\banthropic[\s\-_]?api\b/i',
'/\bclaude\b.*\bai\b/i',
'/\bclaude\b.*\bassistant\b/i',
'/claude[\s\-_]?code/i', // e.g. "claude-code/1.0", "claude code"
'/\banthopic\b/i', // e.g. "Anthropic/1.0" (intentional typo tolerance)
'/\banthropic[\s\-_]?api\b/i', // e.g. "Anthropic-API/2.0"
'/\bclaude\b.*\bai\b/i', // e.g. "Claude AI Assistant/1.0"
'/\bclaude\b.*\bassistant\b/i', // e.g. "Claude-Assistant/2.1"
],
'model_patterns' => [
'claude-opus' => '/claude[\s\-_]?opus/i',
'claude-sonnet' => '/claude[\s\-_]?sonnet/i',
'claude-haiku' => '/claude[\s\-_]?haiku/i',
'claude-opus' => '/claude[\s\-_]?opus/i', // e.g. "claude-opus-4-5"
'claude-sonnet' => '/claude[\s\-_]?sonnet/i', // e.g. "claude-sonnet-4-6"
'claude-haiku' => '/claude[\s\-_]?haiku/i', // e.g. "claude-haiku-4-5"
],
],
'openai' => [
'patterns' => [
'/\bChatGPT\b/i',
'/\bOpenAI\b/i',
'/\bGPT[\s\-_]?4\b/i',
'/\bGPT[\s\-_]?3\.?5\b/i',
'/\bo1[\s\-_]?preview\b/i',
'/\bo1[\s\-_]?mini\b/i',
'/\bChatGPT\b/i', // e.g. "ChatGPT-User/1.0"
'/\bOpenAI\b/i', // e.g. "OpenAI/1.0 python-httpx/0.26"
'/\bGPT[\s\-_]?4\b/i', // e.g. "GPT-4-turbo/2024-04"
'/\bGPT[\s\-_]?3\.?5\b/i', // e.g. "GPT-3.5-turbo/1.0"
'/\bo1[\s\-_]?preview\b/i', // e.g. "o1-preview/2024-09"
'/\bo1[\s\-_]?mini\b/i', // e.g. "o1-mini/1.0"
],
'model_patterns' => [
'gpt-4' => '/\bGPT[\s\-_]?4/i',
'gpt-3.5' => '/\bGPT[\s\-_]?3\.?5/i',
'o1' => '/\bo1[\s\-_]?(preview|mini)?\b/i',
'gpt-4' => '/\bGPT[\s\-_]?4/i', // e.g. "GPT-4o", "GPT-4-turbo"
'gpt-3.5' => '/\bGPT[\s\-_]?3\.?5/i', // e.g. "GPT-3.5-turbo"
'o1' => '/\bo1[\s\-_]?(preview|mini)?\b/i', // e.g. "o1", "o1-preview", "o1-mini"
],
],
'google' => [
'patterns' => [
'/\bGoogle[\s\-_]?AI\b/i',
'/\bGemini\b/i',
'/\bBard\b/i',
'/\bPaLM\b/i',
'/\bGoogle[\s\-_]?AI\b/i', // e.g. "Google-AI/1.0"
'/\bGemini\b/i', // e.g. "Gemini/1.5-pro", "gemini-flash"
'/\bBard\b/i', // e.g. "Google Bard/0.1" (legacy)
'/\bPaLM\b/i', // e.g. "PaLM API/1.0" (legacy)
],
'model_patterns' => [
'gemini-pro' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i',
'gemini-ultra' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i',
'gemini-flash' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i',
'gemini-pro' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?pro/i', // e.g. "gemini-1.5-pro"
'gemini-ultra' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?ultra/i', // e.g. "gemini-ultra"
'gemini-flash' => '/gemini[\s\-_]?(1\.5[\s\-_]?)?flash/i', // e.g. "gemini-1.5-flash"
],
],
'meta' => [
'patterns' => [
'/\bMeta[\s\-_]?AI\b/i',
'/\bLLaMA\b/i',
'/\bLlama[\s\-_]?[23]\b/i',
'/\bMeta[\s\-_]?AI\b/i', // e.g. "Meta AI/1.0"
'/\bLLaMA\b/i', // e.g. "LLaMA/2.0 meta-ai"
'/\bLlama[\s\-_]?[23]\b/i', // e.g. "Llama-3/2024-04", "Llama-2-chat"
],
'model_patterns' => [
'llama-3' => '/llama[\s\-_]?3/i',
'llama-2' => '/llama[\s\-_]?2/i',
'llama-3' => '/llama[\s\-_]?3/i', // e.g. "Llama-3-8B", "llama3-70b"
'llama-2' => '/llama[\s\-_]?2/i', // e.g. "Llama-2-chat/70B"
],
],
'mistral' => [
'patterns' => [
'/\bMistral\b/i',
'/\bMixtral\b/i',
'/\bMistral\b/i', // e.g. "Mistral/0.1.0 mistralai-python/0.1"
'/\bMixtral\b/i', // e.g. "Mixtral-8x7B/1.0"
],
'model_patterns' => [
'mistral-large' => '/mistral[\s\-_]?large/i',
'mistral-medium' => '/mistral[\s\-_]?medium/i',
'mixtral' => '/mixtral/i',
'mistral-large' => '/mistral[\s\-_]?large/i', // e.g. "mistral-large-latest"
'mistral-medium' => '/mistral[\s\-_]?medium/i', // e.g. "mistral-medium"
'mixtral' => '/mixtral/i', // e.g. "Mixtral-8x7B-Instruct"
],
],
];
/**
* Patterns that indicate a typical web browser.
* If none of these are present, it might be programmatic access.
*
* If none of these tokens appear in a User-Agent string, the request is likely
* programmatic (a script, CLI tool, or potential agent). The patterns cover all
* major browser families and legacy rendering engine identifiers.
*
* Examples of matching User-Agents:
* - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0"
* - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) ... Safari/537.36"
* - "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0"
* - "Mozilla/5.0 ... Edg/120.0" Microsoft Edge (Chromium)
* - "Opera/9.80 ... OPR/106.0" Opera
* - "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1)" Internet Explorer
* - "Mozilla/5.0 ... Trident/7.0; rv:11.0" IE 11 (Trident engine)
*/
protected const BROWSER_INDICATORS = [
'/\bMozilla\b/i',
'/\bChrome\b/i',
'/\bSafari\b/i',
'/\bFirefox\b/i',
'/\bEdge\b/i',
'/\bOpera\b/i',
'/\bMSIE\b/i',
'/\bTrident\b/i',
'/\bMozilla\b/i', // All Gecko/WebKit/Blink browsers include "Mozilla/5.0"
'/\bChrome\b/i', // Chrome, Chromium, and most Chromium-based browsers
'/\bSafari\b/i', // Safari and WebKit-based browsers
'/\bFirefox\b/i', // Mozilla Firefox
'/\bEdge\b/i', // Microsoft Edge (legacy "Edge/" and Chromium "Edg/")
'/\bOpera\b/i', // Opera ("Opera/" classic, "OPR/" modern)
'/\bMSIE\b/i', // Internet Explorer (e.g. "MSIE 11.0")
'/\bTrident\b/i', // IE 11 Trident rendering engine token
];
/**
* Known bot patterns that are NOT AI agents.
* These should return notAnAgent, not unknown.
*
* These should resolve to `AgentIdentity::notAnAgent()` rather than
* `AgentIdentity::unknownAgent()`, because we can positively identify them
* as a specific non-AI automated client (crawler, monitoring, HTTP library, etc.).
*
* Categories and example User-Agents:
*
* Search engine crawlers:
* - "Googlebot/2.1 (+http://www.google.com/bot.html)"
* - "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"
* - "Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)"
* - "DuckDuckBot/1.0; (+http://duckduckgo.com/duckduckbot.html)"
* - "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)"
* - "Applebot/0.1 (+http://www.apple.com/go/applebot)"
*
* Social media / link-preview bots:
* - "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"
* - "Twitterbot/1.0"
* - "LinkedInBot/1.0 (compatible; Mozilla/5.0; Apache-HttpClient/4.5)"
* - "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"
* - "DiscordBot (https://discordapp.com) 1.0"
* - "TelegramBot (like TwitterBot)"
* - "WhatsApp/2.23.20 A"
*
* SEO / analytics crawlers:
* - "Mozilla/5.0 (compatible; SemrushBot/7~bl; +http://www.semrush.com/bot.html)"
* - "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"
*
* Generic HTTP clients (scripts, developer tools):
* - "curl/7.88.1"
* - "Wget/1.21.4"
* - "python-requests/2.31.0"
* - "Go-http-client/2.0"
* - "PostmanRuntime/7.35.0"
* - "insomnia/2023.5.8"
* - "axios/1.6.0"
* - "node-fetch/2.6.11"
*
* Uptime / monitoring services:
* - "UptimeRobot/2.0 (+http://www.uptimerobot.com/)"
* - "Pingdom.com_bot_version_1.4 (http://www.pingdom.com/)"
* - "Datadog Agent/7.45.0"
* - "NewRelicPinger/v1 AccountId=12345"
*/
protected const NON_AGENT_BOTS = [
// Search engine crawlers
'/\bGooglebot\b/i',
'/\bBingbot\b/i',
'/\bYandexBot\b/i',
'/\bDuckDuckBot\b/i',
'/\bBaiduspider\b/i',
'/\bApplebot\b/i',
// Social media / link-preview bots
'/\bfacebookexternalhit\b/i',
'/\bTwitterbot\b/i',
'/\bLinkedInBot\b/i',
@ -124,17 +239,22 @@ class AgentDetection
'/\bDiscordBot\b/i',
'/\bTelegramBot\b/i',
'/\bWhatsApp\//i',
'/\bApplebot\b/i',
// SEO / analytics crawlers
'/\bSEMrushBot\b/i',
'/\bAhrefsBot\b/i',
// Generic HTTP clients
'/\bcurl\b/i',
'/\bwget\b/i',
'/\bpython-requests\b/i',
'/\bgo-http-client\b/i',
'/\bPostman\b/i',
'/\bPostman/i',
'/\bInsomnia\b/i',
'/\baxios\b/i',
'/\bnode-fetch\b/i',
// Uptime / monitoring services
'/\bUptimeRobot\b/i',
'/\bPingdom\b/i',
'/\bDatadog\b/i',
@ -142,7 +262,19 @@ class AgentDetection
];
/**
* The MCP token header name.
* The MCP token header used to identify registered AI agents.
*
* Agents send this header to bypass User-Agent heuristics and declare their
* identity explicitly. Two token formats are supported:
*
* - Opaque AgentApiKey token (prefix "ak_"):
* Looked up in the database. Grants highest confidence when the key is active.
* Example: `X-MCP-Token: ak_a1b2c3d4e5f6...`
*
* - Structured provider:model:secret token:
* Encodes provider and model directly in the token value.
* Example: `X-MCP-Token: anthropic:claude-sonnet:mysecret`
* Example: `X-MCP-Token: openai:gpt-4:xyz789`
*/
protected const MCP_TOKEN_HEADER = 'X-MCP-Token';

View file

@ -9,6 +9,7 @@ use Core\Mcp\Dependencies\HasDependencies;
use Core\Mcp\Services\ToolDependencyService;
use Core\Mod\Agentic\Mcp\Tools\Agent\Contracts\AgentToolInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
/**
* Registry for MCP Agent Server tools.
@ -98,24 +99,57 @@ class AgentToolRegistry
);
}
/**
* Cache TTL for permitted tool lists (1 hour).
*/
public const CACHE_TTL = 3600;
/**
* Get tools accessible by an API key.
*
* Results are cached per API key for {@see CACHE_TTL} seconds to avoid
* repeated O(n) filtering on every request (PERF-002).
* Use {@see flushCacheForApiKey()} to invalidate on permission changes.
*
* @return Collection<string, AgentToolInterface>
*/
public function forApiKey(ApiKey $apiKey): Collection
{
return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) {
// Check if API key has required scopes
foreach ($tool->requiredScopes() as $scope) {
if (! $apiKey->hasScope($scope)) {
return false;
}
}
$cacheKey = $this->apiKeyCacheKey($apiKey->getKey());
// Check if API key has tool-level permission
return $this->apiKeyCanAccessTool($apiKey, $tool->name());
$permittedNames = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($apiKey) {
return $this->all()->filter(function (AgentToolInterface $tool) use ($apiKey) {
// Check if API key has required scopes
foreach ($tool->requiredScopes() as $scope) {
if (! $apiKey->hasScope($scope)) {
return false;
}
}
// Check if API key has tool-level permission
return $this->apiKeyCanAccessTool($apiKey, $tool->name());
})->keys()->all();
});
return $this->all()->only($permittedNames);
}
/**
* Flush the cached tool list for an API key.
*
* Call this whenever an API key's permissions or tool scopes change.
*/
public function flushCacheForApiKey(int|string $apiKeyId): void
{
Cache::forget($this->apiKeyCacheKey($apiKeyId));
}
/**
* Build the cache key for a given API key ID.
*/
private function apiKeyCacheKey(int|string $apiKeyId): string
{
return "agent_tool_registry:api_key:{$apiKeyId}";
}
/**

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Core\Mod\Agentic\Services;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
class AgenticManager
@ -91,22 +92,46 @@ class AgenticManager
/**
* Register all AI providers.
*
* Logs a warning for each provider whose API key is absent so that
* misconfiguration is surfaced at boot time rather than on the first
* API call. Set the corresponding environment variable to silence it:
*
* ANTHROPIC_API_KEY Claude
* GOOGLE_AI_API_KEY Gemini
* OPENAI_API_KEY OpenAI
*/
private function registerProviders(): void
{
// Use null coalescing since config() returns null for missing env vars
$claudeKey = config('services.anthropic.api_key') ?? '';
$geminiKey = config('services.google.ai_api_key') ?? '';
$openaiKey = config('services.openai.api_key') ?? '';
if (empty($claudeKey)) {
Log::warning("Agentic: 'claude' provider has no API key configured. Set ANTHROPIC_API_KEY to enable it.");
}
if (empty($geminiKey)) {
Log::warning("Agentic: 'gemini' provider has no API key configured. Set GOOGLE_AI_API_KEY to enable it.");
}
if (empty($openaiKey)) {
Log::warning("Agentic: 'openai' provider has no API key configured. Set OPENAI_API_KEY to enable it.");
}
$this->providers['claude'] = new ClaudeService(
apiKey: config('services.anthropic.api_key') ?? '',
apiKey: $claudeKey,
model: config('services.anthropic.model') ?? 'claude-sonnet-4-20250514',
);
$this->providers['gemini'] = new GeminiService(
apiKey: config('services.google.ai_api_key') ?? '',
apiKey: $geminiKey,
model: config('services.google.ai_model') ?? 'gemini-2.0-flash',
);
$this->providers['openai'] = new OpenAIService(
apiKey: config('services.openai.api_key') ?? '',
apiKey: $openaiKey,
model: config('services.openai.model') ?? 'gpt-4o-mini',
);
}

277
Services/BrainService.php Normal file
View file

@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Models\BrainMemory;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BrainService
{
private const DEFAULT_MODEL = 'embeddinggemma';
private const VECTOR_DIMENSION = 768;
public function __construct(
private string $ollamaUrl = 'http://localhost:11434',
private string $qdrantUrl = 'http://localhost:6334',
private string $collection = 'openbrain',
private string $embeddingModel = self::DEFAULT_MODEL,
private bool $verifySsl = true,
) {}
/**
* Create an HTTP client with common settings.
*/
private function http(int $timeout = 10): \Illuminate\Http\Client\PendingRequest
{
return $this->verifySsl
? Http::timeout($timeout)
: Http::withoutVerifying()->timeout($timeout);
}
/**
* Generate an embedding vector for the given text.
*
* @return array<float>
*
* @throws \RuntimeException
*/
public function embed(string $text): array
{
$response = $this->http(30)
->post("{$this->ollamaUrl}/api/embeddings", [
'model' => $this->embeddingModel,
'prompt' => $text,
]);
if (! $response->successful()) {
throw new \RuntimeException("Ollama embedding failed: {$response->status()}");
}
$embedding = $response->json('embedding');
if (! is_array($embedding) || empty($embedding)) {
throw new \RuntimeException('Ollama returned no embedding vector');
}
return $embedding;
}
/**
* Store a memory in both MariaDB and Qdrant.
*
* Creates the MariaDB record and upserts the Qdrant vector within
* a single DB transaction. If the memory supersedes an older one,
* the old entry is soft-deleted from MariaDB and removed from Qdrant.
*
* @param array<string, mixed> $attributes Fillable attributes for BrainMemory
* @return BrainMemory The created memory
*/
public function remember(array $attributes): BrainMemory
{
$vector = $this->embed($attributes['content']);
return DB::connection('brain')->transaction(function () use ($attributes, $vector) {
$memory = BrainMemory::create($attributes);
$payload = $this->buildQdrantPayload($memory->id, [
'workspace_id' => $memory->workspace_id,
'agent_id' => $memory->agent_id,
'type' => $memory->type,
'tags' => $memory->tags ?? [],
'project' => $memory->project,
'confidence' => $memory->confidence,
'created_at' => $memory->created_at->toIso8601String(),
]);
$payload['vector'] = $vector;
$this->qdrantUpsert([$payload]);
if ($memory->supersedes_id) {
BrainMemory::where('id', $memory->supersedes_id)->delete();
$this->qdrantDelete([$memory->supersedes_id]);
}
return $memory;
});
}
/**
* Semantic search: find memories similar to the query.
*
* @param array<string, mixed> $filter Optional filter criteria
* @return array{memories: array, scores: array<string, float>}
*/
public function recall(string $query, int $topK, array $filter, int $workspaceId): array
{
$vector = $this->embed($query);
$filter['workspace_id'] = $workspaceId;
$qdrantFilter = $this->buildQdrantFilter($filter);
$response = $this->http(10)
->post("{$this->qdrantUrl}/collections/{$this->collection}/points/search", [
'vector' => $vector,
'filter' => $qdrantFilter,
'limit' => $topK,
'with_payload' => false,
]);
if (! $response->successful()) {
throw new \RuntimeException("Qdrant search failed: {$response->status()}");
}
$results = $response->json('result', []);
$ids = array_column($results, 'id');
$scoreMap = [];
foreach ($results as $r) {
$scoreMap[$r['id']] = $r['score'];
}
if (empty($ids)) {
return ['memories' => [], 'scores' => []];
}
$memories = BrainMemory::whereIn('id', $ids)
->forWorkspace($workspaceId)
->active()
->latestVersions()
->get()
->sortBy(fn (BrainMemory $m) => array_search($m->id, $ids))
->values();
return [
'memories' => $memories->map(fn (BrainMemory $m) => $m->toMcpContext())->all(),
'scores' => $scoreMap,
];
}
/**
* Remove a memory from both Qdrant and MariaDB.
*/
public function forget(string $id): void
{
DB::connection('brain')->transaction(function () use ($id) {
BrainMemory::where('id', $id)->delete();
$this->qdrantDelete([$id]);
});
}
/**
* Ensure the Qdrant collection exists, creating it if needed.
*/
public function ensureCollection(): void
{
$response = $this->http(5)
->get("{$this->qdrantUrl}/collections/{$this->collection}");
if ($response->status() === 404) {
$createResponse = $this->http(10)
->put("{$this->qdrantUrl}/collections/{$this->collection}", [
'vectors' => [
'size' => self::VECTOR_DIMENSION,
'distance' => 'Cosine',
],
]);
if (! $createResponse->successful()) {
throw new \RuntimeException("Qdrant collection creation failed: {$createResponse->status()}");
}
Log::info("OpenBrain: created Qdrant collection '{$this->collection}'");
}
}
/**
* Build a Qdrant point payload.
*
* @param array<string, mixed> $metadata
* @return array{id: string, payload: array<string, mixed>}
*/
public function buildQdrantPayload(string $id, array $metadata): array
{
return [
'id' => $id,
'payload' => $metadata,
];
}
/**
* Build a Qdrant filter from criteria.
*
* @param array<string, mixed> $criteria
* @return array{must: array}
*/
public function buildQdrantFilter(array $criteria): array
{
$must = [];
if (isset($criteria['workspace_id'])) {
$must[] = ['key' => 'workspace_id', 'match' => ['value' => $criteria['workspace_id']]];
}
if (isset($criteria['project'])) {
$must[] = ['key' => 'project', 'match' => ['value' => $criteria['project']]];
}
if (isset($criteria['type'])) {
if (is_array($criteria['type'])) {
$must[] = ['key' => 'type', 'match' => ['any' => $criteria['type']]];
} else {
$must[] = ['key' => 'type', 'match' => ['value' => $criteria['type']]];
}
}
if (isset($criteria['agent_id'])) {
$must[] = ['key' => 'agent_id', 'match' => ['value' => $criteria['agent_id']]];
}
if (isset($criteria['min_confidence'])) {
$must[] = ['key' => 'confidence', 'range' => ['gte' => $criteria['min_confidence']]];
}
return ['must' => $must];
}
/**
* Upsert points into Qdrant.
*
* @param array<array> $points
*
* @throws \RuntimeException
*/
private function qdrantUpsert(array $points): void
{
$response = $this->http(10)
->put("{$this->qdrantUrl}/collections/{$this->collection}/points", [
'points' => $points,
]);
if (! $response->successful()) {
Log::error("Qdrant upsert failed: {$response->status()}", ['body' => $response->body()]);
throw new \RuntimeException("Qdrant upsert failed: {$response->status()}");
}
}
/**
* Delete points from Qdrant by ID.
*
* @param array<string> $ids
*/
private function qdrantDelete(array $ids): void
{
$response = $this->http(10)
->post("{$this->qdrantUrl}/collections/{$this->collection}/points/delete", [
'points' => $ids,
]);
if (! $response->successful()) {
Log::warning("Qdrant delete failed: {$response->status()}", ['ids' => $ids, 'body' => $response->body()]);
}
}
}

View file

@ -9,6 +9,8 @@ use Core\Mod\Agentic\Services\Concerns\HasStreamParsing;
use Generator;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class ClaudeService implements AgenticProviderInterface
{
@ -58,28 +60,47 @@ class ClaudeService implements AgenticProviderInterface
);
}
/**
* Stream a completion from Claude.
*
* Yields text chunks as strings on success.
*
* On failure, yields a single error event array and terminates:
* ['type' => 'error', 'message' => string]
*
* @return Generator<string|array{type: 'error', message: string}>
*/
public function stream(
string $systemPrompt,
string $userPrompt,
array $config = []
): Generator {
$response = $this->client()
->withOptions(['stream' => true])
->post(self::API_URL, [
'model' => $config['model'] ?? $this->model,
'max_tokens' => $config['max_tokens'] ?? 4096,
'temperature' => $config['temperature'] ?? 1.0,
'stream' => true,
'system' => $systemPrompt,
'messages' => [
['role' => 'user', 'content' => $userPrompt],
],
try {
$response = $this->client()
->withOptions(['stream' => true])
->post(self::API_URL, [
'model' => $config['model'] ?? $this->model,
'max_tokens' => $config['max_tokens'] ?? 4096,
'temperature' => $config['temperature'] ?? 1.0,
'stream' => true,
'system' => $systemPrompt,
'messages' => [
['role' => 'user', 'content' => $userPrompt],
],
]);
yield from $this->parseSSEStream(
$response->getBody(),
fn (array $data) => $data['delta']['text'] ?? null
);
} catch (Throwable $e) {
Log::error('Claude stream error', [
'message' => $e->getMessage(),
'exception' => $e,
]);
yield from $this->parseSSEStream(
$response->getBody(),
fn (array $data) => $data['delta']['text'] ?? null
);
yield ['type' => 'error', 'message' => $e->getMessage()];
}
}
public function name(): string

View file

@ -119,6 +119,7 @@ trait HasStreamParsing
$inString = false;
$escape = false;
$objectStart = -1;
$scanPos = 0;
while (! $stream->eof()) {
$chunk = $stream->read(8192);
@ -129,9 +130,10 @@ trait HasStreamParsing
$buffer .= $chunk;
// Parse JSON objects from the buffer
// Parse JSON objects from the buffer, continuing from where
// the previous iteration left off to preserve parser state.
$length = strlen($buffer);
$i = 0;
$i = $scanPos;
while ($i < $length) {
$char = $buffer[$i];
@ -176,6 +178,7 @@ trait HasStreamParsing
$buffer = substr($buffer, $i + 1);
$length = strlen($buffer);
$i = -1; // Will be incremented to 0
$scanPos = 0;
$objectStart = -1;
}
}
@ -183,6 +186,9 @@ trait HasStreamParsing
$i++;
}
// Save scan position so we resume from here on the next chunk
$scanPos = $i;
}
}
}

View file

@ -118,15 +118,21 @@ class ContentService
/**
* Generate content for a batch.
*
* Progress is persisted to a state file after each article so the batch
* can be resumed after a partial failure. Call generateBatch() or
* resumeBatch() again to pick up from the last saved state.
*
* @param string $batchId Batch identifier (e.g., 'batch-001-link-getting-started')
* @param string $provider AI provider ('gemini' for bulk, 'claude' for refinement)
* @param bool $dryRun If true, shows what would be generated without creating files
* @param int $maxRetries Extra attempts per article on failure (0 = no retry)
* @return array Generation results
*/
public function generateBatch(
string $batchId,
string $provider = 'gemini',
bool $dryRun = false
bool $dryRun = false,
int $maxRetries = 1,
): array {
$spec = $this->loadBatch($batchId);
if (! $spec) {
@ -144,6 +150,13 @@ class ContentService
$promptTemplate = $this->loadPromptTemplate('help-article');
// Load or initialise progress state (skipped for dry runs)
$progress = null;
if (! $dryRun) {
$progress = $this->loadBatchProgress($batchId)
?? $this->initialiseBatchState($batchId, $spec['articles'] ?? [], $provider);
}
foreach ($spec['articles'] ?? [] as $article) {
$slug = $article['slug'] ?? null;
if (! $slug) {
@ -152,10 +165,13 @@ class ContentService
$draftPath = $this->getDraftPath($spec, $slug);
// Skip if already drafted
// Skip if draft file already exists on disk
if (File::exists($draftPath)) {
$results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'already drafted'];
$results['skipped']++;
if ($progress !== null) {
$progress['articles'][$slug]['status'] = 'skipped';
}
continue;
}
@ -166,20 +182,168 @@ class ContentService
continue;
}
try {
$content = $this->generateArticle($article, $spec, $promptTemplate, $provider);
$this->saveDraft($draftPath, $content, $article);
$results['articles'][$slug] = ['status' => 'generated', 'path' => $draftPath];
$results['generated']++;
} catch (\Exception $e) {
$results['articles'][$slug] = ['status' => 'failed', 'error' => $e->getMessage()];
$results['failed']++;
// Skip articles successfully generated in a prior run
if (($progress['articles'][$slug]['status'] ?? 'pending') === 'generated') {
$results['articles'][$slug] = ['status' => 'skipped', 'reason' => 'previously generated'];
$results['skipped']++;
continue;
}
$priorAttempts = $progress['articles'][$slug]['attempts'] ?? 0;
$articleResult = $this->attemptArticleGeneration($article, $spec, $promptTemplate, $provider, $maxRetries);
if ($articleResult['status'] === 'generated') {
$results['articles'][$slug] = ['status' => 'generated', 'path' => $articleResult['path']];
$results['generated']++;
$progress['articles'][$slug] = [
'status' => 'generated',
'attempts' => $priorAttempts + $articleResult['attempts'],
'last_error' => null,
'generated_at' => now()->toIso8601String(),
'last_attempt_at' => now()->toIso8601String(),
];
} else {
$results['articles'][$slug] = ['status' => 'failed', 'error' => $articleResult['error']];
$results['failed']++;
$progress['articles'][$slug] = [
'status' => 'failed',
'attempts' => $priorAttempts + $articleResult['attempts'],
'last_error' => $articleResult['error'],
'generated_at' => null,
'last_attempt_at' => now()->toIso8601String(),
];
}
// Persist after each article so a crash mid-batch is recoverable
$progress['last_updated'] = now()->toIso8601String();
$this->saveBatchProgress($batchId, $progress);
}
if ($progress !== null) {
$progress['last_updated'] = now()->toIso8601String();
$this->saveBatchProgress($batchId, $progress);
}
return $results;
}
/**
* Resume a batch from its last saved state.
*
* Articles that were successfully generated are skipped; failed and
* pending articles are retried. Returns an error if no progress state
* exists (i.e. generateBatch() has never been called for this batch).
*/
public function resumeBatch(string $batchId, ?string $provider = null, int $maxRetries = 1): array
{
$progress = $this->loadBatchProgress($batchId);
if ($progress === null) {
return ['error' => "No progress state found for batch: {$batchId}"];
}
$provider ??= $progress['provider'] ?? 'gemini';
$result = $this->generateBatch($batchId, $provider, false, $maxRetries);
$result['resumed_from'] = $progress['last_updated'];
return $result;
}
/**
* Load batch progress state from the state file.
*
* Returns null when no state file exists (batch has not been started).
*/
public function loadBatchProgress(string $batchId): ?array
{
$path = $this->getProgressPath($batchId);
if (! File::exists($path)) {
return null;
}
$data = json_decode(File::get($path), true);
return is_array($data) ? $data : null;
}
/**
* Attempt to generate a single article with retry logic.
*
* Returns ['status' => 'generated', 'path' => ..., 'attempts' => N]
* or ['status' => 'failed', 'error' => ..., 'attempts' => N].
*/
protected function attemptArticleGeneration(
array $article,
array $spec,
string $promptTemplate,
string $provider,
int $maxRetries,
): array {
$draftPath = $this->getDraftPath($spec, $article['slug']);
$lastError = null;
$totalAttempts = $maxRetries + 1;
for ($attempt = 1; $attempt <= $totalAttempts; $attempt++) {
try {
$content = $this->generateArticle($article, $spec, $promptTemplate, $provider);
$this->saveDraft($draftPath, $content, $article);
return ['status' => 'generated', 'path' => $draftPath, 'attempts' => $attempt];
} catch (\Exception $e) {
$lastError = $e->getMessage();
}
}
return ['status' => 'failed', 'error' => $lastError, 'attempts' => $totalAttempts];
}
/**
* Initialise a fresh batch progress state.
*/
protected function initialiseBatchState(string $batchId, array $articles, string $provider): array
{
$articleStates = [];
foreach ($articles as $article) {
$slug = $article['slug'] ?? null;
if ($slug) {
$articleStates[$slug] = [
'status' => 'pending',
'attempts' => 0,
'last_error' => null,
'generated_at' => null,
'last_attempt_at' => null,
];
}
}
return [
'batch_id' => $batchId,
'provider' => $provider,
'started_at' => now()->toIso8601String(),
'last_updated' => now()->toIso8601String(),
'articles' => $articleStates,
];
}
/**
* Save batch progress state to the state file.
*/
protected function saveBatchProgress(string $batchId, array $state): void
{
File::put($this->getProgressPath($batchId), json_encode($state, JSON_PRETTY_PRINT));
}
/**
* Get the progress state file path for a batch.
*/
protected function getProgressPath(string $batchId): string
{
return base_path("{$this->batchPath}/{$batchId}.progress.json");
}
/**
* Generate a single article.
*/

203
Services/ForgejoService.php Normal file
View file

@ -0,0 +1,203 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Mod\Agentic\Services;
use Illuminate\Support\Facades\Http;
/**
* Forgejo REST API client for agent orchestration.
*
* Wraps the Forgejo v1 API for issue management, pull requests,
* commit statuses, and branch operations.
*/
class ForgejoService
{
public function __construct(
private string $baseUrl,
private string $token,
) {}
/**
* List issues for a repository.
*
* @return array<int, array<string, mixed>>
*/
public function listIssues(string $owner, string $repo, string $state = 'open', ?string $label = null): array
{
$query = ['state' => $state, 'type' => 'issues'];
if ($label !== null) {
$query['labels'] = $label;
}
return $this->get("/repos/{$owner}/{$repo}/issues", $query);
}
/**
* Get a single issue by number.
*
* @return array<string, mixed>
*/
public function getIssue(string $owner, string $repo, int $number): array
{
return $this->get("/repos/{$owner}/{$repo}/issues/{$number}");
}
/**
* Create a comment on an issue.
*
* @return array<string, mixed>
*/
public function createComment(string $owner, string $repo, int $issueNumber, string $body): array
{
return $this->post("/repos/{$owner}/{$repo}/issues/{$issueNumber}/comments", [
'body' => $body,
]);
}
/**
* Add labels to an issue.
*
* @param array<int> $labelIds
* @return array<int, array<string, mixed>>
*/
public function addLabels(string $owner, string $repo, int $issueNumber, array $labelIds): array
{
return $this->post("/repos/{$owner}/{$repo}/issues/{$issueNumber}/labels", [
'labels' => $labelIds,
]);
}
/**
* List pull requests for a repository.
*
* @return array<int, array<string, mixed>>
*/
public function listPullRequests(string $owner, string $repo, string $state = 'all'): array
{
return $this->get("/repos/{$owner}/{$repo}/pulls", ['state' => $state]);
}
/**
* Get a single pull request by number.
*
* @return array<string, mixed>
*/
public function getPullRequest(string $owner, string $repo, int $number): array
{
return $this->get("/repos/{$owner}/{$repo}/pulls/{$number}");
}
/**
* Get the combined commit status for a ref.
*
* @return array<string, mixed>
*/
public function getCombinedStatus(string $owner, string $repo, string $sha): array
{
return $this->get("/repos/{$owner}/{$repo}/commits/{$sha}/status");
}
/**
* Merge a pull request.
*
* @param string $method One of: merge, rebase, rebase-merge, squash, fast-forward-only
*
* @throws \RuntimeException
*/
public function mergePullRequest(string $owner, string $repo, int $number, string $method = 'merge'): void
{
$response = $this->request()
->post($this->url("/repos/{$owner}/{$repo}/pulls/{$number}/merge"), [
'Do' => $method,
]);
if (! $response->successful()) {
throw new \RuntimeException(
"Failed to merge PR #{$number}: {$response->status()} {$response->body()}"
);
}
}
/**
* Create a branch in a repository.
*
* @return array<string, mixed>
*/
public function createBranch(string $owner, string $repo, string $name, string $from = 'main'): array
{
return $this->post("/repos/{$owner}/{$repo}/branches", [
'new_branch_name' => $name,
'old_branch_name' => $from,
]);
}
/**
* Build an authenticated HTTP client.
*/
private function request(): \Illuminate\Http\Client\PendingRequest
{
return Http::withToken($this->token)
->acceptJson()
->timeout(15);
}
/**
* Build the full API URL for a path.
*/
private function url(string $path): string
{
return "{$this->baseUrl}/api/v1{$path}";
}
/**
* Perform a GET request and return decoded JSON.
*
* @param array<string, mixed> $query
* @return array<string, mixed>
*
* @throws \RuntimeException
*/
private function get(string $path, array $query = []): array
{
$response = $this->request()->get($this->url($path), $query);
if (! $response->successful()) {
throw new \RuntimeException(
"Forgejo API GET {$path} failed: {$response->status()}"
);
}
return $response->json();
}
/**
* Perform a POST request and return decoded JSON.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*
* @throws \RuntimeException
*/
private function post(string $path, array $data = []): array
{
$response = $this->request()->post($this->url($path), $data);
if (! $response->successful()) {
throw new \RuntimeException(
"Forgejo API POST {$path} failed: {$response->status()}"
);
}
return $response->json();
}
}

View file

@ -6,6 +6,7 @@ namespace Core\Mod\Agentic\Services;
use Core\Mod\Agentic\Models\AgentPhase;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Agentic\Models\PlanTemplateVersion;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
@ -146,6 +147,10 @@ class PlanTemplateService
return null;
}
// Snapshot the raw template content before variable substitution so the
// version record captures the canonical template, not the instantiated copy.
$templateVersion = PlanTemplateVersion::findOrCreateFromTemplate($templateSlug, $template);
// Replace variables in template
$template = $this->substituteVariables($template, $variables);
@ -164,10 +169,12 @@ class PlanTemplateService
'description' => $template['description'] ?? null,
'context' => $context,
'status' => ($options['activate'] ?? false) ? AgentPlan::STATUS_ACTIVE : AgentPlan::STATUS_DRAFT,
'template_version_id' => $templateVersion->id,
'metadata' => array_merge($template['metadata'] ?? [], [
'source' => 'template',
'template_slug' => $templateSlug,
'template_name' => $template['name'],
'template_version' => $templateVersion->version,
'variables' => $variables,
'created_at' => now()->toIso8601String(),
]),
@ -329,13 +336,18 @@ class PlanTemplateService
/**
* Validate variables against template requirements.
*
* Returns a result array with:
* - valid: bool
* - errors: string[] actionable messages including description and examples
* - naming_convention: string reminder that variable names use snake_case
*/
public function validateVariables(string $templateSlug, array $variables): array
{
$template = $this->get($templateSlug);
if (! $template) {
return ['valid' => false, 'errors' => ['Template not found']];
return ['valid' => false, 'errors' => ['Template not found'], 'naming_convention' => self::NAMING_CONVENTION];
}
$errors = [];
@ -344,16 +356,94 @@ class PlanTemplateService
$required = $varDef['required'] ?? true;
if ($required && ! isset($variables[$name]) && ! isset($varDef['default'])) {
$errors[] = "Required variable '{$name}' is missing";
$errors[] = $this->buildVariableError($name, $varDef);
}
}
return [
'valid' => empty($errors),
'errors' => $errors,
'naming_convention' => self::NAMING_CONVENTION,
];
}
/**
<<<<<<< HEAD
* Naming convention reminder included in validation results.
*/
private const NAMING_CONVENTION = 'Variable names use snake_case (e.g. project_name, api_key)';
/**
* Build an actionable error message for a missing required variable.
*
* Incorporates the variable's description, example values, and expected
* format so the caller knows exactly what to provide.
*/
private function buildVariableError(string $name, array $varDef): string
{
$message = "Required variable '{$name}' is missing";
if (! empty($varDef['description'])) {
$message .= ": {$varDef['description']}";
}
$hints = [];
if (! empty($varDef['format'])) {
$hints[] = "expected format: {$varDef['format']}";
}
if (! empty($varDef['example'])) {
$hints[] = "example: '{$varDef['example']}'";
} elseif (! empty($varDef['examples'])) {
$exampleValues = is_array($varDef['examples'])
? array_slice($varDef['examples'], 0, 2)
: [$varDef['examples']];
$hints[] = "examples: '".implode("', '", $exampleValues)."'";
}
if (! empty($hints)) {
$message .= ' ('.implode('; ', $hints).')';
}
return $message;
}
/**
* Get the version history for a template slug, newest first.
*
* Returns an array of version summaries (without full content) for display.
*
* @return array<int, array{id: int, slug: string, version: int, name: string, content_hash: string, created_at: string}>
*/
public function getVersionHistory(string $slug): array
{
return PlanTemplateVersion::historyFor($slug)
->map(fn (PlanTemplateVersion $v) => [
'id' => $v->id,
'slug' => $v->slug,
'version' => $v->version,
'name' => $v->name,
'content_hash' => $v->content_hash,
'created_at' => $v->created_at?->toIso8601String(),
])
->toArray();
}
/**
* Get a specific stored version of a template by slug and version number.
*
* Returns the snapshotted content array, or null if not found.
*/
public function getVersion(string $slug, int $version): ?array
{
$record = PlanTemplateVersion::where('slug', $slug)
->where('version', $version)
->first();
return $record?->content;
}
/**
* Get templates by category.
*/

43
TODO.md
View file

@ -104,10 +104,11 @@ Production-quality task list for the AI agent orchestration package.
- Issue: No try/catch around streaming, could fail silently
- Fix: Wrap in exception handling, yield error events
- [ ] **ERR-002: ContentService has no batch failure recovery**
- [x] **ERR-002: ContentService has no batch failure recovery** (FIXED 2026-02-23)
- Location: `Services/ContentService.php::generateBatch()`
- Issue: Failed articles stop processing, no resume capability
- Fix: Add progress tracking, allow resuming from failed point
- Fix: Added progress state file, per-article retry (maxRetries param), `resumeBatch()` method
- Tests: 6 new tests in `tests/Feature/ContentServiceTest.php` covering state persistence, resume, retries
---
@ -115,26 +116,29 @@ Production-quality task list for the AI agent orchestration package.
### Developer Experience
- [ ] **DX-001: Missing workspace context error messages unclear**
- [x] **DX-001: Missing workspace context error messages unclear** (FIXED 2026-02-23)
- Location: Multiple MCP tools
- Issue: "workspace_id is required" doesn't explain how to fix
- Fix: Include context about authentication/session setup
- Issue: "workspace_id is required" didn't explain how to fix
- Fix: Updated error messages in PlanCreate, PlanGet, PlanList, StateSet, StateGet, StateList, SessionStart to include actionable guidance and link to documentation
- [ ] **DX-002: AgenticManager doesn't validate API keys on init**
- [x] **DX-002: AgenticManager doesn't validate API keys on init** (FIXED 2026-02-23)
- Location: `Services/AgenticManager.php::registerProviders()`
- Issue: Empty API key creates provider that fails on first use
- Fix: Log warning or throw if provider configured without key
- Fix: `Log::warning()` emitted for each provider registered without an API key; message names the env var to set
- [ ] **DX-003: Plan template variable errors not actionable**
- [x] **DX-003: Plan template variable errors not actionable** (FIXED 2026-02-23)
- Location: `Services/PlanTemplateService.php::validateVariables()`
- Fix: Include expected format, examples in error messages
- Fix: Error messages now include variable description, example/examples, and expected format
- Added `naming_convention` field to result; extracted `buildVariableError()` helper
- New tests: description in error, example value, multiple examples, format hint, naming_convention field
### Code Quality
- [ ] **CQ-001: Duplicate state models (WorkspaceState vs AgentWorkspaceState)**
- Files: `Models/WorkspaceState.php`, `Models/AgentWorkspaceState.php`
- Issue: Two similar models for same purpose
- Fix: Consolidate into single model, or clarify distinct purposes
- [x] **CQ-001: Duplicate state models (WorkspaceState vs AgentWorkspaceState)** (FIXED 2026-02-23)
- Deleted `Models/AgentWorkspaceState.php` (unused legacy port)
- Consolidated into `Models/WorkspaceState.php` backed by `agent_workspace_states` table
- Updated `AgentPlan`, `StateSet`, `SecurityTest` to use `WorkspaceState`
- Added `WorkspaceStateTest` covering model behaviour and static helpers
- [x] **CQ-002: ApiKeyManager uses Core\Api\ApiKey, not AgentApiKey** (FIXED 2026-02-23)
- Location: `View/Modal/Admin/ApiKeyManager.php`
@ -168,10 +172,10 @@ Production-quality task list for the AI agent orchestration package.
### Documentation Gaps
- [ ] **DOC-001: Add PHPDoc to AgentDetection patterns**
- [x] **DOC-001: Add PHPDoc to AgentDetection patterns** (FIXED 2026-02-23)
- Location: `Services/AgentDetection.php`
- Issue: User-Agent patterns undocumented
- Fix: Document each pattern with agent examples
- Fix: Added PHPDoc with real UA examples to all pattern constants, class-level usage examples, and MCP_TOKEN_HEADER docs
- [ ] **DOC-002: Document MCP tool dependency system**
- Location: `Mcp/Tools/Agent/` directory
@ -188,16 +192,17 @@ Production-quality task list for the AI agent orchestration package.
- Issue: Archived plans kept forever
- Fix: Add configurable retention period, cleanup job
- [ ] **FEAT-003: Template version management**
- Location: `Services/PlanTemplateService.php`
- [x] **FEAT-003: Template version management**
- Location: `Services/PlanTemplateService.php`, `Models/PlanTemplateVersion.php`
- Issue: Template changes affect existing plan references
- Fix: Add version tracking to templates
- Fix: Add version tracking to templates — implemented in #35
### Consistency
- [ ] **CON-001: Mixed UK/US spelling in code comments**
- [x] **CON-001: Mixed UK/US spelling in code comments** (FIXED 2026-02-23)
- Issue: Some comments use "organize" instead of "organise"
- Fix: Audit and fix to UK English per CLAUDE.md
- Changed: `Mcp/Servers/Marketing.php` "Organize" → "Organise" in docstring
- [ ] **CON-002: Inconsistent error response format**
- Issue: Some tools return `['error' => ...]`, others `['success' => false, ...]`

37
agentic.php Normal file
View file

@ -0,0 +1,37 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Plan Retention Policy
|--------------------------------------------------------------------------
|
| Archived plans are permanently deleted after this many days. This frees
| up storage and keeps the database lean over time.
|
| Set to 0 or null to disable automatic cleanup entirely.
|
| Default: 90 days
|
*/
'plan_retention_days' => env('AGENTIC_PLAN_RETENTION_DAYS', 90),
/*
|--------------------------------------------------------------------------
| Forgejo Integration
|--------------------------------------------------------------------------
|
| Configuration for the Forgejo-based scan/dispatch/PR pipeline.
| AGENTIC_SCAN_REPOS is a comma-separated list of owner/name repos.
|
*/
'scan_repos' => array_filter(explode(',', env('AGENTIC_SCAN_REPOS', ''))),
'forge_url' => env('FORGE_URL', 'https://forge.lthn.ai'),
'forge_token' => env('FORGE_TOKEN', ''),
];

View file

@ -1,5 +1,5 @@
{
"name": "host-uk/core-agentic",
"name": "lthn/php-agentic",
"description": "AI agent orchestration and MCP tools for Laravel",
"keywords": [
"ai",
@ -10,7 +10,7 @@
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"host-uk/core": "dev-main"
"lthn/php": "*"
},
"require-dev": {
"laravel/pint": "^1.18",
@ -49,5 +49,8 @@
}
},
"minimum-stability": "dev",
"prefer-stable": true
"prefer-stable": true,
"replace": {
"core/php-agentic": "self.version"
}
}

View file

@ -7,15 +7,12 @@ return [
| MCP Portal Domain
|--------------------------------------------------------------------------
|
| The domain where the MCP Portal is served. This hosts the server registry,
| documentation, and discovery endpoints for AI agents.
|
| Production: mcp.host.uk.com
| Local dev: mcp.host.test (Valet)
| Default domain for the MCP Portal. The app-level Boot may override this
| with a wildcard (e.g. mcp.{tld}) for multi-domain support.
|
*/
'domain' => env('MCP_DOMAIN', 'mcp.host.uk.com'),
'domain' => env('MCP_DOMAIN', 'mcp.'.env('APP_DOMAIN', 'host.uk.com')),
/*
|--------------------------------------------------------------------------
@ -71,4 +68,37 @@ return [
'for_agents_ttl' => 3600,
],
/*
|--------------------------------------------------------------------------
| OpenBrain (Shared Agent Knowledge Store)
|--------------------------------------------------------------------------
|
| Configuration for the vector-indexed knowledge store. Requires
| Ollama (for embeddings) and Qdrant (for vector search).
|
*/
'brain' => [
'ollama_url' => env('BRAIN_OLLAMA_URL', 'https://ollama.lthn.sh'),
'qdrant_url' => env('BRAIN_QDRANT_URL', 'https://qdrant.lthn.sh'),
'collection' => env('BRAIN_COLLECTION', 'openbrain'),
'embedding_model' => env('BRAIN_EMBEDDING_MODEL', 'embeddinggemma'),
// Dedicated database connection for brain_memories.
// Defaults to the app's main database when BRAIN_DB_* env vars are absent.
// Set BRAIN_DB_HOST to a remote MariaDB (e.g. the homelab) to co-locate
// DB rows with their Qdrant vectors.
'database' => [
'driver' => env('BRAIN_DB_DRIVER', env('DB_CONNECTION', 'mariadb')),
'host' => env('BRAIN_DB_HOST', env('DB_HOST', '127.0.0.1')),
'port' => env('BRAIN_DB_PORT', env('DB_PORT', '3306')),
'database' => env('BRAIN_DB_DATABASE', env('DB_DATABASE', 'forge')),
'username' => env('BRAIN_DB_USERNAME', env('DB_USERNAME', 'forge')),
'password' => env('BRAIN_DB_PASSWORD', env('DB_PASSWORD', '')),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
],
],
];

View file

@ -0,0 +1,213 @@
# OpenBrain Design
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Shared vector-indexed knowledge store that all agents (Virgil, Charon, Darbs, LEM) read/write through MCP, building singular state across sessions.
**Architecture:** MariaDB for relational metadata + Qdrant for vector embeddings. Four MCP tools in php-agentic. Go bridge in go-ai for CLI agents. Ollama for embedding generation.
**Repos:** `forge.lthn.ai/core/php-agentic` (primary), `forge.lthn.ai/core/go-ai` (bridge)
---
## Problem
Agent knowledge is scattered:
- Virgil's `MEMORY.md` files in `~/.claude/projects/*/memory/` — file-based, single-agent, no semantic search
- Plans in `docs/plans/` across repos — forgotten after completion
- Session handoff notes in `agent_sessions.handoff_notes` — JSON blobs, not searchable
- Research findings lost when context windows compress
When Charon discovers a scoring calibration bug, Virgil only knows about it if explicitly told. There's no shared knowledge graph.
## Concept
**OpenBrain** — "Open" means open protocol (MCP), not open source. All agents on the platform access the same knowledge graph via `brain_*` MCP tools. Data is stored *for agents* — structured for near-native context transfer between sessions and models.
## Data Model
### `brain_memories` table (MariaDB)
| Column | Type | Purpose |
|--------|------|---------|
| `id` | UUID | Primary key, also Qdrant point ID |
| `workspace_id` | FK | Multi-tenant isolation |
| `agent_id` | string | Who wrote it (virgil, charon, darbs, lem) |
| `type` | enum | `decision`, `observation`, `convention`, `research`, `plan`, `bug`, `architecture` |
| `content` | text | The knowledge (markdown) |
| `tags` | JSON | Topic tags for filtering |
| `project` | string nullable | Repo/project scope (null = cross-project) |
| `confidence` | float | 0.01.0, how certain the agent is |
| `supersedes_id` | UUID nullable | FK to older memory this replaces |
| `expires_at` | timestamp nullable | TTL for session-scoped context |
| `deleted_at` | timestamp nullable | Soft delete |
| `created_at` | timestamp | |
| `updated_at` | timestamp | |
### `openbrain` Qdrant collection
- **Vector dimension:** 768 (nomic-embed-text via Ollama)
- **Distance metric:** Cosine
- **Point ID:** MariaDB UUID
- **Payload:** `workspace_id`, `agent_id`, `type`, `tags`, `project`, `confidence`, `created_at` (for filtered search)
## MCP Tools
### `brain_remember` — Store a memory
```json
{
"content": "LEM emotional_register was blind to negative emotions. Fixed by adding 8 weighted pattern groups.",
"type": "bug",
"tags": ["scoring", "emotional-register", "lem"],
"project": "eaas",
"confidence": 0.95,
"supersedes": "uuid-of-outdated-memory"
}
```
Agent ID injected from MCP session context. Returns the new memory UUID.
**Pipeline:**
1. Validate input
2. Embed content via Ollama (`POST /api/embeddings`, model: `nomic-embed-text`)
3. Insert into MariaDB
4. Upsert into Qdrant with payload metadata
5. If `supersedes` set, soft-delete the old memory and remove from Qdrant
### `brain_recall` — Semantic search
```json
{
"query": "How does verdict classification work?",
"top_k": 5,
"filter": {
"project": "eaas",
"type": ["decision", "architecture"],
"min_confidence": 0.5
}
}
```
**Pipeline:**
1. Embed query via Ollama
2. Search Qdrant with vector + payload filters
3. Get top-K point IDs with similarity scores
4. Hydrate from MariaDB (content, tags, supersedes chain)
5. Return ranked results with scores
Only returns latest version of superseded memories (includes `supersedes_count` so agent knows history exists).
### `brain_forget` — Soft-delete or supersede
```json
{
"id": "uuid",
"reason": "Superseded by new calibration approach"
}
```
Sets `deleted_at` in MariaDB, removes point from Qdrant. Keeps audit trail.
### `brain_list` — Browse (no vectors)
```json
{
"project": "eaas",
"type": "decision",
"agent_id": "charon",
"limit": 20
}
```
Pure MariaDB query. For browsing, auditing, bulk export. No embedding needed.
## Architecture
### PHP side (`php-agentic`)
```
Mcp/Tools/Agent/Brain/
├── BrainRemember.php
├── BrainRecall.php
├── BrainForget.php
└── BrainList.php
Services/
└── BrainService.php # Ollama embeddings + Qdrant client + MariaDB CRUD
Models/
└── BrainMemory.php # Eloquent model
Migrations/
└── XXXX_create_brain_memories_table.php
```
`BrainService` handles:
- Ollama HTTP calls for embeddings
- Qdrant REST API (upsert, search, delete points)
- MariaDB CRUD via Eloquent
- Supersession chain management
### Go side (`go-ai`)
Thin bridge tools in the MCP server that proxy `brain_*` calls to Laravel via the existing WebSocket bridge. Same pattern as `ide_chat_send` / `ide_session_create`.
### Data flow
```
Agent (any Claude)
↓ MCP tool call
Go MCP server (local, macOS/Linux)
↓ WebSocket bridge
Laravel php-agentic (lthn.ai, de1)
↓ ↓
MariaDB Qdrant
(relational) (vectors)
Ollama (embeddings)
```
PHP-native agents skip the Go bridge — call `BrainService` directly.
### Infrastructure
- **Qdrant:** New container on de1. Shared between OpenBrain and EaaS scoring (different collections).
- **Ollama:** Existing instance. `nomic-embed-text` model for 768d embeddings. CPU is fine for the volume (~10K memories).
- **MariaDB:** Existing instance on de1. New table in the agentic database.
## Integration
### Plans → Brain
On plan completion, agents can extract key decisions/findings and `brain_remember` them. Optional — agents decide what's worth persisting. The plan itself stays in `agent_plans`; lessons learned go to the brain.
### Sessions → Brain
Handoff notes (summary, next_steps, blockers) can auto-persist as memories with `type: observation` and optional TTL. Agents can also manually remember during a session.
### MEMORY.md migration
Seed data: collect all `MEMORY.md` files from `~/.claude/projects/*/memory/` across worktrees. Parse into individual memories, embed, and load into OpenBrain. After migration, `brain_recall` replaces file-based memory.
### EaaS
Same Qdrant instance, different collection (`eaas_scoring` vs `openbrain`). Shared infrastructure, separate concerns.
### LEM
LEM models query the brain for project context during training data curation or benchmark analysis. Same MCP tools, different agent ID.
## What this replaces
- Virgil's `MEMORY.md` files (file-based, single-agent, no search)
- Scattered `docs/plans/` findings that get forgotten
- Manual "Charon found X" cross-agent handoffs
- Session-scoped knowledge that dies with context compression
## What this enables
- Any Claude picks up where another left off — semantically
- Decisions surface when related code is touched
- Knowledge graph grows with every session across all agents
- Near-native context transfer between models and sessions

File diff suppressed because it is too large Load diff

View file

@ -1,3 +0,0 @@
<?php
// API routes are registered via Core modules

View file

@ -286,6 +286,78 @@ class AgentPhaseTest extends TestCase
$this->assertEquals($dep2->id, $blockers[0]['phase_id']);
}
public function test_check_dependencies_returns_empty_when_no_dependencies(): void
{
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'dependencies' => null,
]);
$this->assertSame([], $phase->checkDependencies());
}
public function test_check_dependencies_not_blocked_by_skipped_phase(): void
{
$dep = AgentPhase::factory()->skipped()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
]);
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'order' => 2,
'dependencies' => [$dep->id],
]);
$this->assertSame([], $phase->checkDependencies());
$this->assertTrue($phase->canStart());
}
public function test_check_dependencies_uses_single_query_for_multiple_deps(): void
{
$deps = AgentPhase::factory()->pending()->count(5)->create([
'agent_plan_id' => $this->plan->id,
]);
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'dependencies' => $deps->pluck('id')->toArray(),
]);
$queryCount = 0;
\DB::listen(function () use (&$queryCount) {
$queryCount++;
});
$blockers = $phase->checkDependencies();
$this->assertCount(5, $blockers);
$this->assertSame(1, $queryCount, 'checkDependencies() should issue exactly one query');
}
public function test_check_dependencies_blocker_contains_expected_keys(): void
{
$dep = AgentPhase::factory()->inProgress()->create([
'agent_plan_id' => $this->plan->id,
'order' => 1,
'name' => 'Blocker Phase',
]);
$phase = AgentPhase::factory()->pending()->create([
'agent_plan_id' => $this->plan->id,
'order' => 2,
'dependencies' => [$dep->id],
]);
$blockers = $phase->checkDependencies();
$this->assertCount(1, $blockers);
$this->assertEquals($dep->id, $blockers[0]['phase_id']);
$this->assertEquals(1, $blockers[0]['phase_order']);
$this->assertEquals('Blocker Phase', $blockers[0]['phase_name']);
$this->assertEquals(AgentPhase::STATUS_IN_PROGRESS, $blockers[0]['status']);
}
public function test_can_start_checks_dependencies(): void
{
$dep = AgentPhase::factory()->pending()->create([

View file

@ -76,7 +76,7 @@ class AgentPlanTest extends TestCase
$fresh = $plan->fresh();
$this->assertEquals(AgentPlan::STATUS_ARCHIVED, $fresh->status);
$this->assertEquals('No longer needed', $fresh->metadata['archive_reason']);
$this->assertNotNull($fresh->metadata['archived_at']);
$this->assertNotNull($fresh->archived_at);
}
public function test_it_generates_unique_slugs(): void

View file

@ -9,7 +9,6 @@ declare(strict_types=1);
* for all create, list, and revoke operations.
*/
use Carbon\Carbon;
use Core\Mod\Agentic\Models\AgentApiKey;
use Core\Mod\Agentic\Services\AgentApiKeyService;
use Core\Tenant\Models\Workspace;

Some files were not shown because too many files have changed in this diff Show more