the-campfire 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/bin/cli.ts +182 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
  4. package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
  5. package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
  6. package/dist/assets/arc-D9SEA8dX.js +1 -0
  7. package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
  8. package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
  9. package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
  10. package/dist/assets/channel-CxhcHn7S.js +1 -0
  11. package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
  12. package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
  13. package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
  14. package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
  15. package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
  16. package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
  17. package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
  18. package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
  19. package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
  21. package/dist/assets/clone-C6kt1rE7.js +1 -0
  22. package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
  23. package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  24. package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
  25. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  26. package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
  27. package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
  28. package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
  29. package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
  30. package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
  31. package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
  32. package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
  33. package/dist/assets/graph-DKRbrxcs.js +1 -0
  34. package/dist/assets/index-Byjg9zgI.css +32 -0
  35. package/dist/assets/index-Cwj3m0hT.js +284 -0
  36. package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
  37. package/dist/assets/init-Gi6I4Gst.js +1 -0
  38. package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
  39. package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
  40. package/dist/assets/katex-BlHpptmG.js +261 -0
  41. package/dist/assets/layout-DnxSwLDJ.js +1 -0
  42. package/dist/assets/linear-CWbbIT8l.js +1 -0
  43. package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
  44. package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
  45. package/dist/assets/ordinal-BENe2yWM.js +1 -0
  46. package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
  47. package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
  48. package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
  49. package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
  50. package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
  51. package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
  52. package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
  53. package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
  54. package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
  55. package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
  56. package/dist/favicon.svg +3 -0
  57. package/dist/fonts/Geist-Variable.woff2 +0 -0
  58. package/dist/fonts/GeistMono-Variable.woff2 +0 -0
  59. package/dist/icon-192.png +0 -0
  60. package/dist/icon-512.png +0 -0
  61. package/dist/index.html +33 -0
  62. package/dist/logo-codex.svg +14 -0
  63. package/dist/logo.svg +3 -0
  64. package/dist/manifest.json +26 -0
  65. package/dist/og-image.png +0 -0
  66. package/dist/sw.js +130 -0
  67. package/package.json +92 -0
  68. package/server/ADAPTERS.md +121 -0
  69. package/server/adapter-registry-types.ts +28 -0
  70. package/server/adapter-registry.ts +293 -0
  71. package/server/adapter-types.ts +51 -0
  72. package/server/agent-executor.ts +249 -0
  73. package/server/agent-mcp-bridge.ts +148 -0
  74. package/server/agent-mcp-stdio.ts +171 -0
  75. package/server/agent-mcp-tools.ts +76 -0
  76. package/server/agent-store.ts +183 -0
  77. package/server/agent-types.ts +79 -0
  78. package/server/aider-adapter.ts +525 -0
  79. package/server/auth.ts +153 -0
  80. package/server/auto-namer.ts +132 -0
  81. package/server/capability-discovery.ts +431 -0
  82. package/server/claude-container-auth.ts +40 -0
  83. package/server/claude-stdio-adapter.ts +452 -0
  84. package/server/clawhub-export.ts +218 -0
  85. package/server/cli-launcher.ts +1613 -0
  86. package/server/codex-adapter.ts +1987 -0
  87. package/server/codex-container-auth.ts +40 -0
  88. package/server/codex-home.ts +26 -0
  89. package/server/collective-intelligence.ts +401 -0
  90. package/server/commands-discovery.ts +160 -0
  91. package/server/constants.ts +5 -0
  92. package/server/container-manager.ts +328 -0
  93. package/server/cron-scheduler.ts +243 -0
  94. package/server/cron-store.ts +149 -0
  95. package/server/cron-types.ts +63 -0
  96. package/server/deliberation-engine.ts +323 -0
  97. package/server/embedding.ts +89 -0
  98. package/server/env-manager.ts +146 -0
  99. package/server/environment-detector.ts +106 -0
  100. package/server/environment-rules.ts +106 -0
  101. package/server/gallery-store.ts +216 -0
  102. package/server/gallery-types.ts +52 -0
  103. package/server/gallery-votes.ts +101 -0
  104. package/server/git-utils.ts +384 -0
  105. package/server/github-pr.ts +379 -0
  106. package/server/goose-adapter.ts +929 -0
  107. package/server/image-pull-manager.ts +144 -0
  108. package/server/index.ts +363 -0
  109. package/server/linear-project-manager.ts +78 -0
  110. package/server/moltbook-client.ts +160 -0
  111. package/server/openclaw-adapter.ts +916 -0
  112. package/server/openclaw-channel/channel.ts +188 -0
  113. package/server/openclaw-channel/index.ts +68 -0
  114. package/server/openclaw-channel/package.json +19 -0
  115. package/server/openclaw-channel/types.ts +93 -0
  116. package/server/opencode-adapter.ts +951 -0
  117. package/server/openhands-adapter.ts +775 -0
  118. package/server/orchestrator-executor.ts +184 -0
  119. package/server/orchestrator-store.ts +106 -0
  120. package/server/orchestrator-types.ts +86 -0
  121. package/server/path-resolver.ts +168 -0
  122. package/server/pr-poller.ts +162 -0
  123. package/server/proactive-keepalive.ts +121 -0
  124. package/server/prompt-manager.ts +103 -0
  125. package/server/protocol-monitor.ts +197 -0
  126. package/server/race-controller.ts +295 -0
  127. package/server/race-store.ts +82 -0
  128. package/server/recorder.ts +359 -0
  129. package/server/recording-hub/compat-validator.ts +122 -0
  130. package/server/recording-hub/diagnostics.ts +289 -0
  131. package/server/recording-hub/hub-routes.ts +146 -0
  132. package/server/recording-hub/hub-store.ts +224 -0
  133. package/server/replay.ts +78 -0
  134. package/server/routes/adapter-routes.ts +31 -0
  135. package/server/routes/agent-mcp-routes.ts +35 -0
  136. package/server/routes/agent-routes.ts +185 -0
  137. package/server/routes/auth-routes.ts +101 -0
  138. package/server/routes/ci-routes.ts +165 -0
  139. package/server/routes/commands-routes.ts +94 -0
  140. package/server/routes/cron-routes.ts +100 -0
  141. package/server/routes/env-routes.ts +50 -0
  142. package/server/routes/folder-routes.ts +56 -0
  143. package/server/routes/fs-routes.ts +403 -0
  144. package/server/routes/gallery-routes.ts +410 -0
  145. package/server/routes/git-routes.ts +108 -0
  146. package/server/routes/index.ts +62 -0
  147. package/server/routes/linear-routes.ts +254 -0
  148. package/server/routes/monitor-routes.ts +11 -0
  149. package/server/routes/orchestrator-routes.ts +210 -0
  150. package/server/routes/prompt-routes.ts +47 -0
  151. package/server/routes/race-routes.ts +103 -0
  152. package/server/routes/recording-routes.ts +101 -0
  153. package/server/routes/route-deps.ts +32 -0
  154. package/server/routes/session-routes.ts +560 -0
  155. package/server/routes/settings-routes.ts +108 -0
  156. package/server/routes/skills-routes.ts +52 -0
  157. package/server/routes/system-routes.ts +243 -0
  158. package/server/routes/webhook-routes.ts +135 -0
  159. package/server/routes.ts +44 -0
  160. package/server/security-middleware.ts +100 -0
  161. package/server/semantic-memory.ts +389 -0
  162. package/server/service.ts +733 -0
  163. package/server/session-folders.ts +107 -0
  164. package/server/session-git-info.ts +89 -0
  165. package/server/session-linear-issues.ts +94 -0
  166. package/server/session-names.ts +67 -0
  167. package/server/session-store.ts +169 -0
  168. package/server/session-types.ts +415 -0
  169. package/server/settings-manager.ts +120 -0
  170. package/server/shared-context.ts +401 -0
  171. package/server/skills-manager.ts +250 -0
  172. package/server/sub-session-manager.ts +263 -0
  173. package/server/terminal-manager.ts +185 -0
  174. package/server/update-checker.ts +114 -0
  175. package/server/usage-limits.ts +192 -0
  176. package/server/webhook-manager.ts +228 -0
  177. package/server/webhook-store.ts +168 -0
  178. package/server/webhook-types.ts +66 -0
  179. package/server/worktree-tracker.ts +84 -0
  180. package/server/ws-bridge.ts +2322 -0
@@ -0,0 +1,149 @@
1
+ import {
2
+ mkdirSync,
3
+ readdirSync,
4
+ readFileSync,
5
+ writeFileSync,
6
+ unlinkSync,
7
+ existsSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { homedir } from "node:os";
11
+ import type { CronJob, CronJobCreateInput } from "./cron-types.js";
12
+
13
+ // ─── Paths ──────────────────────────────────────────────────────────────────
14
+
15
+ const CAMPFIRE_DIR = join(homedir(), ".campfire");
16
+ const CRON_DIR = join(CAMPFIRE_DIR, "cron");
17
+
18
+ function ensureDir(): void {
19
+ mkdirSync(CRON_DIR, { recursive: true });
20
+ }
21
+
22
+ function filePath(id: string): string {
23
+ return join(CRON_DIR, `${id}.json`);
24
+ }
25
+
26
+ // ─── Helpers ────────────────────────────────────────────────────────────────
27
+
28
+ function slugify(name: string): string {
29
+ return name
30
+ .toLowerCase()
31
+ .replace(/\s+/g, "-")
32
+ .replace(/[^a-z0-9-]/g, "")
33
+ .replace(/-+/g, "-")
34
+ .replace(/^-|-$/g, "");
35
+ }
36
+
37
+ // ─── CRUD ───────────────────────────────────────────────────────────────────
38
+
39
+ export function listJobs(): CronJob[] {
40
+ ensureDir();
41
+ try {
42
+ const files = readdirSync(CRON_DIR).filter((f) => f.endsWith(".json"));
43
+ const jobs: CronJob[] = [];
44
+ for (const file of files) {
45
+ try {
46
+ const raw = readFileSync(join(CRON_DIR, file), "utf-8");
47
+ jobs.push(JSON.parse(raw));
48
+ } catch {
49
+ // Skip corrupt files
50
+ }
51
+ }
52
+ jobs.sort((a, b) => a.name.localeCompare(b.name));
53
+ return jobs;
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ export function getJob(id: string): CronJob | null {
60
+ ensureDir();
61
+ try {
62
+ const raw = readFileSync(filePath(id), "utf-8");
63
+ return JSON.parse(raw) as CronJob;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export function createJob(data: CronJobCreateInput): CronJob {
70
+ if (!data.name || !data.name.trim()) throw new Error("Job name is required");
71
+ if (!data.prompt || !data.prompt.trim()) throw new Error("Job prompt is required");
72
+ if (!data.schedule || !data.schedule.trim()) throw new Error("Job schedule is required");
73
+ if (!data.cwd || !data.cwd.trim()) throw new Error("Job working directory is required");
74
+
75
+ const id = slugify(data.name.trim());
76
+ if (!id) throw new Error("Job name must contain alphanumeric characters");
77
+
78
+ ensureDir();
79
+ if (existsSync(filePath(id))) {
80
+ throw new Error(`A job with a similar name already exists ("${id}")`);
81
+ }
82
+
83
+ const now = Date.now();
84
+ const job: CronJob = {
85
+ ...data,
86
+ id,
87
+ name: data.name.trim(),
88
+ prompt: data.prompt.trim(),
89
+ schedule: data.schedule.trim(),
90
+ cwd: data.cwd.trim(),
91
+ createdAt: now,
92
+ updatedAt: now,
93
+ consecutiveFailures: 0,
94
+ totalRuns: 0,
95
+ };
96
+ writeFileSync(filePath(id), JSON.stringify(job, null, 2), "utf-8");
97
+ return job;
98
+ }
99
+
100
+ export function updateJob(
101
+ id: string,
102
+ updates: Partial<CronJob>,
103
+ ): CronJob | null {
104
+ ensureDir();
105
+ const existing = getJob(id);
106
+ if (!existing) return null;
107
+
108
+ const newName = updates.name?.trim() || existing.name;
109
+ const newId = slugify(newName);
110
+ if (!newId) throw new Error("Job name must contain alphanumeric characters");
111
+
112
+ // If name changed, check for slug collision with a different job
113
+ if (newId !== id && existsSync(filePath(newId))) {
114
+ throw new Error(`A job with a similar name already exists ("${newId}")`);
115
+ }
116
+
117
+ const job: CronJob = {
118
+ ...existing,
119
+ ...updates,
120
+ id: newId,
121
+ name: newName,
122
+ updatedAt: Date.now(),
123
+ // Preserve immutable fields
124
+ createdAt: existing.createdAt,
125
+ };
126
+
127
+ // If id changed, delete old file
128
+ if (newId !== id) {
129
+ try {
130
+ unlinkSync(filePath(id));
131
+ } catch {
132
+ /* ok */
133
+ }
134
+ }
135
+
136
+ writeFileSync(filePath(newId), JSON.stringify(job, null, 2), "utf-8");
137
+ return job;
138
+ }
139
+
140
+ export function deleteJob(id: string): boolean {
141
+ ensureDir();
142
+ if (!existsSync(filePath(id))) return false;
143
+ try {
144
+ unlinkSync(filePath(id));
145
+ return true;
146
+ } catch {
147
+ return false;
148
+ }
149
+ }
@@ -0,0 +1,63 @@
1
+ // ─── Cron Job Types ────────────────────────────────────────────────────────
2
+
3
+ export interface CronJob {
4
+ /** Unique slug-based ID (derived from name) */
5
+ id: string;
6
+ /** Human-readable job name */
7
+ name: string;
8
+ /** The prompt to send when the job fires */
9
+ prompt: string;
10
+ /** Cron expression (e.g. "0 8 * * *") or ISO datetime string for one-shot */
11
+ schedule: string;
12
+ /** true = recurring cron, false = one-shot at a specific time */
13
+ recurring: boolean;
14
+ /** Backend to use */
15
+ backendType: "claude" | "codex";
16
+ /** Model to use (e.g. "claude-sonnet-4-5-20250929") */
17
+ model: string;
18
+ /** Working directory for the session */
19
+ cwd: string;
20
+ /** Optional environment slug (references ~/.campfire/envs/) */
21
+ envSlug?: string;
22
+ /** Whether the job is currently enabled */
23
+ enabled: boolean;
24
+ /** Permission mode — defaults to "bypassPermissions" for autonomy */
25
+ permissionMode: string;
26
+ /** Codex-only: enable internet access */
27
+ codexInternetAccess?: boolean;
28
+
29
+ // ── Tracking ──
30
+ createdAt: number;
31
+ updatedAt: number;
32
+ /** Last time this job was triggered */
33
+ lastRunAt?: number;
34
+ /** Session ID of the last execution */
35
+ lastSessionId?: string;
36
+ /** Number of consecutive failures */
37
+ consecutiveFailures: number;
38
+ /** Total number of runs */
39
+ totalRuns: number;
40
+ }
41
+
42
+ export interface CronJobExecution {
43
+ /** The session ID created for this execution */
44
+ sessionId: string;
45
+ /** The job ID that triggered this */
46
+ jobId: string;
47
+ /** When the execution started */
48
+ startedAt: number;
49
+ /** When the execution completed (result received) */
50
+ completedAt?: number;
51
+ /** Whether the execution succeeded */
52
+ success?: boolean;
53
+ /** Error message if it failed */
54
+ error?: string;
55
+ /** Cost in USD */
56
+ costUsd?: number;
57
+ }
58
+
59
+ /** Input for creating a cron job (without auto-generated fields) */
60
+ export type CronJobCreateInput = Omit<
61
+ CronJob,
62
+ "id" | "createdAt" | "updatedAt" | "consecutiveFailures" | "totalRuns" | "lastRunAt" | "lastSessionId"
63
+ >;
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Layer 2: Deliberation Protocol
3
+ *
4
+ * Structured debate before agents execute significant actions (large refactors,
5
+ * architectural changes, file deletions). Extends Campfire's existing binary
6
+ * permission voting into nuanced multi-stakeholder deliberation.
7
+ *
8
+ * Flow: Agent proposes → humans/agents respond → consensus engine resolves.
9
+ *
10
+ * Proposal lifecycle:
11
+ * pending → [responses collected] → resolved (approved | rejected | synthesized)
12
+ *
13
+ * Resolution is triggered by:
14
+ * - All requested parties have responded
15
+ * - Deadline reached (if set)
16
+ * - Human owner force-resolves via POST /api/sessions/:id/deliberations/:id/resolve
17
+ */
18
+
19
+ import { randomUUID } from "node:crypto";
20
+ import type { BackendType } from "./session-types.js";
21
+
22
+ // ─── Types ────────────────────────────────────────────────────────────────────
23
+
24
+ export type DeliberationAction = "refactor" | "feature" | "fix" | "investigate" | "delete" | "architect";
25
+ export type DeliberationStance = "agree" | "disagree" | "suggest_alternative" | "abstain";
26
+ export type DeliberationOutcome = "approved" | "rejected" | "synthesized";
27
+
28
+ export interface DeliberationAlternative {
29
+ description: string;
30
+ tradeoffs: string;
31
+ }
32
+
33
+ export interface DeliberationProposal {
34
+ type: "deliberation_proposal";
35
+ proposalId: string;
36
+ sessionId: string;
37
+ agentId: string;
38
+ backendType: BackendType;
39
+ timestamp: number;
40
+ action: DeliberationAction;
41
+ title: string;
42
+ description: string;
43
+ approach: string;
44
+ alternatives: DeliberationAlternative[];
45
+ risks: string[];
46
+ affectedFiles: string[];
47
+ estimatedTurns?: number;
48
+ requestingFeedbackFrom: string[]; // viewer IDs or ["all"]
49
+ deadline?: number; // Unix ms, null = indefinite
50
+ }
51
+
52
+ export interface DeliberationResponse {
53
+ type: "deliberation_response";
54
+ proposalId: string;
55
+ responderId: string;
56
+ responderType: "agent" | "human";
57
+ backendType?: BackendType;
58
+ timestamp: number;
59
+ stance: DeliberationStance;
60
+ reasoning: string;
61
+ suggestedAlternative?: string;
62
+ concerns?: string[];
63
+ }
64
+
65
+ export interface DeliberationResolution {
66
+ type: "deliberation_resolved";
67
+ proposalId: string;
68
+ timestamp: number;
69
+ outcome: DeliberationOutcome;
70
+ finalApproach: string;
71
+ participants: string[];
72
+ voteBreakdown: {
73
+ agree: number;
74
+ disagree: number;
75
+ suggest_alternative: number;
76
+ abstain: number;
77
+ };
78
+ synthesis?: string;
79
+ }
80
+
81
+ export type ResolutionCallback = (resolution: DeliberationResolution) => void;
82
+
83
+ // ─── Deliberation Engine ──────────────────────────────────────────────────────
84
+
85
+ interface ActiveDeliberation {
86
+ proposal: DeliberationProposal;
87
+ responses: DeliberationResponse[];
88
+ deadlineTimer?: ReturnType<typeof setTimeout>;
89
+ }
90
+
91
+ /**
92
+ * Manages active deliberations for all sessions.
93
+ * One instance shared across the server (singleton pattern matching existing managers).
94
+ *
95
+ * Consensus weights:
96
+ * - Human owner: 2.0x
97
+ * - Human collaborator: 1.5x
98
+ * - Human spectator: 0.5x
99
+ * - Agent: 1.0x (weighted by historical accuracy in future)
100
+ * Threshold: 60% weighted majority to approve or reject. Below threshold → synthesize.
101
+ */
102
+ export class DeliberationEngine {
103
+ // key = proposalId
104
+ private active = new Map<string, ActiveDeliberation>();
105
+ // callbacks for broadcasting resolutions to browsers
106
+ private onResolution: ResolutionCallback | null = null;
107
+
108
+ setOnResolution(cb: ResolutionCallback): void {
109
+ this.onResolution = cb;
110
+ }
111
+
112
+ /**
113
+ * Register a new proposal. Returns the proposal with a generated proposalId.
114
+ * Starts deadline timer if deadline is set.
115
+ */
116
+ register(proposal: Omit<DeliberationProposal, "proposalId" | "type">): DeliberationProposal {
117
+ const full: DeliberationProposal = {
118
+ ...proposal,
119
+ type: "deliberation_proposal",
120
+ proposalId: randomUUID(),
121
+ };
122
+
123
+ const entry: ActiveDeliberation = { proposal: full, responses: [] };
124
+
125
+ if (full.deadline) {
126
+ const ms = full.deadline - Date.now();
127
+ if (ms > 0) {
128
+ entry.deadlineTimer = setTimeout(() => {
129
+ this.resolveById(full.proposalId);
130
+ }, ms);
131
+ }
132
+ }
133
+
134
+ this.active.set(full.proposalId, entry);
135
+ return full;
136
+ }
137
+
138
+ /**
139
+ * Add a response to a proposal.
140
+ * If all requested parties have responded, triggers resolution.
141
+ */
142
+ addResponse(response: Omit<DeliberationResponse, "type">): DeliberationResponse | null {
143
+ const entry = this.active.get(response.proposalId);
144
+ if (!entry) return null;
145
+
146
+ const full: DeliberationResponse = { ...response, type: "deliberation_response" };
147
+
148
+ // Deduplicate: one response per responder
149
+ const existing = entry.responses.findIndex((r) => r.responderId === full.responderId);
150
+ if (existing >= 0) {
151
+ entry.responses[existing] = full;
152
+ } else {
153
+ entry.responses.push(full);
154
+ }
155
+
156
+ // Check if all requested parties have responded
157
+ const requested = entry.proposal.requestingFeedbackFrom;
158
+ if (requested.length > 0 && !requested.includes("all")) {
159
+ const respondedIds = new Set(entry.responses.map((r) => r.responderId));
160
+ const allResponded = requested.every((id) => respondedIds.has(id));
161
+ if (allResponded) {
162
+ this.resolveById(response.proposalId);
163
+ }
164
+ }
165
+
166
+ return full;
167
+ }
168
+
169
+ /**
170
+ * Force-resolve a deliberation (e.g. owner clicking "Resolve Now").
171
+ */
172
+ resolveById(proposalId: string): DeliberationResolution | null {
173
+ const entry = this.active.get(proposalId);
174
+ if (!entry) return null;
175
+
176
+ if (entry.deadlineTimer) clearTimeout(entry.deadlineTimer);
177
+
178
+ const resolution = this.evaluate(entry.proposal, entry.responses);
179
+ this.active.delete(proposalId);
180
+ this.onResolution?.(resolution);
181
+ return resolution;
182
+ }
183
+
184
+ getProposal(proposalId: string): DeliberationProposal | null {
185
+ return this.active.get(proposalId)?.proposal ?? null;
186
+ }
187
+
188
+ getResponses(proposalId: string): DeliberationResponse[] {
189
+ return this.active.get(proposalId)?.responses ?? [];
190
+ }
191
+
192
+ getActiveProposals(sessionId: string): DeliberationProposal[] {
193
+ return Array.from(this.active.values())
194
+ .filter((e) => e.proposal.sessionId === sessionId)
195
+ .map((e) => e.proposal);
196
+ }
197
+
198
+ // ─── Consensus Algorithm ────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Evaluate responses to a proposal and produce a resolution.
202
+ *
203
+ * Weights: human owner = 2.0, collaborator = 1.5, spectator = 0.5, agent = 1.0
204
+ * Threshold: 60% weighted score to approve or reject. Otherwise synthesize.
205
+ */
206
+ evaluate(
207
+ proposal: DeliberationProposal,
208
+ responses: DeliberationResponse[],
209
+ /** Optional map of responderId → role for human weight calculation */
210
+ roles?: Map<string, "owner" | "collaborator" | "spectator">,
211
+ ): DeliberationResolution {
212
+ if (responses.length === 0) {
213
+ // No responses — auto-approve (agent proceeds unblocked)
214
+ return {
215
+ type: "deliberation_resolved",
216
+ proposalId: proposal.proposalId,
217
+ timestamp: Date.now(),
218
+ outcome: "approved",
219
+ finalApproach: proposal.approach,
220
+ participants: [],
221
+ voteBreakdown: { agree: 0, disagree: 0, suggest_alternative: 0, abstain: 0 },
222
+ };
223
+ }
224
+
225
+ const weights = new Map<string, number>();
226
+ for (const r of responses) {
227
+ if (r.responderType === "human") {
228
+ const role = roles?.get(r.responderId);
229
+ weights.set(r.responderId, role === "owner" ? 2.0 : role === "collaborator" ? 1.5 : 0.5);
230
+ } else {
231
+ weights.set(r.responderId, 1.0);
232
+ }
233
+ }
234
+
235
+ const totalWeight = Array.from(weights.values()).reduce((s, w) => s + w, 0);
236
+
237
+ const breakdown = { agree: 0, disagree: 0, suggest_alternative: 0, abstain: 0 };
238
+ let agreeScore = 0;
239
+ let disagreeScore = 0;
240
+
241
+ for (const r of responses) {
242
+ const w = weights.get(r.responderId) ?? 1.0;
243
+ breakdown[r.stance]++;
244
+ if (r.stance === "agree") agreeScore += w;
245
+ if (r.stance === "disagree") disagreeScore += w;
246
+ }
247
+
248
+ const THRESHOLD = 0.6;
249
+
250
+ if (totalWeight > 0 && agreeScore / totalWeight >= THRESHOLD) {
251
+ return {
252
+ type: "deliberation_resolved",
253
+ proposalId: proposal.proposalId,
254
+ timestamp: Date.now(),
255
+ outcome: "approved",
256
+ finalApproach: proposal.approach,
257
+ participants: responses.map((r) => r.responderId),
258
+ voteBreakdown: breakdown,
259
+ };
260
+ }
261
+
262
+ if (totalWeight > 0 && disagreeScore / totalWeight >= THRESHOLD) {
263
+ return {
264
+ type: "deliberation_resolved",
265
+ proposalId: proposal.proposalId,
266
+ timestamp: Date.now(),
267
+ outcome: "rejected",
268
+ finalApproach: "",
269
+ participants: responses.map((r) => r.responderId),
270
+ voteBreakdown: breakdown,
271
+ };
272
+ }
273
+
274
+ // Below threshold — synthesize from alternatives
275
+ const alternatives = responses
276
+ .filter((r) => r.stance === "suggest_alternative" && r.suggestedAlternative)
277
+ .map((r) => r.suggestedAlternative!);
278
+
279
+ const synthesis = this.synthesizeAlternatives(proposal, alternatives, responses);
280
+
281
+ return {
282
+ type: "deliberation_resolved",
283
+ proposalId: proposal.proposalId,
284
+ timestamp: Date.now(),
285
+ outcome: "synthesized",
286
+ finalApproach: synthesis,
287
+ participants: responses.map((r) => r.responderId),
288
+ voteBreakdown: breakdown,
289
+ synthesis,
290
+ };
291
+ }
292
+
293
+ private synthesizeAlternatives(
294
+ proposal: DeliberationProposal,
295
+ alternatives: string[],
296
+ responses: DeliberationResponse[],
297
+ ): string {
298
+ // Simple synthesis: combine original approach with top-confidence alternatives.
299
+ // In production, replace with an LLM call that considers all responses.
300
+ const concerns = responses
301
+ .flatMap((r) => r.concerns ?? [])
302
+ .slice(0, 5)
303
+ .map((c) => ` - ${c}`)
304
+ .join("\n");
305
+
306
+ const alts = alternatives
307
+ .slice(0, 3)
308
+ .map((a, i) => ` ${i + 1}. ${a}`)
309
+ .join("\n");
310
+
311
+ return [
312
+ `Synthesized approach for: ${proposal.title}`,
313
+ "",
314
+ `Original: ${proposal.approach}`,
315
+ concerns ? `\nConcerns raised:\n${concerns}` : "",
316
+ alts ? `\nSuggested alternatives:\n${alts}` : "",
317
+ "\nRecommendation: Address the concerns raised before proceeding with the original approach.",
318
+ ].filter(Boolean).join("\n");
319
+ }
320
+ }
321
+
322
+ // Singleton instance
323
+ export const deliberationEngine = new DeliberationEngine();
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Embedding provider abstraction for semantic memory.
3
+ *
4
+ * Supports two providers:
5
+ * - "openai": text-embedding-3-small (1536-dim) via OpenAI API
6
+ * - "ollama": nomic-embed-text (768-dim) via local Ollama at http://localhost:11434
7
+ * - "none": returns zero-vectors (disables vector search, fragments stored without embeddings)
8
+ *
9
+ * Provider is configured in ~/.campfire/settings.json via embeddingProvider field.
10
+ */
11
+
12
+ import { getSettings } from "./settings-manager.js";
13
+
14
+ export const OPENAI_DIM = 1536;
15
+ export const OLLAMA_DIM = 768;
16
+
17
+ /**
18
+ * Generate an embedding vector for the given text using the configured provider.
19
+ * Returns null if provider is "none" or if the embedding call fails.
20
+ */
21
+ export async function embed(text: string): Promise<number[] | null> {
22
+ const settings = getSettings();
23
+
24
+ if (settings.embeddingProvider === "openai") {
25
+ return embedWithOpenAI(text, settings.embeddingApiKey, settings.embeddingModel || "text-embedding-3-small");
26
+ }
27
+
28
+ if (settings.embeddingProvider === "ollama") {
29
+ return embedWithOllama(text, settings.embeddingBaseUrl || "http://localhost:11434", settings.embeddingModel || "nomic-embed-text");
30
+ }
31
+
32
+ return null;
33
+ }
34
+
35
+ /**
36
+ * Return the embedding dimension for the currently configured provider.
37
+ * Used when creating LanceDB tables so the vector column has the correct width.
38
+ */
39
+ export function getEmbeddingDim(): number {
40
+ const settings = getSettings();
41
+ if (settings.embeddingProvider === "openai") return OPENAI_DIM;
42
+ if (settings.embeddingProvider === "ollama") return OLLAMA_DIM;
43
+ return OPENAI_DIM; // default dimension when "none" (stored as zeros)
44
+ }
45
+
46
+ async function embedWithOpenAI(text: string, apiKey: string, model: string): Promise<number[] | null> {
47
+ if (!apiKey) {
48
+ console.warn("[embedding] OpenAI provider configured but no API key set");
49
+ return null;
50
+ }
51
+ try {
52
+ const res = await fetch("https://api.openai.com/v1/embeddings", {
53
+ method: "POST",
54
+ headers: {
55
+ "Authorization": `Bearer ${apiKey}`,
56
+ "Content-Type": "application/json",
57
+ },
58
+ body: JSON.stringify({ model, input: text }),
59
+ });
60
+ if (!res.ok) {
61
+ console.warn(`[embedding] OpenAI API error ${res.status}: ${await res.text()}`);
62
+ return null;
63
+ }
64
+ const json = await res.json() as { data: Array<{ embedding: number[] }> };
65
+ return json.data[0]?.embedding ?? null;
66
+ } catch (err) {
67
+ console.warn("[embedding] OpenAI request failed:", err);
68
+ return null;
69
+ }
70
+ }
71
+
72
+ async function embedWithOllama(text: string, baseUrl: string, model: string): Promise<number[] | null> {
73
+ try {
74
+ const res = await fetch(`${baseUrl}/api/embeddings`, {
75
+ method: "POST",
76
+ headers: { "Content-Type": "application/json" },
77
+ body: JSON.stringify({ model, prompt: text }),
78
+ });
79
+ if (!res.ok) {
80
+ console.warn(`[embedding] Ollama API error ${res.status}: ${await res.text()}`);
81
+ return null;
82
+ }
83
+ const json = await res.json() as { embedding: number[] };
84
+ return json.embedding ?? null;
85
+ } catch (err) {
86
+ console.warn("[embedding] Ollama request failed:", err);
87
+ return null;
88
+ }
89
+ }