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.
- package/config/roles/_common/wiki-instructions.md +33 -0
- package/config/roles/orchestrator/prompt.md +35 -0
- package/config/roles/team-leader/prompt.md +38 -0
- package/config/skills/agent/core/wiki-query/SKILL.md +66 -0
- package/config/skills/agent/core/wiki-query/execute.sh +107 -0
- package/config/skills/orchestrator/wiki-bookkeep/SKILL.md +71 -0
- package/config/skills/orchestrator/wiki-bookkeep/execute.sh +72 -0
- package/config/skills/orchestrator/wiki-ingest/SKILL.md +63 -0
- package/config/skills/orchestrator/wiki-ingest/execute.sh +113 -0
- package/config/skills/orchestrator/wiki-process-queue/SKILL.md +71 -0
- package/config/skills/orchestrator/wiki-process-queue/execute.sh +93 -0
- package/config/skills/orchestrator/wiki-queue-add/SKILL.md +89 -0
- package/config/skills/orchestrator/wiki-queue-add/execute.sh +115 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.controller.d.ts +134 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.controller.js +718 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.routes.d.ts +23 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.routes.js +43 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.routes.js.map +1 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +39 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +4 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.d.ts +28 -0
- package/dist/backend/backend/src/services/session/pty/pty-session.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.js +162 -4
- package/dist/backend/backend/src/services/session/pty/pty-session.js.map +1 -1
- package/dist/backend/backend/src/services/wiki/referenced-by.resolver.d.ts +69 -0
- package/dist/backend/backend/src/services/wiki/referenced-by.resolver.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/referenced-by.resolver.js +174 -0
- package/dist/backend/backend/src/services/wiki/referenced-by.resolver.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/schema-loader.service.d.ts +57 -0
- package/dist/backend/backend/src/services/wiki/schema-loader.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/schema-loader.service.js +183 -0
- package/dist/backend/backend/src/services/wiki/schema-loader.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.d.ts +86 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.js +187 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.d.ts +116 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.js +299 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.d.ts +74 -0
- package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.js +154 -0
- package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts +100 -0
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.js +212 -0
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-process.service.d.ts +84 -0
- package/dist/backend/backend/src/services/wiki/wiki-process.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-process.service.js +138 -0
- package/dist/backend/backend/src/services/wiki/wiki-process.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-query.service.d.ts +115 -0
- package/dist/backend/backend/src/services/wiki/wiki-query.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-query.service.js +291 -0
- package/dist/backend/backend/src/services/wiki/wiki-query.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-queue.service.d.ts +115 -0
- package/dist/backend/backend/src/services/wiki/wiki-queue.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-queue.service.js +261 -0
- package/dist/backend/backend/src/services/wiki/wiki-queue.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki.types.d.ts +84 -0
- package/dist/backend/backend/src/services/wiki/wiki.types.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki.types.js +10 -0
- package/dist/backend/backend/src/services/wiki/wiki.types.js.map +1 -0
- package/frontend/dist/assets/{index-b279da34.js → index-cc115bb4.js} +246 -246
- package/frontend/dist/assets/{index-c07e04c0.css → index-db3f5041.css} +1 -1
- package/frontend/dist/index.html +2 -2
- 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
|