Compare commits
1 Commits
mimo/resea
...
mimo/code/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
229edf16e2 |
51
.gitea.yml
51
.gitea.yml
@@ -15,3 +15,54 @@ protection:
|
|||||||
- perplexity
|
- perplexity
|
||||||
required_reviewers:
|
required_reviewers:
|
||||||
- Timmy # Owner gate for hermes-agent
|
- Timmy # Owner gate for hermes-agent
|
||||||
|
main:
|
||||||
|
require_pull_request: true
|
||||||
|
required_approvals: 1
|
||||||
|
dismiss_stale_approvals: true
|
||||||
|
require_ci_to_pass: true
|
||||||
|
block_force_push: true
|
||||||
|
block_deletion: true
|
||||||
|
>>>>>>> replace
|
||||||
|
</source>
|
||||||
|
|
||||||
|
CODEOWNERS
|
||||||
|
<source>
|
||||||
|
<<<<<<< search
|
||||||
|
protection:
|
||||||
|
main:
|
||||||
|
required_status_checks:
|
||||||
|
- "ci/unit-tests"
|
||||||
|
- "ci/integration"
|
||||||
|
required_pull_request_reviews:
|
||||||
|
- "1 approval"
|
||||||
|
restrictions:
|
||||||
|
- "block force push"
|
||||||
|
- "block deletion"
|
||||||
|
enforce_admins: true
|
||||||
|
|
||||||
|
the-nexus:
|
||||||
|
required_status_checks: []
|
||||||
|
required_pull_request_reviews:
|
||||||
|
- "1 approval"
|
||||||
|
restrictions:
|
||||||
|
- "block force push"
|
||||||
|
- "block deletion"
|
||||||
|
enforce_admins: true
|
||||||
|
|
||||||
|
timmy-home:
|
||||||
|
required_status_checks: []
|
||||||
|
required_pull_request_reviews:
|
||||||
|
- "1 approval"
|
||||||
|
restrictions:
|
||||||
|
- "block force push"
|
||||||
|
- "block deletion"
|
||||||
|
enforce_admins: true
|
||||||
|
|
||||||
|
timmy-config:
|
||||||
|
required_status_checks: []
|
||||||
|
required_pull_request_reviews:
|
||||||
|
- "1 approval"
|
||||||
|
restrictions:
|
||||||
|
- "block force push"
|
||||||
|
- "block deletion"
|
||||||
|
enforce_admins: true
|
||||||
|
|||||||
4
app.js
4
app.js
@@ -4,7 +4,6 @@ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
|
|||||||
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
|
||||||
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
|
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
|
||||||
import { SpatialMemory } from './nexus/components/spatial-memory.js';
|
import { SpatialMemory } from './nexus/components/spatial-memory.js';
|
||||||
import { SpatialAudio } from './nexus/components/spatial-audio.js';
|
|
||||||
import { MemoryBirth } from './nexus/components/memory-birth.js';
|
import { MemoryBirth } from './nexus/components/memory-birth.js';
|
||||||
import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
|
import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
|
||||||
import { MemoryInspect } from './nexus/components/memory-inspect.js';
|
import { MemoryInspect } from './nexus/components/memory-inspect.js';
|
||||||
@@ -716,8 +715,6 @@ async function init() {
|
|||||||
MemoryBirth.init(scene);
|
MemoryBirth.init(scene);
|
||||||
MemoryBirth.wrapSpatialMemory(SpatialMemory);
|
MemoryBirth.wrapSpatialMemory(SpatialMemory);
|
||||||
SpatialMemory.setCamera(camera);
|
SpatialMemory.setCamera(camera);
|
||||||
SpatialAudio.init(camera, scene);
|
|
||||||
SpatialAudio.bindSpatialMemory(SpatialMemory);
|
|
||||||
MemoryInspect.init({ onNavigate: _navigateToMemory });
|
MemoryInspect.init({ onNavigate: _navigateToMemory });
|
||||||
MemoryPulse.init(SpatialMemory);
|
MemoryPulse.init(SpatialMemory);
|
||||||
updateLoad(90);
|
updateLoad(90);
|
||||||
@@ -2929,7 +2926,6 @@ function gameLoop() {
|
|||||||
// Project Mnemosyne - Memory Orb Animation
|
// Project Mnemosyne - Memory Orb Animation
|
||||||
if (typeof animateMemoryOrbs === 'function') {
|
if (typeof animateMemoryOrbs === 'function') {
|
||||||
SpatialMemory.update(delta);
|
SpatialMemory.update(delta);
|
||||||
SpatialAudio.update(delta);
|
|
||||||
MemoryBirth.update(delta);
|
MemoryBirth.update(delta);
|
||||||
MemoryPulse.update();
|
MemoryPulse.update();
|
||||||
animateMemoryOrbs(delta);
|
animateMemoryOrbs(delta);
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════
|
|
||||||
// SPATIAL AUDIO MANAGER — Nexus Spatial Sound for Mnemosyne
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
|
||||||
//
|
|
||||||
// Attaches a Three.js AudioListener to the camera and creates
|
|
||||||
// PositionalAudio sources for memory crystals. Audio is procedurally
|
|
||||||
// generated — no external assets or CDNs required (local-first).
|
|
||||||
//
|
|
||||||
// Each region gets a distinct tone. Proximity controls volume and
|
|
||||||
// panning. Designed to layer on top of SpatialMemory without
|
|
||||||
// modifying it.
|
|
||||||
//
|
|
||||||
// Usage from app.js:
|
|
||||||
// SpatialAudio.init(camera, scene);
|
|
||||||
// SpatialAudio.bindSpatialMemory(SpatialMemory);
|
|
||||||
// SpatialAudio.update(delta); // call in animation loop
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const SpatialAudio = (() => {
|
|
||||||
|
|
||||||
// ─── CONFIG ──────────────────────────────────────────────
|
|
||||||
const REGION_TONES = {
|
|
||||||
engineering: { freq: 220, type: 'sine' }, // A3
|
|
||||||
social: { freq: 261, type: 'triangle' }, // C4
|
|
||||||
knowledge: { freq: 329, type: 'sine' }, // E4
|
|
||||||
projects: { freq: 392, type: 'triangle' }, // G4
|
|
||||||
working: { freq: 440, type: 'sine' }, // A4
|
|
||||||
archive: { freq: 110, type: 'sine' }, // A2
|
|
||||||
user_pref: { freq: 349, type: 'triangle' }, // F4
|
|
||||||
project: { freq: 392, type: 'sine' }, // G4
|
|
||||||
tool: { freq: 493, type: 'triangle' }, // B4
|
|
||||||
general: { freq: 293, type: 'sine' }, // D4
|
|
||||||
};
|
|
||||||
const MAX_AUDIBLE_DIST = 40; // distance at which volume reaches 0
|
|
||||||
const REF_DIST = 5; // full volume within this range
|
|
||||||
const ROLLOFF = 1.5;
|
|
||||||
const BASE_VOLUME = 0.12; // master volume cap per source
|
|
||||||
const AMBIENT_VOLUME = 0.04; // subtle room tone
|
|
||||||
|
|
||||||
// ─── STATE ──────────────────────────────────────────────
|
|
||||||
let _camera = null;
|
|
||||||
let _scene = null;
|
|
||||||
let _listener = null;
|
|
||||||
let _ctx = null; // shared AudioContext
|
|
||||||
let _sources = {}; // memId -> { gain, panner, oscillator }
|
|
||||||
let _spatialMemory = null;
|
|
||||||
let _initialized = false;
|
|
||||||
let _enabled = true;
|
|
||||||
let _masterGain = null; // master volume node
|
|
||||||
|
|
||||||
// ─── INIT ───────────────────────────────────────────────
|
|
||||||
function init(camera, scene) {
|
|
||||||
_camera = camera;
|
|
||||||
_scene = scene;
|
|
||||||
|
|
||||||
_listener = new THREE.AudioListener();
|
|
||||||
camera.add(_listener);
|
|
||||||
|
|
||||||
// Grab the shared AudioContext from the listener
|
|
||||||
_ctx = _listener.context;
|
|
||||||
_masterGain = _ctx.createGain();
|
|
||||||
_masterGain.gain.value = 1.0;
|
|
||||||
_masterGain.connect(_ctx.destination);
|
|
||||||
|
|
||||||
_initialized = true;
|
|
||||||
console.info('[SpatialAudio] Initialized — AudioContext state:', _ctx.state);
|
|
||||||
|
|
||||||
// Browsers require a user gesture to resume audio context
|
|
||||||
if (_ctx.state === 'suspended') {
|
|
||||||
const resume = () => {
|
|
||||||
_ctx.resume().then(() => {
|
|
||||||
console.info('[SpatialAudio] AudioContext resumed');
|
|
||||||
document.removeEventListener('click', resume);
|
|
||||||
document.removeEventListener('keydown', resume);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
document.addEventListener('click', resume);
|
|
||||||
document.addEventListener('keydown', resume);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _listener;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── BIND TO SPATIAL MEMORY ─────────────────────────────
|
|
||||||
function bindSpatialMemory(sm) {
|
|
||||||
_spatialMemory = sm;
|
|
||||||
// Create sources for any existing memories
|
|
||||||
const all = sm.getAllMemories();
|
|
||||||
all.forEach(mem => _ensureSource(mem));
|
|
||||||
console.info('[SpatialAudio] Bound to SpatialMemory —', Object.keys(_sources).length, 'audio sources');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── CREATE A PROCEDURAL TONE SOURCE ────────────────────
|
|
||||||
function _ensureSource(mem) {
|
|
||||||
if (!_ctx || !_enabled || _sources[mem.id]) return;
|
|
||||||
|
|
||||||
const regionKey = mem.category || 'working';
|
|
||||||
const tone = REGION_TONES[regionKey] || REGION_TONES.working;
|
|
||||||
|
|
||||||
// Procedural oscillator
|
|
||||||
const osc = _ctx.createOscillator();
|
|
||||||
osc.type = tone.type;
|
|
||||||
osc.frequency.value = tone.freq + _hashOffset(mem.id); // slight per-crystal detune
|
|
||||||
|
|
||||||
const gain = _ctx.createGain();
|
|
||||||
gain.gain.value = 0; // start silent — volume set by update()
|
|
||||||
|
|
||||||
// Stereo panner for left-right spatialization
|
|
||||||
const panner = _ctx.createStereoPanner();
|
|
||||||
panner.pan.value = 0;
|
|
||||||
|
|
||||||
osc.connect(gain);
|
|
||||||
gain.connect(panner);
|
|
||||||
panner.connect(_masterGain);
|
|
||||||
|
|
||||||
osc.start();
|
|
||||||
|
|
||||||
_sources[mem.id] = { osc, gain, panner, region: regionKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small deterministic pitch offset so crystals in the same region don't phase-lock
|
|
||||||
function _hashOffset(id) {
|
|
||||||
let h = 0;
|
|
||||||
for (let i = 0; i < id.length; i++) {
|
|
||||||
h = ((h << 5) - h) + id.charCodeAt(i);
|
|
||||||
h |= 0;
|
|
||||||
}
|
|
||||||
return (Math.abs(h) % 40) - 20; // ±20 Hz
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── PER-FRAME UPDATE ───────────────────────────────────
|
|
||||||
function update() {
|
|
||||||
if (!_initialized || !_enabled || !_spatialMemory || !_camera) return;
|
|
||||||
|
|
||||||
const camPos = _camera.position;
|
|
||||||
const memories = _spatialMemory.getAllMemories();
|
|
||||||
|
|
||||||
// Ensure sources for newly placed memories
|
|
||||||
memories.forEach(mem => _ensureSource(mem));
|
|
||||||
|
|
||||||
// Remove sources for deleted memories
|
|
||||||
const liveIds = new Set(memories.map(m => m.id));
|
|
||||||
Object.keys(_sources).forEach(id => {
|
|
||||||
if (!liveIds.has(id)) {
|
|
||||||
_removeSource(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update each source's volume & panning based on camera distance
|
|
||||||
memories.forEach(mem => {
|
|
||||||
const src = _sources[mem.id];
|
|
||||||
if (!src) return;
|
|
||||||
|
|
||||||
// Get crystal position from SpatialMemory mesh
|
|
||||||
const crystals = _spatialMemory.getCrystalMeshes();
|
|
||||||
let meshPos = null;
|
|
||||||
for (const mesh of crystals) {
|
|
||||||
if (mesh.userData.memId === mem.id) {
|
|
||||||
meshPos = mesh.position;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!meshPos) return;
|
|
||||||
|
|
||||||
const dx = meshPos.x - camPos.x;
|
|
||||||
const dy = meshPos.y - camPos.y;
|
|
||||||
const dz = meshPos.z - camPos.z;
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
||||||
|
|
||||||
// Volume rolloff (inverse distance model)
|
|
||||||
let vol = 0;
|
|
||||||
if (dist < MAX_AUDIBLE_DIST) {
|
|
||||||
vol = BASE_VOLUME / (1 + ROLLOFF * (dist - REF_DIST));
|
|
||||||
vol = Math.max(0, Math.min(BASE_VOLUME, vol));
|
|
||||||
}
|
|
||||||
src.gain.gain.setTargetAtTime(vol, _ctx.currentTime, 0.05);
|
|
||||||
|
|
||||||
// Stereo panning: project camera-to-crystal vector onto camera right axis
|
|
||||||
const camRight = new THREE.Vector3();
|
|
||||||
_camera.getWorldDirection(camRight);
|
|
||||||
camRight.cross(_camera.up).normalize();
|
|
||||||
const toCrystal = new THREE.Vector3(dx, 0, dz).normalize();
|
|
||||||
const pan = THREE.MathUtils.clamp(toCrystal.dot(camRight), -1, 1);
|
|
||||||
src.panner.pan.setTargetAtTime(pan, _ctx.currentTime, 0.05);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function _removeSource(id) {
|
|
||||||
const src = _sources[id];
|
|
||||||
if (!src) return;
|
|
||||||
try {
|
|
||||||
src.osc.stop();
|
|
||||||
src.osc.disconnect();
|
|
||||||
src.gain.disconnect();
|
|
||||||
src.panner.disconnect();
|
|
||||||
} catch (_) { /* already stopped */ }
|
|
||||||
delete _sources[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── CONTROLS ───────────────────────────────────────────
|
|
||||||
function setEnabled(enabled) {
|
|
||||||
_enabled = enabled;
|
|
||||||
if (!_enabled) {
|
|
||||||
// Silence all sources
|
|
||||||
Object.values(_sources).forEach(src => {
|
|
||||||
src.gain.gain.setTargetAtTime(0, _ctx.currentTime, 0.05);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.info('[SpatialAudio]', enabled ? 'Enabled' : 'Disabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEnabled() {
|
|
||||||
return _enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMasterVolume(vol) {
|
|
||||||
if (_masterGain) {
|
|
||||||
_masterGain.gain.setTargetAtTime(
|
|
||||||
THREE.MathUtils.clamp(vol, 0, 1),
|
|
||||||
_ctx.currentTime,
|
|
||||||
0.05
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveSourceCount() {
|
|
||||||
return Object.keys(_sources).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── API ────────────────────────────────────────────────
|
|
||||||
return {
|
|
||||||
init,
|
|
||||||
bindSpatialMemory,
|
|
||||||
update,
|
|
||||||
setEnabled,
|
|
||||||
isEnabled,
|
|
||||||
setMasterVolume,
|
|
||||||
getActiveSourceCount,
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
export { SpatialAudio };
|
|
||||||
@@ -243,24 +243,108 @@ async def playback(log_path: Path, ws_url: str):
|
|||||||
await ws.send(json.dumps(event))
|
await ws.send(json.dumps(event))
|
||||||
|
|
||||||
|
|
||||||
|
async def inject_event(event_type: str, ws_url: str, **kwargs):
|
||||||
|
"""Inject a single Evennia event into the Nexus WS gateway. Dev/test use."""
|
||||||
|
from nexus.evennia_event_adapter import (
|
||||||
|
actor_located, command_issued, command_result,
|
||||||
|
room_snapshot, session_bound,
|
||||||
|
)
|
||||||
|
|
||||||
|
builders = {
|
||||||
|
"room_snapshot": lambda: room_snapshot(
|
||||||
|
kwargs.get("room_key", "Gate"),
|
||||||
|
kwargs.get("title", "Gate"),
|
||||||
|
kwargs.get("desc", "The entrance gate."),
|
||||||
|
exits=kwargs.get("exits"),
|
||||||
|
objects=kwargs.get("objects"),
|
||||||
|
),
|
||||||
|
"actor_located": lambda: actor_located(
|
||||||
|
kwargs.get("actor_id", "Timmy"),
|
||||||
|
kwargs.get("room_key", "Gate"),
|
||||||
|
kwargs.get("room_name"),
|
||||||
|
),
|
||||||
|
"command_result": lambda: command_result(
|
||||||
|
kwargs.get("session_id", "dev-inject"),
|
||||||
|
kwargs.get("actor_id", "Timmy"),
|
||||||
|
kwargs.get("command_text", "look"),
|
||||||
|
kwargs.get("output_text", "You see the Gate."),
|
||||||
|
success=kwargs.get("success", True),
|
||||||
|
),
|
||||||
|
"command_issued": lambda: command_issued(
|
||||||
|
kwargs.get("session_id", "dev-inject"),
|
||||||
|
kwargs.get("actor_id", "Timmy"),
|
||||||
|
kwargs.get("command_text", "look"),
|
||||||
|
),
|
||||||
|
"session_bound": lambda: session_bound(
|
||||||
|
kwargs.get("session_id", "dev-inject"),
|
||||||
|
kwargs.get("account", "Timmy"),
|
||||||
|
kwargs.get("character", "Timmy"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if event_type not in builders:
|
||||||
|
print(f"[inject] Unknown event type: {event_type}", flush=True)
|
||||||
|
print(f"[inject] Available: {', '.join(builders)}", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
event = builders[event_type]()
|
||||||
|
payload = json.dumps(event)
|
||||||
|
|
||||||
|
if websockets is None:
|
||||||
|
print(f"[inject] websockets not installed, printing event:\n{payload}", flush=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with websockets.connect(ws_url, open_timeout=5) as ws:
|
||||||
|
await ws.send(payload)
|
||||||
|
print(f"[inject] Sent {event_type} -> {ws_url}", flush=True)
|
||||||
|
print(f"[inject] Payload: {payload}", flush=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[inject] Failed to send to {ws_url}: {e}", flush=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge")
|
parser = argparse.ArgumentParser(description="Evennia -> Nexus WebSocket Bridge")
|
||||||
sub = parser.add_subparsers(dest="mode")
|
sub = parser.add_subparsers(dest="mode")
|
||||||
|
|
||||||
live = sub.add_parser("live", help="Live tail Evennia logs and stream to Nexus")
|
live = sub.add_parser("live", help="Live tail Evennia logs and stream to Nexus")
|
||||||
live.add_argument("--log-dir", default="/root/workspace/timmy-academy/server/logs", help="Evennia logs directory")
|
live.add_argument("--log-dir", default="/root/workspace/timmy-academy/server/logs", help="Evennia logs directory")
|
||||||
live.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL")
|
live.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL")
|
||||||
|
|
||||||
replay = sub.add_parser("playback", help="Replay a telemetry JSONL file")
|
replay = sub.add_parser("playback", help="Replay a telemetry JSONL file")
|
||||||
replay.add_argument("log_path", help="Path to Evennia telemetry JSONL")
|
replay.add_argument("log_path", help="Path to Evennia telemetry JSONL")
|
||||||
replay.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL")
|
replay.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL")
|
||||||
|
|
||||||
|
inject = sub.add_parser("inject", help="Inject a single Evennia event (dev/test)")
|
||||||
|
inject.add_argument("event_type", choices=["room_snapshot", "actor_located", "command_result", "command_issued", "session_bound"])
|
||||||
|
inject.add_argument("--ws", default="ws://127.0.0.1:8765", help="Nexus WebSocket URL")
|
||||||
|
inject.add_argument("--room-key", default="Gate", help="Room key (room_snapshot, actor_located)")
|
||||||
|
inject.add_argument("--title", default="Gate", help="Room title (room_snapshot)")
|
||||||
|
inject.add_argument("--desc", default="The entrance gate.", help="Room description (room_snapshot)")
|
||||||
|
inject.add_argument("--actor-id", default="Timmy", help="Actor ID")
|
||||||
|
inject.add_argument("--command-text", default="look", help="Command text (command_result, command_issued)")
|
||||||
|
inject.add_argument("--output-text", default="You see the Gate.", help="Command output (command_result)")
|
||||||
|
inject.add_argument("--session-id", default="dev-inject", help="Hermes session ID")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.mode == "live":
|
if args.mode == "live":
|
||||||
asyncio.run(live_bridge(args.log_dir, args.ws))
|
asyncio.run(live_bridge(args.log_dir, args.ws))
|
||||||
elif args.mode == "playback":
|
elif args.mode == "playback":
|
||||||
asyncio.run(playback(Path(args.log_path).expanduser(), args.ws))
|
asyncio.run(playback(Path(args.log_path).expanduser(), args.ws))
|
||||||
|
elif args.mode == "inject":
|
||||||
|
asyncio.run(inject_event(
|
||||||
|
args.event_type,
|
||||||
|
args.ws,
|
||||||
|
room_key=args.room_key,
|
||||||
|
title=args.title,
|
||||||
|
desc=args.desc,
|
||||||
|
actor_id=args.actor_id,
|
||||||
|
command_text=args.command_text,
|
||||||
|
output_text=args.output_text,
|
||||||
|
session_id=args.session_id,
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user