@fcg-labs/cx-agent-hook 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/MIGRATION.md ADDED
@@ -0,0 +1,87 @@
1
+ # 0.2.x → 0.3.0 마이그레이션
2
+
3
+ ## 요약 — 깨지는 것은 없다
4
+
5
+ 0.2.x 의 루트 표면(`createCxHook`)·어댑터(`/react` `/vue` `/element` …)·
6
+ 클래스명(`fcx-*`)은 **전부 그대로 동작한다** (기존 시험 94건 무수정 통과).
7
+ 0.3.0 은 그 아래 구조를 객체화하고 새 표면을 **추가**한 릴리스다.
8
+
9
+ ```js
10
+ // 0.2.x — 그대로 유효
11
+ import { createCxHook } from "@fcg-labs/cx-agent-hook";
12
+
13
+ // 0.3.0 — 문의별 세션·초안 영속이 필요하면 이걸로 올라온다
14
+ import { createCxAgent } from "@fcg-labs/cx-agent-hook/agent";
15
+ ```
16
+
17
+ ## 왜 올라오나 — 0.2.x 훅의 구조적 한계 2가지
18
+
19
+ 0.2.x 훅은 **채택 슬롯이 훅 전역 하나**이고 **초안이 화면(컴포넌트) 상태**다.
20
+ 그 귀결로 스테이징 실사용에서 두 결함이 실증됐다 (2026-08-10):
21
+
22
+ 1. 다른 메뉴를 다녀오면 AI 초안이 사라진다 (컴포넌트 언마운트 = 초안 소멸)
23
+ 2. 스트림 도중 문의를 전환하면 이전 문의 텍스트가 새 에디터를 오염할 수 있다
24
+
25
+ 0.3.0 세션은 **초안의 정본을 세션 버퍼**에 두고 UI 를 탈착식 sink 로 분리해
26
+ 두 결함을 구조의 귀결로 없앤다.
27
+
28
+ ## 새 표면 한 눈에
29
+
30
+ ```js
31
+ const agent = createCxAgent({
32
+ baseUrl, token, domain, // 전송 3요소 (0.2.x 와 동일)
33
+ source: "cms", // 인그레스 채널명 (테넌트 선언)
34
+ draftDecorators: [/* 기본: 한국어 호칭 개인화. 비한국어면 [] */],
35
+ storage: { ttlMs: 30 * 60_000, maxSessions: 20 }, // 초안 영속 정책
36
+ });
37
+
38
+ const session = agent.session(csId); // 문의별 — 같은 id = 같은 세션
39
+ session.attachUi({ getDraft, setDraft, setStatus });
40
+ session.restore(); // 복귀 시 초안·채택 복원 한 줄
41
+ session.compose({ inquiry, context }); // 이탈해도 버퍼로 완주
42
+ session.remember(editorText); // 상담사 편집을 정본에 반영
43
+ session.answerSent(finalText, agentId); // gold 쌍 후킹 + 세션 소멸
44
+ ```
45
+
46
+ ## 훅 → 세션 대응표
47
+
48
+ | 0.2.x (`createCxHook`) | 0.3.0 (`agent.session(id)`) |
49
+ |-------------------------------|----------------------------------|
50
+ | `composeDraft({...sink})` | `attachUi(sink)` + `compose({})` |
51
+ | `noteAdopted / clearAdopted` | 동명 메서드 |
52
+ | `answerSent/scored/edited/discarded` | 동명 메서드 (consume 규칙 동일) |
53
+ | `adoptedAnswerId` | `session.adoptedAnswerId` |
54
+ | (없음 — 결함의 원천) | `detachUi()` · `restore()` · `remember()` |
55
+ | `requestAnswer / inquirySent / ready / flags / messages` | agent 동명 메서드 |
56
+
57
+ ## 행동 변화 (호환층 아님 — 원장 어휘)
58
+
59
+ - 문의 전환 시 CMS 가 쏘던 `discarded(note:"inquiry_switch")` 는 세션
60
+ 구조에서 **불필요해진다** (전환은 더 이상 오염이 아니다). 대신 수명 종료
61
+ 어휘가 늘었다: `session_expired`(TTL 만료) · `session_evicted`(LRU 퇴거).
62
+ 원장 통계를 사유별로 보던 곳은 note 어휘에 이 둘을 반영할 것.
63
+ - 초안이 sessionStorage 에 남는다 (탭 수명 + TTL, 발송·폐기 시 즉시 삭제).
64
+ 브라우저 공용 PC 정책이 있으면 `storage: { enabled: false }`.
65
+
66
+ ## CSS — 고객 제어권
67
+
68
+ 토큰 선언이 `.fcx-ai` 에서 **사용처 `var(--fcx-*, 폴백)` 직참조**로 바뀌었다.
69
+ 이제 소비처 전역 한 줄로 팔레트를 바꾼다:
70
+
71
+ ```css
72
+ :root { --fcx-accent: #7c3aed; --fcx-radius: 6px; }
73
+ [data-fcx-theme="dark"] { /* 다크는 조상 속성으로 켠다 */ }
74
+ ```
75
+
76
+ 0.2.x 처럼 `.fcx-ai { --fcx-accent: … }` 로 덮던 코드도 그대로 동작한다
77
+ (더 구체적인 선언이 이긴다). 클래스명 리네임은 없다.
78
+
79
+ headless 2단계: ① 스타일 0 — styles.css 를 import 하지 않는다.
80
+ ② UI 0 — 어댑터 대신 `aiSuggestTree`(view.js)와 세션 표면만 쓴다.
81
+
82
+ ## 기타
83
+
84
+ - `AnswerResult.answerId` 타입이 `number|string|null` — 신원 수술(ULID)
85
+ 이후 허브 발급분은 문자열이다. 불투명 취급하면 코드 변경은 없다.
86
+ - element 어댑터의 내부 DOM 구조는 동일하나, 어댑터 3종이 `aiSuggestTree`
87
+ 인터프리터로 재구현됐다 — 클래스·이벤트·마크업 계약은 시험으로 고정.
package/README.md CHANGED
@@ -30,6 +30,33 @@ npm install @fcg-labs/cx-agent-hook
30
30
  React·Vue 어댑터를 쓸 때만 그 프레임워크가 필요하다 (optional peer). 전송 계층만
31
31
  쓰면 의존성은 0 이다.
32
32
 
33
+ ## 0.3.0 — 문의별 세션 (권장 표면)
34
+
35
+ `createCxHook` 은 그대로 동작한다(호환층). 문의별 초안 상태·영속이 필요한
36
+ 관리자 화면은 세션 표면으로 올라온다 — **초안의 정본이 세션 버퍼**라 메뉴
37
+ 이탈·문의 전환에도 AI 초안이 살아남고, 스트림은 화면과 무관하게 완주한다:
38
+
39
+ ```js
40
+ import { createCxAgent } from "@fcg-labs/cx-agent-hook/agent";
41
+
42
+ const agent = createCxAgent({ baseUrl, token, domain });
43
+ const session = agent.session(inquiryId); // 같은 id = 같은 세션
44
+ session.attachUi({ getDraft, setDraft, setStatus });
45
+ session.restore(); // 복귀 시 초안 복원 한 줄
46
+ session.compose({ inquiry, context }); // 이탈해도 버퍼로 완주
47
+ session.remember(editorText); // 상담사 편집 반영
48
+ session.answerSent(finalText, agentId); // 발송 후킹 + 세션 소멸
49
+ ```
50
+
51
+ 테넌트 특수분은 파라미터다: `source`(인그레스 채널명, 기본 "cms"),
52
+ `draftDecorators`(초안 후처리 — 기본 한국어 호칭 개인화, 비한국어는 `[]`),
53
+ `storage`(sessionStorage TTL·LRU 정책). 상세와 훅→세션 대응표는
54
+ [MIGRATION.md](./MIGRATION.md).
55
+
56
+ CSS 는 사용처 `var(--fcx-*, 폴백)` 직참조라 소비처 `:root` 한 줄로 팔레트를
57
+ 바꾼다. 다크는 조상에 `data-fcx-theme="dark"`. headless 2단계(스타일 0 /
58
+ UI 0 — `aiSuggestTree`)도 공식 표면이다.
59
+
33
60
  ## 지원 프레임워크
34
61
 
35
62
  | 진입점 | 대상 | 필요한 peer |
package/agent.d.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * 0.3.0 객체 표면 — CxAgent + InquirySession.
3
+ *
4
+ * 보편 구조(설정·전송·문구·채택 수명·세션·영속)는 코어가 소유하고, 테넌트
5
+ * 특수분(source·draftDecorators·storage)은 파라미터다. 전송 계층 타입은
6
+ * client.d.ts 에 있고 여기서 다시 내보내지 않는다.
7
+ */
8
+ import type { AnswerResult, CxAgentConfig } from "./client.js";
9
+ import type { Locale } from "./index.js";
10
+
11
+ export type { AnswerResult };
12
+ export { LOCALES, MESSAGES, normalizeLocale } from "./index.js";
13
+
14
+ /** 초안 후처리 데코레이터 — 사슬로 적용된다 (예: 호칭 개인화) */
15
+ export type DraftDecorator = (
16
+ text: string,
17
+ ctx: { customerName?: string },
18
+ ) => string;
19
+
20
+ /** 세션 영속 정책 (sessionStorage) */
21
+ export interface SessionStorageConfig {
22
+ /** false 면 영속 없이 메모리 세션만 (기본 true) */
23
+ enabled?: boolean;
24
+ /** 초안 보존 시한 — 기본 30분. 만료는 discarded(session_expired)로 원장에 남는다 */
25
+ ttlMs?: number;
26
+ /** 동시 보존 세션 수 — 기본 20. 초과는 LRU 퇴거(session_evicted) */
27
+ maxSessions?: number;
28
+ /** 저장 키 접두 — 기본 `cx-agent-session:<domain>` */
29
+ prefix?: string;
30
+ /** 저장소 주입 (시험·커스텀) — 기본 sessionStorage */
31
+ impl?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
32
+ }
33
+
34
+ export type CxAgentSetup = Partial<CxAgentConfig> & {
35
+ locale?: string;
36
+ bootstrap?: boolean;
37
+ messages?: Record<string, string>;
38
+ /** 인그레스 채널명 (테넌트 선언) — 기본 "cms" */
39
+ source?: string;
40
+ /** 초안 후처리 사슬 — 기본 [koreanGreetingDecorator]. 비한국어 테넌트는 [] */
41
+ draftDecorators?: DraftDecorator[];
42
+ storage?: SessionStorageConfig;
43
+ };
44
+
45
+ export interface ComposeHandle {
46
+ promise: Promise<AnswerResult>;
47
+ abort: () => void;
48
+ }
49
+
50
+ export interface SessionUi {
51
+ getDraft?: () => string;
52
+ setDraft: (text: string) => void;
53
+ setStatus?: (text: string) => void;
54
+ }
55
+
56
+ /** 문의 1건의 AI 초안 수명주기 — 초안의 정본은 세션 버퍼, UI 는 sink 다 */
57
+ export declare class InquirySession {
58
+ readonly externalId: string;
59
+ readonly state: "idle" | "composing" | "drafted" | "declined";
60
+ readonly draft: string;
61
+ readonly adoptedAnswerId: number | string | null;
62
+
63
+ /** UI sink 연결 — 자동 주입 없음, 복원은 restore() 명시 호출 */
64
+ attachUi(ui: SessionUi): this;
65
+ /** UI 분리 — 진행 중 스트림은 버퍼로 계속 완주한다 */
66
+ detachUi(): this;
67
+ /** 세션 정본을 UI 로 (복귀 한 줄) */
68
+ restore(): { draft: string; answerId: number | string | null; state: string };
69
+ /** 상담사 편집을 정본에 반영 (스로틀은 소비처 몫) */
70
+ remember(text: string): void;
71
+
72
+ compose(opts: {
73
+ inquiry: string;
74
+ context?: Record<string, string>;
75
+ customerName?: string;
76
+ confirmOverwrite?: () => boolean;
77
+ /** 스트림 시한 관통 — 큰 컨텍스트 창(프리필 분 단위) 운영용. 기본 90s/190s */
78
+ idleTimeoutMs?: number;
79
+ overallTimeoutMs?: number;
80
+ }): ComposeHandle;
81
+
82
+ noteAdopted(answerId: number | string | null): void;
83
+ clearAdopted(): void;
84
+ answerSent(finalText: string, agent?: string): void;
85
+ scored(score: number, agent?: string): void;
86
+ edited(finalText: string, agent?: string): void;
87
+ discarded(agent?: string, note?: string): void;
88
+ }
89
+
90
+ export interface CxAgent {
91
+ enabled: boolean;
92
+ ready: Promise<void>;
93
+ readonly flags: Record<string, unknown>;
94
+ locale: Locale;
95
+ messages: Record<string, string>;
96
+ declineText(reason: string): string;
97
+ /** 원격 설정(bootstrap) 반영 구독 — 반환값으로 해지 */
98
+ onConfigChange(
99
+ fn: (cfg: { messages: Record<string, string>; flags: Record<string, unknown> }) => void,
100
+ ): () => void;
101
+
102
+ /** 문의별 세션 — 같은 externalId 는 같은 인스턴스. 인자 없으면 비영속 일회 세션 */
103
+ session(externalId?: string | number, opts?: { persist?: boolean }): InquirySession;
104
+
105
+ requestAnswer(inquiry: string, context?: Record<string, string>): Promise<AnswerResult>;
106
+ requestInvestigation(
107
+ externalId: string | number,
108
+ identity?: { userId?: string | number; subUserId?: string | number },
109
+ ): Promise<{ ok: boolean; status: string; error: string | null }>;
110
+ investigationStatus(
111
+ externalId: string | number,
112
+ ): Promise<{ ok: boolean; status: string; jobId?: number; error: string | null }>;
113
+ inquirySent(payload: {
114
+ externalId: string | number;
115
+ inquiry: string;
116
+ reply?: string;
117
+ agent?: string;
118
+ meta?: Record<string, string>;
119
+ }): void;
120
+ }
121
+
122
+ export declare function createCxAgent(config?: CxAgentSetup): CxAgent;
123
+ /** 기본 초안 데코레이터 — 한국어 인사말 호칭 개인화 (서버 선굽기의 안전망) */
124
+ export declare const koreanGreetingDecorator: DraftDecorator;
125
+ export declare function notConfiguredResult(): AnswerResult;
126
+ export declare function textOf(messages: Record<string, string>, reason: string): string;
package/agent.js ADDED
@@ -0,0 +1,242 @@
1
+ /**
2
+ * CxAgent — 0.3.0 객체 표면. `createCxAgent(config)` 하나로 테넌트를 세운다.
3
+ *
4
+ * 구조 원칙 (객체화의 이유):
5
+ * - **보편 구조는 코어가 소유한다** — 어느 CMS 든 존재하는 것들: 설정
6
+ * 동결, 전송, 로케일/문구, 채택 수명, 문의별 세션, 초안 영속.
7
+ * - **테넌트 특수분은 파라미터다** — `source`(인그레스 채널명),
8
+ * `draftDecorators`(호칭 개인화 등 초안 후처리), `storage`(영속 정책).
9
+ * 특정 테넌트 전용 값이 코어에 하드코딩되던 것(source:"cms", 한국어
10
+ * 인사말 정규식)을 기본값 있는 파라미터로 끌어냈다.
11
+ * - 전송 계층(client.js)은 여전히 비공개 — 능력이 모자라면 이 표면을
12
+ * 채운다 (0.2.x 원칙 승계).
13
+ *
14
+ * 0.2.x `createCxHook` 은 이 위의 호환 표면이다 (compat.js) — 루트 import
15
+ * 는 그대로 동작한다. 새 소비처는 이 파일의 표면으로 온다:
16
+ *
17
+ * ```js
18
+ * import { createCxAgent } from "@fcg-labs/cx-agent-hook/agent";
19
+ * const agent = createCxAgent({ baseUrl, token, domain });
20
+ * const session = agent.session(csId); // 문의별 상태·초안 영속
21
+ * session.attachUi({ getDraft, setDraft, setStatus }).restore();
22
+ * session.compose({ inquiry, context }); // 이탈해도 완주, 복귀 시 restore
23
+ * session.answerSent(finalText, agentId); // gold 쌍 후킹 + 세션 소멸
24
+ * ```
25
+ */
26
+ import { CxAgentClient } from "./client.js";
27
+ import { LOCALES, MESSAGES, normalizeLocale, resolveMessages } from "./locales.js";
28
+ import { InquirySession, SessionStore } from "./session.js";
29
+
30
+ export { LOCALES, MESSAGES, normalizeLocale };
31
+
32
+ /** 문구 한 벌에서 사유 하나 — 기계 코드를 화면에 노출하지 않는다. */
33
+ export function textOf(messages, reason) {
34
+ const known = messages[reason];
35
+ if (known) return known;
36
+ if (String(reason || "").startsWith("http_")) return messages.http_error;
37
+ return messages.unknown;
38
+ }
39
+
40
+ /** 미설정 결과 팩토리 — 0.2.x 의 가변 싱글턴(공유 객체 오염 위험) 청산. */
41
+ export function notConfiguredResult() {
42
+ return {
43
+ ok: false, answered: false, answer: "", answerId: null,
44
+ evidence: [], declinedReason: "not_configured", raw: {},
45
+ };
46
+ }
47
+
48
+ /**
49
+ * 기본 초안 데코레이터 — 한국어 인사말 호칭 개인화.
50
+ *
51
+ * 서버가 사설 서빙(AI_CS_LLM_PRIVATE)이면 인사말을 처음부터 이름으로 굽고,
52
+ * 이 치환은 안전망이다: 이름 없이 "안녕하세요 고객님" 으로 온 초안에만
53
+ * 작동하고, 서버가 이미 이름을 넣었으면 패턴이 안 맞아 no-op.
54
+ * 한국어 CS 가 아닌 테넌트는 `draftDecorators: []` 로 끈다.
55
+ */
56
+ export function koreanGreetingDecorator(text, { customerName } = {}) {
57
+ const name = String(customerName || "").trim();
58
+ if (!name) return text;
59
+ return String(text).replace(
60
+ /안녕하세요[,]?\s*고객님/,
61
+ (m) => m.replace("고객님", `${name} 고객님`),
62
+ );
63
+ }
64
+
65
+ /**
66
+ * CxAgent 를 만든다 — 미설정이면 전부 무동작 (자격 없이는 네트워크 0).
67
+ *
68
+ * @param {object} config
69
+ * @param {string} config.baseUrl · config.token · config.domain 전송 3요소
70
+ * @param {"hub"|"platform"} [config.api="hub"] 기본 대상 — **agent 가 기본값의
71
+ * 유일한 소유자다** (0.2.x 에서 훅 "hub" vs 클라이언트 "platform" 으로
72
+ * 갈라져 있던 불일치 청산. 클라이언트에는 항상 명시 전달한다).
73
+ * @param {string} [config.locale] · @param {object} [config.messages]
74
+ * @param {boolean} [config.bootstrap=false] 허브 client-config 병합
75
+ * @param {string} [config.source="cms"] 인그레스 채널명 (테넌트 선언)
76
+ * @param {Array<(text:string, ctx:{customerName?:string})=>string>}
77
+ * [config.draftDecorators] 초안 후처리 사슬 — 기본 [한국어 호칭]
78
+ * @param {{enabled?:boolean, ttlMs?:number, maxSessions?:number,
79
+ * prefix?:string, impl?:Storage}} [config.storage] 세션 영속 정책
80
+ */
81
+ export function createCxAgent(config = {}) {
82
+ const {
83
+ baseUrl, token, domain, api = "hub", onError,
84
+ locale, messages: messageOverrides,
85
+ bootstrap = false,
86
+ source = "cms",
87
+ draftDecorators = [koreanGreetingDecorator],
88
+ storage = {},
89
+ ...rest
90
+ } = config;
91
+
92
+ const messages = resolveMessages(locale, messageOverrides);
93
+ let flags = {};
94
+ const configListeners = new Set();
95
+ const client =
96
+ baseUrl && token && domain
97
+ ? new CxAgentClient({
98
+ baseUrl, token, domain, api, source,
99
+ // 후킹 실패는 CS 업무와 무관 — 기록만 하고 화면을 막지 않는다.
100
+ onError:
101
+ onError ||
102
+ ((err, ctx) => console.warn("[cx-agent-hook]", ctx.op, err.message)),
103
+ ...rest,
104
+ })
105
+ : null;
106
+
107
+ const decorate = (text, ctx) =>
108
+ draftDecorators.reduce((acc, fn) => {
109
+ try { return fn(acc, ctx || {}); } catch { return acc; }
110
+ }, String(text ?? ""));
111
+
112
+ const applyRemoteConfig = (remote) => {
113
+ if (!remote || typeof remote !== "object") return;
114
+ if (remote.locale || remote.messages) {
115
+ // 원격 문구 위에 로컬 override 재적용 — 로컬 우선 규칙을 병합 순서로 강제
116
+ const next = resolveMessages(locale || remote.locale, {
117
+ ...(remote.messages || {}),
118
+ ...(messageOverrides || {}),
119
+ });
120
+ Object.assign(messages, next);
121
+ }
122
+ if (remote.flags && typeof remote.flags === "object") {
123
+ flags = { ...remote.flags };
124
+ }
125
+ for (const fn of configListeners) {
126
+ try { fn({ messages, flags }); } catch { /* 리스너 실패 무시 */ }
127
+ }
128
+ };
129
+
130
+ const fetchRemoteConfig = async () => {
131
+ const doFetch = rest.fetchImpl || (typeof fetch !== "undefined" ? fetch : null);
132
+ if (!doFetch) return;
133
+ const cacheKey = `cx-agent-config:${domain}`;
134
+ try {
135
+ if (typeof sessionStorage !== "undefined") {
136
+ const cached = JSON.parse(sessionStorage.getItem(cacheKey) || "null");
137
+ if (cached && Date.now() - cached.at < 5 * 60 * 1000) {
138
+ applyRemoteConfig(cached.config);
139
+ return;
140
+ }
141
+ }
142
+ } catch { /* 캐시 실패는 무시 — 네트워크로 진행 */ }
143
+ const res = await doFetch(
144
+ `${String(baseUrl).replace(/\/$/, "")}/v1/domains/${domain}/client-config`,
145
+ { headers: { Authorization: `Bearer ${token}` } },
146
+ );
147
+ if (!res.ok) return; // 404(미발행) 포함 — 정적 설정으로 동작 (fail-soft)
148
+ const remote = await res.json();
149
+ applyRemoteConfig(remote);
150
+ try {
151
+ if (typeof sessionStorage !== "undefined") {
152
+ sessionStorage.setItem(
153
+ cacheKey, JSON.stringify({ at: Date.now(), config: remote }),
154
+ );
155
+ }
156
+ } catch { /* 저장 실패 무시 */ }
157
+ };
158
+
159
+ // 부트스트랩은 백그라운드 — 화면을 막지 않는다. 실패도 resolve.
160
+ const ready = (bootstrap && client && api === "hub")
161
+ ? fetchRemoteConfig().catch(() => {})
162
+ : Promise.resolve();
163
+
164
+ // 세션 영속 — 퇴거(TTL·LRU)는 원장에 기각으로 남긴다 (침묵 소거 금지)
165
+ const store = storage.enabled === false || !client
166
+ ? null
167
+ : new SessionStore({
168
+ impl: storage.impl,
169
+ prefix: storage.prefix || `cx-agent-session:${domain}`,
170
+ ttlMs: storage.ttlMs,
171
+ maxSessions: storage.maxSessions,
172
+ onEvict: (rec, why) => {
173
+ if (rec && rec.answerId) client.discarded(rec.answerId, "", why);
174
+ },
175
+ });
176
+
177
+ const sessionDeps = {
178
+ client, messages, decorate, store,
179
+ textOf: (reason) => textOf(messages, reason),
180
+ };
181
+ const sessions = new Map();
182
+
183
+ return {
184
+ /** 전송 3요소가 다 있으면 true */
185
+ enabled: Boolean(client),
186
+ /** 부트스트랩 완료 신호 — bootstrap 미사용 시 즉시 resolve */
187
+ ready,
188
+ /** 중앙 발행 기능 플래그 — 부트스트랩 전/미발행이면 빈 객체 */
189
+ get flags() { return flags; },
190
+ /** setup 에서 정해진 화면 언어 (정규화) */
191
+ locale: normalizeLocale(locale),
192
+ /** 이 테넌트의 문구 한 벌 — 부트스트랩이 제자리 갱신한다 */
193
+ messages,
194
+ declineText(reason) { return textOf(messages, reason); },
195
+ /** 원격 설정 반영 구독 (bootstrap) — 반환값으로 해지 */
196
+ onConfigChange(fn) {
197
+ configListeners.add(fn);
198
+ return () => configListeners.delete(fn);
199
+ },
200
+
201
+ /**
202
+ * 문의별 세션 — 같은 externalId 는 같은 인스턴스 (프로세스 내 캐시 +
203
+ * sessionStorage 복원). externalId 없이 부르면 비영속 일회 세션.
204
+ */
205
+ session(externalId, opts = {}) {
206
+ const id = externalId == null ? "" : String(externalId);
207
+ if (!id) return new InquirySession("", { ...sessionDeps, store: null });
208
+ let s = sessions.get(id);
209
+ if (!s) {
210
+ s = new InquirySession(id, {
211
+ ...sessionDeps,
212
+ store: opts.persist === false ? null : store,
213
+ });
214
+ sessions.set(id, s);
215
+ }
216
+ return s;
217
+ },
218
+
219
+ /** 답변 제안 (비스트림) — throw 하지 않음 */
220
+ requestAnswer(inquiry, context) {
221
+ if (!client || !inquiry) return Promise.resolve(notConfiguredResult());
222
+ return client.getAnswer(inquiry, context);
223
+ },
224
+
225
+ /** 조사 요청 (E-8) — 큐 적재만, 결과는 investigationStatus 폴링 */
226
+ requestInvestigation(externalId, identity) {
227
+ if (!client) return Promise.resolve({ ok: false, status: "", error: "not_configured" });
228
+ return client.requestInvestigation(externalId, identity);
229
+ },
230
+
231
+ investigationStatus(externalId) {
232
+ if (!client) return Promise.resolve({ ok: false, status: "", error: "not_configured" });
233
+ return client.investigationStatus(externalId);
234
+ },
235
+
236
+ /** 문의+최종답변 쌍 적재 (fire-and-forget, external_id 멱등) */
237
+ inquirySent({ externalId, inquiry, reply, agent, meta } = {}) {
238
+ if (!client || !externalId || !inquiry) return;
239
+ client.logInquiry({ externalId, inquiry, reply, agent, meta });
240
+ },
241
+ };
242
+ }
package/client.d.ts CHANGED
@@ -25,6 +25,8 @@ export interface CxAgentConfig {
25
25
  feedback?: CapabilityTarget;
26
26
  retries?: number;
27
27
  timeoutMs?: number;
28
+ /** 인그레스 body.source (테넌트 채널명) — 기본 "cms" (0.3.0 파라미터화) */
29
+ source?: string;
28
30
  onError?: (err: Error, context: { op: string; code?: string }) => void;
29
31
  fetchImpl?: typeof fetch;
30
32
  }
@@ -33,7 +35,8 @@ export interface AnswerResult {
33
35
  ok: boolean;
34
36
  answered: boolean;
35
37
  answer: string;
36
- answerId: number | null;
38
+ /** 신원 수술(0048) 이후 허브 발급분은 ULID 문자열 — 불투명 취급 */
39
+ answerId: number | string | null;
37
40
  evidence: Array<{ unit_key: string; title: string; score: number }>;
38
41
  declinedReason: string;
39
42
  raw: Record<string, unknown>;
package/client.js CHANGED
@@ -113,7 +113,7 @@ export class CxAgentClient {
113
113
  */
114
114
  constructor({ baseUrl, token, domain, api = "platform", retries = 2,
115
115
  timeoutMs = 90000, onError, fetchImpl,
116
- answer, ingress, feedback } = {}) {
116
+ answer, ingress, feedback, source = "cms" } = {}) {
117
117
  if (!baseUrl || !token || !domain) {
118
118
  throw new Error("@fcg-labs/cx-agent-hook: baseUrl·token·domain 은 필수입니다");
119
119
  }
@@ -140,6 +140,9 @@ export class CxAgentClient {
140
140
  }
141
141
  this.retries = retries;
142
142
  this.timeoutMs = timeoutMs;
143
+ // 인그레스 body.source — 문의가 어느 채널에서 왔는지의 테넌트 선언.
144
+ // "cms" 하드코딩이던 것을 0.3.0 에서 파라미터화 (기본값은 하위호환).
145
+ this.source = String(source || "cms");
143
146
  this.onError = onError || (() => {});
144
147
  this.fetchImpl = fetchImpl || globalThis.fetch.bind(globalThis);
145
148
  }
@@ -485,7 +488,7 @@ export class CxAgentClient {
485
488
  return this._inqFail(new Error("inquiry 필수"), "invalid_inquiry");
486
489
  }
487
490
  const body = {
488
- source: "cms",
491
+ source: this.source,
489
492
  external_id: String(externalId),
490
493
  inquiry: String(inquiry),
491
494
  ...(reply ? { reply: String(reply) } : {}),
package/compat.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * createCxHook — 0.2.x 호환 표면 (내부용 모듈, exports 맵에 없다).
3
+ *
4
+ * 0.3.0 에서 구현이 CxAgent + InquirySession 으로 옮겨졌다. 이 파일은 그
5
+ * 위에 0.2.x 문서화 표면을 **비영속 기본 세션** 하나로 재구성한다 — 기존
6
+ * 소비처(와 시험 94건)는 글자 하나 안 바꾸고 그대로 동작한다.
7
+ *
8
+ * 0.2.x 와 0.3.0 의 관계:
9
+ * - hook.noteAdopted/answerSent/... = 기본 세션의 동명 메서드
10
+ * - hook.composeDraft = session.compose + 호출별 UI sink
11
+ * - 문의별 상태·영속이 필요하면 `createCxAgent` + `agent.session()` 으로
12
+ * 올라간다 (MIGRATION.md)
13
+ */
14
+ import { createCxAgent, notConfiguredResult, textOf } from "./agent.js";
15
+
16
+ /** 후킹 한 벌을 만든다 — 미설정이면 전부 무동작. (0.2.x 표면 그대로) */
17
+ export function createCxHook(config = {}) {
18
+ const agent = createCxAgent({
19
+ ...config,
20
+ // 0.2.x 훅은 문의별 세션 개념이 없다 — 영속은 세션 표면의 능력이다.
21
+ storage: { enabled: false, ...(config.storage || {}) },
22
+ });
23
+ // 훅 전체가 공유하는 단일 채택 슬롯 = 비영속 기본 세션 하나
24
+ const session = agent.session();
25
+
26
+ return {
27
+ enabled: agent.enabled,
28
+ ready: agent.ready,
29
+ get flags() { return agent.flags; },
30
+ locale: agent.locale,
31
+ messages: agent.messages,
32
+ declineText(reason) { return agent.declineText(reason); },
33
+
34
+ requestAnswer(inquiry, context) {
35
+ return agent.requestAnswer(inquiry, context);
36
+ },
37
+ requestInvestigation(externalId, identity) {
38
+ return agent.requestInvestigation(externalId, identity);
39
+ },
40
+ investigationStatus(externalId) {
41
+ return agent.investigationStatus(externalId);
42
+ },
43
+
44
+ /** 초안 직주입 스트리밍 — 세션 compose 에 호출별 UI sink 를 붙인다.
45
+ * 의미론(자동 채택·교체 기각·실패 원복·abort 무간섭)은 session.js 소유. */
46
+ composeDraft({ inquiry, context, getDraft, setDraft, setStatus,
47
+ confirmOverwrite, customerName } = {}) {
48
+ if (!agent.enabled || !inquiry || typeof setDraft !== "function") {
49
+ if (setStatus) setStatus(textOf(agent.messages, "not_configured"));
50
+ return { promise: Promise.resolve(notConfiguredResult()), abort: () => {} };
51
+ }
52
+ session.attachUi({ getDraft, setDraft, setStatus });
53
+ return session.compose({ inquiry, context, customerName, confirmOverwrite });
54
+ },
55
+
56
+ noteAdopted(answerId) { session.noteAdopted(answerId); },
57
+ clearAdopted() { session.clearAdopted(); },
58
+ get adoptedAnswerId() { return session.adoptedAnswerId; },
59
+
60
+ answerSent(finalText, agentId) { session.answerSent(finalText, agentId); },
61
+ scored(score, agentId) { session.scored(score, agentId); },
62
+ edited(finalText, agentId) { session.edited(finalText, agentId); },
63
+ discarded(agentId, note) { session.discarded(agentId, note); },
64
+
65
+ inquirySent(payload) { agent.inquirySent(payload); },
66
+ };
67
+ }