forge-server 0.1.0 → 0.1.1

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 (119) hide show
  1. package/bin/setup-forge.sh +1 -1
  2. package/bin/setup.js +99 -0
  3. package/dist/cli.js +37 -37
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +4 -4
  6. package/dist/index.js.map +1 -1
  7. package/dist/storage/schema.js +113 -113
  8. package/dist/storage/schema.js.map +1 -1
  9. package/dist/storage/sqlite.js +1 -1
  10. package/dist/storage/sqlite.js.map +1 -1
  11. package/dist/util/logger.d.ts +1 -1
  12. package/dist/util/logger.js +1 -1
  13. package/dist/util/types.js +1 -1
  14. package/dist/util/types.js.map +1 -1
  15. package/package.json +8 -2
  16. package/plugin/.mcp.json +1 -1
  17. package/.claude/hooks/worktree-create.sh +0 -64
  18. package/.claude/hooks/worktree-remove.sh +0 -57
  19. package/.claude/settings.local.json +0 -29
  20. package/.forge/knowledge/conventions.yaml +0 -1
  21. package/.forge/knowledge/decisions.yaml +0 -1
  22. package/.forge/knowledge/gotchas.yaml +0 -1
  23. package/.forge/knowledge/patterns.yaml +0 -1
  24. package/.forge/manifest.yaml +0 -6
  25. package/CLAUDE.md +0 -144
  26. package/docker-compose.yml +0 -20
  27. package/docs/plans/2026-02-27-swarm-coordination/architecture.md +0 -203
  28. package/docs/plans/2026-02-27-swarm-coordination/vision.md +0 -57
  29. package/docs/plans/completed/2026-02-26-forge-plugin-bundling/architecture.md +0 -1
  30. package/docs/plans/completed/2026-02-26-forge-plugin-bundling/vision.md +0 -300
  31. package/docs/plans/completed/2026-02-27-forge-swarm-learning/architecture.md +0 -480
  32. package/docs/plans/completed/2026-02-27-forge-swarm-learning/verification-checklist.md +0 -462
  33. package/docs/plans/completed/2026-02-27-git-history-atlassian/git-jira-plan.md +0 -181
  34. package/src/cli.ts +0 -655
  35. package/src/context/.gitkeep +0 -0
  36. package/src/context/codebase.ts +0 -393
  37. package/src/context/injector.ts +0 -797
  38. package/src/context/memory.ts +0 -187
  39. package/src/context/session-index.ts +0 -327
  40. package/src/context/session.ts +0 -152
  41. package/src/index.ts +0 -47
  42. package/src/ingestion/.gitkeep +0 -0
  43. package/src/ingestion/chunker.ts +0 -277
  44. package/src/ingestion/embedder.ts +0 -167
  45. package/src/ingestion/git-analyzer.ts +0 -545
  46. package/src/ingestion/indexer.ts +0 -984
  47. package/src/ingestion/markdown-chunker.ts +0 -337
  48. package/src/ingestion/markdown-knowledge.ts +0 -175
  49. package/src/ingestion/parser.ts +0 -475
  50. package/src/ingestion/watcher.ts +0 -182
  51. package/src/knowledge/.gitkeep +0 -0
  52. package/src/knowledge/hydrator.ts +0 -246
  53. package/src/knowledge/registry.ts +0 -463
  54. package/src/knowledge/search.ts +0 -565
  55. package/src/knowledge/store.ts +0 -262
  56. package/src/learning/.gitkeep +0 -0
  57. package/src/learning/confidence.ts +0 -193
  58. package/src/learning/patterns.ts +0 -360
  59. package/src/learning/trajectory.ts +0 -268
  60. package/src/memory/.gitkeep +0 -0
  61. package/src/memory/memory-compat.ts +0 -233
  62. package/src/memory/observation-store.ts +0 -224
  63. package/src/memory/session-tracker.ts +0 -332
  64. package/src/pipeline/.gitkeep +0 -0
  65. package/src/pipeline/engine.ts +0 -1139
  66. package/src/pipeline/events.ts +0 -253
  67. package/src/pipeline/parallel.ts +0 -394
  68. package/src/pipeline/state-machine.ts +0 -199
  69. package/src/query/.gitkeep +0 -0
  70. package/src/query/graph-queries.ts +0 -262
  71. package/src/query/hybrid-search.ts +0 -337
  72. package/src/query/intent-detector.ts +0 -131
  73. package/src/query/ranking.ts +0 -161
  74. package/src/server.ts +0 -352
  75. package/src/storage/.gitkeep +0 -0
  76. package/src/storage/falkordb-store.ts +0 -388
  77. package/src/storage/file-cache.ts +0 -141
  78. package/src/storage/interfaces.ts +0 -201
  79. package/src/storage/qdrant-store.ts +0 -557
  80. package/src/storage/schema.ts +0 -139
  81. package/src/storage/sqlite.ts +0 -168
  82. package/src/tools/.gitkeep +0 -0
  83. package/src/tools/collaboration-tools.ts +0 -208
  84. package/src/tools/context-tools.ts +0 -493
  85. package/src/tools/graph-tools.ts +0 -295
  86. package/src/tools/ingestion-tools.ts +0 -122
  87. package/src/tools/learning-tools.ts +0 -181
  88. package/src/tools/memory-tools.ts +0 -234
  89. package/src/tools/phase-tools.ts +0 -1452
  90. package/src/tools/pipeline-tools.ts +0 -188
  91. package/src/tools/registration-tools.ts +0 -450
  92. package/src/util/.gitkeep +0 -0
  93. package/src/util/circuit-breaker.ts +0 -193
  94. package/src/util/config.ts +0 -177
  95. package/src/util/logger.ts +0 -53
  96. package/src/util/token-counter.ts +0 -52
  97. package/src/util/types.ts +0 -710
  98. package/tests/context/.gitkeep +0 -0
  99. package/tests/integration/.gitkeep +0 -0
  100. package/tests/knowledge/.gitkeep +0 -0
  101. package/tests/learning/.gitkeep +0 -0
  102. package/tests/pipeline/.gitkeep +0 -0
  103. package/tests/tools/.gitkeep +0 -0
  104. package/tsconfig.json +0 -21
  105. package/vitest.config.ts +0 -10
  106. package/vscode-extension/.vscodeignore +0 -7
  107. package/vscode-extension/README.md +0 -43
  108. package/vscode-extension/out/edge-collector.js +0 -274
  109. package/vscode-extension/out/edge-collector.js.map +0 -1
  110. package/vscode-extension/out/extension.js +0 -264
  111. package/vscode-extension/out/extension.js.map +0 -1
  112. package/vscode-extension/out/forge-client.js +0 -318
  113. package/vscode-extension/out/forge-client.js.map +0 -1
  114. package/vscode-extension/package-lock.json +0 -59
  115. package/vscode-extension/package.json +0 -71
  116. package/vscode-extension/src/edge-collector.ts +0 -320
  117. package/vscode-extension/src/extension.ts +0 -269
  118. package/vscode-extension/src/forge-client.ts +0 -364
  119. package/vscode-extension/tsconfig.json +0 -19
@@ -1,187 +0,0 @@
1
- // MemoryBridge — B8
2
- //
3
- // Wraps the Qdrant vector store's observation collection to provide
4
- // memory search and save operations for the context injection layer.
5
- //
6
- // Uses the QdrantVectorStore directly (not the VectorStore interface) because
7
- // we need the searchObservations + upsertObservation methods that are Qdrant-specific.
8
- // When vectorStore is null or unhealthy, all methods degrade gracefully.
9
-
10
- import { randomUUID } from 'node:crypto';
11
- import type { QdrantVectorStore } from '../storage/qdrant-store.js';
12
- import type { ObservationPayload } from '../util/types.js';
13
- import { embedText } from '../ingestion/embedder.js';
14
- import { logger } from '../util/logger.js';
15
-
16
- // ---------------------------------------------------------------------------
17
- // Public types
18
- // ---------------------------------------------------------------------------
19
-
20
- export interface MemorySearchResult {
21
- content: string;
22
- createdAt: number;
23
- relevanceScore: number;
24
- sessionId?: string;
25
- }
26
-
27
- export interface MemorySearchOptions {
28
- repoId?: string;
29
- sessionId?: string;
30
- limit?: number;
31
- scoreThreshold?: number;
32
- }
33
-
34
- // ---------------------------------------------------------------------------
35
- // MemoryBridge
36
- // ---------------------------------------------------------------------------
37
-
38
- export class MemoryBridge {
39
- constructor(private readonly vectorStore: QdrantVectorStore | null) {}
40
-
41
- /**
42
- * Search observations by semantic similarity.
43
- * Optionally filters by repoId and/or sessionId.
44
- * Returns [] gracefully when Qdrant is unavailable.
45
- */
46
- async searchMemories(
47
- query: string,
48
- options: MemorySearchOptions = {},
49
- ): Promise<MemorySearchResult[]> {
50
- if (!this.vectorStore) {
51
- return [];
52
- }
53
-
54
- const healthy = await this.vectorStore.isHealthy();
55
- if (!healthy) {
56
- logger.debug('MemoryBridge: Qdrant unavailable — returning empty memories');
57
- return [];
58
- }
59
-
60
- const {
61
- repoId,
62
- sessionId,
63
- limit = 10,
64
- scoreThreshold = 0.3,
65
- } = options;
66
-
67
- let queryVector: number[];
68
- try {
69
- queryVector = await embedText(query);
70
- } catch (err) {
71
- logger.warn('MemoryBridge: embedding failed', { error: String(err) });
72
- return [];
73
- }
74
-
75
- // Build filter — only include conditions for fields that are specified
76
- const mustConditions: Record<string, unknown>[] = [];
77
- if (repoId) {
78
- mustConditions.push({ key: 'repo_id', match: { value: repoId } });
79
- }
80
- if (sessionId) {
81
- mustConditions.push({ key: 'session_id', match: { value: sessionId } });
82
- }
83
-
84
- // Exclude session markers from memory results
85
- const mustNotConditions: Record<string, unknown>[] = [
86
- { key: 'tags', match: { value: 'session_marker' } },
87
- ];
88
-
89
- const filter: Record<string, unknown> =
90
- mustConditions.length > 0
91
- ? { must: mustConditions, must_not: mustNotConditions }
92
- : { must_not: mustNotConditions };
93
-
94
- try {
95
- const results = await this.vectorStore.searchObservations(queryVector, {
96
- limit,
97
- scoreThreshold,
98
- filter,
99
- });
100
-
101
- return results
102
- .map((r) => {
103
- const payload = r.payload as ObservationPayload;
104
- return {
105
- content: payload.content,
106
- createdAt: payload.created_at,
107
- relevanceScore: Math.round(r.score * 1000) / 1000,
108
- sessionId: payload.session_id || undefined,
109
- };
110
- })
111
- .filter((m) => m.content && m.content.trim().length > 0);
112
- } catch (err) {
113
- logger.warn('MemoryBridge: search failed', { error: String(err) });
114
- return [];
115
- }
116
- }
117
-
118
- /**
119
- * Save a memory (observation) to the vector store.
120
- * Returns the UUID of the stored observation.
121
- * Throws when storage fails so the caller can decide whether to propagate.
122
- */
123
- async saveMemory(
124
- content: string,
125
- repoId: string,
126
- sessionId?: string,
127
- ): Promise<string> {
128
- if (!this.vectorStore) {
129
- throw new Error('MemoryBridge: vector store not configured');
130
- }
131
-
132
- const healthy = await this.vectorStore.isHealthy();
133
- if (!healthy) {
134
- throw new Error('MemoryBridge: Qdrant is unavailable');
135
- }
136
-
137
- const id = randomUUID();
138
- const now = Date.now();
139
- const effectiveSessionId = sessionId ?? `session-${now}`;
140
-
141
- let vector: number[];
142
- try {
143
- vector = await embedText(content);
144
- } catch (err) {
145
- logger.warn('MemoryBridge: embedding failed, using zero vector', {
146
- error: String(err),
147
- });
148
- vector = new Array(384).fill(0);
149
- }
150
-
151
- const payload: ObservationPayload = {
152
- id,
153
- repo_id: repoId,
154
- session_id: effectiveSessionId,
155
- content,
156
- type: 'semantic',
157
- category: null,
158
- tags: [],
159
- importance: 0.5,
160
- created_at: now,
161
- accessed_at: now,
162
- access_count: 0,
163
- is_stale: false,
164
- linked_symbols: [],
165
- source: 'forge',
166
- metadata: {},
167
- key: null,
168
- namespace: 'default',
169
- };
170
-
171
- await this.vectorStore.upsertObservation(id, vector, payload);
172
-
173
- logger.debug('MemoryBridge: memory saved', {
174
- id,
175
- repoId,
176
- sessionId: effectiveSessionId,
177
- contentLength: content.length,
178
- });
179
-
180
- return id;
181
- }
182
-
183
- /** True when the vector store is configured (does not perform a health check). */
184
- isAvailable(): boolean {
185
- return this.vectorStore !== null;
186
- }
187
- }
@@ -1,327 +0,0 @@
1
- // session-index.ts
2
- //
3
- // Session-scoped context index — virtual memory for LLM context windows.
4
- // Instead of pruning old context (losing it), offloads to Qdrant for
5
- // semantic retrieval. The injector searches this before assembling
6
- // context, retrieving only what's relevant to the current task.
7
- //
8
- // Uses existing forge_observations collection with type='working' to
9
- // avoid creating new infrastructure.
10
-
11
- import type { QdrantVectorStore } from '../storage/qdrant-store.js';
12
- import type { GraphStore } from '../storage/interfaces.js';
13
- import { saveObservation } from '../memory/observation-store.js';
14
- import { embedText } from '../ingestion/embedder.js';
15
- import { logger } from '../util/logger.js';
16
-
17
- // No-op graph store for saveObservation calls (graph linking not needed for cache)
18
- const noOpGraph: GraphStore = {
19
- connect: async () => {},
20
- disconnect: async () => {},
21
- isHealthy: async () => false,
22
- query: async () => ({ nodes: [], edges: [] }),
23
- upsertNode: async () => {},
24
- upsertEdge: async () => {},
25
- deleteFile: async () => {},
26
- deleteRepo: async () => {},
27
- getCounts: async () => ({ totalNodes: 0, totalEdges: 0, byLabel: {} }),
28
- ensureIndexes: async () => {},
29
- };
30
-
31
- // Phase names for tag extraction
32
- const KNOWN_PHASES = [
33
- 'interview',
34
- 'requirements',
35
- 'architecture',
36
- 'design',
37
- 'qa_strategy',
38
- 'implementation',
39
- 'inspection',
40
- 'knowledge_collection',
41
- ] as const;
42
-
43
- // Event types for tag extraction
44
- const KNOWN_EVENT_TYPES = [
45
- 'phase_output',
46
- 'broadcast',
47
- 'tool_result',
48
- 'agent_summary',
49
- ] as const;
50
-
51
- export interface SessionEvent {
52
- type: 'phase_output' | 'broadcast' | 'tool_result' | 'agent_summary';
53
- phase: string;
54
- agent: string;
55
- projectId: string;
56
- content: string;
57
- created_at: number;
58
- }
59
-
60
- export interface TieredContext {
61
- /** Tier 0: critical directives, contracts, blockers — always injected */
62
- alwaysInject: string[];
63
- /** Tier 1: top-K semantic matches for this module — smart-injected */
64
- smartInjected: string[];
65
- /** Tier 2: count of remaining searchable items (for the agent's awareness) */
66
- searchableCount: number;
67
- }
68
-
69
- export class SessionContextIndex {
70
- constructor(
71
- private readonly vectorStore: QdrantVectorStore,
72
- private readonly sessionId: string,
73
- ) {}
74
-
75
- /**
76
- * Index a session event for later retrieval.
77
- * Fire-and-forget — never blocks the caller.
78
- */
79
- async index(event: SessionEvent): Promise<void> {
80
- try {
81
- await saveObservation(
82
- {
83
- content: event.content.slice(0, 2000),
84
- sessionId: this.sessionId,
85
- type: 'working',
86
- category: `session_${event.type}`,
87
- importance: event.type === 'broadcast' ? 0.7 : 0.5,
88
- tags: [
89
- 'session_cache',
90
- this.sessionId,
91
- event.projectId,
92
- event.phase,
93
- event.agent,
94
- event.type,
95
- ],
96
- },
97
- this.vectorStore,
98
- noOpGraph,
99
- );
100
- } catch (err) {
101
- logger.debug('SessionContextIndex.index: failed (non-fatal)', {
102
- type: event.type,
103
- error: String(err),
104
- });
105
- }
106
- }
107
-
108
- /**
109
- * Semantic search scoped to this session + project.
110
- * Returns the most relevant cached items for a query.
111
- */
112
- async retrieve(
113
- query: string,
114
- projectId: string,
115
- limit: number = 5,
116
- ): Promise<SessionEvent[]> {
117
- try {
118
- // Attempt semantic search first — requires embedding the query
119
- let queryVector: number[] | null = null;
120
- try {
121
- queryVector = await embedText(query);
122
- } catch {
123
- // Embedding unavailable — fall back to filter-only below
124
- }
125
-
126
- if (queryVector) {
127
- // Semantic search: ranked by vector similarity
128
- const results = await this.vectorStore.searchObservations(queryVector, {
129
- limit,
130
- scoreThreshold: 0.2,
131
- filter: {
132
- must: [
133
- { key: 'tags', match: { value: 'session_cache' } },
134
- { key: 'tags', match: { value: this.sessionId } },
135
- { key: 'tags', match: { value: projectId } },
136
- ],
137
- },
138
- });
139
- return results.map((r) => this.resultToSessionEvent(r.payload as unknown as Record<string, unknown>, projectId));
140
- }
141
-
142
- // Fallback: filter-only retrieval (no semantic ranking)
143
- const filter = {
144
- must: [
145
- { key: 'tags', match: { value: 'session_cache' } },
146
- { key: 'tags', match: { value: this.sessionId } },
147
- { key: 'tags', match: { value: projectId } },
148
- ],
149
- };
150
- const results = await this.vectorStore.filterObservations(filter, limit);
151
- return results.map((r) => this.resultToSessionEvent(r.payload as unknown as Record<string, unknown>, projectId));
152
- } catch (err) {
153
- logger.debug('SessionContextIndex.retrieve: failed (non-fatal)', { error: String(err) });
154
- return [];
155
- }
156
- }
157
-
158
- /**
159
- * Build tiered context for a module using semantic search.
160
- *
161
- * Tier 0: Items tagged as critical/directive (importance >= 0.8) — always injected
162
- * Tier 1: Top-K semantic matches for the module description — smart-injected
163
- * Tier 2: Count of remaining items (awareness only)
164
- */
165
- async buildTieredContext(
166
- moduleDescription: string,
167
- projectId: string,
168
- ): Promise<TieredContext> {
169
- const defaultCtx: TieredContext = {
170
- alwaysInject: [],
171
- smartInjected: [],
172
- searchableCount: 0,
173
- };
174
-
175
- try {
176
- // Tier 0: All session cache items for this project (we filter high-importance in memory)
177
- const allFilter = {
178
- must: [
179
- { key: 'tags', match: { value: 'session_cache' } },
180
- { key: 'tags', match: { value: this.sessionId } },
181
- { key: 'tags', match: { value: projectId } },
182
- ],
183
- };
184
- const allResults = await this.vectorStore.filterObservations(allFilter, 50);
185
-
186
- // Extract high-importance items for Tier 0
187
- const criticals = allResults.filter((r) => {
188
- const payload = r.payload as unknown as Record<string, unknown>;
189
- return (payload['importance'] as number) >= 0.8;
190
- });
191
- defaultCtx.alwaysInject = criticals.map((r) => {
192
- const payload = r.payload as unknown as Record<string, unknown>;
193
- return (payload['content'] as string) ?? '';
194
- });
195
-
196
- // Tier 1: Semantic search for this module's description
197
- // Requires embedding the module description into a query vector
198
- let smartResults: string[] = [];
199
- if (moduleDescription) {
200
- let queryVector: number[] | null = null;
201
- try {
202
- queryVector = await embedText(moduleDescription);
203
- } catch {
204
- // Embedding unavailable — skip semantic search
205
- }
206
-
207
- if (queryVector) {
208
- const searchResults = await this.vectorStore.searchObservations(
209
- queryVector,
210
- {
211
- limit: 5,
212
- scoreThreshold: 0.3,
213
- filter: {
214
- must: [
215
- { key: 'tags', match: { value: 'session_cache' } },
216
- { key: 'tags', match: { value: this.sessionId } },
217
- { key: 'tags', match: { value: projectId } },
218
- ],
219
- },
220
- },
221
- );
222
- smartResults = searchResults.map((r) => {
223
- const payload = r.payload as unknown as Record<string, unknown>;
224
- return (payload['content'] as string) ?? '';
225
- });
226
- } else {
227
- // Fallback: use the non-critical filter results as Tier 1
228
- const nonCriticalItems = allResults.filter((r) => {
229
- const payload = r.payload as unknown as Record<string, unknown>;
230
- return (payload['importance'] as number) < 0.8;
231
- });
232
- smartResults = nonCriticalItems.slice(0, 5).map((r) => {
233
- const payload = r.payload as unknown as Record<string, unknown>;
234
- return (payload['content'] as string) ?? '';
235
- });
236
- }
237
- }
238
- defaultCtx.smartInjected = smartResults;
239
-
240
- // Tier 2: Total count minus what we already injected
241
- defaultCtx.searchableCount = Math.max(
242
- 0,
243
- allResults.length - defaultCtx.alwaysInject.length - defaultCtx.smartInjected.length,
244
- );
245
-
246
- return defaultCtx;
247
- } catch (err) {
248
- logger.debug('SessionContextIndex.buildTieredContext: failed (non-fatal)', { error: String(err) });
249
- return defaultCtx;
250
- }
251
- }
252
-
253
- /**
254
- * End-of-session cleanup: promote high-value items to permanent
255
- * observations, let the rest expire naturally.
256
- */
257
- async promoteAndExpire(): Promise<{ promoted: number; expired: number }> {
258
- try {
259
- const filter = {
260
- must: [
261
- { key: 'tags', match: { value: 'session_cache' } },
262
- { key: 'tags', match: { value: this.sessionId } },
263
- ],
264
- };
265
- const all = await this.vectorStore.filterObservations(filter, 100);
266
-
267
- let promoted = 0;
268
- for (const item of all) {
269
- const payload = item.payload as unknown as Record<string, unknown>;
270
- const importance = (payload['importance'] as number) ?? 0;
271
- // Promote items with high importance to permanent episodic memory
272
- if (importance >= 0.7) {
273
- await saveObservation(
274
- {
275
- content: (payload['content'] as string) ?? '',
276
- sessionId: this.sessionId,
277
- type: 'episodic',
278
- category: (payload['category'] as string) ?? 'session_promoted',
279
- importance,
280
- tags: ((payload['tags'] as string[]) ?? []).filter(t => t !== 'session_cache'),
281
- },
282
- this.vectorStore,
283
- noOpGraph,
284
- );
285
- promoted++;
286
- }
287
- }
288
-
289
- return { promoted, expired: all.length - promoted };
290
- } catch (err) {
291
- logger.debug('SessionContextIndex.promoteAndExpire: failed (non-fatal)', { error: String(err) });
292
- return { promoted: 0, expired: 0 };
293
- }
294
- }
295
-
296
- /**
297
- * Convert a raw Qdrant payload into a SessionEvent.
298
- * Extracts type, phase, and agent from the tags array.
299
- */
300
- private resultToSessionEvent(
301
- payload: Record<string, unknown>,
302
- projectId: string,
303
- ): SessionEvent {
304
- const tags = (payload['tags'] as string[]) ?? [];
305
-
306
- const eventType = tags.find((t): t is SessionEvent['type'] =>
307
- (KNOWN_EVENT_TYPES as readonly string[]).includes(t),
308
- ) ?? 'phase_output';
309
-
310
- const phase = tags.find((t) =>
311
- (KNOWN_PHASES as readonly string[]).includes(t),
312
- ) ?? 'unknown';
313
-
314
- // Agent is stored at index 4 in the tags array:
315
- // ['session_cache', sessionId, projectId, phase, agent, type]
316
- const agent = tags[4] ?? 'unknown';
317
-
318
- return {
319
- type: eventType,
320
- phase,
321
- agent,
322
- projectId,
323
- content: (payload['content'] as string) ?? '',
324
- created_at: (payload['created_at'] as number) ?? 0,
325
- };
326
- }
327
- }
@@ -1,152 +0,0 @@
1
- // SessionManager — B9
2
- //
3
- // Manages session lifecycle against the SQLite sessions table.
4
- // Sessions are lightweight records that tie memory observations and
5
- // phase transitions to a chronological window so the context injector can
6
- // fetch "what was learned in this session" when assembling phase context.
7
- //
8
- // Design:
9
- // - All operations are synchronous SQLite reads/writes (better-sqlite3).
10
- // - Session IDs follow the format "session-{timestamp}-{random4}".
11
- // - State is stored as a JSON blob in the `state` column; callers can
12
- // stash arbitrary resumption data there.
13
- // - observation_count is incremented via a single UPDATE statement so
14
- // concurrent callers in the same process do not race.
15
-
16
- import { randomUUID } from 'node:crypto';
17
- import type { PipelineDB } from '../storage/sqlite.js';
18
- import type { Session, SessionRow, SessionState } from '../util/types.js';
19
- import { logger } from '../util/logger.js';
20
-
21
- // ---------------------------------------------------------------------------
22
- // Row deserialization helper
23
- // ---------------------------------------------------------------------------
24
-
25
- function rowToSession(row: SessionRow): Session {
26
- let state: SessionState = {};
27
- try {
28
- state = JSON.parse(row.state) as SessionState;
29
- } catch {
30
- state = {};
31
- }
32
-
33
- return {
34
- id: row.id,
35
- projectId: row.project_id ?? null,
36
- repoId: row.repo_id ?? null,
37
- startedAt: row.started_at,
38
- endedAt: row.ended_at ?? null,
39
- state,
40
- observationCount: row.observation_count,
41
- };
42
- }
43
-
44
- // ---------------------------------------------------------------------------
45
- // SessionManager
46
- // ---------------------------------------------------------------------------
47
-
48
- export class SessionManager {
49
- constructor(private readonly db: PipelineDB) {}
50
-
51
- /**
52
- * Create and persist a new session.
53
- * Returns the new session ID.
54
- */
55
- startSession(repoId?: string, projectId?: string): string {
56
- const id = `session-${Date.now()}-${randomUUID().slice(0, 4)}`;
57
- const now = Date.now();
58
-
59
- this.db.run(
60
- `INSERT INTO sessions (id, project_id, repo_id, started_at, ended_at, state, observation_count)
61
- VALUES (?, ?, ?, ?, NULL, '{}', 0)`,
62
- [id, projectId ?? null, repoId ?? null, now],
63
- );
64
-
65
- logger.debug('SessionManager: session started', { id, repoId, projectId });
66
- return id;
67
- }
68
-
69
- /**
70
- * Mark a session as ended. Idempotent — silently succeeds if already ended.
71
- */
72
- endSession(sessionId: string): void {
73
- this.db.run(
74
- `UPDATE sessions SET ended_at = ? WHERE id = ? AND ended_at IS NULL`,
75
- [Date.now(), sessionId],
76
- );
77
- logger.debug('SessionManager: session ended', { sessionId });
78
- }
79
-
80
- /**
81
- * Retrieve a session by ID.
82
- * Returns null if no session with that ID exists.
83
- */
84
- getSession(sessionId: string): Session | null {
85
- const row = this.db.get<SessionRow>(
86
- `SELECT * FROM sessions WHERE id = ?`,
87
- [sessionId],
88
- );
89
- if (!row) return null;
90
- return rowToSession(row);
91
- }
92
-
93
- /**
94
- * List sessions, optionally filtered by repoId.
95
- * Results are ordered most-recent first.
96
- */
97
- listSessions(repoId?: string): Session[] {
98
- let rows: SessionRow[];
99
- if (repoId) {
100
- rows = this.db.all<SessionRow>(
101
- `SELECT * FROM sessions WHERE repo_id = ? ORDER BY started_at DESC`,
102
- [repoId],
103
- );
104
- } else {
105
- rows = this.db.all<SessionRow>(
106
- `SELECT * FROM sessions ORDER BY started_at DESC`,
107
- );
108
- }
109
- return rows.map(rowToSession);
110
- }
111
-
112
- /**
113
- * Overwrite the JSON state blob for a session.
114
- * Callers use this to persist resumption context (current task, agent
115
- * outputs, phase position) so the supervisor can restore a paused session.
116
- */
117
- saveState(sessionId: string, state: Record<string, unknown>): void {
118
- this.db.run(
119
- `UPDATE sessions SET state = ? WHERE id = ?`,
120
- [JSON.stringify(state), sessionId],
121
- );
122
- logger.debug('SessionManager: state saved', { sessionId });
123
- }
124
-
125
- /**
126
- * Read the JSON state blob for a session.
127
- * Returns null if the session does not exist.
128
- */
129
- restoreState(sessionId: string): Record<string, unknown> | null {
130
- const row = this.db.get<{ state: string }>(
131
- `SELECT state FROM sessions WHERE id = ?`,
132
- [sessionId],
133
- );
134
- if (!row) return null;
135
- try {
136
- return JSON.parse(row.state) as Record<string, unknown>;
137
- } catch {
138
- return {};
139
- }
140
- }
141
-
142
- /**
143
- * Increment the observation_count for a session by 1.
144
- * Called by the observation save path each time a memory is stored.
145
- */
146
- incrementObservations(sessionId: string): void {
147
- this.db.run(
148
- `UPDATE sessions SET observation_count = observation_count + 1 WHERE id = ?`,
149
- [sessionId],
150
- );
151
- }
152
- }