kcg-code 0.0.0-stage → 0.1.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.
Files changed (286) hide show
  1. package/README.md +228 -2
  2. package/bin/kcgcode.ts +13 -0
  3. package/bun-env.d.ts +19 -0
  4. package/kcg-code.config.example.json +3 -0
  5. package/package.json +83 -4
  6. package/public/logo.svg +1 -0
  7. package/public/manifest.json +18 -0
  8. package/public/sw.js +49 -0
  9. package/src/App.tsx +39 -0
  10. package/src/__tests__/manifest.test.ts +61 -0
  11. package/src/build-config.ts +9 -0
  12. package/src/cli/__tests__/dashboard.test.ts +62 -0
  13. package/src/cli/args.ts +123 -0
  14. package/src/cli/commands.ts +174 -0
  15. package/src/cli/dashboard.tsx +343 -0
  16. package/src/cli/index.ts +37 -0
  17. package/src/cli/theme.ts +100 -0
  18. package/src/components/auth/AuthGate.tsx +182 -0
  19. package/src/components/auth/AvatarEditor.tsx +236 -0
  20. package/src/components/auth/LockScreen.tsx +714 -0
  21. package/src/components/auth/ProfileAvatar.tsx +60 -0
  22. package/src/components/auth/WelcomeScreen.tsx +672 -0
  23. package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
  24. package/src/components/auth/avatar-icons.tsx +114 -0
  25. package/src/components/layout/AppShell.tsx +142 -0
  26. package/src/components/layout/AppSidebar.tsx +419 -0
  27. package/src/components/layout/SearchDialog.tsx +189 -0
  28. package/src/components/layout/SidebarAccount.tsx +111 -0
  29. package/src/components/layout/SidebarSessionMenu.tsx +163 -0
  30. package/src/components/layout/ThemeToggle.tsx +67 -0
  31. package/src/components/projects/FolderBrowser.tsx +142 -0
  32. package/src/components/projects/HomeChat.tsx +250 -0
  33. package/src/components/projects/NewProjectDialog.tsx +249 -0
  34. package/src/components/projects/PanelSection.tsx +102 -0
  35. package/src/components/projects/ProjectInstructions.tsx +229 -0
  36. package/src/components/projects/ProjectRow.tsx +58 -0
  37. package/src/components/projects/ProjectSidePanel.tsx +448 -0
  38. package/src/components/sessions/AgentModeList.tsx +265 -0
  39. package/src/components/sessions/AgentPicker.tsx +123 -0
  40. package/src/components/sessions/AssistantTurn.tsx +145 -0
  41. package/src/components/sessions/AttachmentTile.tsx +161 -0
  42. package/src/components/sessions/CodeBlock.tsx +107 -0
  43. package/src/components/sessions/ComposerActionSheet.tsx +96 -0
  44. package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
  45. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
  46. package/src/components/sessions/ContextPanel.tsx +561 -0
  47. package/src/components/sessions/DropPlate.tsx +165 -0
  48. package/src/components/sessions/FileDropOverlay.tsx +232 -0
  49. package/src/components/sessions/FileMentionDropdown.tsx +47 -0
  50. package/src/components/sessions/ImageLightbox.tsx +173 -0
  51. package/src/components/sessions/MarkdownContent.tsx +187 -0
  52. package/src/components/sessions/Mascot.tsx +47 -0
  53. package/src/components/sessions/MessageActions.tsx +60 -0
  54. package/src/components/sessions/MessageRail.tsx +121 -0
  55. package/src/components/sessions/ModelPicker.tsx +209 -0
  56. package/src/components/sessions/ModelSearchList.tsx +151 -0
  57. package/src/components/sessions/NewSessionDialog.tsx +189 -0
  58. package/src/components/sessions/PendingAttachments.tsx +127 -0
  59. package/src/components/sessions/PromptCard.tsx +1085 -0
  60. package/src/components/sessions/PromptPanel.tsx +112 -0
  61. package/src/components/sessions/SessionComposer.tsx +640 -0
  62. package/src/components/sessions/SessionHeader.tsx +175 -0
  63. package/src/components/sessions/SessionList.tsx +396 -0
  64. package/src/components/sessions/SessionRow.tsx +137 -0
  65. package/src/components/sessions/SessionTimeline.tsx +275 -0
  66. package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
  67. package/src/components/sessions/SessionView.tsx +236 -0
  68. package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
  69. package/src/components/sessions/UserMessage.tsx +177 -0
  70. package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
  71. package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
  72. package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
  73. package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
  74. package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
  75. package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
  76. package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
  77. package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
  78. package/src/components/settings/AboutGroup.tsx +170 -0
  79. package/src/components/settings/AccountGroup.tsx +122 -0
  80. package/src/components/settings/DevicesGroup.tsx +151 -0
  81. package/src/components/settings/LhrRemoteAccess.tsx +262 -0
  82. package/src/components/settings/PasskeysGroup.tsx +551 -0
  83. package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
  84. package/src/components/settings/SecurityGroup.tsx +566 -0
  85. package/src/components/settings/WorkspaceGroup.tsx +370 -0
  86. package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
  87. package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
  88. package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
  89. package/src/components/settings/prefs.tsx +265 -0
  90. package/src/components/skills/InstallNotifications.tsx +325 -0
  91. package/src/components/skills/InstallSkillDialog.tsx +233 -0
  92. package/src/components/skills/SkillCards.tsx +210 -0
  93. package/src/components/skills/SkillsProjectPicker.tsx +178 -0
  94. package/src/components/ui/alert-dialog.tsx +179 -0
  95. package/src/components/ui/alert.tsx +60 -0
  96. package/src/components/ui/badge.tsx +46 -0
  97. package/src/components/ui/bubble.tsx +125 -0
  98. package/src/components/ui/button.tsx +62 -0
  99. package/src/components/ui/card.tsx +75 -0
  100. package/src/components/ui/collapsible.tsx +19 -0
  101. package/src/components/ui/dialog.tsx +142 -0
  102. package/src/components/ui/dropdown-menu.tsx +225 -0
  103. package/src/components/ui/empty.tsx +94 -0
  104. package/src/components/ui/field.tsx +234 -0
  105. package/src/components/ui/input-group.tsx +161 -0
  106. package/src/components/ui/input.tsx +21 -0
  107. package/src/components/ui/label.tsx +19 -0
  108. package/src/components/ui/message-scroller.tsx +131 -0
  109. package/src/components/ui/message.tsx +85 -0
  110. package/src/components/ui/popover.tsx +46 -0
  111. package/src/components/ui/select.tsx +172 -0
  112. package/src/components/ui/separator.tsx +28 -0
  113. package/src/components/ui/sheet.tsx +138 -0
  114. package/src/components/ui/sidebar.tsx +699 -0
  115. package/src/components/ui/skeleton.tsx +15 -0
  116. package/src/components/ui/sonner.tsx +45 -0
  117. package/src/components/ui/spinner.tsx +16 -0
  118. package/src/components/ui/switch.tsx +32 -0
  119. package/src/components/ui/textarea.tsx +18 -0
  120. package/src/components/ui/tooltip.tsx +51 -0
  121. package/src/config.ts +85 -0
  122. package/src/db.ts +146 -0
  123. package/src/frontend.tsx +51 -0
  124. package/src/hooks/__tests__/useMention.test.ts +79 -0
  125. package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
  126. package/src/hooks/__tests__/useRouter.test.ts +17 -0
  127. package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
  128. package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
  129. package/src/hooks/__tests__/useTheme.test.ts +118 -0
  130. package/src/hooks/use-mobile.ts +16 -0
  131. package/src/hooks/useAgentPicker.ts +97 -0
  132. package/src/hooks/useMediaQuery.ts +49 -0
  133. package/src/hooks/useMention.ts +236 -0
  134. package/src/hooks/useProjects.ts +88 -0
  135. package/src/hooks/useResyncOnReconnect.ts +28 -0
  136. package/src/hooks/useRouter.ts +92 -0
  137. package/src/hooks/useSessionChat.ts +427 -0
  138. package/src/hooks/useSessionContext.ts +106 -0
  139. package/src/hooks/useSessionList.ts +234 -0
  140. package/src/hooks/useSidebarData.ts +108 -0
  141. package/src/hooks/useSmoothText.ts +122 -0
  142. package/src/hooks/useTheme.ts +164 -0
  143. package/src/hooks/useWebSocket.ts +196 -0
  144. package/src/index.css +27 -0
  145. package/src/index.html +14 -0
  146. package/src/index.ts +49 -0
  147. package/src/lib/__tests__/attachments.test.ts +58 -0
  148. package/src/lib/__tests__/avatar-presets.test.ts +25 -0
  149. package/src/lib/__tests__/collapsible.test.ts +101 -0
  150. package/src/lib/__tests__/composer.test.ts +118 -0
  151. package/src/lib/__tests__/highlight.test.ts +42 -0
  152. package/src/lib/__tests__/home-chat.test.ts +64 -0
  153. package/src/lib/__tests__/lock-screen.test.ts +64 -0
  154. package/src/lib/__tests__/model-picker.test.ts +205 -0
  155. package/src/lib/__tests__/onboarding.test.ts +41 -0
  156. package/src/lib/__tests__/permission-info.test.ts +61 -0
  157. package/src/lib/__tests__/project-extensions.test.ts +52 -0
  158. package/src/lib/__tests__/project-overview.test.ts +173 -0
  159. package/src/lib/__tests__/references.test.ts +121 -0
  160. package/src/lib/__tests__/routes.test.ts +134 -0
  161. package/src/lib/__tests__/session-summary.test.ts +115 -0
  162. package/src/lib/__tests__/session-title.test.ts +58 -0
  163. package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
  164. package/src/lib/__tests__/skill-installs.test.ts +134 -0
  165. package/src/lib/__tests__/skills-catalog.test.ts +96 -0
  166. package/src/lib/__tests__/tone.test.ts +37 -0
  167. package/src/lib/__tests__/turns-delta.test.ts +55 -0
  168. package/src/lib/api.ts +306 -0
  169. package/src/lib/attachments.ts +203 -0
  170. package/src/lib/auth.ts +92 -0
  171. package/src/lib/avatar-presets.ts +31 -0
  172. package/src/lib/collapsible.ts +75 -0
  173. package/src/lib/composer.ts +89 -0
  174. package/src/lib/data-events.ts +17 -0
  175. package/src/lib/headlines.ts +30 -0
  176. package/src/lib/highlight.ts +186 -0
  177. package/src/lib/layout.ts +20 -0
  178. package/src/lib/lock-screen.ts +54 -0
  179. package/src/lib/model-picker.ts +133 -0
  180. package/src/lib/onboarding.ts +38 -0
  181. package/src/lib/passkeys.ts +113 -0
  182. package/src/lib/pending-prompt.ts +53 -0
  183. package/src/lib/permission-info.ts +101 -0
  184. package/src/lib/project-extensions.ts +74 -0
  185. package/src/lib/project-overview.ts +117 -0
  186. package/src/lib/prompts.ts +87 -0
  187. package/src/lib/references.ts +106 -0
  188. package/src/lib/routes.ts +115 -0
  189. package/src/lib/session-handoff.ts +25 -0
  190. package/src/lib/session-status.ts +45 -0
  191. package/src/lib/session-summary.ts +68 -0
  192. package/src/lib/session-title.ts +63 -0
  193. package/src/lib/sidebar-groups.ts +78 -0
  194. package/src/lib/skill-installs.ts +360 -0
  195. package/src/lib/skills-catalog.ts +99 -0
  196. package/src/lib/time.ts +22 -0
  197. package/src/lib/tone.ts +80 -0
  198. package/src/lib/turns.ts +320 -0
  199. package/src/lib/utils.ts +6 -0
  200. package/src/logo.svg +31 -0
  201. package/src/pages/projects/ProjectDetailPage.tsx +130 -0
  202. package/src/pages/projects/ProjectsPage.tsx +97 -0
  203. package/src/pages/sessions/SessionPage.tsx +122 -0
  204. package/src/pages/settings/SettingsPage.tsx +299 -0
  205. package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
  206. package/src/pages/skills/SkillsPage.tsx +701 -0
  207. package/src/paths.ts +121 -0
  208. package/src/runtime.ts +24 -0
  209. package/src/server/__tests__/app.test.ts +1031 -0
  210. package/src/server/__tests__/auth.test.ts +370 -0
  211. package/src/server/__tests__/config.test.ts +101 -0
  212. package/src/server/__tests__/db.test.ts +485 -0
  213. package/src/server/__tests__/host.test.ts +14 -0
  214. package/src/server/__tests__/tunnel-config.test.ts +38 -0
  215. package/src/server/app.ts +415 -0
  216. package/src/server/db/auth.ts +311 -0
  217. package/src/server/db/messages.ts +50 -0
  218. package/src/server/db/projects.ts +106 -0
  219. package/src/server/db/prompts.ts +78 -0
  220. package/src/server/db/rows.ts +150 -0
  221. package/src/server/db/schema.ts +221 -0
  222. package/src/server/db/sessions.ts +239 -0
  223. package/src/server/db/tunnel.ts +124 -0
  224. package/src/server/host.ts +12 -0
  225. package/src/server/result.ts +15 -0
  226. package/src/server/routes/auth-guard.ts +156 -0
  227. package/src/server/routes/auth.routes.ts +287 -0
  228. package/src/server/routes/helpers.ts +165 -0
  229. package/src/server/routes/meta.routes.ts +24 -0
  230. package/src/server/routes/passkeys.routes.ts +161 -0
  231. package/src/server/routes/projects.routes.ts +157 -0
  232. package/src/server/routes/sessions.routes.ts +197 -0
  233. package/src/server/routes/skills.routes.ts +117 -0
  234. package/src/server/routes/tunnel.routes.ts +132 -0
  235. package/src/server/routes/types.ts +35 -0
  236. package/src/server/routes/uploads.routes.ts +54 -0
  237. package/src/server/routes/workspace.routes.ts +127 -0
  238. package/src/server/services/__tests__/app-info.test.ts +18 -0
  239. package/src/server/services/__tests__/attachments.test.ts +159 -0
  240. package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
  241. package/src/server/services/__tests__/auth.test.ts +341 -0
  242. package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
  243. package/src/server/services/__tests__/git-info.test.ts +74 -0
  244. package/src/server/services/__tests__/opencode.test.ts +320 -0
  245. package/src/server/services/__tests__/project-manager.test.ts +190 -0
  246. package/src/server/services/__tests__/sandbox.test.ts +141 -0
  247. package/src/server/services/__tests__/session-manager.test.ts +3469 -0
  248. package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
  249. package/src/server/services/__tests__/skills-registry.test.ts +359 -0
  250. package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
  251. package/src/server/services/__tests__/tunnel.test.ts +367 -0
  252. package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
  253. package/src/server/services/__tests__/workspace.test.ts +249 -0
  254. package/src/server/services/app-info.ts +29 -0
  255. package/src/server/services/attachments.ts +193 -0
  256. package/src/server/services/auth-passkeys.ts +459 -0
  257. package/src/server/services/auth.ts +633 -0
  258. package/src/server/services/frpc-installer.ts +183 -0
  259. package/src/server/services/git-info.ts +87 -0
  260. package/src/server/services/opencode-client.ts +714 -0
  261. package/src/server/services/opencode-server.ts +319 -0
  262. package/src/server/services/opencode-sse.ts +82 -0
  263. package/src/server/services/project-manager.ts +147 -0
  264. package/src/server/services/sandbox.ts +83 -0
  265. package/src/server/services/session-events.ts +256 -0
  266. package/src/server/services/session-manager.ts +1374 -0
  267. package/src/server/services/skill-install-jobs.ts +262 -0
  268. package/src/server/services/skills-registry.ts +468 -0
  269. package/src/server/services/tunnel-api.ts +207 -0
  270. package/src/server/services/tunnel-config.ts +45 -0
  271. package/src/server/services/tunnel-lhr.ts +387 -0
  272. package/src/server/services/tunnel.ts +633 -0
  273. package/src/server/services/turn-stream.ts +339 -0
  274. package/src/server/services/websocket-gateway.ts +374 -0
  275. package/src/server/services/workspace.ts +223 -0
  276. package/src/server/services/ws-transport.ts +121 -0
  277. package/src/types/context.ts +69 -0
  278. package/src/types/index.ts +13 -0
  279. package/src/types/message.ts +58 -0
  280. package/src/types/project.ts +16 -0
  281. package/src/types/prompt.ts +118 -0
  282. package/src/types/session.ts +52 -0
  283. package/src/types/turns.ts +47 -0
  284. package/src/ws-protocol.ts +93 -0
  285. package/styles/globals.css +311 -0
  286. package/tsconfig.json +37 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Unit test helper katalog skill (src/lib/skills-catalog.ts).
3
+ */
4
+ import { describe, expect, test } from "bun:test";
5
+ import type { SkillInfo } from "@/server/services/opencode-client";
6
+ import type { RegistrySkill } from "@/server/services/skills-registry";
7
+ import type { Project } from "@/types";
8
+ import {
9
+ formatInstalls,
10
+ githubOwner,
11
+ installedProjectSkillNames,
12
+ isInstalled,
13
+ resolveSkillsProject,
14
+ visibleInstalledSkills,
15
+ } from "../skills-catalog";
16
+
17
+ describe("formatInstalls", () => {
18
+ test("ringkas per ribuan / jutaan", () => {
19
+ expect(formatInstalls(0)).toBe("0");
20
+ expect(formatInstalls(950)).toBe("950");
21
+ expect(formatInstalls(1000)).toBe("1K");
22
+ expect(formatInstalls(1234)).toBe("1.2K");
23
+ expect(formatInstalls(752_553)).toBe("753K");
24
+ expect(formatInstalls(1_300_000)).toBe("1.3M");
25
+ });
26
+ });
27
+
28
+ describe("deteksi terpasang", () => {
29
+ const skills: SkillInfo[] = [
30
+ {
31
+ name: "find-skills",
32
+ description: null,
33
+ location: "/p/.agents/skills/find-skills/SKILL.md",
34
+ source: "project",
35
+ },
36
+ { name: "Global-One", description: null, location: "/home/u/.config/x", source: "global" },
37
+ { name: "builtin", description: null, location: null, source: "builtin" },
38
+ ];
39
+ const reg = (skillId: string, name = skillId): RegistrySkill => ({
40
+ id: `a/b/${skillId}`,
41
+ name,
42
+ source: "a/b",
43
+ skillId,
44
+ installs: 1,
45
+ url: "",
46
+ });
47
+
48
+ test("hanya skill sumber project yang dihitung, case-insensitive", () => {
49
+ const names = installedProjectSkillNames(skills);
50
+ expect([...names]).toEqual(["find-skills"]);
51
+ expect(isInstalled(reg("FIND-SKILLS"), names)).toBe(true);
52
+ expect(isInstalled(reg("other", "find-skills"), names)).toBe(true);
53
+ expect(isInstalled(reg("global-one"), names)).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe("resolveSkillsProject", () => {
58
+ const p = (id: string): Project => ({ id, name: id, path: `/${id}`, createdAt: 1 });
59
+ const projects = [p("a"), p("b")];
60
+
61
+ test("URL menang, lalu tersimpan; id tak dikenal dilewati", () => {
62
+ expect(resolveSkillsProject(projects, "b", "a")?.id).toBe("b");
63
+ expect(resolveSkillsProject(projects, "gone", "a")?.id).toBe("a");
64
+ expect(resolveSkillsProject(projects, null, "gone")).toBeNull();
65
+ expect(resolveSkillsProject(projects, null, null)).toBeNull();
66
+ });
67
+
68
+ test("satu-satunya Project dipilih otomatis", () => {
69
+ expect(resolveSkillsProject([p("only")], null, null)?.id).toBe("only");
70
+ expect(resolveSkillsProject([], null, null)).toBeNull();
71
+ });
72
+ });
73
+
74
+ describe("githubOwner", () => {
75
+ test("owner/repo -> owner; domain / bentuk lain -> null", () => {
76
+ expect(githubOwner("vercel-labs/skills")).toBe("vercel-labs");
77
+ expect(githubOwner("mintlify.com")).toBeNull();
78
+ expect(githubOwner("a/b/c")).toBeNull();
79
+ });
80
+ });
81
+
82
+ describe("visibleInstalledSkills", () => {
83
+ const s = (name: string, source: SkillInfo["source"], description: string | null = null) => ({
84
+ name,
85
+ description,
86
+ location: null,
87
+ source,
88
+ });
89
+ const all = [s("g1", "global"), s("builtin", "builtin"), s("p1", "project", "React helper")];
90
+
91
+ test("tanpa bawaan, project dulu, filter nama/deskripsi", () => {
92
+ expect(visibleInstalledSkills(all, "").map((x) => x.name)).toEqual(["p1", "g1"]);
93
+ expect(visibleInstalledSkills(all, "react").map((x) => x.name)).toEqual(["p1"]);
94
+ expect(visibleInstalledSkills(all, "G1").map((x) => x.name)).toEqual(["g1"]);
95
+ });
96
+ });
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BASE_FREQUENCY, scaleFrequency } from "../tone";
3
+
4
+ describe("scaleFrequency", () => {
5
+ test("penanda pertama = nada dasar, makin ke bawah makin tinggi", () => {
6
+ expect(scaleFrequency(0)).toBeCloseTo(BASE_FREQUENCY, 2);
7
+ const freqs = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => scaleFrequency(i));
8
+ for (let i = 1; i < freqs.length; i++) {
9
+ expect(freqs[i]).toBeGreaterThan(freqs[i - 1] as number);
10
+ }
11
+ });
12
+
13
+ test("nada mayor: langkah ke-3 (fa) = 5 semiton, oktaf penuh = 2x dasar", () => {
14
+ expect(scaleFrequency(3)).toBeCloseTo(BASE_FREQUENCY * 2 ** (5 / 12), 2);
15
+ expect(scaleFrequency(7)).toBeCloseTo(BASE_FREQUENCY * 2, 2);
16
+ });
17
+
18
+ test("ditahan di dua oktaf: tidak melengking & tidak turun lagi", () => {
19
+ // Derajat ke-14 = do dua oktaf di atas dasar; di atas itu nadanya sama.
20
+ expect(scaleFrequency(14)).toBeCloseTo(BASE_FREQUENCY * 4, 2);
21
+ expect(scaleFrequency(20)).toBeCloseTo(scaleFrequency(14), 2);
22
+ expect(scaleFrequency(1000)).toBeCloseTo(scaleFrequency(14), 2);
23
+ // Tidak pernah menurun (rail panjang tetap terasa menaik).
24
+ let prev = 0;
25
+ for (let i = 0; i < 40; i++) {
26
+ const f = scaleFrequency(i);
27
+ expect(f).toBeGreaterThanOrEqual(prev);
28
+ expect(f).toBeLessThanOrEqual(BASE_FREQUENCY * 4 + 0.01);
29
+ prev = f;
30
+ }
31
+ });
32
+
33
+ test("index negatif / pecahan tetap aman", () => {
34
+ expect(scaleFrequency(-5)).toBeCloseTo(BASE_FREQUENCY, 2);
35
+ expect(scaleFrequency(1.9)).toBeCloseTo(scaleFrequency(1), 2);
36
+ });
37
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Unit test penerapan delta streaming di sisi Client (`appendMessagePartDelta`)
3
+ * — logika murni, meniru urutan event opencode 1.x: part dibuat dengan teks
4
+ * kosong (`message_part`), lalu teks bertambah lewat `message_part_delta`.
5
+ */
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { SessionMessage } from "@/types";
8
+ import { appendMessagePartDelta, upsertMessagePart } from "../turns";
9
+
10
+ function base(): SessionMessage[] {
11
+ return upsertMessagePart([], "s1", "msg_a1", { type: "text", id: "prt_t1", text: "" });
12
+ }
13
+
14
+ describe("appendMessagePartDelta", () => {
15
+ test("menambahkan delta berurutan ke field part", () => {
16
+ let msgs = base();
17
+ for (const d of ["Ku", "cing ", "lucu."]) {
18
+ msgs = appendMessagePartDelta(msgs, "msg_a1", "prt_t1", "text", d);
19
+ }
20
+ expect(msgs[0]?.parts[0]?.text).toBe("Kucing lucu.");
21
+ expect(msgs[0]?.streaming).toBe(true);
22
+ });
23
+
24
+ test("field belum ada diperlakukan sebagai string kosong", () => {
25
+ const msgs = upsertMessagePart([], "s1", "msg_a1", { type: "reasoning", id: "prt_r1" });
26
+ const next = appendMessagePartDelta(msgs, "msg_a1", "prt_r1", "text", "pikir");
27
+ expect(next[0]?.parts[0]?.text).toBe("pikir");
28
+ });
29
+
30
+ test("pesan/part tak dikenal atau delta kosong -> array yang sama", () => {
31
+ const msgs = base();
32
+ expect(appendMessagePartDelta(msgs, "msg_x", "prt_t1", "text", "a")).toBe(msgs);
33
+ expect(appendMessagePartDelta(msgs, "msg_a1", "prt_x", "text", "a")).toBe(msgs);
34
+ expect(appendMessagePartDelta(msgs, "msg_a1", "prt_t1", "text", "")).toBe(msgs);
35
+ });
36
+
37
+ test("tidak memutasi state sebelumnya", () => {
38
+ const msgs = base();
39
+ const before = JSON.stringify(msgs);
40
+ appendMessagePartDelta(msgs, "msg_a1", "prt_t1", "text", "hai");
41
+ expect(JSON.stringify(msgs)).toBe(before);
42
+ });
43
+
44
+ test("snapshot final (message_part) menggantikan hasil akumulasi", () => {
45
+ let msgs = base();
46
+ msgs = appendMessagePartDelta(msgs, "msg_a1", "prt_t1", "text", "Kuc");
47
+ msgs = upsertMessagePart(msgs, "s1", "msg_a1", {
48
+ type: "text",
49
+ id: "prt_t1",
50
+ text: "Kucing lucu.",
51
+ });
52
+ expect(msgs[0]?.parts).toHaveLength(1);
53
+ expect(msgs[0]?.parts[0]?.text).toBe("Kucing lucu.");
54
+ });
55
+ });
package/src/lib/api.ts ADDED
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Helper fetch API KCG Code (task 24).
3
+ *
4
+ * - Kesalahan HTTP dilempar sebagai `ApiError` dengan `status` dan `code`
5
+ * (dari body `{ error }`) agar komponen menampilkan pesan yang tepat.
6
+ */
7
+
8
+ /** Error hasil permintaan API dengan status HTTP dan kode domain. */
9
+ export class ApiError extends Error {
10
+ readonly status: number;
11
+ readonly code: string;
12
+ /** Body JSON respons error (detail tambahan, mis. daftar item penghalang). */
13
+ readonly body: Record<string, unknown> | null;
14
+
15
+ constructor(
16
+ status: number,
17
+ code: string,
18
+ message: string,
19
+ body?: Record<string, unknown> | null,
20
+ ) {
21
+ super(message);
22
+ this.name = "ApiError";
23
+ this.status = status;
24
+ this.code = code;
25
+ this.body = body ?? null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Dipanggil saat server membalas 401 `AUTH_REQUIRED` (sesi habis / terkunci
31
+ * otomatis / dicabut) — lock screen dipasang oleh `lib/auth.ts`.
32
+ */
33
+ let onAuthRequired: (() => void) | null = null;
34
+ export function setAuthRequiredHandler(fn: (() => void) | null): void {
35
+ onAuthRequired = fn;
36
+ }
37
+
38
+ /** Pemetaan kode error domain -> pesan ramah pengguna (design.md — Error Handling). */
39
+ export function apiErrorMessage(code: string): string {
40
+ // Kode dari server headless opencode membawa detail status, mis.
41
+ // "OC_LIST_MODELS_FAILED(500)" -> cocokkan berdasarkan prefiks.
42
+ if (code.startsWith("OC_LIST_MODELS_FAILED")) {
43
+ return "Failed to load the model list from the opencode server.";
44
+ }
45
+ if (code.startsWith("OC_CREATE_SESSION_FAILED")) {
46
+ return "Failed to create the session on the opencode server.";
47
+ }
48
+ if (code.startsWith("OC_DELETE_SESSION_FAILED")) {
49
+ return "Failed to delete the session on the opencode server.";
50
+ }
51
+ if (code.startsWith("SKILLS_SEARCH_FAILED") || code.startsWith("SKILLS_AUDIT_FAILED")) {
52
+ return "Couldn't reach skills.sh. Check your connection and try again.";
53
+ }
54
+ if (code.startsWith("SERVER_START_FAILED")) {
55
+ return "Failed to start the opencode server for this project.";
56
+ }
57
+
58
+ switch (code) {
59
+ case "NAME_REQUIRED":
60
+ return "Project name is required.";
61
+ case "NAME_TAKEN":
62
+ return "That project name is already in use.";
63
+ case "PATH_TAKEN":
64
+ return "That path is already used by another project.";
65
+ case "PATH_OUTSIDE_SANDBOX":
66
+ return "The path is outside the Sandbox root.";
67
+ case "INVALID_PATH_CHARS":
68
+ return "The path contains invalid characters.";
69
+ case "PROJECT_NOT_FOUND":
70
+ return "Project not found.";
71
+ case "PROJECT_DIR_NOT_FOUND":
72
+ return "The project directory was not found on the server.";
73
+ case "PROJECT_HAS_SESSIONS":
74
+ return "The project still has sessions. Delete them first.";
75
+ case "UNSUPPORTED_AGENT_TYPE":
76
+ return "This CLI_Agent type is not supported.";
77
+ case "SESSION_NOT_FOUND":
78
+ return "Session not found.";
79
+ case "SESSION_NOT_RUNNING":
80
+ return "The session is not running.";
81
+ case "SESSION_ALREADY_RUNNING":
82
+ return "The session is already running.";
83
+ case "MODEL_NOT_FOUND":
84
+ return "The model is not available on this project's opencode server.";
85
+ case "INVALID_INSTRUCTIONS":
86
+ return "Instructions must be text.";
87
+ case "INSTRUCTIONS_TOO_LONG":
88
+ return "Instructions are too long (max 20,000 characters).";
89
+ case "INVALID_SKILL_QUERY":
90
+ return "Type at least 2 characters to search.";
91
+ case "INVALID_SKILL_ID":
92
+ return "That skill identifier is not valid.";
93
+ case "SKILLS_RATE_LIMITED":
94
+ return "skills.sh is rate limiting requests. Wait a moment and try again.";
95
+ case "SKILL_INSTALL_IN_PROGRESS":
96
+ return "Another skill is being installed in this project. Try again when it finishes.";
97
+ case "SKILL_INSTALL_FAILED":
98
+ return "Failed to install the skill. See the output for details.";
99
+ case "SKILL_INSTALL_CANCELLED":
100
+ return "Installation was cancelled.";
101
+ case "SKILL_INSTALL_TIMEOUT":
102
+ return "Installation timed out and was stopped.";
103
+ case "INSTALL_JOB_NOT_RUNNING":
104
+ return "That installation has already finished.";
105
+ case "INSTALL_JOB_LOST":
106
+ return "The server restarted before the installation finished.";
107
+ case "INSTALL_JOB_NOT_FOUND":
108
+ return "That installation is no longer available.";
109
+ case "AUTH_REQUIRED":
110
+ return "Your session is locked. Unlock to continue.";
111
+ case "AUTH_INVALID":
112
+ return "Incorrect PIN or password.";
113
+ case "CURRENT_SECRET_INVALID":
114
+ return "Your current PIN or password is incorrect.";
115
+ case "AUTH_RATE_LIMITED":
116
+ return "Too many attempts. Wait a moment and try again.";
117
+ case "AUTH_NOT_CONFIGURED":
118
+ return "No lock has been set up yet.";
119
+ case "WORKSPACE_PATH_REQUIRED":
120
+ return "Enter a folder path.";
121
+ case "WORKSPACE_PATH_INVALID":
122
+ return "That path isn't valid.";
123
+ case "WORKSPACE_PATH_NOT_ABSOLUTE":
124
+ return "Use a full path, like ~/code or /srv/projects.";
125
+ case "WORKSPACE_NOT_FOUND":
126
+ return 'That folder doesn\'t exist. Tick "Create the folder" to make it.';
127
+ case "WORKSPACE_PARENT_NOT_FOUND":
128
+ return "The folder above it doesn't exist. Only the last folder can be created.";
129
+ case "WORKSPACE_NOT_DIRECTORY":
130
+ return "That path is a file, not a folder.";
131
+ case "WORKSPACE_TOO_BROAD":
132
+ return "That folder is too broad. Pick a dedicated folder, not your home or a system folder.";
133
+ case "WORKSPACE_CONTAINS_APP_DATA":
134
+ return "That folder overlaps KCG Code's own data. Pick a different folder.";
135
+ case "WORKSPACE_NOT_WRITABLE":
136
+ return "KCG Code can't write to that folder. Check its permissions.";
137
+ case "WORKSPACE_PROJECTS_OUTSIDE":
138
+ return "Some projects are outside the new folder. Remove them or pick a folder that contains them:";
139
+ case "WORKSPACE_CREATE_FAILED":
140
+ return "Couldn't create the folder.";
141
+ case "WORKSPACE_CONFIG_UNREADABLE":
142
+ case "WORKSPACE_CONFIG_WRITE_FAILED":
143
+ return "Couldn't save the config file. Check its permissions.";
144
+ case "PASSKEY_INVALID":
145
+ return "That passkey wasn't accepted. Try again or use your PIN or password.";
146
+ case "PASSKEY_NONE":
147
+ return "No passkey is set up for this address yet.";
148
+ case "PASSKEY_UNSUPPORTED_ORIGIN":
149
+ return "Passkeys need a secure address (https or localhost).";
150
+ case "PASSKEY_CHALLENGE_INVALID":
151
+ return "The passkey request expired. Try again.";
152
+ case "PASSKEY_VERIFY_FAILED":
153
+ return "Couldn't verify the passkey. Try again.";
154
+ case "PASSKEY_ALREADY_REGISTERED":
155
+ return "This passkey is already added.";
156
+ case "PASSKEY_LIMIT":
157
+ return "You've reached the maximum of 20 passkeys. Remove one first.";
158
+ case "PASSKEY_LOCK_REQUIRED":
159
+ return "Set up a PIN or password before adding a passkey.";
160
+ case "PASSKEY_NAME_INVALID":
161
+ return "Passkey names must be 1–60 characters.";
162
+ case "PASSKEY_NOT_FOUND":
163
+ return "That passkey no longer exists.";
164
+ case "PIN_DIGITS_ONLY":
165
+ return "PIN can only contain numbers.";
166
+ case "PIN_LENGTH":
167
+ return "PIN must be 6–12 digits.";
168
+ case "PIN_TOO_SIMPLE":
169
+ return "That PIN is too easy to guess. Avoid repeated or sequential digits.";
170
+ case "PASSWORD_TOO_SHORT":
171
+ return "Password must be at least 8 characters.";
172
+ case "PASSWORD_TOO_LONG":
173
+ return "Password must be at most 128 characters.";
174
+ case "NICKNAME_TOO_LONG":
175
+ return "Nickname must be at most 40 characters.";
176
+ case "NICKNAME_INVALID":
177
+ return "Nickname contains invalid characters.";
178
+ case "AVATAR_PRESET_INVALID":
179
+ return "That avatar isn't available.";
180
+ case "AVATAR_TOO_LARGE":
181
+ return "The photo exceeds the 2 MB limit.";
182
+ case "ORIGIN_FORBIDDEN":
183
+ return "Request blocked: it didn't come from this app.";
184
+ case "INVALID_JSON":
185
+ return "The request JSON format is invalid.";
186
+ case "UNSUPPORTED_IMAGE_MIME":
187
+ return "Unsupported image format. Use PNG, JPEG, GIF, or WebP.";
188
+ case "IMAGE_TOO_LARGE":
189
+ return "The image exceeds the 20 MiB limit.";
190
+ case "FILE_TOO_LARGE":
191
+ return "The file exceeds the 20 MiB limit.";
192
+ case "EMPTY_UPLOAD":
193
+ return "The file is empty.";
194
+ case "ATTACHMENT_NOT_FOUND":
195
+ return "The attachment was not found (it may have been deleted).";
196
+ case "TUNNEL_LOCK_REQUIRED":
197
+ return "Set an app lock before turning on remote access.";
198
+ case "TUNNEL_NOT_CONFIGURED":
199
+ return "Remote access isn't configured. Set KCG_TUNNEL_API_URL and restart kcgcode.";
200
+ case "TUNNEL_SIGNED_OUT":
201
+ return "Connect this machine first.";
202
+ case "TUNNEL_PAIRING_EXPIRED":
203
+ return "The code expired before it was approved. Connect again.";
204
+ case "TUNNEL_PAIRING_DENIED":
205
+ return "The request was denied on the sign-in page.";
206
+ case "TUNNEL_DEVICE_REVOKED":
207
+ return "This machine was disconnected from your account. Connect again.";
208
+ case "TUNNEL_RATE_LIMITED":
209
+ return "Too many attempts. Wait a few minutes and try again.";
210
+ case "TUNNEL_API_UNREACHABLE":
211
+ return "Couldn't reach the remote access service. Check your connection.";
212
+ case "TUNNEL_AUTH_FAILED":
213
+ return "The tunnel server rejected this device. Try rotating the secret.";
214
+ case "TUNNEL_PROXY_FAILED":
215
+ return "The tunnel server couldn't publish your address. See the log for details.";
216
+ case "FRPC_UNSUPPORTED_PLATFORM":
217
+ return "Remote access isn't supported on this OS or CPU yet.";
218
+ case "LHR_SSH_MISSING":
219
+ return "localhost.run needs the ssh command, which wasn't found on this computer.";
220
+ case "LHR_START_FAILED":
221
+ return "Couldn't start the connection to localhost.run.";
222
+ case "LHR_DENIED":
223
+ return "localhost.run refused the connection. Try again in a moment.";
224
+ case "INVALID_PROVIDER":
225
+ return "Unknown remote access option.";
226
+ case "FRPC_DOWNLOAD_FAILED":
227
+ case "FRPC_NOT_FOUND":
228
+ return "Couldn't install the tunnel client (frpc). See the log for details.";
229
+ case "FRPC_CHECKSUM_MISMATCH":
230
+ return "The downloaded tunnel client failed verification and was discarded.";
231
+ case "FRPC_START_FAILED":
232
+ return "Couldn't start the tunnel client. See the log for details.";
233
+ default:
234
+ return `Something went wrong (${code}).`;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * `fetch` dengan header otentikasi & penanganan error terstruktur.
240
+ * Melempar `ApiError` untuk status selain 2xx.
241
+ */
242
+ export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
243
+ const headers = new Headers(init.headers);
244
+ // FormData/Blob: browser yang menetapkan content-type (+ boundary multipart).
245
+ const browserTyped =
246
+ init.body instanceof FormData ||
247
+ init.body instanceof Blob ||
248
+ init.body instanceof URLSearchParams;
249
+ if (init.body != null && !browserTyped && !headers.has("content-type")) {
250
+ headers.set("content-type", "application/json");
251
+ }
252
+ const res = await fetch(path, { ...init, headers });
253
+ if (!res.ok) {
254
+ let code = `HTTP_${res.status}`;
255
+ let body: Record<string, unknown> | null = null;
256
+ try {
257
+ const parsed = (await res.json()) as unknown;
258
+ if (parsed && typeof parsed === "object") {
259
+ body = parsed as Record<string, unknown>;
260
+ if (typeof body.error === "string" && body.error !== "") code = body.error;
261
+ }
262
+ } catch {
263
+ // body bukan JSON — pakai kode HTTP default.
264
+ }
265
+ if (res.status === 401 && code === "AUTH_REQUIRED") onAuthRequired?.();
266
+ throw new ApiError(res.status, code, apiErrorMessage(code), body);
267
+ }
268
+ return res;
269
+ }
270
+
271
+ /**
272
+ * Upload satu file (jenis apa pun) ke endpoint lampiran Session.
273
+ * `fetch` multipart mengelola boundary-nya sendiri, jadi content-type TIDAK
274
+ * di-set manual.
275
+ */
276
+ export async function apiUploadFile(
277
+ path: string,
278
+ file: File,
279
+ ): Promise<{ id: string; filename: string; mime: string; size: number }> {
280
+ const body = new FormData();
281
+ body.append("file", file);
282
+ const res = await fetch(path, { method: "POST", body });
283
+ if (!res.ok) {
284
+ let code = `HTTP_${res.status}`;
285
+ try {
286
+ const parsed = (await res.json()) as { error?: string };
287
+ if (typeof parsed.error === "string" && parsed.error !== "") code = parsed.error;
288
+ } catch {
289
+ // body bukan JSON — pakai kode HTTP default.
290
+ }
291
+ if (res.status === 401 && code === "AUTH_REQUIRED") onAuthRequired?.();
292
+ throw new ApiError(res.status, code, apiErrorMessage(code));
293
+ }
294
+ const parsed = (await res.json()) as {
295
+ upload?: { id: string; filename: string; mime: string; size: number };
296
+ };
297
+ if (typeof parsed.upload?.id !== "string") {
298
+ throw new ApiError(res.status, "INVALID_UPLOAD_RESPONSE", "Invalid upload response.");
299
+ }
300
+ return parsed.upload;
301
+ }
302
+
303
+ /** URL HTTP untuk memuat gambar lampiran di `<img>`. */
304
+ export function attachmentUrl(sessionId: string, attachmentId: string): string {
305
+ return `/api/uploads/${encodeURIComponent(sessionId)}/${encodeURIComponent(attachmentId)}`;
306
+ }