@flame0510/project-aether 1.1.12 → 1.1.13

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.
Files changed (59) hide show
  1. package/README.md +4 -1
  2. package/app/agents/PageClient.tsx +84 -22
  3. package/app/api/agents/create/route.ts +15 -3
  4. package/app/api/agents/download-image/route.ts +2 -0
  5. package/app/api/agents/image-status/route.ts +2 -0
  6. package/app/api/agents/route.ts +1 -36
  7. package/app/api/agents/token/route.ts +4 -1
  8. package/app/api/auth/check/route.ts +2 -23
  9. package/app/api/auth/login/route.ts +3 -35
  10. package/app/api/auth/logout/route.ts +1 -1
  11. package/app/api/auth/status/route.ts +2 -21
  12. package/app/api/config/env/route.ts +7 -3
  13. package/app/api/config/restart/route.ts +2 -0
  14. package/app/api/containers/route.ts +3 -34
  15. package/app/api/crons/route.ts +2 -0
  16. package/app/api/debug/route.ts +2 -0
  17. package/app/api/envcheck/route.ts +3 -1
  18. package/app/api/gateway/provider/route.ts +43 -5
  19. package/app/api/gateway/route.ts +2 -0
  20. package/app/api/models/route.ts +2 -0
  21. package/app/api/setup/agent-image/route.ts +8 -3
  22. package/app/api/setup/password/route.ts +32 -37
  23. package/app/api/setup/restart/route.ts +8 -2
  24. package/app/api/skills/delete/route.js +1 -1
  25. package/app/api/skills/promote/route.js +1 -1
  26. package/app/api/skills/route.js +1 -1
  27. package/app/api/skills/save/route.js +18 -5
  28. package/app/api/update-check/route.ts +4 -23
  29. package/app/api/wizard/complete/route.ts +7 -3
  30. package/app/api/wizard/reset/route.ts +8 -3
  31. package/app/api/wizard/status/route.ts +8 -5
  32. package/app/components/AuthGuard.tsx +29 -8
  33. package/app/components/ui/ModalityIcons.tsx +157 -0
  34. package/app/components/ui/index.ts +1 -0
  35. package/app/gateway/PageClient.tsx +104 -16
  36. package/bin/rev4a.js +35 -24
  37. package/docs/ARCHITECTURE.md +31 -13
  38. package/docs/FRONTEND-ARCHITECTURE.md +1 -0
  39. package/docs/REV4A.md +36 -6
  40. package/docs/dev/API-REFERENCE.md +9 -4
  41. package/docs/dev/GATEWAY.md +42 -1
  42. package/docs/dev/PROVIDERS.md +4 -3
  43. package/docs/rag/REV4A-OVERVIEW.md +1 -1
  44. package/instrumentation.ts +18 -0
  45. package/lib/agent-setup.ts +8 -9
  46. package/lib/buildAgentImage.ts +5 -1
  47. package/lib/db.ts +0 -10
  48. package/lib/docker-socket.ts +121 -0
  49. package/lib/openrouter-pricing.ts +184 -0
  50. package/lib/rev4a-auth.d.ts +4 -1
  51. package/lib/rev4a-auth.js +68 -20
  52. package/lib/rev4a-paths.ts +19 -0
  53. package/model-pricing.json +50 -50
  54. package/models.config.json +2 -2
  55. package/package.json +4 -1
  56. package/proxy.ts +68 -0
  57. package/scripts/refresh-model-pricing.mjs +171 -0
  58. package/lib/auth.ts +0 -28
  59. package/lib/requireAuth.tsx +0 -38
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Modality badges for a model.
5
+ *
6
+ * Reads the `modality` string that ships in models.config.json (and matches
7
+ * OpenRouter's `architecture.modality` format verbatim), e.g.
8
+ * "text+image+file->text" → accepts text, images, files
9
+ * "text+image+file+audio+video->text+image" → also *produces* images
10
+ *
11
+ * Inputs are shown as icons; a non-text output is called out separately,
12
+ * since "can read an image" and "can generate an image" are very different
13
+ * capabilities and collapsing them would be misleading.
14
+ */
15
+
16
+ interface ModalitySpec {
17
+ label: string;
18
+ icon: React.ReactNode;
19
+ }
20
+
21
+ const ICONS: Record<string, ModalitySpec> = {
22
+ text: {
23
+ label: 'Text',
24
+ icon: (
25
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
26
+ <path d="M3 4h10M3 8h10M3 12h6" />
27
+ </svg>
28
+ ),
29
+ },
30
+ image: {
31
+ label: 'Images',
32
+ icon: (
33
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4">
34
+ <rect x="2" y="3" width="12" height="10" rx="1.5" />
35
+ <circle cx="6" cy="6.5" r="1.2" />
36
+ <path d="M3 11.5l3.2-3 2.3 2.2 2-1.8 2.5 2.6" strokeLinecap="round" strokeLinejoin="round" />
37
+ </svg>
38
+ ),
39
+ },
40
+ file: {
41
+ label: 'Files / PDF',
42
+ icon: (
43
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
44
+ <path d="M9 2H4.5A1.5 1.5 0 0 0 3 3.5v9A1.5 1.5 0 0 0 4.5 14h7a1.5 1.5 0 0 0 1.5-1.5V6z" />
45
+ <path d="M9 2v4h4" />
46
+ </svg>
47
+ ),
48
+ },
49
+ audio: {
50
+ label: 'Audio',
51
+ icon: (
52
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
53
+ <path d="M8 2.5v11" />
54
+ <path d="M5 5.5v5M11 5.5v5" />
55
+ <path d="M2.5 7.5v1M13.5 7.5v1" />
56
+ </svg>
57
+ ),
58
+ },
59
+ video: {
60
+ label: 'Video',
61
+ icon: (
62
+ <svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
63
+ <rect x="2" y="4" width="8.5" height="8" rx="1.5" />
64
+ <path d="M10.5 8l3.5-2v6l-3.5-2z" />
65
+ </svg>
66
+ ),
67
+ },
68
+ };
69
+
70
+ export interface ParsedModality {
71
+ inputs: string[];
72
+ outputs: string[];
73
+ }
74
+
75
+ /** Split "text+image->text+image" into its input and output lists. */
76
+ export function parseModality(modality: string | undefined | null): ParsedModality | null {
77
+ if (!modality || typeof modality !== 'string') return null;
78
+ const [rawIn, rawOut] = modality.split('->');
79
+ if (!rawIn) return null;
80
+ const split = (s: string) => s.split('+').map((p) => p.trim()).filter(Boolean);
81
+ return { inputs: split(rawIn), outputs: rawOut ? split(rawOut) : [] };
82
+ }
83
+
84
+ /** Own-property lookup: a token like "constructor" must not resolve to Object.prototype. */
85
+ function iconFor(token: string): ModalitySpec | null {
86
+ return Object.hasOwn(ICONS, token) ? ICONS[token] : null;
87
+ }
88
+
89
+ interface ModalityIconsProps {
90
+ modality?: string | null;
91
+ /**
92
+ * Router models (OpenRouter Auto) advertise the union of everything they
93
+ * might route to, which reads as a guarantee it isn't. They already show
94
+ * "Dynamic" for price; capabilities get the same treatment rather than
95
+ * concrete icons.
96
+ */
97
+ dynamic?: boolean;
98
+ }
99
+
100
+ export default function ModalityIcons({ modality, dynamic }: ModalityIconsProps) {
101
+ if (dynamic) {
102
+ return (
103
+ <span
104
+ title="Routed to a different model per request — capabilities vary"
105
+ style={{ fontSize: 10, color: 'var(--text-dim)', fontStyle: 'italic' }}
106
+ >
107
+ varies
108
+ </span>
109
+ );
110
+ }
111
+
112
+ const parsed = parseModality(modality);
113
+ if (!parsed) return null;
114
+
115
+ // Anything the model can produce beyond plain text is worth surfacing:
116
+ // only 15 of 422 upstream models do, and "reads images" vs "makes images"
117
+ // are very different capabilities.
118
+ const generates = parsed.outputs.filter((o) => o !== 'text');
119
+
120
+ return (
121
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, verticalAlign: 'middle' }}>
122
+ {parsed.inputs.map((input) => {
123
+ const spec = iconFor(input);
124
+ if (!spec) return null;
125
+ return (
126
+ <span
127
+ key={input}
128
+ title={`Accepts ${spec.label.toLowerCase()}`}
129
+ aria-label={`Accepts ${spec.label.toLowerCase()}`}
130
+ style={{ display: 'inline-flex', color: 'var(--text-dim)', opacity: 0.75 }}
131
+ >
132
+ {spec.icon}
133
+ </span>
134
+ );
135
+ })}
136
+
137
+ {generates.map((out) => {
138
+ const spec = iconFor(out);
139
+ if (!spec) return null;
140
+ return (
141
+ <span
142
+ key={`out-${out}`}
143
+ title={`Generates ${spec.label.toLowerCase()}`}
144
+ aria-label={`Generates ${spec.label.toLowerCase()}`}
145
+ style={{
146
+ display: 'inline-flex', alignItems: 'center', gap: 2,
147
+ color: 'var(--violet)', fontSize: 9, fontWeight: 600,
148
+ }}
149
+ >
150
+ <span aria-hidden="true">→</span>
151
+ {spec.icon}
152
+ </span>
153
+ );
154
+ })}
155
+ </span>
156
+ );
157
+ }
@@ -17,3 +17,4 @@ export * from './tokens';
17
17
  export * from './Tabs';
18
18
  export * from './ItemList';
19
19
  export * from './TemplateOption';
20
+ export { default as ModalityIcons, parseModality, type ParsedModality } from './ModalityIcons';
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
- import { Button, ConfirmModal, LoadingSpinner, Metric, Page, PageHeader, Pill, Surface, Tabs, type Tone } from '../components/ui';
4
+ import { Button, ConfirmModal, Input, LoadingSpinner, Metric, ModalityIcons, Page, PageHeader, Pill, Surface, Tabs, type Tone } from '../components/ui';
5
5
  import PasswordInput from '../components/PasswordInput';
6
6
  import { Skeleton } from '../components/Skeleton';
7
7
 
@@ -14,6 +14,12 @@ interface ModelInfo {
14
14
  id: string;
15
15
  name: string;
16
16
  enabled: boolean;
17
+ /** USD per 1M tokens. -1/-1 means the model routes dynamically. */
18
+ pricing?: { input: number; output: number };
19
+ /** Set when the price came from the live OpenRouter catalogue, not the static file. */
20
+ pricingLive?: boolean;
21
+ /** e.g. "text+image+file->text" — curated, matches what sync.ts provisions. */
22
+ modality?: string;
17
23
  }
18
24
 
19
25
  interface GatewayProvider {
@@ -345,6 +351,9 @@ export default function GatewayPageClient() {
345
351
  const [revealedKeys, setRevealedKeys] = useState<Record<string, string | null>>({});
346
352
  const [revealing, setRevealing] = useState<Record<string, boolean>>({});
347
353
 
354
+ // Per-provider model filter (purely local, no requests)
355
+ const [modelSearch, setModelSearch] = useState<Record<string, string>>({});
356
+
348
357
  // Agent edit state
349
358
  const [editingAgent, setEditingAgent] = useState<string | null>(null);
350
359
  const [agentModelInput, setAgentModelInput] = useState('');
@@ -431,6 +440,24 @@ export default function GatewayPageClient() {
431
440
  return `${f(p.input)} / ${f(p.output)}`;
432
441
  }
433
442
 
443
+ /**
444
+ * Local model filter. Matches against both the display name and the id,
445
+ * because the upstream vendor often only appears in one of them — e.g.
446
+ * "Kimi K2 (OR)" has id `openrouter/moonshotai/kimi-k2`, so searching
447
+ * either "kimi" or "moonshot" has to find it.
448
+ *
449
+ * Every whitespace-separated term must match somewhere, so "kimi code"
450
+ * narrows rather than widening.
451
+ */
452
+ function filterModels<T extends { id: string; name: string }>(models: T[], query: string): T[] {
453
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
454
+ if (terms.length === 0) return models;
455
+ return models.filter((m) => {
456
+ const haystack = `${m.name} ${m.id}`.toLowerCase();
457
+ return terms.every((t) => haystack.includes(t));
458
+ });
459
+ }
460
+
434
461
  /* ---- Overlay helper ---- */
435
462
  async function withOverlay<T>(message: string, fn: () => Promise<T>): Promise<T> {
436
463
  setOverlay({ active: true, message });
@@ -904,33 +931,93 @@ export default function GatewayPageClient() {
904
931
  )}
905
932
 
906
933
  {/* Models with checkboxes */}
907
- {p.models.length > 0 && (
934
+ {p.models.length > 0 && (() => {
935
+ const query = modelSearch[p.provider] || '';
936
+ const visibleModels = filterModels(p.models, query);
937
+ // Below this many, everything fits on screen and a
938
+ // search box is just noise.
939
+ const showSearch = p.models.length > 6;
940
+ return (
908
941
  <div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
909
- <div style={{ fontSize: 11, color: 'var(--text-dim)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
910
- Models
911
- <span style={{ marginLeft: 8, textTransform: 'none', letterSpacing: 'normal', color: 'var(--violet)', opacity: 0.7, fontSize: 10 }}>
912
- in / out per 1M tokens
913
- </span>
942
+ <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8, marginBottom: 8, flexWrap: 'wrap' }}>
943
+ <div style={{ fontSize: 11, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
944
+ Models
945
+ <span style={{ marginLeft: 8, textTransform: 'none', letterSpacing: 'normal', color: 'var(--violet)', opacity: 0.7, fontSize: 10 }}>
946
+ in / out per 1M tokens
947
+ </span>
948
+ </div>
949
+ {query && (
950
+ <span style={{ fontSize: 10, color: 'var(--text-dim)' }}>
951
+ {visibleModels.length} of {p.models.length}
952
+ </span>
953
+ )}
914
954
  </div>
955
+
956
+ {showSearch && (
957
+ <div style={{ marginBottom: 8 }}>
958
+ {/* Deliberately type="text": Safari clears type="search"
959
+ on Escape via a `search` event React never sees,
960
+ leaving an empty box over a still-filtered list. */}
961
+ <Input
962
+ type="text"
963
+ value={query}
964
+ placeholder="Filter by name or provider — e.g. kimi, moonshot, flash"
965
+ aria-label={`Filter ${p.label} models`}
966
+ onChange={(e) => setModelSearch((prev) => ({ ...prev, [p.provider]: e.target.value }))}
967
+ />
968
+ </div>
969
+ )}
970
+
915
971
  <div style={{ display: 'grid', gap: 4, maxHeight: 360, overflowY: 'auto' }}>
916
- {p.models.map((m) => {
972
+ {visibleModels.length === 0 && (
973
+ <div style={{ fontSize: 12, color: 'var(--text-dim)', padding: '8px 4px' }}>
974
+ No model matches “{query}”.
975
+ </div>
976
+ )}
977
+ {visibleModels.map((m) => {
917
978
  const modelMsg = messages[`model-${m.id}`];
979
+ const pricing = m.pricing;
980
+ // -1/-1 is OpenRouter's marker for router models,
981
+ // whose price and capabilities both vary per call.
982
+ const isDynamic = pricing?.input === -1 && pricing?.output === -1;
918
983
  return (
919
- <label key={m.id} style={{
920
- display: 'flex', alignItems: 'center', gap: 10, padding: '5px 8px', borderRadius: 0,
984
+ // Only the name/id are inside the <label>: clicking
985
+ // anywhere in a label toggles its checkbox, and the
986
+ // badges below carry tooltips people will click to
987
+ // read — which would silently flip the model and
988
+ // restart every agent container.
989
+ <div key={m.id} style={{
990
+ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 8px',
921
991
  background: m.enabled ? 'rgba(var(--green-rgb,34,197,94),0.06)' : 'transparent',
922
- cursor: overlay.active ? 'wait' : 'pointer', fontSize: 12,
992
+ fontSize: 12,
923
993
  }}>
924
994
  <input type="checkbox" checked={m.enabled} disabled={overlay.active}
995
+ id={`model-${m.id}`}
925
996
  onChange={(e) => handleToggleModel(m.id, e.target.checked)}
926
997
  style={{ accentColor: 'var(--green)', cursor: 'pointer' }} />
927
- <div style={{ flex: 1 }}>
928
- <div style={{ fontWeight: m.enabled ? 500 : 400 }}>{m.name}</div>
998
+ <div style={{ flex: 1, minWidth: 0 }}>
999
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
1000
+ <label
1001
+ htmlFor={`model-${m.id}`}
1002
+ style={{ fontWeight: m.enabled ? 500 : 400, cursor: overlay.active ? 'wait' : 'pointer' }}
1003
+ >
1004
+ {m.name}
1005
+ </label>
1006
+ <ModalityIcons modality={m.modality} dynamic={isDynamic} />
1007
+ </div>
929
1008
  <div style={{ fontSize: 11, color: 'var(--text-dim)', fontFamily: 'monospace' }}>
930
1009
  {m.id}
931
1010
  <span style={{ marginLeft: 8, color: 'var(--violet)', fontWeight: 500 }}>
932
- {fmtPrice((m as any).pricing)}
1011
+ {fmtPrice(pricing)}
933
1012
  </span>
1013
+ {m.pricingLive && (
1014
+ <span
1015
+ title="Live price from the OpenRouter API"
1016
+ style={{ marginLeft: 6, display: 'inline-block', verticalAlign: 'middle' }}
1017
+ >
1018
+ <Pill tone="success">LIVE</Pill>
1019
+ </span>
1020
+ )}
934
1021
  </div>
935
1022
  </div>
936
1023
  {modelMsg && (
@@ -938,12 +1025,13 @@ export default function GatewayPageClient() {
938
1025
  {modelMsg.text}
939
1026
  </span>
940
1027
  )}
941
- </label>
1028
+ </div>
942
1029
  );
943
1030
  })}
944
1031
  </div>
945
1032
  </div>
946
- )}
1033
+ );
1034
+ })()}
947
1035
 
948
1036
  {msg && (
949
1037
  <div className={msg.type === 'success' ? 'msg-success' : 'msg-error'}>
package/bin/rev4a.js CHANGED
@@ -29,8 +29,14 @@ const os = require('os');
29
29
  const ROOT = join(__dirname, '..');
30
30
  const NEXT_DIR = join(ROOT, '.next');
31
31
  const PKG_JSON = join(ROOT, 'package.json');
32
- // Config lives outside the npm package so it survives npm update / reinstall
33
- const CONFIG_DIR = join(os.homedir(), '.config', 'rev4a');
32
+ // Config lives outside the npm package so it survives npm update / reinstall.
33
+ // REV4A_DATA_DIR must be honoured here exactly as rev4aDataDir() honours it in
34
+ // lib/rev4a-paths.ts: this file creates the shared directories that the app then
35
+ // bind-mounts into agents, so a divergence would have serve writing the system
36
+ // rules to one path while every agent mounted another — silently empty.
37
+ // It can only come from the real process environment, never from the .env file:
38
+ // that file is looked up under this very directory.
39
+ const CONFIG_DIR = process.env.REV4A_DATA_DIR || join(os.homedir(), '.config', 'rev4a');
34
40
  const DATA_DIR = join(CONFIG_DIR, 'data');
35
41
  const ENV_SOURCE = join(CONFIG_DIR, '.env');
36
42
  const ENV_LINK = join(ROOT, '.env');
@@ -213,14 +219,26 @@ function ensureNetwork() {
213
219
  // ── Rev4a system rules ───────────────────────────────────────────────────
214
220
 
215
221
  function ensureRev4aRules() {
216
- // Ensure all Rev4a host directories exist (shared-skills, rev4a-rules, …).
217
- // Defined centrally in lib/agent-setup so create & recreate routes stay in sync.
222
+ // These must mirror SHARED_SKILLS_DIR / REV4A_RULES_DIR in lib/rev4a-paths.ts.
223
+ // This file is CommonJS and runs before .next exists (it is what builds it),
224
+ // so it cannot import from lib/ — same reason CONFIG_DIR is recomputed above.
225
+ //
226
+ // It previously tried `require('.next/server/lib/agent-setup.js')` to reuse
227
+ // ensureHostDirs(). Next bundles route code and never emits that path, so the
228
+ // require always threw into an empty catch and the directories were never
229
+ // created here at all.
230
+ const sharedDir = join(CONFIG_DIR, 'shared');
231
+ const sharedSkillsDir = join(sharedDir, 'shared-skills');
232
+ const rev4aRulesDir = join(sharedDir, 'rev4a-rules');
233
+
218
234
  try {
219
- const { ensureHostDirs } = require(join(ROOT, '.next', 'server', 'lib', 'agent-setup.js'));
220
- if (typeof ensureHostDirs === 'function') ensureHostDirs();
221
- } catch { /* .next not built yet — dirs may already exist */ }
235
+ mkdirSync(sharedSkillsDir, { recursive: true });
236
+ mkdirSync(rev4aRulesDir, { recursive: true });
237
+ } catch (err) {
238
+ log('RULES', `⚠ Could not create shared directories: ${err.message}`);
239
+ return;
240
+ }
222
241
 
223
- const rev4aRulesDir = '/docker/rev4a-rules';
224
242
  const ruleFile = join(rev4aRulesDir, 'AGENTS.md');
225
243
  const source = join(ROOT, 'rev4a-rules', 'AGENTS.md');
226
244
 
@@ -241,14 +259,10 @@ function ensureRev4aRules() {
241
259
  }
242
260
 
243
261
  try {
244
- const sudo = isRoot() ? '' : 'sudo ';
245
- if (!existsSync(rev4aRulesDir)) {
246
- execSync(`${sudo}mkdir -p ${rev4aRulesDir}`);
247
- }
248
- const tmpFile = `/tmp/rev4a-rules-${Date.now()}.md`;
249
- writeFileSync(tmpFile, sourceContent, 'utf-8');
250
- execSync(`${sudo}cp ${tmpFile} ${ruleFile} && ${sudo}rm ${tmpFile}`);
251
- log('RULES', 'Deployed rev4a system rules to /docker/rev4a-rules/');
262
+ // Direct write — the old /tmp + `sudo cp` + `sudo rm` dance was only
263
+ // needed because the destination sat outside the user's home.
264
+ writeFileSync(ruleFile, sourceContent, 'utf-8');
265
+ log('RULES', `Deployed rev4a system rules to ${rev4aRulesDir}/`);
252
266
  } catch (err) {
253
267
  log('RULES', `⚠ Failed to deploy rev4a rules: ${err.message}`);
254
268
  }
@@ -539,14 +553,11 @@ const args = process.argv.slice(2);
539
553
  const cmd = args[0];
540
554
 
541
555
  const BANNER = `
542
- ██
543
- ███████╗ ██╗ █████╗ ██╗
544
- ██╔═══██╗ ██║ ██╔══██╗ ██║
545
- ██║ ██║ ██║ ████████║ ██║
546
- ██║ ██║ ██║ ██╔════╝ ██║
547
- ███████╔╝ ██████╗ ██║ ██║
548
- ╚══════╝ ╚═════╝ ╚═╝ ╚═╝
549
-
556
+ ████ █████ █ █ █ █ ███
557
+ █ █ █ █ █ █ █ █ █
558
+ ████ ████ █ █ █████ █████
559
+ █ █ █ █ █ █ █ █
560
+ █ █ █████ █ █ █ █
550
561
  `;
551
562
 
552
563
  const HELP_TEXT = `
@@ -55,7 +55,7 @@
55
55
  │ │
56
56
  │ ┌──────────────────────────────────────────────────────────┐ │
57
57
  │ │ SHARED VOLUMES │ │
58
- │ │ • /docker/shared-skills → read-only on all agents │ │
58
+ │ │ • ~/.config/rev4a/shared/shared-skills → all agents (ro) │ │
59
59
  │ │ • /docker/shared-repos → shared project repos │ │
60
60
  │ └──────────────────────────────────────────────────────────┘ │
61
61
  │ └──────────────────────────────────────────────────────────┘ │
@@ -107,14 +107,14 @@ The central container, running the Next.js dashboard + orchestration API.
107
107
 
108
108
  **Volume mounts (target):**
109
109
  ```
110
- /path/to/rev4a/data → /data (persistent)
111
- /var/run/docker.sock → /var/run/docker.sock
112
- /docker/shared-skills → /data/.openclaw/shared-skills (ro)
113
- /docker/shared-repos → /data/repos (rw)
110
+ /path/to/rev4a/data → /data (persistent)
111
+ /var/run/docker.sock → /var/run/docker.sock
112
+ ~/.config/rev4a/shared/shared-skills → /data/.openclaw/shared-skills (ro)
113
+ /docker/shared-repos → /data/repos (rw)
114
114
  ```
115
115
 
116
116
  **Rev4a System Rules** (`rev4a-rules/AGENTS.md`):
117
- - Versioned in the repo, deployed to `/docker/rev4a-rules/` on every `rev4a serve` start
117
+ - Versioned in the repo, deployed to `~/.config/rev4a/shared/rev4a-rules/` on every `rev4a serve` start
118
118
  - Mounted read-only into every agent at `/root/.openclaw/workspace/.rev4a/AGENTS.md`
119
119
  - Injected into every session via the `bootstrap-extra-files` hook (glob `.rev4a/*.md`)
120
120
  - Immutable by agents — enforced by the `:ro` mount
@@ -132,9 +132,9 @@ copies the entrypoint script, and applies baseline gateway config patches.
132
132
 
133
133
  **Volume mounts (per agent):**
134
134
  ```
135
- /docker/shared-skills → /data/.openclaw/shared-skills (ro)
136
- /docker/rev4a-rules → /root/.openclaw/workspace/.rev4a (ro)
137
- agent-{name}-data (named vol) → /root (rw, per-agent persistent state)
135
+ ~/.config/rev4a/shared/shared-skills → /data/.openclaw/shared-skills (ro)
136
+ ~/.config/rev4a/shared/rev4a-rules → /root/.openclaw/workspace/.rev4a (ro)
137
+ agent-{name}-data (named vol) → /root (rw, per-agent persistent state)
138
138
  ```
139
139
 
140
140
  All shared volumes are defined centrally in `lib/agent-setup.ts`
@@ -287,9 +287,9 @@ GET /api/agents List all agents (Docker containers with AGENT
287
287
 
288
288
  ## 6. Shared Volumes
289
289
 
290
- ### Shared Skills (`/docker/shared-skills`)
290
+ ### Shared Skills (`~/.config/rev4a/shared/shared-skills`)
291
291
  ```
292
- /docker/shared-skills/
292
+ ~/.config/rev4a/shared/shared-skills/
293
293
  my-custom-skill/ # promoted from an agent workspace
294
294
  SKILL.md
295
295
  another-skill/
@@ -306,12 +306,30 @@ This keeps the layout simple and avoids hierarchy debates at this stage.
306
306
  **Auto-configuration:**
307
307
  - The agent base image (`openclaw-agent-base`) pre-configures `skills.load.extraDirs: ["/data/.openclaw/shared-skills"]` so every new agent discovers shared skills automatically.
308
308
  - Both create and recreate routes guarantee this via `applyRuntimeConfig()` from `lib/agent-setup.ts`.
309
- - On first install, create `/docker/shared-skills` with `mkdir -p /docker/shared-skills && chmod 2775 /docker/shared-skills`.
309
+ - No manual setup: `rev4a serve` creates both shared directories during
310
+ preflight (`ensureRev4aRules()` in `bin/rev4a.js`). They live under
311
+ `REV4A_DATA`, so a plain `mkdir` suffices — no `sudo`, no permission
312
+ bits to set.
313
+
314
+ > These directories used to live at `/docker/shared-skills` and
315
+ > `/docker/rev4a-rules`, which needed `sudo` on Linux and could not exist at
316
+ > all on macOS — the root volume is a sealed APFS system volume, so `/docker`
317
+ > is not creatable even as root, and agent creation failed with
318
+ > `mounts denied`. Being under the user's home also puts them inside the
319
+ > directories Docker Desktop shares with its Linux VM by default, so bind
320
+ > mounts work with no extra configuration.
321
+ >
322
+ > Upgrading an existing install: move any promoted skills from
323
+ > `/docker/shared-skills` into the new location, then hit **Recreate** on each
324
+ > agent so its container picks up the new mount source. Containers freeze
325
+ > their mounts at creation time, so running agents keep using the old path
326
+ > until recreated. `rev4a-rules/AGENTS.md` needs no migration — it is
327
+ > regenerated from the package on every start.
310
328
 
311
329
  ### Rev4a System Rules (`rev4a-rules/`)
312
330
 
313
331
  Versioned in the repo at `rev4a-rules/AGENTS.md`. Deployed to
314
- `/docker/rev4a-rules/` on every `rev4a serve` startup via
332
+ `~/.config/rev4a/shared/rev4a-rules/` on every `rev4a serve` startup via
315
333
  `ensureRev4aRules()` in `bin/rev4a.js`. Mounted read-only into every
316
334
  agent container and injected into sessions through the
317
335
  `bootstrap-extra-files` hook (glob `.rev4a/*.md`).
@@ -34,6 +34,7 @@ All shared UI primitives live in `app/components/ui/` and are exported from `app
34
34
  | `PropertyList` | `PropertyList.tsx` | 2-column grid for label/value pairs. Children rendered as `<Fragment>` — do not wrap in extra `<div>`. |
35
35
  | `TemplateOption` | `TemplateOption.tsx` | Agent template selection card with avatar, description, optional badge. Centralises create-agent card styling. |
36
36
  | `Pill` | `Pill.tsx` | Small status/attribute label. Variants: `default`, `accent`. |
37
+ | `ModalityIcons` | `ModalityIcons.tsx` | Capability badges parsed from a model's `modality` string (`"text+image->text"`). Input types render as icons; a non-text **output** is called out separately, since reading an image and generating one are different capabilities. Pass `dynamic` for router models, which advertise the union of everything they might route to and so show "varies" instead. |
37
38
  | `Metric` | `Metric.tsx` | Metric card with title, value, subtitle, tone. |
38
39
  | `StatusCard` | `StatusCard.tsx` | Health/status report card. |
39
40
  | `Surface` | `Surface.tsx` | Shared panel/card surface, variant prop. |
package/docs/REV4A.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Rev4a — VPS Dashboard
2
2
 
3
- > **Last updated:** 2026-07-27
3
+ > **Last updated:** 2026-08-09
4
4
 
5
5
  A Next.js 16 dashboard for monitoring and managing the OpenClaw ecosystem.
6
6
 
@@ -52,15 +52,24 @@ A Next.js 16 dashboard for monitoring and managing the OpenClaw ecosystem.
52
52
 
53
53
  ### Environment
54
54
 
55
- Rev4a reads the following environment variables. Set them in `/config` (UI) or directly in `.env`.
55
+ Rev4a reads the following environment variables. Set them in `/config` (UI) or directly in `.env`. See [.env.example](../.env.example) for the canonical, always-current list.
56
56
 
57
57
  | Variable | Required | Description |
58
58
  |---|---|---|
59
59
  | `REV4A_PASSWORD` | Yes | Login password for the dashboard |
60
60
  | `REV4A_TOKEN` | Yes | Token used for programmatic API access |
61
61
  | `REV4A_JWT_SECRET` | Yes | Secret used to sign session JWT tokens |
62
- | `REV4A_DB` | No | Path to the events database (default: `data/events.db`) |
62
+ | `REV4A_API_KEY` | No | API key for the Gateway provider endpoint (auto-generated) |
63
+ | `GATEWAY_TOKEN` | No | Shared token handed to agent containers |
64
+ | `REV4A_DATA_DIR` | No | Root of all persistent data (default: `~/.config/rev4a`) |
65
+ | `REV4A_DB` | No | Path to the events database (default: `<data dir>/data/events.db`) |
66
+ | `REV4A_CREDENTIALS_DB` | No | Path to the credentials vault database |
63
67
  | `REV4A_WORKSPACE_ROOT` | No | Filesystem path for the local file explorer |
68
+ | `REV4A_ROOT` | No | Installation root, used by `rev4a update` to detect a git checkout |
69
+ | `REV4A_TIMEZONE` | No | IANA timezone used for scheduling and timestamps |
70
+ | `REV4A_FORCE_INSECURE_COOKIE` | No | Allow a non-Secure session cookie — plain-HTTP deployments only |
71
+
72
+ See [Alerts](#alerts-telegram) below for the Telegram alert variables.
64
73
 
65
74
  ## Agent creation
66
75
 
@@ -138,6 +147,29 @@ REV4A_JWT_SECRET=*** # Secret for signing JWTs
138
147
 
139
148
  ---
140
149
 
150
+ ## Alerts (Telegram)
151
+
152
+ Rev4a can send Telegram alerts when the events database goes stale (no
153
+ recent writes — see `/api/system-health`). Implemented in `lib/alerts.ts`.
154
+
155
+ **Env vars:**
156
+ ```
157
+ REV4A_ALERTS_ENABLED=false # Master switch — no alerts sent unless "true"
158
+ REV4A_TELEGRAM_BOT_TOKEN= # Telegram bot token
159
+ REV4A_TELEGRAM_CHAT_ID= # Telegram chat to notify
160
+ REV4A_ALERT_COOLDOWN_MS=600000 # Minimum time between repeat alerts for the same condition
161
+ REV4A_ALERT_STALE_SECONDS=120 # How long without a DB write before the system is considered stale
162
+ REV4A_ALERT_SMOKE=false # Set true to send a one-off test alert on startup
163
+ ```
164
+
165
+ **Behavior:**
166
+ - If `REV4A_ALERTS_ENABLED` is not `"true"`, `maybeSendAlert` is a no-op — nothing is sent, nothing is recorded.
167
+ - If enabled but `REV4A_TELEGRAM_BOT_TOKEN` or `REV4A_TELEGRAM_CHAT_ID` is missing, alerts run in **dry-run**: state transitions are still recorded in the `alert_state` table (SQLite), but no Telegram message is sent — visible in server logs as `[alerts] dry-run`.
168
+ - Alert state (`ok` / `alerted` / `resolved`) is tracked per `alert_key` in `alert_state`, so a resolved condition sends a follow-up "✅ resolved" message instead of staying silent.
169
+ - `REV4A_ALERT_COOLDOWN_MS` prevents repeat-sending the same stale alert while the condition persists.
170
+
171
+ ---
172
+
141
173
  ## Service Management
142
174
 
143
175
  ```bash
@@ -167,8 +199,6 @@ Environment=REV4A_PASSWORD=***
167
199
  Environment=REV4A_TOKEN=***
168
200
  Environment=REV4A_JWT_SECRET=***
169
201
  Environment=REV4A_DB=/path/to/rev4a/data/events.db
170
- Environment=OPENCLAW_CONFIG_PATH=/path/to/openclaw-core.json
171
- Environment=SHARED_CONTEXT_DIR=/path/to/shared-context
172
202
  Environment=NODE_ENV=production
173
203
  Environment=NEXT_TELEMETRY_DISABLED=1
174
204
  ```
@@ -498,7 +528,7 @@ curl -b /tmp/cookies.txt -X PUT http://localhost:3740/api/workspace \
498
528
  ├── public/ # Static assets
499
529
  ├── scripts/ # Utility scripts (setup, helpers)
500
530
  ├── .env # Local env vars (gitignored)
501
- ├── proxy.ts # Auth proxy
531
+ ├── proxy.ts # Auth gate — redirects to /login without a valid JWT cookie
502
532
  ├── next.config.mjs
503
533
  └── package.json
504
534
  ```
@@ -197,6 +197,11 @@ Returns current provider configuration state.
197
197
 
198
198
  **Auth:** any auth method
199
199
 
200
+ **Query params:**
201
+ | Param | Effect |
202
+ |---|---|
203
+ | `summary=1` | Returns only `{ provider, label, configured }` per provider — no `models`, no `pricing`, no `apiKey`. The full response awaits live pricing from OpenRouter, a network round trip costing ~2.4s on a cold cache; callers that only need to know whether *any* provider is configured should use this. |
204
+
200
205
  **Response:**
201
206
  ```json
202
207
  {
@@ -1562,7 +1567,7 @@ Enable or disable a plugin.
1562
1567
  ## Skills
1563
1568
 
1564
1569
  Skill discovery across three sources:
1565
- - **Shared:** skills in `/docker/shared-skills` on the VPS host (global, available to all agents)
1570
+ - **Shared:** skills in `~/.config/rev4a/shared/shared-skills` on the VPS host (global, available to all agents)
1566
1571
  - **Workspace:** skills in `<workspace>/skills` inside each agent container (discovered via `docker exec`)
1567
1572
  - **Bundled:** skills shipped with OpenClaw (read-only)
1568
1573
 
@@ -1578,8 +1583,8 @@ List all available skills from all sources.
1578
1583
  {
1579
1584
  "name": "code-review",
1580
1585
  "type": "shared",
1581
- "path": "/docker/shared-skills/code-review",
1582
- "skillMdPath": "/docker/shared-skills/code-review/SKILL.md",
1586
+ "path": "~/.config/rev4a/shared/shared-skills/code-review",
1587
+ "skillMdPath": "~/.config/rev4a/shared/shared-skills/code-review/SKILL.md",
1583
1588
  "hasSkillMd": true,
1584
1589
  "description": "Systematic code review patterns...",
1585
1590
  "version": "1.0"
@@ -1643,7 +1648,7 @@ Copy a skill from an agent's workspace to the shared skills directory, making it
1643
1648
  "skillName": "my-custom-skill",
1644
1649
  "agentId": "atlas",
1645
1650
  "containerName": "openclaw-atlas",
1646
- "destPath": "/docker/shared-skills/my-custom-skill",
1651
+ "destPath": "~/.config/rev4a/shared/shared-skills/my-custom-skill",
1647
1652
  "overwritten": false,
1648
1653
  "extraCopied": 0
1649
1654
  }