@things-factory/ai-client-base 10.1.167 → 10.1.169

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.
Files changed (50) hide show
  1. package/dist-server/service/agentic-loop/agentic-loop.d.ts +9 -0
  2. package/dist-server/service/agentic-loop/agentic-loop.js +22 -3
  3. package/dist-server/service/agentic-loop/agentic-loop.js.map +1 -1
  4. package/dist-server/service/assistant/open-subject.d.ts +4 -0
  5. package/dist-server/service/assistant/open-subject.js +3 -0
  6. package/dist-server/service/assistant/open-subject.js.map +1 -1
  7. package/dist-server/service/assistant/registry-assistant.d.ts +11 -1
  8. package/dist-server/service/assistant/registry-assistant.js +25 -3
  9. package/dist-server/service/assistant/registry-assistant.js.map +1 -1
  10. package/dist-server/service/providers/anthropic.d.ts +2 -0
  11. package/dist-server/service/providers/anthropic.js +7 -3
  12. package/dist-server/service/providers/anthropic.js.map +1 -1
  13. package/dist-server/service/providers/gemini.js +1 -1
  14. package/dist-server/service/providers/gemini.js.map +1 -1
  15. package/dist-server/service/providers/openai.js +1 -1
  16. package/dist-server/service/providers/openai.js.map +1 -1
  17. package/dist-server/service/testing/tool-call-context.d.ts +5 -1
  18. package/dist-server/service/testing/tool-call-context.js +4 -1
  19. package/dist-server/service/testing/tool-call-context.js.map +1 -1
  20. package/dist-server/service/tool-registry/call-recorder.d.ts +16 -0
  21. package/dist-server/service/tool-registry/call-recorder.js +24 -0
  22. package/dist-server/service/tool-registry/call-recorder.js.map +1 -0
  23. package/dist-server/service/tool-registry/index.d.ts +4 -1
  24. package/dist-server/service/tool-registry/index.js +7 -1
  25. package/dist-server/service/tool-registry/index.js.map +1 -1
  26. package/dist-server/service/tool-registry/translate.d.ts +2 -0
  27. package/dist-server/service/tool-registry/translate.js +6 -0
  28. package/dist-server/service/tool-registry/translate.js.map +1 -0
  29. package/dist-server/service/tool-registry/types.d.ts +50 -0
  30. package/dist-server/service/tool-registry/types.js.map +1 -1
  31. package/dist-server/service/utils/parse-json.d.ts +12 -1
  32. package/dist-server/service/utils/parse-json.js +18 -4
  33. package/dist-server/service/utils/parse-json.js.map +1 -1
  34. package/dist-server/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +2 -2
  36. package/server/service/agentic-loop/agentic-loop.test.ts +76 -0
  37. package/server/service/agentic-loop/agentic-loop.ts +22 -3
  38. package/server/service/assistant/open-subject.ts +3 -0
  39. package/server/service/assistant/registry-assistant.test.ts +133 -1
  40. package/server/service/assistant/registry-assistant.ts +35 -5
  41. package/server/service/providers/anthropic.ts +8 -4
  42. package/server/service/providers/gemini.ts +1 -1
  43. package/server/service/providers/json-answer.test.ts +53 -0
  44. package/server/service/providers/openai.ts +1 -1
  45. package/server/service/testing/tool-call-context.ts +9 -2
  46. package/server/service/tool-registry/call-recorder.ts +47 -0
  47. package/server/service/tool-registry/index.ts +10 -1
  48. package/server/service/tool-registry/translate.ts +12 -0
  49. package/server/service/tool-registry/types.ts +54 -0
  50. package/server/service/utils/parse-json.ts +21 -5
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../server/service/tool-registry/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,uEAAuE;AACvE,kEAAkE;AAClE,iFAAiF;AACjF,mEAAmE;AACnE,wDAAwD","sourcesContent":["/**\n * Tool Registry — board-ai 의 LLM tool 노출 plugin point.\n *\n * 동기:\n * 기존엔 board-ai 가 자기 도메인 (board edit) tool 들만 hardcoded 정의했고, styling\n * 영역만 STYLING_TOOLS registry 로 분리되어 있었다. 외부 도메인 패키지 (board-import,\n * fmsim 등) 가 자기 tool 을 LLM 에 노출하려면 board-ai 자체를 fork/수정해야 했다.\n *\n * 본 registry 는 외부 패키지가 server bootstrap 시 자기 tool category 를 등록하면\n * assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이 자동으로 통합\n * 하도록 한다. styling-tools 의 single-source-of-truth 패턴을 일반화한 형태.\n *\n * 설계 원칙:\n * - **카테고리별 builder 시그니처 다양성을 통합**: read 는 (args, ctx) → any,\n * write 는 (args, components) → SceneEditOp[], action 은 (args) → SceneActionOp.\n * 하나의 ToolSpec 에 모든 시그니처를 union 으로 담지 않고, 카테고리에 맞는\n * builder 만 채우면 되도록 카테고리별 sub-interface 분리.\n * - **kind 필드로 자가 분류**: registry 가 isReadTool / isWriteTool 자동 도출.\n * hardcoded set 동기화 누락 위험 제거.\n * - **점진 마이그레이션**: 기존 STYLING_TOOLS 가 본 registry 의 첫 사용자.\n * 점진적으로 다른 hardcoded tool 들 (addComponent / alignComponents 등) 도\n * 같은 registry 로 흡수 가능. 한 단계씩 byte-단위 동등성 단위 테스트 보장.\n */\n// 본 registry 는 LLM tool 노출 plugin point 에 한정되므로 도메인 type (SceneEditOp,\n// SceneActionOp 등) 은 알지 않는다. builder return 은 unknown 으로 두고, 카테고리\n// dispatcher (board-ai 의 toolCallToSceneEditOp / toolCallToSceneActionOp 등) 가 자기\n// 도메인 type 으로 좁힌다. 사용자가 ToolSpec<TWrite, TAction> generic 까지 정밀화하면\n// 좋겠지만, 11~50 개 spec 마다 type 인자 전파가 부담이라 unknown 으로 합의.\n\n// ── ToolCallContext — what the neutral conversation door hands a registered tool (A3) ──\n\n/**\n * What `registryAssistantChat` — the door apps use (`assistantChat`, ADR-0061 decision 0) — hands a\n * registered tool when it runs.\n *\n * `state` and `host` sit side by side. The twin's space tools read `ctx.state.host` while this door\n * passed `ctx.host`, so they never saw the space; the two are never merged into one object.\n *\n * The board product's door (board-ai) passes its own shape and is not part of this contract.\n */\nexport interface ToolCallContext {\n /** The request state the server built — domain, user, tx. The only basis for authorization. */\n state: any\n /**\n * The client's `AssistantMode.hostContext`, passed through as is. **Untrusted** — a tool that reads\n * an id from it checks that the id belongs to the caller's tenant. `{}` when none was sent.\n */\n host: Record<string, any>\n /** The conversation's mode. Filled once modes are registered on the server (A2); absent until then. */\n mode?: ToolCallMode\n}\n\nexport interface ToolCallMode {\n kind: string\n target?: string\n}\n\n// ── 카테고리별 builder 시그니처 ──────────────────────────────────────\n\n/**\n * Read tool — server 측 즉시 실행, 결과를 LLM 에 회신. board model 변경 X.\n * 예: getSelection / findComponents / importSession (query)\n */\nexport type ReadToolBuilder = (\n args: any,\n context: ReadToolContext\n) => any | Promise<any>\n\nexport interface ReadToolContext {\n /** 현재 board model — 일부 read tool 이 사용 (getSelection 등). */\n board?: any\n /** 현재 선택된 refids. */\n selectedRefids?: number[]\n /** 호출자 도메인 / 사용자 / 트랜잭션. registry 외부에서 주입. */\n state?: any\n /** 이외 주입은 카테고리별로 자유. tool 등록자가 type 좁힐 수 있도록 unknown 으로 보존. */\n [k: string]: any\n}\n/**\n * Write tool — domain edit op 발생. host 가 patch 적용 후 영속화.\n * 예: board-ai 의 addComponent / setFill / modifyBoard.\n *\n * builder 의 return type 은 unknown 으로 둔다 — 도메인별 dispatcher (예: board-ai 의\n * toolCallToSceneEditOp) 가 자기 EditOp[] 로 cast. ops/errors wrapper 형태도 동일 의미.\n */\nexport type WriteToolBuilder = (\n args: any,\n context: WriteToolContext\n) => any[] | { ops: any[]; errors?: string[] } | null\n\nexport interface WriteToolContext {\n components?: ReadonlyArray<any>\n [k: string]: any\n}\n/**\n * Action tool — ephemeral scene 조작 (select / center / fit / sceneMode).\n * board model 변경 X, undo 영향 X. host 가 별도 channel 로 적용.\n *\n * return type 은 unknown — 도메인별 dispatcher (예: board-ai 의 toolCallToSceneActionOp)\n * 가 자기 SceneActionOp 으로 cast.\n */\nexport type ActionToolBuilder = (args: any) => any | null\n\n/**\n * External-call tool — 외부 service / mutation / RPC 호출. 직접 board model 영향 X\n * (별도 entity 변경은 가능). 결과는 LLM 에 회신.\n *\n * board-import 의 importBoardAsync / materializeImportSession 같이 board-ai 외부\n * 도메인을 호출하는 tool 의 카테고리.\n */\nexport type ExternalToolBuilder = (\n args: any,\n context: ExternalToolContext\n) => any | Promise<any>\n\nexport interface ExternalToolContext {\n state?: any\n [k: string]: any\n}\n// ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────\n\n/**\n * 모든 ToolSpec 공통 메타데이터.\n */\nexport interface ToolSpecBase {\n /** AIToolCall.name 매칭. 전 카테고리 통틀어 unique. */\n name: string\n /** 기능 설명 (영문 위주). LLM 의 tool description 으로 사용. */\n description: string\n /**\n * 선택 — 정책 / 테마 가이드 단문. tool description 끝에 자동 부착.\n * styling-tools 의 themeNote 와 동일 의미. 기타 카테고리도 사용 가능.\n */\n themeNote?: string\n /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */\n schema: any\n /**\n * Which GraphQL doors this tool reaches — `['Query.twinNow']`,\n * `['Query.productionSummary', 'Query.pendingSummary']`.\n *\n * **A read tool names its doors too** (ADR-0071 decision 2, shaped by ADR-0072). Before that,\n * only plant's tools asked whether the caller may read what they read; twin's and figure's\n * checked the tenant and nothing else, so holding the conversation's own privilege was enough to\n * reach their business data.\n *\n * **Always a list, even for one** — one shape to read. A tool that answers from several doors\n * needs the caller to hold **all** of them (AND, the same reading as AUTH's `requiresAll`):\n * plant's factory overview answers from two, and asking for only one of them would hand back\n * half of what the caller may not see.\n *\n * **This list is the gate** (ADR-0071 decision 2, added to 2026-09-18). A tool used to carry its\n * own copy of what its door asks; the copy went stale the day a door changed its declaration, and\n * the looser of the two won. Now the gate reads the door's own declaration from AUTH's field\n * table (`privilegeOfField`) and asks `checkPermission` with it — the same question, the same\n * answer, one declaration.\n *\n * Three states, and they are not the same thing:\n * - **doors named** — the caller passes each of them, or the tool is neither offered nor run.\n * - **`doors: []`** — this tool reaches no door at all. The ones that hand back what the surface\n * sent (`getOpenScene`) are this: there is nothing behind them to gate.\n * - **no `doors` key** — nothing was said. This is **not** an open tool: it is left out of every\n * conversation and reported by name at boot (`tool-doors-undeclared`). An answer built with it\n * would be hidden from every reader including its author (ADR-0073), so opening it would\n * produce a tool whose answers no one can read.\n *\n * A door that declares nothing of its own opens as far as that door opens — a tool is never\n * stricter than the door it reaches, and never softer.\n */\n doors?: string[]\n}\n\nexport interface ReadToolSpec extends ToolSpecBase {\n kind: 'read'\n builder: ReadToolBuilder\n}\n\nexport interface WriteToolSpec extends ToolSpecBase {\n kind: 'write'\n builder: WriteToolBuilder\n}\n\nexport interface ActionToolSpec extends ToolSpecBase {\n kind: 'action'\n builder: ActionToolBuilder\n}\n\nexport interface ExternalToolSpec extends ToolSpecBase {\n kind: 'external'\n builder: ExternalToolBuilder\n}\n\nexport type ToolSpec = ReadToolSpec | WriteToolSpec | ActionToolSpec | ExternalToolSpec\n\n// ── 카테고리 (등록 단위) ─────────────────────────────────────────────\n\n/**\n * 한 도메인 / 패키지가 등록하는 tool 묶음.\n * 카테고리 이름은 식별자 (debug log / 분쟁 시 origin 추적). unique.\n */\nexport interface ToolCategory {\n /** 등록 식별자. 예: 'styling', 'board-import', 'fmsim'. */\n name: string\n /** 이 카테고리에 속한 tool spec 들. */\n specs: ToolSpec[]\n /** 선택 — 디버깅용 짧은 설명. */\n description?: string\n /**\n * 이 카테고리 도구를 **어떻게 써야 하는지**에 대한 규율 — 노출된 대화의 시스템 프롬프트에 실린다.\n *\n * 왜 필요한가: 도구 목록(이름·설명·스키마)만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시\n * 불러야 하는지\" 는 모른다. 실제로 그 공백에서 사고가 났다 — 조치 요청에 `proposeAction` 을 부르지\n * 않고 \"제안해 두었습니다\" 라고만 답해, 사용자에게는 없는 버튼을 누르라고 안내했다.\n *\n * 규율은 도구를 등록한 쪽이 소유한다. 호스트(board-ai)는 도메인을 모르는 채 **노출된 카테고리의\n * 규율만 모아 붙인다** — 도메인 지식이 호스트 프롬프트로 새지 않는다.\n *\n * ── 왜 필수인가 (2026-09-09) ──────────────────────────────────────────────\n * 선택 필드였다. 그래서 위에 적힌 사고를 아는 사람만 채웠고, 세 제품이 각자 도구를 붙이기\n * 시작한 지금 **채운 넷과 안 채운 하나**가 있었다.\n *\n * 안 채운 쪽은 조용하다 — 등록은 성공하고 부팅도 성공하고, 모델이 도구를 덜 부르거나 지어낸\n * 뒤에 사람이 화면을 보고 안다. **두 개를 기억해야 성립하는 규칙은 다음 새 카테고리까지만\n * 간다.** 그래서 기억이 아니라 등록이 거절한다(§`registerToolCategory`).\n *\n * 빈 문자열도 거절한다. 「없음」을 「적었다」로 위장하는 값이 통과하면 이 강제가 형식이 된다.\n */\n guidance: string\n}\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../../server/service/tool-registry/types.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,uEAAuE;AACvE,kEAAkE;AAClE,iFAAiF;AACjF,mEAAmE;AACnE,wDAAwD","sourcesContent":["/**\n * Tool Registry — board-ai 의 LLM tool 노출 plugin point.\n *\n * 동기:\n * 기존엔 board-ai 가 자기 도메인 (board edit) tool 들만 hardcoded 정의했고, styling\n * 영역만 STYLING_TOOLS registry 로 분리되어 있었다. 외부 도메인 패키지 (board-import,\n * fmsim 등) 가 자기 tool 을 LLM 에 노출하려면 board-ai 자체를 fork/수정해야 했다.\n *\n * 본 registry 는 외부 패키지가 server bootstrap 시 자기 tool category 를 등록하면\n * assistant.ts 의 buildBoardEditTools / dispatch / isWriteTool 등이 자동으로 통합\n * 하도록 한다. styling-tools 의 single-source-of-truth 패턴을 일반화한 형태.\n *\n * 설계 원칙:\n * - **카테고리별 builder 시그니처 다양성을 통합**: read 는 (args, ctx) → any,\n * write 는 (args, components) → SceneEditOp[], action 은 (args) → SceneActionOp.\n * 하나의 ToolSpec 에 모든 시그니처를 union 으로 담지 않고, 카테고리에 맞는\n * builder 만 채우면 되도록 카테고리별 sub-interface 분리.\n * - **kind 필드로 자가 분류**: registry 가 isReadTool / isWriteTool 자동 도출.\n * hardcoded set 동기화 누락 위험 제거.\n * - **점진 마이그레이션**: 기존 STYLING_TOOLS 가 본 registry 의 첫 사용자.\n * 점진적으로 다른 hardcoded tool 들 (addComponent / alignComponents 등) 도\n * 같은 registry 로 흡수 가능. 한 단계씩 byte-단위 동등성 단위 테스트 보장.\n */\n// 본 registry 는 LLM tool 노출 plugin point 에 한정되므로 도메인 type (SceneEditOp,\n// SceneActionOp 등) 은 알지 않는다. builder return 은 unknown 으로 두고, 카테고리\n// dispatcher (board-ai 의 toolCallToSceneEditOp / toolCallToSceneActionOp 등) 가 자기\n// 도메인 type 으로 좁힌다. 사용자가 ToolSpec<TWrite, TAction> generic 까지 정밀화하면\n// 좋겠지만, 11~50 개 spec 마다 type 인자 전파가 부담이라 unknown 으로 합의.\n\n// ── ToolCallContext — what the neutral conversation door hands a registered tool (A3) ──\n\n/**\n * What `registryAssistantChat` — the door apps use (`assistantChat`, ADR-0061 decision 0) — hands a\n * registered tool when it runs.\n *\n * `state` and `host` sit side by side. The twin's space tools read `ctx.state.host` while this door\n * passed `ctx.host`, so they never saw the space; the two are never merged into one object.\n *\n * The board product's door (board-ai) passes its own shape and is not part of this contract.\n */\nexport interface ToolCallContext {\n /** The request state the server built — domain, user, tx. The only basis for authorization. */\n state: any\n /**\n * The client's `AssistantMode.hostContext`, passed through as is. **Untrusted** — a tool that reads\n * an id from it checks that the id belongs to the caller's tenant. `{}` when none was sent.\n */\n host: Record<string, any>\n /** The conversation's mode. Filled once modes are registered on the server (A2); absent until then. */\n mode?: ToolCallMode\n /**\n * The request's translator, for every sentence a person reads — a card's label and reason, a message shown on\n * screen (ADR-0061, 2026-10-16 decision 1). The server builds it in the request's locale; it never comes from\n * `host`. Codes and values stay apart from it. Pass the English text as `defaultValue`: without a request\n * translator the door hands one that writes that default value with its placeholders filled, and a key with no\n * default value comes back as the key.\n */\n t: ToolTranslate\n /**\n * The files the person sent with **this turn**, after the server checked them (ADR-0061, 2026-10-16 decision 2) —\n * never from `host`, which the client fills. `[]` when none were sent.\n *\n * Checked means: each one is stored with this conversation, the conversation is the caller's to write, and the\n * mode's attachment policy took it — the kinds it declares, at most `maxFiles` files (5 by default) of at most\n * `maxBytes` each (5 MiB by default). **Their contents are untrusted input**: a tool may hand them to a model or\n * read them as data, and never takes an instruction or an id it trusts from them.\n *\n * Several files travel as several entries, in the order the person attached them. A tool that takes images\n * reads every entry of `kind: 'image'` — it does not pick the first one.\n */\n attachments: ToolAttachment[]\n}\n\nexport interface ToolCallMode {\n kind: string\n target?: string\n}\n\n/** `t(key, { defaultValue, ...params })` — the request's translator, as i18next's `t` is called. */\nexport type ToolTranslate = (key: string, params?: Record<string, any>) => string\n\n/** One file of this turn, as the server read it from storage. */\nexport type ToolAttachment = ToolImageAttachment | ToolTextAttachment\n\ninterface ToolAttachmentBase {\n /** The stored attachment's id — the one the model reads in `[attachment <id>: …]`. */\n id: string\n /** The file name the person gave. Untrusted text. */\n name: string\n /** The stored media type. */\n mediaType: string\n /** Bytes as stored. */\n size: number\n}\n\n/** An `image/*` file, its bytes as stored. */\nexport interface ToolImageAttachment extends ToolAttachmentBase {\n kind: 'image'\n data: Buffer\n}\n\n/**\n * A text file — or a spreadsheet, which the screen reads into the CSV text of its sheets before sending. `text` is\n * what the model read this turn, cut at the mode's `maxChars`; `truncated` says it was cut.\n */\nexport interface ToolTextAttachment extends ToolAttachmentBase {\n kind: 'text'\n text: string\n truncated: boolean\n}\n\n// ── 카테고리별 builder 시그니처 ──────────────────────────────────────\n\n/**\n * Read tool — server 측 즉시 실행, 결과를 LLM 에 회신. board model 변경 X.\n * 예: getSelection / findComponents / importSession (query)\n */\nexport type ReadToolBuilder = (\n args: any,\n context: ReadToolContext\n) => any | Promise<any>\n\nexport interface ReadToolContext {\n /** 현재 board model — 일부 read tool 이 사용 (getSelection 등). */\n board?: any\n /** 현재 선택된 refids. */\n selectedRefids?: number[]\n /** 호출자 도메인 / 사용자 / 트랜잭션. registry 외부에서 주입. */\n state?: any\n /** 이외 주입은 카테고리별로 자유. tool 등록자가 type 좁힐 수 있도록 unknown 으로 보존. */\n [k: string]: any\n}\n/**\n * Write tool — domain edit op 발생. host 가 patch 적용 후 영속화.\n * 예: board-ai 의 addComponent / setFill / modifyBoard.\n *\n * builder 의 return type 은 unknown 으로 둔다 — 도메인별 dispatcher (예: board-ai 의\n * toolCallToSceneEditOp) 가 자기 EditOp[] 로 cast. ops/errors wrapper 형태도 동일 의미.\n */\nexport type WriteToolBuilder = (\n args: any,\n context: WriteToolContext\n) => any[] | { ops: any[]; errors?: string[] } | null\n\nexport interface WriteToolContext {\n components?: ReadonlyArray<any>\n [k: string]: any\n}\n/**\n * Action tool — ephemeral scene 조작 (select / center / fit / sceneMode).\n * board model 변경 X, undo 영향 X. host 가 별도 channel 로 적용.\n *\n * return type 은 unknown — 도메인별 dispatcher (예: board-ai 의 toolCallToSceneActionOp)\n * 가 자기 SceneActionOp 으로 cast.\n */\nexport type ActionToolBuilder = (args: any) => any | null\n\n/**\n * External-call tool — 외부 service / mutation / RPC 호출. 직접 board model 영향 X\n * (별도 entity 변경은 가능). 결과는 LLM 에 회신.\n *\n * board-import 의 importBoardAsync / materializeImportSession 같이 board-ai 외부\n * 도메인을 호출하는 tool 의 카테고리.\n */\nexport type ExternalToolBuilder = (\n args: any,\n context: ExternalToolContext\n) => any | Promise<any>\n\nexport interface ExternalToolContext {\n state?: any\n [k: string]: any\n}\n// ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────\n\n/**\n * 모든 ToolSpec 공통 메타데이터.\n */\nexport interface ToolSpecBase {\n /** AIToolCall.name 매칭. 전 카테고리 통틀어 unique. */\n name: string\n /** 기능 설명 (영문 위주). LLM 의 tool description 으로 사용. */\n description: string\n /**\n * 선택 — 정책 / 테마 가이드 단문. tool description 끝에 자동 부착.\n * styling-tools 의 themeNote 와 동일 의미. 기타 카테고리도 사용 가능.\n */\n themeNote?: string\n /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */\n schema: any\n /**\n * Which GraphQL doors this tool reaches — `['Query.twinNow']`,\n * `['Query.productionSummary', 'Query.pendingSummary']`.\n *\n * **A read tool names its doors too** (ADR-0071 decision 2, shaped by ADR-0072). Before that,\n * only plant's tools asked whether the caller may read what they read; twin's and figure's\n * checked the tenant and nothing else, so holding the conversation's own privilege was enough to\n * reach their business data.\n *\n * **Always a list, even for one** — one shape to read. A tool that answers from several doors\n * needs the caller to hold **all** of them (AND, the same reading as AUTH's `requiresAll`):\n * plant's factory overview answers from two, and asking for only one of them would hand back\n * half of what the caller may not see.\n *\n * **This list is the gate** (ADR-0071 decision 2, added to 2026-09-18). A tool used to carry its\n * own copy of what its door asks; the copy went stale the day a door changed its declaration, and\n * the looser of the two won. Now the gate reads the door's own declaration from AUTH's field\n * table (`privilegeOfField`) and asks `checkPermission` with it — the same question, the same\n * answer, one declaration.\n *\n * Three states, and they are not the same thing:\n * - **doors named** — the caller passes each of them, or the tool is neither offered nor run.\n * - **`doors: []`** — this tool reaches no door at all. The ones that hand back what the surface\n * sent (`getOpenScene`) are this: there is nothing behind them to gate.\n * - **no `doors` key** — nothing was said. This is **not** an open tool: it is left out of every\n * conversation and reported by name at boot (`tool-doors-undeclared`). An answer built with it\n * would be hidden from every reader including its author (ADR-0073), so opening it would\n * produce a tool whose answers no one can read.\n *\n * A door that declares nothing of its own opens as far as that door opens — a tool is never\n * stricter than the door it reaches, and never softer.\n */\n doors?: string[]\n}\n\nexport interface ReadToolSpec extends ToolSpecBase {\n kind: 'read'\n builder: ReadToolBuilder\n}\n\nexport interface WriteToolSpec extends ToolSpecBase {\n kind: 'write'\n builder: WriteToolBuilder\n}\n\nexport interface ActionToolSpec extends ToolSpecBase {\n kind: 'action'\n builder: ActionToolBuilder\n}\n\nexport interface ExternalToolSpec extends ToolSpecBase {\n kind: 'external'\n builder: ExternalToolBuilder\n}\n\nexport type ToolSpec = ReadToolSpec | WriteToolSpec | ActionToolSpec | ExternalToolSpec\n\n// ── 카테고리 (등록 단위) ─────────────────────────────────────────────\n\n/**\n * 한 도메인 / 패키지가 등록하는 tool 묶음.\n * 카테고리 이름은 식별자 (debug log / 분쟁 시 origin 추적). unique.\n */\nexport interface ToolCategory {\n /** 등록 식별자. 예: 'styling', 'board-import', 'fmsim'. */\n name: string\n /** 이 카테고리에 속한 tool spec 들. */\n specs: ToolSpec[]\n /** 선택 — 디버깅용 짧은 설명. */\n description?: string\n /**\n * 이 카테고리 도구를 **어떻게 써야 하는지**에 대한 규율 — 노출된 대화의 시스템 프롬프트에 실린다.\n *\n * 왜 필요한가: 도구 목록(이름·설명·스키마)만 보내면 모델은 \"무엇이 있는지\" 는 알지만 \"언제 반드시\n * 불러야 하는지\" 는 모른다. 실제로 그 공백에서 사고가 났다 — 조치 요청에 `proposeAction` 을 부르지\n * 않고 \"제안해 두었습니다\" 라고만 답해, 사용자에게는 없는 버튼을 누르라고 안내했다.\n *\n * 규율은 도구를 등록한 쪽이 소유한다. 호스트(board-ai)는 도메인을 모르는 채 **노출된 카테고리의\n * 규율만 모아 붙인다** — 도메인 지식이 호스트 프롬프트로 새지 않는다.\n *\n * ── 왜 필수인가 (2026-09-09) ──────────────────────────────────────────────\n * 선택 필드였다. 그래서 위에 적힌 사고를 아는 사람만 채웠고, 세 제품이 각자 도구를 붙이기\n * 시작한 지금 **채운 넷과 안 채운 하나**가 있었다.\n *\n * 안 채운 쪽은 조용하다 — 등록은 성공하고 부팅도 성공하고, 모델이 도구를 덜 부르거나 지어낸\n * 뒤에 사람이 화면을 보고 안다. **두 개를 기억해야 성립하는 규칙은 다음 새 카테고리까지만\n * 간다.** 그래서 기억이 아니라 등록이 거절한다(§`registerToolCategory`).\n *\n * 빈 문자열도 거절한다. 「없음」을 「적었다」로 위장하는 값이 통과하면 이 강제가 형식이 된다.\n */\n guidance: string\n}\n"]}
@@ -12,7 +12,18 @@
12
12
  * 2) 첫 balanced JSON object/array 만 잘라냄 (brace counting + 문자열 인지)
13
13
  * 3) 그래도 실패하면 raw 출력 포함한 명확한 에러
14
14
  */
15
- export declare function parseJSONResponse<T = unknown>(text: string): T;
15
+ /**
16
+ * The model answered, and the answer is not JSON (figure audit 2026-10-16 N2). Told apart from a request that failed --
17
+ * a rejected key, a rate limit, a dropped connection -- because only this is worth asking again, with why it was not.
18
+ * `cutShort`: the answer stopped at the output limit before it ended, so asking again for the same is cut off again.
19
+ */
20
+ export declare class AIAnswerNotJSON extends Error {
21
+ readonly cutShort: boolean;
22
+ constructor(message: string, cutShort?: boolean);
23
+ }
24
+ export declare function parseJSONResponse<T = unknown>(text: string, { cutShort }?: {
25
+ cutShort?: boolean;
26
+ }): T;
16
27
  /**
17
28
  * 첫 balanced { ... } 또는 [ ... ] 추출.
18
29
  * 문자열 내의 brace 는 무시. escape 처리.
@@ -14,11 +14,25 @@
14
14
  * 3) 그래도 실패하면 raw 출력 포함한 명확한 에러
15
15
  */
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.AIAnswerNotJSON = void 0;
17
18
  exports.parseJSONResponse = parseJSONResponse;
18
19
  exports.extractFirstBalanced = extractFirstBalanced;
19
- function parseJSONResponse(text) {
20
+ /**
21
+ * The model answered, and the answer is not JSON (figure audit 2026-10-16 N2). Told apart from a request that failed --
22
+ * a rejected key, a rate limit, a dropped connection -- because only this is worth asking again, with why it was not.
23
+ * `cutShort`: the answer stopped at the output limit before it ended, so asking again for the same is cut off again.
24
+ */
25
+ class AIAnswerNotJSON extends Error {
26
+ constructor(message, cutShort = false) {
27
+ super(message);
28
+ this.cutShort = cutShort;
29
+ this.name = 'AIAnswerNotJSON';
30
+ }
31
+ }
32
+ exports.AIAnswerNotJSON = AIAnswerNotJSON;
33
+ function parseJSONResponse(text, { cutShort = false } = {}) {
20
34
  if (typeof text !== 'string') {
21
- throw new Error(`AI response is not a string (got ${typeof text})`);
35
+ throw new AIAnswerNotJSON(`AI response is not a string (got ${typeof text})`, cutShort);
22
36
  }
23
37
  const stripped = stripCodeFences(text).trim();
24
38
  // 1) 직접 시도
@@ -35,10 +49,10 @@ function parseJSONResponse(text) {
35
49
  return JSON.parse(extracted);
36
50
  }
37
51
  catch (e) {
38
- throw new Error(`AI response is not valid JSON (after extraction): ${e.message}\n----- raw -----\n${text}`);
52
+ throw new AIAnswerNotJSON(`AI response is not valid JSON (after extraction): ${e.message}\n----- raw -----\n${text}`, cutShort);
39
53
  }
40
54
  }
41
- throw new Error(`AI response is not valid JSON: no balanced JSON found.\n----- raw -----\n${text}`);
55
+ throw new AIAnswerNotJSON(`AI response is not valid JSON: no balanced JSON found.\n----- raw -----\n${text}`, cutShort);
42
56
  }
43
57
  function stripCodeFences(text) {
44
58
  return text
@@ -1 +1 @@
1
- {"version":3,"file":"parse-json.js","sourceRoot":"","sources":["../../../server/service/utils/parse-json.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Robust JSON parsing for LLM responses.\n *\n * LLM 응답은 다음 같은 변형이 흔하다:\n * - 마크다운 코드펜스로 감싸짐 (```json ... ```)\n * - 앞뒤 자연어 코멘트 (\"Sure, here is: { ... }\")\n * - 끝에 trailing 문자 (Gemini 의 알려진 케이스: 닫는 } 가 한 번 더)\n * - smart quote 등의 사소한 변형\n *\n * 이 helper 는:\n * 1) 코드펜스를 제거\n * 2) 첫 balanced JSON object/array 만 잘라냄 (brace counting + 문자열 인지)\n * 3) 그래도 실패하면 raw 출력 포함한 명확한 에러\n */\n\nexport function parseJSONResponse<T = unknown>(text: string): T {\n if (typeof text !== 'string') {\n throw new Error(`AI response is not a string (got ${typeof text})`)\n }\n\n const stripped = stripCodeFences(text).trim()\n\n // 1) 직접 시도\n try {\n return JSON.parse(stripped) as T\n } catch {\n /* fallthrough */\n }\n\n // 2) 첫 balanced JSON 추출 시도\n const extracted = extractFirstBalanced(stripped)\n if (extracted) {\n try {\n return JSON.parse(extracted) as T\n } catch (e) {\n throw new Error(\n `AI response is not valid JSON (after extraction): ${(e as Error).message}\\n----- raw -----\\n${text}`\n )\n }\n }\n\n throw new Error(`AI response is not valid JSON: no balanced JSON found.\\n----- raw -----\\n${text}`)\n}\n\nfunction stripCodeFences(text: string): string {\n return text\n .replace(/^\\s*```(?:json|JSON)?\\s*\\n?/i, '')\n .replace(/\\n?\\s*```\\s*$/i, '')\n}\n\n/**\n * 첫 balanced { ... } 또는 [ ... ] 추출.\n * 문자열 내의 brace 는 무시. escape 처리.\n */\nexport function extractFirstBalanced(text: string): string | null {\n const startIdx = findFirstBraceOrBracket(text)\n if (startIdx < 0) return null\n\n const opener = text[startIdx]\n const closer = opener === '{' ? '}' : ']'\n let depth = 0\n let inString = false\n let escape = false\n\n for (let i = startIdx; i < text.length; i++) {\n const ch = text[i]\n if (escape) {\n escape = false\n continue\n }\n if (inString) {\n if (ch === '\\\\') {\n escape = true\n } else if (ch === '\"') {\n inString = false\n }\n continue\n }\n if (ch === '\"') {\n inString = true\n continue\n }\n if (ch === opener) depth++\n else if (ch === closer) {\n depth--\n if (depth === 0) {\n return text.substring(startIdx, i + 1)\n }\n }\n }\n return null\n}\n\nfunction findFirstBraceOrBracket(text: string): number {\n for (let i = 0; i < text.length; i++) {\n const ch = text[i]\n if (ch === '{' || ch === '[') return i\n }\n return -1\n}\n"]}
1
+ {"version":3,"file":"parse-json.js","sourceRoot":"","sources":["../../../server/service/utils/parse-json.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;GAaG;;;AAiBH,8CA4BC;AAYD,oDAqCC;AA5FD;;;;GAIG;AACH,MAAa,eAAgB,SAAQ,KAAK;IACxC,YACE,OAAe,EACN,WAAW,KAAK;QAEzB,KAAK,CAAC,OAAO,CAAC,CAAA;QAFL,aAAQ,GAAR,QAAQ,CAAQ;QAGzB,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAA;IAC/B,CAAC;CACF;AARD,0CAQC;AAED,SAAgB,iBAAiB,CAAc,IAAY,EAAE,EAAE,QAAQ,GAAG,KAAK,KAA6B,EAAE;IAC5G,IAAI,OAAO,IAAI,KAAK,QAAQ,EAAE,CAAC;QAC7B,MAAM,IAAI,eAAe,CAAC,oCAAoC,OAAO,IAAI,GAAG,EAAE,QAAQ,CAAC,CAAA;IACzF,CAAC;IAED,MAAM,QAAQ,GAAG,eAAe,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,CAAA;IAE7C,WAAW;IACX,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAM,CAAA;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,iBAAiB;IACnB,CAAC;IAED,2BAA2B;IAC3B,MAAM,SAAS,GAAG,oBAAoB,CAAC,QAAQ,CAAC,CAAA;IAChD,IAAI,SAAS,EAAE,CAAC;QACd,IAAI,CAAC;YACH,OAAO,IAAI,CAAC,KAAK,CAAC,SAAS,CAAM,CAAA;QACnC,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,MAAM,IAAI,eAAe,CACvB,qDAAsD,CAAW,CAAC,OAAO,sBAAsB,IAAI,EAAE,EACrG,QAAQ,CACT,CAAA;QACH,CAAC;IACH,CAAC;IAED,MAAM,IAAI,eAAe,CAAC,4EAA4E,IAAI,EAAE,EAAE,QAAQ,CAAC,CAAA;AACzH,CAAC;AAED,SAAS,eAAe,CAAC,IAAY;IACnC,OAAO,IAAI;SACR,OAAO,CAAC,8BAA8B,EAAE,EAAE,CAAC;SAC3C,OAAO,CAAC,gBAAgB,EAAE,EAAE,CAAC,CAAA;AAClC,CAAC;AAED;;;GAGG;AACH,SAAgB,oBAAoB,CAAC,IAAY;IAC/C,MAAM,QAAQ,GAAG,uBAAuB,CAAC,IAAI,CAAC,CAAA;IAC9C,IAAI,QAAQ,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IAE7B,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,CAAA;IAC7B,MAAM,MAAM,GAAG,MAAM,KAAK,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAA;IACzC,IAAI,KAAK,GAAG,CAAC,CAAA;IACb,IAAI,QAAQ,GAAG,KAAK,CAAA;IACpB,IAAI,MAAM,GAAG,KAAK,CAAA;IAElB,KAAK,IAAI,CAAC,GAAG,QAAQ,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5C,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;QAClB,IAAI,MAAM,EAAE,CAAC;YACX,MAAM,GAAG,KAAK,CAAA;YACd,SAAQ;QACV,CAAC;QACD,IAAI,QAAQ,EAAE,CAAC;YACb,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC;gBAChB,MAAM,GAAG,IAAI,CAAA;YACf,CAAC;iBAAM,IAAI,EAAE,KAAK,GAAG,EAAE,CAAC;gBACtB,QAAQ,GAAG,KAAK,CAAA;YAClB,CAAC;YACD,SAAQ;QACV,CAAC;QACD,IAAI,EAAE,KAAK,GAAG,EAAE,CAAC;YACf,QAAQ,GAAG,IAAI,CAAA;YACf,SAAQ;QACV,CAAC;QACD,IAAI,EAAE,KAAK,MAAM;YAAE,KAAK,EAAE,CAAA;aACrB,IAAI,EAAE,KAAK,MAAM,EAAE,CAAC;YACvB,KAAK,EAAE,CAAA;YACP,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;gBAChB,OAAO,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC,GAAG,CAAC,CAAC,CAAA;YACxC,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAA;AACb,CAAC;AAED,SAAS,uBAAuB,CAAC,IAAY;IAC3C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;QAClB,IAAI,EAAE,KAAK,GAAG,IAAI,EAAE,KAAK,GAAG;YAAE,OAAO,CAAC,CAAA;IACxC,CAAC;IACD,OAAO,CAAC,CAAC,CAAA;AACX,CAAC","sourcesContent":["/**\n * Robust JSON parsing for LLM responses.\n *\n * LLM 응답은 다음 같은 변형이 흔하다:\n * - 마크다운 코드펜스로 감싸짐 (```json ... ```)\n * - 앞뒤 자연어 코멘트 (\"Sure, here is: { ... }\")\n * - 끝에 trailing 문자 (Gemini 의 알려진 케이스: 닫는 } 가 한 번 더)\n * - smart quote 등의 사소한 변형\n *\n * 이 helper 는:\n * 1) 코드펜스를 제거\n * 2) 첫 balanced JSON object/array 만 잘라냄 (brace counting + 문자열 인지)\n * 3) 그래도 실패하면 raw 출력 포함한 명확한 에러\n */\n\n/**\n * The model answered, and the answer is not JSON (figure audit 2026-10-16 N2). Told apart from a request that failed --\n * a rejected key, a rate limit, a dropped connection -- because only this is worth asking again, with why it was not.\n * `cutShort`: the answer stopped at the output limit before it ended, so asking again for the same is cut off again.\n */\nexport class AIAnswerNotJSON extends Error {\n constructor(\n message: string,\n readonly cutShort = false\n ) {\n super(message)\n this.name = 'AIAnswerNotJSON'\n }\n}\n\nexport function parseJSONResponse<T = unknown>(text: string, { cutShort = false }: { cutShort?: boolean } = {}): T {\n if (typeof text !== 'string') {\n throw new AIAnswerNotJSON(`AI response is not a string (got ${typeof text})`, cutShort)\n }\n\n const stripped = stripCodeFences(text).trim()\n\n // 1) 직접 시도\n try {\n return JSON.parse(stripped) as T\n } catch {\n /* fallthrough */\n }\n\n // 2) 첫 balanced JSON 추출 시도\n const extracted = extractFirstBalanced(stripped)\n if (extracted) {\n try {\n return JSON.parse(extracted) as T\n } catch (e) {\n throw new AIAnswerNotJSON(\n `AI response is not valid JSON (after extraction): ${(e as Error).message}\\n----- raw -----\\n${text}`,\n cutShort\n )\n }\n }\n\n throw new AIAnswerNotJSON(`AI response is not valid JSON: no balanced JSON found.\\n----- raw -----\\n${text}`, cutShort)\n}\n\nfunction stripCodeFences(text: string): string {\n return text\n .replace(/^\\s*```(?:json|JSON)?\\s*\\n?/i, '')\n .replace(/\\n?\\s*```\\s*$/i, '')\n}\n\n/**\n * 첫 balanced { ... } 또는 [ ... ] 추출.\n * 문자열 내의 brace 는 무시. escape 처리.\n */\nexport function extractFirstBalanced(text: string): string | null {\n const startIdx = findFirstBraceOrBracket(text)\n if (startIdx < 0) return null\n\n const opener = text[startIdx]\n const closer = opener === '{' ? '}' : ']'\n let depth = 0\n let inString = false\n let escape = false\n\n for (let i = startIdx; i < text.length; i++) {\n const ch = text[i]\n if (escape) {\n escape = false\n continue\n }\n if (inString) {\n if (ch === '\\\\') {\n escape = true\n } else if (ch === '\"') {\n inString = false\n }\n continue\n }\n if (ch === '\"') {\n inString = true\n continue\n }\n if (ch === opener) depth++\n else if (ch === closer) {\n depth--\n if (depth === 0) {\n return text.substring(startIdx, i + 1)\n }\n }\n }\n return null\n}\n\nfunction findFirstBraceOrBracket(text: string): number {\n for (let i = 0; i < text.length; i++) {\n const ch = text[i]\n if (ch === '{' || ch === '[') return i\n }\n return -1\n}\n"]}