@mobcode/openclaw-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # MobCode OpenClaw Plugin
2
+
3
+ Отдельный plugin-проект для OpenClaw, который можно ставить в уже существующий сервер OpenClaw как локальную папку.
4
+
5
+ ## Зачем он нужен
6
+
7
+ Этот каркас заточен под мобильную интеграцию MobCode и закрывает первую архитектурную основу под такие задачи:
8
+
9
+ - получить по RPC redacted snapshot runtime-конфига;
10
+ - получать список провайдеров через MobCode static catalog;
11
+ - получать список моделей конкретного provider через live fetch в upstream API провайдера;
12
+ - иметь отдельное mobile-friendly хранилище сообщений по session key;
13
+ - хранить rich artifacts и открывать их из мобильной истории;
14
+ - маршрутизировать app-surface actions (`mobcode_open`) в активный MobCode client;
15
+ - завести device registry и очередь push-событий;
16
+ - подготовить базу под approvals, diff-flow и custom tools.
17
+
18
+ ## Что уже есть
19
+
20
+ Плагин регистрирует:
21
+
22
+ - gateway methods:
23
+ - `mobcode.capabilities`
24
+ - `mobcode.config.snapshot`
25
+ - `mobcode.providers.available`
26
+ - `mobcode.provider.models.list`
27
+ - `mobcode.messages.page`
28
+ - `mobcode.approvals.list`
29
+ - `mobcode.artifacts.get`
30
+ - `mobcode.artifacts.resolveBlob`
31
+ - `mobcode.client.register`
32
+ - `mobcode.client.actions.wait`
33
+ - `mobcode.client.actions.ack`
34
+ - tools:
35
+ - `present_artifact`
36
+ - `mobcode_open`
37
+ - HTTP routes:
38
+ - `/plugins/mobcode/health`
39
+ - `/plugins/mobcode/config`
40
+ - `/plugins/mobcode/providers`
41
+ - `/plugins/mobcode/provider-models?providerId=...`
42
+ - `/plugins/mobcode/messages?sessionKey=...`
43
+ - `/plugins/mobcode/artifacts?artifactId=...`
44
+ - `/plugins/mobcode/devices/register`
45
+ - `/plugins/mobcode/client/register`
46
+ - background service, которая:
47
+ - слушает transcript updates;
48
+ - индексирует сообщения в SQLite;
49
+ - делает history backfill через публичный `chat.history` и вытаскивает artifacts из tool-result history в SQLite;
50
+ - слушает `exec.approval.requested/resolved` через внутренний gateway approvals client;
51
+ - при включенном push складывает события в локальную очередь.
52
+ - `mobcode.messages.page` отдает уже нормализованные one-bubble conversation messages с ordered timeline `text -> tool -> artifact -> text`, а не только raw transcript rows.
53
+
54
+ ## Что пока частично или TODO
55
+
56
+ - `providers.available` сейчас отдает MobCode static provider catalog, синхронизированный с мобильным клиентом, а не настоящий OpenClaw onboarding wizard source of truth.
57
+ - `provider.models.list` получает модели по сети для конкретного provider, используя `api.runtime.config.loadConfig()` и `api.runtime.modelAuth.resolveApiKeyForProvider(...)`, не отдавая токены в mobileapp.
58
+ - `present_artifact` и `mobcode_open` уже зарегистрированы как plugin tools; `mobcode_open` завершает tool call после client ack или timeout.
59
+ - real push delivery в FCM/APNs пока не сделана; есть только device registry и queue.
60
+ - file diff flow лучше делать через интеграцию с официальным `diffs` plugin, а не изобретать свой формат.
61
+ - mobile approvals построены поверх существующих `exec.approval.*` методов OpenClaw: plugin хранит state/history, а resolve по-прежнему идет через штатный RPC.
62
+ - custom chart tools пока не реализованы, но под них уже есть правильная точка расширения: `registerTool`.
63
+ - history backfill через `chat.history` portable, но ограничен 1000 последними сообщениями, потому что это hard-cap самого OpenClaw gateway method.
64
+
65
+ ## Установка из папки
66
+
67
+ На сервере с OpenClaw:
68
+
69
+ ```bash
70
+ openclaw plugins install @mobcode/openclaw-plugin
71
+ openclaw gateway restart
72
+ ```
73
+
74
+ Локальная разработка из папки по-прежнему поддерживается:
75
+
76
+ ```bash
77
+ openclaw plugins install ./openclaw-mobcode-plugin
78
+ openclaw gateway restart
79
+ ```
80
+
81
+ Если хочешь линк для разработки:
82
+
83
+ ```bash
84
+ openclaw plugins install -l ./openclaw-mobcode-plugin
85
+ openclaw gateway restart
86
+ ```
87
+
88
+ ## Конфиг
89
+
90
+ Пример plugin config:
91
+
92
+ ```json
93
+ {
94
+ "plugins": {
95
+ "entries": {
96
+ "mobcode": {
97
+ "enabled": true,
98
+ "config": {
99
+ "storage": {
100
+ "retainMessages": true,
101
+ "maxMessagesPerSession": 2000
102
+ },
103
+ "push": {
104
+ "enabled": false,
105
+ "provider": "none"
106
+ },
107
+ "security": {
108
+ "allowSecretMaterialOverGateway": false
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ ```
116
+
117
+ ## Следующие шаги
118
+
119
+ 1. Добавить `mobcode.push.dispatch` и реальную отправку в FCM/APNs/webhook relay.
120
+ 2. Подключить diff/viewer flow через официальный `diffs` plugin.
121
+ 3. Добавить `registerTool` для графиков/диаграмм поверх artifact pipeline.
122
+ 4. Заменить текущий wait/ack long-wait transport на полноценный plugin-owned event stream/push-aware channel.
123
+
124
+ ## Важный нюанс про portability
125
+
126
+ Этот plugin больше не зависит от `openclaw/src/...` для provider/model discovery.
127
+
128
+ - `mobcode.providers.available` работает на своем static catalog.
129
+ - `mobcode.provider.models.list` работает через публичный plugin runtime (`loadConfig` + `resolveApiKeyForProvider`) и сетевой запрос к upstream provider API.
130
+
131
+ Это делает plugin переносимым для обычной npm/бинарной установки OpenClaw, но означает, что provider catalog пока остается MobCode-owned, а не OpenClaw-owned source of truth.
package/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { createMobcodePluginDefinition } from "./src/plugin-definition.js";
2
+
3
+ export default createMobcodePluginDefinition();
@@ -0,0 +1,63 @@
1
+ {
2
+ "id": "mobcode",
3
+ "name": "MobCode",
4
+ "description": "MobCode mobile integration plugin for OpenClaw.",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "storage": {
10
+ "type": "object",
11
+ "additionalProperties": false,
12
+ "properties": {
13
+ "retainMessages": {
14
+ "type": "boolean",
15
+ "default": true
16
+ },
17
+ "maxMessagesPerSession": {
18
+ "type": "integer",
19
+ "minimum": 100,
20
+ "maximum": 10000,
21
+ "default": 2000
22
+ }
23
+ }
24
+ },
25
+ "push": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "properties": {
29
+ "enabled": {
30
+ "type": "boolean",
31
+ "default": false
32
+ },
33
+ "provider": {
34
+ "type": "string",
35
+ "enum": [
36
+ "none",
37
+ "webhook",
38
+ "fcm",
39
+ "apns"
40
+ ],
41
+ "default": "none"
42
+ },
43
+ "webhookUrl": {
44
+ "type": "string"
45
+ },
46
+ "sharedSecret": {
47
+ "type": "string"
48
+ }
49
+ }
50
+ },
51
+ "security": {
52
+ "type": "object",
53
+ "additionalProperties": false,
54
+ "properties": {
55
+ "allowSecretMaterialOverGateway": {
56
+ "type": "boolean",
57
+ "default": false
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@mobcode/openclaw-plugin",
3
+ "version": "0.1.0",
4
+ "description": "MobCode integration plugin for OpenClaw",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://gitlab.autoecommerce.ru/rootgroup/mobcode/openclaw-plugin.git"
10
+ },
11
+ "homepage": "https://gitlab.autoecommerce.ru/rootgroup/mobcode/openclaw-plugin",
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "README.md",
17
+ "index.js",
18
+ "openclaw.plugin.json",
19
+ "src"
20
+ ],
21
+ "keywords": [
22
+ "openclaw",
23
+ "mobcode",
24
+ "plugin",
25
+ "mobile"
26
+ ],
27
+ "openclaw": {
28
+ "extensions": [
29
+ "./index.js"
30
+ ]
31
+ }
32
+ }
@@ -0,0 +1,148 @@
1
+ function isObject(value) {
2
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3
+ }
4
+
5
+ function deepCloneJson(value) {
6
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
7
+ }
8
+
9
+ function redactSecrets(value, keyName = "") {
10
+ if (Array.isArray(value)) {
11
+ return value.map((item) => redactSecrets(item));
12
+ }
13
+ if (!isObject(value)) {
14
+ return value;
15
+ }
16
+ const loweredKey = keyName.toLowerCase();
17
+ if (
18
+ loweredKey.includes("token") ||
19
+ loweredKey.includes("secret") ||
20
+ loweredKey.includes("apikey") ||
21
+ loweredKey.includes("api_key") ||
22
+ loweredKey.includes("password") ||
23
+ loweredKey.includes("privatekey") ||
24
+ loweredKey.includes("private_key")
25
+ ) {
26
+ return "[redacted]";
27
+ }
28
+ const result = {};
29
+ for (const [key, nested] of Object.entries(value)) {
30
+ result[key] = redactSecrets(nested, key);
31
+ }
32
+ return result;
33
+ }
34
+
35
+ function normalizeStringArray(values) {
36
+ return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort();
37
+ }
38
+
39
+ function extractConfiguredProviders(config) {
40
+ const providers = config?.models?.providers;
41
+ if (!isObject(providers)) {
42
+ return [];
43
+ }
44
+ return Object.entries(providers).map(([id, raw]) => ({
45
+ id,
46
+ configured: true,
47
+ fields: isObject(raw) ? Object.keys(raw).sort() : [],
48
+ hasBaseUrl: Boolean(raw?.baseUrl),
49
+ hasApiKey: Boolean(raw?.apiKey || raw?.api_key || raw?.token),
50
+ }));
51
+ }
52
+
53
+ function extractConfiguredModels(config) {
54
+ const models = [];
55
+ const defaults = config?.models?.defaults;
56
+ if (isObject(defaults) && defaults.provider && defaults.model) {
57
+ models.push({
58
+ source: "defaults",
59
+ provider: String(defaults.provider),
60
+ model: String(defaults.model),
61
+ });
62
+ }
63
+
64
+ const profileGroups = [
65
+ config?.models?.profiles,
66
+ config?.gateway?.profiles,
67
+ config?.agents?.profiles,
68
+ ];
69
+ for (const group of profileGroups) {
70
+ if (!isObject(group)) {
71
+ continue;
72
+ }
73
+ for (const [profileId, raw] of Object.entries(group)) {
74
+ if (!isObject(raw)) {
75
+ continue;
76
+ }
77
+ const provider = raw.provider ?? raw.modelProvider;
78
+ const model = raw.model;
79
+ if (!provider || !model) {
80
+ continue;
81
+ }
82
+ models.push({
83
+ source: "profile",
84
+ profileId,
85
+ provider: String(provider),
86
+ model: String(model),
87
+ });
88
+ }
89
+ }
90
+
91
+ const deduped = new Map();
92
+ for (const entry of models) {
93
+ const key = `${entry.source}:${entry.profileId ?? ""}:${entry.provider}:${entry.model}`;
94
+ deduped.set(key, entry);
95
+ }
96
+ return [...deduped.values()];
97
+ }
98
+
99
+ export function buildConfigSnapshot(api, options = {}) {
100
+ const includeSecrets =
101
+ options.includeSecrets === true &&
102
+ api?.pluginConfig?.security?.allowSecretMaterialOverGateway === true;
103
+ const loaded = api.runtime.config.loadConfig();
104
+ const rawConfig = deepCloneJson(loaded) ?? {};
105
+ const snapshot = includeSecrets ? rawConfig : redactSecrets(rawConfig);
106
+ const stateDir = api.runtime.state.resolveStateDir();
107
+ return {
108
+ generatedAt: new Date().toISOString(),
109
+ pluginId: api.id,
110
+ includeSecrets,
111
+ stateDir,
112
+ configPathHints: {
113
+ stateDir,
114
+ openclawConfigGuess: `${stateDir}/openclaw.json`,
115
+ runtimeSecretsGuess: `${stateDir}/runtime-secrets.json`,
116
+ },
117
+ runtimeDefaults: {
118
+ provider: api.runtime.agent.defaults.provider,
119
+ model: api.runtime.agent.defaults.model,
120
+ },
121
+ snapshot,
122
+ };
123
+ }
124
+
125
+ export function buildProvidersPayload(api) {
126
+ const config = api.runtime.config.loadConfig();
127
+ return {
128
+ generatedAt: new Date().toISOString(),
129
+ source: "runtime-config",
130
+ providers: extractConfiguredProviders(config),
131
+ };
132
+ }
133
+
134
+ export function buildModelsPayload(api) {
135
+ const config = api.runtime.config.loadConfig();
136
+ const configuredModels = extractConfiguredModels(config);
137
+ const providers = normalizeStringArray(configuredModels.map((entry) => entry.provider));
138
+ return {
139
+ generatedAt: new Date().toISOString(),
140
+ source: "runtime-config",
141
+ defaults: {
142
+ provider: api.runtime.agent.defaults.provider,
143
+ model: api.runtime.agent.defaults.model,
144
+ },
145
+ configuredProviders: providers,
146
+ configuredModels,
147
+ };
148
+ }
@@ -0,0 +1,49 @@
1
+ export const MOB_CODE_FEATURE_MATRIX = {
2
+ configSnapshot: {
3
+ status: "implemented",
4
+ note: "Gateway method and HTTP route return a redacted runtime config snapshot and config path hints.",
5
+ },
6
+ providersList: {
7
+ status: "implemented",
8
+ note: "Provider list is derived from runtime config models/providers keys.",
9
+ },
10
+ modelsList: {
11
+ status: "partial",
12
+ note: "Current implementation returns configured/default models and heuristics from runtime config, not the full gateway catalog.",
13
+ },
14
+ fileDiffs: {
15
+ status: "planned",
16
+ note: "Best path is to integrate with the official diffs plugin or reuse its viewer/file flow.",
17
+ },
18
+ dangerousActionApprovals: {
19
+ status: "implemented",
20
+ note: "Plugin persists exec approval history/pending state for mobile reads while resolve still uses the official exec.approval.resolve RPC.",
21
+ },
22
+ pushNotifications: {
23
+ status: "scaffolded",
24
+ note: "Device registry, queue, and HTTP registration endpoints are present. Real FCM/APNs delivery is the next step.",
25
+ },
26
+ messageStorage: {
27
+ status: "implemented",
28
+ note: "Plugin indexes transcript updates into its own state for mobile-friendly reads.",
29
+ },
30
+ richArtifacts: {
31
+ status: "implemented",
32
+ note: "Plugin stores artifact documents in SQLite, exposes artifact RPC, and registers a present_artifact tool compatible with MobCode rich output flow.",
33
+ },
34
+ appSurfaceActions: {
35
+ status: "implemented",
36
+ note: "Plugin registers mobcode_open, tracks active MobCode clients, and delivers app-surface actions through wait/ack realtime-style RPC with timeout-aware tool completion.",
37
+ },
38
+ customToolsAndCharts: {
39
+ status: "partial",
40
+ note: "present_artifact and mobcode_open are implemented; chart/graph generation is the next custom-tool step.",
41
+ },
42
+ };
43
+
44
+ export function listFeatureMatrix() {
45
+ return Object.entries(MOB_CODE_FEATURE_MATRIX).map(([key, value]) => ({
46
+ key,
47
+ ...value,
48
+ }));
49
+ }
@@ -0,0 +1,180 @@
1
+ export function registerMobcodeGatewayMethods({ store, builders, featureMatrix, api }) {
2
+ api.registerGatewayMethod("mobcode.capabilities", async ({ respond }) => {
3
+ respond(true, {
4
+ ok: true,
5
+ features: featureMatrix(),
6
+ });
7
+ });
8
+
9
+ api.registerGatewayMethod("mobcode.config.snapshot", async ({ params, respond }) => {
10
+ const includeSecrets = params?.includeSecrets === true;
11
+ respond(true, {
12
+ ok: true,
13
+ ...builders.configSnapshot({ includeSecrets }),
14
+ });
15
+ });
16
+
17
+ api.registerGatewayMethod("mobcode.providers.available", async ({ respond }) => {
18
+ respond(true, {
19
+ ok: true,
20
+ ...(await builders.providers()),
21
+ });
22
+ });
23
+
24
+ api.registerGatewayMethod("mobcode.provider.models.list", async ({ params, respond }) => {
25
+ const providerId = typeof params?.providerId === "string" ? params.providerId.trim() : "";
26
+ if (!providerId) {
27
+ respond(false, { error: "providerId required" });
28
+ return;
29
+ }
30
+ const payload = await builders.providerModels(providerId);
31
+ respond(true, {
32
+ ok: true,
33
+ ...payload,
34
+ });
35
+ });
36
+
37
+ api.registerGatewayMethod("mobcode.messages.page", async ({ params, respond }) => {
38
+ const sessionKey = typeof params?.sessionKey === "string" ? params.sessionKey.trim() : "";
39
+ if (!sessionKey) {
40
+ respond(false, { error: "sessionKey required" });
41
+ return;
42
+ }
43
+ await builders.ensureSessionMessages(sessionKey);
44
+ const page = await store.pageSessionMessages(sessionKey, {
45
+ limit: params?.limit,
46
+ beforeId: params?.beforeId,
47
+ });
48
+ respond(true, {
49
+ ok: true,
50
+ sessionKey: page.sessionKey,
51
+ messages: page.items,
52
+ hasMore: page.hasMore,
53
+ nextBeforeId: page.nextBeforeId,
54
+ total: page.total,
55
+ });
56
+ });
57
+
58
+ api.registerGatewayMethod("mobcode.approvals.list", async ({ params, respond }) => {
59
+ const approvals = await store.listApprovals({
60
+ sessionKey:
61
+ typeof params?.sessionKey === "string" ? params.sessionKey.trim() : undefined,
62
+ status: typeof params?.status === "string" ? params.status.trim() : undefined,
63
+ limit: params?.limit,
64
+ });
65
+ respond(true, {
66
+ ok: true,
67
+ approvals,
68
+ });
69
+ });
70
+
71
+ api.registerGatewayMethod("mobcode.artifacts.get", async ({ params, respond }) => {
72
+ const artifactId = typeof params?.artifactId === "string" ? params.artifactId.trim() : "";
73
+ if (!artifactId) {
74
+ respond(false, { error: "artifactId required" });
75
+ return;
76
+ }
77
+ const artifact = await store.readArtifactById(artifactId);
78
+ if (!artifact?.document) {
79
+ respond(false, { error: "artifact not found" });
80
+ return;
81
+ }
82
+ respond(true, {
83
+ ok: true,
84
+ artifactId: artifact.artifactId,
85
+ document: artifact.document,
86
+ sessionKey: artifact.sessionKey,
87
+ runId: artifact.runId,
88
+ kind: artifact.kind,
89
+ title: artifact.title,
90
+ summary: artifact.summary,
91
+ });
92
+ });
93
+
94
+ api.registerGatewayMethod("mobcode.artifacts.resolveBlob", async ({ params, respond }) => {
95
+ const blobId = typeof params?.blobId === "string" ? params.blobId.trim() : "";
96
+ if (!blobId) {
97
+ respond(false, { error: "blobId required" });
98
+ return;
99
+ }
100
+ const path = await store.resolveArtifactBlobPath(blobId);
101
+ if (!path) {
102
+ respond(false, { error: "blob not found" });
103
+ return;
104
+ }
105
+ respond(true, {
106
+ ok: true,
107
+ blobId,
108
+ path,
109
+ });
110
+ });
111
+
112
+ api.registerGatewayMethod("mobcode.client.register", async ({ params, respond }) => {
113
+ const clientId = typeof params?.clientId === "string" ? params.clientId.trim() : "";
114
+ if (!clientId) {
115
+ respond(false, { error: "clientId required" });
116
+ return;
117
+ }
118
+ const result = await store.registerClientSession({
119
+ clientId,
120
+ sessionKey: typeof params?.sessionKey === "string" ? params.sessionKey.trim() : "",
121
+ platform: typeof params?.platform === "string" ? params.platform.trim() : "",
122
+ appVersion: typeof params?.appVersion === "string" ? params.appVersion.trim() : "",
123
+ capabilities: params?.capabilities && typeof params.capabilities === "object"
124
+ ? params.capabilities
125
+ : {},
126
+ });
127
+ respond(true, {
128
+ ok: true,
129
+ ...result,
130
+ });
131
+ });
132
+
133
+ api.registerGatewayMethod("mobcode.client.actions.poll", async ({ params, respond }) => {
134
+ const clientId = typeof params?.clientId === "string" ? params.clientId.trim() : "";
135
+ if (!clientId) {
136
+ respond(false, { error: "clientId required" });
137
+ return;
138
+ }
139
+ const actions = await store.pollClientActions(clientId, {
140
+ limit: params?.limit,
141
+ });
142
+ respond(true, {
143
+ ok: true,
144
+ actions,
145
+ });
146
+ });
147
+
148
+ api.registerGatewayMethod("mobcode.client.actions.wait", async ({ params, respond }) => {
149
+ const clientId = typeof params?.clientId === "string" ? params.clientId.trim() : "";
150
+ if (!clientId) {
151
+ respond(false, { error: "clientId required" });
152
+ return;
153
+ }
154
+ const action = await store.waitForClientAction(clientId, {
155
+ timeoutMs: params?.timeoutMs,
156
+ });
157
+ respond(true, {
158
+ ok: true,
159
+ action,
160
+ });
161
+ });
162
+
163
+ api.registerGatewayMethod("mobcode.client.actions.ack", async ({ params, respond }) => {
164
+ const clientId = typeof params?.clientId === "string" ? params.clientId.trim() : "";
165
+ const actionId = typeof params?.actionId === "string" ? params.actionId.trim() : "";
166
+ const status = typeof params?.status === "string" ? params.status.trim() : "";
167
+ if (!clientId || !actionId || !status) {
168
+ respond(false, { error: "clientId, actionId, and status are required" });
169
+ return;
170
+ }
171
+ respond(true, {
172
+ ...(await store.ackClientAction({
173
+ clientId,
174
+ actionId,
175
+ status,
176
+ errorText: typeof params?.errorText === "string" ? params.errorText.trim() : "",
177
+ })),
178
+ });
179
+ });
180
+ }