forge-openclaw-plugin 0.2.24 → 0.2.25

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 (82) hide show
  1. package/README.md +13 -0
  2. package/dist/assets/{board-_C6oMy5w.js → board-VmF4FAfr.js} +3 -3
  3. package/dist/assets/{board-_C6oMy5w.js.map → board-VmF4FAfr.js.map} +1 -1
  4. package/dist/assets/index-CFCKDIMH.js +67 -0
  5. package/dist/assets/index-CFCKDIMH.js.map +1 -0
  6. package/dist/assets/index-ZPY6U1TU.css +1 -0
  7. package/dist/assets/{motion-D4sZgCHd.js → motion-DvkU14p-.js} +3 -3
  8. package/dist/assets/motion-DvkU14p-.js.map +1 -0
  9. package/dist/assets/{table-BWzTaky1.js → table-DgiPof9E.js} +2 -2
  10. package/dist/assets/{table-BWzTaky1.js.map → table-DgiPof9E.js.map} +1 -1
  11. package/dist/assets/{ui-BzK4azQb.js → ui-nYfoC0Gq.js} +2 -2
  12. package/dist/assets/{ui-BzK4azQb.js.map → ui-nYfoC0Gq.js.map} +1 -1
  13. package/dist/assets/vendor-D9PTEPSB.js +824 -0
  14. package/dist/assets/vendor-D9PTEPSB.js.map +1 -0
  15. package/dist/assets/viz-Cqb6s--o.js +34 -0
  16. package/dist/assets/viz-Cqb6s--o.js.map +1 -0
  17. package/dist/index.html +8 -8
  18. package/dist/openclaw/parity.d.ts +1 -1
  19. package/dist/openclaw/parity.js +29 -0
  20. package/dist/openclaw/plugin-entry-shared.d.ts +1 -0
  21. package/dist/openclaw/plugin-entry-shared.js +7 -4
  22. package/dist/openclaw/plugin-sdk-types.d.ts +12 -0
  23. package/dist/openclaw/routes.js +236 -0
  24. package/dist/openclaw/session-bootstrap.d.ts +78 -0
  25. package/dist/openclaw/session-bootstrap.js +240 -0
  26. package/dist/openclaw/tools.js +279 -3
  27. package/dist/server/app.js +855 -19
  28. package/dist/server/connectors/box-registry.js +257 -0
  29. package/dist/server/db.js +2 -0
  30. package/dist/server/discovery-advertiser.js +114 -0
  31. package/dist/server/health.js +39 -11
  32. package/dist/server/index.js +4 -0
  33. package/dist/server/managers/platform/llm-manager.js +40 -4
  34. package/dist/server/managers/platform/openai-responses-provider.js +129 -19
  35. package/dist/server/movement.js +2935 -0
  36. package/dist/server/openapi.js +628 -5
  37. package/dist/server/psyche-types.js +15 -1
  38. package/dist/server/questionnaire-flow.js +552 -0
  39. package/dist/server/questionnaire-seeds.js +853 -0
  40. package/dist/server/questionnaire-types.js +340 -0
  41. package/dist/server/repositories/ai-connectors.js +944 -0
  42. package/dist/server/repositories/ai-processors.js +547 -0
  43. package/dist/server/repositories/entity-ownership.js +9 -1
  44. package/dist/server/repositories/habits.js +69 -5
  45. package/dist/server/repositories/model-settings.js +216 -0
  46. package/dist/server/repositories/notes.js +57 -15
  47. package/dist/server/repositories/preferences.js +124 -0
  48. package/dist/server/repositories/questionnaires.js +1338 -0
  49. package/dist/server/repositories/settings.js +108 -12
  50. package/dist/server/repositories/surface-layouts.js +76 -0
  51. package/dist/server/repositories/wiki-memory.js +5 -1
  52. package/dist/server/services/entity-crud.js +81 -2
  53. package/dist/server/services/openai-codex-oauth.js +153 -0
  54. package/dist/server/services/psyche-observation-calendar.js +46 -0
  55. package/dist/server/types.js +492 -3
  56. package/dist/server/watch-mobile.js +562 -0
  57. package/dist/server/web.js +9 -2
  58. package/openclaw.plugin.json +1 -1
  59. package/package.json +6 -1
  60. package/server/migrations/024_questionnaires.sql +96 -0
  61. package/server/migrations/025_ai_model_connections.sql +26 -0
  62. package/server/migrations/026_custom_theme_settings.sql +2 -0
  63. package/server/migrations/027_ai_processors.sql +31 -0
  64. package/server/migrations/028_movement_domain.sql +136 -0
  65. package/server/migrations/029_watch_micro_capture.sql +23 -0
  66. package/server/migrations/030_surface_layouts.sql +5 -0
  67. package/server/migrations/031_ai_processor_runtime_upgrades.sql +10 -0
  68. package/server/migrations/032_ai_connectors.sql +44 -0
  69. package/server/migrations/033_movement_trip_point_sync.sql +36 -0
  70. package/server/migrations/034_movement_segment_sync.sql +49 -0
  71. package/skills/forge-openclaw/SKILL.md +12 -1
  72. package/skills/forge-openclaw/entity_conversation_playbooks.md +331 -84
  73. package/skills/forge-openclaw/psyche_entity_playbooks.md +252 -221
  74. package/dist/assets/index-DTCwBWAs.js +0 -65
  75. package/dist/assets/index-DTCwBWAs.js.map +0 -1
  76. package/dist/assets/index-DttXlAgi.css +0 -1
  77. package/dist/assets/motion-D4sZgCHd.js.map +0 -1
  78. package/dist/assets/vendor-De38P6YR.js +0 -729
  79. package/dist/assets/vendor-De38P6YR.js.map +0 -1
  80. package/dist/assets/viz-C6hfyqzu.js +0 -34
  81. package/dist/assets/viz-C6hfyqzu.js.map +0 -1
  82. package/skills/forge-openclaw/cron_jobs.md +0 -395
@@ -0,0 +1,257 @@
1
+ import { createNote } from "../repositories/notes.js";
2
+ import { updateTask } from "../repositories/tasks.js";
3
+ import { searchEntities } from "../services/entity-crud.js";
4
+ const SEARCH_TOOL = {
5
+ key: "forge.search_entities",
6
+ label: "Search Forge entities",
7
+ description: "Search Forge entities by query and entity types. Args: { query, entityTypes?, limit? }",
8
+ accessMode: "read"
9
+ };
10
+ const MOVE_TASK_TOOL = {
11
+ key: "forge.update_task_status",
12
+ label: "Move task",
13
+ description: "Update a task status. Args: { taskId, status } where status is backlog, focus, in_progress, blocked, or done.",
14
+ accessMode: "write"
15
+ };
16
+ const CREATE_NOTE_TOOL = {
17
+ key: "forge.create_note",
18
+ label: "Create note",
19
+ description: "Create an evidence note. Args: { title, markdown, summary? }.",
20
+ accessMode: "write"
21
+ };
22
+ const GENERIC_SURFACE_BOXES = [
23
+ ["overview", "/overview", "Overview", "Strategic overview and priorities."],
24
+ ["goals-index", "/goals", "Goals", "Goals workspace and long-range direction."],
25
+ ["habits-index", "/habits", "Habits", "Recurring commitments and check-ins."],
26
+ ["project-detail", "/projects/:projectId", "Project detail", "Project execution surface."],
27
+ ["projects", "/projects", "Projects", "Projects browser and search."],
28
+ ["strategies-index", "/strategies", "Strategies", "Strategy graphs and sequencing."],
29
+ ["strategy-detail", "/strategies/:strategyId", "Strategy detail", "Single strategy execution plan."],
30
+ ["preferences-index", "/preferences", "Preferences", "Preference model and comparisons."],
31
+ ["calendar", "/calendar", "Calendar", "Calendar planning and timeboxes."],
32
+ ["movement", "/movement", "Movement", "Movement stays, trips, and mobility context."],
33
+ ["sleep", "/sleep", "Sleep", "Sleep session review and recovery context."],
34
+ ["sports", "/sports", "Sports", "Workout history and sport reflection."],
35
+ ["kanban", "/kanban", "Kanban", "Task execution board."],
36
+ ["today", "/today", "Today", "Daily execution and focus."],
37
+ ["notes", "/notes", "Notes", "Notes browser and evidence surface."],
38
+ ["wiki", "/wiki", "Wiki", "Wiki knowledge workspace."],
39
+ ["psyche", "/psyche", "Psyche", "Psychological reflection and maps."],
40
+ ["activity", "/activity", "Activity", "Activity timeline and audit trail."],
41
+ ["insights", "/insights", "Insights", "Synthesized system recommendations."],
42
+ ["review-weekly", "/review/weekly", "Weekly review", "Weekly reflection report."],
43
+ ["settings", "/settings", "Settings", "Forge settings and operator controls."],
44
+ ["workbench", "/workbench", "Workbench", "Custom utility surface."]
45
+ ].map(([surfaceId, routePath, label, description]) => ({
46
+ boxId: `surface:${surfaceId}:main`,
47
+ surfaceId,
48
+ routePath,
49
+ label,
50
+ description,
51
+ category: "Views",
52
+ capabilityModes: ["content"],
53
+ toolAdapters: []
54
+ }));
55
+ const FEATURE_BOXES = [
56
+ {
57
+ boxId: "kanban:board",
58
+ surfaceId: "kanban",
59
+ routePath: "/kanban",
60
+ label: "Kanban board",
61
+ description: "Task board with task search context and task status actions.",
62
+ category: "Execution",
63
+ capabilityModes: ["content", "tool"],
64
+ toolAdapters: [SEARCH_TOOL, MOVE_TASK_TOOL]
65
+ },
66
+ {
67
+ boxId: "projects:list",
68
+ surfaceId: "projects",
69
+ routePath: "/projects",
70
+ label: "Projects list",
71
+ description: "Project browser, filters, and search context.",
72
+ category: "Execution",
73
+ capabilityModes: ["content", "tool"],
74
+ toolAdapters: [SEARCH_TOOL]
75
+ },
76
+ {
77
+ boxId: "today:focus",
78
+ surfaceId: "today",
79
+ routePath: "/today",
80
+ label: "Today focus",
81
+ description: "Today priorities and daily focus context.",
82
+ category: "Execution",
83
+ capabilityModes: ["content", "tool"],
84
+ toolAdapters: [SEARCH_TOOL]
85
+ },
86
+ {
87
+ boxId: "overview:priorities",
88
+ surfaceId: "overview",
89
+ routePath: "/overview",
90
+ label: "Overview priorities",
91
+ description: "Priority summary, momentum, and active work context.",
92
+ category: "Views",
93
+ capabilityModes: ["content"],
94
+ toolAdapters: []
95
+ },
96
+ {
97
+ boxId: "notes:quick-capture",
98
+ surfaceId: "notes",
99
+ routePath: "/notes",
100
+ label: "Quick capture",
101
+ description: "Simple note capture and evidence drafting surface.",
102
+ category: "Capture",
103
+ capabilityModes: ["content", "tool"],
104
+ toolAdapters: [CREATE_NOTE_TOOL]
105
+ }
106
+ ];
107
+ function summarizeSearchMatches(boxId, query, entityTypes, limit) {
108
+ const result = searchEntities({
109
+ searches: [
110
+ {
111
+ query,
112
+ entityTypes,
113
+ includeDeleted: false,
114
+ limit
115
+ }
116
+ ]
117
+ }).results[0];
118
+ const matches = result?.ok ? result.matches ?? [] : [];
119
+ const lines = matches.slice(0, limit).map((match) => {
120
+ const title = typeof match.title === "string"
121
+ ? match.title
122
+ : typeof match.name === "string"
123
+ ? match.name
124
+ : typeof match.id === "string"
125
+ ? match.id
126
+ : "Untitled";
127
+ const entityType = typeof match.entityType === "string" ? match.entityType : "entity";
128
+ return `${entityType}: ${title}`;
129
+ });
130
+ return {
131
+ boxId,
132
+ label: getForgeBoxCatalogEntry(boxId)?.label ?? boxId,
133
+ capturedAt: new Date().toISOString(),
134
+ contentText: lines.length > 0
135
+ ? lines.join("\n")
136
+ : "No matching Forge entities were found for this box snapshot.",
137
+ contentJson: {
138
+ matches
139
+ },
140
+ tools: getForgeBoxCatalogEntry(boxId)?.toolAdapters ?? []
141
+ };
142
+ }
143
+ export function listForgeBoxCatalog() {
144
+ return [...GENERIC_SURFACE_BOXES, ...FEATURE_BOXES];
145
+ }
146
+ export function getForgeBoxCatalogEntry(boxId) {
147
+ return listForgeBoxCatalog().find((entry) => entry.boxId === boxId) ?? null;
148
+ }
149
+ export function buildConnectorOutputCatalogEntry(input) {
150
+ return {
151
+ boxId: `connector-output:${input.outputId}`,
152
+ surfaceId: null,
153
+ routePath: `/connectors/${input.connectorId}`,
154
+ label: `${input.title} output`,
155
+ description: "Published AI connector output.",
156
+ category: "Connector outputs",
157
+ capabilityModes: ["content"],
158
+ toolAdapters: []
159
+ };
160
+ }
161
+ export function resolveForgeBoxSnapshot(boxId) {
162
+ if (boxId === "kanban:board") {
163
+ return summarizeSearchMatches(boxId, "", ["task"], 24);
164
+ }
165
+ if (boxId === "projects:list") {
166
+ return summarizeSearchMatches(boxId, "", ["project"], 20);
167
+ }
168
+ if (boxId === "today:focus") {
169
+ return summarizeSearchMatches(boxId, "", ["task", "habit"], 16);
170
+ }
171
+ if (boxId === "overview:priorities") {
172
+ return summarizeSearchMatches(boxId, "", ["goal", "project", "task"], 18);
173
+ }
174
+ const entry = getForgeBoxCatalogEntry(boxId);
175
+ return {
176
+ boxId,
177
+ label: entry?.label ?? boxId,
178
+ capturedAt: new Date().toISOString(),
179
+ contentText: entry
180
+ ? `${entry.label}\n${entry.description}\nRoute: ${entry.routePath ?? "n/a"}`
181
+ : "This box is registered but no live snapshot resolver is available yet.",
182
+ contentJson: entry
183
+ ? {
184
+ surfaceId: entry.surfaceId,
185
+ routePath: entry.routePath,
186
+ category: entry.category
187
+ }
188
+ : null,
189
+ tools: entry?.toolAdapters ?? []
190
+ };
191
+ }
192
+ export function executeForgeBoxTool(boxId, toolKey, args) {
193
+ if (toolKey === "forge.search_entities") {
194
+ const query = typeof args.query === "string" ? args.query.trim() : "";
195
+ const entityTypes = Array.isArray(args.entityTypes)
196
+ ? args.entityTypes.filter((entry) => typeof entry === "string" && entry.trim().length > 0)
197
+ : [];
198
+ const limit = typeof args.limit === "number" && Number.isFinite(args.limit)
199
+ ? Math.max(1, Math.min(50, Math.round(args.limit)))
200
+ : 12;
201
+ return summarizeSearchMatches(boxId, query, entityTypes, limit);
202
+ }
203
+ if (toolKey === "forge.update_task_status") {
204
+ const taskId = typeof args.taskId === "string" ? args.taskId : "";
205
+ const status = typeof args.status === "string" ? args.status : "";
206
+ const allowed = new Set([
207
+ "backlog",
208
+ "focus",
209
+ "in_progress",
210
+ "blocked",
211
+ "done"
212
+ ]);
213
+ if (!taskId || !allowed.has(status)) {
214
+ throw new Error("forge.update_task_status requires { taskId, status } with a valid task status.");
215
+ }
216
+ const task = updateTask(taskId, { status: status }, { source: "agent", actor: "AI Connector" });
217
+ if (!task) {
218
+ throw new Error(`Task ${taskId} was not found.`);
219
+ }
220
+ return {
221
+ ok: true,
222
+ task
223
+ };
224
+ }
225
+ if (toolKey === "forge.create_note") {
226
+ const title = typeof args.title === "string" ? args.title.trim() : "";
227
+ const markdown = typeof args.markdown === "string" ? args.markdown.trim() : "";
228
+ const summary = typeof args.summary === "string" ? args.summary.trim() : markdown.slice(0, 160);
229
+ if (!title || !markdown) {
230
+ throw new Error("forge.create_note requires { title, markdown }.");
231
+ }
232
+ const note = createNote({
233
+ kind: "evidence",
234
+ title,
235
+ slug: "",
236
+ spaceId: "",
237
+ parentSlug: null,
238
+ indexOrder: 0,
239
+ showInIndex: false,
240
+ aliases: [],
241
+ summary,
242
+ contentMarkdown: markdown,
243
+ author: "AI Connector",
244
+ destroyAt: null,
245
+ sourcePath: "ai-connector",
246
+ frontmatter: {},
247
+ revisionHash: "",
248
+ links: [],
249
+ tags: []
250
+ }, { source: "agent", actor: "AI Connector" });
251
+ return {
252
+ ok: true,
253
+ note
254
+ };
255
+ }
256
+ throw new Error(`Unsupported Forge box tool: ${toolKey}`);
257
+ }
package/dist/server/db.js CHANGED
@@ -3,6 +3,7 @@ import { mkdir, readdir, readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { DatabaseSync } from "node:sqlite";
6
+ import { ensureQuestionnaireSeeds } from "./repositories/questionnaires.js";
6
7
  function nowIso() {
7
8
  return new Date().toISOString();
8
9
  }
@@ -319,6 +320,7 @@ export async function initializeDatabase() {
319
320
  if (seedDemoDataEnabled) {
320
321
  seedData();
321
322
  }
323
+ ensureQuestionnaireSeeds();
322
324
  }
323
325
  export function configureDatabaseSeeding(enabled) {
324
326
  seedDemoDataEnabled = enabled;
@@ -0,0 +1,114 @@
1
+ import { execFile } from "node:child_process";
2
+ import os from "node:os";
3
+ import { promisify } from "node:util";
4
+ import { Bonjour } from "bonjour-service";
5
+ const execFileAsync = promisify(execFile);
6
+ export async function startForgeDiscoveryAdvertiser(options) {
7
+ if (options.enabled === false || process.env.FORGE_DISABLE_DISCOVERY_ADVERTISEMENT === "1") {
8
+ return null;
9
+ }
10
+ const basePath = normalizeBasePath(options.basePath);
11
+ const tailscaleTargets = await resolveTailscaleTargets({
12
+ apiBaseUrl: options.tailscaleApiBaseUrl,
13
+ uiBaseUrl: options.tailscaleUiBaseUrl,
14
+ basePath
15
+ });
16
+ const bonjour = new Bonjour();
17
+ const service = bonjour.publish({
18
+ name: buildServiceName(),
19
+ type: "forge",
20
+ protocol: "tcp",
21
+ port: options.port,
22
+ txt: {
23
+ apiPath: "/api/v1",
24
+ uiPath: basePath,
25
+ tsApiBaseUrl: tailscaleTargets.apiBaseUrl ?? "",
26
+ tsUiBaseUrl: tailscaleTargets.uiBaseUrl ?? "",
27
+ tsDnsName: tailscaleTargets.dnsName ?? "",
28
+ watchReady: "1"
29
+ }
30
+ });
31
+ if (typeof service.start === "function") {
32
+ service.start();
33
+ }
34
+ return {
35
+ stop: () => {
36
+ if (typeof service.stop === "function") {
37
+ service.stop(() => {
38
+ bonjour.destroy();
39
+ });
40
+ return;
41
+ }
42
+ bonjour.destroy();
43
+ }
44
+ };
45
+ }
46
+ function buildServiceName() {
47
+ const hostname = os.hostname().trim();
48
+ return hostname ? `Forge on ${hostname}` : "Forge";
49
+ }
50
+ function normalizeBasePath(value) {
51
+ if (!value || value === "/") {
52
+ return "/";
53
+ }
54
+ const withLeadingSlash = value.startsWith("/") ? value : `/${value}`;
55
+ return withLeadingSlash.endsWith("/") ? withLeadingSlash : `${withLeadingSlash}/`;
56
+ }
57
+ async function resolveTailscaleTargets(input) {
58
+ const explicitApi = normalizeHttpsUrl(input.apiBaseUrl);
59
+ const explicitUi = normalizeHttpsUrl(input.uiBaseUrl);
60
+ if (explicitApi || explicitUi) {
61
+ return {
62
+ apiBaseUrl: explicitApi,
63
+ uiBaseUrl: explicitUi,
64
+ dnsName: readDnsNameFromUrl(explicitApi ?? explicitUi)
65
+ };
66
+ }
67
+ const dnsName = await readTailscaleDnsName();
68
+ if (!dnsName) {
69
+ return { apiBaseUrl: null, uiBaseUrl: null, dnsName: null };
70
+ }
71
+ return {
72
+ apiBaseUrl: `https://${dnsName}/api/v1`,
73
+ uiBaseUrl: `https://${dnsName}${input.basePath}`,
74
+ dnsName
75
+ };
76
+ }
77
+ function normalizeHttpsUrl(value) {
78
+ const trimmed = value?.trim();
79
+ if (!trimmed) {
80
+ return null;
81
+ }
82
+ try {
83
+ const url = new URL(trimmed);
84
+ return url.protocol === "https:" ? url.toString().replace(/\/$/, "") : null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ function readDnsNameFromUrl(value) {
91
+ if (!value) {
92
+ return null;
93
+ }
94
+ try {
95
+ return new URL(value).hostname;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ }
101
+ async function readTailscaleDnsName() {
102
+ try {
103
+ const { stdout } = await execFileAsync("tailscale", ["status", "--json"], {
104
+ timeout: 1_500,
105
+ env: process.env
106
+ });
107
+ const parsed = JSON.parse(stdout);
108
+ const dnsName = parsed.Self?.DNSName?.trim().replace(/\.$/, "");
109
+ return dnsName || null;
110
+ }
111
+ catch {
112
+ return null;
113
+ }
114
+ }
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { getDatabase, runInTransaction } from "./db.js";
4
4
  import { HttpError } from "./errors.js";
5
+ import { getMovementMobileBootstrap, ingestMovementSync, movementSyncPayloadSchema } from "./movement.js";
5
6
  import { recordActivityEvent } from "./repositories/activity-events.js";
6
7
  import { recordHabitGeneratedWorkoutReward } from "./repositories/rewards.js";
7
8
  const healthLinkSchema = z.object({
@@ -119,7 +120,8 @@ export const mobileHealthSyncSchema = z.object({
119
120
  links: z.array(healthLinkSchema).default([]),
120
121
  annotations: workoutAnnotationSchema.partial().default({})
121
122
  }))
122
- .default([])
123
+ .default([]),
124
+ movement: movementSyncPayloadSchema.default({})
123
125
  });
124
126
  export const verifyCompanionPairingSchema = z.object({
125
127
  sessionId: z.string().trim().min(1),
@@ -606,7 +608,7 @@ export function verifyCompanionPairing(payload) {
606
608
  .get(pairing.id))
607
609
  };
608
610
  }
609
- function requireValidPairing(sessionId, pairingToken) {
611
+ export function requireValidPairing(sessionId, pairingToken) {
610
612
  const row = getDatabase()
611
613
  .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
612
614
  .get(sessionId);
@@ -838,6 +840,7 @@ export function ingestMobileHealthSync(payload) {
838
840
  let createdCount = 0;
839
841
  let updatedCount = 0;
840
842
  let mergedCount = 0;
843
+ const movementSync = ingestMovementSync(pairing, parsed.movement);
841
844
  for (const sleep of parsed.sleepSessions) {
842
845
  const result = insertOrUpdateSleepSession(pairing, sleep);
843
846
  if (result.mode === "created") {
@@ -879,8 +882,16 @@ export function ingestMobileHealthSync(payload) {
879
882
  .run(runId, pairing.id, pairing.user_id, parsed.device.sourceDevice, JSON.stringify({
880
883
  permissions: parsed.permissions,
881
884
  sleepSessions: parsed.sleepSessions.length,
882
- workouts: parsed.workouts.length
883
- }), parsed.sleepSessions.length + parsed.workouts.length, createdCount, updatedCount, mergedCount, now, now, now);
885
+ workouts: parsed.workouts.length,
886
+ movement: {
887
+ knownPlaces: parsed.movement.knownPlaces.length,
888
+ stays: parsed.movement.stays.length,
889
+ trips: parsed.movement.trips.length
890
+ }
891
+ }), parsed.sleepSessions.length +
892
+ parsed.workouts.length +
893
+ parsed.movement.stays.length +
894
+ parsed.movement.trips.length, createdCount + movementSync.createdCount, updatedCount + movementSync.updatedCount, mergedCount, now, now, now);
884
895
  recordActivityEvent({
885
896
  entityType: "system",
886
897
  entityId: pairing.id,
@@ -892,8 +903,10 @@ export function ingestMobileHealthSync(payload) {
892
903
  metadata: {
893
904
  sleepSessions: parsed.sleepSessions.length,
894
905
  workouts: parsed.workouts.length,
895
- createdCount,
896
- updatedCount,
906
+ movementStays: parsed.movement.stays.length,
907
+ movementTrips: parsed.movement.trips.length,
908
+ createdCount: createdCount + movementSync.createdCount,
909
+ updatedCount: updatedCount + movementSync.updatedCount,
897
910
  mergedCount
898
911
  }
899
912
  });
@@ -904,10 +917,14 @@ export function ingestMobileHealthSync(payload) {
904
917
  imported: {
905
918
  sleepSessions: parsed.sleepSessions.length,
906
919
  workouts: parsed.workouts.length,
907
- createdCount,
908
- updatedCount,
909
- mergedCount
910
- }
920
+ createdCount: createdCount + movementSync.createdCount,
921
+ updatedCount: updatedCount + movementSync.updatedCount,
922
+ mergedCount,
923
+ movementStays: parsed.movement.stays.length,
924
+ movementTrips: parsed.movement.trips.length,
925
+ movementKnownPlaces: parsed.movement.knownPlaces.length
926
+ },
927
+ movement: getMovementMobileBootstrap(pairing)
911
928
  };
912
929
  });
913
930
  }
@@ -916,6 +933,14 @@ export function getCompanionOverview(userIds) {
916
933
  const importRuns = listHealthImportRunRows(userIds).map(mapHealthImportRun);
917
934
  const sleepSessions = listSleepRows(userIds).map(mapSleepSession);
918
935
  const workouts = listWorkoutRows(userIds).map(mapWorkoutSession);
936
+ const movementSummary = importRuns.reduce((totals, run) => {
937
+ const movement = safeJsonParse(JSON.stringify(run.payloadSummary.movement ?? {}), {}) ?? {};
938
+ return {
939
+ knownPlaces: totals.knownPlaces + (movement.knownPlaces ?? 0),
940
+ stays: totals.stays + (movement.stays ?? 0),
941
+ trips: totals.trips + (movement.trips ?? 0)
942
+ };
943
+ }, { knownPlaces: 0, stays: 0, trips: 0 });
919
944
  const activePairings = pairings.filter((pairing) => pairing.status !== "revoked");
920
945
  const recentPermissionStates = importRuns
921
946
  .map((run) => safeJsonParse(JSON.stringify(run.payloadSummary), {}))
@@ -951,7 +976,10 @@ export function getCompanionOverview(userIds) {
951
976
  }).length,
952
977
  linkedWorkouts: workouts.filter((session) => session.links.length > 0).length,
953
978
  habitGeneratedWorkouts: workouts.filter((session) => session.sourceType === "habit_generated").length,
954
- reconciledWorkouts: workouts.filter((session) => session.reconciliationStatus === "merged").length
979
+ reconciledWorkouts: workouts.filter((session) => session.reconciliationStatus === "merged").length,
980
+ movementKnownPlaces: movementSummary.knownPlaces,
981
+ movementStays: movementSummary.stays,
982
+ movementTrips: movementSummary.trips
955
983
  },
956
984
  permissions: {
957
985
  healthKitAuthorized: recentPermissionStates.some((state) => state.healthKitAuthorized === true),
@@ -1,9 +1,13 @@
1
1
  import { buildServer } from "./app.js";
2
2
  import { closeDatabase } from "./db.js";
3
+ import { startForgeDiscoveryAdvertiser } from "./discovery-advertiser.js";
3
4
  const port = Number(process.env.PORT ?? 4317);
4
5
  const host = process.env.HOST ?? "0.0.0.0";
6
+ const basePath = process.env.FORGE_BASE_PATH ?? "/forge/";
5
7
  const app = await buildServer();
8
+ const discoveryAdvertiser = await startForgeDiscoveryAdvertiser({ port, basePath });
6
9
  const close = async () => {
10
+ discoveryAdvertiser?.stop();
7
11
  await app.close();
8
12
  closeDatabase();
9
13
  };
@@ -1,5 +1,6 @@
1
1
  import { AbstractManager } from "../base.js";
2
- import { readEncryptedSecret } from "../../repositories/calendar.js";
2
+ import { refreshOpenAICodexToken } from "@mariozechner/pi-ai/oauth";
3
+ import { readEncryptedSecret, storeEncryptedSecret } from "../../repositories/calendar.js";
3
4
  function emitDiagnostic(logger, input) {
4
5
  logger?.(input);
5
6
  }
@@ -32,7 +33,7 @@ export class LlmManager extends AbstractManager {
32
33
  });
33
34
  return null;
34
35
  }
35
- const apiKey = this.readApiKey(profile.secretId);
36
+ const apiKey = await this.readApiKey(profile.secretId);
36
37
  if (!apiKey) {
37
38
  emitDiagnostic(logger, {
38
39
  level: "error",
@@ -72,7 +73,7 @@ export class LlmManager extends AbstractManager {
72
73
  });
73
74
  throw new Error("Unsupported LLM provider.");
74
75
  }
75
- const apiKey = explicitApiKey?.trim() || this.readApiKey(profile.secretId);
76
+ const apiKey = explicitApiKey?.trim() || (await this.readApiKey(profile.secretId));
76
77
  if (!apiKey) {
77
78
  emitDiagnostic(logger, {
78
79
  level: "error",
@@ -107,12 +108,29 @@ export class LlmManager extends AbstractManager {
107
108
  outputPreview: result.outputPreview
108
109
  };
109
110
  }
111
+ async runTextPrompt(profile, input, logger) {
112
+ const provider = this.resolveProvider(profile.provider);
113
+ if (!provider?.runText) {
114
+ throw new Error("This LLM provider does not support text prompt execution.");
115
+ }
116
+ const apiKey = input.explicitApiKey?.trim() || (await this.readApiKey(profile.secretId));
117
+ if (!apiKey) {
118
+ throw new Error("Missing provider credential for prompt execution.");
119
+ }
120
+ return await provider.runText({
121
+ apiKey,
122
+ profile,
123
+ systemPrompt: input.systemPrompt,
124
+ prompt: input.prompt,
125
+ logger
126
+ });
127
+ }
110
128
  resolveProvider(providerName) {
111
129
  return (this.providers.get(providerName) ??
112
130
  this.providers.get("openai-responses") ??
113
131
  null);
114
132
  }
115
- readApiKey(secretId) {
133
+ async readApiKey(secretId) {
116
134
  if (!secretId) {
117
135
  return null;
118
136
  }
@@ -121,6 +139,24 @@ export class LlmManager extends AbstractManager {
121
139
  return null;
122
140
  }
123
141
  const payload = this.secretsManager.openJson(cipherText);
142
+ if (payload.kind === "oauth" &&
143
+ payload.provider === "openai-codex" &&
144
+ typeof payload.refresh === "string") {
145
+ let access = payload.access?.trim() || null;
146
+ const expires = typeof payload.expires === "number" ? payload.expires : Date.now();
147
+ if (!access || expires <= Date.now() + 60_000) {
148
+ const refreshed = await refreshOpenAICodexToken(payload.refresh);
149
+ const nextPayload = {
150
+ ...payload,
151
+ access: refreshed.access,
152
+ refresh: refreshed.refresh,
153
+ expires: refreshed.expires
154
+ };
155
+ storeEncryptedSecret(secretId, this.secretsManager.sealJson(nextPayload), "Refreshed OpenAI Codex OAuth credential");
156
+ access = refreshed.access;
157
+ }
158
+ return access;
159
+ }
124
160
  return payload.apiKey?.trim() || null;
125
161
  }
126
162
  }