agentlas 1.0.27 → 1.0.29
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/CHANGELOG.md +33 -0
- package/README.md +5 -2
- package/engine/agentlas-i18n.cjs +4 -4
- package/engine/agentlas-input.cjs +0 -2
- package/engine/agentlas-onboard.cjs +20 -0
- package/engine/agentlas-workforce.cjs +41 -8
- package/engine/agentlas.cjs +7 -1
- package/engine/commands/billing.cjs +3 -0
- package/engine/commands/creds.cjs +49 -1
- package/engine/commands/doctor.cjs +46 -3
- package/engine/commands/graph.cjs +1150 -0
- package/engine/commands/help.cjs +82 -6
- package/engine/commands/hep-cloud.cjs +9 -23
- package/engine/commands/hep-hub.cjs +9 -22
- package/engine/commands/hep-local.cjs +9 -24
- package/engine/commands/hep-network.cjs +9 -35
- package/engine/commands/index.cjs +49 -25
- package/engine/commands/mcp.cjs +6 -2
- package/engine/commands/native.cjs +18 -2
- package/engine/commands/plugin.cjs +22 -0
- package/engine/commands/roles.cjs +202 -0
- package/engine/commands/workforce.cjs +63 -12
- package/engine/graph/ask-model.cjs +131 -0
- package/engine/graph/interview.cjs +875 -0
- package/engine/graph/layout.cjs +137 -0
- package/engine/graph/package.cjs +223 -0
- package/engine/graph/vocabulary.generated.cjs +30 -0
- package/engine/hephaestus/local-core.cjs +159 -0
- package/engine/hephaestus/runtime.cjs +43 -9
- package/engine/runtimes/auth-evidence.cjs +78 -0
- package/engine/sessions/prompt.cjs +16 -0
- package/engine/sessions/session.cjs +9 -0
- package/engine/tools/access-notice.cjs +86 -0
- package/engine/ui/palette.cjs +6 -3
- package/engine/ui/repl.cjs +8 -2
- package/engine/workforce/deps.cjs +13 -0
- package/engine/workforce/local-core-transport.cjs +298 -0
- package/package.json +4 -3
- package/engine/commands/legacy-network.cjs +0 -29
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* graph — 저장된 자동화 그래프를 터미널에서 보고 실행 요청한다.
|
|
4
|
+
*
|
|
5
|
+
* 실행 주체는 데스크탑 스케줄러다. 터미널은 그래프를 "지금 실행 대상"으로 표시할 뿐이며,
|
|
6
|
+
* 데스크탑이 꺼져 있으면 아무 일도 일어나지 않는다 — 그 사실을 숨기지 않고 그대로 말한다.
|
|
7
|
+
* (표시해 놓고 "실행했습니다"라고 답하면, 사용자는 돌아가지 않은 자동화를 돌아갔다고 믿는다.)
|
|
8
|
+
*
|
|
9
|
+
* 공유 DB(데스크탑과 동일 파일)를 읽고 쓴다. 스키마 소유권은 데스크탑에 있으므로
|
|
10
|
+
* 여기서는 컬럼을 만들지 않고, 없는 컬럼은 없는 대로 다룬다.
|
|
11
|
+
*/
|
|
12
|
+
const readline = require("node:readline");
|
|
13
|
+
const fs = require("node:fs");
|
|
14
|
+
const path = require("node:path");
|
|
15
|
+
const crypto = require("node:crypto");
|
|
16
|
+
const pkgLib = require("../graph/package.cjs");
|
|
17
|
+
|
|
18
|
+
function graphRows(ctx, db) {
|
|
19
|
+
if (!ctx.tableExists(db, "automations")) return [];
|
|
20
|
+
const hasGraph = ctx.columnExists(db, "automations", "graph_json");
|
|
21
|
+
const hasTriggerType = ctx.columnExists(db, "automations", "trigger_type");
|
|
22
|
+
const hasTarget = ctx.columnExists(db, "automations", "target_id");
|
|
23
|
+
const cols = [
|
|
24
|
+
// 실행 시각을 안 읽으면 내보낸 패키지의 매니페스트가 schedule: null이 되고,
|
|
25
|
+
// 설치하는 쪽이 기본값을 지어낸다 — 받는 사람 컴퓨터에서 **다른 시각에 도는** 자동화가 된다.
|
|
26
|
+
"schedule",
|
|
27
|
+
"id", "name", "enabled", "next_run_at", "last_run_at",
|
|
28
|
+
hasGraph ? "graph_json" : "NULL AS graph_json",
|
|
29
|
+
hasTriggerType ? "trigger_type" : "NULL AS trigger_type",
|
|
30
|
+
// 노드가 ref를 선언하지 않으면 자동화의 대상 에이전트를 상속한다 — 패키지의
|
|
31
|
+
// 가장 중요한 의존성이 여기 있으므로 반드시 함께 읽는다.
|
|
32
|
+
hasTarget ? "target_type" : "NULL AS target_type",
|
|
33
|
+
hasTarget ? "target_id" : "NULL AS target_id",
|
|
34
|
+
].join(", ");
|
|
35
|
+
return db.prepare(`SELECT ${cols} FROM automations ORDER BY name`).all();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseGraph(row) {
|
|
39
|
+
if (!row.graph_json) return null;
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(row.graph_json);
|
|
42
|
+
return parsed && Array.isArray(parsed.nodes) ? parsed : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function triggerKind(row, graph) {
|
|
49
|
+
if (row.trigger_type && row.trigger_type !== "schedule") return "input";
|
|
50
|
+
const trigger = graph?.nodes?.find((n) => n.type === "trigger");
|
|
51
|
+
const configured = trigger?.config?.kind;
|
|
52
|
+
return configured === "input" ? "input" : "cron";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 입력 트리거 계약 — 데스크탑 shared/graph-trigger-input.ts 와 같은 규칙이어야 한다.
|
|
56
|
+
// 어긋나면 화면은 "주제"를 묻고 커널은 다른 이름을 찾아, 값을 넣었는데 빈 채로 돈다.
|
|
57
|
+
const DEFAULT_TRIGGER_INPUT_VAR = "input";
|
|
58
|
+
|
|
59
|
+
/** 어떤 단계도 만들어 주지 않는데 누군가 읽는 값들 — 밖에서 들어와야 하는 값이다. */
|
|
60
|
+
function unproducedVariables(graph) {
|
|
61
|
+
const produced = new Set();
|
|
62
|
+
for (const node of graph?.nodes ?? []) {
|
|
63
|
+
for (const key of ["produces", "to"]) {
|
|
64
|
+
const value = node.config?.[key];
|
|
65
|
+
if (typeof value === "string" && value.trim()) produced.add(value.trim());
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const referenced = [];
|
|
69
|
+
for (const node of graph?.nodes ?? []) {
|
|
70
|
+
const text = `${node.config?.prompt ?? ""}\n${node.config?.text ?? ""}\n${node.config?.template ?? ""}`;
|
|
71
|
+
for (const match of text.matchAll(/\{\{\s*([\w.-]+)\s*\}\}/g)) {
|
|
72
|
+
if (!produced.has(match[1]) && !referenced.includes(match[1])) referenced.push(match[1]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return referenced;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 이 그래프가 시작할 때 사람에게 값을 받아야 하는가. */
|
|
79
|
+
function graphInputRequirement(graph, en) {
|
|
80
|
+
const trigger = graph?.nodes?.find((n) => n.type === "trigger");
|
|
81
|
+
if (!trigger) return null;
|
|
82
|
+
const unproduced = unproducedVariables(graph);
|
|
83
|
+
const declaredName = typeof trigger.config?.produces === "string" && trigger.config.produces.trim()
|
|
84
|
+
? trigger.config.produces.trim()
|
|
85
|
+
: null;
|
|
86
|
+
// 이름 선언이 없어도, 아무 단계도 만들지 않는 값이 정확히 하나면 그것이 사람이 넣을 값이다.
|
|
87
|
+
const varName = declaredName
|
|
88
|
+
?? (unproduced.length === 1 ? unproduced[0] : DEFAULT_TRIGGER_INPUT_VAR);
|
|
89
|
+
const kind = trigger.config?.kind;
|
|
90
|
+
const declaredInput = kind === "input" || kind === "manual";
|
|
91
|
+
if (!declaredInput && !unproduced.includes(varName)) return null;
|
|
92
|
+
const label = typeof trigger.config?.promptLabel === "string" && trigger.config.promptLabel.trim()
|
|
93
|
+
? trigger.config.promptLabel.trim()
|
|
94
|
+
: (en ? "Input for this graph" : "이 그래프에 넘길 값");
|
|
95
|
+
return { varName, label };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function triggerKindOfManifest(manifest) {
|
|
99
|
+
return manifest?.trigger?.kind === "input" ? "input" : "cron";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function describe(ctx, row, graph, en) {
|
|
103
|
+
const kind = triggerKind(row, graph);
|
|
104
|
+
const nodeCount = graph?.nodes?.length ?? 0;
|
|
105
|
+
const state = row.enabled ? (en ? "on" : "켜짐") : (en ? "off" : "꺼짐");
|
|
106
|
+
// 입력으로 시작하는 그래프는 예약 시각이 의미가 없다. 그 시각을 보여주면
|
|
107
|
+
// 사용자는 그때 저절로 돌 거라고 읽는다 — 실제로는 값을 넣어야만 돈다.
|
|
108
|
+
const when = kind === "input"
|
|
109
|
+
? (en ? "runs when you give it a value" : "값을 넣으면 실행")
|
|
110
|
+
: row.next_run_at
|
|
111
|
+
? new Date(row.next_run_at).toLocaleString()
|
|
112
|
+
: (en ? "not scheduled" : "예약 없음");
|
|
113
|
+
const kindLabel = kind === "cron"
|
|
114
|
+
? (en ? "schedule" : "예약")
|
|
115
|
+
: (en ? "input" : "입력");
|
|
116
|
+
return `${ctx.ui.bold(row.name)} ${ctx.ui.dim(`${kindLabel} · ${nodeCount} ${en ? "steps" : "단계"} · ${state} · ${when}`)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findGraph(rows, needle) {
|
|
120
|
+
const lowered = String(needle || "").trim().toLowerCase();
|
|
121
|
+
if (!lowered) return null;
|
|
122
|
+
const exact = rows.find((row) => row.name.toLowerCase() === lowered)
|
|
123
|
+
?? rows.find((row) => row.id === needle);
|
|
124
|
+
if (exact) return exact;
|
|
125
|
+
const partial = rows.filter((row) => row.name.toLowerCase().includes(lowered));
|
|
126
|
+
// 여러 개가 걸리면 하나를 골라 주지 않는다. 조용히 고르면 사용자가 본 적 없는
|
|
127
|
+
// 비슷한 이름의 자동화가 실행된다(실측: "글 다듬기" → "(친구가 준 것)" 사본이 돌았다).
|
|
128
|
+
if (partial.length > 1) return { ambiguous: partial };
|
|
129
|
+
return partial[0] ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 이름이 여러 개 걸렸을 때, 고르지 말고 후보를 보여준다. */
|
|
133
|
+
function reportAmbiguous(ctx, needle, matches, en) {
|
|
134
|
+
ctx.err(en
|
|
135
|
+
? `"${needle}" matches ${matches.length} automations. Say which one:`
|
|
136
|
+
: `"${needle}"에 자동화 ${matches.length}개가 걸립니다. 어느 것인지 정확히 적어 주세요:`);
|
|
137
|
+
for (const row of matches) ctx.err(` · ${row.name}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function listGraphs(ctx) {
|
|
141
|
+
const db = ctx.db();
|
|
142
|
+
const rows = graphRows(ctx, db);
|
|
143
|
+
const en = ctx.lang === "en";
|
|
144
|
+
if (!rows.length) {
|
|
145
|
+
ctx.out(en
|
|
146
|
+
? "No automation graphs saved yet. Build one in the desktop app under Graph."
|
|
147
|
+
: "저장된 자동화 그래프가 없습니다. 데스크탑 앱의 Graph에서 만들 수 있습니다.");
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
ctx.out(ctx.ui.bold(en ? "Saved graphs" : "저장된 그래프"));
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
ctx.out(" " + describe(ctx, row, parseGraph(row), en));
|
|
153
|
+
}
|
|
154
|
+
ctx.out("");
|
|
155
|
+
ctx.out(ctx.ui.dim(en
|
|
156
|
+
? "Run one with: agentlas graph run \"<name>\""
|
|
157
|
+
: "실행하려면: agentlas graph run \"<이름>\""));
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function showGraph(ctx, needle) {
|
|
162
|
+
const db = ctx.db();
|
|
163
|
+
const rows = graphRows(ctx, db);
|
|
164
|
+
const en = ctx.lang === "en";
|
|
165
|
+
const row = findGraph(rows, needle);
|
|
166
|
+
if (row?.ambiguous) { reportAmbiguous(ctx, needle, row.ambiguous, en); return 1; }
|
|
167
|
+
if (!row) {
|
|
168
|
+
ctx.err(en ? `No graph matches "${needle}".` : `"${needle}"와 맞는 그래프가 없습니다.`);
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
const graph = parseGraph(row);
|
|
172
|
+
ctx.out(describe(ctx, row, graph, en));
|
|
173
|
+
if (!graph) {
|
|
174
|
+
ctx.out(" " + ctx.ui.dim(en
|
|
175
|
+
? "This automation has no visual graph yet (single-prompt automation)."
|
|
176
|
+
: "이 자동화에는 아직 시각 그래프가 없습니다(단일 프롬프트 자동화)."));
|
|
177
|
+
return 0;
|
|
178
|
+
}
|
|
179
|
+
const requirement = graphInputRequirement(graph, en);
|
|
180
|
+
if (requirement) {
|
|
181
|
+
ctx.out(" " + ctx.ui.dim(en
|
|
182
|
+
? `Starts from a value you provide — ${requirement.label}`
|
|
183
|
+
: `시작할 때 값을 받습니다 — ${requirement.label}`));
|
|
184
|
+
}
|
|
185
|
+
ctx.out("");
|
|
186
|
+
renderGraphTree(ctx, graph, en);
|
|
187
|
+
const problems = graphProblems(graph, en);
|
|
188
|
+
if (problems.length) {
|
|
189
|
+
ctx.out("");
|
|
190
|
+
ctx.out(ctx.ui.red(en
|
|
191
|
+
? `This graph will stop when it runs (${problems.length}):`
|
|
192
|
+
: `이대로 실행하면 도중에 멈춥니다 (${problems.length}건):`));
|
|
193
|
+
for (const p of problems) {
|
|
194
|
+
ctx.out(` ⚠ ${p.what}`);
|
|
195
|
+
ctx.out(` ${ctx.ui.dim(p.fix)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// 그대로 복사해 쓸 수 있는 명령. 예전에는 값이 필요한 그래프인지 화면에서만 알려주고
|
|
199
|
+
// **넣는 방법은 안 알려줘서**, 사용자가 실패한 뒤에야 --input을 알게 됐다.
|
|
200
|
+
ctx.out("");
|
|
201
|
+
ctx.out(ctx.ui.dim(en ? "Run it with:" : "실행하려면:"));
|
|
202
|
+
ctx.out(requirement
|
|
203
|
+
? ` agentlas graph run "${row.name}" --input "<${requirement.label}>"`
|
|
204
|
+
: ` agentlas graph run "${row.name}"`);
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 실행하기 전에 알 수 있는 결함을 찾는다.
|
|
210
|
+
* 예전에는 `show`가 결함을 하나도 표시하지 않아, **돌려서 실패시켜야만** 알 수 있었다.
|
|
211
|
+
* 자동화는 사람이 없는 동안 도는 것이라, 실패를 새벽에 발견하게 된다.
|
|
212
|
+
*/
|
|
213
|
+
function graphProblems(graph, en) {
|
|
214
|
+
const problems = [];
|
|
215
|
+
const nodes = new Map((graph.nodes ?? []).map((n) => [n.id, n]));
|
|
216
|
+
const out = new Map();
|
|
217
|
+
for (const edge of graph.edges ?? []) {
|
|
218
|
+
if (!out.has(edge.source)) out.set(edge.source, []);
|
|
219
|
+
out.get(edge.source).push(edge);
|
|
220
|
+
}
|
|
221
|
+
for (const node of graph.nodes ?? []) {
|
|
222
|
+
const label = node.label || node.id;
|
|
223
|
+
if (node.type === "condition") {
|
|
224
|
+
const edges = out.get(node.id) ?? [];
|
|
225
|
+
const undeclared = edges.filter((e) => e.sourceHandle !== "true" && e.sourceHandle !== "false");
|
|
226
|
+
if (undeclared.length) {
|
|
227
|
+
problems.push({
|
|
228
|
+
what: en
|
|
229
|
+
? `Branch "${label}" has ${undeclared.length} outgoing link(s) that do not say yes or no.`
|
|
230
|
+
: `갈림길 "${label}"에서 나가는 연결 ${undeclared.length}개가 "예"인지 "아니오"인지 정해져 있지 않습니다.`,
|
|
231
|
+
fix: en
|
|
232
|
+
? "Open it in the desktop app and reconnect from the yes / no outlets."
|
|
233
|
+
: "데스크탑 앱에서 열어 참·거짓 출구에서 다시 이으세요.",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (!edges.length) {
|
|
237
|
+
problems.push({
|
|
238
|
+
what: en ? `Branch "${label}" leads nowhere.` : `갈림길 "${label}" 뒤에 아무것도 이어져 있지 않습니다.`,
|
|
239
|
+
fix: en ? "Connect what should happen on each side." : "각 갈래 뒤에 할 일을 이으세요.",
|
|
240
|
+
});
|
|
241
|
+
} else if (!edges.some((e) => e.sourceHandle === "true") || !edges.some((e) => e.sourceHandle === "false")) {
|
|
242
|
+
const missing = edges.some((e) => e.sourceHandle === "true")
|
|
243
|
+
? (en ? "no" : "아니오") : (en ? "yes" : "예");
|
|
244
|
+
problems.push({
|
|
245
|
+
what: en
|
|
246
|
+
? `Branch "${label}" has nothing on its "${missing}" side — it stops there when it goes that way.`
|
|
247
|
+
: `갈림길 "${label}"의 "${missing}" 쪽에 아무것도 없습니다 — 그쪽으로 가면 거기서 멈춥니다.`,
|
|
248
|
+
fix: en ? "Connect that side, or make it end there on purpose." : "그쪽에도 다음 단계를 잇거나, 거기서 끝나도 되게 두세요.",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// 되돌아가는 연결에 반복 횟수가 없으면 실행 자체가 거절된다.
|
|
253
|
+
for (const edge of out.get(node.id) ?? []) {
|
|
254
|
+
const target = nodes.get(edge.target);
|
|
255
|
+
if (!target) {
|
|
256
|
+
problems.push({
|
|
257
|
+
what: en ? `"${label}" links to a step that no longer exists.` : `"${label}"이(가) 없는 단계로 이어져 있습니다.`,
|
|
258
|
+
fix: en ? "Remove or repoint that link in the desktop app." : "데스크탑 앱에서 그 연결을 지우거나 다시 이으세요.",
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// 밖에서 받아야 하는 값이 여럿이면 무엇을 넣어야 하는지 정할 수 없다.
|
|
264
|
+
const unproduced = unproducedVariables(graph);
|
|
265
|
+
if (unproduced.length > 1) {
|
|
266
|
+
problems.push({
|
|
267
|
+
what: en
|
|
268
|
+
? `Nothing in this graph produces these values: ${unproduced.join(", ")}. Only one value can be supplied at the start.`
|
|
269
|
+
: `이 그래프 안에서 아무도 만들지 않는 값이 여럿입니다: ${unproduced.join(", ")}. 시작할 때 넣을 수 있는 값은 하나뿐입니다.`,
|
|
270
|
+
fix: en
|
|
271
|
+
? "Make the earlier steps produce them, or reduce them to one."
|
|
272
|
+
: "앞 단계가 그 값들을 만들게 하거나, 시작 값을 하나로 줄이세요.",
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return problems;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 노드 한 줄. 무엇인지, 바깥을 바꾸는지, 어떤 값을 만들고 쓰는지. */
|
|
279
|
+
function nodeLine(ctx, node, en) {
|
|
280
|
+
const effect = node.config?.effect;
|
|
281
|
+
const approval = node.config?.approval;
|
|
282
|
+
const marks = [
|
|
283
|
+
effect === "mutation" ? (en ? "changes things outside" : "바깥을 바꿈") : null,
|
|
284
|
+
approval === "ask" || (effect === "mutation" && approval !== "auto")
|
|
285
|
+
? (en ? "asks first" : "확인 후 실행")
|
|
286
|
+
: null,
|
|
287
|
+
node.config?.consumes ? `${en ? "uses" : "사용"} {{${node.config.consumes}}}` : null,
|
|
288
|
+
node.config?.produces ? `${en ? "makes" : "생성"} {{${node.config.produces}}}` : null,
|
|
289
|
+
].filter(Boolean);
|
|
290
|
+
if (node.type === "eval") {
|
|
291
|
+
// ★채점표는 판정 기준 그 자체다. 캔버스 없는 표면에서 이게 안 보이면
|
|
292
|
+
// 사용자는 무엇으로 채점되는지 모른 채 그래프를 켠다.
|
|
293
|
+
const items = Array.isArray(node.config?.items) ? node.config.items : [];
|
|
294
|
+
if (items.length) {
|
|
295
|
+
marks.unshift(en ? `checklist ${items.length} item(s)` : `채점표 ${items.length}칸`);
|
|
296
|
+
} else if (node.config?.criteria) {
|
|
297
|
+
marks.unshift(en ? "one-line criteria" : "기준 한 문장");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (node.type === "code") {
|
|
301
|
+
marks.unshift(node.config?.codeLang === "js" ? "javascript" : "python");
|
|
302
|
+
}
|
|
303
|
+
if (node.type === "condition") {
|
|
304
|
+
// 갈림길 이름은 만든 사람이 지은 것이라 실제 규칙과 다를 수 있다(실측: 이름은
|
|
305
|
+
// "길이가 충분한가?"인데 실제로는 어떤 단어가 들어 있는지를 봤다).
|
|
306
|
+
// 사람이 예측하려면 이름이 아니라 규칙을 봐야 한다.
|
|
307
|
+
const rule = conditionRule(node, en);
|
|
308
|
+
if (rule) marks.unshift(rule);
|
|
309
|
+
}
|
|
310
|
+
return `${ctx.ui.accent(kindWord(node.type, en))} ${node.label || node.id}`
|
|
311
|
+
+ (marks.length ? ctx.ui.dim(` — ${marks.join(", ")}`) : "");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 갈림길이 실제로 무엇을 보는지 한 줄로. 모르는 연산은 지어내지 않고 그대로 보여준다. */
|
|
315
|
+
function conditionRule(node, en) {
|
|
316
|
+
const cfg = node.config || {};
|
|
317
|
+
const v = cfg.var;
|
|
318
|
+
if (typeof v !== "string" || !v.trim()) return null;
|
|
319
|
+
const value = cfg.value;
|
|
320
|
+
const shown = typeof value === "string" ? `"${value}"` : String(value ?? "");
|
|
321
|
+
switch (cfg.op) {
|
|
322
|
+
case "contains": return en ? `yes when {{${v}}} contains ${shown}` : `{{${v}}}에 ${shown}이(가) 들어 있으면 예`;
|
|
323
|
+
case "truthy": return en ? `yes when {{${v}}} has a value` : `{{${v}}}에 값이 있으면 예`;
|
|
324
|
+
case "falsy": return en ? `yes when {{${v}}} is empty` : `{{${v}}}이(가) 비어 있으면 예`;
|
|
325
|
+
case "eq": return en ? `yes when {{${v}}} equals ${shown}` : `{{${v}}}이(가) ${shown}과 같으면 예`;
|
|
326
|
+
case "neq": return en ? `yes when {{${v}}} differs from ${shown}` : `{{${v}}}이(가) ${shown}과 다르면 예`;
|
|
327
|
+
case "gt": return en ? `yes when {{${v}}} > ${shown}` : `{{${v}}}이(가) ${shown}보다 크면 예`;
|
|
328
|
+
case "lt": return en ? `yes when {{${v}}} < ${shown}` : `{{${v}}}이(가) ${shown}보다 작으면 예`;
|
|
329
|
+
default: return en ? `checks {{${v}}} with "${cfg.op ?? "?"}"` : `{{${v}}}을(를) "${cfg.op ?? "?"}"(으)로 검사`;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 내부 타입 이름을 그대로 보여주지 않는다 — 사용자는 "condition"이 뭔지 알 이유가 없다. */
|
|
334
|
+
/**
|
|
335
|
+
* 노드 종류를 사람 말로.
|
|
336
|
+
*
|
|
337
|
+
* ★터미널은 데스크탑과 **같은 SQLite**를 읽는데 스키마 판이 뒤따라온다(터미널 부트스트랩
|
|
338
|
+
* v86 vs 데스크탑 v89). 그래서 이 버전이 모르는 노드 종류를 만나는 것은 **고장이 아니라
|
|
339
|
+
* 정상**이다. 실제로 `eval` 이 이 표에 빠져 있었고, 원문이 그대로 찍혀서 마치 아는 종류인
|
|
340
|
+
* 것처럼 보였다.
|
|
341
|
+
*
|
|
342
|
+
* 규칙(레지스트리 06 §2.5): 모르는 값은 **그 항목만** 강등하고 원문을 보존해 보여준다.
|
|
343
|
+
* 목록을 버리거나 에러로 올리지 않는다 — 이 플랫폼은 모르는 코드 1개에 후보집합을 통째로
|
|
344
|
+
* 폐기한 사고를 겪었다.
|
|
345
|
+
*/
|
|
346
|
+
const vocabulary = require("../graph/vocabulary.generated.cjs");
|
|
347
|
+
|
|
348
|
+
function kindWord(type, en) {
|
|
349
|
+
const ko = {
|
|
350
|
+
trigger: "시작", agent: "에이전트", tool: "도구", action: "행동",
|
|
351
|
+
condition: "갈림길", eval: "검증", transform: "변환", output: "출력",
|
|
352
|
+
// ★커널이 아는 종류는 여기 다 있어야 한다. 빠지면 화면에 내부 이름이 그대로 찍혀,
|
|
353
|
+
// 사용자는 "subgraph"가 뭔지 모른 채 그래프를 읽게 된다(eval이 정확히 그랬다).
|
|
354
|
+
subgraph: "다른 자동화 부르기",
|
|
355
|
+
code: "코드",
|
|
356
|
+
};
|
|
357
|
+
const enWords = {
|
|
358
|
+
trigger: "start", agent: "agent", tool: "tool", action: "action",
|
|
359
|
+
condition: "branch", eval: "check", transform: "transform", output: "output",
|
|
360
|
+
subgraph: "call another automation",
|
|
361
|
+
code: "code",
|
|
362
|
+
};
|
|
363
|
+
const read = vocabulary.readEnum(type, vocabulary.GRAPH_NODE_KINDS);
|
|
364
|
+
if ("unknown" in read) {
|
|
365
|
+
// 이 버전이 모르는 종류 — 지어내지 않고 그렇다고 말한다(원문 보존).
|
|
366
|
+
return vocabulary.degradedLabel(read, en ? "en" : "ko");
|
|
367
|
+
}
|
|
368
|
+
return (en ? enWords[read.known] : ko[read.known]) || read.known;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 배선을 보여준다. 예전에는 노드를 평평한 목록으로만 찍어서, 갈림길이 어디로 갈라지는지
|
|
373
|
+
* 화면 없는 표면에서는 알 방법이 아예 없었다 — 그래프의 핵심이 배선인데 그것만 빠져 있었다.
|
|
374
|
+
*/
|
|
375
|
+
function renderGraphTree(ctx, graph, en) {
|
|
376
|
+
const nodes = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
377
|
+
const outgoing = new Map();
|
|
378
|
+
for (const edge of graph.edges ?? []) {
|
|
379
|
+
if (!outgoing.has(edge.source)) outgoing.set(edge.source, []);
|
|
380
|
+
outgoing.get(edge.source).push(edge);
|
|
381
|
+
}
|
|
382
|
+
const hasIncoming = new Set((graph.edges ?? []).map((e) => e.target));
|
|
383
|
+
const roots = graph.nodes.filter((n) => !hasIncoming.has(n.id));
|
|
384
|
+
const seen = new Set();
|
|
385
|
+
|
|
386
|
+
const walk = (nodeId, depth, branchLabel, backEdge) => {
|
|
387
|
+
const node = nodes.get(nodeId);
|
|
388
|
+
if (!node) return;
|
|
389
|
+
const indent = " ".repeat(depth + 1);
|
|
390
|
+
const prefix = branchLabel ? ctx.ui.dim(`${branchLabel} `) : "";
|
|
391
|
+
if (seen.has(nodeId)) {
|
|
392
|
+
// 되돌아가는 연결(루프). 다시 펼치면 끝나지 않으므로 되돌아간다는 사실만 말한다.
|
|
393
|
+
const cap = typeof backEdge?.maxIterations === "number" ? backEdge.maxIterations : null;
|
|
394
|
+
const capText = cap === null
|
|
395
|
+
? (en ? " — no repeat limit set, so it will refuse to run" : " — 반복 횟수가 정해져 있지 않아 실행이 거절됩니다")
|
|
396
|
+
: (en ? ` — up to ${cap} more time(s)` : ` — 최대 ${cap}바퀴까지`);
|
|
397
|
+
ctx.out(`${indent}${prefix}↩ ${ctx.ui.dim(en
|
|
398
|
+
? `back to "${node.label || node.id}"${capText}`
|
|
399
|
+
: `"${node.label || node.id}"(으)로 되돌아감${capText}`)}`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
seen.add(nodeId);
|
|
403
|
+
ctx.out(`${indent}${prefix}${nodeLine(ctx, node, en)}`);
|
|
404
|
+
if (node.type === "eval") {
|
|
405
|
+
const items = Array.isArray(node.config?.items) ? node.config.items : [];
|
|
406
|
+
for (const item of items) {
|
|
407
|
+
if (!item || typeof item.text !== "string" || !item.text.trim()) continue;
|
|
408
|
+
const mark = item.kind === "mustNot" ? (en ? "must not" : "하면 안 됨") : (en ? "must" : "있어야 함");
|
|
409
|
+
ctx.out(`${indent} ${ctx.ui.dim(`· [${mark}] ${item.text.trim()}`)}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const edges = outgoing.get(nodeId) ?? [];
|
|
413
|
+
for (const edge of edges) {
|
|
414
|
+
const handle = edge.sourceHandle;
|
|
415
|
+
const label = handle === "true"
|
|
416
|
+
? (en ? "[yes]" : "[예]")
|
|
417
|
+
: handle === "false"
|
|
418
|
+
? (en ? "[no]" : "[아니오]")
|
|
419
|
+
: "";
|
|
420
|
+
// ★들여쓰기는 **갈라질 때만** 깊어진다.
|
|
421
|
+
// 예전에는 한 줄로 이어지는 사슬에서도 매 단계 두 칸씩 밀려, 14단계짜리
|
|
422
|
+
// 그래프가 28칸 들여쓰기로 화면을 넘어갔다(실사용 실측 2026-08-06).
|
|
423
|
+
// 갈림길·실패 출구처럼 **실제로 나뉘는** 곳에서만 계층이 생겨야 사람이 읽는다.
|
|
424
|
+
const branches = edges.length > 1 || Boolean(handle);
|
|
425
|
+
walk(edge.target, branches ? depth + 1 : depth, label, edge);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
for (const root of roots) walk(root.id, 0, "", null);
|
|
430
|
+
// 어디에서도 닿지 않는 노드는 조용히 숨기지 않는다 — 만들어 놓고 안 이어진 단계다.
|
|
431
|
+
const orphans = graph.nodes.filter((n) => !seen.has(n.id));
|
|
432
|
+
if (orphans.length) {
|
|
433
|
+
ctx.out("");
|
|
434
|
+
// ★"실행되지 않는다"고 쓰면 안 된다. 들어오는 연결이 없는 단계는 **따로 시작되는 단계**로
|
|
435
|
+
// 실제로 실행된다(실측: "아무도 안 부르는 단계"가 done으로 끝났다).
|
|
436
|
+
// 화면이 실행되지 않는다고 말해 놓고 실행되면, 사용자는 그래프를 보고 결과를 예측할 수 없다.
|
|
437
|
+
ctx.out(" " + ctx.ui.dim(en
|
|
438
|
+
? "Not wired to the start — each of these starts on its own, at the same time:"
|
|
439
|
+
: "시작과 이어져 있지 않은 단계 — 각각 따로, 시작과 동시에 실행됩니다:"));
|
|
440
|
+
for (const node of orphans) ctx.out(` ${nodeLine(ctx, node, en)}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* 시작 값을 대기열에 넣는다. 데스크탑 스키마 v88의 automation_run_inputs를 쓴다.
|
|
446
|
+
* 자리가 아직 없는(구버전) 데스크탑이면 false — 값이 전달된 것처럼 말하지 않기 위해서다.
|
|
447
|
+
*/
|
|
448
|
+
function enqueueRunInput(ctx, db, automationId, payload) {
|
|
449
|
+
if (!ctx.tableExists || !ctx.tableExists(db, "automation_run_inputs")) return false;
|
|
450
|
+
try {
|
|
451
|
+
db.prepare(
|
|
452
|
+
`INSERT INTO automation_run_inputs (id, automation_id, payload_json, requested_by, created_at)
|
|
453
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
454
|
+
).run(crypto.randomUUID(), automationId, JSON.stringify(payload), "terminal", new Date().toISOString());
|
|
455
|
+
return true;
|
|
456
|
+
} catch {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function ask(rl, question) {
|
|
462
|
+
return new Promise((resolve) => rl.question(question, (answer) => resolve(String(answer || "").trim())));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function runGraph(ctx, needle, flags) {
|
|
466
|
+
const db = ctx.db();
|
|
467
|
+
const rows = graphRows(ctx, db);
|
|
468
|
+
const en = ctx.lang === "en";
|
|
469
|
+
const row = findGraph(rows, needle);
|
|
470
|
+
if (row?.ambiguous) { reportAmbiguous(ctx, needle, row.ambiguous, en); return 1; }
|
|
471
|
+
if (!row) {
|
|
472
|
+
ctx.err(en ? `No graph matches "${needle}".` : `"${needle}"와 맞는 그래프가 없습니다.`);
|
|
473
|
+
ctx.err(en ? "See what is saved with: agentlas graph list" : "저장된 목록: agentlas graph list");
|
|
474
|
+
return 1;
|
|
475
|
+
}
|
|
476
|
+
const graph = parseGraph(row);
|
|
477
|
+
const kind = triggerKind(row, graph);
|
|
478
|
+
|
|
479
|
+
if (!flags.yes && process.stdin.isTTY) {
|
|
480
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
481
|
+
try {
|
|
482
|
+
if (kind === "cron") {
|
|
483
|
+
const nextRun = row.next_run_at
|
|
484
|
+
? new Date(row.next_run_at).toLocaleString()
|
|
485
|
+
: (en ? "not scheduled" : "예약 없음");
|
|
486
|
+
const answer = await ask(rl, en
|
|
487
|
+
? `"${row.name}" runs on a schedule (next: ${nextRun}). Run it now? [y/N] `
|
|
488
|
+
: `"${row.name}"은(는) 예약 실행입니다(다음: ${nextRun}). 지금 실행할까요? [y/N] `);
|
|
489
|
+
if (!/^y(es)?$/i.test(answer)) {
|
|
490
|
+
ctx.out(en ? "Left as is." : "그대로 두었습니다.");
|
|
491
|
+
return 0;
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
const requirement = graphInputRequirement(graph, en);
|
|
495
|
+
const answer = await ask(rl, `${requirement?.label ?? (en ? "Input for this graph" : "이 그래프에 넘길 값")}: `);
|
|
496
|
+
if (!answer) {
|
|
497
|
+
ctx.err(en ? "This graph needs an input to start." : "이 그래프는 입력이 있어야 시작합니다.");
|
|
498
|
+
return 1;
|
|
499
|
+
}
|
|
500
|
+
flags.input = answer;
|
|
501
|
+
}
|
|
502
|
+
} finally {
|
|
503
|
+
rl.close();
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 시작 값이 필요한 그래프는 값 없이 요청하지 않는다. 값 없이 보내면 빈 채로 실행돼,
|
|
508
|
+
// 사용자가 요청한 적 없는 내용이 만들어진다.
|
|
509
|
+
const requirement = graphInputRequirement(graph, en);
|
|
510
|
+
if (requirement && !flags.input) {
|
|
511
|
+
ctx.err(en
|
|
512
|
+
? `"${row.name}" starts from a value you provide — ${requirement.label}.`
|
|
513
|
+
: `"${row.name}"은(는) 시작할 때 값을 받습니다 — ${requirement.label}.`);
|
|
514
|
+
ctx.err(ctx.ui.dim(en
|
|
515
|
+
? `Run it like this:\n agentlas graph run "${row.name}" --input "<${requirement.label}>"`
|
|
516
|
+
: `이렇게 실행하세요:\n agentlas graph run "${row.name}" --input "<${requirement.label}>"`));
|
|
517
|
+
return 1;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 실행 요청 = "지금 예약". 데스크탑 스케줄러가 60초 주기로 due를 집어간다.
|
|
521
|
+
const now = new Date().toISOString();
|
|
522
|
+
const updated = db.prepare(
|
|
523
|
+
"UPDATE automations SET next_run_at = ? WHERE id = ? AND enabled = 1",
|
|
524
|
+
).run(now, row.id);
|
|
525
|
+
if (updated.changes !== 1) {
|
|
526
|
+
ctx.err(en
|
|
527
|
+
? `"${row.name}" is switched off, so a run request would sit unread. Turn it on in the desktop app first.`
|
|
528
|
+
: `"${row.name}"이(가) 꺼져 있어 실행 요청이 읽히지 않습니다. 데스크탑 앱에서 먼저 켜 주세요.`);
|
|
529
|
+
return 1;
|
|
530
|
+
}
|
|
531
|
+
ctx.out(en
|
|
532
|
+
? `Requested a run of "${row.name}".`
|
|
533
|
+
: `"${row.name}" 실행을 요청했습니다.`);
|
|
534
|
+
// 여기서 "실행했습니다"라고 말하면 거짓이 된다 — 실행 주체는 데스크탑이다.
|
|
535
|
+
ctx.out(ctx.ui.dim(en
|
|
536
|
+
? "The desktop app picks this up within a minute while it is running. If it is closed, the run happens the next time you open it."
|
|
537
|
+
: "데스크탑 앱이 켜져 있으면 1분 안에 가져갑니다. 꺼져 있으면 다음에 앱을 열 때 실행됩니다."));
|
|
538
|
+
if (requirement && flags.input) {
|
|
539
|
+
// 값은 대기열에 넣는다. 다음 실행 1회가 이 값을 집어간다(한 번만 소비된다).
|
|
540
|
+
const enqueued = enqueueRunInput(ctx, db, row.id, { [requirement.varName]: flags.input });
|
|
541
|
+
if (!enqueued) {
|
|
542
|
+
ctx.err(en
|
|
543
|
+
? "The value could not be attached to this run. Update the desktop app, then try again."
|
|
544
|
+
: "이번 실행에 값을 붙이지 못했습니다. 데스크탑 앱을 업데이트한 뒤 다시 시도해 주세요.");
|
|
545
|
+
return 1;
|
|
546
|
+
}
|
|
547
|
+
ctx.out(ctx.ui.dim(en
|
|
548
|
+
? `Attached ${requirement.label}: ${flags.input}`
|
|
549
|
+
: `${requirement.label}: ${flags.input} — 이번 실행에 함께 넘겼습니다.`));
|
|
550
|
+
}
|
|
551
|
+
return 0;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* graph new — 자연어 한 문장에서 시작해, 만들 수 있을 만큼 알아낼 때까지 되묻고 그래프를 만든다.
|
|
558
|
+
*
|
|
559
|
+
* 두 가지를 코드가 강제한다(프롬프트 문구가 아니라):
|
|
560
|
+
* · 모델은 **청사진만** 말한다. 노드와 연결은 buildGraphFromBlueprint 가 짓는다.
|
|
561
|
+
* · 청사진이 검증을 못 넘으면 "완성"이 아니라 **질문**으로 되돌아간다.
|
|
562
|
+
* 그래서 실행 시각·바깥으로 나가는지·반복 상한은 지어내지지 않는다.
|
|
563
|
+
*/
|
|
564
|
+
async function newGraph(ctx, request, flags) {
|
|
565
|
+
/*
|
|
566
|
+
* 산출 언어는 **제품 언어 설정**이 정한다.
|
|
567
|
+
*
|
|
568
|
+
* 예전에는 요청에 한글이 한 글자라도 있으면 설정을 무시하고 한국어로 강제했다
|
|
569
|
+
* ("한국어로 말한 사람에게 for example이 섞이지 않게"가 의도였다). 그 결과
|
|
570
|
+
* 언어를 English로 둔 사용자가 한국어 파일명 하나만 섞어 말해도 **인터뷰만**
|
|
571
|
+
* 한국어로 뒤집히고 `graph show`·목록·오류는 영어로 남아, 같은 CLI 안에서
|
|
572
|
+
* 화면 언어가 갈렸다(실사용 실측 2026-08-06: 설정 en인데 질문 3개가 전부 한국어).
|
|
573
|
+
*
|
|
574
|
+
* 섞임을 막는 자리는 여기가 아니라 인터뷰 지시문의 PRODUCT LANGUAGE 계약이다 —
|
|
575
|
+
* 그쪽이 모델의 모든 산출 문구를 한 언어로 고정한다.
|
|
576
|
+
*/
|
|
577
|
+
const en = ctx.lang === "en";
|
|
578
|
+
const db = ctx.db();
|
|
579
|
+
if (!request) {
|
|
580
|
+
ctx.err(en
|
|
581
|
+
? 'Say what you want run for you, in your own words.\n agentlas graph new "weekday mornings at 8, pull three blog topics"'
|
|
582
|
+
: '자동으로 돌릴 일을 그대로 적어 주세요.\n agentlas graph new "평일 아침 8시에 블로그 글감 세 개 뽑아줘"');
|
|
583
|
+
return 1;
|
|
584
|
+
}
|
|
585
|
+
// 파이프로 답을 넣어도 된다 — 답이 떨어지면 무엇이 더 필요했는지 말하고 멈춘다.
|
|
586
|
+
// (조용히 기본값으로 채우면, 사용자가 정한 적 없는 자동화가 만들어진다.)
|
|
587
|
+
const piped = !process.stdin.isTTY;
|
|
588
|
+
|
|
589
|
+
const interview = require("../graph/interview.cjs");
|
|
590
|
+
const { askModel } = require("../graph/ask-model.cjs");
|
|
591
|
+
let state = interview.startInterview(request);
|
|
592
|
+
|
|
593
|
+
// 파이프로 들어온 답은 **미리 전부 읽어 둔다.** readline은 입력 스트림이 끝나면 닫히므로,
|
|
594
|
+
// 질문마다 물으면 두 번째 질문에서 "readline was closed"로 죽는다(실측).
|
|
595
|
+
const queued = piped ? await readAllLines() : [];
|
|
596
|
+
let queueAt = 0;
|
|
597
|
+
const rl = piped ? null : readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
598
|
+
const nextAnswer = async (promptText) => {
|
|
599
|
+
if (piped) return queueAt < queued.length ? queued[queueAt++] : "";
|
|
600
|
+
return ask(rl, promptText);
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
ctx.out(ctx.ui.dim(en
|
|
605
|
+
? "Working out what to build. I will ask only what is not mine to decide."
|
|
606
|
+
: "무엇을 만들지 정리합니다. 임의로 정하면 안 되는 것만 여쭙겠습니다."));
|
|
607
|
+
|
|
608
|
+
let built = null;
|
|
609
|
+
let announcedFallback = false;
|
|
610
|
+
for (let round = 0; round < interview.MAX_INTERVIEW_ROUNDS; round += 1) {
|
|
611
|
+
const answer = await askModel(ctx, interview.buildInterviewPrompt(state, en ? "en" : "ko"), {});
|
|
612
|
+
if (!answer.ok) {
|
|
613
|
+
ctx.err(answer.reason);
|
|
614
|
+
ctx.err(ctx.ui.dim(answer.nextAction));
|
|
615
|
+
return 1;
|
|
616
|
+
}
|
|
617
|
+
// 고른 런타임이 안 돌아 다른 것으로 넘어갔으면 말한다 — 조용히 바꾸면
|
|
618
|
+
// 사용자는 자기가 고른 모델이 만든 줄 안다.
|
|
619
|
+
if (answer.fellBackFrom && !announcedFallback) {
|
|
620
|
+
announcedFallback = true;
|
|
621
|
+
ctx.out(ctx.ui.dim(en
|
|
622
|
+
? `${answer.fellBackFrom} did not answer, so ${answer.runtime} is building this.`
|
|
623
|
+
: `${answer.fellBackFrom}이(가) 응답하지 않아 ${answer.runtime}(으)로 진행합니다.`));
|
|
624
|
+
}
|
|
625
|
+
const parsed = interview.parseInterviewTurn(answer.text, state);
|
|
626
|
+
if (!parsed.ok) {
|
|
627
|
+
ctx.err(parsed.reason);
|
|
628
|
+
ctx.err(ctx.ui.dim(parsed.nextAction));
|
|
629
|
+
return 1;
|
|
630
|
+
}
|
|
631
|
+
// ★모델이 형식을 틀렸다 — 사람이 답을 안 준 게 아니다. 무엇이 틀렸는지 돌려주고
|
|
632
|
+
// 정해진 횟수만큼 스스로 고치게 한다. "구체적으로 적어 주세요"로 떠넘기면
|
|
633
|
+
// 막다른 길이 된다: 무엇이 틀렸는지 사람은 모르고, 우리는 안다.
|
|
634
|
+
if (parsed.turn.kind === "retry") {
|
|
635
|
+
state.attempts = [...(state.attempts || []), {
|
|
636
|
+
round, problems: parsed.turn.problems,
|
|
637
|
+
stepCount: parsed.turn.stepCount, triggerKind: parsed.turn.triggerKind,
|
|
638
|
+
}];
|
|
639
|
+
if ((state.attempts || []).length > interview.MAX_SELF_CORRECTIONS) {
|
|
640
|
+
const tried = [...new Set(state.attempts.flatMap((a) => a.problems))].slice(0, 3);
|
|
641
|
+
ctx.err(en
|
|
642
|
+
? `Tried ${interview.MAX_SELF_CORRECTIONS + 1} times and kept hitting the same wall: ${tried.join(" / ")}`
|
|
643
|
+
: `${interview.MAX_SELF_CORRECTIONS + 1}번 다시 만들어 봤지만 같은 자리에서 막혔습니다: ${tried.join(" / ")}`);
|
|
644
|
+
ctx.err(ctx.ui.dim(en
|
|
645
|
+
? "Describe it differently, or build it on the desktop canvas."
|
|
646
|
+
: "만들고 싶은 것을 다른 말로 적어 주시거나, 데스크탑 캔버스에서 직접 만들어 보세요."));
|
|
647
|
+
return 1;
|
|
648
|
+
}
|
|
649
|
+
ctx.out(ctx.ui.dim(en ? "Fixing what didn't fit and trying again…" : "맞지 않는 부분을 고쳐 다시 만드는 중…"));
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
if (parsed.turn.kind === "blueprint") {
|
|
653
|
+
built = interview.buildGraphFromBlueprint(parsed.turn.blueprint, en ? "en" : "ko");
|
|
654
|
+
if (!built.ok) {
|
|
655
|
+
// 청사진 검증은 통과했는데 짓는 데서 걸렸다 — 이것도 형식 문제라 같은 규율.
|
|
656
|
+
state.attempts = [...(state.attempts || []), { round, problems: built.problems.map((p) => p.reason) }];
|
|
657
|
+
if ((state.attempts || []).length <= interview.MAX_SELF_CORRECTIONS) {
|
|
658
|
+
ctx.out(ctx.ui.dim(en ? "Fixing what didn't fit and trying again…" : "맞지 않는 부분을 고쳐 다시 만드는 중…"));
|
|
659
|
+
built = null;
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
ctx.err(en ? "Could not build it after all:" : "끝내 만들지 못했습니다:");
|
|
663
|
+
for (const p of built.problems) ctx.err(` · ${p.reason}`);
|
|
664
|
+
return 1;
|
|
665
|
+
}
|
|
666
|
+
built.blueprint = parsed.turn.blueprint;
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 질문 — 하나씩 묻는다. 한꺼번에 쏟으면 사람이 답을 포기한다.
|
|
671
|
+
// 끝이 안 보이면 도중에 그만두므로 몇 바퀴째인지 함께 보여준다.
|
|
672
|
+
ctx.out("");
|
|
673
|
+
ctx.out(ctx.ui.dim(en
|
|
674
|
+
? `Not mine to decide (round ${round + 1} of ${interview.MAX_INTERVIEW_ROUNDS}):`
|
|
675
|
+
: `임의로 정하면 안 되는 항목입니다 (${round + 1}번째 / 최대 ${interview.MAX_INTERVIEW_ROUNDS}번):`));
|
|
676
|
+
const given = [];
|
|
677
|
+
for (const q of parsed.turn.questions) {
|
|
678
|
+
ctx.out(ctx.ui.bold(` ${q.question}`));
|
|
679
|
+
if (q.why) ctx.out(ctx.ui.dim(` ${q.why}`));
|
|
680
|
+
if (q.choices && q.choices.length) {
|
|
681
|
+
ctx.out(ctx.ui.dim(` ${en ? "for example" : "예를 들면"} — ${q.choices.join(" / ")}`));
|
|
682
|
+
}
|
|
683
|
+
const text = await nextAnswer(" > ");
|
|
684
|
+
if (!text) {
|
|
685
|
+
ctx.err("");
|
|
686
|
+
ctx.err(en
|
|
687
|
+
? `Stopped here without an answer to: ${q.question}`
|
|
688
|
+
: `답을 받지 못해 여기서 멈췄습니다: ${q.question}`);
|
|
689
|
+
ctx.err(ctx.ui.dim(en
|
|
690
|
+
? "Nothing was saved. Answer \"you decide\" and I take the safest option and name what I chose.\n"
|
|
691
|
+
+ "The run time, anything that goes outside, and repeat limits I keep asking about."
|
|
692
|
+
: "저장된 것은 없습니다. 판단이 서지 않으면 \"알아서 해주세요\"라고 답해 주시면\n"
|
|
693
|
+
+ "가장 안전한 쪽으로 정하고 무엇을 골랐는지 알려 드립니다.\n"
|
|
694
|
+
+ "다만 실행 시각, 바깥으로 나가는 동작, 반복 횟수는 계속 여쭙니다."));
|
|
695
|
+
return 1;
|
|
696
|
+
}
|
|
697
|
+
if (piped) ctx.out(` > ${text}`);
|
|
698
|
+
given.push({ questionId: q.id, question: q.question, answer: text });
|
|
699
|
+
ctx.out("");
|
|
700
|
+
}
|
|
701
|
+
state = interview.recordAnswers(state, given);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (!built) {
|
|
705
|
+
ctx.err(en
|
|
706
|
+
? "I asked as much as I should and still could not pin it down."
|
|
707
|
+
: "여쭤볼 만큼 여쭤봤는데도 정하지 못했습니다.");
|
|
708
|
+
ctx.err(ctx.ui.dim(en
|
|
709
|
+
? "Try describing it in smaller pieces, one automation at a time."
|
|
710
|
+
: "한 번에 하나씩, 더 작게 나눠서 말씀해 주시면 다시 해보겠습니다."));
|
|
711
|
+
return 1;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// 저장 전에 만든 것을 보여주고 확인을 받는다. 자동화는 사람이 없는 동안 도는 것이라
|
|
715
|
+
// "만들어 뒀습니다"로 끝내면 안 된다.
|
|
716
|
+
const bp = built.blueprint;
|
|
717
|
+
ctx.out("");
|
|
718
|
+
ctx.out(ctx.ui.bold(bp.name));
|
|
719
|
+
ctx.out(ctx.ui.dim(` ${bp.goal}`));
|
|
720
|
+
ctx.out("");
|
|
721
|
+
renderGraphTree(ctx, built.graph, en);
|
|
722
|
+
const mutations = built.graph.nodes.filter((n) => n.config && n.config.effect === "mutation");
|
|
723
|
+
if (mutations.length) {
|
|
724
|
+
ctx.out("");
|
|
725
|
+
ctx.out(en ? "Steps that go outside (locked to ask first):" : "바깥으로 나가는 단계 (실행 전에 확인하도록 잠급니다):");
|
|
726
|
+
for (const n of mutations) ctx.out(` · ${n.label}`);
|
|
727
|
+
}
|
|
728
|
+
ctx.out("");
|
|
729
|
+
// 갈림길 방향은 코드가 검증할 수 없다 — 사람이 읽고 답해야 한다.
|
|
730
|
+
// 실측: 만들어진 갈림길 3개가 전부 거꾸로였고, 그림을 안 보면 알 수 없었다.
|
|
731
|
+
const branchLines = interview.describeBranches(bp, en ? "en" : "ko");
|
|
732
|
+
if (branchLines.length) {
|
|
733
|
+
ctx.out("");
|
|
734
|
+
ctx.out(en ? "Check the branches — is this the right way round?" : "갈림길이 이 방향이 맞나요?");
|
|
735
|
+
for (const line of branchLines) ctx.out(` ${line}`);
|
|
736
|
+
}
|
|
737
|
+
ctx.out("");
|
|
738
|
+
ctx.out(built.triggerType === "schedule"
|
|
739
|
+
? (en ? `Runs ${interview.humanSchedule(built.scheduleHuman, "en")}` : `${interview.humanSchedule(built.scheduleHuman, "ko")}에 실행`)
|
|
740
|
+
: (en ? "Runs only when you give it a value." : "값을 넣을 때만 실행합니다."));
|
|
741
|
+
|
|
742
|
+
if (!flags.yes) {
|
|
743
|
+
const confirm = await nextAnswer(en ? "\nSave this? [Y/n] " : "\n이대로 저장할까요? [Y/n] ");
|
|
744
|
+
if (/^n(o)?$/i.test(confirm)) {
|
|
745
|
+
ctx.out(en ? "Nothing was saved." : "저장하지 않았습니다.");
|
|
746
|
+
return 0;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// 이름이 겹치면 덮어쓰지 않는다.
|
|
751
|
+
const existing = graphRows(ctx, db).find((row) => row.name === bp.name);
|
|
752
|
+
const name = existing ? `${bp.name} (2)` : bp.name;
|
|
753
|
+
const target = pickDefaultAgent(ctx, db);
|
|
754
|
+
const id = `graph-${crypto.randomUUID()}`;
|
|
755
|
+
try {
|
|
756
|
+
db.prepare(
|
|
757
|
+
`INSERT INTO automations
|
|
758
|
+
(id, name, schedule, target_type, target_id, prompt_template, enabled, created_by, created_at, next_run_at, graph_json)
|
|
759
|
+
VALUES (?, ?, ?, 'agent', ?, ?, 0, 'user', ?, NULL, ?)`,
|
|
760
|
+
).run(id, name, built.scheduleHuman, target, name, new Date().toISOString(), JSON.stringify(built.graph));
|
|
761
|
+
} catch (err) {
|
|
762
|
+
ctx.err(en ? `Could not save: ${err.message}` : `저장하지 못했습니다: ${err.message}`);
|
|
763
|
+
return 1;
|
|
764
|
+
}
|
|
765
|
+
ctx.out("");
|
|
766
|
+
ctx.out(en
|
|
767
|
+
? `Saved "${name}". Switched off, so it does not run until you turn it on.`
|
|
768
|
+
: `"${name}" 저장했습니다. 꺼진 상태라 직접 켜기 전에는 돌지 않습니다.`);
|
|
769
|
+
if (existing) {
|
|
770
|
+
ctx.out(ctx.ui.dim(en
|
|
771
|
+
? `An automation named "${bp.name}" already existed, so this one was saved as "${name}".`
|
|
772
|
+
: `"${bp.name}" 이름이 이미 있어서 "${name}"(으)로 저장했습니다.`));
|
|
773
|
+
}
|
|
774
|
+
ctx.out(ctx.ui.dim(en ? "Look it over:" : "내용 확인:"));
|
|
775
|
+
ctx.out(` agentlas graph show "${name}"`);
|
|
776
|
+
ctx.out(ctx.ui.dim(en ? "Turn it on when it looks right:" : "확인 뒤 켜기:"));
|
|
777
|
+
ctx.out(` agentlas automation on ${id}`);
|
|
778
|
+
return 0;
|
|
779
|
+
} finally {
|
|
780
|
+
if (rl) rl.close();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/** 파이프로 들어온 답을 전부 읽는다. */
|
|
785
|
+
function readAllLines() {
|
|
786
|
+
return new Promise((resolve) => {
|
|
787
|
+
let buf = "";
|
|
788
|
+
process.stdin.setEncoding("utf8");
|
|
789
|
+
process.stdin.on("data", (chunk) => { buf += chunk; });
|
|
790
|
+
process.stdin.on("end", () => resolve(buf.split(/\r?\n/).map((l) => l.trim())));
|
|
791
|
+
process.stdin.on("error", () => resolve([]));
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** 노드가 대상을 선언하지 않으면 자동화의 대상 에이전트를 상속한다. 없으면 기본 오케스트레이터. */
|
|
796
|
+
function pickDefaultAgent(ctx, db) {
|
|
797
|
+
try {
|
|
798
|
+
if (!ctx.tableExists(db, "installed_agents")) return "builtin-agentlas-orchestrator";
|
|
799
|
+
const row = db.prepare(
|
|
800
|
+
"SELECT id FROM installed_agents WHERE id = 'builtin-agentlas-orchestrator' LIMIT 1",
|
|
801
|
+
).get();
|
|
802
|
+
if (row) return row.id;
|
|
803
|
+
const any = db.prepare("SELECT id FROM installed_agents ORDER BY installed_at LIMIT 1").get();
|
|
804
|
+
return (any && any.id) || "builtin-agentlas-orchestrator";
|
|
805
|
+
} catch {
|
|
806
|
+
return "builtin-agentlas-orchestrator";
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function exportGraph(ctx, needle, outPath) {
|
|
811
|
+
const db = ctx.db();
|
|
812
|
+
const rows = graphRows(ctx, db);
|
|
813
|
+
const en = ctx.lang === "en";
|
|
814
|
+
const row = findGraph(rows, needle);
|
|
815
|
+
if (row?.ambiguous) { reportAmbiguous(ctx, needle, row.ambiguous, en); return 1; }
|
|
816
|
+
if (!row) {
|
|
817
|
+
ctx.err(en ? `No graph matches "${needle}".` : `"${needle}"와 맞는 그래프가 없습니다.`);
|
|
818
|
+
return 1;
|
|
819
|
+
}
|
|
820
|
+
const graph = parseGraph(row);
|
|
821
|
+
if (!graph) {
|
|
822
|
+
ctx.err(en
|
|
823
|
+
? "This automation has no visual graph to export yet."
|
|
824
|
+
: "이 자동화에는 아직 내보낼 시각 그래프가 없습니다.");
|
|
825
|
+
return 1;
|
|
826
|
+
}
|
|
827
|
+
const built = pkgLib.buildPackage({ automation: row, graph });
|
|
828
|
+
if (built.blocked) {
|
|
829
|
+
// 지울 수 없는 비밀이 남았는데 내보내면, 사용자는 빠진 줄 알고 공유한다.
|
|
830
|
+
ctx.err(en
|
|
831
|
+
? `Export stopped: ${built.blockers.length} value(s) look like credentials and cannot be blanked automatically.`
|
|
832
|
+
: `내보내기를 멈췄습니다: 자격증명처럼 보이는 값 ${built.blockers.length}건을 자동으로 빈칸 처리할 수 없습니다.`);
|
|
833
|
+
for (const blocker of built.blockers) {
|
|
834
|
+
ctx.err(` · ${blocker.nodeId}.${blocker.field} — ${blocker.reason}`);
|
|
835
|
+
ctx.err(` ${blocker.nextAction}`);
|
|
836
|
+
}
|
|
837
|
+
return 1;
|
|
838
|
+
}
|
|
839
|
+
// 기본 파일 이름은 자동화마다 달라야 한다. 예전에는 언제나 graph.agentgraph.json 이라
|
|
840
|
+
// 두 번째 내보내기가 **말없이 첫 번째를 덮어썼다**(실측: 먼저 뽑은 것이 사라졌다).
|
|
841
|
+
const safeName = String(built.package.manifest.name || "graph")
|
|
842
|
+
.replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").trim().slice(0, 60) || "graph";
|
|
843
|
+
const target = path.resolve(outPath || `${safeName}.agentgraph.json`);
|
|
844
|
+
if (!outPath && fs.existsSync(target)) {
|
|
845
|
+
ctx.err(en
|
|
846
|
+
? `${target} already exists. Pass a file name to write somewhere else:\n agentlas graph export "<name>" <file>`
|
|
847
|
+
: `${target} 파일이 이미 있습니다. 덮어쓰지 않았습니다. 다른 이름을 주세요:\n agentlas graph export "<이름>" <파일>`);
|
|
848
|
+
return 1;
|
|
849
|
+
}
|
|
850
|
+
fs.writeFileSync(target, JSON.stringify(built.package, null, 2) + "\n", "utf8");
|
|
851
|
+
// 전체 경로를 보여준다 — 어디에 저장됐는지 모르면 친구에게 보낼 수가 없다.
|
|
852
|
+
ctx.out(en ? `Wrote ${target}` : `저장했습니다: ${target}`);
|
|
853
|
+
const findings = built.findings;
|
|
854
|
+
// 뽑을 때마다 "친구에게 보내도 되는가"에 답한다. 예전에는 파일을 직접 열어
|
|
855
|
+
// scrubReport 같은 영어 필드를 해독해야만 알 수 있었다(실측).
|
|
856
|
+
if (findings.length) {
|
|
857
|
+
ctx.out(ctx.ui.dim(en ? "Removed before packaging:" : "패키징 전에 지운 것:"));
|
|
858
|
+
for (const f of findings) ctx.out(ctx.ui.dim(` · ${f.nodeId}.${f.field} — ${f.rule} (${f.action})`));
|
|
859
|
+
} else {
|
|
860
|
+
ctx.out(ctx.ui.dim(en
|
|
861
|
+
? "Checked for passwords, keys and personal paths — none were found in this graph."
|
|
862
|
+
: "비밀번호·키·개인 경로가 있는지 훑었고, 이 그래프에는 없었습니다."));
|
|
863
|
+
}
|
|
864
|
+
const blanks = built.package.manifest.vaultTemplate;
|
|
865
|
+
if (blanks.length) {
|
|
866
|
+
ctx.out(en ? "Whoever installs this must fill:" : "받는 사람이 채워야 하는 것:");
|
|
867
|
+
for (const b of blanks) ctx.out(` · ${b.key}`);
|
|
868
|
+
}
|
|
869
|
+
const mutations = built.package.manifest.permissionsSummary.mutationNodes;
|
|
870
|
+
if (mutations.length) {
|
|
871
|
+
ctx.out(en ? "Steps that change something outside:" : "바깥을 바꾸는 단계:");
|
|
872
|
+
for (const m of mutations) ctx.out(` · ${m.label}`);
|
|
873
|
+
}
|
|
874
|
+
return 0;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function inspectPackage(ctx, filePath) {
|
|
878
|
+
const en = ctx.lang === "en";
|
|
879
|
+
let parsed;
|
|
880
|
+
try {
|
|
881
|
+
parsed = JSON.parse(fs.readFileSync(path.resolve(filePath), "utf8"));
|
|
882
|
+
} catch (err) {
|
|
883
|
+
// 이름을 넣은 것이 거의 확실하면 ENOENT를 그대로 던지지 않는다. 사용자는
|
|
884
|
+
// show/run/export 를 전부 이름으로 썼기 때문에 여기도 이름일 거라고 생각한다(실측 3/4명).
|
|
885
|
+
const looksLikeName = !String(filePath).includes("/") && !/\.(json|agentgraph)$/i.test(String(filePath));
|
|
886
|
+
if (looksLikeName && err && err.code === "ENOENT") {
|
|
887
|
+
ctx.err(en
|
|
888
|
+
? `This command reads a package file, not a saved automation. To look at "${filePath}" that is already saved, use:\n agentlas graph show "${filePath}"`
|
|
889
|
+
: `이 명령은 저장된 자동화가 아니라 **패키지 파일**을 읽습니다. 이미 저장된 "${filePath}"을(를) 보려면:\n agentlas graph show "${filePath}"`);
|
|
890
|
+
return 1;
|
|
891
|
+
}
|
|
892
|
+
ctx.err(en ? `Could not read ${filePath}: ${err.message}` : `${filePath}을(를) 읽지 못했습니다: ${err.message}`);
|
|
893
|
+
return 1;
|
|
894
|
+
}
|
|
895
|
+
const problems = pkgLib.verifyPackage(parsed);
|
|
896
|
+
if (problems.length) {
|
|
897
|
+
ctx.err(en ? "This package cannot be used:" : "이 패키지는 사용할 수 없습니다:");
|
|
898
|
+
for (const problem of problems) ctx.err(` · ${problem}`);
|
|
899
|
+
return 1;
|
|
900
|
+
}
|
|
901
|
+
const manifest = parsed.manifest;
|
|
902
|
+
ctx.out(`${ctx.ui.bold(manifest.name)} ${ctx.ui.dim(`${manifest.version} · ${parsed.graph.nodes.length} ${en ? "steps" : "단계"}`)}`);
|
|
903
|
+
const checklist = pkgLib.bindingChecklist(parsed);
|
|
904
|
+
if (!checklist.length) {
|
|
905
|
+
ctx.out(en ? "Nothing to fill in — it can run as is." : "채울 것이 없습니다 — 그대로 실행할 수 있습니다.");
|
|
906
|
+
} else {
|
|
907
|
+
ctx.out(en ? "Before it can run, fill in:" : "실행하려면 먼저 채워야 합니다:");
|
|
908
|
+
for (const item of checklist) {
|
|
909
|
+
if (item.kind === "vault-key") ctx.out(` · ${en ? "key" : "키"} ${item.key}`);
|
|
910
|
+
else if (item.kind === "agent") ctx.out(` · ${en ? "agent" : "에이전트"} ${item.slug}${item.source === "hub" ? ctx.ui.dim(en ? " (borrowed from the network)" : " (네트워크에서 빌림)") : ""}`);
|
|
911
|
+
else ctx.out(` · MCP ${item.serverSlug}`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const mutations = manifest.permissionsSummary?.mutationNodes ?? [];
|
|
915
|
+
if (mutations.length) {
|
|
916
|
+
ctx.out(en ? "It changes things outside at:" : "바깥을 바꾸는 지점:");
|
|
917
|
+
for (const m of mutations) ctx.out(` · ${m.label}`);
|
|
918
|
+
}
|
|
919
|
+
// 설치는 아직 데스크탑이 소유한다 — 여기서 "설치했다"고 말하지 않는다.
|
|
920
|
+
ctx.out(ctx.ui.dim(en
|
|
921
|
+
? "This command only reads the file. Install it with: agentlas graph install <file>"
|
|
922
|
+
: "패키지 설치는 데스크탑 앱에서 합니다. 이 명령은 읽기만 합니다."));
|
|
923
|
+
return 0;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function installPackage(ctx, filePath, flags = {}) {
|
|
927
|
+
const en = ctx.lang === "en";
|
|
928
|
+
const db = ctx.db();
|
|
929
|
+
let parsed;
|
|
930
|
+
try {
|
|
931
|
+
parsed = JSON.parse(fs.readFileSync(path.resolve(filePath), "utf8"));
|
|
932
|
+
} catch (err) {
|
|
933
|
+
// 이름을 넣은 것이 거의 확실하면 ENOENT를 그대로 던지지 않는다. 사용자는
|
|
934
|
+
// show/run/export 를 전부 이름으로 썼기 때문에 여기도 이름일 거라고 생각한다(실측 3/4명).
|
|
935
|
+
const looksLikeName = !String(filePath).includes("/") && !/\.(json|agentgraph)$/i.test(String(filePath));
|
|
936
|
+
if (looksLikeName && err && err.code === "ENOENT") {
|
|
937
|
+
ctx.err(en
|
|
938
|
+
? `This command reads a package file, not a saved automation. To look at "${filePath}" that is already saved, use:\n agentlas graph show "${filePath}"`
|
|
939
|
+
: `이 명령은 저장된 자동화가 아니라 **패키지 파일**을 읽습니다. 이미 저장된 "${filePath}"을(를) 보려면:\n agentlas graph show "${filePath}"`);
|
|
940
|
+
return 1;
|
|
941
|
+
}
|
|
942
|
+
ctx.err(en ? `Could not read ${filePath}: ${err.message}` : `${filePath}을(를) 읽지 못했습니다: ${err.message}`);
|
|
943
|
+
return 1;
|
|
944
|
+
}
|
|
945
|
+
const problems = pkgLib.verifyPackage(parsed);
|
|
946
|
+
if (problems.length) {
|
|
947
|
+
ctx.err(en ? "This package cannot be installed:" : "이 패키지는 설치할 수 없습니다:");
|
|
948
|
+
for (const problem of problems) ctx.err(` · ${problem}`);
|
|
949
|
+
return 1;
|
|
950
|
+
}
|
|
951
|
+
const manifest = parsed.manifest;
|
|
952
|
+
// --name 을 주면 그 이름으로 나란히 설치한다. 이 옵션이 없으면 사용자는 같은 자동화를
|
|
953
|
+
// 두 벌 가질 방법이 없어, JSON을 손으로 고치는 수밖에 없었다(실측).
|
|
954
|
+
const installName = typeof flags.name === "string" && flags.name.trim()
|
|
955
|
+
? flags.name.trim()
|
|
956
|
+
: manifest.name;
|
|
957
|
+
const existing = graphRows(ctx, db).find((row) => row.name === installName);
|
|
958
|
+
if (existing) {
|
|
959
|
+
ctx.err(en
|
|
960
|
+
? `An automation named "${installName}" already exists, and this command never overwrites your work.`
|
|
961
|
+
: `"${installName}" 이름의 자동화가 이미 있습니다. 이 명령은 기존 작업을 덮어쓰지 않습니다.`);
|
|
962
|
+
ctx.err(ctx.ui.dim(en
|
|
963
|
+
? `Install it alongside the existing one with a different name:\n agentlas graph install <file> --name "${installName} (2)"`
|
|
964
|
+
: `다른 이름으로 나란히 설치하려면:\n agentlas graph install <파일> --name "${installName} (2)"`));
|
|
965
|
+
return 1;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// 받는 사람 계정에는 아직 아무것도 채워지지 않았다. 켜진 채로 설치하면
|
|
969
|
+
// 빈 금고·없는 에이전트로 첫 스케줄에 바로 실패한다 — 꺼진 채로 들어온다(D14).
|
|
970
|
+
const checklist = pkgLib.bindingChecklist(parsed);
|
|
971
|
+
const target = manifest.dependencies?.agents?.[0];
|
|
972
|
+
// 실행 시각을 지어내면 보낸 사람과 **다른 시각에 도는** 자동화가 된다.
|
|
973
|
+
// 시각이 안 실려 온 패키지는 시각 없이 설치하고, 사람이 정하라고 말한다.
|
|
974
|
+
const packagedSchedule = typeof manifest.trigger?.schedule === "string" && manifest.trigger.schedule.trim()
|
|
975
|
+
? manifest.trigger.schedule.trim()
|
|
976
|
+
: null;
|
|
977
|
+
const now = new Date().toISOString();
|
|
978
|
+
const id = `graph-${crypto.randomUUID()}`;
|
|
979
|
+
try {
|
|
980
|
+
db.prepare(
|
|
981
|
+
`INSERT INTO automations
|
|
982
|
+
(id, name, schedule, target_type, target_id, prompt_template, enabled, created_by, created_at, next_run_at, graph_json)
|
|
983
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, 'user', ?, NULL, ?)`,
|
|
984
|
+
).run(
|
|
985
|
+
id,
|
|
986
|
+
installName,
|
|
987
|
+
packagedSchedule ?? "unscheduled",
|
|
988
|
+
target?.source === "hub" ? "hub" : "agent",
|
|
989
|
+
target?.slug || "builtin-agentlas-orchestrator",
|
|
990
|
+
installName,
|
|
991
|
+
now,
|
|
992
|
+
JSON.stringify(parsed.graph),
|
|
993
|
+
);
|
|
994
|
+
} catch (err) {
|
|
995
|
+
ctx.err(en ? `Install failed: ${err.message}` : `설치하지 못했습니다: ${err.message}`);
|
|
996
|
+
return 1;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
ctx.out(en
|
|
1000
|
+
? `Installed "${installName}" — switched off, so nothing runs yet.`
|
|
1001
|
+
: `"${installName}"을(를) 설치했습니다 — 꺼진 상태라 아직 아무것도 돌지 않습니다.`);
|
|
1002
|
+
if (!packagedSchedule && triggerKindOfManifest(manifest) === "cron") {
|
|
1003
|
+
ctx.out(en
|
|
1004
|
+
? "This package did not carry a run time, so none was set. Pick one in the desktop app before switching it on."
|
|
1005
|
+
: "이 패키지에는 실행 시각이 실려 있지 않아 시각을 정하지 않았습니다. 데스크탑 앱에서 시각을 정한 뒤 켜세요.");
|
|
1006
|
+
} else if (packagedSchedule) {
|
|
1007
|
+
const sched = require("../graph/interview.cjs").humanSchedule;
|
|
1008
|
+
ctx.out(ctx.ui.dim(en ? `Runs ${sched(packagedSchedule, "en")}` : `${sched(packagedSchedule, "ko")}에 실행`));
|
|
1009
|
+
}
|
|
1010
|
+
// 이미 가진 것까지 "채우라"고 하면, 사용자는 채울 수 없는 항목을 앞에 두고 멈춘다.
|
|
1011
|
+
// (실측: 원본과 똑같은 에이전트를 쓰는 사본인데도 그 에이전트를 채우라고 요구했고,
|
|
1012
|
+
// 아무것도 안 채운 채 켜니 그냥 돌았다 — 요구 자체가 거짓이었다.)
|
|
1013
|
+
const missing = checklist.filter((item) => {
|
|
1014
|
+
if (item.kind !== "agent") return true;
|
|
1015
|
+
try {
|
|
1016
|
+
if (!ctx.tableExists(db, "installed_agents")) return true;
|
|
1017
|
+
const owned = db.prepare("SELECT 1 FROM installed_agents WHERE id = ? OR slug = ? LIMIT 1")
|
|
1018
|
+
.get(item.slug, item.slug);
|
|
1019
|
+
return !owned;
|
|
1020
|
+
} catch {
|
|
1021
|
+
return true;
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
if (missing.length) {
|
|
1025
|
+
ctx.out(en ? "Missing on this computer — add these in the desktop app, then switch it on:" : "이 컴퓨터에 없는 것 — 데스크탑 앱에서 아래를 채운 뒤 켜세요:");
|
|
1026
|
+
for (const item of missing) {
|
|
1027
|
+
if (item.kind === "vault-key") ctx.out(` · ${en ? "key" : "키"} ${item.key}`);
|
|
1028
|
+
else if (item.kind === "agent") ctx.out(` · ${en ? "agent" : "에이전트"} ${item.slug}${item.source === "hub" ? ctx.ui.dim(en ? " (borrowed from the network — costs credits)" : " (네트워크에서 빌림 — 크레딧 소모)") : ""}`);
|
|
1029
|
+
else ctx.out(` · MCP ${item.serverSlug}`);
|
|
1030
|
+
}
|
|
1031
|
+
} else if (checklist.length) {
|
|
1032
|
+
ctx.out(ctx.ui.dim(en
|
|
1033
|
+
? "Everything it needs is already on this computer."
|
|
1034
|
+
: "이 자동화가 쓰는 것은 이미 이 컴퓨터에 다 있습니다."));
|
|
1035
|
+
}
|
|
1036
|
+
const mutations = manifest.permissionsSummary?.mutationNodes ?? [];
|
|
1037
|
+
if (mutations.length) {
|
|
1038
|
+
ctx.out(en ? "It changes things outside at:" : "바깥을 바꾸는 지점:");
|
|
1039
|
+
for (const m of mutations) ctx.out(` · ${m.label}`);
|
|
1040
|
+
ctx.out(ctx.ui.dim(en
|
|
1041
|
+
? "Those steps stop and ask before they run unless you set them to automatic."
|
|
1042
|
+
: "그 단계들은 자동 허용으로 바꾸지 않는 한 실행 전에 멈추고 묻습니다."));
|
|
1043
|
+
}
|
|
1044
|
+
ctx.out(ctx.ui.dim(en
|
|
1045
|
+
? "Nothing runs until you switch it on. The desktop app can simulate it first — the terminal cannot."
|
|
1046
|
+
: "켜기 전에는 아무것도 실행되지 않습니다. 실제로 나가지 않는 시뮬레이션은 데스크탑 앱에서만 됩니다."));
|
|
1047
|
+
return 0;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function run(ctx, args = []) {
|
|
1051
|
+
const en = ctx.lang === "en";
|
|
1052
|
+
// --input "<값>" 은 값을 하나 받는 플래그다. 값까지 함께 걷어내지 않으면
|
|
1053
|
+
// 그 값이 그래프 이름의 일부로 붙어 "맞는 그래프가 없다"는 엉뚱한 실패가 된다.
|
|
1054
|
+
const rest = [];
|
|
1055
|
+
const flags = { yes: false };
|
|
1056
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
1057
|
+
const arg = args[i];
|
|
1058
|
+
if (arg === "-y" || arg === "--yes") { flags.yes = true; continue; }
|
|
1059
|
+
if (arg === "--input" || arg === "-i") { flags.input = String(args[i + 1] ?? "").trim(); i += 1; continue; }
|
|
1060
|
+
if (arg.startsWith("--input=")) { flags.input = arg.slice("--input=".length).trim(); continue; }
|
|
1061
|
+
// ★--name 도 값을 하나 받는 플래그다. 걷어내지 않으면 그 값이 파일 경로에 붙어
|
|
1062
|
+
// "그런 파일 없음"으로 죽는다 — `graph help`가 문법으로 광고하는 옵션인데
|
|
1063
|
+
// 실제로는 한 번도 동작한 적이 없었다(실사용 실측 2026-08-06).
|
|
1064
|
+
if (arg === "--name" || arg === "-n") { flags.name = String(args[i + 1] ?? "").trim(); i += 1; continue; }
|
|
1065
|
+
if (arg.startsWith("--name=")) { flags.name = arg.slice("--name=".length).trim(); continue; }
|
|
1066
|
+
rest.push(arg);
|
|
1067
|
+
}
|
|
1068
|
+
const sub = (rest[0] || "list").toLowerCase();
|
|
1069
|
+
const target = rest.slice(1).join(" ").trim();
|
|
1070
|
+
|
|
1071
|
+
if (sub === "help" || sub === "--help" || sub === "-h" || sub === "?") {
|
|
1072
|
+
ctx.out(ctx.ui.bold(en ? "agentlas graph — saved automations" : "agentlas graph — 저장된 자동화"));
|
|
1073
|
+
ctx.out(en ? ' new "<what you want>" build one by talking it through' : ' new "<하고 싶은 일>" 말로 설명하면 만들어 줍니다');
|
|
1074
|
+
ctx.out(en ? " list what is saved" : " list 저장된 것 목록");
|
|
1075
|
+
ctx.out(en ? " show \"<name>\" steps, wiring, and problems" : " show \"<이름>\" 단계·배선·문제점");
|
|
1076
|
+
ctx.out(en ? " run \"<name>\" [--input \"<value>\"] ask the desktop app to run it" : " run \"<이름>\" [--input \"<값>\"] 데스크탑 앱에 실행을 요청");
|
|
1077
|
+
ctx.out(en ? " export \"<name>\" [file] write a shareable package file" : " export \"<이름>\" [파일] 남에게 줄 수 있는 파일로 저장");
|
|
1078
|
+
ctx.out(en ? " inspect <file> read a package file before installing" : " inspect <파일> 설치 전에 패키지 파일 확인");
|
|
1079
|
+
ctx.out(en ? " install <file> [--name \"<new name>\"] install a package file" : " install <파일> [--name \"<새 이름>\"] 패키지 파일 설치");
|
|
1080
|
+
ctx.out("");
|
|
1081
|
+
ctx.out(ctx.ui.dim(en
|
|
1082
|
+
? "-y skips the confirmation question. Graphs are built and edited in the desktop app."
|
|
1083
|
+
: "-y 를 붙이면 확인 질문을 건너뜁니다. 그래프를 만들고 고치는 일은 데스크탑 앱에서 합니다."));
|
|
1084
|
+
return 0;
|
|
1085
|
+
}
|
|
1086
|
+
if (sub === "new" || sub === "create" || sub === "add" || sub === "만들기") {
|
|
1087
|
+
return newGraph(ctx, target, flags);
|
|
1088
|
+
}
|
|
1089
|
+
if (sub === "list" || sub === "ls") return listGraphs(ctx);
|
|
1090
|
+
if (sub === "show") {
|
|
1091
|
+
if (!target) {
|
|
1092
|
+
ctx.err(en ? "Usage: agentlas graph show \"<name>\"" : "사용법: agentlas graph show \"<이름>\"");
|
|
1093
|
+
return 1;
|
|
1094
|
+
}
|
|
1095
|
+
return showGraph(ctx, target);
|
|
1096
|
+
}
|
|
1097
|
+
if (sub === "export") {
|
|
1098
|
+
if (!target) {
|
|
1099
|
+
ctx.err(en ? "Usage: agentlas graph export \"<name>\" [file]" : "사용법: agentlas graph export \"<이름>\" [파일]");
|
|
1100
|
+
return 1;
|
|
1101
|
+
}
|
|
1102
|
+
const parts = rest.slice(1);
|
|
1103
|
+
const outPath = parts.length > 1 && /\.json$/i.test(parts[parts.length - 1]) ? parts.pop() : null;
|
|
1104
|
+
return exportGraph(ctx, parts.join(" ").trim(), outPath);
|
|
1105
|
+
}
|
|
1106
|
+
if (sub === "install") {
|
|
1107
|
+
if (!target) {
|
|
1108
|
+
ctx.err(en ? "Usage: agentlas graph install <file>" : "사용법: agentlas graph install <파일>");
|
|
1109
|
+
return 1;
|
|
1110
|
+
}
|
|
1111
|
+
return installPackage(ctx, target, flags);
|
|
1112
|
+
}
|
|
1113
|
+
if (sub === "inspect") {
|
|
1114
|
+
if (!target) {
|
|
1115
|
+
ctx.err(en ? "Usage: agentlas graph inspect <file>" : "사용법: agentlas graph inspect <파일>");
|
|
1116
|
+
return 1;
|
|
1117
|
+
}
|
|
1118
|
+
return inspectPackage(ctx, target);
|
|
1119
|
+
}
|
|
1120
|
+
if (sub === "run") {
|
|
1121
|
+
if (!target) {
|
|
1122
|
+
ctx.err(en ? "Usage: agentlas graph run \"<name>\"" : "사용법: agentlas graph run \"<이름>\"");
|
|
1123
|
+
return 1;
|
|
1124
|
+
}
|
|
1125
|
+
return runGraph(ctx, target, flags);
|
|
1126
|
+
}
|
|
1127
|
+
// 만들기·고치기를 시도한 경우에는 "그런 명령 없음"으로 끝내지 않는다. 사용자는
|
|
1128
|
+
// 오타를 낸 게 아니라 **여기서 되는 일이 아니라는 사실**을 모르는 것이고,
|
|
1129
|
+
// 어디로 가야 하는지 말해 주지 않으면 목록만 보다 포기한다.
|
|
1130
|
+
const AUTHORING = new Set([
|
|
1131
|
+
"make", "edit", "update", "delete", "remove", "rename",
|
|
1132
|
+
"enable", "disable", "on", "off", "수정",
|
|
1133
|
+
]);
|
|
1134
|
+
if (AUTHORING.has(sub)) {
|
|
1135
|
+
ctx.err(en
|
|
1136
|
+
? `Graphs are built and edited in the Agentlas desktop app (Automation → the graph canvas). The terminal can only look at saved graphs and ask for a run.`
|
|
1137
|
+
: `그래프를 만들고 고치는 일은 Agentlas 데스크탑 앱에서 합니다(자동화 → 그래프 화면). 터미널에서는 저장된 그래프를 보고 실행을 요청하는 것까지만 됩니다.`);
|
|
1138
|
+
ctx.err(ctx.ui.dim(en
|
|
1139
|
+
? `Here you can: list, show <name>, run <name>, export <name>, inspect <file>, install <file>.`
|
|
1140
|
+
: `여기서 되는 것: list, show <이름>, run <이름>, export <이름>, inspect <파일>, install <파일>.`));
|
|
1141
|
+
return 1;
|
|
1142
|
+
}
|
|
1143
|
+
// 목록에 없는 하위 명령을 조용히 list로 처리하면, 오타가 성공처럼 보인다.
|
|
1144
|
+
ctx.err(en
|
|
1145
|
+
? `Unknown subcommand "${sub}". Try: list, show, run, export, inspect, install.`
|
|
1146
|
+
: `모르는 하위 명령 "${sub}"입니다. list, show, run, export, inspect, install 중에서 고르세요.`);
|
|
1147
|
+
return 1;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
module.exports = { run };
|