cdsa-harness 0.1.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/llm.js CHANGED
@@ -1,31 +1,58 @@
1
- // LLM 호출 계층. OpenAI / OpenRouter (OpenAI 호환) + 없이 체험하는 mock.
1
+ // LLM 호출 계층. OpenAI / OpenRouter (OpenAI 호환) / Anthropic(Claude) + mock.
2
2
  // Node 18+ 내장 fetch 사용 → 외부 의존성 없음.
3
+ //
4
+ // 모든 provider 의 응답을 아래 한 가지 형태로 정규화해서 돌려준다(교육 모드용 메타 포함):
5
+ // { content, toolCalls:[{id,name,args}], usage:{input,output,total}|null,
6
+ // latencyMs, request:{ provider, endpoint, model, temperature, toolCount, bodyBytes } }
3
7
 
4
8
  export class LLMError extends Error {}
5
9
 
6
10
  const ENDPOINTS = {
7
11
  openai: "https://api.openai.com/v1/chat/completions",
8
12
  openrouter: "https://openrouter.ai/api/v1/chat/completions",
13
+ anthropic: "https://api.anthropic.com/v1/messages",
9
14
  };
10
15
 
11
16
  export class LLMClient {
12
- constructor({ provider, apiKey, model, temperature = 0.2, timeout = 60000 }) {
17
+ constructor({ provider, apiKey, model, temperature = 0.2, maxTokens = 1024, timeout = 60000 }) {
13
18
  this.provider = provider;
14
19
  this.apiKey = apiKey;
15
20
  this.model = model;
16
21
  this.temperature = temperature;
22
+ this.maxTokens = maxTokens;
17
23
  this.timeout = timeout;
18
24
  }
19
25
 
20
26
  async chat(messages, tools) {
21
27
  if (this.provider === "mock") return mockChat(messages);
22
- if (!ENDPOINTS[this.provider]) {
23
- throw new LLMError(`지원하지 않는 provider 입니다: ${this.provider}`);
28
+ if (this.provider === "anthropic") return this._anthropicChat(messages, tools);
29
+ if (ENDPOINTS[this.provider]) return this._openaiChat(messages, tools);
30
+ throw new LLMError(`지원하지 않는 provider 입니다: ${this.provider}`);
31
+ }
32
+
33
+ async _post(url, headers, body) {
34
+ const json = JSON.stringify(body);
35
+ const ctrl = new AbortController();
36
+ const timer = setTimeout(() => ctrl.abort(), this.timeout);
37
+ const started = Date.now();
38
+ let res;
39
+ try {
40
+ res = await fetch(url, { method: "POST", headers, body: json, signal: ctrl.signal });
41
+ } catch (e) {
42
+ throw new LLMError(`네트워크 오류: ${e.message}`);
43
+ } finally {
44
+ clearTimeout(timer);
45
+ }
46
+ const latencyMs = Date.now() - started;
47
+ if (!res.ok) {
48
+ const text = await res.text().catch(() => "");
49
+ throw new LLMError(`API 오류 ${res.status}: ${trim(text) || res.statusText}`);
24
50
  }
25
- return this._httpChat(messages, tools);
51
+ return { payload: await res.json(), latencyMs, bodyBytes: Buffer.byteLength(json, "utf8") };
26
52
  }
27
53
 
28
- async _httpChat(messages, tools) {
54
+ // --- OpenAI / OpenRouter (chat/completions 형식) ---
55
+ async _openaiChat(messages, tools) {
29
56
  const url = ENDPOINTS[this.provider];
30
57
  const body = { model: this.model, messages, temperature: this.temperature };
31
58
  if (tools && tools.length) {
@@ -37,96 +64,206 @@ export class LLMClient {
37
64
  "Content-Type": "application/json",
38
65
  };
39
66
  if (this.provider === "openrouter") {
40
- headers["HTTP-Referer"] = "https://github.com/cdsa-harness";
67
+ headers["HTTP-Referer"] = "https://github.com/cdsassj00/miniharness";
41
68
  headers["X-Title"] = "CDSA Harness";
42
69
  }
70
+ const { payload, latencyMs, bodyBytes } = await this._post(url, headers, body);
71
+ const parsed = parseOpenAiReply(payload);
72
+ return {
73
+ ...parsed,
74
+ latencyMs,
75
+ request: this._meta(url, tools, bodyBytes),
76
+ };
77
+ }
43
78
 
44
- const ctrl = new AbortController();
45
- const timer = setTimeout(() => ctrl.abort(), this.timeout);
46
- let res;
47
- try {
48
- res = await fetch(url, {
49
- method: "POST",
50
- headers,
51
- body: JSON.stringify(body),
52
- signal: ctrl.signal,
53
- });
54
- } catch (e) {
55
- throw new LLMError(`네트워크 오류: ${e.message}`);
56
- } finally {
57
- clearTimeout(timer);
58
- }
79
+ // --- Anthropic (messages 형식) ---
80
+ async _anthropicChat(messages, tools) {
81
+ const url = ENDPOINTS.anthropic;
82
+ const body = toAnthropicBody(messages, tools, this.model, this.temperature, this.maxTokens);
83
+ const headers = {
84
+ "x-api-key": this.apiKey,
85
+ "anthropic-version": "2023-06-01",
86
+ "Content-Type": "application/json",
87
+ };
88
+ const { payload, latencyMs, bodyBytes } = await this._post(url, headers, body);
89
+ const parsed = parseAnthropicReply(payload);
90
+ return {
91
+ ...parsed,
92
+ latencyMs,
93
+ request: this._meta(url, tools, bodyBytes),
94
+ };
95
+ }
59
96
 
60
- if (!res.ok) {
61
- const text = await res.text().catch(() => "");
62
- throw new LLMError(`API 오류 ${res.status}: ${text || res.statusText}`);
63
- }
64
- const payload = await res.json();
65
- return parseOpenAiReply(payload);
97
+ _meta(endpoint, tools, bodyBytes) {
98
+ return {
99
+ provider: this.provider,
100
+ endpoint,
101
+ model: this.model,
102
+ temperature: this.temperature,
103
+ toolCount: tools ? tools.length : 0,
104
+ bodyBytes,
105
+ };
66
106
  }
67
107
  }
68
108
 
109
+ function trim(s) {
110
+ s = String(s || "");
111
+ return s.length > 400 ? s.slice(0, 400) + " …" : s;
112
+ }
113
+
69
114
  function parseOpenAiReply(payload) {
70
115
  const msg = payload?.choices?.[0]?.message;
71
116
  if (!msg) {
72
- throw new LLMError(
73
- `예상치 못한 응답 형식입니다: ${JSON.stringify(payload).slice(0, 300)}`
74
- );
117
+ throw new LLMError(`예상치 못한 응답 형식입니다: ${JSON.stringify(payload).slice(0, 300)}`);
75
118
  }
76
119
  const toolCalls = [];
77
120
  for (const tc of msg.tool_calls || []) {
78
121
  let args = {};
79
122
  try {
80
- args = typeof tc.function?.arguments === "string"
81
- ? JSON.parse(tc.function.arguments || "{}")
82
- : tc.function?.arguments || {};
123
+ args =
124
+ typeof tc.function?.arguments === "string"
125
+ ? JSON.parse(tc.function.arguments || "{}")
126
+ : tc.function?.arguments || {};
83
127
  } catch {
84
128
  args = { _raw: tc.function?.arguments };
85
129
  }
86
130
  toolCalls.push({ id: tc.id || `call_${toolCalls.length}`, name: tc.function?.name || "", args });
87
131
  }
88
- return { content: msg.content ?? null, toolCalls };
132
+ const u = payload.usage;
133
+ const usage = u
134
+ ? { input: u.prompt_tokens ?? null, output: u.completion_tokens ?? null, total: u.total_tokens ?? null }
135
+ : null;
136
+ return { content: msg.content ?? null, toolCalls, usage };
137
+ }
138
+
139
+ // 내부(OpenAI 형식) 메시지를 Anthropic messages 형식으로 변환.
140
+ // - system 메시지 → 최상위 system 필드
141
+ // - assistant tool_calls → content 의 tool_use 블록
142
+ // - tool 메시지 → user 의 tool_result 블록 (연속된 것은 하나의 user 로 합침)
143
+ export function toAnthropicBody(messages, tools, model, temperature, maxTokens) {
144
+ let system = "";
145
+ const out = [];
146
+ for (const m of messages) {
147
+ if (m.role === "system") {
148
+ system += (system ? "\n" : "") + (m.content || "");
149
+ } else if (m.role === "user") {
150
+ pushUserBlock(out, { type: "text", text: m.content || "" });
151
+ } else if (m.role === "assistant") {
152
+ const content = [];
153
+ if (m.content) content.push({ type: "text", text: m.content });
154
+ for (const tc of m.tool_calls || []) {
155
+ let input = {};
156
+ try {
157
+ input = JSON.parse(tc.function?.arguments || "{}");
158
+ } catch {
159
+ input = {};
160
+ }
161
+ content.push({ type: "tool_use", id: tc.id, name: tc.function?.name, input });
162
+ }
163
+ out.push({ role: "assistant", content: content.length ? content : [{ type: "text", text: "" }] });
164
+ } else if (m.role === "tool") {
165
+ pushUserBlock(out, { type: "tool_result", tool_use_id: m.tool_call_id, content: m.content || "" });
166
+ }
167
+ }
168
+ const body = { model, max_tokens: maxTokens, system, messages: out, temperature };
169
+ if (tools && tools.length) {
170
+ body.tools = tools.map((t) => ({
171
+ name: t.function.name,
172
+ description: t.function.description,
173
+ input_schema: t.function.parameters,
174
+ }));
175
+ }
176
+ return body;
177
+ }
178
+
179
+ // 직전 메시지가 user 면 블록을 이어붙이고, 아니면 새 user 메시지를 만든다.
180
+ function pushUserBlock(out, block) {
181
+ const last = out[out.length - 1];
182
+ if (last && last.role === "user") last.content.push(block);
183
+ else out.push({ role: "user", content: [block] });
184
+ }
185
+
186
+ function parseAnthropicReply(payload) {
187
+ if (payload?.type === "error") {
188
+ throw new LLMError(`Anthropic 오류: ${payload.error?.message || JSON.stringify(payload)}`);
189
+ }
190
+ const blocks = payload?.content || [];
191
+ let text = "";
192
+ const toolCalls = [];
193
+ for (const b of blocks) {
194
+ if (b.type === "text") text += b.text;
195
+ else if (b.type === "tool_use") toolCalls.push({ id: b.id, name: b.name, args: b.input || {} });
196
+ }
197
+ const u = payload?.usage;
198
+ const usage = u
199
+ ? {
200
+ input: u.input_tokens ?? null,
201
+ output: u.output_tokens ?? null,
202
+ total: (u.input_tokens || 0) + (u.output_tokens || 0),
203
+ }
204
+ : null;
205
+ return { content: text || null, toolCalls, usage };
89
206
  }
90
207
 
91
208
  // ---------------------------------------------------------------------------
92
209
  // Mock 에이전트: 키 없이 Agent Loop 전체를 체험.
93
- // 히스토리에 쌓인 tool 결과 개수로 '현재 단계'를 판단하는 결정형 에이전트.
210
+ // 인사/잡담엔 그냥 대화로 답하고, 파일 작업을 시킬 때만 도구 데모를 돌린다.
94
211
  // ---------------------------------------------------------------------------
212
+ const FILE_TASK_RE =
213
+ /(파일|폴더|디렉|notes|\.txt|\.md|\.js|읽|쓰|수정|고치|만들|생성|추가|삭제|편집|list|read|write|만들어|적어|기록)/i;
214
+
215
+ function mockReturn(extra) {
216
+ return { usage: null, latencyMs: 1, request: { provider: "mock", endpoint: "(mock)", model: "mock-agent", temperature: 0, toolCount: 0, bodyBytes: 0 }, ...extra };
217
+ }
218
+
95
219
  function mockChat(messages) {
96
220
  const toolMsgs = messages.filter((m) => m.role === "tool");
97
221
  const lastUser = [...messages].reverse().find((m) => m.role === "user")?.content || "";
98
222
  const phase = toolMsgs.length;
99
223
 
224
+ // 아직 도구를 쓰기 전 + 파일 작업 요청이 아니면 → 그냥 대화로 응답(도구 X)
225
+ if (phase === 0 && !FILE_TASK_RE.test(lastUser)) {
226
+ return mockReturn({
227
+ content:
228
+ "안녕하세요! 저는 CDSA Harness 의 mock(연습) 에이전트예요. 실제 AI 는 아니고, " +
229
+ "에이전트가 도구로 파일을 다루는 흐름을 보여주는 데모입니다.\n" +
230
+ "예) \"notes.txt 맨 아래에 할 일 3개 추가해줘\" 처럼 파일 작업을 시켜보세요. " +
231
+ "진짜 AI 와 대화하려면 /setup 으로 OpenAI·Claude 키를 연결하면 됩니다.",
232
+ toolCalls: [],
233
+ });
234
+ }
235
+
100
236
  if (phase === 0) {
101
- return {
237
+ return mockReturn({
102
238
  content: "작업 폴더 구조부터 확인하겠습니다.",
103
239
  toolCalls: [{ id: "mock_1", name: "list_dir", args: { path: "." } }],
104
- };
240
+ });
105
241
  }
106
242
  if (phase === 1) {
107
243
  const target = guessFile(toolMsgs[0].content || "", lastUser);
108
- return {
244
+ return mockReturn({
109
245
  content: `\`${target}\` 파일을 읽어 현재 내용을 확인하겠습니다.`,
110
246
  toolCalls: [{ id: "mock_2", name: "read_file", args: { path: target } }],
111
- };
247
+ });
112
248
  }
113
249
  if (phase === 2) {
114
250
  const target = guessFile(toolMsgs[0].content || "", lastUser);
115
251
  const current = toolMsgs[1].content || "";
116
- const addition = `\n\n# (CDSA Harness mock 에이전트가 추가) 요청: ${lastUser.trim().slice(0, 60)}`;
117
- const newContent = current.replace(/\n+$/, "") + addition + "\n";
118
- return {
119
- content: `\`${target}\` 끝에 메모 한 줄을 추가하는 수정을 제안합니다.`,
252
+ const base = /파일이 없습니다|경로가 없습니다/.test(current) ? "" : current;
253
+ const addition = `# (CDSA Harness mock 에이전트가 추가) 요청: ${lastUser.trim().slice(0, 60)}`;
254
+ const newContent = (base.replace(/\n+$/, "") + "\n\n" + addition + "\n").replace(/^\n+/, "");
255
+ return mockReturn({
256
+ content: `\`${target}\` 에 메모 한 줄을 추가하는 수정을 제안합니다.`,
120
257
  toolCalls: [{ id: "mock_3", name: "write_file", args: { path: target, content: newContent } }],
121
- };
258
+ });
122
259
  }
123
- return {
260
+ return mockReturn({
124
261
  content:
125
262
  "완료했습니다. 방금까지의 흐름이 바로 하네스의 Agent Loop 입니다:\n" +
126
- "① 작업 폴더 보기 → ② 파일 읽기 → ③ 파일 수정 제안 → ④ 사용자 승인 → ⑤ 저장 → ⑥ 결과를 다시 모델에 전달.\n" +
127
- "실제 LLM 쓰려면 --provider openai --model ... API Key 를 설정하세요.",
263
+ "① 폴더 보기 → ② 파일 읽기 → ③ 수정 제안 → ④ 승인 → ⑤ 저장 → ⑥ 결과를 다시 모델에 전달.\n" +
264
+ "실제 LLM 으로 같은 흐름을 보려면 /setup 으로 키를 연결하세요.",
128
265
  toolCalls: [],
129
- };
266
+ });
130
267
  }
131
268
 
132
269
  function guessFile(listing, userText) {
@@ -142,5 +279,7 @@ function guessFile(listing, userText) {
142
279
  for (const name of files) {
143
280
  if (userText.includes(name)) return name;
144
281
  }
282
+ const m = userText.match(/[\w./-]+\.(txt|md|js|json|py|ts|csv)/i);
283
+ if (m) return m[0];
145
284
  return files[0] || "notes.txt";
146
285
  }
package/src/loop.js CHANGED
@@ -7,7 +7,7 @@ import fs from "node:fs";
7
7
  import path from "node:path";
8
8
 
9
9
  import { LLMError } from "./llm.js";
10
- import { MUTATING_TOOLS, TOOL_LABELS, ToolError, toolSchemas } from "./tools.js";
10
+ import { TOOL_LABELS, ToolError, toolSchemas } from "./tools.js";
11
11
 
12
12
  // 단계(Step) 상수
13
13
  export const Step = {
@@ -19,6 +19,7 @@ export const Step = {
19
19
  APPROVAL: "approval",
20
20
  TOOL_RUN: "tool_run",
21
21
  TOOL_RESULT: "tool_result",
22
+ FEEDBACK: "feedback",
22
23
  DONE: "done",
23
24
  ERROR: "error",
24
25
  };
@@ -32,10 +33,35 @@ export const STEP_LABELS = {
32
33
  approval: "사용자 승인",
33
34
  tool_run: "도구 실행",
34
35
  tool_result: "결과 반영",
36
+ feedback: "결과 되먹임",
35
37
  done: "완료",
36
38
  error: "오류",
37
39
  };
38
40
 
41
+ // 아주 거친 토큰 추정(영문 ~4자/토큰, 한글은 더 많지만 교육용 어림값).
42
+ export function estimateTokens(text) {
43
+ return Math.max(1, Math.round((text || "").length / 4));
44
+ }
45
+
46
+ // messages 를 교육용으로 요약: 역할별 글자수 + 도구호출 수.
47
+ function summarizeMessages(messages) {
48
+ let totalChars = 0;
49
+ const rows = messages.map((m) => {
50
+ const text = m.content || "";
51
+ let chars = text.length;
52
+ let extra = "";
53
+ if (m.tool_calls && m.tool_calls.length) {
54
+ const j = JSON.stringify(m.tool_calls);
55
+ chars += j.length;
56
+ extra = ` +tool_calls(${m.tool_calls.length})`;
57
+ }
58
+ if (m.role === "tool") extra = " (도구 결과)";
59
+ totalChars += chars;
60
+ return { role: m.role, chars, extra };
61
+ });
62
+ return { rows, totalChars, estTokens: estimateTokens(messages.map((m) => (m.content || "") + JSON.stringify(m.tool_calls || "")).join("")) };
63
+ }
64
+
39
65
  const RULES_FILENAMES = ["AGENT.md", "AGENTS.md", "CLAUDE.md", "rules.md", "RULES.md"];
40
66
 
41
67
  function findRules(workspace) {
@@ -98,7 +124,13 @@ export class AgentLoop {
98
124
  }
99
125
 
100
126
  reset() {
101
- this.messages = [{ role: "system", content: this._systemPrompt() }];
127
+ this.systemPromptText = this._systemPrompt();
128
+ this.messages = [{ role: "system", content: this.systemPromptText }];
129
+ }
130
+
131
+ // /context 명령용: 현재 대화 컨텍스트 요약.
132
+ contextSummary() {
133
+ return { ...summarizeMessages(this.messages), systemPrompt: this.systemPromptText };
102
134
  }
103
135
 
104
136
  async run(userInput) {
@@ -113,14 +145,27 @@ export class AgentLoop {
113
145
  "규칙 파일 + 작업 폴더 내용을 시스템 프롬프트로 묶어 모델에 전달합니다."
114
146
  );
115
147
 
116
- const tools = toolSchemas(this.config.allow_shell);
148
+ const tools = toolSchemas(this.config.allow_shell, this.toolbox.plugins);
149
+ const toolNames = tools.map((t) => t.function.name);
117
150
  let finalText = "";
118
151
 
119
152
  for (let stepNo = 1; stepNo <= this.config.max_steps; stepNo++) {
153
+ // ② 모델에 보내는 컨텍스트를 그대로 드러낸다(교육 모드 핵심).
154
+ const ctx = summarizeMessages(this.messages);
120
155
  this._emit(
121
156
  Step.MODEL_CALL,
122
- `LLM 호출 (반복 ${stepNo})`,
123
- `메시지 ${this.messages.length}개를 모델에 전송합니다.`
157
+ `LLM 호출 (반복 ${stepNo}/${this.config.max_steps})`,
158
+ `메시지 ${this.messages.length}개(추정 ${ctx.estTokens} 토큰)를 모델에 전송합니다.`,
159
+ {
160
+ iteration: stepNo,
161
+ provider: this.config.provider,
162
+ model: this.config.model,
163
+ messages: ctx.rows,
164
+ totalChars: ctx.totalChars,
165
+ estTokens: ctx.estTokens,
166
+ tools: toolNames,
167
+ systemPrompt: stepNo === 1 ? this.systemPromptText : null,
168
+ }
124
169
  );
125
170
 
126
171
  let reply;
@@ -134,8 +179,12 @@ export class AgentLoop {
134
179
  throw e;
135
180
  }
136
181
 
182
+ // ③ 모델의 원본 판단 + 실측 메타(응답시간/토큰/요청크기)를 드러낸다.
137
183
  this._emit(Step.MODEL_REPLY, "모델 응답", reply.content || "(텍스트 없음)", {
138
184
  toolCalls: reply.toolCalls.map((tc) => ({ name: tc.name, args: tc.args })),
185
+ usage: reply.usage || null,
186
+ latencyMs: reply.latencyMs ?? null,
187
+ request: reply.request || null,
139
188
  });
140
189
  if (reply.content) finalText = reply.content;
141
190
 
@@ -163,6 +212,14 @@ export class AgentLoop {
163
212
  const resultText = await this._handleToolCall(tc);
164
213
  this.messages.push({ role: "tool", tool_call_id: tc.id, content: resultText });
165
214
  }
215
+
216
+ // ⑤ 도구 결과를 messages 에 넣고 다시 ②로 — 이 되먹임이 'Loop' 의 정체.
217
+ this._emit(
218
+ Step.FEEDBACK,
219
+ "결과 되먹임",
220
+ `도구 결과를 대화에 추가했습니다(현재 메시지 ${this.messages.length}개). 같은 컨텍스트로 다시 모델을 호출합니다.`,
221
+ { messageCount: this.messages.length }
222
+ );
166
223
  }
167
224
 
168
225
  this._emit(Step.DONE, "반복 한도 도달", `max_steps(${this.config.max_steps})에 도달해 종료했습니다.`);
@@ -170,8 +227,8 @@ export class AgentLoop {
170
227
  }
171
228
 
172
229
  async _handleToolCall(tc) {
173
- const label = TOOL_LABELS[tc.name] || tc.name;
174
- const needsApproval = MUTATING_TOOLS.has(tc.name);
230
+ const label = this.toolbox.label ? this.toolbox.label(tc.name) : TOOL_LABELS[tc.name] || tc.name;
231
+ const needsApproval = this.toolbox.isMutating(tc.name);
175
232
 
176
233
  if (needsApproval && this.config.approval_mode === "manual") {
177
234
  const req = this._buildApprovalRequest(tc);
@@ -193,7 +250,7 @@ export class AgentLoop {
193
250
 
194
251
  this._emit(Step.TOOL_RUN, `도구 실행: ${label}`, JSON.stringify(tc.args).slice(0, 2000));
195
252
  try {
196
- const result = this.toolbox.execute(tc.name, tc.args);
253
+ const result = await this.toolbox.execute(tc.name, tc.args);
197
254
  this._emit(Step.TOOL_RESULT, `결과 반영: ${label}`, (result.output || "").slice(0, 4000));
198
255
  return result.output;
199
256
  } catch (e) {
package/src/plugins.js ADDED
@@ -0,0 +1,191 @@
1
+ // 플러그인 시스템 — OpenCode 스타일 확장성.
2
+ // `.cdsa/plugins/` (작업 폴더) 와 `~/.cdsa_harness/plugins/` (전역) 의 .js/.mjs 파일을
3
+ // 불러와 '새 도구'로 등록한다. 각 파일은 아래 형태의 객체를 default export 한다:
4
+ //
5
+ // export default {
6
+ // name: "word_count",
7
+ // description: "텍스트 파일의 글자/줄 수를 센다",
8
+ // parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
9
+ // mutating: false, // true 면 실행 전 사용자 승인 필요
10
+ // async handler(args, ctx) { ... } // ctx = { workspace }
11
+ // }
12
+ import fs from "node:fs";
13
+ import { createRequire } from "node:module";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
+
18
+ export function pluginDirs(workspace) {
19
+ return [
20
+ path.join(os.homedir(), ".cdsa_harness", "plugins"),
21
+ path.join(workspace, ".cdsa", "plugins"),
22
+ ];
23
+ }
24
+
25
+ export async function loadPlugins(workspace) {
26
+ const plugins = [];
27
+ for (const dir of pluginDirs(workspace)) {
28
+ let files = [];
29
+ try {
30
+ if (!fs.existsSync(dir)) continue;
31
+ files = fs.readdirSync(dir).filter((f) => /\.(mjs|js)$/.test(f)).sort();
32
+ } catch {
33
+ continue;
34
+ }
35
+ for (const f of files) {
36
+ const full = path.join(dir, f);
37
+ try {
38
+ const mod = await import(pathToFileURL(full).href);
39
+ const def = mod.default || mod.plugin || mod;
40
+ if (!def || !def.name || typeof def.handler !== "function") {
41
+ plugins.push({ error: `${f}: name/handler 가 없습니다` });
42
+ continue;
43
+ }
44
+ plugins.push({
45
+ name: def.name,
46
+ description: def.description || "",
47
+ parameters: def.parameters || { type: "object", properties: {} },
48
+ mutating: Boolean(def.mutating),
49
+ handler: def.handler,
50
+ source: full,
51
+ });
52
+ } catch (e) {
53
+ plugins.push({ error: `${f}: ${e.message}` });
54
+ }
55
+ }
56
+ }
57
+ return plugins;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // npm 패키지로 설치한 플러그인 자동 발견.
62
+ // - 이름이 `cdsa-harness-plugin-*` (또는 `@scope/cdsa-harness-plugin-*`)
63
+ // - 또는 package.json 에 keywords: ["cdsa-harness-plugin"] / "cdsaHarness" 필드
64
+ // 인 패키지를 node_modules 에서 찾아 불러온다.
65
+ // 패키지는 다음 중 하나를 default export:
66
+ // · 플러그인 def 객체 · def 배열 · { tools:[...], skills:[{name,description,body}] }
67
+ // ---------------------------------------------------------------------------
68
+ function isPluginPackage(pkgJson, name) {
69
+ if (/^(@[^/]+\/)?cdsa-harness-plugin-/.test(name)) return true;
70
+ if (pkgJson && pkgJson.cdsaHarness) return true;
71
+ const kw = (pkgJson && pkgJson.keywords) || [];
72
+ return Array.isArray(kw) && kw.includes("cdsa-harness-plugin");
73
+ }
74
+
75
+ async function importPackage(nmDir, name) {
76
+ const req = createRequire(path.join(nmDir, "__cdsa_resolve__.js"));
77
+ const entry = req.resolve(name);
78
+ return import(pathToFileURL(entry).href);
79
+ }
80
+
81
+ function normalizeModule(mod, source) {
82
+ const out = { plugins: [], skills: [] };
83
+ const def = (mod && (mod.default ?? mod)) || null;
84
+ if (!def) return out;
85
+ let tools = [];
86
+ let skills = [];
87
+ if (Array.isArray(def)) tools = def;
88
+ else if (def.tools || def.skills) {
89
+ tools = def.tools || [];
90
+ skills = def.skills || [];
91
+ } else if (def.name && typeof def.handler === "function") tools = [def];
92
+ for (const t of tools) {
93
+ if (t && t.name && typeof t.handler === "function") {
94
+ out.plugins.push({
95
+ name: t.name,
96
+ description: t.description || "",
97
+ parameters: t.parameters || { type: "object", properties: {} },
98
+ mutating: Boolean(t.mutating),
99
+ handler: t.handler,
100
+ source,
101
+ });
102
+ }
103
+ }
104
+ for (const s of skills) if (s && s.name && s.body) out.skills.push(s);
105
+ return out;
106
+ }
107
+
108
+ // 한 node_modules 디렉터리를 훑어 플러그인 패키지를 모은다.
109
+ export async function scanNodeModules(nmDir) {
110
+ const result = { plugins: [], skills: [], errors: [] };
111
+ let entries = [];
112
+ try {
113
+ if (!fs.existsSync(nmDir)) return result;
114
+ entries = fs.readdirSync(nmDir);
115
+ } catch {
116
+ return result;
117
+ }
118
+ const names = [];
119
+ for (const e of entries) {
120
+ if (e.startsWith(".")) continue;
121
+ if (e.startsWith("@")) {
122
+ try {
123
+ for (const sub of fs.readdirSync(path.join(nmDir, e))) names.push(`${e}/${sub}`);
124
+ } catch {
125
+ /* ignore */
126
+ }
127
+ } else names.push(e);
128
+ }
129
+ for (const name of names) {
130
+ let pkgJson = {};
131
+ try {
132
+ pkgJson = JSON.parse(fs.readFileSync(path.join(nmDir, name, "package.json"), "utf8"));
133
+ } catch {
134
+ continue;
135
+ }
136
+ if (!isPluginPackage(pkgJson, name)) continue;
137
+ try {
138
+ const mod = await importPackage(nmDir, name);
139
+ const norm = normalizeModule(mod, name);
140
+ result.plugins.push(...norm.plugins);
141
+ result.skills.push(...norm.skills);
142
+ } catch (e) {
143
+ result.errors.push(`${name}: ${e.message}`);
144
+ }
145
+ }
146
+ return result;
147
+ }
148
+
149
+ // cwd 의 node_modules + cdsa-harness 자신의 node_modules(전역 설치 시 형제 패키지) 를 훑고,
150
+ // config.plugins 에 적힌 패키지는 이름 규칙과 무관하게 강제로 불러온다.
151
+ export async function discoverNpmExtensions(cwd, explicitNames = []) {
152
+ const here = path.dirname(fileURLToPath(import.meta.url));
153
+ const nmDirs = [];
154
+ const add = (d) => {
155
+ const r = path.resolve(d);
156
+ if (!nmDirs.includes(r)) nmDirs.push(r);
157
+ };
158
+ add(path.join(cwd, "node_modules"));
159
+ add(path.resolve(here, "..", "..")); // .../node_modules/cdsa-harness/src → .../node_modules
160
+
161
+ const merged = { plugins: [], skills: [], errors: [] };
162
+ for (const nm of nmDirs) {
163
+ const r = await scanNodeModules(nm);
164
+ merged.plugins.push(...r.plugins);
165
+ merged.skills.push(...r.skills);
166
+ merged.errors.push(...r.errors);
167
+ }
168
+ for (const name of explicitNames) {
169
+ if (merged.plugins.some((p) => p.source === name)) continue;
170
+ let loaded = false;
171
+ for (const nm of nmDirs) {
172
+ try {
173
+ const norm = normalizeModule(await importPackage(nm, name), name);
174
+ if (norm.plugins.length || norm.skills.length) {
175
+ merged.plugins.push(...norm.plugins);
176
+ merged.skills.push(...norm.skills);
177
+ loaded = true;
178
+ break;
179
+ }
180
+ } catch {
181
+ /* try next dir */
182
+ }
183
+ }
184
+ if (!loaded) merged.errors.push(`${name}: 불러올 수 없음(설치되어 있나요? npm i ${name})`);
185
+ }
186
+ // 이름 중복 제거(먼저 발견된 것 우선)
187
+ const byName = new Map();
188
+ for (const p of merged.plugins) if (!byName.has(p.name)) byName.set(p.name, p);
189
+ merged.plugins = [...byName.values()];
190
+ return merged;
191
+ }