@c0sc0s/codex-tags 0.5.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/.codex-plugin/plugin.json +24 -0
- package/AGENTS.md +44 -0
- package/CHANGELOG.md +75 -0
- package/README.md +87 -0
- package/README.zh-CN.md +87 -0
- package/assets/README.md +19 -0
- package/assets/banner.png +0 -0
- package/assets/icon.icns +0 -0
- package/assets/logo.png +0 -0
- package/bin/codex-tags.mjs +89 -0
- package/docs/architecture.md +56 -0
- package/docs/compatibility.md +47 -0
- package/docs/development.md +84 -0
- package/docs/distribution.md +65 -0
- package/docs/protocol.md +89 -0
- package/docs/roadmap.md +40 -0
- package/hooks/hooks.json +40 -0
- package/hooks/session-naming.mjs +107 -0
- package/package.json +65 -0
- package/runtime/dist/injected.js +3161 -0
- package/runtime/src/cdp-client.mjs +100 -0
- package/runtime/src/codex-process.mjs +115 -0
- package/runtime/src/content-index.mjs +138 -0
- package/runtime/src/controller-router.mjs +84 -0
- package/runtime/src/controller-state.mjs +17 -0
- package/runtime/src/controller.mjs +290 -0
- package/runtime/src/inject-expression.mjs +49 -0
- package/runtime/src/protocol.d.mts +31 -0
- package/runtime/src/protocol.mjs +43 -0
- package/runtime/src/runtime-target-registry.mjs +92 -0
- package/runtime/src/search-index.mjs +191 -0
- package/runtime/src/session-catalog.mjs +52 -0
- package/runtime/src/settings-repository.mjs +58 -0
- package/runtime/src/tag-settings.d.mts +18 -0
- package/runtime/src/tag-settings.mjs +65 -0
- package/runtime/src/title-format.d.mts +11 -0
- package/runtime/src/title-format.mjs +33 -0
- package/scripts/cli-options.mjs +17 -0
- package/scripts/health.mjs +20 -0
- package/scripts/lifecycle-lock.mjs +21 -0
- package/scripts/manage.mjs +19 -0
- package/scripts/manager-core.mjs +463 -0
- package/skills/doctor/SKILL.md +18 -0
- package/skills/doctor/agents/openai.yaml +4 -0
- package/skills/initial/SKILL.md +22 -0
- package/skills/initial/agents/openai.yaml +4 -0
- package/skills/rename/SKILL.md +20 -0
- package/skills/rename/agents/openai.yaml +4 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { chmodSync, existsSync } from "node:fs";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
|
|
5
|
+
import { discoverSessionFiles, extractConversationText } from "./content-index.mjs";
|
|
6
|
+
|
|
7
|
+
const defaultResultLimit = 50;
|
|
8
|
+
const maximumResultLimit = 100;
|
|
9
|
+
const extractionVersion = 1;
|
|
10
|
+
|
|
11
|
+
function localThreadIdFor(threadId) {
|
|
12
|
+
return threadId.includes(":") ? threadId.slice(threadId.lastIndexOf(":") + 1) : threadId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function quotedFtsQuery(query) {
|
|
16
|
+
return `"${query.replaceAll('"', '""')}"`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildSnippet(text, query) {
|
|
20
|
+
const normalized = text.replace(/\s+/gu, " ");
|
|
21
|
+
const matchIndex = normalized.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
|
|
22
|
+
if (matchIndex === -1) return normalized.slice(0, 132);
|
|
23
|
+
const start = Math.max(0, matchIndex - 46);
|
|
24
|
+
const end = Math.min(normalized.length, matchIndex + query.length + 82);
|
|
25
|
+
return `${start > 0 ? "…" : ""}${normalized.slice(start, end)}${end < normalized.length ? "…" : ""}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class SessionSearchIndex {
|
|
29
|
+
constructor(databasePath, options = {}) {
|
|
30
|
+
this.discoverFiles = options.discoverFiles ?? (() => discoverSessionFiles(true));
|
|
31
|
+
this.readOnly = options.readOnly === true;
|
|
32
|
+
this.database = new Database(databasePath, {
|
|
33
|
+
readonly: this.readOnly,
|
|
34
|
+
fileMustExist: this.readOnly,
|
|
35
|
+
timeout: 5000,
|
|
36
|
+
});
|
|
37
|
+
if (!this.readOnly) this.database.exec(`
|
|
38
|
+
PRAGMA journal_mode = WAL;
|
|
39
|
+
PRAGMA synchronous = NORMAL;
|
|
40
|
+
CREATE TABLE IF NOT EXISTS indexed_sessions (
|
|
41
|
+
thread_id TEXT PRIMARY KEY,
|
|
42
|
+
file_path TEXT NOT NULL,
|
|
43
|
+
file_size INTEGER NOT NULL,
|
|
44
|
+
modified_at REAL NOT NULL
|
|
45
|
+
);
|
|
46
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS session_messages USING fts5(
|
|
47
|
+
thread_id UNINDEXED,
|
|
48
|
+
role UNINDEXED,
|
|
49
|
+
content,
|
|
50
|
+
tokenize = 'trigram'
|
|
51
|
+
);
|
|
52
|
+
`);
|
|
53
|
+
if (!this.readOnly) {
|
|
54
|
+
// Cached file timestamps cannot detect changes to the transcript extractor.
|
|
55
|
+
if (this.database.pragma("user_version", { simple: true }) < extractionVersion) {
|
|
56
|
+
this.database.transaction(() => {
|
|
57
|
+
this.database.exec("DELETE FROM session_messages; DELETE FROM indexed_sessions;");
|
|
58
|
+
this.database.pragma(`user_version = ${extractionVersion}`);
|
|
59
|
+
})();
|
|
60
|
+
}
|
|
61
|
+
for (const path of [databasePath, `${databasePath}-wal`, `${databasePath}-shm`]) {
|
|
62
|
+
if (existsSync(path)) chmodSync(path, 0o600);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
this.selectIndexedSessions = this.database.prepare("SELECT thread_id, file_path, file_size, modified_at FROM indexed_sessions");
|
|
66
|
+
if (this.readOnly) return;
|
|
67
|
+
this.deleteMessages = this.database.prepare("DELETE FROM session_messages WHERE thread_id = ?");
|
|
68
|
+
this.deleteSession = this.database.prepare("DELETE FROM indexed_sessions WHERE thread_id = ?");
|
|
69
|
+
this.insertMessage = this.database.prepare("INSERT INTO session_messages(thread_id, role, content) VALUES (?, ?, ?)");
|
|
70
|
+
this.upsertSession = this.database.prepare(`
|
|
71
|
+
INSERT INTO indexed_sessions(thread_id, file_path, file_size, modified_at)
|
|
72
|
+
VALUES (?, ?, ?, ?)
|
|
73
|
+
ON CONFLICT(thread_id) DO UPDATE SET
|
|
74
|
+
file_path = excluded.file_path,
|
|
75
|
+
file_size = excluded.file_size,
|
|
76
|
+
modified_at = excluded.modified_at
|
|
77
|
+
`);
|
|
78
|
+
this.searchFts = this.database.prepare(`
|
|
79
|
+
SELECT thread_id, role, content, bm25(session_messages) AS rank
|
|
80
|
+
FROM session_messages
|
|
81
|
+
WHERE session_messages MATCH ?
|
|
82
|
+
AND thread_id IN (SELECT value FROM json_each(?))
|
|
83
|
+
ORDER BY rank
|
|
84
|
+
LIMIT ?
|
|
85
|
+
`);
|
|
86
|
+
this.searchShortQuery = this.database.prepare(`
|
|
87
|
+
SELECT thread_id, role, content, 0 AS rank
|
|
88
|
+
FROM session_messages
|
|
89
|
+
WHERE instr(lower(content), lower(?)) > 0
|
|
90
|
+
AND thread_id IN (SELECT value FROM json_each(?))
|
|
91
|
+
LIMIT ?
|
|
92
|
+
`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async refresh(onProgress = () => {}) {
|
|
96
|
+
if (this.readOnly) throw new Error("Cannot refresh a read-only search index");
|
|
97
|
+
const files = await this.discoverFiles();
|
|
98
|
+
const indexed = new Map(this.selectIndexedSessions.all().map((row) => [row.thread_id, row]));
|
|
99
|
+
const entries = [...files.entries()];
|
|
100
|
+
let completed = 0;
|
|
101
|
+
let changed = 0;
|
|
102
|
+
onProgress({ phase: "indexing", completed, total: entries.length, changed });
|
|
103
|
+
|
|
104
|
+
for (const [threadId, filePath] of entries) {
|
|
105
|
+
let metadata;
|
|
106
|
+
try {
|
|
107
|
+
metadata = await stat(filePath);
|
|
108
|
+
} catch {
|
|
109
|
+
completed += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const previous = indexed.get(threadId);
|
|
113
|
+
if (previous?.file_path === filePath && previous.file_size === metadata.size && previous.modified_at === metadata.mtimeMs) {
|
|
114
|
+
indexed.delete(threadId);
|
|
115
|
+
completed += 1;
|
|
116
|
+
onProgress({ phase: "indexing", completed, total: entries.length, changed });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const chunks = await extractConversationText(filePath);
|
|
121
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
122
|
+
try {
|
|
123
|
+
this.deleteMessages.run(threadId);
|
|
124
|
+
for (const chunk of chunks) this.insertMessage.run(threadId, chunk.role, chunk.text);
|
|
125
|
+
this.upsertSession.run(threadId, filePath, metadata.size, metadata.mtimeMs);
|
|
126
|
+
this.database.exec("COMMIT");
|
|
127
|
+
} catch (error) {
|
|
128
|
+
this.database.exec("ROLLBACK");
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
indexed.delete(threadId);
|
|
132
|
+
changed += 1;
|
|
133
|
+
completed += 1;
|
|
134
|
+
onProgress({ phase: "indexing", completed, total: entries.length, changed });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const threadId of indexed.keys()) {
|
|
138
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
139
|
+
try {
|
|
140
|
+
this.deleteMessages.run(threadId);
|
|
141
|
+
this.deleteSession.run(threadId);
|
|
142
|
+
this.database.exec("COMMIT");
|
|
143
|
+
} catch (error) {
|
|
144
|
+
this.database.exec("ROLLBACK");
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
changed += 1;
|
|
148
|
+
}
|
|
149
|
+
const result = { phase: "ready", completed: entries.length, total: entries.length, changed };
|
|
150
|
+
this.database.pragma("optimize");
|
|
151
|
+
this.database.pragma("wal_checkpoint(TRUNCATE)");
|
|
152
|
+
onProgress(result);
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
search({ query, threadIds, limit = defaultResultLimit }) {
|
|
157
|
+
if (this.readOnly) throw new Error("Cannot search through a status-only index connection");
|
|
158
|
+
const normalizedQuery = typeof query === "string" ? query.trim() : "";
|
|
159
|
+
if (!normalizedQuery || !Array.isArray(threadIds) || threadIds.length === 0) return [];
|
|
160
|
+
const boundedLimit = Math.max(1, Math.min(maximumResultLimit, Number(limit) || defaultResultLimit));
|
|
161
|
+
const originalIdByLocalId = new Map();
|
|
162
|
+
for (const threadId of threadIds) {
|
|
163
|
+
if (typeof threadId !== "string" || !threadId) continue;
|
|
164
|
+
originalIdByLocalId.set(localThreadIdFor(threadId), threadId);
|
|
165
|
+
}
|
|
166
|
+
const localThreadIds = [...originalIdByLocalId.keys()];
|
|
167
|
+
if (localThreadIds.length === 0) return [];
|
|
168
|
+
const rows = [...normalizedQuery].length < 3
|
|
169
|
+
? this.searchShortQuery.iterate(normalizedQuery, JSON.stringify(localThreadIds), -1)
|
|
170
|
+
: this.searchFts.iterate(quotedFtsQuery(normalizedQuery), JSON.stringify(localThreadIds), -1);
|
|
171
|
+
const results = [];
|
|
172
|
+
const seenThreadIds = new Set();
|
|
173
|
+
for (const row of rows) {
|
|
174
|
+
const threadId = originalIdByLocalId.get(row.thread_id);
|
|
175
|
+
if (!threadId || seenThreadIds.has(threadId)) continue;
|
|
176
|
+
seenThreadIds.add(threadId);
|
|
177
|
+
results.push({ threadId, role: row.role, snippet: buildSnippet(row.content, normalizedQuery), score: -Number(row.rank) || 0 });
|
|
178
|
+
if (results.length >= boundedLimit) break;
|
|
179
|
+
}
|
|
180
|
+
return results;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
status() {
|
|
184
|
+
const row = this.database.prepare("SELECT count(*) AS count FROM indexed_sessions").get();
|
|
185
|
+
return { indexedSessions: Number(row?.count ?? 0) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
close() {
|
|
189
|
+
this.database.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
|
|
6
|
+
function isInternalSession({ source, threadSource }) {
|
|
7
|
+
if (threadSource === "subagent" || threadSource === "guardian_review") return true;
|
|
8
|
+
if (source === "subagent") return true;
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(source);
|
|
11
|
+
return parsed === "subagent" || (parsed !== null && typeof parsed === "object" && Object.hasOwn(parsed, "subagent"));
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Keep the private Codex database schema isolated and always open it read-only.
|
|
18
|
+
export class SessionCatalog {
|
|
19
|
+
constructor(root = process.env.CODEX_HOME ?? join(homedir(), ".codex")) {
|
|
20
|
+
this.root = root;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async read() {
|
|
24
|
+
let database;
|
|
25
|
+
try {
|
|
26
|
+
const files = (await readdir(this.root)).filter((name) => /^state_\d+\.sqlite$/u.test(name))
|
|
27
|
+
.sort((a, b) => Number(b.match(/\d+/u)[0]) - Number(a.match(/\d+/u)[0]));
|
|
28
|
+
if (!files.length) return { items: [], error: "Local session catalog is unavailable", complete: false };
|
|
29
|
+
database = new Database(join(this.root, files[0]), { readonly: true, fileMustExist: true, timeout: 1000 });
|
|
30
|
+
const columns = new Set(database.pragma("table_info(threads)").map(({ name }) => name));
|
|
31
|
+
for (const column of ["id", "title", "updated_at", "archived"]) {
|
|
32
|
+
if (!columns.has(column)) throw new Error("Unsupported Codex session catalog schema");
|
|
33
|
+
}
|
|
34
|
+
const title = columns.has("name") ? "COALESCE(NULLIF(name, ''), title)" : "title";
|
|
35
|
+
const time = columns.has("updated_at_ms") ? "COALESCE(updated_at_ms, updated_at * 1000)" : "updated_at * 1000";
|
|
36
|
+
const project = columns.has("project_id") ? "project_id" : "NULL";
|
|
37
|
+
const pinned = columns.has("is_pinned") ? "is_pinned" : "NULL";
|
|
38
|
+
const source = columns.has("source") ? "source" : "NULL";
|
|
39
|
+
const threadSource = columns.has("thread_source") ? "thread_source" : "NULL";
|
|
40
|
+
const rows = database.prepare(`SELECT id AS threadId, ${title} AS raw, ${time} AS updatedAt, ${project} AS projectId, ${pinned} AS pinned, ${source} AS source, ${threadSource} AS threadSource FROM threads WHERE archived = 0 ORDER BY ${time} DESC, id`).all();
|
|
41
|
+
// Older builds encode child provenance in source; newer builds also expose thread_source.
|
|
42
|
+
const items = rows.filter((row) => !isInternalSession(row)).map(({ source: _source, threadSource: _threadSource, ...row }) => ({
|
|
43
|
+
...row, pinned: row.pinned === null ? null : Boolean(row.pinned),
|
|
44
|
+
}));
|
|
45
|
+
return { items, error: null, complete: true };
|
|
46
|
+
} catch {
|
|
47
|
+
return { items: [], error: "Local session catalog could not be read; sidebar-only results are available", complete: false };
|
|
48
|
+
} finally {
|
|
49
|
+
database?.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { createTagSettings, DEFAULT_TAG_DEFINITIONS, TAG_SETTINGS_SCHEMA_VERSION } from "./tag-settings.mjs";
|
|
5
|
+
|
|
6
|
+
export class SettingsRepository {
|
|
7
|
+
#serialized = null;
|
|
8
|
+
#writeQueue = Promise.resolve();
|
|
9
|
+
|
|
10
|
+
constructor(path, options = {}) {
|
|
11
|
+
this.path = path;
|
|
12
|
+
this.fallback = options.fallback ?? DEFAULT_TAG_DEFINITIONS;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async read() {
|
|
16
|
+
try {
|
|
17
|
+
const source = await readFile(this.path, "utf8");
|
|
18
|
+
const candidate = JSON.parse(source);
|
|
19
|
+
if (!candidate || typeof candidate !== "object" || ![1, TAG_SETTINGS_SCHEMA_VERSION].includes(candidate.schemaVersion) || !Array.isArray(candidate.tags)) {
|
|
20
|
+
throw new Error("Unsupported or malformed tag settings schema");
|
|
21
|
+
}
|
|
22
|
+
const settings = createTagSettings(candidate.tags);
|
|
23
|
+
this.#serialized = this.#serialize(settings);
|
|
24
|
+
return { settings, exists: true, error: null };
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error?.code !== "ENOENT") {
|
|
27
|
+
return {
|
|
28
|
+
settings: createTagSettings(this.fallback),
|
|
29
|
+
exists: false,
|
|
30
|
+
error: `Unable to read tag settings: ${error.message}`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return { settings: createTagSettings(this.fallback), exists: false, error: null };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
write(definitions) {
|
|
38
|
+
const operation = this.#writeQueue.then(() => this.#commit(definitions));
|
|
39
|
+
this.#writeQueue = operation.then(() => undefined, () => undefined);
|
|
40
|
+
return operation;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async #commit(definitions) {
|
|
44
|
+
const settings = createTagSettings(definitions);
|
|
45
|
+
const serialized = this.#serialize(settings);
|
|
46
|
+
if (serialized === this.#serialized) return { settings, changed: false };
|
|
47
|
+
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
48
|
+
const temporaryPath = `${this.path}.next-${process.pid}`;
|
|
49
|
+
await writeFile(temporaryPath, serialized, { encoding: "utf8", mode: 0o600 });
|
|
50
|
+
await rename(temporaryPath, this.path);
|
|
51
|
+
this.#serialized = serialized;
|
|
52
|
+
return { settings, changed: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#serialize(settings) {
|
|
56
|
+
return `${JSON.stringify(settings, null, 2)}\n`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface TagDefinition {
|
|
2
|
+
name: string;
|
|
3
|
+
color: string;
|
|
4
|
+
description: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface TagSettings {
|
|
8
|
+
schemaVersion: 2;
|
|
9
|
+
tags: TagDefinition[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const TAG_SETTINGS_SCHEMA_VERSION: 2;
|
|
13
|
+
export const TAG_COLOR_PRESETS: ReadonlyArray<Readonly<{ name: string; color: string }>>;
|
|
14
|
+
export const LEGACY_TONE_COLORS: Readonly<Record<string, string>>;
|
|
15
|
+
export const DEFAULT_TAG_DEFINITIONS: ReadonlyArray<Readonly<TagDefinition>>;
|
|
16
|
+
|
|
17
|
+
export function normalizeTagDefinitions(value: unknown, fallback?: ReadonlyArray<Readonly<TagDefinition>>): TagDefinition[];
|
|
18
|
+
export function createTagSettings(definitions: unknown): TagSettings;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const TAG_SETTINGS_SCHEMA_VERSION = 2;
|
|
2
|
+
|
|
3
|
+
export const TAG_COLOR_PRESETS = Object.freeze([
|
|
4
|
+
{ name: "海蓝", color: "#4f8fd7" },
|
|
5
|
+
{ name: "鸢紫", color: "#956ad1" },
|
|
6
|
+
{ name: "珊瑚", color: "#d95c5c" },
|
|
7
|
+
{ name: "琥珀", color: "#c98b28" },
|
|
8
|
+
{ name: "松绿", color: "#3f9a6b" },
|
|
9
|
+
{ name: "雾灰", color: "#7c8798" },
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
const PRESET_COLORS = Object.fromEntries(TAG_COLOR_PRESETS.map(({ name, color }) => [name, color]));
|
|
13
|
+
export const LEGACY_TONE_COLORS = Object.freeze({
|
|
14
|
+
amber: PRESET_COLORS["琥珀"],
|
|
15
|
+
blue: PRESET_COLORS["海蓝"],
|
|
16
|
+
red: PRESET_COLORS["珊瑚"],
|
|
17
|
+
purple: PRESET_COLORS["鸢紫"],
|
|
18
|
+
green: PRESET_COLORS["松绿"],
|
|
19
|
+
neutral: PRESET_COLORS["雾灰"],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_TAG_DEFINITIONS = Object.freeze([
|
|
23
|
+
{ name: "Feature", color: PRESET_COLORS["海蓝"], description: "Build or extend functionality. Use when the main goal is to implement a new capability or improve existing behavior, rather than fix a defect." },
|
|
24
|
+
{ name: "Bug", color: PRESET_COLORS["珊瑚"], description: "Diagnose and fix incorrect behavior, errors, or regressions. Use when the goal is to restore expected behavior, including investigation needed for the fix." },
|
|
25
|
+
{ name: "Design", color: PRESET_COLORS["鸢紫"], description: "Define how a solution should look or work: UI, interactions, architecture, or technical plans. Use when the main deliverable is a design or specification." },
|
|
26
|
+
{ name: "Research", color: PRESET_COLORS["松绿"], description: "Explore a topic, understand existing code, compare options, or assess feasibility. Use when the main deliverable is findings or an explanation, rather than a design or implementation." },
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function normalizeColor(value, legacyTone, fallbackColor) {
|
|
30
|
+
if (typeof value === "string" && /^#[0-9a-f]{6}$/iu.test(value.trim())) return value.trim().toLocaleLowerCase();
|
|
31
|
+
return LEGACY_TONE_COLORS[legacyTone] ?? fallbackColor ?? LEGACY_TONE_COLORS.neutral;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeDescription(value, fallbackDescription = "") {
|
|
35
|
+
if (typeof value !== "string") return fallbackDescription;
|
|
36
|
+
return value.replace(/\s+/gu, " ").trim().slice(0, 240);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeTagDefinitions(value, fallback = DEFAULT_TAG_DEFINITIONS) {
|
|
40
|
+
if (!Array.isArray(value)) return fallback.map((item) => ({ ...item }));
|
|
41
|
+
const defaultsByName = new Map(fallback.map((item) => [item.name.toLocaleLowerCase(), item]));
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
const definitions = [];
|
|
44
|
+
for (const item of value) {
|
|
45
|
+
const name = typeof item?.name === "string" ? item.name.trim() : "";
|
|
46
|
+
const key = name.toLocaleLowerCase();
|
|
47
|
+
if (!name || name.length > 32 || /[\[\]【】\r\n]/u.test(name) || seen.has(key)) continue;
|
|
48
|
+
const defaultDefinition = defaultsByName.get(key);
|
|
49
|
+
seen.add(key);
|
|
50
|
+
definitions.push({
|
|
51
|
+
name,
|
|
52
|
+
color: normalizeColor(item?.color, item?.tone, defaultDefinition?.color),
|
|
53
|
+
description: normalizeDescription(item?.description, defaultDefinition?.description),
|
|
54
|
+
});
|
|
55
|
+
if (definitions.length >= 32) break;
|
|
56
|
+
}
|
|
57
|
+
return definitions;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createTagSettings(definitions) {
|
|
61
|
+
return {
|
|
62
|
+
schemaVersion: TAG_SETTINGS_SCHEMA_VERSION,
|
|
63
|
+
tags: normalizeTagDefinitions(definitions),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ParsedTitleMetadata {
|
|
2
|
+
raw: string;
|
|
3
|
+
tag: string;
|
|
4
|
+
time: string;
|
|
5
|
+
title: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function colorForTag(tag: string): string;
|
|
9
|
+
export function parseTitleMetadata(value: unknown): ParsedTitleMetadata | null;
|
|
10
|
+
export function parseSidebarTitle(value: unknown): (ParsedTitleMetadata & { color: string }) | null;
|
|
11
|
+
export const titlePatternSource: string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { DEFAULT_TAG_DEFINITIONS, LEGACY_TONE_COLORS } from "./tag-settings.mjs";
|
|
2
|
+
|
|
3
|
+
const MAX_TAG_LENGTH = 32;
|
|
4
|
+
const MAX_TIME_LENGTH = 32;
|
|
5
|
+
|
|
6
|
+
const BRACKETED_TITLE = /^(?:\[([^\]\r\n]{1,32})\]|【([^】\r\n]{1,32})】)(?:(?:\[([^\]\r\n]{1,32})\]|【([^】\r\n]{1,32})】))?\s*(.+)$/u;
|
|
7
|
+
|
|
8
|
+
const TAG_COLORS = new Map(DEFAULT_TAG_DEFINITIONS.map(({ name, color }) => [name.toLocaleLowerCase(), color]));
|
|
9
|
+
|
|
10
|
+
export function colorForTag(tag) {
|
|
11
|
+
return TAG_COLORS.get(tag.trim().toLocaleLowerCase()) ?? LEGACY_TONE_COLORS.neutral;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseTitleMetadata(value) {
|
|
15
|
+
if (typeof value !== "string") return null;
|
|
16
|
+
const raw = value.trim();
|
|
17
|
+
const match = BRACKETED_TITLE.exec(raw);
|
|
18
|
+
if (!match) return null;
|
|
19
|
+
|
|
20
|
+
const tag = (match[1] ?? match[2] ?? "").trim();
|
|
21
|
+
const time = (match[3] ?? match[4] ?? "").trim();
|
|
22
|
+
const title = match[5].trim();
|
|
23
|
+
if (!tag || !title || tag.length > MAX_TAG_LENGTH || time.length > MAX_TIME_LENGTH) return null;
|
|
24
|
+
|
|
25
|
+
return { raw, tag, time, title };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseSidebarTitle(value) {
|
|
29
|
+
const parsed = parseTitleMetadata(value);
|
|
30
|
+
return parsed ? { ...parsed, color: colorForTag(parsed.tag) } : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const titlePatternSource = BRACKETED_TITLE.source;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const commands = new Set(["install", "enable", "on", "disable", "off", "restore", "status", "doctor", "update", "uninstall", "help", "version"]);
|
|
2
|
+
const flags = new Set(["--json", "--purge", "--help", "-h", "--version", "-v"]);
|
|
3
|
+
|
|
4
|
+
export function parseCliOptions(args) {
|
|
5
|
+
for (const argument of args) {
|
|
6
|
+
if (argument.startsWith("-") && !flags.has(argument)) throw new Error(`Unknown option: ${argument}`);
|
|
7
|
+
}
|
|
8
|
+
const positional = args.filter((argument) => !argument.startsWith("-"));
|
|
9
|
+
if (positional.length > 1) throw new Error("Expected one command. Run codex-tags --help for usage.");
|
|
10
|
+
const command = args.some((argument) => ["--help", "-h"].includes(argument)) ? "help"
|
|
11
|
+
: args.some((argument) => ["--version", "-v"].includes(argument)) ? "version"
|
|
12
|
+
: positional[0] ?? "install";
|
|
13
|
+
if (!commands.has(command)) throw new Error(`Unknown command: ${command}`);
|
|
14
|
+
const purge = args.includes("--purge");
|
|
15
|
+
if (purge && command !== "uninstall") throw new Error("--purge is only supported with uninstall.");
|
|
16
|
+
return { command, purge, json: args.includes("--json") };
|
|
17
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function runtimeHealthChecks({ runtime = {}, plugin = {}, supervisor = {} }) {
|
|
2
|
+
const versions = runtime.activeVersions;
|
|
3
|
+
return [
|
|
4
|
+
{ id: "runtime-installed", ok: runtime.installed === true, message: "Local runtime installed" },
|
|
5
|
+
{ id: "legacy-supervisor-stopped", ok: supervisor.loaded !== true, message: "No automatic app takeover running" },
|
|
6
|
+
{ id: "plugin-installed", ok: plugin.installed === true && plugin.enabled === true && plugin.payloadPresent === true && !plugin.error, message: "Plugin installed and enabled" },
|
|
7
|
+
{ id: "cdp", ok: runtime.cdp === true, message: "Owned loopback connection active" },
|
|
8
|
+
{ id: "controller", ok: Number.isSafeInteger(runtime.controllerPid) && runtime.controllerPid > 0 && !runtime.error, message: "Local controller running" },
|
|
9
|
+
{ id: "injected", ok: typeof runtime.sourceVersion === "string" && Array.isArray(versions) && versions.length > 0 && versions.every((version) => version === runtime.sourceVersion), message: "Current Tags UI loaded in every discovered window" },
|
|
10
|
+
{ id: "settings", ok: Array.isArray(runtime.tagSettings?.tags) && !runtime.tagSettingsError, message: "Tag settings readable" },
|
|
11
|
+
{ id: "search", ok: Number.isInteger(runtime.searchIndex?.indexedSessions) && !runtime.searchIndex?.error, message: "Local search index readable" },
|
|
12
|
+
{ id: "catalog", ok: runtime.catalog?.complete === true && !runtime.catalog?.error, message: "Active local session catalog readable" },
|
|
13
|
+
{ id: "sidebar", ok: Array.isArray(runtime.activeWindows) && runtime.activeWindows.some((window) => window?.toolbar === true), message: "Tags navigation available" },
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function activationHealth(status) {
|
|
18
|
+
const checks = runtimeHealthChecks(status);
|
|
19
|
+
return { ok: checks.every(({ ok }) => ok), checks, hookAuthorization: "Review and enable all three Codex Tags hooks in Codex Plugins. CLI does not verify or grant hook trust." };
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { lstat, mkdir, open, rm } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export async function withLifecycleLock(directory, operation) {
|
|
4
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
5
|
+
if ((await lstat(directory)).isSymbolicLink()) throw new Error("Installation directory must not be a symbolic link.");
|
|
6
|
+
const path = `${directory}/.lifecycle.lock`;
|
|
7
|
+
let handle;
|
|
8
|
+
try {
|
|
9
|
+
handle = await open(path, "wx", 0o600);
|
|
10
|
+
} catch (error) {
|
|
11
|
+
if (error.code !== "EEXIST") throw error;
|
|
12
|
+
throw new Error(`Another installation may be running. Wait for it to finish. If it crashed, remove only ${path} after confirming no Codex Tags CLI is running.`);
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
16
|
+
return await operation();
|
|
17
|
+
} finally {
|
|
18
|
+
await handle.close();
|
|
19
|
+
await rm(path, { force: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { createManager } from "./manager-core.mjs";
|
|
5
|
+
|
|
6
|
+
const manager = createManager();
|
|
7
|
+
const command = process.argv[2] ?? "status";
|
|
8
|
+
let result;
|
|
9
|
+
|
|
10
|
+
if (command === "install") result = await manager.installRuntime();
|
|
11
|
+
else if (command === "status") result = await manager.status();
|
|
12
|
+
else if (command === "enable" || command === "on" || command === "update") result = await manager.enable();
|
|
13
|
+
else if (command === "apply") result = await manager.runController("apply");
|
|
14
|
+
else if (command === "restore" || command === "disable" || command === "off") result = await manager.disable();
|
|
15
|
+
else if (command === "doctor") result = await manager.doctor();
|
|
16
|
+
else if (command === "uninstall") result = await manager.uninstall({ purge: process.argv.includes("--purge") });
|
|
17
|
+
else throw new Error(`Unknown command: ${basename(command)}`);
|
|
18
|
+
|
|
19
|
+
console.log(JSON.stringify(result, null, 2));
|