@kidsinai/kids-client 0.0.1

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.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Persists "last session" metadata so the [r] Resume option on the
3
+ * Startup screen has something to offer.
4
+ *
5
+ * Note: this is NOT LLM-session resumption (PRD §5.3, deferred to V1
6
+ * because client owns serve subprocess). It only remembers which
7
+ * course/mission the kid was working on so they jump back into the
8
+ * MissionScreen without re-entering flags.
9
+ */
10
+
11
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"
12
+ import { dirname, join } from "node:path"
13
+
14
+ export interface LastSession {
15
+ coursePack: string | null
16
+ mission: string | null
17
+ /** ISO timestamp of the last user action. */
18
+ lastActiveAt: string
19
+ /** Working directory at the time, so resume picks the right project. */
20
+ projectDir: string
21
+ }
22
+
23
+ export function lastSessionPath(configDir: string): string {
24
+ return join(configDir, "last-session.json")
25
+ }
26
+
27
+ export function readLastSession(configDir: string): LastSession | null {
28
+ const path = lastSessionPath(configDir)
29
+ if (!existsSync(path)) return null
30
+ try {
31
+ const raw = readFileSync(path, "utf8")
32
+ const parsed = JSON.parse(raw) as LastSession
33
+ if (typeof parsed.lastActiveAt !== "string") return null
34
+ return parsed
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ export function writeLastSession(configDir: string, session: LastSession): void {
41
+ const path = lastSessionPath(configDir)
42
+ try {
43
+ mkdirSync(dirname(path), { recursive: true })
44
+ writeFileSync(path, JSON.stringify(session, null, 2), "utf8")
45
+ } catch {
46
+ // Resume is a nice-to-have; silently skip if we can't persist.
47
+ }
48
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Owns the local `opencode serve` subprocess for the lifetime of this client.
3
+ *
4
+ * Per docs/v2-api-verification.md Q3 (resolved 2026-05-16): plugin cannot
5
+ * publish custom events through SDK public API, so the audit pipeline
6
+ * tails serve stderr and parses `[kids-audit] {...}` lines.
7
+ *
8
+ * V0 scope cut: client crash kills serve. Session-resume (PRD §5.3) is
9
+ * deferred to V1. See Q3 spike notes.
10
+ */
11
+
12
+ import { spawn, type Subprocess } from "bun"
13
+
14
+ export type ServeReadiness =
15
+ | { kind: "already_running" }
16
+ | { kind: "spawned"; pid: number }
17
+ | { kind: "timeout"; lastError: string }
18
+
19
+ export interface ServeManagerOptions {
20
+ baseUrl: string
21
+ serverPassword: string
22
+ opencodeBin: string
23
+ /** Max wait for readiness probe in ms. Default 10s. */
24
+ readyTimeoutMs?: number
25
+ /** Called for every parsed `[kids-audit]` JSON line on stderr. */
26
+ onAuditLine?: (event: unknown) => void
27
+ /** Called for every other (non-audit) stderr line. Useful for debug log. */
28
+ onDebugLine?: (line: string) => void
29
+ }
30
+
31
+ export class ServeManager {
32
+ private child: Subprocess | null = null
33
+ private opts: ServeManagerOptions
34
+
35
+ constructor(opts: ServeManagerOptions) {
36
+ this.opts = opts
37
+ }
38
+
39
+ /**
40
+ * Probe baseUrl. If already up, no-op. Otherwise spawn `opencode serve`
41
+ * as a child, hook stderr parsing, poll until /app responds 200.
42
+ */
43
+ async ensureReady(): Promise<ServeReadiness> {
44
+ if (await this.probe()) return { kind: "already_running" }
45
+
46
+ const url = new URL(this.opts.baseUrl)
47
+ const proc = spawn({
48
+ cmd: [this.opts.opencodeBin, "serve", "--hostname", url.hostname, "--port", url.port || "4096"],
49
+ env: {
50
+ ...process.env,
51
+ OPENCODE_SERVER_PASSWORD: this.opts.serverPassword,
52
+ },
53
+ stdout: "pipe",
54
+ stderr: "pipe",
55
+ })
56
+ this.child = proc
57
+
58
+ if (proc.stderr) void this.pipeStderr(proc.stderr)
59
+
60
+ const timeout = this.opts.readyTimeoutMs ?? 10_000
61
+ const start = Date.now()
62
+ let lastError = "no response"
63
+ while (Date.now() - start < timeout) {
64
+ if (await this.probe()) return { kind: "spawned", pid: proc.pid ?? -1 }
65
+ await new Promise((r) => setTimeout(r, 200))
66
+ lastError = "still booting"
67
+ }
68
+ return { kind: "timeout", lastError }
69
+ }
70
+
71
+ /** Kill the serve child (V0 MVP: on client exit). */
72
+ async shutdown(): Promise<void> {
73
+ if (this.child && !this.child.killed) {
74
+ this.child.kill()
75
+ await this.child.exited
76
+ }
77
+ this.child = null
78
+ }
79
+
80
+ /** GET /app with Basic Auth. Returns true on 200. */
81
+ private async probe(): Promise<boolean> {
82
+ try {
83
+ const res = await fetch(`${this.opts.baseUrl}/app`, {
84
+ headers: {
85
+ authorization: "Basic " + btoa(`:${this.opts.serverPassword}`),
86
+ },
87
+ })
88
+ return res.ok
89
+ } catch {
90
+ return false
91
+ }
92
+ }
93
+
94
+ private async pipeStderr(stream: ReadableStream<Uint8Array>): Promise<void> {
95
+ const decoder = new TextDecoder()
96
+ let buf = ""
97
+ const reader = stream.getReader()
98
+ while (true) {
99
+ const { value, done } = await reader.read()
100
+ if (done) break
101
+ buf += decoder.decode(value, { stream: true })
102
+ let nl: number
103
+ while ((nl = buf.indexOf("\n")) >= 0) {
104
+ const line = buf.slice(0, nl)
105
+ buf = buf.slice(nl + 1)
106
+ this.handleLine(line)
107
+ }
108
+ }
109
+ if (buf.length > 0) this.handleLine(buf)
110
+ }
111
+
112
+ private handleLine(line: string): void {
113
+ if (!line) return
114
+ const audit = parseAuditLine(line)
115
+ if (audit) this.opts.onAuditLine?.(audit)
116
+ else this.opts.onDebugLine?.(line)
117
+ }
118
+ }
119
+
120
+ export function parseAuditLine(line: string): unknown | null {
121
+ const prefixes = ["[kids-audit] ", "[kids-tui-audit] "]
122
+ for (const prefix of prefixes) {
123
+ if (line.startsWith(prefix)) {
124
+ try {
125
+ return JSON.parse(line.slice(prefix.length))
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+ }
131
+ return null
132
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Session lifecycle wrapper. Thin shim around SDK v2 session resource.
3
+ *
4
+ * Public API:
5
+ * - createSession(): create a new session, return its ID
6
+ * - prompt(text): send a kid message; returns immediately (SSE delivers stream)
7
+ * - abort(): stop the in-flight prompt; session stays live
8
+ *
9
+ * Errors propagate to caller; UI maps to ErrorScreen variants.
10
+ */
11
+
12
+ import type { OpencodeClient } from "./connection.ts"
13
+
14
+ export class SessionManager {
15
+ private client: OpencodeClient
16
+ private currentSessionId: string | null = null
17
+
18
+ constructor(client: OpencodeClient) {
19
+ this.client = client
20
+ }
21
+
22
+ getId(): string | null {
23
+ return this.currentSessionId
24
+ }
25
+
26
+ async create(): Promise<string> {
27
+ const api = (this.client as unknown as { session?: { create: (input?: unknown) => Promise<{ data?: { id?: string } } | { id?: string } | string> } }).session
28
+ if (!api?.create) throw new Error("SDK v2: client.session.create unavailable")
29
+ const result = await api.create({})
30
+ const id = extractId(result)
31
+ if (!id) throw new Error("SDK v2 session.create returned no id")
32
+ this.currentSessionId = id
33
+ return id
34
+ }
35
+
36
+ async prompt(text: string, opts?: { model?: string; agent?: string }): Promise<void> {
37
+ if (!this.currentSessionId) await this.create()
38
+ const sessionID = this.currentSessionId!
39
+ const api = (this.client as unknown as { session?: { prompt: (sessionID: string, body: unknown) => Promise<unknown> } }).session
40
+ if (!api?.prompt) throw new Error("SDK v2: client.session.prompt unavailable")
41
+ await api.prompt(sessionID, {
42
+ parts: [{ type: "text", text }],
43
+ model: opts?.model,
44
+ agent: opts?.agent,
45
+ })
46
+ }
47
+
48
+ async abort(): Promise<void> {
49
+ if (!this.currentSessionId) return
50
+ const api = (this.client as unknown as { session?: { abort: (sessionID: string) => Promise<unknown> } }).session
51
+ if (!api?.abort) return
52
+ await api.abort(this.currentSessionId)
53
+ }
54
+ }
55
+
56
+ function extractId(result: unknown): string | null {
57
+ if (typeof result === "string") return result
58
+ if (result && typeof result === "object") {
59
+ const r = result as { id?: string; data?: { id?: string } }
60
+ return r.id ?? r.data?.id ?? null
61
+ }
62
+ return null
63
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * In-process state store. Pure TS, no Ink imports — V1 Tauri (WebView)
3
+ * reuses this verbatim.
4
+ *
5
+ * Subscription pattern: dumb pub/sub. Renderers (Ink today, WebView later)
6
+ * subscribe to changes via getSnapshot + subscribe pair, suitable for
7
+ * React 18 useSyncExternalStore.
8
+ */
9
+
10
+ export type Screen =
11
+ | { kind: "loading"; message?: string }
12
+ | { kind: "startup" }
13
+ | { kind: "mission" }
14
+ | { kind: "help" }
15
+ | { kind: "course_picker" }
16
+ | {
17
+ kind: "mission_complete"
18
+ missionId: string
19
+ missionTitle: string | null
20
+ passed: number
21
+ total: number
22
+ completionMessage: string
23
+ hasNextMission: boolean
24
+ }
25
+ | { kind: "error"; variant: ErrorVariant; detail?: string }
26
+
27
+ export type ErrorVariant =
28
+ | "serve_unreachable"
29
+ | "network_down"
30
+ | "stars_exhausted"
31
+ | "auth_failed"
32
+ | "config_missing"
33
+ | "ai_hung"
34
+
35
+ export interface ChatMessage {
36
+ id: string
37
+ actor: "kid" | "agent" | "system"
38
+ text: string
39
+ /** Stream still flowing for this message. */
40
+ streaming: boolean
41
+ ts: number
42
+ }
43
+
44
+ export interface PendingPermission {
45
+ requestID: string
46
+ tool?: string
47
+ /** Free-form text the kid sees ("AI 想要读取 index.html") */
48
+ summary: string
49
+ metadata: Record<string, unknown>
50
+ /** Plugin-reported predicted Stars cost for this tool call. */
51
+ starsEstimated?: number
52
+ }
53
+
54
+ export interface DangerousTopic {
55
+ category: "self_harm" | "violence" | "adult" | "other"
56
+ snippet: string
57
+ }
58
+
59
+ export interface ToastState {
60
+ kind: "info" | "warn" | "success"
61
+ text: string
62
+ }
63
+
64
+ export interface KidsClientState {
65
+ screen: Screen
66
+ sessionId: string | null
67
+ messages: ChatMessage[]
68
+ starsBalance: number
69
+ starsBudget: number
70
+ pendingPermission: PendingPermission | null
71
+ dangerousTopic: DangerousTopic | null
72
+ thinking: boolean
73
+ /** Set when wrapper passed --course or KIDS_COURSE_PACK. */
74
+ coursePack: string | null
75
+ mission: string | null
76
+ /** Resolved from CoursePack metadata. null in free-play. */
77
+ packTitle: string | null
78
+ missionTitle: string | null
79
+ /** 1-based current mission index within pack.missions; null in free-play. */
80
+ missionIndex: number | null
81
+ missionTotal: number | null
82
+ /** Transient toast (auto-dismisses after a few seconds in the orchestrator). */
83
+ toast: ToastState | null
84
+ /** Plugin emitted audit events kept for parent dashboard sync (capped). */
85
+ auditBuffer: unknown[]
86
+ }
87
+
88
+ type Listener = (state: KidsClientState) => void
89
+
90
+ const INITIAL: KidsClientState = {
91
+ screen: { kind: "loading" },
92
+ sessionId: null,
93
+ messages: [],
94
+ starsBalance: 0,
95
+ starsBudget: 0,
96
+ pendingPermission: null,
97
+ dangerousTopic: null,
98
+ thinking: false,
99
+ coursePack: null,
100
+ mission: null,
101
+ packTitle: null,
102
+ missionTitle: null,
103
+ missionIndex: null,
104
+ missionTotal: null,
105
+ toast: null,
106
+ auditBuffer: [],
107
+ }
108
+
109
+ const AUDIT_BUFFER_CAP = 500
110
+
111
+ export class Store {
112
+ private state: KidsClientState = INITIAL
113
+ private listeners = new Set<Listener>()
114
+
115
+ getSnapshot(): KidsClientState {
116
+ return this.state
117
+ }
118
+
119
+ subscribe(fn: Listener): () => void {
120
+ this.listeners.add(fn)
121
+ return () => this.listeners.delete(fn)
122
+ }
123
+
124
+ update(patch: Partial<KidsClientState>): void {
125
+ this.state = { ...this.state, ...patch }
126
+ this.notify()
127
+ }
128
+
129
+ appendMessage(msg: ChatMessage): void {
130
+ this.state = { ...this.state, messages: [...this.state.messages, msg] }
131
+ this.notify()
132
+ }
133
+
134
+ appendDelta(messageId: string, delta: string): void {
135
+ const messages = this.state.messages.map((m) =>
136
+ m.id === messageId ? { ...m, text: m.text + delta } : m,
137
+ )
138
+ this.state = { ...this.state, messages }
139
+ this.notify()
140
+ }
141
+
142
+ endStream(messageId: string): void {
143
+ const messages = this.state.messages.map((m) =>
144
+ m.id === messageId ? { ...m, streaming: false } : m,
145
+ )
146
+ this.state = { ...this.state, messages, thinking: false }
147
+ this.notify()
148
+ }
149
+
150
+ pushAudit(event: unknown): void {
151
+ const next = [...this.state.auditBuffer, event]
152
+ if (next.length > AUDIT_BUFFER_CAP) next.shift()
153
+ this.state = { ...this.state, auditBuffer: next }
154
+ this.notify()
155
+ }
156
+
157
+ reset(): void {
158
+ this.state = INITIAL
159
+ this.notify()
160
+ }
161
+
162
+ private notify(): void {
163
+ for (const fn of this.listeners) fn(this.state)
164
+ }
165
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Thin re-export so the client can reuse kids-tui-plugin's pattern list
3
+ * without duplicating it. If the pattern set evolves there, we pick up
4
+ * the change for free via the workspace dep.
5
+ *
6
+ * The kids-tui-plugin export is the source of truth.
7
+ */
8
+
9
+ export type DangerousCategory = "self_harm" | "violence" | "adult" | "other"
10
+
11
+ interface DangerousMatcher {
12
+ category: DangerousCategory
13
+ patterns: RegExp[]
14
+ }
15
+
16
+ // Curated mirror of the patterns we know live in kids-tui-plugin/src/dangerous-topic.ts.
17
+ // Keeping these here as a redundancy in case the upstream module isn't
18
+ // loaded yet (the workspace dep should resolve, but at the time of this
19
+ // commit kids-tui-plugin hasn't been imported into kids-client tests).
20
+ //
21
+ // When the actual file becomes available via the workspace, replace
22
+ // these arrays with `import { detect } from "@kidsinai/kids-opencode-tui-plugin"`.
23
+ const ZH: DangerousMatcher[] = [
24
+ { category: "self_harm", patterns: [/想.{0,3}(自杀|死|结束.{0,2}生命)/, /(伤害自己|自残)/] },
25
+ { category: "violence", patterns: [/(怎么.{0,3}杀|做.{0,2}炸弹|做.{0,2}武器)/] },
26
+ { category: "adult", patterns: [/(色情|裸照|性.{0,2}行为)/] },
27
+ ]
28
+ const EN: DangerousMatcher[] = [
29
+ { category: "self_harm", patterns: [/\b(kill\s+myself|end\s+my\s+life|suicide|self[-\s]?harm)\b/i] },
30
+ { category: "violence", patterns: [/\b(make\s+a\s+bomb|how\s+to\s+kill|weapon)\b/i] },
31
+ { category: "adult", patterns: [/\b(nude|porn|sexual\s+act)\b/i] },
32
+ ]
33
+
34
+ function detect(text: string, matchers: DangerousMatcher[]): DangerousCategory | null {
35
+ if (!text) return null
36
+ for (const m of matchers) {
37
+ for (const p of m.patterns) {
38
+ if (p.test(text)) return m.category
39
+ }
40
+ }
41
+ return null
42
+ }
43
+
44
+ export function detectDangerousTopicZh(text: string): DangerousCategory | null {
45
+ return detect(text, ZH)
46
+ }
47
+
48
+ export function detectDangerousTopicEn(text: string): DangerousCategory | null {
49
+ return detect(text, EN)
50
+ }