Compare commits
24 Commits
e04f9059ae
...
e74d28a808
| Author | SHA1 | Date | |
|---|---|---|---|
| e74d28a808 | |||
| e049eef81a | |||
| 4ac595a3a9 | |||
| 544637c073 | |||
| 4dfc870dd1 | |||
| cf4d57ea16 | |||
| 9ab6b7dfed | |||
| 1e66d3dcb5 | |||
| fb93655636 | |||
| eadda808d2 | |||
| f5c14efb37 | |||
| 546d3b9438 | |||
|
|
73ebe6408d | ||
| 6869e4ea09 | |||
|
|
fa1bddf537 | ||
|
|
406083310f | ||
|
|
995d639b60 | ||
|
|
b8efe4732d | ||
|
|
4363130163 | ||
|
|
808c3ee254 | ||
|
|
7ef3ffa00e | ||
|
|
85033136d8 | ||
|
|
52190b63b8 | ||
|
|
b7c503499a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
adolf/.env
|
adolf/.env
|
||||||
seafile/.env
|
seafile/.env
|
||||||
|
openai/.env
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ This repository manages Docker Compose configurations for the **Agap** self-host
|
|||||||
| `gitea/` | Gitea (git hosting) + Postgres | 3000, 222 | Standalone compose |
|
| `gitea/` | Gitea (git hosting) + Postgres | 3000, 222 | Standalone compose |
|
||||||
| `openai/` | Open WebUI + Ollama (AI chat) | 3125 | Requires NVIDIA GPU |
|
| `openai/` | Open WebUI + Ollama (AI chat) | 3125 | Requires NVIDIA GPU |
|
||||||
| `vaultwarden/` | Vaultwarden (password manager) | 8041 | Backup script in `vaultwarden/backup.sh` |
|
| `vaultwarden/` | Vaultwarden (password manager) | 8041 | Backup script in `vaultwarden/backup.sh` |
|
||||||
|
| `kanboard/` | Kanboard (kanban board) | 4800 | Tasks assignable to the `claude` bot user — see `kanboard/CLAUDE.md` |
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
|
|||||||
109
Caddyfile
109
Caddyfile
@@ -1,5 +1,11 @@
|
|||||||
|
{
|
||||||
|
servers {
|
||||||
|
protocols h1 h2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
haos.alogins.net {
|
haos.alogins.net {
|
||||||
reverse_proxy http://192.168.1.141:8123 {
|
reverse_proxy http://192.168.1.4:8123 {
|
||||||
|
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
header_up X-Forwarded-Proto {scheme}
|
header_up X-Forwarded-Proto {scheme}
|
||||||
@@ -16,7 +22,7 @@ doc.alogins.net {
|
|||||||
}
|
}
|
||||||
|
|
||||||
zb.alogins.net {
|
zb.alogins.net {
|
||||||
reverse_proxy localhost:81
|
reverse_proxy 192.168.1.4:81
|
||||||
}
|
}
|
||||||
|
|
||||||
wiki.alogins.net {
|
wiki.alogins.net {
|
||||||
@@ -27,6 +33,10 @@ wiki.alogins.net {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lt.alogins.net {
|
||||||
|
reverse_proxy localhost:4321
|
||||||
|
}
|
||||||
|
|
||||||
nn.alogins.net {
|
nn.alogins.net {
|
||||||
reverse_proxy localhost:5678
|
reverse_proxy localhost:5678
|
||||||
}
|
}
|
||||||
@@ -43,12 +53,41 @@ ai.alogins.net {
|
|||||||
reverse_proxy localhost:3125
|
reverse_proxy localhost:3125
|
||||||
}
|
}
|
||||||
|
|
||||||
|
news.alogins.net {
|
||||||
|
reverse_proxy localhost:8091
|
||||||
|
}
|
||||||
|
|
||||||
|
family.alogins.net {
|
||||||
|
reverse_proxy localhost:8099
|
||||||
|
}
|
||||||
|
|
||||||
|
lw.alogins.net {
|
||||||
|
reverse_proxy localhost:3012
|
||||||
|
}
|
||||||
|
|
||||||
|
todo.alogins.net {
|
||||||
|
reverse_proxy localhost:3457
|
||||||
|
}
|
||||||
|
|
||||||
|
ttt.alogins.net {
|
||||||
|
reverse_proxy localhost:3003
|
||||||
|
}
|
||||||
|
|
||||||
openpi.alogins.net {
|
openpi.alogins.net {
|
||||||
root * /home/alvis/tmp/files/pi05_droid
|
root * /home/alvis/tmp/files/pi05_droid
|
||||||
file_server browse
|
file_server browse
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dl.alogins.net {
|
||||||
|
@chiefx17 path /chief-x17.zip
|
||||||
|
handle @chiefx17 {
|
||||||
|
root * /mnt/misc/qbittorrent/downloads
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
respond 404
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
vui3.alogins.net {
|
vui3.alogins.net {
|
||||||
@xhttp {
|
@xhttp {
|
||||||
@@ -69,6 +108,24 @@ vui3.alogins.net {
|
|||||||
respond 401
|
respond 401
|
||||||
}
|
}
|
||||||
|
|
||||||
|
o.alogins.net {
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy localhost:3078
|
||||||
|
}
|
||||||
|
handle /admin* {
|
||||||
|
reverse_proxy localhost:3080
|
||||||
|
}
|
||||||
|
handle /mlflow* {
|
||||||
|
reverse_proxy localhost:5000
|
||||||
|
}
|
||||||
|
handle /airflow* {
|
||||||
|
reverse_proxy localhost:8080
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
reverse_proxy localhost:3079
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
vui4.alogins.net {
|
vui4.alogins.net {
|
||||||
reverse_proxy localhost:58959
|
reverse_proxy localhost:58959
|
||||||
}
|
}
|
||||||
@@ -116,6 +173,54 @@ lk.alogins.net {
|
|||||||
reverse_proxy localhost:7880
|
reverse_proxy localhost:7880
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lf.alogins.net {
|
||||||
|
reverse_proxy localhost:3200
|
||||||
|
}
|
||||||
|
|
||||||
|
llm.alogins.net {
|
||||||
|
reverse_proxy localhost:4000
|
||||||
|
}
|
||||||
|
|
||||||
|
overleaf.alogins.net {
|
||||||
|
reverse_proxy localhost:8089
|
||||||
|
}
|
||||||
|
|
||||||
|
voice.alogins.net {
|
||||||
|
reverse_proxy localhost:8882
|
||||||
|
}
|
||||||
|
|
||||||
|
iperf.alogins.net {
|
||||||
|
reverse_proxy localhost:8095
|
||||||
|
}
|
||||||
|
|
||||||
|
anki.alogins.net {
|
||||||
|
reverse_proxy localhost:8180
|
||||||
|
}
|
||||||
|
|
||||||
|
kb.alogins.net {
|
||||||
|
reverse_proxy localhost:4800
|
||||||
|
}
|
||||||
|
|
||||||
|
mood.alogins.net {
|
||||||
|
reverse_proxy localhost:5177
|
||||||
|
}
|
||||||
|
|
||||||
|
sync.alogins.net {
|
||||||
|
reverse_proxy localhost:8384
|
||||||
|
}
|
||||||
|
|
||||||
|
dav.alogins.net {
|
||||||
|
reverse_proxy localhost:5232
|
||||||
|
}
|
||||||
|
|
||||||
|
tor.alogins.net {
|
||||||
|
reverse_proxy localhost:8085
|
||||||
|
}
|
||||||
|
|
||||||
|
win.alogins.net {
|
||||||
|
reverse_proxy localhost:8006
|
||||||
|
}
|
||||||
|
|
||||||
localhost:8042 {
|
localhost:8042 {
|
||||||
reverse_proxy localhost:8041
|
reverse_proxy localhost:8041
|
||||||
tls internal
|
tls internal
|
||||||
|
|||||||
219
adolf/HINDSIGHT-MIGRATION.md
Normal file
219
adolf/HINDSIGHT-MIGRATION.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Adolf memory migration — Cognee → Hindsight
|
||||||
|
|
||||||
|
**Status:** Planned · **Date:** 2026-07-13 · **Owner:** alvis
|
||||||
|
|
||||||
|
This is the authoritative design + plan for replacing Adolf's long-term memory
|
||||||
|
subsystem (**Cognee**) with **Hindsight** (Vectorize, MIT, self-hosted). It
|
||||||
|
supersedes the Cognee-specific parts of `docs/ARCHITECTURE.md` and gates 4–5 of
|
||||||
|
`docs/SPIKE-FINDINGS.md` in the OpenClaw fork (`/home/alvis/adolf`).
|
||||||
|
|
||||||
|
Memory stays integrated into Adolf **exactly the two ways Cognee was** — as a
|
||||||
|
**tool** (MCP) and as **forced hooks** (an OpenClaw memory plugin) — so no
|
||||||
|
behaviour the user sees is lost; only the backend changes.
|
||||||
|
|
||||||
|
> Scope note: this document + the kanboard **Ready** tasks (H1–H5) are the
|
||||||
|
> migration. No live service, compose file, `openclaw.json`, or plugin code has
|
||||||
|
> been changed yet — those edits are the H-tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why migrate
|
||||||
|
|
||||||
|
Cognee works, but its Agap deployment carries three structural costs, all
|
||||||
|
documented in the (now-retired) kanboard cognee tasks:
|
||||||
|
|
||||||
|
- **Three bespoke services** to keep the memory stack alive: `cognee` (FastAPI +
|
||||||
|
embedded Kuzu graph + Qdrant vectors), `cognee-mcp` (a patched MCP→HTTP proxy,
|
||||||
|
local build overlay from kb#70), and `cognee-llm` (a stateless Kimi-CLI wrapper
|
||||||
|
that exists *only* to give Cognee an LLM on the flat subscription).
|
||||||
|
- **The cognify pipeline is fragile and expensive.** "Cognify" (turning raw
|
||||||
|
turns into a graph) is an LLM step. On the Kimi CLI it runs ~5–24 s per call
|
||||||
|
and drains the single-seat subscription quota (SPIKE gate 5 recommended
|
||||||
|
LiteLLM instead). The plugin's async "cognify sweep" also silently stalled
|
||||||
|
twice (kb#69) because of OpenClaw plugin-lifecycle edge cases, so freshly told
|
||||||
|
facts weren't retrievable cross-session until the sweep was re-armed.
|
||||||
|
- **Scoping is best-effort.** Under `ENABLE_BACKEND_ACCESS_CONTROL=False` all
|
||||||
|
datasets share one graph/vector backend, so per-chat isolation leaks (kb#59).
|
||||||
|
|
||||||
|
## 2. What Hindsight gives us
|
||||||
|
|
||||||
|
- **One container.** `ghcr.io/vectorize-io/hindsight:latest` — REST API on
|
||||||
|
**:8888**, web UI on **:9999**, built-in PostgreSQL (`pg0`, persisted under
|
||||||
|
`/home/hindsight/.pg0`). It owns its own vector + graph + temporal
|
||||||
|
representation internally (the "four-network" model), so **no external Qdrant
|
||||||
|
or Kuzu** is needed for memory.
|
||||||
|
- **Built-in MCP server** mounted at `/mcp` on the same port — ~30 tools
|
||||||
|
including `retain` / `recall` / `reflect`. This **removes the need for a
|
||||||
|
separate `cognee-mcp` proxy container entirely**.
|
||||||
|
- **Retain learns on its own.** `retain` runs Hindsight's extraction/reflection
|
||||||
|
pipeline internally (optionally `async`), so there is **no separate "cognify
|
||||||
|
sweep" to arm, throttle, or watch** — the whole class of kb#69 bugs disappears.
|
||||||
|
- **Memory banks** are first-class isolation units, scoped by URL path
|
||||||
|
(`/v1/{tenant}/banks/{bank_id}/…`), so per-chat / per-user scoping is real, not
|
||||||
|
best-effort.
|
||||||
|
- **Bring-your-own LLM/embeddings** — OpenAI-compatible, Anthropic, Ollama,
|
||||||
|
LMStudio, etc. We point it at the infra we already run (LiteLLM `:4000` and/or
|
||||||
|
Ollama), so we can **delete `cognee-llm`** rather than port it.
|
||||||
|
|
||||||
|
Net: **3 services → 1**, plus we drop the cognify-sweep machinery.
|
||||||
|
|
||||||
|
## 3. Target architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Matrix ⇄ OpenClaw ("Adolf" gateway)
|
||||||
|
│ provider adolf-llm (Kimi wrapper, :8010) — unchanged
|
||||||
|
▼
|
||||||
|
adolf-llm ── kimi CLI (agent)
|
||||||
|
│
|
||||||
|
┌─────────┴───────────────── memory is a boundary concern ─────────────┐
|
||||||
|
│ │
|
||||||
|
│ (a) FORCED HOOKS — hindsight-memory OpenClaw plugin │
|
||||||
|
│ before_prompt_build → recall → inject as prependContext │
|
||||||
|
│ agent_end → retain (async:true) the turn │
|
||||||
|
│ │
|
||||||
|
│ (b) TOOL — Hindsight built-in MCP in openclaw.json mcp.servers │
|
||||||
|
│ http://hindsight:8888/mcp/adolf/ → retain/recall/reflect/… │
|
||||||
|
└───────────────────────────────┬───────────────────────────────────────┘
|
||||||
|
▼
|
||||||
|
hindsight (ONE container)
|
||||||
|
:8888 REST + /mcp · :9999 UI
|
||||||
|
built-in Postgres (pg0)
|
||||||
|
LLM → LiteLLM :4000 (or Ollama) [decision, §6]
|
||||||
|
embeddings → Ollama / built-in [decision, §6]
|
||||||
|
```
|
||||||
|
|
||||||
|
Reused infra: **LiteLLM `:4000`** and/or **Ollama** for Hindsight's model calls.
|
||||||
|
**Retired:** `cognee`, `cognee-mcp`, `cognee-llm`, Qdrant-for-cognee, Kuzu, the
|
||||||
|
`cognee-openclaw-plugin`, and all `openclaw.json` cognee references.
|
||||||
|
|
||||||
|
### 3.1 Surface (a) — forced hooks (the `hindsight-memory` plugin)
|
||||||
|
|
||||||
|
A new OpenClaw memory plugin replacing `cognee-openclaw-plugin`, modelled on the
|
||||||
|
same Honcho touchpoints Cognee used, so the plugin shape is familiar:
|
||||||
|
|
||||||
|
| Cognee plugin (`cognee-memory`) | Hindsight plugin (`hindsight-memory`) |
|
||||||
|
|-----------------------------------------------------|---------------------------------------------------------------|
|
||||||
|
| `before_prompt_build` → LLM-free graph recall inject | `before_prompt_build` → `recall` → inject `prependContext` |
|
||||||
|
| `agent_end` → raw `/add` (no inline cognify) | `agent_end` → `retain` (`async:true`) the user+assistant turn |
|
||||||
|
| throttled **cognify sweep** (dirty-tracker, timers) | **removed** — retain does extraction/learning internally |
|
||||||
|
| `cognee_recall` registered tool | `hindsight_recall` (+ optional `hindsight_reflect`) tool |
|
||||||
|
|
||||||
|
- Activated via `plugins.entries.hindsight-memory` in `openclaw.json` with the
|
||||||
|
same hook grants Cognee needed: `hooks.allowConversationAccess: true` and
|
||||||
|
`allowPromptInjection: true` (external plugins must opt in).
|
||||||
|
- **Recall stays off the hot LLM path.** Hindsight `recall` is retrieval
|
||||||
|
(semantic + BM25 + graph + temporal) with evidence scoring — no generative
|
||||||
|
synthesis — so it's the direct analogue of Cognee's LLM-free `onlyContext`
|
||||||
|
recall. The LLM-backed synthesis path is `reflect`, exposed as a deliberate
|
||||||
|
tool, not run per-turn.
|
||||||
|
- **No freshness dial / sweep.** Retain with `async:true` returns fast and lets
|
||||||
|
Hindsight extract/consolidate in the background; there is no plugin-owned timer
|
||||||
|
to stall.
|
||||||
|
- Same "untrusted metadata" framing on the injected block; same cleaning of
|
||||||
|
OpenClaw's `Conversation info (untrusted metadata):` and the memory block out
|
||||||
|
of stored/queried text.
|
||||||
|
|
||||||
|
### 3.2 Surface (b) — tool (built-in MCP)
|
||||||
|
|
||||||
|
Replace the `cognee` entry in `openclaw.json` `mcp.servers` with:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
mcp: {
|
||||||
|
servers: {
|
||||||
|
hindsight: {
|
||||||
|
type: "http",
|
||||||
|
url: "http://hindsight:8888/mcp/adolf/", // bank-scoped by URL path
|
||||||
|
},
|
||||||
|
// openclaw-tools, kanboard, marketplace — unchanged
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The single bank in the path (`adolf`, or a per-chat bank) selects isolation; the
|
||||||
|
built-in MCP then exposes `retain`, `recall`, `reflect`, plus mental-model /
|
||||||
|
directive / memory-browse tools. This replaces cognee-mcp's `remember` / `recall`
|
||||||
|
/ `forget` — and gives deliberate delete via the memory-management tools instead
|
||||||
|
of the hand-patched `forget(data_id)` from kb#70.
|
||||||
|
|
||||||
|
## 4. REST / MCP API mapping
|
||||||
|
|
||||||
|
Base path: `http://hindsight:8888/v1/default` (tenant `default`). Bank id is a
|
||||||
|
**path** parameter.
|
||||||
|
|
||||||
|
| Operation | Cognee (old) | Hindsight (new) |
|
||||||
|
|------------------|------------------------------------------------|-----------------------------------------------------------------------|
|
||||||
|
| store a turn | `POST /api/v1/add` (+ later `/cognify`) | `POST /banks/{bank}/memories` body `{items:[{content,context,tags,timestamp}], async:true}` |
|
||||||
|
| recall (no LLM) | `POST /api/v1/search` `GRAPH_COMPLETION` `onlyContext:true` | `POST /banks/{bank}/memories/recall` body `{query, budget, max_tokens, tags}` |
|
||||||
|
| deep answer (LLM)| cognee-mcp `recall` (GRAPH_COMPLETION) | `POST /banks/{bank}/reflect` body `{query, budget, max_tokens, response_schema?}` |
|
||||||
|
| delete an entry | patched `forget(dataset, data_id)` (kb#70) | memory-management endpoints / MCP (`delete`, `clear_memories`) |
|
||||||
|
| list / inspect | dataset status polling | `GET /banks/{bank}/memories/list`, `GET /banks` |
|
||||||
|
| create bank | dataset created implicitly on add | `PUT /banks/{bank}` |
|
||||||
|
|
||||||
|
Built-in MCP tools live at `http://hindsight:8888/mcp/{bank}/` (HTTP transport;
|
||||||
|
bank via URL path, `X-Bank-Id` header, or `HINDSIGHT_MCP_BANK_ID` default).
|
||||||
|
|
||||||
|
> Exact request-body field names and any auth headers must be confirmed against
|
||||||
|
> the running instance's OpenAPI (`http://localhost:8888/docs`) and the Hindsight
|
||||||
|
> configuration docs during H1 — treat the bodies above as the shape, not gospel.
|
||||||
|
|
||||||
|
## 5. Bank scoping
|
||||||
|
|
||||||
|
Mirror Cognee's per-conversation `chat_<chatId>` dataset with a per-conversation
|
||||||
|
**bank**: derive `bank_id` from OpenClaw's `chat_id` (the
|
||||||
|
`Conversation info (untrusted metadata):` block; see SPIKE gate 2), sanitized to
|
||||||
|
`chat_<slug>`. A single shared `adolf` bank is the simpler alternative if
|
||||||
|
cross-chat recall is actually wanted — decide in H3. Banks are hard isolation in
|
||||||
|
Hindsight, so per-chat is now safe (unlike Cognee's leaky datasets).
|
||||||
|
|
||||||
|
## 6. Open decisions (resolve in H1)
|
||||||
|
|
||||||
|
1. **LLM backend for retain/reflect.** SPIKE gate 5 already concluded the
|
||||||
|
extraction workload should *not* sit on the Kimi CLI (latency + single-seat
|
||||||
|
quota). Recommendation: point Hindsight's LLM at **LiteLLM `:4000`** (or a
|
||||||
|
local **Ollama** model for zero marginal cost). This is why `cognee-llm` is
|
||||||
|
deleted, not ported. Confirm Hindsight's provider env-var names on the image.
|
||||||
|
2. **Embeddings.** Prefer the local **Ollama** embedder already available
|
||||||
|
(`nomic-embed` / `bge-m3` at `host.docker.internal:11436`) or Hindsight's
|
||||||
|
built-in, to keep embeddings off any paid path.
|
||||||
|
3. **Storage path.** Persist `pg0` under `/mnt/ssd/dbs/hindsight/` to match the
|
||||||
|
Agap storage layout (replaces `/mnt/ssd/dbs/cognee/`).
|
||||||
|
4. **UI exposure.** Whether to reverse-proxy the `:9999` UI (Caddy) or keep it
|
||||||
|
internal-only.
|
||||||
|
5. **Auth.** Open by default; enable the tenant API-key extension
|
||||||
|
(`HINDSIGHT_API_TENANT_API_KEY`, `Authorization: Bearer`) if the service is
|
||||||
|
reachable beyond the compose network.
|
||||||
|
|
||||||
|
## 7. Data migration
|
||||||
|
|
||||||
|
Cognee's Kuzu graph is **not** portable into Hindsight's store. The memory corpus
|
||||||
|
is low-value conversational history, so **start Hindsight empty** rather than
|
||||||
|
building an exporter. Optionally replay a handful of durable facts by calling
|
||||||
|
`retain` once at cutover. The two throwaway datasets left in Cognee
|
||||||
|
(`chat_verify`, `chat_webchat`) are discarded with the stack.
|
||||||
|
|
||||||
|
## 8. Migration phases (kanboard **Ready**, project *Adolf*)
|
||||||
|
|
||||||
|
- **H1 · Deploy Hindsight service** — add the `hindsight` container to
|
||||||
|
`openai/docker-compose.yml` (image, ports 8888/9999, `pg0` volume, LLM +
|
||||||
|
embedding provider env → LiteLLM/Ollama), bring it up, confirm `/docs` + a
|
||||||
|
round-trip `retain`→`recall`. Resolves §6 decisions.
|
||||||
|
- **H2 · Wire built-in MCP as an Adolf tool** — swap `mcp.servers.cognee` →
|
||||||
|
`mcp.servers.hindsight` (`/mcp/{bank}/`) in `openclaw.json`; verify Adolf can
|
||||||
|
call `retain`/`recall`/`reflect` as tools.
|
||||||
|
- **H3 · `hindsight-memory` OpenClaw plugin (forced hooks)** — build the plugin
|
||||||
|
replacing `cognee-openclaw-plugin`: `before_prompt_build`→recall inject,
|
||||||
|
`agent_end`→`retain(async)`, `hindsight_recall`/`hindsight_reflect` tools, bank
|
||||||
|
scoping; activate in `openclaw.json`. Delete the cognify-sweep machinery.
|
||||||
|
- **H4 · Decommission Cognee** — remove `cognee`, `cognee-mcp`, `cognee-llm`
|
||||||
|
services + volumes, the `cognee-openclaw-plugin`, and all `openclaw.json`/
|
||||||
|
`shared-mcp.json` cognee references. Free `/mnt/ssd/dbs/cognee`.
|
||||||
|
- **H5 · End-to-end verification** — state a fact → fresh session → recalled via
|
||||||
|
injected memory (no LLM on the recall path); measure recall latency; confirm
|
||||||
|
per-bank isolation; confirm no sweep/timer exists to stall.
|
||||||
|
|
||||||
|
## 9. What is unchanged
|
||||||
|
|
||||||
|
The Kimi/OpenClaw/Matrix substrate is untouched: `adolf` gateway, `adolf-llm`
|
||||||
|
(:8010) provider, the SSE-heartbeat/idle-watchdog fix (kb#71), the
|
||||||
|
`openclaw-tools` bridge, `kanboard`/`marketplace` MCP servers, Matrix allow-list
|
||||||
|
and E2EE. Only the memory backend and its two integration surfaces change.
|
||||||
111
adolf/README.md
Normal file
111
adolf/README.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Adolf — OpenClaw gateway deployment
|
||||||
|
|
||||||
|
Adolf is the self-hosted OpenClaw fork that runs as the `adolf` container
|
||||||
|
(Matrix-first personal assistant). This directory holds its **version-controlled
|
||||||
|
gateway configuration**.
|
||||||
|
|
||||||
|
- Source tree (the OpenClaw fork being built): `/home/alvis/adolf`
|
||||||
|
- Compose service `adolf` lives in: `openai/docker-compose.yml`
|
||||||
|
- This config directory lives at the repo root (`agap_git/adolf/`), not
|
||||||
|
nested inside `openai/`, since it is shared config rather than part of
|
||||||
|
the `openai` compose project's own tree.
|
||||||
|
- Model backend: `adolf-llm` container (Kimi-CLI wrapper) on `:8010`
|
||||||
|
|
||||||
|
## Config source of truth
|
||||||
|
|
||||||
|
The gateway config is **`openclaw.json` in this directory**. It is bind-mounted
|
||||||
|
**read-only** over the `adolf-state` volume:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- adolf-state:/home/node/.openclaw # runtime state only
|
||||||
|
- ../adolf/openclaw.json:/home/node/.openclaw/openclaw.json:ro # tracked config
|
||||||
|
```
|
||||||
|
|
||||||
|
Previously this file was a hand-edited copy inside the `adolf-state` Docker
|
||||||
|
volume (edited via `docker cp` into the running container). It is now tracked
|
||||||
|
in git and seeded into the container by the mount, so **git is the single
|
||||||
|
source of truth**.
|
||||||
|
|
||||||
|
- The file is **JSONC** (comments + unquoted keys allowed).
|
||||||
|
- **No secrets live here.** Every credential is a `${VAR}` reference resolved
|
||||||
|
from the container's environment, which is sourced from `openai/.env`
|
||||||
|
(gitignored, never committed): `OPENCLAW_GATEWAY_TOKEN`, `ADOLF_KEY`,
|
||||||
|
`MATRIX_*`, `MARKETPLACE_MCP_TOKEN`.
|
||||||
|
- The gateway reads this file and writes its own `openclaw.json.last-good`
|
||||||
|
and `openclaw.json.rejected.*` snapshots into the volume dir (writable). It
|
||||||
|
does **not** rewrite this file, so the read-only mount is safe.
|
||||||
|
|
||||||
|
### Changing the config
|
||||||
|
|
||||||
|
1. Edit `agap_git/adolf/openclaw.json`.
|
||||||
|
2. Restart the container:
|
||||||
|
```bash
|
||||||
|
env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u all_proxy \
|
||||||
|
docker compose -f /home/alvis/agap_git/openai/docker-compose.yml up -d adolf
|
||||||
|
```
|
||||||
|
3. Verify it came up healthy and the config was accepted:
|
||||||
|
```bash
|
||||||
|
env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u all_proxy \
|
||||||
|
docker ps --filter name=adolf --format '{{.Names}} {{.Status}}'
|
||||||
|
env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY -u https_proxy -u http_proxy -u all_proxy \
|
||||||
|
docker logs --tail 40 adolf
|
||||||
|
```
|
||||||
|
A fresh `openclaw.json.rejected.*` file in `/home/node/.openclaw` means the
|
||||||
|
edit failed validation and the previous `.last-good` is still in use.
|
||||||
|
|
||||||
|
Because the mount is read-only, editing config through the gateway UI/API is
|
||||||
|
intentionally disabled — all changes go through git.
|
||||||
|
|
||||||
|
## Granting a Matrix user access to Adolf
|
||||||
|
|
||||||
|
Adolf only responds to Matrix users on an **allow-list**. This is the
|
||||||
|
`channels.matrix.dm` block in `openclaw.json`:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
enabled: true,
|
||||||
|
encryption: true,
|
||||||
|
dm: {
|
||||||
|
policy: "allowlist",
|
||||||
|
allowFrom: [
|
||||||
|
"@admin:mtx.alogins.net",
|
||||||
|
"@elizaveta:mtx.alogins.net",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
groupPolicy: "disabled", // no group-room handling yet
|
||||||
|
autoJoin: "always",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`dm.policy: "allowlist"`** — only users whose full Matrix ID appears in
|
||||||
|
`allowFrom` can DM the bot. Everyone else is ignored.
|
||||||
|
(`policy: "pairing"` is the alternative: the owner must approve each unknown
|
||||||
|
sender interactively. `allowlist` is stricter and declarative.)
|
||||||
|
- **`dm.allowFrom`** — the list of authorized Matrix user IDs.
|
||||||
|
- **`groupPolicy: "disabled"`** — Adolf does not act in group rooms; DM only.
|
||||||
|
|
||||||
|
### To grant a new user access
|
||||||
|
|
||||||
|
1. Get the user's **full Matrix ID**, e.g. `@ivan:mtx.alogins.net`.
|
||||||
|
2. Add it to `allowFrom` in `agap_git/adolf/openclaw.json`:
|
||||||
|
```jsonc
|
||||||
|
allowFrom: [
|
||||||
|
"@admin:mtx.alogins.net",
|
||||||
|
"@elizaveta:mtx.alogins.net",
|
||||||
|
"@ivan:mtx.alogins.net",
|
||||||
|
],
|
||||||
|
```
|
||||||
|
3. Restart adolf (see "Changing the config" above).
|
||||||
|
4. The user can now start a DM with Adolf's Matrix account
|
||||||
|
(`MATRIX_USER_ID` in `openai/.env`). `autoJoin: "always"` means Adolf
|
||||||
|
auto-accepts the DM invite; conversation is end-to-end encrypted
|
||||||
|
(`encryption: true`).
|
||||||
|
|
||||||
|
To **revoke** access, remove the ID from `allowFrom` and restart.
|
||||||
|
|
||||||
|
> Matrix accounts themselves are created on the Synapse homeserver
|
||||||
|
> (`mtx.alogins.net`) — see the AgapHost wiki **Matrix** page. The allow-list
|
||||||
|
> here only controls which existing Matrix users Adolf will talk to.
|
||||||
178
adolf/openclaw.json
Normal file
178
adolf/openclaw.json
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
{
|
||||||
|
// Adolf P6 — OpenClaw gateway config for the "adolf" container.
|
||||||
|
// Lives in the adolf-state VOLUME (mounted at /home/node/.openclaw), not
|
||||||
|
// in the openai/ git repo. Secrets referenced below (${VAR}) are resolved
|
||||||
|
// from this container's process env, itself sourced from openai/.env
|
||||||
|
// (gitignored) via docker-compose.yml — never inlined here.
|
||||||
|
|
||||||
|
gateway: {
|
||||||
|
mode: "local",
|
||||||
|
auth: {
|
||||||
|
// Compose already binds "lan" (0.0.0.0) and publishes 18789/18790 to
|
||||||
|
// the host, so this is a non-loopback bind and auth is mandatory.
|
||||||
|
// Shared-secret token auth also gives the openclaw-tools bridge
|
||||||
|
// (which calls POST /tools/invoke with the same token) full
|
||||||
|
// trusted-operator scope, which is what lets gateway.tools.allow
|
||||||
|
// below actually unlock cron/nodes for it.
|
||||||
|
mode: "token",
|
||||||
|
token: "${OPENCLAW_GATEWAY_TOKEN}",
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
// cron and nodes are owner-only and hard-denied on the HTTP
|
||||||
|
// /tools/invoke surface by default. The openclaw-tools MCP bridge
|
||||||
|
// (P5) calls that surface for cron_create/cron_list/nodes_invoke, so
|
||||||
|
// without this allow-list those tools 404 even with a valid token.
|
||||||
|
allow: ["cron", "nodes"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Model provider: adolf-llm (P2/P4), the Kimi-CLI OpenAI-compatible
|
||||||
|
// wrapper on :8010. Its HTTP server (openai/adolf-llm/server.js) performs
|
||||||
|
// NO api-key/Authorization validation at all -- ADOLF_KEY's value is
|
||||||
|
// functionally irrelevant to adolf-llm itself. It's still wired through
|
||||||
|
// env (not hardcoded) because OpenClaw's custom-provider schema requires
|
||||||
|
// a non-empty apiKey field and ${VAR} substitution fails closed on an
|
||||||
|
// empty/missing var.
|
||||||
|
models: {
|
||||||
|
mode: "merge",
|
||||||
|
providers: {
|
||||||
|
"adolf-llm": {
|
||||||
|
baseUrl: "http://adolf-llm:8010/v1",
|
||||||
|
apiKey: "${ADOLF_KEY}",
|
||||||
|
api: "openai-completions",
|
||||||
|
// Margin above the server.js SSE heartbeat cadence (empty-content
|
||||||
|
// keepalive delta every ~25s once idle) so the idle watchdog never
|
||||||
|
// fires on long thinking/tool/MCP phases even if a heartbeat tick
|
||||||
|
// is delayed (kb #71). Raised to 10min: long agentic turns (Cognee
|
||||||
|
// tool-loops / recalls up to 150s each) were producing no *content*
|
||||||
|
// progress for >300s, tripping "no response from model" and surfacing
|
||||||
|
// an error before the agent finished. The wrapper now also kills the
|
||||||
|
// kimi child on disconnect, so an over-timeout turn no longer orphans.
|
||||||
|
timeoutSeconds: 600,
|
||||||
|
models: [
|
||||||
|
{ id: "adolf", name: "Adolf", input: ["text", "image"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: "adolf-llm/adolf",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ui: {
|
||||||
|
assistant: { name: "Adolf" },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Matrix channel (P6). Deliberately no accessToken/userId/password here:
|
||||||
|
// MATRIX_HOMESERVER / MATRIX_USER_ID / MATRIX_PASSWORD / MATRIX_DEVICE_NAME
|
||||||
|
// are config-key-backed env vars OpenClaw reads automatically when the
|
||||||
|
// matching config key is unset, so real creds never touch this file or
|
||||||
|
// git. Password auth (not the raw access token) mints Adolf its own fresh
|
||||||
|
// Matrix device instead of reusing the existing matrixbot (Python/nio)
|
||||||
|
// device -- see the P6 report for why that separation matters.
|
||||||
|
//
|
||||||
|
// Conservative defaults on purpose: dm "pairing" (owner must approve
|
||||||
|
// unknown senders) and groupPolicy "disabled" (no room handling yet).
|
||||||
|
// Revisit once the legacy matrixbot adapter is retired.
|
||||||
|
channels: {
|
||||||
|
matrix: {
|
||||||
|
enabled: true,
|
||||||
|
encryption: true,
|
||||||
|
dm: { policy: "allowlist", allowFrom: ["@admin:mtx.alogins.net", "@elizaveta:mtx.alogins.net"] },
|
||||||
|
groupPolicy: "disabled",
|
||||||
|
autoJoin: "always",
|
||||||
|
// mtx.alogins.net is deliberately mapped to the host-gateway (private)
|
||||||
|
// IP via extra_hosts in docker-compose.yml to dodge a hairpin-NAT dead
|
||||||
|
// end on the public route -- not an actual SSRF exposure, so opt out
|
||||||
|
// of the private-network block for this trusted, self-owned homeserver.
|
||||||
|
network: { dangerouslyAllowPrivateNetwork: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// MCP registry (P6) -- same servers as openai/shared-mcp.json,
|
||||||
|
// expressed in OpenClaw's own mcp.servers schema. `type: "http"` is
|
||||||
|
// OpenClaw's documented CLI-native alias for transport: "streamable-http".
|
||||||
|
mcp: {
|
||||||
|
servers: {
|
||||||
|
hindsight: {
|
||||||
|
type: "http",
|
||||||
|
url: "http://hindsight:8888/mcp/adolf/",
|
||||||
|
},
|
||||||
|
"openclaw-tools": {
|
||||||
|
type: "http",
|
||||||
|
url: "http://openclaw-tools:8020/mcp",
|
||||||
|
},
|
||||||
|
// kanboard-mcp-adolf (kb task #58) -- standalone kanboard-mcp image
|
||||||
|
// (source /home/alvis/kanboard/mcp), second instance on :3104,
|
||||||
|
// authenticated as the Kanboard "adolf" user via its own personal API
|
||||||
|
// access token (KANBOARD_AUTH_USER=adolf in that instance's
|
||||||
|
// .env.adolf) -- a genuinely distinct credential from the "claude"
|
||||||
|
// instance on :3103 (app-wide jsonrpc token). Not part of the openai
|
||||||
|
// compose network, so reached via host.docker.internal (already
|
||||||
|
// extra_hosts-mapped for this container) rather than a service name.
|
||||||
|
kanboard: {
|
||||||
|
type: "http",
|
||||||
|
url: "http://host.docker.internal:3104/mcp",
|
||||||
|
},
|
||||||
|
// marketplace-mcp (kb task #61) -- the SAME shared marketplace-mcp
|
||||||
|
// instance Claude Code and OpenWebUI use (single service, port 3101,
|
||||||
|
// network_mode: host on the Agap host), not a second copy. It can
|
||||||
|
// place real orders on live marketplace accounts, so it's gated by a
|
||||||
|
// shared bearer token (MARKETPLACE_MCP_TOKEN in Vaultwarden / this
|
||||||
|
// container's env, injected via openai/.env -> docker-compose.yml).
|
||||||
|
// Reached via host.docker.internal, same reasoning as kanboard above.
|
||||||
|
marketplace: {
|
||||||
|
type: "http",
|
||||||
|
url: "http://host.docker.internal:3101/mcp",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer ${MARKETPLACE_MCP_TOKEN}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// agap-mcp (kb#64) -- the SAME shared agap-mcp instance Claude Code uses
|
||||||
|
// (network_mode: host, :3100, unauthenticated on localhost). Grants Adolf
|
||||||
|
// the same access as Claude: vault (vw_* for credential fetching) plus
|
||||||
|
// gitea/ha/zabbix/radicale. Reached via host.docker.internal like
|
||||||
|
// kanboard/marketplace above.
|
||||||
|
agap: {
|
||||||
|
type: "http",
|
||||||
|
url: "http://host.docker.internal:3100/mcp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Memory plugins (P8). Activation entry is required for the gateway to
|
||||||
|
// load a plugin at startup (discovery alone is not enough).
|
||||||
|
plugins: {
|
||||||
|
entries: {
|
||||||
|
// Hindsight memory plugin (kb #75, H3) — installed external plugin
|
||||||
|
// under .openclaw/extensions/hindsight-memory, bind-mounted read-only
|
||||||
|
// from openai/hindsight-openclaw-plugin (see that project's
|
||||||
|
// docker-compose.yml adolf.volumes). Structural successor to
|
||||||
|
// cognee-memory above: LLM-free recall inject (before_prompt_build) +
|
||||||
|
// async retain (agent_end) against the hindsight service, bank
|
||||||
|
// "adolf" (same bank the mcp.servers.hindsight tool surface above
|
||||||
|
// uses, so hook-based and tool-based memory stay one consistent
|
||||||
|
// store). No cognify/sweep config here — Hindsight's retain does
|
||||||
|
// extraction/consolidation server-side, so that whole class of
|
||||||
|
// config (sweepIntervalMs etc. above) doesn't apply.
|
||||||
|
"hindsight-memory": {
|
||||||
|
enabled: true,
|
||||||
|
// Same opt-in requirement as cognee-memory above: before_prompt_build
|
||||||
|
// => allowPromptInjection; agent_end => allowConversationAccess.
|
||||||
|
hooks: { allowConversationAccess: true, allowPromptInjection: true },
|
||||||
|
},
|
||||||
|
// Kimi quota readout (kb #62) — installed external plugin, bind-mounted
|
||||||
|
// read-only from openai/quota-command-openclaw-plugin (see that
|
||||||
|
// project's docker-compose.yml adolf.volumes) onto
|
||||||
|
// .openclaw/extensions/quota-command. Registers a `/quota` native
|
||||||
|
// command; no hooks, so no allowConversationAccess/allowPromptInjection
|
||||||
|
// opt-in needed.
|
||||||
|
"quota-command": {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
agap-mcp/.gitignore
vendored
Normal file
1
agap-mcp/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
14
agap-mcp/Dockerfile
Normal file
14
agap-mcp/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN npm install -g @bitwarden/cli
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY start.sh ./
|
||||||
|
RUN chmod +x start.sh
|
||||||
|
CMD ["./start.sh"]
|
||||||
25
agap-mcp/docker-compose.yml
Normal file
25
agap-mcp/docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
agap-mcp:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
- PORT=3100
|
||||||
|
- NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
|
- HTTPS_PROXY=
|
||||||
|
- HTTP_PROXY=
|
||||||
|
- ALL_PROXY=
|
||||||
|
- https_proxy=
|
||||||
|
- http_proxy=
|
||||||
|
- all_proxy=
|
||||||
|
- BITWARDENCLI_APPDATA_DIR=/bw-data
|
||||||
|
- BW_EMAIL=adolf46@proton.me
|
||||||
|
- BW_PASSWORD=${BW_PASSWORD}
|
||||||
|
- VAULTWARDEN_URL=http://localhost:8041
|
||||||
|
- GITEA_URL=http://localhost:3000
|
||||||
|
- HA_URL=http://192.168.1.4:8123
|
||||||
|
- ZABBIX_URL=http://192.168.1.4:81
|
||||||
|
- RADICALE_URL=http://localhost:5232
|
||||||
|
- RADICALE_USER=alvis
|
||||||
|
volumes:
|
||||||
|
- /home/alvis/.config/Bitwarden CLI:/bw-data
|
||||||
10
agap-mcp/package.json
Normal file
10
agap-mcp/package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "agap-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"express": "^4.19.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
73
agap-mcp/src/gitea.js
Normal file
73
agap-mcp/src/gitea.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const BASE = () => process.env.GITEA_URL || 'http://localhost:3000';
|
||||||
|
let _token = null;
|
||||||
|
|
||||||
|
export function initGitea(token) {
|
||||||
|
_token = token;
|
||||||
|
console.log('Gitea: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
if (!_token) throw new Error('Gitea not initialized');
|
||||||
|
return _token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(`${BASE()}/api/v1${path}`, {
|
||||||
|
...opts,
|
||||||
|
headers: { Authorization: `token ${token()}`, 'Content-Type': 'application/json', ...opts.headers },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Gitea API ${path}: ${res.status} ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaListRepos() {
|
||||||
|
return api('/repos/search?limit=50').then(r => r.data.map(r => ({
|
||||||
|
name: r.full_name, description: r.description, stars: r.stars_count,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaReadFile(repo, path, ref = 'HEAD') {
|
||||||
|
const data = await api(`/repos/${repo}/contents/${path}?ref=${ref}`);
|
||||||
|
return Buffer.from(data.content, 'base64').toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaWikiList(repo = 'alvis/AgapHost') {
|
||||||
|
const data = await api(`/repos/${repo}/wiki/pages?limit=50`);
|
||||||
|
return data.map(p => ({ name: p.title, updated: p.last_commit?.created }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaWikiRead(page, repo = 'alvis/AgapHost') {
|
||||||
|
const data = await api(`/repos/${repo}/wiki/page/${encodeURIComponent(page)}`);
|
||||||
|
return Buffer.from(data.content_base64, 'base64').toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaWikiWrite(page, content, message, repo = 'alvis/AgapHost') {
|
||||||
|
const dir = join(tmpdir(), 'agap-mcp-wiki');
|
||||||
|
const wikiUrl = `${BASE().replace('http://', `http://alvis:${token()}@`)}/alvis/AgapHost.wiki.git`;
|
||||||
|
|
||||||
|
const gitEnv = { ...process.env, GIT_AUTHOR_NAME: 'agap-mcp', GIT_AUTHOR_EMAIL: 'allogn@gmail.com', GIT_COMMITTER_NAME: 'agap-mcp', GIT_COMMITTER_EMAIL: 'allogn@gmail.com' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`git -C ${dir} pull ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' });
|
||||||
|
} catch {
|
||||||
|
execSync(`git clone ${wikiUrl} ${dir}`, { env: gitEnv, stdio: 'pipe' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = join(dir, `${page}.md`);
|
||||||
|
writeFileSync(file, content);
|
||||||
|
execSync(`git -C ${dir} add "${page}.md"`, { env: gitEnv, stdio: 'pipe' });
|
||||||
|
execSync(`git -C ${dir} commit -m "${message || `Update ${page}`}"`, { env: gitEnv, stdio: 'pipe' });
|
||||||
|
execSync(`git -C ${dir} push ${wikiUrl} main`, { env: gitEnv, stdio: 'pipe' });
|
||||||
|
return `${page} updated`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function giteaListIssues(repo, state = 'open') {
|
||||||
|
return api(`/repos/${repo}/issues?state=${state}&type=issues&limit=50`).then(issues =>
|
||||||
|
issues.map(i => ({ number: i.number, title: i.title, state: i.state, labels: i.labels.map(l => l.name) }))
|
||||||
|
);
|
||||||
|
}
|
||||||
45
agap-mcp/src/homeassistant.js
Normal file
45
agap-mcp/src/homeassistant.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const BASE = () => process.env.HA_URL || 'http://192.168.1.4:8123';
|
||||||
|
let _token = null;
|
||||||
|
|
||||||
|
export function initHA(token) {
|
||||||
|
_token = token;
|
||||||
|
console.log('Home Assistant: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
if (!_token) throw new Error('HA not initialized');
|
||||||
|
return _token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(`${BASE()}/api${path}`, {
|
||||||
|
...opts,
|
||||||
|
headers: { Authorization: `Bearer ${token()}`, 'Content-Type': 'application/json', ...opts.headers },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HA API ${path}: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function haGetState(entityId) {
|
||||||
|
const s = await api(`/states/${entityId}`);
|
||||||
|
return { entity_id: s.entity_id, state: s.state, attributes: s.attributes, last_changed: s.last_changed };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function haListEntities(domain) {
|
||||||
|
const states = await api('/states');
|
||||||
|
const filtered = domain ? states.filter(s => s.entity_id.startsWith(`${domain}.`)) : states;
|
||||||
|
return filtered.map(s => ({ entity_id: s.entity_id, state: s.state, friendly_name: s.attributes.friendly_name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function haCallService(domain, service, data = {}) {
|
||||||
|
return api(`/services/${domain}/${service}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function haGetHistory(entityId, hours = 24) {
|
||||||
|
const start = new Date(Date.now() - hours * 3600 * 1000).toISOString();
|
||||||
|
const data = await api(`/history/period/${start}?filter_entity_id=${entityId}&minimal_response=true`);
|
||||||
|
return data[0] || [];
|
||||||
|
}
|
||||||
215
agap-mcp/src/kanboard.js
Normal file
215
agap-mcp/src/kanboard.js
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
const BASE = () => process.env.KANBOARD_URL || 'http://localhost:4800';
|
||||||
|
const BOT_USER = () => process.env.KANBOARD_BOT_USER || 'claude';
|
||||||
|
|
||||||
|
let _token = null;
|
||||||
|
let _botId = null;
|
||||||
|
|
||||||
|
export function initKanboard(token) {
|
||||||
|
_token = token;
|
||||||
|
console.log('Kanboard: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function token() {
|
||||||
|
if (!_token) throw new Error('Kanboard not initialized');
|
||||||
|
return _token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON-RPC over the app-wide token (login "jsonrpc:<token>") — full access to all projects.
|
||||||
|
async function rpc(method, params = {}) {
|
||||||
|
const auth = Buffer.from(`jsonrpc:${token()}`).toString('base64');
|
||||||
|
const res = await fetch(`${BASE()}/jsonrpc.php`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Basic ${auth}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', method, id: 1, params }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Kanboard ${method}: ${res.status} ${await res.text()}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.error) throw new Error(`Kanboard ${method}: ${json.error.message || JSON.stringify(json.error)}`);
|
||||||
|
return json.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve + cache the bot user's id (default author for writes, filter for "my tasks").
|
||||||
|
async function botUserId() {
|
||||||
|
if (_botId != null) return _botId;
|
||||||
|
const user = await rpc('getUserByName', { username: BOT_USER() });
|
||||||
|
if (!user || !user.id) throw new Error(`Bot user not found in Kanboard: ${BOT_USER()}`);
|
||||||
|
_botId = parseInt(user.id);
|
||||||
|
return _botId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS = { open: 1, closed: 0 };
|
||||||
|
|
||||||
|
// --- Read / monitor ---
|
||||||
|
|
||||||
|
export async function kbListProjects() {
|
||||||
|
const projects = await rpc('getAllProjects');
|
||||||
|
return projects.map(p => ({
|
||||||
|
id: parseInt(p.id), name: p.name, is_active: parseInt(p.is_active),
|
||||||
|
is_private: parseInt(p.is_private), owner_id: parseInt(p.owner_id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbGetProject(projectId) {
|
||||||
|
const [project, columns, swimlanes] = await Promise.all([
|
||||||
|
rpc('getProjectById', { project_id: projectId }),
|
||||||
|
rpc('getColumns', { project_id: projectId }),
|
||||||
|
rpc('getAllSwimlanes', { project_id: projectId }),
|
||||||
|
]);
|
||||||
|
if (!project) throw new Error(`Project not found: ${projectId}`);
|
||||||
|
return {
|
||||||
|
id: parseInt(project.id), name: project.name, description: project.description,
|
||||||
|
is_active: parseInt(project.is_active), owner_id: parseInt(project.owner_id),
|
||||||
|
columns: columns.map(c => ({ id: parseInt(c.id), title: c.title, position: parseInt(c.position) })),
|
||||||
|
swimlanes: swimlanes.map(s => ({ id: parseInt(s.id), name: s.name, is_active: parseInt(s.is_active) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactTask(t) {
|
||||||
|
return {
|
||||||
|
id: parseInt(t.id), title: t.title, project_id: parseInt(t.project_id),
|
||||||
|
column_id: parseInt(t.column_id), swimlane_id: parseInt(t.swimlane_id),
|
||||||
|
owner_id: parseInt(t.owner_id), is_active: parseInt(t.is_active),
|
||||||
|
color_id: t.color_id, priority: parseInt(t.priority || 0),
|
||||||
|
date_due: parseInt(t.date_due) || 0, reference: t.reference || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbListTasks(projectId, status = 'open') {
|
||||||
|
let tasks = [];
|
||||||
|
if (status === 'all') {
|
||||||
|
const [open, closed] = await Promise.all([
|
||||||
|
rpc('getAllTasks', { project_id: projectId, status_id: STATUS.open }),
|
||||||
|
rpc('getAllTasks', { project_id: projectId, status_id: STATUS.closed }),
|
||||||
|
]);
|
||||||
|
tasks = [...open, ...closed];
|
||||||
|
} else {
|
||||||
|
tasks = await rpc('getAllTasks', { project_id: projectId, status_id: STATUS[status] ?? STATUS.open });
|
||||||
|
}
|
||||||
|
return tasks.map(compactTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tasks assigned to the bot across every project. status: open | closed | all
|
||||||
|
export async function kbMyTasks(status = 'open') {
|
||||||
|
const botId = await botUserId();
|
||||||
|
const projects = await rpc('getAllProjects');
|
||||||
|
const out = [];
|
||||||
|
for (const p of projects) {
|
||||||
|
const tasks = await kbListTasks(parseInt(p.id), status);
|
||||||
|
for (const t of tasks) {
|
||||||
|
if (t.owner_id === botId) out.push({ ...t, project_name: p.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbGetTask(taskId) {
|
||||||
|
const task = await rpc('getTask', { task_id: taskId });
|
||||||
|
if (!task) throw new Error(`Task not found: ${taskId}`);
|
||||||
|
const [subtasks, comments] = await Promise.all([
|
||||||
|
rpc('getAllSubtasks', { task_id: taskId }),
|
||||||
|
rpc('getAllComments', { task_id: taskId }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
task,
|
||||||
|
subtasks: (subtasks || []).map(s => ({
|
||||||
|
id: parseInt(s.id), title: s.title, status: parseInt(s.status),
|
||||||
|
status_name: s.status_name, user_id: parseInt(s.user_id) || 0, assignee: s.name || s.username || null,
|
||||||
|
})),
|
||||||
|
comments: (comments || []).map(c => ({
|
||||||
|
id: parseInt(c.id), user_id: parseInt(c.user_id), author: c.name || c.username,
|
||||||
|
date: parseInt(c.date_creation), content: c.comment,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbSearchTasks(projectId, query) {
|
||||||
|
const tasks = await rpc('searchTasks', { project_id: projectId, query });
|
||||||
|
return (tasks || []).map(compactTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbListUsers() {
|
||||||
|
const users = await rpc('getAllUsers');
|
||||||
|
return users.map(u => ({
|
||||||
|
id: parseInt(u.id), username: u.username, name: u.name,
|
||||||
|
role: u.role, is_active: parseInt(u.is_active), email: u.email,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbProjectActivity(projectId) {
|
||||||
|
return rpc('getProjectActivity', { project_id: projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Write (creation/comments authored by the bot user) ---
|
||||||
|
|
||||||
|
export async function kbCreateTask(params) {
|
||||||
|
const body = { ...params };
|
||||||
|
if (body.creator_id == null) body.creator_id = await botUserId();
|
||||||
|
const id = await rpc('createTask', body);
|
||||||
|
if (!id) throw new Error('createTask returned false (check required fields: title, project_id)');
|
||||||
|
return { task_id: parseInt(id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbUpdateTask(params) {
|
||||||
|
const ok = await rpc('updateTask', params);
|
||||||
|
return { updated: !!ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move a card. project_id and swimlane_id are resolved from the task when omitted.
|
||||||
|
export async function kbMoveTask({ task_id, column_id, position = 1, swimlane_id, project_id }) {
|
||||||
|
if (project_id == null || swimlane_id == null) {
|
||||||
|
const task = await rpc('getTask', { task_id });
|
||||||
|
if (!task) throw new Error(`Task not found: ${task_id}`);
|
||||||
|
if (project_id == null) project_id = parseInt(task.project_id);
|
||||||
|
if (swimlane_id == null) swimlane_id = parseInt(task.swimlane_id);
|
||||||
|
}
|
||||||
|
const ok = await rpc('moveTaskPosition', { project_id, task_id, column_id, position, swimlane_id });
|
||||||
|
return { moved: !!ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbChangeTaskStatus(taskId, action) {
|
||||||
|
const method = action === 'close' ? 'closeTask' : 'openTask';
|
||||||
|
const ok = await rpc(method, { task_id: taskId });
|
||||||
|
return { [action === 'close' ? 'closed' : 'opened']: !!ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign a task to a user (username or numeric id).
|
||||||
|
export async function kbAssignTask(taskId, owner) {
|
||||||
|
let ownerId = owner;
|
||||||
|
if (typeof owner === 'string' && !/^\d+$/.test(owner)) {
|
||||||
|
const user = await rpc('getUserByName', { username: owner });
|
||||||
|
if (!user || !user.id) throw new Error(`User not found: ${owner}`);
|
||||||
|
ownerId = parseInt(user.id);
|
||||||
|
}
|
||||||
|
const ok = await rpc('updateTask', { id: taskId, owner_id: parseInt(ownerId) });
|
||||||
|
return { assigned: !!ok, owner_id: parseInt(ownerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbAddComment(taskId, content, userId) {
|
||||||
|
const uid = userId != null ? userId : await botUserId();
|
||||||
|
const id = await rpc('createComment', { task_id: taskId, user_id: uid, content });
|
||||||
|
if (!id) throw new Error('createComment returned false');
|
||||||
|
return { comment_id: parseInt(id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbCreateSubtask(params) {
|
||||||
|
const body = { ...params };
|
||||||
|
if (body.user_id == null) body.user_id = await botUserId();
|
||||||
|
const id = await rpc('createSubtask', body);
|
||||||
|
if (!id) throw new Error('createSubtask returned false (required: task_id, title)');
|
||||||
|
return { subtask_id: parseInt(id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbUpdateSubtask(params) {
|
||||||
|
const ok = await rpc('updateSubtask', params);
|
||||||
|
return { updated: !!ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbRemoveTask(taskId) {
|
||||||
|
const ok = await rpc('removeTask', { task_id: taskId });
|
||||||
|
return { removed: !!ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kbRemoveComment(commentId) {
|
||||||
|
const ok = await rpc('removeComment', { comment_id: commentId });
|
||||||
|
return { removed: !!ok };
|
||||||
|
}
|
||||||
198
agap-mcp/src/radicale.js
Normal file
198
agap-mcp/src/radicale.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
const BASE = () => (process.env.RADICALE_URL || 'http://localhost:5232').replace(/\/$/, '');
|
||||||
|
const DEFAULT_USER = () => process.env.RADICALE_USER || 'alvis';
|
||||||
|
|
||||||
|
let _password = null;
|
||||||
|
|
||||||
|
export function initRadicale(password, _user) {
|
||||||
|
_password = password;
|
||||||
|
console.log('Radicale: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeader(user) {
|
||||||
|
if (!_password) throw new Error('Radicale not initialized');
|
||||||
|
const u = user || DEFAULT_USER();
|
||||||
|
return 'Basic ' + Buffer.from(`${u}:${_password}`).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dav(method, path, { user, headers = {}, body } = {}) {
|
||||||
|
const res = await fetch(`${BASE()}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: authHeader(user), ...headers },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok && res.status !== 207) {
|
||||||
|
throw new Error(`Radicale ${method} ${path}: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
return { status: res.status, text, headers: res.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function userPath(user) {
|
||||||
|
return `/${encodeURIComponent(user || DEFAULT_USER())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract <response> blocks; for each, pull href + displayname + resourcetype components
|
||||||
|
function parseMultistatus(xml) {
|
||||||
|
const responses = [];
|
||||||
|
const re = /<(?:[a-zA-Z0-9]+:)?response\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?response>/g;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(xml)) !== null) {
|
||||||
|
const block = m[1];
|
||||||
|
const href = (block.match(/<(?:[a-zA-Z0-9]+:)?href\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?href>/) || [])[1];
|
||||||
|
const displayname = (block.match(/<(?:[a-zA-Z0-9]+:)?displayname\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?displayname>/) || [])[1];
|
||||||
|
const isCalendar = /<(?:[a-zA-Z0-9]+:)?calendar\b/.test(block);
|
||||||
|
const isAddressbook = /<(?:[a-zA-Z0-9]+:)?addressbook\b/.test(block);
|
||||||
|
const isCollection = /<(?:[a-zA-Z0-9]+:)?collection\b/.test(block);
|
||||||
|
const getetag = (block.match(/<(?:[a-zA-Z0-9]+:)?getetag\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?getetag>/) || [])[1];
|
||||||
|
const componentSet = [];
|
||||||
|
const csRe = /<(?:[a-zA-Z0-9]+:)?comp\b[^>]*\sname="([^"]+)"/g;
|
||||||
|
let cm;
|
||||||
|
while ((cm = csRe.exec(block)) !== null) componentSet.push(cm[1]);
|
||||||
|
responses.push({ href, displayname, isCalendar, isAddressbook, isCollection, getetag, componentSet });
|
||||||
|
}
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROPFIND_COLLECTIONS = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/">
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<c:supported-calendar-component-set/>
|
||||||
|
<ic:calendar-color/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`;
|
||||||
|
|
||||||
|
const PROPFIND_EVENTS = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<d:propfind xmlns:d="DAV:">
|
||||||
|
<d:prop>
|
||||||
|
<d:getetag/>
|
||||||
|
<d:resourcetype/>
|
||||||
|
</d:prop>
|
||||||
|
</d:propfind>`;
|
||||||
|
|
||||||
|
export async function radicaleListCalendars(user) {
|
||||||
|
const { text } = await dav('PROPFIND', `${userPath(user)}/`, {
|
||||||
|
user,
|
||||||
|
headers: { Depth: '1', 'Content-Type': 'application/xml' },
|
||||||
|
body: PROPFIND_COLLECTIONS,
|
||||||
|
});
|
||||||
|
const items = parseMultistatus(text);
|
||||||
|
const out = [];
|
||||||
|
for (const r of items) {
|
||||||
|
if (!r.href || !r.isCalendar) continue;
|
||||||
|
// skip the parent (the user principal itself, which usually has no displayname)
|
||||||
|
const id = decodeURIComponent(r.href.replace(/\/$/, '').split('/').pop());
|
||||||
|
if (!id || id === (user || DEFAULT_USER())) continue;
|
||||||
|
out.push({
|
||||||
|
id,
|
||||||
|
displayname: r.displayname || null,
|
||||||
|
components: r.componentSet,
|
||||||
|
href: r.href,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIcsField(ics, field, { component = 'VEVENT' } = {}) {
|
||||||
|
// restrict search to the named component block (default VEVENT)
|
||||||
|
const block = (() => {
|
||||||
|
if (!component) return ics;
|
||||||
|
const re = new RegExp(`BEGIN:${component}([\\s\\S]*?)END:${component}`);
|
||||||
|
const m = ics.match(re);
|
||||||
|
return m ? m[1] : ics;
|
||||||
|
})();
|
||||||
|
const re = new RegExp(`(?:^|\\n)${field}(?:;[^:\\n]*)?:([^\\n\\r]*)`);
|
||||||
|
const m = block.match(re);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleListEvents(calendarId, user) {
|
||||||
|
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/`;
|
||||||
|
const { text } = await dav('PROPFIND', path, {
|
||||||
|
user,
|
||||||
|
headers: { Depth: '1', 'Content-Type': 'application/xml' },
|
||||||
|
body: PROPFIND_EVENTS,
|
||||||
|
});
|
||||||
|
const items = parseMultistatus(text);
|
||||||
|
const out = [];
|
||||||
|
for (const r of items) {
|
||||||
|
if (!r.href || !r.href.endsWith('.ics')) continue;
|
||||||
|
const filename = decodeURIComponent(r.href.split('/').pop());
|
||||||
|
// GET event to extract summary + start time
|
||||||
|
const ev = await dav('GET', r.href, { user });
|
||||||
|
const summary = parseIcsField(ev.text, 'SUMMARY');
|
||||||
|
const dtstart = parseIcsField(ev.text, 'DTSTART');
|
||||||
|
const dtend = parseIcsField(ev.text, 'DTEND');
|
||||||
|
const uid = parseIcsField(ev.text, 'UID');
|
||||||
|
out.push({ filename, uid, summary, dtstart, dtend, etag: r.getetag, href: r.href });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleGetEvent(calendarId, filename, user) {
|
||||||
|
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(filename)}`;
|
||||||
|
const { text } = await dav('GET', path, { user });
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleCreateCalendar({ displayname, color, components = ['VEVENT'], user, id }) {
|
||||||
|
if (!displayname) throw new Error('displayname required');
|
||||||
|
const calId = id || randomUUID();
|
||||||
|
const compXml = components.map(c => `<c:comp name="${c}"/>`).join('');
|
||||||
|
const body = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<c:mkcalendar xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:ic="http://apple.com/ns/ical/">
|
||||||
|
<d:set>
|
||||||
|
<d:prop>
|
||||||
|
<d:displayname>${escapeXml(displayname)}</d:displayname>
|
||||||
|
<c:supported-calendar-component-set>${compXml}</c:supported-calendar-component-set>
|
||||||
|
${color ? `<ic:calendar-color>${escapeXml(color)}</ic:calendar-color>` : ''}
|
||||||
|
</d:prop>
|
||||||
|
</d:set>
|
||||||
|
</c:mkcalendar>`;
|
||||||
|
const path = `${userPath(user)}/${calId}/`;
|
||||||
|
await dav('MKCALENDAR', path, {
|
||||||
|
user,
|
||||||
|
headers: { 'Content-Type': 'application/xml' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
return { id: calId, displayname, components };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleDeleteCalendar(calendarId, user) {
|
||||||
|
await dav('DELETE', `${userPath(user)}/${encodeURIComponent(calendarId)}/`, { user });
|
||||||
|
return { deleted: calendarId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicalePutEvent(calendarId, filename, ics, user) {
|
||||||
|
const name = filename.endsWith('.ics') ? filename : `${filename}.ics`;
|
||||||
|
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(name)}`;
|
||||||
|
await dav('PUT', path, {
|
||||||
|
user,
|
||||||
|
headers: { 'Content-Type': 'text/calendar; charset=utf-8' },
|
||||||
|
body: ics,
|
||||||
|
});
|
||||||
|
return { put: name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleDeleteEvent(calendarId, filename, user) {
|
||||||
|
const path = `${userPath(user)}/${encodeURIComponent(calendarId)}/${encodeURIComponent(filename)}`;
|
||||||
|
await dav('DELETE', path, { user });
|
||||||
|
return { deleted: filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function radicaleMoveEvent({ fromCalendar, toCalendar, filename, user }) {
|
||||||
|
if (!fromCalendar || !toCalendar || !filename) {
|
||||||
|
throw new Error('fromCalendar, toCalendar, filename required');
|
||||||
|
}
|
||||||
|
const ics = await radicaleGetEvent(fromCalendar, filename, user);
|
||||||
|
await radicalePutEvent(toCalendar, filename, ics, user);
|
||||||
|
await radicaleDeleteEvent(fromCalendar, filename, user);
|
||||||
|
return { moved: filename, from: fromCalendar, to: toCalendar };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(s) {
|
||||||
|
return String(s).replace(/[<>&'"]/g, c => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]));
|
||||||
|
}
|
||||||
442
agap-mcp/src/server.js
Normal file
442
agap-mcp/src/server.js
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { initVaultwarden, vwGetPassword, vwGetItem, vwListItems, vwListOrgItems, vwCreateLogin, vwUpdatePassword } from './vaultwarden.js';
|
||||||
|
import { initGitea, giteaListRepos, giteaReadFile, giteaWikiList, giteaWikiRead, giteaWikiWrite, giteaListIssues } from './gitea.js';
|
||||||
|
import { initHA, haGetState, haListEntities, haCallService, haGetHistory } from './homeassistant.js';
|
||||||
|
import { initZabbix, zabbixGetProblems, zabbixGetHosts, zabbixGetItems, zabbixGetTriggers } from './zabbix.js';
|
||||||
|
import { initRadicale, radicaleListCalendars, radicaleListEvents, radicaleGetEvent, radicaleCreateCalendar, radicaleDeleteCalendar, radicalePutEvent, radicaleDeleteEvent, radicaleMoveEvent } from './radicale.js';
|
||||||
|
import { initKanboard, kbListProjects, kbGetProject, kbListTasks, kbMyTasks, kbGetTask, kbSearchTasks, kbListUsers, kbProjectActivity, kbCreateTask, kbUpdateTask, kbMoveTask, kbChangeTaskStatus, kbAssignTask, kbAddComment, kbCreateSubtask, kbUpdateSubtask, kbRemoveTask, kbRemoveComment } from './kanboard.js';
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || '3100');
|
||||||
|
|
||||||
|
// --- Init services ---
|
||||||
|
async function init() {
|
||||||
|
await initVaultwarden();
|
||||||
|
|
||||||
|
// Fetch all tokens from org to avoid "more than one result" on duplicates
|
||||||
|
const orgItems = vwListOrgItems();
|
||||||
|
const orgToken = (name) => {
|
||||||
|
const item = orgItems.find(i => i.name === name);
|
||||||
|
if (!item) throw new Error(`Token not found in org: ${name}`);
|
||||||
|
return item.login?.password;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gitea_token = orgToken('GITEA_TOKEN');
|
||||||
|
const ha_token = orgToken('HA_TOKEN');
|
||||||
|
const zabbix_token = orgToken('ZABBIX_TOKEN');
|
||||||
|
const radicale_password = orgToken('RADICALE_PASSWORD');
|
||||||
|
const kanboard_token = orgToken('KANBOARD_TOKEN');
|
||||||
|
|
||||||
|
initGitea(gitea_token);
|
||||||
|
initHA(ha_token);
|
||||||
|
initZabbix(zabbix_token);
|
||||||
|
initRadicale(radicale_password);
|
||||||
|
initKanboard(kanboard_token);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MCP Server factory (one per session — McpServer can't share transports) ---
|
||||||
|
function ok(text) {
|
||||||
|
return { content: [{ type: 'text', text: typeof text === 'string' ? text : JSON.stringify(text, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function err(e) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createServer() {
|
||||||
|
const server = new McpServer({ name: 'agap-mcp', version: '1.0.0' });
|
||||||
|
|
||||||
|
// --- Vaultwarden tools ---
|
||||||
|
server.tool('vw_get_password', 'Get password for a Vaultwarden item by name', { name: z.string() },
|
||||||
|
async ({ name }) => {
|
||||||
|
try { return ok(vwGetPassword(name)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('vw_get_item', 'Get full details of a Vaultwarden item (name, username, password, url, notes)', { name: z.string() },
|
||||||
|
async ({ name }) => {
|
||||||
|
try {
|
||||||
|
const item = vwGetItem(name);
|
||||||
|
return ok({ name: item.name, username: item.login?.username, password: item.login?.password, url: item.login?.uris?.[0]?.uri, notes: item.notes });
|
||||||
|
} catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('vw_list_items', 'List Vaultwarden items. Searches personal vault by default; set org=true to search org/AI collection', {
|
||||||
|
search: z.string().optional(),
|
||||||
|
org: z.boolean().optional(),
|
||||||
|
}, async ({ search, org }) => {
|
||||||
|
try {
|
||||||
|
const items = org ? vwListOrgItems(search) : vwListItems(search);
|
||||||
|
return ok(items.map(i => ({ id: i.id, name: i.name, username: i.login?.username, url: i.login?.uris?.[0]?.uri })));
|
||||||
|
} catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('vw_create_login', 'Create a new login item in Vaultwarden AI collection', {
|
||||||
|
name: z.string(),
|
||||||
|
username: z.string().optional(),
|
||||||
|
password: z.string(),
|
||||||
|
url: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(vwCreateLogin(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('vw_update_password', 'Update the password of an existing Vaultwarden item', {
|
||||||
|
name: z.string().describe('Item name or ID'),
|
||||||
|
password: z.string(),
|
||||||
|
}, async ({ name, password }) => {
|
||||||
|
try { return ok(vwUpdatePassword(name, password)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Gitea tools ---
|
||||||
|
server.tool('gitea_list_repos', 'List all Gitea repositories', {},
|
||||||
|
async () => {
|
||||||
|
try { return ok(await giteaListRepos()); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('gitea_read_file', 'Read a file from a Gitea repository', {
|
||||||
|
repo: z.string().describe('e.g. alvis/AgapHost'),
|
||||||
|
path: z.string().describe('file path in repo'),
|
||||||
|
ref: z.string().optional().describe('branch/tag/commit, default HEAD'),
|
||||||
|
}, async ({ repo, path, ref }) => {
|
||||||
|
try { return ok(await giteaReadFile(repo, path, ref)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('gitea_wiki_list', 'List wiki pages', {
|
||||||
|
repo: z.string().optional().describe('default: alvis/AgapHost'),
|
||||||
|
}, async ({ repo }) => {
|
||||||
|
try { return ok(await giteaWikiList(repo)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('gitea_wiki_read', 'Read a wiki page', {
|
||||||
|
page: z.string(),
|
||||||
|
repo: z.string().optional().describe('default: alvis/AgapHost'),
|
||||||
|
}, async ({ page, repo }) => {
|
||||||
|
try { return ok(await giteaWikiRead(page, repo)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('gitea_wiki_write', 'Write/update a wiki page', {
|
||||||
|
page: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
message: z.string().optional().describe('commit message'),
|
||||||
|
repo: z.string().optional().describe('default: alvis/AgapHost'),
|
||||||
|
}, async ({ page, content, message, repo }) => {
|
||||||
|
try { return ok(await giteaWikiWrite(page, content, message, repo)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('gitea_list_issues', 'List issues for a Gitea repo', {
|
||||||
|
repo: z.string().describe('e.g. alvis/AgapHost'),
|
||||||
|
state: z.enum(['open', 'closed', 'all']).optional(),
|
||||||
|
}, async ({ repo, state }) => {
|
||||||
|
try { return ok(await giteaListIssues(repo, state)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Home Assistant tools ---
|
||||||
|
server.tool('ha_get_state', 'Get current state of a Home Assistant entity', {
|
||||||
|
entity_id: z.string().describe('e.g. light.living_room'),
|
||||||
|
}, async ({ entity_id }) => {
|
||||||
|
try { return ok(await haGetState(entity_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('ha_list_entities', 'List Home Assistant entities, optionally filtered by domain', {
|
||||||
|
domain: z.string().optional().describe('e.g. light, switch, sensor, binary_sensor'),
|
||||||
|
}, async ({ domain }) => {
|
||||||
|
try { return ok(await haListEntities(domain)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('ha_call_service', 'Call a Home Assistant service', {
|
||||||
|
domain: z.string().describe('e.g. light, switch, automation'),
|
||||||
|
service: z.string().describe('e.g. turn_on, turn_off, toggle'),
|
||||||
|
data: z.record(z.unknown()).optional().describe('service call data, e.g. {"entity_id": "light.x"}'),
|
||||||
|
}, async ({ domain, service, data }) => {
|
||||||
|
try { return ok(await haCallService(domain, service, data)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('ha_get_history', 'Get state history for a Home Assistant entity', {
|
||||||
|
entity_id: z.string(),
|
||||||
|
hours: z.number().optional().describe('hours of history, default 24'),
|
||||||
|
}, async ({ entity_id, hours }) => {
|
||||||
|
try { return ok(await haGetHistory(entity_id, hours)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Zabbix tools ---
|
||||||
|
server.tool('zabbix_get_problems', 'Get current active problems in Zabbix', {},
|
||||||
|
async () => {
|
||||||
|
try { return ok(await zabbixGetProblems()); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('zabbix_get_hosts', 'List all monitored hosts in Zabbix with availability status', {},
|
||||||
|
async () => {
|
||||||
|
try { return ok(await zabbixGetHosts()); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('zabbix_get_items', 'Get monitored items and their latest values for a Zabbix host', {
|
||||||
|
hostid: z.string().describe('Zabbix host ID'),
|
||||||
|
}, async ({ hostid }) => {
|
||||||
|
try { return ok(await zabbixGetItems(hostid)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('zabbix_get_triggers', 'Get triggers for a Zabbix host or all hosts', {
|
||||||
|
hostid: z.string().optional().describe('Zabbix host ID, omit for all hosts'),
|
||||||
|
}, async ({ hostid }) => {
|
||||||
|
try { return ok(await zabbixGetTriggers(hostid)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Radicale (CalDAV) tools ---
|
||||||
|
server.tool('radicale_list_calendars', 'List CalDAV calendars for a Radicale user (defaults to alvis)', {
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ user }) => {
|
||||||
|
try { return ok(await radicaleListCalendars(user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_list_events', 'List events in a Radicale calendar (returns filename, uid, summary, dtstart, dtend)', {
|
||||||
|
calendar_id: z.string().describe('Calendar UUID from radicale_list_calendars'),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ calendar_id, user }) => {
|
||||||
|
try { return ok(await radicaleListEvents(calendar_id, user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_get_event', 'Get raw iCalendar text for a single event', {
|
||||||
|
calendar_id: z.string(),
|
||||||
|
filename: z.string().describe('e.g. ABCD-1234.ics'),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ calendar_id, filename, user }) => {
|
||||||
|
try { return ok(await radicaleGetEvent(calendar_id, filename, user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_create_calendar', 'Create a new CalDAV calendar (MKCALENDAR). Returns the new calendar id.', {
|
||||||
|
displayname: z.string(),
|
||||||
|
color: z.string().optional().describe('e.g. #33CC66ff'),
|
||||||
|
components: z.array(z.string()).optional().describe('default ["VEVENT"]'),
|
||||||
|
id: z.string().optional().describe('explicit collection id; otherwise UUID generated'),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await radicaleCreateCalendar(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_delete_calendar', 'Delete a Radicale calendar collection', {
|
||||||
|
calendar_id: z.string(),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ calendar_id, user }) => {
|
||||||
|
try { return ok(await radicaleDeleteCalendar(calendar_id, user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_put_event', 'PUT an iCalendar event into a calendar (create or replace)', {
|
||||||
|
calendar_id: z.string(),
|
||||||
|
filename: z.string().describe('Event filename, with or without .ics'),
|
||||||
|
ics: z.string().describe('Full VCALENDAR body'),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ calendar_id, filename, ics, user }) => {
|
||||||
|
try { return ok(await radicalePutEvent(calendar_id, filename, ics, user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_delete_event', 'Delete an event from a calendar', {
|
||||||
|
calendar_id: z.string(),
|
||||||
|
filename: z.string(),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ calendar_id, filename, user }) => {
|
||||||
|
try { return ok(await radicaleDeleteEvent(calendar_id, filename, user)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('radicale_move_event', 'Move an event between calendars (copy then delete original)', {
|
||||||
|
from_calendar: z.string(),
|
||||||
|
to_calendar: z.string(),
|
||||||
|
filename: z.string(),
|
||||||
|
user: z.string().optional(),
|
||||||
|
}, async ({ from_calendar, to_calendar, filename, user }) => {
|
||||||
|
try { return ok(await radicaleMoveEvent({ fromCalendar: from_calendar, toCalendar: to_calendar, filename, user })); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Kanboard tools ---
|
||||||
|
server.tool('kanboard_list_projects', 'List all Kanboard projects (id, name, active, owner)', {},
|
||||||
|
async () => {
|
||||||
|
try { return ok(await kbListProjects()); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_get_project', 'Get a Kanboard project with its columns and swimlanes (needed to move cards)', {
|
||||||
|
project_id: z.number().describe('Project ID'),
|
||||||
|
}, async ({ project_id }) => {
|
||||||
|
try { return ok(await kbGetProject(project_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_list_tasks', 'List tasks in a Kanboard project', {
|
||||||
|
project_id: z.number(),
|
||||||
|
status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'),
|
||||||
|
}, async ({ project_id, status }) => {
|
||||||
|
try { return ok(await kbListTasks(project_id, status)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_my_tasks', "List tasks assigned to the bot user (claude) across all projects", {
|
||||||
|
status: z.enum(['open', 'closed', 'all']).optional().describe('default: open'),
|
||||||
|
}, async ({ status }) => {
|
||||||
|
try { return ok(await kbMyTasks(status)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_get_task', 'Get full detail of a Kanboard task, including its subtasks and comments', {
|
||||||
|
task_id: z.number(),
|
||||||
|
}, async ({ task_id }) => {
|
||||||
|
try { return ok(await kbGetTask(task_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_search_tasks', 'Search tasks in a project using Kanboard query syntax (e.g. "assignee:claude status:open", "due:today", "color:red")', {
|
||||||
|
project_id: z.number(),
|
||||||
|
query: z.string(),
|
||||||
|
}, async ({ project_id, query }) => {
|
||||||
|
try { return ok(await kbSearchTasks(project_id, query)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_list_users', 'List all Kanboard users (id, username, name, role)', {},
|
||||||
|
async () => {
|
||||||
|
try { return ok(await kbListUsers()); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_project_activity', 'Get the recent activity stream for a project (who did what)', {
|
||||||
|
project_id: z.number(),
|
||||||
|
}, async ({ project_id }) => {
|
||||||
|
try { return ok(await kbProjectActivity(project_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_create_task', 'Create a Kanboard task (authored by claude). At minimum provide title and project_id', {
|
||||||
|
title: z.string(),
|
||||||
|
project_id: z.number(),
|
||||||
|
description: z.string().optional().describe('Markdown'),
|
||||||
|
column_id: z.number().optional(),
|
||||||
|
owner_id: z.number().optional().describe('Assignee user id'),
|
||||||
|
color_id: z.string().optional().describe('e.g. yellow, blue, red, green'),
|
||||||
|
date_due: z.string().optional().describe('YYYY-MM-DD'),
|
||||||
|
priority: z.number().optional(),
|
||||||
|
swimlane_id: z.number().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await kbCreateTask(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_update_task', 'Update fields of a Kanboard task', {
|
||||||
|
id: z.number().describe('Task id'),
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
owner_id: z.number().optional(),
|
||||||
|
color_id: z.string().optional(),
|
||||||
|
date_due: z.string().optional().describe('YYYY-MM-DD'),
|
||||||
|
priority: z.number().optional(),
|
||||||
|
category_id: z.number().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await kbUpdateTask(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_move_task', 'Move a task to a column/position (e.g. to "Work in progress" or "Done"). project_id and swimlane_id are auto-resolved if omitted', {
|
||||||
|
task_id: z.number(),
|
||||||
|
column_id: z.number().describe('Target column id (see kanboard_get_project)'),
|
||||||
|
position: z.number().optional().describe('default 1 (top)'),
|
||||||
|
swimlane_id: z.number().optional(),
|
||||||
|
project_id: z.number().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await kbMoveTask(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_change_task_status', 'Open or close (mark done/archive) a task', {
|
||||||
|
task_id: z.number(),
|
||||||
|
action: z.enum(['open', 'close']),
|
||||||
|
}, async ({ task_id, action }) => {
|
||||||
|
try { return ok(await kbChangeTaskStatus(task_id, action)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_assign_task', 'Assign a task to a user (username or numeric id). Use "claude" to take it', {
|
||||||
|
task_id: z.number(),
|
||||||
|
owner: z.union([z.string(), z.number()]).describe('Username or user id'),
|
||||||
|
}, async ({ task_id, owner }) => {
|
||||||
|
try { return ok(await kbAssignTask(task_id, owner)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_add_comment', 'Add a comment to a task (authored by claude by default)', {
|
||||||
|
task_id: z.number(),
|
||||||
|
content: z.string().describe('Markdown'),
|
||||||
|
user_id: z.number().optional().describe('Override author; defaults to claude'),
|
||||||
|
}, async ({ task_id, content, user_id }) => {
|
||||||
|
try { return ok(await kbAddComment(task_id, content, user_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_create_subtask', 'Add a subtask to a task (good for tracking execution steps)', {
|
||||||
|
task_id: z.number(),
|
||||||
|
title: z.string(),
|
||||||
|
user_id: z.number().optional().describe('Assignee; defaults to claude'),
|
||||||
|
status: z.number().optional().describe('0=todo, 1=in progress, 2=done'),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await kbCreateSubtask(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_update_subtask', 'Update a subtask (e.g. mark in-progress or done)', {
|
||||||
|
id: z.number().describe('Subtask id'),
|
||||||
|
task_id: z.number(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
status: z.number().optional().describe('0=todo, 1=in progress, 2=done'),
|
||||||
|
user_id: z.number().optional(),
|
||||||
|
}, async (args) => {
|
||||||
|
try { return ok(await kbUpdateSubtask(args)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_remove_task', 'Permanently delete a task (irreversible)', {
|
||||||
|
task_id: z.number(),
|
||||||
|
}, async ({ task_id }) => {
|
||||||
|
try { return ok(await kbRemoveTask(task_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
server.tool('kanboard_remove_comment', 'Permanently delete a comment (irreversible)', {
|
||||||
|
comment_id: z.number(),
|
||||||
|
}, async ({ comment_id }) => {
|
||||||
|
try { return ok(await kbRemoveComment(comment_id)); } catch (e) { return err(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP server (Streamable HTTP + legacy SSE) ---
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const sseTransports = new Map();
|
||||||
|
|
||||||
|
// Streamable HTTP — stateless: fresh server per request, survives container restarts
|
||||||
|
app.all('/mcp', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||||
|
res.on('close', () => transport.close());
|
||||||
|
await createServer().connect(transport);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('MCP request error:', e.message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legacy SSE — kept for backward compatibility
|
||||||
|
app.get('/sse', async (req, res) => {
|
||||||
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
|
sseTransports.set(transport.sessionId, transport);
|
||||||
|
res.on('close', () => sseTransports.delete(transport.sessionId));
|
||||||
|
await createServer().connect(transport);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/messages', async (req, res) => {
|
||||||
|
const transport = sseTransports.get(req.query.sessionId);
|
||||||
|
if (!transport) return res.status(400).send('Unknown session');
|
||||||
|
await transport.handlePostMessage(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/health', (_, res) => res.json({ status: 'ok', tools: 45 }));
|
||||||
|
|
||||||
|
init()
|
||||||
|
.then(() => {
|
||||||
|
app.listen(PORT, () => console.log(`agap-mcp listening on :${PORT}`));
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
console.error('Init failed:', e.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
102
agap-mcp/src/vaultwarden.js
Normal file
102
agap-mcp/src/vaultwarden.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
|
||||||
|
const BW = 'bw';
|
||||||
|
const ORG_ID = '4bd75130-b4d3-48d4-a4cb-e52b70295a51';
|
||||||
|
const AI_COLLECTION = '5be27a82-8475-4c38-96b2-fa94ec8c957b';
|
||||||
|
|
||||||
|
let _session = null;
|
||||||
|
|
||||||
|
function bwEnv() {
|
||||||
|
const env = { ...process.env };
|
||||||
|
for (const k of ['HTTPS_PROXY','HTTP_PROXY','ALL_PROXY','https_proxy','http_proxy','all_proxy'])
|
||||||
|
delete env[k];
|
||||||
|
env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(args, input) {
|
||||||
|
return execFileSync(BW, args, {
|
||||||
|
env: bwEnv(),
|
||||||
|
encoding: 'utf8',
|
||||||
|
input,
|
||||||
|
stdio: input ? ['pipe','pipe','pipe'] : ['ignore','pipe','pipe'],
|
||||||
|
}).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initVaultwarden() {
|
||||||
|
const email = process.env.BW_EMAIL || 'allogn@gmail.com';
|
||||||
|
const password = process.env.BW_PASSWORD;
|
||||||
|
|
||||||
|
// Data dir is mounted from host — server already configured, skip bw config server
|
||||||
|
|
||||||
|
let status = 'unauthenticated';
|
||||||
|
try {
|
||||||
|
status = JSON.parse(run(['status'])).status;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (status === 'unauthenticated') {
|
||||||
|
run(['login', email, password, '--raw']);
|
||||||
|
}
|
||||||
|
|
||||||
|
_session = run(['unlock', password, '--raw']);
|
||||||
|
run(['sync', '--session', _session]);
|
||||||
|
console.log('Vaultwarden: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
function session() {
|
||||||
|
if (!_session) throw new Error('Vaultwarden not initialized');
|
||||||
|
return _session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwGetPassword(name) {
|
||||||
|
return run(['get', 'password', name, '--session', session()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwGetItem(name) {
|
||||||
|
return JSON.parse(run(['get', 'item', name, '--session', session()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwListItems(search) {
|
||||||
|
const args = ['list', 'items', '--session', session()];
|
||||||
|
if (search) args.push('--search', search);
|
||||||
|
return JSON.parse(run(args));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwListOrgItems(search) {
|
||||||
|
const args = ['list', 'items', '--organizationid', ORG_ID, '--session', session()];
|
||||||
|
if (search) args.push('--search', search);
|
||||||
|
return JSON.parse(run(args));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwCreateLogin({ name, username, password, url, notes }) {
|
||||||
|
const item = {
|
||||||
|
organizationId: ORG_ID,
|
||||||
|
collectionIds: [AI_COLLECTION],
|
||||||
|
folderId: null,
|
||||||
|
type: 1,
|
||||||
|
name,
|
||||||
|
notes: notes || null,
|
||||||
|
favorite: false,
|
||||||
|
login: {
|
||||||
|
username: username || null,
|
||||||
|
password,
|
||||||
|
uris: url ? [{ match: null, uri: url }] : [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const encoded = run(['encode'], JSON.stringify(item));
|
||||||
|
return JSON.parse(run(['create', 'item', encoded, '--session', session()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vwUpdatePassword(nameOrId, newPassword) {
|
||||||
|
let item;
|
||||||
|
try {
|
||||||
|
item = JSON.parse(run(['get', 'item', nameOrId, '--session', session()]));
|
||||||
|
} catch {
|
||||||
|
const items = vwListOrgItems(nameOrId);
|
||||||
|
item = items.find(i => i.name === nameOrId);
|
||||||
|
if (!item) throw new Error(`Item not found: ${nameOrId}`);
|
||||||
|
}
|
||||||
|
item.login.password = newPassword;
|
||||||
|
const encoded = run(['encode'], JSON.stringify(item));
|
||||||
|
return JSON.parse(run(['edit', 'item', item.id, encoded, '--session', session()]));
|
||||||
|
}
|
||||||
75
agap-mcp/src/zabbix.js
Normal file
75
agap-mcp/src/zabbix.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const BASE = () => `${process.env.ZABBIX_URL || 'http://localhost:81'}/api_jsonrpc.php`;
|
||||||
|
let _token = null;
|
||||||
|
let _reqId = 1;
|
||||||
|
|
||||||
|
export function initZabbix(token) {
|
||||||
|
_token = token;
|
||||||
|
console.log('Zabbix: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(method, params = {}) {
|
||||||
|
const res = await fetch(BASE(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${_token}` },
|
||||||
|
body: JSON.stringify({ jsonrpc: '2.0', method, params, id: _reqId++ }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) throw new Error(`Zabbix ${method}: ${data.error.data}`);
|
||||||
|
return data.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zabbixGetProblems() {
|
||||||
|
const problems = await api('problem.get', {
|
||||||
|
output: ['eventid', 'name', 'severity', 'clock', 'acknowledged'],
|
||||||
|
selectAcknowledges: 'count',
|
||||||
|
recent: true,
|
||||||
|
sortfield: 'eventid',
|
||||||
|
sortorder: 'DESC',
|
||||||
|
});
|
||||||
|
return problems.map(p => ({
|
||||||
|
id: p.eventid,
|
||||||
|
name: p.name,
|
||||||
|
severity: ['Not classified','Information','Warning','Average','High','Disaster'][+p.severity],
|
||||||
|
time: new Date(+p.clock * 1000).toISOString(),
|
||||||
|
acknowledged: +p.acknowledged > 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zabbixGetHosts() {
|
||||||
|
return api('host.get', {
|
||||||
|
output: ['hostid', 'host', 'name', 'available', 'status'],
|
||||||
|
selectInterfaces: ['ip'],
|
||||||
|
}).then(hosts => hosts.map(h => ({
|
||||||
|
id: h.hostid,
|
||||||
|
name: h.name || h.host,
|
||||||
|
available: ['Unknown','Available','Unavailable'][+h.available] || 'Unknown',
|
||||||
|
enabled: h.status === '0',
|
||||||
|
ip: h.interfaces?.[0]?.ip,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zabbixGetItems(hostid) {
|
||||||
|
return api('item.get', {
|
||||||
|
output: ['itemid', 'name', 'key_', 'lastvalue', 'units', 'lastclock'],
|
||||||
|
hostids: hostid,
|
||||||
|
monitored: true,
|
||||||
|
sortfield: 'name',
|
||||||
|
}).then(items => items.map(i => ({
|
||||||
|
name: i.name,
|
||||||
|
key: i.key_,
|
||||||
|
value: i.lastvalue,
|
||||||
|
units: i.units,
|
||||||
|
updated: new Date(+i.lastclock * 1000).toISOString(),
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zabbixGetTriggers(hostid) {
|
||||||
|
const params = { output: ['triggerid','description','priority','value'], active: 1, monitored: 1 };
|
||||||
|
if (hostid) params.hostids = hostid;
|
||||||
|
return api('trigger.get', params).then(triggers => triggers.map(t => ({
|
||||||
|
id: t.triggerid,
|
||||||
|
name: t.description,
|
||||||
|
severity: ['Not classified','Information','Warning','Average','High','Disaster'][+t.priority],
|
||||||
|
problem: t.value === '1',
|
||||||
|
})));
|
||||||
|
}
|
||||||
2
agap-mcp/start.sh
Normal file
2
agap-mcp/start.sh
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
exec node src/server.js
|
||||||
11
anki/Dockerfile
Normal file
11
anki/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir anki
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
VOLUME /anki_data
|
||||||
|
|
||||||
|
ENV SYNC_BASE=/anki_data
|
||||||
|
|
||||||
|
CMD ["python", "-m", "anki.syncserver"]
|
||||||
17
anki/docker-compose.yml
Normal file
17
anki/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: anki
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
image: anki-sync-server:local
|
||||||
|
container_name: anki-sync-server
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8180:8080"
|
||||||
|
volumes:
|
||||||
|
- data:/anki_data
|
||||||
|
environment:
|
||||||
|
- SYNC_USER1=${ANKI_USER1:-admin:changeme}
|
||||||
|
- SYNC_BASE=/anki_data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
6
family/Dockerfile
Normal file
6
family/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM mediawiki:latest
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y ffmpeg unzip && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN curl -sL "https://extdist.wmflabs.org/dist/extensions/TimedMediaHandler-REL1_44-ef5edcf.tar.gz" \
|
||||||
|
| tar -xz -C /var/www/html/extensions/
|
||||||
BIN
family/IMG_0448.JPG
Normal file
BIN
family/IMG_0448.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
165
family/LocalSettings.php
Normal file
165
family/LocalSettings.php
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<?php
|
||||||
|
# This file was automatically generated by the MediaWiki 1.45.3
|
||||||
|
# installer. If you make manual changes, please keep track in case you
|
||||||
|
# need to recreate them later.
|
||||||
|
#
|
||||||
|
# See includes/MainConfigSchema.php for all configurable settings
|
||||||
|
# and their default values, but don't forget to make changes in _this_
|
||||||
|
# file, not there.
|
||||||
|
#
|
||||||
|
# Further documentation for configuration settings may be found at:
|
||||||
|
# https://www.mediawiki.org/wiki/Manual:Configuration_settings
|
||||||
|
|
||||||
|
# Protect against web entry
|
||||||
|
if ( !defined( 'MEDIAWIKI' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Uncomment this to disable output compression
|
||||||
|
# $wgDisableOutputCompression = true;
|
||||||
|
|
||||||
|
$wgSitename = "РодоВики";
|
||||||
|
|
||||||
|
## The URL base path to the directory containing the wiki;
|
||||||
|
## defaults for all runtime URL paths are based off of this.
|
||||||
|
## For more information on customizing the URLs
|
||||||
|
## (like /w/index.php/Page_title to /wiki/Page_title) please see:
|
||||||
|
## https://www.mediawiki.org/wiki/Manual:Short_URL
|
||||||
|
$wgScriptPath = "";
|
||||||
|
|
||||||
|
## The protocol and server name to use in fully-qualified URLs
|
||||||
|
$wgServer = "https://family.alogins.net";
|
||||||
|
|
||||||
|
## The URL path to static resources (images, scripts, etc.)
|
||||||
|
$wgResourceBasePath = $wgScriptPath;
|
||||||
|
|
||||||
|
## The URL paths to the logo. Make sure you change this from the default,
|
||||||
|
## or else you'll overwrite your logo when you upgrade!
|
||||||
|
$wgLogos = [
|
||||||
|
'1x' => "$wgResourceBasePath/images/logo.jpg",
|
||||||
|
'icon' => "$wgResourceBasePath/images/logo.jpg",
|
||||||
|
];
|
||||||
|
|
||||||
|
## UPO means: this is also a user preference option
|
||||||
|
|
||||||
|
$wgEnableEmail = true;
|
||||||
|
$wgEnableUserEmail = true; # UPO
|
||||||
|
|
||||||
|
$wgEmergencyContact = "";
|
||||||
|
$wgPasswordSender = "";
|
||||||
|
|
||||||
|
$wgEnotifUserTalk = false; # UPO
|
||||||
|
$wgEnotifWatchlist = false; # UPO
|
||||||
|
$wgEmailAuthentication = true;
|
||||||
|
|
||||||
|
## Database settings
|
||||||
|
$wgDBtype = "mysql";
|
||||||
|
$wgDBserver = "db";
|
||||||
|
$wgDBname = "mediawiki";
|
||||||
|
$wgDBuser = "mw_k7px2q";
|
||||||
|
$wgDBpassword = "Vt9#mLqR4wXn8bZ2";
|
||||||
|
|
||||||
|
# MySQL specific settings
|
||||||
|
$wgDBprefix = "fw";
|
||||||
|
$wgDBssl = false;
|
||||||
|
|
||||||
|
# MySQL table options to use during installation or update
|
||||||
|
$wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary";
|
||||||
|
|
||||||
|
# Shared database table
|
||||||
|
# This has no effect unless $wgSharedDB is also set.
|
||||||
|
$wgSharedTables[] = "actor";
|
||||||
|
|
||||||
|
## Shared memory settings
|
||||||
|
$wgMainCacheType = CACHE_ACCEL;
|
||||||
|
$wgMemCachedServers = [];
|
||||||
|
|
||||||
|
## To enable image uploads, make sure the 'images' directory
|
||||||
|
## is writable, then set this to true:
|
||||||
|
$wgEnableUploads = true;
|
||||||
|
$wgMaxUploadSize = 20 * 1024 * 1024; // 20MB
|
||||||
|
$wgFileExtensions = array_merge( $wgFileExtensions, [
|
||||||
|
'png', 'gif', 'jpg', 'jpeg', 'webp',
|
||||||
|
'mp4', 'webm', 'ogv',
|
||||||
|
'mp3', 'ogg', 'oga', 'wav', 'flac',
|
||||||
|
] );
|
||||||
|
$wgUseImageMagick = true;
|
||||||
|
$wgImageMagickConvertCommand = "/usr/bin/convert";
|
||||||
|
|
||||||
|
# InstantCommons allows wiki to use images from https://commons.wikimedia.org
|
||||||
|
$wgUseInstantCommons = false;
|
||||||
|
|
||||||
|
# Periodically send a pingback to https://www.mediawiki.org/ with basic data
|
||||||
|
# about this MediaWiki instance. The Wikimedia Foundation shares this data
|
||||||
|
# with MediaWiki developers to help guide future development efforts.
|
||||||
|
$wgPingback = true;
|
||||||
|
|
||||||
|
# Site language code, should be one of the list in ./includes/languages/data/Names.php
|
||||||
|
$wgLanguageCode = "ru";
|
||||||
|
|
||||||
|
# Time zone
|
||||||
|
$wgLocaltimezone = "UTC";
|
||||||
|
|
||||||
|
## Set $wgCacheDirectory to a writable directory on the web server
|
||||||
|
## to make your wiki go slightly faster. The directory should not
|
||||||
|
## be publicly accessible from the web.
|
||||||
|
#$wgCacheDirectory = "$IP/cache";
|
||||||
|
|
||||||
|
$wgSecretKey = "5156b70f5486793efade503a2301caf53b9fe241505868a90e33509814fb7d1f";
|
||||||
|
|
||||||
|
# Changing this will log out all existing sessions.
|
||||||
|
$wgAuthenticationTokenVersion = "1";
|
||||||
|
|
||||||
|
# Bust ResourceLoader CSS cache
|
||||||
|
$wgCacheEpoch = '20260403000001';
|
||||||
|
|
||||||
|
# Site upgrade key. Must be set to a string (default provided) to turn on the
|
||||||
|
# web installer while LocalSettings.php is in place
|
||||||
|
$wgUpgradeKey = "da11c2f2774f498f";
|
||||||
|
|
||||||
|
## For attaching licensing metadata to pages, and displaying an
|
||||||
|
## appropriate copyright notice / icon. GNU Free Documentation
|
||||||
|
## License and Creative Commons licenses are supported so far.
|
||||||
|
$wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright
|
||||||
|
$wgRightsUrl = "";
|
||||||
|
$wgRightsText = "";
|
||||||
|
$wgRightsIcon = "";
|
||||||
|
|
||||||
|
# Path to the GNU diff3 utility. Used for conflict resolution.
|
||||||
|
$wgDiff3 = "/usr/bin/diff3";
|
||||||
|
|
||||||
|
## Default skin: you can change the default skin. Use the internal symbolic
|
||||||
|
## names, e.g. 'vector' or 'monobook':
|
||||||
|
$wgDefaultSkin = "vector-2022";
|
||||||
|
|
||||||
|
# Enabled skins.
|
||||||
|
# The following skins were automatically enabled:
|
||||||
|
wfLoadSkin( 'MinervaNeue' );
|
||||||
|
wfLoadSkin( 'MonoBook' );
|
||||||
|
wfLoadSkin( 'Timeless' );
|
||||||
|
wfLoadSkin( 'Vector' );
|
||||||
|
|
||||||
|
|
||||||
|
wfLoadExtension( 'Cite' );
|
||||||
|
wfLoadExtension( 'MultimediaViewer' );
|
||||||
|
wfLoadExtension( 'ParserFunctions' );
|
||||||
|
wfLoadExtension( 'VisualEditor' );
|
||||||
|
wfLoadExtension( 'TimedMediaHandler' );
|
||||||
|
|
||||||
|
$wgDefaultUserOptions['visualeditor-enable'] = 1;
|
||||||
|
$wgDefaultUserOptions['visualeditor-editor'] = 'visualeditor';
|
||||||
|
$wgVisualEditorParsoidAutoConfig = true;
|
||||||
|
|
||||||
|
# End of automatically generated settings.
|
||||||
|
# Add more configuration options below.
|
||||||
|
|
||||||
|
# Restrict all access to logged-in users only
|
||||||
|
$wgGroupPermissions['*']['read'] = false;
|
||||||
|
$wgGroupPermissions['*']['edit'] = false;
|
||||||
|
$wgGroupPermissions['*']['createaccount'] = false;
|
||||||
|
|
||||||
|
# Only admins can create accounts
|
||||||
|
$wgGroupPermissions['sysop']['createaccount'] = true;
|
||||||
BIN
family/__pycache__/migrate.cpython-312.pyc
Normal file
BIN
family/__pycache__/migrate.cpython-312.pyc
Normal file
Binary file not shown.
32
family/docker-compose.yml
Normal file
32
family/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
mediawiki:
|
||||||
|
build: .
|
||||||
|
image: mediawiki-tmh:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8099:80"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/wiki/mediawiki_images:/var/www/html/images
|
||||||
|
- ./LocalSettings.php:/var/www/html/LocalSettings.php # uncomment after initial setup
|
||||||
|
- ./IMG_0448.JPG:/var/www/html/images/logo.jpg
|
||||||
|
- ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
|
||||||
|
environment:
|
||||||
|
MEDIAWIKI_DB_HOST: db
|
||||||
|
MEDIAWIKI_DB_NAME: mediawiki
|
||||||
|
MEDIAWIKI_DB_USER: mw_k7px2q
|
||||||
|
MEDIAWIKI_DB_PASSWORD: Vt9#mLqR4wXn8bZ2
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:lts
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: mediawiki
|
||||||
|
MYSQL_USER: mw_k7px2q
|
||||||
|
MYSQL_PASSWORD: Vt9#mLqR4wXn8bZ2
|
||||||
|
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/wiki/mediawiki_db:/var/lib/mysql
|
||||||
|
|
||||||
|
|
||||||
458
family/migrate.py
Normal file
458
family/migrate.py
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OtterWiki → MediaWiki migration script."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
REPO = Path('/mnt/ssd/dbs/otter/app-data/repository')
|
||||||
|
API = 'http://localhost:8099/api.php'
|
||||||
|
|
||||||
|
_FN_DEF = re.compile(r'^\[(\^[^\]]+)\]:\s*(.*)')
|
||||||
|
|
||||||
|
# Cached pandoc availability (None = not yet checked)
|
||||||
|
_PANDOC_AVAILABLE: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _cap(s: str) -> str:
|
||||||
|
"""Title-case: capitalize first letter of each word."""
|
||||||
|
return s.title() if s else s
|
||||||
|
|
||||||
|
|
||||||
|
def _pandoc_available() -> bool:
|
||||||
|
global _PANDOC_AVAILABLE
|
||||||
|
if _PANDOC_AVAILABLE is None:
|
||||||
|
try:
|
||||||
|
_PANDOC_AVAILABLE = subprocess.run(
|
||||||
|
['pandoc', '--version'], capture_output=True
|
||||||
|
).returncode == 0
|
||||||
|
except FileNotFoundError:
|
||||||
|
_PANDOC_AVAILABLE = False
|
||||||
|
return _PANDOC_AVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MediaWiki session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def mw_login(user: str, password: str):
|
||||||
|
s = requests.Session()
|
||||||
|
r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'type': 'login', 'format': 'json'})
|
||||||
|
token = r.json()['query']['tokens']['logintoken']
|
||||||
|
s.post(API, data={'action': 'login', 'lgname': user, 'lgpassword': password,
|
||||||
|
'lgtoken': token, 'format': 'json'})
|
||||||
|
r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'format': 'json'})
|
||||||
|
csrf = r.json()['query']['tokens']['csrftoken']
|
||||||
|
return s, csrf
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Title determination
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def page_title(md_path: Path) -> str:
|
||||||
|
parts = md_path.relative_to(REPO).parts
|
||||||
|
if md_path.name == 'home.md' and len(parts) == 1:
|
||||||
|
return 'Заглавная страница'
|
||||||
|
return _cap(md_path.stem)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Markdown → wikitext conversion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def convert_pandoc(text: str) -> str:
|
||||||
|
return subprocess.run(
|
||||||
|
['pandoc', '-f', 'markdown', '-t', 'mediawiki'],
|
||||||
|
input=text, capture_output=True, text=True
|
||||||
|
).stdout
|
||||||
|
|
||||||
|
|
||||||
|
def convert_python(text: str, skip_info: bool = False) -> str:
|
||||||
|
lines = text.split('\n')
|
||||||
|
|
||||||
|
# Collect footnote definitions in one pass
|
||||||
|
footnotes: dict[str, str] = {}
|
||||||
|
for line in lines:
|
||||||
|
m = _FN_DEF.match(line)
|
||||||
|
if m:
|
||||||
|
footnotes[m.group(1)] = m.group(2)
|
||||||
|
|
||||||
|
def replace_fn(m):
|
||||||
|
key = m.group(0)[1:-1] # strip outer [ ] to match footnotes dict keys
|
||||||
|
content = footnotes.get(key, m.group(0))
|
||||||
|
content = re.sub(r'\[([^\]^][^\]]*)\]\(([^)]+)\)', r'[\2 \1]', content)
|
||||||
|
return f'<ref>{content}</ref>'
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for line in lines:
|
||||||
|
if _FN_DEF.match(line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = re.match(r'^(#{1,6})\s+(.*)', line)
|
||||||
|
if m:
|
||||||
|
eq = '=' * len(m.group(1))
|
||||||
|
out.append(f'{eq} {m.group(2)} {eq}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
if re.match(r'^---+$', line.strip()):
|
||||||
|
out.append('----')
|
||||||
|
continue
|
||||||
|
|
||||||
|
line = re.sub(r'^(\s*)-(\s)', r'\1*\2', line)
|
||||||
|
line = re.sub(r'\*\*\*(.+?)\*\*\*', r"'''''\1'''''", line)
|
||||||
|
line = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", line)
|
||||||
|
line = re.sub(r'\*(.+?)\*', r"''\1''", line)
|
||||||
|
line = re.sub(r'(?<!!)\[([^\]^!][^\]]*)\]\(([^)]+)\)', r'[\2 \1]', line)
|
||||||
|
line = re.sub(r'\[\^\S+?\]', replace_fn, line)
|
||||||
|
out.append(line)
|
||||||
|
|
||||||
|
result = convert_tables('\n'.join(out), skip_info=skip_info)
|
||||||
|
if footnotes:
|
||||||
|
result += '\n<references />'
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _split_cells(line: str) -> list[str]:
|
||||||
|
"""Split a markdown table row on | but not inside [[ ]]."""
|
||||||
|
cells = []
|
||||||
|
depth = 0
|
||||||
|
current = []
|
||||||
|
i = 0
|
||||||
|
# Strip leading/trailing |
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith('|'):
|
||||||
|
line = line[1:]
|
||||||
|
if line.endswith('|'):
|
||||||
|
line = line[:-1]
|
||||||
|
while i < len(line):
|
||||||
|
if line[i:i+2] == '[[':
|
||||||
|
depth += 1
|
||||||
|
current.append('[[')
|
||||||
|
i += 2
|
||||||
|
elif line[i:i+2] == ']]':
|
||||||
|
depth -= 1
|
||||||
|
current.append(']]')
|
||||||
|
i += 2
|
||||||
|
elif line[i] == '|' and depth == 0:
|
||||||
|
cells.append(''.join(current).strip())
|
||||||
|
current = []
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
current.append(line[i])
|
||||||
|
i += 1
|
||||||
|
cells.append(''.join(current).strip())
|
||||||
|
return cells
|
||||||
|
|
||||||
|
|
||||||
|
def convert_tables(text: str, skip_info: bool = False) -> str:
|
||||||
|
lines = text.split('\n')
|
||||||
|
out = []
|
||||||
|
in_table = False
|
||||||
|
skip_table = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if re.match(r'^\|', line):
|
||||||
|
cells = _split_cells(line)
|
||||||
|
if all(re.match(r'^:?-+:?$', c) for c in cells if c):
|
||||||
|
# Separator row: start or continue table
|
||||||
|
if not in_table:
|
||||||
|
header_line = out.pop() if out else ''
|
||||||
|
hcells = _split_cells(header_line)
|
||||||
|
# Blank-header table (all-empty header cells) = OtterWiki info table
|
||||||
|
if skip_info and all(c == '' for c in hcells):
|
||||||
|
skip_table = True
|
||||||
|
in_table = True
|
||||||
|
else:
|
||||||
|
out += ['{| class="wikitable"', '|-', '! ' + ' !! '.join(hcells)]
|
||||||
|
in_table = True
|
||||||
|
skip_table = False
|
||||||
|
if not skip_table:
|
||||||
|
out.append('|-')
|
||||||
|
else:
|
||||||
|
if in_table:
|
||||||
|
if not skip_table:
|
||||||
|
out.append('|-')
|
||||||
|
out.append('| ' + ' || '.join(cells))
|
||||||
|
# else: skip info table row
|
||||||
|
else:
|
||||||
|
out.append(line)
|
||||||
|
else:
|
||||||
|
if in_table:
|
||||||
|
if not skip_table:
|
||||||
|
out.append('|}')
|
||||||
|
in_table = False
|
||||||
|
skip_table = False
|
||||||
|
out.append(line)
|
||||||
|
|
||||||
|
if in_table and not skip_table:
|
||||||
|
out.append('|}')
|
||||||
|
|
||||||
|
return '\n'.join(out)
|
||||||
|
|
||||||
|
|
||||||
|
_PERSON_FIELD_MAP = {
|
||||||
|
'родился': 'родился', 'родилась': 'родился',
|
||||||
|
'умер': 'умер', 'умерла': 'умер',
|
||||||
|
'отец': 'отец', 'мать': 'мать',
|
||||||
|
'супруг': 'супруг', 'супруга': 'супруг', 'муж': 'супруг', 'жена': 'супруг',
|
||||||
|
'дети': 'дети', 'ребёнок': 'дети',
|
||||||
|
'братья': 'братья', 'брат': 'братья', 'сестра': 'братья',
|
||||||
|
'сёстры': 'братья', 'сестры': 'братья',
|
||||||
|
'место рождения': 'место_рождения', 'место_рождения': 'место_рождения',
|
||||||
|
'прочее': 'прочее',
|
||||||
|
}
|
||||||
|
_PERSONA_PARAM_ORDER = ['родился', 'место_рождения', 'умер', 'отец', 'мать', 'супруг', 'дети', 'братья', 'прочее']
|
||||||
|
|
||||||
|
_PLACE_FIELD_MAP = {
|
||||||
|
'тип': 'тип',
|
||||||
|
'статус': 'статус',
|
||||||
|
'страна': 'страна',
|
||||||
|
'регион': 'регион', 'область': 'регион',
|
||||||
|
'район': 'район', 'расположение': 'район', 'самоуправление': 'район',
|
||||||
|
'река': 'река',
|
||||||
|
'основана': 'основана', 'основан': 'основана',
|
||||||
|
'население': 'население',
|
||||||
|
'адрес': 'адрес',
|
||||||
|
'период': 'период', 'годы': 'период',
|
||||||
|
'жильцы': 'жильцы', 'жильцы/семья': 'жильцы', 'семейное имя': 'прочее',
|
||||||
|
'латв. название': 'назв_латыш',
|
||||||
|
'белор. название': 'назв_белор',
|
||||||
|
'координаты': 'координаты',
|
||||||
|
'сайт': 'сайт',
|
||||||
|
'телефон': 'телефон', 'email': 'телефон',
|
||||||
|
'полное название': 'прочее', 'классы': 'прочее',
|
||||||
|
'штаб-квартира': 'прочее', 'сотрудников': 'прочее',
|
||||||
|
}
|
||||||
|
_PLACE_PARAM_ORDER = ['тип', 'статус', 'страна', 'регион', 'район', 'река', 'основана',
|
||||||
|
'население', 'адрес', 'период', 'жильцы', 'назв_латыш', 'назв_белор',
|
||||||
|
'координаты', 'сайт', 'телефон', 'прочее']
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_infobox(text: str, title: str, name_param: str, template: str,
|
||||||
|
field_map: dict, param_order: list) -> tuple[str, str]:
|
||||||
|
"""Extract photo + blank-header info table; return ({{Template|...}}, cleaned_text)."""
|
||||||
|
lines = text.split('\n')
|
||||||
|
photo = None
|
||||||
|
fields: dict[str, str] = {}
|
||||||
|
remove: set[int] = set()
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if re.match(r'^\|', line):
|
||||||
|
break # reached info table — stop looking for photo
|
||||||
|
m = re.match(r'^\s*\[!\[[^\]]*\]\(\./(?:[^/)]+/)?([^)?]+?)(?:\?[^)]*)?\)\]', line)
|
||||||
|
if m:
|
||||||
|
photo = m.group(1)
|
||||||
|
remove.add(i)
|
||||||
|
break
|
||||||
|
|
||||||
|
in_table = False
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if re.match(r'^\|', line):
|
||||||
|
cells = _split_cells(line)
|
||||||
|
if all(re.match(r'^:?-+:?$', c) for c in cells if c):
|
||||||
|
if not in_table:
|
||||||
|
prev = i - 1
|
||||||
|
if prev >= 0 and re.match(r'^\|', lines[prev]):
|
||||||
|
hcells = _split_cells(lines[prev])
|
||||||
|
if all(c == '' for c in hcells):
|
||||||
|
in_table = True
|
||||||
|
remove.add(prev)
|
||||||
|
if in_table:
|
||||||
|
remove.add(i)
|
||||||
|
elif in_table:
|
||||||
|
remove.add(i)
|
||||||
|
if len(cells) >= 2:
|
||||||
|
key = re.sub(r'\*\*(.+?)\*\*', r'\1', cells[0]).strip().lower()
|
||||||
|
val = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", cells[1].strip())
|
||||||
|
param = field_map.get(key)
|
||||||
|
if param and param not in fields:
|
||||||
|
fields[param] = val
|
||||||
|
elif in_table:
|
||||||
|
in_table = False
|
||||||
|
|
||||||
|
if not photo and not fields:
|
||||||
|
return '', text
|
||||||
|
|
||||||
|
parts = ['{{' + template, f'| {name_param:<16} = {title}']
|
||||||
|
if photo:
|
||||||
|
parts.append(f'| фото = {photo}')
|
||||||
|
for param in param_order:
|
||||||
|
if param in fields:
|
||||||
|
parts.append(f'| {param:<16} = {fields[param]}')
|
||||||
|
infobox = '\n'.join(parts) + '\n}}'
|
||||||
|
cleaned = '\n'.join(line for i, line in enumerate(lines) if i not in remove)
|
||||||
|
return infobox, cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def extract_person_infobox(text: str, title: str) -> tuple[str, str]:
|
||||||
|
return _extract_infobox(text, title, 'имя', 'Персона', _PERSON_FIELD_MAP, _PERSONA_PARAM_ORDER)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_place_infobox(text: str, title: str) -> tuple[str, str]:
|
||||||
|
return _extract_infobox(text, title, 'название', 'Место', _PLACE_FIELD_MAP, _PLACE_PARAM_ORDER)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_first_heading(text: str) -> str:
|
||||||
|
"""Remove the first H1 line — MW displays the page title itself."""
|
||||||
|
return re.sub(r'^#[^#][^\n]*\n?', '', text, count=1)
|
||||||
|
|
||||||
|
|
||||||
|
def convert(text: str, skip_info: bool = False, is_place: bool = False, title: str = '') -> str:
|
||||||
|
text = strip_first_heading(text)
|
||||||
|
infobox = ''
|
||||||
|
if skip_info:
|
||||||
|
infobox, text = extract_person_infobox(text, title)
|
||||||
|
elif is_place:
|
||||||
|
infobox, text = extract_place_infobox(text, title)
|
||||||
|
result = convert_pandoc(text) if _pandoc_available() else convert_python(text, skip_info=skip_info)
|
||||||
|
if infobox:
|
||||||
|
result = infobox + '\n' + result.lstrip('\n')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Post-processing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def fix_links(text: str) -> str:
|
||||||
|
pattern = (r'\[\[([^\]|]+)\|'
|
||||||
|
r'(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)'
|
||||||
|
r'/([^\]]+)\]\]')
|
||||||
|
|
||||||
|
def replace_link(m):
|
||||||
|
display = m.group(1).strip()
|
||||||
|
page = _cap(m.group(2).strip().lower())
|
||||||
|
if display.lower() == page.lower():
|
||||||
|
return f'[[{page}]]'
|
||||||
|
return f'[[{page}|{display}]]'
|
||||||
|
|
||||||
|
text = re.sub(pattern, replace_link, text)
|
||||||
|
# Also handle bare section paths: [[Section/PageName]] → [[PageName]]
|
||||||
|
text = re.sub(
|
||||||
|
r'\[\[(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)/([^\]|]+)\]\]',
|
||||||
|
lambda m: f'[[{m.group(1).strip().lower().title()}]]',
|
||||||
|
text
|
||||||
|
)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def fix_images(text: str) -> str:
|
||||||
|
# Handle linked images: [](./file.jpg)
|
||||||
|
# and plain images: 
|
||||||
|
pattern = r'(?:\[)?!\[[^\]]*\]\(\./(?:[^/)]+/)?([^)?]+?)(\?[^)]*)?\)(?:\]\([^)]*\))?'
|
||||||
|
|
||||||
|
def replace_img(m):
|
||||||
|
filename = m.group(1)
|
||||||
|
size_m = re.search(r'thumbnail=(\d+)', m.group(2) or '')
|
||||||
|
return f'[[File:{filename}|{size_m.group(1)}px]]' if size_m else f'[[File:{filename}]]'
|
||||||
|
|
||||||
|
return re.sub(pattern, replace_img, text)
|
||||||
|
|
||||||
|
|
||||||
|
_CATEGORY_MAP = {'люди': 'Люди', 'места': 'Места', 'воспоминания': 'Воспоминания'}
|
||||||
|
|
||||||
|
|
||||||
|
def category_suffix(md_path: Path) -> str:
|
||||||
|
parts = md_path.relative_to(REPO).parts
|
||||||
|
if len(parts) == 1:
|
||||||
|
return '' if md_path.name == 'home.md' else '\n\n[[Category:Статьи]]'
|
||||||
|
cat = _CATEGORY_MAP.get(parts[0].lower())
|
||||||
|
return f'\n\n[[Category:{cat}]]' if cat else ''
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MW operations
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def post_page(session, csrf: str, title: str, text: str, dry_run: bool) -> bool:
|
||||||
|
if dry_run:
|
||||||
|
print(f'[DRY] {title}')
|
||||||
|
return True
|
||||||
|
data = session.post(API, data={
|
||||||
|
'action': 'edit', 'title': title, 'text': text,
|
||||||
|
'token': csrf, 'format': 'json'
|
||||||
|
}).json()
|
||||||
|
if 'error' in data:
|
||||||
|
print(f'[ERR] {title}: {data["error"].get("info", data["error"])}')
|
||||||
|
return False
|
||||||
|
print(f'[OK] {title}')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(session, csrf: str, image_path: Path, dry_run: bool) -> bool:
|
||||||
|
basename = image_path.name
|
||||||
|
if dry_run:
|
||||||
|
print(f'[DRY] File:{basename}')
|
||||||
|
return True
|
||||||
|
with open(image_path, 'rb') as f:
|
||||||
|
data = session.post(API, data={
|
||||||
|
'action': 'upload', 'filename': basename,
|
||||||
|
'token': csrf, 'format': 'json', 'ignorewarnings': '1'
|
||||||
|
}, files={'file': f}).json()
|
||||||
|
if 'error' in data:
|
||||||
|
print(f'[ERR] File:{basename}: {data["error"].get("info", data["error"])}')
|
||||||
|
return False
|
||||||
|
if data.get('upload', {}).get('result') == 'Success':
|
||||||
|
print(f'[OK] File:{basename}')
|
||||||
|
else:
|
||||||
|
print(f'[SKIP] File:{basename} (already exists or no change)')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def collect_images() -> list[Path]:
|
||||||
|
images = []
|
||||||
|
for folder in ('люди', 'места'):
|
||||||
|
p = REPO / folder
|
||||||
|
if p.exists():
|
||||||
|
for ext in ('*.jpg', '*.jpeg', '*.JPG', '*.JPEG', '*.png', '*.PNG'):
|
||||||
|
images.extend(p.rglob(ext))
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Migrate OtterWiki to MediaWiki')
|
||||||
|
parser.add_argument('--user', required=True)
|
||||||
|
parser.add_argument('--password', required=True)
|
||||||
|
parser.add_argument('--dry-run', action='store_true')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
session = csrf = None
|
||||||
|
if not args.dry_run:
|
||||||
|
session, csrf = mw_login(args.user, args.password)
|
||||||
|
|
||||||
|
pages_ok = pages_err = images_ok = images_err = 0
|
||||||
|
|
||||||
|
for md_path in sorted(REPO.rglob('*.md')):
|
||||||
|
title = page_title(md_path)
|
||||||
|
raw = md_path.read_text(encoding='utf-8')
|
||||||
|
parts = md_path.relative_to(REPO).parts
|
||||||
|
is_people = len(parts) > 0 and parts[0].lower() == 'люди'
|
||||||
|
is_place = len(parts) > 0 and parts[0].lower() == 'места'
|
||||||
|
wikitext = convert(raw, skip_info=is_people, is_place=is_place, title=title)
|
||||||
|
wikitext = fix_links(wikitext)
|
||||||
|
wikitext = fix_images(wikitext)
|
||||||
|
wikitext += category_suffix(md_path)
|
||||||
|
if post_page(session, csrf, title, wikitext, args.dry_run):
|
||||||
|
pages_ok += 1
|
||||||
|
else:
|
||||||
|
pages_err += 1
|
||||||
|
|
||||||
|
for img in collect_images():
|
||||||
|
if upload_image(session, csrf, img, args.dry_run):
|
||||||
|
images_ok += 1
|
||||||
|
else:
|
||||||
|
images_err += 1
|
||||||
|
|
||||||
|
print(f'\nDone: {pages_ok} pages, {images_ok} images, {pages_err + images_err} errors')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
2
family/uploads.ini
Normal file
2
family/uploads.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
upload_max_filesize = 20M
|
||||||
|
post_max_size = 25M
|
||||||
11
freshrss/.env
Normal file
11
freshrss/.env
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
BASE_URL=https://news.alogins.net
|
||||||
|
ADMIN_EMAIL=allogn@gmail.com
|
||||||
|
ADMIN_PASSWORD=ff221f4d1!
|
||||||
|
ADMIN_API_PASSWORD=sfs32003r2
|
||||||
|
# Published port if running locally
|
||||||
|
PUBLISHED_PORT=8091
|
||||||
|
# Database credentials (not relevant if using default SQLite database)
|
||||||
|
DB_HOST=freshrss-db
|
||||||
|
DB_BASE=freshrss
|
||||||
|
DB_PASSWORD=freshrss1945133
|
||||||
|
DB_USER=freshrss12
|
||||||
36
freshrss/docker-compose.yml
Normal file
36
freshrss/docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
freshrss:
|
||||||
|
image: freshrss/freshrss:latest
|
||||||
|
# # Optional build section if you want to build the image locally:
|
||||||
|
# build:
|
||||||
|
# # Pick #latest (slow releases) or #edge (rolling release) or a specific release like #1.27.1
|
||||||
|
# context: https://github.com/FreshRSS/FreshRSS.git#latest
|
||||||
|
# dockerfile: Docker/Dockerfile-Alpine
|
||||||
|
container_name: freshrss
|
||||||
|
hostname: freshrss
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# If you want to open a port 8080 on the local machine:
|
||||||
|
- "8091:80"
|
||||||
|
logging:
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
volumes:
|
||||||
|
- /mnt/dbs/freshrss/data:/var/www/FreshRSS/data
|
||||||
|
- /mnt/dbs/freshrss/extensions:/var/www/FreshRSS/extensions
|
||||||
|
- /mnt/dbs/freshrss/db:/var/lib/postgresql
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${DB_BASE:-freshrss}
|
||||||
|
POSTGRES_USER: ${DB_USER:-freshrss}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-freshrss}
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
CRON_MIN: '3,33'
|
||||||
|
LISTEN: 0.0.0.0:80
|
||||||
|
# Optional healthcheck section:
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "cli/health.php"]
|
||||||
|
timeout: 10s
|
||||||
|
start_period: 60s
|
||||||
|
start_interval: 11s
|
||||||
|
interval: 75s
|
||||||
|
retries: 3
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
# You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables
|
# You can find documentation for all the supported env variables at https://docs.immich.app/install/environment-variables
|
||||||
|
|
||||||
# The location where your uploaded files are stored
|
# The location where your uploaded files are stored
|
||||||
UPLOAD_LOCATION=/mnt/media/upload
|
UPLOAD_LOCATION=/mnt/smsg/media/upload
|
||||||
THUMB_LOCATION=/mnt/ssd/media/thumbs
|
THUMB_LOCATION=/mnt/smsg/media/thumbs
|
||||||
ENCODED_VIDEO_LOCATION=/mnt/ssd/media/encoded-video
|
ENCODED_VIDEO_LOCATION=/mnt/smsg/media/encoded-video
|
||||||
|
|
||||||
# The location where your database files are stored. Network shares are not supported for the database
|
# The location where your database files are stored. Network shares are not supported for the database
|
||||||
DB_DATA_LOCATION=/mnt/ssd/media/postgres
|
DB_DATA_LOCATION=/mnt/ssd/media/postgres
|
||||||
|
|||||||
@@ -1,30 +1,42 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
BACKUP_DIR=/mnt/backups/media
|
BACKUP_DIR=/mnt/toshiba/backups/media
|
||||||
DB_BACKUP_DIR="$BACKUP_DIR/backups"
|
|
||||||
LOG="$BACKUP_DIR/backup.log"
|
LOG="$BACKUP_DIR/backup.log"
|
||||||
RETAIN_DAYS=14
|
VERBOSE=0
|
||||||
|
|
||||||
mkdir -p "$DB_BACKUP_DIR"
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
echo "[$(date)] Starting Immich backup" >> "$LOG"
|
-v|--verbose) VERBOSE=1 ;;
|
||||||
|
esac
|
||||||
# 1. Database dump (must come before file sync)
|
|
||||||
DUMP_FILE="$DB_BACKUP_DIR/immich-db-$(date +%Y%m%dT%H%M%S).sql.gz"
|
|
||||||
docker exec immich_postgres pg_dump --clean --if-exists \
|
|
||||||
--dbname=immich --username=postgres | gzip > "$DUMP_FILE"
|
|
||||||
echo "[$(date)] DB dump: $DUMP_FILE" >> "$LOG"
|
|
||||||
|
|
||||||
# 2. Rsync critical asset folders (skip thumbs and encoded-video — regeneratable)
|
|
||||||
for DIR in library upload profile; do
|
|
||||||
rsync -a --delete /mnt/media/upload/$DIR/ "$BACKUP_DIR/$DIR/" >> "$LOG" 2>&1
|
|
||||||
echo "[$(date)] Synced $DIR" >> "$LOG"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# 3. Remove old DB dumps
|
log() { echo "[$(date)] $*" >> "$LOG"; }
|
||||||
find "$DB_BACKUP_DIR" -name "immich-db-*.sql.gz" -mtime +$RETAIN_DAYS -delete
|
say() { [[ $VERBOSE -eq 1 ]] && echo "$*" || true; }
|
||||||
echo "[$(date)] Cleaned dumps older than ${RETAIN_DAYS}d" >> "$LOG"
|
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
say ""
|
||||||
|
say "┌─────────────────────────────────────┐"
|
||||||
|
say "│ Immich Backup Starting │"
|
||||||
|
say "└─────────────────────────────────────┘"
|
||||||
|
say ""
|
||||||
|
log "Starting Immich backup"
|
||||||
|
|
||||||
|
# Rsync critical asset folders (skip thumbs and encoded-video — regeneratable)
|
||||||
|
RSYNC_OPTS="-a --ignore-existing"
|
||||||
|
[[ $VERBOSE -eq 1 ]] && RSYNC_OPTS="$RSYNC_OPTS --info=progress2"
|
||||||
|
|
||||||
|
for DIR in library upload profile; do
|
||||||
|
say " Syncing $DIR/ ..."
|
||||||
|
rsync $RSYNC_OPTS /mnt/smsg/media/upload/$DIR/ "$BACKUP_DIR/$DIR/" 2>&1 | \
|
||||||
|
tee -a "$LOG" | { [[ $VERBOSE -eq 0 ]] && cat > /dev/null || cat; }
|
||||||
|
log "Synced $DIR"
|
||||||
|
say ""
|
||||||
|
done
|
||||||
|
|
||||||
touch "$BACKUP_DIR/.last_sync"
|
touch "$BACKUP_DIR/.last_sync"
|
||||||
echo "[$(date)] Immich backup complete" >> "$LOG"
|
log "Immich backup complete"
|
||||||
|
say "✓ Done"
|
||||||
|
say ""
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
- redis
|
- redis
|
||||||
- database
|
- database
|
||||||
restart: always
|
restart: always
|
||||||
|
mem_limit: 1500m
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: false
|
disable: false
|
||||||
|
|
||||||
@@ -37,15 +38,16 @@ services:
|
|||||||
container_name: immich_machine_learning
|
container_name: immich_machine_learning
|
||||||
# For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
|
# For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
|
||||||
# Example tag: ${IMMICH_VERSION:-release}-cuda
|
# Example tag: ${IMMICH_VERSION:-release}-cuda
|
||||||
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
|
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}-cuda
|
||||||
# extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
|
extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
|
||||||
# file: hwaccel.ml.yml
|
file: hwaccel.ml.yml
|
||||||
# service: cpu # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
|
service: cuda # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
|
||||||
volumes:
|
volumes:
|
||||||
- model-cache:/cache
|
- model-cache:/cache
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: always
|
restart: always
|
||||||
|
mem_limit: 750m
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: false
|
disable: false
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ services:
|
|||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
mem_limit: 256m
|
||||||
|
|
||||||
database:
|
database:
|
||||||
container_name: immich_postgres
|
container_name: immich_postgres
|
||||||
@@ -71,6 +74,7 @@ services:
|
|||||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||||
shm_size: 128mb
|
shm_size: 128mb
|
||||||
restart: always
|
restart: always
|
||||||
|
mem_limit: 1500m
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
model-cache:
|
model-cache:
|
||||||
|
|||||||
57
immich-app/hwaccel.ml.yml
Normal file
57
immich-app/hwaccel.ml.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Configurations for hardware-accelerated machine learning
|
||||||
|
|
||||||
|
# If using Unraid or another platform that doesn't allow multiple Compose files,
|
||||||
|
# you can inline the config for a backend by copying its contents
|
||||||
|
# into the immich-machine-learning service in the docker-compose.yml file.
|
||||||
|
|
||||||
|
# See https://docs.immich.app/features/ml-hardware-acceleration for info on usage.
|
||||||
|
|
||||||
|
services:
|
||||||
|
armnn:
|
||||||
|
devices:
|
||||||
|
- /dev/mali0:/dev/mali0
|
||||||
|
volumes:
|
||||||
|
- /lib/firmware/mali_csffw.bin:/lib/firmware/mali_csffw.bin:ro # Mali firmware for your chipset (not always required depending on the driver)
|
||||||
|
- /usr/lib/libmali.so:/usr/lib/libmali.so:ro # Mali driver for your chipset (always required)
|
||||||
|
|
||||||
|
rknn:
|
||||||
|
security_opt:
|
||||||
|
- systempaths=unconfined
|
||||||
|
- apparmor=unconfined
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
|
||||||
|
cpu: {}
|
||||||
|
|
||||||
|
cuda:
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities:
|
||||||
|
- gpu
|
||||||
|
|
||||||
|
rocm:
|
||||||
|
group_add:
|
||||||
|
- video
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
- /dev/kfd:/dev/kfd
|
||||||
|
|
||||||
|
openvino:
|
||||||
|
device_cgroup_rules:
|
||||||
|
- 'c 189:* rmw'
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
volumes:
|
||||||
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
|
|
||||||
|
openvino-wsl:
|
||||||
|
devices:
|
||||||
|
- /dev/dri:/dev/dri
|
||||||
|
- /dev/dxg:/dev/dxg
|
||||||
|
volumes:
|
||||||
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
|
- /usr/lib/wsl:/usr/lib/wsl
|
||||||
BIN
iperf3/CellularLab-v2.2.apk
Normal file
BIN
iperf3/CellularLab-v2.2.apk
Normal file
Binary file not shown.
BIN
iperf3/apk/CellularLab-v2.2.apk
Normal file
BIN
iperf3/apk/CellularLab-v2.2.apk
Normal file
Binary file not shown.
9
iperf3/apk/index.html
Normal file
9
iperf3/apk/index.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>iperf3 Android</title></head>
|
||||||
|
<body>
|
||||||
|
<h2>iperf3 for Android</h2>
|
||||||
|
<p><a href="CellularLab-v2.2.apk">Download CellularLab v2.2 (iperf3 client)</a></p>
|
||||||
|
<p>Server: <code>iperf.alogins.net</code> — port 5201</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
18
iperf3/docker-compose.yml
Normal file
18
iperf3/docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
iperf3:
|
||||||
|
image: networkstatic/iperf3
|
||||||
|
container_name: iperf3
|
||||||
|
command: -s
|
||||||
|
ports:
|
||||||
|
- "5201:5201"
|
||||||
|
- "5201:5201/udp"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
iperf3-files:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: iperf3-files
|
||||||
|
ports:
|
||||||
|
- "8095:80"
|
||||||
|
volumes:
|
||||||
|
- ./apk:/usr/share/nginx/html:ro
|
||||||
|
restart: unless-stopped
|
||||||
25
kanboard/CLAUDE.md
Normal file
25
kanboard/CLAUDE.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# CLAUDE.md — Kanboard
|
||||||
|
|
||||||
|
Guidance for Claude Code when working with the Kanboard service on the Agap server.
|
||||||
|
|
||||||
|
This directory holds only the **live** Docker Compose config for the `kanboard`
|
||||||
|
container (board data lives in a Docker volume). Everything else Kanboard-related —
|
||||||
|
service docs, the task-orchestration ruleset, the `claude-usage` quota script, and a
|
||||||
|
reference copy of the `kanboard_*` MCP tool implementation — has been consolidated into
|
||||||
|
its own repo: **`/home/alvis/kanboard`** (Gitea: `alvis/kanboard`). Read
|
||||||
|
`/home/alvis/kanboard/CLAUDE.md` before working with Kanboard or the orchestration
|
||||||
|
pipeline.
|
||||||
|
|
||||||
|
- Container `kanboard`, image `kanboard/kanboard:latest`, port `127.0.0.1:4800:80`
|
||||||
|
- The MCP server implementing `mcp__agap__kanboard_*` (`src/kanboard.js`) lives in
|
||||||
|
`agap-mcp` (`/home/alvis/agap_git/agap-mcp`) — shared with other tool sets (`vw_*`,
|
||||||
|
`gitea_*`, `ha_*`, `zabbix_*`, `radicale_*`), so it stays here rather than moving to
|
||||||
|
the kanboard repo.
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # start
|
||||||
|
docker compose restart # restart
|
||||||
|
docker compose logs -f # logs
|
||||||
|
```
|
||||||
17
kanboard/docker-compose.yml
Normal file
17
kanboard/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: kanboard
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: kanboard/kanboard:latest
|
||||||
|
container_name: kanboard
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:4800:80"
|
||||||
|
volumes:
|
||||||
|
- data:/var/www/app/data
|
||||||
|
- plugins:/var/www/app/plugins
|
||||||
|
environment:
|
||||||
|
- PLUGIN_INSTALLER=true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
|
plugins:
|
||||||
473
linkwarden/.env
Normal file
473
linkwarden/.env
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
NEXTAUTH_URL=https://lw.alogins.net/api/v1/auth
|
||||||
|
NEXTAUTH_SECRET=sdf2323frghjkj211
|
||||||
|
|
||||||
|
# Manual installation database settings
|
||||||
|
# Example: DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden
|
||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
|
# Docker installation database settings
|
||||||
|
POSTGRES_PASSWORD=KAf122!!fsdf2w
|
||||||
|
|
||||||
|
# Additional Optional Settings
|
||||||
|
PAGINATION_TAKE_COUNT=
|
||||||
|
STORAGE_FOLDER=
|
||||||
|
AUTOSCROLL_TIMEOUT=
|
||||||
|
NEXT_PUBLIC_DISABLE_REGISTRATION=true
|
||||||
|
NEXT_PUBLIC_CREDENTIALS_ENABLED=
|
||||||
|
DISABLE_NEW_SSO_USERS=
|
||||||
|
MAX_LINKS_PER_USER=
|
||||||
|
ARCHIVE_TAKE_COUNT=
|
||||||
|
BROWSER_TIMEOUT=
|
||||||
|
IGNORE_URL_SIZE_LIMIT=
|
||||||
|
NEXT_PUBLIC_DEMO=
|
||||||
|
NEXT_PUBLIC_DEMO_USERNAME=
|
||||||
|
NEXT_PUBLIC_DEMO_PASSWORD=
|
||||||
|
NEXT_PUBLIC_ADMIN=
|
||||||
|
NEXT_PUBLIC_MAX_FILE_BUFFER=
|
||||||
|
PDF_MAX_BUFFER=
|
||||||
|
SCREENSHOT_MAX_BUFFER=
|
||||||
|
READABILITY_MAX_BUFFER=
|
||||||
|
PREVIEW_MAX_BUFFER=
|
||||||
|
MONOLITH_MAX_BUFFER=
|
||||||
|
MONOLITH_CUSTOM_OPTIONS=
|
||||||
|
IMPORT_LIMIT=
|
||||||
|
PLAYWRIGHT_LAUNCH_OPTIONS_EXECUTABLE_PATH=
|
||||||
|
PLAYWRIGHT_WS_URL=
|
||||||
|
MAX_WORKERS=
|
||||||
|
DISABLE_PRESERVATION=
|
||||||
|
NEXT_PUBLIC_RSS_POLLING_INTERVAL_MINUTES=
|
||||||
|
RSS_SUBSCRIPTION_LIMIT_PER_USER=
|
||||||
|
TEXT_CONTENT_LIMIT=
|
||||||
|
SEARCH_FILTER_LIMIT=
|
||||||
|
INDEX_TAKE_COUNT=
|
||||||
|
MEILI_TIMEOUT=
|
||||||
|
ALLOW_PRIVATE_NETWORK_ACCESS=
|
||||||
|
ALLOW_INSECURE_TLS=
|
||||||
|
|
||||||
|
# AI Settings
|
||||||
|
NEXT_PUBLIC_OLLAMA_ENDPOINT_URL=
|
||||||
|
OLLAMA_MODEL=
|
||||||
|
|
||||||
|
# https://ai-sdk.dev/providers/openai-compatible-providers
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_MODEL=
|
||||||
|
# Optional: Set a custom OpenAI base URL and name (for third-party providers)
|
||||||
|
CUSTOM_OPENAI_BASE_URL=
|
||||||
|
CUSTOM_OPENAI_NAME=
|
||||||
|
|
||||||
|
# https://sdk.vercel.ai/providers/ai-sdk-providers/azure
|
||||||
|
AZURE_API_KEY=
|
||||||
|
AZURE_RESOURCE_NAME=
|
||||||
|
AZURE_MODEL=
|
||||||
|
|
||||||
|
# https://sdk.vercel.ai/providers/ai-sdk-providers/anthropic
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
ANTHROPIC_MODEL=
|
||||||
|
|
||||||
|
# https://github.com/OpenRouterTeam/ai-sdk-provider
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
OPENROUTER_MODEL=
|
||||||
|
|
||||||
|
# https://ai-sdk.dev/providers/ai-sdk-providers/perplexity
|
||||||
|
PERPLEXITY_API_KEY=
|
||||||
|
PERPLEXITY_MODEL=
|
||||||
|
|
||||||
|
# MeiliSearch Settings
|
||||||
|
MEILI_HOST=
|
||||||
|
MEILI_MASTER_KEY=FAfg24!@bbqq
|
||||||
|
|
||||||
|
# AWS S3 Settings
|
||||||
|
SPACES_KEY=
|
||||||
|
SPACES_SECRET=
|
||||||
|
SPACES_ENDPOINT=
|
||||||
|
SPACES_BUCKET_NAME=
|
||||||
|
SPACES_REGION=
|
||||||
|
SPACES_FORCE_PATH_STYLE=
|
||||||
|
|
||||||
|
# SMTP Settings
|
||||||
|
NEXT_PUBLIC_EMAIL_PROVIDER=
|
||||||
|
EMAIL_FROM=
|
||||||
|
EMAIL_SERVER=
|
||||||
|
BASE_URL=
|
||||||
|
|
||||||
|
# Proxy settings
|
||||||
|
PROXY=
|
||||||
|
PROXY_USERNAME=
|
||||||
|
PROXY_PASSWORD=
|
||||||
|
PROXY_BYPASS=
|
||||||
|
|
||||||
|
# PDF archive settings
|
||||||
|
PDF_MARGIN_TOP=
|
||||||
|
PDF_MARGIN_BOTTOM=
|
||||||
|
|
||||||
|
#################
|
||||||
|
# SSO Providers #
|
||||||
|
#################
|
||||||
|
|
||||||
|
# 42 School
|
||||||
|
NEXT_PUBLIC_FORTYTWO_ENABLED=
|
||||||
|
FORTYTWO_CUSTOM_NAME=
|
||||||
|
FORTYTWO_CLIENT_ID=
|
||||||
|
FORTYTWO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Apple
|
||||||
|
NEXT_PUBLIC_APPLE_ENABLED=
|
||||||
|
APPLE_CUSTOM_NAME=
|
||||||
|
APPLE_ID=
|
||||||
|
APPLE_SECRET=
|
||||||
|
|
||||||
|
# Atlassian
|
||||||
|
NEXT_PUBLIC_ATLASSIAN_ENABLED=
|
||||||
|
ATLASSIAN_CUSTOM_NAME=
|
||||||
|
ATLASSIAN_CLIENT_ID=
|
||||||
|
ATLASSIAN_CLIENT_SECRET=
|
||||||
|
ATLASSIAN_SCOPE=
|
||||||
|
|
||||||
|
# Auth0
|
||||||
|
NEXT_PUBLIC_AUTH0_ENABLED=
|
||||||
|
AUTH0_CUSTOM_NAME=
|
||||||
|
AUTH0_ISSUER=
|
||||||
|
AUTH0_CLIENT_SECRET=
|
||||||
|
AUTH0_CLIENT_ID=
|
||||||
|
|
||||||
|
# Authelia
|
||||||
|
NEXT_PUBLIC_AUTHELIA_ENABLED=
|
||||||
|
AUTHELIA_CLIENT_ID=
|
||||||
|
AUTHELIA_CLIENT_SECRET=
|
||||||
|
AUTHELIA_WELLKNOWN_URL=
|
||||||
|
|
||||||
|
# Authentik
|
||||||
|
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||||
|
AUTHENTIK_CUSTOM_NAME=
|
||||||
|
AUTHENTIK_ISSUER=
|
||||||
|
AUTHENTIK_CLIENT_ID=
|
||||||
|
AUTHENTIK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Azure AD B2C
|
||||||
|
NEXT_PUBLIC_AZURE_AD_B2C_ENABLED=
|
||||||
|
AZURE_AD_B2C_TENANT_NAME=
|
||||||
|
AZURE_AD_B2C_CLIENT_ID=
|
||||||
|
AZURE_AD_B2C_CLIENT_SECRET=
|
||||||
|
AZURE_AD_B2C_PRIMARY_USER_FLOW=
|
||||||
|
|
||||||
|
# Azure AD
|
||||||
|
NEXT_PUBLIC_AZURE_AD_ENABLED=
|
||||||
|
AZURE_AD_CLIENT_ID=
|
||||||
|
AZURE_AD_CLIENT_SECRET=
|
||||||
|
AZURE_AD_TENANT_ID=
|
||||||
|
|
||||||
|
# Battle.net
|
||||||
|
NEXT_PUBLIC_BATTLENET_ENABLED=
|
||||||
|
BATTLENET_CUSTOM_NAME=
|
||||||
|
BATTLENET_CLIENT_ID=
|
||||||
|
BATTLENET_CLIENT_SECRET=
|
||||||
|
BATTLENET_ISSUER=
|
||||||
|
|
||||||
|
# Box
|
||||||
|
NEXT_PUBLIC_BOX_ENABLED=
|
||||||
|
BOX_CUSTOM_NAME=
|
||||||
|
BOX_CLIENT_ID=
|
||||||
|
BOX_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Bungie
|
||||||
|
NEXT_PUBLIC_BUNGIE_ENABLED=
|
||||||
|
BUNGIE_CUSTOM_NAME=
|
||||||
|
BUNGIE_CLIENT_ID=
|
||||||
|
BUNGIE_CLIENT_SECRET=
|
||||||
|
BUNGIE_API_KEY=
|
||||||
|
|
||||||
|
# Cognito
|
||||||
|
NEXT_PUBLIC_COGNITO_ENABLED=
|
||||||
|
COGNITO_CUSTOM_NAME=
|
||||||
|
COGNITO_CLIENT_ID=
|
||||||
|
COGNITO_CLIENT_SECRET=
|
||||||
|
COGNITO_ISSUER=
|
||||||
|
|
||||||
|
# Coinbase
|
||||||
|
NEXT_PUBLIC_COINBASE_ENABLED=
|
||||||
|
COINBASE_CUSTOM_NAME=
|
||||||
|
COINBASE_CLIENT_ID=
|
||||||
|
COINBASE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Discord
|
||||||
|
NEXT_PUBLIC_DISCORD_ENABLED=
|
||||||
|
DISCORD_CUSTOM_NAME=
|
||||||
|
DISCORD_CLIENT_ID=
|
||||||
|
DISCORD_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Dropbox
|
||||||
|
NEXT_PUBLIC_DROPBOX_ENABLED=
|
||||||
|
DROPBOX_CUSTOM_NAME=
|
||||||
|
DROPBOX_CLIENT_ID=
|
||||||
|
DROPBOX_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# DuendeIndentityServer6
|
||||||
|
NEXT_PUBLIC_DUENDE_IDS6_ENABLED=
|
||||||
|
DUENDE_IDS6_CUSTOM_NAME=
|
||||||
|
DUENDE_IDS6_CLIENT_ID=
|
||||||
|
DUENDE_IDS6_CLIENT_SECRET=
|
||||||
|
DUENDE_IDS6_ISSUER=
|
||||||
|
|
||||||
|
# EVE Online
|
||||||
|
NEXT_PUBLIC_EVEONLINE_ENABLED=
|
||||||
|
EVEONLINE_CUSTOM_NAME=
|
||||||
|
EVEONLINE_CLIENT_ID=
|
||||||
|
EVEONLINE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Facebook
|
||||||
|
NEXT_PUBLIC_FACEBOOK_ENABLED=
|
||||||
|
FACEBOOK_CUSTOM_NAME=
|
||||||
|
FACEBOOK_CLIENT_ID=
|
||||||
|
FACEBOOK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# FACEIT
|
||||||
|
NEXT_PUBLIC_FACEIT_ENABLED=
|
||||||
|
FACEIT_CUSTOM_NAME=
|
||||||
|
FACEIT_CLIENT_ID=
|
||||||
|
FACEIT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Foursquare
|
||||||
|
NEXT_PUBLIC_FOURSQUARE_ENABLED=
|
||||||
|
FOURSQUARE_CUSTOM_NAME=
|
||||||
|
FOURSQUARE_CLIENT_ID=
|
||||||
|
FOURSQUARE_CLIENT_SECRET=
|
||||||
|
FOURSQUARE_APIVERSION=
|
||||||
|
|
||||||
|
# Freshbooks
|
||||||
|
NEXT_PUBLIC_FRESHBOOKS_ENABLED=
|
||||||
|
FRESHBOOKS_CUSTOM_NAME=
|
||||||
|
FRESHBOOKS_CLIENT_ID=
|
||||||
|
FRESHBOOKS_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# FusionAuth
|
||||||
|
NEXT_PUBLIC_FUSIONAUTH_ENABLED=
|
||||||
|
FUSIONAUTH_CUSTOM_NAME=
|
||||||
|
FUSIONAUTH_CLIENT_ID=
|
||||||
|
FUSIONAUTH_CLIENT_SECRET=
|
||||||
|
FUSIONAUTH_ISSUER=
|
||||||
|
FUSIONAUTH_TENANT_ID=
|
||||||
|
|
||||||
|
# GitHub
|
||||||
|
NEXT_PUBLIC_GITHUB_ENABLED=
|
||||||
|
GITHUB_CUSTOM_NAME=
|
||||||
|
GITHUB_ID=
|
||||||
|
GITHUB_SECRET=
|
||||||
|
|
||||||
|
# GitLab
|
||||||
|
NEXT_PUBLIC_GITLAB_ENABLED=
|
||||||
|
GITLAB_CUSTOM_NAME=
|
||||||
|
GITLAB_CLIENT_ID=
|
||||||
|
GITLAB_CLIENT_SECRET=
|
||||||
|
GITLAB_AUTH_URL=
|
||||||
|
|
||||||
|
# Google
|
||||||
|
NEXT_PUBLIC_GOOGLE_ENABLED=
|
||||||
|
GOOGLE_CUSTOM_NAME=
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# HubSpot
|
||||||
|
NEXT_PUBLIC_HUBSPOT_ENABLED=
|
||||||
|
HUBSPOT_CUSTOM_NAME=
|
||||||
|
HUBSPOT_CLIENT_ID=
|
||||||
|
HUBSPOT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# IdentityServer4
|
||||||
|
NEXT_PUBLIC_IDS4_ENABLED=
|
||||||
|
IDS4_CUSTOM_NAME=
|
||||||
|
IDS4_CLIENT_ID=
|
||||||
|
IDS4_CLIENT_SECRET=
|
||||||
|
IDS4_ISSUER=
|
||||||
|
|
||||||
|
# Kakao
|
||||||
|
NEXT_PUBLIC_KAKAO_ENABLED=
|
||||||
|
KAKAO_CUSTOM_NAME=
|
||||||
|
KAKAO_CLIENT_ID=
|
||||||
|
KAKAO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Keycloak
|
||||||
|
NEXT_PUBLIC_KEYCLOAK_ENABLED=
|
||||||
|
KEYCLOAK_CUSTOM_NAME=
|
||||||
|
KEYCLOAK_ISSUER=
|
||||||
|
KEYCLOAK_CLIENT_ID=
|
||||||
|
KEYCLOAK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# LINE
|
||||||
|
NEXT_PUBLIC_LINE_ENABLED=
|
||||||
|
LINE_CUSTOM_NAME=
|
||||||
|
LINE_CLIENT_ID=
|
||||||
|
LINE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# LinkedIn
|
||||||
|
NEXT_PUBLIC_LINKEDIN_ENABLED=
|
||||||
|
LINKEDIN_CUSTOM_NAME=
|
||||||
|
LINKEDIN_CLIENT_ID=
|
||||||
|
LINKEDIN_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Mailchimp
|
||||||
|
NEXT_PUBLIC_MAILCHIMP_ENABLED=
|
||||||
|
MAILCHIMP_CUSTOM_NAME=
|
||||||
|
MAILCHIMP_CLIENT_ID=
|
||||||
|
MAILCHIMP_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Mail.ru
|
||||||
|
NEXT_PUBLIC_MAILRU_ENABLED=
|
||||||
|
MAILRU_CUSTOM_NAME=
|
||||||
|
MAILRU_CLIENT_ID=
|
||||||
|
MAILRU_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Naver
|
||||||
|
NEXT_PUBLIC_NAVER_ENABLED=
|
||||||
|
NAVER_CUSTOM_NAME=
|
||||||
|
NAVER_CLIENT_ID=
|
||||||
|
NAVER_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Netlify
|
||||||
|
NEXT_PUBLIC_NETLIFY_ENABLED=
|
||||||
|
NETLIFY_CUSTOM_NAME=
|
||||||
|
NETLIFY_CLIENT_ID=
|
||||||
|
NETLIFY_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Okta
|
||||||
|
NEXT_PUBLIC_OKTA_ENABLED=
|
||||||
|
OKTA_CUSTOM_NAME=
|
||||||
|
OKTA_CLIENT_ID=
|
||||||
|
OKTA_CLIENT_SECRET=
|
||||||
|
OKTA_ISSUER=
|
||||||
|
|
||||||
|
# OneLogin
|
||||||
|
NEXT_PUBLIC_ONELOGIN_ENABLED=
|
||||||
|
ONELOGIN_CUSTOM_NAME=
|
||||||
|
ONELOGIN_CLIENT_ID=
|
||||||
|
ONELOGIN_CLIENT_SECRET=
|
||||||
|
ONELOGIN_ISSUER=
|
||||||
|
|
||||||
|
# Osso
|
||||||
|
NEXT_PUBLIC_OSSO_ENABLED=
|
||||||
|
OSSO_CUSTOM_NAME=
|
||||||
|
OSSO_CLIENT_ID=
|
||||||
|
OSSO_CLIENT_SECRET=
|
||||||
|
OSSO_ISSUER=
|
||||||
|
|
||||||
|
# osu!
|
||||||
|
NEXT_PUBLIC_OSU_ENABLED=
|
||||||
|
OSU_CUSTOM_NAME=
|
||||||
|
OSU_CLIENT_ID=
|
||||||
|
OSU_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Patreon
|
||||||
|
NEXT_PUBLIC_PATREON_ENABLED=
|
||||||
|
PATREON_CUSTOM_NAME=
|
||||||
|
PATREON_CLIENT_ID=
|
||||||
|
PATREON_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Pinterest
|
||||||
|
NEXT_PUBLIC_PINTEREST_ENABLED=
|
||||||
|
PINTEREST_CUSTOM_NAME=
|
||||||
|
PINTEREST_CLIENT_ID=
|
||||||
|
PINTEREST_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Pipedrive
|
||||||
|
NEXT_PUBLIC_PIPEDRIVE_ENABLED=
|
||||||
|
PIPEDRIVE_CUSTOM_NAME=
|
||||||
|
PIPEDRIVE_CLIENT_ID=
|
||||||
|
PIPEDRIVE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Reddit
|
||||||
|
NEXT_PUBLIC_REDDIT_ENABLED=
|
||||||
|
REDDIT_CUSTOM_NAME=
|
||||||
|
REDDIT_CLIENT_ID=
|
||||||
|
REDDIT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Salesforce
|
||||||
|
NEXT_PUBLIC_SALESFORCE_ENABLED=
|
||||||
|
SALESFORCE_CUSTOM_NAME=
|
||||||
|
SALESFORCE_CLIENT_ID=
|
||||||
|
SALESFORCE_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Slack
|
||||||
|
NEXT_PUBLIC_SLACK_ENABLED=
|
||||||
|
SLACK_CUSTOM_NAME=
|
||||||
|
SLACK_CLIENT_ID=
|
||||||
|
SLACK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Spotify
|
||||||
|
NEXT_PUBLIC_SPOTIFY_ENABLED=
|
||||||
|
SPOTIFY_CUSTOM_NAME=
|
||||||
|
SPOTIFY_CLIENT_ID=
|
||||||
|
SPOTIFY_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Strava
|
||||||
|
NEXT_PUBLIC_STRAVA_ENABLED=
|
||||||
|
STRAVA_CUSTOM_NAME=
|
||||||
|
STRAVA_CLIENT_ID=
|
||||||
|
STRAVA_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Synology
|
||||||
|
NEXT_PUBLIC_SYNOLOGY_ENABLED=
|
||||||
|
SYNOLOGY_CUSTOM_NAME=
|
||||||
|
SYNOLOGY_CLIENT_ID=
|
||||||
|
SYNOLOGY_CLIENT_SECRET=
|
||||||
|
SYNOLOGY_WELLKNOWN_URL=
|
||||||
|
|
||||||
|
# Todoist
|
||||||
|
NEXT_PUBLIC_TODOIST_ENABLED=
|
||||||
|
TODOIST_CUSTOM_NAME=
|
||||||
|
TODOIST_CLIENT_ID=
|
||||||
|
TODOIST_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Twitch
|
||||||
|
NEXT_PUBLIC_TWITCH_ENABLED=
|
||||||
|
TWITCH_CUSTOM_NAME=
|
||||||
|
TWITCH_CLIENT_ID=
|
||||||
|
TWITCH_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# United Effects
|
||||||
|
NEXT_PUBLIC_UNITED_EFFECTS_ENABLED=
|
||||||
|
UNITED_EFFECTS_CUSTOM_NAME=
|
||||||
|
UNITED_EFFECTS_CLIENT_ID=
|
||||||
|
UNITED_EFFECTS_CLIENT_SECRET=
|
||||||
|
UNITED_EFFECTS_ISSUER=
|
||||||
|
|
||||||
|
# VK
|
||||||
|
NEXT_PUBLIC_VK_ENABLED=
|
||||||
|
VK_CUSTOM_NAME=
|
||||||
|
VK_CLIENT_ID=
|
||||||
|
VK_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Wikimedia
|
||||||
|
NEXT_PUBLIC_WIKIMEDIA_ENABLED=
|
||||||
|
WIKIMEDIA_CUSTOM_NAME=
|
||||||
|
WIKIMEDIA_CLIENT_ID=
|
||||||
|
WIKIMEDIA_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Wordpress.com
|
||||||
|
NEXT_PUBLIC_WORDPRESS_ENABLED=
|
||||||
|
WORDPRESS_CUSTOM_NAME=
|
||||||
|
WORDPRESS_CLIENT_ID=
|
||||||
|
WORDPRESS_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Yandex
|
||||||
|
NEXT_PUBLIC_YANDEX_ENABLED=
|
||||||
|
YANDEX_CUSTOM_NAME=
|
||||||
|
YANDEX_CLIENT_ID=
|
||||||
|
YANDEX_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Zitadel
|
||||||
|
NEXT_PUBLIC_ZITADEL_ENABLED=
|
||||||
|
ZITADEL_CUSTOM_NAME=
|
||||||
|
ZITADEL_CLIENT_ID=
|
||||||
|
ZITADEL_CLIENT_SECRET=
|
||||||
|
ZITADEL_ISSUER=
|
||||||
|
|
||||||
|
# Zoho
|
||||||
|
NEXT_PUBLIC_ZOHO_ENABLED=
|
||||||
|
ZOHO_CUSTOM_NAME=
|
||||||
|
ZOHO_CLIENT_ID=
|
||||||
|
ZOHO_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Zoom
|
||||||
|
NEXT_PUBLIC_ZOOM_ENABLED=
|
||||||
|
ZOOM_CUSTOM_NAME=
|
||||||
|
ZOOM_CLIENT_ID=
|
||||||
|
ZOOM_CLIENT_SECRET=
|
||||||
3
linkwarden/.gitignore
vendored
Normal file
3
linkwarden/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
data/
|
||||||
|
meili_data/
|
||||||
|
pgdata/
|
||||||
28
linkwarden/docker-compose.yml
Normal file
28
linkwarden/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env_file: .env
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/linkwarden/pgdata:/var/lib/postgresql/data
|
||||||
|
linkwarden:
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
|
||||||
|
restart: always
|
||||||
|
# build: . # uncomment to build from source
|
||||||
|
image: ghcr.io/linkwarden/linkwarden:latest # comment to build from source
|
||||||
|
ports:
|
||||||
|
- 3012:3000
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/linkwarden/data:/data/data
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- meilisearch
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.12.8
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/linkwarden/meili_data:/meili_data
|
||||||
@@ -26,6 +26,7 @@ Connect clients to: `https://mtx.alogins.net`
|
|||||||
| admin | yes |
|
| admin | yes |
|
||||||
| elizaveta | no |
|
| elizaveta | no |
|
||||||
| aleksandra | no |
|
| aleksandra | no |
|
||||||
|
| juris | no |
|
||||||
|
|
||||||
## Managing Users
|
## Managing Users
|
||||||
|
|
||||||
|
|||||||
18
ollama/docker-compose.yml
Normal file
18
ollama/docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
ollama:
|
||||||
|
image: ollama/ollama
|
||||||
|
container_name: ollama
|
||||||
|
ports:
|
||||||
|
- "11436:11434"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/ai/ollama:/root/.ollama
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
# Allow qwen3:8b + qwen2.5:1.5b to coexist in VRAM (~6.7-7.7 GB on 8 GB GPU)
|
||||||
|
- OLLAMA_MAX_LOADED_MODELS=2
|
||||||
|
# One GPU inference at a time — prevents compute contention between models
|
||||||
|
- OLLAMA_NUM_PARALLEL=1
|
||||||
|
# Force all layers to GPU — fail instead of falling back to CPU
|
||||||
|
- OLLAMA_NUM_GPU=999
|
||||||
|
runtime: nvidia
|
||||||
|
mem_limit: 4g
|
||||||
12
omo/Dockerfile
Normal file
12
omo/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
# Install oh-my-opencode (ships its own opencode binary with omo extensions)
|
||||||
|
RUN npm install -g oh-my-opencode
|
||||||
|
|
||||||
|
# Run non-interactive setup — all cloud providers disabled, bifrost configured via opencode.json
|
||||||
|
RUN oh-my-opencode install --no-tui --claude=no --openai=no --gemini=no --copilot=no
|
||||||
|
|
||||||
|
# Config is mounted at runtime via volume
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
ENTRYPOINT ["oh-my-opencode"]
|
||||||
17
omo/docker-compose.yml
Normal file
17
omo/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
omo:
|
||||||
|
build: .
|
||||||
|
container_name: omo
|
||||||
|
volumes:
|
||||||
|
- /home/alvis:/workspace
|
||||||
|
- ./opencode.json:/root/.config/opencode/opencode.json:ro
|
||||||
|
entrypoint: ["sleep", "infinity"]
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
networks:
|
||||||
|
- adolf_default
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
adolf_default:
|
||||||
|
external: true
|
||||||
28
omo/opencode.json
Normal file
28
omo/opencode.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"provider": {
|
||||||
|
"bifrost": {
|
||||||
|
"npm": "@ai-sdk/openai-compatible",
|
||||||
|
"name": "Bifrost",
|
||||||
|
"options": {
|
||||||
|
"baseURL": "http://bifrost:8080/v1",
|
||||||
|
"apiKey": "dummy"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"ollama/qwen3:8b": {
|
||||||
|
"name": "qwen3:8b"
|
||||||
|
},
|
||||||
|
"ollama/qwen3:4b": {
|
||||||
|
"name": "qwen3:4b"
|
||||||
|
},
|
||||||
|
"ollama/qwen2.5:1.5b": {
|
||||||
|
"name": "qwen2.5:1.5b"
|
||||||
|
},
|
||||||
|
"ollama/gemma3:4b": {
|
||||||
|
"name": "gemma3:4b"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model": "bifrost/ollama/qwen3:8b"
|
||||||
|
}
|
||||||
11
openai/adolf-llm/Dockerfile
Normal file
11
openai/adolf-llm/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
RUN npm install -g @moonshot-ai/kimi-code
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
COPY server.js /app/server.js
|
||||||
|
|
||||||
|
EXPOSE 8010
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "/app/server.js"]
|
||||||
762
openai/adolf-llm/server.js
Normal file
762
openai/adolf-llm/server.js
Normal file
@@ -0,0 +1,762 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const PORT = 8010;
|
||||||
|
const MODEL_ID = 'adolf';
|
||||||
|
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
const WORKSPACE = '/workspace';
|
||||||
|
const CONV_ROOT = path.join(WORKSPACE, 'conversations');
|
||||||
|
const STATE_DIR = path.join(WORKSPACE, '.adolf-llm');
|
||||||
|
const MAP_FILE = path.join(STATE_DIR, 'sessions.json');
|
||||||
|
const MAX_ENTRIES = 1000; // prune oldest beyond this
|
||||||
|
|
||||||
|
fs.mkdirSync(CONV_ROOT, { recursive: true });
|
||||||
|
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared MCP layer (Gate 1). Kimi Code CLI has NO `--mcp-config-file` flag and
|
||||||
|
// no `kimi mcp` subcommand; it auto-discovers a project-root `.mcp.json` by
|
||||||
|
// walking up from its cwd to the nearest `.git` (falling back to cwd itself
|
||||||
|
// when none is found). So we drop a `.mcp.json` into each session's working
|
||||||
|
// directory before spawning kimi.
|
||||||
|
//
|
||||||
|
// Single source of truth: `/shared-mcp.json` (mounted read-only from the repo
|
||||||
|
// root's `shared-mcp.json`, the same file P6 wires into OpenClaw's own
|
||||||
|
// `mcp.servers` registry). Adding a server is then a one-file change — no
|
||||||
|
// server list is hardcoded here anymore.
|
||||||
|
//
|
||||||
|
// Gate-1 transport finding (P5, verified by decompiling the installed
|
||||||
|
// @moonshot-ai/kimi-code package, packages/agent-core/src/config/schema.ts's
|
||||||
|
// McpServerConfigSchema): Kimi's own field name for remote MCP servers is
|
||||||
|
// `transport` (literal "stdio" | "http" | "sse"), not `type`. When `transport`
|
||||||
|
// is omitted, Kimi's config preprocessor infers it from shape: `command` ->
|
||||||
|
// "stdio", `url` -> "http" (never "sse" — sse requires an explicit
|
||||||
|
// `transport: "sse"`). It does NOT recognize a `type` key at all; unknown keys
|
||||||
|
// are silently stripped by the (non-strict) zod schema.
|
||||||
|
// OpenClaw's own canonical `mcp.servers` schema (docs/gateway/
|
||||||
|
// configuration-reference.md) uses different literals for the same
|
||||||
|
// transport: `transport: "streamable-http"` or `"sse"`, with `type: "http"`
|
||||||
|
// documented as a *CLI-native alias* that `openclaw mcp set` / `openclaw
|
||||||
|
// doctor --fix` normalize into canonical `transport: "streamable-http"`.
|
||||||
|
// So the two consumers disagree on the literal value for HTTP streaming
|
||||||
|
// ("http" vs "streamable-http") under the same field name `transport` --
|
||||||
|
// writing `transport` explicitly in shared-mcp.json would satisfy at most one
|
||||||
|
// side. `type: "http"` is the one shape both sides tolerate today: Kimi
|
||||||
|
// ignores the unrecognized `type` key and correctly infers transport "http"
|
||||||
|
// from the `url` field alone; OpenClaw recognizes `type` as its documented
|
||||||
|
// alias and normalizes it on its own terms (P6 concern, not touched here).
|
||||||
|
// Hence shared-mcp.json intentionally keeps `"type": "http"` for both cognee
|
||||||
|
// and openclaw-tools rather than switching to `transport`.
|
||||||
|
let SHARED_MCP_SERVERS = {};
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync('/shared-mcp.json', 'utf8');
|
||||||
|
SHARED_MCP_SERVERS = JSON.parse(raw).mcpServers || {};
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`shared-mcp.json not loaded (${err.message}); sessions will get no shared MCP servers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMcpConfig(dir) {
|
||||||
|
const cfg = { mcpServers: SHARED_MCP_SERVERS };
|
||||||
|
fs.writeFileSync(path.join(dir, '.mcp.json'), JSON.stringify(cfg, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Memory lives at the OpenClaw layer, not here (P8). The Adolf gateway loads
|
||||||
|
// the `cognee-memory` OpenClaw plugin, which owns all memory touchpoints:
|
||||||
|
// - before_prompt_build => LLM-free cognee graph recall, injected into the
|
||||||
|
// prompt this wrapper then receives from OpenClaw.
|
||||||
|
// - agent_end => raw `add` of the turn to cognee.
|
||||||
|
// - background sweep => async `cognify` on cognee-llm (Kimi).
|
||||||
|
// - cognee_recall tool + cognee-mcp `recall` for on-demand deep queries.
|
||||||
|
// This wrapper is therefore a dumb model endpoint again: it must never call
|
||||||
|
// cognee itself. The former cogneeSearch/cogneeAdd stubs (and their call sites)
|
||||||
|
// were deleted when the plugin took over (P8).
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Persistent conversation -> Kimi session map.
|
||||||
|
// Primary key: `chat:<chat_id>` parsed from OpenClaw's "Conversation info" block
|
||||||
|
// (Gate 2). Fallback key: `hist:<sha256(prior history)>` when no chat_id is
|
||||||
|
// present (e.g. webchat surface / future OpenClaw layout change).
|
||||||
|
// value = { convId, sessionId, dir, ts }
|
||||||
|
let sessionMap = {};
|
||||||
|
try {
|
||||||
|
sessionMap = JSON.parse(fs.readFileSync(MAP_FILE, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
sessionMap = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let writeQueue = Promise.resolve();
|
||||||
|
function persistMap() {
|
||||||
|
// prune to the MAX_ENTRIES most-recently-used before writing
|
||||||
|
const keys = Object.keys(sessionMap);
|
||||||
|
if (keys.length > MAX_ENTRIES) {
|
||||||
|
keys.sort((a, b) => (sessionMap[a].ts || 0) - (sessionMap[b].ts || 0));
|
||||||
|
for (const k of keys.slice(0, keys.length - MAX_ENTRIES)) delete sessionMap[k];
|
||||||
|
}
|
||||||
|
const snapshot = JSON.stringify(sessionMap);
|
||||||
|
writeQueue = writeQueue.then(
|
||||||
|
() => fs.promises.writeFile(MAP_FILE, snapshot),
|
||||||
|
() => fs.promises.writeFile(MAP_FILE, snapshot),
|
||||||
|
);
|
||||||
|
return writeQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Message helpers.
|
||||||
|
function textOf(msg) {
|
||||||
|
const c = msg.content;
|
||||||
|
if (Array.isArray(c)) return c.map(p => (typeof p === 'string' ? p : p.text || '')).join('\n');
|
||||||
|
return c == null ? '' : String(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// only user/assistant turns define conversation identity (system is constant)
|
||||||
|
function convTurns(messages) {
|
||||||
|
return messages.filter(m => m.role === 'user' || m.role === 'assistant');
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyKey(turns) {
|
||||||
|
const norm = turns.map(m => ({ role: m.role, text: textOf(m).trim() }));
|
||||||
|
return crypto.createHash('sha256').update(JSON.stringify(norm)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTranscript(turns) {
|
||||||
|
return turns
|
||||||
|
.map(m => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${textOf(m)}`)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gate 2 — parse the stable chat_id out of OpenClaw's untrusted-metadata block.
|
||||||
|
// OpenClaw injects, into the *user-role* content, a block that looks like:
|
||||||
|
// Conversation info (untrusted metadata):
|
||||||
|
// ```json
|
||||||
|
// { "chat_id": "matrix:!room:server", "message_id": "...", ... }
|
||||||
|
// ```
|
||||||
|
// We grep on the label string (never a fixed line offset) then pull the first
|
||||||
|
// balanced JSON object after it and read chat_id. Robust to edited/truncated
|
||||||
|
// history, which is exactly why it beats a history hash for the common case.
|
||||||
|
const CONV_INFO_LABEL = 'Conversation info (untrusted metadata):';
|
||||||
|
|
||||||
|
function extractBalancedJson(str, from) {
|
||||||
|
const start = str.indexOf('{', from);
|
||||||
|
if (start === -1) return null;
|
||||||
|
let depth = 0;
|
||||||
|
let inStr = false;
|
||||||
|
let esc = false;
|
||||||
|
for (let i = start; i < str.length; i++) {
|
||||||
|
const ch = str[i];
|
||||||
|
if (inStr) {
|
||||||
|
if (esc) esc = false;
|
||||||
|
else if (ch === '\\') esc = true;
|
||||||
|
else if (ch === '"') inStr = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') inStr = true;
|
||||||
|
else if (ch === '{') depth++;
|
||||||
|
else if (ch === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) return str.slice(start, i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractChatId(userMsg) {
|
||||||
|
const text = textOf(userMsg);
|
||||||
|
const at = text.indexOf(CONV_INFO_LABEL);
|
||||||
|
if (at === -1) return null;
|
||||||
|
const jsonStr = extractBalancedJson(text, at + CONV_INFO_LABEL.length);
|
||||||
|
if (!jsonStr) return null;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(jsonStr);
|
||||||
|
const id = obj.chat_id;
|
||||||
|
return typeof id === 'string' && id ? id : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gate 3 — media. Persist inbound image parts into the session dir and return
|
||||||
|
// relative path references; the CLI autonomously calls its built-in
|
||||||
|
// ReadMediaFile tool on referenced paths (no flag/placeholder syntax needed).
|
||||||
|
const MIME_EXT = {
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/jpg': 'jpg',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'image/bmp': 'bmp',
|
||||||
|
'image/heic': 'heic',
|
||||||
|
'image/heif': 'heif',
|
||||||
|
};
|
||||||
|
|
||||||
|
function extFromMime(mime) {
|
||||||
|
return MIME_EXT[(mime || '').toLowerCase()] || 'img';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist one image_url part; returns "./img_N.ext" or null if it couldn't.
|
||||||
|
async function persistImage(url, dir, n) {
|
||||||
|
if (typeof url !== 'string' || !url) return null;
|
||||||
|
if (url.startsWith('data:')) {
|
||||||
|
const m = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(url);
|
||||||
|
if (!m) return null;
|
||||||
|
const mime = m[1] || 'application/octet-stream';
|
||||||
|
const isB64 = !!m[2];
|
||||||
|
const ext = extFromMime(mime);
|
||||||
|
const name = `img_${n}.${ext}`;
|
||||||
|
const buf = isB64
|
||||||
|
? Buffer.from(m[3], 'base64')
|
||||||
|
: Buffer.from(decodeURIComponent(m[3]), 'utf8');
|
||||||
|
fs.writeFileSync(path.join(dir, name), buf);
|
||||||
|
return `./${name}`;
|
||||||
|
}
|
||||||
|
// Remote URL: best-effort fetch so the CLI gets a local path to ReadMediaFile.
|
||||||
|
// On any failure fall back to handing the raw URL to the model as text.
|
||||||
|
if (/^https?:\/\//i.test(url)) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) return url;
|
||||||
|
const mime = resp.headers.get('content-type') || '';
|
||||||
|
const ext = extFromMime(mime.split(';')[0].trim());
|
||||||
|
const name = `img_${n}.${ext}`;
|
||||||
|
const buf = Buffer.from(await resp.arrayBuffer());
|
||||||
|
fs.writeFileSync(path.join(dir, name), buf);
|
||||||
|
return `./${name}`;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the prompt for the current user turn: join text parts, persist any
|
||||||
|
// image parts, append path references.
|
||||||
|
async function buildPrompt(userMsg, dir) {
|
||||||
|
const content = userMsg.content;
|
||||||
|
const textParts = [];
|
||||||
|
const imageRefs = [];
|
||||||
|
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
let n = 0;
|
||||||
|
for (const part of content) {
|
||||||
|
if (typeof part === 'string') {
|
||||||
|
textParts.push(part);
|
||||||
|
} else if (part && (part.type === 'text' || typeof part.text === 'string')) {
|
||||||
|
textParts.push(part.text || '');
|
||||||
|
} else if (part && part.type === 'image_url' && part.image_url && part.image_url.url) {
|
||||||
|
n++;
|
||||||
|
const ref = await persistImage(part.image_url.url, dir, n);
|
||||||
|
if (ref) imageRefs.push(ref);
|
||||||
|
} else if (part && part.type === 'input_image' && (part.image_url || part.url)) {
|
||||||
|
n++;
|
||||||
|
const u = typeof part.image_url === 'string' ? part.image_url : part.url;
|
||||||
|
const ref = await persistImage(u, dir, n);
|
||||||
|
if (ref) imageRefs.push(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
textParts.push(content == null ? '' : String(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = textParts.join('\n');
|
||||||
|
if (imageRefs.length) {
|
||||||
|
prompt += '\n\n' + imageRefs.map(r => `See attached image: ${r}`).join('\n');
|
||||||
|
}
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Kimi invocation with REAL streaming. Parses `--output-format stream-json`
|
||||||
|
// incrementally: each complete stdout line is one JSON object.
|
||||||
|
// {"role":"assistant","content":"..."} -> emit as a delta
|
||||||
|
// {"type":"session.resume_hint","session_id":"..."} -> capture session id
|
||||||
|
// onDelta(chunk) is called per assistant content fragment as it arrives.
|
||||||
|
// Resolves { text, sessionId } once the process closes.
|
||||||
|
function runKimi({ prompt, cwd, resumeId, onDelta, signal }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (signal?.aborted) { reject(new Error('aborted before start')); return; }
|
||||||
|
const args = [];
|
||||||
|
if (resumeId) args.push('-r', resumeId);
|
||||||
|
args.push('-p', prompt, '--output-format', 'stream-json');
|
||||||
|
|
||||||
|
const child = spawn('kimi', args, { cwd, timeout: TIMEOUT_MS });
|
||||||
|
|
||||||
|
let buf = '';
|
||||||
|
let stderr = '';
|
||||||
|
const parts = [];
|
||||||
|
let sessionId = null;
|
||||||
|
let settled = false;
|
||||||
|
let aborted = false;
|
||||||
|
|
||||||
|
// If the caller aborts (the gateway/client disconnected — e.g. its idle
|
||||||
|
// watchdog gave up), kill the child so it doesn't keep grinding an
|
||||||
|
// orphaned agent turn to completion, wasting Kimi quota and streaming into
|
||||||
|
// a dead socket. SIGTERM first, hard SIGKILL if it lingers.
|
||||||
|
const onAbort = () => {
|
||||||
|
aborted = true;
|
||||||
|
try { child.kill('SIGTERM'); } catch {}
|
||||||
|
setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 3000).unref();
|
||||||
|
};
|
||||||
|
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
|
||||||
|
function handleLine(line) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) return;
|
||||||
|
let obj;
|
||||||
|
try { obj = JSON.parse(t); } catch { return; }
|
||||||
|
if (obj.role === 'assistant' && typeof obj.content === 'string' && obj.content) {
|
||||||
|
parts.push(obj.content);
|
||||||
|
if (onDelta) onDelta(obj.content);
|
||||||
|
}
|
||||||
|
if (obj.type === 'session.resume_hint' && obj.session_id) sessionId = obj.session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.on('data', d => {
|
||||||
|
buf += d;
|
||||||
|
let nl;
|
||||||
|
while ((nl = buf.indexOf('\n')) !== -1) {
|
||||||
|
const line = buf.slice(0, nl);
|
||||||
|
buf = buf.slice(nl + 1);
|
||||||
|
handleLine(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr.on('data', d => { stderr += d; });
|
||||||
|
|
||||||
|
child.on('error', err => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (signal) signal.removeEventListener('abort', onAbort);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
child.on('close', code => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (signal) signal.removeEventListener('abort', onAbort);
|
||||||
|
if (buf) handleLine(buf); // flush any trailing partial line
|
||||||
|
const text = parts.join('').trim();
|
||||||
|
if (aborted) {
|
||||||
|
reject(new Error('aborted: client disconnected'));
|
||||||
|
} else if (!text && code !== 0) {
|
||||||
|
reject(new Error(`kimi exited ${code}: ${stderr.slice(0, 2000)}`));
|
||||||
|
} else {
|
||||||
|
resolve({ text, sessionId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// One turn: resolve session (chat_id primary, history-hash fallback), persist
|
||||||
|
// media + .mcp.json, run kimi (streaming through onDelta), record the mapping,
|
||||||
|
// and fire the async cognee ingest. Returns { text }.
|
||||||
|
async function handleTurn(messages, onDelta, signal) {
|
||||||
|
const turns = convTurns(messages);
|
||||||
|
let lastUserIdx = -1;
|
||||||
|
for (let i = turns.length - 1; i >= 0; i--) {
|
||||||
|
if (turns[i].role === 'user') { lastUserIdx = i; break; }
|
||||||
|
}
|
||||||
|
if (lastUserIdx === -1) throw new Error('no user message found');
|
||||||
|
|
||||||
|
const userMsg = turns[lastUserIdx];
|
||||||
|
const prior = turns.slice(0, lastUserIdx);
|
||||||
|
const chatId = extractChatId(userMsg);
|
||||||
|
|
||||||
|
// Resolve the session key + working dir + resume id.
|
||||||
|
let key;
|
||||||
|
let convId;
|
||||||
|
let dir;
|
||||||
|
let resumeId = null;
|
||||||
|
let reseed = false; // when true, prompt with the full transcript to rebuild continuity
|
||||||
|
|
||||||
|
if (chatId) {
|
||||||
|
key = `chat:${chatId}`;
|
||||||
|
const entry = sessionMap[key];
|
||||||
|
if (entry) {
|
||||||
|
convId = entry.convId;
|
||||||
|
dir = entry.dir;
|
||||||
|
resumeId = entry.sessionId;
|
||||||
|
} else {
|
||||||
|
convId = crypto.randomUUID();
|
||||||
|
dir = path.join(CONV_ROOT, convId);
|
||||||
|
// First time we see this chat_id but history exists (server restart / lost
|
||||||
|
// map): reseed the fresh session with the transcript so context survives.
|
||||||
|
reseed = prior.length > 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: no chat_id -> forward history-hash mapping (kimi-agent style).
|
||||||
|
if (prior.length === 0) {
|
||||||
|
convId = crypto.randomUUID();
|
||||||
|
dir = path.join(CONV_ROOT, convId);
|
||||||
|
} else {
|
||||||
|
const entry = sessionMap[`hist:${historyKey(prior)}`];
|
||||||
|
if (entry) {
|
||||||
|
convId = entry.convId;
|
||||||
|
dir = entry.dir;
|
||||||
|
resumeId = entry.sessionId;
|
||||||
|
} else {
|
||||||
|
convId = crypto.randomUUID();
|
||||||
|
dir = path.join(CONV_ROOT, convId);
|
||||||
|
reseed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
writeMcpConfig(dir); // Gate 1: shared MCP via project-root .mcp.json
|
||||||
|
|
||||||
|
let prompt;
|
||||||
|
if (reseed) {
|
||||||
|
// Rebuild the whole conversation for a fresh session, plus current media.
|
||||||
|
const base = renderTranscript(turns.slice(0, lastUserIdx + 1));
|
||||||
|
const media = await buildPrompt(userMsg, dir);
|
||||||
|
// buildPrompt already includes the current user text; for reseed we want the
|
||||||
|
// transcript to carry it, so only append image refs.
|
||||||
|
prompt = base;
|
||||||
|
const extra = media.replace(textOf(userMsg), '').trim();
|
||||||
|
if (extra) prompt += `\n\n${extra}`;
|
||||||
|
} else {
|
||||||
|
prompt = await buildPrompt(userMsg, dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text, sessionId } = await runKimi({ prompt, cwd: dir, resumeId, onDelta, signal });
|
||||||
|
|
||||||
|
// Record the forward mapping.
|
||||||
|
const entry = { convId, sessionId: sessionId || resumeId, dir, ts: Date.now() };
|
||||||
|
if (chatId) {
|
||||||
|
sessionMap[`chat:${chatId}`] = entry;
|
||||||
|
} else {
|
||||||
|
const forward = turns.slice(0, lastUserIdx + 1).concat([{ role: 'assistant', content: text }]);
|
||||||
|
sessionMap[`hist:${historyKey(forward)}`] = entry;
|
||||||
|
}
|
||||||
|
persistMap();
|
||||||
|
|
||||||
|
return { text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Kimi quota readout (kb #62). GET /usage — the claude-usage analog for
|
||||||
|
// Adolf. LLM-free: hits Kimi's own managed-usage endpoint directly, never
|
||||||
|
// spawns `kimi`. Mirrors the parsing logic of the installed
|
||||||
|
// @moonshot-ai/kimi-code CLI itself (decompiled from dist/main.mjs's
|
||||||
|
// parseManagedUsagePayload/toUsageRow/limitLabel/resetHintFrom — same
|
||||||
|
// endpoint, same response shape) so bucket labels/derivations stay in sync
|
||||||
|
// with what `kimi` would show via its own /usage-equivalent.
|
||||||
|
//
|
||||||
|
// Token source: the CLI's own OAuth creds file, kept fresh by the running
|
||||||
|
// `kimi` process (adolf-llm-home volume). We only ever READ that file. If
|
||||||
|
// its access_token is stale/expired we refresh in memory (POST
|
||||||
|
// https://auth.kimi.com/api/oauth/token, form-encoded, grant_type=
|
||||||
|
// refresh_token — endpoint + client_id taken from the same decompiled
|
||||||
|
// KIMI_CODE_FLOW_CONFIG/refreshAccessToken) and cache the result in a
|
||||||
|
// module-level variable ONLY — we deliberately never write the refreshed
|
||||||
|
// token back to the creds file, since the live CLI owns that file and a
|
||||||
|
// racing write from here could corrupt/rotate state it depends on.
|
||||||
|
const KIMI_CREDS_PATH = '/root/.kimi-code/credentials/kimi-code.json';
|
||||||
|
const KIMI_OAUTH_HOST = 'https://auth.kimi.com';
|
||||||
|
const KIMI_CLIENT_ID = '17e5f671-d194-4dfb-9706-5516cb48c098';
|
||||||
|
const KIMI_USAGES_URL = 'https://api.kimi.com/coding/v1/usages';
|
||||||
|
|
||||||
|
let kimiMemToken = null; // { access_token, expires_at } — in-memory only, never persisted
|
||||||
|
|
||||||
|
async function loadKimiCreds() {
|
||||||
|
const raw = await fs.promises.readFile(KIMI_CREDS_PATH, 'utf8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshKimiToken(refreshToken) {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: KIMI_CLIENT_ID,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
}).toString();
|
||||||
|
const res = await fetch(`${KIMI_OAUTH_HOST}/api/oauth/token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || typeof data.access_token !== 'string') {
|
||||||
|
throw new Error(`kimi oauth refresh failed (HTTP ${res.status}): ${data.error || data.error_description || 'unknown error'}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
access_token: data.access_token,
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + Number(data.expires_in || 900),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a usable access token, preferring the creds-file token (kept fresh
|
||||||
|
// by the live CLI) and falling back to an in-memory refresh only when that
|
||||||
|
// one is stale/expired.
|
||||||
|
async function getKimiAccessToken(forceRefresh) {
|
||||||
|
const creds = await loadKimiCreds();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (!forceRefresh && creds.access_token && creds.expires_at && now < creds.expires_at - 30) {
|
||||||
|
return creds.access_token;
|
||||||
|
}
|
||||||
|
if (!forceRefresh && kimiMemToken && now < kimiMemToken.expires_at - 30) {
|
||||||
|
return kimiMemToken.access_token;
|
||||||
|
}
|
||||||
|
if (!creds.refresh_token) throw new Error('no refresh_token in kimi credentials file');
|
||||||
|
kimiMemToken = await refreshKimiToken(creds.refresh_token);
|
||||||
|
return kimiMemToken.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchKimiUsagesRaw() {
|
||||||
|
let token = await getKimiAccessToken(false);
|
||||||
|
let res = await fetch(KIMI_USAGES_URL, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
|
||||||
|
if (res.status === 401) {
|
||||||
|
token = await getKimiAccessToken(true); // force one in-memory refresh + retry
|
||||||
|
res = await fetch(KIMI_USAGES_URL, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`kimi /usages HTTP ${res.status}: ${text.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); }
|
||||||
|
|
||||||
|
function toInt(v) {
|
||||||
|
if (typeof v === 'number') return Number.isFinite(v) ? Math.trunc(v) : null;
|
||||||
|
if (typeof v === 'string') { const n = Number(v); return Number.isFinite(n) ? Math.trunc(n) : null; }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port of the CLI's limitLabel(): prefer an explicit name/title/scope field,
|
||||||
|
// else derive "<N>h limit" / "<N>m limit" / "<N>d limit" from the window's
|
||||||
|
// duration+timeUnit.
|
||||||
|
function kimiLimitLabel(item, detail, window, idx) {
|
||||||
|
for (const key of ['name', 'title', 'scope']) {
|
||||||
|
const v = item[key] ?? detail[key];
|
||||||
|
if (typeof v === 'string' && v) return v;
|
||||||
|
}
|
||||||
|
const duration = toInt(window.duration ?? item.duration ?? detail.duration);
|
||||||
|
const rawUnit = window.timeUnit ?? item.timeUnit ?? detail.timeUnit;
|
||||||
|
const timeUnit = typeof rawUnit === 'string' ? rawUnit : '';
|
||||||
|
if (duration !== null) {
|
||||||
|
if (timeUnit.includes('MINUTE')) {
|
||||||
|
if (duration >= 60 && duration % 60 === 0) return `${duration / 60}h limit`;
|
||||||
|
return `${duration}m limit`;
|
||||||
|
}
|
||||||
|
if (timeUnit.includes('HOUR')) return `${duration}h limit`;
|
||||||
|
if (timeUnit.includes('DAY')) return `${duration}d limit`;
|
||||||
|
return `${duration}s limit`;
|
||||||
|
}
|
||||||
|
return `Limit #${idx + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kimiResetIso(raw) {
|
||||||
|
for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime']) {
|
||||||
|
const v = raw[key];
|
||||||
|
if (typeof v === 'string' && v) return v;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port of the CLI's toUsageRow(): used = raw.used, or limit-remaining when
|
||||||
|
// used is absent.
|
||||||
|
function kimiUsageRow(raw, defaultLabel) {
|
||||||
|
if (!isRecord(raw)) return null;
|
||||||
|
const limit = toInt(raw.limit);
|
||||||
|
let used = toInt(raw.used);
|
||||||
|
const remaining = toInt(raw.remaining);
|
||||||
|
if (used === null && remaining !== null && limit !== null) used = limit - remaining;
|
||||||
|
if (used === null && limit === null) return null;
|
||||||
|
const name = typeof raw.name === 'string' ? raw.name : (typeof raw.title === 'string' ? raw.title : defaultLabel);
|
||||||
|
return {
|
||||||
|
label: name,
|
||||||
|
used: used ?? 0,
|
||||||
|
limit: limit ?? 0,
|
||||||
|
remaining: remaining !== null ? remaining : (limit !== null && used !== null ? limit - used : null),
|
||||||
|
resets: kimiResetIso(raw),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function kimiRowOut(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
const pct = row.limit > 0 ? Math.round((row.used / row.limit) * 100) : null;
|
||||||
|
return { pct, used: row.used, limit: row.limit, remaining: row.remaining, resets: row.resets };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize Kimi's /usages payload ({ usage, limits: [...] }) into the
|
||||||
|
// claude-usage-analog shape: weekly / window_5h / window_7d, each
|
||||||
|
// pct/used/limit/remaining/resets, plus a raw `limits` passthrough so no
|
||||||
|
// bucket is lost if label text ever drifts from what we match on below.
|
||||||
|
function normalizeKimiUsage(payload) {
|
||||||
|
const rec = isRecord(payload) ? payload : {};
|
||||||
|
const summaryRow = kimiUsageRow(rec.usage, 'Weekly limit');
|
||||||
|
const limitRows = [];
|
||||||
|
const rawLimits = Array.isArray(rec.limits) ? rec.limits : [];
|
||||||
|
rawLimits.forEach((item, idx) => {
|
||||||
|
if (!isRecord(item)) return;
|
||||||
|
const detail = isRecord(item.detail) ? item.detail : item;
|
||||||
|
const window = isRecord(item.window) ? item.window : {};
|
||||||
|
const label = kimiLimitLabel(item, detail, window, idx);
|
||||||
|
const row = kimiUsageRow(detail, label);
|
||||||
|
if (row) limitRows.push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const findByLabel = re => limitRows.find(r => re.test(r.label));
|
||||||
|
const weekly = summaryRow || findByLabel(/week/i) || null;
|
||||||
|
const window5h = findByLabel(/^5\s*h(our)?\b|5h limit/i) || null;
|
||||||
|
const window7d = findByLabel(/^7\s*d(ay)?\b|7d limit/i) || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
weekly: kimiRowOut(weekly),
|
||||||
|
window_5h: kimiRowOut(window5h),
|
||||||
|
window_7d: kimiRowOut(window7d),
|
||||||
|
limits: limitRows.map(r => ({ label: r.label, ...kimiRowOut(r) })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OpenAI-compatible HTTP surface.
|
||||||
|
function completionBody(text) {
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-${Date.now()}`,
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: MODEL_ID,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: text },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sseChunk(id, created, delta, finishReason) {
|
||||||
|
return `data: ${JSON.stringify({
|
||||||
|
id, object: 'chat.completion.chunk', created, model: MODEL_ID,
|
||||||
|
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||||
|
})}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
object: 'list',
|
||||||
|
data: [{ id: MODEL_ID, object: 'model', owned_by: 'moonshot' }],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && req.url === '/usage') {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const raw = await fetchKimiUsagesRaw();
|
||||||
|
const out = normalizeKimiUsage(raw);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(out));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: String(err.message || err) }));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/v1/chat/completions') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', d => { body += d; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'invalid JSON body' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = parsed.messages || [];
|
||||||
|
|
||||||
|
if (parsed.stream) {
|
||||||
|
// Real streaming: open SSE, emit role chunk, then forward kimi deltas.
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
|
const id = `chatcmpl-${Date.now()}`;
|
||||||
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Heartbeat keepalive: OpenClaw's LLM idle watchdog aborts a turn on
|
||||||
|
// any >120s gap between SSE stream events (default timeoutSeconds),
|
||||||
|
// not on total run length. During long thinking/tool/MCP phases Kimi
|
||||||
|
// emits stream-json events we don't forward, so the SSE stream can go
|
||||||
|
// silent well past that window. Every write resets `lastWrite`; a 5s
|
||||||
|
// ticker emits an empty-content delta once 25s of silence elapses —
|
||||||
|
// still a stream event (resets the watchdog) but appends nothing
|
||||||
|
// visible to the rendered reply or cognee-persisted text.
|
||||||
|
let lastWrite = Date.now();
|
||||||
|
const write = (delta, finish) => {
|
||||||
|
if (res.writableEnded || res.destroyed) return;
|
||||||
|
res.write(sseChunk(id, created, delta, finish));
|
||||||
|
lastWrite = Date.now();
|
||||||
|
};
|
||||||
|
write({ role: 'assistant' }, null);
|
||||||
|
const hb = setInterval(() => {
|
||||||
|
if (Date.now() - lastWrite >= 25_000) write({ content: '' }, null);
|
||||||
|
}, 5_000);
|
||||||
|
|
||||||
|
// Propagate a client/gateway disconnect down to the kimi child so an
|
||||||
|
// abandoned turn (e.g. OpenClaw's idle watchdog gave up) is killed
|
||||||
|
// instead of finishing invisibly and burning quota. `done` guards
|
||||||
|
// against the normal res.end() 'close' also aborting.
|
||||||
|
const ac = new AbortController();
|
||||||
|
let done = false;
|
||||||
|
res.on('close', () => { if (!done) ac.abort(); });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleTurn(messages, delta => write({ content: delta }, null), ac.signal);
|
||||||
|
done = true;
|
||||||
|
write({}, 'stop');
|
||||||
|
res.write('data: [DONE]\n\n');
|
||||||
|
} catch (err) {
|
||||||
|
done = true;
|
||||||
|
// Headers already sent — surface the error inside the stream (unless
|
||||||
|
// the socket is already gone, in which case there is nowhere to write).
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
write({ content: `\n[error: ${String(err.message || err)}]` }, 'stop');
|
||||||
|
res.write('data: [DONE]\n\n');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearInterval(hb);
|
||||||
|
if (!res.writableEnded) res.end();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
let done = false;
|
||||||
|
res.on('close', () => { if (!done) ac.abort(); });
|
||||||
|
try {
|
||||||
|
const { text } = await handleTurn(messages, null, ac.signal);
|
||||||
|
done = true;
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(completionBody(text)));
|
||||||
|
} catch (err) {
|
||||||
|
done = true;
|
||||||
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: String(err.message || err) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'not found' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => console.log(`adolf-llm wrapper listening on :${PORT}`));
|
||||||
20
openai/adolf-llm/service-block.yml
Normal file
20
openai/adolf-llm/service-block.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# adolf-llm — conversational Kimi-CLI wrapper (P2, :8010). OpenAI-compatible,
|
||||||
|
# model id "adolf". Real streaming, chat_id session mapping (1:1 kimi -r resume),
|
||||||
|
# media persistence, shared .mcp.json per session. cognee/MCP wiring is stubbed
|
||||||
|
# until P4/P5. Needs `kimi login` credentials seeded into its own home volume.
|
||||||
|
# Orchestrator: merge this `adolf-llm` service + the two named volumes into
|
||||||
|
# openai/docker-compose.yml (do NOT edit that file here).
|
||||||
|
services:
|
||||||
|
adolf-llm:
|
||||||
|
build: ./adolf-llm
|
||||||
|
container_name: adolf-llm
|
||||||
|
ports:
|
||||||
|
- "8010:8010"
|
||||||
|
volumes:
|
||||||
|
- adolf-llm-workspace:/workspace
|
||||||
|
- adolf-llm-home:/root/.kimi-code
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
adolf-llm-workspace:
|
||||||
|
adolf-llm-home:
|
||||||
11
openai/cognee-llm/Dockerfile
Normal file
11
openai/cognee-llm/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
RUN npm install -g @moonshot-ai/kimi-code
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
COPY server.js /app/server.js
|
||||||
|
|
||||||
|
EXPOSE 8011
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "/app/server.js"]
|
||||||
55
openai/cognee-llm/README.md
Normal file
55
openai/cognee-llm/README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# cognee-llm (:8011)
|
||||||
|
|
||||||
|
OpenAI-compatible wrapper around the Kimi Code CLI (`@moonshot-ai/kimi-code`, home
|
||||||
|
`/root/.kimi-code`), built for Cognee's batch/structured LLM calls. **Opposite policy to
|
||||||
|
`kimi-agent`**:
|
||||||
|
|
||||||
|
- **Stateless one-shot** — fresh temp dir under `/workspace/<uuid>` per request, `kimi -p
|
||||||
|
<prompt> --output-format stream-json`, **no `-r`/`-S` resume**, dir removed after every call
|
||||||
|
(success or failure).
|
||||||
|
- **Non-streaming** — always returns a full `chat.completion` body, even if the caller sets
|
||||||
|
`stream: true`.
|
||||||
|
- **No media, no MCP** — text-only prompt built from `messages`; no image persistence, no
|
||||||
|
`.mcp.json`.
|
||||||
|
- **Structured/low-temperature intent via prompt, not a sampling param** — the CLI has no raw
|
||||||
|
temperature knob (it's an agent loop, not a completions API), so determinism/JSON-only output
|
||||||
|
is enforced with an instruction preamble prepended to the caller's system prompt.
|
||||||
|
- **Bounded concurrency** — `MAX_CONCURRENCY = 3` in `server.js`, queued beyond that.
|
||||||
|
|
||||||
|
Endpoints: `GET /v1/models` (model id `cognee-llm`), `POST /v1/chat/completions`.
|
||||||
|
|
||||||
|
Own disposable in-container `/workspace` (no host bind mount — nothing here is meant to
|
||||||
|
survive a request, let alone a container restart) + own `cognee-llm-home` volume
|
||||||
|
(`/root/.kimi-code`), same Kimi subscription as `kimi-agent`/`adolf-llm`, separate volume so
|
||||||
|
each wrapper's CLI state stays isolated.
|
||||||
|
|
||||||
|
## This IS Cognee's LLM backbone
|
||||||
|
|
||||||
|
By design, Cognee's LLM runs on the flat Kimi subscription through this wrapper — the whole
|
||||||
|
reason it exists — mirroring how `adolf-llm` backs the assistant. P4 wires cognee's
|
||||||
|
`LLM_ENDPOINT` → `http://cognee-llm:8011`, `LLM_MODEL` → `openai/cognee-llm`.
|
||||||
|
|
||||||
|
**Accepted tradeoff (SPIKE-FINDINGS gate 5).** The CLI's JSON output is clean/schema-conformant,
|
||||||
|
but it's slower than a raw API: ~5s fixed per-invocation floor + ~22-24s for a realistic
|
||||||
|
structured-extraction call, and every call is agentic. Cognify issues one call per
|
||||||
|
chunk/entity-extraction step, so large batches serialize into minutes. To protect the
|
||||||
|
single-seat subscription, `MAX_CONCURRENCY = 3` bounds concurrent spawns.
|
||||||
|
|
||||||
|
**Documented fallback (not the default):** if cognify throughput ever becomes a real problem,
|
||||||
|
route cognee's LLM to a LiteLLM model instead (`ARCHITECTURE.md` §3.3) — see the commented block
|
||||||
|
in `cognee/cognee.env`. Embeddings already run on LiteLLM's `nomic-embed` regardless (embeddings
|
||||||
|
can't go through the agentic CLI).
|
||||||
|
|
||||||
|
## Smoke test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alvis/agap_git/openai
|
||||||
|
docker build -t cognee-llm:local ./cognee-llm
|
||||||
|
docker run --rm -d --name cognee-llm-smoke -p 18011:8011 cognee-llm:local
|
||||||
|
curl -s http://localhost:18011/v1/models
|
||||||
|
docker rm -f cognee-llm-smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
A full `/v1/chat/completions` round-trip needs a `kimi login`-authed
|
||||||
|
`/root/.kimi-code` volume (shared Kimi subscription) — not present in a bare smoke container,
|
||||||
|
so that step is deferred to integration/P4 wiring.
|
||||||
178
openai/cognee-llm/server.js
Normal file
178
openai/cognee-llm/server.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const PORT = 8011;
|
||||||
|
const MODEL_ID = 'cognee-llm';
|
||||||
|
const TIMEOUT_MS = 5 * 60 * 1000; // one-shot structured calls; generous but bounded
|
||||||
|
// Bounded parallelism: SPIKE-FINDINGS.md gate 5 flagged the Kimi subscription as a
|
||||||
|
// single-seat, interactive-oriented plan — batch cognify must not hammer it with
|
||||||
|
// unbounded concurrent CLI spawns (rate-limit/throttle risk on a shared live account).
|
||||||
|
const MAX_CONCURRENCY = 3;
|
||||||
|
|
||||||
|
const WORKSPACE = '/workspace';
|
||||||
|
fs.mkdirSync(WORKSPACE, { recursive: true });
|
||||||
|
|
||||||
|
// The CLI has no raw sampling-temperature knob (it's an agent loop, not a
|
||||||
|
// completions API) — "low temperature" for structured extraction is enforced
|
||||||
|
// via an instruction preamble instead, prepended to whatever system prompt
|
||||||
|
// the caller (Cognee) supplies.
|
||||||
|
const STRUCTURED_SYSTEM_PREAMBLE = [
|
||||||
|
'You are a stateless structured-extraction engine.',
|
||||||
|
'This is a one-shot call with no memory of prior calls: do not reference earlier turns.',
|
||||||
|
'Respond deterministically and concisely. When asked for JSON, output raw JSON only',
|
||||||
|
'- no prose, no markdown code fences, no commentary before or after.',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
// --- message helpers ---------------------------------------------------------
|
||||||
|
// Text only, no media parts: this wrapper's policy is no-media/no-MCP, unlike
|
||||||
|
// adolf-llm which persists inbound images and lets the CLI's ReadMediaFile
|
||||||
|
// tool read them.
|
||||||
|
function textOf(msg) {
|
||||||
|
const c = msg.content;
|
||||||
|
if (Array.isArray(c)) return c.map(p => (typeof p.text === 'string' ? p.text : '')).join('\n');
|
||||||
|
return c == null ? '' : String(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(messages) {
|
||||||
|
const systemParts = messages.filter(m => m.role === 'system').map(textOf);
|
||||||
|
const rest = messages.filter(m => m.role !== 'system');
|
||||||
|
const preamble = [STRUCTURED_SYSTEM_PREAMBLE, ...systemParts].join('\n\n');
|
||||||
|
const transcript = rest
|
||||||
|
.map(m => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${textOf(m)}`)
|
||||||
|
.join('\n\n');
|
||||||
|
return `${preamble}\n\n${transcript}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bounded concurrency queue -----------------------------------------------
|
||||||
|
let active = 0;
|
||||||
|
const queue = [];
|
||||||
|
function drain() {
|
||||||
|
if (queue.length && active < MAX_CONCURRENCY) queue.shift()();
|
||||||
|
}
|
||||||
|
function withSlot(fn) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const run = () => {
|
||||||
|
active++;
|
||||||
|
fn().then(
|
||||||
|
v => { active--; drain(); resolve(v); },
|
||||||
|
e => { active--; drain(); reject(e); },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (active < MAX_CONCURRENCY) run();
|
||||||
|
else queue.push(run);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- kimi invocation: stateless one-shot, no resume --------------------------
|
||||||
|
// Fresh temp dir per call, NO -r/-S session flag, discard the dir after.
|
||||||
|
// Returns the assembled text from --output-format stream-json:
|
||||||
|
// {"role":"assistant","content":"..."}
|
||||||
|
// (reuses the same parse core as kimi-agent/server.js's runKimi, minus the
|
||||||
|
// resume/session-id bookkeeping that wrapper needs and this one deliberately
|
||||||
|
// does not).
|
||||||
|
function runKimi({ prompt, cwd }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = ['-p', prompt, '--output-format', 'stream-json'];
|
||||||
|
const child = spawn('kimi', args, { cwd, timeout: TIMEOUT_MS });
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', d => { stdout += d; });
|
||||||
|
child.stderr.on('data', d => { stderr += d; });
|
||||||
|
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', code => {
|
||||||
|
const parts = [];
|
||||||
|
for (const line of stdout.split('\n')) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
let obj;
|
||||||
|
try { obj = JSON.parse(t); } catch { continue; }
|
||||||
|
if (obj.role === 'assistant' && obj.content) parts.push(obj.content);
|
||||||
|
}
|
||||||
|
const text = parts.join('').trim();
|
||||||
|
if (!text && code !== 0) {
|
||||||
|
reject(new Error(`kimi exited ${code}: ${stderr.slice(0, 2000)}`));
|
||||||
|
} else {
|
||||||
|
resolve(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTurn(messages) {
|
||||||
|
const prompt = buildPrompt(messages || []);
|
||||||
|
const reqId = crypto.randomUUID();
|
||||||
|
const dir = path.join(WORKSPACE, reqId);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
try {
|
||||||
|
return await withSlot(() => runKimi({ prompt, cwd: dir }));
|
||||||
|
} finally {
|
||||||
|
// Stateless one-shot: nothing about this call is meant to survive it, so
|
||||||
|
// the temp dir is discarded unconditionally, success or failure.
|
||||||
|
fs.rm(dir, { recursive: true, force: true }, () => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OpenAI-compatible HTTP surface (non-streaming only) ---------------------
|
||||||
|
function completionBody(text) {
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-${Date.now()}`,
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: MODEL_ID,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: text },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
object: 'list',
|
||||||
|
data: [{ id: MODEL_ID, object: 'model', owned_by: 'moonshot' }],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/v1/chat/completions') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', d => { body += d; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'invalid JSON body' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await handleTurn(parsed.messages || []);
|
||||||
|
// Non-streaming policy: always return the full body even if the
|
||||||
|
// caller sets stream:true. Cognee's batch cognify has no use for SSE,
|
||||||
|
// and a one-shot call has nothing to incrementally stream anyway.
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(completionBody(text)));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: String(err.message || err) }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'not found' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => console.log(`cognee-llm wrapper listening on :${PORT}`));
|
||||||
15
openai/cognee-llm/service-block.yml
Normal file
15
openai/cognee-llm/service-block.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Intended service block for /home/alvis/agap_git/openai/docker-compose.yml.
|
||||||
|
# Not wired in yet (see P3 task note) — orchestrator merges this in and adds
|
||||||
|
# `cognee-llm-home` to the top-level `volumes:` section.
|
||||||
|
|
||||||
|
cognee-llm:
|
||||||
|
build: ./cognee-llm
|
||||||
|
container_name: cognee-llm
|
||||||
|
ports:
|
||||||
|
- "8011:8011"
|
||||||
|
volumes:
|
||||||
|
- cognee-llm-home:/root/.kimi-code
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Add to the top-level `volumes:` block:
|
||||||
|
# cognee-llm-home:
|
||||||
66
openai/cognee/Dockerfile
Normal file
66
openai/cognee/Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Adolf P4 — cognee memory service.
|
||||||
|
#
|
||||||
|
# Base: official upstream image (do not hand-roll cognee itself). Adds ONE
|
||||||
|
# thing upstream doesn't ship: Qdrant vector-store support. Qdrant is a
|
||||||
|
# *community* adapter (separate PyPI package, not one of cognee's own
|
||||||
|
# `[project.optional-dependencies]` extras — the image's own EXTRAS=
|
||||||
|
# mechanism only installs cognee's own extras, so it can't pull this in).
|
||||||
|
#
|
||||||
|
# Version note: cognee-community-vector-adapter-qdrant's declared dependency
|
||||||
|
# pin (both the PyPI release 0.2.4 -> cognee==0.5.6, and the unreleased
|
||||||
|
# GitHub main 0.3.0 -> cognee==1.1.0) trails this image's cognee 1.2.2.
|
||||||
|
# Installed with --no-deps (below) to avoid pip fighting that pin and
|
||||||
|
# downgrading cognee. Verified compatible by direct import test on 2026-07-05:
|
||||||
|
# both registry hooks the adapter calls (`use_vector_adapter`,
|
||||||
|
# `use_dataset_database_handler` from cognee.infrastructure.databases.*)
|
||||||
|
# exist unchanged in cognee 1.2.2, and a full container import of
|
||||||
|
# cognee_community_vector_adapter_qdrant.register succeeds with no error
|
||||||
|
# against this exact image. Not yet exercised against a live Qdrant round
|
||||||
|
# trip (cognify + search) — do that once the LiteLLM LLM/embedder blockers
|
||||||
|
# below are resolved, as a final confirmation.
|
||||||
|
FROM cognee/cognee:1.2.2
|
||||||
|
|
||||||
|
# qdrant-client is the adapter's one genuinely-missing runtime dependency
|
||||||
|
# (starlette/instructor are already satisfied by cognee's own base deps).
|
||||||
|
# Installed normally (with deps) since it's a fresh package, not a conflict.
|
||||||
|
RUN /usr/local/bin/pip --python /app/.venv/bin/python install --no-cache-dir \
|
||||||
|
"qdrant-client>=1.18.0"
|
||||||
|
|
||||||
|
# Pinned to a specific commit for reproducibility (no tagged release exists
|
||||||
|
# yet compatible with our cognee version — see version note above).
|
||||||
|
RUN /usr/local/bin/pip --python /app/.venv/bin/python install --no-cache-dir --no-deps \
|
||||||
|
"https://github.com/topoteretes/cognee-community/archive/52281288052970f57e533b9be75b64da9ac7c773.tar.gz#subdirectory=packages/vector/qdrant"
|
||||||
|
|
||||||
|
# sitecustomize.py auto-imports at every Python interpreter start in this
|
||||||
|
# venv. Gated on VECTOR_DB_PROVIDER so it's a no-op unless qdrant is actually
|
||||||
|
# selected — this is the adapter's own documented registration call
|
||||||
|
# (cognee-community-vector-adapter-qdrant README: "Import and register the
|
||||||
|
# adapter in your code: from cognee_community_vector_adapter_qdrant import
|
||||||
|
# register"), just run automatically instead of requiring a cognee source
|
||||||
|
# edit to add the import.
|
||||||
|
RUN printf '%s\n' \
|
||||||
|
'import os' \
|
||||||
|
'if os.environ.get("VECTOR_DB_PROVIDER") == "qdrant":' \
|
||||||
|
' from cognee_community_vector_adapter_qdrant import register # noqa: F401' \
|
||||||
|
> /app/.venv/lib/python3.12/site-packages/sitecustomize.py
|
||||||
|
|
||||||
|
# Pre-installed Kuzu/Ladybug JSON extension (P4 deploy blocker fix, 2026-07-05).
|
||||||
|
# cognee's graph adapter (cognee/infrastructure/databases/graph/ladybug/adapter.py)
|
||||||
|
# always tries `LOAD EXTENSION JSON` on startup and on every /health graph check,
|
||||||
|
# falling back to `INSTALL JSON` (a network download from
|
||||||
|
# extension.ladybugdb.com) if not already cached at
|
||||||
|
# ~/.lbdb/extension/<kuzu_version>/<platform>/json/libjson.lbug_extension. This
|
||||||
|
# extension is required for recall/temporal-search graph queries — without it
|
||||||
|
# cognee's /health reports "unhealthy" and graph queries that use JSON fail
|
||||||
|
# with a Binder exception ("Extension: json ... has not been installed").
|
||||||
|
#
|
||||||
|
# This deployment's egress to extension.ladybugdb.com is severely
|
||||||
|
# bandwidth-throttled (~1-1.2 KB/s per connection — confirmed via direct curl,
|
||||||
|
# not a proxy/DNS block: TLS handshake and HTTP 200 succeed, the transfer
|
||||||
|
# itself just crawls), so the runtime auto-download reliably times out before
|
||||||
|
# the ~827KB file finishes, and every subsequent health check/query re-attempts
|
||||||
|
# and fails the same way. Downloaded once out-of-band (16-way parallel ranged
|
||||||
|
# GETs, ~846920 bytes, verified ELF shared object) and baked into the image
|
||||||
|
# here so the container never needs to touch that host at runtime.
|
||||||
|
COPY extensions/0.17.0/linux_amd64/json/libjson.lbug_extension \
|
||||||
|
/root/.lbdb/extension/0.17.0/linux_amd64/json/libjson.lbug_extension
|
||||||
149
openai/cognee/cognee.env
Normal file
149
openai/cognee/cognee.env
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Adolf P4 — Cognee memory service config (mounted at /app/.env in the
|
||||||
|
# `cognee` container; matches upstream's own docker-compose `.env` pattern).
|
||||||
|
# cognee-mcp does NOT need this file — it runs in API mode (see
|
||||||
|
# service-block.yml) and only ever talks HTTP to `cognee`, never touching
|
||||||
|
# these DBs directly.
|
||||||
|
|
||||||
|
ENV=local
|
||||||
|
DEBUG=false
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
CORS_ALLOWED_ORIGINS=*
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# LLM — cognee runs on the Kimi subscription via the `cognee-llm` wrapper
|
||||||
|
# (:8011, built in P3). This is the intended backbone: the whole reason
|
||||||
|
# cognee-llm exists is to be cognee's LLM on the flat Kimi subscription (no
|
||||||
|
# per-token cost), consistent with adolf-llm doing the same for the assistant.
|
||||||
|
#
|
||||||
|
# Tradeoff (SPIKE-FINDINGS gate 5, accepted): the agentic CLI adds latency
|
||||||
|
# (~5s floor + ~22-24s/structured call) and runs on a single-seat subscription,
|
||||||
|
# so batch cognify is slower than a raw API. cognee-llm bounds concurrency
|
||||||
|
# (MAX_CONCURRENCY=3) to protect the account. If cognify throughput ever
|
||||||
|
# becomes a problem, the LiteLLM route below is the documented fallback.
|
||||||
|
#
|
||||||
|
# Requires: `kimi login` seeded into the `cognee-llm-home` volume (same as
|
||||||
|
# adolf-llm/kimi-agent).
|
||||||
|
###############################################################################
|
||||||
|
LLM_PROVIDER=openai
|
||||||
|
LLM_MODEL=openai/cognee-llm
|
||||||
|
# Must include /v1 — cognee's OpenAI-compatible LLM adapter passes this
|
||||||
|
# straight through to litellm as api_base and litellm appends
|
||||||
|
# "/chat/completions" verbatim (no path normalization). Without /v1 this hits
|
||||||
|
# http://cognee-llm:8011/chat/completions, which 404s (cognee-llm only serves
|
||||||
|
# /v1/chat/completions and /v1/models) — confirmed 2026-07-05 during the P4
|
||||||
|
# smoke test (litellm.NotFoundError: Error code 404 - 'not found').
|
||||||
|
LLM_ENDPOINT=http://cognee-llm:8011/v1
|
||||||
|
LLM_API_KEY=sk-cognee-llm-local
|
||||||
|
|
||||||
|
# Force instructor's plain JSON-in-content mode instead of its default
|
||||||
|
# tool-calling mode. cognee-llm's Kimi CLI wrapper is a text-only pass-through
|
||||||
|
# (no real OpenAI function/tool-calling support — it just returns
|
||||||
|
# {"content": "..."}), so instructor's default mode for the "openai" provider
|
||||||
|
# (tool-calling, since no explicit LLM_INSTRUCTOR_MODE means it never applies
|
||||||
|
# json_schema_mode either) fails with "Instructor does not support multiple
|
||||||
|
# tool calls, use List[Model] instead" — confirmed 2026-07-05 during the P4
|
||||||
|
# smoke test. json_mode matches cognee-llm's own documented behavior
|
||||||
|
# (STRUCTURED_SYSTEM_PREAMBLE: "When asked for JSON, output raw JSON only").
|
||||||
|
LLM_INSTRUCTOR_MODE=json_mode
|
||||||
|
|
||||||
|
# Fallback only (NOT the default) — route cognify's LLM to a LiteLLM model if
|
||||||
|
# the Kimi CLI path is ever too slow under batch load. Requires a working
|
||||||
|
# LiteLLM general model (fix judge's ANTHROPIC_API_KEY or a local qwen's port):
|
||||||
|
#LLM_MODEL=openai/judge
|
||||||
|
#LLM_ENDPOINT=http://litellm:4000
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Embeddings — ollama directly (P4 blocker #1 resolution, per orchestrator:
|
||||||
|
# "use ollama directly"). LiteLLM's `embedder` route was dead (port bug), so
|
||||||
|
# rather than fix that indirection we go straight to ollama's own dedicated
|
||||||
|
# embedding-engine implementation (OllamaEmbeddingEngine, verified present in
|
||||||
|
# cognee 1.2.2's infra/databases/vector/embeddings/).
|
||||||
|
#
|
||||||
|
# Ollama lives in a SEPARATE compose project (not on this `openai` network),
|
||||||
|
# reachable from containers only via host.docker.internal — hence
|
||||||
|
# extra_hosts: host.docker.internal:host-gateway on the cognee service in
|
||||||
|
# docker-compose.yml. Verified 2026-07-05: `curl host.docker.internal:11436`
|
||||||
|
# from a throwaway container with that extra_hosts entry returns 200.
|
||||||
|
#
|
||||||
|
# EMBEDDING_ENDPOINT must be the FULL endpoint URL including path —
|
||||||
|
# OllamaEmbeddingEngine POSTs directly to whatever EMBEDDING_ENDPOINT is (its
|
||||||
|
# own default is "http://localhost:11434/api/embed"), unlike the
|
||||||
|
# openai_compatible engine which appends its own path onto a base URL. Ollama's
|
||||||
|
# native /api/embed (batch endpoint, not the singular /api/embeddings) returns
|
||||||
|
# {"embeddings": [[...]]}; the engine handles that key.
|
||||||
|
#
|
||||||
|
# Swapped nomic-embed-text (768-d) -> bge-m3 (1024-d, multilingual, GPU-served)
|
||||||
|
# 2026-07-06 [Adolf kb#60]. bge-m3 pulled into the same :11436 ollama; tested
|
||||||
|
# directly against :11436 -> 1024-dim vector, confirmed working. cognee's
|
||||||
|
# Qdrant collections were all still 768-d (a handful of P4 smoke-test points
|
||||||
|
# only — "pineapple-7742"/"p4 deployment smoke test" fixtures, no real
|
||||||
|
# conversation data; adolf-llm's cogneeSearch/cogneeAdd are still stubs and
|
||||||
|
# have never actually written to cognee), so the stale 768-d collections were
|
||||||
|
# dropped rather than migrated — cognee recreates them at the new dimension
|
||||||
|
# on first write.
|
||||||
|
###############################################################################
|
||||||
|
EMBEDDING_PROVIDER=ollama
|
||||||
|
EMBEDDING_MODEL=bge-m3
|
||||||
|
EMBEDDING_ENDPOINT=http://host.docker.internal:11436/api/embed
|
||||||
|
EMBEDDING_DIMENSIONS=1024
|
||||||
|
HUGGINGFACE_TOKENIZER=BAAI/bge-m3
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Graph store — SPIKE-FINDINGS gate 4: Kuzu embedded, not Neo4j.
|
||||||
|
# This is cognee's own default; listed explicitly for clarity.
|
||||||
|
###############################################################################
|
||||||
|
GRAPH_DATABASE_PROVIDER=kuzu
|
||||||
|
GRAPH_DATASET_DATABASE_HANDLER=kuzu
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Vector store — Qdrant (existing infra, :6333). Community adapter installed
|
||||||
|
# via the custom Dockerfile in this directory (see comments there).
|
||||||
|
###############################################################################
|
||||||
|
VECTOR_DB_PROVIDER=qdrant
|
||||||
|
VECTOR_DB_URL=http://qdrant:6333
|
||||||
|
VECTOR_DB_KEY=
|
||||||
|
VECTOR_DATASET_DATABASE_HANDLER=qdrant
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Relational metadata DB (cognee's own bookkeeping, not the memory graph).
|
||||||
|
###############################################################################
|
||||||
|
DB_PROVIDER=sqlite
|
||||||
|
DB_NAME=cognee_db
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Storage paths — persisted under /mnt/ssd/dbs/cognee/ on the host (see
|
||||||
|
# service-block.yml volume mounts to /data and /system).
|
||||||
|
###############################################################################
|
||||||
|
DATA_ROOT_DIRECTORY=/data
|
||||||
|
SYSTEM_ROOT_DIRECTORY=/system
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# Single-user/single-agent posture. Adolf is one Matrix bot (SPIKE-FINDINGS
|
||||||
|
# gate 4's own reasoning: no multi-tenant/concurrent-writer need at this
|
||||||
|
# scale). Scoping happens at the *dataset* level (one dataset per OpenClaw
|
||||||
|
# chat_id — see P4 report), not via cognee's own per-user auth/isolation
|
||||||
|
# machinery, so we skip that machinery rather than bootstrap a default user
|
||||||
|
# just to satisfy it.
|
||||||
|
#
|
||||||
|
# ENABLE_BACKEND_ACCESS_CONTROL=true (cognee's own default) would give each
|
||||||
|
# (user, dataset) pair a fully isolated Kuzu+vector store, but *requires*
|
||||||
|
# authentication (REQUIRE_AUTHENTICATION=false is ignored when this is true)
|
||||||
|
# - extra machinery (default user bootstrap, token plumbing into cognee-mcp)
|
||||||
|
# for no real benefit in a single-owner home deployment. With it off, all
|
||||||
|
# datasets share one graph/vector backend; dataset_name/datasets filters on
|
||||||
|
# remember/recall/forget still scope top-level data points per conversation,
|
||||||
|
# with one documented caveat: GRAPH_COMPLETION search can traverse into
|
||||||
|
# nodes from other datasets. Acceptable for one person's own conversation
|
||||||
|
# threads; revisit (flip this flag + bootstrap a default user) if that
|
||||||
|
# leakage ever matters.
|
||||||
|
###############################################################################
|
||||||
|
ENABLE_BACKEND_ACCESS_CONTROL=False
|
||||||
|
REQUIRE_AUTHENTICATION=False
|
||||||
|
|
||||||
|
# Only exercised if the above is ever flipped to true.
|
||||||
|
FASTAPI_USERS_JWT_SECRET=059bd0fdd9cecc46d055cf589d4275bd34c0fb73543f286beff09da2c2d27b65
|
||||||
|
FASTAPI_USERS_VERIFICATION_TOKEN_SECRET=7246494bb622c9c89417fbe0b94de6d7718f1338eb40dd370fb072873f921832
|
||||||
|
FASTAPI_USERS_RESET_PASSWORD_TOKEN_SECRET=18ad75671edf003f0142aad124276268fa766e702ab6bdb71a75d1c71a688beb
|
||||||
|
|
||||||
|
TOKENIZERS_PARALLELISM=false
|
||||||
|
LITELLM_LOG=ERROR
|
||||||
Binary file not shown.
@@ -1,62 +1,70 @@
|
|||||||
services:
|
services:
|
||||||
ollama:
|
litellm-db:
|
||||||
image: ollama/ollama
|
image: postgres:16-alpine
|
||||||
container_name: ollama
|
container_name: litellm-db
|
||||||
ports:
|
|
||||||
- "11436:11434"
|
|
||||||
volumes:
|
|
||||||
- /mnt/ssd/ai/ollama:/root/.ollama
|
|
||||||
- /mnt/ssd/ai/open-webui:/app/backend/data
|
|
||||||
restart: always
|
|
||||||
environment:
|
environment:
|
||||||
# Allow qwen3:8b + qwen2.5:1.5b to coexist in VRAM (~6.7-7.7 GB on 8 GB GPU)
|
- POSTGRES_DB=litellm
|
||||||
- OLLAMA_MAX_LOADED_MODELS=2
|
- POSTGRES_USER=litellm
|
||||||
# One GPU inference at a time — prevents compute contention between models
|
- POSTGRES_PASSWORD=litellm
|
||||||
- OLLAMA_NUM_PARALLEL=1
|
volumes:
|
||||||
deploy:
|
- /mnt/ssd/dbs/litellm/postgres:/var/lib/postgresql/data
|
||||||
resources:
|
restart: always
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
count: all
|
|
||||||
capabilities: [gpu]
|
|
||||||
|
|
||||||
ollama-cpu:
|
litellm:
|
||||||
image: ollama/ollama
|
image: ghcr.io/berriai/litellm:main-latest
|
||||||
container_name: ollama-cpu
|
container_name: litellm
|
||||||
ports:
|
ports:
|
||||||
- "11435:11434"
|
- "4000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/ssd/ai/ollama-cpu:/root/.ollama
|
- ./litellm-config.yaml:/app/config.yaml
|
||||||
restart: always
|
|
||||||
|
|
||||||
open-webui:
|
|
||||||
image: ghcr.io/open-webui/open-webui:main
|
|
||||||
container_name: open-webui
|
|
||||||
ports:
|
|
||||||
- "3125:8080"
|
|
||||||
volumes:
|
|
||||||
- /mnt/ssd/ai/open-webui:/app/backend/data
|
|
||||||
restart: always
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
reservations:
|
|
||||||
devices:
|
|
||||||
- driver: nvidia
|
|
||||||
count: all
|
|
||||||
capabilities: [gpu]
|
|
||||||
environment:
|
environment:
|
||||||
- ANTHROPIC_API_KEY=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
|
- DATABASE_URL=postgresql://litellm:litellm@litellm-db:5432/litellm
|
||||||
|
- LITELLM_MASTER_KEY=sk-fjQC1BxAiGFSMs
|
||||||
searxng:
|
- LANGFUSE_PUBLIC_KEY=${LANGFUSE_PUBLIC_KEY:-changeme}
|
||||||
image: docker.io/searxng/searxng:latest
|
- LANGFUSE_SECRET_KEY=${LANGFUSE_SECRET_KEY:-changeme}
|
||||||
container_name: searxng
|
- LANGFUSE_HOST=http://langfuse:3000
|
||||||
volumes:
|
- OPENROUTER_API_KEY=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
|
||||||
- /mnt/ssd/ai/searxng/config/:/etc/searxng/
|
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||||
- /mnt/ssd/ai/searxng/data/:/var/cache/searxng/
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
- litellm-db
|
||||||
|
- langfuse
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
|
kimi-agent:
|
||||||
|
build: ./kimi-agent
|
||||||
|
container_name: kimi-agent
|
||||||
|
volumes:
|
||||||
|
- /home/alvis/kimi-workspace:/workspace
|
||||||
|
- kimi-agent-home:/root/.kimi-code
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
langfuse-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: langfuse-db
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=langfuse
|
||||||
|
- POSTGRES_USER=langfuse
|
||||||
|
- POSTGRES_PASSWORD=langfuse
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/langfuse/postgres:/var/lib/postgresql/data
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
langfuse:
|
||||||
|
image: ghcr.io/langfuse/langfuse:2
|
||||||
|
container_name: langfuse
|
||||||
ports:
|
ports:
|
||||||
- "11437:8080"
|
- "3200:3000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://langfuse:langfuse@langfuse-db:5432/langfuse
|
||||||
|
- NEXTAUTH_URL=https://lf.alogins.net
|
||||||
|
- NEXTAUTH_SECRET=532a746b24ac40afa39f9d317031cab94d4d6881107ea3b1209b28020f1a9761
|
||||||
|
- SALT=7927b3b0092afe4542274940b557becea6418a5fed79f7acd25c3a789349fdc9
|
||||||
|
- AUTH_DISABLE_SIGNUP=true
|
||||||
|
depends_on:
|
||||||
|
- langfuse-db
|
||||||
|
restart: always
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: qdrant/qdrant
|
image: qdrant/qdrant
|
||||||
@@ -67,3 +75,286 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- /mnt/ssd/dbs/qdrant:/qdrant/storage:z
|
- /mnt/ssd/dbs/qdrant:/qdrant/storage:z
|
||||||
|
|
||||||
|
faster-whisper:
|
||||||
|
image: fedirz/faster-whisper-server:latest-cuda
|
||||||
|
container_name: faster-whisper
|
||||||
|
runtime: nvidia
|
||||||
|
ports:
|
||||||
|
- "8880:8000"
|
||||||
|
environment:
|
||||||
|
- WHISPER__MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||||
|
- WHISPER__INFERENCE_DEVICE=cuda
|
||||||
|
- WHISPER__COMPUTE_TYPE=int8
|
||||||
|
- WHISPER__LANGUAGE=ru
|
||||||
|
- NVIDIA_VISIBLE_DEVICES=all
|
||||||
|
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/ai/faster-whisper:/root/.cache/huggingface
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
silero-tts:
|
||||||
|
build: ./silero-tts
|
||||||
|
container_name: silero-tts
|
||||||
|
ports:
|
||||||
|
- "8881:8881"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/ai/silero-tts:/cache/torch
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
pipecat:
|
||||||
|
build: ./pipecat
|
||||||
|
container_name: pipecat
|
||||||
|
ports:
|
||||||
|
- "8882:8882"
|
||||||
|
environment:
|
||||||
|
- LIVEKIT_URL=ws://host.docker.internal:7880
|
||||||
|
- LIVEKIT_PUBLIC_URL=wss://lk.alogins.net
|
||||||
|
- LIVEKIT_API_KEY=devkey
|
||||||
|
- LIVEKIT_SECRET=ef3ef4b903ca8469b09b2dd7ab6af529c4d2f3c95668f53832fc351cf67777a9
|
||||||
|
- ADOLF_URL=http://host.docker.internal:8000/v1
|
||||||
|
- STT_URL=http://host.docker.internal:8880/v1
|
||||||
|
- TTS_URL=http://host.docker.internal:8881/v1
|
||||||
|
- STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||||
|
- TTS_VOICE=onyx
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Adolf — OpenClaw fork (Matrix-first personal assistant). The OpenClaw
|
||||||
|
# gateway config (Matrix channel + allow-list, model provider ->
|
||||||
|
# adolf-llm:8010, MCP registry, gateway.tools.allow for cron/nodes) is
|
||||||
|
# version-controlled at agap_git/adolf/openclaw.json (repo root, alongside
|
||||||
|
# this openai/ project, not nested inside it) and bind-mounted read-only
|
||||||
|
# over the adolf-state volume (see volumes below), so git is the single
|
||||||
|
# source of truth — not a hand-edited volume file. The volume still
|
||||||
|
# holds runtime state only (Matrix crypto/devices, credentials, sessions,
|
||||||
|
# workspace/SOUL.md, logs). Matrix creds and ADOLF_KEY come from
|
||||||
|
# openai/.env (gitignored, never committed). Source tree: /home/alvis/adolf.
|
||||||
|
# To change config: edit ../adolf/openclaw.json + restart adolf.
|
||||||
|
adolf:
|
||||||
|
build:
|
||||||
|
context: ../../adolf
|
||||||
|
# Matrix is opt-in at build time (see adolf/Dockerfile); without this,
|
||||||
|
# the gateway logs "no-channel-owner" and channels.matrix is inert.
|
||||||
|
args:
|
||||||
|
OPENCLAW_EXTENSIONS: matrix
|
||||||
|
image: adolf:local
|
||||||
|
container_name: adolf
|
||||||
|
environment:
|
||||||
|
- HOME=/home/node
|
||||||
|
- OPENCLAW_HOME=/home/node
|
||||||
|
- OPENCLAW_STATE_DIR=/home/node/.openclaw
|
||||||
|
- OPENCLAW_CONFIG_PATH=/home/node/.openclaw/openclaw.json
|
||||||
|
- OPENCLAW_CONFIG_DIR=/home/node/.openclaw
|
||||||
|
- OPENCLAW_WORKSPACE_DIR=/home/node/.openclaw/workspace
|
||||||
|
- OPENCLAW_GATEWAY_TOKEN=${ADOLF_GATEWAY_TOKEN:-}
|
||||||
|
- ADOLF_KEY=${ADOLF_KEY:-}
|
||||||
|
- MATRIX_HOMESERVER=${MATRIX_HOMESERVER:-}
|
||||||
|
- MATRIX_USER_ID=${MATRIX_USER_ID:-}
|
||||||
|
- MATRIX_PASSWORD=${MATRIX_PASSWORD:-}
|
||||||
|
- MATRIX_DEVICE_NAME=${MATRIX_DEVICE_NAME:-Adolf OpenClaw Gateway}
|
||||||
|
# marketplace-mcp bearer token (kb task #61) -- referenced by
|
||||||
|
# openclaw.json's mcp.servers.marketplace.headers.Authorization via
|
||||||
|
# ${MARKETPLACE_MCP_TOKEN} substitution; never inlined into that file.
|
||||||
|
- MARKETPLACE_MCP_TOKEN=${MARKETPLACE_MCP_TOKEN:-}
|
||||||
|
- TZ=Europe/Riga
|
||||||
|
volumes:
|
||||||
|
# Runtime state only (Matrix crypto/devices, credentials, sessions,
|
||||||
|
# workspace, logs). The gateway config file itself is overlaid below.
|
||||||
|
- adolf-state:/home/node/.openclaw
|
||||||
|
# Version-controlled OpenClaw gateway config, mounted read-only on top
|
||||||
|
# of the state volume so it is the single source of truth. The gateway
|
||||||
|
# reads this JSONC file and snapshots its own .last-good/.rejected
|
||||||
|
# copies into the volume dir (writable) — it never rewrites this file,
|
||||||
|
# so read-only is safe. Edit the tracked file + restart to change config;
|
||||||
|
# runtime/UI edits are intentionally disabled by the ro mount.
|
||||||
|
- ../adolf/openclaw.json:/home/node/.openclaw/openclaw.json:ro
|
||||||
|
# quota-command plugin (kb #62) — same read-only-bind-over-volume
|
||||||
|
# pattern as openclaw.json above, applied to a single external plugin
|
||||||
|
# dir instead of the whole state tree. Previously the only precedent
|
||||||
|
# (cognee-memory) was docker cp'd straight into the adolf-state volume
|
||||||
|
# at runtime with no git backing; this plugin is small enough (no
|
||||||
|
# node_modules — only Node built-ins/global fetch) to just bind-mount
|
||||||
|
# its tracked source directly at its extensions/<id> path, so git stays
|
||||||
|
# the single source of truth the same way it already is for
|
||||||
|
# openclaw.json. Activated via plugins.entries.quota-command in that file.
|
||||||
|
- ./quota-command-openclaw-plugin:/home/node/.openclaw/extensions/quota-command:ro
|
||||||
|
# hindsight-memory plugin (kb #75, H3) — same read-only-bind-over-volume
|
||||||
|
# pattern as quota-command above. Structural successor to cognee-memory
|
||||||
|
# (still docker cp'd into the adolf-state volume, no git backing; that
|
||||||
|
# plugin's activation/container is decommissioned in H4, not here).
|
||||||
|
# Forced hooks (before_prompt_build recall / agent_end retain) against
|
||||||
|
# the hindsight service (see that service's block below), replacing
|
||||||
|
# Cognee as Adolf's memory backend. Activated via
|
||||||
|
# plugins.entries.hindsight-memory in openclaw.json.
|
||||||
|
- ./hindsight-openclaw-plugin:/home/node/.openclaw/extensions/hindsight-memory:ro
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
# mtx.alogins.net's public A record can't hairpin-NAT back through the
|
||||||
|
# router from inside a container; route it to the host gateway instead,
|
||||||
|
# matching matrix/docker-compose.yml's lk-jwt-service (same problem,
|
||||||
|
# same fix). Caddy on the host terminates TLS on :443 and proxies to
|
||||||
|
# synapse:8008.
|
||||||
|
- "mtx.alogins.net:host-gateway"
|
||||||
|
cap_drop:
|
||||||
|
- NET_RAW
|
||||||
|
- NET_ADMIN
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
init: true
|
||||||
|
ports:
|
||||||
|
- "18789:18789"
|
||||||
|
- "18790:18790"
|
||||||
|
command:
|
||||||
|
["node", "dist/index.js", "gateway", "--bind", "lan", "--port", "18789"]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# hindsight-llm — standalone clone of cognee-llm (kb#76, H4 option B): the
|
||||||
|
# dedicated Kimi-CLI wrapper that is now Hindsight's LLM, so the whole cognee
|
||||||
|
# stack (incl. cognee-llm) can be decommissioned. Own port (:8012) + own
|
||||||
|
# kimi-code volume; needs a one-time `kimi login` seeded into hindsight-llm-home.
|
||||||
|
hindsight-llm:
|
||||||
|
build: ./hindsight-llm
|
||||||
|
container_name: hindsight-llm
|
||||||
|
ports:
|
||||||
|
- "8012:8012"
|
||||||
|
volumes:
|
||||||
|
- hindsight-llm-home:/root/.kimi-code
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# adolf-llm — conversational Kimi-CLI wrapper (:8010), the model backend for
|
||||||
|
# the Adolf OpenClaw gateway (P2). Real streaming (SSE), chat_id session-keying
|
||||||
|
# + 1:1 kimi resume, media, per-session .mcp.json sourced from the shared
|
||||||
|
# shared-mcp.json contract (cognee-mcp P4, openclaw-tools P5). Needs
|
||||||
|
# `kimi login` in adolf-llm-home.
|
||||||
|
adolf-llm:
|
||||||
|
build: ./adolf-llm
|
||||||
|
container_name: adolf-llm
|
||||||
|
ports:
|
||||||
|
- "8010:8010"
|
||||||
|
volumes:
|
||||||
|
- adolf-llm-workspace:/workspace
|
||||||
|
- adolf-llm-home:/root/.kimi-code
|
||||||
|
- ./shared-mcp.json:/shared-mcp.json:ro
|
||||||
|
extra_hosts:
|
||||||
|
# Needed to reach kanboard-mcp-adolf (:3104, network_mode: host, outside
|
||||||
|
# this compose project's network) via shared-mcp.json's "kanboard"
|
||||||
|
# entry — same host-gateway trick used by adolf/cognee/pipecat above.
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# hindsight — Adolf memory backend, replacing cognee/cognee-mcp/cognee-llm
|
||||||
|
# (kb#73, migration doc agap_git/adolf/HINDSIGHT-MIGRATION.md, H1). One
|
||||||
|
# container: REST API :8888 (also serves the built-in MCP at /mcp/{bank}/),
|
||||||
|
# UI :9999, built-in Postgres (pg0) bind-mounted to
|
||||||
|
# /mnt/ssd/dbs/hindsight/ (host dir created + chowned 1000:1000 to match
|
||||||
|
# the image's non-root `hindsight` user, confirmed via
|
||||||
|
# `docker run --entrypoint id`).
|
||||||
|
#
|
||||||
|
# LLM + embeddings reconfigured 2026-07-15 (kb#84) to fix two wrong H1
|
||||||
|
# choices for a Russian/multilingual use case:
|
||||||
|
#
|
||||||
|
# LLM -> hindsight-llm:8012 (dedicated Kimi-CLI wrapper cloned from the shim cognee
|
||||||
|
# uses — see cognee/cognee.env's LLM section for the full precedent,
|
||||||
|
# including why LLM_INSTRUCTOR_MODE=json_mode isn't needed here since
|
||||||
|
# Hindsight's own client doesn't go through `instructor`). Replaces the
|
||||||
|
# H1 choice of LiteLLM + ollama/gemma3:4b (a tiny local model): validated
|
||||||
|
# 2026-07-15 that cognee-llm returns clean, JSON-parseable structured
|
||||||
|
# extraction for Russian input (see kb#84 probe B) — gemma3:4b's fluency
|
||||||
|
# on Russian was never actually verified, it was picked only to dodge
|
||||||
|
# qwen3:8b's <think>-token empty-content bug. Kimi is also the flat-rate
|
||||||
|
# subscription already paid for, so this isn't a new cost.
|
||||||
|
#
|
||||||
|
# Embeddings -> ollama's bge-m3 on the GPU (host.docker.internal:11436,
|
||||||
|
# separate compose project, same extra_hosts trick as cognee/adolf-llm
|
||||||
|
# below), via ollama's OpenAI-compatible /v1/embeddings endpoint
|
||||||
|
# (confirmed 200 + 1024-dim vector 2026-07-15, kb#84 probe A). Replaces
|
||||||
|
# the H1 choice of Hindsight's built-in `local` provider
|
||||||
|
# (BAAI/bge-small-en-v1.5, English-only, 384-d, CPU-bound in-process
|
||||||
|
# SentenceTransformers). The hindsight image itself is CPU-only (torch
|
||||||
|
# +cpu build, no onnxruntime GPU provider — confirmed 2026-07-15), so its
|
||||||
|
# in-process local/onnx embedders can never reach the GPU; routing
|
||||||
|
# through ollama's `openai` embeddings provider (HTTP, not the bespoke
|
||||||
|
# cognee-style `ollama` provider Hindsight doesn't have) is how GPU
|
||||||
|
# serving happens here. Dimensions var matches cognee.env's own bge-m3
|
||||||
|
# swap (kb#60): 1024.
|
||||||
|
#
|
||||||
|
# Runs ALONGSIDE cognee/cognee-mcp/cognee-llm during the migration; those
|
||||||
|
# are untouched here and only decommissioned in H4, after H2/H3/H5 prove
|
||||||
|
# this service out. Not yet wired into openclaw.json/shared-mcp.json
|
||||||
|
# (that's H2, kb#74) — this block only stands the service up and proves
|
||||||
|
# retain/recall against a throwaway bank.
|
||||||
|
hindsight:
|
||||||
|
image: ghcr.io/vectorize-io/hindsight:latest
|
||||||
|
container_name: hindsight
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- HINDSIGHT_API_LLM_PROVIDER=openai
|
||||||
|
- HINDSIGHT_API_LLM_BASE_URL=http://hindsight-llm:8012/v1
|
||||||
|
- HINDSIGHT_API_LLM_MODEL=openai/hindsight-llm
|
||||||
|
# hindsight-llm ignores the key entirely (Kimi CLI wrapper, no real
|
||||||
|
# OpenAI auth) — dummy value, non-empty so the client constructs.
|
||||||
|
- HINDSIGHT_API_LLM_API_KEY=sk-hindsight-llm-local
|
||||||
|
- HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
|
||||||
|
- HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL=http://host.docker.internal:11436/v1
|
||||||
|
- HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=bge-m3
|
||||||
|
- HINDSIGHT_API_EMBEDDINGS_OPENAI_DIMENSIONS=1024
|
||||||
|
# ollama doesn't check this value at all (no auth), but the openai
|
||||||
|
# embeddings client requires a non-empty key to construct.
|
||||||
|
- HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=ollama
|
||||||
|
# Stable worker id (compose service name), not the container hostname
|
||||||
|
# default -- without this, recreating the container orphans any
|
||||||
|
# in-flight async retain/consolidation tasks under the old hostname
|
||||||
|
# (startup log warns about exactly this).
|
||||||
|
- HINDSIGHT_API_WORKER_ID=hindsight
|
||||||
|
# Reranker -> multilingual (kb#84 follow-up). The TEMPR rerank stage
|
||||||
|
# defaulted to English cross-encoder/ms-marco-MiniLM, which ranks
|
||||||
|
# Russian/multilingual candidates poorly. jina v2 multilingual fixes
|
||||||
|
# that. Runs on CPU in this image (no CUDA torch) but only over the
|
||||||
|
# small recall candidate set. trust_remote_code: jina ships custom code.
|
||||||
|
- HINDSIGHT_API_RERANKER_PROVIDER=local
|
||||||
|
- HINDSIGHT_API_RERANKER_LOCAL_MODEL=jinaai/jina-reranker-v2-base-multilingual
|
||||||
|
- HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE=true
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/hindsight:/home/hindsight/.pg0
|
||||||
|
# Persist HuggingFace/sentence-transformers model cache so the jina
|
||||||
|
# reranker (~1GB) doesn't re-download on every container recreate.
|
||||||
|
- /mnt/ssd/dbs/hindsight-cache:/home/hindsight/.cache
|
||||||
|
ports:
|
||||||
|
- "8888:8888"
|
||||||
|
- "9999:9999"
|
||||||
|
extra_hosts:
|
||||||
|
# Needed to resolve host.docker.internal from inside the container
|
||||||
|
# for the ollama embeddings call above — ollama lives in a separate
|
||||||
|
# compose project, same trick as cognee/adolf-llm elsewhere in this
|
||||||
|
# file.
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
- hindsight-llm
|
||||||
|
|
||||||
|
# openclaw-tools — MCP bridge (P5) exposing a minimal slice of the Adolf
|
||||||
|
# OpenClaw gateway's agent tools (message/cron/nodes/browser) over MCP
|
||||||
|
# Streamable HTTP, so Kimi CLI sessions (adolf-llm) can call them instead of
|
||||||
|
# bypassing OpenClaw entirely. Proxies each MCP tool call to the gateway's
|
||||||
|
# `POST /tools/invoke` HTTP surface (http://adolf:18789). NOTE: `cron` and
|
||||||
|
# `nodes` are hard-denied on that surface by default until P6 adds them to
|
||||||
|
# `gateway.tools.allow` in the adolf openclaw.json — see openclaw-tools/
|
||||||
|
# server.js for the full gate writeup. Not useful until `adolf` (P6) is
|
||||||
|
# configured and running; safe to build/run standalone before that.
|
||||||
|
openclaw-tools:
|
||||||
|
build: ./openclaw-tools
|
||||||
|
container_name: openclaw-tools
|
||||||
|
environment:
|
||||||
|
- OPENCLAW_GATEWAY_URL=http://adolf:18789
|
||||||
|
- OPENCLAW_GATEWAY_TOKEN=${ADOLF_GATEWAY_TOKEN:-}
|
||||||
|
ports:
|
||||||
|
- "8020:8020"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
kimi-agent-home:
|
||||||
|
adolf-state:
|
||||||
|
hindsight-llm-home:
|
||||||
|
adolf-llm-workspace:
|
||||||
|
adolf-llm-home:
|
||||||
|
|||||||
11
openai/hindsight-llm/Dockerfile
Normal file
11
openai/hindsight-llm/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
RUN npm install -g @moonshot-ai/kimi-code
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
COPY server.js /app/server.js
|
||||||
|
|
||||||
|
EXPOSE 8012
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "/app/server.js"]
|
||||||
178
openai/hindsight-llm/server.js
Normal file
178
openai/hindsight-llm/server.js
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const PORT = 8012;
|
||||||
|
const MODEL_ID = 'hindsight-llm';
|
||||||
|
const TIMEOUT_MS = 5 * 60 * 1000; // one-shot structured calls; generous but bounded
|
||||||
|
// Bounded parallelism: SPIKE-FINDINGS.md gate 5 flagged the Kimi subscription as a
|
||||||
|
// single-seat, interactive-oriented plan — batch cognify must not hammer it with
|
||||||
|
// unbounded concurrent CLI spawns (rate-limit/throttle risk on a shared live account).
|
||||||
|
const MAX_CONCURRENCY = 3;
|
||||||
|
|
||||||
|
const WORKSPACE = '/workspace';
|
||||||
|
fs.mkdirSync(WORKSPACE, { recursive: true });
|
||||||
|
|
||||||
|
// The CLI has no raw sampling-temperature knob (it's an agent loop, not a
|
||||||
|
// completions API) — "low temperature" for structured extraction is enforced
|
||||||
|
// via an instruction preamble instead, prepended to whatever system prompt
|
||||||
|
// the caller (Cognee) supplies.
|
||||||
|
const STRUCTURED_SYSTEM_PREAMBLE = [
|
||||||
|
'You are a stateless structured-extraction engine.',
|
||||||
|
'This is a one-shot call with no memory of prior calls: do not reference earlier turns.',
|
||||||
|
'Respond deterministically and concisely. When asked for JSON, output raw JSON only',
|
||||||
|
'- no prose, no markdown code fences, no commentary before or after.',
|
||||||
|
].join(' ');
|
||||||
|
|
||||||
|
// --- message helpers ---------------------------------------------------------
|
||||||
|
// Text only, no media parts: this wrapper's policy is no-media/no-MCP, unlike
|
||||||
|
// adolf-llm which persists inbound images and lets the CLI's ReadMediaFile
|
||||||
|
// tool read them.
|
||||||
|
function textOf(msg) {
|
||||||
|
const c = msg.content;
|
||||||
|
if (Array.isArray(c)) return c.map(p => (typeof p.text === 'string' ? p.text : '')).join('\n');
|
||||||
|
return c == null ? '' : String(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPrompt(messages) {
|
||||||
|
const systemParts = messages.filter(m => m.role === 'system').map(textOf);
|
||||||
|
const rest = messages.filter(m => m.role !== 'system');
|
||||||
|
const preamble = [STRUCTURED_SYSTEM_PREAMBLE, ...systemParts].join('\n\n');
|
||||||
|
const transcript = rest
|
||||||
|
.map(m => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${textOf(m)}`)
|
||||||
|
.join('\n\n');
|
||||||
|
return `${preamble}\n\n${transcript}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- bounded concurrency queue -----------------------------------------------
|
||||||
|
let active = 0;
|
||||||
|
const queue = [];
|
||||||
|
function drain() {
|
||||||
|
if (queue.length && active < MAX_CONCURRENCY) queue.shift()();
|
||||||
|
}
|
||||||
|
function withSlot(fn) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const run = () => {
|
||||||
|
active++;
|
||||||
|
fn().then(
|
||||||
|
v => { active--; drain(); resolve(v); },
|
||||||
|
e => { active--; drain(); reject(e); },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (active < MAX_CONCURRENCY) run();
|
||||||
|
else queue.push(run);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- kimi invocation: stateless one-shot, no resume --------------------------
|
||||||
|
// Fresh temp dir per call, NO -r/-S session flag, discard the dir after.
|
||||||
|
// Returns the assembled text from --output-format stream-json:
|
||||||
|
// {"role":"assistant","content":"..."}
|
||||||
|
// (reuses the same parse core as kimi-agent/server.js's runKimi, minus the
|
||||||
|
// resume/session-id bookkeeping that wrapper needs and this one deliberately
|
||||||
|
// does not).
|
||||||
|
function runKimi({ prompt, cwd }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = ['-p', prompt, '--output-format', 'stream-json'];
|
||||||
|
const child = spawn('kimi', args, { cwd, timeout: TIMEOUT_MS });
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', d => { stdout += d; });
|
||||||
|
child.stderr.on('data', d => { stderr += d; });
|
||||||
|
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', code => {
|
||||||
|
const parts = [];
|
||||||
|
for (const line of stdout.split('\n')) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
let obj;
|
||||||
|
try { obj = JSON.parse(t); } catch { continue; }
|
||||||
|
if (obj.role === 'assistant' && obj.content) parts.push(obj.content);
|
||||||
|
}
|
||||||
|
const text = parts.join('').trim();
|
||||||
|
if (!text && code !== 0) {
|
||||||
|
reject(new Error(`kimi exited ${code}: ${stderr.slice(0, 2000)}`));
|
||||||
|
} else {
|
||||||
|
resolve(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTurn(messages) {
|
||||||
|
const prompt = buildPrompt(messages || []);
|
||||||
|
const reqId = crypto.randomUUID();
|
||||||
|
const dir = path.join(WORKSPACE, reqId);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
try {
|
||||||
|
return await withSlot(() => runKimi({ prompt, cwd: dir }));
|
||||||
|
} finally {
|
||||||
|
// Stateless one-shot: nothing about this call is meant to survive it, so
|
||||||
|
// the temp dir is discarded unconditionally, success or failure.
|
||||||
|
fs.rm(dir, { recursive: true, force: true }, () => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OpenAI-compatible HTTP surface (non-streaming only) ---------------------
|
||||||
|
function completionBody(text) {
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-${Date.now()}`,
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: MODEL_ID,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: text },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
object: 'list',
|
||||||
|
data: [{ id: MODEL_ID, object: 'model', owned_by: 'moonshot' }],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/v1/chat/completions') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', d => { body += d; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'invalid JSON body' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await handleTurn(parsed.messages || []);
|
||||||
|
// Non-streaming policy: always return the full body even if the
|
||||||
|
// caller sets stream:true. Cognee's batch cognify has no use for SSE,
|
||||||
|
// and a one-shot call has nothing to incrementally stream anyway.
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(completionBody(text)));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: String(err.message || err) }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'not found' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => console.log(`hindsight-llm wrapper listening on :${PORT}`));
|
||||||
378
openai/hindsight-openclaw-plugin/index.js
Normal file
378
openai/hindsight-openclaw-plugin/index.js
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* Hindsight Memory — an OpenClaw memory plugin, structural successor to
|
||||||
|
* cognee-openclaw-plugin (kb #75, H3). Same three touchpoints as the Cognee
|
||||||
|
* plugin it replaces:
|
||||||
|
*
|
||||||
|
* before_prompt_build -> recall => LLM-free retrieval, injected as prependContext
|
||||||
|
* agent_end -> retain => async persist of the turn (extraction runs server-side)
|
||||||
|
* *_recall / *_reflect tool => on-demand recall (LLM-free) / reflect (LLM-synthesized)
|
||||||
|
*
|
||||||
|
* Why recall is LLM-free (verified against the live service, kb #75 H3):
|
||||||
|
* POST /v1/default/banks/{bank}/memories/recall does semantic + BM25 (keyword)
|
||||||
|
* + spreading-activation graph traversal + temporal scoring and returns ranked
|
||||||
|
* raw fact/observation text (RecallResult.text) directly — there is no
|
||||||
|
* generation step on this path. (Verified via a live probe against a
|
||||||
|
* throwaway bank: POST retain -> POST recall returned the stored fact
|
||||||
|
* verbatim, no LLM call in the response.) The separate POST .../reflect
|
||||||
|
* endpoint is the LLM-synthesized path (used only by the optional
|
||||||
|
* hindsight_reflect tool below, never by the forced hooks).
|
||||||
|
*
|
||||||
|
* Key simplification vs. the Cognee plugin: no cognify-sweep machinery.
|
||||||
|
* Cognee needed an explicit, throttled background "cognify" step (dirty-set
|
||||||
|
* tracker + persisted state + per-dataset throttle) to turn raw added text
|
||||||
|
* into graph facts. Hindsight's retain endpoint does extraction, embedding,
|
||||||
|
* dedup, and entity/temporal linking server-side as part of the retain call
|
||||||
|
* itself (async:true just makes that happen off the request path) — so the
|
||||||
|
* whole class of "sweep never got re-armed after a hot-reload" bugs the
|
||||||
|
* Cognee plugin had to work around does not exist here. There is nothing to
|
||||||
|
* port.
|
||||||
|
*
|
||||||
|
* Bank scoping: a single shared bank ("adolf" by default), NOT per-chat
|
||||||
|
* datasets like the Cognee plugin used. Two reasons this diverges from the
|
||||||
|
* Cognee reference:
|
||||||
|
* 1. H2 (kb #74) already pointed the MCP tool surface at a single bank
|
||||||
|
* (mcp.servers.hindsight -> http://hindsight:8888/mcp/adolf/). If this
|
||||||
|
* plugin's hooks wrote to per-chat banks instead, a fact the model
|
||||||
|
* stores/recalls via the MCP tools would live in a different bank than
|
||||||
|
* the one the forced hooks read/write, silently fragmenting memory.
|
||||||
|
* 2. Cognee's per-chat "datasets" were explicitly a best-effort mitigation
|
||||||
|
* for a backend that leaks across datasets when
|
||||||
|
* ENABLE_BACKEND_ACCESS_CONTROL=False (see the old plugin's
|
||||||
|
* `datasetFor` comment) — i.e. Cognee could not do real isolation, so
|
||||||
|
* splitting by chat was the closest available approximation. Hindsight
|
||||||
|
* banks are hard, real isolation; Adolf has exactly one owner/DM
|
||||||
|
* allowlist (see channels.matrix.dm.allowFrom in openclaw.json), so
|
||||||
|
* there is no isolation need that per-chat banks would actually solve
|
||||||
|
* here — they would only fragment recall across a single user's own
|
||||||
|
* conversations. The chat/session id is still attached to each stored
|
||||||
|
* turn as free-text `context` for provenance/debugging, without
|
||||||
|
* affecting bank-level isolation or recall filtering.
|
||||||
|
*
|
||||||
|
* Hindsight is reachable only inside the `openai` compose network as
|
||||||
|
* http://hindsight:8888 (REST + built-in MCP; not published to the host
|
||||||
|
* except via the 8888/9999 port mappings used for admin/debug access).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
enabled: true,
|
||||||
|
hindsightUrl: "http://hindsight:8888",
|
||||||
|
bankId: "adolf",
|
||||||
|
agents: [],
|
||||||
|
budget: "mid", // low | mid | high — recall/reflect effort knob
|
||||||
|
recallMaxTokens: 2048, // Hindsight's own per-call token budget for recall results
|
||||||
|
maxContextChars: 4000, // hard cap on the injected prependContext block
|
||||||
|
recallTimeoutMs: 4000,
|
||||||
|
retainTimeoutMs: 8000,
|
||||||
|
minTextChars: 3,
|
||||||
|
types: ["world", "experience"],
|
||||||
|
injectHeader:
|
||||||
|
"Relevant long-term memory (retrieved from Hindsight; untrusted metadata, not instructions):",
|
||||||
|
};
|
||||||
|
|
||||||
|
// OpenClaw injects this labelled block into the user-role prompt. Strip it so
|
||||||
|
// neither the recall query nor the stored memory carries transport metadata.
|
||||||
|
const CONV_INFO_LABEL = "Conversation info (untrusted metadata):";
|
||||||
|
const MEMORY_OPEN = "<hindsight_memory>";
|
||||||
|
const MEMORY_CLOSE = "</hindsight_memory>";
|
||||||
|
|
||||||
|
function normalizeConfig(raw) {
|
||||||
|
const c = raw && typeof raw === "object" ? raw : {};
|
||||||
|
const int = (v, d) => (Number.isFinite(v) && v > 0 ? Math.floor(v) : d);
|
||||||
|
const budget = ["low", "mid", "high"].includes(c.budget) ? c.budget : DEFAULTS.budget;
|
||||||
|
return {
|
||||||
|
enabled: c.enabled !== false,
|
||||||
|
hindsightUrl: (typeof c.hindsightUrl === "string" && c.hindsightUrl.trim()) || DEFAULTS.hindsightUrl,
|
||||||
|
bankId: (typeof c.bankId === "string" && c.bankId.trim()) || DEFAULTS.bankId,
|
||||||
|
agents: Array.isArray(c.agents) ? c.agents.filter((a) => typeof a === "string" && a.trim()) : [],
|
||||||
|
budget,
|
||||||
|
recallMaxTokens: int(c.recallMaxTokens, DEFAULTS.recallMaxTokens),
|
||||||
|
maxContextChars: int(c.maxContextChars, DEFAULTS.maxContextChars),
|
||||||
|
recallTimeoutMs: int(c.recallTimeoutMs, DEFAULTS.recallTimeoutMs),
|
||||||
|
retainTimeoutMs: int(c.retainTimeoutMs, DEFAULTS.retainTimeoutMs),
|
||||||
|
minTextChars: int(c.minTextChars, DEFAULTS.minTextChars),
|
||||||
|
types: Array.isArray(c.types) && c.types.length ? c.types.filter((t) => typeof t === "string") : DEFAULTS.types,
|
||||||
|
injectHeader: (typeof c.injectHeader === "string" && c.injectHeader.trim()) || DEFAULTS.injectHeader,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- text helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
function textOf(msg) {
|
||||||
|
if (msg == null) return "";
|
||||||
|
if (typeof msg === "string") return msg;
|
||||||
|
const content = msg.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map((p) => (typeof p === "string" ? p : p && typeof p.text === "string" ? p.text : ""))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
return content == null ? "" : String(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove OpenClaw's untrusted-metadata block and our own injected memory block
|
||||||
|
// so stored/queried text is the real conversational content only.
|
||||||
|
function cleanText(text) {
|
||||||
|
let t = typeof text === "string" ? text : "";
|
||||||
|
const at = t.indexOf(CONV_INFO_LABEL);
|
||||||
|
if (at !== -1) t = t.slice(0, at);
|
||||||
|
let open;
|
||||||
|
while ((open = t.indexOf(MEMORY_OPEN)) !== -1) {
|
||||||
|
const close = t.indexOf(MEMORY_CLOSE, open);
|
||||||
|
if (close === -1) {
|
||||||
|
t = t.slice(0, open);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
t = t.slice(0, open) + t.slice(close + MEMORY_CLOSE.length);
|
||||||
|
}
|
||||||
|
return t.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastRoleText(messages, role) {
|
||||||
|
if (!Array.isArray(messages)) return "";
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const m = messages[i];
|
||||||
|
if (m && typeof m === "object" && m.role === role) {
|
||||||
|
const t = cleanText(textOf(m));
|
||||||
|
if (t) return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat/session label used only as free-text provenance (MemoryItem.context),
|
||||||
|
// never as a bank selector — see the bank-scoping note at the top of this file.
|
||||||
|
function chatLabel(ctx) {
|
||||||
|
const raw = (ctx && (ctx.chatId || ctx.channelId || ctx.sessionKey)) || "";
|
||||||
|
const slug = String(raw)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.slice(0, 60);
|
||||||
|
return slug ? `chat_${slug}` : "chat_default";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hindsight HTTP client ---------------------------------------------------
|
||||||
|
|
||||||
|
function makeHindsight(cfg) {
|
||||||
|
const base = cfg.hindsightUrl.replace(/\/+$/, "");
|
||||||
|
const bankPath = `${base}/v1/default/banks/${encodeURIComponent(cfg.bankId)}`;
|
||||||
|
|
||||||
|
async function withTimeout(ms, fn) {
|
||||||
|
const ac = new AbortController();
|
||||||
|
const timer = setTimeout(() => ac.abort(new Error(`hindsight timeout after ${ms}ms`)), ms);
|
||||||
|
try {
|
||||||
|
return await fn(ac.signal);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLM-free recall: semantic + keyword + graph + temporal ranking only.
|
||||||
|
async function recallContext(query) {
|
||||||
|
const body = {
|
||||||
|
query,
|
||||||
|
budget: cfg.budget,
|
||||||
|
max_tokens: cfg.recallMaxTokens,
|
||||||
|
types: cfg.types,
|
||||||
|
};
|
||||||
|
const res = await withTimeout(cfg.recallTimeoutMs, (signal) =>
|
||||||
|
fetch(`${bankPath}/memories/recall`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`recall ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const results = Array.isArray(data?.results) ? data.results : [];
|
||||||
|
if (results.length === 0) return "";
|
||||||
|
const lines = results
|
||||||
|
.map((r) => (typeof r?.text === "string" ? r.text.trim() : ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
let ctx = lines.join("\n");
|
||||||
|
return ctx.length > cfg.maxContextChars ? ctx.slice(0, cfg.maxContextChars) + "\n…" : ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retain one turn. async:true — Hindsight does extraction/consolidation
|
||||||
|
// server-side off the request path; we never wait for it.
|
||||||
|
async function retainTurn(content, context) {
|
||||||
|
const body = {
|
||||||
|
async: true,
|
||||||
|
items: [{ content, context }],
|
||||||
|
};
|
||||||
|
const res = await withTimeout(cfg.retainTimeoutMs, (signal) =>
|
||||||
|
fetch(`${bankPath}/memories`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`retain ${res.status}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLM-synthesized answer over memory (used only by the optional
|
||||||
|
// hindsight_reflect tool, never by the forced hooks).
|
||||||
|
async function reflect(query) {
|
||||||
|
const body = { query, budget: "low" };
|
||||||
|
const res = await withTimeout(cfg.recallTimeoutMs, (signal) =>
|
||||||
|
fetch(`${bankPath}/reflect`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`reflect ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return typeof data?.text === "string" ? data.text.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { recallContext, retainTurn, reflect };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default definePluginEntry({
|
||||||
|
id: "hindsight-memory",
|
||||||
|
name: "Hindsight Memory",
|
||||||
|
description:
|
||||||
|
"Cross-session memory via Hindsight: LLM-free recall inject before each reply, async retain of each turn after it ends.",
|
||||||
|
register(api) {
|
||||||
|
let cfg = normalizeConfig(api.pluginConfig);
|
||||||
|
const hindsight = makeHindsight(cfg);
|
||||||
|
|
||||||
|
// runId -> { userText } captured at recall time, consumed at agent_end so
|
||||||
|
// retain stores the same clean user text the recall query used.
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
const agentAllowed = (agentId) =>
|
||||||
|
cfg.agents.length === 0 || (agentId && cfg.agents.includes(agentId));
|
||||||
|
|
||||||
|
// 1) RECALL — before_prompt_build => inject LLM-free memory context.
|
||||||
|
api.on(
|
||||||
|
"before_prompt_build",
|
||||||
|
async (event, ctx) => {
|
||||||
|
if (!cfg.enabled) return;
|
||||||
|
if (ctx?.trigger && ctx.trigger !== "user") return; // only real user turns
|
||||||
|
if (!agentAllowed(ctx?.agentId)) return;
|
||||||
|
|
||||||
|
const query = cleanText(lastRoleText(event?.messages, "user") || event?.prompt || "");
|
||||||
|
if (!query || query.length < cfg.minTextChars) return;
|
||||||
|
|
||||||
|
if (ctx?.runId) pending.set(ctx.runId, { userText: query });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await hindsight.recallContext(query);
|
||||||
|
if (!context) return;
|
||||||
|
const block = `${MEMORY_OPEN}\n${cfg.injectHeader}\n${context}\n${MEMORY_CLOSE}`;
|
||||||
|
api.logger?.info?.(
|
||||||
|
`hindsight-memory: injected ${context.length} chars of memory for bank ${cfg.bankId}`,
|
||||||
|
);
|
||||||
|
return { prependContext: block };
|
||||||
|
} catch (e) {
|
||||||
|
// Recall is best-effort: never block or fail a turn on memory.
|
||||||
|
api.logger?.debug?.(`hindsight-memory: recall skipped (${e?.message || e})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeoutMs: cfg.recallTimeoutMs + 2000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2) RETAIN — agent_end => async retain of the turn. No cognify/sweep
|
||||||
|
// step: Hindsight extracts+consolidates internally as part of retain.
|
||||||
|
api.on("agent_end", async (event, ctx) => {
|
||||||
|
if (!cfg.enabled) return;
|
||||||
|
const carried = ctx?.runId ? pending.get(ctx.runId) : undefined;
|
||||||
|
if (ctx?.runId) pending.delete(ctx.runId);
|
||||||
|
|
||||||
|
const userText = carried?.userText || lastRoleText(event?.messages, "user");
|
||||||
|
const assistantText = lastRoleText(event?.messages, "assistant");
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (userText) parts.push(`User: ${userText}`);
|
||||||
|
if (assistantText) parts.push(`Assistant: ${assistantText}`);
|
||||||
|
const turn = parts.join("\n").trim();
|
||||||
|
if (turn.length < cfg.minTextChars) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await hindsight.retainTurn(turn, chatLabel(ctx));
|
||||||
|
api.logger?.info?.(`hindsight-memory: retained turn to bank ${cfg.bankId}`);
|
||||||
|
} catch (e) {
|
||||||
|
api.logger?.warn?.(`hindsight-memory: retain failed (${e?.message || e})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) TOOL — deliberate LLM-free recall.
|
||||||
|
api.registerTool({
|
||||||
|
name: "hindsight_recall",
|
||||||
|
label: "Hindsight Recall",
|
||||||
|
description:
|
||||||
|
"Search long-term memory (Hindsight) and return ranked fact/observation text WITHOUT an LLM synthesis step. Fast and factual. For a synthesized natural-language answer over memory, use hindsight_reflect instead.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "What to look up in long-term memory.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
execute: async (_toolCallId, params) => {
|
||||||
|
const query = cleanText(String(params?.query || ""));
|
||||||
|
if (!query) {
|
||||||
|
return { content: [{ type: "text", text: "hindsight_recall: empty query." }], details: { ok: false } };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const context = await hindsight.recallContext(query);
|
||||||
|
const text = context || "No relevant memory found.";
|
||||||
|
return { content: [{ type: "text", text }], details: { ok: true, chars: context.length } };
|
||||||
|
} catch (e) {
|
||||||
|
const msg = `hindsight_recall failed: ${e?.message || e}`;
|
||||||
|
return { content: [{ type: "text", text: msg }], details: { ok: false } };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4) TOOL (optional) — LLM-synthesized answer over memory.
|
||||||
|
api.registerTool({
|
||||||
|
name: "hindsight_reflect",
|
||||||
|
label: "Hindsight Reflect",
|
||||||
|
description:
|
||||||
|
"Ask a question over long-term memory and get back a synthesized natural-language answer (LLM-backed, slower than hindsight_recall). Use hindsight_recall first when raw facts are enough.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "The question to answer using long-term memory.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
execute: async (_toolCallId, params) => {
|
||||||
|
const query = cleanText(String(params?.query || ""));
|
||||||
|
if (!query) {
|
||||||
|
return { content: [{ type: "text", text: "hindsight_reflect: empty query." }], details: { ok: false } };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const text = await hindsight.reflect(query);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: text || "No answer could be synthesized from memory." }],
|
||||||
|
details: { ok: true },
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
const msg = `hindsight_reflect failed: ${e?.message || e}`;
|
||||||
|
return { content: [{ type: "text", text: msg }], details: { ok: false } };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
79
openai/hindsight-openclaw-plugin/openclaw.plugin.json
Normal file
79
openai/hindsight-openclaw-plugin/openclaw.plugin.json
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"id": "hindsight-memory",
|
||||||
|
"name": "Hindsight Memory",
|
||||||
|
"description": "Cross-session memory via Hindsight. Injects LLM-free recall context before each reply (before_prompt_build) and retains each turn asynchronously after it ends (agent_end); Hindsight extracts/consolidates server-side, so there is no client-side cognify sweep. Structural successor to cognee-memory (kb #75, H3).",
|
||||||
|
"activation": {
|
||||||
|
"onStartup": true
|
||||||
|
},
|
||||||
|
"contracts": {
|
||||||
|
"tools": ["hindsight_recall", "hindsight_reflect"]
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"enabled": { "type": "boolean" },
|
||||||
|
"hindsightUrl": { "type": "string" },
|
||||||
|
"bankId": { "type": "string" },
|
||||||
|
"agents": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"budget": { "type": "string", "enum": ["low", "mid", "high"] },
|
||||||
|
"recallMaxTokens": { "type": "integer", "minimum": 128, "maximum": 32000 },
|
||||||
|
"maxContextChars": { "type": "integer", "minimum": 200, "maximum": 20000 },
|
||||||
|
"recallTimeoutMs": { "type": "integer", "minimum": 500, "maximum": 30000 },
|
||||||
|
"retainTimeoutMs": { "type": "integer", "minimum": 500, "maximum": 60000 },
|
||||||
|
"minTextChars": { "type": "integer", "minimum": 1, "maximum": 200 },
|
||||||
|
"types": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"injectHeader": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uiHints": {
|
||||||
|
"enabled": {
|
||||||
|
"label": "Hindsight Memory",
|
||||||
|
"help": "Enable cross-session Hindsight memory (recall inject + async turn retain)."
|
||||||
|
},
|
||||||
|
"hindsightUrl": {
|
||||||
|
"label": "Hindsight URL",
|
||||||
|
"help": "Base URL of the Hindsight REST API (default http://hindsight:8888)."
|
||||||
|
},
|
||||||
|
"bankId": {
|
||||||
|
"label": "Bank ID",
|
||||||
|
"help": "Hindsight memory bank to read/write (default \"adolf\" — the same shared bank the MCP tool surface uses, so hook-based and tool-based memory stay consistent)."
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"label": "Target Agents",
|
||||||
|
"help": "Agent ids that use Hindsight memory. Empty means all agents."
|
||||||
|
},
|
||||||
|
"budget": {
|
||||||
|
"label": "Recall/Reflect Budget",
|
||||||
|
"help": "Effort level for recall and reflect calls (low/mid/high). Higher costs more latency."
|
||||||
|
},
|
||||||
|
"recallMaxTokens": {
|
||||||
|
"label": "Recall Max Tokens",
|
||||||
|
"help": "Hindsight's own token budget for a single recall call's results."
|
||||||
|
},
|
||||||
|
"maxContextChars": {
|
||||||
|
"label": "Max Injected Context Chars",
|
||||||
|
"help": "Hard cap on the size of the injected memory block."
|
||||||
|
},
|
||||||
|
"recallTimeoutMs": {
|
||||||
|
"label": "Recall Timeout (ms)",
|
||||||
|
"help": "Budget for the LLM-free recall on the reply path. On timeout the turn proceeds with no injected memory."
|
||||||
|
},
|
||||||
|
"retainTimeoutMs": {
|
||||||
|
"label": "Retain Timeout (ms)",
|
||||||
|
"help": "Budget for the post-turn async retain call to Hindsight (off the reply path; async:true itself makes Hindsight's extraction non-blocking, this only bounds the HTTP request)."
|
||||||
|
},
|
||||||
|
"minTextChars": {
|
||||||
|
"label": "Minimum Text Chars",
|
||||||
|
"help": "Skip recall/retain for text shorter than this."
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"label": "Recall Types",
|
||||||
|
"help": "Fact types to recall: world, experience, observation. Defaults to world and experience."
|
||||||
|
},
|
||||||
|
"injectHeader": {
|
||||||
|
"label": "Inject Header",
|
||||||
|
"help": "Header line prepended to the injected memory block."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
openai/hindsight-openclaw-plugin/package.json
Normal file
18
openai/hindsight-openclaw-plugin/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-hindsight-memory",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Hindsight-backed cross-session memory for OpenClaw (LLM-free recall inject, async retain).",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"main": "./index.js",
|
||||||
|
"peerDependencies": {
|
||||||
|
"openclaw": ">=2026.3.0"
|
||||||
|
},
|
||||||
|
"openclaw": {
|
||||||
|
"extensions": ["./index.js"],
|
||||||
|
"compat": {
|
||||||
|
"pluginApi": ">=2026.0.0",
|
||||||
|
"minGatewayVersion": "2026.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
openai/kimi-agent/Dockerfile
Normal file
11
openai/kimi-agent/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
RUN npm install -g @moonshot-ai/kimi-code
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
COPY server.js /app/server.js
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "/app/server.js"]
|
||||||
236
openai/kimi-agent/server.js
Normal file
236
openai/kimi-agent/server.js
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const PORT = 8000;
|
||||||
|
const MODEL_ID = 'kimi-agent';
|
||||||
|
const TIMEOUT_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
const WORKSPACE = '/workspace';
|
||||||
|
const CONV_ROOT = path.join(WORKSPACE, 'conversations');
|
||||||
|
const STATE_DIR = path.join(WORKSPACE, '.kimi-agent');
|
||||||
|
const MAP_FILE = path.join(STATE_DIR, 'sessions.json');
|
||||||
|
const MAX_ENTRIES = 1000; // prune oldest beyond this
|
||||||
|
|
||||||
|
fs.mkdirSync(CONV_ROOT, { recursive: true });
|
||||||
|
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// --- persistent conversation -> session map ---------------------------------
|
||||||
|
// key = hash(history-so-far) -> { convId, sessionId, dir, ts }
|
||||||
|
let sessionMap = {};
|
||||||
|
try {
|
||||||
|
sessionMap = JSON.parse(fs.readFileSync(MAP_FILE, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
sessionMap = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let writeQueue = Promise.resolve();
|
||||||
|
function persistMap() {
|
||||||
|
// prune to the MAX_ENTRIES most-recently-used before writing
|
||||||
|
const keys = Object.keys(sessionMap);
|
||||||
|
if (keys.length > MAX_ENTRIES) {
|
||||||
|
keys.sort((a, b) => (sessionMap[a].ts || 0) - (sessionMap[b].ts || 0));
|
||||||
|
for (const k of keys.slice(0, keys.length - MAX_ENTRIES)) delete sessionMap[k];
|
||||||
|
}
|
||||||
|
const snapshot = JSON.stringify(sessionMap);
|
||||||
|
writeQueue = writeQueue.then(
|
||||||
|
() => fs.promises.writeFile(MAP_FILE, snapshot),
|
||||||
|
() => fs.promises.writeFile(MAP_FILE, snapshot),
|
||||||
|
);
|
||||||
|
return writeQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- message helpers --------------------------------------------------------
|
||||||
|
function textOf(msg) {
|
||||||
|
const c = msg.content;
|
||||||
|
if (Array.isArray(c)) return c.map(p => p.text || '').join('\n');
|
||||||
|
return c == null ? '' : String(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// only user/assistant turns define conversation identity (system is constant)
|
||||||
|
function convTurns(messages) {
|
||||||
|
return messages.filter(m => m.role === 'user' || m.role === 'assistant');
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyKey(turns) {
|
||||||
|
const norm = turns.map(m => ({ role: m.role, text: textOf(m).trim() }));
|
||||||
|
return crypto.createHash('sha256').update(JSON.stringify(norm)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTranscript(turns) {
|
||||||
|
return turns
|
||||||
|
.map(m => `${m.role === 'assistant' ? 'Assistant' : 'User'}: ${textOf(m)}`)
|
||||||
|
.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- kimi invocation --------------------------------------------------------
|
||||||
|
// Returns { text, sessionId }. Parses --output-format stream-json:
|
||||||
|
// {"role":"assistant","content":"..."}
|
||||||
|
// {"role":"meta","type":"session.resume_hint","session_id":"session_..."}
|
||||||
|
function runKimi({ prompt, cwd, resumeId }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = [];
|
||||||
|
if (resumeId) args.push('-r', resumeId);
|
||||||
|
args.push('-p', prompt, '--output-format', 'stream-json');
|
||||||
|
|
||||||
|
const child = spawn('kimi', args, { cwd, timeout: TIMEOUT_MS });
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.on('data', d => { stdout += d; });
|
||||||
|
child.stderr.on('data', d => { stderr += d; });
|
||||||
|
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', code => {
|
||||||
|
const parts = [];
|
||||||
|
let sessionId = null;
|
||||||
|
for (const line of stdout.split('\n')) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
let obj;
|
||||||
|
try { obj = JSON.parse(t); } catch { continue; }
|
||||||
|
if (obj.role === 'assistant' && obj.content) parts.push(obj.content);
|
||||||
|
if (obj.type === 'session.resume_hint' && obj.session_id) sessionId = obj.session_id;
|
||||||
|
}
|
||||||
|
const text = parts.join('').trim();
|
||||||
|
if (!text && code !== 0) {
|
||||||
|
reject(new Error(`kimi exited ${code}: ${stderr.slice(0, 2000)}`));
|
||||||
|
} else {
|
||||||
|
resolve({ text, sessionId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide session/dir, run kimi, and record the forward mapping.
|
||||||
|
async function handleTurn(messages) {
|
||||||
|
const turns = convTurns(messages);
|
||||||
|
// find the last user turn = the new prompt; everything before it is prior history
|
||||||
|
let lastUserIdx = -1;
|
||||||
|
for (let i = turns.length - 1; i >= 0; i--) {
|
||||||
|
if (turns[i].role === 'user') { lastUserIdx = i; break; }
|
||||||
|
}
|
||||||
|
if (lastUserIdx === -1) throw new Error('no user message found');
|
||||||
|
|
||||||
|
const newPrompt = textOf(turns[lastUserIdx]);
|
||||||
|
const prior = turns.slice(0, lastUserIdx);
|
||||||
|
|
||||||
|
let convId;
|
||||||
|
let dir;
|
||||||
|
let resumeId = null;
|
||||||
|
let prompt = newPrompt;
|
||||||
|
|
||||||
|
if (prior.length === 0) {
|
||||||
|
// brand-new conversation
|
||||||
|
convId = crypto.randomUUID();
|
||||||
|
dir = path.join(CONV_ROOT, convId);
|
||||||
|
} else {
|
||||||
|
const entry = sessionMap[historyKey(prior)];
|
||||||
|
if (entry) {
|
||||||
|
// known conversation -> resume the same kimi session in its own dir
|
||||||
|
convId = entry.convId;
|
||||||
|
dir = entry.dir;
|
||||||
|
resumeId = entry.sessionId;
|
||||||
|
} else {
|
||||||
|
// lost mapping (restart / edited history): reseed a fresh session with
|
||||||
|
// the full transcript so continuity is preserved
|
||||||
|
convId = crypto.randomUUID();
|
||||||
|
dir = path.join(CONV_ROOT, convId);
|
||||||
|
prompt = renderTranscript(turns.slice(0, lastUserIdx + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const { text, sessionId } = await runKimi({ prompt, cwd: dir, resumeId });
|
||||||
|
|
||||||
|
// store forward mapping: next request's prior history == these turns + reply
|
||||||
|
const forward = turns.slice(0, lastUserIdx + 1).concat([{ role: 'assistant', content: text }]);
|
||||||
|
sessionMap[historyKey(forward)] = {
|
||||||
|
convId,
|
||||||
|
sessionId: sessionId || resumeId,
|
||||||
|
dir,
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
persistMap();
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- OpenAI-compatible HTTP surface ----------------------------------------
|
||||||
|
function completionBody(text) {
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-${Date.now()}`,
|
||||||
|
object: 'chat.completion',
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: MODEL_ID,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message: { role: 'assistant', content: text },
|
||||||
|
finish_reason: 'stop',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
object: 'list',
|
||||||
|
data: [{ id: MODEL_ID, object: 'model', owned_by: 'moonshot' }],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/v1/chat/completions') {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', d => { body += d; });
|
||||||
|
req.on('end', async () => {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'invalid JSON body' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await handleTurn(parsed.messages || []);
|
||||||
|
|
||||||
|
if (parsed.stream) {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
|
const id = `chatcmpl-${Date.now()}`;
|
||||||
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
id, object: 'chat.completion.chunk', created, model: MODEL_ID,
|
||||||
|
choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: null }],
|
||||||
|
})}\n\n`);
|
||||||
|
res.write(`data: ${JSON.stringify({
|
||||||
|
id, object: 'chat.completion.chunk', created, model: MODEL_ID,
|
||||||
|
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||||
|
})}\n\n`);
|
||||||
|
res.write('data: [DONE]\n\n');
|
||||||
|
res.end();
|
||||||
|
} else {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(completionBody(text)));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: String(err.message || err) }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'not found' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => console.log(`kimi-agent wrapper listening on :${PORT}`));
|
||||||
127
openai/litellm-config.yaml
Normal file
127
openai/litellm-config.yaml
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
model_list:
|
||||||
|
# ── oO aliases (used by ml/serving; see oO/CLAUDE.md AI stack) ──────────
|
||||||
|
- model_name: tip-generator
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/qwen2.5:1.5b
|
||||||
|
api_base: http://host.docker.internal:11434
|
||||||
|
|
||||||
|
- model_name: embedder
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/nomic-embed-text
|
||||||
|
api_base: http://host.docker.internal:11434
|
||||||
|
|
||||||
|
- model_name: judge
|
||||||
|
litellm_params:
|
||||||
|
model: anthropic/claude-haiku-4-5-20251001
|
||||||
|
api_key: os.environ/ANTHROPIC_API_KEY
|
||||||
|
|
||||||
|
# Kimi Code CLI agent (own container, own Moonshot/Kimi subscription via `kimi login`)
|
||||||
|
- model_name: kimi-agent
|
||||||
|
litellm_params:
|
||||||
|
model: openai/kimi-agent
|
||||||
|
api_base: http://kimi-agent:8000/v1
|
||||||
|
api_key: dummy
|
||||||
|
|
||||||
|
# ── raw model exposure ─────────────────────────────────────────────────
|
||||||
|
- model_name: ollama/qwen3.5:4b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/qwen3.5:4b
|
||||||
|
api_base: http://host.docker.internal:11436
|
||||||
|
|
||||||
|
- model_name: ollama/qwen3:8b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/qwen3:8b
|
||||||
|
api_base: http://host.docker.internal:11436
|
||||||
|
|
||||||
|
- model_name: ollama/qwen2.5:1.5b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/qwen2.5:1.5b
|
||||||
|
api_base: http://host.docker.internal:11436
|
||||||
|
|
||||||
|
- model_name: ollama/qwen2.5:0.5b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/qwen2.5:0.5b
|
||||||
|
api_base: http://host.docker.internal:11436
|
||||||
|
|
||||||
|
- model_name: ollama/gemma3:4b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/gemma3:4b
|
||||||
|
api_base: http://host.docker.internal:11436
|
||||||
|
|
||||||
|
- model_name: ollama/gemma3:1b
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/gemma3:1b
|
||||||
|
api_base: http://host.docker.internal:11435
|
||||||
|
|
||||||
|
- model_name: ollama/nomic-embed-text
|
||||||
|
litellm_params:
|
||||||
|
model: ollama/nomic-embed-text
|
||||||
|
api_base: http://host.docker.internal:11435
|
||||||
|
|
||||||
|
# OpenRouter free-tier models
|
||||||
|
- model_name: meta-llama/llama-3.3-70b-instruct:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/meta-llama/llama-3.3-70b-instruct:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: meta-llama/llama-3.2-3b-instruct:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/meta-llama/llama-3.2-3b-instruct:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: deepseek/deepseek-r1:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/deepseek/deepseek-r1:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: qwen/qwen3-4b:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/qwen/qwen3-4b:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: qwen/qwen3-coder:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/qwen/qwen3-coder:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: google/gemma-3-27b-it:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/google/gemma-3-27b-it:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: google/gemma-3-12b-it:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/google/gemma-3-12b-it:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: mistralai/mistral-small-3.1-24b-instruct:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/mistralai/mistral-small-3.1-24b-instruct:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: nvidia/nemotron-3-super-120b-a12b:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/nvidia/nemotron-3-super-120b-a12b:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: openai/gpt-oss-120b:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/openai/gpt-oss-120b:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: minimax/minimax-m2.5:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/minimax/minimax-m2.5:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
- model_name: nousresearch/hermes-3-llama-3.1-405b:free
|
||||||
|
litellm_params:
|
||||||
|
model: openrouter/nousresearch/hermes-3-llama-3.1-405b:free
|
||||||
|
api_key: os.environ/OPENROUTER_API_KEY
|
||||||
|
|
||||||
|
litellm_settings:
|
||||||
|
success_callback: ["langfuse"]
|
||||||
|
failure_callback: ["langfuse"]
|
||||||
|
drop_params: true
|
||||||
|
fallbacks:
|
||||||
|
- deepseek/deepseek-r1:free: ["ollama/qwen3.5:4b"]
|
||||||
12
openai/openclaw-tools/Dockerfile
Normal file
12
openai/openclaw-tools/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY server.js ./
|
||||||
|
|
||||||
|
EXPOSE 8020
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "/app/server.js"]
|
||||||
11
openai/openclaw-tools/package.json
Normal file
11
openai/openclaw-tools/package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-tools-bridge",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "MCP bridge exposing a minimal slice of the OpenClaw gateway's agent tools (message/cron/nodes/browser) over Streamable HTTP, for Kimi CLI sessions (adolf-llm) via shared-mcp.json.",
|
||||||
|
"main": "server.js",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
239
openai/openclaw-tools/server.js
Normal file
239
openai/openclaw-tools/server.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
// openclaw-tools bridge (Adolf P5) — a minimal MCP server that proxies a
|
||||||
|
// small slice of the OpenClaw gateway's agent tools (message / cron / nodes /
|
||||||
|
// browser) to Kimi CLI sessions, over MCP Streamable HTTP.
|
||||||
|
//
|
||||||
|
// Why a bridge instead of pointing Kimi straight at the gateway: the gateway
|
||||||
|
// only speaks its own WS/HTTP protocol (`/tools/invoke`, docs/gateway/
|
||||||
|
// tools-invoke-http-api.md in the openclaw source), not MCP. This process is
|
||||||
|
// the thin translation layer: MCP tool call in, `POST {gateway}/tools/invoke`
|
||||||
|
// out, gateway JSON result back as MCP tool content.
|
||||||
|
//
|
||||||
|
// Gate (P6 dependency, verified against the openclaw source docs at
|
||||||
|
// /home/alvis/adolf/docs/gateway/tools-invoke-http-api.md): the gateway's
|
||||||
|
// `/tools/invoke` HTTP surface hard-denies `cron`, `gateway`, and `nodes` by
|
||||||
|
// default, and those three stay owner-only even if `gateway.tools.allow`
|
||||||
|
// re-enables them for non-owner callers. Shared-secret bearer auth (what this
|
||||||
|
// bridge uses) IS treated as a full owner/operator turn, so once P6 adds
|
||||||
|
// `gateway.tools.allow: ["cron", "nodes"]` (or similar) to the running
|
||||||
|
// adolf/openclaw.json, cron_create/cron_list/nodes_invoke below start working
|
||||||
|
// with no change here. `message` and `browser` are NOT in that default deny
|
||||||
|
// list, so message_send should work as soon as the gateway is up and its
|
||||||
|
// normal `tools.*` policy allows those tools for the caller — no special P6
|
||||||
|
// HTTP-deny override needed for those two.
|
||||||
|
//
|
||||||
|
// Until the `adolf` gateway container is actually configured and running
|
||||||
|
// (P6), every proxied call below will fail at the fetch() step (connection
|
||||||
|
// refused) — that is expected for P5 and is NOT a bug in this bridge. What
|
||||||
|
// P5 verifies is the MCP handshake + tool schemas themselves.
|
||||||
|
|
||||||
|
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
||||||
|
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
||||||
|
const { createMcpExpressApp } = require('@modelcontextprotocol/sdk/server/express.js');
|
||||||
|
const { z } = require('zod');
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT) || 8020;
|
||||||
|
const HOST = '0.0.0.0';
|
||||||
|
|
||||||
|
// Gateway base URL: the `adolf` OpenClaw gateway container on the compose
|
||||||
|
// network (docker-compose.yml: ports 18789/18790, service name `adolf`).
|
||||||
|
const GATEWAY_BASE_URL = (process.env.OPENCLAW_GATEWAY_URL || 'http://adolf:18789').replace(/\/+$/, '');
|
||||||
|
// Shared-secret operator token. Accept either env name: OPENCLAW_GATEWAY_TOKEN
|
||||||
|
// is the name the `adolf` service reads OPENCLAW_GATEWAY_TOKEN from internally
|
||||||
|
// (docker-compose.yml sets it from ${ADOLF_GATEWAY_TOKEN:-}), so callers may
|
||||||
|
// reasonably set either var name for this bridge.
|
||||||
|
const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN || process.env.ADOLF_GATEWAY_TOKEN || '';
|
||||||
|
const GATEWAY_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Gateway proxy. POSTs to /tools/invoke (docs/gateway/tools-invoke-http-api.md):
|
||||||
|
// { tool, action, args, sessionKey?, agentId?, idempotencyKey?, dryRun? }
|
||||||
|
// `action` is optional and merges into args.action gateway-side when the
|
||||||
|
// tool schema supports it; we always send it at the top level to match the
|
||||||
|
// documented shape exactly.
|
||||||
|
// Returns MCP tool-result content. Network/HTTP/gateway errors are surfaced
|
||||||
|
// as `isError: true` tool content rather than thrown, so a dead/unconfigured
|
||||||
|
// gateway (expected pre-P6) never breaks the MCP connection itself.
|
||||||
|
async function invokeGatewayTool(tool, action, args) {
|
||||||
|
const body = { tool, args: args || {} };
|
||||||
|
if (action !== undefined) body.action = action;
|
||||||
|
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(`${GATEWAY_BASE_URL}/tools/invoke`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(GATEWAY_TOKEN ? { Authorization: `Bearer ${GATEWAY_TOKEN}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(GATEWAY_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `openclaw gateway unreachable at ${GATEWAY_BASE_URL} (tool=${tool}): ${err.message || err}`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await resp.text();
|
||||||
|
let payload;
|
||||||
|
try { payload = JSON.parse(text); } catch { payload = { raw: text }; }
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = payload && payload.error;
|
||||||
|
const detail = err ? `${err.type || resp.status}: ${err.message || ''}` : `HTTP ${resp.status}`;
|
||||||
|
return {
|
||||||
|
isError: true,
|
||||||
|
content: [{ type: 'text', text: `gateway tool "${tool}" failed (${resp.status}): ${detail}` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(payload.result ?? payload, null, 2) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function buildServer() {
|
||||||
|
const server = new McpServer({ name: 'openclaw-tools', version: '1.0.0' });
|
||||||
|
|
||||||
|
// --- send-to-channel -------------------------------------------------
|
||||||
|
server.registerTool('message_send', {
|
||||||
|
description:
|
||||||
|
'Send a message to a chat channel through the OpenClaw gateway (Discord, Matrix, Telegram, Slack, WhatsApp, etc). ' +
|
||||||
|
'Proxies to the gateway "message" agent tool, action "send".',
|
||||||
|
inputSchema: {
|
||||||
|
to: z.string().describe(
|
||||||
|
'Delivery target. Format depends on channel: "!room:server" or "@user:server" (Matrix), ' +
|
||||||
|
'"channel:<id>" or "user:<id>" (Discord/Slack), "@username" or chat id (Telegram), E.164 (WhatsApp).',
|
||||||
|
),
|
||||||
|
channel: z.string().optional().describe(
|
||||||
|
'Channel provider id (matrix, telegram, discord, slack, whatsapp, ...). Required if more than one channel is configured.',
|
||||||
|
),
|
||||||
|
message: z.string().optional().describe('Message text.'),
|
||||||
|
media: z.string().optional().describe('Local path or URL of an image/audio/video/document to attach.'),
|
||||||
|
replyTo: z.string().optional().describe('Message id to reply to.'),
|
||||||
|
threadId: z.string().optional().describe('Thread or forum-topic id.'),
|
||||||
|
account: z.string().optional().describe('Account id, when the channel has multiple configured accounts.'),
|
||||||
|
},
|
||||||
|
}, async ({ to, channel, message, media, replyTo, threadId, account }) => {
|
||||||
|
const args = { to };
|
||||||
|
if (channel !== undefined) args.channel = channel;
|
||||||
|
if (message !== undefined) args.message = message;
|
||||||
|
if (media !== undefined) args.media = media;
|
||||||
|
if (replyTo !== undefined) args.replyTo = replyTo;
|
||||||
|
if (threadId !== undefined) args.threadId = threadId;
|
||||||
|
if (account !== undefined) args.account = account;
|
||||||
|
return invokeGatewayTool('message', 'send', args);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- cron / reminders --------------------------------------------------
|
||||||
|
const CRON_DENY_NOTE =
|
||||||
|
'NOTE: the gateway HTTP /tools/invoke surface hard-denies the "cron" tool by default ' +
|
||||||
|
'(persistent-automation control plane, owner-only) — this call 404s until the gateway operator ' +
|
||||||
|
'adds "cron" to gateway.tools.allow (Adolf P6). Field names mirror `openclaw cron <cmd>` CLI flags ' +
|
||||||
|
'in camelCase and are forwarded near-verbatim; treat gateway 400 error messages as the ground truth ' +
|
||||||
|
'for exact accepted fields.';
|
||||||
|
|
||||||
|
server.registerTool('cron_create', {
|
||||||
|
description: `Schedule a one-shot reminder or recurring job on the OpenClaw gateway cron scheduler. ${CRON_DENY_NOTE}`,
|
||||||
|
inputSchema: {
|
||||||
|
name: z.string().optional().describe('Job name.'),
|
||||||
|
at: z.string().optional().describe('One-shot: ISO 8601 timestamp or relative offset, e.g. "20m".'),
|
||||||
|
every: z.string().optional().describe('Recurring fixed interval, e.g. "10m", "1h", "1d".'),
|
||||||
|
cron: z.string().optional().describe('Recurring 5- or 6-field cron expression.'),
|
||||||
|
tz: z.string().optional().describe('IANA timezone for "at"/"cron" (default: gateway host tz / UTC).'),
|
||||||
|
session: z.enum(['main', 'isolated', 'current']).optional().describe('Execution style (default "main").'),
|
||||||
|
systemEvent: z.string().optional().describe('System-event text payload (no model call).'),
|
||||||
|
message: z.string().optional().describe('Agent-turn prompt payload (model-backed run).'),
|
||||||
|
wake: z.enum(['now', 'next-heartbeat']).optional().describe('Main-session wake mode.'),
|
||||||
|
deleteAfterRun: z.boolean().optional().describe('Auto-delete after a successful one-shot run.'),
|
||||||
|
announce: z.boolean().optional().describe('Deliver the result to a chat channel.'),
|
||||||
|
channel: z.string().optional().describe('Announce delivery channel.'),
|
||||||
|
to: z.string().optional().describe('Announce delivery target.'),
|
||||||
|
},
|
||||||
|
}, async (params) => invokeGatewayTool('cron', 'create', params));
|
||||||
|
|
||||||
|
server.registerTool('cron_list', {
|
||||||
|
description: `List jobs on the OpenClaw gateway cron scheduler. ${CRON_DENY_NOTE}`,
|
||||||
|
inputSchema: {
|
||||||
|
compact: z.boolean().optional().describe('Compact summaries (id, name, enabled, nextRunAtMs, ...). Default true.'),
|
||||||
|
},
|
||||||
|
}, async ({ compact }) => invokeGatewayTool('cron', 'list', { compact: compact ?? true }));
|
||||||
|
|
||||||
|
// --- nodes --------------------------------------------------------------
|
||||||
|
server.registerTool('nodes_invoke', {
|
||||||
|
description:
|
||||||
|
'Invoke a command on a paired OpenClaw node (camera, canvas, location, notify, screen record, etc). ' +
|
||||||
|
'NOTE: the gateway HTTP /tools/invoke surface hard-denies the "nodes" tool by default (node command ' +
|
||||||
|
'relay can reach system.run on paired hosts, owner-only) — this call 404s until the gateway operator ' +
|
||||||
|
'adds "nodes" to gateway.tools.allow (Adolf P6). `system.run`/`system.run.prepare` are blocked on this ' +
|
||||||
|
'path regardless; `system.which` is allowed.',
|
||||||
|
inputSchema: {
|
||||||
|
node: z.string().describe('Node id, display name, or IP.'),
|
||||||
|
command: z.string().describe('Node command, e.g. "canvas.eval", "location.get", "notify", "system.which".'),
|
||||||
|
params: z.record(z.string(), z.unknown()).optional().describe('Command-specific parameters object.'),
|
||||||
|
idempotencyKey: z.string().optional().describe('Optional idempotency key for the invoke.'),
|
||||||
|
},
|
||||||
|
}, async ({ node, command, params, idempotencyKey }) => {
|
||||||
|
const args = { node, command, params: params || {} };
|
||||||
|
if (idempotencyKey !== undefined) args.idempotencyKey = idempotencyKey;
|
||||||
|
return invokeGatewayTool('nodes', 'invoke', args);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- browser --------------------------------------------------------------
|
||||||
|
server.registerTool('browser_invoke', {
|
||||||
|
description:
|
||||||
|
'Generic pass-through to the OpenClaw gateway "browser" agent tool (agent-controlled Chrome/Brave/Edge ' +
|
||||||
|
'automation: tabs, snapshot, click, type, screenshot). Unlike cron/nodes, "browser" is NOT in the ' +
|
||||||
|
'gateway HTTP default hard-deny list, so this can work as soon as the gateway is up and its normal ' +
|
||||||
|
'tools.* policy allows "browser" for the caller — no special P6 HTTP-deny override needed. Args are ' +
|
||||||
|
'forwarded verbatim as the tool call payload; the exact action/argument vocabulary is defined by the ' +
|
||||||
|
'running gateway and needs live introspection once it exists (deferred to P6/P7).',
|
||||||
|
inputSchema: {
|
||||||
|
action: z.string().describe('Browser tool action, e.g. "status", "open", "snapshot", "click", "type".'),
|
||||||
|
args: z.record(z.string(), z.unknown()).optional().describe('Action-specific parameters object.'),
|
||||||
|
},
|
||||||
|
}, async ({ action, args }) => invokeGatewayTool('browser', action, args || {}));
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stateless Streamable HTTP transport (mirrors the MCP SDK's own
|
||||||
|
// examples/server/simpleStatelessStreamableHttp.js): one McpServer + one
|
||||||
|
// transport per request, no session persistence needed for these tools.
|
||||||
|
const app = createMcpExpressApp({ host: HOST });
|
||||||
|
|
||||||
|
app.get('/health', (_req, res) => res.status(200).json({ ok: true }));
|
||||||
|
|
||||||
|
app.post('/mcp', async (req, res) => {
|
||||||
|
const server = buildServer();
|
||||||
|
try {
|
||||||
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||||
|
await server.connect(transport);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
res.on('close', () => {
|
||||||
|
transport.close();
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('error handling MCP request:', err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'internal server error' }, id: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/mcp', (_req, res) => {
|
||||||
|
res.writeHead(405).end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'method not allowed' }, id: null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/mcp', (_req, res) => {
|
||||||
|
res.writeHead(405).end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'method not allowed' }, id: null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`openclaw-tools bridge listening on ${HOST}:${PORT} (gateway: ${GATEWAY_BASE_URL})`);
|
||||||
|
});
|
||||||
18
openai/pipecat/Dockerfile
Normal file
18
openai/pipecat/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends gcc g++ && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# CPU torch first — prevents silero-vad from pulling in the CUDA variant
|
||||||
|
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
"pipecat-ai[openai,livekit,silero]" \
|
||||||
|
"livekit-api" \
|
||||||
|
fastapi \
|
||||||
|
"uvicorn[standard]"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8882
|
||||||
|
CMD ["uvicorn", "bot:app", "--host", "0.0.0.0", "--port", "8882"]
|
||||||
228
openai/pipecat/bot.py
Normal file
228
openai/pipecat/bot.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from livekit import api as lkapi
|
||||||
|
|
||||||
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||||
|
from pipecat.audio.vad.vad_analyzer import VADParams
|
||||||
|
from pipecat.frames.frames import TextFrame
|
||||||
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
|
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||||
|
from pipecat.processors.frame_processor import FrameProcessor, FrameDirection
|
||||||
|
from pipecat.services.openai.llm import OpenAILLMService
|
||||||
|
from pipecat.services.openai.stt import OpenAISTTService
|
||||||
|
from pipecat.services.openai.tts import OpenAITTSService
|
||||||
|
from pipecat.transports.livekit.transport import LiveKitTransport, LiveKitParams
|
||||||
|
|
||||||
|
|
||||||
|
# ── TTS text normalizer ──────────────────────────────────────────────────────
|
||||||
|
# Replaces symbols and abbreviations with spoken Russian words so Silero TTS
|
||||||
|
# doesn't truncate on unknown characters.
|
||||||
|
|
||||||
|
_NORM_RULES: list[tuple[re.Pattern, str]] = [
|
||||||
|
# Temperature: +12°C / -5°С / 12 °C → плюс двенадцать градусов цельсия
|
||||||
|
(re.compile(r"([+-]?\d+)\s*°\s*[CСcс]", re.IGNORECASE), r"\1 градусов цельсия"),
|
||||||
|
# Bare degree sign: 90° → 90 градусов
|
||||||
|
(re.compile(r"(\d+)\s*°"), r"\1 градусов"),
|
||||||
|
# Percent
|
||||||
|
(re.compile(r"(\d+)\s*%"), r"\1 процентов"),
|
||||||
|
# Speed: m/s, м/с, km/h, км/ч
|
||||||
|
(re.compile(r"\bm/s\b", re.IGNORECASE), "метров в секунду"),
|
||||||
|
(re.compile(r"\bм/с\b"), "метров в секунду"),
|
||||||
|
(re.compile(r"\bkm/h\b", re.IGNORECASE), "километров в час"),
|
||||||
|
(re.compile(r"\bкм/ч\b"), "километров в час"),
|
||||||
|
# Currency
|
||||||
|
(re.compile(r"\$\s*(\d+)"), r"\1 долларов"),
|
||||||
|
(re.compile(r"(\d+)\s*\$"), r"\1 долларов"),
|
||||||
|
(re.compile(r"€\s*(\d+)"), r"\1 евро"),
|
||||||
|
(re.compile(r"(\d+)\s*€"), r"\1 евро"),
|
||||||
|
(re.compile(r"(\d+)\s*₽"), r"\1 рублей"),
|
||||||
|
# Plus/minus signs before numbers
|
||||||
|
(re.compile(r"\+(\d)"), r"плюс \1"),
|
||||||
|
(re.compile(r"-(\d)"), r"минус \1"),
|
||||||
|
# Common abbreviations
|
||||||
|
(re.compile(r"\bкг\b"), "килограмм"),
|
||||||
|
(re.compile(r"\bг\b(?=\s|$)"), "грамм"),
|
||||||
|
(re.compile(r"\bмм\b"), "миллиметров"),
|
||||||
|
(re.compile(r"\bсм\b"), "сантиметров"),
|
||||||
|
(re.compile(r"\bкм\b"), "километров"),
|
||||||
|
# Strip remaining special chars that TTS can't handle
|
||||||
|
(re.compile(r"[°•·†‡§¶©®™«»<>{}[\]|\\~^`]"), ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_for_tts(text: str) -> str:
|
||||||
|
"""Replace symbols with spoken Russian equivalents."""
|
||||||
|
for pattern, replacement in _NORM_RULES:
|
||||||
|
text = pattern.sub(replacement, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
class TTSTextNormalizer(FrameProcessor):
|
||||||
|
"""Intercepts TextFrames between LLM and TTS, normalizing symbols to words."""
|
||||||
|
|
||||||
|
async def process_frame(self, frame, direction: FrameDirection = FrameDirection.DOWNSTREAM):
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
if isinstance(frame, TextFrame):
|
||||||
|
original = frame.text
|
||||||
|
normalized = normalize_for_tts(original)
|
||||||
|
if normalized != original:
|
||||||
|
logger.debug(f"TTSTextNormalizer: {original!r} → {normalized!r}")
|
||||||
|
await self.push_frame(TextFrame(text=normalized), direction)
|
||||||
|
else:
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
LIVEKIT_URL = os.getenv("LIVEKIT_URL", "ws://host.docker.internal:7880") # bot connects here
|
||||||
|
LIVEKIT_PUBLIC_URL = os.getenv("LIVEKIT_PUBLIC_URL", "wss://lk.alogins.net") # browser connects here
|
||||||
|
LIVEKIT_API_KEY = os.getenv("LIVEKIT_API_KEY", "devkey")
|
||||||
|
LIVEKIT_SECRET = os.getenv("LIVEKIT_SECRET", "")
|
||||||
|
ADOLF_URL = os.getenv("ADOLF_URL", "http://host.docker.internal:8000/v1")
|
||||||
|
STT_URL = os.getenv("STT_URL", "http://host.docker.internal:8880/v1")
|
||||||
|
TTS_URL = os.getenv("TTS_URL", "http://host.docker.internal:8881/v1")
|
||||||
|
STT_MODEL = os.getenv("STT_MODEL", "deepdml/faster-whisper-large-v3-turbo-ct2")
|
||||||
|
TTS_VOICE = os.getenv("TTS_VOICE", "onyx")
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = "You are Adolf, a helpful voice assistant. Keep replies concise — 1-3 sentences. No markdown."
|
||||||
|
|
||||||
|
app = FastAPI(title="Pipecat Voice Bot")
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
# ── LiveKit helpers ───────────────────────────────────────────────────────────
|
||||||
|
def _lk_token(room: str, identity: str, is_bot: bool = False) -> str:
|
||||||
|
grants = lkapi.VideoGrants(
|
||||||
|
room_join=True,
|
||||||
|
room=room,
|
||||||
|
can_publish=True,
|
||||||
|
can_subscribe=True,
|
||||||
|
can_publish_data=True,
|
||||||
|
)
|
||||||
|
token = (
|
||||||
|
lkapi.AccessToken(LIVEKIT_API_KEY, LIVEKIT_SECRET)
|
||||||
|
.with_identity(identity)
|
||||||
|
.with_name("Adolf Bot" if is_bot else identity)
|
||||||
|
.with_grants(grants)
|
||||||
|
)
|
||||||
|
return token.to_jwt()
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_room(room_name: str) -> None:
|
||||||
|
lk = lkapi.LiveKitAPI(LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_SECRET)
|
||||||
|
try:
|
||||||
|
await lk.room.create_room(
|
||||||
|
lkapi.CreateRoomRequest(name=room_name, empty_timeout=300, max_participants=5)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await lk.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pipecat pipeline ──────────────────────────────────────────────────────────
|
||||||
|
async def _run_bot(room_name: str) -> None:
|
||||||
|
bot_token = _lk_token(room_name, "pipecat-bot", is_bot=True)
|
||||||
|
|
||||||
|
transport = LiveKitTransport(
|
||||||
|
url=LIVEKIT_URL,
|
||||||
|
token=bot_token,
|
||||||
|
room_name=room_name,
|
||||||
|
params=LiveKitParams(
|
||||||
|
audio_in_enabled=True,
|
||||||
|
audio_out_enabled=True,
|
||||||
|
vad_enabled=True,
|
||||||
|
vad_analyzer=SileroVADAnalyzer(params=VADParams(
|
||||||
|
stop_secs=0.8, # wait 0.8s of silence before end-of-speech
|
||||||
|
start_secs=0.2, # start speech detection after 0.2s
|
||||||
|
confidence=0.85, # high confidence to avoid triggering on ambient noise
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
stt = OpenAISTTService(
|
||||||
|
api_key="dummy",
|
||||||
|
base_url=STT_URL,
|
||||||
|
model=STT_MODEL,
|
||||||
|
language="ru",
|
||||||
|
)
|
||||||
|
|
||||||
|
llm = OpenAILLMService(
|
||||||
|
api_key="dummy",
|
||||||
|
base_url=ADOLF_URL,
|
||||||
|
model="adolf-light",
|
||||||
|
)
|
||||||
|
|
||||||
|
tts = OpenAITTSService(
|
||||||
|
api_key="dummy",
|
||||||
|
base_url=TTS_URL,
|
||||||
|
model="silero",
|
||||||
|
voice=TTS_VOICE,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||||
|
context = OpenAILLMContext(messages)
|
||||||
|
context_aggregator = llm.create_context_aggregator(context)
|
||||||
|
|
||||||
|
normalizer = TTSTextNormalizer()
|
||||||
|
|
||||||
|
pipeline = Pipeline([
|
||||||
|
transport.input(),
|
||||||
|
stt,
|
||||||
|
context_aggregator.user(),
|
||||||
|
llm,
|
||||||
|
normalizer,
|
||||||
|
tts,
|
||||||
|
transport.output(),
|
||||||
|
context_aggregator.assistant(),
|
||||||
|
])
|
||||||
|
|
||||||
|
task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=False))
|
||||||
|
|
||||||
|
@transport.event_handler("on_participant_disconnected")
|
||||||
|
async def on_disconnect(transport, participant):
|
||||||
|
identity = participant if isinstance(participant, str) else getattr(participant, "identity", str(participant))
|
||||||
|
logger.info(f"Participant {identity} left — stopping bot")
|
||||||
|
await task.cancel()
|
||||||
|
|
||||||
|
runner = PipelineRunner()
|
||||||
|
logger.info(f"Bot starting in room={room_name}")
|
||||||
|
await runner.run(task)
|
||||||
|
logger.info(f"Bot done in room={room_name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── API ───────────────────────────────────────────────────────────────────────
|
||||||
|
class ConnectResponse(BaseModel):
|
||||||
|
room: str
|
||||||
|
token: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/connect", response_model=ConnectResponse)
|
||||||
|
async def connect():
|
||||||
|
room_name = f"voice-{uuid.uuid4().hex[:6]}"
|
||||||
|
await _create_room(room_name)
|
||||||
|
user_token = _lk_token(room_name, "user")
|
||||||
|
asyncio.create_task(_run_bot(room_name))
|
||||||
|
return ConnectResponse(room=room_name, token=user_token, url=LIVEKIT_PUBLIC_URL)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def index():
|
||||||
|
with open("static/index.html") as f:
|
||||||
|
return f.read()
|
||||||
241
openai/pipecat/static/index.html
Normal file
241
openai/pipecat/static/index.html
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Adolf Voice</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
background: #0f0f0f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 1px solid #2a2a2a;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
width: 360px;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.4rem; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.subtitle { color: #666; font-size: 0.85rem; margin-bottom: 32px; }
|
||||||
|
#orb {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, #3a3a3a 0%, #1a1a1a 100%);
|
||||||
|
border: 2px solid #333;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
#orb.listening {
|
||||||
|
background: radial-gradient(circle, #1e3a5f 0%, #0d1f33 100%);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 20px #3b82f640;
|
||||||
|
animation: pulse-blue 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#orb.speaking {
|
||||||
|
background: radial-gradient(circle, #1e4034 0%, #0d2018 100%);
|
||||||
|
border-color: #22c55e;
|
||||||
|
box-shadow: 0 0 20px #22c55e40;
|
||||||
|
animation: pulse-green 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#orb.thinking {
|
||||||
|
background: radial-gradient(circle, #3a2e1e 0%, #1a160d 100%);
|
||||||
|
border-color: #f59e0b;
|
||||||
|
box-shadow: 0 0 20px #f59e0b40;
|
||||||
|
animation: pulse-amber 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
#orb.user-speaking {
|
||||||
|
background: radial-gradient(circle, #3a1e3a 0%, #1a0d1a 100%);
|
||||||
|
border-color: #a855f7;
|
||||||
|
box-shadow: 0 0 20px #a855f740;
|
||||||
|
animation: pulse-purple 0.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse-blue { 0%,100%{box-shadow:0 0 20px #3b82f640} 50%{box-shadow:0 0 35px #3b82f680} }
|
||||||
|
@keyframes pulse-green { 0%,100%{box-shadow:0 0 20px #22c55e40} 50%{box-shadow:0 0 35px #22c55e80} }
|
||||||
|
@keyframes pulse-amber { 0%,100%{box-shadow:0 0 20px #f59e0b40} 50%{box-shadow:0 0 35px #f59e0b80} }
|
||||||
|
@keyframes pulse-purple { 0%,100%{box-shadow:0 0 20px #a855f740} 50%{box-shadow:0 0 35px #a855f780} }
|
||||||
|
#status {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
#transcript {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
min-height: 2.4em;
|
||||||
|
line-height: 1.4;
|
||||||
|
font-style: italic;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
#transcript .user-text { color: #7ba8d4; font-style: normal; }
|
||||||
|
#transcript .bot-text { color: #6ab88a; font-style: normal; }
|
||||||
|
#btn {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 10px 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
#btn:hover { background: #333; }
|
||||||
|
#btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
#btn.active { border-color: #ef4444; color: #ef4444; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Adolf</h1>
|
||||||
|
<p class="subtitle">Voice assistant</p>
|
||||||
|
<div id="orb" onclick="toggle()">🎙️</div>
|
||||||
|
<div id="status">Press to connect</div>
|
||||||
|
<div id="transcript"></div>
|
||||||
|
<button id="btn" onclick="toggle()">Connect</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let room = null;
|
||||||
|
let audioCtx = null;
|
||||||
|
|
||||||
|
// Unlock browser autoplay — must happen on first user gesture
|
||||||
|
function unlockAudio() {
|
||||||
|
if (!audioCtx) {
|
||||||
|
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUI(state, msg) {
|
||||||
|
const orb = document.getElementById('orb');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const btn = document.getElementById('btn');
|
||||||
|
orb.className = state || '';
|
||||||
|
status.textContent = msg;
|
||||||
|
if (state === null) {
|
||||||
|
btn.textContent = 'Connect';
|
||||||
|
btn.classList.remove('active');
|
||||||
|
orb.textContent = '🎙️';
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Disconnect';
|
||||||
|
btn.classList.add('active');
|
||||||
|
orb.textContent = state === 'thinking' ? '💭' :
|
||||||
|
state === 'speaking' ? '🔊' :
|
||||||
|
state === 'user-speaking' ? '🗣️' : '🎙️';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTranscript(role, text) {
|
||||||
|
const div = document.getElementById('transcript');
|
||||||
|
const cls = role === 'user' ? 'user-text' : 'bot-text';
|
||||||
|
const prefix = role === 'user' ? 'You: ' : 'Adolf: ';
|
||||||
|
div.innerHTML = `<span class="${cls}">${prefix}${text}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
unlockAudio();
|
||||||
|
if (room) {
|
||||||
|
room.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn').disabled = true;
|
||||||
|
setUI('thinking', 'Connecting…');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/connect', { method: 'POST' });
|
||||||
|
const { token, url } = await res.json();
|
||||||
|
|
||||||
|
room = new LivekitClient.Room({ adaptiveStream: true, dynacast: true });
|
||||||
|
|
||||||
|
room.on(LivekitClient.RoomEvent.Connected, () => {
|
||||||
|
setUI('listening', 'Listening…');
|
||||||
|
document.getElementById('btn').disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(LivekitClient.RoomEvent.Disconnected, () => {
|
||||||
|
setUI(null, 'Press to connect');
|
||||||
|
document.getElementById('btn').disabled = false;
|
||||||
|
document.getElementById('transcript').innerHTML = '';
|
||||||
|
room = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(LivekitClient.RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||||
|
if (!room) return;
|
||||||
|
const botSpeaking = speakers.some(s => s.identity === 'pipecat-bot');
|
||||||
|
const userSpeaking = speakers.some(s => s.identity === 'user');
|
||||||
|
if (botSpeaking) {
|
||||||
|
setUI('speaking', 'Adolf is speaking…');
|
||||||
|
} else if (userSpeaking) {
|
||||||
|
setUI('user-speaking', 'Listening to you…');
|
||||||
|
} else {
|
||||||
|
setUI('listening', 'Listening…');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach remote audio so browser plays it
|
||||||
|
room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, pub, participant) => {
|
||||||
|
if (track.kind === 'audio') {
|
||||||
|
// Remove old element if any
|
||||||
|
const old = document.getElementById(`audio-${participant.identity}`);
|
||||||
|
if (old) old.remove();
|
||||||
|
const el = track.attach();
|
||||||
|
el.id = `audio-${participant.identity}`;
|
||||||
|
el.autoplay = true;
|
||||||
|
// Resume audio context on attach to beat autoplay restrictions
|
||||||
|
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.play().catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(LivekitClient.RoomEvent.TrackUnsubscribed, (track) => {
|
||||||
|
track.detach().forEach(el => el.remove());
|
||||||
|
});
|
||||||
|
|
||||||
|
room.on(LivekitClient.RoomEvent.ParticipantConnected, (p) => {
|
||||||
|
if (p.identity === 'pipecat-bot') {
|
||||||
|
setUI('listening', 'Listening…');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Data messages from bot (transcripts/events if pipecat sends them)
|
||||||
|
room.on(LivekitClient.RoomEvent.DataReceived, (data, participant) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(new TextDecoder().decode(data));
|
||||||
|
if (msg.type === 'transcript' && msg.role === 'user') addTranscript('user', msg.text);
|
||||||
|
if (msg.type === 'transcript' && msg.role === 'bot') addTranscript('bot', msg.text);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const wsUrl = url.replace(/^http/, 'ws');
|
||||||
|
await room.connect(wsUrl, token);
|
||||||
|
await room.localParticipant.setMicrophoneEnabled(true);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setUI(null, 'Error: ' + err.message);
|
||||||
|
document.getElementById('btn').disabled = false;
|
||||||
|
room = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
122
openai/pipecat/test_pipeline.py
Normal file
122
openai/pipecat/test_pipeline.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
End-to-end pipeline test:
|
||||||
|
1. Call /connect to get a room + token
|
||||||
|
2. Join the LiveKit room as a Python client
|
||||||
|
3. Publish TTS audio (pre-generated) as microphone input
|
||||||
|
4. Capture bot's audio response and save to file
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import wave
|
||||||
|
import struct
|
||||||
|
import httpx
|
||||||
|
import numpy as np
|
||||||
|
from livekit import rtc
|
||||||
|
|
||||||
|
PIPECAT_URL = "http://localhost:8882"
|
||||||
|
TTS_URL = "http://host.docker.internal:8881"
|
||||||
|
OUTPUT_FILE = "/tmp/bot_response.wav"
|
||||||
|
SAMPLE_RATE = 48000
|
||||||
|
NUM_CHANNELS = 1
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_tts_pcm(text: str) -> bytes:
|
||||||
|
"""Get WAV audio from Silero TTS, return raw PCM int16 bytes."""
|
||||||
|
async with httpx.AsyncClient(timeout=30) as c:
|
||||||
|
r = await c.post(f"{TTS_URL}/v1/audio/speech", json={
|
||||||
|
"input": text, "voice": "onyx", "response_format": "wav"
|
||||||
|
})
|
||||||
|
r.raise_for_status()
|
||||||
|
# Skip WAV header (44 bytes) to get raw PCM
|
||||||
|
return r.content[44:]
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Step 1 — create room
|
||||||
|
print("[test] Creating room...")
|
||||||
|
async with httpx.AsyncClient() as c:
|
||||||
|
r = await c.post(f"{PIPECAT_URL}/connect")
|
||||||
|
r.raise_for_status()
|
||||||
|
creds = r.json()
|
||||||
|
print(f"[test] Room: {creds['room']} URL: {creds['url']}")
|
||||||
|
|
||||||
|
# Step 2 — generate test audio
|
||||||
|
test_phrase = "Привет! Как тебя зовут?"
|
||||||
|
print(f"[test] Generating TTS for: {test_phrase!r}")
|
||||||
|
pcm_bytes = await generate_tts_pcm(test_phrase)
|
||||||
|
print(f"[test] TTS PCM: {len(pcm_bytes)} bytes (~{len(pcm_bytes)//(SAMPLE_RATE*2):.1f}s)")
|
||||||
|
|
||||||
|
# Step 3 — join room
|
||||||
|
room = rtc.Room()
|
||||||
|
received_frames: list[bytes] = []
|
||||||
|
|
||||||
|
@room.on("track_subscribed")
|
||||||
|
def on_track(track, pub, participant):
|
||||||
|
if track.kind == rtc.TrackKind.KIND_AUDIO and participant.identity == "pipecat-bot":
|
||||||
|
print(f"[test] Subscribed to bot audio track")
|
||||||
|
audio_stream = rtc.AudioStream(track, sample_rate=SAMPLE_RATE, num_channels=NUM_CHANNELS)
|
||||||
|
asyncio.ensure_future(_collect_audio(audio_stream, received_frames))
|
||||||
|
|
||||||
|
ws_url = creds["url"].replace("https://", "wss://").replace("http://", "ws://")
|
||||||
|
# Connect internally via host.docker.internal
|
||||||
|
internal_url = "ws://host.docker.internal:7880"
|
||||||
|
print(f"[test] Connecting to LiveKit at {internal_url}...")
|
||||||
|
await room.connect(internal_url, creds["token"])
|
||||||
|
print(f"[test] Connected. Waiting for bot to join...")
|
||||||
|
|
||||||
|
# Wait for bot participant
|
||||||
|
for _ in range(20):
|
||||||
|
if any(p.identity == "pipecat-bot" for p in room.remote_participants.values()):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
print(f"[test] Participants: {[p.identity for p in room.remote_participants.values()]}")
|
||||||
|
|
||||||
|
# Step 4 — publish audio as microphone
|
||||||
|
print("[test] Publishing audio track...")
|
||||||
|
source = rtc.AudioSource(SAMPLE_RATE, NUM_CHANNELS)
|
||||||
|
local_track = rtc.LocalAudioTrack.create_audio_track("microphone", source)
|
||||||
|
opts = rtc.TrackPublishOptions(source=rtc.TrackSource.SOURCE_MICROPHONE)
|
||||||
|
await room.local_participant.publish_track(local_track, opts)
|
||||||
|
|
||||||
|
# Send PCM in 20ms chunks
|
||||||
|
chunk_samples = SAMPLE_RATE * 20 // 1000 # 960 samples per chunk
|
||||||
|
chunk_bytes = chunk_samples * 2 # int16
|
||||||
|
print(f"[test] Sending {len(pcm_bytes) // chunk_bytes} audio chunks...")
|
||||||
|
for i in range(0, len(pcm_bytes), chunk_bytes):
|
||||||
|
chunk = pcm_bytes[i:i + chunk_bytes]
|
||||||
|
if len(chunk) < chunk_bytes:
|
||||||
|
chunk = chunk + b'\x00' * (chunk_bytes - len(chunk))
|
||||||
|
samples = np.frombuffer(chunk, dtype=np.int16)
|
||||||
|
frame = rtc.AudioFrame(
|
||||||
|
data=samples.tobytes(),
|
||||||
|
sample_rate=SAMPLE_RATE,
|
||||||
|
num_channels=NUM_CHANNELS,
|
||||||
|
samples_per_channel=chunk_samples,
|
||||||
|
)
|
||||||
|
await source.capture_frame(frame)
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
print("[test] Audio sent. Waiting for bot response (up to 30s)...")
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
|
||||||
|
await room.disconnect()
|
||||||
|
|
||||||
|
# Step 5 — save response
|
||||||
|
if received_frames:
|
||||||
|
total = b"".join(received_frames)
|
||||||
|
print(f"[test] Received {len(total)} bytes of bot audio ({len(total)//(SAMPLE_RATE*2):.1f}s)")
|
||||||
|
with wave.open(OUTPUT_FILE, "wb") as wf:
|
||||||
|
wf.setnchannels(NUM_CHANNELS)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(SAMPLE_RATE)
|
||||||
|
wf.writeframes(total)
|
||||||
|
print(f"[test] Saved to {OUTPUT_FILE}")
|
||||||
|
else:
|
||||||
|
print("[test] No audio received from bot!")
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_audio(stream: rtc.AudioStream, buf: list):
|
||||||
|
async for event in stream:
|
||||||
|
buf.append(bytes(event.frame.data))
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
64
openai/quota-command-openclaw-plugin/index.js
Normal file
64
openai/quota-command-openclaw-plugin/index.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Kimi Quota Command (kb #62) — registers `/quota` on Adolf's Matrix channel.
|
||||||
|
*
|
||||||
|
* OpenClaw's native-command dispatch (`api.registerCommand`) runs a
|
||||||
|
* `/`-prefixed command BEFORE the agent turn: no model is invoked, so this
|
||||||
|
* never spends a Kimi turn (unlike asking Adolf in prose "what's my quota").
|
||||||
|
* It hits adolf-llm's own GET /usage route (server.js, kb #62 piece 1), which
|
||||||
|
* itself talks straight to Kimi's managed-usage API — no LLM anywhere in the
|
||||||
|
* path.
|
||||||
|
*
|
||||||
|
* Gating: `requireAuth: true` (the registerCommand default) restricts the
|
||||||
|
* command to `ctx.isAuthorizedSender`, i.e. the same Matrix DM allowlist
|
||||||
|
* (`channels.matrix.dm.allowFrom` in openclaw.json) that already gates every
|
||||||
|
* other interaction with Adolf. No separate owner-only tier is needed here —
|
||||||
|
* it's a read-only status line, not a privileged action.
|
||||||
|
*/
|
||||||
|
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||||
|
|
||||||
|
// adolf-llm is a sibling service on the same `openai` compose network —
|
||||||
|
// reached by service name, not localhost/host.docker.internal.
|
||||||
|
const USAGE_URL = "http://adolf-llm:8010/usage";
|
||||||
|
const FETCH_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
function pct(row) {
|
||||||
|
return row && typeof row.pct === "number" ? `${row.pct}%` : "n/a";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUsage() {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(USAGE_URL, { signal: controller.signal });
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(body?.error || `adolf-llm /usage HTTP ${res.status}`);
|
||||||
|
return body;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePluginEntry({
|
||||||
|
id: "quota-command",
|
||||||
|
name: "Kimi Quota Command",
|
||||||
|
description:
|
||||||
|
"LLM-free /quota command: reads Adolf's Kimi usage from adolf-llm:8010/usage and replies with a compact readout.",
|
||||||
|
register(api) {
|
||||||
|
api.registerCommand({
|
||||||
|
name: "quota",
|
||||||
|
description: "Show Kimi quota usage (5h / weekly / 7d) — no model call.",
|
||||||
|
acceptsArgs: false,
|
||||||
|
requireAuth: true,
|
||||||
|
handler: async () => {
|
||||||
|
try {
|
||||||
|
const usage = await fetchUsage();
|
||||||
|
const line = `Kimi: 5h ${pct(usage.window_5h)} · weekly ${pct(usage.weekly)} · 7d ${pct(usage.window_7d)}`;
|
||||||
|
return { text: line, suppressReply: true };
|
||||||
|
} catch (e) {
|
||||||
|
api.logger?.warn?.(`quota-command: fetch failed (${e?.message || e})`);
|
||||||
|
return { text: `Kimi quota unavailable: ${e?.message || e}`, suppressReply: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
13
openai/quota-command-openclaw-plugin/openclaw.plugin.json
Normal file
13
openai/quota-command-openclaw-plugin/openclaw.plugin.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"id": "quota-command",
|
||||||
|
"name": "Kimi Quota Command",
|
||||||
|
"description": "Registers /quota: a native-command handler (runs before the agent, zero model calls) that reads Adolf's Kimi usage from adolf-llm:8010/usage and replies with a compact 5h/weekly/7d readout.",
|
||||||
|
"activation": {
|
||||||
|
"onStartup": true
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
openai/quota-command-openclaw-plugin/package.json
Normal file
18
openai/quota-command-openclaw-plugin/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "openclaw-quota-command",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "LLM-free /quota command for Adolf: reads Kimi usage from adolf-llm:8010/usage and replies with a compact readout.",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"main": "./index.js",
|
||||||
|
"peerDependencies": {
|
||||||
|
"openclaw": ">=2026.3.0"
|
||||||
|
},
|
||||||
|
"openclaw": {
|
||||||
|
"extensions": ["./index.js"],
|
||||||
|
"compat": {
|
||||||
|
"pluginApi": ">=2026.0.0",
|
||||||
|
"minGatewayVersion": "2026.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
openai/shared-mcp.json
Normal file
20
openai/shared-mcp.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"hindsight": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://hindsight:8888/mcp/adolf/"
|
||||||
|
},
|
||||||
|
"openclaw-tools": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://openclaw-tools:8020/mcp"
|
||||||
|
},
|
||||||
|
"kanboard": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://host.docker.internal:3104/mcp"
|
||||||
|
},
|
||||||
|
"agap": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://host.docker.internal:3100/mcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
openai/silero-tts/Dockerfile
Normal file
15
openai/silero-tts/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# CPU-only torch keeps image ~500MB vs ~2GB for CUDA
|
||||||
|
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
RUN pip install --no-cache-dir fastapi uvicorn scipy numpy pydub omegaconf
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY server.py .
|
||||||
|
|
||||||
|
ENV TORCH_HOME=/cache/torch
|
||||||
|
|
||||||
|
EXPOSE 8881
|
||||||
|
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8881"]
|
||||||
153
openai/silero-tts/server.py
Normal file
153
openai/silero-tts/server.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import io
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import scipy.io.wavfile as wavfile
|
||||||
|
from pydub import AudioSegment
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(title="Silero TTS")
|
||||||
|
|
||||||
|
# ── Config ────────────────────────────────────────────────────────────────────
|
||||||
|
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
SAMPLE_RATE = 24000
|
||||||
|
MAX_CHUNK = 800 # chars per Silero call
|
||||||
|
|
||||||
|
# Model identifiers (passed as `speaker` to torch.hub.load — selects model file)
|
||||||
|
MODEL_ID = {"ru": "v3_1_ru", "en": "v3_en"}
|
||||||
|
|
||||||
|
# Silero speakers (passed to apply_tts)
|
||||||
|
RU_SPEAKERS = ["aidar", "baya", "kseniya", "xenia", "eugene"]
|
||||||
|
EN_SPEAKERS = [f"en_{i}" for i in range(10)]
|
||||||
|
|
||||||
|
# OpenAI voice → Silero speaker
|
||||||
|
VOICE_MAP = {
|
||||||
|
"ru": {"alloy": "eugene", "echo": "aidar", "fable": "baya",
|
||||||
|
"onyx": "eugene", "nova": "kseniya", "shimmer": "xenia"},
|
||||||
|
"en": {"alloy": "en_3", "echo": "en_1", "fable": "en_2",
|
||||||
|
"onyx": "en_3", "nova": "en_4", "shimmer": "en_5"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Model cache ───────────────────────────────────────────────────────────────
|
||||||
|
_models: dict[str, object] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_model(language: str):
|
||||||
|
if language not in _models:
|
||||||
|
logger.info(f"Loading Silero model {MODEL_ID[language]} lang={language} device={DEVICE}")
|
||||||
|
model, _ = torch.hub.load(
|
||||||
|
repo_or_dir="snakers4/silero-models",
|
||||||
|
model="silero_tts",
|
||||||
|
language=language,
|
||||||
|
speaker=MODEL_ID[language],
|
||||||
|
trust_repo=True,
|
||||||
|
)
|
||||||
|
model.to(DEVICE)
|
||||||
|
_models[language] = model
|
||||||
|
logger.info(f"Model ready: lang={language}")
|
||||||
|
return _models[language]
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def preload():
|
||||||
|
"""Preload both language models to avoid cold-start on first request."""
|
||||||
|
for lang in ("ru", "en"):
|
||||||
|
try:
|
||||||
|
_get_model(lang)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Preload failed for lang={lang}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
def _is_russian(text: str) -> bool:
|
||||||
|
return bool(re.search(r"[а-яёА-ЯЁ]", text))
|
||||||
|
|
||||||
|
|
||||||
|
def _split_sentences(text: str) -> list[str]:
|
||||||
|
"""Split on sentence boundaries, keeping chunks under MAX_CHUNK chars."""
|
||||||
|
if len(text) <= MAX_CHUNK:
|
||||||
|
return [text]
|
||||||
|
parts = re.split(r"(?<=[.!?;])\s+", text.strip())
|
||||||
|
chunks, cur = [], ""
|
||||||
|
for part in parts:
|
||||||
|
if len(cur) + len(part) + 1 <= MAX_CHUNK:
|
||||||
|
cur = f"{cur} {part}" if cur else part
|
||||||
|
else:
|
||||||
|
if cur:
|
||||||
|
chunks.append(cur)
|
||||||
|
# If single part is too long, split mid-word as last resort
|
||||||
|
cur = part[:MAX_CHUNK] if len(part) > MAX_CHUNK else part
|
||||||
|
if cur:
|
||||||
|
chunks.append(cur)
|
||||||
|
return chunks or [text[:MAX_CHUNK]]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_bytes(audio: torch.Tensor, fmt: str) -> bytes:
|
||||||
|
pcm = (audio.cpu().numpy() * 32767).astype(np.int16)
|
||||||
|
if fmt == "pcm":
|
||||||
|
return pcm.tobytes()
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wavfile.write(buf, SAMPLE_RATE, pcm)
|
||||||
|
if fmt == "wav":
|
||||||
|
return buf.getvalue()
|
||||||
|
seg = AudioSegment.from_wav(io.BytesIO(buf.getvalue()))
|
||||||
|
out = io.BytesIO()
|
||||||
|
seg.export(out, format="mp3")
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# ── API ───────────────────────────────────────────────────────────────────────
|
||||||
|
class SpeechRequest(BaseModel):
|
||||||
|
model: str = "silero"
|
||||||
|
input: str
|
||||||
|
voice: str = "alloy"
|
||||||
|
response_format: str = "mp3"
|
||||||
|
speed: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "device": DEVICE}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
async def list_models():
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"data": [{"id": "silero", "object": "model", "owned_by": "silero"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/audio/speech")
|
||||||
|
async def speech(req: SpeechRequest):
|
||||||
|
text = req.input.strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=400, detail="input is empty")
|
||||||
|
|
||||||
|
language = "ru" if _is_russian(text) else "en"
|
||||||
|
vm = VOICE_MAP[language]
|
||||||
|
speaker = vm.get(req.voice, vm["alloy"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = _get_model(language)
|
||||||
|
chunks = _split_sentences(text)
|
||||||
|
parts = [
|
||||||
|
model.apply_tts(text=chunk, speaker=speaker, sample_rate=SAMPLE_RATE)
|
||||||
|
for chunk in chunks
|
||||||
|
]
|
||||||
|
audio = parts[0] if len(parts) == 1 else torch.cat(parts)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TTS error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
fmt = req.response_format.lower()
|
||||||
|
audio_bytes = _to_bytes(audio, fmt)
|
||||||
|
media_types = {"wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg"}
|
||||||
|
media_type = media_types.get(fmt, "audio/mpeg")
|
||||||
|
return Response(content=audio_bytes, media_type=media_type)
|
||||||
1
openwebui/.gitignore
vendored
Normal file
1
openwebui/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
35
openwebui/docker-compose.yml
Normal file
35
openwebui/docker-compose.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
open-webui:
|
||||||
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
|
container_name: open-webui
|
||||||
|
ports:
|
||||||
|
- "3125:8080"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/ai/open-webui:/app/backend/data
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: always
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
|
environment:
|
||||||
|
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
|
||||||
|
- OLLAMA_BASE_URL=http://host.docker.internal:11436
|
||||||
|
- OPENAI_API_BASE_URL=http://host.docker.internal:4000/v1
|
||||||
|
- OPENAI_API_KEY=${LITELLM_MASTER_KEY}
|
||||||
|
# STT — Faster-Whisper large-v3-turbo
|
||||||
|
- AUDIO_STT_ENGINE=openai
|
||||||
|
- AUDIO_STT_OPENAI_API_BASE_URL=http://host.docker.internal:8880/v1
|
||||||
|
- AUDIO_STT_OPENAI_API_KEY=dummy
|
||||||
|
- AUDIO_STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||||
|
# TTS — Silero v4
|
||||||
|
- AUDIO_TTS_ENGINE=openai
|
||||||
|
- AUDIO_TTS_OPENAI_API_BASE_URL=http://host.docker.internal:8881/v1
|
||||||
|
- AUDIO_TTS_OPENAI_API_KEY=dummy
|
||||||
|
- AUDIO_TTS_MODEL=silero
|
||||||
|
- AUDIO_TTS_VOICE=onyx
|
||||||
|
- ENABLE_API_KEYS=True
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
|
|
||||||
networks:
|
|
||||||
macvlan-br0:
|
|
||||||
driver: macvlan
|
|
||||||
driver_opts:
|
|
||||||
parent: br0
|
|
||||||
ipam:
|
|
||||||
config:
|
|
||||||
- subnet: 192.168.1.0/24
|
|
||||||
gateway: 192.168.1.1
|
|
||||||
# ip_range: 192.168.1.192/27
|
|
||||||
|
|
||||||
services:
|
|
||||||
pihole:
|
|
||||||
container_name: pihole
|
|
||||||
image: pihole/pihole:latest
|
|
||||||
#ports:
|
|
||||||
# DNS Ports
|
|
||||||
#- "53:53/tcp"
|
|
||||||
#- "53:53/udp"
|
|
||||||
# Default HTTP Port
|
|
||||||
#- "80:80/tcp"
|
|
||||||
# Default HTTPs Port. FTL will generate a self-signed certificate
|
|
||||||
#- "443:443/tcp"
|
|
||||||
# Uncomment the below if using Pi-hole as your DHCP Server
|
|
||||||
#- "67:67/udp"
|
|
||||||
# Uncomment the line below if you are using Pi-hole as your NTP server
|
|
||||||
#- "123:123/udp"
|
|
||||||
|
|
||||||
dns:
|
|
||||||
- 8.8.8.8
|
|
||||||
- 1.1.1.1
|
|
||||||
networks:
|
|
||||||
macvlan-br0:
|
|
||||||
ipv4_address: 192.168.1.2
|
|
||||||
environment:
|
|
||||||
# Set the appropriate timezone for your location from
|
|
||||||
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones, e.g:
|
|
||||||
TZ: 'Europe/Moscow'
|
|
||||||
# Set a password to access the web interface. Not setting one will result in a random password being assigned
|
|
||||||
FTLCONF_webserver_api_password: 'correct horse 123'
|
|
||||||
# If using Docker's default `bridge` network setting the dns listening mode should be set to 'ALL'
|
|
||||||
FTLCONF_dns_listeningMode: 'ALL'
|
|
||||||
# Volumes store your data between container upgrades
|
|
||||||
volumes:
|
|
||||||
# For persisting Pi-hole's databases and common configuration file
|
|
||||||
- '/mnt/ssd/dbs/pihole:/etc/pihole'
|
|
||||||
# Uncomment the below if you have custom dnsmasq config files that you want to persist. Not needed for most starting fresh with Pi-hole v6. If you're upgrading from v5 you and have used this directory before, you should keep it enabled for the first v6 container start to allow for a complete migration. It can be removed afterwards. Needs environment variable FTLCONF_misc_etc_dnsmasq_d: 'true'
|
|
||||||
#- './etc-dnsmasq.d:/etc/dnsmasq.d'
|
|
||||||
cap_add:
|
|
||||||
# See https://github.com/pi-hole/docker-pi-hole#note-on-capabilities
|
|
||||||
# Required if you are using Pi-hole as your DHCP server, else not needed
|
|
||||||
- NET_ADMIN
|
|
||||||
# Required if you are using Pi-hole as your NTP client to be able to set the host's system time
|
|
||||||
- SYS_TIME
|
|
||||||
# Optional, if Pi-hole should get some more processing time
|
|
||||||
- SYS_NICE
|
|
||||||
restart: unless-stopped
|
|
||||||
17
qbittorrent/docker-compose.yml
Normal file
17
qbittorrent/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
qbittorrent:
|
||||||
|
image: lscr.io/linuxserver/qbittorrent:latest
|
||||||
|
container_name: qbittorrent
|
||||||
|
environment:
|
||||||
|
- PUID=1000
|
||||||
|
- PGID=1000
|
||||||
|
- TZ=Europe/Moscow
|
||||||
|
- WEBUI_PORT=8085
|
||||||
|
volumes:
|
||||||
|
- /mnt/misc/qbittorrent/config:/config
|
||||||
|
- /mnt/misc/qbittorrent/downloads:/downloads
|
||||||
|
ports:
|
||||||
|
- "8085:8085"
|
||||||
|
- "6881:6881"
|
||||||
|
- "6881:6881/udp"
|
||||||
|
restart: unless-stopped
|
||||||
13
radicale/config
Normal file
13
radicale/config
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[server]
|
||||||
|
hosts = 0.0.0.0:5232
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
type = htpasswd
|
||||||
|
htpasswd_filename = /config/users
|
||||||
|
htpasswd_encryption = bcrypt
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
filesystem_folder = /data/collections
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
level = info
|
||||||
9
radicale/docker-compose.yml
Normal file
9
radicale/docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
radicale:
|
||||||
|
image: tomsquest/docker-radicale
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 5232:5232
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/radicale/data:/data
|
||||||
|
- /mnt/ssd/dbs/radicale/config:/config
|
||||||
@@ -95,6 +95,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
extra_hosts:
|
||||||
|
- "office.alogins.net:host-gateway"
|
||||||
networks:
|
networks:
|
||||||
- seafile-net
|
- seafile-net
|
||||||
|
|
||||||
|
|||||||
19
searxng/docker-compose.yml
Normal file
19
searxng/docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
searxng:
|
||||||
|
image: docker.io/searxng/searxng:latest
|
||||||
|
container_name: searxng
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/ai/searxng/config/:/etc/searxng/
|
||||||
|
- /mnt/ssd/ai/searxng/data/:/var/cache/searxng/
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "11437:8080"
|
||||||
|
|
||||||
|
searxng-mcp:
|
||||||
|
build: ./mcp
|
||||||
|
container_name: searxng-mcp
|
||||||
|
network_mode: host
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- PORT=3102
|
||||||
|
- SEARXNG_URL=http://localhost:11437
|
||||||
6
searxng/mcp/Dockerfile
Normal file
6
searxng/mcp/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
COPY server.js ./
|
||||||
|
CMD ["node", "server.js"]
|
||||||
11
searxng/mcp/package.json
Normal file
11
searxng/mcp/package.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "searxng-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "server.js",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||||
|
"express": "^4.19.0",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
98
searxng/mcp/server.js
Normal file
98
searxng/mcp/server.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || '3102');
|
||||||
|
const SEARXNG_URL = (process.env.SEARXNG_URL || 'http://localhost:11437').replace(/\/$/, '');
|
||||||
|
|
||||||
|
function createServer() {
|
||||||
|
const server = new McpServer({ name: 'searxng-mcp', version: '1.0.0' });
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'searxng_search',
|
||||||
|
'Search the web using the self-hosted SearXNG meta-search engine. Returns titles, URLs, and content snippets from multiple search engines.',
|
||||||
|
{
|
||||||
|
query: z.string().describe('Search query'),
|
||||||
|
categories: z.enum(['general', 'news', 'images', 'videos', 'science', 'files', 'social_media', 'it'])
|
||||||
|
.optional()
|
||||||
|
.describe('Search category, default: general'),
|
||||||
|
language: z.string().optional().describe('Language code (e.g. "ru", "en", "auto"), default: auto'),
|
||||||
|
time_range: z.enum(['day', 'week', 'month', 'year']).optional().describe('Limit results to time range'),
|
||||||
|
limit: z.number().optional().describe('Max results to return, default 10'),
|
||||||
|
},
|
||||||
|
async ({ query, categories, language, time_range, limit = 10 }) => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: query,
|
||||||
|
format: 'json',
|
||||||
|
categories: categories || 'general',
|
||||||
|
language: language || 'auto',
|
||||||
|
});
|
||||||
|
if (time_range) params.set('time_range', time_range);
|
||||||
|
|
||||||
|
const res = await fetch(`${SEARXNG_URL}/search?${params}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
return { content: [{ type: 'text', text: `SearXNG returned HTTP ${res.status}` }], isError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const results = (data.results || []).slice(0, limit).map(r => ({
|
||||||
|
title: r.title || null,
|
||||||
|
url: r.url || null,
|
||||||
|
content: (r.content || '').slice(0, 500) || null,
|
||||||
|
engine: r.engine || null,
|
||||||
|
score: r.score != null ? Math.round(r.score * 100) / 100 : null,
|
||||||
|
publishedDate: r.publishedDate || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
query: data.query,
|
||||||
|
totalResults: data.number_of_results,
|
||||||
|
count: results.length,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] };
|
||||||
|
} catch (e) {
|
||||||
|
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const sseTransports = new Map();
|
||||||
|
|
||||||
|
app.all('/mcp', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||||
|
res.on('close', () => transport.close());
|
||||||
|
await createServer().connect(transport);
|
||||||
|
await transport.handleRequest(req, res, req.body);
|
||||||
|
} catch (e) {
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/sse', async (req, res) => {
|
||||||
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
|
sseTransports.set(transport.sessionId, transport);
|
||||||
|
res.on('close', () => sseTransports.delete(transport.sessionId));
|
||||||
|
await createServer().connect(transport);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/messages', async (req, res) => {
|
||||||
|
const transport = sseTransports.get(req.query.sessionId);
|
||||||
|
if (!transport) return res.status(400).send('Unknown session');
|
||||||
|
await transport.handlePostMessage(req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/health', (_, res) => res.json({ status: 'ok', searxng: SEARXNG_URL }));
|
||||||
|
|
||||||
|
app.listen(PORT, () => console.log(`searxng-mcp listening on :${PORT}`));
|
||||||
26
syncthing/docker-compose.yml
Normal file
26
syncthing/docker-compose.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
services:
|
||||||
|
syncthing:
|
||||||
|
image: syncthing/syncthing:latest
|
||||||
|
container_name: syncthing
|
||||||
|
hostname: agap
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /mnt/misc/syncthing/config:/var/syncthing/config
|
||||||
|
- /mnt/misc/syncthing/data:/var/syncthing
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8384:8384" # web UI (proxied by Caddy)
|
||||||
|
- "22000:22000/tcp" # sync protocol TCP
|
||||||
|
- "22000:22000/udp" # sync protocol UDP
|
||||||
|
- "21027:21027/udp" # local discovery
|
||||||
|
|
||||||
|
relay:
|
||||||
|
image: syncthing/relaysrv:latest
|
||||||
|
container_name: syncthing-relay
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: relay.env
|
||||||
|
entrypoint: ["/bin/sh", "-c", "exec /bin/strelaysrv -listen=:22067 -status-srv=:22070 -pools= -token=$$RELAY_TOKEN"]
|
||||||
|
volumes:
|
||||||
|
- /mnt/misc/syncthing/relay-keys:/keys
|
||||||
|
ports:
|
||||||
|
- "22067:22067/tcp" # relay protocol
|
||||||
|
- "22070:22070/tcp" # status/metrics
|
||||||
1
syncthing/relay.env
Normal file
1
syncthing/relay.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
RELAY_TOKEN=04a4e751b42e1cec36f53ff2a99520d5f6403cdb520f7392
|
||||||
13
vikunja/docker-compose.yml
Normal file
13
vikunja/docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
vikunja:
|
||||||
|
image: vikunja/vikunja:2.2.2
|
||||||
|
environment:
|
||||||
|
VIKUNJA_SERVICE_PUBLICURL: https://todo.alogins.net/
|
||||||
|
VIKUNJA_SERVICE_JWTSECRET: 13122c95a1fa87bc5f4aefbfc415f7b6e3c2c9e9ba3f784e905c514fec19ae9d9b69
|
||||||
|
VIKUNJA_DATABASE_PATH: /db/vikunja.db
|
||||||
|
ports:
|
||||||
|
- 3457:3456
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/vikunja/files:/app/vikunja/files
|
||||||
|
- /mnt/ssd/dbs/vikunja/db:/db
|
||||||
|
restart: unless-stopped
|
||||||
28
windows/docker-compose.yml
Normal file
28
windows/docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
windows:
|
||||||
|
image: dockurr/windows
|
||||||
|
container_name: windows
|
||||||
|
environment:
|
||||||
|
VERSION: "tiny11"
|
||||||
|
RAM_SIZE: "2G"
|
||||||
|
CPU_CORES: "2"
|
||||||
|
DISK_SIZE: "64G"
|
||||||
|
USERNAME: "alvis"
|
||||||
|
PASSWORD: "alvis"
|
||||||
|
LANGUAGE: "English"
|
||||||
|
REGION: "en-US"
|
||||||
|
KEYBOARD: "en-US"
|
||||||
|
devices:
|
||||||
|
- /dev/kvm
|
||||||
|
- /dev/net/tun
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
ports:
|
||||||
|
- "8006:8006"
|
||||||
|
- "3389:3389/tcp"
|
||||||
|
- "3389:3389/udp"
|
||||||
|
volumes:
|
||||||
|
- /mnt/ssd/dbs/windows/storage:/storage
|
||||||
|
- /mnt/misc/qbittorrent/downloads:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 2m
|
||||||
@@ -17,6 +17,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "10051:10051"
|
- "10051:10051"
|
||||||
|
extra_hosts:
|
||||||
|
- "haos.alogins.net:192.168.1.3"
|
||||||
environment:
|
environment:
|
||||||
DB_SERVER_HOST: postgres-server
|
DB_SERVER_HOST: postgres-server
|
||||||
DB_SERVER_PORT: 5432
|
DB_SERVER_PORT: 5432
|
||||||
|
|||||||
Reference in New Issue
Block a user