crewly 1.8.4 → 1.8.5

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 (75) hide show
  1. package/config/roles/_common/wiki-instructions.md +33 -0
  2. package/config/roles/orchestrator/prompt.md +35 -0
  3. package/config/roles/team-leader/prompt.md +38 -0
  4. package/config/skills/agent/core/wiki-query/SKILL.md +66 -0
  5. package/config/skills/agent/core/wiki-query/execute.sh +107 -0
  6. package/config/skills/orchestrator/wiki-bookkeep/SKILL.md +71 -0
  7. package/config/skills/orchestrator/wiki-bookkeep/execute.sh +72 -0
  8. package/config/skills/orchestrator/wiki-ingest/SKILL.md +63 -0
  9. package/config/skills/orchestrator/wiki-ingest/execute.sh +113 -0
  10. package/config/skills/orchestrator/wiki-process-queue/SKILL.md +71 -0
  11. package/config/skills/orchestrator/wiki-process-queue/execute.sh +93 -0
  12. package/config/skills/orchestrator/wiki-queue-add/SKILL.md +89 -0
  13. package/config/skills/orchestrator/wiki-queue-add/execute.sh +115 -0
  14. package/dist/backend/backend/src/controllers/wiki/wiki.controller.d.ts +134 -0
  15. package/dist/backend/backend/src/controllers/wiki/wiki.controller.d.ts.map +1 -0
  16. package/dist/backend/backend/src/controllers/wiki/wiki.controller.js +718 -0
  17. package/dist/backend/backend/src/controllers/wiki/wiki.controller.js.map +1 -0
  18. package/dist/backend/backend/src/controllers/wiki/wiki.routes.d.ts +23 -0
  19. package/dist/backend/backend/src/controllers/wiki/wiki.routes.d.ts.map +1 -0
  20. package/dist/backend/backend/src/controllers/wiki/wiki.routes.js +43 -0
  21. package/dist/backend/backend/src/controllers/wiki/wiki.routes.js.map +1 -0
  22. package/dist/backend/backend/src/index.d.ts.map +1 -1
  23. package/dist/backend/backend/src/index.js +39 -0
  24. package/dist/backend/backend/src/index.js.map +1 -1
  25. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  26. package/dist/backend/backend/src/routes/api.routes.js +4 -0
  27. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  28. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts +28 -0
  29. package/dist/backend/backend/src/services/session/pty/pty-session.d.ts.map +1 -1
  30. package/dist/backend/backend/src/services/session/pty/pty-session.js +162 -4
  31. package/dist/backend/backend/src/services/session/pty/pty-session.js.map +1 -1
  32. package/dist/backend/backend/src/services/wiki/referenced-by.resolver.d.ts +69 -0
  33. package/dist/backend/backend/src/services/wiki/referenced-by.resolver.d.ts.map +1 -0
  34. package/dist/backend/backend/src/services/wiki/referenced-by.resolver.js +174 -0
  35. package/dist/backend/backend/src/services/wiki/referenced-by.resolver.js.map +1 -0
  36. package/dist/backend/backend/src/services/wiki/schema-loader.service.d.ts +57 -0
  37. package/dist/backend/backend/src/services/wiki/schema-loader.service.d.ts.map +1 -0
  38. package/dist/backend/backend/src/services/wiki/schema-loader.service.js +183 -0
  39. package/dist/backend/backend/src/services/wiki/schema-loader.service.js.map +1 -0
  40. package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.d.ts +86 -0
  41. package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.d.ts.map +1 -0
  42. package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.js +187 -0
  43. package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.js.map +1 -0
  44. package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.d.ts +116 -0
  45. package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.d.ts.map +1 -0
  46. package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.js +299 -0
  47. package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.js.map +1 -0
  48. package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.d.ts +74 -0
  49. package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.d.ts.map +1 -0
  50. package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.js +154 -0
  51. package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.js.map +1 -0
  52. package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts +100 -0
  53. package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts.map +1 -0
  54. package/dist/backend/backend/src/services/wiki/wiki-ingest.service.js +212 -0
  55. package/dist/backend/backend/src/services/wiki/wiki-ingest.service.js.map +1 -0
  56. package/dist/backend/backend/src/services/wiki/wiki-process.service.d.ts +84 -0
  57. package/dist/backend/backend/src/services/wiki/wiki-process.service.d.ts.map +1 -0
  58. package/dist/backend/backend/src/services/wiki/wiki-process.service.js +138 -0
  59. package/dist/backend/backend/src/services/wiki/wiki-process.service.js.map +1 -0
  60. package/dist/backend/backend/src/services/wiki/wiki-query.service.d.ts +115 -0
  61. package/dist/backend/backend/src/services/wiki/wiki-query.service.d.ts.map +1 -0
  62. package/dist/backend/backend/src/services/wiki/wiki-query.service.js +291 -0
  63. package/dist/backend/backend/src/services/wiki/wiki-query.service.js.map +1 -0
  64. package/dist/backend/backend/src/services/wiki/wiki-queue.service.d.ts +115 -0
  65. package/dist/backend/backend/src/services/wiki/wiki-queue.service.d.ts.map +1 -0
  66. package/dist/backend/backend/src/services/wiki/wiki-queue.service.js +261 -0
  67. package/dist/backend/backend/src/services/wiki/wiki-queue.service.js.map +1 -0
  68. package/dist/backend/backend/src/services/wiki/wiki.types.d.ts +84 -0
  69. package/dist/backend/backend/src/services/wiki/wiki.types.d.ts.map +1 -0
  70. package/dist/backend/backend/src/services/wiki/wiki.types.js +10 -0
  71. package/dist/backend/backend/src/services/wiki/wiki.types.js.map +1 -0
  72. package/frontend/dist/assets/{index-b279da34.js → index-cc115bb4.js} +246 -246
  73. package/frontend/dist/assets/{index-c07e04c0.css → index-db3f5041.css} +1 -1
  74. package/frontend/dist/index.html +2 -2
  75. package/package.json +1 -1
@@ -0,0 +1,718 @@
1
+ /**
2
+ * Wiki REST Controller
3
+ *
4
+ * Exposes the LLM-wiki ingest/query operations over HTTP so the bash
5
+ * skills (`wiki-ingest`, `wiki-query`) and in-process subscribers
6
+ * (`WikiChatSubscriberService`) all funnel through the same code path.
7
+ *
8
+ * @module controllers/wiki/wiki.controller
9
+ */
10
+ import { LoggerService } from '../../services/core/logger.service.js';
11
+ import { WikiIngestService, } from '../../services/wiki/wiki-ingest.service.js';
12
+ import { WikiQueryService } from '../../services/wiki/wiki-query.service.js';
13
+ import { WikiQueueService, } from '../../services/wiki/wiki-queue.service.js';
14
+ import { WikiProcessService } from '../../services/wiki/wiki-process.service.js';
15
+ import { WikiBookkeepService } from '../../services/wiki/wiki-bookkeep.service.js';
16
+ import { WikiBookkeepTriggerService } from '../../services/wiki/wiki-bookkeep-trigger.service.js';
17
+ import { discoverWikiVaults } from '../../services/wiki/wiki-bookkeep-trigger.service.js';
18
+ import { SchemaLoaderService } from '../../services/wiki/schema-loader.service.js';
19
+ import * as path from 'path';
20
+ import * as fs from 'fs/promises';
21
+ import { existsSync } from 'fs';
22
+ const logger = LoggerService.getInstance().createComponentLogger('WikiController');
23
+ const VALID_SOURCE_TYPES = new Set([
24
+ 'user_chat',
25
+ 'slack_message',
26
+ 'spec_file',
27
+ 'pr_merge',
28
+ 'record_learning',
29
+ 'task_verified',
30
+ ]);
31
+ /**
32
+ * POST /api/wiki/ingest
33
+ *
34
+ * Write a single source into a vault's `llm-curated/log.md` (or other
35
+ * non-frozen target). The handler enforces request validation; per-call
36
+ * routing / frozen-path / size limits live in `WikiIngestService`.
37
+ *
38
+ * Request body:
39
+ * {
40
+ * vaultPath: string,
41
+ * sourceType: WikiSourceType,
42
+ * sourceRef: string,
43
+ * sourceBody: string,
44
+ * callerSession?: string,
45
+ * targetRelativePath?: string
46
+ * }
47
+ *
48
+ * Response:
49
+ * 200 { success: true, result: WikiIngestOutcome (ok=true variant) }
50
+ * 400 { success: false, error: string, outcome?: WikiIngestOutcome }
51
+ * 422 { success: false, error: 'frozen_path', outcome: WikiIngestOutcome }
52
+ */
53
+ export async function ingest(req, res, next) {
54
+ try {
55
+ const { vaultPath, sourceType, sourceRef, sourceBody, callerSession, targetRelativePath, } = req.body ?? {};
56
+ if (typeof vaultPath !== 'string' || vaultPath.length === 0) {
57
+ res.status(400).json({ success: false, error: 'vaultPath (string) is required' });
58
+ return;
59
+ }
60
+ if (typeof sourceType !== 'string' || !VALID_SOURCE_TYPES.has(sourceType)) {
61
+ res.status(400).json({
62
+ success: false,
63
+ error: `sourceType must be one of: ${[...VALID_SOURCE_TYPES].join(', ')}`,
64
+ });
65
+ return;
66
+ }
67
+ if (typeof sourceRef !== 'string' || sourceRef.length === 0) {
68
+ res.status(400).json({ success: false, error: 'sourceRef (string) is required' });
69
+ return;
70
+ }
71
+ if (typeof sourceBody !== 'string') {
72
+ res.status(400).json({ success: false, error: 'sourceBody (string) is required' });
73
+ return;
74
+ }
75
+ if (callerSession !== undefined && typeof callerSession !== 'string') {
76
+ res.status(400).json({ success: false, error: 'callerSession must be a string when provided' });
77
+ return;
78
+ }
79
+ if (targetRelativePath !== undefined && typeof targetRelativePath !== 'string') {
80
+ res.status(400).json({ success: false, error: 'targetRelativePath must be a string when provided' });
81
+ return;
82
+ }
83
+ const outcome = await WikiIngestService.getInstance().ingest({
84
+ vaultPath,
85
+ sourceType: sourceType,
86
+ sourceRef,
87
+ sourceBody,
88
+ callerSession,
89
+ targetRelativePath,
90
+ });
91
+ if (outcome.ok) {
92
+ res.status(200).json({ success: true, result: outcome });
93
+ return;
94
+ }
95
+ // Refusal — surface a stable status code per reason so callers can branch.
96
+ if (outcome.reason === 'frozen_path') {
97
+ res.status(422).json({ success: false, error: 'frozen_path', outcome });
98
+ return;
99
+ }
100
+ if (outcome.reason === 'schema_missing') {
101
+ res.status(404).json({ success: false, error: 'schema_missing', outcome });
102
+ return;
103
+ }
104
+ res.status(400).json({ success: false, error: outcome.reason, outcome });
105
+ }
106
+ catch (err) {
107
+ logger.error('wiki/ingest threw', { error: err.message });
108
+ next(err);
109
+ }
110
+ }
111
+ /**
112
+ * POST /api/wiki/query
113
+ *
114
+ * Build the system-context payload for a single-vault read. The skill /
115
+ * caller's runtime concatenates this with its per-LLM task-instruction
116
+ * prompt (`config/skills/<role>/wiki-query/prompts/<runtime>.md`) before
117
+ * the actual LLM call.
118
+ *
119
+ * Request body:
120
+ * {
121
+ * vaultPath: string,
122
+ * query: string,
123
+ * topK?: number,
124
+ * recentLogEntries?: number
125
+ * }
126
+ *
127
+ * Response:
128
+ * 200 { success: true, result: { context: WikiQuerySystemContext } }
129
+ * 400 { success: false, error: string, outcome?: failure }
130
+ * 404 { success: false, error: 'schema_missing', outcome }
131
+ */
132
+ export async function queryVault(req, res, next) {
133
+ try {
134
+ const { vaultPath, query, topK, recentLogEntries } = req.body ?? {};
135
+ if (typeof vaultPath !== 'string' || vaultPath.length === 0) {
136
+ res.status(400).json({ success: false, error: 'vaultPath (string) is required' });
137
+ return;
138
+ }
139
+ if (typeof query !== 'string' || query.length === 0) {
140
+ res.status(400).json({ success: false, error: 'query (string) is required' });
141
+ return;
142
+ }
143
+ if (topK !== undefined && (!Number.isInteger(topK) || topK <= 0)) {
144
+ res.status(400).json({ success: false, error: 'topK must be a positive integer' });
145
+ return;
146
+ }
147
+ if (recentLogEntries !== undefined &&
148
+ (!Number.isInteger(recentLogEntries) || recentLogEntries < 0)) {
149
+ res.status(400).json({
150
+ success: false,
151
+ error: 'recentLogEntries must be a non-negative integer',
152
+ });
153
+ return;
154
+ }
155
+ const outcome = await WikiQueryService.getInstance().query({
156
+ vaultPath,
157
+ query,
158
+ topK,
159
+ recentLogEntries,
160
+ });
161
+ if (outcome.ok) {
162
+ res.status(200).json({ success: true, result: { context: outcome.context } });
163
+ return;
164
+ }
165
+ if (outcome.reason === 'schema_missing') {
166
+ res.status(404).json({ success: false, error: 'schema_missing', outcome });
167
+ return;
168
+ }
169
+ res.status(400).json({ success: false, error: outcome.reason, outcome });
170
+ }
171
+ catch (err) {
172
+ logger.error('wiki/query threw', { error: err.message });
173
+ next(err);
174
+ }
175
+ }
176
+ // =============================================================================
177
+ // Wiki Queue (Steve 2026-05-22 redesign — agents enqueue, agents process)
178
+ // =============================================================================
179
+ const VALID_QUEUE_STATUSES = new Set([
180
+ 'pending',
181
+ 'claimed',
182
+ 'processed',
183
+ 'skipped',
184
+ ]);
185
+ // Note: VALID_SOURCE_TYPES is already declared above for the /ingest route.
186
+ // The queue-add handler below reuses that same set.
187
+ /**
188
+ * POST /api/wiki/queue
189
+ *
190
+ * Enqueue a candidate item. Agents call this when a turn produces
191
+ * content worth saving to the wiki. The `reason` is REQUIRED — the
192
+ * agent has to justify why this is wiki-worthy (audit trail + helps
193
+ * the processor decide where it goes).
194
+ */
195
+ export async function queueAdd(req, res, next) {
196
+ try {
197
+ const { vaultPath, queuedBy, sourceType, sourceRef, content, reason } = req.body ?? {};
198
+ if (typeof vaultPath !== 'string' || vaultPath.length === 0) {
199
+ res.status(400).json({ success: false, error: 'vaultPath (string) is required' });
200
+ return;
201
+ }
202
+ if (typeof queuedBy !== 'string' || queuedBy.length === 0) {
203
+ res.status(400).json({ success: false, error: 'queuedBy (string) is required' });
204
+ return;
205
+ }
206
+ if (typeof sourceType !== 'string' ||
207
+ !VALID_SOURCE_TYPES.has(sourceType)) {
208
+ res.status(400).json({
209
+ success: false,
210
+ error: `sourceType must be one of: ${[...VALID_SOURCE_TYPES].join(', ')}`,
211
+ });
212
+ return;
213
+ }
214
+ if (typeof sourceRef !== 'string' || sourceRef.length === 0) {
215
+ res.status(400).json({ success: false, error: 'sourceRef (string) is required' });
216
+ return;
217
+ }
218
+ if (typeof content !== 'string' || content.length === 0) {
219
+ res.status(400).json({ success: false, error: 'content (string) is required' });
220
+ return;
221
+ }
222
+ if (typeof reason !== 'string' || reason.length === 0) {
223
+ res.status(400).json({
224
+ success: false,
225
+ error: 'reason (string) is required — agents must justify why this is wiki-worthy',
226
+ });
227
+ return;
228
+ }
229
+ const item = await WikiQueueService.getInstance().add({
230
+ vaultPath,
231
+ queuedBy,
232
+ sourceType: sourceType,
233
+ sourceRef,
234
+ content,
235
+ reason,
236
+ });
237
+ res.status(201).json({ success: true, item });
238
+ }
239
+ catch (err) {
240
+ // Service-level validation errors bubble up as 400; everything else 500.
241
+ const msg = err.message;
242
+ if (/exceeds|empty|absolute|required/i.test(msg)) {
243
+ res.status(400).json({ success: false, error: msg });
244
+ return;
245
+ }
246
+ logger.error('wiki/queue add threw', { error: msg });
247
+ next(err);
248
+ }
249
+ }
250
+ /**
251
+ * GET /api/wiki/queue?vaultPath=…&status=…&queuedBy=…&limit=…
252
+ */
253
+ export async function queueList(req, res, next) {
254
+ try {
255
+ const vaultPath = typeof req.query.vaultPath === 'string' ? req.query.vaultPath : undefined;
256
+ const queuedBy = typeof req.query.queuedBy === 'string' ? req.query.queuedBy : undefined;
257
+ const statusRaw = typeof req.query.status === 'string' ? req.query.status : undefined;
258
+ if (statusRaw !== undefined && !VALID_QUEUE_STATUSES.has(statusRaw)) {
259
+ res.status(400).json({
260
+ success: false,
261
+ error: `status must be one of: ${[...VALID_QUEUE_STATUSES].join(', ')}`,
262
+ });
263
+ return;
264
+ }
265
+ const limitRaw = typeof req.query.limit === 'string' ? Number(req.query.limit) : undefined;
266
+ if (limitRaw !== undefined && (!Number.isInteger(limitRaw) || limitRaw <= 0)) {
267
+ res.status(400).json({ success: false, error: 'limit must be a positive integer' });
268
+ return;
269
+ }
270
+ const items = await WikiQueueService.getInstance().list({
271
+ vaultPath,
272
+ queuedBy,
273
+ status: statusRaw,
274
+ limit: limitRaw,
275
+ });
276
+ res.status(200).json({ success: true, items, count: items.length });
277
+ }
278
+ catch (err) {
279
+ logger.error('wiki/queue list threw', { error: err.message });
280
+ next(err);
281
+ }
282
+ }
283
+ /**
284
+ * POST /api/wiki/queue/:id/claim
285
+ * body: { claimedBy: string }
286
+ */
287
+ export async function queueClaim(req, res, next) {
288
+ try {
289
+ const { id } = req.params;
290
+ const { claimedBy } = req.body ?? {};
291
+ if (typeof claimedBy !== 'string' || claimedBy.length === 0) {
292
+ res.status(400).json({ success: false, error: 'claimedBy (string) is required' });
293
+ return;
294
+ }
295
+ const item = await WikiQueueService.getInstance().claim(id, claimedBy);
296
+ res.status(200).json({ success: true, item });
297
+ }
298
+ catch (err) {
299
+ const msg = err.message;
300
+ if (/not found/i.test(msg)) {
301
+ res.status(404).json({ success: false, error: msg });
302
+ return;
303
+ }
304
+ if (/pending|claimed|status/i.test(msg)) {
305
+ res.status(409).json({ success: false, error: msg });
306
+ return;
307
+ }
308
+ next(err);
309
+ }
310
+ }
311
+ /**
312
+ * POST /api/wiki/queue/:id/process
313
+ * body: { ingested: boolean, pagesWritten?: string[], targetPath?: string, summary?: string }
314
+ */
315
+ export async function queueProcess(req, res, next) {
316
+ try {
317
+ const { id } = req.params;
318
+ const { ingested, pagesWritten, targetPath, summary } = req.body ?? {};
319
+ if (typeof ingested !== 'boolean') {
320
+ res.status(400).json({ success: false, error: 'ingested (boolean) is required' });
321
+ return;
322
+ }
323
+ if (pagesWritten !== undefined && !Array.isArray(pagesWritten)) {
324
+ res.status(400).json({ success: false, error: 'pagesWritten must be an array' });
325
+ return;
326
+ }
327
+ const item = await WikiQueueService.getInstance().markProcessed(id, {
328
+ ingested,
329
+ pagesWritten,
330
+ targetPath,
331
+ summary,
332
+ });
333
+ res.status(200).json({ success: true, item });
334
+ }
335
+ catch (err) {
336
+ const msg = err.message;
337
+ if (/not found/i.test(msg)) {
338
+ res.status(404).json({ success: false, error: msg });
339
+ return;
340
+ }
341
+ if (/claimed|status/i.test(msg)) {
342
+ res.status(409).json({ success: false, error: msg });
343
+ return;
344
+ }
345
+ next(err);
346
+ }
347
+ }
348
+ /**
349
+ * POST /api/wiki/queue/:id/skip
350
+ * body: { skipReason: string }
351
+ */
352
+ export async function queueSkip(req, res, next) {
353
+ try {
354
+ const { id } = req.params;
355
+ const { skipReason } = req.body ?? {};
356
+ if (typeof skipReason !== 'string' || skipReason.length === 0) {
357
+ res.status(400).json({ success: false, error: 'skipReason (string) is required' });
358
+ return;
359
+ }
360
+ const item = await WikiQueueService.getInstance().markSkipped(id, skipReason);
361
+ res.status(200).json({ success: true, item });
362
+ }
363
+ catch (err) {
364
+ const msg = err.message;
365
+ if (/not found/i.test(msg)) {
366
+ res.status(404).json({ success: false, error: msg });
367
+ return;
368
+ }
369
+ if (/claimed|status/i.test(msg)) {
370
+ res.status(409).json({ success: false, error: msg });
371
+ return;
372
+ }
373
+ next(err);
374
+ }
375
+ }
376
+ /**
377
+ * POST /api/wiki/queue/claim-next
378
+ * body: { claimedBy: string, vaultPath?: string, offset?: number, topK?: number, recentLogEntries?: number }
379
+ *
380
+ * Claims the next pending item AND returns the vault context the
381
+ * agent's LLM should classify against. The agent then picks a target
382
+ * page, calls /wiki/ingest, and commits via /queue/:id/process.
383
+ */
384
+ export async function queueClaimNext(req, res, next) {
385
+ try {
386
+ const { claimedBy, vaultPath, offset, topK, recentLogEntries } = req.body ?? {};
387
+ if (typeof claimedBy !== 'string' || claimedBy.length === 0) {
388
+ res.status(400).json({ success: false, error: 'claimedBy (string) is required' });
389
+ return;
390
+ }
391
+ if (vaultPath !== undefined && typeof vaultPath !== 'string') {
392
+ res.status(400).json({ success: false, error: 'vaultPath must be a string when provided' });
393
+ return;
394
+ }
395
+ if (offset !== undefined && (!Number.isInteger(offset) || offset < 0)) {
396
+ res.status(400).json({ success: false, error: 'offset must be a non-negative integer' });
397
+ return;
398
+ }
399
+ const outcome = await WikiProcessService.getInstance().claimNext({
400
+ claimedBy,
401
+ vaultPath,
402
+ offset,
403
+ topK,
404
+ recentLogEntries,
405
+ });
406
+ if (outcome.ok) {
407
+ res.status(200).json({ success: true, result: outcome.result });
408
+ return;
409
+ }
410
+ if (outcome.reason === 'no_pending_items') {
411
+ res.status(404).json({ success: false, error: 'no_pending_items', message: outcome.message });
412
+ return;
413
+ }
414
+ res.status(400).json({ success: false, error: outcome.reason, message: outcome.message });
415
+ }
416
+ catch (err) {
417
+ logger.error('wiki/queue claim-next threw', { error: err.message });
418
+ next(err);
419
+ }
420
+ }
421
+ /**
422
+ * POST /api/wiki/bookkeep
423
+ * body: { vaultPath: string, windowDays?: number, threshold?: number }
424
+ */
425
+ export async function bookkeep(req, res, next) {
426
+ try {
427
+ const { vaultPath, windowDays, threshold } = req.body ?? {};
428
+ if (typeof vaultPath !== 'string' || vaultPath.length === 0) {
429
+ res.status(400).json({ success: false, error: 'vaultPath (string) is required' });
430
+ return;
431
+ }
432
+ if (windowDays !== undefined && (!Number.isInteger(windowDays) || windowDays <= 0)) {
433
+ res.status(400).json({ success: false, error: 'windowDays must be a positive integer' });
434
+ return;
435
+ }
436
+ if (threshold !== undefined && (!Number.isInteger(threshold) || threshold <= 0)) {
437
+ res.status(400).json({ success: false, error: 'threshold must be a positive integer' });
438
+ return;
439
+ }
440
+ const outcome = await WikiBookkeepService.getInstance().generate({
441
+ vaultPath,
442
+ windowDays,
443
+ threshold,
444
+ });
445
+ if (outcome.ok) {
446
+ res.status(200).json({ success: true, report: outcome.report });
447
+ return;
448
+ }
449
+ const status = outcome.reason === 'vault_missing' || outcome.reason === 'schema_missing' ? 404 : 400;
450
+ res.status(status).json({ success: false, error: outcome.reason, message: outcome.message });
451
+ }
452
+ catch (err) {
453
+ logger.error('wiki/bookkeep threw', { error: err.message });
454
+ next(err);
455
+ }
456
+ }
457
+ // =============================================================================
458
+ // Browse endpoints — power the OSS /wiki UI.
459
+ // =============================================================================
460
+ const MAX_TREE_ENTRIES = 500;
461
+ const MAX_PAGE_BYTES = 256 * 1024;
462
+ /**
463
+ * GET /api/wiki/vaults
464
+ *
465
+ * Returns the list of all known vaults (project + team + global) with
466
+ * stats so the UI can render a sidebar. Discovery uses the same logic
467
+ * the bookkeep trigger uses.
468
+ */
469
+ export async function listVaults(_req, res, next) {
470
+ try {
471
+ const vaultPaths = await discoverWikiVaults();
472
+ const schemaLoader = new SchemaLoaderService();
473
+ const entries = await Promise.all(vaultPaths.map(async (vaultPath) => {
474
+ let scope = 'unknown';
475
+ let vaultId = '';
476
+ try {
477
+ const schema = await schemaLoader.load(vaultPath);
478
+ scope = schema.vault_scope;
479
+ vaultId = schema.vault_id;
480
+ }
481
+ catch {
482
+ // skip — vault present but malformed; still surface to UI
483
+ }
484
+ let bookkeepReport = null;
485
+ try {
486
+ const outcome = await WikiBookkeepService.getInstance().generate({ vaultPath });
487
+ if (outcome.ok) {
488
+ bookkeepReport = {
489
+ totalMdCount: outcome.report.totalMdCount,
490
+ recentMdCount: outcome.report.recentMdCount,
491
+ queue: {
492
+ pending: outcome.report.queue.pending,
493
+ processed: outcome.report.queue.processed,
494
+ total: outcome.report.queue.total,
495
+ },
496
+ };
497
+ }
498
+ }
499
+ catch {
500
+ // ignore — show vault anyway with null stats
501
+ }
502
+ // Human-readable label: prefer schema vault_id, else basename of parent
503
+ const label = vaultId || path.basename(path.dirname(vaultPath));
504
+ return {
505
+ vaultPath,
506
+ scope,
507
+ vaultId,
508
+ label,
509
+ stats: bookkeepReport,
510
+ };
511
+ }));
512
+ res.status(200).json({ success: true, vaults: entries });
513
+ }
514
+ catch (err) {
515
+ logger.error('wiki/vaults threw', { error: err.message });
516
+ next(err);
517
+ }
518
+ }
519
+ /**
520
+ * GET /api/wiki/tree?vaultPath=...
521
+ *
522
+ * Walks the vault directory and returns the file tree. Frozen folders
523
+ * are marked so the UI can render them differently. SCHEMA.md is
524
+ * surfaced as a top-level file.
525
+ */
526
+ export async function getVaultTree(req, res, next) {
527
+ try {
528
+ const vaultPath = typeof req.query.vaultPath === 'string' ? req.query.vaultPath : undefined;
529
+ if (!vaultPath || !path.isAbsolute(vaultPath)) {
530
+ res.status(400).json({ success: false, error: 'vaultPath (absolute) is required' });
531
+ return;
532
+ }
533
+ if (!existsSync(vaultPath)) {
534
+ res.status(404).json({ success: false, error: 'vault_missing' });
535
+ return;
536
+ }
537
+ const schemaLoader = new SchemaLoaderService();
538
+ let frozenSet = new Set();
539
+ try {
540
+ const schema = await schemaLoader.load(vaultPath);
541
+ frozenSet = new Set(schemaLoader.getFrozenPaths(schema).map((p) => p.replace(/[/\\]+$/, '')));
542
+ }
543
+ catch {
544
+ // proceed without frozen markers
545
+ }
546
+ let scanned = 0;
547
+ const buildTree = async (absDir) => {
548
+ const out = [];
549
+ let entries;
550
+ try {
551
+ entries = await fs.readdir(absDir, { withFileTypes: true });
552
+ }
553
+ catch {
554
+ return [];
555
+ }
556
+ for (const entry of entries) {
557
+ if (scanned >= MAX_TREE_ENTRIES)
558
+ break;
559
+ if (entry.name.startsWith('.'))
560
+ continue;
561
+ scanned++;
562
+ const abs = path.join(absDir, entry.name);
563
+ const rel = path.relative(vaultPath, abs).replace(/\\/g, '/');
564
+ if (entry.isDirectory()) {
565
+ const folderKey = rel.replace(/[/\\]+$/, '');
566
+ const isFrozen = frozenSet.has(folderKey) || frozenSet.has(`${folderKey}/`);
567
+ const children = await buildTree(abs);
568
+ out.push({
569
+ name: entry.name,
570
+ relativePath: rel,
571
+ type: 'directory',
572
+ frozen: isFrozen || undefined,
573
+ children: children.sort((a, b) => sortTree(a, b)),
574
+ });
575
+ }
576
+ else if (entry.isFile()) {
577
+ // Surface markdown + SCHEMA.md only — skip other binaries.
578
+ if (!entry.name.endsWith('.md'))
579
+ continue;
580
+ try {
581
+ const stat = await fs.stat(abs);
582
+ // Determine frozen via parent dir.
583
+ const parts = rel.split('/');
584
+ const parentRel = parts.slice(0, -1).join('/');
585
+ const isFrozen = frozenSet.has(parentRel) || frozenSet.has(`${parentRel}/`);
586
+ out.push({
587
+ name: entry.name,
588
+ relativePath: rel,
589
+ type: 'file',
590
+ frozen: isFrozen || undefined,
591
+ bytes: stat.size,
592
+ modifiedAt: new Date(stat.mtimeMs).toISOString(),
593
+ });
594
+ }
595
+ catch {
596
+ // ignore unreadable file
597
+ }
598
+ }
599
+ }
600
+ return out;
601
+ };
602
+ const tree = (await buildTree(vaultPath)).sort((a, b) => sortTree(a, b));
603
+ res.status(200).json({
604
+ success: true,
605
+ vaultPath,
606
+ tree,
607
+ truncated: scanned >= MAX_TREE_ENTRIES,
608
+ });
609
+ }
610
+ catch (err) {
611
+ logger.error('wiki/tree threw', { error: err.message });
612
+ next(err);
613
+ }
614
+ }
615
+ function sortTree(a, b) {
616
+ // Directories first, then files; both alphabetical.
617
+ if (a.type !== b.type)
618
+ return a.type === 'directory' ? -1 : 1;
619
+ return a.name.localeCompare(b.name);
620
+ }
621
+ /**
622
+ * GET /api/wiki/page?vaultPath=...&relativePath=...
623
+ *
624
+ * Returns the raw markdown content of a single page. The relativePath
625
+ * is validated to ensure it doesn't escape the vault directory.
626
+ */
627
+ export async function getPage(req, res, next) {
628
+ try {
629
+ const vaultPath = typeof req.query.vaultPath === 'string' ? req.query.vaultPath : undefined;
630
+ const relativePath = typeof req.query.relativePath === 'string' ? req.query.relativePath : undefined;
631
+ if (!vaultPath || !path.isAbsolute(vaultPath)) {
632
+ res.status(400).json({ success: false, error: 'vaultPath (absolute) is required' });
633
+ return;
634
+ }
635
+ if (!relativePath || relativePath.length === 0) {
636
+ res.status(400).json({ success: false, error: 'relativePath is required' });
637
+ return;
638
+ }
639
+ if (!relativePath.endsWith('.md')) {
640
+ res.status(400).json({ success: false, error: 'only .md files can be read' });
641
+ return;
642
+ }
643
+ // Resolve and verify containment — path-traversal guard.
644
+ const requested = path.resolve(vaultPath, relativePath);
645
+ const vaultRoot = path.resolve(vaultPath);
646
+ if (!requested.startsWith(vaultRoot + path.sep) && requested !== vaultRoot) {
647
+ res.status(400).json({ success: false, error: 'relativePath escapes vault root' });
648
+ return;
649
+ }
650
+ if (!existsSync(requested)) {
651
+ res.status(404).json({ success: false, error: 'page_not_found' });
652
+ return;
653
+ }
654
+ const stat = await fs.stat(requested);
655
+ if (stat.size > MAX_PAGE_BYTES) {
656
+ res.status(413).json({
657
+ success: false,
658
+ error: 'page_too_large',
659
+ bytes: stat.size,
660
+ maxBytes: MAX_PAGE_BYTES,
661
+ });
662
+ return;
663
+ }
664
+ const content = await fs.readFile(requested, 'utf8');
665
+ res.status(200).json({
666
+ success: true,
667
+ vaultPath,
668
+ relativePath,
669
+ bytes: stat.size,
670
+ modifiedAt: new Date(stat.mtimeMs).toISOString(),
671
+ content,
672
+ });
673
+ }
674
+ catch (err) {
675
+ logger.error('wiki/page threw', { error: err.message });
676
+ next(err);
677
+ }
678
+ }
679
+ /**
680
+ * POST /api/wiki/bookkeep/trigger-now
681
+ *
682
+ * Force one tick of the bookkeep trigger NOW. Used for manual testing
683
+ * (curl, the chat UI, an admin button) — production cadence is the
684
+ * automatic interval scan, but operators sometimes want to invoke it
685
+ * synchronously to verify wiring or to clear a backlog.
686
+ */
687
+ export async function bookkeepTriggerNow(_req, res, next) {
688
+ try {
689
+ const trigger = WikiBookkeepTriggerService.getInstance();
690
+ if (!trigger) {
691
+ res.status(503).json({
692
+ success: false,
693
+ error: 'wiki bookkeep trigger is not registered — boot wiring did not complete',
694
+ });
695
+ return;
696
+ }
697
+ const result = await trigger.tick();
698
+ res.status(200).json({ success: true, result });
699
+ }
700
+ catch (err) {
701
+ logger.error('wiki/bookkeep/trigger-now threw', { error: err.message });
702
+ next(err);
703
+ }
704
+ }
705
+ /**
706
+ * GET /api/wiki/queue/stats?vaultPath=…
707
+ */
708
+ export async function queueStats(req, res, next) {
709
+ try {
710
+ const vaultPath = typeof req.query.vaultPath === 'string' ? req.query.vaultPath : undefined;
711
+ const stats = await WikiQueueService.getInstance().getStats(vaultPath);
712
+ res.status(200).json({ success: true, stats, vaultPath: vaultPath ?? null });
713
+ }
714
+ catch (err) {
715
+ next(err);
716
+ }
717
+ }
718
+ //# sourceMappingURL=wiki.controller.js.map