omnirush 0.9.0 → 0.9.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.
@@ -4,7 +4,11 @@
4
4
  // the SAME workspace:
5
5
  // - role presets: code-searcher (read-only exploration),
6
6
  // researcher-web (web_fetch research), general-worker (full tools)
7
- // - parallel execution, every task at once unless max_parallel throttles
7
+ // - parallel execution, every task at once unless max_parallel throttles;
8
+ // no count cap, but memory-aware admission (memory-lib.ts): a child
9
+ // starts while the machine keeps enough RAM free and the agents' process
10
+ // tree stays under its budget, the rest wait ("queued: waiting for
11
+ // memory") and start as memory frees up
8
12
  // - no wall-clock limit: a child runs until it is done. Only an explicit
9
13
  // timeout_minutes, the inactivity watchdog (no output at all for
10
14
  // OMNIRUSH_AGENT_INACTIVITY_MINUTES, default 30, longer while one of
@@ -20,7 +24,8 @@
20
24
  // running children
21
25
  //
22
26
  // Tracing is free: every child writes its own session JSONL under
23
- // <agentDir>/sessions/<cwd-slug>/ (children run without --no-session)
27
+ // <session dir>/subagents/ (children run without --no-session; that
28
+ // folder keeps them out of /resume and --continue)
24
29
  // under a session id the parent picks, and the parent session's
25
30
  // collector records each child's conversation as a sub-agent of the
26
31
  // turn (session.child events with parent and depth, as the desktop
@@ -29,6 +34,11 @@
29
34
  // stopped and recorded as interrupted. Children get
30
35
  // OMNIRUSH_PARENT_SESSION, so a child process uploads nothing of its own.
31
36
  //
37
+ // Nesting: a sub-agent may start sub-agents of its own, up to
38
+ // MAX_SUBAGENT_DEPTH (3) layers below the main session (the desktop's
39
+ // OMNIRUSH_SUBAGENT_DEPTH). Each child gets OMNIRUSH_AGENT_DEPTH (its layer)
40
+ // and a note saying it; layer 3 has no spawn_agents.
41
+ //
32
42
  // `/agents` lists this session's subagents (running + finished);
33
43
  // `/agents cancel <id|all>` stops running ones.
34
44
 
@@ -46,16 +56,25 @@ import {
46
56
  type AgentRunner,
47
57
  type AgentsHook,
48
58
  type AgentRecord,
59
+ agentDepth,
60
+ canDelegate,
49
61
  childInactivityMs,
50
62
  renderAgentStatus,
63
+ subagentLayerNote,
51
64
  renderChildResults,
52
65
  renderDelivery,
53
66
  runChildAgent,
54
67
  childModelFallback,
68
+ subagentSessionDir,
55
69
  type ChildResult,
56
70
  type ChildTask,
57
71
  } from "./agents-lib";
58
72
  import { SWARM_GUIDELINE } from "./swarm-lib";
73
+ import { omniDir } from "./auth";
74
+ import { MemoryAdmission, readMemorySettings } from "./memory-lib";
75
+
76
+ /** Blocking spawn_agents: progress updates at most this often (ms). */
77
+ const PROGRESS_UPDATE_MS = 500;
59
78
 
60
79
  /** A timeout_minutes below this is taken for a placeholder, not a limit. */
61
80
  const MIN_TIMEOUT_MINUTES = 1;
@@ -109,7 +128,19 @@ function renderSome(records: AgentRecord[], unknown: string[] = []): string {
109
128
  return parts.join("\n\n") || "No matching sub-agents.";
110
129
  }
111
130
 
112
- export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {}) {
131
+ export default function (pi: ExtensionAPI, options: { run?: AgentRunner; admission?: MemoryAdmission | null; env?: NodeJS.ProcessEnv } = {}) {
132
+ // Sub-agents nest up to MAX_SUBAGENT_DEPTH layers below the main session,
133
+ // as in the desktop app: every sub-agent is told its layer, and the last
134
+ // layer gets no spawn_agents (nor the tools that manage sub-agents).
135
+ const depth = agentDepth(options.env ?? process.env);
136
+ if (depth > 0) {
137
+ pi.on("before_agent_start", async (event: any) => {
138
+ const base = typeof event?.systemPrompt === "string" ? event.systemPrompt : "";
139
+ return { systemPrompt: `${base}\n\n${subagentLayerNote(depth)}` };
140
+ });
141
+ }
142
+ if (!canDelegate(depth)) return;
143
+
113
144
  const watchdogMinutes = Math.round(childInactivityMs() / 60_000);
114
145
  const hub = deliveryHub(pi);
115
146
  /** The last context seen (the TUI status bar for running agents). */
@@ -130,18 +161,49 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
130
161
  statusTimer.unref?.();
131
162
  }
132
163
 
164
+ // Memory-aware admission of children; thresholds from the environment or
165
+ // settings.json "subagentMemory" (memory-lib.ts). Built on first use.
166
+ let admission: MemoryAdmission | null = null;
167
+ const admissionOf = (): MemoryAdmission => {
168
+ admission ??= options.admission ?? new MemoryAdmission({ settings: readMemorySettings(omniDir()) });
169
+ return admission;
170
+ };
171
+ const lazyAdmission = {
172
+ check: () => admissionOf().check(),
173
+ started: (key: object, pid?: number | null) => admissionOf().started(key, pid ?? null),
174
+ spawned: (key: object, pid: number | null | undefined) => admissionOf().spawned(key, pid),
175
+ finished: (key: object) => admissionOf().finished(key),
176
+ };
177
+
178
+ /** Tell the user when children wait for memory (a warning when it is critically low). */
179
+ function memoryWait(info: { critical: boolean; detail: string; queued: number } | null): void {
180
+ scheduleStatus();
181
+ if (!info) return;
182
+ const text = info.critical
183
+ ? `omnirush: ${info.detail}. Not starting more sub-agents until memory frees up (${info.queued} queued); running ones continue.`
184
+ : `omnirush: ${info.queued} sub-agent${info.queued === 1 ? "" : "s"} queued: waiting for memory (${info.detail}).`;
185
+ if (lastContext?.hasUI && lastContext?.ui?.notify) lastContext.ui.notify(text, info.critical ? "warning" : "info");
186
+ // Headless: the top agent says it on stderr (a sub-agent's stderr only reaches its parent).
187
+ else if (info.critical || !String(process.env.OMNIRUSH_PARENT_SESSION ?? "").trim()) process.stderr.write(`${text}\n`);
188
+ }
189
+
133
190
  const manager = new AgentManager({
134
191
  run: options.run ?? ((task, options) => runChildAgent(task, {
135
192
  cwd: options.cwd ?? hub.cwd,
136
193
  parentSessionId: options.parentSessionId,
137
194
  sessionId: options.sessionId,
195
+ ...(options.sessionDir ? { sessionDir: options.sessionDir } : {}),
138
196
  signal: options.signal,
139
197
  onActivity: options.onActivity,
198
+ ...(options.onSpawn ? { onSpawn: options.onSpawn } : {}),
199
+ extraEnv: admissionOf().childEnv(),
140
200
  ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
141
201
  })),
142
202
  // After the settle's own bookkeeping (waiters took theirs).
143
203
  onDeliverable: () => hub.notify(),
144
204
  onChanged: scheduleStatus,
205
+ admission: options.admission === null ? null : lazyAdmission,
206
+ onMemoryWait: memoryWait,
145
207
  });
146
208
 
147
209
  hub.addSource({
@@ -231,6 +293,8 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
231
293
  ? Math.floor(params.max_parallel)
232
294
  : undefined;
233
295
  const background = params.wait === false;
296
+ let progressTimer: ReturnType<typeof setTimeout> | null = null;
297
+ let finished = false;
234
298
 
235
299
  const dispatched = manager.dispatch(parentSessionId, resolvedTasks, {
236
300
  background,
@@ -245,14 +309,24 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
245
309
  });
246
310
  },
247
311
  onChanged: () => {
248
- if (background) return;
249
- onUpdate?.({
250
- content: [{ type: "text", text: agentStatusSummary(manager.list(parentSessionId)) }],
251
- details: { results: manager.list(parentSessionId).filter((candidate) => candidate.result).map(resultOf) },
252
- });
312
+ // Every sign of life of every child lands here: the progress update
313
+ // (which carries the finished results) goes out at most every
314
+ // PROGRESS_UPDATE_MS, not once per output chunk.
315
+ if (background || progressTimer) return;
316
+ progressTimer = setTimeout(() => {
317
+ progressTimer = null;
318
+ if (finished) return;
319
+ onUpdate?.({
320
+ content: [{ type: "text", text: agentStatusSummary(manager.list(parentSessionId)) }],
321
+ details: { results: manager.list(parentSessionId).filter((candidate) => candidate.result).map(resultOf) },
322
+ });
323
+ }, PROGRESS_UPDATE_MS);
324
+ progressTimer.unref?.();
253
325
  },
254
326
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
255
327
  cwd: workdir,
328
+ // Children's sessions stay out of the user's /resume list.
329
+ sessionDir: subagentSessionDir(ctx?.sessionManager?.getSessionDir?.()),
256
330
  });
257
331
 
258
332
  if (background) {
@@ -295,6 +369,8 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
295
369
  }
296
370
 
297
371
  await dispatched.done;
372
+ finished = true;
373
+ if (progressTimer) clearTimeout(progressTimer);
298
374
  const records = dispatched.records;
299
375
  return {
300
376
  content: [{ type: "text", text: renderChildResults(records.map((record) => record.result as ChildResult), records.map((record) => record.id)) }],
@@ -404,7 +480,7 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
404
480
  });
405
481
 
406
482
  pi.registerCommand("agents", {
407
- description: "List this session's subagents (running and finished); /agents cancel <id|all> stops running ones",
483
+ description: "List this session's subagents (running and finished) and the memory they may use; /agents cancel <id|all> stops running ones; /agents memory shows the memory admission",
408
484
  handler: async (args: string, ctx: any) => {
409
485
  const session = sessionOf(ctx);
410
486
  const words = String(args ?? "").trim().split(/\s+/).filter(Boolean);
@@ -416,11 +492,16 @@ export default function (pi: ExtensionAPI, options: { run?: AgentRunner } = {})
416
492
  return;
417
493
  }
418
494
  const records = manager.list(session);
495
+ const memory = options.admission === null ? "" : `\n${admissionOf().describe()}`;
496
+ if (words[0] === "memory") {
497
+ ctx?.ui?.notify?.(memory.trim() || "Memory admission is off.");
498
+ return;
499
+ }
419
500
  if (records.length === 0) {
420
- ctx?.ui?.notify?.("No subagents have been spawned in this session yet.");
501
+ ctx?.ui?.notify?.(`No subagents have been spawned in this session yet.${memory}`);
421
502
  return;
422
503
  }
423
- ctx?.ui?.notify?.(renderAgentStatus(records));
504
+ ctx?.ui?.notify?.(`${renderAgentStatus(records)}${memory}`);
424
505
  },
425
506
  });
426
507
  }
@@ -21,6 +21,12 @@ export const MAX_FOREGROUND_SECONDS = 3_600;
21
21
  export const JOB_BUFFER_CHARS = 1_000_000;
22
22
  /** Output a delivery or a read shows at most (characters): the newest part. */
23
23
  export const SHOW_CHARS = 30_000;
24
+ /**
25
+ * Finished jobs whose result the model has seen are kept (for bash_output /
26
+ * status) without their output; beyond this many they are forgotten, oldest
27
+ * first. Every bash call is a job, so a long session has thousands.
28
+ */
29
+ export const MAX_SETTLED_JOBS = 200;
24
30
  /** Longest wait a bash_wait call may ask for (seconds). */
25
31
  export const MAX_WAIT_SECONDS = 3_600;
26
32
 
@@ -53,6 +59,11 @@ export class OutputBuffer {
53
59
  if (this.text.length > this.max * 1.25) this.text = this.text.slice(this.text.length - this.max);
54
60
  }
55
61
 
62
+ /** Nothing more will be read: drop the text, keep the counters. */
63
+ release(): void {
64
+ this.text = "";
65
+ }
66
+
56
67
  /** Offset of the oldest character still kept. */
57
68
  get start(): number {
58
69
  return this.total - this.text.length;
@@ -143,6 +154,7 @@ export class ShellJobManager {
143
154
  toolCallId?: string | null;
144
155
  onData?: (data: Buffer | string) => void;
145
156
  }): ShellJob {
157
+ this.prune();
146
158
  const controller = new AbortController();
147
159
  const job: ShellJob = {
148
160
  id: `sh${this.next++}`,
@@ -209,6 +221,25 @@ export class ShellJobManager {
209
221
  if (job.background && !job.delivered) this.onDeliverable();
210
222
  }
211
223
 
224
+ /**
225
+ * Memory: finished jobs whose result reached the model keep no output (up
226
+ * to 1.25M characters each otherwise, for the life of the process), and
227
+ * only the newest MAX_SETTLED_JOBS of them are kept at all.
228
+ */
229
+ prune(): void {
230
+ let settled = 0;
231
+ for (let index = this.jobs.length - 1; index >= 0; index--) {
232
+ const job = this.jobs[index];
233
+ if (job.status === "running" || !job.delivered || (this.awaited.get(job) ?? 0) > 0) continue;
234
+ job.output.release();
235
+ settled += 1;
236
+ if (settled > MAX_SETTLED_JOBS) {
237
+ this.jobs.splice(index, 1);
238
+ this.reminded.delete(job);
239
+ }
240
+ }
241
+ }
242
+
212
243
  /** Hear the job's (cleaned) output from now on; returns the unsubscribe. */
213
244
  listen(job: ShellJob, listener: (text: string) => void): () => void {
214
245
  const set = this.listeners.get(job) ?? new Set();
@@ -304,6 +335,7 @@ export class ShellJobManager {
304
335
 
305
336
  /** Finished background jobs whose result the model has not seen (marked delivered). */
306
337
  takeDeliverable(session: string): ShellJob[] {
338
+ this.prune();
307
339
  const ready = this.list(session).filter((job) => job.background && job.status !== "running" && !job.delivered);
308
340
  ready.forEach((job) => { job.delivered = true; });
309
341
  return ready;
@@ -1689,11 +1689,16 @@ export async function readSessionLedger(path: string | null): Promise<SessionLed
1689
1689
  }
1690
1690
  }
1691
1691
 
1692
+ /**
1693
+ * The trace document (scrubbed, cut to `maxBytes`) as JSON text, plus the
1694
+ * events it carries (the same values the text encodes): the envelope's
1695
+ * "trace" array is written from them without parsing the text back.
1696
+ */
1692
1697
  function boundedTracePayload(
1693
1698
  state: Pick<SessionState, "id" | "workspaceId" | "segment" | "resumed">,
1694
1699
  events: TraceEvent[],
1695
1700
  maxBytes = MAX_TRACE_BYTES,
1696
- ): Buffer {
1701
+ ): { content: string; events: unknown[] } {
1697
1702
  // The trace is scrubbed as a JSON value, never as serialised text: a text
1698
1703
  // redaction inside an escaped string (`\n@app.function`) once left a
1699
1704
  // dangling backslash that made the whole document unparseable. Header and
@@ -1707,12 +1712,15 @@ function boundedTracePayload(
1707
1712
  session_resumed: state.resumed,
1708
1713
  events,
1709
1714
  }).value as Record<string, unknown> & { events: TraceEvent[] };
1710
- const encode = (selected: TraceEvent[], truncated: boolean, includedCount = selected.length) => Buffer.from(JSON.stringify({
1711
- ...header,
1712
- trace_truncated: truncated,
1713
- dropped_event_count: Math.max(0, events.length - includedCount),
1714
- events: selected,
1715
- }));
1715
+ const encode = (selected: TraceEvent[], truncated: boolean, includedCount = selected.length) => {
1716
+ const content = JSON.stringify({
1717
+ ...header,
1718
+ trace_truncated: truncated,
1719
+ dropped_event_count: Math.max(0, events.length - includedCount),
1720
+ events: selected,
1721
+ });
1722
+ return { content, events: selected as unknown[], byteLength: Buffer.byteLength(content) };
1723
+ };
1716
1724
 
1717
1725
  // One encode settles the common case. Only an oversized trace pays for the
1718
1726
  // search, and that search measures each event once: the payload of a prefix
@@ -1721,7 +1729,7 @@ function boundedTracePayload(
1721
1729
  // probe used to be a full serialisation of the prefix, which made a flush
1722
1730
  // at the MAX_COLLECTOR_TRACE_EVENTS cap peak at many times the payload.
1723
1731
  let payload = encode(scrubbed, false);
1724
- if (payload.byteLength <= maxBytes) return payload;
1732
+ if (payload.byteLength <= maxBytes) return { content: payload.content, events: payload.events };
1725
1733
  const wrapperBytes = (included: number) => Buffer.byteLength(JSON.stringify({
1726
1734
  ...header,
1727
1735
  trace_truncated: true,
@@ -1749,9 +1757,9 @@ function boundedTracePayload(
1749
1757
  data: { dropped_event_count: events.length },
1750
1758
  };
1751
1759
  const marked = encode([marker], true, 0);
1752
- if (marked.byteLength <= maxBytes) return marked;
1760
+ if (marked.byteLength <= maxBytes) return { content: marked.content, events: marked.events };
1753
1761
  }
1754
- return payload;
1762
+ return { content: payload.content, events: payload.events };
1755
1763
  }
1756
1764
 
1757
1765
  /** Eligible files plus the number of non-ignored paths the denylist dropped. */
@@ -2837,6 +2845,45 @@ const utf8Encoder = new TextEncoder();
2837
2845
  * entry exists in memory at a time, and writing allocates no buffer per chunk.
2838
2846
  * The compressed result is read back from disk only for the upload.
2839
2847
  */
2848
+ /** Strings longer than this are escaped and written a piece at a time. */
2849
+ const JSON_STRING_PIECE_CHARS = 1 << 20;
2850
+
2851
+ /**
2852
+ * Writes `value` as JSON, byte for byte what JSON.stringify(value) gives,
2853
+ * without building the whole text: a plain object's long string values (a
2854
+ * file's content, up to MAX_COLLECTOR_FILE_BYTES; the trace document, up to
2855
+ * MAX_TRACE_BYTES) are escaped in pieces, so no escaped copy of them exists
2856
+ * at once. Anything else goes through JSON.stringify.
2857
+ */
2858
+ export async function writeJsonStreaming(writer: { write(text: string): Promise<void> }, value: unknown): Promise<void> {
2859
+ if (typeof value === "string" && value.length > JSON_STRING_PIECE_CHARS) {
2860
+ await writer.write('"');
2861
+ for (let at = 0; at < value.length;) {
2862
+ let end = Math.min(value.length, at + JSON_STRING_PIECE_CHARS);
2863
+ // Never split a surrogate pair: each half alone would be escaped.
2864
+ const last = value.charCodeAt(end - 1);
2865
+ if (end < value.length && last >= 0xd800 && last <= 0xdbff) end -= 1;
2866
+ await writer.write(JSON.stringify(value.slice(at, end)).slice(1, -1));
2867
+ at = end;
2868
+ }
2869
+ await writer.write('"');
2870
+ return;
2871
+ }
2872
+ if (value && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype && typeof (value as { toJSON?: unknown }).toJSON !== "function") {
2873
+ await writer.write("{");
2874
+ let first = true;
2875
+ for (const [key, item] of Object.entries(value)) {
2876
+ if (item === undefined || typeof item === "function" || typeof item === "symbol") continue;
2877
+ await writer.write(`${first ? "" : ","}${JSON.stringify(key)}:`);
2878
+ first = false;
2879
+ await writeJsonStreaming(writer, item);
2880
+ }
2881
+ await writer.write("}");
2882
+ return;
2883
+ }
2884
+ await writer.write(JSON.stringify(value) ?? "null");
2885
+ }
2886
+
2840
2887
  class EnvelopeWriter {
2841
2888
  /** Uncompressed bytes written so far. */
2842
2889
  bytes = 0;
@@ -4105,6 +4152,17 @@ export class WorkspaceCollector {
4105
4152
  });
4106
4153
  }
4107
4154
 
4155
+ /**
4156
+ * Resolves once the session's queued uploads (traces, snapshots) went out:
4157
+ * a caller recording a lot at once (every sub-agent of a turn) waits here
4158
+ * between flushes, so only one flush's events wait in memory at a time.
4159
+ */
4160
+ async drained(sessionId: string): Promise<void> {
4161
+ const state = this.sessions.get(sessionId);
4162
+ if (!state) return;
4163
+ await state.tail.catch(() => undefined);
4164
+ }
4165
+
4108
4166
  /** Resolves once every queued capture and upload for the session has settled, and its watchers are up. */
4109
4167
  async idle(sessionId: string): Promise<void> {
4110
4168
  const state = this.sessions.get(sessionId);
@@ -4558,15 +4616,10 @@ export class WorkspaceCollector {
4558
4616
 
4559
4617
  private async uploadTrace(state: SessionState, traceEvents: TraceEvent[]): Promise<void> {
4560
4618
  if (traceEvents.length === 0) return;
4561
- const bounded = boundedTracePayload(state, traceEvents, MAX_TRACE_BYTES);
4562
- const content = bounded.toString("utf8");
4563
- let events: unknown[] = [];
4564
- try {
4565
- const parsed = JSON.parse(content) as { events?: unknown };
4566
- if (Array.isArray(parsed.events)) events = parsed.events;
4567
- } catch {
4568
- events = [];
4569
- }
4619
+ // The text is uploaded as the trace file and its events as the envelope's
4620
+ // trace (written event by event); neither is copied or parsed back.
4621
+ const { content, events } = boundedTracePayload(state, traceEvents, MAX_TRACE_BYTES);
4622
+ traceEvents.length = 0;
4570
4623
  await this.uploadEnvelope(state, "trace", "trace_flush", {
4571
4624
  extras: {
4572
4625
  workspace: { root_name: workspaceRootName(state.root), git: null },
@@ -4600,7 +4653,8 @@ export class WorkspaceCollector {
4600
4653
  "Content-Type": "application/zstd",
4601
4654
  "X-OmniRush-Session-ID": sessionId,
4602
4655
  },
4603
- body: compressed.buffer.slice(compressed.byteOffset, compressed.byteOffset + compressed.byteLength) as ArrayBuffer,
4656
+ // The bytes as they are (a view, not a copy of up to 64 MiB).
4657
+ body: compressed as Uint8Array<ArrayBuffer>,
4604
4658
  signal,
4605
4659
  });
4606
4660
  }
@@ -4731,7 +4785,8 @@ export class WorkspaceCollector {
4731
4785
  });
4732
4786
  await writer.write(`${head.slice(0, -1)},"files":[`);
4733
4787
  await body.writeFiles(async (file) => {
4734
- await writer.write(`${fileCount > 0 ? "," : ""}${JSON.stringify(file)}`);
4788
+ if (fileCount > 0) await writer.write(",");
4789
+ await writeJsonStreaming(writer, file);
4735
4790
  fileCount += 1;
4736
4791
  });
4737
4792
  await writer.write('],"manifest":[');
@@ -4741,7 +4796,16 @@ export class WorkspaceCollector {
4741
4796
  first = false;
4742
4797
  }
4743
4798
  await writer.write(`],"privacy":${JSON.stringify(body.privacy())}`);
4744
- if (body.trace) await writer.write(`,"trace":${JSON.stringify(body.trace)}`);
4799
+ if (Array.isArray(body.trace)) {
4800
+ // Event by event: the same text as one JSON.stringify of the array.
4801
+ await writer.write(',"trace":[');
4802
+ for (let index = 0; index < body.trace.length; index += 1) {
4803
+ await writer.write(`${index > 0 ? "," : ""}${JSON.stringify(body.trace[index]) ?? "null"}`);
4804
+ }
4805
+ await writer.write("]");
4806
+ } else if (body.trace) {
4807
+ await writer.write(`,"trace":${JSON.stringify(body.trace)}`);
4808
+ }
4745
4809
  await writer.write("}");
4746
4810
  compressedBytes = await writer.finish();
4747
4811
  } catch (error) {