@ibobbyts/dsh-git-commit 0.0.0-stage → 0.2.1

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.
@@ -0,0 +1,120 @@
1
+ /** Host-owned settings. Global and repository scopes are stored outside Git. */
2
+ import { mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { randomUUID } from 'node:crypto';
6
+
7
+ const HOME = () => process.env.DSH_HOME || join(homedir(), '.dsh');
8
+ /** Stable fallback for existing v1 settings files without presentation preferences. */
9
+ export const DEFAULT_COUNTDOWN_COLOR = '#f59e0b';
10
+ const countdownColor = value => typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value)
11
+ ? value.toLowerCase() : DEFAULT_COUNTDOWN_COLOR;
12
+ const validFloatingPosition = value => value !== null && value !== undefined && typeof value === 'object'
13
+ && !Array.isArray(value) && Number.isFinite(value.right) && Number.isFinite(value.top)
14
+ && value.right >= 0 && value.top >= 0 && value.right <= 100000 && value.top <= 100000;
15
+ const defaults = () => ({ version: 1, global: { instruction: '', model: null, autoHideNonGit: false, showTokenDetails: false, countdownColor: DEFAULT_COUNTDOWN_COLOR, floatingPosition: null }, repositories: {} });
16
+ const plain = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
17
+ const string = (value, field, limit = 12000) => {
18
+ if (typeof value !== 'string' || value.length > limit) throw new Error(`${field} must be a string of at most ${limit} characters`);
19
+ return value;
20
+ };
21
+ /** null is the fixed 'inherit global' marker for repository model selection. */
22
+ const storedModel = value => plain(value) && typeof value.provider === 'string' && value.provider.length > 0
23
+ && typeof value.model === 'string' && value.model.length > 0
24
+ ? { provider: value.provider, model: value.model } : null;
25
+ const checkedModel = (value, key) => {
26
+ if (value === null) return null;
27
+ if (!plain(value) || typeof value.provider !== 'string' || typeof value.model !== 'string'
28
+ || !value.provider || !value.model) throw new Error(`Invalid ${key} selection`);
29
+ return { provider: string(value.provider, `${key}.provider`, 200),
30
+ model: string(value.model, `${key}.model`, 400) };
31
+ };
32
+
33
+ export class InstructionStore {
34
+ constructor(path = join(HOME(), 'dsh-git-commit.json')) {
35
+ this.path = path;
36
+ this.writes = Promise.resolve();
37
+ }
38
+ async read() {
39
+ try {
40
+ const data = JSON.parse(await readFile(this.path, 'utf8'));
41
+ if (!plain(data) || data.version !== 1) throw new Error('Unsupported commit settings version');
42
+ return { version: 1, global: plain(data.global) ? data.global : defaults().global, repositories: plain(data.repositories) ? data.repositories : {} };
43
+ } catch (error) {
44
+ if (error.code === 'ENOENT') return defaults();
45
+ throw error;
46
+ }
47
+ }
48
+ async get(root) {
49
+ const data = await this.read();
50
+ const repo = typeof root === 'string' && plain(data.repositories[root]) ? data.repositories[root] : {};
51
+ const model = storedModel(data.global?.model);
52
+ const repositoryModel = storedModel(repo.model);
53
+ return {
54
+ globalInstruction: typeof data.global?.instruction === 'string' ? data.global.instruction : '',
55
+ autoHideNonGit: data.global?.autoHideNonGit === true,
56
+ showTokenDetails: data.global?.showTokenDetails === true,
57
+ countdownColor: countdownColor(data.global?.countdownColor),
58
+ floatingPosition: validFloatingPosition(data.global?.floatingPosition)
59
+ ? {right:data.global.floatingPosition.right,top:data.global.floatingPosition.top} : null,
60
+ repositoryInstruction: typeof repo.instruction === 'string' ? repo.instruction : '',
61
+ inheritGlobal: repo.inheritGlobal !== false,
62
+ model,
63
+ repositoryModel,
64
+ };
65
+ }
66
+ async save(root, patch) {
67
+ const allowed = ['globalInstruction', 'repositoryInstruction', 'inheritGlobal', 'model', 'repositoryModel', 'autoHideNonGit', 'showTokenDetails', 'countdownColor', 'floatingPosition'];
68
+ if (!plain(patch) || Object.keys(patch).some(key => !allowed.includes(key))) throw new Error('Unknown commit setting');
69
+ if (root === null && ('repositoryInstruction' in patch || 'inheritGlobal' in patch || 'repositoryModel' in patch)) {
70
+ throw new Error('Repository-specific settings require a Git repository');
71
+ }
72
+ if (root !== null && (typeof root !== 'string' || !root)) throw new Error('Invalid repository scope');
73
+ const apply = async () => {
74
+ const data = await this.read();
75
+ const global = plain(data.global) ? data.global : {};
76
+ const current = root !== null && plain(data.repositories[root]) ? data.repositories[root] : {};
77
+ if ('globalInstruction' in patch) global.instruction = string(patch.globalInstruction, 'globalInstruction');
78
+ if ('autoHideNonGit' in patch) {
79
+ if (typeof patch.autoHideNonGit !== 'boolean') throw new Error('autoHideNonGit must be a boolean');
80
+ global.autoHideNonGit = patch.autoHideNonGit;
81
+ }
82
+ if ('showTokenDetails' in patch) {
83
+ if (typeof patch.showTokenDetails !== 'boolean') throw new Error('showTokenDetails must be a boolean');
84
+ global.showTokenDetails = patch.showTokenDetails;
85
+ }
86
+ if ('countdownColor' in patch) {
87
+ if (typeof patch.countdownColor !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(patch.countdownColor)) {
88
+ throw new Error('countdownColor must be a #RRGGBB color');
89
+ }
90
+ global.countdownColor = patch.countdownColor.toLowerCase();
91
+ }
92
+ if ('floatingPosition' in patch) {
93
+ if (patch.floatingPosition !== null && !validFloatingPosition(patch.floatingPosition)) {
94
+ throw new Error('floatingPosition must be null or finite nonnegative right/top coordinates');
95
+ }
96
+ global.floatingPosition = patch.floatingPosition === null ? null
97
+ : {right:patch.floatingPosition.right,top:patch.floatingPosition.top};
98
+ }
99
+ if ('repositoryInstruction' in patch) current.instruction = string(patch.repositoryInstruction, 'repositoryInstruction');
100
+ if ('inheritGlobal' in patch) {
101
+ if (typeof patch.inheritGlobal !== 'boolean') throw new Error('inheritGlobal must be a boolean');
102
+ current.inheritGlobal = patch.inheritGlobal;
103
+ }
104
+ if ('model' in patch) global.model = checkedModel(patch.model, 'model');
105
+ if ('repositoryModel' in patch) current.model = checkedModel(patch.repositoryModel, 'repositoryModel');
106
+ data.global = global;
107
+ if (root !== null) data.repositories[root] = current;
108
+ await mkdir(dirname(this.path), { recursive: true });
109
+ const tmp = `${this.path}.${randomUUID()}.tmp`;
110
+ try {
111
+ await writeFile(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
112
+ await rename(tmp, this.path);
113
+ } finally { await rm(tmp, { force: true }); }
114
+ return this.get(root);
115
+ };
116
+ const next = this.writes.then(apply);
117
+ this.writes = next.catch(() => {});
118
+ return next;
119
+ }
120
+ }
@@ -0,0 +1,57 @@
1
+ /** Change tokens keep browser polling cheap: Git is queried only on changes. */
2
+ import { watch } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { git } from './git.js';
5
+
6
+ const IGNORE = new Set(['.git', 'node_modules', 'dist', 'build', 'coverage', 'target', '.next', '.venv', '__pycache__']);
7
+ export class RepoWatcher {
8
+ constructor() { this.repos = new Map(); this.seed = 0; }
9
+ async token(root) {
10
+ let record = this.repos.get(root);
11
+ if (record) return record.token;
12
+ record = { token: ++this.seed, handles: [], timer: null, first: 0 };
13
+ this.repos.set(root, record);
14
+ const publish = () => { record.token += 1; record.first = 0; record.timer = null; };
15
+ const bump = () => {
16
+ const now = Date.now();
17
+ if (!record.first) record.first = now;
18
+ if (record.timer) clearTimeout(record.timer);
19
+ if (now - record.first > 1000) return publish();
20
+ record.timer = setTimeout(publish, 160);
21
+ record.timer.unref?.();
22
+ };
23
+ const listen = (path, options, filter) => {
24
+ try {
25
+ const handle = watch(path, options, (_, file) => { if (filter(file)) bump(); });
26
+ handle.on('error', () => bump());
27
+ handle.unref?.();
28
+ record.handles.push(handle);
29
+ } catch { /* A low-frequency client fallback still ensures eventual refresh. */ }
30
+ };
31
+ listen(root, { recursive: true }, path => {
32
+ if (!path) return true;
33
+ const first = String(path).replaceAll('\\', '/').split('/')[0];
34
+ return !IGNORE.has(first);
35
+ });
36
+ // Git worktrees have a .git FILE, and common refs may live in another checkout.
37
+ for (const path of ['HEAD', 'index', 'packed-refs', 'refs', 'MERGE_HEAD']) {
38
+ try {
39
+ const actual = (await git(root, ['rev-parse', '--git-path', path])).trim();
40
+ const full = resolve(root, actual);
41
+ listen(dirname(full), {}, name => {
42
+ if (!name) return true;
43
+ if (path === 'refs') return String(name) === 'refs';
44
+ return String(name) === path.split('/').at(-1);
45
+ });
46
+ } catch { /* Path may not exist on a freshly initialized repository. */ }
47
+ }
48
+ return record.token;
49
+ }
50
+ close() {
51
+ for (const record of this.repos.values()) {
52
+ if (record.timer) clearTimeout(record.timer);
53
+ for (const handle of record.handles) handle.close();
54
+ }
55
+ this.repos.clear();
56
+ }
57
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,81 @@
1
+ /** Public host-facing API for DSH plugins. */
2
+ export interface GitCommitRequest {
3
+ /** Working directory inside the repository. Prefer an explicit cwd from another plugin. */
4
+ cwd?: string;
5
+ repositoryPath?: string;
6
+ sessionId?: string;
7
+ /** Optional caller-supplied message; skips AI generation but preserves scope/snapshot validation. */
8
+ message?: string;
9
+ signal?: AbortSignal;
10
+ }
11
+ /** Provider usage totals and local monotonic timings across at most two LLM calls. */
12
+ export interface GitCommitLlmMetrics {
13
+ calls: number;
14
+ /** Total input including uncached, cache-read, and cache-write tokens. Null if usage unavailable. */
15
+ inputTokens: number | null;
16
+ outputTokens: number | null;
17
+ cacheReadTokens: number | null;
18
+ cacheWriteTokens: number | null;
19
+ reasoningTokens: number | null;
20
+ /** Earliest content token across calls, relative to first request's start. */
21
+ latencyMs: number | null;
22
+ /** LLM phase elapsed wall-clock time. */
23
+ durationMs: number;
24
+ /** Total output tokens / sum of per-call decoding times. Null if data incomplete. */
25
+ outputSpeedTps: number | null;
26
+ }
27
+ export interface GitCommitResult {
28
+ success: boolean;
29
+ status: 'success' | 'failure';
30
+ scope: 'staged' | 'all' | null;
31
+ additions: number;
32
+ deletions: number;
33
+ message: string;
34
+ /** Exactly the first seven characters of the resulting commit OID, or null upon failure. */
35
+ hash: string | null;
36
+ fullHash?: string;
37
+ /** Present when an LLM was invoked; caller-supplied messages omit this. */
38
+ llm?: GitCommitLlmMetrics;
39
+ totalDurationMs?: number;
40
+ error?: string;
41
+ }
42
+ export interface GitCommitSettings {
43
+ globalInstruction: string;
44
+ repositoryInstruction: string;
45
+ inheritGlobal: boolean;
46
+ /** Global preference, defaults to false; affects floating panel only. */
47
+ autoHideNonGit: boolean;
48
+ /** Global setting; defaults to false. */
49
+ showTokenDetails: boolean;
50
+ /** Progress bar color in #rrggbb form; defaults to orange (#f59e0b). */
51
+ countdownColor: string;
52
+ /** Null means default lower-right anchor. Otherwise saved top/right coordinates in CSS pixels. */
53
+ floatingPosition: {right:number;top:number} | null;
54
+ /** Global model selection, null means use the DSH default. */
55
+ model: { provider: string; model: string } | null;
56
+ /** Repository-only override; null means inherit the global model selection (fixed UI option value). */
57
+ repositoryModel: { provider: string; model: string } | null;
58
+ /** True for a Git working tree; false for global-only settings in ordinary directories. */
59
+ isRepository: boolean;
60
+ /** Canonical Git root, or null outside a repository. */
61
+ root: string | null;
62
+ }
63
+ export interface GitCommitApi {
64
+ commit(request: GitCommitRequest): Promise<GitCommitResult>;
65
+ status(request: GitCommitRequest): Promise<
66
+ | { isRepository: false; root: null }
67
+ | { isRepository: true; root: string; repo: string; branch: string; head: string;
68
+ additions: number; deletions: number; binary: number;
69
+ untracked: number; unknownUntrackedLines: number; stagedFiles: number }
70
+ >;
71
+ changeToken(request: GitCommitRequest): Promise<{ token: number | null }>;
72
+ settings(request?: GitCommitRequest): Promise<GitCommitSettings>;
73
+ saveSettings(request: GitCommitRequest & { patch: Partial<Pick<GitCommitSettings, 'globalInstruction' | 'repositoryInstruction' | 'inheritGlobal' | 'model' | 'repositoryModel' | 'autoHideNonGit' | 'showTokenDetails' | 'countdownColor' | 'floatingPosition'>> }): Promise<GitCommitSettings>;
74
+ models(): Promise<{ models: Array<{provider:string;providerName:string;model:string;name:string}>; defaultModel: {provider:string;model:string}|null }>;
75
+ }
76
+ declare module '@deepseek-ai/cordis' {
77
+ interface Context { gitCommit: GitCommitApi; }
78
+ }
79
+ export const name: string;
80
+ export const inject: string[];
81
+ export function apply(ctx: import('@deepseek-ai/cordis').Context): void;
package/lib/index.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * DSH Host plugin. Register a feature endpoint on Connection's already-authenticated
3
+ * /api transport, NOT a new webServer route. Connection's own host context does
4
+ * not inject webServer and ctx.connection.rpc.handle() can trigger Cordis's
5
+ * "cannot get property webServer without inject" on that shadow context.
6
+ *
7
+ * Browser/desktop clients use the public connection.rpc.call('/api', 'git-commit', ...)
8
+ * protocol. Other host plugins use the in-process ctx.gitCommit service.
9
+ */
10
+ import { GitCommitService } from './core/service.js';
11
+
12
+ export const name = 'dsh-git-commit';
13
+ export const inject = ['connection', 'llm'];
14
+ export const ROUTE = '/api/git-commit';
15
+ const ENDPOINT = 'git-commit';
16
+ const COMMANDS = new Set(['status', 'changeToken', 'settings', 'saveSettings', 'models', 'commit']);
17
+ const MAX_REQUEST_CHARS = 262_144;
18
+ const HEADERS = { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' };
19
+
20
+ function record(value) {
21
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
22
+ }
23
+
24
+ function response(rpcId, result) {
25
+ return Response.json({ type: 'server-response', rpcId, result }, { headers: HEADERS });
26
+ }
27
+
28
+ function failure(code, error) {
29
+ return { ok: false, error: { code, message: error instanceof Error ? error.message : String(error), details: {} } };
30
+ }
31
+
32
+ /**
33
+ * The DSH Connection bridge has already authenticated the caller and enforces
34
+ * an owner principal for exact Fetch routes. We only validate application input
35
+ * and return the standard Connection RPC response envelope here.
36
+ */
37
+ export function createHandler(service) {
38
+ return async function serve(request) {
39
+ const contentType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
40
+ if (request.method !== 'POST') return new Response('Method not allowed', { status: 405, headers: HEADERS });
41
+ if (contentType !== 'application/json') return new Response('JSON required', { status: 415, headers: HEADERS });
42
+ let input;
43
+ try {
44
+ const text = await request.text();
45
+ if (text.length > MAX_REQUEST_CHARS) return new Response('Request too large', { status: 413, headers: HEADERS });
46
+ input = JSON.parse(text);
47
+ } catch {
48
+ return new Response('Invalid JSON', { status: 400, headers: HEADERS });
49
+ }
50
+ if (!record(input) || input.type !== 'client-request' || input.method !== ENDPOINT
51
+ || typeof input.rpcId !== 'string' || input.rpcId.length === 0 || input.rpcId.length > 256
52
+ || !record(input.payload) || typeof input.payload.method !== 'string') {
53
+ return new Response('Invalid Connection request', { status: 400, headers: HEADERS });
54
+ }
55
+ const { method, payload } = input.payload;
56
+ if (!COMMANDS.has(method)) return response(input.rpcId, failure('NOT_FOUND', 'Unknown method'));
57
+ const args = record(payload) ? payload : {};
58
+ try {
59
+ const value = await service[method](method === 'commit' ? { ...args, signal: request.signal } : args);
60
+ return response(input.rpcId, { ok: true, value });
61
+ } catch (error) {
62
+ return response(input.rpcId, failure('GIT_COMMIT_ERROR', error));
63
+ }
64
+ };
65
+ }
66
+
67
+ export function apply(ctx) {
68
+ const service = new GitCommitService(ctx);
69
+ // Cordis-native, injected by other Host plugins via inject: ['gitCommit'].
70
+ const release = ctx.reflect.provide('gitCommit', service);
71
+ ctx.effect(() => () => { service.close(); return release(); }, 'dsh-git-commit: service cleanup');
72
+ // Connection handles authentication, Origin trust, and DSH Web/Desktop transport.
73
+ // Use the same exact Fetch-route seam as DSH's /api/file and /api/present.
74
+ ctx.effect(() => ctx.connection.fetch.register({
75
+ path: ROUTE,
76
+ methods: ['POST'],
77
+ requestBody: 'buffered',
78
+ fetch: createHandler(service),
79
+ }), 'dsh-git-commit: authenticated API route');
80
+ }
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "@ibobbyts/dsh-git-commit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.1",
4
+ "type": "module",
5
+ "description": "Floating Git stats and AI commits with native DSH LLM, authenticated RPC, settings and shared service",
6
+ "license": "MIT",
7
+ "main": "lib/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./client": "./lib/client.js",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "dsh": {
17
+ "bundle": {
18
+ "patch": "./cordis.patch.yml"
19
+ },
20
+ "client": {
21
+ "platform": "web",
22
+ "inject": [
23
+ "@deepseek-ai/dsh-client-connection",
24
+ "@deepseek-ai/dsh-client-ui-renderer",
25
+ "@deepseek-ai/dsh-client-ui-layout",
26
+ "@deepseek-ai/dsh-client-ui-session",
27
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
28
+ ],
29
+ "immediately": true
30
+ }
31
+ },
32
+ "files": [
33
+ "lib",
34
+ "cordis.patch.yml",
35
+ "README.md",
36
+ "LICENSE",
37
+ "THIRD_PARTY.md"
38
+ ],
39
+ "scripts": {
40
+ "test": "node --test test/*.test.js",
41
+ "check": "node --check lib/index.js && node --check lib/client.js && node --test test/*.test.js",
42
+ "prepublishOnly": "npm run check"
43
+ },
44
+ "dependencies": {
45
+ "@visulima/redact": "5.0.0"
46
+ },
47
+ "peerDependencies": {
48
+ "@deepseek-ai/cordis": "^4.0.1",
49
+ "@deepseek-ai/dsh-llm": ">=0.1.1-rc.2"
50
+ },
51
+ "engines": {
52
+ "node": ">=22.22.1"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/iBobbyTS/dsh-git-commit.git"
57
+ },
58
+ "keywords": [
59
+ "deepseek-harness",
60
+ "dsh",
61
+ "git",
62
+ "commit",
63
+ "ai",
64
+ "plugin"
65
+ ],
66
+ "types": "lib/index.d.ts",
67
+ "homepage": "https://github.com/iBobbyTS/dsh-git-commit#readme",
68
+ "bugs": {
69
+ "url": "https://github.com/iBobbyTS/dsh-git-commit/issues"
70
+ },
71
+ "publishConfig": {
72
+ "access": "public",
73
+ "registry": "https://registry.npmjs.org"
74
+ }
75
+ }