@trac3er/oh-my-god 2.0.0 → 2.0.2
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/.claude-plugin/marketplace.json +8 -8
- package/.claude-plugin/plugin.json +5 -4
- package/.claude-plugin/scripts/uninstall.sh +74 -3
- package/.claude-plugin/scripts/update.sh +78 -3
- package/.coveragerc +26 -0
- package/.mcp.json +4 -4
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +62 -0
- package/OMG-setup.sh +1201 -355
- package/README.md +77 -56
- package/SECURITY.md +25 -0
- package/agents/__init__.py +1 -0
- package/agents/model_roles.py +196 -0
- package/agents/omg-architect-mode.md +3 -5
- package/agents/omg-backend-engineer.md +3 -5
- package/agents/omg-database-engineer.md +3 -5
- package/agents/omg-frontend-designer.md +4 -5
- package/agents/omg-implement-mode.md +4 -5
- package/agents/omg-infra-engineer.md +3 -5
- package/agents/omg-research-mode.md +4 -6
- package/agents/omg-security-auditor.md +3 -5
- package/agents/omg-testing-engineer.md +3 -5
- package/build/lib/yaml.py +321 -0
- package/commands/OMG:ai-commit.md +101 -14
- package/commands/OMG:arch.md +302 -19
- package/commands/OMG:ccg.md +12 -7
- package/commands/OMG:compat.md +25 -17
- package/commands/OMG:cost.md +173 -13
- package/commands/OMG:crazy.md +1 -1
- package/commands/OMG:create-agent.md +170 -20
- package/commands/OMG:deps.md +235 -17
- package/commands/OMG:domain-init.md +1 -1
- package/commands/OMG:escalate.md +41 -12
- package/commands/OMG:health-check.md +37 -13
- package/commands/OMG:init.md +122 -14
- package/commands/OMG:project-init.md +1 -1
- package/commands/OMG:session-branch.md +76 -9
- package/commands/OMG:session-fork.md +42 -5
- package/commands/OMG:session-merge.md +124 -8
- package/commands/OMG:setup.md +69 -12
- package/commands/OMG:stats.md +215 -14
- package/commands/OMG:teams.md +19 -10
- package/config/lsp_languages.yaml +8 -0
- package/hooks/__init__.py +0 -0
- package/hooks/_agent_registry.py +423 -0
- package/hooks/_analytics.py +291 -0
- package/hooks/_budget.py +31 -0
- package/hooks/_common.py +569 -0
- package/hooks/_compression_optimizer.py +119 -0
- package/hooks/_cost_ledger.py +176 -0
- package/hooks/_learnings.py +126 -0
- package/hooks/_memory.py +103 -0
- package/hooks/_protected_context.py +150 -0
- package/hooks/_token_counter.py +221 -0
- package/hooks/branch_manager.py +236 -0
- package/hooks/budget_governor.py +232 -0
- package/hooks/circuit-breaker.py +270 -0
- package/hooks/compression_feedback.py +254 -0
- package/hooks/config-guard.py +216 -0
- package/hooks/context_pressure.py +53 -0
- package/hooks/credential_store.py +1020 -0
- package/hooks/fetch-rate-limits.py +212 -0
- package/hooks/firewall.py +48 -0
- package/hooks/hashline-formatter-bridge.py +224 -0
- package/hooks/hashline-injector.py +273 -0
- package/hooks/hashline-validator.py +216 -0
- package/hooks/idle-detector.py +95 -0
- package/hooks/intentgate-keyword-detector.py +188 -0
- package/hooks/magic-keyword-router.py +195 -0
- package/hooks/policy_engine.py +505 -0
- package/hooks/post-tool-failure.py +19 -0
- package/hooks/post-write.py +219 -0
- package/hooks/post_write.py +46 -0
- package/hooks/pre-compact.py +398 -0
- package/hooks/pre-tool-inject.py +98 -0
- package/hooks/prompt-enhancer.py +672 -0
- package/hooks/quality-runner.py +191 -0
- package/hooks/query.py +512 -0
- package/hooks/secret-guard.py +61 -0
- package/hooks/secret_audit.py +144 -0
- package/hooks/session-end-capture.py +137 -0
- package/hooks/session-start.py +277 -0
- package/hooks/setup_wizard.py +582 -0
- package/hooks/shadow_manager.py +297 -0
- package/hooks/state_migration.py +225 -0
- package/hooks/stop-gate.py +7 -0
- package/hooks/stop_dispatcher.py +945 -0
- package/hooks/test-validator.py +361 -0
- package/hooks/test_generator_hook.py +123 -0
- package/hooks/todo-state-tracker.py +114 -0
- package/hooks/tool-ledger.py +149 -0
- package/hooks/trust_review.py +585 -0
- package/hud/omg-hud.mjs +31 -1
- package/lab/__init__.py +1 -0
- package/lab/pipeline.py +75 -0
- package/lab/policies.py +52 -0
- package/package.json +7 -18
- package/plugins/README.md +33 -61
- package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
- package/plugins/advanced/commands/OMG:learn.md +1 -1
- package/plugins/advanced/commands/OMG:security-review.md +3 -3
- package/plugins/advanced/commands/OMG:ship.md +1 -1
- package/plugins/advanced/plugin.json +1 -1
- package/plugins/core/plugin.json +8 -3
- package/plugins/dephealth/__init__.py +0 -0
- package/plugins/dephealth/cve_scanner.py +188 -0
- package/plugins/dephealth/license_checker.py +135 -0
- package/plugins/dephealth/manifest_detector.py +423 -0
- package/plugins/dephealth/vuln_analyzer.py +169 -0
- package/plugins/testgen/__init__.py +0 -0
- package/plugins/testgen/codamosa_engine.py +402 -0
- package/plugins/testgen/edge_case_synthesizer.py +184 -0
- package/plugins/testgen/framework_detector.py +271 -0
- package/plugins/testgen/skeleton_generator.py +219 -0
- package/plugins/viz/__init__.py +0 -0
- package/plugins/viz/ast_parser.py +139 -0
- package/plugins/viz/diagram_generator.py +192 -0
- package/plugins/viz/graph_builder.py +444 -0
- package/plugins/viz/native_parsers.py +259 -0
- package/plugins/viz/regex_parser.py +112 -0
- package/pyproject.toml +81 -0
- package/rules/contextual/write-verify.md +2 -2
- package/rules/core/00-truth.md +1 -1
- package/rules/core/01-surgical.md +1 -1
- package/rules/core/02-circuit-breaker.md +2 -2
- package/rules/core/03-ensemble.md +3 -3
- package/rules/core/04-testing.md +3 -3
- package/runtime/__init__.py +32 -0
- package/runtime/adapters/__init__.py +13 -0
- package/runtime/adapters/claude.py +60 -0
- package/runtime/adapters/gpt.py +53 -0
- package/runtime/adapters/local.py +53 -0
- package/runtime/adoption.py +212 -0
- package/runtime/business_workflow.py +220 -0
- package/runtime/cli_provider.py +85 -0
- package/runtime/compat.py +1299 -0
- package/runtime/custom_agent_loader.py +366 -0
- package/runtime/dispatcher.py +47 -0
- package/runtime/ecosystem.py +371 -0
- package/runtime/legacy_compat.py +7 -0
- package/runtime/mcp_config_writers.py +115 -0
- package/runtime/mcp_lifecycle.py +153 -0
- package/runtime/mcp_memory_server.py +135 -0
- package/runtime/memory_parsers/__init__.py +0 -0
- package/runtime/memory_parsers/chatgpt_parser.py +257 -0
- package/runtime/memory_parsers/claude_import.py +107 -0
- package/runtime/memory_parsers/export.py +97 -0
- package/runtime/memory_parsers/gemini_import.py +91 -0
- package/runtime/memory_parsers/kimi_import.py +91 -0
- package/runtime/memory_store.py +215 -0
- package/runtime/omc_compat.py +7 -0
- package/runtime/providers/__init__.py +0 -0
- package/runtime/providers/codex_provider.py +112 -0
- package/runtime/providers/gemini_provider.py +128 -0
- package/runtime/providers/kimi_provider.py +151 -0
- package/runtime/providers/opencode_provider.py +144 -0
- package/runtime/subagent_dispatcher.py +362 -0
- package/runtime/team_router.py +1167 -0
- package/runtime/tmux_session_manager.py +169 -0
- package/scripts/check-omg-compat-contract-snapshot.py +137 -0
- package/scripts/check-omg-contract-snapshot.py +12 -0
- package/scripts/check-omg-public-ready.py +193 -0
- package/scripts/check-omg-standalone-clean.py +103 -0
- package/scripts/legacy_to_omg_migrate.py +29 -0
- package/scripts/migrate-legacy.py +464 -0
- package/scripts/omc_to_omg_migrate.py +12 -0
- package/scripts/omg.py +492 -0
- package/scripts/settings-merge.py +283 -0
- package/scripts/verify-standalone.sh +8 -4
- package/settings.json +126 -29
- package/templates/profile.yaml +1 -1
- package/tools/__init__.py +2 -0
- package/tools/browser_consent.py +289 -0
- package/tools/browser_stealth.py +481 -0
- package/tools/browser_tool.py +448 -0
- package/tools/changelog_generator.py +347 -0
- package/tools/commit_splitter.py +746 -0
- package/tools/config_discovery.py +151 -0
- package/tools/config_merger.py +449 -0
- package/tools/dashboard_generator.py +300 -0
- package/tools/git_inspector.py +298 -0
- package/tools/lsp_client.py +275 -0
- package/tools/lsp_discovery.py +231 -0
- package/tools/lsp_operations.py +392 -0
- package/tools/pr_generator.py +404 -0
- package/tools/python_repl.py +656 -0
- package/tools/python_sandbox.py +609 -0
- package/tools/search_providers/__init__.py +77 -0
- package/tools/search_providers/brave.py +115 -0
- package/tools/search_providers/exa.py +116 -0
- package/tools/search_providers/jina.py +104 -0
- package/tools/search_providers/perplexity.py +139 -0
- package/tools/search_providers/synthetic.py +74 -0
- package/tools/session_snapshot.py +736 -0
- package/tools/ssh_manager.py +912 -0
- package/tools/theme_engine.py +294 -0
- package/tools/theme_selector.py +137 -0
- package/tools/web_search.py +622 -0
- package/yaml.py +321 -0
- package/.claude-plugin/scripts/install.sh +0 -9
- package/bun.lock +0 -23
- package/bunfig.toml +0 -3
- package/hooks/_budget.ts +0 -1
- package/hooks/_common.ts +0 -63
- package/hooks/circuit-breaker.ts +0 -101
- package/hooks/config-guard.ts +0 -4
- package/hooks/firewall.ts +0 -20
- package/hooks/policy_engine.ts +0 -156
- package/hooks/post-tool-failure.ts +0 -22
- package/hooks/post-write.ts +0 -4
- package/hooks/pre-tool-inject.ts +0 -4
- package/hooks/prompt-enhancer.ts +0 -46
- package/hooks/quality-runner.ts +0 -24
- package/hooks/secret-guard.ts +0 -4
- package/hooks/session-end-capture.ts +0 -19
- package/hooks/session-start.ts +0 -19
- package/hooks/shadow_manager.ts +0 -81
- package/hooks/stop-gate.ts +0 -22
- package/hooks/stop_dispatcher.ts +0 -147
- package/hooks/test-generator-hook.ts +0 -4
- package/hooks/tool-ledger.ts +0 -27
- package/hooks/trust_review.ts +0 -175
- package/lab/pipeline.ts +0 -75
- package/lab/policies.ts +0 -68
- package/runtime/common.ts +0 -111
- package/runtime/compat.ts +0 -174
- package/runtime/dispatcher.ts +0 -25
- package/runtime/ecosystem.ts +0 -186
- package/runtime/provider_bootstrap.ts +0 -99
- package/runtime/provider_smoke.ts +0 -34
- package/runtime/release_readiness.ts +0 -186
- package/runtime/team_router.ts +0 -144
- package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
- package/scripts/check-omg-standalone-clean.ts +0 -12
- package/scripts/check-runtime-clean.ts +0 -94
- package/scripts/omg.ts +0 -352
- package/scripts/settings-merge.ts +0 -93
- package/tools/commit_splitter.ts +0 -23
- package/tools/git_inspector.ts +0 -18
- package/tools/session_snapshot.ts +0 -47
- package/trac3er-oh-my-god-2.0.0.tgz +0 -0
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Kimi Web paste-based memory import — parse and import memories from Kimi Web interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any # pyright: ignore[reportExplicitAny]
|
|
7
|
+
|
|
8
|
+
EXTRACTION_PROMPT = (
|
|
9
|
+
"What information do you remember about me? Please list all stored preferences, facts, and context "
|
|
10
|
+
"as bullet points starting with '- '."
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_kimi_paste(text: str) -> list[dict[str, Any]]: # pyright: ignore[reportExplicitAny]
|
|
15
|
+
"""Parse memories from Kimi Web paste text.
|
|
16
|
+
|
|
17
|
+
Supports bullet points (- item) and numbered lists (1. item).
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
text: Raw text pasted from Kimi Web interface.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
List of memory items with keys, content, source_cli, and tags.
|
|
24
|
+
"""
|
|
25
|
+
if not text or not text.strip():
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
items: list[str] = []
|
|
29
|
+
lines = text.split("\n")
|
|
30
|
+
|
|
31
|
+
for line in lines:
|
|
32
|
+
line = line.strip()
|
|
33
|
+
if not line:
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
# Try bullet point format: "- item"
|
|
37
|
+
if line.startswith("-"):
|
|
38
|
+
content = line[1:].strip()
|
|
39
|
+
if content:
|
|
40
|
+
items.append(content)
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
# Try numbered format: "1. item", "2. item", etc.
|
|
44
|
+
match = re.match(r"^\d+\.\s+(.+)$", line)
|
|
45
|
+
if match:
|
|
46
|
+
content = match.group(1).strip()
|
|
47
|
+
if content:
|
|
48
|
+
items.append(content)
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
# Convert to memory item dicts
|
|
52
|
+
result: list[dict[str, Any]] = [] # pyright: ignore[reportExplicitAny]
|
|
53
|
+
for i, content in enumerate(items):
|
|
54
|
+
result.append(
|
|
55
|
+
{
|
|
56
|
+
"key": f"kimi-memory-{i+1}",
|
|
57
|
+
"content": content,
|
|
58
|
+
"source_cli": "kimi-web",
|
|
59
|
+
"tags": ["kimi-web", "imported"],
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def import_from_paste(text: str, store: Any) -> int: # pyright: ignore[reportAny]
|
|
67
|
+
"""Import memories from Kimi Web paste into the memory store.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
text: Raw text pasted from Kimi Web interface.
|
|
71
|
+
store: MemoryStore instance to add items to.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Number of items successfully imported.
|
|
75
|
+
"""
|
|
76
|
+
items = parse_kimi_paste(text)
|
|
77
|
+
count = 0
|
|
78
|
+
|
|
79
|
+
for item in items:
|
|
80
|
+
store.add( # pyright: ignore[reportAny]
|
|
81
|
+
key=item["key"],
|
|
82
|
+
content=item["content"],
|
|
83
|
+
source_cli=item["source_cli"],
|
|
84
|
+
tags=item["tags"],
|
|
85
|
+
)
|
|
86
|
+
count += 1
|
|
87
|
+
|
|
88
|
+
return count
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
__all__ = ["EXTRACTION_PROMPT", "parse_kimi_paste", "import_from_paste"]
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""MemoryStore — CRUD + JSON persistence for shared memory across CLI providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MemoryStoreFullError(Exception):
|
|
14
|
+
"""Raised when adding to a store that has reached the 10,000 item limit."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_MAX_ITEMS = 10_000
|
|
18
|
+
|
|
19
|
+
# Type alias for memory items — JSON-like dicts.
|
|
20
|
+
_Item = dict[str, Any] # pyright: ignore[reportExplicitAny]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MemoryStore:
|
|
24
|
+
"""Thread-unsafe, file-backed key/content store with JSON persistence.
|
|
25
|
+
|
|
26
|
+
Each item follows the schema::
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
"id": str, # UUID4
|
|
30
|
+
"key": str, # user-defined key
|
|
31
|
+
"content": str, # the memory text
|
|
32
|
+
"source_cli": str, # originating CLI name
|
|
33
|
+
"tags": list[str], # optional tags
|
|
34
|
+
"created_at": str, # ISO 8601 UTC
|
|
35
|
+
"updated_at": str, # ISO 8601 UTC
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Persistence uses atomic writes (tmp file + ``os.replace``).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, store_path: str | None = None) -> None:
|
|
42
|
+
if store_path is None:
|
|
43
|
+
store_path = str(Path.home() / ".omg" / "shared-memory" / "store.json")
|
|
44
|
+
self.store_path: str = store_path
|
|
45
|
+
self._items: list[_Item] = self._load()
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
# Public API
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def add(
|
|
52
|
+
self,
|
|
53
|
+
key: str,
|
|
54
|
+
content: str,
|
|
55
|
+
source_cli: str,
|
|
56
|
+
tags: list[str] | None = None,
|
|
57
|
+
) -> _Item:
|
|
58
|
+
"""Create a new memory item and persist to disk.
|
|
59
|
+
|
|
60
|
+
Raises ``MemoryStoreFullError`` when the store already holds
|
|
61
|
+
``_MAX_ITEMS`` entries.
|
|
62
|
+
"""
|
|
63
|
+
if self.count() >= _MAX_ITEMS:
|
|
64
|
+
raise MemoryStoreFullError(
|
|
65
|
+
f"Memory store is full ({_MAX_ITEMS} items). "
|
|
66
|
+
"Delete items before adding new ones."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
now = _utc_now_iso()
|
|
70
|
+
item: _Item = {
|
|
71
|
+
"id": str(uuid.uuid4()),
|
|
72
|
+
"key": key,
|
|
73
|
+
"content": content,
|
|
74
|
+
"source_cli": source_cli,
|
|
75
|
+
"tags": tags if tags is not None else [],
|
|
76
|
+
"created_at": now,
|
|
77
|
+
"updated_at": now,
|
|
78
|
+
}
|
|
79
|
+
self._items.append(item)
|
|
80
|
+
self._save()
|
|
81
|
+
return item
|
|
82
|
+
|
|
83
|
+
def get(self, item_id: str) -> _Item | None:
|
|
84
|
+
"""Return the item with *item_id*, or ``None`` if not found."""
|
|
85
|
+
for item in self._items:
|
|
86
|
+
if item["id"] == item_id:
|
|
87
|
+
return item
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def update(
|
|
91
|
+
self,
|
|
92
|
+
item_id: str,
|
|
93
|
+
content: str | None = None,
|
|
94
|
+
tags: list[str] | None = None,
|
|
95
|
+
) -> _Item | None:
|
|
96
|
+
"""Update *content* and/or *tags* for an existing item.
|
|
97
|
+
|
|
98
|
+
Returns the updated item, or ``None`` if *item_id* is not found.
|
|
99
|
+
"""
|
|
100
|
+
item = self.get(item_id)
|
|
101
|
+
if item is None:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
if content is not None:
|
|
105
|
+
item["content"] = content
|
|
106
|
+
if tags is not None:
|
|
107
|
+
item["tags"] = tags
|
|
108
|
+
item["updated_at"] = _utc_now_iso()
|
|
109
|
+
self._save()
|
|
110
|
+
return item
|
|
111
|
+
|
|
112
|
+
def delete(self, item_id: str) -> bool:
|
|
113
|
+
"""Remove the item with *item_id*.
|
|
114
|
+
|
|
115
|
+
Returns ``True`` if deleted, ``False`` if not found.
|
|
116
|
+
"""
|
|
117
|
+
for idx, item in enumerate(self._items):
|
|
118
|
+
if item["id"] == item_id:
|
|
119
|
+
del self._items[idx]
|
|
120
|
+
self._save()
|
|
121
|
+
return True
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
def search(
|
|
125
|
+
self,
|
|
126
|
+
query: str,
|
|
127
|
+
source_cli: str | None = None,
|
|
128
|
+
) -> list[_Item]:
|
|
129
|
+
"""Keyword search across key, content, and tags (case-insensitive).
|
|
130
|
+
|
|
131
|
+
Optionally filtered by *source_cli*.
|
|
132
|
+
"""
|
|
133
|
+
q = query.lower()
|
|
134
|
+
results: list[_Item] = []
|
|
135
|
+
for item in self._items:
|
|
136
|
+
if source_cli is not None and item["source_cli"] != source_cli:
|
|
137
|
+
continue
|
|
138
|
+
key_str = str(item.get("key", "")).lower()
|
|
139
|
+
content_str = str(item.get("content", "")).lower()
|
|
140
|
+
tags_raw = item.get("tags", [])
|
|
141
|
+
tags_str = " ".join(str(t).lower() for t in tags_raw) if isinstance(tags_raw, list) else ""
|
|
142
|
+
if q in key_str or q in content_str or q in tags_str:
|
|
143
|
+
results.append(item)
|
|
144
|
+
return results
|
|
145
|
+
|
|
146
|
+
def list_all(self, source_cli: str | None = None) -> list[_Item]:
|
|
147
|
+
"""Return all items, optionally filtered by *source_cli*."""
|
|
148
|
+
if source_cli is None:
|
|
149
|
+
return list(self._items)
|
|
150
|
+
return [i for i in self._items if i["source_cli"] == source_cli]
|
|
151
|
+
|
|
152
|
+
def export_all(self) -> list[_Item]:
|
|
153
|
+
"""Return a copy of all items as a list."""
|
|
154
|
+
return list(self._items)
|
|
155
|
+
|
|
156
|
+
def import_items(self, items: list[_Item]) -> int:
|
|
157
|
+
"""Bulk import items, skipping any whose ``id`` already exists.
|
|
158
|
+
|
|
159
|
+
Returns the number of items actually added.
|
|
160
|
+
"""
|
|
161
|
+
existing_ids = {str(i["id"]) for i in self._items}
|
|
162
|
+
added = 0
|
|
163
|
+
for item in items:
|
|
164
|
+
item_id = str(item.get("id", ""))
|
|
165
|
+
if item_id and item_id not in existing_ids:
|
|
166
|
+
self._items.append(item)
|
|
167
|
+
existing_ids.add(item_id)
|
|
168
|
+
added += 1
|
|
169
|
+
if added:
|
|
170
|
+
self._save()
|
|
171
|
+
return added
|
|
172
|
+
|
|
173
|
+
def count(self) -> int:
|
|
174
|
+
"""Return the total number of stored items."""
|
|
175
|
+
return len(self._items)
|
|
176
|
+
|
|
177
|
+
def clear(self) -> int:
|
|
178
|
+
"""Delete all items and return the count of deleted items."""
|
|
179
|
+
n = len(self._items)
|
|
180
|
+
self._items.clear()
|
|
181
|
+
self._save()
|
|
182
|
+
return n
|
|
183
|
+
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
# Internal helpers
|
|
186
|
+
# ------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def _load(self) -> list[_Item]:
|
|
189
|
+
"""Load items from the JSON file. Returns empty list if missing."""
|
|
190
|
+
path = Path(self.store_path)
|
|
191
|
+
if not path.exists():
|
|
192
|
+
return []
|
|
193
|
+
try:
|
|
194
|
+
raw = json.loads(path.read_text())
|
|
195
|
+
if isinstance(raw, list):
|
|
196
|
+
return raw # type: ignore[return-value]
|
|
197
|
+
return []
|
|
198
|
+
except (json.JSONDecodeError, ValueError):
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
def _save(self) -> None:
|
|
202
|
+
"""Persist items to disk with atomic write (tmp + os.replace)."""
|
|
203
|
+
path = Path(self.store_path)
|
|
204
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
tmp_path = path.with_name(f"{path.name}.tmp")
|
|
206
|
+
_ = tmp_path.write_text(json.dumps(self._items, indent=2) + "\n")
|
|
207
|
+
_ = os.replace(tmp_path, path)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _utc_now_iso() -> str:
|
|
211
|
+
"""Return current UTC time as ISO 8601 string."""
|
|
212
|
+
return datetime.now(timezone.utc).isoformat()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
__all__ = ["MemoryStore", "MemoryStoreFullError"]
|
|
File without changes
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Codex CLI provider — implements CLIProvider for the ``codex`` binary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from runtime.cli_provider import CLIProvider, register_provider
|
|
14
|
+
from runtime.tmux_session_manager import TmuxSessionManager
|
|
15
|
+
|
|
16
|
+
_logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CodexProvider(CLIProvider):
|
|
20
|
+
"""CLIProvider implementation for the Codex CLI (``codex``)."""
|
|
21
|
+
|
|
22
|
+
# -- identity -----------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def get_name(self) -> str: # noqa: D401
|
|
25
|
+
"""Return the canonical provider name."""
|
|
26
|
+
return "codex"
|
|
27
|
+
|
|
28
|
+
# -- detection ----------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def detect(self) -> bool:
|
|
31
|
+
"""Return ``True`` when the ``codex`` binary is available on PATH."""
|
|
32
|
+
return shutil.which("codex") is not None
|
|
33
|
+
|
|
34
|
+
# -- authentication -----------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def check_auth(self) -> tuple[bool | None, str]:
|
|
37
|
+
"""Check Codex authentication status via ``codex auth status``."""
|
|
38
|
+
try:
|
|
39
|
+
result = self.run_tool(["codex", "auth", "status"], timeout=30)
|
|
40
|
+
if result.returncode == 0:
|
|
41
|
+
return True, result.stdout.strip()
|
|
42
|
+
return False, result.stderr.strip() or result.stdout.strip()
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
return None, f"codex auth check failed: {exc}"
|
|
45
|
+
|
|
46
|
+
# -- invocation ---------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def invoke(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
|
49
|
+
"""Invoke ``codex exec --json`` via subprocess."""
|
|
50
|
+
try:
|
|
51
|
+
result = self.run_tool(
|
|
52
|
+
["codex", "exec", "--json", prompt],
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
)
|
|
55
|
+
return {
|
|
56
|
+
"model": "codex-cli",
|
|
57
|
+
"output": result.stdout,
|
|
58
|
+
"exit_code": result.returncode,
|
|
59
|
+
}
|
|
60
|
+
except subprocess.TimeoutExpired:
|
|
61
|
+
return {"error": "codex-cli timeout", "fallback": "claude"}
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
return {"error": "codex-cli not found", "fallback": "claude"}
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
return {"error": str(exc), "fallback": "claude"}
|
|
66
|
+
|
|
67
|
+
def invoke_tmux(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
|
68
|
+
"""Invoke ``codex exec --json`` via a persistent tmux session.
|
|
69
|
+
|
|
70
|
+
Falls back to :meth:`invoke` on failure.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
mgr = TmuxSessionManager()
|
|
74
|
+
session_name = mgr.make_session_name("codex", unique_id=str(uuid.uuid4())[:8])
|
|
75
|
+
session = mgr.get_or_create_session(session_name)
|
|
76
|
+
output = mgr.send_command(session, f"codex exec --json {shlex.quote(prompt)}", timeout=timeout)
|
|
77
|
+
mgr.kill_session(session)
|
|
78
|
+
return {"model": "codex-cli", "output": output, "exit_code": 0}
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
_logger.warning("tmux codex invocation failed, falling back to subprocess: %s", exc)
|
|
81
|
+
return self.invoke(prompt, project_dir, timeout=timeout)
|
|
82
|
+
|
|
83
|
+
# -- command helpers ----------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def get_non_interactive_cmd(self, prompt: str) -> list[str]:
|
|
86
|
+
"""Return the non-interactive command for codex."""
|
|
87
|
+
return ["codex", "exec", "--json", prompt]
|
|
88
|
+
|
|
89
|
+
# -- configuration ------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def get_config_path(self) -> str:
|
|
92
|
+
"""Return the Codex configuration file path."""
|
|
93
|
+
return os.path.expanduser("~/.codex/config.toml")
|
|
94
|
+
|
|
95
|
+
def write_mcp_config(self, server_url: str, server_name: str = "memory-server") -> None:
|
|
96
|
+
"""Write an MCP server entry to ``~/.codex/config.toml``."""
|
|
97
|
+
config_path = self.get_config_path()
|
|
98
|
+
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
99
|
+
|
|
100
|
+
entry = (
|
|
101
|
+
f'[mcp_servers."{server_name}"]\n'
|
|
102
|
+
f'url = "{server_url}"\n'
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Append or create
|
|
106
|
+
mode = "a" if os.path.exists(config_path) else "w"
|
|
107
|
+
with open(config_path, mode) as fh:
|
|
108
|
+
fh.write(entry)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# -- auto-register on import -----------------------------------------------
|
|
112
|
+
register_provider(CodexProvider())
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Gemini CLI provider — implements CLIProvider for the ``gemini`` binary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from runtime.cli_provider import CLIProvider, register_provider
|
|
15
|
+
from runtime.tmux_session_manager import TmuxSessionManager
|
|
16
|
+
|
|
17
|
+
_logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GeminiProvider(CLIProvider):
|
|
21
|
+
"""CLIProvider implementation for the Gemini CLI (``gemini``)."""
|
|
22
|
+
|
|
23
|
+
# -- identity -----------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def get_name(self) -> str: # noqa: D401
|
|
26
|
+
"""Return the canonical provider name."""
|
|
27
|
+
return "gemini"
|
|
28
|
+
|
|
29
|
+
# -- detection ----------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def detect(self) -> bool:
|
|
32
|
+
"""Return ``True`` when the ``gemini`` binary is available on PATH."""
|
|
33
|
+
return shutil.which("gemini") is not None
|
|
34
|
+
|
|
35
|
+
# -- authentication -----------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def check_auth(self) -> tuple[bool | None, str]:
|
|
38
|
+
"""Check Gemini authentication status via ``gemini auth status``."""
|
|
39
|
+
try:
|
|
40
|
+
result = self.run_tool(["gemini", "auth", "status"], timeout=30)
|
|
41
|
+
if result.returncode == 0:
|
|
42
|
+
return True, result.stdout.strip()
|
|
43
|
+
return False, result.stderr.strip() or result.stdout.strip()
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
return None, f"gemini auth check failed: {exc}"
|
|
46
|
+
|
|
47
|
+
# -- invocation ---------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
def invoke(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
|
50
|
+
"""Invoke ``gemini -p`` via subprocess.
|
|
51
|
+
|
|
52
|
+
Gemini CLI has no ``--json`` flag — output is plain text stdout.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
result = self.run_tool(
|
|
56
|
+
["gemini", "-p", prompt],
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
)
|
|
59
|
+
return {
|
|
60
|
+
"model": "gemini-cli",
|
|
61
|
+
"output": result.stdout,
|
|
62
|
+
"exit_code": result.returncode,
|
|
63
|
+
}
|
|
64
|
+
except subprocess.TimeoutExpired:
|
|
65
|
+
return {"error": "gemini-cli timeout", "fallback": "claude"}
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
return {"error": "gemini-cli not found", "fallback": "claude"}
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
return {"error": str(exc), "fallback": "claude"}
|
|
70
|
+
|
|
71
|
+
def invoke_tmux(self, prompt: str, project_dir: str, timeout: int = 120) -> dict[str, Any]: # pyright: ignore[reportExplicitAny]
|
|
72
|
+
"""Invoke ``gemini -p`` via a persistent tmux session.
|
|
73
|
+
|
|
74
|
+
Falls back to :meth:`invoke` on failure.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
mgr = TmuxSessionManager()
|
|
78
|
+
session_name = mgr.make_session_name("gemini", unique_id=str(uuid.uuid4())[:8])
|
|
79
|
+
session = mgr.get_or_create_session(session_name)
|
|
80
|
+
output = mgr.send_command(session, f"gemini -p {shlex.quote(prompt)}", timeout=timeout)
|
|
81
|
+
mgr.kill_session(session)
|
|
82
|
+
return {"model": "gemini-cli", "output": output, "exit_code": 0}
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
_logger.warning("tmux gemini invocation failed, falling back to subprocess: %s", exc)
|
|
85
|
+
return self.invoke(prompt, project_dir, timeout=timeout)
|
|
86
|
+
|
|
87
|
+
# -- command helpers ----------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def get_non_interactive_cmd(self, prompt: str) -> list[str]:
|
|
90
|
+
"""Return the non-interactive command for gemini."""
|
|
91
|
+
return ["gemini", "-p", prompt]
|
|
92
|
+
|
|
93
|
+
# -- configuration ------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def get_config_path(self) -> str:
|
|
96
|
+
"""Return the Gemini configuration file path."""
|
|
97
|
+
return os.path.expanduser("~/.gemini/settings.json")
|
|
98
|
+
|
|
99
|
+
def write_mcp_config(self, server_url: str, server_name: str = "memory-server") -> None:
|
|
100
|
+
"""Write an MCP server entry to ``~/.gemini/settings.json``.
|
|
101
|
+
|
|
102
|
+
Uses JSON format with ``mcpServers`` key and ``httpUrl`` field,
|
|
103
|
+
merging into any existing configuration.
|
|
104
|
+
"""
|
|
105
|
+
config_path = self.get_config_path()
|
|
106
|
+
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
107
|
+
|
|
108
|
+
# Load existing config or start fresh
|
|
109
|
+
existing: dict[str, Any] = {} # pyright: ignore[reportExplicitAny]
|
|
110
|
+
if os.path.exists(config_path):
|
|
111
|
+
with open(config_path) as fh:
|
|
112
|
+
try:
|
|
113
|
+
existing = json.load(fh)
|
|
114
|
+
except (json.JSONDecodeError, ValueError):
|
|
115
|
+
existing = {}
|
|
116
|
+
|
|
117
|
+
# Ensure mcpServers dict exists
|
|
118
|
+
if "mcpServers" not in existing:
|
|
119
|
+
existing["mcpServers"] = {}
|
|
120
|
+
|
|
121
|
+
existing["mcpServers"][server_name] = {"httpUrl": server_url}
|
|
122
|
+
|
|
123
|
+
with open(config_path, "w") as fh:
|
|
124
|
+
json.dump(existing, fh, indent=2)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# -- auto-register on import -----------------------------------------------
|
|
128
|
+
register_provider(GeminiProvider())
|