agentlas 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,8 @@ const fs = require("node:fs");
29
29
  const { spawn } = require("node:child_process");
30
30
  const crypto = require("node:crypto");
31
31
  const { compareSemVer, normalizeSemVer, parseSemVer } = require("./semver.cjs");
32
+ const terminalAssets = require("./agentlas-experience-mcp.cjs");
33
+ const terminalExperienceExchange = require("./agentlas-experience-exchange.cjs");
32
34
 
33
35
  // ── 앱과 동일한 userData 경로 (electron app.getPath('userData')와 일치) ──
34
36
  function userDataDir() {
@@ -93,6 +95,19 @@ function openNodeSqliteDb(p) {
93
95
  run: (...args) => stmt.run(...args),
94
96
  };
95
97
  },
98
+ // better-sqlite3 API 패리티 — 폴백 경로에서도 db.exec/db.pragma가 있어야 한다.
99
+ // 누락 시 ensureMemoryContextColumn 등 ALTER TABLE(exec)이 TypeError로 조용히 죽어
100
+ // (try/catch 삼킴) context_json 컬럼 마이그레이션이 되지 않고 memory 조회가 깨진다.
101
+ exec: (sql) => db.exec(sql),
102
+ pragma: (source) => {
103
+ const rows = db.prepare(`PRAGMA ${source}`).all();
104
+ // better-sqlite3 pragma()의 단일값 반환 관례를 근사(단일 컬럼·단일 행 → 스칼라).
105
+ if (rows.length === 1) {
106
+ const keys = Object.keys(rows[0]);
107
+ if (keys.length === 1) return rows[0][keys[0]];
108
+ }
109
+ return rows;
110
+ },
96
111
  transaction(fn) {
97
112
  return (...args) => {
98
113
  db.exec("BEGIN");
@@ -280,7 +295,17 @@ const AGENT_BUILD_TERMS = [
280
295
  const BUILD_ENTITY_RE = /(에이전트|agent|팀|team|회사|company)/i;
281
296
  const BUILD_VERB_RE = /(만들|만든|생성|구축|구성해|꾸려|세팅|패키징|scaffold|build|create|\bmake\b|set\s?up|spin\s?up)/i;
282
297
  function isAgentBuildIntent(prompt) {
283
- const p = routeNormalize(prompt);
298
+ // 경로/파일 참조는 빌드 의도의 증거가 아니다 — "/Users/x/agent-tools/notes.md 요약본
299
+ // 만들어줘"의 디렉터리명이나 "agent-notes.md" 같은 파일명이 BUILD_ENTITY_RE를 때려
300
+ // 메타빌더(score 1000)로 직행하던 우회로 차단. 빌드 의도는 산문에서만 읽는다.
301
+ // ⚠️ 남은 슬래시는 통째로 지우지 않고 공백으로만 벌린다 — "에이전트/팀 만들어줘"의
302
+ // 슬래시-엔티티("에이전트/팀")를 삭제하면 BUILD_ENTITY_RE가 못 맞아 빌드 의도를 놓친다.
303
+ // 진짜 경로는 이미 routeStripPaths(마지막 세그먼트만)+확장자 제거가 처리했다.
304
+ const p = routeNormalize(
305
+ routeStripPaths(prompt)
306
+ .replace(/\S+\.[A-Za-z0-9]{1,6}(?=\s|$)/g, " ")
307
+ .replace(/[\\/]+/g, " "),
308
+ );
284
309
  if (!p.trim() || isTrivialRoutePrompt(p)) return false;
285
310
  if (AGENT_BUILD_TERMS.some((term) => p.includes(routeNormalize(term)))) return true;
286
311
  // 예: "단일 에이전트 하나만 만들어줘", "팀 좀 꾸려줘", "make me an agent"
@@ -302,7 +327,9 @@ function resolveMetaBuilder(db) {
302
327
  }
303
328
  // "ai"/"llm" 같은 초범용 토큰은 모든 에이전트 프롬프트에 나오므로 판별력이 0이다 —
304
329
  // 이런 단어 하나로 전문 에이전트가 선택되던 오라우팅(예: 일반 맥 질문 → Pitch Deck Architect)을 막는다.
305
- const ROUTE_STOP_WORDS = new Set(["the", "and", "for", "with", "this", "that", "from", "into", "make", "build", "create", "agent", "agents", "please", "ai", "llm", "인공지능", "에이아이", "좀", "해주세요", "해줘", "만들어", "붙여", "연결", "작업", "요청"]);
330
+ // "local"/"imported"/"team" 임포터 보일러플레이트("Imported local team")와 slug 접두/접미에
331
+ // 편재해 판별력이 없다 — 'team' 한 단어가 아무 임포트 팀의 slug 부분문자열(+6 strong)을 때리던 구멍.
332
+ const ROUTE_STOP_WORDS = new Set(["the", "and", "for", "with", "this", "that", "from", "into", "make", "build", "create", "agent", "agents", "team", "please", "ai", "llm", "local", "imported", "인공지능", "에이아이", "좀", "해주세요", "해줘", "만들어", "붙여", "연결", "작업", "요청"]);
306
333
  const ROUTE_HINTS = [
307
334
  {
308
335
  slug: "agentlas-app-builder",
@@ -360,24 +387,46 @@ const ROUTE_HINTS = [
360
387
  function routeNormalize(value) {
361
388
  return String(value || "").toLowerCase().replace(/[_/]+/g, "-");
362
389
  }
390
+ // 경로 디렉터리 성분은 라우팅 의도가 아니다 — 마지막 세그먼트(파일/폴더명)만 남긴다.
391
+ // 사고(2026-07-12): "/Users/mason/Documents/…/Appbridge_Template.이 …" 프롬프트의 경로 토큰
392
+ // ("users","mason","documents","users-mason-documents-")이 임포트 에이전트 system_prompt 속
393
+ // 절대경로와 맞아떨어져 +2씩 쌓이고 라우팅 근거에까지 노출됐다. 프롬프트/헤이스택 양쪽에
394
+ // 대칭 적용해 경로↔경로 우연 일치를 차단한다. 파일/폴더명은 실제 의도라서 보존한다.
395
+ // 규칙: 공백/인용부호/괄호 뒤(또는 문자열 시작)에서 시작하고, "세그먼트+구분자"가 2회 이상
396
+ // 이어지는 절대·홈·드라이브·UNC 경로만 경로로 본다 — "and/or", "서울/부산", 날짜(2026/07/12),
397
+ // "https://…"(콜론 뒤 //는 시작 조건 불충족)는 건드리지 않는다. 세그먼트 안의 단일 공백은
398
+ // 뒤가 대문자로 시작할 때만 허용해 "Mobile Documents"/"Application Support"는 접되,
399
+ // "/tmp/out 기획/디자인 …" 같은 한글 프로즈를 경로로 삼켜버리지 않는다. 상대경로는
400
+ // 확장자 있는 파일 참조("docs/plan/roadmap.md")만 접는다 — 디렉터리명("plan")이 힌트/이름
401
+ // strong 채널을 때리는 것을 막으면서 "서울/부산/대구" 같은 나열은 보존한다.
402
+ const ROUTE_PATH_RE = /(^|[\s"'`(<\[{])((?:~|[A-Za-z]:)?[\\/]{1,2}(?:[^\s\\/]+(?: [A-Z][^\s\\/]*)?[\\/]+){2,}[^\s\\/]*|(?:[^\s\\/]+[\\/]+){2,}[^\s\\/]+\.[A-Za-z0-9]{1,6})/g;
403
+ function routeStripPaths(value) {
404
+ return String(value || "").replace(ROUTE_PATH_RE, (whole, pre, p) => {
405
+ const segs = p.split(/[\\/]+/).filter(Boolean);
406
+ return pre + (segs.length ? segs[segs.length - 1] : "");
407
+ });
408
+ }
363
409
  function routeTokenize(value) {
364
- const matches = routeNormalize(value).match(/[a-z0-9][a-z0-9-]{1,}|[가-힣]{2,}/g) || [];
410
+ // 매치가 영숫자로 끝나도록 강제해 "users-mason-documents-" 같은 후행 하이픈 토큰을 원천 차단.
411
+ const matches = routeNormalize(routeStripPaths(value)).match(/[a-z0-9][a-z0-9-]*[a-z0-9]|[가-힣]{2,}/g) || [];
365
412
  const expanded = matches.flatMap((term) => term.split("-").filter(Boolean).concat(term));
366
413
  return [...new Set(expanded.filter((term) => term.length >= 2 && !ROUTE_STOP_WORDS.has(term)))];
367
414
  }
368
415
  // 정체성 존(slug/이름/태그라인) — 여기 적중은 강한 라우팅 신호. system_prompt 본문 적중은 약한 신호.
416
+ // 임포터 보일러플레이트 태그라인("Imported local team/agent")의 세 단어는 전부 스톱워드라
417
+ // 프롬프트 토큰이 될 수 없다 — 별도 필터 불필요.
369
418
  function routeIdentityHaystack(agent) {
370
- return routeNormalize([agent.slug, agent.name, agent.name_en, agent.tagline, agent.tagline_en].join("\n"));
419
+ return routeNormalize(routeStripPaths([agent.slug, agent.name, agent.name_en, agent.tagline, agent.tagline_en].join("\n")));
371
420
  }
372
421
  function routeHaystack(agent) {
373
- return routeNormalize([
422
+ return routeNormalize(routeStripPaths([
374
423
  agent.slug,
375
424
  agent.name,
376
425
  agent.name_en,
377
426
  agent.tagline,
378
427
  agent.tagline_en,
379
428
  String(agent.system_prompt || "").slice(0, 3500),
380
- ].join("\n"));
429
+ ].join("\n")));
381
430
  }
382
431
  const APP_BUILDER_EXPLICIT_TERMS = [
383
432
  "apps generate", "app builder", "make an app", "build an app", "create an app",
@@ -420,7 +469,7 @@ function isTrivialRoutePrompt(promptText) {
420
469
  return words.length <= 3 && TRIVIAL_ROUTE_PROMPTS.has(stripped);
421
470
  }
422
471
  function isAppBuilderWorthyRoutePrompt(prompt) {
423
- const promptText = routeNormalize(prompt);
472
+ const promptText = routeNormalize(routeStripPaths(prompt));
424
473
  if (!promptText.trim() || isTrivialRoutePrompt(promptText)) return false;
425
474
  const explicit = routeMatchedTerms(promptText, APP_BUILDER_EXPLICIT_TERMS);
426
475
  if (explicit.length) return true;
@@ -442,8 +491,10 @@ function routeHint(promptText, agent, lang) {
442
491
  if (!terms.length) return { score: 0, terms: [], reason: "" };
443
492
  return { score: 12 + terms.length * 3, terms, reason: lang === "ko" ? hint.reasonKo : hint.reasonEn };
444
493
  }
445
- function scoreRouteAgent(prompt, promptTerms, agent, lang) {
446
- const promptText = routeNormalize(prompt);
494
+ function scoreRouteAgent(prompt, promptTerms, agent, lang, pre) {
495
+ // 대칭 스트리핑 필수: promptText 이름(+20)·힌트(+12↑) strong 채널의 입력이라, 여기서
496
+ // 경로를 안 벗기면 "/Users/x/project-plan/…"의 디렉터리명이 strong 게이트를 그대로 뚫는다.
497
+ const promptText = routeNormalize(routeStripPaths(prompt));
447
498
  if (agent.slug === "agentlas-app-builder" && !isAppBuilderWorthyRoutePrompt(promptText)) {
448
499
  return {
449
500
  agent,
@@ -452,24 +503,33 @@ function scoreRouteAgent(prompt, promptTerms, agent, lang) {
452
503
  ? "전용 App을 만들 만큼 반복·상태·편집·자동화가 뚜렷하지 않아 App Builder 라우트를 보류했습니다"
453
504
  : "the request does not clearly need a dedicated App with durable workflow, state, editing, or automation",
454
505
  terms: [],
506
+ strong: false,
455
507
  };
456
508
  }
457
- const identityHay = routeIdentityHaystack(agent);
458
- const haystack = routeHaystack(agent);
509
+ // 헤이스택은 설치/임포트 시에만 변하므로 autoRouteAgent가 미리 계산해 넘긴다(중복 계산 제거).
510
+ const identityHay = (pre && pre.identityHay) || routeIdentityHaystack(agent);
511
+ const haystack = (pre && pre.haystack) || routeHaystack(agent);
459
512
  let score = 0;
513
+ let strong = false; // 이름 언급/정체성 적중/큐레이션 힌트 — 데스크탑처럼 "이름/힌트급 증거"가 있어야 위임한다
460
514
  const terms = [];
515
+ const seenNames = new Set();
461
516
  for (const name of [agent.slug, agent.name, agent.name_en].filter(Boolean)) {
462
517
  const n = routeNormalize(name);
463
518
  // 4자 미만 일반 단어("team","agent" 등)가 프롬프트에 우연히 들어가 +20을 독식하지 않도록 가드.
464
- if (n && n.length >= 4 && promptText.includes(n)) {
519
+ // name === name_en 인 임포트 에이전트(appbridge 등)가 +20을 받지 않도록 정규화 기준 dedupe.
520
+ if (!n || n.length < 4 || seenNames.has(n)) continue;
521
+ seenNames.add(n);
522
+ if (promptText.includes(n)) {
465
523
  score += 20;
466
524
  terms.push(name);
525
+ strong = true;
467
526
  }
468
527
  }
469
528
  for (const term of promptTerms) {
470
529
  if (identityHay.includes(term)) {
471
530
  score += 6; // 이름/태그라인 적중 = 그 에이전트의 정체성 자체를 부른 것
472
531
  terms.push(term);
532
+ strong = true;
473
533
  } else if (haystack.includes(term)) {
474
534
  score += term.length >= 5 ? 3 : 2;
475
535
  terms.push(term);
@@ -477,6 +537,7 @@ function scoreRouteAgent(prompt, promptTerms, agent, lang) {
477
537
  }
478
538
  const hint = routeHint(promptText, agent, lang);
479
539
  score += hint.score;
540
+ if (hint.score) strong = true;
480
541
  terms.push(...hint.terms);
481
542
  const unique = [...new Set(terms)].slice(0, 6);
482
543
  const reason = hint.reason || (lang === "ko"
@@ -486,13 +547,14 @@ function scoreRouteAgent(prompt, promptTerms, agent, lang) {
486
547
  : unique.length
487
548
  ? `request terms ${unique.map((term) => `"${term}"`).join(", ")} best match this agent's role/triggers`
488
549
  : "no specialist matched clearly, so the default project coordinator is safest");
489
- return { agent, score, reason, terms: unique };
490
- }
491
- // 라우팅 확신 임계값. 이름/태그라인 적중(+6), 라우트 힌트(+12↑), 이름 포함(+20)만 전문 라우트로 인정하고,
492
- // system_prompt 본문의 약한 단어 적중(+2~3) 한두 개로는 에이전트를 절대 선택하지 않는다.
493
- // 임계값 미만이면 "직답"(에이전트·능력 라우팅 없음) 일반 질문이 Pitch Deck Architect 같은
494
- // 무관 페르소나 + gemini 이미지 런타임으로 끌려가던 사고의 근본 수리.
495
- const MIN_ROUTE_SCORE = 6;
550
+ return { agent, score, reason, terms: unique, strong };
551
+ }
552
+ // 라우팅 확신 임계값 데스크탑 auto-router의 MIN_SPECIALIST_SCORE(10) 동일 기준.
553
+ // 위임에는 점수뿐 아니라 strong 신호(이름 포함 +20 / 정체성 적중 +6 / 큐레이션 힌트 +12↑)가
554
+ // 반드시 있어야 한다. system_prompt 본문의 약한 단어 적중(+2~3)이 쌓여도, strong 신호가
555
+ // 없으면 절대 위임하지 않는다. 미달이면 "직답"(에이전트·능력 라우팅 없음) 일반 질문이
556
+ // Pitch Deck Architect 같은 무관 페르소나 + gemini 이미지 런타임으로 끌려가던 사고의 근본 수리.
557
+ const MIN_ROUTE_SCORE = 10;
496
558
  function directRouteChoice(lang) {
497
559
  const resolvedLang = lang || prefsLang();
498
560
  return {
@@ -500,6 +562,7 @@ function directRouteChoice(lang) {
500
562
  agent: null,
501
563
  score: 0,
502
564
  terms: [],
565
+ strong: false,
503
566
  reason: resolvedLang === "ko"
504
567
  ? "특정 전문 에이전트가 필요 없는 일반 요청입니다"
505
568
  : "this is a general request that needs no specialist agent",
@@ -521,6 +584,7 @@ function autoRouteAgent(db, prompt, lang) {
521
584
  return {
522
585
  agent: meta,
523
586
  score: 1000,
587
+ strong: true,
524
588
  reason:
525
589
  resolvedLang === "ko"
526
590
  ? "새 에이전트/팀/회사를 만드는 요청이라 메타에이전트(빌더)로 라우팅했습니다"
@@ -532,13 +596,19 @@ function autoRouteAgent(db, prompt, lang) {
532
596
  const agents = listRoutableAgents(db).filter((agent) => !NON_GENERIC_ROUTE_SLUGS.has(agent.slug));
533
597
  if (!agents.length) return directRouteChoice(resolvedLang);
534
598
  let terms = routeTokenize(prompt);
599
+ // 헤이스택은 한 번만 계산해 IDF와 스코어링 양쪽에서 재사용한다.
600
+ const hays = agents.map((agent) => ({ identityHay: routeIdentityHaystack(agent), haystack: routeHaystack(agent) }));
535
601
  // IDF 근사 — 설치 에이전트 절반 이상의 haystack에 나오는 단어("ai","도구" 등)는 판별력이 없어 제외.
536
602
  if (agents.length >= 3) {
537
- const hays = agents.map((agent) => routeHaystack(agent));
538
- terms = terms.filter((term) => hays.filter((hay) => hay.includes(term)).length * 2 <= agents.length);
539
- }
540
- const ranked = agents.map((agent) => scoreRouteAgent(prompt, terms, agent, resolvedLang)).sort((a, b) => b.score - a.score);
541
- if (ranked[0] && ranked[0].score >= MIN_ROUTE_SCORE) return ranked[0];
603
+ terms = terms.filter((term) => hays.filter((h) => h.haystack.includes(term)).length * 2 <= agents.length);
604
+ }
605
+ const ranked = agents
606
+ .map((agent, i) => scoreRouteAgent(prompt, terms, agent, resolvedLang, hays[i]))
607
+ .sort((a, b) => b.score - a.score);
608
+ // 1위가 아니라 "임계값+strong을 모두 만족하는 최고 순위"를 뽑는다 — 장황한 프롬프트의
609
+ // 약한 단어 적중이 점수 1위를 먹어도, 자격 있는 전문 에이전트가 직답으로 밀려나지 않는다.
610
+ const pick = ranked.find((r) => r.score >= MIN_ROUTE_SCORE && r.strong);
611
+ if (pick) return pick;
542
612
  return directRouteChoice(resolvedLang);
543
613
  }
544
614
  function autoRouteNote(choice, lang) {
@@ -754,9 +824,14 @@ function importLocalFolderCli(db, absPath) {
754
824
  ).run(id, slug, name, name, tagline, tagline, systemPrompt, envReqsJson, now, tone);
755
825
  }
756
826
  }
827
+ // detectKind 결과를 DB에도 기록 — needsImage의 팀 body-veto 등 능력 판정이
828
+ // 데스크탑이 써준 entity_kind에 무임승차하지 않고 터미널 단독 임포트에서도 성립한다.
829
+ if (columnExists(db, "installed_agents", "entity_kind")) {
830
+ db.prepare("UPDATE installed_agents SET entity_kind=? WHERE id=?").run(kind, id);
831
+ }
757
832
  // 라우트 저장
758
833
  routes[id] = { agentId: id, path: dir, runtime, labels, kind, importedAt: now };
759
- fs.writeFileSync(path.join(userDataDir(), "agent-routes.json"), JSON.stringify(routes, null, 2), "utf8");
834
+ writeJsonPrivateAtomicCli(path.join(userDataDir(), "agent-routes.json"), routes);
760
835
 
761
836
  // 팀이면 회사(firm)로도 등록 → 앱 FIRMS 목록 + `agentlas firm <slug>` 사용 가능. slug 기준 멱등.
762
837
  let firm = null;
@@ -831,6 +906,7 @@ const CLOUD_AGENT_FILES = new Set(["AGENT.md", "AGENTS.md", "CLAUDE.md", "GEMINI
831
906
  const CLOUD_SKIP_DIRS = new Set([".git", ".next", ".studio-runtime", ".turbo", "build", "coverage", "dist", "node_modules", "out", "release"]);
832
907
  const CLOUD_BLOCKED_FILE_RE = [/^\.env(?:\..*)?$/i, /^id_rsa(?:\.pub)?$/i, /^credentials(?:\..*)?$/i, /^secrets?(?:\..*)?$/i, /^cloud-asset-state\.v1\.json$/i, /(?:^|[._-])service-account(?:[._-]|$)/i, /\.(?:key|pem|p12|pfx|mobileprovision)$/i];
833
908
  const CLOUD_ROUTING_CARD_PATH = ".agentlas/routing-card.json";
909
+ const CLOUD_LOCAL_EXPERIENCE_LINEAGE_PATH = ".agentlas/experience-relations.jsonl";
834
910
  const CLOUD_ROUTING_CARD_CAPABILITY_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/;
835
911
  const CLOUD_ROUTING_CARD_STATUSES = new Set(["draft", "searchable", "candidate", "routing_ready", "trusted"]);
836
912
  const CLOUD_SECRET_RE = [
@@ -1620,6 +1696,12 @@ function scanCloudFolderCli(rootPath) {
1620
1696
  if (entry.name.startsWith("._")) continue;
1621
1697
  const abs = path.join(dir, entry.name);
1622
1698
  const rel = path.relative(rootPath, abs).split(path.sep).join("/");
1699
+ if (cloudIsLocalExperienceLineagePath(rel)) {
1700
+ let bytes = 0;
1701
+ try { bytes = Number(fs.lstatSync(abs).size) || 0; } catch { /* excluded local state */ }
1702
+ files.push({ path: rel, bytes, sha256: "", kind: "text", included: false, reason: "experience-lineage-separate-asset" });
1703
+ continue;
1704
+ }
1623
1705
  if (cloudPortablePathKey(rel) === cloudPortablePathKey(CLOUD_RESTORE_MARKER_PATH)) {
1624
1706
  // Local restore/CAS metadata is runtime state, never portable asset
1625
1707
  // data, but it must be captured with the same no-follow stability gate.
@@ -2364,6 +2446,17 @@ function persistCloudListingCli(db, listing) {
2364
2446
  throw error;
2365
2447
  }
2366
2448
  const localPath = restore?.path || null;
2449
+ // entity_kind 기록 — needsImage의 팀 body-veto가 로컬 폴더 임포트(detectKind)뿐 아니라
2450
+ // 클라우드/Hub 소스 설치 팀에도 걸리게 한다. 안 하면 팀 CEO 두뇌의 부서 키워드
2451
+ // ("Design HQ" 등)로 needsImage가 참이 되어 세션 런타임이 통째로 gemini로 하이재킹된다.
2452
+ // Hub가 준 entityKind를 우선하고, 없으면 materialize된 팩 폴더 구조로 판정한다.
2453
+ if (columnExists(db, "installed_agents", "entity_kind")) {
2454
+ let kind = String(listing.entityKind || "").toLowerCase();
2455
+ if (kind !== "team" && kind !== "agent") {
2456
+ kind = localPath && fs.existsSync(localPath) ? detectKind(localPath) : "agent";
2457
+ }
2458
+ db.prepare("UPDATE installed_agents SET entity_kind=? WHERE id=?").run(kind, id);
2459
+ }
2367
2460
  return existing
2368
2461
  ? { ...existing, slug, name: dbExpected.name, ...(localPath ? { localPath } : {}) }
2369
2462
  : { id, slug, name: dbExpected.name, ...(localPath ? { localPath } : {}) };
@@ -2709,6 +2802,13 @@ function printCloudPackageResult(result) {
2709
2802
  function cloudPackageSnapshot(files) {
2710
2803
  return new Map(files.map((file) => [file.path, file]));
2711
2804
  }
2805
+ function cloudIsLocalExperienceLineagePath(value) {
2806
+ const normalized = cloudPortablePathKey(String(value || "").replace(/\\/g, "/"));
2807
+ const canonical = cloudPortablePathKey(CLOUD_LOCAL_EXPERIENCE_LINEAGE_PATH);
2808
+ return normalized === canonical
2809
+ || normalized.startsWith(`${canonical}.`)
2810
+ || normalized.startsWith(cloudPortablePathKey(".agentlas/.experience-relations.jsonl."));
2811
+ }
2712
2812
  function cloudReadPublicCareerCard(snapshot, findings) {
2713
2813
  const relativePath = ".agentlas/public-career-card.json";
2714
2814
  const file = snapshot.get(relativePath);
@@ -2901,7 +3001,7 @@ function cloudHashPackage(files, version = CLOUD_PACKAGE_HASH_V1) {
2901
3001
  // 서버 package-contract.ts와 바이트 동일해야 한다: 경로 코드포인트 순 정렬.
2902
3002
  // 정렬 없이 스캔 순서로 해시하면 대소문자 혼합 경로 패키지(AGENTS.md + agents/…)가
2903
3003
  // 전부 package_hash_mismatch로 거절된다(2026-07-02 근본 수정).
2904
- for (const file of [...files].sort(cloudCodePointPathOrder)) {
3004
+ for (const file of [...files].filter((file) => !cloudIsLocalExperienceLineagePath(file.path)).sort(cloudCodePointPathOrder)) {
2905
3005
  h.update(file.path);
2906
3006
  h.update("\0");
2907
3007
  h.update(file.sha256);
@@ -6986,6 +7086,21 @@ function readJsonSafeCli(filePath, fallback) {
6986
7086
  function writeJsonSafeCli(filePath, value) {
6987
7087
  fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n", "utf8");
6988
7088
  }
7089
+ // 원자적(temp+rename) + 소유자 전용(0600) JSON 쓰기. 세션 ID/경로 등 민감 상태 파일용:
7090
+ // (1) 크래시 중간 쓰기로 JSON이 깨져 routesMap()이 {}를 돌려주며 임포트 매핑을 통째로 잃던 사고,
7091
+ // (2) 기본 umask(0644)로 cli-sessions.json/agent-routes.json이 world-readable이던 정보 노출을 함께 막는다.
7092
+ function writeJsonPrivateAtomicCli(filePath, value) {
7093
+ const dir = path.dirname(filePath);
7094
+ const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`);
7095
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
7096
+ try {
7097
+ fs.renameSync(tmp, filePath);
7098
+ } catch (e) {
7099
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
7100
+ throw e;
7101
+ }
7102
+ try { fs.chmodSync(filePath, 0o600); } catch { /* 일부 FS는 chmod 미지원 — best-effort */ }
7103
+ }
6989
7104
 
6990
7105
  function ontologySourceManifestSkeletonCli(root) {
6991
7106
  return {
@@ -7848,10 +7963,30 @@ async function runApi(backend, model, system, prompt, options) {
7848
7963
  async function executeOnce(db, system, prompt, override, ctx) {
7849
7964
  ctx = ctx || { projectPath: null, agentId: null };
7850
7965
  if (!ctx.cwdAtRequest) ctx.cwdAtRequest = projectCwd();
7966
+ let runtimeSystem = system;
7967
+ let localExperienceContext = null;
7968
+ if (ctx.runtimeExperience && ctx.runtimeExperience.disabled !== true) {
7969
+ const runtimeExperience = ctx.runtimeExperience;
7970
+ const augmented = terminalExperienceExchange.augmentRuntimeSystemWithLocalExperience(system, {
7971
+ userDataDir: userDataDir(),
7972
+ cwd: ctx.projectPath || ctx.cwdAtRequest,
7973
+ baseAgentReleaseId: runtimeExperience.baseAgentReleaseId,
7974
+ agentDefinitionId: runtimeExperience.agentDefinitionId,
7975
+ taskSignatures: runtimeExperience.taskSignatures || [],
7976
+ environmentTags: Array.isArray(runtimeExperience.environmentTags) && runtimeExperience.environmentTags.length
7977
+ ? runtimeExperience.environmentTags
7978
+ : terminalExperienceExchange.defaultEnvironmentTags(),
7979
+ });
7980
+ runtimeSystem = augmented.systemPrompt;
7981
+ localExperienceContext = augmented.experienceContext;
7982
+ if (localExperienceContext.itemIds.length) {
7983
+ process.stderr.write(`▸ local Experience advisory · ${localExperienceContext.itemIds.length} item(s) · ~${localExperienceContext.estimatedTokens} tokens · no server rental receipt\n`);
7984
+ }
7985
+ }
7851
7986
  const rt = resolveRuntime(db, override);
7852
7987
  if (rt.mode === "cli") {
7853
7988
  // 네이티브 CLI는 자체 세션을 가지므로 emitter는 넣지 않고(노이즈 방지) 메모리 컨텍스트만 주입.
7854
- const sys = augmentSystem(db, system, ctx, false);
7989
+ const sys = augmentSystem(db, runtimeSystem, ctx, false);
7855
7990
  const cwd = ctx.projectPath || projectCwd();
7856
7991
  const permission = ctx.permission || "write";
7857
7992
  const env = await buildChildEnvCli(db, { ...ctx, cwd });
@@ -7885,7 +8020,7 @@ async function executeOnce(db, system, prompt, override, ctx) {
7885
8020
  return res.error ? 1 : 0;
7886
8021
  }
7887
8022
  // API 경로 — emitter 동봉 → 답변에서 메모리 이벤트를 파싱·큐레이션하고 블록은 제거.
7888
- const sys = augmentSystem(db, system, ctx, true);
8023
+ const sys = augmentSystem(db, runtimeSystem, ctx, true);
7889
8024
  const env = await buildChildEnvCli(db, { ...ctx, cwd: ctx.cwd || projectCwd() });
7890
8025
  Object.assign(process.env, env);
7891
8026
  process.stderr.write(`▸ ${rt.backend}${rt.model ? " · " + rt.model : ""}\n`);
@@ -8055,13 +8190,28 @@ const PROTECTED_CHILD_ENV_KEYS_CLI = new Set([
8055
8190
  "AGENTLAS_NATIVE_IDLE_TIMEOUT_MS", "AGENTLAS_NATIVE_TOTAL_TIMEOUT_MS", "AGENTLAS_NATIVE_KILL_GRACE_MS",
8056
8191
  "AGENTLAS_CAPTURE_MAX_OUTPUT_BYTES",
8057
8192
  ]);
8058
- function isProtectedChildEnvKeyCli(key) {
8059
- return PROTECTED_CHILD_ENV_KEYS_CLI.has(String(key || "").trim().toUpperCase());
8193
+ // 네트워크 무결성 키 — TLS 검증·프록시·CA·엔드포인트·세션. 프로젝트/에이전트 dotenv(신뢰 불가:
8194
+ // 클론한 레포에 딸려올 수 있음)로 주입되면 MITM/SSRF/세션 하이재킹이 된다. 단, 사용자 본인의
8195
+ // 전역 credentials.env와 호스트 셸 env는 신뢰하므로 그대로 허용한다. 사고 방지: 원샷 API 경로는
8196
+ // buildChildEnvCli 결과를 process.env에 병합(Object.assign)하므로, 프로젝트 .env가 부모 프로세스의
8197
+ // 클라우드 호출(세션 쿠키 동반)까지 오염시킬 수 있었다.
8198
+ const UNTRUSTED_PROTECTED_ENV_KEYS_CLI = new Set([
8199
+ "NODE_TLS_REJECT_UNAUTHORIZED", "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR",
8200
+ "OPENSSL_CONF", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE",
8201
+ "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "GRPC_PROXY", "NPM_CONFIG_PROXY",
8202
+ "AGENTLAS_SESSION", "AGENTLAS_MCP_BASE_URL", "AGENTLAS_WEB_BASE_URL", "AGENTLAS_API_BASE_URL",
8203
+ "AGENTLAS_HUB_BASE_URL", "AGENTLAS_CLOUD_BASE_URL", "OLLAMA_HOST",
8204
+ ]);
8205
+ function isProtectedChildEnvKeyCli(key, trusted) {
8206
+ const k = String(key || "").trim().toUpperCase();
8207
+ if (PROTECTED_CHILD_ENV_KEYS_CLI.has(k)) return true; // 호스트 신원/플러그인 루트 — 모든 출처 차단
8208
+ if (!trusted && UNTRUSTED_PROTECTED_ENV_KEYS_CLI.has(k)) return true; // 네트워크 무결성 — 비신뢰 출처만 차단
8209
+ return false;
8060
8210
  }
8061
- function mergeChildEnvValuesCli(target, values, overwrite) {
8211
+ function mergeChildEnvValuesCli(target, values, overwrite, trusted) {
8062
8212
  const injected = [];
8063
8213
  for (const [key, value] of Object.entries(values || {})) {
8064
- if (!value || isProtectedChildEnvKeyCli(key)) continue;
8214
+ if (!value || isProtectedChildEnvKeyCli(key, trusted)) continue;
8065
8215
  if (!overwrite && target[key]) continue;
8066
8216
  target[key] = value;
8067
8217
  injected.push(key);
@@ -8070,19 +8220,20 @@ function mergeChildEnvValuesCli(target, values, overwrite) {
8070
8220
  }
8071
8221
  async function buildChildEnvCli(db, ctx) {
8072
8222
  const env = { ...process.env };
8073
- const apply = (values, overwrite) => {
8074
- mergeChildEnvValuesCli(env, values, overwrite);
8223
+ // trusted=true: 사용자 본인의 전역 자격/볼트. trusted=false: 프로젝트·에이전트 폴더 dotenv.
8224
+ const apply = (values, overwrite, trusted) => {
8225
+ mergeChildEnvValuesCli(env, values, overwrite, trusted);
8075
8226
  };
8076
8227
  const globalCredentials = {
8077
8228
  ...readDotEnvFileCli(path.join(userDataDir(), "credentials.env")),
8078
8229
  ...readDotEnvFileCli(path.join(os.homedir(), ".agentlas", "credentials.env")),
8079
8230
  };
8080
- apply(globalCredentials, false);
8081
- if (ctx && ctx.projectPath) apply(projectScopedEnvValuesCli(globalCredentials, ctx.projectPath), true);
8082
- if (ctx && ctx.cwd) apply(readDotEnvDirCli(ctx.cwd), true);
8083
- if (ctx && ctx.projectPath) apply(readDotEnvDirCli(ctx.projectPath), true);
8231
+ apply(globalCredentials, false, true);
8232
+ if (ctx && ctx.projectPath) apply(projectScopedEnvValuesCli(globalCredentials, ctx.projectPath), true, true);
8233
+ if (ctx && ctx.cwd) apply(readDotEnvDirCli(ctx.cwd), true, false);
8234
+ if (ctx && ctx.projectPath) apply(readDotEnvDirCli(ctx.projectPath), true, false);
8084
8235
  const agentDir = agentEnvDirCli(ctx && ctx.agentId);
8085
- if (agentDir) apply(readDotEnvDirCli(agentDir), true);
8236
+ if (agentDir) apply(readDotEnvDirCli(agentDir), true, false);
8086
8237
 
8087
8238
  const mm = loadMultimodalCatalog();
8088
8239
  const settings = getMultimodalSettingsCli(db);
@@ -8091,7 +8242,7 @@ async function buildChildEnvCli(db, ctx) {
8091
8242
  if (req && req.key) keys.add(req.key);
8092
8243
  }
8093
8244
  const vaultValues = await readVaultEnvValuesCli([...keys].filter((key) => !env[key]), ctx && ctx.projectPath);
8094
- apply(vaultValues, false);
8245
+ apply(vaultValues, false, true); // 볼트는 사용자 본인 저장소 — 신뢰
8095
8246
  env.AGENTLAS_MULTIMODAL_IMAGE_PROVIDER = settings.imageProvider;
8096
8247
  env.AGENTLAS_MULTIMODAL_VIDEO_PROVIDER = settings.videoProvider;
8097
8248
  env.AGENTLAS_MULTIMODAL_AUDIO_PROVIDER = settings.audioProvider;
@@ -8211,11 +8362,21 @@ function buildHelpers(db) {
8211
8362
  try { return JSON.parse(fs.readFileSync(path.join(userDataDir(), "cli-sessions.json"), "utf8")) || []; } catch { return []; }
8212
8363
  },
8213
8364
  sessionsSave: (list) => {
8214
- try { fs.writeFileSync(path.join(userDataDir(), "cli-sessions.json"), JSON.stringify((list || []).slice(0, 30), null, 2), "utf8"); } catch { /* ignore */ }
8365
+ try { writeJsonPrivateAtomicCli(path.join(userDataDir(), "cli-sessions.json"), (list || []).slice(0, 30)); } catch { /* ignore */ }
8215
8366
  },
8216
8367
  // 패리티: REPL의 /storm·/swarm·/build·/route·/research 가 그대로 호출한다.
8217
8368
  stormRun: (db_, goal, ctx) => parity().stormRun(db_, goal, ctx),
8218
8369
  swarmRun: (db_, goal, ctx) => parity().swarmRun(db_, goal, ctx),
8370
+ terminalBuild: (db_, args, ctx = {}) => terminalAssets.cmdBuild({
8371
+ db: db_,
8372
+ args: Array.isArray(args) ? args : terminalAssets.tokenizeBuildCommandLine(String(args || "")),
8373
+ userDataDir: userDataDir(),
8374
+ cwd: ctx.cwd || projectCwd(),
8375
+ input: ctx.input || process.stdin,
8376
+ promptOutput: ctx.promptOutput || process.stderr,
8377
+ out: ctx.out || out,
8378
+ invokeBuild: (request) => parity().cmdHep(db_, request ? ["hep-build", request] : ["hep-build"]),
8379
+ }),
8219
8380
  hepRun: (args, opts) => parity().runHephaestusInteractive(args, opts),
8220
8381
  cloudSearch: (db_, args) => parity().cloudSearch(db_, args),
8221
8382
  careerGraphCommand: (text, ctx) => runCareerGraphNaturalCli(text, {
@@ -8564,22 +8725,52 @@ function cmdCd(db, query) {
8564
8725
  process.stdout.write(folder + "\n");
8565
8726
  }
8566
8727
 
8567
- async function cmdRun(db, query, prompt, runtimeOverride) {
8728
+ function parseRunExperienceArgs(args) {
8729
+ const prompt = [];
8730
+ const experience = { taskSignatures: [], environmentTags: [] };
8731
+ let passthrough = false;
8732
+ const addList = (target, value) => {
8733
+ for (const item of String(value || "").split(",").map((entry) => entry.trim()).filter(Boolean)) {
8734
+ if (!target.includes(item)) target.push(item);
8735
+ }
8736
+ };
8737
+ for (let index = 0; index < args.length; index += 1) {
8738
+ const token = String(args[index]);
8739
+ if (passthrough) { prompt.push(token); continue; }
8740
+ if (token === "--") { passthrough = true; continue; }
8741
+ const take = () => index + 1 < args.length ? String(args[++index]) : "";
8742
+ if (token === "--experience-base-release") experience.baseAgentReleaseId = take();
8743
+ else if (token.startsWith("--experience-base-release=")) experience.baseAgentReleaseId = token.slice(26);
8744
+ else if (token === "--experience-agent-definition") experience.agentDefinitionId = take();
8745
+ else if (token.startsWith("--experience-agent-definition=")) experience.agentDefinitionId = token.slice(30);
8746
+ else if (token === "--experience-task-signature") addList(experience.taskSignatures, take());
8747
+ else if (token.startsWith("--experience-task-signature=")) addList(experience.taskSignatures, token.slice(28));
8748
+ else if (token === "--experience-environment") addList(experience.environmentTags, take());
8749
+ else if (token.startsWith("--experience-environment=")) addList(experience.environmentTags, token.slice(25));
8750
+ else if (token === "--no-experience") experience.disabled = true;
8751
+ else prompt.push(token);
8752
+ }
8753
+ return { prompt: prompt.join(" "), experience };
8754
+ }
8755
+
8756
+ async function cmdRun(db, query, prompt, runtimeOverride, runtimeExperience = null) {
8568
8757
  const agent = resolveAgent(db, query);
8569
8758
  if (!agent) {
8570
8759
  const routedPrompt = [query, prompt].filter(Boolean).join(" ").trim() || (await readStdin());
8571
8760
  if (!routedPrompt || !routedPrompt.trim()) fail("프롬프트가 비어 있습니다. agentlas run <agent> \"...\" 또는 agentlas run \"...\" 형식으로 입력하세요.");
8572
- return cmdAutoRun(db, routedPrompt.trim(), runtimeOverride);
8761
+ return cmdAutoRun(db, routedPrompt.trim(), runtimeOverride, runtimeExperience);
8573
8762
  }
8574
8763
  let userPrompt = prompt;
8575
8764
  if (!userPrompt) userPrompt = await readStdin();
8576
8765
  if (!userPrompt || !userPrompt.trim()) fail("프롬프트가 비어 있습니다. agentlas run <agent> \"...\" 또는 stdin으로 전달하세요.");
8577
8766
  process.stderr.write(`▸ ${agent.name}\n`);
8578
- const code = await executeOnce(db, agentSystemPromptCli(agent), userPrompt.trim(), runtimeOverride, { projectPath: activeProjectPath(db), agentId: agent.id, permission: PERMISSION });
8767
+ const code = await executeOnce(db, agentSystemPromptCli(agent), userPrompt.trim(), runtimeOverride, {
8768
+ projectPath: activeProjectPath(db), agentId: agent.id, permission: PERMISSION, runtimeExperience,
8769
+ });
8579
8770
  process.exit(code);
8580
8771
  }
8581
8772
 
8582
- async function cmdAutoRun(db, prompt, runtimeOverride) {
8773
+ async function cmdAutoRun(db, prompt, runtimeOverride, runtimeExperience = null) {
8583
8774
  const lang = prefsLang();
8584
8775
  const choice = autoRouteAgent(db, prompt, lang);
8585
8776
  if (!choice) fail("자동 라우팅할 에이전트가 없습니다. agentlas list로 설치 상태를 확인하세요.");
@@ -8592,6 +8783,7 @@ async function cmdAutoRun(db, prompt, runtimeOverride) {
8592
8783
  projectPath: activeProjectPath(db),
8593
8784
  agentId: null,
8594
8785
  permission: PERMISSION,
8786
+ runtimeExperience,
8595
8787
  });
8596
8788
  process.exit(code);
8597
8789
  }
@@ -8602,6 +8794,7 @@ async function cmdAutoRun(db, prompt, runtimeOverride) {
8602
8794
  projectPath: activeProjectPath(db),
8603
8795
  agentId: choice.agent.id,
8604
8796
  permission: PERMISSION,
8797
+ runtimeExperience,
8605
8798
  });
8606
8799
  process.exit(code);
8607
8800
  }
@@ -8662,7 +8855,7 @@ async function cmdFirm(db, query, prompt, runtimeOverride) {
8662
8855
  slug: firm.slug,
8663
8856
  label: firm.name + " CEO",
8664
8857
  system: sys,
8665
- capAgent: { name: firm.name, name_en: firm.name_en || firm.name, tagline: firm.tagline, system_prompt: sys },
8858
+ capAgent: { name: firm.name, name_en: firm.name_en || firm.name, tagline: firm.tagline, tagline_en: firm.tagline_en, entity_kind: "team", system_prompt: sys },
8666
8859
  };
8667
8860
  return launchTui(db, subject, runtimeOverride);
8668
8861
  }
@@ -9995,6 +10188,9 @@ function cmdHelp() {
9995
10188
  " connect [<sub>] wire Telegram / platforms to an agent team (hep-connect)",
9996
10189
  " import <path> import a local agent/team folder",
9997
10190
  " list installed agents/companies + active runtime",
10191
+ " experience <sub> portable Experience: validate|save|publish|status|export|withdraw",
10192
+ " legacy local intents: list|inspect|unpublish|legacy-publish",
10193
+ " variant resolve local variant selection: selected|fallback|base-only|error",
9998
10194
  "",
9999
10195
  hdr("EXECUTE"),
10000
10196
  " storm <goal> force-robust pipeline: route → verify → execute (Stormbreaker) [--research]",
@@ -10097,8 +10293,10 @@ async function main() {
10097
10293
  return cmdImport(db, rest[1]);
10098
10294
  case "cd":
10099
10295
  return cmdCd(db, rest[1]);
10100
- case "run":
10101
- return cmdRun(db, rest[1], rest.slice(2).join(" "), runtimeOverride);
10296
+ case "run": {
10297
+ const runInput = parseRunExperienceArgs(rest.slice(2));
10298
+ return cmdRun(db, rest[1], runInput.prompt, runtimeOverride, runInput.experience);
10299
+ }
10102
10300
  case "chat":
10103
10301
  case "open":
10104
10302
  return cmdOpen(db, rest[1], runtimeOverride);
@@ -10133,8 +10331,37 @@ async function main() {
10133
10331
  return parity().cmdHep(db, rest.slice(1));
10134
10332
  // ── Agentlas OS 정식 표면 (hep-*) 1급 노출 ──
10135
10333
  case "build":
10136
- // hep-build "<요청>" 에이전트/팀 빌드 (Meta-Agent Factory)
10137
- return parity().cmdHep(db, rest.length > 1 ? ["hep-build", rest.slice(1).join(" ")] : ["hep-build"]);
10334
+ // Terminal-owned preflight: trusted system-global MCP metadata first, one consent,
10335
+ // then pass only approved catalog IDs/value-free shortages to the existing builder.
10336
+ return terminalAssets.cmdBuild({
10337
+ db,
10338
+ args: rest.slice(1),
10339
+ userDataDir: userDataDir(),
10340
+ cwd: projectCwd(),
10341
+ input: process.stdin,
10342
+ promptOutput: process.stderr,
10343
+ out,
10344
+ invokeBuild: (request) => parity().cmdHep(db, request ? ["hep-build", request] : ["hep-build"]),
10345
+ });
10346
+ case "experience":
10347
+ return terminalExperienceExchange.cmdExperienceExchange({
10348
+ args: rest.slice(1),
10349
+ userDataDir: userDataDir(),
10350
+ cwd: projectCwd(),
10351
+ out,
10352
+ env: process.env,
10353
+ getSessionCookie: cloudSessionCookieCli,
10354
+ fetchHub: (url, init) => fetchHubCli(url, init),
10355
+ legacyCommand: (legacyOptions) => terminalAssets.cmdExperience(legacyOptions),
10356
+ });
10357
+ case "variant":
10358
+ return terminalAssets.cmdVariant({
10359
+ db,
10360
+ args: rest.slice(1),
10361
+ userDataDir: userDataDir(),
10362
+ cwd: projectCwd(),
10363
+ out,
10364
+ });
10138
10365
  case "search": // hep-search — 에이전트 디렉터리 발견 (Hub + 로컬)
10139
10366
  if (!rest[1]) return fail('usage: agentlas search "<찾는 일>" [--limit 10]');
10140
10367
  return parity().cloudSearch(db, rest.slice(1));
@@ -10219,6 +10446,9 @@ module.exports = {
10219
10446
  parseDotEnvCli,
10220
10447
  isProtectedChildEnvKeyCli,
10221
10448
  mergeChildEnvValuesCli,
10449
+ openNodeSqliteDb,
10450
+ ensureMemoryContextColumn,
10451
+ writeJsonPrivateAtomicCli,
10222
10452
  resolveCredentialSourcePath,
10223
10453
  upsertEnvLine,
10224
10454
  fetchHubCli,
@@ -10252,6 +10482,7 @@ module.exports = {
10252
10482
  cloudPackageHashVersion,
10253
10483
  cloudPortablePathConflict,
10254
10484
  cloudPortableExecutableForFile,
10485
+ parseRunExperienceArgs,
10255
10486
  DEFAULT_API_MODEL,
10256
10487
  ANTHROPIC_COMPAT_API,
10257
10488
  // 자동 라우팅 회귀 테스트 표면 — 약한 매치 직답/오라우팅 방지 규칙 검증용.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlas",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
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"