@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.
- package/dist/build-info.json +3 -3
- package/dist/canvas-host/a2ui/.bundle.hash +1 -1
- package/package.json +1 -1
- package/extensions/copilot-proxy/README.md +0 -24
- package/extensions/copilot-proxy/index.ts +0 -154
- package/extensions/copilot-proxy/node_modules/.bin/symi +0 -21
- package/extensions/copilot-proxy/package.json +0 -15
- package/extensions/copilot-proxy/symi.plugin.json +0 -9
- package/extensions/device-pair/index.ts +0 -642
- package/extensions/device-pair/symi.plugin.json +0 -20
- package/extensions/diagnostics-otel/index.ts +0 -15
- package/extensions/diagnostics-otel/node_modules/.bin/acorn +0 -21
- package/extensions/diagnostics-otel/node_modules/.bin/symi +0 -21
- package/extensions/diagnostics-otel/package.json +0 -27
- package/extensions/diagnostics-otel/src/service.test.ts +0 -290
- package/extensions/diagnostics-otel/src/service.ts +0 -666
- package/extensions/diagnostics-otel/symi.plugin.json +0 -8
- package/extensions/google-antigravity-auth/README.md +0 -24
- package/extensions/google-antigravity-auth/index.ts +0 -424
- package/extensions/google-antigravity-auth/node_modules/.bin/symi +0 -21
- package/extensions/google-antigravity-auth/package.json +0 -15
- package/extensions/google-antigravity-auth/symi.plugin.json +0 -9
- package/extensions/google-gemini-cli-auth/README.md +0 -35
- package/extensions/google-gemini-cli-auth/index.ts +0 -75
- package/extensions/google-gemini-cli-auth/node_modules/.bin/symi +0 -21
- package/extensions/google-gemini-cli-auth/oauth.test.ts +0 -162
- package/extensions/google-gemini-cli-auth/oauth.ts +0 -636
- package/extensions/google-gemini-cli-auth/package.json +0 -15
- package/extensions/google-gemini-cli-auth/symi.plugin.json +0 -9
- package/extensions/learning-loop/index.ts +0 -159
- package/extensions/learning-loop/node_modules/.bin/symi +0 -21
- package/extensions/learning-loop/package.json +0 -18
- package/extensions/learning-loop/src/analytics/gateway-methods.ts +0 -230
- package/extensions/learning-loop/src/analytics/metrics-aggregator.ts +0 -153
- package/extensions/learning-loop/src/capture/run-tracker.ts +0 -181
- package/extensions/learning-loop/src/capture/serializer.ts +0 -74
- package/extensions/learning-loop/src/db.ts +0 -583
- package/extensions/learning-loop/src/feedback/explicit-feedback.ts +0 -58
- package/extensions/learning-loop/src/feedback/implicit-signals.ts +0 -89
- package/extensions/learning-loop/src/graph/edge-inference.ts +0 -189
- package/extensions/learning-loop/src/graph/graph-retrieval.ts +0 -144
- package/extensions/learning-loop/src/graph/graph-store.ts +0 -183
- package/extensions/learning-loop/src/hooks.ts +0 -244
- package/extensions/learning-loop/src/injection/cache.ts +0 -73
- package/extensions/learning-loop/src/injection/context-injector.ts +0 -104
- package/extensions/learning-loop/src/injection/prompt-builder.ts +0 -43
- package/extensions/learning-loop/src/learning/embedding-bridge.ts +0 -54
- package/extensions/learning-loop/src/learning/learning-extractor.ts +0 -217
- package/extensions/learning-loop/src/learning/learning-store.ts +0 -158
- package/extensions/learning-loop/src/learning/retrieval.ts +0 -87
- package/extensions/learning-loop/src/math/confidence-intervals.ts +0 -62
- package/extensions/learning-loop/src/math/ewma.ts +0 -51
- package/extensions/learning-loop/src/math/weighted-scorer.ts +0 -42
- package/extensions/learning-loop/src/schema.ts +0 -176
- package/extensions/learning-loop/src/scoring/normalization.ts +0 -32
- package/extensions/learning-loop/src/scoring/quality-engine.ts +0 -78
- package/extensions/learning-loop/src/scoring/signal-extractors.ts +0 -155
- package/extensions/learning-loop/src/test/context-injector.test.ts +0 -142
- package/extensions/learning-loop/src/test/fixes.test.ts +0 -1286
- package/extensions/learning-loop/src/test/graph.test.ts +0 -711
- package/extensions/learning-loop/src/test/integration.test.ts +0 -312
- package/extensions/learning-loop/src/test/learning-store.test.ts +0 -191
- package/extensions/learning-loop/src/test/math.test.ts +0 -148
- package/extensions/learning-loop/src/test/quality-engine.test.ts +0 -231
- package/extensions/learning-loop/src/test/run-tracker.test.ts +0 -143
- package/extensions/learning-loop/src/types.ts +0 -281
- package/extensions/learning-loop/symi.plugin.json +0 -46
- package/extensions/llm-task/README.md +0 -97
- package/extensions/llm-task/index.ts +0 -6
- package/extensions/llm-task/package.json +0 -12
- package/extensions/llm-task/src/llm-task-tool.test.ts +0 -138
- package/extensions/llm-task/src/llm-task-tool.ts +0 -249
- package/extensions/llm-task/symi.plugin.json +0 -21
- package/extensions/memory-lancedb/config.ts +0 -161
- package/extensions/memory-lancedb/index.test.ts +0 -330
- package/extensions/memory-lancedb/index.ts +0 -670
- package/extensions/memory-lancedb/node_modules/.bin/arrow2csv +0 -21
- package/extensions/memory-lancedb/node_modules/.bin/openai +0 -21
- package/extensions/memory-lancedb/node_modules/.bin/symi +0 -21
- package/extensions/memory-lancedb/package.json +0 -20
- package/extensions/memory-lancedb/symi.plugin.json +0 -71
- package/extensions/minimax-portal-auth/README.md +0 -33
- package/extensions/minimax-portal-auth/index.ts +0 -161
- package/extensions/minimax-portal-auth/node_modules/.bin/symi +0 -21
- package/extensions/minimax-portal-auth/oauth.ts +0 -247
- package/extensions/minimax-portal-auth/package.json +0 -15
- package/extensions/minimax-portal-auth/symi.plugin.json +0 -9
- package/extensions/model-equalizer/index.ts +0 -80
- package/extensions/model-equalizer/skills/model-equalizer/SKILL.md +0 -58
- package/extensions/model-equalizer/src/detection.ts +0 -62
- package/extensions/model-equalizer/src/enhancer.ts +0 -63
- package/extensions/model-equalizer/src/test/detection.test.ts +0 -218
- package/extensions/model-equalizer/src/test/enhancer.test.ts +0 -137
- package/extensions/model-equalizer/src/test/integration.test.ts +0 -185
- package/extensions/model-equalizer/src/types.ts +0 -24
- package/extensions/model-equalizer/symi.plugin.json +0 -12
- package/extensions/phone-control/index.ts +0 -421
- package/extensions/phone-control/symi.plugin.json +0 -10
- package/extensions/pipeline/README.md +0 -75
- package/extensions/pipeline/SKILL.md +0 -97
- package/extensions/pipeline/index.ts +0 -18
- package/extensions/pipeline/package.json +0 -11
- package/extensions/pipeline/src/pipeline-tool.test.ts +0 -345
- package/extensions/pipeline/src/pipeline-tool.ts +0 -266
- package/extensions/pipeline/src/windows-spawn.test.ts +0 -148
- package/extensions/pipeline/src/windows-spawn.ts +0 -193
- package/extensions/pipeline/symi.plugin.json +0 -10
- package/extensions/qwen-portal-auth/README.md +0 -24
- package/extensions/qwen-portal-auth/index.ts +0 -134
- package/extensions/qwen-portal-auth/oauth.ts +0 -190
- package/extensions/qwen-portal-auth/symi.plugin.json +0 -9
- package/extensions/talk-voice/index.ts +0 -150
- package/extensions/talk-voice/symi.plugin.json +0 -10
- package/extensions/thread-ownership/index.test.ts +0 -180
- package/extensions/thread-ownership/index.ts +0 -133
- 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
|
-
}
|