@sdsrs/code-graph 0.157.0 → 0.159.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/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/post-grep-inject.js +10 -0
- package/claude-plugin/scripts/pre-grep-guide.js +101 -8
- package/claude-plugin/scripts/pre-read-guide.js +35 -18
- package/claude-plugin/templates/code-graph-snapshot.yml +1 -1
- package/package.json +6 -6
|
@@ -46,6 +46,8 @@ const {
|
|
|
46
46
|
extractCgFlags,
|
|
47
47
|
cgFlagSet,
|
|
48
48
|
extractSearchPath,
|
|
49
|
+
bareSourceTarget,
|
|
50
|
+
firstShellClause,
|
|
49
51
|
normalizeCommandPaths,
|
|
50
52
|
rebaseRelativePaths,
|
|
51
53
|
resolveProjectRoot,
|
|
@@ -263,6 +265,14 @@ function runMain() {
|
|
|
263
265
|
markCooldown(rawCmd, root);
|
|
264
266
|
|
|
265
267
|
const { segment, block } = found;
|
|
268
|
+
// D#73 — a bare dir names `<shell cwd>/src`. From a subdirectory shell (same
|
|
269
|
+
// guard as pre-grep-guide's runMain), or after anything earlier in the same
|
|
270
|
+
// command, that is not provably the root's src: round 2 found `cd x && grep`,
|
|
271
|
+
// round 3 reproduced 19 more forms that move the shell (`builtin cd`, `if cd`,
|
|
272
|
+
// `{ cd …; }`, `\cd`, `popd`, `eval`, a function). A denylist of those never
|
|
273
|
+
// closes, so a bare dir is answered only when the grep is the FIRST segment.
|
|
274
|
+
if (bareSourceTarget(firstShellClause(segment))
|
|
275
|
+
&& (relPrefix || splitTopLevelSegments(cmd)[0] !== segment)) return;
|
|
266
276
|
// Run the answer exactly like the deny path.
|
|
267
277
|
const rawPattern = pickBlockPattern(segment);
|
|
268
278
|
// Grep-response gate (2026-07-03 audit: 18/18 injects were 0 CONSUMED because they
|
|
@@ -56,7 +56,7 @@ const {
|
|
|
56
56
|
runGrepAnswer, runShowAnswer, sanitizeSearchPath, buildGrepArgs, formatCgCommand, shellQuoteArg,
|
|
57
57
|
resolveAnswerBinary,
|
|
58
58
|
} = require('./cg-answer');
|
|
59
|
-
const { emitPreToolRewrite } = require('./hook-emit');
|
|
59
|
+
const { emitPreToolRewrite, emitPreToolContext, MAX_INJECTED_BYTES } = require('./hook-emit');
|
|
60
60
|
|
|
61
61
|
// --- Pure logic (testable) ---
|
|
62
62
|
|
|
@@ -89,6 +89,30 @@ const SRC_PREFIXES =
|
|
|
89
89
|
const SRC_PATH = new RegExp(`(?:^|\\s|["'])(${SRC_PREFIXES})/`);
|
|
90
90
|
// Anchored variant for whole-token matching in extractSearchPath.
|
|
91
91
|
const SRC_PATH_TOKEN = new RegExp(`^(?:\\./)?(${SRC_PREFIXES})/`);
|
|
92
|
+
// A path operand the rewrite grammar proved: a bare prefix word (`src`), or a
|
|
93
|
+
// `./`-rooted one with any subpath (`./src`, `./src/x/`), which SRC_PATH's
|
|
94
|
+
// lookbehind never matched.
|
|
95
|
+
const SRC_BARE_TOKEN = new RegExp(`^(?:(?:${SRC_PREFIXES})|\\./(?:${SRC_PREFIXES})(?:/.*)?)$`);
|
|
96
|
+
|
|
97
|
+
// D#73 — a source dir written as a bare word (`grep -rn X src`, `rg X tests`,
|
|
98
|
+
// `./src`): the shape models write when a prompt says "under src/", and it
|
|
99
|
+
// matched nothing before. Recognized ONLY where rewritePlan's grammar proves the
|
|
100
|
+
// word is the command's path operand. A regex over the text cannot tell: these
|
|
101
|
+
// prefixes are English words (`server`, `tasks`) and import-path fragments
|
|
102
|
+
// inside patterns, flag values (`rg -t cmd`, `ag --ignore tests`), halves of a
|
|
103
|
+
// two-path or `$(…)` search, or the pattern itself (`grep -n tasks f.py`) —
|
|
104
|
+
// pre-ship review round 1 reproduced each as a wrong hint or inject. Callers
|
|
105
|
+
// in a subdirectory shell must not use it on an un-rebased word: there a bare
|
|
106
|
+
// `src` is `<cwd>/src`, not the root's (see runMain). `.` and no path at all
|
|
107
|
+
// stay out: they reach non-source files, and grep -r ignores .gitignore where
|
|
108
|
+
// cg does not.
|
|
109
|
+
function bareSourceTarget(clause) {
|
|
110
|
+
const plan = rewritePlan(clause);
|
|
111
|
+
return plan && plan.target !== undefined && SRC_BARE_TOKEN.test(plan.target) ? plan.target : null;
|
|
112
|
+
}
|
|
113
|
+
function namesSourcePath(clause) {
|
|
114
|
+
return SRC_PATH.test(clause) || bareSourceTarget(clause) !== null;
|
|
115
|
+
}
|
|
92
116
|
const PIPE_INTO_GREP = new RegExp(`\\|\\s*(?:${GREP_VERB})\\b`);
|
|
93
117
|
const CG_INVOKED = /\bcode-graph-mcp\b/;
|
|
94
118
|
// File argument(s) that end in a config/lockfile/data extension. If, after removing
|
|
@@ -184,11 +208,11 @@ function shouldHint(cmd) {
|
|
|
184
208
|
// v0.96 — the source-path gate must see ONLY the grep's own args, not a path in
|
|
185
209
|
// a non-grep tail (`grep X skills/a.py; wc scripts/b` must not fire on scripts/b).
|
|
186
210
|
const clause = firstShellClause(cmd);
|
|
187
|
-
if (!
|
|
211
|
+
if (!namesSourcePath(clause)) return false; // not against indexed source tree
|
|
188
212
|
// If a config file appears AND no source path remains after stripping it, skip.
|
|
189
213
|
if (CONFIG_TARGET_ONLY.test(clause)) {
|
|
190
214
|
const stripped = clause.replace(CONFIG_TARGET_STRIP, ' ');
|
|
191
|
-
if (!
|
|
215
|
+
if (!namesSourcePath(stripped)) return false;
|
|
192
216
|
}
|
|
193
217
|
return true;
|
|
194
218
|
}
|
|
@@ -679,6 +703,13 @@ function extractSearchPath(cmd) {
|
|
|
679
703
|
// v0.96 — scope to the grep's own clause so the answer is never scoped to a
|
|
680
704
|
// path in a non-grep tail (the file the user actually grepped is the only one
|
|
681
705
|
// the "already ran for you" answer may claim to have searched).
|
|
706
|
+
// D#73 — when the grammar proves a bare/`./` source operand, it is the scope.
|
|
707
|
+
// It must win over the text scan below, which takes the first path-shaped
|
|
708
|
+
// token and so can take a PATTERN (`grep -rn "./src/x" tests`) or cut a
|
|
709
|
+
// quoted operand at its space (pre-ship review round 2 B1, B2). The grammar
|
|
710
|
+
// rejects `..`, so no traversal reaches this return.
|
|
711
|
+
const bare = bareSourceTarget(firstShellClause(cmd));
|
|
712
|
+
if (bare) return bare;
|
|
682
713
|
for (const raw of firstShellClause(cmd).split(/\s+/)) {
|
|
683
714
|
const token = raw.replace(/^["']|["']$/g, '');
|
|
684
715
|
if (!token || token.startsWith('-')) continue;
|
|
@@ -711,6 +742,11 @@ function countNamedPaths(cmd, patterns) {
|
|
|
711
742
|
if (pats.has(tok)) continue; // the search pattern, not a path
|
|
712
743
|
if (tok.includes('/') || /\.[A-Za-z0-9]{1,6}$/.test(tok)) n++; // dir-sep or file extension
|
|
713
744
|
}
|
|
745
|
+
// D#73 — the grammar's bare-dir operand is a named path too, counted like
|
|
746
|
+
// its `src/` spelling (whose loop pass counts it). The grammar admits one
|
|
747
|
+
// path operand, so this adds at most one.
|
|
748
|
+
const bare = bareSourceTarget(firstShellClause(cmd));
|
|
749
|
+
if (bare && !bare.includes('/') && !/\.[A-Za-z0-9]{1,6}$/.test(bare)) n++;
|
|
714
750
|
return n;
|
|
715
751
|
}
|
|
716
752
|
|
|
@@ -1141,6 +1177,43 @@ function isAnswerDisabled(env = process.env) {
|
|
|
1141
1177
|
return env.CODE_GRAPH_NO_ANSWER_IN_DENY === '1';
|
|
1142
1178
|
}
|
|
1143
1179
|
|
|
1180
|
+
// Smallest per-dir share of the context cap worth spending on an overview answer.
|
|
1181
|
+
const MIN_FANOUT_ANSWER_BYTES = 400;
|
|
1182
|
+
|
|
1183
|
+
/// Byte budget for each overview answer when several dirs fire in one command
|
|
1184
|
+
/// (undefined for one dir: the answer keeps its own default). Every hint first
|
|
1185
|
+
/// pays for its own lines — an answer's header, footer and separator are about
|
|
1186
|
+
/// 175 bytes plus the dir name twice, the one-line advice about 165 plus the
|
|
1187
|
+
/// name three times — reserved as 200 plus the name three times, and the
|
|
1188
|
+
/// answers share what is left of the cap. A share under MIN_FANOUT_ANSWER_BYTES
|
|
1189
|
+
/// returns 0: every dir then gets the one-line advice instead.
|
|
1190
|
+
function fanoutAnswerBudget(dirs) {
|
|
1191
|
+
if (dirs.length < 2) return undefined;
|
|
1192
|
+
const reserved = dirs.reduce((sum, d) => sum + 200 + 3 * Buffer.byteLength(d, 'utf8'), 0);
|
|
1193
|
+
const share = Math.floor((MAX_INJECTED_BYTES - reserved) / dirs.length);
|
|
1194
|
+
return share >= MIN_FANOUT_ANSWER_BYTES ? share : 0;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/// The fired dirs' hints as one context text. Every dir was marked delivered,
|
|
1198
|
+
/// so each must be named in it: once the next hint would leave no room for a
|
|
1199
|
+
/// line naming the dirs still to come, that line replaces the remaining hints.
|
|
1200
|
+
function joinFanoutHints(dirs, hints) {
|
|
1201
|
+
const rest = (from) => `[code-graph] 5+ Reads also into ${dirs.slice(from).map((d) => `${d}/`).join(', ')} — \`code-graph-mcp overview <dir>/\` gives each dir's symbols+callers in one call.`;
|
|
1202
|
+
const out = [];
|
|
1203
|
+
let used = 0;
|
|
1204
|
+
for (let i = 0; i < hints.length; i++) {
|
|
1205
|
+
const need = Buffer.byteLength(hints[i], 'utf8') + (out.length ? 2 : 0);
|
|
1206
|
+
const tail = i + 1 < hints.length ? Buffer.byteLength(rest(i + 1), 'utf8') + 2 : 0;
|
|
1207
|
+
if (used + need + tail > MAX_INJECTED_BYTES) {
|
|
1208
|
+
out.push(rest(i));
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1211
|
+
out.push(hints[i]);
|
|
1212
|
+
used += need;
|
|
1213
|
+
}
|
|
1214
|
+
return out.join('\n\n');
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1144
1217
|
function runMain() {
|
|
1145
1218
|
if (isSilenced()) return;
|
|
1146
1219
|
// v0.48 — process.cwd() follows the persistent shell; resolve the project
|
|
@@ -1162,18 +1235,34 @@ function runMain() {
|
|
|
1162
1235
|
// v0.49 — sed-range reads count toward the read-fanout state (the Read hook
|
|
1163
1236
|
// never sees Bash-side file reads). A fired fanout hint already delivered an
|
|
1164
1237
|
// overview — skip grep hinting for this command to avoid double output.
|
|
1238
|
+
//
|
|
1239
|
+
// Every dir that fired goes into ONE envelope. Claude Code parses a hook's
|
|
1240
|
+
// stdout as a single JSON value: two envelopes (two dirs crossing in one
|
|
1241
|
+
// compound command) were rejected whole, so the model saw neither hint while
|
|
1242
|
+
// the state recorded both as delivered. The shared context cap is split
|
|
1243
|
+
// between the answers, so a large first overview cannot truncate the second
|
|
1244
|
+
// dir out of the envelope.
|
|
1245
|
+
//
|
|
1246
|
+
// No permission decision: this is a Bash call, and `allow` would skip the
|
|
1247
|
+
// user's prompt for the WHOLE command (`sed -n 1,5p a.js; <anything>`). The
|
|
1248
|
+
// read hook's allow envelope used to leak here because pre-read-guide wrote
|
|
1249
|
+
// it from inside the shared tracker, out of hook-emit.test.js's allowlist view.
|
|
1165
1250
|
const sedTargets = extractSedReadTargets(rawCmd);
|
|
1166
1251
|
if (sedTargets.length > 0) {
|
|
1167
1252
|
const readGuide = require('./pre-read-guide');
|
|
1168
|
-
|
|
1253
|
+
const firedDirs = [];
|
|
1169
1254
|
for (const t of sedTargets) {
|
|
1170
1255
|
if (!readGuide.isSourceFile(t)) continue;
|
|
1171
1256
|
const abs = path.isAbsolute(t) ? t : path.resolve(shellCwd, t);
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1257
|
+
const dir = readGuide.trackRead(root, path.relative(root, abs));
|
|
1258
|
+
if (dir !== null) firedDirs.push(dir); // a dir fires once: markHint starts its cooldown
|
|
1259
|
+
}
|
|
1260
|
+
if (firedDirs.length > 0) {
|
|
1261
|
+
const maxBytes = fanoutAnswerBudget(firedDirs);
|
|
1262
|
+
const hints = firedDirs.map((dir) => readGuide.buildFanoutHint(root, dir, { maxBytes }));
|
|
1263
|
+
process.stdout.write(emitPreToolContext(joinFanoutHints(firedDirs, hints)) + '\n');
|
|
1264
|
+
return;
|
|
1175
1265
|
}
|
|
1176
|
-
if (fanoutFired) return;
|
|
1177
1266
|
}
|
|
1178
1267
|
|
|
1179
1268
|
// v0.47.1 — match against the root-stripped form so absolute paths under the
|
|
@@ -1183,6 +1272,9 @@ function runMain() {
|
|
|
1183
1272
|
let cmd = normalizeCommandPaths(rawCmd, root);
|
|
1184
1273
|
const relPrefix = path.relative(root, shellCwd);
|
|
1185
1274
|
if (relPrefix) cmd = rebaseRelativePaths(cmd, relPrefix, root);
|
|
1275
|
+
// D#73 — from a subdirectory, a bare dir the rebase left alone is `<cwd>/src`,
|
|
1276
|
+
// which the root-relative answer would not search (pre-ship review round 1 H1).
|
|
1277
|
+
if (relPrefix && bareSourceTarget(firstShellClause(cmd))) return;
|
|
1186
1278
|
if (!shouldHint(cmd)) return;
|
|
1187
1279
|
|
|
1188
1280
|
// v0.64 — fingerprint the grep's pattern once, shared by the emit points below.
|
|
@@ -1393,6 +1485,7 @@ module.exports = {
|
|
|
1393
1485
|
extractUnansweredTail, // v0.50 — compound-tail honesty in answered denies
|
|
1394
1486
|
extractPatterns, // v0.32.1 — exposed for tests
|
|
1395
1487
|
countNamedPaths, // v0.70 — multi-path deny→hint downgrade
|
|
1488
|
+
bareSourceTarget, // D#73 — a bare source dir the rewrite grammar proves is the path operand
|
|
1396
1489
|
isRevisionScopedGitGrep, // v0.71 — git grep --cached/treeish exclusion
|
|
1397
1490
|
extractSearchPath, // v0.47.0 — deny-with-answer
|
|
1398
1491
|
normalizeCommandPaths, // v0.47.1 — abs-path matcher fix
|
|
@@ -157,13 +157,14 @@ function isAnswerDisabled(env = process.env) {
|
|
|
157
157
|
|
|
158
158
|
// --- Shared tracking core (also driven by pre-grep-guide's sed-range path) ---
|
|
159
159
|
|
|
160
|
-
/// Record one read of `rel` (project-root-relative source path)
|
|
161
|
-
///
|
|
162
|
-
///
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
/// Record one read of `rel` (project-root-relative source path). Returns the
|
|
161
|
+
/// dir when this read crossed the fanout threshold (the hint is marked as
|
|
162
|
+
/// delivered), else null. Writes nothing to stdout: a hook's stdout is parsed as
|
|
163
|
+
/// ONE JSON value, so only the entry point may write, and only once.
|
|
164
|
+
function trackRead(root, rel, now = Date.now()) {
|
|
165
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
165
166
|
const dir = path.dirname(rel);
|
|
166
|
-
if (!dir || dir === '.' || dir === '') return
|
|
167
|
+
if (!dir || dir === '.' || dir === '') return null; // top-level file: not fanout
|
|
167
168
|
|
|
168
169
|
const state = loadState(root, now);
|
|
169
170
|
recordRead(state, dir, now);
|
|
@@ -178,12 +179,21 @@ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
|
|
|
178
179
|
// Record it (best-effort) so `stats` can measure the model's read fan-out —
|
|
179
180
|
// e.g. a read right after cg answered a grep in-place (search-decay).
|
|
180
181
|
recordRecommendation(root, { hook: 'read', action: 'observe' });
|
|
181
|
-
return
|
|
182
|
+
return null;
|
|
182
183
|
}
|
|
184
|
+
return dir;
|
|
185
|
+
}
|
|
183
186
|
|
|
187
|
+
/// The fanout hint text for `dir`, with the overview answer embedded when the
|
|
188
|
+
/// CLI delivers one. `maxBytes` bounds that answer — the pre-grep sed path
|
|
189
|
+
/// splits one envelope's budget between several dirs.
|
|
190
|
+
function buildFanoutHint(root, dir, { maxBytes } = {}) {
|
|
184
191
|
let answer = { status: 'unavailable' };
|
|
185
|
-
if (
|
|
186
|
-
|
|
192
|
+
if (maxBytes === 0) {
|
|
193
|
+
// No room left in a shared envelope: the advice line, and no CLI run.
|
|
194
|
+
answer = { status: 'unavailable', reason: 'budget' };
|
|
195
|
+
} else if (!isAnswerDisabled()) {
|
|
196
|
+
answer = runOverviewAnswer({ cwd: root, dir, ...(maxBytes ? { maxBytes } : {}) });
|
|
187
197
|
}
|
|
188
198
|
const answered = answer.status === 'hits';
|
|
189
199
|
recordRecommendation(root, {
|
|
@@ -202,14 +212,14 @@ function trackReadAndMaybeHint(root, rel, now = Date.now()) {
|
|
|
202
212
|
...(answered ? {} : { reason: answer.status }),
|
|
203
213
|
...(answered || !answer.reason ? {} : { fallthrough_reason: answer.reason }),
|
|
204
214
|
});
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
return
|
|
215
|
+
return answered ? buildHintWithAnswer(dir, answer) : buildHint(dir);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// trackRead + buildFanoutHint for one read. Returns the hint text when the
|
|
219
|
+
/// hint fired, else null. The caller emits it.
|
|
220
|
+
function trackReadAndMaybeHint(root, rel, now = Date.now()) {
|
|
221
|
+
const dir = trackRead(root, rel, now);
|
|
222
|
+
return dir === null ? null : buildFanoutHint(root, dir);
|
|
213
223
|
}
|
|
214
224
|
|
|
215
225
|
// --- Main execution ---
|
|
@@ -237,7 +247,13 @@ function runMain() {
|
|
|
237
247
|
rel = path.relative(root, filePath);
|
|
238
248
|
} catch { return; }
|
|
239
249
|
|
|
240
|
-
trackReadAndMaybeHint(root, rel);
|
|
250
|
+
const hint = trackReadAndMaybeHint(root, rel);
|
|
251
|
+
// Emit via the PreToolUse allow+additionalContext envelope (shared
|
|
252
|
+
// hook-emit.js). Bare stdout on a PreToolUse exit-0 lands in the debug log
|
|
253
|
+
// only and never reaches the model (CC docs v2026-06); the additionalContext
|
|
254
|
+
// channel is what actually surfaces the fanout hint. Read is a safe tool, so
|
|
255
|
+
// the allow elevation is negligible.
|
|
256
|
+
if (hint) process.stdout.write(emitPreToolAllowContext(hint) + '\n');
|
|
241
257
|
}
|
|
242
258
|
|
|
243
259
|
if (require.main === module) {
|
|
@@ -249,5 +265,6 @@ module.exports = {
|
|
|
249
265
|
loadState, saveState, recordRead, shouldHint, markHint,
|
|
250
266
|
buildHint, buildHintWithAnswer, isSilenced, isAnswerDisabled,
|
|
251
267
|
trackReadAndMaybeHint, // v0.49 — shared with pre-grep-guide's sed-range path
|
|
268
|
+
trackRead, buildFanoutHint, // the sed path's two halves: one envelope for every dir that fired
|
|
252
269
|
FANOUT_THRESHOLD, COOLDOWN_MS, STATE_TTL_MS, SRC_EXT,
|
|
253
270
|
};
|
|
@@ -35,7 +35,7 @@ jobs:
|
|
|
35
35
|
node-version: '20'
|
|
36
36
|
- name: Build snapshot
|
|
37
37
|
run: |
|
|
38
|
-
npx -y -p @sdsrs/code-graph@0.
|
|
38
|
+
npx -y -p @sdsrs/code-graph@0.159.0 code-graph-mcp snapshot create --out snapshot.db
|
|
39
39
|
zstd -9 snapshot.db -o snapshot.db.zst
|
|
40
40
|
mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
|
|
41
41
|
- name: Upload to release
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdsrs/code-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.159.0",
|
|
4
4
|
"description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"node": ">=16"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@sdsrs/code-graph-linux-x64": "0.
|
|
39
|
-
"@sdsrs/code-graph-linux-arm64": "0.
|
|
40
|
-
"@sdsrs/code-graph-darwin-x64": "0.
|
|
41
|
-
"@sdsrs/code-graph-darwin-arm64": "0.
|
|
42
|
-
"@sdsrs/code-graph-win32-x64": "0.
|
|
38
|
+
"@sdsrs/code-graph-linux-x64": "0.159.0",
|
|
39
|
+
"@sdsrs/code-graph-linux-arm64": "0.159.0",
|
|
40
|
+
"@sdsrs/code-graph-darwin-x64": "0.159.0",
|
|
41
|
+
"@sdsrs/code-graph-darwin-arm64": "0.159.0",
|
|
42
|
+
"@sdsrs/code-graph-win32-x64": "0.159.0"
|
|
43
43
|
}
|
|
44
44
|
}
|