mulmoclaude 0.9.3 → 0.9.4
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/client/assets/{index-40ErrJ4a.js → index-B3NxFcEH.js} +36 -36
- package/client/assets/{marp-Dh7C24F1.js → marp-C9QDHFAJ.js} +1 -1
- package/client/index.html +1 -1
- package/package.json +6 -6
- package/server/api/routes/agent.ts +5 -1
- package/server/api/routes/collections.ts +16 -1
- package/server/api/routes/config.ts +12 -0
- package/server/remoteHost/firebase.ts +6 -0
- package/server/remoteHost/handlers/index.ts +4 -0
- package/server/remoteHost/handlers/ingestAttachments.ts +88 -0
- package/server/remoteHost/handlers/listAccountingBooks.ts +31 -0
- package/server/remoteHost/handlers/listCollections.ts +6 -1
- package/server/remoteHost/handlers/listSkills.ts +39 -0
- package/server/remoteHost/handlers/startChat.ts +103 -39
- package/server/system/config.ts +101 -4
- package/server/system/logs/aaa +737 -0
- package/server/system/logs/bb +446 -0
- package/server/workspace/chat-index/index.ts +21 -2
- package/server/workspace/chat-index/indexer.ts +57 -14
- package/server/workspace/chat-index/summarizer.ts +18 -10
- package/server/workspace/collections/index.ts +1 -1
- package/server/workspace/journal/archivist-cli.ts +17 -3
- package/server/workspace/journal/dailyPass.ts +52 -2
- package/server/workspace/journal/index.ts +20 -4
- package/src/App.vue +5 -0
- package/src/components/FileTree.vue +21 -1
- package/src/components/FileTreePane.vue +41 -25
- package/src/components/FilesView.vue +4 -0
- package/src/components/RemoteHostControl.vue +18 -0
- package/src/components/SessionHistoryPanel.vue +24 -11
- package/src/components/SettingsChatIndexTab.vue +119 -0
- package/src/components/SettingsJournalTab.vue +117 -0
- package/src/components/SettingsModal.vue +12 -2
- package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
- package/src/composables/useShowHiddenSystemFiles.ts +23 -0
- package/src/config/visibleWorkspaceDirs.ts +21 -0
- package/src/lang/de.ts +45 -0
- package/src/lang/en.ts +43 -0
- package/src/lang/es.ts +44 -0
- package/src/lang/fr.ts +45 -0
- package/src/lang/ja.ts +44 -0
- package/src/lang/ko.ts +43 -0
- package/src/lang/pt-BR.ts +44 -0
- package/src/lang/zh.ts +43 -0
- package/src/utils/session/sessionPreview.ts +29 -0
|
@@ -1,78 +1,142 @@
|
|
|
1
1
|
// startChat command handler (remote-host — start a chat from the mobile remote).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// chatId. No streaming back — starting the chat on the host is enough.
|
|
3
|
+
// The remote sends text; the host starts a new VISIBLE chat session (origin
|
|
4
|
+
// `skill`, openable from desktop history) seeded with it, and returns the new
|
|
5
|
+
// chatId. Fire-and-forget: no streaming back — starting the chat on the host is
|
|
6
|
+
// enough.
|
|
8
7
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
8
|
+
// ┌─────────────────────────────────────────────────────────────────────────┐
|
|
9
|
+
// │ THE CONTRACT — read this before touching the params. │
|
|
10
|
+
// │ │
|
|
11
|
+
// │ CURRENT clients send ONLY `{ message }`. The message is seeded VERBATIM │
|
|
12
|
+
// │ as the first user turn. The host does NOT interpret it, so the client is │
|
|
13
|
+
// │ free to put a slash command (`/<slug> …`), a plain question, or anything │
|
|
14
|
+
// │ else in the text. This is the ONLY form new code should emit. │
|
|
15
|
+
// │ │
|
|
16
|
+
// │ `slug` (and its optional companion `itemId`) is LEGACY-ONLY. Older │
|
|
17
|
+
// │ clients still send `{ slug, itemId?, message }` to target a collection │
|
|
18
|
+
// │ or one record; we keep composing `/<slug> [id=<itemId>] <message>` for │
|
|
19
|
+
// │ them so they don't break. DO NOT add new features to this branch, and DO │
|
|
20
|
+
// │ NOT teach new clients to send `slug` — put the slash command in │
|
|
21
|
+
// │ `message` instead. The whole legacy path can be deleted once no deployed │
|
|
22
|
+
// │ client sends `slug` anymore. │
|
|
23
|
+
// └─────────────────────────────────────────────────────────────────────────┘
|
|
12
24
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
25
|
+
// Optional `role` — the id of the role the chat should run in (built-in or
|
|
26
|
+
// custom). Absent / null / "" ⇒ the host default role. A provided id MUST match
|
|
27
|
+
// an existing role: spawnSystemWorker requires a concrete roleId and the
|
|
28
|
+
// downstream getRole silently falls back to `general` on a miss, so we validate
|
|
29
|
+
// here and reject an unknown role rather than seed the wrong assistant.
|
|
16
30
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
31
|
+
// Optional `attachments` — full-res files (photos, videos, PDFs) the remote
|
|
32
|
+
// staged to Storage, carried as `[{ storage_id }]`. The host ingests them into
|
|
33
|
+
// the workspace (ingestAttachments) and hands the resulting path-only
|
|
34
|
+
// Attachments to the spawned chat. Absent / empty ⇒ byte-for-byte the prior
|
|
35
|
+
// text-only behaviour.
|
|
22
36
|
//
|
|
23
37
|
// Factory (createStartChat) keeps composition/wiring unit-testable with the
|
|
24
38
|
// engine + spawner stubbed; the default export wires the real ones.
|
|
25
39
|
import { spawnSystemWorker } from "../../api/routes/agent.js";
|
|
26
40
|
import { loadCollection } from "../../workspace/collections/index.js";
|
|
41
|
+
import { loadAllRoles } from "../../workspace/roles.js";
|
|
27
42
|
import { DEFAULT_ROLE_ID } from "../../../src/config/roles.js";
|
|
28
43
|
import type { CommandHandler, JsonObject, JsonValue } from "../commandChannel.js";
|
|
44
|
+
import { ingestAttachments } from "./ingestAttachments.js";
|
|
29
45
|
|
|
30
46
|
export interface StartChatDeps {
|
|
31
47
|
spawn: typeof spawnSystemWorker;
|
|
32
48
|
loadCollection: typeof loadCollection;
|
|
49
|
+
ingest: typeof ingestAttachments;
|
|
50
|
+
loadRoles: typeof loadAllRoles;
|
|
33
51
|
}
|
|
34
52
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
53
|
+
// Parse the optional `attachments` param into a list of storage_ids. Absent ⇒ no
|
|
54
|
+
// attachments. A malformed shape (not an array, or an element without a string
|
|
55
|
+
// `storage_id`) rejects the whole command: the remote already uploaded the
|
|
56
|
+
// bytes and is waiting, so a surfaced error beats a chat with a missing file.
|
|
57
|
+
const readStorageIds = (attachments: JsonValue | undefined): string[] => {
|
|
58
|
+
if (attachments == null) return [];
|
|
59
|
+
if (!Array.isArray(attachments)) throw new Error("attachments must be an array of { storage_id }");
|
|
60
|
+
return attachments.map((entry) => {
|
|
61
|
+
const storageId = entry && typeof entry === "object" && !Array.isArray(entry) ? entry.storage_id : undefined;
|
|
62
|
+
if (typeof storageId !== "string" || storageId.length === 0) throw new Error("each attachments entry must be { storage_id: string }");
|
|
63
|
+
return storageId;
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// LEGACY. Prefix the message with the collection's slash command. `itemId`
|
|
68
|
+
// scopes the chat to one record; empty ⇒ the whole collection. Matches the
|
|
69
|
+
// desktop item-chat format documented in CollectionRecordPanel.vue. Only the
|
|
70
|
+
// legacy `slug` path calls this — new clients put the slash command in
|
|
71
|
+
// `message` themselves.
|
|
38
72
|
export const composeMessage = (slug: string, itemId: string, message: string): string => {
|
|
39
73
|
const prefix = itemId ? `/${slug} id=${itemId}` : `/${slug}`;
|
|
40
74
|
return `${prefix} ${message}`;
|
|
41
75
|
};
|
|
42
76
|
|
|
43
|
-
// slug and itemId become single tokens in the slash command
|
|
44
|
-
// `id=<itemId>`), so a whitespace-containing or non-string value
|
|
45
|
-
// the command parse (e.g. `/ hello`). Accept only a trimmed,
|
|
46
|
-
// string; anything else ⇒ "" so the caller rejects it.
|
|
77
|
+
// LEGACY. slug and itemId become single tokens in the slash command
|
|
78
|
+
// (`/<slug>`, `id=<itemId>`), so a whitespace-containing or non-string value
|
|
79
|
+
// would break the command parse (e.g. `/ hello`). Accept only a trimmed,
|
|
80
|
+
// whitespace-free string; anything else ⇒ "" so the caller rejects it.
|
|
47
81
|
const asToken = (value: JsonValue): string => {
|
|
48
82
|
if (typeof value !== "string") return "";
|
|
49
83
|
const trimmed = value.trim();
|
|
50
84
|
return /\s/.test(trimmed) ? "" : trimmed;
|
|
51
85
|
};
|
|
52
86
|
|
|
87
|
+
// LEGACY collection/record targeting for older clients that still send `slug`.
|
|
88
|
+
// Compose the `/<slug> [id=<itemId>] <message>` seed the desktop uses; reject a
|
|
89
|
+
// malformed or unknown slug and refuse feeds, so we never seed a `/<slug>`
|
|
90
|
+
// command that resolves to nothing. New clients skip all of this by sending the
|
|
91
|
+
// slash command (or plain text) in `message`.
|
|
92
|
+
const composeCollectionSeed = async (deps: StartChatDeps, params: JsonObject, message: string): Promise<string> => {
|
|
93
|
+
const slug = asToken(params.slug);
|
|
94
|
+
const itemId = asToken(params.itemId);
|
|
95
|
+
if (!slug) throw new Error("slug must be a non-empty, whitespace-free string");
|
|
96
|
+
if (params.itemId != null && !itemId) throw new Error("itemId must be a non-empty, whitespace-free string when provided");
|
|
97
|
+
const target = await deps.loadCollection(slug);
|
|
98
|
+
if (!target) throw new Error(`collection '${slug}' not found`);
|
|
99
|
+
if (target.source === "feed") throw new Error("chat is not available for feeds");
|
|
100
|
+
return composeMessage(slug, itemId, message);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// A `slug` is "provided" (⇒ LEGACY targeting) only when it is a non-empty
|
|
104
|
+
// value. Omitted / null / "" ⇒ the current free-text form. New clients never
|
|
105
|
+
// set it, so they always take the verbatim branch below.
|
|
106
|
+
const hasSlug = (value: JsonValue | undefined): boolean => value != null && value !== "";
|
|
107
|
+
|
|
108
|
+
// Resolve the optional `role` param to a concrete roleId. Absent / null / "" ⇒
|
|
109
|
+
// the host default. A provided id must be a string that matches an existing
|
|
110
|
+
// role (built-in or custom) — reject an unknown one so we never seed the chat
|
|
111
|
+
// with the wrong (default-fallback) assistant. `isDebugRole` roles are excluded:
|
|
112
|
+
// the desktop picker hides them from new sessions outside dev mode
|
|
113
|
+
// (RoleSelector.vue), and the remote channel is a production-facing entry point,
|
|
114
|
+
// so a debug role id is treated as not selectable here (rejected as unknown).
|
|
115
|
+
const resolveRoleId = (deps: StartChatDeps, role: JsonValue | undefined): string => {
|
|
116
|
+
if (role == null || role === "") return DEFAULT_ROLE_ID;
|
|
117
|
+
if (typeof role !== "string") throw new Error("role must be a string");
|
|
118
|
+
if (!deps.loadRoles().some((candidate) => candidate.id === role && !candidate.isDebugRole)) throw new Error(`role '${role}' not found`);
|
|
119
|
+
return role;
|
|
120
|
+
};
|
|
121
|
+
|
|
53
122
|
export const createStartChat =
|
|
54
123
|
(deps: StartChatDeps): CommandHandler =>
|
|
55
124
|
async (params: JsonObject) => {
|
|
56
125
|
// Params arrive as JSON over the channel — coerce/validate defensively.
|
|
57
|
-
const slug = asToken(params.slug);
|
|
58
|
-
const itemId = asToken(params.itemId);
|
|
59
126
|
const message = (typeof params.message === "string" ? params.message : "").trim();
|
|
60
|
-
if (!slug) throw new Error("slug must be a non-empty, whitespace-free string");
|
|
61
|
-
if (params.itemId != null && !itemId) throw new Error("itemId must be a non-empty, whitespace-free string when provided");
|
|
62
127
|
if (!message) throw new Error("message is required");
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
});
|
|
128
|
+
// Current clients: `message` only ⇒ seed it verbatim. Legacy clients that
|
|
129
|
+
// still send `slug` ⇒ compose the old `/<slug> [id=<itemId>] <message>` seed.
|
|
130
|
+
const seed = hasSlug(params.slug) ? await composeCollectionSeed(deps, params, message) : message;
|
|
131
|
+
// Resolve the role BEFORE ingest/spawn so an unknown role rejects the
|
|
132
|
+
// command without staging work or launching a chat.
|
|
133
|
+
const roleId = resolveRoleId(deps, params.role);
|
|
134
|
+
// Ingest any staged files BEFORE spawning so a download/validation failure
|
|
135
|
+
// rejects the command instead of starting a chat missing its attachments.
|
|
136
|
+
const attachments = await deps.ingest(readStorageIds(params.attachments));
|
|
137
|
+
const result = await deps.spawn({ message: seed, roleId, hidden: false, attachments });
|
|
74
138
|
if (!result.ok) throw new Error(result.error);
|
|
75
139
|
return { started: true, chatId: result.chatId };
|
|
76
140
|
};
|
|
77
141
|
|
|
78
|
-
export const startChat = createStartChat({ spawn: spawnSystemWorker, loadCollection });
|
|
142
|
+
export const startChat = createStartChat({ spawn: spawnSystemWorker, loadCollection, ingest: ingestAttachments, loadRoles: loadAllRoles });
|
package/server/system/config.ts
CHANGED
|
@@ -23,6 +23,27 @@ import { isRecord, isStringArray, isStringRecord } from "../utils/types.js";
|
|
|
23
23
|
export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const;
|
|
24
24
|
export type EffortLevel = (typeof EFFORT_LEVELS)[number];
|
|
25
25
|
|
|
26
|
+
// Chat-index summarizer setting (#1944). "off" disables the whole
|
|
27
|
+
// AI-title / summary / keywords background indexer; "haiku" / "sonnet"
|
|
28
|
+
// select the Claude model the summarizer spawns. Default (undefined)
|
|
29
|
+
// is treated as "off" — indexing is opt-in from Settings → Chat index
|
|
30
|
+
// so a fresh workspace doesn't burn CLI budget on scheduler/system
|
|
31
|
+
// automation sessions the user may never look at (see #1929 / #1944
|
|
32
|
+
// for the cost analysis). The origin filter (system + scheduler
|
|
33
|
+
// sessions always skipped) is enforced separately and does NOT
|
|
34
|
+
// depend on this setting.
|
|
35
|
+
export const CHAT_INDEX_MODES = ["off", "haiku", "sonnet"] as const;
|
|
36
|
+
export type ChatIndexMode = (typeof CHAT_INDEX_MODES)[number];
|
|
37
|
+
|
|
38
|
+
// Journal daily-pass setting (follow-up to #1944). Same three states as
|
|
39
|
+
// chat-index: "off" disables both the turn-end hook and the hourly
|
|
40
|
+
// scheduled pass; "haiku" / "sonnet" pick the Claude model the archivist
|
|
41
|
+
// CLI spawns for its summary calls. Default (undefined) is "off" so a
|
|
42
|
+
// fresh workspace doesn't burn CLI budget until the user opts in from
|
|
43
|
+
// Settings → Journal.
|
|
44
|
+
export const JOURNAL_MODES = ["off", "haiku", "sonnet"] as const;
|
|
45
|
+
export type JournalMode = (typeof JOURNAL_MODES)[number];
|
|
46
|
+
|
|
26
47
|
export interface AppSettings {
|
|
27
48
|
// Extra tool names appended to BASE_ALLOWED_TOOLS in
|
|
28
49
|
// server/agent/config.ts#buildCliArgs. Typical entries are
|
|
@@ -63,6 +84,24 @@ export interface AppSettings {
|
|
|
63
84
|
enabled: boolean;
|
|
64
85
|
model?: string;
|
|
65
86
|
};
|
|
87
|
+
|
|
88
|
+
// Chat-index summarizer mode (#1944). Ships "off" by default: the
|
|
89
|
+
// background AI-title / summary / keywords indexer stays disabled
|
|
90
|
+
// until the user opts in from Settings → Chat index. Flipping to
|
|
91
|
+
// "haiku" or "sonnet" selects the model the summarizer spawns per
|
|
92
|
+
// session. Non-user origins (`system`, `scheduler`) are always
|
|
93
|
+
// skipped regardless of this value — see `indexer.ts`.
|
|
94
|
+
chatIndex?: ChatIndexMode;
|
|
95
|
+
|
|
96
|
+
// Journal daily-pass mode (follow-up to #1944). Ships "off" by
|
|
97
|
+
// default: the archivist that summarizes chat sessions into
|
|
98
|
+
// journal/*.md stays disabled until the user opts in from Settings →
|
|
99
|
+
// Journal. Flipping to "haiku" / "sonnet" enables the run and picks
|
|
100
|
+
// the Claude model the archivist CLI spawns. The turn-end hook
|
|
101
|
+
// (`maybeRunJournal` in the agent finally block) and the hourly
|
|
102
|
+
// `system:journal` scheduled task both honour this — off short-
|
|
103
|
+
// circuits before the interval gate is even consulted.
|
|
104
|
+
journal?: JournalMode;
|
|
66
105
|
}
|
|
67
106
|
|
|
68
107
|
const DEFAULT_SETTINGS: AppSettings = { extraAllowedTools: [] };
|
|
@@ -94,6 +133,14 @@ function isEffortLevel(value: unknown): value is EffortLevel {
|
|
|
94
133
|
return typeof value === "string" && (EFFORT_LEVELS as readonly string[]).includes(value);
|
|
95
134
|
}
|
|
96
135
|
|
|
136
|
+
function isChatIndexMode(value: unknown): value is ChatIndexMode {
|
|
137
|
+
return typeof value === "string" && (CHAT_INDEX_MODES as readonly string[]).includes(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isJournalMode(value: unknown): value is JournalMode {
|
|
141
|
+
return typeof value === "string" && (JOURNAL_MODES as readonly string[]).includes(value);
|
|
142
|
+
}
|
|
143
|
+
|
|
97
144
|
function isVoiceInputSettings(value: unknown): value is { enabled: boolean; model?: string } {
|
|
98
145
|
if (!isRecord(value)) return false;
|
|
99
146
|
if (typeof value.enabled !== "boolean") return false;
|
|
@@ -108,9 +155,13 @@ export function isAppSettings(value: unknown): value is AppSettings {
|
|
|
108
155
|
if (value.photoExif !== undefined && !isPhotoExifSettings(value.photoExif)) return false;
|
|
109
156
|
if (value.effortLevel !== undefined && !isEffortLevel(value.effortLevel)) return false;
|
|
110
157
|
if (value.voiceInput !== undefined && !isVoiceInputSettings(value.voiceInput)) return false;
|
|
158
|
+
if (value.chatIndex !== undefined && !isChatIndexMode(value.chatIndex)) return false;
|
|
159
|
+
if (value.journal !== undefined && !isJournalMode(value.journal)) return false;
|
|
111
160
|
return true;
|
|
112
161
|
}
|
|
113
162
|
|
|
163
|
+
// isAppSettingsPatch adds a null sentinel to `chatIndex` / `journal` (see below).
|
|
164
|
+
|
|
114
165
|
/** A PUT-payload validator: every field optional, but if present
|
|
115
166
|
* it must match the AppSettings shape. Distinct from
|
|
116
167
|
* `isAppSettings` (which insists on the full storage shape) so a
|
|
@@ -127,28 +178,46 @@ export function isAppSettings(value: unknown): value is AppSettings {
|
|
|
127
178
|
* but lets nullable fields carry `null` as a "clear me" sentinel —
|
|
128
179
|
* callers normalise via `normaliseAppSettingsPatch` before merging
|
|
129
180
|
* into the storage shape (#1323). */
|
|
130
|
-
export type AppSettingsPatch = Partial<Omit<AppSettings, "effortLevel">> & {
|
|
181
|
+
export type AppSettingsPatch = Partial<Omit<AppSettings, "effortLevel" | "chatIndex" | "journal">> & {
|
|
131
182
|
effortLevel?: EffortLevel | null;
|
|
183
|
+
chatIndex?: ChatIndexMode | null;
|
|
184
|
+
journal?: JournalMode | null;
|
|
132
185
|
};
|
|
133
186
|
|
|
134
187
|
/** Convert a wire patch to the storage-shape patch by dropping any
|
|
135
188
|
* `null` sentinels (which mean "clear" for the corresponding field). */
|
|
136
189
|
export function normaliseAppSettingsPatch(patch: AppSettingsPatch): Partial<AppSettings> {
|
|
137
|
-
const { effortLevel, ...rest } = patch;
|
|
190
|
+
const { effortLevel, chatIndex, journal, ...rest } = patch;
|
|
138
191
|
const out: Partial<AppSettings> = { ...rest };
|
|
139
192
|
if (effortLevel !== null && effortLevel !== undefined) {
|
|
140
193
|
out.effortLevel = effortLevel;
|
|
141
194
|
}
|
|
195
|
+
if (chatIndex !== null && chatIndex !== undefined) {
|
|
196
|
+
out.chatIndex = chatIndex;
|
|
197
|
+
}
|
|
198
|
+
if (journal !== null && journal !== undefined) {
|
|
199
|
+
out.journal = journal;
|
|
200
|
+
}
|
|
142
201
|
return out;
|
|
143
202
|
}
|
|
144
203
|
|
|
204
|
+
// Split each field's optional-with-null-sentinel validation into its own
|
|
205
|
+
// mini-helper so `isAppSettingsPatch` stays under the cognitive-complexity
|
|
206
|
+
// ceiling as new nullable fields land.
|
|
207
|
+
const isOptionalString = (value: unknown): boolean => value === undefined || typeof value === "string";
|
|
208
|
+
const isOptionalNullableEffortLevel = (value: unknown): boolean => value === undefined || value === null || isEffortLevel(value);
|
|
209
|
+
const isOptionalNullableChatIndexMode = (value: unknown): boolean => value === undefined || value === null || isChatIndexMode(value);
|
|
210
|
+
const isOptionalNullableJournalMode = (value: unknown): boolean => value === undefined || value === null || isJournalMode(value);
|
|
211
|
+
|
|
145
212
|
export function isAppSettingsPatch(value: unknown): value is AppSettingsPatch {
|
|
146
213
|
if (!isRecord(value)) return false;
|
|
147
214
|
if (value.extraAllowedTools !== undefined && !isStringArray(value.extraAllowedTools)) return false;
|
|
148
|
-
if (value.googleMapsApiKey
|
|
215
|
+
if (!isOptionalString(value.googleMapsApiKey)) return false;
|
|
149
216
|
if (value.photoExif !== undefined && !isPhotoExifSettings(value.photoExif)) return false;
|
|
150
|
-
if (
|
|
217
|
+
if (!isOptionalNullableEffortLevel(value.effortLevel)) return false;
|
|
151
218
|
if (value.voiceInput !== undefined && !isVoiceInputSettings(value.voiceInput)) return false;
|
|
219
|
+
if (!isOptionalNullableChatIndexMode(value.chatIndex)) return false;
|
|
220
|
+
if (!isOptionalNullableJournalMode(value.journal)) return false;
|
|
152
221
|
return true;
|
|
153
222
|
}
|
|
154
223
|
|
|
@@ -182,9 +251,31 @@ function cloneAppSettings(settings: AppSettings): AppSettings {
|
|
|
182
251
|
copy.voiceInput.model = settings.voiceInput.model;
|
|
183
252
|
}
|
|
184
253
|
}
|
|
254
|
+
if (settings.chatIndex !== undefined) {
|
|
255
|
+
copy.chatIndex = settings.chatIndex;
|
|
256
|
+
}
|
|
257
|
+
if (settings.journal !== undefined) {
|
|
258
|
+
copy.journal = settings.journal;
|
|
259
|
+
}
|
|
185
260
|
return copy;
|
|
186
261
|
}
|
|
187
262
|
|
|
263
|
+
/** Chat-index mode with the documented "undefined → off" default, so
|
|
264
|
+
* every reader (indexer, backfill scheduler, force-startup switch,
|
|
265
|
+
* Settings tab) resolves the same way. Callers switch on the
|
|
266
|
+
* returned literal string to decide skip vs pick a model. */
|
|
267
|
+
export function chatIndexMode(settings: AppSettings): ChatIndexMode {
|
|
268
|
+
return settings.chatIndex ?? "off";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Journal mode with the documented "undefined → off" default, so
|
|
272
|
+
* every reader (turn-end hook, hourly scheduler, force-startup
|
|
273
|
+
* switch, Settings tab) resolves the same way. Callers switch on
|
|
274
|
+
* the returned literal string to decide skip vs pick a model. */
|
|
275
|
+
export function journalMode(settings: AppSettings): JournalMode {
|
|
276
|
+
return settings.journal ?? "off";
|
|
277
|
+
}
|
|
278
|
+
|
|
188
279
|
/** Read the photo-exif auto-capture flag with the documented
|
|
189
280
|
* default of `true`. Centralises the "missing block ⇒ on" rule so
|
|
190
281
|
* the post-save hook + future Settings UI stay aligned. */
|
|
@@ -234,6 +325,12 @@ export function saveSettings(settings: AppSettings): void {
|
|
|
234
325
|
payload.voiceInput.model = settings.voiceInput.model;
|
|
235
326
|
}
|
|
236
327
|
}
|
|
328
|
+
if (settings.chatIndex !== undefined) {
|
|
329
|
+
payload.chatIndex = settings.chatIndex;
|
|
330
|
+
}
|
|
331
|
+
if (settings.journal !== undefined) {
|
|
332
|
+
payload.journal = settings.journal;
|
|
333
|
+
}
|
|
237
334
|
const serialised = JSON.stringify(payload, null, 2);
|
|
238
335
|
writeFileAtomicSync(settingsPath(), `${serialised}\n`, { mode: 0o600 });
|
|
239
336
|
}
|