wendkeep 0.36.0 → 0.38.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/CHANGELOG.md +29 -6
- package/README.md +4 -0
- package/README.pt-BR.md +4 -0
- package/bin/wendkeep.mjs +7 -1
- package/hooks/brain-inject.mjs +13 -6
- package/hooks/change-context.mjs +8 -4
- package/hooks/decision-capture.mjs +4 -2
- package/hooks/import-sessions.mjs +3 -7
- package/hooks/obsidian-common.mjs +116 -34
- package/hooks/plan-capture.mjs +8 -7
- package/hooks/session-ensure.mjs +36 -15
- package/hooks/session-identity.mjs +75 -0
- package/hooks/session-observability.mjs +176 -0
- package/hooks/session-start.mjs +41 -18
- package/hooks/session-stop.mjs +27 -27
- package/hooks/subagent-stop.mjs +10 -9
- package/hooks/subagent-usage.mjs +8 -4
- package/hooks/task-log.mjs +5 -4
- package/hooks/token-usage.mjs +58 -31
- package/hooks/vault-health.mjs +21 -13
- package/package.json +1 -1
- package/src/change.mjs +14 -2
- package/src/rebuild-costs.mjs +6 -8
- package/src/session.mjs +37 -0
package/hooks/token-usage.mjs
CHANGED
|
@@ -153,7 +153,16 @@ const MANAGED_FRONTMATTER_KEYS = new Set([
|
|
|
153
153
|
'custo_modelo_label',
|
|
154
154
|
'custo_modelo_usd',
|
|
155
155
|
'custo_por_modelo',
|
|
156
|
-
'usage_por_transcript',
|
|
156
|
+
'usage_por_transcript',
|
|
157
|
+
'subagents_count',
|
|
158
|
+
'subagents_tokens_total',
|
|
159
|
+
'subagents_custo_usd',
|
|
160
|
+
'subagents_tools',
|
|
161
|
+
'subagents_wasted_usd',
|
|
162
|
+
'tokens_total_incl_subagents',
|
|
163
|
+
'custo_total_incl_subagents_usd',
|
|
164
|
+
'observability_schema',
|
|
165
|
+
'custo_por_modelo_json',
|
|
157
166
|
// Legado: chaves antigas removidas ao reprocessar a sessão.
|
|
158
167
|
'custo_estimado_gpt55_usd',
|
|
159
168
|
'custo_estimado_opus47_usd',
|
|
@@ -357,7 +366,7 @@ function parseCodexLines(lines, result) {
|
|
|
357
366
|
const payload = event.payload || {};
|
|
358
367
|
|
|
359
368
|
if (event.type === 'session_meta') {
|
|
360
|
-
result.sessionId = payload.id || result.sessionId;
|
|
369
|
+
result.sessionId = payload.session_id || payload.id || result.sessionId;
|
|
361
370
|
currentProvider = normalizeProvider(payload.model_provider || currentProvider);
|
|
362
371
|
currentModel = normalizeModelName(payload.model || currentModel);
|
|
363
372
|
result.provider = currentProvider;
|
|
@@ -442,8 +451,9 @@ function parseClaudeLines(lines, result) {
|
|
|
442
451
|
result.provider = 'anthropic';
|
|
443
452
|
const seenUsage = new Set();
|
|
444
453
|
const seenTools = new Set();
|
|
445
|
-
const seenThinking = new Set();
|
|
446
|
-
let thinkingChars = 0;
|
|
454
|
+
const seenThinking = new Set();
|
|
455
|
+
let thinkingChars = 0;
|
|
456
|
+
const thinkingCharsByModel = new Map();
|
|
447
457
|
let latestPrompt = '';
|
|
448
458
|
|
|
449
459
|
for (const line of lines) {
|
|
@@ -476,8 +486,9 @@ function parseClaudeLines(lines, result) {
|
|
|
476
486
|
if (block?.type === 'thinking' && block.thinking) {
|
|
477
487
|
const thinkKey = `${msg.id || ''}:${block.thinking.slice(0, 60)}`;
|
|
478
488
|
if (!seenThinking.has(thinkKey)) {
|
|
479
|
-
seenThinking.add(thinkKey);
|
|
480
|
-
thinkingChars += block.thinking.length;
|
|
489
|
+
seenThinking.add(thinkKey);
|
|
490
|
+
thinkingChars += block.thinking.length;
|
|
491
|
+
thinkingCharsByModel.set(model, (thinkingCharsByModel.get(model) || 0) + block.thinking.length);
|
|
481
492
|
}
|
|
482
493
|
}
|
|
483
494
|
}
|
|
@@ -502,10 +513,14 @@ function parseClaudeLines(lines, result) {
|
|
|
502
513
|
// Thinking estimado: ~3,5 chars por token. Distribuído no total como informação à parte
|
|
503
514
|
// (já contido em output_tokens — não somar de novo).
|
|
504
515
|
const thinkingTokens = Math.round(thinkingChars / 3.5);
|
|
505
|
-
if (thinkingTokens > 0) {
|
|
506
|
-
result.totals.reasoning = thinkingTokens;
|
|
507
|
-
result.pensamento = `thinking ~${formatTokensShort(thinkingTokens)}`;
|
|
508
|
-
|
|
516
|
+
if (thinkingTokens > 0) {
|
|
517
|
+
result.totals.reasoning = thinkingTokens;
|
|
518
|
+
result.pensamento = `thinking ~${formatTokensShort(thinkingTokens)}`;
|
|
519
|
+
for (const [model, chars] of thinkingCharsByModel) {
|
|
520
|
+
const entry = result.byModel.get(`anthropic:${model}`);
|
|
521
|
+
if (entry) entry.usage.reasoning = Math.round(chars / 3.5);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
509
524
|
|
|
510
525
|
return result;
|
|
511
526
|
}
|
|
@@ -896,40 +911,52 @@ function legacyEntryFromNote(content, summary) {
|
|
|
896
911
|
};
|
|
897
912
|
}
|
|
898
913
|
|
|
899
|
-
export function
|
|
900
|
-
if (!
|
|
901
|
-
return null;
|
|
902
|
-
}
|
|
914
|
+
export function collectSessionUsage({ sessionContent, transcriptPath }) {
|
|
915
|
+
if (!transcriptPath || !existsSync(transcriptPath)) {
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
903
918
|
|
|
904
919
|
const parsed = parseTokenUsageFromTranscript(transcriptPath);
|
|
905
920
|
const summary = summarizeTokenUsage(parsed);
|
|
906
921
|
if (!summary.calls) return null;
|
|
907
922
|
|
|
908
|
-
const
|
|
909
|
-
const fmMatch = sessionContent.match(/^---\n([\s\S]*?)\n---/);
|
|
923
|
+
const fmMatch = sessionContent.match(/^---\n([\s\S]*?)\n---/);
|
|
910
924
|
const existingEntries = fmMatch ? parseUsageHistory(fmMatch[1]) : [];
|
|
911
925
|
|
|
912
|
-
const transcriptId = transcriptIdFromPath(transcriptPath);
|
|
913
|
-
|
|
926
|
+
const transcriptId = transcriptIdFromPath(transcriptPath);
|
|
927
|
+
const previous = existingEntries.find((entry) => entry.transcript_id === transcriptId);
|
|
928
|
+
const current = entryFromSummary(summary, transcriptId);
|
|
929
|
+
if (previous) {
|
|
930
|
+
const comparable = (entry) => JSON.stringify({ ...entry, atualizado_em: undefined });
|
|
931
|
+
if (comparable(previous) === comparable(current)) current.atualizado_em = previous.atualizado_em;
|
|
932
|
+
}
|
|
933
|
+
let entries = existingEntries.filter((e) => e.transcript_id !== transcriptId);
|
|
914
934
|
|
|
915
935
|
if (!existingEntries.length) {
|
|
916
936
|
const legacy = legacyEntryFromNote(sessionContent, summary);
|
|
917
937
|
if (legacy) entries.push(legacy);
|
|
918
938
|
}
|
|
919
939
|
|
|
920
|
-
entries.push(
|
|
921
|
-
|
|
922
|
-
const agg = aggregateEntries(entries);
|
|
923
|
-
const withFrontmatter = upsertSessionFrontmatter(sessionContent, agg, entries);
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
}
|
|
940
|
+
entries.push(current);
|
|
941
|
+
|
|
942
|
+
const agg = aggregateEntries(entries);
|
|
943
|
+
const withFrontmatter = upsertSessionFrontmatter(sessionContent, agg, entries);
|
|
944
|
+
return {
|
|
945
|
+
summary,
|
|
946
|
+
aggregate: agg,
|
|
947
|
+
entries,
|
|
948
|
+
content: withFrontmatter,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
export function updateSessionUsage({ vaultBase, sessionRel, sessionPath, transcriptPath }) {
|
|
953
|
+
if (!sessionPath || !existsSync(sessionPath)) return null;
|
|
954
|
+
const result = collectSessionUsage({ sessionContent: readFileSync(sessionPath, 'utf-8'), transcriptPath });
|
|
955
|
+
if (!result) return null;
|
|
956
|
+
const withSection = upsertUsageSection(result.content, buildUsageSection(result.aggregate, result.entries, result.summary));
|
|
957
|
+
writeFileSync(sessionPath, withSection, 'utf-8');
|
|
958
|
+
return result;
|
|
959
|
+
}
|
|
933
960
|
|
|
934
961
|
function parseCliArgs(argv) {
|
|
935
962
|
const args = {};
|
package/hooks/vault-health.mjs
CHANGED
|
@@ -72,7 +72,9 @@ function hasDefaultPending(content) {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function usageSectionIsPlaced(content, { active = false } = {}) {
|
|
75
|
-
const
|
|
75
|
+
const unified = content.indexOf('\n## Agentes, tokens e custos');
|
|
76
|
+
const legacy = content.indexOf('\n## Uso de tokens e custos');
|
|
77
|
+
const usage = unified !== -1 ? unified : legacy;
|
|
76
78
|
if (usage === -1) return true;
|
|
77
79
|
const changed = content.indexOf('\n## Arquivos criados ou alterados');
|
|
78
80
|
const pending = content.indexOf('\n## Pendências');
|
|
@@ -108,18 +110,19 @@ function checkSession({ vaultBase, sessionRel, control, registry }) {
|
|
|
108
110
|
metrics.turnMarkers = (content.match(/<!-- (?:wk-turn|codex-turn):/g) || []).length;
|
|
109
111
|
metrics.duplicateTurnMarkers = duplicates.length;
|
|
110
112
|
|
|
111
|
-
if (duplicates.length) failures.push(`Marcadores de turno duplicados: ${duplicates.join(', ')}`);
|
|
112
|
-
if (hasHeadingAfterClosing(content)) failures.push('Há headings/iterações após ## Encerramento.');
|
|
113
|
-
if (!usageSectionIsPlaced(content, { active: activeSession })) failures.push('
|
|
113
|
+
if (duplicates.length) failures.push(`Marcadores de turno duplicados: ${duplicates.join(', ')}`);
|
|
114
|
+
if (hasHeadingAfterClosing(content)) failures.push('Há headings/iterações após ## Encerramento.');
|
|
115
|
+
if (!usageSectionIsPlaced(content, { active: activeSession })) failures.push('A seção de agentes, tokens e custos está fora da posição esperada.');
|
|
116
|
+
if (content.includes('\n## Agentes, tokens e custos') && (content.includes('\n## Uso de tokens e custos') || content.includes('\n## Subagents & Workflows'))) {
|
|
117
|
+
failures.push('A sessão mistura observabilidade consolidada e seções legadas.');
|
|
118
|
+
}
|
|
114
119
|
if (hasDefaultPending(content)) warnings.push('Pendências ainda contém placeholders padrão.');
|
|
115
120
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
failures.push('SESSION_REGISTRY diverge do CURRENT_SESSION.md para a sessão ativa.');
|
|
122
|
-
}
|
|
121
|
+
const registryPair = Object.entries(registry.sessions || {}).find(([, entry]) => entry?.session_file === sessionRel);
|
|
122
|
+
const registryEntry = registryPair?.[1];
|
|
123
|
+
if (!registryEntry) {
|
|
124
|
+
failures.push(`SESSION_REGISTRY não possui a sessão: ${sessionRel}`);
|
|
125
|
+
} else {
|
|
123
126
|
if (!registryEntry.transcript_path) {
|
|
124
127
|
warnings.push('SESSION_REGISTRY não possui transcript_path para a sessão ativa.');
|
|
125
128
|
} else if (!existsSync(registryEntry.transcript_path)) {
|
|
@@ -161,10 +164,15 @@ export function runVaultHealth({ vaultBase, session = '' }) {
|
|
|
161
164
|
failures.push(...sessionResult.failures);
|
|
162
165
|
warnings.push(...sessionResult.warnings);
|
|
163
166
|
|
|
164
|
-
const staleDone = Object.values(registry.sessions || {})
|
|
167
|
+
const staleDone = Object.values(registry.sessions || {})
|
|
165
168
|
.filter((item) => item.status === 'active' && item.ended_at)
|
|
166
169
|
.length;
|
|
167
|
-
if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
|
|
170
|
+
if (staleDone) warnings.push(`${staleDone} entradas active com ended_at no SESSION_REGISTRY.`);
|
|
171
|
+
const activeEntries = Object.values(registry.sessions || {}).filter((item) => item?.status === 'active');
|
|
172
|
+
for (const entry of activeEntries) {
|
|
173
|
+
if (!entry.session_file) failures.push('SESSION_REGISTRY possui sessão ativa sem session_file.');
|
|
174
|
+
if (!entry.transcript_path) warnings.push(`Sessão ativa sem transcript_path: ${entry.session_file || '(sem arquivo)'}`);
|
|
175
|
+
}
|
|
168
176
|
|
|
169
177
|
const locF = getLocale(vaultBase).folders;
|
|
170
178
|
const derivedFolders = [locF.decisions, locF.bugs, locF.learnings];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wendkeep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "A persistent-memory harness for AI coding agents on your Obsidian vault: turn-by-turn session capture plus a native, zero-dependency spec→change→verify→archive loop (sensor-gated, independent verdict, mutation discrimination). Local-first, agent-agnostic (Claude Code, Codex, Cursor…).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/change.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
} from '../hooks/change-core.mjs';
|
|
19
19
|
import { evaluateGate, requiredSensors } from '../hooks/sensors-core.mjs';
|
|
20
20
|
import { buildEffectiveRequirementPackage, evaluateVerdict, tasksHashOf, parseSpecsList, parseDelta, parseRequirements, applyDelta, validateSpecImpact } from '../hooks/spec-core.mjs';
|
|
21
|
-
import { getNextAdrNumber, readControl } from '../hooks/obsidian-common.mjs';
|
|
21
|
+
import { getNextAdrNumber, readControl, readSessionRegistry, upsertSessionRegistry } from '../hooks/obsidian-common.mjs';
|
|
22
22
|
import { getLocale } from '../hooks/locale.mjs';
|
|
23
23
|
|
|
24
24
|
function resolveVault(argv) {
|
|
@@ -51,7 +51,7 @@ function today() {
|
|
|
51
51
|
export function runChange(argv) {
|
|
52
52
|
const [sub, ...rest] = argv;
|
|
53
53
|
const vaultBase = resolveVault(rest);
|
|
54
|
-
const VALUE_FLAGS = new Set(['--vault', '--change', '--project']);
|
|
54
|
+
const VALUE_FLAGS = new Set(['--vault', '--change', '--project', '--session']);
|
|
55
55
|
const slugArg = () => rest.find((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
|
|
56
56
|
|
|
57
57
|
if (sub === 'new') {
|
|
@@ -74,6 +74,18 @@ export function runChange(argv) {
|
|
|
74
74
|
process.exit(0);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
if (sub === 'bind') {
|
|
78
|
+
const slug = slugArg();
|
|
79
|
+
const sessionId = opt(rest, '--session');
|
|
80
|
+
if (!slug || !sessionId) { process.stderr.write('wendkeep change bind: use <slug> --session <id>\n'); process.exit(2); }
|
|
81
|
+
const state = allChangesState(vaultBase);
|
|
82
|
+
if (!state.changes.some((item) => item.slug === slug)) { process.stderr.write(`wendkeep change bind: open change not found: ${slug}\n`); process.exit(2); }
|
|
83
|
+
if (!readSessionRegistry(vaultBase).sessions?.[sessionId]) { process.stderr.write(`wendkeep change bind: session not found: ${sessionId}\n`); process.exit(2); }
|
|
84
|
+
upsertSessionRegistry(vaultBase, sessionId, { change_slug: slug });
|
|
85
|
+
process.stdout.write(`session ${sessionId} -> change ${slug}\n`);
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
if (sub === 'continue') {
|
|
78
90
|
const positionals = rest.filter((a, i) => !a.startsWith('-') && !VALUE_FLAGS.has(rest[i - 1]));
|
|
79
91
|
const [archivedSlug, newSlug] = positionals;
|
package/src/rebuild-costs.mjs
CHANGED
|
@@ -3,27 +3,25 @@
|
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { readSessionRegistry } from '../hooks/obsidian-common.mjs';
|
|
6
|
-
import {
|
|
7
|
-
import { upsertSubagentUsage } from '../hooks/subagent-usage.mjs';
|
|
6
|
+
import { updateSessionObservability } from '../hooks/session-observability.mjs';
|
|
8
7
|
|
|
9
8
|
export function rebuildSessionCosts(vaultBase, { apply = false, session = '', limit = 0 } = {}) {
|
|
10
9
|
const registry = readSessionRegistry(vaultBase);
|
|
11
10
|
const report = { version: 1, generatedAt: new Date().toISOString(), mode: apply ? 'apply' : 'dry-run', scanned: 0, changed: 0, unchanged: 0, missing: [], errors: [], sessions: [] };
|
|
12
11
|
const entries = Object.entries(registry.sessions || {}).map(([sessionId, value]) => ({ sessionId, ...value }))
|
|
13
|
-
.filter((e) => e.session_file
|
|
12
|
+
.filter((e) => e.session_file)
|
|
14
13
|
.filter((e) => !session || e.sessionId === session || e.session_file === session);
|
|
15
14
|
for (const entry of entries) {
|
|
16
15
|
if (limit && report.scanned >= limit) break;
|
|
17
16
|
report.scanned += 1;
|
|
18
17
|
const note = join(vaultBase, entry.session_file);
|
|
19
|
-
if (!existsSync(note) || !existsSync(entry.transcript_path)) {
|
|
20
|
-
report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: existsSync(note), transcript: existsSync(entry.transcript_path) });
|
|
18
|
+
if (!entry.transcript_path || !existsSync(note) || !existsSync(entry.transcript_path)) {
|
|
19
|
+
report.missing.push({ sessionId: entry.sessionId, session: entry.session_file, note: existsSync(note), transcript: !!entry.transcript_path && existsSync(entry.transcript_path), transcriptPath: entry.transcript_path || '' });
|
|
21
20
|
continue;
|
|
22
21
|
}
|
|
23
22
|
const before = readFileSync(note, 'utf8');
|
|
24
23
|
try {
|
|
25
|
-
|
|
26
|
-
upsertSubagentUsage(note, entry.transcript_path);
|
|
24
|
+
updateSessionObservability({ sessionPath: note, transcriptPath: entry.transcript_path, caller: 'cost-rebuild', canonicalConversationId: entry.sessionId });
|
|
27
25
|
const after = readFileSync(note, 'utf8');
|
|
28
26
|
const changed = before !== after;
|
|
29
27
|
if (changed) report.changed += 1; else report.unchanged += 1;
|
|
@@ -34,7 +32,7 @@ export function rebuildSessionCosts(vaultBase, { apply = false, session = '', li
|
|
|
34
32
|
report.errors.push({ sessionId: entry.sessionId, session: entry.session_file, error: error.message });
|
|
35
33
|
}
|
|
36
34
|
}
|
|
37
|
-
report.ok = report.errors.length === 0;
|
|
35
|
+
report.ok = report.errors.length === 0 && report.missing.length === 0;
|
|
38
36
|
if (apply) writeFileSync(join(vaultBase, '.brain', 'COST_REBUILD.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
39
37
|
return report;
|
|
40
38
|
}
|
package/src/session.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
2
|
+
import { readControl, readSessionRegistry, writeControl } from '../hooks/obsidian-common.mjs';
|
|
3
|
+
|
|
4
|
+
function vaultOf(argv) {
|
|
5
|
+
const i = argv.indexOf('--vault');
|
|
6
|
+
const raw = i >= 0 ? argv[i + 1] : argv.find((a) => a.startsWith('--vault='))?.slice(8) || process.env.OBSIDIAN_VAULT_PATH;
|
|
7
|
+
if (!raw) throw new Error('pass --vault <path> or set OBSIDIAN_VAULT_PATH');
|
|
8
|
+
return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function positionals(argv) {
|
|
12
|
+
return argv.filter((arg, index) => !arg.startsWith('-') && argv[index - 1] !== '--vault');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function runSession(argv) {
|
|
16
|
+
const vault = vaultOf(argv);
|
|
17
|
+
const [sub, id] = positionals(argv);
|
|
18
|
+
const registry = readSessionRegistry(vault);
|
|
19
|
+
const rows = Object.entries(registry.sessions || {}).sort((a, b) => String(b[1].last_seen || '').localeCompare(String(a[1].last_seen || '')));
|
|
20
|
+
if (sub === 'list') {
|
|
21
|
+
for (const [sessionId, item] of rows) process.stdout.write(`${sessionId}\t${item.status || 'unknown'}\t${item.provider || 'unknown'}\t${item.change_slug || '-'}\t${item.session_file || '-'}\n`);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const entry = registry.sessions?.[id];
|
|
25
|
+
if (!entry) throw new Error(`session not found: ${id || '(missing id)'}`);
|
|
26
|
+
if (sub === 'show') {
|
|
27
|
+
process.stdout.write(`${JSON.stringify({ session_id: id, ...entry }, null, 2)}\n`);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (sub === 'use') {
|
|
31
|
+
const control = readControl(vault);
|
|
32
|
+
writeControl(vault, { ...control, status: entry.status || 'active', session_id: id, session_file: entry.session_file || '', started_at: entry.started_at || '' });
|
|
33
|
+
process.stdout.write(`session focus: ${id}\n`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
throw new Error('use: wendkeep session list | show <id> | use <id>');
|
|
37
|
+
}
|