stagent 0.9.5 → 0.10.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.
- package/README.md +5 -42
- package/dist/cli.js +42 -18
- package/docs/.coverage-gaps.json +13 -55
- package/docs/.last-generated +1 -1
- package/docs/features/provider-runtimes.md +4 -0
- package/docs/features/schedules.md +32 -4
- package/docs/features/settings.md +28 -5
- package/docs/features/tables.md +9 -2
- package/docs/features/workflows.md +10 -4
- package/docs/journeys/developer.md +15 -1
- package/docs/journeys/personal-use.md +21 -4
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
- package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
- package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
- package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
- package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
- package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
- package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
- package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
- package/package.json +3 -2
- package/src/__tests__/instrumentation-smoke.test.ts +15 -0
- package/src/app/analytics/page.tsx +1 -21
- package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
- package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
- package/src/app/api/instance/config/route.ts +41 -0
- package/src/app/api/instance/init/route.ts +34 -0
- package/src/app/api/instance/upgrade/check/route.ts +26 -0
- package/src/app/api/instance/upgrade/route.ts +96 -0
- package/src/app/api/instance/upgrade/status/route.ts +35 -0
- package/src/app/api/memory/route.ts +0 -11
- package/src/app/api/notifications/route.ts +4 -2
- package/src/app/api/projects/[id]/route.ts +5 -155
- package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
- package/src/app/api/schedules/[id]/execute/route.ts +111 -0
- package/src/app/api/schedules/[id]/route.ts +9 -1
- package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
- package/src/app/api/schedules/route.ts +3 -12
- package/src/app/api/settings/openai/login/route.ts +22 -0
- package/src/app/api/settings/openai/logout/route.ts +7 -0
- package/src/app/api/settings/openai/route.ts +21 -1
- package/src/app/api/settings/providers/route.ts +35 -8
- package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
- package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
- package/src/app/api/tables/[id]/enrich/route.ts +147 -0
- package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
- package/src/app/api/tasks/[id]/execute/route.ts +0 -21
- package/src/app/api/workflows/[id]/resume/route.ts +59 -0
- package/src/app/api/workflows/[id]/status/route.ts +22 -8
- package/src/app/api/workspace/context/route.ts +2 -0
- package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
- package/src/app/chat/page.tsx +11 -0
- package/src/app/inbox/page.tsx +12 -5
- package/src/app/layout.tsx +42 -21
- package/src/app/page.tsx +0 -2
- package/src/app/settings/page.tsx +6 -9
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
- package/src/components/chat/chat-command-popover.tsx +2 -2
- package/src/components/chat/chat-input.tsx +2 -3
- package/src/components/chat/chat-session-provider.tsx +720 -0
- package/src/components/chat/chat-shell.tsx +92 -401
- package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
- package/src/components/instance/instance-section.tsx +382 -0
- package/src/components/instance/upgrade-badge.tsx +219 -0
- package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
- package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
- package/src/components/notifications/batch-proposal-review.tsx +20 -5
- package/src/components/notifications/inbox-list.tsx +11 -2
- package/src/components/notifications/notification-item.tsx +56 -2
- package/src/components/notifications/pending-approval-host.tsx +56 -37
- package/src/components/schedules/schedule-create-sheet.tsx +19 -1
- package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
- package/src/components/schedules/schedule-form.tsx +31 -0
- package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
- package/src/components/settings/auth-method-selector.tsx +19 -4
- package/src/components/settings/auth-status-badge.tsx +28 -3
- package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
- package/src/components/settings/openai-runtime-section.tsx +7 -1
- package/src/components/settings/providers-runtimes-section.tsx +138 -19
- package/src/components/shared/app-sidebar.tsx +4 -3
- package/src/components/shared/command-palette.tsx +4 -5
- package/src/components/shared/theme-toggle.tsx +5 -24
- package/src/components/shared/workspace-indicator.tsx +61 -2
- package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
- package/src/components/tables/table-create-sheet.tsx +4 -0
- package/src/components/tables/table-enrichment-runs.tsx +103 -0
- package/src/components/tables/table-enrichment-sheet.tsx +538 -0
- package/src/components/tables/table-spreadsheet.tsx +29 -5
- package/src/components/tables/table-toolbar.tsx +10 -1
- package/src/components/tasks/kanban-board.tsx +1 -0
- package/src/components/tasks/kanban-column.tsx +53 -14
- package/src/components/tasks/task-bento-grid.tsx +19 -0
- package/src/components/tasks/task-card.tsx +26 -3
- package/src/components/tasks/task-chip-bar.tsx +24 -0
- package/src/components/tasks/task-result-renderer.tsx +1 -1
- package/src/components/workflows/delay-step-body.tsx +109 -0
- package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
- package/src/components/workflows/loop-status-view.tsx +1 -1
- package/src/components/workflows/shared/step-result.tsx +78 -0
- package/src/components/workflows/shared/workflow-header.tsx +141 -0
- package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
- package/src/components/workflows/swarm-dashboard.tsx +2 -15
- package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
- package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
- package/src/components/workflows/workflow-form-view.tsx +133 -16
- package/src/components/workflows/workflow-status-view.tsx +30 -740
- package/src/instrumentation-node.ts +94 -0
- package/src/instrumentation.ts +4 -48
- package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
- package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
- package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
- package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
- package/src/lib/agents/claude-agent.ts +155 -18
- package/src/lib/agents/execution-manager.ts +0 -35
- package/src/lib/agents/learned-context.ts +0 -12
- package/src/lib/agents/learning-session.ts +18 -5
- package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
- package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
- package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
- package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
- package/src/lib/agents/runtime/openai-codex.ts +29 -60
- package/src/lib/agents/runtime/types.ts +8 -0
- package/src/lib/book/chapter-mapping.ts +11 -0
- package/src/lib/book/content.ts +10 -0
- package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
- package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
- package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
- package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
- package/src/lib/chat/active-streams.ts +27 -0
- package/src/lib/chat/codex-engine.ts +16 -17
- package/src/lib/chat/context-builder.ts +5 -3
- package/src/lib/chat/engine.ts +50 -3
- package/src/lib/chat/reconcile.ts +117 -0
- package/src/lib/chat/stagent-tools.ts +1 -0
- package/src/lib/chat/stream-telemetry.ts +132 -0
- package/src/lib/chat/suggested-prompts.ts +28 -1
- package/src/lib/chat/system-prompt.ts +26 -1
- package/src/lib/chat/tool-catalog.ts +2 -1
- package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
- package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
- package/src/lib/chat/tools/document-tools.ts +29 -13
- package/src/lib/chat/tools/helpers.ts +39 -0
- package/src/lib/chat/tools/notification-tools.ts +9 -5
- package/src/lib/chat/tools/project-tools.ts +33 -0
- package/src/lib/chat/tools/schedule-tools.ts +44 -11
- package/src/lib/chat/tools/table-tools.ts +71 -0
- package/src/lib/chat/tools/task-tools.ts +84 -20
- package/src/lib/chat/tools/workflow-tools.ts +234 -32
- package/src/lib/constants/settings.ts +8 -18
- package/src/lib/data/__tests__/clear.test.ts +56 -2
- package/src/lib/data/clear.ts +20 -15
- package/src/lib/data/delete-project.ts +171 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
- package/src/lib/db/bootstrap.ts +45 -16
- package/src/lib/db/index.ts +5 -0
- package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
- package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
- package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
- package/src/lib/db/migrations/0026_drop_license.sql +3 -0
- package/src/lib/db/migrations/meta/_journal.json +21 -0
- package/src/lib/db/schema.ts +68 -23
- package/src/lib/environment/workspace-context.ts +13 -1
- package/src/lib/import/dedup.ts +4 -54
- package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
- package/src/lib/instance/__tests__/detect.test.ts +115 -0
- package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
- package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
- package/src/lib/instance/__tests__/settings.test.ts +83 -0
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
- package/src/lib/instance/bootstrap.ts +270 -0
- package/src/lib/instance/detect.ts +49 -0
- package/src/lib/instance/fingerprint.ts +78 -0
- package/src/lib/instance/git-ops.ts +95 -0
- package/src/lib/instance/settings.ts +61 -0
- package/src/lib/instance/types.ts +77 -0
- package/src/lib/instance/upgrade-poller.ts +153 -0
- package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
- package/src/lib/notifications/visibility.ts +33 -0
- package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
- package/src/lib/schedules/__tests__/config.test.ts +62 -0
- package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
- package/src/lib/schedules/__tests__/integration.test.ts +82 -0
- package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
- package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
- package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
- package/src/lib/schedules/collision-check.ts +105 -0
- package/src/lib/schedules/config.ts +53 -0
- package/src/lib/schedules/scheduler.ts +232 -13
- package/src/lib/schedules/slot-claim.ts +105 -0
- package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
- package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
- package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
- package/src/lib/settings/openai-auth.ts +105 -10
- package/src/lib/settings/openai-login-manager.ts +260 -0
- package/src/lib/settings/runtime-setup.ts +14 -4
- package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
- package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
- package/src/lib/tables/enrichment-planner.ts +454 -0
- package/src/lib/tables/enrichment.ts +328 -0
- package/src/lib/tables/query-builder.ts +5 -2
- package/src/lib/tables/trigger-evaluator.ts +3 -2
- package/src/lib/theme.ts +71 -0
- package/src/lib/usage/ledger.ts +2 -18
- package/src/lib/util/__tests__/similarity.test.ts +106 -0
- package/src/lib/util/similarity.ts +77 -0
- package/src/lib/utils/format-timestamp.ts +24 -0
- package/src/lib/utils/stagent-paths.ts +12 -0
- package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
- package/src/lib/validators/__tests__/settings.test.ts +10 -0
- package/src/lib/validators/blueprint.ts +70 -9
- package/src/lib/validators/profile.ts +2 -2
- package/src/lib/validators/settings.ts +3 -1
- package/src/lib/workflows/__tests__/delay.test.ts +196 -0
- package/src/lib/workflows/__tests__/engine.test.ts +8 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
- package/src/lib/workflows/blueprints/instantiator.ts +22 -1
- package/src/lib/workflows/blueprints/types.ts +10 -2
- package/src/lib/workflows/delay.ts +106 -0
- package/src/lib/workflows/engine.ts +207 -4
- package/src/lib/workflows/loop-executor.ts +349 -24
- package/src/lib/workflows/post-action.ts +91 -0
- package/src/lib/workflows/types.ts +166 -1
- package/src/app/api/license/checkout/route.ts +0 -28
- package/src/app/api/license/portal/route.ts +0 -26
- package/src/app/api/license/route.ts +0 -89
- package/src/app/api/license/usage/route.ts +0 -63
- package/src/app/api/marketplace/browse/route.ts +0 -15
- package/src/app/api/marketplace/import/route.ts +0 -28
- package/src/app/api/marketplace/publish/route.ts +0 -40
- package/src/app/api/onboarding/email/route.ts +0 -53
- package/src/app/api/settings/telemetry/route.ts +0 -14
- package/src/app/api/sync/export/route.ts +0 -54
- package/src/app/api/sync/restore/route.ts +0 -37
- package/src/app/api/sync/sessions/route.ts +0 -24
- package/src/app/auth/callback/route.ts +0 -73
- package/src/app/marketplace/page.tsx +0 -19
- package/src/components/analytics/analytics-gate-card.tsx +0 -101
- package/src/components/marketplace/blueprint-card.tsx +0 -61
- package/src/components/marketplace/marketplace-browser.tsx +0 -131
- package/src/components/onboarding/email-capture-card.tsx +0 -104
- package/src/components/settings/activation-form.tsx +0 -95
- package/src/components/settings/cloud-account-section.tsx +0 -147
- package/src/components/settings/cloud-sync-section.tsx +0 -155
- package/src/components/settings/subscription-section.tsx +0 -410
- package/src/components/settings/telemetry-section.tsx +0 -80
- package/src/components/shared/premium-gate-overlay.tsx +0 -50
- package/src/components/shared/schedule-gate-dialog.tsx +0 -64
- package/src/components/shared/upgrade-banner.tsx +0 -112
- package/src/hooks/use-supabase-auth.ts +0 -79
- package/src/lib/billing/email.ts +0 -54
- package/src/lib/billing/products.ts +0 -80
- package/src/lib/billing/stripe.ts +0 -101
- package/src/lib/cloud/supabase-browser.ts +0 -32
- package/src/lib/cloud/supabase-client.ts +0 -56
- package/src/lib/license/__tests__/features.test.ts +0 -56
- package/src/lib/license/__tests__/key-format.test.ts +0 -88
- package/src/lib/license/__tests__/manager.test.ts +0 -64
- package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
- package/src/lib/license/cloud-validation.ts +0 -60
- package/src/lib/license/features.ts +0 -44
- package/src/lib/license/key-format.ts +0 -101
- package/src/lib/license/limit-check.ts +0 -111
- package/src/lib/license/limit-queries.ts +0 -51
- package/src/lib/license/manager.ts +0 -345
- package/src/lib/license/notifications.ts +0 -59
- package/src/lib/license/tier-limits.ts +0 -71
- package/src/lib/marketplace/marketplace-client.ts +0 -107
- package/src/lib/sync/cloud-sync.ts +0 -235
- package/src/lib/telemetry/conversion-events.ts +0 -71
- package/src/lib/telemetry/queue.ts +0 -122
- package/src/lib/validators/license.ts +0 -33
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table enrichment orchestration.
|
|
3
|
+
*
|
|
4
|
+
* V1 shipped a single-step loop primitive. V2 keeps the row fan-out model but
|
|
5
|
+
* promotes planning, typed contracts, and richer metadata so the same plan can
|
|
6
|
+
* drive preview, launch, runtime validation, and recent-run UX.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { db } from "@/lib/db";
|
|
10
|
+
import { workflows } from "@/lib/db/schema";
|
|
11
|
+
import { desc, eq } from "drizzle-orm";
|
|
12
|
+
import { listRows, getTable } from "@/lib/data/tables";
|
|
13
|
+
import { executeWorkflow } from "@/lib/workflows/engine";
|
|
14
|
+
import type { WorkflowDefinition } from "@/lib/workflows/types";
|
|
15
|
+
import type { ColumnDef, FilterSpec } from "@/lib/tables/types";
|
|
16
|
+
import {
|
|
17
|
+
assertEnrichmentCompatibleColumn,
|
|
18
|
+
buildEnrichmentPlan,
|
|
19
|
+
type EnrichmentPlan,
|
|
20
|
+
type EnrichmentPromptMode,
|
|
21
|
+
type EnrichmentRow,
|
|
22
|
+
validateEnrichmentPlan,
|
|
23
|
+
wrapPromptWithOutputContract,
|
|
24
|
+
} from "@/lib/tables/enrichment-planner";
|
|
25
|
+
|
|
26
|
+
export type { EnrichmentPlan, EnrichmentPromptMode, EnrichmentRow };
|
|
27
|
+
export { wrapPromptWithOutputContract };
|
|
28
|
+
|
|
29
|
+
export interface GenerateEnrichmentInput {
|
|
30
|
+
rows: EnrichmentRow[];
|
|
31
|
+
tableId: string;
|
|
32
|
+
tableName?: string;
|
|
33
|
+
targetColumn: ColumnDef | string;
|
|
34
|
+
plan?: EnrichmentPlan;
|
|
35
|
+
prompt?: string;
|
|
36
|
+
agentProfile?: string;
|
|
37
|
+
itemVariable?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build a row-driven loop workflow definition. Each plan step becomes one
|
|
42
|
+
* inner step inside the row iteration. Only the final step carries the
|
|
43
|
+
* writeback postAction.
|
|
44
|
+
*/
|
|
45
|
+
export function generateEnrichmentDefinition(
|
|
46
|
+
input: GenerateEnrichmentInput
|
|
47
|
+
): WorkflowDefinition {
|
|
48
|
+
const targetColumn =
|
|
49
|
+
typeof input.targetColumn === "string"
|
|
50
|
+
? {
|
|
51
|
+
name: input.targetColumn,
|
|
52
|
+
displayName: input.targetColumn,
|
|
53
|
+
dataType: "text" as const,
|
|
54
|
+
position: 0,
|
|
55
|
+
}
|
|
56
|
+
: input.targetColumn;
|
|
57
|
+
const plan =
|
|
58
|
+
input.plan ??
|
|
59
|
+
buildEnrichmentPlan({
|
|
60
|
+
targetColumn,
|
|
61
|
+
sampleRows: input.rows,
|
|
62
|
+
eligibleRowCount: input.rows.length,
|
|
63
|
+
promptMode: "custom",
|
|
64
|
+
prompt: input.prompt ?? "",
|
|
65
|
+
agentProfileOverride: input.agentProfile,
|
|
66
|
+
});
|
|
67
|
+
const itemVariable =
|
|
68
|
+
input.itemVariable && input.itemVariable.length > 0
|
|
69
|
+
? input.itemVariable
|
|
70
|
+
: "row";
|
|
71
|
+
|
|
72
|
+
const items = input.rows.map((row) => ({
|
|
73
|
+
id: row.id,
|
|
74
|
+
...row.data,
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
pattern: "loop",
|
|
79
|
+
steps: plan.steps.map((step, index) => ({
|
|
80
|
+
id: step.id,
|
|
81
|
+
name: step.name,
|
|
82
|
+
prompt: step.prompt,
|
|
83
|
+
agentProfile: step.agentProfile ?? plan.agentProfile,
|
|
84
|
+
postAction:
|
|
85
|
+
index === plan.steps.length - 1
|
|
86
|
+
? {
|
|
87
|
+
type: "update_row" as const,
|
|
88
|
+
tableId: input.tableId,
|
|
89
|
+
rowId: `{{${itemVariable}.id}}`,
|
|
90
|
+
column: targetColumn.name,
|
|
91
|
+
}
|
|
92
|
+
: undefined,
|
|
93
|
+
})),
|
|
94
|
+
loopConfig: {
|
|
95
|
+
maxIterations: items.length,
|
|
96
|
+
items,
|
|
97
|
+
itemVariable,
|
|
98
|
+
},
|
|
99
|
+
metadata: {
|
|
100
|
+
enrichment: {
|
|
101
|
+
tableId: input.tableId,
|
|
102
|
+
tableName: input.tableName ?? input.tableId,
|
|
103
|
+
targetColumn: targetColumn.name,
|
|
104
|
+
targetColumnLabel: targetColumn.displayName,
|
|
105
|
+
promptMode: plan.promptMode,
|
|
106
|
+
strategy: plan.strategy,
|
|
107
|
+
agentProfile: plan.agentProfile,
|
|
108
|
+
eligibleRowCount: items.length,
|
|
109
|
+
targetContract: plan.targetContract,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Idempotent skip: drop rows whose target column already has a non-empty,
|
|
117
|
+
* non-whitespace value. Missing keys, null, "", and whitespace-only all
|
|
118
|
+
* count as "not yet populated".
|
|
119
|
+
*/
|
|
120
|
+
export function filterUnpopulatedRows(
|
|
121
|
+
rows: EnrichmentRow[],
|
|
122
|
+
targetColumn: string
|
|
123
|
+
): EnrichmentRow[] {
|
|
124
|
+
return rows.filter((row) => {
|
|
125
|
+
const value = row.data[targetColumn];
|
|
126
|
+
if (value === undefined || value === null) return true;
|
|
127
|
+
if (typeof value !== "string") return false;
|
|
128
|
+
return value.trim() === "";
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const DEFAULT_BATCH_SIZE = 50;
|
|
133
|
+
const MAX_BATCH_SIZE = 200;
|
|
134
|
+
|
|
135
|
+
interface EnrichmentPlanningParams {
|
|
136
|
+
targetColumn: string;
|
|
137
|
+
filter?: FilterSpec;
|
|
138
|
+
promptMode?: EnrichmentPromptMode;
|
|
139
|
+
prompt?: string;
|
|
140
|
+
agentProfile?: string;
|
|
141
|
+
agentProfileOverride?: string;
|
|
142
|
+
batchSize?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface PreviewEnrichmentPlanParams extends EnrichmentPlanningParams {}
|
|
146
|
+
|
|
147
|
+
export interface CreateEnrichmentWorkflowParams extends EnrichmentPlanningParams {
|
|
148
|
+
projectId?: string | null;
|
|
149
|
+
itemVariable?: string;
|
|
150
|
+
workflowName?: string;
|
|
151
|
+
plan?: EnrichmentPlan;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface CreateEnrichmentWorkflowResult {
|
|
155
|
+
workflowId: string;
|
|
156
|
+
rowCount: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface EnrichmentRunSummary {
|
|
160
|
+
workflowId: string;
|
|
161
|
+
name: string;
|
|
162
|
+
status: string;
|
|
163
|
+
updatedAt: string;
|
|
164
|
+
targetColumn: string;
|
|
165
|
+
targetColumnLabel: string;
|
|
166
|
+
rowCount: number;
|
|
167
|
+
strategy: EnrichmentPlan["strategy"];
|
|
168
|
+
promptMode: EnrichmentPromptMode;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function previewEnrichmentPlan(
|
|
172
|
+
tableId: string,
|
|
173
|
+
params: PreviewEnrichmentPlanParams
|
|
174
|
+
): Promise<EnrichmentPlan> {
|
|
175
|
+
const prepared = await prepareEnrichment(tableId, params);
|
|
176
|
+
return buildEnrichmentPlan({
|
|
177
|
+
targetColumn: prepared.targetColumn,
|
|
178
|
+
sampleRows: prepared.eligibleRows,
|
|
179
|
+
eligibleRowCount: prepared.eligibleRows.length,
|
|
180
|
+
promptMode: resolvePromptMode(params.promptMode, params.prompt),
|
|
181
|
+
prompt: params.prompt,
|
|
182
|
+
agentProfileOverride: params.agentProfileOverride ?? params.agentProfile,
|
|
183
|
+
filter: params.filter,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export async function createEnrichmentWorkflow(
|
|
188
|
+
tableId: string,
|
|
189
|
+
params: CreateEnrichmentWorkflowParams
|
|
190
|
+
): Promise<CreateEnrichmentWorkflowResult> {
|
|
191
|
+
const prepared = await prepareEnrichment(tableId, params);
|
|
192
|
+
|
|
193
|
+
const plan =
|
|
194
|
+
params.plan ??
|
|
195
|
+
buildEnrichmentPlan({
|
|
196
|
+
targetColumn: prepared.targetColumn,
|
|
197
|
+
sampleRows: prepared.eligibleRows,
|
|
198
|
+
eligibleRowCount: prepared.eligibleRows.length,
|
|
199
|
+
promptMode: resolvePromptMode(params.promptMode, params.prompt),
|
|
200
|
+
prompt: params.prompt,
|
|
201
|
+
agentProfileOverride: params.agentProfileOverride ?? params.agentProfile,
|
|
202
|
+
filter: params.filter,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
validateEnrichmentPlan(plan, prepared.targetColumn);
|
|
206
|
+
|
|
207
|
+
const definition = generateEnrichmentDefinition({
|
|
208
|
+
rows: prepared.eligibleRows,
|
|
209
|
+
tableId,
|
|
210
|
+
tableName: prepared.table.name,
|
|
211
|
+
targetColumn: prepared.targetColumn,
|
|
212
|
+
plan,
|
|
213
|
+
itemVariable: params.itemVariable,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const workflowId = crypto.randomUUID();
|
|
217
|
+
const now = new Date();
|
|
218
|
+
const name =
|
|
219
|
+
params.workflowName?.trim() ||
|
|
220
|
+
`Enrich ${prepared.table.name} · ${prepared.targetColumn.displayName}`;
|
|
221
|
+
|
|
222
|
+
await db.insert(workflows).values({
|
|
223
|
+
id: workflowId,
|
|
224
|
+
name,
|
|
225
|
+
projectId: params.projectId ?? prepared.table.projectId ?? null,
|
|
226
|
+
definition: JSON.stringify(definition),
|
|
227
|
+
status: "draft",
|
|
228
|
+
createdAt: now,
|
|
229
|
+
updatedAt: now,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
executeWorkflow(workflowId).catch((err) => {
|
|
233
|
+
console.error(`[enrichment] executeWorkflow failed for ${workflowId}:`, err);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await db.select().from(workflows).where(eq(workflows.id, workflowId));
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
workflowId,
|
|
240
|
+
rowCount: prepared.eligibleRows.length,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function listRecentEnrichmentRuns(
|
|
245
|
+
tableId: string,
|
|
246
|
+
limit: number = 5
|
|
247
|
+
): Promise<EnrichmentRunSummary[]> {
|
|
248
|
+
const rows = await db
|
|
249
|
+
.select()
|
|
250
|
+
.from(workflows)
|
|
251
|
+
.orderBy(desc(workflows.updatedAt));
|
|
252
|
+
|
|
253
|
+
const runs = rows
|
|
254
|
+
.map((workflow): EnrichmentRunSummary | null => {
|
|
255
|
+
try {
|
|
256
|
+
const definition = JSON.parse(workflow.definition) as WorkflowDefinition;
|
|
257
|
+
const meta = definition.metadata?.enrichment;
|
|
258
|
+
if (!meta || meta.tableId !== tableId) return null;
|
|
259
|
+
return {
|
|
260
|
+
workflowId: workflow.id,
|
|
261
|
+
name: workflow.name,
|
|
262
|
+
status: workflow.status,
|
|
263
|
+
updatedAt: workflow.updatedAt.toISOString(),
|
|
264
|
+
targetColumn: meta.targetColumn,
|
|
265
|
+
targetColumnLabel: meta.targetColumnLabel,
|
|
266
|
+
rowCount: meta.eligibleRowCount,
|
|
267
|
+
strategy: meta.strategy,
|
|
268
|
+
promptMode: meta.promptMode,
|
|
269
|
+
} satisfies EnrichmentRunSummary;
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return runs
|
|
276
|
+
.filter((run): run is EnrichmentRunSummary => run !== null)
|
|
277
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
278
|
+
.slice(0, limit);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function prepareEnrichment(
|
|
282
|
+
tableId: string,
|
|
283
|
+
params: EnrichmentPlanningParams
|
|
284
|
+
): Promise<{
|
|
285
|
+
table: NonNullable<Awaited<ReturnType<typeof getTable>>>;
|
|
286
|
+
targetColumn: ColumnDef;
|
|
287
|
+
eligibleRows: EnrichmentRow[];
|
|
288
|
+
}> {
|
|
289
|
+
const table = await getTable(tableId);
|
|
290
|
+
if (!table) {
|
|
291
|
+
throw new Error(`Table ${tableId} not found`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const columnSchema = JSON.parse(table.columnSchema) as ColumnDef[];
|
|
295
|
+
const targetColumn = columnSchema.find((column) => column.name === params.targetColumn);
|
|
296
|
+
if (!targetColumn) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`Column "${params.targetColumn}" does not exist on table ${tableId}`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
assertEnrichmentCompatibleColumn(targetColumn);
|
|
302
|
+
|
|
303
|
+
const batchSize = Math.min(params.batchSize ?? DEFAULT_BATCH_SIZE, MAX_BATCH_SIZE);
|
|
304
|
+
const rawRows = await listRows(tableId, {
|
|
305
|
+
filters: params.filter ? [params.filter] : undefined,
|
|
306
|
+
limit: batchSize,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const rows: EnrichmentRow[] = rawRows.map((row) => ({
|
|
310
|
+
id: row.id,
|
|
311
|
+
data: JSON.parse(row.data) as Record<string, unknown>,
|
|
312
|
+
}));
|
|
313
|
+
|
|
314
|
+
const eligibleRows = filterUnpopulatedRows(rows, targetColumn.name);
|
|
315
|
+
return {
|
|
316
|
+
table,
|
|
317
|
+
targetColumn,
|
|
318
|
+
eligibleRows,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function resolvePromptMode(
|
|
323
|
+
promptMode: EnrichmentPromptMode | undefined,
|
|
324
|
+
prompt: string | undefined
|
|
325
|
+
): EnrichmentPromptMode {
|
|
326
|
+
if (promptMode) return promptMode;
|
|
327
|
+
return prompt?.trim() ? "custom" : "auto";
|
|
328
|
+
}
|
|
@@ -77,9 +77,12 @@ function buildFilterClause(
|
|
|
77
77
|
return sql`${col} IN (${sql.join(placeholders, sql`, `)})`;
|
|
78
78
|
}
|
|
79
79
|
case "is_empty":
|
|
80
|
-
|
|
80
|
+
// Treat whitespace-only as empty so the filter agrees with the
|
|
81
|
+
// server-side `filterUnpopulatedRows` / `shouldSkipPostActionValue`
|
|
82
|
+
// semantics used by bulk row enrichment.
|
|
83
|
+
return sql`(${col} IS NULL OR TRIM(${col}) = '')`;
|
|
81
84
|
case "is_not_empty":
|
|
82
|
-
return sql`(${col} IS NOT NULL AND ${col} != '')`;
|
|
85
|
+
return sql`(${col} IS NOT NULL AND TRIM(${col}) != '')`;
|
|
83
86
|
default:
|
|
84
87
|
throw new Error(`Unknown filter operator: ${filter.operator}`);
|
|
85
88
|
}
|
|
@@ -99,9 +99,10 @@ function matchesCondition(
|
|
|
99
99
|
case "in":
|
|
100
100
|
return Array.isArray(condition.value) && condition.value.includes(strValue);
|
|
101
101
|
case "is_empty":
|
|
102
|
-
|
|
102
|
+
// Whitespace-only counts as empty (matches SQL `is_empty` operator).
|
|
103
|
+
return value == null || strValue.trim() === "";
|
|
103
104
|
case "is_not_empty":
|
|
104
|
-
return value != null && strValue !== "";
|
|
105
|
+
return value != null && strValue.trim() !== "";
|
|
105
106
|
default:
|
|
106
107
|
return true;
|
|
107
108
|
}
|
package/src/lib/theme.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Shared theme utilities. Server reads the `stagent-theme` cookie and renders
|
|
2
|
+
// <html className="dark"> directly, so we no longer need a pre-hydration <script>
|
|
3
|
+
// bootstrap to prevent FOUC. Every client-side toggle must keep the cookie in
|
|
4
|
+
// sync so the next SSR matches.
|
|
5
|
+
//
|
|
6
|
+
// Previously a next/script <Script strategy="beforeInteractive"> injected the
|
|
7
|
+
// theme, but in Next.js 16 + React 19 any <script> element in the component
|
|
8
|
+
// tree fires a "script tag inside React component" dev warning.
|
|
9
|
+
|
|
10
|
+
export type ResolvedTheme = "light" | "dark";
|
|
11
|
+
|
|
12
|
+
export const THEME_COOKIE = "stagent-theme";
|
|
13
|
+
export const DEFAULT_THEME: ResolvedTheme = "light";
|
|
14
|
+
|
|
15
|
+
export function isResolvedTheme(value: unknown): value is ResolvedTheme {
|
|
16
|
+
return value === "light" || value === "dark";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Client-only: apply a theme everywhere it needs to land — DOM, localStorage,
|
|
21
|
+
* and cookie. Always use this instead of setting them individually so we can't
|
|
22
|
+
* drift between storage locations.
|
|
23
|
+
*/
|
|
24
|
+
export function applyTheme(theme: ResolvedTheme): void {
|
|
25
|
+
if (typeof document === "undefined") return;
|
|
26
|
+
const root = document.documentElement;
|
|
27
|
+
root.classList.toggle("dark", theme === "dark");
|
|
28
|
+
root.dataset.theme = theme;
|
|
29
|
+
root.style.colorScheme = theme;
|
|
30
|
+
root.style.backgroundColor =
|
|
31
|
+
theme === "dark" ? "oklch(0.14 0.02 250)" : "oklch(0.985 0.004 250)";
|
|
32
|
+
try {
|
|
33
|
+
localStorage.setItem(THEME_COOKIE, theme);
|
|
34
|
+
} catch {
|
|
35
|
+
/* storage may be unavailable (private mode, quota) */
|
|
36
|
+
}
|
|
37
|
+
document.cookie = `${THEME_COOKIE}=${theme};path=/;max-age=31536000;SameSite=Lax`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Client-only: flip the current theme and apply. Returns the new theme.
|
|
42
|
+
*/
|
|
43
|
+
export function toggleTheme(): ResolvedTheme {
|
|
44
|
+
const current: ResolvedTheme = document.documentElement.classList.contains(
|
|
45
|
+
"dark"
|
|
46
|
+
)
|
|
47
|
+
? "dark"
|
|
48
|
+
: "light";
|
|
49
|
+
const next: ResolvedTheme = current === "dark" ? "light" : "dark";
|
|
50
|
+
applyTheme(next);
|
|
51
|
+
return next;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Client-only: resolve the user's preferred theme from localStorage, falling
|
|
56
|
+
* back to the cookie, then the default. Used by the toggle button to seed its
|
|
57
|
+
* `dark` state on mount.
|
|
58
|
+
*/
|
|
59
|
+
export function readClientTheme(): ResolvedTheme {
|
|
60
|
+
if (typeof document === "undefined") return DEFAULT_THEME;
|
|
61
|
+
try {
|
|
62
|
+
const stored = localStorage.getItem(THEME_COOKIE);
|
|
63
|
+
if (isResolvedTheme(stored)) return stored;
|
|
64
|
+
} catch {
|
|
65
|
+
/* ignore */
|
|
66
|
+
}
|
|
67
|
+
const cookieMatch = document.cookie.match(/(?:^|;\s*)stagent-theme=([^;]+)/);
|
|
68
|
+
const cookieValue = cookieMatch?.[1];
|
|
69
|
+
if (isResolvedTheme(cookieValue)) return cookieValue;
|
|
70
|
+
return DEFAULT_THEME;
|
|
71
|
+
}
|
package/src/lib/usage/ledger.ts
CHANGED
|
@@ -19,7 +19,8 @@ export type UsageActivityType =
|
|
|
19
19
|
| "pattern_extraction"
|
|
20
20
|
| "context_summarization"
|
|
21
21
|
| "chat_turn"
|
|
22
|
-
| "profile_assist"
|
|
22
|
+
| "profile_assist"
|
|
23
|
+
| "manual_force_bypass";
|
|
23
24
|
|
|
24
25
|
export type UsageLedgerStatus =
|
|
25
26
|
| "completed"
|
|
@@ -249,23 +250,6 @@ export async function recordUsageLedgerEntry(input: UsageLedgerWriteInput) {
|
|
|
249
250
|
|
|
250
251
|
await db.insert(usageLedger).values(row);
|
|
251
252
|
|
|
252
|
-
// Queue telemetry event (opt-in, fire-and-forget)
|
|
253
|
-
try {
|
|
254
|
-
const { queueTelemetryEvent } = await import("@/lib/telemetry/queue");
|
|
255
|
-
queueTelemetryEvent({
|
|
256
|
-
runtimeId: input.runtimeId,
|
|
257
|
-
providerId: input.providerId,
|
|
258
|
-
modelId: input.modelId ?? "unknown",
|
|
259
|
-
activityType: input.activityType,
|
|
260
|
-
outcomeStatus: status,
|
|
261
|
-
tokenCount: normalizedTotalTokens ?? undefined,
|
|
262
|
-
costMicros: resolvedCostMicros ?? undefined,
|
|
263
|
-
durationMs: input.finishedAt.getTime() - input.startedAt.getTime(),
|
|
264
|
-
});
|
|
265
|
-
} catch {
|
|
266
|
-
// Telemetry is non-critical
|
|
267
|
-
}
|
|
268
|
-
|
|
269
253
|
return row;
|
|
270
254
|
}
|
|
271
255
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { extractKeywords, jaccard, tagOverlap, STOP_WORDS } from "../similarity";
|
|
3
|
+
|
|
4
|
+
describe("similarity.extractKeywords", () => {
|
|
5
|
+
it("returns an empty set for empty input", () => {
|
|
6
|
+
expect(extractKeywords("")).toEqual(new Set());
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("lowercases and strips punctuation", () => {
|
|
10
|
+
const result = extractKeywords("Research Customer Feedback!");
|
|
11
|
+
expect(result.has("research")).toBe(true);
|
|
12
|
+
expect(result.has("customer")).toBe(true);
|
|
13
|
+
expect(result.has("feedback")).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("excludes stop words", () => {
|
|
17
|
+
const result = extractKeywords("the research about customer and the feedback");
|
|
18
|
+
expect(result.has("the")).toBe(false);
|
|
19
|
+
expect(result.has("about")).toBe(false);
|
|
20
|
+
expect(result.has("research")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("filters tokens shorter than 4 chars", () => {
|
|
24
|
+
const result = extractKeywords("go do it now with research");
|
|
25
|
+
expect(result.has("go")).toBe(false);
|
|
26
|
+
expect(result.has("do")).toBe(false);
|
|
27
|
+
expect(result.has("it")).toBe(false);
|
|
28
|
+
expect(result.has("now")).toBe(false);
|
|
29
|
+
expect(result.has("research")).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("filters tokens longer than 29 chars", () => {
|
|
33
|
+
const longWord = "a".repeat(30);
|
|
34
|
+
const result = extractKeywords(`research ${longWord} customer`);
|
|
35
|
+
expect(result.has(longWord)).toBe(false);
|
|
36
|
+
expect(result.has("research")).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("respects the limit parameter", () => {
|
|
40
|
+
const text = "alpha bravo charlie delta echo foxtrot golf hotel india juliet";
|
|
41
|
+
const result = extractKeywords(text, 3);
|
|
42
|
+
expect(result.size).toBe(3);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("orders by frequency before applying the limit", () => {
|
|
46
|
+
// 'research' appears 3 times, 'customer' 2 times, 'feedback' 1 time.
|
|
47
|
+
const text = "research customer feedback research customer research";
|
|
48
|
+
const result = extractKeywords(text, 2);
|
|
49
|
+
expect(result.has("research")).toBe(true);
|
|
50
|
+
expect(result.has("customer")).toBe(true);
|
|
51
|
+
expect(result.has("feedback")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("preserves hyphenated tokens", () => {
|
|
55
|
+
const result = extractKeywords("multi-agent workflow");
|
|
56
|
+
expect(result.has("multi-agent")).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("similarity.jaccard", () => {
|
|
61
|
+
it("returns 0 for two empty sets", () => {
|
|
62
|
+
expect(jaccard(new Set(), new Set())).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns 0 for disjoint sets", () => {
|
|
66
|
+
expect(jaccard(new Set(["a", "b"]), new Set(["c", "d"]))).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns 1 for identical sets", () => {
|
|
70
|
+
expect(jaccard(new Set(["a", "b"]), new Set(["a", "b"]))).toBe(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("computes intersection / union correctly", () => {
|
|
74
|
+
// {a,b,c} vs {b,c,d} → intersection=2, union=4 → 0.5
|
|
75
|
+
expect(jaccard(new Set(["a", "b", "c"]), new Set(["b", "c", "d"]))).toBe(0.5);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns 0 when one set is empty and other is not", () => {
|
|
79
|
+
expect(jaccard(new Set(), new Set(["a"]))).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("similarity.tagOverlap", () => {
|
|
84
|
+
it("returns 0 when candidate has no tags", () => {
|
|
85
|
+
expect(tagOverlap([], ["a", "b"])).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("is case-insensitive", () => {
|
|
89
|
+
expect(tagOverlap(["Research"], ["research"])).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns fraction of candidate tags present in existing", () => {
|
|
93
|
+
expect(tagOverlap(["a", "b", "c"], ["a", "b", "z"])).toBeCloseTo(2 / 3);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns 1 when all candidate tags match", () => {
|
|
97
|
+
expect(tagOverlap(["a", "b"], ["a", "b", "c"])).toBe(1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("similarity.STOP_WORDS", () => {
|
|
102
|
+
it("exposes a non-empty stop word set", () => {
|
|
103
|
+
expect(STOP_WORDS.size).toBeGreaterThan(0);
|
|
104
|
+
expect(STOP_WORDS.has("the")).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared similarity utilities.
|
|
3
|
+
*
|
|
4
|
+
* Small, dependency-free helpers for fuzzy matching: keyword extraction,
|
|
5
|
+
* Jaccard similarity, and tag overlap. Used by the profile import dedup
|
|
6
|
+
* engine (`src/lib/import/dedup.ts`) and the chat workflow tool dedup
|
|
7
|
+
* check (`src/lib/chat/tools/workflow-tools.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Extracted into a shared module so both callers use the same keyword
|
|
10
|
+
* normalization and comparison math — if one grows, the other benefits.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Common stop words to exclude from keyword extraction. */
|
|
14
|
+
export const STOP_WORDS = new Set([
|
|
15
|
+
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
|
|
16
|
+
"her", "was", "one", "our", "out", "has", "have", "that", "this", "with",
|
|
17
|
+
"from", "they", "been", "will", "each", "make", "like", "into", "them",
|
|
18
|
+
"some", "when", "what", "your", "should", "would", "could", "about",
|
|
19
|
+
"which", "their", "other", "than", "then", "more", "also",
|
|
20
|
+
"only", "must", "does", "here", "just", "over", "such", "after",
|
|
21
|
+
"before", "between", "through", "where", "these", "those", "being",
|
|
22
|
+
"using", "ensure", "every", "following", "include",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract meaningful keywords from text.
|
|
27
|
+
*
|
|
28
|
+
* Lowercases, strips non-alphanumeric, filters out stop words and tokens
|
|
29
|
+
* outside a reasonable length window, then returns the top-N most frequent
|
|
30
|
+
* terms as a Set.
|
|
31
|
+
*/
|
|
32
|
+
export function extractKeywords(text: string, limit = 20): Set<string> {
|
|
33
|
+
const words = text
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replace(/[^a-z0-9\s-]/g, " ")
|
|
36
|
+
.split(/\s+/)
|
|
37
|
+
.filter((w) => w.length > 3 && w.length < 30 && !STOP_WORDS.has(w));
|
|
38
|
+
|
|
39
|
+
const freq = new Map<string, number>();
|
|
40
|
+
for (const word of words) {
|
|
41
|
+
freq.set(word, (freq.get(word) ?? 0) + 1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const sorted = Array.from(freq.entries())
|
|
45
|
+
.sort((a, b) => b[1] - a[1])
|
|
46
|
+
.slice(0, limit)
|
|
47
|
+
.map(([word]) => word);
|
|
48
|
+
|
|
49
|
+
return new Set(sorted);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Jaccard similarity between two sets — |A ∩ B| / |A ∪ B|.
|
|
54
|
+
*
|
|
55
|
+
* Returns 0 when both sets are empty (a reasonable default for "nothing to
|
|
56
|
+
* compare" rather than the mathematical indeterminate form).
|
|
57
|
+
*/
|
|
58
|
+
export function jaccard(a: Set<string>, b: Set<string>): number {
|
|
59
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
60
|
+
let intersection = 0;
|
|
61
|
+
for (const item of a) {
|
|
62
|
+
if (b.has(item)) intersection++;
|
|
63
|
+
}
|
|
64
|
+
const union = a.size + b.size - intersection;
|
|
65
|
+
return union === 0 ? 0 : intersection / union;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Tag overlap ratio — how many of the candidate's tags match the existing
|
|
70
|
+
* set, normalized by candidate size. Case-insensitive.
|
|
71
|
+
*/
|
|
72
|
+
export function tagOverlap(candidateTags: string[], existingTags: string[]): number {
|
|
73
|
+
if (candidateTags.length === 0) return 0;
|
|
74
|
+
const existingSet = new Set(existingTags.map((t) => t.toLowerCase()));
|
|
75
|
+
const matches = candidateTags.filter((t) => existingSet.has(t.toLowerCase()));
|
|
76
|
+
return matches.length / candidateTags.length;
|
|
77
|
+
}
|
|
@@ -44,3 +44,27 @@ export function formatTime(date: string | Date): string {
|
|
|
44
44
|
second: "2-digit",
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compact date-time for space-constrained surfaces (kanban cards, bento cells).
|
|
50
|
+
* Today: "14:23" | This week: "Mon 14:23" | This year: "Apr 12, 14:23" | Older: "Apr 12, 2025"
|
|
51
|
+
*/
|
|
52
|
+
export function formatCompactDateTime(date: string | Date): string {
|
|
53
|
+
const d = typeof date === "string" ? new Date(date) : date;
|
|
54
|
+
const now = new Date();
|
|
55
|
+
const diff = now.getTime() - d.getTime();
|
|
56
|
+
const time = d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
|
|
57
|
+
|
|
58
|
+
if (d.toDateString() === now.toDateString()) return time;
|
|
59
|
+
|
|
60
|
+
if (diff > 0 && diff < 7 * DAY) {
|
|
61
|
+
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
|
62
|
+
return `${weekday} ${time}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const monthDay = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
66
|
+
|
|
67
|
+
if (d.getFullYear() === now.getFullYear()) return `${monthDay}, ${time}`;
|
|
68
|
+
|
|
69
|
+
return `${monthDay}, ${d.getFullYear()}`;
|
|
70
|
+
}
|
|
@@ -40,3 +40,15 @@ export function getStagentLogsDir(): string {
|
|
|
40
40
|
export function getStagentDocumentsDir(): string {
|
|
41
41
|
return join(getStagentDataDir(), "documents");
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
export function getStagentCodexDir(): string {
|
|
45
|
+
return join(getStagentDataDir(), "codex");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getStagentCodexConfigPath(): string {
|
|
49
|
+
return join(getStagentCodexDir(), "config.toml");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function getStagentCodexAuthPath(): string {
|
|
53
|
+
return join(getStagentCodexDir(), "auth.json");
|
|
54
|
+
}
|