flint-agent 1.14.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/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- package/system.md +63 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// The one door to the provider.
|
|
2
|
+
//
|
|
3
|
+
// Every paid call leaves from here, and leaves only after the budget has been
|
|
4
|
+
// consulted. There used to be four other ways out — the intent classifier, the
|
|
5
|
+
// fact extractor, the outcome ask, and a stray copy of the benchmark judge —
|
|
6
|
+
// each with its own `fetch` and none of them within sight of a ceiling. The
|
|
7
|
+
// ceilings, meanwhile, were checked after the fact against numbers that did not
|
|
8
|
+
// include those calls. See src/agent/usage.js for the notebook side of it.
|
|
9
|
+
//
|
|
10
|
+
// A new caller must come through here. tests/unit/api/one-door.test.js walks
|
|
11
|
+
// src/ and fails the build if a second door appears.
|
|
12
|
+
|
|
13
|
+
import { freeRequestFields, paymentRequiredMessage, recordFreeRequest, createServedModelNotifier } from "../free-models.js";
|
|
14
|
+
import { config } from "../config.js";
|
|
15
|
+
import { getProvider } from "../providers/registry.js";
|
|
16
|
+
import * as openaiAdapter from "../providers/adapters/openai.js";
|
|
17
|
+
import * as anthropicAdapter from "../providers/adapters/anthropic.js";
|
|
18
|
+
import { fetchModelInfo as fetchModelInfoFromProvider } from "../providers/models.js";
|
|
19
|
+
import { assertWithinBudget, recordUsage } from "../agent/usage.js";
|
|
20
|
+
|
|
21
|
+
function getAdapter() {
|
|
22
|
+
const provider = getProvider(config.provider);
|
|
23
|
+
if (!provider) return { adapter: openaiAdapter, provider: getProvider("openrouter") };
|
|
24
|
+
if (provider.format === "anthropic") return { adapter: anthropicAdapter, provider };
|
|
25
|
+
return { adapter: openaiAdapter, provider };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* RX-1 fix: Combine external AbortSignal with an internal timeout signal.
|
|
30
|
+
* Returns a signal that aborts when either fires. Compatible with Node 18+.
|
|
31
|
+
*/
|
|
32
|
+
function combineAbortSignals(external, timeoutMs) {
|
|
33
|
+
const combined = new AbortController();
|
|
34
|
+
let timeoutHandle = null;
|
|
35
|
+
let externalHandler = null;
|
|
36
|
+
|
|
37
|
+
const cleanup = () => {
|
|
38
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
39
|
+
if (external && externalHandler) {
|
|
40
|
+
try { external.removeEventListener("abort", externalHandler); } catch {}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// External signal already aborted — return immediately
|
|
45
|
+
if (external?.aborted) {
|
|
46
|
+
combined.abort(external.reason);
|
|
47
|
+
return { signal: combined.signal, cleanup };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Set up timeout
|
|
51
|
+
timeoutHandle = setTimeout(() => {
|
|
52
|
+
combined.abort(new Error(`API call exceeded timeout of ${timeoutMs}ms`));
|
|
53
|
+
}, timeoutMs);
|
|
54
|
+
|
|
55
|
+
// Forward external abort
|
|
56
|
+
if (external) {
|
|
57
|
+
externalHandler = () => combined.abort(external.reason);
|
|
58
|
+
external.addEventListener("abort", externalHandler, { once: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { signal: combined.signal, cleanup };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Call the provider. The only function in src/ that does.
|
|
66
|
+
*
|
|
67
|
+
* @param {Array} messages
|
|
68
|
+
* @param {Array} tools
|
|
69
|
+
* @param {Function} onToken
|
|
70
|
+
* @param {object} [opts]
|
|
71
|
+
* @param {AbortSignal} [opts.signal]
|
|
72
|
+
* @param {string} [opts.model] model for this call; defaults to config.model
|
|
73
|
+
* @param {string} [opts.source="agent"] which part of Flint is spending. Must
|
|
74
|
+
* be one of USAGE_SOURCES — it is what the bill is broken down by.
|
|
75
|
+
* @param {number} [opts.maxTokens] defaults to config.maxResponseTokens
|
|
76
|
+
* @param {number} [opts.temperature]
|
|
77
|
+
* @param {number} [opts.seed]
|
|
78
|
+
* @param {object} [opts.responseFormat] e.g. {type: "json_object"}
|
|
79
|
+
* @param {number} [opts.timeoutMs] hard ceiling for this call
|
|
80
|
+
* @param {boolean} [opts.stream=true] side calls want the whole answer at once
|
|
81
|
+
* @throws {BudgetExceededError} before sending anything, when a ceiling is spent
|
|
82
|
+
*/
|
|
83
|
+
// Free mode notices go to the console through this (index.js sets it).
|
|
84
|
+
const notifyServedModel = createServedModelNotifier();
|
|
85
|
+
let freeNoticeSink = null;
|
|
86
|
+
export function setFreeNoticeSink(fn) { freeNoticeSink = fn; }
|
|
87
|
+
|
|
88
|
+
export async function chatCompletion(messages, tools, onToken, {
|
|
89
|
+
signal,
|
|
90
|
+
model: modelOverride,
|
|
91
|
+
source = "agent",
|
|
92
|
+
maxTokens,
|
|
93
|
+
temperature,
|
|
94
|
+
seed,
|
|
95
|
+
responseFormat,
|
|
96
|
+
timeoutMs,
|
|
97
|
+
stream: streamOpt = true,
|
|
98
|
+
} = {}) {
|
|
99
|
+
// FIRST, before anything is built or sent. The ceilings are read live because
|
|
100
|
+
// /budget can move them mid-session; 0 means unlimited, which is the default.
|
|
101
|
+
// The run ceiling of an autonomous run lives in the ledger and is checked in
|
|
102
|
+
// the same call, so all three refuse in one place.
|
|
103
|
+
assertWithinBudget({ perAction: config.maxCostPerAction, session: config.sessionBudget });
|
|
104
|
+
|
|
105
|
+
// Strip internal metadata fields before sending to API
|
|
106
|
+
const cleanMessages = messages.map((m) => {
|
|
107
|
+
if (m._toolName || m._toolArgs || m._compressed) {
|
|
108
|
+
const { _toolName, _toolArgs, _compressed, ...clean } = m;
|
|
109
|
+
return clean;
|
|
110
|
+
}
|
|
111
|
+
return m;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Detect if messages contain images — disable streaming for image requests
|
|
115
|
+
const hasImages = cleanMessages.some((m) =>
|
|
116
|
+
Array.isArray(m.content) && m.content.some((p) => p.type === "image_url")
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const { adapter, provider } = getAdapter();
|
|
120
|
+
const apiKey = config.apiKey;
|
|
121
|
+
const stream = streamOpt && !hasImages;
|
|
122
|
+
|
|
123
|
+
const headers = adapter.buildHeaders(provider, apiKey);
|
|
124
|
+
const effectiveModel = modelOverride || config.model;
|
|
125
|
+
const body = adapter.buildBody(
|
|
126
|
+
cleanMessages,
|
|
127
|
+
effectiveModel,
|
|
128
|
+
tools,
|
|
129
|
+
maxTokens || config.maxResponseTokens,
|
|
130
|
+
stream,
|
|
131
|
+
{ temperature, seed, responseFormat },
|
|
132
|
+
);
|
|
133
|
+
// OpenRouter's provider preference, for the main model only. One model is
|
|
134
|
+
// served by several hosts that bill and answer differently (DigitalOcean
|
|
135
|
+
// billed cache reads at 27x Xiaomi's rate on 2026-09-28 and once answered a
|
|
136
|
+
// file-sorting task about Kubernetes). A side call on another model must not
|
|
137
|
+
// inherit "only these hosts": they may not serve it.
|
|
138
|
+
if (config.openrouterProvider && provider?.id === "openrouter" && effectiveModel === config.model) {
|
|
139
|
+
body.provider = config.openrouterProvider;
|
|
140
|
+
}
|
|
141
|
+
// Free mode: the main model's request names the fallbacks, and OpenRouter
|
|
142
|
+
// answers from the first that works (free-models.js).
|
|
143
|
+
Object.assign(body, freeRequestFields({ chain: config.freeChain, provider, model: effectiveModel, mainModel: config.model }));
|
|
144
|
+
const freeCall = !!config.freeChain?.includes(effectiveModel);
|
|
145
|
+
const url = adapter.getChatUrl(provider);
|
|
146
|
+
|
|
147
|
+
// Env-gated snapshot of the exact prompt going to the LLM. Used to compare
|
|
148
|
+
// Flint's wrapped prompt vs. raw model behaviour on the same task. Writes
|
|
149
|
+
// one JSON per call to ~/.flint/agent-prompt-snapshots/.
|
|
150
|
+
// Only the main loop: this dump is an instrument for comparing Flint's
|
|
151
|
+
// wrapped prompt against a raw one, and now the classifier, the fact
|
|
152
|
+
// extractor and the outcome ask come through here too. Mixing their payloads
|
|
153
|
+
// into the same directory would quietly change what the measurement means.
|
|
154
|
+
if (process.env.FLINT_DUMP_AGENT_PROMPT === "1" && source === "agent") {
|
|
155
|
+
try {
|
|
156
|
+
const { writeFileSync, existsSync, mkdirSync } = await import("node:fs");
|
|
157
|
+
const { join } = await import("node:path");
|
|
158
|
+
const { homedir } = await import("node:os");
|
|
159
|
+
const dir = join(homedir(), ".flint", "agent-prompt-snapshots");
|
|
160
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
161
|
+
const ts = Date.now();
|
|
162
|
+
const file = join(dir, `${ts}.json`);
|
|
163
|
+
writeFileSync(file, JSON.stringify({
|
|
164
|
+
ts,
|
|
165
|
+
model: effectiveModel,
|
|
166
|
+
url,
|
|
167
|
+
messages: cleanMessages,
|
|
168
|
+
tools: tools,
|
|
169
|
+
body_summary: {
|
|
170
|
+
message_count: cleanMessages.length,
|
|
171
|
+
tool_count: Array.isArray(tools) ? tools.length : 0,
|
|
172
|
+
total_chars: JSON.stringify(cleanMessages).length,
|
|
173
|
+
},
|
|
174
|
+
}, null, 2));
|
|
175
|
+
} catch {}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// RX-1 fix: hard timeout on API call (configurable).
|
|
179
|
+
// Prevents agent loop from hanging indefinitely when provider is slow
|
|
180
|
+
// or when streaming never terminates. Combines with external abort signal.
|
|
181
|
+
//
|
|
182
|
+
// 600s, not 120: the ceiling is on the whole call, and a reasoning model on a
|
|
183
|
+
// long context was cut off mid-answer and paid for again. The SWE pilot of
|
|
184
|
+
// 2026-09-26 lost three calls in a row that way and the turn ended with
|
|
185
|
+
// "API error (3x)". 600 is the OpenAI SDK default.
|
|
186
|
+
const API_TIMEOUT = timeoutMs || parseInt(process.env.AGENT_API_TIMEOUT || "600", 10) * 1000;
|
|
187
|
+
const { signal: effectiveSignal, cleanup } = combineAbortSignals(signal, API_TIMEOUT);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const response = await fetch(url, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers,
|
|
193
|
+
body: JSON.stringify(body),
|
|
194
|
+
signal: effectiveSignal,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
const text = await response.text();
|
|
199
|
+
// Surface rate-limit / quota errors explicitly so the user sees them.
|
|
200
|
+
// Fail-fast on daily / monthly quota (no retry would help).
|
|
201
|
+
if (response.status === 429) {
|
|
202
|
+
const retryAfter = response.headers.get("retry-after");
|
|
203
|
+
const err = new Error(
|
|
204
|
+
`Rate limit (429): ${text.slice(0, 300)}${retryAfter ? ` | retry-after: ${retryAfter}s` : ""}`
|
|
205
|
+
);
|
|
206
|
+
err.statusCode = 429;
|
|
207
|
+
err.isRateLimit = true;
|
|
208
|
+
err.retryAfter = retryAfter ? parseInt(retryAfter, 10) : null;
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
if (response.status === 401 || response.status === 403) {
|
|
212
|
+
const err = new Error(`Auth failed (${response.status}): check API key for provider '${provider?.id || "unknown"}'. ${text.slice(0, 200)}`);
|
|
213
|
+
err.statusCode = response.status;
|
|
214
|
+
err.isAuthError = true;
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
if (response.status === 402) {
|
|
218
|
+
const err = new Error(paymentRequiredMessage(freeCall) || `Payment required (402): insufficient credits. ${text.slice(0, 200)}`);
|
|
219
|
+
err.statusCode = 402;
|
|
220
|
+
err.isQuotaError = true;
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
// The status travels with the error: a caller deciding whether to retry
|
|
224
|
+
// needs to tell a client error from a transient one, and parsing it back
|
|
225
|
+
// out of the message is not a decision, it is a guess.
|
|
226
|
+
throw Object.assign(new Error(`API ${response.status}: ${text}`), { statusCode: response.status });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const result = !stream
|
|
230
|
+
? adapter.parseResponse(await response.json(), onToken)
|
|
231
|
+
: await adapter.parseStreamResponse(response, onToken);
|
|
232
|
+
|
|
233
|
+
// Free mode: today's count for the footer, and one line when a fallback
|
|
234
|
+
// answered instead of the primary.
|
|
235
|
+
if (freeCall) {
|
|
236
|
+
recordFreeRequest();
|
|
237
|
+
if (effectiveModel === config.model) {
|
|
238
|
+
const note = notifyServedModel(config.model, result.servedModel);
|
|
239
|
+
if (note) freeNoticeSink?.(note);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The caller does not record anything. Two sources were declared and never
|
|
244
|
+
// wired to a counter before this moved here, both times
|
|
245
|
+
// because recording was somebody else's job.
|
|
246
|
+
recordUsage(source, result.usage);
|
|
247
|
+
return result;
|
|
248
|
+
} finally {
|
|
249
|
+
cleanup();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function fetchModelInfo(modelId) {
|
|
254
|
+
// Use provider-aware model info fetching
|
|
255
|
+
const providerId = config.provider;
|
|
256
|
+
const info = await fetchModelInfoFromProvider(providerId, modelId);
|
|
257
|
+
if (info) return info;
|
|
258
|
+
|
|
259
|
+
// Fallback: try OpenRouter directly (backward compat)
|
|
260
|
+
if (providerId === "openrouter") {
|
|
261
|
+
try {
|
|
262
|
+
const res = await fetch("https://openrouter.ai/api/v1/models", {
|
|
263
|
+
headers: { Authorization: `Bearer ${config.apiKey}` },
|
|
264
|
+
});
|
|
265
|
+
if (!res.ok) return null;
|
|
266
|
+
const data = await res.json();
|
|
267
|
+
const model = data.data?.find((m) => m.id === modelId);
|
|
268
|
+
if (!model?.pricing) return null;
|
|
269
|
+
return {
|
|
270
|
+
prompt: parseFloat(model.pricing.prompt),
|
|
271
|
+
completion: parseFloat(model.pricing.completion),
|
|
272
|
+
contextLength: model.context_length || null,
|
|
273
|
+
};
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return null;
|
|
280
|
+
}
|