@indigoai-us/hq-cloud 6.14.30 → 6.14.32

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.
@@ -15,9 +15,10 @@
15
15
  * script inside the synced HQ tree (which may be stale).
16
16
  *
17
17
  * What it does, best-effort and idempotent:
18
- * 1. Auto-registers any `companies/<slug>/knowledge` dir that isn't yet a
19
- * qmd collection (kills the manual "map" step). Detects path drift when
20
- * the name exists but points elsewhere; repairs only when
18
+ * 1. Auto-registers populated company knowledge, company project, and
19
+ * personal knowledge dirs that aren't yet qmd collections (kills the
20
+ * manual "map" step). Company knowledge detects path drift when the name
21
+ * exists but points elsewhere; repairs only when
21
22
  * `HQ_QMD_REPAIR_PATH_DRIFT=1` (non-breaking default: detect-only).
22
23
  * 2. Runs an incremental lexical `qmd update` (fast — qmd skips unchanged
23
24
  * files by mtime).
@@ -395,6 +396,8 @@ export interface ReindexOptions {
395
396
  readCompanies?: (companiesDir: string) => string[];
396
397
  /** Returns true if the knowledge dir has at least one indexable .md file. */
397
398
  hasIndexableMarkdown?: (knowledgeDir: string) => boolean;
399
+ /** Returns true if the projects dir has at least one indexable .md or .json file. */
400
+ hasIndexableProjectContent?: (projectsDir: string) => boolean;
398
401
  /** Reindex-lock acquirer override for tests. */
399
402
  acquireReindexLock?: AcquireReindexLock;
400
403
  /** Corrupt-index quarantine override for tests. */
@@ -474,7 +477,7 @@ export function reindexAfterSync(
474
477
  const changedPaths = opts.changedPaths;
475
478
  const dirtyFromChanges =
476
479
  changedPaths === undefined ||
477
- changedPaths.some(isKnowledgeMarkdownPath);
480
+ changedPaths.some(isQmdIndexableContentPath);
478
481
  const registrationMayBeStale =
479
482
  opts.forceCollectionRefresh === true ||
480
483
  changedPaths === undefined ||
@@ -528,6 +531,9 @@ export function reindexAfterSync(
528
531
  result.qmdAvailable = true;
529
532
  const existingCollections = list.stdout;
530
533
  let repairedThisCycle = false;
534
+ const hasIndexableMarkdown = opts.hasIndexableMarkdown ?? defaultHasIndexableMarkdown;
535
+ const hasIndexableProjectContent =
536
+ opts.hasIndexableProjectContent ?? defaultHasIndexableProjectContent;
531
537
 
532
538
  // 1. Auto-register missing / reconcile drifted company knowledge collections.
533
539
  if (registrationMayBeStale) {
@@ -537,7 +543,7 @@ export function reindexAfterSync(
537
543
  for (const slug of slugs) {
538
544
  const knowledgeDir = path.join(companiesDir, slug, "knowledge");
539
545
  if (!existsSync(knowledgeDir)) continue;
540
- const hasMd = (opts.hasIndexableMarkdown ?? defaultHasIndexableMarkdown)(knowledgeDir);
546
+ const hasMd = hasIndexableMarkdown(knowledgeDir);
541
547
  if (!hasMd) continue;
542
548
 
543
549
  const namePresent = existingCollections.includes(`qmd://${slug}/`);
@@ -601,6 +607,58 @@ export function reindexAfterSync(
601
607
  result.collectionsRepaired.push(slug);
602
608
  repairedThisCycle = true;
603
609
  }
610
+
611
+ // 1b. Auto-register company projects collections. Match the shell
612
+ // post-sync convention exactly so prd.json and project docs are searchable.
613
+ for (const slug of slugs) {
614
+ const projectsDir = path.join(companiesDir, slug, "projects");
615
+ if (!existsSync(projectsDir)) continue;
616
+ if (!hasIndexableProjectContent(projectsDir)) continue;
617
+
618
+ const name = `${slug}-projects`;
619
+ if (existingCollections.includes(`qmd://${name}/`)) continue;
620
+ const add = exec([
621
+ "collection",
622
+ "add",
623
+ projectsDir,
624
+ "--name",
625
+ name,
626
+ "--mask",
627
+ "**/*.{md,json}",
628
+ ]);
629
+ if (add.status === 0) {
630
+ exec(["context", "add", `qmd://${name}`, `Project PRDs and documentation for ${slug}.`]);
631
+ result.collectionsAdded.push(name);
632
+ }
633
+ }
634
+
635
+ // 1c. Auto-register the owner overlay's personal knowledge collection.
636
+ const personalKnowledgeDir = path.join(hqRoot, "personal", "knowledge");
637
+ const personalKnowledgeName = "personal-knowledge";
638
+ if (
639
+ existsSync(personalKnowledgeDir) &&
640
+ hasIndexableMarkdown(personalKnowledgeDir) &&
641
+ !existingCollections.includes(`qmd://${personalKnowledgeName}/`)
642
+ ) {
643
+ const add = exec([
644
+ "collection",
645
+ "add",
646
+ personalKnowledgeDir,
647
+ "--name",
648
+ personalKnowledgeName,
649
+ "--mask",
650
+ "**/*.md",
651
+ ]);
652
+ if (add.status === 0) {
653
+ exec([
654
+ "context",
655
+ "add",
656
+ `qmd://${personalKnowledgeName}`,
657
+ "Personal knowledge base (owner overlay).",
658
+ ]);
659
+ result.collectionsAdded.push(personalKnowledgeName);
660
+ }
661
+ }
604
662
  }
605
663
 
606
664
  // 2. Incremental lexical reindex.
@@ -721,9 +779,13 @@ function writeState(statePath: string, state: QmdReindexState): void {
721
779
  }
722
780
  }
723
781
 
724
- function isKnowledgeMarkdownPath(relPath: string): boolean {
782
+ function isQmdIndexableContentPath(relPath: string): boolean {
725
783
  const normalized = relPath.split(path.sep).join("/");
726
- return /^companies\/[^/]+\/knowledge\/.+\.md$/i.test(normalized);
784
+ return (
785
+ /^companies\/[^/]+\/knowledge\/.+\.md$/i.test(normalized) ||
786
+ /^companies\/[^/]+\/projects\/.+\.(?:md|json)$/i.test(normalized) ||
787
+ /^personal\/knowledge\/.+\.md$/i.test(normalized)
788
+ );
727
789
  }
728
790
 
729
791
  function defaultReadCompanies(companiesDir: string): string[] {
@@ -758,3 +820,23 @@ function defaultHasIndexableMarkdown(knowledgeDir: string): boolean {
758
820
  }
759
821
  return false;
760
822
  }
823
+
824
+ function defaultHasIndexableProjectContent(projectsDir: string): boolean {
825
+ // Match the shell's `find -type f \( -name '*.md' -o -name '*.json' \)`
826
+ // guard: project documentation and PRD JSON both make the collection useful.
827
+ const stack = [projectsDir];
828
+ while (stack.length) {
829
+ const dir = stack.pop()!;
830
+ let entries: fs.Dirent[];
831
+ try {
832
+ entries = fs.readdirSync(dir, { withFileTypes: true });
833
+ } catch {
834
+ continue;
835
+ }
836
+ for (const e of entries) {
837
+ if (e.isFile() && (e.name.endsWith(".md") || e.name.endsWith(".json"))) return true;
838
+ if (e.isDirectory()) stack.push(path.join(dir, e.name));
839
+ }
840
+ }
841
+ return false;
842
+ }
@@ -126,6 +126,8 @@ function makeOpts(env: TestEnv, client: TelemetryClientSurface): CollectTelemetr
126
126
  installerVersion: "test-version",
127
127
  claudeProjectsRoot: env.claudeProjects,
128
128
  codexRoot: env.codexRoot,
129
+ maxScanBytesPerSource: Number.MAX_SAFE_INTEGER,
130
+ maxBatchesPerRun: Number.MAX_SAFE_INTEGER,
129
131
  cursorPath: env.cursorPath,
130
132
  menubarPath: env.menubarPath,
131
133
  };
@@ -813,6 +815,96 @@ describe("collectAndSendTelemetry — Codex rollouts", () => {
813
815
  expect(cursor.codex_next_rollout).toBe(file);
814
816
  });
815
817
 
818
+ it("bounds a quiet first backfill and resumes until the token event", async () => {
819
+ const file = writeCodexRollout(env, "sessions", "rollout-bounded.jsonl", [
820
+ { type: "session_meta", payload: { id: "bounded", model: "gpt-5-codex" } },
821
+ ...Array.from({ length: 8 }, (_, index) => ({
822
+ type: "response_item",
823
+ payload: { type: "message", content: "x".repeat(140), index },
824
+ })),
825
+ codexToken("2026-07-30T14:00:00Z", "bounded-event", 5, 7),
826
+ ]);
827
+ const client = makeClient();
828
+ const opts = {
829
+ ...makeOpts(env, client),
830
+ maxScanBytesPerSource: 350,
831
+ };
832
+
833
+ const first = await collectAndSendTelemetry(opts);
834
+
835
+ expect(first.eventsSent).toBe(0);
836
+ const firstOffset = readCursor(env).files[file]?.offset ?? 0;
837
+ expect(firstOffset).toBeGreaterThan(0);
838
+ expect(firstOffset).toBeLessThan(fs.statSync(file).size);
839
+
840
+ for (let run = 0; run < 10 && client.posts.length === 0; run++) {
841
+ await collectAndSendTelemetry(opts);
842
+ }
843
+ expect(client.posts.flatMap((post) => post.events)).toEqual([
844
+ expect.objectContaining({
845
+ sessionId: "bounded",
846
+ uuid: "bounded-event",
847
+ model: "gpt-5-codex",
848
+ }),
849
+ ]);
850
+ });
851
+
852
+ it("discards an oversized UTF-8 row without corrupting resume offsets", async () => {
853
+ const file = writeCodexRollout(env, "sessions", "rollout-utf8.jsonl", [
854
+ { type: "session_meta", payload: { id: "utf8", model: "gpt-5-codex" } },
855
+ {
856
+ type: "response_item",
857
+ payload: { type: "message", content: "😀".repeat(20_000) },
858
+ },
859
+ codexToken("2026-07-30T14:00:01Z", "utf8-event", 2, 3),
860
+ ]);
861
+ const client = makeClient();
862
+ const opts = {
863
+ ...makeOpts(env, client),
864
+ maxScanBytesPerSource: 64 * 1024,
865
+ };
866
+
867
+ for (let run = 0; run < 5 && client.posts.length === 0; run++) {
868
+ await collectAndSendTelemetry(opts);
869
+ }
870
+
871
+ const events = client.posts.flatMap((post) => post.events);
872
+ expect(events).toEqual([
873
+ expect.objectContaining({ uuid: "utf8-event", sessionId: "utf8" }),
874
+ ]);
875
+ expect(readCursor(env).files[file].offset).toBeLessThanOrEqual(
876
+ fs.statSync(file).size,
877
+ );
878
+ });
879
+
880
+ it("caps upload batches and resumes remaining token events", async () => {
881
+ writeCodexRollout(env, "sessions", "rollout-batch-cap.jsonl", [
882
+ { type: "session_meta", payload: { id: "batch-cap", model: "gpt-5-codex" } },
883
+ ...Array.from({ length: 201 }, (_, index) =>
884
+ codexToken(
885
+ "2026-07-30T14:01:" + String(index % 60).padStart(2, "0") + "Z",
886
+ "batch-cap-" + index,
887
+ 1,
888
+ 1,
889
+ ),
890
+ ),
891
+ ]);
892
+ const client = makeClient();
893
+ const opts = {
894
+ ...makeOpts(env, client),
895
+ maxScanBytesPerSource: 1_000_000,
896
+ maxBatchesPerRun: 2,
897
+ };
898
+
899
+ const first = await collectAndSendTelemetry(opts);
900
+ expect(first.batchesSent).toBe(2);
901
+ expect(first.eventsSent).toBe(200);
902
+
903
+ const second = await collectAndSendTelemetry(opts);
904
+ expect(second.eventsSent).toBe(1);
905
+ expect(client.posts.flatMap((post) => post.events)).toHaveLength(201);
906
+ });
907
+
816
908
  it("does not advance a Codex rollout cursor when upload fails", async () => {
817
909
  const file = writeCodexRollout(env, "sessions", "rollout-retry.jsonl", [
818
910
  { type: "session_meta", payload: { id: "retry", model: "gpt-5-codex" } },
@@ -886,6 +978,35 @@ describe("collectAndSendTelemetry — companyUid attribution", () => {
886
978
  expect("companyUid" in client.posts[0].events[0]).toBe(false);
887
979
  });
888
980
 
981
+ it("uses the explicit single-company fallback when cwd is the shared HQ root", async () => {
982
+ const client = makeClient();
983
+ writeJsonl(env, "proj", "s.jsonl", [rowWithCwd("u1", hqRoot)]);
984
+
985
+ await collectAndSendTelemetry({
986
+ ...makeOpts(env, client),
987
+ hqRoot,
988
+ fallbackCompany: "indigo",
989
+ });
990
+
991
+ expect(client.posts).toHaveLength(1);
992
+ expect(client.posts[0].events[0].companyUid).toBe(COMPANY_UID);
993
+ });
994
+
995
+ it("prefers the cwd-resolved company over the single-company fallback", async () => {
996
+ const client = makeClient();
997
+ const inRepo = path.join(hqRoot, "repos/private/hq-cloud");
998
+ writeJsonl(env, "proj", "s.jsonl", [rowWithCwd("u1", inRepo)]);
999
+
1000
+ await collectAndSendTelemetry({
1001
+ ...makeOpts(env, client),
1002
+ hqRoot,
1003
+ fallbackCompany: "cmp_01OTHER",
1004
+ });
1005
+
1006
+ expect(client.posts).toHaveLength(1);
1007
+ expect(client.posts[0].events[0].companyUid).toBe(COMPANY_UID);
1008
+ });
1009
+
889
1010
  it("never sends the reserved 'unattributed' value", async () => {
890
1011
  const client = makeClient();
891
1012
  writeJsonl(env, "proj", "s.jsonl", [