@audiofool/moondog 0.1.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 +125 -0
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
- package/contracts/v1/capability-policy.schema.json +572 -0
- package/contracts/v1/definitions.schema.json +365 -0
- package/contracts/v1/library-track-observation.schema.json +138 -0
- package/contracts/v1/listening-event.schema.json +111 -0
- package/contracts/v1/profile-evidence.schema.json +237 -0
- package/contracts/v1/taste-event.schema.json +258 -0
- package/contracts/v1/tool-invocation.schema.json +318 -0
- package/contracts/v1/tool-receipt.schema.json +1066 -0
- package/contracts/v1/track-ref.schema.json +275 -0
- package/contracts/v2/profile-evidence.schema.json +284 -0
- package/package.json +105 -0
- package/scripts/contract-lib.mjs +361 -0
- package/scripts/contract-semantics.mjs +2630 -0
- package/scripts/demo-studio.mjs +58 -0
- package/scripts/moondog +52 -0
- package/scripts/moondog.mjs +954 -0
- package/src/core/apple-data-migration.mjs +50 -0
- package/src/core/apple-library-source-status.mjs +220 -0
- package/src/core/apple-projection-domain-services.mjs +2356 -0
- package/src/core/capability-catalog.mjs +577 -0
- package/src/core/listening-profile-domain-services.mjs +1010 -0
- package/src/core/lyric-library.mjs +121 -0
- package/src/core/lyric-profile.mjs +50 -0
- package/src/core/lyric-service.mjs +41 -0
- package/src/core/moondog-application.mjs +1827 -0
- package/src/core/state-directory.mjs +19 -0
- package/src/core/synthetic-domain-services.mjs +1485 -0
- package/src/core/uuid-v5.mjs +42 -0
- package/src/demo/fictional-spotify-history.mjs +400 -0
- package/src/demo/moondog-demo.mjs +355 -0
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
- package/src/demo/moondog-tasteprint-demo.mjs +631 -0
- package/src/importers/apple-music-library/import-batch.mjs +290 -0
- package/src/importers/apple-music-library/index.mjs +76 -0
- package/src/importers/apple-music-library/normalize.mjs +1126 -0
- package/src/importers/apple-music-library/parse-plist.mjs +357 -0
- package/src/importers/apple-music-library/projection-records.mjs +557 -0
- package/src/importers/apple-music-library/read-batch.mjs +276 -0
- package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
- package/src/importers/apple-music-library/stable-ids.mjs +19 -0
- package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
- package/src/integrations/apple-music/catalog.mjs +770 -0
- package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
- package/src/integrations/listenbrainz/history-file.mjs +560 -0
- package/src/integrations/lyrics/lrclib.mjs +55 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
- package/src/integrations/public-playlists.mjs +104 -0
- package/src/integrations/spotify/account-data-history.mjs +545 -0
- package/src/integrations/spotify/account-data-profile.mjs +918 -0
- package/src/integrations/spotify/authentication.mjs +791 -0
- package/src/integrations/spotify/catalog-resolver.mjs +657 -0
- package/src/integrations/spotify/connection.mjs +125 -0
- package/src/integrations/spotify/credential-store.mjs +26 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
- package/src/integrations/spotify/history-archive.mjs +59 -0
- package/src/integrations/spotify/index.mjs +80 -0
- package/src/integrations/spotify/recent-activity.mjs +198 -0
- package/src/integrations/spotify/resolution-cache.mjs +260 -0
- package/src/integrations/spotify/service.mjs +713 -0
- package/src/integrations/spotify/settings.mjs +89 -0
- package/src/integrations/spotify/web-api-client.mjs +761 -0
- package/src/integrations/web/codex-web.mjs +263 -0
- package/src/integrations/youtube-music/takeout.mjs +143 -0
- package/src/memory/local-memory-store.mjs +1705 -0
- package/src/memory/reflection-worker.mjs +87 -0
- package/src/profile/apple-library-import.mjs +28 -0
- package/src/profile/history-import.mjs +163 -0
- package/src/profile/listener-corrections.mjs +217 -0
- package/src/profile/listening-history-store.mjs +2055 -0
- package/src/profile/listening-profile-projection.mjs +2520 -0
- package/src/profile/local-music-data.mjs +812 -0
- package/src/profile/music-import-bundle.mjs +91 -0
- package/src/profile/music-providers.mjs +25 -0
- package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
- package/src/profile/spotify-archive-taste.mjs +482 -0
- package/src/runtime/pi/agent-runtime.mjs +6444 -0
- package/src/runtime/pi/authentication.mjs +104 -0
- package/src/runtime/pi/configured-runtime.mjs +98 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
- package/src/runtime/pi/model-catalog.mjs +62 -0
- package/src/runtime/pi/model-transport.mjs +117 -0
- package/src/runtime/pi/persistent-credential-store.mjs +670 -0
- package/src/runtime/pi/provider-registry.mjs +74 -0
- package/src/runtime/pi/runtime-settings.mjs +81 -0
- package/src/surfaces/cli/auth-command.mjs +355 -0
- package/src/surfaces/cli/brand-components.mjs +390 -0
- package/src/surfaces/cli/brand-theme.mjs +231 -0
- package/src/surfaces/cli/catalog-command.mjs +509 -0
- package/src/surfaces/cli/command-completions.mjs +113 -0
- package/src/surfaces/cli/data-command.mjs +214 -0
- package/src/surfaces/cli/format-output.mjs +750 -0
- package/src/surfaces/cli/history-import-view.mjs +445 -0
- package/src/surfaces/cli/import-guides.mjs +9 -0
- package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
- package/src/surfaces/cli/listening-menu.mjs +171 -0
- package/src/surfaces/cli/profile-command.mjs +290 -0
- package/src/surfaces/cli/spotify-command.mjs +849 -0
- package/src/surfaces/cli/taste-profile-model.mjs +306 -0
- package/src/surfaces/cli/taste-profile-view.mjs +272 -0
- package/src/surfaces/cli/terminal-art.mjs +295 -0
- package/src/surfaces/cli/tui.mjs +1714 -0
- package/src/surfaces/cli/web-command.mjs +52 -0
- package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
- package/src/surfaces/html/tasteprint.mjs +3575 -0
- package/src/surfaces/web/showcase.mjs +272 -0
- package/src/surfaces/web/studio-page.mjs +4148 -0
- package/src/surfaces/web/studio.mjs +2288 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Input,
|
|
3
|
+
SelectList,
|
|
4
|
+
getKeybindings,
|
|
5
|
+
truncateToWidth,
|
|
6
|
+
visibleWidth,
|
|
7
|
+
} from "@earendil-works/pi-tui";
|
|
8
|
+
|
|
9
|
+
function paintLine(value, width, theme) {
|
|
10
|
+
const clipped = truncateToWidth(value, width, "");
|
|
11
|
+
const padded = clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
12
|
+
// Retain Pi's zero-width cursor marker, including in NO_COLOR terminals.
|
|
13
|
+
if (theme.plain) return padded.replace(/\u001b\[[\d;:]*m/g, "");
|
|
14
|
+
return theme.background(theme.text(padded));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A searchable Pi menu that keeps its own contents inside the viewport. */
|
|
18
|
+
export class ListeningMenu {
|
|
19
|
+
constructor({ items, title, getTheme, getRows, currentValue }) {
|
|
20
|
+
this.items = items;
|
|
21
|
+
this.title = title;
|
|
22
|
+
this.getTheme = getTheme;
|
|
23
|
+
this.getRows = getRows;
|
|
24
|
+
this.currentValue = currentValue;
|
|
25
|
+
this.input = new Input();
|
|
26
|
+
this.query = "";
|
|
27
|
+
this.pasteActive = false;
|
|
28
|
+
this.pasteTail = "";
|
|
29
|
+
this.capacity = 1;
|
|
30
|
+
this.filteredItems = items;
|
|
31
|
+
this.select = null;
|
|
32
|
+
this.onSelect = undefined;
|
|
33
|
+
this.onCancel = undefined;
|
|
34
|
+
this.rebuildList();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get focused() { return this.input.focused; }
|
|
38
|
+
set focused(value) { this.input.focused = value; }
|
|
39
|
+
|
|
40
|
+
invalidate() {
|
|
41
|
+
this.input.invalidate();
|
|
42
|
+
this.select.invalidate();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getSelectedItem() { return this.select.getSelectedItem(); }
|
|
46
|
+
|
|
47
|
+
rebuildList() {
|
|
48
|
+
const previousValue = this.select?.getSelectedItem()?.value ?? this.currentValue;
|
|
49
|
+
const tokens = this.query.toLowerCase().trim().split(/\s+/u).filter(Boolean);
|
|
50
|
+
this.filteredItems = this.items.filter((item) => {
|
|
51
|
+
const searchable = [item.value, item.label, item.description].filter(Boolean).join(" ").toLowerCase();
|
|
52
|
+
return tokens.every((token) => searchable.includes(token));
|
|
53
|
+
});
|
|
54
|
+
const theme = Object.fromEntries(
|
|
55
|
+
["selectedPrefix", "selectedText", "description", "scrollInfo", "noMatch"].map(
|
|
56
|
+
(key) => [key, (text) => this.getTheme().selectListTheme[key](text)],
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
this.select = new SelectList(this.filteredItems, this.capacity, theme, {
|
|
60
|
+
minPrimaryColumnWidth: 16,
|
|
61
|
+
maxPrimaryColumnWidth: 34,
|
|
62
|
+
});
|
|
63
|
+
const previousIndex = this.filteredItems.findIndex((item) => item.value === previousValue);
|
|
64
|
+
if (previousIndex >= 0) this.select.setSelectedIndex(previousIndex);
|
|
65
|
+
this.select.onSelect = (item) => this.onSelect?.(item);
|
|
66
|
+
this.select.onCancel = () => this.onCancel?.();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
handleInput(data) {
|
|
70
|
+
if (data.includes("\x1b[200~")) this.pasteActive = true;
|
|
71
|
+
if (this.pasteActive) {
|
|
72
|
+
// A paste chunk containing only Enter must remain text, never select an item.
|
|
73
|
+
this.updateInput(data);
|
|
74
|
+
const paste = this.pasteTail + data;
|
|
75
|
+
this.pasteActive = !paste.includes("\x1b[201~");
|
|
76
|
+
this.pasteTail = this.pasteActive ? paste.slice(-5) : "";
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const keys = getKeybindings();
|
|
80
|
+
if (keys.matches(data, "tui.select.cancel")) {
|
|
81
|
+
this.onCancel?.();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (
|
|
85
|
+
keys.matches(data, "tui.select.up") ||
|
|
86
|
+
keys.matches(data, "tui.select.down") ||
|
|
87
|
+
keys.matches(data, "tui.select.confirm")
|
|
88
|
+
) {
|
|
89
|
+
// Pi's selection arrows assume at least one item.
|
|
90
|
+
if (this.filteredItems.length) this.select.handleInput(data);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
this.updateInput(data);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
updateInput(data) {
|
|
97
|
+
const before = this.input.getValue();
|
|
98
|
+
this.input.handleInput(data);
|
|
99
|
+
if (before !== this.input.getValue()) {
|
|
100
|
+
this.query = this.input.getValue();
|
|
101
|
+
this.rebuildList();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
render(width) {
|
|
106
|
+
const columns = Math.max(1, Math.floor(width));
|
|
107
|
+
const theme = this.getTheme();
|
|
108
|
+
const rows = Number(this.getRows());
|
|
109
|
+
const budget = Math.max(1, Math.min(18, Math.floor(Number.isFinite(rows) ? rows : 24) - 2));
|
|
110
|
+
const boxed = budget >= 5 && columns >= 8;
|
|
111
|
+
const compactBox = boxed && budget === 5;
|
|
112
|
+
const inset = boxed ? 2 : columns >= 8 ? 1 : 0;
|
|
113
|
+
const inner = Math.max(1, columns - inset * 2);
|
|
114
|
+
const separator = boxed ? budget >= 8 : budget >= 7;
|
|
115
|
+
const chromeRows = boxed ? (compactBox ? 4 : 5) : 3;
|
|
116
|
+
const capacity = Math.max(1, budget - chromeRows - (separator ? 1 : 0));
|
|
117
|
+
if (capacity !== this.capacity) {
|
|
118
|
+
this.capacity = capacity;
|
|
119
|
+
this.rebuildList();
|
|
120
|
+
}
|
|
121
|
+
const selected = this.getSelectedItem();
|
|
122
|
+
const position = selected ? this.filteredItems.indexOf(selected) + 1 : 0;
|
|
123
|
+
const count = `${position}/${this.filteredItems.length}`;
|
|
124
|
+
const titleWidth = Math.max(1, inner - visibleWidth(count) - 2);
|
|
125
|
+
const title = theme.bold(truncateToWidth(this.title, titleWidth, ""));
|
|
126
|
+
const heading = title + " ".repeat(Math.max(1, inner - visibleWidth(title) - count.length)) + theme.muted(count);
|
|
127
|
+
const search = this.input.render(inner)[0];
|
|
128
|
+
const hint = theme.muted(inner >= 55
|
|
129
|
+
? "type to filter ↑↓ choose enter select esc back"
|
|
130
|
+
: inner >= 29 ? "↑↓ choose enter ↵ esc back" : "↑↓ ↵ esc");
|
|
131
|
+
let lines;
|
|
132
|
+
if (budget < 4) {
|
|
133
|
+
// An exceptionally short terminal has no room for separate chrome rows.
|
|
134
|
+
const compactTitle = budget === 1
|
|
135
|
+
? theme.bold(truncateToWidth(this.title, Math.max(1, Math.min(12, Math.floor(inner / 4))), "")) + " "
|
|
136
|
+
: "";
|
|
137
|
+
const compactHint = " ↵ esc";
|
|
138
|
+
const compactSearch = this.input.render(Math.max(1, inner - visibleWidth(compactTitle) - visibleWidth(compactHint)))[0];
|
|
139
|
+
const combined = compactTitle + compactSearch + theme.muted(compactHint);
|
|
140
|
+
lines = budget === 1 ? [combined]
|
|
141
|
+
: budget === 2 ? [heading, combined]
|
|
142
|
+
: [heading, search, hint];
|
|
143
|
+
} else {
|
|
144
|
+
// The title owns the item count; omit SelectList's additional scroll-info row.
|
|
145
|
+
const candidates = this.filteredItems.length
|
|
146
|
+
? this.select.render(inner).slice(0, this.capacity)
|
|
147
|
+
: [theme.muted("Nothing matches. Backspace to widen the search.")];
|
|
148
|
+
let labels = [heading, search];
|
|
149
|
+
if (compactBox) {
|
|
150
|
+
const labelWidth = Math.max(1, Math.floor(inner / 2));
|
|
151
|
+
const compactTitle = theme.bold(truncateToWidth(this.title, Math.max(1, labelWidth - count.length - 1), ""));
|
|
152
|
+
const label = truncateToWidth(compactTitle + " " + theme.muted(count), labelWidth, "");
|
|
153
|
+
labels = [label + " ".repeat(Math.max(1, labelWidth - visibleWidth(label) + 1)) + this.input.render(Math.max(1, inner - labelWidth - 1))[0]];
|
|
154
|
+
}
|
|
155
|
+
lines = [...labels,
|
|
156
|
+
...(separator ? [theme.faint("─".repeat(inner))] : []),
|
|
157
|
+
...candidates, hint];
|
|
158
|
+
}
|
|
159
|
+
if (boxed) {
|
|
160
|
+
const edge = "─".repeat(columns - 2);
|
|
161
|
+
const body = lines.map((line) => {
|
|
162
|
+
const clipped = truncateToWidth(line, inner, "");
|
|
163
|
+
const padded = clipped + " ".repeat(Math.max(0, inner - visibleWidth(clipped)));
|
|
164
|
+
return theme.faint("│") + " " + padded + " " + theme.faint("│");
|
|
165
|
+
});
|
|
166
|
+
return [theme.faint(`╭${edge}╮`), ...body, theme.faint(`╰${edge}╯`)]
|
|
167
|
+
.map((line) => paintLine(line, columns, theme));
|
|
168
|
+
}
|
|
169
|
+
return lines.map((line) => paintLine(" ".repeat(inset) + line, columns, theme));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
openListeningHistoryStore,
|
|
5
|
+
resolveListeningHistoryPath,
|
|
6
|
+
} from "../../profile/listening-history-store.mjs";
|
|
7
|
+
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
8
|
+
|
|
9
|
+
export function profileCorrectionHelpText(commandPrefix = "moondog profile") {
|
|
10
|
+
return `# Telling Moondog what it got wrong
|
|
11
|
+
|
|
12
|
+
Commands:
|
|
13
|
+
|
|
14
|
+
- \`${commandPrefix} corrections [--all] [--json]\` - everything you've told me; add --all to include earlier choices
|
|
15
|
+
- \`${commandPrefix} correct --artist <name> (--like|--avoid) [--note <text>] [--json]\` - like an artist, or keep them out
|
|
16
|
+
- \`${commandPrefix} correct --track <title> --by <artist> (--like|--avoid) [--note <text>] [--json]\` - like a track, or keep it out
|
|
17
|
+
- \`${commandPrefix} retract <correction-id> [--json]\` - undo one of your choices
|
|
18
|
+
|
|
19
|
+
What you tell me counts for more than what your play counts suggest.
|
|
20
|
+
Your listening history stays exactly as it was.
|
|
21
|
+
You can see, change, or undo any choice at any time, and they stay on this machine.`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function optionValue(values, option) {
|
|
25
|
+
const indexes = values
|
|
26
|
+
.map((value, index) => (value === option ? index : -1))
|
|
27
|
+
.filter((index) => index !== -1);
|
|
28
|
+
if (indexes.length > 1) {
|
|
29
|
+
throw new Error(`The ${option} option may be provided only once.`);
|
|
30
|
+
}
|
|
31
|
+
if (indexes.length === 0) return null;
|
|
32
|
+
const index = indexes[0];
|
|
33
|
+
const value = values[index + 1];
|
|
34
|
+
if (!value || value.startsWith("--")) {
|
|
35
|
+
throw new Error(`The ${option} option requires a value.`);
|
|
36
|
+
}
|
|
37
|
+
values.splice(index, 2);
|
|
38
|
+
return value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function switchOption(values, option) {
|
|
42
|
+
const indexes = values
|
|
43
|
+
.map((value, index) => (value === option ? index : -1))
|
|
44
|
+
.filter((index) => index !== -1);
|
|
45
|
+
if (indexes.length > 1) {
|
|
46
|
+
throw new Error(`The ${option} option may be provided only once.`);
|
|
47
|
+
}
|
|
48
|
+
if (indexes.length === 0) return false;
|
|
49
|
+
values.splice(indexes[0], 1);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseProfileArguments(args) {
|
|
54
|
+
const values = [...args];
|
|
55
|
+
const action = values.shift();
|
|
56
|
+
if (action === "-h" || action === "--help") {
|
|
57
|
+
return { action: "help", values: [] };
|
|
58
|
+
}
|
|
59
|
+
if (action === "corrections") {
|
|
60
|
+
const includeInactive = switchOption(values, "--all");
|
|
61
|
+
if (values.length > 0) {
|
|
62
|
+
throw new Error(`Unknown profile corrections option: ${values[0]}`);
|
|
63
|
+
}
|
|
64
|
+
return { action, includeInactive };
|
|
65
|
+
}
|
|
66
|
+
if (action === "correct") {
|
|
67
|
+
const artist = optionValue(values, "--artist");
|
|
68
|
+
const track = optionValue(values, "--track");
|
|
69
|
+
const by = optionValue(values, "--by");
|
|
70
|
+
const note = optionValue(values, "--note");
|
|
71
|
+
const like = switchOption(values, "--like");
|
|
72
|
+
const avoid = switchOption(values, "--avoid");
|
|
73
|
+
if (values.length > 0) {
|
|
74
|
+
throw new Error(`Unknown profile correction option: ${values[0]}`);
|
|
75
|
+
}
|
|
76
|
+
if (Boolean(artist) === Boolean(track)) {
|
|
77
|
+
throw new Error("Tell me one thing to change: --artist or --track.");
|
|
78
|
+
}
|
|
79
|
+
if (track && !by) {
|
|
80
|
+
throw new Error("Add --by <artist> so I know which track you mean.");
|
|
81
|
+
}
|
|
82
|
+
if (artist && by) {
|
|
83
|
+
throw new Error("--by only goes with --track.");
|
|
84
|
+
}
|
|
85
|
+
if (like === avoid) {
|
|
86
|
+
throw new Error("Choose one: --like or --avoid.");
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
action,
|
|
90
|
+
entityType: artist ? "artist" : "track",
|
|
91
|
+
label: artist ?? track,
|
|
92
|
+
artistCredit: track ? by : undefined,
|
|
93
|
+
stance: like ? "like" : "avoid",
|
|
94
|
+
note: note ?? undefined,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (action === "retract") {
|
|
98
|
+
if (values.length !== 1 || values[0].startsWith("--")) {
|
|
99
|
+
throw new Error("Usage: moondog profile retract <correction-id> [--json].");
|
|
100
|
+
}
|
|
101
|
+
return { action, correctionId: values[0] };
|
|
102
|
+
}
|
|
103
|
+
return { action, values };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeLine(output, value = "") {
|
|
107
|
+
output.write(`${sanitizeTerminalText(value)}\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function formatCorrections(value) {
|
|
111
|
+
const lines = [
|
|
112
|
+
"# What you've told me",
|
|
113
|
+
"",
|
|
114
|
+
`${value.active} ${value.active === 1 ? "choice" : "choices"} in effect.`,
|
|
115
|
+
];
|
|
116
|
+
if (value.corrections.length === 0) {
|
|
117
|
+
lines.push("", "Nothing yet. Open a song or artist in /taste to like it or keep it out.");
|
|
118
|
+
return lines.join("\n");
|
|
119
|
+
}
|
|
120
|
+
lines.push("");
|
|
121
|
+
for (const item of value.corrections) {
|
|
122
|
+
const target = item.artist_credit
|
|
123
|
+
? `${item.label} - ${item.artist_credit}`
|
|
124
|
+
: item.label;
|
|
125
|
+
lines.push(
|
|
126
|
+
`- ${item.stance === "avoid" ? "Keep out" : "Like"} ${item.entity_type}: ${target}${item.state === "active" ? "" : ` (${item.state})`}`,
|
|
127
|
+
` ${String(item.occurred_at).slice(0, 10)} · \`${item.correction_id}\``,
|
|
128
|
+
);
|
|
129
|
+
if (item.note) lines.push(` "${item.note}"`);
|
|
130
|
+
}
|
|
131
|
+
return lines.join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatCorrection(value, commandPrefix) {
|
|
135
|
+
const target = value.artist_credit
|
|
136
|
+
? `${value.label} - ${value.artist_credit}`
|
|
137
|
+
: value.label;
|
|
138
|
+
return [
|
|
139
|
+
value.stance === "avoid" ? `Noted. I'll keep ${target} out.` : `Noted. You like ${target}.`,
|
|
140
|
+
...(value.superseded_correction_id ? ["This replaces what you told me before about it."] : []),
|
|
141
|
+
"Your profile is updated. Your listening history stays as it was.",
|
|
142
|
+
`To undo it: ${commandPrefix} retract ${value.correction_id}`,
|
|
143
|
+
].join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function formatRetraction(value) {
|
|
147
|
+
return [
|
|
148
|
+
`Undone. I'll read ${value.label} from your listening again.`,
|
|
149
|
+
"Your profile is updated. Your listening history stays as it was.",
|
|
150
|
+
].join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function pathExists(filePath) {
|
|
154
|
+
try {
|
|
155
|
+
await access(filePath);
|
|
156
|
+
return true;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error?.code === "ENOENT") return false;
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function runProfileCommand({
|
|
164
|
+
args,
|
|
165
|
+
json = false,
|
|
166
|
+
environment = process.env,
|
|
167
|
+
resolvePreferredSubjectId = async () => null,
|
|
168
|
+
output = process.stdout,
|
|
169
|
+
openStore = openListeningHistoryStore,
|
|
170
|
+
now = () => new Date(),
|
|
171
|
+
commandPrefix = "moondog profile",
|
|
172
|
+
} = {}) {
|
|
173
|
+
const options = parseProfileArguments(args ?? []);
|
|
174
|
+
if (!options.action || options.action === "help") {
|
|
175
|
+
if (options.values?.length > 0 || json) {
|
|
176
|
+
throw new Error("Usage: moondog profile help.");
|
|
177
|
+
}
|
|
178
|
+
writeLine(output, profileCorrectionHelpText(commandPrefix));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (!new Set(["corrections", "correct", "retract"]).has(options.action)) {
|
|
182
|
+
throw new Error(`Unknown profile command: ${options.action}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const databasePath = resolveListeningHistoryPath(environment);
|
|
186
|
+
const databasePresent = await pathExists(databasePath);
|
|
187
|
+
let preferredSubjectId = null;
|
|
188
|
+
if (options.action === "corrections" && !databasePresent) {
|
|
189
|
+
const empty = {
|
|
190
|
+
subject_id: null,
|
|
191
|
+
active: 0,
|
|
192
|
+
include_inactive: options.includeInactive,
|
|
193
|
+
corrections: [],
|
|
194
|
+
writes: "none",
|
|
195
|
+
};
|
|
196
|
+
writeLine(output, json ? JSON.stringify(empty, null, 2) : formatCorrections(empty));
|
|
197
|
+
return empty;
|
|
198
|
+
}
|
|
199
|
+
if (options.action === "retract" && !databasePresent) {
|
|
200
|
+
throw new Error("There's nothing to undo yet.");
|
|
201
|
+
}
|
|
202
|
+
if (
|
|
203
|
+
options.action === "correct" &&
|
|
204
|
+
!databasePresent
|
|
205
|
+
) {
|
|
206
|
+
preferredSubjectId = await resolvePreferredSubjectId();
|
|
207
|
+
if (!preferredSubjectId) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
"There's no profile to change yet. Bring in your music with /import first.",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const store = await openStore({ environment });
|
|
215
|
+
try {
|
|
216
|
+
let subjectId = store.localSubjectId();
|
|
217
|
+
if (options.action === "corrections" && !subjectId) {
|
|
218
|
+
const empty = {
|
|
219
|
+
subject_id: null,
|
|
220
|
+
active: 0,
|
|
221
|
+
include_inactive: options.includeInactive,
|
|
222
|
+
corrections: [],
|
|
223
|
+
writes: "none",
|
|
224
|
+
};
|
|
225
|
+
writeLine(
|
|
226
|
+
output,
|
|
227
|
+
json ? JSON.stringify(empty, null, 2) : formatCorrections(empty),
|
|
228
|
+
);
|
|
229
|
+
return empty;
|
|
230
|
+
}
|
|
231
|
+
if (options.action === "correct" && !subjectId) {
|
|
232
|
+
preferredSubjectId ??= await resolvePreferredSubjectId();
|
|
233
|
+
if (preferredSubjectId) {
|
|
234
|
+
subjectId = store.localSubjectId({ preferredSubjectId });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!subjectId) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
"There's no profile to change yet. Bring in your music with /import first.",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
let value;
|
|
243
|
+
if (options.action === "corrections") {
|
|
244
|
+
const corrections = store.listListenerCorrections({
|
|
245
|
+
subjectId,
|
|
246
|
+
includeInactive: options.includeInactive,
|
|
247
|
+
});
|
|
248
|
+
value = {
|
|
249
|
+
subject_id: subjectId,
|
|
250
|
+
active: store.subjectDataStatus({ subjectId }).active_taste_assertions,
|
|
251
|
+
include_inactive: options.includeInactive,
|
|
252
|
+
corrections,
|
|
253
|
+
writes: "none",
|
|
254
|
+
};
|
|
255
|
+
} else if (options.action === "correct") {
|
|
256
|
+
const timestamp = now().toISOString();
|
|
257
|
+
value = store.recordListenerCorrection({
|
|
258
|
+
subjectId,
|
|
259
|
+
entityType: options.entityType,
|
|
260
|
+
label: options.label,
|
|
261
|
+
artistCredit: options.artistCredit,
|
|
262
|
+
stance: options.stance,
|
|
263
|
+
note: options.note,
|
|
264
|
+
occurredAt: timestamp,
|
|
265
|
+
recordedAt: timestamp,
|
|
266
|
+
});
|
|
267
|
+
} else {
|
|
268
|
+
const timestamp = now().toISOString();
|
|
269
|
+
value = store.retractListenerCorrection({
|
|
270
|
+
subjectId,
|
|
271
|
+
correctionId: options.correctionId,
|
|
272
|
+
occurredAt: timestamp,
|
|
273
|
+
recordedAt: timestamp,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
writeLine(
|
|
277
|
+
output,
|
|
278
|
+
json
|
|
279
|
+
? JSON.stringify(value, null, 2)
|
|
280
|
+
: options.action === "corrections"
|
|
281
|
+
? formatCorrections(value)
|
|
282
|
+
: options.action === "correct"
|
|
283
|
+
? formatCorrection(value, commandPrefix)
|
|
284
|
+
: formatRetraction(value),
|
|
285
|
+
);
|
|
286
|
+
return value;
|
|
287
|
+
} finally {
|
|
288
|
+
store.close();
|
|
289
|
+
}
|
|
290
|
+
}
|