@smartmemory/compose 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.
- package/LICENSE +21 -0
- package/README.md +1014 -0
- package/bin/compose.js +1515 -0
- package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
- package/dist/assets/arc-SxJ2J1sh.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
- package/dist/assets/channel-DGElom1e.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
- package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
- package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
- package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
- package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
- package/dist/assets/clone-DUJKJXd7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
- package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
- package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
- package/dist/assets/graph-D0Cfv00Y.js +1 -0
- package/dist/assets/index-CUd6pFGF.css +1 -0
- package/dist/assets/index-DReRlzZI.js +1144 -0
- package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-Bj72wOEB.js +1 -0
- package/dist/assets/linear-BRFo114D.js +1 -0
- package/dist/assets/min-GCHnKlJS.js +1 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
- package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
- package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
- package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
- package/dist/index.html +30 -0
- package/lib/agent-chains.js +65 -0
- package/lib/agent-string.js +86 -0
- package/lib/budget-ledger.js +86 -0
- package/lib/build-all.js +162 -0
- package/lib/build-dag.js +120 -0
- package/lib/build-stream-writer.js +190 -0
- package/lib/build.js +2997 -0
- package/lib/capability-checker.js +53 -0
- package/lib/cert-inject.js +38 -0
- package/lib/cli-progress.js +483 -0
- package/lib/constants.js +69 -0
- package/lib/cross-layer-audit.js +84 -0
- package/lib/debug-discipline.js +173 -0
- package/lib/feature-json.js +106 -0
- package/lib/gate-prompt.js +291 -0
- package/lib/gate-tiers.js +194 -0
- package/lib/health-history.js +119 -0
- package/lib/health-score.js +227 -0
- package/lib/ideabox.js +570 -0
- package/lib/import.js +244 -0
- package/lib/migrate-roadmap.js +94 -0
- package/lib/model-pricing.js +67 -0
- package/lib/new.js +413 -0
- package/lib/pipeline-cli.js +489 -0
- package/lib/plan-parser.js +103 -0
- package/lib/qa-scoping.js +474 -0
- package/lib/questionnaire.js +200 -0
- package/lib/resolve-port.js +7 -0
- package/lib/result-normalizer.js +349 -0
- package/lib/review-lenses.js +166 -0
- package/lib/roadmap-gen.js +210 -0
- package/lib/roadmap-parser.js +176 -0
- package/lib/server-probe.js +23 -0
- package/lib/staleness.js +87 -0
- package/lib/step-prompt.js +260 -0
- package/lib/step-validator.js +49 -0
- package/lib/stratum-mcp-client.js +365 -0
- package/lib/team-flag.js +46 -0
- package/lib/test-bootstrap.js +401 -0
- package/lib/triage.js +274 -0
- package/lib/vision-writer.js +391 -0
- package/package.json +111 -0
- package/pipelines/bug-fix.stratum.yaml +230 -0
- package/pipelines/build.stratum.yaml +498 -0
- package/pipelines/content.stratum.yaml +112 -0
- package/pipelines/coverage-sweep.stratum.yaml +52 -0
- package/pipelines/refactor.stratum.yaml +169 -0
- package/pipelines/research.stratum.yaml +88 -0
- package/pipelines/review-fix.stratum.yaml +109 -0
- package/presets/team-feature.stratum.yaml +105 -0
- package/presets/team-research.stratum.yaml +108 -0
- package/presets/team-review.stratum.yaml +106 -0
- package/scripts/agent-activity-hook.sh +31 -0
- package/scripts/agent-error-hook.sh +28 -0
- package/scripts/analyze-orphans.mjs +50 -0
- package/scripts/find-orphans.mjs +26 -0
- package/scripts/fix-phases.mjs +49 -0
- package/scripts/generate-stratum-spec.mjs +137 -0
- package/scripts/import-roadmap.mjs +116 -0
- package/scripts/phase-audit.mjs +33 -0
- package/scripts/run-pipeline.mjs +314 -0
- package/scripts/session-end-hook.sh +18 -0
- package/scripts/session-start-hook.sh +38 -0
- package/scripts/vision-hook.sh +104 -0
- package/scripts/vision-track.mjs +554 -0
- package/scripts/wire-all-orphans.mjs +108 -0
- package/scripts/wire-orphans.mjs +164 -0
- package/server/activity-routes.js +123 -0
- package/server/agent-health.js +197 -0
- package/server/agent-hooks.js +102 -0
- package/server/agent-mcp.js +10 -0
- package/server/agent-registry.js +95 -0
- package/server/agent-server.js +290 -0
- package/server/agent-spawn.js +251 -0
- package/server/agent-templates.js +77 -0
- package/server/artifact-manager.js +247 -0
- package/server/artifact-templates/architecture.md +28 -0
- package/server/artifact-templates/blueprint.md +21 -0
- package/server/artifact-templates/design.md +36 -0
- package/server/artifact-templates/plan.md +25 -0
- package/server/artifact-templates/prd.md +43 -0
- package/server/artifact-templates/report.md +40 -0
- package/server/block-tracker.js +90 -0
- package/server/build-stream-bridge.js +502 -0
- package/server/coalescing-buffer.js +46 -0
- package/server/compose-mcp-tools.js +479 -0
- package/server/compose-mcp.js +324 -0
- package/server/connectors/agent-connector.js +78 -0
- package/server/connectors/claude-sdk-connector.js +198 -0
- package/server/connectors/codex-connector.js +240 -0
- package/server/connectors/connector-discovery.js +18 -0
- package/server/connectors/connector-runtime.js +13 -0
- package/server/connectors/opencode-connector.js +200 -0
- package/server/design-routes.js +540 -0
- package/server/design-session.js +161 -0
- package/server/feature-scan.js +593 -0
- package/server/file-watcher.js +284 -0
- package/server/find-root.js +29 -0
- package/server/graph-export.js +343 -0
- package/server/ideabox-cache.js +77 -0
- package/server/ideabox-routes.js +294 -0
- package/server/index.js +156 -0
- package/server/model-tiers.js +49 -0
- package/server/pipeline-routes.js +288 -0
- package/server/policy-evaluator.js +36 -0
- package/server/project-root.js +122 -0
- package/server/security.js +23 -0
- package/server/session-manager.js +403 -0
- package/server/session-routes.js +190 -0
- package/server/session-store.js +107 -0
- package/server/settings-routes.js +35 -0
- package/server/settings-store.js +234 -0
- package/server/stratum-api.js +102 -0
- package/server/stratum-client.js +192 -0
- package/server/stratum-sync.js +193 -0
- package/server/summarizer.js +139 -0
- package/server/supervisor.js +196 -0
- package/server/vision-routes.js +668 -0
- package/server/vision-server.js +393 -0
- package/server/vision-store.js +360 -0
- package/server/vision-utils.js +179 -0
- package/server/worktree-gc.js +137 -0
- package/templates/ROADMAP.md +46 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionManager — session lifecycle, per-item accumulator, batched Haiku summaries.
|
|
3
|
+
*
|
|
4
|
+
* Sessions are NOT tracker entities — they're execution context that accumulates
|
|
5
|
+
* tool-use events, groups them into work blocks by resolved tracker item, and
|
|
6
|
+
* periodically summarises batches via Haiku.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { copyFile, mkdir } from 'node:fs/promises';
|
|
13
|
+
import { buildSummaryPrompt, summarize } from './summarizer.js';
|
|
14
|
+
import { updateBlock, closeCurrentBlock } from './block-tracker.js';
|
|
15
|
+
import { serializeSession, persistSession, readLastSession, readSessionsByFeature } from './session-store.js';
|
|
16
|
+
|
|
17
|
+
import { getTargetRoot, getDataDir } from './project-root.js';
|
|
18
|
+
|
|
19
|
+
const PROJECT_ROOT = getTargetRoot();
|
|
20
|
+
const SESSIONS_FILE = path.join(getDataDir(), 'sessions.json');
|
|
21
|
+
|
|
22
|
+
/** Tools whose events count toward the Haiku summary batch threshold */
|
|
23
|
+
const SIGNIFICANT_TOOLS = new Set(['Write', 'Edit', 'Bash', 'NotebookEdit']);
|
|
24
|
+
|
|
25
|
+
/** Number of significant events before triggering a Haiku summary call */
|
|
26
|
+
const BATCH_SIZE = 4;
|
|
27
|
+
|
|
28
|
+
export class SessionManager {
|
|
29
|
+
constructor({ getFeaturePhase, featureRoot, sessionsFile } = {}) {
|
|
30
|
+
/** @type {object|null} Current active session */
|
|
31
|
+
this.currentSession = null;
|
|
32
|
+
|
|
33
|
+
/** @type {function} Callback to get current phase for a featureCode */
|
|
34
|
+
this._getFeaturePhase = getFeaturePhase || (() => null);
|
|
35
|
+
|
|
36
|
+
/** @type {string} Root directory for feature folders */
|
|
37
|
+
this._featureRoot = featureRoot || 'docs/features';
|
|
38
|
+
|
|
39
|
+
/** @type {string} Path to sessions.json — injectable for tests */
|
|
40
|
+
this._sessionsFile = sessionsFile || SESSIONS_FILE;
|
|
41
|
+
|
|
42
|
+
/** @type {Array<{tool,filePath,input,itemIds,timestamp}>} Buffered significant events */
|
|
43
|
+
this._pendingBatch = [];
|
|
44
|
+
|
|
45
|
+
/** @type {boolean} Whether a Haiku flush is currently in-flight */
|
|
46
|
+
this._flushing = false;
|
|
47
|
+
|
|
48
|
+
/** @type {Promise|null} In-flight flush promise for awaiting */
|
|
49
|
+
this._flushPromise = null;
|
|
50
|
+
|
|
51
|
+
/** @type {Array<function>} Callbacks for when summaries arrive */
|
|
52
|
+
this._summaryListeners = [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Public API
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start a new session.
|
|
61
|
+
* @param {'startup'|'resume'|'clear'|'compact'} source — what triggered the session
|
|
62
|
+
*/
|
|
63
|
+
startSession(source = 'startup') {
|
|
64
|
+
// Close any lingering session synchronously (skip Haiku flush to avoid async race)
|
|
65
|
+
if (this.currentSession) {
|
|
66
|
+
closeCurrentBlock(this.currentSession);
|
|
67
|
+
const old = this.currentSession;
|
|
68
|
+
old.endedAt = new Date().toISOString();
|
|
69
|
+
old.endReason = 'replaced';
|
|
70
|
+
this._persist(this._serialize(old));
|
|
71
|
+
console.log(`[session] Ended ${old.id} (reason: replaced, tools: ${old.toolCount})`);
|
|
72
|
+
this.currentSession = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const now = new Date().toISOString();
|
|
76
|
+
this.currentSession = {
|
|
77
|
+
id: `session-${Date.now()}-${randomBytes(3).toString('hex')}`,
|
|
78
|
+
startedAt: now,
|
|
79
|
+
source,
|
|
80
|
+
toolCount: 0,
|
|
81
|
+
items: new Map(), // itemId → { title, summaries[], reads, writes, firstTouched, lastTouched }
|
|
82
|
+
currentBlock: null, // { itemIds: Set, startedAt, toolCount } | null
|
|
83
|
+
blocks: [], // closed blocks: { itemIds[], startedAt, endedAt, toolCount }
|
|
84
|
+
commits: [],
|
|
85
|
+
errors: [], // { type, severity, tool, message, itemIds, timestamp }
|
|
86
|
+
featureCode: null,
|
|
87
|
+
featureItemId: null,
|
|
88
|
+
phaseAtBind: null,
|
|
89
|
+
boundAt: null,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
this._pendingBatch = [];
|
|
93
|
+
this._flushing = false;
|
|
94
|
+
this._flushPromise = null;
|
|
95
|
+
|
|
96
|
+
console.log(`[session] Started ${this.currentSession.id} (source: ${source})`);
|
|
97
|
+
return this.currentSession;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* End the current session, persist it, return session data.
|
|
102
|
+
* @param {string} reason — why the session ended
|
|
103
|
+
* @param {string} [transcriptPath] — optional path to conversation transcript
|
|
104
|
+
* @returns {object|null} The completed session data, or null if no session active
|
|
105
|
+
*/
|
|
106
|
+
async endSession(reason = 'manual', transcriptPath = null) {
|
|
107
|
+
if (!this.currentSession) return null;
|
|
108
|
+
|
|
109
|
+
await this.flush();
|
|
110
|
+
|
|
111
|
+
closeCurrentBlock(this.currentSession);
|
|
112
|
+
|
|
113
|
+
const session = this.currentSession;
|
|
114
|
+
session.endedAt = new Date().toISOString();
|
|
115
|
+
session.endReason = reason;
|
|
116
|
+
if (transcriptPath) session.transcriptPath = transcriptPath;
|
|
117
|
+
|
|
118
|
+
// Capture phaseAtEnd for bound sessions
|
|
119
|
+
if (session.featureCode) {
|
|
120
|
+
session.phaseAtEnd = this._getFeaturePhase(session.featureCode);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Auto-file transcript to feature folder — awaited to ensure copy completes before process exit
|
|
124
|
+
if (session.featureCode && transcriptPath) {
|
|
125
|
+
try {
|
|
126
|
+
await this._fileTranscript(session.featureCode, session.id, transcriptPath);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
console.error(`[session] Failed to file transcript to ${session.featureCode}:`, err.message);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const serializable = this._serialize(session);
|
|
133
|
+
|
|
134
|
+
this._persist(serializable);
|
|
135
|
+
|
|
136
|
+
console.log(`[session] Ended ${session.id} (reason: ${reason}, tools: ${session.toolCount})`);
|
|
137
|
+
|
|
138
|
+
this.currentSession = null;
|
|
139
|
+
this._pendingBatch = [];
|
|
140
|
+
return serializable;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Record a tool-use event. Accumulates per-item stats, detects block boundaries,
|
|
144
|
+
* and buffers significant events for Haiku summarization. */
|
|
145
|
+
recordActivity(tool, category, filePath, input, resolvedItems = []) {
|
|
146
|
+
if (!this.currentSession) return;
|
|
147
|
+
|
|
148
|
+
const now = new Date().toISOString();
|
|
149
|
+
const session = this.currentSession;
|
|
150
|
+
session.toolCount++;
|
|
151
|
+
|
|
152
|
+
const isWrite = ['Write', 'Edit', 'NotebookEdit'].includes(tool);
|
|
153
|
+
const isRead = tool === 'Read';
|
|
154
|
+
|
|
155
|
+
for (const item of resolvedItems) {
|
|
156
|
+
let acc = session.items.get(item.id);
|
|
157
|
+
if (!acc) {
|
|
158
|
+
acc = {
|
|
159
|
+
title: item.title,
|
|
160
|
+
summaries: [],
|
|
161
|
+
reads: 0,
|
|
162
|
+
writes: 0,
|
|
163
|
+
firstTouched: now,
|
|
164
|
+
lastTouched: now,
|
|
165
|
+
};
|
|
166
|
+
session.items.set(item.id, acc);
|
|
167
|
+
}
|
|
168
|
+
acc.lastTouched = now;
|
|
169
|
+
if (isRead) acc.reads++;
|
|
170
|
+
if (isWrite) acc.writes++;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const itemIds = resolvedItems.map(i => i.id);
|
|
174
|
+
if (itemIds.length > 0) {
|
|
175
|
+
updateBlock(session, itemIds, now, category);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (SIGNIFICANT_TOOLS.has(tool)) {
|
|
179
|
+
this._pendingBatch.push({
|
|
180
|
+
tool,
|
|
181
|
+
category,
|
|
182
|
+
filePath,
|
|
183
|
+
input: this._truncateInput(input),
|
|
184
|
+
itemIds,
|
|
185
|
+
itemTitles: resolvedItems.map(i => i.title),
|
|
186
|
+
timestamp: now,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
if (this._pendingBatch.length >= BATCH_SIZE && !this._flushing) {
|
|
190
|
+
this._flushPromise = this._flushSummary().catch(err => {
|
|
191
|
+
console.error('[session] Background flush failed:', err.message);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Record a detected error. Stored for persistence + Haiku batch context. */
|
|
198
|
+
recordError(tool, filePath, errorType, severity, message, resolvedItems = []) {
|
|
199
|
+
if (!this.currentSession) return;
|
|
200
|
+
|
|
201
|
+
this.currentSession.errors.push({
|
|
202
|
+
type: errorType,
|
|
203
|
+
severity,
|
|
204
|
+
tool,
|
|
205
|
+
filePath: filePath || null,
|
|
206
|
+
message: message.length > 200 ? message.slice(0, 197) + '...' : message,
|
|
207
|
+
itemIds: resolvedItems.map(i => i.id),
|
|
208
|
+
timestamp: new Date().toISOString(),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Force-flush any pending events to Haiku. Awaitable. */
|
|
213
|
+
async flush() {
|
|
214
|
+
if (this._flushPromise) {
|
|
215
|
+
await this._flushPromise;
|
|
216
|
+
}
|
|
217
|
+
if (this._pendingBatch.length > 0) {
|
|
218
|
+
await this._flushSummary();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Register a callback for when Haiku summaries arrive. */
|
|
223
|
+
onSummary(fn) {
|
|
224
|
+
this._summaryListeners.push(fn);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Return the most recent session summary for the SessionStart hook. */
|
|
228
|
+
getContext(featureCode) {
|
|
229
|
+
if (featureCode) {
|
|
230
|
+
return readSessionsByFeature(featureCode, 1, this._sessionsFile)[0] || null;
|
|
231
|
+
}
|
|
232
|
+
return readLastSession(this._sessionsFile);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Bind the current session to a lifecycle feature. One-shot — re-bind returns already_bound. */
|
|
236
|
+
bindToFeature(featureCode, itemId, phase) {
|
|
237
|
+
const session = this.currentSession;
|
|
238
|
+
if (!session) throw new Error('No active session');
|
|
239
|
+
if (session.featureCode) {
|
|
240
|
+
return { already_bound: true, featureCode: session.featureCode };
|
|
241
|
+
}
|
|
242
|
+
session.featureCode = featureCode;
|
|
243
|
+
session.featureItemId = itemId;
|
|
244
|
+
session.phaseAtBind = phase;
|
|
245
|
+
session.boundAt = new Date().toISOString();
|
|
246
|
+
return { bound: true, featureCode, itemId, phase };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** COMP-UX-2b: Return recent sessions for visionState broadcast. */
|
|
250
|
+
getRecentSessions(limit = 20) {
|
|
251
|
+
const sessions = [];
|
|
252
|
+
// Current active session
|
|
253
|
+
if (this.currentSession) {
|
|
254
|
+
const s = this.currentSession;
|
|
255
|
+
let reads = 0, writes = 0, lastSummary = null;
|
|
256
|
+
if (s.items instanceof Map) {
|
|
257
|
+
for (const v of s.items.values()) {
|
|
258
|
+
reads += v.reads || 0;
|
|
259
|
+
writes += v.writes || 0;
|
|
260
|
+
if (v.summaries?.length) {
|
|
261
|
+
const s_ = v.summaries[v.summaries.length - 1];
|
|
262
|
+
lastSummary = typeof s_ === 'string' ? s_ : s_?.summary || null;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
sessions.push({
|
|
267
|
+
id: s.id,
|
|
268
|
+
status: 'active',
|
|
269
|
+
agent: 'claude',
|
|
270
|
+
featureCode: s.featureCode || null,
|
|
271
|
+
summary: lastSummary,
|
|
272
|
+
startedAt: s.startedAt,
|
|
273
|
+
reads, writes,
|
|
274
|
+
errors: s.errors?.length || 0,
|
|
275
|
+
workType: s.phaseAtBind || 'general',
|
|
276
|
+
toolCount: s.toolCount || 0,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
// Persisted sessions (most recent first)
|
|
280
|
+
try {
|
|
281
|
+
const persisted = readSessionsByFeature(null, limit, this._sessionsFile);
|
|
282
|
+
for (const s of persisted) {
|
|
283
|
+
if (sessions.length >= limit) break;
|
|
284
|
+
if (sessions.some(x => x.id === s.id)) continue;
|
|
285
|
+
// Aggregate reads/writes/summaries from per-item data
|
|
286
|
+
let reads = 0, writes = 0;
|
|
287
|
+
let lastSummary = null;
|
|
288
|
+
if (s.items && typeof s.items === 'object') {
|
|
289
|
+
for (const v of Object.values(s.items)) {
|
|
290
|
+
reads += v.reads || 0;
|
|
291
|
+
writes += v.writes || 0;
|
|
292
|
+
if (v.summaries?.length) {
|
|
293
|
+
const s_ = v.summaries[v.summaries.length - 1];
|
|
294
|
+
lastSummary = typeof s_ === 'string' ? s_ : s_?.summary || null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
sessions.push({
|
|
299
|
+
id: s.id,
|
|
300
|
+
status: 'completed',
|
|
301
|
+
agent: 'claude',
|
|
302
|
+
featureCode: s.featureCode || null,
|
|
303
|
+
summary: lastSummary,
|
|
304
|
+
startedAt: s.startedAt,
|
|
305
|
+
reads, writes,
|
|
306
|
+
errors: s.errors?.length || 0,
|
|
307
|
+
workType: s.phaseAtBind || 'general',
|
|
308
|
+
toolCount: s.toolCount || 0,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
} catch { /* sessions file may not exist yet */ }
|
|
312
|
+
return sessions;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Expose sessions file path for use by routes. */
|
|
316
|
+
get sessionsFile() { return this._sessionsFile; }
|
|
317
|
+
|
|
318
|
+
/** True if the current session crosses the journal-worthiness threshold. */
|
|
319
|
+
meetsJournalThreshold() {
|
|
320
|
+
if (!this.currentSession) return false;
|
|
321
|
+
if (this.currentSession.toolCount > 20) return true;
|
|
322
|
+
const elapsed = Date.now() - new Date(this.currentSession.startedAt).getTime();
|
|
323
|
+
return elapsed > 10 * 60 * 1000;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Internal: Haiku pipeline ─────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
async _flushSummary() {
|
|
329
|
+
if (this._pendingBatch.length === 0) return;
|
|
330
|
+
this._flushing = true;
|
|
331
|
+
|
|
332
|
+
const batch = this._pendingBatch.splice(0);
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const prompt = buildSummaryPrompt(batch, PROJECT_ROOT);
|
|
336
|
+
const result = await summarize(prompt, { projectRoot: PROJECT_ROOT });
|
|
337
|
+
if (result) {
|
|
338
|
+
this._distributeSummary(result, batch);
|
|
339
|
+
for (const fn of this._summaryListeners) {
|
|
340
|
+
try { fn(result, batch); } catch { /* listener errors don't propagate */ }
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
} catch (err) {
|
|
344
|
+
console.error('[session] Haiku summary failed, raw data preserved:', err.message);
|
|
345
|
+
} finally {
|
|
346
|
+
this._flushing = false;
|
|
347
|
+
this._flushPromise = null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
_distributeSummary(result, batch) {
|
|
352
|
+
if (!this.currentSession) return;
|
|
353
|
+
|
|
354
|
+
const itemIds = new Set();
|
|
355
|
+
for (const evt of batch) {
|
|
356
|
+
for (const id of evt.itemIds) {
|
|
357
|
+
itemIds.add(id);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const summary = {
|
|
362
|
+
...result,
|
|
363
|
+
batchSize: batch.length,
|
|
364
|
+
timestamp: new Date().toISOString(),
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
for (const id of itemIds) {
|
|
368
|
+
const acc = this.currentSession.items.get(id);
|
|
369
|
+
if (acc) {
|
|
370
|
+
acc.summaries.push(summary);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
console.log(`[session] Haiku summary distributed to ${itemIds.size} items: "${result.summary}"`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Internal: Utilities ──────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
_truncateInput(input) {
|
|
380
|
+
if (!input) return '(no input)';
|
|
381
|
+
const raw = input.content
|
|
382
|
+
|| input.new_string
|
|
383
|
+
|| input.command
|
|
384
|
+
|| input.new_source
|
|
385
|
+
|| input.old_string
|
|
386
|
+
|| input.pattern
|
|
387
|
+
|| input.query
|
|
388
|
+
|| (typeof input === 'string' ? input : JSON.stringify(input));
|
|
389
|
+
const str = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
390
|
+
return str.length > 200 ? str.slice(0, 197) + '...' : str;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async _fileTranscript(featureCode, sessionId, transcriptPath) {
|
|
394
|
+
const ext = path.extname(transcriptPath) || '.transcript';
|
|
395
|
+
const sessionsDir = path.join(this._featureRoot, featureCode, 'sessions');
|
|
396
|
+
await mkdir(sessionsDir, { recursive: true });
|
|
397
|
+
const dest = path.join(sessionsDir, `${sessionId}${ext}`);
|
|
398
|
+
await copyFile(transcriptPath, dest);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
_serialize(session) { return serializeSession(session); }
|
|
402
|
+
_persist(session) { persistSession(session, this._sessionsFile); }
|
|
403
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-routes.js — Session lifecycle REST routes.
|
|
3
|
+
*
|
|
4
|
+
* Routes:
|
|
5
|
+
* POST /api/session/start
|
|
6
|
+
* POST /api/session/end
|
|
7
|
+
* POST /api/session/bind
|
|
8
|
+
* GET /api/session/history
|
|
9
|
+
* GET /api/session/current
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readSessionsByFeature } from './session-store.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Attach session lifecycle routes to an Express app.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} app — Express app
|
|
18
|
+
* @param {{
|
|
19
|
+
* sessionManager: object,
|
|
20
|
+
* scheduleBroadcast: function,
|
|
21
|
+
* broadcastMessage: function,
|
|
22
|
+
* spawnJournalAgent: function,
|
|
23
|
+
* projectRoot: string,
|
|
24
|
+
* store: object
|
|
25
|
+
* }} deps
|
|
26
|
+
*/
|
|
27
|
+
export function attachSessionRoutes(app, { sessionManager, scheduleBroadcast, broadcastMessage, spawnJournalAgent, store }) {
|
|
28
|
+
// POST /api/session/start — hook calls this on SessionStart
|
|
29
|
+
app.post('/api/session/start', (req, res) => {
|
|
30
|
+
const { source } = req.body || {};
|
|
31
|
+
if (!sessionManager) return res.status(503).json({ error: 'No session manager' });
|
|
32
|
+
const session = sessionManager.startSession(source || 'startup');
|
|
33
|
+
const context = sessionManager.getContext();
|
|
34
|
+
|
|
35
|
+
broadcastMessage({
|
|
36
|
+
type: 'sessionStart',
|
|
37
|
+
sessionId: session.id,
|
|
38
|
+
source: source || 'startup',
|
|
39
|
+
timestamp: new Date().toISOString(),
|
|
40
|
+
});
|
|
41
|
+
scheduleBroadcast(); // COMP-UX-2b: refresh sessions list in visionState
|
|
42
|
+
|
|
43
|
+
res.json({ sessionId: session.id, context });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// POST /api/session/end — hook calls this on SessionEnd
|
|
47
|
+
app.post('/api/session/end', async (req, res) => {
|
|
48
|
+
const { reason, transcriptPath } = req.body || {};
|
|
49
|
+
if (!sessionManager) return res.status(503).json({ error: 'No session manager' });
|
|
50
|
+
const meetsThreshold = sessionManager.meetsJournalThreshold();
|
|
51
|
+
const session = await sessionManager.endSession(reason, transcriptPath);
|
|
52
|
+
if (!session) return res.json({ sessionId: null, persisted: false });
|
|
53
|
+
let journalSpawned = false;
|
|
54
|
+
if (meetsThreshold && transcriptPath) {
|
|
55
|
+
spawnJournalAgent(session, transcriptPath);
|
|
56
|
+
journalSpawned = true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
broadcastMessage({
|
|
60
|
+
type: 'sessionEnd',
|
|
61
|
+
sessionId: session.id,
|
|
62
|
+
reason,
|
|
63
|
+
toolCount: session.toolCount,
|
|
64
|
+
duration: Math.round((new Date(session.endedAt) - new Date(session.startedAt)) / 1000),
|
|
65
|
+
journalSpawned,
|
|
66
|
+
featureCode: session.featureCode || null,
|
|
67
|
+
phaseAtEnd: session.phaseAtEnd || null,
|
|
68
|
+
timestamp: new Date().toISOString(),
|
|
69
|
+
});
|
|
70
|
+
scheduleBroadcast(); // COMP-UX-2b: refresh sessions list in visionState
|
|
71
|
+
|
|
72
|
+
res.json({ sessionId: session.id, persisted: true, journalSpawned });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// POST /api/session/bind — bind active session to a lifecycle feature
|
|
76
|
+
app.post('/api/session/bind', (req, res) => {
|
|
77
|
+
try {
|
|
78
|
+
const { featureCode } = req.body;
|
|
79
|
+
if (!featureCode) return res.status(400).json({ error: 'featureCode required' });
|
|
80
|
+
if (!/^[A-Za-z0-9_-]+$/.test(featureCode)) return res.status(400).json({ error: 'Invalid featureCode' });
|
|
81
|
+
|
|
82
|
+
const session = sessionManager.currentSession;
|
|
83
|
+
if (!session) return res.status(409).json({ error: 'No active session' });
|
|
84
|
+
|
|
85
|
+
// If already bound, return early without validating the new feature code
|
|
86
|
+
if (session.featureCode) {
|
|
87
|
+
return res.json({ already_bound: true, featureCode: session.featureCode });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Look up the vision item for this feature — reject if not found
|
|
91
|
+
const item = store.getItemByFeatureCode(featureCode);
|
|
92
|
+
if (!item) return res.status(404).json({ error: `No lifecycle item for feature code: ${featureCode}` });
|
|
93
|
+
const itemId = item.id;
|
|
94
|
+
const phase = item.lifecycle?.currentPhase || null;
|
|
95
|
+
|
|
96
|
+
const result = sessionManager.bindToFeature(featureCode, itemId, phase);
|
|
97
|
+
|
|
98
|
+
if (!result.already_bound) {
|
|
99
|
+
broadcastMessage({
|
|
100
|
+
type: 'sessionBound',
|
|
101
|
+
sessionId: session.id,
|
|
102
|
+
featureCode,
|
|
103
|
+
itemId,
|
|
104
|
+
phase,
|
|
105
|
+
timestamp: new Date().toISOString(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
res.json(result);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
res.status(500).json({ error: err.message });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// GET /api/session/history — sessions bound to a feature
|
|
116
|
+
app.get('/api/session/history', (req, res) => {
|
|
117
|
+
try {
|
|
118
|
+
const { featureCode, limit } = req.query;
|
|
119
|
+
if (!featureCode) return res.status(400).json({ error: 'featureCode required' });
|
|
120
|
+
const sessions = readSessionsByFeature(featureCode, parseInt(limit) || 10, sessionManager.sessionsFile);
|
|
121
|
+
res.json({ sessions });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
res.status(500).json({ error: err.message });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// GET /api/session/current — current session state (with optional featureCode enrichment)
|
|
128
|
+
app.get('/api/session/current', (_req, res) => {
|
|
129
|
+
const { featureCode } = _req.query;
|
|
130
|
+
|
|
131
|
+
if (!sessionManager?.currentSession) {
|
|
132
|
+
if (featureCode) {
|
|
133
|
+
return res.json(_buildFeatureContext(featureCode, null));
|
|
134
|
+
}
|
|
135
|
+
return res.json({ session: null });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const s = sessionManager.currentSession;
|
|
139
|
+
const items = {};
|
|
140
|
+
const allSummaries = [];
|
|
141
|
+
for (const [id, acc] of s.items) {
|
|
142
|
+
items[id] = { title: acc.title, reads: acc.reads, writes: acc.writes, summaries: acc.summaries };
|
|
143
|
+
for (const summary of (acc.summaries || [])) {
|
|
144
|
+
if (!summary) continue;
|
|
145
|
+
allSummaries.push(typeof summary === 'string' ? { summary } : summary);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const sessionData = {
|
|
149
|
+
id: s.id, startedAt: s.startedAt, source: s.source, toolCount: s.toolCount,
|
|
150
|
+
blockCount: s.blocks.length, errorCount: (s.errors || []).length, items,
|
|
151
|
+
summaries: allSummaries,
|
|
152
|
+
featureCode: s.featureCode || null,
|
|
153
|
+
featureItemId: s.featureItemId || null,
|
|
154
|
+
phaseAtBind: s.phaseAtBind || null,
|
|
155
|
+
boundAt: s.boundAt || null,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// When featureCode requested and active session is bound to THAT feature
|
|
159
|
+
if (featureCode && s.featureCode === featureCode) {
|
|
160
|
+
return res.json(_buildFeatureContext(featureCode, sessionData));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// When featureCode requested but active session is for a DIFFERENT feature
|
|
164
|
+
if (featureCode && s.featureCode !== featureCode) {
|
|
165
|
+
return res.json(_buildFeatureContext(featureCode, null));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// No featureCode requested — return generic active session (existing behavior)
|
|
169
|
+
res.json({ session: sessionData });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Helper: normalize feature-aware response shape across all branches
|
|
173
|
+
function _buildFeatureContext(featureCode, sessionData) {
|
|
174
|
+
const item = store.getItemByFeatureCode(featureCode);
|
|
175
|
+
const recentSessions = readSessionsByFeature(featureCode, 3, sessionManager.sessionsFile);
|
|
176
|
+
const recentSummaries = recentSessions
|
|
177
|
+
.flatMap(rs => Object.values(rs.items || {}).flatMap(i => i.summaries || []))
|
|
178
|
+
.slice(0, 10);
|
|
179
|
+
return {
|
|
180
|
+
session: sessionData || recentSessions[0] || null,
|
|
181
|
+
lifecycle: item?.lifecycle ? {
|
|
182
|
+
currentPhase: item.lifecycle.currentPhase,
|
|
183
|
+
phaseHistory: (item.lifecycle.phaseHistory || []).map(h => ({ phase: h.phase, enteredAt: h.enteredAt, exitedAt: h.exitedAt })),
|
|
184
|
+
artifacts: item.lifecycle.artifacts || {},
|
|
185
|
+
pendingGate: item.lifecycle.pendingGate || null,
|
|
186
|
+
} : null,
|
|
187
|
+
recentSummaries,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-store.js — Session persistence: serialize, persist, and read context.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from SessionManager. All functions are pure/stateless helpers
|
|
5
|
+
* that operate on plain session objects and the sessions.json file path.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Convert session state (with Maps/Sets) to a plain serializable object.
|
|
13
|
+
* @param {object} session
|
|
14
|
+
* @returns {object}
|
|
15
|
+
*/
|
|
16
|
+
export function serializeSession(session) {
|
|
17
|
+
const items = {};
|
|
18
|
+
for (const [id, acc] of session.items) {
|
|
19
|
+
items[id] = { ...acc };
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
id: session.id,
|
|
23
|
+
startedAt: session.startedAt,
|
|
24
|
+
endedAt: session.endedAt || null,
|
|
25
|
+
endReason: session.endReason || null,
|
|
26
|
+
source: session.source,
|
|
27
|
+
toolCount: session.toolCount,
|
|
28
|
+
items,
|
|
29
|
+
blocks: session.blocks,
|
|
30
|
+
commits: session.commits,
|
|
31
|
+
errors: session.errors || [],
|
|
32
|
+
transcriptPath: session.transcriptPath || null,
|
|
33
|
+
featureCode: session.featureCode || null,
|
|
34
|
+
featureItemId: session.featureItemId || null,
|
|
35
|
+
phaseAtBind: session.phaseAtBind || null,
|
|
36
|
+
phaseAtEnd: session.phaseAtEnd || null,
|
|
37
|
+
boundAt: session.boundAt || null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Append a completed session to data/sessions.json.
|
|
43
|
+
* @param {object} session — serialized session data
|
|
44
|
+
* @param {string} sessionsFile — absolute path to sessions.json
|
|
45
|
+
*/
|
|
46
|
+
export function persistSession(session, sessionsFile) {
|
|
47
|
+
try {
|
|
48
|
+
fs.mkdirSync(path.dirname(sessionsFile), { recursive: true });
|
|
49
|
+
|
|
50
|
+
let sessions = [];
|
|
51
|
+
try {
|
|
52
|
+
const raw = fs.readFileSync(sessionsFile, 'utf-8');
|
|
53
|
+
sessions = JSON.parse(raw);
|
|
54
|
+
if (!Array.isArray(sessions)) sessions = [];
|
|
55
|
+
} catch (parseErr) {
|
|
56
|
+
if (parseErr.code !== 'ENOENT') {
|
|
57
|
+
const backup = sessionsFile + '.bak';
|
|
58
|
+
try { fs.copyFileSync(sessionsFile, backup); } catch { /* best effort */ }
|
|
59
|
+
console.warn(`[session] Corrupted sessions.json backed up to ${backup}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
sessions.push(session);
|
|
64
|
+
fs.writeFileSync(sessionsFile, JSON.stringify(sessions, null, 2), 'utf-8');
|
|
65
|
+
console.log(`[session] Persisted to ${sessionsFile} (${sessions.length} total sessions)`);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error('[session] Failed to persist session:', err.message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read the most recent session from sessions.json.
|
|
73
|
+
* @param {string} sessionsFile — absolute path to sessions.json
|
|
74
|
+
* @returns {object|null}
|
|
75
|
+
*/
|
|
76
|
+
export function readLastSession(sessionsFile) {
|
|
77
|
+
try {
|
|
78
|
+
const raw = fs.readFileSync(sessionsFile, 'utf-8');
|
|
79
|
+
const sessions = JSON.parse(raw);
|
|
80
|
+
if (Array.isArray(sessions) && sessions.length > 0) {
|
|
81
|
+
return sessions[sessions.length - 1];
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// No sessions file yet — that's fine
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read sessions bound to a specific feature, sorted descending by startedAt.
|
|
91
|
+
* @param {string} featureCode
|
|
92
|
+
* @param {number} limit — max results to return
|
|
93
|
+
* @param {string} sessionsFile — absolute path to sessions.json
|
|
94
|
+
* @returns {Array<object>}
|
|
95
|
+
*/
|
|
96
|
+
export function readSessionsByFeature(featureCode, limit, sessionsFile) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = fs.readFileSync(sessionsFile, 'utf8');
|
|
99
|
+
const sessions = JSON.parse(raw);
|
|
100
|
+
return sessions
|
|
101
|
+
.filter(s => featureCode === null || featureCode === undefined ? true : s.featureCode === featureCode)
|
|
102
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt))
|
|
103
|
+
.slice(0, limit);
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}
|