@nebutra/cofounder-match 0.1.1

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/src/index.ts ADDED
@@ -0,0 +1,258 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { appendCapabilityDebug, readCapabilityDebug } from "@nebutra/capability-kit/debug";
4
+ import { ContentStore } from "@nebutra/content-store";
5
+ import { CapabilityError } from "@nebutra/errors";
6
+ import { EventLog } from "@nebutra/event-log";
7
+ import { assetId } from "@nebutra/generation-context";
8
+
9
+ export type SkillArea = "code" | "growth" | "brand" | "support";
10
+
11
+ export interface ActivityProfileInput {
12
+ readonly founderId: string;
13
+ readonly activeDays: number;
14
+ readonly playCounts: Partial<Record<SkillArea, number>>;
15
+ readonly domainTags: readonly string[];
16
+ readonly communicationSamples: readonly string[];
17
+ }
18
+
19
+ export interface FounderProfile {
20
+ readonly founderId: string;
21
+ readonly activityVerified: boolean;
22
+ readonly lockedReason?: string;
23
+ readonly activeDays: number;
24
+ readonly skillVector: Record<SkillArea, number>;
25
+ readonly domainTags: readonly string[];
26
+ readonly communicationStyle: readonly string[];
27
+ }
28
+
29
+ export interface MatchScore {
30
+ readonly leftFounderId: string;
31
+ readonly rightFounderId: string;
32
+ readonly overall: number;
33
+ readonly skillComplementarity: number;
34
+ readonly domainAdjacency: number;
35
+ readonly paceCompatibility: number;
36
+ readonly why: readonly string[];
37
+ }
38
+
39
+ export interface ChatThread {
40
+ readonly threadId: string;
41
+ readonly threadKind: "mutual-consent-intro";
42
+ readonly founders: readonly [string, string];
43
+ readonly icebreaker: string;
44
+ }
45
+
46
+ export interface CofounderMatchDoctorReport {
47
+ readonly capability: "cofounder-match";
48
+ readonly ok: boolean;
49
+ readonly checkedAt: string;
50
+ readonly features: readonly string[];
51
+ readonly suggestion?: string;
52
+ }
53
+
54
+ export interface CofounderMatchOptions {
55
+ readonly tenantId?: string;
56
+ readonly debugRoot?: string;
57
+ readonly contentStore?: ContentStore;
58
+ readonly eventLog?: EventLog;
59
+ }
60
+
61
+ function requireTenant(value: string | undefined): string {
62
+ if (!value?.trim()) {
63
+ throw new CapabilityError("cofounder-match", "Cofounder Match requires tenant context", {
64
+ suggestion: "Pass tenantId so profile and consent artifacts stay tenant-scoped.",
65
+ statusCode: 400,
66
+ });
67
+ }
68
+ return value;
69
+ }
70
+
71
+ function skillVector(input: Partial<Record<SkillArea, number>>): Record<SkillArea, number> {
72
+ return {
73
+ code: input.code ?? 0,
74
+ growth: input.growth ?? 0,
75
+ brand: input.brand ?? 0,
76
+ support: input.support ?? 0,
77
+ };
78
+ }
79
+
80
+ function strongestSkill(vector: Record<SkillArea, number>): SkillArea {
81
+ return (
82
+ (Object.entries(vector) as Array<[SkillArea, number]>).sort(
83
+ ([leftKey, left], [rightKey, right]) => right - left || leftKey.localeCompare(rightKey),
84
+ )[0]?.[0] ?? "code"
85
+ );
86
+ }
87
+
88
+ function weakestSkill(vector: Record<SkillArea, number>): SkillArea {
89
+ return (
90
+ (Object.entries(vector) as Array<[SkillArea, number]>).sort(
91
+ ([leftKey, left], [rightKey, right]) => left - right || leftKey.localeCompare(rightKey),
92
+ )[0]?.[0] ?? "growth"
93
+ );
94
+ }
95
+
96
+ function sharedCount(left: readonly string[], right: readonly string[]): number {
97
+ const rightSet = new Set(right.map((item) => item.toLowerCase()));
98
+ return left.filter((item) => rightSet.has(item.toLowerCase())).length;
99
+ }
100
+
101
+ function consentKey(from: string, to: string): string {
102
+ return `${from}->${to}`;
103
+ }
104
+
105
+ export class CofounderMatch {
106
+ readonly #tenantId: string;
107
+ readonly #debugRoot: string;
108
+ readonly #contentStore: ContentStore;
109
+ readonly #eventLog: EventLog;
110
+ readonly #profiles = new Map<string, FounderProfile>();
111
+ readonly #interests = new Set<string>();
112
+
113
+ private constructor(
114
+ options: Required<Pick<CofounderMatchOptions, "tenantId" | "contentStore" | "eventLog">> &
115
+ Pick<CofounderMatchOptions, "debugRoot">,
116
+ ) {
117
+ this.#tenantId = requireTenant(options.tenantId);
118
+ this.#debugRoot = options.debugRoot ?? process.cwd();
119
+ this.#contentStore = options.contentStore;
120
+ this.#eventLog = options.eventLog;
121
+ }
122
+
123
+ static async open(
124
+ root = ".nebutra/cofounder-match",
125
+ options: Omit<CofounderMatchOptions, "contentStore" | "eventLog"> = {},
126
+ ): Promise<CofounderMatch> {
127
+ const tenantId = options.tenantId ?? "local";
128
+ await mkdir(root, { recursive: true });
129
+ const contentStore = await ContentStore.open(join(root, "content"), { tenantId });
130
+ const eventLog = await EventLog.open(join(root, "event-log"), { tenantId });
131
+ return new CofounderMatch({ ...options, tenantId, contentStore, eventLog });
132
+ }
133
+
134
+ async buildProfileFromActivity(input: ActivityProfileInput): Promise<FounderProfile> {
135
+ const profile: FounderProfile = {
136
+ founderId: input.founderId,
137
+ activeDays: input.activeDays,
138
+ activityVerified: input.activeDays >= 30,
139
+ ...(input.activeDays < 30
140
+ ? { lockedReason: "Cofounder matching unlocks after 30 days of real activity." }
141
+ : {}),
142
+ skillVector: skillVector(input.playCounts),
143
+ domainTags: input.domainTags,
144
+ communicationStyle: input.communicationSamples,
145
+ };
146
+ const path = `cofounder-match/profiles/${input.founderId}.json`;
147
+ const snapshot = `${JSON.stringify(profile, null, 2)}\n`;
148
+ await this.#contentStore.write(path, snapshot);
149
+ this.#profiles.set(input.founderId, profile);
150
+ await this.#debug({
151
+ type: "profile",
152
+ tenantId: this.#tenantId,
153
+ founderId: input.founderId,
154
+ activityVerified: profile.activityVerified,
155
+ });
156
+ return profile;
157
+ }
158
+
159
+ scoreMatch(left: FounderProfile, right: FounderProfile): MatchScore {
160
+ const leftWeak = weakestSkill(left.skillVector);
161
+ const rightStrong = strongestSkill(right.skillVector);
162
+ const rightWeak = weakestSkill(right.skillVector);
163
+ const leftStrong = strongestSkill(left.skillVector);
164
+ const growthFill = left.skillVector.growth <= 10 && right.skillVector.growth >= 30 ? 45 : 0;
165
+ const codeFill = right.skillVector.code <= 10 && left.skillVector.code >= 30 ? 25 : 0;
166
+ const supportFill = left.skillVector.support <= 5 && right.skillVector.support >= 15 ? 10 : 0;
167
+ const brandFill = right.skillVector.brand <= 5 && left.skillVector.brand >= 10 ? 10 : 0;
168
+ const complements = Math.min(100, 20 + growthFill + codeFill + supportFill + brandFill);
169
+ const domainAdjacency = Math.min(100, 55 + sharedCount(left.domainTags, right.domainTags) * 20);
170
+ const paceCompatibility = Math.max(40, 100 - Math.abs(left.activeDays - right.activeDays));
171
+ const overall = Math.round(
172
+ Math.min(100, complements * 0.55 + domainAdjacency * 0.25 + paceCompatibility * 0.2),
173
+ );
174
+ const why = [
175
+ `${right.founderId} is strong in ${rightStrong[0]?.toUpperCase()}${rightStrong.slice(
176
+ 1,
177
+ )}, which complements ${left.founderId}'s ${leftWeak} gap.`,
178
+ `${left.founderId} is strong in ${leftStrong}, which balances ${right.founderId}'s ${rightWeak} gap.`,
179
+ `Shared domains: ${sharedCount(left.domainTags, right.domainTags)}.`,
180
+ ];
181
+ return {
182
+ leftFounderId: left.founderId,
183
+ rightFounderId: right.founderId,
184
+ overall,
185
+ skillComplementarity: Math.min(100, complements),
186
+ domainAdjacency,
187
+ paceCompatibility,
188
+ why,
189
+ };
190
+ }
191
+
192
+ async expressInterest(fromFounderId: string, toFounderId: string): Promise<void> {
193
+ this.#interests.add(consentKey(fromFounderId, toFounderId));
194
+ await this.#debug({ type: "interest", tenantId: this.#tenantId, fromFounderId, toFounderId });
195
+ }
196
+
197
+ async startChat(leftFounderId: string, rightFounderId: string): Promise<ChatThread> {
198
+ if (
199
+ !this.#interests.has(consentKey(leftFounderId, rightFounderId)) ||
200
+ !this.#interests.has(consentKey(rightFounderId, leftFounderId))
201
+ ) {
202
+ throw new CapabilityError("cofounder-match", "Mutual consent is required before chat", {
203
+ suggestion: "Wait for mutual interest before opening a cofounder intro thread.",
204
+ statusCode: 403,
205
+ });
206
+ }
207
+ const thread: ChatThread = {
208
+ threadId: assetId("cofounder_chat", `${leftFounderId}_${rightFounderId}`),
209
+ threadKind: "mutual-consent-intro",
210
+ founders: [leftFounderId, rightFounderId],
211
+ icebreaker: `${leftFounderId}, start with the specific work you admired in ${rightFounderId}'s profile.`,
212
+ };
213
+ const path = `cofounder-match/chats/${thread.threadId}.json`;
214
+ const snapshot = `${JSON.stringify(thread, null, 2)}\n`;
215
+ await this.#contentStore.write(path, snapshot);
216
+ await this.#eventLog.commit({
217
+ traceId: assetId("cofounder_match_chat", thread.threadId),
218
+ kind: "content_write",
219
+ affected: [path],
220
+ parent: null,
221
+ snapshot: { [path]: snapshot },
222
+ });
223
+ await this.#debug({
224
+ type: "start_chat",
225
+ tenantId: this.#tenantId,
226
+ founders: thread.founders,
227
+ });
228
+ return thread;
229
+ }
230
+
231
+ async doctor(): Promise<CofounderMatchDoctorReport> {
232
+ const report: CofounderMatchDoctorReport = {
233
+ capability: "cofounder-match",
234
+ ok: true,
235
+ checkedAt: new Date().toISOString(),
236
+ features: ["activity-verified-profile", "explainable-score", "mutual-consent-intro"],
237
+ suggestion:
238
+ "Local matching contracts are ready; encrypted chat and identity providers remain adapter handoffs.",
239
+ };
240
+ await this.#debug({ type: "doctor", tenantId: this.#tenantId, profiles: this.#profiles.size });
241
+ return report;
242
+ }
243
+
244
+ async close(): Promise<void> {
245
+ await this.#contentStore.close();
246
+ }
247
+
248
+ async #debug(entry: Record<string, unknown>): Promise<void> {
249
+ await appendCapabilityDebug("cofounder-match", entry, { root: this.#debugRoot });
250
+ }
251
+ }
252
+
253
+ export async function readCofounderMatchDebug(
254
+ root = process.cwd(),
255
+ limit = 20,
256
+ ): Promise<unknown[]> {
257
+ return readCapabilityDebug("cofounder-match", { root, limit });
258
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src",
6
+ "types": ["node", "vitest/globals"]
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ clean: true,
5
+ dts: true,
6
+ entry: ["src/index.ts", "src/cli.ts"],
7
+ format: ["esm"],
8
+ sourcemap: true,
9
+ });