Commit graph

60 commits

Author SHA1 Message Date
Snider
82ffd420e0 feat(agent/php): DispatchMantisTicketJob + HermesClient + agent_dispatches table (#827)
Phase 3 lane: queueable Job that resolves a profile via ProfileSelector,
posts POST {gateway}/v1/responses to the chosen Hermes gateway, persists
ticket_id/profile_id/response_id/run_id/status in agent_dispatches, and
chains CaptureDispatchResultJob.

Migration 2026_04_25_000003 creates agent_dispatches table (FK-free,
Postgres-compatible).

HermesClient: thin Laravel HTTP wrapper around the Hermes /v1/responses
endpoint with Authorization header + JSON body.

DispatchMantisTicketJob behaviour:
- Resolves profile via ProfileSelector::pickFor()
- Null-profile → log warn + ->release(60) requeue
- Otherwise POSTs to gateway, persists AgentDispatch row, queues
  CaptureDispatchResultJob

AgentDispatch Eloquent model with minimal $fillable.

Pest Feature test (Http::fake): verifies request shape, persisted row,
downstream capture-job queueing, and the no-profile requeue path.
Test file conditionally aliases minimal stubs for sibling-lane services
so this file remains runnable before #826/#828 fully land in dev.

Codex note: php -l clean; pest skipped (no vendor/).

Closes tasks.lthn.sh/view.php?id=827

Co-authored-by: Codex <noreply@openai.com>
2026-04-26 00:52:37 +01:00
Snider
fc59aa02eb feat(agent/php): plugin-cc integration — column + sync command (#837)
Migration 2026_04_25_000002 adds nullable plugin_cc_name string column
to agent_profiles. AgentProfile::$fillable extended to allow it.

agentic:sync-plugins-cc artisan command:
- Scans ~/.claude/plugins via Storage::disk(...) local disk (Finder
  fallback) for directories with plugin.json
- Maps to enabled AgentProfile by name first, plugin_cc_name second
- Upserts plugin_cc_name on matches; emits mapped/unmapped table

Pest Feature test fakes HOME, creates plugin dirs, verifies both
mapping paths + disabled/non-matching profiles stay null.

Codex note: php -l clean; pest skipped (no vendor/). Boot.php command
registration deferred — new test registers the command directly to
verify behaviour; Boot wiring belongs to a follow-up that touches
existing Boot file.

Closes tasks.lthn.sh/view.php?id=837

Co-authored-by: Codex <noreply@openai.com>
2026-04-26 00:48:37 +01:00
Snider
060f5abb66 feat(agent/php): ProfileSelector + ShapeClassifier services per Phase 3 (#826)
ShapeClassifier::classify($ticket) returns 'A'|'B'|'C' per policy v1:
- (severity=critical OR priority=urgent) → A
- has tag in ['security','crypto','core'] → A
- (severity=major OR priority=high) → B
- everything else → C

ProfileSelector::pickFor($ticket) walks AgentProfile::active(), matches
capability tags case-insensitively against ticket.tags:
- Class A: cheapest matching profile (cost_class alphabetic order)
- Class B: any active profile with quota_headroom_pct >= 25
- Class C: deterministic round-robin via last_dispatched_at

Pest Unit tests cover Good (matching profile picked), Bad (no match → null),
Ugly (all profiles disabled → null), plus class A/B headroom gating + class C
round-robin determinism.

Codex note: php -l clean; pest skipped — no vendor/ at this repo root
(downstream lab/lthn.ai owns composer install).

Closes tasks.lthn.sh/view.php?id=826

Co-authored-by: Codex <noreply@openai.com>
2026-04-26 00:48:37 +01:00
Snider
8117d89287 feat(agent/php): CaptureDispatchResultJob + MantisClient + ShaExtractor (#828)
Phase 3 lane: queueable Job that normalises Hermes fetch/event payloads,
extracts the first commit SHA via ShaExtractor (matches both forge URL
and bare 7-40 hex forms), builds a close-note with optional
?\Mod\AgentProfile profile reference, posts the Mantis note via
MantisClient->note(), then transitions the ticket to closed/fixed via
MantisClient->close().

ShaExtractor: extract($modelOutput): {?sha, ?repo, ?forge_url} regex
matcher for forge.lthn.sh commit URLs + bare SHAs.

MantisClient: thin Guzzle wrapper around tasks.lthn.sh REST API
(note + close), Authorization header, base URL via config.

Pest Feature test: 8 tests / 17 assertions covering forge URL parsing,
bare SHA parsing, note/close ordering, repo-hint fallback, missing-SHA
RuntimeException path. Verified via temp Composer harness (no checked-in
vendor/ at this repo level).

Closes tasks.lthn.sh/view.php?id=828

Co-authored-by: Codex <noreply@openai.com>
2026-04-26 00:44:23 +01:00
Snider
38dcb17083 feat(agent/php): AgentProfile migration + Eloquent model + scopes (#825)
Adds Phase 3 foundation: agent_profiles table (Postgres-compatible, FK-free),
AgentProfile Eloquent model with encrypted api_key_cipher cast, array
capability_tags cast, datetime last_dispatched_at cast, active() and
forClass(string) local scopes.

AX-10 Pest Feature test covers:
- active() returns only enabled+headroom>0 rows
- active()->forClass('A') chains correctly
- api_key_cipher round-trips via encrypted cast
- capability_tags round-trips as array

Codex note: php -l clean on all 3 files; pest+artisan unavailable at
repo root for runtime verification (composer + bootstrap live downstream
in lab/lthn.ai).

Closes tasks.lthn.sh/view.php?id=825

Co-authored-by: Codex <noreply@openai.com>
2026-04-26 00:34:24 +01:00
Snider
f96bd67bd6 feat(agent/admin+hub): RFC foundation — admin scaffold + Hub global components
Foundation slice for Mantis #843 php/Mod/Admin + php/Website/Hub RFC:

* php/Mod/Admin/Boot.php — search registry, menu registry, form component
  layer, HasRateLimiting concern, reusable form/view primitives under
  Mod/Admin/Forms
* php/Website/Hub/Boot.php — host-aware Hub route naming for secondary
  domains
* WorkspaceSwitcher and GlobalSearch global Hub Livewire components
* Foundation routed slice in Hub/Routes/admin.php: dashboard shell,
  workspace listing, site settings (with WordPress/webhook connector),
  account usage, platform user list+detail
* Foundation tests under php/tests/Feature/Mod/Admin/

53 PHP files. php -l clean. Pest unrunnable in sandbox (no vendor/).

Foundation slice only — composer.json kept off-limits so namespace stays
under Core\Mod\Agentic\... rather than standalone Core\Admin package.
Deferred: Profile, Settings, ServiceManager, ServicesAdmin, Honeypot,
Entitlement\{Dashboard,FeatureManager,PackageManager}, PromptManager,
WaitlistManager, Console, Databases, Deployments, Content,
ContentManager, ContentEditor, ActivityLog, Analytics, AIServices,
BoostPurchase. Lane was under-instructed by supervisor with stop-at
framing — follow-up tickets needed for remainder.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=843
2026-04-25 21:09:22 +01:00
Snider
5385385314 feat(agent/api): RFC foundation — API keys, webhooks, rate limiting, docs split
Foundation slice for Mantis #844 php/Mod/Api RFC implementation:

* New php/Mod/Api/ package: Boot, Controllers, Documentation, Jobs,
  Middleware, Models, RateLimit, Routes, Services
* Models: ApiKey, WebhookEndpoint, WebhookDelivery
* WebhookService::dispatch() with DB::transaction + afterCommit
* DeliverWebhookJob with retry/backoff
* WebhookSignature with timing-safe verification + 5-minute tolerance +
  dual-secret rotation support
* Sliding-window rate limiter in RateLimit/RateLimitService.php
* AuthenticateApiKey middleware: hk_ prefix + Sanctum fallback
* DocsController / DocumentationController split
* 3 root migrations: api_keys, webhook_endpoints, webhook_deliveries
* Foundation tests under php/tests/Feature/Mod/Api/
* FOLLOWUP.md tracks remaining RFC scope

php -l clean across 21 PHP files. Pest unrunnable in sandbox (no vendor/).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=844
2026-04-25 21:01:54 +01:00
Snider
429d1c0897 feat(agent/agentic): RFC foundation — atomic CompleteTask + credit ledger reconcile
Foundation slice for Mantis #841 php/Mod/Agent RFC implementation:

* CompleteTask now wraps in DB::transaction with idempotent credit awards
  and safe current_task_id clearing
* Credits/{Award,GetBalance,GetCreditHistory} updated for agent_id +
  fleet_task_id ledger support and richer balance totals
* GenerateCommand canonical agentic:generate wiring; legacy duplicate
  no longer registered
* Boot wires brain:clean / brain:prune / brain:reindex
* EmbedMemory exits early when memory already indexed
* 3 follow-on fleet migrations reconcile fleet_nodes pointer column,
  fleet_tasks/credit_entries fk/index hygiene, fleet+credit constraints
* 4 foundation tests under php/tests/Feature/Mod/Agent/

php -l clean on all modified files. pest unrunnable in sandbox (no vendor/).

Foundation slice only: remaining model/action parity, full MCP tool/
service sweep, fleet controller auth-context, and 41-tool/45-action
surface left for follow-up tickets.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=841
2026-04-25 20:59:38 +01:00
Snider
1872424cfd feat(agent/brain): lift OpenBrain discovery features (search/discoverTags/listScopes) (#180)
Bounded subset of RFC-OPENBRAIN lifted from lab/lthn.ai shim into the
OSS BrainService at php/Services/BrainService.php:

- search(query, filter, pagination): Elasticsearch path first, falls
  back to MariaDB if ES is unavailable. Operates on active/latest
  memories only.
- discoverTags(filter): tag-cloud / popular-tags discovery scoped to
  authenticated org(s).
- listScopes(filter): org/project distribution counts for the
  authenticated session.

All three:
- Enforce bounded inputs (per #1001 patterns)
- Honour org auth (per #312 patterns)
- Only operate on active/latest memories (active=1, deleted_at IS NULL)

Self-hosters now get the same discovery surface that lab/lthn.ai
exposes — no need to fork the OSS service to access these features.

Pest covers: bounds-violation rejection, fallback behaviour, scoped
discovery returning correct org/project breakdowns.

Lab-only features still out of scope for this lane (would pull in
extra schema/models/events): agentContext, recall feedback,
maintenance lifecycle (reindex/consolidate/clean/prune). Those need
follow-up tickets if/when bounded-lift becomes possible.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=180
2026-04-25 20:39:14 +01:00
Snider
b0118ef8ef feat(core/events): add WebhookRegistering lifecycle event (HIGH)
WebhookRegistering event exposes:
- register(string $type, array $spec): add a webhook type to the
  registry
- types(): array — queryable post-dispatch registry

CoreServiceProvider dispatches the event at app boot and exposes the
collected registry via webhookTypes() — matches the existing
ApiRoutesRegistering / ConsoleBooting / ClientRoutesRegistering
event-driven module pattern.

Pairs with #1034 ofm.bot WebhookRegistrar (just landed) — that
service can now also be wired through this event, allowing OTHER
modules and external apps using Core to register webhook types via
the standard Core lifecycle.

Note: real Core lifecycle dispatcher lives in a sibling read-only
framework checkout. CoreServiceProvider here is a local shim that
mirrors the dispatch behaviour. Upstream patch needed when that
sibling lands.

Pest covers: instantiation + register, boot-time dispatch, post-boot
registry lookup.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=1013
2026-04-25 19:37:23 +01:00
Snider
b7bc526d50 test(agent/brain): regression coverage for filter field bounds (closes #1000)
#1000 was stale-fixed: BrainService::recall() validates filter input
via the shared validator at line 489, which already bounds org,
project, type, agent_id. forget() bounds id at line 499.

These tests pin the safety claim explicitly:
- project=129 chars rejected
- agent_id=65 chars rejected
- project="core" accepted (sanity)
- project=128 chars accepted (boundary)

Note: BrainList.php (separate MCP list path) still lacks explicit
max lengths for project + agent_id — file outside this lane's allow-
list. File a follow-up if that surface needs the same bounds.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=1000
2026-04-25 19:23:41 +01:00
Snider
599544010e feat(agent/mcp): McpContext::getScopes() + hasScope() (HIGH)
McpContext exposes the authenticated session's authorisation scopes
via getScopes(): array and hasScope(string): bool.

Resolution order:
1. Explicit scope source passed to constructor
2. Session-like object linked to an API key
3. Authenticated Laravel request context (mcp_workspace_context,
   agent_api_key, api_key)
4. Empty array (default) — never null

Dedupes scope strings, normalises separators in hasScope() matching.

Closes the OFM MCP tool gap where scope-gated tools currently return
empty/incorrect handling. No call-site stubs found needing update in
this worktree — call sites pick up the new method directly.

Pest covers: session scopes returned, hasScope present/missing, empty
session defaults to [], request-context regression against real MCP
auth shape.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=1014
2026-04-25 19:04:35 +01:00
Snider
385b89b3eb fix(agent/brain): cap remember()/recall()/forget() input field sizes
Bound input field sizes against memory/DB/Qdrant bloat (DoS-by-self):
- content: 65536 bytes via mb_strlen
- tags: max 100 entries; each tag max 128 chars
- agent_id, type: 64 chars each
- project, org: 128 chars each
- supersedes_id: ULID-shape only

validateRememberInput() throws InvalidArgumentException at every entry
point (remember, recall, forget) before any DB or upstream call. Field-
specific error messages so callers know which field violated.

Pest covers good-path, content-too-long, tags-array-too-large, tag-
length, exact-boundary cases.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=1001
2026-04-25 18:58:41 +01:00
Snider
dea64f4099 fix(agent/brain): walk supersede chain to current head + cycle guard
remember() now resolves a stale supersedes_id to the current live head
before writing — when X has been superseded by Y, a retried call with
supersedes_id=X automatically links the new memory to Y instead of
silently dropping the supersede.

- Walk the chain from supplied supersedes_id to find the active head
- Cap the walk at depth 100 (cycle/runaway protection)
- Throw RuntimeException("Detected cycle while resolving supersede chain")
  on detected cycle, BEFORE any DB write
- Throw InvalidArgumentException("Superseded memory not found") when
  the original supersedes_id never existed
- deleteSupersededMemory no longer silently no-ops once the resolved
  head is expected to exist

Pest coverage extended:
- Direct chain link (X exists, succeeds with X→linked)
- Retry path (X→Y, then retry on X produces Z→Y, walks chain)
- Never-existed target (graceful error)
- Synthetic X↔Y cycle (caps walk + throws, no writes leak)

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=316
2026-04-25 18:42:52 +01:00
Snider
167ce9783e fix(agent/brain): authorise org against MCP context at every entry point
remember(), recall(), forget(), and elasticSearch() now resolve the
allowed-orgs set from the authenticated request context (mcp_workspace_context),
preferring explicit authorised_orgs/authorized_orgs, falling back to the
authenticated workspace's org/slug. A mismatched org throws
AuthorizationException BEFORE any Qdrant/Elasticsearch call or destructive
DB action — closes the horizontal-priv-escalation vector where an MCP
client could recall/remember/forget memories scoped to ANY org by
setting org="other-org" in the request body.

Pest coverage in OrgScopingTest covers good path, unauthorised recall
(asserts no HTTP), cross-org forget (asserts no DB delete), unauthorised
remember (asserts no embed/index jobs).

Note: BrainList free-form org filter is a separate ticket — outside this
lane's allowlist.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=312
2026-04-25 18:32:19 +01:00
Snider
a1a0981b06 fix(agent/brain): retryableHttp narrows retryable set + 6-attempt budget
retryableHttp() now retries only 408 (Request Timeout), 429 (Too Many
Requests), and 503 (Service Unavailable). 500-and-other-5xx fail
immediately so the circuit-breaker registers them as a single
failure rather than smearing across retry attempts. Retry-After
honoured (numeric + HTTP-date), capped reasonably.

Attempt budget bumped to 6 so a burst of 5 transient 503s can recover
within ONE circuit-permitted call — the original concern from #311.

Note: CircuitBreaker is already applied OUTSIDE the logical Brain
operation by the MCP tool layer, not around each HTTP retry. The
nesting report was stale at this code shape; the real drift was the
retryableHttp() retry set + budget.

Pest coverage in CircuitBreakerTest:
- Recovered 503 burst → circuit stays closed, no failure registered
- Exhausted 503 burst → ONE breaker failure (not five)
- 429 + Retry-After 1 → sleeps 1s, no breaker failure
- 500 → immediate breaker failure, no retry

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=311
2026-04-25 18:14:40 +01:00
Snider
b6565263f3 fix(agent/brain): lock forget+supersede paths against late index writes
Cache::lock keyed by memory id wraps the delete path in BrainService::
forget(); supersede cleanup in remember() lifted to the same idiom.
forget() now ALWAYS queues DeleteFromIndex on a successful delete
(was previously skipped when indexed_at was null — left late writes
from stale preloaded models a window to land entries after the
underlying memory was gone).

Index write paths (qdrantUpsert / elasticIndex) re-check that the
memory row still exists before writing — defence-in-depth against any
future caller that holds a stale model reference past a forget.

Pest coverage extended in SupersedeForgetIndexCleanupTest:
- never-indexed forget queues cleanup
- late stale-model index writes are skipped after forget
- never-indexed supersede cleanup queues deletion
- late stale-model index writes are skipped after supersede

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=999
2026-04-25 18:04:55 +01:00
Snider
6832d40587 fix(agent/brain): batch — org maxLength + retry semantics + forget index cleanup
Codex 5.5 batch lane processed 8 brain Mantis tickets. 4 implemented,
1 stale, 3 deferred.

Tickets implemented:
- #313 — MCP schemas (BrainRemember/Recall/List): org field maxLength=128 with runtime validation; recall filter.org also bounded; pest test coverage added
- #314 — BrainList: removed withCircuitBreaker('brain') from DB-only handler; CircuitBreakerTest updated to assert no breaker call
- #315 — BrainService.retryableHttp(): now retries 408 (request-timeout), 429 (rate-limit), and 5xx; honours Retry-After header; focused retry tests added
- #326 — BrainService.forget(): dispatches DeleteFromIndex only when row has indexed_at (was unconditional); SupersedeForgetIndexCleanupTest covers never-indexed case

Tickets stale-fixed: #316 (RememberKnowledge already rejects missing/deleted supersedes target before dangling retry)
Tickets deferred: #121 (cross-surface audit), #311 (retry-inside-breaker architectural redesign), #312 (no authoritative org claim in MCP request context yet)

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=313
Closes tasks.lthn.sh/view.php?id=314
Closes tasks.lthn.sh/view.php?id=315
Closes tasks.lthn.sh/view.php?id=326
2026-04-25 14:55:40 +01:00
Snider
91551dec9b feat(mcp): implement extended RFC services + transport (#842)
Additive-only — no existing files modified.

Services (php/Mcp/Services/):
- CircuitBreaker (3-state, Cache::add trial lock)
- DataRedactor (28 sensitive + 16 PII keys, partial-redact algorithm)
- McpHealthService (YAML registry + JSON-RPC stdio ping protocolVersion 2024-11-05)
- McpMetricsService (p50/p95/p99 linear interpolation)
- McpWebhookDispatcher (mcp.tool.executed → WebhookEndpoints)
- OpenApiGenerator (OpenAPI 3.0.3)
- ToolRateLimiter (Cache::put first, Cache::increment after — no reset)
- AgentSessionService (php/Mod/Mcp/Services/ namespace per spec)

Transport (php/Mcp/Transport/):
- McpContext (transport-agnostic callbacks)
- Contracts/McpToolHandler interface

Resources (php/Mcp/Resources/):
- AppConfig, ContentResource, DatabaseSchema

Config: php/resources/mcp/registry.yaml.
Pest Feature tests _Good/_Bad/_Ugly per AX-10 for each new class.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=842
2026-04-25 05:50:16 +01:00
Snider
dffdad8418 feat(api): implement §3 fleet+credits+subscription+sync+agent-auth routes (#848)
Additive-only — appended to php/Routes/api.php (existing routes
preserved). Existing /v1/fleet/{nodes,heartbeat,stats} +
/v1/agent/auth/provision left untouched.

New routes:
- /v1/agent/auth/register
- /v1/fleet/dispatch + /v1/fleet/stream
- /v1/credits/{balance,deduct,refund,ledger}
- /v1/subscription/{status,upgrade,cancel}
- /v1/agent/sync/{push,pull}

New controllers under php/Controllers/Api/{Fleet,Credits,Subscription,
Sync,AgentAuth}/. Reference FleetService/CreditService/SessionService
when available with fallbacks to current action/model layer (pre #849).

Pest Feature coverage under php/tests/Feature/Api/. pest skipped
(vendor binaries missing in sandbox).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=848
2026-04-25 05:43:51 +01:00
Snider
470ce0de99 feat(agentic): implement §9 Services (FleetService + CreditService + SessionService) (#849)
Additive-only — no existing files modified.

- FleetService: wraps fleet actions+models, register/heartbeat/dispatch
  (direct or queued), node health snapshots, typed fleet stats
- CreditService: workspace-level balance/refund/deduct/ledger over
  credit_entries, returns typed CreditTransaction DTOs
- SessionService: RFC-§7 lifecycle session creation + guarded state
  transitions + SSE-style emission via Laravel events

DTOs: FleetStats, CreditTransaction (readonly).
Pest Feature tests _Good/_Bad/_Ugly per AX-10. pest skipped (vendor missing).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=849
2026-04-25 05:28:49 +01:00
Snider
066e1fee51 feat(mcp): implement §8 Console Commands (3 commands) (#853)
Additive-only — no existing files modified.

- McpAgentServerCommand: line-oriented JSON-RPC stdio loop over
  ToolRegistry with McpQuotaService + QueryAuditService hooks
- PruneMetricsCommand: prunes stale mcp_tool_metrics rows + aggregate
  reporting, fails cleanly when table missing
- McpMonitorCommand: status / alerts / export / report / prometheus
  subcommands, --json flag

Pest Feature tests _Good/_Bad/_Ugly per AX-10 for each command.
Boot.php registration deferred per scope (additive-only). pest skipped
(vendor binaries missing).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=853
2026-04-25 05:27:48 +01:00
Snider
8091bad2c0 feat(mcp): implement §4 Middleware (5 middleware classes) (#852)
Additive-only — no existing files modified.

- McpApiKeyAuth: validates Bearer or X-MCP-API-Key header, attaches
  workspace context
- CheckMcpQuota: consumes via McpQuotaService, exposes MCP quota headers
- ValidateWorkspaceContext: normalises + enforces authenticated workspace scope
- ValidateToolDependencies: JSON-RPC + flat tool-call payload validation
  via ToolDependencyService
- McpAuthenticate: combined auth gate chaining the full stack

Pest Feature tests _Good/_Bad/_Ugly per AX-10 for each middleware.
pest skipped (vendor binaries missing in sandbox).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=852
2026-04-25 05:25:09 +01:00
Snider
40dccb2a14 feat(agentic): implement §11 Admin UI Livewire components (FleetOverview + BrainExplorer + CreditLedger) (#850)
Additive-only — no existing files modified.

- FleetOverview: node list + status badges + dispatch button + stats panel
- BrainExplorer: semantic-recall search with DB fallback + forget action
- CreditLedger: balance display + transaction list + deduct/refund actions

Flux Pro components (no vanilla Alpine). Uses existing
fleet/brain/credit actions+services in this package.

Pest Feature tests _Good/_Bad/_Ugly per AX-10 — load classes directly
since composer.json + Boot.php were left untouched per scope. Future
follow-up: wire PSR-4 + view registration in Boot.php.

pest skipped (vendor binaries missing in sandbox).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=850
2026-04-25 05:16:50 +01:00
Snider
09054fbdab feat(mcp): implement §3 Services (ToolRegistry + McpQuotaService + QueryAuditService + ToolDependencyService) (#851)
Additive-only — no existing files modified.

- ToolRegistry: register/resolve/listTools/buildDependencyGraph
  - Singleton via registerSingleton() entry point (no Boot.php wire-in
    per scope; tests cover the binding path)
- McpQuotaService: workspace-scoped checkQuota/consume/reset
- QueryAuditService: log/query/aggregate (expects mcp_audit_entries
  table; tests create inline as migration was out-of-scope)
- ToolDependencyService: validateDependencies via graph traversal

Data DTOs: ToolMetadata, QuotaResult, AuditEntry as readonly.
Pest Feature tests _Good/_Bad/_Ugly per AX-10.
pest skipped (vendor binaries missing).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=851
2026-04-25 05:14:15 +01:00
Snider
c616ff1e32 fix(brain): close openbrain audit gaps — org scoping + index cleanup + reindex flags + MCP schemas + circuit layer
Closes the 5 PARTIAL items flagged in docs/AUDIT-openbrain-20260424.md.

- Gap A (org scoping persisted on writes): new migration adds `org`
  nullable+indexed column to brain_memories; BrainMemory fillable;
  RememberKnowledge action forwards org; BrainService::remember
  persists it.

- Gap B (supersede/forget Elastic cleanup): BrainService::forget
  dispatches DeleteFromIndex (handles both Qdrant + Elastic); supersede
  path dispatches cleanup for the old memory id before replacing it.
  DeleteFromIndex itself untouched — already handled both indexes.

- Gap C (brain:reindex flags): --org, --project, --stale (null OR
  >14d old), --dry-run (count+stop), --elastic-only added to the
  artisan command.

- Gap D (MCP schemas expose org): brain_remember, brain_recall,
  brain_list now accept `org` in input schema + forward into
  action/service.

- Gap E (resilience uneven): brain_list now wrapped in
  withCircuitBreaker('brain', ...) matching the pattern used by
  BrainRemember/Recall/Forget. BrainService gains retryableHttp()
  helper — 100/300/900ms exponential backoff, retries only on 5xx +
  connection errors, not on 4xx. Qdrant calls route through it;
  Ollama left alone (EmbedMemory job has its own retry).

Tests (Good/Bad/Ugly per gap):
- Feature/Brain/OrgScopingTest.php
- Feature/Brain/SupersedeForgetIndexCleanupTest.php
- Feature/Brain/ReindexFlagsTest.php
- Feature/Mcp/BrainSchemaOrgTest.php
- Feature/Brain/CircuitBreakerTest.php

php -l clean on all 13 files. Pest binary not in this checkout —
CI path validates the full suite.

Closes tasks.lthn.sh/view.php?id=107

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 08:14:06 +01:00
Snider
4c1fa56d17 fix(brain): wire Qdrant api-key header from BRAIN_QDRANT_API_KEY
BrainService::http() was building a PendingRequest with no auth
header, so when Qdrant has auth enabled (the production lthn.sh
deploy does) every upsert/lookup returned 401. The circuit breaker
logged the 401 via Cache::store('file'), which was the red-herring
cache-write error chased in the first #97 iteration.

Changes:
- BrainService loads + trims a Qdrant api key from
  config('brain.qdrant.api_key') in the constructor.
- New qdrantHttp() helper returns a PendingRequest with the
  api-key header when the key is non-empty, or the plain client
  otherwise. Ollama + Elasticsearch call sites still use http()
  (separate auth shapes).
- php/config.php adds a brain.qdrant.api_key entry reading
  env('BRAIN_QDRANT_API_KEY').
- Good/Bad/Ugly Pest tests cover: configured key → header sent,
  unset → header absent, empty-string → header absent.

Closes tasks.lthn.sh/view.php?id=97

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-24 05:21:15 +01:00
Snider
7639f56c2d test(brain): partial MCP smoke-test for remember/list/forget
Exercises the 3 MCP handlers that work MariaDB-only (no Qdrant
dependency): brain_remember writes + returns id, brain_list
surfaces it, brain_forget removes. Negative case: brain_forget on
a non-existent id returns a proper error response (not TypeError).
brain_recall is out of scope — needs the Qdrant collection +
embedding pipeline.

Implementation note: handlers use `type` + workspace context for
scoping, not a `scope` parameter; the test matches the actual
signatures.

Closes tasks.lthn.sh/view.php?id=96

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-23 18:57:22 +01:00
Snider
d4f2fa9204 refactor(forge): route ScanForWork + ManagePullRequest through MetaReader
ScanForWork and ManagePullRequest now depend on the MetaReader
interface (added in #89) instead of reading raw Forgejo body /
description / PR text. Epic child-linkage comes from
EpicMeta.children, PR merge decisions come from PRMeta.state /
mergeability / checkStatuses. The returned shape drops issue_body
and replaces it with structural issue_state / issue_labels.

Adds a feature test that injects a mocked MetaReader carrying
intentionally-tainted body/description/review_text fields and
recursively asserts none of those keys appear in the output of
either action — the regression fence for the RFC rule that body
content must never reach pipeline decisions.

Closes tasks.lthn.sh/view.php?id=90

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-23 18:21:28 +01:00
Snider
3f5f4d15fe fix(mcp): SessionArtifact passes description as metadata array
AgentSession::addArtifact expects ?array $metadata in the third
argument slot; the MCP tool was passing the optional description
string directly, producing a TypeError whenever a caller supplied a
non-null description. Wrap the description into a metadata array so
the call matches the model signature, and add a feature test that
exercises the MCP handler end-to-end to prevent regression.

Closes tasks.lthn.sh/view.php?id=95

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-23 18:10:21 +01:00
Snider
5e2aecd68a feat(sync): update WorkspaceState workflow progress on dispatch push
Extend PushDispatchHistory so /v1/agent/sync writes four sync.*
workflow-progress keys into WorkspaceState (last_dispatch_at,
last_agent_type, last_findings_count, last_status) in addition to the
existing BrainMemory + SyncRecord persistence. Plan resolves via
agent_plan_id first, plan_slug fallback. Missing plan is treated as
non-fatal — state writes are skipped, BrainMemory still persists.

Adds a three-case feature test covering direct id, slug fallback, and
the missing-plan safety branch.

Closes tasks.lthn.sh/view.php?id=93

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-23 18:10:17 +01:00
Snider
e83c3d811d feat(pipeline): add MetaReader contract + Forgejo-backed implementation
Introduce a pipeline metadata surface that enforces "no body content
ever reaches pipeline decisions". MetaReader is an interface with four
methods (getPRMeta, getEpicMeta, getIssueState, getCommentReactions),
each returning a readonly DTO carrying only structural fields —
state, mergeability, SHAs, branches, reaction counts, child linkage.
ForgejoMetaReader projects raw Forgejo API payloads into these DTOs
and drops body/description/review text before the caller can see it.

Unit test mocks rich Forgejo payloads containing body, description,
review_text, and comment_body, then asserts the DTO toArray output
never exposes those keys — the regression fence for the RFC rule.

Downstream callers (ScanForWork, ManagePullRequest) still use the
raw ForgejoService today; that refactor lands under Mantis #90.

Closes tasks.lthn.sh/view.php?id=89

Co-authored-by: Codex <noreply@openai.com>
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-23 18:09:54 +01:00
Snider
296ed59b1b feat(brain): add GET /v1/brain/search — ES full-text endpoint
New endpoint GET /v1/brain/search?q=<query>&org=<>&project=<>&limit=<N>
that full-text-searches brain_memories via Elasticsearch using
BrainService::elasticSearch(). Separate from /v1/brain/recall (which is
vector/semantic via Qdrant) — this one is keyword/lexical.

Sits under the existing brain.read-auth middleware group.

Pest coverage (Http::fake for ES): Good (matches), Bad (invalid limit),
Ugly (empty query + filters).

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=64
2026-04-23 13:54:11 +01:00
Snider
a13f4c4bbd feat(brain): add GET /v1/brain/tags + /v1/brain/scopes
Two introspection endpoints for OpenBrain:
- GET /v1/brain/tags — ES terms aggregation over tags.keyword, returns
  {tag: count} pairs for UI filter chips
- GET /v1/brain/scopes — composite aggregation over {org, project},
  returns the scope hierarchy present in the index

Sits under the existing brain.read-auth group in Routes/api.php. New
BrainService helpers for aggregation shape; reuses the elasticSearch
HTTP path added in #59.

Pest coverage with Http::fake for ES.

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=65
2026-04-23 13:52:50 +01:00
Snider
6da45637f5 feat(brain): extend /v1/brain/recall with org + keywords + boost_keywords
Hybrid recall: vector (Qdrant) + keyword (ES) with dedupe-by-id and
score re-ranking via configurable boost multiplier.

- org: string → adds to Qdrant must-filter + ES filter
- keywords: array<string> → when present, ES full-text hits merge into
  results; without keywords, path stays purely vector
- boost_keywords: array<string> → each matched boost-keyword amplifies
  the memory's score by mcp.brain.boost_keywords_multiplier (default 1.5)

BrainService gains a hybridRecall() helper; BrainController::recall()
delegates to it. Existing request fields (query, limit, workspace_id,
project, type) unchanged.

php/tests/Feature/Api/BrainRecallExtendedTest.php — Pest coverage with
Http::fake for both Qdrant + ES, asserting dedupe + boost behaviour.

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=63
2026-04-23 13:51:37 +01:00
Snider
34525038a8 feat(brain): add brain:prune artisan command
New artisan command brain:prune {--older-than=90} {--chunk=100} {--dry-run}
that completes the soft-delete → hard-delete lifecycle by:
1. selecting BrainMemory::onlyTrashed() where deleted_at < now - N days
2. dispatching DeleteFromIndex for each (Qdrant + ES cleanup)
3. forceDelete()'ing the rows

--dry-run counts without dispatching.

Complements brain:clean (which cleans recent soft-deletes) with a
retention-bounded terminal cleanup.

Pest coverage: Good (dispatch + forceDelete on aged trashed rows), Bad
(invalid chunk), Ugly (--dry-run skips both dispatch and delete).

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=62
2026-04-23 13:36:41 +01:00
Snider
638091f3b3 feat(brain): add brain:clean artisan command
New artisan command brain:clean {--chunk=100} {--dry-run} that dispatches
the DeleteFromIndex job for soft-deleted BrainMemory rows (those in
onlyTrashed scope). Cleans up orphaned Qdrant + Elasticsearch index
entries that remain after a memory is soft-deleted.

--dry-run counts without dispatching.

php/tests/Feature/Console/BrainCleanCommandTest.php covers Good
(dispatches on trashed), Bad (invalid chunk), Ugly (--dry-run prevents
dispatch).

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=61
2026-04-23 13:30:27 +01:00
Snider
677d890308 feat(brain): make BrainService::remember() async via EmbedMemory job
remember() now writes the brain_memories row with indexed_at=null and
dispatches EmbedMemory::dispatch($memory->id) for async Qdrant + ES
indexing, instead of calling qdrantUpsert() synchronously. Response shape
matches the row state — caller gets the memory immediately, the Job
flips indexed_at once the Qdrant write succeeds.

Superseded rows still soft-delete synchronously (part of the remember
contract, not the indexing path).

php/tests/Feature/Services/BrainServiceRememberTest.php uses Queue::fake()
to assert EmbedMemory is dispatched and BrainService::qdrantUpsert() is
NOT called directly (subclass probe).

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=55
2026-04-23 13:29:30 +01:00
Snider
47f241d880 feat(brain): add brain:reindex artisan command
New artisan command brain:reindex {--all} {--chunk=100} that dispatches
the EmbedMemory job for brain memories needing (re)indexing. Without
--all, only memories where indexed_at IS NULL are dispatched; --all
re-embeds every memory (useful after a Qdrant collection wipe or
embedding model change). Uses chunkById for memory-safe iteration at
scale.

php/tests/Feature/Console/BrainReindexCommandTest.php covers Good
(unindexed-only default), Bad (invalid chunk), Ugly (--all flag).

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=60
2026-04-23 13:29:18 +01:00
Snider
f223a77daa feat(brain): add Elasticsearch indexing to BrainService
Fills in the elasticIndex/elasticDelete stubs added by #56 and #57, plus a
new elasticSearch() method used by the upcoming /v1/brain/search endpoint
(#64).

- elasticIndex(BrainMemory) → PUT /brain_memories/_doc/{id}
- elasticDelete(string $id) → DELETE /brain_memories/_doc/{id}
- elasticSearch(string $query, array $filters) → POST /brain_memories/_search
- ES URL default http://127.0.0.1:9200 (config override via
  BRAIN_ELASTICSEARCH_URL env var)
- RuntimeException on HTTP failures (same pattern as qdrantUpsert)

php/tests/Feature/Services/BrainServiceElasticTest.php covers Good/Bad/Ugly
for index, delete, and search using Http::fake.

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=59
2026-04-23 13:11:29 +01:00
Snider
8d520adb5e feat(brain): add DeleteFromIndex job
Inverse of the EmbedMemory job (#56): removes a memory from Qdrant (and
the future Elasticsearch index) when brain_forget fires or a memory is
soft-deleted.

- php/Jobs/DeleteFromIndex.php — Laravel Job, 3 retries with backoff
- BrainService: qdrantDelete() private→public and now throws on HTTP
  failure (was silent Log::warning — wouldn't trigger Job retry)
- elasticDelete() stub added (fills in with the ES integration ticket)
- php/tests/Feature/Jobs/DeleteFromIndexTest.php — success + HTTP-failure
  paths via mocked Http

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=57
2026-04-23 12:55:45 +01:00
Snider
4dc5ed8d14 feat(brain): add EmbedMemory job + indexed_at tracking
Implements the async-embedding pipeline's worker side:

- php/Jobs/EmbedMemory.php — Laravel Job that calls BrainService::embed()
  + qdrantUpsert() and sets indexed_at on success
- php/Migrations/…_add_indexed_at_to_brain_memories.php — nullable
  timestamp + index, portable across pgsql/mariadb (hasColumn guard)
- BrainMemory: +indexed_at fillable + datetime cast + PHPDoc
- BrainService: qdrantUpsert() private→public so the Job can use it;
  elasticIndex() stub added (to be implemented by the ES ticket)
- php/tests/Feature/Jobs/EmbedMemoryTest.php — Pest tests for success
  path and Qdrant-failure path

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=56
2026-04-23 12:47:10 +01:00
Snider
bd060c8aa0 feat(brain): add org filter to BrainService::buildQdrantFilter()
Adds an `org` match filter between workspace_id and project in the Qdrant
payload filter chain. Multi-org isolation for OpenBrain memory retrieval.

Co-authored-by: Codex <noreply@openai.com>

Closes tasks.lthn.sh/view.php?id=58
2026-04-23 12:32:58 +01:00
e58986a3b4 revert fcb9c189e5
revert fix(agentic): harden TODO workspace write

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-23 12:32:57 +01:00
Codex
cbc262add4 fix(agentic): harden TODO workspace write
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-23 12:32:57 +01:00
Snider
ccedf536d6 fix(agent-tool-registry): harden rate limiting and api key identifiers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-17 20:48:29 +01:00
Snider
1e8af462f2 fix(agentic): harden tool execution and template validation
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-17 20:25:19 +01:00
Snider
39914fbf14 refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.

Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:00 +01:00
Virgil
425008f855 feat(brain): expose supersession metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:53:58 +00:00
Virgil
c4f5b77786 feat(agentic): add canonical generate command
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 04:54:20 +00:00