tryharness 0.7.18 → 0.7.23

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 (48) hide show
  1. package/README.md +57 -14
  2. package/assets/opencode-plugin.js +47 -66
  3. package/assets/pi-extension.ts +91 -90
  4. package/cli.mjs +115 -9
  5. package/lib/adapters.mjs +19 -11
  6. package/lib/agentnode.mjs +154 -0
  7. package/lib/atlas.mjs +304 -0
  8. package/lib/bip39-english.txt +2048 -0
  9. package/lib/cards.mjs +16 -4
  10. package/lib/context.mjs +290 -0
  11. package/lib/coordinator.mjs +27 -9
  12. package/lib/daemon.mjs +790 -93
  13. package/lib/digest.mjs +40 -0
  14. package/lib/environment.mjs +98 -0
  15. package/lib/firewall.mjs +154 -0
  16. package/lib/folders.mjs +161 -0
  17. package/lib/frames.mjs +159 -0
  18. package/lib/inference.mjs +326 -0
  19. package/lib/learn.mjs +120 -0
  20. package/lib/live.mjs +30 -5
  21. package/lib/localmodels.mjs +230 -0
  22. package/lib/mcp-stdio.mjs +107 -7
  23. package/lib/media.mjs +118 -0
  24. package/lib/members.json +1 -1
  25. package/lib/models.mjs +233 -0
  26. package/lib/network.mjs +118 -0
  27. package/lib/nudge.mjs +13 -5
  28. package/lib/ocr.mjs +217 -0
  29. package/lib/personal.mjs +39 -0
  30. package/lib/phones.mjs +31 -2
  31. package/lib/places.mjs +61 -0
  32. package/lib/receipts.mjs +42 -0
  33. package/lib/redact.mjs +334 -0
  34. package/lib/runner.mjs +37 -6
  35. package/lib/runtime-install.mjs +6 -5
  36. package/lib/screens.mjs +96 -0
  37. package/lib/sealed.mjs +3 -0
  38. package/lib/service.mjs +4 -2
  39. package/lib/snippets.mjs +64 -0
  40. package/lib/speech.mjs +480 -0
  41. package/lib/store.mjs +64 -5
  42. package/lib/swarm.mjs +89 -26
  43. package/lib/t3.mjs +7 -4
  44. package/lib/trails.mjs +177 -0
  45. package/lib/watches.mjs +163 -0
  46. package/lib/worktrees.mjs +21 -5
  47. package/package.json +6 -4
  48. package/lib/embedder.mjs +0 -156
package/lib/atlas.mjs ADDED
@@ -0,0 +1,304 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { spawnSync } from "node:child_process";
5
+ import sqliteWasm from "node-sqlite3-wasm";
6
+ import { fromBlob, toBlob } from "./vectors.mjs";
7
+
8
+ // The relay's local index of the code its agents work in: every text file in the folders the swarm uses, cut into
9
+ // pieces at top-level declarations and headings, each piece embedded once by the search model and found again by words
10
+ // (SQLite's full-text index) and by meaning. Pieces are keyed by their repository path and text, not by commit, so a
11
+ // new worktree or a small edit costs only the pieces that changed. Files that look like keys or secrets are never read.
12
+
13
+ const { Database } = sqliteWasm;
14
+ export const ATLAS_VERSION = "embeddinggemma-2:q8:768:v1";
15
+ const MAX_FILE = 300_000;
16
+ const MIN_PIECE = 300;
17
+ const MAX_PIECE = 2_400;
18
+ const TEXT_EXT = /\.(?:[cm]?[jt]sx?|json|md|mdx|txt|ya?ml|toml|sql|sol|rs|go|py|rb|java|kt|swift|c|cc|cpp|h|hpp|cs|php|sh|zsh|css|scss|html|vue|svelte|astro|graphql|proto|ini|cfg|conf|env\.example|lua|ex|exs|dart|scala|zig)$/i;
19
+ const SKIP_PATH = /(?:^|\/)(?:node_modules|dist|build|out|\.next|coverage|vendor|target|\.git)\/|(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock|go\.sum)$|\.min\.[jc]ss?$|\.map$/;
20
+ const SECRET_PATH = /(?:^|\/)(?:\.env(?:\.[\w-]+)?|id_[rd]sa\w*|[\w.-]+\.(?:pem|key|p12|pfx|keystore|jks)|\.npmrc|\.pypirc|credentials(?:\.json)?)$/i;
21
+ const SECRET_TEXT = /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/;
22
+ const DECLARATION = /^(?:export\s|import\s|async\s|function\s|class\s|const\s|let\s|var\s|type\s|interface\s|enum\s|def\s|fn\s|pub\s|impl\s|struct\s|mod\s|func\s|package\s|contract\s|library\s|module\s|describe\(|it\(|test\(|#{1,6}\s|\[[\w.-]+\]$|create\s|alter\s)/i;
23
+ const STOP = new Set("the a an and or of to in on for with is are be it this that from by at as was were can should would could will do does did not no yes we you i me my our your they them their its into out up so if then else than but about how what why when where which who whom also just like make made use used using need needs want".split(" "));
24
+
25
+ /** Whether a repository path may be indexed: a text file, not generated, not a secret. */
26
+ export const indexable = (rel, size = 0) => TEXT_EXT.test(rel) && !SKIP_PATH.test(rel) && !SECRET_PATH.test(rel) && size <= MAX_FILE;
27
+
28
+ /** A file's pieces: { text, line } cut at top-level declarations and headings, small ones merged, big ones split at blank lines. */
29
+ export function pieces(text) {
30
+ const lines = text.split("\n");
31
+ const segments = [];
32
+ let start = 0;
33
+ for (let i = 1; i <= lines.length; i += 1) {
34
+ const line = lines[i];
35
+ const boundary = i === lines.length || (line && !/^\s/.test(line) && (lines[i - 1].trim() === "" || DECLARATION.test(line)) && !/^[)}\]]/.test(line));
36
+ if (boundary) { segments.push({ line: start + 1, text: lines.slice(start, i).join("\n") }); start = i; }
37
+ }
38
+ const merged = [];
39
+ for (const segment of segments) {
40
+ const last = merged.at(-1);
41
+ if (last && last.text.length < MIN_PIECE) { last.text += `\n${segment.text}`; continue; }
42
+ merged.push({ ...segment });
43
+ }
44
+ const out = [];
45
+ for (const segment of merged) {
46
+ if (!segment.text.trim()) continue;
47
+ if (segment.text.length <= MAX_PIECE) { out.push(segment); continue; }
48
+ let buffer = "";
49
+ let line = segment.line;
50
+ let at = segment.line;
51
+ for (const block of segment.text.split(/\n(?=\s*\n)/)) {
52
+ if (buffer && buffer.length + block.length > MAX_PIECE) { out.push({ line, text: buffer }); buffer = ""; line = at; }
53
+ for (let i = 0; i < block.length; i += MAX_PIECE) {
54
+ const part = block.slice(i, i + MAX_PIECE);
55
+ if (buffer && buffer.length + part.length > MAX_PIECE) { out.push({ line, text: buffer }); buffer = ""; line = at; }
56
+ buffer += (buffer ? "\n" : "") + part;
57
+ }
58
+ at += block.split("\n").length;
59
+ }
60
+ if (buffer.trim()) out.push({ line, text: buffer });
61
+ }
62
+ return out;
63
+ }
64
+
65
+ export const pieceHash = (rel, text) => createHash("sha1").update(`${rel}\0${text}`).digest("hex");
66
+
67
+ /** Words and identifiers worth searching for in a request, most specific first (identifiers, paths, then words). */
68
+ export function queryTerms(text, max = 24) {
69
+ const raw = String(text).match(/[\p{L}\p{N}_$./-]{3,}/gu) || [];
70
+ const terms = [];
71
+ for (const token of raw) {
72
+ const clean = token.replace(/^[./-]+|[./-]+$/g, "");
73
+ if (clean.length < 3 || STOP.has(clean.toLowerCase())) continue;
74
+ for (const part of clean.split(/[./-]/)) if (part.length >= 3 && !STOP.has(part.toLowerCase())) terms.push(part);
75
+ }
76
+ const specific = (term) => (/[A-Z_$]/.test(term.slice(1)) || /\d/.test(term) ? 0 : 1);
77
+ return [...new Set(terms)].sort((a, b) => specific(a) - specific(b)).slice(0, max);
78
+ }
79
+
80
+ export function openAtlas(file) {
81
+ const db = new Database(file);
82
+ // The atlas is a cache the relay can always rebuild from the repositories and session files, so writes skip the disk sync.
83
+ db.exec("pragma journal_mode = MEMORY; pragma synchronous = OFF; pragma busy_timeout = 5000;");
84
+ db.exec(`
85
+ create table if not exists pieces (hash text primary key, path text not null, line integer not null, text text not null);
86
+ create table if not exists piece_vectors (hash text not null, version text not null, vector blob not null, primary key(hash, version));
87
+ create table if not exists blobs (blob text primary key, path text not null, pieces text not null);
88
+ create table if not exists files (root text not null, path text not null, blob text not null, indexed_at text not null, primary key(root, path));
89
+ create virtual table if not exists pieces_fts using fts5(hash unindexed, path, text, tokenize='unicode61 tokenchars ''_$''');
90
+ create table if not exists runs (agent text not null, session_id text not null, root text not null, at integer not null, brief text not null,
91
+ edited text not null, read text not null, before text not null, source_mtime real not null, primary key(agent, session_id));
92
+ create index if not exists runs_root_at on runs(root, at);
93
+ create table if not exists run_vectors (agent text not null, session_id text not null, version text not null, vector blob not null, primary key(agent, session_id, version));
94
+ create table if not exists meta (key text primary key, value text not null);
95
+ create table if not exists media (hash text primary key, path text not null, kind text not null, start_ms integer not null, end_ms integer not null, mtime real not null);
96
+ create index if not exists media_path on media(path);
97
+ `);
98
+ // A blob's pieces never change, so lookups are kept (a few MB for a large repository and its recent versions).
99
+ const blobPieces = new Map();
100
+ const run = (sql, ...values) => db.run(sql, values.length ? values : undefined);
101
+ const all = (sql, ...values) => db.all(sql, values.length ? values : undefined);
102
+ const get = (sql, ...values) => db.get(sql, values.length ? values : undefined);
103
+ return {
104
+ close: () => db.close(),
105
+ blob(blob) {
106
+ if (blobPieces.has(blob)) return blobPieces.get(blob);
107
+ const row = get("select pieces from blobs where blob = ?", blob);
108
+ if (!row) return null;
109
+ if (blobPieces.size >= 200_000) blobPieces.clear();
110
+ blobPieces.set(blob, JSON.parse(row.pieces));
111
+ return blobPieces.get(blob);
112
+ },
113
+ /** Runs fn inside one transaction. */
114
+ batch(fn) {
115
+ db.exec("begin");
116
+ try { const out = fn(); db.exec("commit"); return out; } catch (error) { db.exec("rollback"); throw error; }
117
+ },
118
+ putBlob(blob, rel, parts) {
119
+ const hashes = [];
120
+ for (const part of parts) {
121
+ const hash = pieceHash(rel, part.text);
122
+ hashes.push(hash);
123
+ if (!get("select 1 as one from pieces where hash = ?", hash)) {
124
+ run("insert into pieces(hash, path, line, text) values(?, ?, ?, ?)", hash, rel, part.line, part.text);
125
+ run("insert into pieces_fts(hash, path, text) values(?, ?, ?)", hash, rel.replace(/[/._-]/g, " "), part.text);
126
+ }
127
+ }
128
+ run("insert or replace into blobs(blob, path, pieces) values(?, ?, ?)", blob, rel, JSON.stringify(hashes));
129
+ return hashes;
130
+ },
131
+ setFiles(root, rows) {
132
+ db.exec("begin");
133
+ try {
134
+ run("delete from files where root = ?", root);
135
+ const now = new Date().toISOString();
136
+ for (const row of rows) run("insert into files(root, path, blob, indexed_at) values(?, ?, ?, ?)", root, row.path, row.blob, now);
137
+ db.exec("commit");
138
+ } catch (error) { db.exec("rollback"); throw error; }
139
+ },
140
+ files: (root) => all("select path, blob from files where root = ?", root),
141
+ roots: () => all("select root, count(*) as files, max(indexed_at) as indexed_at from files group by root").map((row) => ({ ...row, files: Number(row.files) })),
142
+ toEmbed: (version, limit = 64) => all("select p.hash, p.path, p.text from pieces p left join piece_vectors v on v.hash = p.hash and v.version = ? where v.hash is null limit ?", version, limit),
143
+ missing: (version) => Number(get("select count(*) as n from pieces p left join piece_vectors v on v.hash = p.hash and v.version = ? where v.hash is null", version).n),
144
+ putVector: (hash, version, vector) => run("insert or replace into piece_vectors(hash, version, vector) values(?, ?, ?)", hash, version, toBlob(vector)),
145
+ vectors(hashes, version) {
146
+ const out = new Map();
147
+ const list = [...hashes];
148
+ for (let i = 0; i < list.length; i += 500) {
149
+ const slice = list.slice(i, i + 500);
150
+ for (const row of all(`select hash, vector from piece_vectors where version = ? and hash in (${slice.map(() => "?").join(",")})`, version, ...slice)) out.set(row.hash, fromBlob(row.vector));
151
+ }
152
+ return out;
153
+ },
154
+ piece: (hash) => get("select hash, path, line, text from pieces where hash = ?", hash),
155
+ meta(key, value) {
156
+ if (value === undefined) return get("select value from meta where key = ?", key)?.value ?? null;
157
+ run("insert or replace into meta(key, value) values(?, ?)", key, String(value));
158
+ return value;
159
+ },
160
+ runSeen: (agent, sessionId) => get("select source_mtime from runs where agent = ? and session_id = ?", agent, sessionId)?.source_mtime ?? null,
161
+ putRun(row) {
162
+ run(`insert or replace into runs(agent, session_id, root, at, brief, edited, read, before, source_mtime) values(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
163
+ row.agent, row.sessionId, row.root, row.at, row.brief.slice(0, 4_000), JSON.stringify(row.edited), JSON.stringify(row.read), JSON.stringify(row.before), row.sourceMtime);
164
+ },
165
+ runs: (root, since = 0) => all("select agent, session_id, root, at, brief, edited, read, before from runs where root = ? and at >= ? order by at", root, since)
166
+ .map((row) => ({ ...row, at: Number(row.at), edited: JSON.parse(row.edited), read: JSON.parse(row.read), before: JSON.parse(row.before) })),
167
+ runsToEmbed: (version, limit = 32) => all("select r.agent, r.session_id, r.brief from runs r left join run_vectors v on v.agent = r.agent and v.session_id = r.session_id and v.version = ? where v.agent is null limit ?", version, limit),
168
+ putRunVector: (agent, sessionId, version, vector) => run("insert or replace into run_vectors(agent, session_id, version, vector) values(?, ?, ?, ?)", agent, sessionId, version, toBlob(vector)),
169
+ runVectors: (root, version) => all("select r.agent, r.session_id, r.at, r.brief, r.edited, v.vector from runs r join run_vectors v on v.agent = r.agent and v.session_id = r.session_id and v.version = ? where r.root = ?", version, root)
170
+ .map((row) => ({ agent: row.agent, sessionId: row.session_id, at: Number(row.at), brief: row.brief, edited: JSON.parse(row.edited), vector: fromBlob(row.vector) })),
171
+ /** Recordings' windows and images (media.mjs): one row each, its vector beside the pieces'. */
172
+ mediaSeen: (file) => get("select max(mtime) as mtime from media where path = ?", file)?.mtime ?? null,
173
+ mediaPaths: () => all("select distinct path from media").map((row) => row.path),
174
+ dropMedia: (file) => run("delete from media where path = ?", file),
175
+ putMedia: (row) => run("insert or replace into media(hash, path, kind, start_ms, end_ms, mtime) values(?, ?, ?, ?, ?, ?)", row.hash, row.path, row.kind, row.startMs, row.endMs, row.mtime),
176
+ mediaVectors: (version) => all("select m.hash, m.path, m.kind, m.start_ms, m.end_ms, v.vector from media m join piece_vectors v on v.hash = m.hash and v.version = ?", version)
177
+ .map((row) => ({ hash: row.hash, path: row.path, kind: row.kind, startMs: Number(row.start_ms), endMs: Number(row.end_ms), vector: fromBlob(row.vector) })),
178
+ /** Full-text matches: { hash, rank } best first (bm25, lower is better), over every indexed piece. */
179
+ words(terms, limit = 400) {
180
+ if (!terms.length) return [];
181
+ const match = terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" OR ");
182
+ return all("select hash, bm25(pieces_fts, 0, 2.0, 1.0) as rank from pieces_fts where pieces_fts match ? order by rank limit ?", match, limit);
183
+ },
184
+ };
185
+ }
186
+
187
+ const git = (cwd, args, input) => spawnSync("git", args, { cwd, input, encoding: "utf8", maxBuffer: 256 * 2 ** 20 });
188
+
189
+ /** The repository's files at a commit (or the working tree when commit is null): [{ path, blob, size }]. */
190
+ export function treeFiles(root, commit = null) {
191
+ if (commit) {
192
+ const listed = git(root, ["ls-tree", "-r", "-l", commit]);
193
+ if (listed.status !== 0) return [];
194
+ return listed.stdout.split("\n").filter(Boolean).map((line) => {
195
+ const [meta, rel] = line.split("\t");
196
+ const [, type, blob, size] = meta.split(/\s+/);
197
+ return type === "blob" ? { path: rel, blob, size: Number(size) || 0 } : null;
198
+ }).filter((row) => row && indexable(row.path, row.size));
199
+ }
200
+ // The working tree: git's index already holds the blob of every unchanged tracked file; only files changed on disk
201
+ // and new files git does not ignore are hashed, so a pass over a large repository costs about what `git status` does.
202
+ const staged = git(root, ["ls-files", "-s", "-z"]);
203
+ if (staged.status !== 0) return [];
204
+ const blobs = new Map();
205
+ for (const entry of staged.stdout.split("\0")) {
206
+ const tab = entry.indexOf("\t");
207
+ if (tab < 0) continue;
208
+ const [, blob, stage] = entry.slice(0, tab).split(" ");
209
+ if (stage === "0") blobs.set(entry.slice(tab + 1), blob);
210
+ }
211
+ const changed = git(root, ["diff", "--name-only", "-z"]).stdout.split("\0").filter(Boolean);
212
+ const untracked = git(root, ["ls-files", "--others", "--exclude-standard", "-z"]).stdout.split("\0").filter(Boolean);
213
+ const rows = [];
214
+ const rehash = [];
215
+ for (const rel of new Set([...blobs.keys(), ...untracked])) {
216
+ if (!indexable(rel, 0)) continue;
217
+ let stat;
218
+ try { stat = fs.statSync(path.join(root, rel)); } catch { continue; }
219
+ if (!stat.isFile() || stat.size > MAX_FILE) continue;
220
+ const row = { path: rel, size: stat.size, blob: blobs.get(rel) };
221
+ rows.push(row);
222
+ if (!row.blob || changed.includes(rel)) rehash.push(row);
223
+ }
224
+ if (rehash.length) {
225
+ const hashed = git(root, ["hash-object", "--stdin-paths"], rehash.map((row) => path.join(root, row.path)).join("\n"));
226
+ const out = hashed.status === 0 ? hashed.stdout.split("\n") : [];
227
+ rehash.forEach((row, i) => { row.blob = out[i]; });
228
+ }
229
+ return rows.filter((row) => /^[0-9a-f]{40}$/.test(row.blob || ""));
230
+ }
231
+
232
+ /** File contents for blobs, read through one `git cat-file --batch`. */
233
+ export function readBlobs(root, blobs) {
234
+ const out = new Map();
235
+ if (!blobs.length) return out;
236
+ const result = spawnSync("git", ["cat-file", "--batch"], { cwd: root, input: blobs.join("\n") + "\n", maxBuffer: 2 * 2 ** 30 });
237
+ const buffer = result.stdout;
238
+ let at = 0;
239
+ while (at < buffer.length) {
240
+ const end = buffer.indexOf(10, at);
241
+ if (end < 0) break;
242
+ const [blob, type, size] = buffer.subarray(at, end).toString("utf8").split(" ");
243
+ if (type === "missing" || !size) { at = end + 1; continue; }
244
+ const length = Number(size);
245
+ const body = buffer.subarray(end + 1, end + 1 + length);
246
+ at = end + 1 + length + 1;
247
+ if (!body.includes(0) && !SECRET_TEXT.test(body.toString("latin1"))) out.set(blob, body.toString("utf8"));
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /** Adds the files' pieces to the atlas (new blobs only); returns how many blobs were new. */
253
+ export function addFiles(atlas, root, rows) {
254
+ const fresh = rows.filter((row) => !atlas.blob(row.blob));
255
+ const texts = readBlobs(root, [...new Set(fresh.map((row) => row.blob))]);
256
+ for (let i = 0; i < fresh.length; i += 500) atlas.batch(() => {
257
+ for (const row of fresh.slice(i, i + 500)) {
258
+ const text = texts.get(row.blob);
259
+ atlas.putBlob(row.blob, row.path, text === undefined ? [] : pieces(text));
260
+ }
261
+ });
262
+ return fresh.length;
263
+ }
264
+
265
+ /** Embeds every piece that has no vector yet, in batches, on the given lane. */
266
+ export async function embedMissing(atlas, inference, { lane = "background", version = ATLAS_VERSION, batch = 32, onProgress } = {}) {
267
+ let done = 0;
268
+ for (;;) {
269
+ const rows = atlas.toEmbed(version, batch);
270
+ if (!rows.length) return done;
271
+ const vectors = await inference.embed(rows.map((row) => ({ kind: "document", title: row.path, text: row.text })), { lane });
272
+ atlas.batch(() => rows.forEach((row, i) => atlas.putVector(row.hash, version, vectors[i])));
273
+ done += rows.length;
274
+ onProgress?.(done);
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Files in scope ranked for a request, with the signals each one scored on. scope: [{ path, blob }]. Returns
280
+ * [{ path, blob, dense, lexical, named, best: { hash, line } }] sorted by the given scorer (default: dense+lexical+named).
281
+ */
282
+ export async function searchFiles(atlas, inference, query, scope, { version = ATLAS_VERSION, lane = "interactive", queryVector = null, vectors = null, wordLimit = 400 } = {}) {
283
+ const byHash = new Map();
284
+ const files = scope.map((row) => ({ path: row.path, blob: row.blob, hashes: atlas.blob(row.blob) ?? [] }));
285
+ for (const file of files) for (const hash of file.hashes) { if (!byHash.has(hash)) byHash.set(hash, []); byHash.get(hash).push(file); }
286
+ const qv = queryVector ?? (await inference.embed([{ kind: "code-query", text: String(query).slice(0, 6_000) }], { lane }))[0];
287
+ const pieceVectors = vectors ?? atlas.vectors(byHash.keys(), version);
288
+ const terms = queryTerms(query);
289
+ const lexical = new Map();
290
+ for (const row of atlas.words(terms, wordLimit)) if (byHash.has(row.hash)) lexical.set(row.hash, -row.rank);
291
+ const lowered = String(query).toLowerCase();
292
+ return files.map((file) => {
293
+ let dense = -1, best = null, words = 0;
294
+ for (const hash of file.hashes) {
295
+ const vector = pieceVectors.get(hash);
296
+ if (vector) { let dot = 0; for (let i = 0; i < qv.length; i += 1) dot += qv[i] * vector[i]; if (dot > dense) { dense = dot; best = hash; } }
297
+ if (lexical.has(hash)) words = Math.max(words, lexical.get(hash));
298
+ }
299
+ const base = path.basename(file.path).toLowerCase();
300
+ const stem = base.replace(/\.[^.]+$/, "");
301
+ const named = lowered.includes(file.path.toLowerCase()) ? 2 : lowered.includes(base) ? 1.5 : stem.length > 3 && new RegExp(`\\b${stem.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(lowered) ? 1 : 0;
302
+ return { path: file.path, blob: file.blob, dense, lexical: words, named, best };
303
+ });
304
+ }