cue-ai 0.9.0 → 0.9.2
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/CHANGELOG.md +40 -0
- package/README.md +82 -33
- package/bin/cue-review-progress +107 -0
- package/bin/cue-review-watch +98 -0
- package/dist/cue.js +7352 -3744
- package/package.json +16 -5
- package/profiles/_types.ts +9 -0
- package/profiles/backend/profile.yaml +2 -0
- package/profiles/blog-writer/profile.yaml +10 -0
- package/profiles/browser/profile.yaml +9 -2
- package/profiles/builder/profile.yaml +3 -6
- package/profiles/career/profile.yaml +13 -2
- package/profiles/claude-api/profile.yaml +1 -1
- package/profiles/commerce/profile.yaml +27 -3
- package/profiles/core/logo.png +0 -0
- package/profiles/core/profile.yaml +62 -2
- package/profiles/dash-merge-test/profile.yaml +109 -0
- package/profiles/designer/profile.yaml +2 -0
- package/profiles/designer-medusa-next/profile.yaml +4 -1
- package/profiles/designer-medusa-vite/profile.yaml +4 -1
- package/profiles/docs-writer/profile.yaml +3 -1
- package/profiles/eu-tender-research/README.md +48 -0
- package/profiles/eu-tender-research/logo.png +0 -0
- package/profiles/eu-tender-research/profile.yaml +108 -0
- package/profiles/finance/logo.png +0 -0
- package/profiles/finance/profile.yaml +46 -0
- package/profiles/frontend/profile.yaml +5 -9
- package/profiles/growth/profile.yaml +2 -3
- package/profiles/gstack/profile.yaml +15 -0
- package/profiles/higgsfield/profile.yaml +3 -0
- package/profiles/hyperframes/logo.png +0 -0
- package/profiles/hyperframes/profile.yaml +59 -0
- package/profiles/improver/profile.yaml +88 -0
- package/profiles/marketing/profile.yaml +0 -3
- package/profiles/medusa-dev/profile.yaml +2 -0
- package/profiles/medusa-next/profile.yaml +2 -3
- package/profiles/medusa-vite/profile.yaml +2 -3
- package/profiles/n8n/logo.png +0 -0
- package/profiles/n8n/profile.yaml +50 -0
- package/profiles/nextjs/profile.yaml +2 -3
- package/profiles/ops/profile.yaml +2 -0
- package/profiles/postizz/profile.yaml +13 -3
- package/profiles/python/profile.yaml +3 -0
- package/profiles/research/profile.yaml +3 -1
- package/profiles/schema.json +10 -0
- package/profiles/secops/profile.yaml +2 -0
- package/profiles/seo/profile.yaml +56 -0
- package/profiles/skill-writer/profile.yaml +8 -0
- package/profiles/ssh/profile.yaml +32 -0
- package/profiles/strapi/logo.png +0 -0
- package/profiles/strapi/profile.yaml +45 -0
- package/profiles/stripe/logo.png +0 -0
- package/profiles/stripe/profile.yaml +1 -0
- package/profiles/supabase/logo.png +0 -0
- package/profiles/supabase/profile.yaml +85 -0
- package/profiles/vercel/logo.png +0 -0
- package/profiles/vercel/profile.yaml +25 -1
- package/profiles/vite/profile.yaml +4 -3
- package/profiles/web-frontend-base/profile.yaml +5 -4
- package/profiles/webshop/profile.yaml +23 -5
- package/profiles/x-growth-bot/profile.yaml +44 -0
- package/resources/icons/generate-icons.py +128 -2
- package/resources/mcps/configs/claude.sanitized.json +42 -0
- package/resources/mcps/configs/codex.sanitized.json +7 -0
- package/resources/skills/skills/career/resume-version-manager/SKILL.md +351 -0
- package/resources/skills/skills/career/salary-negotiation-prep/SKILL.md +378 -0
- package/resources/skills/skills/content/pdf/SKILL.md +2 -0
- package/resources/skills/skills/content/postiz-cards/SKILL.md +48 -0
- package/resources/skills/skills/content/postiz-cards/scripts/analytics.sh +38 -0
- package/resources/skills/skills/content/postiz-cards/scripts/card.sh +42 -0
- package/resources/skills/skills/content/postiz-cards/scripts/lint.py +38 -0
- package/resources/skills/skills/design/headless-gif-demo/SKILL.md +1 -1
- package/resources/skills/skills/design/readme-svg-design/SKILL.md +1 -1
- package/resources/skills/skills/eu-funding/grant-outreach/SKILL.md +70 -0
- package/resources/skills/skills/eu-funding/hu-grant-finder/SKILL.md +114 -0
- package/resources/skills/skills/eu-funding/hu-grant-finder/evals.md +26 -0
- package/resources/skills/skills/eu-funding/ted-tender-search/SKILL.md +80 -0
- package/resources/skills/skills/eu-funding/ted-tender-search/evals.md +26 -0
- package/resources/skills/skills/eu-funding/ted-tender-search/scripts/ted-search.sh +46 -0
- package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +1 -1
- package/resources/skills/skills/github/gx-agents/SKILL.md +96 -0
- package/resources/skills/skills/gstack/design-shotgun/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ab-test-analyzer/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ab-test-setup-and-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/account-structure-review/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ad-copy-variant-generator/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ad-extension-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/ad-spend-allocator/SKILL.md +1 -1
- package/resources/skills/skills/marketing/anomaly-detection/SKILL.md +1 -1
- package/resources/skills/skills/marketing/attribution-model-comparison/SKILL.md +1 -1
- package/resources/skills/skills/marketing/audience-overlap-analysis/SKILL.md +7 -1
- package/resources/skills/skills/marketing/bid-strategy-recommendations/SKILL.md +7 -1
- package/resources/skills/skills/marketing/budget-scenario-planner/SKILL.md +6 -1
- package/resources/skills/skills/marketing/campaign-naming-convention-builder/SKILL.md +7 -1
- package/resources/skills/skills/marketing/channel-mix-optimizer/SKILL.md +7 -1
- package/resources/skills/skills/marketing/client-report-narratives/SKILL.md +6 -1
- package/resources/skills/skills/marketing/competitor-creative-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/competitor-teardown/SKILL.md +1 -1
- package/resources/skills/skills/marketing/content-repurposer/SKILL.md +1 -1
- package/resources/skills/skills/marketing/conversion-path-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/cpa-diagnostics/SKILL.md +1 -1
- package/resources/skills/skills/marketing/creative-fatigue-detection/SKILL.md +1 -1
- package/resources/skills/skills/marketing/day-hour-performance-breakdown/SKILL.md +1 -1
- package/resources/skills/skills/marketing/device-performance-split/SKILL.md +1 -1
- package/resources/skills/skills/marketing/e2e-seo-assistant/SKILL.md +1 -1
- package/resources/skills/skills/marketing/email-sequence-writer/SKILL.md +1 -1
- package/resources/skills/skills/marketing/frequency-cap-recommendations/SKILL.md +1 -1
- package/resources/skills/skills/marketing/geo-performance-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/google-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/icp-research-assistant/SKILL.md +1 -1
- package/resources/skills/skills/marketing/keyword-cannibalization-check/SKILL.md +1 -1
- package/resources/skills/skills/marketing/landing-page-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/landing-page-audit-quick/SKILL.md +1 -1
- package/resources/skills/skills/marketing/linkedin-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/meta-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/pacing-monitor/SKILL.md +1 -1
- package/resources/skills/skills/marketing/performance-benchmarking/SKILL.md +1 -1
- package/resources/skills/skills/marketing/programmatic-seo-builder/SKILL.md +1 -1
- package/resources/skills/skills/marketing/quality-score-breakdown/SKILL.md +1 -1
- package/resources/skills/skills/marketing/reddit-ads-audit/SKILL.md +1 -1
- package/resources/skills/skills/marketing/retargeting-window-analysis/SKILL.md +1 -1
- package/resources/skills/skills/marketing/roas-forecasting/SKILL.md +1 -1
- package/resources/skills/skills/marketing/search-term-mining/SKILL.md +1 -1
- package/resources/skills/skills/marketing/utm-tracking-generator/SKILL.md +1 -1
- package/resources/skills/skills/marketing/wasted-spend-finder/SKILL.md +1 -1
- package/resources/skills/skills/marketing/weekly-account-summary/SKILL.md +1 -1
- package/resources/skills/skills/meta/awesome-list-submit/SKILL.md +4 -4
- package/resources/skills/skills/meta/cue-dashboard/SKILL.md +109 -0
- package/resources/skills/skills/meta/cue-developer/SKILL.md +161 -0
- package/resources/skills/skills/meta/cue-developer/evals/evals.json +57 -0
- package/resources/skills/skills/meta/cue-developer/references/architecture.md +65 -0
- package/resources/skills/skills/meta/cue-developer/references/build_and_test.md +72 -0
- package/resources/skills/skills/meta/cue-developer/references/contributing.md +75 -0
- package/resources/skills/skills/meta/cue-developer/references/conventions.md +57 -0
- package/resources/skills/skills/meta/cue-developer/references/first_time_setup.md +51 -0
- package/resources/skills/skills/meta/cue-developer/references/skill_and_mcp_authoring.md +84 -0
- package/resources/skills/skills/meta/cue-developer/references/troubleshooting.md +42 -0
- package/resources/skills/skills/meta/delegation-check/SKILL.md +148 -0
- package/resources/skills/skills/meta/delegation-check/specs/scan-algorithm.md +125 -0
- package/resources/skills/skills/meta/delegation-check/specs/separation-rules.md +190 -0
- package/resources/skills/skills/meta/focus/SKILL.md +62 -0
- package/resources/skills/skills/meta/help/SKILL.md +1 -1
- package/resources/skills/skills/meta/integrity-tags/SKILL.md +2 -0
- package/resources/skills/skills/meta/next-steps/SKILL.md +124 -0
- package/resources/skills/skills/meta/next-steps/evals/eval-set.json +92 -0
- package/resources/skills/skills/meta/profile-from-docs/SKILL.md +141 -0
- package/resources/skills/skills/meta/ralph-loop/SKILL.md +83 -0
- package/resources/skills/skills/meta/ralph-loop/scripts/loop.sh +73 -0
- package/resources/skills/skills/meta/skill-simplify/SKILL.md +136 -0
- package/resources/skills/skills/meta/skill-simplify/phases/01-analysis.md +173 -0
- package/resources/skills/skills/meta/skill-simplify/phases/02-optimize.md +104 -0
- package/resources/skills/skills/meta/skill-simplify/phases/03-check.md +145 -0
- package/resources/skills/skills/meta/smart-loader/scripts/smart-lookup.sh +13 -4
- package/resources/skills/skills/meta/verify-council/SKILL.md +182 -0
- package/resources/skills/skills/meta/verify-council/references/lane-prompts.md +103 -0
- package/resources/skills/skills/meta/verify-council/references/workflow.js +217 -0
- package/resources/skills/skills/nvidia/aiq-research/SKILL.md +1 -1
- package/resources/skills/skills/nvidia/cuopt-developer/SKILL.md +16 -1
- package/resources/skills/skills/nvidia/cuopt-developer/resources/contributing.md +2 -2
- package/resources/skills/skills/nvidia/cuopt-developer/resources/numerical_debugging.md +128 -0
- package/resources/skills/skills/nvidia/cuopt-developer/resources/python_bindings.md +2 -9
- package/resources/skills/skills/nvidia/cuopt-developer/resources/vrp_skills.md +166 -0
- package/resources/skills/skills/nvidia/cuopt-install/SKILL.md +2 -10
- package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/SKILL.md +3 -23
- package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/resources/examples.md +40 -20
- package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-python/SKILL.md +5 -1
- package/resources/skills/skills/nvidia/skill-evolution/SKILL.md +4 -5
- package/resources/skills/skills/research/trendradar/SKILL.md +1 -1
- package/resources/skills/skills/ssh/ssh-config/SKILL.md +94 -0
- package/resources/skills/skills/ssh/ssh-copy/SKILL.md +92 -0
- package/resources/skills/skills/ssh/ssh-harden/SKILL.md +108 -0
- package/resources/skills/skills/ssh/ssh-keys/SKILL.md +82 -0
- package/resources/skills/skills/ssh/ssh-paste-image/LICENSE +28 -0
- package/resources/skills/skills/ssh/ssh-paste-image/SKILL.md +149 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/build.sh +29 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/go.mod +3 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/main.go +79 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/ccimgd.service +12 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/com.ccimgd.plist +20 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/go.mod +3 -0
- package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/main.go +98 -0
- package/resources/skills/skills/ssh/ssh-tunnel/SKILL.md +96 -0
- package/resources/skills/skills/strapi/building-with-strapi/SKILL.md +112 -0
- package/resources/skills/skills/strapi/strapi-cli/SKILL.md +93 -0
- package/resources/skills/skills/strapi/strapi-content-api/SKILL.md +115 -0
- package/resources/skills/skills/strapi/strapi-deploy/SKILL.md +89 -0
- package/resources/skills/skills/strapi/strapi-mcp-setup/SKILL.md +101 -0
- package/resources/skills/skills/strapi/strapi-plugins/SKILL.md +97 -0
- package/resources/skills/skills/tools/context7/SKILL.md +101 -0
- package/resources/skills/skills/tools/opensrc/SKILL.md +1 -1
- package/resources/skills/skills/tools/portless/SKILL.md +186 -0
- package/resources/skills/skills/xbot/operate/SKILL.md +229 -0
- package/src/commands/_index.ts +8 -0
- package/src/commands/ai-score.e2e.test.ts +11 -4
- package/src/commands/ai.ts +3 -4
- package/src/commands/auto-detect.ts +1 -1
- package/src/commands/cli.test.ts +1 -2
- package/src/commands/cli.ts +1 -1
- package/src/commands/cloud.ts +1 -1
- package/src/commands/current.ts +1 -4
- package/src/commands/dash.test.ts +110 -0
- package/src/commands/dash.ts +194 -0
- package/src/commands/dashboard.ts +26 -0
- package/src/commands/diff.ts +1 -1
- package/src/commands/discover.test.ts +1 -1
- package/src/commands/discover.ts +90 -40
- package/src/commands/doctor.test.ts +58 -0
- package/src/commands/doctor.ts +79 -3
- package/src/commands/eval-behavior.ts +1 -1
- package/src/commands/eval.ts +2 -2
- package/src/commands/evolve.ts +4 -3
- package/src/commands/failures.test.ts +1 -1
- package/src/commands/features-batch1.test.ts +6 -1
- package/src/commands/icon.ts +1 -5
- package/src/commands/import-profile.ts +1 -1
- package/src/commands/init.ts +50 -7
- package/src/commands/install-sh.e2e.test.ts +65 -0
- package/src/commands/launch-handoff.e2e.test.ts +88 -0
- package/src/commands/launch.e2e.test.ts +8 -1
- package/src/commands/launch.test.ts +29 -0
- package/src/commands/launch.ts +185 -131
- package/src/commands/lock.ts +0 -1
- package/src/commands/marketplace.ts +0 -4
- package/src/commands/materialize.ts +1 -1
- package/src/commands/mem.ts +341 -0
- package/src/commands/optimizer.ts +0 -3
- package/src/commands/playground.ts +1 -2
- package/src/commands/profile-draft-skill.ts +1 -1
- package/src/commands/replay-whatif.ts +1 -6
- package/src/commands/score.ts +2 -2
- package/src/commands/security.test.ts +88 -0
- package/src/commands/security.ts +74 -28
- package/src/commands/shell.test.ts +65 -4
- package/src/commands/shell.ts +67 -7
- package/src/commands/skills-test.ts +0 -1
- package/src/commands/skills.ts +28 -2
- package/src/commands/sources.ts +1 -2
- package/src/commands/status.ts +2 -6
- package/src/commands/submit-profile.ts +1 -1
- package/src/commands/suggest.ts +35 -10
- package/src/commands/trigger-gaps.test.ts +50 -0
- package/src/commands/trigger-gaps.ts +63 -29
- package/src/commands/update.ts +1 -1
- package/src/commands/validate.ts +16 -4
- package/src/commands/watch-live.ts +1 -1
- package/src/commands/workspace.ts +1 -1
- package/src/index.ts +26 -10
- package/src/lib/active-sessions.ts +1 -1
- package/src/lib/agent-adapters.test.ts +100 -0
- package/src/lib/agent-adapters.ts +2 -2
- package/src/lib/analytics.test.ts +88 -0
- package/src/lib/analytics.ts +82 -1
- package/src/lib/auto-detect.test.ts +10 -4
- package/src/lib/auto-detect.ts +19 -23
- package/src/lib/brand-icons.ts +0 -1
- package/src/lib/cache.ts +2 -3
- package/src/lib/claude-mem-env.test.ts +148 -0
- package/src/lib/claude-mem-env.ts +172 -0
- package/src/lib/combo-history.test.ts +53 -0
- package/src/lib/combo-history.ts +83 -0
- package/src/lib/companion-detect.test.ts +108 -0
- package/src/lib/companion-detect.ts +140 -0
- package/src/lib/companion-fetch.ts +4 -6
- package/src/lib/conditional-skills.test.ts +1 -1
- package/src/lib/config-paths.test.ts +53 -0
- package/src/lib/config-paths.ts +33 -0
- package/src/lib/dashboard-server.test.ts +351 -0
- package/src/lib/dashboard-server.ts +1476 -27
- package/src/lib/debug-log.test.ts +66 -0
- package/src/lib/debug-log.ts +45 -0
- package/src/lib/mcp-catalog.test.ts +102 -0
- package/src/lib/mcp-catalog.ts +193 -0
- package/src/lib/pair-suggestions.test.ts +111 -0
- package/src/lib/pair-suggestions.ts +98 -5
- package/src/lib/permissions.test.ts +76 -0
- package/src/lib/permissions.ts +125 -0
- package/src/lib/picker.test.ts +1106 -1
- package/src/lib/picker.ts +1230 -142
- package/src/lib/plugin-discovery.ts +126 -0
- package/src/lib/pr-poster.ts +1 -1
- package/src/lib/pr-throttle.ts +2 -6
- package/src/lib/profile-linter.test.ts +67 -1
- package/src/lib/profile-linter.ts +59 -14
- package/src/lib/profile-loader.test.ts +21 -0
- package/src/lib/profile-loader.ts +22 -3
- package/src/lib/profile-metrics.ts +2 -6
- package/src/lib/profile-names.test.ts +58 -0
- package/src/lib/repos.test.ts +57 -0
- package/src/lib/repos.ts +167 -0
- package/src/lib/resolver-npx.ts +10 -1
- package/src/lib/runtime-materializer.test.ts +200 -3
- package/src/lib/runtime-materializer.ts +129 -20
- package/src/lib/shared-profiles.ts +2 -3
- package/src/lib/skill-clis.test.ts +113 -0
- package/src/lib/skill-clis.ts +232 -0
- package/src/lib/skill-dependencies.ts +9 -1
- package/src/lib/skill-deps.ts +1 -1
- package/src/lib/skill-linter.ts +1 -1
- package/src/lib/skill-quality.ts +0 -1
- package/src/lib/skill-sandbox.test.ts +1 -1
- package/src/lib/skills-lock.test.ts +1 -1
- package/src/lib/telemetry-consent.ts +3 -5
- package/src/lib/telemetry-report.test.ts +2 -2
- package/src/lib/token-budget.ts +111 -0
- package/src/lib/trigger-gaps.test.ts +70 -0
- package/src/lib/trigger-gaps.ts +48 -6
- package/src/lib/tui/data.ts +1 -5
- package/src/lib/workflow-store.ts +150 -0
- package/src/lib/workspace-secrets.ts +0 -4
- package/src/lib/workspaces.ts +1 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
assignPorts,
|
|
5
|
+
claudeMemDataDir,
|
|
6
|
+
type PortRegistry,
|
|
7
|
+
portsForSlot,
|
|
8
|
+
resolveClaudeMemEnv,
|
|
9
|
+
} from "./claude-mem-env";
|
|
10
|
+
|
|
11
|
+
describe("portsForSlot", () => {
|
|
12
|
+
test("slot 0 → worker 30000 / server 30001", () => {
|
|
13
|
+
expect(portsForSlot(0)).toEqual({ worker: 30000, server: 30001 });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("consecutive slots never overlap (worker even, server odd, +2 per slot)", () => {
|
|
17
|
+
expect(portsForSlot(5)).toEqual({ worker: 30010, server: 30011 });
|
|
18
|
+
// slot N's server (30000+2N+1) is always below slot N+1's worker (30000+2N+2)
|
|
19
|
+
expect(portsForSlot(5).server).toBeLessThan(portsForSlot(6).worker);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("stays below the Linux ephemeral range for any realistic slot", () => {
|
|
23
|
+
expect(portsForSlot(999).server).toBeLessThan(32768);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("assignPorts", () => {
|
|
28
|
+
test("assigns the lowest free slot to a new profile and does not mutate input", () => {
|
|
29
|
+
const reg: PortRegistry = { version: 1, slots: {} };
|
|
30
|
+
const out = assignPorts(reg, "backend");
|
|
31
|
+
expect(out.assigned).toBe(true);
|
|
32
|
+
expect(out.ports).toEqual({ worker: 30000, server: 30001 });
|
|
33
|
+
expect(out.registry.slots).toEqual({ backend: 0 });
|
|
34
|
+
expect(reg.slots).toEqual({}); // input untouched
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("returns the same ports (and assigned=false) for an already-known profile", () => {
|
|
38
|
+
const reg: PortRegistry = { version: 1, slots: { backend: 3 } };
|
|
39
|
+
const out = assignPorts(reg, "backend");
|
|
40
|
+
expect(out.assigned).toBe(false);
|
|
41
|
+
expect(out.ports).toEqual(portsForSlot(3));
|
|
42
|
+
expect(out.registry).toBe(reg); // unchanged reference
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("fills the lowest gap rather than appending", () => {
|
|
46
|
+
const reg: PortRegistry = { version: 1, slots: { a: 0, c: 2 } };
|
|
47
|
+
const out = assignPorts(reg, "b");
|
|
48
|
+
expect(out.registry.slots.b).toBe(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("distinct profiles get distinct, non-overlapping ports", () => {
|
|
52
|
+
let reg: PortRegistry = { version: 1, slots: {} };
|
|
53
|
+
const a = assignPorts(reg, "frontend");
|
|
54
|
+
reg = a.registry;
|
|
55
|
+
const b = assignPorts(reg, "secops");
|
|
56
|
+
const ports = [a.ports.worker, a.ports.server, b.ports.worker, b.ports.server];
|
|
57
|
+
expect(new Set(ports).size).toBe(4);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("claudeMemDataDir", () => {
|
|
62
|
+
test("nests the profile under <home>/.claude-mem/profiles", () => {
|
|
63
|
+
expect(claudeMemDataDir("backend", "/home/u")).toBe("/home/u/.claude-mem/profiles/backend");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("keeps + and @ (real profile names) but sanitizes path separators", () => {
|
|
67
|
+
expect(claudeMemDataDir("gstack+skill-writer", "/home/u")).toBe(
|
|
68
|
+
"/home/u/.claude-mem/profiles/gstack+skill-writer",
|
|
69
|
+
);
|
|
70
|
+
expect(claudeMemDataDir("a/b", "/home/u")).toBe("/home/u/.claude-mem/profiles/a_b");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("resolveClaudeMemEnv", () => {
|
|
75
|
+
const inMemoryRegistry = () => {
|
|
76
|
+
let reg: PortRegistry = { version: 1, slots: {} };
|
|
77
|
+
return {
|
|
78
|
+
readRegistry: () => reg,
|
|
79
|
+
writeRegistry: (r: PortRegistry) => {
|
|
80
|
+
reg = r;
|
|
81
|
+
},
|
|
82
|
+
current: () => reg,
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
test("returns null when isolation is opted out", () => {
|
|
87
|
+
const out = resolveClaudeMemEnv("backend", {
|
|
88
|
+
home: "/home/u",
|
|
89
|
+
existingEnv: { CUE_CLAUDE_MEM_ISOLATE: "0" },
|
|
90
|
+
});
|
|
91
|
+
expect(out).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("returns null when the user already manages CLAUDE_MEM_* by hand", () => {
|
|
95
|
+
const out = resolveClaudeMemEnv("backend", {
|
|
96
|
+
home: "/home/u",
|
|
97
|
+
existingEnv: { CLAUDE_MEM_DATA_DIR: "/custom" },
|
|
98
|
+
});
|
|
99
|
+
expect(out).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("produces the SQLite-only overlay and persists a new slot", () => {
|
|
103
|
+
const io = inMemoryRegistry();
|
|
104
|
+
const out = resolveClaudeMemEnv("backend", {
|
|
105
|
+
home: "/home/u",
|
|
106
|
+
existingEnv: {},
|
|
107
|
+
readRegistry: io.readRegistry,
|
|
108
|
+
writeRegistry: io.writeRegistry,
|
|
109
|
+
});
|
|
110
|
+
expect(out).toEqual({
|
|
111
|
+
CLAUDE_MEM_DATA_DIR: "/home/u/.claude-mem/profiles/backend",
|
|
112
|
+
CLAUDE_MEM_CHROMA_ENABLED: "false",
|
|
113
|
+
CLAUDE_MEM_WORKER_PORT: "30000",
|
|
114
|
+
CLAUDE_MEM_SERVER_PORT: "30001",
|
|
115
|
+
});
|
|
116
|
+
expect(io.current().slots).toEqual({ backend: 0 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("is stable across calls for the same profile (no re-allocation)", () => {
|
|
120
|
+
const io = inMemoryRegistry();
|
|
121
|
+
const first = resolveClaudeMemEnv("backend", {
|
|
122
|
+
home: "/home/u",
|
|
123
|
+
existingEnv: {},
|
|
124
|
+
readRegistry: io.readRegistry,
|
|
125
|
+
writeRegistry: io.writeRegistry,
|
|
126
|
+
});
|
|
127
|
+
const second = resolveClaudeMemEnv("backend", {
|
|
128
|
+
home: "/home/u",
|
|
129
|
+
existingEnv: {},
|
|
130
|
+
readRegistry: io.readRegistry,
|
|
131
|
+
writeRegistry: io.writeRegistry,
|
|
132
|
+
});
|
|
133
|
+
expect(second).toEqual(first);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("two profiles get different ports", () => {
|
|
137
|
+
const io = inMemoryRegistry();
|
|
138
|
+
const opts = {
|
|
139
|
+
home: "/home/u",
|
|
140
|
+
existingEnv: {},
|
|
141
|
+
readRegistry: io.readRegistry,
|
|
142
|
+
writeRegistry: io.writeRegistry,
|
|
143
|
+
};
|
|
144
|
+
const a = resolveClaudeMemEnv("frontend", opts);
|
|
145
|
+
const b = resolveClaudeMemEnv("secops", opts);
|
|
146
|
+
expect(a?.CLAUDE_MEM_WORKER_PORT).not.toBe(b?.CLAUDE_MEM_WORKER_PORT);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-mem-env — per-profile claude-mem environment overlay.
|
|
3
|
+
*
|
|
4
|
+
* claude-mem keys its ENTIRE store off CLAUDE_MEM_DATA_DIR (default ~/.claude-mem)
|
|
5
|
+
* and addresses its background worker over a single TCP port (default UID-derived).
|
|
6
|
+
* So out of the box every cue profile shares one memory pool AND one worker port —
|
|
7
|
+
* meaning two concurrent profiles would cross-write through whichever worker
|
|
8
|
+
* claimed the port first. This module gives each profile an isolated, SQLite-only
|
|
9
|
+
* store with its own worker/server ports:
|
|
10
|
+
*
|
|
11
|
+
* CLAUDE_MEM_DATA_DIR ~/.claude-mem/profiles/<profile>
|
|
12
|
+
* CLAUDE_MEM_CHROMA_ENABLED "false" → worker runs SQLite-only, never spawns Chroma
|
|
13
|
+
* CLAUDE_MEM_WORKER_PORT per-profile (registry-allocated, collision-free)
|
|
14
|
+
* CLAUDE_MEM_SERVER_PORT per-profile (registry-allocated)
|
|
15
|
+
*
|
|
16
|
+
* Verified against claude-mem v13.4.0:
|
|
17
|
+
* - paths.ts:18-40 CLAUDE_MEM_DATA_DIR override + ~/.claude-mem default
|
|
18
|
+
* - SettingsDefaultsManager process.env[key] wins over the data-dir settings.json
|
|
19
|
+
* - worker-service.ts:343-348 CHROMA_ENABLED="false" skips spawning ChromaMcpManager
|
|
20
|
+
* - worker-service.ts:996 the worker treats the TCP port as a global singleton
|
|
21
|
+
*
|
|
22
|
+
* Pure core (portsForSlot/assignPorts) + injectable I/O so it tests without $HOME.
|
|
23
|
+
* Opt out for a session with CUE_CLAUDE_MEM_ISOLATE=0.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { homedir as osHomedir } from "node:os";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
/** A worker+server TCP port pair for one profile. */
|
|
31
|
+
export interface PortPair {
|
|
32
|
+
worker: number;
|
|
33
|
+
server: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Persisted slot assignments: profile name → slot index. */
|
|
37
|
+
export interface PortRegistry {
|
|
38
|
+
version: number;
|
|
39
|
+
slots: Record<string, number>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The four env vars cue injects to isolate a profile's claude-mem store. */
|
|
43
|
+
export interface ClaudeMemEnv {
|
|
44
|
+
CLAUDE_MEM_DATA_DIR: string;
|
|
45
|
+
CLAUDE_MEM_CHROMA_ENABLED: string;
|
|
46
|
+
CLAUDE_MEM_WORKER_PORT: string;
|
|
47
|
+
CLAUDE_MEM_SERVER_PORT: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Ports sit just below the Linux ephemeral range (32768+) so a profile's worker
|
|
51
|
+
// never clashes with an OS-assigned ephemeral port. Two ports per slot:
|
|
52
|
+
// worker = even, server = worker + 1 (odd). Slot N → [30000+2N, 30000+2N+1].
|
|
53
|
+
const PORT_BASE = 30000;
|
|
54
|
+
const MAX_SLOTS = 1000; // 30000..31999
|
|
55
|
+
|
|
56
|
+
/** Map a slot index to its worker/server port pair. Pure. */
|
|
57
|
+
export function portsForSlot(slot: number): PortPair {
|
|
58
|
+
return { worker: PORT_BASE + slot * 2, server: PORT_BASE + slot * 2 + 1 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve the slot for `profileName`, assigning the lowest free slot if it is
|
|
63
|
+
* new. Pure: never mutates the input registry; returns a fresh registry when a
|
|
64
|
+
* slot was assigned (so the caller knows to persist).
|
|
65
|
+
*/
|
|
66
|
+
export function assignPorts(
|
|
67
|
+
registry: PortRegistry,
|
|
68
|
+
profileName: string,
|
|
69
|
+
): { registry: PortRegistry; ports: PortPair; assigned: boolean } {
|
|
70
|
+
const existing = registry.slots[profileName];
|
|
71
|
+
if (existing !== undefined) {
|
|
72
|
+
return { registry, ports: portsForSlot(existing), assigned: false };
|
|
73
|
+
}
|
|
74
|
+
const used = new Set(Object.values(registry.slots));
|
|
75
|
+
let slot = 0;
|
|
76
|
+
while (used.has(slot)) slot++;
|
|
77
|
+
if (slot >= MAX_SLOTS) {
|
|
78
|
+
throw new Error(`claude-mem-env: exhausted ${MAX_SLOTS} port slots`);
|
|
79
|
+
}
|
|
80
|
+
const next: PortRegistry = {
|
|
81
|
+
version: registry.version,
|
|
82
|
+
slots: { ...registry.slots, [profileName]: slot },
|
|
83
|
+
};
|
|
84
|
+
return { registry: next, ports: portsForSlot(slot), assigned: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Filesystem-safe per-profile claude-mem data dir under the claude-mem home. */
|
|
88
|
+
export function claudeMemDataDir(profileName: string, home: string): string {
|
|
89
|
+
const safe = profileName.replace(/[^A-Za-z0-9._+@-]/g, "_");
|
|
90
|
+
return join(home, ".claude-mem", "profiles", safe);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Path to the cue-owned port registry inside the claude-mem home. */
|
|
94
|
+
export function registryPath(home: string): string {
|
|
95
|
+
return join(home, ".claude-mem", "cue-ports.json");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const EMPTY_REGISTRY: PortRegistry = { version: 1, slots: {} };
|
|
99
|
+
|
|
100
|
+
export interface ResolveDeps {
|
|
101
|
+
/** Home dir (defaults to os.homedir()). */
|
|
102
|
+
home?: string;
|
|
103
|
+
/** Environment to read overrides from (defaults to process.env). */
|
|
104
|
+
existingEnv?: Record<string, string | undefined>;
|
|
105
|
+
/** Whether isolation applies (defaults to CUE_CLAUDE_MEM_ISOLATE !== "0"). */
|
|
106
|
+
isolate?: boolean;
|
|
107
|
+
/** Registry reader (defaults to reading registryPath(home)). */
|
|
108
|
+
readRegistry?: () => PortRegistry;
|
|
109
|
+
/** Registry writer (defaults to an atomic write to registryPath(home)). */
|
|
110
|
+
writeRegistry?: (registry: PortRegistry) => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Compute the env overlay for a profile, or null when isolation must not apply:
|
|
115
|
+
* - opted out via CUE_CLAUDE_MEM_ISOLATE=0, or
|
|
116
|
+
* - the user is hand-managing CLAUDE_MEM_DATA_DIR / *_PORT in their own env.
|
|
117
|
+
* In both cases cue leaves claude-mem entirely alone.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveClaudeMemEnv(
|
|
120
|
+
profileName: string,
|
|
121
|
+
deps: ResolveDeps = {},
|
|
122
|
+
): ClaudeMemEnv | null {
|
|
123
|
+
const env = deps.existingEnv ?? process.env;
|
|
124
|
+
const isolate = deps.isolate ?? env.CUE_CLAUDE_MEM_ISOLATE !== "0";
|
|
125
|
+
if (!isolate) return null;
|
|
126
|
+
if (env.CLAUDE_MEM_DATA_DIR || env.CLAUDE_MEM_WORKER_PORT || env.CLAUDE_MEM_SERVER_PORT) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const home = deps.home ?? osHomedir();
|
|
131
|
+
const readRegistry = deps.readRegistry ?? (() => defaultReadRegistry(home));
|
|
132
|
+
const writeRegistry =
|
|
133
|
+
deps.writeRegistry ?? ((registry: PortRegistry) => defaultWriteRegistry(home, registry));
|
|
134
|
+
|
|
135
|
+
const { registry, ports, assigned } = assignPorts(readRegistry(), profileName);
|
|
136
|
+
if (assigned) writeRegistry(registry);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
CLAUDE_MEM_DATA_DIR: claudeMemDataDir(profileName, home),
|
|
140
|
+
CLAUDE_MEM_CHROMA_ENABLED: "false",
|
|
141
|
+
CLAUDE_MEM_WORKER_PORT: String(ports.worker),
|
|
142
|
+
CLAUDE_MEM_SERVER_PORT: String(ports.server),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function defaultReadRegistry(home: string): PortRegistry {
|
|
147
|
+
const path = registryPath(home);
|
|
148
|
+
try {
|
|
149
|
+
if (!existsSync(path)) return { version: 1, slots: {} };
|
|
150
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<PortRegistry> | null;
|
|
151
|
+
if (!raw || typeof raw.slots !== "object" || raw.slots === null) {
|
|
152
|
+
return { version: 1, slots: {} };
|
|
153
|
+
}
|
|
154
|
+
return { version: raw.version ?? 1, slots: raw.slots as Record<string, number> };
|
|
155
|
+
} catch {
|
|
156
|
+
// Corrupt or unreadable registry — start fresh rather than block the launch.
|
|
157
|
+
return { version: 1, slots: {} };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function defaultWriteRegistry(home: string, registry: PortRegistry): void {
|
|
162
|
+
const path = registryPath(home);
|
|
163
|
+
mkdirSync(join(home, ".claude-mem"), { recursive: true });
|
|
164
|
+
// Write-then-rename so a registry read never sees a torn file. Two never-seen
|
|
165
|
+
// profiles launching in the same instant can still race the read-modify-write;
|
|
166
|
+
// that only mis-assigns on first launch and is fixable via `cue mem ports`.
|
|
167
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
168
|
+
writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`);
|
|
169
|
+
renameSync(tmp, path);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export { EMPTY_REGISTRY };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { recordCombo, type ComboRecord } from "./combo-history";
|
|
4
|
+
|
|
5
|
+
describe("recordCombo", () => {
|
|
6
|
+
const capture = () => {
|
|
7
|
+
const lines: string[] = [];
|
|
8
|
+
return { lines, append: (l: string) => lines.push(l) };
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
test("writes a {ts, profile, primary} row for a real combo (≥2 parts)", () => {
|
|
12
|
+
const { lines, append } = capture();
|
|
13
|
+
const wrote = recordCombo(["gstack", "skill-writer", "core"], "2026-06-02T00:00:00.000Z", append);
|
|
14
|
+
expect(wrote).toBe(true);
|
|
15
|
+
expect(lines).toHaveLength(1);
|
|
16
|
+
const rec = JSON.parse(lines[0]!) as ComboRecord;
|
|
17
|
+
expect(rec.profile).toBe("gstack+skill-writer+core");
|
|
18
|
+
expect(rec.primary).toBe("gstack");
|
|
19
|
+
expect(rec.ts).toBe("2026-06-02T00:00:00.000Z");
|
|
20
|
+
expect(lines[0]!.endsWith("\n")).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a single-profile pick is not a combo → no write", () => {
|
|
24
|
+
const { lines, append } = capture();
|
|
25
|
+
expect(recordCombo(["gstack"], "t", append)).toBe(false);
|
|
26
|
+
expect(lines).toHaveLength(0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("dedupes parts before recording (so a+a+b → a+b)", () => {
|
|
30
|
+
const { lines, append } = capture();
|
|
31
|
+
recordCombo(["a", "a", "b"], "t", append);
|
|
32
|
+
expect((JSON.parse(lines[0]!) as ComboRecord).profile).toBe("a+b");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("dedup that collapses to a single distinct part is not recorded", () => {
|
|
36
|
+
const { lines, append } = capture();
|
|
37
|
+
expect(recordCombo(["a", "a"], "t", append)).toBe(false);
|
|
38
|
+
expect(lines).toHaveLength(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("trims whitespace and drops empty parts", () => {
|
|
42
|
+
const { lines, append } = capture();
|
|
43
|
+
recordCombo([" a ", "", " b "], "t", append);
|
|
44
|
+
expect((JSON.parse(lines[0]!) as ComboRecord).profile).toBe("a+b");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a throwing append is swallowed (best-effort) → returns false", () => {
|
|
48
|
+
const wrote = recordCombo(["a", "b"], "t", () => {
|
|
49
|
+
throw new Error("disk full");
|
|
50
|
+
});
|
|
51
|
+
expect(wrote).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combo history — a local, telemetry-independent record of multi-profile picks.
|
|
3
|
+
*
|
|
4
|
+
* When the picker confirms a combine (≥2 profiles), `recordCombo` appends one
|
|
5
|
+
* line to `~/.config/cue/combo-history.jsonl`. Unlike `analytics.jsonl` (gated
|
|
6
|
+
* on telemetry consent) and `session-log.jsonl` (written by the session-summary
|
|
7
|
+
* Stop hook), this file is written directly by the picker with no consent gate
|
|
8
|
+
* and no hook — so "remember the combos I pick" works out of the box.
|
|
9
|
+
*
|
|
10
|
+
* `pair-suggestions` folds these lines into its affinity map, so a combo picked
|
|
11
|
+
* once resurfaces (unchecked, hinted) the next time its primary is chosen.
|
|
12
|
+
*
|
|
13
|
+
* All writes are best-effort: a failure to append never blocks a launch.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
|
|
20
|
+
/** Resolved path mirrors `pair-suggestions.sessionLogPath` (same config dir). */
|
|
21
|
+
export function comboHistoryPath(): string {
|
|
22
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
23
|
+
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".config");
|
|
24
|
+
return join(base, "cue", "combo-history.jsonl");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One recorded combo. `profile` is the full composite selector ("a+b+c") so
|
|
28
|
+
* `computeAffinityMap` (which reads `.profile`) can consume it unchanged. */
|
|
29
|
+
export interface ComboRecord {
|
|
30
|
+
ts: string;
|
|
31
|
+
profile: string;
|
|
32
|
+
/** Convenience field — the first part, the profile the user picked first. */
|
|
33
|
+
primary: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Append a combo to the history log. No-op (returns false) when there's fewer
|
|
38
|
+
* than two distinct parts — a single-profile pick isn't a combo. Deduplicates
|
|
39
|
+
* parts (preserving order) so "a+a+b" records as "a+b". `now` is injected for
|
|
40
|
+
* testability. Returns whether a line was written.
|
|
41
|
+
*
|
|
42
|
+
* `append` is injectable so tests don't touch the real config dir.
|
|
43
|
+
*/
|
|
44
|
+
export function recordCombo(
|
|
45
|
+
parts: string[],
|
|
46
|
+
now: string,
|
|
47
|
+
append: (line: string) => void = defaultAppend,
|
|
48
|
+
): boolean {
|
|
49
|
+
const deduped: string[] = [];
|
|
50
|
+
for (const raw of parts) {
|
|
51
|
+
const part = raw.trim();
|
|
52
|
+
if (part.length > 0 && !deduped.includes(part)) deduped.push(part);
|
|
53
|
+
}
|
|
54
|
+
if (deduped.length < 2) return false;
|
|
55
|
+
const record: ComboRecord = {
|
|
56
|
+
ts: now,
|
|
57
|
+
profile: deduped.join("+"),
|
|
58
|
+
primary: deduped[0]!,
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
append(JSON.stringify(record) + "\n");
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false; // best-effort — never block a launch on a logging failure
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Read the combo-history lines (newline-split, blank-tolerant). Missing file or
|
|
69
|
+
* read error → []. Exposed so `pair-suggestions` can fold these into affinity. */
|
|
70
|
+
export function readComboHistoryLines(path: string = comboHistoryPath()): string[] {
|
|
71
|
+
if (!existsSync(path)) return [];
|
|
72
|
+
try {
|
|
73
|
+
return readFileSync(path, "utf8").split("\n");
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function defaultAppend(line: string): void {
|
|
80
|
+
const path = comboHistoryPath();
|
|
81
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
82
|
+
appendFileSync(path, line);
|
|
83
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { detectCompanions, type CompanionDetectInput } from "./companion-detect";
|
|
4
|
+
|
|
5
|
+
const KNOWN = new Set(["higgsfield", "blog-writer", "postizz", "creative-media"]);
|
|
6
|
+
|
|
7
|
+
function run(
|
|
8
|
+
entries: string[],
|
|
9
|
+
extra: Partial<CompanionDetectInput> = {},
|
|
10
|
+
): ReturnType<typeof detectCompanions> {
|
|
11
|
+
return detectCompanions({
|
|
12
|
+
cwd: "/work/volaria",
|
|
13
|
+
knownProfiles: KNOWN,
|
|
14
|
+
listEntries: () => entries,
|
|
15
|
+
...extra,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("detectCompanions — images → higgsfield", () => {
|
|
20
|
+
test("≥3 image files suggest higgsfield, auto-checkable (≥0.7), with a count reason", () => {
|
|
21
|
+
const r = run(["a.png", "b.jpg", "c.webp", "d.jpeg"]);
|
|
22
|
+
const hf = r.find((s) => s.profile === "higgsfield");
|
|
23
|
+
expect(hf).toBeDefined();
|
|
24
|
+
expect(hf!.confidence).toBeGreaterThanOrEqual(0.7);
|
|
25
|
+
expect(hf!.reason).toBe("4 image assets");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("fewer than 3 images produces no higgsfield signal", () => {
|
|
29
|
+
const r = run(["a.png", "b.png", "notes.txt"]);
|
|
30
|
+
expect(r.find((s) => s.profile === "higgsfield")).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("confidence scales with count but caps at 0.9", () => {
|
|
34
|
+
const many = Array.from({ length: 30 }, (_, i) => `img${i}.png`);
|
|
35
|
+
const hf = run(many).find((s) => s.profile === "higgsfield");
|
|
36
|
+
expect(hf!.confidence).toBe(0.9);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("detectCompanions — video → higgsfield", () => {
|
|
41
|
+
test("a single video file suggests higgsfield", () => {
|
|
42
|
+
const hf = run(["clip.mp4"]).find((s) => s.profile === "higgsfield");
|
|
43
|
+
expect(hf).toBeDefined();
|
|
44
|
+
expect(hf!.confidence).toBeGreaterThanOrEqual(0.7);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("images + video collapse to one higgsfield row, reasons merged", () => {
|
|
48
|
+
const r = run(["a.png", "b.png", "c.png", "reel.mov"]);
|
|
49
|
+
const hf = r.filter((s) => s.profile === "higgsfield");
|
|
50
|
+
expect(hf).toHaveLength(1);
|
|
51
|
+
expect(hf[0]!.reason).toContain("image assets");
|
|
52
|
+
expect(hf[0]!.reason).toContain("video file");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("detectCompanions — markdown → blog-writer", () => {
|
|
57
|
+
test("≥2 draft .md files suggest blog-writer but stay below auto-check", () => {
|
|
58
|
+
const bw = run(["draft-1.md", "draft-2.md"]).find((s) => s.profile === "blog-writer");
|
|
59
|
+
expect(bw).toBeDefined();
|
|
60
|
+
expect(bw!.confidence).toBeLessThan(0.7);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("README/AGENTS/CLAUDE are not counted as drafts", () => {
|
|
64
|
+
const r = run(["README.md", "AGENTS.md", "CLAUDE.md"]);
|
|
65
|
+
expect(r.find((s) => s.profile === "blog-writer")).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("a content/ entry alone is enough", () => {
|
|
69
|
+
const bw = run(["content", "x.txt"]).find((s) => s.profile === "blog-writer");
|
|
70
|
+
expect(bw).toBeDefined();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("detectCompanions — brand dir → postizz", () => {
|
|
75
|
+
test("cwd basename matching a registered brand suggests postizz", () => {
|
|
76
|
+
const pz = run(["whatever.txt"], { brands: new Set(["volaria", "slopix"]) }).find(
|
|
77
|
+
(s) => s.profile === "postizz",
|
|
78
|
+
);
|
|
79
|
+
expect(pz).toBeDefined();
|
|
80
|
+
expect(pz!.confidence).toBeGreaterThanOrEqual(0.7);
|
|
81
|
+
expect(pz!.reason).toBe("registered brand: volaria");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("non-brand cwd basename yields no postizz signal", () => {
|
|
85
|
+
const r = run(["x.txt"], { cwd: "/work/random-proj", brands: new Set(["volaria"]) });
|
|
86
|
+
expect(r.find((s) => s.profile === "postizz")).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("detectCompanions — gating & robustness", () => {
|
|
91
|
+
test("companions not installed in this cue install are filtered out", () => {
|
|
92
|
+
const r = run(["a.png", "b.png", "c.png"], { knownProfiles: new Set(["blog-writer"]) });
|
|
93
|
+
expect(r.find((s) => s.profile === "higgsfield")).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("results are sorted by confidence descending", () => {
|
|
97
|
+
const r = run(["a.png", "b.png", "c.png", "d.md", "e.md"], {
|
|
98
|
+
brands: new Set(["volaria"]),
|
|
99
|
+
});
|
|
100
|
+
const confs = r.map((s) => s.confidence);
|
|
101
|
+
expect(confs).toEqual([...confs].sort((a, b) => b - a));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("an empty / unreadable directory yields no signals", () => {
|
|
105
|
+
expect(run([])).toEqual([]);
|
|
106
|
+
expect(detectCompanions({ cwd: "/work/x", knownProfiles: KNOWN })).toBeInstanceOf(Array);
|
|
107
|
+
});
|
|
108
|
+
});
|