agent-simple-english 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +23 -0
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/THIRD_PARTY_NOTICES.md +13 -0
- package/commands/ste.md +13 -0
- package/hooks/hooks.json +58 -0
- package/package.json +64 -0
- package/src/adapter/commit-message.ts +472 -0
- package/src/adapter/feedback.ts +40 -0
- package/src/adapter/rule-summary.ts +79 -0
- package/src/cli/hook.ts +681 -0
- package/src/cli/main.ts +201 -0
- package/src/cli/session-command.ts +78 -0
- package/src/cli/session-state.ts +214 -0
- package/src/config/load.ts +85 -0
- package/src/config/merge.ts +20 -0
- package/src/config/schema.ts +69 -0
- package/src/dictionary/README.md +18 -0
- package/src/dictionary/data/pi-ste.json +200 -0
- package/src/dictionary/form.ts +6 -0
- package/src/dictionary/load.ts +54 -0
- package/src/dictionary/schema.ts +28 -0
- package/src/engine/comments.ts +386 -0
- package/src/engine/diff.ts +328 -0
- package/src/engine/identifiers.ts +20 -0
- package/src/engine/kinds.ts +43 -0
- package/src/engine/lint.ts +530 -0
- package/src/engine/markdown.ts +338 -0
- package/src/engine/paragraphs.ts +105 -0
- package/src/engine/rules/contraction.ts +19 -0
- package/src/engine/rules/dictionary.ts +281 -0
- package/src/engine/rules/hedging.ts +27 -0
- package/src/engine/rules/marketing.ts +71 -0
- package/src/engine/rules/paragraph-length.ts +23 -0
- package/src/engine/rules/phrasal-verb.ts +57 -0
- package/src/engine/rules/registry.ts +15 -0
- package/src/engine/rules/semicolon.ts +14 -0
- package/src/engine/rules/sentence-length.ts +24 -0
- package/src/engine/rules/verb-form.ts +76 -0
- package/src/engine/scan.ts +15 -0
- package/src/engine/sentences.ts +285 -0
- package/src/engine/tagger.ts +8 -0
- package/src/engine/tokens.ts +2 -0
- package/src/engine/types.ts +45 -0
- package/src/extension/index.ts +755 -0
- package/src/tagger/wink.ts +44 -0
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { readFile } from "node:fs/promises"
|
|
3
|
+
import { Effect, Either } from "effect"
|
|
4
|
+
import { loadConfig } from "../config/load.ts"
|
|
5
|
+
import { loadDictionary } from "../dictionary/load.ts"
|
|
6
|
+
import { classifyPath } from "../engine/kinds.ts"
|
|
7
|
+
import { lint } from "../engine/lint.ts"
|
|
8
|
+
import type { LintKind, LintReport } from "../engine/types.ts"
|
|
9
|
+
import { TaggerService, WinkTaggerLive } from "../tagger/wink.ts"
|
|
10
|
+
import { hookInternalFailure, runHookMode } from "./hook.ts"
|
|
11
|
+
import { runSessionCommand } from "./session-command.ts"
|
|
12
|
+
|
|
13
|
+
const KINDS: readonly LintKind[] = ["prose-file", "slash-source", "hash-source", "commit-message"]
|
|
14
|
+
|
|
15
|
+
interface CliArgs {
|
|
16
|
+
readonly json: boolean
|
|
17
|
+
readonly configPath: string | undefined
|
|
18
|
+
readonly kind: string | undefined
|
|
19
|
+
readonly kindMissingValue: boolean
|
|
20
|
+
readonly paths: readonly string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const parseArgs = (args: readonly string[]): Effect.Effect<CliArgs, Error> =>
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
let json = false
|
|
26
|
+
let configPath: string | undefined
|
|
27
|
+
let kind: string | undefined
|
|
28
|
+
let kindMissingValue = false
|
|
29
|
+
const paths: string[] = []
|
|
30
|
+
for (let i = 0; i < args.length; i++) {
|
|
31
|
+
const arg = args[i] as string
|
|
32
|
+
if (arg === "--json") {
|
|
33
|
+
json = true
|
|
34
|
+
} else if (arg === "--config") {
|
|
35
|
+
configPath = args[++i]
|
|
36
|
+
if (configPath === undefined) {
|
|
37
|
+
yield* Effect.fail(new Error("--config requires a file path"))
|
|
38
|
+
}
|
|
39
|
+
} else if (arg === "--kind") {
|
|
40
|
+
const value = args[i + 1]
|
|
41
|
+
if (value === undefined || value.startsWith("--")) {
|
|
42
|
+
kindMissingValue = true
|
|
43
|
+
} else {
|
|
44
|
+
kind = value
|
|
45
|
+
i++
|
|
46
|
+
}
|
|
47
|
+
} else if (arg.startsWith("--kind=")) {
|
|
48
|
+
kind = arg.slice("--kind=".length)
|
|
49
|
+
} else {
|
|
50
|
+
paths.push(arg)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { json, configPath, kind, kindMissingValue, paths }
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const isLintKind = (value: string): value is LintKind =>
|
|
57
|
+
(KINDS as readonly string[]).includes(value)
|
|
58
|
+
|
|
59
|
+
interface FileViolation {
|
|
60
|
+
readonly file: string
|
|
61
|
+
readonly ruleId: string
|
|
62
|
+
readonly severity: string
|
|
63
|
+
readonly message: string
|
|
64
|
+
readonly suggestions?: readonly string[]
|
|
65
|
+
readonly line: number
|
|
66
|
+
readonly column: number
|
|
67
|
+
readonly suggestion?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface CliReport {
|
|
71
|
+
readonly violations: readonly FileViolation[]
|
|
72
|
+
readonly summary: { readonly total: number; readonly hard: number }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const readStdin = Effect.promise(async () => {
|
|
76
|
+
const chunks: Buffer[] = []
|
|
77
|
+
for await (const chunk of process.stdin) {
|
|
78
|
+
chunks.push(chunk as Buffer)
|
|
79
|
+
}
|
|
80
|
+
return Buffer.concat(chunks).toString("utf8")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const readInput = (path: string) =>
|
|
84
|
+
path === "-"
|
|
85
|
+
? readStdin.pipe(Effect.map((text) => ({ path: "<stdin>", text })))
|
|
86
|
+
: Effect.tryPromise({
|
|
87
|
+
try: () => readFile(path, "utf8"),
|
|
88
|
+
catch: (cause) => new Error(`cannot read ${path}: ${cause}`),
|
|
89
|
+
}).pipe(Effect.map((text) => ({ path, text })))
|
|
90
|
+
|
|
91
|
+
const toCliReport = (reports: readonly { path: string; report: LintReport }[]): CliReport => {
|
|
92
|
+
const violations = reports.flatMap(({ path, report }) =>
|
|
93
|
+
report.violations.map((violation) => ({ file: path, ...violation })),
|
|
94
|
+
)
|
|
95
|
+
return {
|
|
96
|
+
violations,
|
|
97
|
+
summary: {
|
|
98
|
+
total: violations.length,
|
|
99
|
+
hard: violations.filter((violation) => violation.severity === "hard").length,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const render = (report: CliReport, json: boolean): string => {
|
|
105
|
+
if (json) {
|
|
106
|
+
return JSON.stringify(report, null, 2)
|
|
107
|
+
}
|
|
108
|
+
return report.violations
|
|
109
|
+
.map((v) => `${v.file}:${v.line}:${v.column} [${v.severity}] ${v.ruleId} ${v.message}`)
|
|
110
|
+
.join("\n")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const args = process.argv.slice(2)
|
|
114
|
+
|
|
115
|
+
const hookProgram = Effect.gen(function* () {
|
|
116
|
+
const output = yield* runHookMode(yield* readStdin).pipe(Effect.provide(WinkTaggerLive))
|
|
117
|
+
console.log(JSON.stringify(output))
|
|
118
|
+
return 0
|
|
119
|
+
}).pipe(
|
|
120
|
+
Effect.catchAllCause((cause) =>
|
|
121
|
+
Effect.sync(() => {
|
|
122
|
+
console.log(JSON.stringify(hookInternalFailure(cause)))
|
|
123
|
+
return 0
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const sessionProgram = Effect.gen(function* () {
|
|
129
|
+
console.log(yield* runSessionCommand(args.slice(1)))
|
|
130
|
+
return 0
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const lintProgram = Effect.gen(function* () {
|
|
134
|
+
const tagger = yield* TaggerService
|
|
135
|
+
const { json, configPath, kind, kindMissingValue, paths } = yield* parseArgs(args)
|
|
136
|
+
if (kindMissingValue) {
|
|
137
|
+
return yield* Effect.fail(
|
|
138
|
+
new Error(`--kind requires a value; expected one of: ${KINDS.join(", ")}`),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
if (kind !== undefined && !isLintKind(kind)) {
|
|
142
|
+
return yield* Effect.fail(
|
|
143
|
+
new Error(`unknown kind "${kind}"; expected one of: ${KINDS.join(", ")}`),
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
const config = yield* loadConfig(configPath)
|
|
147
|
+
const loadedDictionary = yield* Effect.either(
|
|
148
|
+
loadDictionary(process.env.SIMPLE_ENGLISH_DICTIONARY),
|
|
149
|
+
)
|
|
150
|
+
const dictionary = Either.getOrUndefined(loadedDictionary)
|
|
151
|
+
if (Either.isLeft(loadedDictionary)) {
|
|
152
|
+
yield* Effect.sync(() => console.error(loadedDictionary.left.message))
|
|
153
|
+
}
|
|
154
|
+
const inputs =
|
|
155
|
+
paths.length === 0
|
|
156
|
+
? [{ path: "<stdin>", text: yield* readStdin }]
|
|
157
|
+
: yield* Effect.forEach(paths, readInput)
|
|
158
|
+
|
|
159
|
+
const report = toCliReport(
|
|
160
|
+
inputs.map(({ path, text }) => {
|
|
161
|
+
const classification = classifyPath(path)
|
|
162
|
+
return {
|
|
163
|
+
path,
|
|
164
|
+
report: lint(kind ?? classification.kind, text, {
|
|
165
|
+
...config,
|
|
166
|
+
dictionary,
|
|
167
|
+
tagger,
|
|
168
|
+
sourceDialect: classification.sourceDialect,
|
|
169
|
+
}),
|
|
170
|
+
}
|
|
171
|
+
}),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
const output = render(report, json)
|
|
175
|
+
if (output !== "") {
|
|
176
|
+
console.log(output)
|
|
177
|
+
}
|
|
178
|
+
return report.summary.hard > 0 ? 1 : 0
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const program: Effect.Effect<number, Error> =
|
|
182
|
+
args[0] === "hook"
|
|
183
|
+
? hookProgram
|
|
184
|
+
: args[0] === "session"
|
|
185
|
+
? sessionProgram
|
|
186
|
+
: lintProgram.pipe(Effect.provide(WinkTaggerLive))
|
|
187
|
+
|
|
188
|
+
const handled = program.pipe(
|
|
189
|
+
Effect.catchAll((error) =>
|
|
190
|
+
Effect.sync(() => {
|
|
191
|
+
console.error(error.message)
|
|
192
|
+
return 2
|
|
193
|
+
}),
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const exitCode = await Effect.runPromise(handled).catch((error) => {
|
|
198
|
+
console.error(String(error))
|
|
199
|
+
return 2
|
|
200
|
+
})
|
|
201
|
+
process.exit(exitCode)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { resolve } from "node:path"
|
|
2
|
+
import { Effect, Either } from "effect"
|
|
3
|
+
import { formatFailedStatusSummary, formatStatusSummary } from "../adapter/rule-summary.ts"
|
|
4
|
+
import { loadConfig } from "../config/load.ts"
|
|
5
|
+
import { loadDictionary } from "../dictionary/load.ts"
|
|
6
|
+
import {
|
|
7
|
+
type SessionControl,
|
|
8
|
+
getSessionControl,
|
|
9
|
+
setSessionEnabled,
|
|
10
|
+
setSessionStrict,
|
|
11
|
+
} from "./session-state.ts"
|
|
12
|
+
|
|
13
|
+
const USAGE = "Usage: /ste [on|off|status|strict|strict off]"
|
|
14
|
+
|
|
15
|
+
type DictionaryState = "loaded" | "not loaded" | `failed (${string})`
|
|
16
|
+
|
|
17
|
+
function modeName(control: SessionControl): "disabled" | "enabled" | "strict" {
|
|
18
|
+
if (!control.enabled) return "disabled"
|
|
19
|
+
return control.strict ? "strict" : "enabled"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const readControl = (sessionId: string) =>
|
|
23
|
+
Effect.tryPromise({
|
|
24
|
+
try: () => getSessionControl(sessionId),
|
|
25
|
+
catch: (cause) => new Error(`cannot read session state: ${cause}`),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const updateEnabled = (sessionId: string, enabled: boolean) =>
|
|
29
|
+
Effect.tryPromise({
|
|
30
|
+
try: () => setSessionEnabled(sessionId, enabled),
|
|
31
|
+
catch: (cause) => new Error(`cannot update session state: ${cause}`),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const updateStrict = (sessionId: string, strict: boolean) =>
|
|
35
|
+
Effect.tryPromise({
|
|
36
|
+
try: () => setSessionStrict(sessionId, strict),
|
|
37
|
+
catch: (cause) => new Error(`cannot update session state: ${cause}`),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
function status(sessionId: string, cwd: string): Effect.Effect<string, Error> {
|
|
41
|
+
return Effect.gen(function* () {
|
|
42
|
+
const control = yield* readControl(sessionId)
|
|
43
|
+
const configResult = yield* Effect.either(loadConfig(undefined, cwd))
|
|
44
|
+
if (Either.isLeft(configResult)) {
|
|
45
|
+
return formatFailedStatusSummary(modeName(control), configResult.left.message)
|
|
46
|
+
}
|
|
47
|
+
const dictionaryPath = process.env.SIMPLE_ENGLISH_DICTIONARY
|
|
48
|
+
const dictionaryResult = yield* Effect.either(
|
|
49
|
+
loadDictionary(dictionaryPath === undefined ? undefined : resolve(cwd, dictionaryPath)),
|
|
50
|
+
)
|
|
51
|
+
const dictionary: DictionaryState = Either.isRight(dictionaryResult)
|
|
52
|
+
? "loaded"
|
|
53
|
+
: `failed (${dictionaryResult.left.message})`
|
|
54
|
+
return formatStatusSummary(configResult.right, modeName(control), dictionary)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function runSessionCommand(args: readonly string[]): Effect.Effect<string, Error> {
|
|
59
|
+
const [sessionId, cwd, ...commandParts] = args
|
|
60
|
+
if (sessionId === undefined || sessionId.length === 0 || cwd === undefined || cwd.length === 0) {
|
|
61
|
+
return Effect.fail(new Error(USAGE))
|
|
62
|
+
}
|
|
63
|
+
const command = commandParts.join(" ").trim().toLowerCase()
|
|
64
|
+
if (command === "status") return status(sessionId, cwd)
|
|
65
|
+
if (command === "on") {
|
|
66
|
+
return updateEnabled(sessionId, true).pipe(Effect.as("STE enforcement enabled."))
|
|
67
|
+
}
|
|
68
|
+
if (command === "off") {
|
|
69
|
+
return updateEnabled(sessionId, false).pipe(Effect.as("STE enforcement disabled."))
|
|
70
|
+
}
|
|
71
|
+
if (command === "strict" || command === "strict on") {
|
|
72
|
+
return updateStrict(sessionId, true).pipe(Effect.as("STE strict mode enabled."))
|
|
73
|
+
}
|
|
74
|
+
if (command === "strict off") {
|
|
75
|
+
return updateStrict(sessionId, false).pipe(Effect.as("STE strict mode disabled."))
|
|
76
|
+
}
|
|
77
|
+
return Effect.fail(new Error(USAGE))
|
|
78
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"
|
|
3
|
+
import { homedir } from "node:os"
|
|
4
|
+
import { isAbsolute, join } from "node:path"
|
|
5
|
+
|
|
6
|
+
export interface SessionControl {
|
|
7
|
+
readonly enabled: boolean
|
|
8
|
+
readonly strict: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SessionState extends SessionControl {
|
|
12
|
+
readonly version: 3
|
|
13
|
+
readonly lastProcessedReply?: string
|
|
14
|
+
readonly pendingFeedback?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_CONTROL: SessionControl = { enabled: true, strict: false }
|
|
18
|
+
const LOCK_STALE_MILLISECONDS = 10_000
|
|
19
|
+
const LOCK_RETRY_MILLISECONDS = 5
|
|
20
|
+
const LOCK_RETRIES = 200
|
|
21
|
+
|
|
22
|
+
const isFileError = (cause: unknown, code: string): boolean =>
|
|
23
|
+
typeof cause === "object" && cause !== null && (cause as { code?: string }).code === code
|
|
24
|
+
|
|
25
|
+
const stateRoot = (): string => {
|
|
26
|
+
const configured = process.env.XDG_STATE_HOME
|
|
27
|
+
return configured && isAbsolute(configured) ? configured : join(homedir(), ".local", "state")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sessionsDirectory = (): string => join(stateRoot(), "simple-english", "sessions")
|
|
31
|
+
|
|
32
|
+
const sessionKey = (sessionId: string): string =>
|
|
33
|
+
createHash("sha256").update(sessionId).digest("hex")
|
|
34
|
+
|
|
35
|
+
const sessionPath = (sessionId: string): string =>
|
|
36
|
+
join(sessionsDirectory(), `${sessionKey(sessionId)}.json`)
|
|
37
|
+
|
|
38
|
+
const lockPath = (sessionId: string): string =>
|
|
39
|
+
join(sessionsDirectory(), `.${sessionKey(sessionId)}.lock`)
|
|
40
|
+
|
|
41
|
+
function optionalString(state: Record<string, unknown>, name: string): string | undefined {
|
|
42
|
+
const value = state[name]
|
|
43
|
+
if (value !== undefined && typeof value !== "string") {
|
|
44
|
+
throw new Error(`${name} must be a string`)
|
|
45
|
+
}
|
|
46
|
+
return value as string | undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function decodeState(text: string, path: string): SessionState {
|
|
50
|
+
const value = JSON.parse(text) as unknown
|
|
51
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
52
|
+
throw new Error(`invalid session state in ${path}`)
|
|
53
|
+
}
|
|
54
|
+
const state = value as Record<string, unknown>
|
|
55
|
+
const pendingFeedback = optionalString(state, "pendingFeedback")
|
|
56
|
+
if (state.version === 1 && typeof pendingFeedback === "string") {
|
|
57
|
+
return { version: 3, ...DEFAULT_CONTROL, pendingFeedback }
|
|
58
|
+
}
|
|
59
|
+
const lastProcessedReply = optionalString(state, "lastProcessedReply")
|
|
60
|
+
if (state.version === 2 && lastProcessedReply !== undefined && lastProcessedReply.length > 0) {
|
|
61
|
+
return {
|
|
62
|
+
version: 3,
|
|
63
|
+
...DEFAULT_CONTROL,
|
|
64
|
+
lastProcessedReply,
|
|
65
|
+
...(pendingFeedback === undefined ? {} : { pendingFeedback }),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
state.version !== 3 ||
|
|
70
|
+
typeof state.enabled !== "boolean" ||
|
|
71
|
+
typeof state.strict !== "boolean" ||
|
|
72
|
+
(state.strict && !state.enabled) ||
|
|
73
|
+
lastProcessedReply === ""
|
|
74
|
+
) {
|
|
75
|
+
throw new Error(`invalid session state in ${path}`)
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
version: 3,
|
|
79
|
+
enabled: state.enabled,
|
|
80
|
+
strict: state.strict,
|
|
81
|
+
...(lastProcessedReply === undefined ? {} : { lastProcessedReply }),
|
|
82
|
+
...(pendingFeedback === undefined ? {} : { pendingFeedback }),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readState(sessionId: string): Promise<SessionState | undefined> {
|
|
87
|
+
const path = sessionPath(sessionId)
|
|
88
|
+
try {
|
|
89
|
+
return decodeState(await readFile(path, "utf8"), path)
|
|
90
|
+
} catch (cause) {
|
|
91
|
+
if (isFileError(cause, "ENOENT")) return undefined
|
|
92
|
+
throw cause
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function writeState(sessionId: string, state: SessionState): Promise<void> {
|
|
97
|
+
const directory = sessionsDirectory()
|
|
98
|
+
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
99
|
+
const temporaryPath = join(directory, `.${sessionKey(sessionId)}.${randomUUID()}.tmp`)
|
|
100
|
+
try {
|
|
101
|
+
await writeFile(temporaryPath, JSON.stringify(state), { encoding: "utf8", mode: 0o600 })
|
|
102
|
+
await rename(temporaryPath, sessionPath(sessionId))
|
|
103
|
+
} finally {
|
|
104
|
+
await rm(temporaryPath, { force: true })
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const wait = (milliseconds: number): Promise<void> =>
|
|
109
|
+
new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
110
|
+
|
|
111
|
+
async function acquireLock(sessionId: string): Promise<() => Promise<void>> {
|
|
112
|
+
const directory = sessionsDirectory()
|
|
113
|
+
const path = lockPath(sessionId)
|
|
114
|
+
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
115
|
+
for (let attempt = 0; attempt < LOCK_RETRIES; attempt += 1) {
|
|
116
|
+
try {
|
|
117
|
+
await mkdir(path, { mode: 0o700 })
|
|
118
|
+
return () => rm(path, { recursive: true, force: true })
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
if (!isFileError(cause, "EEXIST")) throw cause
|
|
121
|
+
try {
|
|
122
|
+
const lockStat = await stat(path)
|
|
123
|
+
if (Date.now() - lockStat.mtimeMs > LOCK_STALE_MILLISECONDS) {
|
|
124
|
+
await rm(path, { recursive: true, force: true })
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
} catch (statCause) {
|
|
128
|
+
if (!isFileError(statCause, "ENOENT")) throw statCause
|
|
129
|
+
}
|
|
130
|
+
await wait(LOCK_RETRY_MILLISECONDS)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw new Error(`timed out while reading session ${sessionId}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function withStateLock<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
|
|
137
|
+
const release = await acquireLock(sessionId)
|
|
138
|
+
try {
|
|
139
|
+
return await operation()
|
|
140
|
+
} finally {
|
|
141
|
+
await release()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const currentState = (state: SessionState | undefined): SessionState =>
|
|
146
|
+
state ?? { version: 3, ...DEFAULT_CONTROL }
|
|
147
|
+
|
|
148
|
+
export async function getSessionControl(sessionId: string): Promise<SessionControl> {
|
|
149
|
+
return withStateLock(sessionId, async () => {
|
|
150
|
+
const { enabled, strict } = currentState(await readState(sessionId))
|
|
151
|
+
return { enabled, strict }
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function setSessionEnabled(sessionId: string, enabled: boolean): Promise<void> {
|
|
156
|
+
await withStateLock(sessionId, async () => {
|
|
157
|
+
const state = currentState(await readState(sessionId))
|
|
158
|
+
await writeState(sessionId, {
|
|
159
|
+
...state,
|
|
160
|
+
enabled,
|
|
161
|
+
strict: enabled ? state.strict : false,
|
|
162
|
+
...(enabled || state.pendingFeedback === undefined ? {} : { pendingFeedback: undefined }),
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function setSessionStrict(sessionId: string, strict: boolean): Promise<void> {
|
|
168
|
+
await withStateLock(sessionId, async () => {
|
|
169
|
+
const state = currentState(await readState(sessionId))
|
|
170
|
+
await writeState(sessionId, {
|
|
171
|
+
...state,
|
|
172
|
+
enabled: strict ? true : state.enabled,
|
|
173
|
+
strict,
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function hasProcessedReply(
|
|
179
|
+
sessionId: string,
|
|
180
|
+
replyIdentity: string,
|
|
181
|
+
): Promise<boolean> {
|
|
182
|
+
return withStateLock(sessionId, async () => {
|
|
183
|
+
const state = await readState(sessionId)
|
|
184
|
+
return state?.lastProcessedReply === replyIdentity
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function setReplyFeedback(
|
|
189
|
+
sessionId: string,
|
|
190
|
+
replyIdentity: string,
|
|
191
|
+
pendingFeedback: string | undefined,
|
|
192
|
+
): Promise<SessionControl | undefined> {
|
|
193
|
+
return withStateLock(sessionId, async () => {
|
|
194
|
+
const state = currentState(await readState(sessionId))
|
|
195
|
+
if (!state.enabled || state.lastProcessedReply === replyIdentity) return undefined
|
|
196
|
+
await writeState(sessionId, {
|
|
197
|
+
...state,
|
|
198
|
+
lastProcessedReply: replyIdentity,
|
|
199
|
+
...(state.strict || pendingFeedback === undefined
|
|
200
|
+
? { pendingFeedback: undefined }
|
|
201
|
+
: { pendingFeedback }),
|
|
202
|
+
})
|
|
203
|
+
return { enabled: state.enabled, strict: state.strict }
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function consumePendingFeedback(sessionId: string): Promise<string | undefined> {
|
|
208
|
+
return withStateLock(sessionId, async () => {
|
|
209
|
+
const state = await readState(sessionId)
|
|
210
|
+
if (state?.pendingFeedback === undefined) return undefined
|
|
211
|
+
await writeState(sessionId, { ...state, pendingFeedback: undefined })
|
|
212
|
+
return state.pendingFeedback
|
|
213
|
+
})
|
|
214
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { isAbsolute, join, resolve } from "node:path"
|
|
4
|
+
import { Effect } from "effect"
|
|
5
|
+
import { mergeConfigs } from "./merge.ts"
|
|
6
|
+
import { ConfigError, type SteConfig, decodeConfig } from "./schema.ts"
|
|
7
|
+
|
|
8
|
+
const legacyAgentConfigDirectory = (cwd: string): string => {
|
|
9
|
+
const configured = process.env.PI_CODING_AGENT_DIR
|
|
10
|
+
if (!configured) return join(homedir(), ".pi", "agent")
|
|
11
|
+
if (configured === "~") return homedir()
|
|
12
|
+
const expanded =
|
|
13
|
+
configured.startsWith("~/") || (process.platform === "win32" && configured.startsWith("~\\"))
|
|
14
|
+
? join(homedir(), configured.slice(2))
|
|
15
|
+
: configured
|
|
16
|
+
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const xdgConfigDirectory = (): string => {
|
|
20
|
+
const configured = process.env.XDG_CONFIG_HOME
|
|
21
|
+
return configured && isAbsolute(configured) ? configured : join(homedir(), ".config")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const globalConfigPath = (): string =>
|
|
25
|
+
join(xdgConfigDirectory(), "simple-english", "config.json")
|
|
26
|
+
|
|
27
|
+
export const projectConfigPath = (cwd: string): string => join(cwd, ".simple-english.json")
|
|
28
|
+
|
|
29
|
+
export const legacyGlobalConfigPath = (cwd = process.cwd()): string =>
|
|
30
|
+
join(legacyAgentConfigDirectory(cwd), "simple-english.json")
|
|
31
|
+
|
|
32
|
+
export const legacyProjectConfigPath = (cwd: string): string =>
|
|
33
|
+
join(cwd, ".pi", "simple-english.json")
|
|
34
|
+
|
|
35
|
+
const isMissingFile = (cause: unknown): boolean =>
|
|
36
|
+
typeof cause === "object" && cause !== null && (cause as { code?: string }).code === "ENOENT"
|
|
37
|
+
|
|
38
|
+
const readConfigFile = (
|
|
39
|
+
path: string,
|
|
40
|
+
optional: boolean,
|
|
41
|
+
): Effect.Effect<SteConfig | undefined, ConfigError> =>
|
|
42
|
+
Effect.tryPromise({
|
|
43
|
+
try: () => readFile(path, "utf8"),
|
|
44
|
+
catch: (cause) => cause,
|
|
45
|
+
}).pipe(
|
|
46
|
+
Effect.matchEffect({
|
|
47
|
+
onFailure: (cause) =>
|
|
48
|
+
optional && isMissingFile(cause)
|
|
49
|
+
? Effect.succeed(undefined)
|
|
50
|
+
: Effect.fail(new ConfigError(`cannot read config file ${path}: ${cause}`)),
|
|
51
|
+
onSuccess: (text) =>
|
|
52
|
+
Effect.try({
|
|
53
|
+
try: () => JSON.parse(text) as unknown,
|
|
54
|
+
catch: (cause) => new ConfigError(`invalid JSON in ${path}: ${cause}`),
|
|
55
|
+
}).pipe(Effect.flatMap((json) => decodeConfig(json, path))),
|
|
56
|
+
}),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const readConfigWithFallback = (
|
|
60
|
+
path: string,
|
|
61
|
+
fallbackPath: string,
|
|
62
|
+
): Effect.Effect<SteConfig, ConfigError> =>
|
|
63
|
+
readConfigFile(path, true).pipe(
|
|
64
|
+
Effect.flatMap((config) =>
|
|
65
|
+
config === undefined
|
|
66
|
+
? readConfigFile(fallbackPath, true).pipe(Effect.map((fallback) => fallback ?? {}))
|
|
67
|
+
: Effect.succeed(config),
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
export const loadConfig = (
|
|
72
|
+
explicitPath?: string,
|
|
73
|
+
cwd = process.cwd(),
|
|
74
|
+
includeProjectConfig = true,
|
|
75
|
+
): Effect.Effect<SteConfig, ConfigError> => {
|
|
76
|
+
if (explicitPath !== undefined) {
|
|
77
|
+
return readConfigFile(explicitPath, false).pipe(Effect.map((config) => config ?? {}))
|
|
78
|
+
}
|
|
79
|
+
const globalConfig = readConfigWithFallback(globalConfigPath(), legacyGlobalConfigPath(cwd))
|
|
80
|
+
if (!includeProjectConfig) return globalConfig
|
|
81
|
+
const projectConfig = readConfigWithFallback(projectConfigPath(cwd), legacyProjectConfigPath(cwd))
|
|
82
|
+
return Effect.all([globalConfig, projectConfig]).pipe(
|
|
83
|
+
Effect.map(([global, project]) => mergeConfigs(global, project)),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { SteConfig } from "./schema.ts"
|
|
2
|
+
|
|
3
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
4
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
5
|
+
|
|
6
|
+
const deepMerge = (
|
|
7
|
+
base: Record<string, unknown>,
|
|
8
|
+
over: Record<string, unknown>,
|
|
9
|
+
): Record<string, unknown> => {
|
|
10
|
+
const merged = { ...base }
|
|
11
|
+
for (const [key, value] of Object.entries(over)) {
|
|
12
|
+
const existing = merged[key]
|
|
13
|
+
merged[key] =
|
|
14
|
+
isPlainObject(existing) && isPlainObject(value) ? deepMerge(existing, value) : value
|
|
15
|
+
}
|
|
16
|
+
return merged
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const mergeConfigs = (global: SteConfig, project: SteConfig): SteConfig =>
|
|
20
|
+
deepMerge(global as Record<string, unknown>, project as Record<string, unknown>) as SteConfig
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Effect, ParseResult, Schema } from "effect"
|
|
2
|
+
import { type RuleId, ruleIds } from "../engine/rules/registry.ts"
|
|
3
|
+
import type { RuleSetting } from "../engine/types.ts"
|
|
4
|
+
|
|
5
|
+
export interface SteConfig {
|
|
6
|
+
readonly rules?: Partial<Readonly<Record<RuleId, RuleSetting>>>
|
|
7
|
+
readonly maxSentenceWords?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const RuleSettingSchema = Schema.Literal("hard", "soft", "off").annotations({
|
|
11
|
+
message: (issue) => ({
|
|
12
|
+
message: `must be "hard", "soft", or "off", got ${JSON.stringify(issue.actual)}`,
|
|
13
|
+
override: true,
|
|
14
|
+
}),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const RulesSchema = Schema.partial(
|
|
18
|
+
Schema.Struct(Object.fromEntries(ruleIds.map((id) => [id, RuleSettingSchema]))),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const MaxSentenceWordsSchema = Schema.Int.pipe(Schema.positive()).annotations({
|
|
22
|
+
message: (issue) => ({
|
|
23
|
+
message: `must be a positive integer, got ${JSON.stringify(issue.actual)}`,
|
|
24
|
+
override: true,
|
|
25
|
+
}),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const SteConfigSchema = Schema.Struct({
|
|
29
|
+
rules: Schema.optional(RulesSchema),
|
|
30
|
+
maxSentenceWords: Schema.optional(MaxSentenceWordsSchema),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const decodeUnknown = Schema.decodeUnknown(SteConfigSchema, {
|
|
34
|
+
onExcessProperty: "error",
|
|
35
|
+
errors: "all",
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
export class ConfigError extends Error {
|
|
39
|
+
readonly _tag = "ConfigError"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const formatError = (error: ParseResult.ParseError, source: string): string => {
|
|
43
|
+
// Optional fields decode as `T | undefined` unions, so every failure also
|
|
44
|
+
// reports a useless "Expected undefined" branch; drop those.
|
|
45
|
+
const issues = ParseResult.ArrayFormatter.formatErrorSync(error).filter(
|
|
46
|
+
(issue) => !issue.message.startsWith("Expected undefined"),
|
|
47
|
+
)
|
|
48
|
+
const lines = [
|
|
49
|
+
...new Set(
|
|
50
|
+
issues.map(
|
|
51
|
+
(issue) => `${issue.path.length > 0 ? issue.path.join(".") : "config"}: ${issue.message}`,
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
]
|
|
55
|
+
const detail =
|
|
56
|
+
lines.length > 0
|
|
57
|
+
? lines.map((line) => ` ${line}`).join("\n")
|
|
58
|
+
: ` ${ParseResult.TreeFormatter.formatErrorSync(error)}`
|
|
59
|
+
return `invalid config in ${source}:\n${detail}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const decodeConfig = (
|
|
63
|
+
input: unknown,
|
|
64
|
+
source: string,
|
|
65
|
+
): Effect.Effect<SteConfig, ConfigError> =>
|
|
66
|
+
decodeUnknown(input).pipe(
|
|
67
|
+
Effect.mapError((error) => new ConfigError(formatError(error, source))),
|
|
68
|
+
Effect.map((config) => config as SteConfig),
|
|
69
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Dictionary data format
|
|
2
|
+
|
|
3
|
+
`data/pi-ste.json` uses the package-owned format that `schema.ts` defines and Effect Schema validates.
|
|
4
|
+
|
|
5
|
+
- `formatVersion` identifies incompatible format changes.
|
|
6
|
+
- `source` records the source name and pins the repository, commit, and path from which the data was converted.
|
|
7
|
+
- `entries[].unapproved` lists exact case-insensitive word forms or phrases.
|
|
8
|
+
Forms can contain letters, numbers, internal apostrophes, internal hyphens, and horizontal whitespace between words.
|
|
9
|
+
- `entries[].suggestions` lists approved alternatives.
|
|
10
|
+
- `entries[].partsOfSpeech`, when present, lists the POS tags for which the forms are unapproved.
|
|
11
|
+
|
|
12
|
+
Unknown properties and unsupported form syntax cause dictionary validation to fail.
|
|
13
|
+
Matching is token based, and a hyphenated form matches only that exact hyphenated token.
|
|
14
|
+
A phrase can span horizontal whitespace or a soft line break in the same Markdown paragraph, but it cannot span Markdown block boundaries or hard line breaks.
|
|
15
|
+
Fenced and indented Markdown code is excluded from matching.
|
|
16
|
+
The rule checks an entry with `partsOfSpeech` only when an injected tagger returns one of those tags for the form's first token.
|
|
17
|
+
The rule checks an entry without `partsOfSpeech` by word or phrase alone.
|
|
18
|
+
See [`THIRD_PARTY_NOTICES.md`](../../THIRD_PARTY_NOTICES.md) for the bundled data's exact source, conversion scope, and license details.
|