pi-anycopy 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # anycopy for Pi (`pi-anycopy`)
2
2
 
3
- Browse session tree nodes with a live preview and copy any of them to the clipboard.
4
-
5
- By comparison to Pi's native `/copy` (copies only the last assistant message) and `/md` (bulk-exports the entire branch as a Markdown transcript), `/anycopy` allows you to navigate the full session tree, preview each node's content with syntax highlighting, and copy to the clipboard any node(s) from the tree.
3
+ This extension mirrors all the behaviors of Pi's native `/tree` while adding a live, syntax-highlighting preview of each node's content and the ability to copy any node(s) to the clipboard.
6
4
 
7
5
  <p align="center">
8
6
  <img width="450" alt="anycopy demo" src="https://raw.githubusercontent.com/w-winter/dot314/main/assets/anycopy-demo.gif" />
@@ -48,8 +46,9 @@ Defaults (customizable in `config.json`):
48
46
 
49
47
  | Key | Action |
50
48
  |-----|--------|
51
- | `Space` | Select/unselect focused node |
52
- | `Shift+C` | Copy selected nodes (or focused node if nothing is selected) |
49
+ | `Enter` | Navigate to the focused node (same semantics as `/tree`) |
50
+ | `Space` | Select/unselect focused node for copy |
51
+ | `Shift+C` | Copy selected nodes, or the focused node if nothing is selected |
53
52
  | `Shift+X` | Clear selection |
54
53
  | `Shift+L` | Label node (native tree behavior) |
55
54
  | `Shift+Up` / `Shift+Down` | Scroll node preview by line |
@@ -57,18 +56,24 @@ Defaults (customizable in `config.json`):
57
56
  | `Esc` | Close |
58
57
 
59
58
  Notes:
59
+ - `Enter` always navigates the focused node, not the marked set
60
+ - After `Enter`, `/anycopy` offers the same summary choices as `/tree`: `No summary`, `Summarize`, and `Summarize with custom prompt`
61
+ - If `branchSummary.skipPrompt` is `true` in Pi settings, `/anycopy` matches native `/tree` and skips the summary chooser, defaulting to no summary
62
+ - Escaping the summary chooser reopens `/anycopy` with focus restored to the node you tried to select
63
+ - Cancelling the custom summarization editor returns to the summary chooser
60
64
  - If no nodes are selected, `Shift+C` copies the focused node
61
- - When copying multiple selected nodes, they are auto-sorted chronologically (by position in the session tree), not by selection order
65
+ - Single-node copies use just that node's content; role prefixes like `user:` or `assistant:` are only added when copying 2 or more nodes
66
+ - When copying multiple selected nodes, they are auto-sorted chronologically by position in the session tree, not by selection order
67
+ - Space/`Shift+C` multi-select copy behavior is unchanged by navigation support
62
68
  - Label edits are persisted via `pi.setLabel(...)`
63
- - Despite reoffering node labeling (`/anycopy` is arguably a better UI than `/tree` to also perform this action in), this extension doesn't offer a full reproduction of `/tree`'s other features (e.g., branch switching and summarization are not included)
64
69
 
65
70
  ## Configuration
66
71
 
67
72
  Edit `~/.pi/agent/extensions/anycopy/config.json`:
68
73
 
69
- - `treeFilterMode`: initial tree filter mode when opening `/anycopy` (idea sourced from [lajarre](https://github.com/lajarre)'s [pi-mono/issues/1845](https://github.com/badlogic/pi-mono/issues/1845))
74
+ - `treeFilterMode`: initial tree filter mode when opening `/anycopy`; defaults to `default` to match `/tree`
70
75
  - one of: `default` | `no-tools` | `user-only` | `labeled-only` | `all`
71
- - `keys`: keybindings (see above)
76
+ - `keys`: keybindings used inside the `/anycopy` overlay for copy/preview actions
72
77
 
73
78
  ```json
74
79
  {
@@ -0,0 +1,101 @@
1
+ export type AnycopyEnterNavigationResult = "reopen" | "closed";
2
+
3
+ export type AnycopySummaryChoice = "No summary" | "Summarize" | "Summarize with custom prompt";
4
+
5
+ export type AnycopyEnterNavigationDeps = {
6
+ entryId: string;
7
+ effectiveLeafIdForNoop: string | null;
8
+ skipSummaryPrompt: boolean;
9
+ close: () => void;
10
+ reopen: (options: { initialSelectedId: string }) => void;
11
+ navigateTree: (
12
+ targetId: string,
13
+ options?: { summarize?: boolean; customInstructions?: string },
14
+ ) => Promise<{ cancelled: boolean; aborted?: boolean }>;
15
+ ui: {
16
+ select: (title: string, options: AnycopySummaryChoice[]) => Promise<AnycopySummaryChoice | undefined>;
17
+ editor: (title: string) => Promise<string | undefined>;
18
+ setStatus: (source: string, message: string) => void;
19
+ setWorkingMessage: (message?: string) => void;
20
+ notify: (message: string, level: "error") => void;
21
+ };
22
+ };
23
+
24
+ export async function runAnycopyEnterNavigation(
25
+ deps: AnycopyEnterNavigationDeps,
26
+ ): Promise<AnycopyEnterNavigationResult> {
27
+ const { entryId, effectiveLeafIdForNoop, skipSummaryPrompt, close, reopen, navigateTree, ui } = deps;
28
+
29
+ if (effectiveLeafIdForNoop !== null && entryId === effectiveLeafIdForNoop) {
30
+ close();
31
+ ui.setStatus("anycopy", "Already at this point");
32
+ return "closed";
33
+ }
34
+
35
+ close();
36
+
37
+ let wantsSummary = false;
38
+ let customInstructions: string | undefined;
39
+
40
+ if (!skipSummaryPrompt) {
41
+ while (true) {
42
+ const choice = await ui.select("Summarize branch?", [
43
+ "No summary",
44
+ "Summarize",
45
+ "Summarize with custom prompt",
46
+ ]);
47
+
48
+ if (choice === undefined) {
49
+ reopen({ initialSelectedId: entryId });
50
+ return "reopen";
51
+ }
52
+
53
+ if (choice === "No summary") {
54
+ wantsSummary = false;
55
+ customInstructions = undefined;
56
+ break;
57
+ }
58
+
59
+ if (choice === "Summarize") {
60
+ wantsSummary = true;
61
+ customInstructions = undefined;
62
+ break;
63
+ }
64
+
65
+ customInstructions = await ui.editor("Custom summarization instructions");
66
+ if (customInstructions === undefined) {
67
+ continue;
68
+ }
69
+
70
+ wantsSummary = true;
71
+ break;
72
+ }
73
+ }
74
+
75
+ ui.setWorkingMessage("Navigating tree…");
76
+
77
+ try {
78
+ const result = await navigateTree(entryId, {
79
+ summarize: wantsSummary,
80
+ customInstructions,
81
+ });
82
+
83
+ if (result.cancelled) {
84
+ if (wantsSummary) {
85
+ ui.setStatus("anycopy", "Branch summarization cancelled");
86
+ reopen({ initialSelectedId: entryId });
87
+ return "reopen";
88
+ }
89
+
90
+ ui.setStatus("anycopy", "Navigation cancelled");
91
+ return "closed";
92
+ }
93
+
94
+ return "closed";
95
+ } catch (error) {
96
+ ui.notify(error instanceof Error ? error.message : String(error), "error");
97
+ return "closed";
98
+ } finally {
99
+ ui.setWorkingMessage();
100
+ }
101
+ }
@@ -26,9 +26,12 @@ import { Markdown, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
26
26
  import type { Focusable } from "@mariozechner/pi-tui";
27
27
 
28
28
  import { existsSync, readFileSync } from "fs";
29
+ import { homedir } from "os";
29
30
  import { dirname, join } from "path";
30
31
  import { fileURLToPath } from "url";
31
32
 
33
+ import { runAnycopyEnterNavigation } from "./enter-navigation.ts";
34
+
32
35
  type SessionTreeNode = {
33
36
  entry: SessionEntry;
34
37
  children: SessionTreeNode[];
@@ -57,6 +60,12 @@ type anycopyRuntimeConfig = {
57
60
  treeFilterMode: TreeFilterMode;
58
61
  };
59
62
 
63
+ type BranchSummarySettingsFile = {
64
+ branchSummary?: {
65
+ skipPrompt?: boolean;
66
+ };
67
+ };
68
+
60
69
  const DEFAULT_KEYS: anycopyKeyConfig = {
61
70
  toggleSelect: "space",
62
71
  copy: "shift+c",
@@ -75,10 +84,35 @@ const getExtensionDir = (): string => {
75
84
  return dirname(fileURLToPath(import.meta.url));
76
85
  };
77
86
 
87
+ const getAgentDir = (): string => process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
88
+
89
+ const readJsonFile = <T>(path: string): T | undefined => {
90
+ if (!existsSync(path)) return undefined;
91
+
92
+ try {
93
+ return JSON.parse(readFileSync(path, "utf8")) as T;
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ };
98
+
99
+ const loadBranchSummarySkipPrompt = (cwd: string): boolean => {
100
+ const globalSettings = readJsonFile<BranchSummarySettingsFile>(join(getAgentDir(), "settings.json"));
101
+ const projectSettings = readJsonFile<BranchSummarySettingsFile>(join(cwd, ".pi", "settings.json"));
102
+ const projectSkipPrompt = projectSettings?.branchSummary?.skipPrompt;
103
+ if (typeof projectSkipPrompt === "boolean") return projectSkipPrompt;
104
+
105
+ const globalSkipPrompt = globalSettings?.branchSummary?.skipPrompt;
106
+ return typeof globalSkipPrompt === "boolean" ? globalSkipPrompt : false;
107
+ };
108
+
78
109
  const loadConfig = (): anycopyRuntimeConfig => {
79
110
  const configPath = join(getExtensionDir(), "config.json");
80
111
  if (!existsSync(configPath)) {
81
- return { keys: { ...DEFAULT_KEYS }, treeFilterMode: DEFAULT_TREE_FILTER_MODE };
112
+ return {
113
+ keys: { ...DEFAULT_KEYS },
114
+ treeFilterMode: DEFAULT_TREE_FILTER_MODE,
115
+ };
82
116
  }
83
117
 
84
118
  try {
@@ -105,7 +139,10 @@ const loadConfig = (): anycopyRuntimeConfig => {
105
139
  treeFilterMode,
106
140
  };
107
141
  } catch {
108
- return { keys: { ...DEFAULT_KEYS }, treeFilterMode: DEFAULT_TREE_FILTER_MODE };
142
+ return {
143
+ keys: { ...DEFAULT_KEYS },
144
+ treeFilterMode: DEFAULT_TREE_FILTER_MODE,
145
+ };
109
146
  }
110
147
  };
111
148
 
@@ -331,10 +368,14 @@ const buildNodeOrder = (roots: SessionTreeNode[]): Map<string, number> => {
331
368
  return order;
332
369
  };
333
370
 
334
- /** Clipboard text: role:\n\ncontent\n\n---\n\nrole:\n\ncontent
371
+ /** Clipboard text omits role prefix for a single node and includes it for multi-node copies
335
372
  * The preview pane is truncated for performance, while the clipboard copy is not
336
373
  */
337
374
  const buildClipboardText = (nodes: SessionTreeNode[]): string => {
375
+ if (nodes.length === 1) {
376
+ return getEntryContent(nodes[0]!.entry);
377
+ }
378
+
338
379
  return nodes
339
380
  .map((node) => {
340
381
  const label = getEntryRoleLabel(node.entry);
@@ -363,6 +404,7 @@ class anycopyOverlay implements Focusable {
363
404
  private getTree: () => SessionTreeNode[],
364
405
  private nodeById: Map<string, SessionTreeNode>,
365
406
  private keys: anycopyKeyConfig,
407
+ private onClose: () => void,
366
408
  private getTermHeight: () => number,
367
409
  private requestRender: () => void,
368
410
  private theme: any,
@@ -381,6 +423,12 @@ class anycopyOverlay implements Focusable {
381
423
  }
382
424
 
383
425
  handleInput(data: string): void {
426
+ if (this.isEditingNodeLabel()) {
427
+ this.selector.handleInput(data);
428
+ this.requestRender();
429
+ return;
430
+ }
431
+
384
432
  if (matchesKey(data, this.keys.toggleSelect)) {
385
433
  this.toggleSelectedFocusedNode();
386
434
  return;
@@ -421,6 +469,10 @@ class anycopyOverlay implements Focusable {
421
469
  this.requestRender();
422
470
  }
423
471
 
472
+ private isEditingNodeLabel(): boolean {
473
+ return Boolean((this.selector as { labelInput?: unknown }).labelInput);
474
+ }
475
+
424
476
  invalidate(): void {
425
477
  // Preview is derived from focused entry + width; invalidate forces recompute
426
478
  this.previewCache = null;
@@ -532,10 +584,11 @@ class anycopyOverlay implements Focusable {
532
584
 
533
585
  private renderTreeHeaderHint(width: number): string {
534
586
  const hint =
535
- ` │ ${formatKeyHint(this.keys.toggleSelect)}: select` +
587
+ ` │ Enter: navigate` +
588
+ ` • ${formatKeyHint(this.keys.toggleSelect)}: select` +
536
589
  ` • ${formatKeyHint(this.keys.copy)}: copy` +
537
590
  ` • ${formatKeyHint(this.keys.clear)}: clear` +
538
- " • Esc: close";
591
+ ` • Esc: close`;
539
592
  return truncateToWidth(this.theme.fg("dim", hint), width);
540
593
  }
541
594
 
@@ -615,7 +668,7 @@ class anycopyOverlay implements Focusable {
615
668
  const selectorLines = this.selector.render(width);
616
669
  const headerHint = this.renderTreeHeaderHint(width);
617
670
 
618
- // Inject shortcut hint near the tree header (above the list)
671
+ // Inject action hints near the tree header (above the list)
619
672
  const insertAfter = Math.max(0, selectorLines.findIndex((l) => l.includes("Type to search")));
620
673
  if (selectorLines.length > 0) {
621
674
  const idx = insertAfter >= 0 ? insertAfter + 1 : 1;
@@ -652,108 +705,121 @@ export default function anycopyExtension(pi: ExtensionAPI) {
652
705
  const keys = config.keys;
653
706
  const treeFilterMode = config.treeFilterMode;
654
707
 
655
- pi.registerCommand("anycopy", {
656
- description: "Browse session tree with preview and copy any node(s) to clipboard",
657
- handler: async (args, ctx: ExtensionCommandContext) => {
658
- if (!ctx.hasUI) return;
708
+ const openAnycopy = async (
709
+ ctx: ExtensionCommandContext,
710
+ opts?: { initialSelectedId?: string },
711
+ ) => {
712
+ if (!ctx.hasUI) return;
659
713
 
660
- const initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
661
- if (initialTree.length === 0) {
662
- ctx.ui.notify("No entries in session", "warning");
663
- return;
664
- }
714
+ const initialTree = ctx.sessionManager.getTree() as SessionTreeNode[];
715
+ if (initialTree.length === 0) {
716
+ ctx.ui.notify("No entries in session", "warning");
717
+ return;
718
+ }
665
719
 
666
- const getTree = () => ctx.sessionManager.getTree() as SessionTreeNode[];
667
- const currentLeafId = ctx.sessionManager.getLeafId();
668
-
669
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
670
- const termRows = tui.terminal?.rows ?? 40;
671
- // Pass reduced height so tree takes ~35% of terminal (it uses floor(h/2) internally)
672
- const treeTermHeight = Math.floor(termRows * 0.65);
673
-
674
- const selector = new TreeSelectorComponent(
675
- initialTree,
676
- currentLeafId,
677
- treeTermHeight,
678
- () => {
679
- // Intentionally ignore Enter: closing on Enter is counterintuitive here.
680
- // Use Esc to close the overlay.
681
- },
682
- () => done(),
683
- (entryId, label) => {
684
- pi.setLabel(entryId, label);
685
- },
686
- );
720
+ const getTree = () => ctx.sessionManager.getTree() as SessionTreeNode[];
721
+ const currentLeafId = ctx.sessionManager.getLeafId();
722
+ const skipSummaryPrompt = loadBranchSummarySkipPrompt(ctx.cwd);
723
+
724
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
725
+ const termRows = tui.terminal?.rows ?? 40;
726
+ const treeTermHeight = Math.floor(termRows * 0.65);
727
+
728
+ const selector = new TreeSelectorComponent(
729
+ initialTree,
730
+ currentLeafId,
731
+ treeTermHeight,
732
+ (entryId) => {
733
+ void runAnycopyEnterNavigation({
734
+ entryId,
735
+ effectiveLeafIdForNoop,
736
+ skipSummaryPrompt,
737
+ close: done,
738
+ reopen: (reopenOpts) => {
739
+ void openAnycopy(ctx, reopenOpts);
740
+ },
741
+ navigateTree: async (targetId, options) => ctx.navigateTree(targetId, options),
742
+ ui: {
743
+ select: (title, options) => ctx.ui.select(title, options),
744
+ editor: (title) => ctx.ui.editor(title),
745
+ setStatus: (source, message) => ctx.ui.setStatus(source, message),
746
+ setWorkingMessage: (message) => ctx.ui.setWorkingMessage(message),
747
+ notify: (message, level) => ctx.ui.notify(message, level),
748
+ },
749
+ });
750
+ },
751
+ () => done(),
752
+ (entryId, label) => {
753
+ pi.setLabel(entryId, label);
754
+ },
755
+ opts?.initialSelectedId,
756
+ treeFilterMode,
757
+ );
758
+ const effectiveLeafIdForNoop = selector.getTreeList().getSelectedNode()?.entry.id ?? currentLeafId;
759
+
760
+ const nodeById = buildNodeMap(initialTree);
761
+ const overlay = new anycopyOverlay(
762
+ selector,
763
+ getTree,
764
+ nodeById,
765
+ keys,
766
+ () => done(),
767
+ () => tui.terminal?.rows ?? 40,
768
+ () => tui.requestRender(),
769
+ theme,
770
+ );
771
+
772
+ const treeList = selector.getTreeList();
773
+ const originalRender = treeList.render.bind(treeList);
774
+ treeList.render = (width: number) => {
775
+ const innerWidth = Math.max(10, width - 2);
776
+ const lines = originalRender(innerWidth);
687
777
 
688
- // Build a node map once for parent traversal (toolResult → parent assistant toolCall args)
689
- const nodeById = buildNodeMap(initialTree);
690
-
691
- const overlay = new anycopyOverlay(
692
- selector,
693
- getTree,
694
- nodeById,
695
- keys,
696
- () => tui.terminal?.rows ?? 40,
697
- () => tui.requestRender(),
698
- theme,
778
+ const tl = treeList as any;
779
+ const filteredRaw = tl.filteredNodes;
780
+ if (!Array.isArray(filteredRaw) || filteredRaw.length === 0) {
781
+ return lines.map((line: string) => " " + line);
782
+ }
783
+ const filtered = filteredRaw as { node: SessionTreeNode }[];
784
+
785
+ const selectedIdxRaw = tl.selectedIndex;
786
+ const maxVisibleRaw = tl.maxVisibleLines;
787
+ const selectedIdx =
788
+ typeof selectedIdxRaw === "number" && Number.isFinite(selectedIdxRaw) ? selectedIdxRaw : 0;
789
+ const maxVisible =
790
+ typeof maxVisibleRaw === "number" && Number.isFinite(maxVisibleRaw) && maxVisibleRaw > 0
791
+ ? maxVisibleRaw
792
+ : filtered.length;
793
+
794
+ const startIdx = Math.max(
795
+ 0,
796
+ Math.min(selectedIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
699
797
  );
798
+ const treeRowCount = Math.max(0, lines.length - 1);
700
799
 
701
- const treeList = selector.getTreeList();
800
+ return lines.map((line: string, i: number) => {
801
+ if (i >= treeRowCount) return " " + line;
702
802
 
703
- // Set initial tree filter mode (same semantics as `/tree`)
704
- const rawTreeList = treeList as any;
705
- if (rawTreeList && typeof rawTreeList === "object") {
706
- rawTreeList.filterMode = treeFilterMode;
707
- if (typeof rawTreeList.applyFilter === "function") rawTreeList.applyFilter();
708
- }
803
+ const nodeIdx = startIdx + i;
804
+ const node = filtered[nodeIdx]?.node as SessionTreeNode | undefined;
805
+ const nodeId = node?.entry?.id;
806
+ if (typeof nodeId !== "string") return " " + line;
709
807
 
710
- // Monkey-patch render to inject checkbox markers (✓/○) into tree rows
711
- const originalRender = treeList.render.bind(treeList);
712
- treeList.render = (width: number) => {
713
- const innerWidth = Math.max(10, width - 2);
714
- const lines = originalRender(innerWidth);
715
-
716
- const tl = treeList as any;
717
- const filteredRaw = tl.filteredNodes;
718
- if (!Array.isArray(filteredRaw) || filteredRaw.length === 0) {
719
- return lines.map((line: string) => " " + line);
720
- }
721
- const filtered = filteredRaw as { node: SessionTreeNode }[];
722
-
723
- const selectedIdxRaw = tl.selectedIndex;
724
- const maxVisibleRaw = tl.maxVisibleLines;
725
- const selectedIdx =
726
- typeof selectedIdxRaw === "number" && Number.isFinite(selectedIdxRaw) ? selectedIdxRaw : 0;
727
- const maxVisible =
728
- typeof maxVisibleRaw === "number" && Number.isFinite(maxVisibleRaw) && maxVisibleRaw > 0
729
- ? maxVisibleRaw
730
- : filtered.length;
731
-
732
- const startIdx = Math.max(
733
- 0,
734
- Math.min(selectedIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
735
- );
736
- const treeRowCount = Math.max(0, lines.length - 1);
737
-
738
- return lines.map((line: string, i: number) => {
739
- if (i >= treeRowCount) return " " + line;
740
-
741
- const nodeIdx = startIdx + i;
742
- const node = filtered[nodeIdx]?.node as SessionTreeNode | undefined;
743
- const nodeId = node?.entry?.id;
744
- if (typeof nodeId !== "string") return " " + line;
745
-
746
- const selected = overlay.isSelectedNode(nodeId);
747
- const marker = selected
748
- ? theme.fg("success", "\u2713 ")
749
- : theme.fg("dim", "\u25CB ");
750
- return marker + line;
751
- });
752
- };
808
+ const selected = overlay.isSelectedNode(nodeId);
809
+ const marker = selected ? theme.fg("success", "✓ ") : theme.fg("dim", "○ ");
810
+ return marker + line;
811
+ });
812
+ };
753
813
 
754
- tui.setFocus?.(overlay);
755
- return overlay;
756
- });
814
+ tui.setFocus?.(overlay);
815
+ return overlay;
816
+ });
817
+ };
818
+
819
+ pi.registerCommand("anycopy", {
820
+ description: "Browse session tree with preview and copy any node(s) to clipboard",
821
+ handler: async (_args, ctx: ExtensionCommandContext) => {
822
+ await openAnycopy(ctx);
757
823
  },
758
824
  });
759
825
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-anycopy",
3
- "version": "0.1.3",
4
- "description": "Copy any single message, or multiple selected messages, from the session tree, with scrollable message preview",
3
+ "version": "0.2.0",
4
+ "description": "Pi's /tree with a live syntax-highlighted preview and ability to copy any node(s) to the clipboard",
5
5
  "keywords": ["pi-package", "pi", "pi-coding-agent"],
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -28,6 +28,7 @@
28
28
  "dot314Prepack": {
29
29
  "copy": [
30
30
  { "from": "../../extensions/anycopy/index.ts", "to": "extensions/anycopy/index.ts" },
31
+ { "from": "../../extensions/anycopy/enter-navigation.ts", "to": "extensions/anycopy/enter-navigation.ts" },
31
32
  { "from": "../../extensions/anycopy/config.json", "to": "extensions/anycopy/config.json" },
32
33
  { "from": "../../LICENSE", "to": "LICENSE" }
33
34
  ],