@symerian/symi 3.0.18 → 3.0.19

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 (116) hide show
  1. package/dist/build-info.json +3 -3
  2. package/dist/canvas-host/a2ui/.bundle.hash +1 -1
  3. package/package.json +1 -1
  4. package/extensions/copilot-proxy/README.md +0 -24
  5. package/extensions/copilot-proxy/index.ts +0 -154
  6. package/extensions/copilot-proxy/node_modules/.bin/symi +0 -21
  7. package/extensions/copilot-proxy/package.json +0 -15
  8. package/extensions/copilot-proxy/symi.plugin.json +0 -9
  9. package/extensions/device-pair/index.ts +0 -642
  10. package/extensions/device-pair/symi.plugin.json +0 -20
  11. package/extensions/diagnostics-otel/index.ts +0 -15
  12. package/extensions/diagnostics-otel/node_modules/.bin/acorn +0 -21
  13. package/extensions/diagnostics-otel/node_modules/.bin/symi +0 -21
  14. package/extensions/diagnostics-otel/package.json +0 -27
  15. package/extensions/diagnostics-otel/src/service.test.ts +0 -290
  16. package/extensions/diagnostics-otel/src/service.ts +0 -666
  17. package/extensions/diagnostics-otel/symi.plugin.json +0 -8
  18. package/extensions/google-antigravity-auth/README.md +0 -24
  19. package/extensions/google-antigravity-auth/index.ts +0 -424
  20. package/extensions/google-antigravity-auth/node_modules/.bin/symi +0 -21
  21. package/extensions/google-antigravity-auth/package.json +0 -15
  22. package/extensions/google-antigravity-auth/symi.plugin.json +0 -9
  23. package/extensions/google-gemini-cli-auth/README.md +0 -35
  24. package/extensions/google-gemini-cli-auth/index.ts +0 -75
  25. package/extensions/google-gemini-cli-auth/node_modules/.bin/symi +0 -21
  26. package/extensions/google-gemini-cli-auth/oauth.test.ts +0 -162
  27. package/extensions/google-gemini-cli-auth/oauth.ts +0 -636
  28. package/extensions/google-gemini-cli-auth/package.json +0 -15
  29. package/extensions/google-gemini-cli-auth/symi.plugin.json +0 -9
  30. package/extensions/learning-loop/index.ts +0 -159
  31. package/extensions/learning-loop/node_modules/.bin/symi +0 -21
  32. package/extensions/learning-loop/package.json +0 -18
  33. package/extensions/learning-loop/src/analytics/gateway-methods.ts +0 -230
  34. package/extensions/learning-loop/src/analytics/metrics-aggregator.ts +0 -153
  35. package/extensions/learning-loop/src/capture/run-tracker.ts +0 -181
  36. package/extensions/learning-loop/src/capture/serializer.ts +0 -74
  37. package/extensions/learning-loop/src/db.ts +0 -583
  38. package/extensions/learning-loop/src/feedback/explicit-feedback.ts +0 -58
  39. package/extensions/learning-loop/src/feedback/implicit-signals.ts +0 -89
  40. package/extensions/learning-loop/src/graph/edge-inference.ts +0 -189
  41. package/extensions/learning-loop/src/graph/graph-retrieval.ts +0 -144
  42. package/extensions/learning-loop/src/graph/graph-store.ts +0 -183
  43. package/extensions/learning-loop/src/hooks.ts +0 -244
  44. package/extensions/learning-loop/src/injection/cache.ts +0 -73
  45. package/extensions/learning-loop/src/injection/context-injector.ts +0 -104
  46. package/extensions/learning-loop/src/injection/prompt-builder.ts +0 -43
  47. package/extensions/learning-loop/src/learning/embedding-bridge.ts +0 -54
  48. package/extensions/learning-loop/src/learning/learning-extractor.ts +0 -217
  49. package/extensions/learning-loop/src/learning/learning-store.ts +0 -158
  50. package/extensions/learning-loop/src/learning/retrieval.ts +0 -87
  51. package/extensions/learning-loop/src/math/confidence-intervals.ts +0 -62
  52. package/extensions/learning-loop/src/math/ewma.ts +0 -51
  53. package/extensions/learning-loop/src/math/weighted-scorer.ts +0 -42
  54. package/extensions/learning-loop/src/schema.ts +0 -176
  55. package/extensions/learning-loop/src/scoring/normalization.ts +0 -32
  56. package/extensions/learning-loop/src/scoring/quality-engine.ts +0 -78
  57. package/extensions/learning-loop/src/scoring/signal-extractors.ts +0 -155
  58. package/extensions/learning-loop/src/test/context-injector.test.ts +0 -142
  59. package/extensions/learning-loop/src/test/fixes.test.ts +0 -1286
  60. package/extensions/learning-loop/src/test/graph.test.ts +0 -711
  61. package/extensions/learning-loop/src/test/integration.test.ts +0 -312
  62. package/extensions/learning-loop/src/test/learning-store.test.ts +0 -191
  63. package/extensions/learning-loop/src/test/math.test.ts +0 -148
  64. package/extensions/learning-loop/src/test/quality-engine.test.ts +0 -231
  65. package/extensions/learning-loop/src/test/run-tracker.test.ts +0 -143
  66. package/extensions/learning-loop/src/types.ts +0 -281
  67. package/extensions/learning-loop/symi.plugin.json +0 -46
  68. package/extensions/llm-task/README.md +0 -97
  69. package/extensions/llm-task/index.ts +0 -6
  70. package/extensions/llm-task/package.json +0 -12
  71. package/extensions/llm-task/src/llm-task-tool.test.ts +0 -138
  72. package/extensions/llm-task/src/llm-task-tool.ts +0 -249
  73. package/extensions/llm-task/symi.plugin.json +0 -21
  74. package/extensions/memory-lancedb/config.ts +0 -161
  75. package/extensions/memory-lancedb/index.test.ts +0 -330
  76. package/extensions/memory-lancedb/index.ts +0 -670
  77. package/extensions/memory-lancedb/node_modules/.bin/arrow2csv +0 -21
  78. package/extensions/memory-lancedb/node_modules/.bin/openai +0 -21
  79. package/extensions/memory-lancedb/node_modules/.bin/symi +0 -21
  80. package/extensions/memory-lancedb/package.json +0 -20
  81. package/extensions/memory-lancedb/symi.plugin.json +0 -71
  82. package/extensions/minimax-portal-auth/README.md +0 -33
  83. package/extensions/minimax-portal-auth/index.ts +0 -161
  84. package/extensions/minimax-portal-auth/node_modules/.bin/symi +0 -21
  85. package/extensions/minimax-portal-auth/oauth.ts +0 -247
  86. package/extensions/minimax-portal-auth/package.json +0 -15
  87. package/extensions/minimax-portal-auth/symi.plugin.json +0 -9
  88. package/extensions/model-equalizer/index.ts +0 -80
  89. package/extensions/model-equalizer/skills/model-equalizer/SKILL.md +0 -58
  90. package/extensions/model-equalizer/src/detection.ts +0 -62
  91. package/extensions/model-equalizer/src/enhancer.ts +0 -63
  92. package/extensions/model-equalizer/src/test/detection.test.ts +0 -218
  93. package/extensions/model-equalizer/src/test/enhancer.test.ts +0 -137
  94. package/extensions/model-equalizer/src/test/integration.test.ts +0 -185
  95. package/extensions/model-equalizer/src/types.ts +0 -24
  96. package/extensions/model-equalizer/symi.plugin.json +0 -12
  97. package/extensions/phone-control/index.ts +0 -421
  98. package/extensions/phone-control/symi.plugin.json +0 -10
  99. package/extensions/pipeline/README.md +0 -75
  100. package/extensions/pipeline/SKILL.md +0 -97
  101. package/extensions/pipeline/index.ts +0 -18
  102. package/extensions/pipeline/package.json +0 -11
  103. package/extensions/pipeline/src/pipeline-tool.test.ts +0 -345
  104. package/extensions/pipeline/src/pipeline-tool.ts +0 -266
  105. package/extensions/pipeline/src/windows-spawn.test.ts +0 -148
  106. package/extensions/pipeline/src/windows-spawn.ts +0 -193
  107. package/extensions/pipeline/symi.plugin.json +0 -10
  108. package/extensions/qwen-portal-auth/README.md +0 -24
  109. package/extensions/qwen-portal-auth/index.ts +0 -134
  110. package/extensions/qwen-portal-auth/oauth.ts +0 -190
  111. package/extensions/qwen-portal-auth/symi.plugin.json +0 -9
  112. package/extensions/talk-voice/index.ts +0 -150
  113. package/extensions/talk-voice/symi.plugin.json +0 -10
  114. package/extensions/thread-ownership/index.test.ts +0 -180
  115. package/extensions/thread-ownership/index.ts +0 -133
  116. package/extensions/thread-ownership/symi.plugin.json +0 -28
@@ -1,185 +0,0 @@
1
- import type { SymiConfig } from "symi/plugin-sdk";
2
- import { describe, it, expect } from "vitest";
3
- import { buildProviderMap, isLocalProvider, resolveDefaultProvider } from "../detection.js";
4
- import { buildEnhancement } from "../enhancer.js";
5
- import { resolveConfig } from "../types.js";
6
-
7
- describe("Model Equalizer Integration", () => {
8
- // Simulate the full flow that index.ts performs
9
-
10
- function simulateEnhancement(
11
- config: SymiConfig,
12
- pluginConfig: Record<string, unknown> | undefined,
13
- prompt: string,
14
- cachedProvider?: string,
15
- ): string | null {
16
- const cfg = resolveConfig(pluginConfig);
17
- if (!cfg.enabled) return null;
18
-
19
- const providerMap = buildProviderMap(config);
20
- const provider = cachedProvider ?? resolveDefaultProvider(config);
21
- if (!provider) return null;
22
-
23
- const isLocal = providerMap.get(provider.trim().toLowerCase()) ?? isLocalProvider(provider);
24
- if (!isLocal) return null;
25
-
26
- return buildEnhancement(prompt, {
27
- intensity: cfg.intensity,
28
- minPromptLength: cfg.minPromptLength,
29
- });
30
- }
31
-
32
- it("should enhance prompts when default agent uses ollama", () => {
33
- const config: SymiConfig = {
34
- models: {
35
- providers: {
36
- ollama: { baseUrl: "http://localhost:11434", models: [] },
37
- },
38
- },
39
- agents: {
40
- list: [{ id: "main", default: true, model: "ollama/llama3.1" }],
41
- },
42
- };
43
- const result = simulateEnhancement(config, undefined, "Explain how async/await works in JS");
44
- expect(result).not.toBeNull();
45
- expect(result).toContain("<model-equalizer>");
46
- });
47
-
48
- it("should not enhance prompts when default agent uses openai", () => {
49
- const config: SymiConfig = {
50
- models: {
51
- providers: {
52
- openai: { baseUrl: "https://api.openai.com/v1", models: [] },
53
- },
54
- },
55
- agents: {
56
- list: [{ id: "main", default: true, model: "openai/gpt-4o" }],
57
- },
58
- };
59
- const result = simulateEnhancement(config, undefined, "Explain how async/await works in JS");
60
- expect(result).toBeNull();
61
- });
62
-
63
- it("should enhance when cached provider is local", () => {
64
- const config: SymiConfig = {
65
- models: {
66
- providers: {
67
- openai: { baseUrl: "https://api.openai.com/v1", models: [] },
68
- ollama: { baseUrl: "http://localhost:11434", models: [] },
69
- },
70
- },
71
- agents: {
72
- list: [{ id: "main", default: true, model: "openai/gpt-4o" }],
73
- },
74
- };
75
- // Default agent is openai, but cached provider is ollama
76
- const result = simulateEnhancement(
77
- config,
78
- undefined,
79
- "Explain how async/await works in JS",
80
- "ollama",
81
- );
82
- expect(result).not.toBeNull();
83
- });
84
-
85
- it("should not enhance when cached provider is remote", () => {
86
- const config: SymiConfig = {
87
- models: {
88
- providers: {
89
- ollama: { baseUrl: "http://localhost:11434", models: [] },
90
- openai: { baseUrl: "https://api.openai.com/v1", models: [] },
91
- },
92
- },
93
- agents: {
94
- list: [{ id: "main", default: true, model: "ollama/llama3" }],
95
- },
96
- };
97
- // Default agent is ollama, but cached provider override is openai
98
- const result = simulateEnhancement(
99
- config,
100
- undefined,
101
- "Explain how async/await works in JS",
102
- "openai",
103
- );
104
- expect(result).toBeNull();
105
- });
106
-
107
- it("should respect enabled=false in plugin config", () => {
108
- const config: SymiConfig = {
109
- agents: {
110
- list: [{ id: "main", default: true, model: "ollama/llama3.1" }],
111
- },
112
- };
113
- const result = simulateEnhancement(config, { enabled: false }, "Explain something");
114
- expect(result).toBeNull();
115
- });
116
-
117
- it("should detect local provider by baseUrl even with unknown name", () => {
118
- const config: SymiConfig = {
119
- models: {
120
- providers: {
121
- "my-server": { baseUrl: "http://127.0.0.1:5000/v1", models: [] },
122
- },
123
- },
124
- agents: {
125
- list: [{ id: "main", default: true, model: "my-server/custom-model" }],
126
- },
127
- };
128
- const result = simulateEnhancement(config, undefined, "Explain something in detail");
129
- expect(result).not.toBeNull();
130
- });
131
-
132
- it("should skip very short prompts even for local providers", () => {
133
- const config: SymiConfig = {
134
- agents: {
135
- list: [{ id: "main", default: true, model: "ollama/llama3" }],
136
- },
137
- };
138
- const result = simulateEnhancement(config, undefined, "hi");
139
- expect(result).toBeNull();
140
- });
141
-
142
- it("should use aggressive intensity when configured", () => {
143
- const config: SymiConfig = {
144
- agents: {
145
- list: [{ id: "main", default: true, model: "ollama/llama3" }],
146
- },
147
- };
148
- const result = simulateEnhancement(config, { intensity: "aggressive" }, "Simple question?");
149
- expect(result).not.toBeNull();
150
- expect(result).toContain("Decompose");
151
- });
152
-
153
- it("should return null when no agents are configured", () => {
154
- const config: SymiConfig = {};
155
- const result = simulateEnhancement(config, undefined, "Explain something");
156
- expect(result).toBeNull();
157
- });
158
- });
159
-
160
- describe("resolveConfig", () => {
161
- it("should apply defaults for missing values", () => {
162
- const cfg = resolveConfig(undefined);
163
- expect(cfg.enabled).toBe(true);
164
- expect(cfg.intensity).toBe("standard");
165
- expect(cfg.minPromptLength).toBe(10);
166
- });
167
-
168
- it("should preserve explicit values", () => {
169
- const cfg = resolveConfig({
170
- enabled: false,
171
- intensity: "aggressive",
172
- minPromptLength: 50,
173
- });
174
- expect(cfg.enabled).toBe(false);
175
- expect(cfg.intensity).toBe("aggressive");
176
- expect(cfg.minPromptLength).toBe(50);
177
- });
178
-
179
- it("should handle partial config", () => {
180
- const cfg = resolveConfig({ intensity: "light" });
181
- expect(cfg.enabled).toBe(true);
182
- expect(cfg.intensity).toBe("light");
183
- expect(cfg.minPromptLength).toBe(10);
184
- });
185
- });
@@ -1,24 +0,0 @@
1
- export type ModelEqualizerConfig = {
2
- /** Whether the equalizer is active. Default: true. */
3
- enabled?: boolean;
4
- /** Enhancement intensity. Default: "standard". */
5
- intensity?: "light" | "standard" | "aggressive";
6
- /** Skip enhancement for prompts shorter than this. Default: 10. */
7
- minPromptLength?: number;
8
- };
9
-
10
- export type DetectionState = {
11
- /** Cached provider per session key. */
12
- sessionProviders: Map<string, string>;
13
- /** Pre-built provider→isLocal map from config. */
14
- providerMap: Map<string, boolean>;
15
- };
16
-
17
- export function resolveConfig(raw: Record<string, unknown> | undefined): ModelEqualizerConfig {
18
- const cfg = (raw ?? {}) as Partial<ModelEqualizerConfig>;
19
- return {
20
- enabled: cfg.enabled ?? true,
21
- intensity: cfg.intensity ?? "standard",
22
- minPromptLength: cfg.minPromptLength ?? 10,
23
- };
24
- }
@@ -1,12 +0,0 @@
1
- {
2
- "id": "model-equalizer",
3
- "configSchema": {
4
- "type": "object",
5
- "additionalProperties": false,
6
- "properties": {
7
- "enabled": { "type": "boolean" },
8
- "intensity": { "enum": ["light", "standard", "aggressive"] },
9
- "minPromptLength": { "type": "number" }
10
- }
11
- }
12
- }
@@ -1,421 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import type { SymiPluginApi, SymiPluginService } from "symi/plugin-sdk";
4
-
5
- type ArmGroup = "camera" | "screen" | "writes" | "all";
6
-
7
- type ArmStateFileV1 = {
8
- version: 1;
9
- armedAtMs: number;
10
- expiresAtMs: number | null;
11
- removedFromDeny: string[];
12
- };
13
-
14
- type ArmStateFileV2 = {
15
- version: 2;
16
- armedAtMs: number;
17
- expiresAtMs: number | null;
18
- group: ArmGroup;
19
- armedCommands: string[];
20
- addedToAllow: string[];
21
- removedFromDeny: string[];
22
- };
23
-
24
- type ArmStateFile = ArmStateFileV1 | ArmStateFileV2;
25
-
26
- const STATE_VERSION = 2;
27
- const STATE_REL_PATH = ["plugins", "phone-control", "armed.json"] as const;
28
-
29
- const GROUP_COMMANDS: Record<Exclude<ArmGroup, "all">, string[]> = {
30
- camera: ["camera.snap", "camera.clip"],
31
- screen: ["screen.record"],
32
- writes: ["calendar.add", "contacts.add", "reminders.add"],
33
- };
34
-
35
- function uniqSorted(values: string[]): string[] {
36
- return [...new Set(values.map((v) => v.trim()).filter(Boolean))].toSorted();
37
- }
38
-
39
- function resolveCommandsForGroup(group: ArmGroup): string[] {
40
- if (group === "all") {
41
- return uniqSorted(Object.values(GROUP_COMMANDS).flat());
42
- }
43
- return uniqSorted(GROUP_COMMANDS[group]);
44
- }
45
-
46
- function formatGroupList(): string {
47
- return ["camera", "screen", "writes", "all"].join(", ");
48
- }
49
-
50
- function parseDurationMs(input: string | undefined): number | null {
51
- if (!input) {
52
- return null;
53
- }
54
- const raw = input.trim().toLowerCase();
55
- if (!raw) {
56
- return null;
57
- }
58
- const m = raw.match(/^(\d+)(s|m|h|d)$/);
59
- if (!m) {
60
- return null;
61
- }
62
- const n = Number.parseInt(m[1] ?? "", 10);
63
- if (!Number.isFinite(n) || n <= 0) {
64
- return null;
65
- }
66
- const unit = m[2];
67
- const mult = unit === "s" ? 1000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
68
- return n * mult;
69
- }
70
-
71
- function formatDuration(ms: number): string {
72
- const s = Math.max(0, Math.floor(ms / 1000));
73
- if (s < 60) {
74
- return `${s}s`;
75
- }
76
- const m = Math.floor(s / 60);
77
- if (m < 60) {
78
- return `${m}m`;
79
- }
80
- const h = Math.floor(m / 60);
81
- if (h < 48) {
82
- return `${h}h`;
83
- }
84
- const d = Math.floor(h / 24);
85
- return `${d}d`;
86
- }
87
-
88
- function resolveStatePath(stateDir: string): string {
89
- return path.join(stateDir, ...STATE_REL_PATH);
90
- }
91
-
92
- async function readArmState(statePath: string): Promise<ArmStateFile | null> {
93
- try {
94
- const raw = await fs.readFile(statePath, "utf8");
95
- // Type as unknown record first to allow property access during validation
96
- const parsed = JSON.parse(raw) as Record<string, unknown>;
97
- if (parsed.version !== 1 && parsed.version !== 2) {
98
- return null;
99
- }
100
- if (typeof parsed.armedAtMs !== "number") {
101
- return null;
102
- }
103
- if (!(parsed.expiresAtMs === null || typeof parsed.expiresAtMs === "number")) {
104
- return null;
105
- }
106
-
107
- if (parsed.version === 1) {
108
- if (
109
- !Array.isArray(parsed.removedFromDeny) ||
110
- !parsed.removedFromDeny.every((v: unknown) => typeof v === "string")
111
- ) {
112
- return null;
113
- }
114
- return parsed as unknown as ArmStateFile;
115
- }
116
-
117
- const group = typeof parsed.group === "string" ? parsed.group : "";
118
- if (group !== "camera" && group !== "screen" && group !== "writes" && group !== "all") {
119
- return null;
120
- }
121
- if (
122
- !Array.isArray(parsed.armedCommands) ||
123
- !parsed.armedCommands.every((v: unknown) => typeof v === "string")
124
- ) {
125
- return null;
126
- }
127
- if (
128
- !Array.isArray(parsed.addedToAllow) ||
129
- !parsed.addedToAllow.every((v: unknown) => typeof v === "string")
130
- ) {
131
- return null;
132
- }
133
- if (
134
- !Array.isArray(parsed.removedFromDeny) ||
135
- !parsed.removedFromDeny.every((v: unknown) => typeof v === "string")
136
- ) {
137
- return null;
138
- }
139
- return parsed as unknown as ArmStateFile;
140
- } catch {
141
- return null;
142
- }
143
- }
144
-
145
- async function writeArmState(statePath: string, state: ArmStateFile | null): Promise<void> {
146
- await fs.mkdir(path.dirname(statePath), { recursive: true });
147
- if (!state) {
148
- try {
149
- await fs.unlink(statePath);
150
- } catch {
151
- // ignore
152
- }
153
- return;
154
- }
155
- await fs.writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
156
- }
157
-
158
- function normalizeDenyList(cfg: SymiPluginApi["config"]): string[] {
159
- return uniqSorted([...(cfg.gateway?.nodes?.denyCommands ?? [])]);
160
- }
161
-
162
- function normalizeAllowList(cfg: SymiPluginApi["config"]): string[] {
163
- return uniqSorted([...(cfg.gateway?.nodes?.allowCommands ?? [])]);
164
- }
165
-
166
- function patchConfigNodeLists(
167
- cfg: SymiPluginApi["config"],
168
- next: { allowCommands: string[]; denyCommands: string[] },
169
- ): SymiPluginApi["config"] {
170
- return {
171
- ...cfg,
172
- gateway: {
173
- ...cfg.gateway,
174
- nodes: {
175
- ...cfg.gateway?.nodes,
176
- allowCommands: next.allowCommands,
177
- denyCommands: next.denyCommands,
178
- },
179
- },
180
- };
181
- }
182
-
183
- async function disarmNow(params: {
184
- api: SymiPluginApi;
185
- stateDir: string;
186
- statePath: string;
187
- reason: string;
188
- }): Promise<{ changed: boolean; restored: string[]; removed: string[] }> {
189
- const { api, stateDir, statePath, reason } = params;
190
- const state = await readArmState(statePath);
191
- if (!state) {
192
- return { changed: false, restored: [], removed: [] };
193
- }
194
- const cfg = api.runtime.config.loadConfig();
195
- const allow = new Set(normalizeAllowList(cfg));
196
- const deny = new Set(normalizeDenyList(cfg));
197
- const removed: string[] = [];
198
- const restored: string[] = [];
199
-
200
- if (state.version === 1) {
201
- for (const cmd of state.removedFromDeny) {
202
- if (!deny.has(cmd)) {
203
- deny.add(cmd);
204
- restored.push(cmd);
205
- }
206
- }
207
- } else {
208
- for (const cmd of state.addedToAllow) {
209
- if (allow.delete(cmd)) {
210
- removed.push(cmd);
211
- }
212
- }
213
- for (const cmd of state.removedFromDeny) {
214
- if (!deny.has(cmd)) {
215
- deny.add(cmd);
216
- restored.push(cmd);
217
- }
218
- }
219
- }
220
-
221
- if (removed.length > 0 || restored.length > 0) {
222
- const next = patchConfigNodeLists(cfg, {
223
- allowCommands: uniqSorted([...allow]),
224
- denyCommands: uniqSorted([...deny]),
225
- });
226
- await api.runtime.config.writeConfigFile(next);
227
- }
228
- await writeArmState(statePath, null);
229
- api.logger.info(`phone-control: disarmed (${reason}) stateDir=${stateDir}`);
230
- return {
231
- changed: removed.length > 0 || restored.length > 0,
232
- removed: uniqSorted(removed),
233
- restored: uniqSorted(restored),
234
- };
235
- }
236
-
237
- function formatHelp(): string {
238
- return [
239
- "Phone control commands:",
240
- "",
241
- "/phone status",
242
- "/phone arm <group> [duration]",
243
- "/phone disarm",
244
- "",
245
- "Groups:",
246
- `- ${formatGroupList()}`,
247
- "",
248
- "Duration format: 30s | 10m | 2h | 1d (default: 10m).",
249
- "",
250
- "Notes:",
251
- "- This only toggles what the gateway is allowed to invoke on phone nodes.",
252
- "- iOS will still ask for permissions (camera, photos, contacts, etc.) on first use.",
253
- ].join("\n");
254
- }
255
-
256
- function parseGroup(raw: string | undefined): ArmGroup | null {
257
- const value = (raw ?? "").trim().toLowerCase();
258
- if (!value) {
259
- return null;
260
- }
261
- if (value === "camera" || value === "screen" || value === "writes" || value === "all") {
262
- return value;
263
- }
264
- return null;
265
- }
266
-
267
- function formatStatus(state: ArmStateFile | null): string {
268
- if (!state) {
269
- return "Phone control: disarmed.";
270
- }
271
- const until =
272
- state.expiresAtMs == null
273
- ? "manual disarm required"
274
- : `expires in ${formatDuration(Math.max(0, state.expiresAtMs - Date.now()))}`;
275
- const cmds = uniqSorted(
276
- state.version === 1
277
- ? state.removedFromDeny
278
- : state.armedCommands.length > 0
279
- ? state.armedCommands
280
- : [...state.addedToAllow, ...state.removedFromDeny],
281
- );
282
- const cmdLabel = cmds.length > 0 ? cmds.join(", ") : "none";
283
- return `Phone control: armed (${until}).\nTemporarily allowed: ${cmdLabel}`;
284
- }
285
-
286
- export default function register(api: SymiPluginApi) {
287
- let expiryInterval: ReturnType<typeof setInterval> | null = null;
288
-
289
- const timerService: SymiPluginService = {
290
- id: "phone-control-expiry",
291
- start: async (ctx) => {
292
- const statePath = resolveStatePath(ctx.stateDir);
293
- const tick = async () => {
294
- const state = await readArmState(statePath);
295
- if (!state || state.expiresAtMs == null) {
296
- return;
297
- }
298
- if (Date.now() < state.expiresAtMs) {
299
- return;
300
- }
301
- await disarmNow({
302
- api,
303
- stateDir: ctx.stateDir,
304
- statePath,
305
- reason: "expired",
306
- });
307
- };
308
-
309
- // Best effort; don't crash the gateway if state is corrupt.
310
- await tick().catch(() => {});
311
-
312
- expiryInterval = setInterval(() => {
313
- tick().catch(() => {});
314
- }, 15_000);
315
- expiryInterval.unref?.();
316
-
317
- return;
318
- },
319
- stop: async () => {
320
- if (expiryInterval) {
321
- clearInterval(expiryInterval);
322
- expiryInterval = null;
323
- }
324
- return;
325
- },
326
- };
327
-
328
- api.registerService(timerService);
329
-
330
- api.registerCommand({
331
- name: "phone",
332
- description: "Arm/disarm high-risk phone node commands (camera/screen/writes).",
333
- acceptsArgs: true,
334
- handler: async (ctx) => {
335
- const args = ctx.args?.trim() ?? "";
336
- const tokens = args.split(/\s+/).filter(Boolean);
337
- const action = tokens[0]?.toLowerCase() ?? "";
338
-
339
- const stateDir = api.runtime.state.resolveStateDir();
340
- const statePath = resolveStatePath(stateDir);
341
-
342
- if (!action || action === "help") {
343
- const state = await readArmState(statePath);
344
- return { text: `${formatStatus(state)}\n\n${formatHelp()}` };
345
- }
346
-
347
- if (action === "status") {
348
- const state = await readArmState(statePath);
349
- return { text: formatStatus(state) };
350
- }
351
-
352
- if (action === "disarm") {
353
- const res = await disarmNow({
354
- api,
355
- stateDir,
356
- statePath,
357
- reason: "manual",
358
- });
359
- if (!res.changed) {
360
- return { text: "Phone control: disarmed." };
361
- }
362
- const restoredLabel = res.restored.length > 0 ? res.restored.join(", ") : "none";
363
- const removedLabel = res.removed.length > 0 ? res.removed.join(", ") : "none";
364
- return {
365
- text: `Phone control: disarmed.\nRemoved allowlist: ${removedLabel}\nRestored denylist: ${restoredLabel}`,
366
- };
367
- }
368
-
369
- if (action === "arm") {
370
- const group = parseGroup(tokens[1]);
371
- if (!group) {
372
- return { text: `Usage: /phone arm <group> [duration]\nGroups: ${formatGroupList()}` };
373
- }
374
- const durationMs = parseDurationMs(tokens[2]) ?? 10 * 60_000;
375
- const expiresAtMs = Date.now() + durationMs;
376
-
377
- const commands = resolveCommandsForGroup(group);
378
- const cfg = api.runtime.config.loadConfig();
379
- const allowSet = new Set(normalizeAllowList(cfg));
380
- const denySet = new Set(normalizeDenyList(cfg));
381
-
382
- const addedToAllow: string[] = [];
383
- const removedFromDeny: string[] = [];
384
- for (const cmd of commands) {
385
- if (!allowSet.has(cmd)) {
386
- allowSet.add(cmd);
387
- addedToAllow.push(cmd);
388
- }
389
- if (denySet.delete(cmd)) {
390
- removedFromDeny.push(cmd);
391
- }
392
- }
393
- const next = patchConfigNodeLists(cfg, {
394
- allowCommands: uniqSorted([...allowSet]),
395
- denyCommands: uniqSorted([...denySet]),
396
- });
397
- await api.runtime.config.writeConfigFile(next);
398
-
399
- await writeArmState(statePath, {
400
- version: STATE_VERSION,
401
- armedAtMs: Date.now(),
402
- expiresAtMs,
403
- group,
404
- armedCommands: uniqSorted(commands),
405
- addedToAllow: uniqSorted(addedToAllow),
406
- removedFromDeny: uniqSorted(removedFromDeny),
407
- });
408
-
409
- const allowedLabel = uniqSorted(commands).join(", ");
410
- return {
411
- text:
412
- `Phone control: armed for ${formatDuration(durationMs)}.\n` +
413
- `Temporarily allowed: ${allowedLabel}\n` +
414
- `To disarm early: /phone disarm`,
415
- };
416
- }
417
-
418
- return { text: formatHelp() };
419
- },
420
- });
421
- }
@@ -1,10 +0,0 @@
1
- {
2
- "id": "phone-control",
3
- "name": "Phone Control",
4
- "description": "Arm/disarm high-risk phone node commands (camera/screen/writes) with an optional auto-expiry.",
5
- "configSchema": {
6
- "type": "object",
7
- "additionalProperties": false,
8
- "properties": {}
9
- }
10
- }