@sciilo.ai/codex-sidecar 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.
@@ -0,0 +1,125 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { spawn } from 'node:child_process'
3
+ import { createInterface } from 'node:readline'
4
+ import { codexInvocation } from './codex-cli.js'
5
+
6
+ export class CodexAppServer extends EventEmitter {
7
+ constructor({ command, cwd, spawnProcess = spawn } = {}) {
8
+ super()
9
+ this.invocation = codexInvocation(command)
10
+ this.cwd = cwd
11
+ this.spawnProcess = spawnProcess
12
+ this.nextId = 1
13
+ this.pending = new Map()
14
+ this.child = null
15
+ }
16
+
17
+ async start() {
18
+ if (this.child) return
19
+ const child = this.spawnProcess(
20
+ this.invocation.command,
21
+ [...this.invocation.prefixArgs, 'app-server'],
22
+ {
23
+ cwd: this.cwd,
24
+ stdio: ['pipe', 'pipe', 'pipe'],
25
+ env: process.env,
26
+ },
27
+ )
28
+ this.child = child
29
+ child.once('error', error => this.failAll(error))
30
+ child.once('exit', (code, signal) => {
31
+ const error = new Error(`Codex App Server stopped (${signal || code}).`)
32
+ this.child = null
33
+ this.failAll(error)
34
+ this.emit('exit', error)
35
+ })
36
+ child.stderr.setEncoding('utf8')
37
+ child.stderr.on('data', chunk => this.emit('log', chunk.trimEnd()))
38
+ createInterface({ input: child.stdout }).on('line', line => this.receiveLine(line))
39
+
40
+ await this.request('initialize', {
41
+ clientInfo: {
42
+ name: 'sciilo-sidecar',
43
+ title: 'Sciilo Codex Sidecar',
44
+ version: '0.1.0',
45
+ },
46
+ capabilities: {
47
+ experimentalApi: true,
48
+ requestAttestation: false,
49
+ },
50
+ })
51
+ this.notify('initialized')
52
+ const account = await this.request('account/read', { refreshToken: false })
53
+ if (account?.requiresOpenaiAuth && !account?.account) {
54
+ throw new Error('Codex is not authenticated. Run `sciilo-sidecar setup`.')
55
+ }
56
+ }
57
+
58
+ request(method, params) {
59
+ if (!this.child?.stdin?.writable) {
60
+ return Promise.reject(new Error('Codex App Server is unavailable.'))
61
+ }
62
+ const id = this.nextId++
63
+ return new Promise((resolve, reject) => {
64
+ this.pending.set(String(id), { resolve, reject, method })
65
+ this.write({ method, id, params })
66
+ })
67
+ }
68
+
69
+ notify(method, params) {
70
+ this.write(params === undefined ? { method } : { method, params })
71
+ }
72
+
73
+ respond(id, result) {
74
+ this.write({ id, result })
75
+ }
76
+
77
+ respondError(id, message, code = -32603) {
78
+ this.write({ id, error: { code, message } })
79
+ }
80
+
81
+ write(frame) {
82
+ this.child?.stdin?.write(`${JSON.stringify(frame)}\n`)
83
+ }
84
+
85
+ receiveLine(line) {
86
+ if (!line.trim()) return
87
+ let frame
88
+ try {
89
+ frame = JSON.parse(line)
90
+ } catch {
91
+ this.emit('log', `Ignored non-JSON Codex frame: ${line}`)
92
+ return
93
+ }
94
+ if (frame.id !== undefined && !frame.method) {
95
+ const pending = this.pending.get(String(frame.id))
96
+ if (!pending) return
97
+ this.pending.delete(String(frame.id))
98
+ if (frame.error) {
99
+ const error = new Error(frame.error.message || `${pending.method} failed`)
100
+ error.code = frame.error.code
101
+ pending.reject(error)
102
+ } else {
103
+ pending.resolve(frame.result)
104
+ }
105
+ return
106
+ }
107
+ if (frame.method && frame.id !== undefined) {
108
+ this.emit('request', frame)
109
+ return
110
+ }
111
+ if (frame.method) this.emit('notification', frame)
112
+ }
113
+
114
+ stop() {
115
+ const child = this.child
116
+ this.child = null
117
+ if (child && !child.killed) child.kill('SIGTERM')
118
+ this.failAll(new Error('Codex App Server stopped.'))
119
+ }
120
+
121
+ failAll(error) {
122
+ for (const pending of this.pending.values()) pending.reject(error)
123
+ this.pending.clear()
124
+ }
125
+ }
@@ -0,0 +1,37 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { createRequire } from 'node:module'
3
+
4
+ const require = createRequire(import.meta.url)
5
+
6
+ export function bundledCodexEntrypoint(resolvePackage = require.resolve) {
7
+ return resolvePackage('@openai/codex/bin/codex.js')
8
+ }
9
+
10
+ export function codexInvocation(configuredCommand, resolvePackage) {
11
+ if (configuredCommand) {
12
+ return {
13
+ command: configuredCommand,
14
+ prefixArgs: [],
15
+ source: 'configured',
16
+ }
17
+ }
18
+ return {
19
+ command: process.execPath,
20
+ prefixArgs: [bundledCodexEntrypoint(resolvePackage)],
21
+ source: 'bundled',
22
+ }
23
+ }
24
+
25
+ export function runCodexSync(
26
+ configuredCommand,
27
+ args,
28
+ options,
29
+ spawnProcess = spawnSync,
30
+ ) {
31
+ const invocation = codexInvocation(configuredCommand)
32
+ return spawnProcess(
33
+ invocation.command,
34
+ [...invocation.prefixArgs, ...args],
35
+ options,
36
+ )
37
+ }
package/src/config.js ADDED
@@ -0,0 +1,87 @@
1
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { dirname, join, resolve } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ export const DEFAULT_CODEX_MODEL = 'gpt-5.6-sol'
6
+
7
+ /**
8
+ * Pinned rather than inherited.
9
+ *
10
+ * A thread started without this setting takes whatever `model_reasoning_effort`
11
+ * the machine's `~/.codex/config.toml` happens to hold — a personal CLI
12
+ * preference, set for an entirely different purpose. On a machine configured
13
+ * for `xhigh`, every answer paid for maximum reasoning before its first token,
14
+ * and the product behaved differently from one computer to the next for a
15
+ * reason no one could see from here.
16
+ */
17
+ export const DEFAULT_REASONING_EFFORT = 'medium'
18
+
19
+ export const defaultConfigPath = () => process.env.SCIILO_SIDECAR_CONFIG
20
+ || join(homedir(), '.config', 'sciilo-sidecar', 'config.json')
21
+
22
+ export function resolveWorkspace(explicit) {
23
+ return resolve(explicit || process.cwd())
24
+ }
25
+
26
+ export async function loadConfig(path = defaultConfigPath()) {
27
+
28
+ let raw
29
+ try {
30
+ raw = await readFile(path, 'utf8')
31
+ } catch (error) {
32
+ if (error.code === 'ENOENT') return null
33
+ throw error
34
+ }
35
+ const config = JSON.parse(raw)
36
+ validateConfig(config)
37
+ return normalizeConfig(config)
38
+ }
39
+
40
+ export async function saveConfig(config, path = defaultConfigPath()) {
41
+ validateConfig(config)
42
+ const normalized = normalizeConfig(config)
43
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
44
+ await writeFile(path, `${JSON.stringify(normalized, null, 2)}\n`, {
45
+ encoding: 'utf8',
46
+ mode: 0o600,
47
+ })
48
+ await chmod(path, 0o600)
49
+ return normalized
50
+ }
51
+
52
+ export function normalizeConfig(config) {
53
+ const configuredModel = config.model === 'gtp-sol'
54
+ ? DEFAULT_CODEX_MODEL
55
+ : config.model
56
+ const configuredCommand = typeof config.codexCommand === 'string'
57
+ ? config.codexCommand.trim()
58
+ : ''
59
+ return {
60
+ appUrl: config.appUrl.replace(/\/+$/, ''),
61
+ connectionKey: config.connectionKey.trim(),
62
+ codexCommand: configuredCommand && configuredCommand !== 'codex'
63
+ ? configuredCommand
64
+ : null,
65
+ model: configuredModel || DEFAULT_CODEX_MODEL,
66
+ modelProvider: config.modelProvider || null,
67
+ reasoningEffort: (typeof config.reasoningEffort === 'string'
68
+ && config.reasoningEffort.trim()) || DEFAULT_REASONING_EFFORT,
69
+ }
70
+ }
71
+
72
+ export function validateConfig(config) {
73
+ if (!config || typeof config !== 'object') {
74
+ throw new Error('Sidecar configuration is missing.')
75
+ }
76
+ if (!/^https?:\/\//.test(config.appUrl || '')) {
77
+ throw new Error('appUrl must start with http:// or https://.')
78
+ }
79
+ const appUrl = new URL(config.appUrl)
80
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(appUrl.hostname)
81
+ if (appUrl.protocol !== 'https:' && !loopback) {
82
+ throw new Error('HTTPS is required outside the local machine.')
83
+ }
84
+ if (!/^sc_[0-9a-f-]{36}\.[A-Za-z0-9_-]{40,}$/.test(config.connectionKey || '')) {
85
+ throw new Error('The sidecar connection key is invalid.')
86
+ }
87
+ }
@@ -0,0 +1,195 @@
1
+ import { fieldContext, openField, sealField } from './vault.js'
2
+
3
+ export const MARKER = 'sciilo:sealed:v1:'
4
+
5
+ const TOKEN = /sciilo:sealed:v1:([0-9a-fA-F-]{36}):([a-z_]+):([A-Za-z0-9+/]+={0,2})/g
6
+
7
+ export function isSealed(value) {
8
+ return typeof value === 'string' && value.startsWith(MARKER)
9
+ }
10
+
11
+ function contextOf(documentId, field) {
12
+ return fieldContext({ documentId, field })
13
+ }
14
+
15
+ export async function sealValue(key, documentId, field, plaintext) {
16
+ const sealed = await sealField(key, String(plaintext), contextOf(documentId, field))
17
+ return `${MARKER}${documentId}:${field}:${sealed}`
18
+ }
19
+
20
+ export async function openValue(key, token) {
21
+ const parts = TOKEN.exec(token)
22
+ TOKEN.lastIndex = 0
23
+ if (!parts) throw new Error('Not a sealed field.')
24
+ const [, documentId, field, payload] = parts
25
+ return openField(key, payload, contextOf(documentId, field))
26
+ }
27
+
28
+ /**
29
+ * Opens every sealed token embedded in a piece of text.
30
+ *
31
+ * <p>The replacement goes through a FUNCTION, never a string. Passing the
32
+ * plaintext as the replacement made `$&`, `` $` ``, `$'` and `$1` inside it
33
+ * expand as substitution patterns: a decrypted note containing `100 $&` used to
34
+ * paste the ciphertext token back into what the agent reads. The content is
35
+ * data, and this is the only way to say so.</p>
36
+ *
37
+ * <p>Tokens are opened once each and substituted in a single pass. Replacing
38
+ * them one at a time re-scanned the whole text per token, which on a board
39
+ * carrying several sealed fields is quadratic for no reason.</p>
40
+ */
41
+ export async function openText(key, text) {
42
+ if (typeof text !== 'string' || !text.includes(MARKER)) return text
43
+ const tokens = [...new Set(text.match(TOKEN) || [])]
44
+ if (!tokens.length) return text
45
+
46
+ const clear = new Map()
47
+ await Promise.all(tokens.map(async token => {
48
+ try {
49
+ clear.set(token, await openValue(key, token))
50
+ } catch {
51
+ // Left as it stands: a token that refuses to open is better shown
52
+ // unreadable than replaced by nothing, which would read as empty.
53
+ }
54
+ }))
55
+ return text.replace(TOKEN, token => clear.get(token) ?? token)
56
+ }
57
+
58
+ export function diagramKind(language, source) {
59
+ const lang = String(language || '').trim().toLowerCase()
60
+ const lower = String(source || '').trim().toLowerCase()
61
+
62
+ if (lang === 'plantuml') {
63
+ if (/\b(?:class|interface|enum|annotation|entity|struct)\b/.test(lower)) {
64
+ return 'Class diagram'
65
+ }
66
+ if (lower.includes('[*]') || /\bstate\s+[\w"]/.test(lower)) return 'State diagram'
67
+ return 'Sequence diagram'
68
+ }
69
+ if (lang !== 'mermaid') return 'Diagram'
70
+
71
+ if (lower.includes('%% sciilo:product-vision')) return 'Product Vision'
72
+ if (/^erdiagram\b/.test(lower)) return 'Data model'
73
+ if (/^classdiagram\b/.test(lower)) return 'Class diagram'
74
+ if (/^sequencediagram\b/.test(lower)) return 'Sequence diagram'
75
+ if (/^statediagram(?:-v2)?\b/.test(lower)) return 'State diagram'
76
+ if (/^mindmap\b/.test(lower)) return 'Mind map'
77
+ if (/^journey\b/.test(lower)) return 'User journey'
78
+ if (/^c4(?:context|container|component|dynamic|deployment)\b/.test(lower)) return 'C4 architecture'
79
+ if (/^(?:flowchart|graph)\b/.test(lower)) return 'Flowchart'
80
+ return 'Mermaid diagram'
81
+ }
82
+
83
+ /** PlantUML announces itself; everything else here is Mermaid. */
84
+ export function guessLanguage(source) {
85
+ return /^\s*@startuml/i.test(String(source || '')) ? 'plantuml' : 'mermaid'
86
+ }
87
+
88
+ export function excerptOf(source) {
89
+ const plain = String(source || '')
90
+ .replace(/^\s{0,3}#{1,6}\s+/gm, '')
91
+ .replace(/\[([^\]]+)]\([^)]+\)/g, '$1')
92
+ .replace(/[*_`>|]/g, '')
93
+ .replace(/\s+/g, ' ')
94
+ .trim()
95
+ return plain.length > 180 ? `${plain.slice(0, 177).trimEnd()}...` : plain
96
+ }
97
+
98
+ const VISION_MARKER = '%% sciilo:product-vision'
99
+
100
+ const VISION_SECTIONS = [
101
+ ['emotions', 'Desired momentum'],
102
+ ['problem', 'Problem'],
103
+ ['users', 'Users'],
104
+ ['value', 'Value proposition'],
105
+ ['capabilities', 'Main capabilities'],
106
+ ['journeys', 'Key journeys'],
107
+ ['unknowns', 'Unknowns to confirm'],
108
+ ]
109
+
110
+ function visionEscaped(value) {
111
+ return String(value ?? '').replace(/\r/g, ' ').replace(/\n/g, ' ').replace(/"/g, '#quot;')
112
+ }
113
+
114
+ function visionBranch(lines, key, label, items) {
115
+ lines.push(` ${key}["${visionEscaped(label)}"]`)
116
+ items.forEach((item, index) => {
117
+ lines.push(` ${key}_${index + 1}["${visionEscaped(item)}"]`)
118
+ })
119
+ }
120
+
121
+ function visionList(raw) {
122
+ if (!Array.isArray(raw)) return []
123
+ return raw.map(value => String(value ?? '').trim()).filter(Boolean).slice(0, 6)
124
+ }
125
+
126
+ export function productVisionSource(args = {}) {
127
+ const lines = [VISION_MARKER, 'mindmap', ` product(("${visionEscaped(args.product)}"))`]
128
+ visionBranch(lines, 'vision', 'Vision', [String(args.vision ?? '')])
129
+ for (const [key, label] of VISION_SECTIONS) {
130
+ visionBranch(lines, key, label, visionList(args[key]))
131
+ }
132
+ return lines.join('\n')
133
+ }
134
+
135
+ const CONTENT_TOOLS = {
136
+ create_diagram: { fields: ['source'], creates: true, derive: deriveDiagram },
137
+ update_diagram: { fields: ['source'], idFrom: 'document_id', derive: deriveDiagram },
138
+ create_markdown_board: { fields: ['source', 'excerpt'], creates: true, derive: deriveMarkdown },
139
+ update_markdown_board: { fields: ['source', 'excerpt'], idFrom: 'document_id', derive: deriveMarkdown },
140
+ create_product_vision: {
141
+ fields: ['source'],
142
+ creates: true,
143
+ derive: deriveVision,
144
+ strip: ['product', 'vision', ...VISION_SECTIONS.map(([key]) => key)],
145
+ },
146
+
147
+ add_note: { fields: ['content'], idFrom: 'document_id' },
148
+ update_note: { fields: ['content'], idFrom: 'document_id' },
149
+ }
150
+
151
+ function deriveDiagram(args) {
152
+ const lang = args.lang || guessLanguage(args.source)
153
+ return { kind: diagramKind(lang, args.source) }
154
+ }
155
+
156
+ function deriveMarkdown(args) {
157
+ return { excerpt: excerptOf(args.source) }
158
+ }
159
+
160
+ function deriveVision(args) {
161
+ return { source: productVisionSource(args), lang: 'mermaid' }
162
+ }
163
+
164
+ /**
165
+ * Seals the content-bearing arguments of a tool call.
166
+ *
167
+ * Returns the arguments unchanged when there is no key, when the tool carries
168
+ * no content, or when there is nothing to seal. Refusing to write would be a
169
+ * worse failure than writing in clear: the user would lose the work, and the
170
+ * database guard already reports anything readable that reaches storage.
171
+ */
172
+ export async function sealArguments(key, tool, args) {
173
+ const spec = CONTENT_TOOLS[tool]
174
+ if (!key || !spec || !args || typeof args !== 'object') return args
175
+
176
+ const sealedArgs = { ...args, ...(spec.derive ? spec.derive(args) : {}) }
177
+ const documentId = spec.creates
178
+ ? (sealedArgs.documentId = randomId())
179
+ : sealedArgs[spec.idFrom]
180
+ if (!documentId) return args
181
+
182
+ for (const field of spec.fields) {
183
+ const value = sealedArgs[field]
184
+ if (typeof value !== 'string' || !value || isSealed(value)) continue
185
+ sealedArgs[field] = await sealValue(key, documentId, field, value)
186
+ }
187
+ for (const field of spec.strip || []) {
188
+ delete sealedArgs[field]
189
+ }
190
+ return sealedArgs
191
+ }
192
+
193
+ function randomId() {
194
+ return crypto.randomUUID()
195
+ }