@try-works/dsh-memory 0.1.19 → 0.1.20

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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +287 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/src/SYSTEM.template.md +311 -311
  8. package/src/capture.js +242 -242
  9. package/src/client/index.js +886 -886
  10. package/src/config.js +57 -57
  11. package/src/consolidate-run.js +132 -132
  12. package/src/consolidate.js +330 -330
  13. package/src/context.js +88 -88
  14. package/src/decisions.js +106 -106
  15. package/src/defaults.js +93 -93
  16. package/src/digest.js +255 -255
  17. package/src/entry.js +179 -179
  18. package/src/eval.js +72 -72
  19. package/src/files.js +60 -60
  20. package/src/fixture.js +56 -56
  21. package/src/forget.js +71 -71
  22. package/src/freshness.js +65 -65
  23. package/src/fts.js +166 -166
  24. package/src/git.js +53 -53
  25. package/src/history.js +226 -226
  26. package/src/host.js +866 -866
  27. package/src/index.js +115 -94
  28. package/src/inject.js +108 -108
  29. package/src/layers.js +178 -178
  30. package/src/ledger.js +19 -19
  31. package/src/map.js +184 -184
  32. package/src/models.js +136 -136
  33. package/src/queue.js +602 -602
  34. package/src/rank.js +31 -31
  35. package/src/recover.js +91 -91
  36. package/src/refresh.js +80 -80
  37. package/src/remember.js +58 -58
  38. package/src/rerank.js +90 -90
  39. package/src/search.js +75 -75
  40. package/src/select.js +57 -57
  41. package/src/session.js +60 -60
  42. package/src/state.js +240 -240
  43. package/src/store.js +128 -128
  44. package/src/summarize.js +195 -195
  45. package/src/system-doc.js +171 -171
  46. package/src/tasks.js +617 -617
  47. package/src/telemetry.js +122 -122
  48. package/src/tools.js +828 -818
  49. package/src/working.js +288 -288
  50. package/src/workspace-key.js +68 -68
  51. package/src/workspace.js +87 -87
package/src/summarize.js CHANGED
@@ -1,195 +1,195 @@
1
- // Session summarization: the CAPTURE stage turns raw session material into durable
2
- // notes. The instructions below are deliberately durable (they are the contract the
3
- // store relies on), and parsing is lenient so a model that ignores the format still
4
- // degrades to a deterministic fallback instead of writing noise.
5
-
6
- /** Marker a model returns when a session holds nothing worth remembering. */
7
- export const NO_DURABLE = 'NO_DURABLE_CONTENT'
8
-
9
- /** Durable capture instructions: what durable memory is, and what it is not. */
10
- export const SUMMARY_INSTRUCTIONS = [
11
- 'You are the CAPTURE stage of a long-term memory system for a coding agent.',
12
- 'You receive the raw material of ONE work session. Reply with durable notes only.',
13
- '',
14
- 'FORMAT (strict):',
15
- '- Markdown bullet points, one fact per bullet. No preamble, no headings, no code fences.',
16
- '- 5-12 bullets, together under 1200 characters.',
17
- '- Each bullet: "- **<short label>:** <the fact>"',
18
- '- If a bullet is time-bound, lead with the date: "- **2026-09-30 - <label>:** <fact>"',
19
- '- Name concrete identifiers when they matter: file paths, commands, ids, config keys, versions.',
20
- '- Add one clause of why when a decision was made.',
21
- '',
22
- 'KEEP (durable):',
23
- '- decisions and their rationale; constraints, gotchas, surprises',
24
- '- chosen dependencies, conventions and architecture, with the reason they won',
25
- '- user preferences, standing rules, corrections',
26
- '- environment facts: paths, services, ports, versions, where credentials live',
27
- '- which modules or subsystems the session actually worked in, and what each one does',
28
- '- whether a fact is durable (a decision, a convention) or perishable (what is running now)',
29
- '- verified outcomes and results',
30
- '- unresolved problems and agreed next steps',
31
- '',
32
- 'DROP (never store):',
33
- '- tool calls, command mechanics, retries, build/scan output',
34
- '- code blocks, diffs and file dumps (name the file instead)',
35
- '- a bare list of file paths: they are given for context, so name only the ones that matter',
36
- '- status updates, acknowledgements, pleasantries, restatements',
37
- '- anything that is only meaningful inside a single turn',
38
- '',
39
- 'RULES:',
40
- '- Never invent facts. Compress only what the material shows.',
41
- '- Prefer durable statements over narration of what the agent did.',
42
- '- If the session holds nothing durable, reply with exactly: ' + NO_DURABLE,
43
- ' That is a common and correct answer. Never manufacture a bullet to avoid an empty reply.',
44
- ].join('\n')
45
-
46
- /** Clip one message to a head+tail window so a single huge message cannot dominate. */
47
- export function clip(text, max = 600) {
48
- const value = String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
49
- if (value.length <= max) return value
50
- const head = Math.max(1, Math.floor(max * 0.7))
51
- const tail = Math.max(0, max - head - 3)
52
- return value.slice(0, head) + '...' + (tail > 0 ? value.slice(value.length - tail) : '')
53
- }
54
-
55
- /** Gist of an assistant message: its first meaningful line, clipped. */
56
- /**
57
- * Gist of an assistant message: the substance of what it reported.
58
- *
59
- * Taking the first line was wrong for research and analysis work. Those answers open with a
60
- * one-line status ("Analysis complete.") and put every finding in the body, so the capture
61
- * pipeline saw the announcement and none of the substance - the exact material the memory
62
- * system exists to keep. The gist now skips a bare status opener and carries the following
63
- * content, so a report reaches the summarizer as findings rather than as a headline.
64
- */
65
- export function gist(text, max = 240) {
66
- const value = String(text == null ? '' : text)
67
- const lines = value.split('\n').map((raw) => raw.replace(/^[#>*\-\s]+/, '').trim())
68
- const meaningful = lines.filter((line) => line.length >= 12)
69
- if (meaningful.length === 0) return clip(value, max)
70
- // A short opener that only announces completion is a headline, not the content.
71
- const OPENER = /^(analysis complete|done|finished|completed|all done|i(\x27ve| have) (finished|completed)|summary[:\s]|here(\x27s| is) (a|the) (summary|report|analysis))/i
72
- const start = (meaningful.length > 1 && OPENER.test(meaningful[0])) ? 1 : 0
73
- // Prefer the first two meaningful lines so a heading plus its substance both survive.
74
- const picked = meaningful.slice(start, start + 2).join(' ')
75
- return clip(picked === '' ? meaningful[0] : picked, max)
76
- }
77
-
78
- /** Build the single summarization prompt from bounded session candidates. */
79
- export function buildSummaryPrompt(candidates = {}, meta = {}) {
80
- const lines = [
81
- SUMMARY_INSTRUCTIONS,
82
- '',
83
- '--- SESSION ---',
84
- 'Title: ' + (candidates.title || candidates.sessionId || 'untitled'),
85
- 'Date: ' + (meta.date || candidates.date || ''),
86
- 'Cwd: ' + (candidates.cwd || ''),
87
- 'Events: ' + (candidates.eventCount == null ? 0 : candidates.eventCount)
88
- + ' Tool calls: ' + (candidates.toolCallCount == null ? 0 : candidates.toolCallCount),
89
- ]
90
- if (candidates.goals && candidates.goals.length) lines.push('Goals: ' + candidates.goals.join(' | '))
91
- if (candidates.userTurns && candidates.userTurns.length) {
92
- lines.push('', '--- USER TURNS (' + candidates.userTurns.length + ') ---')
93
- for (const turn of candidates.userTurns) lines.push('- ' + turn)
94
- }
95
- if (candidates.assistantGists && candidates.assistantGists.length) {
96
- lines.push('', '--- ASSISTANT GISTS (' + candidates.assistantGists.length + ') ---')
97
- for (const item of candidates.assistantGists) lines.push('- ' + item)
98
- }
99
- if (candidates.files && candidates.files.length) {
100
- lines.push('', '--- FILES THE SESSION WORKED IN (' + candidates.files.length + ') ---')
101
- for (const file of candidates.files) lines.push('- ' + file)
102
- }
103
- if (candidates.checkpoints && candidates.checkpoints.length) {
104
- lines.push('', '--- COMPACTION CHECKPOINTS ---')
105
- for (const item of candidates.checkpoints) lines.push('- ' + item)
106
- }
107
- return lines.join('\n')
108
- }
109
-
110
- /**
111
- * Leniently read bullets out of a model reply.
112
- * @returns {{ bullets: string[], none: boolean }} normalized bullet lines.
113
- */
114
- export function parseSummary(text, { maxBullets = 16, maxChars = 2000 } = {}) {
115
- const source = String(text == null ? '' : text).trim()
116
- if (source === '') return { bullets: [], none: true, reason: 'empty-reply' }
117
- if (source.toUpperCase().includes(NO_DURABLE)) return { bullets: [], none: true, reason: 'no-durable' }
118
- const bullets = []
119
- let total = 0
120
- for (const raw of source.replace(/\x60\x60\x60[\s\S]*?\x60\x60\x60/g, '').split('\n')) {
121
- const line = raw.trim()
122
- if (!/^[-*+]\s+/.test(line)) continue
123
- const bullet = '- ' + line.replace(/^[-*+]\s+/, '').trim()
124
- if (bullet.length <= 3) continue
125
- if (total + bullet.length > maxChars && bullets.length > 0) break
126
- bullets.push(bullet)
127
- total += bullet.length + 1
128
- if (bullets.length >= maxBullets) break
129
- }
130
- return bullets.length === 0 ? { bullets: [], none: true, reason: 'unparseable' } : { bullets, none: false }
131
- }
132
-
133
- /** Deterministic notes used when the summarizer is unavailable or unusable. */
134
- export function fallbackBullets(candidates = {}, { maxBullets = 8, maxChars = 1000 } = {}) {
135
- const bullets = []
136
- let total = 0
137
- const push = (label, fact) => {
138
- const body = clip(fact, 220)
139
- if (body === '') return
140
- const bullet = '- **' + label + ':** ' + body
141
- if (total + bullet.length > maxChars && bullets.length > 0) return
142
- bullets.push(bullet)
143
- total += bullet.length + 1
144
- }
145
- const date = candidates.date ? String(candidates.date).slice(0, 10) + ' - ' : ''
146
- for (const goal of (candidates.goals || []).slice(0, 2)) push(date + 'goal', goal)
147
- const turns = (candidates.userTurns || []).slice(-6)
148
- for (const turn of turns) { if (bullets.length >= maxBullets) break; push(date + 'request', turn) }
149
- for (const item of (candidates.checkpoints || []).slice(-2)) { if (bullets.length >= maxBullets) break; push(date + 'checkpoint', item) }
150
- return bullets.slice(0, maxBullets)
151
- }
152
-
153
- /**
154
- * Summarize one session through the given llm, falling back deterministically.
155
- * @returns {Promise<{ bullets: string[], source: 'llm'|'fallback'|'none', error?: string }>}
156
- */
157
- export async function summarizeSession(llm, candidates = {}, meta = {}, options = {}) {
158
- const config = { maxBullets: 16, maxChars: 2000, fallback: true, ...options }
159
- const deterministic = () => {
160
- const bullets = fallbackBullets(candidates, config)
161
- return bullets.length === 0 ? { bullets: [], source: 'none' } : { bullets, source: 'fallback' }
162
- }
163
- if (typeof llm !== 'function') return deterministic()
164
- let reply
165
- try {
166
- reply = await llm(buildSummaryPrompt(candidates, meta))
167
- } catch (error) {
168
- if (!config.fallback) throw error
169
- const result = deterministic()
170
- // A thrown call is a failure even when the fallback salvaged bullets from the material.
171
- return { ...result, reason: result.bullets.length ? undefined : 'error', error: String((error && error.message) || error) }
172
- }
173
- const parsed = parseSummary(reply, config)
174
- // The REASON matters downstream: 'no-durable' is a settled conclusion, while an empty or
175
- // unparseable reply is a FAILURE that deserves another attempt. Collapsing both into
176
- // 'none' is how a failed summary got permanently marked as handled.
177
- if (parsed.none) return { bullets: [], source: 'none', reason: parsed.reason }
178
- return { bullets: parsed.bullets, source: 'llm' }
179
- }
180
-
181
- /** Clip a markdown block, preserving line structure (clip() flattens whitespace). */
182
- export function clipBlock(text, max) {
183
- const value = String(text == null ? '' : text)
184
- if (value.length <= max) return value
185
- const head = Math.max(1, Math.floor(max * 0.6))
186
- const tail = Math.max(0, max - head - 6)
187
- return value.slice(0, head) + '\n...\n' + (tail > 0 ? value.slice(value.length - tail) : '')
188
- }
189
-
190
- /** Render one dated summary section for a digest file. */
191
- export function renderSummarySection(bullets, { stamp, source = 'llm' } = {}) {
192
- const lines = ['## ' + (stamp || new Date().toISOString()) + ' - summary (' + source + ')']
193
- for (const bullet of bullets) lines.push(bullet)
194
- return lines.join('\n') + '\n'
195
- }
1
+ // Session summarization: the CAPTURE stage turns raw session material into durable
2
+ // notes. The instructions below are deliberately durable (they are the contract the
3
+ // store relies on), and parsing is lenient so a model that ignores the format still
4
+ // degrades to a deterministic fallback instead of writing noise.
5
+
6
+ /** Marker a model returns when a session holds nothing worth remembering. */
7
+ export const NO_DURABLE = 'NO_DURABLE_CONTENT'
8
+
9
+ /** Durable capture instructions: what durable memory is, and what it is not. */
10
+ export const SUMMARY_INSTRUCTIONS = [
11
+ 'You are the CAPTURE stage of a long-term memory system for a coding agent.',
12
+ 'You receive the raw material of ONE work session. Reply with durable notes only.',
13
+ '',
14
+ 'FORMAT (strict):',
15
+ '- Markdown bullet points, one fact per bullet. No preamble, no headings, no code fences.',
16
+ '- 5-12 bullets, together under 1200 characters.',
17
+ '- Each bullet: "- **<short label>:** <the fact>"',
18
+ '- If a bullet is time-bound, lead with the date: "- **2026-09-30 - <label>:** <fact>"',
19
+ '- Name concrete identifiers when they matter: file paths, commands, ids, config keys, versions.',
20
+ '- Add one clause of why when a decision was made.',
21
+ '',
22
+ 'KEEP (durable):',
23
+ '- decisions and their rationale; constraints, gotchas, surprises',
24
+ '- chosen dependencies, conventions and architecture, with the reason they won',
25
+ '- user preferences, standing rules, corrections',
26
+ '- environment facts: paths, services, ports, versions, where credentials live',
27
+ '- which modules or subsystems the session actually worked in, and what each one does',
28
+ '- whether a fact is durable (a decision, a convention) or perishable (what is running now)',
29
+ '- verified outcomes and results',
30
+ '- unresolved problems and agreed next steps',
31
+ '',
32
+ 'DROP (never store):',
33
+ '- tool calls, command mechanics, retries, build/scan output',
34
+ '- code blocks, diffs and file dumps (name the file instead)',
35
+ '- a bare list of file paths: they are given for context, so name only the ones that matter',
36
+ '- status updates, acknowledgements, pleasantries, restatements',
37
+ '- anything that is only meaningful inside a single turn',
38
+ '',
39
+ 'RULES:',
40
+ '- Never invent facts. Compress only what the material shows.',
41
+ '- Prefer durable statements over narration of what the agent did.',
42
+ '- If the session holds nothing durable, reply with exactly: ' + NO_DURABLE,
43
+ ' That is a common and correct answer. Never manufacture a bullet to avoid an empty reply.',
44
+ ].join('\n')
45
+
46
+ /** Clip one message to a head+tail window so a single huge message cannot dominate. */
47
+ export function clip(text, max = 600) {
48
+ const value = String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
49
+ if (value.length <= max) return value
50
+ const head = Math.max(1, Math.floor(max * 0.7))
51
+ const tail = Math.max(0, max - head - 3)
52
+ return value.slice(0, head) + '...' + (tail > 0 ? value.slice(value.length - tail) : '')
53
+ }
54
+
55
+ /** Gist of an assistant message: its first meaningful line, clipped. */
56
+ /**
57
+ * Gist of an assistant message: the substance of what it reported.
58
+ *
59
+ * Taking the first line was wrong for research and analysis work. Those answers open with a
60
+ * one-line status ("Analysis complete.") and put every finding in the body, so the capture
61
+ * pipeline saw the announcement and none of the substance - the exact material the memory
62
+ * system exists to keep. The gist now skips a bare status opener and carries the following
63
+ * content, so a report reaches the summarizer as findings rather than as a headline.
64
+ */
65
+ export function gist(text, max = 240) {
66
+ const value = String(text == null ? '' : text)
67
+ const lines = value.split('\n').map((raw) => raw.replace(/^[#>*\-\s]+/, '').trim())
68
+ const meaningful = lines.filter((line) => line.length >= 12)
69
+ if (meaningful.length === 0) return clip(value, max)
70
+ // A short opener that only announces completion is a headline, not the content.
71
+ const OPENER = /^(analysis complete|done|finished|completed|all done|i(\x27ve| have) (finished|completed)|summary[:\s]|here(\x27s| is) (a|the) (summary|report|analysis))/i
72
+ const start = (meaningful.length > 1 && OPENER.test(meaningful[0])) ? 1 : 0
73
+ // Prefer the first two meaningful lines so a heading plus its substance both survive.
74
+ const picked = meaningful.slice(start, start + 2).join(' ')
75
+ return clip(picked === '' ? meaningful[0] : picked, max)
76
+ }
77
+
78
+ /** Build the single summarization prompt from bounded session candidates. */
79
+ export function buildSummaryPrompt(candidates = {}, meta = {}) {
80
+ const lines = [
81
+ SUMMARY_INSTRUCTIONS,
82
+ '',
83
+ '--- SESSION ---',
84
+ 'Title: ' + (candidates.title || candidates.sessionId || 'untitled'),
85
+ 'Date: ' + (meta.date || candidates.date || ''),
86
+ 'Cwd: ' + (candidates.cwd || ''),
87
+ 'Events: ' + (candidates.eventCount == null ? 0 : candidates.eventCount)
88
+ + ' Tool calls: ' + (candidates.toolCallCount == null ? 0 : candidates.toolCallCount),
89
+ ]
90
+ if (candidates.goals && candidates.goals.length) lines.push('Goals: ' + candidates.goals.join(' | '))
91
+ if (candidates.userTurns && candidates.userTurns.length) {
92
+ lines.push('', '--- USER TURNS (' + candidates.userTurns.length + ') ---')
93
+ for (const turn of candidates.userTurns) lines.push('- ' + turn)
94
+ }
95
+ if (candidates.assistantGists && candidates.assistantGists.length) {
96
+ lines.push('', '--- ASSISTANT GISTS (' + candidates.assistantGists.length + ') ---')
97
+ for (const item of candidates.assistantGists) lines.push('- ' + item)
98
+ }
99
+ if (candidates.files && candidates.files.length) {
100
+ lines.push('', '--- FILES THE SESSION WORKED IN (' + candidates.files.length + ') ---')
101
+ for (const file of candidates.files) lines.push('- ' + file)
102
+ }
103
+ if (candidates.checkpoints && candidates.checkpoints.length) {
104
+ lines.push('', '--- COMPACTION CHECKPOINTS ---')
105
+ for (const item of candidates.checkpoints) lines.push('- ' + item)
106
+ }
107
+ return lines.join('\n')
108
+ }
109
+
110
+ /**
111
+ * Leniently read bullets out of a model reply.
112
+ * @returns {{ bullets: string[], none: boolean }} normalized bullet lines.
113
+ */
114
+ export function parseSummary(text, { maxBullets = 16, maxChars = 2000 } = {}) {
115
+ const source = String(text == null ? '' : text).trim()
116
+ if (source === '') return { bullets: [], none: true, reason: 'empty-reply' }
117
+ if (source.toUpperCase().includes(NO_DURABLE)) return { bullets: [], none: true, reason: 'no-durable' }
118
+ const bullets = []
119
+ let total = 0
120
+ for (const raw of source.replace(/\x60\x60\x60[\s\S]*?\x60\x60\x60/g, '').split('\n')) {
121
+ const line = raw.trim()
122
+ if (!/^[-*+]\s+/.test(line)) continue
123
+ const bullet = '- ' + line.replace(/^[-*+]\s+/, '').trim()
124
+ if (bullet.length <= 3) continue
125
+ if (total + bullet.length > maxChars && bullets.length > 0) break
126
+ bullets.push(bullet)
127
+ total += bullet.length + 1
128
+ if (bullets.length >= maxBullets) break
129
+ }
130
+ return bullets.length === 0 ? { bullets: [], none: true, reason: 'unparseable' } : { bullets, none: false }
131
+ }
132
+
133
+ /** Deterministic notes used when the summarizer is unavailable or unusable. */
134
+ export function fallbackBullets(candidates = {}, { maxBullets = 8, maxChars = 1000 } = {}) {
135
+ const bullets = []
136
+ let total = 0
137
+ const push = (label, fact) => {
138
+ const body = clip(fact, 220)
139
+ if (body === '') return
140
+ const bullet = '- **' + label + ':** ' + body
141
+ if (total + bullet.length > maxChars && bullets.length > 0) return
142
+ bullets.push(bullet)
143
+ total += bullet.length + 1
144
+ }
145
+ const date = candidates.date ? String(candidates.date).slice(0, 10) + ' - ' : ''
146
+ for (const goal of (candidates.goals || []).slice(0, 2)) push(date + 'goal', goal)
147
+ const turns = (candidates.userTurns || []).slice(-6)
148
+ for (const turn of turns) { if (bullets.length >= maxBullets) break; push(date + 'request', turn) }
149
+ for (const item of (candidates.checkpoints || []).slice(-2)) { if (bullets.length >= maxBullets) break; push(date + 'checkpoint', item) }
150
+ return bullets.slice(0, maxBullets)
151
+ }
152
+
153
+ /**
154
+ * Summarize one session through the given llm, falling back deterministically.
155
+ * @returns {Promise<{ bullets: string[], source: 'llm'|'fallback'|'none', error?: string }>}
156
+ */
157
+ export async function summarizeSession(llm, candidates = {}, meta = {}, options = {}) {
158
+ const config = { maxBullets: 16, maxChars: 2000, fallback: true, ...options }
159
+ const deterministic = () => {
160
+ const bullets = fallbackBullets(candidates, config)
161
+ return bullets.length === 0 ? { bullets: [], source: 'none' } : { bullets, source: 'fallback' }
162
+ }
163
+ if (typeof llm !== 'function') return deterministic()
164
+ let reply
165
+ try {
166
+ reply = await llm(buildSummaryPrompt(candidates, meta))
167
+ } catch (error) {
168
+ if (!config.fallback) throw error
169
+ const result = deterministic()
170
+ // A thrown call is a failure even when the fallback salvaged bullets from the material.
171
+ return { ...result, reason: result.bullets.length ? undefined : 'error', error: String((error && error.message) || error) }
172
+ }
173
+ const parsed = parseSummary(reply, config)
174
+ // The REASON matters downstream: 'no-durable' is a settled conclusion, while an empty or
175
+ // unparseable reply is a FAILURE that deserves another attempt. Collapsing both into
176
+ // 'none' is how a failed summary got permanently marked as handled.
177
+ if (parsed.none) return { bullets: [], source: 'none', reason: parsed.reason }
178
+ return { bullets: parsed.bullets, source: 'llm' }
179
+ }
180
+
181
+ /** Clip a markdown block, preserving line structure (clip() flattens whitespace). */
182
+ export function clipBlock(text, max) {
183
+ const value = String(text == null ? '' : text)
184
+ if (value.length <= max) return value
185
+ const head = Math.max(1, Math.floor(max * 0.6))
186
+ const tail = Math.max(0, max - head - 6)
187
+ return value.slice(0, head) + '\n...\n' + (tail > 0 ? value.slice(value.length - tail) : '')
188
+ }
189
+
190
+ /** Render one dated summary section for a digest file. */
191
+ export function renderSummarySection(bullets, { stamp, source = 'llm' } = {}) {
192
+ const lines = ['## ' + (stamp || new Date().toISOString()) + ' - summary (' + source + ')']
193
+ for (const bullet of bullets) lines.push(bullet)
194
+ return lines.join('\n') + '\n'
195
+ }