agent-sh 0.15.6 → 0.15.7

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/dist/agent/agent-loop.js +2 -5
  4. package/dist/agent/extensions/rolling-history/index.js +20 -8
  5. package/dist/agent/extensions/rolling-history/recall.d.ts +2 -2
  6. package/dist/agent/extensions/rolling-history/recall.js +17 -7
  7. package/dist/agent/providers/openai-compatible.d.ts +8 -0
  8. package/dist/agent/providers/openai-compatible.js +9 -2
  9. package/dist/agent/store.js +6 -1
  10. package/dist/agent/token-budget.d.ts +2 -1
  11. package/dist/agent/token-budget.js +6 -1
  12. package/dist/cli/index.js +1 -1
  13. package/dist/core/event-bus.d.ts +16 -1
  14. package/dist/core/event-bus.js +73 -11
  15. package/dist/core/index.js +18 -0
  16. package/dist/shell/tui-renderer.js +115 -174
  17. package/dist/utils/executor.js +19 -11
  18. package/dist/utils/floating-panel.d.ts +1 -0
  19. package/dist/utils/floating-panel.js +28 -26
  20. package/dist/utils/markdown.js +19 -21
  21. package/dist/utils/palette.d.ts +11 -0
  22. package/dist/utils/palette.js +11 -0
  23. package/docs/agent.md +13 -11
  24. package/docs/architecture.md +3 -5
  25. package/docs/extensions.md +21 -20
  26. package/docs/library.md +6 -3
  27. package/docs/troubleshooting.md +2 -2
  28. package/docs/tui-composition.md +11 -3
  29. package/docs/usage.md +70 -50
  30. package/examples/extensions/ashi/src/chat/assistant.ts +6 -4
  31. package/examples/extensions/ashi/src/compaction.ts +4 -7
  32. package/examples/extensions/ashi/src/frontend.ts +2 -0
  33. package/examples/extensions/ashi/src/schema.ts +8 -2
  34. package/examples/extensions/command-suggest.ts +4 -0
  35. package/examples/extensions/solarized-theme.ts +11 -0
  36. package/package.json +1 -1
  37. package/src/agent/agent-loop.ts +2 -5
  38. package/src/agent/extensions/rolling-history/index.ts +20 -8
  39. package/src/agent/extensions/rolling-history/recall.ts +28 -7
  40. package/src/agent/providers/openai-compatible.ts +19 -4
  41. package/src/agent/store.ts +5 -1
  42. package/src/agent/token-budget.ts +10 -1
  43. package/src/cli/index.ts +1 -1
  44. package/src/core/event-bus.ts +67 -12
  45. package/src/core/index.ts +18 -0
  46. package/src/shell/tui-renderer.ts +130 -207
  47. package/src/utils/executor.ts +17 -14
  48. package/src/utils/floating-panel.ts +24 -22
  49. package/src/utils/markdown.ts +17 -20
  50. package/src/utils/palette.ts +30 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yilun Guan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -59,7 +59,7 @@ Once installed, pick a backend below.
59
59
 
60
60
  ### Option A: Use the built-in agent (ash) — recommended
61
61
 
62
- `ash` is agent-sh's own lightweight agent, and the path most users should start with: it shares its tool surface with the rest of the system, so extensions you install (new tools, content transforms, slash commands, themes) compose with it directly. It works with any OpenAI-compatible API — pick one of the zero-config paths below, no settings file needed. The built-in providers (openrouter, openai, openai-compatible, deepseek) register on startup; ash activates the first one with a usable key.
62
+ `ash` is agent-sh's own lightweight agent, and the path most users should start with: it shares its tool surface with the rest of the system, so extensions you install (new tools, content transforms, slash commands, themes) compose with it directly. It works with any OpenAI-compatible API — pick one of the zero-config paths below, no settings file needed. The built-in providers (openrouter, openai, deepseek, ollama, zai-coding-plan, opencode — plus openai-compatible when `OPENAI_BASE_URL` is set) register on startup; ash activates the first one with a usable key.
63
63
 
64
64
  **Quickest path** — store a key once via the auth subcommand:
65
65
 
@@ -820,12 +820,9 @@ export class AgentLoop {
820
820
  // tool-heavy workloads.
821
821
  const target = Math.floor(threshold * 0.25);
822
822
  const result = await this.compactWithHooks(target, 1);
823
- if (!result) {
824
- // Auto-compact fired but nothing was evictable. This can happen
825
- // in short conversations with heavy tool output where the pin
826
- // fraction consumes all turns. Log it so it's not silent.
823
+ if (result) {
827
824
  this.bus.emit("ui:info", {
828
- message: `[auto-compact] above threshold (${totalEstimate.toLocaleString()} > ${threshold.toLocaleString()}) but nothing to evict — conversation may be too short`,
825
+ message: `(auto-compacted: ~${result.before.toLocaleString()} ~${result.after.toLocaleString()} tokens, evicted ${result.evictedCount})`,
829
826
  });
830
827
  }
831
828
  cachedSystemPrompt = undefined;
@@ -102,34 +102,46 @@ export default function activate(ctx) {
102
102
  const toolDef = {
103
103
  name: TOOL_NAME,
104
104
  displayName: "recall",
105
- description: "Browse, search, or expand evicted conversation turns. " +
106
- "Use when you need context from earlier in the conversation that was compacted away. " +
107
- "Search is regex-based and covers both summaries and full body text. " +
108
- "If search doesn't find what you expect, try broader/shorter terms or browse to scan the timeline.",
105
+ description: "Browse, search, or expand the persistent conversation memory — all captured turns across this and recent sessions. " +
106
+ "Use when you need context from prior turns or past sessions that may no longer be in the active window. " +
107
+ "Search accepts a regex pattern (e.g. 'foo|bar') and falls back to literal matching if the pattern is invalid. " +
108
+ "Covers both summaries and full body text. " +
109
+ "If search doesn't find what you expect, try broader/shorter terms or browse to scan the timeline. " +
110
+ "Use offset for pagination on both browse and search.",
109
111
  input_schema: {
110
112
  type: "object",
111
113
  properties: {
112
114
  action: {
113
115
  type: "string",
114
116
  enum: ["browse", "search", "expand"],
115
- description: "browse: list evicted turns, search: regex search, expand: show full turn",
117
+ description: "browse: list recent captured turns, search: regex search across memory, expand: show full turn body",
116
118
  },
117
- query: { type: "string", description: "Search query (for action=search)" },
119
+ query: { type: "string", description: "Search pattern — a regex (e.g. 'foo|bar') or literal text (for action=search)" },
118
120
  turn_id: { type: "string", description: "Turn ID to expand (for action=expand)" },
121
+ offset: {
122
+ type: "number",
123
+ description: "Skip first N results; for browse, start at this entry offset; for search, skip first N hits. Default 0.",
124
+ },
125
+ limit: {
126
+ type: "number",
127
+ description: "Max entries to return for browse (default 25) or search (default 30).",
128
+ },
119
129
  },
120
130
  required: ["action"],
121
131
  },
122
132
  execute: async (args) => {
123
133
  const action = args.action;
134
+ const offset = args.offset ?? 0;
135
+ const limit = args.limit ?? (action === "search" ? 30 : 25);
124
136
  let content;
125
137
  if (action === "search") {
126
- content = await recallSearch(summaryStore, args.query ?? "");
138
+ content = await recallSearch(summaryStore, args.query ?? "", offset, limit);
127
139
  }
128
140
  else if (action === "expand") {
129
141
  content = await recallExpand(summaryStore, args.turn_id);
130
142
  }
131
143
  else {
132
- content = await recallBrowse(summaryStore);
144
+ content = await recallBrowse(summaryStore, offset, limit);
133
145
  }
134
146
  return { content, exitCode: 0, isError: false };
135
147
  },
@@ -1,4 +1,4 @@
1
1
  import type { Store } from "../../store.js";
2
- export declare function recallSearch(store: Store, query: string): Promise<string>;
2
+ export declare function recallSearch(store: Store, query: string, offset?: number, maxResults?: number): Promise<string>;
3
3
  export declare function recallExpand(store: Store, id: string): Promise<string>;
4
- export declare function recallBrowse(store: Store, limit?: number): Promise<string>;
4
+ export declare function recallBrowse(store: Store, offset?: number, limit?: number): Promise<string>;
@@ -64,7 +64,7 @@ async function findCacheChild(store, parentId) {
64
64
  }
65
65
  return null;
66
66
  }
67
- export async function recallSearch(store, query) {
67
+ export async function recallSearch(store, query, offset = 0, maxResults = 30) {
68
68
  if (!query.trim())
69
69
  return "No query provided.";
70
70
  const regex = buildSearchRegex(query);
@@ -96,8 +96,12 @@ export async function recallSearch(store, query) {
96
96
  if (hits.length === 0)
97
97
  return `No results found for "${query}".`;
98
98
  const total = hits.length;
99
- const summary = `Found ${total} match${total === 1 ? "" : "es"} for "${query}"`;
100
- return `${summary}\n\n${hits.slice(0, 30).join("\n\n")}`;
99
+ const paged = hits.slice(offset, offset + maxResults);
100
+ const range = offset > 0 || paged.length < total
101
+ ? ` (showing ${offset + 1}–${offset + paged.length} of ${total})`
102
+ : "";
103
+ const summary = `Found ${total} match${total === 1 ? "" : "es"} for "${query}"${range}`;
104
+ return `${summary}\n\n${paged.join("\n\n")}`;
101
105
  }
102
106
  export async function recallExpand(store, id) {
103
107
  const entry = await store.findById(id);
@@ -114,9 +118,15 @@ export async function recallExpand(store, id) {
114
118
  return `${header}\n\n${body}`;
115
119
  return `${header}\n\n(no expanded content available — recall cache may have been cleared)`;
116
120
  }
117
- export async function recallBrowse(store, limit = 25) {
118
- const lines = await readSummaryLines(store, limit);
119
- if (lines.length === 0)
121
+ export async function recallBrowse(store, offset = 0, limit = 25) {
122
+ const overRead = Math.max(limit * 3, offset + limit);
123
+ const allLines = await readSummaryLines(store, overRead);
124
+ if (allLines.length === 0)
120
125
  return "No conversation history.";
121
- return ["Recent summary entries:", ...lines.map((l) => ` ${l}`)].join("\n");
126
+ const end = Math.min(offset + limit, allLines.length);
127
+ const paged = allLines.slice(offset, end);
128
+ const range = offset > 0 || end < allLines.length
129
+ ? ` (entries ${offset + 1}–${end} of ${allLines.length} shown)`
130
+ : "";
131
+ return [`Recent summary entries${range}:`, ...paged.map((l) => ` ${l}`)].join("\n");
122
132
  }
@@ -7,3 +7,11 @@
7
7
  */
8
8
  import type { AgentContext } from "../host-types.js";
9
9
  export default function activate(ctx: AgentContext): void;
10
+ export interface CatalogModel {
11
+ id: string;
12
+ meta?: {
13
+ n_ctx?: number;
14
+ };
15
+ max_model_len?: number;
16
+ }
17
+ export declare function catalogContextWindow(m: CatalogModel): number | undefined;
@@ -18,11 +18,18 @@ export default function activate(ctx) {
18
18
  id,
19
19
  apiKey,
20
20
  baseURL,
21
- defaultModel: models[0],
21
+ defaultModel: models[0].id,
22
22
  models,
23
23
  });
24
24
  }).catch(() => { });
25
25
  }
26
+ export function catalogContextWindow(m) {
27
+ if (typeof m.meta?.n_ctx === "number" && m.meta.n_ctx > 0)
28
+ return m.meta.n_ctx;
29
+ if (typeof m.max_model_len === "number" && m.max_model_len > 0)
30
+ return m.max_model_len;
31
+ return undefined;
32
+ }
26
33
  async function fetchModels(baseURL, apiKey) {
27
34
  const headers = {};
28
35
  if (apiKey && apiKey !== "no-key")
@@ -31,5 +38,5 @@ async function fetchModels(baseURL, apiKey) {
31
38
  if (!res.ok)
32
39
  return [];
33
40
  const data = await res.json();
34
- return (data.data ?? []).map((m) => m.id);
41
+ return (data.data ?? []).map((m) => ({ id: m.id, contextWindow: catalogContextWindow(m) }));
35
42
  }
@@ -14,7 +14,12 @@ function escapeRegex(s) {
14
14
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
15
  }
16
16
  function compileSearchRegex(query) {
17
- return new RegExp(escapeRegex(query), "i");
17
+ try {
18
+ return new RegExp(query, "i");
19
+ }
20
+ catch {
21
+ return new RegExp(escapeRegex(query), "i");
22
+ }
18
23
  }
19
24
  function matchEntry(entry, re) {
20
25
  const line = JSON.stringify(entry);
@@ -6,5 +6,6 @@
6
6
  */
7
7
  /** Response reserve — tokens reserved for the model's output. */
8
8
  export declare const RESPONSE_RESERVE = 8192;
9
+ export declare function resolveDefaultContextWindow(env?: Record<string, string | undefined>): number;
9
10
  /** Fallback when contextWindow is unknown. */
10
- export declare const DEFAULT_CONTEXT_WINDOW = 60000;
11
+ export declare const DEFAULT_CONTEXT_WINDOW: number;
@@ -6,5 +6,10 @@
6
6
  */
7
7
  /** Response reserve — tokens reserved for the model's output. */
8
8
  export const RESPONSE_RESERVE = 8192;
9
+ const FALLBACK_CONTEXT_WINDOW = 60_000;
10
+ export function resolveDefaultContextWindow(env = process.env) {
11
+ const n = Number(env.AGENT_SH_DEFAULT_CONTEXT_WINDOW);
12
+ return Number.isInteger(n) && n > 0 ? n : FALLBACK_CONTEXT_WINDOW;
13
+ }
9
14
  /** Fallback when contextWindow is unknown. */
10
- export const DEFAULT_CONTEXT_WINDOW = 60_000;
15
+ export const DEFAULT_CONTEXT_WINDOW = resolveDefaultContextWindow();
package/dist/cli/index.js CHANGED
@@ -98,7 +98,6 @@ async function main() {
98
98
  // Load before spawning the shell so PS1 lands below the banner.
99
99
  const settings = getSettings();
100
100
  await loadBuiltinExtensions(extCtx, settings.disabledBuiltins);
101
- activateRollingHistory(extCtx);
102
101
  const loadExtensionsTimeoutMs = 10000;
103
102
  let loadedExtensions = [];
104
103
  await Promise.race([
@@ -154,6 +153,7 @@ async function main() {
154
153
  borderLine + "\n\n");
155
154
  }
156
155
  await core.activateBackend(config.backend);
156
+ activateRollingHistory(extCtx);
157
157
  // 100ms sidesteps macOS SIGTTOU during fg-pgrp handoff.
158
158
  await new Promise(resolve => setTimeout(resolve, 100));
159
159
  shell = activateShell(extCtx, {
@@ -58,6 +58,13 @@ export interface BusMeta {
58
58
  name: string;
59
59
  }
60
60
  export type AnyListener = (name: string, payload: unknown, meta: BusMeta) => void;
61
+ /** A listener fault routed to the error reporter; `phase` is the callback site. */
62
+ export interface BusFault {
63
+ phase: "on" | "any" | "pipe" | "pipe-async";
64
+ event: string;
65
+ err: unknown;
66
+ }
67
+ export type ErrorReporter = (fault: BusFault) => void;
61
68
  /**
62
69
  * Typed event bus with two modes:
63
70
  * - emit/on/off: fire-and-forget notifications
@@ -65,14 +72,22 @@ export type AnyListener = (name: string, payload: unknown, meta: BusMeta) => voi
65
72
  * can modify the payload before passing to the next
66
73
  */
67
74
  export declare class EventBus {
68
- private emitter;
75
+ private listeners;
69
76
  private pipeListeners;
70
77
  private asyncPipeListeners;
71
78
  private source;
72
79
  private nextSeq;
73
80
  private anyListeners;
81
+ /** Default fault sink, overridable via setErrorReporter: silent unless DEBUG. */
82
+ private reportError;
74
83
  /** Set the source id stamped onto every emitted event. */
75
84
  setSource(src: string): void;
85
+ /** Install a fault reporter. */
86
+ setErrorReporter(fn: ErrorReporter): void;
87
+ /** Report a fault; guarded so a broken reporter can't break dispatch. */
88
+ private fault;
89
+ /** Fire every listener for `name`, isolating faults. */
90
+ private notify;
76
91
  /** Subscribe to every emitted event with full envelope. Returns unsubscribe. */
77
92
  onAny(fn: AnyListener): () => void;
78
93
  /** Stamp + dispatch — used by every emit path. */
@@ -1,4 +1,3 @@
1
- import { EventEmitter } from "node:events";
2
1
  /**
3
2
  * Typed event bus with two modes:
4
3
  * - emit/on/off: fire-and-forget notifications
@@ -6,16 +5,60 @@ import { EventEmitter } from "node:events";
6
5
  * can modify the payload before passing to the next
7
6
  */
8
7
  export class EventBus {
9
- emitter = new EventEmitter().setMaxListeners(0);
8
+ listeners = new Map();
10
9
  pipeListeners = new Map();
11
10
  asyncPipeListeners = new Map();
12
11
  source = "0000";
13
12
  nextSeq = 0;
14
13
  anyListeners = [];
14
+ /** Default fault sink, overridable via setErrorReporter: silent unless DEBUG. */
15
+ reportError = ({ phase, event, err }) => {
16
+ if (process.env.DEBUG) {
17
+ const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
18
+ process.stderr.write(`[event-bus] ${phase} fault on "${event}": ${msg}\n`);
19
+ }
20
+ };
15
21
  /** Set the source id stamped onto every emitted event. */
16
22
  setSource(src) {
17
23
  this.source = src;
18
24
  }
25
+ /** Install a fault reporter. */
26
+ setErrorReporter(fn) {
27
+ this.reportError = fn;
28
+ }
29
+ /** Report a fault; guarded so a broken reporter can't break dispatch. */
30
+ fault(phase, event, err) {
31
+ try {
32
+ this.reportError({ phase, event, err });
33
+ }
34
+ catch {
35
+ /* swallow */
36
+ }
37
+ }
38
+ /** Fire every listener for `name`, isolating faults. */
39
+ notify(name, payload) {
40
+ const arr = this.listeners.get(name);
41
+ if (!arr || arr.length === 0)
42
+ return;
43
+ // snapshot so a listener that (un)subscribes mid-dispatch can't shift iteration
44
+ if (arr.length === 1) {
45
+ try {
46
+ arr[0](payload);
47
+ }
48
+ catch (err) {
49
+ this.fault("on", name, err);
50
+ }
51
+ return;
52
+ }
53
+ for (const fn of arr.slice()) {
54
+ try {
55
+ fn(payload);
56
+ }
57
+ catch (err) {
58
+ this.fault("on", name, err);
59
+ }
60
+ }
61
+ }
19
62
  /** Subscribe to every emitted event with full envelope. Returns unsubscribe. */
20
63
  onAny(fn) {
21
64
  this.anyListeners.push(fn);
@@ -38,18 +81,30 @@ export class EventBus {
38
81
  try {
39
82
  fn(name, payload, meta);
40
83
  }
41
- catch { /* swallow */ }
84
+ catch (err) {
85
+ this.fault("any", name, err);
86
+ }
42
87
  }
43
88
  }
44
- this.emitter.emit(name, payload);
89
+ this.notify(name, payload);
45
90
  }
46
91
  /** Subscribe to a fire-and-forget event. */
47
92
  on(event, fn) {
48
- this.emitter.on(event, fn);
93
+ let arr = this.listeners.get(event);
94
+ if (!arr) {
95
+ arr = [];
96
+ this.listeners.set(event, arr);
97
+ }
98
+ arr.push(fn);
49
99
  }
50
100
  /** Unsubscribe from a fire-and-forget event. */
51
101
  off(event, fn) {
52
- this.emitter.off(event, fn);
102
+ const arr = this.listeners.get(event);
103
+ if (!arr)
104
+ return;
105
+ const idx = arr.indexOf(fn);
106
+ if (idx !== -1)
107
+ arr.splice(idx, 1);
53
108
  }
54
109
  /** Emit a fire-and-forget event. */
55
110
  emit(event, payload) {
@@ -64,10 +119,12 @@ export class EventBus {
64
119
  try {
65
120
  fn(meta.name, payload, meta);
66
121
  }
67
- catch { /* swallow */ }
122
+ catch (err) {
123
+ this.fault("any", meta.name, err);
124
+ }
68
125
  }
69
126
  }
70
- this.emitter.emit(meta.name, payload);
127
+ this.notify(meta.name, payload);
71
128
  }
72
129
  /**
73
130
  * Transform-then-notify: run the payload through any registered pipe
@@ -120,13 +177,13 @@ export class EventBus {
120
177
  try {
121
178
  const out = fn(result);
122
179
  if (out && typeof out.then === "function") {
123
- console.error(`[event-bus] Warning: async handler in sync pipe "${String(event)}" — use onPipeAsync instead`);
180
+ this.fault("pipe", String(event), new Error("async handler in sync pipe — use onPipeAsync instead"));
124
181
  continue;
125
182
  }
126
183
  result = out;
127
184
  }
128
185
  catch (err) {
129
- console.error(`[event-bus] Pipe handler error in "${String(event)}":`, err instanceof Error ? err.message : err);
186
+ this.fault("pipe", String(event), err);
130
187
  }
131
188
  }
132
189
  return result;
@@ -165,7 +222,12 @@ export class EventBus {
165
222
  return payload;
166
223
  let result = payload;
167
224
  for (const fn of listeners) {
168
- result = await fn(result);
225
+ try {
226
+ result = await fn(result);
227
+ }
228
+ catch (err) {
229
+ this.fault("pipe-async", String(event), err);
230
+ }
169
231
  }
170
232
  return result;
171
233
  }
@@ -27,6 +27,24 @@ export function createCore(config) {
27
27
  // should accept ≥6 hex chars.
28
28
  const instanceId = crypto.randomBytes(3).toString("hex");
29
29
  bus.setSource(instanceId);
30
+ // Surface faults on ui:error; `surfacing` stops a faulting renderer from looping.
31
+ let surfacing = false;
32
+ bus.setErrorReporter(({ phase, event, err }) => {
33
+ const detail = err instanceof Error ? err.message : String(err);
34
+ if (process.env.DEBUG) {
35
+ const full = err instanceof Error ? (err.stack ?? err.message) : detail;
36
+ process.stderr.write(`[event-bus] ${phase} fault on "${event}": ${full}\n`);
37
+ }
38
+ if (surfacing)
39
+ return;
40
+ surfacing = true;
41
+ try {
42
+ bus.emit("ui:error", { message: `Handler error on "${event}": ${detail}` });
43
+ }
44
+ finally {
45
+ surfacing = false;
46
+ }
47
+ });
30
48
  handlers.define("config:get-app-config", () => config);
31
49
  handlers.define("cwd", () => process.cwd());
32
50
  // Empty defaults so advisors can wrap these regardless of load order;