@minhpnq1807/contextos 0.1.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.
@@ -0,0 +1,263 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { tokenize } from "./analyzer.js";
6
+
7
+ const COMPLIANCE_STOPWORDS = new Set([
8
+ "always",
9
+ "before",
10
+ "cannot",
11
+ "cheaper",
12
+ "context",
13
+ "coverage",
14
+ "dependents",
15
+ "faster",
16
+ "fewer",
17
+ "gives",
18
+ "important",
19
+ "instead",
20
+ "never",
21
+ "project",
22
+ "rules",
23
+ "scanning",
24
+ "strictly",
25
+ "structural",
26
+ "that",
27
+ "this",
28
+ "tools",
29
+ "use",
30
+ "using",
31
+ "visible"
32
+ ]);
33
+
34
+ const RUNTIME_EVIDENCE_PATTERNS = [
35
+ /\bbefore\s+(using|reading|grep|glob|read|searching)/i,
36
+ /\bafter\s+(running|checking|reading)/i,
37
+ /\balways\s+use\s+(code-review-graph|mcp|memory|agentmemory)\b/i,
38
+ /\bmust\s+run\b/i,
39
+ /\bdo not\s+prefix\b/i,
40
+ /\bkhong\s+the\b/i
41
+ ];
42
+
43
+ const TEXT_EXTENSIONS = new Set([
44
+ ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".md", ".txt", ".yml", ".yaml", ".sql", ".py", ".sh", ".css", ".scss", ".html"
45
+ ]);
46
+ const MAX_STATUS_FILE_LINES = 400;
47
+ const MAX_STATUS_FILE_BYTES = 200_000;
48
+
49
+ export function readGitSnapshot({ cwd = process.cwd() } = {}) {
50
+ try {
51
+ const diff = execFileSync("git", ["diff", "HEAD"], {
52
+ cwd,
53
+ encoding: "utf8",
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ timeout: 1000
56
+ });
57
+ return parseGitDiff(diff);
58
+ } catch {
59
+ try {
60
+ const status = execFileSync("git", ["status", "--short"], {
61
+ cwd,
62
+ encoding: "utf8",
63
+ stdio: ["ignore", "pipe", "pipe"],
64
+ timeout: 1000
65
+ });
66
+ const changedFiles = parseStatusFiles(status);
67
+ return {
68
+ mode: "status",
69
+ changedFiles,
70
+ addedLines: collectStatusAddedLines({ cwd, changedFiles }),
71
+ warnings: ["git diff HEAD unavailable; used git status and readable file content"]
72
+ };
73
+ } catch {
74
+ return {
75
+ mode: "none",
76
+ changedFiles: [],
77
+ addedLines: [],
78
+ warnings: ["git unavailable; skipped ContextOS measurement"]
79
+ };
80
+ }
81
+ }
82
+ }
83
+
84
+ function parseStatusFiles(status) {
85
+ return String(status || "")
86
+ .split(/\r?\n/)
87
+ .map((line) => line.slice(3).trim())
88
+ .filter(Boolean)
89
+ .map((file) => file.includes(" -> ") ? file.split(" -> ").at(-1).trim() : file);
90
+ }
91
+
92
+ function collectStatusAddedLines({ cwd, changedFiles }) {
93
+ const addedLines = [];
94
+ for (const file of changedFiles) {
95
+ if (addedLines.length >= MAX_STATUS_FILE_LINES) break;
96
+ if (!isReadableTextFile(file)) continue;
97
+ const fullPath = path.join(cwd, file);
98
+ let stat;
99
+ try {
100
+ stat = fs.statSync(fullPath);
101
+ } catch {
102
+ continue;
103
+ }
104
+ if (!stat.isFile() || stat.size > MAX_STATUS_FILE_BYTES) continue;
105
+ let content = "";
106
+ try {
107
+ content = fs.readFileSync(fullPath, "utf8");
108
+ } catch {
109
+ continue;
110
+ }
111
+ const lines = content.split(/\r?\n/).slice(0, MAX_STATUS_FILE_LINES - addedLines.length);
112
+ lines.forEach((line, index) => {
113
+ if (line.trim()) addedLines.push({ file, line: index + 1, content: line });
114
+ });
115
+ }
116
+ return addedLines;
117
+ }
118
+
119
+ function isReadableTextFile(file) {
120
+ const base = path.basename(file);
121
+ if (base === "AGENTS.md" || base === "README.md" || base === "package.json") return true;
122
+ return TEXT_EXTENSIONS.has(path.extname(file));
123
+ }
124
+
125
+ export function parseGitDiff(diff) {
126
+ const changedFiles = new Set();
127
+ const addedLines = [];
128
+ let currentFile = null;
129
+ let newLine = null;
130
+
131
+ for (const line of String(diff || "").split(/\r?\n/)) {
132
+ const fileMatch = line.match(/^\+\+\+ b\/(.+)$/);
133
+ if (fileMatch) {
134
+ currentFile = fileMatch[1];
135
+ changedFiles.add(currentFile);
136
+ continue;
137
+ }
138
+ const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
139
+ if (hunkMatch) {
140
+ newLine = Number(hunkMatch[1]);
141
+ continue;
142
+ }
143
+ if (line.startsWith("+") && !line.startsWith("+++")) {
144
+ addedLines.push({ file: currentFile, line: newLine, content: line.slice(1) });
145
+ if (typeof newLine === "number") newLine += 1;
146
+ } else if (!line.startsWith("-") && typeof newLine === "number") {
147
+ newLine += 1;
148
+ }
149
+ }
150
+
151
+ return {
152
+ mode: "diff",
153
+ changedFiles: [...changedFiles],
154
+ addedLines,
155
+ warnings: []
156
+ };
157
+ }
158
+
159
+ export function checkCompliance({ rules = [], addedLines = [] } = {}) {
160
+ const results = [];
161
+
162
+ for (const rule of rules) {
163
+ const lower = rule.content.toLowerCase();
164
+ const keywords = extractComplianceKeywords(rule.content);
165
+ const isRuntimeOnly = needsRuntimeEvidence(rule.content);
166
+ const kind = lower.includes("no ") || lower.includes("never") || lower.includes("khong")
167
+ ? "forbidden"
168
+ : "required";
169
+
170
+ if (!keywords.length || !addedLines.length) {
171
+ results.push({
172
+ rule,
173
+ status: "unknown",
174
+ kind,
175
+ keywords,
176
+ evidence: !addedLines.length ? "no added lines in git diff" : "no concrete compliance keywords found"
177
+ });
178
+ continue;
179
+ }
180
+
181
+ if (isRuntimeOnly) {
182
+ results.push({
183
+ rule,
184
+ status: "unknown",
185
+ kind: "runtime",
186
+ keywords,
187
+ evidence: "requires runtime/tool-call evidence, not git diff evidence"
188
+ });
189
+ continue;
190
+ }
191
+
192
+ if (kind === "forbidden") {
193
+ const violation = findKeywordEvidence(addedLines, keywords);
194
+ const violated = violation?.keyword;
195
+ results.push(violated
196
+ ? {
197
+ rule,
198
+ status: "ignored",
199
+ kind,
200
+ keywords,
201
+ evidence: `found forbidden ${violated} in ${formatLineRef(violation.line)}`,
202
+ matchedLines: [violation.line]
203
+ }
204
+ : {
205
+ rule,
206
+ status: "followed",
207
+ kind,
208
+ keywords,
209
+ evidence: `forbidden keywords not found: ${keywords.join(", ")}`
210
+ });
211
+ continue;
212
+ }
213
+
214
+ const match = findKeywordEvidence(addedLines, keywords);
215
+ const matched = match?.keyword;
216
+ results.push(matched
217
+ ? {
218
+ rule,
219
+ status: "followed",
220
+ kind,
221
+ keywords,
222
+ evidence: `found required ${matched} in ${formatLineRef(match.line)}`,
223
+ matchedLines: [match.line]
224
+ }
225
+ : {
226
+ rule,
227
+ status: "unknown",
228
+ kind,
229
+ keywords,
230
+ evidence: `expected keywords not visible in added lines: ${keywords.join(", ")}`
231
+ });
232
+ }
233
+
234
+ return results;
235
+ }
236
+
237
+ function needsRuntimeEvidence(content) {
238
+ return RUNTIME_EVIDENCE_PATTERNS.some((pattern) => pattern.test(content));
239
+ }
240
+
241
+ function findKeywordEvidence(lines, keywords) {
242
+ for (const keyword of keywords) {
243
+ const normalized = keyword.toLowerCase();
244
+ const line = lines.find((item) => String(item.content || "").toLowerCase().includes(normalized));
245
+ if (line) return { keyword, line };
246
+ }
247
+ return null;
248
+ }
249
+
250
+ function formatLineRef(line) {
251
+ if (!line?.file) return "diff";
252
+ return typeof line.line === "number" ? `${line.file}:${line.line}` : line.file;
253
+ }
254
+
255
+ function extractComplianceKeywords(content) {
256
+ const explicit = [...String(content || "").matchAll(/`([^`]+)`/g)].map((match) => match[1]);
257
+ const tokens = tokenize(content).filter((token) => {
258
+ if (token.length < 3) return false;
259
+ if (COMPLIANCE_STOPWORDS.has(token)) return false;
260
+ return /[./_-]/.test(token) || /^[a-z][a-z0-9]*$/i.test(token);
261
+ });
262
+ return [...new Set([...explicit, ...tokens])].slice(0, 8);
263
+ }
@@ -0,0 +1,72 @@
1
+ import { scheduleContext } from "./scheduler.js";
2
+ import { appendJsonLine, writeJsonFile } from "./fs-utils.js";
3
+ import { callCtxScoreContext } from "./ctx-mcp-client.js";
4
+ import path from "node:path";
5
+
6
+ export async function handlePromptPayload(
7
+ payload,
8
+ {
9
+ dataPath,
10
+ historyPath,
11
+ now = new Date(),
12
+ started = Date.now(),
13
+ injectContext = process.env.CONTEXTOS_INJECT !== "0",
14
+ scoreContextClient = callCtxScoreContext
15
+ } = {}
16
+ ) {
17
+ const prompt = payload.prompt || payload.message || payload.user_prompt || "";
18
+ const cwd = payload.cwd || payload.working_directory || process.cwd();
19
+ const openFiles = payload.openFiles || payload.open_files || payload.files || [];
20
+ const dataDir = dataPath ? path.dirname(dataPath) : undefined;
21
+
22
+ const scored = await scoreContextClient({
23
+ cwd,
24
+ prompt,
25
+ openFiles,
26
+ maxFiles: 3
27
+ }, {
28
+ dataDir,
29
+ timeoutMs: Number(process.env.CONTEXTOS_MCP_BRIDGE_TIMEOUT_MS || 1000)
30
+ });
31
+
32
+ if (scored.error) throw new Error(scored.error);
33
+ const scoredRules = scored.scoredRules || [];
34
+ const relevantFiles = (scored.suggestedFiles || []).slice(0, 3);
35
+ const scheduled = scheduleContext({ rules: scoredRules, relevantFiles });
36
+
37
+ const runtime = {
38
+ at: now.toISOString(),
39
+ cwd,
40
+ prompt,
41
+ rules: scoredRules,
42
+ scoring: {
43
+ keyword: true,
44
+ mcp: scored.telemetry || {}
45
+ },
46
+ relevantFiles,
47
+ telemetry: {
48
+ ...(scored.telemetry || {}),
49
+ rulesInjected: (scheduled.highRules?.length || 0) + (scheduled.midRules?.length || 0),
50
+ filesSuggested: relevantFiles.length
51
+ },
52
+ scheduled,
53
+ injected: injectContext,
54
+ elapsedMs: Date.now() - started
55
+ };
56
+
57
+ try {
58
+ if (dataPath) writeJsonFile(dataPath, runtime);
59
+ if (historyPath) appendJsonLine(historyPath, runtime);
60
+ } catch {
61
+ // Context injection is the critical path; diagnostics are best-effort.
62
+ }
63
+
64
+ return {
65
+ continue: true,
66
+ suppressOutput: true,
67
+ hookSpecificOutput: {
68
+ hookEventName: "UserPromptSubmit",
69
+ additionalContext: injectContext ? scheduled.additionalContext : ""
70
+ }
71
+ };
72
+ }
@@ -0,0 +1,57 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { safeReadText } from "./fs-utils.js";
5
+
6
+ function findProjectRoot(cwd) {
7
+ let current = path.resolve(cwd);
8
+ while (true) {
9
+ if (fs.existsSync(path.join(current, ".git"))) return current;
10
+ const parent = path.dirname(current);
11
+ if (parent === current) return path.resolve(cwd);
12
+ current = parent;
13
+ }
14
+ }
15
+
16
+ function pathChain(root, cwd) {
17
+ const resolvedRoot = path.resolve(root);
18
+ const resolvedCwd = path.resolve(cwd);
19
+ const relative = path.relative(resolvedRoot, resolvedCwd);
20
+ const parts = relative && !relative.startsWith("..") ? relative.split(path.sep) : [];
21
+ const chain = [resolvedRoot];
22
+ let current = resolvedRoot;
23
+ for (const part of parts) {
24
+ current = path.join(current, part);
25
+ chain.push(current);
26
+ }
27
+ return chain;
28
+ }
29
+
30
+ export function readAgentsChain({ cwd = process.cwd(), home = process.env.HOME } = {}) {
31
+ const files = [];
32
+ if (home) files.push(path.join(home, ".codex", "AGENTS.md"));
33
+
34
+ const root = findProjectRoot(cwd);
35
+ for (const directory of pathChain(root, cwd)) {
36
+ files.push(path.join(directory, "AGENTS.md"));
37
+ }
38
+
39
+ const sources = [];
40
+ const sections = [];
41
+ const seen = new Set();
42
+
43
+ for (const filePath of files) {
44
+ if (seen.has(filePath) || !fs.existsSync(filePath)) continue;
45
+ seen.add(filePath);
46
+ const content = safeReadText(filePath).trim();
47
+ if (!content) continue;
48
+ sources.push(filePath);
49
+ sections.push(`## Source: ${filePath}\n${content}`);
50
+ }
51
+
52
+ return {
53
+ root,
54
+ sources,
55
+ content: sections.join("\n\n")
56
+ };
57
+ }
@@ -0,0 +1,105 @@
1
+ export function buildReport({ cwd, prompt, relevantFiles, scheduled, gitSnapshot, compliance }) {
2
+ const followed = compliance.filter((item) => item.status === "followed");
3
+ const ignored = compliance.filter((item) => item.status === "ignored");
4
+ const unknown = compliance.filter((item) => item.status === "unknown");
5
+ const measured = followed.length + ignored.length;
6
+ const efficiencyScore = measured ? Math.round((followed.length / measured) * 100) : null;
7
+
8
+ return {
9
+ at: new Date().toISOString(),
10
+ cwd,
11
+ prompt,
12
+ injectedRuleCount: (scheduled?.highRules?.length || 0) + (scheduled?.midRules?.length || 0),
13
+ relevantFiles,
14
+ changedFiles: gitSnapshot.changedFiles,
15
+ warnings: gitSnapshot.warnings || [],
16
+ followed,
17
+ ignored,
18
+ unknown,
19
+ measuredRuleCount: measured,
20
+ unknownRuleCount: unknown.length,
21
+ efficiencyScore
22
+ };
23
+ }
24
+
25
+ export function formatReport(report) {
26
+ const lines = [];
27
+ lines.push("ContextOS report");
28
+ lines.push(`Efficiency: ${report.efficiencyScore == null ? "unknown" : `${report.efficiencyScore}%`}`);
29
+ lines.push(`Injected rules: ${report.injectedRuleCount || 0}`);
30
+ lines.push(`Rule outcomes: ${report.followed?.length || 0} followed, ${report.ignored?.length || 0} ignored, ${report.unknown?.length || 0} unknown`);
31
+ lines.push(`Measured rules: ${report.measuredRuleCount ?? ((report.followed?.length || 0) + (report.ignored?.length || 0))}`);
32
+ lines.push(`Changed files: ${report.changedFiles?.length ? report.changedFiles.join(", ") : "none detected"}`);
33
+
34
+ if (report.relevantFiles?.length) {
35
+ lines.push(`Suggested files: ${report.relevantFiles.map((file) => file.path).join(", ")}`);
36
+ }
37
+
38
+ for (const warning of report.warnings || []) lines.push(`Warning: ${warning}`);
39
+
40
+ appendBucket(lines, "Followed", report.followed);
41
+ appendBucket(lines, "Ignored", report.ignored);
42
+ appendBucket(lines, "Unknown", report.unknown);
43
+
44
+ if (report.ignored?.length) {
45
+ lines.push(`Suggestion: fix ignored rule evidence first: ${truncate(report.ignored[0].rule?.content || "", 70)}`);
46
+ } else if (report.unknown?.length && !(report.followed?.length || report.ignored?.length)) {
47
+ lines.push("Suggestion: these rules need runtime evidence or more concrete keywords before ContextOS can score them from git diff.");
48
+ }
49
+
50
+ return lines.join("\n");
51
+ }
52
+
53
+ export function formatEvidence(report) {
54
+ const lines = [];
55
+ lines.push("ContextOS evidence");
56
+ lines.push(`Prompt: ${report.prompt || "(empty)"}`);
57
+ lines.push(`Efficiency: ${report.efficiencyScore == null ? "unknown" : `${report.efficiencyScore}%`}`);
58
+ lines.push(`Changed files: ${report.changedFiles?.length ? report.changedFiles.join(", ") : "none detected"}`);
59
+
60
+ for (const warning of report.warnings || []) lines.push(`Warning: ${warning}`);
61
+
62
+ const items = [
63
+ ...(report.followed || []).map((item) => ({ ...item, status: "followed" })),
64
+ ...(report.ignored || []).map((item) => ({ ...item, status: "ignored" })),
65
+ ...(report.unknown || []).map((item) => ({ ...item, status: "unknown" }))
66
+ ];
67
+
68
+ if (!items.length) {
69
+ lines.push("No rule evidence captured for the last report.");
70
+ lines.push("Run a task that schedules at least one relevant rule, then let the Stop hook finish.");
71
+ return lines.join("\n");
72
+ }
73
+
74
+ items.forEach((item, index) => {
75
+ lines.push("");
76
+ lines.push(`${index + 1}. ${item.status.toUpperCase()}`);
77
+ lines.push(`Rule: ${item.rule?.content || "(missing rule)"}`);
78
+ if (item.rule?.sourcePath) lines.push(`Source: ${item.rule.sourcePath}`);
79
+ if (typeof item.rule?.score === "number") lines.push(`Score: ${item.rule.score.toFixed(2)}`);
80
+ if (item.kind) lines.push(`Kind: ${item.kind}`);
81
+ if (item.keywords?.length) lines.push(`Keywords: ${item.keywords.join(", ")}`);
82
+ lines.push(`Evidence: ${item.evidence || "(none)"}`);
83
+ for (const line of item.matchedLines || []) {
84
+ const where = line.file ? `${line.file}${typeof line.line === "number" ? `:${line.line}` : ""}` : "diff";
85
+ lines.push(`Matched line: ${where} ${truncate(line.content || "", 140)}`);
86
+ }
87
+ });
88
+
89
+ return lines.join("\n");
90
+ }
91
+
92
+ function appendBucket(lines, label, items = []) {
93
+ if (!items.length) return;
94
+ lines.push(`${label}:`);
95
+ for (const item of items.slice(0, 5)) {
96
+ lines.push(`- Rule: ${truncate(item.rule.content, 68)}`);
97
+ lines.push(` Evidence: ${truncate(item.evidence, 64)}`);
98
+ }
99
+ if (items.length > 5) lines.push(`- ... ${items.length - 5} more`);
100
+ }
101
+
102
+ function truncate(value, max) {
103
+ const normalized = String(value || "").replace(/\s+/g, " ").trim();
104
+ return normalized.length > max ? `${normalized.slice(0, max - 3)}...` : normalized;
105
+ }
@@ -0,0 +1,45 @@
1
+ const MAX_CONTEXT_CHARS = 4000;
2
+
3
+ export function scheduleContext({ rules = [], relevantFiles = [], maxChars = MAX_CONTEXT_CHARS } = {}) {
4
+ const high = rules.filter((rule) => rule.score >= 0.5);
5
+ const mid = rules.filter((rule) => rule.score >= 0.1 && rule.score < 0.5);
6
+ const dropped = rules.filter((rule) => rule.score < 0.1);
7
+
8
+ const sections = [];
9
+ if (high.length) {
10
+ sections.push(section("Critical ContextOS rules", high.slice(0, 8).map(formatRule)));
11
+ }
12
+ if (relevantFiles.length) {
13
+ sections.push(section("Suggested files to check", relevantFiles.map((file) => `- ${file.path}`)));
14
+ }
15
+ if (mid.length) {
16
+ sections.push(section("Additional relevant rules", mid.slice(0, 8).map(formatRule)));
17
+ }
18
+ if (high.length) {
19
+ sections.push(section("ContextOS reminders", high.slice(0, 5).map(formatRule)));
20
+ }
21
+
22
+ const additionalContext = trimToLimit(sections.filter(Boolean).join("\n\n"), maxChars);
23
+ return {
24
+ highRules: high,
25
+ midRules: mid,
26
+ droppedRules: dropped,
27
+ relevantFiles,
28
+ additionalContext
29
+ };
30
+ }
31
+
32
+ function section(title, lines) {
33
+ if (!lines.length) return "";
34
+ return `## ${title}\n${lines.join("\n")}`;
35
+ }
36
+
37
+ function formatRule(rule) {
38
+ const source = rule.sourcePath && rule.sourcePath !== "unknown" ? ` (${rule.sourcePath})` : "";
39
+ return `- ${rule.content}${source}`;
40
+ }
41
+
42
+ function trimToLimit(value, maxChars) {
43
+ if (value.length <= maxChars) return value;
44
+ return `${value.slice(0, Math.max(0, maxChars - 80)).trimEnd()}\n\n[ContextOS truncated context to ${maxChars} chars]`;
45
+ }
@@ -0,0 +1,55 @@
1
+ import path from "node:path";
2
+
3
+ import { readAgentsChain } from "./reader.js";
4
+ import { parseRules, scoreRules, findRelevantFiles } from "./analyzer.js";
5
+ import { enhanceRuleScoresWithEmbeddings } from "./embedding-scorer.js";
6
+
7
+ export async function scoreContext({
8
+ cwd = process.cwd(),
9
+ prompt = "",
10
+ openFiles = [],
11
+ dataDir,
12
+ maxFiles = 5,
13
+ embeddingTimeoutMs = 5000,
14
+ fileEmbeddingTimeoutMs = Number(process.env.CONTEXTOS_FILE_EMBEDDING_TIMEOUT_MS || 80)
15
+ } = {}) {
16
+ const started = Date.now();
17
+ const merged = readAgentsChain({ cwd });
18
+ const parsedRules = parseRules(merged.content);
19
+ const baseScoredRules = scoreRules(parsedRules, prompt, openFiles);
20
+ const embedding = await enhanceRuleScoresWithEmbeddings(baseScoredRules, prompt, {
21
+ dataDir,
22
+ sources: merged.sources,
23
+ timeoutMs: embeddingTimeoutMs,
24
+ allowRemote: false
25
+ });
26
+ const scoredRules = embedding.rules;
27
+ const suggestedFiles = await findRelevantFiles({
28
+ cwd,
29
+ task: prompt,
30
+ rules: scoredRules,
31
+ dataDir,
32
+ limit: maxFiles,
33
+ fileEmbeddingTimeoutMs,
34
+ fileEmbeddingOptions: {
35
+ allowRemote: false
36
+ }
37
+ });
38
+
39
+ return {
40
+ cwd,
41
+ prompt,
42
+ scoredRules,
43
+ suggestedFiles,
44
+ telemetry: {
45
+ elapsedMs: Date.now() - started,
46
+ modelStatus: embedding.status,
47
+ model: embedding.model,
48
+ cachePath: embedding.cachePath,
49
+ rulesParsed: parsedRules.length,
50
+ rulesInjected: scoredRules.filter((rule) => Number(rule.score || 0) >= 0.1).length,
51
+ filesSuggested: suggestedFiles.length,
52
+ sources: merged.sources.map((source) => path.relative(cwd, source))
53
+ }
54
+ };
55
+ }