@prover-coder-ai/context-doc 1.0.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/README.md +19 -0
- package/dist/core/greeting.js +25 -0
- package/dist/core/knowledge.js +51 -0
- package/dist/main.js +27 -0
- package/dist/shell/codexSync.js +119 -0
- package/dist/shell/qwenSync.js +49 -0
- package/dist/shell/syncKnowledge.js +47 -0
- package/dist/shell/syncShared.js +13 -0
- package/dist/shell/syncTypes.js +1 -0
- package/package.json +58 -0
- package/src/core/greeting.ts +39 -0
- package/src/core/knowledge.ts +148 -0
- package/src/main.ts +49 -0
- package/src/shell/codexSync.ts +189 -0
- package/src/shell/qwenSync.ts +98 -0
- package/src/shell/syncKnowledge.ts +62 -0
- package/src/shell/syncShared.ts +20 -0
- package/src/shell/syncTypes.ts +14 -0
- package/src/types/env.d.ts +10 -0
- package/tsconfig.build.json +18 -0
- package/vitest.config.ts +81 -0
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Knowledge Sync CLI
|
|
2
|
+
|
|
3
|
+
Command: `npm run sync:knowledge` (or `tsx src/shell/syncKnowledge.ts`) — copies only dialogs that belong to the current project into `.knowledge/.codex`.
|
|
4
|
+
|
|
5
|
+
## Flags
|
|
6
|
+
- `--source, -s <path>` — explicit path to `.codex`.
|
|
7
|
+
- `--dest, -d <path>` — destination root (defaults to `.knowledge/.codex` in the project).
|
|
8
|
+
- `--project-url` / `--project-name <url>` — override repository URL (otherwise read from `package.json`).
|
|
9
|
+
- `--meta-root <path>` — path to a meta folder; if it’s not already `.codex`, the tool looks for `<meta-root>/.codex`.
|
|
10
|
+
|
|
11
|
+
## `.codex` lookup order
|
|
12
|
+
1) `--source` (if provided)
|
|
13
|
+
2) env `CODEX_SOURCE_DIR`
|
|
14
|
+
3) `--meta-root` (either the `.codex` itself or `<meta-root>/.codex`)
|
|
15
|
+
4) project-local `.codex`
|
|
16
|
+
5) home `~/.codex`
|
|
17
|
+
|
|
18
|
+
Only `.jsonl` files whose `git.repository_url` or `cwd` match this project are copied.\
|
|
19
|
+
Copies are placed under `.knowledge/.codex` preserving the directory structure.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
const normalize = (value) => value.trim();
|
|
3
|
+
const isNonEmpty = (value) => value.length > 0;
|
|
4
|
+
/**
|
|
5
|
+
* CHANGE: Introduce pure greeting synthesis for console entrypoint
|
|
6
|
+
* WHY: Provide mathematically checkable construction of startup banner without side effects
|
|
7
|
+
* QUOTE(ТЗ): "Каждая функция — это теорема."
|
|
8
|
+
* REF: REQ-GREETING-CORE
|
|
9
|
+
* SOURCE: AGENTS.md – функциональное ядро без побочных эффектов
|
|
10
|
+
* FORMAT THEOREM: ∀p ∈ AppProfile: valid(p) → nonEmpty(buildGreeting(p).message)
|
|
11
|
+
* PURITY: CORE
|
|
12
|
+
* EFFECT: None (pure)
|
|
13
|
+
* INVARIANT: output.message.length > 0 when Option is Some
|
|
14
|
+
* COMPLEXITY: O(1)/O(1)
|
|
15
|
+
*/
|
|
16
|
+
export const buildGreeting = (profile) => {
|
|
17
|
+
const trimmedName = normalize(profile.name);
|
|
18
|
+
const trimmedMission = normalize(profile.mission);
|
|
19
|
+
if (!isNonEmpty(trimmedName) || !isNonEmpty(trimmedMission)) {
|
|
20
|
+
return Option.none();
|
|
21
|
+
}
|
|
22
|
+
return Option.some({
|
|
23
|
+
message: `${trimmedName}: ${trimmedMission}`,
|
|
24
|
+
});
|
|
25
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Option, pipe } from "effect";
|
|
3
|
+
const isJsonRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
+
const normalizeRepositoryUrl = (value) => pipe(value.trim(), (trimmed) => trimmed.replace(/^git\+/, ""), (withoutPrefix) => withoutPrefix.replace(/\.git$/, ""), (withoutGitSuffix) => withoutGitSuffix.replace(/^git@github\.com:/, "https://github.com/"), (withoutSsh) => withoutSsh.replace(/^ssh:\/\/git@github\.com\//, "https://github.com/"), (normalized) => normalized.toLowerCase());
|
|
5
|
+
const normalizeCwd = (value) => path.resolve(value);
|
|
6
|
+
const pickString = (record, key) => {
|
|
7
|
+
const candidate = record[key];
|
|
8
|
+
return typeof candidate === "string" ? Option.some(candidate) : Option.none();
|
|
9
|
+
};
|
|
10
|
+
const pickRecord = (record, key) => pipe(record[key], Option.fromNullable, Option.filter(isJsonRecord));
|
|
11
|
+
const pickGitRepository = (record) => pipe(pickString(record, "repository_url"), Option.orElse(() => pickString(record, "repositoryUrl")));
|
|
12
|
+
const extractRepository = (record) => pipe(pickRecord(record, "git"), Option.flatMap(pickGitRepository), Option.orElse(() => pipe(pickRecord(record, "payload"), Option.flatMap((payload) => pipe(pickRecord(payload, "git"), Option.flatMap(pickGitRepository))))));
|
|
13
|
+
const extractCwd = (record) => pipe(pickString(record, "cwd"), Option.orElse(() => pipe(pickRecord(record, "payload"), Option.flatMap((payload) => pickString(payload, "cwd")))));
|
|
14
|
+
const toMetadata = (value) => {
|
|
15
|
+
if (!isJsonRecord(value)) {
|
|
16
|
+
return { repositoryUrl: Option.none(), cwd: Option.none() };
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
repositoryUrl: extractRepository(value),
|
|
20
|
+
cwd: extractCwd(value),
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
const normalizeLocator = (repositoryUrl, cwd) => ({
|
|
24
|
+
normalizedRepositoryUrl: normalizeRepositoryUrl(repositoryUrl),
|
|
25
|
+
normalizedCwd: normalizeCwd(cwd),
|
|
26
|
+
});
|
|
27
|
+
const safeParseJson = (line) => {
|
|
28
|
+
try {
|
|
29
|
+
return Option.some(JSON.parse(line));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return Option.none();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const metadataMatches = (metadata, locator) => {
|
|
36
|
+
const repoMatches = Option.exists(metadata.repositoryUrl, (repositoryUrl) => normalizeRepositoryUrl(repositoryUrl) === locator.normalizedRepositoryUrl);
|
|
37
|
+
const cwdMatches = Option.exists(metadata.cwd, (cwdValue) => {
|
|
38
|
+
const normalized = normalizeCwd(cwdValue);
|
|
39
|
+
return (locator.normalizedCwd.startsWith(normalized) ||
|
|
40
|
+
normalized.startsWith(locator.normalizedCwd));
|
|
41
|
+
});
|
|
42
|
+
return repoMatches || cwdMatches;
|
|
43
|
+
};
|
|
44
|
+
export const buildProjectLocator = (repositoryUrl, cwd) => normalizeLocator(repositoryUrl, cwd);
|
|
45
|
+
export const linesMatchProject = (lines, locator) => lines.some((line) => {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
if (trimmed.length === 0) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return pipe(safeParseJson(trimmed), Option.map(toMetadata), Option.exists((metadata) => metadataMatches(metadata, locator)));
|
|
51
|
+
});
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Console, Effect, Option, pipe } from "effect";
|
|
2
|
+
import { match } from "ts-pattern";
|
|
3
|
+
import { buildGreeting } from "./core/greeting.js";
|
|
4
|
+
const appProfile = {
|
|
5
|
+
name: "Context Console",
|
|
6
|
+
mission: "Functional core ready for verifiable effects",
|
|
7
|
+
};
|
|
8
|
+
const toStartupError = (reason) => ({
|
|
9
|
+
_tag: "StartupError",
|
|
10
|
+
reason,
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* CHANGE: Compose shell-level startup program around pure greeting builder
|
|
14
|
+
* WHY: Isolate side effects (console IO) while delegating computation to functional core
|
|
15
|
+
* QUOTE(ТЗ): "FUNCTIONAL CORE, IMPERATIVE SHELL"
|
|
16
|
+
* REF: REQ-SHELL-STARTUP
|
|
17
|
+
* SOURCE: AGENTS.md — эффекты только в SHELL
|
|
18
|
+
* FORMAT THEOREM: ∀profile: valid(profile) → logs(buildGreeting(profile))
|
|
19
|
+
* PURITY: SHELL
|
|
20
|
+
* EFFECT: Effect<void, StartupError, Console>
|
|
21
|
+
* INVARIANT: Console side-effects occur once and only after successful greeting synthesis
|
|
22
|
+
* COMPLEXITY: O(1)/O(1)
|
|
23
|
+
*/
|
|
24
|
+
const program = pipe(Effect.succeed(buildGreeting(appProfile)), Effect.filterOrFail(Option.isSome, () => toStartupError("Profile must not be empty")), Effect.map((option) => option.value), Effect.flatMap((greeting) => Console.log(greeting.message)), Effect.catchAll((error) => match(error)
|
|
25
|
+
.with({ _tag: "StartupError" }, (startupError) => Console.error(`StartupError: ${startupError.reason}`))
|
|
26
|
+
.exhaustive()));
|
|
27
|
+
Effect.runSync(program);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import * as S from "@effect/schema/Schema";
|
|
5
|
+
import { Console, Effect, pipe } from "effect";
|
|
6
|
+
import { buildProjectLocator, linesMatchProject } from "../core/knowledge.js";
|
|
7
|
+
import { ensureDirectory, syncError } from "./syncShared.js";
|
|
8
|
+
const PackageRepositorySchema = S.Struct({
|
|
9
|
+
url: S.String,
|
|
10
|
+
});
|
|
11
|
+
const PackageFileSchema = S.Struct({
|
|
12
|
+
repository: S.optional(PackageRepositorySchema),
|
|
13
|
+
});
|
|
14
|
+
const PackageFileFromJson = S.compose(S.parseJson(), PackageFileSchema);
|
|
15
|
+
const decodePackageFile = S.decodeUnknownSync(PackageFileFromJson);
|
|
16
|
+
const readRepositoryUrl = (cwd, repositoryUrlOverride) => Effect.try({
|
|
17
|
+
try: () => {
|
|
18
|
+
if (repositoryUrlOverride !== undefined) {
|
|
19
|
+
return repositoryUrlOverride;
|
|
20
|
+
}
|
|
21
|
+
const raw = fs.readFileSync(path.join(cwd, "package.json"), "utf8");
|
|
22
|
+
const parsed = decodePackageFile(raw);
|
|
23
|
+
const repositoryUrl = parsed.repository?.url;
|
|
24
|
+
if (repositoryUrl === undefined) {
|
|
25
|
+
throw new Error("repository url is missing");
|
|
26
|
+
}
|
|
27
|
+
return repositoryUrl;
|
|
28
|
+
},
|
|
29
|
+
catch: () => syncError("package.json", "Cannot read repository url"),
|
|
30
|
+
});
|
|
31
|
+
const resolveSourceDir = (cwd, override, metaRoot) => Effect.try({
|
|
32
|
+
try: () => {
|
|
33
|
+
const envSource = process.env.CODEX_SOURCE_DIR;
|
|
34
|
+
const metaCandidate = metaRoot === undefined
|
|
35
|
+
? undefined
|
|
36
|
+
: metaRoot.endsWith(".codex")
|
|
37
|
+
? metaRoot
|
|
38
|
+
: path.join(metaRoot, ".codex");
|
|
39
|
+
const localSource = path.join(cwd, ".codex");
|
|
40
|
+
const homeSource = path.join(os.homedir(), ".codex");
|
|
41
|
+
const candidates = [
|
|
42
|
+
override,
|
|
43
|
+
envSource,
|
|
44
|
+
metaCandidate,
|
|
45
|
+
localSource,
|
|
46
|
+
homeSource,
|
|
47
|
+
];
|
|
48
|
+
const existing = candidates.find((candidate) => candidate !== undefined && fs.existsSync(candidate));
|
|
49
|
+
if (existing === undefined) {
|
|
50
|
+
throw new Error("source .codex not found");
|
|
51
|
+
}
|
|
52
|
+
return existing;
|
|
53
|
+
},
|
|
54
|
+
catch: () => syncError(".codex", "Source .codex directory is missing"),
|
|
55
|
+
});
|
|
56
|
+
const collectJsonlFiles = (root) => Effect.try({
|
|
57
|
+
try: () => {
|
|
58
|
+
const collected = [];
|
|
59
|
+
const walk = (current) => {
|
|
60
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const fullPath = path.join(current, entry.name);
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
walk(fullPath);
|
|
65
|
+
}
|
|
66
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
67
|
+
collected.push(fullPath);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
walk(root);
|
|
72
|
+
return collected;
|
|
73
|
+
},
|
|
74
|
+
catch: () => syncError(root, "Cannot traverse .codex"),
|
|
75
|
+
});
|
|
76
|
+
const isFileRelevant = (filePath, locator) => Effect.try({
|
|
77
|
+
try: () => {
|
|
78
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
79
|
+
const lines = content.split("\n");
|
|
80
|
+
return linesMatchProject(lines, locator);
|
|
81
|
+
},
|
|
82
|
+
catch: () => syncError(filePath, "Cannot read jsonl file"),
|
|
83
|
+
});
|
|
84
|
+
const copyRelevantFile = (sourceRoot, destinationRoot, filePath) => Effect.try({
|
|
85
|
+
try: () => {
|
|
86
|
+
const relative = path.relative(sourceRoot, filePath);
|
|
87
|
+
const targetPath = path.join(destinationRoot, relative);
|
|
88
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
89
|
+
fs.copyFileSync(filePath, targetPath);
|
|
90
|
+
},
|
|
91
|
+
catch: () => syncError(filePath, "Cannot copy file into .knowledge/.codex"),
|
|
92
|
+
});
|
|
93
|
+
const selectRelevantFiles = (files, locator) => Effect.reduce(files, [], (acc, filePath) => pipe(isFileRelevant(filePath, locator), Effect.map((isRelevant) => {
|
|
94
|
+
if (isRelevant) {
|
|
95
|
+
acc.push(filePath);
|
|
96
|
+
}
|
|
97
|
+
return acc;
|
|
98
|
+
})));
|
|
99
|
+
// CHANGE: Extract Codex dialog sync into dedicated module for clarity.
|
|
100
|
+
// WHY: Separate Codex-specific shell effects from other sync flows.
|
|
101
|
+
// QUOTE(ТЗ): "вынеси в отдельный файл"
|
|
102
|
+
// REF: user request 2025-11-26
|
|
103
|
+
// SOURCE: internal requirement
|
|
104
|
+
// FORMAT THEOREM: ∀f ∈ Files: relevant(f, locator) → copied(f, destination)
|
|
105
|
+
// PURITY: SHELL
|
|
106
|
+
// EFFECT: Effect<void, SyncError, never>
|
|
107
|
+
// INVARIANT: ∀f ∈ copiedFiles: linesMatchProject(f, locator)
|
|
108
|
+
// COMPLEXITY: O(n) time / O(n) space, n = |files|
|
|
109
|
+
export const syncCodex = (options) => Effect.gen(function* (_) {
|
|
110
|
+
const repositoryUrl = yield* _(readRepositoryUrl(options.cwd, options.repositoryUrlOverride));
|
|
111
|
+
const sourceDir = yield* _(resolveSourceDir(options.cwd, options.sourceDir, options.metaRoot));
|
|
112
|
+
const destinationDir = options.destinationDir ?? path.join(options.cwd, ".knowledge", ".codex");
|
|
113
|
+
yield* _(ensureDirectory(destinationDir));
|
|
114
|
+
const locator = buildProjectLocator(repositoryUrl, options.cwd);
|
|
115
|
+
const allJsonlFiles = yield* _(collectJsonlFiles(sourceDir));
|
|
116
|
+
const relevantFiles = yield* _(selectRelevantFiles(allJsonlFiles, locator));
|
|
117
|
+
yield* _(Effect.forEach(relevantFiles, (filePath) => copyRelevantFile(sourceDir, destinationDir, filePath)));
|
|
118
|
+
yield* _(Console.log(`Synced ${relevantFiles.length} dialog files into .knowledge/.codex`));
|
|
119
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Console, Effect, pipe } from "effect";
|
|
6
|
+
import { ensureDirectory, syncError } from "./syncShared.js";
|
|
7
|
+
const copyDirectoryJsonOnly = (sourceRoot, destinationRoot) => Effect.try({
|
|
8
|
+
try: () => {
|
|
9
|
+
let copied = 0;
|
|
10
|
+
fs.cpSync(sourceRoot, destinationRoot, {
|
|
11
|
+
recursive: true,
|
|
12
|
+
filter: (src) => {
|
|
13
|
+
const stat = fs.statSync(src);
|
|
14
|
+
if (stat.isDirectory()) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (src.endsWith(".json")) {
|
|
18
|
+
copied += 1;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
return copied;
|
|
25
|
+
},
|
|
26
|
+
catch: () => syncError(sourceRoot, "Cannot traverse Qwen directory"),
|
|
27
|
+
});
|
|
28
|
+
const qwenHashFromPath = (projectPath) => createHash("sha256").update(projectPath).digest("hex");
|
|
29
|
+
const resolveQwenSourceDir = (cwd, override, metaRoot) => pipe(Effect.sync(() => {
|
|
30
|
+
const hash = qwenHashFromPath(cwd);
|
|
31
|
+
const envSource = process.env.QWEN_SOURCE_DIR;
|
|
32
|
+
const baseFromMeta = metaRoot === undefined
|
|
33
|
+
? undefined
|
|
34
|
+
: metaRoot.endsWith(".qwen")
|
|
35
|
+
? metaRoot
|
|
36
|
+
: path.join(metaRoot, ".qwen");
|
|
37
|
+
const homeBase = path.join(os.homedir(), ".qwen");
|
|
38
|
+
const candidates = [
|
|
39
|
+
override,
|
|
40
|
+
envSource,
|
|
41
|
+
baseFromMeta ? path.join(baseFromMeta, "tmp", hash) : undefined,
|
|
42
|
+
path.join(cwd, ".qwen", "tmp", hash),
|
|
43
|
+
path.join(homeBase, "tmp", hash),
|
|
44
|
+
];
|
|
45
|
+
return candidates.find((candidate) => candidate !== undefined && fs.existsSync(candidate));
|
|
46
|
+
}), Effect.flatMap((found) => found === undefined
|
|
47
|
+
? Effect.fail(syncError(".qwen", "Qwen source directory is missing"))
|
|
48
|
+
: Effect.succeed(found)));
|
|
49
|
+
export const syncQwen = (options) => pipe(resolveQwenSourceDir(options.cwd, options.qwenSourceDir, options.metaRoot), Effect.flatMap((qwenSource) => pipe(ensureDirectory(path.join(options.cwd, ".knowledge", ".qwen")), Effect.flatMap(() => copyDirectoryJsonOnly(qwenSource, path.join(options.cwd, ".knowledge", ".qwen"))), Effect.flatMap((copiedCount) => Console.log(`Synced ${copiedCount} Qwen dialog files into .knowledge/.qwen`)))), Effect.catchAll(() => Console.log("Qwen source not found; skipped syncing Qwen dialog files")));
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { syncCodex } from "./codexSync.js";
|
|
4
|
+
import { syncQwen } from "./qwenSync.js";
|
|
5
|
+
// PURPOSE: Sync project-scoped dialogs (Codex + Qwen) into .knowledge storage.
|
|
6
|
+
export const buildSyncProgram = (options) => Effect.gen(function* (_) {
|
|
7
|
+
yield* _(syncCodex(options));
|
|
8
|
+
yield* _(syncQwen(options));
|
|
9
|
+
});
|
|
10
|
+
const isMainModule = () => {
|
|
11
|
+
const entry = process.argv[1];
|
|
12
|
+
if (entry === undefined) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
return import.meta.url === pathToFileURL(entry).href;
|
|
16
|
+
};
|
|
17
|
+
const parseArgs = () => {
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
let result = { cwd: process.cwd() };
|
|
20
|
+
const mapping = {
|
|
21
|
+
"--source": "sourceDir",
|
|
22
|
+
"-s": "sourceDir",
|
|
23
|
+
"--dest": "destinationDir",
|
|
24
|
+
"-d": "destinationDir",
|
|
25
|
+
"--project-url": "repositoryUrlOverride",
|
|
26
|
+
"--project-name": "repositoryUrlOverride",
|
|
27
|
+
"--meta-root": "metaRoot",
|
|
28
|
+
"--qwen-source": "qwenSourceDir",
|
|
29
|
+
};
|
|
30
|
+
for (let i = 0; i < argv.length; i++) {
|
|
31
|
+
const arg = argv[i];
|
|
32
|
+
const key = mapping[arg];
|
|
33
|
+
if (key === undefined) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const value = argv[i + 1];
|
|
37
|
+
if (value !== undefined) {
|
|
38
|
+
result = { ...result, [key]: value };
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
};
|
|
44
|
+
if (isMainModule()) {
|
|
45
|
+
const program = buildSyncProgram(parseArgs());
|
|
46
|
+
Effect.runSync(program);
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
export const syncError = (pathValue, reason) => ({
|
|
4
|
+
_tag: "SyncError",
|
|
5
|
+
path: pathValue,
|
|
6
|
+
reason,
|
|
7
|
+
});
|
|
8
|
+
export const ensureDirectory = (directory) => Effect.try({
|
|
9
|
+
try: () => {
|
|
10
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
11
|
+
},
|
|
12
|
+
catch: () => syncError(directory, "Cannot create destination directory structure"),
|
|
13
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@prover-coder-ai/context-doc",
|
|
3
|
+
"version": "1.0.4",
|
|
4
|
+
"description": "",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "tsx src/main.ts",
|
|
8
|
+
"build": "tsc -p tsconfig.build.json",
|
|
9
|
+
"lint": "npx --no-install @ton-ai-core/vibecode-linter src/",
|
|
10
|
+
"test": "npx --no-install @ton-ai-core/vibecode-linter tests/ && vitest run --passWithNoTests",
|
|
11
|
+
"sync:knowledge": "tsx src/shell/syncKnowledge.ts",
|
|
12
|
+
"release": "npm run build && npm run lint && npm version patch && npm publish --access public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/prover-coder-ai/context-doc.git"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [],
|
|
19
|
+
"author": "",
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/prover-coder-ai/context-doc/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/prover-coder-ai/context-doc#readme",
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src",
|
|
28
|
+
"doc",
|
|
29
|
+
"README.md",
|
|
30
|
+
"tsconfig.build.json",
|
|
31
|
+
"vitest.config.ts"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@effect/schema": "^0.75.5",
|
|
38
|
+
"effect": "^3.19.6",
|
|
39
|
+
"ts-morph": "^27.0.2",
|
|
40
|
+
"ts-pattern": "^5.9.0",
|
|
41
|
+
"vite-tsconfig-paths": "^5.1.4"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@biomejs/biome": "^2.3.7",
|
|
45
|
+
"@eslint-community/eslint-plugin-eslint-comments": "^4.5.0",
|
|
46
|
+
"@eslint/js": "^9.39.1",
|
|
47
|
+
"@ton-ai-core/eslint-plugin-suggest-members": "^1.6.17",
|
|
48
|
+
"@ton-ai-core/vibecode-linter": "^1.0.6",
|
|
49
|
+
"@types/node": "^24.10.1",
|
|
50
|
+
"eslint": "^9.39.1",
|
|
51
|
+
"eslint-plugin-vitest": "^0.5.4",
|
|
52
|
+
"globals": "^16.5.0",
|
|
53
|
+
"tsx": "^4.20.6",
|
|
54
|
+
"typescript": "^5.9.3",
|
|
55
|
+
"typescript-eslint": "^8.48.0",
|
|
56
|
+
"vitest": "^4.0.14"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
|
|
3
|
+
export interface AppProfile {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly mission: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Greeting {
|
|
9
|
+
readonly message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const normalize = (value: string): string => value.trim();
|
|
13
|
+
|
|
14
|
+
const isNonEmpty = (value: string): boolean => value.length > 0;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* CHANGE: Introduce pure greeting synthesis for console entrypoint
|
|
18
|
+
* WHY: Provide mathematically checkable construction of startup banner without side effects
|
|
19
|
+
* QUOTE(ТЗ): "Каждая функция — это теорема."
|
|
20
|
+
* REF: REQ-GREETING-CORE
|
|
21
|
+
* SOURCE: AGENTS.md – функциональное ядро без побочных эффектов
|
|
22
|
+
* FORMAT THEOREM: ∀p ∈ AppProfile: valid(p) → nonEmpty(buildGreeting(p).message)
|
|
23
|
+
* PURITY: CORE
|
|
24
|
+
* EFFECT: None (pure)
|
|
25
|
+
* INVARIANT: output.message.length > 0 when Option is Some
|
|
26
|
+
* COMPLEXITY: O(1)/O(1)
|
|
27
|
+
*/
|
|
28
|
+
export const buildGreeting = (profile: AppProfile): Option.Option<Greeting> => {
|
|
29
|
+
const trimmedName = normalize(profile.name);
|
|
30
|
+
const trimmedMission = normalize(profile.mission);
|
|
31
|
+
|
|
32
|
+
if (!isNonEmpty(trimmedName) || !isNonEmpty(trimmedMission)) {
|
|
33
|
+
return Option.none();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return Option.some({
|
|
37
|
+
message: `${trimmedName}: ${trimmedMission}`,
|
|
38
|
+
});
|
|
39
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Option, pipe } from "effect";
|
|
3
|
+
|
|
4
|
+
type JsonPrimitive = string | number | boolean | null;
|
|
5
|
+
type JsonValue = JsonPrimitive | readonly JsonValue[] | JsonRecord;
|
|
6
|
+
interface JsonRecord {
|
|
7
|
+
readonly [key: string]: JsonValue;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ProjectLocator {
|
|
11
|
+
readonly normalizedRepositoryUrl: string;
|
|
12
|
+
readonly normalizedCwd: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RecordMetadata {
|
|
16
|
+
readonly repositoryUrl: Option.Option<string>;
|
|
17
|
+
readonly cwd: Option.Option<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const isJsonRecord = (value: JsonValue): value is JsonRecord =>
|
|
21
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
|
|
23
|
+
const normalizeRepositoryUrl = (value: string): string =>
|
|
24
|
+
pipe(
|
|
25
|
+
value.trim(),
|
|
26
|
+
(trimmed) => trimmed.replace(/^git\+/, ""),
|
|
27
|
+
(withoutPrefix) => withoutPrefix.replace(/\.git$/, ""),
|
|
28
|
+
(withoutGitSuffix) =>
|
|
29
|
+
withoutGitSuffix.replace(/^git@github\.com:/, "https://github.com/"),
|
|
30
|
+
(withoutSsh) =>
|
|
31
|
+
withoutSsh.replace(/^ssh:\/\/git@github\.com\//, "https://github.com/"),
|
|
32
|
+
(normalized) => normalized.toLowerCase(),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const normalizeCwd = (value: string): string => path.resolve(value);
|
|
36
|
+
|
|
37
|
+
const pickString = (record: JsonRecord, key: string): Option.Option<string> => {
|
|
38
|
+
const candidate = record[key];
|
|
39
|
+
return typeof candidate === "string" ? Option.some(candidate) : Option.none();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const pickRecord = (
|
|
43
|
+
record: JsonRecord,
|
|
44
|
+
key: string,
|
|
45
|
+
): Option.Option<JsonRecord> =>
|
|
46
|
+
pipe(record[key], Option.fromNullable, Option.filter(isJsonRecord));
|
|
47
|
+
|
|
48
|
+
const pickGitRepository = (record: JsonRecord): Option.Option<string> =>
|
|
49
|
+
pipe(
|
|
50
|
+
pickString(record, "repository_url"),
|
|
51
|
+
Option.orElse(() => pickString(record, "repositoryUrl")),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const extractRepository = (record: JsonRecord): Option.Option<string> =>
|
|
55
|
+
pipe(
|
|
56
|
+
pickRecord(record, "git"),
|
|
57
|
+
Option.flatMap(pickGitRepository),
|
|
58
|
+
Option.orElse(() =>
|
|
59
|
+
pipe(
|
|
60
|
+
pickRecord(record, "payload"),
|
|
61
|
+
Option.flatMap((payload) =>
|
|
62
|
+
pipe(pickRecord(payload, "git"), Option.flatMap(pickGitRepository)),
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const extractCwd = (record: JsonRecord): Option.Option<string> =>
|
|
69
|
+
pipe(
|
|
70
|
+
pickString(record, "cwd"),
|
|
71
|
+
Option.orElse(() =>
|
|
72
|
+
pipe(
|
|
73
|
+
pickRecord(record, "payload"),
|
|
74
|
+
Option.flatMap((payload) => pickString(payload, "cwd")),
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const toMetadata = (value: JsonValue): RecordMetadata => {
|
|
80
|
+
if (!isJsonRecord(value)) {
|
|
81
|
+
return { repositoryUrl: Option.none(), cwd: Option.none() };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
repositoryUrl: extractRepository(value),
|
|
86
|
+
cwd: extractCwd(value),
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const normalizeLocator = (
|
|
91
|
+
repositoryUrl: string,
|
|
92
|
+
cwd: string,
|
|
93
|
+
): ProjectLocator => ({
|
|
94
|
+
normalizedRepositoryUrl: normalizeRepositoryUrl(repositoryUrl),
|
|
95
|
+
normalizedCwd: normalizeCwd(cwd),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const safeParseJson = (line: string): Option.Option<JsonValue> => {
|
|
99
|
+
try {
|
|
100
|
+
return Option.some(JSON.parse(line));
|
|
101
|
+
} catch {
|
|
102
|
+
return Option.none();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const metadataMatches = (
|
|
107
|
+
metadata: RecordMetadata,
|
|
108
|
+
locator: ProjectLocator,
|
|
109
|
+
): boolean => {
|
|
110
|
+
const repoMatches = Option.exists(
|
|
111
|
+
metadata.repositoryUrl,
|
|
112
|
+
(repositoryUrl) =>
|
|
113
|
+
normalizeRepositoryUrl(repositoryUrl) === locator.normalizedRepositoryUrl,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const cwdMatches = Option.exists(metadata.cwd, (cwdValue) => {
|
|
117
|
+
const normalized = normalizeCwd(cwdValue);
|
|
118
|
+
return (
|
|
119
|
+
locator.normalizedCwd.startsWith(normalized) ||
|
|
120
|
+
normalized.startsWith(locator.normalizedCwd)
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return repoMatches || cwdMatches;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const buildProjectLocator = (
|
|
128
|
+
repositoryUrl: string,
|
|
129
|
+
cwd: string,
|
|
130
|
+
): ProjectLocator => normalizeLocator(repositoryUrl, cwd);
|
|
131
|
+
|
|
132
|
+
export const linesMatchProject = (
|
|
133
|
+
lines: readonly string[],
|
|
134
|
+
locator: ProjectLocator,
|
|
135
|
+
): boolean =>
|
|
136
|
+
lines.some((line) => {
|
|
137
|
+
const trimmed = line.trim();
|
|
138
|
+
|
|
139
|
+
if (trimmed.length === 0) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return pipe(
|
|
144
|
+
safeParseJson(trimmed),
|
|
145
|
+
Option.map(toMetadata),
|
|
146
|
+
Option.exists((metadata) => metadataMatches(metadata, locator)),
|
|
147
|
+
);
|
|
148
|
+
});
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Console, Effect, Option, pipe } from "effect";
|
|
2
|
+
import { match } from "ts-pattern";
|
|
3
|
+
import type { AppProfile } from "./core/greeting.js";
|
|
4
|
+
import { buildGreeting } from "./core/greeting.js";
|
|
5
|
+
|
|
6
|
+
interface StartupError {
|
|
7
|
+
readonly _tag: "StartupError";
|
|
8
|
+
readonly reason: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const appProfile: AppProfile = {
|
|
12
|
+
name: "Context Console",
|
|
13
|
+
mission: "Functional core ready for verifiable effects",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const toStartupError = (reason: string): StartupError => ({
|
|
17
|
+
_tag: "StartupError",
|
|
18
|
+
reason,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* CHANGE: Compose shell-level startup program around pure greeting builder
|
|
23
|
+
* WHY: Isolate side effects (console IO) while delegating computation to functional core
|
|
24
|
+
* QUOTE(ТЗ): "FUNCTIONAL CORE, IMPERATIVE SHELL"
|
|
25
|
+
* REF: REQ-SHELL-STARTUP
|
|
26
|
+
* SOURCE: AGENTS.md — эффекты только в SHELL
|
|
27
|
+
* FORMAT THEOREM: ∀profile: valid(profile) → logs(buildGreeting(profile))
|
|
28
|
+
* PURITY: SHELL
|
|
29
|
+
* EFFECT: Effect<void, StartupError, Console>
|
|
30
|
+
* INVARIANT: Console side-effects occur once and only after successful greeting synthesis
|
|
31
|
+
* COMPLEXITY: O(1)/O(1)
|
|
32
|
+
*/
|
|
33
|
+
const program = pipe(
|
|
34
|
+
Effect.succeed(buildGreeting(appProfile)),
|
|
35
|
+
Effect.filterOrFail(Option.isSome, () =>
|
|
36
|
+
toStartupError("Profile must not be empty"),
|
|
37
|
+
),
|
|
38
|
+
Effect.map((option) => option.value),
|
|
39
|
+
Effect.flatMap((greeting) => Console.log(greeting.message)),
|
|
40
|
+
Effect.catchAll((error) =>
|
|
41
|
+
match(error)
|
|
42
|
+
.with({ _tag: "StartupError" }, (startupError) =>
|
|
43
|
+
Console.error(`StartupError: ${startupError.reason}`),
|
|
44
|
+
)
|
|
45
|
+
.exhaustive(),
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
Effect.runSync(program);
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import * as S from "@effect/schema/Schema";
|
|
5
|
+
import { Console, Effect, pipe } from "effect";
|
|
6
|
+
import type { ProjectLocator } from "../core/knowledge.js";
|
|
7
|
+
import { buildProjectLocator, linesMatchProject } from "../core/knowledge.js";
|
|
8
|
+
import { ensureDirectory, syncError } from "./syncShared.js";
|
|
9
|
+
import type { SyncError, SyncOptions } from "./syncTypes.js";
|
|
10
|
+
|
|
11
|
+
const PackageRepositorySchema = S.Struct({
|
|
12
|
+
url: S.String,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const PackageFileSchema = S.Struct({
|
|
16
|
+
repository: S.optional(PackageRepositorySchema),
|
|
17
|
+
});
|
|
18
|
+
const PackageFileFromJson = S.compose(S.parseJson(), PackageFileSchema);
|
|
19
|
+
const decodePackageFile = S.decodeUnknownSync(PackageFileFromJson);
|
|
20
|
+
|
|
21
|
+
const readRepositoryUrl = (
|
|
22
|
+
cwd: string,
|
|
23
|
+
repositoryUrlOverride?: string,
|
|
24
|
+
): Effect.Effect<string, SyncError> =>
|
|
25
|
+
Effect.try({
|
|
26
|
+
try: () => {
|
|
27
|
+
if (repositoryUrlOverride !== undefined) {
|
|
28
|
+
return repositoryUrlOverride;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const raw = fs.readFileSync(path.join(cwd, "package.json"), "utf8");
|
|
32
|
+
const parsed = decodePackageFile(raw);
|
|
33
|
+
const repositoryUrl = parsed.repository?.url;
|
|
34
|
+
|
|
35
|
+
if (repositoryUrl === undefined) {
|
|
36
|
+
throw new Error("repository url is missing");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return repositoryUrl;
|
|
40
|
+
},
|
|
41
|
+
catch: () => syncError("package.json", "Cannot read repository url"),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const resolveSourceDir = (
|
|
45
|
+
cwd: string,
|
|
46
|
+
override?: string,
|
|
47
|
+
metaRoot?: string,
|
|
48
|
+
): Effect.Effect<string, SyncError> =>
|
|
49
|
+
Effect.try({
|
|
50
|
+
try: () => {
|
|
51
|
+
const envSource = process.env.CODEX_SOURCE_DIR;
|
|
52
|
+
const metaCandidate =
|
|
53
|
+
metaRoot === undefined
|
|
54
|
+
? undefined
|
|
55
|
+
: metaRoot.endsWith(".codex")
|
|
56
|
+
? metaRoot
|
|
57
|
+
: path.join(metaRoot, ".codex");
|
|
58
|
+
const localSource = path.join(cwd, ".codex");
|
|
59
|
+
const homeSource = path.join(os.homedir(), ".codex");
|
|
60
|
+
const candidates = [
|
|
61
|
+
override,
|
|
62
|
+
envSource,
|
|
63
|
+
metaCandidate,
|
|
64
|
+
localSource,
|
|
65
|
+
homeSource,
|
|
66
|
+
];
|
|
67
|
+
const existing = candidates.find(
|
|
68
|
+
(candidate) => candidate !== undefined && fs.existsSync(candidate),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
if (existing === undefined) {
|
|
72
|
+
throw new Error("source .codex not found");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return existing;
|
|
76
|
+
},
|
|
77
|
+
catch: () => syncError(".codex", "Source .codex directory is missing"),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const collectJsonlFiles = (
|
|
81
|
+
root: string,
|
|
82
|
+
): Effect.Effect<ReadonlyArray<string>, SyncError> =>
|
|
83
|
+
Effect.try({
|
|
84
|
+
try: () => {
|
|
85
|
+
const collected: string[] = [];
|
|
86
|
+
const walk = (current: string): void => {
|
|
87
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const fullPath = path.join(current, entry.name);
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
walk(fullPath);
|
|
92
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
93
|
+
collected.push(fullPath);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
walk(root);
|
|
99
|
+
return collected;
|
|
100
|
+
},
|
|
101
|
+
catch: () => syncError(root, "Cannot traverse .codex"),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const isFileRelevant = (
|
|
105
|
+
filePath: string,
|
|
106
|
+
locator: ProjectLocator,
|
|
107
|
+
): Effect.Effect<boolean, SyncError> =>
|
|
108
|
+
Effect.try({
|
|
109
|
+
try: () => {
|
|
110
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
111
|
+
const lines = content.split("\n");
|
|
112
|
+
return linesMatchProject(lines, locator);
|
|
113
|
+
},
|
|
114
|
+
catch: () => syncError(filePath, "Cannot read jsonl file"),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const copyRelevantFile = (
|
|
118
|
+
sourceRoot: string,
|
|
119
|
+
destinationRoot: string,
|
|
120
|
+
filePath: string,
|
|
121
|
+
): Effect.Effect<void, SyncError> =>
|
|
122
|
+
Effect.try({
|
|
123
|
+
try: () => {
|
|
124
|
+
const relative = path.relative(sourceRoot, filePath);
|
|
125
|
+
const targetPath = path.join(destinationRoot, relative);
|
|
126
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
127
|
+
fs.copyFileSync(filePath, targetPath);
|
|
128
|
+
},
|
|
129
|
+
catch: () => syncError(filePath, "Cannot copy file into .knowledge/.codex"),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const selectRelevantFiles = (
|
|
133
|
+
files: ReadonlyArray<string>,
|
|
134
|
+
locator: ProjectLocator,
|
|
135
|
+
): Effect.Effect<ReadonlyArray<string>, SyncError> =>
|
|
136
|
+
Effect.reduce(files, [] as string[], (acc, filePath) =>
|
|
137
|
+
pipe(
|
|
138
|
+
isFileRelevant(filePath, locator),
|
|
139
|
+
Effect.map((isRelevant) => {
|
|
140
|
+
if (isRelevant) {
|
|
141
|
+
acc.push(filePath);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return acc;
|
|
145
|
+
}),
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// CHANGE: Extract Codex dialog sync into dedicated module for clarity.
|
|
150
|
+
// WHY: Separate Codex-specific shell effects from other sync flows.
|
|
151
|
+
// QUOTE(ТЗ): "вынеси в отдельный файл"
|
|
152
|
+
// REF: user request 2025-11-26
|
|
153
|
+
// SOURCE: internal requirement
|
|
154
|
+
// FORMAT THEOREM: ∀f ∈ Files: relevant(f, locator) → copied(f, destination)
|
|
155
|
+
// PURITY: SHELL
|
|
156
|
+
// EFFECT: Effect<void, SyncError, never>
|
|
157
|
+
// INVARIANT: ∀f ∈ copiedFiles: linesMatchProject(f, locator)
|
|
158
|
+
// COMPLEXITY: O(n) time / O(n) space, n = |files|
|
|
159
|
+
export const syncCodex = (
|
|
160
|
+
options: SyncOptions,
|
|
161
|
+
): Effect.Effect<void, SyncError> =>
|
|
162
|
+
Effect.gen(function* (_) {
|
|
163
|
+
const repositoryUrl = yield* _(
|
|
164
|
+
readRepositoryUrl(options.cwd, options.repositoryUrlOverride),
|
|
165
|
+
);
|
|
166
|
+
const sourceDir = yield* _(
|
|
167
|
+
resolveSourceDir(options.cwd, options.sourceDir, options.metaRoot),
|
|
168
|
+
);
|
|
169
|
+
const destinationDir =
|
|
170
|
+
options.destinationDir ?? path.join(options.cwd, ".knowledge", ".codex");
|
|
171
|
+
|
|
172
|
+
yield* _(ensureDirectory(destinationDir));
|
|
173
|
+
|
|
174
|
+
const locator = buildProjectLocator(repositoryUrl, options.cwd);
|
|
175
|
+
const allJsonlFiles = yield* _(collectJsonlFiles(sourceDir));
|
|
176
|
+
const relevantFiles = yield* _(selectRelevantFiles(allJsonlFiles, locator));
|
|
177
|
+
|
|
178
|
+
yield* _(
|
|
179
|
+
Effect.forEach(relevantFiles, (filePath) =>
|
|
180
|
+
copyRelevantFile(sourceDir, destinationDir, filePath),
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
yield* _(
|
|
185
|
+
Console.log(
|
|
186
|
+
`Synced ${relevantFiles.length} dialog files into .knowledge/.codex`,
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Console, Effect, pipe } from "effect";
|
|
6
|
+
import { ensureDirectory, syncError } from "./syncShared.js";
|
|
7
|
+
import type { SyncError, SyncOptions } from "./syncTypes.js";
|
|
8
|
+
|
|
9
|
+
const copyDirectoryJsonOnly = (
|
|
10
|
+
sourceRoot: string,
|
|
11
|
+
destinationRoot: string,
|
|
12
|
+
): Effect.Effect<number, SyncError> =>
|
|
13
|
+
Effect.try({
|
|
14
|
+
try: () => {
|
|
15
|
+
let copied = 0;
|
|
16
|
+
fs.cpSync(sourceRoot, destinationRoot, {
|
|
17
|
+
recursive: true,
|
|
18
|
+
filter: (src) => {
|
|
19
|
+
const stat = fs.statSync(src);
|
|
20
|
+
if (stat.isDirectory()) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (src.endsWith(".json")) {
|
|
24
|
+
copied += 1;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
return copied;
|
|
31
|
+
},
|
|
32
|
+
catch: () => syncError(sourceRoot, "Cannot traverse Qwen directory"),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const qwenHashFromPath = (projectPath: string): string =>
|
|
36
|
+
createHash("sha256").update(projectPath).digest("hex");
|
|
37
|
+
|
|
38
|
+
const resolveQwenSourceDir = (
|
|
39
|
+
cwd: string,
|
|
40
|
+
override?: string,
|
|
41
|
+
metaRoot?: string,
|
|
42
|
+
): Effect.Effect<string, SyncError> =>
|
|
43
|
+
pipe(
|
|
44
|
+
Effect.sync(() => {
|
|
45
|
+
const hash = qwenHashFromPath(cwd);
|
|
46
|
+
const envSource = process.env.QWEN_SOURCE_DIR;
|
|
47
|
+
const baseFromMeta =
|
|
48
|
+
metaRoot === undefined
|
|
49
|
+
? undefined
|
|
50
|
+
: metaRoot.endsWith(".qwen")
|
|
51
|
+
? metaRoot
|
|
52
|
+
: path.join(metaRoot, ".qwen");
|
|
53
|
+
const homeBase = path.join(os.homedir(), ".qwen");
|
|
54
|
+
|
|
55
|
+
const candidates = [
|
|
56
|
+
override,
|
|
57
|
+
envSource,
|
|
58
|
+
baseFromMeta ? path.join(baseFromMeta, "tmp", hash) : undefined,
|
|
59
|
+
path.join(cwd, ".qwen", "tmp", hash),
|
|
60
|
+
path.join(homeBase, "tmp", hash),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
return candidates.find(
|
|
64
|
+
(candidate) => candidate !== undefined && fs.existsSync(candidate),
|
|
65
|
+
);
|
|
66
|
+
}),
|
|
67
|
+
Effect.flatMap((found) =>
|
|
68
|
+
found === undefined
|
|
69
|
+
? Effect.fail(syncError(".qwen", "Qwen source directory is missing"))
|
|
70
|
+
: Effect.succeed(found),
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
export const syncQwen = (
|
|
75
|
+
options: SyncOptions,
|
|
76
|
+
): Effect.Effect<void, SyncError> =>
|
|
77
|
+
pipe(
|
|
78
|
+
resolveQwenSourceDir(options.cwd, options.qwenSourceDir, options.metaRoot),
|
|
79
|
+
Effect.flatMap((qwenSource) =>
|
|
80
|
+
pipe(
|
|
81
|
+
ensureDirectory(path.join(options.cwd, ".knowledge", ".qwen")),
|
|
82
|
+
Effect.flatMap(() =>
|
|
83
|
+
copyDirectoryJsonOnly(
|
|
84
|
+
qwenSource,
|
|
85
|
+
path.join(options.cwd, ".knowledge", ".qwen"),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
Effect.flatMap((copiedCount) =>
|
|
89
|
+
Console.log(
|
|
90
|
+
`Synced ${copiedCount} Qwen dialog files into .knowledge/.qwen`,
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
Effect.catchAll(() =>
|
|
96
|
+
Console.log("Qwen source not found; skipped syncing Qwen dialog files"),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { syncCodex } from "./codexSync.js";
|
|
4
|
+
import { syncQwen } from "./qwenSync.js";
|
|
5
|
+
import type { SyncError, SyncOptions } from "./syncTypes.js";
|
|
6
|
+
|
|
7
|
+
// PURPOSE: Sync project-scoped dialogs (Codex + Qwen) into .knowledge storage.
|
|
8
|
+
export const buildSyncProgram = (
|
|
9
|
+
options: SyncOptions,
|
|
10
|
+
): Effect.Effect<void, SyncError> =>
|
|
11
|
+
Effect.gen(function* (_) {
|
|
12
|
+
yield* _(syncCodex(options));
|
|
13
|
+
yield* _(syncQwen(options));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const isMainModule = (): boolean => {
|
|
17
|
+
const entry = process.argv[1];
|
|
18
|
+
|
|
19
|
+
if (entry === undefined) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return import.meta.url === pathToFileURL(entry).href;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const parseArgs = (): SyncOptions => {
|
|
27
|
+
const argv = process.argv.slice(2);
|
|
28
|
+
let result: SyncOptions = { cwd: process.cwd() };
|
|
29
|
+
|
|
30
|
+
const mapping: Readonly<Record<string, keyof SyncOptions>> = {
|
|
31
|
+
"--source": "sourceDir",
|
|
32
|
+
"-s": "sourceDir",
|
|
33
|
+
"--dest": "destinationDir",
|
|
34
|
+
"-d": "destinationDir",
|
|
35
|
+
"--project-url": "repositoryUrlOverride",
|
|
36
|
+
"--project-name": "repositoryUrlOverride",
|
|
37
|
+
"--meta-root": "metaRoot",
|
|
38
|
+
"--qwen-source": "qwenSourceDir",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const arg = argv[i];
|
|
43
|
+
const key = mapping[arg as keyof typeof mapping];
|
|
44
|
+
if (key === undefined) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const value = argv[i + 1];
|
|
49
|
+
if (value !== undefined) {
|
|
50
|
+
result = { ...result, [key]: value };
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return result;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (isMainModule()) {
|
|
59
|
+
const program = buildSyncProgram(parseArgs());
|
|
60
|
+
|
|
61
|
+
Effect.runSync(program);
|
|
62
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import type { SyncError } from "./syncTypes.js";
|
|
4
|
+
|
|
5
|
+
export const syncError = (pathValue: string, reason: string): SyncError => ({
|
|
6
|
+
_tag: "SyncError",
|
|
7
|
+
path: pathValue,
|
|
8
|
+
reason,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const ensureDirectory = (
|
|
12
|
+
directory: string,
|
|
13
|
+
): Effect.Effect<void, SyncError> =>
|
|
14
|
+
Effect.try({
|
|
15
|
+
try: () => {
|
|
16
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
17
|
+
},
|
|
18
|
+
catch: () =>
|
|
19
|
+
syncError(directory, "Cannot create destination directory structure"),
|
|
20
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface SyncError {
|
|
2
|
+
readonly _tag: "SyncError";
|
|
3
|
+
readonly path: string;
|
|
4
|
+
readonly reason: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface SyncOptions {
|
|
8
|
+
readonly cwd: string;
|
|
9
|
+
readonly sourceDir?: string;
|
|
10
|
+
readonly destinationDir?: string;
|
|
11
|
+
readonly repositoryUrlOverride?: string;
|
|
12
|
+
readonly metaRoot?: string;
|
|
13
|
+
readonly qwenSourceDir?: string;
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
// CHANGE: Emit compiled artifacts into dist instead of source tree
|
|
3
|
+
// WHY: Keep repository free of generated .js while still supporting production builds
|
|
4
|
+
// QUOTE(ТЗ): "FUNCTIONAL CORE, IMPERATIVE SHELL" — сборка отделена от исходников
|
|
5
|
+
// PURITY: SHELL (build configuration)
|
|
6
|
+
"extends": "./tsconfig.json",
|
|
7
|
+
"compilerOptions": {
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
9
|
+
// EMIT OVERRIDES (build-only)
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
11
|
+
"noEmit": false,
|
|
12
|
+
"outDir": "dist",
|
|
13
|
+
"rootDir": "src",
|
|
14
|
+
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"],
|
|
17
|
+
"exclude": ["tests/**/*", "node_modules", "dist"]
|
|
18
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// CHANGE: Migrate from Jest to Vitest with mathematical equivalence
|
|
2
|
+
// WHY: Faster execution, native ESM, Effect integration via @effect/vitest
|
|
3
|
+
// QUOTE(ТЗ): "Проект использует Effect + функциональную парадигму"
|
|
4
|
+
// REF: Migration from jest.config.mjs
|
|
5
|
+
// PURITY: SHELL (configuration only)
|
|
6
|
+
// INVARIANT: ∀ test: behavior_jest ≡ behavior_vitest
|
|
7
|
+
// EFFECT: Effect<TestReport, never, TestEnvironment>
|
|
8
|
+
// COMPLEXITY: O(n) test execution where n = |test_files|
|
|
9
|
+
|
|
10
|
+
import { defineConfig } from "vitest/config";
|
|
11
|
+
import tsconfigPaths from "vite-tsconfig-paths";
|
|
12
|
+
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
plugins: [tsconfigPaths()], // Resolves @/* paths from tsconfig
|
|
15
|
+
test: {
|
|
16
|
+
// CHANGE: Native ESM support without experimental flags
|
|
17
|
+
// WHY: Vitest designed for ESM, no need for --experimental-vm-modules
|
|
18
|
+
// INVARIANT: Deterministic test execution without side effects
|
|
19
|
+
globals: false, // IMPORTANT: Use explicit imports for type safety
|
|
20
|
+
environment: "node",
|
|
21
|
+
|
|
22
|
+
// CHANGE: Match Jest's test file patterns
|
|
23
|
+
// INVARIANT: Same test discovery as Jest
|
|
24
|
+
include: ["tests/**/*.{test,spec}.ts"],
|
|
25
|
+
exclude: ["node_modules", "dist", "dist-test"],
|
|
26
|
+
|
|
27
|
+
// CHANGE: Coverage with 100% threshold for CORE (same as Jest)
|
|
28
|
+
// WHY: CORE must maintain mathematical guarantees via complete coverage
|
|
29
|
+
// INVARIANT: coverage_vitest ≥ coverage_jest ∧ ∀ f ∈ CORE: coverage(f) = 100%
|
|
30
|
+
coverage: {
|
|
31
|
+
provider: "v8", // Faster than babel (istanbul), native V8 coverage
|
|
32
|
+
reporter: ["text", "json", "html"],
|
|
33
|
+
include: ["src/**/*.ts"],
|
|
34
|
+
exclude: [
|
|
35
|
+
"src/**/*.test.ts",
|
|
36
|
+
"src/**/*.spec.ts",
|
|
37
|
+
"src/**/__tests__/**",
|
|
38
|
+
"scripts/**/*.ts",
|
|
39
|
+
],
|
|
40
|
+
// CHANGE: Maintain exact same thresholds as Jest
|
|
41
|
+
// WHY: Enforce 100% coverage for CORE, 10% minimum for SHELL
|
|
42
|
+
// INVARIANT: ∀ f ∈ src/core/**/*.ts: all_metrics(f) = 100%
|
|
43
|
+
// NOTE: Vitest v8 provider collects coverage for all matched files by default
|
|
44
|
+
thresholds: {
|
|
45
|
+
"src/core/**/*.ts": {
|
|
46
|
+
branches: 100,
|
|
47
|
+
functions: 100,
|
|
48
|
+
lines: 100,
|
|
49
|
+
statements: 100,
|
|
50
|
+
},
|
|
51
|
+
global: {
|
|
52
|
+
branches: 10,
|
|
53
|
+
functions: 10,
|
|
54
|
+
lines: 10,
|
|
55
|
+
statements: 10,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
// CHANGE: Faster test execution via thread pooling
|
|
61
|
+
// WHY: Vitest uses worker threads by default (faster than Jest's processes)
|
|
62
|
+
// COMPLEXITY: O(n/k) where n = tests, k = worker_count
|
|
63
|
+
// NOTE: Vitest runs tests in parallel by default, no additional config needed
|
|
64
|
+
|
|
65
|
+
// CHANGE: Clear mocks between tests (Jest equivalence)
|
|
66
|
+
// WHY: Prevent test contamination, ensure test independence
|
|
67
|
+
// INVARIANT: ∀ test_i, test_j: independent(test_i, test_j) ⇒ no_shared_state
|
|
68
|
+
clearMocks: true,
|
|
69
|
+
mockReset: true,
|
|
70
|
+
restoreMocks: true,
|
|
71
|
+
|
|
72
|
+
// CHANGE: Disable globals to enforce explicit imports
|
|
73
|
+
// WHY: Type safety, explicit dependencies, functional purity
|
|
74
|
+
// NOTE: Tests must import { describe, it, expect } from "vitest"
|
|
75
|
+
},
|
|
76
|
+
resolve: {
|
|
77
|
+
alias: {
|
|
78
|
+
"@": "/src",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|