Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ WIKI_DISCORD_CHANNEL_IDS=1472113454482591922,1553638464988385431
WIKI_DISCORD_FORUM_IDS=1553645670844334152
WIKI_SERVICE_URL=http://127.0.0.1:3110
WIKI_SERVICE_KEY=
# The same read-only Wiki connection serves /스레드-정리 노션참고:true.
# No Notion token is needed in this bot.
HERMES_WIKI_URL=http://127.0.0.1:8647/v1/wiki/answer
HERMES_WIKI_KEY=
WIKI_STATE_PATH=runtime/wiki-proposals.sqlite
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Issue, 브랜치 생성, push, 라벨, workflow, 배포 등은 구독·전송하

## `/스레드-정리`

`/스레드-정리 노션참고:true`는 스레드의 Notion 링크를 최대 3개 읽고 참고 문서 링크를 함께 표시합니다. `WIKI_SERVICE_URL`과 `WIKI_SERVICE_KEY`로 Wiki 서버의 읽기 API를 호출합니다. Notion 토큰은 Wiki 서버에만 저장합니다. 기본 명령은 대화만 정리하며, 참고 문서 내용을 Discord 대화의 사건이나 팀 합의로 표현하지 않도록 별도 지시를 전달합니다. 접근할 수 없는 문서는 조회 실패로 표시합니다. Wiki 서버의 Notion 기능을 먼저 배포해야 합니다.

Gateway가 `/스레드-정리` 명령을 등록하고, 일반 답글 스레드와 독립 생성 스레드의 최근 500개 메시지를 익명화해 정리합니다. 결과는 `3줄 요약 → 시간순 타임라인 → 다음 작업` 형식으로 원본 채널에 남깁니다. 기본 OpenAI API 구성의 `gpt-5-nano`는 `reasoning.effort: minimal`, `store: false`를 사용합니다. Hermes 구성은 아래 절차에 따라 `gpt-6-luna`를 사용합니다.

## PR 알림용 GitHub App 설정
Expand Down
4 changes: 4 additions & 0 deletions deploy/hermes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ Hermes의 Wiki MCP OAuth 갱신이 400으로 실패해 검색 도구를 등록
- Node Gateway의 위키 멘션 처리 버전을 배포한다. 기존 `/위키-제안`과 참여자 승인 절차를 재사용한다.
- `configure_wiki_runtime.py --enable-mention-routing`으로 기존 서비스 키를 Hermes 환경 파일에 연결한다. 키를 로그나 저장소에 넣지 않는다. 모델은 기존 ChatGPT OAuth의 `gpt-6-luna`, 기본 추론은 `max`, 처리 요청은 `priority`다.
- `wiki_routing_patch.py ~/.hermes/hermes-agent/plugins/platforms/discord/adapter.py`를 적용하고 Hermes Gateway를 재시작한다. 허용 채널의 직접 멘션 갱신 요청은 Node가 처리한다. 일반 대화와 봇 답글 회신은 Hermes가 처리한다.

- 갱신된 `framework-wiki-reader`는 일반 문서 질문에도 `get_sources_context`로 위키와 노션을 함께 확인한다. Wiki 서버의 Notion 기능 배포 후 스킬을 설치하고 MCP 도구 목록을 다시 로드한다.
- `mention_policy_patch.py`는 진행 중인 요청과 같은 작성자·서버·채널의 후속 메시지도 받는다. 작업이 끝나면 일반 채널에서 다시 멘션이나 봇 답글 회신이 필요하다. 다른 작성자의 일반 채널 메시지는 새 요청으로 받지 않는다.
- `configure_wiki_runtime.py`는 `display.busy_input_mode=steer`를 설정한다. 진행 중에 들어온 범위 수정은 현재 에이전트에 전달하고, 최신 제외 지시를 최종 답변에 반영하도록 스킬에서 안내한다. 재시작 전에 진행 중인 요청을 확인한다.
- `framework-thread-summary`와 `framework-wiki-inference`도 갱신한다. 요약과 위키 변경안은 max로 요청한다. 웹은 max를 기본으로 시작하며 사용자가 강도를 선택할 수 있다.

설정 도구는 수정 전 파일을 `~/.hermes/backups/framework-wiki-<timestamp>`에 권한 600으로 보관한다. 라우팅 패치는 원본 옆 `.framework-before-wiki-routing`에 백업하며, 예상한 코드가 없으면 중단한다. 되돌릴 때는 백업을 복원하고 서비스를 재시작한다.
Expand Down
3 changes: 2 additions & 1 deletion deploy/hermes/configure_wiki_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ def configure(hermes_home, wiki_env, bot_envs, enable_mentions=False):
raise ValueError('Existing Hermes ChatGPT OAuth provider is required')
model['default'] = 'gpt-6-luna'
config.setdefault('agent', {}).update(reasoning_effort='max', service_tier='priority')
config.setdefault('display', {})['busy_input_mode'] = 'steer'
servers = config.get('mcp_servers', {})
if not isinstance(servers.get('framework_wiki'), dict):
raise ValueError('Existing framework_wiki MCP configuration is required')
Expand All @@ -78,7 +79,7 @@ def configure(hermes_home, wiki_env, bot_envs, enable_mentions=False):
server['headers'] = {k: v for k, v in server.get('headers', {}).items() if k.lower() != 'authorization'}
server['headers']['Authorization'] = 'Bearer ${FRAMEWORK_WIKI_SERVICE_KEY}'
append_wiki_hint(config)
updates = {'FRAMEWORK_WIKI_SERVICE_KEY': key}
updates = {'FRAMEWORK_WIKI_SERVICE_KEY': key, 'HERMES_GATEWAY_BUSY_INPUT_MODE': 'steer'}
if enable_mentions:
bot = {}
for path in bot_envs:
Expand Down
6 changes: 6 additions & 0 deletions deploy/hermes/framework-wiki-reader/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ Notion의 URL·수정 시각·조회 시각·부분 조회 상태를 유지한

Notion 조회는 공통 Wiki 서버의 서비스 인증을 사용한다. Hermes 브라우저의 로그인 상태로 이 연결의 접근 가능 여부를 판단하지 않는다. 통합 도구가 검색되지 않으면 도구 목록 갱신이 필요하다고 알리고, 기존 Wiki 도구로 확인한 범위만 답한다. 과거 답변의 “Notion 조회 불가” 문구를 현재 연결 상태의 근거로 사용하지 않는다.

## 답변 범위 변경

후속 메시지의 수정·제외 지시를 최신 범위로 적용한다. 이미 찾은 자료에 포함돼 있어도 제외한 항목을 최종 비교·결론·다음 방향에 다시 넣지 않는다. 검색을 더 해야 하면 기존 질문의 목적과 최신 범위를 함께 사용한다.

예를 들어 얼굴 합성 모델의 아키텍처·정량·정성 결과를 묻다가 “자체 모델에 한정, InSwapper / GHOST 제외”라고 좁혔다면 자체 학습 모델의 구조·조건·수치·육안 평가를 위키와 노션에서 확인한다. 외부 모델의 속도 결과나 외부 모델 채택 방향을 답변에 포함하지 않는다. 노션 실험 기록을 조회하지 못하면 그 범위를 밝힌다. 진행 중에 범위 변경을 받으면 반영한 뒤 최종 답변을 보낸다.

## Discord에서 위키 갱신

`@Framework Bot … 위키 갱신해`처럼 명시한 요청은 같은 서버의 전용 제안 처리기가 받는다. 요청 메시지의 사실과 링크를 바탕으로 변경안을 만들고, 참여자가 승인하면 Draft PR을 생성한다. 승인·병합이 끝나기 전에 위키 반영 완료라고 말하지 않는다.
Expand Down
79 changes: 74 additions & 5 deletions deploy/hermes/mention_policy_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,85 @@
NEW = " and not await self._framework_is_reply_to_self(message)\n"


FOLLOWUP_MARKER = " def _framework_is_active_request_followup(self, message):"
FOLLOWUP = ''' def _framework_requester_key(self, message):
return (str(getattr(getattr(message, "guild", None), "id", "")),
str(getattr(getattr(message, "channel", None), "id", "")),
str(getattr(getattr(message, "author", None), "id", "")))

def _framework_record_requester(self, message, source):
author = getattr(message, "author", None)
if not author or getattr(author, "bot", False) or getattr(message, "webhook_id", None):
return
requesters = getattr(self, "_framework_requesters", None)
if requesters is None:
self._framework_requesters = requesters = {}
key = self._framework_requester_key(message)
session_key = self._source_session_key(source)
for requester, previous in list(requesters.items()):
if previous["session_key"] == session_key and requester != key:
requesters.pop(requester)
if len(requesters) >= 200 and key not in requesters:
requesters.pop(next(iter(requesters)))
requesters[key] = {"session_key": session_key, "source": source}

def _framework_is_active_request_followup(self, message):
author = getattr(message, "author", None)
if not author or getattr(author, "bot", False) or getattr(message, "webhook_id", None):
return False
explicit = getattr(self, "_self_is_explicitly_mentioned", None)
if explicit and explicit(message):
return False
key = self._framework_requester_key(message)
requesters = getattr(self, "_framework_requesters", {})
previous = requesters.get(key)
if not previous:
return False
session_key = previous["session_key"]
active = session_key in getattr(self, "_active_sessions", {}) or session_key in getattr(self, "_pending_text_batches", {})
if not active:
requesters.pop(key, None)
return active

def _framework_followup_source(self, message, source):
if not self._framework_is_active_request_followup(message):
return source
import copy
previous = self._framework_requesters[self._framework_requester_key(message)]["source"]
result = copy.copy(source)
for field in ("chat_id", "chat_name", "chat_type", "thread_id", "parent_chat_id"):
if hasattr(previous, field):
setattr(result, field, getattr(previous, field))
return result

'''
FOLLOWUP_GATE = " and not self._framework_is_active_request_followup(message)\n"
RECORD_ANCHOR = " # Track participation so follow-ups in this thread don't need @mention.\n"
RECORD = " self._framework_record_requester(message, event.source)\n"
AUTO_THREAD = ' auto_thread = self._extra_or_env_flag("auto_thread", "DISCORD_AUTO_THREAD", "true", truthy=True)\n'
AUTO_THREAD_GUARD = ' auto_thread = auto_thread and not self._framework_is_active_request_followup(message)\n'
EVENT = " event = MessageEvent(\n text=event_text, message_type=msg_type, source=source, raw_message=message,\n"
ROUTE = " source = self._framework_followup_source(message, source)\n"


def patched(source):
if MARKER in source and NEW in source and OLD not in source:
if FOLLOWUP_MARKER in source and NEW + FOLLOWUP_GATE in source and RECORD + RECORD_ANCHOR in source and AUTO_THREAD_GUARD in source and ROUTE + EVENT in source:
return source
if MARKER in source or source.count(OLD) != 1 or source.count(" async def _handle_message(\n") != 1:
raise ValueError("Hermes adapter changed; review the mention gate before applying")
result = source.replace(" async def _handle_message(\n", HELPER + " async def _handle_message(\n").replace(OLD, NEW)
if FOLLOWUP_MARKER in source or source.count(RECORD_ANCHOR) != 1 or source.count(AUTO_THREAD) != 1 or source.count(EVENT) != 1:
raise ValueError("Hermes adapter changed; review active-request follow-ups before applying")
if MARKER in source:
if source.count(HELPER) != 1 or source.count(NEW) != 1 or OLD in source:
raise ValueError("Hermes adapter changed; review the existing mention policy before applying")
result = source.replace(HELPER, HELPER + FOLLOWUP).replace(NEW, NEW + FOLLOWUP_GATE)
else:
if source.count(OLD) != 1 or source.count(" async def _handle_message(\n") != 1:
raise ValueError("Hermes adapter changed; review the mention gate before applying")
result = source.replace(" async def _handle_message(\n", HELPER + FOLLOWUP + " async def _handle_message(\n").replace(OLD, NEW + FOLLOWUP_GATE)
result = result.replace(RECORD_ANCHOR, RECORD + RECORD_ANCHOR)
result = result.replace(AUTO_THREAD, AUTO_THREAD + AUTO_THREAD_GUARD).replace(EVENT, ROUTE + EVENT)
compile(result, "adapter.py", "exec")
return result


def main():
target = Path(sys.argv[1]).resolve()
source = target.read_text()
Expand Down
54 changes: 51 additions & 3 deletions deploy/hermes/test_mention_policy.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import asyncio
from types import SimpleNamespace as NS
import unittest
from mention_policy_patch import HELPER, OLD, NEW, patched
from mention_policy_patch import HELPER, FOLLOWUP, OLD, NEW, AUTO_THREAD, EVENT, patched

namespace = {}
exec("class Policy:\n" + HELPER, namespace)
exec("class Policy:\n" + HELPER + FOLLOWUP, namespace)
Policy = namespace["Policy"]


Expand Down Expand Up @@ -39,13 +39,61 @@ async def deleted(_):
self.assertFalse(await policy._framework_is_reply_to_self(message))

def test_patch_is_idempotent_and_rejects_changed_upstream(self):
fixture = "class Adapter:\n async def _handle_message(\n self, message\n ):\n if (True\n" + OLD + " ):\n return False\n"
fixture = "class Adapter:\n async def _handle_message(\n self, message\n ):\n if (True\n" + OLD + " ):\n return False\n" + AUTO_THREAD + EVENT + " )\n # Track participation so follow-ups in this thread don't need @mention.\n"
result = patched(fixture)
self.assertIn(NEW, result)
self.assertEqual(patched(result), result)
with self.assertRaises(ValueError):
patched("class Adapter: pass\n")

def test_parent_channel_followup_keeps_original_thread_and_current_message_identity(self):
policy = Policy()
policy._source_session_key = lambda source: source.key
message = NS(guild=NS(id=1), channel=NS(id=7), author=NS(id=10, bot=False), webhook_id=None)
original = NS(key="thread-session", chat_id="99", chat_type="thread", chat_name="실험", thread_id="99", parent_chat_id="7", message_id="first")
policy._framework_record_requester(message, original)
policy._active_sessions = {"thread-session": object()}
current = NS(chat_id="7", chat_type="group", message_id="correction")
routed = policy._framework_followup_source(message, current)
self.assertEqual(routed.chat_id, "99")
self.assertEqual(routed.chat_type, "thread")
self.assertEqual(routed.message_id, "correction")
self.assertEqual(current.chat_id, "7")
policy._self_is_explicitly_mentioned = lambda message: True
self.assertFalse(policy._framework_is_active_request_followup(message))
self.assertIs(policy._framework_followup_source(message, current), current)

def test_reused_session_does_not_authorize_the_previous_requester(self):
policy = Policy()
policy._source_session_key = lambda source: source.key
first = NS(guild=NS(id=1), channel=NS(id=7), author=NS(id=10, bot=False), webhook_id=None)
second = NS(guild=NS(id=1), channel=NS(id=7), author=NS(id=11, bot=False), webhook_id=None)
policy._framework_record_requester(first, NS(key="session"))
policy._framework_record_requester(second, NS(key="session"))
policy._active_sessions = {"session": object()}
self.assertFalse(policy._framework_is_active_request_followup(first))
self.assertTrue(policy._framework_is_active_request_followup(second))

def test_only_active_same_requester_in_same_channel_can_correct_without_ping(self):
policy = Policy()
policy._source_session_key = lambda source: source.key
original = NS(guild=NS(id=1), channel=NS(id=7), author=NS(id=10, bot=False), webhook_id=None)
policy._framework_record_requester(original, NS(key="session"))
policy._active_sessions = {"session": object()}
correction = NS(guild=NS(id=1), channel=NS(id=7), author=NS(id=10, bot=False), webhook_id=None,
content="아, 자체 모델에 한정해야 해. InSwapper / GHOST 제외")
self.assertTrue(policy._framework_is_active_request_followup(correction))
for field, value in [("author", NS(id=11, bot=False)), ("channel", NS(id=8)), ("guild", NS(id=2))]:
previous = getattr(correction, field)
setattr(correction, field, value)
self.assertFalse(policy._framework_is_active_request_followup(correction))
setattr(correction, field, previous)
correction.author.bot = True
self.assertFalse(policy._framework_is_active_request_followup(correction))
correction.author.bot = False
policy._active_sessions = {}
self.assertFalse(policy._framework_is_active_request_followup(correction))


if __name__ == "__main__":
unittest.main()
5 changes: 4 additions & 1 deletion deploy/hermes/test_wiki_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def setup_home(self, root):
'mcp_servers': {'framework_wiki': {'url': 'https://framework-wiki.chaeyn.com/mcp',
'auth': 'oauth', 'oauth': {'redirect_port': 123}, 'headers': {'X-Test': 'keep'}}}}
(home / 'config.yaml').write_text(yaml.safe_dump(config))
(home / '.env').write_text('EXISTING_VALUE=keep\n')
(home / '.env').write_text('EXISTING_VALUE=keep\nHERMES_GATEWAY_BUSY_INPUT_MODE=queue\n')
wiki = root / 'wiki.env'; wiki.write_text('WIKI_SERVICE_KEY=' + 'k' * 40 + '\n')
bot = root / 'bot.env'; bot.write_text('WIKI_PROPOSALS_ENABLED=true\nWIKI_DISCORD_GUILD_ID=123456789012345678\nWIKI_DISCORD_CATEGORY_IDS=223456789012345678\n')
return home, wiki, bot
Expand All @@ -33,6 +33,9 @@ def test_config_private_backup_and_preserved_settings(self):
cfg = yaml.safe_load((home / 'config.yaml').read_text())
self.assertEqual(cfg['agent'], {'max_turns': 90, 'reasoning_effort': 'max', 'service_tier': 'priority'})
self.assertTrue(cfg['discord']['require_mention'])
self.assertEqual(cfg['display']['busy_input_mode'], 'steer')
self.assertIn('HERMES_GATEWAY_BUSY_INPUT_MODE=steer', (home / '.env').read_text())
self.assertNotIn('HERMES_GATEWAY_BUSY_INPUT_MODE=queue', (home / '.env').read_text())
mcp = cfg['mcp_servers']['framework_wiki']
self.assertNotIn('auth', mcp); self.assertNotIn('oauth', mcp)
self.assertEqual(mcp['headers']['Authorization'], 'Bearer ${FRAMEWORK_WIKI_SERVICE_KEY}')
Expand Down
6 changes: 5 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type Config = {
hermesSummaryKey: string | null;
openAIKey: string | null;
openAIModel: string;
wikiServiceUrl: string;
wikiServiceKey: string | null;
};

function required(env: NodeJS.ProcessEnv, name: string) {
Expand Down Expand Up @@ -99,6 +101,8 @@ export function loadConfig(env = process.env): Config {
hermesSummaryUrl: env.HERMES_SUMMARY_URL?.trim() || "http://127.0.0.1:8646/v1/responses",
hermesSummaryKey: env.HERMES_SUMMARY_KEY?.trim() || null,
openAIKey: env.OPENAI_API_KEY?.trim() || null,
openAIModel: summaryProvider === "hermes" ? "gpt-6-luna" : env.OPENAI_MODEL?.trim() || "gpt-5-nano"
openAIModel: summaryProvider === "hermes" ? "gpt-6-luna" : env.OPENAI_MODEL?.trim() || "gpt-5-nano",
wikiServiceUrl: env.WIKI_SERVICE_URL?.trim() || "http://127.0.0.1:3100",
wikiServiceKey: env.WIKI_SERVICE_KEY?.trim() || null
};
}
2 changes: 1 addition & 1 deletion src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ if (process.argv[1]?.endsWith("gateway.js") || process.argv[1]?.endsWith("gatewa
const knownCommand = interaction.type === 2 && ["위키-제안", "스레드-정리"].includes(interaction.data?.name);
if (knownCommand) console.log(`Discord Gateway 명령 수신 ${++receivedCommands}: ${interaction.data.name}`);
if (wiki && await wiki.handle(interaction)) { if (knownCommand) console.log("Discord Gateway 명령 처리: 위키-제안"); return; }
await handleThreadSummaryInteraction({ interaction, token: config.discordToken, openAIKey: config.openAIKey, provider: config.summaryProvider, hermesUrl: config.hermesSummaryUrl, hermesKey: config.hermesSummaryKey, model: config.openAIModel, botUserId });
await handleThreadSummaryInteraction({ interaction, token: config.discordToken, openAIKey: config.openAIKey, provider: config.summaryProvider, hermesUrl: config.hermesSummaryUrl, hermesKey: config.hermesSummaryKey, model: config.openAIModel, botUserId, wikiServiceUrl: config.wikiServiceUrl, wikiServiceKey: config.wikiServiceKey });
}, onMessage: (message: any) => mention?.handle(message),
onError: (error: Error) => console.error(`Discord Gateway 처리에 실패했어요: ${error.message}`), onFatal: (code: number) => console.error(`Discord Gateway가 종료됐어요. close code: ${code}.`) });
process.once("SIGINT", () => clearInterval(timer)); process.once("SIGTERM", () => clearInterval(timer));
Expand Down
Loading
Loading