agentlas 0.9.3 → 0.9.5

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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.4 — 2026-07-23
4
+
5
+ - `plugin add` no longer registers a code-hosting page (GitHub/GitLab/Bitbucket
6
+ repo or homepage URL) as if it were a live MCP server, even when a manifest
7
+ row explicitly claims `transport:"http"`. A connectorless catalog entry now
8
+ refuses honestly with its docs link instead of writing an unreachable
9
+ server into the local MCP config.
10
+ - stdio rows (`command`+`args`+`envKeys`) from a plugin manifest now install
11
+ correctly into the local MCP server registry.
12
+ - `plugin-add-contract` runs as part of the regular smoke suite.
13
+
3
14
  ## 0.9.3 — 2026-07-20
4
15
 
5
16
  - Preserve the terminal UI spinner lifecycle through the memory-output guard,
@@ -0,0 +1,299 @@
1
+ "use strict";
2
+
3
+ // `agentlas evolve [list|apply <id>|revert <id>]` — Phase 2 / 2+ (terminal 표면).
4
+ //
5
+ // 데스크탑 트리거가 만든 "성장 제안"을 공유 agentlas.sqlite에서 읽어 검토·적용·되돌린다.
6
+ // hep/터미널 세션은 UI가 없으므로 이 명령이 4표면 발화 UX의 터미널 창구다.
7
+ // list — 대기 중(고위험 candidate) + 자동적용(저위험) 제안을 사람이 읽는 3줄로.
8
+ // apply <id> — 고위험 candidate를 명시 승인해 프롬프트에 적용(런타임 authority=system_prompt + 파일).
9
+ // revert <id> — 적용된 제안을 되돌린다.
10
+ //
11
+ // 적용/되돌리기 게이트는 도구 무관한 "타깃 파일 내용 해시(before_hash/after_hash)"로만 판정한다
12
+ // (데스크탑이 생성 시 저장한 sha256). 그래서 데스크탑이 자동적용한 저위험 제안도 터미널에서
13
+ // 안전하게 되돌릴 수 있다. 어떤 실패도 폴백 없이 상태로 노출한다.
14
+
15
+ const fs = require("node:fs");
16
+ const path = require("node:path");
17
+ const { createHash, randomUUID } = require("node:crypto");
18
+
19
+ function sha256(content) {
20
+ return createHash("sha256").update(String(content), "utf8").digest("hex");
21
+ }
22
+
23
+ const ABSENT_TARGET_HASH = sha256("agentlas:absent-agent-asset:v1");
24
+ const RULE_TARGETS = new Set([
25
+ "system-prompt.md",
26
+ "soul.md",
27
+ "agent.md",
28
+ "claude.md",
29
+ "agents.md",
30
+ "gemini.md",
31
+ "persona.md",
32
+ "prompt.md",
33
+ ]);
34
+
35
+ function parseSource(json) {
36
+ try {
37
+ const parsed = JSON.parse(json || "{}");
38
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
44
+ /** 대기 중(사람 결정 필요) 고위험 성장 제안 개수 — 터미널 홈 배너용. */
45
+ function countPendingGrowthProposals(db) {
46
+ try {
47
+ const row = db
48
+ .prepare(
49
+ `SELECT COUNT(*) AS n FROM agent_evolution_proposals
50
+ WHERE json_extract(source_json, '$._growth') = 1 AND status = 'candidate'`,
51
+ )
52
+ .get();
53
+ return Number(row && row.n ? row.n : 0);
54
+ } catch {
55
+ return 0;
56
+ }
57
+ }
58
+
59
+ function listGrowthProposals(db, limit = 50) {
60
+ let rows = [];
61
+ try {
62
+ rows = db
63
+ .prepare(
64
+ `SELECT * FROM agent_evolution_proposals
65
+ WHERE json_extract(source_json, '$._growth') = 1
66
+ AND status IN ('candidate','applied','measured')
67
+ ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
68
+ LIMIT ?`,
69
+ )
70
+ .all(limit);
71
+ } catch {
72
+ rows = [];
73
+ }
74
+ const pending = [];
75
+ const autoApplied = [];
76
+ for (const row of rows) {
77
+ const source = parseSource(row.source_json);
78
+ const entry = { row, source };
79
+ if (row.status === "candidate") pending.push(entry);
80
+ else if (source._autoApplied === true) autoApplied.push(entry);
81
+ }
82
+ return { pending, autoApplied };
83
+ }
84
+
85
+ function agentById(db, id) {
86
+ try {
87
+ return db.prepare("SELECT * FROM installed_agents WHERE id = ?").get(id) || null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function targetFilePath(agentFolder, agent, targetPath) {
94
+ const dir = agentFolder(agent);
95
+ const safe = path.resolve(dir, targetPath);
96
+ if (safe !== path.join(dir, targetPath) && !safe.startsWith(path.resolve(dir) + path.sep)) {
97
+ throw new Error("Evolution target escapes the agent folder");
98
+ }
99
+ return safe;
100
+ }
101
+
102
+ function currentTargetHash(file) {
103
+ try {
104
+ const content = fs.readFileSync(file, "utf8");
105
+ return { exists: true, content, hash: sha256(content) };
106
+ } catch (error) {
107
+ if (error && error.code === "ENOENT") return { exists: false, content: "", hash: ABSENT_TARGET_HASH };
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ function printCard(out, entry, index) {
113
+ const { row, source } = entry;
114
+ const card = source.humanCard && typeof source.humanCard === "object" ? source.humanCard : null;
115
+ const tier = source.riskTier === "high" ? "high" : "low";
116
+ out(` [${index}] ${row.id} (${tier} · ${row.status} · ${row.agent_id})`);
117
+ if (card) {
118
+ out(` 배운 것 : ${card.learned}`);
119
+ out(` 바뀌는 것: ${card.change}`);
120
+ out(` 되돌리기 : ${card.reversible}`);
121
+ } else {
122
+ out(` ${row.summary}`);
123
+ }
124
+ }
125
+
126
+ function cmdList(db, out) {
127
+ const { pending, autoApplied } = listGrowthProposals(db);
128
+ out("== agent growth proposals ==");
129
+ out("");
130
+ out(`대기(승인 필요) ${pending.length}건:`);
131
+ if (!pending.length) out(" (없음)");
132
+ pending.forEach((entry, i) => printCard(out, entry, i + 1));
133
+ out("");
134
+ out(`자동 적용됨(저위험) ${autoApplied.length}건:`);
135
+ if (!autoApplied.length) out(" (없음)");
136
+ autoApplied.forEach((entry, i) => printCard(out, entry, i + 1));
137
+ out("");
138
+ out("적용: agentlas evolve apply <id> · 되돌리기: agentlas evolve revert <id>");
139
+ }
140
+
141
+ function loadProposal(db, id, fail) {
142
+ const row = db.prepare("SELECT * FROM agent_evolution_proposals WHERE id = ?").get(id);
143
+ if (!row) return fail(`Proposal not found: ${id}`);
144
+ if (row.proposal_type !== "rule") {
145
+ return fail(`Terminal evolve applies rule (prompt) proposals only; ${id} is '${row.proposal_type}'. Use the desktop app.`);
146
+ }
147
+ if (!RULE_TARGETS.has(String(row.target_path).toLowerCase())) {
148
+ return fail(`Unsupported evolution target: ${row.target_path}`);
149
+ }
150
+ return row;
151
+ }
152
+
153
+ // 터미널이 부트스트랩한 DB는 v45라 진화 영수증 테이블이 없을 수 있다(데스크탑 v51+ 마이그레이션이
154
+ // 추가). 앱과 동일한 스키마로 idempotent 생성 — 앱이 이미 만들었으면 no-op.
155
+ function ensureReceiptTable(db) {
156
+ db.exec(`
157
+ CREATE TABLE IF NOT EXISTS agent_evolution_receipts (
158
+ id TEXT PRIMARY KEY,
159
+ proposal_id TEXT NOT NULL,
160
+ agent_id TEXT NOT NULL,
161
+ action TEXT NOT NULL,
162
+ target_path TEXT NOT NULL,
163
+ version_before INTEGER NOT NULL,
164
+ version_after INTEGER NOT NULL,
165
+ target_hash_before TEXT NOT NULL,
166
+ target_hash_after TEXT NOT NULL,
167
+ package_hash_before TEXT NOT NULL,
168
+ package_hash_after TEXT NOT NULL,
169
+ created_at TEXT NOT NULL,
170
+ UNIQUE(proposal_id, action)
171
+ );
172
+ `);
173
+ }
174
+
175
+ function insertReceipt(db, row, action, hashBefore, hashAfter, now) {
176
+ ensureReceiptTable(db);
177
+ db.prepare(
178
+ `INSERT INTO agent_evolution_receipts (
179
+ id, proposal_id, agent_id, action, target_path,
180
+ version_before, version_after, target_hash_before, target_hash_after,
181
+ package_hash_before, package_hash_after, created_at
182
+ ) VALUES (?, ?, ?, ?, ?, 1, 2, ?, ?, ?, ?, ?)
183
+ ON CONFLICT(proposal_id, action) DO NOTHING`,
184
+ ).run(
185
+ `evo_receipt_${randomUUID()}`,
186
+ row.id,
187
+ row.agent_id,
188
+ action,
189
+ row.target_path,
190
+ hashBefore,
191
+ hashAfter,
192
+ hashBefore,
193
+ hashAfter,
194
+ now,
195
+ );
196
+ }
197
+
198
+ function cmdApply(db, id, out, fail, agentFolder) {
199
+ const row = loadProposal(db, id, fail);
200
+ if (!row) return;
201
+ if (row.status !== "candidate") {
202
+ return fail(`Only a pending candidate can be applied; ${id} is '${row.status}'.`);
203
+ }
204
+ const agent = agentById(db, row.agent_id);
205
+ if (!agent) return fail(`Agent not found for proposal: ${row.agent_id}`);
206
+ const file = targetFilePath(agentFolder, agent, row.target_path);
207
+ const current = currentTargetHash(file);
208
+ if (current.hash !== row.before_hash) {
209
+ return fail("Agent prompt changed after this proposal was created; review it in the desktop app and re-propose.");
210
+ }
211
+ const now = new Date().toISOString();
212
+ fs.mkdirSync(path.dirname(file), { recursive: true });
213
+ fs.writeFileSync(file, row.after_content, "utf8");
214
+ const verify = sha256(fs.readFileSync(file, "utf8"));
215
+ if (verify !== row.after_hash) {
216
+ // 원상복구 후 실패 노출(폴백 금지).
217
+ fs.writeFileSync(file, row.before_content, "utf8");
218
+ return fail("Applied content did not match the approved hash; restored the original.");
219
+ }
220
+ const tx = db.transaction(() => {
221
+ db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.after_content, row.agent_id);
222
+ insertReceipt(db, row, "apply", row.before_hash, row.after_hash, now);
223
+ db.prepare(
224
+ `UPDATE agent_evolution_proposals
225
+ SET status = 'applied', applied_at = COALESCE(applied_at, ?),
226
+ last_error = NULL, updated_at = ?
227
+ WHERE id = ? AND status = 'candidate'`,
228
+ ).run(now, now, row.id);
229
+ });
230
+ tx();
231
+ out(`applied ${id} → ${row.target_path} (agent ${row.agent_id}). Revert with: agentlas evolve revert ${id}`);
232
+ }
233
+
234
+ function cmdRevert(db, id, out, fail, agentFolder) {
235
+ const row = loadProposal(db, id, fail);
236
+ if (!row) return;
237
+ if (row.status !== "applied" && row.status !== "measured") {
238
+ return fail(`Only an applied proposal can be reverted; ${id} is '${row.status}'.`);
239
+ }
240
+ const applyReceipt = db
241
+ .prepare("SELECT 1 FROM agent_evolution_receipts WHERE proposal_id = ? AND action = 'apply' LIMIT 1")
242
+ .get(row.id);
243
+ if (!applyReceipt) return fail("Revert requires the verified apply receipt.");
244
+ const agent = agentById(db, row.agent_id);
245
+ if (!agent) return fail(`Agent not found for proposal: ${row.agent_id}`);
246
+ const file = targetFilePath(agentFolder, agent, row.target_path);
247
+ const current = currentTargetHash(file);
248
+ if (current.hash !== row.after_hash) {
249
+ return fail("Agent prompt changed after this proposal was applied; revert blocked to avoid clobbering newer edits.");
250
+ }
251
+ const now = new Date().toISOString();
252
+ fs.writeFileSync(file, row.before_content, "utf8");
253
+ const verify = sha256(fs.readFileSync(file, "utf8"));
254
+ if (verify !== row.before_hash) {
255
+ fs.writeFileSync(file, row.after_content, "utf8");
256
+ return fail("Reverted content did not match the original hash; restored the applied version.");
257
+ }
258
+ const tx = db.transaction(() => {
259
+ db.prepare("UPDATE installed_agents SET system_prompt = ? WHERE id = ?").run(row.before_content, row.agent_id);
260
+ insertReceipt(db, row, "rollback", row.after_hash, row.before_hash, now);
261
+ db.prepare(
262
+ `UPDATE agent_evolution_proposals
263
+ SET status = 'rolled_back', rolled_back_at = COALESCE(rolled_back_at, ?),
264
+ last_error = NULL, updated_at = ?
265
+ WHERE id = ? AND status IN ('applied','measured')`,
266
+ ).run(now, now, row.id);
267
+ });
268
+ tx();
269
+ out(`reverted ${id} → restored ${row.target_path} (agent ${row.agent_id}).`);
270
+ }
271
+
272
+ /**
273
+ * cmdEvolve — `agentlas evolve <sub> ...`.
274
+ * @param {{db:any,args:string[],out:(s:string)=>void,fail:(s:string)=>void,agentFolder:(a:any)=>string}} ctx
275
+ */
276
+ function cmdEvolve(ctx) {
277
+ const { db, out, fail, agentFolder } = ctx;
278
+ const args = Array.isArray(ctx.args) ? ctx.args : [];
279
+ const sub = args[0] || "list";
280
+ if (sub === "help" || sub === "--help" || sub === "-h") {
281
+ out("usage: agentlas evolve [list | apply <id> | revert <id>]");
282
+ out(" list — review pending (approval-needed) and auto-applied agent growth proposals");
283
+ out(" apply <id> — approve and apply a pending proposal to the agent prompt");
284
+ out(" revert <id> — roll back an applied proposal");
285
+ return;
286
+ }
287
+ if (sub === "list" || sub === "ls") return cmdList(db, out);
288
+ if (sub === "apply") {
289
+ if (!args[1]) return fail("usage: agentlas evolve apply <id>");
290
+ return cmdApply(db, args[1], out, fail, agentFolder);
291
+ }
292
+ if (sub === "revert" || sub === "rollback") {
293
+ if (!args[1]) return fail("usage: agentlas evolve revert <id>");
294
+ return cmdRevert(db, args[1], out, fail, agentFolder);
295
+ }
296
+ return fail(`Unknown evolve subcommand: ${sub} (list|apply|revert)`);
297
+ }
298
+
299
+ module.exports = { cmdEvolve, countPendingGrowthProposals, listGrowthProposals };
@@ -0,0 +1,315 @@
1
+ "use strict";
2
+
3
+ // `agentlas memory import <path> [--apply]` — Phase 1b.
4
+ //
5
+ // Promote legacy markdown memory into the shared agentlas.sqlite the desktop
6
+ // uses (same userData/agentlas.sqlite). Maps each substantive markdown section
7
+ // to a durable memory_entries row owned by the right layer of a team (member
8
+ // cell / orchestrator / shared team_memory) or a single agent. Mirrors the app's
9
+ // electron/memory/import.ts mapping so app and terminal agree. Dry-run by
10
+ // default (prints the preview table); --apply writes. Idempotent via a stable
11
+ // per-section source-hash sentinel embedded in evidence. Secrets are dropped.
12
+
13
+ const fs = require("node:fs");
14
+ const path = require("node:path");
15
+ const { createHash, randomUUID } = require("node:crypto");
16
+
17
+ const SOURCE_TOKEN_PREFIX = "mem-import:v1";
18
+ const MAX_FILES = 400;
19
+ const MAX_CONTENT = 4000;
20
+
21
+ // ── Section extraction (parity with electron/memory/import.ts) ───────────────
22
+ const TEMPLATE_LINE =
23
+ /^\s*[-*]?\s*(Add\b|Fill in\b|Example:|Record\b|Link\b|Prefer\b|Note\b|Which\b|Date:\s*$|Topic:\s*$|Decision:\s*$|Why:\s*$|Risk accepted:\s*$)/i;
24
+ const META_HEADING =
25
+ /^(How To Use|사용 규칙|사용법|구조|형식|사전 참조|누가 업데이트|Entries|Read First|Memory Rules|Recent Activity|Recently Touched|CROSS-REFERENCES|LEARNINGS LOG|ANTIPATTERNS|GOTCHAS|Repeated Failures|성공 패턴|발견 사항|안티패턴|에이전트 구성)/i;
26
+ const SHARED_HINT =
27
+ /(team[-_ ]?memory|team_memory|glossary|handoff|scope[-_ ]?ownership|common[-_ ]?safety|safety|tone|language|dossier|operating[-_ ]?architecture|memory[-_ ]?architecture|용어|공통|안전|인계|톤)/i;
28
+ const RISK_HINT = /(security|attack|vuln|bug|gotcha|incident|보안|취약|버그|사고)/i;
29
+ const ALWAYS_KEEP_HINT = /(team[-_ ]?memory|glossary|dossier|handoff|safety|scope[-_ ]?ownership|tone)/i;
30
+
31
+ // Minimal secret guard (a subset of the shared secret-patterns chokepoint) so a
32
+ // stray key in legacy notes never becomes a durable memory row.
33
+ const SECRET_RE =
34
+ /(sk-[A-Za-z0-9]{20,}|sk_live_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|gh[opsu]_[A-Za-z0-9]{20,}|AIza[0-9A-Za-z_-]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16})/;
35
+
36
+ function looksSecret(content) {
37
+ return SECRET_RE.test(String(content || ""));
38
+ }
39
+
40
+ function substantiveBody(body) {
41
+ const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
42
+ const real = lines.filter((l) => !TEMPLATE_LINE.test(l) && l.replace(/^#+\s*/, "").length >= 12);
43
+ return real.join("\n");
44
+ }
45
+
46
+ function kindForHeading(heading, fallback) {
47
+ const m = /\[([A-Z_]+)\]/.exec(heading);
48
+ const tag = m ? m[1] : "";
49
+ if (["SUCCESS", "DISCOVERY"].includes(tag)) return "procedure";
50
+ if (["ANTIPATTERN", "GOTCHA", "SECURITY", "CONFIRMED", "REGRESSION", "FALSE_POSITIVE", "BLOCKED_BY_GUARD", "FAILURE"].includes(tag)) {
51
+ return "risk";
52
+ }
53
+ return fallback;
54
+ }
55
+
56
+ function keepSection(heading, body, alwaysKeep) {
57
+ if (META_HEADING.test(heading.replace(/\[[A-Z_]+\]\s*/, "").trim())) return false;
58
+ const hasDate = /\(20\d\d-\d\d-\d\d\)|Date:\s*20\d\d-\d\d-\d\d/.test(heading + "\n" + body);
59
+ const hasTag = /\[[A-Z_]+\]/.test(heading);
60
+ const real = substantiveBody(body);
61
+ if (alwaysKeep) return real.length >= 60;
62
+ if (hasDate || hasTag) return real.length >= 40;
63
+ return real.length >= 160;
64
+ }
65
+
66
+ function splitSections(md) {
67
+ const lines = md.split("\n");
68
+ const sections = [];
69
+ let cur = null;
70
+ for (const line of lines) {
71
+ if (/^#{2,3}\s+\S/.test(line)) {
72
+ if (cur) sections.push(cur);
73
+ cur = { heading: line.replace(/^#{2,3}\s+/, "").trim(), body: "" };
74
+ } else if (cur) {
75
+ cur.body += line + "\n";
76
+ }
77
+ }
78
+ if (cur) sections.push(cur);
79
+ return sections;
80
+ }
81
+
82
+ function splitDatedBullets(md) {
83
+ const idx = md.indexOf("- Date:");
84
+ if (idx === -1) return [];
85
+ return md
86
+ .slice(idx)
87
+ .split(/\n(?=- Date:)/)
88
+ .map((p) => p.trim())
89
+ .filter((p) => /Date:\s*20\d\d-\d\d-\d\d/.test(p))
90
+ .map((p) => {
91
+ const topic = /Topic:\s*(.+)/.exec(p);
92
+ const date = /Date:\s*(20\d\d-\d\d-\d\d)/.exec(p);
93
+ return { heading: `Decision ${date ? date[1] : ""}: ${topic ? topic[1].trim() : ""}`.trim(), body: p };
94
+ });
95
+ }
96
+
97
+ function normalizeToken(value) {
98
+ return String(value || "").toLowerCase().replace(/[^a-z0-9가-힣]+/g, " ").trim();
99
+ }
100
+
101
+ function resolveTarget(db, agentId) {
102
+ let firms = [];
103
+ try {
104
+ firms = db.prepare("SELECT id, ceo_agent_id, org_chart_json FROM firms").all();
105
+ } catch {
106
+ firms = [];
107
+ }
108
+ const firm =
109
+ firms.find((f) => f.id === agentId) ||
110
+ firms.find((f) => f.ceo_agent_id === agentId) ||
111
+ null;
112
+ if (!firm) return { agentId, kind: "agent", members: [] };
113
+ let chart = [];
114
+ try {
115
+ const parsed = JSON.parse(firm.org_chart_json);
116
+ if (Array.isArray(parsed)) chart = parsed;
117
+ } catch {
118
+ chart = [];
119
+ }
120
+ const members = chart
121
+ .filter((node) => node && node.agentId && node.agentId !== firm.ceo_agent_id)
122
+ .map((node) => ({ agentId: node.agentId, role: node.role || node.agentSlug, slug: node.agentSlug }));
123
+ return { agentId: firm.ceo_agent_id, kind: "team", members };
124
+ }
125
+
126
+ function matchMember(fileTokens, target) {
127
+ if (!target.members.length) return null;
128
+ let best = null;
129
+ for (const member of target.members) {
130
+ const roleTokens = normalizeToken(member.role).split(" ").filter((t) => t.length >= 3);
131
+ const slugTokens = normalizeToken(member.slug).split(" ").filter((t) => t.length >= 3);
132
+ const tokens = [...new Set([...roleTokens, ...slugTokens])];
133
+ let score = 0;
134
+ for (const token of tokens) if (fileTokens.includes(token)) score += token.length;
135
+ if (score > 0 && (!best || score > best.score)) best = { agentId: member.agentId, role: member.role, score };
136
+ }
137
+ return best ? { agentId: best.agentId, role: best.role } : null;
138
+ }
139
+
140
+ function decideOwner(relFile, target) {
141
+ const lower = relFile.toLowerCase();
142
+ const fileTokens = normalizeToken(relFile);
143
+ const alwaysKeep = ALWAYS_KEEP_HINT.test(lower);
144
+ if (SHARED_HINT.test(lower)) {
145
+ return {
146
+ scope: "team_memory",
147
+ ownerAgentId: null,
148
+ ownerLabel: "team_memory",
149
+ fallbackKind: /glossary|dossier|용어/i.test(lower) ? "fact" : "procedure",
150
+ alwaysKeep,
151
+ };
152
+ }
153
+ if (target.kind === "team") {
154
+ const member = matchMember(fileTokens, target);
155
+ if (member) {
156
+ return { scope: "agent_repo", ownerAgentId: member.agentId, ownerLabel: member.role, fallbackKind: RISK_HINT.test(lower) ? "risk" : "procedure", alwaysKeep };
157
+ }
158
+ return { scope: "agent_repo", ownerAgentId: target.agentId, ownerLabel: "orchestrator", fallbackKind: RISK_HINT.test(lower) ? "risk" : "decision", alwaysKeep };
159
+ }
160
+ return { scope: "agent_repo", ownerAgentId: target.agentId, ownerLabel: "agent", fallbackKind: RISK_HINT.test(lower) ? "risk" : "procedure", alwaysKeep };
161
+ }
162
+
163
+ function collectMarkdown(root) {
164
+ const stat = fs.statSync(root);
165
+ if (stat.isFile()) {
166
+ return /\.(md|markdown|mdx|txt)$/i.test(root) ? [{ abs: root, rel: path.basename(root) }] : [];
167
+ }
168
+ const out = [];
169
+ const walk = (dir) => {
170
+ if (out.length >= MAX_FILES) return;
171
+ let entries;
172
+ try {
173
+ entries = fs.readdirSync(dir, { withFileTypes: true });
174
+ } catch {
175
+ return;
176
+ }
177
+ for (const entry of entries) {
178
+ if (out.length >= MAX_FILES) return;
179
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
180
+ const abs = path.join(dir, entry.name);
181
+ if (entry.isDirectory()) walk(abs);
182
+ else if (entry.isFile() && /\.(md|markdown|mdx|txt)$/i.test(entry.name)) out.push({ abs, rel: path.relative(root, abs) });
183
+ }
184
+ };
185
+ walk(root);
186
+ return out.sort((a, b) => a.rel.localeCompare(b.rel));
187
+ }
188
+
189
+ function stableToken(relFile, heading) {
190
+ const hash = createHash("sha256").update(`${SOURCE_TOKEN_PREFIX}|${relFile}|${heading}`).digest("hex").slice(0, 16);
191
+ return `${SOURCE_TOKEN_PREFIX}:${hash}`;
192
+ }
193
+
194
+ function buildEntries(sourcePath, target) {
195
+ const built = [];
196
+ for (const { abs, rel } of collectMarkdown(sourcePath)) {
197
+ let md;
198
+ try {
199
+ md = fs.readFileSync(abs, "utf8");
200
+ } catch {
201
+ continue;
202
+ }
203
+ const owner = decideOwner(rel, target);
204
+ const sections = /decisions\.md$/i.test(rel) ? splitDatedBullets(md) : splitSections(md);
205
+ for (const sec of sections) {
206
+ if (!keepSection(sec.heading, sec.body, owner.alwaysKeep)) continue;
207
+ const bodyText = sec.body.replace(/\n{3,}/g, "\n\n").trim();
208
+ const content = `${sec.heading}\n${bodyText}`.trim().slice(0, MAX_CONTENT);
209
+ if (content.length < 40) continue;
210
+ built.push({
211
+ token: stableToken(rel, sec.heading),
212
+ relFile: rel,
213
+ heading: sec.heading,
214
+ content,
215
+ scope: owner.scope,
216
+ kind: kindForHeading(sec.heading, owner.fallbackKind),
217
+ ownerAgentId: owner.ownerAgentId,
218
+ ownerLabel: owner.ownerLabel,
219
+ redacted: looksSecret(content),
220
+ });
221
+ }
222
+ }
223
+ return built;
224
+ }
225
+
226
+ function existsByToken(db, token) {
227
+ try {
228
+ return Boolean(db.prepare("SELECT 1 FROM memory_entries WHERE evidence_json LIKE ? LIMIT 1").get(`%${token}%`));
229
+ } catch {
230
+ return false;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * cmdMemory — `agentlas memory <sub> ...`. Currently: import.
236
+ * @param {{db:any,args:string[],out:(s:string)=>void,fail:(s:string)=>void}} ctx
237
+ */
238
+ function cmdMemory(ctx) {
239
+ const { db, out, fail } = ctx;
240
+ const args = Array.isArray(ctx.args) ? ctx.args : [];
241
+ const sub = args[0] || "help";
242
+ if (sub === "help" || sub === "--help" || sub === "-h") {
243
+ out("usage: agentlas memory import <folder-or-file> --agent <agentId> [--apply]");
244
+ out(" dry-run by default (prints the preview table); --apply writes to the shared DB.");
245
+ return;
246
+ }
247
+ if (sub !== "import") return fail(`Unknown memory subcommand: ${sub} (import)`);
248
+
249
+ const apply = args.includes("--apply");
250
+ const agentIdx = args.indexOf("--agent");
251
+ const agentId = agentIdx >= 0 ? String(args[agentIdx + 1] || "").trim() : "";
252
+ const positional = args.slice(1).filter((a, i, arr) => a !== "--apply" && a !== "--agent" && arr[i - 1] !== "--agent");
253
+ const rawPath = positional[0];
254
+ if (!rawPath) return fail('usage: agentlas memory import <folder-or-file> --agent <agentId> [--apply]');
255
+ if (!agentId) return fail("memory import requires --agent <agentId> (the single agent or team to import into).");
256
+ const sourcePath = path.resolve(rawPath);
257
+ if (!fs.existsSync(sourcePath)) return fail(`Import source not found: ${sourcePath}`);
258
+
259
+ const target = resolveTarget(db, agentId);
260
+ const entries = buildEntries(sourcePath, target);
261
+
262
+ out(`== memory import (${apply ? "APPLY" : "DRY-RUN"}) ==`);
263
+ out(`source: ${sourcePath}`);
264
+ out(`target: ${agentId} (${target.kind})`);
265
+ out("");
266
+ out(pad("OWNER", 26) + pad("KIND", 10) + pad("STATUS", 8) + "SECTION");
267
+ const byOwner = {};
268
+ let newCount = 0;
269
+ let dupCount = 0;
270
+ let redacted = 0;
271
+ for (const e of entries) {
272
+ const status = e.redacted ? "skip" : existsByToken(db, e.token) ? "dup" : "new";
273
+ if (status === "new") {
274
+ newCount += 1;
275
+ byOwner[e.ownerLabel] = (byOwner[e.ownerLabel] || 0) + 1;
276
+ } else if (status === "dup") dupCount += 1;
277
+ else redacted += 1;
278
+ out(pad(e.ownerLabel, 26) + pad(e.kind, 10) + pad(status, 8) + e.heading.slice(0, 70));
279
+ }
280
+ out("");
281
+ out(`total ${entries.length} · new ${newCount} · duplicate ${dupCount} · redacted ${redacted}`);
282
+
283
+ if (!apply) {
284
+ out("");
285
+ out("dry-run — nothing written. Re-run with --apply to write to the shared agentlas.sqlite.");
286
+ return;
287
+ }
288
+
289
+ const now = new Date().toISOString();
290
+ let imported = 0;
291
+ const insert = db.prepare(
292
+ "INSERT INTO memory_entries (id,scope,kind,content,project_id,project_path,agent_id,chat_id,confidence,sensitivity,evidence_json,context_json,superseded_at,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,NULL,?)",
293
+ );
294
+ const write = db.transaction((list) => {
295
+ for (const e of list) {
296
+ if (e.redacted || existsByToken(db, e.token)) continue;
297
+ const confidence = /\(20\d\d-\d\d-\d\d\)|Date:\s*20\d\d/.test(e.content) ? "high" : "medium";
298
+ const context = JSON.stringify({ userIntent: `Imported memory: ${e.heading}`.slice(0, 200), outcome: "imported-from-existing-memory" });
299
+ const evidence = JSON.stringify([e.token, `source:memory-import/${e.relFile}`]);
300
+ insert.run(randomUUID(), e.scope, e.kind, e.content, null, null, e.ownerAgentId, null, confidence, "internal", evidence, context, now);
301
+ imported += 1;
302
+ }
303
+ });
304
+ write(entries);
305
+
306
+ out("");
307
+ out(`imported ${imported} memory entries into the shared DB. (Embedding runs in the desktop app on next open.)`);
308
+ }
309
+
310
+ function pad(value, width) {
311
+ const s = String(value == null ? "" : value);
312
+ return s.length >= width ? s.slice(0, width - 1) + " " : s + " ".repeat(width - s.length);
313
+ }
314
+
315
+ module.exports = { cmdMemory, buildEntries, resolveTarget, decideOwner };
@@ -2210,43 +2210,107 @@ async function fetchPluginManifestCli(slug) {
2210
2210
  return manifest;
2211
2211
  }
2212
2212
 
2213
- /** 매니페스트의 mcp[] 항목을 mcp_servers 행으로 정규화. stdio(command)와 remote(url)를 구분한다. */
2213
+ // 레포/홈페이지 HTML 페이지는 문서지 MCP 연결이 아니다. URL들을 transport:"http"로
2214
+ // 등록하면 "절대 연결될 수 없는 MCP 서버"가 생긴다(2026-07-23 근본수리 계열).
2215
+ const PLUGIN_CODE_HOSTING_HTML_RE = /^https?:\/\/(www\.)?(github\.com|gitlab\.com|bitbucket\.org)\//i;
2216
+
2217
+ /** 휴리스틱: 명시적 transport 선언이 없는 레거시 source URL이 진짜 MCP 엔드포인트로 보이는가. */
2218
+ function pluginLooksLikeMcpEndpointCli(rawUrl) {
2219
+ let parsed;
2220
+ try {
2221
+ parsed = new URL(String(rawUrl || ""));
2222
+ } catch {
2223
+ return false;
2224
+ }
2225
+ if (!/^https?:$/.test(parsed.protocol)) return false;
2226
+ if (PLUGIN_CODE_HOSTING_HTML_RE.test(parsed.href)) return false;
2227
+ const pathname = parsed.pathname.replace(/\/+$/, "");
2228
+ if (/\/(mcp|sse)$/i.test(pathname)) return true; // …/mcp, …/sse 관례
2229
+ if (/^mcp\./i.test(parsed.hostname)) return true; // mcp.linear.app 류 전용 호스트
2230
+ return false;
2231
+ }
2232
+
2233
+ /**
2234
+ * 매니페스트의 mcp[] 항목을 mcp_servers 행으로 정규화. stdio(command)와 remote(url)를 구분한다.
2235
+ * 반환: { row } | { refused: { name, source, reason } } | null(빈 항목).
2236
+ *
2237
+ * 규칙(근본수리): 레포 URL은 어떤 경우에도 transport:"http" 행으로 쓰이지 않는다.
2238
+ * - transport:"stdio" + command 명시 → stdio 행 (mcp_servers는 command/args_json을 이미 지원).
2239
+ * - transport:"http" + url 명시 → http 행. 단 코드호스팅 HTML 페이지(github.com/…)면 거부.
2240
+ * - 레거시 {name, source}: http(s)면 MCP 엔드포인트로 보일 때만(…/mcp, …/sse, mcp.* 호스트) 수용,
2241
+ * 아니면 거부. 비-URL 문자열은 기존대로 stdio 실행 커맨드로 해석.
2242
+ */
2214
2243
  function pluginMcpRowCli(slug, entry, index) {
2215
- const source = typeof entry?.source === "string" ? entry.source.trim() : "";
2216
2244
  const name = (typeof entry?.name === "string" && entry.name.trim()) || `${slug}-${index + 1}`;
2217
- const remote = /^https?:\/\//i.test(source);
2245
+ const source = typeof entry?.source === "string" ? entry.source.trim() : "";
2246
+ const transport = typeof entry?.transport === "string" ? entry.transport.trim().toLowerCase() : "";
2247
+ const envKeys = Array.isArray(entry?.envKeys)
2248
+ ? entry.envKeys.filter((key) => typeof key === "string")
2249
+ : entry?.env && typeof entry.env === "object"
2250
+ ? Object.keys(entry.env)
2251
+ : [];
2252
+ const makeRow = (fields) => ({
2253
+ row: {
2254
+ id: require("node:crypto").randomUUID(),
2255
+ catalogId: `hub:${slug}:${name}`,
2256
+ name,
2257
+ envKeysJson: JSON.stringify(envKeys),
2258
+ ...fields,
2259
+ },
2260
+ });
2261
+ const refuse = (reason) => ({ refused: { name, source: source || (typeof entry?.url === "string" ? entry.url : ""), reason } });
2262
+
2263
+ if (transport === "stdio") {
2264
+ const command = typeof entry?.command === "string" ? entry.command.trim() : "";
2265
+ if (!command) return refuse("stdio row without a launch command");
2266
+ const args = Array.isArray(entry?.args) ? entry.args.filter((a) => typeof a === "string") : [];
2267
+ return makeRow({ transport: "stdio", command, argsJson: JSON.stringify(args), url: null });
2268
+ }
2269
+ if (transport === "http" || transport === "sse") {
2270
+ const url = typeof entry?.url === "string" && entry.url.trim() ? entry.url.trim() : source;
2271
+ if (!/^https?:\/\//i.test(url)) return refuse("http row without a usable endpoint URL");
2272
+ if (PLUGIN_CODE_HOSTING_HTML_RE.test(url)) {
2273
+ return refuse("URL is a code-hosting HTML page (docs), not an MCP endpoint");
2274
+ }
2275
+ // 명시적 transport 선언은 서버가 검증한 연결정보로 신뢰한다 (레포 페이지만 방어).
2276
+ return makeRow({ transport: "http", command: null, argsJson: "[]", url });
2277
+ }
2278
+ if (transport) return refuse(`unsupported transport "${transport}"`);
2279
+
2280
+ // ── 레거시 {name, source} 행 ──
2281
+ if (!source) return null;
2282
+ if (/^https?:\/\//i.test(source)) {
2283
+ if (!pluginLooksLikeMcpEndpointCli(source)) {
2284
+ return refuse(
2285
+ PLUGIN_CODE_HOSTING_HTML_RE.test(source)
2286
+ ? "URL is a code-hosting HTML page (docs), not an MCP endpoint"
2287
+ : "URL does not look like an MCP endpoint (no /mcp, /sse, or mcp.* host, and no declared transport)",
2288
+ );
2289
+ }
2290
+ return makeRow({ transport: "http", command: null, argsJson: "[]", url: source });
2291
+ }
2218
2292
  // 원격은 URL, stdio는 실행 커맨드다. 둘을 섞으면 codex config.toml 스키마 위반으로
2219
2293
  // 런타임이 통째로 죽는다(Runtime Doctor가 반복해서 잡던 사고 계열).
2220
- if (!remote && !source) return null;
2221
- const argv = remote ? [] : source.split(/\s+/).filter(Boolean);
2222
- return {
2223
- id: require("node:crypto").randomUUID(),
2224
- catalogId: `hub:${slug}:${name}`,
2225
- name,
2226
- transport: remote ? "http" : "stdio",
2227
- command: remote ? null : (argv[0] ?? null),
2228
- argsJson: JSON.stringify(remote ? [] : argv.slice(1)),
2229
- url: remote ? source : null,
2230
- envKeysJson: JSON.stringify(
2231
- Array.isArray(entry?.envKeys) ? entry.envKeys.filter((key) => typeof key === "string") : [],
2232
- ),
2233
- };
2294
+ const argv = source.split(/\s+/).filter(Boolean);
2295
+ return makeRow({ transport: "stdio", command: argv[0] ?? null, argsJson: JSON.stringify(argv.slice(1)), url: null });
2234
2296
  }
2235
2297
 
2236
- async function cmdPluginAdd(db, slug) {
2237
- if (!slug) fail('usage: agentlas plugin add <slug> (run agentlas plugin list first)');
2238
- const manifest = await fetchPluginManifestCli(slug);
2239
- if (!manifest) fail(`Hub plugin not found: ${slug}`);
2240
- const entries = Array.isArray(manifest.mcp) ? manifest.mcp : [];
2241
- const rows = entries.map((entry, index) => pluginMcpRowCli(slug, entry, index)).filter(Boolean);
2242
- if (!rows.length) {
2243
- // 설치할 MCP 서버가 없으면 조용히 성공했다고 하지 않는다 — 사용자는 이 플러그인이
2244
- // 붙었다고 믿고 도구를 기대하게 된다.
2245
- fail(
2246
- `${slug} ships no MCP server to install (skills-only or source-link plugin). ` +
2247
- `Nothing was registered. See: ${manifest.source?.repo || manifest.source?.homepage || "the plugin page"}`,
2248
- );
2249
- }
2298
+ /** 매니페스트 전체를 설치 계획으로 정규화: 등록할 행과, 정직하게 거부한 항목을 분리한다. */
2299
+ function planPluginMcpInstallCli(slug, manifest) {
2300
+ const entries = Array.isArray(manifest?.mcp) ? manifest.mcp : [];
2301
+ const rows = [];
2302
+ const refused = [];
2303
+ entries.forEach((entry, index) => {
2304
+ const normalized = pluginMcpRowCli(slug, entry, index);
2305
+ if (!normalized) return;
2306
+ if (normalized.row) rows.push(normalized.row);
2307
+ else if (normalized.refused) refused.push(normalized.refused);
2308
+ });
2309
+ return { rows, refused };
2310
+ }
2311
+
2312
+ /** 정규화된 행들을 로컬 mcp_servers 스키마에 멱등 삽입. { installed, reused } 반환. */
2313
+ function installPluginMcpRowsCli(db, rows) {
2250
2314
  let installed = 0;
2251
2315
  let reused = 0;
2252
2316
  for (const row of rows) {
@@ -2261,7 +2325,34 @@ async function cmdPluginAdd(db, slug) {
2261
2325
  );
2262
2326
  installed += 1;
2263
2327
  }
2328
+ return { installed, reused };
2329
+ }
2330
+
2331
+ async function cmdPluginAdd(db, slug) {
2332
+ if (!slug) fail('usage: agentlas plugin add <slug> (run agentlas plugin list first)');
2333
+ const manifest = await fetchPluginManifestCli(slug);
2334
+ if (!manifest) fail(`Hub plugin not found: ${slug}`);
2335
+ const { rows, refused } = planPluginMcpInstallCli(slug, manifest);
2336
+ const docsLink = manifest.docs || manifest.source?.repo || manifest.source?.homepage || null;
2337
+ if (!rows.length) {
2338
+ // 설치할 MCP 서버가 없으면 조용히 성공했다고 하지 않는다 — 사용자는 이 플러그인이
2339
+ // 붙었다고 믿고 도구를 기대하게 된다. 레포 URL을 http MCP 서버로 등록하는 일도
2340
+ // 절대 하지 않는다(연결 불가능한 가짜 서버).
2341
+ const reasonLines = refused.map((item) => ` ✗ ${item.name}: ${item.reason}${item.source ? ` (${item.source})` : ""}`);
2342
+ fail(
2343
+ [
2344
+ `${slug} ships no machine-connectable MCP endpoint yet. Nothing was registered.`,
2345
+ ...reasonLines,
2346
+ docsLink ? ` docs: ${docsLink} (upstream project page — not an MCP endpoint)` : null,
2347
+ " When the catalog gains verified connection info for this plugin, re-run: agentlas plugin add " + slug,
2348
+ ].filter(Boolean).join("\n"),
2349
+ );
2350
+ }
2351
+ const { installed, reused } = installPluginMcpRowsCli(db, rows);
2264
2352
  out(`✓ Plugin installed ${manifest.slug} — ${manifest.name}`);
2353
+ for (const item of refused) {
2354
+ out(` ⚠ skipped ${item.name}: ${item.reason}${item.source ? ` (${item.source})` : ""}`);
2355
+ }
2265
2356
  out(` MCP servers: ${installed} added${reused ? `, ${reused} already present` : ""}`);
2266
2357
  const authKind = manifest.auth?.kind;
2267
2358
  if (authKind && authKind !== "none") {
@@ -9931,6 +10022,19 @@ function cmdList(db) {
9931
10022
  : "\n(Built-in orchestration agents run in the background. Find agents with `agentlas cloud search \"what you need\"`, or just open `agentlas` and type a task.)",
9932
10023
  );
9933
10024
  }
10025
+ // Phase 2+: 검토 대기 중인 에이전트 성장 제안이 있으면 홈에 한 줄로 노출.
10026
+ try {
10027
+ const pendingGrowth = require("./agentlas-evolution.cjs").countPendingGrowthProposals(db);
10028
+ if (pendingGrowth > 0) {
10029
+ out(
10030
+ lang === "ko"
10031
+ ? `\n🧬 에이전트 성장 제안 ${pendingGrowth}건 · \`agentlas evolve\`로 검토`
10032
+ : `\n🧬 ${pendingGrowth} agent growth proposal(s) · review with \`agentlas evolve\``,
10033
+ );
10034
+ }
10035
+ } catch {
10036
+ /* 홈 배너 실패는 무해 */
10037
+ }
9934
10038
  out("\nRun: agentlas <agent> · agentlas firm <firm> · agentlas run <agent> \"...\"");
9935
10039
  }
9936
10040
 
@@ -11646,6 +11750,12 @@ async function main() {
11646
11750
  return cmdFirm(db, rest[1], rest.slice(2).join(" "), runtimeOverride);
11647
11751
  case "env":
11648
11752
  return cmdEnv(db);
11753
+ case "memory":
11754
+ // Phase 1b: 기존 마크다운 메모리 → 공유 agentlas.sqlite 이관(dry-run 기본, --apply).
11755
+ return require("./agentlas-memory-import.cjs").cmdMemory({ db, args: rest.slice(1), out, fail });
11756
+ case "evolve":
11757
+ // Phase 2/2+: 데스크탑 트리거가 만든 성장 제안 검토·적용·되돌리기(공유 DB).
11758
+ return require("./agentlas-evolution.cjs").cmdEvolve({ db, args: rest.slice(1), out, fail, agentFolder });
11649
11759
  case "multimodal":
11650
11760
  return cmdMultimodal(db, rest.slice(1));
11651
11761
  case "oberon":
@@ -11875,4 +11985,9 @@ module.exports = {
11875
11985
  autoRouteNote,
11876
11986
  autoRoutePreamble,
11877
11987
  directSystemPrompt,
11988
+ // Hub 플러그인 설치 회귀 테스트 표면 — 레포 URL을 MCP 서버로 등록하지 않는 규칙 검증용.
11989
+ pluginMcpRowCli,
11990
+ planPluginMcpInstallCli,
11991
+ installPluginMcpRowsCli,
11992
+ pluginLooksLikeMcpEndpointCli,
11878
11993
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlas",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "description": "Agentlas agent terminal — chat with your installed AI agents and teams from the terminal, Claude Code style. Standalone: no desktop app required.",
5
5
  "bin": {
6
6
  "agentlas": "bin/agentlas.cjs"