lilflow 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,228 @@
1
+ import { access } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+ import path from "node:path";
4
+ import { runOpencode } from "./opencode.js";
5
+ import { runClaudeCode } from "./claude-code.js";
6
+ import { resolvePromptInput } from "./prompt.js";
7
+ import { getSessionId, loadSessionStore, saveSessionId } from "./session-store.js";
8
+
9
+ const PROVIDERS = {
10
+ opencode: {
11
+ defaultBin: "opencode",
12
+ configKey: "agent.opencode.bin",
13
+ run: runOpencode
14
+ },
15
+ "claude-code": {
16
+ defaultBin: "claude",
17
+ configKey: "agent.claude.bin",
18
+ run: runClaudeCode
19
+ }
20
+ };
21
+
22
+ /**
23
+ * Return the list of supported agent providers.
24
+ *
25
+ * @returns {string[]} Provider names.
26
+ */
27
+ export function getSupportedProviders() {
28
+ return Object.keys(PROVIDERS);
29
+ }
30
+
31
+ /**
32
+ * Execute an agent step. Resolves the binary, loads any prior session for
33
+ * `session: continue`, resolves the prompt input, delegates to the provider
34
+ * adapter, and persists any new session ID returned by the agent.
35
+ *
36
+ * @param {object} options - Dispatch options.
37
+ * @param {{name: string, agent: object}} options.step - Normalized step definition.
38
+ * @param {string} options.cwd - Workflow working directory.
39
+ * @param {object} options.env - Step environment.
40
+ * @param {number} options.timeoutMs - Step timeout in milliseconds.
41
+ * @param {object} options.config - Flow config (used to resolve binary overrides).
42
+ * @param {string} options.runId - Current workflow run ID.
43
+ * @param {(message: string) => void} options.warn - Warning sink used when session continue fails.
44
+ * @param {typeof import("node:child_process").spawn} [options.spawnProcess] - Child process factory (tests).
45
+ * @param {{isCancelled: () => boolean, getReason: () => string | null, trackChild: (child: import("node:child_process").ChildProcess) => () => void}} [options.cancellation] - Cancellation controller.
46
+ * @returns {Promise<{exitCode: number, stdout: string, stderr: string, combinedOutput: string, costUsd: number | null, sessionId: string | null}>} Agent run result.
47
+ */
48
+ export async function executeAgentStep(options) {
49
+ const {
50
+ step,
51
+ cwd,
52
+ env,
53
+ timeoutMs,
54
+ config,
55
+ runId,
56
+ warn,
57
+ spawnProcess,
58
+ cancellation = null,
59
+ templateFn = (value) => value
60
+ } = options;
61
+ const agentSpec = step.agent;
62
+ const providerName = agentSpec.provider;
63
+ const provider = PROVIDERS[providerName];
64
+
65
+ if (!provider) {
66
+ throw new AgentDispatchError(
67
+ `Unknown agent provider '${providerName}'. Supported: ${getSupportedProviders().join(", ")}.`,
68
+ "AGENT_UNKNOWN_PROVIDER"
69
+ );
70
+ }
71
+
72
+ const binary = await resolveAgentBinary(providerName, config, env);
73
+
74
+ let sessionId = null;
75
+
76
+ if (agentSpec.session === "continue") {
77
+ const store = await loadSessionStore(cwd, runId);
78
+ sessionId = getSessionId(store, step.name);
79
+
80
+ if (sessionId === null && typeof warn === "function") {
81
+ warn(
82
+ `Agent step '${step.name}' requested session: continue but no prior session exists for this step; starting fresh.`
83
+ );
84
+ }
85
+ }
86
+
87
+ // Resolve prompt input from the RAW (untemplated) value first, then template
88
+ // the resolved text. This prevents templated content (e.g. a `{{steps.x.stdout}}`
89
+ // that happens to start with `./`) from being mis-interpreted as a file path.
90
+ const rawPrompt = agentSpec.prompt;
91
+ const promptText = await resolvePromptInput(rawPrompt, cwd);
92
+ const templatedPrompt = templateFn(promptText);
93
+
94
+ if (typeof templatedPrompt !== "string" || templatedPrompt.trim() === "") {
95
+ throw new AgentDispatchError(
96
+ `Agent step '${step.name}' resolved to an empty prompt after templating; refusing to invoke ${providerName} with no input.`,
97
+ "AGENT_EMPTY_PROMPT"
98
+ );
99
+ }
100
+
101
+ const templatedAppendSystemPrompt = agentSpec.append_system_prompt === undefined
102
+ ? undefined
103
+ : templateFn(agentSpec.append_system_prompt);
104
+ const result = await provider.run({
105
+ bin: binary,
106
+ prompt: templatedPrompt,
107
+ model: agentSpec.model,
108
+ sessionId,
109
+ timeoutMs,
110
+ cwd,
111
+ env,
112
+ interactive: agentSpec.interactive === true,
113
+ plugins: agentSpec.plugins ?? [],
114
+ allowTools: agentSpec.allow_tools ?? [],
115
+ appendSystemPrompt: templatedAppendSystemPrompt,
116
+ sourceAccess: agentSpec.source_access !== false,
117
+ spawnProcess,
118
+ cancellation
119
+ });
120
+
121
+ if (result.sessionId) {
122
+ if (isSafeSessionId(result.sessionId)) {
123
+ await saveSessionId(cwd, runId, step.name, providerName, result.sessionId);
124
+ } else if (typeof warn === "function") {
125
+ warn(
126
+ `Agent step '${step.name}' returned an unsafe session_id; not persisting (would risk CLI flag injection on resume).`
127
+ );
128
+ }
129
+ }
130
+
131
+ return result;
132
+ }
133
+
134
+ /**
135
+ * Validate that a session ID is safe to pass back to a CLI as a `--continue`
136
+ * or `--resume` argument value on the next run.
137
+ *
138
+ * @param {string} sessionId - Session identifier captured from agent output.
139
+ * @returns {boolean} True when the ID is alphanumeric/dash/underscore/dot only.
140
+ */
141
+ export function isSafeSessionId(sessionId) {
142
+ return typeof sessionId === "string"
143
+ && sessionId.length > 0
144
+ && sessionId.length <= 256
145
+ && /^[A-Za-z0-9_.][A-Za-z0-9_.-]*$/.test(sessionId);
146
+ }
147
+
148
+ /**
149
+ * Resolve the absolute path (or bare command name) used to launch an agent.
150
+ *
151
+ * Resolution order:
152
+ * 1. `config.agent.<provider>.bin` override
153
+ * 2. `$PATH` lookup for the provider's default binary
154
+ *
155
+ * @param {string} providerName - Provider name (e.g. `opencode`).
156
+ * @param {object} config - Flow config.
157
+ * @param {object} env - Process environment used for PATH lookup.
158
+ * @returns {Promise<string>} Resolved binary path.
159
+ */
160
+ export async function resolveAgentBinary(providerName, config, env) {
161
+ const provider = PROVIDERS[providerName];
162
+
163
+ if (!provider) {
164
+ throw new AgentDispatchError(
165
+ `Unknown agent provider '${providerName}'.`,
166
+ "AGENT_UNKNOWN_PROVIDER"
167
+ );
168
+ }
169
+
170
+ const configKey = providerName === "claude-code" ? "claude" : providerName;
171
+ const override = config?.agent?.[configKey]?.bin;
172
+
173
+ if (typeof override === "string" && override.trim() !== "") {
174
+ return override;
175
+ }
176
+
177
+ const found = await findOnPath(provider.defaultBin, env);
178
+
179
+ if (found === null) {
180
+ throw new AgentDispatchError(
181
+ `Could not find '${provider.defaultBin}' on PATH. Set '${provider.configKey}' in flow config or install the CLI.`,
182
+ "AGENT_BINARY_NOT_FOUND"
183
+ );
184
+ }
185
+
186
+ return found;
187
+ }
188
+
189
+ /**
190
+ * Locate a binary on `$PATH`. Returns the absolute path or `null` when absent.
191
+ *
192
+ * @param {string} binary - Bare binary name.
193
+ * @param {object} env - Environment providing `PATH`.
194
+ * @returns {Promise<string | null>} Resolved path or null.
195
+ */
196
+ async function findOnPath(binary, env) {
197
+ const pathValue = env?.PATH ?? "";
198
+ const separator = process.platform === "win32" ? ";" : ":";
199
+ const segments = pathValue.split(separator).filter((segment) => segment !== "");
200
+
201
+ for (const segment of segments) {
202
+ const candidate = path.join(segment, binary);
203
+
204
+ try {
205
+ await access(candidate, constants.X_OK);
206
+ return candidate;
207
+ } catch {
208
+ continue;
209
+ }
210
+ }
211
+
212
+ return null;
213
+ }
214
+
215
+ /**
216
+ * Error thrown when an agent step cannot be dispatched.
217
+ */
218
+ export class AgentDispatchError extends Error {
219
+ /**
220
+ * @param {string} message - Human-readable error message.
221
+ * @param {string} code - Stable machine-readable error code.
222
+ */
223
+ constructor(message, code) {
224
+ super(message);
225
+ this.name = "AgentDispatchError";
226
+ this.code = code;
227
+ }
228
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Line-buffered NDJSON parser that tolerates malformed lines and chunk splits.
3
+ *
4
+ * Usage:
5
+ * const buffer = createNdjsonBuffer();
6
+ * for (const chunk of stdoutChunks) {
7
+ * const events = buffer.push(chunk);
8
+ * for (const event of events) { ... }
9
+ * }
10
+ * const trailingEvents = buffer.flush();
11
+ *
12
+ * Malformed lines are surfaced as `{ __malformed: "<raw line>" }` so callers
13
+ * can log or ignore without losing the stream.
14
+ *
15
+ * @returns {{push: (chunk: string) => object[], flush: () => object[]}} Buffer API.
16
+ */
17
+ export function createNdjsonBuffer() {
18
+ let pending = "";
19
+
20
+ return {
21
+ push(chunk) {
22
+ if (chunk === undefined || chunk === null) {
23
+ return [];
24
+ }
25
+
26
+ pending += String(chunk);
27
+ const lines = pending.split("\n");
28
+ pending = lines.pop() ?? "";
29
+
30
+ return parseLines(lines);
31
+ },
32
+ flush() {
33
+ if (pending.trim() === "") {
34
+ pending = "";
35
+ return [];
36
+ }
37
+
38
+ const events = parseLines([pending]);
39
+ pending = "";
40
+ return events;
41
+ }
42
+ };
43
+ }
44
+
45
+ /**
46
+ * @param {string[]} lines - Raw NDJSON lines.
47
+ * @returns {object[]} Parsed objects; malformed lines surfaced as `{__malformed}`.
48
+ */
49
+ function parseLines(lines) {
50
+ const events = [];
51
+
52
+ for (const line of lines) {
53
+ const trimmed = line.trim();
54
+
55
+ if (trimmed === "") {
56
+ continue;
57
+ }
58
+
59
+ try {
60
+ events.push(JSON.parse(trimmed));
61
+ } catch {
62
+ events.push({ __malformed: trimmed });
63
+ }
64
+ }
65
+
66
+ return events;
67
+ }
@@ -0,0 +1,290 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createNdjsonBuffer } from "./ndjson.js";
3
+
4
+ /**
5
+ * Run the `opencode` CLI as a headless or interactive agent step.
6
+ *
7
+ * In headless mode, output is captured and NDJSON events are parsed to
8
+ * extract `cost_usd` and `session_id` from the final `result` event. The
9
+ * assistant's final text (if present) becomes the step stdout.
10
+ *
11
+ * In interactive mode, stdio is inherited so the user can drive the
12
+ * agent directly from the terminal. No NDJSON parsing occurs.
13
+ *
14
+ * @param {object} options - Invocation options.
15
+ * @param {string} options.bin - Resolved binary path for the `opencode` CLI.
16
+ * @param {string} options.prompt - Final prompt text (already templated and resolved).
17
+ * @param {string} [options.model] - Optional model identifier.
18
+ * @param {string | null} [options.sessionId] - Prior session ID to continue, or null for fresh.
19
+ * @param {number} options.timeoutMs - Step timeout in milliseconds.
20
+ * @param {string} options.cwd - Working directory.
21
+ * @param {object} options.env - Process environment.
22
+ * @param {boolean} [options.interactive=false] - Whether to run with TTY passthrough.
23
+ * @param {string[]} [options.plugins] - Optional plugin names passed with `--plugin`.
24
+ * @param {typeof spawn} [options.spawnProcess=spawn] - Child process factory (for tests).
25
+ * @param {{isCancelled: () => boolean, getReason: () => string | null, trackChild: (child: import("node:child_process").ChildProcess) => () => void}} [options.cancellation] - Cancellation controller.
26
+ * @returns {Promise<{exitCode: number, stdout: string, stderr: string, combinedOutput: string, costUsd: number | null, sessionId: string | null}>} Agent run result.
27
+ */
28
+ export function runOpencode(options) {
29
+ const {
30
+ bin,
31
+ prompt,
32
+ model,
33
+ sessionId = null,
34
+ timeoutMs,
35
+ cwd,
36
+ env,
37
+ interactive = false,
38
+ plugins = [],
39
+ spawnProcess = spawn,
40
+ cancellation = null
41
+ } = options;
42
+
43
+ const args = ["run", "--format", "json"];
44
+
45
+ if (model) {
46
+ args.push("--model", model);
47
+ }
48
+
49
+ if (sessionId) {
50
+ args.push("--continue", sessionId);
51
+ }
52
+
53
+ for (const plugin of plugins) {
54
+ args.push("--plugin", plugin);
55
+ }
56
+
57
+ // `--` separator ensures a prompt starting with `-` is not parsed as a flag.
58
+ args.push("--", prompt);
59
+
60
+ if (interactive) {
61
+ return spawnInteractive({ bin, args, cwd, env, timeoutMs, cancellation, spawnProcess });
62
+ }
63
+
64
+ return spawnHeadless({ bin, args, cwd, env, timeoutMs, cancellation, spawnProcess });
65
+ }
66
+
67
+ /**
68
+ * @param {object} options - Spawn options.
69
+ * @returns {Promise<{exitCode: number, stdout: string, stderr: string, combinedOutput: string, costUsd: number | null, sessionId: string | null}>} Result.
70
+ */
71
+ function spawnHeadless(options) {
72
+ const { bin, args, cwd, env, timeoutMs, cancellation, spawnProcess } = options;
73
+
74
+ return new Promise((resolve, reject) => {
75
+ const child = spawnProcess(bin, args, {
76
+ cwd,
77
+ env,
78
+ stdio: ["ignore", "pipe", "pipe"]
79
+ });
80
+ const untrackChild = cancellation?.trackChild(child) ?? (() => {});
81
+ const ndjson = createNdjsonBuffer();
82
+ let stdoutCapture = "";
83
+ let stderrCapture = "";
84
+ let combinedOutput = "";
85
+ let costUsd = null;
86
+ let sessionId = null;
87
+ let finalText = "";
88
+ let settled = false;
89
+ let timedOut = false;
90
+
91
+ const timer = setTimeout(() => {
92
+ timedOut = true;
93
+ child.kill("SIGTERM");
94
+ }, timeoutMs);
95
+
96
+ child.stdout.on("data", (chunk) => {
97
+ const text = chunk.toString();
98
+ stdoutCapture += text;
99
+ combinedOutput += text;
100
+
101
+ for (const event of ndjson.push(text)) {
102
+ const captured = captureOpencodeEvent(event);
103
+
104
+ if (captured.costUsd !== undefined) {
105
+ costUsd = captured.costUsd;
106
+ }
107
+
108
+ if (captured.sessionId !== undefined) {
109
+ sessionId = captured.sessionId;
110
+ }
111
+
112
+ if (captured.text !== undefined) {
113
+ finalText = captured.text;
114
+ }
115
+ }
116
+ });
117
+
118
+ child.stderr.on("data", (chunk) => {
119
+ const text = chunk.toString();
120
+ stderrCapture += text;
121
+ combinedOutput += text;
122
+ });
123
+
124
+ child.on("error", (error) => {
125
+ if (settled) return;
126
+
127
+ settled = true;
128
+ clearTimeout(timer);
129
+ untrackChild();
130
+ reject(new Error(`Failed to start opencode agent: ${error.message}`));
131
+ });
132
+
133
+ child.on("close", (code, signal) => {
134
+ if (settled) return;
135
+
136
+ settled = true;
137
+ clearTimeout(timer);
138
+ untrackChild();
139
+
140
+ for (const event of ndjson.flush()) {
141
+ const captured = captureOpencodeEvent(event);
142
+
143
+ if (captured.costUsd !== undefined) {
144
+ costUsd = captured.costUsd;
145
+ }
146
+
147
+ if (captured.sessionId !== undefined) {
148
+ sessionId = captured.sessionId;
149
+ }
150
+
151
+ if (captured.text !== undefined) {
152
+ finalText = captured.text;
153
+ }
154
+ }
155
+
156
+ if (timedOut) {
157
+ reject(Object.assign(new Error("opencode agent timed out"), { code: "STEP_TIMEOUT" }));
158
+ return;
159
+ }
160
+
161
+ if (signal) {
162
+ if (cancellation?.isCancelled()) {
163
+ const reason = cancellation.getReason();
164
+
165
+ reject(Object.assign(new Error(`opencode agent cancelled (${reason})`), {
166
+ code: reason === "STEP_FAILURE" ? "STEP_CANCELLED" : "WORKFLOW_CANCELLED"
167
+ }));
168
+ return;
169
+ }
170
+
171
+ reject(new Error(`opencode agent exited due to signal ${signal}`));
172
+ return;
173
+ }
174
+
175
+ resolve({
176
+ exitCode: code ?? 1,
177
+ stdout: finalText !== "" ? finalText : stdoutCapture,
178
+ stderr: stderrCapture,
179
+ combinedOutput,
180
+ costUsd,
181
+ sessionId
182
+ });
183
+ });
184
+ });
185
+ }
186
+
187
+ /**
188
+ * @param {object} options - Spawn options for interactive mode.
189
+ * @returns {Promise<{exitCode: number, stdout: string, stderr: string, combinedOutput: string, costUsd: null, sessionId: null}>} Result.
190
+ */
191
+ function spawnInteractive(options) {
192
+ const { bin, args, cwd, env, timeoutMs, cancellation, spawnProcess } = options;
193
+ // Strip headless-only flags (`--format json`) and the `--` separator that
194
+ // only matters when the prompt is a positional arg in headless mode.
195
+ const interactiveArgs = args.filter((arg, index, array) => {
196
+ if (arg === "--format" || arg === "--") {
197
+ return false;
198
+ }
199
+
200
+ if (array[index - 1] === "--format") {
201
+ return false;
202
+ }
203
+
204
+ return true;
205
+ });
206
+
207
+ return new Promise((resolve, reject) => {
208
+ const child = spawnProcess(bin, interactiveArgs, {
209
+ cwd,
210
+ env,
211
+ stdio: "inherit"
212
+ });
213
+ const untrackChild = cancellation?.trackChild(child) ?? (() => {});
214
+ let settled = false;
215
+ let timedOut = false;
216
+
217
+ const timer = setTimeout(() => {
218
+ timedOut = true;
219
+ child.kill("SIGTERM");
220
+ }, timeoutMs);
221
+
222
+ child.on("error", (error) => {
223
+ if (settled) return;
224
+
225
+ settled = true;
226
+ clearTimeout(timer);
227
+ untrackChild();
228
+ reject(new Error(`Failed to start opencode agent: ${error.message}`));
229
+ });
230
+
231
+ child.on("close", (code, signal) => {
232
+ if (settled) return;
233
+
234
+ settled = true;
235
+ clearTimeout(timer);
236
+ untrackChild();
237
+
238
+ if (timedOut) {
239
+ reject(Object.assign(new Error("opencode agent timed out"), { code: "STEP_TIMEOUT" }));
240
+ return;
241
+ }
242
+
243
+ if (signal) {
244
+ reject(new Error(`opencode agent exited due to signal ${signal}`));
245
+ return;
246
+ }
247
+
248
+ resolve({
249
+ exitCode: code ?? 1,
250
+ stdout: "",
251
+ stderr: "",
252
+ combinedOutput: "",
253
+ costUsd: null,
254
+ sessionId: null
255
+ });
256
+ });
257
+ });
258
+ }
259
+
260
+ /**
261
+ * @param {object} event - Parsed NDJSON event from opencode.
262
+ * @returns {{costUsd?: number, sessionId?: string, text?: string}} Captured fields.
263
+ */
264
+ function captureOpencodeEvent(event) {
265
+ const captured = {};
266
+
267
+ if (event == null || typeof event !== "object") {
268
+ return captured;
269
+ }
270
+
271
+ if (event.type === "result") {
272
+ if (typeof event.cost_usd === "number") {
273
+ captured.costUsd = event.cost_usd;
274
+ } else if (typeof event.total_cost_usd === "number") {
275
+ captured.costUsd = event.total_cost_usd;
276
+ }
277
+
278
+ if (typeof event.session_id === "string" && event.session_id !== "") {
279
+ captured.sessionId = event.session_id;
280
+ }
281
+
282
+ if (typeof event.text === "string") {
283
+ captured.text = event.text;
284
+ } else if (typeof event.result === "string") {
285
+ captured.text = event.result;
286
+ }
287
+ }
288
+
289
+ return captured;
290
+ }
@@ -0,0 +1,91 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ const FILE_EXTENSION_WHITELIST = new Set([".md", ".txt", ".prompt"]);
5
+
6
+ /**
7
+ * Resolve a prompt input value to its raw text.
8
+ *
9
+ * A value is treated as a file path when:
10
+ * 1. It starts with `./`, `../`, or `/`, OR its extension is in the whitelist
11
+ * (`.md`, `.txt`, `.prompt`)
12
+ * 2. AND the resolved path exists on disk as a regular file
13
+ *
14
+ * Otherwise the value is treated as an inline string and returned as-is.
15
+ *
16
+ * @param {string} value - Inline prompt text or a path to a prompt file.
17
+ * @param {string} cwd - Working directory used to resolve relative paths.
18
+ * @returns {Promise<string>} Raw prompt text (caller applies flow templating).
19
+ */
20
+ export async function resolvePromptInput(value, cwd) {
21
+ if (typeof value !== "string") {
22
+ throw new TypeError("resolvePromptInput requires a string value");
23
+ }
24
+
25
+ if (!looksLikeFilePath(value)) {
26
+ return value;
27
+ }
28
+
29
+ const absolute = path.resolve(cwd, value);
30
+
31
+ let stats;
32
+
33
+ try {
34
+ stats = await stat(absolute);
35
+ } catch (error) {
36
+ if (error.code === "ENOENT") {
37
+ if (isUnambiguousFilePath(value)) {
38
+ throw new Error(`Prompt file not found: ${value}`);
39
+ }
40
+
41
+ return value;
42
+ }
43
+
44
+ throw error;
45
+ }
46
+
47
+ if (!stats.isFile()) {
48
+ if (isUnambiguousFilePath(value)) {
49
+ throw new Error(`Prompt path is not a regular file: ${value}`);
50
+ }
51
+
52
+ return value;
53
+ }
54
+
55
+ return readFile(absolute, "utf8");
56
+ }
57
+
58
+ /**
59
+ * @param {string} value - Candidate prompt value.
60
+ * @returns {boolean} True when the value has file-path markers.
61
+ *
62
+ * Rules:
63
+ * - Unambiguous prefix (`./`, `../`, `/`) → always treated as a path
64
+ * - Single-token value (no whitespace, no newlines) with whitelisted extension
65
+ * → treated as a path
66
+ * - Anything else → inline text
67
+ *
68
+ * The single-token requirement prevents inline prompts like "see notes.txt for
69
+ * details" from being mis-detected as file paths.
70
+ */
71
+ function looksLikeFilePath(value) {
72
+ if (isUnambiguousFilePath(value)) {
73
+ return true;
74
+ }
75
+
76
+ if (/\s/.test(value)) {
77
+ return false;
78
+ }
79
+
80
+ const extension = path.extname(value).toLowerCase();
81
+
82
+ return FILE_EXTENSION_WHITELIST.has(extension);
83
+ }
84
+
85
+ /**
86
+ * @param {string} value - Candidate prompt value.
87
+ * @returns {boolean} True when the value is clearly intended as a path.
88
+ */
89
+ function isUnambiguousFilePath(value) {
90
+ return value.startsWith("./") || value.startsWith("../") || value.startsWith("/");
91
+ }