agentlas 1.0.28 → 1.0.35
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 +102 -0
- package/README.md +5 -2
- package/bin/agentlas.cjs +15 -0
- package/engine/agentlas-cloud-runtime.cjs +54 -4
- package/engine/agentlas-i18n.cjs +4 -4
- package/engine/agentlas-input.cjs +10 -3
- package/engine/agentlas-judgment.cjs +0 -0
- package/engine/agentlas-native-host.cjs +69 -4
- package/engine/agentlas-onboard.cjs +20 -0
- package/engine/agentlas-ui.cjs +2 -0
- package/engine/agentlas-workforce.cjs +69 -13
- package/engine/agentlas.cjs +71 -3
- package/engine/agents/builder.cjs +84 -0
- package/engine/agents/router.cjs +8 -2
- package/engine/automation/launchd.cjs +131 -0
- package/engine/bootstrap-schema.sql +6 -44
- package/engine/browser/cdp.cjs +188 -0
- package/engine/browser/vault.cjs +124 -0
- package/engine/cli-output.cjs +262 -0
- package/engine/cloud-assets/package.cjs +5 -1
- package/engine/commands/automation.cjs +37 -1
- package/engine/commands/billing.cjs +3 -0
- package/engine/commands/browser.cjs +166 -8
- package/engine/commands/build.cjs +101 -20
- package/engine/commands/connect.cjs +162 -9
- package/engine/commands/creds.cjs +65 -3
- package/engine/commands/doctor.cjs +66 -4
- package/engine/commands/document.cjs +79 -0
- package/engine/commands/graph.cjs +1190 -0
- package/engine/commands/help.cjs +87 -9
- 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 +50 -25
- package/engine/commands/list.cjs +10 -1
- package/engine/commands/mcp.cjs +6 -2
- package/engine/commands/native.cjs +18 -2
- package/engine/commands/plugin.cjs +22 -0
- package/engine/commands/project.cjs +79 -16
- package/engine/commands/roles.cjs +210 -0
- package/engine/commands/telegram.cjs +23 -16
- package/engine/commands/workforce.cjs +63 -12
- package/engine/core/desktop-core-fetch.cjs +98 -0
- package/engine/core/desktop-core.cjs +170 -0
- package/engine/graph/ask-model.cjs +159 -0
- package/engine/graph/interview.cjs +960 -0
- package/engine/graph/layout.cjs +139 -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 +14 -10
- package/engine/project/controller.cjs +8 -8
- package/engine/project/team.cjs +99 -0
- package/engine/runtime-refusal.cjs +71 -0
- package/engine/runtimes/auth-evidence.cjs +78 -0
- package/engine/runtimes/detect.cjs +3 -0
- package/engine/runtimes/resolve.cjs +1 -1
- package/engine/sessions/prompt.cjs +16 -0
- package/engine/sessions/session.cjs +24 -2
- package/engine/telegram/connect.cjs +202 -0
- package/engine/tools/access-notice.cjs +86 -0
- package/engine/ui/palette.cjs +7 -3
- package/engine/ui/repl.cjs +120 -3
- package/engine/vendor/desktop-core.manifest.json +7 -0
- package/engine/workforce/capture.cjs +51 -1
- package/engine/workforce/deps.cjs +13 -0
- package/engine/workforce/local-core-transport.cjs +298 -0
- package/package.json +4 -2
- package/engine/commands/legacy-network.cjs +0 -29
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* browser/cdp — 터미널 명령적 브라우저 조종 (2026-08-06).
|
|
4
|
+
*
|
|
5
|
+
* 배경(오너): 데스크탑은 Electron BrowserWindow.executeJavaScript 로 페이지를
|
|
6
|
+
* 명령적으로 조종한다(navigate → innerText 읽기 → 타이핑 → 토큰 파싱). 터미널
|
|
7
|
+
* `agentlas browser` 는 리서치용(URL 읽기·검색)이라 그 명령적 조종이 없었다.
|
|
8
|
+
* 공유 CDP 엔진(browser-cdp-launcher.js)이 이미 Agentlas 전용 Chrome 을 원격
|
|
9
|
+
* 디버깅 포트(기본 9222)로 띄우므로, 그 포트에 raw CDP(DevTools Protocol)로 붙어
|
|
10
|
+
* navigate/evaluate/waitFor 를 제공한다. Node 22+ 의 global WebSocket·fetch 만
|
|
11
|
+
* 쓴다(의존성 0).
|
|
12
|
+
*
|
|
13
|
+
* evaluate(js) 는 데스크탑 executeJavaScript 와 동형(Runtime.evaluate,
|
|
14
|
+
* returnByValue + awaitPromise). BotFather 자동생성 같은 데스크탑 흐름이 이 위에
|
|
15
|
+
* 얹힌다.
|
|
16
|
+
*
|
|
17
|
+
* 안전: Agentlas 전용 프로필 Chrome 에만 붙는다(개인 크롬 아님). 되돌릴 수 없는
|
|
18
|
+
* 행동은 호출자가 판단한다 — 이 모듈은 조종 primitive 만 제공한다.
|
|
19
|
+
*/
|
|
20
|
+
const http = require("node:http");
|
|
21
|
+
|
|
22
|
+
const DEFAULT_PORT = Number(process.env.AGENTLAS_CDP_PORT || 9222);
|
|
23
|
+
|
|
24
|
+
function httpJson(port, path, { timeout = 3000 } = {}) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const req = http.get({ host: "127.0.0.1", port, path, timeout }, (res) => {
|
|
27
|
+
let body = "";
|
|
28
|
+
res.on("data", (d) => { body += d; });
|
|
29
|
+
res.on("end", () => {
|
|
30
|
+
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
req.on("error", reject);
|
|
34
|
+
req.on("timeout", () => { req.destroy(); reject(new Error(`CDP ${path} timed out`)); });
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 9222 가 살아있나(Agentlas 전용 Chrome). */
|
|
39
|
+
async function cdpReady(port = DEFAULT_PORT) {
|
|
40
|
+
try { await httpJson(port, "/json/version"); return true; } catch { return false; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 조종할 page 타겟 하나를 고른다(없으면 새로 연다). 반환: webSocketDebuggerUrl. */
|
|
44
|
+
async function pickPageTarget(port = DEFAULT_PORT) {
|
|
45
|
+
const targets = await httpJson(port, "/json");
|
|
46
|
+
let page = (Array.isArray(targets) ? targets : []).find((t) => t.type === "page" && t.webSocketDebuggerUrl);
|
|
47
|
+
if (!page) {
|
|
48
|
+
// 새 탭을 연다(DevTools HTTP: PUT /json/new).
|
|
49
|
+
page = await httpJson(port, "/json/new?about:blank");
|
|
50
|
+
}
|
|
51
|
+
if (!page || !page.webSocketDebuggerUrl) throw new Error("no CDP page target available");
|
|
52
|
+
return page.webSocketDebuggerUrl;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 페이지 하나에 CDP 로 붙는다. 반환: { navigate, evaluate, waitFor, close }.
|
|
57
|
+
* primitive:
|
|
58
|
+
* navigate(url) Page.navigate + load 대기(간이)
|
|
59
|
+
* evaluate(js, {awaitPromise}) Runtime.evaluate(returnByValue) — 값 반환
|
|
60
|
+
* waitFor(jsPredicate, {timeoutMs, pollMs}) predicate 가 truthy 될 때까지
|
|
61
|
+
*/
|
|
62
|
+
async function attachPage({ port = DEFAULT_PORT, wsUrl } = {}) {
|
|
63
|
+
const url = wsUrl || (await pickPageTarget(port));
|
|
64
|
+
const ws = new WebSocket(url);
|
|
65
|
+
await new Promise((resolve, reject) => {
|
|
66
|
+
ws.addEventListener("open", resolve, { once: true });
|
|
67
|
+
ws.addEventListener("error", () => reject(new Error("CDP websocket failed to open")), { once: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
let nextId = 0;
|
|
71
|
+
const pending = new Map();
|
|
72
|
+
ws.addEventListener("message", (event) => {
|
|
73
|
+
let msg;
|
|
74
|
+
try { msg = JSON.parse(typeof event.data === "string" ? event.data : String(event.data)); } catch { return; }
|
|
75
|
+
if (msg.id != null && pending.has(msg.id)) {
|
|
76
|
+
const { resolve, reject } = pending.get(msg.id);
|
|
77
|
+
pending.delete(msg.id);
|
|
78
|
+
if (msg.error) reject(Object.assign(new Error(msg.error.message || "CDP error"), { cdp: msg.error }));
|
|
79
|
+
else resolve(msg.result);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function send(method, params = {}, { timeout = 30000 } = {}) {
|
|
84
|
+
const id = ++nextId;
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP ${method} timed out`)); }, timeout);
|
|
87
|
+
pending.set(id, {
|
|
88
|
+
resolve: (r) => { clearTimeout(timer); resolve(r); },
|
|
89
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
90
|
+
});
|
|
91
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await send("Page.enable").catch(() => {});
|
|
96
|
+
await send("Runtime.enable").catch(() => {});
|
|
97
|
+
|
|
98
|
+
async function evaluate(expression, { awaitPromise = true } = {}) {
|
|
99
|
+
const res = await send("Runtime.evaluate", {
|
|
100
|
+
expression: `(() => { ${expression} })()`,
|
|
101
|
+
returnByValue: true,
|
|
102
|
+
awaitPromise,
|
|
103
|
+
});
|
|
104
|
+
if (res && res.exceptionDetails) {
|
|
105
|
+
throw new Error("page evaluate threw: " + (res.exceptionDetails.text || JSON.stringify(res.exceptionDetails)));
|
|
106
|
+
}
|
|
107
|
+
return res && res.result ? res.result.value : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 편의: 표현식(값) 하나를 평가.
|
|
111
|
+
async function evalExpr(expression, opts) {
|
|
112
|
+
return evaluate(`return (${expression});`, opts);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function navigate(target, { waitMs = 1500 } = {}) {
|
|
116
|
+
await send("Page.navigate", { url: target });
|
|
117
|
+
// 간이 로드 대기 — 필요하면 waitFor 로 정밀 대기.
|
|
118
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function waitFor(predicateExpr, { timeoutMs = 60000, pollMs = 800 } = {}) {
|
|
122
|
+
const deadline = Date.now() + timeoutMs;
|
|
123
|
+
while (Date.now() < deadline) {
|
|
124
|
+
let ok = false;
|
|
125
|
+
try { ok = await evalExpr(predicateExpr); } catch { ok = false; }
|
|
126
|
+
if (ok) return true;
|
|
127
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
* 진짜 조종(읽기만이 아니라 몰기). 데스크탑은 Electron sendInputEvent 로 키·클릭을
|
|
134
|
+
* 넣는다. 여기서는 CDP Input.* 로 같은 일을 한다:
|
|
135
|
+
* focusSelector(sel) 해당 요소에 포커스(evaluate)
|
|
136
|
+
* typeInto(sel, text) 요소에 포커스 후 Input.insertText 로 문자열 삽입
|
|
137
|
+
* pressKey("Enter") Input.dispatchKeyEvent (keyDown+keyUp)
|
|
138
|
+
* clickSelector(sel) 요소를 클릭(evaluate element.click — SPA에 안정적)
|
|
139
|
+
* BotFather /newbot 같은 데스크탑 흐름이 이 위에 얹힌다.
|
|
140
|
+
*/
|
|
141
|
+
const KEYS = { Enter: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" } };
|
|
142
|
+
|
|
143
|
+
async function focusSelector(selector) {
|
|
144
|
+
const ok = await evaluate(`const el=document.querySelector(${JSON.stringify(selector)}); if(!el) return false; el.focus(); return true;`);
|
|
145
|
+
if (!ok) throw new Error(`no element matches ${selector}`);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function typeInto(selector, text) {
|
|
150
|
+
await focusSelector(selector);
|
|
151
|
+
await send("Input.insertText", { text: String(text) });
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function pressKey(name) {
|
|
156
|
+
const k = KEYS[name];
|
|
157
|
+
if (!k) throw new Error(`unsupported key: ${name}`);
|
|
158
|
+
await send("Input.dispatchKeyEvent", { type: "keyDown", ...k });
|
|
159
|
+
await send("Input.dispatchKeyEvent", { type: "keyUp", ...k });
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function clickSelector(selector) {
|
|
164
|
+
const ok = await evaluate(`const el=document.querySelector(${JSON.stringify(selector)}); if(!el) return false; el.click(); return true;`);
|
|
165
|
+
if (!ok) throw new Error(`no element matches ${selector}`);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/*
|
|
170
|
+
* 현재 페이지를 PDF 로 인쇄한다(데스크탑 document/export-pdf.ts 의 offscreen
|
|
171
|
+
* BrowserWindow.printToPDF 와 같은 메커니즘 — 여기서는 CDP Page.printToPDF).
|
|
172
|
+
* base64 를 디코드해 outPath 에 쓴다. 반환: {path, bytes}.
|
|
173
|
+
*/
|
|
174
|
+
async function printPdf(outPath, { landscape = false, printBackground = true, scale = 1 } = {}) {
|
|
175
|
+
const res = await send("Page.printToPDF", { landscape, printBackground, scale, transferMode: "ReturnAsBase64" }, { timeout: 60000 });
|
|
176
|
+
const b64 = res && res.data;
|
|
177
|
+
if (!b64) throw new Error("Page.printToPDF returned no data");
|
|
178
|
+
const buf = Buffer.from(b64, "base64");
|
|
179
|
+
require("node:fs").writeFileSync(outPath, buf);
|
|
180
|
+
return { path: outPath, bytes: buf.length };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function close() { try { ws.close(); } catch { /* already closed */ } }
|
|
184
|
+
|
|
185
|
+
return { navigate, evaluate, evalExpr, waitFor, focusSelector, typeInto, pressKey, clickSelector, printPdf, close, send };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { cdpReady, pickPageTarget, attachPage, DEFAULT_PORT };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* browser/vault — 터미널 독립 브라우저 볼트 (2026-08-06).
|
|
4
|
+
*
|
|
5
|
+
* 배경(오너: "조종을 다른 흐름으로 확장"): 데스크탑 electron/store/browser-vault.ts 는
|
|
6
|
+
* 사이트별 전용-프로필 로그인/세션 상태를 관리한다(사이트 카드 · 세션 valid/expired/none ·
|
|
7
|
+
* 권한 · 행동 로그). 저장 테이블(browser_sites/browser_sessions/…)은 이미 터미널 부트스트랩
|
|
8
|
+
* 스키마에 있어 **데스크탑과 그대로 공유**된다. 그 핵심 CRUD 만 이식한다.
|
|
9
|
+
*
|
|
10
|
+
* 보안(데스크탑과 동일): 사이트 비밀번호를 받거나 자동 입력하지 않는다. 로그인은 제공자
|
|
11
|
+
* 페이지에서 사용자가 직접 하고, 터미널은 페이지를 열어 주고(조종) 세션 상태만 기록한다.
|
|
12
|
+
* has_password 는 항상 0 — CLI 로는 어떤 자격증명도 볼트에 들어가지 않는다.
|
|
13
|
+
*/
|
|
14
|
+
const crypto = require("node:crypto");
|
|
15
|
+
const { runWriteTransaction } = require("../agentlas-sqlite-policy.cjs");
|
|
16
|
+
|
|
17
|
+
function nowIso() { return new Date().toISOString(); }
|
|
18
|
+
|
|
19
|
+
/** 입력을 host 로 정규화한다(데스크탑 normalizeSite 와 같은 규칙 — userinfo 는 거부). */
|
|
20
|
+
function normalizeSite(input) {
|
|
21
|
+
const raw = String(input || "").trim();
|
|
22
|
+
if (!raw) return "";
|
|
23
|
+
let u;
|
|
24
|
+
try { u = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { return ""; }
|
|
25
|
+
if (u.username || u.password) return ""; // 자격증명은 사이트 키에 절대 들이지 않는다
|
|
26
|
+
const host = u.host.toLowerCase().replace(/^www\./, "");
|
|
27
|
+
return host && !/\s/.test(host) ? host : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function listBrowserSites(db) {
|
|
31
|
+
let rows;
|
|
32
|
+
try {
|
|
33
|
+
rows = db.prepare(
|
|
34
|
+
`SELECT s.id, s.site, s.label, s.username, s.created_at, s.updated_at,
|
|
35
|
+
se.status AS sess_status, se.captured_at AS sess_captured
|
|
36
|
+
FROM browser_sites s
|
|
37
|
+
LEFT JOIN browser_sessions se ON se.site = s.site
|
|
38
|
+
ORDER BY s.updated_at DESC`,
|
|
39
|
+
).all();
|
|
40
|
+
} catch { return []; }
|
|
41
|
+
return rows.map((r) => ({
|
|
42
|
+
id: String(r.id),
|
|
43
|
+
site: String(r.site),
|
|
44
|
+
label: r.label ?? null,
|
|
45
|
+
username: r.username ?? null,
|
|
46
|
+
session: { status: r.sess_status ?? "none", capturedAt: r.sess_captured ?? null },
|
|
47
|
+
createdAt: String(r.created_at),
|
|
48
|
+
updatedAt: String(r.updated_at),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getBrowserSite(db, site) {
|
|
53
|
+
const norm = normalizeSite(site);
|
|
54
|
+
return listBrowserSites(db).find((s) => s.site === norm) || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 사이트 카드를 만들거나 갱신한다(비밀번호 없음 — has_password 항상 0). */
|
|
58
|
+
function upsertBrowserSite(db, input) {
|
|
59
|
+
const site = normalizeSite(input.site);
|
|
60
|
+
if (!site) throw new Error("site address is empty or malformed");
|
|
61
|
+
const now = nowIso();
|
|
62
|
+
runWriteTransaction(db, () => {
|
|
63
|
+
const existing = db.prepare("SELECT id FROM browser_sites WHERE site = ?").get(site);
|
|
64
|
+
if (existing) {
|
|
65
|
+
db.prepare(
|
|
66
|
+
"UPDATE browser_sites SET label = COALESCE(?, label), username = COALESCE(?, username), updated_at = ? WHERE site = ?",
|
|
67
|
+
).run(input.label ?? null, input.username ?? null, now, site);
|
|
68
|
+
} else {
|
|
69
|
+
db.prepare(
|
|
70
|
+
"INSERT INTO browser_sites (id, site, label, username, has_password, created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
|
|
71
|
+
).run(crypto.randomUUID(), site, input.label ?? null, input.username ?? null, 0, now, now);
|
|
72
|
+
db.prepare(
|
|
73
|
+
"INSERT OR IGNORE INTO browser_sessions (id, site, status, captured_at) VALUES (?, ?, 'none', NULL)",
|
|
74
|
+
).run(crypto.randomUUID(), site);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return getBrowserSite(db, site);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function deleteBrowserSite(db, site) {
|
|
81
|
+
const norm = normalizeSite(site) || String(site || "").trim();
|
|
82
|
+
runWriteTransaction(db, () => {
|
|
83
|
+
db.prepare("DELETE FROM browser_sessions WHERE site = ?").run(norm);
|
|
84
|
+
db.prepare("DELETE FROM browser_permissions WHERE site = ?").run(norm);
|
|
85
|
+
db.prepare("DELETE FROM browser_sites WHERE site = ?").run(norm);
|
|
86
|
+
});
|
|
87
|
+
return { ok: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 세션 상태를 기록한다(valid 면 captured_at 을 지금으로, 아니면 비운다). 데스크탑과 동일. */
|
|
91
|
+
function setBrowserSession(db, site, status) {
|
|
92
|
+
const norm = normalizeSite(site);
|
|
93
|
+
if (!norm) throw new Error("site address is empty or malformed");
|
|
94
|
+
if (status !== "valid" && status !== "expired" && status !== "none") {
|
|
95
|
+
throw new Error(`status must be valid | expired | none, got ${status}`);
|
|
96
|
+
}
|
|
97
|
+
const captured = status === "valid" ? nowIso() : null;
|
|
98
|
+
runWriteTransaction(db, () => {
|
|
99
|
+
const existing = db.prepare("SELECT id FROM browser_sessions WHERE site = ?").get(norm);
|
|
100
|
+
if (existing) {
|
|
101
|
+
db.prepare("UPDATE browser_sessions SET status = ?, captured_at = ? WHERE site = ?").run(status, captured, norm);
|
|
102
|
+
} else {
|
|
103
|
+
db.prepare("INSERT INTO browser_sessions (id, site, status, captured_at) VALUES (?,?,?,?)")
|
|
104
|
+
.run(crypto.randomUUID(), norm, status, captured);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return getBrowserSite(db, norm);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 되돌릴 수 없는/외부로 나가는 행동을 날짜 로그에 남긴다(데스크탑과 같은 테이블). */
|
|
111
|
+
function logBrowserAction(db, { site = null, action, target = null, result = null, approval = null, meta = null } = {}) {
|
|
112
|
+
try {
|
|
113
|
+
runWriteTransaction(db, () => {
|
|
114
|
+
db.prepare(
|
|
115
|
+
"INSERT INTO browser_action_logs (id, ts, site, action, target, result, approval, meta) VALUES (?,?,?,?,?,?,?,?)",
|
|
116
|
+
).run(crypto.randomUUID(), nowIso(), site, action, target, result, approval, meta ? JSON.stringify(meta) : null);
|
|
117
|
+
});
|
|
118
|
+
} catch { /* 로그 실패는 치명적이지 않다 */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = {
|
|
122
|
+
normalizeSite, listBrowserSites, getBrowserSite,
|
|
123
|
+
upsertBrowserSite, deleteBrowserSite, setBrowserSession, logBrowserAction,
|
|
124
|
+
};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* CLI 출력 계약 — 명령은 **문자열이 아니라 {데이터 + 스키마}** 를 돌려준다.
|
|
4
|
+
*
|
|
5
|
+
* 왜(2026-08-08, Paseo CLI 대조 실측):
|
|
6
|
+
* 우리 명령들은 각자 `ctx.out("...")` 로 직접 찍었다. 그래서
|
|
7
|
+
* · `--json` 이 어떤 명령엔 있고 어떤 명령엔 없다,
|
|
8
|
+
* · 에러 형식이 명령마다 다르다,
|
|
9
|
+
* · 스크립트가 쓰려면 사람용 문장을 파싱해야 한다.
|
|
10
|
+
*
|
|
11
|
+
* Paseo CLI(`packages/cli/src/output/`)는 명령이 `{type,data,schema}` 를 반환하고
|
|
12
|
+
* 렌더러 하나가 `--json/--yaml/--quiet/--no-color/--no-headers` 를 해석한다.
|
|
13
|
+
* 그 계약을 그대로 가져온다. 규칙 셋:
|
|
14
|
+
*
|
|
15
|
+
* 1. **quiet 이 모든 것을 이긴다.** `--quiet` 는 id 만 한 줄씩 — `xargs` 로 바로 흐른다.
|
|
16
|
+
* 2. **에러도 같은 형식 규율을 따른다.** `--json` 이면 `{"error":{"code","message"}}`.
|
|
17
|
+
* 사람용이면 빨간 `Error: `.
|
|
18
|
+
* 3. **색은 옵션이 정한다.** 명령이 직접 ANSI 를 박지 않는다(파이프 오염 금지).
|
|
19
|
+
*
|
|
20
|
+
* 이 파일은 의존성이 없다(테스트가 순수 함수로 검증한다).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** @typedef {"table"|"json"|"yaml"} OutputFormat */
|
|
24
|
+
|
|
25
|
+
const DEFAULT_OPTIONS = Object.freeze({
|
|
26
|
+
format: "table",
|
|
27
|
+
quiet: false,
|
|
28
|
+
noHeaders: false,
|
|
29
|
+
noColor: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** 명령이 돌려주는 단일 항목 결과. */
|
|
33
|
+
function single(data, schema) {
|
|
34
|
+
return { type: "single", data, schema };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 명령이 돌려주는 목록 결과. */
|
|
38
|
+
function list(data, schema) {
|
|
39
|
+
return { type: "list", data: Array.isArray(data) ? data : [], schema };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isResult(value) {
|
|
43
|
+
return Boolean(
|
|
44
|
+
value &&
|
|
45
|
+
typeof value === "object" &&
|
|
46
|
+
(value.type === "single" || value.type === "list") &&
|
|
47
|
+
value.schema &&
|
|
48
|
+
Array.isArray(value.schema.columns),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rowsOf(result) {
|
|
53
|
+
return result.type === "list" ? result.data : [result.data];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readField(item, field) {
|
|
57
|
+
if (typeof field === "function") return field(item);
|
|
58
|
+
if (item && typeof item === "object") return item[field];
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function textOf(value) {
|
|
63
|
+
if (value === null || value === undefined) return "";
|
|
64
|
+
if (typeof value === "string") return value;
|
|
65
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
66
|
+
if (value instanceof Date) return value.toISOString();
|
|
67
|
+
return JSON.stringify(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 표시 폭 — ANSI 를 벗기고, CJK 전각을 2칸으로 센다.
|
|
72
|
+
* 한글 표가 어긋나던 이유가 이것이다(폭을 문자 수로 세면 열이 밀린다).
|
|
73
|
+
*/
|
|
74
|
+
function displayWidth(value) {
|
|
75
|
+
const plain = String(value).replace(/\[[0-9;]*m/g, "");
|
|
76
|
+
let width = 0;
|
|
77
|
+
for (const char of plain) {
|
|
78
|
+
const code = char.codePointAt(0) ?? 0;
|
|
79
|
+
const wide =
|
|
80
|
+
(code >= 0x1100 && code <= 0x115f) ||
|
|
81
|
+
(code >= 0x2e80 && code <= 0xa4cf) ||
|
|
82
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
83
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
84
|
+
(code >= 0xfe30 && code <= 0xfe6f) ||
|
|
85
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
86
|
+
(code >= 0xffe0 && code <= 0xffe6);
|
|
87
|
+
width += wide ? 2 : 1;
|
|
88
|
+
}
|
|
89
|
+
return width;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function padTo(value, width, align) {
|
|
93
|
+
const pad = Math.max(0, width - displayWidth(value));
|
|
94
|
+
if (align === "right") return " ".repeat(pad) + value;
|
|
95
|
+
if (align === "center") {
|
|
96
|
+
const left = Math.floor(pad / 2);
|
|
97
|
+
return " ".repeat(left) + value + " ".repeat(pad - left);
|
|
98
|
+
}
|
|
99
|
+
return value + " ".repeat(pad);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderQuiet(result) {
|
|
103
|
+
const idField = result.schema.idField;
|
|
104
|
+
return rowsOf(result)
|
|
105
|
+
.map((item) => textOf(readField(item, idField)))
|
|
106
|
+
.filter((line) => line.length > 0)
|
|
107
|
+
.join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function renderJson(result) {
|
|
111
|
+
const serialize = result.schema.serialize;
|
|
112
|
+
const project = (item) => (serialize ? serialize(item) : item);
|
|
113
|
+
const payload = result.type === "list" ? result.data.map(project) : project(result.data);
|
|
114
|
+
return JSON.stringify(payload, null, 2);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 의존성 없이 쓰는 최소 YAML(스칼라·객체·배열 1단계). */
|
|
118
|
+
function renderYaml(result) {
|
|
119
|
+
const serialize = result.schema.serialize;
|
|
120
|
+
const project = (item) => (serialize ? serialize(item) : item);
|
|
121
|
+
const scalar = (value) => {
|
|
122
|
+
if (value === null || value === undefined) return "null";
|
|
123
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
124
|
+
const text = textOf(value);
|
|
125
|
+
return /^[\w./:@-]+$/.test(text) ? text : JSON.stringify(text);
|
|
126
|
+
};
|
|
127
|
+
const objectLines = (object, indent) => {
|
|
128
|
+
const pad = " ".repeat(indent);
|
|
129
|
+
return Object.entries(object).map(([key, value]) => {
|
|
130
|
+
if (value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date)) {
|
|
131
|
+
return `${pad}${key}:\n${objectLines(value, indent + 2).join("\n")}`;
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(value)) {
|
|
134
|
+
if (value.length === 0) return `${pad}${key}: []`;
|
|
135
|
+
return `${pad}${key}:\n${value.map((entry) => `${pad} - ${scalar(entry)}`).join("\n")}`;
|
|
136
|
+
}
|
|
137
|
+
return `${pad}${key}: ${scalar(value)}`;
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
if (result.type === "list") {
|
|
141
|
+
if (result.data.length === 0) return "[]";
|
|
142
|
+
return result.data
|
|
143
|
+
.map((item) => {
|
|
144
|
+
const lines = objectLines(project(item), 2);
|
|
145
|
+
return `- ${lines.join("\n").trimStart()}`;
|
|
146
|
+
})
|
|
147
|
+
.join("\n");
|
|
148
|
+
}
|
|
149
|
+
return objectLines(project(result.data), 0).join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function renderTable(result, options) {
|
|
153
|
+
const columns = result.schema.columns;
|
|
154
|
+
const rows = rowsOf(result);
|
|
155
|
+
if (rows.length === 0) return "";
|
|
156
|
+
const cells = rows.map((item) =>
|
|
157
|
+
columns.map((column) => textOf(readField(item, column.field))),
|
|
158
|
+
);
|
|
159
|
+
const widths = columns.map((column, index) =>
|
|
160
|
+
Math.max(
|
|
161
|
+
options.noHeaders ? 0 : displayWidth(column.header),
|
|
162
|
+
...cells.map((row) => displayWidth(row[index] ?? "")),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
const lines = [];
|
|
166
|
+
if (!options.noHeaders) {
|
|
167
|
+
lines.push(
|
|
168
|
+
columns
|
|
169
|
+
.map((column, index) => padTo(column.header.toUpperCase(), widths[index], column.align))
|
|
170
|
+
.join(" ")
|
|
171
|
+
.trimEnd(),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
for (const row of cells) {
|
|
175
|
+
lines.push(
|
|
176
|
+
columns
|
|
177
|
+
.map((column, index) => padTo(row[index] ?? "", widths[index], column.align))
|
|
178
|
+
.join(" ")
|
|
179
|
+
.trimEnd(),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 형식 선택은 여기 한 곳. quiet 이 언제나 이긴다. */
|
|
186
|
+
function render(result, options = {}) {
|
|
187
|
+
if (!isResult(result)) {
|
|
188
|
+
throw new TypeError("render() expects a {type,data,schema} command result");
|
|
189
|
+
}
|
|
190
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
191
|
+
if (opts.quiet) return renderQuiet(result);
|
|
192
|
+
if (opts.format === "json") return renderJson(result);
|
|
193
|
+
if (opts.format === "yaml") return renderYaml(result);
|
|
194
|
+
if (typeof result.schema.renderHuman === "function") return result.schema.renderHuman(result, opts);
|
|
195
|
+
return renderTable(result, opts);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 무엇이든 구조화 에러로. code 는 기계가 분기할 값이다. */
|
|
199
|
+
function toCommandError(error) {
|
|
200
|
+
if (error && typeof error === "object" && typeof error.code === "string" && typeof error.message === "string") {
|
|
201
|
+
return { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) };
|
|
202
|
+
}
|
|
203
|
+
if (error instanceof Error) {
|
|
204
|
+
return { code: "unknown_error", message: error.message, ...(error.stack ? { details: error.stack } : {}) };
|
|
205
|
+
}
|
|
206
|
+
return { code: "unknown_error", message: String(error) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 에러도 같은 형식 규율을 따른다 — 사람용만 색을 쓴다. */
|
|
210
|
+
function renderError(error, options = {}) {
|
|
211
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
212
|
+
const commandError = toCommandError(error);
|
|
213
|
+
if (opts.format === "json") return JSON.stringify({ error: commandError }, null, 2);
|
|
214
|
+
if (opts.format === "yaml") {
|
|
215
|
+
return ["error:", ` code: ${commandError.code}`, ` message: ${JSON.stringify(commandError.message)}`].join("\n");
|
|
216
|
+
}
|
|
217
|
+
const prefix = opts.noColor ? "Error: " : "[31mError: [0m";
|
|
218
|
+
return commandError.details && typeof commandError.details === "string" && opts.format === "table"
|
|
219
|
+
? `${prefix}${commandError.message}`
|
|
220
|
+
: `${prefix}${commandError.message}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* argv 에서 전역 출력 플래그를 뜯어낸다. **모든 명령이 같은 이름·같은 의미**를 갖도록
|
|
225
|
+
* 파서가 한 곳이다(명령마다 --json 유무가 갈리던 것을 막는다).
|
|
226
|
+
*
|
|
227
|
+
* 색 규칙은 clig.dev 를 따른다: `NO_COLOR` 환경변수, `--no-color`, 그리고 TTY 가
|
|
228
|
+
* 아니면 자동으로 끈다(파이프에 ANSI 를 흘리지 않는다).
|
|
229
|
+
*/
|
|
230
|
+
function parseOutputFlags(argv, env = process.env, isTty = Boolean(process.stdout.isTTY)) {
|
|
231
|
+
const rest = [];
|
|
232
|
+
const options = { ...DEFAULT_OPTIONS };
|
|
233
|
+
for (const token of argv) {
|
|
234
|
+
if (token === "--json") options.format = "json";
|
|
235
|
+
else if (token === "--yaml") options.format = "yaml";
|
|
236
|
+
else if (token === "--quiet" || token === "-q") options.quiet = true;
|
|
237
|
+
else if (token === "--no-headers") options.noHeaders = true;
|
|
238
|
+
else if (token === "--no-color") options.noColor = true;
|
|
239
|
+
else rest.push(token);
|
|
240
|
+
}
|
|
241
|
+
if (env && (env.NO_COLOR || env.AGENTLAS_NO_COLOR)) options.noColor = true;
|
|
242
|
+
if (!isTty) options.noColor = true;
|
|
243
|
+
return { options, rest };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 사람용 표현이 스피너·배너를 써도 되는가(비대화형이면 평문으로 떨어진다). */
|
|
247
|
+
function isRichUi(options, isTty = Boolean(process.stdout.isTTY)) {
|
|
248
|
+
return Boolean(isTty) && options.format === "table" && !options.quiet;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
module.exports = {
|
|
252
|
+
DEFAULT_OPTIONS,
|
|
253
|
+
single,
|
|
254
|
+
list,
|
|
255
|
+
isResult,
|
|
256
|
+
render,
|
|
257
|
+
renderError,
|
|
258
|
+
toCommandError,
|
|
259
|
+
parseOutputFlags,
|
|
260
|
+
isRichUi,
|
|
261
|
+
displayWidth,
|
|
262
|
+
};
|
|
@@ -591,10 +591,14 @@ function cloudRoutingCardProblem(card) {
|
|
|
591
591
|
if (!workforce || typeof workforce !== "object" || Array.isArray(workforce)) {
|
|
592
592
|
return "workforce must be a complete semantic resume";
|
|
593
593
|
}
|
|
594
|
+
// `skills` has a floor of 0, not 1. Skills are modules and live outside the
|
|
595
|
+
// core, so a fully modular agent legitimately declares none. Requiring one
|
|
596
|
+
// here would block publishing every modular package. Kept identical in
|
|
597
|
+
// agentlas_desktop/electron/cloud-agents/package.ts — the two must not drift.
|
|
594
598
|
const semanticLists = [
|
|
595
599
|
["communities", /^community:[a-z0-9][a-z0-9-]*$/, 1, 5],
|
|
596
600
|
["roles", /^role:[a-z0-9][a-z0-9-]*$/, 0, 4],
|
|
597
|
-
["skills", /^skill:[a-z0-9][a-z0-9-]*$/,
|
|
601
|
+
["skills", /^skill:[a-z0-9][a-z0-9-]*$/, 0, 12],
|
|
598
602
|
["knowledge", /^knowledge:[a-z0-9][a-z0-9-]*$/, 0, 256],
|
|
599
603
|
];
|
|
600
604
|
for (const [field, pattern, minimum, maximum] of semanticLists) {
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
/*
|
|
3
3
|
* automation — 자동화 등록/목록/토글/삭제/실행 (v1 cmdAutomation 의 v2 포팅).
|
|
4
4
|
* list (기본) | add | on <id> | off <id> | remove <id> | run <id> | runs | daemon
|
|
5
|
+
* tick 1회 due 스윕 후 종료(launchd/cron 이 poke 하는 진입점)
|
|
6
|
+
* install macOS launchd 상주 켜기 — 앱/창이 꺼져 있어도 발동(opt-in)
|
|
7
|
+
* uninstall 상주 끄기 · status 상주 상태
|
|
5
8
|
*
|
|
6
9
|
* 스케줄 계산은 automation/schedule, DB는 automation/store, 실행은
|
|
7
10
|
* automation/daemon(세션 계층)만 쓴다. run <id> 는 스케줄을 건드리지 않는다
|
|
@@ -196,7 +199,40 @@ async function run(ctx, args) {
|
|
|
196
199
|
return daemon.automationDaemon(ctx, db, { intervalSec: interval, runtimeOverride });
|
|
197
200
|
}
|
|
198
201
|
|
|
199
|
-
|
|
202
|
+
// 1회 due 스윕 후 종료 — launchd/cron 이 poke 하는 진입점(상주 루프 아님).
|
|
203
|
+
if (sub === "tick") {
|
|
204
|
+
await daemon.daemonTick(ctx, db, {});
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 앱/창이 꺼져 있어도 자동화가 발동하도록 macOS launchd 로 상주시킨다(opt-in).
|
|
209
|
+
if (sub === "install" || sub === "uninstall" || sub === "status") {
|
|
210
|
+
const launchd = require("../automation/launchd.cjs");
|
|
211
|
+
if (sub === "status") {
|
|
212
|
+
const st = launchd.launchdStatus();
|
|
213
|
+
if (!st.supported) { ctx.out(ko ? "launchd 상주는 macOS 전용입니다. 다른 OS 는 `agentlas automation daemon` 을 켜 두세요." : "launchd persistence is macOS-only. On other systems keep `agentlas automation daemon` running."); return 0; }
|
|
214
|
+
ctx.out(`${st.loaded ? ctx.ui.green("✓") : ctx.ui.dim("○")} ${ko ? "상주(launchd)" : "persistence (launchd)"}: ${st.loaded ? (ko ? "실행 중" : "loaded") : st.installed ? (ko ? "설치됨(미로드)" : "installed (not loaded)") : (ko ? "미설치" : "not installed")}`);
|
|
215
|
+
ctx.out(ctx.ui.dim(`plist: ${st.plistPath}`));
|
|
216
|
+
if (!st.loaded) ctx.out(ctx.ui.dim(ko ? "켜기: agentlas automation install" : "Enable: agentlas automation install"));
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
if (sub === "install") {
|
|
220
|
+
let interval = 300;
|
|
221
|
+
for (let i = 1; i < args.length; i++) if (args[i] === "--interval") interval = Math.max(30, Number(args[++i]) || 300);
|
|
222
|
+
const st = launchd.enableLaunchd({ intervalSec: interval });
|
|
223
|
+
if (st.error) { ctx.err(`${ctx.ui.red("✖")} ${st.error}`); return 1; }
|
|
224
|
+
ctx.out(`${ctx.ui.green("✓")} ${ko ? "상주를 켰습니다 — 앱/창이 꺼져 있어도 자동화가 발동합니다" : "persistence on — automations fire even with the app/window closed"} (${interval}s)`);
|
|
225
|
+
ctx.out(ctx.ui.dim(ko ? `${Math.max(30, interval)}초마다 due 를 확인합니다. 끄기: agentlas automation uninstall` : `checks due automations every ${Math.max(30, interval)}s. Disable: agentlas automation uninstall`));
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
// uninstall
|
|
229
|
+
const st = launchd.disableLaunchd();
|
|
230
|
+
if (st.error) { ctx.err(`${ctx.ui.red("✖")} ${st.error}`); return 1; }
|
|
231
|
+
ctx.out(`${ctx.ui.green("✓")} ${ko ? "상주를 껐습니다. 자동화는 포그라운드 daemon 을 켜 둘 때만 발동합니다." : "persistence off. Automations fire only while a foreground daemon runs."}`);
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
ctx.err(usage("list|add|on <id>|off <id>|remove <id>|run <id>|runs|daemon|tick|install|uninstall|status"));
|
|
200
236
|
return 1;
|
|
201
237
|
}
|
|
202
238
|
|
|
@@ -25,6 +25,9 @@ function usage(ko) {
|
|
|
25
25
|
ko
|
|
26
26
|
? " 구독 계좌(A)와 렌트수익 계좌(B) 잔액을 표시합니다."
|
|
27
27
|
: " Shows the subscription account (A) and rental-earnings account (B) balances.",
|
|
28
|
+
ko
|
|
29
|
+
? " 크레딧은 Hub 에이전트 호출(공개 에이전트 3·팀 10, 활성 리스는 0)에 쓰입니다."
|
|
30
|
+
: " Credits pay for Hub agent calls (public agent 3 · team 10; active leases cost 0).",
|
|
28
31
|
ko
|
|
29
32
|
? " 참고: 렌트수익(B) → 구독(A) 전송은 Agentlas Desktop 에서만 가능합니다 (터미널 전송 명령 없음)."
|
|
30
33
|
: " Note: earnings (B) → subscription (A) transfer is Desktop-only (no transfer command in the terminal).",
|