@hraness/dawg 0.0.0-stage → 0.2.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.
- package/CHANGELOG.md +47 -0
- package/DAWG.md +142 -0
- package/LICENSE +21 -0
- package/README.md +164 -2
- package/core/drums.ts +102 -0
- package/core/loop.ts +78 -0
- package/core/score.ts +1065 -0
- package/package.json +42 -4
- package/src/agent/agent.ts +587 -0
- package/src/agent/brief.ts +140 -0
- package/src/agent/gateway.ts +267 -0
- package/src/agent/ops.ts +172 -0
- package/src/agent/planner.ts +221 -0
- package/src/agent/provider.ts +326 -0
- package/src/agent/sse.ts +90 -0
- package/src/agent/tools.ts +976 -0
- package/src/agent/xcb-agent.ts +247 -0
- package/src/agent/xcb.ts +367 -0
- package/src/audio/clock.ts +68 -0
- package/src/audio/engine.ts +467 -0
- package/src/audio/lock.ts +57 -0
- package/src/audio/player.ts +119 -0
- package/src/audio/wav.ts +647 -0
- package/src/auth/cli.ts +112 -0
- package/src/auth/credentials.ts +285 -0
- package/src/auth/login.ts +434 -0
- package/src/auth/runner.ts +184 -0
- package/src/auth/tui.ts +90 -0
- package/src/commands/history.ts +56 -0
- package/src/commands/music.ts +444 -0
- package/src/daemon.ts +31 -0
- package/src/main.ts +1332 -0
- package/src/render.ts +99 -0
- package/src/session/attach.ts +182 -0
- package/src/session/client.ts +498 -0
- package/src/session/daemon.ts +723 -0
- package/src/session/list.ts +180 -0
- package/src/session/lock.ts +92 -0
- package/src/session/meta.ts +253 -0
- package/src/session/naming.ts +464 -0
- package/src/session/port.ts +461 -0
- package/src/session/presence.ts +159 -0
- package/src/session/protocol.ts +618 -0
- package/src/session/rebase.ts +138 -0
- package/src/session/store.ts +486 -0
- package/tui/activity.ts +325 -0
- package/tui/app.ts +1129 -0
- package/tui/drums.ts +21 -0
- package/tui/highway.ts +905 -0
- package/tui/input.ts +63 -0
- package/tui/keys.ts +102 -0
- package/tui/layers.ts +68 -0
- package/tui/prompt.ts +609 -0
- package/tui/render.ts +124 -0
- package/tui/screen.ts +247 -0
- package/tui/text.ts +72 -0
- package/tui/theme.ts +451 -0
package/src/auth/cli.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin, stdout } from "node:process";
|
|
4
|
+
import { defaultAuthEnv } from "./credentials.ts";
|
|
5
|
+
import {
|
|
6
|
+
authStatus,
|
|
7
|
+
login,
|
|
8
|
+
logout,
|
|
9
|
+
type LoginIO,
|
|
10
|
+
type LoginMode,
|
|
11
|
+
} from "./login.ts";
|
|
12
|
+
import { systemRunner } from "./runner.ts";
|
|
13
|
+
import { audioStatusLine } from "../audio/engine.ts";
|
|
14
|
+
|
|
15
|
+
export const AUTH_USAGE = `Usage:
|
|
16
|
+
dawg login create an AI Gateway key with the Vercel CLI (default)
|
|
17
|
+
dawg login --key paste an existing AI Gateway key (hidden input)
|
|
18
|
+
dawg login --xcb use a Claude/Codex/Devin subscription through xcb
|
|
19
|
+
dawg login --budget <dollars> spend limit for the created key
|
|
20
|
+
dawg logout remove the stored key and provider choice
|
|
21
|
+
dawg auth status [--check] show provider, key, xcb account, audio backend`;
|
|
22
|
+
|
|
23
|
+
/** Entry for `dawg login|logout|auth`. Returns the process exit code. */
|
|
24
|
+
export async function runAuthCommand(argv: readonly string[]): Promise<number> {
|
|
25
|
+
const [command, ...rest] = argv;
|
|
26
|
+
const io = terminalIO();
|
|
27
|
+
const deps = { auth: defaultAuthEnv(systemRunner), io, hostname: hostname() };
|
|
28
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
29
|
+
io.print(AUTH_USAGE);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
if (command === "logout") return logout(deps);
|
|
33
|
+
if (command === "auth") {
|
|
34
|
+
if (
|
|
35
|
+
rest[0] !== undefined &&
|
|
36
|
+
rest[0] !== "status" &&
|
|
37
|
+
rest[0] !== "--check"
|
|
38
|
+
) {
|
|
39
|
+
io.print(AUTH_USAGE);
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
for (const line of await authStatus(deps, {
|
|
43
|
+
verify: rest.includes("--check"),
|
|
44
|
+
}))
|
|
45
|
+
io.print(line);
|
|
46
|
+
io.print(audioStatusLine());
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const flags = new Set(rest.filter((arg) => arg.startsWith("--")));
|
|
50
|
+
const mode: LoginMode = flags.has("--xcb")
|
|
51
|
+
? "xcb"
|
|
52
|
+
: flags.has("--key")
|
|
53
|
+
? "key"
|
|
54
|
+
: "gateway";
|
|
55
|
+
let budget: number | undefined;
|
|
56
|
+
const budgetIndex = rest.indexOf("--budget");
|
|
57
|
+
if (budgetIndex >= 0) {
|
|
58
|
+
budget = Number(rest[budgetIndex + 1]);
|
|
59
|
+
if (!Number.isFinite(budget) || budget < 1 || budget > 1_000_000) {
|
|
60
|
+
io.print("--budget takes a dollar amount of at least 1");
|
|
61
|
+
return 2;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return login(mode, deps, budget === undefined ? {} : { budget });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function terminalIO(): LoginIO {
|
|
68
|
+
return {
|
|
69
|
+
interactive: Boolean(stdin.isTTY && stdout.isTTY),
|
|
70
|
+
print: (line) => void stdout.write(`${line}\n`),
|
|
71
|
+
async ask(prompt) {
|
|
72
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
73
|
+
try {
|
|
74
|
+
return (await rl.question(prompt)).slice(0, 200);
|
|
75
|
+
} finally {
|
|
76
|
+
rl.close();
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
readSecret: (prompt) => readHidden(prompt),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Read one line in raw mode without echo. Ctrl-C rejects. */
|
|
84
|
+
function readHidden(prompt: string): Promise<string> {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
stdout.write(prompt);
|
|
87
|
+
let value = "";
|
|
88
|
+
const wasRaw = stdin.isRaw;
|
|
89
|
+
stdin.setRawMode?.(true);
|
|
90
|
+
stdin.resume();
|
|
91
|
+
const done = (error?: Error) => {
|
|
92
|
+
stdin.off("data", onData);
|
|
93
|
+
stdin.setRawMode?.(wasRaw);
|
|
94
|
+
stdin.pause();
|
|
95
|
+
stdout.write("\n");
|
|
96
|
+
if (error) reject(error);
|
|
97
|
+
else resolve(value);
|
|
98
|
+
};
|
|
99
|
+
const onData = (chunk: Buffer) => {
|
|
100
|
+
let text = chunk.toString("utf8");
|
|
101
|
+
// Bracketed paste markers, if the terminal sends them.
|
|
102
|
+
text = text.replace(/\u001b\[20[01]~/g, "");
|
|
103
|
+
for (const char of text) {
|
|
104
|
+
if (char === "\u0003") return done(new Error("cancelled"));
|
|
105
|
+
if (char === "\r" || char === "\n") return done();
|
|
106
|
+
if (char === "\u007f" || char === "\b") value = value.slice(0, -1);
|
|
107
|
+
else if (char >= " " && value.length < 512) value += char;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
stdin.on("data", onData);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
mkdir,
|
|
5
|
+
open,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
stat,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import type { CommandRunner } from "./runner.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Local credentials and provider choice. Secrets live in the macOS Keychain
|
|
17
|
+
* or `~/.config/dawg/credentials.json` (0600 under a 0700 directory), never in
|
|
18
|
+
* the project's `.dawg/` directory. `AI_GATEWAY_API_KEY` always wins.
|
|
19
|
+
*/
|
|
20
|
+
export const KEYCHAIN_SERVICE = "dawg";
|
|
21
|
+
export const CONFIG_DIR_NAME = "dawg";
|
|
22
|
+
export const KEYCHAIN_ACCOUNT = "ai-gateway";
|
|
23
|
+
const MAX_CONFIG_BYTES = 16 * 1024;
|
|
24
|
+
/** Gateway keys are opaque tokens; restricting the alphabet keeps them quote-safe. */
|
|
25
|
+
const KEY_PATTERN = /^[A-Za-z0-9._-]{16,256}$/;
|
|
26
|
+
const XCB_ACCOUNT_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
27
|
+
const XCB_MODEL_PATTERN = /^[A-Za-z0-9._:/-]{1,200}$/;
|
|
28
|
+
|
|
29
|
+
export const PROVIDER_CHOICES = Object.freeze([
|
|
30
|
+
"gateway",
|
|
31
|
+
"xcb",
|
|
32
|
+
"auto",
|
|
33
|
+
] as const);
|
|
34
|
+
export type ProviderChoice = (typeof PROVIDER_CHOICES)[number];
|
|
35
|
+
|
|
36
|
+
export type DawgConfig = Readonly<{
|
|
37
|
+
provider?: ProviderChoice;
|
|
38
|
+
xcb?: Readonly<{ account: string; model: string }>;
|
|
39
|
+
}>;
|
|
40
|
+
|
|
41
|
+
export type CredentialSource = "env" | "keychain" | "file";
|
|
42
|
+
export type ResolvedKey = Readonly<{ key: string; source: CredentialSource }>;
|
|
43
|
+
|
|
44
|
+
export type AuthEnv = Readonly<{
|
|
45
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
46
|
+
runner: CommandRunner;
|
|
47
|
+
platform: string;
|
|
48
|
+
/** Absolute config directory; see `configDir`. */
|
|
49
|
+
dir: string;
|
|
50
|
+
}>;
|
|
51
|
+
|
|
52
|
+
export function configDir(
|
|
53
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
54
|
+
): string {
|
|
55
|
+
const explicit = env.DAWG_CONFIG_DIR;
|
|
56
|
+
if (explicit) return explicit;
|
|
57
|
+
const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), ".config");
|
|
58
|
+
return join(base, CONFIG_DIR_NAME);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function defaultAuthEnv(runner: CommandRunner): AuthEnv {
|
|
62
|
+
return {
|
|
63
|
+
env: process.env,
|
|
64
|
+
runner,
|
|
65
|
+
platform: process.platform,
|
|
66
|
+
dir: configDir(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isValidKey(value: unknown): value is string {
|
|
71
|
+
return typeof value === "string" && KEY_PATTERN.test(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `vck_…abcd`: never more than a short prefix and the last four characters. */
|
|
75
|
+
export function maskKey(key: string): string {
|
|
76
|
+
if (key.length < 12) return "…";
|
|
77
|
+
const underscore = key.indexOf("_");
|
|
78
|
+
const prefix =
|
|
79
|
+
underscore > 0 && underscore <= 4 ? key.slice(0, underscore + 1) : "";
|
|
80
|
+
return `${prefix}…${key.slice(-4)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function useKeychain(auth: AuthEnv): boolean {
|
|
84
|
+
return (
|
|
85
|
+
auth.platform === "darwin" &&
|
|
86
|
+
auth.env.DAWG_CREDENTIAL_STORE !== "file" &&
|
|
87
|
+
auth.runner.which("security") !== undefined
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Env, then Keychain, then the credentials file. */
|
|
92
|
+
export async function resolveGatewayKey(
|
|
93
|
+
auth: AuthEnv,
|
|
94
|
+
): Promise<ResolvedKey | undefined> {
|
|
95
|
+
const fromEnv = auth.env.AI_GATEWAY_API_KEY?.trim();
|
|
96
|
+
if (fromEnv) return { key: fromEnv, source: "env" };
|
|
97
|
+
if (useKeychain(auth)) {
|
|
98
|
+
const key = (await readKeychain(auth, KEYCHAIN_SERVICE)) ?? "";
|
|
99
|
+
if (isValidKey(key)) return { key, source: "keychain" };
|
|
100
|
+
}
|
|
101
|
+
const file = await readJsonObject(join(auth.dir, "credentials.json"));
|
|
102
|
+
const key = file?.aiGateway;
|
|
103
|
+
if (isValidKey(key)) return { key, source: "file" };
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Store the key. Returns where it went. Throws on an invalid key. */
|
|
108
|
+
export async function storeGatewayKey(
|
|
109
|
+
auth: AuthEnv,
|
|
110
|
+
key: string,
|
|
111
|
+
): Promise<"keychain" | "file"> {
|
|
112
|
+
if (!isValidKey(key))
|
|
113
|
+
throw new Error("that does not look like an AI Gateway key");
|
|
114
|
+
if (useKeychain(auth)) {
|
|
115
|
+
// `security -i` reads the command from stdin, so the secret never appears
|
|
116
|
+
// in any process's argv. KEY_PATTERN guarantees it needs no escaping.
|
|
117
|
+
const result = await auth.runner
|
|
118
|
+
.run("security", ["-i"], {
|
|
119
|
+
stdin: `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${KEYCHAIN_ACCOUNT} -l "dawg AI Gateway key" -w "${key}"\n`,
|
|
120
|
+
timeoutMs: 15_000,
|
|
121
|
+
maxOutputBytes: 4096,
|
|
122
|
+
})
|
|
123
|
+
.catch(() => undefined);
|
|
124
|
+
if (result?.code === 0) {
|
|
125
|
+
const check = await resolveFromKeychainOnly(auth);
|
|
126
|
+
if (check === key) {
|
|
127
|
+
await removeFileKey(auth);
|
|
128
|
+
return "keychain";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const path = join(auth.dir, "credentials.json");
|
|
133
|
+
const current = (await readJsonObject(path)) ?? {};
|
|
134
|
+
await writePrivateJson(auth.dir, path, {
|
|
135
|
+
...current,
|
|
136
|
+
version: 1,
|
|
137
|
+
aiGateway: key,
|
|
138
|
+
});
|
|
139
|
+
return "file";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function resolveFromKeychainOnly(
|
|
143
|
+
auth: AuthEnv,
|
|
144
|
+
): Promise<string | undefined> {
|
|
145
|
+
return readKeychain(auth, KEYCHAIN_SERVICE);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function readKeychain(
|
|
149
|
+
auth: AuthEnv,
|
|
150
|
+
service: string,
|
|
151
|
+
): Promise<string | undefined> {
|
|
152
|
+
const result = await auth.runner
|
|
153
|
+
.run(
|
|
154
|
+
"security",
|
|
155
|
+
["find-generic-password", "-s", service, "-a", KEYCHAIN_ACCOUNT, "-w"],
|
|
156
|
+
{ timeoutMs: 10_000, maxOutputBytes: 4096 },
|
|
157
|
+
)
|
|
158
|
+
.catch(() => undefined);
|
|
159
|
+
return result?.code === 0 ? result.stdout.trim() : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Remove stored keys from both backends. Env keys are left to the user. */
|
|
163
|
+
export async function clearGatewayKey(auth: AuthEnv): Promise<string[]> {
|
|
164
|
+
const removed: string[] = [];
|
|
165
|
+
if (useKeychain(auth)) {
|
|
166
|
+
const result = await auth.runner
|
|
167
|
+
.run(
|
|
168
|
+
"security",
|
|
169
|
+
[
|
|
170
|
+
"delete-generic-password",
|
|
171
|
+
"-s",
|
|
172
|
+
KEYCHAIN_SERVICE,
|
|
173
|
+
"-a",
|
|
174
|
+
KEYCHAIN_ACCOUNT,
|
|
175
|
+
],
|
|
176
|
+
{ timeoutMs: 10_000, maxOutputBytes: 4096 },
|
|
177
|
+
)
|
|
178
|
+
.catch(() => undefined);
|
|
179
|
+
if (result?.code === 0) removed.push("keychain");
|
|
180
|
+
}
|
|
181
|
+
if (await removeFileKey(auth)) removed.push("file");
|
|
182
|
+
return removed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function removeFileKey(auth: AuthEnv): Promise<boolean> {
|
|
186
|
+
const path = join(auth.dir, "credentials.json");
|
|
187
|
+
const current = await readJsonObject(path);
|
|
188
|
+
if (!current || !("aiGateway" in current)) return false;
|
|
189
|
+
const { aiGateway: _removed, ...rest } = current;
|
|
190
|
+
if (Object.keys(rest).filter((key) => key !== "version").length === 0)
|
|
191
|
+
await rm(path, { force: true });
|
|
192
|
+
else await writePrivateJson(auth.dir, path, rest);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function readConfig(
|
|
197
|
+
auth: Pick<AuthEnv, "dir">,
|
|
198
|
+
): Promise<DawgConfig> {
|
|
199
|
+
return parseConfig(await readJsonObject(join(auth.dir, "config.json")));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function parseConfig(value: unknown): DawgConfig {
|
|
203
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
204
|
+
return {};
|
|
205
|
+
const record = value as Record<string, unknown>;
|
|
206
|
+
const config: {
|
|
207
|
+
provider?: ProviderChoice;
|
|
208
|
+
xcb?: { account: string; model: string };
|
|
209
|
+
} = {};
|
|
210
|
+
if (PROVIDER_CHOICES.includes(record.provider as ProviderChoice))
|
|
211
|
+
config.provider = record.provider as ProviderChoice;
|
|
212
|
+
const xcb = record.xcb;
|
|
213
|
+
if (typeof xcb === "object" && xcb !== null && !Array.isArray(xcb)) {
|
|
214
|
+
const { account, model } = xcb as Record<string, unknown>;
|
|
215
|
+
if (
|
|
216
|
+
typeof account === "string" &&
|
|
217
|
+
XCB_ACCOUNT_PATTERN.test(account) &&
|
|
218
|
+
typeof model === "string" &&
|
|
219
|
+
XCB_MODEL_PATTERN.test(model)
|
|
220
|
+
)
|
|
221
|
+
config.xcb = { account, model };
|
|
222
|
+
}
|
|
223
|
+
return config;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function writeConfig(
|
|
227
|
+
auth: Pick<AuthEnv, "dir">,
|
|
228
|
+
patch: Partial<{
|
|
229
|
+
provider: ProviderChoice | undefined;
|
|
230
|
+
xcb: DawgConfig["xcb"] | undefined;
|
|
231
|
+
}>,
|
|
232
|
+
): Promise<DawgConfig> {
|
|
233
|
+
const next: Record<string, unknown> = { ...(await readConfig(auth)) };
|
|
234
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
235
|
+
if (value === undefined) delete next[key];
|
|
236
|
+
else next[key] = value;
|
|
237
|
+
}
|
|
238
|
+
const parsed = parseConfig(next);
|
|
239
|
+
await writePrivateJson(auth.dir, join(auth.dir, "config.json"), {
|
|
240
|
+
version: 1,
|
|
241
|
+
...parsed,
|
|
242
|
+
});
|
|
243
|
+
return parsed;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function readJsonObject(
|
|
247
|
+
path: string,
|
|
248
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
249
|
+
try {
|
|
250
|
+
const info = await stat(path);
|
|
251
|
+
if (!info.isFile() || info.size > MAX_CONFIG_BYTES) return undefined;
|
|
252
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
253
|
+
return typeof parsed === "object" &&
|
|
254
|
+
parsed !== null &&
|
|
255
|
+
!Array.isArray(parsed)
|
|
256
|
+
? (parsed as Record<string, unknown>)
|
|
257
|
+
: undefined;
|
|
258
|
+
} catch {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Write via a 0600 temp file and rename, under a 0700 directory. */
|
|
264
|
+
export async function writePrivateJson(
|
|
265
|
+
dir: string,
|
|
266
|
+
path: string,
|
|
267
|
+
value: unknown,
|
|
268
|
+
): Promise<void> {
|
|
269
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
270
|
+
await chmod(dir, 0o700);
|
|
271
|
+
const temp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
272
|
+
const handle = await open(temp, "wx", 0o600);
|
|
273
|
+
try {
|
|
274
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
275
|
+
await handle.sync();
|
|
276
|
+
} finally {
|
|
277
|
+
await handle.close();
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
await rename(temp, path);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
await rm(temp, { force: true });
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|