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.
- package/LICENSE +21 -0
- package/README.md +114 -0
- package/bin/cli.js +278 -0
- package/bin/setup.js +260 -0
- package/hooks/hook.py +60 -0
- package/hooks/stop_hook.py +64 -0
- package/npm-scripts/postinstall.js +62 -0
- package/npm-scripts/prepare.js +45 -0
- package/npm-scripts/register-hook.js +182 -0
- package/npm-scripts/run-dev.js +9 -0
- package/npm-scripts/venv-paths.js +45 -0
- package/package.json +59 -0
- package/requirements.txt +13 -0
- package/src/api/server.py +48 -0
- package/src/api/ui_routes.py +340 -0
- package/src/api/voice_routes.py +94 -0
- package/src/approval/command_parser.py +62 -0
- package/src/approval/tool_formatter.py +68 -0
- package/src/cogs/general_cog.py +287 -0
- package/src/cogs/voice/__init__.py +0 -0
- package/src/cogs/voice/enrollment.py +436 -0
- package/src/cogs/voice/stt_session.py +121 -0
- package/src/cogs/voice_cog.py +573 -0
- package/src/config.py +123 -0
- package/src/core/agy_runner.py +380 -0
- package/src/core/atomic_io.py +31 -0
- package/src/core/logger.py +28 -0
- package/src/core/session_manager.py +126 -0
- package/src/handlers/message_router.py +16 -0
- package/src/handlers/thread_reply.py +165 -0
- package/src/main.py +313 -0
- package/src/messengers/base.py +105 -0
- package/src/messengers/discord_adapter.py +240 -0
- package/src/messengers/registry.py +19 -0
- package/src/services/audio_service.py +67 -0
- package/src/services/discord_helpers.py +95 -0
- package/src/services/discord_mcp.py +50 -0
- package/src/services/response.py +51 -0
- package/src/services/streaming.py +199 -0
- package/src/utils/utils.py +40 -0
- package/voice-service/index.js +1048 -0
- package/voice-service/package-lock.json +1880 -0
- 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
|
+
}
|
package/requirements.txt
ADDED
|
@@ -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")
|