terminalhire 0.14.0 → 0.15.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.
@@ -2953,11 +2953,14 @@ You control whether this connects \u2014 no contact details are shared unless yo
2953
2953
  return { subject, text };
2954
2954
  }
2955
2955
  function introActorRole(intro, actorLogin) {
2956
- const a = actorLogin.trim().toLowerCase();
2957
- if (a && a === intro.targetLogin.trim().toLowerCase()) return "target";
2958
- if (a && a === intro.requesterLogin.trim().toLowerCase()) return "requester";
2956
+ if (sameLogin(actorLogin, intro.targetLogin)) return "target";
2957
+ if (sameLogin(actorLogin, intro.requesterLogin)) return "requester";
2959
2958
  return "other";
2960
2959
  }
2960
+ function sameLogin(a, b) {
2961
+ const an = a.trim().toLowerCase();
2962
+ return an.length > 0 && an === b.trim().toLowerCase();
2963
+ }
2961
2964
  function authorizeIntroDecision(intro, actorLogin) {
2962
2965
  const role = introActorRole(intro, actorLogin);
2963
2966
  if (role === "target") return { ok: true };
@@ -6399,6 +6402,7 @@ __export(src_exports, {
6399
6402
  rejectExtraIntroFields: () => rejectExtraIntroFields,
6400
6403
  revealIntroContacts: () => revealIntroContacts,
6401
6404
  safetyNumber: () => safetyNumber,
6405
+ sameLogin: () => sameLogin,
6402
6406
  setStatus: () => setStatus,
6403
6407
  tokenize: () => tokenize,
6404
6408
  validateGraph: () => validateGraph,
@@ -6670,7 +6674,7 @@ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSy
6670
6674
  import { join as join3 } from "path";
6671
6675
  import { homedir as homedir2 } from "os";
6672
6676
  import { createInterface } from "readline";
6673
- var TERMINALHIRE_DIR2 = join3(homedir2(), ".terminalhire");
6677
+ var TERMINALHIRE_DIR2 = process.env.TERMINALHIRE_DIR || join3(homedir2(), ".terminalhire");
6674
6678
  var INDEX_CACHE_FILE = join3(TERMINALHIRE_DIR2, "index-cache.json");
6675
6679
  var INDEX_TTL_MS = 15 * 60 * 1e3;
6676
6680
  var API_URL = process.env["TERMINALHIRE_API_URL"] ?? process.env["JPI_API_URL"] ?? "https://terminalhire.com";
@@ -4456,6 +4456,8 @@ function readReadCursors() {
4456
4456
  function writeReadCursor(login, iso, deps = {}) {
4457
4457
  const read = deps.readReadCursors ?? readReadCursors;
4458
4458
  const cursors = read();
4459
+ const prev = cursors[login];
4460
+ if (prev && iso <= prev) return;
4459
4461
  cursors[login] = iso;
4460
4462
  mkdirSync6(TERMINALHIRE_DIR5, { recursive: true });
4461
4463
  writeFileSync6(READS_FILE, JSON.stringify(cursors, null, 2), { mode: 384, encoding: "utf8" });
@@ -4771,7 +4773,7 @@ var init_jpi_chat_read = __esm({
4771
4773
  init_jpi_chat();
4772
4774
  CHAT_BASE3 = process.env["TERMINALHIRE_API_URL"] || "https://www.terminalhire.com";
4773
4775
  GH_SESSION_COOKIE3 = "__jpi_gh_session";
4774
- TERMINALHIRE_DIR5 = join8(homedir7(), ".terminalhire");
4776
+ TERMINALHIRE_DIR5 = process.env.TERMINALHIRE_DIR || join8(homedir7(), ".terminalhire");
4775
4777
  READS_FILE = join8(TERMINALHIRE_DIR5, "chat-reads.json");
4776
4778
  INDEX_CACHE_FILE = join8(TERMINALHIRE_DIR5, "index-cache.json");
4777
4779
  REACHABLE_DISPLAY = { shareActivity: false, optin: false, lastSeen: null };
@@ -4281,246 +4281,6 @@ var init_config = __esm({
4281
4281
  }
4282
4282
  });
4283
4283
 
4284
- // src/profile.ts
4285
- var profile_exports = {};
4286
- __export(profile_exports, {
4287
- accumulateGitHubTags: () => accumulateGitHubTags,
4288
- accumulateSession: () => accumulateSession,
4289
- accumulateTags: () => accumulateTags,
4290
- addSavedJob: () => addSavedJob,
4291
- deleteProfile: () => deleteProfile,
4292
- listSavedJobs: () => listSavedJobs,
4293
- profileToFingerprint: () => profileToFingerprint,
4294
- readProfile: () => readProfile,
4295
- removeSavedJob: () => removeSavedJob,
4296
- writeProfile: () => writeProfile
4297
- });
4298
- import {
4299
- createCipheriv as createCipheriv2,
4300
- createDecipheriv as createDecipheriv2,
4301
- randomBytes as randomBytes4
4302
- } from "crypto";
4303
- import {
4304
- readFileSync as readFileSync7,
4305
- writeFileSync as writeFileSync6,
4306
- mkdirSync as mkdirSync6,
4307
- existsSync as existsSync6
4308
- } from "fs";
4309
- import { join as join7 } from "path";
4310
- import { homedir as homedir6 } from "os";
4311
- async function loadKey2() {
4312
- try {
4313
- const kt = await import("keytar");
4314
- const stored = await kt.getPassword("terminalhire", "profile-key");
4315
- if (stored) {
4316
- return Buffer.from(stored, "hex");
4317
- }
4318
- const key2 = randomBytes4(KEY_BYTES2);
4319
- await kt.setPassword("terminalhire", "profile-key", key2.toString("hex"));
4320
- return key2;
4321
- } catch {
4322
- }
4323
- mkdirSync6(TERMINALHIRE_DIR5, { recursive: true });
4324
- if (existsSync6(KEY_FILE2)) {
4325
- return Buffer.from(readFileSync7(KEY_FILE2, "utf8").trim(), "hex");
4326
- }
4327
- const key = randomBytes4(KEY_BYTES2);
4328
- writeFileSync6(KEY_FILE2, key.toString("hex"), { mode: 384, encoding: "utf8" });
4329
- return key;
4330
- }
4331
- function encrypt2(plaintext, key) {
4332
- const iv = randomBytes4(IV_BYTES2);
4333
- const cipher = createCipheriv2(ALGO2, key, iv);
4334
- const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
4335
- const tag = cipher.getAuthTag();
4336
- return {
4337
- iv: iv.toString("hex"),
4338
- tag: tag.toString("hex"),
4339
- ciphertext: ct.toString("hex")
4340
- };
4341
- }
4342
- function decrypt2(blob, key) {
4343
- const decipher = createDecipheriv2(
4344
- ALGO2,
4345
- key,
4346
- Buffer.from(blob.iv, "hex")
4347
- );
4348
- decipher.setAuthTag(Buffer.from(blob.tag, "hex"));
4349
- const plain = Buffer.concat([
4350
- decipher.update(Buffer.from(blob.ciphertext, "hex")),
4351
- decipher.final()
4352
- ]);
4353
- return plain.toString("utf8");
4354
- }
4355
- function blankProfile() {
4356
- return {
4357
- version: 3,
4358
- skillTags: [],
4359
- tagWeights: {},
4360
- hasEmployerSessions: false,
4361
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4362
- };
4363
- }
4364
- function recencyDecay(lastSeen) {
4365
- const ageMs = Date.now() - new Date(lastSeen).getTime();
4366
- return Math.pow(0.5, ageMs / DECAY_HALF_LIFE_MS);
4367
- }
4368
- function tagScore(w) {
4369
- return w.count * recencyDecay(w.lastSeen);
4370
- }
4371
- function deriveSkillTags(tagWeights) {
4372
- return Object.entries(tagWeights).filter(([, w]) => w.count >= 1).sort(([, a], [, b]) => tagScore(b) - tagScore(a)).map(([tag]) => tag);
4373
- }
4374
- function migrateTagWeights(profile) {
4375
- if (!profile.tagWeights) {
4376
- profile.tagWeights = {};
4377
- }
4378
- const seed = profile.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
4379
- for (const tag of profile.skillTags) {
4380
- if (!profile.tagWeights[tag]) {
4381
- profile.tagWeights[tag] = { count: 1, firstSeen: seed, lastSeen: seed, sessions: 1 };
4382
- }
4383
- }
4384
- }
4385
- async function readProfile() {
4386
- if (!existsSync6(PROFILE_FILE)) return blankProfile();
4387
- try {
4388
- const key = await loadKey2();
4389
- const raw = readFileSync7(PROFILE_FILE, "utf8");
4390
- const blob = JSON.parse(raw);
4391
- const plaintext = decrypt2(blob, key);
4392
- const parsed = JSON.parse(plaintext);
4393
- migrateTagWeights(parsed);
4394
- return parsed;
4395
- } catch {
4396
- return blankProfile();
4397
- }
4398
- }
4399
- async function writeProfile(profile) {
4400
- mkdirSync6(TERMINALHIRE_DIR5, { recursive: true });
4401
- const key = await loadKey2();
4402
- profile.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4403
- profile.skillTags = deriveSkillTags(profile.tagWeights);
4404
- const blob = encrypt2(JSON.stringify(profile), key);
4405
- writeFileSync6(PROFILE_FILE, JSON.stringify(blob, null, 2), { encoding: "utf8" });
4406
- }
4407
- function accumulateSession(profile, tags, isEmployerContext, inferredSeniority, seniorityIsAuthoritative = false) {
4408
- const now = (/* @__PURE__ */ new Date()).toISOString();
4409
- let filtered = normalize(tags);
4410
- if (isEmployerContext) {
4411
- filtered = filtered.filter((t) => LANGUAGE_TAGS.has(t));
4412
- profile.hasEmployerSessions = true;
4413
- }
4414
- for (const tag of filtered) {
4415
- const existing = profile.tagWeights[tag];
4416
- if (existing) {
4417
- existing.count += 1;
4418
- existing.sessions += 1;
4419
- existing.lastSeen = now;
4420
- } else {
4421
- profile.tagWeights[tag] = { count: 1, firstSeen: now, lastSeen: now, sessions: 1 };
4422
- }
4423
- }
4424
- if (inferredSeniority && !isEmployerContext) {
4425
- if (seniorityIsAuthoritative || !profile.github) {
4426
- profile.seniority = inferredSeniority;
4427
- }
4428
- }
4429
- }
4430
- async function accumulateTags(rawTokens, isEmployerContext, inferredSeniority) {
4431
- const profile = await readProfile();
4432
- accumulateSession(profile, rawTokens, isEmployerContext, inferredSeniority);
4433
- await writeProfile(profile);
4434
- }
4435
- function accumulateGitHubTags(profile, tags, inferredSeniority) {
4436
- accumulateSession(
4437
- profile,
4438
- tags,
4439
- /* isEmployerContext */
4440
- false,
4441
- inferredSeniority,
4442
- true
4443
- );
4444
- }
4445
- async function listSavedJobs() {
4446
- const profile = await readProfile();
4447
- return profile.savedJobs ?? [];
4448
- }
4449
- async function addSavedJob(job) {
4450
- const profile = await readProfile();
4451
- const existing = profile.savedJobs ?? [];
4452
- const filtered = existing.filter((j) => j.id !== job.id);
4453
- profile.savedJobs = [...filtered, { ...job, savedAt: (/* @__PURE__ */ new Date()).toISOString() }];
4454
- await writeProfile(profile);
4455
- }
4456
- async function removeSavedJob(id) {
4457
- const profile = await readProfile();
4458
- const existing = profile.savedJobs ?? [];
4459
- const filtered = existing.filter((j) => j.id !== id);
4460
- if (filtered.length === existing.length) return false;
4461
- profile.savedJobs = filtered;
4462
- await writeProfile(profile);
4463
- return true;
4464
- }
4465
- async function deleteProfile() {
4466
- const { rmSync: rmSync4 } = await import("fs");
4467
- try {
4468
- rmSync4(PROFILE_FILE);
4469
- } catch {
4470
- }
4471
- try {
4472
- rmSync4(KEY_FILE2);
4473
- } catch {
4474
- }
4475
- }
4476
- function profileToFingerprint(profile) {
4477
- const rankedTags = Object.entries(profile.tagWeights).map(([tag, w]) => ({ tag, score: tagScore(w) })).filter(({ score }) => score >= MIN_FINGERPRINT_SCORE).sort((a, b) => b.score - a.score).map(({ tag }) => tag);
4478
- const skillTags = rankedTags.length > 0 ? rankedTags : profile.skillTags;
4479
- return {
4480
- skillTags,
4481
- seniorityBand: profile.seniority,
4482
- prefs: {
4483
- roleTypes: profile.roleTypes,
4484
- remoteOnly: profile.remoteOnly,
4485
- compFloorUsd: profile.compFloorUsd
4486
- }
4487
- };
4488
- }
4489
- var TERMINALHIRE_DIR5, PROFILE_FILE, KEY_FILE2, ALGO2, KEY_BYTES2, IV_BYTES2, DECAY_HALF_LIFE_MS, LANGUAGE_TAGS, MIN_FINGERPRINT_SCORE;
4490
- var init_profile = __esm({
4491
- "src/profile.ts"() {
4492
- "use strict";
4493
- init_src();
4494
- TERMINALHIRE_DIR5 = join7(homedir6(), ".terminalhire");
4495
- PROFILE_FILE = join7(TERMINALHIRE_DIR5, "profile.enc");
4496
- KEY_FILE2 = join7(TERMINALHIRE_DIR5, "key");
4497
- ALGO2 = "aes-256-gcm";
4498
- KEY_BYTES2 = 32;
4499
- IV_BYTES2 = 12;
4500
- DECAY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1e3;
4501
- LANGUAGE_TAGS = /* @__PURE__ */ new Set([
4502
- "typescript",
4503
- "javascript",
4504
- "python",
4505
- "go",
4506
- "rust",
4507
- "java",
4508
- "ruby",
4509
- "elixir",
4510
- "scala",
4511
- "kotlin",
4512
- "swift",
4513
- "cpp",
4514
- "csharp",
4515
- "php",
4516
- "haskell",
4517
- "clojure",
4518
- "r"
4519
- ]);
4520
- MIN_FINGERPRINT_SCORE = 0.05;
4521
- }
4522
- });
4523
-
4524
4284
  // bin/jpi-chat-read.js
4525
4285
  var jpi_chat_read_exports = {};
4526
4286
  __export(jpi_chat_read_exports, {
@@ -4536,16 +4296,16 @@ __export(jpi_chat_read_exports, {
4536
4296
  syncUnreadBadge: () => syncUnreadBadge,
4537
4297
  writeReadCursor: () => writeReadCursor
4538
4298
  });
4539
- import { existsSync as existsSync7, mkdirSync as mkdirSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
4540
- import { homedir as homedir7 } from "os";
4541
- import { join as join8 } from "path";
4299
+ import { existsSync as existsSync6, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
4300
+ import { homedir as homedir6 } from "os";
4301
+ import { join as join7 } from "path";
4542
4302
  async function syncUnreadBadge(deps = {}) {
4543
4303
  const readCookie = deps.readCookie ?? readWebSessionCookie;
4544
4304
  const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
4545
4305
  const cacheFile = deps.cacheFile ?? INDEX_CACHE_FILE;
4546
4306
  try {
4547
4307
  const cookie = readCookie();
4548
- if (!cookie || !existsSync7(cacheFile)) return;
4308
+ if (!cookie || !existsSync6(cacheFile)) return;
4549
4309
  const res = await fetchImpl(`${CHAT_BASE2}/api/chat/inbox`, {
4550
4310
  method: "GET",
4551
4311
  headers: { Cookie: `${GH_SESSION_COOKIE2}=${cookie}` },
@@ -4558,16 +4318,16 @@ async function syncUnreadBadge(deps = {}) {
4558
4318
  (sum, it) => sum + (it && typeof it.unreadCount === "number" && it.unreadCount > 0 ? it.unreadCount : 0),
4559
4319
  0
4560
4320
  );
4561
- const entry = JSON.parse(readFileSync8(cacheFile, "utf8"));
4321
+ const entry = JSON.parse(readFileSync7(cacheFile, "utf8"));
4562
4322
  entry.unreadChat = { count: total };
4563
- writeFileSync7(cacheFile, JSON.stringify(entry), "utf8");
4323
+ writeFileSync6(cacheFile, JSON.stringify(entry), "utf8");
4564
4324
  } catch {
4565
4325
  }
4566
4326
  }
4567
4327
  function readReadCursors() {
4568
4328
  try {
4569
- if (!existsSync7(READS_FILE)) return {};
4570
- const parsed = JSON.parse(readFileSync8(READS_FILE, "utf8"));
4329
+ if (!existsSync6(READS_FILE)) return {};
4330
+ const parsed = JSON.parse(readFileSync7(READS_FILE, "utf8"));
4571
4331
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
4572
4332
  const out = {};
4573
4333
  for (const [login, iso] of Object.entries(parsed)) {
@@ -4581,9 +4341,11 @@ function readReadCursors() {
4581
4341
  function writeReadCursor(login, iso, deps = {}) {
4582
4342
  const read = deps.readReadCursors ?? readReadCursors;
4583
4343
  const cursors = read();
4344
+ const prev = cursors[login];
4345
+ if (prev && iso <= prev) return;
4584
4346
  cursors[login] = iso;
4585
- mkdirSync7(TERMINALHIRE_DIR6, { recursive: true });
4586
- writeFileSync7(READS_FILE, JSON.stringify(cursors, null, 2), { mode: 384, encoding: "utf8" });
4347
+ mkdirSync6(TERMINALHIRE_DIR5, { recursive: true });
4348
+ writeFileSync6(READS_FILE, JSON.stringify(cursors, null, 2), { mode: 384, encoding: "utf8" });
4587
4349
  }
4588
4350
  async function postReadCursor(peerLogin, lastReadAt, deps = {}) {
4589
4351
  const readCookie = deps.readCookie ?? readWebSessionCookie;
@@ -4888,7 +4650,7 @@ async function runSend(opts = {}) {
4888
4650
  );
4889
4651
  return { ok: true };
4890
4652
  }
4891
- var CHAT_BASE2, GH_SESSION_COOKIE2, TERMINALHIRE_DIR6, READS_FILE, INDEX_CACHE_FILE, REACHABLE_DISPLAY;
4653
+ var CHAT_BASE2, GH_SESSION_COOKIE2, TERMINALHIRE_DIR5, READS_FILE, INDEX_CACHE_FILE, REACHABLE_DISPLAY;
4892
4654
  var init_jpi_chat_read = __esm({
4893
4655
  "bin/jpi-chat-read.js"() {
4894
4656
  "use strict";
@@ -4897,13 +4659,253 @@ var init_jpi_chat_read = __esm({
4897
4659
  init_jpi_chat();
4898
4660
  CHAT_BASE2 = process.env["TERMINALHIRE_API_URL"] || "https://www.terminalhire.com";
4899
4661
  GH_SESSION_COOKIE2 = "__jpi_gh_session";
4900
- TERMINALHIRE_DIR6 = join8(homedir7(), ".terminalhire");
4901
- READS_FILE = join8(TERMINALHIRE_DIR6, "chat-reads.json");
4902
- INDEX_CACHE_FILE = join8(TERMINALHIRE_DIR6, "index-cache.json");
4662
+ TERMINALHIRE_DIR5 = process.env.TERMINALHIRE_DIR || join7(homedir6(), ".terminalhire");
4663
+ READS_FILE = join7(TERMINALHIRE_DIR5, "chat-reads.json");
4664
+ INDEX_CACHE_FILE = join7(TERMINALHIRE_DIR5, "index-cache.json");
4903
4665
  REACHABLE_DISPLAY = { shareActivity: false, optin: false, lastSeen: null };
4904
4666
  }
4905
4667
  });
4906
4668
 
4669
+ // src/profile.ts
4670
+ var profile_exports = {};
4671
+ __export(profile_exports, {
4672
+ accumulateGitHubTags: () => accumulateGitHubTags,
4673
+ accumulateSession: () => accumulateSession,
4674
+ accumulateTags: () => accumulateTags,
4675
+ addSavedJob: () => addSavedJob,
4676
+ deleteProfile: () => deleteProfile,
4677
+ listSavedJobs: () => listSavedJobs,
4678
+ profileToFingerprint: () => profileToFingerprint,
4679
+ readProfile: () => readProfile,
4680
+ removeSavedJob: () => removeSavedJob,
4681
+ writeProfile: () => writeProfile
4682
+ });
4683
+ import {
4684
+ createCipheriv as createCipheriv2,
4685
+ createDecipheriv as createDecipheriv2,
4686
+ randomBytes as randomBytes4
4687
+ } from "crypto";
4688
+ import {
4689
+ readFileSync as readFileSync8,
4690
+ writeFileSync as writeFileSync7,
4691
+ mkdirSync as mkdirSync7,
4692
+ existsSync as existsSync7
4693
+ } from "fs";
4694
+ import { join as join8 } from "path";
4695
+ import { homedir as homedir7 } from "os";
4696
+ async function loadKey2() {
4697
+ try {
4698
+ const kt = await import("keytar");
4699
+ const stored = await kt.getPassword("terminalhire", "profile-key");
4700
+ if (stored) {
4701
+ return Buffer.from(stored, "hex");
4702
+ }
4703
+ const key2 = randomBytes4(KEY_BYTES2);
4704
+ await kt.setPassword("terminalhire", "profile-key", key2.toString("hex"));
4705
+ return key2;
4706
+ } catch {
4707
+ }
4708
+ mkdirSync7(TERMINALHIRE_DIR6, { recursive: true });
4709
+ if (existsSync7(KEY_FILE2)) {
4710
+ return Buffer.from(readFileSync8(KEY_FILE2, "utf8").trim(), "hex");
4711
+ }
4712
+ const key = randomBytes4(KEY_BYTES2);
4713
+ writeFileSync7(KEY_FILE2, key.toString("hex"), { mode: 384, encoding: "utf8" });
4714
+ return key;
4715
+ }
4716
+ function encrypt2(plaintext, key) {
4717
+ const iv = randomBytes4(IV_BYTES2);
4718
+ const cipher = createCipheriv2(ALGO2, key, iv);
4719
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
4720
+ const tag = cipher.getAuthTag();
4721
+ return {
4722
+ iv: iv.toString("hex"),
4723
+ tag: tag.toString("hex"),
4724
+ ciphertext: ct.toString("hex")
4725
+ };
4726
+ }
4727
+ function decrypt2(blob, key) {
4728
+ const decipher = createDecipheriv2(
4729
+ ALGO2,
4730
+ key,
4731
+ Buffer.from(blob.iv, "hex")
4732
+ );
4733
+ decipher.setAuthTag(Buffer.from(blob.tag, "hex"));
4734
+ const plain = Buffer.concat([
4735
+ decipher.update(Buffer.from(blob.ciphertext, "hex")),
4736
+ decipher.final()
4737
+ ]);
4738
+ return plain.toString("utf8");
4739
+ }
4740
+ function blankProfile() {
4741
+ return {
4742
+ version: 3,
4743
+ skillTags: [],
4744
+ tagWeights: {},
4745
+ hasEmployerSessions: false,
4746
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
4747
+ };
4748
+ }
4749
+ function recencyDecay(lastSeen) {
4750
+ const ageMs = Date.now() - new Date(lastSeen).getTime();
4751
+ return Math.pow(0.5, ageMs / DECAY_HALF_LIFE_MS);
4752
+ }
4753
+ function tagScore(w) {
4754
+ return w.count * recencyDecay(w.lastSeen);
4755
+ }
4756
+ function deriveSkillTags(tagWeights) {
4757
+ return Object.entries(tagWeights).filter(([, w]) => w.count >= 1).sort(([, a], [, b]) => tagScore(b) - tagScore(a)).map(([tag]) => tag);
4758
+ }
4759
+ function migrateTagWeights(profile) {
4760
+ if (!profile.tagWeights) {
4761
+ profile.tagWeights = {};
4762
+ }
4763
+ const seed = profile.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
4764
+ for (const tag of profile.skillTags) {
4765
+ if (!profile.tagWeights[tag]) {
4766
+ profile.tagWeights[tag] = { count: 1, firstSeen: seed, lastSeen: seed, sessions: 1 };
4767
+ }
4768
+ }
4769
+ }
4770
+ async function readProfile() {
4771
+ if (!existsSync7(PROFILE_FILE)) return blankProfile();
4772
+ try {
4773
+ const key = await loadKey2();
4774
+ const raw = readFileSync8(PROFILE_FILE, "utf8");
4775
+ const blob = JSON.parse(raw);
4776
+ const plaintext = decrypt2(blob, key);
4777
+ const parsed = JSON.parse(plaintext);
4778
+ migrateTagWeights(parsed);
4779
+ return parsed;
4780
+ } catch {
4781
+ return blankProfile();
4782
+ }
4783
+ }
4784
+ async function writeProfile(profile) {
4785
+ mkdirSync7(TERMINALHIRE_DIR6, { recursive: true });
4786
+ const key = await loadKey2();
4787
+ profile.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4788
+ profile.skillTags = deriveSkillTags(profile.tagWeights);
4789
+ const blob = encrypt2(JSON.stringify(profile), key);
4790
+ writeFileSync7(PROFILE_FILE, JSON.stringify(blob, null, 2), { encoding: "utf8" });
4791
+ }
4792
+ function accumulateSession(profile, tags, isEmployerContext, inferredSeniority, seniorityIsAuthoritative = false) {
4793
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4794
+ let filtered = normalize(tags);
4795
+ if (isEmployerContext) {
4796
+ filtered = filtered.filter((t) => LANGUAGE_TAGS.has(t));
4797
+ profile.hasEmployerSessions = true;
4798
+ }
4799
+ for (const tag of filtered) {
4800
+ const existing = profile.tagWeights[tag];
4801
+ if (existing) {
4802
+ existing.count += 1;
4803
+ existing.sessions += 1;
4804
+ existing.lastSeen = now;
4805
+ } else {
4806
+ profile.tagWeights[tag] = { count: 1, firstSeen: now, lastSeen: now, sessions: 1 };
4807
+ }
4808
+ }
4809
+ if (inferredSeniority && !isEmployerContext) {
4810
+ if (seniorityIsAuthoritative || !profile.github) {
4811
+ profile.seniority = inferredSeniority;
4812
+ }
4813
+ }
4814
+ }
4815
+ async function accumulateTags(rawTokens, isEmployerContext, inferredSeniority) {
4816
+ const profile = await readProfile();
4817
+ accumulateSession(profile, rawTokens, isEmployerContext, inferredSeniority);
4818
+ await writeProfile(profile);
4819
+ }
4820
+ function accumulateGitHubTags(profile, tags, inferredSeniority) {
4821
+ accumulateSession(
4822
+ profile,
4823
+ tags,
4824
+ /* isEmployerContext */
4825
+ false,
4826
+ inferredSeniority,
4827
+ true
4828
+ );
4829
+ }
4830
+ async function listSavedJobs() {
4831
+ const profile = await readProfile();
4832
+ return profile.savedJobs ?? [];
4833
+ }
4834
+ async function addSavedJob(job) {
4835
+ const profile = await readProfile();
4836
+ const existing = profile.savedJobs ?? [];
4837
+ const filtered = existing.filter((j) => j.id !== job.id);
4838
+ profile.savedJobs = [...filtered, { ...job, savedAt: (/* @__PURE__ */ new Date()).toISOString() }];
4839
+ await writeProfile(profile);
4840
+ }
4841
+ async function removeSavedJob(id) {
4842
+ const profile = await readProfile();
4843
+ const existing = profile.savedJobs ?? [];
4844
+ const filtered = existing.filter((j) => j.id !== id);
4845
+ if (filtered.length === existing.length) return false;
4846
+ profile.savedJobs = filtered;
4847
+ await writeProfile(profile);
4848
+ return true;
4849
+ }
4850
+ async function deleteProfile() {
4851
+ const { rmSync: rmSync4 } = await import("fs");
4852
+ try {
4853
+ rmSync4(PROFILE_FILE);
4854
+ } catch {
4855
+ }
4856
+ try {
4857
+ rmSync4(KEY_FILE2);
4858
+ } catch {
4859
+ }
4860
+ }
4861
+ function profileToFingerprint(profile) {
4862
+ const rankedTags = Object.entries(profile.tagWeights).map(([tag, w]) => ({ tag, score: tagScore(w) })).filter(({ score }) => score >= MIN_FINGERPRINT_SCORE).sort((a, b) => b.score - a.score).map(({ tag }) => tag);
4863
+ const skillTags = rankedTags.length > 0 ? rankedTags : profile.skillTags;
4864
+ return {
4865
+ skillTags,
4866
+ seniorityBand: profile.seniority,
4867
+ prefs: {
4868
+ roleTypes: profile.roleTypes,
4869
+ remoteOnly: profile.remoteOnly,
4870
+ compFloorUsd: profile.compFloorUsd
4871
+ }
4872
+ };
4873
+ }
4874
+ var TERMINALHIRE_DIR6, PROFILE_FILE, KEY_FILE2, ALGO2, KEY_BYTES2, IV_BYTES2, DECAY_HALF_LIFE_MS, LANGUAGE_TAGS, MIN_FINGERPRINT_SCORE;
4875
+ var init_profile = __esm({
4876
+ "src/profile.ts"() {
4877
+ "use strict";
4878
+ init_src();
4879
+ TERMINALHIRE_DIR6 = join8(homedir7(), ".terminalhire");
4880
+ PROFILE_FILE = join8(TERMINALHIRE_DIR6, "profile.enc");
4881
+ KEY_FILE2 = join8(TERMINALHIRE_DIR6, "key");
4882
+ ALGO2 = "aes-256-gcm";
4883
+ KEY_BYTES2 = 32;
4884
+ IV_BYTES2 = 12;
4885
+ DECAY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1e3;
4886
+ LANGUAGE_TAGS = /* @__PURE__ */ new Set([
4887
+ "typescript",
4888
+ "javascript",
4889
+ "python",
4890
+ "go",
4891
+ "rust",
4892
+ "java",
4893
+ "ruby",
4894
+ "elixir",
4895
+ "scala",
4896
+ "kotlin",
4897
+ "swift",
4898
+ "cpp",
4899
+ "csharp",
4900
+ "php",
4901
+ "haskell",
4902
+ "clojure",
4903
+ "r"
4904
+ ]);
4905
+ MIN_FINGERPRINT_SCORE = 0.05;
4906
+ }
4907
+ });
4908
+
4907
4909
  // bin/jpi-chat.js
4908
4910
  import { createInterface } from "readline";
4909
4911
  import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
@@ -5075,7 +5077,7 @@ function mergeMessages(existing, incoming) {
5075
5077
  }
5076
5078
  function readCachedSessionStale() {
5077
5079
  try {
5078
- const p = join9(homedir8(), ".terminalhire", "index-cache.json");
5080
+ const p = join9(process.env.TERMINALHIRE_DIR || join9(homedir8(), ".terminalhire"), "index-cache.json");
5079
5081
  if (!existsSync8(p)) return false;
5080
5082
  const cache = JSON.parse(readFileSync9(p, "utf8"));
5081
5083
  return cache?.sessionStale === true;
@@ -5083,6 +5085,14 @@ function readCachedSessionStale() {
5083
5085
  return false;
5084
5086
  }
5085
5087
  }
5088
+ async function defaultMarkThreadRead(peerLogin, iso) {
5089
+ const { writeReadCursor: writeReadCursor2, postReadCursor: postReadCursor2, syncUnreadBadge: syncUnreadBadge2 } = await Promise.resolve().then(() => (init_jpi_chat_read(), jpi_chat_read_exports));
5090
+ writeReadCursor2(peerLogin, iso);
5091
+ await syncUnreadBadge2().catch(() => {
5092
+ });
5093
+ await postReadCursor2(peerLogin, iso).catch(() => {
5094
+ });
5095
+ }
5086
5096
  async function runChatPane(opts = {}) {
5087
5097
  const {
5088
5098
  login,
@@ -5093,7 +5103,8 @@ async function runChatPane(opts = {}) {
5093
5103
  signals = process,
5094
5104
  pollIntervalMs = 1500,
5095
5105
  setTimer = (fn, ms) => setInterval(fn, ms),
5096
- clearTimer = (t) => clearInterval(t)
5106
+ clearTimer = (t) => clearInterval(t),
5107
+ markRead = defaultMarkThreadRead
5097
5108
  } = opts;
5098
5109
  const target = String(login ?? "").replace(/^@/, "").trim();
5099
5110
  if (!target) {
@@ -5210,6 +5221,7 @@ async function runChatPane(opts = {}) {
5210
5221
  let presence = null;
5211
5222
  let banner = "";
5212
5223
  let lastSeen;
5224
+ let lastReadMarked;
5213
5225
  let timer = null;
5214
5226
  let polling = false;
5215
5227
  let cleaned = false;
@@ -5281,6 +5293,20 @@ async function runChatPane(opts = {}) {
5281
5293
  messages = mergeMessages(messages, fresh);
5282
5294
  const newest = fresh[fresh.length - 1];
5283
5295
  if (newest && newest.createdAt) lastSeen = newest.createdAt;
5296
+ let newestIncoming;
5297
+ for (const m of fresh) {
5298
+ if (m && m.senderLogin === peerLogin && m.createdAt && (!newestIncoming || m.createdAt > newestIncoming)) {
5299
+ newestIncoming = m.createdAt;
5300
+ }
5301
+ }
5302
+ if (newestIncoming && (!lastReadMarked || newestIncoming > lastReadMarked)) {
5303
+ lastReadMarked = newestIncoming;
5304
+ try {
5305
+ void Promise.resolve(markRead(peerLogin, newestIncoming)).catch(() => {
5306
+ });
5307
+ } catch {
5308
+ }
5309
+ }
5284
5310
  if (banner && !banner.startsWith("\u26A0")) banner = "";
5285
5311
  }
5286
5312
  try {
@@ -109,7 +109,7 @@ import { homedir as homedir2 } from "os";
109
109
  import { execFile } from "child_process";
110
110
  import { promisify } from "util";
111
111
  import { createInterface } from "readline";
112
- var TERMINALHIRE_DIR2 = join2(homedir2(), ".terminalhire");
112
+ var TERMINALHIRE_DIR2 = process.env.TERMINALHIRE_DIR || join2(homedir2(), ".terminalhire");
113
113
  var INDEX_CACHE_FILE = join2(TERMINALHIRE_DIR2, "index-cache.json");
114
114
  var GH_API = "https://api.github.com";
115
115
  var GH_HEADERS = { "User-Agent": "terminalhire-claim", Accept: "application/vnd.github+json" };