cuelo 0.10.21 → 0.10.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +5 -5
  3. package/.next/build-manifest.json +2 -2
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/required-server-files.js +1 -1
  6. package/.next/required-server-files.json +1 -1
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/server/app/_not-found.html +1 -1
  18. package/.next/server/app/_not-found.rsc +2 -2
  19. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  21. package/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  23. package/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  24. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  25. package/.next/server/app/api/updates/route.js +1 -1
  26. package/.next/server/app/index.html +1 -1
  27. package/.next/server/app/index.rsc +4 -4
  28. package/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
  29. package/.next/server/app/index.segments/_full.segment.rsc +4 -4
  30. package/.next/server/app/index.segments/_head.segment.rsc +1 -1
  31. package/.next/server/app/index.segments/_index.segment.rsc +2 -2
  32. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  33. package/.next/server/app/office/page_client-reference-manifest.js +1 -1
  34. package/.next/server/app/office-preview/page_client-reference-manifest.js +1 -1
  35. package/.next/server/app/office-preview.html +1 -1
  36. package/.next/server/app/office-preview.rsc +2 -2
  37. package/.next/server/app/office-preview.segments/_full.segment.rsc +2 -2
  38. package/.next/server/app/office-preview.segments/_head.segment.rsc +1 -1
  39. package/.next/server/app/office-preview.segments/_index.segment.rsc +2 -2
  40. package/.next/server/app/office-preview.segments/_tree.segment.rsc +1 -1
  41. package/.next/server/app/office-preview.segments/office-preview/__PAGE__.segment.rsc +1 -1
  42. package/.next/server/app/office-preview.segments/office-preview.segment.rsc +1 -1
  43. package/.next/server/app/office.html +1 -1
  44. package/.next/server/app/office.rsc +4 -4
  45. package/.next/server/app/office.segments/_full.segment.rsc +4 -4
  46. package/.next/server/app/office.segments/_head.segment.rsc +1 -1
  47. package/.next/server/app/office.segments/_index.segment.rsc +2 -2
  48. package/.next/server/app/office.segments/_tree.segment.rsc +1 -1
  49. package/.next/server/app/office.segments/office/__PAGE__.segment.rsc +3 -3
  50. package/.next/server/app/office.segments/office.segment.rsc +1 -1
  51. package/.next/server/app/page_client-reference-manifest.js +1 -1
  52. package/.next/server/app/recover/page_client-reference-manifest.js +1 -1
  53. package/.next/server/app/recover.html +1 -1
  54. package/.next/server/app/recover.rsc +2 -2
  55. package/.next/server/app/recover.segments/_full.segment.rsc +2 -2
  56. package/.next/server/app/recover.segments/_head.segment.rsc +1 -1
  57. package/.next/server/app/recover.segments/_index.segment.rsc +2 -2
  58. package/.next/server/app/recover.segments/_tree.segment.rsc +1 -1
  59. package/.next/server/app/recover.segments/recover/__PAGE__.segment.rsc +1 -1
  60. package/.next/server/app/recover.segments/recover.segment.rsc +1 -1
  61. package/.next/server/app-paths-manifest.json +5 -5
  62. package/.next/server/chunks/1517.js +4 -4
  63. package/.next/server/chunks/5489.js +3 -3
  64. package/.next/server/chunks/7362.js +1 -1
  65. package/.next/server/middleware-build-manifest.js +1 -1
  66. package/.next/server/pages/404.html +1 -1
  67. package/.next/server/pages/500.html +1 -1
  68. package/.next/server/server-reference-manifest.json +1 -1
  69. package/.next/static/chunks/{6110-7a2311ab143b31e2.js → 6110-ac70431887855d47.js} +1 -1
  70. package/.next/static/chunks/{9575-032510d25828764f.js → 9575-0c26b1d8b9a98d3b.js} +3 -3
  71. package/.next/static/chunks/app/{layout-a7fca69a4fa9b2c2.js → layout-bc09a4f5bbf65cee.js} +1 -1
  72. package/.next/trace +108 -108
  73. package/.next/trace-build +1 -1
  74. package/Tools/CUELO_Setup/files/native-runtime-patch.js +1 -1
  75. package/Tools/OMP_Global_Config/agent/extensions/docmap-route.ts +124 -26
  76. package/Tools/OMP_Global_Config/agent/extensions/edit-lessons.ts +158 -0
  77. package/Tools/OMP_Global_Config/agent/extensions/jev-runtime.ts +1 -1
  78. package/Tools/OMP_Global_Config/agent/extensions/lib/docmap-search.ts +38 -2
  79. package/Tools/OMP_Global_Config/agent/extensions/lib/memory-excerpt.ts +5 -0
  80. package/Tools/OMP_Global_Config/agent/extensions/lib/native-judge.ts +82 -0
  81. package/Tools/OMP_Global_Config/agent/extensions/mcp-selection.ts +11 -78
  82. package/Tools/OMP_Global_Config/agent/rules/docs-handoff.md +1 -1
  83. package/Tools/OMP_Global_Config/patches/apply-core-patch.mjs +117 -198
  84. package/package.json +1 -1
  85. /package/.next/static/{IKi2VjUzAnD5AUcDn7QNi → MDZ1YH4IJ1RwwO-lRfPFz}/_buildManifest.js +0 -0
  86. /package/.next/static/{IKi2VjUzAnD5AUcDn7QNi → MDZ1YH4IJ1RwwO-lRfPFz}/_ssgManifest.js +0 -0
package/.next/trace-build CHANGED
@@ -1 +1 @@
1
- [{"name":"run-webpack","duration":93821325,"timestamp":575170,"id":14,"parentId":1,"tags":{},"startTime":1792195703745,"traceId":"b1cf54e2dd52d389"},{"name":"run-typescript","duration":16362368,"timestamp":94770493,"id":11087,"parentId":1,"tags":{},"startTime":1792195797939,"traceId":"b1cf54e2dd52d389"},{"name":"static-check","duration":3743210,"timestamp":111221009,"id":11090,"parentId":1,"tags":{},"startTime":1792195814389,"traceId":"b1cf54e2dd52d389"},{"name":"static-generation","duration":10044206,"timestamp":115058105,"id":11262,"parentId":1,"tags":{},"startTime":1792195818226,"traceId":"b1cf54e2dd52d389"},{"name":"collect-build-traces","duration":16970438,"timestamp":115031605,"id":11259,"parentId":1,"tags":{},"startTime":1792195818200,"traceId":"b1cf54e2dd52d389"},{"name":"telemetry-flush","duration":59,"timestamp":132005996,"id":11271,"parentId":1,"tags":{},"startTime":1792195835174,"traceId":"b1cf54e2dd52d389"},{"name":"next-build","duration":131534973,"timestamp":471099,"id":1,"tags":{"buildMode":"default","version":"16.2.12","bundler":"webpack","has-custom-webpack-config":"true","use-build-worker":"false"},"startTime":1792195703641,"traceId":"b1cf54e2dd52d389"}]
1
+ [{"name":"run-webpack","duration":118341191,"timestamp":692166,"id":14,"parentId":1,"tags":{},"startTime":1792207112919,"traceId":"3028e5e77e38b16d"},{"name":"run-typescript","duration":21384990,"timestamp":119434669,"id":11087,"parentId":1,"tags":{},"startTime":1792207231663,"traceId":"3028e5e77e38b16d"},{"name":"static-check","duration":4272323,"timestamp":140918414,"id":11090,"parentId":1,"tags":{},"startTime":1792207253146,"traceId":"3028e5e77e38b16d"},{"name":"static-generation","duration":13354939,"timestamp":145308193,"id":11262,"parentId":1,"tags":{},"startTime":1792207257536,"traceId":"3028e5e77e38b16d"},{"name":"collect-build-traces","duration":22691837,"timestamp":145285325,"id":11259,"parentId":1,"tags":{},"startTime":1792207257513,"traceId":"3028e5e77e38b16d"},{"name":"telemetry-flush","duration":87,"timestamp":167982010,"id":11271,"parentId":1,"tags":{},"startTime":1792207280210,"traceId":"3028e5e77e38b16d"},{"name":"next-build","duration":167399180,"timestamp":582941,"id":1,"tags":{"buildMode":"default","version":"16.2.12","bundler":"webpack","has-custom-webpack-config":"true","use-build-worker":"false"},"startTime":1792207112810,"traceId":"3028e5e77e38b16d"}]
@@ -9,7 +9,7 @@ const MEMORY_RE = /(const phase1Model = await resolveMemoryModel\(\{[^}]*?fallba
9
9
  const MEMORY_DONE = /const phase1Model = await resolveMemoryModel\(\{[^}]*?fallbackRole: "smol"/;
10
10
  const EXPECTED = {
11
11
  name: 'cuelo',
12
- version: '0.10.21',
12
+ version: '0.10.23',
13
13
  distribution: 'cuelo',
14
14
  coreVersion: '18.11.3',
15
15
  };
@@ -1,34 +1,132 @@
1
- import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
- import { routeDocmap } from "./lib/docmap-search";
1
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
2
+ import { type DocmapCandidate, type DocmapRoute, routeDocmap } from "./lib/docmap-search";
3
+ import { type CoreJudgmentModules, defaultLoadCore, JUDGE_STOPPED, type NativeJudge, resolveNativeJudge, stoppedBy } from "./lib/native-judge";
3
4
 
4
5
  // 2026-10-16 재조사(doc/history/2026/10/16-rag-reinvestigation): 격리 표본 12건에서 문서 지도를 연 경우가 0건이었다.
5
6
  // 도구나 규칙만 두면 같은 문제가 되풀이되므로, 사용자 요청마다 하네스가 지도·RECORD 헤더 검색을 직접 돌려
6
7
  // 후보 기록과 맞는 구간을 그 prompt의 숨은 메시지로 넘긴다. 하한 미만·지도 없음·오류면 아무것도 붙이지 않는다.
7
8
  // 안내는 external-advice-check와 같은 이유로 aside가 아니라 그 prompt의 `before_agent_start` 메시지로만 붙인다.
8
- export default function docmapRoute(pi: ExtensionAPI): void {
9
- let pendingText: string | undefined;
10
- pi.on("session_start", () => { pendingText = undefined; });
11
- pi.on("session_shutdown", () => { pendingText = undefined; });
12
- pi.on("input", (event) => {
13
- pendingText = undefined;
14
- if (event.source !== "interactive" && event.source !== "rpc") return;
15
- const text = event.text.trim();
16
- if (text) pendingText = text;
9
+ //
10
+ // 2026-10-17 사용자 결정(doc/history/2026/10/17-docmap-judge): 낱말로 고른 후보의 절반은 주제만 겹치는 지시였다
11
+ // (실제 프롬프트 표본 60건 중 맞는 첨부 29·엉뚱한 첨부 31). 낱말·길이·의도 표지로는 둘이 갈리지 않아, 붙이기 전에
12
+ // native JEV에 후보마다 「요청이 가리키는 그 과거 작업의 기록인가」를 한 요청으로 묻고 P>=0.6인 후보만 붙인다(사용자 선택).
13
+ // 같은 표본에서 맞는 첨부 19/29를 지키고 엉뚱한 첨부는 5/31만 남았다(0.5면 23/29·10/31). 판정 실패·제한 시간·native 판정기 없음은
14
+ // 낱말 후보를 그대로 붙이고(재현율 보존), 실행 중 steering·queued 재요청은 판정하지 않는다(스티어링 지연 방지).
15
+
16
+ export interface DocmapRouteDeps {
17
+ /** 결정적 판정기 seam(테스트). 없으면 {@link loadCore}의 native JEV를 쓴다. */
18
+ resolveJudge?: (ctx: ExtensionContext) => Promise<NativeJudge | undefined>;
19
+ loadCore?: () => Promise<CoreJudgmentModules>;
20
+ /** 판정 대기 상한. 테스트만 짧게 준다. 기본은 DOCMAP_JUDGE_TIMEOUT_MS다. */
21
+ judgeTimeoutMs?: number;
22
+ /** 날짜 의도 판정의 기준 시각(테스트). */
23
+ now?: () => Date;
24
+ }
25
+
26
+ /** 판정 대기 상한. 넘으면 낱말 후보를 그대로 붙인다. */
27
+ export const DOCMAP_JUDGE_TIMEOUT_MS = 1_500;
28
+ /** 후보를 남기는 P(관련) 하한. */
29
+ export const DOCMAP_JUDGE_KEEP = 0.6;
30
+ const PROMPT_HEAD_CHARS = 1000;
31
+ const PROMPT_TAIL_CHARS = 1000;
32
+ const CUSTOM_TYPE = "docmap-route";
33
+
34
+ function judgePrompt(prompt: string): string {
35
+ return prompt.length <= PROMPT_HEAD_CHARS + PROMPT_TAIL_CHARS ? prompt : `${prompt.slice(0, PROMPT_HEAD_CHARS)}\n…\n${prompt.slice(-PROMPT_TAIL_CHARS)}`;
36
+ }
37
+
38
+ /** 후보마다 bool 질문 하나. 같은 state를 보므로 한 요청에 묶는다. */
39
+ export function buildDocmapJudgeRequest(prompt: string, route: DocmapRoute): { state: unknown; questions: Record<string, unknown> } {
40
+ const questions: Record<string, unknown> = {};
41
+ route.judgeCandidates.forEach((_, i) => {
42
+ questions[`c${i + 1}`] = {
43
+ type: "bool",
44
+ instructions: `Is candidates[${i}] a record of the earlier work the prompt refers to (the same event, decision, procedure or state), not merely sharing words with it? The prompt is one message a user sent to a coding agent mid-conversation; a new instruction, a reaction or small talk that only shares vocabulary is "no".`,
45
+ criteria: { true: "the prompt asks about or continues the work this record covers", false: "new instruction, reaction, small talk, or only topical word overlap" },
46
+ };
17
47
  });
18
- pi.on("before_agent_start", (event, ctx) => {
19
- // 대화형·RPC는 사용자 입력과 같은 본문의 prompt에만 붙인다. CUELO의 재개·업데이트 통지와 goal 계속 같은
20
- // 내부 prompt는 `input`을 내지 않아(lib/rpc-manager.ts isGenuineUserRequest) 여기서 빠진다.
21
- // `omp -p`는 input 이벤트가 없어 최상위 세션의 CLI prompt 자체를 사용자 요청으로 본다. CUELO도 확장을
22
- // mode 없이 초기화해 ctx.mode가 "print"이므로, UI 문맥이 없다는 것까지 봐야 CLI로 가를 수 있다.
23
- const fromInput = pendingText !== undefined && event.prompt.includes(pendingText);
24
- const cliPrompt = (ctx.mode === "print" || ctx.mode === "json") && !ctx.hasUI && ctx.agent?.kind === "main";
25
- if (!fromInput && !cliPrompt) return;
26
- try {
27
- const route = routeDocmap(ctx.cwd, fromInput ? pendingText! : event.prompt);
48
+ return { state: { prompt: judgePrompt(prompt), candidates: route.judgeCandidates }, questions };
49
+ }
50
+
51
+ /** 판정 답에서 남길 후보. 답이 없는 후보는 버린다. */
52
+ export function keptByJudge(route: DocmapRoute, answers: Record<string, { bool?: number } | undefined>): DocmapCandidate[] {
53
+ return route.candidates.filter((_, i) => (answers[`c${i + 1}`]?.bool ?? 0) >= DOCMAP_JUDGE_KEEP);
54
+ }
55
+
56
+ export function createDocmapRoute(deps: DocmapRouteDeps = {}) {
57
+ const loadCore = deps.loadCore ?? defaultLoadCore;
58
+ const resolveJudge = deps.resolveJudge ?? ((ctx: ExtensionContext) => resolveNativeJudge(ctx, loadCore, "docmap_route"));
59
+ const judgeTimeoutMs = deps.judgeTimeoutMs ?? DOCMAP_JUDGE_TIMEOUT_MS;
60
+
61
+ return function docmapRoute(pi: ExtensionAPI): void {
62
+ let pendingText: string | undefined;
63
+ let judgePromise: Promise<NativeJudge | undefined> | undefined;
64
+ /** agent_start~agent_end 사이. 실행 중 steering·queued 재요청은 판정 없이 붙인다(mcp-selection과 같은 추적). */
65
+ let inRun = false;
66
+ const judgeFor = (ctx: ExtensionContext) => (judgePromise ??= resolveJudge(ctx).catch(() => undefined));
67
+
68
+ pi.on("session_start", () => {
69
+ pendingText = undefined;
70
+ judgePromise = undefined;
71
+ inRun = false;
72
+ });
73
+ pi.on("session_shutdown", () => { pendingText = undefined; });
74
+ pi.on("agent_start", () => { inRun = true; });
75
+ pi.on("agent_end", () => { inRun = false; });
76
+ pi.on("input", (event) => {
77
+ pendingText = undefined;
78
+ if (event.source !== "interactive" && event.source !== "rpc") return;
79
+ const text = event.text.trim();
80
+ if (text) pendingText = text;
81
+ });
82
+
83
+ /** 판정으로 거른 안내문. 판정 실패·제한 시간이면 낱말 후보 그대로. */
84
+ const filtered = async (route: DocmapRoute, prompt: string, ctx: ExtensionContext): Promise<string> => {
85
+ const controller = new AbortController();
86
+ const stopped = stoppedBy(controller.signal);
87
+ let timedOut = false;
88
+ const timer = setTimeout(() => {
89
+ timedOut = true;
90
+ controller.abort();
91
+ }, judgeTimeoutMs);
92
+ try {
93
+ const judge = await Promise.race([judgeFor(ctx), stopped]);
94
+ if (judge === JUDGE_STOPPED) throw new Error("판정 취소");
95
+ if (!judge) throw new Error("native JEV 판정기 없음");
96
+ const result = await Promise.race([judge.judge(buildDocmapJudgeRequest(prompt, route), { signal: controller.signal }), stopped]);
97
+ if (result === JUDGE_STOPPED) throw new Error("판정 취소");
98
+ return route.render(keptByJudge(route, result.answers));
99
+ } catch (error) {
100
+ pi.logger.debug("docmap-route: JEV 후보 판정 없이 낱말 후보를 붙임", {
101
+ error: timedOut ? "판정이 제한 시간 안에 끝나지 않음" : error instanceof Error ? error.message : String(error),
102
+ });
103
+ return route.text;
104
+ } finally {
105
+ clearTimeout(timer);
106
+ }
107
+ };
108
+
109
+ pi.on("before_agent_start", async (event, ctx) => {
110
+ // 대화형·RPC는 사용자 입력과 같은 본문의 prompt에만 붙인다. CUELO의 재개·업데이트 통지와 goal 계속 같은
111
+ // 내부 prompt는 `input`을 내지 않아(lib/rpc-manager.ts isGenuineUserRequest) 여기서 빠진다.
112
+ // `omp -p`는 input 이벤트가 없어 최상위 세션의 CLI prompt 자체를 사용자 요청으로 본다. CUELO도 확장을
113
+ // mode 없이 초기화해 ctx.mode가 "print"이므로, UI 문맥이 없다는 것까지 봐야 CLI로 가를 수 있다.
114
+ const fromInput = pendingText !== undefined && event.prompt.includes(pendingText);
115
+ const cliPrompt = (ctx.mode === "print" || ctx.mode === "json") && !ctx.hasUI && ctx.agent?.kind === "main";
116
+ if (!fromInput && !cliPrompt) return;
117
+ const prompt = fromInput ? pendingText! : event.prompt;
118
+ let route: DocmapRoute | undefined;
119
+ try {
120
+ route = routeDocmap(ctx.cwd, prompt, deps.now?.());
121
+ } catch {
122
+ return;
123
+ }
28
124
  if (!route) return;
29
- return { message: { customType: "docmap-route", content: route.text, display: false, attribution: "agent" } };
30
- } catch {
31
- return;
32
- }
33
- });
125
+ const content = inRun ? route.text : await filtered(route, prompt, ctx);
126
+ if (!content) return;
127
+ return { message: { customType: CUSTOM_TYPE, content, display: false, attribution: "agent" } };
128
+ });
129
+ };
34
130
  }
131
+
132
+ export default createDocmapRoute();
@@ -0,0 +1,158 @@
1
+ import { posix, win32 } from "node:path";
2
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
3
+ import { MEMORY_SECRET_PATTERN } from "./lib/memory-excerpt";
4
+
5
+ // 2026-10-17: 저장 교훈은 첫 턴 recall(`<memories>`)과 요청별 docmap-route로만 닿는다. 파일 경로에 묶인 교훈
6
+ // (예: `setup.ps1 -ValidateOnly`는 배포 전에 먼저 돌린다)은 그 파일을 고치는 순간에 필요한데, 그때는 어디서도 다시 오르지 않는다
7
+ // (doc/history/2026/10/05-memory-application, 10/04-memory-followup). 그래서 `edit`·`write`가 호출될 때 대상 경로로 기억을 찾아
8
+ // 그 도구 호출 직후 다음 모델 요청에 단서로 붙인다(tool_call `additionalContext`, 비차단).
9
+ // 안내는 단서일 뿐이다. 도구를 막지 않고, 기억 검색이 없거나 실패하거나 제한 시간을 넘으면 아무것도 붙이지 않는다.
10
+ //
11
+ // 질의·필터 근거(doc/history/2026/10/17-edit-lessons, 은행 사본 실측): 경로를 담은 교훈 123건·파일 81개에서 질의는 상대 경로 그대로가
12
+ // basename만이나 둘을 이은 것보다 낫거나 같았다(상위 8개 안 91/80/92쌍). 검색 결과의 content는 Mnemopi 회상 미리보기라 500자에서 잘려 오므로
13
+ // 파일명이 뒤쪽에 있는 교훈은 basename 필터에서 빠진다(상위 8의 91쌍 중 35쌍). 그 필터를 풀면 그 경로를 가리키지 않는 교훈이 대부분이 되어
14
+ // (보인 243건 중 169건) 단서가 묻히므로 필터는 유지한다. 본문은 공백만 정리해 미리보기 그대로 싣는다(`<memories>`도 원문을 싣는다).
15
+
16
+ export interface EditLessonsDeps {
17
+ /** 기억 검색 대기 상한. 테스트만 짧게 준다. 기본은 EDIT_LESSONS_SEARCH_TIMEOUT_MS다. */
18
+ searchTimeoutMs?: number;
19
+ }
20
+
21
+ /** 한 호출의 기억 검색 대기 상한. 넘으면 아무것도 붙이지 않고 도구는 그대로 진행한다. */
22
+ export const EDIT_LESSONS_SEARCH_TIMEOUT_MS = 1_500;
23
+ /** 경로 하나에 붙이는 기억 상한. */
24
+ export const EDIT_LESSONS_PER_PATH = 3;
25
+ const SEARCH_LIMIT = 8;
26
+ const LESSON_SOURCES = new Set(["coding-agent-learn", "coding-agent-retain"]);
27
+
28
+ interface MemoryItem {
29
+ id?: string;
30
+ content: string;
31
+ source?: string;
32
+ timestamp?: string;
33
+ score?: number;
34
+ }
35
+
36
+ /** 입력의 `paths`(문자열 배열)와 `path`를 입력 순서대로 모은다. */
37
+ function inputPaths(input: unknown): string[] {
38
+ if (!input || typeof input !== "object") return [];
39
+ const record = input as Record<string, unknown>;
40
+ const raw: unknown[] = [...(Array.isArray(record.paths) ? record.paths : []), record.path];
41
+ const paths: string[] = [];
42
+ for (const value of raw) {
43
+ if (typeof value !== "string") continue;
44
+ const trimmed = value.trim();
45
+ if (trimmed && !paths.includes(trimmed)) paths.push(trimmed);
46
+ }
47
+ return paths;
48
+ }
49
+
50
+ /**
51
+ * 입력 경로를 cwd 기준으로 정규화한다(역슬래시→`/`, `.`·`..` 정리). cwd 아래면 cwd 기준 `/` 경로, 밖이면 정규화한 절대 경로(`/` 구분).
52
+ * cwd가 없으면 준 문자열을 posix로 정규화만 한다. 같은 파일을 가리키는 다른 표기가 같은 검색·중복 키가 되게 한다.
53
+ */
54
+ export function relativeTarget(cwd: string | undefined, path: string): string {
55
+ // 호스트 플랫폼이 아니라 cwd(없으면 입력)의 모양으로 경로 규칙을 고른다. Windows 러너에서 POSIX 입력에 드라이브가 붙지 않게 한다.
56
+ const p = /^[A-Za-z]:[\\/]/.test(cwd ?? path) ? win32 : posix;
57
+ const slashed = path.replace(/\\/g, "/");
58
+ if (!cwd) return p.normalize(slashed).replace(/\\/g, "/");
59
+ const abs = p.resolve(cwd, slashed);
60
+ const rel = p.relative(cwd, abs);
61
+ if (!rel || rel === ".." || rel.startsWith(`..${p.sep}`) || p.isAbsolute(rel)) return abs.replace(/\\/g, "/");
62
+ return rel.split(p.sep).join("/");
63
+ }
64
+
65
+ /** 검색 결과에서 이 경로에 묶인 교훈 후보만 남긴다(출처·basename 일치·id 필요). 같은 id는 처음 것만 둔다. */
66
+ function lessonItems(items: readonly MemoryItem[], base: string): MemoryItem[] {
67
+ const seen = new Set<string>();
68
+ return items.filter((item) => {
69
+ if (typeof item?.id !== "string" || item.id.length === 0 || seen.has(item.id)) return false;
70
+ if (typeof item.content !== "string" || typeof item.source !== "string" || !LESSON_SOURCES.has(item.source) || !item.content.includes(base)) return false;
71
+ seen.add(item.id);
72
+ return true;
73
+ });
74
+ }
75
+
76
+ export function createEditLessons(deps: EditLessonsDeps = {}) {
77
+ const searchTimeoutMs = deps.searchTimeoutMs ?? EDIT_LESSONS_SEARCH_TIMEOUT_MS;
78
+
79
+ return function editLessons(pi: ExtensionAPI): void {
80
+ /** 이 세션에서 이미 찾아 본 경로와 이미 붙인(또는 비밀 패턴으로 가린) 기억 id. */
81
+ const lookedUp = new Set<string>();
82
+ const attached = new Set<string>();
83
+ let sessionGeneration = 0;
84
+ const reset = (): void => {
85
+ sessionGeneration++;
86
+ lookedUp.clear();
87
+ attached.clear();
88
+ };
89
+ pi.on("session_start", reset);
90
+ pi.on("session_shutdown", reset);
91
+
92
+ /** 경로 하나의 후보 기억. 검색 없음·오류·제한 시간·빈 결과는 undefined. */
93
+ const search = async (ctx: ExtensionContext, path: string): Promise<MemoryItem[] | undefined> => {
94
+ const memory = ctx.memory;
95
+ if (!memory) return undefined;
96
+ const controller = new AbortController();
97
+ const { promise: timeout, resolve: expire } = Promise.withResolvers<undefined>();
98
+ const timer = setTimeout(() => {
99
+ controller.abort();
100
+ expire(undefined);
101
+ }, searchTimeoutMs);
102
+ try {
103
+ const result = await Promise.race([memory.search(path, { limit: SEARCH_LIMIT, signal: controller.signal }), timeout]);
104
+ return result?.items?.length ? (result.items as MemoryItem[]) : undefined;
105
+ } catch (error) {
106
+ pi.logger.debug("edit-lessons: 기억 검색 없이 진행", { path, error: error instanceof Error ? error.message : String(error) });
107
+ return undefined;
108
+ } finally {
109
+ clearTimeout(timer);
110
+ }
111
+ };
112
+
113
+ pi.on("tool_call", async (event, ctx) => {
114
+ if (event.toolName !== "edit" && event.toolName !== "write") return;
115
+ try {
116
+ // 같은 경로·같은 id는 병렬 호출에서도 한 번만 다루도록 await 전에 표시한다.
117
+ const targets: string[] = [];
118
+ for (const path of inputPaths(event.input)) {
119
+ const target = relativeTarget(ctx.cwd, path);
120
+ if (!target || lookedUp.has(target)) continue;
121
+ lookedUp.add(target);
122
+ targets.push(target);
123
+ }
124
+ if (targets.length === 0) return;
125
+ const generation = sessionGeneration;
126
+ const found = await Promise.all(targets.map((target) => search(ctx, target)));
127
+ // 기다리는 사이 세션이 바뀌었으면(session_start·session_shutdown) 이 결과는 옛 세션 것이라 집합을 건드리지 않고 버린다.
128
+ if (generation !== sessionGeneration) return;
129
+ const sections: string[] = [];
130
+ for (const [index, target] of targets.entries()) {
131
+ const items = found[index];
132
+ if (!items) continue;
133
+ const fresh = lessonItems(items, target.slice(target.lastIndexOf("/") + 1)).filter((item) => !attached.has(item.id!));
134
+ const withheld = fresh.filter((item) => MEMORY_SECRET_PATTERN.test(item.content));
135
+ const shown = fresh.filter((item) => !MEMORY_SECRET_PATTERN.test(item.content))
136
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
137
+ .slice(0, EDIT_LESSONS_PER_PATH);
138
+ if (shown.length + withheld.length === 0) continue;
139
+ for (const item of [...shown, ...withheld]) attached.add(item.id!);
140
+ const lines = [`[edit-lessons] ${target}에 묶인 기억 ${shown.length + withheld.length}건. 단서일 뿐이며 현재 지시·정본·승인이 우선한다. 원문은 memory://<id>로 읽는다.`];
141
+ for (const item of shown) {
142
+ const date = item.timestamp?.match(/^\d{4}-\d{2}-\d{2}/)?.[0];
143
+ lines.push(`- ${item.content.replace(/\s+/g, " ").trim()} (id: ${item.id}${date ? `, ${date}` : ""})`);
144
+ }
145
+ if (withheld.length > 0) lines.push(`- 비밀 패턴이 있어 보이지 않음: ${withheld.map((item) => item.id).join(", ")}`);
146
+ sections.push(lines.join("\n"));
147
+ }
148
+ if (sections.length === 0) return;
149
+ return { additionalContext: sections.join("\n\n") };
150
+ } catch (error) {
151
+ pi.logger.debug("edit-lessons: 안내 없이 진행", { error: error instanceof Error ? error.message : String(error) });
152
+ return;
153
+ }
154
+ });
155
+ };
156
+ }
157
+
158
+ export default createEditLessons();
@@ -10,6 +10,7 @@ import {
10
10
  type JobIncarnation,
11
11
  type JobObservationClock,
12
12
  } from "./lib/job-observation";
13
+ import { MEMORY_SECRET_PATTERN } from "./lib/memory-excerpt";
13
14
  import { clearPreparedTaskSession } from "./lib/prepared-task";
14
15
  import { createRoutingLedger, DEFAULT_LEDGER_PATH, scopedAttempts, type AttemptIdentity, type DispatchOwnership, type DispatchRecord, type OutcomeRecord, type RevisionRelation, type VerdictRecord } from "./lib/routing-ledger";
15
16
  import {
@@ -836,7 +837,6 @@ function renderTodoProgressAdvice(assessment: TodoProgressAssessment): string {
836
837
  // emitBeforeAgentStart → 같은 commit으로 메시지 전달). 원문 system prompt·대화·비밀 항목은 보내지 않는다.
837
838
  // ---------------------------------------------------------------------------
838
839
 
839
- const MEMORY_SECRET_PATTERN = /(?:^|[\s"'`:])(?:password|passwd|api[_ -]?key|bearer|secret|token|credential|client[_ -]?secret)\b|(?:비밀번호|자격증명|인증키|토큰)|(?:sk-[A-Za-z0-9_-]{12,})/i;
840
840
  const MEMORY_ITEM_LIMIT = 8;
841
841
  const MEMORY_EXCERPT_CHARS = 500;
842
842
  const REQUEST_EXCERPT_CHARS = 1200;
@@ -100,11 +100,24 @@ export interface DocmapSection {
100
100
  readonly excerpt: string;
101
101
  }
102
102
 
103
+ /** 후보 하나를 판정기에 보여 주는 모양. 경로·지도 줄·SCOPE·DATE·가장 맞는 절의 발췌만 싣는다. */
104
+ export interface DocmapJudgeCandidate {
105
+ readonly path: string;
106
+ readonly map?: string;
107
+ readonly scope?: string;
108
+ readonly date?: string;
109
+ readonly excerpt?: string;
110
+ }
111
+
103
112
  export interface DocmapRoute {
104
113
  readonly topScore: number;
105
114
  readonly signals: DocmapSignals;
106
115
  readonly candidates: readonly DocmapCandidate[];
107
116
  readonly text: string;
117
+ /** 판정기에 넘길 후보 요약. `candidates`와 같은 순서다. */
118
+ readonly judgeCandidates: readonly DocmapJudgeCandidate[];
119
+ /** 후보 일부만 남겼을 때의 안내문. 빈 배열이면 빈 문자열. */
120
+ render(kept: readonly DocmapCandidate[]): string;
108
121
  }
109
122
 
110
123
  /** 소문자 ASCII 낱말(2자 이상)과 한글 연속 구간의 2글자 조각. */
@@ -529,12 +542,17 @@ function clip(text: string, max: number): string {
529
542
  return one.length > max ? `${one.slice(0, max - 1)}…` : one;
530
543
  }
531
544
 
545
+ /** 지도 줄에서 링크 대상·머리 기호·표 구분을 빼고 한 줄로 줄인다. */
546
+ function mapLineText(line: string): string {
547
+ return clip(line.replace(/\]\([^)]*\)/g, "]").replace(/^[-*]\s*/, "").replace(/\s*\|\s*/g, " "), 200);
548
+ }
549
+
532
550
  export function renderDocmapRoute(corpus: DocmapCorpus, candidates: readonly DocmapCandidate[], sections: ReadonlyMap<string, DocmapSection>, maxChars = DOCMAP_MAX_CHARS): string {
533
551
  const blocks = candidates.map((c, i) => {
534
552
  const u = c.unit;
535
553
  const lines = [`${i + 1}. ${rel(corpus.root, u.file)}${u.fields.STATUS ? ` (STATUS: ${clip(u.fields.STATUS, 60)})` : ""}`];
536
554
  if (c.supersededFrom) lines.push(` 대체 전: ${rel(corpus.root, c.supersededFrom)}`);
537
- if (u.mapLines[0]) lines.push(` 지도: ${clip(u.mapLines[0].replace(/\]\([^)]*\)/g, "]").replace(/^[-*]\s*/, "").replace(/\s*\|\s*/g, " "), 200)}`);
555
+ if (u.mapLines[0]) lines.push(` 지도: ${mapLineText(u.mapLines[0])}`);
538
556
  else if (u.title) lines.push(` 제목: ${clip(u.title, 160)}`);
539
557
  if (u.fields.SCOPE) lines.push(` SCOPE: ${clip(u.fields.SCOPE, 160)}`);
540
558
  const sec = sections.get(u.file);
@@ -572,5 +590,23 @@ export function routeDocmap(cwd: string, prompt: string, now = new Date()): Docm
572
590
  if (!docmapPasses(signals)) return undefined;
573
591
  const candidates = pickDocmapCandidates(corpus, ranked, DOCMAP_MAX_CANDIDATES, DOCMAP_CANDIDATE_RATIO, docmapDateIntent(prompt, now));
574
592
  const sections = bestDocmapSections(candidates.map((c) => c.unit.file), prompt);
575
- return { topScore: top.score, signals, candidates, text: renderDocmapRoute(corpus, candidates, sections) };
593
+ const judgeCandidates = candidates.map((c) => {
594
+ const u = c.unit;
595
+ const sec = sections.get(u.file);
596
+ return {
597
+ path: rel(corpus.root, u.file),
598
+ ...(u.mapLines[0] ? { map: mapLineText(u.mapLines[0]) } : u.title ? { map: clip(u.title, 160) } : {}),
599
+ ...(u.fields.SCOPE ? { scope: clip(u.fields.SCOPE, 160) } : {}),
600
+ ...(u.fields.DATE ? { date: u.fields.DATE } : {}),
601
+ ...(sec ? { excerpt: `${clip(sec.heading, 60)}${sec.excerpt ? ` — ${sec.excerpt}` : ""}` } : {}),
602
+ };
603
+ });
604
+ return {
605
+ topScore: top.score,
606
+ signals,
607
+ candidates,
608
+ text: renderDocmapRoute(corpus, candidates, sections),
609
+ judgeCandidates,
610
+ render: (kept) => (kept.length === 0 ? "" : renderDocmapRoute(corpus, kept, sections)),
611
+ };
576
612
  }
@@ -0,0 +1,5 @@
1
+ // 저장 기억을 판정기나 모델 문맥에 다시 실을 때 비밀을 가리는 공통 패턴. jev-runtime(회상 기억 적용 점검)과
2
+ // edit-lessons(편집 경로 교훈 단서)가 같은 규칙을 쓰도록 한 곳에 둔다.
3
+
4
+ /** 비밀로 보이는 낱말·접두. 걸리면 내용은 싣지 않고 id만 남긴다. */
5
+ export const MEMORY_SECRET_PATTERN = /(?:^|[\s"'`:])(?:password|passwd|api[_ -]?key|bearer|secret|token|credential|client[_ -]?secret)\b|(?:비밀번호|자격증명|인증키|토큰)|(?:sk-[A-Za-z0-9_-]{12,})/i;
@@ -0,0 +1,82 @@
1
+ import type { ExtensionContext } from "@oh-my-pi/pi-coding-agent";
2
+
3
+ /**
4
+ * core의 native JEV를 확장에서 쓰는 공통 틀. mcp-selection과 docmap-route가 같이 쓴다.
5
+ * judge 역할의 첫 후보가 native가 아니면 판정하지 않고, 세션 모델 fallback을 붙이지 않으며(sessionModel 미전달),
6
+ * 체인이 native가 아닌 후보를 내면 그 후보에서는 실행하지 않는다.
7
+ */
8
+
9
+ export interface JudgeAnswers {
10
+ answers: Record<string, { type: string; choice?: string; bool?: number } | undefined>;
11
+ }
12
+
13
+ /** 판정기 seam. 기본 구현은 native 후보에서만 판정을 실행한다. */
14
+ export interface NativeJudge {
15
+ judge(request: { state: unknown; questions: Record<string, unknown> }, options?: { signal?: AbortSignal }): Promise<JudgeAnswers>;
16
+ }
17
+
18
+ export type JudgeKind = "native" | "local" | "online";
19
+
20
+ interface ChainJudgeLike {
21
+ withCandidate<T>(run: (judge: NativeJudge, kind: JudgeKind) => Promise<T>, options?: { signal?: AbortSignal }): Promise<T>;
22
+ }
23
+
24
+ /** 기존 core 판정 모듈. 런타임 값은 host가 해석하고 타입은 여기 필요한 만큼만 둔다. */
25
+ export interface CoreJudgmentModules {
26
+ judgment: {
27
+ resolveJudge(deps: {
28
+ settings: unknown;
29
+ registry: unknown;
30
+ sessionId?: string;
31
+ purpose: string;
32
+ onUsage?: unknown;
33
+ }): ChainJudgeLike;
34
+ hasNativeJudge(settings: unknown, registry: unknown): boolean;
35
+ journalJudgmentUsage?(manager: unknown): unknown;
36
+ };
37
+ settings: { findScopedSettings(cwd?: string): unknown };
38
+ }
39
+
40
+ export async function defaultLoadCore(): Promise<CoreJudgmentModules> {
41
+ // 지연 import: 테스트 미러에는 node_modules가 없고, 런타임은 host 번들로 해석한다(jev-runtime과 같다).
42
+ const [judgment, settings] = await Promise.all([
43
+ import("@oh-my-pi/pi-coding-agent/judgment"),
44
+ import("@oh-my-pi/pi-coding-agent/config/settings"),
45
+ ]);
46
+ return { judgment, settings } as unknown as CoreJudgmentModules;
47
+ }
48
+
49
+ export async function resolveNativeJudge(ctx: ExtensionContext, loadCore: () => Promise<CoreJudgmentModules>, purpose: string): Promise<NativeJudge> {
50
+ const { judgment, settings: settingsModule } = await loadCore();
51
+ const settings = settingsModule.findScopedSettings(ctx.cwd);
52
+ if (!settings) throw new Error("세션 설정을 찾지 못함");
53
+ if (!judgment.hasNativeJudge(settings, ctx.modelRegistry)) throw new Error("judge 역할 첫 후보가 native JEV가 아님");
54
+ const chain = judgment.resolveJudge({
55
+ settings,
56
+ registry: ctx.modelRegistry,
57
+ sessionId: ctx.sessionManager?.getSessionId?.(),
58
+ purpose,
59
+ onUsage: judgment.journalJudgmentUsage?.(ctx.sessionManager),
60
+ });
61
+ return {
62
+ judge: (request, options) =>
63
+ chain.withCandidate(
64
+ (candidate, kind) =>
65
+ kind === "native" ? candidate.judge(request, options) : Promise.reject(new Error(`native가 아닌 judge 후보(${kind})`)),
66
+ options,
67
+ ),
68
+ };
69
+ }
70
+
71
+ /** 취소·제한 시간이 판정보다 먼저 끝났음을 나타내는 race 표지. */
72
+ export const JUDGE_STOPPED: unique symbol = Symbol("judge-stopped");
73
+
74
+ /**
75
+ * `signal`이 끊기면 바로 돌아오는 promise. provider가 signal을 무시해도 호출부가 `Promise.race`로 제때 빠져나온다.
76
+ */
77
+ export function stoppedBy(signal: AbortSignal): Promise<typeof JUDGE_STOPPED> {
78
+ const stopped = Promise.withResolvers<typeof JUDGE_STOPPED>();
79
+ if (signal.aborted) stopped.resolve(JUDGE_STOPPED);
80
+ else signal.addEventListener("abort", () => stopped.resolve(JUDGE_STOPPED), { once: true });
81
+ return stopped.promise;
82
+ }
@@ -16,6 +16,9 @@ import {
16
16
  summarizeProjectStack,
17
17
  VERIFIED_CATALOG,
18
18
  } from "./lib/mcp-selection";
19
+ import { type CoreJudgmentModules, defaultLoadCore, JUDGE_STOPPED, type NativeJudge, resolveNativeJudge, stoppedBy } from "./lib/native-judge";
20
+
21
+ export type { CoreJudgmentModules, NativeJudge } from "./lib/native-judge";
19
22
 
20
23
  /**
21
24
  * 요청별 MCP 선택(`mcp.selection: per-request`). 패치된 core가 매 사용자 요청의 system prompt를 만들기
@@ -26,37 +29,6 @@ import {
26
29
  * `mcp_select`를 모르는 core(standalone omp 등)에서는 핸들러가 호출되지 않아 아무 일도 하지 않는다.
27
30
  */
28
31
 
29
- interface JudgeAnswers {
30
- answers: Record<string, { type: string; choice?: string } | undefined>;
31
- }
32
-
33
- /** 판정기 seam. 기본 구현은 native 후보에서만 판정을 실행한다. */
34
- export interface NativeJudge {
35
- judge(request: { state: unknown; questions: Record<string, unknown> }, options?: { signal?: AbortSignal }): Promise<JudgeAnswers>;
36
- }
37
-
38
- type JudgeKind = "native" | "local" | "online";
39
-
40
- interface ChainJudgeLike {
41
- withCandidate<T>(run: (judge: NativeJudge, kind: JudgeKind) => Promise<T>, options?: { signal?: AbortSignal }): Promise<T>;
42
- }
43
-
44
- /** 기존 core 판정 모듈. 런타임 값은 host가 해석하고 타입은 여기 필요한 만큼만 둔다. */
45
- export interface CoreJudgmentModules {
46
- judgment: {
47
- resolveJudge(deps: {
48
- settings: unknown;
49
- registry: unknown;
50
- sessionId?: string;
51
- purpose: string;
52
- onUsage?: unknown;
53
- }): ChainJudgeLike;
54
- hasNativeJudge(settings: unknown, registry: unknown): boolean;
55
- journalJudgmentUsage?(manager: unknown): unknown;
56
- };
57
- settings: { findScopedSettings(cwd?: string): unknown };
58
- }
59
-
60
32
  export interface McpSelectionDeps {
61
33
  /** 결정적 판정기 seam(테스트). 없으면 {@link loadCore}의 native JEV를 쓴다. */
62
34
  resolveJudge?: (ctx: ExtensionContext) => Promise<NativeJudge | undefined>;
@@ -89,8 +61,6 @@ const STACK_DIR_LIMIT = 40;
89
61
  /** 새 요청의 판정 대기 상한. 넘으면 판정을 취소하고 판정 실패와 같게 새 연결 없이 진행한다(runner 30초 제한에 기대지 않는다). */
90
62
  const MCP_SELECTION_TIMEOUT_MS = 3_000;
91
63
  const JUDGE_TIMEOUT_REASON = "판정이 제한 시간 안에 끝나지 않음";
92
- /** 취소·제한 시간이 판정보다 먼저 끝났음을 나타내는 race 표지. */
93
- const STOPPED: unique symbol = Symbol("mcp-selection-stopped");
94
64
 
95
65
  async function readJson(path: string): Promise<Record<string, unknown> | undefined> {
96
66
  try {
@@ -167,46 +137,11 @@ async function readProjectStack(cwd: string): Promise<string> {
167
137
  return summarizeProjectStack({ fileNames, dependencyNames });
168
138
  }
169
139
 
170
- async function defaultLoadCore(): Promise<CoreJudgmentModules> {
171
- // 지연 import: 테스트 미러에는 node_modules가 없고, 런타임은 host 번들로 해석한다(jev-runtime과 같다).
172
- const [judgment, settings] = await Promise.all([
173
- import("@oh-my-pi/pi-coding-agent/judgment"),
174
- import("@oh-my-pi/pi-coding-agent/config/settings"),
175
- ]);
176
- return { judgment, settings } as unknown as CoreJudgmentModules;
177
- }
178
-
179
- /**
180
- * 기존 native JEV만 쓴다. judge 역할의 첫 후보가 native가 아니면 판정하지 않고, 세션 모델 fallback을
181
- * 붙이지 않으며(sessionModel 미전달), 체인이 native가 아닌 후보를 내면 그 후보에서는 실행하지 않는다.
182
- */
183
- async function resolveNativeJudge(ctx: ExtensionContext, loadCore: () => Promise<CoreJudgmentModules>): Promise<NativeJudge> {
184
- const { judgment, settings: settingsModule } = await loadCore();
185
- const settings = settingsModule.findScopedSettings(ctx.cwd);
186
- if (!settings) throw new Error("세션 설정을 찾지 못함");
187
- if (!judgment.hasNativeJudge(settings, ctx.modelRegistry)) throw new Error("judge 역할 첫 후보가 native JEV가 아님");
188
- const chain = judgment.resolveJudge({
189
- settings,
190
- registry: ctx.modelRegistry,
191
- sessionId: ctx.sessionManager?.getSessionId?.(),
192
- purpose: "mcp_selection",
193
- onUsage: judgment.journalJudgmentUsage?.(ctx.sessionManager),
194
- });
195
- return {
196
- judge: (request, options) =>
197
- chain.withCandidate(
198
- (candidate, kind) =>
199
- kind === "native" ? candidate.judge(request, options) : Promise.reject(new Error(`native가 아닌 judge 후보(${kind})`)),
200
- options,
201
- ),
202
- };
203
- }
204
-
205
140
  export function createMcpSelection(deps: McpSelectionDeps = {}) {
206
141
  const agentDir = deps.agentDir ?? join(import.meta.dir, "..");
207
142
  const catalog = deps.catalog ?? VERIFIED_CATALOG;
208
143
  const loadCore = deps.loadCore ?? defaultLoadCore;
209
- const resolveJudge = deps.resolveJudge ?? ((ctx: ExtensionContext) => resolveNativeJudge(ctx, loadCore));
144
+ const resolveJudge = deps.resolveJudge ?? ((ctx: ExtensionContext) => resolveNativeJudge(ctx, loadCore, "mcp_selection"));
210
145
  const judgeTimeoutMs = deps.judgeTimeoutMs ?? MCP_SELECTION_TIMEOUT_MS;
211
146
 
212
147
  return function mcpSelection(pi: ExtensionAPI): void {
@@ -280,22 +215,20 @@ export function createMcpSelection(deps: McpSelectionDeps = {}) {
280
215
  const answers: Record<string, string | undefined> = {};
281
216
  const signal = event.signal ? AbortSignal.any([event.signal, token.controller.signal]) : token.controller.signal;
282
217
  // provider가 signal을 무시해도 취소·제한 시간에 바로 돌아온다.
283
- const stopped = Promise.withResolvers<typeof STOPPED>();
284
- if (signal.aborted) stopped.resolve(STOPPED);
285
- else signal.addEventListener("abort", () => stopped.resolve(STOPPED), { once: true });
218
+ const stopped = stoppedBy(signal);
286
219
  let timedOut = false;
287
220
  const timer = setTimeout(() => {
288
221
  timedOut = true;
289
222
  token.controller.abort();
290
223
  }, judgeTimeoutMs);
291
224
  try {
292
- const judge = await Promise.race([judgeFor(ctx), stopped.promise]);
293
- if (judge === STOPPED) throw new Error("판정 취소");
225
+ const judge = await Promise.race([judgeFor(ctx), stopped]);
226
+ if (judge === JUDGE_STOPPED) throw new Error("판정 취소");
294
227
  if (!judge) throw new Error("JEV 판정기를 해석하지 못함");
295
- const stack = await Promise.race([projectStack, stopped.promise]);
296
- if (stack === STOPPED) throw new Error("판정 취소");
297
- const result = await Promise.race([judge.judge(buildJudgeRequest(cue, stack, candidates), { signal }), stopped.promise]);
298
- if (result === STOPPED) throw new Error("판정 취소");
228
+ const stack = await Promise.race([projectStack, stopped]);
229
+ if (stack === JUDGE_STOPPED) throw new Error("판정 취소");
230
+ const result = await Promise.race([judge.judge(buildJudgeRequest(cue, stack, candidates), { signal }), stopped]);
231
+ if (result === JUDGE_STOPPED) throw new Error("판정 취소");
299
232
  for (const [key, answer] of Object.entries(result.answers)) {
300
233
  answers[key] = answer?.type === "choice" ? answer.choice : undefined;
301
234
  }