linkgravity 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -0
  3. package/bin/cli.js +278 -0
  4. package/bin/setup.js +260 -0
  5. package/hooks/hook.py +60 -0
  6. package/hooks/stop_hook.py +64 -0
  7. package/npm-scripts/postinstall.js +62 -0
  8. package/npm-scripts/prepare.js +45 -0
  9. package/npm-scripts/register-hook.js +182 -0
  10. package/npm-scripts/run-dev.js +9 -0
  11. package/npm-scripts/venv-paths.js +45 -0
  12. package/package.json +59 -0
  13. package/requirements.txt +13 -0
  14. package/src/api/server.py +48 -0
  15. package/src/api/ui_routes.py +340 -0
  16. package/src/api/voice_routes.py +94 -0
  17. package/src/approval/command_parser.py +62 -0
  18. package/src/approval/tool_formatter.py +68 -0
  19. package/src/cogs/general_cog.py +287 -0
  20. package/src/cogs/voice/__init__.py +0 -0
  21. package/src/cogs/voice/enrollment.py +436 -0
  22. package/src/cogs/voice/stt_session.py +121 -0
  23. package/src/cogs/voice_cog.py +573 -0
  24. package/src/config.py +123 -0
  25. package/src/core/agy_runner.py +380 -0
  26. package/src/core/atomic_io.py +31 -0
  27. package/src/core/logger.py +28 -0
  28. package/src/core/session_manager.py +126 -0
  29. package/src/handlers/message_router.py +16 -0
  30. package/src/handlers/thread_reply.py +165 -0
  31. package/src/main.py +313 -0
  32. package/src/messengers/base.py +105 -0
  33. package/src/messengers/discord_adapter.py +240 -0
  34. package/src/messengers/registry.py +19 -0
  35. package/src/services/audio_service.py +67 -0
  36. package/src/services/discord_helpers.py +95 -0
  37. package/src/services/discord_mcp.py +50 -0
  38. package/src/services/response.py +51 -0
  39. package/src/services/streaming.py +199 -0
  40. package/src/utils/utils.py +40 -0
  41. package/voice-service/index.js +1048 -0
  42. package/voice-service/package-lock.json +1880 -0
  43. package/voice-service/package.json +24 -0
package/hooks/hook.py ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import os
4
+ import sys
5
+ import urllib.error
6
+ import urllib.request
7
+
8
+
9
+ def main():
10
+ if os.environ.get("AGY_DISCORD_BOT") != "1":
11
+ print(json.dumps({"decision": "allow"}))
12
+ return
13
+
14
+ try:
15
+ raw_input = sys.stdin.read()
16
+ hook_input = json.loads(raw_input)
17
+ except Exception:
18
+ print(json.dumps({"decision": "deny", "reason": "Failed to parse hook input."}))
19
+ return
20
+
21
+ tool_call = hook_input.get("toolCall", {})
22
+ tool_name = tool_call.get("name", "unknown_tool")
23
+
24
+ tool_input_data = tool_call.get("args", {})
25
+ conv_id = hook_input.get("conversationId", "unknown")
26
+
27
+ payload = json.dumps(
28
+ {
29
+ "conversation_id": conv_id,
30
+ "tool_name": tool_name,
31
+ "tool_input": tool_input_data,
32
+ "thread_id": os.environ.get("DISCORD_THREAD_ID"),
33
+ }
34
+ ).encode("utf-8")
35
+
36
+ req = urllib.request.Request(
37
+ "http://localhost:18080/approve", data=payload, headers={"Content-Type": "application/json"}
38
+ )
39
+
40
+ try:
41
+ with urllib.request.urlopen(req, timeout=3600) as response:
42
+ res_data = json.loads(response.read().decode("utf-8"))
43
+ decision = res_data.get("decision", "allow")
44
+ if decision == "allow":
45
+ out = {"decision": "allow"}
46
+ # Print mode requires a matching allow rule even when this
47
+ # hook says "allow", or it soft-denies the tool call anyway.
48
+ if res_data.get("permissionOverrides"):
49
+ out["permissionOverrides"] = res_data["permissionOverrides"]
50
+ print(json.dumps(out))
51
+ else:
52
+ reason = res_data.get("reason", "User rejected the action.")
53
+ print(json.dumps({"decision": "deny", "reason": reason}))
54
+ except Exception as e:
55
+ sys.stderr.write(f"Hook error: {e}\n")
56
+ print(json.dumps({"decision": "deny", "reason": f"Connection to webhook failed: {e}"}))
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env python3
2
+ """Stop-event hook (https://antigravity.google/docs/hooks#stop).
3
+
4
+ `fullyIdle: false` means a run_command call detached to async (its
5
+ WaitMsBeforeAsync budget ran out) is still in flight. Returning
6
+ {"decision": "continue"} keeps the turn alive so agy can pick up that
7
+ result instead of ending the turn with it lost. Capped by executionNum
8
+ so a genuinely stuck command doesn't loop forever.
9
+ """
10
+
11
+ import json
12
+ import sys
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+ MAX_CONTINUE_ATTEMPTS = 20
17
+ # This runs as its own process, invoked directly by agy - not visible in
18
+ # `lgy logs`. Plain-file logging is the only way to inspect it.
19
+ DEBUG_LOG = Path.home() / ".gemini" / "linkgravity" / "logs" / "stop_hook_debug.log"
20
+
21
+
22
+ def log(line: str):
23
+ try:
24
+ DEBUG_LOG.parent.mkdir(parents=True, exist_ok=True)
25
+ with open(DEBUG_LOG, "a", encoding="utf-8") as f:
26
+ f.write(f"{datetime.now().isoformat()} {line}\n")
27
+ except Exception:
28
+ pass
29
+
30
+
31
+ def main():
32
+ try:
33
+ raw = sys.stdin.read()
34
+ hook_input = json.loads(raw)
35
+ except Exception as e:
36
+ log(f"[PARSE ERROR] {e} raw={raw!r}")
37
+ print(json.dumps({}))
38
+ return
39
+
40
+ fully_idle = hook_input.get("fullyIdle", True)
41
+ execution_num = hook_input.get("executionNum", 0)
42
+ termination_reason = hook_input.get("terminationReason")
43
+ log(
44
+ f"[STOP HOOK] fullyIdle={fully_idle!r} executionNum={execution_num!r} "
45
+ f"terminationReason={termination_reason!r} conv={hook_input.get('conversationId')!r}"
46
+ )
47
+
48
+ if not fully_idle and execution_num < MAX_CONTINUE_ATTEMPTS:
49
+ response = {
50
+ "decision": "continue",
51
+ "reason": (
52
+ "A background command is still running. Wait for it to finish, "
53
+ "then report its actual result to the user before ending your turn."
54
+ ),
55
+ }
56
+ log(f"[STOP HOOK] -> continue: {response}")
57
+ print(json.dumps(response))
58
+ else:
59
+ log("[STOP HOOK] -> {} (fullyIdle true or attempt cap reached)")
60
+ print(json.dumps({}))
61
+
62
+
63
+ if __name__ == "__main__":
64
+ main()
@@ -0,0 +1,62 @@
1
+ const { execSync } = require('child_process');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { pip: venvPip, workspaceDir } = require('./venv-paths');
6
+
7
+ console.log('⚙️ Setting up Python Virtual Environment...');
8
+ console.log(` (in ${path.join(workspaceDir, 'venv')} - not inside this install, so it survives`);
9
+ console.log(' package updates/reinstalls and works the same whether this is a global');
10
+ console.log(' `npm install -g linkgravity` or a local dev clone.)');
11
+
12
+ // Use python on Windows, python3 on Mac/Linux - this is the *system*
13
+ // python used only to create the venv below; once it exists, every
14
+ // other script (this one included) goes through venv-paths.js instead.
15
+ const isWin = os.platform() === 'win32';
16
+ const pyCmd = isWin ? 'python' : 'python3';
17
+
18
+ async function main() {
19
+ try {
20
+ // 1. Create Python virtual environment (venv) in the fixed
21
+ // workspace dir, not in cwd - see venv-paths.js for why.
22
+ fs.mkdirSync(workspaceDir, { recursive: true });
23
+ execSync(`${pyCmd} -m venv "${path.join(workspaceDir, 'venv')}"`, { stdio: 'inherit' });
24
+
25
+ // 2. Install Python packages
26
+ console.log('📦 Installing Python dependencies...');
27
+ execSync(`"${venvPip}" install -r requirements.txt`, { stdio: 'inherit' });
28
+
29
+ // 3. Install Node.js voice service packages (includes
30
+ // rustpotter-web, which handles both wake-word detection AND
31
+ // building .rpw reference files in-process - no separate
32
+ // binary download needed for either).
33
+ console.log('🎙️ Installing Voice Service dependencies...');
34
+ execSync('npm install', {
35
+ stdio: 'inherit',
36
+ cwd: path.join(__dirname, '..', 'voice-service'),
37
+ });
38
+
39
+ // 4. Register this install's location with agy as its
40
+ // tool-approval hook (~/.gemini/config/hooks.json) - always
41
+ // re-run so the registered path self-heals if this checkout
42
+ // gets moved/renamed later, instead of silently going stale.
43
+ // Guarded on its own: agy may not be installed/configured yet
44
+ // on a brand new machine, and that shouldn't fail the rest of
45
+ // the install - just means the hook needs registering once agy
46
+ // itself is set up (re-running `npm install` after does it).
47
+ try {
48
+ require('./register-hook')();
49
+ } catch (err) {
50
+ console.warn(
51
+ `⚠️ Couldn't register the agy tool-approval hook: ${err.message.split('\n')[0]}`,
52
+ );
53
+ }
54
+
55
+ console.log('✅ Installation complete!');
56
+ } catch (error) {
57
+ console.error('❌ Installation failed. Please ensure Python 3.10+ is installed.');
58
+ process.exit(1);
59
+ }
60
+ }
61
+
62
+ main();
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // Runs at `prepare` time - which npm only triggers for a local `npm
3
+ // install` inside this repo (i.e. a git clone / contributor checkout),
4
+ // never for `npm install -g linkgravity` end users installing the published
5
+ // package from the registry. That's exactly why dev-only setup (git
6
+ // hooks, lint tooling) lives here instead of postinstall.js, which runs
7
+ // for everyone, including end users who don't need any of this.
8
+ 'use strict';
9
+ const { execSync } = require('child_process');
10
+ const fs = require('fs');
11
+ const { repoRoot, pip, preCommit } = require('./venv-paths');
12
+
13
+ if (!fs.existsSync(pip)) {
14
+ console.warn(
15
+ '⚠️ No venv found yet - skipping dev tooling install and git hook setup. ' +
16
+ 'Run `npm install` again once the venv exists, or set it up manually.',
17
+ );
18
+ return;
19
+ }
20
+
21
+ // 1. Dev-only Python tooling into the same venv postinstall.js already
22
+ // created (prepare always runs after postinstall in npm's lifecycle
23
+ // order): ruff (editor/manual use) and pre-commit itself, which is
24
+ // what actually runs the hooks declared in .pre-commit-config.yaml.
25
+ try {
26
+ execSync(`"${pip}" install -r requirements-dev.txt`, { stdio: 'inherit', cwd: repoRoot });
27
+ } catch (err) {
28
+ console.warn(`⚠️ Couldn't install dev Python tooling: ${err.message.split('\n')[0]}`);
29
+ return;
30
+ }
31
+
32
+ // 2. Register the actual git hooks. --hook-type is passed explicitly
33
+ // here even though .pre-commit-config.yaml's default_install_hook_types
34
+ // already covers it, just so this line is self-explanatory on its own.
35
+ try {
36
+ execSync(`"${preCommit}" install --hook-type pre-commit --hook-type commit-msg`, {
37
+ stdio: 'inherit',
38
+ cwd: repoRoot,
39
+ });
40
+ } catch (err) {
41
+ console.warn(
42
+ `⚠️ Couldn't install git hooks (${err.message.split('\n')[0]}). ` +
43
+ "Pre-commit checks won't run until `venv/bin/pre-commit install --hook-type pre-commit --hook-type commit-msg` is run manually.",
44
+ );
45
+ }
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+ // Registers/fixes this project's agy hooks in ~/.gemini/config/hooks.json
3
+ // (schema: https://antigravity.google/docs/hooks). Runs on every `npm
4
+ // install` so paths stay correct if this checkout moves, identifying its
5
+ // own entries by `name` (not command string) so a stale path gets fixed
6
+ // in place rather than duplicated, leaving any other configured hooks
7
+ // untouched. Only ever overwrites `command` - never `type`/`timeout`,
8
+ // which the user may have customized - logging old/new values on change.
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const { repoRoot, python: venvPython } = require('./venv-paths');
13
+
14
+ const hooksJsonPath = path.join(os.homedir(), '.gemini', 'config', 'hooks.json');
15
+
16
+ // PreToolUse: tool-approval gate, matcher-wrapped per schema.
17
+ // Stop: fires when agy is about to end a turn; if fullyIdle is false
18
+ // (an async run_command is still in flight), stop_hook.py tells agy to
19
+ // keep going instead of ending the turn with that result lost. Flat
20
+ // array per schema (no matcher - nothing to match tool names against).
21
+ const HOOK_REGISTRATIONS = [
22
+ {
23
+ eventType: 'PreToolUse',
24
+ name: 'discord-approval',
25
+ scriptPath: path.join(repoRoot, 'hooks', 'hook.py'),
26
+ defaultTimeout: 3600,
27
+ wrapInMatcher: true,
28
+ },
29
+ {
30
+ eventType: 'Stop',
31
+ name: 'discord-approval-stop',
32
+ scriptPath: path.join(repoRoot, 'hooks', 'stop_hook.py'),
33
+ defaultTimeout: 30,
34
+ wrapInMatcher: false,
35
+ },
36
+ ];
37
+
38
+ // Hooks retired from HOOK_REGISTRATIONS but listed here so an existing
39
+ // install actually gets the stale entry removed from hooks.json, instead
40
+ // of a zombie entry pointing at a script that no longer exists.
41
+ const RETIRED_HOOKS = [{ eventType: 'PreInvocation', name: 'wait-ms-before-async-reminder' }];
42
+
43
+ function loadHooksConfig() {
44
+ if (!fs.existsSync(hooksJsonPath)) {
45
+ return { hooks: {} };
46
+ }
47
+ try {
48
+ return JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
49
+ } catch (err) {
50
+ // Back up rather than silently clobbering whatever was there -
51
+ // it may have other hooks configured that have nothing to do
52
+ // with this project.
53
+ const backupPath = `${hooksJsonPath}.corrupted-${Date.now()}`;
54
+ fs.copyFileSync(hooksJsonPath, backupPath);
55
+ console.warn(
56
+ `⚠️ ${hooksJsonPath} was invalid JSON - backed up to ${backupPath} and starting fresh.`,
57
+ );
58
+ return { hooks: {} };
59
+ }
60
+ }
61
+
62
+ function findHookEntry(config, eventType, name, wrapInMatcher) {
63
+ config.hooks[eventType] = config.hooks[eventType] || [];
64
+ if (wrapInMatcher) {
65
+ let matcher = config.hooks[eventType].find((m) =>
66
+ (m.hooks || []).some((h) => h.name === name),
67
+ );
68
+ if (!matcher) {
69
+ matcher = { matcher: '.*', hooks: [] };
70
+ config.hooks[eventType].push(matcher);
71
+ }
72
+ let hookEntry = matcher.hooks.find((h) => h.name === name);
73
+ if (!hookEntry) {
74
+ hookEntry = { name };
75
+ matcher.hooks.push(hookEntry);
76
+ }
77
+ return hookEntry;
78
+ }
79
+ // Flat array (Stop/PreInvocation/PostInvocation) - no matcher wrapper.
80
+ let hookEntry = config.hooks[eventType].find((h) => h.name === name);
81
+ if (!hookEntry) {
82
+ hookEntry = { name };
83
+ config.hooks[eventType].push(hookEntry);
84
+ }
85
+ return hookEntry;
86
+ }
87
+
88
+ function removeRetiredHooks(config) {
89
+ let removedAny = false;
90
+ for (const retired of RETIRED_HOOKS) {
91
+ const arr = config.hooks[retired.eventType];
92
+ if (!arr) continue;
93
+
94
+ const nextArr = [];
95
+ for (const entry of arr) {
96
+ if (Array.isArray(entry.hooks)) {
97
+ // Matcher-wrapped shape - drop the matcher block too if
98
+ // nothing's left in it.
99
+ const beforeLen = entry.hooks.length;
100
+ entry.hooks = entry.hooks.filter((h) => h.name !== retired.name);
101
+ if (entry.hooks.length !== beforeLen) {
102
+ removedAny = true;
103
+ console.log(
104
+ `🧹 Removed retired agy ${retired.eventType} hook '${retired.name}' from ${hooksJsonPath}`,
105
+ );
106
+ }
107
+ if (entry.hooks.length > 0) nextArr.push(entry);
108
+ } else {
109
+ // Flat shape.
110
+ if (entry.name === retired.name) {
111
+ removedAny = true;
112
+ console.log(
113
+ `🧹 Removed retired agy ${retired.eventType} hook '${retired.name}' from ${hooksJsonPath}`,
114
+ );
115
+ } else {
116
+ nextArr.push(entry);
117
+ }
118
+ }
119
+ }
120
+ config.hooks[retired.eventType] = nextArr;
121
+ }
122
+ return removedAny;
123
+ }
124
+
125
+ function registerHook() {
126
+ const config = loadHooksConfig();
127
+ config.hooks = config.hooks || {};
128
+ let wroteChange = false;
129
+ let backedUp = false;
130
+
131
+ const backupBeforeFirstChange = () => {
132
+ if (!backedUp && fs.existsSync(hooksJsonPath)) {
133
+ fs.copyFileSync(hooksJsonPath, `${hooksJsonPath}.bak`);
134
+ backedUp = true;
135
+ }
136
+ };
137
+
138
+ if (removeRetiredHooks(config)) {
139
+ backupBeforeFirstChange();
140
+ wroteChange = true;
141
+ }
142
+
143
+ for (const reg of HOOK_REGISTRATIONS) {
144
+ const command = `"${venvPython}" "${reg.scriptPath}"`;
145
+ const hookEntry = findHookEntry(config, reg.eventType, reg.name, reg.wrapInMatcher);
146
+ const isNew = !hookEntry.command;
147
+
148
+ if (isNew) {
149
+ hookEntry.type = 'command';
150
+ hookEntry.timeout = reg.defaultTimeout;
151
+ hookEntry.command = command;
152
+ console.log(
153
+ `🔗 Registered agy ${reg.eventType} hook '${reg.name}' -> ${reg.scriptPath}`,
154
+ );
155
+ wroteChange = true;
156
+ } else if (hookEntry.command !== command) {
157
+ backupBeforeFirstChange();
158
+ console.log(
159
+ `🔗 Fixing agy ${reg.eventType} hook '${reg.name}' in ${hooksJsonPath}` +
160
+ (backedUp ? ` (previous version backed up to ${hooksJsonPath}.bak)` : '') +
161
+ `:\n was: ${hookEntry.command}\n now: ${command}`,
162
+ );
163
+ hookEntry.command = command;
164
+ wroteChange = true;
165
+ } else {
166
+ console.log(
167
+ `🔗 agy ${reg.eventType} hook '${reg.name}' already up to date -> ${reg.scriptPath}`,
168
+ );
169
+ }
170
+ }
171
+
172
+ if (wroteChange) {
173
+ fs.mkdirSync(path.dirname(hooksJsonPath), { recursive: true });
174
+ fs.writeFileSync(hooksJsonPath, JSON.stringify(config, null, 2));
175
+ }
176
+ }
177
+
178
+ module.exports = registerHook;
179
+
180
+ if (require.main === module) {
181
+ registerHook();
182
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ // Used by the "start"/"dev" npm scripts.
3
+ 'use strict';
4
+ const { spawnSync } = require('child_process');
5
+ const path = require('path');
6
+ const { python, repoRoot } = require('./venv-paths');
7
+
8
+ const result = spawnSync(python, [path.join(repoRoot, 'src', 'main.py')], { stdio: 'inherit' });
9
+ process.exit(result.status === null ? 1 : result.status);
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+ // Single source of truth for "where's the venv's stuff on this OS".
3
+ // Previously bin/cli.js, postinstall.js, prepare.js, and
4
+ // .lintstagedrc.cjs each computed their own `isWin ? 'Scripts' : 'bin'`
5
+ // branch independently - four copies of the same three lines, with no
6
+ // guarantee they'd stay in sync if one changed. Everything that needs
7
+ // a path inside venv/ should require() this instead of recomputing it.
8
+ const path = require('path');
9
+ const os = require('os');
10
+
11
+ const repoRoot = path.join(__dirname, '..');
12
+ const isWin = os.platform() === 'win32';
13
+
14
+ // The venv is generated, mutable, potentially large (hundreds of MB
15
+ // once dependencies are installed) runtime state - not "package code" -
16
+ // so, like every other piece of this project's persistent state
17
+ // (lgy.json, logs/, wake_refs/ - see WORKSPACE_DIR in src/config.py),
18
+ // it belongs in the user's own fixed data directory, not wherever this
19
+ // copy of the code happens to be checked out or installed.
20
+ //
21
+ // This matters concretely for a real `npm install -g linkgravity`: the global
22
+ // npm install location often isn't writable without sudo, and isn't
23
+ // meant to hold generated mutable data in the first place - npm can
24
+ // replace that directory's contents wholesale on update/reinstall.
25
+ // Putting the venv there would reproduce both problems. One fixed
26
+ // location, shared by every checkout/install on the machine, matches
27
+ // this project's existing single-workspace assumption (there's already
28
+ // only one lgy.json / one wake_refs/ / one logs/ for the whole
29
+ // machine, not one per checkout).
30
+ const workspaceDir = path.join(os.homedir(), '.gemini', 'linkgravity');
31
+ const venvBinDir = path.join(workspaceDir, 'venv', isWin ? 'Scripts' : 'bin');
32
+
33
+ function venvBin(name) {
34
+ return path.join(venvBinDir, isWin ? `${name}.exe` : name);
35
+ }
36
+
37
+ module.exports = {
38
+ repoRoot, // where the CODE lives (this checkout/install) - hooks/hook.py, src/main.py, etc.
39
+ workspaceDir, // where generated/user DATA lives (venv, logs, lgy.json, wake_refs, ...)
40
+ isWin,
41
+ venvBinDir,
42
+ python: venvBin('python'),
43
+ pip: venvBin('pip'),
44
+ preCommit: venvBin('pre-commit'),
45
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "linkgravity",
3
+ "version": "1.0.0",
4
+ "description": "Discord bot bridge for the Antigravity (agy) CLI, with voice interaction support",
5
+ "scripts": {
6
+ "postinstall": "node npm-scripts/postinstall.js",
7
+ "start": "node npm-scripts/run-dev.js",
8
+ "dev": "node npm-scripts/run-dev.js",
9
+ "format": "prettier --write \"bin/**/*.js\" \"npm-scripts/**/*.js\" \"voice-service/*.js\"",
10
+ "format:check": "prettier --check \"bin/**/*.js\" \"npm-scripts/**/*.js\" \"voice-service/*.js\"",
11
+ "prepare": "node npm-scripts/prepare.js"
12
+ },
13
+ "author": "dev-sseul",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/dev-sseul/linkgravity.git"
18
+ },
19
+ "homepage": "https://github.com/dev-sseul/linkgravity#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/dev-sseul/linkgravity/issues"
22
+ },
23
+ "keywords": [
24
+ "discord",
25
+ "discord-bot",
26
+ "antigravity",
27
+ "agy",
28
+ "cli",
29
+ "voice",
30
+ "ai-agent"
31
+ ],
32
+ "bin": {
33
+ "lgy": "./bin/cli.js",
34
+ "linkgravity": "./bin/cli.js"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "npm-scripts",
39
+ "hooks",
40
+ "src",
41
+ "voice-service/index.js",
42
+ "voice-service/package.json",
43
+ "voice-service/package-lock.json",
44
+ "requirements.txt",
45
+ "!**/__pycache__/**",
46
+ "!**/*.py[cod]"
47
+ ],
48
+ "engines": {
49
+ "node": ">=18.0.0",
50
+ "python": ">=3.10"
51
+ },
52
+ "dependencies": {
53
+ "@clack/prompts": "^1.7.0",
54
+ "pm2": "^5.3.0"
55
+ },
56
+ "devDependencies": {
57
+ "prettier": "^3.3.0"
58
+ }
59
+ }
@@ -0,0 +1,13 @@
1
+ discord.py[voice]>=2.4.0
2
+ PyNaCl>=1.5.0
3
+ numpy>=1.24.0
4
+ edge-tts
5
+ google-genai>=1.0.0
6
+ aiohttp==3.10.3
7
+ aiofiles==24.1.0
8
+ SpeechRecognition>=3.17.0
9
+ schedule==1.2.1
10
+ jamo>=0.4.1
11
+ loguru>=0.7.0
12
+ mcp>=1.0.0
13
+ requests>=2.31.0
@@ -0,0 +1,48 @@
1
+ from aiohttp import web
2
+
3
+ from config import logger, session_manager
4
+
5
+
6
+ def is_tool_allowed(tool_name, tool_input):
7
+ if tool_name == "manage_task" and tool_input.get("Action") in ["list", "status", "kill", "send_input"]:
8
+ return True
9
+ if tool_name == "list_dir":
10
+ return True
11
+ if tool_name == "view_file":
12
+ return True
13
+ if tool_name == "read_file":
14
+ return True
15
+ if tool_name == "read_url_content":
16
+ return True
17
+ if tool_name in session_manager.persistent_allowed.get("tools", []):
18
+ return True
19
+ return False
20
+
21
+
22
+ async def setup_webhook_server(bot):
23
+ app = web.Application(client_max_size=50 * 1024 * 1024)
24
+ app["bot"] = bot
25
+
26
+ from api.ui_routes import handle_approve_request, handle_mcp_ask, handle_mcp_send_channel
27
+ from api.voice_routes import (
28
+ handle_enroll_sample,
29
+ handle_stt_input,
30
+ handle_stt_partial,
31
+ handle_stt_partial_cancel,
32
+ handle_tts_finished,
33
+ )
34
+
35
+ app.router.add_post("/approve", handle_approve_request)
36
+ app.router.add_post("/mcp_ask", handle_mcp_ask)
37
+ app.router.add_post("/mcp_send_channel", handle_mcp_send_channel)
38
+ app.router.add_post("/stt_input", handle_stt_input)
39
+ app.router.add_post("/tts_finished", handle_tts_finished)
40
+ app.router.add_post("/stt_partial", handle_stt_partial)
41
+ app.router.add_post("/stt_partial_cancel", handle_stt_partial_cancel)
42
+ app.router.add_post("/enroll_sample", handle_enroll_sample)
43
+
44
+ runner = web.AppRunner(app)
45
+ await runner.setup()
46
+ site = web.TCPSite(runner, "0.0.0.0", 18080)
47
+ await site.start()
48
+ logger.info("Webhook / MCP / STT Server started on port 18080")