@chenguangyao/devflow-kit 0.1.43

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 (198) hide show
  1. package/CHANGELOG.md +232 -0
  2. package/LICENSE +21 -0
  3. package/README.md +539 -0
  4. package/bin/devflow.js +9 -0
  5. package/docs/RFC-001-devflow-kit.md +617 -0
  6. package/docs/RFC-002-workflow-kernel.md +134 -0
  7. package/docs/enterprise-integration-supplement.md +274 -0
  8. package/docs/internal-gitlab-setup.md +426 -0
  9. package/docs/marketplace-skills.md +231 -0
  10. package/docs/migration-from-arb.md +232 -0
  11. package/docs/tooling-overview.md +774 -0
  12. package/docs/workflow-orchestration.md +695 -0
  13. package/docs/workflow-ui-prototype.html +271 -0
  14. package/package.json +52 -0
  15. package/schemas/config.schema.json +51 -0
  16. package/schemas/delta.schema.json +22 -0
  17. package/schemas/state.schema.json +130 -0
  18. package/schemas/status-surface.schema.json +197 -0
  19. package/schemas/workflow-confirmation-surface.schema.json +70 -0
  20. package/schemas/workflow-picker.schema.json +94 -0
  21. package/scripts/postinstall.js +101 -0
  22. package/scripts/render-workflow-ui-prototype.js +271 -0
  23. package/skills/apply/SKILL.md +313 -0
  24. package/skills/apply/references/discipline-checklist.md +145 -0
  25. package/skills/apply/references/subagent-implementer-prompt.md +113 -0
  26. package/skills/apply/references/subagent-orchestration.md +150 -0
  27. package/skills/apply/references/subagent-reviewer-prompt.md +180 -0
  28. package/skills/apply/references/tdd-loop.md +287 -0
  29. package/skills/apply/references/when-plan-is-wrong.md +279 -0
  30. package/skills/apply/references/worktree-swarm.md +292 -0
  31. package/skills/archive/SKILL.md +229 -0
  32. package/skills/archive/references/conflict-resolution.md +336 -0
  33. package/skills/archive/references/knowledge-deposit.md +381 -0
  34. package/skills/archive/references/spec-merge.md +365 -0
  35. package/skills/brainstorm/SKILL.md +123 -0
  36. package/skills/brainstorm/references/proposal-template.md +244 -0
  37. package/skills/brainstorm/references/question-catalog.md +168 -0
  38. package/skills/brainstorm/references/session-template.md +184 -0
  39. package/skills/ci-fix/SKILL.md +63 -0
  40. package/skills/ci-fix/references/loop.md +25 -0
  41. package/skills/code-review/SKILL.md +279 -0
  42. package/skills/code-review/references/escalation-playbook.md +192 -0
  43. package/skills/code-review/references/language-cheatsheets/go.md +175 -0
  44. package/skills/code-review/references/language-cheatsheets/java-spring-mybatis.md +246 -0
  45. package/skills/code-review/references/language-cheatsheets/python.md +170 -0
  46. package/skills/code-review/references/language-cheatsheets/vue.md +199 -0
  47. package/skills/code-review/references/output-template.md +275 -0
  48. package/skills/code-review/references/review-checklist.md +251 -0
  49. package/skills/complexity-grading/SKILL.md +259 -0
  50. package/skills/deliver/SKILL.md +271 -0
  51. package/skills/deliver/references/delivery-modes.md +299 -0
  52. package/skills/deliver/references/notify.md +359 -0
  53. package/skills/deliver/references/pr-description.md +319 -0
  54. package/skills/dependency-upgrade/SKILL.md +57 -0
  55. package/skills/dependency-upgrade/references/risk-matrix.md +38 -0
  56. package/skills/df-orchestrator/SKILL.md +407 -0
  57. package/skills/df-orchestrator/references/complexity-grading.md +177 -0
  58. package/skills/df-orchestrator/references/escalation-matrix.md +191 -0
  59. package/skills/df-orchestrator/references/routing-rules.md +290 -0
  60. package/skills/df-orchestrator/references/workflow-state-machine.md +208 -0
  61. package/skills/frontend-quality/SKILL.md +61 -0
  62. package/skills/frontend-quality/references/checklist.md +35 -0
  63. package/skills/handoff-resume/SKILL.md +59 -0
  64. package/skills/handoff-resume/references/handoff-template.md +54 -0
  65. package/skills/plan/SKILL.md +166 -0
  66. package/skills/plan/references/task-breakdown.md +207 -0
  67. package/skills/plan/references/task-sequencing.md +143 -0
  68. package/skills/plan/references/task-template.md +248 -0
  69. package/skills/requirement-analysis/SKILL.md +499 -0
  70. package/skills/requirement-analysis/references/acceptance-criteria.md +183 -0
  71. package/skills/requirement-analysis/references/code-recon.md +151 -0
  72. package/skills/requirement-analysis/references/edge-case-catalog.md +164 -0
  73. package/skills/requirement-analysis/references/requirement-template.md +339 -0
  74. package/skills/requirement-analysis/references/scope-negotiation.md +162 -0
  75. package/skills/security-hardening/SKILL.md +60 -0
  76. package/skills/security-hardening/references/checklist.md +42 -0
  77. package/skills/tech-spec/SKILL.md +388 -0
  78. package/skills/tech-spec/references/api-contract-design.md +172 -0
  79. package/skills/tech-spec/references/decision-records.md +110 -0
  80. package/skills/tech-spec/references/design-template.md +301 -0
  81. package/skills/tech-spec/references/rollout-and-rollback.md +203 -0
  82. package/skills/tech-spec/references/spec-delta-conventions.md +250 -0
  83. package/skills/tech-spec/references/transaction-patterns.md +212 -0
  84. package/skills/test-spec/SKILL.md +219 -0
  85. package/skills/test-spec/references/coverage-strategy.md +218 -0
  86. package/skills/test-spec/references/edge-case-to-test.md +143 -0
  87. package/skills/test-spec/references/test-case-template.md +276 -0
  88. package/skills/verify/SKILL.md +232 -0
  89. package/skills/verify/references/nfr-verification.md +292 -0
  90. package/skills/verify/references/report-templates.md +510 -0
  91. package/skills/verify/references/self-test-guide.md +240 -0
  92. package/skills/verify/references/verify-rollback-map.md +247 -0
  93. package/src/cli/commands/_helpers.js +108 -0
  94. package/src/cli/commands/_submit.js +718 -0
  95. package/src/cli/commands/apply.js +198 -0
  96. package/src/cli/commands/archive.js +180 -0
  97. package/src/cli/commands/checkpoint.js +113 -0
  98. package/src/cli/commands/deliver.js +377 -0
  99. package/src/cli/commands/deploy.js +504 -0
  100. package/src/cli/commands/design.js +158 -0
  101. package/src/cli/commands/disable.js +21 -0
  102. package/src/cli/commands/doctor.js +178 -0
  103. package/src/cli/commands/enable.js +21 -0
  104. package/src/cli/commands/flow.js +645 -0
  105. package/src/cli/commands/help.js +93 -0
  106. package/src/cli/commands/ingest.js +602 -0
  107. package/src/cli/commands/init.js +341 -0
  108. package/src/cli/commands/knowledge.js +523 -0
  109. package/src/cli/commands/logs.js +43 -0
  110. package/src/cli/commands/new.js +202 -0
  111. package/src/cli/commands/plan.js +49 -0
  112. package/src/cli/commands/propose.js +27 -0
  113. package/src/cli/commands/provider.js +698 -0
  114. package/src/cli/commands/report.js +143 -0
  115. package/src/cli/commands/requirement.js +227 -0
  116. package/src/cli/commands/review.js +301 -0
  117. package/src/cli/commands/skills.js +457 -0
  118. package/src/cli/commands/status.js +925 -0
  119. package/src/cli/commands/switch.js +27 -0
  120. package/src/cli/commands/sync.js +47 -0
  121. package/src/cli/commands/test.js +366 -0
  122. package/src/cli/commands/uninstall.js +32 -0
  123. package/src/cli/commands/update.js +74 -0
  124. package/src/cli/commands/verify.js +354 -0
  125. package/src/cli/commands/worktree.js +78 -0
  126. package/src/cli/index.js +72 -0
  127. package/src/cli/parse-args.js +102 -0
  128. package/src/core/autodetect.js +271 -0
  129. package/src/core/change.js +208 -0
  130. package/src/core/checkpoint.js +217 -0
  131. package/src/core/config.js +60 -0
  132. package/src/core/delta.js +290 -0
  133. package/src/core/markers.js +59 -0
  134. package/src/core/paths.js +173 -0
  135. package/src/core/plan-tasks.js +36 -0
  136. package/src/core/project-routing.js +285 -0
  137. package/src/core/projects.js +200 -0
  138. package/src/core/state.js +200 -0
  139. package/src/core/workflow-check.js +177 -0
  140. package/src/core/workflow-init.js +34 -0
  141. package/src/core/workflow-picker.js +154 -0
  142. package/src/core/workflow-policy.js +119 -0
  143. package/src/core/workflow-suggest.js +181 -0
  144. package/src/core/workflow-verify.js +88 -0
  145. package/src/core/workflow.js +433 -0
  146. package/src/core/worktree.js +241 -0
  147. package/src/knowledge/categories.js +107 -0
  148. package/src/knowledge/classify.js +125 -0
  149. package/src/knowledge/deposit.js +414 -0
  150. package/src/knowledge/migrate.js +149 -0
  151. package/src/knowledge/mr.js +219 -0
  152. package/src/knowledge/query.js +131 -0
  153. package/src/knowledge/registry.js +151 -0
  154. package/src/knowledge/sync.js +179 -0
  155. package/src/providers/base.js +74 -0
  156. package/src/providers/drivers/api-yapi.js +78 -0
  157. package/src/providers/drivers/ci-jenkins.js +109 -0
  158. package/src/providers/drivers/intake-confluence.js +544 -0
  159. package/src/providers/drivers/kb-git.js +549 -0
  160. package/src/providers/drivers/kb-weknora.js +472 -0
  161. package/src/providers/drivers/notify-smtp.js +515 -0
  162. package/src/providers/drivers/observability-oss.js +43 -0
  163. package/src/providers/drivers/observability-sls.js +50 -0
  164. package/src/providers/lifecycle.js +135 -0
  165. package/src/providers/loader.js +132 -0
  166. package/src/providers/local.js +190 -0
  167. package/src/providers/userconfig.js +283 -0
  168. package/src/reports/aggregate.js +185 -0
  169. package/src/reports/coverage.js +163 -0
  170. package/src/reports/detect.js +143 -0
  171. package/src/reports/parse.js +236 -0
  172. package/src/templates/files/ci/github.yml +38 -0
  173. package/src/templates/files/ci/gitlab.yml +27 -0
  174. package/src/templates/files/design.md +63 -0
  175. package/src/templates/files/ide/devflow-workflow.md +58 -0
  176. package/src/templates/files/ide/project-overview-reference.md +1 -0
  177. package/src/templates/files/ide/project-overview.md +27 -0
  178. package/src/templates/files/knowledge-index.json +17 -0
  179. package/src/templates/files/knowledge.md +28 -0
  180. package/src/templates/files/meta.json +8 -0
  181. package/src/templates/files/plan.md +38 -0
  182. package/src/templates/files/proposal.md +33 -0
  183. package/src/templates/files/reports/contract-test.md +40 -0
  184. package/src/templates/files/reports/e2e-test.md +30 -0
  185. package/src/templates/files/reports/integration-test.md +36 -0
  186. package/src/templates/files/reports/joint-test.md +58 -0
  187. package/src/templates/files/reports/perf.md +24 -0
  188. package/src/templates/files/reports/regression.md +20 -0
  189. package/src/templates/files/reports/remote-test.md +55 -0
  190. package/src/templates/files/reports/self-test.md +43 -0
  191. package/src/templates/files/reports/smoke-test.md +22 -0
  192. package/src/templates/files/reports/unit-test.md +36 -0
  193. package/src/templates/files/requirement.md +51 -0
  194. package/src/templates/files/review.md +38 -0
  195. package/src/templates/files/tests.md +36 -0
  196. package/src/templates/files/verify.md +32 -0
  197. package/src/templates/index.js +21 -0
  198. package/src/utils/log.js +37 -0
@@ -0,0 +1,472 @@
1
+ 'use strict';
2
+ const fs = require('fs/promises');
3
+ const path = require('path');
4
+ const { Provider } = require('../base.js');
5
+
6
+ /**
7
+ * WeKnora kb provider — **config-driven**.
8
+ *
9
+ * Why config-driven and not "profile-by-name"?
10
+ * Different WeKnora deployments (and WeKnora-compatible backends) expose
11
+ * slightly different REST contracts. Rather than shipping named presets
12
+ * that bake in deployment-specific assumptions (endpoint paths, auth
13
+ * headers, response shapes), the driver ships **one generic minimal
14
+ * default** and lets the operator supply the full contract via
15
+ * `providers.json`. Deployment-specific snippets live outside the package
16
+ * (e.g. a local `.internal/providers-*.sample.json`) and are applied by
17
+ * hand or via `devflow provider add --from <file>`.
18
+ *
19
+ * Config schema (providers.json config{}):
20
+ *
21
+ * {
22
+ * // --- required ---
23
+ * "endpoint": "https://your-weknora-host", // no trailing slash
24
+ * "kb_id": "kb-00000001", // knowledge base ID from WeKnora UI
25
+ * "token": "sk-xxxxx", // API Key from account settings page
26
+ *
27
+ * // --- optional core ---
28
+ * "tag_id": "", // default tag_id for uploads
29
+ * "idempotent": true, // recommended: findByTitle→update-or-create (avoids duplicates)
30
+ * "dryRun": false, // or env DEVFLOW_KB_DRY_RUN=1
31
+ * "timeoutMs": 30000,
32
+ *
33
+ * // --- contract overrides (merged into built-in WeKnora defaults) ---
34
+ * // Usually NOT needed for standard WeKnora deployments.
35
+ * // Only set if your instance has custom paths or auth.
36
+ * "auth": { "header": "X-API-Key", "scheme": "" },
37
+ * "paths": { "search": { "method": "POST", "path": "/api/v1/knowledge-search" } },
38
+ * "body": { "search": { "query": "{query}", "knowledge_base_id": "{kb_id}" } },
39
+ * "responsePath":{ "createId": "data.id", "searchItems": "data",
40
+ * "itemId": "knowledge_id", "itemTitle": "knowledge_title",
41
+ * "itemScore": "score", "itemSnippet": "content" }
42
+ * }
43
+ *
44
+ * Token resolution order:
45
+ * 1. config.token / config.api_key (supports "${env:VAR}" via caller's config loader)
46
+ * 2. env WEKNORA_TOKEN
47
+ * 3. env WEKNORA_API_KEY
48
+ *
49
+ * Templating:
50
+ * Both URL and body strings may contain `{var}` or `{var?}` (optional, dropped if
51
+ * unset). Vars available during calls: kb_id, title, content, id, tag_id,
52
+ * query, k, category, filename, ids.
53
+ *
54
+ * Provider contract:
55
+ * validate() -> { ok, reason?, needsLogin? }
56
+ * upload(file, meta) -> { uuid, url, action }
57
+ * update(uuid, file, meta)
58
+ * delete(uuid)
59
+ * search(query, opts={ k }) -> [{ title, uuid, url, score, snippet }]
60
+ */
61
+
62
+ /**
63
+ * Built-in contract aligned with the actual WeKnora REST API
64
+ * (https://github.com/Tencent/WeKnora/blob/main/docs/api/knowledge.md).
65
+ *
66
+ * Auth: X-API-Key header (no Bearer scheme)
67
+ * Create: POST /api/v1/knowledge-bases/{kb_id}/knowledge/manual {title,content,tag_id?}
68
+ * Update: PUT /api/v1/knowledge/manual/{id} {title?,content?}
69
+ * Delete: DELETE /api/v1/knowledge/{id}
70
+ * Search: POST /api/v1/knowledge-search {query, knowledge_base_id} → chunks with scores
71
+ * FindByTitle (idempotent): GET /api/v1/knowledge/search?keyword={title}&limit=5
72
+ */
73
+ const DEFAULT_CONTRACT = Object.freeze({
74
+ auth: { header: 'X-API-Key', scheme: '' }, // no scheme prefix — raw token
75
+ paths: {
76
+ create: { method: 'POST', path: '/api/v1/knowledge-bases/{kb_id}/knowledge/manual' },
77
+ update: { method: 'PUT', path: '/api/v1/knowledge/manual/{id}' },
78
+ delete: { method: 'DELETE', path: '/api/v1/knowledge/{id}' },
79
+ // Vector semantic search (returns ranked chunks)
80
+ search: { method: 'POST', path: '/api/v1/knowledge-search' },
81
+ // Metadata / keyword filter (used by idempotent findByTitle)
82
+ findByTitle: { method: 'GET', path: '/api/v1/knowledge/search?keyword={title}&limit=5' },
83
+ },
84
+ body: {
85
+ create: { title: '{title}', content: '{content}', tag_id: '{tag_id?}' },
86
+ update: { title: '{title}', content: '{content}' },
87
+ // search is POST with JSON body
88
+ search: { query: '{query}', knowledge_base_id: '{kb_id}' },
89
+ },
90
+ responsePath: {
91
+ createId: 'data.id', // { data: { id, ... }, success: true }
92
+ searchItems: 'data', // { data: [ { knowledge_id, content, score, ... } ] }
93
+ listItems: 'data', // GET knowledge list: { data: [...], success: true }
94
+ itemId: 'knowledge_id', // chunk item field for knowledge ID
95
+ itemTitle: 'knowledge_title',
96
+ itemUrl: 'knowledge_source', // source URL when type=url
97
+ itemScore: 'score',
98
+ itemSnippet: 'content',
99
+ },
100
+ idempotent: false,
101
+ });
102
+
103
+ class WeKnoraKbProvider extends Provider {
104
+ constructor({ name, type, driver, config }) {
105
+ super({ name, type, driver, config });
106
+
107
+ const cfg = config || {};
108
+ this.contract = {
109
+ auth: { ...DEFAULT_CONTRACT.auth, ...(cfg.auth || {}) },
110
+ paths: { ...DEFAULT_CONTRACT.paths, ...(cfg.paths || {}) },
111
+ body: mergeBodyMap(DEFAULT_CONTRACT.body, cfg.body),
112
+ responsePath: { ...DEFAULT_CONTRACT.responsePath, ...(cfg.responsePath || {}) },
113
+ idempotent: cfg.idempotent !== undefined ? !!cfg.idempotent : !!DEFAULT_CONTRACT.idempotent,
114
+ };
115
+
116
+ this.endpoint = (cfg.endpoint || '').replace(/\/+$/, '');
117
+ this.kbId = cfg.kb_id || cfg.knowledge_base_id || '';
118
+ this.token = cfg.token
119
+ || cfg.api_key
120
+ || process.env.WEKNORA_TOKEN
121
+ || process.env.WEKNORA_API_KEY
122
+ || '';
123
+ this.tagId = cfg.tag_id || '';
124
+ this.dryRun = !!cfg.dryRun || process.env.DEVFLOW_KB_DRY_RUN === '1';
125
+ this.timeout = Number(cfg.timeoutMs || 30000);
126
+ }
127
+
128
+ async validate() {
129
+ if (this.dryRun) return { ok: true };
130
+ if (!this.endpoint) return { ok: false, reason: 'missing endpoint' };
131
+ if (!this.kbId) return { ok: false, reason: 'missing kb_id (or knowledge_base_id)' };
132
+ if (!this.token) return { ok: false, reason: 'missing token (set WEKNORA_TOKEN or config.token)', needsLogin: true };
133
+ return { ok: true };
134
+ }
135
+
136
+ async upload(file, meta = {}) {
137
+ const { title, content } = await readMarkdownFile(file);
138
+ const tagId = meta.tag_id || this.tagId || '';
139
+ const category = meta.category || '';
140
+ const filename = meta.filename || path.basename(file);
141
+
142
+ if (this.contract.idempotent && this.contract.paths.findByTitle) {
143
+ const existing = await this._findByTitle(title);
144
+ if (existing) {
145
+ const id = getPath(existing, this.contract.responsePath.itemId) || existing.id;
146
+ await this._call('update', { id, title, content, tag_id: tagId, category, filename });
147
+ // Re-publish after updating so it re-enters embedding queue
148
+ await this._reparseById(id);
149
+ return { uuid: id, url: existing.url || null, action: 'updated' };
150
+ }
151
+ }
152
+ const res = await this._call('create', { title, content, tag_id: tagId, category, filename });
153
+ // WeKnora: { data: { id, ... }, success: true }
154
+ const id = getPath(res, this.contract.responsePath.createId)
155
+ || getPath(res, 'data.id')
156
+ || (res && res.uuid)
157
+ || null;
158
+ const url = getPath(res, this.contract.responsePath.itemUrl) || null;
159
+
160
+ // WeKnora manual items are created as drafts (enable_status: disabled).
161
+ // POST /knowledge/:id/reparse publishes them into the search index.
162
+ if (id) await this._reparseById(id);
163
+
164
+ // Ensure tag association is applied. Some WeKnora builds ignore tag_id in the
165
+ // create body, so we also call the batch-update-tags endpoint as a guarantee.
166
+ if (id && tagId) {
167
+ try { await this.batchUpdateTags({ [id]: tagId }); } catch { /* non-fatal */ }
168
+ }
169
+
170
+ return { uuid: id, url, action: 'created' };
171
+ }
172
+
173
+ async update(uuid, file, meta = {}) {
174
+ const { title, content } = await readMarkdownFile(file);
175
+ const tagId = meta.tag_id || this.tagId || '';
176
+ await this._call('update', { id: uuid, title, content, tag_id: tagId, category: meta.category, filename: meta.filename });
177
+ // Re-publish so updated content enters the embedding queue
178
+ await this._reparseById(uuid);
179
+ // Ensure tag is applied (in case update body didn't carry tag_id through)
180
+ if (tagId) {
181
+ try { await this.batchUpdateTags({ [uuid]: tagId }); } catch { /* non-fatal */ }
182
+ }
183
+ }
184
+
185
+ async delete(uuid) {
186
+ if (!this.contract.paths.delete) {
187
+ log('delete-skipped', { uuid, reason: 'contract has no delete endpoint' });
188
+ return;
189
+ }
190
+ await this._call('delete', { id: uuid });
191
+ }
192
+
193
+ /**
194
+ * Trigger async re-parse (re-embedding) for a knowledge item.
195
+ * This also transitions enable_status to "enabled" (i.e. "发布入库").
196
+ * WeKnora: POST /api/v1/knowledge/:id/reparse
197
+ */
198
+ async reparse(uuid) {
199
+ return this._reparseById(uuid);
200
+ }
201
+
202
+ async _reparseById(uuid) {
203
+ if (!uuid) return null;
204
+ if (this.dryRun) { log('reparse', { uuid }); return { status: 'dry_run' }; }
205
+ const url = `${this.endpoint}/api/v1/knowledge/${encodeURIComponent(uuid)}/reparse`;
206
+ const res = await this._fetch('POST', url, null);
207
+ return { status: 'reparsing', data: res && res.data };
208
+ }
209
+
210
+ /**
211
+ * Get parse_status for a knowledge item.
212
+ * WeKnora: GET /api/v1/knowledge/:id
213
+ */
214
+ async getStatus(uuid) {
215
+ if (this.dryRun) return { parse_status: 'completed', enable_status: 'enabled' };
216
+ const url = `${this.endpoint}/api/v1/knowledge/${encodeURIComponent(uuid)}`;
217
+ const res = await this._fetch('GET', url, null);
218
+ const d = (res && res.data) || res || {};
219
+ return { parse_status: d.parse_status, enable_status: d.enable_status, title: d.title };
220
+ }
221
+
222
+ async search(query, opts = {}) {
223
+ const res = await this._call('search', {
224
+ query,
225
+ kb_id: this.kbId,
226
+ tag_id: opts.tag_id || this.tagId || '',
227
+ k: opts.k || 10,
228
+ });
229
+ // WeKnora /knowledge-search returns { data: [ chunk... ], success }
230
+ // where each chunk has: knowledge_id, knowledge_title, content, score, knowledge_source
231
+ const itemsPath = this.contract.responsePath.searchItems || 'data';
232
+ const items = getPath(res, itemsPath) || [];
233
+ const rp = this.contract.responsePath;
234
+ return items.map((it) => ({
235
+ title: getPath(it, rp.itemTitle) || it.knowledge_title || it.title || '',
236
+ uuid: getPath(it, rp.itemId) || it.knowledge_id || it.id || '',
237
+ url: getPath(it, rp.itemUrl) || it.knowledge_source || it.url || null,
238
+ score: getPath(it, rp.itemScore) || it.score || 0,
239
+ snippet: getPath(it, rp.itemSnippet) || it.content || it.snippet || '',
240
+ }));
241
+ }
242
+
243
+ // ───────── tag management ─────────
244
+
245
+ /**
246
+ * List all tags in the knowledge base.
247
+ * WeKnora: GET /api/v1/knowledge-bases/:id/tags
248
+ * Returns array of { id, name, color, sort_order, knowledge_count }
249
+ */
250
+ async listTags() {
251
+ if (this.dryRun) return [];
252
+ const url = `${this.endpoint}/api/v1/knowledge-bases/${encodeURIComponent(this.kbId)}/tags?page=1&page_size=200`;
253
+ const res = await this._fetch('GET', url, null);
254
+ // { data: { data: [...], total }, success }
255
+ const items = getPath(res, 'data.data') || getPath(res, 'data') || [];
256
+ return Array.isArray(items) ? items : [];
257
+ }
258
+
259
+ /**
260
+ * Create a new tag in the knowledge base.
261
+ * WeKnora: POST /api/v1/knowledge-bases/:id/tags
262
+ * Returns the created tag object { id, name, color, sort_order }
263
+ */
264
+ async createTag(name, color = '#1890ff', sortOrder = 99) {
265
+ if (this.dryRun) {
266
+ const stubId = `dryrun-tag-${name.slice(0, 16)}-${Date.now().toString(36)}`;
267
+ log('createTag', { name, color });
268
+ return { id: stubId, name, color };
269
+ }
270
+ const url = `${this.endpoint}/api/v1/knowledge-bases/${encodeURIComponent(this.kbId)}/tags`;
271
+ const res = await this._fetch('POST', url, { name, color, sort_order: sortOrder });
272
+ return getPath(res, 'data') || res;
273
+ }
274
+
275
+ /**
276
+ * Batch-update tag associations for multiple knowledge items.
277
+ * Uses WeKnora's PUT /knowledge/tags bulk endpoint.
278
+ *
279
+ * @param {Object} updates { [uuid]: tagId | null } — null clears the tag
280
+ * @returns {boolean} true on success
281
+ */
282
+ async batchUpdateTags(updates) {
283
+ if (!updates || !Object.keys(updates).length) return true;
284
+ if (this.dryRun) { log('batchUpdateTags', updates); return true; }
285
+ const url = `${this.endpoint}/api/v1/knowledge/tags`;
286
+ const res = await this._fetch('PUT', url, { updates });
287
+ return !!(res && res.success !== false);
288
+ }
289
+
290
+ /**
291
+ * Idempotent ensure-tag: returns existing tag if name matches, otherwise creates it.
292
+ * Uses an in-memory cache per provider instance to avoid repeated list calls.
293
+ */
294
+ async ensureTag(name, color = '#1890ff', sortOrder = 99) {
295
+ // Initialise cache once per provider instance
296
+ if (!this._tagCache) {
297
+ const tags = await this.listTags();
298
+ this._tagCache = new Map(tags.map((t) => [t.name, t]));
299
+ }
300
+ if (this._tagCache.has(name)) return this._tagCache.get(name);
301
+ const tag = await this.createTag(name, color, sortOrder);
302
+ if (tag && tag.id) this._tagCache.set(name, tag);
303
+ return tag;
304
+ }
305
+
306
+ // ───────── internals ─────────
307
+
308
+ async _findByTitle(title) {
309
+ const tpl = this.contract.paths.findByTitle;
310
+ if (!tpl) return null;
311
+ if (this.dryRun) { log('findByTitle', { title }); return null; }
312
+ const pathTmpl = typeof tpl === 'string' ? tpl : tpl.path;
313
+ const url = this.endpoint + template(pathTmpl, { kb_id: this.kbId, title });
314
+ const res = await this._fetch('GET', url, null);
315
+ // WeKnora GET /knowledge/search returns { data: { data: [...], has_more }, success }
316
+ // Fall back to flat data array for other deployments.
317
+ const nested = getPath(res, 'data.data');
318
+ const flat = getPath(res, this.contract.responsePath.listItems);
319
+ const items = Array.isArray(nested) ? nested : (Array.isArray(flat) ? flat : []);
320
+ return items.find((it) => it && (it.title === title)) || null;
321
+ }
322
+
323
+ async _call(opKey, ctx) {
324
+ const endp = this.contract.paths[opKey];
325
+ if (!endp) throw new Error(`weknora contract has no endpoint for op: ${opKey}`);
326
+ const { method, pathTmpl } = normaliseEndpoint(endp);
327
+ const url = this.endpoint + template(pathTmpl, { kb_id: this.kbId, ...ctx });
328
+ let body = null;
329
+ if (method !== 'GET' && method !== 'DELETE' && method !== 'HEAD') {
330
+ const bodyTmpl = this.contract.body[opKey];
331
+ if (bodyTmpl !== undefined) body = fillBody(bodyTmpl, { kb_id: this.kbId, ...ctx });
332
+ }
333
+ if (this.dryRun) {
334
+ log(opKey, { method, url, body });
335
+ return stubResponse(opKey, ctx);
336
+ }
337
+ return this._fetch(method, url, body);
338
+ }
339
+
340
+ async _fetch(method, url, body) {
341
+ const headers = { 'accept': 'application/json' };
342
+ if (body !== null && body !== undefined) headers['content-type'] = 'application/json';
343
+ const auth = this.contract.auth;
344
+ if (auth && auth.header && this.token) {
345
+ // WeKnora uses raw API key without Bearer prefix; other deployments may use scheme
346
+ headers[auth.header] = (auth.scheme && auth.scheme.trim())
347
+ ? `${auth.scheme} ${this.token}`
348
+ : this.token;
349
+ }
350
+
351
+ const ctl = new AbortController();
352
+ const timer = setTimeout(() => ctl.abort(), this.timeout);
353
+ let res;
354
+ try {
355
+ res = await fetch(url, {
356
+ method,
357
+ headers,
358
+ body: body != null ? JSON.stringify(body) : undefined,
359
+ signal: ctl.signal,
360
+ });
361
+ } finally {
362
+ clearTimeout(timer);
363
+ }
364
+ if (!res.ok) {
365
+ const text = await res.text().catch(() => '');
366
+ const err = new Error(`${method} ${url} -> ${res.status} ${res.statusText}\n${text.slice(0, 400)}`);
367
+ err.status = res.status;
368
+ if (res.status === 401 || res.status === 403) err.code = 'AUTH_EXPIRED';
369
+ throw err;
370
+ }
371
+ const ct = res.headers.get('content-type') || '';
372
+ if (ct.includes('json')) return res.json();
373
+ const text = await res.text();
374
+ return text ? { raw: text } : null;
375
+ }
376
+ }
377
+
378
+ // ─────────── helpers ───────────
379
+
380
+ /** Read a markdown file, extract title from frontmatter title: or first `# heading`, fall back to filename. */
381
+ async function readMarkdownFile(filePath) {
382
+ const content = await fs.readFile(filePath, 'utf8');
383
+ let title = '';
384
+ const fm = /^---\s*\n([\s\S]*?)\n---/.exec(content);
385
+ if (fm) {
386
+ const m = /^title:\s*"?([^"\n]+)"?/m.exec(fm[1]);
387
+ if (m) title = m[1].trim();
388
+ }
389
+ if (!title) {
390
+ const m = /^#\s+(.+)$/m.exec(content);
391
+ if (m) title = m[1].trim();
392
+ }
393
+ if (!title) title = path.basename(filePath, path.extname(filePath));
394
+ return { title, content };
395
+ }
396
+
397
+ /** Normalise paths[op] — accepts string "/x/y" (POST default) or { method, path }. */
398
+ function normaliseEndpoint(entry) {
399
+ if (typeof entry === 'string') {
400
+ return { method: 'POST', pathTmpl: entry };
401
+ }
402
+ return { method: (entry.method || 'GET').toUpperCase(), pathTmpl: entry.path };
403
+ }
404
+
405
+ /** URL template: replaces {var} and {var?} with encoded value, leaves other text untouched. */
406
+ function template(str, ctx) {
407
+ return String(str).replace(/\{(\w+)(\?)?\}/g, (_, k) => {
408
+ const v = ctx[k];
409
+ if (v === undefined || v === null || v === '') return '';
410
+ return encodeURIComponent(String(v));
411
+ });
412
+ }
413
+
414
+ /** Body template: recursively resolve {var} / {var?} against ctx. Drop keys with optional+missing values. */
415
+ function fillBody(tpl, ctx) {
416
+ if (tpl === null || tpl === undefined) return tpl;
417
+ if (Array.isArray(tpl)) return tpl.map((x) => fillBody(x, ctx)).filter((x) => x !== undefined);
418
+ if (typeof tpl === 'object') {
419
+ const out = {};
420
+ for (const [k, v] of Object.entries(tpl)) {
421
+ const filled = fillBody(v, ctx);
422
+ if (filled !== undefined) out[k] = filled;
423
+ }
424
+ return out;
425
+ }
426
+ if (typeof tpl !== 'string') return tpl;
427
+ const m = /^\{(\w+)(\?)?\}$/.exec(tpl);
428
+ if (m) {
429
+ const v = ctx[m[1]];
430
+ if (v === undefined || v === null || v === '') return m[2] ? undefined : '';
431
+ return v;
432
+ }
433
+ return tpl.replace(/\{(\w+)(\?)?\}/g, (_, k) => {
434
+ const v = ctx[k];
435
+ if (v === undefined || v === null || v === '') return '';
436
+ return String(v);
437
+ });
438
+ }
439
+
440
+ function getPath(obj, dotPath) {
441
+ if (!obj || !dotPath) return undefined;
442
+ return dotPath.split('.').reduce((acc, k) => (acc == null ? undefined : acc[k]), obj);
443
+ }
444
+
445
+ function mergeBodyMap(base = {}, override) {
446
+ if (!override) return { ...base };
447
+ const out = { ...base };
448
+ for (const k of Object.keys(override)) out[k] = override[k];
449
+ return out;
450
+ }
451
+
452
+ function stubResponse(opKey, ctx) {
453
+ const stubId = `dryrun-${opKey}-${(ctx.title || ctx.query || 'x').slice(0, 32)}-${Date.now().toString(36)}`;
454
+ // WeKnora shapes
455
+ if (opKey === 'search') return { data: [], success: true };
456
+ if (opKey === 'create') return { data: { id: stubId, type: 'manual', parse_status: 'processing', enable_status: 'disabled' }, success: true };
457
+ if (opKey === 'update') return { message: 'Updated successfully', success: true };
458
+ if (opKey === 'delete') return { message: 'Deleted successfully', success: true };
459
+ if (opKey === 'reparse') return { data: { id: ctx.id, parse_status: 'pending' }, success: true };
460
+ return { success: true };
461
+ }
462
+
463
+ function log(action, payload) {
464
+ process.stdout.write(`[kb-weknora dry-run] ${action} ${JSON.stringify(payload)}\n`);
465
+ }
466
+
467
+ module.exports = {
468
+ default: WeKnoraKbProvider,
469
+ WeKnoraKbProvider,
470
+ DEFAULT_CONTRACT,
471
+ _internals: { template, fillBody, getPath, readMarkdownFile, normaliseEndpoint },
472
+ };